From cb20faeb3da57b0f6053d8c2f3bdb508bef8e37e Mon Sep 17 00:00:00 2001 From: Emily Boudreaux Date: Mon, 9 Nov 2026 13:53:17 -0500 Subject: [PATCH] test(tests): added test suite The test suite is extensive. Note that LLMs were used to aid in the development of the test suite. All LLM generated code was manually reviewed. --- tests/AI_DISCLOSURE.md | 8 + tests/deformation/composition.cpp | 556 ++++ tests/deformation/computations.cpp | 706 ++++ .../deformation/domain_actions_meanfield.cpp | 115 + tests/deformation/equation_integration.cpp | 397 +++ tests/deformation/geometry_step.cpp | 381 +++ tests/deformation/meson.build | 13 + tests/deformation/nonconforming.cpp | 221 ++ tests/deformation/radial_extension.cpp | 475 +++ .../reference_geometry_meanfield.cpp | 210 ++ tests/deformation/surface_embedding.cpp | 369 +++ tests/deformation/surface_geometry.cpp | 174 + tests/deformation/surface_metric.cpp | 295 ++ tests/discritization/domain/.DS_Store | Bin 6148 -> 0 bytes tests/discritization/domain/mesh_topology.cpp | 59 + tests/discritization/domain/schema/.DS_Store | Bin 6148 -> 0 bytes tests/discritization/domain/schema/utils.cpp | 42 - .../domain/schema/validation/all.cpp | 12 +- .../domain/schema/validation/boundary.cpp | 21 +- .../domain/schema/validation/connected.cpp | 11 +- .../domain/schema/validation/inscribed.cpp | 15 +- .../field_support_meanfield.cpp | 279 ++ tests/discritization/meson.build | 21 +- tests/discritization/nonconforming.cpp | 219 ++ tests/discritization/reconstruction.cpp | 77 + tests/eos/barotrope_meanfield.cpp | 135 + tests/eos/equation_of_state_type_system.cpp | 237 ++ tests/eos/meson.build | 7 + tests/eos/polytropic_eos_characterization.cpp | 166 + tests/eos/polytropic_eos_relations.cpp | 196 ++ tests/eos/pressure_potential.cpp | 33 + tests/equations/angular_momentum.cpp | 427 +++ tests/equations/barotropic_density.cpp | 105 + .../equations/barotropic_density_assembly.cpp | 143 + tests/equations/barycenter.cpp | 79 + tests/equations/basis_lifetime_meanfield.cpp | 91 + tests/equations/closure_conditions.cpp | 121 + tests/equations/closure_dilation.cpp | 36 + tests/equations/closure_kernels.cpp | 175 + tests/equations/closure_prepared.cpp | 150 + tests/equations/computation_graphs.cpp | 319 ++ tests/equations/contribution_lifetimes.cpp | 77 + tests/equations/equation_contracts.cpp | 91 + tests/equations/fixture.cpp | 106 + tests/equations/gravity_port_fixture.cpp | 165 + tests/equations/h1_divergence.cpp | 134 + tests/equations/h1_gradient.cpp | 109 + tests/equations/h1_gradient_equation.cpp | 86 + tests/equations/h1_vector_gradient.cpp | 118 + .../equations/h1_vector_gradient_equation.cpp | 196 ++ tests/equations/manual_closure.cpp | 109 + tests/equations/mass_normalization.cpp | 245 ++ tests/equations/meson.build | 31 + tests/equations/pressure_boundary.cpp | 159 + tests/equations/pressure_surface.cpp | 316 ++ .../equations/reference_tables_meanfield.cpp | 92 + tests/equations/scoped_computations.cpp | 198 ++ tests/equations/serial_backend.cpp | 440 +++ tests/equations/state_validation.cpp | 252 ++ tests/equations/surface_potential_test.cpp | 93 + tests/equations/tensor_basis.cpp | 248 ++ .../include/serif/tests/deformation/mesh.hpp | 55 + .../domain/domain_test_utils.hpp | 6 +- .../serif/tests/equations/barotropic_eos.hpp | 41 + .../include/serif/tests/equations/fixture.hpp | 78 + .../serif/tests/equations/gravity_fixture.hpp | 106 + .../serif/tests/equations/gravity_port.hpp | 64 + .../serif/tests/equations/test_equations.hpp | 105 + .../equations/volume_equation_fixture.hpp | 193 ++ .../serif/tests/mapping/volume_variation.hpp | 38 + tests/include/serif/tests/nonconforming.hpp | 20 + tests/include/serif/tests/solver/archive.hpp | 455 +++ .../serif/tests/solver/native_problem.hpp | 17 + .../serif/tests/solver/physical_summary.hpp | 171 + .../serif/tests/solver/stellar_fixture.hpp | 88 + tests/include/serif/tests/solver/virial.hpp | 331 ++ .../serif/tests/solver/virial_output.hpp | 50 + .../serif/tests/surface/boundary_fixture.hpp | 147 + .../include/serif/tests/surface/contexts.hpp | 42 + tests/include/serif/tests/surface/models.hpp | 148 + tests/include/serif/tests/test_tags.hpp | 54 +- tests/mapping/compactification/kelvin.cpp | 773 +++++ tests/mapping/domain_mapper.cpp | 2850 +++++++++++++++++ tests/mapping/meson.build | 5 + tests/mapping/volume_cache.cpp | 171 + tests/meson.build | 55 +- tests/model/automatic_surface.cpp | 285 ++ tests/model/description.cpp | 272 ++ tests/model/equilibrium_graph.cpp | 463 +++ tests/model/finite_elements.cpp | 148 + tests/model/meson.build | 30 + tests/model/move_only.cpp | 175 + tests/model/moving_equilibrium.cpp | 809 +++++ tests/model/normalized_system.cpp | 115 + tests/model/pressure_graph.cpp | 144 + tests/model/reconstruction.cpp | 119 + tests/model/reconstruction_system.cpp | 206 ++ tests/model/runtime.cpp | 385 +++ tests/model/runtime_deformation.cpp | 116 + tests/model/runtime_volume.cpp | 82 + tests/model/scalar_row.cpp | 89 + tests/model/solver_services.cpp | 404 +++ tests/model/state.cpp | 93 + tests/model/surface_refinement.cpp | 232 ++ tests/normalization/computations.cpp | 266 ++ tests/normalization/configuration.cpp | 119 + tests/normalization/diagonal.cpp | 167 + tests/normalization/dimensionless.cpp | 248 ++ tests/normalization/meson.build | 12 + tests/normalization/operators.cpp | 316 ++ tests/normalization/point_rows.cpp | 87 + tests/normalization/scales.cpp | 101 + tests/normalization/surface_metric.cpp | 232 ++ tests/normalization/volume_computations.cpp | 163 + tests/normalization/volume_metrics.cpp | 300 ++ tests/preconditioner/border.cpp | 225 ++ tests/preconditioner/coupling.cpp | 150 + tests/preconditioner/graph.cpp | 197 ++ tests/preconditioner/graph_extensions.cpp | 101 + tests/preconditioner/graph_review.cpp | 219 ++ tests/preconditioner/inverses.cpp | 233 ++ tests/preconditioner/layout.cpp | 102 + tests/preconditioner/material.cpp | 89 + tests/preconditioner/meson.build | 22 + tests/preconditioner/parity.cpp | 58 + tests/preconditioner/pressure_rows.cpp | 189 ++ tests/quadrature/meson.build | 4 + tests/quadrature/policy.cpp | 199 ++ tests/quadrature/policy_meanfield.cpp | 124 + tests/sandbox/complete_sandbox.cpp | 654 ++++ tests/sandbox/deformation_sandbox.cpp | 67 + tests/sandbox/dependencies_sandbox.cpp | 292 ++ tests/sandbox/dimensions_sandbox.cpp | 1 - tests/sandbox/discritization_sandbox.cpp | 61 +- tests/sandbox/eos_sandbox.cpp | 11 - tests/sandbox/equations_sandbox.cpp | 48 + tests/sandbox/geometry_sandbox.cpp | 10 + tests/sandbox/lane-emden_sandbox.cpp | 17 + tests/sandbox/mapping_sandbox.cpp | 42 +- tests/sandbox/meson.build | 61 +- tests/sandbox/meta_sandbox.cpp | 2 +- tests/sandbox/model_sandbox.cpp | 67 + tests/sandbox/normalization_sandbox.cpp | 145 + tests/sandbox/polytropic_model.hpp | 44 + tests/sandbox/preconditioner_sandbox.cpp | 102 + tests/sandbox/quadrature_sandbox.cpp | 15 +- tests/sandbox/rayfem_sandbox.cpp | 25 + tests/sandbox/serialization_sandbox.cpp | 86 + tests/sandbox/solver_sandbox.cpp | 47 + tests/sandbox/stellar_rayfem.hpp | 152 + tests/sandbox/surface_sandbox.cpp | 48 + tests/serialize/archive.cpp | 329 ++ tests/serialize/components.cpp | 280 ++ tests/serialize/meson.build | 24 + tests/serialize/mfem.cpp | 308 ++ tests/serialize/python_reader.py | 53 + tests/serialize/restart.cpp | 134 + tests/serialize/top.cpp | 267 ++ tests/serialize/top_mfem.cpp | 366 +++ tests/serialize/top_reader.py | 78 + tests/serialize/write_fixture.cpp | 27 + tests/serialize/write_top_fixture.cpp | 57 + tests/serif_tests.cpp | 653 ---- tests/solver/archive.cpp | 38 + tests/solver/backend_independent.cpp | 197 ++ tests/solver/continuation_controller.cpp | 294 ++ tests/solver/continuation_driver.cpp | 227 ++ tests/solver/continuation_equilibrium.cpp | 288 ++ tests/solver/continuation_family.cpp | 223 ++ tests/solver/continuation_settings.cpp | 116 + tests/solver/ester_validation.cpp | 292 ++ tests/solver/linear.cpp | 220 ++ tests/solver/linear_overflow.cpp | 99 + tests/solver/meson.build | 32 + tests/solver/nonconforming.cpp | 69 + tests/solver/nonlinear.cpp | 561 ++++ tests/solver/nonlinear_parity.cpp | 195 ++ tests/solver/seed.cpp | 362 +++ tests/solver/strategies.cpp | 173 + tests/solver/virial.cpp | 154 + tests/surface/automatic.cpp | 163 + tests/surface/boundary.cpp | 544 ++++ tests/surface/compilation.cpp | 132 + tests/surface/computations.cpp | 169 + tests/surface/meson.build | 8 + tests/surface/rejection.cpp | 138 + tests/surface/relations.cpp | 124 + tests/test_cuda_toolchain.py | 88 - tests/utils/dependencies/context.cpp | 103 + tests/utils/dependencies/mixed_errors.cpp | 189 ++ tests/utils/dependencies/observers.cpp | 378 +++ tests/utils/dependencies/ownership.cpp | 203 ++ tests/utils/dependencies/preparation.cpp | 260 ++ tests/utils/dependencies/reachability.cpp | 62 + tests/utils/dependencies/rejection.cpp | 193 ++ tests/utils/dependencies/snapshots.cpp | 137 + tests/utils/laneemden/formatting.cpp | 71 + tests/utils/laneemden/scaling.cpp | 152 + tests/utils/laneemden/solution.cpp | 155 + tests/utils/meson.build | 36 + tests/utils/projection/nonconforming.cpp | 103 + tests/utils/projection/radial.cpp | 423 +++ 202 files changed, 35505 insertions(+), 953 deletions(-) create mode 100644 tests/deformation/composition.cpp create mode 100644 tests/deformation/computations.cpp create mode 100644 tests/deformation/domain_actions_meanfield.cpp create mode 100644 tests/deformation/equation_integration.cpp create mode 100644 tests/deformation/geometry_step.cpp create mode 100644 tests/deformation/meson.build create mode 100644 tests/deformation/nonconforming.cpp create mode 100644 tests/deformation/radial_extension.cpp create mode 100644 tests/deformation/reference_geometry_meanfield.cpp create mode 100644 tests/deformation/surface_embedding.cpp create mode 100644 tests/deformation/surface_geometry.cpp create mode 100644 tests/deformation/surface_metric.cpp delete mode 100644 tests/discritization/domain/.DS_Store create mode 100644 tests/discritization/domain/mesh_topology.cpp delete mode 100644 tests/discritization/domain/schema/.DS_Store delete mode 100644 tests/discritization/domain/schema/utils.cpp create mode 100644 tests/discritization/field_support_meanfield.cpp create mode 100644 tests/discritization/nonconforming.cpp create mode 100644 tests/discritization/reconstruction.cpp create mode 100644 tests/eos/barotrope_meanfield.cpp create mode 100644 tests/eos/equation_of_state_type_system.cpp create mode 100644 tests/eos/meson.build create mode 100644 tests/eos/polytropic_eos_characterization.cpp create mode 100644 tests/eos/polytropic_eos_relations.cpp create mode 100644 tests/eos/pressure_potential.cpp create mode 100644 tests/equations/angular_momentum.cpp create mode 100644 tests/equations/barotropic_density.cpp create mode 100644 tests/equations/barotropic_density_assembly.cpp create mode 100644 tests/equations/barycenter.cpp create mode 100644 tests/equations/basis_lifetime_meanfield.cpp create mode 100644 tests/equations/closure_conditions.cpp create mode 100644 tests/equations/closure_dilation.cpp create mode 100644 tests/equations/closure_kernels.cpp create mode 100644 tests/equations/closure_prepared.cpp create mode 100644 tests/equations/computation_graphs.cpp create mode 100644 tests/equations/contribution_lifetimes.cpp create mode 100644 tests/equations/equation_contracts.cpp create mode 100644 tests/equations/fixture.cpp create mode 100644 tests/equations/gravity_port_fixture.cpp create mode 100644 tests/equations/h1_divergence.cpp create mode 100644 tests/equations/h1_gradient.cpp create mode 100644 tests/equations/h1_gradient_equation.cpp create mode 100644 tests/equations/h1_vector_gradient.cpp create mode 100644 tests/equations/h1_vector_gradient_equation.cpp create mode 100644 tests/equations/manual_closure.cpp create mode 100644 tests/equations/mass_normalization.cpp create mode 100644 tests/equations/meson.build create mode 100644 tests/equations/pressure_boundary.cpp create mode 100644 tests/equations/pressure_surface.cpp create mode 100644 tests/equations/reference_tables_meanfield.cpp create mode 100644 tests/equations/scoped_computations.cpp create mode 100644 tests/equations/serial_backend.cpp create mode 100644 tests/equations/state_validation.cpp create mode 100644 tests/equations/surface_potential_test.cpp create mode 100644 tests/equations/tensor_basis.cpp create mode 100644 tests/include/serif/tests/deformation/mesh.hpp create mode 100644 tests/include/serif/tests/equations/barotropic_eos.hpp create mode 100644 tests/include/serif/tests/equations/fixture.hpp create mode 100644 tests/include/serif/tests/equations/gravity_fixture.hpp create mode 100644 tests/include/serif/tests/equations/gravity_port.hpp create mode 100644 tests/include/serif/tests/equations/test_equations.hpp create mode 100644 tests/include/serif/tests/equations/volume_equation_fixture.hpp create mode 100644 tests/include/serif/tests/mapping/volume_variation.hpp create mode 100644 tests/include/serif/tests/nonconforming.hpp create mode 100644 tests/include/serif/tests/solver/archive.hpp create mode 100644 tests/include/serif/tests/solver/native_problem.hpp create mode 100644 tests/include/serif/tests/solver/physical_summary.hpp create mode 100644 tests/include/serif/tests/solver/stellar_fixture.hpp create mode 100644 tests/include/serif/tests/solver/virial.hpp create mode 100644 tests/include/serif/tests/solver/virial_output.hpp create mode 100644 tests/include/serif/tests/surface/boundary_fixture.hpp create mode 100644 tests/include/serif/tests/surface/contexts.hpp create mode 100644 tests/include/serif/tests/surface/models.hpp create mode 100644 tests/mapping/compactification/kelvin.cpp create mode 100644 tests/mapping/domain_mapper.cpp create mode 100644 tests/mapping/meson.build create mode 100644 tests/mapping/volume_cache.cpp create mode 100644 tests/model/automatic_surface.cpp create mode 100644 tests/model/description.cpp create mode 100644 tests/model/equilibrium_graph.cpp create mode 100644 tests/model/finite_elements.cpp create mode 100644 tests/model/meson.build create mode 100644 tests/model/move_only.cpp create mode 100644 tests/model/moving_equilibrium.cpp create mode 100644 tests/model/normalized_system.cpp create mode 100644 tests/model/pressure_graph.cpp create mode 100644 tests/model/reconstruction.cpp create mode 100644 tests/model/reconstruction_system.cpp create mode 100644 tests/model/runtime.cpp create mode 100644 tests/model/runtime_deformation.cpp create mode 100644 tests/model/runtime_volume.cpp create mode 100644 tests/model/scalar_row.cpp create mode 100644 tests/model/solver_services.cpp create mode 100644 tests/model/state.cpp create mode 100644 tests/model/surface_refinement.cpp create mode 100644 tests/normalization/computations.cpp create mode 100644 tests/normalization/configuration.cpp create mode 100644 tests/normalization/diagonal.cpp create mode 100644 tests/normalization/dimensionless.cpp create mode 100644 tests/normalization/meson.build create mode 100644 tests/normalization/operators.cpp create mode 100644 tests/normalization/point_rows.cpp create mode 100644 tests/normalization/scales.cpp create mode 100644 tests/normalization/surface_metric.cpp create mode 100644 tests/normalization/volume_computations.cpp create mode 100644 tests/normalization/volume_metrics.cpp create mode 100644 tests/preconditioner/border.cpp create mode 100644 tests/preconditioner/coupling.cpp create mode 100644 tests/preconditioner/graph.cpp create mode 100644 tests/preconditioner/graph_extensions.cpp create mode 100644 tests/preconditioner/graph_review.cpp create mode 100644 tests/preconditioner/inverses.cpp create mode 100644 tests/preconditioner/layout.cpp create mode 100644 tests/preconditioner/material.cpp create mode 100644 tests/preconditioner/meson.build create mode 100644 tests/preconditioner/parity.cpp create mode 100644 tests/preconditioner/pressure_rows.cpp create mode 100644 tests/quadrature/meson.build create mode 100644 tests/quadrature/policy.cpp create mode 100644 tests/quadrature/policy_meanfield.cpp create mode 100644 tests/sandbox/complete_sandbox.cpp create mode 100644 tests/sandbox/deformation_sandbox.cpp create mode 100644 tests/sandbox/dependencies_sandbox.cpp create mode 100644 tests/sandbox/equations_sandbox.cpp create mode 100644 tests/sandbox/model_sandbox.cpp create mode 100644 tests/sandbox/normalization_sandbox.cpp create mode 100644 tests/sandbox/polytropic_model.hpp create mode 100644 tests/sandbox/preconditioner_sandbox.cpp create mode 100644 tests/sandbox/rayfem_sandbox.cpp create mode 100644 tests/sandbox/serialization_sandbox.cpp create mode 100644 tests/sandbox/stellar_rayfem.hpp create mode 100644 tests/sandbox/surface_sandbox.cpp create mode 100644 tests/serialize/archive.cpp create mode 100644 tests/serialize/components.cpp create mode 100644 tests/serialize/meson.build create mode 100644 tests/serialize/mfem.cpp create mode 100644 tests/serialize/python_reader.py create mode 100644 tests/serialize/restart.cpp create mode 100644 tests/serialize/top.cpp create mode 100644 tests/serialize/top_mfem.cpp create mode 100644 tests/serialize/top_reader.py create mode 100644 tests/serialize/write_fixture.cpp create mode 100644 tests/serialize/write_top_fixture.cpp delete mode 100644 tests/serif_tests.cpp create mode 100644 tests/solver/archive.cpp create mode 100644 tests/solver/backend_independent.cpp create mode 100644 tests/solver/continuation_controller.cpp create mode 100644 tests/solver/continuation_driver.cpp create mode 100644 tests/solver/continuation_equilibrium.cpp create mode 100644 tests/solver/continuation_family.cpp create mode 100644 tests/solver/continuation_settings.cpp create mode 100644 tests/solver/ester_validation.cpp create mode 100644 tests/solver/linear.cpp create mode 100644 tests/solver/linear_overflow.cpp create mode 100644 tests/solver/meson.build create mode 100644 tests/solver/nonconforming.cpp create mode 100644 tests/solver/nonlinear.cpp create mode 100644 tests/solver/nonlinear_parity.cpp create mode 100644 tests/solver/seed.cpp create mode 100644 tests/solver/strategies.cpp create mode 100644 tests/solver/virial.cpp create mode 100644 tests/surface/automatic.cpp create mode 100644 tests/surface/boundary.cpp create mode 100644 tests/surface/compilation.cpp create mode 100644 tests/surface/computations.cpp create mode 100644 tests/surface/meson.build create mode 100644 tests/surface/rejection.cpp create mode 100644 tests/surface/relations.cpp delete mode 100644 tests/test_cuda_toolchain.py create mode 100644 tests/utils/dependencies/context.cpp create mode 100644 tests/utils/dependencies/mixed_errors.cpp create mode 100644 tests/utils/dependencies/observers.cpp create mode 100644 tests/utils/dependencies/ownership.cpp create mode 100644 tests/utils/dependencies/preparation.cpp create mode 100644 tests/utils/dependencies/reachability.cpp create mode 100644 tests/utils/dependencies/rejection.cpp create mode 100644 tests/utils/dependencies/snapshots.cpp create mode 100644 tests/utils/laneemden/formatting.cpp create mode 100644 tests/utils/laneemden/scaling.cpp create mode 100644 tests/utils/laneemden/solution.cpp create mode 100644 tests/utils/meson.build create mode 100644 tests/utils/projection/nonconforming.cpp create mode 100644 tests/utils/projection/radial.cpp diff --git a/tests/AI_DISCLOSURE.md b/tests/AI_DISCLOSURE.md index e69de29b..ee41c824 100644 --- a/tests/AI_DISCLOSURE.md +++ b/tests/AI_DISCLOSURE.md @@ -0,0 +1,8 @@ +# GENERATIVE AI DISCLOSURE +- Generative AI has been used to develop the test suite of this code base. Specifically ChatGPT 6 astra max. +- There are two primary ways this was used + - Porting tests from a previous version of this code base to the current version. Specific instructions were given and code was manually reviewed to ensure correctness. This porting was required as significant parts of the interface have changed; however, the goal is to maintain the same numeric behavior. + - Developing new tests based on preexisting tests (e.g a test ensuring volume is consistent under an affine transformation was written and then we asked GenAI to write a number of other tests with a number of different affine transformations) +- The backend consolidation added AI-assisted regressions comparing element coordinate extraction and assembly against native MFEM operations, and exercising the shared Newton algorithm with standard-library vectors without an MFEM dependency. + +- The TOP-export regressions were developed with AI assistance: analytic polytrope and manufactured geometry checks, physical-unit invariance, deformed/nonconforming FE sampling, axis handling, state preservation, publication failures, and an optional check with the actual TOP reader. diff --git a/tests/deformation/composition.cpp b/tests/deformation/composition.cpp new file mode 100644 index 00000000..0d4ddff9 --- /dev/null +++ b/tests/deformation/composition.cpp @@ -0,0 +1,556 @@ +// The nonlinear surface and bilinear extension retain MeanField's independent chain-rule fixture. + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include "serif/geometry/deformation/composition.hpp" +#include "serif/geometry/deformation/linearization.hpp" + +namespace +{ + namespace deformation = serif::geometry::deformation; + namespace spaces = deformation::spaces; + using Catch::Matchers::WithinAbs; + + /** @brief Reject fixture misuse as an error value so failure recovery exercises the production composition. */ + [[nodiscard]] deformation::DeformationStatus validate_output(const std::span output, const std::size_t expected_size) + { + if (output.size() != expected_size) + { + return std::unexpected(deformation::DeformationFailure{.reason = deformation::DeformationFailureReason::invalid_size, .message = "The analytic deformation fixture received an incompatible output."}); + } + return {}; + } + + /** @brief Provide independently known quadratic maps with two inputs and a variable number of outputs. */ + template + struct QuadraticMap + { + using InputSpace = Input; + using OutputSpace = Output; + static constexpr bool is_linear = false; + + struct Workspace + { + std::size_t actions{0}; + }; + + // Each row defines a*x + b*y + c*x*x + d*x*y + e*y*y. + std::array, OutputSize> coefficients; + + /** @brief Keep the analytic parameter space small enough to inspect by hand. */ + [[nodiscard]] std::size_t input_size() const noexcept + { + return 2; + } + + /** @brief Allow the extension to have more outputs than surface coordinates. */ + [[nodiscard]] std::size_t output_size() const noexcept + { + return OutputSize; + } + + /** @brief Make caller-owned action state visible in recovery tests. */ + [[nodiscard]] Workspace make_workspace() const + { + return {}; + } + + /** @brief Evaluate the independent quadratic definition, including both pure and mixed terms. */ + [[nodiscard]] deformation::DeformationStatus build_displacement(const std::span state, const std::span output, Workspace& workspace) const + { + if (const auto status = deformation::validate_deformation_values(state, input_size()); !status) + { + return status; + } + if (const auto status = validate_output(output, output_size()); !status) + { + return status; + } + ++workspace.actions; + for (std::size_t row = 0; row < OutputSize; ++row) + { + const std::array& values = coefficients[row]; + output[row] = values[0] * state[0] + values[1] * state[1] + values[2] * state[0] * state[0] + values[3] * state[0] * state[1] + values[4] * state[1] * state[1]; + } + return deformation::validate_deformation_values(output, output_size()); + } + + /** @brief Apply the analytic gradient of each quadratic row. */ + [[nodiscard]] deformation::DeformationStatus apply_jacobian(const std::span state, const std::span direction, const std::span output, Workspace& workspace) const + { + if (const auto status = deformation::validate_deformation_values(state, input_size()); !status) + { + return status; + } + if (const auto status = deformation::validate_deformation_values(direction, input_size()); !status) + { + return status; + } + if (const auto status = validate_output(output, output_size()); !status) + { + return status; + } + ++workspace.actions; + for (std::size_t row = 0; row < OutputSize; ++row) + { + const std::array row_gradient = gradient(row, state); + output[row] = row_gradient[0] * direction[0] + row_gradient[1] * direction[1]; + } + return deformation::validate_deformation_values(output, output_size()); + } + + /** @brief Reverse the exact same mathematical derivative without constructing a matrix. */ + [[nodiscard]] deformation::DeformationStatus apply_jacobian_transpose(const std::span state, const std::span dual, const std::span output, Workspace& workspace) const + { + if (const auto status = deformation::validate_deformation_values(state, input_size()); !status) + { + return status; + } + if (const auto status = deformation::validate_deformation_values(dual, output_size()); !status) + { + return status; + } + if (const auto status = validate_output(output, input_size()); !status) + { + return status; + } + ++workspace.actions; + std::ranges::fill(output, 0.0); + for (std::size_t row = 0; row < OutputSize; ++row) + { + const std::array row_gradient = gradient(row, state); + output[0] += row_gradient[0] * dual[row]; + output[1] += row_gradient[1] * dual[row]; + } + return deformation::validate_deformation_values(output, input_size()); + } + + /** @brief Contract the constant quadratic Hessians with direction and fixed output dual. */ + [[nodiscard]] deformation::DeformationStatus apply_pullback_derivative(const std::span state, const std::span direction, const std::span dual, const std::span output, Workspace& workspace) const + { + if (const auto status = deformation::validate_deformation_values(state, input_size()); !status) + { + return status; + } + if (const auto status = deformation::validate_deformation_values(direction, input_size()); !status) + { + return status; + } + if (const auto status = deformation::validate_deformation_values(dual, output_size()); !status) + { + return status; + } + if (const auto status = validate_output(output, input_size()); !status) + { + return status; + } + ++workspace.actions; + std::ranges::fill(output, 0.0); + for (std::size_t row = 0; row < OutputSize; ++row) + { + const std::array& values = coefficients[row]; + output[0] += (2.0 * values[2] * direction[0] + values[3] * direction[1]) * dual[row]; + output[1] += (values[3] * direction[0] + 2.0 * values[4] * direction[1]) * dual[row]; + } + return deformation::validate_deformation_values(output, input_size()); + } + + /** @brief Keep the fixture's analytic row derivative in one easily checked expression. */ + [[nodiscard]] std::array gradient(const std::size_t row, const std::span state) const + { + const std::array& values = coefficients[row]; + return {values[0] + 2.0 * values[2] * state[0] + values[3] * state[1], values[1] + values[3] * state[0] + 2.0 * values[4] * state[1]}; + } + }; + + using NonlinearSurface = QuadraticMap; + using NonlinearExtension = QuadraticMap; + + /** @brief Retain MeanField's surface definition (x*x + y, x*y). */ + [[nodiscard]] std::shared_ptr nonlinear_surface() + { + return std::make_shared(NonlinearSurface{.coefficients = {{{0.0, 1.0, 1.0, 0.0, 0.0}, {0.0, 0.0, 0.0, 1.0, 0.0}}}}); + } + + /** @brief Retain the bilinear extension with distinct row coefficients from MeanField's nonlinear fixture. */ + [[nodiscard]] std::shared_ptr nonlinear_extension() + { + return std::make_shared(NonlinearExtension{.coefficients = {{{0.01, -0.02, 0.0, 0.0005, 0.0}, {0.011, -0.018, 0.0, 0.001, 0.0}, {0.012, -0.016, 0.0, 0.0015, 0.0}}}}); + } + + /** @brief Supply the minimal linear-map contract with an independently known nonsymmetric matrix. */ + template + struct LinearMap + { + using InputSpace = Input; + using OutputSpace = Output; + using Workspace = deformation::EmptyDeformationWorkspace; + static constexpr bool is_linear = true; + std::size_t input_count{2}; + + /** @brief Permit an intentionally incompatible count for factory validation. */ + [[nodiscard]] std::size_t input_size() const noexcept + { + return input_count; + } + + /** @brief Fix two rows for the explicit matrix used in the test. */ + [[nodiscard]] std::size_t output_size() const noexcept + { + return 2; + } + + /** @brief Apply [[2, 1], [-1, 3]], with no derivative-specific implementation. */ + [[nodiscard]] deformation::DeformationStatus apply(const std::span input, const std::span output, Workspace = {}) const + { + if (const auto status = deformation::validate_deformation_values(input, input_size()); !status) + { + return status; + } + if (const auto status = validate_output(output, output_size()); !status) + { + return status; + } + output[0] = 2.0 * input[0] + input[1]; + output[1] = -input[0] + 3.0 * input[1]; + return deformation::validate_deformation_values(output, output_size()); + } + + /** @brief Use an analytic transpose to test the generic linear derivative branch. */ + [[nodiscard]] deformation::DeformationStatus apply_transpose(const std::span input, const std::span output, Workspace = {}) const + { + if (const auto status = deformation::validate_deformation_values(input, output_size()); !status) + { + return status; + } + if (const auto status = validate_output(output, input_size()); !status) + { + return status; + } + output[0] = 2.0 * input[0] - input[1]; + output[1] = input[0] + 3.0 * input[1]; + return deformation::validate_deformation_values(output, input_size()); + } + }; + + using LinearSurface = LinearMap; + using LinearExtension = LinearMap; + + /** @brief Build centered differences without borrowing any production derivative calculation. */ + [[nodiscard]] std::array perturb(const std::array& state, const std::array& direction, const double step) + { + return {state[0] + step * direction[0], state[1] + step * direction[1]}; + } + + /** @brief Compare work without depending on a backend vector representation. */ + [[nodiscard]] double inner_product(const std::span first, const std::span second) + { + REQUIRE(first.size() == second.size()); + return std::inner_product(first.begin(), first.end(), second.begin(), 0.0); + } + + /** @brief Give a nonlinear physical residual whose derivative contains cross-component couplings. */ + [[nodiscard]] std::array volume_force(const std::span displacement) + { + return {1.0 + displacement[0] * displacement[0] + 0.5 * displacement[1], -0.2 + displacement[1] * displacement[1] - 0.3 * displacement[2], 0.7 + displacement[2] * displacement[2] + 0.1 * displacement[0]}; + } + + /** @brief Verify the surviving curvature term when either factor is linear. */ + template + void check_mixed_pullback(const Map& map) + { + auto workspace = deformation::make_deformation_workspace(map); + const std::array state{0.37, -0.21}; + const std::array direction{-0.42, 0.63}; + constexpr double step = 2.0e-6; + const std::array plus = perturb(state, direction, step); + const std::array minus = perturb(state, direction, -step); + const std::vector dual(map.output_size(), 0.7); + std::array derivative{}; + std::array plus_transpose{}; + std::array minus_transpose{}; + REQUIRE(deformation::apply_pullback_derivative(map, state, direction, dual, derivative, workspace)); + REQUIRE(deformation::apply_jacobian_transpose(map, plus, dual, plus_transpose, workspace)); + REQUIRE(deformation::apply_jacobian_transpose(map, minus, dual, minus_transpose, workspace)); + for (std::size_t parameter = 0; parameter < derivative.size(); ++parameter) + { + CHECK_THAT(derivative[parameter], WithinAbs((plus_transpose[parameter] - minus_transpose[parameter]) / (2.0 * step), 2.0e-10)); + } + CHECK(std::abs(derivative[0]) + std::abs(derivative[1]) > 1.0e-5); + } +} // namespace + +TEST_CASE("Nonlinear domain deformation preserves the complete chain rule and virtual work", "[unit][deformation][composition]") +{ + const auto composed = deformation::compose_deformation(nonlinear_surface(), nonlinear_extension()); + REQUIRE(composed); + auto workspace = deformation::make_deformation_workspace(*composed); + const std::array state{0.37, -0.21}; + const std::array direction{-0.42, 0.63}; + const std::array dual{0.9, -0.35, 0.24}; + constexpr double step = 2.0e-6; + const std::array plus = perturb(state, direction, step); + const std::array minus = perturb(state, direction, -step); + const auto plus_displacement = deformation::evaluate_displacement(*composed, plus); + const auto minus_displacement = deformation::evaluate_displacement(*composed, minus); + REQUIRE(plus_displacement); + REQUIRE(minus_displacement); + + std::array tangent{}; + std::array transpose{}; + std::array pullback{}; + std::array plus_transpose{}; + std::array minus_transpose{}; + REQUIRE(deformation::apply_jacobian(*composed, state, direction, tangent, workspace)); + REQUIRE(deformation::apply_jacobian_transpose(*composed, state, dual, transpose, workspace)); + REQUIRE(deformation::apply_pullback_derivative(*composed, state, direction, dual, pullback, workspace)); + REQUIRE(deformation::apply_jacobian_transpose(*composed, plus, dual, plus_transpose, workspace)); + REQUIRE(deformation::apply_jacobian_transpose(*composed, minus, dual, minus_transpose, workspace)); + + for (std::size_t row = 0; row < tangent.size(); ++row) + { + CHECK_THAT(tangent[row], WithinAbs(((*plus_displacement)[row] - (*minus_displacement)[row]) / (2.0 * step), 2.0e-12)); + } + CHECK_THAT(inner_product(tangent, dual), WithinAbs(inner_product(direction, transpose), 2.0e-15)); + for (std::size_t parameter = 0; parameter < pullback.size(); ++parameter) + { + CHECK_THAT(pullback[parameter], WithinAbs((plus_transpose[parameter] - minus_transpose[parameter]) / (2.0 * step), 3.0e-12)); + } + CHECK(std::abs(pullback[0]) > 1.0e-3); + CHECK(std::abs(pullback[1]) > 1.0e-3); +} + +TEST_CASE("Nonlinear shape force derivative includes geometric and physical residual contributions", "[unit][deformation][composition]") +{ + const auto composed = deformation::compose_deformation(nonlinear_surface(), nonlinear_extension()); + REQUIRE(composed); + auto workspace = deformation::make_deformation_workspace(*composed); + const std::array state{0.37, -0.21}; + const std::array direction{-0.42, 0.63}; + const auto displacement = deformation::evaluate_displacement(*composed, state); + REQUIRE(displacement); + const std::array force = volume_force(*displacement); + std::array displacement_direction{}; + REQUIRE(deformation::apply_jacobian(*composed, state, direction, displacement_direction, workspace)); + const std::array force_direction{ + 2.0 * (*displacement)[0] * displacement_direction[0] + 0.5 * displacement_direction[1], + 2.0 * (*displacement)[1] * displacement_direction[1] - 0.3 * displacement_direction[2], + 2.0 * (*displacement)[2] * displacement_direction[2] + 0.1 * displacement_direction[0]}; + std::array geometric_contribution{}; + std::array physical_contribution{}; + REQUIRE(deformation::apply_pullback_derivative(*composed, state, direction, force, geometric_contribution, workspace)); + REQUIRE(deformation::apply_jacobian_transpose(*composed, state, force_direction, physical_contribution, workspace)); + + constexpr double step = 2.0e-6; + const std::array plus = perturb(state, direction, step); + const std::array minus = perturb(state, direction, -step); + const auto plus_displacement = deformation::evaluate_displacement(*composed, plus); + const auto minus_displacement = deformation::evaluate_displacement(*composed, minus); + REQUIRE(plus_displacement); + REQUIRE(minus_displacement); + std::array plus_force{}; + std::array minus_force{}; + REQUIRE(deformation::apply_jacobian_transpose(*composed, plus, volume_force(*plus_displacement), plus_force, workspace)); + REQUIRE(deformation::apply_jacobian_transpose(*composed, minus, volume_force(*minus_displacement), minus_force, workspace)); + for (std::size_t parameter = 0; parameter < plus_force.size(); ++parameter) + { + const double centered_difference = (plus_force[parameter] - minus_force[parameter]) / (2.0 * step); + CHECK_THAT(geometric_contribution[parameter] + physical_contribution[parameter], WithinAbs(centered_difference, 3.0e-12)); + CHECK(std::abs(geometric_contribution[parameter]) > 1.0e-3); + CHECK(std::abs(physical_contribution[parameter]) > 1.0e-7); + } +} + +TEST_CASE("Linear composition needs only forward and transpose actions and recovers after rejected scratch", "[unit][deformation][composition]") +{ + const auto composed = deformation::compose_deformation(std::make_shared(), std::make_shared()); + REQUIRE(composed); + auto workspace = deformation::make_deformation_workspace(*composed); + const std::array state{0.37, -0.21}; + const std::array direction{-0.42, 0.63}; + const std::array dual{0.9, -0.35}; + std::array output{}; + REQUIRE(deformation::build_displacement(*composed, state, output, workspace)); + CHECK_THAT(output[0], WithinAbs(3.0 * state[0] + 5.0 * state[1], 1.0e-15)); + CHECK_THAT(output[1], WithinAbs(-5.0 * state[0] + 8.0 * state[1], 1.0e-15)); + + REQUIRE(deformation::apply_pullback_derivative(*composed, state, direction, dual, output, workspace)); + CHECK(output == std::array{0.0, 0.0}); + const std::array enormous{std::numeric_limits::max(), std::numeric_limits::max()}; + CHECK_FALSE(deformation::build_displacement(*composed, enormous, output, workspace)); + REQUIRE(deformation::apply_jacobian_transpose(*composed, state, dual, output, workspace)); + CHECK_THAT(output[0], WithinAbs(3.0 * dual[0] - 5.0 * dual[1], 1.0e-15)); + CHECK_THAT(output[1], WithinAbs(5.0 * dual[0] + 8.0 * dual[1], 1.0e-15)); +} + +TEST_CASE("Mixed linear and nonlinear deformation factors retain their curvature", "[unit][deformation][composition]") +{ + const auto nonlinear_surface_only = deformation::compose_deformation(nonlinear_surface(), std::make_shared()); + const auto nonlinear_extension_only = deformation::compose_deformation(std::make_shared(), nonlinear_extension()); + REQUIRE(nonlinear_surface_only); + REQUIRE(nonlinear_extension_only); + check_mixed_pullback(*nonlinear_surface_only); + check_mixed_pullback(*nonlinear_extension_only); +} + +TEST_CASE("Composition rejects incompatible factors and invalid actions without preventing workspace reuse", "[unit][deformation][composition]") +{ + const auto surface = nonlinear_surface(); + CHECK_FALSE(deformation::compose_deformation(surface, std::make_shared(LinearExtension{.input_count = 3}))); + CHECK_FALSE(deformation::compose_deformation(std::shared_ptr{}, nonlinear_extension())); + CHECK_FALSE(deformation::compose_deformation(surface, std::shared_ptr{})); + + const auto composed = deformation::compose_deformation(surface, nonlinear_extension()); + REQUIRE(composed); + auto workspace = deformation::make_deformation_workspace(*composed); + const std::array state{0.37, -0.21}; + const std::array direction{-0.42, 0.63}; + const std::array dual{0.9, -0.35, 0.24}; + const std::array invalid{std::numeric_limits::quiet_NaN(), 0.0}; + std::array volume{}; + std::array output{}; + std::array wrong{}; + CHECK_FALSE(deformation::build_displacement(*composed, wrong, volume, workspace)); + CHECK_FALSE(deformation::apply_jacobian(*composed, state, wrong, volume, workspace)); + CHECK_FALSE(deformation::apply_jacobian_transpose(*composed, state, wrong, output, workspace)); + CHECK_FALSE(deformation::apply_pullback_derivative(*composed, state, direction, dual, wrong, workspace)); + CHECK_FALSE(deformation::apply_pullback_derivative(*composed, invalid, direction, dual, output, workspace)); + REQUIRE(deformation::build_displacement(*composed, state, volume, workspace)); + REQUIRE(deformation::apply_pullback_derivative(*composed, state, direction, dual, output, workspace)); + CHECK(workspace.surface_workspace.actions > 0); + CHECK(workspace.extension_workspace.actions > 0); +} + +TEST_CASE("A deformation linearization retains its accepted shape independently of subsequent trials", "[unit][deformation][composition]") +{ + const auto surface = nonlinear_surface(); + std::vector trial{0.37, -0.21}; + const deformation::DeformationLinearization accepted{.map = surface, .parameters = trial}; + auto workspace = deformation::make_deformation_workspace(*surface); + const std::array direction{-0.42, 0.63}; + const std::array dual{0.9, -0.35}; + std::array tangent{}; + std::array transpose{}; + std::array pullback{}; + trial = {8.0, 9.0}; + REQUIRE(deformation::apply_jacobian(accepted, direction, tangent, workspace)); + REQUIRE(deformation::apply_jacobian_transpose(accepted, dual, transpose, workspace)); + REQUIRE(deformation::apply_pullback_derivative(accepted, direction, dual, pullback, workspace)); + CHECK_THAT(tangent[0], WithinAbs(2.0 * 0.37 * direction[0] + direction[1], 1.0e-15)); + CHECK_THAT(tangent[1], WithinAbs(-0.21 * direction[0] + 0.37 * direction[1], 1.0e-15)); + CHECK_THAT(transpose[0], WithinAbs(2.0 * 0.37 * dual[0] - 0.21 * dual[1], 1.0e-15)); + CHECK_THAT(transpose[1], WithinAbs(dual[0] + 0.37 * dual[1], 1.0e-15)); + CHECK_THAT(pullback[0], WithinAbs(2.0 * direction[0] * dual[0] + direction[1] * dual[1], 1.0e-15)); + CHECK_THAT(pullback[1], WithinAbs(direction[0] * dual[1], 1.0e-15)); +} + +TEST_CASE("Nonlinear composed actions reject overlapping state direction and dual before changing any data", "[unit][deformation][composition]") +{ + const auto composed = deformation::compose_deformation(nonlinear_surface(), nonlinear_extension()); + REQUIRE(composed); + auto workspace = deformation::make_deformation_workspace(*composed); + const std::array state{0.37, -0.21}; + const std::array direction{-0.42, 0.63}; + const std::array dual{0.9, -0.35, 0.24}; + + // Exercise direct map methods as well as the generic interface, with exact and partial overlap. + for (const bool direct : {false, true}) + { + for (int action = 0; action < 4; ++action) + { + for (int aliased_input = 0; aliased_input < 3; ++aliased_input) + { + if ((aliased_input == 1 && action != 1 && action != 3) || (aliased_input == 2 && action < 2)) + { + continue; + } + for (const int offset : {0, 1}) + { + CAPTURE(direct, action, aliased_input, offset); + std::array storage{0.37, -0.21, 0.24, 5.0, 7.0, 9.0}; + const std::array original = storage; + std::span input_state = state; + std::span input_direction = direction; + std::span input_dual = dual; + if (aliased_input == 0) + { + input_state = std::span(storage).first(2); + } + else if (aliased_input == 1) + { + input_direction = std::span(storage).first(2); + } + else + { + input_dual = std::span(storage).first(3); + } + const std::size_t output_size = action < 2 ? 3 : 2; + const std::span output = std::span(storage).subspan(offset, output_size); + deformation::DeformationStatus status; + if (action == 0) + { + status = direct ? composed->build_displacement(input_state, output, workspace) : deformation::build_displacement(*composed, input_state, output, workspace); + } + else if (action == 1) + { + status = direct ? composed->apply_jacobian(input_state, input_direction, output, workspace) : deformation::apply_jacobian(*composed, input_state, input_direction, output, workspace); + } + else if (action == 2) + { + status = direct ? composed->apply_jacobian_transpose(input_state, input_dual, output, workspace) : deformation::apply_jacobian_transpose(*composed, input_state, input_dual, output, workspace); + } + else + { + status = direct ? composed->apply_pullback_derivative(input_state, input_direction, input_dual, output, workspace) : deformation::apply_pullback_derivative(*composed, input_state, input_direction, input_dual, output, workspace); + } + REQUIRE_FALSE(status); + CHECK(status.error().reason == deformation::DeformationFailureReason::invalid_configuration); + CHECK(storage == original); + CHECK(workspace.surface_workspace.actions == 0); + CHECK(workspace.extension_workspace.actions == 0); + } + } + } + } + std::array recovered{}, reference{}; + auto independent_workspace = composed->make_workspace(); + REQUIRE(deformation::apply_pullback_derivative(*composed, state, direction, dual, recovered, workspace)); + REQUIRE(composed->apply_pullback_derivative(state, direction, dual, reference, independent_workspace)); + CHECK(recovered == reference); +} + +TEST_CASE("Generic actions protect custom nonlinear maps and linear zero derivatives from aliases", "[unit][deformation][composition]") +{ + const auto nonlinear = nonlinear_surface(); + auto nonlinear_workspace = deformation::make_deformation_workspace(*nonlinear); + const std::array state{0.37, -0.21}; + const std::array direction{-0.42, 0.63}; + std::array storage{0.9, -0.35}; + const std::array original = storage; + CHECK_FALSE(deformation::build_displacement(*nonlinear, storage, storage, nonlinear_workspace)); + CHECK_FALSE(deformation::apply_jacobian(*nonlinear, state, storage, storage, nonlinear_workspace)); + CHECK_FALSE(deformation::apply_jacobian_transpose(*nonlinear, state, storage, storage, nonlinear_workspace)); + CHECK_FALSE(deformation::apply_pullback_derivative(*nonlinear, state, direction, storage, storage, nonlinear_workspace)); + CHECK(storage == original); + CHECK(nonlinear_workspace.actions == 0); + + const LinearSurface linear; + auto linear_workspace = deformation::make_deformation_workspace(linear); + CHECK_FALSE(deformation::apply_pullback_derivative(linear, state, direction, storage, storage, linear_workspace)); + CHECK(storage == original); + + // Adjacent slices in one allocation are disjoint and remain a valid allocation-free calling pattern. + std::array adjacent{0.37, -0.21, 0.0, 0.0}; + REQUIRE(deformation::build_displacement(*nonlinear, std::span(adjacent).first(2), std::span(adjacent).last(2), nonlinear_workspace)); + CHECK_THAT(adjacent[2], WithinAbs(state[0] * state[0] + state[1], 1.0e-15)); + CHECK_THAT(adjacent[3], WithinAbs(state[0] * state[1], 1.0e-15)); +} diff --git a/tests/deformation/computations.cpp b/tests/deformation/computations.cpp new file mode 100644 index 00000000..122368c2 --- /dev/null +++ b/tests/deformation/computations.cpp @@ -0,0 +1,706 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "mfem/fem/coefficient.hpp" + +#include "serif/backend/mfem/geometry/deformation/computations.hpp" +#include "serif/tests/deformation/mesh.hpp" +#include "serif/utils/dependencies/computation_cache.hpp" + +namespace +{ + namespace deformation = serif::geometry::deformation; + namespace backend = deformation::backend::mfem; + namespace dependencies = serif::utils::dependencies; + namespace keys = deformation::keys; + using Status = dependencies::PreparationStatus; + using Model = deformation::DomainDeformation; + + /** @brief Keep borrowed meshes and shape coefficients alive through every graph preparation. */ + struct GraphFixture + { + serif::tests::deformation::PairedMesh mesh; + backend::ReferenceDiscretization discretization; + Model model{.surface = {.maximum_degree = 2}, .stellar_interior = {}, .vacuum = {}}; + std::vector coefficients = std::vector(9, 0.01); + + /// Resolve domain attributes through the same schema used by the stellar mesh. + GraphFixture() : discretization(backend::deformation_discretization(mesh.scalar_space, mesh.vector_space, *mesh.generated.reference_mesh)) + { + } + + /// Reconstructed context wrappers must not invalidate unchanged physical values. + [[nodiscard]] auto inputs() const + { + return backend::make_deformation_inputs(model, discretization, coefficients); + } + }; + + /** @brief Construct the dependency cache without performing numerical reference compilation. */ + [[nodiscard]] auto make_cache(const GraphFixture& fixture) + { + return dependencies::make_computation_cache(backend::make_deformation_graph(), fixture.inputs()); + } + + /** @brief A one-coordinate nonlinear surface distinguishes equal displacements from unequal tangents. */ + struct SquaredSurface + { + using InputSpace = deformation::spaces::ShapeParameters; + using OutputSpace = deformation::spaces::SurfaceDisplacement; + + struct Workspace + { + }; + + static constexpr bool is_linear = false; + + /// A single shape coordinate suffices to expose the derivative-sign regression. + [[nodiscard]] std::size_t input_size() const + { + return 1; + } + + /// This algebraic fixture has one trace component. + [[nodiscard]] std::size_t output_size() const + { + return 1; + } + + /// The polynomial needs no temporary numerical storage. + [[nodiscard]] Workspace make_workspace() const + { + return {}; + } + + /// Opposite coefficients deliberately produce identical displacement values. + [[nodiscard]] deformation::DeformationStatus build_displacement(std::span state, std::span output, Workspace&) const + { + if (const auto status = deformation::validate_deformation_values(state, 1); !status) + { + return status; + } + output[0] = state[0] * state[0]; + return {}; + } + + /// Retain the base-state sign in the tangent even when the displacement is unchanged. + [[nodiscard]] deformation::DeformationStatus apply_jacobian(std::span state, std::span direction, std::span output, Workspace&) const + { + output[0] = 2.0 * state[0] * direction[0]; + return {}; + } + + /// A scalar transpose preserves the same work-conjugate derivative. + [[nodiscard]] deformation::DeformationStatus apply_jacobian_transpose(std::span state, std::span dual, std::span output, Workspace&) const + { + output[0] = 2.0 * state[0] * dual[0]; + return {}; + } + + /// Nonlinear shape coordinates contribute the Hessian term to a Newton force action. + [[nodiscard]] deformation::DeformationStatus apply_pullback_derivative(std::span, std::span direction, std::span dual, std::span output, Workspace&) const + { + output[0] = 2.0 * direction[0] * dual[0]; + return {}; + } + }; + + /** @brief Preserve the nonlinear surface value through a simple exact linear extension. */ + struct UnitExtension + { + using InputSpace = deformation::spaces::SurfaceDisplacement; + using OutputSpace = deformation::spaces::VolumeDisplacement; + using Workspace = deformation::EmptyDeformationWorkspace; + static constexpr bool is_linear = true; + + /// This extension accepts the fixture's single trace component. + [[nodiscard]] std::size_t input_size() const + { + return 1; + } + + /// A single volume value makes the expected derivative independent of a mesh. + [[nodiscard]] std::size_t output_size() const + { + return 1; + } + + /// Identity extension preserves the physical value exactly. + [[nodiscard]] deformation::DeformationStatus apply(std::span input, std::span output, Workspace = {}) const + { + output[0] = input[0]; + return {}; + } + + /// Identity force transfer preserves virtual work exactly. + [[nodiscard]] deformation::DeformationStatus apply_transpose(std::span input, std::span output, Workspace = {}) const + { + return apply(input, output); + } + }; +} // namespace + +TEST_CASE("Deformation graph construction is lazy and equal physical inputs reuse reference work", "[integration][deformation]") +{ + GraphFixture fixture; + auto cache = make_cache(fixture); + CHECK_FALSE(cache.ready()); + CHECK(cache.preparation_count() == 0); + REQUIRE(cache.try_prepare(fixture.inputs())); + const auto surface = cache.get(); + const auto interpolation = cache.get(); + const auto map = cache.get(); + const std::vector original = cache.get(); + const std::vector copied_coefficients = fixture.coefficients; + const auto equal_inputs = backend::make_deformation_inputs(fixture.model, fixture.discretization, copied_coefficients); + const auto reused = cache.try_prepare(equal_inputs); + REQUIRE(reused); + CHECK(reused->computed_count() == 0); + CHECK(reused->status() == Status::reused); + CHECK(reused->status() == Status::reused); + CHECK(reused->status() == Status::reused); + CHECK(reused->status() == Status::reused); + CHECK(cache.get() == surface); + CHECK(cache.get() == interpolation); + CHECK(cache.get() == map); + CHECK(cache.get() == original); +} + +TEST_CASE("Changing shape coefficients recomputes only deformation values and their linearization", "[integration][deformation]") +{ + GraphFixture fixture; + auto cache = make_cache(fixture); + REQUIRE(cache.try_prepare(fixture.inputs())); + const auto reference = cache.get(); + const auto interpolation = cache.get(); + const auto map = cache.get(); + const std::vector original = cache.get(); + fixture.coefficients[0] += 0.03; + const auto report = cache.try_prepare(fixture.inputs()); + REQUIRE(report); + CHECK(report->status() == Status::reused); + CHECK(report->status() == Status::reused); + CHECK(report->status() == Status::reused); + CHECK(report->status() == Status::reused); + CHECK(report->status() == Status::reused); + CHECK(report->status() == Status::computed); + CHECK(report->status() == Status::computed); + CHECK(report->status() == Status::computed); + CHECK(cache.get() == reference); + CHECK(cache.get() == interpolation); + CHECK(cache.get() == map); + CHECK(cache.get() != original); +} + +TEST_CASE("Changing the stellar radial power retains interpolation and the surface representation", "[integration][deformation]") +{ + GraphFixture fixture; + auto cache = make_cache(fixture); + REQUIRE(cache.try_prepare(fixture.inputs())); + const auto interpolation = cache.get(); + const auto surface = cache.get(); + const std::vector original = cache.get(); + fixture.model.stellar_interior.radial_power = 3.0; + const auto report = cache.try_prepare(fixture.inputs()); + REQUIRE(report); + CHECK(report->status() == Status::reused); + CHECK(report->status() == Status::reused); + CHECK(report->status() == Status::reused); + CHECK(report->status() == Status::reused); + CHECK(report->status() == Status::computed); + CHECK(report->status() == Status::computed); + CHECK(report->status() == Status::computed); + CHECK(report->status() == Status::computed); + CHECK(cache.get() == interpolation); + CHECK(cache.get() == surface); + CHECK(cache.get()->interpolation() == interpolation); + CHECK(cache.get() != original); +} + +TEST_CASE("Changing the reference center invalidates radial surface directions without rebuilding logical interpolation", "[integration][deformation]") +{ + GraphFixture fixture; + auto cache = make_cache(fixture); + REQUIRE(cache.try_prepare(fixture.inputs())); + const auto interpolation = cache.get(); + const auto extension = cache.get(); + const std::vector original = cache.get(); + fixture.model.surface.reference_center[0] = 0.02; + const auto report = cache.try_prepare(fixture.inputs()); + REQUIRE(report); + CHECK(report->status() == Status::reused); + CHECK(report->status() == Status::reused); + CHECK(report->status() == Status::reused); + CHECK(report->status() == Status::computed); + CHECK(report->status() == Status::computed); + CHECK(report->status() == Status::computed); + CHECK(report->status() == Status::computed); + CHECK(report->status() == Status::computed); + CHECK(cache.get() == interpolation); + CHECK(cache.get() == extension); + CHECK(cache.get() != original); +} + +TEST_CASE("Cached deformation linearizations freeze borrowed coefficients until the next accepted preparation", "[integration][deformation]") +{ + GraphFixture fixture; + const auto inputs = fixture.inputs(); + auto cache = make_cache(fixture); + REQUIRE(cache.try_prepare(inputs)); + const auto accepted = cache.get(); + const std::vector original = cache.get(); + auto workspace = deformation::make_deformation_workspace(*accepted.map); + std::vector action(original.size()); + REQUIRE(deformation::apply_jacobian(accepted, accepted.parameters, action, workspace)); + CHECK(action == original); + fixture.coefficients[0] += 0.2; + CHECK(accepted.parameters != fixture.coefficients); + CHECK(cache.get().parameters == accepted.parameters); + CHECK(cache.get() == original); + REQUIRE(deformation::apply_jacobian(accepted, accepted.parameters, action, workspace)); + CHECK(action == original); + REQUIRE(cache.try_prepare(inputs)); + CHECK(cache.get().parameters == fixture.coefficients); + CHECK(accepted.parameters != fixture.coefficients); + CHECK(cache.get() != original); +} + +TEST_CASE("Physical and logical coordinate revisions invalidate only their own reference mathematics", "[integration][deformation]") +{ + GraphFixture fixture; + auto cache = make_cache(fixture); + REQUIRE(cache.try_prepare(fixture.inputs())); + const auto reference = cache.get(); + const auto interpolation = cache.get(); + const std::vector original = cache.get(); + bool physical = false; + SECTION("Physical mesh node revision") + { + physical = true; + fixture.mesh.generated.mesh->NodesUpdated(); + } + SECTION("Logical mesh node revision") + { + fixture.mesh.generated.reference_mesh->NodesUpdated(); + } + const auto report = cache.try_prepare(fixture.inputs()); + REQUIRE(report); + CHECK(report->status() == Status::reused); + CHECK(report->status() == (physical ? Status::computed : Status::reused)); + CHECK(report->status() == (physical ? Status::computed : Status::reused)); + CHECK(report->status() == (physical ? Status::reused : Status::computed)); + CHECK(report->status() == (physical ? Status::reused : Status::computed)); + CHECK(report->status() == Status::computed); + CHECK((cache.get() != reference) == physical); + CHECK((cache.get() == interpolation) == physical); + CHECK(cache.get() == original); +} + +TEST_CASE("Physical coordinate edits reuse interpolation and agree with a fresh deformation compilation", "[integration][deformation]") +{ + GraphFixture fixture; + auto cache = make_cache(fixture); + REQUIRE(cache.try_prepare(fixture.inputs())); + const auto reference = cache.get(); + const auto interpolation = cache.get(); + const std::vector original = cache.get(); + ::mfem::VectorFunctionCoefficient stretch(3, [](const ::mfem::Vector& input, ::mfem::Vector& output) + { + output = input; + output[2] *= 0.85; + }); + fixture.mesh.generated.mesh->Transform(stretch); + fixture.mesh.generated.mesh->NodesUpdated(); + const auto report = cache.try_prepare(fixture.inputs()); + REQUIRE(report); + CHECK(report->status() == Status::reused); + CHECK(report->status() == Status::computed); + CHECK(report->status() == Status::reused); + CHECK(cache.get()->positions != reference->positions); + CHECK(cache.get() == interpolation); + CHECK(cache.get() != original); + auto fresh = make_cache(fixture); + REQUIRE(fresh.try_prepare(fixture.inputs())); + CHECK(cache.get()->positions == fresh.get()->positions); + CHECK(cache.get() == fresh.get()); +} + +TEST_CASE("Logical coordinate edits reuse the physical surface and agree with a fresh deformation compilation", "[integration][deformation]") +{ + GraphFixture fixture; + auto cache = make_cache(fixture); + REQUIRE(cache.try_prepare(fixture.inputs())); + const auto reference = cache.get(); + const auto interpolation = cache.get(); + const std::vector original = cache.get(); + ::mfem::VectorFunctionCoefficient scale(3, [](const ::mfem::Vector& input, ::mfem::Vector& output) + { + output = input; + output *= 2.0; + }); + fixture.mesh.generated.reference_mesh->Transform(scale); + fixture.mesh.generated.reference_mesh->NodesUpdated(); + const auto report = cache.try_prepare(fixture.inputs()); + REQUIRE(report); + CHECK(report->status() == Status::reused); + CHECK(report->status() == Status::reused); + CHECK(report->status() == Status::computed); + CHECK(cache.get() == reference); + CHECK(cache.get()->data().stellar_radius == Catch::Approx(2.0 * interpolation->data().stellar_radius)); + auto fresh = make_cache(fixture); + REQUIRE(fresh.try_prepare(fixture.inputs())); + CHECK(cache.get()->data().logical_radii == fresh.get()->data().logical_radii); + CHECK(cache.get() == fresh.get()); + for (std::size_t index = 0; index < original.size(); ++index) + { + CHECK(cache.get()[index] == Catch::Approx(original[index]).margin(1.0e-12)); + } +} + +TEST_CASE("Material attribute edits are tracked without requiring a mesh revision bump", "[integration][deformation]") +{ + namespace domains = serif::discretization::domain; + using Schema = serif::tests::deformation::Schema; + + GraphFixture fixture; + auto cache = make_cache(fixture); + REQUIRE(cache.try_prepare(fixture.inputs())); + const auto interpolation = cache.get(); + const std::vector original = cache.get(); + const int core = Schema::domain_id(); + const int envelope = Schema::domain_id(); + int selected = -1; + for (int element = 0; element < fixture.mesh.generated.mesh->GetNE(); ++element) + { + if (fixture.mesh.generated.mesh->GetAttribute(element) == core) + { + selected = element; + break; + } + } + REQUIRE(selected >= 0); + fixture.mesh.generated.mesh->GetElement(selected)->SetAttribute(envelope); + fixture.mesh.generated.reference_mesh->GetElement(selected)->SetAttribute(envelope); + const auto report = cache.try_prepare(fixture.inputs()); + REQUIRE(report); + CHECK(report->status() == Status::reused); + CHECK(report->status() == Status::reused); + CHECK(report->status() == Status::computed); + CHECK(cache.get() != interpolation); + CHECK(cache.get() == original); +} + +TEST_CASE("Distinct mesh and space identities invalidate same-sized deformation data", "[integration][deformation]") +{ + GraphFixture fixture; + auto cache = make_cache(fixture); + REQUIRE(cache.try_prepare(fixture.inputs())); + const auto reference = cache.get(); + const auto interpolation = cache.get(); + GraphFixture other; + REQUIRE(fixture.mesh.scalar_space.GetVSize() == other.mesh.scalar_space.GetVSize()); + REQUIRE(fixture.mesh.vector_space.GetVSize() == other.mesh.vector_space.GetVSize()); + const auto report = cache.try_prepare(other.inputs()); + REQUIRE(report); + CHECK(report->status() == Status::computed); + CHECK(report->status() == Status::computed); + CHECK(report->status() == Status::computed); + CHECK(report->status() == Status::computed); + CHECK(cache.get() != reference); + CHECK(cache.get() != interpolation); +} + +TEST_CASE("Requested reference results do not evaluate invalid trial shape coefficients", "[integration][deformation]") +{ + GraphFixture fixture; + std::ranges::fill(fixture.coefficients, std::numeric_limits::quiet_NaN()); + auto cache = make_cache(fixture); + const auto reference_report = cache.try_prepare(fixture.inputs(), dependencies::RequestedResults{}); + REQUIRE(reference_report); + CHECK(reference_report->computed_count() == 2); + CHECK(cache.contains()); + CHECK_FALSE(cache.contains()); + CHECK_FALSE(cache.contains()); + CHECK_FALSE(cache.contains()); + REQUIRE(cache.try_prepare(fixture.inputs(), dependencies::RequestedResults{})); + const auto interpolation = cache.get(); + CHECK_FALSE(cache.try_prepare(fixture.inputs())); + CHECK_FALSE(cache.ready()); + CHECK_THROWS_AS(cache.get(), std::logic_error); + std::ranges::fill(fixture.coefficients, 0.01); + const auto recovered = cache.try_prepare(fixture.inputs()); + REQUIRE(recovered); + CHECK(recovered->status() == Status::reused); + CHECK(cache.get() == interpolation); +} + +TEST_CASE("Rejected trial coefficients hide old results and preserve accepted reference factors for recovery", "[integration][deformation]") +{ + GraphFixture fixture; + auto cache = make_cache(fixture); + REQUIRE(cache.try_prepare(fixture.inputs())); + const auto interpolation = cache.get(); + const auto surface = cache.get(); + const auto accepted_count = cache.preparation_count(); + const std::vector accepted_coefficients = fixture.coefficients; + SECTION("Nonfinite trial parameter") + { + fixture.coefficients.back() = std::numeric_limits::quiet_NaN(); + } + SECTION("Wrong coefficient count") + { + fixture.coefficients.push_back(0.01); + } + const auto failed = cache.try_prepare(fixture.inputs()); + REQUIRE_FALSE(failed); + CHECK_FALSE(cache.ready()); + CHECK(cache.preparation_count() == accepted_count); + CHECK_THROWS_AS(cache.get(), std::logic_error); + CHECK_THROWS_AS(cache.get(), std::logic_error); + CHECK_THROWS_AS(cache.get(), std::logic_error); + fixture.coefficients = accepted_coefficients; + const auto recovered = cache.try_prepare(fixture.inputs()); + REQUIRE(recovered); + CHECK(recovered->status() == Status::reused); + CHECK(recovered->status() == Status::reused); + CHECK(cache.get() == interpolation); + CHECK(cache.get() == surface); + CHECK(cache.get().parameters == accepted_coefficients); +} + +TEST_CASE("Invalid extension options cannot leave a deformation cache publicly ready", "[integration][deformation]") +{ + GraphFixture fixture; + auto cache = make_cache(fixture); + REQUIRE(cache.try_prepare(fixture.inputs())); + const auto interpolation = cache.get(); + fixture.model.stellar_interior.radial_power = 0.5; + const auto failed = cache.try_prepare(fixture.inputs()); + REQUIRE_FALSE(failed); + CHECK_FALSE(cache.ready()); + CHECK_THROWS_AS(cache.get(), std::logic_error); + fixture.model.stellar_interior.radial_power = 2.0; + const auto recovered = cache.try_prepare(fixture.inputs()); + REQUIRE(recovered); + CHECK(recovered->status() == Status::reused); + CHECK(cache.get() == interpolation); +} + +TEST_CASE("Removing the surface boundary label invalidates graph preparation immediately", "[integration][deformation]") +{ + GraphFixture fixture; + auto cache = make_cache(fixture); + REQUIRE(cache.try_prepare(fixture.inputs())); + std::vector attributes(fixture.mesh.generated.mesh->GetNBE()); + for (int boundary = 0; boundary < fixture.mesh.generated.mesh->GetNBE(); ++boundary) + { + attributes[boundary] = fixture.mesh.generated.mesh->GetBdrAttribute(boundary); + if (attributes[boundary] == fixture.mesh.surface_attribute) + { + fixture.mesh.generated.mesh->GetBdrElement(boundary)->SetAttribute(999); + } + } + const auto failed = cache.try_prepare(fixture.inputs()); + REQUIRE_FALSE(failed); + CHECK_FALSE(cache.ready()); + CHECK_THROWS_AS(cache.get(), std::logic_error); + for (int boundary = 0; boundary < fixture.mesh.generated.mesh->GetNBE(); ++boundary) + { + fixture.mesh.generated.mesh->GetBdrElement(boundary)->SetAttribute(attributes[boundary]); + } + REQUIRE(cache.try_prepare(fixture.inputs())); +} + +TEST_CASE("Equal nonlinear displacements still refresh the deformation tangent and its accepted state", "[unit][deformation]") +{ + std::vector coefficients{1.0}; + const auto surface = std::make_shared(); + const auto extension = std::make_shared(); + const auto inputs = dependencies::make_dependency_context( + dependencies::own(surface), + dependencies::own(extension), + dependencies::borrow(coefficients)); + auto cache = dependencies::make_computation_cache(deformation::make_deformation_graph(), inputs); + REQUIRE(cache.try_prepare(inputs)); + const auto accepted = cache.get(); + auto workspace = deformation::make_deformation_workspace(*accepted.map); + const std::array direction{1.0}; + std::array action{}; + REQUIRE(deformation::apply_jacobian(accepted, direction, action, workspace)); + CHECK(action[0] == 2.0); + const std::vector original = cache.get(); + coefficients[0] = -1.0; + REQUIRE(deformation::apply_jacobian(accepted, direction, action, workspace)); + CHECK(action[0] == 2.0); + const auto report = cache.try_prepare(inputs); + REQUIRE(report); + CHECK(report->status() == Status::reused); + CHECK(report->status() == Status::computed); + CHECK(report->status() == Status::computed); + CHECK(report->status() == Status::computed); + CHECK(cache.get() == original); + REQUIRE(deformation::apply_jacobian(cache.get(), direction, action, workspace)); + CHECK(action[0] == -2.0); + REQUIRE(deformation::apply_jacobian_transpose(cache.get(), direction, action, workspace)); + CHECK(action[0] == -2.0); + REQUIRE(deformation::apply_pullback_derivative(cache.get(), direction, direction, action, workspace)); + CHECK(action[0] == 2.0); + CHECK(accepted.parameters[0] == 1.0); +} + +TEST_CASE("Requested shape metrics prepare only the reference surface and ignore trial shape and volume extension choices", "[integration][deformation]") +{ + GraphFixture fixture; + fixture.coefficients[0] = std::numeric_limits::quiet_NaN(); + fixture.model.stellar_interior.radial_power = 0.5; + auto computations = std::tuple_cat(backend::make_deformation_computations(), std::tuple{backend::make_surface_metric_computation()}); + /// Additional reference physics joins the same dependency graph without adding a preparation lifecycle. + auto graph = std::apply([](auto... nodes) + { + return dependencies::make_computation_graph(std::move(nodes)...); + }, + std::move(computations)); + auto cache = dependencies::make_computation_cache(std::move(graph), fixture.inputs()); + const auto requested = dependencies::RequestedResults{}; + const auto initial = cache.try_prepare(fixture.inputs(), requested); + REQUIRE(initial); + CHECK(initial->computed_count() == 4); + CHECK(initial->status() == Status::computed); + CHECK(initial->status() == Status::computed); + CHECK(initial->status() == Status::computed); + CHECK(initial->status() == Status::computed); + CHECK(initial->status() == Status::not_required); + CHECK(initial->status() == Status::not_required); + CHECK(initial->status() == Status::not_required); + CHECK_FALSE(cache.contains()); + CHECK_FALSE(cache.contains()); + CHECK_FALSE(cache.contains()); + CHECK_FALSE(cache.contains()); + CHECK_FALSE(cache.contains()); + const std::vector metric = cache.get(); + REQUIRE(metric.size() == fixture.coefficients.size()); + CHECK(std::ranges::all_of(metric, [](double value) + { + return std::isfinite(value) && value > 0.0; + })); + + fixture.coefficients[0] = 0.2; + const auto changed_coefficients = cache.try_prepare(fixture.inputs(), requested); + REQUIRE(changed_coefficients); + CHECK(changed_coefficients->computed_count() == 0); + CHECK(changed_coefficients->status() == Status::reused); + fixture.model.stellar_interior.radial_power = 3.0; + const auto changed_extension = cache.try_prepare(fixture.inputs(), requested); + REQUIRE(changed_extension); + CHECK(changed_extension->computed_count() == 0); + CHECK(changed_extension->status() == Status::reused); + CHECK(cache.get() == metric); + + fixture.mesh.generated.reference_mesh->NodesUpdated(); + const auto changed_logical_reference = cache.try_prepare(fixture.inputs(), requested); + REQUIRE(changed_logical_reference); + CHECK(changed_logical_reference->computed_count() == 0); + CHECK(changed_logical_reference->status() == Status::reused); + + fixture.model.surface.reference_center[0] = 0.02; + const auto changed_surface = cache.try_prepare(fixture.inputs(), requested); + REQUIRE(changed_surface); + CHECK(changed_surface->status() == Status::reused); + CHECK(changed_surface->status() == Status::computed); + CHECK(changed_surface->status() == Status::computed); + ::mfem::VectorFunctionCoefficient stretch(3, [](const ::mfem::Vector& input, ::mfem::Vector& output) + { + output = input; + output[2] *= 0.85; + }); + fixture.mesh.generated.mesh->Transform(stretch); + fixture.mesh.generated.mesh->NodesUpdated(); + const auto changed_reference = cache.try_prepare(fixture.inputs(), requested); + REQUIRE(changed_reference); + CHECK(changed_reference->status() == Status::computed); + CHECK(changed_reference->status() == Status::computed); + CHECK(changed_reference->status() == Status::computed); + const auto reference = backend::sample_reference_surface(fixture.mesh.scalar_space, fixture.mesh.surface_attribute); + REQUIRE(reference); + const auto embedding = deformation::build_surface_embedding(fixture.model.surface, reference->positions); + REQUIRE(embedding); + const auto fresh_metric = backend::surface_parameter_mass_diagonal(fixture.mesh.scalar_space, fixture.mesh.surface_attribute, *reference, *embedding); + REQUIRE(fresh_metric); + CHECK(cache.get() == *fresh_metric); + CHECK(cache.get() != metric); + CHECK_FALSE(cache.contains()); + CHECK_FALSE(cache.contains()); + CHECK_FALSE(cache.contains()); +} + +TEST_CASE("Packed shape views track only their values and retain independent linearizations", "[integration][deformation][shape_view]") { + GraphFixture fixture; + constexpr std::size_t offset = 4; + std::vector packed(offset + fixture.coefficients.size() + 3, 0.0); + std::ranges::copy(fixture.coefficients, packed.begin() + offset); + const auto inputs = backend::make_deformation_inputs(fixture.model, fixture.discretization, + std::span(packed).subspan(offset, fixture.coefficients.size())); + auto cache = dependencies::make_computation_cache( + backend::make_deformation_graph(), inputs); + REQUIRE(cache.try_prepare(inputs)); + const auto frozen = cache.get(); + CHECK(frozen.parameters == fixture.coefficients); + + packed.front() = 2.0; + REQUIRE(cache.try_prepare(inputs)); + CHECK(cache.report().computed_count() == 0); + + packed[offset] += 0.02; + REQUIRE(cache.try_prepare(inputs)); + CHECK(cache.report().status() == Status::reused); + CHECK(cache.report().status() == Status::reused); + CHECK(cache.report().status() == Status::computed); + CHECK(cache.report().status() == Status::computed); + CHECK(cache.get().parameters.front() == packed[offset]); + CHECK(frozen.parameters == fixture.coefficients); + + std::vector equal_coefficients(packed.begin() + offset, packed.end() - 3); + const auto equal_inputs = backend::make_deformation_inputs(fixture.model, fixture.discretization, + std::span(equal_coefficients)); + REQUIRE(cache.try_prepare(equal_inputs)); + CHECK(cache.report().computed_count() == 0); + + equal_coefficients.front() = std::numeric_limits::quiet_NaN(); + REQUIRE_FALSE(cache.try_prepare(equal_inputs)); + CHECK_FALSE(cache.contains()); + CHECK(frozen.parameters == fixture.coefficients); + REQUIRE(cache.try_prepare(inputs)); + + const auto short_inputs = backend::make_deformation_inputs(fixture.model, fixture.discretization, + std::span(packed).subspan(offset, fixture.coefficients.size() - 1)); + REQUIRE_FALSE(cache.try_prepare(short_inputs)); + CHECK_FALSE(cache.contains()); + REQUIRE(cache.try_prepare(inputs)); + CHECK(cache.get().parameters.front() == packed[offset]); +} + +TEST_CASE("Borrowed shape vectors retain current storage after resizing", "[integration][deformation][shape_view]") { + GraphFixture fixture; + const auto inputs = fixture.inputs(); + auto cache = make_cache(fixture); + REQUIRE(cache.try_prepare(inputs)); + const std::vector original = fixture.coefficients; + fixture.coefficients.resize(fixture.coefficients.capacity() + 1, 0.0); + REQUIRE_FALSE(cache.try_prepare(inputs)); + CHECK_FALSE(cache.contains()); + fixture.coefficients = original; + REQUIRE(cache.try_prepare(inputs)); + CHECK(cache.get().parameters == original); +} diff --git a/tests/deformation/domain_actions_meanfield.cpp b/tests/deformation/domain_actions_meanfield.cpp new file mode 100644 index 00000000..50766e11 --- /dev/null +++ b/tests/deformation/domain_actions_meanfield.cpp @@ -0,0 +1,115 @@ +#include +#include +#include +#include +#include +#include + +#include +#include "serif/backend/mfem/geometry/deformation/reference_geometry.hpp" +#include "serif/geometry/deformation/composition.hpp" +#include "serif/geometry/deformation/linearization.hpp" +#include "serif/geometry/deformation/surface_embedding.hpp" +#include "serif/tests/deformation/mesh.hpp" + +namespace +{ + namespace deformation = serif::geometry::deformation; + namespace backend = deformation::backend::mfem; + using DomainMap = deformation::ComposedDeformation; + + /** @brief Pair complete surface and volume actions on the same serial STROID geometry. */ + template + [[nodiscard]] DomainMap make_domain_map(serif::tests::deformation::PairedMesh& mesh, const Model& model) + { + const auto reference = backend::sample_reference_surface(mesh.scalar_space, mesh.surface_attribute); + REQUIRE(reference); + auto surface = deformation::build_surface_embedding(model, reference->positions); + auto interpolation = mesh.interpolation(); + REQUIRE(surface); + REQUIRE(interpolation); + auto extension = deformation::make_radial_extension(std::make_shared(std::move(*interpolation))); + REQUIRE(extension); + auto result = deformation::compose_deformation(std::make_shared(std::move(*surface)), std::make_shared(std::move(*extension))); + REQUIRE(result); + return std::move(*result); + } + + /** @brief Compare the assembled physical map and complete transpose with the original MeanField tolerances. */ + void check_complete_actions(const DomainMap& map, const std::vector& state, const std::vector& direction, const double step, const double difference_tolerance, const double work_tolerance) + { + std::vector plus(state), minus(state); + for (std::size_t parameter = 0; parameter < state.size(); ++parameter) + { + plus[parameter] += step * direction[parameter]; + minus[parameter] -= step * direction[parameter]; + } + const auto plus_value = deformation::evaluate_displacement(map, plus); + const auto minus_value = deformation::evaluate_displacement(map, minus); + REQUIRE(plus_value); + REQUIRE(minus_value); + auto workspace = deformation::make_deformation_workspace(map); + std::vector action(map.output_size()), dual(map.output_size()), force(map.input_size()), curvature(map.input_size(), 7.0); + REQUIRE(deformation::apply_jacobian(map, state, direction, action, workspace)); + long double error_squared = 0.0L; + long double reference_squared = 0.0L; + for (std::size_t row = 0; row < action.size(); ++row) + { + const double difference = ((*plus_value)[row] - (*minus_value)[row]) / (2.0 * step); + error_squared += static_cast(action[row] - difference) * (action[row] - difference); + reference_squared += static_cast(difference) * difference; + const double index = static_cast(row + 1); + dual[row] = std::sin(0.07 * index) + 0.4 * std::cos(0.11 * index); + } + REQUIRE(reference_squared > 0.0L); + CHECK(std::sqrt(error_squared / reference_squared) < difference_tolerance); + REQUIRE(deformation::apply_jacobian_transpose(map, state, dual, force, workspace)); + const double volume_work = std::inner_product(action.begin(), action.end(), dual.begin(), 0.0); + const double parameter_work = std::inner_product(direction.begin(), direction.end(), force.begin(), 0.0); + CHECK(std::abs(volume_work - parameter_work) <= work_tolerance * std::max({1.0, std::abs(volume_work), std::abs(parameter_work)})); + REQUIRE(deformation::apply_pullback_derivative(map, state, direction, dual, curvature, workspace)); + CHECK(std::ranges::all_of(curvature, [](const double value) + { + return value == 0.0; + })); + + std::vector plus_force(map.input_size()), minus_force(map.input_size()); + REQUIRE(deformation::apply_jacobian_transpose(map, plus, dual, plus_force, workspace)); + REQUIRE(deformation::apply_jacobian_transpose(map, minus, dual, minus_force, workspace)); + CHECK(plus_force == minus_force); + } +} // namespace + +TEST_CASE("MeanField nodal domain directions preserve complete finite differences and virtual work", "[unit][deformation][meanfield]") +{ + // Preserve the original cubic displacement space, including interpolation between refined surface rays. + for (const int refinement : {0, 1}) + { + CAPTURE(refinement); + serif::tests::deformation::PairedMesh mesh(refinement, 0.0, 3, 4, 5.0); + const DomainMap map = make_domain_map(mesh, deformation::NodalRadialSurface{}); + std::vector state(map.input_size()), direction(map.input_size()); + for (std::size_t parameter = 0; parameter < state.size(); ++parameter) + { + const double index = static_cast(parameter + 1); + state[parameter] = 0.008 * std::sin(0.13 * index); + direction[parameter] = std::cos(0.17 * index) - 0.25 * std::sin(0.29 * index); + } + check_complete_actions(map, state, direction, 1.0e-6, 3.0e-10, 8.0e-13); + } +} + +TEST_CASE("MeanField harmonic domain directions preserve complete finite differences and dual work", "[unit][deformation][meanfield]") +{ + serif::tests::deformation::PairedMesh mesh(0, 0.0, 3, 4, 5.0); + const DomainMap map = make_domain_map(mesh, deformation::SphericalHarmonicSurface{.maximum_degree = 2}); + REQUIRE(map.input_size() == 9); + std::vector state(map.input_size()), direction(map.input_size()); + for (std::size_t parameter = 0; parameter < state.size(); ++parameter) + { + const double value = std::sin(0.37 * static_cast(parameter + 1)); + state[parameter] = 0.001 * value; + direction[parameter] = 0.004 * value; + } + check_complete_actions(map, state, direction, 1.0e-5, 2.0e-9, 1.0e-11); +} diff --git a/tests/deformation/equation_integration.cpp b/tests/deformation/equation_integration.cpp new file mode 100644 index 00000000..a9dcb7dc --- /dev/null +++ b/tests/deformation/equation_integration.cpp @@ -0,0 +1,397 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include "serif/backend/mfem/geometry/deformation/computations.hpp" +#include "serif/backend/mfem/model/volume_displacement.hpp" +#include "serif/backend/mfem/model/deformation.hpp" +#include "serif/tests/deformation/mesh.hpp" +#include "serif/tests/equations/fixture.hpp" + +namespace +{ + namespace deformation = serif::geometry::deformation; + namespace deformation_backend = deformation::backend::mfem; + namespace equation_backend = serif::operators::backend::mfem; + namespace equation_fixture = serif::tests::equations; + namespace dependencies = serif::utils::dependencies; + namespace blocks = serif::discretization::blocks; + namespace stages = equation_backend::volume_keys; + using Status = dependencies::PreparationStatus; + using Closure = equation_fixture::Closure; + using Model = deformation::DomainDeformation; + using VolumeDiscretization = equation_backend::VolumeDiscretization; + using Failure = std::variant; + using DensityValue = serif::discretization::forms::Operand; + + /** @brief Exercise displacement support independently of the stellar domain vocabulary. */ + struct SupportedDisplacement : serif::discretization::backend::mfem::DofMap { + /** @brief A selected element set gives an independent gather/scatter reference. */ + SupportedDisplacement(mfem::FiniteElementSpace& space, const mfem::Array& support) : DofMap(space, support) {} + }; + + /** @brief Keep the collection and coefficient count unchanged while exposing incompatible local numbering. */ + struct ReorderedDisplacementSpace : mfem::FiniteElementSpace + { + using mfem::FiniteElementSpace::FiniteElementSpace; + + /** @brief Model an equation companion whose scalar coefficients have different local meanings. */ + void GetElementDofs(int element, mfem::Array& dofs, mfem::DofTransformation& transformation) const override + { + mfem::FiniteElementSpace::GetElementDofs(element, dofs, transformation); + std::swap(dofs[0], dofs[1]); + } + }; + + /** @brief Reuse the volume equation's existing field bindings and physical mapper. */ + [[nodiscard]] VolumeDiscretization equation_discretization(const equation_fixture::Fixture& fixture) + { + return {.residual_binding = fixture.density_binding, .displacement_binding = fixture.displacement_binding, .compactification = fixture.compactification, .mapper = fixture.mapper}; + } + + /** @brief Supply only independent physical inputs; displacement is produced inside the combined graph. */ + [[nodiscard]] auto combined_inputs(const Model& model, const deformation_backend::ReferenceDiscretization& reference, const std::vector& shape, const equation_fixture::Fixture& fixture, const mfem::Vector& density, const mfem::Vector& enthalpy) + { + const auto shape_inputs = deformation_backend::make_deformation_inputs(model, reference, shape); + const auto equation_inputs = equation_backend::make_volume_inputs(equation_discretization(fixture), fixture.state(density, enthalpy)); + return dependencies::make_dependency_context( + shape_inputs.template resource(), + shape_inputs.template resource(), + shape_inputs.template resource(), + shape_inputs.template resource(), + shape_inputs.template resource(), + shape_inputs.template resource(), + shape_inputs.template resource(), + equation_inputs.template resource(), + equation_inputs.template resource(), + equation_inputs.template resource(), + equation_inputs.template resource<::serif::tests::equations::Enthalpy>()); + } + + /** @brief Compare accepted residuals and both linear actions with the established standalone equation path. */ + template + void check_against_volume_cache(const Cache& combined, equation_fixture::Fixture& fixture, const serif::eos::models::Polytrope& eos, const mfem::Vector& density, const mfem::Vector& enthalpy) + { + const mfem::Vector displacement(combined.template get().values); + const auto discretization = equation_discretization(fixture); + const auto inputs = equation_backend::make_volume_inputs(discretization, fixture.state(density, enthalpy), displacement); + auto independent = equation_backend::make_volume_equation_cache(Closure{eos}, discretization, inputs); + REQUIRE(independent.try_prepare(inputs)); + mfem::Vector actual, expected; + equation_backend::volume_residual(combined.template get(), actual); + equation_backend::volume_residual(independent.template get(), expected); + CHECK(equation_fixture::relative_error(actual, expected) < 2.0e-14); + + const mfem::Vector direction = equation_fixture::packed(fixture.density(0.7), fixture.enthalpy(0.2), fixture.displacement(0.01)); + equation_backend::apply_jacobian(combined.template get(), direction, fixture.workspace, actual); + equation_backend::apply_jacobian(independent.template get(), direction, fixture.workspace, expected); + CHECK(equation_fixture::relative_error(actual, expected) < 2.0e-14); + const mfem::Vector dual = equation_fixture::deterministic(fixture.density_binding.size(), 0.3); + equation_backend::apply_jacobian_transpose(combined.template get(), dual, fixture.workspace, actual); + equation_backend::apply_jacobian_transpose(independent.template get(), dual, fixture.workspace, expected); + CHECK(equation_fixture::relative_error(actual, expected) < 2.0e-14); + } + + /** @brief Verify the complete harmonic-shape-to-equation tangent and its reverse virtual-work pairing. */ + template + void check_shape_equation_linearization(Cache& cache, const Model& model, const deformation_backend::ReferenceDiscretization& reference, std::vector& shape, equation_fixture::Fixture& fixture, const mfem::Vector& density, const mfem::Vector& enthalpy) + { + const auto& accepted = cache.template get(); + auto shape_workspace = deformation::make_deformation_workspace(*accepted.map); + const std::array shape_direction{0.35, -0.17, 0.22, 0.09}; + std::vector volume_direction(accepted.map->output_size()); + REQUIRE(deformation::apply_jacobian(accepted, shape_direction, volume_direction, shape_workspace)); + + // This fixture uses the same complete component-block ordering on both sides of the interface. + REQUIRE(fixture.displacement_space.GetOrdering() == mfem::Ordering::byNODES); + mfem::Vector displacement_direction(fixture.displacement_binding.size()); + REQUIRE(volume_direction.size() == static_cast(displacement_direction.Size())); + std::ranges::copy(volume_direction, displacement_direction.GetData()); + const mfem::Vector zero_density = equation_fixture::constant(fixture.density_binding.size(), 0.0); + const mfem::Vector zero_enthalpy = equation_fixture::constant(fixture.enthalpy_binding.size(), 0.0); + const mfem::Vector direction = equation_fixture::packed(zero_density, zero_enthalpy, displacement_direction); + mfem::Vector analytic, equation_transpose; + equation_backend::apply_jacobian(cache.template get(), direction, fixture.workspace, analytic); + const mfem::Vector equation_dual = equation_fixture::deterministic(fixture.density_binding.size(), 0.3); + equation_backend::apply_jacobian_transpose(cache.template get(), equation_dual, fixture.workspace, equation_transpose); + + const int displacement_offset = zero_density.Size() + zero_enthalpy.Size(); + REQUIRE(equation_transpose.Size() == displacement_offset + displacement_direction.Size()); + const std::span displacement_force(equation_transpose.GetData() + displacement_offset, displacement_direction.Size()); + std::array shape_force{}; + REQUIRE(deformation::apply_jacobian_transpose(accepted, displacement_force, shape_force, shape_workspace)); + const double equation_work = analytic * equation_dual; + const double shape_work = std::inner_product(shape_direction.begin(), shape_direction.end(), shape_force.begin(), 0.0); + CHECK(std::abs(equation_work - shape_work) <= 2.0e-12 * std::max({1.0, std::abs(equation_work), std::abs(shape_work)})); + + const std::vector accepted_shape = shape; + constexpr double step = 1.0e-6; + for (std::size_t parameter = 0; parameter < shape.size(); ++parameter) + { + shape[parameter] = accepted_shape[parameter] + step * shape_direction[parameter]; + } + REQUIRE(cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy))); + mfem::Vector plus_residual, minus_residual; + equation_backend::volume_residual(cache.template get(), plus_residual); + for (std::size_t parameter = 0; parameter < shape.size(); ++parameter) + { + shape[parameter] = accepted_shape[parameter] - step * shape_direction[parameter]; + } + REQUIRE(cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy))); + equation_backend::volume_residual(cache.template get(), minus_residual); + plus_residual -= minus_residual; + plus_residual /= 2.0 * step; + CHECK(equation_fixture::relative_error(analytic, plus_residual) < 2.0e-8); + + shape = accepted_shape; + REQUIRE(cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy))); + } +} // namespace + +TEST_CASE("Deformation and volume equations share one dependency graph with atomic preparation", "[unit][deformation][equation_integration]") +{ + auto generated = serif::tests::deformation::make_mesh(); + equation_fixture::Fixture fixture(std::move(*generated.mesh)); + const auto reference = deformation_backend::deformation_discretization(fixture.enthalpy_space, fixture.displacement_space, *generated.reference_mesh); + Model model{.surface = {.maximum_degree = 1}, .stellar_interior = {}, .vacuum = {}}; + std::vector shape{0.004, -0.002, 0.001, 0.003}; + mfem::Vector density = fixture.density(); + mfem::Vector enthalpy = fixture.enthalpy(); + const serif::eos::models::Polytrope eos{1.5, 0.25}; + const auto inputs = combined_inputs(model, reference, shape, fixture, density, enthalpy); + const auto calculations = std::tuple_cat( + deformation_backend::make_deformation_computations(), + deformation_backend::make_volume_displacement_computations(fixture.displacement_binding), + equation_backend::make_volume_equation_computations(Closure{eos}, equation_discretization(fixture))); + auto cache = std::apply([&](auto... calculation) + { + return dependencies::make_computation_cache(inputs, std::move(calculation)...); + }, + calculations); + CHECK_FALSE(cache.ready()); + REQUIRE(cache.try_prepare(inputs)); + check_against_volume_cache(cache, fixture, eos, density, enthalpy); + check_shape_equation_linearization(cache, model, reference, shape, fixture, density, enthalpy); + + const auto& backend_vector = cache.get(); + CHECK(&cache.get().values == &backend_vector); + REQUIRE(cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy))); + CHECK(cache.report().computed_count() == 0); + + shape[1] += 0.001; + REQUIRE(cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy))); + CHECK(cache.report().status() == Status::reused); + CHECK(cache.report().status() == Status::reused); + CHECK(cache.report().status() == Status::computed); + CHECK(cache.report().status() == Status::reused); + CHECK(cache.report().status>() == Status::reused); + CHECK(cache.report().status>() == Status::computed); + CHECK(cache.report().status>() == Status::reused); + check_against_volume_cache(cache, fixture, eos, density, enthalpy); + + density += 0.02; + REQUIRE(cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy))); + CHECK(cache.report().status() == Status::reused); + CHECK(cache.report().status>() == Status::reused); + CHECK(cache.report().status>() == Status::computed); + check_against_volume_cache(cache, fixture, eos, density, enthalpy); + + // A different interior response changes derivative information even when a spherical zero-displacement state has equal values. + std::ranges::fill(shape, 0.0); + REQUIRE(cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy))); + model.stellar_interior.radial_power = 3.0; + REQUIRE(cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy))); + CHECK(cache.report().status() == Status::computed); + CHECK(cache.report().status() == Status::reused); + CHECK(cache.report().status() == Status::computed); + CHECK(cache.report().status>() == Status::computed); + CHECK(cache.get().values.Norml2() == 0.0); + + shape[0] = std::numeric_limits::quiet_NaN(); + const auto invalid_shape = cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy)); + REQUIRE_FALSE(invalid_shape); + CHECK(std::holds_alternative(invalid_shape.error().error)); + CHECK_FALSE(cache.ready()); + CHECK_FALSE(cache.contains()); + CHECK_FALSE(cache.contains()); + + shape[0] = 0.003; + enthalpy = -1.0; + const auto invalid_equation = cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy)); + REQUIRE_FALSE(invalid_equation); + CHECK(std::holds_alternative(invalid_equation.error().error)); + CHECK_FALSE(cache.ready()); + CHECK_FALSE(cache.contains()); + CHECK_FALSE(cache.contains()); + + enthalpy = fixture.enthalpy(); + REQUIRE(cache.try_prepare(combined_inputs(model, reference, shape, fixture, density, enthalpy))); + check_against_volume_cache(cache, fixture, eos, density, enthalpy); +} + +TEST_CASE("The deformation displacement connection respects both global MFEM orderings", "[unit][deformation][equation_integration]") +{ + for (const mfem::Ordering::Type ordering : {mfem::Ordering::byNODES, mfem::Ordering::byVDIM}) + { + equation_fixture::Fixture fixture(mfem::Element::HEXAHEDRON, ordering); + const mfem::Vector density = fixture.density(); + const mfem::Vector enthalpy = fixture.enthalpy(); + const mfem::Vector reference_displacement = fixture.displacement(0.03); + const int scalar_nodes = fixture.displacement_space.GetNDofs(); + std::vector displacement(fixture.displacement_binding.full_size()); + for (int component = 0; component < 3; ++component) + { + for (int node = 0; node < scalar_nodes; ++node) + { + const int full = fixture.displacement_space.DofToVDof(node, component); + const int reduced = *fixture.displacement_binding.reduced_dof(full); + displacement[node + component * scalar_nodes] = reference_displacement[reduced]; + } + } + const auto equation_inputs = equation_backend::make_volume_inputs(equation_discretization(fixture), fixture.state(density, enthalpy)); + const auto reference = deformation_backend::deformation_discretization(fixture.enthalpy_space, fixture.displacement_space, fixture.mesh); + const auto inputs = dependencies::make_dependency_context( + dependencies::borrow(displacement, deformation_backend::DeformationValueTracking{}), + dependencies::own(reference, deformation_backend::ReferenceDiscretizationTracking{}), + equation_inputs.resource(), + equation_inputs.resource(), + equation_inputs.resource(), + equation_inputs.resource<::serif::tests::equations::Enthalpy>()); + const serif::eos::models::Polytrope eos{1.5, 0.25}; + const auto calculations = std::tuple_cat(deformation_backend::make_volume_displacement_computations(fixture.displacement_binding), equation_backend::make_volume_equation_computations(Closure{eos}, equation_discretization(fixture))); + auto cache = std::apply([&](auto... calculation) + { + return dependencies::make_computation_cache(inputs, std::move(calculation)...); + }, + calculations); + REQUIRE(cache.try_prepare(inputs)); + const mfem::Vector& actual = cache.get().values; + CHECK(equation_fixture::relative_error(actual, reference_displacement) == 0.0); + check_against_volume_cache(cache, fixture, eos, density, enthalpy); + + equation_fixture::Fixture other_fixture(mfem::Element::HEXAHEDRON, ordering); + const auto other_reference = deformation_backend::deformation_discretization(other_fixture.enthalpy_space, other_fixture.displacement_space, other_fixture.mesh); + const auto mismatched_inputs = dependencies::make_dependency_context( + inputs.resource(), + dependencies::own(other_reference, deformation_backend::ReferenceDiscretizationTracking{}), + inputs.resource(), + inputs.resource(), + inputs.resource(), + inputs.resource<::serif::tests::equations::Enthalpy>()); + const auto mismatch = cache.try_prepare(mismatched_inputs); + REQUIRE_FALSE(mismatch); + CHECK(std::holds_alternative(mismatch.error().error)); + CHECK_FALSE(cache.ready()); + REQUIRE(cache.try_prepare(inputs)); + + displacement.pop_back(); + const auto invalid = cache.try_prepare(inputs); + REQUIRE_FALSE(invalid); + CHECK(std::holds_alternative(invalid.error().error)); + CHECK_FALSE(cache.ready()); + } +} + +TEST_CASE("Deformation rejects an equation displacement space with matching dimensions but different scalar numbering", "[unit][deformation][equation_integration]") +{ + equation_fixture::Fixture fixture; + ReorderedDisplacementSpace reordered(&fixture.mesh, &fixture.enthalpy_collection, 3, mfem::Ordering::byNODES); + using Binding = serif::discretization::backend::mfem::TermBinding; + const Binding binding(reordered); + REQUIRE(reordered.GetNDofs() == fixture.displacement_space.GetNDofs()); + REQUIRE(reordered.FEColl() == fixture.displacement_space.FEColl()); + REQUIRE(binding.size() == fixture.displacement_binding.size()); + const VolumeDiscretization discretization{ + .residual_binding = fixture.density_binding, + .displacement_binding = binding, + .compactification = fixture.compactification, + .mapper = fixture.mapper}; + const mfem::Vector density = fixture.density(); + const mfem::Vector enthalpy = fixture.enthalpy(); + const auto equation_inputs = equation_backend::make_volume_inputs(discretization, fixture.state(density, enthalpy)); + const auto reference = deformation_backend::deformation_discretization(fixture.enthalpy_space, fixture.displacement_space, fixture.mesh); + const std::vector displacement(binding.full_size(), 0.0); + const auto inputs = dependencies::make_dependency_context( + dependencies::borrow(displacement, deformation_backend::DeformationValueTracking{}), + dependencies::own(reference, deformation_backend::ReferenceDiscretizationTracking{}), + equation_inputs.resource()); + auto cache = std::apply([&](auto... calculation) + { + return dependencies::make_computation_cache(inputs, std::move(calculation)...); + }, + deformation_backend::make_volume_displacement_computations(binding)); + const auto outcome = cache.try_prepare(inputs); + REQUIRE_FALSE(outcome); + CHECK(outcome.error().error.reason == deformation::DeformationFailureReason::incompatible_discretization); + CHECK(outcome.error().error.element == 0); + CHECK_FALSE(cache.ready()); + CHECK_FALSE(cache.contains()); +} + +TEST_CASE("Displacement values and sparse derivatives share supported MFEM coordinates", "[unit][deformation][displacement_mapping]") { + for (const mfem::Ordering::Type ordering : {mfem::Ordering::byNODES, mfem::Ordering::byVDIM}) { + for (const bool restrict_support : {false, true}) { + mfem::Vector component_values, full_values, expected, dual, expected_transpose; + const auto projection = [&]() { + mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(2, 1, 1, mfem::Element::HEXAHEDRON); + mfem::H1_FECollection collection(1, 3); + mfem::FiniteElementSpace space(&mesh, &collection, 3, ordering); + mfem::Array support(mesh.GetNE()); + support = 1; + if (restrict_support) { + support[1] = 0; + } + const SupportedDisplacement binding(space, support); + if (restrict_support) { + REQUIRE(binding.size() < binding.full_size()); + } + component_values.SetSize(binding.full_size()); + full_values.SetSize(binding.full_size()); + for (int component = 0; component < space.GetVDim(); ++component) { + for (int node = 0; node < space.GetNDofs(); ++node) { + const int column = node + component * space.GetNDofs(); + component_values[column] = 0.2 + 0.03 * column; + full_values[space.DofToVDof(node, component)] = component_values[column]; + } + } + expected = binding.gather(full_values); + mfem::Vector converted(binding.size()); + std::vector visits(static_cast(binding.size()), 0); + deformation_backend::for_each_displacement_coefficient(binding, [&](const int row, const int column) { + converted[row] = component_values[column]; + ++visits[static_cast(row)]; + }); + CHECK(equation_fixture::relative_error(converted, expected) == 0.0); + CHECK(std::ranges::all_of(visits, [](const int count) { return count == 1; })); + + dual = equation_fixture::deterministic(binding.size(), 0.4); + const mfem::Vector scattered = binding.scatter(dual); + expected_transpose.SetSize(binding.full_size()); + for (int component = 0; component < space.GetVDim(); ++component) { + for (int node = 0; node < space.GetNDofs(); ++node) { + expected_transpose[node + component * space.GetNDofs()] = scattered[space.DofToVDof(node, component)]; + } + } + return serif::model::backend::mfem::displacement_projection(binding); + }(); + + // The immutable projection must not depend on the departed mesh or binding. + REQUIRE(projection.has_assembly()); + const auto sparse = projection.assemble(); + REQUIRE(sparse->Finalized()); + mfem::Vector actual(expected.Size()), transpose(expected_transpose.Size()); + projection.action()->Mult(component_values, actual); + projection.action()->MultTranspose(dual, transpose); + CHECK(equation_fixture::relative_error(actual, expected) == 0.0); + CHECK(equation_fixture::relative_error(transpose, expected_transpose) == 0.0); + CHECK(std::abs(actual * dual - component_values * transpose) < 1.0e-12); + CHECK(sparse->NumNonZeroElems() == expected.Size()); + } + } +} diff --git a/tests/deformation/geometry_step.cpp b/tests/deformation/geometry_step.cpp new file mode 100644 index 00000000..2971cbed --- /dev/null +++ b/tests/deformation/geometry_step.cpp @@ -0,0 +1,381 @@ +// Ported from MeanField tests/deformation/safe_newton_step.cpp, with serial input and sampling regressions. + +#include +#include +#include +#include +#include +#include + +#include +#include +#include "mfem/fem/coefficient.hpp" +#include "mfem/fem/fe_coll.hpp" +#include "mfem/fem/gridfunc.hpp" +#include "mfem/mesh/mesh.hpp" +#include "serif/backend/mfem/geometry/deformation/geometry_step.hpp" +#include "serif/geometry/deformation/detail/determinant_polynomial.hpp" +#include "serif/backend/mfem/mapping/domain_mapper.hpp" + +namespace { + namespace deformation = serif::geometry::deformation; + namespace backend = deformation::backend::mfem; + namespace mapping = serif::mapping; + using Catch::Matchers::WithinAbs; + + constexpr int dimension = 3; + + /** @brief Keep unit element widths so the mapped determinant has an independent analytic value. */ + [[nodiscard]] mfem::Mesh make_mesh(const bool compactified, const bool nonconforming = false) { + mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(2, 1, 1, mfem::Element::HEXAHEDRON, 2.0, 1.0, 1.0); + if (nonconforming) { + mesh.EnsureNCMesh(); + mfem::Array selected{0}; + mesh.GeneralRefinement(selected); + mesh.SetCurvature(6); + mesh.Transform([](const mfem::Vector& x, mfem::Vector& y) { + y = x; + y[0] += 0.03 * x[0] * x[1]; + y[1] += 0.02 * x[1] * x[2]; + y[2] += 0.01 * x[0] * x[2]; + }); + } + for (int element = 0; element < mesh.GetNE(); ++element) { + mesh.GetElement(element)->SetAttribute(compactified ? 2 : 1); + } + return mesh; + } + + /** @brief Reuse the physical Kelvin implementation in both interior and vacuum tests. */ + [[nodiscard]] std::unique_ptr make_exterior_map() { + return std::make_unique(serif::utils::user_options::KelvinCompactificationOptions{.r_star_ref = 1.0, .r_inf_ref = 4.0}); + } + + struct GeometryFixture { + mfem::Mesh mesh; + mfem::H1_FECollection displacement_collection; + mfem::FiniteElementSpace displacement_space; + mfem::H1_FECollection compactification_collection; + mfem::FiniteElementSpace compactification_space; + mfem::GridFunction compactification_coordinate; + mapping::DomainMapper mapper; + + /** @brief Vary global ordering and interpolation degree without changing the reference geometry. */ + explicit GeometryFixture(const bool compactified = false, const mfem::Ordering::Type ordering = mfem::Ordering::byNODES, const int degree = 1, const bool nonconforming = false) : + mesh(make_mesh(compactified, nonconforming)), + displacement_collection(degree, dimension), + displacement_space(&mesh, &displacement_collection, dimension, ordering), + compactification_collection(1, dimension), + compactification_space(&mesh, &compactification_collection), + compactification_coordinate(&compactification_space), + mapper({.dimension = dimension, .vacuum_element_attribute_ID = 2}, make_exterior_map()) { + compactification_coordinate = 0.0; + } + + /** @brief Provide an accepted identity map in true-DOF ordering. */ + [[nodiscard]] mfem::Vector zero_displacement() const { + mfem::Vector result(displacement_space.GetTrueVSize()); + result = 0.0; + return result; + } + + /** @brief Construct directions independently of the geometry-step element extraction. */ + template + [[nodiscard]] mfem::Vector project_direction(Function&& function) { + mfem::VectorFunctionCoefficient coefficient(dimension, std::forward(function)); + mfem::GridFunction field(&displacement_space); + field.ProjectCoefficient(coefficient); + mfem::Vector result; + field.GetTrueDofs(result); + return result; + } + + /** @brief Supply explicit sample rules for every element instead of choosing a hidden quadrature policy. */ + [[nodiscard]] std::vector sample_rules() { + std::vector result; + for (int element = 0; element < mesh.GetNE(); ++element) { + result.push_back({.element = element, .integration_rule = &mfem::IntRules.Get(mesh.GetElementBaseGeometry(element), 2)}); + } + return result; + } + }; + + /** @brief Give det(J(alpha)) = 1 - 2 alpha. */ + void compress_x(const mfem::Vector& position, mfem::Vector& value) { + value.SetSize(dimension); + value = 0.0; + value(0) = -2.0 * position(0); + } + + /** @brief Give a tangent singularity at one half while both endpoints remain positive. */ + void compress_x_and_y(const mfem::Vector& position, mfem::Vector& value) { + compress_x(position, value); + value(1) = -2.0 * position(1); + } + + /** @brief Provide a path with no geometry obstruction at positive steps. */ + void expand_x(const mfem::Vector& position, mfem::Vector& value) { + value.SetSize(dimension); + value = 0.0; + value(0) = position(0); + } + + class NonAffineExteriorMap final : public mapping::compactification::ExteriorDomainMap { + public: + /** @brief Declare that a cubic determinant model cannot certify this map. */ + [[nodiscard]] bool is_affine_in_displacement() const noexcept override { + return false; + } + + /** @brief Fail loudly if validation mistakenly reaches exterior evaluation. */ + [[nodiscard]] mapping::MappingStatus evaluate(const mapping::compactification::ExteriorMapInput&, mapping::compactification::ExteriorMapResult&) const override { + FAIL("A non-affine exterior map must be rejected before evaluation."); + return mapping::MappingStatus::NON_FINITE_MAPPING_RESULT; + } + + /** @brief Fail loudly if validation mistakenly reaches exterior differentiation. */ + [[nodiscard]] mapping::MappingStatus evaluate_variation(const mapping::compactification::ExteriorMapInput&, const mapping::compactification::ExteriorMapResult&, const mapping::compactification::ExteriorMapDirection&, mapping::compactification::ExteriorMapVariation&) const override { + FAIL("A non-affine exterior map must be rejected before differentiation."); + return mapping::MappingStatus::NON_FINITE_MAPPING_RESULT; + } + + /** @brief Identify the deliberately unsupported model in diagnostics. */ + [[nodiscard]] std::string_view get_name() const noexcept override { + return "NonAffineExteriorMap"; + } + }; +} + +TEST_CASE("Geometry step finds the first mapping boundary for both global DOF orderings", "[unit][deformation][geometry_step]") { + for (const mfem::Ordering::Type ordering : {mfem::Ordering::byNODES, mfem::Ordering::byVDIM}) { + GeometryFixture fixture(false, ordering); + const mfem::Vector accepted = fixture.zero_displacement(); + const mfem::Vector direction = fixture.project_direction(compress_x); + const auto rules = fixture.sample_rules(); + const auto result = backend::estimate_geometry_step_limit(fixture.mapper, fixture.displacement_space, fixture.compactification_coordinate, accepted, direction, rules, {.fraction_to_boundary = 0.8}); + + REQUIRE(result); + CHECK(result->limited_by_geometry); + CHECK_THAT(result->boundary_step, WithinAbs(0.5, 2.0e-13)); + CHECK_THAT(result->step, WithinAbs(0.4, 2.0e-13)); + CHECK_THAT(result->minimum_determinant_at_accepted, WithinAbs(1.0, 2.0e-13)); + CHECK_THAT(result->minimum_determinant_at_maximum, WithinAbs(-1.0, 2.0e-13)); + CHECK_THAT(result->limiting_point_determinant_at_step, WithinAbs(0.2, 2.0e-13)); + CHECK(result->sampled_point_count > 0); + CHECK(result->limiting_element >= 0); + CHECK(result->limiting_rule >= 0); + CHECK(result->limiting_point >= 0); + } +} + +TEST_CASE("Geometry step detects a tangent singularity before an admissible endpoint", "[unit][deformation][geometry_step]") { + GeometryFixture fixture(true); + const mfem::Vector accepted = fixture.zero_displacement(); + const mfem::Vector direction = fixture.project_direction(compress_x_and_y); + const auto rules = fixture.sample_rules(); + const auto result = backend::estimate_geometry_step_limit(fixture.mapper, fixture.displacement_space, fixture.compactification_coordinate, accepted, direction, rules); + + REQUIRE(result); + CHECK(result->limited_by_geometry); + CHECK_THAT(result->minimum_determinant_at_maximum, WithinAbs(1.0, 3.0e-13)); + CHECK_THAT(result->boundary_step, WithinAbs(0.5, 3.0e-13)); + CHECK_THAT(result->step, WithinAbs(0.45, 3.0e-13)); + CHECK_THAT(result->limiting_point_determinant_at_step, WithinAbs(0.01, 3.0e-13)); +} + +TEST_CASE("Geometry step preserves constrained displacement and all samples on curved NC geometry", "[unit][deformation][geometry_step][geometry_reuse]") { + for (const bool compactified : {false, true}) { + for (const mfem::Ordering::Type ordering : {mfem::Ordering::byNODES, mfem::Ordering::byVDIM}) { + GeometryFixture fixture(compactified, ordering, 3, true); + REQUIRE(fixture.displacement_space.GetTrueVSize() < fixture.displacement_space.GetVSize()); + const auto accepted = fixture.zero_displacement(); + const auto direction = fixture.project_direction(compress_x); + auto rules = fixture.sample_rules(); + std::size_t expected_samples = 0; + for (int element = 0; element < fixture.mesh.GetNE(); ++element) { + rules.push_back({.element = element, .integration_rule = &mfem::IntRules.Get(fixture.mesh.GetElementBaseGeometry(element), 4)}); + } + for (const auto& rule : rules) { expected_samples += rule.integration_rule->GetNPoints(); } + const auto result = backend::estimate_geometry_step_limit(fixture.mapper, fixture.displacement_space, + fixture.compactification_coordinate, accepted, direction, rules, {.fraction_to_boundary = 0.8}); + REQUIRE(result); + CHECK(result->sampled_point_count == expected_samples); + CHECK(result->limited_by_geometry); + CHECK_THAT(result->boundary_step, WithinAbs(0.5, 3.0e-12)); + CHECK_THAT(result->step, WithinAbs(0.4, 3.0e-12)); + CHECK_THAT(result->minimum_determinant_at_accepted, WithinAbs(1.0, 3.0e-12)); + CHECK_THAT(result->minimum_determinant_at_maximum, WithinAbs(-1.0, 3.0e-12)); + CHECK_THAT(result->limiting_point_determinant_at_step, WithinAbs(0.2, 3.0e-12)); + REQUIRE(result->limiting_rule >= 0); + REQUIRE(static_cast(result->limiting_rule) < rules.size()); + CHECK(result->limiting_element == rules[result->limiting_rule].element); + CHECK(result->limiting_point >= 0); + CHECK(result->limiting_point < rules[result->limiting_rule].integration_rule->GetNPoints()); + } + } +} + +TEST_CASE("Geometry step honors a positive determinant floor", "[unit][deformation][geometry_step]") { + GeometryFixture fixture; + const mfem::Vector accepted = fixture.zero_displacement(); + const mfem::Vector direction = fixture.project_direction(compress_x); + const auto rules = fixture.sample_rules(); + const auto result = backend::estimate_geometry_step_limit(fixture.mapper, fixture.displacement_space, fixture.compactification_coordinate, accepted, direction, rules, {.determinant_floor = 0.25, .fraction_to_boundary = 0.8}); + + REQUIRE(result); + CHECK(result->limited_by_geometry); + CHECK_THAT(result->boundary_step, WithinAbs(0.375, 2.0e-13)); + CHECK_THAT(result->step, WithinAbs(0.3, 2.0e-13)); + CHECK(result->limiting_point_determinant_at_step > 0.25); +} + +TEST_CASE("Geometry step leaves an unconstrained step unchanged", "[unit][deformation][geometry_step]") { + GeometryFixture fixture; + const mfem::Vector accepted = fixture.zero_displacement(); + const mfem::Vector direction = fixture.project_direction(expand_x); + const auto rules = fixture.sample_rules(); + const auto result = backend::estimate_geometry_step_limit(fixture.mapper, fixture.displacement_space, fixture.compactification_coordinate, accepted, direction, rules); + + REQUIRE(result); + CHECK_FALSE(result->limited_by_geometry); + CHECK_THAT(result->boundary_step, WithinAbs(1.0, 2.0e-13)); + CHECK_THAT(result->step, WithinAbs(1.0, 2.0e-13)); + CHECK_THAT(result->minimum_determinant_at_maximum, WithinAbs(2.0, 2.0e-13)); + CHECK_THAT(result->limiting_point_determinant_at_step, WithinAbs(2.0, 2.0e-13)); + CHECK(result->limiting_element == -1); + CHECK(result->limiting_rule == -1); + CHECK(result->limiting_point == -1); +} + +TEST_CASE("Geometry step rescales the polynomial to the requested maximum step", "[unit][deformation][geometry_step]") { + GeometryFixture fixture; + const mfem::Vector zero = fixture.zero_displacement(); + const mfem::Vector direction = fixture.project_direction(compress_x); + const auto rules = fixture.sample_rules(); + const auto result = backend::estimate_geometry_step_limit(fixture.mapper, fixture.displacement_space, fixture.compactification_coordinate, zero, direction, rules, {.maximum_step = 0.25}); + REQUIRE(result); + CHECK_FALSE(result->limited_by_geometry); + CHECK(result->step == 0.25); + CHECK_THAT(result->minimum_determinant_at_maximum, WithinAbs(0.5, 2.0e-13)); + + const auto endpoint = backend::estimate_geometry_step_limit(fixture.mapper, fixture.displacement_space, fixture.compactification_coordinate, zero, direction, rules, {.maximum_step = 0.5}); + REQUIRE(endpoint); + CHECK(endpoint->limited_by_geometry); + CHECK_THAT(endpoint->step, WithinAbs(0.45, 2.0e-13)); +} + +TEST_CASE("Geometry step rejects invalid inputs as values", "[unit][deformation][geometry_step]") { + GeometryFixture fixture; + mfem::Vector zero = fixture.zero_displacement(); + auto rules = fixture.sample_rules(); + const auto estimate = [&](const deformation::GeometryStepOptions options = {}) { + return backend::estimate_geometry_step_limit(fixture.mapper, fixture.displacement_space, fixture.compactification_coordinate, zero, zero, rules, options); + }; + + SECTION("Invalid numerical controls") { + CHECK_FALSE(estimate({.maximum_step = 0.0})); + CHECK_FALSE(estimate({.maximum_step = std::numeric_limits::infinity()})); + CHECK_FALSE(estimate({.determinant_floor = -1.0})); + CHECK_FALSE(estimate({.fraction_to_boundary = 0.0})); + CHECK_FALSE(estimate({.fraction_to_boundary = 1.0})); + CHECK_FALSE(estimate({.fraction_to_boundary = std::numeric_limits::quiet_NaN()})); + } + SECTION("Empty or invalid rules") { + rules.clear(); + CHECK_FALSE(estimate()); + rules.push_back({.element = 0, .integration_rule = nullptr}); + CHECK_FALSE(estimate()); + rules = fixture.sample_rules(); + rules[0].element = fixture.mesh.GetNE(); + CHECK_FALSE(estimate()); + } + SECTION("Invalid coefficients") { + zero.SetSize(1); + CHECK_FALSE(estimate()); + zero = fixture.zero_displacement(); + zero(0) = std::numeric_limits::quiet_NaN(); + CHECK_FALSE(estimate()); + zero = fixture.zero_displacement(); + fixture.compactification_coordinate(0) = std::numeric_limits::infinity(); + CHECK_FALSE(estimate()); + } + SECTION("Folded accepted state") { + const mfem::Vector invalid_accepted = fixture.project_direction(compress_x); + const auto result = backend::estimate_geometry_step_limit(fixture.mapper, fixture.displacement_space, fixture.compactification_coordinate, invalid_accepted, zero, rules); + REQUIRE_FALSE(result); + CHECK(result.error().reason == deformation::DeformationFailureReason::invalid_geometry); + CHECK(result.error().element >= 0); + CHECK(result.error().point >= 0); + } + SECTION("Accepted state must lie strictly above the floor") { + const auto result = estimate({.determinant_floor = 1.0}); + REQUIRE_FALSE(result); + CHECK(result.error().reason == deformation::DeformationFailureReason::invalid_geometry); + } +} + +TEST_CASE("Geometry step rejects a non-affine exterior model before evaluation", "[unit][deformation][geometry_step]") { + GeometryFixture fixture(true); + mapping::DomainMapper mapper({.dimension = dimension, .vacuum_element_attribute_ID = 2}, std::make_unique()); + const mfem::Vector zero = fixture.zero_displacement(); + const auto rules = fixture.sample_rules(); + const auto result = backend::estimate_geometry_step_limit(mapper, fixture.displacement_space, fixture.compactification_coordinate, zero, zero, rules); + REQUIRE_FALSE(result); + CHECK(result.error().reason == deformation::DeformationFailureReason::invalid_configuration); +} + +TEST_CASE("Geometry step retains the union of nonnested sampling rules", "[unit][deformation][geometry_step]") { + GeometryFixture fixture(false, mfem::Ordering::byVDIM, 3); + const mfem::Vector zero = fixture.zero_displacement(); + const mfem::Vector direction = fixture.project_direction([](const mfem::Vector& position, mfem::Vector& value) { + value.SetSize(dimension); + value = 0.0; + const double centered_x = position(0) - 0.5; + value(0) = -2.0 * position(0) + 8.0 * centered_x * centered_x * centered_x; + }); + const std::array rules{ + backend::GeometrySampleRule{.element = 0, .integration_rule = &mfem::IntRules.Get(mfem::Geometry::CUBE, 1)}, + backend::GeometrySampleRule{.element = 0, .integration_rule = &mfem::IntRules.Get(mfem::Geometry::CUBE, 3)} + }; + + // The cubic direction compresses at the central point, while its x derivative vanishes at the two-point Gauss abscissae. + const auto highest_only = backend::estimate_geometry_step_limit(fixture.mapper, fixture.displacement_space, fixture.compactification_coordinate, zero, direction, std::span(rules).subspan(1)); + const auto both = backend::estimate_geometry_step_limit(fixture.mapper, fixture.displacement_space, fixture.compactification_coordinate, zero, direction, rules); + REQUIRE(highest_only); + REQUIRE(both); + CHECK_FALSE(highest_only->limited_by_geometry); + CHECK(both->limited_by_geometry); + CHECK_THAT(both->boundary_step, WithinAbs(0.5, 2.0e-13)); + CHECK(both->limiting_rule == 0); + CHECK(both->sampled_point_count == static_cast(rules[0].integration_rule->GetNPoints() + rules[1].integration_rule->GetNPoints())); +} + +TEST_CASE("Determinant polynomial preserves first roots and dimensional degree", "[unit][deformation][geometry_step]") { + using deformation::detail::DeterminantPolynomial; + using deformation::detail::JacobianEntries; + using deformation::detail::determinant_polynomial; + using deformation::detail::evaluate_polynomial; + using deformation::detail::first_boundary_parameter; + + JacobianEntries base{}; + JacobianEntries direction{}; + for (int component = 0; component < 3; ++component) { + base[component][component] = 1.0; + direction[component][component] = -2.0; + } + for (int spatial_dimension = 1; spatial_dimension <= 3; ++spatial_dimension) { + const DeterminantPolynomial coefficients = determinant_polynomial(base, direction, spatial_dimension, 0.75, 0.125); + for (const double parameter : {0.0, 0.2, 0.6, 1.0}) { + const double expected = std::pow(1.0 - 1.5 * parameter, spatial_dimension) - 0.125; + CHECK_THAT(evaluate_polynomial(coefficients, parameter), WithinAbs(expected, 2.0e-15)); + } + } + + CHECK(std::isinf(first_boundary_parameter({1.0, 0.0, 0.0, 0.0}))); + CHECK(std::isinf(first_boundary_parameter({1.0, 2.0, 3.0, 4.0}))); + CHECK_THAT(first_boundary_parameter({1.0, -2.0, 0.0, 0.0}), WithinAbs(0.5, 2.0e-13)); + CHECK_THAT(first_boundary_parameter({1.0, -4.0, 4.0, 0.0}), WithinAbs(0.5, 2.0e-13)); + CHECK_THAT(first_boundary_parameter({1.0, -5.5, 8.5, -3.0}), WithinAbs(1.0 / 3.0, 2.0e-13)); + CHECK_THAT(first_boundary_parameter({1.0e300, -4.0e300, 4.0e300, 0.0}), WithinAbs(0.5, 2.0e-13)); + CHECK_THAT(first_boundary_parameter({1.0e300, -5.5e300, 8.5e300, -3.0e300}), WithinAbs(1.0 / 3.0, 2.0e-13)); +} diff --git a/tests/deformation/meson.build b/tests/deformation/meson.build new file mode 100644 index 00000000..e9329658 --- /dev/null +++ b/tests/deformation/meson.build @@ -0,0 +1,13 @@ +deformation_test_sources = files( + 'nonconforming.cpp', + 'reference_geometry_meanfield.cpp', + 'domain_actions_meanfield.cpp', + 'surface_embedding.cpp', + 'radial_extension.cpp', + 'composition.cpp', + 'computations.cpp', + 'surface_metric.cpp', + 'geometry_step.cpp', + 'surface_geometry.cpp', + 'equation_integration.cpp', +) diff --git a/tests/deformation/nonconforming.cpp b/tests/deformation/nonconforming.cpp new file mode 100644 index 00000000..d7b196ec --- /dev/null +++ b/tests/deformation/nonconforming.cpp @@ -0,0 +1,221 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include "mfem/fem/gridfunc.hpp" +#include "serif/backend/mfem/geometry/deformation/computations.hpp" +#include "serif/backend/mfem/model/deformation.hpp" +#include "serif/equations/registry.hpp" +#include "serif/tests/nonconforming.hpp" +#include "serif/tests/solver/stellar_fixture.hpp" +#include "serif/utils/dependencies/computation_cache.hpp" + +namespace { + namespace deformation = serif::geometry::deformation; + namespace backend = deformation::backend::mfem; + namespace fixture = serif::tests::solver; + + /** @brief Compare complete deformed coordinates on both sides of every hanging face. */ + void check_hanging_geometry(mfem::FiniteElementSpace& space, const std::vector& displacement) { + mfem::GridFunction field(&space); + REQUIRE(displacement.size() == static_cast(field.Size())); + std::ranges::copy(displacement, field.GetData()); + mfem::Mesh& mesh = *space.GetMesh(); + mfem::Vector first(3), second(3), first_motion(3), second_motion(3); + int hanging_faces = 0; + for (int face = 0; face < mesh.GetNumFaces(); ++face) { + const mfem::Mesh::FaceInformation information = mesh.GetFaceInformation(face); + if (!information.IsNonconformingFine() || !information.IsLocal()) { + continue; + } + ++hanging_faces; + mfem::FaceElementTransformations* transformation = mesh.GetFaceElementTransformations(face); + REQUIRE(transformation->Elem1 != nullptr); + REQUIRE(transformation->Elem2 != nullptr); + const mfem::IntegrationRule& rule = mfem::IntRules.Get(mfem::Geometry::SQUARE, 6); + for (int sample = 0; sample < rule.GetNPoints(); ++sample) { + transformation->SetAllIntPoints(&rule.IntPoint(sample)); + const mfem::IntegrationPoint& first_point = transformation->Elem1->GetIntPoint(); + const mfem::IntegrationPoint& second_point = transformation->Elem2->GetIntPoint(); + transformation->Elem1->Transform(first_point, first); + transformation->Elem2->Transform(second_point, second); + field.GetVectorValue(transformation->Elem1No, first_point, first_motion); + field.GetVectorValue(transformation->Elem2No, second_point, second_motion); + first += first_motion; + second += second_motion; + first -= second; + CHECK(first.Norml2() < 2.0e-11); + } + } + REQUIRE(hanging_faces > 0); + } + + /** @brief Check independent displacement extraction and its assembled transpose for either MFEM ordering. */ + void check_displacement_projection(mfem::FiniteElementSpace& space, const std::vector& component_values) { + serif::discretization::backend::mfem::TermBinding binding{space}; + const auto projection = serif::model::backend::mfem::displacement_projection(binding); + const mfem::SparseMatrix* restriction = space.GetConformingRestriction(); + REQUIRE(restriction != nullptr); + mfem::Vector local(space.GetVSize()), component(space.GetVSize()), expected(space.GetTrueVSize()); + for (int node = 0; node < space.GetNDofs(); ++node) { + for (int axis = 0; axis < 3; ++axis) { + const int column = node + axis * space.GetNDofs(); + component[column] = component_values[column]; + local[space.DofToVDof(node, axis)] = component[column]; + } + } + restriction->Mult(local, expected); + REQUIRE(binding.size() == expected.Size()); + mfem::Vector actual(binding.size()), dual(binding.size()), transpose(space.GetVSize()); + projection.action()->Mult(component, actual); + actual -= expected; + CHECK(actual.Norml2() < 2.0e-13); + for (int index = 0; index < dual.Size(); ++index) { + dual[index] = std::sin(0.13 * (index + 1)); + } + projection.action()->MultTranspose(dual, transpose); + const double reduced_work = expected * dual; + CHECK(std::abs(component * transpose - reduced_work) < 2.0e-11 * std::max(1.0, std::abs(reduced_work))); + const auto sparse = projection.assemble(); + REQUIRE(sparse); + mfem::Vector sparse_action(binding.size()), sparse_transpose(space.GetVSize()); + sparse->Mult(component, sparse_action); + sparse->MultTranspose(dual, sparse_transpose); + sparse_action -= expected; + sparse_transpose -= transpose; + CHECK(sparse_action.Norml2() < 2.0e-13); + CHECK(sparse_transpose.Norml2() < 2.0e-13); + } + + /** @brief Preserve convenience calls while leaving reusable scratch available for repeated actions. */ + void check_extension_call_forms(const backend::ConstrainedRadialExtension& extension, const std::vector& surface) { + auto workspace = extension.make_workspace(); + std::vector expected(extension.output_size()), actual(extension.output_size()); + REQUIRE(extension.apply(surface, expected, workspace)); + REQUIRE(extension.apply(surface, actual)); + CHECK(actual == expected); + std::vector force(extension.output_size()); + for (std::size_t index = 0; index < force.size(); ++index) { + force[index] = std::sin(0.19 * (index + 1)); + } + expected.resize(extension.input_size()); + actual.resize(extension.input_size()); + REQUIRE(extension.apply_transpose(force, expected, workspace)); + REQUIRE(extension.apply_transpose(force, actual)); + CHECK(actual == expected); + } +} + +TEST_CASE("STROID nonconforming geometry retains continuity and complete shape derivatives", "[integration][deformation][nonconforming]") { + const auto resources = std::make_shared(serif::tests::nonconforming::mesh(2, 0, 2)); + const auto finite_elements = fixture::make_discretization(resources, 2); + std::vector shape(9), direction(9); + for (std::size_t index = 0; index < shape.size(); ++index) { + shape[index] = 0.002 * std::sin(0.37 * (index + 1)); + direction[index] = std::cos(0.23 * (index + 1)); + } + const auto inputs = backend::make_deformation_inputs(finite_elements.deformation.model, finite_elements.deformation.reference, shape); + auto cache = serif::utils::dependencies::make_computation_cache( + backend::make_deformation_graph(), inputs); + REQUIRE(cache.try_prepare(inputs)); + const auto map = cache.get(); + const auto extension = cache.get(); + const std::vector& volume = cache.get(); + mfem::FiniteElementSpace& space = resources->reference_displacement; + REQUIRE(space.GetTrueVSize() < space.GetVSize()); + REQUIRE(map->output_size() == static_cast(space.GetVSize())); + + const auto raw_extension = deformation::make_radial_extension(cache.get()); + REQUIRE(raw_extension); + const auto raw = deformation::evaluate_displacement(*raw_extension, cache.get()); + REQUIRE(raw); + mfem::Vector local(space.GetVSize()), independent(space.GetTrueVSize()), expected(space.GetVSize()); + std::ranges::copy(*raw, local.GetData()); + space.GetConformingRestriction()->Mult(local, independent); + space.GetConformingProlongation()->Mult(independent, expected); + mfem::Vector defect(local); + defect -= expected; + REQUIRE(defect.Norml2() > 1.0e-10); + for (int index = 0; index < expected.Size(); ++index) { + CHECK(std::abs(volume[index] - expected[index]) < 2.0e-13); + } + check_hanging_geometry(space, volume); + check_displacement_projection(resources->reference_displacement, volume); + check_displacement_projection(resources->displacement_space, volume); + + const auto& reference = *cache.get(); + const std::vector& surface = cache.get(); + check_extension_call_forms(*extension, surface); + for (std::size_t node = 0; node < reference.scalar_dofs.size(); ++node) { + for (int component = 0; component < 3; ++component) { + CHECK(std::abs(volume[reference.scalar_dofs[node] + component * space.GetNDofs()] - surface[3 * node + component]) < 2.0e-13); + } + } + mfem::Array boundary(space.GetMesh()->bdr_attributes.Max()), boundary_dofs; + boundary = 0; + boundary[fixture::Schema::boundary_id() - 1] = 1; + space.GetEssentialVDofs(boundary, boundary_dofs); + for (int dof = 0; dof < boundary_dofs.Size(); ++dof) { + if (boundary_dofs[dof] != 0) { + CHECK(std::abs(volume[dof]) < 2.0e-13); + } + } + + auto workspace = deformation::make_deformation_workspace(*map); + std::vector tangent(volume.size()), force(volume.size()), pullback(shape.size()); + REQUIRE(deformation::apply_jacobian(*map, shape, direction, tangent, workspace)); + for (std::size_t index = 0; index < force.size(); ++index) { + force[index] = std::sin(0.17 * (index + 1)); + } + REQUIRE(deformation::apply_jacobian_transpose(*map, shape, force, pullback, workspace)); + const double volume_work = std::inner_product(tangent.begin(), tangent.end(), force.begin(), 0.0); + const double shape_work = std::inner_product(direction.begin(), direction.end(), pullback.begin(), 0.0); + CHECK(std::abs(volume_work - shape_work) < 2.0e-11 * std::max({1.0, std::abs(volume_work), std::abs(shape_work)})); + std::vector plus(shape), minus(shape); + constexpr double step = 1.0e-6; + for (std::size_t index = 0; index < shape.size(); ++index) { + plus[index] += step * direction[index]; + minus[index] -= step * direction[index]; + } + const auto positive = deformation::evaluate_displacement(*map, plus); + const auto negative = deformation::evaluate_displacement(*map, minus); + REQUIRE(positive); + REQUIRE(negative); + double error_squared = 0.0, norm_squared = 0.0; + for (std::size_t index = 0; index < tangent.size(); ++index) { + const double error = ((*positive)[index] - (*negative)[index]) / (2.0 * step) - tangent[index]; + error_squared += error * error; + norm_squared += tangent[index] * tangent[index]; + } + REQUIRE(norm_squared > 0.0); + CHECK(std::sqrt(error_squared / norm_squared) < 2.0e-9); + backend::ConstrainedRadialExtension::Workspace wrong_workspace; + std::vector output(volume.size()); + CHECK_FALSE(extension->apply(surface, output, wrong_workspace)); +} + +TEST_CASE("Conforming deformation graph preserves the original radial action bit for bit", "[unit][deformation][nonconforming][parity]") { + const auto resources = fixture::make_resources(1.0, 0, 2); + const auto finite_elements = fixture::make_discretization(resources, 2); + const std::vector shape{0.001, -0.002, 0.003, -0.001, 0.002, 0.001, -0.003, 0.002, 0.001}; + const auto inputs = backend::make_deformation_inputs(finite_elements.deformation.model, finite_elements.deformation.reference, shape); + auto cache = serif::utils::dependencies::make_computation_cache( + backend::make_deformation_graph(), inputs); + REQUIRE(cache.try_prepare(inputs)); + REQUIRE(resources->reference_displacement.GetConformingProlongation() == nullptr); + check_extension_call_forms(*cache.get(), cache.get()); + const auto radial = deformation::make_radial_extension(cache.get()); + REQUIRE(radial); + const auto expected = deformation::evaluate_displacement(*radial, cache.get()); + REQUIRE(expected); + const std::vector& actual = cache.get(); + REQUIRE(actual.size() == expected->size()); + for (std::size_t index = 0; index < actual.size(); ++index) { + CHECK(std::bit_cast(actual[index]) == std::bit_cast((*expected)[index])); + } +} diff --git a/tests/deformation/radial_extension.cpp b/tests/deformation/radial_extension.cpp new file mode 100644 index 00000000..0a66baeb --- /dev/null +++ b/tests/deformation/radial_extension.cpp @@ -0,0 +1,475 @@ +#include +#include +#include +#include +#include +#include +#include "mfem/fem/gridfunc.hpp" +#include "serif/tests/deformation/mesh.hpp" + +namespace +{ + namespace deformation = serif::geometry::deformation; + namespace backend = deformation::backend::mfem; + using serif::tests::deformation::PairedMesh; + + /** @brief Expose a numbering mismatch without requiring unsupported variable-order nonconforming meshes. */ + struct ReorderedCompanionSpace : ::mfem::FiniteElementSpace + { + using ::mfem::FiniteElementSpace::FiniteElementSpace; + + /** @brief Preserve the basis, collection and total size while swapping two local scalar coefficients. */ + void GetElementDofs(int element, ::mfem::Array& dofs, ::mfem::DofTransformation& transformation) const override + { + ::mfem::FiniteElementSpace::GetElementDofs(element, dofs, transformation); + std::swap(dofs[0], dofs[1]); + } + }; + + /** @brief Keep action tests concise while ensuring compilation failure is reported at its source. */ + [[nodiscard]] std::shared_ptr interpolation(PairedMesh& mesh) + { + auto result = mesh.interpolation(); + if (!result) + { + INFO(result.error().message); + REQUIRE(result.has_value()); + } + return std::make_shared(std::move(*result)); + } + + /** @brief Preserve the MeanField finite-difference normalization, including its epsilon floor. */ + [[nodiscard]] double relative_error(std::span actual, std::span expected) + { + double squared_error = 0.0, squared_norm = 0.0; + for (std::size_t entry = 0; entry < actual.size(); ++entry) + { + const double difference = actual[entry] - expected[entry]; + squared_error += difference * difference; + squared_norm += expected[entry] * expected[entry]; + } + return std::sqrt(squared_error) / std::max(std::sqrt(squared_norm), std::numeric_limits::epsilon()); + } +} // namespace + +TEST_CASE("Radial interpolation preserves constants on paired logical rays and canonical physical surface nodes", "[unit][deformation]") +{ + // Cubic nodes on a refined core project between surface nodes; the unrefined quadratic fixture is exactly aligned. + PairedMesh mesh(1, 0.0, 3); + const auto data = interpolation(mesh); + const auto logical_positions = backend::sample_reference_positions(mesh.scalar_space, *mesh.generated.reference_mesh); + REQUIRE(logical_positions.has_value()); + const auto surface = backend::sample_reference_surface(mesh.scalar_space, PairedMesh::surface_attribute); + REQUIRE(surface.has_value()); + CHECK(surface->scalar_dofs == data->data().surface_scalar_dofs); + CHECK(std::is_sorted(surface->scalar_dofs.begin(), surface->scalar_dofs.end())); + CHECK(surface->positions.size() == data->surface_node_count()); + CHECK(data->data().stellar_radius > 0.0); + CHECK(data->data().infinity_radius > data->data().stellar_radius); + CHECK(data->scalar_dof_count() == static_cast(mesh.scalar_space.GetNDofs())); + + int interpolated_rows = 0; + int centers = 0; + for (std::size_t node = 0; node < data->scalar_dof_count(); ++node) + { + const int begin = data->data().row_offsets[node]; + const int end = data->data().row_offsets[node + 1]; + if (data->data().logical_radii[node] < 1.0e-14) + { + CHECK(begin == end); + ++centers; + continue; + } + const double sum = std::accumulate(data->data().interpolation_weights.begin() + begin, data->data().interpolation_weights.begin() + end, 0.0); + CHECK(sum == Catch::Approx(1.0).margin(2.0e-12)); + for (std::size_t component = 0; component < 3; ++component) + { + double interpolated_position = 0.0; + for (int entry = begin; entry < end; ++entry) + { + const int surface_dof = data->data().surface_scalar_dofs[data->data().surface_columns[entry]]; + interpolated_position += data->data().interpolation_weights[entry] * (*logical_positions)[surface_dof][component]; + } + const double projected_position = data->data().stellar_radius * (*logical_positions)[node][component] / data->data().logical_radii[node]; + CHECK(interpolated_position == Catch::Approx(projected_position).margin(2.0e-11)); + } + if (end - begin > 1) + { + ++interpolated_rows; + } + } + CHECK(centers > 0); + CHECK(interpolated_rows > 0); +} + +TEST_CASE("Fused radial extension reproduces the surface fixes center and infinity and preserves original powers", "[unit][deformation]") +{ + PairedMesh mesh; + const auto data = interpolation(mesh); + const auto quadratic = deformation::make_radial_extension(data); + const auto cubic = deformation::make_radial_extension(data, {.radial_power = 3.0}); + REQUIRE(quadratic.has_value()); + REQUIRE(cubic.has_value()); + CHECK(quadratic->interpolation() == cubic->interpolation()); + std::vector surface(quadratic->input_size()); + for (std::size_t node = 0; node < data->surface_node_count(); ++node) + { + surface[3 * node] = 0.02; + surface[3 * node + 1] = -0.03; + surface[3 * node + 2] = 0.05; + } + std::vector output(quadratic->output_size()); + REQUIRE(quadratic->apply(surface, output)); + int shared_nodes = 0, fixed_centers = 0, fixed_infinity = 0; + for (std::size_t node = 0; node < data->scalar_dof_count(); ++node) + { + const double radius = data->data().logical_radii[node]; + double weight = 0.0; + if (data->data().stellar_support[node]) + { + weight = std::pow(radius / data->data().stellar_radius, 2.0); + CHECK(cubic->radial_weights()[node] == Catch::Approx(std::pow(radius / data->data().stellar_radius, 3.0)).margin(2.0e-14)); + } + else if (data->data().vacuum_support[node]) + { + weight = (data->data().infinity_radius - radius) / (data->data().infinity_radius - data->data().stellar_radius); + CHECK(cubic->radial_weights()[node] == quadratic->radial_weights()[node]); + } + for (std::size_t component = 0; component < 3; ++component) + { + CHECK(output[node + component * data->scalar_dof_count()] == Catch::Approx(weight * surface[component]).margin(2.0e-13)); + } + if (radius < 1.0e-14) + { + CHECK(quadratic->radial_weights()[node] == 0.0); + ++fixed_centers; + } + if (std::abs(radius - data->data().infinity_radius) < 1.0e-14) + { + CHECK(quadratic->radial_weights()[node] == Catch::Approx(0.0).margin(1.0e-14)); + ++fixed_infinity; + } + if (data->data().stellar_support[node] && data->data().vacuum_support[node]) + { + CHECK(quadratic->radial_weights()[node] == Catch::Approx(1.0).margin(2.0e-14)); + ++shared_nodes; + } + } + CHECK(fixed_centers > 0); + CHECK(fixed_infinity > 0); + CHECK(shared_nodes == static_cast(data->surface_node_count())); + + for (std::size_t entry = 0; entry < surface.size(); ++entry) + { + surface[entry] = std::sin(0.17 * (entry + 1)); + } + REQUIRE(quadratic->apply(surface, output)); + for (std::size_t node = 0; node < data->surface_node_count(); ++node) + { + for (std::size_t component = 0; component < 3; ++component) + { + CHECK(output[data->data().surface_scalar_dofs[node] + component * data->scalar_dof_count()] == Catch::Approx(surface[3 * node + component]).margin(2.0e-12)); + } + } +} + +TEST_CASE("Radial extension derivatives retain centered differences and exact transpose work after refinement", "[unit][deformation]") +{ + for (const int refinement : {0, 1}) + { + CAPTURE(refinement); + PairedMesh mesh(refinement, 0.1); + const auto extension = deformation::make_radial_extension(interpolation(mesh)); + REQUIRE(extension.has_value()); + std::vector direction(extension->input_size()), plus(direction.size()), minus(direction.size()); + constexpr double step = 1.0e-6; + for (std::size_t entry = 0; entry < direction.size(); ++entry) + { + const double index = entry + 1; + direction[entry] = std::cos(0.13 * index) - 0.2 * std::sin(0.31 * index); + const double surface = 0.01 * std::sin(0.17 * index); + plus[entry] = surface + step * direction[entry]; + minus[entry] = surface - step * direction[entry]; + } + std::vector plus_output(extension->output_size()), minus_output(plus_output.size()), derivative(plus_output.size()), force(plus_output.size()), surface_force(direction.size()); + REQUIRE(extension->apply(plus, plus_output)); + REQUIRE(extension->apply(minus, minus_output)); + REQUIRE(extension->apply(direction, derivative)); + for (std::size_t entry = 0; entry < derivative.size(); ++entry) + { + plus_output[entry] = (plus_output[entry] - minus_output[entry]) / (2.0 * step); + const double index = entry + 1; + force[entry] = std::sin(0.07 * index) + 0.3 * std::cos(0.11 * index); + } + CHECK(relative_error(derivative, plus_output) < 2.0e-10); + REQUIRE(extension->apply_transpose(force, surface_force)); + const double volume_work = std::inner_product(derivative.begin(), derivative.end(), force.begin(), 0.0); + const double surface_work = std::inner_product(direction.begin(), direction.end(), surface_force.begin(), 0.0); + CHECK(std::abs(volume_work - surface_work) <= 5.0e-13 * std::max({1.0, std::abs(volume_work), std::abs(surface_work)})); + } +} + +TEST_CASE("Logical radial interpolation is independent of physical flattening", "[unit][deformation]") +{ + PairedMesh spherical; + PairedMesh oblate(0, 0.15); + const auto spherical_data = interpolation(spherical); + const auto oblate_data = interpolation(oblate); + CHECK(spherical_data->data().row_offsets == oblate_data->data().row_offsets); + CHECK(spherical_data->data().surface_columns == oblate_data->data().surface_columns); + CHECK(spherical_data->data().interpolation_weights == oblate_data->data().interpolation_weights); + CHECK(spherical_data->data().logical_radii == oblate_data->data().logical_radii); + const auto spherical_surface = backend::sample_reference_surface(spherical.scalar_space, PairedMesh::surface_attribute); + const auto oblate_surface = backend::sample_reference_surface(oblate.scalar_space, PairedMesh::surface_attribute); + REQUIRE(spherical_surface.has_value()); + REQUIRE(oblate_surface.has_value()); + CHECK(spherical_surface->positions != oblate_surface->positions); +} + +TEST_CASE("Radial compilation rejects unsupported spaces broken mesh pairing and invalid foliation", "[unit][deformation]") +{ + PairedMesh mesh; + SECTION("Wrong vector storage") + { + ::mfem::FiniteElementSpace interleaved(mesh.generated.mesh.get(), &mesh.collection, 3, ::mfem::Ordering::byVDIM); + CHECK_FALSE(backend::build_radial_interpolation(mesh.scalar_space, interleaved, *mesh.generated.reference_mesh, mesh.stellar_elements, mesh.vacuum_elements, 1, 2)); + } + SECTION("Different companion collection") + { + ::mfem::H1_FECollection different_collection(2, 3); + ::mfem::FiniteElementSpace different_vector(mesh.generated.mesh.get(), &different_collection, 3); + CHECK_FALSE(backend::build_radial_interpolation(mesh.scalar_space, different_vector, *mesh.generated.reference_mesh, mesh.stellar_elements, mesh.vacuum_elements, 1, 2)); + } + SECTION("Matching sizes and collection with different scalar coefficient numbering") + { + ReorderedCompanionSpace reordered(mesh.generated.mesh.get(), &mesh.collection, 3, ::mfem::Ordering::byNODES); + REQUIRE(reordered.GetVSize() == mesh.vector_space.GetVSize()); + REQUIRE(reordered.FEColl() == mesh.scalar_space.FEColl()); + const auto result = backend::build_radial_interpolation(mesh.scalar_space, reordered, *mesh.generated.reference_mesh, mesh.stellar_elements, mesh.vacuum_elements, 1, 2); + REQUIRE_FALSE(result); + CHECK(result.error().reason == deformation::DeformationFailureReason::incompatible_discretization); + CHECK(result.error().element == 0); + } + SECTION("Discontinuous scalar basis") + { + ::mfem::L2_FECollection discontinuous(2, 3); + ::mfem::FiniteElementSpace scalar(mesh.generated.mesh.get(), &discontinuous); + CHECK_FALSE(backend::sample_reference_surface(scalar, 1)); + } + SECTION("Missing boundary") + { + CHECK_FALSE(backend::sample_reference_surface(mesh.scalar_space, 77)); + CHECK_FALSE(backend::build_radial_interpolation(mesh.scalar_space, mesh.vector_space, *mesh.generated.reference_mesh, mesh.stellar_elements, mesh.vacuum_elements, 1, 77)); + } + SECTION("Malformed element support") + { + mesh.stellar_elements.pop_back(); + CHECK_FALSE(mesh.interpolation()); + } + SECTION("Non-binary element support") + { + mesh.stellar_elements[0] = -1; + CHECK_FALSE(mesh.interpolation()); + } + SECTION("Reversed logical shells") + { + CHECK_FALSE(backend::build_radial_interpolation(mesh.scalar_space, mesh.vector_space, *mesh.generated.reference_mesh, mesh.stellar_elements, mesh.vacuum_elements, 2, 1)); + } + SECTION("Inconsistent paired boundary attributes") + { + mesh.generated.reference_mesh->GetBdrElement(0)->SetAttribute(77); + CHECK_FALSE(mesh.interpolation()); + } + SECTION("Inconsistent paired element attributes") + { + mesh.generated.reference_mesh->GetElement(0)->SetAttribute(77); + CHECK_FALSE(mesh.interpolation()); + } + SECTION("Inconsistent paired element orientation") + { + int* vertices = mesh.generated.reference_mesh->GetElement(0)->GetVertices(); + std::swap(vertices[0], vertices[1]); + CHECK_FALSE(mesh.interpolation()); + } + SECTION("Physical geometry is not a logical cubical foliation") + { + CHECK_FALSE(backend::build_radial_interpolation(mesh.scalar_space, mesh.vector_space, *mesh.generated.mesh, mesh.stellar_elements, mesh.vacuum_elements, 1, 2)); + } + SECTION("Vacuum support incorrectly includes the center") + { + std::fill(mesh.vacuum_elements.begin(), mesh.vacuum_elements.end(), 1); + CHECK_FALSE(mesh.interpolation()); + } +} + +TEST_CASE("Radial actions reject invalid values lengths and overlapping buffers", "[unit][deformation]") +{ + PairedMesh mesh; + const auto data = interpolation(mesh); + const auto extension = deformation::make_radial_extension(data); + REQUIRE(extension.has_value()); + CHECK_FALSE(deformation::make_radial_extension(data, {.radial_power = 0.5})); + CHECK_FALSE(deformation::make_radial_extension(data, {.radial_power = std::numeric_limits::infinity()})); + CHECK_FALSE(deformation::make_radial_extension(nullptr)); + std::vector surface(extension->input_size(), 1.0), volume(extension->output_size(), 7.0); + CHECK_FALSE(extension->apply(std::span(surface).first(surface.size() - 1), volume)); + CHECK(std::all_of(volume.begin(), volume.end(), [](double value) + { + return value == 7.0; + })); + surface[0] = std::numeric_limits::quiet_NaN(); + CHECK_FALSE(extension->apply(surface, volume)); + surface[0] = 1.0; + volume[0] = std::numeric_limits::infinity(); + CHECK_FALSE(extension->apply_transpose(volume, surface)); + std::vector overlap(extension->input_size() + extension->output_size(), 1.0); + CHECK_FALSE(extension->apply(std::span(overlap).first(extension->input_size()), std::span(overlap).first(extension->output_size()))); + CHECK_FALSE(extension->apply_transpose(std::span(overlap).first(extension->output_size()), std::span(overlap).first(extension->input_size()))); + volume[0] = 7.0; + REQUIRE(extension->apply(surface, volume)); + REQUIRE(extension->apply_transpose(volume, surface)); +} + +TEST_CASE("Imported radial interpolation is checked once and owns its validated data", "[unit][deformation]") +{ + PairedMesh mesh; + const auto data = interpolation(mesh); + deformation::RadialInterpolationData malformed = data->data(); + malformed.surface_columns.front() = static_cast(data->surface_node_count()); + CHECK_FALSE(deformation::make_radial_interpolation(malformed)); + malformed = data->data(); + malformed.row_offsets.back() += 1; + CHECK_FALSE(deformation::make_radial_interpolation(malformed)); + malformed = data->data(); + const int surface_dof = data->data().surface_scalar_dofs.front(); + for (int entry = malformed.row_offsets[surface_dof]; entry < malformed.row_offsets[surface_dof + 1]; ++entry) + { + malformed.interpolation_weights[entry] *= 0.5; + } + CHECK_FALSE(deformation::make_radial_interpolation(malformed)); + + malformed = data->data(); + const int first_entry = malformed.row_offsets[surface_dof]; + const int next_entry = malformed.row_offsets[surface_dof + 1]; + const int removed_entries = next_entry - first_entry; + REQUIRE(removed_entries > 0); + malformed.surface_columns.erase(malformed.surface_columns.begin() + first_entry, malformed.surface_columns.begin() + next_entry); + malformed.interpolation_weights.erase(malformed.interpolation_weights.begin() + first_entry, malformed.interpolation_weights.begin() + next_entry); + for (std::size_t row = surface_dof + 1; row < malformed.row_offsets.size(); ++row) + { + malformed.row_offsets[row] -= removed_entries; + } + CHECK_FALSE(deformation::make_radial_interpolation(malformed)); + SECTION("Nonfinite interpolation weight") + { + malformed = data->data(); + malformed.interpolation_weights.front() = std::numeric_limits::quiet_NaN(); + CHECK_FALSE(deformation::make_radial_interpolation(malformed)); + } + SECTION("Invalid material support") + { + malformed = data->data(); + malformed.stellar_support.front() = 2; + CHECK_FALSE(deformation::make_radial_interpolation(malformed)); + } + SECTION("Nonfinite logical radius") + { + malformed = data->data(); + malformed.logical_radii.front() = std::numeric_limits::infinity(); + CHECK_FALSE(deformation::make_radial_interpolation(malformed)); + } + SECTION("Imported storage cannot mutate a compiled table") + { + deformation::RadialInterpolationData imported = data->data(); + const auto checked = deformation::make_radial_interpolation(imported); + REQUIRE(checked); + imported.interpolation_weights.front() = std::numeric_limits::quiet_NaN(); + CHECK(checked->data().interpolation_weights == data->data().interpolation_weights); + const auto shared = std::make_shared(*checked); + const auto first = deformation::make_radial_extension(shared); + const auto second = deformation::make_radial_extension(shared, {.radial_power = 3.0}); + REQUIRE(first); + REQUIRE(second); + CHECK(first->interpolation() == shared); + CHECK(second->interpolation() == shared); + CHECK_FALSE(std::ranges::equal(first->radial_weights(), second->radial_weights())); + } +} + +TEST_CASE("Extended constant vectors retain scalar finite element interpolation between volume nodes", "[unit][deformation]") +{ + PairedMesh mesh; + const auto extension = deformation::make_radial_extension(interpolation(mesh)); + REQUIRE(extension.has_value()); + constexpr std::array components{1.25, -0.75, 2.5}; + std::vector surface(extension->input_size()), volume(extension->output_size()); + for (std::size_t node = 0; node < surface.size() / 3; ++node) + { + for (std::size_t component = 0; component < 3; ++component) + { + surface[3 * node + component] = components[component]; + } + } + REQUIRE(extension->apply(surface, volume)); + ::mfem::GridFunction displacement(&mesh.vector_space); + ::mfem::GridFunction radial_weight(&mesh.scalar_space); + std::copy(volume.begin(), volume.end(), displacement.GetData()); + std::copy(extension->radial_weights().begin(), extension->radial_weights().end(), radial_weight.GetData()); + ::mfem::Vector value(3); + for (int element = 0; element < mesh.generated.mesh->GetNE(); ++element) + { + const ::mfem::IntegrationPoint& point = ::mfem::Geometries.GetCenter(mesh.generated.mesh->GetElementGeometry(element)); + const double weight = radial_weight.GetValue(element, point); + displacement.GetVectorValue(element, point, value); + for (int component = 0; component < 3; ++component) + { + CHECK(value[component] == Catch::Approx(components[component] * weight).margin(2.0e-11)); + } + } +} + +TEST_CASE("Small radial surface motion preserves orientation before and after uniform refinement", "[unit][deformation]") +{ + for (const int refinement : {0, 1}) + { + CAPTURE(refinement); + PairedMesh mesh(refinement); + const auto surface_geometry = backend::sample_reference_surface(mesh.scalar_space, PairedMesh::surface_attribute); + REQUIRE(surface_geometry.has_value()); + const auto extension = deformation::make_radial_extension(interpolation(mesh)); + REQUIRE(extension.has_value()); + std::vector surface(extension->input_size()), volume(extension->output_size()); + for (std::size_t node = 0; node < surface_geometry->positions.size(); ++node) + { + const std::array& position = surface_geometry->positions[node]; + const double radius = std::sqrt(position[0] * position[0] + position[1] * position[1] + position[2] * position[2]); + const double polar_direction = position[2] / radius; + const double amplitude = 0.006 - 0.001 * 0.5 * (3.0 * polar_direction * polar_direction - 1.0); + for (std::size_t component = 0; component < 3; ++component) + { + surface[3 * node + component] = amplitude * position[component] / radius; + } + } + REQUIRE(extension->apply(surface, volume)); + ::mfem::GridFunction displacement(&mesh.vector_space); + std::copy(volume.begin(), volume.end(), displacement.GetData()); + double minimum_determinant = std::numeric_limits::infinity(); + ::mfem::DenseMatrix gradient; + for (int element = 0; element < mesh.generated.mesh->GetNE(); ++element) + { + ::mfem::ElementTransformation& transformation = *mesh.generated.mesh->GetElementTransformation(element); + const ::mfem::IntegrationRule& rule = ::mfem::IntRules.Get(transformation.GetGeometryType(), mesh.vector_space.GetFE(element)->GetOrder() + 2); + for (int point = 0; point < rule.GetNPoints(); ++point) + { + transformation.SetIntPoint(&rule.IntPoint(point)); + displacement.GetVectorGradient(transformation, gradient); + for (int component = 0; component < 3; ++component) + { + gradient(component, component) += 1.0; + } + minimum_determinant = std::min(minimum_determinant, gradient.Det()); + } + } + CHECK(minimum_determinant > 0.0); + } +} diff --git a/tests/deformation/reference_geometry_meanfield.cpp b/tests/deformation/reference_geometry_meanfield.cpp new file mode 100644 index 00000000..ef546a02 --- /dev/null +++ b/tests/deformation/reference_geometry_meanfield.cpp @@ -0,0 +1,210 @@ +#include +#include +#include +#include +#include + +#include +#include +#include "mfem/fem/coefficient.hpp" +#include "mfem/fem/gridfunc.hpp" +#include "serif/tests/equations/test_equations.hpp" +#include "serif/backend/mfem/discretization/boundary_trace.hpp" +#include "serif/discretization/blocks/fields.hpp" +#include "serif/backend/mfem/geometry/deformation/inputs.hpp" +#include "serif/backend/mfem/geometry/deformation/reference_geometry.hpp" +#include "serif/backend/mfem/geometry/deformation/surface_metric.hpp" +#include "serif/tests/deformation/mesh.hpp" + +namespace +{ + namespace deformation = serif::geometry::deformation; + namespace backend = deformation::backend::mfem; + namespace finite_elements = serif::discretization::backend::mfem; + namespace domain = serif::discretization::domain; + using Schema = domain::schema::CoreEnvelopeVacuumDomainSchema; + using Catch::Matchers::WithinAbs; + using Catch::Matchers::WithinRel; + + /** @brief Retain the original eight-coordinate indexing example without imposing a higher-order physical field. */ + struct LinearEnthalpy : serif::discretization::blocks::term<::serif::tests::equations::enthalpy> + { + using FunctionalSpace = serif::discretization::functional_space::DiscreteFunctionalSpace; + using Support = domain::StellarDomains; + using Quantity = serif::dimensions::SpecificEnthalpy; + struct value : serif::discretization::blocks::value_block_base + { + }; + struct residual : serif::discretization::blocks::residual_block_base + { + }; + }; + + using AlternateBoundarySchema = domain::schema::DomainSchema, domain::ids::BoundaryID>, domain::relation::lists::RelationList<>>; + /** @brief Resolve boundary coordinates independently using MFEM's serial essential true-DOF list. */ + [[nodiscard]] std::vector essential_dofs(const mfem::FiniteElementSpace& space, const int attribute) + { + mfem::Array marker(space.GetMesh()->bdr_attributes.Max()), selected; + marker = 0; + marker[attribute - 1] = 1; + space.GetEssentialTrueDofs(marker, selected); + return std::vector(selected.begin(), selected.end()); + } +} // namespace + +TEST_CASE("MeanField canonical scalar coordinates retain exact gather and transpose scatter indexing", "[unit][deformation][meanfield]") +{ + mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(1, 1, 1, mfem::Element::HEXAHEDRON); + mfem::H1_FECollection collection(1, 3); + mfem::FiniteElementSpace space(&mesh, &collection); + finite_elements::TermBinding binding(space); + REQUIRE(binding.size() == 8); + const mfem::Array selected{1, 3, 6}; + const auto rows = std::make_shared(binding, selected); + const auto trace = finite_elements::make_boundary_trace(rows, binding); + mfem::Vector full(binding.size()), sampled(rows->size()), scattered(binding.size()); + for (int dof = 0; dof < full.Size(); ++dof) + { + full[dof] = 10.0 + dof; + } + trace.interpolation().Mult(full, sampled); + CHECK(sampled[0] == 11.0); + CHECK(sampled[1] == 13.0); + CHECK(sampled[2] == 16.0); + trace.interpolation().MultTranspose(sampled, scattered); + for (int dof = 0; dof < scattered.Size(); ++dof) + { + CHECK(scattered[dof] == (dof == 1 || dof == 3 || dof == 6 ? full[dof] : 0.0)); + } + CHECK_THROWS_AS((finite_elements::DofSelection{binding, mfem::Array{3, 1}}), std::invalid_argument); + CHECK_THROWS_AS((finite_elements::DofSelection{binding, mfem::Array{1, 1}}), std::invalid_argument); + CHECK_THROWS_AS((finite_elements::DofSelection{binding, mfem::Array{-1}}), std::invalid_argument); + CHECK_THROWS_AS((finite_elements::DofSelection{binding, mfem::Array{8}}), std::invalid_argument); +} + +TEST_CASE("MeanField surface coordinates resolve semantic boundary IDs and scalar companion nodes", "[unit][deformation][meanfield]") +{ + serif::tests::deformation::PairedMesh fixture(0, 0.1, 3, 4); + const auto primary = backend::deformation_discretization(fixture.scalar_space, fixture.vector_space, *fixture.generated.reference_mesh); + const auto alternate = backend::deformation_discretization(fixture.scalar_space, fixture.vector_space, *fixture.generated.reference_mesh); + const auto stellar = backend::sample_reference_surface(fixture.scalar_space, primary.surface_attribute); + const auto infinity = backend::select_surface_scalar_dofs(fixture.scalar_space, alternate.surface_attribute); + REQUIRE(stellar); + REQUIRE(infinity); + CHECK(stellar->scalar_dofs == essential_dofs(fixture.scalar_space, 1)); + CHECK(*infinity == essential_dofs(fixture.scalar_space, 2)); + CHECK(stellar->scalar_dofs != *infinity); + CHECK(std::is_sorted(stellar->scalar_dofs.begin(), stellar->scalar_dofs.end())); + REQUIRE(stellar->scalar_dofs.size() > 0); + CHECK(stellar->scalar_dofs.size() < static_cast(fixture.scalar_space.GetTrueVSize())); + const auto repeated = backend::select_surface_scalar_dofs(fixture.scalar_space, primary.surface_attribute); + REQUIRE(repeated); + CHECK(*repeated == stellar->scalar_dofs); + + for (const mfem::Ordering::Type ordering : {mfem::Ordering::byNODES, mfem::Ordering::byVDIM}) + { + mfem::FiniteElementSpace vector_space(fixture.generated.mesh.get(), &fixture.collection, 3, ordering); + const std::vector vector_dofs = essential_dofs(vector_space, primary.surface_attribute); + CHECK(vector_dofs.size() == 3 * stellar->scalar_dofs.size()); + mfem::VectorFunctionCoefficient coordinates(3, [](const mfem::Vector& position, mfem::Vector& value) + { + value = position; + }); + mfem::GridFunction projected(&vector_space); + projected.ProjectCoefficient(coordinates); + for (std::size_t node = 0; node < stellar->positions.size(); ++node) + { + for (int component = 0; component < 3; ++component) + { + CHECK_THAT(stellar->positions[node][component], WithinAbs(projected[vector_space.DofToVDof(stellar->scalar_dofs[node], component)], 2.0e-13)); + } + } + } +} + +TEST_CASE("MeanField stellar surface metrics include tagged internal interfaces", "[unit][deformation][meanfield]") +{ + serif::tests::deformation::PairedMesh fixture; + int selected_faces = 0; + for (int boundary = 0; boundary < fixture.generated.mesh->GetNBE(); ++boundary) + { + if (fixture.generated.mesh->GetBdrAttribute(boundary) == fixture.surface_attribute) + { + const int face = fixture.generated.mesh->GetBdrElementFaceIndex(boundary); + REQUIRE(face >= 0); + CHECK(fixture.generated.mesh->GetFaceInformation(face).IsOfFaceType(mfem::FaceType::Interior)); + ++selected_faces; + } + } + REQUIRE(selected_faces > 0); + const auto reference = backend::sample_reference_surface(fixture.scalar_space, fixture.surface_attribute); + REQUIRE(reference); + const auto embedding = deformation::build_surface_embedding(deformation::NodalRadialSurface{}, reference->positions); + REQUIRE(embedding); + const auto metric = backend::surface_parameter_mass_diagonal(fixture.scalar_space, fixture.surface_attribute, *reference, *embedding); + REQUIRE(metric); + REQUIRE(metric->size() == reference->scalar_dofs.size()); + for (const double diagonal : *metric) + { + CHECK(std::isfinite(diagonal)); + CHECK(diagonal > 0.0); + } +} + +TEST_CASE("MeanField STROID spherical volume retains its original mesh accuracy", "[integration][deformation][geometry][meanfield]") +{ + // Match sandbox.smesh: two refinements, quartic geometry, stellar radius 1 and reference infinity 5. + auto generated = serif::tests::deformation::make_mesh(2, 0.0, 4, 5.0); + long double volume = 0.0L; + int stellar_elements = 0; + for (int element = 0; element < generated.mesh->GetNE(); ++element) + { + if (!Schema::domain_id_belongs_to(generated.mesh->GetAttribute(element))) + { + continue; + } + mfem::ElementTransformation& transformation = *generated.mesh->GetElementTransformation(element); + const mfem::IntegrationRule& rule = mfem::IntRules.Get(transformation.GetGeometryType(), transformation.OrderW()); + for (int point = 0; point < rule.GetNPoints(); ++point) + { + const mfem::IntegrationPoint& sample = rule.IntPoint(point); + transformation.SetIntPoint(&sample); + volume += sample.weight * transformation.Weight(); + } + ++stellar_elements; + } + REQUIRE(stellar_elements > 0); + CHECK_THAT(static_cast(volume), WithinRel(4.0 * std::numbers::pi / 3.0, 1.0e-6)); +} + +TEST_CASE("Shared MFEM reference tracking distinguishes coordinate and label dependencies", "[unit][deformation][reference_tracking]") { + namespace shared = serif::backend::mfem::shared; + mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(1, 1, 1, mfem::Element::HEXAHEDRON); + mfem::H1_FECollection collection(1, 3); + mfem::FiniteElementSpace space(&mesh, &collection); + const auto topology = shared::mesh_snapshot(&mesh, false, false); + const auto materials = shared::mesh_snapshot(&mesh, false, true); + const auto coordinates = shared::mesh_snapshot(&mesh, true, true); + const auto original_space = shared::space_snapshot(space); + REQUIRE(shared::mesh_matches(topology, &mesh)); + REQUIRE(shared::mesh_matches(coordinates, &mesh)); + + mesh.Transform([](const mfem::Vector& position, mfem::Vector& transformed) { + transformed = position; + transformed[0] *= 1.2; + }); + CHECK(shared::mesh_matches(topology, &mesh)); + CHECK(shared::mesh_matches(materials, &mesh)); + CHECK_FALSE(shared::mesh_matches(coordinates, &mesh)); + CHECK(shared::space_snapshot(space) == original_space); + + mesh.GetElement(0)->SetAttribute(9); + CHECK(shared::mesh_matches(topology, &mesh)); + CHECK_FALSE(shared::mesh_matches(materials, &mesh)); + mesh.GetBdrElement(0)->SetAttribute(9); + CHECK_FALSE(shared::mesh_matches(topology, &mesh)); + mfem::FiniteElementSpace other(&mesh, &collection); + CHECK_FALSE(shared::space_snapshot(other) == original_space); + CHECK_FALSE(shared::mesh_matches(topology, nullptr)); + CHECK(shared::mesh_matches(shared::mesh_snapshot(nullptr, true, true), nullptr)); +} diff --git a/tests/deformation/surface_embedding.cpp b/tests/deformation/surface_embedding.cpp new file mode 100644 index 00000000..b6c47d2b --- /dev/null +++ b/tests/deformation/surface_embedding.cpp @@ -0,0 +1,369 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "serif/geometry/deformation/actions.hpp" +#include "serif/geometry/deformation/surface_embedding.hpp" + +namespace { + namespace deformation = serif::geometry::deformation; + + /** @brief Resolve low harmonic degrees without relying on a particular mesh backend. */ + [[nodiscard]] std::vector> surface_positions(const std::array& center = {}) { + std::vector> positions; + constexpr std::size_t node_count = 96; + const double golden_angle = std::numbers::pi * (3.0 - std::sqrt(5.0)); + for (std::size_t node = 0; node < node_count; ++node) { + const double cosine = 1.0 - 2.0 * (static_cast(node) + 0.5) / node_count; + const double sine = std::sqrt(1.0 - cosine * cosine); + const double azimuth = golden_angle * static_cast(node); + const double radius = 2.0 * (1.0 + 0.1 * cosine * cosine); + positions.push_back({center[0] + radius * sine * std::cos(azimuth), center[1] + radius * sine * std::sin(azimuth), center[2] + radius * cosine}); + } + positions.push_back({center[0], center[1], center[2] + 2.0}); + positions.push_back({center[0], center[1], center[2] - 2.0}); + return positions; + } + + /** @brief Select an analytic mode independently of its expected position in the ordered basis. */ + [[nodiscard]] std::size_t mode_index(const deformation::SurfaceEmbedding& surface, int degree, int order) { + const auto selected = std::ranges::find(surface.modes(), deformation::SphericalHarmonicMode{.degree = degree, .order = order}); + REQUIRE(selected != surface.modes().end()); + return static_cast(selected - surface.modes().begin()); + } + + /** @brief Use deterministic nonconstant data to expose ordering errors in displacement and force actions. */ + [[nodiscard]] std::vector varying_values(std::size_t size, double scale = 1.0) { + std::vector result(size); + for (std::size_t index = 0; index < size; ++index) { + result[index] = scale * std::sin(0.37 * static_cast(index + 1)); + } + return result; + } + + /** @brief Evaluate virtual work without depending on the tested transpose implementation. */ + [[nodiscard]] double work(std::span first, std::span second) { + REQUIRE(first.size() == second.size()); + long double result = 0.0L; + for (std::size_t index = 0; index < first.size(); ++index) { + result += static_cast(first[index]) * second[index]; + } + return static_cast(result); + } + + /** @brief Compare against a centered finite difference and independent dual work at MeanField tolerances. */ + void check_linear_actions(const deformation::SurfaceEmbedding& surface) { + const std::vector parameters = varying_values(surface.input_size(), 0.03); + const std::vector direction = varying_values(surface.input_size(), 0.07); + const std::vector force = varying_values(surface.output_size()); + std::vector action(surface.output_size()); + std::vector transferred(surface.input_size()); + REQUIRE(surface.apply(direction, action)); + REQUIRE(surface.apply_transpose(force, transferred)); + const double scale = std::max({1.0, std::sqrt(work(action, action) * work(force, force)), std::sqrt(work(direction, direction) * work(transferred, transferred))}); + CHECK(std::abs(work(action, force) - work(direction, transferred)) < 2e-13 * scale); + + constexpr double epsilon = 1e-5; + std::vector plus(parameters); + std::vector minus(parameters); + for (std::size_t parameter = 0; parameter < parameters.size(); ++parameter) { + plus[parameter] += epsilon * direction[parameter]; + minus[parameter] -= epsilon * direction[parameter]; + } + std::vector plus_value(surface.output_size()); + std::vector minus_value(surface.output_size()); + REQUIRE(surface.apply(plus, plus_value)); + REQUIRE(surface.apply(minus, minus_value)); + for (std::size_t index = 0; index < action.size(); ++index) { + plus_value[index] = (plus_value[index] - minus_value[index]) / (2.0 * epsilon) - action[index]; + } + CHECK(std::sqrt(work(plus_value, plus_value) / work(action, action)) < 2e-10); + } +} + +TEST_CASE("Nodal surface coordinates remain physical radial displacement lengths", "[unit][deformation]") { + const std::array center{0.3, -0.2, 0.4}; + const auto positions = surface_positions(center); + const auto surface = deformation::build_surface_embedding(deformation::NodalRadialSurface{.reference_center = center}, positions); + REQUIRE(surface); + CHECK(surface->has_nodal_parameters()); + CHECK(surface->modes().empty()); + CHECK(surface->nodal_basis().empty()); + REQUIRE(surface->input_size() == positions.size()); + REQUIRE(surface->output_size() == 3 * positions.size()); + std::vector parameters(surface->input_size(), 0.125); + std::vector displacement(surface->output_size(), 8.0); + REQUIRE(surface->apply(parameters, displacement)); + for (std::size_t node = 0; node < positions.size(); ++node) { + double moved_radius_squared = 0.0; + for (std::size_t component = 0; component < 3; ++component) { + const double radial_coordinate = positions[node][component] - center[component]; + CHECK(std::abs(surface->radial_directions()[node][component] * surface->reference_radii()[node] - radial_coordinate) < 2e-15); + CHECK(displacement[3 * node + component] == 0.125 * surface->radial_directions()[node][component]); + moved_radius_squared += std::pow(radial_coordinate + displacement[3 * node + component], 2); + } + CHECK(std::abs(std::sqrt(moved_radius_squared) - surface->reference_radii()[node] - 0.125) < 2e-15); + } +} + +TEST_CASE("Nodal surface actions preserve exact derivatives and virtual work", "[unit][deformation]") { + const auto surface = deformation::build_surface_embedding(deformation::NodalRadialSurface{}, surface_positions()); + REQUIRE(surface); + check_linear_actions(*surface); +} + +TEST_CASE("Nodal radial derivatives retain MeanField nonproportional directions and original work tolerances", "[unit][deformation]") { + const auto surface = deformation::build_surface_embedding(deformation::NodalRadialSurface{}, surface_positions()); + REQUIRE(surface); + std::vector parameters(surface->input_size()); + std::vector direction(surface->input_size()); + std::vector plus(surface->input_size()); + std::vector minus(surface->input_size()); + constexpr double step = 1e-6; + for (std::size_t parameter = 0; parameter < parameters.size(); ++parameter) { + const double index = static_cast(parameter + 1); + parameters[parameter] = 0.013 * std::sin(0.37 * index); + direction[parameter] = std::cos(0.19 * index) - 0.21 * std::sin(0.43 * index); + plus[parameter] = parameters[parameter] + step * direction[parameter]; + minus[parameter] = parameters[parameter] - step * direction[parameter]; + } + std::vector plus_value(surface->output_size()); + std::vector minus_value(surface->output_size()); + std::vector action(surface->output_size()); + auto workspace = deformation::make_deformation_workspace(*surface); + REQUIRE(surface->apply(plus, plus_value)); + REQUIRE(surface->apply(minus, minus_value)); + REQUIRE(deformation::apply_jacobian(*surface, parameters, direction, action, workspace)); + for (std::size_t entry = 0; entry < action.size(); ++entry) { + plus_value[entry] = (plus_value[entry] - minus_value[entry]) / (2.0 * step) - action[entry]; + } + CHECK(std::sqrt(work(plus_value, plus_value) / work(action, action)) < 2e-11); + + std::vector surface_force(surface->output_size()); + for (std::size_t entry = 0; entry < surface_force.size(); ++entry) { + const double index = static_cast(entry + 1); + surface_force[entry] = std::sin(0.23 * index) + 0.17 * std::cos(0.31 * index); + } + std::vector parameter_force(surface->input_size()); + REQUIRE(deformation::apply_jacobian_transpose(*surface, parameters, surface_force, parameter_force, workspace)); + const double surface_work = work(action, surface_force); + const double parameter_work = work(direction, parameter_force); + CHECK(std::abs(surface_work - parameter_work) <= 3e-14 * std::max({1.0, std::abs(surface_work), std::abs(parameter_work)})); + std::ranges::fill(parameter_force, 1.0); + REQUIRE(deformation::apply_pullback_derivative(*surface, parameters, direction, surface_force, parameter_force, workspace)); + CHECK(work(parameter_force, parameter_force) == 0.0); +} + +TEST_CASE("Reference radial geometry rejects empty nonfinite and centered nodes", "[unit][deformation]") { + CHECK_FALSE(deformation::build_surface_embedding(deformation::NodalRadialSurface{}, {})); + const std::array, 1> center_node{{{0.0, 0.0, 0.0}}}; + CHECK_FALSE(deformation::build_surface_embedding(deformation::NodalRadialSurface{}, center_node)); + for (double invalid : {std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity(), -std::numeric_limits::infinity()}) { + for (std::size_t component = 0; component < 3; ++component) { + std::array center{}; + center[component] = invalid; + CHECK_FALSE(deformation::build_surface_embedding(deformation::NodalRadialSurface{.reference_center = center}, surface_positions())); + auto positions = surface_positions(); + positions[4][component] = invalid; + CHECK_FALSE(deformation::build_surface_embedding(deformation::NodalRadialSurface{}, positions)); + CHECK_FALSE(deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{}, positions)); + } + } +} + +TEST_CASE("Reference radius calculation does not overflow a representable length", "[unit][deformation]") { + const std::array, 2> positions{{{1e200, 0.0, 0.0}, {0.0, 1e-200, 0.0}}}; + const auto surface = deformation::build_surface_embedding(deformation::NodalRadialSurface{}, positions); + REQUIRE(surface); + CHECK(surface->reference_radii()[0] == 1e200); + CHECK(surface->reference_radii()[1] == 1e-200); + CHECK(surface->radial_directions()[0][0] == 1.0); + CHECK(surface->radial_directions()[1][1] == 1.0); +} + +TEST_CASE("Harmonic surfaces retain physical mode counts and canonical ordering", "[unit][deformation]") { + const auto positions = surface_positions(); + const std::array expected_counts{5, 13, 19, 23, 25}; + for (int cutoff = 0; cutoff <= 4; ++cutoff) { + const auto surface = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{.maximum_degree = 4, .maximum_azimuthal_order = cutoff}, positions); + REQUIRE(surface); + CHECK_FALSE(surface->has_nodal_parameters()); + CHECK(surface->input_size() == static_cast(expected_counts[static_cast(cutoff)])); + std::size_t index = 0; + for (int degree = 0; degree <= 4; ++degree) { + for (int order = -std::min(degree, cutoff); order <= std::min(degree, cutoff); ++order) { + CHECK((surface->modes()[index++] == deformation::SphericalHarmonicMode{.degree = degree, .order = order})); + } + } + } + const auto monopole = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{.maximum_degree = 0}, positions); + REQUIRE(monopole); + CHECK(monopole->input_size() == 1); +} + +TEST_CASE("Harmonic surfaces reject invalid or unresolved mode selections", "[unit][deformation]") { + for (const deformation::SphericalHarmonicSurface model : { + deformation::SphericalHarmonicSurface{.maximum_degree = -1}, + deformation::SphericalHarmonicSurface{.maximum_degree = 4, .maximum_azimuthal_order = -1}, + deformation::SphericalHarmonicSurface{.maximum_degree = 4, .maximum_azimuthal_order = 5}, + deformation::SphericalHarmonicSurface{.maximum_degree = std::numeric_limits::max()}}) { + const auto surface = deformation::build_surface_embedding(model, surface_positions()); + REQUIRE_FALSE(surface); + CHECK(surface.error().reason == deformation::DeformationFailureReason::invalid_configuration); + } + const auto unresolved = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{.maximum_degree = 10}, surface_positions()); + REQUIRE_FALSE(unresolved); + CHECK(unresolved.error().reason == deformation::DeformationFailureReason::unresolved_surface); + CHECK_FALSE(deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{}, {})); +} + +TEST_CASE("Real harmonics preserve analytic normalization phase and signed-order convention", "[unit][deformation]") { + const auto surface = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{}, surface_positions()); + REQUIRE(surface); + constexpr double pi = std::numbers::pi; + for (std::size_t node = 0; node < surface->surface_node_count(); ++node) { + const double x = surface->radial_directions()[node][0]; + const double y = surface->radial_directions()[node][1]; + const double z = surface->radial_directions()[node][2]; + /// Analytic low-degree values protect physical coefficient conventions independently of the recurrence. + const auto check_mode = [&](int degree, int order, double exact) { + CHECK(std::abs(surface->nodal_basis()[node * surface->input_size() + mode_index(*surface, degree, order)] - exact) < 2e-14); + }; + check_mode(0, 0, 1.0 / std::sqrt(4.0 * pi)); + check_mode(1, 0, std::sqrt(3.0 / (4.0 * pi)) * z); + check_mode(1, 1, -std::sqrt(3.0 / (4.0 * pi)) * x); + check_mode(1, -1, -std::sqrt(3.0 / (4.0 * pi)) * y); + check_mode(2, 0, std::sqrt(5.0 / (16.0 * pi)) * (3.0 * z * z - 1.0)); + check_mode(2, -2, std::sqrt(15.0 / (4.0 * pi)) * x * y); + check_mode(2, 2, std::sqrt(15.0 / (16.0 * pi)) * (x * x - y * y)); + } + std::vector parameters(surface->input_size(), 0.0); + parameters[0] = 0.125 * std::sqrt(4.0 * pi); + std::vector displacement(surface->output_size()); + REQUIRE(surface->apply(parameters, displacement)); + for (std::size_t node = 0; node < surface->surface_node_count(); ++node) { + for (std::size_t component = 0; component < 3; ++component) { + CHECK(std::abs(displacement[3 * node + component] - 0.125 * surface->radial_directions()[node][component]) < 1e-15); + } + } +} + +TEST_CASE("Restricting harmonic orders does not renormalize surviving coefficients", "[unit][deformation]") { + const auto positions = surface_positions(); + const auto full = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{}, positions); + REQUIRE(full); + for (int cutoff : {0, 1, 2}) { + const auto restricted = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{.maximum_azimuthal_order = cutoff}, positions); + REQUIRE(restricted); + for (std::size_t mode = 0; mode < restricted->input_size(); ++mode) { + const deformation::SphericalHarmonicMode selected = restricted->modes()[mode]; + const std::size_t full_mode = mode_index(*full, selected.degree, selected.order); + for (std::size_t node = 0; node < full->surface_node_count(); ++node) { + CHECK(restricted->nodal_basis()[node * restricted->input_size() + mode] == full->nodal_basis()[node * full->input_size() + full_mode]); + } + } + } +} + +TEST_CASE("Harmonic surface actions preserve exact derivatives virtual work and the reference basis", "[unit][deformation]") { + const auto surface = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{}, surface_positions()); + REQUIRE(surface); + const std::vector original_basis(surface->nodal_basis().begin(), surface->nodal_basis().end()); + check_linear_actions(*surface); + CHECK(std::ranges::equal(original_basis, surface->nodal_basis())); +} + +TEST_CASE("Harmonic surfaces reject aliased modes even when enough nodes are present", "[unit][deformation]") { + std::vector> corners; + for (double x : {-0.5, 0.5}) { + for (double y : {-0.5, 0.5}) { + for (double z : {-0.5, 0.5}) { + corners.push_back({x, y, z}); + } + } + } + const auto surface = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{.maximum_degree = 2, .maximum_azimuthal_order = 0}, corners); + REQUIRE_FALSE(surface); + CHECK(surface.error().reason == deformation::DeformationFailureReason::unresolved_surface); +} + +TEST_CASE("Nodal and harmonic representations produce identical nodal surface motion", "[unit][deformation]") { + const auto positions = surface_positions(); + const auto harmonic = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{}, positions); + const auto nodal = deformation::build_surface_embedding(deformation::NodalRadialSurface{}, positions); + REQUIRE(harmonic); + REQUIRE(nodal); + const std::vector parameters = varying_values(harmonic->input_size(), 0.03); + std::vector nodal_parameters(nodal->input_size()); + for (std::size_t node = 0; node < nodal_parameters.size(); ++node) { + long double amplitude = 0.0L; + for (std::size_t mode = 0; mode < harmonic->input_size(); ++mode) { + amplitude += static_cast(harmonic->nodal_basis()[node * harmonic->input_size() + mode]) * parameters[mode]; + } + nodal_parameters[node] = static_cast(amplitude); + } + std::vector harmonic_motion(harmonic->output_size()); + std::vector nodal_motion(nodal->output_size()); + REQUIRE(harmonic->apply(parameters, harmonic_motion)); + REQUIRE(nodal->apply(nodal_parameters, nodal_motion)); + // Independent reductions must agree to roundoff, including optimized builds. + const double scale = std::sqrt(std::max(work(harmonic_motion, harmonic_motion), work(nodal_motion, nodal_motion))); + for (std::size_t index = 0; index < harmonic_motion.size(); ++index) { + harmonic_motion[index] -= nodal_motion[index]; + } + CHECK(std::sqrt(work(harmonic_motion, harmonic_motion)) <= 8.0 * std::numeric_limits::epsilon() * scale); +} + +TEST_CASE("Surface actions reject wrong sizes nonfinite inputs and overlapping storage", "[unit][deformation]") { + for (bool harmonic : {false, true}) { + const auto positions = surface_positions(); + const auto surface = harmonic ? deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{}, positions) : deformation::build_surface_embedding(deformation::NodalRadialSurface{}, positions); + REQUIRE(surface); + std::vector parameters(surface->input_size(), 0.0); + std::vector displacement(surface->output_size(), 0.0); + const std::vector wrong(surface->input_size() + 1, 0.0); + CHECK_FALSE(surface->apply(wrong, displacement)); + CHECK_FALSE(surface->apply(parameters, parameters)); + CHECK_FALSE(surface->apply_transpose(parameters, parameters)); + CHECK_FALSE(surface->apply_transpose(displacement, displacement)); + for (double invalid : {std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity(), -std::numeric_limits::infinity()}) { + parameters.back() = invalid; + CHECK_FALSE(surface->apply(parameters, displacement)); + parameters.back() = 0.0; + displacement.back() = invalid; + CHECK_FALSE(surface->apply_transpose(displacement, parameters)); + displacement.back() = 0.0; + } + const std::span buffer(displacement); + CHECK_FALSE(surface->apply(buffer.first(surface->input_size()), buffer)); + CHECK_FALSE(surface->apply_transpose(buffer, buffer.first(surface->input_size()))); + } +} + +TEST_CASE("Finite harmonic inputs cannot silently overflow surface motion or modal forces", "[unit][deformation]") { + const auto surface = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{}, surface_positions()); + REQUIRE(surface); + const double maximum = std::numeric_limits::max(); + std::vector parameters(surface->input_size()); + std::vector displacement(surface->output_size()); + for (std::size_t mode = 0; mode < surface->input_size(); ++mode) { + parameters[mode] = std::copysign(maximum, surface->nodal_basis()[mode]); + } + CHECK_FALSE(surface->apply(parameters, displacement)); + for (std::size_t node = 0; node < surface->surface_node_count(); ++node) { + for (std::size_t component = 0; component < 3; ++component) { + displacement[3 * node + component] = maximum * surface->radial_directions()[node][component]; + REQUIRE(std::isfinite(displacement[3 * node + component])); + } + } + const auto transpose = surface->apply_transpose(displacement, parameters); + REQUIRE_FALSE(transpose); + CHECK(transpose.error().reason == deformation::DeformationFailureReason::non_finite); +} diff --git a/tests/deformation/surface_geometry.cpp b/tests/deformation/surface_geometry.cpp new file mode 100644 index 00000000..28739193 --- /dev/null +++ b/tests/deformation/surface_geometry.cpp @@ -0,0 +1,174 @@ +#include +#include +#include +#include +#include +#include +#include +#include "serif/geometry/deformation/radial_extension.hpp" +#include "serif/geometry/deformation/surface_embedding.hpp" +#include "serif/tests/deformation/mesh.hpp" + +namespace { + namespace deformation = serif::geometry::deformation; + namespace backend = deformation::backend::mfem; + using serif::tests::deformation::PairedMesh; + + /** @brief Preserve the old physical Roche equipotential and its asymmetric companion potential. */ + struct BinaryRochePotential { + double primary_mass; + double companion_mass; + double separation; + + /** @brief Evaluate gravitational and centrifugal contributions in units with G equal to one. */ + [[nodiscard]] double operator()(double x, double y, double z) const { + const double primary_distance = std::sqrt(x * x + y * y + z * z); + const double companion_offset = x - separation; + const double companion_distance = std::sqrt(companion_offset * companion_offset + y * y + z * z); + const double total_mass = primary_mass + companion_mass; + const double center_of_mass = separation * companion_mass / total_mass; + const double angular_speed_squared = total_mass / (separation * separation * separation); + const double rotation_radius_squared = (x - center_of_mass) * (x - center_of_mass) + y * y; + return -primary_mass / primary_distance - companion_mass / companion_distance - 0.5 * angular_speed_squared * rotation_radius_squared; + } + + /** @brief Preserve the first closed-envelope radial crossing and the original root tolerances. */ + [[nodiscard]] double radial_intersection(const std::array& direction, double target_potential, double reference_scale) const { + double lower_radius = 1.0e-8 * reference_scale; + const double lower_value = (*this)(lower_radius * direction[0], lower_radius * direction[1], lower_radius * direction[2]) - target_potential; + REQUIRE(lower_value < 0.0); + double upper_radius = lower_radius; + double upper_value = lower_value; + constexpr int bracket_samples = 512; + for (int sample = 1; sample <= bracket_samples && upper_value <= 0.0; ++sample) { + upper_radius = 0.45 * separation * static_cast(sample) / bracket_samples; + upper_value = (*this)(upper_radius * direction[0], upper_radius * direction[1], upper_radius * direction[2]) - target_potential; + } + REQUIRE(upper_value > 0.0); + for (int iteration = 0; iteration < 80; ++iteration) { + const double middle_radius = 0.5 * (lower_radius + upper_radius); + const double middle_value = (*this)(middle_radius * direction[0], middle_radius * direction[1], middle_radius * direction[2]) - target_potential; + if (middle_value > 0.0) { + upper_radius = middle_radius; + } else { + lower_radius = middle_radius; + } + } + return 0.5 * (lower_radius + upper_radius); + } + }; + + /** + * @brief Sample the actual finite-element trace using its moved tangents for physical area weights. + * Only trace basis functions affect this restriction; no ideal continuous radial surface is substituted. + * The independent boundary calculation avoids requiring an interior volume extension to test a surface representation. + */ + template + [[nodiscard]] std::array sample_surface_error(PairedMesh& mesh, const deformation::ReferenceSurface& reference, std::span displacement, const PointError& point_error) { + std::vector surface_indices(mesh.scalar_space.GetNDofs(), -1); + for (std::size_t node = 0; node < reference.scalar_dofs.size(); ++node) { + surface_indices[reference.scalar_dofs[node]] = static_cast(node); + } + double maximum_error = 0.0, squared_error = 0.0, surface_area = 0.0; + int samples = 0; + ::mfem::Array boundary_dofs; + ::mfem::Vector shape, position(3); + ::mfem::DenseMatrix derivatives; + for (int boundary = 0; boundary < mesh.generated.mesh->GetNBE(); ++boundary) { + if (mesh.generated.mesh->GetBdrAttribute(boundary) != PairedMesh::surface_attribute) { + continue; + } + ::mfem::ElementTransformation& transformation = *mesh.generated.mesh->GetBdrElementTransformation(boundary); + const ::mfem::FiniteElement& element = *mesh.scalar_space.GetBE(boundary); + const ::mfem::IntegrationRule& rule = ::mfem::IntRules.Get(transformation.GetGeometryType(), 2 * element.GetOrder()); + mesh.scalar_space.GetBdrElementDofs(boundary, boundary_dofs); + shape.SetSize(boundary_dofs.Size()); + derivatives.SetSize(boundary_dofs.Size(), 2); + for (int point = 0; point < rule.GetNPoints(); ++point) { + const ::mfem::IntegrationPoint& integration_point = rule.IntPoint(point); + transformation.Transform(integration_point, position); + transformation.SetIntPoint(&integration_point); + ::mfem::DenseMatrix tangents(transformation.Jacobian()); + element.CalcShape(integration_point, shape); + element.CalcDShape(integration_point, derivatives); + for (int node = 0; node < boundary_dofs.Size(); ++node) { + const int surface_node = surface_indices[::mfem::FiniteElementSpace::DecodeDof(boundary_dofs[node])]; + REQUIRE(surface_node >= 0); + for (int component = 0; component < 3; ++component) { + const double value = displacement[3 * surface_node + component]; + position[component] += shape[node] * value; + for (int tangent = 0; tangent < 2; ++tangent) { + tangents(component, tangent) += derivatives(node, tangent) * value; + } + } + } + double area_squared = 0.0; + for (int component = 0; component < 3; ++component) { + const int second = (component + 1) % 3; + const int third = (component + 2) % 3; + const double normal = tangents(second, 0) * tangents(third, 1) - tangents(third, 0) * tangents(second, 1); + area_squared += normal * normal; + } + const double weight = integration_point.weight * std::sqrt(area_squared); + const double error = point_error(position); + REQUIRE(std::isfinite(error)); + REQUIRE(weight > 0.0); + maximum_error = std::max(maximum_error, error); + squared_error += error * error * weight; + surface_area += weight; + ++samples; + } + } + REQUIRE(samples > 0); + REQUIRE(surface_area > 0.0); + return {maximum_error, std::sqrt(squared_error / surface_area)}; + } +} + +TEST_CASE("Nodal radial displacement resolves oblate and binary Roche envelopes between surface nodes", "[unit][deformation]") { + // MeanField's baseline sandbox mesh uses two refinements, order-four geometry and order-three displacement. + PairedMesh mesh(2, 0.0, 3, 4, 5.0); + const auto reference = backend::sample_reference_surface(mesh.scalar_space, PairedMesh::surface_attribute); + REQUIRE(reference.has_value()); + const auto surface = deformation::build_surface_embedding(deformation::NodalRadialSurface{}, reference->positions); + REQUIRE(surface.has_value()); + const double reference_scale = std::accumulate(surface->reference_radii().begin(), surface->reference_radii().end(), 0.0) / surface->surface_node_count(); + const double equatorial_radius = 1.08 * reference_scale; + const double polar_radius = 0.94 * reference_scale; + std::vector oblate_parameters(surface->input_size()), roche_parameters(surface->input_size()); + const BinaryRochePotential potential{.primary_mass = 1.0, .companion_mass = 0.7, .separation = 4.0 * reference_scale}; + const double target_potential = potential(0.0, 0.0, reference_scale); + for (std::size_t node = 0; node < surface->surface_node_count(); ++node) { + const std::array& direction = surface->radial_directions()[node]; + const double oblate_radius = 1.0 / std::sqrt((direction[0] * direction[0] + direction[1] * direction[1]) / (equatorial_radius * equatorial_radius) + direction[2] * direction[2] / (polar_radius * polar_radius)); + oblate_parameters[node] = oblate_radius - surface->reference_radii()[node]; + roche_parameters[node] = potential.radial_intersection(direction, target_potential, reference_scale) - surface->reference_radii()[node]; + } + std::vector oblate_displacement(surface->output_size()), roche_displacement(surface->output_size()); + REQUIRE(surface->apply(oblate_parameters, oblate_displacement)); + REQUIRE(surface->apply(roche_parameters, roche_displacement)); + for (std::size_t node = 0; node < surface->surface_node_count(); ++node) { + std::array oblate_position{}, roche_position{}; + for (std::size_t component = 0; component < 3; ++component) { + oblate_position[component] = reference->positions[node][component] + oblate_displacement[3 * node + component]; + roche_position[component] = reference->positions[node][component] + roche_displacement[3 * node + component]; + } + const double level = (oblate_position[0] * oblate_position[0] + oblate_position[1] * oblate_position[1]) / (equatorial_radius * equatorial_radius) + oblate_position[2] * oblate_position[2] / (polar_radius * polar_radius); + CHECK(std::abs(level - 1.0) <= 3.0e-12); + CHECK(std::abs(potential(roche_position[0], roche_position[1], roche_position[2]) - target_potential) <= 2.0e-11 * std::max(1.0, std::abs(target_potential))); + } + const auto oblate_error = sample_surface_error(mesh, *reference, oblate_displacement, [equatorial_radius, polar_radius](const ::mfem::Vector& position) { + return std::abs((position[0] * position[0] + position[1] * position[1]) / (equatorial_radius * equatorial_radius) + position[2] * position[2] / (polar_radius * polar_radius) - 1.0); + }); + const auto roche_error = sample_surface_error(mesh, *reference, roche_displacement, [&potential, target_potential](const ::mfem::Vector& position) { + return std::abs(potential(position[0], position[1], position[2]) - target_potential) / std::max(1.0, std::abs(target_potential)); + }); + CAPTURE(oblate_error, roche_error); + CHECK(oblate_error[0] < 5.0e-3); + CHECK(oblate_error[1] < 1.0e-3); + CHECK(roche_error[0] < 2.0e-2); + CHECK(roche_error[1] < 5.0e-3); + const double companion_facing = potential.radial_intersection({1.0, 0.0, 0.0}, target_potential, reference_scale); + const double companion_opposing = potential.radial_intersection({-1.0, 0.0, 0.0}, target_potential, reference_scale); + CHECK(std::abs(companion_facing - companion_opposing) > 1.0e-3 * reference_scale); +} diff --git a/tests/deformation/surface_metric.cpp b/tests/deformation/surface_metric.cpp new file mode 100644 index 00000000..442eaeca --- /dev/null +++ b/tests/deformation/surface_metric.cpp @@ -0,0 +1,295 @@ +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "mfem/fem/bilinearform.hpp" +#include "mfem/fem/bilininteg.hpp" +#include "mfem/fem/coefficient.hpp" +#include "mfem/fem/gridfunc.hpp" +#include "serif/backend/mfem/shared/boundary_elements.hpp" +#include "serif/backend/mfem/geometry/deformation/reference_geometry.hpp" +#include "serif/backend/mfem/geometry/deformation/surface_metric.hpp" +#include "serif/geometry/deformation/composition.hpp" +#include "serif/geometry/deformation/linearization.hpp" +#include "serif/tests/deformation/mesh.hpp" + +namespace { + namespace deformation = serif::geometry::deformation; + namespace backend = deformation::backend::mfem; + using PairedMesh = serif::tests::deformation::PairedMesh; + + /** @brief Independently integrate an analytic monopole or axial-dipole vector trace using MeanField's declared mass quadrature. */ + [[nodiscard]] double analytic_trace_norm(PairedMesh& fixture, int degree) { + /// Project analytic Cartesian data at FE nodes; evaluating it directly at quadrature points would change the realized trace. + ::mfem::VectorFunctionCoefficient coefficient(3, [degree](const ::mfem::Vector& position, ::mfem::Vector& value) { + const double radius = position.Norml2(); + value.SetSize(3); + value = 0.0; + if (radius <= 0.0) { + return; + } + const double harmonic = degree == 0 ? 1.0 / std::sqrt(4.0 * std::numbers::pi) : std::sqrt(3.0 / (4.0 * std::numbers::pi)) * position[2] / radius; + for (int component = 0; component < 3; ++component) { + value[component] = harmonic * position[component] / radius; + } + }); + ::mfem::GridFunction tangent(&fixture.vector_space); + tangent.ProjectCoefficient(coefficient); + ::mfem::Array dofs; + ::mfem::Vector local; + ::mfem::Vector shape; + long double work = 0.0L; + for (int boundary = 0; boundary < fixture.generated.mesh->GetNBE(); ++boundary) { + if (fixture.generated.mesh->GetBdrAttribute(boundary) != fixture.surface_attribute) { + continue; + } + const ::mfem::FiniteElement& element = *fixture.vector_space.GetBE(boundary); + ::mfem::ElementTransformation& transformation = *fixture.vector_space.GetBdrElementTransformation(boundary); + REQUIRE(fixture.vector_space.GetBdrElementVDofs(boundary, dofs) == nullptr); + tangent.GetSubVector(dofs, local); + REQUIRE(local.Size() == 3 * element.GetDof()); + shape.SetSize(element.GetDof()); + const ::mfem::IntegrationRule& rule = ::mfem::MassIntegrator::GetRule(element, element, transformation); + for (int point_index = 0; point_index < rule.GetNPoints(); ++point_index) { + const ::mfem::IntegrationPoint& point = rule.IntPoint(point_index); + transformation.SetIntPoint(&point); + element.CalcShape(point, shape); + long double norm_squared = 0.0L; + for (int component = 0; component < 3; ++component) { + long double value = 0.0L; + for (int node = 0; node < element.GetDof(); ++node) { + value += static_cast(shape[node]) * local[node + component * element.GetDof()]; + } + norm_squared += value * value; + } + work += point.weight * transformation.Weight() * norm_squared; + } + } + REQUIRE(std::isfinite(work)); + REQUIRE(work > 0.0L); + return static_cast(work); + } +} + +TEST_CASE("Harmonic reference metrics measure the actual vector finite-element surface trace", "[integration][deformation]") { + for (double flattening : {0.0, 0.2}) { + // MeanField uses cubic displacement traces; refine the surface to resolve all 25 degree-four modes. + PairedMesh fixture(1, flattening, 3); + const auto reference = backend::sample_reference_surface(fixture.scalar_space, fixture.surface_attribute); + REQUIRE(reference); + for (int cutoff : {0, 4}) { + const auto embedding = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{.maximum_degree = 4, .maximum_azimuthal_order = cutoff}, reference->positions); + REQUIRE(embedding); + const auto metric = backend::surface_parameter_mass_diagonal(fixture.scalar_space, fixture.surface_attribute, *reference, *embedding); + REQUIRE(metric); + REQUIRE(metric->size() == static_cast(cutoff == 0 ? 5 : 25)); + CHECK((*metric)[0] == Catch::Approx(analytic_trace_norm(fixture, 0)).epsilon(5e-13)); + CHECK((*metric)[cutoff == 0 ? 1 : 2] == Catch::Approx(analytic_trace_norm(fixture, 1)).epsilon(5e-13)); + for (double entry : *metric) { + CHECK(std::isfinite(entry)); + CHECK(entry > 0.0); + } + } + } +} + +TEST_CASE("Nodal radial metrics match the independent assembled boundary mass diagonal", "[integration][deformation]") { + PairedMesh fixture; + const auto reference = backend::sample_reference_surface(fixture.scalar_space, fixture.surface_attribute); + REQUIRE(reference); + const auto embedding = deformation::build_surface_embedding(deformation::NodalRadialSurface{}, reference->positions); + REQUIRE(embedding); + const auto metric = backend::surface_parameter_mass_diagonal(fixture.scalar_space, fixture.surface_attribute, *reference, *embedding); + REQUIRE(metric); + ::mfem::Array marker(fixture.generated.mesh->bdr_attributes.Max()); + marker = 0; + marker[fixture.surface_attribute - 1] = 1; + ::mfem::BilinearForm mass(&fixture.scalar_space); + mass.AddBoundaryIntegrator(new ::mfem::MassIntegrator(), marker); + mass.Assemble(); + mass.Finalize(); + ::mfem::Vector diagonal; + mass.SpMat().GetDiag(diagonal); + REQUIRE(metric->size() == reference->scalar_dofs.size()); + for (std::size_t node = 0; node < metric->size(); ++node) { + CHECK((*metric)[node] == Catch::Approx(diagonal[reference->scalar_dofs[node]]).epsilon(5e-14)); + } +} + +TEST_CASE("Reference metric compilation rejects incompatible trace ordering and geometry", "[integration][deformation]") { + PairedMesh fixture; + const auto reference = backend::sample_reference_surface(fixture.scalar_space, fixture.surface_attribute); + REQUIRE(reference); + const auto embedding = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{.maximum_degree = 2}, reference->positions); + REQUIRE(embedding); + deformation::ReferenceSurface changed = *reference; + SECTION("Canonical scalar node ordering changed") { + REQUIRE(changed.scalar_dofs.size() > 1); + std::swap(changed.scalar_dofs[0], changed.scalar_dofs[1]); + } + SECTION("Reference positions changed at fixed dimensions") { + changed.positions[0][0] += 0.01; + } + SECTION("A reference node is missing") { + changed.positions.pop_back(); + changed.scalar_dofs.pop_back(); + } + const auto result = backend::surface_parameter_mass_diagonal(fixture.scalar_space, fixture.surface_attribute, changed, *embedding); + REQUIRE_FALSE(result); + CHECK(result.error().reason == deformation::DeformationFailureReason::incompatible_discretization); +} + +TEST_CASE("Reference metrics reject an embedding from different points despite equal dimensions", "[integration][deformation]") { + PairedMesh fixture; + const auto reference = backend::sample_reference_surface(fixture.scalar_space, fixture.surface_attribute); + REQUIRE(reference); + auto positions = reference->positions; + positions[0][0] += 0.01; + const auto embedding = deformation::build_surface_embedding(deformation::NodalRadialSurface{}, positions); + REQUIRE(embedding); + const auto result = backend::surface_parameter_mass_diagonal(fixture.scalar_space, fixture.surface_attribute, *reference, *embedding); + REQUIRE_FALSE(result); + CHECK(result.error().reason == deformation::DeformationFailureReason::incompatible_discretization); +} + +TEST_CASE("Reference metrics reject a changed mesh before assembling parameter norms", "[integration][deformation]") { + PairedMesh fixture; + const auto reference = backend::sample_reference_surface(fixture.scalar_space, fixture.surface_attribute); + REQUIRE(reference); + const auto embedding = deformation::build_surface_embedding(deformation::NodalRadialSurface{}, reference->positions); + REQUIRE(embedding); + /// Moving the reference coordinates must invalidate the old trace even if all vector dimensions stay equal. + ::mfem::VectorFunctionCoefficient translation(3, [](const ::mfem::Vector& old_position, ::mfem::Vector& new_position) { + new_position = old_position; + new_position[0] += 0.01; + }); + fixture.generated.mesh->Transform(translation); + const auto result = backend::surface_parameter_mass_diagonal(fixture.scalar_space, fixture.surface_attribute, *reference, *embedding); + REQUIRE_FALSE(result); + CHECK(result.error().reason == deformation::DeformationFailureReason::incompatible_discretization); +} + +TEST_CASE("Reference metrics require the selected scalar surface trace", "[integration][deformation]") { + PairedMesh fixture; + const auto reference = backend::sample_reference_surface(fixture.scalar_space, fixture.surface_attribute); + REQUIRE(reference); + const auto embedding = deformation::build_surface_embedding(deformation::NodalRadialSurface{}, reference->positions); + REQUIRE(embedding); + CHECK_FALSE(backend::surface_parameter_mass_diagonal(fixture.scalar_space, fixture.infinity_attribute, *reference, *embedding)); + CHECK_FALSE(backend::surface_parameter_mass_diagonal(fixture.scalar_space, 999, *reference, *embedding)); + CHECK_FALSE(backend::surface_parameter_mass_diagonal(fixture.vector_space, fixture.surface_attribute, *reference, *embedding)); + const deformation::ReferenceSurface copied = *reference; + CHECK(backend::surface_parameter_mass_diagonal(fixture.scalar_space, fixture.surface_attribute, copied, *embedding)); +} + +TEST_CASE("Harmonic and nodal surfaces couple to identical stellar and vacuum volume fields", "[integration][deformation]") { + PairedMesh fixture; + const auto reference = backend::sample_reference_surface(fixture.scalar_space, fixture.surface_attribute); + REQUIRE(reference); + auto harmonic = deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{.maximum_degree = 2}, reference->positions); + auto nodal = deformation::build_surface_embedding(deformation::NodalRadialSurface{}, reference->positions); + REQUIRE(harmonic); + REQUIRE(nodal); + auto interpolation = fixture.interpolation(); + REQUIRE(interpolation); + auto extension = deformation::make_radial_extension(std::make_shared(std::move(*interpolation))); + REQUIRE(extension); + const auto harmonic_map = std::make_shared(std::move(*harmonic)); + const auto nodal_map = std::make_shared(std::move(*nodal)); + const auto volume_extension = std::make_shared(std::move(*extension)); + const auto harmonic_domain = deformation::compose_deformation(harmonic_map, volume_extension); + const auto nodal_domain = deformation::compose_deformation(nodal_map, volume_extension); + REQUIRE(harmonic_domain); + REQUIRE(nodal_domain); + + std::vector harmonic_parameters(harmonic_map->input_size()); + for (std::size_t parameter = 0; parameter < harmonic_parameters.size(); ++parameter) { + harmonic_parameters[parameter] = 0.001 * std::sin(0.37 * static_cast(parameter + 1)); + } + std::vector nodal_parameters(nodal_map->input_size()); + for (std::size_t node = 0; node < nodal_parameters.size(); ++node) { + long double value = 0.0L; + for (std::size_t parameter = 0; parameter < harmonic_parameters.size(); ++parameter) { + value += static_cast(harmonic_map->nodal_basis()[node * harmonic_parameters.size() + parameter]) * harmonic_parameters[parameter]; + } + nodal_parameters[node] = static_cast(value); + } + const auto harmonic_volume = deformation::evaluate_displacement(*harmonic_domain, harmonic_parameters); + const auto nodal_volume = deformation::evaluate_displacement(*nodal_domain, nodal_parameters); + REQUIRE(harmonic_volume); + REQUIRE(nodal_volume); + REQUIRE(harmonic_volume->size() == nodal_volume->size()); + long double difference_norm = 0.0L; + long double reference_norm = 0.0L; + for (std::size_t entry = 0; entry < harmonic_volume->size(); ++entry) { + const long double difference = (*harmonic_volume)[entry] - (*nodal_volume)[entry]; + difference_norm += difference * difference; + reference_norm += static_cast((*nodal_volume)[entry]) * (*nodal_volume)[entry]; + } + REQUIRE(reference_norm > 0.0L); + CHECK(std::sqrt(difference_norm / reference_norm) < 3e-13); + const auto infinity_nodes = backend::select_surface_scalar_dofs(fixture.scalar_space, fixture.infinity_attribute); + REQUIRE(infinity_nodes); + for (int node : *infinity_nodes) { + for (int component = 0; component < 3; ++component) { + CHECK(std::abs((*harmonic_volume)[node + component * fixture.scalar_space.GetNDofs()]) < 1e-15); + } + } +} + +TEST_CASE("Shared boundary traversal rejects malformed coordinates before numerical assembly", "[integration][deformation][backend]") { + namespace shared = serif::backend::mfem::shared; + + PairedMesh fixture; + const auto reference = backend::sample_reference_surface(fixture.scalar_space, fixture.surface_attribute); + REQUIRE(reference); + std::vector selected = reference->scalar_dofs; + REQUIRE_FALSE(selected.empty()); + SECTION("Repeated scalar nodes") { + selected.push_back(selected.front()); + } + SECTION("An out-of-range scalar node") { + selected.front() = fixture.scalar_space.GetNDofs(); + } + SECTION("A negative scalar node") { + selected.front() = -1; + } + SECTION("No selected coordinates for an existing boundary") { + selected.clear(); + } + bool assembled = false; + const shared::BoundaryStatus status = shared::for_each_boundary_element(fixture.scalar_space, fixture.surface_attribute, selected, + [&](const shared::BoundaryElement&) -> shared::BoundaryStatus { + assembled = true; + return {}; + }); + REQUIRE_FALSE(status); + CHECK(status.error().incompatible_coordinates); + CHECK_FALSE(assembled); +} + +TEST_CASE("Shared boundary traversal stops and identifies a rejected element assembly", "[integration][deformation][backend]") { + namespace shared = serif::backend::mfem::shared; + + PairedMesh fixture; + const auto reference = backend::sample_reference_surface(fixture.scalar_space, fixture.surface_attribute); + REQUIRE(reference); + int visited = 0; + const shared::BoundaryStatus status = shared::for_each_boundary_element(fixture.scalar_space, fixture.surface_attribute, reference->scalar_dofs, + [&](const shared::BoundaryElement&) -> shared::BoundaryStatus { + ++visited; + return std::unexpected(shared::BoundaryFailure{.message = "Rejected local numerical contribution.", .incompatible_coordinates = false}); + }); + REQUIRE_FALSE(status); + CHECK(visited == 1); + CHECK_FALSE(status.error().incompatible_coordinates); + CHECK(status.error().message == "Rejected local numerical contribution."); + REQUIRE(status.error().element >= 0); + CHECK(fixture.scalar_space.GetMesh()->GetBdrAttribute(status.error().element) == fixture.surface_attribute); +} diff --git a/tests/discritization/domain/.DS_Store b/tests/discritization/domain/.DS_Store deleted file mode 100644 index 00ad28bf38f97a2f24eaebb3666dc9f28f4c7d11..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKJ5B>Z41I&bKn#chF(3xS!0!xrd!=;eiLzoq42Xd*2K@WbsE&Q% zlo+25niv6y3%WzNj#+}(+(GOMr$l-vmZ`){wHh%j)9G&&*B4HSnGTDaS*LEcnoulm zr@uuwtWT5`17hHifz#YBz5icPf0+Ldle7~9V&GpfV2kzjdc`MIZ=F5Pdu^lMP(9|I s>cVxT&_p{XMmy%l+wnyNWnA+$&-=nDG1BRebfSI+oEM20_-h6}04j?ca{vGU diff --git a/tests/discritization/domain/mesh_topology.cpp b/tests/discritization/domain/mesh_topology.cpp new file mode 100644 index 00000000..d273497d --- /dev/null +++ b/tests/discritization/domain/mesh_topology.cpp @@ -0,0 +1,59 @@ +#include +#include + +#include "serif/discretization/domain/mesh/topology.hpp" +#include "serif/discretization/domain/schema/schemas.hpp" +#include "serif/discretization/domain/schema/validation/all.hpp" + +namespace { + namespace domain = serif::discretization::domain; + + /** @brief Represent the core, envelope, vacuum and their interfaces without a finite-element backend. */ + [[nodiscard]] domain::mesh::MeshTopologyData layered_topology() { + return { + .elementDomainIDs = {1, 2, 3}, + .faceElements = {{.firstElementID = 0, .secondElementID = 1}, {.firstElementID = 1, .secondElementID = 2}, {.firstElementID = 2}}, + .boundaryElementBoundaryIDs = {1, 2}, + .boundaryElementFaceIDs = {1, 2} + }; + } +} + +TEST_CASE("Physical schema validation uses backend-independent mesh connectivity", "[unit][discretization][domain]") { + const domain::mesh::MeshTopology topology(layered_topology()); + const auto result = domain::schema::validation::validate_schema(topology); + REQUIRE(result); + CHECK(result.relation_count() == 7); + CHECK(topology.element_neighbors(0) == std::vector{1}); + CHECK((topology.element_neighbors(1) == std::vector{0, 2})); + CHECK(topology.boundary_elements_on_face(1) == std::vector{0}); + CHECK(topology.boundary_element_face(1) == 2); + CHECK(topology.face_elements(2).secondElementID == -1); +} + +TEST_CASE("Neutral topology rejects invalid indexing before deriving adjacency", "[unit][discretization][domain]") { + domain::mesh::MeshTopologyData data = layered_topology(); + SECTION("A face names an absent volume element") { + data.faceElements.front().firstElementID = 3; + } + SECTION("Boundary labels and faces have different counts") { + data.boundaryElementFaceIDs.pop_back(); + } + SECTION("A boundary names an absent face") { + data.boundaryElementFaceIDs.front() = 3; + } + CHECK_THROWS(domain::mesh::MeshTopology(data)); +} + +TEST_CASE("Neutral topology retains unresolved and multiply labelled boundary faces", "[unit][discretization][domain]") { + domain::mesh::MeshTopologyData data = layered_topology(); + data.boundaryElementBoundaryIDs.insert(data.boundaryElementBoundaryIDs.end(), {7, 8}); + data.boundaryElementFaceIDs.insert(data.boundaryElementFaceIDs.end(), {1, -1}); + const domain::mesh::MeshTopology topology(data); + CHECK((topology.boundary_elements_on_face(1) == std::vector{0, 2})); + CHECK(topology.boundary_element_face(3) == -1); + CHECK(topology.boundary_element_boundary_id(3) == 8); + CHECK(topology.element_neighbors(-1).empty()); + CHECK(topology.boundary_elements_on_face(100).empty()); + CHECK(topology.element_domain_id(100) == 0); +} diff --git a/tests/discritization/domain/schema/.DS_Store b/tests/discritization/domain/schema/.DS_Store deleted file mode 100644 index ba7b0dc6f56cf28c278a0f84bf6358857d7dc5bc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKyG{c!5S)b+5ouCV`WN^ED+*u04+OXd>BJR0>Z|fyd>XTlg6N;{_957umlDoO^>$@<#H0@Q1hO z@oAbyPmoCgDIf);fE17dXH%d`>w0sx6LpXjkOG&YfPWtv-LV(WiSg;+5G??4#c&ws z(Mu4U2Z+6JPGp2;NhK!Ls>QIRGu|q%7tV=EhgI`ob+c86VsSgqZ;=k`i5jJV6gXAj zI+t6o|M&Di`u|gsR#HF;T$BQ~+CS`fd{Wic#pArzHu{0?IbU=)&V#}s$}us@F&AEr eZzC!5n$Nl43+Ke3GahuJeg<3@nH2bM1-=0*h!vFp diff --git a/tests/discritization/domain/schema/utils.cpp b/tests/discritization/domain/schema/utils.cpp deleted file mode 100644 index e988edb1..00000000 --- a/tests/discritization/domain/schema/utils.cpp +++ /dev/null @@ -1,42 +0,0 @@ -#include -#include - -#include "serif/discretization/domain/physical_domains.hpp" -#include "serif/discretization/domain/schema/schemas.hpp" -#include "serif/discretization/domain/schema/utils.hpp" -#include "serif/discretization/domain/types.hpp" - -#include "serif/tests/test_tags.hpp" -#include "serif/tests/discritization/domain/domain_test_utils.hpp" - -namespace domain = serif::discretization::domain; -namespace schema = domain::schema; - -TEST_CASE( - "Domain Schema Builds Exact MFEM Attribute Markers", - tags::domain &tags::utils &tags::unit -) { - using Schema = schema::CoreEnvelopeVacuumDomainSchema; - - const mfem::Mesh mesh = domain_test_utils::make_layered_mesh(); - - const mfem::Array stellarMarker = schema::make_attribute_marker(mesh); - const mfem::Array vacuumMarker = schema::make_attribute_marker(mesh); - const mfem::Array allMarker = schema::make_attribute_marker(mesh); - - REQUIRE(stellarMarker.Size() == 3); - REQUIRE(vacuumMarker.Size() == 3); - REQUIRE(allMarker.Size() == 3); - - CHECK(stellarMarker[0] == 1); - CHECK(stellarMarker[1] == 1); - CHECK(stellarMarker[2] == 0); - - CHECK(vacuumMarker[0] == 0); - CHECK(vacuumMarker[1] == 0); - CHECK(vacuumMarker[2] == 1); - - CHECK(allMarker[0] == 1); - CHECK(allMarker[1] == 1); - CHECK(allMarker[2] == 1); -} diff --git a/tests/discritization/domain/schema/validation/all.cpp b/tests/discritization/domain/schema/validation/all.cpp index 8533dcbe..74dea511 100644 --- a/tests/discritization/domain/schema/validation/all.cpp +++ b/tests/discritization/domain/schema/validation/all.cpp @@ -3,11 +3,11 @@ #include #include -#include +#include "mfem/mesh/mesh.hpp" #include #include "serif/discretization/domain/schema/schemas.hpp" -#include "serif/discretization/domain/schema/validation/all.hpp" +#include "serif/backend/mfem/discretization/schema.hpp" #include "serif/tests/test_tags.hpp" #include "serif/tests/discritization/domain/domain_test_utils.hpp" @@ -23,7 +23,7 @@ TEST_CASE( const mfem::Mesh mesh = domain_test_utils::make_layered_mesh(); const auto validation = - schema_validation::validate_schema(mesh); + serif::backend::mfem::discretization::validate_schema(mesh); REQUIRE(validation.valid()); REQUIRE(validation.relationResults.size() == 7); @@ -53,7 +53,7 @@ TEST_CASE( */ const mfem::Mesh mesh = domain_test_utils::make_layered_mesh(false, true); const auto validation = - schema_validation::validate_schema(mesh); + serif::backend::mfem::discretization::validate_schema(mesh); CHECK_FALSE(validation.valid()); REQUIRE(validation.relationResults.size() == 7); CHECK(validation.failed_relation_count() == 1); @@ -145,7 +145,7 @@ TEST_CASE( * accept a mesh generated under another numbering convention. */ const auto productionValidation = - schema_validation::validate_schema(*stroidMesh.mesh); + serif::backend::mfem::discretization::validate_schema(*stroidMesh.mesh); CHECK_FALSE(productionValidation.valid()); CHECK(productionValidation.failed_relation_count() > 0); @@ -157,7 +157,7 @@ TEST_CASE( ) { const mfem::Mesh mesh = domain_test_utils::make_grid_mesh(3, 3, {2, 2, 2, 2, 1, 2, 2, 2, 2}, {}); - const auto validation = schema_validation::validate_schema(mesh); + const auto validation = serif::backend::mfem::discretization::validate_schema(mesh); CHECK_FALSE(validation.valid()); REQUIRE(validation.relationResults.size() == 7); diff --git a/tests/discritization/domain/schema/validation/boundary.cpp b/tests/discritization/domain/schema/validation/boundary.cpp index e8bcd624..229b1b76 100644 --- a/tests/discritization/domain/schema/validation/boundary.cpp +++ b/tests/discritization/domain/schema/validation/boundary.cpp @@ -1,9 +1,9 @@ #include #include -#include +#include "mfem/mesh/mesh.hpp" -#include "serif/discretization/domain/mesh/topology.hpp" +#include "serif/backend/mfem/discretization/topology.hpp" #include "serif/discretization/domain/physical_domains.hpp" #include "serif/discretization/domain/relation/relations.hpp" #include "serif/discretization/domain/relation/validation/runtime.hpp" @@ -20,6 +20,7 @@ namespace schema = domain::schema; namespace validation = schema::validation; using domain::mesh::MeshTopology; +using serif::backend::mfem::discretization::make_mesh_topology; TEST_CASE( "Domain Boundary Accepts A Complete Internal Stellar Vacuum Interface", @@ -28,7 +29,7 @@ TEST_CASE( std::vector boundaries{{.firstVertexId = 1, .secondVertexId = 4, .attribute = 1}}; const mfem::Mesh mesh = domain_test_utils::make_grid_mesh(2, 1, {2, 3}, boundaries); - const MeshTopology topology{mesh}; + const MeshTopology topology = make_mesh_topology(mesh); const auto result = validation::RelationValidator>:: - template validate(MeshTopology{mesh}); + template validate(make_mesh_topology(mesh)); CHECK(result); } @@ -76,7 +77,7 @@ TEST_CASE( const auto result = validation::RelationValidator>:: - template validate(MeshTopology{mesh}); + template validate(make_mesh_topology(mesh)); CHECK_FALSE(result); CHECK( @@ -92,7 +93,7 @@ TEST_CASE( const auto result = validation::RelationValidator>:: - template validate(MeshTopology{mesh}); + template validate(make_mesh_topology(mesh)); CHECK_FALSE(result); CHECK( @@ -112,7 +113,7 @@ TEST_CASE( const auto result = validation::RelationValidator>:: - template validate(MeshTopology{mesh}); + template validate(make_mesh_topology(mesh)); CHECK_FALSE(result); @@ -131,7 +132,7 @@ TEST_CASE( const auto result = validation::RelationValidator>:: - template validate(MeshTopology{mesh}); + template validate(make_mesh_topology(mesh)); CHECK_FALSE(result); CHECK(result.failure == validation::RelationValidationFailure::DomainBoundaryExpectedFaceIsUntagged); @@ -150,7 +151,7 @@ TEST_CASE( const auto result = validation::RelationValidator>:: - template validate(MeshTopology{mesh}); + template validate(make_mesh_topology(mesh)); CHECK_FALSE(result); CHECK( @@ -172,7 +173,7 @@ TEST_CASE( const auto result = validation::RelationValidator>:: - template validate(MeshTopology{mesh}); + template validate(make_mesh_topology(mesh)); CHECK_FALSE(result); CHECK(result.failure == validation::RelationValidationFailure::DomainBoundaryAbsent); diff --git a/tests/discritization/domain/schema/validation/connected.cpp b/tests/discritization/domain/schema/validation/connected.cpp index 69156c2d..a76724c9 100644 --- a/tests/discritization/domain/schema/validation/connected.cpp +++ b/tests/discritization/domain/schema/validation/connected.cpp @@ -1,7 +1,7 @@ #include -#include +#include "mfem/mesh/mesh.hpp" -#include "serif/discretization/domain/mesh/topology.hpp" +#include "serif/backend/mfem/discretization/topology.hpp" #include "serif/discretization/domain/physical_domains.hpp" #include "serif/discretization/domain/relation/relations.hpp" #include "serif/discretization/domain/relation/validation/runtime.hpp" @@ -18,13 +18,14 @@ namespace schema = domain::schema; namespace validation = schema::validation; using domain::mesh::MeshTopology; +using serif::backend::mfem::discretization::make_mesh_topology; TEST_CASE( "Connected Accepts Face Connected Atomic And Composite Domains", tags::unit &tags::mesh &tags::utils &tags::domain ) { const mfem::Mesh mesh = domain_test_utils::make_layered_mesh(); - const MeshTopology topology{mesh}; + const MeshTopology topology = make_mesh_topology(mesh); const auto coreResult = validation::RelationValidator>::template validate(topology); @@ -47,7 +48,7 @@ TEST_CASE( ) { const mfem::Mesh mesh = domain_test_utils::make_grid_mesh(2, 1, {2, 2}, {}); - const auto result = validation::RelationValidator>::template validate(MeshTopology{mesh}); + const auto result = validation::RelationValidator>::template validate(make_mesh_topology(mesh)); CHECK_FALSE(result); CHECK(result.failure == validation::RelationValidationFailure::DomainAbsent); @@ -62,7 +63,7 @@ TEST_CASE( ) { const mfem::Mesh mesh = domain_test_utils::make_grid_mesh(3, 1, {1, 2, 1}, {}); - const auto result = validation::RelationValidator>::template validate(MeshTopology{mesh}); + const auto result = validation::RelationValidator>::template validate(make_mesh_topology(mesh)); CHECK_FALSE(result); CHECK(result.failure == validation::RelationValidationFailure::DomainDisconnected); diff --git a/tests/discritization/domain/schema/validation/inscribed.cpp b/tests/discritization/domain/schema/validation/inscribed.cpp index b0bbee2f..cf39e9f1 100644 --- a/tests/discritization/domain/schema/validation/inscribed.cpp +++ b/tests/discritization/domain/schema/validation/inscribed.cpp @@ -1,9 +1,9 @@ #include #include -#include +#include "mfem/mesh/mesh.hpp" -#include "serif/discretization/domain/mesh/topology.hpp" +#include "serif/backend/mfem/discretization/topology.hpp" #include "serif/discretization/domain/physical_domains.hpp" #include "serif/discretization/domain/relation/relations.hpp" #include "serif/discretization/domain/relation/validation/runtime.hpp" @@ -20,13 +20,14 @@ namespace schema = domain::schema; namespace validation = schema::validation; using domain::mesh::MeshTopology; +using serif::backend::mfem::discretization::make_mesh_topology; TEST_CASE( "Inscribed Accepts Nested Atomic And Composite Domains", tags::unit &tags::mesh &tags::utils &tags::domain ) { const mfem::Mesh mesh = domain_test_utils::make_layered_mesh(); - const MeshTopology topology{mesh}; + const MeshTopology topology = make_mesh_topology(mesh); const auto coreResult = validation::RelationValidator>::template validate(topology); @@ -43,7 +44,7 @@ TEST_CASE( ) { const mfem::Mesh mesh = domain_test_utils::make_grid_mesh(2, 2, {2, 2, 2, 2}, {}); - const auto result = validation::RelationValidator>::template validate(MeshTopology{mesh}); + const auto result = validation::RelationValidator>::template validate(make_mesh_topology(mesh)); CHECK_FALSE(result); CHECK(result.failure == validation::RelationValidationFailure::InnerDomainAbsent); @@ -55,7 +56,7 @@ TEST_CASE( ) { const mfem::Mesh mesh = domain_test_utils::make_grid_mesh(1, 1, {1}, {}); - const auto result = validation::RelationValidator>::template validate(MeshTopology{mesh}); + const auto result = validation::RelationValidator>::template validate(make_mesh_topology(mesh)); CHECK_FALSE(result); CHECK(result.failure == validation::RelationValidationFailure::OuterDomainAbsent); @@ -67,7 +68,7 @@ TEST_CASE( ) { const mfem::Mesh mesh = domain_test_utils::make_grid_mesh(2, 2, {1, 2, 2, 2}, {}); - const auto result = validation::RelationValidator>::template validate(MeshTopology{mesh}); + const auto result = validation::RelationValidator>::template validate(make_mesh_topology(mesh)); CHECK_FALSE(result); CHECK(result.failure == validation::RelationValidationFailure::InnerDomainTouchesMeshBoundary); @@ -87,7 +88,7 @@ TEST_CASE( const auto result = validation::RelationValidator< relation::Inscribed>:: - template validate(MeshTopology{mesh}); + template validate(make_mesh_topology(mesh)); CHECK_FALSE(result); CHECK(result.failure == validation::RelationValidationFailure::InnerDomainTouchesUnexpectedDomain); diff --git a/tests/discritization/field_support_meanfield.cpp b/tests/discritization/field_support_meanfield.cpp new file mode 100644 index 00000000..cd20f509 --- /dev/null +++ b/tests/discritization/field_support_meanfield.cpp @@ -0,0 +1,279 @@ +// MeanField field_dof_map.cpp and field_mfem.cpp invariants expressed through serial TermBinding. +#include +#include +#include +#include +#include + +#include + +#include "mfem/fem/gridfunc.hpp" +#include "serif/tests/equations/test_equations.hpp" +#include "serif/backend/mfem/discretization/dof_selection.hpp" +#include "serif/discretization/domain/schema/schemas.hpp" +#include "serif/tests/deformation/mesh.hpp" + +namespace +{ + namespace discretization = serif::discretization; + namespace backend = discretization::backend::mfem; + namespace blocks = discretization::blocks; + namespace domain = discretization::domain; + using Schema = domain::schema::CoreEnvelopeVacuumDomainSchema; + using AlternateSchema = domain::schema::DomainSchema, domain::ids::DomainID, domain::ids::DomainID>, domain::ids::lists::BoundaryIDList<>, domain::relation::lists::RelationList<>>; + + /** @brief A stellar velocity represented by independent discontinuous scalar components. */ + struct DiscontinuousVelocity final : blocks::field + { + struct vector final : blocks::term + { + using FunctionalSpace = discretization::functional_space::DiscreteFunctionalSpace; + using Support = domain::StellarDomains; + }; + }; + + /** @brief Two adjacent cells expose both inactive vacuum DOFs and shared continuous trace DOFs. */ + [[nodiscard]] mfem::Mesh split_mesh(const int stellar_attribute = 2, const int vacuum_attribute = 3) + { + mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(2, 1, 1, mfem::Element::HEXAHEDRON, 2.0, 1.0, 1.0); + mesh.GetElement(0)->SetAttribute(stellar_attribute); + mesh.GetElement(1)->SetAttribute(vacuum_attribute); + mesh.SetAttributes(); + return mesh; + } + + /** @brief Independently mark the native element DOFs selected by semantic support. */ + template + [[nodiscard]] std::vector expected_support(const mfem::FiniteElementSpace& space) + { + std::vector active(space.GetVSize(), false); + mfem::Array dofs; + for (int element = 0; element < space.GetNE(); ++element) + { + if (!DomainSchema::template domain_id_belongs_to(space.GetMesh()->GetAttribute(element))) + { + continue; + } + space.GetElementVDofs(element, dofs); + for (const int encoded : dofs) + { + active[encoded < 0 ? -1 - encoded : encoded] = true; + } + } + return active; + } + + /** @brief Compare canonical indexing, projection, and GridFunction transfer with the independent marker. */ + template + void check_supported_projection(mfem::FiniteElementSpace& space) + { + const backend::TermBinding binding{space}; + const std::vector active = expected_support(space); + REQUIRE(binding.full_size() == space.GetVSize()); + CHECK(binding.size() == std::ranges::count(active, true)); + mfem::Vector full(binding.full_size()); + for (int dof = 0; dof < full.Size(); ++dof) + { + full[dof] = 10.0 + dof; + } + const mfem::Vector reduced = binding.gather(full); + const mfem::Vector projected = binding.scatter(reduced); + const mfem::Vector recovered = binding.gather(projected); + int next_reduced = 0; + for (int dof = 0; dof < full.Size(); ++dof) + { + CAPTURE(dof); + const std::optional index = binding.reduced_dof(dof); + CHECK(index.has_value() == active[dof]); + if (active[dof]) + { + REQUIRE(index); + CHECK(*index == next_reduced++); + CHECK(reduced[*index] == full[dof]); + CHECK(recovered[*index] == reduced[*index]); + CHECK(projected[dof] == full[dof]); + } + else + { + CHECK(projected[dof] == 0.0); + } + } + + // MFEM owns the GridFunction conversion; no additional field-adapter lifecycle is needed. + mfem::GridFunction grid(&space); + grid.SetFromTrueDofs(projected); + mfem::Vector true_values; + grid.GetTrueDofs(true_values); + const mfem::Vector grid_values = binding.gather(true_values); + for (int dof = 0; dof < reduced.Size(); ++dof) + { + CHECK(grid_values[dof] == reduced[dof]); + } + std::vector storage(full.Size() + 2, -77.0); + std::copy_n(full.GetData(), full.Size(), storage.data() + 1); + const mfem::Vector full_view(storage.data() + 1, full.Size()); + const mfem::Vector from_view = binding.gather(full_view); + for (int dof = 0; dof < reduced.Size(); ++dof) + { + CHECK(from_view[dof] == reduced[dof]); + } + CHECK(storage.front() == -77.0); + CHECK(storage.back() == -77.0); + CHECK_THROWS_AS(binding.reduced_dof(-1), std::out_of_range); + CHECK_THROWS_AS(binding.reduced_dof(binding.full_size()), std::out_of_range); + CHECK_THROWS_AS(binding.supports(-1), std::out_of_range); + CHECK_THROWS_AS(binding.supports(space.GetNE()), std::out_of_range); + CHECK_THROWS_AS(binding.gather(mfem::Vector(binding.full_size() + 1)), std::invalid_argument); + CHECK_THROWS_AS(binding.scatter(mfem::Vector(binding.size() + 1)), std::invalid_argument); + } +} + +TEST_CASE("MeanField supported field projections preserve canonical indexing and native GridFunctions", "[unit][discretization][meanfield]") +{ + mfem::Mesh mesh = split_mesh(); + mfem::H1_FECollection density_collection(blocks::density::mass::FunctionalSpace::order, 3); + mfem::L2_FECollection velocity_collection(2, 3); + mfem::H1_FECollection enthalpy_collection(3, 3); + mfem::FiniteElementSpace density(&mesh, &density_collection); + mfem::FiniteElementSpace enthalpy(&mesh, &enthalpy_collection); + mfem::H1_FECollection potential_collection(blocks::gravity::potential::FunctionalSpace::order, 3); + mfem::FiniteElementSpace potential(&mesh, &potential_collection); + check_supported_projection(density); + check_supported_projection<::serif::tests::equations::Enthalpy>(enthalpy); + check_supported_projection(potential); + for (const mfem::Ordering::Type ordering : {mfem::Ordering::byNODES, mfem::Ordering::byVDIM}) + { + mfem::FiniteElementSpace displacement(&mesh, &enthalpy_collection, 3, ordering); + check_supported_projection(displacement); + mfem::FiniteElementSpace velocity(&mesh, &velocity_collection, 3, ordering); + CHECK(velocity.GetFE(0)->GetOrder() == 2); + CHECK(velocity.GetVDim() == 3); + CHECK(velocity.GetOrdering() == ordering); + check_supported_projection(velocity); + } + CHECK_THROWS_AS((backend::TermBinding{density}), std::invalid_argument); +} + +TEST_CASE("MeanField stellar H1 fields retain interface values and exclude vacuum-only coefficients", "[unit][discretization][meanfield]") +{ + mfem::Mesh mesh = split_mesh(); + mfem::H1_FECollection h1(3, 3); + mfem::FiniteElementSpace enthalpy_space(&mesh, &h1), density_space(&mesh, &h1); + const backend::TermBinding<::serif::tests::equations::Enthalpy, Schema> enthalpy{enthalpy_space}; + const backend::TermBinding density{density_space}; + mfem::Array stellar, vacuum; + enthalpy_space.GetElementDofs(0, stellar); + enthalpy_space.GetElementDofs(1, vacuum); + int shared = 0; + int excluded = 0; + for (const int dof : vacuum) + { + const bool on_interface = std::find(stellar.begin(), stellar.end(), dof) != stellar.end(); + CHECK(enthalpy.reduced_dof(dof).has_value() == on_interface); + shared += on_interface; + excluded += !on_interface; + } + CHECK(shared > 0); + CHECK(excluded > 0); + density_space.GetElementDofs(0, stellar); + density_space.GetElementDofs(1, vacuum); + shared = 0; + excluded = 0; + for (const int dof : vacuum) + { + const bool on_interface = std::find(stellar.begin(), stellar.end(), dof) != stellar.end(); + CHECK(density.reduced_dof(dof).has_value() == on_interface); + shared += on_interface; + excluded += !on_interface; + } + CHECK(shared > 0); + CHECK(excluded > 0); + CHECK(enthalpy.supports(0)); + CHECK_FALSE(enthalpy.supports(1)); + CHECK(density.supports(0)); + CHECK_FALSE(density.supports(1)); +} + +TEST_CASE("MeanField field support follows alternate semantic material IDs", "[unit][discretization][meanfield]") +{ + mfem::Mesh mesh = split_mesh(17, 29); + mfem::H1_FECollection h1(3, 3); + mfem::FiniteElementSpace density(&mesh, &h1), enthalpy(&mesh, &h1); + check_supported_projection(density); + check_supported_projection<::serif::tests::equations::Enthalpy, AlternateSchema>(enthalpy); +} + +TEST_CASE("MeanField supported maps reject stale bindings and malformed selections", "[unit][discretization][meanfield]") +{ + mfem::Mesh mesh = split_mesh(); + mfem::H1_FECollection collection(3, 3); + mfem::FiniteElementSpace space(&mesh, &collection, 3); + const backend::TermBinding binding{space}; + for (const std::vector& entries : {std::vector{-1, 2}, {1, binding.size()}, {1, 1, 3}, {1, 3, 2}}) + { + mfem::Array selected(static_cast(entries.size())); + std::copy(entries.begin(), entries.end(), selected.begin()); + CHECK_THROWS_AS(backend::DofSelection(binding, selected), std::invalid_argument); + } + mfem::Vector values(binding.size()); + values = 0.0; + CHECK_NOTHROW(binding.validate_values(values)); + values[0] = std::numeric_limits::quiet_NaN(); + CHECK_THROWS_AS(binding.scatter(values), std::domain_error); + mesh.GetElement(0)->SetAttribute(3); + CHECK_THROWS(binding.validate()); +} + +TEST_CASE("MeanField registered spaces retain family order vector dimension and global scalar storage", "[unit][discretization][meanfield]") +{ + mfem::Mesh mesh = split_mesh(); + mfem::H1_FECollection h1(3, 3); + mfem::H1_FECollection potential_collection(blocks::gravity::potential::FunctionalSpace::order, 3); + mfem::FiniteElementSpace potential(&mesh, &potential_collection), displacement(&mesh, &h1, 3); + const backend::TermBinding potential_binding{potential}; + const backend::TermBinding displacement_binding{displacement}; + CHECK(potential.GetFE(0)->GetOrder() == blocks::gravity::potential::FunctionalSpace::order); + CHECK(displacement.GetFE(0)->GetOrder() == 3); + CHECK(potential.GetVDim() == 1); + CHECK(displacement.GetVDim() == 3); + CHECK(potential_binding.size() == potential.GetVSize()); + CHECK(displacement_binding.size() == displacement.GetVSize()); + const backend::TermBinding global{mesh}; + CHECK(global.size() == 1); + CHECK(global.full_size() == 1); + CHECK_THROWS_AS(global.space(), std::logic_error); + mfem::Vector scalar(1); + scalar[0] = 2.7; + CHECK(global.gather(global.scatter(scalar))[0] == 2.7); + mfem::Vector local; + global.element_values(0, scalar, local); + CHECK(local[0] == 2.7); + global.element_values(1, scalar, local); + CHECK(local[0] == 0.0); + CHECK_THROWS_AS((backend::TermBinding{displacement}), std::invalid_argument); +} + +TEST_CASE("MeanField stellar boundary selections preserve exact reduced field ordering", "[unit][discretization][meanfield]") +{ + serif::tests::deformation::PairedMesh fixture(0, 0.0, 3, 4); + const backend::TermBinding<::serif::tests::equations::Enthalpy, Schema> enthalpy{fixture.scalar_space}; + const backend::DofSelection selected = backend::select_boundary_dofs(enthalpy); + REQUIRE(selected.size() > 0); + CHECK(selected.size() < enthalpy.size()); + CHECK(&selected.binding() == &enthalpy); + mfem::Array marker{1, 0}; + mfem::Array full_boundary; + fixture.scalar_space.GetEssentialTrueDofs(marker, full_boundary); + REQUIRE(selected.size() == full_boundary.Size()); + for (int index = 0; index < full_boundary.Size(); ++index) + { + const std::optional reduced = enthalpy.reduced_dof(full_boundary[index]); + REQUIRE(reduced); + CHECK(selected.reduced_dofs()[index] == *reduced); + if (index > 0) + { + CHECK(selected.reduced_dofs()[index] > selected.reduced_dofs()[index - 1]); + } + } + CHECK_NOTHROW(selected.validate()); +} diff --git a/tests/discritization/meson.build b/tests/discritization/meson.build index 5ed1e782..6c3a7d1a 100644 --- a/tests/discritization/meson.build +++ b/tests/discritization/meson.build @@ -1,12 +1,15 @@ discretization_test_sources = files( + 'domain/mesh_topology.cpp', + 'field_support_meanfield.cpp', + 'nonconforming.cpp', + 'reconstruction.cpp', 'domain/concepts.cpp', -# 'domain/ids/lists/lists.cpp', -# 'domain/relation/relations.cpp', -# 'domain/schema/domain_schema.cpp', -# 'domain/schema/schemas.cpp', -# 'domain/schema/utils.cpp', -# 'domain/schema/validation/all.cpp', -# 'domain/schema/validation/boundary.cpp', -# 'domain/schema/validation/connected.cpp', -# 'domain/schema/validation/inscribed.cpp', + 'domain/ids/lists/lists.cpp', + 'domain/relation/relations.cpp', + 'domain/schema/domain_schema.cpp', + 'domain/schema/schemas.cpp', + 'domain/schema/validation/all.cpp', + 'domain/schema/validation/boundary.cpp', + 'domain/schema/validation/connected.cpp', + 'domain/schema/validation/inscribed.cpp', ) diff --git a/tests/discritization/nonconforming.cpp b/tests/discritization/nonconforming.cpp new file mode 100644 index 00000000..bd6fbcc9 --- /dev/null +++ b/tests/discritization/nonconforming.cpp @@ -0,0 +1,219 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include "mfem/fem/bilinearform.hpp" +#include "mfem/fem/bilininteg.hpp" +#include "serif/backend/mfem/discretization/element_coordinates.hpp" +#include "serif/backend/mfem/discretization/mass_diagonal.hpp" +#include "serif/backend/mfem/discretization/schema.hpp" +#include "serif/tests/nonconforming.hpp" +#include "serif/discretization/domain/schema/schemas.hpp" + +namespace { + namespace backend = serif::discretization::backend::mfem; + namespace blocks = serif::discretization::blocks; + using Schema = serif::discretization::domain::schema::CoreEnvelopeVacuumDomainSchema; + + /** @brief Keep each saved STROID fixture private to this invocation and remove it after any assertion. */ + struct TemporaryMesh { + std::filesystem::path path = std::filesystem::temp_directory_path() / + ("serif-nc-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + ".smesh"); + /** @brief Release only this test's generated file. */ + ~TemporaryMesh() { + std::error_code error; + std::filesystem::remove(path, error); + } + }; + + /** @brief Use nonconstant signed data so primal restriction cannot masquerade as dual accumulation. */ + [[nodiscard]] ::mfem::Vector sequence(const int size) { + ::mfem::Vector values(size); + for (int index = 0; index < size; ++index) { + values[index] = std::sin(0.17 * (index + 1)) + 0.3 * std::cos(0.23 * index); + } + return values; + } + + /** @brief Compare complete vectors with a norm-scaled roundoff tolerance. */ + void check_values(const ::mfem::Vector& actual, const ::mfem::Vector& expected) { + REQUIRE(actual.Size() == expected.Size()); + ::mfem::Vector difference(actual); + difference -= expected; + CHECK(difference.Norml2() <= 2.0e-12 * std::max(1.0, expected.Norml2())); + } + + /** @brief Independently close element support through native MFEM prolongation rows. */ + [[nodiscard]] std::vector supported_true_dofs(const backend::DofMap& binding) { + const ::mfem::SparseMatrix& prolongation = *binding.space().GetConformingProlongation(); + std::vector supported(prolongation.Width(), false); + ::mfem::Array dofs; + for (int element = 0; element < binding.mesh().GetNE(); ++element) { + if (!binding.supports(element)) { + continue; + } + binding.space().GetElementVDofs(element, dofs); + for (const int encoded : dofs) { + const int local = ::mfem::FiniteElementSpace::DecodeDof(encoded); + for (int entry = prolongation.GetI()[local]; entry < prolongation.GetI()[local + 1]; ++entry) { + if (prolongation.GetData()[entry] != 0.0) { + supported[prolongation.GetJ()[entry]] = true; + } + } + } + } + std::vector result; + for (int index = 0; index < prolongation.Width(); ++index) { + if (supported[index]) { + result.push_back(index); + } + } + return result; + } + + /** @brief Compare primal, dual, and oriented element coordinates with independent native matrices. */ + void check_transfers(const backend::DofMap& binding) { + REQUIRE(binding.constrained()); + const auto supported = supported_true_dofs(binding); + REQUIRE(static_cast(supported.size()) == binding.size()); + const ::mfem::Vector reduced = sequence(binding.size()); + ::mfem::Vector independent(binding.true_size()); + independent = 0.0; + for (int index = 0; index < binding.size(); ++index) { + independent[supported[index]] = reduced[index]; + } + ::mfem::Vector expected(binding.full_size()); + binding.space().GetConformingProlongation()->Mult(independent, expected); + check_values(binding.scatter(reduced), expected); + check_values(binding.scatter_true(reduced), independent); + check_values(binding.gather(expected), reduced); + const ::mfem::Vector dual = sequence(binding.full_size()); + ::mfem::Vector true_dual(binding.true_size()), expected_dual(binding.size()); + binding.space().GetConformingProlongation()->MultTranspose(dual, true_dual); + for (int index = 0; index < binding.size(); ++index) { + expected_dual[index] = true_dual[supported[index]]; + } + check_values(binding.accumulate_dual(dual), expected_dual); + const double full_work = expected * dual; + CHECK(std::abs(reduced * expected_dual - full_work) <= 2.0e-11 * std::max(1.0, std::abs(full_work))); + ::mfem::Vector arbitrary_true(binding.true_size()), expected_primal(binding.size()); + binding.space().GetConformingRestriction()->Mult(dual, arbitrary_true); + for (int index = 0; index < binding.size(); ++index) { + expected_primal[index] = arbitrary_true[supported[index]]; + } + check_values(binding.gather(dual), expected_primal); + const auto elements = binding.validated_elements(); + for (int element = 0; element < binding.mesh().GetNE(); ++element) { + if (!binding.supports(element)) { + continue; + } + ::mfem::Array dofs; + const auto* transformation = binding.space().GetElementVDofs(element, dofs); + ::mfem::Vector native, actual; + expected.GetSubVector(dofs, native); + if (transformation != nullptr) { + transformation->InvTransformPrimal(native); + } + elements.element_values(element, reduced, actual); + check_values(actual, native); + } + } + + /** @brief Retain every local mass coupling before reducing; local diagonal restriction is not an oracle. */ + template + void check_mass(const backend::TermBinding& binding) { + ::mfem::Array marker(binding.mesh().attributes.Max()); + marker = 0; + for (int element = 0; element < binding.mesh().GetNE(); ++element) { + if (binding.supports(element)) { + marker[binding.mesh().GetAttribute(element) - 1] = 1; + } + } + ::mfem::BilinearForm native(&binding.space()); + if constexpr (std::same_as) { + native.AddDomainIntegrator(new ::mfem::VectorMassIntegrator(), marker); + } else { + native.AddDomainIntegrator(new ::mfem::MassIntegrator(), marker); + } + native.Assemble(); + native.Finalize(); + const ::mfem::SparseMatrix& prolongation = *binding.space().GetConformingProlongation(); + const std::unique_ptr<::mfem::SparseMatrix> constrained(::mfem::RAP(prolongation, native.SpMat(), prolongation)); + ::mfem::Vector diagonal; + constrained->GetDiag(diagonal); + const auto supported = supported_true_dofs(binding); + const auto actual = backend::reference_mass_diagonal(binding); + REQUIRE(actual); + REQUIRE(actual->size() == supported.size()); + for (int index = 0; index < binding.size(); ++index) { + const double expected = diagonal[supported[index]]; + CHECK(std::abs((*actual)[index] - expected) <= 2.0e-11 * expected); + } + } +} + +TEST_CASE("STROID nonconforming meshes preserve native coefficient and dual transfers", "[integration][nonconforming]") { + auto generated = serif::tests::nonconforming::mesh(0, 3); + REQUIRE(generated.mesh->Nonconforming()); + CHECK(serif::backend::mfem::discretization::validate_schema(*generated.mesh).valid()); + CHECK(serif::backend::mfem::discretization::validate_schema(*generated.reference_mesh).valid()); + ::mfem::H1_FECollection scalar_collection(blocks::density::mass::FunctionalSpace::order, 3); + ::mfem::FiniteElementSpace scalar(generated.mesh.get(), &scalar_collection); + REQUIRE(scalar.GetTrueVSize() < scalar.GetVSize()); + backend::TermBinding density(scalar); + ::mfem::H1_FECollection potential_collection(blocks::gravity::potential::FunctionalSpace::order, 3); + ::mfem::FiniteElementSpace potential_space(generated.mesh.get(), &potential_collection); + REQUIRE(potential_space.GetTrueVSize() < potential_space.GetVSize()); + backend::TermBinding potential(potential_space); + CHECK(density.size() < potential.size()); + REQUIRE(potential.size() == potential_space.GetTrueVSize()); + const ::mfem::SparseMatrix& density_prolongation = *scalar.GetConformingProlongation(); + int material_hanging_coefficients = 0; + ::mfem::Array material_dofs; + for (int element = 0; element < scalar.GetNE(); ++element) { + if (!density.supports(element)) { + continue; + } + scalar.GetElementDofs(element, material_dofs); + for (const int local : material_dofs) { + const int begin = density_prolongation.GetI()[local]; + if (density_prolongation.GetI()[local + 1] != begin + 1 || density_prolongation.GetData()[begin] != 1.0) { + ++material_hanging_coefficients; + } + } + } + REQUIRE(material_hanging_coefficients > 0); + check_transfers(density); + check_transfers(potential); + check_mass(density); + check_mass(potential); + for (const auto ordering : {::mfem::Ordering::byNODES, ::mfem::Ordering::byVDIM}) { + ::mfem::FiniteElementSpace vector(generated.mesh.get(), &scalar_collection, 3, ordering); + backend::TermBinding displacement(vector); + check_transfers(displacement); + check_mass(displacement); + } +} + +TEST_CASE("STROID saved meshes retain genuine hanging faces when loaded directly", "[integration][nonconforming]") { + auto generated = serif::tests::nonconforming::mesh(); + const TemporaryMesh file; + stroid::IO::SaveStroidMesh(generated, file.path.string()); + auto restored = stroid::IO::LoadStroidMesh(file.path.string()); + INFO((restored ? "restored" : restored.error())); + REQUIRE(restored); + REQUIRE(restored->mesh->Nonconforming()); + REQUIRE(restored->reference_mesh->Nonconforming()); + CHECK(restored->mesh->GetNE() == generated.mesh->GetNE()); + ::mfem::H1_FECollection collection(blocks::gravity::potential::FunctionalSpace::order, 3); + ::mfem::FiniteElementSpace space(restored->mesh.get(), &collection); + REQUIRE(space.GetTrueVSize() < space.GetVSize()); + backend::TermBinding potential(space); + check_transfers(potential); + CHECK(serif::backend::mfem::discretization::validate_schema(*restored->mesh).valid()); +} diff --git a/tests/discritization/reconstruction.cpp b/tests/discritization/reconstruction.cpp new file mode 100644 index 00000000..9ddc59cb --- /dev/null +++ b/tests/discritization/reconstruction.cpp @@ -0,0 +1,77 @@ +#include +#include + +#include +#include +#include "mfem/fem/fe/fe_h1.hpp" +#include "serif/backend/mfem/discretization/basis_evaluation.hpp" + +TEST_CASE("Surface power retains nodal density and its analytic surface limit", "[unit][reconstruction]") { + namespace reconstruction = serif::discretization::reconstruction; + const std::array nodes{0.0, 0.2, 0.7, 1.0}; + std::array values, slopes; + for (const double exponent : {1.0, 4.0 / 3.0, 5.0 / 3.0, 2.0, 2.8}) { + const reconstruction::SurfacePowerProfile profile(exponent); + for (std::size_t node = 0; node < nodes.size(); ++node) { + profile.evaluate(nodes, nodes[node], values, slopes); + for (std::size_t basis = 0; basis < nodes.size(); ++basis) { + CHECK(values[basis] == Catch::Approx(node == basis ? 1.0 : 0.0).margin(2e-14)); + CHECK(std::isfinite(slopes[basis])); + } + } + for (const double point : {0.0, 1e-12, 0.01, 0.35, 0.9, 1.0}) { + profile.evaluate(nodes, point, values, slopes); + double density = 0.0, derivative = 0.0, constant = 0.0, constant_derivative = 0.0; + for (std::size_t basis = 0; basis < nodes.size(); ++basis) { + density += values[basis] * std::pow(nodes[basis], exponent); + derivative += slopes[basis] * std::pow(nodes[basis], exponent); + constant += values[basis]; + constant_derivative += slopes[basis]; + } + CHECK(density == Catch::Approx(std::pow(point, exponent)).margin(3e-14)); + CHECK(derivative == Catch::Approx(exponent * std::pow(point, exponent - 1.0)).margin(3e-13)); + CHECK(constant == Catch::Approx(1.0).margin(3e-14)); + CHECK(constant_derivative == Catch::Approx(0.0).margin(3e-13)); + } + } + CHECK_THROWS(reconstruction::SurfacePowerProfile(0.8)); + CHECK_THROWS(reconstruction::SurfacePowerProfile(NAN)); +} + +TEST_CASE("Reconstructed tensor values gradients and transposes agree in every surface orientation", "[unit][reconstruction]") { + namespace backend = serif::discretization::backend::mfem; + namespace reconstruction = serif::discretization::reconstruction; + ::mfem::H1_HexahedronElement element(3); + const auto& rule = ::mfem::IntRules.Get(::mfem::Geometry::CUBE, 12); + backend::TensorBasisCache cache; + backend::BasisWorkspace workspace; + for (const double exponent : {1.0, 4.0 / 3.0, 2.8}) { + const reconstruction::SurfacePowerProfile profile(exponent); + for (int axis = 0; axis < 3; ++axis) { + for (const bool high : {false, true}) { + const backend::ElementReconstruction reconstruction{&profile, axis, high}; + for (const auto operation : {backend::ReferenceOperation::value, backend::ReferenceOperation::gradient, backend::ReferenceOperation::value_and_gradient}) { + backend::BasisEvaluation dense, tensor; + dense.sample(element, rule, operation, 1, cache, backend::ContractionMode::dense, &reconstruction); + tensor.sample(element, rule, operation, 1, cache, backend::ContractionMode::automatic, &reconstruction); + REQUIRE(tensor.uses_tensor()); + ::mfem::Vector coefficients(element.GetDof()), dense_values, tensor_values; + for (int i = 0; i < coefficients.Size(); ++i) { coefficients[i] = std::sin(0.3 * i); } + dense.evaluate(coefficients, dense_values, workspace); + tensor.evaluate(coefficients, tensor_values, workspace); + tensor_values -= dense_values; + CHECK(tensor_values.Normlinf() < 2e-12); + ::mfem::Vector dual(dense.height()), dense_transpose, tensor_transpose; + for (int i = 0; i < dual.Size(); ++i) { dual[i] = std::cos(0.13 * i); } + dense.integrate(dual, dense_transpose, workspace); + tensor.integrate(dual, tensor_transpose, workspace); + CHECK((coefficients * tensor_transpose) == Catch::Approx(dense_values * dual).margin(1e-9)); + tensor_transpose -= dense_transpose; + CHECK(tensor_transpose.Normlinf() < 2e-10); + } + } + } + // The cache borrows the immutable profile, just as it borrows finite elements. + cache = backend::TensorBasisCache{}; + } +} diff --git a/tests/eos/barotrope_meanfield.cpp b/tests/eos/barotrope_meanfield.cpp new file mode 100644 index 00000000..8a9377b3 --- /dev/null +++ b/tests/eos/barotrope_meanfield.cpp @@ -0,0 +1,135 @@ +// Preserve the independent identities, finite differences, and surface conventions from MeanField's barotrope tests. +#include +#include +#include + +#include +#include + +#include "serif/eos/evaluation.hpp" +#include "serif/eos/models/polytropic.hpp" + +namespace +{ + namespace eos = serif::eos; + namespace quantity = serif::dimensions; + using Catch::Matchers::WithinRel; + + /** @brief Evaluate a centered difference independently of the EOS derivative implementation. */ + template + [[nodiscard]] double centered_derivative(Function&& function, const double value, const double step) + { + return (function(value + step) - function(value - step)) / (2.0 * step); + } +} + +TEST_CASE("MeanField polytropic round trips preserve the original strict identities", "[unit][eos][meanfield]") +{ + const eos::models::Polytrope model{3.0, 1.5}; + for (const double density : {1.0e-6, 1.0e-3, 0.1, 0.7, 2.0}) + { + CAPTURE(density); + const double pressure = eos::evaluate(model, quantity::DensityValue{density}).value(); + const double enthalpy = eos::evaluate(model, quantity::DensityValue{density}).value(); + CHECK_THAT(eos::evaluate(model, quantity::SpecificEnthalpyValue{enthalpy}).value(), WithinRel(density, 2.0e-14)); + CHECK_THAT(eos::evaluate(model, quantity::SpecificEnthalpyValue{enthalpy}).value(), WithinRel(pressure, 2.0e-14)); + CHECK_THAT(eos::evaluate(model, quantity::PressureValue{pressure}).value(), WithinRel(enthalpy, 2.0e-14)); + CHECK_THAT(pressure, WithinRel(density * enthalpy / 4.0, 2.0e-14)); + CHECK_THAT((eos::partial_derivative(model, quantity::SpecificEnthalpyValue{enthalpy}).value()), WithinRel(density, 2.0e-14)); + CHECK_THAT((eos::partial_derivative(model, quantity::DensityValue{density}).value()), WithinRel(enthalpy / 3.0, 2.0e-14)); + } +} + +TEST_CASE("MeanField polytropic thermodynamic identities span integer and fractional indices", "[unit][eos][meanfield]") +{ + for (const double index : {1.0, 1.5, 3.0}) + { + const eos::models::Polytrope model{index, 0.73}; + CHECK(model.polytropic_index() == index); + CHECK(model.polytropic_constant() == 0.73); + CHECK(model.enthalpy_scale() == (index + 1.0) * 0.73); + for (const double density : {1.0e-4, 0.02, 0.37, 2.4}) + { + CAPTURE(index, density); + const double pressure = eos::evaluate(model, quantity::DensityValue{density}).value(); + const double enthalpy = eos::evaluate(model, quantity::DensityValue{density}).value(); + const double recovered_density = eos::evaluate(model, quantity::SpecificEnthalpyValue{enthalpy}).value(); + const double recovered_pressure = eos::evaluate(model, quantity::SpecificEnthalpyValue{enthalpy}).value(); + CHECK_THAT(pressure, WithinRel(0.73 * std::pow(density, 1.0 + 1.0 / index), 2.0e-13)); + CHECK_THAT(enthalpy, WithinRel((index + 1.0) * 0.73 * std::pow(density, 1.0 / index), 2.0e-13)); + CHECK_THAT(recovered_density, WithinRel(density, 5.0e-13)); + CHECK_THAT(recovered_pressure, WithinRel(pressure, 5.0e-13)); + CHECK_THAT(recovered_pressure, WithinRel(density * enthalpy / (index + 1.0), 5.0e-13)); + CHECK((eos::partial_derivative(model, quantity::SpecificEnthalpyValue{enthalpy}).value()) == recovered_density); + CHECK_THAT((eos::partial_derivative(model, quantity::DensityValue{density}).value()), WithinRel(enthalpy / index, 5.0e-13)); + } + } +} + +TEST_CASE("MeanField cubic polytrope derivatives retain the strict centered difference tolerance", "[unit][eos][meanfield]") +{ + const eos::models::Polytrope model{3.0, 1.5}; + for (const double enthalpy : {0.05, 0.2, 0.7, 1.4}) + { + CAPTURE(enthalpy); + const double step = 1.0e-6 * std::max(1.0, enthalpy); + const double density_derivative = centered_derivative([&](const double value) + { + return eos::evaluate(model, quantity::SpecificEnthalpyValue{value}).value(); + }, enthalpy, step); + const double pressure_derivative = centered_derivative([&](const double value) + { + return eos::evaluate(model, quantity::SpecificEnthalpyValue{value}).value(); + }, enthalpy, step); + CHECK_THAT(density_derivative, WithinRel((eos::partial_derivative(model, quantity::SpecificEnthalpyValue{enthalpy}).value()), 5.0e-10)); + CHECK_THAT(pressure_derivative, WithinRel((eos::partial_derivative(model, quantity::SpecificEnthalpyValue{enthalpy}).value()), 5.0e-10)); + } +} + +TEST_CASE("MeanField pressure and density derivatives match centered differences for every original index", "[unit][eos][meanfield]") +{ + for (const double index : {1.0, 1.5, 3.0}) + { + const eos::models::Polytrope model{index, 0.61}; + for (const double value : {0.2, 0.73, 1.8}) + { + CAPTURE(index, value); + const double step = 2.0e-6 * std::max(1.0, std::abs(value)); + const double pressure_enthalpy = centered_derivative([&](const double enthalpy) + { + return eos::evaluate(model, quantity::SpecificEnthalpyValue{enthalpy}).value(); + }, value, step); + const double pressure_density = centered_derivative([&](const double density) + { + return eos::evaluate(model, quantity::DensityValue{density}).value(); + }, value, step); + const double density_enthalpy = centered_derivative([&](const double enthalpy) + { + return eos::evaluate(model, quantity::SpecificEnthalpyValue{enthalpy}).value(); + }, value, step); + CHECK_THAT(pressure_enthalpy, WithinRel((eos::partial_derivative(model, quantity::SpecificEnthalpyValue{value}).value()), 5.0e-8)); + CHECK_THAT(pressure_density, WithinRel((eos::partial_derivative(model, quantity::DensityValue{value}).value()), 5.0e-8)); + CHECK_THAT(density_enthalpy, WithinRel((eos::partial_derivative(model, quantity::SpecificEnthalpyValue{value}).value()), 5.0e-8)); + } + } +} + +TEST_CASE("MeanField polytropic surface keeps the n one right derivative and zero exterior", "[unit][eos][meanfield]") +{ + for (const double index : {1.0, 1.5, 3.0}) + { + const eos::models::Polytrope model{index, 0.47}; + for (const double enthalpy : {0.0, -0.3, -1.0}) + { + CAPTURE(index, enthalpy); + CHECK(eos::evaluate(model, quantity::SpecificEnthalpyValue{enthalpy}).value() == 0.0); + CHECK(eos::evaluate(model, quantity::SpecificEnthalpyValue{enthalpy}).value() == 0.0); + CHECK((eos::partial_derivative(model, quantity::SpecificEnthalpyValue{enthalpy}).value()) == 0.0); + const double density_derivative = index == 1.0 && enthalpy == 0.0 ? 1.0 / model.enthalpy_scale() : 0.0; + CHECK((eos::partial_derivative(model, quantity::SpecificEnthalpyValue{enthalpy}).value()) == density_derivative); + } + CHECK(eos::evaluate(model, quantity::DensityValue{0.0}).value() == 0.0); + CHECK(eos::evaluate(model, quantity::DensityValue{0.0}).value() == 0.0); + CHECK((eos::partial_derivative(model, quantity::DensityValue{0.0}).value()) == 0.0); + } +} diff --git a/tests/eos/equation_of_state_type_system.cpp b/tests/eos/equation_of_state_type_system.cpp new file mode 100644 index 00000000..9d1fabdc --- /dev/null +++ b/tests/eos/equation_of_state_type_system.cpp @@ -0,0 +1,237 @@ +// Ported from MeanField tests/physics/equation_of_state_type_system.cpp. + +#include +#include + +#include + +#include "serif/dimensions/type_alias.hpp" +#include "serif/eos/evaluation.hpp" + +#include "serif/tests/test_tags.hpp" + +namespace eos = serif::eos; +namespace dimensions = serif::dimensions; + +namespace { + + struct Entropy final : dimensions::ThermodynamicQuantity {}; + struct ElectronFraction final : dimensions::ThermodynamicQuantity {}; + + using SpecificEnthalpyFromPressureAndEntropy = + eos::relations::Relation; + + class CompleteEquationOfState final { + public: + using Relations = eos::relations::RelationCatalog< + eos::relations::PressureFromDensity, + eos::relations::SpecificEnthalpyFromPressure, + SpecificEnthalpyFromPressureAndEntropy>; + + [[nodiscard]] constexpr dimensions::PressureValue evaluate( + eos::relations::PressureFromDensity, + const dimensions::DensityValue density + ) const noexcept { + return dimensions::PressureValue{2.0 * density.value()}; + } + + [[nodiscard]] constexpr dimensions::SpecificEnthalpyValue evaluate( + eos::relations::SpecificEnthalpyFromPressure, + const dimensions::PressureValue pressure + ) const noexcept { + return dimensions::SpecificEnthalpyValue{3.0 * pressure.value()}; + } + + [[nodiscard]] constexpr dimensions::SpecificEnthalpyValue evaluate( + SpecificEnthalpyFromPressureAndEntropy, + const dimensions::PressureValue pressure, + const dimensions::QuantityValue entropy + ) const noexcept { + return dimensions::SpecificEnthalpyValue{3.0 * pressure.value() + 5.0 * entropy.value()}; + } + + [[nodiscard]] constexpr dimensions::PartialDerivative partial_derivative( + SpecificEnthalpyFromPressureAndEntropy, + dimensions::WithRespectTo, + dimensions::PressureValue, + dimensions::QuantityValue + ) const noexcept { + return dimensions::PartialDerivative{5.0}; + } + }; + + class MissingRelationImplementation final { + public: + using Relations = eos::relations:: + RelationCatalog; + + [[nodiscard]] dimensions::PressureValue evaluate( + eos::relations::PressureFromDensity, + dimensions::DensityValue density + ) const { + return dimensions::PressureValue{density.value()}; + } + }; + + class IncorrectRelationOutput final { + public: + using Relations = eos::relations::RelationCatalog; + + [[nodiscard]] dimensions::DensityValue evaluate( + eos::relations::PressureFromDensity, + dimensions::DensityValue density + ) const { + return density; + } + }; + + class InvalidRelationCatalog final { + public: + using Relations = eos::relations::RelationCatalog>; + }; + + template + concept CanEvaluateDensityFromSpecificEnthalpy = requires(const EquationOfState& equation_of_state) { + eos::evaluate(equation_of_state, dimensions::SpecificEnthalpyValue{1.0}); + }; +} // namespace + +TEST_CASE( + "Thermodynamic Values Preserve Physical Quantity Types", + tags::unit & tags::eos +) { + STATIC_CHECK(dimensions::ThermodynamicQuantityType); + STATIC_CHECK(dimensions::ThermodynamicQuantityType); + STATIC_CHECK(dimensions::ThermodynamicQuantityType); + STATIC_CHECK_FALSE(dimensions::ThermodynamicQuantityType); + + STATIC_CHECK_FALSE(std::same_as); + STATIC_CHECK_FALSE(std::same_as); + STATIC_CHECK_FALSE(std::is_convertible_v); + STATIC_CHECK_FALSE(std::is_constructible_v); + + STATIC_CHECK(std::is_trivially_copyable_v); + STATIC_CHECK(std::is_standard_layout_v); + STATIC_CHECK(sizeof(dimensions::DensityValue) == sizeof(double)); + STATIC_CHECK(sizeof(dimensions::PressureValue) == sizeof(double)); + STATIC_CHECK(sizeof(dimensions::SpecificEnthalpyValue) == sizeof(double)); + STATIC_CHECK(std::is_empty_v); + + constexpr dimensions::DensityValue density{-0.25}; + STATIC_CHECK(density.value() == -0.25); +} + +TEST_CASE( + "Thermodynamic Derivatives Preserve Numerator And Denominator Types", + tags::unit & tags::eos +) { + using PressureByDensity = dimensions::PartialDerivative; + + using PressureBySpecificEnthalpy = + dimensions::PartialDerivative; + + STATIC_CHECK_FALSE(std::same_as); + STATIC_CHECK_FALSE(std::is_convertible_v); + STATIC_CHECK(std::is_trivially_copyable_v); + STATIC_CHECK(std::is_standard_layout_v); + STATIC_CHECK(sizeof(PressureByDensity) == sizeof(double)); + + constexpr PressureByDensity derivative{1.75}; + STATIC_CHECK(derivative.value() == 1.75); +} + +TEST_CASE( + "EOS Relation Catalog Rejects Invalid And Duplicate Relations", + tags::unit & tags::eos +) { + using ValidCatalog = eos::relations::RelationCatalog< + eos::relations::PressureFromDensity, eos::relations::SpecificEnthalpyFromPressure>; + + using DuplicateCatalog = + eos::relations::RelationCatalog; + + using InvalidRelation = eos::relations::Relation; + using InvalidCatalog = eos::relations::RelationCatalog; + using RepeatedInputRelation = + eos::relations::Relation; + using RepeatedInputCatalog = eos::relations::RelationCatalog; + + STATIC_CHECK(eos::relations::ValidRelationCatalog); + STATIC_CHECK_FALSE(eos::relations::ValidRelationCatalog); + STATIC_CHECK_FALSE(eos::relations::ValidRelationCatalog); + STATIC_CHECK_FALSE(eos::relations::ValidRelationCatalog); + STATIC_CHECK_FALSE(eos::relations::ValidRelationCatalog>); + + STATIC_CHECK( + eos::relations::relation_catalog_contains_relation_v + ); + STATIC_CHECK_FALSE( + eos::relations::relation_catalog_contains_relation_v + ); + STATIC_CHECK( + eos::relations::relation_contains_input_v + ); + STATIC_CHECK_FALSE( + eos::relations::relation_contains_input_v + ); + + STATIC_CHECK(std::same_as); + STATIC_CHECK( + std::same_as< + dimensions::QuantityAt<0, eos::relations::PressureFromDensity::InputQuantities>::Type, + dimensions::MassDensity> + ); +} + +TEST_CASE( + "EOS Model Contract Requires Every Declared Relation", + tags::unit & tags::eos +) { + STATIC_CHECK(eos::models::EOSModel); + STATIC_CHECK_FALSE(eos::models::EOSModel); + STATIC_CHECK_FALSE(eos::models::EOSModel); + STATIC_CHECK_FALSE(eos::models::EOSModel); + + STATIC_CHECK(eos::models::EOSSupportingRelation); + STATIC_CHECK_FALSE( + eos::models::EOSSupportingRelation + ); + STATIC_CHECK_FALSE(CanEvaluateDensityFromSpecificEnthalpy); + STATIC_CHECK( + eos::models::EOSSupportingPartialDerivative< + CompleteEquationOfState, SpecificEnthalpyFromPressureAndEntropy, Entropy> + ); + STATIC_CHECK_FALSE( + eos::models::EOSSupportingPartialDerivative< + CompleteEquationOfState, SpecificEnthalpyFromPressureAndEntropy, ElectronFraction> + ); +} + +TEST_CASE( + "EOS Evaluation Selects Relations From Typed Inputs", + tags::unit & tags::eos +) { + constexpr CompleteEquationOfState equation_of_state; + + constexpr dimensions::PressureValue pressure = + eos::evaluate(equation_of_state, dimensions::DensityValue{1.25}); + + constexpr dimensions::SpecificEnthalpyValue specific_enthalpy = eos::evaluate( + equation_of_state, dimensions::PressureValue{0.5}, dimensions::QuantityValue{0.2} + ); + + constexpr auto entropy_derivative = eos::partial_derivative( + equation_of_state, dimensions::PressureValue{0.5}, dimensions::QuantityValue{0.2} + ); + + STATIC_CHECK(noexcept(eos::evaluate(equation_of_state, dimensions::DensityValue{1.25}))); + STATIC_CHECK( + noexcept(eos::partial_derivative( + equation_of_state, dimensions::PressureValue{0.5}, dimensions::QuantityValue{0.2} + )) + ); + + STATIC_CHECK(pressure.value() == 2.5); + STATIC_CHECK(specific_enthalpy.value() == 2.5); + STATIC_CHECK(entropy_derivative.value() == 5.0); +} diff --git a/tests/eos/meson.build b/tests/eos/meson.build new file mode 100644 index 00000000..197bbfc0 --- /dev/null +++ b/tests/eos/meson.build @@ -0,0 +1,7 @@ +eos_test_sources = files( + 'barotrope_meanfield.cpp', + 'equation_of_state_type_system.cpp', + 'polytropic_eos_characterization.cpp', + 'polytropic_eos_relations.cpp', + 'pressure_potential.cpp', +) diff --git a/tests/eos/polytropic_eos_characterization.cpp b/tests/eos/polytropic_eos_characterization.cpp new file mode 100644 index 00000000..fb3d3024 --- /dev/null +++ b/tests/eos/polytropic_eos_characterization.cpp @@ -0,0 +1,166 @@ +// Ported from MeanField tests/physics/polytropic_eos_characterization.cpp. + +#include +#include +#include + +#include +#include + +#include "serif/eos/evaluation.hpp" +#include "serif/eos/exceptions.hpp" +#include "serif/eos/models/polytropic.hpp" + +#include "serif/tests/test_tags.hpp" + +namespace eos = serif::eos; +namespace dimensions = serif::dimensions; + +TEST_CASE( + "Polytropic EOS Pressure To Specific Enthalpy Relation Is Characterized", + tags::unit & tags::eos +) { + constexpr std::array polytropic_indices{1.0, 1.5, 3.0}; + constexpr std::array polytropic_constants{0.25, 0.73, 2.0}; + constexpr std::array pressures{0.0, 1.0e-12, 1.0e-4, 0.3, 5.0}; + + for (const double polytropic_index : polytropic_indices) { + for (const double polytropic_constant : polytropic_constants) { + const eos::models::Polytrope equation_of_state(polytropic_index, polytropic_constant); + + for (const double pressure : pressures) { + CAPTURE(polytropic_index, polytropic_constant, pressure); + + const double index_plus_one = polytropic_index + 1.0; + const double expected_enthalpy = index_plus_one * + std::pow(polytropic_constant, polytropic_index / index_plus_one) * + std::pow(pressure, 1.0 / index_plus_one); + + const double enthalpy = + eos::evaluate(equation_of_state, dimensions::PressureValue{pressure}) + .value(); + + if (pressure == 0.0) { + CHECK(enthalpy == 0.0); + } else { + CHECK_THAT(enthalpy, Catch::Matchers::WithinRel(expected_enthalpy, 5.0e-14)); + + const double recovered_pressure = eos::evaluate( + equation_of_state, dimensions::SpecificEnthalpyValue{enthalpy} + ) + .value(); + + CHECK_THAT(recovered_pressure, Catch::Matchers::WithinRel(pressure, 5.0e-13)); + } + } + } + } +} + +TEST_CASE( + "Polytropic EOS Domain Contract Covers Every Relation", + tags::unit & tags::eos +) { + constexpr double infinity = std::numeric_limits::infinity(); + constexpr double quiet_nan = std::numeric_limits::quiet_NaN(); + + for (const double invalid_index : std::array{0.999, infinity, -infinity, quiet_nan}) { + CAPTURE(invalid_index); + CHECK_THROWS_AS(eos::models::Polytrope(invalid_index, 1.0), eos::EOSEvaluationError); + } + + for (const double invalid_constant : std::array{0.0, -0.1, infinity, -infinity, quiet_nan}) { + CAPTURE(invalid_constant); + CHECK_THROWS_AS(eos::models::Polytrope(3.0, invalid_constant), eos::EOSEvaluationError); + } + + const eos::models::Polytrope equation_of_state(3.0, 0.75); + + constexpr double negative_density = -0.1; + CHECK_THROWS_AS( + eos::evaluate(equation_of_state, dimensions::DensityValue{negative_density}), + eos::EOSEvaluationError + ); + CHECK_THROWS_AS( + eos::evaluate(equation_of_state, dimensions::DensityValue{negative_density}), + eos::EOSEvaluationError + ); + CHECK_THROWS_AS( + (eos::partial_derivative( + equation_of_state, dimensions::DensityValue{negative_density} + )), + eos::EOSEvaluationError + ); + + CHECK_THROWS_AS( + eos::evaluate(equation_of_state, dimensions::PressureValue{-0.1}), + eos::EOSEvaluationError + ); + + constexpr double exterior_enthalpy = -0.1; + CHECK( + eos::evaluate(equation_of_state, dimensions::SpecificEnthalpyValue{exterior_enthalpy}) + .value() == 0.0 + ); + CHECK( + eos::evaluate(equation_of_state, dimensions::SpecificEnthalpyValue{exterior_enthalpy}) + .value() == 0.0 + ); + CHECK( + (eos::partial_derivative( + equation_of_state, dimensions::SpecificEnthalpyValue{exterior_enthalpy} + ) + .value() == 0.0) + ); + CHECK( + (eos::partial_derivative( + equation_of_state, dimensions::SpecificEnthalpyValue{exterior_enthalpy} + ) + .value() == 0.0) + ); + + for (const double nonfinite_value : std::array{infinity, -infinity, quiet_nan}) { + CAPTURE(nonfinite_value); + + CHECK_THROWS_AS( + eos::evaluate(equation_of_state, dimensions::DensityValue{nonfinite_value}), + eos::EOSEvaluationError + ); + CHECK_THROWS_AS( + eos::evaluate(equation_of_state, dimensions::DensityValue{nonfinite_value}), + eos::EOSEvaluationError + ); + CHECK_THROWS_AS( + (eos::partial_derivative( + equation_of_state, dimensions::DensityValue{nonfinite_value} + )), + eos::EOSEvaluationError + ); + CHECK_THROWS_AS( + eos::evaluate(equation_of_state, dimensions::PressureValue{nonfinite_value}), + eos::EOSEvaluationError + ); + CHECK_THROWS_AS( + eos::evaluate( + equation_of_state, dimensions::SpecificEnthalpyValue{nonfinite_value} + ), + eos::EOSEvaluationError + ); + CHECK_THROWS_AS( + eos::evaluate(equation_of_state, dimensions::SpecificEnthalpyValue{nonfinite_value}), + eos::EOSEvaluationError + ); + CHECK_THROWS_AS( + (eos::partial_derivative( + equation_of_state, dimensions::SpecificEnthalpyValue{nonfinite_value} + )), + eos::EOSEvaluationError + ); + CHECK_THROWS_AS( + (eos::partial_derivative( + equation_of_state, dimensions::SpecificEnthalpyValue{nonfinite_value} + )), + eos::EOSEvaluationError + ); + } +} diff --git a/tests/eos/polytropic_eos_relations.cpp b/tests/eos/polytropic_eos_relations.cpp new file mode 100644 index 00000000..9dacf032 --- /dev/null +++ b/tests/eos/polytropic_eos_relations.cpp @@ -0,0 +1,196 @@ +// Ported from MeanField tests/physics/polytropic_eos_relations.cpp. + +#include +#include +#include +#include + +#include +#include + +#include "serif/eos/evaluation.hpp" +#include "serif/eos/exceptions.hpp" +#include "serif/eos/models/polytropic.hpp" + +#include "serif/tests/test_tags.hpp" + +namespace eos = serif::eos; +namespace dimensions = serif::dimensions; + +TEST_CASE( + "Polytropic EOS Typed Relations Preserve Analytic Values", + tags::unit & tags::eos +) { + constexpr std::array polytropic_indices{1.0, 1.5, 3.0}; + constexpr std::array polytropic_constants{0.25, 0.73}; + constexpr std::array densities{0.0, 1.0e-6, 0.2, 2.0}; + constexpr std::array specific_enthalpies{-0.3, 0.0, 0.2, 1.7}; + constexpr std::array pressures{0.0, 1.0e-8, 0.3, 4.0}; + + for (const double polytropic_index : polytropic_indices) { + for (const double polytropic_constant : polytropic_constants) { + const eos::models::Polytrope equation_of_state(polytropic_index, polytropic_constant); + + for (const double density : densities) { + CAPTURE(polytropic_index, polytropic_constant, density); + + const double expected_pressure = polytropic_constant * std::pow(density, 1.0 + 1.0 / polytropic_index); + const double expected_specific_enthalpy = + (polytropic_index + 1.0) * polytropic_constant * std::pow(density, 1.0 / polytropic_index); + + CHECK( + eos::evaluate(equation_of_state, dimensions::DensityValue{density}).value() == + expected_pressure + ); + + CHECK( + eos::evaluate(equation_of_state, dimensions::DensityValue{density}) + .value() == expected_specific_enthalpy + ); + + CHECK_THAT( + (eos::partial_derivative( + equation_of_state, dimensions::DensityValue{density} + ) + .value()), + Catch::Matchers::WithinRel( + density == 0.0 ? 0.0 : expected_specific_enthalpy / polytropic_index, 2.0e-15 + ) + ); + } + + for (const double specific_enthalpy : specific_enthalpies) { + CAPTURE(polytropic_index, polytropic_constant, specific_enthalpy); + + const double expected_density = + specific_enthalpy <= 0.0 + ? 0.0 + : std::pow( + specific_enthalpy / ((polytropic_index + 1.0) * polytropic_constant), polytropic_index + ); + + CHECK( + eos::evaluate( + equation_of_state, dimensions::SpecificEnthalpyValue{specific_enthalpy} + ) + .value() == expected_density + ); + + CHECK( + eos::evaluate( + equation_of_state, dimensions::SpecificEnthalpyValue{specific_enthalpy} + ) + .value() == + (specific_enthalpy <= 0.0 ? 0.0 : expected_density * specific_enthalpy / (polytropic_index + 1.0)) + ); + + CHECK( + (eos::partial_derivative( + equation_of_state, dimensions::SpecificEnthalpyValue{specific_enthalpy} + ) + .value() == expected_density) + ); + } + + for (const double pressure : pressures) { + CAPTURE(polytropic_index, polytropic_constant, pressure); + + const double index_plus_one = polytropic_index + 1.0; + const double expected_specific_enthalpy = + index_plus_one * std::pow(polytropic_constant, polytropic_index / index_plus_one) * + std::pow(pressure, 1.0 / index_plus_one); + + CHECK( + eos::evaluate(equation_of_state, dimensions::PressureValue{pressure}) + .value() == expected_specific_enthalpy + ); + } + } + } +} + +TEST_CASE( + "Typed Polytropic EOS Preserves Domain And Exterior Semantics", + tags::unit & tags::eos +) { + const eos::models::Polytrope equation_of_state(3.0, 0.75); + + try { + static_cast(eos::evaluate(equation_of_state, dimensions::DensityValue{-0.1})); + FAIL("A negative density must be rejected."); + } catch (const eos::EOSEvaluationError& error) { + CHECK(std::string_view{error.what()}.find("EOS_INVALID_THERMODYNAMIC_INPUT") != std::string_view::npos); + } + + try { + static_cast(eos::evaluate( + equation_of_state, dimensions::PressureValue{std::numeric_limits::quiet_NaN()} + )); + FAIL("A nonfinite pressure must be rejected."); + } catch (const eos::EOSEvaluationError& error) { + CHECK(std::string_view{error.what()}.find("EOS_INVALID_THERMODYNAMIC_INPUT") != std::string_view::npos); + } + + constexpr double exterior_specific_enthalpy = -0.3; + + CHECK( + eos::evaluate( + equation_of_state, dimensions::SpecificEnthalpyValue{exterior_specific_enthalpy} + ) + .value() == 0.0 + ); + + CHECK( + eos::evaluate( + equation_of_state, dimensions::SpecificEnthalpyValue{exterior_specific_enthalpy} + ) + .value() == 0.0 + ); + + CHECK( + (eos::partial_derivative( + equation_of_state, dimensions::SpecificEnthalpyValue{exterior_specific_enthalpy} + ) + .value() == 0.0) + ); + + CHECK( + (eos::partial_derivative( + equation_of_state, dimensions::SpecificEnthalpyValue{exterior_specific_enthalpy} + ) + .value() == 0.0) + ); +} + +TEST_CASE("Density based polytropic EOS supplies surface density and bulk Newton curvature", tags::unit & tags::eos) { + for (const double index : {1.0, 1.5, 2.0, 2.5, 3.0}) { + const eos::models::Polytrope model(index, 0.73); + for (const double density : {1.0e-6, 0.2, 2.0}) { + CAPTURE(index, density); + const dimensions::DensityValue state{density}; + const auto pressure = eos::evaluate(model, state); + const double recovered = eos::evaluate(model, pressure).value(); + CHECK_THAT(recovered, Catch::Matchers::WithinRel(density, 3.0e-15)); + const double step = density * 1.0e-4; + const double plus = eos::partial_derivative( + model, dimensions::DensityValue{density + step}).value(); + const double minus = eos::partial_derivative( + model, dimensions::DensityValue{density - step}).value(); + const double derivative = eos::second_partial_derivative(model, state).value(); + CHECK_THAT(derivative, Catch::Matchers::WithinRel((plus - minus) / (2.0 * step), 3.0e-9)); + } + CHECK(eos::evaluate(model, dimensions::PressureValue{0.0}).value() == 0.0); + if (index == 1.0) { + CHECK((eos::second_partial_derivative( + model, dimensions::DensityValue{0.0}).value() == 1.46)); + } else { + CHECK_THROWS_AS((eos::second_partial_derivative( + model, dimensions::DensityValue{0.0})), eos::EOSEvaluationError); + } + for (const double invalid : {-0.1, std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()}) { + CHECK_THROWS_AS(eos::evaluate(model, dimensions::PressureValue{invalid}), eos::EOSEvaluationError); + CHECK_THROWS_AS((eos::second_partial_derivative( + model, dimensions::DensityValue{invalid})), eos::EOSEvaluationError); + } + } +} diff --git a/tests/eos/pressure_potential.cpp b/tests/eos/pressure_potential.cpp new file mode 100644 index 00000000..8197a6b0 --- /dev/null +++ b/tests/eos/pressure_potential.cpp @@ -0,0 +1,33 @@ +#include +#include + +#include +#include + +#include "serif/eos/evaluation.hpp" +#include "serif/eos/exceptions.hpp" +#include "serif/eos/models/polytropic.hpp" + +namespace eos = serif::eos; +namespace dimensions = serif::dimensions; + +TEST_CASE("Polytropic pressure potential obeys its mechanical derivative contract", "[eos][barotropic_density][unit]") { + for (const double index : {1.0, 1.5, 2.0, 2.5, 3.0}) { + const eos::models::Polytrope model{index, 0.73}; + for (const double density : {1.0e-6, 0.2, 2.0}) { + CAPTURE(index, density); + const dimensions::DensityValue state{density}; + const double potential = eos::evaluate(model, state).value(); + CHECK_THAT(potential, Catch::Matchers::WithinRel((index + 1.0) * 0.73 * std::pow(density, 1.0 / index), 2.0e-15)); + const double step = density * 1.0e-4; + const double positive = eos::evaluate(model, dimensions::DensityValue{density + step}).value(); + const double negative = eos::evaluate(model, dimensions::DensityValue{density - step}).value(); + const double expected_slope = eos::partial_derivative(model, state).value() / density; + CHECK_THAT((positive - negative) / (2.0 * step), Catch::Matchers::WithinRel(expected_slope, 3.0e-9)); + } + CHECK(eos::evaluate(model, dimensions::DensityValue{0.0}).value() == 0.0); + for (const double invalid : {-0.1, std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()}) { + CHECK_THROWS_AS(eos::evaluate(model, dimensions::DensityValue{invalid}), eos::EOSEvaluationError); + } + } +} diff --git a/tests/equations/angular_momentum.cpp b/tests/equations/angular_momentum.cpp new file mode 100644 index 00000000..fb814993 --- /dev/null +++ b/tests/equations/angular_momentum.cpp @@ -0,0 +1,427 @@ +#include +#include +#include + +#include "serif/equations/angular_momentum.hpp" +#include "serif/equations/barotropic_density.hpp" +#include "serif/equations/primal_gravity.hpp" +#include "serif/equations/mass_normalization.hpp" +#include "serif/tests/equations/gravity_port.hpp" + +namespace { + using namespace serif; + using namespace serif::tests::equations; + using namespace serif::tests::equations::gravity; + using Equation = serif::equations::AngularMomentumConstraint; + using Prepared = VolumeEquationFixture; + using Vector = ::mfem::Vector; + using Axis = serif::equations::RigidRotation::Vector; + + struct Values { + Vector density, omega, displacement; + }; + + auto state(GravityFixture& f, const Values& values) { + return backend::make_term_coefficients(backend::bind(f.density_binding, values.density), backend::bind(f.angular_velocity_binding, values.omega)); + } + + auto make_operator(GravityFixture& f, double target, Axis axis = {0.0, 0.0, 1.0}, Axis center = {}) { + return Prepared{Equation{target, axis, center}, f.angular_velocity_binding, f.displacement_binding, f.compactification, f.mapper, reference_policy()}; + } + + template double residual(GravityFixture& f, OperatorT& op) { + Vector result; + op.residual(f.workspace, result); + REQUIRE(result.Size() == 1); + return result[0]; + } + + template double residual(GravityFixture& f, OperatorT& op, const Values& values) { + op.prepare(state(f, values), values.displacement, f.workspace); + return residual(f, op); + } + + template double action(GravityFixture& f, OperatorT& op, const Values& direction) { + Vector result; + op.apply_jacobian(state(f, direction), direction.displacement, f.workspace, result); + REQUIRE(result.Size() == 1); + return result[0]; + } + + Values zeros(const GravityFixture& f) { + return {constant(f.density_binding.size(), 0.0), constant(1, 0.0), constant(f.displacement_binding.size(), 0.0)}; + } + + // MeanField tests/operators/prepared_angular_momentum.cpp: preserve its projected profiles. + Vector density(GravityFixture& f, double phase, bool direction = false) { + return f.project(f.density_binding, [=](const Vector& x) { + if (direction) { return 0.17 * std::sin(0.83 * x[0] + phase) - 0.12 * std::cos(0.79 * x[1] - phase) + 0.06 * x[2]; } + return 0.94 + 0.08 * std::sin(0.71 * x[0] + phase) + 0.05 * std::cos(0.63 * x[1] - phase) + 0.03 * x[2] * x[2]; + }); + } + + Vector displacement(GravityFixture& f, double scale) { + return gravity_port::project_vector(f.displacement_binding, [=](const Vector& x, Vector& value) { + value.SetSize(3); + value[0] = scale * (0.07 * x[0] + 0.018 * x[1] * x[2]); + value[1] = scale * (-0.05 * x[1] + 0.013 * x[0] * x[2]); + value[2] = scale * (0.04 * x[2] - 0.011 * x[0] * x[1]); + }); + } + + double relative(double actual, double expected) { + return std::abs(actual - expected) / std::max({std::abs(actual), std::abs(expected), 100.0 * std::numeric_limits::epsilon()}); + } + + Values shifted(Values values, const Values& direction, double step) { + values.density.Add(step, direction.density); + values.omega.Add(step, direction.omega); + values.displacement.Add(step, direction.displacement); + return values; + } + + struct Integrals { + double moment = 0.0, mass = 0.0, action = 0.0; + Axis first_moment{}; + }; + + // Direct MFEM quadrature, independent of the equation and operator evaluators. + // delta(I) = integral [delta(rho) s^2 + 2 rho r_perp . delta(x) + rho s^2 trace(A^-1 delta(A))] dV. + Integrals reference(GravityFixture& f, const Values& values, Axis axis = {0.0, 0.0, 1.0}, Axis center = {}, const Values* direction = nullptr) { + const double norm = std::hypot(axis[0], axis[1], axis[2]); + for (double& component : axis) { component /= norm; } + ::mfem::GridFunction rho(&f.density_space), u(&f.displacement_space), drho(&f.density_space), du(&f.displacement_space); + rho = f.density_binding.scatter(values.density); + u = f.displacement_binding.scatter(values.displacement); + if (direction) { + drho = f.density_binding.scatter(direction->density); + du = f.displacement_binding.scatter(direction->displacement); + } + Integrals result; + for (int element = 0; element < f.mesh.GetNE(); ++element) { + if (!f.density_binding.supports(element)) { continue; } + auto& transform = *f.mesh.GetElementTransformation(element); + const auto& rule = ::mfem::IntRules.Get(f.mesh.GetElementGeometry(element), reference_order); + for (int point = 0; point < rule.GetNPoints(); ++point) { + const auto& ip = rule.IntPoint(point); + transform.SetIntPoint(&ip); + Vector x(3), displacement_value(3), dx(3); + transform.Transform(ip, x); + u.GetVectorValue(transform, ip, displacement_value); + x += displacement_value; + ::mfem::DenseMatrix A(3), dA(3), inverse(3); + u.GetVectorGradient(transform, A); + for (int i = 0; i < 3; ++i) { A(i, i) += 1.0; } + double axial_distance = 0.0; + for (int i = 0; i < 3; ++i) { axial_distance += axis[i] * (x[i] - center[i]); } + Axis perpendicular{}; + double radius_squared = 0.0; + for (int i = 0; i < 3; ++i) { + perpendicular[i] = x[i] - center[i] - axis[i] * axial_distance; + radius_squared += perpendicular[i] * perpendicular[i]; + } + const double weight = ip.weight * transform.Weight() * A.Det(); + const double density_value = rho.GetValue(transform, ip); + result.mass += weight * density_value; + result.moment += weight * density_value * radius_squared; + for (int i = 0; i < 3; ++i) { result.first_moment[i] += weight * density_value * x[i]; } + if (direction) { + du.GetVectorValue(transform, ip, dx); + du.GetVectorGradient(transform, dA); + ::mfem::CalcInverse(A, inverse); + double trace = 0.0, radius_variation = 0.0; + for (int i = 0; i < 3; ++i) { + radius_variation += 2.0 * perpendicular[i] * dx[i]; + for (int j = 0; j < 3; ++j) { trace += inverse(i, j) * dA(j, i); } + } + result.action += weight * (direction->omega[0] * density_value * radius_squared + values.omega[0] * + (drho.GetValue(transform, ip) * radius_squared + density_value * (radius_variation + radius_squared * trace))); + } + } + } + return result; + } + + double moment(GravityFixture& f, Prepared& op) { + auto direction = zeros(f); + direction.omega = 1.0; + return action(f, op, direction); + } + + void check_columns(GravityFixture& f, Axis axis = {0.0, 0.0, 1.0}, Axis center = {}) { + CAPTURE(f.mesh.GetNE(), f.mesh.GetElementGeometry(0), f.displacement_space.GetOrdering(), axis, center); + const Values values{density(f, 0.31), constant(1, 0.63), displacement(f, 0.43)}; + const Values direction{density(f, 0.67, true), constant(1, -0.37), displacement(f, -0.79)}; + auto op = make_operator(f, 0.81, axis, center); + (void)residual(f, op, values); + const double complete = action(f, op, direction); + CHECK(relative(complete, reference(f, values, axis, center, &direction).action) < 2.0e-11); + auto density_direction = zeros(f), geometry_direction = zeros(f), omega_direction = zeros(f); + density_direction.density = direction.density; + geometry_direction.displacement = direction.displacement; + omega_direction.omega = direction.omega; + const double density_action = action(f, op, density_direction), geometry_action = action(f, op, geometry_direction), omega_action = action(f, op, omega_direction); + CHECK(relative(complete, density_action + geometry_action + omega_action) < 3.0e-15); + CHECK(omega_action == Catch::Approx(moment(f, op) * direction.omega[0]).epsilon(2.0e-15)); + backend::JacobianOperator adapter(op.cache()); + CHECK(adapter.Height() == 1); + CHECK(adapter.Width() == direction.density.Size() + 1 + direction.displacement.Size()); + Vector result; + adapter.Mult(packed(direction.density, direction.omega, direction.displacement), result); + CHECK(result[0] == complete); + + // Preserve all three original difference steps and thresholds. + const std::array columns{density_direction, geometry_direction, omega_direction}; + const std::array actions{density_action, geometry_action, omega_action}; + const std::array steps{1.0e-3, 1.0e-6, 1.0e-6}, tolerances{4.0e-8, 4.0e-7, 2.0e-10}; + for (int column = 0; column < 3; ++column) { + const double plus = residual(f, op, shifted(values, columns[column], steps[column])); + const double minus = residual(f, op, shifted(values, columns[column], -steps[column])); + const double difference = (plus - minus) / (2.0 * steps[column]); + CAPTURE(column, actions[column], difference); + CHECK(relative(actions[column], difference) < tolerances[column]); + } + } +} + +TEST_CASE("Angular momentum retains moment scaling and the parallel axis theorem", "[equations][angular_momentum][integration]") { + STATIC_CHECK_FALSE(std::is_copy_constructible_v); + STATIC_CHECK(std::is_move_constructible_v); + auto fixture = gravity_port::stellar_fixture(); + auto& f = *fixture; + auto values = zeros(f); + values.density = 1.37; + values.omega = 0.73; + auto op = make_operator(f, 0.41, {0.0, 0.0, 4.0}); + const double initial = residual(f, op, values), origin_moment = moment(f, op); + const auto independent = reference(f, values); + CHECK(relative(origin_moment, independent.moment) < 2.0e-13); + CHECK(initial + 0.41 == Catch::Approx(0.73 * origin_moment).epsilon(2.0e-15)); + CHECK(initial == Catch::Approx(0.73 * origin_moment - 0.41).epsilon(2.0e-15)); + values.displacement = gravity_port::affine_displacement(f, 0.086); + (void)residual(f, op, values); + CHECK(relative(moment(f, op) / independent.moment, std::pow(1.0 + 0.086, 5)) < 7.0e-7); + + values.displacement = 0.0; + const Axis center{0.27, -0.19, 0.31}; + auto shifted_op = make_operator(f, 0.41, {0.0, 0.0, 1.0}, center); + (void)residual(f, shifted_op, values); + const double expected = origin_moment + independent.mass * (center[0] * center[0] + center[1] * center[1]) - + 2.0 * (center[0] * independent.first_moment[0] + center[1] * independent.first_moment[1]); + CHECK(relative(moment(f, shifted_op), expected) < 3.0e-13); +} + +TEST_CASE("Angular momentum retains density geometry and angular velocity action accuracy", "[equations][angular_momentum][integration]") { + for (const auto geometry : {::mfem::Element::TETRAHEDRON, ::mfem::Element::HEXAHEDRON}) { + for (const auto ordering : {::mfem::Ordering::byNODES, ::mfem::Ordering::byVDIM}) { + GravityFixture f(geometry, ordering); + f.set_compactification(); + check_columns(f); + check_columns(f, {0.17, -0.12, 0.43}, {0.031, -0.024, 0.018}); + } + } + auto fixture = gravity_port::stellar_fixture(); + check_columns(*fixture); + check_columns(*fixture, {0.17, -0.12, 0.43}, {0.031, -0.024, 0.018}); +} + +TEST_CASE("Angular momentum snapshots and refreshed states preserve the old lifecycle numerics", "[equations][angular_momentum][integration]") { + auto fixture = gravity_port::stellar_fixture(); + auto& f = *fixture; + Values values{density(f, 0.29), constant(1, 0.52), displacement(f, 0.41)}; + const Values direction{density(f, 0.67, true), constant(1, -0.19), displacement(f, -0.53)}; + auto op = make_operator(f, 0.71); + const double initial = residual(f, op, values), initial_moment = moment(f, op); + auto check_action = [&] { CHECK(relative(action(f, op, direction), reference(f, values, {0.0, 0.0, 1.0}, {}, &direction).action) < 2.0e-11); }; + check_action(); + CHECK(residual(f, op, values) == initial); + check_action(); + auto potential = f.potential(); + potential *= 1.7; + op.prepare(backend::make_term_coefficients(backend::bind(f.density_binding, values.density), backend::bind(f.angular_velocity_binding, values.omega), backend::bind(f.potential_binding, potential)), values.displacement, f.workspace); + CHECK(residual(f, op) == initial); + check_action(); + + values.omega = 0.81; + CHECK(residual(f, op) == initial); + CHECK(residual(f, op, values) - initial == Catch::Approx((0.81 - 0.52) * initial_moment).epsilon(3.0e-15)); + CHECK(moment(f, op) == initial_moment); + check_action(); + const double before_density = residual(f, op); + values.density = density(f, 0.83); + CHECK(residual(f, op) == before_density); + (void)residual(f, op, values); + check_action(); + const double before_geometry = residual(f, op); + values.displacement = displacement(f, 0.87); + CHECK(residual(f, op) == before_geometry); + (void)residual(f, op, values); + check_action(); +} + +TEST_CASE("Angular momentum rejects inadmissible moments and angular speeds then recovers", "[equations][angular_momentum][unit]") { + GravityFixture f; + auto values = zeros(f); + values.density = 0.91; + values.omega = 0.52; + auto op = make_operator(f, 0.71); + const double initial = residual(f, op, values); + for (const double omega : {0.52, 0.0, -0.52}) { + values.omega = omega; + values.density = -0.91; + const auto count = op.preparation_count(); + const auto rejected = op.try_prepare(state(f, values), values.displacement, f.workspace); + REQUIRE_FALSE(rejected.has_value()); + CHECK(rejected.error().reason == backend::PreparationRejectionReason::EQUATION); + CHECK(op.preparation_count() == count); + CHECK_FALSE(op.is_prepared()); + CHECK_THROWS_AS(op.prepare(state(f, values), values.displacement, f.workspace), std::domain_error); + values.density = 0.91; + values.omega = 0.52; + CHECK(residual(f, op, values) == initial); + } + for (double bad : {std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity()}) { + values.omega = bad; + const auto rejected = op.try_prepare(state(f, values), values.displacement, f.workspace); + REQUIRE_FALSE(rejected.has_value()); + CHECK(rejected.error().reason == backend::PreparationRejectionReason::NON_FINITE); + CHECK_FALSE(op.is_prepared()); + CHECK_THROWS_AS(op.prepare(state(f, values), values.displacement, f.workspace), std::domain_error); + values.omega = 0.52; + CHECK(residual(f, op, values) == initial); + } + // Local negative density is allowed when the total moment remains nonnegative. + values.density = f.project(f.density_binding, [](const Vector& x) { return x[0] - 0.25; }); + REQUIRE(reference(f, values).moment > 0.0); + CHECK(relative(residual(f, op, values), 0.52 * reference(f, values).moment - 0.71) < 2.0e-13); +} + +TEST_CASE("Angular momentum target axis and center retain the old validity requirements", "[equations][angular_momentum][unit]") { + for (double target : {-1.0, std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()}) { + CHECK_THROWS_AS(Equation{target}, std::invalid_argument); + } + CHECK_NOTHROW(Equation{0.0}); + CHECK_THROWS_AS((Equation{1.0, {0.0, 0.0, 0.0}}), std::invalid_argument); + CHECK_THROWS_AS((Equation{1.0, {0.0, 0.0, 1.0}, {0.0, std::numeric_limits::quiet_NaN(), 0.0}}), std::invalid_argument); +} + +TEST_CASE("Angular momentum rejects overflow in integrated moments and residuals", "[equations][angular_momentum][unit]") { + // A long cylinder-like box keeps point values finite while their volume integral overflows. + auto mesh = make_mesh(::mfem::Element::HEXAHEDRON); + for (int vertex = 0; vertex < mesh.GetNV(); ++vertex) { mesh.GetVertex(vertex)[2] *= 100.0; } + GravityFixture f(std::move(mesh)); + auto values = zeros(f); + const double maximum = std::numeric_limits::max(); + for (const double target : {0.0, maximum}) { + auto op = make_operator(f, target); + values.density = 1.0; + values.omega = target == 0.0 ? 0.1 * maximum : -0.01 * maximum; + const auto rejected = op.try_prepare(state(f, values), values.displacement, f.workspace); + REQUIRE_FALSE(rejected.has_value()); + CHECK(rejected.error().reason == backend::PreparationRejectionReason::NON_FINITE); + CHECK_FALSE(op.is_prepared()); + } + auto op = make_operator(f, 0.0); + values.density = 0.1 * maximum; + values.omega = 0.0; + const auto rejected = op.try_prepare(state(f, values), values.displacement, f.workspace); + REQUIRE_FALSE(rejected.has_value()); + CHECK(rejected.error().reason == backend::PreparationRejectionReason::EQUATION); + CHECK_FALSE(op.is_prepared()); + values.density = 1.0; + CHECK(residual(f, op, values) == 0.0); +} + +TEST_CASE("Angular momentum handles zero speed signed speed and default quadrature", "[equations][angular_momentum][integration]") { + for (const auto geometry : {::mfem::Element::TETRAHEDRON, ::mfem::Element::HEXAHEDRON}) { + GravityFixture f(geometry); + Values values{density(f, 0.31), constant(1, 0.0), displacement(f, 0.43)}; + Prepared op{Equation{0.41}, f.angular_velocity_binding, f.displacement_binding, f.compactification, f.mapper}; + CHECK(residual(f, op, values) == -0.41); + CHECK(relative(moment(f, op), reference(f, values).moment) < 2.0e-13); + const Values direction{density(f, 0.67, true), constant(1, 0.0), displacement(f, -0.79)}; + CHECK(action(f, op, direction) == 0.0); + values.omega = -0.63; + CHECK(relative(residual(f, op, values), -0.63 * reference(f, values).moment - 0.41) < 2.0e-13); + values.density = 0.0; + CHECK(residual(f, op, values) == -0.41); + CHECK(moment(f, op) == 0.0); + } +} + +TEST_CASE("Angular momentum preserves physical admissibility", "[equations][angular_momentum][unit]") { + GravityFixture f; + Values values{density(f, 0.31), constant(1, 0.63), displacement(f, 0.43)}; + VolumeEquationFixture op{Equation{0.41}, f.angular_velocity_binding, f.displacement_binding, f.compactification, f.mapper, reference_policy()}; + CHECK(relative(residual(f, op, values), 0.63 * reference(f, values).moment - 0.41) < 2.0e-13); + const Values direction{density(f, 0.67, true), constant(1, -0.37), displacement(f, -0.79)}; + CHECK(relative(action(f, op, direction), reference(f, values, {0.0, 0.0, 1.0}, {}, &direction).action) < 2.0e-11); + values.density = -0.91; + const auto rejected = op.try_prepare(state(f, values), values.displacement, f.workspace); + REQUIRE_FALSE(rejected.has_value()); + CHECK(rejected.error().reason == backend::PreparationRejectionReason::EQUATION); + CHECK_FALSE(op.is_prepared()); +} + +TEST_CASE("One angular speed drives all supported equilibrium rows consistently", "[equations][angular_momentum][integration]") { + // Port the physical columns of MeanField stellar_equilibrium_system.cpp:253. + // Root manifests, surface projection, and Lane-Emden seed generation are separate infrastructure. + auto fixture = gravity_port::stellar_fixture(); + auto& f = *fixture; + Values values{density(f, 0.31), constant(1, 0.63), displacement(f, 0.43)}; + auto direction = zeros(f); + direction.omega = -0.37; + const auto h = f.enthalpy(), phi = f.potential(), c = constant(1, 0.39); + const auto zero_h = constant(h.Size(), 0.0), zero_phi = constant(phi.Size(), 0.0), zero_c = constant(1, 0.0); + auto bind_all = [&](const Values& v, const Vector& enthalpy, const Vector& potential, const Vector& bernoulli) { + return backend::make_term_coefficients(backend::bind(f.density_binding, v.density), backend::bind(f.angular_velocity_binding, v.omega), + backend::bind(f.enthalpy_binding, enthalpy), backend::bind(f.potential_binding, potential), + backend::bind(f.constant_binding, bernoulli)); + }; + const auto base = bind_all(values, h, phi, c), delta = bind_all(direction, zero_h, zero_phi, zero_c); + auto make_row = [&](auto equation, const auto& binding) { + return VolumeEquationFixture{equation, binding, f.displacement_binding, f.compactification, f.mapper, reference_policy()}; + }; + const Axis axis{0.0, 0.0, 3.0}; + const eos::models::Polytrope eos{1.0, 0.25}; + auto momentum = make_row(Equation{0.1, axis}, f.angular_velocity_binding); + auto density_balance = make_row(serif::equations::BarotropicDensityBalance{eos, dimensions::PressureValue{0.0}, axis}, f.density_binding); + auto mass = make_row(serif::equations::MassNormalization{1.0}, f.constant_binding); + auto closure = make_row(::serif::tests::equations::BarotropicClosure{eos}, f.density_binding); + auto poisson = make_row(serif::equations::PrimalPoissonBalance{four_pi_G}, f.potential_binding); + + auto check_rotating_row = [&](auto& row, double tolerance, bool zero_at_rest) { + for (double omega : {0.63, 0.0}) { + values.omega = omega; + row.prepare(base, values.displacement, f.workspace); + Vector actual, plus, minus; + row.apply_jacobian(delta, direction.displacement, f.workspace, actual); + if (omega == 0.0 && zero_at_rest) { CHECK(actual.Norml2() == 0.0); } + else { CHECK(actual.Norml2() > 0.0); } + constexpr double step = 1.0e-5; + values.omega = omega + step * direction.omega[0]; + row.prepare(base, values.displacement, f.workspace); + row.residual(f.workspace, plus); + values.omega = omega - step * direction.omega[0]; + row.prepare(base, values.displacement, f.workspace); + row.residual(f.workspace, minus); + plus -= minus; + plus /= 2.0 * step; + const double scale = std::max({1.0, actual.Norml2(), plus.Norml2()}); + plus -= actual; + CHECK(plus.Norml2() / scale < tolerance); + } + }; + check_rotating_row(momentum, 3.0e-10, false); + check_rotating_row(density_balance, 3.0e-7, true); + values.omega = 0.63; + auto check_independent_row = [&](auto& row) { + row.prepare(base, values.displacement, f.workspace); + Vector actual; + row.apply_jacobian(delta, direction.displacement, f.workspace, actual); + CHECK(actual.Norml2() == 0.0); + }; + check_independent_row(mass); + check_independent_row(closure); + check_independent_row(poisson); +} diff --git a/tests/equations/barotropic_density.cpp b/tests/equations/barotropic_density.cpp new file mode 100644 index 00000000..485acb85 --- /dev/null +++ b/tests/equations/barotropic_density.cpp @@ -0,0 +1,105 @@ +#include +#include + +#include +#include + +#include "serif/equations/barotropic_density.hpp" +#include "serif/solver/normalization/scales.hpp" +#include "serif/tests/equations/barotropic_eos.hpp" + +namespace { + namespace dimensions = serif::dimensions; + namespace eos = serif::eos; + + using MixedPowerBarotrope = serif::tests::equations::MixedPowerBarotrope; + using Balance = serif::equations::BarotropicDensityBalance; + using Point = serif::equations::EquationPoint; + + /** @brief Check one scalar physical column independently of the other field and geometry columns. */ + template + void check_field_derivative(const Balance& equation, const Point& point) { + constexpr double step = 2.0e-6; + Point positive = point, negative = point; + positive.fields.get() += step; + negative.fields.get() -= step; + const double difference = (equation.evaluate(positive).residual - equation.evaluate(negative).residual) / (2.0 * step); + CHECK_THAT(equation.evaluate(point).field_derivatives.get(), Catch::Matchers::WithinAbs(difference, 2.0e-8)); + } +} + +TEST_CASE("Barotropic density balance preserves finite pressure and primitive gauge invariance", "[equations][barotropic_density][unit]") { + const Balance equation{MixedPowerBarotrope{}, dimensions::PressureValue{2.0}}; + const Balance shifted{MixedPowerBarotrope{.gauge = 7.5}, dimensions::PressureValue{2.0}}; + Point point; + point.fields.get() = 2.0; + point.fields.get() = -12.0; + point.fields.get() = -2.0 / 3.0; + const auto balance = equation.evaluate(point); + const auto shifted_balance = shifted.evaluate(point); + CHECK_THAT(balance.residual, Catch::Matchers::WithinAbs(0.0, 3.0e-15)); + CHECK_THAT(shifted_balance.residual, Catch::Matchers::WithinAbs(balance.residual, 4.0e-15)); + CHECK(shifted_balance.field_derivatives.values == balance.field_derivatives.values); + CHECK(shifted_balance.position_derivative == balance.position_derivative); + CHECK(equation.surface_pressure().value() == 2.0); + + const Balance vacuum{MixedPowerBarotrope{}}; + CHECK_THAT(vacuum.evaluate(point).residual, Catch::Matchers::WithinAbs(10.0 / 3.0, 3.0e-15)); + point.fields.get() = 1.0; + point.fields.get() = -2.0; + point.fields.get() = -2.0; + CHECK(equation.evaluate(point).residual == 0.0); + CHECK(shifted.evaluate(point).residual == 0.0); +} + +TEST_CASE("Barotropic density tangent includes every field and physical position", "[equations][barotropic_density][unit]") { + const Balance equation{MixedPowerBarotrope{}, dimensions::PressureValue{2.0}, {1.0, 2.0, 3.0}, {0.2, -0.1, 0.3}}; + Point point; + point.fields.get() = 1.3; + point.fields.get() = -3.2; + point.fields.get() = -0.9; + point.fields.get() = 0.8; + point.position = {1.2, 0.7, -0.2}; + // The displacement from the rotation origin is (1, 0.8, -0.5), with projection 1.1/sqrt(14). + const double centrifugal_potential = 0.5 * (1.0 + 0.64 + 0.25 - 1.21 / 14.0); + const double expected = 2.6 + (4.0 / 3.0) * 1.3 * 1.3 * 1.3 - 10.0 / 3.0 - 3.2 - 0.64 * centrifugal_potential + 0.9; + const auto balance = equation.evaluate(point); + CHECK_THAT(balance.residual, Catch::Matchers::WithinAbs(expected, 4.0e-15)); + CHECK_THAT(balance.field_derivatives.get(), Catch::Matchers::WithinAbs(2.0 + 4.0 * 1.3 * 1.3, 2.0e-15)); + check_field_derivative(equation, point); + check_field_derivative(equation, point); + check_field_derivative(equation, point); + check_field_derivative(equation, point); + constexpr double step = 2.0e-6; + for (int axis = 0; axis < 3; ++axis) { + Point positive = point, negative = point; + positive.position[axis] += step; + negative.position[axis] -= step; + const double difference = (equation.evaluate(positive).residual - equation.evaluate(negative).residual) / (2.0 * step); + CHECK_THAT(balance.position_derivative[axis], Catch::Matchers::WithinAbs(difference, 2.0e-8)); + } + point.fields.get() = 0.0; + CHECK(equation.evaluate(point).position_derivative == serif::discretization::forms::VectorValue{}); +} + +TEST_CASE("Barotropic density balance rejects invalid interior states without clipping", "[equations][barotropic_density][unit]") { + const Balance equation{MixedPowerBarotrope{}}; + Point point; + for (const double density : {0.0, -0.1, std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()}) { + point.fields.get() = density; + CHECK_THROWS_AS(equation.evaluate(point), eos::EOSEvaluationError); + } + point.fields.get() = 1.0e200; + CHECK_THROWS_AS(equation.evaluate(point), eos::EOSEvaluationError); + for (const double pressure : {-0.1, std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()}) { + CHECK_THROWS_AS((Balance{MixedPowerBarotrope{}, dimensions::PressureValue{pressure}}), eos::EOSEvaluationError); + } + CHECK_THROWS_AS((Balance{MixedPowerBarotrope{.gauge = std::numeric_limits::infinity()}}), eos::EOSEvaluationError); +} + +TEST_CASE("Pressure potential uses the gravitational specific energy reference scale", "[equations][barotropic_density][unit]") { + const auto scales = serif::solver::normalization::prepare_stellar_reference_scales(dimensions::MassValue{2.0}, + dimensions::LengthValue{4.0}, dimensions::QuantityValue{3.0}); + REQUIRE(scales.has_value()); + CHECK(serif::solver::normalization::characteristic_scale(*scales, dimensions::BarotropicPressurePotential{}).value() == 1.5); +} diff --git a/tests/equations/barotropic_density_assembly.cpp b/tests/equations/barotropic_density_assembly.cpp new file mode 100644 index 00000000..d1037548 --- /dev/null +++ b/tests/equations/barotropic_density_assembly.cpp @@ -0,0 +1,143 @@ +#include + +#include + +#include "serif/equations/barotropic_density.hpp" +#include "serif/tests/equations/barotropic_eos.hpp" +#include "serif/tests/equations/gravity_fixture.hpp" + +namespace { + using Balance = serif::equations::BarotropicDensityBalance; +} + +TEST_CASE("Barotropic density assembles a nonpolytropic value balance and complete ALE tangent", "[equations][barotropic_density][unit]") { + using namespace serif::tests::equations; + + for (const auto geometry : {::mfem::Element::HEXAHEDRON, ::mfem::Element::TETRAHEDRON}) { + gravity::GravityFixture fixture(geometry); + auto density = fixture.density(), potential = fixture.potential(); + auto surface_level = constant(1, -0.8), omega = constant(1, 0.2), displacement = fixture.displacement(0.3); + /** @brief Bind exactly the four physical inputs; the EOS supplies every thermodynamic value. */ + const auto fields = [&](const auto& rho, const auto& phi, const auto& level, const auto& rotation) { + return backend::make_term_coefficients(backend::bind(fixture.density_binding, rho), + backend::bind(fixture.potential_binding, phi), backend::bind(fixture.constant_binding, level), + backend::bind(fixture.angular_velocity_binding, rotation)); + }; + const Balance balance{MixedPowerBarotrope{.gauge = 7.5}, serif::dimensions::PressureValue{0.3125}, + {1.0, 2.0, 3.0}, {0.2, -0.1, 0.3}}; + VolumeEquationFixture equation{balance, fixture.density_binding, fixture.displacement_binding, + fixture.compactification, fixture.mapper, gravity::reference_policy()}; + equation.prepare(fields(density, potential, surface_level, omega), constant(displacement.Size(), 0.0), fixture.workspace); + ::mfem::Vector residual; + equation.residual(fixture.workspace, residual); + + /** @brief Integrate the analytic physical residual independently of the equation's evaluator. */ + ::mfem::FunctionCoefficient expected_balance([](const ::mfem::Vector& position) { + const double rho = 0.55 + 0.025 * position[0] - 0.010 * position[1] + 0.006 * position[2]; + const double phi = -0.5 + 0.1 * position[0] * position[1] - 0.07 * position[2] * position[2]; + const double x = position[0] - 0.2, y = position[1] + 0.1, z = position[2] - 0.3; + const double axial_projection = x + 2.0 * y + 3.0 * z; + const double centrifugal = 0.5 * (x * x + y * y + z * z - axial_projection * axial_projection / 14.0); + // P_surface = 5/16 fixes rho_surface = 1/2 and A_surface - gauge = 7/6. + return 2.0 * rho + (4.0 / 3.0) * rho * rho * rho - 7.0 / 6.0 + phi - 0.04 * centrifugal + 0.8; + }); + ::mfem::LinearForm reference(&fixture.density_space); + ::mfem::Array stellar{1, 1, 0}; + const auto& rule = ::mfem::IntRules.Get(fixture.mesh.GetElementBaseGeometry(0), gravity::reference_order); + auto* integrator = new ::mfem::DomainLFIntegrator(expected_balance); + integrator->SetIntRule(&rule); + reference.AddDomainIntegrator(integrator, stellar); + reference.Assemble(); + CHECK(relative_error(residual, fixture.density_binding.gather(reference)) < 3.0e-12); + + const auto density_direction = deterministic(density.Size(), 0.1), potential_direction = deterministic(potential.Size(), 0.3); + const auto level_direction = constant(1, -0.05), omega_direction = constant(1, 0.07), displacement_direction = fixture.displacement(0.2); + equation.prepare(fields(density, potential, surface_level, omega), displacement, fixture.workspace); + ::mfem::Vector action, transpose; + equation.apply_jacobian(fields(density_direction, potential_direction, level_direction, omega_direction), + displacement_direction, fixture.workspace, action); + const auto dual = deterministic(residual.Size(), 0.7); + equation.apply_jacobian_transpose(dual, fixture.workspace, transpose); + const auto direction = packed(packed(density_direction, potential_direction, level_direction), omega_direction, displacement_direction); + CHECK(std::abs(dual * action - direction * transpose) < 4.0e-11); + + constexpr double step = 1.0e-6; + auto plus_density = density, minus_density = density, plus_potential = potential, minus_potential = potential; + auto plus_level = surface_level, minus_level = surface_level, plus_omega = omega, minus_omega = omega; + auto plus_displacement = displacement, minus_displacement = displacement; + plus_density.Add(step, density_direction); + minus_density.Add(-step, density_direction); + plus_potential.Add(step, potential_direction); + minus_potential.Add(-step, potential_direction); + plus_level.Add(step, level_direction); + minus_level.Add(-step, level_direction); + plus_omega.Add(step, omega_direction); + minus_omega.Add(-step, omega_direction); + plus_displacement.Add(step, displacement_direction); + minus_displacement.Add(-step, displacement_direction); + ::mfem::Vector positive, negative; + equation.prepare(fields(plus_density, plus_potential, plus_level, plus_omega), plus_displacement, fixture.workspace); + equation.residual(fixture.workspace, positive); + equation.prepare(fields(minus_density, minus_potential, minus_level, minus_omega), minus_displacement, fixture.workspace); + equation.residual(fixture.workspace, negative); + positive -= negative; + positive /= 2.0 * step; + CHECK(relative_error(action, positive) < 4.0e-8); + } +} + +TEST_CASE("Reconstructed density retains the field and geometry Jacobian including its transpose", "[equations][unit][reconstruction]") { + using namespace serif::tests::equations; + namespace reconstruction = serif::discretization::reconstruction; + namespace discretization_backend = serif::discretization::backend::mfem; + using Density = blocks::density::mass; + using PolytropicBalance = serif::equations::BarotropicDensityBalance; + + gravity::GravityFixture fixture(::mfem::Mesh::MakeCartesian3D(2, 1, 1, ::mfem::Element::HEXAHEDRON)); + const double exponent = 5.0 / 3.0; + const auto reconstruction_plan = discretization_backend::make_reconstruction( + reconstruction::SurfacePower{.density_exponent = exponent}, fixture.density_binding); + discretization_backend::TermBinding density_binding(fixture.density_space, reconstruction_plan); + auto density = fixture.project(density_binding, [exponent](const ::mfem::Vector& point) { + return std::pow(point[2], exponent) * (1.0 + 0.1 * point[0]); + }); + const auto density_direction = fixture.project(density_binding, [exponent](const ::mfem::Vector& point) { + return 0.1 * std::pow(point[2], exponent) * (point[0] - 0.3 * point[1]); + }); + const auto potential = fixture.potential(), level = constant(1, -0.8), omega = constant(1, 0.2); + const auto displacement = fixture.displacement(0.3), displacement_direction = fixture.displacement(0.2); + const auto fields = [&](const auto& rho) { + return backend::make_term_coefficients(backend::bind(density_binding, rho), + backend::bind(fixture.potential_binding, potential), backend::bind(fixture.constant_binding, level), + backend::bind(fixture.angular_velocity_binding, omega)); + }; + VolumeEquationFixture equation{PolytropicBalance{serif::eos::models::Polytrope{exponent, 0.7}, serif::dimensions::PressureValue{0.0}}, + density_binding, fixture.displacement_binding, fixture.compactification, fixture.mapper, gravity::reference_policy()}; + equation.prepare(fields(density), displacement, fixture.workspace); + CHECK(equation.integration_order(0) == ::mfem::IntRules.Get(::mfem::Geometry::CUBE, gravity::reference_order).GetOrder()); + ::mfem::Vector action, transpose; + const auto potential_direction = constant(potential.Size(), 0.0), level_direction = constant(1, 0.0), omega_direction = constant(1, 0.0); + equation.apply_jacobian(backend::make_term_coefficients(backend::bind(density_binding, density_direction), + backend::bind(fixture.potential_binding, potential_direction), backend::bind(fixture.constant_binding, level_direction), + backend::bind(fixture.angular_velocity_binding, omega_direction)), displacement_direction, fixture.workspace, action); + const auto dual = deterministic(density_binding.size(), 0.7); + equation.apply_jacobian_transpose(dual, fixture.workspace, transpose); + const auto direction = packed(packed(density_direction, potential_direction, level_direction), omega_direction, displacement_direction); + CHECK(std::abs(dual * action - direction * transpose) < 4e-11); + for (const double step : {1e-5, 1e-6}) { + auto plus_density = density, minus_density = density; + auto plus_displacement = displacement, minus_displacement = displacement; + plus_density.Add(step, density_direction); + minus_density.Add(-step, density_direction); + plus_displacement.Add(step, displacement_direction); + minus_displacement.Add(-step, displacement_direction); + ::mfem::Vector plus, minus; + equation.prepare(fields(plus_density), plus_displacement, fixture.workspace); + equation.residual(fixture.workspace, plus); + equation.prepare(fields(minus_density), minus_displacement, fixture.workspace); + equation.residual(fixture.workspace, minus); + plus -= minus; + plus /= 2.0 * step; + CHECK(relative_error(action, plus) < 4e-8); + } +} diff --git a/tests/equations/barycenter.cpp b/tests/equations/barycenter.cpp new file mode 100644 index 00000000..db629a17 --- /dev/null +++ b/tests/equations/barycenter.cpp @@ -0,0 +1,79 @@ +#include +#include +#include +#include + +#include +#include +#include "serif/solver/normalization/scales.hpp" +#include "serif/solver/normalization/physical_coordinates.hpp" +#include "serif/equations/barycenter.hpp" +#include "serif/utils/serialize/equations.hpp" + +namespace { + namespace equations = serif::equations; + namespace dimensions = serif::dimensions; + using Density = serif::discretization::blocks::density::mass; + + /** @brief Check the density partial and every explicit position partial by central differences. */ + template + void check_partials(const Equation& equation, const Point& point) { + const auto linearization = equation.evaluate(point); + for (const double step : {1.0e-5, 1.0e-6}) { + auto plus = point, minus = point; + plus.fields.template get() += step; + minus.fields.template get() -= step; + const double difference = (equation.evaluate(plus).residual - equation.evaluate(minus).residual) / (2.0 * step); + const double expected = linearization.field_derivatives.template get(); + CHECK(std::abs(difference - expected) < 2.0e-9 * std::max(1.0, std::abs(expected))); + } + for (int axis = 0; axis < 3; ++axis) { + auto plus = point, minus = point; + constexpr double step = 1.0e-5; + plus.position[axis] += step; + minus.position[axis] -= step; + const double difference = (equation.evaluate(plus).residual - equation.evaluate(minus).residual) / (2.0 * step); + CHECK(std::abs(difference - linearization.position_derivative[axis]) < 2.0e-9); + } + } +} + +TEST_CASE("Barycenter constraints differentiate all axes about a displaced center and persist", "[equations][barycenter][unit]") { + const auto check_axis = []() { + using Center = equations::BarycenterConstraint; + const std::array origin{0.1, -0.2, 0.3}; + const std::array position{0.6, 0.3, -0.4}; + constexpr double density = 1.2; + constexpr double mass = 2.7; + equations::EquationPoint center; + center.position = position; + center.fields.template get() = density; + const Center center_law{mass, origin}; + CHECK(center_law.evaluate(center).residual == Catch::Approx(density * (position[Axis] - origin[Axis]) / mass)); + check_partials(center_law, center); + namespace serialization = serif::utils::serialize; + const auto saved = serialization::encode(center_law); + const auto restored = serialization::decode
(saved); + REQUIRE(restored); + CHECK(serialization::encode(*restored) == saved); + for (const double invalid : {std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()}) { + auto bad_center = origin; + bad_center[Axis] = invalid; + CHECK_THROWS(Center(mass, bad_center)); + } + for (const double invalid : {0.0, -0.1, std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()}) { + CHECK_THROWS(Center{invalid}); + } + }; + check_axis.template operator()<0>(); + check_axis.template operator()<1>(); + check_axis.template operator()<2>(); +} + +TEST_CASE("Frame acceleration coordinates use the stellar acceleration scale", "[equations][barycenter][unit]") { + namespace normalization = serif::solver::normalization; + const auto scales = normalization::prepare_stellar_reference_scales(dimensions::MassValue{12.0}, dimensions::LengthValue{3.0}, + dimensions::QuantityValue{2.0}); + REQUIRE(scales); + CHECK(normalization::characteristic_scale(*scales, dimensions::Acceleration{}).value() == Catch::Approx(24.0 / 9.0)); +} diff --git a/tests/equations/basis_lifetime_meanfield.cpp b/tests/equations/basis_lifetime_meanfield.cpp new file mode 100644 index 00000000..a8031f95 --- /dev/null +++ b/tests/equations/basis_lifetime_meanfield.cpp @@ -0,0 +1,91 @@ +#include + +#include + +#include "mfem/fem/fe_coll.hpp" +#include "serif/backend/mfem/discretization/basis_evaluation.hpp" + +namespace +{ + using namespace serif::discretization::backend::mfem; + + /// Require repeatability after the original finite element, quadrature, and cache have been destroyed. + void check_retained_basis(const int basis) + { + BasisEvaluation retained; + BasisWorkspace workspace; + ::mfem::Vector coefficients, dual, expected_values, expected_transpose; + { + const ::mfem::H1_FECollection collection(3, 3, basis); + const ::mfem::FiniteElement& element = *collection.FiniteElementForGeometry(::mfem::Geometry::CUBE); + const ::mfem::IntegrationRule rule(::mfem::IntRules.Get(::mfem::Geometry::CUBE, 7)); + TensorBasisCache cache; + retained.sample(element, rule, ReferenceOperation::value_and_gradient, 1, cache, ContractionMode::automatic); + CHECK(retained.uses_tensor() == (basis != ::mfem::BasisType::Positive)); + coefficients.SetSize(retained.width()); + dual.SetSize(retained.height()); + for (int i = 0; i < coefficients.Size(); ++i) + { + coefficients[i] = std::sin(0.37 * (i + 1)); + } + for (int i = 0; i < dual.Size(); ++i) + { + dual[i] = std::cos(0.23 * (i + 1)); + } + retained.evaluate(coefficients, expected_values, workspace); + retained.integrate(dual, expected_transpose, workspace); + } + + ::mfem::Vector values, transpose; + retained.evaluate(coefficients, values, workspace); + retained.integrate(dual, transpose, workspace); + values -= expected_values; + transpose -= expected_transpose; + CHECK(values.Normlinf() == 0.0); + CHECK(transpose.Normlinf() == 0.0); + } +} + +TEST_CASE("MeanField scalar basis actions survive cache finite element and rule destruction", "[equations][tensor_basis][unit]") +{ + check_retained_basis(::mfem::BasisType::GaussLobatto); + check_retained_basis(::mfem::BasisType::Positive); +} + +TEST_CASE("MeanField basis cache follows rule contents without mutating published factors", "[equations][tensor_basis][unit]") +{ + ::mfem::H1_FECollection collection(3, 3); + const ::mfem::FiniteElement& element = *collection.FiniteElementForGeometry(::mfem::Geometry::CUBE); + const ::mfem::IntegrationRule& rule = ::mfem::IntRules.Get(::mfem::Geometry::CUBE, 7); + ::mfem::IntegrationRule copy(rule), changed_weight(rule), moved_point(rule); + changed_weight.IntPoint(0).weight *= 1.25; + moved_point.IntPoint(0).x += 0.03125; + TensorBasisCache cache; + const auto original = cache.get(element, rule, ReferenceOperation::value_and_gradient, 1); + REQUIRE(original); + ::mfem::Vector coefficients(original->dofs()), expected, values; + coefficients = 0.37; + TensorWorkspace workspace; + original->apply(coefficients, expected, workspace); + + CHECK(cache.get(element, copy, ReferenceOperation::value_and_gradient, 1) == original); + const auto reweighted = cache.get(element, changed_weight, ReferenceOperation::value_and_gradient, 1); + REQUIRE(reweighted); + CHECK(reweighted != original); + CHECK_FALSE(cache.get(element, moved_point, ReferenceOperation::value_and_gradient, 1)); + CHECK(cache.size() == 3); + copy.IntPoint(0).x = moved_point.IntPoint(0).x; + CHECK_FALSE(cache.get(element, copy, ReferenceOperation::value_and_gradient, 1)); + CHECK(cache.size() == 3); + CHECK(cache.get(element, rule, ReferenceOperation::value_and_gradient, 1) == original); + + // Weights identify cache entries, but basis evaluation introduces no integration weight. + reweighted->apply(coefficients, values, workspace); + values -= expected; + CHECK(values.Normlinf() == 0.0); + cache.clear(); + CHECK(cache.size() == 0); + original->apply(coefficients, values, workspace); + values -= expected; + CHECK(values.Normlinf() == 0.0); +} diff --git a/tests/equations/closure_conditions.cpp b/tests/equations/closure_conditions.cpp new file mode 100644 index 00000000..3f5a62cc --- /dev/null +++ b/tests/equations/closure_conditions.cpp @@ -0,0 +1,121 @@ +// EOS/geometry conditions and tolerances from MeanField's prepared closure tests. + +#include +#include +#include +#include + +#include "serif/tests/equations/fixture.hpp" +#include "serif/tests/test_tags.hpp" + +namespace serif::tests::equations { + struct ClosureCondition final { + double n, K, enthalpy_offset, enthalpy_gradient, density_factor, density_offset, density_gradient; + double deformation, direction_phase; + }; + + constexpr std::array closure_conditions{ + {{1.0, 0.8, 0.65, 0.06, 0.80, 0.015, 0.004, 0.0, 0.31}, {1.5, 1.2, 0.90, 0.09, 1.15, -0.003, 0.003, 0.45, 0.53}, + {3.0, 1.5, 1.20, 0.12, 1.40, 0.006, 0.002, 1.0, 0.79}} + }; + + TEST_CASE("Equation Closure Covers Multiple EOS And Geometry Conditions", tags::integration & tags::equations) { + Fixture f; + for (const auto& condition : closure_conditions) { + CAPTURE(condition.n, condition.K, condition.deformation); + const eos::models::Polytrope eos(condition.n, condition.K); + const auto enthalpy_function = [&](const ::mfem::Vector& x) { + return condition.enthalpy_offset + + condition.enthalpy_gradient * (0.50 * x[0] - 0.30 * x[1] + 0.20 * x[2]); + }; + const auto h = f.project(f.enthalpy_binding, enthalpy_function); + const auto rho = f.project(f.density_binding, [&](const ::mfem::Vector& x) { + return condition.density_factor * std::pow(enthalpy_function(x) / eos.enthalpy_scale(), condition.n) + + condition.density_offset + + condition.density_gradient * (0.40 * x[0] + 0.25 * x[1] - 0.15 * x[2]); + }); + const auto u = f.displacement(condition.deformation); + auto drho = deterministic(rho.Size(), condition.direction_phase); + auto dh = f.project(f.enthalpy_binding, [](const ::mfem::Vector& x) { + return 0.07 + 0.015 * x[0] + 0.008 * x[2]; + }); + auto du = f.displacement(0.63); + auto density_action = manual_closure(f, eos, rho, h, u, &drho); + auto enthalpy_action = manual_closure(f, eos, rho, h, u, nullptr, &dh); + auto geometry_action = manual_closure(f, eos, rho, h, u, nullptr, nullptr, &du); + const double target = + std::min({density_action.Norml2(), enthalpy_action.Norml2(), geometry_action.Norml2()}); + REQUIRE(target > 1.0e-12); + drho *= target / density_action.Norml2(); + dh *= target / enthalpy_action.Norml2(); + du *= target / geometry_action.Norml2(); + PreparedClosure prepared(Closure{eos}, f.density_binding, f.displacement_binding, f.compactification, f.mapper); + prepared.prepare(f.state(rho, h), u, f.workspace); + ::mfem::Vector residual, action; + prepared.residual(f.workspace, residual); + prepared.apply_jacobian(f.state(drho, dh), du, f.workspace, action); + CHECK(relative_error(residual, manual_closure(f, eos, rho, h, u)) < 5.0e-12); + CHECK(relative_error(action, manual_closure(f, eos, rho, h, u, &drho, &dh, &du)) < 5.0e-12); + const int expected_order = std::max(4, 5 + static_cast(std::ceil((condition.n - 1.0) * 3))) + 6; + CHECK(prepared.integration_order(0) >= expected_order); + constexpr double step = 1.0e-5; + auto plus_rho = rho, minus_rho = rho, plus_h = h, minus_h = h, plus_u = u, minus_u = u; + plus_rho.Add(step, drho); + minus_rho.Add(-step, drho); + plus_h.Add(step, dh); + minus_h.Add(-step, dh); + plus_u.Add(step, du); + minus_u.Add(-step, du); + auto difference = manual_closure(f, eos, plus_rho, plus_h, plus_u); + difference -= manual_closure(f, eos, minus_rho, minus_h, minus_u); + difference /= 2.0 * step; + difference -= action; + CHECK(difference.Norml2() / (3.0 * target) < 2.0e-7); + } + } + + TEST_CASE("Exact Equation Closure Remains Zero Under Deformation", tags::integration & tags::equations) { + Fixture f; + const auto h = constant(f.enthalpy_binding.size(), 1.20); + const auto rho = constant(f.density_binding.size(), std::pow(1.20 / 6.0, 3.0)); + auto reference_rho = rho; + reference_rho += 1.0; + const auto zero_rho = constant(rho.Size(), 0.0), zero_h = constant(h.Size(), 0.0), du = f.displacement(0.67); + for (const double deformation : {0.0, 0.5, 1.0}) { + CAPTURE(deformation); + const auto u = f.displacement(deformation); + PreparedClosure prepared( + Closure{eos::models::Polytrope{3.0, 1.5}}, f.density_binding, f.displacement_binding, f.compactification, + f.mapper + ); + ::mfem::Vector exact, exact_geometry, reference, reference_geometry; + prepared.prepare(f.state(rho, h), u, f.workspace); + prepared.residual(f.workspace, exact); + prepared.apply_jacobian(f.state(zero_rho, zero_h), du, f.workspace, exact_geometry); + prepared.prepare(f.state(reference_rho, h), u, f.workspace); + prepared.residual(f.workspace, reference); + prepared.apply_jacobian(f.state(zero_rho, zero_h), du, f.workspace, reference_geometry); + REQUIRE(reference.Norml2() > 1.0e-12); + REQUIRE(reference_geometry.Norml2() > 1.0e-14); + CHECK(exact.Norml2() <= 5.0e-12 * reference.Norml2()); + CHECK(exact_geometry.Norml2() <= 5.0e-12 * reference_geometry.Norml2()); + } + } + + TEST_CASE("Equation Quadrature Resolves Both Closure Forms Through The Existing Policy", tags::unit & tags::equations) { + Fixture f; + const auto rho = f.density(), h = f.enthalpy(), u = f.displacement(0.0); + using Mass = discretization::forms::mass; + using Source = discretization::forms::source<::serif::tests::equations::Enthalpy, blocks::density::mass>; + discretization::quadrature::RuleSet rules; + rules.set_form_control({.fixed_order = 18}); + rules.set_form_control({.fixed_order = 8}); + PreparedClosure prepared( + Closure{eos::models::Polytrope{1.0, 0.8}}, f.density_binding, f.displacement_binding, f.compactification, + f.mapper, discretization::quadrature::Policy{rules} + ); + prepared.prepare(f.state(rho, h), u, f.workspace); + CHECK(prepared.integration_order(0) == ::mfem::IntRules.Get(::mfem::Geometry::CUBE, 18).GetOrder()); + CHECK_THROWS_AS(prepared.integration_order(2), std::out_of_range); + } +} // namespace serif::tests::equations diff --git a/tests/equations/closure_dilation.cpp b/tests/equations/closure_dilation.cpp new file mode 100644 index 00000000..8fbd6fc1 --- /dev/null +++ b/tests/equations/closure_dilation.cpp @@ -0,0 +1,36 @@ +#include + +#include "serif/tests/equations/fixture.hpp" +#include "serif/tests/test_tags.hpp" + +namespace serif::tests::equations { + TEST_CASE("Equation Closure Geometry Has The Analytic Dilation Factor", tags::unit & tags::equations) { + Fixture f; + const auto rho = constant(f.density_binding.size(), 0.55), h = constant(f.enthalpy_binding.size(), 0.9); + const auto zero_rho = constant(rho.Size(), 0.0), zero_h = constant(h.Size(), 0.0), zero_u = f.displacement(0.0); + constexpr double dilation = 0.2, direction_scale = 0.07; + ::mfem::VectorFunctionCoefficient coefficient(3, [](const ::mfem::Vector& x, ::mfem::Vector& u) { + u = x; + u *= dilation; + }); + ::mfem::GridFunction displacement(&f.displacement_space); + displacement.ProjectCoefficient(coefficient); + const auto u = f.displacement_binding.gather(displacement); + auto du = u; + du *= direction_scale / dilation; + PreparedClosure prepared( + Closure{eos::models::Polytrope{3.0, 1.5}}, f.density_binding, f.displacement_binding, f.compactification, + f.mapper + ); + ::mfem::Vector identity, deformed, action; + prepared.prepare(f.state(rho, h), zero_u, f.workspace); + prepared.residual(f.workspace, identity); + prepared.prepare(f.state(rho, h), u, f.workspace); + prepared.residual(f.workspace, deformed); + prepared.apply_jacobian(f.state(zero_rho, zero_h), du, f.workspace, action); + identity *= std::pow(1.0 + dilation, 3.0); + CHECK(relative_error(deformed, identity) < 5.0e-12); + deformed *= 3.0 * direction_scale / (1.0 + dilation); + CHECK(relative_error(action, deformed) < 5.0e-12); + } +} // namespace serif::tests::equations diff --git a/tests/equations/closure_kernels.cpp b/tests/equations/closure_kernels.cpp new file mode 100644 index 00000000..17c8b6b0 --- /dev/null +++ b/tests/equations/closure_kernels.cpp @@ -0,0 +1,175 @@ +// Numerical contracts ported from MeanField tests/operators/kernels/barotropic_closure_kernels.cpp. + +#include +#include + +#include "serif/tests/equations/fixture.hpp" +#include "serif/tests/test_tags.hpp" + +namespace serif::tests::equations { + TEST_CASE("Equation Closure Vanishes For A Representable Constant State", tags::unit & tags::equations) { + Fixture f; + const eos::models::Polytrope eos(3.0, 1.5); + const double h_value = 0.8; + const auto h = constant(f.enthalpy_binding.size(), h_value); + const auto rho = constant(f.density_binding.size(), std::pow(h_value / 6.0, 3.0)); + const auto u = f.displacement(0.0); + PreparedClosure prepared(Closure{eos}, f.density_binding, f.displacement_binding, f.compactification, f.mapper); + prepared.prepare(f.state(rho, h), u, f.workspace); + ::mfem::Vector residual, scale; + prepared.residual(f.workspace, residual); + const auto zero_h = constant(h.Size(), 0.0); + prepared.apply_jacobian(f.state(rho, zero_h), u, f.workspace, scale); + REQUIRE(scale.Norml2() > 0.0); + CHECK(residual.Norml2() / scale.Norml2() < 5.0e-12); + } + + TEST_CASE("Equation Closure Density Action Matches The Stellar Mass Matrix", tags::unit & tags::equations) { + for (const auto geometry : {::mfem::Element::HEXAHEDRON, ::mfem::Element::TETRAHEDRON}) { + CAPTURE(geometry); + Fixture f(geometry); + const auto rho = f.density(); + const auto h = f.enthalpy(); + const auto zero_h = constant(h.Size(), 0.0); + const auto u = f.displacement(0.0); + const auto drho = deterministic(rho.Size(), 0.37); + PreparedClosure prepared( + Closure{eos::models::Polytrope{3.0, 1.5}}, f.density_binding, f.displacement_binding, f.compactification, + f.mapper + ); + prepared.prepare(f.state(rho, h), u, f.workspace); + ::mfem::Vector action; + prepared.apply_jacobian(f.state(drho, zero_h), u, f.workspace, action); + ::mfem::Array marker(3); + marker = 0; + marker[0] = marker[1] = 1; + ::mfem::BilinearForm mass(&f.density_space); + mass.AddDomainIntegrator(new ::mfem::MassIntegrator(), marker); + mass.Assemble(); + mass.Finalize(); + const auto full_direction = f.density_binding.scatter(drho); + ::mfem::Vector full_action(full_direction.Size()); + mass.Mult(full_direction, full_action); + CHECK(relative_error(action, f.density_binding.gather(full_action)) < 5.0e-12); + } + } + + TEST_CASE("Equation Closure Thermodynamic Jacobian Matches Centered Differences", tags::unit & tags::equations) { + Fixture f; + const eos::models::Polytrope eos(3.0, 1.5); + const auto rho = f.density(); + const auto h = f.enthalpy(); + const auto u = f.displacement(1.0); + const auto drho = deterministic(rho.Size(), 0.63); + const auto dh = f.project(f.enthalpy_binding, [](const ::mfem::Vector& x) { + return 0.07 + 0.015 * x[0] + 0.008 * x[2]; + }); + const auto zero_u = constant(u.Size(), 0.0); + PreparedClosure prepared(Closure{eos}, f.density_binding, f.displacement_binding, f.compactification, f.mapper); + prepared.prepare(f.state(rho, h), u, f.workspace); + ::mfem::Vector analytic; + prepared.apply_jacobian(f.state(drho, dh), zero_u, f.workspace, analytic); + constexpr double step = 1.0e-6; + auto plus_rho = rho, minus_rho = rho, plus_h = h, minus_h = h; + plus_rho.Add(step, drho); + minus_rho.Add(-step, drho); + plus_h.Add(step, dh); + minus_h.Add(-step, dh); + auto difference = manual_closure(f, eos, plus_rho, plus_h, u); + difference -= manual_closure(f, eos, minus_rho, minus_h, u); + difference /= 2.0 * step; + CHECK(relative_error(analytic, difference) < 2.0e-8); + } + + TEST_CASE("Equation Closure Excludes Vacuum And Uses Mapped Volume", tags::unit & tags::equations) { + Fixture f; + const auto rho = f.density(), h = f.enthalpy(); + const auto zero_h = constant(h.Size(), 0.0); + const auto zero_u = f.displacement(0.0); + const auto ones = constant(rho.Size(), 1.0); + ::mfem::Vector vacuum(f.density_space.GetVSize()); + vacuum = 1.0; + vacuum -= f.density_binding.scatter(ones); + const auto vacuum_direction = f.density_binding.gather(vacuum); + CHECK(vacuum.Norml2() > 0.0); + ::mfem::Vector identity, deformed, vacuum_action; + PreparedClosure prepared( + Closure{eos::models::Polytrope{3.0, 1.5}}, f.density_binding, f.displacement_binding, f.compactification, + f.mapper + ); + prepared.prepare(f.state(rho, h), zero_u, f.workspace); + prepared.apply_jacobian(f.state(ones, zero_h), zero_u, f.workspace, identity); + prepared.apply_jacobian(f.state(vacuum_direction, zero_h), zero_u, f.workspace, vacuum_action); + prepared.prepare(f.state(rho, h), f.displacement(1.0), f.workspace); + prepared.apply_jacobian(f.state(ones, zero_h), zero_u, f.workspace, deformed); + REQUIRE(identity.Norml2() > 0.0); + CHECK(vacuum_action.Norml2() <= 1.0e-13 * identity.Norml2()); + CHECK(relative_error(deformed, identity) > 1.0e-5); + } + + TEST_CASE("Equation Closure Geometry Jacobian Matches Centered Differences", tags::integration & tags::equations) { + for (const auto ordering : {::mfem::Ordering::byNODES, ::mfem::Ordering::byVDIM}) { + Fixture f(::mfem::Element::HEXAHEDRON, ordering); + const eos::models::Polytrope eos(3.0, 1.5); + const auto rho = f.density(), h = f.enthalpy(), du = f.displacement(0.65); + const auto zero_rho = constant(rho.Size(), 0.0), zero_h = constant(h.Size(), 0.0); + for (const double deformation : {0.0, 1.0}) { + CAPTURE(ordering, deformation); + const auto u = f.displacement(deformation); + PreparedClosure prepared( + Closure{eos}, f.density_binding, f.displacement_binding, f.compactification, f.mapper + ); + prepared.prepare(f.state(rho, h), u, f.workspace); + ::mfem::Vector analytic; + prepared.apply_jacobian(f.state(zero_rho, zero_h), du, f.workspace, analytic); + auto plus = u, minus = u; + constexpr double step = 1.0e-5; + plus.Add(step, du); + minus.Add(-step, du); + auto difference = manual_closure(f, eos, rho, h, plus); + difference -= manual_closure(f, eos, rho, h, minus); + difference /= 2.0 * step; + REQUIRE(analytic.Norml2() > 1.0e-12); + CHECK(relative_error(analytic, difference) < 5.0e-8); + } + } + } + + TEST_CASE("Equation Closure Geometry Action Is Linear And Zero Outside Stellar Support", tags::unit & tags::equations) { + Fixture f; + const auto rho = f.density(), h = f.enthalpy(), u = f.displacement(0.8); + const auto zero_rho = constant(rho.Size(), 0.0), zero_h = constant(h.Size(), 0.0), + zero_u = constant(u.Size(), 0.0); + const auto first = f.displacement(0.4), second = deterministic(u.Size(), 0.91); + auto combined = first; + combined *= 1.7; + combined.Add(-0.43, second); + PreparedClosure prepared( + Closure{eos::models::Polytrope{3.0, 1.5}}, f.density_binding, f.displacement_binding, f.compactification, + f.mapper + ); + prepared.prepare(f.state(rho, h), u, f.workspace); + ::mfem::Vector a, b, sum, zero; + prepared.apply_jacobian(f.state(zero_rho, zero_h), first, f.workspace, a); + prepared.apply_jacobian(f.state(zero_rho, zero_h), second, f.workspace, b); + prepared.apply_jacobian(f.state(zero_rho, zero_h), combined, f.workspace, sum); + prepared.apply_jacobian(f.state(zero_rho, zero_h), zero_u, f.workspace, zero); + a *= 1.7; + a.Add(-0.43, b); + CHECK(relative_error(sum, a) < 5.0e-12); + CHECK(zero.Norml2() == 0.0); + auto vacuum_direction = second; + ::mfem::Array dofs; + for (int element = 0; element < f.mesh.GetNE(); ++element) { + if (f.mesh.GetAttribute(element) != 3) { + f.displacement_space.GetElementVDofs(element, dofs); + for (int dof : dofs) { + vacuum_direction[dof < 0 ? -1 - dof : dof] = 0.0; + } + } + } + REQUIRE(vacuum_direction.Norml2() > 0.0); + prepared.apply_jacobian(f.state(zero_rho, zero_h), vacuum_direction, f.workspace, zero); + CHECK(zero.Norml2() == 0.0); + } +} // namespace serif::tests::equations diff --git a/tests/equations/closure_prepared.cpp b/tests/equations/closure_prepared.cpp new file mode 100644 index 00000000..9c6232d4 --- /dev/null +++ b/tests/equations/closure_prepared.cpp @@ -0,0 +1,150 @@ +// Prepared-state contracts ported from MeanField's prepared closure and context tests. + +#include +#include + +#include "serif/tests/equations/fixture.hpp" +#include "serif/tests/test_tags.hpp" + +namespace serif::tests::equations { + TEST_CASE("Prepared Equations Use Supported Field Dimensions", tags::unit & tags::equations) { + Fixture f; + PreparedClosure prepared( + Closure{eos::models::Polytrope{3.0, 1.5}}, f.density_binding, f.displacement_binding, f.compactification, + f.mapper + ); + CHECK_FALSE(prepared.is_prepared()); + CHECK(prepared.preparation_count() == 0); + CHECK(f.density_binding.size() > 0); + CHECK(f.density_binding.size() < f.density_space.GetTrueVSize()); + CHECK(f.enthalpy_binding.size() < f.enthalpy_space.GetTrueVSize()); + CHECK(f.displacement_binding.size() == f.displacement_space.GetTrueVSize()); + CHECK(prepared.height() == f.density_binding.size()); + const auto rho = f.density(), h = f.enthalpy(), u = f.displacement(0.0); + prepared.prepare(f.state(rho, h), u, f.workspace); + CHECK(prepared.width() == rho.Size() + h.Size() + u.Size()); + const auto expanded = f.density_binding.scatter(rho); + CHECK(relative_error(f.density_binding.gather(expanded), rho) == 0.0); + } + + TEST_CASE("Prepared Equations Reject EOS Candidates And Recover", tags::unit & tags::equations) { + Fixture f; + const auto rho = f.density(), u = f.displacement(0.0); + auto h = f.enthalpy(); + PreparedClosure prepared( + Closure{eos::models::Polytrope{3.0, 1.5}}, f.density_binding, f.displacement_binding, f.compactification, + f.mapper + ); + prepared.prepare(f.state(rho, h), u, f.workspace); + for (const double bad_value : {-1.0, 1.0e150}) { + CAPTURE(bad_value); + h = bad_value; + const auto rejected = prepared.try_prepare(f.state(rho, h), u, f.workspace); + REQUIRE_FALSE(rejected.has_value()); + CHECK(rejected.error().reason == backend::PreparationRejectionReason::EQUATION); + CHECK(rejected.error().element >= 0); + CHECK(rejected.error().quadrature_point >= 0); + CHECK_FALSE(prepared.is_prepared()); + ::mfem::Vector result; + CHECK_THROWS_AS(prepared.residual(f.workspace, result), std::logic_error); + CHECK_THROWS_AS(prepared.prepare(f.state(rho, h), u, f.workspace), std::domain_error); + } + h = 1.0; + REQUIRE(prepared.try_prepare(f.state(rho, h), u, f.workspace).has_value()); + CHECK(prepared.preparation_count() == 2); + } + + TEST_CASE("Prepared Equations Reject Folding Geometry And Recover", tags::unit & tags::equations) { + Fixture f; + const auto rho = f.density(), h = f.enthalpy(); + ::mfem::GridFunction folded(&f.displacement_space); + ::mfem::VectorFunctionCoefficient coefficient(3, [](const ::mfem::Vector& x, ::mfem::Vector& u) { + u.SetSize(3); + u = 0.0; + u[0] = -2.0 * x[0]; + }); + folded.ProjectCoefficient(coefficient); + auto u = f.displacement_binding.gather(folded); + PreparedClosure prepared( + Closure{eos::models::Polytrope{3.0, 1.5}}, f.density_binding, f.displacement_binding, f.compactification, + f.mapper + ); + const auto rejected = prepared.try_prepare(f.state(rho, h), u, f.workspace); + REQUIRE_FALSE(rejected.has_value()); + CHECK(rejected.error().reason == backend::PreparationRejectionReason::MAPPING); + CHECK(rejected.error().mapping_status == mapping::MappingStatus::NON_POSITIVE_DETERMINATE); + CHECK_FALSE(prepared.is_prepared()); + CHECK_THROWS_AS(prepared.prepare(f.state(rho, h), u, f.workspace), std::domain_error); + u = 0.0; + CHECK(prepared.try_prepare(f.state(rho, h), u, f.workspace).has_value()); + } + + TEST_CASE("Prepared Closure Matches Manual Residual And Every Jacobian Column", tags::integration & tags::equations) { + for (const auto geometry : {::mfem::Element::HEXAHEDRON, ::mfem::Element::TETRAHEDRON}) { + CAPTURE(geometry); + Fixture f(geometry); + const eos::models::Polytrope eos(3.0, 1.5); + const auto rho = f.density(), h = f.enthalpy(), u = f.displacement(1.0); + const auto drho = deterministic(rho.Size(), 0.43), dh = deterministic(h.Size(), 0.52), + du = f.displacement(0.63); + const auto zero_rho = constant(rho.Size(), 0.0), zero_h = constant(h.Size(), 0.0), + zero_u = constant(u.Size(), 0.0); + PreparedClosure prepared(Closure{eos}, f.density_binding, f.displacement_binding, f.compactification, f.mapper); + prepared.prepare(f.state(rho, h), u, f.workspace); + ::mfem::Vector residual, density_action, enthalpy_action, geometry_action, complete, packed_action; + prepared.residual(f.workspace, residual); + prepared.apply_jacobian(f.state(drho, zero_h), zero_u, f.workspace, density_action); + prepared.apply_jacobian(f.state(zero_rho, dh), zero_u, f.workspace, enthalpy_action); + prepared.apply_jacobian(f.state(zero_rho, zero_h), du, f.workspace, geometry_action); + prepared.apply_jacobian(f.state(drho, dh), du, f.workspace, complete); + backend::JacobianOperator jacobian(prepared.cache()); + jacobian.Mult(packed(drho, dh, du), packed_action); + CHECK(relative_error(residual, manual_closure(f, eos, rho, h, u)) < 2.0e-12); + CHECK(relative_error(density_action, manual_closure(f, eos, rho, h, u, &drho)) < 2.0e-12); + CHECK(relative_error(enthalpy_action, manual_closure(f, eos, rho, h, u, nullptr, &dh)) < 2.0e-12); + CHECK(relative_error(geometry_action, manual_closure(f, eos, rho, h, u, nullptr, nullptr, &du)) < 2.0e-12); + density_action += enthalpy_action; + density_action += geometry_action; + CHECK(relative_error(complete, density_action) < 2.0e-12); + CHECK(relative_error(packed_action, complete) < 2.0e-12); + CHECK(prepared.preparation_count() == 1); + } + } + + TEST_CASE("Prepared Equations Own Frozen Numerical State And Reprepare Every Dependency", tags::unit & tags::equations) { + Fixture f; + const eos::models::Polytrope eos(3.0, 1.5); + auto rho = f.density(), h = f.enthalpy(), u = f.displacement(0.35); + const auto drho = deterministic(rho.Size(), 0.12), dh = deterministic(h.Size(), 0.53), + du = f.displacement(0.63); + PreparedClosure prepared(Closure{eos}, f.density_binding, f.displacement_binding, f.compactification, f.mapper); + prepared.prepare(f.state(rho, h), u, f.workspace); + ::mfem::Vector frozen, frozen_action; + prepared.residual(f.workspace, frozen); + prepared.apply_jacobian(f.state(drho, dh), du, f.workspace, frozen_action); + for (int dependency = 0; dependency < 3; ++dependency) { + CAPTURE(dependency); + if (dependency == 0) { + rho = f.density(1.0); + } else if (dependency == 1) { + h = f.enthalpy(1.0); + } else { + u = f.displacement(0.85); + } + ::mfem::Vector unchanged, unchanged_action; + prepared.residual(f.workspace, unchanged); + prepared.apply_jacobian(f.state(drho, dh), du, f.workspace, unchanged_action); + CHECK(relative_error(unchanged, frozen) == 0.0); + CHECK(relative_error(unchanged_action, frozen_action) == 0.0); + prepared.prepare(f.state(rho, h), u, f.workspace); + prepared.residual(f.workspace, frozen); + prepared.apply_jacobian(f.state(drho, dh), du, f.workspace, frozen_action); + CHECK(relative_error(frozen, manual_closure(f, eos, rho, h, u)) < 2.0e-12); + CHECK(relative_error(frozen_action, manual_closure(f, eos, rho, h, u, &drho, &dh, &du)) < 2.0e-12); + } + CHECK(prepared.preparation_count() == 4); + prepared.invalidate(); + CHECK_FALSE(prepared.is_prepared()); + CHECK_THROWS_AS(prepared.residual(f.workspace, frozen), std::logic_error); + } +} // namespace serif::tests::equations diff --git a/tests/equations/computation_graphs.cpp b/tests/equations/computation_graphs.cpp new file mode 100644 index 00000000..f4a406ae --- /dev/null +++ b/tests/equations/computation_graphs.cpp @@ -0,0 +1,319 @@ +#include "serif/tests/equations/fixture.hpp" + +#include +#include +#include +#include +#include + +namespace { + using namespace serif; + using namespace serif::tests::equations; + namespace dependencies = utils::dependencies; + namespace stages = backend::volume_keys; + + using Vector = ::mfem::Vector; + using Mode = backend::PreparationMode; + using Status = dependencies::PreparationStatus; + using DensityValue = discretization::forms::Operand; + using EnthalpyValue = discretization::forms::Operand<::serif::tests::equations::Enthalpy>; +} + +TEST_CASE("Named volume caches retain independent equation configurations", "[equations][computation_graphs][unit]") { + Fixture fixture; + const eos::models::Polytrope first_eos{1.5, 0.25}; + const eos::models::Polytrope second_eos{3.0, 1.5}; + auto first_density = fixture.density(); + const auto first_enthalpy = fixture.enthalpy(); + const auto first_displacement = fixture.displacement(0.04); + const auto second_density = fixture.density(1.0); + const auto second_enthalpy = fixture.enthalpy(0.5); + const auto second_displacement = fixture.displacement(-0.03); + + const backend::VolumeDiscretization discretization{ + .residual_binding = fixture.density_binding, + .displacement_binding = fixture.displacement_binding, + .compactification = fixture.compactification, + .mapper = fixture.mapper + }; + const auto first_inputs = backend::make_volume_inputs( + discretization, fixture.state(first_density, first_enthalpy), first_displacement + ); + const auto second_inputs = backend::make_volume_inputs( + discretization, fixture.state(second_density, second_enthalpy), second_displacement + ); + auto first = backend::make_volume_equation_cache(Closure{first_eos}, discretization, first_inputs); + auto second = backend::make_volume_equation_cache(Closure{second_eos}, discretization, second_inputs); + CHECK_FALSE(first.ready()); + CHECK_FALSE(second.ready()); + + backend::prepare_volume(first, discretization, fixture.state(first_density, first_enthalpy), first_displacement); + backend::prepare_volume(second, discretization, fixture.state(second_density, second_enthalpy), second_displacement); + Vector first_residual, second_residual; + backend::volume_residual(first.get(), first_residual); + backend::volume_residual(second.get(), second_residual); + CHECK(relative_error(first_residual, manual_closure(fixture, first_eos, first_density, first_enthalpy, first_displacement)) < 2.0e-12); + CHECK(relative_error(second_residual, manual_closure(fixture, second_eos, second_density, second_enthalpy, second_displacement)) < 2.0e-12); + + first_density += 0.07; + backend::prepare_volume(first, discretization, fixture.state(first_density, first_enthalpy), first_displacement); + backend::volume_residual(first.get(), first_residual); + CHECK(relative_error(first_residual, manual_closure(fixture, first_eos, first_density, first_enthalpy, first_displacement)) < 2.0e-12); + + Vector still_second; + backend::volume_residual(second.get(), still_second); + CHECK(relative_error(still_second, second_residual) == 0.0); + CHECK(second.preparation_count() == 1); + CHECK(first.preparation_count() == 2); +} + +TEST_CASE("Volume graph nodes expose coefficient and geometry reuse independently of workspace storage", "[equations][computation_graphs][unit]") { + Fixture fixture; + const eos::models::Polytrope eos{1.5, 0.25}; + auto density = fixture.density(); + const auto enthalpy = fixture.enthalpy(); + auto displacement = fixture.displacement(0.04); + const backend::VolumeDiscretization discretization{ + .residual_binding = fixture.density_binding, + .displacement_binding = fixture.displacement_binding, + .compactification = fixture.compactification, + .mapper = fixture.mapper + }; + const auto inputs = backend::make_volume_inputs(discretization, fixture.state(density, enthalpy), displacement); + auto cache = backend::make_volume_equation_cache(Closure{eos}, discretization, inputs); + backend::prepare_volume(cache, discretization, fixture.state(density, enthalpy), displacement); + const auto initial = cache.report(); + CHECK(initial.status>() == Status::computed); + CHECK(initial.status>() == Status::computed); + CHECK(initial.status>() == Status::computed); + CHECK(initial.status>() == Status::computed); + CHECK(initial.status>() == Status::computed); + CHECK(initial.status>() == Status::computed); + const auto original_basis = cache.get>().front(); + REQUIRE(original_basis->reference_geometry.size() == static_cast(original_basis->rule->GetNPoints())); + const auto original_derivatives = cache.get>().front(); + CHECK(cache.get().geometry_derivatives[0].front() == original_derivatives); + + density += 0.03; + backend::prepare_volume(cache, discretization, fixture.state(density, enthalpy), displacement); + const auto coefficients_changed = cache.report(); + CHECK(coefficients_changed.status>() == Status::reused); + CHECK(coefficients_changed.status>() == Status::reused); + CHECK(coefficients_changed.status>() == Status::computed); + CHECK(coefficients_changed.status>() == Status::reused); + CHECK(coefficients_changed.status>() == Status::computed); + CHECK(coefficients_changed.status>() == Status::computed); + CHECK(cache.get>().front() != original_derivatives); + Vector residual; + backend::volume_residual(cache.get(), residual); + CHECK(relative_error(residual, manual_closure(fixture, eos, density, enthalpy, displacement)) < 2.0e-12); + + displacement *= 1.5; + backend::prepare_volume(cache, discretization, fixture.state(density, enthalpy), displacement); + const auto geometry_changed = cache.report(); + CHECK(geometry_changed.status>() == Status::reused); + CHECK(cache.get>().front() == original_basis); + CHECK(geometry_changed.status>() == Status::computed); + CHECK(geometry_changed.status>() == Status::reused); + CHECK(geometry_changed.status>() == Status::reused); + CHECK(geometry_changed.status>() == Status::computed); + CHECK(geometry_changed.status>() == Status::computed); + backend::volume_residual(cache.get(), residual); + CHECK(relative_error(residual, manual_closure(fixture, eos, density, enthalpy, displacement)) < 2.0e-12); + + // Equal coefficient copies and different application scratch describe the same numerical state. + const Vector density_copy(density), enthalpy_copy(enthalpy), displacement_copy(displacement); + backend::Workspace another_workspace; + backend::prepare_volume(cache, discretization, fixture.state(density_copy, enthalpy_copy), displacement_copy); + const auto unchanged = cache.report(); + CHECK(unchanged.computed_count() == 0); + CHECK(unchanged.status() == Status::reused); + CHECK(unchanged.status>() == Status::reused); + Vector repeated; + backend::volume_residual(cache.get(), repeated); + CHECK(relative_error(repeated, residual) == 0.0); + + // Application reads accepted snapshots until the caller explicitly prepares new inputs. + const auto direction = packed(fixture.density(0.7), fixture.enthalpy(0.2), fixture.displacement(0.01)); + const auto dual = deterministic(fixture.density_binding.size(), 0.3); + Vector action, transpose, frozen_action, frozen_transpose; + backend::apply_jacobian(cache.get(), direction, fixture.workspace, action); + backend::apply_jacobian_transpose(cache.get(), dual, fixture.workspace, transpose); + density += 0.09; + displacement *= 1.1; + backend::volume_residual(cache.get(), repeated); + backend::apply_jacobian(cache.get(), direction, another_workspace, frozen_action); + backend::apply_jacobian_transpose(cache.get(), dual, another_workspace, frozen_transpose); + CHECK(relative_error(repeated, residual) == 0.0); + CHECK(relative_error(frozen_action, action) == 0.0); + CHECK(relative_error(frozen_transpose, transpose) == 0.0); +} + +TEST_CASE("Rejected volume graph candidates never expose partially updated operator results", "[equations][computation_graphs][unit]") { + Fixture fixture; + const eos::models::Polytrope eos{1.5, 0.25}; + auto density = fixture.density(); + auto enthalpy = fixture.enthalpy(); + auto displacement = fixture.displacement(0.04); + const backend::VolumeDiscretization discretization{ + .residual_binding = fixture.density_binding, + .displacement_binding = fixture.displacement_binding, + .compactification = fixture.compactification, + .mapper = fixture.mapper + }; + const auto inputs = backend::make_volume_inputs(discretization, fixture.state(density, enthalpy), displacement); + auto cache = backend::make_volume_equation_cache(Closure{eos}, discretization, inputs); + backend::prepare_volume(cache, discretization, fixture.state(density, enthalpy), displacement); + + // Several upstream results change before the equation rejects this candidate. + density += 0.05; + displacement *= 1.5; + enthalpy = -1.0; + const auto rejected = backend::try_prepare_volume(cache, discretization, fixture.state(density, enthalpy), displacement); + REQUIRE_FALSE(rejected.has_value()); + CHECK(rejected.error().reason == backend::PreparationRejectionReason::EQUATION); + CHECK_FALSE(cache.ready()); + CHECK_THROWS_AS(cache.get(), std::logic_error); + CHECK_THROWS_AS(cache.get>(), std::logic_error); + CHECK_THROWS_AS(cache.get>(), std::logic_error); + Vector result; + CHECK_THROWS_AS(backend::volume_residual(cache.get(), result), std::logic_error); + + enthalpy = fixture.enthalpy(0.5); + backend::prepare_volume(cache, discretization, fixture.state(density, enthalpy), displacement); + backend::volume_residual(cache.get(), result); + CHECK(relative_error(result, manual_closure(fixture, eos, density, enthalpy, displacement)) < 2.0e-12); + CHECK(cache.preparation_count() == 2); + + backend::prepare_volume(cache, discretization, fixture.state(density, enthalpy), displacement, Mode::rebuild); + Vector rebuilt; + backend::volume_residual(cache.get(), rebuilt); + CHECK(relative_error(result, rebuilt) < 2.0e-15); +} + +TEST_CASE("Volume caches reject another discretization of the same type and recover", "[equations][computation_graphs][unit]") { + Fixture fixture; + Fixture other_fixture; + const eos::models::Polytrope eos{1.5, 0.25}; + const auto density = fixture.density(); + const auto enthalpy = fixture.enthalpy(); + const auto displacement = fixture.displacement(0.04); + const auto other_density = other_fixture.density(0.5); + const auto other_enthalpy = other_fixture.enthalpy(0.7); + const auto other_displacement = other_fixture.displacement(-0.03); + const backend::VolumeDiscretization discretization{ + .residual_binding = fixture.density_binding, + .displacement_binding = fixture.displacement_binding, + .compactification = fixture.compactification, + .mapper = fixture.mapper + }; + const backend::VolumeDiscretization other_discretization{ + .residual_binding = other_fixture.density_binding, + .displacement_binding = other_fixture.displacement_binding, + .compactification = other_fixture.compactification, + .mapper = other_fixture.mapper + }; + const auto inputs = backend::make_volume_inputs(discretization, fixture.state(density, enthalpy), displacement); + auto cache = backend::make_volume_equation_cache(Closure{eos}, discretization, inputs); + backend::prepare_volume(cache, discretization, fixture.state(density, enthalpy), displacement); + CHECK(cache.preparation_count() == 1); + + CHECK_THROWS_AS( + backend::try_prepare_volume(cache, other_discretization, other_fixture.state(other_density, other_enthalpy), other_displacement), + std::invalid_argument + ); + CHECK_FALSE(cache.ready()); + CHECK_THROWS_AS(cache.get(), std::logic_error); + CHECK_THROWS_AS(cache.get>(), std::logic_error); + CHECK_THROWS_AS(cache.get>(), std::logic_error); + CHECK(cache.preparation_count() == 1); + + backend::prepare_volume(cache, discretization, fixture.state(density, enthalpy), displacement); + Vector residual; + backend::volume_residual(cache.get(), residual); + CHECK(relative_error(residual, manual_closure(fixture, eos, density, enthalpy, displacement)) < 2.0e-12); + CHECK(cache.preparation_count() == 2); +} + +TEST_CASE("The volume graph refreshes immutable reference geometry after mesh coordinate updates", "[equations][computation_graphs][unit]") { + Fixture fixture; + const eos::models::Polytrope eos{1.5, 0.25}; + const auto density = fixture.density(); + const auto enthalpy = fixture.enthalpy(); + const auto displacement = fixture.displacement(0.04); + const backend::VolumeDiscretization discretization{ + .residual_binding = fixture.density_binding, + .displacement_binding = fixture.displacement_binding, + .compactification = fixture.compactification, + .mapper = fixture.mapper + }; + const auto inputs = backend::make_volume_inputs(discretization, fixture.state(density, enthalpy), displacement); + auto cache = backend::make_volume_equation_cache(Closure{eos}, discretization, inputs); + backend::prepare_volume(cache, discretization, fixture.state(density, enthalpy), displacement); + const auto original = cache.get>().front(); + const double original_weight = original->reference_geometry.front().weight; + + fixture.mesh.Transform([](const ::mfem::Vector& position, ::mfem::Vector& transformed) { + transformed = position; + transformed[0] *= 1.2; + }); + backend::prepare_volume(cache, discretization, fixture.state(density, enthalpy), displacement); + CHECK(cache.report().status>() == Status::computed); + const auto changed = cache.get>().front(); + CHECK(changed != original); + CHECK(changed->reference_geometry.front().weight > original_weight); + CHECK(original->reference_geometry.front().weight == original_weight); + Vector residual; + backend::volume_residual(cache.get(), residual); + CHECK(relative_error(residual, manual_closure(fixture, eos, density, enthalpy, displacement)) < 2.0e-12); + + fixture.mesh.Transform([](const ::mfem::Vector& position, ::mfem::Vector& transformed) { + transformed = position; + transformed[0] = 0.0; + }); + const auto rejected = backend::try_prepare_volume(cache, discretization, fixture.state(density, enthalpy), displacement); + REQUIRE_FALSE(rejected); + CHECK(rejected.error().reason == backend::PreparationRejectionReason::MAPPING); + CHECK_FALSE(cache.ready()); + CHECK(original->reference_geometry.front().weight == original_weight); +} + +TEST_CASE("Volume graph source tracking rejects stale reused input contexts", "[equations][computation_graphs][unit]") { + const bool initially_prepared = GENERATE(false, true); + Fixture fixture; + const eos::models::Polytrope eos{1.5, 0.25}; + const auto density = fixture.density(); + const auto enthalpy = fixture.enthalpy(); + const auto displacement = fixture.displacement(0.04); + const backend::VolumeDiscretization discretization{ + .residual_binding = fixture.density_binding, + .displacement_binding = fixture.displacement_binding, + .compactification = fixture.compactification, + .mapper = fixture.mapper + }; + const auto inputs = backend::make_volume_inputs(discretization, fixture.state(density, enthalpy), displacement); + auto cache = backend::make_volume_equation_cache(Closure{eos}, discretization, inputs); + if (initially_prepared) { + REQUIRE(cache.try_prepare(inputs)); + } + SECTION("Coordinate changes require fresh source metadata") { + fixture.mesh.NodesUpdated(); + CHECK_THROWS_AS(cache.try_prepare(inputs), std::logic_error); + CHECK_FALSE(cache.ready()); + const auto current = backend::make_volume_inputs(discretization, fixture.state(density, enthalpy), displacement); + REQUIRE(cache.try_prepare(current)); + Vector residual; + backend::volume_residual(cache.get(), residual); + CHECK(relative_error(residual, manual_closure(fixture, eos, density, enthalpy, displacement)) < 2.0e-12); + } + SECTION("Domain edits invalidate the borrowed bindings") { + fixture.mesh.GetElement(0)->SetAttribute(3); + CHECK_THROWS_AS(cache.try_prepare(inputs), std::logic_error); + CHECK_FALSE(cache.ready()); + } + SECTION("Topology edits invalidate the borrowed bindings") { + fixture.mesh.UniformRefinement(); + CHECK_THROWS_AS(cache.try_prepare(inputs), std::logic_error); + CHECK_FALSE(cache.ready()); + } +} diff --git a/tests/equations/contribution_lifetimes.cpp b/tests/equations/contribution_lifetimes.cpp new file mode 100644 index 00000000..0ae1fda0 --- /dev/null +++ b/tests/equations/contribution_lifetimes.cpp @@ -0,0 +1,77 @@ +#include +#include +#include + +#include + +#include "serif/discretization/blocks/fields.hpp" +#include "serif/equations/concepts.hpp" +#include "serif/operators/contributions.hpp" + +namespace { + /// Record exactly when an equation's owned numerical coefficient is destroyed. + struct TrackedCoefficient { + double value; + std::shared_ptr destructions; + + /// Construct the tracked value without an intermediate temporary. + TrackedCoefficient(double coefficient, std::shared_ptr count) : value(coefficient), destructions(std::move(count)) {} + /// Release the observable coefficient lifetime. + ~TrackedCoefficient() { ++*destructions; } + }; + + /// A move-only leaf prevents traversal from concealing lifetime mistakes through equation copies. + struct TrackedEquation { + using Test = serif::discretization::blocks::density::mass; + using Density = serif::discretization::forms::Operand; + using Operands = serif::utils::types::TypeList; + static constexpr std::string_view name = "tracked equation"; + + std::unique_ptr slope; + + /// Own a numerical coefficient whose destruction remains observable outside this equation. + TrackedEquation(double coefficient, const std::shared_ptr& count) : slope(std::make_unique(coefficient, count)) {} + + /// Evaluate a linear residual using the retained coefficient. + [[nodiscard]] serif::equations::PointLinearization evaluate(const serif::equations::EquationPoint& point) const { + serif::equations::PointLinearization result; + result.residual = slope->value * point.fields.get(); + result.field_derivatives.get() = slope->value; + return result; + } + }; + + /// Generate a nonempty move-only contribution tuple by value, as mixed gravity generates its leaves. + struct GeneratedEquation { + using Test = TrackedEquation::Test; + static constexpr std::string_view name = "generated contribution"; + double slope; + std::shared_ptr destructions; + + /// The caller must retain this tuple if it wants to retain references to its equation. + [[nodiscard]] auto contributions() const { return std::tuple{TrackedEquation{slope, destructions}}; } + }; + + static_assert(serif::equations::Equation); + static_assert(!std::is_copy_constructible_v); +} + +TEST_CASE("Contribution bindings retain generated tuples and borrowed move-only leaves", "[equations][contributions][unit]") { + using namespace serif::equations; + + const auto destructions = std::make_shared(0); + auto owner = std::make_shared(3.0, destructions); + { + const auto leaves = serif::operators::bind_contributions(owner); + STATIC_CHECK(std::tuple_size_v == 1); + owner.reset(); + + // The generated tuple remains alive after its parent equation is released. + REQUIRE(*destructions == 0); + CHECK(std::get<0>(leaves).coefficient == 1.0); + EquationPoint point; + point.fields.get() = 4.0; + CHECK(std::get<0>(leaves).equation->evaluate(point).residual == 12.0); + } + CHECK(*destructions == 1); +} diff --git a/tests/equations/equation_contracts.cpp b/tests/equations/equation_contracts.cpp new file mode 100644 index 00000000..e6f5885c --- /dev/null +++ b/tests/equations/equation_contracts.cpp @@ -0,0 +1,91 @@ +#include +#include +#include +#include + +#include "serif/equations/primal_gravity.hpp" +#include "serif/equations/registry.hpp" +#include "serif/tests/equations/fixture.hpp" +#include "serif/tests/test_tags.hpp" + +namespace serif::tests::equations { + // An external-style EOS needs only the advertised relation and derivative. + struct LinearEOS final { + using Relations = eos::relations::RelationCatalog; + std::shared_ptr evaluations; + + dimensions::DensityValue evaluate( + eos::relations::DensityFromSpecificEnthalpy, + dimensions::SpecificEnthalpyValue h + ) const { + ++*evaluations; + return dimensions::DensityValue{2.0 * h.value()}; + } + + dimensions::PartialDerivative partial_derivative( + eos::relations::DensityFromSpecificEnthalpy, + dimensions::WithRespectTo, + dimensions::SpecificEnthalpyValue + ) const { + return dimensions::PartialDerivative{2.0}; + } + }; + + TEST_CASE("Equation Closure Accepts Compatible EOS Without Backend Specialization", tags::unit & tags::equations) { + Fixture f; + const auto calls = std::make_shared(0); + const auto equation = ::serif::tests::equations::BarotropicClosure{LinearEOS{calls}, 1.0}; + VolumeEquationFixture prepared( + equation, f.density_binding, f.displacement_binding, f.compactification, f.mapper + ); + const auto rho = constant(f.density_binding.size(), 1.6), h = constant(f.enthalpy_binding.size(), 0.8); + const auto u = f.displacement(0.8); + prepared.prepare(f.state(rho, h), u, f.workspace); + const int preparation_calls = *calls; + REQUIRE(preparation_calls > 0); + ::mfem::Vector residual, action; + prepared.residual(f.workspace, residual); + prepared.apply_jacobian(f.state(rho, h), u, f.workspace, action); + CHECK(residual.Norml2() < 1.0e-13); + CHECK(action.Norml2() < 1.0e-13); + CHECK(*calls == preparation_calls); + } + + TEST_CASE("Equation Closure Local Derivatives Match The Analytic Polytrope", tags::unit & tags::equations) { + for (const double n : {1.0, 1.5, 3.0}) { + Closure equation{eos::models::Polytrope{n, 1.5}}; + serif::equations::EquationPoint point; + point.fields.get() = 0.55; + point.fields.get<::serif::tests::equations::Enthalpy>() = 0.9; + const auto result = equation.evaluate(point); + const double scale = (n + 1.0) * 1.5; + CHECK_THAT(result.residual, Catch::Matchers::WithinRel(0.55 - std::pow(0.9 / scale, n), 1.0e-14)); + CHECK(result.field_derivatives.get() == 1.0); + CHECK_THAT( + result.field_derivatives.get<::serif::tests::equations::Enthalpy>(), + Catch::Matchers::WithinRel(-n / scale * std::pow(0.9 / scale, n - 1.0), 1.0e-14) + ); + CHECK(result.position_derivative == std::array{}); + } + } + + TEST_CASE("Equation Registry Defines Rows From Its Equations", tags::unit & tags::equations) { + const serif::equations::EquationRegistry registry{ + Closure{eos::models::Polytrope{1.0, 0.8}}, serif::equations::PrimalPoissonBalance{1.0} + }; + int count = 0; + registry.for_each([&](const auto& equation) { + CHECK_FALSE(equation.name.empty()); + ++count; + }); + CHECK(count == 2); + CHECK(registry.get().name == "barotropic closure"); + CHECK_THROWS_AS( + (::serif::tests::equations::BarotropicClosure{LinearEOS{std::make_shared(0)}, -1.0}), std::invalid_argument + ); + CHECK_THROWS_AS( + (serif::equations::PrimalPoissonBalance{std::numeric_limits::infinity()}), + std::invalid_argument + ); + } +} // namespace serif::tests::equations diff --git a/tests/equations/fixture.cpp b/tests/equations/fixture.cpp new file mode 100644 index 00000000..c4e7f61c --- /dev/null +++ b/tests/equations/fixture.cpp @@ -0,0 +1,106 @@ +#include "serif/tests/equations/fixture.hpp" + +#include +#include +#include + +namespace serif::tests::equations { + ::mfem::Mesh make_mesh(::mfem::Element::Type geometry) { + auto mesh = ::mfem::Mesh::MakeCartesian3D(3, 1, 1, geometry, 1.5, 1.0, 1.0); + ::mfem::Vector center(3); + for (int element = 0; element < mesh.GetNE(); ++element) { + auto* transformation = mesh.GetElementTransformation(element); + transformation->Transform(::mfem::Geometries.GetCenter(transformation->GetGeometryType()), center); + mesh.GetElement(element)->SetAttribute(1 + static_cast(center[0] / 0.5)); + } + mesh.SetAttributes(); + return mesh; + } + + ::mfem::Vector constant(int size, double value) { + ::mfem::Vector result(size); + result = value; + return result; + } + + ::mfem::Vector deterministic(int size, double phase) { + ::mfem::Vector result(size); + for (int i = 0; i < size; ++i) { + result[i] = 0.1 * std::sin(0.13 * i + phase); + } + return result; + } + + ::mfem::Vector packed( + const ::mfem::Vector& density, + const ::mfem::Vector& enthalpy, + const ::mfem::Vector& displacement + ) { + ::mfem::Vector result(density.Size() + enthalpy.Size() + displacement.Size()); + std::copy_n(density.GetData(), density.Size(), result.GetData()); + std::copy_n(enthalpy.GetData(), enthalpy.Size(), result.GetData() + density.Size()); + std::copy_n(displacement.GetData(), displacement.Size(), result.GetData() + density.Size() + enthalpy.Size()); + return result; + } + + double relative_error(const ::mfem::Vector& actual, const ::mfem::Vector& expected) { + ::mfem::Vector difference(actual); + difference -= expected; + return difference.Norml2() / std::max(expected.Norml2(), 1.0e-30); + } + + Fixture::Fixture(::mfem::Element::Type geometry, ::mfem::Ordering::Type ordering) : Fixture(make_mesh(geometry), ordering) {} + + Fixture::Fixture(::mfem::Mesh supplied_mesh, ::mfem::Ordering::Type ordering) : + mesh(std::move(supplied_mesh)), + density_space(&mesh, &density_collection), + enthalpy_space(&mesh, &enthalpy_collection), + potential_space(&mesh, &potential_collection), + displacement_space(&mesh, &enthalpy_collection, 3, ordering), + compactification(&enthalpy_space), + density_binding(density_space), + enthalpy_binding(enthalpy_space), + displacement_binding(displacement_space), + mapper( + {}, + std::make_unique( + utils::user_options::KelvinCompactificationOptions{.r_star_ref = 1.0, .r_inf_ref = 4.0} + ) + ) { + compactification = 0.0; + } + + ::mfem::Vector Fixture::project( + const discretization::backend::mfem::DofMap& field, + std::function function + ) { + ::mfem::FunctionCoefficient coefficient(std::move(function)); + ::mfem::GridFunction values(&field.space()); + values.ProjectCoefficient(coefficient); + return field.gather(values); + } + + ::mfem::Vector Fixture::density(double phase) { + return project(density_binding, [phase](const ::mfem::Vector& x) { + return 0.55 + 0.025 * x[0] - 0.010 * x[1] + 0.006 * x[2] + 0.03 * phase; + }); + } + + ::mfem::Vector Fixture::enthalpy(double phase) { + return project(enthalpy_binding, [phase](const ::mfem::Vector& x) { + return 0.90 + 0.020 * x[0] - 0.010 * x[1] + 0.005 * x[2] + 0.02 * phase; + }); + } + + ::mfem::Vector Fixture::displacement(double scale) { + ::mfem::VectorFunctionCoefficient coefficient(3, [scale](const ::mfem::Vector& x, ::mfem::Vector& u) { + u.SetSize(3); + u[0] = scale * (0.03 * x[0] + 0.02 * x[0] * x[1]); + u[1] = scale * (-0.02 * x[1] + 0.01 * x[0] * x[2]); + u[2] = scale * (0.04 * x[2] - 0.015 * x[0] * x[1]); + }); + ::mfem::GridFunction values(&displacement_space); + values.ProjectCoefficient(coefficient); + return displacement_binding.gather(values); + } +} // namespace serif::tests::equations diff --git a/tests/equations/gravity_port_fixture.cpp b/tests/equations/gravity_port_fixture.cpp new file mode 100644 index 00000000..67bc1a2f --- /dev/null +++ b/tests/equations/gravity_port_fixture.cpp @@ -0,0 +1,165 @@ +#include +#include +#include +#include +#include "serif/tests/equations/gravity_port.hpp" + +namespace serif::tests::equations::gravity_port { + /// Project an old test polynomial into the same registered coefficient representation. + Vector project_vector(const discretization::backend::mfem::DofMap& binding, std::function function) { + ::mfem::VectorFunctionCoefficient coefficient(3, std::move(function)); + ::mfem::GridFunction values(&binding.space()); + values.ProjectCoefficient(coefficient); + return binding.gather(values); + } + Coefficients zeros(const GravityFixture& f) { + return {constant(f.potential_binding.size(), 0.0), + constant(f.density_binding.size(), 0.0), constant(f.displacement_binding.size(), 0.0)}; + } + Vector sequence(int size, double scale, double phase) { + Vector v(size); + for (int i = 0; i < size; ++i) { + const double index = i + 1; + v[i] = scale * (std::sin(0.31 * index + phase) + 0.37 * std::cos(0.17 * index - phase)); + } + return v; + } + Vector prepared_sequence(int size, double phase) { + Vector v(size); + for (int i = 0; i < size; ++i) { + const double index = i + 1; + v[i] = std::sin(0.37 * index + phase) + 0.31 * std::cos(0.19 * index - 0.5 * phase); + } + return v; + } + Vector operator_sequence(int size, double phase) { + Vector v(size); + for (int i = 0; i < size; ++i) { + const double index = i + 1; + v[i] = 0.4 * std::sin(0.37 * index + phase) + 0.2 * std::cos(0.19 * index - phase); + } + return v; + } + Coefficients state(GravityFixture& f, bool deformed) { + return {sequence(f.potential_binding.size(), 0.3, 0.37), + sequence(f.density_binding.size(), 0.7, 0.11), prepared_displacement(f, deformed ? 1.0 : 0.0)}; + } + Coefficients direction(GravityFixture& f, int column) { + auto d = zeros(f); + if (column == 0) { + d.density = sequence(d.density.Size(), 0.13, 0.19); + } else { + d.potential = sequence(d.potential.Size(), 0.09, 0.41); + } + return d; + } + Vector prepared_displacement(GravityFixture& f, double scale) { + return project_vector(f.displacement_binding, [scale](const Vector& x, Vector& u) { + u.SetSize(3); + u[0] = scale * (0.04 * x[0] + 0.01 * x[1] * x[2]); + u[1] = scale * (-0.03 * x[1] + 0.008 * x[0] * x[2]); + u[2] = scale * (0.02 * x[2] - 0.006 * x[0] * x[1]); + }); + } + Vector affine_displacement(GravityFixture& f, double scale) { + return project_vector(f.displacement_binding, [scale](const Vector& x, Vector& u) { + u = x; + u *= scale; + }); + } + Vector kernel_displacement(GravityFixture& f) { + return project_vector(f.displacement_binding, [](const Vector& x, Vector& u) { + u.SetSize(3); + u[0] = 0.025 * x[0] + 0.006 * x[1] * x[2]; + u[1] = -0.018 * x[1] + 0.005 * x[0] * x[2]; + u[2] = 0.014 * x[2] + 0.004 * x[0] * x[1]; + }); + } + Vector geometry_direction(GravityFixture& f, int index) { + return project_vector(f.displacement_binding, [index](const Vector& x, Vector& u) { + u.SetSize(3); + if (index == 0) { + u[0] = 0.16 * x[0] + 0.03 * x[1]; + u[1] = -0.11 * x[1] + 0.02 * x[2]; + u[2] = 0.13 * x[2] - 0.025 * x[0]; + } else { + u[0] = -0.07 * x[1] + 0.025 * x[2]; + u[1] = 0.09 * x[0] + 0.04 * x[2]; + u[2] = -0.08 * x[2] + 0.03 * x[0] * x[1]; + } + }); + } + Vector kernel_density(GravityFixture& f, bool secondary) { + return f.project(f.density_binding, [secondary](const Vector& x) { + return secondary ? 0.8 - 0.11 * x[0] + 0.13 * x[1] - 0.06 * x[2] + 0.03 * x[1] * x[2] + : 1.2 + 0.16 * x[0] - 0.09 * x[1] + 0.07 * x[2] + 0.04 * x[0] * x[1]; + }); + } + Vector domain_density(GravityFixture& f, bool stellar) { + Vector density(f.density_space.GetVSize()); + std::vector stellar_dofs(density.Size(), false); + ::mfem::Array dofs; + for (int element = 0; element < f.mesh.GetNE(); ++element) { + if (!Schema::template domain_id_belongs_to(f.mesh.GetAttribute(element))) { + continue; + } + f.density_space.GetElementDofs(element, dofs); + for (const int dof : dofs) { + stellar_dofs[dof] = true; + } + } + for (int dof = 0; dof < density.Size(); ++dof) { + density[dof] = stellar_dofs[dof] == stellar ? 1.0 : 0.0; + } + return density; + } + std::unique_ptr stellar_fixture(int refinement_levels, int geometry_order) { + stroid::config::MeshConfig config; + config.refinement_levels = refinement_levels; + config.order = geometry_order; + config.include_external_domain = true; + config.r_core = 0.25; + config.r_star = 1.0; + config.r_infinity = 4.0; + config.flattening = 0.0; + config.optimization_methods = stroid::config::OptimizationMethods{false, true}; + auto mesh = stroid::GenerateMesh(config); + REQUIRE(mesh.exterior_coordinate != nullptr); + REQUIRE(mesh.exterior_coordinate->values != nullptr); + auto fixture = std::make_unique(::mfem::Mesh(*mesh.mesh)); + ::mfem::GridFunctionCoefficient coordinate(mesh.exterior_coordinate->values.get()); + fixture->compactification.ProjectCoefficient(coordinate); + return fixture; + } + Coefficients shifted(const Coefficients& v, const Coefficients& d, double scale) { + auto result = v; + result.potential.Add(scale, d.potential); + result.density.Add(scale, d.density); + result.displacement.Add(scale, d.displacement); + return result; + } + double relative(const Vector& actual, const Vector& expected, double floor) { + REQUIRE(actual.Size() == expected.Size()); + Vector difference(actual); + difference -= expected; + return difference.Norml2() / std::max(expected.Norml2(), floor); + } + double symmetric_relative(const Vector& first, const Vector& second) { + REQUIRE(first.Size() == second.Size()); + Vector difference(first); + difference -= second; + return difference.Norml2() / std::max({first.Norml2(), second.Norml2(), 1.0e-14}); + } + double prepared_relative(const Vector& actual, const Vector& expected) { + return relative(actual, expected, std::numeric_limits::epsilon()); + } + double pairing_relative(double actual, double expected) { + return std::abs(actual - expected) / std::max(std::abs(expected), std::numeric_limits::epsilon()); + } + Vector combination(const Vector& first, double a, const Vector& second, double b) { + Vector result(first); + result *= a; + result.Add(b, second); + return result; + } +} // namespace serif::tests::equations::gravity_port diff --git a/tests/equations/h1_divergence.cpp b/tests/equations/h1_divergence.cpp new file mode 100644 index 00000000..aeb5cfcd --- /dev/null +++ b/tests/equations/h1_divergence.cpp @@ -0,0 +1,134 @@ +#include +#include + +#include "serif/tests/equations/gravity_port.hpp" + +namespace { + using namespace serif; + using namespace serif::tests::equations; + using gravity::GravityFixture; + using gravity_port::project_vector; + using Vector = ::mfem::Vector; + using Displacement = blocks::displacement::vector; + using Divergence = discretization::forms::Operand; + + struct DivergenceInput { + using Test = blocks::density::mass; + using Operands = utils::types::TypeList; + static constexpr std::string_view name = "H1 divergence input test"; + + serif::equations::PointLinearization evaluate(const serif::equations::EquationPoint& point) const { + serif::equations::PointLinearization result; + result.residual = point.fields.get(); + result.field_derivatives.get() = 1.0; + + return result; + } + + int additional_order(Divergence, int) const { + return 0; + } + }; + + /// A smooth nonlinear displacement direction, independent of the field being differentiated. + Vector direction_displacement(GravityFixture& fixture) { + return project_vector(fixture.displacement_binding, [](const Vector& position, Vector& value) { + const double x = position[0], y = position[1], z = position[2]; + value.SetSize(3); + value[0] = 0.019 * x + 0.011 * y * z - 0.006 * z * z + 0.004 * 0.83 * y; + value[1] = -0.016 * y + 0.008 * x * z + 0.005 * x * x - 0.003 * 0.83 * z; + value[2] = 0.013 * z - 0.010 * x * y + 0.006 * y * y + 0.004 * 0.83 * x; + }); + } + + double relative(const Vector& actual, const Vector& expected) { + Vector error(actual); + error -= expected; + + return error.Norml2() / std::max({actual.Norml2(), expected.Norml2(), 100.0 * std::numeric_limits::epsilon()}); + } +} + +TEST_CASE("H1 divergence inputs retain component gradients under a nonsymmetric map", "[equations][h1_divergence][unit]") { + for (const auto geometry : {::mfem::Element::TETRAHEDRON, ::mfem::Element::HEXAHEDRON}) { + for (const auto ordering : {::mfem::Ordering::byNODES, ::mfem::Ordering::byVDIM}) { + GravityFixture fixture(geometry, ordering); + const auto value = project_vector(fixture.displacement_binding, [](const Vector& x, Vector& u) { + u.SetSize(3); + u[0] = 0.4 * x[0] - 0.2 * x[1] + 0.3 * x[2]; + u[1] = 0.1 * x[0] + 0.5 * x[1] - 0.1 * x[2]; + u[2] = -0.3 * x[0] + 0.2 * x[1] + 0.6 * x[2]; + }); + const auto geometry_value = project_vector(fixture.displacement_binding, [](const Vector& x, Vector& u) { + u.SetSize(3); + u[0] = 0.2 * x[0] + 0.07 * x[1]; + u[1] = -0.04 * x[0] - 0.1 * x[1] + 0.05 * x[2]; + u[2] = 0.03 * x[0] + 0.1 * x[2]; + }); + const auto direction = gravity_port::prepared_displacement(fixture, 0.83); + const auto geometry_direction = direction_displacement(fixture); + auto state = [&](const Vector& v) { + return backend::make_term_coefficients(backend::bind(fixture.displacement_binding, v)); + }; + VolumeEquationFixture prepared{ + DivergenceInput{}, fixture.density_binding, fixture.displacement_binding, + fixture.compactification, fixture.mapper, gravity::reference_policy() + }; + prepared.prepare(state(value), geometry_value, fixture.workspace); + Vector actual; + prepared.residual(fixture.workspace, actual); + + ::mfem::DenseMatrix A(3), inverse(3); + A = 0.0; + A(0, 0) = 1.2; + A(0, 1) = 0.07; + A(1, 0) = -0.04; + A(1, 1) = 0.9; + A(1, 2) = 0.05; + A(2, 0) = 0.03; + A(2, 2) = 1.1; + ::mfem::CalcInverse(A, inverse); + constexpr double gradient[3][3] = {{0.4, -0.2, 0.3}, {0.1, 0.5, -0.1}, {-0.3, 0.2, 0.6}}; + double divergence = 0.0; + for (int component = 0; component < 3; ++component) { + for (int derivative = 0; derivative < 3; ++derivative) { + divergence += gradient[component][derivative] * inverse(derivative, component); + } + } + + ::mfem::ConstantCoefficient coefficient(divergence * A.Det()); + ::mfem::LinearForm reference_load(&fixture.density_space); + ::mfem::Array stellar{1, 1, 0}; + reference_load.AddDomainIntegrator(new ::mfem::DomainLFIntegrator(coefficient), stellar); + reference_load.Assemble(); + CHECK(relative(actual, fixture.density_binding.gather(reference_load)) < 2.0e-12); + + for (int column = 0; column < 3; ++column) { + auto selected = direction, selected_geometry = geometry_direction; + if (column == 0) { + selected_geometry = 0.0; + } else if (column == 1) { + selected = 0.0; + } + + prepared.prepare(state(value), geometry_value, fixture.workspace); + Vector derivative; + prepared.apply_jacobian(state(selected), selected_geometry, fixture.workspace, derivative); + auto plus = value, minus = value, plus_geometry = geometry_value, minus_geometry = geometry_value; + constexpr double step = 1.0e-5; + plus.Add(step, selected); + minus.Add(-step, selected); + plus_geometry.Add(step, selected_geometry); + minus_geometry.Add(-step, selected_geometry); + Vector plus_residual, minus_residual; + prepared.prepare(state(plus), plus_geometry, fixture.workspace); + prepared.residual(fixture.workspace, plus_residual); + prepared.prepare(state(minus), minus_geometry, fixture.workspace); + prepared.residual(fixture.workspace, minus_residual); + plus_residual -= minus_residual; + plus_residual *= 0.5 / step; + CHECK(relative(derivative, plus_residual) < 3.0e-8); + } + } + } +} diff --git a/tests/equations/h1_gradient.cpp b/tests/equations/h1_gradient.cpp new file mode 100644 index 00000000..baa81bc8 --- /dev/null +++ b/tests/equations/h1_gradient.cpp @@ -0,0 +1,109 @@ +#include + +#include +#include "mfem/fem/fe_coll.hpp" +#include "mfem/mesh/mesh.hpp" +#include "serif/tests/equations/test_equations.hpp" +#include "serif/backend/mfem/operators/operand_evaluation.hpp" +#include "serif/discretization/blocks/fields.hpp" + +namespace { + using Scalar = ::serif::tests::equations::Enthalpy; + using Gradient = serif::discretization::forms::Operand; + using Evaluator = serif::operators::backend::mfem::OperandEvaluator; + using serif::discretization::backend::mfem::BasisEvaluation; + using serif::discretization::backend::mfem::BasisWorkspace; + using serif::discretization::backend::mfem::ContractionMode; + using serif::discretization::backend::mfem::TensorBasisCache; +} + +TEST_CASE("Scalar H1 gradients agree with physical MFEM bases and transpose actions", "[equations][scalar_gradient][unit]") { + for (const auto geometry : {::mfem::Element::HEXAHEDRON, ::mfem::Element::TETRAHEDRON}) { + auto mesh = ::mfem::Mesh::MakeCartesian3D(1, 1, 1, geometry, 1.7, 0.9, 1.2); + // Shear as well as unequal lengths exposes inverse/transpose mistakes. + for (int vertex = 0; vertex < mesh.GetNV(); ++vertex) { + double* position = mesh.GetVertex(vertex); + position[0] += 0.23 * position[1]; + position[2] -= 0.17 * position[1]; + } + ::mfem::H1_FECollection collection(3, 3); + TensorBasisCache cache; + BasisWorkspace workspace; + for (int index = 0; index < mesh.GetNE(); ++index) { + auto& transformation = *mesh.GetElementTransformation(index); + const auto& element = *collection.FiniteElementForGeometry(mesh.GetElementBaseGeometry(index)); + const auto& rule = ::mfem::IntRules.Get(element.GetGeomType(), 8); + for (const auto mode : {ContractionMode::automatic, ContractionMode::dense}) { + BasisEvaluation basis; + Evaluator::sample_basis(element, rule, basis, cache, mode, transformation, workspace); + ::mfem::Vector coefficients(element.GetDof()), values, dual(3 * rule.GetNPoints()), transpose; + for (int dof = 0; dof < coefficients.Size(); ++dof) { + coefficients[dof] = std::sin(0.37 * (dof + 1)); + } + Evaluator::evaluate(basis, coefficients, values, workspace); + ::mfem::DenseMatrix physical_basis(element.GetDof(), 3); + for (int point = 0; point < rule.GetNPoints(); ++point) { + transformation.SetIntPoint(&rule.IntPoint(point)); + element.CalcPhysDShape(transformation, physical_basis); + for (int axis = 0; axis < 3; ++axis) { + double expected = 0.0; + for (int dof = 0; dof < coefficients.Size(); ++dof) { + expected += physical_basis(dof, axis) * coefficients[dof]; + } + CHECK(std::abs(values[3 * point + axis] - expected) < 2.0e-11); + dual[3 * point + axis] = std::cos(0.19 * (3 * point + axis + 1)); + } + } + Evaluator::integrate(basis, dual, transpose, workspace); + CHECK(std::abs(values * dual - coefficients * transpose) < 2.0e-10); + } + } + } +} + +TEST_CASE("Scalar H1 gradient geometry derivatives follow inverse transpose mapping", "[equations][scalar_gradient][unit]") { + serif::mapping::MappingPointContext context; + context.mapping_jacobian.SetSize(3); + context.mapping_jacobian = 0.0; + context.mapping_jacobian(0, 0) = 1.2; + context.mapping_jacobian(1, 1) = 0.8; + context.mapping_jacobian(2, 2) = 1.1; + context.mapping_jacobian(0, 1) = 0.21; + context.mapping_jacobian(2, 0) = -0.13; + context.inverse_mapping_jacobian.SetSize(3); + ::mfem::CalcInverse(context.mapping_jacobian, context.inverse_mapping_jacobian); + serif::mapping::MappingPointVariation variation; + variation.mapping_jacobian_variation.SetSize(3); + for (int row = 0; row < 3; ++row) { + for (int column = 0; column < 3; ++column) { + variation.mapping_jacobian_variation(row, column) = std::sin(0.41 * (3 * row + column + 1)); + } + } + ::mfem::DenseMatrix product(3); + variation.inverse_mapping_jacobian_variation.SetSize(3); + ::mfem::Mult(context.inverse_mapping_jacobian, variation.mapping_jacobian_variation, product); + ::mfem::Mult(product, context.inverse_mapping_jacobian, variation.inverse_mapping_jacobian_variation); + variation.inverse_mapping_jacobian_variation *= -1.0; + const Evaluator::ReferenceValue reference{0.7, -0.2, 0.4}; + const auto physical = Evaluator::physical_value(reference, context); + // A^T grad_x = grad_X is independent of the implementation's inverse multiplication. + for (int axis = 0; axis < 3; ++axis) { + double recovered = 0.0; + for (int component = 0; component < 3; ++component) { + recovered += context.mapping_jacobian(component, axis) * physical[component]; + } + CHECK(std::abs(recovered - reference[axis]) < 2.0e-14); + } + const auto derivative = Evaluator::geometry_variation(reference, context, variation); + auto plus = context, minus = context; + constexpr double step = 1.0e-6; + plus.mapping_jacobian.Add(step, variation.mapping_jacobian_variation); + minus.mapping_jacobian.Add(-step, variation.mapping_jacobian_variation); + ::mfem::CalcInverse(plus.mapping_jacobian, plus.inverse_mapping_jacobian); + ::mfem::CalcInverse(minus.mapping_jacobian, minus.inverse_mapping_jacobian); + const auto plus_gradient = Evaluator::physical_value(reference, plus); + const auto minus_gradient = Evaluator::physical_value(reference, minus); + for (int axis = 0; axis < 3; ++axis) { + CHECK(std::abs(derivative[axis] - (plus_gradient[axis] - minus_gradient[axis]) / (2.0 * step)) < 2.0e-10); + } +} diff --git a/tests/equations/h1_gradient_equation.cpp b/tests/equations/h1_gradient_equation.cpp new file mode 100644 index 00000000..0c7253a9 --- /dev/null +++ b/tests/equations/h1_gradient_equation.cpp @@ -0,0 +1,86 @@ +#include + +#include +#include "serif/tests/equations/fixture.hpp" + +namespace { + using namespace serif::tests::equations; + using Scalar = ::serif::tests::equations::Enthalpy; + using Gradient = serif::discretization::forms::Operand; + + /** @brief Exercise a scalar diffusion row through the production volume assembly. */ + struct ScalarDiffusion : serif::equations::Contribution { + using Operands = serif::utils::types::TypeList; + static constexpr std::string_view name = "scalar gradient assembly regression"; + + /** @brief The flux is grad(z), so its partial with respect to grad(z) is identity. */ + serif::equations::PointLinearization + evaluate(const serif::equations::EquationPoint& point) const { + serif::equations::PointLinearization result; + result.residual = point.fields.get(); + for (int axis = 0; axis < 3; ++axis) { + result.field_derivatives.get()[axis][axis] = 1.0; + } + return result; + } + + /** @brief This linear flux introduces no extra polynomial coefficient. */ + static int additional_order(Gradient, int) { return 0; } + }; +} + +TEST_CASE("Scalar gradient test rows assemble diffusion and differentiate moving geometry", "[equations][scalar_gradient][unit]") { + for (const auto geometry : {::mfem::Element::HEXAHEDRON, ::mfem::Element::TETRAHEDRON}) { + Fixture fixture(geometry); + const auto values = deterministic(fixture.enthalpy_binding.size(), 0.31); + const auto direction = deterministic(values.Size(), 0.57); + const auto displacement = fixture.displacement(0.6); + const auto displacement_direction = fixture.displacement(0.2); + const auto zero = constant(displacement.Size(), 0.0); + const auto fields = [&](const ::mfem::Vector& coefficients) { + return backend::make_term_coefficients(backend::bind(fixture.enthalpy_binding, coefficients)); + }; + serif::discretization::quadrature::RuleSet rules; + rules.set_fallback_control({.fixed_order = 10}); + VolumeEquationFixture prepared{ScalarDiffusion{}, fixture.enthalpy_binding, fixture.displacement_binding, + fixture.compactification, fixture.mapper, serif::discretization::quadrature::Policy{rules}}; + prepared.prepare(fields(values), zero, fixture.workspace); + ::mfem::Vector residual; + prepared.residual(fixture.workspace, residual); + ::mfem::BilinearForm reference(&fixture.enthalpy_space); + ::mfem::Array stellar{1, 1, 0}; + reference.AddDomainIntegrator(new ::mfem::DiffusionIntegrator(), stellar); + reference.Assemble(); + reference.Finalize(); + const auto full_values = fixture.enthalpy_binding.scatter(values); + ::mfem::Vector full_residual(full_values.Size()); + reference.Mult(full_values, full_residual); + CHECK(relative_error(residual, fixture.enthalpy_binding.gather(full_residual)) < 3.0e-12); + + prepared.prepare(fields(values), displacement, fixture.workspace); + if (geometry == ::mfem::Element::HEXAHEDRON) { + CHECK(prepared.tensor_element_count() > 0); + } + ::mfem::Vector action, transpose; + prepared.apply_jacobian(fields(direction), displacement_direction, fixture.workspace, action); + const auto dual = deterministic(residual.Size(), 0.83); + prepared.apply_jacobian_transpose(dual, fixture.workspace, transpose); + const auto packed_direction = packed(constant(0, 0.0), direction, displacement_direction); + CHECK(std::abs(dual * action - packed_direction * transpose) < 3.0e-11); + + constexpr double step = 1.0e-6; + auto plus = values, minus = values, plus_displacement = displacement, minus_displacement = displacement; + plus.Add(step, direction); + minus.Add(-step, direction); + plus_displacement.Add(step, displacement_direction); + minus_displacement.Add(-step, displacement_direction); + ::mfem::Vector plus_residual, minus_residual; + prepared.prepare(fields(plus), plus_displacement, fixture.workspace); + prepared.residual(fixture.workspace, plus_residual); + prepared.prepare(fields(minus), minus_displacement, fixture.workspace); + prepared.residual(fixture.workspace, minus_residual); + plus_residual -= minus_residual; + plus_residual /= 2.0 * step; + CHECK(relative_error(action, plus_residual) < 3.0e-8); + } +} diff --git a/tests/equations/h1_vector_gradient.cpp b/tests/equations/h1_vector_gradient.cpp new file mode 100644 index 00000000..a310b760 --- /dev/null +++ b/tests/equations/h1_vector_gradient.cpp @@ -0,0 +1,118 @@ +#include + +#include +#include "mfem/fem/fe_coll.hpp" +#include "mfem/mesh/mesh.hpp" +#include "serif/backend/mfem/operators/operand_evaluation.hpp" +#include "serif/discretization/blocks/fields.hpp" + +namespace { + namespace forms = serif::discretization::forms; + namespace backend = serif::operators::backend::mfem; + using Gradient = forms::Operand; + using Evaluator = backend::OperandEvaluator; +} + +TEST_CASE("Vector H1 gradients retain native physical components and transpose actions", "[equations][vector_gradient][unit]") { + for (const auto geometry : {::mfem::Element::HEXAHEDRON, ::mfem::Element::TETRAHEDRON}) { + auto mesh = ::mfem::Mesh::MakeCartesian3D(1, 1, 1, geometry, 1.7, 0.9, 1.2); + for (int vertex = 0; vertex < mesh.GetNV(); ++vertex) { + double* position = mesh.GetVertex(vertex); + position[0] += 0.23 * position[1]; + position[2] -= 0.17 * position[1]; + } + ::mfem::H1_FECollection collection(3, 3); + backend::TensorBasisCache cache; + backend::BasisWorkspace workspace; + for (int index = 0; index < mesh.GetNE(); ++index) { + auto& transformation = *mesh.GetElementTransformation(index); + const auto& element = *collection.FiniteElementForGeometry(mesh.GetElementBaseGeometry(index)); + const auto& rule = ::mfem::IntRules.Get(element.GetGeomType(), 8); + for (const auto mode : {backend::ContractionMode::automatic, backend::ContractionMode::dense}) { + backend::BasisEvaluation basis; + Evaluator::sample_basis(element, rule, basis, cache, mode, transformation, workspace); + CHECK(basis.uses_tensor() == (geometry == ::mfem::Element::HEXAHEDRON && mode == backend::ContractionMode::automatic)); + ::mfem::Vector coefficients(3 * element.GetDof()), values, dual(9 * rule.GetNPoints()), transpose; + for (int repeat = 0; repeat < 2; ++repeat) { + for (int dof = 0; dof < coefficients.Size(); ++dof) { + coefficients[dof] = repeat == 0 ? std::sin(0.37 * (dof + 1)) : 0.0; + } + Evaluator::evaluate(basis, coefficients, values, workspace); + ::mfem::DenseMatrix physical_basis(element.GetDof(), 3); + for (int point = 0; point < rule.GetNPoints(); ++point) { + transformation.SetIntPoint(&rule.IntPoint(point)); + element.CalcPhysDShape(transformation, physical_basis); + for (int component = 0; component < 3; ++component) { + for (int derivative = 0; derivative < 3; ++derivative) { + double expected = 0.0; + for (int dof = 0; dof < element.GetDof(); ++dof) { + expected += physical_basis(dof, derivative) * coefficients[component * element.GetDof() + dof]; + } + const int entry = 9 * point + 3 * component + derivative; + CHECK(std::abs(values[entry] - expected) < 2.0e-11); + dual[entry] = repeat == 0 ? std::cos(0.19 * (entry + 1)) : 0.0; + } + } + } + Evaluator::integrate(basis, dual, transpose, workspace); + CHECK(std::abs(values * dual - coefficients * transpose) < 2.0e-10); + if (repeat != 0) { + CHECK(values.Norml2() == 0.0); + CHECK(transpose.Norml2() == 0.0); + } + } + } + } + } +} + +TEST_CASE("Vector gradient mapping differentiates right multiplication by the inverse map", "[equations][vector_gradient][unit]") { + serif::mapping::MappingPointContext context; + context.mapping_jacobian.SetSize(3); + context.mapping_jacobian = 0.0; + context.mapping_jacobian(0, 0) = 1.2; + context.mapping_jacobian(1, 1) = 0.8; + context.mapping_jacobian(2, 2) = 1.1; + context.mapping_jacobian(0, 1) = 0.21; + context.mapping_jacobian(2, 0) = -0.13; + context.inverse_mapping_jacobian.SetSize(3); + ::mfem::CalcInverse(context.mapping_jacobian, context.inverse_mapping_jacobian); + serif::mapping::MappingPointVariation variation; + variation.mapping_jacobian_variation.SetSize(3); + for (int row = 0; row < 3; ++row) { + for (int column = 0; column < 3; ++column) { + variation.mapping_jacobian_variation(row, column) = std::sin(0.41 * (3 * row + column + 1)); + } + } + ::mfem::DenseMatrix product(3); + variation.inverse_mapping_jacobian_variation.SetSize(3); + ::mfem::Mult(context.inverse_mapping_jacobian, variation.mapping_jacobian_variation, product); + ::mfem::Mult(product, context.inverse_mapping_jacobian, variation.inverse_mapping_jacobian_variation); + variation.inverse_mapping_jacobian_variation *= -1.0; + const Evaluator::ReferenceValue reference{0.7, -0.2, 0.4, -0.6, 0.3, 0.1, 0.8, -0.9, 0.5}; + const auto physical = Evaluator::physical_value(reference, context); + const auto derivative = Evaluator::geometry_variation(reference, context, variation); + for (int component = 0; component < 3; ++component) { + for (int axis = 0; axis < 3; ++axis) { + double recovered = 0.0; + for (int inner = 0; inner < 3; ++inner) { + recovered += physical[component][inner] * context.mapping_jacobian(inner, axis); + } + CHECK(std::abs(recovered - reference[3 * component + axis]) < 2.0e-14); + } + } + for (const double step : {1.0e-5, 1.0e-6}) { + auto plus = context, minus = context; + plus.mapping_jacobian.Add(step, variation.mapping_jacobian_variation); + minus.mapping_jacobian.Add(-step, variation.mapping_jacobian_variation); + ::mfem::CalcInverse(plus.mapping_jacobian, plus.inverse_mapping_jacobian); + ::mfem::CalcInverse(minus.mapping_jacobian, minus.inverse_mapping_jacobian); + const auto plus_gradient = Evaluator::physical_value(reference, plus); + const auto minus_gradient = Evaluator::physical_value(reference, minus); + for (int component = 0; component < 3; ++component) { + for (int axis = 0; axis < 3; ++axis) { + CHECK(std::abs(derivative[component][axis] - (plus_gradient[component][axis] - minus_gradient[component][axis]) / (2.0 * step)) < 3.0e-9); + } + } + } +} diff --git a/tests/equations/h1_vector_gradient_equation.cpp b/tests/equations/h1_vector_gradient_equation.cpp new file mode 100644 index 00000000..c10cc009 --- /dev/null +++ b/tests/equations/h1_vector_gradient_equation.cpp @@ -0,0 +1,196 @@ +#include +#include +#include +#include "mfem/fem/bilinearform.hpp" +#include "mfem/fem/bilininteg.hpp" +#include "serif/tests/equations/fixture.hpp" +#include "serif/tests/nonconforming.hpp" + +namespace { + using namespace serif::tests::equations; + namespace forms = serif::discretization::forms; + using Vector = ::mfem::Vector; + using VectorField = blocks::displacement::vector; + using Value = forms::Operand; + using Gradient = forms::Operand; + using StellarDomains = serif::discretization::domain::StellarDomains; + + /** @brief Supply the componentwise diffusion flux for comparison with native MFEM. */ + struct VectorDiffusion : serif::equations::Contribution { + using Operands = serif::utils::types::TypeList; + static constexpr std::string_view name = "vector diffusion regression"; + /** @brief Each physical gradient component contributes its own identity derivative. */ + serif::equations::PointLinearization evaluate(const serif::equations::EquationPoint& point) const { + serif::equations::PointLinearization result; + result.residual = point.fields.get(); + for (int entry = 0; entry < 9; ++entry) { + result.field_derivatives.get()[entry][entry] = 1.0; + } + return result; + } + /** @brief The diffusion flux has no additional polynomial coefficient. */ + static int additional_order(Gradient, int) { return 0; } + }; + + /** @brief Couple transposed gradient entries and vector values to expose tensor indexing errors. */ + struct CoupledFlux : serif::equations::Contribution { + using Operands = serif::utils::types::TypeList; + static constexpr std::string_view name = "coupled tensor flux regression"; + /** @brief Differentiate a nonlinear manufactured flux, including its explicit position dependence. */ + serif::equations::PointLinearization evaluate(const serif::equations::EquationPoint& point) const { + const auto& gradient = point.fields.get(); + const auto& value = point.fields.get(); + serif::equations::PointLinearization result; + for (int component = 0; component < 3; ++component) { + for (int axis = 0; axis < 3; ++axis) { + const int entry = 3 * component + axis; + result.residual[component][axis] = gradient[component][axis] + 0.37 * gradient[axis][component] + + 0.11 * gradient[component][axis] * gradient[component][axis] + 0.19 * value[component] * value[axis] + + 0.07 * point.position[axis] * value[component]; + auto& gradient_partial = result.field_derivatives.get(); + gradient_partial[entry][entry] += 1.0 + 0.22 * gradient[component][axis]; + gradient_partial[entry][3 * axis + component] += 0.37; + auto& value_partial = result.field_derivatives.get(); + value_partial[entry][component] += 0.19 * value[axis] + 0.07 * point.position[axis]; + value_partial[entry][axis] += 0.19 * value[component]; + result.position_derivative[entry][axis] = 0.07 * value[component]; + } + } + return result; + } + /** @brief Account for the quadratic factors in this manufactured flux. */ + template static int additional_order(Operand, int order) { return order + 1; } + }; + + /** @brief Exercise the reverse rectangular derivative from tensor input to vector output. */ + struct CoupledForce : serif::equations::Contribution { + using Operands = serif::utils::types::TypeList; + static constexpr std::string_view name = "coupled vector force regression"; + /** @brief Contract the gradient with velocity and differentiate both independent operands. */ + serif::equations::PointLinearization evaluate(const serif::equations::EquationPoint& point) const { + const auto& gradient = point.fields.get(); + const auto& value = point.fields.get(); + serif::equations::PointLinearization result; + for (int component = 0; component < 3; ++component) { + for (int axis = 0; axis < 3; ++axis) { + result.residual[component] += gradient[component][axis] * value[axis]; + result.field_derivatives.get()[component][3 * component + axis] = value[axis]; + result.field_derivatives.get()[component][axis] = gradient[component][axis]; + } + } + return result; + } + /** @brief The convection-like product adds one field's polynomial order. */ + template static int additional_order(Operand, int order) { return order; } + }; + + /** @brief Publish the flux and force as two contributions of one vector row. */ + struct CoupledEquation { + using Test = VectorField; + static constexpr std::string_view name = "coupled tensor flux and vector force regression"; + [[nodiscard]] auto contributions() const { return std::tuple{CoupledFlux{}, CoupledForce{}}; } + }; + + /** @brief Fix identical quadrature for native MFEM and the prepared equation. */ + serif::discretization::quadrature::Policy integration_policy() { + serif::discretization::quadrature::RuleSet rules; + rules.set_fallback_control({.fixed_order = 12}); + return serif::discretization::quadrature::Policy{rules}; + } + + /** @brief Give the physical vector field a coefficient input independent of mesh displacement. */ + auto field(const Fixture& fixture, const Vector& values) { + return backend::make_term_coefficients(backend::bind(fixture.displacement_binding, values)); + } + + /** @brief Compare native local assembly followed by P transpose with the reduced backend residual. */ + void check_native_diffusion(Fixture& fixture, backend::ContractionMode mode) { + const Vector values = deterministic(fixture.displacement_binding.size(), 0.31); + const Vector zero = constant(values.Size(), 0.0); + VolumeEquationFixture prepared{VectorDiffusion{}, fixture.displacement_binding, fixture.displacement_binding, + fixture.compactification, fixture.mapper, integration_policy(), mode}; + prepared.prepare(field(fixture, values), zero, fixture.workspace); + Vector actual; + prepared.residual(fixture.workspace, actual); + ::mfem::BilinearForm reference(&fixture.displacement_space); + auto* integrator = new ::mfem::VectorDiffusionIntegrator(); + integrator->SetIntRule(&::mfem::IntRules.Get(fixture.mesh.GetElementBaseGeometry(0), 12)); + ::mfem::Array stellar{1, 1, 0}; + reference.AddDomainIntegrator(integrator, stellar); + reference.Assemble(); + reference.Finalize(); + const Vector full_values = fixture.displacement_binding.scatter(values); + Vector native(full_values.Size()); + reference.Mult(full_values, native); + CHECK(relative_error(actual, fixture.displacement_binding.accumulate_dual(native)) < 2.0e-11); + if (fixture.mesh.GetElementBaseGeometry(0) == ::mfem::Geometry::CUBE) { + CHECK((prepared.tensor_element_count() > 0) == (mode == backend::ContractionMode::automatic)); + } + } + + /** @brief Check each independent field/geometry column, combined action, and complete transpose. */ + void check_coupled_derivatives(Fixture& fixture, backend::ContractionMode mode) { + const Vector values = deterministic(fixture.displacement_binding.size(), 0.31); + const Vector direction = deterministic(values.Size(), 0.57); + const Vector displacement = fixture.displacement(0.6); + const Vector displacement_direction = fixture.displacement(0.2); + const CoupledEquation equation; + VolumeEquationFixture prepared{equation, fixture.displacement_binding, fixture.displacement_binding, + fixture.compactification, fixture.mapper, integration_policy(), mode}; + const Vector dual = deterministic(fixture.displacement_binding.size(), 0.83); + for (int column = 0; column < 3; ++column) { + Vector selected = direction, selected_geometry = displacement_direction; + if (column == 0) { selected_geometry = 0.0; } + if (column == 1) { selected = 0.0; } + prepared.prepare(field(fixture, values), displacement, fixture.workspace); + Vector action, transpose; + prepared.apply_jacobian(field(fixture, selected), selected_geometry, fixture.workspace, action); + prepared.apply_jacobian_transpose(dual, fixture.workspace, transpose); + const Vector packed_direction = packed(constant(0, 0.0), selected, selected_geometry); + const double forward = dual * action; + CHECK(std::abs(forward - packed_direction * transpose) < 3.0e-11 * std::max(1.0, std::abs(forward))); + // Geometry directions are small; these steps avoid subtractive cancellation without relaxing the tolerance. + for (const double step : {1.0e-4, 1.0e-5}) { + CAPTURE(column, step); + Vector plus = values, minus = values, plus_geometry = displacement, minus_geometry = displacement; + plus.Add(step, selected); + minus.Add(-step, selected); + plus_geometry.Add(step, selected_geometry); + minus_geometry.Add(-step, selected_geometry); + Vector plus_residual, minus_residual; + prepared.prepare(field(fixture, plus), plus_geometry, fixture.workspace); + prepared.residual(fixture.workspace, plus_residual); + prepared.prepare(field(fixture, minus), minus_geometry, fixture.workspace); + prepared.residual(fixture.workspace, minus_residual); + plus_residual -= minus_residual; + plus_residual /= 2.0 * step; + CHECK(relative_error(action, plus_residual) < 5.0e-8); + } + } + } +} + +TEST_CASE("Vector gradient equations retain dense tensor and ordering equivalence", "[equations][vector_gradient][unit]") { + for (const auto geometry : {::mfem::Element::HEXAHEDRON, ::mfem::Element::TETRAHEDRON}) { + for (const auto ordering : {::mfem::Ordering::byNODES, ::mfem::Ordering::byVDIM}) { + Fixture fixture(geometry, ordering); + for (const auto mode : {backend::ContractionMode::automatic, backend::ContractionMode::dense}) { + CAPTURE(geometry, ordering, mode); + check_native_diffusion(fixture, mode); + check_coupled_derivatives(fixture, mode); + } + } + } +} + +TEST_CASE("Vector gradient equations preserve true-coordinate work on STROID hanging meshes", "[equations][vector_gradient][integration][nonconforming]") { + for (const auto ordering : {::mfem::Ordering::byNODES, ::mfem::Ordering::byVDIM}) { + auto generated = serif::tests::nonconforming::mesh(0, 3); + REQUIRE(generated.mesh->Nonconforming()); + Fixture fixture(::mfem::Mesh(*generated.mesh), ordering); + REQUIRE(fixture.displacement_space.GetTrueVSize() < fixture.displacement_space.GetVSize()); + CAPTURE(ordering); + check_native_diffusion(fixture, backend::ContractionMode::automatic); + check_coupled_derivatives(fixture, backend::ContractionMode::automatic); + } +} diff --git a/tests/equations/manual_closure.cpp b/tests/equations/manual_closure.cpp new file mode 100644 index 00000000..0ce622ae --- /dev/null +++ b/tests/equations/manual_closure.cpp @@ -0,0 +1,109 @@ +#include "serif/tests/equations/fixture.hpp" +#include "serif/tests/mapping/volume_variation.hpp" + +#include +#include + +namespace serif::tests::equations { + ::mfem::Vector manual_closure( + Fixture& f, + const eos::models::Polytrope& eos, + const ::mfem::Vector& density, + const ::mfem::Vector& enthalpy, + const ::mfem::Vector& displacement, + const ::mfem::Vector* density_direction, + const ::mfem::Vector* enthalpy_direction, + const ::mfem::Vector* displacement_direction + ) { + ::mfem::GridFunction rho(&f.density_space), h(&f.enthalpy_space), u(&f.displacement_space); + ::mfem::GridFunction drho(&f.density_space), dh(&f.enthalpy_space), du(&f.displacement_space); + rho = f.density_binding.scatter(density); + h = f.enthalpy_binding.scatter(enthalpy); + u = f.displacement_binding.scatter(displacement); + drho = 0.0; + dh = 0.0; + du = 0.0; + if (density_direction != nullptr) { + drho = f.density_binding.scatter(*density_direction); + } + if (enthalpy_direction != nullptr) { + dh = f.enthalpy_binding.scatter(*enthalpy_direction); + } + if (displacement_direction != nullptr) { + du = f.displacement_binding.scatter(*displacement_direction); + } + const bool action = + density_direction != nullptr || enthalpy_direction != nullptr || displacement_direction != nullptr; + const double n = eos.polytropic_index(); + const double scale = (n + 1.0) * eos.polytropic_constant(); + ::mfem::Vector result(f.density_space.GetVSize()); + result = 0.0; + mapping::DMWorkspace workspace; + for (int element = 0; element < f.mesh.GetNE(); ++element) { + if (!Schema::domain_id_belongs_to(f.mesh.GetAttribute(element))) { + continue; + } + auto& transformation = *f.mesh.GetElementTransformation(element); + const auto& test = *f.density_space.GetFE(element); + const auto& displacement_element = *f.displacement_space.GetFE(element); + const int order = std::max( + 2 * test.GetOrder(), test.GetOrder() + f.enthalpy_space.GetFE(element)->GetOrder() + + static_cast(std::ceil(std::max(0.0, n - 1.0) * 3)) + ) + + transformation.OrderW() + 3 * (displacement_element.GetOrder() - 1); + const auto& rule = ::mfem::IntRules.Get(transformation.GetGeometryType(), order); + ::mfem::Array dofs; + auto* dof_transformation = f.displacement_space.GetElementVDofs(element, dofs); + ::mfem::Vector u_dofs, du_dofs; + u.GetSubVector(dofs, u_dofs); + du.GetSubVector(dofs, du_dofs); + if (dof_transformation != nullptr) { + dof_transformation->InvTransformPrimal(u_dofs); + dof_transformation->InvTransformPrimal(du_dofs); + } + const auto u_data = mapping::element_displacement_data_from_element_vdofs(displacement_element, u_dofs); + const auto du_data = mapping::element_displacement_data_from_element_vdofs(displacement_element, du_dofs); + ::mfem::Vector compactification_dofs(f.enthalpy_space.GetFE(element)->GetDof()); + compactification_dofs = 0.0; + const mapping::ElementCompactificationData compactification( + *f.enthalpy_space.GetFE(element), compactification_dofs + ); + const mapping::ElementMappingData mapping_data{u_data, compactification}; + ::mfem::Vector shape(test.GetDof()), local(test.GetDof()); + local = 0.0; + for (int point = 0; point < rule.GetNPoints(); ++point) { + const auto& ip = rule.IntPoint(point); + const double enthalpy_value = h.GetValue(element, ip); + const double eos_density = std::pow(enthalpy_value / scale, n); + const double eos_derivative = n / scale * std::pow(enthalpy_value / scale, n - 1.0); + const double closure = rho.GetValue(element, ip) - eos_density; + const double variation = drho.GetValue(element, ip) - eos_derivative * dh.GetValue(element, ip); + mapping::VolumeMappingContext context; + const auto status = f.mapper.evaluate_volume(mapping_data, transformation, ip, workspace, context); + utils::errors::serif_ensure( + status == mapping::MappingStatus::VALID, "Invalid manual-reference geometry." + ); + double integrand = context.quadrature.physical_integration_weight * closure; + if (action) { + mapping::VolumeMappingVariation geometry_variation; + const auto variation_status = serif::tests::evaluate_volume_variation( + f.mapper, mapping_data, du_data, transformation, ip, context, workspace, geometry_variation + ); + utils::errors::serif_ensure( + variation_status == mapping::MappingStatus::VALID, "Invalid manual-reference variation." + ); + integrand = context.quadrature.physical_integration_weight * variation + + geometry_variation.weight_variation * closure; + } + test.CalcShape(ip, shape); + local.Add(integrand, shape); + } + auto* test_transformation = f.density_space.GetElementVDofs(element, dofs); + if (test_transformation != nullptr) { + test_transformation->TransformDual(local); + } + result.AddElementVector(dofs, local); + } + return f.density_binding.gather(result); + } +} // namespace serif::tests::equations diff --git a/tests/equations/mass_normalization.cpp b/tests/equations/mass_normalization.cpp new file mode 100644 index 00000000..3251764e --- /dev/null +++ b/tests/equations/mass_normalization.cpp @@ -0,0 +1,245 @@ +#include +#include + +#include "serif/equations/mass_normalization.hpp" +#include "serif/tests/equations/gravity_port.hpp" + +namespace { + using namespace serif; + using namespace serif::tests::equations; + using namespace serif::tests::equations::gravity; + using Equation = serif::equations::MassNormalization; + using Prepared = VolumeEquationFixture; + using Vector = ::mfem::Vector; + + auto state(GravityFixture& f, const Vector& density) { + return backend::make_term_coefficients(backend::bind(f.density_binding, density)); + } + + auto make_operator(GravityFixture& f, double target) { + return Prepared{Equation{target}, f.constant_binding, f.displacement_binding, f.compactification, f.mapper, reference_policy()}; + } + + double residual(GravityFixture& f, Prepared& op, const Vector& density, const Vector& displacement) { + op.prepare(state(f, density), displacement, f.workspace); + Vector result; + op.residual(f.workspace, result); + REQUIRE(result.Size() == 1); + return result[0]; + } + + double action(GravityFixture& f, Prepared& op, const Vector& density, const Vector& displacement) { + Vector result; + op.apply_jacobian(state(f, density), displacement, f.workspace, result); + REQUIRE(result.Size() == 1); + return result[0]; + } + + // MeanField prepared_mass_normalization.cpp:66,98,132. Preserve the original profiles. + Vector density(GravityFixture& f, double phase, bool direction = false) { + return f.project(f.density_binding, [=](const Vector& x) { + if (direction) { + return 0.19 * std::sin(0.71 * x[0] + phase) - 0.13 * std::cos(0.89 * x[1] - phase) + 0.08 * x[2]; + } + return 0.91 + 0.07 * std::sin(0.83 * x[0] + phase) + 0.05 * std::cos(0.61 * x[1] - phase) + 0.03 * x[2] * x[2]; + }); + } + + Vector displacement(GravityFixture& f, double scale) { + return gravity_port::project_vector(f.displacement_binding, [=](const Vector& x, Vector& value) { + value.SetSize(3); + value[0] = scale * (0.07 * x[0] + 0.018 * x[1] * x[2]); + value[1] = scale * (-0.05 * x[1] + 0.013 * x[0] * x[2]); + value[2] = scale * (0.04 * x[2] - 0.011 * x[0] * x[1]); + }); + } + + double relative(double actual, double expected) { + return std::abs(actual - expected) / std::max(std::abs(expected), 100.0 * std::numeric_limits::epsilon()); + } + + // Independent MFEM quadrature of rho det(A) and delta-rho det(A) + rho cof(A):delta-A. + double reference(GravityFixture& f, const Vector& rho, const Vector& u, const Vector* drho = nullptr, const Vector* du = nullptr) { + ::mfem::GridFunction density(&f.density_space), displacement(&f.displacement_space); + ::mfem::GridFunction delta_density(&f.density_space), delta_displacement(&f.displacement_space); + density = f.density_binding.scatter(rho); + displacement = f.displacement_binding.scatter(u); + if (drho) { delta_density = f.density_binding.scatter(*drho); } + if (du) { delta_displacement = f.displacement_binding.scatter(*du); } + double result = 0.0; + for (int element = 0; element < f.mesh.GetNE(); ++element) { + if (!f.density_binding.supports(element)) { continue; } + auto& transform = *f.mesh.GetElementTransformation(element); + const auto& rule = ::mfem::IntRules.Get(f.mesh.GetElementGeometry(element), reference_order); + for (int point = 0; point < rule.GetNPoints(); ++point) { + const auto& ip = rule.IntPoint(point); + transform.SetIntPoint(&ip); + ::mfem::DenseMatrix A(3), dA(3), inverse(3); + displacement.GetVectorGradient(transform, A); + for (int axis = 0; axis < 3; ++axis) { A(axis, axis) += 1.0; } + double integrand = density.GetValue(transform, ip); + if (drho || du) { + integrand = drho ? delta_density.GetValue(transform, ip) : 0.0; + if (du) { + delta_displacement.GetVectorGradient(transform, dA); + ::mfem::CalcInverse(A, inverse); + double trace = 0.0; + for (int i = 0; i < 3; ++i) { + for (int j = 0; j < 3; ++j) { trace += inverse(i, j) * dA(j, i); } + } + integrand += density.GetValue(transform, ip) * trace; + } + } + result += ip.weight * transform.Weight() * A.Det() * integrand; + } + } + return result; + } + + void check_columns(GravityFixture& f) { + CAPTURE(f.mesh.GetNE(), f.mesh.GetElementGeometry(0), f.displacement_space.GetOrdering()); + f.set_compactification(); + const auto zero_rho = constant(f.density_binding.size(), 0.0), zero_u = constant(f.displacement_binding.size(), 0.0); + // Original density and geometry finite differences, with their original steps and tolerances. + for (int column = 0; column < 2; ++column) { + const auto rho = density(f, column == 0 ? 0.31 : 0.37); + const auto u = displacement(f, column == 0 ? 0.43 : 0.51); + const auto drho = column == 0 ? density(f, 0.67, true) : zero_rho; + const auto du = column == 0 ? zero_u : displacement(f, -0.79); + auto op = make_operator(f, column == 0 ? 1.23 : 1.11); + (void)residual(f, op, rho, u); + const double actual = action(f, op, drho, du); + const double step = column == 0 ? 1.0e-3 : 1.0e-6; + auto plus_rho = rho, minus_rho = rho, plus_u = u, minus_u = u; + plus_rho.Add(step, drho); minus_rho.Add(-step, drho); + plus_u.Add(step, du); minus_u.Add(-step, du); + const double difference = (residual(f, op, plus_rho, plus_u) - residual(f, op, minus_rho, minus_u)) / (2.0 * step); + CAPTURE(column, actual, difference); + CHECK(relative(actual, difference) < (column == 0 ? 3.0e-8 : 2.0e-7)); + } + + // Original complete action, independent reference, column sum, and supported local adapter routing. + const auto rho = density(f, 0.47), u = displacement(f, 0.57); + const auto drho = density(f, 0.71, true), du = displacement(f, -0.63); + auto op = make_operator(f, 1.19); + const auto initial = residual(f, op, rho, u); + CHECK(relative(initial, reference(f, rho, u) - 1.19) < 1.0e-12); + const double complete = action(f, op, drho, du); + CHECK(relative(complete, reference(f, rho, u, &drho, &du)) < 2.0e-11); + CHECK(relative(complete, action(f, op, drho, zero_u) + action(f, op, zero_rho, du)) < 2.0e-15); + backend::JacobianOperator adapter(op.cache()); + CHECK(adapter.Height() == 1); + CHECK(adapter.Width() == drho.Size() + du.Size()); + Vector joined(adapter.Width()), result; + std::copy_n(drho.GetData(), drho.Size(), joined.GetData()); + std::copy_n(du.GetData(), du.Size(), joined.GetData() + drho.Size()); + adapter.Mult(joined, result); + CHECK(result[0] == complete); + } +} + +TEST_CASE("Mass normalization retains affine volume scaling and target subtraction", "[equations][mass_normalization][integration]") { + auto fixture = gravity_port::stellar_fixture(); + auto& f = *fixture; + const auto rho = constant(f.density_binding.size(), 1.37), zero = constant(f.displacement_binding.size(), 0.0); + auto op = make_operator(f, 0.73); + const double undeformed = residual(f, op, rho, zero) + 0.73; + CHECK(relative(undeformed, reference(f, rho, zero)) < 1.0e-12); + CHECK(relative(residual(f, op, rho, zero), undeformed - 0.73) < 2.0e-15); + const double deformed = residual(f, op, rho, gravity_port::affine_displacement(f, 0.086)) + 0.73; + CHECK(relative(deformed / undeformed, std::pow(1.0 + 0.086, 3)) < 5.0e-7); +} + +TEST_CASE("Mass normalization retains density geometry and complete action accuracy", "[equations][mass_normalization][integration]") { + for (const auto geometry : {::mfem::Element::TETRAHEDRON, ::mfem::Element::HEXAHEDRON}) { + for (const auto ordering : {::mfem::Ordering::byNODES, ::mfem::Ordering::byVDIM}) { + GravityFixture f(geometry, ordering); + check_columns(f); + } + } + auto fixture = gravity_port::stellar_fixture(); + check_columns(*fixture); +} + +TEST_CASE("Global mass preparations own their density state", "[equations][mass_normalization][unit]") { + GravityFixture f; + auto rho = density(f, 0.47); + const auto u = displacement(f, 0.57); + VolumeEquationFixture op{Equation{0.73}, f.constant_binding, f.displacement_binding, f.compactification, f.mapper, reference_policy()}; + const double expected = reference(f, rho, u) - 0.73; + op.prepare(state(f, rho), u, f.workspace); + Vector result; + op.residual(f.workspace, result); + CHECK(relative(result[0], expected) < 2.0e-15); + rho *= 1.2; + op.residual(f.workspace, result); + CHECK(relative(result[0], expected) < 2.0e-15); + op.prepare(state(f, rho), u, f.workspace); + op.residual(f.workspace, result); + CHECK(relative(result[0], reference(f, rho, u) - 0.73) < 2.0e-15); + const auto drho = density(f, 0.71, true), du = displacement(f, -0.63); + op.apply_jacobian(state(f, drho), du, f.workspace, result); + CHECK(relative(result[0], reference(f, rho, u, &drho, &du)) < 2.0e-11); +} + +TEST_CASE("Mass normalization rejects invalid targets density and geometry then recovers", "[equations][mass_normalization][unit]") { + for (double target : {0.0, -1.0, std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()}) { + CHECK_THROWS_AS(Equation{target}, std::invalid_argument); + } + GravityFixture f; + auto op = make_operator(f, 1.0); + auto rho = constant(f.density_binding.size(), 1.0); + auto u = constant(f.displacement_binding.size(), 0.0); + const double expected = residual(f, op, rho, u); + rho = std::numeric_limits::quiet_NaN(); + auto rejected = op.try_prepare(state(f, rho), u, f.workspace); + REQUIRE_FALSE(rejected.has_value()); + CHECK(rejected.error().reason == backend::PreparationRejectionReason::NON_FINITE); + CHECK_FALSE(op.is_prepared()); + CHECK_THROWS_AS(op.prepare(state(f, rho), u, f.workspace), std::domain_error); + rho = 1.0; + u = gravity_port::affine_displacement(f, -2.0); + rejected = op.try_prepare(state(f, rho), u, f.workspace); + REQUIRE_FALSE(rejected.has_value()); + CHECK(rejected.error().reason == backend::PreparationRejectionReason::MAPPING); + u = 0.0; + CHECK(residual(f, op, rho, u) == expected); +} + +// MeanField prepared_mass_normalization.cpp:282: finite coefficients can overflow during interpolation. +TEST_CASE("Mass normalization rejects nonfinite density interpolation and recovers", "[equations][mass_normalization][unit]") { + auto fixture = gravity_port::stellar_fixture(); + auto& f = *fixture; + auto rho = constant(f.density_binding.size(), -0.5 * std::numeric_limits::max()); + const auto u = constant(f.displacement_binding.size(), 0.0); + auto op = make_operator(f, std::numeric_limits::max()); + const auto rejected = op.try_prepare(state(f, rho), u, f.workspace); + REQUIRE_FALSE(rejected.has_value()); + CHECK(rejected.error().reason == backend::PreparationRejectionReason::NON_FINITE); + CHECK(rejected.error().element >= 0); + CHECK_FALSE(op.is_prepared()); + CHECK_THROWS_AS(op.prepare(state(f, rho), u, f.workspace), std::domain_error); + rho = 1.0; + auto recovered = make_operator(f, 1.0); + REQUIRE(recovered.try_prepare(state(f, rho), u, f.workspace).has_value()); + CHECK(recovered.is_prepared()); +} + +TEST_CASE("Global scalar bindings retain one coefficient and reject stale meshes", "[equations][mass_normalization][unit]") { + GravityFixture f; + auto& binding = f.constant_binding; + const auto coefficient = constant(1, 0.39); + CHECK(binding.size() == 1); + CHECK(binding.full_size() == 1); + CHECK(binding.gather(binding.scatter(coefficient))[0] == coefficient[0]); + CHECK_THROWS_AS(binding.space(), std::logic_error); + CHECK_THROWS_AS(binding.validate_values(constant(2, 0.0)), std::invalid_argument); + for (int element = 0; element < f.mesh.GetNE(); ++element) { + Vector local; + binding.element_values(element, coefficient, local); + REQUIRE(local.Size() == 1); + CHECK(local[0] == (binding.supports(element) ? coefficient[0] : 0.0)); + } + f.mesh.GetElement(0)->SetAttribute(3); + CHECK_THROWS(binding.validate()); +} diff --git a/tests/equations/meson.build b/tests/equations/meson.build new file mode 100644 index 00000000..044c079e --- /dev/null +++ b/tests/equations/meson.build @@ -0,0 +1,31 @@ +equation_test_sources = files( + 'reference_tables_meanfield.cpp', + 'basis_lifetime_meanfield.cpp', + 'pressure_boundary.cpp', + 'fixture.cpp', + 'manual_closure.cpp', + 'closure_kernels.cpp', + 'closure_prepared.cpp', + 'closure_conditions.cpp', + 'closure_dilation.cpp', + 'state_validation.cpp', + 'equation_contracts.cpp', + 'gravity_port_fixture.cpp', + 'h1_divergence.cpp', + 'h1_gradient.cpp', + 'h1_gradient_equation.cpp', + 'h1_vector_gradient.cpp', + 'h1_vector_gradient_equation.cpp', + 'barycenter.cpp', + 'barotropic_density.cpp', + 'barotropic_density_assembly.cpp', + 'surface_potential_test.cpp', + 'mass_normalization.cpp', + 'angular_momentum.cpp', + 'pressure_surface.cpp', + 'tensor_basis.cpp', + 'serial_backend.cpp', + 'computation_graphs.cpp', + 'contribution_lifetimes.cpp', + 'scoped_computations.cpp', +) diff --git a/tests/equations/pressure_boundary.cpp b/tests/equations/pressure_boundary.cpp new file mode 100644 index 00000000..8876847e --- /dev/null +++ b/tests/equations/pressure_boundary.cpp @@ -0,0 +1,159 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include "serif/backend/mfem/discretization/boundary_trace.hpp" +#include "serif/backend/mfem/geometry/surface/pressure_boundary.hpp" +#include "serif/tests/equations/gravity_fixture.hpp" +#include "serif/tests/equations/volume_equation_fixture.hpp" +#include "serif/tests/surface/boundary_fixture.hpp" + +namespace { + namespace test = serif::tests::equations; + namespace gravity = test::gravity; + namespace blocks = serif::discretization::blocks; + namespace physics = serif::geometry::surface; + namespace surface = physics::backend::mfem; + namespace volume = serif::operators::backend::mfem; + namespace dependencies = serif::utils::dependencies; + using Vector = mfem::Vector; + using Enthalpy = ::serif::tests::equations::Enthalpy; + + /** @brief Identify boundary coefficients that depend on several native independent coordinates. */ + [[nodiscard]] std::vector hanging_boundary_dofs(const mfem::FiniteElementSpace& space, const mfem::Array& attributes) { + const mfem::SparseMatrix* prolongation = space.GetConformingProlongation(); + REQUIRE(prolongation != nullptr); + mfem::Array boundary; + space.GetEssentialVDofs(attributes, boundary); + std::vector hanging; + for (int local = 0; local < boundary.Size(); ++local) { + if (boundary[local] == 0) { + continue; + } + int contributors = 0; + for (int entry = prolongation->GetI()[local]; entry < prolongation->GetI()[local + 1]; ++entry) { + contributors += prolongation->GetData()[entry] != 0.0; + } + if (contributors > 1) { + hanging.push_back(local); + } + } + return hanging; + } + + /** @brief Evaluate an MFEM field at native restriction representatives, without backend trace or reduction helpers. */ + [[nodiscard]] Vector native_boundary_samples(const mfem::FiniteElementSpace& row_space, + const mfem::Array& independent_rows, const mfem::GridFunction& field) { + const mfem::SparseMatrix* restriction = row_space.GetConformingRestriction(); + REQUIRE(restriction != nullptr); + std::vector selected(row_space.GetVSize(), -1); + for (int row = 0; row < independent_rows.Size(); ++row) { + const int independent = independent_rows[row]; + const int entry = restriction->GetI()[independent]; + REQUIRE(restriction->GetI()[independent + 1] == entry + 1); + REQUIRE(restriction->GetData()[entry] == 1.0); + selected[restriction->GetJ()[entry]] = row; + } + Vector samples(independent_rows.Size()); + samples = std::numeric_limits::quiet_NaN(); + mfem::Array dofs; + for (int element = 0; element < row_space.GetNE(); ++element) { + row_space.GetElementDofs(element, dofs); + const mfem::IntegrationRule& nodes = row_space.GetFE(element)->GetNodes(); + for (int local = 0; local < dofs.Size(); ++local) { + const int row = selected[mfem::FiniteElementSpace::DecodeDof(dofs[local])]; + if (row < 0 || std::isfinite(samples[row])) { + continue; + } + samples[row] = field.GetValue(element, nodes.IntPoint(local)); + } + } + for (int row = 0; row < samples.Size(); ++row) { + REQUIRE(std::isfinite(samples[row])); + } + return samples; + } +} // namespace + +TEST_CASE("Nonconforming boundary interpolation retains weighted traces across scalar orders", "[unit][equations][pressure_boundary][nonconforming]") { + namespace finite_elements = serif::discretization::backend::mfem; + namespace boundary = serif::tests::surface::boundary; + using StellarSurface = serif::discretization::domain::StellarSurfaceBoundary; + + mfem::Mesh mesh = boundary::make_boundary_mesh(); + mesh.EnsureNCMesh(); + const mfem::Array refinement{0}; + mesh.GeneralRefinement(refinement); + mfem::H1_FECollection cubic_collection(3, 3), quadratic_collection(2, 3); + mfem::FiniteElementSpace row_space(&mesh, &cubic_collection), input_space(&mesh, &quadratic_collection); + finite_elements::TermBinding row_binding(row_space); + finite_elements::TermBinding input_binding(input_space); + REQUIRE(row_space.GetTrueVSize() < row_space.GetVSize()); + REQUIRE(input_space.GetTrueVSize() < input_space.GetVSize()); + // Every cell is material, so supported coordinates have the native true ordering. + REQUIRE(row_binding.size() == row_space.GetTrueVSize()); + REQUIRE(input_binding.size() == input_space.GetTrueVSize()); + const mfem::Array attributes{1, 0}; + REQUIRE_FALSE(hanging_boundary_dofs(row_space, attributes).empty()); + const std::vector input_hanging = hanging_boundary_dofs(input_space, attributes); + REQUIRE_FALSE(input_hanging.empty()); + + const auto selected = std::make_shared( + finite_elements::select_boundary_dofs(row_binding)); + mfem::Array native_selected; + row_space.GetEssentialTrueDofs(attributes, native_selected); + REQUIRE(selected->size() == native_selected.Size()); + for (int row = 0; row < selected->size(); ++row) { + CHECK(selected->reduced_dofs()[row] == native_selected[row]); + } + const auto trace = finite_elements::make_boundary_trace(selected, input_binding); + const Vector values = boundary::sequence(input_binding.size(), 0.8, 0.37); + mfem::GridFunction field(&input_space); + field.SetFromTrueDofs(values); + const Vector expected = native_boundary_samples(row_space, native_selected, field); + Vector actual(selected->size()); + trace.interpolation().Mult(values, actual); + CHECK(test::relative_error(actual, expected) < 2.0e-12); + + // This deliberately broken local copy proves that weighted boundary inputs affect the tested rows. + mfem::GridFunction missing_hanging_values(&input_space); + missing_hanging_values = field; + for (const int local : input_hanging) { + missing_hanging_values[local] = 0.0; + } + Vector omitted = native_boundary_samples(row_space, native_selected, missing_hanging_values); + omitted -= expected; + REQUIRE(omitted.Norml2() > 1.0e-3); + + const Vector dual = boundary::sequence(selected->size(), -0.2, 0.81); + Vector transpose(input_binding.size()); + trace.interpolation().MultTranspose(dual, transpose); + CHECK(std::abs(values * transpose - actual * dual) < 2.0e-12 * std::max(1.0, std::abs(actual * dual))); + + Vector zero_trace(row_binding.size()); + zero_trace = 1.0; + for (const int row : selected->reduced_dofs()) { + zero_trace[row] = 0.0; + } + mfem::GridFunction constrained(&row_space); + constrained.SetFromTrueDofs(zero_trace); + double unselected_boundary_maximum = 0.0; + for (int face = 0; face < mesh.GetNBE(); ++face) { + mfem::ElementTransformation& transformation = *mesh.GetBdrElementTransformation(face); + const mfem::IntegrationRule& rule = mfem::IntRules.Get(transformation.GetGeometryType(), 8); + for (int sample = 0; sample < rule.GetNPoints(); ++sample) { + const double value = constrained.GetValue(transformation, rule.IntPoint(sample)); + if (mesh.GetBdrAttribute(face) == 1) { + CHECK(std::abs(value) < 2.0e-13); + } else { + unselected_boundary_maximum = std::max(unselected_boundary_maximum, std::abs(value)); + } + } + } + CHECK(unselected_boundary_maximum > 0.5); +} diff --git a/tests/equations/pressure_surface.cpp b/tests/equations/pressure_surface.cpp new file mode 100644 index 00000000..4ea61138 --- /dev/null +++ b/tests/equations/pressure_surface.cpp @@ -0,0 +1,316 @@ +#include "serif/utils/error/errors.hpp" +#include +#include +#include +#include + +#include "serif/equations/barotropic_density.hpp" +#include "serif/equations/mass_normalization.hpp" +#include "serif/backend/mfem/geometry/surface/pressure_boundary.hpp" +#include "serif/geometry/surface/evaluation.hpp" +#include "serif/tests/equations/volume_equation_fixture.hpp" +#include "serif/tests/equations/gravity_port.hpp" + +namespace { + using namespace serif; + using namespace serif::tests::equations; + using namespace serif::tests::equations::gravity; + namespace physics = serif::geometry::surface; + namespace surface = physics::backend::mfem; + namespace dependencies = serif::utils::dependencies; + using Enthalpy = ::serif::tests::equations::Enthalpy; + using SurfaceState = dependencies::Bindings>; + using Formulation = physics::PressureSurfaceFormulation; + using EnthalpyState = dependencies::Bindings>; + using EnthalpyFormulation = physics::PressureSurfaceFormulation; + using Balance = serif::equations::BarotropicDensityBalance; + using Vector = ::mfem::Vector; + + /** @brief Bind the carrier without introducing unrelated field requirements. */ + auto enthalpy_state(GravityFixture& f, const Vector& h) { + return backend::make_term_coefficients(backend::bind(f.enthalpy_binding, h)); + } + + /** @brief Retain every density-balance column when testing strong boundary replacement. */ + auto balance_state(GravityFixture& f, const Vector& rho, const Vector& phi, const Vector& C, const Vector& omega) { + return backend::make_term_coefficients(backend::bind(f.density_binding, rho), backend::bind(f.potential_binding, phi), + backend::bind(f.constant_binding, C), backend::bind(f.angular_velocity_binding, omega)); + } + + /** @brief Track both polytropic parameters alongside the prescribed pressure and current coefficients. */ + template + auto pressure_inputs(const std::shared_ptr& boundary, const eos::models::Polytrope& model, dimensions::PressureValue pressure, const Coefficients& coefficients) { + const auto equation_of_state = dependencies::borrow(model, dependencies::track_by([](const eos::models::Polytrope& eos) { + return std::pair{eos.polytropic_index(), eos.polytropic_constant()}; + })); + return surface::make_pressure_boundary_inputs(boundary, equation_of_state, physics::Isobaric{pressure}, coefficients); + } + + /** @brief Constrain the Cartesian fixture's stellar x=0 face, leaving its other faces free. */ + void tag_cartesian_boundary(GravityFixture& f) { + for (int boundary = 0; boundary < f.mesh.GetNBE(); ++boundary) { + auto& transform = *f.mesh.GetBdrElementTransformation(boundary); + Vector center(3); + transform.Transform(::mfem::Geometries.GetCenter(transform.GetGeometryType()), center); + f.mesh.GetBdrElement(boundary)->SetAttribute(std::abs(center[0]) < 1.0e-14 ? 1 : 2); + } + f.mesh.SetAttributes(); + } + + /** @brief Select essential DOFs independently with MFEM, without the boundary trace compiler. */ + Vector boundary_mask(GravityFixture& f) { + ::mfem::Array attributes(f.mesh.bdr_attributes.Max()), marker; + attributes = 0; + attributes[0] = 1; + f.density_space.GetEssentialVDofs(attributes, marker); + Vector full(marker.Size()); + for (int dof = 0; dof < marker.Size(); ++dof) { + full[dof] = marker[dof] ? 1.0 : 0.0; + } + return f.density_binding.gather(full); + } + + /** @brief Preserve MeanField prepared_stellar_equilibrium.cpp:810 profiles and surface-row tolerances. */ + void check_rows(GravityFixture& f) { + CAPTURE(f.mesh.GetNE(), f.mesh.GetElementGeometry(0), f.displacement_space.GetOrdering()); + f.set_compactification(); + const eos::models::Polytrope eos{3.0, 0.25}; + const dimensions::PressureValue pressure{0.03125}; + // For n=3 and K=1/4, rho(P) = (P/K)^(3/4), independently of the EOS implementation. + const double required_rho = std::pow(pressure.value() / 0.25, 0.75); + const auto rho = f.project(f.density_binding, [](const Vector& x) { + return 0.82 + 0.08 * std::sin(0.62 * x[0] + 0.53) + 0.045 * std::cos(0.57 * x[1] - 0.53) + 0.02 * x[2] * x[2]; + }); + const auto drho = f.project(f.density_binding, [](const Vector& x) { + return 0.21 * std::sin(0.74 * x[0] + 0.97) - 0.14 * std::cos(0.64 * x[1] - 0.97) + 0.075 * x[2]; + }); + const auto phi = f.project(f.potential_binding, [](const Vector& x) { + return 0.24 + 0.09 * std::sin(0.67 * x[0] + 0.47) - 0.06 * std::cos(0.53 * x[1] - 0.47) + 0.035 * x[2]; + }); + const auto dphi = f.project(f.potential_binding, [](const Vector& x) { + return 0.17 * std::sin(0.81 * x[0] + 0.89) + 0.11 * std::cos(0.69 * x[1] - 0.89) - 0.07 * x[2]; + }); + const auto C = constant(1, 1.07), dC = constant(1, -0.37); + const auto omega = constant(1, 0.82), domega = constant(1, 0.13); + // Volume displacement is already supported; surface-parameter extension is deliberately not used. + const auto u = f.displacement(0.08), du = f.displacement(-0.11); + const auto state = balance_state(f, rho, phi, C, omega); + const auto direction = balance_state(f, drho, dphi, dC, domega); + VolumeEquationFixture balance{Balance{eos, pressure}, f.density_binding, f.displacement_binding, + f.compactification, f.mapper, reference_policy()}; + const auto boundary = surface::make_pressure_boundary(state); + const auto inputs = pressure_inputs(boundary, eos, pressure, state); + auto cache = dependencies::make_computation_cache(surface::make_pressure_boundary_graph(), inputs); + const auto mask = boundary_mask(f); + REQUIRE(boundary->selected_rows->size() > 0); + REQUIRE(boundary->selected_rows->size() < f.density_binding.size()); + CHECK(boundary->selected_rows->size() == static_cast(mask.Sum())); + int previous = -1; + for (int dof : boundary->selected_rows->reduced_dofs()) { + CHECK(dof > previous); + CHECK(mask[dof] == 1.0); + previous = dof; + } + + balance.prepare(state, u, f.workspace); + REQUIRE(cache.try_prepare(inputs)); + Vector residual, action; + balance.residual(f.workspace, residual); + balance.apply_jacobian(direction, du, f.workspace, action); + const auto interior_residual = residual, interior_action = action; + surface::apply_residual_rows(*boundary, cache.get(), residual); + surface::apply_jacobian_rows(*boundary, cache.get(), direction, action); + for (int row = 0; row < rho.Size(); ++row) { + CAPTURE(row); + if (mask[row]) { + CHECK(std::abs(residual[row] - (rho[row] - required_rho)) < 1.0e-13); + CHECK(std::abs(action[row] - drho[row]) < 1.0e-13); + } else { + CHECK(residual[row] == interior_residual[row]); + CHECK(action[row] == interior_action[row]); + } + } + + const auto zero_rho = constant(rho.Size(), 0.0); + const auto other_columns = balance_state(f, zero_rho, dphi, dC, domega); + Vector other_action; + balance.apply_jacobian(other_columns, du, f.workspace, other_action); + const auto before_replacement = other_action; + surface::apply_jacobian_rows(*boundary, cache.get(), other_columns, other_action); + double removed_action = 0.0; + for (int row = 0; row < rho.Size(); ++row) { + if (mask[row]) { + CHECK(other_action[row] == 0.0); + removed_action += std::abs(before_replacement[row]); + } else { + CHECK(other_action[row] == before_replacement[row]); + } + } + CHECK(removed_action > 0.0); + + // The constrained density-balance row retains the old complete-root difference step and threshold. + constexpr double step = 1.0e-5; + const auto shifted_residual = [&](double scale) { + auto trial_rho = rho, trial_phi = phi, trial_C = C, trial_omega = omega, trial_u = u; + trial_rho.Add(scale, drho); trial_phi.Add(scale, dphi); trial_C.Add(scale, dC); + trial_omega.Add(scale, domega); trial_u.Add(scale, du); + const auto trial = balance_state(f, trial_rho, trial_phi, trial_C, trial_omega); + balance.prepare(trial, trial_u, f.workspace); + REQUIRE(cache.try_prepare(pressure_inputs(boundary, eos, pressure, trial))); + Vector result; + balance.residual(f.workspace, result); + surface::apply_residual_rows(*boundary, cache.get(), result); + return result; + }; + auto difference = shifted_residual(step); + difference -= shifted_residual(-step); + difference /= 2.0 * step; + auto error = action; + error -= difference; + CHECK(error.Norml2() / std::max({action.Norml2(), difference.Norml2(), 100.0 * std::numeric_limits::epsilon()}) < 2.0e-6); + } + + /** @brief A second EOS prevents assumptions of a polytrope or h(0)=0. */ + struct OffsetEOS { + using Relations = eos::relations::RelationCatalog; + double offset{0.3}; + /** @brief Give zero pressure a nonzero enthalpy that changes with the EOS parameters. */ + dimensions::SpecificEnthalpyValue evaluate(eos::relations::SpecificEnthalpyFromPressure, dimensions::PressureValue pressure) const { + return dimensions::SpecificEnthalpyValue{offset + 2.0 * pressure.value()}; + } + }; +} + +TEST_CASE("Surface pressure resolves the prescribed quantity through the EOS", "[equations][pressure_surface][unit]") { + // MeanField constant_surface_compilation.cpp: pressure-only contract and exact polytropic residual/derivative. + const eos::models::Polytrope eos{3.0, 0.25}; + const dimensions::PressureValue pressure{0.03125}; + const physics::Isobaric condition{pressure}; + CHECK(condition.target_pressure().value() == pressure.value()); + physics::SurfacePoint point{}; + point.fields.get() = eos::evaluate(eos, pressure).value(); + const auto result = physics::evaluate_pressure_surface(eos, condition, point); + REQUIRE(result); + CHECK(result->residual == 0.0); + CHECK(result->field_derivatives.get() * -0.19 == -0.19); + CHECK(result->position_derivative == discretization::forms::VectorValue{}); + for (double invalid : {-0.1, std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()}) { + CHECK_THROWS_AS((physics::Isobaric{dimensions::PressureValue{invalid}}), std::invalid_argument); + } + OffsetEOS offset_eos; + using OffsetFormulation = physics::PressureSurfaceFormulation; + physics::SurfacePoint offset_point{}; + offset_point.fields.get() = 0.3; + const physics::Isobaric zero_pressure{dimensions::PressureValue{0.0}}; + const auto original = physics::evaluate_pressure_surface(offset_eos, zero_pressure, offset_point); + REQUIRE(original); + CHECK(original->residual == 0.0); + offset_eos.offset = 1.2; + const auto updated = physics::evaluate_pressure_surface(offset_eos, zero_pressure, offset_point); + REQUIRE(updated); + CHECK(updated->residual == 0.3 - 1.2); + CHECK(original->residual == 0.0); + offset_eos.offset = std::numeric_limits::infinity(); + const auto rejected = physics::evaluate_pressure_surface(offset_eos, condition, offset_point); + REQUIRE_FALSE(rejected); + CHECK(rejected.error().reason == physics::SurfaceFailureReason::non_finite_eos_response); +} + +TEST_CASE("Pressure constraints replace density-balance boundary rows at the original accuracy", "[equations][pressure_surface][integration]") { + for (const auto geometry : {::mfem::Element::TETRAHEDRON, ::mfem::Element::HEXAHEDRON}) { + for (const auto ordering : {::mfem::Ordering::byNODES, ::mfem::Ordering::byVDIM}) { + GravityFixture f(geometry, ordering); + tag_cartesian_boundary(f); + check_rows(f); + } + } + auto fixture = gravity_port::stellar_fixture(); + check_rows(*fixture); +} + +TEST_CASE("Surface pressure preserves exact analytic closure density-balance and mass rows", "[equations][pressure_surface][integration]") { + // MeanField prepared_stellar_equilibrium.cpp:979. Retain all physical constants and numerical thresholds. + auto fixture = gravity_port::stellar_fixture(); + auto& f = *fixture; + const eos::models::Polytrope eos{1.0, 0.25}; + const dimensions::SpecificEnthalpyValue enthalpy{0.60}; + const double density = eos::evaluate(eos, enthalpy).value(); + const auto pressure = eos::evaluate(eos, enthalpy); + double volume = 0.0; + for (int element = 0; element < f.mesh.GetNE(); ++element) { + if (!f.enthalpy_binding.supports(element)) { + continue; + } + auto& transform = *f.mesh.GetElementTransformation(element); + const auto& rule = ::mfem::IntRules.Get(f.mesh.GetElementGeometry(element), reference_order); + for (int q = 0; q < rule.GetNPoints(); ++q) { + const auto& ip = rule.IntPoint(q); + transform.SetIntPoint(&ip); + volume += ip.weight * transform.Weight(); + } + } + const double target_mass = density * volume; + const auto rho = constant(f.density_binding.size(), density), h = constant(f.enthalpy_binding.size(), enthalpy.value()); + // A uniform state at the surface density has A(rho) = A(rho_surface), so the balance closes at Us = phi. + const auto phi = constant(f.potential_binding.size(), 0.20), C = constant(1, 0.20); + const auto omega = constant(1, 0.0), u = constant(f.displacement_binding.size(), 0.0); + const auto state = backend::make_term_coefficients(backend::bind(f.density_binding, rho), backend::bind(f.enthalpy_binding, h), + backend::bind(f.potential_binding, phi), backend::bind(f.constant_binding, C), + backend::bind(f.angular_velocity_binding, omega)); + VolumeEquationFixture closure{Closure{eos}, f.density_binding, f.displacement_binding, f.compactification, f.mapper, reference_policy()}; + VolumeEquationFixture balance{Balance{eos, pressure}, f.density_binding, f.displacement_binding, f.compactification, f.mapper, reference_policy()}; + VolumeEquationFixture mass{serif::equations::MassNormalization{target_mass}, f.constant_binding, + f.displacement_binding, f.compactification, f.mapper, reference_policy()}; + const auto boundary = surface::make_pressure_boundary(state); + const auto inputs = pressure_inputs(boundary, eos, pressure, state); + auto cache = dependencies::make_computation_cache(surface::make_pressure_boundary_graph(), inputs); + closure.prepare(state, u, f.workspace); + balance.prepare(state, u, f.workspace); + mass.prepare(state, u, f.workspace); + REQUIRE(cache.try_prepare(inputs)); + Vector result; + closure.residual(f.workspace, result); + CHECK(result.Norml2() < 2.0e-12); + balance.residual(f.workspace, result); + surface::apply_residual_rows(*boundary, cache.get(), result); + CHECK(result.Norml2() < 2.0e-12); + mass.residual(f.workspace, result); + REQUIRE(result.Size() == 1); + CHECK(std::abs(result[0]) < 2.0e-11 * target_mass); +} + +TEST_CASE("Boundary selection requires supported tagged nodal coefficients", "[equations][pressure_surface][unit]") { + GravityFixture f; + tag_cartesian_boundary(f); + const Vector h = constant(f.enthalpy_binding.size(), 0.7); + const auto state = enthalpy_state(f, h); + SECTION("Absent boundary") { + for (int boundary = 0; boundary < f.mesh.GetNBE(); ++boundary) { + f.mesh.GetBdrElement(boundary)->SetAttribute(2); + } + CHECK_THROWS_AS(surface::make_pressure_boundary(state), std::invalid_argument); + } + SECTION("Boundary outside the stellar support") { + for (int boundary = 0; boundary < f.mesh.GetNBE(); ++boundary) { + f.mesh.GetBdrElement(boundary)->SetAttribute(1); + } + CHECK_THROWS_AS(surface::make_pressure_boundary(state), std::invalid_argument); + } + SECTION("Positive H1 coefficients are not nodal values") { + ::mfem::H1_FECollection positive{Enthalpy::FunctionalSpace::order, 3, ::mfem::BasisType::Positive}; + ::mfem::FiniteElementSpace space{&f.mesh, &positive}; + discretization::backend::mfem::TermBinding binding{space}; + const Vector values = constant(binding.size(), 0.7); + const auto positive_state = backend::make_term_coefficients(backend::bind(binding, values)); + CHECK_THROWS_AS(surface::make_pressure_boundary(positive_state), std::invalid_argument); + } + SECTION("Indices must be sorted unique and in range") { + using discretization::backend::mfem::DofSelection; + for (const ::mfem::Array& indices : {::mfem::Array{-1}, ::mfem::Array{0, 0}, + ::mfem::Array{1, 0}, ::mfem::Array{f.enthalpy_binding.size()}}) { + CHECK_THROWS_AS((DofSelection{f.enthalpy_binding, indices}), std::invalid_argument); + } + CHECK_THROWS_AS(f.enthalpy_binding.reduced_dof(-1), std::out_of_range); + CHECK_THROWS_AS(f.enthalpy_binding.reduced_dof(f.enthalpy_binding.full_size()), std::out_of_range); + } +} diff --git a/tests/equations/reference_tables_meanfield.cpp b/tests/equations/reference_tables_meanfield.cpp new file mode 100644 index 00000000..8475547c --- /dev/null +++ b/tests/equations/reference_tables_meanfield.cpp @@ -0,0 +1,92 @@ +#include +#include +#include + +#include + +#include "mfem/fem/fe_coll.hpp" +#include "serif/backend/mfem/discretization/basis_evaluation.hpp" + +namespace +{ + using namespace serif::discretization::backend::mfem; + + /// Check every basis coefficient against MFEM, including bases that require dense fallback. + void check_native_basis(const ::mfem::FiniteElement& element, const ::mfem::IntegrationRule& rule, TensorBasisCache& cache) + { + const bool vector = element.GetRangeType() == ::mfem::FiniteElement::VECTOR; + const ReferenceOperation operation = vector ? ReferenceOperation::value : ReferenceOperation::value_and_gradient; + const int components = vector ? 3 : 4; + BasisEvaluation evaluation; + evaluation.sample(element, rule, operation, 1, cache, ContractionMode::automatic); + BasisWorkspace workspace; + ::mfem::Vector coefficients(element.GetDof()), values, shape(element.GetDof()); + ::mfem::DenseMatrix derivatives(element.GetDof(), 3); + + for (int dof = 0; dof < element.GetDof(); ++dof) + { + coefficients = 0.0; + coefficients[dof] = 1.0; + evaluation.evaluate(coefficients, values, workspace); + REQUIRE(std::all_of(values.GetData(), values.GetData() + values.Size(), [](const double value) + { + return std::isfinite(value); + })); + double error = 0.0; + for (int point = 0; point < rule.GetNPoints(); ++point) + { + if (vector) + { + element.CalcVShape(rule.IntPoint(point), derivatives); + } + else + { + element.CalcShape(rule.IntPoint(point), shape); + element.CalcDShape(rule.IntPoint(point), derivatives); + error = std::max(error, std::abs(values[components * point] - shape[dof])); + } + + for (int axis = 0; axis < 3; ++axis) + { + const int sample = components * point + axis + (vector ? 0 : 1); + error = std::max(error, std::abs(values[sample] - derivatives(dof, axis))); + } + } + CHECK(error < 2.0e-12); + } + } +} + +TEST_CASE("MeanField native scalar reference values and gradients include positive and closed bases", "[equations][tensor_basis][unit]") +{ + for (const int order : {1, 3}) + { + ::mfem::H1_FECollection h1(order, 3); + ::mfem::L2_FECollection l2(order - 1, 3); + TensorBasisCache cache; + const ::mfem::IntegrationRule& rule = ::mfem::IntRules.Get(::mfem::Geometry::CUBE, 2 * order + 1); + check_native_basis(*h1.FiniteElementForGeometry(::mfem::Geometry::CUBE), rule, cache); + check_native_basis(*l2.FiniteElementForGeometry(::mfem::Geometry::CUBE), rule, cache); + } + + ::mfem::H1_FECollection nodal(3, 3), positive(3, 3, ::mfem::BasisType::Positive); + ::mfem::L2_FECollection open(3, 3), closed(3, 3, ::mfem::BasisType::GaussLobatto); + TensorBasisCache cache; + const ::mfem::IntegrationRule& rule = ::mfem::IntRules.Get(::mfem::Geometry::CUBE, 5); + std::array, 4> plans; + const std::array collections{&nodal, &positive, &open, &closed}; + for (std::size_t index = 0; index < collections.size(); ++index) + { + const ::mfem::FiniteElement& element = *collections[index]->FiniteElementForGeometry(::mfem::Geometry::CUBE); + check_native_basis(element, rule, cache); + plans[index] = cache.get(element, rule, ReferenceOperation::value_and_gradient, 1); + if (plans[index]) + { + for (std::size_t previous = 0; previous < index; ++previous) + { + CHECK(plans[index] != plans[previous]); + } + } + } +} + diff --git a/tests/equations/scoped_computations.cpp b/tests/equations/scoped_computations.cpp new file mode 100644 index 00000000..7f60ef27 --- /dev/null +++ b/tests/equations/scoped_computations.cpp @@ -0,0 +1,198 @@ +#include +#include +#include +#include +#include +#include +#include "serif/backend/mfem/model/volume_displacement.hpp" + +#include "serif/tests/equations/test_equations.hpp" +#include "serif/equations/mass_normalization.hpp" +#include "serif/tests/equations/fixture.hpp" + +namespace +{ + using namespace serif; + using namespace serif::tests::equations; + namespace equations_backend = serif::operators::backend::mfem; + namespace dependencies = utils::dependencies; + using Status = dependencies::PreparationStatus; + using Mass = serif::equations::MassNormalization; + + struct FirstClosure + { + }; + struct SecondClosure + { + }; + struct MassRow + { + }; + using First = equations_backend::volume_keys::Scoped; + using Second = equations_backend::volume_keys::Scoped; + using MassStages = equations_backend::volume_keys::Scoped; + + /** @brief Reuse the ordinary fixture bindings without introducing an equation-specific owner. */ + auto closure_discretization(const Fixture& fixture) + { + return equations_backend::VolumeDiscretization{ + .residual_binding = fixture.density_binding, + .displacement_binding = fixture.displacement_binding, + .compactification = fixture.compactification, + .mapper = fixture.mapper}; + } + + /** @brief Compose declarations once and retain the ordinary dependency-cache failure type. */ + template + auto make_cache(const Context& context, Nodes nodes) + { + return std::apply([&](auto... node) + { + return dependencies::make_computation_cache(context, std::move(node)...); + }, + std::move(nodes)); + } +} // namespace + +TEST_CASE("Scoped instances of one volume equation share fields without sharing private stages", "[unit][equations][scoped_computations]") +{ + Fixture fixture; + const auto discretization = closure_discretization(fixture); + auto density = fixture.density(); + const auto enthalpy = fixture.enthalpy(); + const auto displacement = fixture.displacement(0.03); + const eos::models::Polytrope first_eos{1.5, 0.25}; + const eos::models::Polytrope second_eos{3.0, 1.5}; + const auto inputs = [&]() + { + const auto coefficients = fixture.state(density, enthalpy); + const auto first = equations_backend::make_volume_inputs(discretization, coefficients, displacement); + const auto second = equations_backend::make_volume_inputs(discretization, coefficients, displacement); + return dependencies::make_dependency_context( + first.resource(), second.resource(), + first.resource(), first.resource(), + first.resource(), first.resource<::serif::tests::equations::Enthalpy>()); + }; + auto cache = make_cache(inputs(), std::tuple_cat( + equations_backend::make_volume_equation_computations(Closure{first_eos}, discretization), + equations_backend::make_volume_equation_computations(Closure{second_eos}, discretization))); + STATIC_CHECK_FALSE(std::same_as); + REQUIRE(cache.try_prepare(inputs())); + CHECK(relative_error(cache.get().residual, manual_closure(fixture, first_eos, density, enthalpy, displacement)) < 2.0e-12); + CHECK(relative_error(cache.get().residual, manual_closure(fixture, second_eos, density, enthalpy, displacement)) < 2.0e-12); + const auto density_direction = deterministic(density.Size(), 0.3); + const auto enthalpy_direction = deterministic(enthalpy.Size(), 0.7); + const auto displacement_direction = fixture.displacement(0.1); + const auto direction = packed(density_direction, enthalpy_direction, displacement_direction); + const auto dual = deterministic(density.Size(), 0.9); + mfem::Vector action, transpose; + equations_backend::Workspace workspace; + equations_backend::apply_jacobian(cache.get(), direction, workspace, action); + CHECK(relative_error(action, manual_closure(fixture, second_eos, density, enthalpy, displacement, &density_direction, &enthalpy_direction, &displacement_direction)) < 2.0e-12); + equations_backend::apply_jacobian_transpose(cache.get(), dual, workspace, transpose); + CHECK((action * dual) == Catch::Approx(direction * transpose).epsilon(2.0e-12)); + const auto frozen = cache.share(); + REQUIRE(cache.try_prepare(inputs())); + CHECK(cache.report().computed_count() == 0); + CHECK(cache.share() == frozen); + + density += 0.04; + REQUIRE(cache.try_prepare(inputs(), dependencies::RequestedResults{})); + CHECK(cache.report().status>() == Status::reused); + CHECK(cache.report().status>() == Status::computed); + CHECK_FALSE(cache.contains()); + CHECK(equations_backend::volume_work_report(cache).physics_points > 0); + REQUIRE(cache.try_prepare(inputs())); + CHECK(cache.report().status() == Status::reused); + CHECK(cache.report().status>() == Status::computed); + CHECK(cache.share() != frozen); + CHECK(relative_error(cache.get().residual, manual_closure(fixture, second_eos, density, enthalpy, displacement)) < 2.0e-12); +} + +TEST_CASE("Distinct volume equations prepare in one graph from common coefficient sources", "[unit][equations][scoped_computations]") +{ + Fixture fixture; + const eos::models::Polytrope eos{1.5, 0.25}; + auto density = fixture.density(); + auto enthalpy = fixture.enthalpy(); + auto displacement = fixture.displacement(0.03); + const auto closure = closure_discretization(fixture); + const equations_backend::VolumeDiscretization mass{ + .residual_binding = fixture.constant_binding, + .displacement_binding = fixture.displacement_binding, + .compactification = fixture.compactification, + .mapper = fixture.mapper}; + const auto coefficients = [&]() + { + return equations_backend::make_term_coefficients(equations_backend::bind(fixture.density_binding, density), equations_backend::bind(fixture.enthalpy_binding, enthalpy)); + }; + const auto inputs = [&]() + { + const auto state = coefficients(); + const auto volume = equations_backend::make_volume_inputs(closure, state, displacement); + const auto integral = equations_backend::make_volume_inputs(mass, state, displacement); + return dependencies::make_dependency_context( + volume.resource(), integral.resource(), + volume.resource(), volume.resource(), + volume.resource(), volume.resource<::serif::tests::equations::Enthalpy>()); + }; + auto cache = make_cache(inputs(), std::tuple_cat( + equations_backend::make_volume_equation_computations(Closure{eos}, closure), + equations_backend::make_volume_equation_computations(Mass{0.8}, mass))); + REQUIRE(cache.try_prepare(inputs())); + const auto frozen_volume = cache.share(); + REQUIRE(cache.try_prepare(inputs())); + CHECK(cache.report().computed_count() == 0); + + enthalpy += 0.03; + REQUIRE(cache.try_prepare(inputs())); + CHECK(cache.report().status() == Status::computed); + CHECK(cache.report().status() == Status::reused); + + density += 0.02; + REQUIRE(cache.try_prepare(inputs())); + CHECK(cache.report().status() == Status::computed); + enthalpy = -1.0; + const auto rejected = cache.try_prepare(inputs()); + REQUIRE_FALSE(rejected); + CHECK_FALSE(cache.ready()); + CHECK_FALSE(cache.contains()); + CHECK(frozen_volume->residual.Size() == fixture.density_binding.size()); +} + +TEST_CASE("Scoped equation metadata connects the shared deformation displacement source", "[unit][equations][scoped_computations]") +{ + namespace deformation = serif::geometry::deformation; + namespace deformation_backend = deformation::backend::mfem; + using Failure = std::variant; + + Fixture fixture; + const auto discretization = closure_discretization(fixture); + const auto density = fixture.density(); + const auto enthalpy = fixture.enthalpy(); + const auto displacement = fixture.displacement(0.03); + std::vector generated(displacement.GetData(), displacement.GetData() + displacement.Size()); + const auto reference = deformation_backend::deformation_discretization(fixture.enthalpy_space, fixture.displacement_space, fixture.mesh); + const auto volume = equations_backend::make_volume_inputs(discretization, fixture.state(density, enthalpy)); + const auto context = dependencies::make_dependency_context( + volume.resource(), volume.resource(), + volume.resource(), volume.resource<::serif::tests::equations::Enthalpy>(), + dependencies::own(reference, deformation_backend::ReferenceDiscretizationTracking{}), + dependencies::borrow(generated)); + const eos::models::Polytrope eos{1.5, 0.25}; + auto cache = std::apply([&](auto... node) + { + return dependencies::make_computation_cache(context, std::move(node)...); + }, std::tuple_cat( + deformation_backend::make_volume_displacement_computations(fixture.displacement_binding), + equations_backend::make_volume_equation_computations(Closure{eos}, discretization))); + REQUIRE(cache.try_prepare(context)); + CHECK(relative_error(cache.get().values, displacement) == 0.0); + CHECK(relative_error(cache.get().residual, manual_closure(fixture, eos, density, enthalpy, displacement)) < 2.0e-12); + REQUIRE(cache.try_prepare(context)); + CHECK(cache.report().computed_count() == 0); + generated[0] += 0.001; + REQUIRE(cache.try_prepare(context)); + CHECK(cache.report().status() == Status::reused); + CHECK(cache.report().status>() == Status::computed); +} diff --git a/tests/equations/serial_backend.cpp b/tests/equations/serial_backend.cpp new file mode 100644 index 00000000..6d64db81 --- /dev/null +++ b/tests/equations/serial_backend.cpp @@ -0,0 +1,440 @@ +#include +#include +#include + +#include "serif/tests/equations/gravity_port.hpp" +#include "serif/equations/mass_normalization.hpp" +#include "serif/equations/angular_momentum.hpp" +#include "serif/equations/primal_gravity.hpp" +#include "serif/equations/barotropic_density.hpp" +#include "serif/backend/mfem/operators/volume_field_block.hpp" +#include "serif/tests/nonconforming.hpp" + +namespace { + using namespace serif; + using namespace serif::tests::equations; + using namespace serif::tests::equations::gravity; + using Vector = ::mfem::Vector; + using Mode = backend::PreparationMode; + + struct Values { + Vector rho, h, phi, c, omega, u; + /// Smooth values covering every migrated equation's unknowns. + explicit Values(GravityFixture& f) : rho(f.density()), h(f.enthalpy()), phi(f.potential()), + c(constant(1, 0.7)), omega(constant(1, 0.4)), u(f.displacement(0.04)) {} + /// Bind each physical term once, independently of which equation consumes it. + auto state(GravityFixture& f) const { + return backend::make_term_coefficients(backend::bind(f.density_binding, rho), backend::bind(f.enthalpy_binding, h), + backend::bind(f.potential_binding, phi), + backend::bind(f.constant_binding, c), backend::bind(f.angular_velocity_binding, omega)); + } + }; + + /// Use the prepared plan's public column order to construct an independent packed direction. + template + Vector pack(const OperatorT& op, const StateT& state, const Vector& displacement) { + Vector result(op.width()); + int offset = 0; + OperatorT::Plan::for_each_column([&](OperandT, std::size_t) { + const auto& values = state.template get().values; + std::copy_n(values.GetData(), values.Size(), result.GetData() + offset); + offset += values.Size(); + }); + std::copy_n(displacement.GetData(), displacement.Size(), result.GetData() + offset); + return result; + } + + /// Check full field/geometry transposes, dense/tensor agreement, freezing and the MFEM adapter. + template + void check_operator(GravityFixture& f, EquationT equation, const BindingT& row) { + CAPTURE(EquationT::name, f.mesh.GetElementGeometry(0), f.displacement_space.GetOrdering()); + Values values(f), direction(f); + direction.rho = deterministic(values.rho.Size(), 0.3); + direction.h = deterministic(values.h.Size(), 0.5); + direction.phi = deterministic(values.phi.Size(), 0.9); + direction.c[0] = -0.23; + direction.omega[0] = 0.17; + direction.u = f.displacement(-0.07); + VolumeEquationFixture automatic{equation, row, f.displacement_binding, f.compactification, f.mapper, reference_policy()}; + VolumeEquationFixture dense{equation, row, f.displacement_binding, f.compactification, f.mapper, reference_policy(), backend::ContractionMode::dense}; + automatic.prepare(values.state(f), values.u, f.workspace); + dense.prepare(values.state(f), values.u, f.workspace); + CHECK(dense.tensor_element_count() == 0); + CHECK((automatic.tensor_element_count() > 0) == (f.mesh.GetElementGeometry(0) == ::mfem::Geometry::CUBE)); + Vector residual, expected_residual, action, expected_action, transpose, expected_transpose; + automatic.residual(f.workspace, residual); + dense.residual(f.workspace, expected_residual); + CHECK(relative_error(residual, expected_residual) < 2.0e-12); + const auto input = pack(automatic, direction.state(f), direction.u); + automatic.apply_jacobian(input, f.workspace, action); + dense.apply_jacobian(input, f.workspace, expected_action); + CHECK(relative_error(action, expected_action) < 2.0e-12); + auto dual = deterministic(automatic.height(), -0.8); + automatic.apply_jacobian_transpose(dual, f.workspace, transpose); + dense.apply_jacobian_transpose(dual, f.workspace, expected_transpose); + CHECK(relative_error(transpose, expected_transpose) < 2.0e-12); + // The old mass-adapter transpose pairing threshold is retained for every equation. + CHECK(gravity_port::pairing_relative(input * transpose, action * dual) < 2.0e-12); + backend::JacobianOperator adapter{automatic.cache()}; + Vector adapted; + adapter.MultTranspose(dual, adapted); + CHECK(relative_error(adapted, transpose) < 2.0e-15); + Vector zero = constant(dual.Size(), 0.0); + automatic.apply_jacobian_transpose(zero, f.workspace, adapted); + CHECK(adapted.Norml2() == 0.0); + + // Probe both ends of each coefficient block and the geometry block independently. + int offset = 0; + const auto check_column = [&](int index) { + Vector basis = constant(automatic.width(), 0.0), column; + basis[index] = 1.0; + automatic.apply_jacobian(basis, f.workspace, column); + CHECK(std::abs(column * dual - transpose[index]) < 2.0e-12 * std::max(1.0, std::abs(transpose[index]))); + }; + decltype(automatic)::Plan::for_each_column([&](OperandT, std::size_t) { + const auto& input_values = values.state(f).template get().values; + check_column(offset); + check_column(offset + input_values.Size() - 1); + offset += input_values.Size(); + }); + check_column(offset); + check_column(automatic.width() - 1); + values.rho = values.h = values.phi = 0.0; + values.c = values.omega = values.u = 0.0; + automatic.apply_jacobian_transpose(dual, f.workspace, adapted); + CHECK(relative_error(adapted, transpose) < 2.0e-15); + Vector wrong_size(automatic.height() + 1); + CHECK_THROWS_AS(automatic.apply_jacobian_transpose(wrong_size, f.workspace, adapted), std::invalid_argument); + dual[0] = std::numeric_limits::quiet_NaN(); + CHECK_THROWS_AS(automatic.apply_jacobian_transpose(dual, f.workspace, adapted), std::domain_error); + } + + /** @brief Ensure a material-only equation actually encounters weighted input or test coefficients. */ + void require_material_hanging_coefficient(const discretization::backend::mfem::DofMap& binding, + const discretization::backend::mfem::DofMap& material) { + const ::mfem::SparseMatrix* prolongation = binding.space().GetConformingProlongation(); + REQUIRE(prolongation != nullptr); + bool found = false; + ::mfem::Array dofs; + for (int element = 0; element < binding.mesh().GetNE(); ++element) { + if (!material.supports(element)) { + continue; + } + binding.space().GetElementVDofs(element, dofs); + for (const int encoded : dofs) { + const int local = ::mfem::FiniteElementSpace::DecodeDof(encoded); + int contributions = 0; + for (int entry = prolongation->GetI()[local]; entry < prolongation->GetI()[local + 1]; ++entry) { + if (prolongation->GetData()[entry] != 0.0) { + ++contributions; + } + } + found = found || contributions > 1; + } + } + REQUIRE(found); + } + + /** @brief Isolate the production stellar source without assembling vacuum diffusion. */ + struct StellarDensitySource : serif::equations::PrimalPoissonBalance::DensitySource { + static constexpr std::string_view name = "stellar density source"; + /** @brief Preserve the primal Poisson source coefficient and sign. */ + explicit StellarDensitySource(const double coefficient) : DensitySource(coefficient) {} + }; + + struct CountingClosure { + using Test = Closure::Test; + using Operands = Closure::Operands; + static constexpr std::string_view name = "counting closure"; + Closure equation{eos::models::Polytrope{1.5, 0.25}}; + std::shared_ptr calls = std::make_shared(0); + /// Count local evaluations without changing the physical residual or derivative. + auto evaluate(const serif::equations::EquationPoint& point) const { ++*calls; return equation.evaluate(point); } + /// Use the real closure's quadrature contract. + template int additional_order(OperandT operand, int order) const { return equation.additional_order(operand, order); } + }; +} + +TEST_CASE("Every migrated volume equation has a complete serial Jacobian transpose", "[equations][serial_backend][integration]") { + for (const auto geometry : {::mfem::Element::TETRAHEDRON, ::mfem::Element::HEXAHEDRON}) { + for (const auto ordering : {::mfem::Ordering::byNODES, ::mfem::Ordering::byVDIM}) { + GravityFixture f(geometry, ordering); + f.set_compactification(); + const eos::models::Polytrope eos{1.5, 0.25}; + check_operator(f, Closure{eos}, f.density_binding); + check_operator(f, PotentialRow{four_pi_G}, f.potential_binding); + check_operator(f, serif::equations::BarotropicDensityBalance{eos}, f.density_binding); + check_operator(f, serif::equations::MassNormalization{0.8}, f.constant_binding); + check_operator(f, serif::equations::AngularMomentumConstraint{0.3}, f.angular_velocity_binding); + } + } +} + +TEST_CASE("Curved compactified geometry preserves transpose pairings", "[equations][serial_backend][integration]") { + auto fixture = gravity_port::stellar_fixture(); + auto& f = *fixture; + check_operator(f, PotentialRow{four_pi_G}, f.potential_binding); + const eos::models::Polytrope eos{1.5, 0.25}; + check_operator(f, serif::equations::BarotropicDensityBalance{eos}, f.density_binding); +} + +TEST_CASE("Selective preparation refreshes only changed operand samples and geometry", "[equations][serial_backend][unit]") { + GravityFixture f; + f.set_compactification(); + CountingClosure equation; + VolumeEquationFixture op{equation, f.density_binding, f.displacement_binding, f.compactification, f.mapper, reference_policy()}; + Values values(f); + op.prepare(values.state(f), values.u, f.workspace); + const auto initial = op.preparation_report(); + REQUIRE(initial.physics_points > 0); + CHECK(initial.physics_points == static_cast(*equation.calls)); + const auto initial_calls = *equation.calls; + Vector before, after; + op.residual(f.workspace, before); + op.prepare(values.state(f), values.u, f.workspace, Mode::reuse_unchanged); + CHECK_FALSE(op.preparation_report().did_work()); + CHECK(op.preparation_report().reused_elements > 0); + CHECK(*equation.calls == initial_calls); + op.residual(f.workspace, after); + CHECK(relative_error(after, before) == 0.0); + + values.rho += 0.03; + op.prepare(values.state(f), values.u, f.workspace, Mode::reuse_unchanged); + CHECK(op.preparation_report().geometry_points == 0); + CHECK(op.preparation_report().sampled_operands * 2 == initial.sampled_operands); + CHECK(op.preparation_report().physics_points == initial.physics_points); + CHECK(*equation.calls == 2 * initial_calls); + op.residual(f.workspace, after); + op.prepare(values.state(f), values.u, f.workspace); + op.residual(f.workspace, before); + CHECK(relative_error(after, before) < 2.0e-15); + + values.u *= 1.01; + op.prepare(values.state(f), values.u, f.workspace, Mode::reuse_unchanged); + CHECK(op.preparation_report().geometry_points == initial.geometry_points); + CHECK(op.preparation_report().sampled_operands == 0); + f.mesh.NodesUpdated(); + CHECK_THROWS_AS(op.residual(f.workspace, after), std::logic_error); + op.prepare(values.state(f), values.u, f.workspace, Mode::reuse_unchanged); + CHECK(op.preparation_report().geometry_points == initial.geometry_points); + CHECK(op.preparation_report().sampled_operands == initial.sampled_operands); + + values.h = -1.0; + CHECK_FALSE(op.try_prepare(values.state(f), values.u, f.workspace, Mode::reuse_unchanged).has_value()); + CHECK_FALSE(op.is_prepared()); + CHECK_THROWS_AS(op.residual(f.workspace, after), std::logic_error); + values.h = f.enthalpy(); + op.prepare(values.state(f), values.u, f.workspace, Mode::reuse_unchanged); + CHECK(op.preparation_report().geometry_points == initial.geometry_points); + CHECK(op.preparation_report().sampled_operands == initial.sampled_operands); + op.invalidate(); + CHECK_FALSE(op.preparation_report().did_work()); + op.prepare(values.state(f), values.u, f.workspace, Mode::reuse_unchanged); + CHECK(op.preparation_report().did_work()); +} + +TEST_CASE("Selective preparation follows contribution dependencies and copied coefficient values", "[equations][serial_backend][unit]") { + GravityFixture f; + Values values(f); + // Only the stellar density source reads density; the potential flux on every domain is reused. + VolumeEquationFixture op{serif::equations::PrimalPoissonBalance{four_pi_G}, f.potential_binding, f.displacement_binding, f.compactification, f.mapper, reference_policy()}; + op.prepare(values.state(f), values.u, f.workspace); + const auto initial = op.preparation_report(); + auto copied = values; + op.prepare(copied.state(f), copied.u, f.workspace, Mode::reuse_unchanged); + CHECK_FALSE(op.preparation_report().did_work()); + const auto elements = op.preparation_report().reused_elements; + copied.rho *= 1.1; + op.prepare(copied.state(f), copied.u, f.workspace, Mode::reuse_unchanged); + CHECK(op.preparation_report().geometry_points == 0); + CHECK(op.preparation_report().reused_elements > 0); + CHECK(op.preparation_report().reused_elements < elements); + CHECK(op.preparation_report().sampled_operands > 0); + CHECK(op.preparation_report().sampled_operands < initial.sampled_operands); + CHECK(op.preparation_report().physics_points > 0); + CHECK(op.preparation_report().physics_points < initial.physics_points); + Vector incremental, refreshed; + op.residual(f.workspace, incremental); + op.prepare(copied.state(f), copied.u, f.workspace); + op.residual(f.workspace, refreshed); + CHECK(relative_error(incremental, refreshed) < 2.0e-15); +} + +TEST_CASE("Selective preparation refreshes compactification without resampling unchanged operands", "[equations][serial_backend][unit]") { + GravityFixture f; + f.set_compactification(); + Values values(f); + VolumeEquationFixture op{PotentialRow{four_pi_G}, f.potential_binding, f.displacement_binding, f.compactification, f.mapper, reference_policy()}; + op.prepare(values.state(f), values.u, f.workspace); + const auto initial = op.preparation_report(); + Vector frozen, unchanged, incremental, refreshed, action, transpose; + op.residual(f.workspace, frozen); + f.compactification *= 0.97; + op.residual(f.workspace, unchanged); + CHECK(relative_error(unchanged, frozen) == 0.0); + + op.prepare(values.state(f), values.u, f.workspace, Mode::reuse_unchanged); + CHECK(op.preparation_report().geometry_points == initial.geometry_points); + CHECK(op.preparation_report().sampled_operands == 0); + op.residual(f.workspace, incremental); + CHECK(relative_error(incremental, frozen) > 1.0e-8); + const auto direction = pack(op, values.state(f), values.u); + const auto dual = deterministic(op.height(), 0.31); + op.apply_jacobian(direction, f.workspace, action); + op.apply_jacobian_transpose(dual, f.workspace, transpose); + + op.prepare(values.state(f), values.u, f.workspace); + op.residual(f.workspace, refreshed); + CHECK(relative_error(incremental, refreshed) < 2.0e-12); + op.apply_jacobian(direction, f.workspace, refreshed); + CHECK(relative_error(action, refreshed) < 2.0e-12); + op.apply_jacobian_transpose(dual, f.workspace, refreshed); + CHECK(relative_error(transpose, refreshed) < 2.0e-12); +} + +TEST_CASE("Dependency preparation detects coefficient binding replacement and ignores unused state roles", "[equations][serial_backend][unit]") { + GravityFixture f; + Values values(f); + CountingClosure equation; + VolumeEquationFixture op{equation, f.density_binding, f.displacement_binding, f.compactification, f.mapper, reference_policy()}; + op.prepare(values.state(f), values.u, f.workspace); + const auto initial = op.preparation_report(); + const auto calls = *equation.calls; + + values.omega[0] *= 1.2; // Rotation is present in the state but is not an input to closure. + op.prepare(values.state(f), values.u, f.workspace, Mode::reuse_unchanged); + CHECK_FALSE(op.preparation_report().did_work()); + CHECK(*equation.calls == calls); + + discretization::backend::mfem::TermBinding replacement{f.density_space}; + const auto rebound = backend::make_term_coefficients(backend::bind(replacement, values.rho), backend::bind(f.enthalpy_binding, values.h)); + op.prepare(rebound, values.u, f.workspace, Mode::reuse_unchanged); + CHECK(op.preparation_report().geometry_points == initial.geometry_points); + CHECK(op.preparation_report().sampled_operands == initial.sampled_operands); + CHECK(*equation.calls == 2 * calls); + + Vector incremental, rebuilt; + op.residual(f.workspace, incremental); + op.prepare(rebound, values.u, f.workspace); + op.residual(f.workspace, rebuilt); + CHECK(relative_error(incremental, rebuilt) < 2.0e-15); +} + +TEST_CASE("STROID hanging nodes preserve complete volume actions and sparse field blocks", "[integration][serial_backend][nonconforming]") { + const auto generated = serif::tests::nonconforming::mesh(1, 2); + for (const auto ordering : {::mfem::Ordering::byNODES, ::mfem::Ordering::byVDIM}) { + GravityFixture fixture(::mfem::Mesh(*generated.mesh), ordering); + ::mfem::GridFunctionCoefficient coordinate(generated.exterior_coordinate->values.get()); + fixture.compactification.ProjectCoefficient(coordinate); + REQUIRE(fixture.potential_space.GetTrueVSize() < fixture.potential_space.GetVSize()); + check_operator(fixture, PotentialRow{four_pi_G}, fixture.potential_binding); + Values values(fixture); + VolumeEquationFixture equation{PotentialRow{four_pi_G}, fixture.potential_binding, fixture.displacement_binding, + fixture.compactification, fixture.mapper, reference_policy()}; + equation.prepare(values.state(fixture), values.u, fixture.workspace); + const auto matrix = backend::assemble_volume_field(equation.data()); + const auto diagonal = backend::volume_field_diagonal(equation.data()); + const Vector input = deterministic(fixture.potential_binding.size(), 0.7); + Vector actual, expected(matrix->Height()), native_diagonal; + auto direction = gravity_port::zeros(fixture); + direction.potential = input; + equation.apply_jacobian(fixture.state(direction.potential, direction.density), direction.displacement, fixture.workspace, actual); + matrix->Mult(input, expected); + CHECK(relative_error(actual, expected) < 3.0e-12); + matrix->GetDiag(native_diagonal); + for (int index = 0; index < native_diagonal.Size(); ++index) { + CHECK(diagonal[index] == native_diagonal[index]); + } + } +} + +TEST_CASE("STROID material hanging nodes preserve nonlinear density-balance and density-source actions", "[integration][serial_backend][nonconforming]") { + const auto generated = serif::tests::nonconforming::mesh(0, 3); + GravityFixture fixture(::mfem::Mesh(*generated.mesh), ::mfem::Ordering::byVDIM); + ::mfem::GridFunctionCoefficient coordinate(generated.exterior_coordinate->values.get()); + fixture.compactification.ProjectCoefficient(coordinate); + require_material_hanging_coefficient(fixture.density_binding, fixture.density_binding); + require_material_hanging_coefficient(fixture.displacement_binding, fixture.density_binding); + + const serif::equations::BarotropicDensityBalance density_balance{eos::models::Polytrope{1.5, 0.25}}; + check_operator(fixture, density_balance, fixture.density_binding); + check_operator(fixture, StellarDensitySource{four_pi_G}, fixture.potential_binding); + + // Positive interior density keeps this nonlinear tangent away from the vacuum singularity. + Values values(fixture), direction(fixture); + direction.rho = deterministic(values.rho.Size(), 0.3); + direction.phi = 0.0; + direction.c = 0.0; + direction.omega = 0.0; + direction.u = fixture.displacement(0.001); + VolumeEquationFixture density_equation{density_balance, fixture.density_binding, fixture.displacement_binding, + fixture.compactification, fixture.mapper, reference_policy()}; + density_equation.prepare(values.state(fixture), values.u, fixture.workspace); + const auto density_matrix = backend::assemble_volume_field(density_equation.data()); + const Vector zero_displacement = constant(values.u.Size(), 0.0); + Vector actual, expected(density_matrix->Height()); + density_equation.apply_jacobian(direction.state(fixture), zero_displacement, fixture.workspace, actual); + density_matrix->Mult(direction.rho, expected); + CHECK(relative_error(actual, expected) < 3.0e-12); + const Vector density_dual = deterministic(density_matrix->Height(), -0.8); + Vector complete_transpose, sparse_transpose(density_matrix->Width()); + density_equation.apply_jacobian_transpose(density_dual, fixture.workspace, complete_transpose); + density_matrix->MultTranspose(density_dual, sparse_transpose); + const Vector field_transpose(complete_transpose.GetData(), direction.rho.Size()); + CHECK(relative_error(sparse_transpose, field_transpose) < 3.0e-12); + + // The nonlinear residual independently checks the constrained field and geometry tangent together. + density_equation.apply_jacobian(direction.state(fixture), direction.u, fixture.workspace, actual); + constexpr double step = 1.0e-6; + Values plus(values), minus(values); + plus.rho.Add(step, direction.rho); + minus.rho.Add(-step, direction.rho); + plus.u.Add(step, direction.u); + minus.u.Add(-step, direction.u); + Vector positive, negative; + density_equation.prepare(plus.state(fixture), plus.u, fixture.workspace); + density_equation.residual(fixture.workspace, positive); + density_equation.prepare(minus.state(fixture), minus.u, fixture.workspace); + density_equation.residual(fixture.workspace, negative); + positive -= negative; + positive /= 2.0 * step; + CHECK(relative_error(actual, positive) < 4.0e-8); + + VolumeEquationFixture source_equation{StellarDensitySource{four_pi_G}, fixture.potential_binding, + fixture.displacement_binding, fixture.compactification, fixture.mapper, reference_policy()}; + source_equation.prepare(values.state(fixture), values.u, fixture.workspace); + const auto source_matrix = backend::assemble_volume_field(source_equation.data()); + ReferenceGeometry geometry{fixture, fixture.mapper, values.u}; + ReferenceVolume volume{geometry}; + ::mfem::MixedBilinearForm native_source(&fixture.density_space, &fixture.potential_space); + auto* integrator = new ::mfem::MixedScalarMassIntegrator(volume); + const auto& rule = ::mfem::IntRules.Get(fixture.mesh.GetElementBaseGeometry(0), reference_order); + integrator->SetIntRule(&rule); + ::mfem::Array stellar{1, 1, 0}; + native_source.AddDomainIntegrator(integrator, stellar); + native_source.Assemble(); + native_source.Finalize(); + + // Native local assembly and native P transpose retain all hanging-node cross terms. + Vector native_full(fixture.potential_binding.full_size()), native_true(fixture.potential_binding.true_size()); + native_source.Mult(fixture.density_binding.scatter(values.rho), native_full); + native_full *= four_pi_G; + fixture.potential_space.GetConformingProlongation()->MultTranspose(native_full, native_true); + const Vector native_residual = fixture.potential_binding.gather_true(native_true); + source_equation.residual(fixture.workspace, actual); + CHECK(relative_error(actual, native_residual) < 3.0e-12); + expected.SetSize(source_matrix->Height()); + source_matrix->Mult(values.rho, expected); + CHECK(relative_error(expected, native_residual) < 3.0e-12); + + const Vector source_dual = deterministic(source_matrix->Height(), 0.7); + native_full.SetSize(fixture.density_binding.full_size()); + native_true.SetSize(fixture.density_binding.true_size()); + native_source.MultTranspose(fixture.potential_binding.scatter(source_dual), native_full); + native_full *= four_pi_G; + fixture.density_space.GetConformingProlongation()->MultTranspose(native_full, native_true); + const Vector native_transpose = fixture.density_binding.gather_true(native_true); + sparse_transpose.SetSize(source_matrix->Width()); + source_matrix->MultTranspose(source_dual, sparse_transpose); + CHECK(relative_error(sparse_transpose, native_transpose) < 3.0e-12); +} diff --git a/tests/equations/state_validation.cpp b/tests/equations/state_validation.cpp new file mode 100644 index 00000000..a16c0891 --- /dev/null +++ b/tests/equations/state_validation.cpp @@ -0,0 +1,252 @@ +#include +#include +#include +#include +#include + +#include "serif/tests/equations/fixture.hpp" +#include "serif/backend/mfem/discretization/element_coordinates.hpp" +#include "serif/tests/test_tags.hpp" + +namespace serif::tests::equations { + TEST_CASE("Scalar Operand Evaluation Rejects Incompatible MFEM Elements", tags::unit & tags::equations) { + Fixture f; + ::mfem::ND_FECollection collection(1, 3); + ::mfem::FiniteElementSpace space(&f.mesh, &collection); + const auto& element = *space.GetFE(0); + const auto& rule = ::mfem::IntRules.Get(element.GetGeomType(), 2); + backend::BasisEvaluation basis; + backend::TensorBasisCache cache; + backend::BasisWorkspace workspace; + auto& transformation = *f.mesh.GetElementTransformation(0); + using Operand = discretization::forms::Operand; + + // Direct sampling must enforce the scalar representation even without a TermBinding. + CHECK_THROWS_AS( + backend::OperandEvaluator::sample_basis(element, rule, basis, cache, backend::ContractionMode::automatic, transformation, workspace), + std::invalid_argument + ); + } + + TEST_CASE("Equation State Guards Remain Active For Invalid Sizes And Nonfinite Values", tags::unit & tags::equations) { + Fixture f; + auto rho = f.density(), h = f.enthalpy(), u = f.displacement(0.0); + PreparedClosure prepared( + Closure{eos::models::Polytrope{3.0, 1.5}}, f.density_binding, f.displacement_binding, f.compactification, + f.mapper + ); + ::mfem::Vector result; + CHECK_THROWS_AS(prepared.residual(f.workspace, result), std::logic_error); + CHECK_THROWS_AS(prepared.width(), std::logic_error); + const auto wrong = constant(rho.Size() + 1, 0.0); + CHECK_THROWS_AS(prepared.prepare(f.state(wrong, h), u, f.workspace), std::invalid_argument); + CHECK_FALSE(prepared.is_prepared()); + rho[0] = std::numeric_limits::quiet_NaN(); + const auto invalid = prepared.try_prepare(f.state(rho, h), u, f.workspace); + REQUIRE_FALSE(invalid.has_value()); + CHECK(invalid.error().reason == backend::PreparationRejectionReason::NON_FINITE); + rho = f.density(); + prepared.prepare(f.state(rho, h), u, f.workspace); + CHECK_THROWS_AS(prepared.apply_jacobian(f.state(wrong, h), u, f.workspace, result), std::invalid_argument); + CHECK_THROWS_AS(prepared.apply_jacobian(wrong, f.workspace, result), std::invalid_argument); + h[0] = std::numeric_limits::infinity(); + CHECK_THROWS_AS(prepared.apply_jacobian(f.state(rho, h), u, f.workspace, result), std::domain_error); + } + + TEST_CASE("Equation Bindings Reject Wrong Spaces And Different Meshes", tags::unit & tags::equations) { + Fixture f, other; + ::mfem::H1_FECollection wrong_collection(2, 3); + ::mfem::FiniteElementSpace wrong_order(&f.mesh, &wrong_collection); + CHECK_THROWS_AS( + (discretization::backend::mfem::TermBinding<::serif::tests::equations::Enthalpy, Schema>{wrong_order}), std::invalid_argument + ); + CHECK_THROWS_AS( + (discretization::backend::mfem::TermBinding{wrong_order}), std::invalid_argument + ); + const auto rho = f.density(), h = other.enthalpy(), u = f.displacement(0.0); + PreparedClosure prepared( + Closure{eos::models::Polytrope{3.0, 1.5}}, f.density_binding, f.displacement_binding, f.compactification, + f.mapper + ); + const auto wrong_state = + backend::make_term_coefficients(backend::bind(f.density_binding, rho), backend::bind(other.enthalpy_binding, h)); + CHECK_THROWS_AS(prepared.prepare(wrong_state, u, f.workspace), std::invalid_argument); + const auto local_h = f.enthalpy(); + prepared.prepare(f.state(rho, local_h), u, f.workspace); + ::mfem::Vector result; + CHECK_THROWS_AS(prepared.apply_jacobian(wrong_state, u, f.workspace, result), std::invalid_argument); + } + + TEST_CASE("Prepared Equations Reject Changed Discretization And Domain Support", tags::unit & tags::equations) { + Fixture f; + const auto rho = f.density(), h = f.enthalpy(), u = f.displacement(0.0); + PreparedClosure prepared( + Closure{eos::models::Polytrope{3.0, 1.5}}, f.density_binding, f.displacement_binding, f.compactification, + f.mapper + ); + prepared.prepare(f.state(rho, h), u, f.workspace); + ::mfem::Vector result; + SECTION("Changed domain attributes") { + f.mesh.GetElement(0)->SetAttribute(3); + CHECK_THROWS_AS(prepared.residual(f.workspace, result), std::logic_error); + } + SECTION("Mesh refinement") { + f.mesh.UniformRefinement(); + CHECK_THROWS_AS(prepared.residual(f.workspace, result), std::logic_error); + } + SECTION("Updated node geometry") { + f.mesh.NodesUpdated(); + CHECK_THROWS_AS(prepared.residual(f.workspace, result), std::logic_error); + prepared.prepare(f.state(rho, h), u, f.workspace); + CHECK_NOTHROW(prepared.residual(f.workspace, result)); + } + SECTION("Rebound compactification") { + Fixture other; + f.compactification.SetSpace(&other.enthalpy_space); + CHECK_THROWS_AS(prepared.residual(f.workspace, result), std::logic_error); + } + } + + TEST_CASE("Equation Field Restriction Excludes Only Vacuum-Only Coefficients", tags::unit & tags::equations) { + Fixture f; + auto full = constant(f.density_space.GetVSize(), 0.0); + ::mfem::Array dofs; + std::vector stellar_dofs(full.Size(), false); + for (int element = 0; element < f.mesh.GetNE(); ++element) { + if (f.mesh.GetAttribute(element) == 3) { + continue; + } + f.density_space.GetElementDofs(element, dofs); + for (const int dof : dofs) { + stellar_dofs[dof] = true; + } + } + for (int dof = 0; dof < full.Size(); ++dof) { + full[dof] = stellar_dofs[dof] ? 0.5 : std::numeric_limits::quiet_NaN(); + } + const auto selected = std::count(stellar_dofs.begin(), stellar_dofs.end(), true); + REQUIRE(selected > 0); + REQUIRE(selected < full.Size()); + CHECK(f.density_binding.size() == selected); + const auto reduced = f.density_binding.gather(full); + CHECK_NOTHROW(f.density_binding.validate_values(reduced)); + const auto expanded = f.density_binding.scatter(reduced); + CHECK(expanded.CheckFinite() == 0); + CHECK(reduced.Min() == 0.5); + } + TEST_CASE("Equation Bindings Reject Unsupported Meshes And Element Indices", tags::unit & tags::equations) { + Fixture f; + CHECK_THROWS_AS(f.density_binding.supports(-1), std::out_of_range); + CHECK_THROWS_AS(f.density_binding.supports(f.mesh.GetNE()), std::out_of_range); + ::mfem::Vector empty, output; + CHECK_THROWS_AS(f.density_binding.element_values(0, empty, output), std::invalid_argument); + auto flat_mesh = ::mfem::Mesh::MakeCartesian2D(2, 2, ::mfem::Element::QUADRILATERAL); + ::mfem::L2_FECollection flat_collection(2, 2); + ::mfem::FiniteElementSpace flat_space(&flat_mesh, &flat_collection); + CHECK_THROWS_AS((discretization::backend::mfem::TermBinding{flat_space}), std::invalid_argument); + auto nonconforming_mesh = make_mesh(::mfem::Element::HEXAHEDRON); + nonconforming_mesh.EnsureNCMesh(); + ::mfem::FiniteElementSpace nonconforming_space(&nonconforming_mesh, &f.density_collection); + CHECK_NOTHROW((discretization::backend::mfem::TermBinding{nonconforming_space})); + } + namespace { + /** @brief Compare primal/dual coordinate access with independent MFEM extraction and assembly. */ + void check_element_coordinates(const discretization::backend::mfem::DofMap& binding) { + const auto reduced = deterministic(binding.size(), 0.31); + const auto full = binding.scatter(reduced); + const auto elements = binding.validated_elements(); + for (int element = 0; element < binding.mesh().GetNE(); ++element) { + ::mfem::Array dofs; + ::mfem::DofTransformation transformation; + binding.space().GetElementVDofs(element, dofs, transformation); + ::mfem::Vector expected, actual; + full.GetSubVector(dofs, expected); + if (transformation.GetDofTransformation() != nullptr) { + transformation.InvTransformPrimal(expected); + } + elements.element_values(element, reduced, actual); + CHECK(relative_error(actual, expected) < 1.0e-13); + + const auto coordinates = elements.coordinates(element); + REQUIRE(coordinates.size() == dofs.Size()); + for (int local = 0; local < dofs.Size(); ++local) { + const int full_index = dofs[local] < 0 ? -1 - dofs[local] : dofs[local]; + const int index = binding.reduced_dof(full_index).value_or(-1); + CHECK(coordinates.reduced_dof(local) == index); + if (index >= 0) { + CHECK(coordinates.local_dof(index) == local); + ::mfem::Vector unit = constant(binding.size(), 0.0), direction; + unit[index] = 1.0; + coordinates.element_values(unit, expected); + coordinates.unit_direction(local, direction); + CHECK(relative_error(direction, expected) < 1.0e-13); + } + } + + const auto dual = deterministic(dofs.Size(), 0.78); + ::mfem::Vector expected_dual(dual), actual_dual(dual); + auto expected_full = constant(binding.full_size(), 0.0); + auto assembled = constant(binding.full_size(), 0.0); + if (transformation.GetDofTransformation() != nullptr) { + transformation.TransformDual(expected_dual); + } + expected_full.AddElementVector(dofs, expected_dual); + elements.add_element(element, actual_dual, assembled); + CHECK(relative_error(assembled, expected_full) < 1.0e-13); + CHECK(full * assembled == Catch::Approx(actual * dual).margin(1.0e-12)); + + actual_dual = dual; + coordinates.orient_dual(actual_dual); + for (int local = 0; local < dofs.Size(); ++local) { + const double expected_sign = dofs[local] < 0 ? -1.0 : 1.0; + CHECK(actual_dual[local] == Catch::Approx(expected_sign * expected_dual[local]).margin(1.0e-14)); + } + } + } + } + + TEST_CASE("Validated Element Traversals Preserve Native MFEM Coordinates", tags::unit & tags::equations) { + for (const auto geometry : {::mfem::Element::HEXAHEDRON, ::mfem::Element::TETRAHEDRON}) { + for (const auto ordering : {::mfem::Ordering::byNODES, ::mfem::Ordering::byVDIM}) { + Fixture fixture(geometry, ordering); + check_element_coordinates(fixture.density_binding); + check_element_coordinates(fixture.enthalpy_binding); + check_element_coordinates(fixture.displacement_binding); + } + } + } + + TEST_CASE("Validated Element Traversals Preserve Global Scalar Support And Guards", tags::unit & tags::equations) { + Fixture fixture; + const auto& binding = fixture.constant_binding; + const auto elements = binding.validated_elements(); + const auto reduced = constant(1, 2.0); + for (int element = 0; element < fixture.mesh.GetNE(); ++element) { + ::mfem::Vector local; + elements.element_values(element, reduced, local); + CHECK(local[0] == (binding.supports(element) ? 2.0 : 0.0)); + auto assembled = constant(1, 0.0); + auto dual = constant(1, 3.0); + elements.add_element(element, dual, assembled); + CHECK(assembled[0] == (binding.supports(element) ? 3.0 : 0.0)); + } + ::mfem::Vector output, empty; + CHECK_THROWS_AS(elements.element_values(-1, reduced, output), std::out_of_range); + CHECK_THROWS_AS(elements.element_values(0, empty, output), std::invalid_argument); + CHECK_THROWS_AS(elements.add_element(0, empty, output), std::invalid_argument); + fixture.mesh.GetElement(0)->SetAttribute(3); + CHECK_THROWS_AS(binding.validated_elements(), std::logic_error); + CHECK_THROWS_AS(binding.element_values(1, reduced, output), std::logic_error); + CHECK_THROWS_AS(elements.element_values(0, reduced, output), std::logic_error); + } + + TEST_CASE("Validated Element Traversals Reject Changed Mesh Revisions", tags::unit & tags::equations) { + Fixture fixture; + const auto elements = fixture.enthalpy_binding.validated_elements(); + const auto values = fixture.enthalpy(); + fixture.mesh.UniformRefinement(); + ::mfem::Vector output; + CHECK_THROWS_AS(elements.element_values(0, values, output), std::logic_error); + CHECK_THROWS_AS(fixture.enthalpy_binding.validated_elements(), std::logic_error); + } +} // namespace serif::tests::equations diff --git a/tests/equations/surface_potential_test.cpp b/tests/equations/surface_potential_test.cpp new file mode 100644 index 00000000..c7b330aa --- /dev/null +++ b/tests/equations/surface_potential_test.cpp @@ -0,0 +1,93 @@ +#include +#include +#include + +#include "serif/tests/deformation/mesh.hpp" +#include "serif/backend/mfem/model/surface_potential.hpp" +#include "serif/backend/mfem/discretization/term_binding.hpp" +#include "serif/backend/mfem/geometry/deformation/reference_geometry.hpp" + +namespace { + namespace deformation = serif::geometry::deformation; + namespace backend = serif::model::backend::mfem; + using Potential = serif::discretization::blocks::gravity::potential; + + /** @brief Compare owned surface matrices with a coupled finite difference of the physical moments. */ + void check_surface(const int surface_order, const bool nodal) { + serif::tests::deformation::PairedMesh mesh(0, 0.1, surface_order); + ::mfem::H1_FECollection potential_collection(Potential::FunctionalSpace::order, 3); + ::mfem::FiniteElementSpace potential_space(mesh.generated.mesh.get(), &potential_collection); + serif::discretization::backend::mfem::TermBinding potential_binding(potential_space); + const auto reference = deformation::backend::mfem::sample_reference_surface(mesh.scalar_space, mesh.surface_attribute); + REQUIRE(reference.has_value()); + const auto embedding = [&] { + if (nodal) { + return deformation::build_surface_embedding(deformation::NodalRadialSurface{}, reference->positions); + } + return deformation::build_surface_embedding(deformation::SphericalHarmonicSurface{.maximum_degree = 1}, reference->positions); + }(); + REQUIRE(embedding.has_value()); + const deformation::backend::mfem::SurfaceDiscretization discretization{ + .scalar_space = mesh.scalar_space, .surface_attribute = mesh.surface_attribute}; + const serif::equations::SurfacePotentialBalance equation{{1.0, 2.0, 3.0}, {0.1, -0.2, 0.3}}; + std::vector shape(embedding->input_size()), displacement(embedding->output_size()); + ::mfem::Vector potential(potential_binding.size()), potential_direction(potential.Size()), shape_direction(static_cast(shape.size())); + for (int index = 0; index < potential.Size(); ++index) { + potential[index] = -1.0 + 0.02 * std::sin(index + 0.3); + potential_direction[index] = std::cos(index + 0.2); + } + for (int index = 0; index < shape_direction.Size(); ++index) { + shape[index] = 0.01 * std::sin(index + 0.7); + shape_direction[index] = 0.03 * std::cos(index + 0.4); + } + REQUIRE(embedding->apply(shape, displacement).has_value()); + const auto accepted = backend::assemble_surface_potential(equation, discretization, *reference, *embedding, + displacement, potential_binding, potential, -1.2, 0.4); + ::mfem::Vector action(shape_direction.Size()), temporary(action.Size()); + accepted.potential->Mult(potential_direction, action); + accepted.shape->Mult(shape_direction, temporary); + action += temporary; + for (int row = 0; row < action.Size(); ++row) { + action[row] += 0.2 * (*accepted.level)(row, 0) - 0.3 * (*accepted.angular_velocity)(row, 0); + } + constexpr double step = 2.0e-6; + auto plus_potential = potential, minus_potential = potential; + plus_potential.Add(step, potential_direction); + minus_potential.Add(-step, potential_direction); + auto plus_shape = shape, minus_shape = shape; + for (int index = 0; index < shape_direction.Size(); ++index) { + plus_shape[index] += step * shape_direction[index]; + minus_shape[index] -= step * shape_direction[index]; + } + REQUIRE(embedding->apply(plus_shape, displacement).has_value()); + const auto plus = backend::assemble_surface_potential(equation, discretization, *reference, *embedding, + displacement, potential_binding, plus_potential, -1.2 + step * 0.2, 0.4 - step * 0.3); + REQUIRE(embedding->apply(minus_shape, displacement).has_value()); + const auto minus = backend::assemble_surface_potential(equation, discretization, *reference, *embedding, + displacement, potential_binding, minus_potential, -1.2 - step * 0.2, 0.4 + step * 0.3); + for (int row = 0; row < action.Size(); ++row) { + CHECK(std::abs(action[row] - (plus.residual[row] - minus.residual[row]) / (2.0 * step)) < 2.0e-9); + } + + // Constant potential and zero rotation give a known normalized moment, independent of surface area. + potential = -0.8; + const auto constant = backend::assemble_surface_potential(equation, discretization, *reference, *embedding, + displacement, potential_binding, potential, -1.2, 0.0); + double moment = 0.0; + if (nodal) { + for (const double value : constant.residual) { moment += value; } + CHECK(std::abs(moment - 0.4) < 3.0e-12); + } else { + CHECK(std::abs(constant.residual.front() - 0.4 / std::sqrt(4.0 * std::numbers::pi)) < 3.0e-12); + } + constant.shape->Mult(shape_direction, temporary); + CHECK(temporary.Norml2() == 0.0); + } +} + +TEST_CASE("Isobar potential moments preserve constant mode and differentiate pullback geometry", "[model][surface_potential][unit]") { + for (const int surface_order : {2, 3}) { + check_surface(surface_order, false); + check_surface(surface_order, true); + } +} diff --git a/tests/equations/tensor_basis.cpp b/tests/equations/tensor_basis.cpp new file mode 100644 index 00000000..d923ce5d --- /dev/null +++ b/tests/equations/tensor_basis.cpp @@ -0,0 +1,248 @@ +#include + +#include +#include "mfem/fem/fe_coll.hpp" +#include "serif/backend/mfem/discretization/basis_evaluation.hpp" + +namespace +{ + using namespace serif::discretization::backend::mfem; + + /// Compare prepared tensor or mapped actions against independently sampled dense matrices. + void check_actions(const BasisEvaluation& tensor, const BasisEvaluation& dense, const double tolerance) { + ::mfem::Vector dofs(tensor.width()), dual(tensor.height()), values, reference, assembled, reference_transpose; + for (int i = 0; i < dofs.Size(); ++i) { + dofs[i] = std::sin(0.53 * (i + 1)); + } + for (int i = 0; i < dual.Size(); ++i) { + dual[i] = std::cos(0.31 * (i + 1)); + } + BasisWorkspace workspace; + for (int repeat = 0; repeat < 2; ++repeat) { + if (repeat) { + dofs = 0.0; + dual = 0.0; + } + tensor.evaluate(dofs, values, workspace); + dense.evaluate(dofs, reference, workspace); + ::mfem::Vector error = values; + error -= reference; + CHECK(error.Normlinf() <= tolerance * std::max(1.0, reference.Normlinf())); + tensor.integrate(dual, assembled, workspace); + dense.integrate(dual, reference_transpose, workspace); + error = assembled; + error -= reference_transpose; + CHECK(error.Norml2() <= tolerance * std::max(1.0, reference_transpose.Norml2())); + CHECK(std::abs(values * dual - dofs * assembled) <= tolerance * std::max(1.0, std::abs(values * dual))); + if (repeat) { + CHECK(values.Norml2() == 0.0); + CHECK(assembled.Norml2() == 0.0); + } + } + } + + /// Compare independent native MFEM basis matrices with the factored contractions and their transposes. + void check_basis(const ::mfem::FiniteElement& element, const ::mfem::IntegrationRule& rule, ReferenceOperation operation, int copies, double tolerance) { + TensorBasisCache cache; + BasisEvaluation tensor, dense; + tensor.sample(element, rule, operation, copies, cache, ContractionMode::automatic); + dense.sample(element, rule, operation, copies, cache, ContractionMode::dense); + REQUIRE(tensor.uses_tensor()); + REQUIRE_FALSE(dense.uses_tensor()); + check_actions(tensor, dense, tolerance); + } + +} + +TEST_CASE("Scalar and vector H1 tensor values gradients and transposes retain MeanField accuracy", "[equations][tensor_basis][unit]") +{ + // MeanField tests/fem/reference_tables.cpp: retain its orders, bases and thresholds. + for (const int order : {1, 2, 4}) + { + for (const int basis : {::mfem::BasisType::GaussLobatto, ::mfem::BasisType::ClosedUniform}) + { + ::mfem::H1_FECollection h1(order, 3, basis); + const auto& element = *h1.FiniteElementForGeometry(::mfem::Geometry::CUBE); + const auto& rule = ::mfem::IntRules.Get(::mfem::Geometry::CUBE, 2 * order + 3); + for (const int copies : {1, 2, 3}) + { + check_basis(element, rule, ReferenceOperation::value, copies, 2.0e-12); + check_basis(element, rule, ReferenceOperation::gradient, copies, 2.0e-11); + check_basis(element, rule, ReferenceOperation::value_and_gradient, copies, 2.0e-12); + } + } + } + for (const int order : {0, 1, 3}) + { + for (const int basis : {::mfem::BasisType::GaussLegendre, ::mfem::BasisType::OpenUniform}) + { + ::mfem::L2_FECollection l2(order, 3, basis); + const auto& element = *l2.FiniteElementForGeometry(::mfem::Geometry::CUBE); + const auto& rule = ::mfem::IntRules.Get(::mfem::Geometry::CUBE, 2 * order + 3); + for (const int copies : {1, 2, 3}) + { + check_basis(element, rule, ReferenceOperation::value, copies, 2.0e-12); + check_basis(element, rule, ReferenceOperation::gradient, copies, 2.0e-11); + } + } + } +} + +TEST_CASE("Tensor plans own their factors and reject non Cartesian rules without changing quadrature", "[equations][tensor_basis][unit]") +{ + ::mfem::H1_FECollection h1(3, 3); + const auto& element = *h1.FiniteElementForGeometry(::mfem::Geometry::CUBE); + const auto& original = ::mfem::IntRules.Get(::mfem::Geometry::CUBE, 9); + ::mfem::IntegrationRule copy(original.GetNPoints()); + for (int q = 0; q < copy.GetNPoints(); ++q) + { + copy.IntPoint(q) = original.IntPoint(q); + } + TensorBasisCache cache; + auto retained = cache.get(element, copy, ReferenceOperation::value, 1); + REQUIRE(retained); + CHECK(cache.get(element, original, ReferenceOperation::value, 1) == retained); + CHECK(cache.size() == 1); + copy.IntPoint(copy.GetNPoints() - 1).x += 0.017; + CHECK_FALSE(cache.get(element, copy, ReferenceOperation::value, 1)); + CHECK(cache.size() == 2); + cache.clear(); + CHECK(cache.size() == 0); + ::mfem::Vector dofs(retained->dofs()), values; + dofs = 1.0; + TensorWorkspace workspace; + retained->apply(dofs, values, workspace); + for (int q = 0; q < values.Size(); ++q) + { + CHECK(std::abs(values[q] - 1.0) < 2.0e-12); + } + + const auto& tetrahedron = *h1.FiniteElementForGeometry(::mfem::Geometry::TETRAHEDRON); + const auto& tetrahedron_rule = ::mfem::IntRules.Get(::mfem::Geometry::TETRAHEDRON, 9); + CHECK_FALSE(TensorBasis::make(tetrahedron, tetrahedron_rule, ReferenceOperation::value, 1)); + CHECK_FALSE(TensorBasis::make(element, original, ReferenceOperation::value, 0)); + BasisEvaluation fallback; + fallback.sample(element, copy, ReferenceOperation::value, 1, cache, ContractionMode::automatic); + CHECK_FALSE(fallback.uses_tensor()); +} + +TEST_CASE("Reused basis actions retain mappings through composition resampling and dense fallback", "[equations][tensor_basis][unit]") { + ::mfem::H1_FECollection h1(3, 3); + const auto& element = *h1.FiniteElementForGeometry(::mfem::Geometry::CUBE); + const auto& rule = ::mfem::IntRules.Get(::mfem::Geometry::CUBE, 9); + TensorBasisCache cache; + BasisEvaluation reference, dense_reference; + reference.sample(element, rule, ReferenceOperation::value_and_gradient, 3, cache, ContractionMode::automatic); + dense_reference.sample(element, rule, ReferenceOperation::value_and_gradient, 3, cache, ContractionMode::dense); + BasisEvaluation tensor = reference; + BasisEvaluation dense = dense_reference; + BasisWorkspace workspace; + + // Direct mapping of a sampled basis must disable its canonical shortcut, even without reset_from. + for (int point = 0; point < rule.GetNPoints(); ++point) { + const auto map = [point](const ::mfem::Vector& input, ::mfem::Vector& output) { + for (int component = 0; component < output.Size(); ++component) { + output[component] = (1.0 + 0.01 * point) * input[component] - 0.2 * input[(component + 1) % input.Size()]; + } + }; + tensor.map_point(point, reference, map, workspace); + dense.map_point(point, dense_reference, map, workspace); + } + check_actions(tensor, dense, 2.0e-12); + + // A mapped reference remains usable when the next physical map changes component count. + reference = tensor; + dense_reference = dense; + tensor.reset_from(reference, 5); + dense.reset_from(dense_reference, 5); + for (int point = 0; point < rule.GetNPoints(); ++point) { + const auto map = [](const ::mfem::Vector& input, ::mfem::Vector& output) { + for (int component = 0; component < output.Size(); ++component) { + output[component] = 0.7 * input[component] + 0.4 * input[component + 5]; + } + }; + tensor.map_point(point, reference, map, workspace); + dense.map_point(point, dense_reference, map, workspace); + } + check_actions(tensor, dense, 2.0e-12); + + // Reusing the same object for canonical, dense and constant bases must clear the earlier representation. + for (const ContractionMode mode : {ContractionMode::automatic, ContractionMode::dense, ContractionMode::automatic}) { + tensor.sample(element, rule, ReferenceOperation::gradient, 2, cache, mode); + dense.sample(element, rule, ReferenceOperation::gradient, 2, cache, ContractionMode::dense); + check_actions(tensor, dense, 2.0e-11); + } + tensor.set_constant(rule.GetNPoints()); + dense.set_constant(rule.GetNPoints()); + check_actions(tensor, dense, 2.0e-12); +} + +TEST_CASE("Canonical mapped and dense basis actions preserve aliased inputs while resizing", "[equations][tensor_basis][unit]") { + ::mfem::H1_FECollection h1(2, 3); + const auto& element = *h1.FiniteElementForGeometry(::mfem::Geometry::CUBE); + TensorBasisCache cache; + BasisWorkspace workspace; + for (const int order : {5, 7}) { + const auto& rule = ::mfem::IntRules.Get(::mfem::Geometry::CUBE, order); + for (const auto mode : {ContractionMode::automatic, ContractionMode::dense}) { + CAPTURE(order, mode); + BasisEvaluation reference, dense_reference; + reference.sample(element, rule, ReferenceOperation::value, 1, cache, mode); + dense_reference.sample(element, rule, ReferenceOperation::value, 1, cache, ContractionMode::dense); + ::mfem::Vector input(reference.width()), dual(reference.height()), expected, aliased; + for (int index = 0; index < input.Size(); ++index) { + input[index] = std::sin(0.53 * (index + 1)); + } + for (int index = 0; index < dual.Size(); ++index) { + dual[index] = std::cos(0.31 * (index + 1)); + } + for (const bool mapped : {false, true}) { + BasisEvaluation basis = reference; + BasisEvaluation dense = dense_reference; + if (mapped) { + for (int point = 0; point < rule.GetNPoints(); ++point) { + const auto map = [point](const ::mfem::Vector& source, ::mfem::Vector& destination) { + destination[0] = (1.0 + 0.03 * point) * source[0]; + }; + basis.map_point(point, reference, map, workspace); + dense.map_point(point, dense_reference, map, workspace); + } + } + dense.evaluate(input, expected, workspace); + aliased = input; + basis.evaluate(aliased, aliased, workspace); + aliased -= expected; + CHECK(aliased.Normlinf() <= 2.0e-12); + dense.integrate(dual, expected, workspace); + aliased = dual; + basis.integrate(aliased, aliased, workspace); + aliased -= expected; + CHECK(aliased.Normlinf() <= 2.0e-12); + } + } + } +} + +TEST_CASE("Implicit canonical identities preserve untouched points and support in place maps", "[equations][tensor_basis][unit]") { + ::mfem::H1_FECollection h1(2, 3); + const auto& element = *h1.FiniteElementForGeometry(::mfem::Geometry::CUBE); + const auto& rule = ::mfem::IntRules.Get(::mfem::Geometry::CUBE, 7); + TensorBasisCache cache; + BasisWorkspace workspace; + BasisEvaluation tensor, dense; + // More than twelve components ensures mapping scratch does not restrict generality. + tensor.sample(element, rule, ReferenceOperation::value_and_gradient, 4, cache, ContractionMode::automatic); + dense.sample(element, rule, ReferenceOperation::value_and_gradient, 4, cache, ContractionMode::dense); + REQUIRE(tensor.uses_tensor()); + const auto map = [](const ::mfem::Vector& input, ::mfem::Vector& output) { + for (int component = 0; component < output.Size(); ++component) { + output[component] = 0.8 * input[component] + 0.3 * input[(component + 1) % input.Size()]; + } + }; + // A first map materializes only the destination representation; later maps compose with it. + for (const int point : {3, 0, 3}) { + tensor.map_point(point, tensor, map, workspace); + dense.map_point(point, dense, map, workspace); + check_actions(tensor, dense, 2.0e-11); + } +} diff --git a/tests/include/serif/tests/deformation/mesh.hpp b/tests/include/serif/tests/deformation/mesh.hpp new file mode 100644 index 00000000..625a0140 --- /dev/null +++ b/tests/include/serif/tests/deformation/mesh.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include +#include "mfem/fem/fespace.hpp" +#include "serif/discretization/domain/schema/schemas.hpp" +#include "serif/backend/mfem/geometry/deformation/radial_interpolation.hpp" + +namespace serif::tests::deformation { + namespace geometry = serif::geometry::deformation; + namespace backend = geometry::backend::mfem; + using Schema = discretization::domain::schema::CoreEnvelopeVacuumDomainSchema; + + /** @brief Preserve paired STROID reference meshes and the old shell-radius fixture. */ + [[nodiscard]] inline stroid::StroidMesh make_mesh(int refinement = 0, double flattening = 0.0, int geometry_order = 2, double infinity_radius = 4.0) { + stroid::config::MeshConfig config; + config.refinement_levels = refinement; + config.order = geometry_order; + config.include_external_domain = true; + config.r_core = 0.25; + config.r_star = 1.0; + config.r_infinity = infinity_radius; + config.flattening = flattening; + config.optimization_methods = stroid::config::OptimizationMethods{false, true}; + return stroid::GenerateMesh(config); + } + + /** @brief Keep both scalar and vector spaces on the same nodal collection and paired geometry. */ + struct PairedMesh { + stroid::StroidMesh generated; + ::mfem::H1_FECollection collection; + ::mfem::FiniteElementSpace scalar_space; + ::mfem::FiniteElementSpace vector_space; + std::vector stellar_elements; + std::vector vacuum_elements; + static constexpr int surface_attribute = Schema::boundary_id(); + static constexpr int infinity_attribute = Schema::boundary_id(); + + /** @brief Construct mesh data only for independent backend regression fixtures. */ + explicit PairedMesh(int refinement = 0, double flattening = 0.0, int displacement_order = 2, int geometry_order = 2, double infinity_radius = 4.0) + : generated(make_mesh(refinement, flattening, geometry_order, infinity_radius)), collection(displacement_order, 3), scalar_space(generated.mesh.get(), &collection), vector_space(generated.mesh.get(), &collection, 3, ::mfem::Ordering::byNODES), stellar_elements(generated.mesh->GetNE()), vacuum_elements(generated.mesh->GetNE()) { + for (int element = 0; element < generated.mesh->GetNE(); ++element) { + const bool vacuum = generated.mesh->GetAttribute(element) == Schema::domain_id(); + stellar_elements[element] = !vacuum; + vacuum_elements[element] = vacuum; + } + } + + /** @brief Exercise the public backend compilation entry point without a second test implementation. */ + [[nodiscard]] std::expected interpolation() { + return backend::build_radial_interpolation(scalar_space, vector_space, *generated.reference_mesh, stellar_elements, vacuum_elements, surface_attribute, infinity_attribute); + } + }; +} diff --git a/tests/include/serif/tests/discritization/domain/domain_test_utils.hpp b/tests/include/serif/tests/discritization/domain/domain_test_utils.hpp index 4ce1edd6..b236867c 100644 --- a/tests/include/serif/tests/discritization/domain/domain_test_utils.hpp +++ b/tests/include/serif/tests/discritization/domain/domain_test_utils.hpp @@ -6,7 +6,7 @@ #include #include -#include +#include "mfem/mesh/mesh.hpp" #include #include "serif/discretization/domain/ids/boundary.hpp" @@ -16,7 +16,7 @@ #include "serif/discretization/domain/relation/lists/relation_list.hpp" #include "serif/discretization/domain/relation/relations.hpp" #include "serif/discretization/domain/schema/domain_schema.hpp" -#include "serif/discretization/domain/schema/validation/all.hpp" +#include "serif/backend/mfem/discretization/schema.hpp" #include "serif/discretization/domain/types.hpp" namespace domain_test_utils { @@ -281,7 +281,7 @@ namespace domain_test_utils { template void check_schema_is_valid(const mfem::Mesh &mesh) { - const auto validation = domain::schema::validation::validate_schema(mesh); + const auto validation = serif::backend::mfem::discretization::validate_schema(mesh); CHECK(validation.relationResults.size() == SchemaT::relation_count); diff --git a/tests/include/serif/tests/equations/barotropic_eos.hpp b/tests/include/serif/tests/equations/barotropic_eos.hpp new file mode 100644 index 00000000..e4aabaf7 --- /dev/null +++ b/tests/include/serif/tests/equations/barotropic_eos.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include + +#include "serif/dimensions/partial.hpp" +#include "serif/dimensions/type_alias.hpp" +#include "serif/eos/relations/relations.hpp" + +namespace serif::tests::equations { + /** @brief A nonpolytropic barotrope P = rho^2 + rho^4 with no enthalpy relation or polytropic parameters. */ + struct MixedPowerBarotrope final { + using Relations = eos::relations::RelationCatalog; + double gauge = 0.0; + + /** @brief Evaluate the independently specified pressure law at the tested nonnegative densities. */ + [[nodiscard]] dimensions::PressureValue evaluate(eos::relations::PressureFromDensity, const dimensions::DensityValue density) const { + const double squared_density = density.value() * density.value(); + return dimensions::PressureValue{squared_density + squared_density * squared_density}; + } + + /** @brief Invert the pressure law using the rationalized positive root. */ + [[nodiscard]] dimensions::DensityValue evaluate(eos::relations::DensityFromPressure, const dimensions::PressureValue pressure) const { + return dimensions::DensityValue{std::sqrt(2.0 * pressure.value() / (1.0 + std::sqrt(1.0 + 4.0 * pressure.value())))}; + } + + /** @brief Integrate P_rho/rho and expose an arbitrary additive constant. */ + [[nodiscard]] dimensions::BarotropicPressurePotentialValue evaluate( + eos::relations::PressurePotentialFromDensity, const dimensions::DensityValue density) const { + return dimensions::BarotropicPressurePotentialValue{2.0 * density.value() + + (4.0 / 3.0) * density.value() * density.value() * density.value() + gauge}; + } + + /** @brief Supply the only thermodynamic derivative required by the density equation. */ + [[nodiscard]] dimensions::PartialDerivative partial_derivative( + eos::relations::PressureFromDensity, dimensions::WithRespectTo, const dimensions::DensityValue density) const { + return dimensions::PartialDerivative{ + 2.0 * density.value() + 4.0 * density.value() * density.value() * density.value()}; + } + }; +} diff --git a/tests/include/serif/tests/equations/fixture.hpp b/tests/include/serif/tests/equations/fixture.hpp new file mode 100644 index 00000000..79e7bab6 --- /dev/null +++ b/tests/include/serif/tests/equations/fixture.hpp @@ -0,0 +1,78 @@ +#pragma once + +#include +#include + +#include "serif/discretization/domain/schema/schemas.hpp" +#include "serif/tests/equations/test_equations.hpp" +#include "serif/backend/mfem/operators/jacobian_operator.hpp" +#include "serif/tests/equations/volume_equation_fixture.hpp" + +namespace serif::tests::equations { + namespace backend = serif::operators::backend::mfem; + namespace blocks = discretization::blocks; + using Schema = discretization::domain::schema::CoreEnvelopeVacuumDomainSchema; + using Closure = ::serif::tests::equations::BarotropicClosure; + using PreparedClosure = VolumeEquationFixture; + + ::mfem::Mesh make_mesh(::mfem::Element::Type geometry); + ::mfem::Vector constant(int size, double value); + ::mfem::Vector deterministic(int size, double phase); + ::mfem::Vector packed( + const ::mfem::Vector& density, + const ::mfem::Vector& enthalpy, + const ::mfem::Vector& displacement + ); + double relative_error(const ::mfem::Vector& actual, const ::mfem::Vector& expected); + + struct Fixture { + ::mfem::Mesh mesh; + ::mfem::H1_FECollection density_collection{blocks::density::mass::FunctionalSpace::order, 3}; + ::mfem::H1_FECollection enthalpy_collection{::serif::tests::equations::Enthalpy::FunctionalSpace::order, 3}; + ::mfem::H1_FECollection potential_collection{blocks::gravity::potential::FunctionalSpace::order, 3}; + ::mfem::FiniteElementSpace density_space; + ::mfem::FiniteElementSpace enthalpy_space; + ::mfem::FiniteElementSpace potential_space; + ::mfem::FiniteElementSpace displacement_space; + ::mfem::GridFunction compactification; + discretization::backend::mfem::TermBinding density_binding; + discretization::backend::mfem::TermBinding<::serif::tests::equations::Enthalpy, Schema> enthalpy_binding; + discretization::backend::mfem::TermBinding displacement_binding; + discretization::backend::mfem::TermBinding constant_binding{mesh}; + discretization::backend::mfem::TermBinding angular_velocity_binding{mesh}; + mapping::DomainMapper mapper; + backend::Workspace workspace; + + explicit Fixture( + ::mfem::Element::Type geometry = ::mfem::Element::HEXAHEDRON, + ::mfem::Ordering::Type ordering = ::mfem::Ordering::byNODES + ); + /// Reuse the registered spaces and bindings on an explicitly supplied conforming mesh. + explicit Fixture(::mfem::Mesh mesh, ::mfem::Ordering::Type ordering = ::mfem::Ordering::byNODES); + [[nodiscard]] ::mfem::Vector density(double phase = 0.0); + [[nodiscard]] ::mfem::Vector enthalpy(double phase = 0.0); + [[nodiscard]] ::mfem::Vector displacement(double scale); + [[nodiscard]] ::mfem::Vector project( + const discretization::backend::mfem::DofMap& field, + std::function function + ); + [[nodiscard]] auto state(const ::mfem::Vector& density, const ::mfem::Vector& enthalpy) const { + return backend::make_term_coefficients( + backend::bind(density_binding, density), backend::bind(enthalpy_binding, enthalpy) + ); + } + }; + + // Independent, direct-quadrature port of MeanField's manual closure kernels. + // Uses the analytic polytropic formula, not the new equation evaluator or bases. + ::mfem::Vector manual_closure( + Fixture& fixture, + const eos::models::Polytrope& eos, + const ::mfem::Vector& density, + const ::mfem::Vector& enthalpy, + const ::mfem::Vector& displacement, + const ::mfem::Vector* density_direction = nullptr, + const ::mfem::Vector* enthalpy_direction = nullptr, + const ::mfem::Vector* displacement_direction = nullptr + ); +} // namespace serif::tests::equations diff --git a/tests/include/serif/tests/equations/gravity_fixture.hpp b/tests/include/serif/tests/equations/gravity_fixture.hpp new file mode 100644 index 00000000..71333d1c --- /dev/null +++ b/tests/include/serif/tests/equations/gravity_fixture.hpp @@ -0,0 +1,106 @@ +#pragma once + +#include +#include +#include "serif/tests/equations/fixture.hpp" +#include "serif/equations/primal_gravity.hpp" +#include "serif/backend/mfem/mapping/evaluator.hpp" + +// Shared fixture and independent mapping reference for potential, density and geometry backend tests. +namespace serif::tests::equations::gravity { + using Potential = blocks::gravity::potential; + using Density = blocks::density::mass; + using DensityValue = discretization::forms::Operand; + using PotentialRow = serif::equations::PrimalPoissonBalance; + using VectorValue = discretization::forms::VectorValue; + constexpr int reference_order = 12; + constexpr double four_pi_G = 2.3; + + /// Use the same explicitly chosen rule in the backend and independent assembled forms. + inline discretization::quadrature::Policy reference_policy() { + discretization::quadrature::RuleSet rules; + rules.set_fallback_control({.fixed_order = reference_order}); + return discretization::quadrature::Policy{rules}; + } + + struct GravityFixture : Fixture { + discretization::backend::mfem::TermBinding potential_binding{potential_space}; + using Fixture::Fixture; + + /// Bind distinct potential and density coefficient vectors. + auto state(const ::mfem::Vector& potential, const ::mfem::Vector& density) const { + return backend::make_term_coefficients(backend::bind(potential_binding, potential), backend::bind(density_binding, density)); + } + /// Use a quadratic potential representable in the registered H1 space. + ::mfem::Vector potential() { + return project(potential_binding, [](const ::mfem::Vector& x) { return -0.5 + 0.1 * x[0] * x[1] - 0.07 * x[2] * x[2]; }); + } + /// Supply a valid nonconstant exterior coordinate without a mesh-generator dependency. + void set_compactification() { + ::mfem::FunctionCoefficient coordinate([](const ::mfem::Vector& x) { return 0.2 + 0.15 * x[0]; }); + compactification.ProjectCoefficient(coordinate); + } + }; + + // Evaluate the shared mapping module independently of the operator backend. + struct ReferenceGeometry + { + GravityFixture& fixture; + const mapping::DomainMapper& mapper; + const ::mfem::Vector& displacement; + + /** + * @brief Freeze the supported displacement once and reuse the production element mapping cache. + * @note This reference represents one fixed numerical state. Construct another instance after changing displacement or compactification coefficients. + */ + ReferenceGeometry(GravityFixture& fixture, const mapping::DomainMapper& mapper, const ::mfem::Vector& displacement) : + fixture(fixture), + mapper(mapper), + displacement(displacement), + m_displacement(&fixture.displacement_space), + m_evaluator(mapper, m_displacement, fixture.compactification) + { + m_displacement = fixture.displacement_binding.scatter(displacement); + } + + /** @brief The evaluator borrows its owned grid function, so its reference object keeps a stable address. */ + ReferenceGeometry(const ReferenceGeometry&) = delete; + ReferenceGeometry& operator=(const ReferenceGeometry&) = delete; + ReferenceGeometry(ReferenceGeometry&&) = delete; + ReferenceGeometry& operator=(ReferenceGeometry&&) = delete; + + /** @brief Evaluate only the shared mapping module for an independent MFEM quadrature coefficient. */ + void evaluate(::mfem::ElementTransformation& transformation, const ::mfem::IntegrationPoint& point, mapping::VolumeMappingContext& context) + { + REQUIRE(m_evaluator.evaluate_volume(transformation, point, context) == mapping::MappingStatus::VALID); + } + + /** @brief Expose compactified-infinity status to coefficients with a known boundary limit. */ + [[nodiscard]] mapping::MappingStatus try_evaluate_point(::mfem::ElementTransformation& transformation, + const ::mfem::IntegrationPoint& point, mapping::MappingPointContext& context) + { + return m_evaluator.evaluate_point(transformation, point, context); + } + + /** @brief Map finite interpolation nodes without requiring a positive quadrature weight. */ + void evaluate_point(::mfem::ElementTransformation& transformation, const ::mfem::IntegrationPoint& point, mapping::MappingPointContext& context) + { + REQUIRE(try_evaluate_point(transformation, point, context) == mapping::MappingStatus::VALID); + } + + private: + ::mfem::GridFunction m_displacement; + mapping::GridFunctionMappingEvaluator m_evaluator; + }; + struct ReferenceVolume : ::mfem::Coefficient { + ReferenceGeometry& geometry; + /// Borrow the geometry shared with the vector mass coefficient. + explicit ReferenceVolume(ReferenceGeometry& geometry) : geometry(geometry) {} + /// The source's pulled-back weight is j; MFEM supplies the mesh Jacobian. + double Eval(::mfem::ElementTransformation& transformation, const ::mfem::IntegrationPoint& point) override { + mapping::VolumeMappingContext context; + geometry.evaluate(transformation, point, context); + return context.mapping.mapping_determinate; + } + }; +} // namespace serif::tests::equations::gravity diff --git a/tests/include/serif/tests/equations/gravity_port.hpp b/tests/include/serif/tests/equations/gravity_port.hpp new file mode 100644 index 00000000..572ec7fd --- /dev/null +++ b/tests/include/serif/tests/equations/gravity_port.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include +#include +#include "serif/tests/equations/gravity_fixture.hpp" + +namespace serif::tests::equations::gravity_port { + using namespace gravity; + using Vector = ::mfem::Vector; + + struct CompactFixture : GravityFixture { + /// Keep the vacuum branch active in all inexpensive Cartesian regression cases. + CompactFixture() { + set_compactification(); + } + }; + + struct Coefficients { + Vector potential, density, displacement; + }; + + /// Return independently owned zero coefficients of every required size. + Coefficients zeros(const GravityFixture& fixture); + /// Preserve MeanField's fixed-geometry base/direction coefficient sequences. + Coefficients state(GravityFixture& fixture, bool deformed = true); + /// Density (column 0) or potential (other columns) direction, using the original amplitudes and phases. + Coefficients direction(GravityFixture& fixture, int column); + /// Preserve the original componentwise sinusoidal coefficient sequence. + Vector sequence(int size, double scale, double phase); + /// Preserve gravity_prepared_test_utils::make_deterministic_vector exactly. + Vector prepared_sequence(int size, double phase); + /// Preserve gravity_field.cpp::make_test_vector exactly. + Vector operator_sequence(int size, double phase); + /// Preserve MeanField's displacement used by prepared operator comparisons. + Vector prepared_displacement(GravityFixture& fixture, double scale); + /// Project a vector coefficient through MFEM, preserving each binding's representation and ordering. + Vector project_vector(const discretization::backend::mfem::DofMap& binding, std::function function); + /// Affine displacement for folding candidates and independent prepared states. + Vector affine_displacement(GravityFixture& fixture, double scale); + /// Preserve the nonlinear geometry used by the old stateless-kernel tests. + Vector kernel_displacement(GravityFixture& fixture); + /// Preserve both old geometry-direction polynomials, including mixed components. + Vector geometry_direction(GravityFixture& fixture, int index = 0); + /// Preserve both old polynomial source densities. + Vector kernel_density(GravityFixture& fixture, bool secondary = false); + /// Select native coefficients active in the star, or their vacuum-only complement. + Vector domain_density(GravityFixture& fixture, bool stellar); + /// Curved Stroid geometry with core, envelope, and nonconstant Kelvin exterior coordinate. + std::unique_ptr stellar_fixture(int refinement_levels = 0, int geometry_order = 2); + /// Add scaled coefficient directions without aliasing the base state. + Coefficients shifted(const Coefficients& base, const Coefficients& delta, double scale); + /// Reference-relative error from the original operator/kernel tests, with their original floor. + double relative(const Vector& actual, const Vector& expected, double floor = 1.0e-14); + /// Symmetric relative difference used by the old residual identity tests. + double symmetric_relative(const Vector& first, const Vector& second); + /// Prepared-test relative error uses machine epsilon, not an enlarged denominator. + double prepared_relative(const Vector& actual, const Vector& expected); + /// Scalar pairing error, preserving the original prepared-test denominator. + double pairing_relative(double actual, double expected); + /// Return a linear combination with the original summation order. + Vector combination(const Vector& first, double a, const Vector& second, double b); +} // namespace serif::tests::equations::gravity_port diff --git a/tests/include/serif/tests/equations/test_equations.hpp b/tests/include/serif/tests/equations/test_equations.hpp new file mode 100644 index 00000000..e03903c1 --- /dev/null +++ b/tests/include/serif/tests/equations/test_equations.hpp @@ -0,0 +1,105 @@ +#pragma once + +/** + * @file test_equations.hpp + * @brief Test-only fields and equations that exercise the shared volume infrastructure. + * + * SERiF's production formulation carries density, potential, shape and global scalars. These definitions add a + * second scalar H1 field and a small closure equation on it so that operator, cache and graph tests keep + * an independent two-field vehicle with an analytic reference. They are not a physical formulation. + */ + +#include +#include +#include +#include +#include + +#include "serif/discretization/blocks/fields.hpp" +#include "serif/eos/evaluation.hpp" +#include "serif/eos/exceptions.hpp" +#include "serif/eos/models/polytropic.hpp" +#include "serif/equations/contributions.hpp" +#include "serif/equations/point.hpp" +#include "serif/utils/error/errors.hpp" + +namespace serif::tests::equations { + /** @brief A third-order scalar H1 test field supported on the stellar domains. */ + struct enthalpy final : discretization::blocks::field { + static constexpr std::string_view name = meta::type_name(); + + struct specific final : discretization::blocks::term { + static constexpr std::string_view name = meta::type_name(); + using FunctionalSpace = discretization::functional_space::DiscreteFunctionalSpace; + using Support = discretization::domain::StellarDomains; + using Quantity = dimensions::SpecificEnthalpy; + static constexpr std::string_view symbol = "h"; + + struct value final : discretization::blocks::value_block_base {}; + struct residual final : discretization::blocks::residual_block_base {}; + }; + using Terms = utils::types::TypeList; + }; + using Enthalpy = enthalpy::specific; + + /** @brief The volume test equation rho - rho_EOS(h) = 0 on the stellar domains, with its analytic tangent. */ + template + requires eos::models::EOSSupportingRelation && + eos::models::EOSSupportingPartialDerivative + class BarotropicClosure final { + public: + using Test = discretization::blocks::density::mass; + using ResidualQuantity = dimensions::MassDensity; + using Enthalpy = equations::Enthalpy; + using Operands = utils::types::TypeList, discretization::forms::Operand>; + static constexpr std::string_view name = "barotropic closure"; + + /** @brief Store the EOS and the positive exponent used only to choose quadrature. */ + BarotropicClosure(EOST equation_of_state, const double enthalpy_power) : m_eos(std::move(equation_of_state)), m_enthalpy_power(enthalpy_power) { + using namespace utils::errors; + serif_ensure(std::isfinite(enthalpy_power) && enthalpy_power > 0.0, + "The density quadrature power must be positive and finite."); + } + + /** @brief Use the polytropic index as the quadrature exponent. */ + explicit BarotropicClosure(EOST equation_of_state) requires std::same_as + : BarotropicClosure(equation_of_state, equation_of_state.polytropic_index()) {} + + /** @brief Evaluate rho - rho_EOS(h) and its derivatives. */ + [[nodiscard]] serif::equations::PointLinearization evaluate(const serif::equations::EquationPoint& point) const { + using namespace utils::errors; + const double density = point.fields.template get(); + const double enthalpy = point.fields.template get(); + serif_ensure(std::isfinite(density) && std::isfinite(enthalpy), "Closure requires finite density and enthalpy."); + serif_ensure(enthalpy >= 0.0, "Stellar closure requires non-negative enthalpy."); + const dimensions::SpecificEnthalpyValue input{enthalpy}; + const double eos_density = eos::evaluate(m_eos, input).value(); + const double density_derivative = eos::partial_derivative(m_eos, input).value(); + serif_ensure(std::isfinite(eos_density) && std::isfinite(density_derivative), "Barotropic closure received a non-finite EOS result."); + + serif::equations::PointLinearization result; + result.residual = density - eos_density; + result.field_derivatives.template get() = 1.0; + result.field_derivatives.template get() = -density_derivative; + return result; + } + + /** @brief Raise the enthalpy operand's quadrature by (power - 1) times its order. */ + template + [[nodiscard]] int additional_order(OperandT, int) const { + if constexpr (std::same_as) { + return static_cast(std::ceil(std::max(0.0, m_enthalpy_power - 1.0) * Enthalpy::FunctionalSpace::order)); + } + return 0; + } + + /** @brief Inspect the EOS defining this closure. */ + [[nodiscard]] const EOST& equation_of_state() const noexcept { return m_eos; } + /** @brief Inspect the declared quadrature power. */ + [[nodiscard]] double enthalpy_power() const noexcept { return m_enthalpy_power; } + + private: + EOST m_eos; + double m_enthalpy_power; + }; +} // namespace serif::tests::equations diff --git a/tests/include/serif/tests/equations/volume_equation_fixture.hpp b/tests/include/serif/tests/equations/volume_equation_fixture.hpp new file mode 100644 index 00000000..9758586b --- /dev/null +++ b/tests/include/serif/tests/equations/volume_equation_fixture.hpp @@ -0,0 +1,193 @@ +#pragma once + +#include "serif/utils/error/errors.hpp" +#include +#include +#include + +#include "serif/backend/mfem/operators/volume_computations.hpp" +#include "serif/backend/mfem/operators/volume_application.hpp" + +namespace serif::tests::equations { + namespace backend = serif::operators::backend::mfem; + + /// Derive the coefficient context used by an equation's unique input terms. + template + struct EquationCoefficients; + + template + struct EquationCoefficients, SchemaT> { + using Type = decltype(backend::make_term_coefficients( + backend::bind( + std::declval&>(), + std::declval() + )... + )); + }; + + /// Keep legacy numerical comparisons concise while production uses dependency caches directly. + template + class VolumeEquationFixture { + public: + using Plan = operators::EquationPlan; + using Discretization = backend::VolumeDiscretization; + using RowBinding = discretization::backend::mfem::TermBinding; + using DisplacementBinding = discretization::backend::mfem::TermBinding; + using Coefficients = EquationCoefficients::Type; + using Inputs = decltype(backend::make_volume_inputs( + std::declval(), std::declval(), std::declval() + )); + using Cache = decltype(backend::make_volume_equation_cache( + std::declval(), std::declval(), std::declval(), backend::VolumeOptions{} + )); + + /// Retain the fixed test configuration until the first numerical input is supplied. + VolumeEquationFixture( + EquationT equation, + const RowBinding& residual_binding, + const DisplacementBinding& displacement_binding, + const ::mfem::GridFunction& compactification, + const mapping::DomainMapper& mapper, + discretization::quadrature::Policy quadrature_policy = discretization::quadrature::Policy{{}}, + backend::ContractionMode contraction_mode = backend::ContractionMode::automatic + ) : + m_equation(std::move(equation)), + m_discretization{ + .residual_binding = residual_binding, + .displacement_binding = displacement_binding, + .compactification = compactification, + .mapper = mapper + }, + m_options{ + .quadrature_policy = std::move(quadrature_policy), + .contraction_mode = contraction_mode + } {} + + /// Exercise the production validation and graph execution with the old test call shape. + template + std::expected try_prepare( + const CoefficientsT& coefficients, + const ::mfem::Vector& displacement, + backend::Workspace&, + backend::PreparationMode mode = backend::PreparationMode::rebuild + ) { + try { + if (!m_cache) { + const auto inputs = backend::make_volume_inputs(m_discretization, coefficients, displacement); + m_cache.emplace(backend::make_volume_equation_cache(std::move(m_equation), m_discretization, inputs, m_options)); + } + } catch (const std::domain_error& error) { + return std::unexpected(backend::PreparationRejection{ + .reason = backend::PreparationRejectionReason::NON_FINITE, + .message = error.what() + }); + } + return backend::try_prepare_volume(*m_cache, m_discretization, coefficients, displacement, mode); + } + + /// Turn a numerical rejection into the exception expected by the numerical fixtures. + template + void prepare( + const CoefficientsT& coefficients, + const ::mfem::Vector& displacement, + backend::Workspace& workspace, + backend::PreparationMode mode = backend::PreparationMode::rebuild + ) { + const auto result = try_prepare(coefficients, displacement, workspace, mode); + if (!result) { + serif::utils::errors::serif_error("{}", result.error().message); + } + } + + /// Access the real cache for MFEM-adapter tests. + Cache& cache() { + if (!m_cache) { + serif::utils::errors::serif_error("The numerical fixture has not received its inputs."); + } + return *m_cache; + } + + /// Read the same accepted result consumed by production functions. + const auto& data() const { + if (!m_cache) { + serif::utils::errors::serif_error("The numerical fixture has not received its inputs."); + } + return m_cache->template get(); + } + + /// Report readiness directly from the dependency cache. + bool is_prepared() const { + return m_cache && m_cache->ready(); + } + + /// Count successful preparations, including explicitly requested rebuilds. + std::size_t preparation_count() const { + return m_cache ? m_cache->preparation_count() : 0; + } + + /// Read derived numerical work without owning a second preparation report. + auto preparation_report() const { + return backend::volume_work_report(*m_cache); + } + + /// Discard cached results through the dependency module. + void invalidate() { + if (m_cache) { + m_cache->invalidate(); + } + } + + /// Return the supported row size before preparation is needed. + int height() const { + return m_discretization.residual_binding.size(); + } + + /// Return the accepted Jacobian column count. + int width() const { + return backend::volume_width(data()); + } + + /// Inspect which numerical path the production preparation selected. + std::size_t tensor_element_count() const { + return backend::tensor_element_count(data()); + } + + /// Inspect the selected quadrature order for a supported element. + int integration_order(int element) const { + return backend::integration_order(data(), element); + } + + /// Fetch the residual already assembled by the graph. + void residual(backend::Workspace&, ::mfem::Vector& result) const { + backend::volume_residual(data(), result); + } + + /// Delegate every Jacobian call to the production numerical kernel. + template + void apply_jacobian(Args&&... args) const { + backend::apply_jacobian(data(), std::forward(args)...); + } + + /// Delegate transpose application to the production numerical kernel. + void apply_jacobian_transpose(const ::mfem::Vector& dual, backend::Workspace& workspace, ::mfem::Vector& result) const { + backend::apply_jacobian_transpose(data(), dual, workspace, result); + } + + private: + EquationT m_equation; + Discretization m_discretization; + backend::VolumeOptions m_options; + std::optional m_cache; + }; + + template + VolumeEquationFixture( + EquationT, + const discretization::backend::mfem::TermBinding&, + const discretization::backend::mfem::TermBinding&, + const ::mfem::GridFunction&, + const mapping::DomainMapper&, + discretization::quadrature::Policy = discretization::quadrature::Policy{{}}, + backend::ContractionMode = backend::ContractionMode::automatic + ) -> VolumeEquationFixture; +} // namespace serif::tests::equations diff --git a/tests/include/serif/tests/mapping/volume_variation.hpp b/tests/include/serif/tests/mapping/volume_variation.hpp new file mode 100644 index 00000000..10c2801f --- /dev/null +++ b/tests/include/serif/tests/mapping/volume_variation.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include "mfem/linalg/densemat.hpp" +#include "mfem/linalg/vector.hpp" +#include "serif/backend/mfem/mapping/domain_mapper.hpp" + +namespace serif::tests { + /** + * @brief Differentiate the volume map along one direction through the solver's direction-variation path. + * @note Samples the direction's value and canonical-element gradient at the point and forms the inverse mesh + * Jacobian from the prepared context, as the geometry derivatives do. + */ + [[nodiscard]] inline serif::mapping::MappingStatus evaluate_volume_variation( + const serif::mapping::DomainMapper& mapper, + const serif::mapping::ElementMappingData& element_data, + const serif::mapping::ElementDisplacementData& direction, + ::mfem::ElementTransformation& transformation, + const ::mfem::IntegrationPoint& ip, + const serif::mapping::VolumeMappingContext& context, + serif::mapping::DMWorkspace& workspace, + serif::mapping::VolumeMappingVariation& variation, + const serif::mapping::CompactificationPointData* compactification_sample = nullptr + ) { + const ::mfem::FiniteElement& element = direction.get_element(); + ::mfem::Vector shape(element.GetDof()); + ::mfem::DenseMatrix shape_gradient(element.GetDof(), element.GetDim()); + element.CalcShape(ip, shape); + element.CalcDShape(ip, shape_gradient); + ::mfem::Vector value(direction.get_dimension()); + direction.get_dof_matrix().MultTranspose(shape, value); + ::mfem::DenseMatrix gradient(direction.get_dimension(), element.GetDim()); + ::mfem::MultAtB(direction.get_dof_matrix(), shape_gradient, gradient); + ::mfem::DenseMatrix inverse_mesh_jacobian(context.mapping.mapping_jacobian.Height()); + ::mfem::Mult(context.quadrature.J_element_to_physical_inverse, context.mapping.mapping_jacobian, inverse_mesh_jacobian); + return mapper.evaluate_volume_direction_variation(element_data, direction, transformation, ip, context, workspace, variation, + serif::mapping::DMReferenceDisplacementSample{value, gradient}, compactification_sample, inverse_mesh_jacobian); + } +} diff --git a/tests/include/serif/tests/nonconforming.hpp b/tests/include/serif/tests/nonconforming.hpp new file mode 100644 index 00000000..7f99f0fc --- /dev/null +++ b/tests/include/serif/tests/nonconforming.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include + +namespace serif::tests::nonconforming { + /** @brief Generate paired static meshes through STROID's public regional refinement controls. */ + [[nodiscard]] inline stroid::StroidMesh mesh(int stellar_level = 2, int outer_level = 0, int order = 2) { + const stroid::config::MeshConfig options{ + .refinement_levels = stellar_level, + .vacuum_refinement_levels = 0, + .vacuum_outer_refinement_levels = outer_level, + .order = order, + .include_external_domain = true, + .r_core = 0.25, + .r_star = 1.0, + .r_infinity = 4.0, + .optimization_methods = stroid::config::OptimizationMethods{.tmop = false, .smoothstep = true}}; + return stroid::GenerateMesh(options); + } +} diff --git a/tests/include/serif/tests/solver/archive.hpp b/tests/include/serif/tests/solver/archive.hpp new file mode 100644 index 00000000..ddeb17cf --- /dev/null +++ b/tests/include/serif/tests/solver/archive.hpp @@ -0,0 +1,455 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "serif/backend/mfem/model/deformation.hpp" +#include "serif/backend/mfem/solver/seed/state.hpp" +#include "serif/tests/solver/stellar_fixture.hpp" +#include "serif/utils/serialize/record.hpp" + +namespace serif::tests::solver::archive { + /** @brief Read a complete nonnegative index without accepting signs, suffixes or overflow. */ + inline std::size_t index_value(std::string_view text) { + std::size_t value{}; + const auto parsed = std::from_chars(text.data(), text.data() + text.size(), value); + utils::errors::serif_ensure(parsed.ec == std::errc{} && parsed.ptr == text.data() + text.size(), + "Archive index '{}' is not a complete nonnegative integer.", text); + return value; + } + + /** @brief Read the study's unquoted CSV cells without interpreting archived physical values as new results. */ + inline std::vector cells(const std::string& line) { + std::vector result; + std::istringstream stream(line); + std::string cell; + while (std::getline(stream, cell, ',')) { + result.push_back(cell); + } + return result; + } + + /** @brief Read the authoritative named physical prescriptions and provenance. */ + inline std::map metadata(const std::filesystem::path& path) { + std::ifstream input(path / "metadata.txt"); + utils::errors::serif_ensure(input.good(), "Cannot open archive metadata: {}", path.string()); + std::map result; + std::string line; + while (std::getline(input, line)) { + const auto separator = line.find('='); + if (separator != std::string::npos) { + const auto [entry, inserted] = result.emplace(line.substr(0, separator), line.substr(separator + 1)); + utils::errors::serif_ensure(inserted, + "Archive metadata repeats physical prescription '{}'.", entry->first); + } + } + return result; + } + + /** @brief Read a complete finite coordinate vector without interpreting it as a converged solution. */ + inline std::vector read_coefficients(const std::filesystem::path& path, const std::size_t expected_count) { + using namespace utils::errors; + std::ifstream input(path); + std::string count; + input >> count; + serif_ensure(input && index_value(count) == expected_count, + "Saved coordinates do not match the declared stellar layout: {}", path.string()); + std::vector values(expected_count); + for (double& value : values) { + input >> value; + serif_ensure(input && std::isfinite(value), + "Saved coordinates contain a missing or nonfinite physical coefficient: {}", path.string()); + } + std::string trailing; + serif_ensure(!(input >> trailing) && input.eof(), + "Saved coordinates contain trailing data or could not be read completely: {}", path.string()); + return values; + } + + /** @brief Atomically replace a complete finite coordinate file; incomplete writes leave the previous checkpoint intact. */ + inline void save_coefficients(const std::filesystem::path& path, const std::span values) { + using namespace utils::errors; + auto temporary = path; + temporary += ".tmp"; + std::ofstream output(temporary); + output << values.size() << '\n' << std::setprecision(17); + for (const double value : values) { + serif_ensure(std::isfinite(value), "Cannot save nonfinite physical coordinates."); + output << value << '\n'; + } + output.close(); + serif_ensure(output.good(), "Cannot write physical coordinates: {}", temporary.string()); + std::error_code error; + std::filesystem::rename(temporary, path, error); + serif_ensure(!error, "Cannot replace physical coordinates at {}: {}", path.string(), error.message()); + } + + /** @brief Identify the bounded driver's mesh bytes without adding a second checksum implementation. */ + [[nodiscard]] inline std::string mesh_identity(const std::filesystem::path& path) { + using namespace utils::errors; + std::ifstream input(path, std::ios::binary); + serif_ensure(input.good(), "Cannot read checkpoint mesh identity: {}", path.string()); + const std::string bytes{std::istreambuf_iterator{input}, std::istreambuf_iterator{}}; + serif_ensure(!input.bad() && !bytes.empty(), "Cannot read complete checkpoint mesh: {}", path.string()); + return utils::serialize::fingerprint(bytes); + } + + /** + * @brief Describe the bounded driver's physical coefficient meanings, including their actual offsets. + * @note This contract is only for StellarResources with its H1 Gauss-Lobatto bases and real harmonics at the supplied + * maximum degree. It is not the library's general restart format. Bump the version if these conventions change. + */ + template + [[nodiscard]] std::string coordinate_identity(const Layout& layout, std::string_view mesh, int harmonic_degree = 4) { + using Kind = discretization::blocks::RepresentedValueKind; + std::ostringstream identity; + identity.imbue(std::locale::classic()); + identity << "coordinates=density_primal_h1_gll_v1;mesh=" << mesh << ";harmonic_degree=" << harmonic_degree + << ";density_order=" << Density::FunctionalSpace::order << ";potential_order=" << Potential::FunctionalSpace::order + << ";geometry_order=" << Displacement::FunctionalSpace::order; + const auto field = [&](std::string_view name) { + identity << ';' << name << '=' << layout.template offset() + << ':' << layout.template size(); + }; + field.template operator()("density"); + field.template operator()("potential"); + field.template operator()("surface_level"); + field.template operator()("angular_velocity"); + field.template operator()("shape"); + return identity.str(); + } + + /** @brief Require exactly one matching preparation identity before importing positional coordinates. */ + inline void require_preparation_identity(std::istream& input, std::string_view expected) { + using namespace utils::errors; + const std::string_view prefix = expected.substr(0, expected.find('=') + 1); + std::string line; + int matches = 0; + while (std::getline(input, line)) { + if (line.starts_with(prefix)) { + serif_ensure(line == expected && ++matches == 1, + "Saved-state {} identity is incompatible or repeated; use a fresh cold solve.", prefix); + } + } + serif_ensure(input.eof() && matches == 1, + "Saved-state recheck requires the current {} identity; unmarked or legacy coordinates require a fresh cold solve.", prefix); + } + + /** @brief Rebuild the production geometry independently of the nonlinear solver's cache for physical measurements. */ + template + inline auto deformation_cache(const Discretization& discretization, const State& state) { + namespace deformation = geometry::deformation; + const auto inputs = deformation::backend::mfem::make_deformation_inputs(discretization.deformation.model, discretization.deformation.reference, + state.template get()); + auto cache = dependencies::make_computation_cache( + deformation::backend::mfem::make_deformation_graph(), inputs); + const auto prepared = cache.try_prepare(inputs); + utils::errors::serif_ensure(prepared.has_value(), + "Cannot reconstruct accepted physical geometry: {}", prepared ? "" : prepared.error().error.message); + return cache; + } + + /** @brief Compare the archived physical harmonic basis before treating its coefficients as a new initial guess. */ + template + inline double verify_surface_basis(const std::filesystem::path& path, const Discretization& discretization, const State& state) { + auto cache = deformation_cache(discretization, state); + const auto& basis = cache.template get()->nodal_basis(); + std::ifstream input(path / "surface_basis.csv"); + // The J=0.1 archive has no historical basis. The caller must pass its documented sibling basis. + utils::errors::serif_ensure(input.good(), "A matching archived harmonic basis is required."); + std::string line; + std::size_t index = 0; + double error = 0.0; + while (std::getline(input, line)) { + for (const auto& cell : cells(line)) { + utils::errors::serif_ensure(index < basis.size(), "Archived harmonic basis is too large."); + std::size_t consumed{}; + const double value = std::stod(cell, &consumed); + utils::errors::serif_ensure(std::isfinite(value) && consumed == cell.size(), + "Archived harmonic basis contains a nonfinite or malformed coefficient."); + error = std::max(error, std::abs(value - basis[index++])); + } + } + utils::errors::serif_ensure(index == basis.size() && error <= 128.0 * std::numeric_limits::epsilon(), + "Archived harmonic ordering or physical normalization differs (maximum error {}).", error); + return error; + } + + /** @brief Expand a named reduced field using its actual support map. */ + template + inline ::mfem::GridFunction field(const Discretization& discretization, const State& state) { + const auto& binding = discretization.template binding(); + const auto values = state.template get(); + ::mfem::Vector reduced(static_cast(values.size())); + std::copy(values.begin(), values.end(), reduced.GetData()); + ::mfem::GridFunction result(&binding.space()); + result = binding.scatter(reduced); + return result; + } + + /** @brief Reconstruct the production displacement once for independent volume and boundary measurements. */ + template + inline ::mfem::GridFunction displacement_field(const Discretization& discretization, const State& state) { + auto cache = deformation_cache(discretization, state); + const auto& values = cache.template get(); + ::mfem::Vector nodal(static_cast(values.size())); + std::copy(values.begin(), values.end(), nodal.GetData()); + const auto& binding = discretization.template binding(); + ::mfem::Vector reduced(binding.size()); + model::backend::mfem::displacement_projection(binding).action()->Mult(nodal, reduced); + ::mfem::GridFunction displacement(&binding.space()); + displacement = binding.scatter(reduced); + return displacement; + } + + /** @brief Record why a bounded inverse search stopped without accepting an unlocated point. */ + struct LocationDiagnostics { + std::string_view stop_reason = "iteration_limit"; + mapping::MappingStatus rejected_mapping_status = mapping::MappingStatus::VALID; + int projected_trials = 0; + int backtracks = 0; + }; + + /** + * @brief Invert one physical element with bounded, residual-decreasing reference steps. + * @note Only search coordinates are projected onto the reference cube. The physical request, + * twelve Newton updates and both final acceptance tolerances are unchanged. + */ + inline bool locate_in_element(mapping::GridFunctionMappingEvaluator& mapping, ::mfem::ElementTransformation& transformation, + const ::mfem::Vector& target, ::mfem::IntegrationPoint& point, mapping::MappingPointContext& context, double& error, + LocationDiagnostics& diagnostics) { + utils::errors::serif_ensure(transformation.GetGeometryType() == ::mfem::Geometry::CUBE, + "The archived ESTER trace-distance convention requires hexahedral elements."); + diagnostics = {}; + error = std::numeric_limits::infinity(); + const double tolerance = 2.0e-11 * std::max(1.0, target.Norml2()); + const auto project = [&](::mfem::IntegrationPoint& trial) { + const double x = std::clamp(trial.x, 0.0, 1.0); + const double y = std::clamp(trial.y, 0.0, 1.0); + const double z = std::clamp(trial.z, 0.0, 1.0); + if (x != trial.x || y != trial.y || z != trial.z) { + ++diagnostics.projected_trials; + } + trial.Set3(x, y, z); + }; + project(point); + const auto initial_status = mapping.evaluate_point(transformation, point, context); + if (initial_status != mapping::MappingStatus::VALID) { + diagnostics.stop_reason = "invalid_initial_mapping"; + diagnostics.rejected_mapping_status = initial_status; + return false; + } + ::mfem::Vector residual(context.physical_position); + residual -= target; + error = residual.Norml2(); + for (int iteration = 0; iteration < 12 && error > tolerance; ++iteration) { + transformation.SetIntPoint(&point); + ::mfem::DenseMatrix jacobian(3); + ::mfem::Mult(context.mapping_jacobian, transformation.Jacobian(), jacobian); + const double determinant = jacobian.Det(); + if (!std::isfinite(determinant) || determinant == 0.0) { + diagnostics.stop_reason = "singular_jacobian"; + return false; + } + ::mfem::Vector correction(3); + ::mfem::DenseMatrixInverse inverse(jacobian); + inverse.Mult(residual, correction); + if (!std::isfinite(correction[0]) || !std::isfinite(correction[1]) || !std::isfinite(correction[2])) { + diagnostics.stop_reason = "nonfinite_correction"; + return false; + } + + // An unrestricted Newton step can cross the Kelvin map's valid coordinate range. + // Keep the last valid candidate until a projected/backtracked trial reduces its error. + bool accepted = false; + double fraction = 1.0; + for (int trial_index = 0; trial_index < 16; ++trial_index, fraction *= 0.5) { + ::mfem::IntegrationPoint trial; + trial.Set3(point.x - fraction * correction[0], point.y - fraction * correction[1], + point.z - fraction * correction[2]); + project(trial); + mapping::MappingPointContext trial_context; + const auto status = mapping.evaluate_point(transformation, trial, trial_context); + if (status == mapping::MappingStatus::VALID) { + ::mfem::Vector trial_residual(trial_context.physical_position); + trial_residual -= target; + const double trial_error = trial_residual.Norml2(); + if (std::isfinite(trial_error) && trial_error < error) { + point = trial; + context = trial_context; + residual = trial_residual; + error = trial_error; + accepted = true; + break; + } + } else { + diagnostics.rejected_mapping_status = status; + } + ++diagnostics.backtracks; + } + if (!accepted) { + diagnostics.stop_reason = "no_decreasing_step"; + return false; + } + } + const bool located = error <= tolerance && + ::mfem::Geometry::CheckPoint(transformation.GetGeometryType(), point, 2.0e-9); + diagnostics.stop_reason = located ? "converged" : "iteration_limit"; + return located; + } + + /** + * @brief Independently sample the new SERiF fields at the archived physical requests. + * @note Only point IDs, coordinates and reference-element locator hints are read from old samples. + * Every old field value is ignored. Newton tries the hint, its face neighbors and finally every remaining physical element; + * failed or ambiguous relocations are reported, never silently replaced by archived measurements. + * Enthalpy is an EOS diagnostic for this zero-pressure polytrope comparison, not an independently solved field. + * The only gravitational acceleration reported is the mapped gradient of the solved scalar potential. + */ + template + inline void sample(const std::filesystem::path& hints, const std::filesystem::path& output, const Discretization& discretization, + const State& state, double n, double K) { + auto displacement = displacement_field(discretization, state); + const auto& binding = discretization.template binding(); + mapping::GridFunctionMappingEvaluator mapping(discretization.mapper, displacement, discretization.compactification); + auto rho = field(discretization, state); + auto phi = field(discretization, state); + std::ifstream input(hints); + std::ofstream stream(output); + utils::errors::serif_ensure(input.good() && stream.good(), "Cannot open physical sampler input or output."); + std::string line; + utils::errors::serif_ensure( + static_cast(std::getline(input, line)), "Physical sample requests have no header."); + const auto header = cells(line); + std::map columns; + for (std::size_t index = 0; index < header.size(); ++index) { + utils::errors::serif_ensure( + columns.emplace(header[index], index).second, "Physical sample requests repeat a column."); + } + stream << "id,x,y,z,located,stellar_material,boundary_trace_possible,location_error,rho,h_eos,pressure_rho,phi,phi_gradient_x,phi_gradient_y,phi_gradient_z,bernoulli_defect,mapped_x,mapped_y,mapped_z,element,xi,eta,zeta,reference_face_distance,location_tolerance,locator_element_attempts,locator_best_element,locator_best_error,locator_best_stop,locator_best_rejected_mapping_status,locator_projected_trials,locator_backtracks\n" << std::setprecision(17); + const double omega = state.template get()[0]; + const double constant = state.template get()[0]; + while (std::getline(input, line)) { + const auto row = cells(line); + const auto number = [&](const char* name) { return std::stod(row.at(columns.at(name))); }; + utils::errors::serif_ensure( + row.size() == header.size(), "Physical sample request has an incompatible column count."); + ::mfem::Vector target(3); + target[0] = number("x"); target[1] = number("y"); target[2] = number("z"); + int element = static_cast(number("element")); + ::mfem::IntegrationPoint point; + point.Set3(number("xi"), number("eta"), number("zeta")); + bool located = number("located") == 1.0 && element >= 0 && element < binding.mesh().GetNE(); + mapping::MappingPointContext context; + double error = std::numeric_limits::infinity(); + int attempts = 0, projected_trials = 0, backtracks = 0, best_element = -1; + double best_error = std::numeric_limits::infinity(); + ::mfem::IntegrationPoint best_point = point; + LocationDiagnostics best_diagnostics; + const auto try_element = [&](const int candidate) { + ++attempts; + LocationDiagnostics diagnostics; + const bool found = locate_in_element(mapping, *binding.mesh().GetElementTransformation(candidate), + target, point, context, error, diagnostics); + projected_trials += diagnostics.projected_trials; + backtracks += diagnostics.backtracks; + if (best_element < 0 || error < best_error) { + best_element = candidate; + best_error = error; + best_point = point; + best_diagnostics = diagnostics; + } + return found; + }; + if (located) { + located = try_element(element); + if (!located) { + // A Newton correction can move an unchanged physical request across an internal face. + // The archive's element is only a hint; evaluate the current field on the actual side. + ::mfem::Array neighbors; + binding.mesh().ElementToElementTable().GetRow(element, neighbors); + for (const int neighbor : neighbors) { + if (neighbor == element) { + continue; + } + point.Set3(0.5, 0.5, 0.5); + if (try_element(neighbor)) { + element = neighbor; + located = true; + break; + } + } + } + } + if (!located) { + // Requests near edges or vertices can cross more than one face after deformation. + // Search the same mesh without changing coordinates or either acceptance tolerance. + for (int candidate = 0; candidate < binding.mesh().GetNE(); ++candidate) { + point.Set3(0.5, 0.5, 0.5); + if (try_element(candidate)) { + element = candidate; + located = true; + break; + } + } + } + if (!located) { + // Report the closest valid search candidate, not the final unrelated element. + // Physical fields still remain undefined and element remains -1 in the output. + point = best_point; + error = best_error; + } + const bool material = located && discretization.template binding().supports(element); + const double nan = std::numeric_limits::quiet_NaN(); + const double face_distance = located ? std::min({point.x, 1.0 - point.x, point.y, 1.0 - point.y, point.z, 1.0 - point.z}) : nan; + const bool boundary_trace = located && face_distance <= 1.0e-8; + const double density = material ? rho.GetValue(element, point) : nan; + // Retain signed density in the output. An invalid EOS state remains visible, with undefined diagnostics. + const double enthalpy = material && density >= 0.0 ? (n + 1.0) * K * std::pow(density, 1.0 / n) : nan; + const double pressure = material && density >= 0.0 ? K * std::pow(density, 1.0 + 1.0 / n) : nan; + const double potential = located ? phi.GetValue(element, point) : nan; + ::mfem::Vector physical_gradient(3); + physical_gradient = nan; + if (located) { + ::mfem::Vector reference_gradient(3); + auto& transformation = *binding.mesh().GetElementTransformation(element); + transformation.SetIntPoint(&point); + phi.GetGradient(transformation, reference_gradient); + context.inverse_mapping_jacobian.MultTranspose(reference_gradient, physical_gradient); + } + double cylindrical_radius_squared = nan; + if (located) { + const auto& position = context.physical_position; + cylindrical_radius_squared = position[0] * position[0] + position[1] * position[1]; + } + const double defect = enthalpy + potential - 0.5 * omega * omega * cylindrical_radius_squared - constant; + stream << row.at(columns.at("id")) << ',' << target[0] << ',' << target[1] << ',' << target[2] << ',' << located << ',' << material << ',' + << boundary_trace << ',' << error << ',' << density << ',' << enthalpy << ',' << pressure << ',' << potential << ',' + << physical_gradient[0] << ',' << physical_gradient[1] << ',' << physical_gradient[2] << ',' << defect; + for (int axis = 0; axis < 3; ++axis) { + stream << ',' << (located ? context.physical_position[axis] : nan); + } + stream << ',' << (located ? element : -1) << ',' << point.x << ',' << point.y << ',' << point.z << ',' << face_distance << ',' + << 2.0e-11 * std::max(1.0, target.Norml2()) << ',' << attempts << ',' << best_element << ',' + << best_error << ',' << best_diagnostics.stop_reason << ',' + << static_cast(best_diagnostics.rejected_mapping_status) << ',' << projected_trials << ',' << backtracks << '\n'; + } + stream.close(); + utils::errors::serif_ensure( + input.eof() && stream.good(), "Physical sampling did not finish reading or writing every requested row."); + } +} diff --git a/tests/include/serif/tests/solver/native_problem.hpp b/tests/include/serif/tests/solver/native_problem.hpp new file mode 100644 index 00000000..bc98e428 --- /dev/null +++ b/tests/include/serif/tests/solver/native_problem.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include "serif/types.hpp" +#include "serif/backend/mfem/solver/linear/solve.hpp" + +namespace serif::tests::solver { + /** @brief Share native linear execution across algebraic tests of the backend-independent driver. */ + struct NativeProblem { + using Vector = serif::Vector; + + /** @brief Exercise the real checked solver with each test's independently constructed operators. */ + [[nodiscard]] auto solve_linear(const auto& prepared, const Vector& rhs, Vector& correction, const auto& options) { + return serif::solver::linear::backend::mfem::solve(options.linear_solver, prepared->system()->jacobian(), + prepared->preconditioner(), prepared->contract(), rhs, correction, options.method.linear_solve); + } + }; +} diff --git a/tests/include/serif/tests/solver/physical_summary.hpp b/tests/include/serif/tests/solver/physical_summary.hpp new file mode 100644 index 00000000..12269677 --- /dev/null +++ b/tests/include/serif/tests/solver/physical_summary.hpp @@ -0,0 +1,171 @@ +#pragma once + +#include + +#include "serif/tests/solver/archive.hpp" + +namespace serif::tests::solver::archive { + /** + * @brief Measure solved constants and the actual curved boundary independently of the nonlinear solver. + * @note Archived records supply only boundary IDs and reference-rule positions. Coordinates, normals, + * area and enclosed volume are rebuilt from the accepted state through the production mapping. + * Surface volume uses the divergence theorem; it is not a density integral or a positivity certificate. + */ + template + void sample_summary(const std::filesystem::path& output, const Discretization& discretization, const State& state, + const std::filesystem::path& hints) { + using namespace utils::errors; + + auto displacement = displacement_field(discretization, state); + mapping::GridFunctionMappingEvaluator mapping(discretization.mapper, displacement, discretization.compactification); + const auto& binding = discretization.template binding(); + const auto& material = discretization.template binding(); + auto& mesh = binding.mesh(); + const auto& space = binding.space(); + std::ifstream input(hints); + std::ofstream surface(output / "surface.csv"); + serif_ensure(input.good() && surface.good(), "Cannot open archived boundary requests or fresh boundary output."); + std::string line; + serif_ensure(static_cast(std::getline(input, line)), "Archived boundary requests have no header."); + const auto header = cells(line); + std::map columns; + for (std::size_t index = 0; index < header.size(); ++index) { + serif_ensure(columns.emplace(header[index], index).second, "Archived boundary requests repeat a column."); + } + surface << "kind,boundary_element,sample,reference_u,reference_v,reference_x,reference_y,reference_z,x,y,z,theta,phi,radius,normal_x,normal_y,normal_z,area_weight\n" << std::setprecision(17); + std::map, std::size_t> counts; + std::array area{}, volume{}; + while (std::getline(input, line)) { + const auto row = cells(line); + serif_ensure(row.size() == header.size(), "Archived boundary request has an incompatible column count."); + const std::string& kind = row.at(columns.at("kind")); + serif_ensure(kind == "node" || kind == "q14" || kind == "q18", "Unknown archived boundary sampling rule '{}'.", kind); + const std::size_t boundary_index = index_value(row.at(columns.at("boundary_element"))); + serif_ensure(boundary_index < static_cast(space.GetNBE()), "Archived boundary element is outside the mesh."); + const int boundary = static_cast(boundary_index); + serif_ensure(space.GetBdrAttribute(boundary) == discretization.deformation.reference.surface_attribute, + "Archived boundary request does not refer to the stellar surface."); + const auto& element = *space.GetBE(boundary); + const bool quadrature = kind != "node"; + const int order = kind == "q14" ? 14 : 18; + const auto& rule = quadrature ? ::mfem::IntRules.Get(element.GetGeomType(), order) : element.GetNodes(); + const std::size_t sample_index = index_value(row.at(columns.at("sample"))); + serif_ensure(sample_index < static_cast(rule.GetNPoints()) && sample_index == counts[{boundary, kind}]++, + "Archived boundary rule samples are missing, duplicated or out of order."); + const auto& point = rule.IntPoint(static_cast(sample_index)); + const double u = std::stod(row.at(columns.at("reference_u"))); + const double v = std::stod(row.at(columns.at("reference_v"))); + serif_ensure(std::isfinite(u) && std::isfinite(v) && + std::abs(point.x - u) <= 64.0 * std::numeric_limits::epsilon() && std::abs(point.y - v) <= 64.0 * std::numeric_limits::epsilon(), + "Archived boundary rule positions do not match the current finite-element rule."); + int face = 0, orientation = 0; + mesh.GetBdrElementFace(boundary, &face, &orientation); + auto* transformation = mesh.GetFaceElementTransformations(face); + serif_ensure(transformation != nullptr, "Cannot reconstruct an archived stellar interface."); + const bool first_is_material = transformation->Elem1No >= 0 && material.supports(transformation->Elem1No); + const bool second_is_material = transformation->Elem2No >= 0 && material.supports(transformation->Elem2No); + serif_ensure(first_is_material != second_is_material, "Archived stellar interface must have exactly one material side."); + const auto side = first_is_material ? mapping::FaceElementSide::ELEMENT_1 : mapping::FaceElementSide::ELEMENT_2; + auto face_point = ::mfem::Mesh::TransformBdrElementToFace(element.GetGeomType(), orientation, point); + face_point.weight = quadrature ? point.weight : 1.0; + mapping::FaceMappingContext context; + serif_ensure(mapping.evaluate_face(*transformation, side, face_point, context) == mapping::MappingStatus::VALID, + "The accepted stellar boundary has an invalid physical mapping."); + const auto& position = context.mapping.physical_position; + const double radius = position.Norml2(); + serif_ensure(std::isfinite(radius) && radius > 0.0 && !context.mapping.compactified, + "The material boundary has an invalid radius or unexpectedly uses vacuum compactification."); + ::mfem::Vector normal(context.quadrature.normal); + if (normal * position < 0.0) { + normal *= -1.0; + } + const double weight = quadrature ? context.physical_surface_weight : std::numeric_limits::quiet_NaN(); + serif_ensure(!quadrature || (std::isfinite(weight) && weight > 0.0), "The accepted boundary has a nonpositive physical area weight."); + const double theta = std::acos(std::clamp(position[2] / radius, -1.0, 1.0)); + double phi = std::atan2(position[1], position[0]); + if (phi < 0.0) { + phi += 2.0 * std::numbers::pi; + } + surface << kind << ',' << boundary << ',' << sample_index << ',' << point.x << ',' << point.y; + for (int axis = 0; axis < 3; ++axis) { + surface << ',' << context.mapping.reference_position[axis]; + } + for (int axis = 0; axis < 3; ++axis) { + surface << ',' << position[axis]; + } + surface << ',' << theta << ',' << phi << ',' << radius; + for (int axis = 0; axis < 3; ++axis) { + surface << ',' << normal[axis]; + } + surface << ',' << weight << '\n'; + if (quadrature) { + const int index = order == 14 ? 0 : 1; + area[index] += weight; + volume[index] += weight * (normal * position) / 3.0; + } + } + std::size_t faces = 0; + for (int boundary = 0; boundary < space.GetNBE(); ++boundary) { + if (space.GetBdrAttribute(boundary) == discretization.deformation.reference.surface_attribute) { + ++faces; + const auto& element = *space.GetBE(boundary); + serif_ensure(counts[{boundary, "node"}] == static_cast(element.GetNodes().GetNPoints()) && + counts[{boundary, "q14"}] == static_cast(::mfem::IntRules.Get(element.GetGeomType(), 14).GetNPoints()) && + counts[{boundary, "q18"}] == static_cast(::mfem::IntRules.Get(element.GetGeomType(), 18).GetNPoints()), + "Archived requests omit part of the current stellar boundary."); + } + } + const double omega = state.template get()[0]; + const double constant = state.template get()[0]; + serif_ensure(input.eof() && surface.good() && faces > 0 && std::isfinite(omega) && std::isfinite(constant), + "Physical summary has incomplete boundary data or nonfinite solved constants."); + // Integrate the signed FE density itself, without clipping or replacing it by the EOS density. + // These are the original study's q14/q18 physical volume measurements. + const auto density = field(discretization, state); + std::ofstream bulk(output / "bulk.csv"); + bulk << "quadrature,metric,value\n" << std::setprecision(17); + for (const int order : {14, 18}) { + double mass = 0.0, inertia = 0.0, material_volume = 0.0; + for (int element = 0; element < mesh.GetNE(); ++element) { + if (!material.supports(element)) { + continue; + } + auto& transformation = *mesh.GetElementTransformation(element); + const auto& rule = ::mfem::IntRules.Get(transformation.GetGeometryType(), order); + for (int index = 0; index < rule.GetNPoints(); ++index) { + const auto& point = rule.IntPoint(index); + mapping::MappingPointContext context; + serif_ensure(mapping.evaluate_point(transformation, point, context) == mapping::MappingStatus::VALID, + "The accepted stellar material has an invalid physical mapping."); + transformation.SetIntPoint(&point); + const double weight = point.weight * transformation.Weight() * context.mapping_determinate; + const double rho = density.GetValue(element, point); + const auto& position = context.physical_position; + serif_ensure(std::isfinite(weight) && weight > 0.0 && std::isfinite(rho), + "Physical bulk integration has nonfinite density or nonpositive volume weight."); + material_volume += weight; + mass += weight * rho; + inertia += weight * rho * (position[0] * position[0] + position[1] * position[1]); + } + } + bulk << order << ",mass," << mass << '\n' + << order << ",moment_of_inertia_z," << inertia << '\n' + << order << ",angular_momentum_z," << omega * inertia << '\n' + << order << ",volume," << material_volume << '\n' + << order << ",volume_radius," << std::cbrt(3.0 * material_volume / (4.0 * std::numbers::pi)) << '\n'; + } + serif_ensure(bulk.good(), "Cannot write the independently integrated physical bulk quantities."); + std::ofstream summary(output / "physical_summary.json"); + summary << std::setprecision(17) << "{\n \"Omega\": " << omega << ",\n \"Us\": " << constant; + for (int index = 0; index < 2; ++index) { + serif_ensure(std::isfinite(volume[index]) && volume[index] > 0.0, + "The accepted stellar boundary encloses a nonpositive or nonfinite measured volume."); + const int order = index == 0 ? 14 : 18; + summary << ",\n \"surface_area_q" << order << "\": " << area[index] + << ",\n \"surface_volume_q" << order << "\": " << volume[index] + << ",\n \"surface_volume_radius_q" << order << "\": " << std::cbrt(3.0 * volume[index] / (4.0 * std::numbers::pi)); + } + summary << "\n}\n"; + serif_ensure(summary.good(), "Cannot write the independent physical summary."); + } +} diff --git a/tests/include/serif/tests/solver/stellar_fixture.hpp b/tests/include/serif/tests/solver/stellar_fixture.hpp new file mode 100644 index 00000000..415dca47 --- /dev/null +++ b/tests/include/serif/tests/solver/stellar_fixture.hpp @@ -0,0 +1,88 @@ +#pragma once + +#include +#include +#include + +#include +#include "mfem/fem/fe_coll.hpp" +#include "mfem/fem/fespace.hpp" +#include "serif/backend/mfem/discretization/term_binding.hpp" +#include "serif/discretization/domain/schema/schemas.hpp" +#include "serif/backend/mfem/mapping/compactification/kelvin.hpp" +#include "serif/backend/mfem/model/discretization.hpp" +#include "../../../../sandbox/polytropic_model.hpp" + +namespace serif::tests::solver { + namespace blocks = discretization::blocks; + namespace dependencies = utils::dependencies; + using Schema = discretization::domain::schema::CoreEnvelopeVacuumDomainSchema; + using Density = blocks::density::mass; + using Potential = blocks::gravity::potential; + using Constant = blocks::barotropic_constant::mass_normalization; + using Omega = blocks::rotation::angular_velocity; + using Shape = blocks::surface_deformation::coefficients; + using Displacement = blocks::displacement::vector; + + /** @brief Own the scalar stellar fields and the shared scalar/vector geometry resources. */ + struct StellarResources final { + stroid::StroidMesh mesh; + ::mfem::H1_FECollection density_collection{Density::FunctionalSpace::order, 3}; + ::mfem::H1_FECollection potential_collection{Potential::FunctionalSpace::order, 3}; + ::mfem::H1_FECollection geometry_collection{Displacement::FunctionalSpace::order, 3}; + ::mfem::FiniteElementSpace density_space{mesh.mesh.get(), &density_collection}; + ::mfem::FiniteElementSpace geometry_space{mesh.mesh.get(), &geometry_collection}; + ::mfem::FiniteElementSpace potential_space{mesh.mesh.get(), &potential_collection}; + ::mfem::FiniteElementSpace displacement_space{mesh.mesh.get(), &geometry_collection, 3, ::mfem::Ordering::byVDIM}; + ::mfem::FiniteElementSpace reference_displacement{mesh.mesh.get(), &geometry_collection, 3, ::mfem::Ordering::byNODES}; + discretization::backend::mfem::TermBinding density{density_space}; + discretization::backend::mfem::TermBinding potential{potential_space}; + discretization::backend::mfem::TermBinding displacement{displacement_space}; + discretization::backend::mfem::TermBinding constant{*mesh.mesh}; + discretization::backend::mfem::TermBinding omega{*mesh.mesh}; + mapping::DomainMapper mapper; + + /** @brief Keep mesh and finite-element addresses stable for every bound physical system. */ + explicit StellarResources(stroid::StroidMesh generated, double radius = 1.0, double infinity_radius = 4.0) : + mesh(std::move(generated)), mapper({.dimension = 3, .vacuum_element_attribute_ID = Schema::domain_id()}, + std::make_unique(utils::user_options::KelvinCompactificationOptions{ + .r_star_ref = radius, .r_inf_ref = infinity_radius})) { + } + }; + + /** + * @brief Generate a small spherical star with the same exterior and field conventions as the archived study. + * @note P6 geometry resolves this coarse mesh's reference surface within the radial seed's default tolerance. + */ + [[nodiscard]] inline std::shared_ptr make_resources(double radius = 1.0, int refinement = 0, int geometry_order = 6) { + stroid::config::MeshConfig options; + options.refinement_levels = refinement; + options.order = geometry_order; + options.include_external_domain = true; + options.r_core = 0.25 * radius; + options.r_star = radius; + options.r_infinity = 4.0 * radius; + options.optimization_methods = stroid::config::OptimizationMethods{false, true}; + return std::make_shared(stroid::GenerateMesh(options), radius, 4.0 * radius); + } + + /** @brief Describe density mechanics, primal gravity and the isobar shape at the prescribed mass and angular momentum. */ + [[nodiscard]] inline auto make_model(double n = 1.0, double K = 2.0 / std::numbers::pi, double mass = 1.0, double angular_momentum = 0.0, double G = 1.0, double surface_pressure = 0.0) { + // Exercise the same physical declaration as the examples, including its deferred compatibility check. + return examples::make_polytropic_model(n, K, mass, angular_momentum, G, surface_pressure); + } + + /** @brief Bind resources once while keeping harmonic shape choices separate from the physical equation list. */ + [[nodiscard]] inline auto make_discretization(const std::shared_ptr& resources, int surface_degree = 4) { + const auto bindings = dependencies::make_dependency_context( + dependencies::borrow(resources->density), dependencies::borrow(resources->potential), + dependencies::borrow(resources->constant), dependencies::borrow(resources->omega), + dependencies::borrow(resources->displacement)); + const auto reference = geometry::deformation::backend::mfem::deformation_discretization( + resources->geometry_space, resources->reference_displacement, *resources->mesh.reference_mesh); + const geometry::deformation::DomainDeformation deformation{ + .surface = {.maximum_degree = surface_degree}, .stellar_interior = {}, .vacuum = {}}; + return model::backend::mfem::with_deformation(model::backend::mfem::make_stellar_discretization(resources, bindings, + *resources->mesh.exterior_coordinate->values, resources->mapper), deformation, reference); + } +} diff --git a/tests/include/serif/tests/solver/virial.hpp b/tests/include/serif/tests/solver/virial.hpp new file mode 100644 index 00000000..41d60a10 --- /dev/null +++ b/tests/include/serif/tests/solver/virial.hpp @@ -0,0 +1,331 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "serif/backend/mfem/discretization/dof_selection.hpp" +#include "serif/backend/mfem/mapping/transformations.hpp" +#include "serif/backend/mfem/shared/parallel.hpp" +#include "serif/eos/evaluation.hpp" +#include "serif/eos/exceptions.hpp" +#include "serif/equations/rigid_rotation.hpp" +#include "serif/tests/solver/archive.hpp" +#include "serif/utils/misc/compensated_sum.hpp" + +namespace serif::tests::solver { + /** @brief Independent physical integrals of one represented vacuum star at a specified quadrature order. */ + struct VacuumVirialSample { + int order{}; + double mass{}, volume{}, kinetic_energy{}, pressure_integral{}, potential_energy{}, gravity_work{}; + + /** @brief Evaluate the full energy virial using the infinity-zero potential gauge. */ + [[nodiscard]] double energy_virial() const { + return (2.0 * kinetic_energy + potential_energy + 3.0 * pressure_integral) / std::abs(potential_energy); + } + + /** @brief Keep the center-relative mechanical virial separate from gravitational energy consistency. */ + [[nodiscard]] double force_virial() const { + return (2.0 * kinetic_energy + gravity_work + 3.0 * pressure_integral) / std::abs(potential_energy); + } + }; + + /** @brief Return physical qualification separately from the nonlinear solver's algebraic convergence. */ + struct VacuumVirialAssessment { + std::array samples; + equations::RigidRotation::Vector center; + equations::RigidRotation::Vector angular_velocity; + int boundary_dofs{}; + double quadrature_allowance{}; + + /** @brief Require the full virial and a separately bounded empirical integration allowance. */ + [[nodiscard]] bool passes(const double tolerance = 1.0e-6, const double integration_tolerance = 1.0e-7) const { + const double error = std::abs(samples.back().energy_virial()); + return std::isfinite(tolerance) && tolerance > 0.0 && std::isfinite(integration_tolerance) && integration_tolerance >= 0.0 && + std::isfinite(error) && std::isfinite(quadrature_allowance) && quadrature_allowance >= 0.0 && + quadrature_allowance <= integration_tolerance && error + quadrature_allowance <= tolerance; + } + }; + + namespace detail { + /** @brief One quadrature point's contributions, in the order measure_vacuum_virial adds them. */ + struct VirialPointTerms { + double mass{}, volume{}, kinetic{}, pressure{}, binding{}, gravity_work{}; + }; + + /** @brief One element's point terms, or why its first rejected point was rejected. */ + struct ElementVirialTerms { + std::vector points; + std::string failure; + std::exception_ptr error; + }; + + /** + * @brief Integrate the vacuum virial with map_point(worker, transformation, point, context) mapping each point. + * @note Elements may be sampled concurrently (when concurrent is true, map_point must then be safe per worker), + * but every sum is added serially in element and point order, and the first rejected point in that order + * decides the error, so the result does not depend on the thread count. + */ + template + [[nodiscard]] std::expected measure_vacuum_virial( + const Binding& density_binding, const ::mfem::GridFunction& density, const ::mfem::GridFunction& potential, + const EOS& equation_of_state, const equations::RigidRotation& rotation, const bool concurrent, MapPoint&& map_point) { + using DensityTerm = discretization::blocks::density::mass; + static_assert(std::same_as); + if (density.FESpace() != &density_binding.space() || potential.FESpace() == nullptr || potential.FESpace()->GetMesh() != &density_binding.mesh()) { + return std::unexpected("Vacuum virial fields must share their declared physical mesh and density binding."); + } + bool has_surface = false; + for (int face = 0; face < density_binding.mesh().GetNBE(); ++face) { + has_surface = has_surface || Binding::Schema::template boundary_id_matches(density_binding.mesh().GetBdrAttribute(face)); + } + if (!has_surface) { return std::unexpected("Vacuum virial requires a represented stellar-surface boundary."); } + const auto boundary = discretization::backend::mfem::select_boundary_dofs(density_binding); + const auto reduced_density = density_binding.gather(density); + for (const int dof : boundary.reduced_dofs()) { + if (!std::isfinite(reduced_density[dof]) || reduced_density[dof] != 0.0) { + return std::unexpected("Vacuum virial requires an exactly zero complete H1 density trace."); + } + } + VacuumVirialAssessment assessment{.center = rotation.axis_origin(), .angular_velocity = rotation.angular_velocity_vector(), .boundary_dofs = boundary.size()}; + constexpr std::array orders{14, 18, 24, 31}; + std::vector elements; + for (int element = 0; element < density_binding.mesh().GetNE(); ++element) { + if (density_binding.supports(element)) { elements.push_back(element); } + } + std::vector element_terms(elements.size()); + try { + const double surface_pressure = eos::evaluate(equation_of_state, dimensions::DensityValue{0.0}).value(); + if (!std::isfinite(surface_pressure) || surface_pressure != 0.0) { + return std::unexpected("Vacuum virial requires EOS pressure P(0)=0; finite-pressure surfaces are not supported."); + } + for (std::size_t quadrature = 0; quadrature < orders.size(); ++quadrature) { + // Sample: each element owns its terms and its own MFEM transformation. + const auto sample_element = [&](const int index, const int worker) { + ElementVirialTerms& terms = element_terms[static_cast(index)]; + terms = {}; + try { + const int element = elements[static_cast(index)]; + ::mfem::IsoparametricTransformation transformation; + utils::parallel::element_transformation(density_binding.mesh(), element, transformation); + ::mfem::Vector reference_gradient(3), physical_gradient(3); + const auto& rule = ::mfem::IntRules.Get(transformation.GetGeometryType(), orders[quadrature]); + // The solver's density: its reconstruction (SurfacePower t^n) where the binding has one, else the plain polynomial. + const auto* reconstruction = density_binding.reconstruction_element(element); + const auto& density_element = *density_binding.space().GetFE(element); + ::mfem::Vector nodal_density, reconstructed_shape; + ::mfem::DenseMatrix reconstructed_derivatives; + if (reconstruction != nullptr) { + density.GetElementDofValues(element, nodal_density); + reconstructed_shape.SetSize(density_element.GetDof()); + reconstructed_derivatives.SetSize(density_element.GetDof(), 3); + } + const auto density_at = [&](const ::mfem::IntegrationPoint& point) { + if (reconstruction == nullptr) { return density.GetValue(element, point); } + reconstruction->sample(density_element, point, reconstructed_shape, reconstructed_derivatives); + return reconstructed_shape * nodal_density; + }; + terms.points.reserve(static_cast(rule.GetNPoints())); + for (int sample = 0; sample < rule.GetNPoints(); ++sample) { + const auto& point = rule.IntPoint(sample); + mapping::MappingPointContext context; + if (map_point(worker, transformation, point, context) != mapping::MappingStatus::VALID || context.compactified) { + terms.failure = "Vacuum virial encountered an invalid or compactified material map."; + return; + } + transformation.SetIntPoint(&point); + const double weight = point.weight * transformation.Weight() * context.mapping_determinate; + const double rho = density_at(point), phi = potential.GetValue(element, point); + if (!std::isfinite(weight) || weight <= 0.0 || !std::isfinite(rho) || rho < 0.0 || !std::isfinite(phi)) { + terms.failure = "Vacuum virial encountered negative/nonfinite density, nonfinite potential, or invalid volume weight."; + return; + } + const double thermodynamic_pressure = eos::evaluate(equation_of_state, dimensions::DensityValue{rho}).value(); + if (!std::isfinite(thermodynamic_pressure) || thermodynamic_pressure < 0.0) { + terms.failure = "Vacuum virial encountered negative or nonfinite EOS pressure."; + return; + } + potential.GetGradient(transformation, reference_gradient); + mapping::map_reference_gradient_to_physical(context, reference_gradient, physical_gradient); + const equations::RigidRotation::Vector position{ + context.physical_position[0], context.physical_position[1], context.physical_position[2]}; + double dilation = 0.0; + for (int axis = 0; axis < 3; ++axis) { + dilation += (position[axis] - assessment.center[axis]) * physical_gradient[axis]; + } + const double rotational_potential = rotation.centrifugal_potential(position); + if (!std::isfinite(dilation) || !std::isfinite(rotational_potential)) { + terms.failure = "Vacuum virial encountered nonfinite physical gravity or rotation."; + return; + } + terms.points.push_back({.mass = weight * rho, .volume = weight, .kinetic = weight * rho * rotational_potential, + .pressure = weight * thermodynamic_pressure, .binding = 0.5 * weight * rho * phi, .gravity_work = -weight * rho * dilation}); + } + } catch (...) { + terms.error = std::current_exception(); + } + }; + if (concurrent) { + utils::parallel::for_each_index(static_cast(elements.size()), sample_element); + } else { + for (int index = 0; index < static_cast(elements.size()); ++index) { sample_element(index, 0); } + } + + // Sum: element and point order, stopping at the first rejected point. + utils::CompensatedSum mass, volume, kinetic, pressure, binding_energy, gravity_work; + for (const ElementVirialTerms& terms : element_terms) { + for (const VirialPointTerms& point : terms.points) { + mass.add(point.mass); + volume.add(point.volume); + kinetic.add(point.kinetic); + pressure.add(point.pressure); + binding_energy.add(point.binding); + gravity_work.add(point.gravity_work); + } + if (terms.error) { std::rethrow_exception(terms.error); } + if (!terms.failure.empty()) { return std::unexpected(terms.failure); } + } + assessment.samples[quadrature] = {.order = orders[quadrature], .mass = mass.value(), .volume = volume.value(), + .kinetic_energy = kinetic.value(), .pressure_integral = pressure.value(), + .potential_energy = binding_energy.value(), .gravity_work = gravity_work.value()}; + const auto& current = assessment.samples[quadrature]; + if (!std::isfinite(current.mass) || current.mass <= 0.0 || !std::isfinite(current.volume) || current.volume <= 0.0 || + !std::isfinite(current.potential_energy) || current.potential_energy >= 0.0 || + !std::isfinite(current.kinetic_energy) || !std::isfinite(current.pressure_integral) || !std::isfinite(current.gravity_work)) { + return std::unexpected("Vacuum virial requires finite integrals, positive mass/volume and negative nonzero binding energy."); + } + } + } catch (const eos::EOSEvaluationError& error) { + return std::unexpected(std::string{"EOS rejected a vacuum virial sample: "} + error.what()); + } + // Consecutive quadrature changes are convergence evidence, not a rigorous error bound. + for (std::size_t index = assessment.samples.size() - 2; index < assessment.samples.size(); ++index) { + const auto& current = assessment.samples[index]; + const auto& previous = assessment.samples[index - 1]; + const double change = (2.0 * std::abs(current.kinetic_energy - previous.kinetic_energy) + + std::abs(current.potential_energy - previous.potential_energy) + + 3.0 * std::abs(current.pressure_integral - previous.pressure_integral)) / std::abs(current.potential_energy); + assessment.quadrature_allowance = std::max(assessment.quadrature_allowance, change); + } + if (!std::isfinite(assessment.quadrature_allowance)) { + return std::unexpected("Vacuum virial quadrature comparison overflowed."); + } + return assessment; + } + + /** + * @brief GridFunctionMappingEvaluator's element mapping, one per worker. + * @note Gathers element DOFs with a local DofTransformation, so concurrent workers never write the spaces' shared state. + */ + class WorkerMapping { + public: + WorkerMapping(const mapping::DomainMapper& mapper, const ::mfem::GridFunction& displacement, const ::mfem::GridFunction& compactification) + : m_mapper(mapper), m_displacement(displacement), m_compactification(compactification), m_workspace(mapper.get_dimension()) {} + + [[nodiscard]] mapping::MappingStatus evaluate_point(::mfem::ElementTransformation& transformation, const ::mfem::IntegrationPoint& point, + mapping::MappingPointContext& context) { + load_element(transformation.ElementNo); + const mapping::ElementMappingData data{.displacement = *m_displacement_data, .compactification = *m_compactification_data}; + return m_mapper.evaluate_point(data, transformation, point, m_workspace, context); + } + + private: + void load_element(const int element) { + if (element == m_element) { return; } + const ::mfem::FiniteElementSpace& displacement_space = *m_displacement.FESpace(); + const ::mfem::FiniteElementSpace& compactification_space = *m_compactification.FESpace(); + ::mfem::Array displacement_dofs, compactification_dofs; + ::mfem::DofTransformation displacement_transform, compactification_transform; + displacement_space.GetElementVDofs(element, displacement_dofs, displacement_transform); + compactification_space.GetElementDofs(element, compactification_dofs, compactification_transform); + m_displacement.GetSubVector(displacement_dofs, m_element_displacement); + m_compactification.GetSubVector(compactification_dofs, m_element_compactification); + if (displacement_transform.GetDofTransformation() != nullptr) { + displacement_transform.InvTransformPrimal(m_element_displacement); + } + m_displacement_data.emplace(mapping::element_displacement_data_from_element_vdofs(*displacement_space.GetFE(element), m_element_displacement)); + m_compactification_data.emplace(*compactification_space.GetFE(element), m_element_compactification); + m_element = element; + } + + const mapping::DomainMapper& m_mapper; + const ::mfem::GridFunction& m_displacement; + const ::mfem::GridFunction& m_compactification; + mapping::DMWorkspace m_workspace; + ::mfem::Vector m_element_displacement, m_element_compactification; + std::optional m_displacement_data; + std::optional m_compactification_data; + int m_element{-1}; + }; + } + + /** + * @brief Measure a zero-density, zero-pressure vacuum boundary without clipping the represented fields. + * @note Density is the solver's represented density: on elements where the binding reconstructs it (SurfacePower + * t^n beside the surface) the reconstruction is sampled, elsewhere the plain finite-element polynomial. + * @note The caller supplies physical angular velocity and axis origin through RigidRotation. W_grad uses + * x minus that origin, consistent with the centrifugal dilation. Its translation dependence in an + * approximate state remains diagnostic. This low-level integrator assumes that the caller has certified + * the isolated infinity-zero Phi gauge; vacuum_virial performs that check for a complete stellar state. + * Only pressure evaluation is required from the EOS. This validation-tool boundary converts the EOS's + * existing domain exception into an invalid assessment; it does not change library exception handling. + */ + template + requires eos::models::EOSSupportingRelation + [[nodiscard]] std::expected measure_vacuum_virial( + const Binding& density_binding, const ::mfem::GridFunction& density, const ::mfem::GridFunction& potential, + mapping::GridFunctionMappingEvaluator& geometry, const EOS& equation_of_state, const equations::RigidRotation& rotation) { + // One evaluator is not thread-safe, so elements are sampled serially. + return detail::measure_vacuum_virial(density_binding, density, potential, equation_of_state, rotation, false, + [&](int, ::mfem::ElementTransformation& transformation, const ::mfem::IntegrationPoint& point, mapping::MappingPointContext& context) { + return geometry.evaluate_point(transformation, point, context); + }); + } + + /** @brief Certify the complete infinity trace before interpreting potential energy as isolated gravitational energy. */ + template + [[nodiscard]] bool has_infinity_zero_gauge(const Binding& potential_binding, const ::mfem::GridFunction& potential) { + static_assert(std::same_as); + if (potential.FESpace() != &potential_binding.space()) { return false; } + bool has_infinity = false; + for (int face = 0; face < potential_binding.mesh().GetNBE(); ++face) { + has_infinity = has_infinity || Binding::Schema::template boundary_id_matches(potential_binding.mesh().GetBdrAttribute(face)); + } + if (!has_infinity) { return false; } + const auto boundary = discretization::backend::mfem::select_boundary_dofs(potential_binding); + const auto values = potential_binding.gather(potential); + for (const int dof : boundary.reduced_dofs()) { + if (!std::isfinite(values[dof]) || values[dof] != 0.0) { return false; } + } + return true; + } + + /** @brief Certify the potential gauge and reconstruct production physical fields for a complete vacuum state. */ + template + requires eos::models::EOSSupportingRelation + [[nodiscard]] std::expected vacuum_virial( + const Discretization& discretization, const State& state, const EOS& equation_of_state, const equations::RigidRotation& rotation) { + const auto potential = archive::field(discretization, state); + if (!has_infinity_zero_gauge(discretization.template binding(), potential)) { + return std::unexpected("Vacuum virial requires an exactly zero complete H1 potential trace at compactified infinity."); + } + auto displacement = archive::displacement_field(discretization, state); + const auto density = archive::field(discretization, state); + // Same mapping as GridFunctionMappingEvaluator, one per worker, so elements are sampled concurrently. + std::vector workers; + workers.reserve(static_cast(std::max(1, utils::parallel::worker_count()))); + for (int worker = 0; worker < std::max(1, utils::parallel::worker_count()); ++worker) { + workers.emplace_back(discretization.mapper, displacement, discretization.compactification); + } + return detail::measure_vacuum_virial(discretization.template binding(), density, potential, equation_of_state, rotation, true, + [&](const int worker, ::mfem::ElementTransformation& transformation, const ::mfem::IntegrationPoint& point, mapping::MappingPointContext& context) { + return workers[static_cast(worker)].evaluate_point(transformation, point, context); + }); + } +} diff --git a/tests/include/serif/tests/solver/virial_output.hpp b/tests/include/serif/tests/solver/virial_output.hpp new file mode 100644 index 00000000..ccc3c4bf --- /dev/null +++ b/tests/include/serif/tests/solver/virial_output.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include + +#include "serif/tests/solver/virial.hpp" + +namespace serif::tests::solver { + /** + * @brief Preserve raw integrals and the vacuum/gauge qualifications independently of algebraic convergence. + * @note Pass the complete-state vacuum_virial result, which certifies the potential gauge before measuring. + */ + inline void write_vacuum_virial(const std::filesystem::path& output, + const std::expected& assessment, const bool algebraically_converged) { + const bool physically_accepted = algebraically_converged && assessment && assessment->passes(); + std::ofstream rows(output / "virial.csv"), status(output / "virial_status.txt"); + rows << std::setprecision(17) + << "quadrature,mass,volume,T,pressure_integral,Wphi,Wgrad,energy_virial_signed,force_virial_signed,energy_work_signed,quadrature_allowance\n"; + status << std::setprecision(17) << std::boolalpha + << "scope=zero_density_zero_pressure_vacuum\n" + << "algebraically_converged=" << algebraically_converged << '\n' + << "assessment_valid=" << assessment.has_value() << '\n' + << "physical_accepted=" << physically_accepted << '\n' + << "virial_tolerance=1e-6\nintegration_allowance_limit=1e-7\n" + << "integration_allowance_kind=maximum_of_last_two_quadrature_changes_not_rigorous_bound\n" + << "accepted_state_meaning=algebraic_checkpoint\n"; + if (assessment) { + status << "stellar_density_trace=complete_exact_zero\nEOS_pressure_at_zero_density=0\n" + << "infinity_potential_trace=complete_exact_zero\n" + << "surface_pressure_term=0\nstellar_boundary_dofs=" << assessment->boundary_dofs << '\n' + << "virial_center=" << assessment->center[0] << ',' << assessment->center[1] << ',' << assessment->center[2] << '\n' + << "angular_velocity=" << assessment->angular_velocity[0] << ',' << assessment->angular_velocity[1] << ',' << assessment->angular_velocity[2] << '\n' + << "quadrature_allowance=" << assessment->quadrature_allowance << '\n'; + for (const auto& sample : assessment->samples) { + rows << sample.order << ',' << sample.mass << ',' << sample.volume << ',' << sample.kinetic_energy + << ',' << sample.pressure_integral << ',' << sample.potential_energy << ',' << sample.gravity_work + << ',' << sample.energy_virial() << ',' << sample.force_virial() + << ',' << (sample.potential_energy - sample.gravity_work) / std::abs(sample.potential_energy) + << ',' << assessment->quadrature_allowance << '\n'; + } + } else { + status << "vacuum_and_gauge_certification=unavailable\nassessment_failure=" << assessment.error() << '\n'; + } + rows.close(); + status.close(); + utils::errors::serif_ensure(rows.good() && status.good(), + "Cannot preserve the vacuum virial measurements and physical acceptance decision."); + } +} diff --git a/tests/include/serif/tests/surface/boundary_fixture.hpp b/tests/include/serif/tests/surface/boundary_fixture.hpp new file mode 100644 index 00000000..ca74b62a --- /dev/null +++ b/tests/include/serif/tests/surface/boundary_fixture.hpp @@ -0,0 +1,147 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "serif/backend/mfem/discretization/dof_selection.hpp" +#include "serif/backend/mfem/discretization/term_binding.hpp" +#include "serif/discretization/domain/schema/schemas.hpp" +#include "serif/backend/mfem/discretization/term_coefficients.hpp" +#include "serif/tests/surface/models.hpp" + +namespace serif::tests::surface::boundary +{ + namespace finite_elements = discretization::backend::mfem; + namespace coefficients = operators::backend::mfem; + using Schema = discretization::domain::schema::CoreEnvelopeVacuumDomainSchema; + using Vector = ::mfem::Vector; + + /** @brief Give different physical fields independent continuous polynomial orders. */ + template + struct ContinuousScalar : ScalarTerm + { + using FunctionalSpace = discretization::functional_space::DiscreteFunctionalSpace; + }; + + using BoundaryDensity = ContinuousScalar; + using QuadraticComposition = ContinuousScalar; + + /** @brief A spatially uniform temperature still contributes to every selected pressure row. */ + struct UniformTemperature : blocks::term, blocks::global_scalar + { + using Quantity = quantities::Temperature; + using Support = discretization::domain::StellarDomains; + }; + + using CompositionState = dependencies::Bindings< + dependencies::Binding, + dependencies::Binding, + dependencies::Binding>; + using CompositionFormulation = physics::PressureSurfaceFormulation; + + template + using ContinuousGasState = dependencies::Bindings< + dependencies::Binding, + dependencies::Binding>; + + template + using DensityFormulation = physics::PressureSurfaceFormulation>; + + template + using PressureFormulation = physics::DirectPressureSurfaceFormulation, quantities::MassDensity, PressureFromDensityAndTemperature>; + + /** @brief Tag only x=0 so row replacement must preserve the other boundary and interior nodes. */ + [[nodiscard]] inline ::mfem::Mesh make_boundary_mesh() + { + ::mfem::Mesh mesh = ::mfem::Mesh::MakeCartesian3D(2, 2, 1, ::mfem::Element::HEXAHEDRON); + Vector center(3); + for (int boundary = 0; boundary < mesh.GetNBE(); ++boundary) + { + ::mfem::ElementTransformation& transformation = *mesh.GetBdrElementTransformation(boundary); + transformation.Transform(::mfem::Geometries.GetCenter(transformation.GetGeometryType()), center); + mesh.GetBdrElement(boundary)->SetAttribute(std::abs(center[0]) < 1.0e-14 ? 1 : 2); + } + mesh.SetAttributes(); + mesh.SetCurvature(3); + return mesh; + } + + /** @brief Retain only the spaces and bindings needed by surface sampling. */ + struct Fixture + { + ::mfem::Mesh mesh = make_boundary_mesh(); + ::mfem::H1_FECollection cubic_collection{3, 3}; + ::mfem::H1_FECollection linear_collection{1, 3}; + ::mfem::H1_FECollection quadratic_collection{2, 3}; + ::mfem::FiniteElementSpace cubic_space{&mesh, &cubic_collection}; + ::mfem::FiniteElementSpace linear_space{&mesh, &linear_collection}; + ::mfem::FiniteElementSpace quadratic_space{&mesh, &quadratic_collection}; + finite_elements::TermBinding enthalpy{cubic_space}; + finite_elements::TermBinding density{cubic_space}; + finite_elements::TermBinding entropy{linear_space}; + finite_elements::TermBinding temperature{linear_space}; + finite_elements::TermBinding composition{quadratic_space}; + finite_elements::TermBinding uniform_temperature{mesh}; + }; + + /** @brief Nonpolynomial coefficient sequences expose accidental truncation and nodal-index reuse across orders. */ + [[nodiscard]] inline Vector sequence(const int size, const double offset, const double phase) + { + Vector values(size); + for (int index = 0; index < size; ++index) + { + values[index] = offset + 0.09 * std::sin(0.37 * index + phase); + } + return values; + } + + /** @brief Evaluate the original MFEM field at row-space nodes, without using compiled boundary stencils. */ + [[nodiscard]] inline Vector reference_samples(const finite_elements::DofMap& row_binding, const finite_elements::DofSelection& selected, const finite_elements::DofMap& input_binding, const Vector& values) + { + Vector samples(selected.size()); + samples = std::numeric_limits::quiet_NaN(); + std::vector sampled(selected.size(), false); + ::mfem::GridFunction field(&input_binding.space()); + field = input_binding.scatter(values); + ::mfem::Array dofs; + for (int element = 0; element < row_binding.space().GetNE(); ++element) + { + row_binding.space().GetElementDofs(element, dofs); + const ::mfem::IntegrationRule& nodes = row_binding.space().GetFE(element)->GetNodes(); + for (int local = 0; local < dofs.Size(); ++local) + { + const auto reduced = row_binding.reduced_dof(dofs[local]); + if (!reduced) + { + continue; + } + for (int row = 0; row < selected.size(); ++row) + { + if (selected.reduced_dofs()[row] == *reduced && !sampled[row]) + { + samples[row] = field.GetValue(element, nodes.IntPoint(local)); + sampled[row] = true; + } + } + } + } + return samples; + } + + /** @brief Locate the constrained carrier rows using MFEM's independent boundary marker. */ + [[nodiscard]] inline Vector boundary_mask(const finite_elements::DofMap& binding) + { + ::mfem::Array attributes{1, 0}, marker; + binding.space().GetEssentialVDofs(attributes, marker); + Vector full(marker.Size()); + for (int index = 0; index < marker.Size(); ++index) + { + full[index] = marker[index] != 0; + } + return binding.gather(full); + } +} // namespace serif::tests::surface::boundary diff --git a/tests/include/serif/tests/surface/contexts.hpp b/tests/include/serif/tests/surface/contexts.hpp new file mode 100644 index 00000000..6bf6e020 --- /dev/null +++ b/tests/include/serif/tests/surface/contexts.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include "serif/tests/surface/models.hpp" +#include "serif/utils/dependencies/computation_cache.hpp" + +namespace serif::tests::surface { + /// Track physical EOS parameters, not the observational evaluation counters used by these tests. + [[nodiscard]] inline auto general_context(GeneralEOS& model, const double pressure, std::vector enthalpy, std::vector entropy, std::vector composition) { + return dependencies::make_dependency_context( + dependencies::borrow(model, dependencies::track_by([](const GeneralEOS& eos) { + return eos.scale; + })), + physics::pressure_condition(physics::IsobaricSurface{quantities::PressureValue{pressure}}), + physics::boundary_values(std::move(enthalpy)), + physics::boundary_values(std::move(entropy)), + physics::boundary_values(std::move(composition)) + ); + } + + /// Rebuilding equal owned sample buffers should reuse the pressure-only response and residual. + [[nodiscard]] inline auto offset_context(OffsetEOS& model, const double pressure, std::vector enthalpy) { + return dependencies::make_dependency_context( + dependencies::borrow(model, dependencies::track_by([](const OffsetEOS& eos) { + return eos.offset; + })), + physics::pressure_condition(physics::IsobaricSurface{quantities::PressureValue{pressure}}), + physics::boundary_values(std::move(enthalpy)) + ); + } + + /// This ideal-gas fixture has no mutable physical parameters; source identity still distinguishes model objects. + [[nodiscard]] inline auto gas_context(GasEOS& model, const double pressure, std::vector density, std::vector temperature) { + return dependencies::make_dependency_context( + dependencies::borrow(model, dependencies::track_by([](const GasEOS&) { + return 0; + })), + physics::pressure_condition(physics::IsobaricSurface{quantities::PressureValue{pressure}}), + physics::boundary_values(std::move(density)), + physics::boundary_values(std::move(temperature)) + ); + } +} diff --git a/tests/include/serif/tests/surface/models.hpp b/tests/include/serif/tests/surface/models.hpp new file mode 100644 index 00000000..f3938b55 --- /dev/null +++ b/tests/include/serif/tests/surface/models.hpp @@ -0,0 +1,148 @@ +#pragma once + +#include + +#include "serif/tests/equations/test_equations.hpp" +#include "serif/eos/exceptions.hpp" +#include "serif/geometry/surface/surface.hpp" +#include "serif/utils/error/errors.hpp" + +namespace serif::tests::surface { + namespace physics = geometry::surface; + namespace dependencies = utils::dependencies; + namespace quantities = dimensions; + namespace blocks = discretization::blocks; + + using Enthalpy = ::serif::tests::equations::Enthalpy; + using Density = blocks::density::mass; + + struct ElectronFraction : quantities::ThermodynamicQuantity { + }; + + struct Material : blocks::field { + }; + + template + struct ScalarTerm : blocks::term { + using Quantity = QuantityT; + using FunctionalSpace = discretization::functional_space::DiscreteFunctionalSpace; + using Support = discretization::domain::StellarDomains; + struct value : blocks::value_block_base { + }; + struct residual : blocks::residual_block_base { + }; + }; + + using Entropy = ScalarTerm; + using Composition = ScalarTerm; + using Temperature = ScalarTerm; + + using BarotropicState = dependencies::Bindings>; + using GeneralState = dependencies::Bindings< + dependencies::Binding, + dependencies::Binding, + dependencies::Binding + >; + using GasState = dependencies::Bindings< + dependencies::Binding, + dependencies::Binding + >; + + using EnthalpyFromPressureAndComposition = eos::relations::Relation; + using DensityFromPressureAndTemperature = eos::relations::Relation; + using PressureFromDensityAndTemperature = eos::relations::Relation; + + /** @brief The affine composition-dependent EOS from MeanField's constant_surface_compilation.cpp. */ + struct GeneralEOS { + using Relations = eos::relations::RelationCatalog; + double scale = 1.0; + mutable int evaluations = 0; + mutable int partials = 0; + + /// Preserve MeanField's independent 2P + 3s + 5Ye reference expression. + [[nodiscard]] quantities::SpecificEnthalpyValue evaluate(EnthalpyFromPressureAndComposition, const quantities::PressureValue pressure, const quantities::EntropyValue entropy, const quantities::QuantityValue composition) const { + ++evaluations; + return quantities::SpecificEnthalpyValue{scale * (2.0 * pressure.value() + 3.0 * entropy.value() + 5.0 * composition.value())}; + } + + /// The entropy partial is independent of all numerical state. + [[nodiscard]] quantities::PartialDerivative partial_derivative(EnthalpyFromPressureAndComposition, quantities::WithRespectTo, quantities::PressureValue, quantities::EntropyValue, quantities::QuantityValue) const { + ++partials; + return quantities::PartialDerivative{scale * 3.0}; + } + + /// The electron-fraction partial exercises a second independent coupling. + [[nodiscard]] quantities::PartialDerivative partial_derivative(EnthalpyFromPressureAndComposition, quantities::WithRespectTo, quantities::PressureValue, quantities::EntropyValue, quantities::QuantityValue) const { + ++partials; + return quantities::PartialDerivative{scale * 5.0}; + } + }; + + /** @brief Both ideal-gas relations are available, but the choice of residual remains explicit and contains no enthalpy. */ + struct GasEOS { + using Relations = eos::relations::RelationCatalog; + mutable int evaluations = 0; + + /// A nonzero temperature is required to use the explicit density relation. + [[nodiscard]] quantities::DensityValue evaluate(DensityFromPressureAndTemperature, const quantities::PressureValue pressure, const quantities::TemperatureValue temperature) const { + utils::errors::serif_ensure(temperature.value() > 0.0, "The ideal-gas test EOS requires positive temperature, received {}.", temperature.value()); + ++evaluations; + return quantities::DensityValue{pressure.value() / temperature.value()}; + } + + /// Supply an independent forward relation, rather than deriving it from the surface implementation. + [[nodiscard]] quantities::PressureValue evaluate(PressureFromDensityAndTemperature, const quantities::DensityValue density, const quantities::TemperatureValue temperature) const { + ++evaluations; + return quantities::PressureValue{density.value() * temperature.value()}; + } + + /// The inverse relation has a state-dependent temperature partial. + [[nodiscard]] quantities::PartialDerivative partial_derivative(DensityFromPressureAndTemperature, quantities::WithRespectTo, const quantities::PressureValue pressure, const quantities::TemperatureValue temperature) const { + return quantities::PartialDerivative{-pressure.value() / (temperature.value() * temperature.value())}; + } + + /// At fixed temperature, pressure varies linearly with density. + [[nodiscard]] quantities::PartialDerivative partial_derivative(PressureFromDensityAndTemperature, quantities::WithRespectTo, quantities::DensityValue, const quantities::TemperatureValue temperature) const { + return quantities::PartialDerivative{temperature.value()}; + } + + /// At fixed density, pressure varies linearly with temperature. + [[nodiscard]] quantities::PartialDerivative partial_derivative(PressureFromDensityAndTemperature, quantities::WithRespectTo, const quantities::DensityValue density, quantities::TemperatureValue) const { + return quantities::PartialDerivative{density.value()}; + } + }; + + /** @brief A pressure-only EOS whose zero-pressure enthalpy is deliberately nonzero. */ + struct OffsetEOS { + using Relations = eos::relations::RelationCatalog; + double offset = 0.3; + mutable int evaluations = 0; + + /// Count only physical evaluations, excluding this observational counter from source tracking. + [[nodiscard]] quantities::SpecificEnthalpyValue evaluate(eos::relations::SpecificEnthalpyFromPressure, const quantities::PressureValue pressure) const { + ++evaluations; + return quantities::SpecificEnthalpyValue{offset + 2.0 * pressure.value()}; + } + }; + + using GeneralFormulation = physics::PressureSurfaceFormulation; + using GasFormulation = physics::PressureSurfaceFormulation; + using DirectGasFormulation = physics::DirectPressureSurfaceFormulation; + + /// Gather the three MeanField state quantities into the standard equation-point representation. + [[nodiscard]] inline physics::SurfacePoint general_point(const double enthalpy, const double entropy, const double composition) { + physics::SurfacePoint point{}; + point.fields.get() = enthalpy; + point.fields.get() = entropy; + point.fields.get() = composition; + return point; + } + + /// Carrier-resolved and direct-pressure gas formulations share these two scalar operands. + [[nodiscard]] inline physics::SurfacePoint gas_point(const double density, const double temperature) { + physics::SurfacePoint point{}; + point.fields.get() = density; + point.fields.get() = temperature; + return point; + } +} diff --git a/tests/include/serif/tests/test_tags.hpp b/tests/include/serif/tests/test_tags.hpp index 54a91148..38f7bfd9 100644 --- a/tests/include/serif/tests/test_tags.hpp +++ b/tests/include/serif/tests/test_tags.hpp @@ -1,11 +1,6 @@ #pragma once -/* - * Header form of the tag machinery that currently lives in - * test_helpers.cppm. Only the tags used by the discretization/domain - * tests are carried over; once test_helpers is itself de-moduled this - * file should be deleted and the project wide header included instead. - */ +// Shared compile-time tags for the SERiF tests. Sub-tags include their parent tag. #include #include @@ -13,20 +8,15 @@ #include -template struct Tag { +template +struct Tag { std::array chars{}; // ReSharper disable once CppNonExplicitConvertingConstructor - consteval Tag( - std::array< - char, - N> arr - ) - : chars(arr) { - } + consteval Tag(std::array arr) : chars(arr) {} // ReSharper disable once CppNonExplicitConversionOperator - constexpr operator const char *() const { + constexpr operator const char*() const { return chars.data(); } @@ -35,7 +25,8 @@ template struct Tag { return Catch::StringRef(chars.data(), N - 1); } - template consteval Tag operator&(const Tag &other) const { + template + consteval Tag operator&(const Tag& other) const { std::array res{}; std::ranges::copy(chars.begin(), chars.end() - 1, res.begin()); std::ranges::copy(other.chars, res.begin() + (N - 1)); @@ -43,29 +34,34 @@ template struct Tag { } }; -template consteval auto make_tag(const char (&str)[N]) { +template +consteval auto make_tag(const char (&str)[N]) { std::array res{}; res[0] = '['; std::ranges::copy(str, str + N - 1, res.begin() + 1); - res[N] = ']'; + res[N] = ']'; res[N + 1] = '\0'; return Tag{res}; } -template < - std::size_t N, - std::size_t M> -consteval auto sub_tag( - const Tag &parent, - const char (&str)[M] -) { +template +consteval auto sub_tag(const Tag& parent, const char (&str)[M]) { return parent & make_tag(str); } namespace tags { - inline constexpr auto unit = make_tag("unit"); - inline constexpr auto mesh = make_tag("mesh"); + inline constexpr auto unit = make_tag("unit"); + inline constexpr auto mesh = make_tag("mesh"); inline constexpr auto integration = make_tag("integration"); - inline constexpr auto utils = make_tag("utils"); - inline constexpr auto domain = sub_tag(mesh, "domain"); + inline constexpr auto utils = make_tag("utils"); + inline constexpr auto domain = sub_tag(mesh, "domain"); + inline constexpr auto convergence = make_tag("convergence"); + inline constexpr auto mapping = make_tag("mapping"); + inline constexpr auto transformations = sub_tag(mapping, "transformations"); + inline constexpr auto kelvin = sub_tag(mapping, "kelvin"); + inline constexpr auto geometry_cache = sub_tag(mapping, "geometry-cache"); + inline constexpr auto volume_cache = sub_tag(mapping, "volume-cache"); + inline constexpr auto eos = make_tag("eos"); + inline constexpr auto equations = make_tag("equations"); + inline constexpr auto quadrature = make_tag("quadrature"); } // namespace tags diff --git a/tests/mapping/compactification/kelvin.cpp b/tests/mapping/compactification/kelvin.cpp new file mode 100644 index 00000000..d228b1e8 --- /dev/null +++ b/tests/mapping/compactification/kelvin.cpp @@ -0,0 +1,773 @@ +// Ported from MeanField tests/mapping/compactification/kelvin.cpp. + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "serif/backend/mfem/mapping/compactification/kelvin.hpp" + +#include "serif/tests/test_tags.hpp" + +namespace mapping = serif::mapping; +using Catch::Matchers::WithinAbs; + +namespace { + constexpr int dimension = 3; + + mfem::Vector make_vector(const double x, const double y, const double z) { + mfem::Vector vector(dimension); + vector(0) = x; + vector(1) = y; + vector(2) = z; + return vector; + } + + mfem::DenseMatrix make_identity() { + mfem::DenseMatrix matrix(dimension); + matrix = 0.0; + for (int i = 0; i < dimension; ++i) { + matrix(i, i) = 1.0; + } + return matrix; + } + + void check_vector(const mfem::Vector& actual, const mfem::Vector& expected, const double tolerance) { + REQUIRE(actual.Size() == expected.Size()); + for (int i = 0; i < actual.Size(); ++i) { + CHECK_THAT(actual(i), WithinAbs(expected(i), tolerance)); + } + } + + void check_matrix(const mfem::DenseMatrix& actual, const mfem::DenseMatrix& expected, const double tolerance) { + REQUIRE(actual.Height() == expected.Height()); + REQUIRE(actual.Width() == expected.Width()); + + for (int i = 0; i < actual.Height(); ++i) { + for (int j = 0; j < actual.Width(); ++j) { + CHECK_THAT(actual(i, j), WithinAbs(expected(i, j), tolerance)); + } + } + } + + struct AnalyticFactors { + double computational_radius; + double scale; + double scale_derivative; + }; + + AnalyticFactors compute_analytic_factors(const double r_star, const double r_inf, const double coordinate) { + const double radial_extent = r_inf - r_star; + const double computational_radius = r_star + coordinate * radial_extent; + const double scale = r_star / (computational_radius * (1.0 - coordinate)); + const double scale_derivative = scale * (1.0 / (1.0 - coordinate) - radial_extent / computational_radius); + return {.computational_radius = computational_radius, .scale = scale, .scale_derivative = scale_derivative}; + } + + mapping::MappingStatus evaluate_affine_map( + const mapping::compactification::ExteriorDomainMap& exterior_map, + const mfem::Vector& reference_position, + const mfem::DenseMatrix& affine_jacobian, + const mfem::Vector& offset, + const double compactification_coordinate, + const mfem::Vector& compactification_coordinate_gradient, + mapping::compactification::ExteriorMapResult& result + ) { + mfem::Vector displaced_position(dimension); + affine_jacobian.Mult(reference_position, displaced_position); + displaced_position += offset; + + const mapping::compactification::ExteriorMapInput input{ + .reference_position = reference_position, + .displaced_position = displaced_position, + .displacement_jacobian = affine_jacobian, + .compactification_coordinate_gradient = compactification_coordinate_gradient, + .compactification_coordinate = compactification_coordinate + }; + + return exterior_map.evaluate(input, result); + } +} // namespace + +TEST_CASE( + "Kelvin Prepared Jacobian Factors Match Full Variation", + tags::unit & 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 coordinate_gradient = 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 direction_jacobian = make_identity(); + direction_jacobian *= 0.03; + direction_jacobian(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_gradient, coordinate + }; + mapping::compactification::ExteriorMapResult base; + REQUIRE(kelvin.evaluate(input, base) == mapping::MappingStatus::VALID); + mapping::compactification::ExteriorMapVariation variation; + REQUIRE( + kelvin.evaluate_variation(input, base, {direction, direction_jacobian}, variation) == + mapping::MappingStatus::VALID + ); + double scale = 0.0; + mfem::Vector scale_gradient; + REQUIRE( + kelvin.evaluate_jacobian_variation_factors(coordinate, coordinate_gradient, scale, scale_gradient) == + 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 * direction_jacobian(row, column) + direction(row) * scale_gradient(column), + WithinAbs(expected, 1.0e-12 * std::max(1.0, std::abs(expected))) + ); + } + } + } + double scale = 0.0; + mfem::Vector gradient; + CHECK( + kelvin.evaluate_jacobian_variation_factors(1.0, coordinate_gradient, scale, gradient) == + mapping::MappingStatus::AT_COMPACTIFIED_INFINITY + ); + CHECK( + kelvin.evaluate_jacobian_variation_factors(-0.1, coordinate_gradient, scale, gradient) == + mapping::MappingStatus::OUTSIDE_REFERENCE_DOMAIN + ); + auto invalid = coordinate_gradient; + invalid(1) = std::numeric_limits::quiet_NaN(); + CHECK( + kelvin.evaluate_jacobian_variation_factors(0.4, invalid, scale, gradient) == + mapping::MappingStatus::NON_FINITE_MAPPING_INPUT + ); +} + +TEST_CASE( + "Kelvin Compactification Validates Its Configuration", + tags::unit & tags::kelvin +) { + CHECK_NOTHROW(mapping::compactification::KelvinCompactification({.r_star_ref = 1.0, .r_inf_ref = 4.0})); + CHECK_THROWS_AS( + mapping::compactification::KelvinCompactification({.r_star_ref = 0.0, .r_inf_ref = 4.0}), std::invalid_argument + ); + CHECK_THROWS_AS( + mapping::compactification::KelvinCompactification({.r_star_ref = -1.0, .r_inf_ref = 4.0}), std::invalid_argument + ); + CHECK_THROWS_AS( + mapping::compactification::KelvinCompactification({.r_star_ref = 2.0, .r_inf_ref = 2.0}), std::invalid_argument + ); + CHECK_THROWS_AS( + mapping::compactification::KelvinCompactification({.r_star_ref = 3.0, .r_inf_ref = 2.0}), std::invalid_argument + ); + CHECK_THROWS_AS( + mapping::compactification::KelvinCompactification( + {.r_star_ref = 1.0, .r_inf_ref = std::numeric_limits::infinity()} + ), + std::invalid_argument + ); + CHECK_THROWS_AS( + mapping::compactification::KelvinCompactification( + {.r_star_ref = 1.0, .r_inf_ref = 4.0, .coordinate_tolerance = -1.0e-12} + ), + std::invalid_argument + ); + CHECK_THROWS_AS( + mapping::compactification::KelvinCompactification( + {.r_star_ref = 1.0, .r_inf_ref = 4.0, .coordinate_tolerance = 1.0} + ), + std::invalid_argument + ); + CHECK_THROWS_AS( + mapping::compactification::KelvinCompactification( + {.r_star_ref = 1.0, .r_inf_ref = 4.0, .coordinate_tolerance = std::numeric_limits::quiet_NaN()} + ), + std::invalid_argument + ); +} + +TEST_CASE( + "Kelvin Compactification Reports Its Configuration", + tags::unit & tags::kelvin +) { + constexpr double coordinate_tolerance = 3.0e-11; + mapping::compactification::KelvinCompactification compactification( + {.r_star_ref = 1.25, .r_inf_ref = 5.5, .coordinate_tolerance = coordinate_tolerance} + ); + + CHECK(compactification.get_name() == "KelvinCompactification"); + CHECK_THAT(compactification.get_reference_stellar_radius(), WithinAbs(1.25, 0.0)); + CHECK_THAT(compactification.get_reference_infinity_radius(), WithinAbs(5.5, 0.0)); + CHECK_THAT(compactification.get_coordinate_tolerance(), WithinAbs(coordinate_tolerance, 0.0)); +} + +TEST_CASE( + "Kelvin Compactification Uses The Exterior Coordinate Rather Than " + "Euclidean Radius", + tags::unit & tags::kelvin +) { + constexpr double tolerance = 0.0; + constexpr double coordinate = 0.37; + + mapping::compactification::KelvinCompactification compactification({.r_star_ref = 1.0, .r_inf_ref = 4.0}); + const mfem::DenseMatrix displacement_jacobian = make_identity(); + mfem::Vector displaced_position = make_vector(1.4, -0.2, 0.3); + mfem::Vector coordinate_gradient = make_vector(0.2, -0.1, 0.05); + mfem::Vector reference_a = make_vector(0.2, 0.1, -0.1); + mfem::Vector reference_b = make_vector(12.0, -7.0, 4.0); + + const mapping::compactification::ExteriorMapInput input_a{ + reference_a, displaced_position, displacement_jacobian, coordinate_gradient, coordinate + }; + const mapping::compactification::ExteriorMapInput input_b{ + reference_b, displaced_position, displacement_jacobian, coordinate_gradient, coordinate + }; + + mapping::compactification::ExteriorMapResult result_a; + mapping::compactification::ExteriorMapResult result_b; + + REQUIRE(compactification.evaluate(input_a, result_a) == mapping::MappingStatus::VALID); + REQUIRE(compactification.evaluate(input_b, result_b) == mapping::MappingStatus::VALID); + + check_vector(result_a.physical_position, result_b.physical_position, tolerance); + check_matrix(result_a.mapping_jacobian, result_b.mapping_jacobian, tolerance); +} + +TEST_CASE( + "Kelvin Compactification Matches Its Analytic Radial Map", + tags::unit & tags::kelvin +) { + constexpr double r_star = 1.0; + constexpr double r_inf = 4.0; + constexpr double radial_extent = r_inf - r_star; + constexpr double tolerance = 2.0e-12; + + mapping::compactification::KelvinCompactification compactification({.r_star_ref = r_star, .r_inf_ref = r_inf}); + const mfem::DenseMatrix identity = make_identity(); + mfem::Vector coordinate_gradient = make_vector(1.0 / radial_extent, 0.0, 0.0); + + for (const double computational_radius : std::array{1.0, 1.25, 2.0, 3.0, 3.75}) { + CAPTURE(computational_radius); + + const double coordinate = (computational_radius - r_star) / radial_extent; + mfem::Vector reference_position = make_vector(computational_radius, 0.0, 0.0); + mfem::Vector displaced_position(reference_position); + const mapping::compactification::ExteriorMapInput input{ + reference_position, displaced_position, identity, coordinate_gradient, coordinate + }; + mapping::compactification::ExteriorMapResult result; + + REQUIRE(compactification.evaluate(input, result) == mapping::MappingStatus::VALID); + + const double expected_radius = r_star * radial_extent / (r_inf - computational_radius); + const double expected_radial_derivative = r_star * radial_extent / std::pow(r_inf - computational_radius, 2.0); + const double expected_tangential_scale = expected_radius / computational_radius; + + mfem::Vector expected_position = make_vector(expected_radius, 0.0, 0.0); + mfem::DenseMatrix expected_jacobian(dimension); + expected_jacobian = 0.0; + expected_jacobian(0, 0) = expected_radial_derivative; + expected_jacobian(1, 1) = expected_tangential_scale; + expected_jacobian(2, 2) = expected_tangential_scale; + + check_vector(result.physical_position, expected_position, tolerance); + check_matrix(result.mapping_jacobian, expected_jacobian, tolerance); + CHECK(result.mapping_jacobian.Det() > 0.0); + } +} + +TEST_CASE( + "Kelvin Compactification Matches Its Full Cartesian Formula", + tags::unit & tags::kelvin +) { + constexpr double r_star = 1.0; + constexpr double r_inf = 4.0; + constexpr double coordinate = 0.42; + constexpr double tolerance = 1.0e-12; + + mapping::compactification::KelvinCompactification compactification({.r_star_ref = r_star, .r_inf_ref = r_inf}); + mfem::Vector reference_position = make_vector(0.8, 0.4, -0.2); + mfem::Vector displaced_position = make_vector(1.1, 0.5, -0.1); + mfem::Vector coordinate_gradient = make_vector(0.20, -0.10, 0.05); + + mfem::DenseMatrix displacement_jacobian(dimension); + displacement_jacobian(0, 0) = 1.10; + displacement_jacobian(0, 1) = 0.05; + displacement_jacobian(0, 2) = 0.00; + displacement_jacobian(1, 0) = -0.02; + displacement_jacobian(1, 1) = 0.95; + displacement_jacobian(1, 2) = 0.03; + displacement_jacobian(2, 0) = 0.01; + displacement_jacobian(2, 1) = -0.04; + displacement_jacobian(2, 2) = 1.05; + + const mapping::compactification::ExteriorMapInput input{ + reference_position, displaced_position, displacement_jacobian, coordinate_gradient, coordinate + }; + mapping::compactification::ExteriorMapResult result; + + REQUIRE(compactification.evaluate(input, result) == mapping::MappingStatus::VALID); + + const AnalyticFactors factors = compute_analytic_factors(r_star, r_inf, coordinate); + mfem::Vector expected_position(displaced_position); + expected_position *= factors.scale; + + mfem::DenseMatrix expected_jacobian(displacement_jacobian); + expected_jacobian *= factors.scale; + + for (int i = 0; i < dimension; ++i) { + for (int j = 0; j < dimension; ++j) { + expected_jacobian(i, j) += displaced_position(i) * factors.scale_derivative * coordinate_gradient(j); + } + } + + check_vector(result.physical_position, expected_position, tolerance); + check_matrix(result.mapping_jacobian, expected_jacobian, tolerance); +} + +TEST_CASE( + "Kelvin Compactification Jacobian Matches Coordinate Finite Differences", + tags::unit & tags::kelvin +) { + constexpr double base_coordinate = 0.38; + constexpr double difference_step = 1.0e-6; + constexpr double tolerance = 3.0e-9; + + mapping::compactification::KelvinCompactification compactification({.r_star_ref = 1.0, .r_inf_ref = 4.0}); + + mfem::DenseMatrix affine_jacobian(dimension); + affine_jacobian(0, 0) = 1.05; + affine_jacobian(0, 1) = 0.02; + affine_jacobian(0, 2) = 0.00; + affine_jacobian(1, 0) = -0.01; + affine_jacobian(1, 1) = 0.98; + affine_jacobian(1, 2) = 0.03; + affine_jacobian(2, 0) = 0.02; + affine_jacobian(2, 1) = 0.00; + affine_jacobian(2, 2) = 1.02; + + mfem::Vector offset = make_vector(0.04, -0.03, 0.02); + mfem::Vector reference_position = make_vector(1.4, 0.3, -0.2); + mfem::Vector coordinate_gradient = make_vector(0.11, -0.07, 0.05); + + mapping::compactification::ExteriorMapResult base_result; + REQUIRE( + evaluate_affine_map( + compactification, reference_position, affine_jacobian, offset, base_coordinate, coordinate_gradient, + base_result + ) == mapping::MappingStatus::VALID + ); + + for (int coordinate = 0; coordinate < dimension; ++coordinate) { + mfem::Vector reference_plus(reference_position); + mfem::Vector reference_minus(reference_position); + reference_plus(coordinate) += difference_step; + reference_minus(coordinate) -= difference_step; + + const double compactification_plus = base_coordinate + difference_step * coordinate_gradient(coordinate); + const double compactification_minus = base_coordinate - difference_step * coordinate_gradient(coordinate); + + mapping::compactification::ExteriorMapResult result_plus; + mapping::compactification::ExteriorMapResult result_minus; + + REQUIRE( + evaluate_affine_map( + compactification, reference_plus, affine_jacobian, offset, compactification_plus, coordinate_gradient, + result_plus + ) == mapping::MappingStatus::VALID + ); + REQUIRE( + evaluate_affine_map( + compactification, reference_minus, affine_jacobian, offset, compactification_minus, coordinate_gradient, + result_minus + ) == mapping::MappingStatus::VALID + ); + + for (int component = 0; component < dimension; ++component) { + const double finite_difference = + (result_plus.physical_position(component) - result_minus.physical_position(component)) / + (2.0 * difference_step); + CHECK_THAT(finite_difference, WithinAbs(base_result.mapping_jacobian(component, coordinate), tolerance)); + } + } +} + +TEST_CASE( + "Kelvin Compactification Variation Matches State Finite Differences", + tags::unit & tags::kelvin +) { + constexpr double coordinate = 0.46; + constexpr double difference_step = 1.0e-6; + constexpr double tolerance = 2.0e-10; + + mapping::compactification::KelvinCompactification compactification({.r_star_ref = 1.0, .r_inf_ref = 4.0}); + mfem::Vector reference_position = make_vector(1.3, -0.2, 0.4); + mfem::Vector displaced_position = make_vector(1.4, -0.1, 0.35); + mfem::Vector coordinate_gradient = make_vector(0.12, -0.04, 0.08); + mfem::Vector position_direction = make_vector(0.03, -0.05, 0.02); + + mfem::DenseMatrix displacement_jacobian = make_identity(); + displacement_jacobian(0, 1) = 0.04; + displacement_jacobian(1, 2) = -0.03; + displacement_jacobian(2, 0) = 0.02; + + mfem::DenseMatrix jacobian_direction(dimension); + jacobian_direction(0, 0) = 0.02; + jacobian_direction(0, 1) = -0.01; + jacobian_direction(0, 2) = 0.03; + jacobian_direction(1, 0) = 0.01; + jacobian_direction(1, 1) = -0.02; + jacobian_direction(1, 2) = 0.00; + jacobian_direction(2, 0) = -0.01; + jacobian_direction(2, 1) = 0.02; + jacobian_direction(2, 2) = 0.01; + + const mapping::compactification::ExteriorMapInput input{ + reference_position, displaced_position, displacement_jacobian, coordinate_gradient, coordinate + }; + const mapping::compactification::ExteriorMapDirection direction{position_direction, jacobian_direction}; + + mapping::compactification::ExteriorMapResult base_result; + mapping::compactification::ExteriorMapVariation variation; + + REQUIRE(compactification.evaluate(input, base_result) == mapping::MappingStatus::VALID); + REQUIRE( + compactification.evaluate_variation(input, base_result, direction, variation) == mapping::MappingStatus::VALID + ); + + mfem::Vector displaced_plus(displaced_position); + mfem::Vector displaced_minus(displaced_position); + displaced_plus.Add(difference_step, position_direction); + displaced_minus.Add(-difference_step, position_direction); + + mfem::DenseMatrix jacobian_plus(displacement_jacobian); + mfem::DenseMatrix jacobian_minus(displacement_jacobian); + jacobian_plus.Add(difference_step, jacobian_direction); + jacobian_minus.Add(-difference_step, jacobian_direction); + + const mapping::compactification::ExteriorMapInput input_plus{ + reference_position, displaced_plus, jacobian_plus, coordinate_gradient, coordinate + }; + const mapping::compactification::ExteriorMapInput input_minus{ + reference_position, displaced_minus, jacobian_minus, coordinate_gradient, coordinate + }; + + mapping::compactification::ExteriorMapResult result_plus; + mapping::compactification::ExteriorMapResult result_minus; + + REQUIRE(compactification.evaluate(input_plus, result_plus) == mapping::MappingStatus::VALID); + REQUIRE(compactification.evaluate(input_minus, result_minus) == mapping::MappingStatus::VALID); + + for (int i = 0; i < dimension; ++i) { + const double position_finite_difference = + (result_plus.physical_position(i) - result_minus.physical_position(i)) / (2.0 * difference_step); + CHECK_THAT(position_finite_difference, WithinAbs(variation.physical_position_variation(i), tolerance)); + + for (int j = 0; j < dimension; ++j) { + const double jacobian_finite_difference = + (result_plus.mapping_jacobian(i, j) - result_minus.mapping_jacobian(i, j)) / (2.0 * difference_step); + CHECK_THAT(jacobian_finite_difference, WithinAbs(variation.mapping_jacobian_variation(i, j), tolerance)); + } + } +} + +TEST_CASE( + "Kelvin Compactification Preserves Rotational Covariance", + tags::unit & tags::kelvin +) { + constexpr double coordinate = 0.31; + constexpr double tolerance = 1.0e-12; + + mapping::compactification::KelvinCompactification compactification({.r_star_ref = 1.0, .r_inf_ref = 4.0}); + mfem::Vector reference_position = make_vector(1.3, 0.4, -0.2); + mfem::Vector displaced_position = make_vector(1.4, 0.2, -0.1); + mfem::Vector coordinate_gradient = make_vector(0.16, -0.08, 0.03); + + mfem::DenseMatrix displacement_jacobian = make_identity(); + displacement_jacobian(0, 1) = 0.05; + displacement_jacobian(1, 0) = -0.02; + displacement_jacobian(2, 1) = 0.03; + + const mapping::compactification::ExteriorMapInput input{ + reference_position, displaced_position, displacement_jacobian, coordinate_gradient, coordinate + }; + mapping::compactification::ExteriorMapResult result; + REQUIRE(compactification.evaluate(input, result) == mapping::MappingStatus::VALID); + + mfem::DenseMatrix rotation(dimension); + rotation = 0.0; + rotation(0, 1) = -1.0; + rotation(1, 0) = 1.0; + rotation(2, 2) = 1.0; + + mfem::Vector rotated_reference(dimension); + mfem::Vector rotated_displaced(dimension); + mfem::Vector rotated_coordinate_gradient(dimension); + rotation.Mult(reference_position, rotated_reference); + rotation.Mult(displaced_position, rotated_displaced); + rotation.Mult(coordinate_gradient, rotated_coordinate_gradient); + + mfem::DenseMatrix temporary(dimension); + mfem::DenseMatrix rotated_displacement_jacobian(dimension); + mfem::Mult(rotation, displacement_jacobian, temporary); + mfem::MultABt(temporary, rotation, rotated_displacement_jacobian); + + const mapping::compactification::ExteriorMapInput rotated_input{ + rotated_reference, rotated_displaced, rotated_displacement_jacobian, rotated_coordinate_gradient, coordinate + }; + mapping::compactification::ExteriorMapResult rotated_result; + REQUIRE(compactification.evaluate(rotated_input, rotated_result) == mapping::MappingStatus::VALID); + + mfem::Vector expected_position(dimension); + rotation.Mult(result.physical_position, expected_position); + + mfem::DenseMatrix expected_jacobian(dimension); + mfem::Mult(rotation, result.mapping_jacobian, temporary); + mfem::MultABt(temporary, rotation, expected_jacobian); + + check_vector(rotated_result.physical_position, expected_position, tolerance); + check_matrix(rotated_result.mapping_jacobian, expected_jacobian, tolerance); +} + +TEST_CASE( + "Kelvin Compactification Is Continuous At The Mesh Defined Stellar Surface", + tags::unit & tags::kelvin +) { + constexpr double tolerance = 1.0e-14; + + mapping::compactification::KelvinCompactification compactification({.r_star_ref = 1.0, .r_inf_ref = 4.0}); + mfem::Vector reference_position = make_vector(-0.5260553366425769, 0.5260553366425769, -0.6553163792879153); + mfem::Vector displaced_position = make_vector(-0.55, 0.51, -0.63); + mfem::Vector coordinate_gradient = make_vector(-0.18, 0.18, -0.22); + const mfem::DenseMatrix displacement_jacobian = make_identity(); + + REQUIRE(reference_position.Norml2() < 1.0); + + const mapping::compactification::ExteriorMapInput input{ + reference_position, displaced_position, displacement_jacobian, coordinate_gradient, 0.0 + }; + mapping::compactification::ExteriorMapResult result; + + REQUIRE(compactification.evaluate(input, result) == mapping::MappingStatus::VALID); + check_vector(result.physical_position, displaced_position, tolerance); + CHECK(result.mapping_jacobian.Det() > 0.0); +} + +TEST_CASE( + "Kelvin Compactification Has Correct Infinity And Coordinate Bound " + "Behavior", + tags::unit & tags::kelvin +) { + constexpr double r_star = 1.0; + constexpr double r_inf = 4.0; + constexpr double radial_extent = r_inf - r_star; + constexpr double coordinate_tolerance = 1.0e-12; + constexpr double tolerance = 2.0e-11; + + mapping::compactification::KelvinCompactification compactification( + {.r_star_ref = r_star, .r_inf_ref = r_inf, .coordinate_tolerance = coordinate_tolerance} + ); + const mfem::DenseMatrix identity = make_identity(); + mfem::Vector coordinate_gradient = make_vector(1.0 / radial_extent, 0.0, 0.0); + + for (const double coordinate : std::array{0.0, 0.25, 0.75, 0.95, 0.99, 0.999}) { + CAPTURE(coordinate); + + const double computational_radius = r_star + coordinate * radial_extent; + mfem::Vector reference_position = make_vector(computational_radius, 0.0, 0.0); + const mapping::compactification::ExteriorMapInput input{ + reference_position, reference_position, identity, coordinate_gradient, coordinate + }; + mapping::compactification::ExteriorMapResult result; + + REQUIRE(compactification.evaluate(input, result) == mapping::MappingStatus::VALID); + CHECK_THAT(result.physical_position.Norml2() * (1.0 - coordinate), WithinAbs(r_star, tolerance)); + } + + mfem::Vector reference_position = make_vector(r_inf, 0.0, 0.0); + mapping::compactification::ExteriorMapResult result; + + CHECK( + compactification.evaluate( + {reference_position, reference_position, identity, coordinate_gradient, 1.0}, result + ) == mapping::MappingStatus::AT_COMPACTIFIED_INFINITY + ); + CHECK( + compactification.evaluate( + {reference_position, reference_position, identity, coordinate_gradient, 1.0 - 0.5 * coordinate_tolerance}, + result + ) == mapping::MappingStatus::AT_COMPACTIFIED_INFINITY + ); + CHECK( + compactification.evaluate( + {reference_position, reference_position, identity, coordinate_gradient, 1.0 + 0.5 * coordinate_tolerance}, + result + ) == mapping::MappingStatus::AT_COMPACTIFIED_INFINITY + ); + CHECK( + compactification.evaluate( + {reference_position, reference_position, identity, coordinate_gradient, 1.0 + 2.0 * coordinate_tolerance}, + result + ) == mapping::MappingStatus::OUTSIDE_REFERENCE_DOMAIN + ); + CHECK( + compactification.evaluate( + {reference_position, reference_position, identity, coordinate_gradient, -2.0 * coordinate_tolerance}, result + ) == mapping::MappingStatus::OUTSIDE_REFERENCE_DOMAIN + ); + + mfem::Vector surface_position = make_vector(0.97, 0.0, 0.0); + CHECK( + compactification.evaluate( + {surface_position, surface_position, identity, coordinate_gradient, -0.5 * coordinate_tolerance}, result + ) == mapping::MappingStatus::VALID + ); + check_vector(result.physical_position, surface_position, tolerance); +} + +TEST_CASE( + "Kelvin Compactification Rejects Invalid Inputs And Inverted Maps", + tags::unit & tags::kelvin +) { + mapping::compactification::KelvinCompactification compactification({.r_star_ref = 1.0, .r_inf_ref = 4.0}); + + mfem::Vector reference_position = make_vector(2.0, 0.0, 0.0); + mfem::Vector displaced_position(reference_position); + mfem::Vector coordinate_gradient = make_vector(1.0 / 3.0, 0.0, 0.0); + mfem::Vector zero_gradient = make_vector(0.0, 0.0, 0.0); + const mfem::DenseMatrix identity = make_identity(); + mapping::compactification::ExteriorMapResult result; + + mfem::Vector wrong_dimension(2); + wrong_dimension = 1.0; + CHECK( + compactification.evaluate( + {wrong_dimension, displaced_position, identity, coordinate_gradient, 1.0 / 3.0}, result + ) == mapping::MappingStatus::INVALID_DIMENSION + ); + CHECK( + compactification.evaluate( + {reference_position, displaced_position, identity, wrong_dimension, 1.0 / 3.0}, result + ) == mapping::MappingStatus::INVALID_DIMENSION + ); + + mfem::Vector non_finite_position(reference_position); + non_finite_position(1) = std::numeric_limits::quiet_NaN(); + CHECK( + compactification.evaluate( + {non_finite_position, displaced_position, identity, coordinate_gradient, 1.0 / 3.0}, result + ) == mapping::MappingStatus::NON_FINITE_MAPPING_INPUT + ); + + mfem::Vector non_finite_gradient(coordinate_gradient); + non_finite_gradient(2) = std::numeric_limits::infinity(); + CHECK( + compactification.evaluate( + {reference_position, displaced_position, identity, non_finite_gradient, 1.0 / 3.0}, result + ) == mapping::MappingStatus::NON_FINITE_MAPPING_INPUT + ); + CHECK( + compactification.evaluate( + {reference_position, displaced_position, identity, coordinate_gradient, + std::numeric_limits::quiet_NaN()}, + result + ) == mapping::MappingStatus::NON_FINITE_MAPPING_INPUT + ); + + mfem::DenseMatrix singular_displacement_jacobian(dimension); + singular_displacement_jacobian = 0.0; + CHECK( + compactification.evaluate( + {reference_position, displaced_position, singular_displacement_jacobian, zero_gradient, 1.0 / 3.0}, result + ) == mapping::MappingStatus::NON_POSITIVE_DETERMINATE + ); + + mfem::DenseMatrix inverted_displacement_jacobian = make_identity(); + inverted_displacement_jacobian(0, 0) = -1.0; + CHECK( + compactification.evaluate( + {reference_position, displaced_position, inverted_displacement_jacobian, zero_gradient, 1.0 / 3.0}, result + ) == mapping::MappingStatus::NON_POSITIVE_DETERMINATE + ); +} + +TEST_CASE( + "Kelvin Compactification Variation Rejects Invalid Inputs", + tags::unit & tags::kelvin +) { + mapping::compactification::KelvinCompactification compactification({.r_star_ref = 1.0, .r_inf_ref = 4.0}); + mfem::Vector reference_position = make_vector(2.0, 0.0, 0.0); + mfem::Vector displaced_position(reference_position); + mfem::Vector coordinate_gradient = make_vector(1.0 / 3.0, 0.0, 0.0); + const mfem::DenseMatrix identity = make_identity(); + const mapping::compactification::ExteriorMapInput input{ + reference_position, displaced_position, identity, coordinate_gradient, 1.0 / 3.0 + }; + + mapping::compactification::ExteriorMapResult result; + REQUIRE(compactification.evaluate(input, result) == mapping::MappingStatus::VALID); + + mfem::Vector valid_position_direction = make_vector(0.01, -0.02, 0.03); + const mfem::DenseMatrix valid_jacobian_direction = make_identity(); + mapping::compactification::ExteriorMapVariation variation; + + mfem::Vector wrong_dimension(2); + wrong_dimension = 0.0; + CHECK( + compactification.evaluate_variation(input, result, {wrong_dimension, valid_jacobian_direction}, variation) == + mapping::MappingStatus::INVALID_DIMENSION + ); + + mfem::DenseMatrix wrong_jacobian_dimension(2); + wrong_jacobian_dimension = 0.0; + CHECK( + compactification.evaluate_variation( + input, result, {valid_position_direction, wrong_jacobian_dimension}, variation + ) == mapping::MappingStatus::INVALID_DIMENSION + ); + + mfem::Vector non_finite_direction(valid_position_direction); + non_finite_direction(0) = std::numeric_limits::quiet_NaN(); + CHECK( + compactification.evaluate_variation( + input, result, {non_finite_direction, valid_jacobian_direction}, variation + ) == mapping::MappingStatus::NON_FINITE_MAPPING_INPUT + ); +} + +TEST_CASE( + "Kelvin Compactification Evaluations Are Independent", + tags::unit & tags::kelvin +) { + constexpr double tolerance = 0.0; + + mapping::compactification::KelvinCompactification compactification({.r_star_ref = 1.0, .r_inf_ref = 4.0}); + const mfem::DenseMatrix identity = make_identity(); + mfem::Vector gradient_a = make_vector(0.12, 0.03, -0.02); + mfem::Vector gradient_b = make_vector(-0.04, 0.15, 0.01); + mfem::Vector reference_a = make_vector(1.5, 0.2, 0.1); + mfem::Vector reference_b = make_vector(2.5, -0.3, 0.4); + + const mapping::compactification::ExteriorMapInput input_a{reference_a, reference_a, identity, gradient_a, 0.25}; + const mapping::compactification::ExteriorMapInput input_b{reference_b, reference_b, identity, gradient_b, 0.70}; + + mapping::compactification::ExteriorMapResult first_a; + mapping::compactification::ExteriorMapResult result_b; + mapping::compactification::ExteriorMapResult second_a; + + REQUIRE(compactification.evaluate(input_a, first_a) == mapping::MappingStatus::VALID); + REQUIRE(compactification.evaluate(input_b, result_b) == mapping::MappingStatus::VALID); + REQUIRE(compactification.evaluate(input_a, second_a) == mapping::MappingStatus::VALID); + + check_vector(first_a.physical_position, second_a.physical_position, tolerance); + check_matrix(first_a.mapping_jacobian, second_a.mapping_jacobian, tolerance); +} diff --git a/tests/mapping/domain_mapper.cpp b/tests/mapping/domain_mapper.cpp new file mode 100644 index 00000000..433c95da --- /dev/null +++ b/tests/mapping/domain_mapper.cpp @@ -0,0 +1,2850 @@ +// Ported from MeanField tests/mapping/domain_mapper.cpp. + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "serif/backend/mfem/mapping/compactification/kelvin.hpp" +#include "serif/backend/mfem/mapping/domain_mapper.hpp" +#include "serif/backend/mfem/mapping/evaluator.hpp" +#include "serif/backend/mfem/mapping/transformations.hpp" + +#include "serif/tests/mapping/volume_variation.hpp" +#include "serif/tests/test_tags.hpp" + +namespace mapping = serif::mapping; +namespace user_options = serif::utils::user_options; +using Catch::Matchers::WithinAbs; + +namespace { + constexpr int dimension = 3; + constexpr double tolerance = 1.0e-12; + + std::unique_ptr make_kelvin_compactification() { + return std::make_unique( + user_options::KelvinCompactificationOptions{.r_star_ref = 1.0, .r_inf_ref = 4.0} + ); + } + + mfem::DenseMatrix make_identity_matrix(const int size) { + mfem::DenseMatrix matrix(size); + matrix = 0.0; + for (int i = 0; i < size; ++i) { + matrix(i, i) = 1.0; + } + return matrix; + } + + mfem::Vector make_constant_compactification_dofs(const mfem::FiniteElement& element, const double value = 0.0) { + mfem::Vector dofs(element.GetDof()); + dofs = value; + return dofs; + } + + template + mfem::Vector make_compactification_element_dofs( + const mfem::FiniteElement& element, + mfem::ElementTransformation& transformation, + Function&& function + ) { + const mfem::IntegrationRule& nodes = element.GetNodes(); + REQUIRE(nodes.GetNPoints() == element.GetDof()); + + mfem::Vector dofs(element.GetDof()); + mfem::Vector reference_position(dimension); + + for (int i = 0; i < element.GetDof(); ++i) { + transformation.Transform(nodes.IntPoint(i), reference_position); + dofs(i) = function(reference_position); + } + + return dofs; + } + + class ElementMappingDataOwner { + public: + explicit ElementMappingDataOwner(const mapping::ElementDisplacementData& displacement) : + m_compactification( + displacement.get_element(), + make_constant_compactification_dofs(displacement.get_element()) + ), + m_element_data{.displacement = displacement, .compactification = m_compactification} {} + + ElementMappingDataOwner( + const mapping::ElementDisplacementData& displacement, + const mfem::FiniteElement& compactification_element, + const mfem::Vector& compactification_dofs + ) : + m_compactification(compactification_element, compactification_dofs), + m_element_data{.displacement = displacement, .compactification = m_compactification} {} + + ElementMappingDataOwner(const ElementMappingDataOwner&) = delete; + ElementMappingDataOwner& operator=(const ElementMappingDataOwner&) = delete; + ElementMappingDataOwner(ElementMappingDataOwner&&) = delete; + ElementMappingDataOwner& operator=(ElementMappingDataOwner&&) = delete; + + [[nodiscard]] const mapping::ElementMappingData& get() const noexcept { + return m_element_data; + } + + private: + mapping::ElementCompactificationData m_compactification; + mapping::ElementMappingData m_element_data; + }; + + void check_vector( + const mfem::Vector& actual, + const mfem::Vector& expected, + const double comparison_tolerance = tolerance + ) { + REQUIRE(actual.Size() == expected.Size()); + for (int i = 0; i < actual.Size(); ++i) { + CHECK_THAT(actual(i), WithinAbs(expected(i), comparison_tolerance)); + } + } + + void check_matrix( + const mfem::DenseMatrix& actual, + const mfem::DenseMatrix& expected, + const double comparison_tolerance = tolerance + ) { + REQUIRE(actual.Height() == expected.Height()); + REQUIRE(actual.Width() == expected.Width()); + + for (int i = 0; i < actual.Height(); ++i) { + for (int j = 0; j < actual.Width(); ++j) { + CHECK_THAT(actual(i, j), WithinAbs(expected(i, j), comparison_tolerance)); + } + } + } + + struct SingleElementFixture { + mfem::Mesh mesh; + mfem::H1_FECollection displacement_collection; + mfem::FiniteElementSpace displacement_space; + + SingleElementFixture() : + mesh(mfem::Mesh::MakeCartesian3D(1, 1, 1, mfem::Element::HEXAHEDRON, 2.0, 3.0, 4.0)), + displacement_collection(1, dimension), + displacement_space(&mesh, &displacement_collection, dimension, mfem::Ordering::byVDIM) {} + + [[nodiscard]] const mfem::FiniteElement& get_element() const { + return *displacement_space.GetFE(0); + } + + [[nodiscard]] mfem::Vector make_zero_element_dofs() const { + mfem::Vector element_dofs(get_element().GetDof() * dimension); + element_dofs = 0.0; + return element_dofs; + } + }; + + mfem::Vector make_vector(const double x, const double y, const double z) { + mfem::Vector vector(3); + vector(0) = x; + vector(1) = y; + vector(2) = z; + return vector; + } + + mfem::DenseMatrix make_affine_displacement_gradient() { + mfem::DenseMatrix gradient(3); + gradient(0, 0) = 0.10; + gradient(0, 1) = 0.04; + gradient(0, 2) = -0.02; + gradient(1, 0) = -0.03; + gradient(1, 1) = 0.08; + gradient(1, 2) = 0.01; + gradient(2, 0) = 0.02; + gradient(2, 1) = -0.01; + gradient(2, 2) = -0.05; + return gradient; + } + + mfem::Vector make_affine_element_dofs( + const mfem::FiniteElement& element, + mfem::ElementTransformation& transformation, + const mfem::DenseMatrix& displacement_gradient, + const mfem::Vector& displacement_offset, + const mfem::Ordering::Type ordering + ) { + const int dof_count = element.GetDof(); + const int field_dimension = displacement_offset.Size(); + const mfem::IntegrationRule& nodes = element.GetNodes(); + + REQUIRE(nodes.GetNPoints() == dof_count); + + mfem::Vector element_dofs(dof_count * field_dimension); + mfem::Vector reference_position(field_dimension); + mfem::Vector displacement(field_dimension); + + for (int i = 0; i < dof_count; ++i) { + transformation.Transform(nodes.IntPoint(i), reference_position); + displacement_gradient.Mult(reference_position, displacement); + displacement += displacement_offset; + + for (int component = 0; component < field_dimension; ++component) { + const int index = + ordering == mfem::Ordering::byNODES ? i + component * dof_count : component + i * field_dimension; + element_dofs(index) = displacement(component); + } + } + + return element_dofs; + } + + mfem::DenseMatrix make_deformation_jacobian(const mfem::DenseMatrix& displacement_gradient) { + mfem::DenseMatrix deformation_jacobian = make_identity_matrix(displacement_gradient.Height()); + deformation_jacobian.Add(1.0, displacement_gradient); + return deformation_jacobian; + } + + mfem::Vector evaluate_affine_physical_position( + const mfem::Vector& reference_position, + const mfem::DenseMatrix& displacement_gradient, + const mfem::Vector& displacement_offset + ) { + mfem::Vector physical_position(reference_position); + mfem::Vector displacement(reference_position.Size()); + displacement_gradient.Mult(reference_position, displacement); + physical_position += displacement; + physical_position += displacement_offset; + return physical_position; + } + + void check_point_context( + const mapping::MappingPointContext& actual, + const mapping::MappingPointContext& expected, + const double comparison_tolerance = tolerance + ) { + CHECK(actual.compactified == expected.compactified); + check_vector(actual.reference_position, expected.reference_position, comparison_tolerance); + check_vector(actual.displaced_position, expected.displaced_position, comparison_tolerance); + check_vector(actual.physical_position, expected.physical_position, comparison_tolerance); + check_matrix(actual.displacement_jacobian, expected.displacement_jacobian, comparison_tolerance); + check_matrix(actual.mapping_jacobian, expected.mapping_jacobian, comparison_tolerance); + check_matrix(actual.inverse_mapping_jacobian, expected.inverse_mapping_jacobian, comparison_tolerance); + CHECK_THAT(actual.mapping_determinate, WithinAbs(expected.mapping_determinate, comparison_tolerance)); + } + + constexpr double polynomial_tolerance = 2.0e-11; + constexpr double difference_step = 2.0e-6; + + struct QuadraticElementFixture { + mfem::Mesh mesh; + mfem::H1_FECollection displacement_collection; + mfem::FiniteElementSpace displacement_space; + + QuadraticElementFixture() : + mesh(mfem::Mesh::MakeCartesian3D(1, 1, 1, mfem::Element::HEXAHEDRON, 2.0, 3.0, 4.0)), + displacement_collection(2, dimension), + displacement_space(&mesh, &displacement_collection, dimension, mfem::Ordering::byVDIM) {} + + [[nodiscard]] const mfem::FiniteElement& get_element() const { + return *displacement_space.GetFE(0); + } + }; + + template + mfem::Vector make_function_element_dofs( + const mfem::FiniteElement& element, + mfem::ElementTransformation& transformation, + Function&& function, + const mfem::Ordering::Type ordering + ) { + const int dof_count = element.GetDof(); + const mfem::IntegrationRule& nodes = element.GetNodes(); + + REQUIRE(nodes.GetNPoints() == dof_count); + + mfem::Vector element_dofs(dof_count * dimension); + mfem::Vector reference_position(dimension); + mfem::Vector value(dimension); + + for (int i = 0; i < dof_count; ++i) { + transformation.Transform(nodes.IntPoint(i), reference_position); + function(reference_position, value); + + for (int component = 0; component < dimension; ++component) { + const int index = + ordering == mfem::Ordering::byNODES ? i + component * dof_count : component + i * dimension; + element_dofs(index) = value(component); + } + } + + return element_dofs; + } + + void evaluate_quadratic_displacement(const mfem::Vector& position, mfem::Vector& displacement) { + const double x = position(0); + const double y = position(1); + const double z = position(2); + + displacement.SetSize(dimension); + displacement(0) = 0.01 + 0.010 * x * x + 0.005 * y * z; + displacement(1) = -0.02 - 0.004 * x * y + 0.006 * z * z; + displacement(2) = 0.015 + 0.003 * x * z - 0.002 * y * y; + } + + mfem::DenseMatrix evaluate_quadratic_displacement_gradient(const mfem::Vector& position) { + const double x = position(0); + const double y = position(1); + const double z = position(2); + + mfem::DenseMatrix gradient(dimension); + gradient(0, 0) = 0.020 * x; + gradient(0, 1) = 0.005 * z; + gradient(0, 2) = 0.005 * y; + gradient(1, 0) = -0.004 * y; + gradient(1, 1) = -0.004 * x; + gradient(1, 2) = 0.012 * z; + gradient(2, 0) = 0.003 * z; + gradient(2, 1) = -0.004 * y; + gradient(2, 2) = 0.003 * x; + return gradient; + } + + void evaluate_quadratic_direction(const mfem::Vector& position, mfem::Vector& direction) { + const double x = position(0); + const double y = position(1); + const double z = position(2); + + direction.SetSize(dimension); + direction(0) = 0.020 * x - 0.010 * y * z; + direction(1) = -0.015 * y + 0.005 * x * z; + direction(2) = 0.010 * z + 0.004 * x * y; + } + + mfem::DenseMatrix evaluate_quadratic_direction_gradient(const mfem::Vector& position) { + const double x = position(0); + const double y = position(1); + const double z = position(2); + + mfem::DenseMatrix gradient(dimension); + gradient(0, 0) = 0.020; + gradient(0, 1) = -0.010 * z; + gradient(0, 2) = -0.010 * y; + gradient(1, 0) = 0.005 * z; + gradient(1, 1) = -0.015; + gradient(1, 2) = 0.005 * x; + gradient(2, 0) = 0.004 * y; + gradient(2, 1) = 0.004 * x; + gradient(2, 2) = 0.010; + return gradient; + } + + void check_vector_central_difference( + const mfem::Vector& plus, + const mfem::Vector& minus, + const mfem::Vector& expected, + const double step, + const double comparison_tolerance + ) { + REQUIRE(plus.Size() == minus.Size()); + REQUIRE(plus.Size() == expected.Size()); + + for (int i = 0; i < expected.Size(); ++i) { + const double finite_difference = (plus(i) - minus(i)) / (2.0 * step); + CHECK_THAT(finite_difference, WithinAbs(expected(i), comparison_tolerance)); + } + } + + void check_matrix_central_difference( + const mfem::DenseMatrix& plus, + const mfem::DenseMatrix& minus, + const mfem::DenseMatrix& expected, + const double step, + const double comparison_tolerance + ) { + REQUIRE(plus.Height() == minus.Height()); + REQUIRE(plus.Width() == minus.Width()); + REQUIRE(plus.Height() == expected.Height()); + REQUIRE(plus.Width() == expected.Width()); + + for (int i = 0; i < expected.Height(); ++i) { + for (int j = 0; j < expected.Width(); ++j) { + const double finite_difference = (plus(i, j) - minus(i, j)) / (2.0 * step); + CHECK_THAT(finite_difference, WithinAbs(expected(i, j), comparison_tolerance)); + } + } + } + + struct QuadraticMappingData { + mfem::Vector base_dofs; + mfem::Vector direction_dofs; + mfem::Vector plus_dofs; + mfem::Vector minus_dofs; + + QuadraticMappingData(const mfem::FiniteElement& element, mfem::ElementTransformation& transformation) { + base_dofs = make_function_element_dofs( + element, transformation, evaluate_quadratic_displacement, mfem::Ordering::byNODES + ); + direction_dofs = make_function_element_dofs( + element, transformation, evaluate_quadratic_direction, mfem::Ordering::byNODES + ); + plus_dofs = base_dofs; + minus_dofs = base_dofs; + plus_dofs.Add(difference_step, direction_dofs); + minus_dofs.Add(-difference_step, direction_dofs); + } + }; + + void check_scalar_relative( + const double actual, + const double expected, + const double relative_tolerance, + const double absolute_tolerance = 1.0e-11 + ) { + CHECK_THAT(actual, WithinAbs(expected, absolute_tolerance + relative_tolerance * std::abs(expected))); + } + + void check_vector_central_difference_relative( + const mfem::Vector& plus, + const mfem::Vector& minus, + const mfem::Vector& expected, + const double step, + const double relative_tolerance + ) { + REQUIRE(plus.Size() == minus.Size()); + REQUIRE(plus.Size() == expected.Size()); + + for (int i = 0; i < expected.Size(); ++i) { + const double finite_difference = (plus(i) - minus(i)) / (2.0 * step); + check_scalar_relative(finite_difference, expected(i), relative_tolerance); + } + } + + void check_matrix_central_difference_relative( + const mfem::DenseMatrix& plus, + const mfem::DenseMatrix& minus, + const mfem::DenseMatrix& expected, + const double step, + const double relative_tolerance + ) { + REQUIRE(plus.Height() == minus.Height()); + REQUIRE(plus.Width() == minus.Width()); + REQUIRE(plus.Height() == expected.Height()); + REQUIRE(plus.Width() == expected.Width()); + + for (int i = 0; i < expected.Height(); ++i) { + for (int j = 0; j < expected.Width(); ++j) { + const double finite_difference = (plus(i, j) - minus(i, j)) / (2.0 * step); + check_scalar_relative(finite_difference, expected(i, j), relative_tolerance); + } + } + } + + mfem::Vector matrix_curl(const mfem::DenseMatrix& gradient) { + return make_vector( + gradient(2, 1) - gradient(1, 2), gradient(0, 2) - gradient(2, 0), gradient(1, 0) - gradient(0, 1) + ); + } + + void check_centered_difference( + const double analytic, + const double finite_difference, + const double plus_value, + const double minus_value, + const double step, + const double relative_tolerance = 2.0e-6, + const double absolute_tolerance = 1.0e-11 + ) { + const double derivative_scale = std::max(std::abs(analytic), std::abs(finite_difference)); + const double primal_scale = std::max(std::abs(plus_value), std::abs(minus_value)); + const double roundoff_tolerance = 8.0 * std::numeric_limits::epsilon() * primal_scale / step; + const double tolerance = absolute_tolerance + relative_tolerance * derivative_scale + roundoff_tolerance; + + CHECK_THAT(finite_difference, Catch::Matchers::WithinAbs(analytic, tolerance)); + } + + double relative_vector_difference(const mfem::Vector& lhs, const mfem::Vector& rhs) { + mfem::Vector difference(lhs); + difference -= rhs; + + const double scale = std::max({lhs.Norml2(), rhs.Norml2(), 1.0e-12}); + return difference.Norml2() / scale; + } + + double relative_matrix_difference(const mfem::DenseMatrix& lhs, const mfem::DenseMatrix& rhs) { + mfem::DenseMatrix difference(lhs); + difference -= rhs; + + const double scale = std::max({lhs.FNorm(), rhs.FNorm(), 1.0e-12}); + return difference.FNorm() / scale; + } +} // namespace + +TEST_CASE( + "Element Displacement Data Rejects Invalid Vector Sizes", + tags::unit & tags::mapping +) { + SingleElementFixture fixture; + + const mfem::FiniteElement& element = fixture.get_element(); + const int dof_count = element.GetDof(); + + mfem::Vector empty_dofs; + mfem::Vector incomplete_dofs(dof_count * dimension - 1); + incomplete_dofs = 0.0; + + CHECK_THROWS_AS( + mapping::ElementDisplacementData(element, empty_dofs), std::invalid_argument + ); + CHECK_THROWS_AS( + mapping::ElementDisplacementData(element, incomplete_dofs), std::invalid_argument + ); +} + +TEST_CASE( + "Domain Mapping Workspace Tracks Its Dimension", + tags::unit & tags::mapping +) { + mapping::DMWorkspace workspace(dimension); + + REQUIRE(workspace.get_dimension() == dimension); + + workspace.set_dimension(2); + REQUIRE(workspace.get_dimension() == 2); + + workspace.set_dimension(dimension); + REQUIRE(workspace.get_dimension() == dimension); + + CHECK_THROWS_AS(workspace.set_dimension(0), std::invalid_argument); + CHECK_THROWS_AS(workspace.set_dimension(-1), std::invalid_argument); + CHECK_THROWS_AS(mapping::DMWorkspace(0), std::invalid_argument); +} + +TEST_CASE( + "Stateless Domain Mapper Validates Its Configuration", + tags::unit & tags::mapping +) { + const user_options::DomainMapperOptions valid_options{.dimension = dimension, .vacuum_element_attribute_ID = 3}; + mapping::DomainMapper mapper(valid_options, make_kelvin_compactification()); + + REQUIRE(mapper.get_dimension() == dimension); + REQUIRE(mapper.get_exterior_map().get_name() == "KelvinCompactification"); + + const user_options::DomainMapperOptions invalid_dimension{.dimension = 0, .vacuum_element_attribute_ID = 3}; + const user_options::DomainMapperOptions invalid_attribute{.dimension = dimension, .vacuum_element_attribute_ID = 0}; + + CHECK_THROWS_AS(mapping::DomainMapper(invalid_dimension, make_kelvin_compactification()), std::invalid_argument); + CHECK_THROWS_AS(mapping::DomainMapper(invalid_attribute, make_kelvin_compactification()), std::invalid_argument); + + std::unique_ptr null_exterior_map; + CHECK_THROWS_AS(mapping::DomainMapper(valid_options, std::move(null_exterior_map)), std::invalid_argument); +} + +TEST_CASE( + "Stateless Domain Mapper Preserves Identity Point Geometry", + tags::unit & tags::mapping +) { + SingleElementFixture fixture; + + const mfem::FiniteElement& element = fixture.get_element(); + mfem::Vector zero_dofs = fixture.make_zero_element_dofs(); + const mapping::ElementDisplacementData displacement(element, zero_dofs); + const ElementMappingDataOwner element_data(displacement); + + mapping::DomainMapper mapper( + {.dimension = dimension, .vacuum_element_attribute_ID = 3}, make_kelvin_compactification() + ); + mapping::DMWorkspace workspace(dimension); + mapping::MappingPointContext context; + + mfem::ElementTransformation* transformation = fixture.mesh.GetElementTransformation(0); + REQUIRE_FALSE(mapper.is_compactified_element(*transformation)); + + const mfem::IntegrationRule& integration_rule = mfem::IntRules.Get(transformation->GetGeometryType(), 4); + const mfem::DenseMatrix identity = make_identity_matrix(dimension); + + for (int q = 0; q < integration_rule.GetNPoints(); ++q) { + const mfem::IntegrationPoint& integration_point = integration_rule.IntPoint(q); + mfem::Vector expected_position(dimension); + transformation->Transform(integration_point, expected_position); + + REQUIRE( + mapper.evaluate_point(element_data.get(), *transformation, integration_point, workspace, context) == + mapping::MappingStatus::VALID + ); + + CAPTURE(q); + REQUIRE_FALSE(context.compactified); + check_vector(context.reference_position, expected_position); + check_vector(context.displaced_position, expected_position); + check_vector(context.physical_position, expected_position); + check_matrix(context.displacement_jacobian, identity); + check_matrix(context.mapping_jacobian, identity); + check_matrix(context.inverse_mapping_jacobian, identity); + CHECK_THAT(context.mapping_determinate, WithinAbs(1.0, tolerance)); + } +} + +TEST_CASE( + "Stateless Domain Mapper Preserves Identity Volume Geometry", + tags::unit & tags::mapping +) { + SingleElementFixture fixture; + + const mfem::FiniteElement& element = fixture.get_element(); + mfem::Vector zero_dofs = fixture.make_zero_element_dofs(); + const mapping::ElementDisplacementData displacement(element, zero_dofs); + const ElementMappingDataOwner element_data(displacement); + + mapping::DomainMapper mapper( + {.dimension = dimension, .vacuum_element_attribute_ID = 3}, make_kelvin_compactification() + ); + mapping::DMWorkspace workspace(dimension); + mapping::VolumeMappingContext context; + + mfem::ElementTransformation* transformation = fixture.mesh.GetElementTransformation(0); + const mfem::IntegrationRule& integration_rule = mfem::IntRules.Get(transformation->GetGeometryType(), 4); + + for (int q = 0; q < integration_rule.GetNPoints(); ++q) { + const mfem::IntegrationPoint& integration_point = integration_rule.IntPoint(q); + + REQUIRE( + mapper.evaluate_volume(element_data.get(), *transformation, integration_point, workspace, context) == + mapping::MappingStatus::VALID + ); + + transformation->SetIntPoint(&integration_point); + + mfem::DenseMatrix expected_inverse(dimension); + mfem::CalcInverse(transformation->Jacobian(), expected_inverse); + + const double expected_weight = integration_point.weight * transformation->Weight(); + + CAPTURE(q); + REQUIRE_FALSE(context.mapping.compactified); + check_matrix(context.quadrature.J_element_to_physical_inverse, expected_inverse); + CHECK_THAT(context.quadrature.mapping_determinate, WithinAbs(1.0, tolerance)); + CHECK_THAT(context.quadrature.physical_integration_weight, WithinAbs(expected_weight, tolerance)); + CHECK(context.quadrature.physical_integration_weight > 0.0); + } +} + +TEST_CASE( + "Stateless Domain Mapper Preserves Identity Face Geometry", + tags::unit & tags::mapping +) { + SingleElementFixture fixture; + + const mfem::FiniteElement& element = fixture.get_element(); + mfem::Vector zero_dofs = fixture.make_zero_element_dofs(); + const mapping::ElementDisplacementData displacement(element, zero_dofs); + const ElementMappingDataOwner element_data(displacement); + + mapping::DomainMapper mapper( + {.dimension = dimension, .vacuum_element_attribute_ID = 3}, make_kelvin_compactification() + ); + mapping::DMWorkspace workspace(dimension); + mapping::FaceMappingContext context; + + for (int boundary_element = 0; boundary_element < fixture.mesh.GetNBE(); ++boundary_element) { + mfem::FaceElementTransformations* transformation = fixture.mesh.GetBdrFaceTransformations(boundary_element); + REQUIRE(transformation != nullptr); + REQUIRE(transformation->Elem1 != nullptr); + + const mfem::IntegrationRule& integration_rule = mfem::IntRules.Get(transformation->GetGeometryType(), 4); + + for (int q = 0; q < integration_rule.GetNPoints(); ++q) { + const mfem::IntegrationPoint& integration_point = integration_rule.IntPoint(q); + + REQUIRE( + mapper.evaluate_face( + element_data.get(), *transformation, mapping::FaceElementSide::ELEMENT_1, integration_point, + workspace, context + ) == mapping::MappingStatus::VALID + ); + + transformation->SetAllIntPoints(&integration_point); + + mfem::Vector raw_normal(dimension); + mfem::CalcOrtho(transformation->Jacobian(), raw_normal); + + const double raw_normal_magnitude = raw_normal.Norml2(); + mfem::Vector expected_normal(raw_normal); + expected_normal /= raw_normal_magnitude; + + const double expected_surface_weight = integration_point.weight * raw_normal_magnitude; + + const mfem::IntegrationPoint element_integration_point = transformation->Elem1->GetIntPoint(); + + mfem::Vector expected_position(dimension); + transformation->Elem1->Transform(element_integration_point, expected_position); + + CAPTURE(boundary_element, q); + REQUIRE_FALSE(context.mapping.compactified); + check_vector(context.mapping.reference_position, expected_position); + check_vector(context.mapping.displaced_position, expected_position); + check_vector(context.mapping.physical_position, expected_position); + check_vector(context.reference_normal, expected_normal); + check_vector(context.quadrature.normal, expected_normal); + CHECK_THAT(context.reference_surface_weight, WithinAbs(expected_surface_weight, tolerance)); + CHECK_THAT(context.physical_surface_weight, WithinAbs(expected_surface_weight, tolerance)); + CHECK_THAT(context.quadrature.ds, WithinAbs(expected_surface_weight, tolerance)); + CHECK_THAT(context.quadrature.v_dot_n_scale, WithinAbs(1.0, tolerance)); + } + } +} + +TEST_CASE( + "Stateless Domain Mapper Matches Exact Affine Point Mapping", + tags::unit & tags::mapping +) { + SingleElementFixture fixture; + + const mfem::FiniteElement& element = fixture.get_element(); + mfem::ElementTransformation* transformation = fixture.mesh.GetElementTransformation(0); + + const mfem::DenseMatrix displacement_gradient = make_affine_displacement_gradient(); + const mfem::DenseMatrix deformation_jacobian = make_deformation_jacobian(displacement_gradient); + mfem::Vector displacement_offset = make_vector(0.07, -0.04, 0.03); + mfem::Vector element_dofs = make_affine_element_dofs( + element, *transformation, displacement_gradient, displacement_offset, mfem::Ordering::byNODES + ); + const mapping::ElementDisplacementData displacement(element, element_dofs); + const ElementMappingDataOwner element_data(displacement); + + mapping::DomainMapper mapper( + {.dimension = dimension, .vacuum_element_attribute_ID = 3}, make_kelvin_compactification() + ); + mapping::DMWorkspace workspace(dimension); + mapping::MappingPointContext context; + + mfem::DenseMatrix inverse_deformation_jacobian(dimension); + mfem::CalcInverse(deformation_jacobian, inverse_deformation_jacobian); + const double deformation_determinant = deformation_jacobian.Det(); + + REQUIRE(deformation_determinant > 0.0); + + const mfem::IntegrationRule& integration_rule = mfem::IntRules.Get(transformation->GetGeometryType(), 6); + + for (int q = 0; q < integration_rule.GetNPoints(); ++q) { + const mfem::IntegrationPoint& integration_point = integration_rule.IntPoint(q); + mfem::Vector reference_position(dimension); + transformation->Transform(integration_point, reference_position); + + mfem::Vector expected_position = + evaluate_affine_physical_position(reference_position, displacement_gradient, displacement_offset); + + REQUIRE( + mapper.evaluate_point(element_data.get(), *transformation, integration_point, workspace, context) == + mapping::MappingStatus::VALID + ); + + CAPTURE(q); + REQUIRE_FALSE(context.compactified); + check_vector(context.reference_position, reference_position); + check_vector(context.displaced_position, expected_position); + check_vector(context.physical_position, expected_position); + check_matrix(context.displacement_jacobian, deformation_jacobian); + check_matrix(context.mapping_jacobian, deformation_jacobian); + check_matrix(context.inverse_mapping_jacobian, inverse_deformation_jacobian); + CHECK_THAT(context.mapping_determinate, WithinAbs(deformation_determinant, tolerance)); + } +} + +TEST_CASE( + "Stateless Domain Mapper Composes Affine Volume Jacobians", + tags::unit & tags::mapping +) { + SingleElementFixture fixture; + + const mfem::FiniteElement& element = fixture.get_element(); + mfem::ElementTransformation* transformation = fixture.mesh.GetElementTransformation(0); + + const mfem::DenseMatrix displacement_gradient = make_affine_displacement_gradient(); + const mfem::DenseMatrix mapping_jacobian = make_deformation_jacobian(displacement_gradient); + mfem::Vector displacement_offset = make_vector(0.07, -0.04, 0.03); + mfem::Vector element_dofs = make_affine_element_dofs( + element, *transformation, displacement_gradient, displacement_offset, mfem::Ordering::byNODES + ); + const mapping::ElementDisplacementData displacement(element, element_dofs); + const ElementMappingDataOwner element_data(displacement); + + mapping::DomainMapper mapper( + {.dimension = dimension, .vacuum_element_attribute_ID = 3}, make_kelvin_compactification() + ); + mapping::DMWorkspace workspace(dimension); + mapping::VolumeMappingContext context; + + const double mapping_determinate = mapping_jacobian.Det(); + REQUIRE(mapping_determinate > 0.0); + + const mfem::IntegrationRule& integration_rule = mfem::IntRules.Get(transformation->GetGeometryType(), 6); + + for (int q = 0; q < integration_rule.GetNPoints(); ++q) { + const mfem::IntegrationPoint& integration_point = integration_rule.IntPoint(q); + transformation->SetIntPoint(&integration_point); + + mfem::DenseMatrix full_element_jacobian(dimension); + mfem::DenseMatrix expected_inverse(dimension); + mfem::Mult(mapping_jacobian, transformation->Jacobian(), full_element_jacobian); + mfem::CalcInverse(full_element_jacobian, expected_inverse); + + const double expected_weight = integration_point.weight * transformation->Weight() * mapping_determinate; + + REQUIRE( + mapper.evaluate_volume(element_data.get(), *transformation, integration_point, workspace, context) == + mapping::MappingStatus::VALID + ); + + CAPTURE(q); + check_matrix(context.mapping.mapping_jacobian, mapping_jacobian); + check_matrix(context.quadrature.J_element_to_physical_inverse, expected_inverse); + CHECK_THAT(context.quadrature.mapping_determinate, WithinAbs(mapping_determinate, tolerance)); + CHECK_THAT(context.quadrature.physical_integration_weight, WithinAbs(expected_weight, tolerance)); + } +} + +TEST_CASE( + "Stateless Domain Mapper Applies Nanson Formula On Affine Faces", + tags::unit & tags::mapping +) { + SingleElementFixture fixture; + + const mfem::FiniteElement& element = fixture.get_element(); + mfem::ElementTransformation* element_transformation = fixture.mesh.GetElementTransformation(0); + + const mfem::DenseMatrix displacement_gradient = make_affine_displacement_gradient(); + const mfem::DenseMatrix mapping_jacobian = make_deformation_jacobian(displacement_gradient); + mfem::Vector displacement_offset = make_vector(0.07, -0.04, 0.03); + mfem::Vector element_dofs = make_affine_element_dofs( + element, *element_transformation, displacement_gradient, displacement_offset, mfem::Ordering::byNODES + ); + const mapping::ElementDisplacementData displacement(element, element_dofs); + const ElementMappingDataOwner element_data(displacement); + + mfem::DenseMatrix inverse_mapping_jacobian(dimension); + mfem::CalcInverse(mapping_jacobian, inverse_mapping_jacobian); + const double mapping_determinate = mapping_jacobian.Det(); + + mapping::DomainMapper mapper( + {.dimension = dimension, .vacuum_element_attribute_ID = 3}, make_kelvin_compactification() + ); + mapping::DMWorkspace workspace(dimension); + mapping::FaceMappingContext context; + + for (int boundary_element = 0; boundary_element < fixture.mesh.GetNBE(); ++boundary_element) { + mfem::FaceElementTransformations* transformation = fixture.mesh.GetBdrFaceTransformations(boundary_element); + REQUIRE(transformation != nullptr); + REQUIRE(transformation->Elem1 != nullptr); + + const mfem::IntegrationRule& integration_rule = mfem::IntRules.Get(transformation->GetGeometryType(), 6); + + for (int q = 0; q < integration_rule.GetNPoints(); ++q) { + const mfem::IntegrationPoint& integration_point = integration_rule.IntPoint(q); + transformation->SetAllIntPoints(&integration_point); + + mfem::Vector raw_normal(dimension); + mfem::Vector mapped_normal(dimension); + mfem::CalcOrtho(transformation->Jacobian(), raw_normal); + + inverse_mapping_jacobian.MultTranspose(raw_normal, mapped_normal); + mapped_normal *= mapping_determinate; + + const double raw_normal_magnitude = raw_normal.Norml2(); + const double mapped_normal_magnitude = mapped_normal.Norml2(); + + mfem::Vector expected_reference_normal(raw_normal); + mfem::Vector expected_physical_normal(mapped_normal); + expected_reference_normal /= raw_normal_magnitude; + expected_physical_normal /= mapped_normal_magnitude; + + const double expected_reference_weight = integration_point.weight * raw_normal_magnitude; + const double expected_physical_weight = integration_point.weight * mapped_normal_magnitude; + const double expected_normal_scale = mapped_normal_magnitude / raw_normal_magnitude; + + REQUIRE( + mapper.evaluate_face( + element_data.get(), *transformation, mapping::FaceElementSide::ELEMENT_1, integration_point, + workspace, context + ) == mapping::MappingStatus::VALID + ); + + CAPTURE(boundary_element, q); + check_matrix(context.mapping.mapping_jacobian, mapping_jacobian); + check_vector(context.reference_normal, expected_reference_normal); + check_vector(context.quadrature.normal, expected_physical_normal); + CHECK_THAT(context.reference_surface_weight, WithinAbs(expected_reference_weight, tolerance)); + CHECK_THAT(context.physical_surface_weight, WithinAbs(expected_physical_weight, tolerance)); + CHECK_THAT(context.quadrature.ds, WithinAbs(expected_reference_weight, tolerance)); + CHECK_THAT(context.quadrature.v_dot_n_scale, WithinAbs(expected_normal_scale, tolerance)); + } + } +} + +TEST_CASE( + "Stateless Domain Mapper Has No Cross State Contamination", + tags::unit & tags::mapping +) { + SingleElementFixture fixture; + + const mfem::FiniteElement& element = fixture.get_element(); + mfem::ElementTransformation* transformation = fixture.mesh.GetElementTransformation(0); + + const mfem::DenseMatrix gradient_a = make_affine_displacement_gradient(); + mfem::DenseMatrix gradient_b(3); + gradient_b = 0.0; + gradient_b(0, 0) = -0.06; + gradient_b(0, 2) = 0.03; + gradient_b(1, 0) = 0.02; + gradient_b(1, 1) = 0.12; + gradient_b(2, 1) = -0.04; + gradient_b(2, 2) = 0.07; + + mfem::Vector offset_a = make_vector(0.07, -0.04, 0.03); + mfem::Vector offset_b = make_vector(-0.05, 0.08, -0.02); + + mfem::Vector dofs_a = + make_affine_element_dofs(element, *transformation, gradient_a, offset_a, mfem::Ordering::byNODES); + mfem::Vector dofs_b = + make_affine_element_dofs(element, *transformation, gradient_b, offset_b, mfem::Ordering::byNODES); + + const mapping::ElementDisplacementData displacement_a(element, dofs_a); + const mapping::ElementDisplacementData displacement_b(element, dofs_b); + const ElementMappingDataOwner element_data_a(displacement_a); + const ElementMappingDataOwner element_data_b(displacement_b); + + mapping::DomainMapper mapper( + {.dimension = dimension, .vacuum_element_attribute_ID = 3}, make_kelvin_compactification() + ); + mapping::DMWorkspace workspace(dimension); + + const mfem::IntegrationPoint& integration_point = mfem::Geometries.GetCenter(transformation->GetGeometryType()); + + mapping::MappingPointContext first_a; + mapping::MappingPointContext result_b; + mapping::MappingPointContext second_a; + + REQUIRE( + mapper.evaluate_point(element_data_a.get(), *transformation, integration_point, workspace, first_a) == + mapping::MappingStatus::VALID + ); + REQUIRE( + mapper.evaluate_point(element_data_b.get(), *transformation, integration_point, workspace, result_b) == + mapping::MappingStatus::VALID + ); + REQUIRE( + mapper.evaluate_point(element_data_a.get(), *transformation, integration_point, workspace, second_a) == + mapping::MappingStatus::VALID + ); + + check_point_context(first_a, second_a, 0.0); + + mfem::Vector state_difference(result_b.physical_position); + state_difference -= first_a.physical_position; + CHECK(state_difference.Norml2() > 1.0e-3); +} + +TEST_CASE( + "Stateless Domain Mapper Reports Invalid Element States", + tags::unit & tags::mapping +) { + SingleElementFixture fixture; + + const mfem::FiniteElement& element = fixture.get_element(); + mfem::ElementTransformation* transformation = fixture.mesh.GetElementTransformation(0); + const mfem::IntegrationPoint& integration_point = mfem::Geometries.GetCenter(transformation->GetGeometryType()); + + mapping::DomainMapper mapper( + {.dimension = dimension, .vacuum_element_attribute_ID = 3}, make_kelvin_compactification() + ); + mapping::DMWorkspace workspace(dimension); + mapping::MappingPointContext context; + + mfem::Vector non_finite_dofs = fixture.make_zero_element_dofs(); + non_finite_dofs(0) = std::numeric_limits::quiet_NaN(); + const mapping::ElementDisplacementData non_finite_displacement(element, non_finite_dofs); + const ElementMappingDataOwner non_finite_data(non_finite_displacement); + + CHECK( + mapper.evaluate_point(non_finite_data.get(), *transformation, integration_point, workspace, context) == + mapping::MappingStatus::NON_FINITE_MAPPING_INPUT + ); + + mfem::DenseMatrix singular_gradient(dimension); + singular_gradient = 0.0; + for (int i = 0; i < dimension; ++i) { + singular_gradient(i, i) = -1.0; + } + + mfem::Vector zero_offset(dimension); + zero_offset = 0.0; + mfem::Vector singular_dofs = + make_affine_element_dofs(element, *transformation, singular_gradient, zero_offset, mfem::Ordering::byNODES); + const mapping::ElementDisplacementData singular_displacement(element, singular_dofs); + const ElementMappingDataOwner singular_data(singular_displacement); + + CHECK( + mapper.evaluate_point(singular_data.get(), *transformation, integration_point, workspace, context) == + mapping::MappingStatus::NON_POSITIVE_DETERMINATE + ); + + mfem::DenseMatrix inverted_gradient(dimension); + inverted_gradient = 0.0; + for (int i = 0; i < dimension; ++i) { + inverted_gradient(i, i) = -2.0; + } + + mfem::Vector inverted_dofs = + make_affine_element_dofs(element, *transformation, inverted_gradient, zero_offset, mfem::Ordering::byNODES); + const mapping::ElementDisplacementData inverted_displacement(element, inverted_dofs); + const ElementMappingDataOwner inverted_data(inverted_displacement); + + CHECK( + mapper.evaluate_point(inverted_data.get(), *transformation, integration_point, workspace, context) == + mapping::MappingStatus::NON_POSITIVE_DETERMINATE + ); + + mapping::DMWorkspace wrong_workspace(2); + CHECK_THROWS_AS( + mapper.evaluate_point(singular_data.get(), *transformation, integration_point, wrong_workspace, context), + std::invalid_argument + ); + + mfem::Vector two_dimensional_dofs(element.GetDof() * 2); + two_dimensional_dofs = 0.0; + const mapping::ElementDisplacementData two_dimensional_displacement( + element, two_dimensional_dofs); + const ElementMappingDataOwner two_dimensional_data(two_dimensional_displacement); + + CHECK_THROWS_AS( + mapper.evaluate_point(two_dimensional_data.get(), *transformation, integration_point, workspace, context), + std::invalid_argument + ); +} + +TEST_CASE( + "Stateless Domain Mapper Matches Exact Quadratic Point Mapping", + tags::unit & tags::mapping +) { + QuadraticElementFixture fixture; + + const mfem::FiniteElement& element = fixture.get_element(); + mfem::ElementTransformation* transformation = fixture.mesh.GetElementTransformation(0); + + mfem::Vector element_dofs = + make_function_element_dofs(element, *transformation, evaluate_quadratic_displacement, mfem::Ordering::byNODES); + const mapping::ElementDisplacementData displacement(element, element_dofs); + const ElementMappingDataOwner element_data(displacement); + + mapping::DomainMapper mapper( + {.dimension = dimension, .vacuum_element_attribute_ID = 3}, make_kelvin_compactification() + ); + mapping::DMWorkspace workspace(dimension); + mapping::MappingPointContext context; + + const mfem::IntegrationRule& integration_rule = mfem::IntRules.Get(transformation->GetGeometryType(), 6); + + for (int q = 0; q < integration_rule.GetNPoints(); ++q) { + const mfem::IntegrationPoint& integration_point = integration_rule.IntPoint(q); + + mfem::Vector reference_position(dimension); + mfem::Vector expected_displacement(dimension); + transformation->Transform(integration_point, reference_position); + evaluate_quadratic_displacement(reference_position, expected_displacement); + + mfem::Vector expected_position(reference_position); + expected_position += expected_displacement; + + const mfem::DenseMatrix displacement_gradient = evaluate_quadratic_displacement_gradient(reference_position); + mfem::DenseMatrix expected_jacobian = make_identity_matrix(dimension); + expected_jacobian.Add(1.0, displacement_gradient); + + mfem::DenseMatrix expected_inverse(dimension); + mfem::CalcInverse(expected_jacobian, expected_inverse); + const double expected_determinant = expected_jacobian.Det(); + + REQUIRE(expected_determinant > 0.0); + REQUIRE( + mapper.evaluate_point(element_data.get(), *transformation, integration_point, workspace, context) == + mapping::MappingStatus::VALID + ); + + CAPTURE(q); + REQUIRE_FALSE(context.compactified); + check_vector(context.reference_position, reference_position, polynomial_tolerance); + check_vector(context.displaced_position, expected_position, polynomial_tolerance); + check_vector(context.physical_position, expected_position, polynomial_tolerance); + check_matrix(context.displacement_jacobian, expected_jacobian, polynomial_tolerance); + check_matrix(context.mapping_jacobian, expected_jacobian, polynomial_tolerance); + check_matrix(context.inverse_mapping_jacobian, expected_inverse, polynomial_tolerance); + CHECK_THAT(context.mapping_determinate, WithinAbs(expected_determinant, polynomial_tolerance)); + } +} + +TEST_CASE( + "Stateless Domain Mapper Point Linearization Matches Centered Differences", + tags::unit & tags::mapping +) { + constexpr double linearization_tolerance = 2.0e-9; + + QuadraticElementFixture fixture; + + const mfem::FiniteElement& element = fixture.get_element(); + mfem::ElementTransformation* transformation = fixture.mesh.GetElementTransformation(0); + QuadraticMappingData mapping_data(element, *transformation); + + const mapping::ElementDisplacementData base_displacement(element, mapping_data.base_dofs); + const mapping::ElementDisplacementData direction(element, mapping_data.direction_dofs); + const mapping::ElementDisplacementData plus_displacement(element, mapping_data.plus_dofs); + const mapping::ElementDisplacementData minus_displacement(element, mapping_data.minus_dofs); + const ElementMappingDataOwner base_data(base_displacement); + const ElementMappingDataOwner plus_data(plus_displacement); + const ElementMappingDataOwner minus_data(minus_displacement); + + mapping::DomainMapper mapper( + {.dimension = dimension, .vacuum_element_attribute_ID = 3}, make_kelvin_compactification() + ); + mapping::DMWorkspace workspace(dimension); + + const mfem::IntegrationRule& integration_rule = mfem::IntRules.Get(transformation->GetGeometryType(), 6); + + for (int q = 0; q < integration_rule.GetNPoints(); ++q) { + const mfem::IntegrationPoint& integration_point = integration_rule.IntPoint(q); + + mapping::MappingPointContext base_context; + mapping::MappingPointContext plus_context; + mapping::MappingPointContext minus_context; + mapping::MappingPointVariation variation; + + REQUIRE( + mapper.evaluate_point(base_data.get(), *transformation, integration_point, workspace, base_context) == + mapping::MappingStatus::VALID + ); + REQUIRE( + mapper.evaluate_point_variation( + base_data.get(), direction, *transformation, integration_point, base_context, workspace, variation + ) == mapping::MappingStatus::VALID + ); + REQUIRE( + mapper.evaluate_point(plus_data.get(), *transformation, integration_point, workspace, plus_context) == + mapping::MappingStatus::VALID + ); + REQUIRE( + mapper.evaluate_point(minus_data.get(), *transformation, integration_point, workspace, minus_context) == + mapping::MappingStatus::VALID + ); + + mfem::Vector reference_position(dimension); + mfem::Vector expected_direction(dimension); + transformation->Transform(integration_point, reference_position); + evaluate_quadratic_direction(reference_position, expected_direction); + + const mfem::DenseMatrix expected_direction_gradient = evaluate_quadratic_direction_gradient(reference_position); + + CAPTURE(q); + check_vector(variation.displacement_variation, expected_direction, polynomial_tolerance); + check_vector(variation.physical_position_variation, expected_direction, polynomial_tolerance); + check_matrix(variation.displacement_jacobian_variation, expected_direction_gradient, polynomial_tolerance); + check_matrix(variation.mapping_jacobian_variation, expected_direction_gradient, polynomial_tolerance); + + check_vector_central_difference( + plus_context.physical_position, minus_context.physical_position, variation.physical_position_variation, + difference_step, linearization_tolerance + ); + check_matrix_central_difference( + plus_context.mapping_jacobian, minus_context.mapping_jacobian, variation.mapping_jacobian_variation, + difference_step, linearization_tolerance + ); + check_matrix_central_difference( + plus_context.inverse_mapping_jacobian, minus_context.inverse_mapping_jacobian, + variation.inverse_mapping_jacobian_variation, difference_step, linearization_tolerance + ); + + const double determinant_finite_difference = + (plus_context.mapping_determinate - minus_context.mapping_determinate) / (2.0 * difference_step); + CHECK_THAT( + determinant_finite_difference, WithinAbs(variation.mapping_determinate_variation, linearization_tolerance) + ); + } +} + +TEST_CASE( + "Stateless Domain Mapper Volume Linearization Matches Centered Differences", + tags::unit & tags::mapping +) { + constexpr double linearization_tolerance = 5.0e-9; + + QuadraticElementFixture fixture; + + const mfem::FiniteElement& element = fixture.get_element(); + mfem::ElementTransformation* transformation = fixture.mesh.GetElementTransformation(0); + QuadraticMappingData mapping_data(element, *transformation); + + const mapping::ElementDisplacementData base_displacement(element, mapping_data.base_dofs); + const mapping::ElementDisplacementData direction(element, mapping_data.direction_dofs); + const mapping::ElementDisplacementData plus_displacement(element, mapping_data.plus_dofs); + const mapping::ElementDisplacementData minus_displacement(element, mapping_data.minus_dofs); + const ElementMappingDataOwner base_data(base_displacement); + const ElementMappingDataOwner plus_data(plus_displacement); + const ElementMappingDataOwner minus_data(minus_displacement); + + mapping::DomainMapper mapper( + {.dimension = dimension, .vacuum_element_attribute_ID = 3}, make_kelvin_compactification() + ); + mapping::DMWorkspace workspace(dimension); + + const mfem::IntegrationRule& integration_rule = mfem::IntRules.Get(transformation->GetGeometryType(), 6); + + for (int q = 0; q < integration_rule.GetNPoints(); ++q) { + const mfem::IntegrationPoint& integration_point = integration_rule.IntPoint(q); + + mapping::VolumeMappingContext base_context; + mapping::VolumeMappingContext plus_context; + mapping::VolumeMappingContext minus_context; + mapping::VolumeMappingVariation variation; + + REQUIRE( + mapper.evaluate_volume(base_data.get(), *transformation, integration_point, workspace, base_context) == + mapping::MappingStatus::VALID + ); + REQUIRE( + serif::tests::evaluate_volume_variation(mapper, + base_data.get(), direction, *transformation, integration_point, base_context, workspace, variation + ) == mapping::MappingStatus::VALID + ); + REQUIRE( + mapper.evaluate_volume(plus_data.get(), *transformation, integration_point, workspace, plus_context) == + mapping::MappingStatus::VALID + ); + REQUIRE( + mapper.evaluate_volume(minus_data.get(), *transformation, integration_point, workspace, minus_context) == + mapping::MappingStatus::VALID + ); + + CAPTURE(q); + const double determinant_finite_difference = + (plus_context.quadrature.mapping_determinate - minus_context.quadrature.mapping_determinate) / + (2.0 * difference_step); + const double weight_finite_difference = (plus_context.quadrature.physical_integration_weight - + minus_context.quadrature.physical_integration_weight) / + (2.0 * difference_step); + + CHECK_THAT( + determinant_finite_difference, + WithinAbs(variation.mapping.mapping_determinate_variation, linearization_tolerance) + ); + CHECK_THAT(weight_finite_difference, WithinAbs(variation.weight_variation, linearization_tolerance)); + } +} + +TEST_CASE( + "Stateless Domain Mapper Face Linearization Matches Centered Differences", + tags::unit & tags::mapping +) { + constexpr double normal_tolerance = 2.0e-8; + constexpr double measure_tolerance = 2.0e-8; + + QuadraticElementFixture fixture; + + const mfem::FiniteElement& element = fixture.get_element(); + mfem::ElementTransformation* element_transformation = fixture.mesh.GetElementTransformation(0); + QuadraticMappingData mapping_data(element, *element_transformation); + + const mapping::ElementDisplacementData base_displacement(element, mapping_data.base_dofs); + const mapping::ElementDisplacementData direction(element, mapping_data.direction_dofs); + const mapping::ElementDisplacementData plus_displacement(element, mapping_data.plus_dofs); + const mapping::ElementDisplacementData minus_displacement(element, mapping_data.minus_dofs); + const ElementMappingDataOwner base_data(base_displacement); + const ElementMappingDataOwner plus_data(plus_displacement); + const ElementMappingDataOwner minus_data(minus_displacement); + + mapping::DomainMapper mapper( + {.dimension = dimension, .vacuum_element_attribute_ID = 3}, make_kelvin_compactification() + ); + mapping::DMWorkspace workspace(dimension); + + for (int boundary_element = 0; boundary_element < fixture.mesh.GetNBE(); ++boundary_element) { + mfem::FaceElementTransformations* transformation = fixture.mesh.GetBdrFaceTransformations(boundary_element); + REQUIRE(transformation != nullptr); + REQUIRE(transformation->Elem1 != nullptr); + + const mfem::IntegrationRule& integration_rule = mfem::IntRules.Get(transformation->GetGeometryType(), 4); + + for (int q = 0; q < integration_rule.GetNPoints(); ++q) { + const mfem::IntegrationPoint& integration_point = integration_rule.IntPoint(q); + + mapping::FaceMappingContext base_context; + mapping::FaceMappingContext plus_context; + mapping::FaceMappingContext minus_context; + mapping::FaceMappingVariation variation; + + REQUIRE( + mapper.evaluate_face( + base_data.get(), *transformation, mapping::FaceElementSide::ELEMENT_1, integration_point, workspace, + base_context + ) == mapping::MappingStatus::VALID + ); + REQUIRE( + mapper.evaluate_face_variation( + base_data.get(), direction, *transformation, mapping::FaceElementSide::ELEMENT_1, integration_point, + base_context, workspace, variation + ) == mapping::MappingStatus::VALID + ); + REQUIRE( + mapper.evaluate_face( + plus_data.get(), *transformation, mapping::FaceElementSide::ELEMENT_1, integration_point, workspace, + plus_context + ) == mapping::MappingStatus::VALID + ); + REQUIRE( + mapper.evaluate_face( + minus_data.get(), *transformation, mapping::FaceElementSide::ELEMENT_1, integration_point, + workspace, minus_context + ) == mapping::MappingStatus::VALID + ); + + CAPTURE(boundary_element, q); + check_vector_central_difference( + plus_context.quadrature.normal, minus_context.quadrature.normal, variation.physical_normal_variation, + difference_step, normal_tolerance + ); + + const double surface_weight_finite_difference = + (plus_context.physical_surface_weight - minus_context.physical_surface_weight) / + (2.0 * difference_step); + const double normal_scale_finite_difference = + (plus_context.quadrature.v_dot_n_scale - minus_context.quadrature.v_dot_n_scale) / + (2.0 * difference_step); + + CHECK_THAT( + surface_weight_finite_difference, + WithinAbs(variation.physical_surface_weight_variation, measure_tolerance) + ); + CHECK_THAT( + normal_scale_finite_difference, WithinAbs(variation.normal_flux_scale_variation, measure_tolerance) + ); + } + } +} + +TEST_CASE( + "Stateless Domain Mapper Rejects Invalid Linearization Directions", + tags::unit & tags::mapping +) { + QuadraticElementFixture fixture; + + const mfem::FiniteElement& element = fixture.get_element(); + mfem::ElementTransformation* transformation = fixture.mesh.GetElementTransformation(0); + const mfem::IntegrationPoint& integration_point = mfem::Geometries.GetCenter(transformation->GetGeometryType()); + QuadraticMappingData mapping_data(element, *transformation); + + const mapping::ElementDisplacementData base_displacement(element, mapping_data.base_dofs); + const ElementMappingDataOwner base_data(base_displacement); + + mfem::Vector non_finite_direction_dofs(mapping_data.direction_dofs); + non_finite_direction_dofs(0) = std::numeric_limits::quiet_NaN(); + const mapping::ElementDisplacementData non_finite_direction( + element, non_finite_direction_dofs); + + mapping::DomainMapper mapper( + {.dimension = dimension, .vacuum_element_attribute_ID = 3}, make_kelvin_compactification() + ); + mapping::DMWorkspace workspace(dimension); + + mapping::MappingPointContext base_context; + mapping::MappingPointVariation variation; + + REQUIRE( + mapper.evaluate_point(base_data.get(), *transformation, integration_point, workspace, base_context) == + mapping::MappingStatus::VALID + ); + CHECK( + mapper.evaluate_point_variation( + base_data.get(), non_finite_direction, *transformation, integration_point, base_context, workspace, + variation + ) == mapping::MappingStatus::NON_FINITE_MAPPING_INPUT + ); + + SingleElementFixture linear_fixture; + const mfem::FiniteElement& linear_element = linear_fixture.get_element(); + mfem::Vector linear_direction_dofs = linear_fixture.make_zero_element_dofs(); + const mapping::ElementDisplacementData incompatible_direction( + linear_element, linear_direction_dofs); + + CHECK_THROWS_AS( + mapper.evaluate_point_variation( + base_data.get(), incompatible_direction, *transformation, integration_point, base_context, workspace, + variation + ), + std::invalid_argument + ); +} + +TEST_CASE( + "Mapping Field Transforms Satisfy Piola And Gradient Identities", + tags::unit & tags::mapping +) { + constexpr double transform_tolerance = 1.0e-12; + + mapping::MappingPointContext context; + context.mapping_jacobian.SetSize(3); + context.mapping_jacobian(0, 0) = 1.20; + context.mapping_jacobian(0, 1) = 0.15; + context.mapping_jacobian(0, 2) = -0.05; + context.mapping_jacobian(1, 0) = -0.08; + context.mapping_jacobian(1, 1) = 0.95; + context.mapping_jacobian(1, 2) = 0.12; + context.mapping_jacobian(2, 0) = 0.04; + context.mapping_jacobian(2, 1) = -0.10; + context.mapping_jacobian(2, 2) = 1.10; + + context.mapping_determinate = context.mapping_jacobian.Det(); + REQUIRE(context.mapping_determinate > 0.0); + + context.inverse_mapping_jacobian.SetSize(3); + mfem::CalcInverse(context.mapping_jacobian, context.inverse_mapping_jacobian); + + mfem::Vector reference_gradient = make_vector(0.3, -0.6, 0.8); + mfem::Vector reference_test_gradient = make_vector(-0.7, 0.2, 0.4); + + mfem::Vector physical_gradient; + mfem::Vector recovered_gradient; + mfem::Vector physical_test_gradient; + + mapping::map_reference_gradient_to_physical(context, reference_gradient, physical_gradient); + mapping::map_physical_gradient_to_reference(context, physical_gradient, recovered_gradient); + mapping::map_reference_gradient_to_physical(context, reference_test_gradient, physical_test_gradient); + + check_vector(recovered_gradient, reference_gradient, transform_tolerance); + + mfem::DenseMatrix reference_vector_gradient(3); + reference_vector_gradient(0, 0) = 0.20; + reference_vector_gradient(0, 1) = -0.10; + reference_vector_gradient(0, 2) = 0.04; + reference_vector_gradient(1, 0) = 0.03; + reference_vector_gradient(1, 1) = 0.15; + reference_vector_gradient(1, 2) = -0.08; + reference_vector_gradient(2, 0) = -0.05; + reference_vector_gradient(2, 1) = 0.02; + reference_vector_gradient(2, 2) = 0.11; + + mfem::DenseMatrix physical_vector_gradient; + mfem::DenseMatrix recovered_vector_gradient; + + mapping::map_reference_vector_gradient_to_physical(context, reference_vector_gradient, physical_vector_gradient); + mapping::map_physical_vector_gradient_to_reference(context, physical_vector_gradient, recovered_vector_gradient); + check_matrix(recovered_vector_gradient, reference_vector_gradient, transform_tolerance); + + mfem::DenseMatrix diffusion_tensor; + mapping::compute_scalar_diffusion_tensor(context, diffusion_tensor); + + mfem::Vector diffusion_action(3); + diffusion_tensor.Mult(reference_test_gradient, diffusion_action); + + const double physical_gradient_inner_product = + context.mapping_determinate * (physical_gradient * physical_test_gradient); + const double reference_gradient_inner_product = reference_gradient * diffusion_action; + + CHECK_THAT(physical_gradient_inner_product, WithinAbs(reference_gradient_inner_product, transform_tolerance)); +} + +TEST_CASE( + "Stateless Domain Mapper Represents Strong Rotating Star Geometry", + tags::integration & tags::mapping +) { + constexpr double r_star = 1.0; + constexpr double r_infinity = 4.0; + constexpr double transform_tolerance = 2.0e-10; + + stroid::config::MeshConfig mesh_config; + mesh_config.refinement_levels = 0; + mesh_config.order = 2; + mesh_config.include_external_domain = true; + mesh_config.r_core = 0.25; + mesh_config.r_star = r_star; + mesh_config.r_infinity = r_infinity; + mesh_config.flattening = 0.0; + mesh_config.optimization_methods = stroid::config::OptimizationMethods{false, true}; + + stroid::StroidMesh stroid_mesh = stroid::GenerateMesh(mesh_config); + mfem::Mesh& mesh = *stroid_mesh.mesh; + REQUIRE(stroid_mesh.exterior_coordinate != nullptr); + REQUIRE(stroid_mesh.exterior_coordinate->space != nullptr); + REQUIRE(stroid_mesh.exterior_coordinate->values != nullptr); + + mfem::FiniteElementSpace& compactification_space = *stroid_mesh.exterior_coordinate->space; + mfem::GridFunction& compactification_coordinate = *stroid_mesh.exterior_coordinate->values; + + mfem::H1_FECollection displacement_collection(3, dimension); + mfem::FiniteElementSpace displacement_space(&mesh, &displacement_collection, dimension, mfem::Ordering::byVDIM); + mfem::GridFunction displacement(&displacement_space); + + auto rotating_displacement = + [r_star, r_infinity](const mfem::Vector& reference_position, mfem::Vector& displacement_value) { + const double x = reference_position(0); + const double y = reference_position(1); + const double z = reference_position(2); + const double radius_squared = x * x + y * y + z * z; + const double radius = std::sqrt(radius_squared); + + displacement_value.SetSize(3); + displacement_value = 0.0; + + if (radius <= 1.0e-14) { + return; + } + + const double cylindrical_fraction = (x * x + y * y) / radius_squared; + const double angular_deformation = + 0.20 * cylindrical_fraction + 0.12 * cylindrical_fraction * cylindrical_fraction; + + double radial_extension = 0.0; + if (radius <= r_star) { + radial_extension = radius_squared / (r_star * r_star); + } else { + radial_extension = std::max(0.0, (r_infinity - radius) / (r_infinity - r_star)); + } + + const double scale = radial_extension * angular_deformation; + displacement_value(0) = scale * x; + displacement_value(1) = scale * y; + displacement_value(2) = scale * z; + }; + + mfem::VectorFunctionCoefficient displacement_coefficient(dimension, rotating_displacement); + displacement.ProjectCoefficient(displacement_coefficient); + + std::unique_ptr exterior_map = + std::make_unique( + user_options::KelvinCompactificationOptions{.r_star_ref = r_star, .r_inf_ref = r_infinity} + ); + + mapping::DomainMapper mapper({.dimension = dimension, .vacuum_element_attribute_ID = 3}, std::move(exterior_map)); + mapping::DMWorkspace workspace(dimension); + + double minimum_mapping_determinant = std::numeric_limits::infinity(); + double maximum_mapping_determinant = 0.0; + double stellar_volume = 0.0; + double moment_x = 0.0; + double moment_y = 0.0; + double moment_z = 0.0; + + int stellar_elements = 0; + int vacuum_elements = 0; + + for (int element_id = 0; element_id < mesh.GetNE(); ++element_id) { + mfem::ElementTransformation* transformation = mesh.GetElementTransformation(element_id); + const mfem::FiniteElement* element = displacement_space.GetFE(element_id); + + mfem::Array element_vdofs; + mfem::Vector element_dofs; + displacement_space.GetElementVDofs(element_id, element_vdofs); + displacement.GetSubVector(element_vdofs, element_dofs); + + const mapping::ElementDisplacementData element_displacement = + mapping::element_displacement_data_from_element_vdofs(*element, element_dofs); + + mfem::Array compactification_dof_indices; + mfem::Vector compactification_dofs; + compactification_space.GetElementDofs(element_id, compactification_dof_indices); + compactification_coordinate.GetSubVector(compactification_dof_indices, compactification_dofs); + + const ElementMappingDataOwner element_data( + element_displacement, *compactification_space.GetFE(element_id), compactification_dofs + ); + const int quadrature_order = 2 * element->GetOrder() + 6; + const mfem::IntegrationRule& integration_rule = + mfem::IntRules.Get(transformation->GetGeometryType(), quadrature_order); + + if (transformation->Attribute == 3) { + ++vacuum_elements; + } else { + ++stellar_elements; + } + + for (int q = 0; q < integration_rule.GetNPoints(); ++q) { + const mfem::IntegrationPoint& integration_point = integration_rule.IntPoint(q); + mapping::VolumeMappingContext context; + + REQUIRE( + mapper.evaluate_volume(element_data.get(), *transformation, integration_point, workspace, context) == + mapping::MappingStatus::VALID + ); + + minimum_mapping_determinant = std::min(minimum_mapping_determinant, context.mapping.mapping_determinate); + maximum_mapping_determinant = std::max(maximum_mapping_determinant, context.mapping.mapping_determinate); + + REQUIRE(context.mapping.mapping_determinate > 0.0); + REQUIRE(context.quadrature.physical_integration_weight > 0.0); + + mfem::Vector reference_gradient = make_vector(0.3, -0.5, 0.8); + mfem::Vector physical_gradient; + mfem::Vector recovered_gradient; + mapping::map_reference_gradient_to_physical(context.mapping, reference_gradient, physical_gradient); + mapping::map_physical_gradient_to_reference(context.mapping, physical_gradient, recovered_gradient); + check_vector(recovered_gradient, reference_gradient, transform_tolerance); + + if (transformation->Attribute == 3) { + transformation->SetIntPoint(&integration_point); + + mfem::Vector compactification_gradient(dimension); + const double coordinate = compactification_coordinate.GetValue(element_id, integration_point); + compactification_coordinate.GetGradient(*transformation, compactification_gradient); + + mapping::compactification::ExteriorMapResult direct_result; + const mapping::compactification::ExteriorMapInput direct_input{ + .reference_position = context.mapping.reference_position, + .displaced_position = context.mapping.displaced_position, + .displacement_jacobian = context.mapping.displacement_jacobian, + .compactification_coordinate_gradient = compactification_gradient, + .compactification_coordinate = coordinate + }; + + REQUIRE( + mapper.get_exterior_map().evaluate(direct_input, direct_result) == mapping::MappingStatus::VALID + ); + check_vector(context.mapping.physical_position, direct_result.physical_position, transform_tolerance); + check_matrix(context.mapping.mapping_jacobian, direct_result.mapping_jacobian, transform_tolerance); + } else { + check_vector( + context.mapping.physical_position, context.mapping.displaced_position, transform_tolerance + ); + + const double x = context.mapping.physical_position(0); + const double y = context.mapping.physical_position(1); + const double z = context.mapping.physical_position(2); + const double weight = context.quadrature.physical_integration_weight; + + stellar_volume += weight; + moment_x += x * x * weight; + moment_y += y * y * weight; + moment_z += z * z * weight; + } + } + } + + REQUIRE(stellar_elements > 0); + REQUIRE(vacuum_elements > 0); + REQUIRE(stellar_volume > 0.0); + REQUIRE(std::isfinite(minimum_mapping_determinant)); + REQUIRE(minimum_mapping_determinant > 0.0); + + const double moment_trace = moment_x + moment_y + moment_z; + const double quadrupole_x = 3.0 * moment_x - moment_trace; + const double quadrupole_y = 3.0 * moment_y - moment_trace; + const double quadrupole_z = 3.0 * moment_z - moment_trace; + const double normalized_quadrupole = + std::sqrt(quadrupole_x * quadrupole_x + quadrupole_y * quadrupole_y + quadrupole_z * quadrupole_z) / + moment_trace; + const double axisymmetry_error = std::abs(moment_x - moment_y) / (0.5 * (moment_x + moment_y)); + + INFO("Stellar volume = " << stellar_volume); + INFO("Minimum mapping determinant = " << minimum_mapping_determinant); + INFO("Maximum mapping determinant = " << maximum_mapping_determinant); + INFO("Normalized geometric quadrupole = " << normalized_quadrupole); + INFO("Axisymmetry error = " << axisymmetry_error); + + CHECK(moment_x > moment_z); + CHECK(moment_y > moment_z); + CHECK(normalized_quadrupole > 1.0e-2); + CHECK(axisymmetry_error < 5.0e-2); +} + +TEST_CASE( + "Kelvin Composed Domain Mapping Linearization Matches Centered Differences", + tags::unit & tags::mapping +) { + constexpr double relative_tolerance = 2.0e-6; + constexpr double kelvin_difference_step = 2.0e-4; + + mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(1, 1, 1, mfem::Element::HEXAHEDRON, 2.98, 0.02, 0.02); + + for (int vertex_id = 0; vertex_id < mesh.GetNV(); ++vertex_id) { + double* vertex = mesh.GetVertex(vertex_id); + vertex[0] += 1.0; + vertex[1] -= 0.01; + vertex[2] -= 0.01; + } + + mesh.GetElement(0)->SetAttribute(3); + mesh.SetAttributes(); + + mfem::H1_FECollection displacement_collection(2, dimension); + mfem::FiniteElementSpace displacement_space(&mesh, &displacement_collection, dimension, mfem::Ordering::byVDIM); + + const mfem::FiniteElement& element = *displacement_space.GetFE(0); + mfem::ElementTransformation* transformation = mesh.GetElementTransformation(0); + + mfem::Vector base_dofs = + make_function_element_dofs(element, *transformation, evaluate_quadratic_displacement, mfem::Ordering::byNODES); + mfem::Vector direction_dofs = + make_function_element_dofs(element, *transformation, evaluate_quadratic_direction, mfem::Ordering::byNODES); + + mfem::Vector plus_dofs(base_dofs); + mfem::Vector minus_dofs(base_dofs); + plus_dofs.Add(kelvin_difference_step, direction_dofs); + minus_dofs.Add(-kelvin_difference_step, direction_dofs); + + const mapping::ElementDisplacementData base_displacement(element, base_dofs); + const mapping::ElementDisplacementData direction(element, direction_dofs); + const mapping::ElementDisplacementData plus_displacement(element, plus_dofs); + const mapping::ElementDisplacementData minus_displacement(element, minus_dofs); + + mfem::Vector compactification_dofs = + make_compactification_element_dofs(element, *transformation, [](const mfem::Vector& reference_position) { + return (reference_position(0) - 1.0) / 3.0; + }); + + const ElementMappingDataOwner base_data(base_displacement, element, compactification_dofs); + const ElementMappingDataOwner plus_data(plus_displacement, element, compactification_dofs); + const ElementMappingDataOwner minus_data(minus_displacement, element, compactification_dofs); + + mapping::DomainMapper mapper( + {.dimension = dimension, .vacuum_element_attribute_ID = 3}, make_kelvin_compactification() + ); + mapping::DMWorkspace workspace(dimension); + + auto vector_central_difference = [](const mfem::Vector& plus_value, const mfem::Vector& minus_value) { + mfem::Vector difference(plus_value); + difference -= minus_value; + difference *= 1.0 / (2.0 * kelvin_difference_step); + return difference; + }; + + auto matrix_central_difference = [](const mfem::DenseMatrix& plus_value, const mfem::DenseMatrix& minus_value) { + mfem::DenseMatrix difference(plus_value); + difference -= minus_value; + difference *= 1.0 / (2.0 * kelvin_difference_step); + return difference; + }; + + for (const double xi : std::array{0.0, 0.25, 0.75, 0.95, 0.99}) { + mfem::IntegrationPoint integration_point; + integration_point.x = 3.0 * xi / 2.98; + integration_point.y = 0.5; + integration_point.z = 0.5; + integration_point.weight = 0.73; + + mapping::VolumeMappingContext base_context; + mapping::VolumeMappingContext plus_context; + mapping::VolumeMappingContext minus_context; + mapping::VolumeMappingVariation variation; + + REQUIRE( + mapper.evaluate_volume(base_data.get(), *transformation, integration_point, workspace, base_context) == + mapping::MappingStatus::VALID + ); + REQUIRE( + serif::tests::evaluate_volume_variation(mapper, + base_data.get(), direction, *transformation, integration_point, base_context, workspace, variation + ) == mapping::MappingStatus::VALID + ); + REQUIRE( + mapper.evaluate_volume(plus_data.get(), *transformation, integration_point, workspace, plus_context) == + mapping::MappingStatus::VALID + ); + REQUIRE( + mapper.evaluate_volume(minus_data.get(), *transformation, integration_point, workspace, minus_context) == + mapping::MappingStatus::VALID + ); + + CAPTURE(xi); + REQUIRE(base_context.mapping.compactified); + + mfem::Vector physical_position_difference = + vector_central_difference(plus_context.mapping.physical_position, minus_context.mapping.physical_position); + const mfem::DenseMatrix mapping_jacobian_difference = + matrix_central_difference(plus_context.mapping.mapping_jacobian, minus_context.mapping.mapping_jacobian); + const mfem::DenseMatrix inverse_mapping_jacobian_difference = matrix_central_difference( + plus_context.mapping.inverse_mapping_jacobian, minus_context.mapping.inverse_mapping_jacobian + ); + + CHECK_THAT( + relative_vector_difference(physical_position_difference, variation.mapping.physical_position_variation), + Catch::Matchers::WithinAbs(0.0, relative_tolerance) + ); + CHECK_THAT( + relative_matrix_difference(mapping_jacobian_difference, variation.mapping.mapping_jacobian_variation), + Catch::Matchers::WithinAbs(0.0, relative_tolerance) + ); + CHECK_THAT( + relative_matrix_difference( + inverse_mapping_jacobian_difference, variation.mapping.inverse_mapping_jacobian_variation + ), + Catch::Matchers::WithinAbs(0.0, relative_tolerance) + ); + + const double determinant_difference = + (plus_context.mapping.mapping_determinate - minus_context.mapping.mapping_determinate) / + (2.0 * kelvin_difference_step); + const double weight_difference = (plus_context.quadrature.physical_integration_weight - + minus_context.quadrature.physical_integration_weight) / + (2.0 * kelvin_difference_step); + + check_centered_difference( + variation.mapping.mapping_determinate_variation, determinant_difference, + plus_context.mapping.mapping_determinate, minus_context.mapping.mapping_determinate, kelvin_difference_step, + relative_tolerance + ); + check_centered_difference( + variation.weight_variation, weight_difference, plus_context.quadrature.physical_integration_weight, + minus_context.quadrature.physical_integration_weight, kelvin_difference_step, relative_tolerance + ); + } + + for (int boundary_element = 0; boundary_element < mesh.GetNBE(); ++boundary_element) { + mfem::FaceElementTransformations* face_transformation = mesh.GetBdrFaceTransformations(boundary_element); + REQUIRE(face_transformation != nullptr); + + const mfem::IntegrationRule& integration_rule = mfem::IntRules.Get(face_transformation->GetGeometryType(), 4); + + for (int q = 0; q < integration_rule.GetNPoints(); ++q) { + const mfem::IntegrationPoint& integration_point = integration_rule.IntPoint(q); + + mapping::FaceMappingContext base_context; + mapping::FaceMappingContext plus_context; + mapping::FaceMappingContext minus_context; + mapping::FaceMappingVariation variation; + + REQUIRE( + mapper.evaluate_face( + base_data.get(), *face_transformation, mapping::FaceElementSide::ELEMENT_1, integration_point, + workspace, base_context + ) == mapping::MappingStatus::VALID + ); + REQUIRE( + mapper.evaluate_face_variation( + base_data.get(), direction, *face_transformation, mapping::FaceElementSide::ELEMENT_1, + integration_point, base_context, workspace, variation + ) == mapping::MappingStatus::VALID + ); + REQUIRE( + mapper.evaluate_face( + plus_data.get(), *face_transformation, mapping::FaceElementSide::ELEMENT_1, integration_point, + workspace, plus_context + ) == mapping::MappingStatus::VALID + ); + REQUIRE( + mapper.evaluate_face( + minus_data.get(), *face_transformation, mapping::FaceElementSide::ELEMENT_1, integration_point, + workspace, minus_context + ) == mapping::MappingStatus::VALID + ); + + CAPTURE(boundary_element, q); + + mfem::Vector normal_difference = + vector_central_difference(plus_context.quadrature.normal, minus_context.quadrature.normal); + + REQUIRE(normal_difference.Size() == variation.physical_normal_variation.Size()); + for (int i = 0; i < normal_difference.Size(); ++i) { + check_centered_difference( + variation.physical_normal_variation(i), normal_difference(i), plus_context.quadrature.normal(i), + minus_context.quadrature.normal(i), kelvin_difference_step, relative_tolerance + ); + } + + const double surface_weight_difference = + (plus_context.physical_surface_weight - minus_context.physical_surface_weight) / + (2.0 * kelvin_difference_step); + const double normal_scale_difference = + (plus_context.quadrature.v_dot_n_scale - minus_context.quadrature.v_dot_n_scale) / + (2.0 * kelvin_difference_step); + + check_centered_difference( + variation.physical_surface_weight_variation, surface_weight_difference, + plus_context.physical_surface_weight, minus_context.physical_surface_weight, kelvin_difference_step, + relative_tolerance + ); + check_centered_difference( + variation.normal_flux_scale_variation, normal_scale_difference, plus_context.quadrature.v_dot_n_scale, + minus_context.quadrature.v_dot_n_scale, kelvin_difference_step, relative_tolerance + ); + } + } +} +TEST_CASE( + "Stateless Domain Mapper Produces Consistent Two Sided Interface Geometry", + tags::integration & tags::mapping +) { + constexpr double r_star = 1.0; + constexpr double r_infinity = 4.0; + constexpr double interface_tolerance = 2.0e-7; + + stroid::config::MeshConfig mesh_config; + mesh_config.refinement_levels = 0; + mesh_config.order = 2; + mesh_config.include_external_domain = true; + mesh_config.r_core = 0.25; + mesh_config.r_star = r_star; + mesh_config.r_infinity = r_infinity; + mesh_config.flattening = 0.0; + mesh_config.optimization_methods = stroid::config::OptimizationMethods{false, true}; + + stroid::StroidMesh stroid_mesh = stroid::GenerateMesh(mesh_config); + mfem::Mesh& mesh = *stroid_mesh.mesh; + REQUIRE(stroid_mesh.exterior_coordinate != nullptr); + REQUIRE(stroid_mesh.exterior_coordinate->space != nullptr); + REQUIRE(stroid_mesh.exterior_coordinate->values != nullptr); + + mfem::FiniteElementSpace& compactification_space = *stroid_mesh.exterior_coordinate->space; + mfem::GridFunction& compactification_coordinate = *stroid_mesh.exterior_coordinate->values; + + mfem::H1_FECollection displacement_collection(2, dimension); + mfem::FiniteElementSpace displacement_space(&mesh, &displacement_collection, dimension, mfem::Ordering::byVDIM); + + auto displacement_function = [](const mfem::Vector& position, mfem::Vector& value) { + value.SetSize(3); + value(0) = 0.08 * position(0) + 0.02 * position(1); + value(1) = -0.03 * position(0) - 0.02 * position(1); + value(2) = 0.04 * position(2); + }; + + auto direction_function = [](const mfem::Vector& position, mfem::Vector& value) { + value.SetSize(3); + value(0) = 0.02 * position(0); + value(1) = -0.01 * position(1) + 0.005 * position(2); + value(2) = 0.015 * position(2); + }; + + mfem::VectorFunctionCoefficient displacement_coefficient(dimension, displacement_function); + mfem::VectorFunctionCoefficient direction_coefficient(dimension, direction_function); + + mfem::GridFunction displacement(&displacement_space); + mfem::GridFunction direction(&displacement_space); + displacement.ProjectCoefficient(displacement_coefficient); + direction.ProjectCoefficient(direction_coefficient); + + std::unique_ptr exterior_map = + std::make_unique( + user_options::KelvinCompactificationOptions{.r_star_ref = r_star, .r_inf_ref = r_infinity} + ); + + mapping::DomainMapper mapper({.dimension = dimension, .vacuum_element_attribute_ID = 3}, std::move(exterior_map)); + mapping::DMWorkspace workspace(dimension); + + int core_envelope_faces = 0; + int stellar_vacuum_faces = 0; + + for (int face_id = 0; face_id < mesh.GetNumFaces(); ++face_id) { + mfem::FaceElementTransformations* transformation = mesh.GetFaceElementTransformations(face_id); + if (transformation == nullptr || transformation->Elem1 == nullptr || transformation->Elem2 == nullptr) { + continue; + } + + const int attribute_1 = transformation->Elem1->Attribute; + const int attribute_2 = transformation->Elem2->Attribute; + const bool core_envelope_interface = + (attribute_1 == 1 && attribute_2 == 2) || (attribute_1 == 2 && attribute_2 == 1); + const bool stellar_vacuum_interface = (attribute_1 == 3) != (attribute_2 == 3); + + if (!core_envelope_interface && !stellar_vacuum_interface) { + continue; + } + + if (core_envelope_interface) { + ++core_envelope_faces; + } + if (stellar_vacuum_interface) { + ++stellar_vacuum_faces; + } + + const int element_1 = transformation->Elem1->ElementNo; + const int element_2 = transformation->Elem2->ElementNo; + + mfem::Array vdofs_1; + mfem::Array vdofs_2; + mfem::Vector displacement_dofs_1; + mfem::Vector displacement_dofs_2; + mfem::Vector direction_dofs_1; + mfem::Vector direction_dofs_2; + mfem::Array compactification_dof_indices_1; + mfem::Array compactification_dof_indices_2; + mfem::Vector compactification_dofs_1; + mfem::Vector compactification_dofs_2; + + displacement_space.GetElementVDofs(element_1, vdofs_1); + displacement_space.GetElementVDofs(element_2, vdofs_2); + displacement.GetSubVector(vdofs_1, displacement_dofs_1); + displacement.GetSubVector(vdofs_2, displacement_dofs_2); + direction.GetSubVector(vdofs_1, direction_dofs_1); + direction.GetSubVector(vdofs_2, direction_dofs_2); + compactification_space.GetElementDofs(element_1, compactification_dof_indices_1); + compactification_space.GetElementDofs(element_2, compactification_dof_indices_2); + compactification_coordinate.GetSubVector(compactification_dof_indices_1, compactification_dofs_1); + compactification_coordinate.GetSubVector(compactification_dof_indices_2, compactification_dofs_2); + + const mapping::ElementDisplacementData displacement_1 = mapping::element_displacement_data_from_element_vdofs( + *displacement_space.GetFE(element_1), displacement_dofs_1 + ); + const mapping::ElementDisplacementData displacement_2 = mapping::element_displacement_data_from_element_vdofs( + *displacement_space.GetFE(element_2), displacement_dofs_2 + ); + const mapping::ElementDisplacementData direction_1 = mapping::element_displacement_data_from_element_vdofs( + *displacement_space.GetFE(element_1), direction_dofs_1 + ); + const mapping::ElementDisplacementData direction_2 = mapping::element_displacement_data_from_element_vdofs( + *displacement_space.GetFE(element_2), direction_dofs_2 + ); + + const ElementMappingDataOwner element_data_1( + displacement_1, *compactification_space.GetFE(element_1), compactification_dofs_1 + ); + const ElementMappingDataOwner element_data_2( + displacement_2, *compactification_space.GetFE(element_2), compactification_dofs_2 + ); + + const mfem::IntegrationRule& integration_rule = mfem::IntRules.Get(transformation->GetGeometryType(), 6); + + for (int q = 0; q < integration_rule.GetNPoints(); ++q) { + const mfem::IntegrationPoint& integration_point = integration_rule.IntPoint(q); + + mapping::FaceMappingContext context_1; + mapping::FaceMappingContext context_2; + mapping::FaceMappingVariation variation_1; + mapping::FaceMappingVariation variation_2; + + auto status_1 = mapper.evaluate_face( + element_data_1.get(), *transformation, mapping::FaceElementSide::ELEMENT_1, integration_point, + workspace, context_1 + ); + auto status_2 = mapper.evaluate_face( + element_data_2.get(), *transformation, mapping::FaceElementSide::ELEMENT_2, integration_point, + workspace, context_2 + ); + INFO("Element 1 ID = " << transformation->Elem1No); + INFO("Element 2 ID = " << transformation->Elem2No); + INFO("Element 1 attribute = " << transformation->Elem1->Attribute); + INFO("Element 2 attribute = " << transformation->Elem2->Attribute); + INFO("Element 2 status = " << static_cast(status_2)); + + mfem::Vector reference_position(dimension); + transformation->Elem2->Transform(transformation->Elem2->GetIntPoint(), reference_position); + INFO( + std::format( + "Reference position = <{},{},{}>", reference_position(0), reference_position(1), + reference_position(2) + ) + ); + INFO(std::format("Reference radius = {}", reference_position.Norml2())); + INFO(std::format("Reference radius minus r_star = {}", reference_position.Norml2() - r_star)); + REQUIRE(status_1 == mapping::MappingStatus::VALID); + REQUIRE(status_2 == mapping::MappingStatus::VALID); + REQUIRE( + mapper.evaluate_face_variation( + element_data_1.get(), direction_1, *transformation, mapping::FaceElementSide::ELEMENT_1, + integration_point, context_1, workspace, variation_1 + ) == mapping::MappingStatus::VALID + ); + REQUIRE( + mapper.evaluate_face_variation( + element_data_2.get(), direction_2, *transformation, mapping::FaceElementSide::ELEMENT_2, + integration_point, context_2, workspace, variation_2 + ) == mapping::MappingStatus::VALID + ); + + CAPTURE(face_id, q, attribute_1, attribute_2); + check_vector(context_1.mapping.physical_position, context_2.mapping.physical_position, interface_tolerance); + check_scalar_relative( + context_1.physical_surface_weight, context_2.physical_surface_weight, interface_tolerance + ); + + mfem::Vector normal_sum(context_1.quadrature.normal); + normal_sum += context_2.quadrature.normal; + CHECK(normal_sum.Norml2() < interface_tolerance); + + check_vector( + variation_1.mapping.physical_position_variation, variation_2.mapping.physical_position_variation, + interface_tolerance + ); + check_scalar_relative( + variation_1.physical_surface_weight_variation, variation_2.physical_surface_weight_variation, + interface_tolerance + ); + + mfem::Vector normal_variation_sum(variation_1.physical_normal_variation); + normal_variation_sum += variation_2.physical_normal_variation; + CHECK(normal_variation_sum.Norml2() < interface_tolerance); + + mfem::Vector physical_flux = make_vector(0.7, -0.4, 0.9); + mfem::Vector reference_flux_1; + mfem::Vector reference_flux_2; + + // Contravariant pullback det(A) A^-1 F of one physical flux from each side of the interface. + reference_flux_1.SetSize(3); + reference_flux_2.SetSize(3); + context_1.mapping.inverse_mapping_jacobian.Mult(physical_flux, reference_flux_1); + reference_flux_1 *= context_1.mapping.mapping_determinate; + context_2.mapping.inverse_mapping_jacobian.Mult(physical_flux, reference_flux_2); + reference_flux_2 *= context_2.mapping.mapping_determinate; + + const double flux_1 = (reference_flux_1 * context_1.reference_normal) * context_1.reference_surface_weight; + const double flux_2 = (reference_flux_2 * context_2.reference_normal) * context_2.reference_surface_weight; + + CHECK_THAT(flux_1 + flux_2, WithinAbs(0.0, interface_tolerance)); + } + } + + INFO("Core-envelope interface faces = " << core_envelope_faces); + INFO("Stellar-vacuum interface faces = " << stellar_vacuum_faces); + + REQUIRE(core_envelope_faces > 0); + REQUIRE(stellar_vacuum_faces > 0); +} + +TEST_CASE( + "Mapped Hcurl Fields Preserve Covariant Piola Identities", + tags::unit & tags::mapping +) { + constexpr double curl_tolerance = 2.0e-12; + + mapping::MappingPointContext context; + context.mapping_jacobian.SetSize(3); + context.mapping_jacobian(0, 0) = 1.20; + context.mapping_jacobian(0, 1) = 0.15; + context.mapping_jacobian(0, 2) = -0.05; + context.mapping_jacobian(1, 0) = -0.08; + context.mapping_jacobian(1, 1) = 0.95; + context.mapping_jacobian(1, 2) = 0.12; + context.mapping_jacobian(2, 0) = 0.04; + context.mapping_jacobian(2, 1) = -0.10; + context.mapping_jacobian(2, 2) = 1.10; + + context.mapping_determinate = context.mapping_jacobian.Det(); + REQUIRE(context.mapping_determinate > 0.0); + + context.inverse_mapping_jacobian.SetSize(3); + mfem::CalcInverse(context.mapping_jacobian, context.inverse_mapping_jacobian); + + mfem::Vector reference_field = make_vector(0.7, -0.4, 1.1); + mfem::Vector reference_test_field = make_vector(-0.2, 0.9, 0.5); + + mfem::DenseMatrix reference_gradient(3); + reference_gradient(0, 0) = 0.20; + reference_gradient(0, 1) = -0.10; + reference_gradient(0, 2) = 0.04; + reference_gradient(1, 0) = 0.03; + reference_gradient(1, 1) = 0.15; + reference_gradient(1, 2) = -0.08; + reference_gradient(2, 0) = -0.05; + reference_gradient(2, 1) = 0.02; + reference_gradient(2, 2) = 0.11; + + mfem::Vector reference_curl = matrix_curl(reference_gradient); + + mfem::Vector physical_field; + mfem::Vector recovered_field; + mfem::Vector physical_test_field; + mfem::Vector physical_curl; + mfem::Vector recovered_curl; + + mapping::map_hcurl_field_to_physical(context, reference_field, physical_field); + mapping::map_physical_field_to_hcurl_reference(context, physical_field, recovered_field); + mapping::map_hcurl_field_to_physical(context, reference_test_field, physical_test_field); + mapping::map_hcurl_curl_to_physical(context, reference_curl, physical_curl); + mapping::map_physical_curl_to_hcurl_reference(context, physical_curl, recovered_curl); + + check_vector(recovered_field, reference_field, curl_tolerance); + check_vector(recovered_curl, reference_curl, curl_tolerance); + + mfem::DenseMatrix temporary(3); + mfem::DenseMatrix physical_gradient(3); + mfem::MultAtB(context.inverse_mapping_jacobian, reference_gradient, temporary); + mfem::Mult(temporary, context.inverse_mapping_jacobian, physical_gradient); + + mfem::Vector directly_computed_physical_curl = matrix_curl(physical_gradient); + check_vector(directly_computed_physical_curl, physical_curl, curl_tolerance); + + mfem::DenseMatrix mass_tensor; + mfem::DenseMatrix curl_tensor; + mapping::compute_scalar_diffusion_tensor(context, mass_tensor); + curl_tensor.SetSize(3); + mfem::MultAtB(context.mapping_jacobian, context.mapping_jacobian, curl_tensor); + curl_tensor *= 1.0 / context.mapping_determinate; + + mfem::Vector mass_action(3); + mass_tensor.Mult(reference_test_field, mass_action); + + const double physical_mass_inner_product = context.mapping_determinate * (physical_field * physical_test_field); + const double reference_mass_inner_product = reference_field * mass_action; + + CHECK_THAT(physical_mass_inner_product, WithinAbs(reference_mass_inner_product, curl_tolerance)); + + mfem::Vector reference_test_curl = make_vector(-0.3, 0.6, 0.2); + mfem::Vector physical_test_curl; + mfem::Vector curl_action(3); + + mapping::map_hcurl_curl_to_physical(context, reference_test_curl, physical_test_curl); + curl_tensor.Mult(reference_test_curl, curl_action); + + const double physical_curl_inner_product = context.mapping_determinate * (physical_curl * physical_test_curl); + const double reference_curl_inner_product = reference_curl * curl_action; + + CHECK_THAT(physical_curl_inner_product, WithinAbs(reference_curl_inner_product, curl_tolerance)); +} + +TEST_CASE( + "Element Displacement Data Matches MFEM GridFunction Evaluation", + tags::unit & tags::mapping +) { + constexpr int displacement_order = 2; + constexpr int quadrature_order = 6; + constexpr double value_tolerance = 5.0e-13; + constexpr double gradient_tolerance = 2.0e-12; + constexpr double mapping_tolerance = 3.0e-12; + + auto check_space_ordering = [](const mfem::Ordering::Type space_ordering) { + mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(2, 1, 1, mfem::Element::HEXAHEDRON, 2.0, 1.5, 1.25); + + mfem::H1_FECollection displacement_collection(displacement_order, dimension); + + mfem::FiniteElementSpace displacement_space(&mesh, &displacement_collection, dimension, space_ordering); + + mfem::GridFunction displacement(&displacement_space); + + auto displacement_function = [](const mfem::Vector& position, mfem::Vector& value) { + const double x = position(0); + const double y = position(1); + const double z = position(2); + + value.SetSize(dimension); + + value(0) = 0.17 + 0.11 * x - 0.07 * y + 0.03 * y * z; + + value(1) = -0.23 + 0.05 * y + 0.09 * z + 0.02 * x * z; + + value(2) = 0.31 - 0.04 * x + 0.08 * z - 0.015 * x * y; + }; + + mfem::VectorFunctionCoefficient displacement_coefficient(dimension, displacement_function); + + displacement.ProjectCoefficient(displacement_coefficient); + + mapping::DomainMapper mapper( + {.dimension = dimension, .vacuum_element_attribute_ID = 3}, make_kelvin_compactification() + ); + + mapping::DMWorkspace workspace(dimension); + + REQUIRE(displacement_space.GetOrdering() == space_ordering); + REQUIRE(displacement.VectorDim() == dimension); + + for (int element_id = 0; element_id < mesh.GetNE(); ++element_id) { + mfem::ElementTransformation* transformation = mesh.GetElementTransformation(element_id); + + REQUIRE(transformation != nullptr); + + const mfem::FiniteElement& displacement_element = *displacement_space.GetFE(element_id); + + mfem::Array element_vdofs; + + mfem::DofTransformation* dof_transformation = displacement_space.GetElementVDofs(element_id, element_vdofs); + + mfem::Vector element_displacement; + + displacement.GetSubVector(element_vdofs, element_displacement); + + if (dof_transformation != nullptr) { + dof_transformation->InvTransformPrimal(element_displacement); + } + + const mapping::ElementDisplacementData displacement_data = + mapping::element_displacement_data_from_element_vdofs(displacement_element, element_displacement); + + + const ElementMappingDataOwner element_data(displacement_data); + + const mfem::IntegrationRule& integration_rule = + mfem::IntRules.Get(transformation->GetGeometryType(), quadrature_order); + + mfem::Vector shape(displacement_element.GetDof()); + + mfem::DenseMatrix physical_dshape(displacement_element.GetDof(), dimension); + + mfem::Vector computed_value(dimension); + mfem::Vector expected_value(dimension); + + mfem::DenseMatrix computed_gradient(dimension, dimension); + + mfem::DenseMatrix expected_gradient(dimension, dimension); + + for (int q = 0; q < integration_rule.GetNPoints(); ++q) { + const mfem::IntegrationPoint& integration_point = integration_rule.IntPoint(q); + + CAPTURE(static_cast(space_ordering), element_id, q); + + transformation->SetIntPoint(&integration_point); + + displacement_element.CalcShape(integration_point, shape); + + displacement_element.CalcPhysDShape(*transformation, physical_dshape); + + displacement_data.get_dof_matrix().MultTranspose(shape, computed_value); + + mfem::MultAtB(displacement_data.get_dof_matrix(), physical_dshape, computed_gradient); + + /* + * Use MFEM's native evaluation as the authoritative + * interpretation of the GridFunction. + */ + transformation->SetIntPoint(&integration_point); + + displacement.GetVectorValue(*transformation, integration_point, expected_value); + + transformation->SetIntPoint(&integration_point); + + displacement.GetVectorGradient(*transformation, expected_gradient); + + check_vector(computed_value, expected_value, value_tolerance); + + check_matrix(computed_gradient, expected_gradient, gradient_tolerance); + + /* + * Also exercise the complete stateless-mapper path. + * The Cartesian elements are stellar-domain elements, + * so the mapper should produce x + u and I + grad(u). + */ + mapping::MappingPointContext context; + + const mapping::MappingStatus status = + mapper.evaluate_point(element_data.get(), *transformation, integration_point, workspace, context); + + REQUIRE(status == serif::mapping::MappingStatus::VALID); + + REQUIRE_FALSE(context.compactified); + + mfem::Vector expected_displaced_position(context.reference_position); + + expected_displaced_position += expected_value; + + mfem::DenseMatrix expected_displacement_jacobian(expected_gradient); + + for (int d = 0; d < dimension; ++d) { + expected_displacement_jacobian(d, d) += 1.0; + } + + check_vector(context.displaced_position, expected_displaced_position, mapping_tolerance); + + check_vector(context.physical_position, expected_displaced_position, mapping_tolerance); + + check_matrix(context.displacement_jacobian, expected_displacement_jacobian, mapping_tolerance); + + check_matrix(context.mapping_jacobian, expected_displacement_jacobian, mapping_tolerance); + + const double expected_determinant = expected_displacement_jacobian.Det(); + + CHECK_THAT( + context.mapping_determinate, Catch::Matchers::WithinAbs(expected_determinant, mapping_tolerance) + ); + + REQUIRE(context.mapping_determinate > 0.0); + } + } + }; + + SECTION("Global finite-element-space ordering is byNODES") { + check_space_ordering(mfem::Ordering::byNODES); + } + + SECTION("Global finite-element-space ordering is byVDIM") { + check_space_ordering(mfem::Ordering::byVDIM); + } +} + +TEST_CASE( + "Grid Function Mapping Evaluator Refreshes A Cached Displacement Element", + tags::unit & tags::mapping +) { + mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(1, 1, 1, mfem::Element::HEXAHEDRON, 2.0, 3.0, 4.0); + + mfem::H1_FECollection displacement_collection(1, dimension); + mfem::H1_FECollection compactification_collection(1, dimension); + mfem::FiniteElementSpace displacement_space(&mesh, &displacement_collection, dimension, mfem::Ordering::byVDIM); + mfem::FiniteElementSpace compactification_space(&mesh, &compactification_collection); + + mfem::GridFunction displacement(&displacement_space); + mfem::GridFunction compactification_coordinate(&compactification_space); + displacement = 0.0; + compactification_coordinate = 0.0; + + mapping::DomainMapper mapper( + {.dimension = dimension, .vacuum_element_attribute_ID = 3}, make_kelvin_compactification() + ); + mapping::GridFunctionMappingEvaluator evaluator(mapper, displacement, compactification_coordinate); + + /* Refreshing before the first evaluation is a validated no-op. */ + evaluator.refresh(); + + mfem::ElementTransformation* transformation = mesh.GetElementTransformation(0); + REQUIRE(transformation != nullptr); + + mfem::IntegrationPoint integration_point; + integration_point.Set3(0.31, 0.43, 0.57); + + mapping::MappingPointContext initial_context; + REQUIRE( + evaluator.evaluate_point(*transformation, integration_point, initial_context) == mapping::MappingStatus::VALID + ); + + mfem::Vector reference_position(dimension); + transformation->Transform(integration_point, reference_position); + check_vector(initial_context.physical_position, reference_position); + + mfem::Vector displacement_offset = make_vector(0.17, -0.09, 0.045); + mfem::VectorFunctionCoefficient displacement_coefficient( + dimension, [&displacement_offset](const mfem::Vector&, mfem::Vector& value) { + value = displacement_offset; + } + ); + displacement.ProjectCoefficient(displacement_coefficient); + + /* + * The same element ID is still cached. refresh must eagerly rebuild it from + * the mutated grid function rather than retaining the old element data. + */ + evaluator.refresh(); + + mapping::MappingPointContext refreshed_context; + REQUIRE( + evaluator.evaluate_point(*transformation, integration_point, refreshed_context) == mapping::MappingStatus::VALID + ); + + mfem::Vector expected_position(reference_position); + expected_position += displacement_offset; + + check_vector(refreshed_context.physical_position, expected_position); + check_vector(refreshed_context.displaced_position, expected_position); +} + +TEST_CASE( + "Grid Function Mapping Evaluator Invalidates A Cached Compactification Element", + tags::unit & tags::mapping +) { + mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(1, 1, 1, mfem::Element::HEXAHEDRON, 2.0, 3.0, 4.0); + mesh.GetElement(0)->SetAttribute(3); + mesh.SetAttributes(); + + mfem::H1_FECollection displacement_collection(1, dimension); + mfem::H1_FECollection compactification_collection(1, dimension); + mfem::FiniteElementSpace displacement_space(&mesh, &displacement_collection, dimension, mfem::Ordering::byVDIM); + mfem::FiniteElementSpace compactification_space(&mesh, &compactification_collection); + + mfem::GridFunction displacement(&displacement_space); + mfem::GridFunction compactification_coordinate(&compactification_space); + displacement = 0.0; + compactification_coordinate = 0.20; + + mapping::DomainMapper mapper( + {.dimension = dimension, .vacuum_element_attribute_ID = 3}, make_kelvin_compactification() + ); + mapping::GridFunctionMappingEvaluator evaluator(mapper, displacement, compactification_coordinate); + + mfem::ElementTransformation* transformation = mesh.GetElementTransformation(0); + REQUIRE(transformation != nullptr); + + mfem::IntegrationPoint integration_point; + integration_point.Set3(0.29, 0.37, 0.61); + + mapping::MappingPointContext initial_context; + REQUIRE( + evaluator.evaluate_point(*transformation, integration_point, initial_context) == mapping::MappingStatus::VALID + ); + REQUIRE(initial_context.compactified); + + compactification_coordinate = 0.40; + + /* Lazy invalidation is idempotent and reloads on the next evaluation. */ + evaluator.invalidate_cache(); + evaluator.invalidate_cache(); + + mapping::MappingPointContext refreshed_context; + REQUIRE( + evaluator.evaluate_point(*transformation, integration_point, refreshed_context) == mapping::MappingStatus::VALID + ); + REQUIRE(refreshed_context.compactified); + + constexpr double coordinate = 0.40; + constexpr double computational_radius = 1.0 + 3.0 * coordinate; + constexpr double expected_scale = 1.0 / (computational_radius * (1.0 - coordinate)); + + mfem::Vector expected_position(refreshed_context.reference_position); + expected_position *= expected_scale; + + check_vector(refreshed_context.physical_position, expected_position); + CHECK(relative_vector_difference(initial_context.physical_position, refreshed_context.physical_position) > 1.0e-3); +} + +TEST_CASE( + "Grid Function Mapping Evaluator Rejects Invalid Fields And Rebinding", + tags::unit & tags::mapping +) { + mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(1, 1, 1, mfem::Element::HEXAHEDRON, 2.0, 3.0, 4.0); + mfem::Mesh other_mesh = mfem::Mesh::MakeCartesian3D(1, 1, 1, mfem::Element::HEXAHEDRON, 2.0, 3.0, 4.0); + + mfem::H1_FECollection collection(1, dimension); + mfem::FiniteElementSpace displacement_space(&mesh, &collection, dimension, mfem::Ordering::byVDIM); + mfem::FiniteElementSpace alternate_displacement_space(&mesh, &collection, dimension, mfem::Ordering::byVDIM); + mfem::FiniteElementSpace scalar_space(&mesh, &collection); + mfem::FiniteElementSpace other_scalar_space(&other_mesh, &collection); + mfem::FiniteElementSpace vector_compactification_space(&mesh, &collection, dimension, mfem::Ordering::byVDIM); + + mfem::GridFunction displacement(&displacement_space); + mfem::GridFunction scalar_displacement(&scalar_space); + mfem::GridFunction compactification_coordinate(&scalar_space); + mfem::GridFunction other_compactification_coordinate(&other_scalar_space); + mfem::GridFunction vector_compactification_coordinate(&vector_compactification_space); + mfem::GridFunction detached; + displacement = 0.0; + compactification_coordinate = 0.0; + + mapping::DomainMapper mapper( + {.dimension = dimension, .vacuum_element_attribute_ID = 3}, make_kelvin_compactification() + ); + + CHECK_THROWS_AS( + (mapping::GridFunctionMappingEvaluator(mapper, detached, compactification_coordinate)), std::logic_error + ); + CHECK_THROWS_AS((mapping::GridFunctionMappingEvaluator(mapper, displacement, detached)), std::logic_error); + CHECK_THROWS_AS( + (mapping::GridFunctionMappingEvaluator(mapper, scalar_displacement, compactification_coordinate)), + std::logic_error + ); + CHECK_THROWS_AS( + (mapping::GridFunctionMappingEvaluator(mapper, displacement, vector_compactification_coordinate)), + std::logic_error + ); + CHECK_THROWS_AS( + (mapping::GridFunctionMappingEvaluator(mapper, displacement, other_compactification_coordinate)), + std::logic_error + ); + + mapping::GridFunctionMappingEvaluator evaluator(mapper, displacement, compactification_coordinate); + + mfem::ElementTransformation* transformation = mesh.GetElementTransformation(0); + REQUIRE(transformation != nullptr); + + mfem::IntegrationPoint integration_point; + integration_point.Set3(0.5, 0.5, 0.5); + + mapping::MappingPointContext context; + REQUIRE(evaluator.evaluate_point(*transformation, integration_point, context) == mapping::MappingStatus::VALID); + + displacement.SetSpace(&alternate_displacement_space); + + CHECK_THROWS_AS(evaluator.refresh(), std::logic_error); + CHECK_THROWS_AS(evaluator.evaluate_point(*transformation, integration_point, context), std::logic_error); +} + +TEST_CASE( + "Prepared Reference Geometry Matches Direct Mapping And Current Mesh Coordinates", + tags::unit & tags::geometry_cache +) { + SingleElementFixture fixture; + mapping::DomainMapper mapper( + {.dimension = dimension, .vacuum_element_attribute_ID = 3}, make_kelvin_compactification() + ); + mapping::DMWorkspace workspace(dimension); + const auto& point = mfem::IntRules.Get(mfem::Geometry::CUBE, 5).IntPoint(0); + auto zero = fixture.make_zero_element_dofs(); + mapping::ElementDisplacementData displacement(fixture.get_element(), zero); + ElementMappingDataOwner data(displacement); + auto compare = [&](const mfem::IntegrationPoint& ip) { + auto& transformation = *fixture.mesh.GetElementTransformation(0); + const auto geometry = mapping::sample_fixed_mesh_geometry(transformation, ip); + REQUIRE(geometry); + mapping::VolumeMappingContext prepared, direct; + REQUIRE(mapper.evaluate_volume(data.get(), transformation, ip, workspace, prepared, nullptr, &*geometry) == mapping::MappingStatus::VALID); + REQUIRE(mapper.evaluate_volume(data.get(), transformation, ip, workspace, direct) == mapping::MappingStatus::VALID); + check_vector(prepared.mapping.physical_position, direct.mapping.physical_position); + check_matrix(prepared.mapping.mapping_jacobian, direct.mapping.mapping_jacobian); + check_matrix(prepared.quadrature.J_element_to_physical_inverse, direct.quadrature.J_element_to_physical_inverse); + CHECK_THAT(prepared.quadrature.physical_integration_weight, WithinAbs(direct.quadrature.physical_integration_weight, 1.0e-12)); + return prepared.quadrature.physical_integration_weight; + }; + const double original_weight = compare(point); + compare(point); + auto changed_point = point; + changed_point.x += 0.013; + const double original_point_weight = compare(changed_point); + changed_point.weight *= 0.5; + CHECK_THAT(compare(changed_point), WithinAbs(0.5 * original_point_weight, 1.0e-12)); + fixture.mesh.Transform([](const mfem::Vector& x, mfem::Vector& y) { + y = x; + y(0) *= 1.2; + y(1) += 0.17; + }); + CHECK(compare(point) > original_weight); +} + +TEST_CASE( + "Prepared Reference Geometry Does Not Freeze Displacement Or Compactification State", + tags::unit & tags::geometry_cache +) { + SingleElementFixture fixture; + mapping::DomainMapper mapper( + {.dimension = dimension, .vacuum_element_attribute_ID = 3}, make_kelvin_compactification() + ); + mapping::DMWorkspace workspace(dimension); + const auto& point = mfem::IntRules.Get(mfem::Geometry::CUBE, 3).IntPoint(0); + auto& transformation = *fixture.mesh.GetElementTransformation(0); + const auto geometry = mapping::sample_fixed_mesh_geometry(transformation, point); + REQUIRE(geometry); + for (double scale : {0.0, 0.02, -0.01}) { + auto dofs = fixture.make_zero_element_dofs(); + dofs = scale; + mapping::ElementDisplacementData displacement(fixture.get_element(), dofs); + auto compactification_dofs = make_constant_compactification_dofs(fixture.get_element(), 0.1 + scale); + mapping::ElementCompactificationData compactification(fixture.get_element(), compactification_dofs); + mapping::ElementMappingData data{displacement, compactification}; + for (int attribute : {1, 3}) { + transformation.Attribute = attribute; + mapping::VolumeMappingContext prepared, direct; + const auto prepared_status = mapper.evaluate_volume(data, transformation, point, workspace, prepared, nullptr, &*geometry); + const auto direct_status = mapper.evaluate_volume(data, transformation, point, workspace, direct); + REQUIRE(prepared_status == direct_status); + if (prepared_status == mapping::MappingStatus::VALID) { + check_vector(prepared.mapping.physical_position, direct.mapping.physical_position); + check_matrix(prepared.mapping.mapping_jacobian, direct.mapping.mapping_jacobian); + check_matrix(prepared.quadrature.J_element_to_physical_inverse, direct.quadrature.J_element_to_physical_inverse); + } + } + } +} + +TEST_CASE( + "Reference Geometry Supports Detached Transformations And Rejects Singular Meshes", + tags::unit & tags::geometry_cache +) { + SingleElementFixture fixture; + auto& transformation = *fixture.mesh.GetElementTransformation(0); + const auto& point = mfem::IntRules.Get(mfem::Geometry::CUBE, 3).IntPoint(0); + const auto attached = mapping::sample_fixed_mesh_geometry(transformation, point); + REQUIRE(attached); + transformation.mesh = nullptr; + const auto detached = mapping::sample_fixed_mesh_geometry(transformation, point); + REQUIRE(detached); + check_vector(detached->position, attached->position); + check_matrix(detached->inverse_jacobian, attached->inverse_jacobian); + fixture.mesh.Transform([](const mfem::Vector& x, mfem::Vector& y) { + y = x; + y(0) = 0.0; + }); + const auto singular = mapping::sample_fixed_mesh_geometry(*fixture.mesh.GetElementTransformation(0), point); + REQUIRE_FALSE(singular); + CHECK(singular.error() == mapping::MappingStatus::NON_POSITIVE_DETERMINATE); +} + +TEST_CASE( + "Supplied compactification samples preserve volume variations and reject invalid exterior inputs", + tags::unit & tags::mapping +) { + for (const int attribute : {1, 3}) { + QuadraticElementFixture fixture; + fixture.mesh.GetElement(0)->SetAttribute(attribute); + fixture.mesh.SetAttributes(); + const mfem::FiniteElement& element = fixture.get_element(); + auto& transformation = *fixture.mesh.GetElementTransformation(0); + QuadraticMappingData fields(element, transformation); + const mapping::ElementDisplacementData base(element, fields.base_dofs); + const mapping::ElementDisplacementData direction(element, fields.direction_dofs); + const mfem::Vector compactification_dofs = make_compactification_element_dofs( + element, transformation, [](const mfem::Vector& position) { + return 0.1 + position[0] / 8.0; + } + ); + const ElementMappingDataOwner data(base, element, compactification_dofs); + mapping::DomainMapper mapper( + {.dimension = dimension, .vacuum_element_attribute_ID = 3}, make_kelvin_compactification() + ); + mapping::DMWorkspace workspace(dimension); + mapping::CompactificationPointData sample; + mfem::DenseMatrix inverse_mesh_jacobian(dimension); + for (const double coordinate : {0.25, 0.5, 0.75}) { + CAPTURE(attribute, coordinate); + mfem::IntegrationPoint point; + point.Set3(coordinate, 0.4, 0.6); + point.weight = 0.73; + mapping::VolumeMappingContext context; + mapping::VolumeMappingVariation expected, supplied; + REQUIRE(mapper.evaluate_volume(data.get(), transformation, point, workspace, context) == mapping::MappingStatus::VALID); + REQUIRE(serif::tests::evaluate_volume_variation(mapper, + data.get(), direction, transformation, point, context, workspace, expected + ) == mapping::MappingStatus::VALID); + mfem::Mult(context.quadrature.J_element_to_physical_inverse, context.mapping.mapping_jacobian, inverse_mesh_jacobian); + REQUIRE(mapper.evaluate_compactification_coordinate( + data.get().compactification, transformation, point, workspace, sample, &inverse_mesh_jacobian + ) == mapping::MappingStatus::VALID); + REQUIRE(serif::tests::evaluate_volume_variation(mapper, + data.get(), direction, transformation, point, context, workspace, supplied, &sample + ) == mapping::MappingStatus::VALID); + check_vector(supplied.mapping.displacement_variation, expected.mapping.displacement_variation, 0.0); + check_vector(supplied.mapping.physical_position_variation, expected.mapping.physical_position_variation, 0.0); + check_matrix(supplied.mapping.displacement_jacobian_variation, expected.mapping.displacement_jacobian_variation, 0.0); + check_matrix(supplied.mapping.mapping_jacobian_variation, expected.mapping.mapping_jacobian_variation, 0.0); + check_matrix(supplied.mapping.inverse_mapping_jacobian_variation, expected.mapping.inverse_mapping_jacobian_variation, 0.0); + CHECK(supplied.mapping.mapping_determinate_variation == expected.mapping.mapping_determinate_variation); + CHECK(supplied.weight_variation == expected.weight_variation); + + // A caller need not establish the transformation's current integration point first. + mfem::IntegrationPoint other_point; + other_point.Set3(0.9, 0.2, 0.1); + transformation.SetIntPoint(&other_point); + mapping::CompactificationPointData independently_sampled; + REQUIRE(mapper.evaluate_compactification_coordinate( + data.get().compactification, transformation, point, workspace, independently_sampled + ) == mapping::MappingStatus::VALID); + CHECK_THAT(independently_sampled.compactification_coordinate, WithinAbs(sample.compactification_coordinate, tolerance)); + check_vector(independently_sampled.coordinate_gradient, sample.coordinate_gradient); + + const mapping::CompactificationPointData valid_sample = sample; + sample.coordinate_gradient.SetSize(2); + CHECK(serif::tests::evaluate_volume_variation(mapper, + data.get(), direction, transformation, point, context, workspace, supplied, &sample + ) == (attribute == 3 ? mapping::MappingStatus::INVALID_DIMENSION : mapping::MappingStatus::VALID)); + sample = valid_sample; + sample.coordinate_gradient[1] = std::numeric_limits::infinity(); + CHECK(serif::tests::evaluate_volume_variation(mapper, + data.get(), direction, transformation, point, context, workspace, supplied, &sample + ) == (attribute == 3 ? mapping::MappingStatus::NON_FINITE_MAPPING_INPUT : mapping::MappingStatus::VALID)); + sample = valid_sample; + sample.compactification_coordinate = std::numeric_limits::quiet_NaN(); + CHECK(serif::tests::evaluate_volume_variation(mapper, + data.get(), direction, transformation, point, context, workspace, supplied, &sample + ) == (attribute == 3 ? mapping::MappingStatus::NON_FINITE_MAPPING_INPUT : mapping::MappingStatus::VALID)); + sample.compactification_coordinate = 2.0; + CHECK(serif::tests::evaluate_volume_variation(mapper, + data.get(), direction, transformation, point, context, workspace, supplied, &sample + ) == (attribute == 3 ? mapping::MappingStatus::OUTSIDE_REFERENCE_DOMAIN : mapping::MappingStatus::VALID)); + sample = valid_sample; + + mfem::DenseMatrix invalid_inverse(2); + CHECK(mapper.evaluate_compactification_coordinate( + data.get().compactification, transformation, point, workspace, sample, &invalid_inverse + ) == mapping::MappingStatus::INVALID_DIMENSION); + invalid_inverse = inverse_mesh_jacobian; + invalid_inverse(0, 0) = std::numeric_limits::quiet_NaN(); + CHECK(mapper.evaluate_compactification_coordinate( + data.get().compactification, transformation, point, workspace, sample, &invalid_inverse + ) == mapping::MappingStatus::NON_FINITE_MAPPING_INPUT); + } + } +} + +TEST_CASE( + "Same-point fixed geometry preserves direct maps and variations on curved conforming and NC meshes", + tags::unit & tags::mapping & tags::geometry_cache +) { + for (const bool nonconforming : {false, true}) { + mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(2, 1, 1, mfem::Element::HEXAHEDRON, 2.0, 1.0, 1.0); + if (nonconforming) { + mesh.EnsureNCMesh(); + mfem::Array selected{0}; + mesh.GeneralRefinement(selected); + } + mesh.SetCurvature(6); + mesh.Transform([](const mfem::Vector& x, mfem::Vector& y) { + y = x; + y[0] += 0.03 * x[0] * x[1]; + y[1] += 0.02 * x[1] * x[2]; + y[2] += 0.01 * x[0] * x[2]; + }); + mfem::H1_FECollection collection(3, dimension); + mfem::FiniteElementSpace space(&mesh, &collection, dimension); + if (nonconforming) { REQUIRE(space.GetTrueVSize() < space.GetVSize()); } + mapping::DomainMapper mapper({.dimension = dimension, .vacuum_element_attribute_ID = 3}, make_kelvin_compactification()); + mapping::DMWorkspace direct_workspace(dimension), supplied_workspace(dimension); + for (int element_index = 0; element_index < mesh.GetNE(); ++element_index) { + const auto& element = *space.GetFE(element_index); + auto& transformation = *mesh.GetElementTransformation(element_index); + auto base_values = make_function_element_dofs(element, transformation, evaluate_quadratic_displacement, mfem::Ordering::byNODES); + auto direction_values = make_function_element_dofs(element, transformation, evaluate_quadratic_direction, mfem::Ordering::byNODES); + const mapping::ElementDisplacementData base(element, base_values); + const mapping::ElementDisplacementData direction(element, direction_values); + const auto compactification_values = make_compactification_element_dofs(element, transformation, + [](const mfem::Vector& x) { return 0.1 + 0.03 * x[0] + 0.02 * x[1]; }); + const ElementMappingDataOwner data(base, element, compactification_values); + for (const int attribute : {1, 3}) { + transformation.Attribute = attribute; + for (const double coordinate : {0.2, 0.5, 0.8}) { + CAPTURE(nonconforming, element_index, attribute, coordinate); + mfem::IntegrationPoint point, other_point; + point.Set3(coordinate, 0.4, 0.6); + other_point.Set3(0.9, 0.1, 0.3); + mapping::MappingPointContext direct, supplied; + mapping::MappingPointVariation direct_variation, supplied_variation; + REQUIRE(mapper.evaluate_point(data.get(), transformation, point, direct_workspace, direct) == mapping::MappingStatus::VALID); + REQUIRE(mapper.evaluate_point_variation(data.get(), direction, transformation, point, direct, direct_workspace, direct_variation) == mapping::MappingStatus::VALID); + + // Reset the transformation between calls: reuse is of the owned checked sample, + // not a dependency on MFEM's mutable last-point Jacobian cache. + const auto fixed = mapping::sample_fixed_mesh_geometry(transformation, point); + REQUIRE(fixed); + transformation.SetIntPoint(&other_point); + REQUIRE(mapper.evaluate_point(data.get(), transformation, point, supplied_workspace, supplied, *fixed) == mapping::MappingStatus::VALID); + transformation.SetIntPoint(&other_point); + REQUIRE(mapper.evaluate_point_variation(data.get(), direction, transformation, point, supplied, supplied_workspace, supplied_variation, fixed->inverse_jacobian) == mapping::MappingStatus::VALID); + check_point_context(supplied, direct, 0.0); + check_vector(supplied_variation.displacement_variation, direct_variation.displacement_variation, 0.0); + check_vector(supplied_variation.physical_position_variation, direct_variation.physical_position_variation, 0.0); + check_matrix(supplied_variation.displacement_jacobian_variation, direct_variation.displacement_jacobian_variation, 0.0); + check_matrix(supplied_variation.mapping_jacobian_variation, direct_variation.mapping_jacobian_variation, 0.0); + check_matrix(supplied_variation.inverse_mapping_jacobian_variation, direct_variation.inverse_mapping_jacobian_variation, 0.0); + CHECK(supplied_variation.mapping_determinate_variation == direct_variation.mapping_determinate_variation); + } + } + } + } +} + +TEST_CASE( + "Same-point reuse rejects malformed geometry and preserves physical mapping failures", + tags::unit & tags::mapping & tags::geometry_cache +) { + SingleElementFixture fixture; + auto& transformation = *fixture.mesh.GetElementTransformation(0); + const auto& point = mfem::IntRules.Get(mfem::Geometry::CUBE, 3).IntPoint(0); + const auto fixed = mapping::sample_fixed_mesh_geometry(transformation, point); + REQUIRE(fixed); + mapping::DomainMapper mapper({.dimension = dimension, .vacuum_element_attribute_ID = 3}, make_kelvin_compactification()); + mapping::DMWorkspace workspace(dimension); + auto zero = fixture.make_zero_element_dofs(); + const mapping::ElementDisplacementData base(fixture.get_element(), zero); + const ElementMappingDataOwner data(base); + mapping::MappingPointContext context; + mapping::MappingPointVariation variation; + auto invalid = *fixed; + invalid.position.SetSize(2); + CHECK(mapper.evaluate_point(data.get(), transformation, point, workspace, context, invalid) == mapping::MappingStatus::INVALID_DIMENSION); + invalid = *fixed; + invalid.inverse_jacobian(0, 0) = std::numeric_limits::infinity(); + CHECK(mapper.evaluate_point(data.get(), transformation, point, workspace, context, invalid) == mapping::MappingStatus::NON_FINITE_MAPPING_INPUT); + invalid = *fixed; + invalid.weight = 0.0; + CHECK(mapper.evaluate_point(data.get(), transformation, point, workspace, context, invalid) == mapping::MappingStatus::NON_POSITIVE_DETERMINATE); + invalid = *fixed; + invalid.jacobian = 0.0; + CHECK(mapper.evaluate_point(data.get(), transformation, point, workspace, context, invalid) == mapping::MappingStatus::NON_POSITIVE_DETERMINATE); + REQUIRE(mapper.evaluate_point(data.get(), transformation, point, workspace, context) == mapping::MappingStatus::VALID); + mfem::DenseMatrix invalid_inverse(2); + CHECK(mapper.evaluate_point_variation(data.get(), base, transformation, point, context, workspace, variation, invalid_inverse) == mapping::MappingStatus::INVALID_DIMENSION); + invalid_inverse = fixed->inverse_jacobian; + invalid_inverse(0, 0) = std::numeric_limits::quiet_NaN(); + CHECK(mapper.evaluate_point_variation(data.get(), base, transformation, point, context, workspace, variation, invalid_inverse) == mapping::MappingStatus::NON_FINITE_MAPPING_INPUT); + + auto inverted_values = make_affine_element_dofs(fixture.get_element(), transformation, + [] { auto gradient = make_identity_matrix(dimension); gradient *= -2.0; return gradient; }(), + make_vector(0.0, 0.0, 0.0), mfem::Ordering::byNODES); + const mapping::ElementDisplacementData inverted(fixture.get_element(), inverted_values); + const ElementMappingDataOwner inverted_data(inverted); + CHECK(mapper.evaluate_point(inverted_data.get(), transformation, point, workspace, context) == mapping::MappingStatus::NON_POSITIVE_DETERMINATE); + CHECK(mapper.evaluate_point(inverted_data.get(), transformation, point, workspace, context, *fixed) == mapping::MappingStatus::NON_POSITIVE_DETERMINATE); + transformation.Attribute = 3; + const auto invalid_compactification = make_constant_compactification_dofs(fixture.get_element(), 2.0); + const ElementMappingDataOwner invalid_exterior(base, fixture.get_element(), invalid_compactification); + CHECK(mapper.evaluate_point(invalid_exterior.get(), transformation, point, workspace, context) == mapping::MappingStatus::OUTSIDE_REFERENCE_DOMAIN); + CHECK(mapper.evaluate_point(invalid_exterior.get(), transformation, point, workspace, context, *fixed) == mapping::MappingStatus::OUTSIDE_REFERENCE_DOMAIN); +} diff --git a/tests/mapping/meson.build b/tests/mapping/meson.build new file mode 100644 index 00000000..1067a754 --- /dev/null +++ b/tests/mapping/meson.build @@ -0,0 +1,5 @@ +mapping_test_sources = files( + 'compactification/kelvin.cpp', + 'domain_mapper.cpp', + 'volume_cache.cpp', +) diff --git a/tests/mapping/volume_cache.cpp b/tests/mapping/volume_cache.cpp new file mode 100644 index 00000000..9b78ca68 --- /dev/null +++ b/tests/mapping/volume_cache.cpp @@ -0,0 +1,171 @@ +// Ported from MeanField tests/mapping/prepared_cache.cpp. + +#include + +#include +#include + +#include "serif/backend/mfem/mapping/volume_cache.hpp" + +#include "serif/tests/test_tags.hpp" + +namespace { + using serif::mapping::VolumeMappingContext; + + void fill_vector(mfem::Vector& vector, const int dimension, const double offset) { + vector.SetSize(dimension); + for (int i = 0; i < dimension; ++i) { + vector(i) = offset + i; + } + } + + void fill_matrix(mfem::DenseMatrix& matrix, const int dimension, const double offset) { + matrix.SetSize(dimension); + for (int j = 0; j < dimension; ++j) { + for (int i = 0; i < dimension; ++i) { + matrix(i, j) = offset + 10 * j + i; + } + } + } + + VolumeMappingContext make_context(const int dimension, const double offset, const bool compactified) { + VolumeMappingContext context; + fill_vector(context.mapping.reference_position, dimension, offset + 1); + fill_vector(context.mapping.displaced_position, dimension, offset + 2); + fill_vector(context.mapping.physical_position, dimension, offset + 3); + fill_matrix(context.mapping.displacement_jacobian, dimension, offset + 4); + fill_matrix(context.mapping.mapping_jacobian, dimension, offset + 5); + fill_matrix(context.mapping.inverse_mapping_jacobian, dimension, offset + 6); + fill_matrix(context.quadrature.J_element_to_physical_inverse, dimension, offset + 7); + context.mapping.mapping_determinate = offset + 8; + context.mapping.compactified = compactified; + context.quadrature.mapping_determinate = offset + 9; + context.quadrature.physical_integration_weight = offset + 10; + return context; + } + + void check_vector(const mfem::Vector& actual, const mfem::Vector& expected) { + REQUIRE(actual.Size() == expected.Size()); + for (int i = 0; i < expected.Size(); ++i) { + CHECK(actual(i) == expected(i)); + } + } + + void check_matrix(const mfem::DenseMatrix& actual, const mfem::DenseMatrix& expected) { + REQUIRE(actual.Height() == expected.Height()); + REQUIRE(actual.Width() == expected.Width()); + for (int j = 0; j < expected.Width(); ++j) { + for (int i = 0; i < expected.Height(); ++i) { + CHECK(actual(i, j) == expected(i, j)); + } + } + } + + void check_context(const VolumeMappingContext& actual, const VolumeMappingContext& expected) { + check_vector(actual.mapping.reference_position, expected.mapping.reference_position); + check_vector(actual.mapping.displaced_position, expected.mapping.displaced_position); + check_vector(actual.mapping.physical_position, expected.mapping.physical_position); + check_matrix(actual.mapping.displacement_jacobian, expected.mapping.displacement_jacobian); + check_matrix(actual.mapping.mapping_jacobian, expected.mapping.mapping_jacobian); + check_matrix(actual.mapping.inverse_mapping_jacobian, expected.mapping.inverse_mapping_jacobian); + check_matrix( + actual.quadrature.J_element_to_physical_inverse, expected.quadrature.J_element_to_physical_inverse + ); + CHECK(actual.mapping.mapping_determinate == expected.mapping.mapping_determinate); + CHECK(actual.mapping.compactified == expected.mapping.compactified); + CHECK(actual.quadrature.mapping_determinate == expected.quadrature.mapping_determinate); + CHECK(actual.quadrature.physical_integration_weight == expected.quadrature.physical_integration_weight); + } +} // namespace + +TEST_CASE( + "Flat Volume Mapping Cache Preserves Every Context Field", + tags::unit & tags::volume_cache +) { + for (const int dimension : {1, 2, 3}) { + CAPTURE(dimension); + serif::mapping::VolumeMappingCache cache; + cache.set_size(2, dimension); + CHECK(cache.get_point_count() == 2); + CHECK(cache.get_dimension() == dimension); + auto first = make_context(dimension, 0.125, false); + auto second = make_context(dimension, -30.25, true); + cache.store(0, first); + cache.store(1, second); + + VolumeMappingContext workspace; + cache.load(1, workspace); + check_context(workspace, second); + const double* inverse_buffer = workspace.quadrature.J_element_to_physical_inverse.HostRead(); + const double* position_buffer = workspace.mapping.physical_position.HostRead(); + cache.load(0, workspace); + check_context(workspace, first); + CHECK(workspace.quadrature.J_element_to_physical_inverse.HostRead() == inverse_buffer); + CHECK(workspace.mapping.physical_position.HostRead() == position_buffer); + + mfem::DenseMatrix inverse; + cache.load_inverse_jacobian(1, inverse); + check_matrix(inverse, second.quadrature.J_element_to_physical_inverse); + const auto copy = cache; + cache.store(1, first); + copy.load(1, workspace); + check_context(workspace, second); + cache.load(1, workspace); + check_context(workspace, first); + + cache.set_size(1, 3); + auto resized = make_context(3, 13.5, true); + cache.store(0, resized); + cache.load(0, workspace); + check_context(workspace, resized); + } +} + +TEST_CASE( + "Flat Volume Mapping Cache Rejects Invalid Indices And Dimensions", + tags::unit & tags::volume_cache +) { + serif::mapping::VolumeMappingCache cache; + VolumeMappingContext workspace; + CHECK_THROWS_AS(cache.set_size(-1, 3), std::logic_error); + CHECK_THROWS_AS(cache.set_size(1, 0), std::logic_error); + CHECK_THROWS_AS(cache.set_size(1, 4), std::logic_error); + cache.set_size(1, 3); + CHECK_THROWS_AS(cache.load(-1, workspace), std::logic_error); + CHECK_THROWS_AS(cache.load(1, workspace), std::logic_error); + auto wrong_dimension = make_context(2, 0.0, false); + CHECK_THROWS_AS(cache.store(0, wrong_dimension), std::logic_error); + cache.set_size(0, 2); + CHECK(cache.get_point_count() == 0); + CHECK_THROWS_AS(cache.load(0, workspace), std::logic_error); +} + +TEST_CASE( + "Flat Volume Mapping Cache Rejects Malformed Packed Fields", + tags::unit & tags::volume_cache +) { + serif::mapping::VolumeMappingCache cache; + cache.set_size(1, 3); + auto context = make_context(3, 0.0, false); + for (auto* vector : + {&context.mapping.reference_position, &context.mapping.displaced_position, + &context.mapping.physical_position}) { + for (const int size : {2, 4}) { + vector->SetSize(size); + CHECK_THROWS_AS(cache.store(0, context), std::logic_error); + } + vector->SetSize(3); + *vector = 0.0; + } + for (auto* matrix : + {&context.mapping.displacement_jacobian, &context.mapping.mapping_jacobian, + &context.mapping.inverse_mapping_jacobian, &context.quadrature.J_element_to_physical_inverse}) { + matrix->SetSize(4, 4); + CHECK_THROWS_AS(cache.store(0, context), std::logic_error); + matrix->SetSize(3, 2); + CHECK_THROWS_AS(cache.store(0, context), std::logic_error); + matrix->SetSize(3, 3); + *matrix = 0.0; + } + CHECK_NOTHROW(cache.store(0, context)); +} diff --git a/tests/meson.build b/tests/meson.build index 26f9bd6b..abf1a881 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -1,22 +1,39 @@ if get_option('build_tests') -# subdir('discritization') -# -# test_include_dir = include_directories('include') -# -# catch2_dep = dependency('Catch2', required:true) - # Stroid exchanges MFEM and standard-library objects with the sandboxes. - # Always build it against the parent's MFEM and C++ ABI, never an unrelated - # installed archive (even when pkg-config reports one). - stroid_project = subproject('stroid', default_options: [ - 'build_tests=false', - 'build_tools=false', - 'build_python=false', - 'pkg_config=false', - ]) - stroid_dep = stroid_project.get_variable('stroid_dep') -# -# test_sources = discretization_test_sources -# executable('serif_tests', test_sources, dependencies: [serif_dep, catch2_dep, stroid_dep], include_directories: test_include_dir) + test('mfem_build_policy', python_build, + args: files('../tools/test_mfem_build_policy.py'), suite: 'build', timeout: 60) + catch2_dep = dependency('catch2-with-main', version: '>=3.0', fallback: ['catch2', 'catch2_with_main_dep'], default_options: ['tests=false', 'install=false']) - subdir('sandbox') + subdir('discritization') + subdir('mapping') + subdir('eos') + subdir('quadrature') + subdir('equations') + subdir('surface') + subdir('deformation') + subdir('utils') + subdir('normalization') + subdir('preconditioner') + subdir('model') + subdir('solver') + subdir('serialize') + + test_include_dir = include_directories('include') + test_sources = discretization_test_sources + mapping_test_sources + eos_test_sources + quadrature_test_sources + equation_test_sources + surface_test_sources + deformation_test_sources + normalization_test_sources + serif_tests = executable( + 'serif_tests', + test_sources, + dependencies: [serif_dep, catch2_dep, stroid_dep], + include_directories: test_include_dir, + build_rpath: mfem_runtime_prefix == '' ? '' : mfem_runtime_prefix / 'lib', + ) + test('serif_unit_tests', serif_tests, args: ['[unit]~[vector_gradient]'], suite: 'unit', timeout: 120) + # These exhaustive dense/tensor comparisons have their own budget without reducing the ordinary unit checks. + test('serif_vector_gradient_tests', serif_tests, args: ['[unit][vector_gradient]'], suite: 'unit', timeout: 300, is_parallel: false) + test('serif_integration_tests', serif_tests, args: ['[integration]~[nonconforming]~[serial_backend]'], suite: 'integration', timeout: 300) + test('serif_serial_backend_tests', serif_tests, args: ['[integration][serial_backend]~[nonconforming]'], suite: 'integration', timeout: 600) + + # Keep native nonconforming studies together without shortening the existing tests' budgets. + test('serif_nonconforming_tests', serif_tests, args: ['[integration][nonconforming]'], suite: 'integration', timeout: 1200, is_parallel: false) + + subdir('sandbox') endif diff --git a/tests/model/automatic_surface.cpp b/tests/model/automatic_surface.cpp new file mode 100644 index 00000000..cda6a3c6 --- /dev/null +++ b/tests/model/automatic_surface.cpp @@ -0,0 +1,285 @@ +#include +#include +#include +#include + +#include +#include + +#include "serif/backend/mfem/model/equilibrium.hpp" +#include "serif/tests/equations/fixture.hpp" +#include "serif/tests/surface/boundary_fixture.hpp" + +namespace { + namespace model = serif::model; + namespace backend = model::backend::mfem; + namespace execution = serif::operators::backend::mfem; + namespace dependencies = serif::utils::dependencies; + namespace equations = serif::equations; + namespace surface = serif::geometry::surface; + namespace quantities = serif::dimensions; + namespace tests = serif::tests::equations; + namespace gas = serif::tests::surface; + namespace boundary = gas::boundary; + namespace blocks = serif::discretization::blocks; + using Displacement = blocks::displacement::vector; + using Kind = blocks::RepresentedValueKind; + using Vector = mfem::Vector; + + struct Material : blocks::field { + static constexpr std::string_view name = "test thermodynamic fields"; + }; + + /** @brief Add the runtime names needed by volume quadrature to the existing boundary test terms. */ + template + struct ScalarField : boundary::ContinuousScalar { + using field_type = Material; + static constexpr std::string_view name = "test scalar thermodynamic field"; + }; + + using Density = ScalarField; + using Temperature = ScalarField; + using Pressure = ScalarField; + + /** @brief Distinguish a uniform thermodynamic state from a global constraint with coincident units. */ + struct UniformTemperature : boundary::UniformTemperature { + using field_type = Material; + static constexpr std::string_view name = "prescribed uniform temperature"; + static constexpr bool thermodynamic_state = true; + }; + + /** @brief Keep the interior equation independent of EOS selection so the boundary must supply its own relation. */ + template + struct FieldEquation : equations::Contribution> { + using Value = serif::discretization::forms::Operand; + using Operands = serif::utils::types::TypeList; + using ResidualQuantity = typename Field::Quantity; + static constexpr std::string_view name = "independent scalar test equation"; + + /** @brief A linear weak equation gives a nonzero interior that the surface must replace. */ + [[nodiscard]] equations::PointLinearization evaluate(const equations::EquationPoint& point) const { + equations::PointLinearization result; + result.residual = point.fields.template get(); + result.field_derivatives.template get() = 1.0; + return result; + } + + /** @brief The scalar coefficient introduces no additional quadrature degree. */ + [[nodiscard]] int additional_order(Value, int) const { + return 0; + } + }; + + /** @brief Supply row ownership only when two different interior rows could carry a pressure residual. */ + struct DensityEquationWithSurface : FieldEquation { + using IsobaricSurfaceField = Density; + }; + + /** @brief Expose the forward ideal-gas relation without advertising any inverse. */ + struct ForwardGasEOS : gas::GasEOS { + using Relations = serif::eos::relations::RelationCatalog; + }; + + /** @brief A pressure field already represents the boundary quantity even without numerical EOS relations. */ + struct SymbolicEOS { + }; + + struct Fixture : tests::Fixture { + serif::discretization::backend::mfem::TermBinding density{enthalpy_space}; + serif::discretization::backend::mfem::TermBinding temperature{enthalpy_space}; + serif::discretization::backend::mfem::TermBinding pressure{enthalpy_space}; + serif::discretization::backend::mfem::TermBinding uniform_temperature{mesh}; + + /** @brief Reuse established volume resources with an independently marked x=0 stellar surface. */ + Fixture() : tests::Fixture(boundary::make_boundary_mesh()) { + } + }; + + /** @brief Retain only the physical bindings needed by a particular model. */ + template + [[nodiscard]] auto discretize(const std::shared_ptr& fixture, Bindings... bindings) { + return backend::make_stellar_discretization(fixture, + dependencies::make_dependency_context(std::move(bindings)..., dependencies::borrow(fixture->displacement_binding)), + fixture->compactification, fixture->mapper); + } + + /** @brief Save a complete residual before another candidate replaces the graph's current result. */ + [[nodiscard]] Vector residual(const backend::PhysicalSystem& system) { + Vector values(static_cast(system.residual().size())); + std::copy(system.residual().begin(), system.residual().end(), values.GetData()); + return values; + } + + /** @brief Exercise the complete assembled action, transpose, cache reuse and retained evaluation lifetime. */ + template + void check_linearization(Evaluator& evaluator, const Coefficients& coefficients, const Vector& displacement, Vector& state) { + auto evaluation = evaluator.evaluate(coefficients, displacement); + REQUIRE(evaluation); + const auto linearization = *evaluation; + evaluation = evaluator.evaluate(coefficients, displacement); + REQUIRE(evaluation); + CHECK(*evaluation == linearization); + CHECK(evaluator.cache().report().computed_count() == 0); + + const Vector original(state); + const Vector direction = tests::deterministic(state.Size(), 0.6); + const Vector dual = tests::deterministic(static_cast(linearization->residual().size()), 1.1); + Vector action(dual.Size()), transpose(state.Size()); + linearization->jacobian().action()->Mult(direction, action); + linearization->jacobian().action()->MultTranspose(dual, transpose); + CHECK((dual * action) == Catch::Approx(direction * transpose).margin(2.0e-12)); + + constexpr double step = 1.0e-5; + state.Add(step, direction); + evaluation = evaluator.evaluate(coefficients, displacement); + REQUIRE(evaluation); + Vector difference = residual(**evaluation); + state = original; + state.Add(-step, direction); + evaluation = evaluator.evaluate(coefficients, displacement); + REQUIRE(evaluation); + difference -= residual(**evaluation); + difference /= 2.0 * step; + for (int row = 0; row < action.Size(); ++row) { + CHECK(action[row] == Catch::Approx(difference[row]).margin(2.0e-9)); + } + state = original; + evaluation = evaluator.evaluate(coefficients, displacement); + REQUIRE(evaluation); + Vector frozen(action.Size()); + linearization->jacobian().action()->Mult(direction, frozen); + for (int row = 0; row < action.Size(); ++row) { + CHECK(frozen[row] == action[row]); + } + } + + /** @brief Infer a density carrier while acquiring prescribed temperature solely from the supplied physical state. */ + template + void check_prescribed_temperature() { + auto fixture = std::make_shared(); + const auto discretization = discretize(fixture, dependencies::borrow(fixture->density), dependencies::borrow(fixture->uniform_temperature)); + Vector density = boundary::sequence(fixture->density.size(), 0.8, 0.3); + Vector temperature = tests::constant(1, 1.7); + const Vector displacement = tests::constant(fixture->displacement_binding.size(), 0.0); + const auto coefficients = execution::make_term_coefficients(execution::bind(fixture->density, density), execution::bind(fixture->uniform_temperature, temperature)); + const auto star = model::make_stellar_model(EOS{}, model::EquilibriumEquations { + .interior_equations = equations::EquationRegistry{FieldEquation{}}, + .shape_force = model::NoShapeForce{}, + .surface_condition = surface::Isobaric{quantities::PressureValue{0.8}} + }); + using Definition = typename decltype(star)::Equations; + static_assert(std::same_as); + auto evaluator = backend::bind_stellar_model>(star, discretization, coefficients, displacement); + static_assert(decltype(evaluator)::Plan::has_surface); + using Formulation = typename decltype(evaluator)::SurfaceFormulation; + STATIC_CHECK(std::same_as); + STATIC_CHECK(Formulation::resolves_carrier == Inverse); + CHECK(evaluator.layout().template total_size() == static_cast(density.Size())); + STATIC_CHECK_FALSE(dependencies::ContainsKey); + const model::StellarState packed_state(evaluator.layout(), std::vector(density.GetData(), density.GetData() + density.Size())); + const auto prescribed = execution::make_term_coefficients(execution::bind(fixture->uniform_temperature, temperature)); + const auto initial_evaluation = evaluator.evaluate(coefficients, displacement); + REQUIRE(initial_evaluation); + using InteriorResult = execution::volume_keys::Scoped::Linearization; + const Vector selected = boundary::boundary_mask(fixture->density); + for (double thermal : {1.7, 2.1}) { + const auto interior = evaluator.cache().template share(); + temperature[0] = thermal; + const auto evaluation = evaluator.evaluate(packed_state, displacement, prescribed); + REQUIRE(evaluation); + CHECK(evaluator.cache().template share() == interior); + CHECK((*evaluation)->jacobian().action()->Width() == density.Size()); + if (thermal == 1.7) { + CHECK(*evaluation == *initial_evaluation); + CHECK(evaluator.cache().report().computed_count() == 0); + } else { + CHECK(*evaluation != *initial_evaluation); + } + const auto external_evaluation = evaluator.evaluate(coefficients, displacement); + REQUIRE(external_evaluation); + CHECK(*external_evaluation == *evaluation); + CHECK(evaluator.cache().report().computed_count() == 0); + int boundary_rows = 0; + for (int row = 0; row < density.Size(); ++row) { + if (selected[row] != 0.0) { + ++boundary_rows; + const double expected = Inverse ? density[row] - 0.8 / thermal : density[row] * thermal - 0.8; + CHECK((*evaluation)->residual()[row] == Catch::Approx(expected).margin(2.0e-13)); + } + } + REQUIRE(boundary_rows > 0); + REQUIRE(boundary_rows < density.Size()); + check_linearization(evaluator, coefficients, displacement, density); + } + } +} // namespace + +TEST_CASE("Isobaric infers a pressure residual from a forward-only non-enthalpy EOS", "[model][surface][integration]") { + check_prescribed_temperature(); +} + +TEST_CASE("Isobaric prefers an available non-enthalpy inverse with prescribed boundary-only input", "[model][surface][integration]") { + check_prescribed_temperature(); +} + +TEST_CASE("Isobaric uses an existing pressure field without requiring EOS relations", "[model][surface][integration]") { + auto fixture = std::make_shared(); + const auto discretization = discretize(fixture, dependencies::borrow(fixture->pressure)); + Vector pressure = boundary::sequence(fixture->pressure.size(), 0.9, 0.1); + const Vector displacement = tests::constant(fixture->displacement_binding.size(), 0.0); + const auto coefficients = execution::make_term_coefficients(execution::bind(fixture->pressure, pressure)); + const auto star = model::make_stellar_model(SymbolicEOS{}, model::EquilibriumEquations { + .interior_equations = equations::EquationRegistry{FieldEquation{}}, + .shape_force = model::NoShapeForce{}, + .surface_condition = surface::Isobaric{quantities::PressureValue{0.7}} + }); + auto evaluator = backend::bind_stellar_model(star, discretization, coefficients, displacement); + using Formulation = typename decltype(evaluator)::SurfaceFormulation; + STATIC_CHECK(std::same_as); + const auto evaluation = evaluator.evaluate(coefficients, displacement); + REQUIRE(evaluation); + const Vector selected = boundary::boundary_mask(fixture->pressure); + int boundary_rows = 0; + for (int row = 0; row < pressure.Size(); ++row) { + if (selected[row] != 0.0) { + ++boundary_rows; + CHECK((*evaluation)->residual()[row] == Catch::Approx(pressure[row] - 0.7).margin(2.0e-13)); + } + } + REQUIRE(boundary_rows > 0); + check_linearization(evaluator, coefficients, displacement, pressure); +} + +TEST_CASE("An equation can name its isobaric field when two continuous rows are eligible", "[model][surface][integration]") { + auto fixture = std::make_shared(); + const auto discretization = discretize(fixture, dependencies::borrow(fixture->temperature), dependencies::borrow(fixture->density)); + const int size = fixture->density.size(); + Vector state(2 * size); + Vector temperature(state.GetData(), size), density(state.GetData() + size, size); + const Vector initial_temperature = boundary::sequence(size, 1.7, 0.2); + const Vector initial_density = boundary::sequence(size, 0.8, 0.4); + temperature = initial_temperature; + density = initial_density; + const Vector displacement = tests::constant(fixture->displacement_binding.size(), 0.0); + const auto coefficients = execution::make_term_coefficients(execution::bind(fixture->temperature, temperature), execution::bind(fixture->density, density)); + const auto star = model::make_stellar_model(ForwardGasEOS{}, model::EquilibriumEquations { + .interior_equations = equations::EquationRegistry{FieldEquation{}, DensityEquationWithSurface{}}, + .shape_force = model::NoShapeForce{}, + .surface_condition = surface::Isobaric{quantities::PressureValue{0.8}} + }); + auto evaluator = backend::bind_stellar_model(star, discretization, coefficients, displacement); + using Formulation = typename decltype(evaluator)::SurfaceFormulation; + STATIC_CHECK(std::same_as); + CHECK(evaluator.layout().template offset() == 0); + CHECK(evaluator.layout().template offset() == static_cast(size)); + const auto evaluation = evaluator.evaluate(coefficients, displacement); + REQUIRE(evaluation); + const int row_offset = static_cast(evaluator.layout().template offset()); + const Vector selected = boundary::boundary_mask(fixture->density); + for (int row = 0; row < size; ++row) { + if (selected[row] != 0.0) { + CHECK((*evaluation)->residual()[row_offset + row] == Catch::Approx(density[row] * temperature[row] - 0.8).margin(2.0e-13)); + } + } + check_linearization(evaluator, coefficients, displacement, state); +} diff --git a/tests/model/description.cpp b/tests/model/description.cpp new file mode 100644 index 00000000..c9d284c9 --- /dev/null +++ b/tests/model/description.cpp @@ -0,0 +1,272 @@ +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "serif/model/description.hpp" +#include "serif/model/eos_reference.hpp" +#include "serif/tests/equations/test_equations.hpp" +#include "serif/equations/registry.hpp" +#include "serif/geometry/surface/isobaric.hpp" + +namespace +{ + namespace model = serif::model; + namespace dimensions = serif::dimensions; + + struct SymbolicEOS + { + double parameter; + }; + + struct MoveOnlyEOS + { + std::unique_ptr parameter; + }; + + struct MoveOnlyConstraint + { + using ScalarDescription = model::ScalarConstraint; + using ModelDefinition = model::SpecificationDefinition; + std::unique_ptr mass; + /** @brief Expose a dimensional target while preserving unique ownership. */ + dimensions::MassValue target() const + { + return dimensions::MassValue{*mass}; + } + }; + + struct MoveOnlyPhysicalEOS + { + using Relation = serif::eos::relations::DensityFromSpecificEnthalpy; + using Relations = serif::eos::relations::RelationCatalog; + std::unique_ptr coefficient; + + /** @brief Supply an independent linear closure with a uniquely owned material parameter. */ + dimensions::DensityValue evaluate(Relation, dimensions::SpecificEnthalpyValue enthalpy) const noexcept + { + return dimensions::DensityValue{*coefficient * enthalpy.value()}; + } + + /** @brief Supply the exact derivative while retaining the EOS relation contract. */ + dimensions::PartialDerivative partial_derivative(Relation, dimensions::WithRespectTo, dimensions::SpecificEnthalpyValue) const noexcept + { + return dimensions::PartialDerivative{*coefficient}; + } + }; + + /** @brief Preserve surface members through aggregate deduction and the substitution performed by a templated factory. */ + template + [[nodiscard]] auto model_with_surface(EOS eos, dimensions::PressureValue target) + { + return model::make_stellar_model(std::move(eos), model::EquilibriumEquations + { + .interior_equations = serif::equations::EquationRegistry{::serif::tests::equations::BarotropicClosure{serif::eos::models::Polytrope{2.0, 0.3}}}, + .shape_force = model::NoShapeForce{}, + .surface_condition = serif::geometry::surface::Isobaric{target} + }); + } +} + +TEST_CASE("Templated model factories retain the declared surface type and physical pressure", "[model][unit][surface]") +{ + const auto star = model_with_surface(SymbolicEOS{3.0}, dimensions::PressureValue{0.8}); + using Description = typename decltype(star)::Equations; + using Surface = serif::geometry::surface::Isobaric; + STATIC_CHECK(std::same_as); + STATIC_CHECK(std::same_as); + STATIC_CHECK(std::same_as, Surface>); + CHECK(star.equations().surface_condition.target_pressure().value() == 0.8); + const auto equations = model::resolve_equilibrium_equations(star); + STATIC_CHECK(std::same_as); + CHECK(&equations.surface_condition == &star.equations().surface_condition); + CHECK(equations.surface_condition.target_pressure().value() == 0.8); +} + +TEST_CASE("Stellar descriptions retain typed physics in canonical order", "[model][unit]") +{ + const auto star = model::make_stellar_model(SymbolicEOS{3.0}, model::EquilibriumEquations{17}, + model::FixedAngularMomentum{dimensions::AngularMomentumValue{0.0}}, + model::FixedTotalMass{dimensions::MassValue{1.5}}); + CHECK(star.equation_of_state().parameter == 3.0); + CHECK(star.equations().interior_equations == 17); + CHECK(star.specification().target().value() == 1.5); + CHECK(std::tuple_size_v())> == 2); + const auto descriptors = star.specification_descriptors(); + REQUIRE(descriptors.size() == 2); + CHECK(descriptors[0].name == "FixedTotalMass"); + CHECK(descriptors[1].name == "FixedAngularMomentum"); + + std::vector targets; + star.visit_specifications([&](const auto& specification) + { + if constexpr (requires { specification.target(); }) + { + targets.push_back(specification.target().value()); + } + else + { + targets.push_back(specification.parameter); + } + }); + CHECK(targets == std::vector{3.0, 1.5, 0.0}); +} + +TEST_CASE("Explicit equation definitions read one authoritative EOS and target at binding", "[model][unit]") +{ + int resolutions = 0; + const auto definition = [&resolutions](const auto& physics) + { + ++resolutions; + return model::EquilibriumEquations{physics.equation_of_state().parameter + physics.template specification().target().value()}; + }; + const auto star = model::make_stellar_model(SymbolicEOS{3.0}, definition, model::FixedTotalMass{dimensions::MassValue{2.0}}); + CHECK(resolutions == 0); + const auto equations = model::resolve_equilibrium_equations(star); + CHECK(resolutions == 1); + CHECK(equations.interior_equations == 5.0); + const auto changed = star.transform_specifications([](const auto& physics) + { + using Physics = std::remove_cvref_t; + if constexpr (std::same_as) + { + return Physics{dimensions::MassValue{7.0}}; + } + else + { + return physics; + } + }); + CHECK(model::resolve_equilibrium_equations(changed).interior_equations == 10.0); + CHECK(model::resolve_equilibrium_equations(star).interior_equations == 5.0); +} + +TEST_CASE("Equation composition resolves member definitions and borrows move-only stored equations", "[model][unit]") +{ + const auto definitions = model::EquilibriumEquations + { + .interior_equations = [](const auto& physics) + { + return std::make_unique(physics.equation_of_state().parameter); + }, + .shape_force = std::make_unique(8) + }; + const auto star = model::make_stellar_model(SymbolicEOS{4.0}, model::EquilibriumEquations + { + .interior_equations = definitions.interior_equations, + .shape_force = std::make_unique(8) + }); + const auto equations = model::resolve_equilibrium_equations(star); + STATIC_CHECK(std::is_reference_v); + STATIC_CHECK_FALSE(std::is_reference_v); + CHECK(*equations.interior_equations == 4.0); + CHECK(&equations.shape_force == &star.equations().shape_force); + CHECK(*equations.shape_force == 8); +} + +TEST_CASE("Move-only physical prescriptions survive construction and explicit reconstruction", "[model][unit]") +{ + const auto star = model::make_stellar_model(MoveOnlyEOS{std::make_unique(3.0)}, model::EquilibriumEquations{std::make_unique(5)}, + MoveOnlyConstraint{std::make_unique(2.0)}, model::FixedTotalMass{dimensions::MassValue{7.0}}); + CHECK(*star.equation_of_state().parameter == 3.0); + CHECK(star.specification().target().value() == 2.0); + const auto changed = star.transform_specifications([](const auto& physics) + { + using Physics = std::remove_cvref_t; + if constexpr (std::same_as) + { + return Physics{std::make_unique(*physics.parameter)}; + } + else if constexpr (std::same_as) + { + return Physics{std::make_unique(11.0)}; + } + else + { + return physics; + } + }, model::EquilibriumEquations{std::make_unique(9)}); + CHECK(changed.specification().target().value() == 11.0); + CHECK(star.specification().target().value() == 2.0); + CHECK(*model::resolve_equilibrium_equations(changed).interior_equations == 9); +} + +TEST_CASE("Fixed physical targets reject invalid numbers and retain independent coordinate units", "[model][unit][validation]") +{ + for (double invalid : {0.0, -1.0, std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()}) + { + CHECK_THROWS_AS(model::FixedTotalMass{dimensions::MassValue{invalid}}, std::invalid_argument); + if (invalid != 0.0) + { + CHECK_THROWS_AS(model::FixedAngularMomentum{dimensions::AngularMomentumValue{invalid}}, std::invalid_argument); + } + } + CHECK_NOTHROW(model::FixedAngularMomentum{dimensions::AngularMomentumValue{0.0}}); + CHECK_THROWS_AS(model::FixedAngularMomentum(model::FixedAngularMomentum::Parameters{.angular_momentum = dimensions::AngularMomentumValue{1.0}, .axis = {0.0, 0.0, 0.0}}), std::invalid_argument); + for (double invalid : {std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()}) + { + for (std::size_t component = 0; component < 3; ++component) + { + model::FixedAngularMomentum::Parameters invalid_axis{.angular_momentum = dimensions::AngularMomentumValue{1.0}}; + invalid_axis.axis[component] = invalid; + CHECK_THROWS_AS(model::FixedAngularMomentum{invalid_axis}, std::invalid_argument); + model::FixedAngularMomentum::Parameters invalid_center{.angular_momentum = dimensions::AngularMomentumValue{1.0}}; + invalid_center.center[component] = invalid; + CHECK_THROWS_AS(model::FixedAngularMomentum{invalid_center}, std::invalid_argument); + } + } + const model::FixedAngularMomentum prescribed(model::FixedAngularMomentum::Parameters{.angular_momentum = dimensions::AngularMomentumValue{2.0}, .axis = {3.0, 4.0, 0.0}, .center = {1.0, 2.0, 3.0}}); + CHECK(prescribed.axis()[0] == Catch::Approx(0.6)); + CHECK(prescribed.axis()[1] == Catch::Approx(0.8)); + CHECK(prescribed.center() == std::array{1.0, 2.0, 3.0}); + for (double scale : {std::numeric_limits::max(), std::numeric_limits::denorm_min()}) + { + const model::FixedAngularMomentum extreme(model::FixedAngularMomentum::Parameters{.angular_momentum = dimensions::AngularMomentumValue{0.0}, .axis = {scale, scale, 0.0}}); + CHECK(extreme.axis()[0] == Catch::Approx(std::sqrt(0.5))); + CHECK(extreme.axis()[1] == Catch::Approx(std::sqrt(0.5))); + } +} + +TEST_CASE("Typed EOS references retain owners and exact relation capabilities", "[model][unit][eos]") +{ + auto star = model::make_stellar_model(MoveOnlyPhysicalEOS{std::make_unique(2.0)}, model::EquilibriumEquations{1}); + using Model = decltype(star); + std::shared_ptr owner = std::make_shared(std::move(star)); + std::weak_ptr lifetime = owner; + const auto shared = model::share_equation_of_state(owner); + STATIC_CHECK(serif::eos::models::EOSSupportingPartialDerivative); + CHECK(&shared.get() == &owner->equation_of_state()); + owner.reset(); + CHECK_FALSE(lifetime.expired()); + CHECK(serif::eos::evaluate(shared, dimensions::SpecificEnthalpyValue{3.0}).value() == 6.0); + CHECK(serif::eos::partial_derivative(shared, dimensions::SpecificEnthalpyValue{3.0}).value() == 2.0); + STATIC_CHECK(noexcept(shared.evaluate(MoveOnlyPhysicalEOS::Relation{}, dimensions::SpecificEnthalpyValue{1.0}))); + CHECK_THROWS_AS(model::EosReference(std::shared_ptr{}), std::invalid_argument); + CHECK_THROWS_AS(model::share_equation_of_state(std::shared_ptr{}), std::invalid_argument); +} + +TEST_CASE("Programmer supplied physical equation recipes share the model EOS", "[model][unit][eos]") +{ + const auto star = model::make_stellar_model(MoveOnlyPhysicalEOS{std::make_unique(4.0)}, model::EquilibriumEquations + { + .interior_equations = [](const auto& physics) + { + return serif::equations::EquationRegistry{::serif::tests::equations::BarotropicClosure{model::borrow_eos(physics.equation_of_state()), 1.0}}; + } + }); + const auto equations = model::resolve_equilibrium_equations(star); + using Closure = ::serif::tests::equations::BarotropicClosure>; + serif::equations::EquationPoint point; + point.fields.get() = 11.0; + point.fields.get() = 2.0; + const auto linearization = equations.interior_equations.get().evaluate(point); + CHECK(linearization.residual == 3.0); + CHECK(linearization.field_derivatives.get() == -4.0); +} diff --git a/tests/model/equilibrium_graph.cpp b/tests/model/equilibrium_graph.cpp new file mode 100644 index 00000000..5724a5a8 --- /dev/null +++ b/tests/model/equilibrium_graph.cpp @@ -0,0 +1,463 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "serif/equations/angular_momentum.hpp" +#include "serif/equations/barotropic_density.hpp" +#include "serif/equations/primal_gravity.hpp" +#include "serif/equations/surface_potential.hpp" +#include "serif/equations/mass_normalization.hpp" +#include "serif/backend/mfem/model/equilibrium.hpp" +#include "serif/backend/mfem/model/discretization.hpp" +#include "serif/backend/mfem/model/graph.hpp" +#include "serif/tests/equations/gravity_fixture.hpp" +#include "serif/tests/deformation/mesh.hpp" + +namespace { + namespace model = serif::model; + namespace backend = model::backend::mfem; + namespace execution = serif::operators::backend::mfem; + namespace dependencies = serif::utils::dependencies; + namespace equations = serif::equations; + namespace surface = serif::geometry::surface; + namespace deformation = serif::geometry::deformation; + namespace blocks = serif::discretization::blocks; + namespace test = serif::tests::equations; + using Polytrope = serif::eos::models::Polytrope; + using Density = blocks::density::mass; + using Potential = blocks::gravity::potential; + using Constant = blocks::barotropic_constant::mass_normalization; + using Omega = blocks::rotation::angular_velocity; + using Shape = blocks::surface_deformation::coefficients; + using Displacement = blocks::displacement::vector; + using Kind = blocks::RepresentedValueKind; + using Vector = mfem::Vector; + + /** @brief Keep the physical composition explicit while exercising the public binding factory. */ + template + [[nodiscard]] auto make_physical_model() { + const Polytrope eos{2.0, 0.3}; + const auto rotation = []() { + if constexpr (SolveOmega) { + return std::tuple{equations::AngularMomentumConstraint{0.14}}; + } else { + return std::tuple{}; + } + }(); + const auto interior = std::apply([](auto... equation) { + return equations::EquationRegistry{std::move(equation)...}; + }, std::tuple_cat(std::tuple{equations::BarotropicDensityBalance{eos, serif::dimensions::PressureValue{0.025}}, equations::PrimalPoissonBalance{2.3}, equations::MassNormalization{1.1}}, rotation)); + const auto definition = model::EquilibriumEquations { + .interior_equations = interior, + .shape_force = equations::SurfacePotentialBalance{}, + .surface_condition = surface::Isobaric{serif::dimensions::PressureValue{0.025}} + }; + const auto invariants = [&]() { + if constexpr (SolveOmega) { + return std::tuple{model::FixedAngularMomentum{serif::dimensions::AngularMomentumValue{0.14}}}; + } else { + return std::tuple{}; + } + }(); + return std::apply([&](const auto&... invariant) { + return model::make_stellar_model(eos, definition, model::FixedTotalMass{serif::dimensions::MassValue{1.1}}, invariant...); + }, invariants); + } + + /** @brief Keep the fixed-mass model's surface and exterior resources under one lifetime owner. */ + struct Fixture { + stroid::StroidMesh generated{serif::tests::deformation::make_mesh()}; + test::gravity::GravityFixture fields{mfem::Mesh(*generated.mesh)}; + mfem::FiniteElementSpace reference_displacement{&fields.mesh, &fields.enthalpy_collection, 3, mfem::Ordering::byNODES}; + mfem::FiniteElementSpace compactification_space{&fields.mesh, generated.exterior_coordinate->space->FEColl()}; + mfem::GridFunction compactification{&compactification_space}; + + Fixture() { + REQUIRE(compactification.Size() == generated.exterior_coordinate->values->Size()); + compactification = *generated.exterior_coordinate->values; + } + }; + + /** @brief Borrow bindings and the existing radial mesh extension from their retained owner. */ + [[nodiscard]] auto discretize(std::shared_ptr fixture) { + const auto& fields = fixture->fields; + const auto bindings = dependencies::make_dependency_context( + dependencies::borrow(fields.density_binding), dependencies::borrow(fields.potential_binding), + dependencies::borrow(fields.constant_binding), dependencies::borrow(fields.angular_velocity_binding), + dependencies::borrow(fields.displacement_binding)); + const auto reference = deformation::backend::mfem::deformation_discretization( + fields.enthalpy_space, fixture->reference_displacement, *fixture->generated.reference_mesh); + const deformation::DomainDeformation geometry{ + .surface = {.maximum_degree = 2}, .stellar_interior = {}, .vacuum = {} + }; + auto discretization = backend::with_deformation( + backend::make_stellar_discretization(fixture, bindings, fixture->compactification, fields.mapper), geometry, reference); + return std::make_shared(std::move(discretization)); + } + + /** @brief A persistent coefficient view follows the independent state ordering declared by the plan. */ + template + [[nodiscard]] Vector field(Vector& state, const Layout& layout) { + return Vector(state.GetData() + layout.template offset(), static_cast(layout.template size())); + } + + /** @brief Compare every residual row so scalar constraints cannot disappear behind a volume norm. */ + void check_close(const Vector& actual, const Vector& expected, const double tolerance) { + REQUIRE(actual.Size() == expected.Size()); + for (int index = 0; index < actual.Size(); ++index) { + CHECK(actual[index] == Catch::Approx(expected[index]).margin(tolerance)); + } + } + + /** @brief Copy an immutable evaluated residual into independent storage for finite differences. */ + [[nodiscard]] Vector residual(const backend::PhysicalSystem& system) { + Vector result(static_cast(system.residual().size())); + std::copy(system.residual().begin(), system.residual().end(), result.GetData()); + return result; + } + + /** @brief A rejected candidate must expose neither stale results nor a stale work report. */ + template + void check_unavailable(const Evaluator& evaluator) { + CHECK_FALSE(evaluator.cache().ready()); + CHECK_FALSE(evaluator.cache().template contains()); + CHECK_THROWS_AS(evaluator.cache().template get(), std::logic_error); + CHECK_THROWS_AS(evaluator.cache().template share(), std::logic_error); + CHECK(evaluator.cache().report().computed_count() == 0); + CHECK(evaluator.cache().report().reused_count() == 0); + } + + /** @brief Check physical assembly and candidate publication through the public stellar-model interface. */ + template + void check_equilibrium_graph() { + std::weak_ptr resources; + std::shared_ptr retained; + Vector retained_direction, retained_action; + { + auto fixture = std::make_shared(); + resources = fixture; + const auto discretization = discretize(fixture); + const auto physical = std::make_shared())>(make_physical_model()); + using Description = std::remove_cvref_tequations())>; + using Prescribed = std::conditional_t, dependencies::KeyList>; + using Plan = model::EquilibriumPlan; + const std::array initial_shape{0.004, -0.002, 0.001, 0.003, -0.001, 0.002, -0.003, 0.001, 0.002}; + const auto layout = backend::equilibrium_layout(*discretization, initial_shape); + REQUIRE(layout.template total_size() == layout.template total_size()); + STATIC_CHECK_FALSE(dependencies::ContainsKey<::serif::tests::equations::Enthalpy, typename Plan::Values>); + const int size = static_cast(layout.template total_size()); + Vector state(size); + Vector density = field(state, layout); + Vector potential = field(state, layout); + Vector constant = field(state, layout); + Vector omega = [&]() { + if constexpr (SolveOmega) { + return field(state, layout); + } else { + return test::constant(1, 0.31); + } + }(); + Vector shape = field(state, layout); + REQUIRE(static_cast(shape.Size()) == initial_shape.size()); + std::copy(initial_shape.begin(), initial_shape.end(), shape.GetData()); + const auto geometry = [&]() { return std::span{shape.GetData(), static_cast(shape.Size())}; }; + // Copy from lvalues: moving an MFEM vector would detach these views from the packed state. + const Vector initial_density = fixture->fields.density(); + const Vector initial_potential = fixture->fields.potential(); + density = initial_density; + potential = initial_potential; + constant = 0.72; + omega = 0.31; + const auto make_coefficients = [&](const auto& density_binding, const auto& omega_binding) { + return execution::make_term_coefficients( + execution::bind(density_binding, density), execution::bind(fixture->fields.potential_binding, potential), + execution::bind(fixture->fields.constant_binding, constant), execution::bind(omega_binding, omega)); + }; + const auto coefficients = make_coefficients(fixture->fields.density_binding, fixture->fields.angular_velocity_binding); + const auto bound = backend::bind_model_definition(physical, discretization, coefficients); + REQUIRE(bound->boundary->selected_rows->size() > 0); + REQUIRE(bound->boundary->selected_rows->size() < density.Size()); + auto evaluator = backend::bind_stellar_model(*physical, *discretization, coefficients, geometry()); + check_unavailable(evaluator); + + const double initial_value = density[0]; + density[0] = std::numeric_limits::quiet_NaN(); + REQUIRE_FALSE(evaluator.evaluate(coefficients, geometry())); + check_unavailable(evaluator); + CHECK(evaluator.cache().preparation_count() == 0); + density[0] = initial_value; + + auto evaluation = evaluator.evaluate(coefficients, geometry()); + REQUIRE(evaluation); + const auto initial_system = *evaluation; + const Vector original_state(state); + const Vector original_residual = residual(*initial_system); + evaluation = evaluator.evaluate(coefficients, geometry()); + REQUIRE(evaluation); + CHECK(*evaluation == initial_system); + CHECK(evaluator.cache().report().computed_count() == 0); + + const Vector frozen_direction = test::deterministic(size, 0.8); + Vector frozen_action, frozen_transpose; + initial_system->jacobian().action()->Mult(frozen_direction, frozen_action); + initial_system->jacobian().action()->MultTranspose(frozen_direction, frozen_transpose); + + // Changing input representation must not create a new physical candidate or invalidate reusable work. + const model::StellarState packed_state(layout, std::vector(state.GetData(), state.GetData() + state.Size())); + const auto prescribed = [&]() { + if constexpr (SolveOmega) { + return dependencies::make_dependency_context(); + } else { + return execution::make_term_coefficients(execution::bind(fixture->fields.angular_velocity_binding, omega)); + } + }(); + STATIC_CHECK(dependencies::ContainsKey == SolveOmega); + evaluation = evaluator.evaluate(packed_state, prescribed); + REQUIRE(evaluation); + CHECK(*evaluation == initial_system); + CHECK(evaluator.cache().report().computed_count() == 0); + evaluation = evaluator.evaluate(coefficients, geometry()); + REQUIRE(evaluation); + CHECK(*evaluation == initial_system); + CHECK(evaluator.cache().report().computed_count() == 0); + + // Identical compile-time coordinates can still describe incompatible meshes or equation sizes. + for (const bool wrong_state_size : {true, false}) { + CAPTURE(SolveOmega, wrong_state_size); + auto incompatible_layout = layout; + if (wrong_state_size) { + ++incompatible_layout.state_sizes[0]; + } else { + ++incompatible_layout.residual_sizes[0]; + } + const model::StellarState incompatible_state(incompatible_layout); + const auto preparations = evaluator.cache().preparation_count(); + CHECK_THROWS_WITH(evaluator.evaluate(incompatible_state, prescribed), Catch::Matchers::ContainsSubstring("INVALID_DOF_COUNT")); + check_unavailable(evaluator); + CHECK(evaluator.cache().preparation_count() == preparations); + check_close(residual(*initial_system), original_residual, 0.0); + Vector action, transpose; + initial_system->jacobian().action()->Mult(frozen_direction, action); + initial_system->jacobian().action()->MultTranspose(frozen_direction, transpose); + check_close(action, frozen_action, 0.0); + check_close(transpose, frozen_transpose, 0.0); + evaluation = evaluator.evaluate(packed_state, prescribed); + REQUIRE(evaluation); + CHECK(*evaluation == initial_system); + CHECK(evaluator.cache().report().computed_count() == 0); + } + + const std::array fields{&density, &potential, &constant, &omega}; + for (std::size_t field_index = 0; field_index < fields.size(); ++field_index) { + Vector& values = *fields[field_index]; + const double original_value = values[0]; + for (double invalid : {std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity(), -std::numeric_limits::infinity()}) { + CAPTURE(SolveOmega, field_index, invalid); + const auto preparations = evaluator.cache().preparation_count(); + values[0] = invalid; + const auto rejected = evaluator.evaluate(coefficients, geometry()); + REQUIRE_FALSE(rejected); + REQUIRE(std::holds_alternative(rejected.error().error)); + CHECK(std::get(rejected.error().error).reason == execution::PreparationRejectionReason::NON_FINITE); + check_unavailable(evaluator); + CHECK(evaluator.cache().preparation_count() == preparations); + check_close(residual(*initial_system), original_residual, 0.0); + Vector action, transpose; + initial_system->jacobian().action()->Mult(frozen_direction, action); + initial_system->jacobian().action()->MultTranspose(frozen_direction, transpose); + check_close(action, frozen_action, 0.0); + check_close(transpose, frozen_transpose, 0.0); + + values[0] = original_value; + evaluation = evaluator.evaluate(coefficients, geometry()); + REQUIRE(evaluation); + CHECK(*evaluation == initial_system); + CHECK(evaluator.cache().report().computed_count() == 0); + } + } + + // Geometry failures originate in mapping, after the input context has been built. + for (Vector* values : {&shape, static_cast(&fixture->compactification)}) { + INFO((values == &shape ? "Invalid surface shape" : "Invalid compactification")); + const Vector original_values(*values); + *values = std::numeric_limits::quiet_NaN(); + const auto preparations = evaluator.cache().preparation_count(); + REQUIRE_FALSE(evaluator.evaluate(coefficients, geometry())); + check_unavailable(evaluator); + CHECK(evaluator.cache().preparation_count() == preparations); + check_close(residual(*initial_system), original_residual, 0.0); + *values = original_values; + evaluation = evaluator.evaluate(coefficients, geometry()); + REQUIRE(evaluation); + CHECK(*evaluation == initial_system); + CHECK(evaluator.cache().report().computed_count() == 0); + } + + // Invalid sizes remain caller errors, but they must still clear preparation availability. + Vector wrong_size(density.Size() + 1); + wrong_size = 1.0; + density.Swap(wrong_size); + CHECK_THROWS_AS(evaluator.evaluate(coefficients, geometry()), std::invalid_argument); + check_unavailable(evaluator); + check_close(residual(*initial_system), original_residual, 0.0); + density.Swap(wrong_size); + evaluation = evaluator.evaluate(coefficients, geometry()); + REQUIRE(evaluation); + CHECK(evaluator.cache().report().computed_count() == 0); + + // Identical meshes and coefficient counts do not give an external binding the owner's lifetime. + const serif::discretization::backend::mfem::TermBinding other_density(fixture->fields.density_space); + const serif::discretization::backend::mfem::TermBinding other_omega(fixture->fields.mesh); + const auto foreign_density = make_coefficients(other_density, fixture->fields.angular_velocity_binding); + const auto foreign_omega = make_coefficients(fixture->fields.density_binding, other_omega); + for (const auto& foreign : {foreign_density, foreign_omega}) { + CHECK_THROWS_WITH(backend::bind_stellar_model(*physical, *discretization, foreign, geometry()), Catch::Matchers::ContainsSubstring("CACHE_FE_BINDING_ERROR")); + CHECK_THROWS_WITH(evaluator.evaluate(foreign, geometry()), Catch::Matchers::ContainsSubstring("CACHE_FE_BINDING_ERROR")); + check_unavailable(evaluator); + check_close(residual(*initial_system), original_residual, 0.0); + evaluation = evaluator.evaluate(coefficients, geometry()); + REQUIRE(evaluation); + CHECK(evaluator.cache().report().computed_count() == 0); + } + + const int density_row = static_cast(layout.template offset()); + const double boundary_density = serif::eos::evaluate( + physical->equation_of_state(), serif::dimensions::PressureValue{0.025}).value(); + for (const int row : bound->boundary->selected_rows->reduced_dofs()) { + CHECK(initial_system->residual()[density_row + row] == Catch::Approx(density[row] - boundary_density).margin(2.0e-14)); + } + const auto infinity = serif::discretization::backend::mfem::select_boundary_dofs< + serif::discretization::domain::InfinitySurfaceBoundary>(fixture->fields.potential_binding); + REQUIRE(infinity.size() > 0); + const int potential_row = static_cast(layout.template offset()); + for (const int row : infinity.reduced_dofs()) { + CHECK(initial_system->residual()[potential_row + row] == Catch::Approx(potential[row]).margin(2.0e-14)); + } + + const Vector dual = test::deterministic(size, 1.7); + Vector transpose(size); + initial_system->jacobian().action()->MultTranspose(dual, transpose); + for (int variation = 0; variation < (SolveOmega ? 4 : 3); ++variation) { + CAPTURE(variation); + Vector direction = test::deterministic(size, 0.4); + if (variation > 0) { + direction = 0.0; + std::size_t column = variation == 1 ? layout.template offset() : layout.template offset(); + if constexpr (SolveOmega) { + if (variation == 3) { + column = layout.template offset(); + } + } + direction[static_cast(column)] = 0.13; + } + Vector action(size); + initial_system->jacobian().action()->Mult(direction, action); + CHECK((action * dual) == Catch::Approx(direction * transpose).margin(2.0e-12)); + // Tiny geometry differences lose digits in the exterior residual. Cancel the leading + // central-difference truncation term while keeping the original absolute error bound. + const auto centered_difference = [&](const double step) { + state = original_state; + state.Add(step, direction); + evaluation = evaluator.evaluate(coefficients, geometry()); + REQUIRE(evaluation); + Vector difference = residual(**evaluation); + state = original_state; + state.Add(-step, direction); + evaluation = evaluator.evaluate(coefficients, geometry()); + REQUIRE(evaluation); + difference -= residual(**evaluation); + difference /= 2.0 * step; + return difference; + }; + const double step = 1.0e-3; + const Vector coarse = centered_difference(step); + Vector difference = centered_difference(0.5 * step); + difference *= 4.0; + difference -= coarse; + difference /= 3.0; + check_close(action, difference, 2.0e-9); + state = original_state; + evaluation = evaluator.evaluate(coefficients, geometry()); + REQUIRE(evaluation); + check_close(residual(**evaluation), original_residual, 2.0e-14); + Vector frozen_action(size); + initial_system->jacobian().action()->Mult(direction, frozen_action); + check_close(frozen_action, action, 0.0); + } + + using PoissonResult = execution::volume_keys::Scoped::Linearization; + using MomentumResult = execution::volume_keys::Scoped::Linearization; + const auto poisson = evaluator.cache().template share(); + const auto momentum = evaluator.cache().template share(); + omega[0] += 0.02; + if constexpr (SolveOmega) { + evaluation = evaluator.evaluate(coefficients, geometry()); + } else { + evaluation = evaluator.evaluate(packed_state, prescribed); + } + REQUIRE(evaluation); + CHECK((*evaluation)->jacobian().action()->Width() == size); + if constexpr (!SolveOmega) { + const auto external_evaluation = evaluator.evaluate(coefficients, geometry()); + REQUIRE(external_evaluation); + CHECK(*external_evaluation == *evaluation); + CHECK(evaluator.cache().report().computed_count() == 0); + } + CHECK(evaluator.cache().template share() == poisson); + CHECK(evaluator.cache().template share() != momentum); + CHECK(*evaluation != initial_system); + check_close(residual(*initial_system), original_residual, 0.0); + + const auto before_rejection = *evaluation; + const Vector before_rejection_residual = residual(*before_rejection); + Vector before_rejection_action, before_rejection_transpose; + before_rejection->jacobian().action()->Mult(frozen_direction, before_rejection_action); + before_rejection->jacobian().action()->MultTranspose(frozen_direction, before_rejection_transpose); + density = -1.0; + const auto rejected = evaluator.evaluate(coefficients, geometry()); + REQUIRE_FALSE(rejected); + REQUIRE(std::holds_alternative(rejected.error().error)); + CHECK(std::get(rejected.error().error).reason == execution::PreparationRejectionReason::EQUATION); + check_unavailable(evaluator); + check_close(residual(*before_rejection), before_rejection_residual, 0.0); + Vector rejected_action, rejected_transpose; + before_rejection->jacobian().action()->Mult(frozen_direction, rejected_action); + before_rejection->jacobian().action()->MultTranspose(frozen_direction, rejected_transpose); + check_close(rejected_action, before_rejection_action, 0.0); + check_close(rejected_transpose, before_rejection_transpose, 0.0); + check_close(residual(*initial_system), original_residual, 0.0); + state = original_state; + omega = 0.31; + evaluation = evaluator.evaluate(coefficients, geometry()); + REQUIRE(evaluation); + check_close(residual(**evaluation), original_residual, 2.0e-14); + retained = *evaluation; + retained_direction = test::deterministic(size, 0.8); + retained->jacobian().action()->Mult(retained_direction, retained_action); + } + CHECK_FALSE(resources.expired()); + Vector after_owner_release; + retained->jacobian().action()->Mult(retained_direction, after_owner_release); + check_close(after_owner_release, retained_action, 0.0); + retained.reset(); + CHECK(resources.expired()); + } +} // namespace + +TEST_CASE("Equilibrium graph combines the density balance, primal gravity and moving isobar rows", "[unit][model][equilibrium_graph]") { + check_equilibrium_graph(); +} + +TEST_CASE("Prescribed rotation remains a physical input without a Newton column or angular momentum equation", "[unit][model][equilibrium_graph]") { + check_equilibrium_graph(); +} diff --git a/tests/model/finite_elements.cpp b/tests/model/finite_elements.cpp new file mode 100644 index 00000000..072ab5cc --- /dev/null +++ b/tests/model/finite_elements.cpp @@ -0,0 +1,148 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include "serif/model/finite_elements.hpp" +#include "serif/equations/primal_gravity.hpp" +#include "serif/runtime.hpp" +#include "serif/solver/seed/state.hpp" +#include "serif/tests/solver/stellar_fixture.hpp" +#include "serif/model/execution_validation.hpp" +#include "../sandbox/polytropic_model.hpp" + +TEST_CASE("Production resources reproduce the established stellar coefficient representation", "[model][resources]") { + namespace fixture = serif::tests::solver; + namespace model = serif::model; + namespace deformation = serif::geometry::deformation; + + serif::initialize(); + serif::initialize(); // Reusing the application runtime must be harmless. + const auto star = fixture::make_model(); + const auto radial = serif::solver::seed::make_polytropic_seed(star); + REQUIRE(radial); + const auto old_resources = fixture::make_resources(radial->scales.radius.value()); + CHECK(old_resources->geometry_space.FEColl() == old_resources->displacement_space.FEColl()); + CHECK(old_resources->geometry_space.FEColl() == old_resources->reference_displacement.FEColl()); + const auto previous = fixture::make_discretization(old_resources, 2); + const auto current = model::make_stellar_discretization(star, {.radius = radial->scales.radius}, + deformation::DomainDeformation{ + .surface = {.maximum_degree = 2}, .stellar_interior = {}, .vacuum = {}}); + const auto old_state = serif::solver::seed::make_polytropic_state(star, previous, *radial); + const auto new_state = serif::solver::seed::make_polytropic_state(star, current, *radial); + REQUIRE(old_state); + REQUIRE(new_state); + CHECK(std::ranges::equal(old_state->values(), new_state->values())); + const auto copied = current; + CHECK(copied.owner == current.owner); + CHECK(&copied.template binding().space() == ¤t.template binding().space()); +} + +TEST_CASE("Geometry construction needs only the fields of the selected equations", "[model][resources]") { + namespace model = serif::model; + namespace equations = serif::equations; + namespace deformation = serif::geometry::deformation; + + const auto star = model::make_stellar_model(serif::eos::models::Polytrope{1.0, 1.0}, model::EquilibriumEquations{ + .interior_equations = equations::EquationRegistry{equations::PrimalPoissonBalance{1.0}}}); + const auto finite_elements = model::make_stellar_discretization(star, {}, + deformation::DomainDeformation{}); + const auto& reference = finite_elements.deformation.reference; + CHECK(reference.scalar_space.FEColl() == reference.displacement_space.FEColl()); + CHECK(reference.scalar_space.GetVDim() == 1); + CHECK(reference.displacement_space.GetVDim() == 3); + CHECK_THROWS(model::make_stellar_discretization(star, {.radius = serif::dimensions::LengthValue{-1.0}})); + CHECK_THROWS(model::make_stellar_discretization(star, {.infinity_radius_fraction = 1.0})); +} + +TEST_CASE("The polytropic factory retains fixed physical targets in the scalar formulation", "[model][unit][primal]") { + namespace model = serif::model; + namespace equations = serif::equations; + namespace blocks = serif::discretization::blocks; + namespace dependencies = serif::utils::dependencies; + + const auto star = serif::examples::make_polytropic_model(2.0, 0.4, 1.7, 0.03, 0.8, 0.02); + const auto definition = model::resolve_equilibrium_equations(star); + using Description = std::remove_cvref_t; + using Model = std::remove_cvref_t; + using Plan = model::EquilibriumPlan; + static_assert(model::executable_model_topology()); + static_assert(dependencies::key_count_v == 5); + static_assert(dependencies::key_count_v == 5); + static_assert(std::same_as); + CHECK(star.equation_of_state().polytropic_index() == 2.0); + CHECK(star.equation_of_state().polytropic_constant() == 0.4); + CHECK(star.specification().target().value() == 1.7); + CHECK(star.specification().target().value() == 0.03); + CHECK(definition.interior_equations.get().residual_offset() == -1.7); + CHECK(definition.interior_equations.get().residual_offset() == -0.03); + CHECK(definition.interior_equations.get().four_pi_G() == Catch::Approx(4.0 * std::numbers::pi * 0.8)); + CHECK(definition.surface_condition.target_pressure().value() == 0.02); +} + +TEST_CASE("The factory rejects only the unresolved nonrotating n3 vacuum scale at equation resolution", "[model][unit][primal]") { + namespace model = serif::model; + CHECK_NOTHROW(serif::eos::models::Polytrope{3.0, 0.4}); + const auto unresolved = serif::examples::make_polytropic_model(3.0, 0.4, 1.0, 0.0, 1.0, 0.0); + CHECK(unresolved.equation_of_state().polytropic_index() == 3.0); + CHECK_THROWS_WITH(model::resolve_equilibrium_equations(unresolved), + Catch::Matchers::ContainsSubstring("compatible mass leaves radius free")); + const auto rotating = serif::examples::make_polytropic_model(3.0, 0.4, 1.0, 0.01, 1.0, 0.0); + const auto confined = serif::examples::make_polytropic_model(3.0, 0.4, 1.0, 0.0, 1.0, 0.01); + const auto below = serif::examples::make_polytropic_model(2.999, 0.4, 1.0, 0.0, 1.0, 0.0); + CHECK_NOTHROW(model::resolve_equilibrium_equations(rotating)); + CHECK_NOTHROW(model::resolve_equilibrium_equations(confined)); + CHECK_NOTHROW(model::resolve_equilibrium_equations(below)); + // Admission of these declarations does not certify existence or uniqueness for their chosen targets. +} + +TEST_CASE("The scalar factory seed contains only primal fields and fixes its density trace", "[model][resources][primal]") { + namespace model = serif::model; + namespace blocks = serif::discretization::blocks; + namespace dependencies = serif::utils::dependencies; + namespace seed = serif::solver::seed; + namespace deformation = serif::geometry::deformation; + using Density = blocks::density::mass; + using Potential = blocks::gravity::potential; + using SurfaceLevel = blocks::barotropic_constant::mass_normalization; + + serif::initialize(); + const auto star = serif::examples::make_polytropic_model(); + const auto radial = seed::make_polytropic_seed(star); + REQUIRE(radial); + const auto finite_elements = model::make_stellar_discretization(star, {.radius = radial->scales.radius}, + deformation::DomainDeformation{ + .surface = {.maximum_degree = 2}, .stellar_interior = {}, .vacuum = {}}); + const auto state = seed::make_polytropic_state(star, finite_elements, *radial); + if (!state) { + INFO(state.error()); + } + REQUIRE(state); + using State = std::remove_cvref_t; + static_assert(dependencies::key_count_v == 5); + CHECK(std::ranges::all_of(state->values(), [](double value) { return std::isfinite(value); })); + CHECK(state->template get().size() == static_cast(finite_elements.template binding().size())); + CHECK(state->template get().size() == static_cast(finite_elements.template binding().size())); + CHECK(state->template get().size() == 9); + CHECK(state->template get()[0] == Catch::Approx(-1.0).epsilon(3.0e-7)); + CHECK(state->template get()[0] == 0.0); + CHECK(std::ranges::max(state->template get()) > 0.7); + const auto surface_rows = serif::discretization::backend::mfem::select_boundary_dofs< + serif::discretization::domain::StellarSurfaceBoundary>(finite_elements.template binding()); + REQUIRE(surface_rows.size() > 0); + for (const int dof : surface_rows.reduced_dofs()) { + CHECK(state->template get()[dof] == 0.0); + } + const auto infinity_rows = serif::discretization::backend::mfem::select_boundary_dofs< + serif::discretization::domain::InfinitySurfaceBoundary>(finite_elements.template binding()); + REQUIRE(infinity_rows.size() > 0); + for (const int dof : infinity_rows.reduced_dofs()) { + CHECK(state->template get()[dof] == 0.0); + } + CHECK(star.specification().target().value() == 1.0); + CHECK(star.equation_of_state().polytropic_constant() == 2.0 / std::numbers::pi); +} diff --git a/tests/model/meson.build b/tests/model/meson.build new file mode 100644 index 00000000..29c35e5b --- /dev/null +++ b/tests/model/meson.build @@ -0,0 +1,30 @@ +model_test_sources = files( + 'finite_elements.cpp', + 'reconstruction.cpp', + 'reconstruction_system.cpp', + 'surface_refinement.cpp', + 'automatic_surface.cpp', + 'description.cpp', + 'state.cpp', + 'scalar_row.cpp', + 'pressure_graph.cpp', + 'runtime.cpp', + 'runtime_deformation.cpp', + 'runtime_volume.cpp', + 'solver_services.cpp', + 'equilibrium_graph.cpp', + 'moving_equilibrium.cpp', + 'normalized_system.cpp', + 'move_only.cpp', + '../equations/fixture.cpp', +) + +serif_model_tests = executable( + 'serif_model_tests', + model_test_sources, + dependencies: [serif_dep, catch2_dep, stroid_dep], + include_directories: include_directories('../include'), + build_rpath: mfem_runtime_prefix == '' ? '' : mfem_runtime_prefix / 'lib', +) +test('serif_model_tests', serif_model_tests, args: ['~[moving_preconditioner]'], suite: 'model', timeout: 600, is_parallel: false) +test('serif_moving_preconditioner_tests', serif_model_tests, args: ['[moving_preconditioner]'], suite: 'model', timeout: 1200, is_parallel: false) diff --git a/tests/model/move_only.cpp b/tests/model/move_only.cpp new file mode 100644 index 00000000..6c07fefd --- /dev/null +++ b/tests/model/move_only.cpp @@ -0,0 +1,175 @@ +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "serif/backend/mfem/model/equilibrium.hpp" +#include "serif/tests/equations/fixture.hpp" + +namespace { + namespace model = serif::model; + namespace backend = model::backend::mfem; + namespace equations = serif::equations; + namespace execution = serif::operators::backend::mfem; + namespace dependencies = serif::utils::dependencies; + namespace blocks = serif::discretization::blocks; + namespace tests = serif::tests::equations; + using EOS = serif::eos::models::Polytrope; + using Density = blocks::density::mass; + using Enthalpy = ::serif::tests::equations::Enthalpy; + using Displacement = blocks::displacement::vector; + using Kind = blocks::RepresentedValueKind; + using Vector = mfem::Vector; + + /** @brief Require genuine ownership transfer while preserving the wrapped equation's physics and weak test. */ + template + class MoveOnlyEquation final : public equations::Contribution, equations::SupportOf> { + public: + using Operands = typename Equation::Operands; + static constexpr std::string_view name = "move-only equation"; + + /** @brief Retain an evaluation counter so tests can observe lifetime without owning the equation. */ + MoveOnlyEquation(Equation equation, std::shared_ptr evaluations) : + m_equation(std::make_unique(std::move(equation))), m_evaluations(std::move(evaluations)) { + } + + /** @brief Forward the exact physical residual and derivatives without supplying a clone operation. */ + [[nodiscard]] auto evaluate(const equations::EquationPoint& point) const { + ++*m_evaluations; + return m_equation->evaluate(point); + } + + /** @brief Use the same integration order as the underlying physical equation. */ + template + [[nodiscard]] int additional_order(Operand operand, const int position_order) const { + return m_equation->additional_order(operand, position_order); + } + + private: + std::unique_ptr m_equation; + std::shared_ptr m_evaluations; + }; + + static_assert(!std::copy_constructible>); + static_assert(std::move_constructible>); + static_assert(serif::operators::VolumeEquation>); + + /** @brief All borrowed finite-element bindings belong to the retained fixture. */ + [[nodiscard]] auto bindings(const tests::Fixture& fields) { + return dependencies::make_dependency_context(dependencies::borrow(fields.density_binding), + dependencies::borrow(fields.enthalpy_binding), dependencies::borrow(fields.displacement_binding)); + } + + /** @brief Copy evaluated residuals before perturbing the candidate state. */ + [[nodiscard]] Vector residual(const backend::PhysicalSystem& system) { + Vector result(static_cast(system.residual().size())); + std::copy(system.residual().begin(), system.residual().end(), result.GetData()); + return result; + } + + /** @brief Check complete actions and exact transposes after their model and cache have gone away. */ + void check_retained(const backend::PhysicalSystem& system, const Vector& direction, const Vector& dual, + const Vector& expected_action, const Vector& expected_transpose) { + Vector action, transpose; + system.jacobian().action()->Mult(direction, action); + system.jacobian().action()->MultTranspose(dual, transpose); + CHECK(tests::relative_error(action, expected_action) == 0.0); + CHECK(tests::relative_error(transpose, expected_transpose) == 0.0); + } +} + +TEST_CASE("Move-only interior equations preserve evaluation derivatives and retained ownership", "[model][move_only][integration]") { + std::weak_ptr resources; + std::weak_ptr equation_lifetime; + std::shared_ptr retained; + Vector direction, dual, expected_action, expected_transpose, expected_residual; + { + auto fields = std::make_shared(); + resources = fields; + auto evaluations = std::make_shared(0); + equation_lifetime = evaluations; + const EOS eos{2.0, 0.3}; + auto physical = model::make_stellar_model(eos, model::EquilibriumEquations { + .interior_equations = equations::EquationRegistry{MoveOnlyEquation{tests::Closure{eos}, std::move(evaluations)}} + }); + STATIC_CHECK_FALSE(std::copy_constructible); + auto discretization = backend::make_stellar_discretization(fields, bindings(*fields), fields->compactification, fields->mapper); + Vector density = fields->density(), enthalpy = fields->enthalpy(); + const Vector displacement = fields->displacement(0.03); + const auto coefficients = fields->state(density, enthalpy); + auto evaluator = backend::bind_stellar_model(std::move(physical), std::move(discretization), coefficients, displacement); + CHECK(*equation_lifetime.lock() == 0); + auto evaluation = evaluator.evaluate(coefficients, displacement); + REQUIRE(evaluation); + CHECK(*equation_lifetime.lock() > 0); + retained = *evaluation; + expected_residual = residual(*retained); + + tests::PreparedClosure reference(tests::Closure{eos}, fields->density_binding, fields->displacement_binding, fields->compactification, fields->mapper); + reference.prepare(coefficients, displacement, fields->workspace); + CHECK(tests::relative_error(expected_residual, reference.data().residual) < 2.0e-13); + const int initial_evaluations = *equation_lifetime.lock(); + evaluation = evaluator.evaluate(coefficients, displacement); + REQUIRE(evaluation); + CHECK(*evaluation == retained); + CHECK(*equation_lifetime.lock() == initial_evaluations); + + direction = tests::deterministic(retained->jacobian().action()->Width(), 0.41); + dual = tests::deterministic(retained->jacobian().action()->Height(), 0.83); + retained->jacobian().action()->Mult(direction, expected_action); + retained->jacobian().action()->MultTranspose(dual, expected_transpose); + REQUIRE(expected_action.Norml2() > 0.0); + CHECK((expected_action * dual) == Catch::Approx(direction * expected_transpose).margin(2.0e-12)); + const auto& layout = evaluator.layout(); + const int density_offset = static_cast(layout.template offset()); + const int enthalpy_offset = static_cast(layout.template offset()); + const Vector initial_density(density), initial_enthalpy(enthalpy); + const auto perturb = [&](const double step) { + for (int index = 0; index < density.Size(); ++index) { + density[index] = initial_density[index] + step * direction[density_offset + index]; + } + for (int index = 0; index < enthalpy.Size(); ++index) { + enthalpy[index] = initial_enthalpy[index] + step * direction[enthalpy_offset + index]; + } + }; + constexpr double step = 1.0e-6; + perturb(step); + evaluation = evaluator.evaluate(coefficients, displacement); + REQUIRE(evaluation); + CHECK(*evaluation != retained); + CHECK(*equation_lifetime.lock() > initial_evaluations); + Vector difference = residual(**evaluation); + perturb(-step); + evaluation = evaluator.evaluate(coefficients, displacement); + REQUIRE(evaluation); + difference -= residual(**evaluation); + difference /= 2.0 * step; + CHECK(tests::relative_error(expected_action, difference) < 2.0e-8); + + const auto before_rejection = *evaluation; + const Vector before_rejection_residual = residual(*before_rejection); + Vector before_rejection_action, before_rejection_transpose; + before_rejection->jacobian().action()->Mult(direction, before_rejection_action); + before_rejection->jacobian().action()->MultTranspose(dual, before_rejection_transpose); + enthalpy = -1.0; + evaluation = evaluator.evaluate(coefficients, displacement); + REQUIRE_FALSE(evaluation); + CHECK_FALSE(evaluator.cache().ready()); + CHECK(tests::relative_error(residual(*before_rejection), before_rejection_residual) == 0.0); + check_retained(*before_rejection, direction, dual, before_rejection_action, before_rejection_transpose); + CHECK(tests::relative_error(residual(*retained), expected_residual) == 0.0); + check_retained(*retained, direction, dual, expected_action, expected_transpose); + } + CHECK_FALSE(resources.expired()); + CHECK_FALSE(equation_lifetime.expired()); + CHECK(tests::relative_error(residual(*retained), expected_residual) == 0.0); + check_retained(*retained, direction, dual, expected_action, expected_transpose); + retained.reset(); + CHECK(resources.expired()); + CHECK(equation_lifetime.expired()); +} diff --git a/tests/model/moving_equilibrium.cpp b/tests/model/moving_equilibrium.cpp new file mode 100644 index 00000000..95a76087 --- /dev/null +++ b/tests/model/moving_equilibrium.cpp @@ -0,0 +1,809 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "mfem/linalg/solvers.hpp" +#include "mfem/general/communication.hpp" + +#include "serif/equations/angular_momentum.hpp" +#include "serif/equations/barotropic_density.hpp" +#include "serif/equations/primal_gravity.hpp" +#include "serif/equations/surface_potential.hpp" +#include "serif/equations/mass_normalization.hpp" +#include "serif/backend/mfem/model/equilibrium.hpp" +#include "serif/model/eos_reference.hpp" +#include "serif/solver/preconditioner/default_preconditioners.hpp" +#include "serif/tests/deformation/mesh.hpp" +#include "serif/tests/equations/gravity_fixture.hpp" + +// MeanField's prepared_stellar_equilibrium.cpp checks the complete moving root +// against its children and centered differences. Keep both checks: child parity +// verifies composition, while finite differences verify the assembled derivative. +namespace { + namespace model = serif::model; + namespace backend = model::backend::mfem; + namespace execution = serif::operators::backend::mfem; + namespace dependencies = serif::utils::dependencies; + namespace equations = serif::equations; + namespace deformation = serif::geometry::deformation; + namespace surface = serif::geometry::surface; + namespace blocks = serif::discretization::blocks; + namespace tests = serif::tests::equations; + using Polytrope = serif::eos::models::Polytrope; + using Density = blocks::density::mass; + using Potential = blocks::gravity::potential; + using Constant = blocks::barotropic_constant::mass_normalization; + using Omega = blocks::rotation::angular_velocity; + using Shape = blocks::surface_deformation::coefficients; + using ShapeRow = blocks::surface_deformation::shape_equilibrium; + using Displacement = blocks::displacement::vector; + using Kind = blocks::RepresentedValueKind; + using Vector = mfem::Vector; + using InteriorRows = dependencies::KeyList; + + /** @brief Own paired stellar/vacuum meshes and all resources borrowed by the public model binding. */ + struct Fixture { + stroid::StroidMesh generated{serif::tests::deformation::make_mesh(1)}; + tests::gravity::GravityFixture fields; + mfem::FiniteElementSpace reference_displacement{&fields.mesh, &fields.enthalpy_collection, 3, mfem::Ordering::byNODES}; + mfem::FiniteElementSpace compactification_space{&fields.mesh, generated.exterior_coordinate->space->FEColl()}; + mfem::GridFunction compactification{&compactification_space}; + + /** @brief Preserve the generator's exterior coordinate on the identically numbered mesh copy. */ + explicit Fixture(const mfem::Ordering::Type ordering) : fields(mfem::Mesh(*generated.mesh), ordering) { + REQUIRE(compactification.Size() == generated.exterior_coordinate->values->Size()); + compactification = *generated.exterior_coordinate->values; + } + }; + + /** @brief Exercise all stellar equations at a valid, deliberately unconverged rotating state. */ + [[nodiscard]] auto physical_model(const double index = 1.5) { + const auto definition = [](const auto& physics) { + const auto eos = model::borrow_eos(physics.equation_of_state()); + const auto& rotation = physics.template specification(); + const auto& axis = rotation.axis(); + const auto& center = rotation.center(); + return model::EquilibriumEquations{ + .interior_equations = equations::EquationRegistry{ + equations::BarotropicDensityBalance{eos, serif::dimensions::PressureValue{0.025}, axis, center}, equations::PrimalPoissonBalance{2.3}, + equations::MassNormalization{physics.template specification().target().value()}, + equations::AngularMomentumConstraint{rotation.target().value(), axis, center} + }, + .shape_force = equations::SurfacePotentialBalance{axis, center}, + .surface_condition = surface::Isobaric{serif::dimensions::PressureValue{0.025}} + }; + }; + return model::make_stellar_model(Polytrope{index, 0.25}, definition, + model::FixedTotalMass{serif::dimensions::MassValue{1.1}}, + model::FixedAngularMomentum{{.angular_momentum = serif::dimensions::AngularMomentumValue{0.14}, + .axis = {0.2, -0.3, 1.0}, .center = {0.03, -0.02, 0.01}}}); + } + + /** @brief Use the production radial lift and vacuum extension with nine independent surface harmonics. */ + [[nodiscard]] auto discretize(const std::shared_ptr& fixture) { + const auto& fields = fixture->fields; + const auto bindings = dependencies::make_dependency_context( + dependencies::borrow(fields.density_binding), dependencies::borrow(fields.potential_binding), + dependencies::borrow(fields.constant_binding), dependencies::borrow(fields.angular_velocity_binding), + dependencies::borrow(fields.displacement_binding)); + const auto reference = deformation::backend::mfem::deformation_discretization( + fields.enthalpy_space, fixture->reference_displacement, *fixture->generated.reference_mesh); + const deformation::DomainDeformation geometry{ + .surface = {.maximum_degree = 2}, .stellar_interior = {}, .vacuum = {} + }; + return backend::with_deformation(backend::make_stellar_discretization(fixture, bindings, fixture->compactification, fields.mapper), geometry, reference); + } + + /** @brief Populate all physical fields, scalar constraints and surface modes in the moving-star fixture. */ + template + [[nodiscard]] model::StellarState initial_state(const Layout& layout, tests::gravity::GravityFixture& fields, const ShapeCoefficients& shape) { + model::StellarState state(layout); + const auto initialize = [&](const Vector& values) { + const auto destination = state.template get(); + REQUIRE(destination.size() == static_cast(values.Size())); + std::copy_n(values.GetData(), values.Size(), destination.begin()); + }; + initialize.template operator()(fields.density()); + initialize.template operator()(fields.potential()); + state.template get()[0] = 0.72; + state.template get()[0] = 0.31; + std::ranges::copy(shape, state.template get().begin()); + return state; + } + + /** @brief Make an independent physical candidate without sharing any mutable field storage. */ + template + [[nodiscard]] model::StellarState perturbed_state(const model::StellarState& original, const double step, const Vector& direction) { + REQUIRE(original.values().size() == static_cast(direction.Size())); + model::StellarState result(original); + for (int index = 0; index < direction.Size(); ++index) { + result.values()[static_cast(index)] += step * direction[index]; + } + return result; + } + + /** @brief Copy a frozen residual before evaluating another candidate. */ + template + [[nodiscard]] Vector residual(const System& system) { + Vector result(static_cast(system.residual().size())); + std::copy(system.residual().begin(), system.residual().end(), result.GetData()); + return result; + } + + /** @brief Check each equation separately so large gravity rows cannot hide missing scalar or shape rows. */ + template + void check_rows(const Vector& actual, const Vector& expected, const Layout& layout, double relative_tolerance, double absolute_tolerance) { + REQUIRE(actual.Size() == expected.Size()); + int offset = 0; + for (std::size_t row = 0; row < layout.residual_sizes.size(); ++row) { + CAPTURE(row); + double error_squared = 0.0, reference_squared = 0.0; + for (std::size_t entry = 0; entry < layout.residual_sizes[row]; ++entry) { + const double difference = actual[offset] - expected[offset]; + error_squared += difference * difference; + reference_squared += expected[offset] * expected[offset]; + ++offset; + } + CHECK(std::sqrt(error_squared) <= absolute_tolerance + relative_tolerance * std::sqrt(reference_squared)); + } + } + + /** @brief Compare cached scalar functionals with the independently retained moving volume actions. */ + template + void check_scalar_row(const Evaluator& evaluator, const backend::PhysicalSystem& physical) { + const auto& layout = evaluator.layout(); + const auto& cache = evaluator.cache(); + const auto volume = cache.template share::Linearization>(); + const auto& shape = cache.template get(); + const auto reference = backend::volume_row_action(volume, layout, backend::ColumnContribution{ + static_cast(layout.template offset()), shape.tangent}); + const auto& cached = cache.template get>(); + const Vector direction = tests::deterministic(reference.action()->Width(), 0.47); + const Vector dual = tests::constant(1, 0.37); + Vector expected(1), actual(1), expected_transpose, actual_transpose(direction.Size()); + reference.action()->Mult(direction, expected); + cached.action()->Mult(direction, actual); + CHECK(actual[0] == Catch::Approx(expected[0]).margin(2.0e-12)); + reference.action()->MultTranspose(dual, expected_transpose); + cached.action()->MultTranspose(dual, actual_transpose); + CHECK(tests::relative_error(actual_transpose, expected_transpose) < 2.0e-12); + const auto check_column = [&]() { + const int offset = static_cast(layout.template offset()); + const int size = static_cast(layout.template size()); + const auto selected = physical.block(static_cast(layout.template offset()), 1, offset, size); + Vector input(size), selected_transpose; + std::copy_n(direction.GetData() + offset, size, input.GetData()); + Vector complete(direction.Size()); + complete = 0.0; + std::copy_n(input.GetData(), size, complete.GetData() + offset); + cached.action()->Mult(complete, expected); + selected.action()->Mult(input, actual); + CHECK(actual[0] == Catch::Approx(expected[0]).margin(2.0e-12)); + selected.action()->MultTranspose(dual, selected_transpose); + Vector expected_column(expected_transpose.GetData() + offset, size); + CHECK(tests::relative_error(selected_transpose, expected_column) < 2.0e-12); + }; + check_column.template operator()(); + check_column.template operator()(); + check_column.template operator()(); + } + + /** @brief Place the volume and surface children, then independently enforce both physical traces. */ + template + void check_composition(const Evaluator& evaluator, const model::StellarState& state, + const backend::PhysicalSystem& physical, const serif::discretization::backend::mfem::DofSelection& boundary) { + const auto& layout = evaluator.layout(); + const auto& cache = evaluator.cache(); + Vector expected(static_cast(layout.template total_size())); + expected = 0.0; + [&](dependencies::KeyList) { + const auto place = [&]() { + const auto& child = cache.template get::Linearization>(); + REQUIRE(static_cast(child.residual.Size()) == layout.template size()); + std::copy_n(child.residual.GetData(), child.residual.Size(), expected.GetData() + layout.template offset()); + }; + (place.template operator()(), ...); + }(InteriorRows{}); + const auto& surface_child = cache.template get(); + REQUIRE(surface_child.residual.size() == 1); + for (const auto& contribution : surface_child.residual) { + std::copy(contribution.values.begin(), contribution.values.end(), expected.GetData() + contribution.row_offset); + } + const int shape_row = static_cast(layout.template offset()); + const int shape_size = static_cast(layout.template size()); + Vector shape_residual(expected.GetData() + shape_row, shape_size); + REQUIRE(shape_residual.Norml2() > 1.0e-8); + + // Compare each retained surface child coupling with the corresponding assembled block. + for (const auto& contribution : surface_child.jacobian) { + const auto selected = physical.block(contribution.row_offset, contribution.block.action()->Height(), + contribution.column_offset, contribution.block.action()->Width()); + const Vector direction = tests::deterministic(contribution.block.action()->Width(), 0.47); + const Vector dual = tests::deterministic(contribution.block.action()->Height(), 0.71); + Vector actual(contribution.block.action()->Height()), reference(contribution.block.action()->Height()); + Vector actual_transpose(contribution.block.action()->Width()), reference_transpose(contribution.block.action()->Width()); + selected.action()->Mult(direction, actual); + contribution.block.action()->Mult(direction, reference); + CHECK(tests::relative_error(actual, reference) < 2.0e-13); + selected.action()->MultTranspose(dual, actual_transpose); + contribution.block.action()->MultTranspose(dual, reference_transpose); + CHECK(tests::relative_error(actual_transpose, reference_transpose) < 2.0e-13); + } + + const auto density = state.template get(); + REQUIRE(boundary.size() > 0); + REQUIRE(boundary.size() < static_cast(density.size())); + const double surface_density = serif::eos::evaluate( + evaluator.model().equation_of_state(), serif::dimensions::PressureValue{0.025}).value(); + const int density_row = static_cast(layout.template offset()); + for (const int row : boundary.reduced_dofs()) { + expected[density_row + row] = density[row] - surface_density; + } + const auto infinity = serif::discretization::backend::mfem::select_boundary_dofs< + serif::discretization::domain::InfinitySurfaceBoundary>( + evaluator.binding().discretization->template binding()); + REQUIRE(infinity.size() > 0); + const int potential_row = static_cast(layout.template offset()); + for (const int row : infinity.reduced_dofs()) { + expected[potential_row + row] = state.template get()[row]; + } + check_rows(residual(physical), expected, layout, 2.0e-13, 2.0e-13); + + // Each strong trace must reach its own scalar field alone, including in the transpose. + const auto check_trace = [&](const auto& selected) { + Vector dual(expected.Size()), transpose; + dual = 0.0; + Vector expected_transpose(physical.jacobian().action()->Width()); + expected_transpose = 0.0; + const int row_offset = static_cast(layout.template offset()); + const int column_offset = static_cast(layout.template offset()); + for (const int row : selected.reduced_dofs()) { + dual[row_offset + row] = 0.17 + 0.01 * (row % 7); + expected_transpose[column_offset + row] = dual[row_offset + row]; + } + physical.jacobian().action()->MultTranspose(dual, transpose); + CHECK(tests::relative_error(transpose, expected_transpose) < 2.0e-13); + }; + check_trace.template operator()(boundary); + check_trace.template operator()(infinity); + } + + /** @brief Compare every physical state column and a mixed perturbation through the public evaluation path. */ + template + void check_derivatives(Evaluator& evaluator, const model::StellarState& state) { + const auto& layout = evaluator.layout(); + const auto evaluation = evaluator.evaluate(state); + REQUIRE(evaluation); + const auto frozen = *evaluation; + const Vector direction_seed = tests::deterministic(static_cast(state.values().size()), 0.43); + const Vector dual = tests::deterministic(frozen->jacobian().action()->Height(), 1.17); + Vector transpose, block_transpose; + frozen->jacobian().action()->MultTranspose(dual, transpose); + const auto blocks = frozen->block(0, dual.Size(), 0, static_cast(state.values().size())); + blocks.action()->MultTranspose(dual, block_transpose); + CHECK(tests::relative_error(transpose, block_transpose) < 2.0e-12); + + // The final iteration moves all physical fields and the surface simultaneously. + int offset = 0; + for (std::size_t column = 0; column <= layout.state_sizes.size(); ++column) { + CAPTURE(column); + Vector direction(direction_seed); + if (column < layout.state_sizes.size()) { + direction = 0.0; + std::copy_n(direction_seed.GetData() + offset, layout.state_sizes[column], direction.GetData() + offset); + offset += static_cast(layout.state_sizes[column]); + } + Vector action, block_action; + frozen->jacobian().action()->Mult(direction, action); + blocks.action()->Mult(direction, block_action); + REQUIRE(action.Norml2() > 1.0e-10); + check_rows(action, block_action, layout, 2.0e-12, 2.0e-12); + CHECK((action * dual) == Catch::Approx(direction * transpose).epsilon(2.0e-12).margin(2.0e-12)); + + constexpr double step = 1.0e-5; + const auto plus = evaluator.evaluate(perturbed_state(state, step, direction)); + REQUIRE(plus); + Vector difference = residual(**plus); + const auto minus = evaluator.evaluate(perturbed_state(state, -step, direction)); + REQUIRE(minus); + difference -= residual(**minus); + difference /= 2.0 * step; + check_rows(action, difference, layout, 2.0e-6, 2.0e-9); + + if (column == Layout::template index()) { + // Shape must affect each interior equation and the surface potential, not only its own row. + int row_offset = 0; + for (std::size_t row = 0; row < layout.residual_sizes.size(); ++row) { + Vector row_action(action.GetData() + row_offset, static_cast(layout.residual_sizes[row])); + CHECK(row_action.Norml2() > 1.0e-10); + row_offset += row_action.Size(); + } + } + } + const auto restored = evaluator.evaluate(state); + REQUIRE(restored); + check_rows(residual(**restored), residual(*frozen), layout, 0.0, 0.0); + } + + /** @brief Check the assembled solver coordinates, including strong rows, scalar constraints and native surface modes. */ + template + void check_normalized_system(Evaluator& evaluator, const model::StellarState& state, + const serif::discretization::backend::mfem::DofSelection& boundary) { + namespace normalization = serif::solver::normalization; + namespace dimensions = serif::dimensions; + const auto evaluation = evaluator.evaluate(state); + REQUIRE(evaluation); + const auto frozen = *evaluation; + REQUIRE(frozen->physical() == evaluator.cache().template get()); + const auto& layout = evaluator.layout(); + const auto& coordinates = frozen->coordinates(); + const auto& scales = frozen->reference_scales(); + CHECK(scales.mass.value() == Catch::Approx(1.1)); + CHECK(scales.radius.value() > 0.0); + CHECK(scales.radius.value() != 1.0); + REQUIRE(coordinates.state_size() == state.values().size()); + REQUIRE(coordinates.residual_size() == frozen->residual().size()); + CHECK(coordinates.state_factors().front() != 1.0); + Vector expected_residual(static_cast(frozen->residual().size())); + coordinates.normalize_residual(frozen->physical()->residual(), {expected_residual.GetData(), frozen->residual().size()}); + check_rows(residual(*frozen), expected_residual, layout, 0.0, 0.0); + + const double radius = scales.radius.value(); + const std::size_t density_row = layout.template offset(); + for (const int row : boundary.reduced_dofs()) { + CHECK(coordinates.residual_factors()[density_row + row] == Catch::Approx(1.0 / scales.density.value()).epsilon(2.0e-13)); + } + const auto& density_metric = evaluator.cache().template get>(); + const auto& selected_rows = boundary.reduced_dofs(); + int interior = 0; + while (std::find(selected_rows.begin(), selected_rows.end(), interior) != selected_rows.end()) { + ++interior; + } + REQUIRE(interior < static_cast(state.template get().size())); + // The density balance residual is a barotropic pressure potential, a specific energy. + CHECK(coordinates.residual_factors()[density_row + interior] == Catch::Approx(1.0 / + (scales.specific_energy.value() * std::sqrt(density_metric[interior] * radius * radius * radius))).epsilon(2.0e-13)); + CHECK(coordinates.state_factors()[layout.template offset()] == Catch::Approx(1.0 / scales.specific_energy.value()).epsilon(2.0e-13)); + const auto infinity = serif::discretization::backend::mfem::select_boundary_dofs< + serif::discretization::domain::InfinitySurfaceBoundary>( + evaluator.binding().discretization->template binding()); + for (const int row : infinity.reduced_dofs()) { + CHECK(coordinates.residual_factors()[layout.template offset() + row] == + Catch::Approx(1.0 / scales.specific_energy.value()).epsilon(2.0e-13)); + } + CHECK(coordinates.residual_factors()[layout.template offset()] == Catch::Approx(1.0 / scales.mass.value()).epsilon(2.0e-13)); + CHECK(coordinates.residual_factors()[layout.template offset()] == Catch::Approx(1.0 / scales.angular_momentum.value()).epsilon(2.0e-13)); + const auto& surface_metric = evaluator.cache().template get(); + for (std::size_t mode = 0; mode < state.template get().size(); ++mode) { + CHECK(coordinates.state_factors()[layout.template offset() + mode] == Catch::Approx(std::sqrt(surface_metric[mode]) / (radius * radius)).epsilon(2.0e-13)); + CHECK(coordinates.residual_factors()[layout.template offset() + mode] == Catch::Approx(1.0 / scales.specific_energy.value()).epsilon(2.0e-13)); + } + + const Vector seed = tests::deterministic(static_cast(state.values().size()), 0.43); + const Vector dual = tests::deterministic(static_cast(coordinates.residual_size()), 1.17); + Vector transpose; + frozen->jacobian().MultTranspose(dual, transpose); + for (const int selected : {-1, 0, 1}) { + CAPTURE(selected); + Vector direction(seed); + if (selected >= 0) { + direction = 0.0; + const std::size_t offset = selected == 0 ? layout.template offset() : layout.template offset(); + const std::size_t count = selected == 0 ? state.template get().size() : 1; + std::copy_n(seed.GetData() + offset, count, direction.GetData() + offset); + } + Vector physical_direction(static_cast(state.values().size())), action, physical_action, expected_action(static_cast(coordinates.residual_size())); + coordinates.denormalize_state({direction.GetData(), coordinates.state_size()}, {physical_direction.GetData(), coordinates.state_size()}); + frozen->jacobian().Mult(direction, action); + frozen->physical()->jacobian().action()->Mult(physical_direction, physical_action); + coordinates.normalize_residual({physical_action.GetData(), coordinates.residual_size()}, {expected_action.GetData(), coordinates.residual_size()}); + check_rows(action, expected_action, layout, 2.0e-13, 2.0e-13); + CHECK((action * dual) == Catch::Approx(direction * transpose).epsilon(3.0e-12).margin(3.0e-12)); + constexpr double step = 2.0e-6; + const auto plus = evaluator.evaluate(perturbed_state(state, step, physical_direction)); + REQUIRE(plus); + CHECK(evaluator.cache().report().template status() == dependencies::PreparationStatus::reused); + CHECK(evaluator.cache().report().template status() == dependencies::PreparationStatus::reused); + CHECK(evaluator.cache().report().template status() == dependencies::PreparationStatus::reused); + Vector difference = residual(**plus); + const auto minus = evaluator.evaluate(perturbed_state(state, -step, physical_direction)); + REQUIRE(minus); + difference -= residual(**minus); + difference /= 2.0 * step; + check_rows(action, difference, layout, 3.0e-6, 3.0e-8); + } + const auto restored = evaluator.evaluate(state); + REQUIRE(restored); + check_rows(residual(**restored), residual(*frozen), layout, 0.0, 0.0); + Vector repeated; + frozen->jacobian().MultTranspose(dual, repeated); + CHECK(tests::relative_error(repeated, transpose) == 0.0); + } + + /** @brief Check the dimensional inverse, solver-coordinate adapter and independently measured linear residual. */ + template + void check_stellar_linear_solve(const Prepared& prepared, const Layout& layout, + const serif::discretization::backend::mfem::DofSelection& boundary) { + const auto& system = *prepared.system(); + const auto& coordinates = system.coordinates(); + const int size = system.jacobian().Width(); + REQUIRE(size == system.jacobian().Height()); + REQUIRE(coordinates.state_factors().front() != 1.0); + REQUIRE(coordinates.residual_factors().front() != 1.0); + CHECK(prepared.contract() == serif::solver::preconditioner::ApplicationContract::fixed_linear); + const Vector normalized_direction = tests::deterministic(size, 0.37); + Vector physical_direction(size), physical_rhs, normalized_rhs(size); + coordinates.denormalize_state({normalized_direction.GetData(), coordinates.state_size()}, + {physical_direction.GetData(), coordinates.state_size()}); + system.physical()->jacobian().action()->Mult(physical_direction, physical_rhs); + coordinates.normalize_residual({physical_rhs.GetData(), coordinates.residual_size()}, + {normalized_rhs.GetData(), coordinates.residual_size()}); + + Vector physical_correction(size), expected_correction(size), normalized_correction(size), preconditioned_action; + prepared.physical_preconditioner().Mult(physical_rhs, physical_correction); + coordinates.normalize_state({physical_correction.GetData(), coordinates.state_size()}, + {expected_correction.GetData(), coordinates.state_size()}); + prepared.preconditioner().Mult(normalized_rhs, normalized_correction); + CHECK(tests::relative_error(normalized_correction, expected_correction) < 3.0e-13); + system.physical()->jacobian().action()->Mult(physical_correction, preconditioned_action); + for (const int row : boundary.reduced_dofs()) { + const int index = static_cast(layout.template offset()) + row; + CHECK(preconditioned_action[index] == Catch::Approx(physical_rhs[index]).epsilon(2.0e-11).margin(2.0e-12)); + } + // The joint surface/scalar Schur solve must enforce every border equation and mutual coupling. + for (const std::size_t index : {layout.template offset(), layout.template offset()}) { + CHECK(preconditioned_action[static_cast(index)] == Catch::Approx(physical_rhs[static_cast(index)]).epsilon(2.0e-11).margin(2.0e-12)); + } + + for (std::size_t mode = 0; mode < layout.template size(); ++mode) { + const int index = static_cast(layout.template offset() + mode); + CHECK(preconditioned_action[index] == Catch::Approx(physical_rhs[index]).epsilon(2.0e-11).margin(2.0e-12)); + } + + mfem::FGMRESSolver krylov; + krylov.SetRelTol(1.0e-10); + krylov.SetAbsTol(1.0e-13); + krylov.SetMaxIter(400); + krylov.SetKDim(100); + krylov.SetPrintLevel(0); + krylov.SetPreconditioner(prepared.preconditioner()); + krylov.SetOperator(system.jacobian()); + for (const bool newton_right_hand_side : {false, true}) { + CAPTURE(newton_right_hand_side); + Vector solve_rhs(physical_rhs); + if (newton_right_hand_side) { + solve_rhs = residual(*system.physical()); + solve_rhs *= -1.0; + } + coordinates.normalize_residual({solve_rhs.GetData(), coordinates.residual_size()}, + {normalized_rhs.GetData(), coordinates.residual_size()}); + Vector solution(size); + solution = 0.0; + krylov.Mult(normalized_rhs, solution); + CAPTURE(krylov.GetNumIterations(), krylov.GetFinalNorm()); + CHECK(krylov.GetConverged()); + coordinates.denormalize_state({solution.GetData(), coordinates.state_size()}, + {physical_correction.GetData(), coordinates.state_size()}); + system.physical()->jacobian().action()->Mult(physical_correction, preconditioned_action); + Vector measured_residual(preconditioned_action); + measured_residual -= solve_rhs; + Vector normalized_residual(size); + coordinates.normalize_residual({measured_residual.GetData(), coordinates.residual_size()}, + {normalized_residual.GetData(), coordinates.residual_size()}); + CHECK(normalized_residual.Norml2() / normalized_rhs.Norml2() < 2.0e-10); + CHECK(measured_residual.Norml2() / solve_rhs.Norml2() < 2.0e-8); + check_rows(preconditioned_action, solve_rhs, layout, 2.0e-7, 2.0e-9); + } + } + + /** @brief Prepare the production stellar recipe selectively and retain its complete frozen numerical state. */ + void check_moving_preconditioner() { + namespace normalization = serif::solver::normalization; + namespace preconditioner = serif::solver::preconditioner; + using InverseKey = preconditioner::keys::Preconditioner; + using PreparedKey = model::preconditioning::PreparedSystem; + using Status = dependencies::PreparationStatus; +#ifdef MFEM_USE_MPI + // The AMG backend borrows the application's MPI session; the test is its application owner. + if (!mfem::Mpi::IsInitialized()) { + mfem::Mpi::Init(); + } +#endif + auto fixture = std::make_shared(mfem::Ordering::byNODES); + std::weak_ptr lifetime = fixture; + Vector right_hand_side, frozen_correction, frozen_action; + auto retained = [&]() { + const auto physics = physical_model(); + const auto discretization = discretize(fixture); + using Description = decltype(model::resolve_equilibrium_equations(physics)); + using Plan = model::EquilibriumPlan; + const std::array shape{0.004, -0.002, 0.001, 0.003, -0.001, 0.002, -0.003, 0.001, 0.002}; + const auto layout = backend::equilibrium_layout(discretization, shape); + const auto state = initial_state(layout, fixture->fields, shape); + const auto recipe = preconditioner::make_primal_preconditioner(layout); + static_assert(dependencies::ContainsKey); + static_assert(dependencies::ContainsKey); + static_assert(dependencies::ContainsKey); + static_assert(!dependencies::ContainsKey<::serif::tests::equations::Enthalpy, typename Plan::Values>); + const auto configured = ::serif::model::make_discretization(::serif::model::DiscretizationOptions{ + .model = physics, .finite_elements = discretization, + .normalization = normalization::make_normalization(normalization::NormalizationOptions{ + .scheme = normalization::LaneEmdenApproximation{.n = 3.0, .central_density = serif::dimensions::DensityValue{0.9}}, .coordinates = normalization::Dimensionless{}, + .gravitational_constant = serif::dimensions::QuantityValue{2.3 / (4.0 * std::numbers::pi)} + }), + .preconditioner = recipe + }); + serif::discretization::quadrature::RuleSet rules; + rules.set_fallback_control({.fixed_order = 8}); + const execution::VolumeOptions volume_options{.quadrature_policy = serif::discretization::quadrature::Policy{rules}}; + auto evaluator = backend::bind_stellar_model(configured, state, volume_options); + const auto physical = evaluator.evaluate(state); + REQUIRE(physical); + CHECK(evaluator.cache().report().template status() == Status::not_required); + CHECK(evaluator.cache().report().template status() == Status::not_required); + const auto prepared = evaluator.prepare_linear_system(state); + if (!prepared) { + UNSCOPED_INFO("Failed computation index: " << prepared.error().computation_index); + if (std::holds_alternative(prepared.error().error)) { + UNSCOPED_INFO(std::get(prepared.error().error).message); + } + } + REQUIRE(prepared); + const auto first = *prepared; + CHECK(first->system() == *physical); + CHECK(evaluator.cache().report().template status() == Status::reused); + CHECK(evaluator.cache().report().template status() == Status::computed); + const auto repeated = evaluator.prepare_linear_system(state); + REQUIRE(repeated); + CHECK(*repeated == first); + CHECK(evaluator.cache().report().computed_count() == 0); + CHECK(evaluator.cache().report().template status() == Status::reused); + + const auto boundary = serif::discretization::backend::mfem::select_boundary_dofs< + serif::discretization::domain::StellarSurfaceBoundary>(fixture->fields.density_binding); + check_stellar_linear_solve(*first, layout, boundary); + right_hand_side = tests::deterministic(static_cast(state.values().size()), 0.82); + frozen_correction.SetSize(right_hand_side.Size()); + first->preconditioner().Mult(right_hand_side, frozen_correction); + first->system()->jacobian().Mult(right_hand_side, frozen_action); + const Vector first_residual = residual(*first->system()); + + auto candidate = state; + candidate.template get()[0] += 0.01; + candidate.template get()[0] += 0.0002; + const auto trial = evaluator.evaluate(candidate); + REQUIRE(trial); + CHECK(*trial != first->system()); + CHECK(evaluator.cache().report().template status() == Status::not_required); + CHECK(evaluator.cache().report().template status() == Status::not_required); + CHECK(tests::relative_error(residual(*first->system()), first_residual) == 0.0); + const auto updated = evaluator.prepare_linear_system(candidate); + REQUIRE(updated); + CHECK(*updated != first); + CHECK((*updated)->system() == *trial); + CHECK(evaluator.cache().report().template status() == Status::computed); + const auto rebuilt = evaluator.prepare_linear_system(candidate, dependencies::make_dependency_context(), dependencies::PreparationMode::rebuild); + REQUIRE(rebuilt); + CHECK(*rebuilt != *updated); + CHECK(evaluator.cache().report().template status() == Status::computed); + CHECK(evaluator.cache().report().template status() == Status::computed); + Vector repeated_correction(right_hand_side.Size()), repeated_action; + first->preconditioner().Mult(right_hand_side, repeated_correction); + first->system()->jacobian().Mult(right_hand_side, repeated_action); + CHECK(tests::relative_error(repeated_correction, frozen_correction) == 0.0); + CHECK(tests::relative_error(repeated_action, frozen_action) == 0.0); + + // At n=1.5 the exact density Jacobian is undefined at zero density; reject the EOS trial. + auto singular = state; + std::ranges::fill(singular.template get(), 0.0); + singular.template get()[0] = 0.0; + const auto preparations = evaluator.cache().preparation_count(); + const auto rejected = evaluator.prepare_linear_system(singular); + REQUIRE_FALSE(rejected); + REQUIRE(std::holds_alternative(rejected.error().error)); + CHECK(std::get(rejected.error().error).reason == execution::PreparationRejectionReason::EQUATION); + CHECK_FALSE(evaluator.cache().ready()); + CHECK_FALSE(evaluator.cache().template contains()); + CHECK(evaluator.cache().report().computed_count() == 0); + CHECK(evaluator.cache().report().reused_count() == 0); + CHECK(evaluator.cache().preparation_count() == preparations); + first->preconditioner().Mult(right_hand_side, repeated_correction); + first->system()->jacobian().Mult(right_hand_side, repeated_action); + CHECK(tests::relative_error(repeated_correction, frozen_correction) == 0.0); + CHECK(tests::relative_error(repeated_action, frozen_action) == 0.0); + CHECK(tests::relative_error(residual(*first->system()), first_residual) == 0.0); + const auto recovered = evaluator.prepare_linear_system(candidate); + REQUIRE(recovered); + CHECK(evaluator.cache().ready()); + return first; + }(); + fixture.reset(); + CHECK_FALSE(lifetime.expired()); + Vector repeated_correction(right_hand_side.Size()), repeated_action; + retained->preconditioner().Mult(right_hand_side, repeated_correction); + retained->system()->jacobian().Mult(right_hand_side, repeated_action); + CHECK(tests::relative_error(repeated_correction, frozen_correction) == 0.0); + CHECK(tests::relative_error(repeated_action, frozen_action) == 0.0); + retained.reset(); + CHECK(lifetime.expired()); + } + + /** @brief Evaluate the full moving star in both native displacement orderings and retain its frozen actions. */ + template + void check_moving_equilibrium(const mfem::Ordering::Type ordering) { + auto fixture = std::make_shared(ordering); + std::weak_ptr lifetime = fixture; + Vector direction, dual, action, transpose, normalized_action, normalized_transpose; + std::shared_ptr normalized_retained; + const auto exercise = [&]() { + auto& fields = fixture->fields; + const auto physics = physical_model(); + const auto discretization = discretize(fixture); + using Description = decltype(model::resolve_equilibrium_equations(physics)); + using Plan = model::EquilibriumPlan; + const std::array shape{0.004, -0.002, 0.001, 0.003, -0.001, 0.002, -0.003, 0.001, 0.002}; + const auto layout = backend::equilibrium_layout(discretization, shape); + REQUIRE(layout.state_sizes.size() == 5); + REQUIRE(layout.residual_sizes.size() == 5); + REQUIRE(layout.template total_size() == layout.template total_size()); + REQUIRE(shape.size() < static_cast(fields.displacement_binding.size())); + const auto state = initial_state(layout, fields, shape); + const auto boundary = serif::discretization::backend::mfem::select_boundary_dofs< + serif::discretization::domain::StellarSurfaceBoundary>(fields.density_binding); + serif::discretization::quadrature::RuleSet rules; + rules.set_fallback_control({.fixed_order = 8}); + const execution::VolumeOptions volume_options{.quadrature_policy = serif::discretization::quadrature::Policy{rules}}; + auto evaluator = [&]() { + if constexpr (Normalize) { + namespace normalization = serif::solver::normalization; + using Reference = normalization::LaneEmdenApproximation; + const auto configured = ::serif::model::make_discretization(::serif::model::DiscretizationOptions{ + .model = physics, .finite_elements = discretization, + .normalization = normalization::make_normalization(normalization::NormalizationOptions{ + .scheme = Reference{.n = 3.0, .central_density = serif::dimensions::DensityValue{0.9}}, + .coordinates = normalization::Dimensionless{}, + .gravitational_constant = serif::dimensions::QuantityValue{2.3 / (4.0 * std::numbers::pi)} + }) + }); + return backend::bind_stellar_model(configured, state, volume_options); + } else { + return backend::bind_stellar_model(physics, discretization, state, volume_options); + } + }(); + const auto initial = evaluator.evaluate(state); + REQUIRE(initial); + const auto first = *initial; + using MassRow = model::computations::ScalarResidualRow; + using AngularMomentumRow = model::computations::ScalarResidualRow; + const auto mass_row = evaluator.cache().template share(); + const auto angular_momentum_row = evaluator.cache().template share(); + CHECK(evaluator.cache().report().template status() == dependencies::PreparationStatus::computed); + CHECK(evaluator.cache().report().template status() == dependencies::PreparationStatus::computed); + const auto repeated = evaluator.evaluate(state); + REQUIRE(repeated); + CHECK(*repeated == first); + CHECK(evaluator.cache().report().computed_count() == 0); + CHECK(evaluator.cache().template share() == mass_row); + CHECK(evaluator.cache().template share() == angular_momentum_row); + + // A successful trial is merely another result; the caller retains the previous candidate. + const auto original = state; + auto copied = state; + CHECK(copied.values().data() != state.values().data()); + copied.template get()[0] += 0.01; + copied.template get()[0] += 0.0002; + auto moved = std::move(copied); + CHECK(std::ranges::equal(state.values(), original.values())); + const Vector first_residual = residual(*first); + const auto discarded = evaluator.evaluate(moved); + REQUIRE(discarded); + CHECK(evaluator.cache().template share() != mass_row); + CHECK(evaluator.cache().template share() != angular_momentum_row); + CHECK(evaluator.cache().report().template status() == dependencies::PreparationStatus::computed); + CHECK(evaluator.cache().report().template status() == dependencies::PreparationStatus::computed); + CHECK(*discarded != first); + CHECK(tests::relative_error(residual(**discarded), first_residual) > 1.0e-10); + CHECK(tests::relative_error(residual(*first), first_residual) == 0.0); + CHECK(std::ranges::equal(state.values(), original.values())); + const auto restored = evaluator.evaluate(state); + REQUIRE(restored); + if constexpr (Normalize) { + check_normalized_system(evaluator, state, boundary); + } else { + check_composition(evaluator, state, **restored, boundary); + check_scalar_row(evaluator, **restored); + check_scalar_row(evaluator, **restored); + check_derivatives(evaluator, state); + } + + const auto evaluated = evaluator.evaluate(state); + REQUIRE(evaluated); + const auto retained = *evaluated; + const auto physical = [&]() { + if constexpr (Normalize) { + return retained->physical(); + } else { + return retained; + } + }(); + const auto preparations = evaluator.cache().preparation_count(); + const Vector retained_residual = residual(*retained); + direction = tests::deterministic(static_cast(state.values().size()), 0.61); + dual = tests::deterministic(static_cast(state.values().size()), 1.31); + physical->jacobian().action()->Mult(direction, action); + physical->jacobian().action()->MultTranspose(dual, transpose); + if constexpr (Normalize) { + normalized_retained = retained; + normalized_retained->jacobian().Mult(direction, normalized_action); + normalized_retained->jacobian().MultTranspose(dual, normalized_transpose); + } + auto invalid = state; + invalid.template get()[0] = std::numeric_limits::quiet_NaN(); + const auto rejected = evaluator.evaluate(invalid); + REQUIRE_FALSE(rejected); + CHECK(std::holds_alternative(rejected.error().error)); + CHECK(tests::relative_error(residual(*retained), retained_residual) == 0.0); + if constexpr (Normalize) { + CHECK_FALSE(evaluator.cache().template contains()); + } + CHECK_FALSE(evaluator.cache().ready()); + CHECK_FALSE(evaluator.cache().template contains()); + CHECK(evaluator.cache().report().computed_count() == 0); + CHECK(evaluator.cache().report().reused_count() == 0); + CHECK(evaluator.cache().preparation_count() == preparations); + const auto recovered = evaluator.evaluate(state); + REQUIRE(recovered); + CHECK(*recovered == retained); + CHECK(evaluator.cache().report().computed_count() == 0); + return physical; + }; + auto retained = exercise(); + fixture.reset(); + CHECK_FALSE(lifetime.expired()); + Vector repeated_action, repeated_transpose; + retained->jacobian().action()->Mult(direction, repeated_action); + retained->jacobian().action()->MultTranspose(dual, repeated_transpose); + CHECK(tests::relative_error(repeated_action, action) == 0.0); + CHECK(tests::relative_error(repeated_transpose, transpose) == 0.0); + retained.reset(); + if constexpr (Normalize) { + CHECK_FALSE(lifetime.expired()); + normalized_retained->jacobian().Mult(direction, repeated_action); + normalized_retained->jacobian().MultTranspose(dual, repeated_transpose); + CHECK(tests::relative_error(repeated_action, normalized_action) == 0.0); + CHECK(tests::relative_error(repeated_transpose, normalized_transpose) == 0.0); + normalized_retained.reset(); + } + CHECK(lifetime.expired()); + } +} + +TEST_CASE("Complete moving stellar assembly preserves every coupled equation and boundary row", "[model][moving_equilibrium][integration]") { + SECTION("Component-block displacement ordering") { + check_moving_equilibrium(mfem::Ordering::byNODES); + } + SECTION("Interleaved displacement ordering") { + check_moving_equilibrium(mfem::Ordering::byVDIM); + } +} + +TEST_CASE("Complete moving stellar normalization preserves solver coordinates and frozen ownership", "[model][moving_normalization][integration]") { + check_moving_equilibrium(mfem::Ordering::byNODES); +} + +TEST_CASE("Complete moving stellar preconditioning prepares a frozen solver system and solves its physical Jacobian", "[model][moving_preconditioner][integration]") { +#ifdef MFEM_USE_MPI + check_moving_preconditioner(); +#else + SKIP("The default stellar recipe uses the one-process BoomerAMG backend, which requires an MPI-enabled MFEM build."); +#endif +} diff --git a/tests/model/normalized_system.cpp b/tests/model/normalized_system.cpp new file mode 100644 index 00000000..1f90894f --- /dev/null +++ b/tests/model/normalized_system.cpp @@ -0,0 +1,115 @@ +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "mfem/linalg/densemat.hpp" +#include "serif/backend/mfem/model/normalized_system.hpp" +#include "serif/utils/dependencies/computation_cache.hpp" +#include "serif/backend/mfem/model/normalization/computations.hpp" + +namespace { + namespace model = serif::model; + namespace backend = model::backend::mfem; + namespace dependencies = serif::utils::dependencies; + namespace normalization = serif::solver::normalization; + namespace dimensions = serif::dimensions; + struct PhysicalResidual {}; + struct ResidualFactor {}; + + /** @brief Give the frozen normalization adapter an independently owned physical action. */ + [[nodiscard]] std::shared_ptr physical_system(double value) { + auto matrix = std::make_shared(1); + (*matrix)(0, 0) = 3.0 + value; + const auto block = serif::operators::backend::mfem::linearized_block(std::shared_ptr(matrix)); + return backend::make_physical_system(1, 1, {{.row_offset = 0, .values = {value}}}, {{.row_offset = 0, .column_offset = 0, .block = block}}); + } + + /** @brief Keep a valid reference fixed while failure is induced only in coordinate conversion. */ + [[nodiscard]] normalization::StellarReferenceScales reference_scales() { + const auto scales = normalization::prepare_stellar_reference_scales(dimensions::MassValue{2.0}, dimensions::LengthValue{3.0}); + REQUIRE(scales); + return *scales; + } +} + +TEST_CASE("A normalization overflow rejects the new physical preparation without losing frozen solver coordinates", "[model][normalization][rejection]") { + using namespace dependencies; + + double value = 1.0; + double factor = 2.0; + int physical_preparations = 0; + const auto scales = reference_scales(); + const auto inputs = make_dependency_context(borrow(value), borrow(factor), borrow(scales)); + const auto physical = make_computation>([&](double residual) { + ++physical_preparations; + return physical_system(residual); + }); + const auto coordinates = make_computation>([](double residual_factor) { + return normalization::make_diagonal_normalization({3.0}, {residual_factor}); + }); + const auto normalized = make_computation>(backend::make_normalized_system); + auto graph = make_computation_graph(physical, coordinates, normalized); + auto cache = make_computation_cache(std::move(graph), inputs); + using Requested = KeyList; + REQUIRE(cache.try_prepare(inputs, Requested{})); + const auto physical_snapshot = cache.get(); + const auto frozen = cache.template get(); + REQUIRE(frozen->physical() == physical_snapshot); + CHECK(frozen->residual() == std::vector{2.0}); + mfem::Vector direction(1), original_action, repeated_action; + direction[0] = 1.0; + frozen->jacobian().Mult(direction, original_action); + CHECK(original_action[0] == Catch::Approx(8.0 / 3.0)); + const auto preparations = cache.preparation_count(); + + value = 2.0; + factor = std::numeric_limits::max(); + const auto rejected = cache.try_prepare(inputs, Requested{}); + REQUIRE_FALSE(rejected); + CHECK_THAT(rejected.error().error, Catch::Matchers::ContainsSubstring("normalized stellar residual is not finite")); + CHECK(physical_preparations == 2); + CHECK(frozen->physical() == physical_snapshot); + CHECK_FALSE(cache.ready()); + CHECK_FALSE(cache.template contains()); + CHECK_FALSE(cache.template contains()); + CHECK(cache.report().computed_count() == 0); + CHECK(cache.report().reused_count() == 0); + CHECK(cache.preparation_count() == preparations); + CHECK(frozen->residual() == std::vector{2.0}); + frozen->jacobian().Mult(direction, repeated_action); + CHECK(repeated_action[0] == original_action[0]); + + value = 1.0; + factor = 2.0; + REQUIRE(cache.try_prepare(inputs, Requested{})); + CHECK(cache.get() == physical_snapshot); + CHECK(cache.template get() == frozen); + CHECK(cache.report().computed_count() == 0); + value = 2.0; + REQUIRE(cache.try_prepare(inputs, Requested{})); + CHECK(cache.get() != physical_snapshot); + CHECK(cache.template get()->residual() == std::vector{4.0}); + CHECK(frozen->residual() == std::vector{2.0}); +} + +TEST_CASE("Normalized system construction diagnoses missing owners and incompatible coordinates", "[model][normalization][validation]") { + const auto factors = normalization::make_diagonal_normalization({1.0}, {2.0}); + REQUIRE(factors); + CHECK_THROWS_WITH(backend::make_normalized_system({}, *factors, reference_scales()), Catch::Matchers::ContainsSubstring("physical system")); + const auto wrong_state = normalization::make_diagonal_normalization({1.0, 1.0}, {2.0}); + const auto wrong_residual = normalization::make_diagonal_normalization({1.0}, {2.0, 2.0}); + REQUIRE(wrong_state); + REQUIRE(wrong_residual); + CHECK_THROWS_AS(backend::make_normalized_system(physical_system(1.0), *wrong_state, reference_scales()), std::invalid_argument); + CHECK_THROWS_AS(backend::make_normalized_system(physical_system(1.0), *wrong_residual, reference_scales()), std::invalid_argument); + const auto excessive = normalization::make_diagonal_normalization({1.0}, {std::numeric_limits::max()}); + REQUIRE(excessive); + CHECK_FALSE(backend::make_normalized_system(physical_system(2.0), *excessive, reference_scales())); +} diff --git a/tests/model/pressure_graph.cpp b/tests/model/pressure_graph.cpp new file mode 100644 index 00000000..28884b64 --- /dev/null +++ b/tests/model/pressure_graph.cpp @@ -0,0 +1,144 @@ +#include +#include +#include +#include +#include +#include + +#include "serif/backend/mfem/geometry/surface/pressure_boundary.hpp" +#include "serif/backend/mfem/model/computations/pressure_boundary.hpp" +#include "serif/tests/surface/boundary_fixture.hpp" +#include "serif/utils/dependencies/computation_cache.hpp" + +namespace { + namespace test = serif::tests::surface; + namespace fixture = test::boundary; + namespace surface = serif::geometry::surface; + namespace boundary = surface::backend::mfem; + namespace physical = serif::operators::backend::mfem; + namespace model = serif::model; + namespace dependencies = serif::utils::dependencies; + namespace blocks = serif::discretization::blocks; + using Density = fixture::BoundaryDensity; + using Temperature = test::Temperature; + using Vector = mfem::Vector; + struct OtherEquation { + }; + + /** @brief Keep an independent nonzero interior action so replacement must remove every old constrained-row column. */ + std::shared_ptr base_matrix(const int rows, const int columns) { + auto matrix = std::make_shared(rows, columns); + for (int row = 0; row < rows; ++row) { + matrix->Add(row, row % columns, 7.0); + } + matrix->Finalize(); + return matrix; + } + + /** @brief Check every row against independent MFEM interpolation and the analytic ideal-gas boundary relation. */ + template + void check_pressure_graph() { + using Values = std::conditional_t, dependencies::KeyList>; + using Layout = blocks::SystemLayout>; + fixture::Fixture mesh; + Vector density = fixture::sequence(mesh.density.size(), 0.8, 0.2); + Vector temperature = fixture::sequence(mesh.temperature.size(), 1.7, 0.4); + const auto state = physical::make_term_coefficients(physical::bind(mesh.density, density), physical::bind(mesh.temperature, temperature)); + const auto compiled = boundary::make_pressure_boundary(state); + Layout layout; + if constexpr (SolveTemperature) { + layout.state_sizes = {static_cast(temperature.Size()), static_cast(density.Size())}; + } + else { + layout.state_sizes = {static_cast(density.Size())}; + } + layout.residual_sizes = {2, static_cast(density.Size())}; + test::GasEOS eos; + const auto inputs = boundary::make_pressure_boundary_inputs(compiled, dependencies::borrow(eos, dependencies::track_by([](const test::GasEOS&) { + return 0; + })), + surface::IsobaricSurface{test::quantities::PressureValue{0.8}}, state); + auto cache = std::apply([&](auto... node) { + return dependencies::make_computation_cache(inputs, std::move(node)...); + }, + std::tuple_cat(boundary::make_pressure_boundary_computations(), std::tuple{model::computations::pressure_boundary_contribution(layout)})); + REQUIRE(cache.try_prepare(inputs)); + const auto frozen = cache.template share(); + REQUIRE(frozen->replacements.size() == 1); + const auto& replacement = frozen->replacements[0]; + REQUIRE(replacement.jacobian.size() == (SolveTemperature ? 2 : 1)); + REQUIRE(replacement.residual.size() == static_cast(compiled->selected_rows->size())); + const int rows = density.Size() + 2; + const int columns = static_cast(layout.template total_size()); + const auto base = base_matrix(rows, columns); + const model::backend::mfem::PhysicalSystem accepted(rows, columns, + {{.row_offset = 0, .values = std::vector(rows, 9.0)}, {.row_offset = 0, .values = std::vector(rows, 2.0)}}, + {{.row_offset = 0, .column_offset = 0, .block = physical::linearized_block(base)}}, frozen->replacements); + const Vector density_samples = fixture::reference_samples(mesh.density, *compiled->selected_rows, mesh.density, density); + const Vector temperature_samples = fixture::reference_samples(mesh.density, *compiled->selected_rows, mesh.temperature, temperature); + Vector direction = fixture::sequence(columns, 0.3, 0.7); + const int density_offset = static_cast(layout.template offset()); + Vector density_direction(direction.GetData() + density_offset, density.Size()); + const Vector density_variation = fixture::reference_samples(mesh.density, *compiled->selected_rows, mesh.density, density_direction); + Vector temperature_variation(compiled->selected_rows->size()); + temperature_variation = 0.0; + if constexpr (SolveTemperature) { + Vector temperature_direction(direction.GetData(), temperature.Size()); + temperature_variation = fixture::reference_samples(mesh.density, *compiled->selected_rows, mesh.temperature, temperature_direction); + } + Vector expected(rows), actual(rows), expected_residual(rows); + base->Mult(direction, expected); + expected_residual = 11.0; + for (int index = 0; index < compiled->selected_rows->size(); ++index) { + const int row = 2 + compiled->selected_rows->reduced_dofs()[index]; + CHECK(replacement.selected_rows[index] == row); + const double rho = density_samples[index]; + const double thermal = temperature_samples[index]; + if constexpr (Formulation::resolves_carrier) { + expected_residual[row] = rho - 0.8 / thermal; + expected[row] = density_variation[index] + 0.8 * temperature_variation[index] / (thermal * thermal); + } + else { + expected_residual[row] = rho * thermal - 0.8; + expected[row] = thermal * density_variation[index] + rho * temperature_variation[index]; + } + } + accepted.jacobian().action()->Mult(direction, actual); + for (int row = 0; row < rows; ++row) { + CHECK(accepted.residual()[row] == Catch::Approx(expected_residual[row]).margin(3.0e-13)); + CHECK(actual[row] == Catch::Approx(expected[row]).margin(3.0e-13)); + } + const Vector dual = fixture::sequence(rows, 0.5, 0.2); + Vector transpose(columns); + accepted.jacobian().action()->MultTranspose(dual, transpose); + CHECK((dual * actual) == Catch::Approx(direction * transpose).margin(2.0e-11)); + REQUIRE(cache.try_prepare(inputs)); + CHECK(cache.report().computed_count() == 0); + CHECK(cache.template share() == frozen); + density += 0.04; + REQUIRE(cache.try_prepare(inputs)); + CHECK(cache.template share() != frozen); + accepted.jacobian().action()->Mult(direction, actual); + for (int row = 0; row < rows; ++row) { + CHECK(actual[row] == Catch::Approx(expected[row]).margin(3.0e-13)); + } + } +} // namespace + +TEST_CASE("Model pressure replacements preserve general EOS fields and independent system ordering", "[unit][model][pressure_graph]") { + SECTION("Density carries an isobaric condition with solved temperature") { + check_pressure_graph, true>(); + } + SECTION("Direct pressure contributes interpolated columns for every solved EOS field") { + check_pressure_graph, true>(); + } + SECTION("Prescribed temperature is evaluated without introducing a Newton column") { + check_pressure_graph, false>(); + } +} + +TEST_CASE("Pressure replacement rejects layouts that cannot be represented by MFEM indices", "[unit][model][pressure_graph]") { + using Layout = blocks::SystemLayout, dependencies::KeyList>; + const Layout layout{.state_sizes = {static_cast(std::numeric_limits::max()) + 1}, .residual_sizes = {1}}; + CHECK_THROWS(model::computations::pressure_boundary_contribution>(layout)); +} diff --git a/tests/model/reconstruction.cpp b/tests/model/reconstruction.cpp new file mode 100644 index 00000000..de07fc4f --- /dev/null +++ b/tests/model/reconstruction.cpp @@ -0,0 +1,119 @@ +#include +#include +#include +#include + +#include "serif/model/finite_elements.hpp" +#include "serif/backend/mfem/discretization/field_values.hpp" +#include "serif/backend/mfem/serialize/discretization.hpp" +#include "../sandbox/polytropic_model.hpp" + +TEST_CASE("Stellar factory selects compatible surface layers and preserves the ordinary default", "[model][reconstruction]") { + namespace reconstruction = serif::discretization::reconstruction; + namespace blocks = serif::discretization::blocks; + namespace model = serif::model; + using Density = blocks::density::mass; + const auto star = serif::examples::make_polytropic_model(4.0 / 3.0); + const auto ordinary = model::make_stellar_discretization(star); + CHECK_FALSE(ordinary.binding().reconstruction()); + const auto identity = model::make_stellar_discretization(star, {}, reconstruction::SurfacePower{.density_exponent = 1.0}); + CHECK_FALSE(identity.binding().reconstruction()); + for (const int equatorial : {0, 2}) { + const auto weighted = model::make_stellar_discretization(star, + {.surface_refinement = {.levels = 1, .equatorial_levels = equatorial, .equatorial_half_angle_degrees = 10.0}}, + reconstruction::SurfacePower{.density_exponent = 4.0 / 3.0, + .region = reconstruction::SurfaceAdjacent{}}); + const auto& density = weighted.binding(); + REQUIRE(density.reconstruction()); + int selected = 0, ordinary_material = 0; + for (int element = 0; element < density.mesh().GetNE(); ++element) { + if (!density.supports(element)) { continue; } + if (density.reconstruction_element(element)) { ++selected; } + else { ++ordinary_material; } + } + CHECK(selected > 0); + CHECK(ordinary_material > 0); + CHECK_FALSE(weighted.binding().reconstruction()); + } + CHECK_THROWS_WITH((model::make_stellar_discretization(star, {}, reconstruction::SurfacePower{})), + Catch::Matchers::ContainsSubstring("only scalar H1 mass density")); + CHECK_THROWS_WITH((model::make_stellar_discretization(star, {}, reconstruction::SurfacePower{}, reconstruction::SurfacePower{})), + Catch::Matchers::ContainsSubstring("at most one")); +} + +TEST_CASE("Weighted archives preserve between-node density and reject missing interpretation", "[model][reconstruction]") { + namespace reconstruction = serif::discretization::reconstruction; + namespace backend = serif::backend::mfem::serialize; + namespace discretization = serif::discretization::backend::mfem; + using Density = serif::discretization::blocks::density::mass; + const auto star = serif::examples::make_polytropic_model(5.0 / 3.0); + const auto original = serif::model::make_stellar_discretization(star, {}, reconstruction::SurfacePower{.density_exponent = 5.0 / 3.0}); + const auto record = backend::save_discretization(original); + auto restored = backend::restore_discretization(record); + INFO((restored ? "restored" : restored.error().message)); + REQUIRE(restored); + CHECK(backend::save_discretization(*restored) == record); + const auto& binding = original.binding(); + ::mfem::Vector coefficients(binding.size()); + for (int i = 0; i < coefficients.Size(); ++i) { coefficients[i] = std::sin(0.13 * i); } + discretization::FieldValues before(binding, coefficients), after(restored->binding(), coefficients); + ::mfem::IntegrationPoint point; + point.Set3(0.17, 0.31, 0.87); + for (int element = 0; element < binding.mesh().GetNE(); ++element) { + if (!binding.supports(element)) { continue; } + CHECK(before.value(element, point) == Catch::Approx(after.value(element, point)).margin(1e-13)); + } + auto malformed = record; + for (auto& [key, saved] : malformed.children.at("bindings").children) { + if (saved.children.contains("reconstruction")) { + CHECK(saved.version == 2); + saved.children.erase("reconstruction"); + } + } + CHECK_FALSE(backend::restore_discretization(malformed)); +} + +TEST_CASE("Reconstruction plans reject attachment after the boundary changes", "[model][reconstruction]") { + namespace reconstruction = serif::discretization::reconstruction; + using Density = serif::discretization::blocks::density::mass; + const auto star = serif::examples::make_polytropic_model(1.5); + const auto finite_elements = serif::model::make_stellar_discretization(star, {}, reconstruction::SurfacePower{.density_exponent = 1.5}); + const auto& density = finite_elements.binding(); + const auto plan = density.reconstruction(); + auto& mesh = density.mesh(); + REQUIRE(mesh.GetNBE() > 0); + mesh.SetBdrAttribute(0, mesh.GetBdrAttribute(0) + 10); + using Binding = std::remove_cvref_t; + CHECK_THROWS_WITH(Binding(density.space(), plan), Catch::Matchers::ContainsSubstring("Rebuild the reconstruction plan")); + CHECK_THROWS(density.validate()); +} + +TEST_CASE("Reconstruction rejects radial hanging layers and stale refined-space plans", "[model][reconstruction]") { + namespace backend = serif::discretization::backend::mfem; + namespace reconstruction = serif::discretization::reconstruction; + using Density = serif::discretization::blocks::density::mass; + using Schema = serif::discretization::domain::schema::CoreEnvelopeVacuumDomainSchema; + auto mesh = ::mfem::Mesh::MakeCartesian3D(2, 1, 1, ::mfem::Element::HEXAHEDRON); + ::mfem::Array vertices; + for (int boundary = 0; boundary < mesh.GetNBE(); ++boundary) { + mesh.GetBdrElementVertices(boundary, vertices); + bool surface = true; + for (int vertex : vertices) { surface = surface && mesh.GetVertex(vertex)[2] == 0.0; } + mesh.GetBdrElement(boundary)->SetAttribute(surface ? 1 : 2); + } + mesh.SetAttributes(); + ::mfem::H1_FECollection collection(3, 3); + ::mfem::FiniteElementSpace space(&mesh, &collection); + backend::TermBinding original(space); + const auto specification = reconstruction::SurfacePower{.density_exponent = 1.5}; + const auto old_plan = backend::make_reconstruction(specification, original); + ::mfem::Array<::mfem::Refinement> refinement; + refinement.Append(::mfem::Refinement(0, ::mfem::Refinement::Z)); + mesh.GeneralRefinement(refinement, 1); + space.Update(); + CHECK_THROWS_WITH((backend::TermBinding(space, old_plan)), + Catch::Matchers::ContainsSubstring("Rebuild the reconstruction plan")); + backend::TermBinding updated(space); + CHECK_THROWS_WITH(backend::make_reconstruction(specification, updated), + Catch::Matchers::ContainsSubstring("matching radial layers")); +} diff --git a/tests/model/reconstruction_system.cpp b/tests/model/reconstruction_system.cpp new file mode 100644 index 00000000..d168f009 --- /dev/null +++ b/tests/model/reconstruction_system.cpp @@ -0,0 +1,206 @@ +#include +#include +#include +#include + +#include +#include + +#include "serif/backend/mfem/model/equilibrium.hpp" +#include "serif/model/finite_elements.hpp" +#include "serif/runtime.hpp" +#include "serif/solver/seed/state.hpp" +#include "../sandbox/polytropic_model.hpp" + +namespace { + namespace model = serif::model; + namespace backend = model::backend::mfem; + namespace blocks = serif::discretization::blocks; + namespace deformation = serif::geometry::deformation; + namespace normalization = serif::solver::normalization; + using Density = blocks::density::mass; + using Potential = blocks::gravity::potential; + using Constant = blocks::barotropic_constant::mass_normalization; + using Omega = blocks::rotation::angular_velocity; + using Shape = blocks::surface_deformation::coefficients; + using Kind = blocks::RepresentedValueKind; + + /** @brief Make quadrature refinement explicit without changing the represented stellar state. */ + [[nodiscard]] serif::operators::backend::mfem::VolumeOptions integration_order(int order) { + serif::discretization::quadrature::RuleSet rules; + rules.set_fallback_control({.fixed_order = order}); + return {.quadrature_policy = serif::discretization::quadrature::Policy{rules}}; + } + + /** @brief Freeze a residual before preparing another physical candidate. */ + template + [[nodiscard]] mfem::Vector residual_copy(const System& system) { + mfem::Vector result(static_cast(system.residual().size())); + std::copy(system.residual().begin(), system.residual().end(), result.GetData()); + return result; + } + + /** @brief Exercise all coefficients with a reproducible bounded direction, independent of mesh numbering. */ + [[nodiscard]] mfem::Vector direction_values(int size, double phase) { + mfem::Vector result(size); + for (int index = 0; index < size; ++index) { result[index] = std::sin(0.37 * index + phase); } + return result; + } + + /** @brief Compare each physical equation separately so gravity cannot mask an incorrect scalar or surface row. */ + template + void check_rows(const mfem::Vector& actual, const mfem::Vector& expected, const Layout& layout, + double relative_tolerance, double absolute_tolerance) { + REQUIRE(actual.Size() == expected.Size()); + int offset = 0; + for (std::size_t row = 0; row < layout.residual_sizes.size(); ++row) { + CAPTURE(row); + double error_squared = 0.0, reference_squared = 0.0; + for (std::size_t entry = 0; entry < layout.residual_sizes[row]; ++entry, ++offset) { + const double error = actual[offset] - expected[offset]; + error_squared += error * error; + reference_squared += expected[offset] * expected[offset]; + } + CHECK(std::sqrt(error_squared) <= absolute_tolerance + relative_tolerance * std::sqrt(reference_squared)); + } + } + + /** @brief Perturb solver coordinates through their actual dimensional scaling. */ + template + [[nodiscard]] model::StellarState perturbed(const model::StellarState& state, + const normalization::DiagonalNormalization& coordinates, const mfem::Vector& direction, double step) { + mfem::Vector physical(direction.Size()); + coordinates.denormalize_state({direction.GetData(), static_cast(direction.Size())}, + {physical.GetData(), static_cast(physical.Size())}); + model::StellarState result(state); + for (int index = 0; index < direction.Size(); ++index) { result.values()[index] += step * physical[index]; } + return result; + } + + /** @brief Verify strong physical boundary equations after the complete normalized transpose assembly. */ + template + void check_boundary_transpose(const backend::NormalizedSystem& system, const Layout& layout, const Selection& selected) { + const auto& coordinates = system.coordinates(); + const int row_offset = static_cast(layout.template offset()); + const int column_offset = static_cast(layout.template offset()); + mfem::Vector dual(system.jacobian().Height()), expected(system.jacobian().Width()), actual; + dual = 0.0; + expected = 0.0; + REQUIRE(selected.size() > 0); + for (int row : selected.reduced_dofs()) { + const double value = 0.2 + 0.01 * (row % 7); + dual[row_offset + row] = value; + expected[column_offset + row] = value * coordinates.residual_factors()[row_offset + row] / + coordinates.state_factors()[column_offset + row]; + } + system.jacobian().MultTranspose(dual, actual); + actual -= expected; + CHECK(actual.Norml2() <= 2e-12 * expected.Norml2()); + } +} + +TEST_CASE("Weighted moving stars retain complete normalized derivatives and physical boundary rows", "[model][integration][reconstruction][reconstruction_system]") { + serif::initialize(); + const auto star = serif::examples::make_polytropic_model(4.0 / 3.0, 0.6, 1.0, 0.04); + const auto radial = serif::solver::seed::make_polytropic_seed(star); + REQUIRE(radial); + const auto finite_elements = model::make_stellar_discretization(star, + {.radius = radial->scales.radius, .geometry_order = 3}, + deformation::DomainDeformation{.surface = {.maximum_degree = 2}}, + serif::discretization::reconstruction::SurfacePower{.density_exponent = 4.0 / 3.0}); + auto seeded = serif::solver::seed::make_polytropic_state(star, finite_elements, *radial); + REQUIRE(seeded); + auto state = *seeded; + state.get()[0] = 0.08; + REQUIRE(state.get().size() == 9); + state.get()[0] = 0.01; + state.get()[6] = -0.015; + const auto scales = normalization::prepare_stellar_reference_scales(radial->scales.mass, radial->scales.radius); + REQUIRE(scales); + const auto configuration = model::make_discretization(model::DiscretizationOptions{ + .model = star, .finite_elements = finite_elements, + .normalization = normalization::NormalizationOptions{.scheme = *scales}}); + auto evaluator = backend::bind_stellar_model(configuration, state, integration_order(12)); + const auto evaluated = evaluator.evaluate(state); + REQUIRE(evaluated); + const auto frozen = *evaluated; + const auto& layout = state.layout(); + const auto& coordinates = frozen->coordinates(); + const mfem::Vector original_residual = residual_copy(*frozen); + const mfem::Vector seed = direction_values(frozen->jacobian().Width(), 0.43); + const mfem::Vector dual = direction_values(frozen->jacobian().Height(), 1.17); + mfem::Vector transpose; + frozen->jacobian().MultTranspose(dual, transpose); + + int offset = 0; + for (std::size_t column = 0; column <= layout.state_sizes.size(); ++column) { + CAPTURE(column); + mfem::Vector direction(seed); + if (column < layout.state_sizes.size()) { + direction = 0.0; + std::copy_n(seed.GetData() + offset, layout.state_sizes[column], direction.GetData() + offset); + offset += static_cast(layout.state_sizes[column]); + } + mfem::Vector action; + frozen->jacobian().Mult(direction, action); + REQUIRE(action.Norml2() > 1e-10); + CHECK(action * dual == Catch::Approx(direction * transpose).epsilon(2e-11).margin(2e-11)); + const std::vector steps = column < layout.state_sizes.size() ? std::vector{2e-6} : std::vector{2e-6, 1e-6}; + for (const double step : steps) { + CAPTURE(step); + const auto plus = evaluator.evaluate(perturbed(state, coordinates, direction, step)); + REQUIRE(plus); + mfem::Vector difference = residual_copy(**plus); + const auto minus = evaluator.evaluate(perturbed(state, coordinates, direction, -step)); + REQUIRE(minus); + difference -= residual_copy(**minus); + difference /= 2.0 * step; + check_rows(action, difference, layout, 5e-6, 2e-8); + } + } + const auto density_surface = serif::discretization::backend::mfem::select_boundary_dofs< + serif::discretization::domain::StellarSurfaceBoundary>(finite_elements.binding()); + const auto infinity = serif::discretization::backend::mfem::select_boundary_dofs< + serif::discretization::domain::InfinitySurfaceBoundary>(finite_elements.binding()); + check_boundary_transpose(*frozen, layout, density_surface); + check_boundary_transpose(*frozen, layout, infinity); + const auto restored = evaluator.evaluate(state); + REQUIRE(restored); + check_rows(residual_copy(**restored), original_residual, layout, 0.0, 0.0); + mfem::Vector repeated; + frozen->jacobian().MultTranspose(dual, repeated); + repeated -= transpose; + CHECK(repeated.Norml2() == 0.0); +} + +TEST_CASE("Weighted moving-star mass and angular momentum converge under independent quadrature refinement", "[model][integration][reconstruction][reconstruction_quadrature]") { + serif::initialize(); + const auto star = serif::examples::make_polytropic_model(4.0 / 3.0, 0.6, 1.0, 0.04); + const auto radial = serif::solver::seed::make_polytropic_seed(star); + REQUIRE(radial); + const auto finite_elements = model::make_stellar_discretization(star, + {.radius = radial->scales.radius, .geometry_order = 3}, + deformation::DomainDeformation{.surface = {.maximum_degree = 2}}, + serif::discretization::reconstruction::SurfacePower{.density_exponent = 4.0 / 3.0}); + auto state = serif::solver::seed::make_polytropic_state(star, finite_elements, *radial); + REQUIRE(state); + state->get()[0] = 0.08; + state->get()[6] = -0.015; + std::vector residuals; + for (int order : {12, 20, 28}) { + auto evaluator = backend::bind_stellar_model(star, finite_elements, *state, integration_order(order)); + const auto evaluated = evaluator.evaluate(*state); + REQUIRE(evaluated); + residuals.push_back(residual_copy(**evaluated)); + } + const auto check_functional = [&]() { + const int offset = static_cast(state->layout().template offset()); + const double coarse_error = std::abs(residuals[0][offset] - residuals[2][offset]); + const double fine_error = std::abs(residuals[1][offset] - residuals[2][offset]); + CAPTURE(offset, coarse_error, fine_error); + CHECK(fine_error <= 0.7 * coarse_error + 1e-11); + CHECK(fine_error < 1e-4); + }; + check_functional.template operator()(); + check_functional.template operator()(); +} diff --git a/tests/model/runtime.cpp b/tests/model/runtime.cpp new file mode 100644 index 00000000..7b86ec12 --- /dev/null +++ b/tests/model/runtime.cpp @@ -0,0 +1,385 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include "mfem/linalg/densemat.hpp" +#include "serif/backend/mfem/model/system.hpp" + +namespace { + namespace backend = serif::model::backend::mfem; + using Catch::Matchers::WithinAbs; + + /** @brief Make small independent matrices whose entries reveal coordinate permutations. */ + [[nodiscard]] backend::LinearizedBlock matrix(int rows, int columns, std::initializer_list entries) { + auto values = std::make_shared<::mfem::DenseMatrix>(rows, columns); + auto entry = entries.begin(); + for (int row = 0; row < rows; ++row) { + for (int column = 0; column < columns; ++column) { + (*values)(row, column) = *entry++; + } + } + return serif::operators::backend::mfem::linearized_block(std::shared_ptr(values)); + } + + /** @brief Compare all coordinate entries, independent of the implementation's block selection. */ + void check_vector(const ::mfem::Vector& actual, std::initializer_list expected) { + REQUIRE(actual.Size() == static_cast(expected.size())); + int index = 0; + for (const double value : expected) { + CHECK_THAT(actual[index++], WithinAbs(value, 1.0e-13)); + } + } +} // namespace + +TEST_CASE("Model runtime adds generated physics before replacing complete boundary rows", "[model][runtime][unit]") { + const auto accepted = backend::make_physical_system(4, 5, + {{0, {1.0, 2.0, 3.0}}, {1, {10.0, 20.0, 30.0}}}, + {{0, 2, matrix(3, 3, {1, 2, 3, 4, 5, 6, 7, 8, 9})}, + {1, 0, matrix(3, 2, {10, 11, 12, 13, 14, 15})}, + {0, 1, matrix(4, 1, {20, 21, 22, 23})}}, + {{{3, 1}, {300, 100}, {{2, matrix(2, 3, {2, 3, 4, 5, 6, 7})}}}, + {{0}, {400}, {{0, matrix(1, 1, {8})}}}}); + CHECK(accepted->residual() == std::vector{400, 100, 23, 300}); + const auto& jacobian = accepted->jacobian(); + REQUIRE(jacobian.has_assembly()); + CHECK_FALSE(jacobian.has_diagonal()); + const auto assembled = jacobian.assemble(); + const std::array, 4> expected{{{8, 0, 0, 0, 0}, {0, 0, 5, 6, 7}, {12, 35, 7, 8, 9}, {0, 0, 2, 3, 4}}}; + for (int row = 0; row < 4; ++row) { + for (int column = 0; column < 5; ++column) { + CHECK_THAT((*assembled)(row, column), WithinAbs(expected[row][column], 1.0e-14)); + } + } + ::mfem::Vector direction(5), dual(4), action, transpose; + for (int index = 0; index < 5; ++index) { + direction[index] = index + 1; + } + for (int index = 0; index < 4; ++index) { + dual[index] = 0.25 + index; + } + jacobian.action()->Mult(direction, action); + check_vector(action, {8, 74, 180, 38}); + jacobian.action()->MultTranspose(dual, transpose); + CHECK_THAT(dual * action, WithinAbs(direction * transpose, 1.0e-12)); + const auto selected = accepted->block(1, 2, 2, 2); + CHECK(selected.has_diagonal()); + CHECK(selected.diagonal() == std::vector{5, 8}); + ::mfem::Vector local(2), selected_action; + local[0] = 2; + local[1] = 3; + selected.action()->Mult(local, selected_action); + check_vector(selected_action, {28, 38}); +} + +TEST_CASE("Model selected blocks retain owners and skip unrelated equation actions", "[model][runtime][unit]") { + auto calls = std::make_shared(0); + auto owner = std::make_shared(37); + std::weak_ptr lifetime = owner; + const auto unrelated = serif::operators::backend::mfem::linearized_action(1, 1, [calls](const auto& input, auto& output) { + ++*calls; + output[0] = input[0]; + }, [calls](const auto& input, auto& output) { + ++*calls; + output[0] = input[0]; + }); + auto accepted = backend::make_physical_system(3, 3, {}, {{0, 0, matrix(2, 2, {2, 1, -1, 3})}, {2, 2, unrelated}}, {}, owner); + std::optional selected = accepted->block(0, 2, 0, 2); + owner.reset(); + accepted.reset(); + CHECK_FALSE(lifetime.expired()); + ::mfem::Vector input(2), output; + input = 1.0; + selected->action()->Mult(input, output); + check_vector(output, {3, 2}); + CHECK(*calls == 0); + CHECK(selected->has_diagonal()); + CHECK(selected->has_assembly()); + CHECK(selected->diagonal() == std::vector{2, 3}); + selected.reset(); + CHECK(lifetime.expired()); +} + +TEST_CASE("Grouped model blocks preserve ordered ranges and boundary rows with one action per child", "[model][runtime][unit]") { + const auto original = matrix(4, 5, {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20}); + auto calls = std::make_shared>(); + const auto action = serif::operators::backend::mfem::linearized_action(4, 5, [calls, original](const auto& input, auto& output) { + ++(*calls)[0]; + original.action()->Mult(input, output); + }, [calls, original](const auto& input, auto& output) { + ++(*calls)[1]; + original.action()->MultTranspose(input, output); + }); + const backend::LinearizedBlock counted(action.action(), {}, [original]() { + return original.assemble(); + }); + auto unrelated_calls = std::make_shared(0); + const auto unrelated_action = [unrelated_calls](const auto& input, auto& output) { + ++*unrelated_calls; + output = input; + }; + auto owner = std::make_shared(42); + std::weak_ptr lifetime = owner; + auto accepted = backend::make_physical_system(5, 6, {}, {{0, 0, counted}, {4, 5, serif::operators::backend::mfem::linearized_action(1, 1, unrelated_action, unrelated_action)}}, + {{{1, 3}, {0.0, 0.0}, {{0, matrix(2, 5, {101, 102, 103, 104, 105, 106, 107, 108, 109, 110})}}}}, owner); + const std::array rows{backend::CoordinateRange{2, 2}, backend::CoordinateRange{0, 2}}; + const std::array columns{backend::CoordinateRange{3, 2}, backend::CoordinateRange{0, 2}}; + std::optional selected = accepted->block(rows, columns); + accepted.reset(); + owner.reset(); + CHECK_FALSE(lifetime.expired()); + + const std::array, 4> expected{{{14, 15, 11, 12}, {109, 110, 106, 107}, {4, 5, 1, 2}, {104, 105, 101, 102}}}; + const auto assembled = selected->assemble(); + for (int row = 0; row < 4; ++row) { + for (int column = 0; column < 4; ++column) { + CHECK((*assembled)(row, column) == expected[row][column]); + } + } + CHECK(selected->diagonal() == std::vector{14, 110, 1, 102}); + CHECK(*calls == std::array{0, 0}); + ::mfem::Vector input(4), dual(4), result, transpose; + for (int index = 0; index < 4; ++index) { + input[index] = index + 1; + dual[index] = index - 0.5; + } + selected->action()->Mult(input, result); + selected->action()->MultTranspose(dual, transpose); + check_vector(result, {125, 1075, 25, 1025}); + CHECK_THAT(result * dual, WithinAbs(input * transpose, 1.0e-12)); + CHECK(*calls == std::array{1, 1}); + CHECK(*unrelated_calls == 0); + selected.reset(); + CHECK(lifetime.expired()); +} + +TEST_CASE("Grouped model selections retain native diagonals and exact transposes for repeated ranges", "[model][runtime][unit]") { + const auto original = matrix(3, 3, {1, 2, 3, 4, 5, 6, 7, 8, 9}); + const backend::LinearizedBlock diagonal_only(original.action(), [original]() { + return original.diagonal(); + }); + const auto accepted = backend::make_physical_system(3, 3, {}, {{0, 0, diagonal_only}}); + const std::array ranges{backend::CoordinateRange{2, 1}, backend::CoordinateRange{0, 2}, backend::CoordinateRange{2, 1}}; + const auto selected = accepted->block(ranges, ranges); + CHECK_FALSE(selected.has_assembly()); + REQUIRE(selected.has_diagonal()); + CHECK(selected.diagonal() == std::vector{9, 1, 5, 9}); + ::mfem::Vector input(4), dual(4), output, transpose; + for (int index = 0; index < 4; ++index) { + input[index] = index + 1; + dual[index] = 0.5 * index - 0.3; + } + selected.action()->Mult(input, output); + selected.action()->MultTranspose(dual, transpose); + check_vector(output, {83, 23, 53, 83}); + CHECK_THAT(output * dual, WithinAbs(input * transpose, 1.0e-12)); + + const auto empty = accepted->block(std::span{}, ranges); + CHECK(empty.action()->Height() == 0); + CHECK(empty.action()->Width() == 4); + empty.action()->Mult(input, output); + CHECK(output.Size() == 0); +} + +TEST_CASE("Model runtime actions permit both shifted buffer overlaps", "[model][runtime][unit]") { + const auto accepted = backend::make_physical_system(2, 2, {}, {{0, 0, matrix(2, 2, {2, 1, -1, 3})}}); + for (const bool transpose : {false, true}) { + for (const bool forward_overlap : {false, true}) { + std::array storage{}; + ::mfem::Vector input(storage.data() + (forward_overlap ? 0 : 1), 2); + ::mfem::Vector output(storage.data() + (forward_overlap ? 1 : 0), 2); + input[0] = 4; + input[1] = 5; + if (transpose) { + accepted->jacobian().action()->MultTranspose(input, output); + check_vector(output, {3, 19}); + } + else { + accepted->jacobian().action()->Mult(input, output); + check_vector(output, {13, 11}); + } + } + } +} + +TEST_CASE("Model runtime rejects malformed contribution and replacement coordinates", "[model][runtime][unit]") { + const auto unit = matrix(1, 1, {1}); + CHECK_THROWS(backend::make_physical_system(-1, 1, {}, {})); + CHECK_THROWS(backend::make_physical_system(1, 1, {{1, {1}}}, {})); + CHECK_THROWS(backend::make_physical_system(1, 1, {}, {{0, 1, unit}})); + CHECK_THROWS(backend::make_physical_system(1, 1, {}, {}, {{{0, 0}, {1, 2}, {}}})); + CHECK_THROWS(backend::make_physical_system(1, 1, {}, {}, {{{0}, {}, {}}})); + CHECK_THROWS(backend::make_physical_system(1, 1, {}, {}, {{{1}, {0}, {}}})); + CHECK_THROWS(backend::make_physical_system(2, 1, {}, {}, {{{0, 1}, {0, 0}, {{0, unit}}}})); + CHECK_THROWS(backend::make_physical_system(1, 1, {{0, {std::numeric_limits::quiet_NaN()}}}, {})); + CHECK_THROWS(backend::make_physical_system(1, 1, {}, {}, {{{0}, {std::numeric_limits::infinity()}, {}}})); + const auto accepted = backend::make_physical_system(1, 2, {}, {}); + CHECK_THROWS(accepted->block(0, 2, 0, 1)); + const std::array row{backend::CoordinateRange{0, 1}}; + for (const backend::CoordinateRange invalid : {backend::CoordinateRange{-1, 1}, {0, -1}, {2, 1}, {std::numeric_limits::max(), 1}}) { + CHECK_THROWS(accepted->block(row, std::array{invalid})); + } + const auto wide = backend::make_physical_system(0, std::numeric_limits::max(), {}, {}); + CHECK_THROWS_WITH(wide->block(std::span{}, std::array{backend::CoordinateRange{0, std::numeric_limits::max()}, backend::CoordinateRange{0, 1}}), + Catch::Matchers::ContainsSubstring("INVALID_DOF_COUNT")); + ::mfem::Vector wrong(3), result(1); + wrong = 0.0; + result = 19; + CHECK_THROWS(accepted->jacobian().action()->Mult(wrong, result)); + CHECK(result[0] == 19); +} + +TEST_CASE("Model action composition preserves forward transpose and native assembly", "[model][runtime][unit]") { + const auto left = matrix(2, 3, {1, 2, 3, 4, 5, 6}); + const auto right = matrix(3, 2, {2, -1, 3, 2, 1, 4}); + const auto product = serif::operators::backend::mfem::compose_blocks(left, right); + const auto result = serif::operators::backend::mfem::sum_blocks(2, 2, {product, serif::operators::backend::mfem::transpose_block(product)}); + REQUIRE(result.has_assembly()); + const auto assembled = result.assemble(); + CHECK((*assembled)(0, 0) == 22); + CHECK((*assembled)(0, 1) == 44); + CHECK((*assembled)(1, 0) == 44); + CHECK((*assembled)(1, 1) == 60); + CHECK_THROWS(serif::operators::backend::mfem::compose_blocks(left, left)); + CHECK_THROWS(serif::operators::backend::mfem::sum_blocks(1, 1, {left})); +} + +TEST_CASE("Model native diagonals do not probe actions or require sparse assembly", "[model][runtime][unit]") { + auto actions = std::make_shared(0); + auto diagonals = std::make_shared(0); + const auto action = serif::operators::backend::mfem::linearized_action(2, 2, [actions](const auto& input, auto& output) { + ++*actions; + output = input; + }, [actions](const auto& input, auto& output) { + ++*actions; + output = input; + }); + const backend::LinearizedBlock block(action.action(), [diagonals]() { + ++*diagonals; + return std::vector{1, 1}; + }); + const auto accepted = backend::make_physical_system(2, 2, {}, {{0, 0, block}, {0, 0, block}}); + REQUIRE(accepted->jacobian().has_diagonal()); + CHECK_FALSE(accepted->jacobian().has_assembly()); + CHECK(accepted->jacobian().diagonal() == std::vector{2, 2}); + CHECK(*actions == 0); + CHECK(*diagonals == 2); +} + +TEST_CASE("Model composition and selection reject malformed physical block callbacks", "[model][runtime][unit]") { + const auto original = matrix(2, 2, {1, 0, 0, 1}); + const auto check_rejection = [&original](backend::LinearizedBlock invalid) { + const auto accepted = backend::make_physical_system(2, 2, {}, {{0, 0, invalid}}); + CHECK_THROWS_WITH(accepted->jacobian().assemble(), Catch::Matchers::ContainsSubstring("MATRIX_SHAPE_ERROR")); + CHECK_THROWS_WITH(serif::operators::backend::mfem::transpose_block(invalid).assemble(), Catch::Matchers::ContainsSubstring("MATRIX_SHAPE_ERROR")); + CHECK_THROWS_WITH(serif::operators::backend::mfem::compose_blocks(original, invalid).assemble(), Catch::Matchers::ContainsSubstring("MATRIX_SHAPE_ERROR")); + CHECK_THROWS_WITH(serif::operators::backend::mfem::compose_blocks(invalid, original).assemble(), Catch::Matchers::ContainsSubstring("MATRIX_SHAPE_ERROR")); + CHECK_THROWS_WITH(serif::operators::backend::mfem::sum_blocks(2, 2, {original, invalid}).assemble(), Catch::Matchers::ContainsSubstring("MATRIX_SHAPE_ERROR")); + }; + check_rejection(backend::LinearizedBlock(original.action(), {}, []() { + return std::shared_ptr{}; + })); + check_rejection(backend::LinearizedBlock(original.action(), {}, []() { + return std::make_shared(2); + })); + check_rejection(backend::LinearizedBlock(original.action(), {}, []() { + return matrix(1, 2, {1, 0}).assemble(); + })); + const backend::LinearizedBlock invalid_diagonal(original.action(), []() { + return std::vector{1}; + }); + const auto accepted = backend::make_physical_system(2, 2, {}, {{0, 0, invalid_diagonal}}); + CHECK_THROWS_WITH(accepted->jacobian().diagonal(), Catch::Matchers::ContainsSubstring("MATRIX_SHAPE_ERROR")); + CHECK_THROWS_WITH(serif::operators::backend::mfem::transpose_block(invalid_diagonal).diagonal(), Catch::Matchers::ContainsSubstring("MATRIX_SHAPE_ERROR")); + CHECK_THROWS_WITH(serif::operators::backend::mfem::sum_blocks(2, 2, {original, invalid_diagonal}).diagonal(), Catch::Matchers::ContainsSubstring("MATRIX_SHAPE_ERROR")); +} + +TEST_CASE("Model invalid callback outputs never overwrite caller storage", "[model][runtime][unit]") { + const auto transpose = [](const auto& input, auto& output) { + output = input; + }; + const auto wrong_size = serif::operators::backend::mfem::linearized_action(1, 1, [](const auto&, auto& output) { + output.SetSize(0); + }, transpose); + const auto nonfinite = serif::operators::backend::mfem::linearized_action(1, 1, [](const auto&, auto& output) { + output[0] = std::numeric_limits::infinity(); + }, transpose); + ::mfem::Vector input(1), output(1); + input = 1.0; + output = 42.0; + CHECK_THROWS(wrong_size.action()->Mult(input, output)); + CHECK(output[0] == 42.0); + CHECK_THROWS(nonfinite.action()->Mult(input, output)); + CHECK(output[0] == 42.0); + input = std::numeric_limits::quiet_NaN(); + CHECK_THROWS(nonfinite.action()->MultTranspose(input, output)); + CHECK(output[0] == 42.0); +} + +TEST_CASE("Complete model actions traverse each equation once while selected blocks keep their native representation", "[model][runtime][unit]") { + auto calls = std::make_shared>(); + const auto counted = [calls](backend::LinearizedBlock block, const int index) { + const auto action = serif::operators::backend::mfem::linearized_action(block.action()->Height(), block.action()->Width(), + [calls, block, index](const auto& input, auto& output) { + ++(*calls)[index]; + block.action()->Mult(input, output); + }, + [calls, block, index](const auto& input, auto& output) { + ++(*calls)[index]; + block.action()->MultTranspose(input, output); + }); + return backend::LinearizedBlock(action.action(), {}, [block]() { + return block.assemble(); + }); + }; + const auto left = counted(matrix(3, 2, {1, 2, 3, 4, 5, 6}), 0); + const auto right = counted(matrix(3, 2, {7, 8, 9, 10, 11, 12}), 1); + const auto complete_matrix = matrix(3, 4, {1, 2, 7, 8, 3, 4, 9, 10, 5, 6, 11, 12}); + const auto complete = serif::operators::backend::mfem::linearized_action(3, 4, [calls, complete_matrix](const auto& input, auto& output) { + ++(*calls)[2]; + complete_matrix.action()->Mult(input, output); + }, [calls, complete_matrix](const auto& input, auto& output) { + ++(*calls)[3]; + complete_matrix.action()->MultTranspose(input, output); + }); + const auto accepted = backend::make_physical_system(3, 4, {}, {{0, 0, left}, {0, 2, right}}, + {{{1}, {0.0}, {{0, matrix(1, 4, {2, 3, 4, 5})}}}}, {}, + std::vector{{0, 0, complete}}); + mfem::Vector direction(4), dual(3), action, transpose; + for (int column = 0; column < 4; ++column) { + direction[column] = column + 1; + } + dual[0] = 0.2; + dual[1] = -0.7; + dual[2] = 0.4; + accepted->jacobian().action()->Mult(direction, action); + accepted->jacobian().action()->MultTranspose(dual, transpose); + check_vector(action, {58, 40, 98}); + CHECK_THAT(action * dual, WithinAbs(direction * transpose, 1.0e-12)); + CHECK(*calls == std::array{0, 0, 1, 1}); + REQUIRE(accepted->jacobian().has_assembly()); + const auto assembled = accepted->jacobian().assemble(); + mfem::Vector assembled_action(3), assembled_transpose(4); + assembled->Mult(direction, assembled_action); + assembled->MultTranspose(dual, assembled_transpose); + assembled_action -= action; + assembled_transpose -= transpose; + CHECK(assembled_action.Norml2() == 0.0); + CHECK(assembled_transpose.Norml2() < 1.0e-13); + CHECK(*calls == std::array{0, 0, 1, 1}); + + const auto selected = accepted->block(0, 3, 0, 2); + mfem::Vector local(2), selected_action; + local[0] = 1.0; + local[1] = 2.0; + selected.action()->Mult(local, selected_action); + check_vector(selected_action, {5, 8, 17}); + CHECK(*calls == std::array{1, 0, 1, 1}); + CHECK(accepted->block(0, 2, 0, 2).diagonal() == std::vector{1, 3}); + CHECK(*calls == std::array{1, 0, 1, 1}); + CHECK_THROWS(backend::make_physical_system(2, 4, {}, {}, {}, {}, std::vector{{0, 0, complete}})); + CHECK_THROWS(backend::make_physical_system(3, 3, {}, {}, {}, {}, std::vector{{0, 0, complete}})); +} diff --git a/tests/model/runtime_deformation.cpp b/tests/model/runtime_deformation.cpp new file mode 100644 index 00000000..590fd45e --- /dev/null +++ b/tests/model/runtime_deformation.cpp @@ -0,0 +1,116 @@ +#include +#include +#include +#include +#include +#include +#include "mfem/linalg/densemat.hpp" +#include "serif/backend/mfem/model/deformation.hpp" +#include "serif/backend/mfem/model/system.hpp" + +namespace { + namespace deformation = serif::geometry::deformation; + namespace backend = serif::model::backend::mfem; + using Catch::Matchers::WithinAbs; + + /** @brief A nonlinear shape with independent mixed curvature and deliberately move-only scratch. */ + struct Shape { + using InputSpace = deformation::spaces::ShapeParameters; + using OutputSpace = deformation::spaces::VolumeDisplacement; + using Workspace = std::unique_ptr; + static constexpr bool is_linear = false; + + /** @brief Two independent shape coefficients determine three displacement coefficients. */ + [[nodiscard]] std::size_t input_size() const { + return 2; + } + /** @brief Keep the physical force dimension different from the shape dimension. */ + [[nodiscard]] std::size_t output_size() const { + return 3; + } + /** @brief Ensure model adapters do not impose an undocumented copyable-workspace requirement. */ + [[nodiscard]] Workspace make_workspace() const { + return std::make_unique(0); + } + + /** @brief Evaluate u(q) = (q0^2 + q1, q0*q1, q1^2). */ + [[nodiscard]] deformation::DeformationStatus build_displacement(std::span q, std::span u, Workspace&) const { + u[0] = q[0] * q[0] + q[1]; + u[1] = q[0] * q[1]; + u[2] = q[1] * q[1]; + return {}; + } + + /** @brief Apply the analytic displacement derivative. */ + [[nodiscard]] deformation::DeformationStatus apply_jacobian(std::span q, std::span direction, std::span output, Workspace&) const { + output[0] = 2.0 * q[0] * direction[0] + direction[1]; + output[1] = q[1] * direction[0] + q[0] * direction[1]; + output[2] = 2.0 * q[1] * direction[1]; + return {}; + } + + /** @brief Pull a physical force back into the two shape coordinates. */ + [[nodiscard]] deformation::DeformationStatus apply_jacobian_transpose(std::span q, std::span force, std::span output, Workspace&) const { + output[0] = 2.0 * q[0] * force[0] + q[1] * force[1]; + output[1] = force[0] + q[0] * force[1] + 2.0 * q[1] * force[2]; + return {}; + } + + /** @brief Retain both pure and mixed second derivatives in the shape Newton action. */ + [[nodiscard]] deformation::DeformationStatus apply_pullback_derivative(std::span, std::span direction, std::span force, std::span output, Workspace&) const { + output[0] = 2.0 * force[0] * direction[0] + force[1] * direction[1]; + output[1] = force[1] * direction[0] + 2.0 * force[2] * direction[1]; + return {}; + } + }; + + /** @brief Use a nonsymmetric physical force derivative so the full transpose cannot be replaced by the forward action. */ + [[nodiscard]] std::shared_ptr force_derivative() { + auto matrix = std::make_shared<::mfem::DenseMatrix>(3); + const std::array, 3> rows{{{2, -1, 0.5}, {0.2, 3, -0.7}, {1, 0.3, 4}}}; + for (int row = 0; row < 3; ++row) { + for (int column = 0; column < 3; ++column) { + (*matrix)(row, column) = rows[row][column]; + } + } + return matrix; + } + + /** @brief Assemble E^T force and E^T K E using only the public model adapters. */ + [[nodiscard]] std::shared_ptr evaluate(const std::array& coordinates) { + const auto map = std::make_shared(); + const auto state = std::make_shared>(deformation::DeformationLinearization{map, {coordinates[0], coordinates[1]}}); + auto workspace = map->make_workspace(); + ::mfem::Vector displacement(3), force(3), residual; + REQUIRE(map->build_displacement(coordinates, std::span(displacement.GetData(), 3), workspace)); + const auto matrix = force_derivative(); + matrix->Mult(displacement, force); + force[0] += 0.7; + force[1] -= 0.2; + force[2] += 0.5; + const auto tangent = backend::deformation_tangent(state); + const auto transpose = serif::operators::backend::mfem::transpose_block(tangent); + transpose.action()->Mult(force, residual); + const auto force_block = serif::operators::backend::mfem::linearized_block(matrix); + const auto material = serif::operators::backend::mfem::compose_blocks(transpose, serif::operators::backend::mfem::compose_blocks(force_block, tangent)); + return backend::make_physical_system(2, 2, {{0, std::vector(residual.GetData(), residual.GetData() + 2)}}, {{0, 0, material}}); + } +} // namespace + +TEST_CASE("Model shape tangent composes with a nonsymmetric force derivative as an exact transpose pair", "[model][runtime][deformation][unit]") { + const std::array coordinates{0.4, -0.3}; + const auto accepted = evaluate(coordinates); + ::mfem::Vector direction(2), action, dual(2), transpose; + direction[0] = 0.7; + direction[1] = -0.2; + dual[0] = -0.8; + dual[1] = 1.3; + accepted->jacobian().action()->Mult(direction, action); + accepted->jacobian().action()->MultTranspose(dual, transpose); + CHECK_THAT(action * dual, WithinAbs(direction * transpose, 2.0e-13)); + ::mfem::Vector repeated; + accepted->jacobian().action()->Mult(direction, repeated); + for (int row = 0; row < 2; ++row) { + CHECK(repeated[row] == action[row]); + } +} diff --git a/tests/model/runtime_volume.cpp b/tests/model/runtime_volume.cpp new file mode 100644 index 00000000..7b8cb75d --- /dev/null +++ b/tests/model/runtime_volume.cpp @@ -0,0 +1,82 @@ +#include +#include +#include +#include "serif/backend/mfem/model/discretization.hpp" +#include "serif/backend/mfem/model/inputs.hpp" +#include "serif/backend/mfem/model/system.hpp" +#include "serif/backend/mfem/model/volume.hpp" +#include "serif/tests/equations/fixture.hpp" + +namespace { + namespace backend = serif::model::backend::mfem; + namespace equation_tests = serif::tests::equations; + namespace execution = serif::operators::backend::mfem; + using Catch::Matchers::WithinAbs; +} // namespace + +TEST_CASE("Model and standalone volume inputs reject missing compactification spaces before access", "[model][runtime][equations][unit]") { + namespace dependencies = serif::utils::dependencies; + namespace blocks = serif::discretization::blocks; + using Registry = serif::equations::EquationRegistry; + + auto fixture = std::make_shared(); + const auto density = fixture->density(), enthalpy = fixture->enthalpy(); + const auto coefficients = fixture->state(density, enthalpy); + const ::mfem::GridFunction unbound_compactification; + const auto bindings = dependencies::make_dependency_context( + dependencies::borrow(fixture->density_binding), + dependencies::borrow<::serif::tests::equations::Enthalpy>(fixture->enthalpy_binding), + dependencies::borrow(fixture->displacement_binding)); + const auto discretization = backend::make_stellar_discretization(fixture, bindings, unbound_compactification, fixture->mapper); + CHECK_THROWS_AS(backend::make_model_inputs(discretization, coefficients), std::logic_error); + CHECK_THROWS_AS(execution::make_volume_inputs(discretization.equation_discretization(), coefficients), std::logic_error); +} + +TEST_CASE("Model volume geometry adapters retain the accepted equation and complete coordinate transpose", "[model][runtime][equations][integration]") { + auto fixture = std::make_shared(); + std::weak_ptr lifetime = fixture; + const auto density = fixture->density(); + auto enthalpy = fixture->enthalpy(); + const auto displacement = fixture->displacement(0.35); + const auto direction = fixture->displacement(0.23); + const auto dual = equation_tests::deterministic(density.Size(), 0.47); + std::shared_ptr system; + ::mfem::Vector expected_action, expected_transpose; { + equation_tests::PreparedClosure prepared(equation_tests::Closure{serif::eos::models::Polytrope{3.0, 1.5}}, fixture->density_binding, fixture->displacement_binding, fixture->compactification, fixture->mapper); + prepared.prepare(fixture->state(density, enthalpy), displacement, fixture->workspace); + const auto accepted = prepared.cache().share(); + const auto geometry = backend::volume_geometry_block(accepted); + system = backend::make_physical_system(density.Size(), displacement.Size(), {{0, backend::volume_residual_values(accepted)}}, {{0, 0, geometry}}, {}, fixture); + geometry.action()->Mult(direction, expected_action); + geometry.action()->MultTranspose(dual, expected_transpose); + CHECK_THAT(dual * expected_action, WithinAbs(direction * expected_transpose, 2.0e-12)); + REQUIRE(expected_action.Norml2() > 0.0); + + constexpr double step = 1.0e-6; + ::mfem::Vector perturbed(displacement); + perturbed.Add(step, direction); + prepared.prepare(fixture->state(density, enthalpy), perturbed, fixture->workspace); + ::mfem::Vector difference(prepared.data().residual); + perturbed = displacement; + perturbed.Add(-step, direction); + prepared.prepare(fixture->state(density, enthalpy), perturbed, fixture->workspace); + difference -= prepared.data().residual; + difference *= 0.5 / step; + CHECK(equation_tests::relative_error(expected_action, difference) < 2.0e-8); + + enthalpy = -1.0; + CHECK_FALSE(prepared.try_prepare(fixture->state(density, enthalpy), displacement, fixture->workspace)); + ::mfem::Vector repeated; + geometry.action()->Mult(direction, repeated); + CHECK(equation_tests::relative_error(repeated, expected_action) == 0.0); + } + fixture.reset(); + CHECK_FALSE(lifetime.expired()); + ::mfem::Vector action, transpose; + system->jacobian().action()->Mult(direction, action); + system->jacobian().action()->MultTranspose(dual, transpose); + CHECK(equation_tests::relative_error(action, expected_action) == 0.0); + CHECK(equation_tests::relative_error(transpose, expected_transpose) == 0.0); + system.reset(); + CHECK(lifetime.expired()); +} diff --git a/tests/model/scalar_row.cpp b/tests/model/scalar_row.cpp new file mode 100644 index 00000000..465500c8 --- /dev/null +++ b/tests/model/scalar_row.cpp @@ -0,0 +1,89 @@ +#include +#include +#include +#include +#include +#include + +#include "mfem/linalg/densemat.hpp" +#include "serif/backend/mfem/model/scalar_row.hpp" +#include "serif/backend/mfem/model/system.hpp" + +TEST_CASE("A scalar residual row retains its exact functional without retaining or repeating the source action", "[model][scalar_row][unit]") { + namespace backend = serif::model::backend::mfem; + namespace execution = serif::operators::backend::mfem; + int forward_calls = 0, transpose_calls = 0; + std::optional cached; + std::weak_ptr lifetime; + { + auto owner = std::make_shared(1); + lifetime = owner; + const auto source = execution::linearized_action(1, 3, [&, owner](const auto& input, auto& output) { + ++forward_calls; + output[0] = 2.0 * input[0] - 3.0 * input[1] + 5.0 * input[2]; + }, [&, owner](const auto& input, auto& output) { + ++transpose_calls; + output[0] = 2.0 * input[0]; + output[1] = -3.0 * input[0]; + output[2] = 5.0 * input[0]; + }); + auto result = backend::cache_scalar_row(source); + REQUIRE(result); + cached.emplace(std::move(*result)); + } + CHECK(lifetime.expired()); + CHECK(forward_calls == 0); + CHECK(transpose_calls == 1); + const auto system = backend::make_physical_system(1, 3, {}, {{0, 0, *cached}}); + mfem::Vector direction(3), action, transpose, dual(1); + direction[0] = 7.0; + direction[1] = 11.0; + direction[2] = 13.0; + dual[0] = 0.25; + system->jacobian().action()->Mult(direction, action); + CHECK(action[0] == Catch::Approx(46.0)); + system->jacobian().action()->MultTranspose(dual, transpose); + CHECK(transpose[0] == Catch::Approx(0.5)); + CHECK(transpose[1] == Catch::Approx(-0.75)); + CHECK(transpose[2] == Catch::Approx(1.25)); + const auto selected = system->block(0, 1, 1, 2); + mfem::Vector selected_direction(direction.GetData() + 1, 2); + selected.action()->Mult(selected_direction, action); + CHECK(action[0] == Catch::Approx(32.0)); + const auto matrix = selected.assemble(); + CHECK((*matrix)(0, 0) == Catch::Approx(-3.0)); + CHECK((*matrix)(0, 1) == Catch::Approx(5.0)); + CHECK(forward_calls == 0); + CHECK(transpose_calls == 1); +} + +TEST_CASE("Scalar row caching rejects a spatial residual without invoking its transpose", "[model][scalar_row][unit]") { + namespace execution = serif::operators::backend::mfem; + int calls = 0; + const auto source = execution::linearized_action(2, 3, [](const auto&, auto&) {}, [&](const auto&, auto&) { + ++calls; + }); + CHECK_THROWS(serif::model::backend::mfem::cache_scalar_row(source)); + CHECK(calls == 0); +} + +TEST_CASE("Scalar row caching preserves numerical failure classification for nonfinite derivatives", "[model][scalar_row][unit]") { + namespace execution = serif::operators::backend::mfem; + for (const double value : {std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity()}) { + auto matrix = std::make_shared(1, 1); + (*matrix)(0, 0) = value; + // A raw matrix deliberately bypasses the checked callback adapter to exercise the cache guard. + const auto source = execution::linearized_block(std::shared_ptr(matrix)); + const auto raw_result = serif::model::backend::mfem::cache_scalar_row(source); + REQUIRE_FALSE(raw_result); + CHECK(raw_result.error().reason == execution::PreparationRejectionReason::NON_FINITE); + const auto checked = execution::linearized_action(1, 1, [source](const auto& input, auto& output) { + source.action()->Mult(input, output); + }, [source](const auto& input, auto& output) { + source.action()->MultTranspose(input, output); + }); + const auto checked_result = serif::model::backend::mfem::cache_scalar_row(checked); + REQUIRE_FALSE(checked_result); + CHECK(checked_result.error().reason == execution::PreparationRejectionReason::NON_FINITE); + } +} diff --git a/tests/model/solver_services.cpp b/tests/model/solver_services.cpp new file mode 100644 index 00000000..c232980c --- /dev/null +++ b/tests/model/solver_services.cpp @@ -0,0 +1,404 @@ +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "mfem/linalg/densemat.hpp" +#include "mfem/general/communication.hpp" +#include "serif/tests/equations/test_equations.hpp" +#include "serif/backend/mfem/model/equilibrium.hpp" +#include "serif/backend/mfem/solver/seed/state.hpp" +#include "serif/solver/seed/polytrope.hpp" +#include "serif/tests/solver/stellar_fixture.hpp" +#include "serif/utils/dependencies/computation_cache.hpp" +#include "serif/utils/dependencies/timing.hpp" +#include "serif/backend/mfem/operators/actions.hpp" +#include "serif/backend/mfem/model/preconditioned_system.hpp" +#include "serif/backend/mfem/model/computations/physical_blocks.hpp" +#include "serif/backend/mfem/model/computations/solver_layout.hpp" +#include "serif/solver/normalization/normalization.hpp" +#include "serif/solver/preconditioner/preconditioner.hpp" + +namespace { + namespace model = serif::model; + namespace backend = model::backend::mfem; + namespace dependencies = serif::utils::dependencies; + namespace blocks = serif::discretization::blocks; + namespace normalization = serif::solver::normalization; + namespace preconditioner = serif::solver::preconditioner; + using Density = blocks::density::mass; + using Enthalpy = ::serif::tests::equations::Enthalpy; + using Layout = blocks::SystemLayout, dependencies::KeyList>; + using DensityBlock = serif::operators::PhysicalBlock, dependencies::KeyList>; + using ReorderedBlock = serif::operators::PhysicalBlock, dependencies::KeyList>; + using Rules = dependencies::Bindings, dependencies::Binding>; + using NormalizationPlan = normalization::NormalizationPlan; + struct DensityResponse { + }; + struct EvaluationService {}; + struct LinearService {}; + + /** @brief Supply a nonsymmetric physical Jacobian with an independently known two-coordinate inverse. */ + [[nodiscard]] std::shared_ptr small_physical_system() { + auto matrix = std::make_shared<::mfem::DenseMatrix>(2); + (*matrix)(0, 0) = 4.0; + (*matrix)(0, 1) = 1.0; + (*matrix)(1, 0) = 2.0; + (*matrix)(1, 1) = 3.0; + const auto block = serif::operators::backend::mfem::linearized_block(std::shared_ptr(matrix)); + return backend::make_physical_system(2, 2, {{.row_offset = 0, .values = {9.0, 7.0}}}, + {{.row_offset = 0, .column_offset = 0, .block = block}}); + } + + /** @brief Keep inverse ownership independent from the physical system so both lifetimes are tested. */ + [[nodiscard]] std::shared_ptr small_physical_inverse() { + auto matrix = std::make_shared<::mfem::DenseMatrix>(2); + (*matrix)(0, 0) = 0.3; + (*matrix)(0, 1) = -0.1; + (*matrix)(1, 0) = -0.2; + (*matrix)(1, 1) = 0.4; + return matrix; + } +} + +TEST_CASE("Prepared stellar linear systems retain matching physical and normalized inverse owners", "[unit][model][preconditioner][normalization][solver_services]") { + const auto check_coordinates = []() { + using System = std::conditional_t; + using Prepared = backend::PreconditionedSystem; + std::weak_ptr physical_owner; + std::weak_ptr inverse_owner; + std::shared_ptr frozen; + { + const auto physical = small_physical_system(); + const auto inverse = small_physical_inverse(); + physical_owner = physical; + inverse_owner = inverse; + const auto system = [&]() { + if constexpr (Normalize) { + const auto coordinates = normalization::make_diagonal_normalization({2.0, 5.0}, {3.0, 7.0}); + const auto scales = normalization::prepare_stellar_reference_scales( + serif::dimensions::MassValue{2.0}, serif::dimensions::LengthValue{3.0}); + REQUIRE(coordinates); + REQUIRE(scales); + const auto normalized = backend::make_normalized_system(physical, *coordinates, *scales); + REQUIRE(normalized); + return *normalized; + } else { + return physical; + } + }(); + frozen = std::make_shared(system, preconditioner::backend::mfem::Inverse{.action = inverse}); + } + REQUIRE_FALSE(physical_owner.expired()); + REQUIRE_FALSE(inverse_owner.expired()); + CHECK(frozen->contract() == preconditioner::ApplicationContract::fixed_linear); + const ::mfem::Operator& jacobian = [&]() -> const ::mfem::Operator& { + if constexpr (Normalize) { + return frozen->system()->jacobian(); + } else { + return *frozen->system()->jacobian().action(); + } + }(); + CHECK_NOTHROW(frozen->preconditioner().SetOperator(jacobian)); + ::mfem::Vector right_hand_side(2), correction(2), reconstructed(2); + right_hand_side[0] = Normalize ? 27.0 : 9.0; + right_hand_side[1] = Normalize ? 49.0 : 7.0; + correction = 123.0; + frozen->preconditioner().Mult(right_hand_side, correction); + CHECK(correction[0] == Catch::Approx(Normalize ? 4.0 : 2.0)); + CHECK(correction[1] == Catch::Approx(Normalize ? 5.0 : 1.0)); + jacobian.Mult(correction, reconstructed); + reconstructed -= right_hand_side; + CHECK(reconstructed.Norml2() < 1.0e-13); + + // Equal dimensions and identical entries do not establish a matching preparation. + const auto unrelated = small_physical_system(); + CHECK_THROWS(frozen->preconditioner().SetOperator(*unrelated->jacobian().action())); + if constexpr (Normalize) { + const auto replacement = backend::make_normalized_system(unrelated, frozen->system()->coordinates(), frozen->system()->reference_scales()); + REQUIRE(replacement); + CHECK_THROWS(frozen->preconditioner().SetOperator((*replacement)->jacobian())); + } + CHECK_NOTHROW(frozen->preconditioner().SetOperator(jacobian)); + correction = -91.0; + frozen->preconditioner().Mult(right_hand_side, correction); + CHECK(correction[0] == Catch::Approx(Normalize ? 4.0 : 2.0)); + CHECK(correction[1] == Catch::Approx(Normalize ? 5.0 : 1.0)); + + right_hand_side[0] = 9.0; + right_hand_side[1] = 7.0; + frozen->physical_preconditioner().Mult(right_hand_side, correction); + CHECK(correction[0] == Catch::Approx(2.0)); + CHECK(correction[1] == Catch::Approx(1.0)); + frozen.reset(); + CHECK(physical_owner.expired()); + CHECK(inverse_owner.expired()); + }; + SECTION("Physical coordinates") { + check_coordinates.template operator()(); + } + SECTION("Nonunit normalized coordinates") { + check_coordinates.template operator()(); + } +} + +TEST_CASE("Prepared stellar systems preserve application contracts and reject incompatible owners", "[unit][model][preconditioner][solver_services]") { + using Prepared = backend::PreconditionedSystem; + const preconditioner::backend::mfem::Inverse inverse{.action = small_physical_inverse(), .contract = preconditioner::ApplicationContract::variable}; + const auto physical = small_physical_system(); + const Prepared prepared(physical, inverse); + CHECK(prepared.contract() == preconditioner::ApplicationContract::variable); + CHECK_THROWS_WITH(Prepared({}, inverse), Catch::Matchers::ContainsSubstring("evaluated star")); + CHECK_THROWS_WITH(Prepared(physical, {}), Catch::Matchers::ContainsSubstring("PRECONDITIONER_INVALID_OPERATOR")); + const preconditioner::backend::mfem::Inverse wrong_size{.action = std::make_shared(1)}; + CHECK_THROWS_WITH(Prepared(physical, wrong_size), Catch::Matchers::ContainsSubstring("PRECONDITIONER_SIZE_MISMATCH")); +} + +TEST_CASE("Model normalization and inverse computations share one physical evaluation", "[model][integration][preconditioner][normalization]") { + using namespace dependencies; + + const Layout layout{.state_sizes = {2, 1}, .residual_sizes = {1, 2}}; + double response = 4.0; + int physical_preparations = 0; + int physical_actions = 0; + const auto inputs = make_dependency_context(borrow(response)); + const auto physical = make_computation>( + [&](double density_response) -> std::expected, std::string> { + ++physical_preparations; + if (density_response < 0.0) { + return std::unexpected("Synthetic physical rejection for transaction testing."); + } + auto matrix = std::make_shared<::mfem::DenseMatrix>(3); + for (int row = 0; row < 3; ++row) { + for (int column = 0; column < 3; ++column) { + (*matrix)(row, column) = 2.0 + row * 3.0 + column; + } + } + (*matrix)(0, 2) = density_response; + const auto original = serif::operators::backend::mfem::linearized_block(std::shared_ptr(matrix)); + const auto action = serif::operators::backend::mfem::linearized_action(3, 3, [original, &physical_actions](const auto& input, auto& output) { + ++physical_actions; + original.action()->Mult(input, output); + }, [original, &physical_actions](const auto& input, auto& output) { + ++physical_actions; + original.action()->MultTranspose(input, output); + }); + const serif::operators::backend::mfem::LinearizedBlock block(action.action(), [original]() { + return original.diagonal(); + }, [original]() { + return original.assemble(); + }); + return model::backend::mfem::make_physical_system(3, 3, {}, {{0, 0, block}}); + }); + const auto inverse = preconditioner::make_preconditioner(preconditioner::strategies::inverse::Diagonal{}); + using InverseKey = decltype(inverse)::Result; + auto graph = std::apply([](auto... nodes) { + return make_computation_graph(std::move(nodes)...); + }, std::tuple_cat(std::tuple{physical}, model::computations::make_solver_layout_computations(layout), + model::computations::make_physical_block_computations(layout, KeyList{}), + normalization::make_normalization_computations(layout), + preconditioner::make_preconditioner_computations(inverse))); + auto cache = make_computation_cache>(std::move(graph), inputs); + using Requested = KeyList; + REQUIRE(cache.try_prepare(inputs, Requested{})); + CHECK(physical_preparations == 1); + const auto frozen = cache.get(); + const auto frozen_inverse = cache.template share(); + const auto& factors = cache.template get(); + REQUIRE(factors.state_factors().size() == 3); + REQUIRE(factors.residual_factors().size() == 3); + for (double factor : factors.state_factors()) { + CHECK(factor == 1.0); + } + ::mfem::Vector source(1), result(1); + source = 8.0; + frozen_inverse->action->Mult(source, result); + CHECK(result[0] == Catch::Approx(2.0)); + + const auto reordered = cache.template get(); + ::mfem::Vector direction(3), dual(3), reordered_result, transpose; + direction[0] = 2.0; + direction[1] = 3.0; + direction[2] = 5.0; + dual[0] = -0.4; + dual[1] = 0.7; + dual[2] = 1.2; + reordered.action()->Mult(direction, reordered_result); + CHECK(reordered_result[0] == Catch::Approx(7.0 * 2.0 + 5.0 * 3.0 + 6.0 * 5.0)); + CHECK(reordered_result[1] == Catch::Approx(10.0 * 2.0 + 8.0 * 3.0 + 9.0 * 5.0)); + CHECK(reordered_result[2] == Catch::Approx(4.0 * 2.0 + 2.0 * 3.0 + 3.0 * 5.0)); + reordered.action()->MultTranspose(dual, transpose); + CHECK(dual * reordered_result == Catch::Approx(direction * transpose)); + CHECK(physical_actions == 2); + CHECK(reordered.diagonal() == std::vector{7, 8, 3}); + const auto reordered_matrix = reordered.assemble(); + ::mfem::Vector assembled_result(3); + reordered_matrix->Mult(direction, assembled_result); + assembled_result -= reordered_result; + CHECK(assembled_result.Norml2() == 0.0); + CHECK(physical_actions == 2); + + REQUIRE(cache.try_prepare(inputs, Requested{})); + CHECK(physical_preparations == 1); + response = 8.0; + REQUIRE(cache.try_prepare(inputs, Requested{})); + CHECK(physical_preparations == 2); + cache.template get().action->Mult(source, result); + CHECK(result[0] == Catch::Approx(1.0)); + frozen_inverse->action->Mult(source, result); + CHECK(result[0] == Catch::Approx(2.0)); + const auto last = cache.get(); + + // A completed physical candidate must not be published if a requested inverse rejects it. + response = 0.0; + const auto preparations = cache.preparation_count(); + const auto singular = cache.try_prepare(inputs, Requested{}); + REQUIRE_FALSE(singular); + CHECK(std::holds_alternative(singular.error().error)); + last->block(0, 1, 2, 1).action()->Mult(source, result); + CHECK(result[0] == Catch::Approx(64.0)); + CHECK_FALSE(cache.ready()); + CHECK_FALSE(cache.template contains()); + CHECK_THROWS_AS(cache.template get(), std::logic_error); + CHECK(cache.report().computed_count() == 0); + CHECK(cache.report().reused_count() == 0); + CHECK(cache.preparation_count() == preparations); + response = 8.0; + REQUIRE(cache.try_prepare(inputs, Requested{})); + CHECK(cache.get() == last); + CHECK(cache.report().computed_count() == 0); + + response = -1.0; + CHECK_FALSE(cache.try_prepare(inputs, Requested{})); + CHECK_FALSE(cache.contains()); + last->block(0, 1, 2, 1).action()->Mult(source, result); + CHECK(result[0] == Catch::Approx(64.0)); + frozen->block(0, 1, 2, 1).action()->Mult(source, result); + CHECK(result[0] == Catch::Approx(32.0)); +} + +TEST_CASE("Physical solver blocks reject unrepresentable or unrelated coordinate layouts", "[unit][model][solver_services]") { + using namespace dependencies; + + const std::size_t too_large = static_cast(std::numeric_limits::max()) + 1; + CHECK_THROWS_WITH(model::computations::physical_block(Layout{.state_sizes = {too_large, 1}, .residual_sizes = {1, 2}}), + Catch::Matchers::ContainsSubstring("INVALID_DOF_COUNT")); + CHECK_THROWS_WITH(model::computations::physical_block(Layout{.state_sizes = {2, 1}, .residual_sizes = {too_large, 2}}), + Catch::Matchers::ContainsSubstring("INVALID_DOF_COUNT")); + + const Layout layout{.state_sizes = {2, 1}, .residual_sizes = {1, 2}}; + std::shared_ptr system; + const auto inputs = make_dependency_context(borrow(system)); + auto cache = make_computation_cache(inputs, model::computations::physical_block(layout)); + CHECK_THROWS_WITH(cache.try_prepare(inputs), Catch::Matchers::ContainsSubstring("INVALID_ARGUMENT")); + for (const auto dimensions : {std::pair{2, 3}, std::pair{3, 2}}) { + system = model::backend::mfem::make_physical_system(dimensions.first, dimensions.second, {}, {}); + CHECK_THROWS_WITH(cache.try_prepare(inputs), Catch::Matchers::ContainsSubstring("MATRIX_SHAPE_ERROR")); + } + system = model::backend::mfem::make_physical_system(3, 3, {}, {}); + REQUIRE(cache.try_prepare(inputs)); + CHECK(cache.get().action()->Height() == 1); + CHECK(cache.get().action()->Width() == 1); +} + +TEST_CASE("Configured model services share cache transactions and defer linear-only work", "[model][integration][solver_services]") { + using namespace dependencies; + namespace fixture = serif::tests::solver; + using Constant = blocks::barotropic_constant::mass_normalization; + if (!::mfem::Mpi::IsInitialized()) { + ::mfem::Mpi::Init(); + } + const auto physics = fixture::make_model(); + const auto seed = serif::solver::seed::make_polytropic_seed(physics); + REQUIRE(seed); + const auto resources = fixture::make_resources(seed->scales.radius.value()); + const auto finite_elements = fixture::make_discretization(resources, 0); + const auto state = serif::solver::seed::backend::mfem::make_polytropic_state(physics, finite_elements, *seed); + REQUIRE(state); + REQUIRE(state->template get()[0] < 0.5); + const auto configuration = ::serif::model::make_discretization(::serif::model::DiscretizationOptions{ + .model = physics, .finite_elements = finite_elements, + .normalization = normalization::NormalizationOptions{.scheme = *normalization::prepare_stellar_reference_scales(seed->scales.mass, seed->scales.radius)}, + .preconditioner = preconditioner::make_primal_preconditioner(state->layout())}); + int evaluations = 0; + int linear_preparations = 0; + const auto services = [&](const auto& bound, const auto& layout, auto, const auto& existing) { + REQUIRE(bound->model); + CHECK(layout == state->layout()); + using Existing = decltype(backend::detail::computation_keys(existing)); + static_assert(ContainsKey); + // The surface potential level is a tracked state input, so changing it invalidates both extensions. + const auto evaluation = make_computation>([&](const auto& constant) -> std::expected { + ++evaluations; + if (constant.values[0] > 10.0) { + return std::unexpected("Synthetic evaluation-service rejection."); + } + return constant.values[0]; + }); + const auto linear = make_computation>( + [&](double constant, const auto& prepared) -> std::expected { + ++linear_preparations; + if (constant > 0.5) { + return std::unexpected("Synthetic linear-service rejection."); + } + return prepared->system()->residual().size(); + }); + return std::pair{std::tuple{evaluation}, std::tuple{linear}}; + }; + GraphTiming timing; + auto evaluator = backend::bind_stellar_model(configuration, *state, {}, make_dependency_context(), services, time_graph_nodes(timing)); + REQUIRE(timing.runs().size() == 1); + const auto recorded = [&]() -> const NodeTiming& { + const auto& nodes = timing.runs().front()->nodes; + const auto node = std::ranges::find(nodes, std::string{nameof::nameof_type()}, &NodeTiming::key_name); + REQUIRE(node != nodes.end()); + return *node; + }; + const auto evaluated = evaluator.evaluate(*state); + REQUIRE(evaluated); + CHECK(evaluations == 1); + CHECK(linear_preparations == 0); + CHECK(recorded.template operator()().completed == 1); + CHECK(recorded.template operator()().calls == 0); + CHECK(evaluator.cache().report().template status() == PreparationStatus::not_required); + REQUIRE(evaluator.evaluate(*state)); + CHECK(evaluations == 1); + const auto prepared = evaluator.prepare_linear_system(*state); + REQUIRE(prepared); + CHECK(evaluations == 1); + CHECK(linear_preparations == 1); + CHECK((*prepared)->system() == *evaluated); + CHECK(evaluator.cache().template get() == (*evaluated)->residual().size()); + CHECK(recorded.template operator()().calls == 1); + CHECK(recorded.template operator()().completed == 1); + + auto changed = *state; + changed.template get()[0] = 1.0; + REQUIRE(evaluator.evaluate(changed)); + CHECK(evaluations == 2); + CHECK(linear_preparations == 1); + const auto rejected_linear = evaluator.prepare_linear_system(changed); + REQUIRE_FALSE(rejected_linear); + CHECK(std::get(rejected_linear.error().error) == "Synthetic linear-service rejection."); + CHECK(recorded.template operator()().rejected == 1); + CHECK(recorded.template operator()().calls == 2); + CHECK(linear_preparations == 2); + CHECK_FALSE(evaluator.cache().ready()); + CHECK((*prepared)->system() == *evaluated); + CHECK_FALSE((*prepared)->system()->residual().empty()); + + changed.template get()[0] = 11.0; + const auto rejected_evaluation = evaluator.evaluate(changed); + REQUIRE_FALSE(rejected_evaluation); + CHECK(std::get(rejected_evaluation.error().error) == "Synthetic evaluation-service rejection."); + CHECK(recorded.template operator()().rejected == 1); + CHECK(linear_preparations == 2); + CHECK_FALSE(evaluator.cache().ready()); + REQUIRE(evaluator.evaluate(*state)); + CHECK(evaluator.cache().template get() == state->template get()[0]); +} diff --git a/tests/model/state.cpp b/tests/model/state.cpp new file mode 100644 index 00000000..df376629 --- /dev/null +++ b/tests/model/state.cpp @@ -0,0 +1,93 @@ +#include +#include +#include +#include + +#include +#include + +#include "serif/discretization/blocks/fields.hpp" +#include "serif/model/state.hpp" + +namespace { + namespace blocks = serif::discretization::blocks; + namespace dependencies = serif::utils::dependencies; + using Density = blocks::density::mass; + using Shape = blocks::surface_deformation::coefficients; + using ShapeRow = blocks::surface_deformation::shape_equilibrium; + using Constant = blocks::barotropic_constant::mass_normalization; + using Layout = blocks::SystemLayout, dependencies::KeyList>; + using State = serif::model::StellarState; +} + +TEST_CASE("A stellar state owns its layout and exposes fields in physical rather than residual order", "[model][state][unit]") { + Layout layout{.state_sizes = {2, 3, 1}, .residual_sizes = {3, 1, 2}}; + State state(layout, {1.0, 2.0, 3.0, 4.0, 5.0, 6.0}); + layout.state_sizes[0] = 9; + layout.residual_sizes[0] = 8; + + CHECK(state.layout().state_sizes == std::array{2, 3, 1}); + CHECK(state.layout().residual_sizes == std::array{3, 1, 2}); + CHECK(state.layout().offset() == 4); + CHECK(state.get().data() == state.values().data()); + CHECK(state.get().data() == state.values().data() + 2); + CHECK(state.get().data() == state.values().data() + 5); + REQUIRE(state.get().size() == 3); + state.get()[1] = 7.0; + state.get()[0] = 8.0; + CHECK(state.values()[3] == 7.0); + CHECK(state.values()[5] == 8.0); + + const State& read_only = state; + CHECK(read_only.get()[1] == 7.0); + CHECK(read_only.values().data() == state.values().data()); +} + +TEST_CASE("Copied and moved stellar candidates retain independent coefficients and valid typed access", "[model][state][ownership]") { + const Layout layout{.state_sizes = {2, 3, 1}, .residual_sizes = {3, 1, 2}}; + State original(layout, {1.0, 2.0, 3.0, 4.0, 5.0, 6.0}); + State candidate = original; + CHECK(candidate.values().data() != original.values().data()); + candidate.get()[0] = 9.0; + candidate.get()[2] = 10.0; + CHECK(original.get()[0] == 1.0); + CHECK(original.get()[2] == 5.0); + + State moved(std::move(candidate)); + CHECK(moved.layout() == layout); + CHECK(moved.get()[0] == 9.0); + CHECK(moved.get()[2] == 10.0); + CHECK(moved.get().data() == moved.values().data() + 2); + moved.get()[0] = 11.0; + CHECK(original.get()[0] == 6.0); + + State assigned(Layout{.state_sizes = {1, 1, 1}, .residual_sizes = {1, 1, 1}}); + assigned = std::move(moved); + CHECK(assigned.layout() == layout); + CHECK(assigned.get()[0] == 11.0); + CHECK(assigned.get().data() == assigned.values().data() + 5); + assigned = original; + assigned.get()[1] = 12.0; + CHECK(original.get()[1] == 2.0); +} + +TEST_CASE("Stellar states reject inconsistent storage and overflowing sizes while supporting empty coordinates", "[model][state][validation]") { + const Layout layout{.state_sizes = {2, 3, 1}, .residual_sizes = {3, 1, 2}}; + const State zero(layout); + REQUIRE(zero.values().size() == 6); + for (double value : zero.values()) { + CHECK(value == 0.0); + } + CHECK_THROWS_WITH(State(layout, std::vector(5)), Catch::Matchers::ContainsSubstring("INVALID_DOF_COUNT")); + CHECK_THROWS_WITH(State(layout, std::vector(7)), Catch::Matchers::ContainsSubstring("INVALID_DOF_COUNT")); + const Layout overflow{.state_sizes = {std::numeric_limits::max(), 1, 0}, .residual_sizes = {0, 1, 0}}; + CHECK_THROWS_WITH(State(overflow), Catch::Matchers::ContainsSubstring("INVALID_DOF_COUNT")); + + const State partial(Layout{.state_sizes = {0, 2, 0}, .residual_sizes = {2, 0, 0}}); + CHECK(partial.get().empty()); + CHECK(partial.get().size() == 2); + CHECK(partial.get().empty()); + const blocks::SystemLayout, dependencies::KeyList<>> empty_layout{}; + const serif::model::StellarState empty(empty_layout); + CHECK(empty.values().empty()); +} diff --git a/tests/model/surface_refinement.cpp b/tests/model/surface_refinement.cpp new file mode 100644 index 00000000..6abae5b3 --- /dev/null +++ b/tests/model/surface_refinement.cpp @@ -0,0 +1,232 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "serif/backend/mfem/geometry/deformation/computations.hpp" +#include "serif/model/finite_elements.hpp" +#include "serif/tests/solver/stellar_fixture.hpp" +#include "serif/utils/dependencies/computation_cache.hpp" + +namespace { + namespace model = serif::model; + namespace factory = model::backend::mfem; + namespace deformation = serif::geometry::deformation; + namespace geometry = deformation::backend::mfem; + namespace domain = serif::discretization::domain; + using Resources = serif::discretization::backend::mfem::Resources; + using Schema = domain::schema::CoreEnvelopeVacuumDomainSchema; + constexpr int stellar_surface = Schema::boundary_id(); + constexpr int infinity_surface = Schema::boundary_id(); + + struct SurfaceStatistics { + int faces{}; + int equatorial_faces{}; + int polar_faces{}; + double largest_equatorial_area{}; + double smallest_polar_area{std::numeric_limits::infinity()}; + std::vector absolute_latitudes; + std::vector areas; + }; + + // Measure concentration in physical space, independently of the selector's + // logical coordinates and refinement-depth bookkeeping. + SurfaceStatistics surface_statistics(mfem::Mesh& mesh, std::array axis) { + double scale = 0.0; + for (const double value : axis) scale = std::max(scale, std::abs(value)); + for (double& value : axis) value /= scale; + const double norm = std::hypot(axis[0], axis[1], axis[2]); + for (double& value : axis) value /= norm; + SurfaceStatistics result; + mfem::Vector position(3); + for (int boundary = 0; boundary < mesh.GetNBE(); ++boundary) { + if (mesh.GetBdrAttribute(boundary) != stellar_surface) continue; + ++result.faces; + auto& transform = *mesh.GetBdrElementTransformation(boundary); + transform.Transform(mfem::Geometries.GetCenter(mesh.GetBdrElementBaseGeometry(boundary)), position); + double latitude = 0.0; + for (int component = 0; component < 3; ++component) latitude += axis[component] * position[component]; + latitude = std::abs(latitude) / position.Norml2(); + double area = 0.0; + const auto& rule = mfem::IntRules.Get(mesh.GetBdrElementBaseGeometry(boundary), 8); + for (int sample = 0; sample < rule.GetNPoints(); ++sample) { + const auto& point = rule.IntPoint(sample); + transform.SetIntPoint(&point); + area += point.weight * transform.Weight(); + } + REQUIRE(std::isfinite(area)); + REQUIRE(area > 0.0); + result.absolute_latitudes.push_back(latitude); + result.areas.push_back(area); + if (latitude < std::sin(15.0 * std::numbers::pi / 180.0)) { + ++result.equatorial_faces; + result.largest_equatorial_area = std::max(result.largest_equatorial_area, area); + } + if (latitude > 0.75) { + ++result.polar_faces; + result.smallest_polar_area = std::min(result.smallest_polar_area, area); + } + } + std::ranges::sort(result.absolute_latitudes); + std::ranges::sort(result.areas); + return result; + } + + void check_mesh_invariants(Resources& resources) { + REQUIRE(resources.meshes.size() == 2); + auto& physical = *resources.meshes[0]; + auto& logical = *resources.meshes[1]; + REQUIRE(physical.GetNE() == logical.GetNE()); + REQUIRE(physical.GetNV() == logical.GetNV()); + REQUIRE(physical.GetNBE() == logical.GetNBE()); + CHECK(physical.Nonconforming() == logical.Nonconforming()); + mfem::Array physical_vertices, logical_vertices; + for (int element = 0; element < physical.GetNE(); ++element) { + CHECK(physical.GetAttribute(element) == logical.GetAttribute(element)); + physical.GetElementVertices(element, physical_vertices); + logical.GetElementVertices(element, logical_vertices); + REQUIRE(physical_vertices.Size() == logical_vertices.Size()); + CHECK(std::ranges::equal(std::span{physical_vertices.GetData(), static_cast(physical_vertices.Size())}, + std::span{logical_vertices.GetData(), static_cast(logical_vertices.Size())})); + auto& transform = *physical.GetElementTransformation(element); + const auto& rule = mfem::IntRules.Get(physical.GetElementBaseGeometry(element), 6); + for (int sample = 0; sample < rule.GetNPoints(); ++sample) { + transform.SetIntPoint(&rule.IntPoint(sample)); + const double determinant = transform.Jacobian().Det(); + REQUIRE(std::isfinite(determinant)); + REQUIRE(determinant > 0.0); + } + } + REQUIRE(resources.compactification); + auto& coordinate = *resources.compactification; + auto& scalar_space = *coordinate.FESpace(); + mfem::Array dofs; + int stellar_faces = 0, infinity_faces = 0; + for (int boundary = 0; boundary < physical.GetNBE(); ++boundary) { + CHECK(physical.GetBdrAttribute(boundary) == logical.GetBdrAttribute(boundary)); + physical.GetBdrElementVertices(boundary, physical_vertices); + logical.GetBdrElementVertices(boundary, logical_vertices); + REQUIRE(physical_vertices.Size() == logical_vertices.Size()); + CHECK(std::ranges::equal(std::span{physical_vertices.GetData(), static_cast(physical_vertices.Size())}, + std::span{logical_vertices.GetData(), static_cast(logical_vertices.Size())})); + const int attribute = physical.GetBdrAttribute(boundary); + if (attribute != stellar_surface && attribute != infinity_surface) continue; + if (attribute == stellar_surface) ++stellar_faces; + else ++infinity_faces; + scalar_space.GetBdrElementDofs(boundary, dofs); + for (const int dof : dofs) { + CHECK(coordinate[mfem::FiniteElementSpace::DecodeDof(dof)] == + Catch::Approx(attribute == stellar_surface ? 0.0 : 1.0).margin(2.0e-13)); + } + } + REQUIRE(stellar_faces > 0); + REQUIRE(infinity_faces > 0); + // This retains the production guard: volume hanging nodes are allowed, + // but every stellar trace coefficient must remain independent. + const auto trace = geometry::select_surface_scalar_dofs(scalar_space, stellar_surface); + INFO((trace ? "Conforming stellar trace" : trace.error().message)); + REQUIRE(trace); + } +} + +TEST_CASE("Surface refinement adds levels beyond the existing global mesh", "[model][resources][surface_refinement]") { + for (const int base : {0, 1}) { + CAPTURE(base); + model::StellarMeshOptions options{.refinement = base, .geometry_order = 2}; + auto original = factory::make_stellar_resources(options); + const auto before = surface_statistics(*original.meshes[0], {0.0, 0.0, 1.0}); + options.surface_refinement.levels = 1; + auto refined = factory::make_stellar_resources(options); + const auto after = surface_statistics(*refined.meshes[0], {0.0, 0.0, 1.0}); + CHECK(after.faces == 4 * before.faces); + check_mesh_invariants(refined); + + if (base == 1) { + options.surface_refinement.equatorial_levels = 1; + auto combined = factory::make_stellar_resources(options); + const auto combined_stats = surface_statistics(*combined.meshes[0], {0.0, 0.0, 1.0}); + CHECK(combined_stats.faces > after.faces); + CHECK(combined_stats.polar_faces == after.polar_faces); + check_mesh_invariants(combined); + } + } +} + +TEST_CASE("Equatorial rings concentrate resolution around the requested physical axis", "[model][resources][surface_refinement]") { + model::StellarMeshOptions options{.geometry_order = 2}; + auto original = factory::make_stellar_resources(options); + const auto before = surface_statistics(*original.meshes[0], {0.0, 0.0, 1.0}); + options.surface_refinement.equatorial_levels = 3; + auto native = factory::make_stellar_resources(options); + const auto expected = surface_statistics(*native.meshes[0], options.surface_refinement.axis); + CHECK(expected.faces > before.faces); + CHECK(expected.equatorial_faces > before.equatorial_faces); + CHECK(expected.polar_faces == before.polar_faces); + CHECK(expected.largest_equatorial_area < 0.5 * expected.smallest_polar_area); + check_mesh_invariants(native); + + const double largest = std::numeric_limits::max(); + const double smallest = std::numeric_limits::min(); + for (const std::array axis : { + std::array{1.0, 0.0, 0.0}, std::array{1.0, 1.0, 1.0}, + std::array{-1.0, -1.0, -1.0}, std::array{0.0, 0.0, -1.0}, + std::array{largest, largest, largest}, std::array{smallest, smallest, smallest}}) { + CAPTURE(axis); + options.surface_refinement.axis = axis; + auto rotated = factory::make_stellar_resources(options); + const auto actual = surface_statistics(*rotated.meshes[0], axis); + REQUIRE(actual.faces == expected.faces); + CHECK(actual.equatorial_faces == expected.equatorial_faces); + CHECK(actual.polar_faces == expected.polar_faces); + for (std::size_t index = 0; index < actual.areas.size(); ++index) { + CHECK(actual.absolute_latitudes[index] == Catch::Approx(expected.absolute_latitudes[index]).margin(2.0e-12)); + CHECK(actual.areas[index] == Catch::Approx(expected.areas[index]).epsilon(2.0e-12)); + } + check_mesh_invariants(rotated); + } +} + +TEST_CASE("Refined rotated meshes retain one surface and volume deformation", "[model][resources][surface_refinement]") { + const auto star = serif::tests::solver::make_model(); + const auto finite_elements = model::make_stellar_discretization(star, + {.geometry_order = 3, .surface_refinement = {.levels = 1, .equatorial_levels = 2, .axis = {1.0, 1.0, 1.0}}}, + deformation::DomainDeformation{ + .surface = {.maximum_degree = 2}, .stellar_interior = {}, .vacuum = {}}); + std::vector shape(9); + for (std::size_t mode = 0; mode < shape.size(); ++mode) shape[mode] = 0.002 * std::sin(0.37 * (mode + 1)); + const auto inputs = geometry::make_deformation_inputs(finite_elements.deformation.model, finite_elements.deformation.reference, shape); + auto cache = serif::utils::dependencies::make_computation_cache( + geometry::make_deformation_graph(), inputs); + const auto prepared = cache.try_prepare(inputs); + INFO((prepared ? "Deformation graph prepared" : prepared.error().error.message)); + REQUIRE(prepared); + const auto& reference = *cache.get(); + const auto& surface = cache.get(); + const auto& volume = cache.get(); + const auto& space = finite_elements.deformation.reference.displacement_space; + REQUIRE(std::ranges::any_of(surface, [](double value) { return std::abs(value) > 1.0e-6; })); + for (std::size_t node = 0; node < reference.scalar_dofs.size(); ++node) { + for (int component = 0; component < 3; ++component) { + CHECK(volume[reference.scalar_dofs[node] + component * space.GetNDofs()] == + Catch::Approx(surface[3 * node + component]).margin(2.0e-12)); + } + } +} + +TEST_CASE("Surface refinement rejects undefined levels bands and axes", "[model][resources][surface_refinement]") { + model::StellarMeshOptions options{.geometry_order = 2}; + SECTION("negative surface levels") { options.surface_refinement.levels = -1; } + SECTION("negative equatorial levels") { options.surface_refinement.equatorial_levels = -1; } + SECTION("zero axis") { options.surface_refinement.axis = {0.0, 0.0, 0.0}; } + SECTION("nonfinite axis") { options.surface_refinement.axis = {0.0, std::numeric_limits::quiet_NaN(), 1.0}; } + SECTION("nonfinite band") { options.surface_refinement.equatorial_half_angle_degrees = std::numeric_limits::quiet_NaN(); } + SECTION("zero band") { options.surface_refinement.equatorial_half_angle_degrees = 0.0; } + SECTION("band outside the supported side patches") { options.surface_refinement.equatorial_half_angle_degrees = 35.1; } + CHECK_THROWS(factory::make_stellar_resources(options)); +} diff --git a/tests/normalization/computations.cpp b/tests/normalization/computations.cpp new file mode 100644 index 00000000..6d3f7c84 --- /dev/null +++ b/tests/normalization/computations.cpp @@ -0,0 +1,266 @@ +#include +#include + +#include +#include +#include + +#include "serif/tests/equations/test_equations.hpp" +#include "serif/equations/mass_normalization.hpp" +#include "serif/equations/angular_momentum.hpp" +#include "serif/equations/primal_gravity.hpp" +#include "serif/solver/normalization/normalization.hpp" +#include "serif/utils/dependencies/dependencies.hpp" + +namespace +{ + namespace normalization = serif::solver::normalization; + namespace dependencies = serif::utils::dependencies; + namespace dimensions = serif::dimensions; + namespace blocks = serif::discretization::blocks; + using Kind = blocks::RepresentedValueKind; + using Density = blocks::density::mass; + using Constant = blocks::barotropic_constant::mass_normalization; + using Rotation = blocks::rotation::angular_velocity; + using Mass = serif::equations::MassNormalization; + using Momentum = serif::equations::AngularMomentumConstraint; + + /** @brief Make a test reference with deliberately different mass, enthalpy, pressure and rotation scales. */ + normalization::StellarReferenceScales reference_scales() + { + return normalization::prepare_stellar_reference_scales(dimensions::QuantityValue{9.0}, dimensions::QuantityValue{2.0}, dimensions::QuantityValue{4.0}).value(); + } + + /** @brief Construct a cache from the ordinary computation tuple, as a coupled graph would. */ + template + auto make_cache(const Context& context, const Layout& layout) + { + return std::apply([&](auto... computations) + { + return dependencies::make_computation_cache(context, std::move(computations)...); + }, normalization::make_normalization_computations(layout)); + } + + struct SurfaceRows + { + }; + + struct TemperatureCoordinate + { + using Quantity = dimensions::Temperature; + }; + + struct TemperatureMetric + { + using Coordinates = dependencies::KeyList; + }; + + struct ThermalScales + { + dimensions::QuantityValue temperature; + + /** @brief Track an external scale provider without adding normalization-specific revision machinery. */ + bool operator==(const ThermalScales&) const = default; + }; + + /** @brief Supply a quantity that cannot be inferred from stellar mass, radius, and gravity. */ + dimensions::QuantityValue characteristic_scale(const ThermalScales& scales, dimensions::Temperature) + { + return scales.temperature; + } + + /** @brief A third-party method whose configuration is an explicit tracked input, including move-only ownership. */ + struct UniformDiagonal : normalization::NormalizationPrescriptionTag + { + std::unique_ptr multiplier; + double residual_multiplier; + std::size_t extra_count = 0; + + /** @brief Return the actual policy's factors for the exact requested role and coordinate. */ + template + std::expected, std::string> prepare_factors(normalization::keys::Factors, std::size_t count) const + { + return std::vector(count + extra_count, Role == Kind::value ? *multiplier : residual_multiplier); + } + }; +} + +TEST_CASE("Normalization plans preserve distinct global quantities and obey layout rather than declaration order", "[unit][normalization][dependencies]") +{ + using Plan = normalization::NormalizationPlan< + dependencies::Bindings, normalization::PhysicalValueRule>, + dependencies::Bindings, normalization::PhysicalResidualRule>>; + const normalization::CoordinateLayout, dependencies::KeyList> layout{.state_sizes = {1, 1}, .residual_sizes = {1, 1}}; + auto scales = reference_scales(); + double irrelevant_candidate = 0.0; + const auto context = dependencies::make_dependency_context(dependencies::borrow(layout), dependencies::borrow(scales), dependencies::borrow(irrelevant_candidate)); + auto cache = make_cache(context, layout); + REQUIRE(cache.try_prepare(context)); + const normalization::DiagonalNormalization frozen = cache.get(); + CHECK(frozen.state_factors()[0] == Catch::Approx(1.0 / scales.specific_energy.value()).epsilon(2.0e-15)); + CHECK(frozen.state_factors()[1] == Catch::Approx(1.0 / scales.angular_velocity.value()).epsilon(2.0e-15)); + CHECK(frozen.residual_factors()[0] == Catch::Approx(1.0 / scales.angular_momentum.value()).epsilon(2.0e-15)); + CHECK(frozen.residual_factors()[1] == Catch::Approx(1.0 / scales.mass.value()).epsilon(2.0e-15)); + + irrelevant_candidate = std::numeric_limits::quiet_NaN(); + auto report = cache.try_prepare(context); + REQUIRE(report); + CHECK(report->computed_count() == 0); + scales.mass = dimensions::QuantityValue{18.0}; + report = cache.try_prepare(context); + REQUIRE(report); + CHECK(cache.get().residual_factors()[1] == Catch::Approx(1.0 / 18.0)); + CHECK(frozen.residual_factors()[1] == Catch::Approx(1.0 / 9.0)); +} + +TEST_CASE("One reference volume metric supplies independent primal and dual coordinates", "[unit][normalization][dependencies]") +{ + using Metric = normalization::ReferenceVolumeMetric; + using Method = normalization::PhysicalRieszCoordinate; + using Plan = normalization::NormalizationPlan>, dependencies::Bindings>>; + const normalization::CoordinateLayout, dependencies::KeyList> layout{.state_sizes = {3}, .residual_sizes = {3}}; + const auto scales = reference_scales(); + std::vector metric{1.0e-20, 4.0, 9.0e20}; + const auto context = dependencies::make_dependency_context(dependencies::borrow(layout), dependencies::borrow(scales), dependencies::borrow(metric)); + auto cache = make_cache(context, layout); + REQUIRE(cache.try_prepare(context)); + const auto& map = cache.get(); + for (std::size_t index = 0; index < metric.size(); ++index) + { + CHECK(map.state_factors()[index] == Catch::Approx(std::sqrt(metric[index]) / scales.density.value()).epsilon(3.0e-15)); + CHECK(map.residual_factors()[index] == Catch::Approx(1.0 / (std::sqrt(metric[index]) * scales.density.value())).epsilon(3.0e-15)); + } + metric[1] = 0.0; + const auto failure = cache.try_prepare(context); + REQUIRE_FALSE(failure); + const auto originating_failure = normalization::make_riesz_factors(Kind::value, metric, scales.density.value()); + REQUIRE_FALSE(originating_failure); + CHECK(failure.error().error == originating_failure.error()); + CHECK_THAT(failure.error().error, Catch::Matchers::ContainsSubstring("NORMALIZATION_ERROR")); + const std::string& diagnostic = failure.error().error; + REQUIRE(diagnostic.find("code No.:") != std::string::npos); + CHECK(diagnostic.find("code No.:") == diagnostic.rfind("code No.:")); + CHECK_FALSE(cache.ready()); + CHECK_THROWS_AS(cache.get(), std::logic_error); + metric[1] = 16.0; + REQUIRE(cache.try_prepare(context)); + CHECK(cache.get().state_factors()[1] == Catch::Approx(4.0 / scales.density.value())); + metric.push_back(1.0); + const auto wrong_size = cache.try_prepare(context); + REQUIRE_FALSE(wrong_size); + CHECK_THAT(wrong_size.error().error, Catch::Matchers::ContainsSubstring("NORMALIZATION_VECTOR_SIZE_MISMATCH")); + metric.pop_back(); + REQUIRE(cache.try_prepare(context)); + CHECK(cache.get().state_factors()[1] == Catch::Approx(4.0 / scales.density.value())); +} + +TEST_CASE("Pressure point rows replace both the volume metric and its physical scale", "[unit][normalization][dependencies]") +{ + using Enthalpy = ::serif::tests::equations::Enthalpy; + using Metric = normalization::ReferenceVolumeMetric; + using Volume = normalization::PhysicalRieszCoordinate; + using Hybrid = normalization::WithPointRows; + using Plan = normalization::NormalizationPlan, dependencies::Bindings>>; + const normalization::CoordinateLayout, dependencies::KeyList> layout{.state_sizes = {}, .residual_sizes = {4}}; + const auto scales = reference_scales(); + const std::vector metric{4.0, 0.0, 16.0, 0.0}; + std::vector rows{1, 3}; + const auto context = dependencies::make_dependency_context(dependencies::borrow(layout), dependencies::borrow(scales), dependencies::borrow(metric), dependencies::borrow(rows)); + auto cache = make_cache(context, layout); + REQUIRE(cache.try_prepare(context)); + const auto& map = cache.get(); + CHECK(map.state_size() == 0); + CHECK(map.residual_factors()[0] == Catch::Approx(1.0 / (2.0 * scales.specific_energy.value()))); + CHECK(map.residual_factors()[1] == Catch::Approx(1.0 / scales.pressure.value())); + CHECK(map.residual_factors()[2] == Catch::Approx(1.0 / (4.0 * scales.specific_energy.value()))); + CHECK(map.residual_factors()[3] == Catch::Approx(1.0 / scales.pressure.value())); + for (const std::vector& invalid : {std::vector{1, 1}, {1, 4}, {-1, 3}, {1}}) + { + rows = invalid; + CHECK_FALSE(cache.try_prepare(context)); + } + rows = {1, 3}; + REQUIRE(cache.try_prepare(context)); +} + +TEST_CASE("Custom normalization uses its exact move-only prescription and tracks configuration changes", "[unit][normalization][dependencies]") +{ + using Method = normalization::RuntimePreparedCoordinate; + using Plan = normalization::NormalizationPlan>, dependencies::Bindings>>; + normalization::CoordinateLayout, dependencies::KeyList> layout{.state_sizes = {2}, .residual_sizes = {3}}; + UniformDiagonal policy{.multiplier = std::make_unique(2.0), .residual_multiplier = 5.0}; + const auto context = dependencies::make_dependency_context(dependencies::borrow(layout), dependencies::borrow>(policy, dependencies::track_by([](const UniformDiagonal& method) + { + return std::tuple{*method.multiplier, method.residual_multiplier, method.extra_count}; + }))); + auto cache = make_cache(context, layout); + REQUIRE(cache.try_prepare(context)); + CHECK(cache.get().state_size() == 2); + CHECK(cache.get().residual_size() == 3); + CHECK(cache.get().state_factors()[1] == 2.0); + CHECK(cache.get().residual_factors()[2] == 5.0); + *policy.multiplier = 3.0; + REQUIRE(cache.try_prepare(context)); + CHECK(cache.get().state_factors()[1] == 3.0); + policy.residual_multiplier = -1.0; + CHECK_FALSE(cache.try_prepare(context)); + policy.residual_multiplier = 5.0; + policy.extra_count = 1; + CHECK_FALSE(cache.try_prepare(context)); + policy.extra_count = 0; + layout.state_sizes[0] = std::numeric_limits::max(); + CHECK_FALSE(cache.try_prepare(context)); +} + +TEST_CASE("External physical scales share ordinary metric preparation and invalidation", "[unit][normalization][dependencies]") +{ + using Method = normalization::PhysicalRieszCoordinate; + using Rule = dependencies::Binding; + using Plan = normalization::NormalizationPlan, dependencies::Bindings>; + const normalization::CoordinateLayout, dependencies::KeyList> layout{.state_sizes = {2}, .residual_sizes = {2}}; + ThermalScales scales{.temperature = dimensions::QuantityValue{6000.0}}; + const std::vector metric{4.0, 9.0}; + const auto context = dependencies::make_dependency_context(dependencies::borrow(layout), dependencies::borrow(scales), dependencies::borrow(metric)); + auto cache = make_cache(context, layout); + REQUIRE(cache.try_prepare(context)); + CHECK(cache.get().state_factors()[0] == Catch::Approx(2.0 / 6000.0)); + CHECK(cache.get().residual_factors()[1] == Catch::Approx(1.0 / 18000.0)); + REQUIRE(cache.try_prepare(context)->computed_count() == 0); + scales.temperature = dimensions::QuantityValue{3000.0}; + REQUIRE(cache.try_prepare(context)); + CHECK(cache.get().state_factors()[0] == Catch::Approx(2.0 / 3000.0)); + scales.temperature = dimensions::QuantityValue{-1.0}; + CHECK_FALSE(cache.try_prepare(context)); +} + +TEST_CASE("Oversized normalization layouts fail before allocating identity factors", "[unit][normalization][dependencies]") +{ + using Plan = normalization::NormalizationPlan>, dependencies::Bindings<>>; + const normalization::CoordinateLayout, dependencies::KeyList<>> layout{.state_sizes = {std::numeric_limits::max()}, .residual_sizes = {}}; + const auto context = dependencies::make_dependency_context(dependencies::borrow(layout)); + auto cache = make_cache(context, layout); + CHECK_FALSE(cache.try_prepare(context)); +} + +TEST_CASE("Identity preparation needs no physical sources and validates global coordinate arity", "[unit][normalization][dependencies]") +{ + using Plan = normalization::NormalizationPlan>, dependencies::Bindings<>>; + normalization::CoordinateLayout, dependencies::KeyList<>> layout{.state_sizes = {1}, .residual_sizes = {}}; + const auto context = dependencies::make_dependency_context(dependencies::borrow(layout)); + auto cache = make_cache(context, layout); + REQUIRE(cache.try_prepare(context)); + CHECK(cache.get().state_factors()[0] == 1.0); + layout.state_sizes[0] = 2; + CHECK_FALSE(cache.try_prepare(context)); +} + +TEST_CASE("Empty normalization plans preserve empty independently packed coordinate spaces", "[unit][normalization][dependencies]") +{ + using Plan = normalization::NormalizationPlan, dependencies::Bindings<>>; + const normalization::CoordinateLayout, dependencies::KeyList<>> layout{.state_sizes = {}, .residual_sizes = {}}; + const auto context = dependencies::make_dependency_context(); + auto cache = make_cache(context, layout); + REQUIRE(cache.try_prepare(context)); + CHECK(cache.get().state_size() == 0); + CHECK(cache.get().residual_size() == 0); +} diff --git a/tests/normalization/configuration.cpp b/tests/normalization/configuration.cpp new file mode 100644 index 00000000..f8c88038 --- /dev/null +++ b/tests/normalization/configuration.cpp @@ -0,0 +1,119 @@ +#include +#include +#include + +#include +#include + +#include "serif/solver/normalization/configuration.hpp" + +namespace { + namespace normalization = serif::solver::normalization; + namespace dimensions = serif::dimensions; + using Gravity = dimensions::QuantityValue; + using Catch::Matchers::WithinRel; + + /** @brief Keep dimensional reference inputs visible in each numerical test. */ + [[nodiscard]] auto reference_scheme(const double n, const double mass, const double density) { + return normalization::LaneEmdenApproximation{ + .n = n, + .mass = dimensions::QuantityValue{mass}, + .central_density = dimensions::QuantityValue{density} + }; + } +} + +TEST_CASE("Normalization configuration preserves explicit choices without numerical preparation", "[unit][normalization][configuration]") { + const auto defaults = normalization::make_normalization(); + CHECK(defaults.scheme.n == 3.0); + CHECK(defaults.gravitational_constant.value() == 1.0); + + // An invalid numerical input can still be described; preparation supplies the recoverable failure. + const auto options = normalization::make_normalization(normalization::NormalizationOptions{ + .scheme = reference_scheme(-1.0, 7.0, 2.0), + .coordinates = normalization::Dimensionless{}, + .gravitational_constant = Gravity{0.25} + }); + CHECK(options.scheme.n == -1.0); + CHECK(options.scheme.mass.value() == 7.0); + CHECK(options.scheme.central_density.value() == 2.0); + CHECK(options.gravitational_constant.value() == 0.25); + CHECK_FALSE(normalization::prepare_reference_scales(options.scheme, options.gravitational_constant)); +} + +TEST_CASE("Lane Emden normalization reference radii match analytic polytropes", "[unit][normalization][configuration]") { + constexpr double mass = 7.0; + constexpr double central_density = 2.0; + constexpr double gravity = 0.25; + for (const double n : {0.0, 1.0}) { + const auto reference = normalization::prepare_reference_scales(reference_scheme(n, mass, central_density), Gravity{gravity}); + REQUIRE(reference); + double radius_cubed = 3.0 * mass / (4.0 * std::numbers::pi * central_density); + if (n == 1.0) { + radius_cubed = std::numbers::pi * mass / (4.0 * central_density); + } + const double radius = std::cbrt(radius_cubed); + CHECK_THAT(reference->radius.value(), WithinRel(radius, 2.0e-8)); + CHECK_THAT(reference->mass.value(), WithinRel(mass, 2.0e-14)); + CHECK(reference->gravitational_constant.value() == gravity); + CHECK_THAT(reference->density.value(), WithinRel(mass / radius_cubed, 6.0e-8)); + CHECK_THAT(reference->specific_energy.value(), WithinRel(gravity * mass / radius, 2.0e-8)); + CHECK_THAT(reference->pressure.value(), WithinRel(gravity * mass * mass / std::pow(radius, 4), 8.0e-8)); + CHECK_THAT(reference->angular_velocity.value(), WithinRel(std::sqrt(gravity * mass / radius_cubed), 4.0e-8)); + } +} + +TEST_CASE("Lane Emden reference preparation respects consistent physical-unit conversions", "[unit][normalization][configuration]") { + constexpr double mass_conversion = 1000.0; + constexpr double length_conversion = 100.0; + constexpr double time_conversion = 10000.0; + constexpr double original_mass = 2.0; + constexpr double original_density = 3.0; + constexpr double original_gravity = 0.8; + const auto original = normalization::prepare_reference_scales(reference_scheme(3.0, original_mass, original_density), Gravity{original_gravity}); + const auto converted = normalization::prepare_reference_scales(reference_scheme( + 3.0, + original_mass * mass_conversion, + original_density * mass_conversion / std::pow(length_conversion, 3) + ), Gravity{original_gravity * std::pow(length_conversion, 3) / (mass_conversion * time_conversion * time_conversion)}); + REQUIRE(original); + REQUIRE(converted); + CHECK_THAT(converted->radius.value(), WithinRel(original->radius.value() * length_conversion, 2.0e-13)); + CHECK_THAT(converted->density.value(), WithinRel(original->density.value() * mass_conversion / std::pow(length_conversion, 3), 2.0e-13)); + CHECK_THAT(converted->acceleration.value(), WithinRel(original->acceleration.value() * length_conversion / std::pow(time_conversion, 2), 2.0e-13)); + CHECK_THAT(converted->specific_energy.value(), WithinRel(original->specific_energy.value() * std::pow(length_conversion / time_conversion, 2), 2.0e-13)); + CHECK_THAT(converted->pressure.value(), WithinRel(original->pressure.value() * mass_conversion / (length_conversion * std::pow(time_conversion, 2)), 2.0e-13)); + CHECK_THAT(converted->angular_velocity.value(), WithinRel(original->angular_velocity.value() / time_conversion, 2.0e-13)); + CHECK_THAT(converted->angular_momentum.value(), WithinRel(original->angular_momentum.value() * mass_conversion * length_conversion * length_conversion / time_conversion, 2.0e-13)); + CHECK_THAT(converted->force.value(), WithinRel(original->force.value() * mass_conversion * length_conversion / std::pow(time_conversion, 2), 2.0e-13)); +} + +TEST_CASE("Normalization reference failures stay explicit for unusable inputs and missing surfaces", "[unit][normalization][configuration]") { + for (const double invalid : {0.0, -1.0, std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity()}) { + CHECK_FALSE(normalization::prepare_reference_scales(reference_scheme(1.0, invalid, 1.0), Gravity{1.0})); + CHECK_FALSE(normalization::prepare_reference_scales(reference_scheme(1.0, 1.0, invalid), Gravity{1.0})); + CHECK_FALSE(normalization::prepare_reference_scales(reference_scheme(1.0, 1.0, 1.0), Gravity{invalid})); + } + const auto infinite_polytrope = normalization::prepare_reference_scales(reference_scheme(5.0, 1.0, 1.0), Gravity{1.0}); + REQUIRE_FALSE(infinite_polytrope); + CHECK_FALSE(infinite_polytrope.error().empty()); + auto unresolved = reference_scheme(3.0, 1.0, 1.0); + unresolved.integration.maximum_radius = 1.0; + CHECK_FALSE(normalization::prepare_reference_scales(unresolved, Gravity{1.0})); +} + +TEST_CASE("Explicit normalization scales share validation and require a consistent G", "[unit][normalization][configuration]") { + const auto reference = normalization::prepare_stellar_reference_scales(dimensions::QuantityValue{2.0}, dimensions::QuantityValue{3.0}, Gravity{0.5}); + REQUIRE(reference); + const auto options = normalization::make_normalization(normalization::NormalizationOptions{ + .scheme = *reference, + .gravitational_constant = Gravity{0.5} + }); + const auto prepared = normalization::prepare_reference_scales(options.scheme, options.gravitational_constant); + REQUIRE(prepared); + CHECK(*prepared == *reference); + CHECK_FALSE(normalization::prepare_reference_scales(options.scheme, Gravity{1.0})); + auto invalid = *reference; + invalid.radius = dimensions::QuantityValue{0.0}; + CHECK_FALSE(normalization::prepare_reference_scales(invalid, Gravity{0.5})); +} diff --git a/tests/normalization/diagonal.cpp b/tests/normalization/diagonal.cpp new file mode 100644 index 00000000..b3950cb2 --- /dev/null +++ b/tests/normalization/diagonal.cpp @@ -0,0 +1,167 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "serif/solver/normalization/diagonal.hpp" + +namespace +{ + namespace normalization = serif::solver::normalization; + using Kind = serif::discretization::blocks::RepresentedValueKind; +} + +// Ported from MeanField tests/normalization/physical_riesz.cpp. +TEST_CASE("Diagonal Riesz maps reproduce primal and dual norms across extreme metrics", "[normalization][unit][math]") +{ + const std::array metric{1.0e-20, 4.0, 9.0e20}; + constexpr double state_scale = 10.0; + constexpr double residual_scale = 0.25; + const auto state_factors = normalization::make_riesz_factors(Kind::value, metric, state_scale); + const auto residual_factors = normalization::make_riesz_factors(Kind::residual, metric, residual_scale); + REQUIRE(state_factors); + REQUIRE(residual_factors); + const auto map = normalization::make_diagonal_normalization(*state_factors, *residual_factors); + REQUIRE(map); + const std::array state{3.0e10, -2.0, 4.0e-10}; + const std::array residual{2.0e-10, -3.0, 5.0e10}; + double primal_norm = 0.0; + double dual_norm = 0.0; + for (std::size_t index = 0; index < metric.size(); ++index) + { + primal_norm += metric[index] * state[index] * state[index] / (state_scale * state_scale); + dual_norm += residual[index] * residual[index] / (metric[index] * residual_scale * residual_scale); + } + CHECK(map->state_norm_squared(state) == Catch::Approx(primal_norm).epsilon(3.0e-15)); + CHECK(map->residual_norm_squared(residual) == Catch::Approx(dual_norm).epsilon(3.0e-15)); + + std::array normalized_state; + std::array normalized_residual; + std::array recovered_state; + std::array recovered_residual; + map->normalize_state(state, normalized_state); + map->denormalize_state(normalized_state, recovered_state); + map->normalize_residual(residual, normalized_residual); + map->denormalize_residual(normalized_residual, recovered_residual); + for (std::size_t index = 0; index < state.size(); ++index) + { + CHECK(recovered_state[index] == Catch::Approx(state[index]).epsilon(3.0e-15)); + CHECK(recovered_residual[index] == Catch::Approx(residual[index]).epsilon(3.0e-15)); + } +} + +TEST_CASE("Riesz factors reject nonpositive nonfinite and unrepresentable data", "[normalization][unit][validation]") +{ + for (const double invalid : {0.0, -1.0, std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity()}) + { + CHECK_FALSE(normalization::make_riesz_factor(Kind::value, invalid, 1.0)); + CHECK_FALSE(normalization::make_riesz_factor(Kind::residual, 1.0, invalid)); + CHECK_FALSE(normalization::make_riesz_factors(Kind::value, std::array{1.0, invalid}, 1.0)); + CHECK_FALSE(normalization::make_riesz_factors(Kind::residual, {}, invalid)); + CHECK_FALSE(normalization::make_diagonal_normalization({invalid}, {1.0})); + CHECK_FALSE(normalization::make_diagonal_normalization({1.0}, {invalid})); + } + CHECK_FALSE(normalization::make_riesz_factor(static_cast(25), 1.0, 1.0)); + CHECK_FALSE(normalization::make_riesz_factor(Kind::value, 1.0e300, 1.0e-300)); + CHECK_FALSE(normalization::make_riesz_factor(Kind::value, 1.0e-300, 1.0e300)); + CHECK_FALSE(normalization::make_riesz_factor(Kind::residual, 1.0e-300, 1.0e-300)); + CHECK_FALSE(normalization::make_riesz_factor(Kind::residual, 1.0e300, 1.0e300)); +} + +TEST_CASE("Dual Riesz factors retain representable subnormals without overflowing an intermediate product", "[normalization][unit][math]") +{ + const auto factor = normalization::make_riesz_factor(Kind::residual, 1.0e300, 1.0e160); + REQUIRE(factor); + CHECK(*factor > 0.0); + CHECK(*factor / 1.0e-310 == Catch::Approx(1.0).epsilon(1.0e-12)); + const auto small_metric = normalization::make_riesz_factor(Kind::value, std::numeric_limits::denorm_min(), 1.0); + REQUIRE(small_metric); + CHECK(*small_metric == std::sqrt(std::numeric_limits::denorm_min())); +} + +TEST_CASE("Diagonal coordinate changes permit exact in-place conversion and reject partial overlap", "[normalization][unit][validation]") +{ + const auto map = normalization::make_diagonal_normalization({2.0, 4.0}, {0.5, 0.25}); + REQUIRE(map); + std::array values{3.0, -7.0}; + map->normalize_state(values, values); + CHECK(values == std::array{6.0, -28.0}); + map->denormalize_state(values, values); + CHECK(values == std::array{3.0, -7.0}); + map->normalize_residual(values, values); + CHECK(values == std::array{1.5, -1.75}); + map->denormalize_residual(values, values); + CHECK(values == std::array{3.0, -7.0}); + + std::array overlap{1.0, 2.0, 3.0}; + CHECK_THROWS_MATCHES(map->normalize_state(std::span{overlap}.first(2), std::span{overlap}.last(2)), std::invalid_argument, Catch::Matchers::MessageMatches(Catch::Matchers::ContainsSubstring("NORMALIZATION_BUFFER_OVERLAP"))); + CHECK_THROWS_MATCHES(map->normalize_state(std::span{overlap}.last(2), std::span{overlap}.first(2)), std::invalid_argument, Catch::Matchers::MessageMatches(Catch::Matchers::ContainsSubstring("NORMALIZATION_BUFFER_OVERLAP"))); + CHECK(overlap == std::array{1.0, 2.0, 3.0}); +} + +TEST_CASE("Diagonal normalization checks both vector sizes before changing any output", "[normalization][unit][validation]") +{ + const auto map = normalization::make_diagonal_normalization({2.0, 4.0}, {3.0}); + REQUIRE(map); + const std::array input{1.0, 2.0}; + std::array output{9.0, 8.0}; + CHECK_THROWS_MATCHES(map->normalize_state(std::span{input}.first(1), output), std::invalid_argument, Catch::Matchers::MessageMatches(Catch::Matchers::ContainsSubstring("NORMALIZATION_VECTOR_SIZE_MISMATCH"))); + CHECK_THROWS_MATCHES(map->normalize_state(input, std::span{output}.first(1)), std::invalid_argument, Catch::Matchers::MessageMatches(Catch::Matchers::ContainsSubstring("NORMALIZATION_VECTOR_SIZE_MISMATCH"))); + CHECK_THROWS_MATCHES(map->denormalize_state(input, std::span{output}.first(1)), std::invalid_argument, Catch::Matchers::MessageMatches(Catch::Matchers::ContainsSubstring("NORMALIZATION_VECTOR_SIZE_MISMATCH"))); + CHECK_THROWS_MATCHES(map->normalize_residual(input, output), std::invalid_argument, Catch::Matchers::MessageMatches(Catch::Matchers::ContainsSubstring("NORMALIZATION_VECTOR_SIZE_MISMATCH"))); + CHECK_THROWS_MATCHES(map->denormalize_residual(input, output), std::invalid_argument, Catch::Matchers::MessageMatches(Catch::Matchers::ContainsSubstring("NORMALIZATION_VECTOR_SIZE_MISMATCH"))); + CHECK_THROWS_MATCHES(map->state_norm_squared(std::span{input}.first(1)), std::invalid_argument, Catch::Matchers::MessageMatches(Catch::Matchers::ContainsSubstring("NORMALIZATION_VECTOR_SIZE_MISMATCH"))); + CHECK_THROWS_MATCHES(map->residual_norm_squared(input), std::invalid_argument, Catch::Matchers::MessageMatches(Catch::Matchers::ContainsSubstring("NORMALIZATION_VECTOR_SIZE_MISMATCH"))); + CHECK(output == std::array{9.0, 8.0}); + CHECK(map->state_size() == 2); + CHECK(map->residual_size() == 1); +} + +TEST_CASE("Frozen normalization owns its factors and supports empty coordinate sets", "[normalization][unit]") +{ + std::vector factors{2.0}; + const auto map = normalization::make_diagonal_normalization(factors, {3.0, 4.0}); + REQUIRE(map); + factors[0] = 17.0; + CHECK(map->state_factors()[0] == 2.0); + CHECK(map->residual_factors()[1] == 4.0); + + const auto empty = normalization::make_diagonal_normalization({}, {}); + REQUIRE(empty); + empty->normalize_state({}, {}); + empty->denormalize_residual({}, {}); + CHECK(empty->state_norm_squared({}) == 0.0); + CHECK(empty->residual_norm_squared({}) == 0.0); +} + +TEST_CASE("Normalization accepts adjacent buffers and identifies expected failures once", "[normalization][unit][validation]") +{ + const auto map = normalization::make_diagonal_normalization({2.0, 4.0}, {}); + REQUIRE(map); + std::array storage{3.0, 5.0, 0.0, 0.0}; + map->normalize_state(std::span{storage}.first(2), std::span{storage}.last(2)); + CHECK(storage == std::array{3.0, 5.0, 6.0, 20.0}); + map->denormalize_state(std::span{storage}.last(2), std::span{storage}.first(2)); + CHECK(storage == std::array{3.0, 5.0, 6.0, 20.0}); + + const auto invalid_metric = normalization::make_riesz_factors(Kind::value, std::array{1.0, 0.0}, 2.0); + REQUIRE_FALSE(invalid_metric); + CHECK_THAT(invalid_metric.error(), Catch::Matchers::ContainsSubstring("NORMALIZATION_ERROR")); + REQUIRE(invalid_metric.error().find("code No.:") != std::string::npos); + CHECK(invalid_metric.error().find("code No.:") == invalid_metric.error().rfind("code No.:")); + + const auto invalid_role = normalization::make_riesz_factors(static_cast(25), {}, 1.0); + REQUIRE_FALSE(invalid_role); + CHECK_THAT(invalid_role.error(), Catch::Matchers::ContainsSubstring("INVALID_ARGUMENT")); + const auto invalid_factors = normalization::make_diagonal_normalization({1.0}, {0.0}); + REQUIRE_FALSE(invalid_factors); + CHECK_THAT(invalid_factors.error(), Catch::Matchers::ContainsSubstring("NORMALIZATION_ERROR")); +} diff --git a/tests/normalization/dimensionless.cpp b/tests/normalization/dimensionless.cpp new file mode 100644 index 00000000..d47a3a51 --- /dev/null +++ b/tests/normalization/dimensionless.cpp @@ -0,0 +1,248 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "mfem/fem/gridfunc.hpp" +#include "mfem/fem/linearform.hpp" +#include "serif/tests/equations/test_equations.hpp" +#include "serif/backend/mfem/geometry/deformation/reference_geometry.hpp" +#include "serif/backend/mfem/geometry/deformation/surface_metric.hpp" +#include "serif/backend/mfem/solver/normalization/metric_computations.hpp" +#include "serif/solver/normalization/normalization.hpp" +#include "serif/tests/deformation/mesh.hpp" +#include "serif/utils/dependencies/computation_cache.hpp" + +namespace { + namespace normalization = serif::solver::normalization; + namespace dimensions = serif::dimensions; + namespace dependencies = serif::utils::dependencies; + namespace discretization = serif::discretization; + namespace blocks = discretization::blocks; + namespace deformation = serif::geometry::deformation; + using Schema = discretization::domain::schema::CoreEnvelopeVacuumDomainSchema; + using Kind = blocks::RepresentedValueKind; + + /** @brief Represent the same reference star after changing numerical mass, length, and time units. */ + [[nodiscard]] normalization::StellarReferenceScales reference_scales(double length, double mass, double time) { + const auto result = normalization::prepare_stellar_reference_scales( + dimensions::QuantityValue{2.0 * mass}, + dimensions::QuantityValue{3.0 * length}, + dimensions::QuantityValue{0.7 * length * length * length / (mass * time * time)}); + REQUIRE(result); + return *result; + } + + /** @brief Exercise the public dependency computations with independent physical state and residual quantities. */ + template + [[nodiscard]] normalization::DiagonalNormalization prepare_factors(const std::vector& metric, const normalization::StellarReferenceScales& scales) { + using Layout = normalization::CoordinateLayout, dependencies::KeyList>; + using Plan = normalization::NormalizationPlan< + dependencies::Bindings>>, + dependencies::Bindings>>>; + const Layout layout{.state_sizes = {metric.size()}, .residual_sizes = {metric.size()}}; + const auto inputs = dependencies::make_dependency_context( + dependencies::borrow(layout), + dependencies::borrow(scales), dependencies::borrow(metric)); + auto graph = std::apply([](auto... nodes) { + return dependencies::make_computation_graph(std::move(nodes)...); + }, normalization::make_normalization_computations(layout)); + auto cache = dependencies::make_computation_cache(std::move(graph), inputs); + REQUIRE(cache.try_prepare(inputs)); + return cache.template get(); + } + + /** @brief Keep dimensionless vectors and their measured norms together for cross-unit comparisons. */ + struct MeasuredCoordinates { + std::vector state; + std::vector residual; + double state_norm_squared; + double residual_norm_squared; + }; + + /** @brief Compare actual coordinates, because equal norms alone could hide different block weights or permutations. */ + void check_same_coordinates(const MeasuredCoordinates& first, const MeasuredCoordinates& second) { + REQUIRE(first.state.size() == second.state.size()); + REQUIRE(first.residual.size() == second.residual.size()); + for (std::size_t index = 0; index < first.state.size(); ++index) { + CHECK(second.state[index] == Catch::Approx(first.state[index]).epsilon(2.0e-11).margin(1.0e-13)); + CHECK(second.residual[index] == Catch::Approx(first.residual[index]).epsilon(2.0e-11).margin(1.0e-13)); + } + CHECK(second.state_norm_squared == Catch::Approx(first.state_norm_squared).epsilon(2.0e-11)); + CHECK(second.residual_norm_squared == Catch::Approx(first.residual_norm_squared).epsilon(2.0e-11)); + } + + /** @brief Use real MFEM projection and weak integration of scalar fields. */ + template + [[nodiscard]] MeasuredCoordinates volume_coordinates(mfem::FiniteElementCollection& collection, double length, double mass, double time) { + mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(2, 1, 1, mfem::Element::HEXAHEDRON, 2.0 * length, 1.5 * length, 0.75 * length); + for (int element = 0; element < mesh.GetNE(); ++element) { + mesh.GetElement(element)->SetAttribute(2); + } + mesh.SetAttributes(); + mfem::FiniteElementSpace space(&mesh, &collection); + const discretization::backend::mfem::TermBinding binding{space}; + const auto metric = discretization::backend::mfem::reference_mass_diagonal(binding); + REQUIRE(metric); + const normalization::StellarReferenceScales scales = reference_scales(length, mass, time); + const double physical_scale = normalization::characteristic_scale(scales, typename Term::Quantity{}).value(); + mfem::GridFunction field(&space); + mfem::LinearForm residual(&space); + mfem::ConstantCoefficient scalar(0.4 * physical_scale); + field.ProjectCoefficient(scalar); + residual.AddDomainIntegrator(new mfem::DomainLFIntegrator(scalar)); + residual.Assemble(); + const mfem::Vector state_coefficients = binding.gather(field); + const mfem::Vector residual_coefficients = binding.gather(residual); + const std::span state{state_coefficients.GetData(), static_cast(state_coefficients.Size())}; + const std::span weak{residual_coefficients.GetData(), static_cast(residual_coefficients.Size())}; + const auto factors = prepare_factors>(*metric, scales); + MeasuredCoordinates result{.state = std::vector(state.size()), .residual = std::vector(weak.size()), + .state_norm_squared = factors.state_norm_squared(state), .residual_norm_squared = factors.residual_norm_squared(weak)}; + factors.normalize_state(state, result.state); + factors.normalize_residual(weak, result.residual); + return result; + } + + /** @brief Supply an exact cellwise norm independent of a diagonal approximation to a higher-order Gram matrix. */ + struct ConstantDensityField final : blocks::field { + struct Values final : blocks::term { + using FunctionalSpace = discretization::functional_space::DiscreteFunctionalSpace; + using Support = discretization::domain::StellarDomains; + using Quantity = dimensions::MassDensity; + }; + }; + + /** @brief Build each native surface metric on the same physical sphere expressed in different length units. */ + template + [[nodiscard]] MeasuredCoordinates surface_coordinates(const Surface& surface, double length, double mass, double time) { + serif::tests::deformation::PairedMesh mesh(0, 0.0, 2); + mfem::VectorFunctionCoefficient dilation(3, [=](const mfem::Vector& point, mfem::Vector& image) { + image = point; + image *= length; + }); + mesh.generated.mesh->Transform(dilation); + const auto reference = deformation::backend::mfem::sample_reference_surface(mesh.scalar_space, mesh.surface_attribute); + REQUIRE(reference); + const auto embedding = deformation::build_surface_embedding(surface, reference->positions); + REQUIRE(embedding); + const auto metric = deformation::backend::mfem::surface_parameter_mass_diagonal(mesh.scalar_space, mesh.surface_attribute, *reference, *embedding); + REQUIRE(metric); + const normalization::StellarReferenceScales scales = reference_scales(length, mass, time); + const auto factors = prepare_factors(*metric, scales); + std::vector state(metric->size()), residual(metric->size()); + double expected_state_norm = 0.0; + double expected_residual_norm = 0.0; + double expected_work = 0.0; + for (std::size_t index = 0; index < metric->size(); ++index) { + const double shape = std::sin(0.7 * (index + 1)); + const double force = std::cos(0.3 * (index + 1)); + state[index] = shape * scales.radius.value(); + residual[index] = force * scales.force.value(); + const double area_fraction = (*metric)[index] / std::pow(scales.radius.value(), 2); + expected_state_norm += area_fraction * shape * shape; + expected_residual_norm += force * force / area_fraction; + expected_work += shape * force; + } + MeasuredCoordinates result{.state = std::vector(metric->size()), .residual = std::vector(metric->size()), + .state_norm_squared = factors.state_norm_squared(state), .residual_norm_squared = factors.residual_norm_squared(residual)}; + factors.normalize_state(state, result.state); + factors.normalize_residual(residual, result.residual); + CHECK(result.state_norm_squared == Catch::Approx(expected_state_norm).epsilon(2.0e-13)); + CHECK(result.residual_norm_squared == Catch::Approx(expected_residual_norm).epsilon(2.0e-13)); + double work = 0.0; + for (std::size_t index = 0; index < metric->size(); ++index) { + work += result.state[index] * result.residual[index]; + } + CHECK(work == Catch::Approx(expected_work).epsilon(2.0e-12)); + return result; + } +} + +TEST_CASE("Dimensionless volume coordinates are independent of mass length and time units", "[normalization][dimensionless][integration]") { + mfem::H1_FECollection enthalpy(3, 3); + mfem::H1_FECollection density(blocks::density::mass::FunctionalSpace::order, 3); + const auto check = [](mfem::FiniteElementCollection& collection) { + const MeasuredCoordinates baseline = volume_coordinates(collection, 1.0, 1.0, 1.0); + for (const std::array conversion : {std::array{100.0, 1000.0, 1.0}, std::array{0.03, 7.0, 0.2}}) { + check_same_coordinates(baseline, volume_coordinates(collection, conversion[0], conversion[1], conversion[2])); + } + }; + check.template operator()<::serif::tests::equations::Enthalpy>(enthalpy); + check.template operator()(density); +} + +TEST_CASE("Dimensionless cellwise state and weak residual norms recover the volume integral", "[normalization][dimensionless][math]") { + mfem::L2_FECollection collection(0, 3); + for (const double length : {1.0, 100.0}) { + const MeasuredCoordinates result = volume_coordinates(collection, length, 1000.0, 0.2); + const double integral = 0.4 * 0.4 * (2.0 * 1.5 * 0.75) / std::pow(3.0, 3); + CHECK(result.state_norm_squared == Catch::Approx(integral).epsilon(2.0e-13)); + CHECK(result.residual_norm_squared == Catch::Approx(integral).epsilon(2.0e-13)); + } +} + +TEST_CASE("Dimensionless harmonic and nodal shape norms retain force work across unit changes", "[normalization][dimensionless][integration]") { + const auto check = [](const Surface& surface) { + const MeasuredCoordinates baseline = surface_coordinates(surface, 1.0, 1.0, 1.0); + check_same_coordinates(baseline, surface_coordinates(surface, 100.0, 1000.0, 0.2)); + }; + check(deformation::SphericalHarmonicSurface{.maximum_degree = 2}); + check(deformation::NodalRadialSurface{}); +} + +TEST_CASE("Dimensionless point replacements retain their own pressure scale", "[normalization][dimensionless][math]") { + using Enthalpy = ::serif::tests::equations::Enthalpy; + using Metric = normalization::ReferenceVolumeMetric; + struct Selection {}; + using Layout = normalization::CoordinateLayout, dependencies::KeyList>; + using Method = normalization::WithPointRows, dimensions::Pressure, Selection>; + using Plan = normalization::NormalizationPlan, dependencies::Bindings>>; + const Layout layout{.state_sizes = {}, .residual_sizes = {2}}; + const std::vector rows{1}; + for (const double length : {1.0, 100.0}) { + const auto scales = reference_scales(length, 1000.0, 0.2); + const std::vector metric{4.0 * length * length * length, std::numeric_limits::quiet_NaN()}; + const auto inputs = dependencies::make_dependency_context(dependencies::borrow(layout), + dependencies::borrow(scales), dependencies::borrow(metric), dependencies::borrow(rows)); + auto graph = std::apply([](auto... nodes) { + return dependencies::make_computation_graph(std::move(nodes)...); + }, normalization::make_normalization_computations(layout)); + auto cache = dependencies::make_computation_cache(std::move(graph), inputs); + REQUIRE(cache.try_prepare(inputs)); + const auto& factors = cache.get(); + std::array residual{scales.specific_energy.value() * 8.0 * std::pow(length, 3), scales.pressure.value() * 0.7}; + std::array normalized; + factors.normalize_residual(residual, normalized); + CHECK(normalized[0] == Catch::Approx(4.0 / std::sqrt(27.0)).epsilon(2.0e-13)); + CHECK(normalized[1] == Catch::Approx(0.7).epsilon(2.0e-13)); + } +} + +TEST_CASE("Dimensionless geometry preparation reports invalid or unrepresentable scales", "[normalization][dimensionless][validation]") { + for (const double invalid : {0.0, -1.0, std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()}) { + CHECK_FALSE(normalization::detail::dimensionless_riesz_scale(Kind::value, normalization::ReferenceGeometry::volume, 1.0, invalid)); + CHECK_FALSE(normalization::detail::dimensionless_riesz_scale(Kind::residual, normalization::ReferenceGeometry::volume, invalid, 1.0)); + } + CHECK_FALSE(normalization::detail::dimensionless_riesz_scale(Kind::value, normalization::ReferenceGeometry::volume, 1.0e300, 1.0e100)); + CHECK_FALSE(normalization::detail::dimensionless_riesz_scale(Kind::value, static_cast(99), 1.0, 1.0)); + CHECK_FALSE(normalization::detail::dimensionless_riesz_scale(static_cast(99), normalization::ReferenceGeometry::volume, 1.0, 1.0)); +} + +TEST_CASE("Dimensionless global coordinates retain independent unknown and constraint scales", "[normalization][dimensionless][math]") { + using MassConstant = blocks::barotropic_constant::mass_normalization; + for (const std::array conversion : {std::array{1.0, 1.0, 1.0}, std::array{100.0, 1000.0, 0.2}}) { + const auto scales = reference_scales(conversion[0], conversion[1], conversion[2]); + const auto factors = prepare_factors({1.0}, scales); + const std::array state{0.3 * scales.specific_energy.value()}, residual{0.7 * scales.mass.value()}; + CHECK(factors.state_norm_squared(state) == Catch::Approx(0.09).epsilon(2.0e-13)); + CHECK(factors.residual_norm_squared(residual) == Catch::Approx(0.49).epsilon(2.0e-13)); + } +} diff --git a/tests/normalization/meson.build b/tests/normalization/meson.build new file mode 100644 index 00000000..7f754a39 --- /dev/null +++ b/tests/normalization/meson.build @@ -0,0 +1,12 @@ +normalization_test_sources = files( + 'computations.cpp', + 'configuration.cpp', + 'diagonal.cpp', + 'dimensionless.cpp', + 'operators.cpp', + 'point_rows.cpp', + 'scales.cpp', + 'surface_metric.cpp', + 'volume_computations.cpp', + 'volume_metrics.cpp', +) diff --git a/tests/normalization/operators.cpp b/tests/normalization/operators.cpp new file mode 100644 index 00000000..aa90d993 --- /dev/null +++ b/tests/normalization/operators.cpp @@ -0,0 +1,316 @@ +#include "serif/utils/error/errors.hpp" +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "serif/backend/mfem/solver/normalization/operators.hpp" + +namespace +{ + namespace normalization = serif::solver::normalization; + namespace backend = normalization::backend::mfem; + + /** @brief Form a vector from physically readable test coordinates. */ + mfem::Vector vector(std::initializer_list values) + { + mfem::Vector result(static_cast(values.size())); + int index = 0; + for (const double value : values) + { + result(index++) = value; + } + return result; + } + + /** @brief Compare coordinate actions at the original MeanField relative tolerances. */ + void check_vector(const mfem::Vector &actual, const mfem::Vector &expected, double epsilon = 2.0e-13) + { + REQUIRE(actual.Size() == expected.Size()); + for (int index = 0; index < actual.Size(); ++index) + { + CHECK(actual(index) == Catch::Approx(expected(index)).epsilon(epsilon).margin(1.0e-300)); + } + } + + /** @brief Exact physical inverse which records what dimensional Jacobian MFEM binds. */ + class DenseInverseSolver final : public mfem::Solver + { + public: + /** @brief Copy a small reference inverse for independent solver assertions. */ + explicit DenseInverseSolver(const mfem::DenseMatrix &inverse) : mfem::Solver(inverse.Height(), inverse.Width()), m_inverse(inverse) + { + } + + /** @brief Bind the dimensional Jacobian, permitting a rectangular inverse map. */ + void SetOperator(const mfem::Operator &operation) override + { + if (operation.Height() != Width() || operation.Width() != Height()) + { + serif::utils::errors::serif_error("Dense test inverse received an incompatible Jacobian."); + } + bound_operator = &operation; + ++bindings; + } + + /** @brief Apply the exact inverse after checking the iterative initial guess. */ + void Mult(const mfem::Vector &input, mfem::Vector &output) const override + { + if (iterative_mode) + { + for (int index = 0; index < output.Size(); ++index) + { + CHECK(output(index) == 0.0); + } + } + m_inverse.Mult(input, output); + } + + /** @brief Apply the transpose of the exact inverse. */ + void MultTranspose(const mfem::Vector &input, mfem::Vector &output) const override + { + m_inverse.MultTranspose(input, output); + } + + const mfem::Operator *bound_operator{nullptr}; + int bindings{0}; + + private: + mfem::DenseMatrix m_inverse; + }; + + /** @brief Recover dimensional matrices from a well-conditioned normalized test problem. */ + struct PhysicalSystem final + { + normalization::DiagonalNormalization map = *normalization::make_diagonal_normalization({1.0e-9, 2.0e7}, {5.0e8, 3.0e-6}); + mfem::DenseMatrix jacobian{2}; + mfem::DenseMatrix inverse{2}; + + /** @brief Use independently known normalized matrix and inverse entries. */ + PhysicalSystem() + { + constexpr double normalized_matrix[2][2]{{4.0, 1.0}, {2.0, 3.0}}; + constexpr double normalized_inverse[2][2]{{0.3, -0.1}, {-0.2, 0.4}}; + for (int row = 0; row < 2; ++row) + { + for (int column = 0; column < 2; ++column) + { + jacobian(row, column) = normalized_matrix[row][column] * map.state_factors()[column] / map.residual_factors()[row]; + inverse(row, column) = normalized_inverse[row][column] * map.residual_factors()[column] / map.state_factors()[row]; + } + } + } + }; +} + +// Ported from MeanField tests/normalization/physical_riesz.cpp, with transpose coverage added. +TEST_CASE("Scaled Jacobian and inverse implement the exact coordinate change", "[normalization][unit][linear-algebra]") +{ + const PhysicalSystem physical; + const backend::ScaledJacobianOperator jacobian(physical.jacobian, physical.map); + const backend::ScaledInverseOperator inverse(physical.inverse, physical.map); + const mfem::Vector direction = vector({0.75, -1.25}); + mfem::Vector action; + jacobian.Mult(direction, action); + check_vector(action, vector({1.75, -2.25}), 4.0e-15); + mfem::Vector recovered; + inverse.Mult(action, recovered); + check_vector(recovered, direction); + + jacobian.MultTranspose(direction, action); + check_vector(action, vector({0.5, -3.0}), 4.0e-15); + inverse.MultTranspose(action, recovered); + check_vector(recovered, direction); +} + +TEST_CASE("Scaled physical preconditioning preserves dimensional binding through FGMRES", "[normalization][unit][solver]") +{ + const PhysicalSystem physical; + const backend::ScaledJacobianOperator jacobian(physical.jacobian, physical.map); + DenseInverseSolver inverse(physical.inverse); + backend::ScaledPreconditioner preconditioner(inverse, jacobian); + CHECK(inverse.bound_operator == &physical.jacobian); + const mfem::Vector right_hand_side = vector({1.5, -0.75}); + const mfem::Vector expected = vector({0.525, -0.6}); + mfem::Vector correction(2); + preconditioner.Mult(right_hand_side, correction); + check_vector(correction, expected, 3.0e-13); + + mfem::FGMRESSolver krylov; + krylov.SetRelTol(1.0e-13); + krylov.SetAbsTol(1.0e-15); + krylov.SetMaxIter(4); + krylov.SetKDim(2); + krylov.SetPrintLevel(0); + krylov.SetPreconditioner(preconditioner); + krylov.SetOperator(jacobian); + mfem::Vector solution(2); + solution = 0.0; + krylov.Mult(right_hand_side, solution); + CHECK(krylov.GetConverged()); + CHECK(krylov.GetNumIterations() <= 1); + check_vector(solution, expected, 3.0e-13); + CHECK(inverse.bound_operator == &physical.jacobian); + CHECK(inverse.bindings >= 2); + CHECK(preconditioner.statistics().operator_bindings >= 2); + CHECK(preconditioner.statistics().applications >= 2); + + preconditioner.MultTranspose(right_hand_side, correction); + check_vector(correction, vector({0.6, -0.45})); +} + +TEST_CASE("Scaled preconditioners reject unrelated preparations and malformed vectors", "[normalization][unit][validation]") +{ + const PhysicalSystem physical; + const backend::ScaledJacobianOperator jacobian(physical.jacobian, physical.map); + DenseInverseSolver inverse(physical.inverse); + backend::ScaledPreconditioner preconditioner(inverse, jacobian); + const backend::ScaledJacobianOperator unrelated(physical.jacobian, physical.map); + const mfem::IdentityOperator wrong_size(3); + CHECK_THROWS_MATCHES(preconditioner.SetOperator(unrelated), std::invalid_argument, Catch::Matchers::MessageMatches(Catch::Matchers::ContainsSubstring("NORMALIZATION_ERROR"))); + CHECK_THROWS_MATCHES(preconditioner.SetOperator(wrong_size), std::invalid_argument, Catch::Matchers::MessageMatches(Catch::Matchers::ContainsSubstring("NORMALIZATION_ERROR"))); + CHECK(inverse.bindings == 1); + + const mfem::Vector right_hand_side = vector({1.0, 2.0}); + mfem::Vector wrong_output(1); + CHECK_THROWS_MATCHES(preconditioner.Mult(right_hand_side, wrong_output), std::invalid_argument, Catch::Matchers::MessageMatches(Catch::Matchers::ContainsSubstring("NORMALIZATION_VECTOR_SIZE_MISMATCH"))); + CHECK_THROWS_MATCHES(preconditioner.MultTranspose(right_hand_side, wrong_output), std::invalid_argument, Catch::Matchers::MessageMatches(Catch::Matchers::ContainsSubstring("NORMALIZATION_VECTOR_SIZE_MISMATCH"))); + mfem::Vector output(2); + output = 7.0; + CHECK_THROWS_MATCHES(preconditioner.Mult(wrong_output, output), std::invalid_argument, Catch::Matchers::MessageMatches(Catch::Matchers::ContainsSubstring("NORMALIZATION_VECTOR_SIZE_MISMATCH"))); + check_vector(output, vector({7.0, 7.0})); + CHECK(preconditioner.statistics().applications == 0); + CHECK_THROWS_MATCHES((backend::ScaledJacobianOperator{wrong_size, physical.map}), std::invalid_argument, Catch::Matchers::MessageMatches(Catch::Matchers::ContainsSubstring("MATRIX_SHAPE_ERROR"))); + CHECK_THROWS_MATCHES((backend::ScaledInverseOperator{wrong_size, physical.map}), std::invalid_argument, Catch::Matchers::MessageMatches(Catch::Matchers::ContainsSubstring("MATRIX_SHAPE_ERROR"))); +} + +TEST_CASE("Physical Riesz scaling collapses a forty-eight-decade diagonal imbalance", "[normalization][unit][numerics]") +{ + const auto map = normalization::make_diagonal_normalization({1.0e-12, 1.0, 1.0e12}, {1.0e12, 1.0, 1.0e-12}); + REQUIRE(map); + mfem::DenseMatrix physical(3); + physical = 0.0; + for (int index = 0; index < 3; ++index) + { + physical(index, index) = map->state_factors()[index] / map->residual_factors()[index]; + } + CHECK(physical(2, 2) / physical(0, 0) == Catch::Approx(1.0e48)); + const backend::ScaledJacobianOperator jacobian(physical, *map); + const mfem::Vector direction = vector({-2.0, 3.5, 0.125}); + mfem::Vector action; + jacobian.Mult(direction, action); + check_vector(action, direction, 4.0e-15); +} + +TEST_CASE("Rectangular normalized operators and their transposes preserve independent coordinate sizes", "[normalization][unit][linear-algebra]") +{ + const auto map = normalization::make_diagonal_normalization({2.0, 3.0}, {5.0, 7.0, 11.0}); + REQUIRE(map); + mfem::DenseMatrix physical(3, 2); + mfem::DenseMatrix physical_inverse(2, 3); + for (int row = 0; row < 3; ++row) + { + for (int column = 0; column < 2; ++column) + { + const double entry = 1.0 + 2.0 * row + column; + physical(row, column) = entry * map->state_factors()[column] / map->residual_factors()[row]; + physical_inverse(column, row) = entry * map->residual_factors()[row] / map->state_factors()[column]; + } + } + const backend::ScaledJacobianOperator jacobian(physical, *map); + const backend::ScaledInverseOperator inverse(physical_inverse, *map); + mfem::Vector action; + jacobian.Mult(vector({2.0, -0.5}), action); + check_vector(action, vector({1.0, 4.0, 7.0})); + jacobian.MultTranspose(vector({1.0, 2.0, 3.0}), action); + check_vector(action, vector({22.0, 28.0})); + inverse.Mult(vector({1.0, 2.0, 3.0}), action); + check_vector(action, vector({22.0, 28.0})); + inverse.MultTranspose(vector({2.0, -0.5}), action); + check_vector(action, vector({1.0, 4.0, 7.0})); + + DenseInverseSolver physical_solver(physical_inverse); + backend::ScaledPreconditioner preconditioner(physical_solver, jacobian); + CHECK(physical_solver.bound_operator == &physical); + action.SetSize(2); + preconditioner.Mult(vector({1.0, 2.0, 3.0}), action); + check_vector(action, vector({22.0, 28.0})); + action.SetSize(3); + preconditioner.MultTranspose(vector({2.0, -0.5}), action); + check_vector(action, vector({1.0, 4.0, 7.0})); +} + +TEST_CASE("Normalized adapters own frozen factors and support in-place applications", "[normalization][unit][lifetime]") +{ + PhysicalSystem physical; + const backend::ScaledJacobianOperator jacobian(physical.jacobian, physical.map); + physical.map = *normalization::make_diagonal_normalization({1.0, 1.0}, {1.0, 1.0}); + CHECK(jacobian.normalization().state_factors()[0] == 1.0e-9); + CHECK(&jacobian.physical_operator() == &physical.jacobian); + mfem::Vector values = vector({0.75, -1.25}); + jacobian.Mult(values, values); + check_vector(values, vector({1.75, -2.25})); + values = vector({0.75, -1.25}); + jacobian.MultTranspose(values, values); + check_vector(values, vector({0.5, -3.0})); +} + +TEST_CASE("Physical iterative inverses receive fresh zero correction guesses", "[normalization][unit][solver]") +{ + const PhysicalSystem physical; + const backend::ScaledJacobianOperator jacobian(physical.jacobian, physical.map); + DenseInverseSolver inverse(physical.inverse); + inverse.iterative_mode = true; + backend::ScaledPreconditioner preconditioner(inverse, jacobian); + mfem::Vector correction(2); + correction = 41.0; + preconditioner.Mult(vector({1.5, -0.75}), correction); + check_vector(correction, vector({0.525, -0.6})); + preconditioner.Mult(vector({1.5, -0.75}), correction); + check_vector(correction, vector({0.525, -0.6})); +} + +TEST_CASE("Scaled preconditioners bind an initially unconfigured physical solver before checking its dimensions", "[normalization][unit][solver]") +{ + mfem::DenseMatrix physical(2); + physical(0, 0) = 4.0; + physical(0, 1) = 1.0; + physical(1, 0) = 1.0; + physical(1, 1) = 3.0; + const auto map = normalization::make_diagonal_normalization({2.0, 5.0}, {7.0, 11.0}); + REQUIRE(map); + const backend::ScaledJacobianOperator jacobian(physical, *map); + mfem::CGSolver inverse; + inverse.SetRelTol(1.0e-14); + inverse.SetAbsTol(1.0e-15); + inverse.SetMaxIter(4); + inverse.SetPrintLevel(0); + CHECK(inverse.Width() == 0); + CHECK(inverse.Height() == 0); + + backend::ScaledPreconditioner preconditioner(inverse, jacobian); + CHECK(inverse.Width() == 2); + CHECK(inverse.Height() == 2); + CHECK(preconditioner.statistics().operator_bindings == 1); + mfem::Vector correction(2); + preconditioner.Mult(vector({2.45, -3.575}), correction); + CHECK(inverse.GetConverged()); + check_vector(correction, vector({0.25, -0.75})); + + preconditioner.SetOperator(jacobian); + CHECK(preconditioner.statistics().operator_bindings == 2); +} + +TEST_CASE("Normalization reports MFEM index overflow before narrowing", "[normalization][unit][validation]") +{ + const std::size_t largest_index = static_cast(std::numeric_limits::max()); + CHECK(backend::detail::mfem_size(0) == 0); + CHECK(backend::detail::mfem_size(largest_index) == std::numeric_limits::max()); + CHECK_THROWS_MATCHES(backend::detail::mfem_size(largest_index + 1), std::invalid_argument, Catch::Matchers::MessageMatches(Catch::Matchers::ContainsSubstring("INVALID_DOF_COUNT"))); +} diff --git a/tests/normalization/point_rows.cpp b/tests/normalization/point_rows.cpp new file mode 100644 index 00000000..664e30d5 --- /dev/null +++ b/tests/normalization/point_rows.cpp @@ -0,0 +1,87 @@ +#include +#include + +#include +#include +#include + +#include "serif/solver/normalization/diagonal.hpp" +#include "serif/solver/normalization/point_rows.hpp" + +namespace +{ + namespace normalization = serif::solver::normalization; +} + +// Ported from MeanField tests/normalization/physical_riesz.cpp, without assuming matching pressure and enthalpy scales. +TEST_CASE("Replaced residual rows use point metrics and independent physical units", "[normalization][unit][math]") +{ + const std::array metric{4.0, 0.0, 16.0, 0.0}; + const std::array point_rows{1, 3}; + const auto enthalpy = normalization::make_point_replaced_residual_factors(metric, 5.0, point_rows, 5.0); + REQUIRE(enthalpy); + CHECK((*enthalpy)[0] == Catch::Approx(1.0 / 10.0)); + CHECK((*enthalpy)[1] == Catch::Approx(1.0 / 5.0)); + CHECK((*enthalpy)[2] == Catch::Approx(1.0 / 20.0)); + CHECK((*enthalpy)[3] == Catch::Approx(1.0 / 5.0)); + + const auto pressure = normalization::make_point_replaced_residual_factors(metric, 5.0, point_rows, 7.0); + REQUIRE(pressure); + CHECK((*pressure)[0] == (*enthalpy)[0]); + CHECK((*pressure)[2] == (*enthalpy)[2]); + CHECK((*pressure)[1] == Catch::Approx(1.0 / 7.0)); + CHECK((*pressure)[3] == Catch::Approx(1.0 / 7.0)); +} + +TEST_CASE("Point normalization rejects duplicate and out-of-range physical row selections", "[normalization][unit][validation]") +{ + const std::array metric{4.0, 9.0}; + const auto duplicate = normalization::make_point_replaced_residual_factors(metric, 1.0, std::array{1, 1}, 1.0); + REQUIRE_FALSE(duplicate); + CHECK_THAT(duplicate.error(), Catch::Matchers::ContainsSubstring("INVALID_ARGUMENT")); + for (const int row : {-1, 2}) + { + const auto outside = normalization::make_point_replaced_residual_factors(metric, 1.0, std::array{row}, 1.0); + REQUIRE_FALSE(outside); + CHECK_THAT(outside.error(), Catch::Matchers::ContainsSubstring("OUT_OF_RANGE_ERROR")); + } + CHECK_FALSE(normalization::make_point_replaced_residual_factors({}, 1.0, std::array{0}, 1.0)); +} + +TEST_CASE("Only replaced residual rows may have unusable volume metrics", "[normalization][unit][validation]") +{ + for (const double invalid : {0.0, -1.0, std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity()}) + { + const std::array metric{4.0, invalid}; + const auto replaced = normalization::make_point_replaced_residual_factors(metric, 2.0, std::array{1}, 5.0); + REQUIRE(replaced); + CHECK((*replaced)[0] == Catch::Approx(0.25)); + CHECK((*replaced)[1] == Catch::Approx(0.2)); + CHECK_FALSE(normalization::make_point_replaced_residual_factors(metric, 2.0, {}, 5.0)); + CHECK_FALSE(normalization::make_point_replaced_residual_factors(metric, invalid, std::array{1}, 5.0)); + CHECK_FALSE(normalization::make_point_replaced_residual_factors(metric, 2.0, std::array{1}, invalid)); + } +} + +TEST_CASE("Point row composition validates factors only in the representations actually used", "[normalization][unit][math]") +{ + const auto extreme_bulk = normalization::make_point_replaced_residual_factors(std::array{1.0e300, 0.0}, 1.0e-310, std::array{1}, 2.0); + REQUIRE(extreme_bulk); + CHECK((*extreme_bulk)[0] == Catch::Approx(1.0e160).epsilon(1.0e-12)); + CHECK((*extreme_bulk)[1] == 0.5); + + const auto no_points = normalization::make_point_replaced_residual_factors(std::array{4.0}, 2.0, {}, 1.0e-310); + REQUIRE(no_points); + CHECK(no_points->front() == 0.25); + CHECK_FALSE(normalization::make_point_replaced_residual_factors(std::array{4.0}, 2.0, std::array{0}, 1.0e-310)); + + const auto no_bulk = normalization::make_point_replaced_residual_factors(std::array{0.0}, 1.0e-310, std::array{0}, 2.0); + REQUIRE(no_bulk); + CHECK(no_bulk->front() == 0.5); + const auto empty = normalization::make_point_replaced_residual_factors({}, 1.0e-310, {}, 1.0e-310); + REQUIRE(empty); + CHECK(empty->empty()); + const auto empty_factors = normalization::make_riesz_factors(serif::discretization::blocks::RepresentedValueKind::residual, {}, 1.0e-310); + REQUIRE(empty_factors); + CHECK(empty_factors->empty()); +} diff --git a/tests/normalization/scales.cpp b/tests/normalization/scales.cpp new file mode 100644 index 00000000..807f41de --- /dev/null +++ b/tests/normalization/scales.cpp @@ -0,0 +1,101 @@ +#include +#include +#include +#include + +#include +#include + +#include "serif/solver/normalization/scales.hpp" + +namespace +{ + namespace dimensions = serif::dimensions; + namespace normalization = serif::solver::normalization; + + /** @brief Supply typed reference values while retaining concise physical test cases. */ + std::expected scales(double mass, double radius, double gravity = 1.0) + { + return normalization::prepare_stellar_reference_scales(dimensions::QuantityValue{mass}, dimensions::QuantityValue{radius}, dimensions::QuantityValue{gravity}); + } +} + +// Ported from MeanField tests/normalization/physical_riesz.cpp. +TEST_CASE("Characteristic stellar scales satisfy gravity virial and rotation identities", "[normalization][unit][physics]") +{ + constexpr double mass = 7.0; + constexpr double radius = 3.0; + constexpr double gravity = 5.0; + const auto prepared = scales(mass, radius, gravity); + REQUIRE(prepared); + const normalization::StellarReferenceScales &reference = *prepared; + + CHECK(reference.density.value() == Catch::Approx(mass / std::pow(radius, 3))); + CHECK(reference.acceleration.value() == Catch::Approx(gravity * mass / std::pow(radius, 2))); + CHECK(reference.specific_energy.value() == Catch::Approx(gravity * mass / radius)); + CHECK(reference.pressure.value() == Catch::Approx(gravity * mass * mass / std::pow(radius, 4))); + CHECK(reference.angular_velocity.value() == Catch::Approx(std::sqrt(gravity * mass / std::pow(radius, 3)))); + CHECK(reference.angular_momentum.value() == Catch::Approx(mass * std::sqrt(gravity * mass * radius))); + + const double virial = reference.pressure.value() * std::pow(radius, 3); + CHECK(virial == Catch::Approx(mass * reference.specific_energy.value()).epsilon(2.0e-15)); + CHECK(virial == Catch::Approx(reference.force.value() * radius).epsilon(2.0e-15)); + CHECK(reference.angular_velocity.value() * reference.angular_velocity.value() * radius == Catch::Approx(reference.acceleration.value()).epsilon(2.0e-15)); + CHECK(reference.angular_momentum.value() == Catch::Approx(mass * radius * radius * reference.angular_velocity.value()).epsilon(2.0e-15)); +} + +TEST_CASE("Characteristic scales obey stellar homology exponents", "[normalization][unit][physics]") +{ + const auto reference = scales(2.5, 4.0, 3.0); + constexpr double mass_factor = 11.0; + constexpr double radius_factor = 0.2; + constexpr double gravity_factor = 7.0; + const auto transformed = scales(2.5 * mass_factor, 4.0 * radius_factor, 3.0 * gravity_factor); + REQUIRE(reference); + REQUIRE(transformed); + + CHECK(transformed->density.value() / reference->density.value() == Catch::Approx(mass_factor / std::pow(radius_factor, 3)).epsilon(4.0e-15)); + CHECK(transformed->acceleration.value() / reference->acceleration.value() == Catch::Approx(gravity_factor * mass_factor / std::pow(radius_factor, 2)).epsilon(4.0e-15)); + CHECK(transformed->inverse_time_squared.value() / reference->inverse_time_squared.value() == Catch::Approx(gravity_factor * mass_factor / std::pow(radius_factor, 3)).epsilon(4.0e-15)); + CHECK(transformed->specific_energy.value() / reference->specific_energy.value() == Catch::Approx(gravity_factor * mass_factor / radius_factor).epsilon(4.0e-15)); + CHECK(transformed->pressure.value() / reference->pressure.value() == Catch::Approx(gravity_factor * mass_factor * mass_factor / std::pow(radius_factor, 4)).epsilon(4.0e-15)); + CHECK(transformed->angular_velocity.value() / reference->angular_velocity.value() == Catch::Approx(std::sqrt(gravity_factor * mass_factor / std::pow(radius_factor, 3))).epsilon(4.0e-15)); + CHECK(transformed->angular_momentum.value() / reference->angular_momentum.value() == Catch::Approx(mass_factor * std::sqrt(gravity_factor * mass_factor * radius_factor)).epsilon(4.0e-15)); + CHECK(transformed->force.value() / reference->force.value() == Catch::Approx(gravity_factor * mass_factor * mass_factor / std::pow(radius_factor, 2)).epsilon(4.0e-15)); +} + +TEST_CASE("Typed scales distinguish residual quantities and share the gravitational energy scale", "[normalization][unit][physics]") +{ + const auto reference = scales(9.0, 2.0, 4.0); + REQUIRE(reference); + CHECK(normalization::characteristic_scale(*reference, dimensions::Dimensionless{}).value() == 1.0); + CHECK(normalization::characteristic_scale(*reference, dimensions::Mass{}).value() == 9.0); + CHECK(normalization::characteristic_scale(*reference, dimensions::Length{}).value() == 2.0); + CHECK(normalization::characteristic_scale(*reference, dimensions::MassDensity{}) == reference->density); + CHECK(normalization::characteristic_scale(*reference, dimensions::Acceleration{}) == reference->acceleration); + CHECK(normalization::characteristic_scale(*reference, dimensions::InverseTimeSquared{}) == reference->inverse_time_squared); + CHECK(normalization::characteristic_scale(*reference, dimensions::Pressure{}) == reference->pressure); + CHECK(normalization::characteristic_scale(*reference, dimensions::AngularVelocity{}) == reference->angular_velocity); + CHECK(normalization::characteristic_scale(*reference, dimensions::AngularMomentum{}) == reference->angular_momentum); + CHECK(normalization::characteristic_scale(*reference, dimensions::Force{}) == reference->force); + CHECK(normalization::characteristic_scale(*reference, dimensions::SpecificEnergy{}) == reference->specific_energy); + CHECK(normalization::characteristic_scale(*reference, dimensions::SpecificEnthalpy{}).value() == reference->specific_energy.value()); + CHECK(normalization::characteristic_scale(*reference, dimensions::GravitationalPotential{}).value() == reference->specific_energy.value()); + CHECK(normalization::characteristic_scale(*reference, dimensions::SpecificEnthalpy{}).value() != reference->density.value()); + CHECK(*reference == *scales(9.0, 2.0, 4.0)); + CHECK_FALSE(*reference == *scales(9.0, 2.1, 4.0)); +} + +TEST_CASE("Characteristic scale preparation rejects invalid and unrepresentable references", "[normalization][unit][validation]") +{ + for (const double invalid : {0.0, -1.0, std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity()}) + { + CHECK_FALSE(scales(invalid, 1.0)); + CHECK_FALSE(scales(1.0, invalid)); + CHECK_FALSE(scales(1.0, 1.0, invalid)); + } + CHECK_FALSE(scales(1.0e300, 1.0e-200, 1.0e100)); + CHECK_FALSE(scales(1.0, 1.0e300)); + CHECK_FALSE(scales(1.0e-300, 1.0)); + REQUIRE(scales(1.0, 1.0)); +} diff --git a/tests/normalization/surface_metric.cpp b/tests/normalization/surface_metric.cpp new file mode 100644 index 00000000..0c199070 --- /dev/null +++ b/tests/normalization/surface_metric.cpp @@ -0,0 +1,232 @@ +// MeanField normalization/spherical_harmonic_surface.cpp, composed with the existing reference geometry graph. +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "mfem/fem/coefficient.hpp" +#include "serif/backend/mfem/geometry/deformation/computations.hpp" +#include "serif/solver/normalization/computations.hpp" +#include "serif/tests/deformation/mesh.hpp" +#include "serif/utils/dependencies/computation_cache.hpp" + +namespace +{ + namespace dependencies = serif::utils::dependencies; + namespace deformation = serif::geometry::deformation; + namespace backend = deformation::backend::mfem; + namespace normalization = serif::solver::normalization; + namespace dimensions = serif::dimensions; + namespace blocks = serif::discretization::blocks; + namespace keys = deformation::keys; + namespace normalization_keys = normalization::keys; + using Status = dependencies::PreparationStatus; + using Failure = std::variant; + using Shape = blocks::surface_deformation::coefficients; + using Force = blocks::surface_deformation::shape_equilibrium; + using Layout = normalization::CoordinateLayout, dependencies::KeyList>; + using Plan = normalization::NormalizationPlan< + dependencies::Bindings>>, + dependencies::Bindings>>>; + + /** @brief Retain all borrowed reference and trial resources while selecting only reference normalization work. */ + struct SurfaceFixture + { + serif::tests::deformation::PairedMesh mesh{1, 0.0, 3}; + backend::ReferenceDiscretization discretization = backend::deformation_discretization(mesh.scalar_space, mesh.vector_space, *mesh.generated.reference_mesh); + deformation::DomainDeformation model{.surface = {.maximum_degree = 4}, .stellar_interior = {.radial_power = 0.5}, .vacuum = {}}; + std::vector coefficients{std::numeric_limits::quiet_NaN()}; + Layout layout{.state_sizes = {25}, .residual_sizes = {25}}; + normalization::StellarReferenceScales scales = *normalization::prepare_stellar_reference_scales(dimensions::QuantityValue{2.0}, dimensions::QuantityValue{3.0}); + + /** @brief Preserve each existing deformation resource's identity and tracking when adding normalization inputs. */ + [[nodiscard]] auto inputs() const + { + const auto reference = backend::make_deformation_inputs(model, discretization, coefficients); + return dependencies::make_dependency_context( + reference.resource(), + reference.resource(), + reference.resource(), + reference.resource(), + reference.resource(), + reference.resource(), + reference.resource(), + dependencies::borrow(layout), + dependencies::borrow(scales)); + } + }; + + /** @brief Share one metric producer between the primal and dual coordinate factors. */ + [[nodiscard]] auto make_cache(const SurfaceFixture& fixture) + { + auto computations = std::tuple_cat(backend::make_deformation_computations(), std::tuple{backend::make_surface_metric_computation()}, normalization::make_normalization_computations(fixture.layout)); + auto graph = std::apply([](auto... nodes) + { + return dependencies::make_computation_graph(std::move(nodes)...); + }, std::move(computations)); + return dependencies::make_computation_cache(std::move(graph), fixture.inputs()); + } + + /** @brief Recover MeanField's primal/dual factors and cancellation of reference Gram weights in physical work. */ + void check_harmonic_factors(const normalization::DiagonalNormalization& coordinates, const std::vector& metric, const normalization::StellarReferenceScales& scales) + { + REQUIRE(coordinates.state_size() == metric.size()); + REQUIRE(coordinates.residual_size() == metric.size()); + double physical_work = 0.0; + double normalized_work = 0.0; + std::vector direction(metric.size()), force(metric.size()), normalized_direction(metric.size()), normalized_force(metric.size()); + for (std::size_t mode = 0; mode < metric.size(); ++mode) + { + CAPTURE(mode); + REQUIRE(std::isfinite(metric[mode])); + REQUIRE(metric[mode] > 0.0); + CHECK(coordinates.state_factors()[mode] == Catch::Approx(std::sqrt(metric[mode]) / scales.radius.value()).epsilon(3.0e-14)); + CHECK(coordinates.residual_factors()[mode] == Catch::Approx(1.0 / (scales.force.value() * std::sqrt(metric[mode]))).epsilon(3.0e-14)); + CHECK(coordinates.state_factors()[mode] * coordinates.residual_factors()[mode] == Catch::Approx(1.0 / (scales.radius.value() * scales.force.value())).epsilon(3.0e-14)); + direction[mode] = std::sin(0.7 * (mode + 1)); + force[mode] = std::cos(0.3 * (mode + 1)); + physical_work += direction[mode] * force[mode]; + } + coordinates.normalize_state(direction, normalized_direction); + coordinates.normalize_residual(force, normalized_force); + for (std::size_t mode = 0; mode < metric.size(); ++mode) + { + normalized_work += normalized_direction[mode] * normalized_force[mode]; + } + CHECK(normalized_work == Catch::Approx(physical_work / (scales.radius.value() * scales.force.value())).epsilon(5.0e-14)); + } +} + +TEST_CASE("Harmonic normalization uses native shape metrics before candidate preparation", "[integration][normalization][meanfield]") +{ + SurfaceFixture fixture; + SECTION("All real azimuthal modes") + { + } + SECTION("Axisymmetric modes") + { + fixture.model.surface.maximum_azimuthal_order = 0; + fixture.layout = {.state_sizes = {5}, .residual_sizes = {5}}; + } + auto cache = make_cache(fixture); + const auto initial = cache.try_prepare(fixture.inputs(), dependencies::RequestedResults{}); + REQUIRE(initial); + CHECK(initial->computed_count() == 7); + CHECK(initial->status() == Status::computed); + CHECK(initial->status() == Status::not_required); + CHECK(initial->status() == Status::not_required); + CHECK(initial->status() == Status::not_required); + CHECK(initial->status() == Status::not_required); + CHECK_FALSE(cache.contains()); + CHECK_FALSE(cache.contains()); + CHECK(cache.get().size() == fixture.layout.state_sizes[0]); + check_harmonic_factors(cache.get(), cache.get(), fixture.scales); +} + +TEST_CASE("Reference changes refresh harmonic normalization while frozen solve coordinates remain unchanged", "[integration][normalization][meanfield]") +{ + SurfaceFixture fixture; + auto cache = make_cache(fixture); + const auto requested = dependencies::RequestedResults{}; + REQUIRE(cache.try_prepare(fixture.inputs(), requested)); + const normalization::DiagonalNormalization frozen = cache.get(); + const std::vector original_metric = cache.get(); + const std::vector original_factors(frozen.state_factors().begin(), frozen.state_factors().end()); + + fixture.coefficients.assign(25, 0.03); + fixture.model.stellar_interior.radial_power = 3.0; + const auto trial_changed = cache.try_prepare(fixture.inputs(), requested); + REQUIRE(trial_changed); + CHECK(trial_changed->computed_count() == 0); + CHECK(trial_changed->status() == Status::reused); + CHECK(trial_changed->status() == Status::reused); + CHECK_FALSE(cache.contains()); + + fixture.scales = *normalization::prepare_stellar_reference_scales(dimensions::QuantityValue{4.0}, dimensions::QuantityValue{5.0}); + const auto scales_changed = cache.try_prepare(fixture.inputs(), requested); + REQUIRE(scales_changed); + CHECK(scales_changed->computed_count() == 3); + CHECK(scales_changed->status() == Status::reused); + CHECK(cache.get() == original_metric); + check_harmonic_factors(cache.get(), original_metric, fixture.scales); + CHECK_FALSE(std::ranges::equal(cache.get().state_factors(), original_factors)); + CHECK(std::ranges::equal(frozen.state_factors(), original_factors)); + + mfem::VectorFunctionCoefficient stretch(3, [](const mfem::Vector& position, mfem::Vector& image) + { + image = position; + image[2] *= 0.85; + }); + fixture.mesh.generated.mesh->Transform(stretch); + const auto reference_changed = cache.try_prepare(fixture.inputs(), requested); + REQUIRE(reference_changed); + CHECK(reference_changed->status() == Status::reused); + CHECK(reference_changed->status() == Status::computed); + CHECK(reference_changed->status() == Status::computed); + CHECK(reference_changed->status() == Status::computed); + CHECK(cache.get() != original_metric); + check_harmonic_factors(cache.get(), cache.get(), fixture.scales); + CHECK(std::ranges::equal(frozen.state_factors(), original_factors)); + CHECK_FALSE(cache.contains()); +} + +TEST_CASE("Prepared reference traces normalize shape without trial or extension sources", "[integration][normalization]") +{ + SurfaceFixture fixture; + const auto reference = backend::sample_reference_surface(fixture.mesh.scalar_space, fixture.mesh.surface_attribute); + REQUIRE(reference); + const auto surface = deformation::build_surface_embedding(fixture.model.surface, reference->positions); + REQUIRE(surface); + const auto inputs = dependencies::make_dependency_context( + dependencies::own(backend::SurfaceDiscretization{.scalar_space = fixture.mesh.scalar_space, .surface_attribute = fixture.mesh.surface_attribute}, backend::SurfaceDiscretizationTracking{}), + dependencies::own(std::make_shared(*reference)), + dependencies::own(std::make_shared(*surface)), + dependencies::borrow(fixture.layout), + dependencies::borrow(fixture.scales)); + auto computations = std::tuple_cat(std::tuple{backend::make_surface_metric_computation()}, normalization::make_normalization_computations(fixture.layout)); + auto graph = std::apply([](auto... nodes) + { + return dependencies::make_computation_graph(std::move(nodes)...); + }, std::move(computations)); + auto cache = dependencies::make_computation_cache(std::move(graph), inputs); + const auto prepared = cache.try_prepare(inputs); + REQUIRE(prepared); + CHECK(prepared->computed_count() == 4); + check_harmonic_factors(cache.get(), cache.get(), fixture.scales); +} + +TEST_CASE("Harmonic normalization preserves geometry and factor failure types", "[integration][normalization]") +{ + SurfaceFixture fixture; + auto cache = make_cache(fixture); + const auto requested = dependencies::RequestedResults{}; + REQUIRE(cache.try_prepare(fixture.inputs(), requested)); + SECTION("Invalid harmonic geometry retains its domain failure") + { + fixture.model.surface.maximum_degree = -1; + const auto failed = cache.try_prepare(fixture.inputs(), requested); + REQUIRE_FALSE(failed); + REQUIRE(std::holds_alternative(failed.error().error)); + CHECK_FALSE(std::get(failed.error().error).message.empty()); + fixture.model.surface.maximum_degree = 4; + } + SECTION("Incompatible normalization layout retains its factor diagnostic") + { + fixture.layout.state_sizes[0] = 24; + const auto failed = cache.try_prepare(fixture.inputs(), requested); + REQUIRE_FALSE(failed); + REQUIRE(std::holds_alternative(failed.error().error)); + CHECK(std::get(failed.error().error).find("24") != std::string::npos); + fixture.layout.state_sizes[0] = 25; + } + CHECK_FALSE(cache.contains()); + REQUIRE(cache.try_prepare(fixture.inputs(), requested)); + check_harmonic_factors(cache.get(), cache.get(), fixture.scales); +} diff --git a/tests/normalization/volume_computations.cpp b/tests/normalization/volume_computations.cpp new file mode 100644 index 00000000..b282d859 --- /dev/null +++ b/tests/normalization/volume_computations.cpp @@ -0,0 +1,163 @@ +#include +#include +#include +#include +#include + +#include +#include + +#include "mfem/fem/coefficient.hpp" +#include "serif/discretization/domain/schema/schemas.hpp" +#include "serif/backend/mfem/solver/normalization/metric_computations.hpp" +#include "serif/solver/normalization/normalization.hpp" +#include "serif/utils/dependencies/computation_cache.hpp" + +namespace +{ + namespace normalization = serif::solver::normalization; + namespace backend = normalization::backend::mfem; + namespace dependencies = serif::utils::dependencies; + namespace dimensions = serif::dimensions; + namespace discretization = serif::discretization; + using Density = discretization::blocks::density::mass; + using Schema = discretization::domain::schema::CoreEnvelopeVacuumDomainSchema; + using Binding = discretization::backend::mfem::TermBinding; + using Metric = normalization::ReferenceVolumeMetric; + using ValueRule = normalization::PhysicalValueRule; + using Plan = normalization::NormalizationPlan, dependencies::Bindings>>; + using Layout = normalization::CoordinateLayout, dependencies::KeyList>; + using Status = dependencies::PreparationStatus; + + /** @brief Exercise linear geometry that gains MFEM nodes during partial assembly as well as existing nodal geometry. */ + [[nodiscard]] mfem::Mesh make_reference_mesh(const bool nodal, const double length) + { + mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(2, 1, 1, mfem::Element::HEXAHEDRON, 2.0 * length, length, length); + if (nodal) + { + mesh.SetCurvature(2); + } + return mesh; + } + + /** @brief Keep reference geometry, binding identity, and candidate coefficients independently observable. */ + struct VolumeFixture + { + mfem::Mesh mesh; + mfem::H1_FECollection collection{Density::FunctionalSpace::order, 3}; + mfem::FiniteElementSpace space{&mesh, &collection}; + Binding binding{space}; + Layout layout{.state_sizes = {static_cast(binding.size())}, .residual_sizes = {static_cast(binding.size())}}; + normalization::StellarReferenceScales scales = *normalization::prepare_stellar_reference_scales(dimensions::QuantityValue{4.0}, dimensions::QuantityValue{3.0}); + std::vector candidate = std::vector(binding.size(), 1.0); + + /** @brief Construct equal layouts on optionally different reference geometries. */ + explicit VolumeFixture(const bool nodal, const double length = 1.0) : mesh(make_reference_mesh(nodal, length)) + { + } + + /** @brief Allow another live binding to replace the reference without changing context or layout types. */ + [[nodiscard]] auto inputs(Binding& reference) + { + return dependencies::make_dependency_context( + backend::reference_metric_binding(reference), + dependencies::borrow(layout), + dependencies::borrow(scales), + dependencies::borrow(candidate)); + } + }; + + /** @brief Compose the shared backend metric with both normalization coordinate roles. */ + [[nodiscard]] auto make_cache(VolumeFixture& fixture) + { + auto computations = std::tuple_cat(std::tuple{backend::make_reference_volume_metric_computation()}, normalization::make_normalization_computations(fixture.layout)); + auto graph = std::apply([](auto... nodes) + { + return dependencies::make_computation_graph(std::move(nodes)...); + }, std::move(computations)); + return dependencies::make_computation_cache(std::move(graph), fixture.inputs(fixture.binding)); + } + + /** @brief Check exact scale cancellation independently of the graph's factor construction. */ + void check_factors(const normalization::DiagonalNormalization& factors, const std::vector& metric, const double scale) + { + REQUIRE(factors.state_size() == metric.size()); + REQUIRE(factors.residual_size() == metric.size()); + for (std::size_t index = 0; index < metric.size(); ++index) + { + CHECK(factors.state_factors()[index] == Catch::Approx(std::sqrt(metric[index]) / scale).epsilon(3.0e-14)); + CHECK(factors.residual_factors()[index] == Catch::Approx(1.0 / (scale * std::sqrt(metric[index]))).epsilon(3.0e-14)); + } + } +} + +TEST_CASE("Reference volume normalization reuses one metric independently of trial coefficients", "[integration][normalization]") +{ + for (const bool nodal : {false, true}) + { + CAPTURE(nodal); + VolumeFixture fixture(nodal); + CHECK((fixture.mesh.GetNodes() != nullptr) == nodal); + auto cache = make_cache(fixture); + const auto prepared = cache.try_prepare(fixture.inputs(fixture.binding)); + REQUIRE(prepared); + CHECK(prepared->computed_count() == 4); + const std::vector original_metric = cache.get(); + check_factors(cache.get(), original_metric, fixture.scales.density.value()); + + const auto unchanged = cache.try_prepare(fixture.inputs(fixture.binding)); + REQUIRE(unchanged); + CHECK(unchanged->computed_count() == 0); + CHECK(unchanged->status() == Status::reused); + std::ranges::fill(fixture.candidate, std::numeric_limits::quiet_NaN()); + const auto changed_candidate = cache.try_prepare(fixture.inputs(fixture.binding)); + REQUIRE(changed_candidate); + CHECK(changed_candidate->computed_count() == 0); + CHECK(cache.get() == original_metric); + } +} + +TEST_CASE("Reference volume normalization follows geometry revisions and exact binding identity", "[integration][normalization]") +{ + for (const bool nodal : {false, true}) + { + CAPTURE(nodal); + VolumeFixture fixture(nodal); + auto cache = make_cache(fixture); + REQUIRE(cache.try_prepare(fixture.inputs(fixture.binding))); + const normalization::DiagonalNormalization frozen = cache.get(); + const std::vector original_metric = cache.get(); + const std::vector original_factors(frozen.state_factors().begin(), frozen.state_factors().end()); + + mfem::VectorFunctionCoefficient dilation(3, [](const mfem::Vector& point, mfem::Vector& image) + { + image = point; + image *= 2.0; + }); + fixture.mesh.Transform(dilation); + const auto changed_geometry = cache.try_prepare(fixture.inputs(fixture.binding)); + REQUIRE(changed_geometry); + CHECK(changed_geometry->status() == Status::computed); + const std::vector dilated_metric = cache.get(); + for (std::size_t index = 0; index < original_metric.size(); ++index) + { + CHECK(dilated_metric[index] == Catch::Approx(8.0 * original_metric[index]).epsilon(2.0e-12)); + } + check_factors(cache.get(), dilated_metric, fixture.scales.density.value()); + CHECK(std::ranges::equal(frozen.state_factors(), original_factors)); + + VolumeFixture replacement(nodal, 3.0); + REQUIRE(replacement.binding.size() == fixture.binding.size()); + const auto changed_binding = cache.try_prepare(fixture.inputs(replacement.binding)); + REQUIRE(changed_binding); + CHECK(changed_binding->status() == Status::computed); + const std::vector replaced_metric = cache.get(); + for (std::size_t index = 0; index < original_metric.size(); ++index) + { + CHECK(replaced_metric[index] == Catch::Approx(27.0 * original_metric[index]).epsilon(2.0e-12)); + } + const auto replacement_unchanged = cache.try_prepare(fixture.inputs(replacement.binding)); + REQUIRE(replacement_unchanged); + CHECK(replacement_unchanged->computed_count() == 0); + } +} diff --git a/tests/normalization/volume_metrics.cpp b/tests/normalization/volume_metrics.cpp new file mode 100644 index 00000000..b666b5fa --- /dev/null +++ b/tests/normalization/volume_metrics.cpp @@ -0,0 +1,300 @@ +// Port the exact reference mass-diagonal comparisons from MeanField normalization/stellar_equilibrium.cpp. +#include +#include +#include +#include "mfem/mesh/ncmesh.hpp" + +#include + +#include "mfem/fem/bilinearform.hpp" +#include "mfem/fem/bilininteg.hpp" +#include "mfem/fem/gridfunc.hpp" +#include "mfem/general/forall.hpp" +#include "serif/tests/equations/test_equations.hpp" +#include "serif/backend/mfem/discretization/mass_diagonal.hpp" +#include "serif/discretization/domain/schema/schemas.hpp" + +namespace +{ + namespace discretization = serif::discretization; + namespace backend = discretization::backend::mfem; + namespace blocks = discretization::blocks; + namespace domain = discretization::domain; + namespace functional_space = discretization::functional_space; + using Schema = domain::schema::CoreEnvelopeVacuumDomainSchema; + + /** @brief Exercise the same mass construction for stellar support and independent vector components. */ + struct TestField final : blocks::field + { + template + struct values final : blocks::term + { + using FunctionalSpace = functional_space::DiscreteFunctionalSpace; + using Support = Domain; + }; + }; + + /** @brief Keep shared interface coefficients while excluding the second material's volume. */ + [[nodiscard]] mfem::Mesh reference_mesh(const mfem::Element::Type geometry) + { + mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(2, 1, 1, geometry, 2.0, 1.5, 0.75); + for (int element = 0; element < mesh.GetNE(); ++element) + { + mesh.GetElement(element)->SetAttribute(element < mesh.GetNE() / 2 ? 2 : 3); + } + mesh.SetAttributes(); + return mesh; + } + + /** @brief Compare the production tensor shortcut or fallback against an independently assembled sparse matrix. */ + template + void check_reference_diagonal(mfem::FiniteElementSpace& space) + { + const backend::TermBinding binding{space}; + const auto actual = backend::reference_mass_diagonal(binding); + REQUIRE(actual); + REQUIRE(actual->size() == static_cast(binding.size())); + + mfem::Array marker(space.GetMesh()->attributes.Max()); + for (int attribute = 1; attribute <= marker.Size(); ++attribute) + { + marker[attribute - 1] = Schema::template domain_id_belongs_to(attribute); + } + mfem::BilinearForm reference(&space); + if constexpr (blocks::SpatialTermTraits::rank == 1) + { + reference.AddDomainIntegrator(new mfem::VectorMassIntegrator(), marker); + } + else + { + reference.AddDomainIntegrator(new mfem::MassIntegrator(), marker); + } + reference.Assemble(); + reference.Finalize(); + mfem::Vector full_diagonal; + reference.SpMat().GetDiag(full_diagonal); + const mfem::Vector expected = binding.gather(full_diagonal); + double error_squared = 0.0; + for (int index = 0; index < expected.Size(); ++index) + { + CAPTURE(index, (*actual)[index], expected[index]); + CHECK(std::isfinite((*actual)[index])); + CHECK((*actual)[index] > 0.0); + error_squared += std::pow((*actual)[index] - expected[index], 2); + } + CHECK(std::sqrt(error_squared) <= 2.0e-11 * expected.Norml2()); + } + + /** @brief Similar reference bodies change scalar volume metrics by length cubed and flux metrics by inverse length. */ + template + void check_geometry_scaling(mfem::FiniteElementSpace& space, const double exponent) + { + const backend::TermBinding binding{space}; + const auto original = backend::reference_mass_diagonal(binding); + REQUIRE(original); + constexpr double length_scale = 2.5; + mfem::VectorFunctionCoefficient dilation(3, [](const mfem::Vector& point, mfem::Vector& image) + { + image = point; + image *= length_scale; + }); + space.GetMesh()->Transform(dilation); + const auto scaled = backend::reference_mass_diagonal(binding); + REQUIRE(scaled); + REQUIRE(scaled->size() == original->size()); + for (std::size_t index = 0; index < original->size(); ++index) + { + const double expected = (*original)[index] * std::pow(length_scale, exponent); + CHECK(std::abs((*scaled)[index] - expected) <= 2.0e-12 * expected); + } + } +} + +TEST_CASE("Reference mass diagonals match exact scalar assembly", "[unit][normalization][meanfield]") +{ + for (const mfem::Element::Type geometry : {mfem::Element::HEXAHEDRON, mfem::Element::TETRAHEDRON}) + { + CAPTURE(geometry); + mfem::Mesh mesh = reference_mesh(geometry); + mfem::H1_FECollection h1(3, 3); + mfem::FiniteElementSpace enthalpy(&mesh, &h1), density(&mesh, &h1); + check_reference_diagonal<::serif::tests::equations::Enthalpy>(enthalpy); + check_reference_diagonal(density); + mfem::H1_FECollection potential_collection(blocks::gravity::potential::FunctionalSpace::order, 3); + mfem::FiniteElementSpace potential(&mesh, &potential_collection); + check_reference_diagonal(potential); + } +} + +TEST_CASE("Reference mass diagonals preserve vector component ordering", "[unit][normalization]") +{ + for (const mfem::Element::Type geometry : {mfem::Element::HEXAHEDRON, mfem::Element::TETRAHEDRON}) + { + mfem::Mesh mesh = reference_mesh(geometry); + mfem::H1_FECollection h1(3, 3); + mfem::L2_FECollection l2(2, 3); + for (const mfem::Ordering::Type ordering : {mfem::Ordering::byNODES, mfem::Ordering::byVDIM}) + { + CAPTURE(geometry, ordering); + mfem::FiniteElementSpace displacement(&mesh, &h1, 3, ordering), velocity(&mesh, &l2, 3, ordering); + check_reference_diagonal(displacement); + check_reference_diagonal>(velocity); + } + } +} + +TEST_CASE("Reference mass diagonals use curved geometry in both assembly paths", "[unit][normalization]") +{ + for (const mfem::Element::Type geometry : {mfem::Element::HEXAHEDRON, mfem::Element::TETRAHEDRON}) + { + CAPTURE(geometry); + mfem::Mesh mesh = reference_mesh(geometry); + mesh.SetCurvature(2); + mfem::VectorFunctionCoefficient curved(3, [](const mfem::Vector& point, mfem::Vector& image) + { + image = point; + image[1] += 0.06 * point[0] * point[0]; + image[2] *= 1.0 + 0.04 * point[0]; + }); + mesh.Transform(curved); + mfem::H1_FECollection h1(3, 3); + mfem::FiniteElementSpace enthalpy(&mesh, &h1), density(&mesh, &h1), displacement(&mesh, &h1, 3); + check_reference_diagonal<::serif::tests::equations::Enthalpy>(enthalpy); + check_reference_diagonal(density); + check_reference_diagonal(displacement); + } +} + +TEST_CASE("Reference metrics retain scalar length scaling", "[unit][normalization]") +{ + SECTION("Scalar volume coefficients") + { + mfem::Mesh mesh = reference_mesh(mfem::Element::HEXAHEDRON); + mfem::H1_FECollection h1(3, 3); + mfem::FiniteElementSpace enthalpy(&mesh, &h1); + check_geometry_scaling<::serif::tests::equations::Enthalpy>(enthalpy, 3.0); + } +} + +TEST_CASE("Reference metrics retain supported ordering and reject stale material bindings", "[unit][normalization]") +{ + mfem::Mesh mesh = reference_mesh(mfem::Element::HEXAHEDRON); + mfem::H1_FECollection h1(3, 3); + mfem::FiniteElementSpace space(&mesh, &h1); + const backend::TermBinding<::serif::tests::equations::Enthalpy, Schema> binding{space}; + const auto diagonal = backend::reference_mass_diagonal(binding); + REQUIRE(diagonal); + CHECK(diagonal->size() < static_cast(space.GetVSize())); + + mesh.GetElement(0)->SetAttribute(3); + CHECK_THROWS(backend::reference_mass_diagonal(binding)); + mesh.SetAttributes(); + const backend::TermBinding<::serif::tests::equations::Enthalpy, Schema> empty_binding{space}; + const auto empty = backend::reference_mass_diagonal(empty_binding); + REQUIRE(empty); + CHECK(empty->empty()); +} + +TEST_CASE("Reference metrics reject zero volume rather than constructing singular normalization", "[unit][normalization]") +{ + mfem::Mesh mesh = reference_mesh(mfem::Element::HEXAHEDRON); + mfem::VectorFunctionCoefficient collapsed(3, [](const mfem::Vector& point, mfem::Vector& image) + { + image = point; + image[2] = 0.0; + }); + mesh.Transform(collapsed); + mfem::H1_FECollection h1(3, 3); + mfem::FiniteElementSpace space(&mesh, &h1); + const backend::TermBinding<::serif::tests::equations::Enthalpy, Schema> binding{space}; + const auto diagonal = backend::reference_mass_diagonal(binding); + REQUIRE_FALSE(diagonal); + CHECK(diagonal.error().find("positive finite") != std::string::npos); +} + +namespace { + /** @brief Assemble an independent dense element oracle before MFEM applies hanging-node constraints. */ + class ReconstructedMass final : public mfem::BilinearFormIntegrator { + public: + /** @brief Borrow the weighted binding and select a verification quadrature increment. */ + ReconstructedMass(const backend::DofMap& binding, int increment) : m_binding(binding), m_increment(increment) {} + /** @brief Preserve every local cross term, so the oracle tests constrained diagonal reduction. */ + void AssembleElementMatrix(const mfem::FiniteElement& element, mfem::ElementTransformation& transformation, + mfem::DenseMatrix& matrix) override { + const auto* reconstruction = m_binding.reconstruction_element(transformation.ElementNo); + const int order = mfem::MassIntegrator::GetRule(element, element, transformation).GetOrder() + m_increment; + const auto& rule = mfem::IntRules.Get(element.GetGeomType(), order); + matrix.SetSize(element.GetDof()); + matrix = 0.0; + mfem::Vector shapes(element.GetDof()); + mfem::DenseMatrix derivatives; + for (int index = 0; index < rule.GetNPoints(); ++index) { + const auto& point = rule.IntPoint(index); + if (reconstruction != nullptr) { reconstruction->sample(element, point, shapes, derivatives); } + else { element.CalcShape(point, shapes); } + transformation.SetIntPoint(&point); + const double weight = point.weight * transformation.Weight(); + for (int row = 0; row < element.GetDof(); ++row) { + for (int column = 0; column < element.GetDof(); ++column) { matrix(row, column) += weight * shapes[row] * shapes[column]; } + } + } + } + private: + const backend::DofMap& m_binding; + int m_increment; + }; + + /** @brief Form the actual sparse mass matrix, then reduce all couplings before reading its diagonal. */ + [[nodiscard]] mfem::Vector reconstructed_diagonal_oracle(const backend::DofMap& binding, int increment) { + mfem::BilinearForm mass(&binding.space()); + mass.AddDomainIntegrator(new ReconstructedMass(binding, increment)); + mass.Assemble(); + mass.Finalize(); + mfem::Vector diagonal; + if (binding.constrained()) { + const auto& prolongation = *binding.space().GetConformingProlongation(); + const std::unique_ptr reduced(mfem::RAP(prolongation, mass.SpMat(), prolongation)); + reduced->GetDiag(diagonal); + return binding.gather_true(diagonal); + } + mass.SpMat().GetDiag(diagonal); + return binding.gather(diagonal); + } +} + +TEST_CASE("Surface-power reference metrics retain constrained cross terms and converge under quadrature", "[unit][normalization][reconstruction]") { + for (bool constrained : {false, true}) { + mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(2, 1, 1, mfem::Element::HEXAHEDRON); + mfem::Array vertices; + for (int boundary = 0; boundary < mesh.GetNBE(); ++boundary) { + mesh.GetBdrElementVertices(boundary, vertices); + bool surface = true; + for (int vertex : vertices) { surface = surface && mesh.GetVertex(vertex)[2] == 0.0; } + mesh.GetBdrElement(boundary)->SetAttribute(surface ? 1 : 2); + } + mesh.SetAttributes(); + if (constrained) { + mfem::Array refinement; + refinement.Append(mfem::Refinement(0, mfem::Refinement::Y)); + mesh.GeneralRefinement(refinement, 1); + } + mfem::H1_FECollection collection(3, 3); + mfem::FiniteElementSpace space(&mesh, &collection); + const backend::TermBinding ordinary(space); + const auto plan = std::make_shared(ordinary, 1, + std::make_shared(5.0 / 3.0)); + const backend::TermBinding binding(space, plan); + REQUIRE(binding.constrained() == constrained); + const auto actual = backend::reference_mass_diagonal(binding); + REQUIRE(actual); + const mfem::Vector oracle = reconstructed_diagonal_oracle(binding, plan->integration_order_increment()); + const mfem::Vector refined = reconstructed_diagonal_oracle(binding, plan->integration_order_increment() + 16); + for (int index = 0; index < binding.size(); ++index) { + CAPTURE(constrained, index); + CHECK((*actual)[index] > 0.0); + CHECK(std::abs((*actual)[index] - oracle[index]) <= 2e-11 * oracle[index]); + // Fractional powers are not integrated exactly by a finite Gaussian rule. + CHECK(std::abs((*actual)[index] - refined[index]) <= 3e-4 * refined[index]); + } + } +} diff --git a/tests/preconditioner/border.cpp b/tests/preconditioner/border.cpp new file mode 100644 index 00000000..d72196c8 --- /dev/null +++ b/tests/preconditioner/border.cpp @@ -0,0 +1,225 @@ +#include +#include +#include +#include + +#include +#include +#include "mfem/linalg/densemat.hpp" +#include "serif/backend/mfem/solver/preconditioner/border.hpp" +#include "serif/backend/mfem/solver/normalization/operators.hpp" + +namespace +{ + namespace backend = serif::solver::preconditioner::backend::mfem; + using serif::solver::preconditioner::ApplicationContract; + + /** @brief Build independent dense examples in the legacy tests' row-major notation. */ + [[nodiscard]] mfem::DenseMatrix matrix(int size, std::initializer_list entries) + { + REQUIRE(entries.size() == static_cast(size * size)); + mfem::DenseMatrix result(size); + auto entry = entries.begin(); + for (int row = 0; row < size; ++row) + { + for (int column = 0; column < size; ++column) + { + result(row, column) = *entry++; + } + } + return result; + } + + /** @brief Keep test vectors explicit without relying on MFEM initializer-list support. */ + [[nodiscard]] mfem::Vector vector(std::initializer_list values) + { + mfem::Vector result(static_cast(values.size())); + int index = 0; + for (double value : values) + { + result[index++] = value; + } + return result; + } + + /** @brief Compare complete actions by their absolute error norm. */ + [[nodiscard]] double difference(const mfem::Vector& left, const mfem::Vector& right) + { + mfem::Vector result(left); + result -= right; + return result.Norml2(); + } + + /** @brief Count physical work while retaining independently specified dense actions. */ + class CountedOperator final : public mfem::Operator + { + public: + /** @brief Freeze the coefficients while allowing failure and work-count instrumentation. */ + explicit CountedOperator(mfem::DenseMatrix coefficients) : Operator(coefficients.Height(), coefficients.Width()), coefficients(std::move(coefficients)) + { + } + + /** @brief Apply the exact dense action and optionally simulate a rejected numerical callback. */ + void Mult(const mfem::Vector& input, mfem::Vector& output) const override + { + ++applications; + coefficients.Mult(input, output); + if (nonfinite) + { + output[0] = std::numeric_limits::infinity(); + } + } + + mfem::DenseMatrix coefficients; + mutable int applications{0}; + bool nonfinite{false}; + }; + + /** @brief A nonlinear inner solve exposes the difference between cached linear responses and fresh solves. */ + class VariableInverse final : public mfem::Operator + { + public: + /** @brief Use one unknown so the independently derived correction is unambiguous. */ + VariableInverse() : Operator(1) + { + } + + /** @brief Model a right-hand-side-dependent inverse with a known analytic response. */ + void Mult(const mfem::Vector& input, mfem::Vector& output) const override + { + ++applications; + output[0] = evaluate(input[0]); + } + + /** @brief Evaluate the independent scalar nonlinear response. */ + [[nodiscard]] static double evaluate(double value) + { + return value / (2.0 + std::abs(value)); + } + + mutable int applications{0}; + }; +} // namespace + +TEST_CASE("Dense borders preserve exact factorizations and stationary response work counts", "[preconditioner][border][unit]") +{ + // MeanField specification_border.cpp:845 retains one, two, and four generated scalars and both preparations. + for (const int count : {1, 2, 4}) + { + CAPTURE(count); + for (const int generation : {0, 1}) + { + CAPTURE(generation); + auto fields = std::make_shared(matrix(3, {1.0 / (2 + 0.25 * generation), 0, 0, 0, 1.0 / (3 + 0.5 * generation), 0, 0, 0, 1.0 / (5 + 0.75 * generation)})); + auto forward = std::make_shared(3, count); + auto reverse = std::make_shared(count, 3); + auto diagonal = std::make_shared(count); + mfem::DenseMatrix complete(3 + count); + complete = 0.0; + complete(0, 0) = 2 + 0.25 * generation; + complete(1, 1) = 3 + 0.5 * generation; + complete(2, 2) = 5 + 0.75 * generation; + for (int row = 0; row < count; ++row) + { + for (int column = 0; column < count; ++column) + { + (*diagonal)(row, column) = row == column ? 2.0 + row + 0.5 * generation : 0.01 * (row + column + 1); + complete(3 + row, 3 + column) = (*diagonal)(row, column); + } + for (int column = 0; column < 3; ++column) + { + (*forward)(column, row) = -0.03 * ((column + 1) * (row + 2)); + (*reverse)(row, column) = 0.04 * ((row + 1) * (column + 2)); + complete(column, 3 + row) = (*forward)(column, row); + complete(3 + row, column) = (*reverse)(row, column); + } + } + auto prepared = backend::try_bordered_inverse({.action = fields}, {.first_from_second = forward, .second_from_first = reverse}, diagonal); + REQUIRE(prepared); + CHECK(fields->applications == count); + mfem::Vector rhs(3 + count), actual(3 + count), expected(3 + count); + for (int index = 0; index < rhs.Size(); ++index) + { + rhs[index] = 0.25 + 0.17 * (index + 1); + } + mfem::DenseMatrixInverse exact(complete); + exact.Mult(rhs, expected); + prepared->action->Mult(rhs, actual); + CHECK(difference(actual, expected) / std::max(1.0, expected.Norml2()) <= 2.0e-13); + CHECK(fields->applications == count + 1); + prepared->action->Mult(rhs, rhs); + CHECK(difference(rhs, expected) / std::max(1.0, expected.Norml2()) <= 2.0e-13); + CHECK(fields->applications == count + 2); + if (count == 2) + { + auto flexible = backend::try_bordered_inverse({.action = fields, .contract = ApplicationContract::variable}, {.first_from_second = forward, .second_from_first = reverse}, diagonal); + REQUIRE(flexible); + const int before = fields->applications; + flexible->action->Mult(vector({0.42, 0.59, 0.76, 0.93, 1.10}), actual); + CHECK(difference(actual, expected) / std::max(1.0, expected.Norml2()) <= 2.0e-13); + CHECK(fields->applications == before + 2); + } + } + } +} + +TEST_CASE("Variable border inverses recompute the corrected field solve", "[preconditioner][border][unit]") +{ + const auto fields = std::make_shared(); + const auto forward = std::make_shared(matrix(1, {0.75})); + const auto reverse = std::make_shared(matrix(1, {1.25})); + const auto diagonal = std::make_shared(matrix(1, {4.0})); + auto prepared = backend::try_bordered_inverse({.action = fields, .contract = ApplicationContract::variable}, {.first_from_second = forward, .second_from_first = reverse}, diagonal); + REQUIRE(prepared); + CHECK(prepared->contract == ApplicationContract::variable); + CHECK(fields->applications == 1); + mfem::Vector rhs = vector({2.5, -0.3}); + mfem::Vector actual(2); + const double response = VariableInverse::evaluate(0.75); + const double first = VariableInverse::evaluate(rhs[0]); + const double border = (rhs[1] - 1.25 * first) / (4.0 - 1.25 * response); + const double corrected = VariableInverse::evaluate(rhs[0] - 0.75 * border); + prepared->action->Mult(rhs, actual); + CHECK(fields->applications == 3); + CHECK(actual[0] == Catch::Approx(corrected).margin(2.0e-14)); + CHECK(actual[1] == Catch::Approx(border).margin(2.0e-14)); + CHECK(std::abs(actual[0] - (first - response * border)) > 1.0e-3); +} + +TEST_CASE("An empty border preserves the original field inverse", "[preconditioner][border][unit]") +{ + const backend::Inverse fields{.action = std::make_shared(matrix(1, {0.5}))}; + auto prepared = backend::try_bordered_inverse(fields, {.first_from_second = std::make_shared(1, 0), .second_from_first = std::make_shared(0, 1)}, std::make_shared(0)); + REQUIRE(prepared); + CHECK(prepared->action == fields.action); +} + +TEST_CASE("Unbordered field selection preserves nonunit normalization and typed packing", "[preconditioner][border][coordinates]") +{ + namespace normalization = serif::solver::normalization; + namespace scaled = normalization::backend::mfem; + + // Original eight-coordinate fixture: three uneven field groups and two excluded scalar borders. + auto packed = std::make_shared(8); + *packed = 0.0; + for (int index = 0; index < 6; ++index) + { + (*packed)(index, index) = index + 2.0; + } + (*packed)(0, 5) = 0.4; + (*packed)(4, 1) = -0.6; + const std::array output_order{5, 2, 3, 4, 6, 0, 1, 7}; + const std::array input_order{4, 5, 1, 2, 3, 6, 0, 7}; + auto physical = backend::try_restrict_operator(packed, output_order, input_order); + REQUIRE(physical); + auto factors = normalization::make_diagonal_normalization({1.0 / 23, 1.0 / 29, 1.0 / 31, 1.0 / 37, 1.0 / 41, 1.0 / 43, 1.0 / 47, 1.0 / 53}, {0.5, 1.0 / 3, 0.2, 1.0 / 7, 1.0 / 11, 1.0 / 13, 1.0 / 17, 1.0 / 19}); + REQUIRE(factors); + scaled::ScaledInverseOperator inverse(**physical, *factors); + mfem::Vector rhs = vector({1, 2, 3, 4, 5, 6, 7, 8}); + const mfem::Vector expected = vector({42.0 / 23, 112.0 / 29, 275.0 / 31, 3.0 / 37, 0, 240.4 / 43, 45.0 / 47, 0}); + mfem::Vector actual(8); + inverse.Mult(rhs, actual); + CHECK(difference(actual, expected) < 1.0e-13); + inverse.Mult(rhs, rhs); + CHECK(difference(rhs, expected) < 1.0e-13); +} diff --git a/tests/preconditioner/coupling.cpp b/tests/preconditioner/coupling.cpp new file mode 100644 index 00000000..3eee6106 --- /dev/null +++ b/tests/preconditioner/coupling.cpp @@ -0,0 +1,150 @@ +#include +#include +#include + +#include +#include +#include "mfem/linalg/densemat.hpp" +#include "serif/backend/mfem/solver/preconditioner/border.hpp" +#include "serif/backend/mfem/solver/preconditioner/inverse/dense.hpp" + +namespace +{ + namespace backend = serif::solver::preconditioner::backend::mfem; + using serif::solver::preconditioner::ApplicationContract; + using serif::solver::preconditioner::strategies::coupling::CouplingOrder; + + /** @brief Construct a small row-major matrix independently of the implementation's block algebra. */ + [[nodiscard]] std::shared_ptr matrix(int height, int width, std::initializer_list entries) + { + REQUIRE(entries.size() == static_cast(height * width)); + auto result = std::make_shared(height, width); + auto entry = entries.begin(); + for (int row = 0; row < height; ++row) + { + for (int column = 0; column < width; ++column) + { + (*result)(row, column) = *entry++; + } + } + return result; + } + + /** @brief Compare complete numerical actions without ignoring small absolute discrepancies. */ + [[nodiscard]] double difference(const mfem::Vector& left, const mfem::Vector& right) + { + mfem::Vector result(left); + result -= right; + return result.Norml2(); + } +} // namespace + +TEST_CASE("Two-group coupling policies reproduce their complete nonsymmetric factorizations", "[preconditioner][coupling][unit]") +{ + const backend::Inverse first{.action = matrix(2, 2, {0.5, 0.0, 0.0, 0.25})}; + const backend::Inverse second{.action = matrix(2, 2, {0.2, 0.0, 0.0, 0.1})}; + const backend::TwoBlockCouplings couplings{.first_from_second = matrix(2, 2, {1, 2, -1, 3}), .second_from_first = matrix(2, 2, {3, -2, 4, 1})}; + const std::array orders{CouplingOrder::independent, CouplingOrder::second_then_first, CouplingOrder::lower_diagonal_upper}; + const std::array systems{ + matrix(4, 4, {2, 0, 0, 0, 0, 4, 0, 0, 0, 0, 5, 0, 0, 0, 0, 10}), + matrix(4, 4, {2, 0, 1, 2, 0, 4, -1, 3, 0, 0, 5, 0, 0, 0, 0, 10}), + matrix(4, 4, {2, 0, 1, 2, 0, 4, -1, 3, 3, -2, 7, 1.5, 4, 1, 1.75, 14.75})}; + mfem::Vector rhs(4), actual(4), expected(4); + rhs[0] = 1.0; + rhs[1] = -2.0; + rhs[2] = 3.0; + rhs[3] = 4.0; + for (std::size_t policy = 0; policy < orders.size(); ++policy) + { + CAPTURE(policy); + auto inverse = backend::try_coupled_inverse(first, second, couplings, orders[policy]); + REQUIRE(inverse); + CHECK(inverse->contract == ApplicationContract::fixed_linear); + mfem::DenseMatrixInverse exact(*systems[policy]); + exact.Mult(rhs, expected); + double* const storage = actual.GetData(); + inverse->action->Mult(rhs, actual); + CHECK(actual.GetData() == storage); + CHECK(difference(actual, expected) < 2.0e-13); + mfem::Vector aliased(rhs); + inverse->action->Mult(aliased, aliased); + CHECK(difference(aliased, expected) < 2.0e-13); + inverse->action->Mult(rhs, actual); + CHECK(difference(actual, expected) < 2.0e-13); + } +} + +TEST_CASE("A matrix-free Schur action shares the eliminated-field orientation", "[preconditioner][coupling][unit]") +{ + const backend::Inverse eliminated{.action = matrix(2, 2, {0.5, 0.0, 0.0, 0.25})}; + const backend::TwoBlockCouplings couplings{.first_from_second = matrix(2, 2, {1, 2, -1, 3}), .second_from_first = matrix(2, 2, {3, -2, 4, 1})}; + auto schur = backend::try_schur_operator(matrix(2, 2, {7, 1.5, 1.75, 14.75}), eliminated, couplings); + REQUIRE(schur); + mfem::Vector values(2); + values[0] = 3.0; + values[1] = -2.0; + (*schur)->Mult(values, values); + CHECK(values[0] == Catch::Approx(15.0).margin(2.0e-14)); + CHECK(values[1] == Catch::Approx(-20.0).margin(2.0e-14)); + backend::Inverse variable = eliminated; + variable.contract = ApplicationContract::variable; + CHECK_FALSE(backend::try_schur_operator(matrix(2, 2, {7, 1.5, 1.75, 14.75}), variable, couplings)); +} + +TEST_CASE("Coupled inverse validation rejects unsupported policies and preserves outputs", "[preconditioner][coupling][validation]") +{ + using serif::utils::errors::SERiFErrorCode; + + const backend::Inverse identity{.action = matrix(1, 1, {1.0})}; + const backend::TwoBlockCouplings full{.first_from_second = matrix(1, 1, {2.0}), .second_from_first = matrix(1, 1, {3.0})}; + CHECK(backend::try_coupled_inverse(identity, identity, {}, CouplingOrder::independent)); + CHECK(backend::try_coupled_inverse(identity, identity, {.first_from_second = full.first_from_second}, CouplingOrder::second_then_first)); + auto missing = backend::try_coupled_inverse({}, identity, full, CouplingOrder::independent); + REQUIRE_FALSE(missing); + CHECK(missing.error().code == SERiFErrorCode::PRECONDITIONER_INVALID_OPERATOR); + auto incompatible = backend::try_coupled_inverse(identity, identity, {}, CouplingOrder::lower_diagonal_upper); + REQUIRE_FALSE(incompatible); + CHECK(incompatible.error().code == SERiFErrorCode::PRECONDITIONER_SIZE_MISMATCH); + auto unknown = backend::try_coupled_inverse(identity, identity, full, static_cast(99)); + REQUIRE_FALSE(unknown); + CHECK(unknown.error().code == SERiFErrorCode::PRECONDITIONER_INVALID_OPTIONS); + backend::Inverse variable = identity; + variable.contract = ApplicationContract::variable; + auto composed = backend::try_coupled_inverse(identity, variable, {}, CouplingOrder::independent); + REQUIRE(composed); + CHECK(composed->contract == ApplicationContract::variable); + + mfem::Vector rhs(2), output(2), short_rhs(1); + rhs = 1.0; + output = 7.0; + CHECK_THROWS(composed->action->Mult(short_rhs, output)); + CHECK(output[0] == 7.0); + CHECK(output[1] == 7.0); + rhs[1] = std::numeric_limits::quiet_NaN(); + CHECK_THROWS(composed->action->Mult(rhs, output)); + CHECK(output[0] == 7.0); + CHECK(output[1] == 7.0); +} + +TEST_CASE("Selected operators preserve distinct equation and unknown orders and their transpose", "[preconditioner][coupling][coordinates]") +{ + const auto source = matrix(3, 4, {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}); + const std::array rows{2, 0}; + const std::array columns{3, 1}; + auto selected = backend::try_restrict_operator(source, rows, columns); + REQUIRE(selected); + mfem::Vector values(2); + values[0] = 2.0; + values[1] = -1.0; + (*selected)->Mult(values, values); + CHECK(values[0] == 14.0); + CHECK(values[1] == 6.0); + values[0] = 2.0; + values[1] = -1.0; + (*selected)->MultTranspose(values, values); + CHECK(values[0] == 20.0); + CHECK(values[1] == 18.0); + CHECK_FALSE(backend::try_restrict_operator(source, std::array{2, 2}, columns)); + CHECK_FALSE(backend::try_restrict_operator(source, std::array{3}, columns)); + CHECK_FALSE(backend::try_restrict_operator(source, rows, std::array{-1})); +} diff --git a/tests/preconditioner/graph.cpp b/tests/preconditioner/graph.cpp new file mode 100644 index 00000000..4b81e015 --- /dev/null +++ b/tests/preconditioner/graph.cpp @@ -0,0 +1,197 @@ +#include +#include +#include + +#include +#include +#include + +#include "serif/backend/mfem/operators/actions.hpp" +#include "serif/solver/preconditioner/preconditioner.hpp" +#include "serif/backend/mfem/solver/normalization/operators.hpp" +#include "serif/utils/dependencies/computation_cache.hpp" + +namespace +{ + namespace pc = serif::solver::preconditioner; + namespace dependencies = serif::utils::dependencies; + namespace operators = serif::operators::backend::mfem; + namespace inverse = pc::strategies::inverse; + namespace coupling = pc::strategies::coupling; + using Inverse = pc::backend::mfem::Inverse; + using Code = serif::utils::errors::SERiFErrorCode; + + struct Temperature + { + }; + struct HeatBalance + { + }; + struct Density + { + }; + struct MassConstraint + { + }; + struct MassParameter + { + }; + struct Potential + { + }; + struct Gradient + { + }; + + /** @brief Construct owned matrices independently of the preconditioner factorizations. */ + std::shared_ptr matrix(const int rows, const int columns, std::initializer_list values) + { + auto result = std::make_shared(rows, columns); + auto value = values.begin(); + for (int row = 0; row < rows; ++row) + { + for (int column = 0; column < columns; ++column) + { + (*result)(row, column) = *value++; + } + } + return result; + } + + /** @brief Apply using preallocated MFEM coordinates, retaining the factory's zero-guess contract. */ + mfem::Vector apply(const Inverse& inverse, std::initializer_list values) + { + mfem::Vector input(values.size()); + std::copy(values.begin(), values.end(), input.GetData()); + mfem::Vector output(inverse.action->Height()); + output = 91.0; + inverse.action->Mult(input, output); + return output; + } +} // namespace + +TEST_CASE("A physical inverse graph reuses accepted results and preserves a frozen solve after failed preparation", "[unit][preconditioner][graph]") +{ + const auto choice = pc::make_preconditioner(inverse::Diagonal{}); + using Recipe = decltype(choice); + using Block = pc::keys::Block; + auto physical = operators::linearized_block(matrix(2, 2, {2.0, 0.0, 0.0, -4.0})); + const auto context = dependencies::make_dependency_context(dependencies::borrow(physical)); + auto cache = dependencies::make_computation_cache(pc::make_preconditioner_graph(choice), context); + REQUIRE(cache.try_prepare(context)); + auto frozen = cache.share(); + CHECK(apply(*frozen, {6.0, 8.0})[0] == Catch::Approx(3.0)); + CHECK(apply(*frozen, {6.0, 8.0})[1] == Catch::Approx(-2.0)); + const auto reused = cache.try_prepare(context); + REQUIRE(reused); + CHECK(reused->computed_count() == 0); + CHECK(cache.share() == frozen); + + physical = operators::linearized_block(matrix(2, 2, {0.0, 0.0, 0.0, -4.0})); + const auto rejected = cache.try_prepare(context); + REQUIRE_FALSE(rejected); + CHECK(rejected.error().error.code == Code::PRECONDITIONER_SINGULAR_OPERATOR); + CHECK_FALSE(cache.ready()); + CHECK_THROWS(cache.share()); + CHECK(apply(*frozen, {6.0, 8.0})[0] == Catch::Approx(3.0)); + + physical = operators::linearized_block(matrix(2, 2, {3.0, 0.0, 0.0, -8.0})); + REQUIRE(cache.try_prepare(context)); + CHECK(cache.share() != frozen); + CHECK(apply(cache.get(), {6.0, 8.0})[0] == Catch::Approx(2.0)); + cache.invalidate(); + CHECK(apply(*frozen, {6.0, 8.0})[1] == Catch::Approx(-2.0)); +} + +TEST_CASE("Composed inverse graphs obey separate physical state and equation orders", "[unit][preconditioner][graph]") +{ + using Values = dependencies::KeyList; + using Rows = dependencies::KeyList; + const serif::discretization::blocks::SystemLayout layout{.state_sizes = {1, 2}, .residual_sizes = {2, 1}}; + const auto heat = pc::make_preconditioner(inverse::Diagonal{}); + const auto density = pc::make_preconditioner(inverse::Diagonal{}); + // A zero cross block makes the triangular coupling act blockwise, isolating the coordinate orders. + const auto choice = pc::couple(heat, density, coupling::SecondThenFirst{}); + const auto context = dependencies::make_dependency_context( + pc::physical_block(operators::linearized_block(matrix(2, 2, {2.0, 0.0, 0.0, 4.0}))), + pc::physical_block(operators::linearized_block(matrix(1, 1, {5.0}))), + pc::physical_block(operators::linearized_block(matrix(2, 1, {0.0, 0.0}))), + dependencies::borrow(layout)); + auto cache = dependencies::make_computation_cache(pc::make_preconditioner_graph(choice, layout), context); + REQUIRE(cache.try_prepare(context)); + const auto result = apply(cache.get(), {6.0, 8.0, 20.0}); + CHECK(result[0] == Catch::Approx(4.0)); + CHECK(result[1] == Catch::Approx(3.0)); + CHECK(result[2] == Catch::Approx(2.0)); +} + +TEST_CASE("Constraint graph refresh follows child inverse changes without requiring a new root Jacobian", "[unit][preconditioner][graph]") +{ + using D = dependencies::KeyList; + using R = dependencies::KeyList; + using V = dependencies::KeyList; + using Constraints = pc::ConstraintCoordinates; + const auto field_choice = pc::make_preconditioner(inverse::Diagonal{}); + const auto choice = pc::with_constraints(field_choice); + using Recipe = decltype(choice); + auto fields = operators::linearized_block(matrix(1, 1, {2.0})); + const auto context = dependencies::make_dependency_context( + dependencies::borrow>(fields), + dependencies::own>(operators::linearized_block(matrix(1, 1, {1.0}))), + dependencies::own>(operators::linearized_block(matrix(1, 1, {3.0}))), + dependencies::own>(operators::linearized_block(matrix(1, 1, {4.0})))); + auto cache = dependencies::make_computation_cache(pc::make_preconditioner_graph(choice), context); + REQUIRE(cache.try_prepare(context)); + auto old = cache.share(); + const auto old_result = apply(*old, {7.0, 15.0}); + CHECK(old_result[0] == Catch::Approx(2.6)); + CHECK(old_result[1] == Catch::Approx(1.8)); + fields = operators::linearized_block(matrix(1, 1, {4.0})); + auto refreshed = cache.try_prepare(context); + REQUIRE(refreshed); + CHECK(refreshed->status() == dependencies::PreparationStatus::computed); + const auto result = apply(cache.get(), {7.0, 15.0}); + CHECK(4.0 * result[0] + result[1] == Catch::Approx(7.0)); + CHECK(3.0 * result[0] + 4.0 * result[1] == Catch::Approx(15.0)); + CHECK(apply(*old, {7.0, 15.0})[0] == Catch::Approx(old_result[0])); +} + +TEST_CASE("An incompatible physical representation returns a preparation error", "[unit][preconditioner][graph]") +{ + const auto choice = pc::make_preconditioner(inverse::Diagonal{}); + const auto context = dependencies::make_dependency_context(pc::physical_block(operators::LinearizedBlock(matrix(1, 1, {2.0})))); + auto cache = dependencies::make_computation_cache(pc::make_preconditioner_graph(choice), context); + const auto result = cache.try_prepare(context); + REQUIRE_FALSE(result); + CHECK(result.error().error.code == Code::PRECONDITIONER_INCOMPATIBLE_STRATEGY); +} + +TEST_CASE("MFEM adapters bind accepted Jacobians and reuse the existing normalization transform", "[unit][preconditioner][graph]") +{ + namespace normalization = serif::solver::normalization; + auto physical = matrix(2, 2, {2.0, 0.0, 0.0, 4.0}); + const auto inverse = pc::backend::mfem::try_dense_inverse(*physical); + REQUIRE(inverse); + pc::backend::mfem::Solver solver(*inverse, physical); + mfem::Vector input(2), output(2), wrong_size(1); + input = 2.0; + CHECK_THROWS_WITH(solver.Mult(wrong_size, output), Catch::Matchers::ContainsSubstring("PRECONDITIONER_SIZE_MISMATCH")); + CHECK_THROWS_WITH(solver.Mult(input, wrong_size), Catch::Matchers::ContainsSubstring("PRECONDITIONER_SIZE_MISMATCH")); + solver.Mult(input, output); + CHECK(output[0] == Catch::Approx(1.0)); + CHECK(output[1] == Catch::Approx(0.5)); + CHECK_NOTHROW(solver.SetOperator(*physical)); + const auto other = matrix(2, 2, {2.0, 0.0, 0.0, 4.0}); + CHECK_THROWS(solver.SetOperator(*other)); + const auto scaling = normalization::make_diagonal_normalization(std::vector{3.0, 5.0}, std::vector{7.0, 11.0}); + REQUIRE(scaling); + normalization::backend::mfem::ScaledJacobianOperator jacobian(*physical, *scaling); + normalization::backend::mfem::ScaledPreconditioner scaled(solver, jacobian); + mfem::Vector state(2), residual(2), recovered(2); + state[0] = 2.0; + state[1] = -3.0; + jacobian.Mult(state, residual); + scaled.Mult(residual, recovered); + CHECK(recovered[0] == Catch::Approx(state[0])); + CHECK(recovered[1] == Catch::Approx(state[1])); +} diff --git a/tests/preconditioner/graph_extensions.cpp b/tests/preconditioner/graph_extensions.cpp new file mode 100644 index 00000000..8bd92c60 --- /dev/null +++ b/tests/preconditioner/graph_extensions.cpp @@ -0,0 +1,101 @@ +#include +#include +#include +#include + +#include +#include + +#include "serif/solver/preconditioner/preconditioner.hpp" +#include "serif/utils/dependencies/computation_cache.hpp" + +namespace +{ + namespace pc = serif::solver::preconditioner; + namespace dependencies = serif::utils::dependencies; + namespace physical = serif::operators::backend::mfem; + using Code = serif::utils::errors::SERiFErrorCode; + + struct Density + { + }; + struct DensityEquation + { + }; + struct PhysicalScale + { + }; + struct PreparedScale + { + }; + using DensityKeys = dependencies::KeyList; + using DensityRows = dependencies::KeyList; + using DensityBlock = pc::keys::Block; +} // namespace + +TEST_CASE("Explicit upstream computations retain move-only owners and share ordinary preconditioner invalidation", "[unit][preconditioner][graph]") +{ + using Layout = serif::discretization::blocks::SystemLayout; + const Layout layout{.state_sizes = {2}, .residual_sizes = {2}}; + const auto choice = pc::make_preconditioner(pc::strategies::inverse::Diagonal{}); + auto prepared_block = dependencies::make_computation>( + [coefficients = std::make_unique>(std::array{2.0, 3.0})](double scale) -> std::expected + { + if (scale <= 0.0) + { + return std::unexpected(pc::Failure{Code::PRECONDITIONER_INVALID_CALIBRATION, "The upstream physical scale must be positive."}); + } + auto matrix = std::make_shared(2); + *matrix = 0.0; + (*matrix)(0, 0) = (*coefficients)[0] * scale; + (*matrix)(1, 1) = (*coefficients)[1] * scale; + return physical::linearized_block(std::move(matrix)); + }); + static_assert(!std::copy_constructible); + auto prepared_scale = dependencies::make_computation>([](double scale) + { + return 2.0 * scale; + }); + // The explicitly supplied nodes are deliberately in the opposite order from their numerical dependency. + auto graph = pc::make_preconditioner_graph(choice, layout, std::move(prepared_block), std::move(prepared_scale)); + double physical_scale = 1.0; + const auto context = dependencies::make_dependency_context(dependencies::borrow(physical_scale), dependencies::borrow(layout)); + auto cache = dependencies::make_computation_cache(std::move(graph), context); + REQUIRE(cache.try_prepare(context)); + const auto accepted = cache.share(); + mfem::Vector rhs(2), result(2); + rhs[0] = 6.0; + rhs[1] = 12.0; + accepted->action->Mult(rhs, result); + CHECK(result[0] == Catch::Approx(1.5)); + CHECK(result[1] == Catch::Approx(2.0)); + const auto reused = cache.try_prepare(context); + REQUIRE(reused); + CHECK(reused->computed_count() == 0); + CHECK(cache.share() == accepted); + + physical_scale = 2.0; + const auto changed = cache.try_prepare(context); + REQUIRE(changed); + CHECK(changed->status() == dependencies::PreparationStatus::computed); + CHECK(changed->status() == dependencies::PreparationStatus::computed); + CHECK(changed->status() == dependencies::PreparationStatus::computed); + cache.get().action->Mult(rhs, result); + CHECK(result[0] == Catch::Approx(0.75)); + CHECK(result[1] == Catch::Approx(1.0)); + + physical_scale = 0.0; + const auto rejected = cache.try_prepare(context); + REQUIRE_FALSE(rejected); + CHECK(rejected.error().error.code == Code::PRECONDITIONER_INVALID_CALIBRATION); + CHECK(rejected.error().error.message == "The upstream physical scale must be positive."); + CHECK_FALSE(cache.ready()); + accepted->action->Mult(rhs, result); + CHECK(result[0] == Catch::Approx(1.5)); + CHECK(result[1] == Catch::Approx(2.0)); + physical_scale = 3.0; + REQUIRE(cache.try_prepare(context)); + cache.get().action->Mult(rhs, result); + CHECK(result[0] == Catch::Approx(0.5)); + CHECK(result[1] == Catch::Approx(2.0 / 3.0)); +} diff --git a/tests/preconditioner/graph_review.cpp b/tests/preconditioner/graph_review.cpp new file mode 100644 index 00000000..63e23587 --- /dev/null +++ b/tests/preconditioner/graph_review.cpp @@ -0,0 +1,219 @@ +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "serif/solver/preconditioner/preconditioner.hpp" +#include "serif/utils/dependencies/computation_cache.hpp" + +namespace +{ + namespace pc = serif::solver::preconditioner; + namespace dependencies = serif::utils::dependencies; + namespace physical = serif::operators::backend::mfem; + namespace coupling = pc::strategies::coupling; + using Inverse = pc::backend::mfem::Inverse; + using Code = serif::utils::errors::SERiFErrorCode; + + struct Density + { + }; + struct Temperature + { + }; + struct HeatBalance + { + }; + using DensityKeys = dependencies::KeyList; + using HeatKeys = dependencies::KeyList; + using TemperatureKeys = dependencies::KeyList; + using DensityBlock = pc::keys::Block; + using HeatBlock = pc::keys::Block; + + /** @brief Create independently owned diagonal or one-entry physical matrices. */ + [[nodiscard]] std::shared_ptr diagonal(std::initializer_list values) + { + auto matrix = std::make_shared(values.size()); + *matrix = 0.0; + int index = 0; + for (double value : values) + { + (*matrix)(index, index) = value; + ++index; + } + return matrix; + } + + /** @brief Apply a compiled inverse without leaving initialization to the implementation. */ + [[nodiscard]] mfem::Vector apply(const Inverse& inverse, std::initializer_list coefficients) + { + mfem::Vector input(coefficients.size()); + std::copy(coefficients.begin(), coefficients.end(), input.GetData()); + mfem::Vector result(inverse.action->Height()); + result = 17.0; + inverse.action->Mult(input, result); + return result; + } + + /** @brief A custom strategy's reported success must still satisfy the physical block contract. */ + struct CustomInverse final + { + std::expected result; + + /** @brief Return a chosen result so malformed successful declarations can be tested independently. */ + [[nodiscard]] std::expected try_inverse(const physical::LinearizedBlock&) const + { + return result; + } + }; +} // namespace + +TEST_CASE("Shared physical layouts reject incorrect constituent sizes even when packed totals agree", "[unit][preconditioner][graph]") +{ + using Layout = serif::discretization::blocks::SystemLayout, dependencies::KeyList>; + Layout layout{.state_sizes = {1, 2}, .residual_sizes = {2, 1}}; + const auto choice = pc::couple(pc::make_preconditioner(pc::strategies::inverse::Diagonal{}), + pc::make_preconditioner(pc::strategies::inverse::Diagonal{}), coupling::SecondThenFirst{}); + const auto context = dependencies::make_dependency_context( + dependencies::own(physical::linearized_block(diagonal({2.0, 4.0}))), + dependencies::own(physical::linearized_block(diagonal({5.0}))), + dependencies::own>(physical::linearized_block(std::make_shared<::mfem::DenseMatrix>(2, 1))), + dependencies::borrow(layout)); + auto cache = dependencies::make_computation_cache(pc::make_preconditioner_graph(choice, layout), context); + REQUIRE(cache.try_prepare(context)); + const auto accepted = cache.share(); + + SECTION("Unknown block counts are not interchangeable") + { + layout.state_sizes = {2, 1}; + } + SECTION("Equation block counts are checked independently") + { + layout.residual_sizes = {1, 2}; + } + SECTION("Matching wrong row and column partitions do not hide a physical mismatch") + { + layout.state_sizes = {2, 1}; + layout.residual_sizes = {1, 2}; + } + const auto rejected = cache.try_prepare(context); + REQUIRE_FALSE(rejected); + CHECK(rejected.error().error.code == Code::PRECONDITIONER_SIZE_MISMATCH); + CHECK_FALSE(cache.ready()); + CHECK_THROWS(cache.share()); + const auto old = apply(*accepted, {6.0, 8.0, 20.0}); + CHECK(old[0] == Catch::Approx(4.0)); + CHECK(old[1] == Catch::Approx(3.0)); + CHECK(old[2] == Catch::Approx(2.0)); + + layout.state_sizes = {1, 2}; + layout.residual_sizes = {2, 1}; + const auto recovered = cache.try_prepare(context); + REQUIRE(recovered); + CHECK(recovered->status>() == dependencies::PreparationStatus::reused); + CHECK(recovered->status>() == dependencies::PreparationStatus::reused); +} + +TEST_CASE("Triangular preconditioner graphs require and track only their used cross coupling", "[unit][preconditioner][graph]") +{ + using Required = pc::keys::Block; + using Unused = pc::keys::Block; + const auto choice = pc::couple(pc::make_preconditioner(pc::strategies::inverse::Diagonal{}), + pc::make_preconditioner(pc::strategies::inverse::Diagonal{}), coupling::SecondThenFirst{}); + const auto graph = pc::make_preconditioner_graph(choice); + using Graph = decltype(graph); + using Result = decltype(choice)::Result; + static_assert(dependencies::ContainsKey); + static_assert(!dependencies::ContainsKey); + static_assert(dependencies::ContainsKey); + static_assert(dependencies::ContainsKey); + + auto used = physical::linearized_block(diagonal({4.0})); + const auto context = dependencies::make_dependency_context( + dependencies::own(physical::linearized_block(diagonal({2.0}))), + dependencies::own(physical::linearized_block(diagonal({3.0}))), dependencies::borrow(used)); + auto cache = dependencies::make_computation_cache(graph, context); + REQUIRE(cache.try_prepare(context)); + const auto result = apply(cache.get(), {6.0, 12.0}); + CHECK(result[0] == Catch::Approx(-5.0)); + CHECK(result[1] == Catch::Approx(4.0)); + + auto ignored = physical::linearized_block(diagonal({99.0})); + const auto extended = dependencies::make_dependency_context( + dependencies::own(physical::linearized_block(diagonal({2.0}))), + dependencies::own(physical::linearized_block(diagonal({3.0}))), + dependencies::borrow(used), dependencies::borrow(ignored)); + auto extended_cache = dependencies::make_computation_cache(graph, extended); + REQUIRE(extended_cache.try_prepare(extended)); + const auto accepted = extended_cache.share(); + ignored = physical::linearized_block(diagonal({-13.0})); + const auto reused = extended_cache.try_prepare(extended); + REQUIRE(reused); + CHECK(reused->computed_count() == 0); + CHECK(extended_cache.share() == accepted); + + used = physical::linearized_block(diagonal({2.0})); + const auto refreshed = extended_cache.try_prepare(extended); + REQUIRE(refreshed); + CHECK(refreshed->source_changed()); + CHECK(refreshed->status() == dependencies::PreparationStatus::computed); + CHECK(refreshed->status>() == dependencies::PreparationStatus::reused); + CHECK(refreshed->status>() == dependencies::PreparationStatus::reused); + const auto updated = apply(extended_cache.get(), {6.0, 12.0}); + CHECK(updated[0] == Catch::Approx(-1.0)); + CHECK(updated[1] == Catch::Approx(4.0)); + CHECK(apply(*accepted, {6.0, 12.0})[0] == Catch::Approx(result[0])); + CHECK(apply(*accepted, {6.0, 12.0})[1] == Catch::Approx(result[1])); +} + +TEST_CASE("Custom inverse graph results must preserve the selected physical action contract", "[unit][preconditioner][graph]") +{ + std::expected result = Inverse{.action = diagonal({0.5, 0.25})}; + Code expected_code = Code::PRECONDITIONER_INVALID_OPERATOR; + SECTION("Successful preparation cannot publish a null inverse") + { + result->action.reset(); + } + SECTION("An inverse must reverse the selected physical dimensions") + { + result->action = diagonal({0.5}); + expected_code = Code::PRECONDITIONER_SIZE_MISMATCH; + } + SECTION("The declared application contract must be valid") + { + result->contract = static_cast(99); + expected_code = Code::PRECONDITIONER_INVALID_OPTIONS; + } + SECTION("An originating custom failure propagates unchanged") + { + expected_code = Code::PRECONDITIONER_INVALID_CALIBRATION; + result = std::unexpected(pc::Failure{expected_code, "Custom calibration failed once."}); + } + const auto choice = pc::make_preconditioner(CustomInverse{result}); + const auto context = dependencies::make_dependency_context(dependencies::own(physical::linearized_block(diagonal({2.0, 4.0})))); + auto cache = dependencies::make_computation_cache(pc::make_preconditioner_graph(choice), context); + const auto rejected = cache.try_prepare(context); + REQUIRE_FALSE(rejected); + CHECK(rejected.error().error.code == expected_code); + if (expected_code == Code::PRECONDITIONER_INVALID_CALIBRATION) + { + CHECK(rejected.error().error.message == "Custom calibration failed once."); + } +} + +TEST_CASE("Empty physical constraint lists reuse the field recipe and accepted inverse", "[unit][preconditioner][graph]") +{ + const auto fields = pc::make_preconditioner(pc::strategies::inverse::Diagonal{}); + using Empty = dependencies::KeyList<>; + const auto choice = pc::with_constraints>(fields); + static_assert(std::same_as); + const auto context = dependencies::make_dependency_context(dependencies::own(physical::linearized_block(diagonal({2.0})))); + auto cache = dependencies::make_computation_cache(pc::make_preconditioner_graph(choice), context); + REQUIRE(cache.try_prepare(context)); + CHECK(apply(cache.get(), {8.0})[0] == Catch::Approx(4.0)); +} diff --git a/tests/preconditioner/inverses.cpp b/tests/preconditioner/inverses.cpp new file mode 100644 index 00000000..330de382 --- /dev/null +++ b/tests/preconditioner/inverses.cpp @@ -0,0 +1,233 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include "serif/backend/mfem/solver/preconditioner/inverse/amg.hpp" +#include "serif/backend/mfem/solver/preconditioner/inverse/dense.hpp" +#include "serif/backend/mfem/solver/preconditioner/inverse/diagonal.hpp" + +#ifdef MFEM_USE_MPI +#include "mfem/general/communication.hpp" +#endif + +namespace +{ + namespace preconditioner = serif::solver::preconditioner; + namespace backend = preconditioner::backend::mfem; + namespace options = preconditioner::strategies::inverse; + using Code = serif::utils::errors::SERiFErrorCode; + + /** @brief Make small right-hand sides readable without coupling expectations to a numerical implementation. */ + mfem::Vector vector(std::initializer_list values) + { + mfem::Vector result(static_cast(values.size())); + int index = 0; + for (const double value : values) + { + result[index++] = value; + } + return result; + } + + /** @brief Compare inverse actions at the original MeanField tolerances. */ + void check_vector(const mfem::Vector& actual, const mfem::Vector& expected, const double tolerance = 2.0e-13) + { + REQUIRE(actual.Size() == expected.Size()); + for (int index = 0; index < actual.Size(); ++index) + { + CHECK(actual[index] == Catch::Approx(expected[index]).epsilon(tolerance).margin(tolerance)); + } + } + + /** @brief Reuse one positive definite mass fixture for polynomial linearity and frozen ownership. */ + std::shared_ptr mass_matrix() + { + auto matrix = std::make_shared(3); + *matrix = 0.0; + (*matrix)(0, 0) = 4.0; + (*matrix)(1, 1) = 3.0; + (*matrix)(2, 2) = 2.0; + (*matrix)(0, 1) = (*matrix)(1, 0) = 1.0; + (*matrix)(1, 2) = (*matrix)(2, 1) = 1.0; + return matrix; + } + + /** @brief Check the fixed-map contract independently of a particular inverse algorithm. */ + void check_fixed_linear(const backend::Inverse& inverse, const mfem::Vector& x, const mfem::Vector& y, const double tolerance) + { + REQUIRE(inverse.contract == preconditioner::ApplicationContract::fixed_linear); + mfem::Vector px(x.Size()), py(x.Size()), actual(x.Size()), expected(x.Size()), combination(x); + inverse.action->Mult(x, px); + inverse.action->Mult(y, py); + combination *= 1.7; + combination.Add(-0.4, y); + actual = std::numeric_limits::quiet_NaN(); + const auto* storage = actual.GetData(); + inverse.action->Mult(combination, actual); + CHECK(actual.GetData() == storage); + expected = px; + expected *= 1.7; + expected.Add(-0.4, py); + check_vector(actual, expected, tolerance); + inverse.action->Mult(x, actual); + check_vector(actual, px, 0.0); + combination = 0.0; + inverse.action->Mult(combination, actual); + CHECK(actual.Norml2() == 0.0); + actual = x; + inverse.action->Mult(actual, actual); + check_vector(actual, px, tolerance); + mfem::Vector overlapping_storage(x.Size() + 1); + for (const int shift : {0, 1}) + { + mfem::Vector overlapping_input(overlapping_storage.GetData() + shift, x.Size()); + mfem::Vector overlapping_output(overlapping_storage.GetData() + 1 - shift, x.Size()); + overlapping_input = x; + inverse.action->Mult(overlapping_input, overlapping_output); + check_vector(overlapping_output, px, tolerance); + } + } +} // namespace + +TEST_CASE("Signed diagonal inverses own their factors and reject unrepresentable reciprocals", "[preconditioner][inverse][unit]") +{ + std::vector diagonal{2.0, -4.0, 0.5}; + const auto inverse = backend::try_diagonal_inverse(diagonal); + REQUIRE(inverse); + diagonal.assign(3, 77.0); + mfem::Vector action(3); + inverse->action->Mult(vector({4.0, 8.0, -1.0}), action); + check_vector(action, vector({2.0, -2.0, -2.0})); + inverse->action->MultTranspose(vector({4.0, 8.0, -1.0}), action); + check_vector(action, vector({2.0, -2.0, -2.0})); + check_fixed_linear(*inverse, vector({1.0, -2.0, 0.25}), vector({-0.5, 0.75, 3.0}), 2.0e-13); + for (const double bad : {0.0, std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN(), std::numeric_limits::denorm_min()}) + { + CHECK_FALSE(backend::try_diagonal_inverse(std::array{bad})); + } + CHECK_FALSE(backend::try_diagonal_inverse({})); +} + +TEST_CASE("Dense inverse retains signed pivoted solves transpose and independent snapshots", "[preconditioner][inverse][unit]") +{ + mfem::DenseMatrix matrix(2); + matrix(0, 0) = 4.0; + matrix(0, 1) = 1.0; + matrix(1, 0) = 2.0; + matrix(1, 1) = 3.0; + const auto inverse = backend::try_dense_inverse(matrix); + REQUIRE(inverse); + matrix = 0.0; + matrix(0, 0) = 2.0; + matrix(1, 1) = 4.0; + const auto replacement = backend::try_dense_inverse(matrix); + REQUIRE(replacement); + mfem::Vector action(2); + inverse->action->Mult(vector({7.0, 1.0}), action); + check_vector(action, vector({2.0, -1.0})); + inverse->action->MultTranspose(vector({6.0, -1.0}), action); + check_vector(action, vector({2.0, -1.0})); + replacement->action->Mult(vector({7.0, 1.0}), action); + check_vector(action, vector({3.5, 0.25})); + check_fixed_linear(*inverse, vector({1.0, -2.0}), vector({-0.5, 0.75}), 2.0e-13); +} + +TEST_CASE("Dense conditioning uses scale-independent infinity norms and recoverable failures", "[preconditioner][inverse][unit]") +{ + for (const double scale : {1.0e-250, 1.0, 1.0e250}) + { + mfem::DenseMatrix matrix(2); + matrix(0, 0) = 0.0; + matrix(0, 1) = 2.0 * scale; + matrix(1, 0) = 3.0 * scale; + matrix(1, 1) = scale; + const auto inverse = backend::try_dense_inverse(matrix, {.minimum_reciprocal_condition = 0.49}); + REQUIRE(inverse); + CHECK(backend::try_dense_inverse(matrix, {.minimum_reciprocal_condition = 0.5})); + const auto ill_conditioned = backend::try_dense_inverse(matrix, {.minimum_reciprocal_condition = 0.51}); + REQUIRE_FALSE(ill_conditioned); + CHECK(ill_conditioned.error().code == Code::PRECONDITIONER_ILL_CONDITIONED_OPERATOR); + mfem::Vector action(2); + inverse->action->Mult(vector({-2.0 * scale, 5.0 * scale}), action); + check_vector(action, vector({2.0, -1.0})); + } + mfem::DenseMatrix singular(2); + singular = 1.0; + const auto rejected = backend::try_dense_inverse(singular); + REQUIRE_FALSE(rejected); + CHECK(rejected.error().code == Code::PRECONDITIONER_SINGULAR_OPERATOR); + singular(0, 0) = std::numeric_limits::quiet_NaN(); + const auto nonfinite = backend::try_dense_inverse(singular); + REQUIRE_FALSE(nonfinite); + CHECK(nonfinite.error().code == Code::PRECONDITIONER_NONFINITE_VALUE); + CHECK_FALSE(backend::try_dense_inverse(singular, {.minimum_reciprocal_condition = -1.0})); + CHECK_FALSE(backend::try_dense_inverse(mfem::DenseMatrix(2, 3))); + + mfem::DenseMatrix disparate(2); + disparate = 0.0; + disparate(0, 0) = 1.0e308; + disparate(1, 1) = 1.0e-308; + const auto disparate_inverse = backend::try_dense_inverse(disparate); + REQUIRE(disparate_inverse); + mfem::Vector action(2); + disparate_inverse->action->Mult(vector({1.0e308, -1.0e-308}), action); + check_vector(action, vector({1.0, -1.0})); + disparate_inverse->action->MultTranspose(vector({1.0e308, -1.0e-308}), action); + check_vector(action, vector({1.0, -1.0})); + CHECK_FALSE(backend::try_dense_inverse(disparate, {.minimum_reciprocal_condition = 1.0e-14})); +} + +TEST_CASE("One-process BoomerAMG preserves fixed-cycle linearity", "[preconditioner][inverse][integration]") +{ + auto matrix = std::make_shared(32); + for (int row = 0; row < matrix->Height(); ++row) + { + matrix->Add(row, row, 2.0); + if (row > 0) + { + matrix->Add(row, row - 1, -1.0); + matrix->Add(row - 1, row, -1.0); + } + } + matrix->Finalize(); + const auto zero = std::make_shared(2); + zero->Add(0, 0, 0.0); + zero->Add(1, 1, 0.0); + zero->Finalize(); + const auto unsupported = backend::try_amg_inverse(zero); + REQUIRE_FALSE(unsupported); + CHECK(unsupported.error().code == Code::PRECONDITIONER_INVALID_OPERATOR); + CHECK_FALSE(backend::try_amg_inverse(matrix, options::FixedAMG{.cycles = 0})); +#ifdef MFEM_USE_MPI + if (!mfem::Mpi::IsInitialized()) + { + mfem::Mpi::Init(); + } + const auto fixed = backend::try_amg_inverse(matrix, options::FixedAMG{.cycles = 2}); + REQUIRE(fixed); + mfem::Vector exact(32), second(32), rhs(32), second_rhs(32), action(32), residual(32); + for (int index = 0; index < 32; ++index) + { + exact[index] = 1.0; + second[index] = 0.25 + index; + } + matrix->Mult(exact, rhs); + matrix->Mult(second, second_rhs); + check_fixed_linear(*fixed, rhs, second_rhs, 1.0e-11); + fixed->action->Mult(rhs, action); + matrix->Mult(action, residual); + residual -= rhs; + CHECK(residual.Norml2() < rhs.Norml2()); + matrix.reset(); + fixed->action->Mult(rhs, action); + CHECK(std::isfinite(action.Norml2())); +#else + const auto unavailable = backend::try_amg_inverse(matrix); + REQUIRE_FALSE(unavailable); + CHECK(unavailable.error().code == Code::PRECONDITIONER_INCOMPATIBLE_STRATEGY); +#endif +} diff --git a/tests/preconditioner/layout.cpp b/tests/preconditioner/layout.cpp new file mode 100644 index 00000000..4c9712b2 --- /dev/null +++ b/tests/preconditioner/layout.cpp @@ -0,0 +1,102 @@ +#include +#include + +#include +#include "serif/discretization/blocks/vector_view.hpp" +#include "serif/operators/linearized_system_view.hpp" +#include "serif/utils/dependencies/dependencies.hpp" + +namespace +{ + namespace blocks = serif::discretization::blocks; + namespace dependencies = serif::utils::dependencies; + using Kind = blocks::RepresentedValueKind; + + struct Density + { + }; + struct Shape + { + }; + struct Gradient + { + }; + struct Potential + { + }; + struct Enthalpy + { + }; + struct MassConstraint + { + }; + struct CentralConstraint + { + }; + + using Values = dependencies::KeyList; + using Residuals = dependencies::KeyList; + using Layout = blocks::SystemLayout; +} // namespace + +TEST_CASE("System layout preserves every uneven physical block in independent state and residual orders", "[preconditioner][layout][unit]") +{ + // The original equilibrium_coordinates.cpp fixture deliberately totals 22 on both sides with different orders. + const Layout layout{.state_sizes = {2, 3, 4, 5, 6, 1, 1}, .residual_sizes = {4, 5, 2, 3, 6, 1, 1}}; + CHECK(layout.total_size() == 22); + CHECK(layout.total_size() == 22); + CHECK(layout.offset() == 0); + CHECK(layout.offset() == 9); + CHECK(layout.offset() == 14); + CHECK(layout.offset() == 14); + CHECK(layout.offset() == 20); + CHECK(layout.offset() == 21); + std::array values{}, residuals{}; + for (std::size_t index = 0; index < values.size(); ++index) + { + values[index] = 100.0 + index; + residuals[index] = -200.0 - index; + } + const blocks::StateView state(layout, values); + const blocks::ResidualView residual(layout, residuals); + CHECK(state.get().data() == values.data()); + CHECK(state.get().data() == values.data() + 14); + CHECK(residual.get().data() == residuals.data() + 9); + CHECK(residual.get().data() == residuals.data()); + state.get()[0] = 5.0; + state.get()[0] = 7.0; + CHECK(values[20] == 5.0); + CHECK(values[21] == 7.0); + CHECK(residual.get()[0] == -209.0); +} + +TEST_CASE("Block vector views reject mismatched storage and overflowing coordinate metadata", "[preconditioner][layout][validation]") +{ + Layout layout{.state_sizes = {2, 3, 4, 5, 6, 1, 1}, .residual_sizes = {4, 5, 2, 3, 6, 1, 1}}; + std::array short_values{}; + CHECK_THROWS(blocks::StateView(layout, short_values)); + std::array values{}; + blocks::StateView state(layout, values); + layout.state_sizes[0] = 23; + CHECK_THROWS(state.get()); + layout.state_sizes[0] = std::numeric_limits::max(); + CHECK_THROWS(layout.total_size()); + CHECK_THROWS(layout.offset()); + CHECK_THROWS(blocks::StateView(layout, values)); + const blocks::SystemLayout, dependencies::KeyList<>> empty{}; + CHECK(empty.total_size() == 0); + CHECK(empty.total_size() == 0); +} + +TEST_CASE("Linearized system views borrow the declared physical blocks without changing their identity", "[preconditioner][layout][unit]") +{ + using DensityBlock = serif::operators::PhysicalBlock; + using SurfaceBlock = serif::operators::PhysicalBlock; + const Layout layout{.state_sizes = {2, 3, 4, 5, 6, 1, 1}, .residual_sizes = {4, 5, 2, 3, 6, 1, 1}}; + const auto sources = dependencies::make_dependency_context(dependencies::own(std::array{2.0, 3.0}), dependencies::own(std::array{5.0, 7.0})); + const serif::operators::LinearizedSystemView system(layout, sources); + CHECK(&system.layout() == &layout); + CHECK(&system.blocks() == &sources); + CHECK(&system.block() == &sources.get()); + CHECK(&system.block() == &sources.get()); +} diff --git a/tests/preconditioner/material.cpp b/tests/preconditioner/material.cpp new file mode 100644 index 00000000..07476b5f --- /dev/null +++ b/tests/preconditioner/material.cpp @@ -0,0 +1,89 @@ +#include +#include +#include + +#include +#include + +#include "mfem/linalg/densemat.hpp" +#include "serif/backend/mfem/solver/preconditioner/coupling.hpp" +#include "serif/backend/mfem/solver/preconditioner/inverse/diagonal.hpp" + +namespace +{ + namespace backend = serif::solver::preconditioner::backend::mfem; + using CouplingOrder = serif::solver::preconditioner::strategies::coupling::CouplingOrder; + + /** @brief Build a small independent physical block from row-major entries. */ + [[nodiscard]] backend::OperatorHandle material_block(int rows, int columns, std::span entries) + { + auto matrix = std::make_shared<::mfem::DenseMatrix>(rows, columns); + for (int row = 0; row < rows; ++row) + { + for (int column = 0; column < columns; ++column) + { + (*matrix)(row, column) = entries[row * columns + column]; + } + } + return matrix; + } + + /** @brief Use the production diagonal inverse for each independently scaled material component. */ + [[nodiscard]] backend::Inverse scalar_inverse(double diagonal) + { + const auto inverse = backend::try_diagonal_inverse(std::span{&diagonal, 1}); + REQUIRE(inverse); + return *inverse; + } +} // namespace + +TEST_CASE("Material surface policies compose the same two-block inverse with signed triangular algebra", "[unit][preconditioner][material]") +{ + const backend::TwoBlockCouplings material_couplings{.first_from_second = material_block(1, 1, std::array{4.0})}; + const backend::TwoBlockCouplings surface_couplings{.first_from_second = material_block(2, 1, std::array{3.0, 8.0}), .second_from_first = material_block(1, 2, std::array{5.0, 7.0})}; + const auto material = backend::try_coupled_inverse(scalar_inverse(2.0), scalar_inverse(9.0), material_couplings, CouplingOrder::second_then_first); + REQUIRE(material); + const auto diagonal_material = backend::try_coupled_inverse(scalar_inverse(2.0), scalar_inverse(9.0), {}, CouplingOrder::independent); + REQUIRE(diagonal_material); + + // Each subsystem keeps its own native order: material=(density, enthalpy), followed by surface. + ::mfem::Vector right_hand_side(3), actual(3); + right_hand_side[0] = 29.0; + right_hand_side[1] = 43.0; + right_hand_side[2] = 44.0; + const double enthalpy = 43.0 / 9.0; + const double density = (29.0 - 4.0 * enthalpy) / 2.0; + const double surface = 44.0 / 6.0; + const double corrected_enthalpy = (43.0 - 8.0 * surface) / 9.0; + const double ldu_surface = (44.0 - 5.0 * density - 7.0 * enthalpy) / 6.0; + const double ldu_enthalpy = (43.0 - 8.0 * ldu_surface) / 9.0; + struct PolicyCase + { + backend::Inverse material; + CouplingOrder order; + std::array expected; + }; + const std::array cases{ + PolicyCase{*diagonal_material, CouplingOrder::independent, {14.5, enthalpy, surface}}, + PolicyCase{*material, CouplingOrder::independent, {density, enthalpy, surface}}, + PolicyCase{*material, CouplingOrder::second_then_first, {(29.0 - 3.0 * surface - 4.0 * corrected_enthalpy) / 2.0, corrected_enthalpy, surface}}, + PolicyCase{*material, CouplingOrder::lower_diagonal_upper, {(29.0 - 3.0 * ldu_surface - 4.0 * ldu_enthalpy) / 2.0, ldu_enthalpy, ldu_surface}}}; + for (const PolicyCase& policy : cases) + { + const auto inverse = backend::try_coupled_inverse(policy.material, scalar_inverse(6.0), surface_couplings, policy.order); + REQUIRE(inverse); + actual = 7.0; + inverse->action->Mult(right_hand_side, actual); + for (int index = 0; index < 3; ++index) + { + CHECK(actual[index] == Catch::Approx(policy.expected[index]).margin(2e-13)); + } + ::mfem::Vector zero(3); + zero = 0.0; + inverse->action->Mult(zero, actual); + CHECK(actual.Norml2() == 0.0); + } + + const auto schur = backend::try_schur_operator(material_block(1, 1, std::array{6.0}), *material, surface_couplings); + REQUIRE(schur); +} diff --git a/tests/preconditioner/meson.build b/tests/preconditioner/meson.build new file mode 100644 index 00000000..48fe8b18 --- /dev/null +++ b/tests/preconditioner/meson.build @@ -0,0 +1,22 @@ +preconditioner_test_sources = files( + 'border.cpp', + 'coupling.cpp', + 'graph.cpp', + 'graph_extensions.cpp', + 'graph_review.cpp', + 'inverses.cpp', + 'layout.cpp', + 'material.cpp', + 'parity.cpp', + 'pressure_rows.cpp', + '../equations/fixture.cpp', +) + +serif_preconditioner_tests = executable( + 'serif_preconditioner_tests', + preconditioner_test_sources, + dependencies: [serif_dep, catch2_dep, stroid_dep], + include_directories: include_directories('../include'), + build_rpath: mfem_runtime_prefix == '' ? '' : mfem_runtime_prefix / 'lib', +) +test('serif_preconditioner_tests', serif_preconditioner_tests, suite: 'preconditioner', timeout: 300, is_parallel: false) diff --git a/tests/preconditioner/parity.cpp b/tests/preconditioner/parity.cpp new file mode 100644 index 00000000..d60426b2 --- /dev/null +++ b/tests/preconditioner/parity.cpp @@ -0,0 +1,58 @@ +#include +#include +#include +#include +#include +#include "mfem/linalg/densemat.hpp" +#include "serif/backend/mfem/solver/preconditioner/coupling.hpp" +#include "serif/backend/mfem/solver/preconditioner/inverse/diagonal.hpp" + +namespace +{ + namespace backend = serif::solver::preconditioner::backend::mfem; + using Order = serif::solver::preconditioner::strategies::coupling::CouplingOrder; + + /** @brief Express the original small physical couplings in row-major order. */ + std::shared_ptr matrix(const int rows, const int columns, const std::initializer_list entries) + { + REQUIRE(entries.size() == static_cast(rows * columns)); + auto result = std::make_shared(rows, columns); + auto entry = entries.begin(); + for (int row = 0; row < rows; ++row) + { + for (int column = 0; column < columns; ++column) + { + (*result)(row, column) = *entry++; + } + } + return result; + } + + /** @brief Prepare the original signed scalar inverse without another test-only numerical implementation. */ + backend::Inverse scalar_inverse(const double diagonal) + { + const auto inverse = backend::try_diagonal_inverse(std::array{diagonal}); + REQUIRE(inverse); + return *inverse; + } +} // namespace + +TEST_CASE("Generic coupling preserves the original material-gravity factorization tolerances", "[preconditioner][parity][unit]") +{ + const auto material = scalar_inverse(2.0); + const auto gravity = scalar_inverse(5.0); + const backend::TwoBlockCouplings cross{.first_from_second = matrix(1, 1, {7.0}), .second_from_first = matrix(1, 1, {3.0})}; + const std::array orders{Order::independent, Order::second_then_first, Order::lower_diagonal_upper}; + const std::array, 3> expected{{{5.5, 2.6}, {-3.6, 2.6}, {7.95, -0.7}}}; + mfem::Vector rhs(2), result(2); + rhs[0] = 11.0; + rhs[1] = 13.0; + for (std::size_t policy = 0; policy < orders.size(); ++policy) + { + const auto inverse = backend::try_coupled_inverse(material, gravity, cross, orders[policy]); + REQUIRE(inverse); + inverse->action->Mult(rhs, result); + CHECK(result[0] == Catch::Approx(expected[policy][0]).margin(2.0e-14)); + CHECK(result[1] == Catch::Approx(expected[policy][1]).margin(2.0e-14)); + } +} diff --git a/tests/preconditioner/pressure_rows.cpp b/tests/preconditioner/pressure_rows.cpp new file mode 100644 index 00000000..2b6de0bf --- /dev/null +++ b/tests/preconditioner/pressure_rows.cpp @@ -0,0 +1,189 @@ +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "serif/backend/mfem/geometry/surface/pressure_boundary.hpp" +#include "serif/backend/mfem/geometry/surface/pressure_field_block.hpp" +#include "serif/tests/surface/boundary_fixture.hpp" +#include "serif/utils/dependencies/computation_cache.hpp" + +namespace +{ + namespace test = serif::tests::surface; + namespace fixture = test::boundary; + namespace physics = serif::geometry::surface; + namespace boundary = physics::backend::mfem; + namespace operators = serif::operators::backend::mfem; + namespace dependencies = serif::utils::dependencies; + using Temperature = fixture::ContinuousScalar; + using Vector = ::mfem::Vector; + + /** @brief Give the volume block nonzero off-diagonal couplings that constrained rows must completely remove. */ + [[nodiscard]] std::shared_ptr volume_matrix(int rows, int columns) + { + auto matrix = std::make_shared<::mfem::SparseMatrix>(rows, columns); + for (int row = 0; row < rows; ++row) + { + matrix->Add(row, row % columns, 2.0); + matrix->Add(row, (row + 1) % columns, -0.25); + } + matrix->Finalize(); + return matrix; + } + + /** @brief Compare each physical row rather than a norm that could hide a wrong small boundary entry. */ + void check_rows(const Vector& actual, const Vector& expected) + { + REQUIRE(actual.Size() == expected.Size()); + for (int row = 0; row < actual.Size(); ++row) + { + CHECK(actual[row] == Catch::Approx(expected[row]).margin(3e-13)); + } + } + + /** @brief Exercise different nodal bases at equal dimensions so an interpolated diagonal cannot be mistaken for a row sum. */ + template + void check_pressure_field() + { + fixture::Fixture mesh; + ::mfem::H1_FECollection uniform_collection(3, 3, ::mfem::BasisType::ClosedUniform); + ::mfem::FiniteElementSpace uniform_space(&mesh.mesh, &uniform_collection); + serif::discretization::backend::mfem::TermBinding temperature_binding(uniform_space); + REQUIRE(temperature_binding.size() == mesh.density.size()); + Vector density = fixture::sequence(mesh.density.size(), 0.8, 0.2); + Vector temperature = fixture::sequence(temperature_binding.size(), 1.7, 0.4); + const Vector direction = fixture::sequence(temperature.Size(), 0.2, 0.1); + Vector zero_density(density.Size()); + zero_density = 0.0; + const auto state = operators::make_term_coefficients(operators::bind(mesh.density, density), operators::bind(temperature_binding, temperature)); + const auto directions = operators::make_term_coefficients(operators::bind(mesh.density, zero_density), operators::bind(temperature_binding, direction)); + test::GasEOS model; + const auto selected = boundary::make_pressure_boundary(state); + /** @brief The ideal-gas fixture has no variable parameters; observational counters do not change its physics. */ + const auto eos_tracking = [](const test::GasEOS&) + { + return 0; + }; + const auto inputs = boundary::make_pressure_boundary_inputs(selected, dependencies::borrow(model, dependencies::track_by(eos_tracking)), physics::IsobaricSurface{test::quantities::PressureValue{0.8}}, state); + auto cache = dependencies::make_computation_cache(boundary::make_pressure_boundary_graph(), inputs); + REQUIRE(cache.try_prepare(inputs)); + std::vector> accepted = cache.template get(); + const auto volume = volume_matrix(density.Size(), temperature.Size()); + const operators::LinearizedBlock block = boundary::pressure_field_block(operators::linearized_block(volume), *selected, accepted); + REQUIRE(block.has_diagonal()); + REQUIRE(block.has_assembly()); + const auto assembled = block.assemble(); + const std::vector diagonal = block.diagonal(); + Vector actual(density.Size()), expected(density.Size()), assembled_action(density.Size()); + volume->Mult(direction, expected); + boundary::apply_jacobian_rows(*selected, accepted, directions, expected); + block.action()->Mult(direction, actual); + assembled->Mult(direction, assembled_action); + check_rows(actual, expected); + check_rows(assembled_action, expected); + + const ::mfem::SparseMatrix& interpolation = selected->traces.template get().interpolation(); + bool interpolating_diagonal = false; + for (int index = 0; index < selected->selected_rows->size(); ++index) + { + const int row = selected->selected_rows->reduced_dofs()[index]; + const double derivative = accepted[index].field_derivatives.template get(); + CHECK(diagonal[row] == Catch::Approx(derivative * interpolation(index, row)).margin(3e-14)); + if (std::abs(interpolation(index, row) - 1.0) > 1e-3) + { + interpolating_diagonal = true; + } + } + REQUIRE(interpolating_diagonal); + for (int row = 0; row < assembled->Height(); ++row) + { + CHECK(diagonal[row] == Catch::Approx((*assembled)(row, row)).margin(3e-14)); + } + Vector alias(direction); + block.action()->Mult(alias, alias); + check_rows(alias, expected); + const Vector dual = fixture::sequence(density.Size(), 0.4, 0.8); + Vector transpose(temperature.Size()), assembled_transpose(temperature.Size()); + block.action()->MultTranspose(dual, transpose); + assembled->MultTranspose(dual, assembled_transpose); + check_rows(transpose, assembled_transpose); + CHECK(dual * actual == Catch::Approx(direction * transpose).margin(2e-12)); + alias = dual; + block.action()->MultTranspose(alias, alias); + check_rows(alias, assembled_transpose); + + // Later accepted candidates cannot mutate the frozen row coefficients of an earlier physical block. + for (auto& point : accepted) + { + point.field_derivatives.template get() *= 2.0; + } + const auto refreshed = boundary::pressure_field_block(operators::linearized_block(volume), *selected, accepted); + refreshed.action()->Mult(direction, assembled_action); + block.action()->Mult(direction, actual); + check_rows(actual, expected); + for (int row : selected->selected_rows->reduced_dofs()) + { + CHECK(assembled_action[row] == Catch::Approx(2.0 * expected[row]).margin(3e-13)); + } + + const auto absent = boundary::pressure_field_block(operators::linearized_block(volume), *selected, accepted); + absent.action()->Mult(direction, actual); + volume->Mult(direction, expected); + const auto absent_matrix = absent.assemble(); + const auto absent_diagonal = absent.diagonal(); + for (int row : selected->selected_rows->reduced_dofs()) + { + expected[row] = 0.0; + CHECK(absent_diagonal[row] == 0.0); + } + check_rows(actual, expected); + absent_matrix->Mult(direction, actual); + check_rows(actual, expected); + + accepted.pop_back(); + CHECK_THROWS(boundary::pressure_field_block(operators::linearized_block(volume), *selected, accepted)); + } +} // namespace + +TEST_CASE("Selected pressure blocks retain general EOS interpolation in actions diagonals and sparse rows", "[unit][preconditioner][surface][operators]") +{ + SECTION("Density carries the surface relation without enthalpy") + { + check_pressure_field>(); + } + SECTION("Direct pressure derivatives include every thermodynamic field") + { + check_pressure_field>(); + } +} + +TEST_CASE("Generic physical row replacement preserves optional capabilities and rejects invalid rows", "[unit][preconditioner][surface][operators]") +{ + const auto original = volume_matrix(3, 3); + auto replacement = std::make_shared<::mfem::SparseMatrix>(1, 3); + replacement->Add(0, 0, 0.2); + replacement->Add(0, 1, -0.7); + replacement->Finalize(); + const operators::LinearizedBlock action_only(original); + const auto replaced = operators::replace_block_rows(action_only, {1}, replacement); + CHECK_FALSE(replaced.has_diagonal()); + CHECK_FALSE(replaced.has_assembly()); + const auto full = operators::replace_block_rows(operators::linearized_block(original), {1}, replacement); + CHECK(full.diagonal()[1] == -0.7); + CHECK_THROWS(operators::replace_block_rows(action_only, {-1}, replacement)); + CHECK_THROWS(operators::replace_block_rows(action_only, {3}, replacement)); + CHECK_THROWS(operators::replace_block_rows(action_only, {}, replacement)); + auto repeated = std::make_shared<::mfem::SparseMatrix>(2, 3); + repeated->Finalize(); + CHECK_THROWS(operators::replace_block_rows(action_only, {1, 1}, repeated)); + auto nonfinite = std::make_shared<::mfem::SparseMatrix>(1, 3); + nonfinite->Add(0, 0, std::numeric_limits::infinity()); + nonfinite->Finalize(); + CHECK_THROWS(operators::replace_block_rows(action_only, {1}, nonfinite)); +} diff --git a/tests/quadrature/meson.build b/tests/quadrature/meson.build new file mode 100644 index 00000000..afd6b8b8 --- /dev/null +++ b/tests/quadrature/meson.build @@ -0,0 +1,4 @@ +quadrature_test_sources = files( + 'policy_meanfield.cpp', + 'policy.cpp', +) diff --git a/tests/quadrature/policy.cpp b/tests/quadrature/policy.cpp new file mode 100644 index 00000000..ad251046 --- /dev/null +++ b/tests/quadrature/policy.cpp @@ -0,0 +1,199 @@ +// Ported from MeanField tests/quadrature/policy.cpp using the consolidated form/role API. + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "serif/backend/mfem/discretization/quadrature/mfem_resolver.hpp" + +#include "serif/tests/test_tags.hpp" + +namespace quadrature = serif::discretization::quadrature; +namespace forms = serif::discretization::forms; +namespace blocks = serif::discretization::blocks; +using VectorMass = forms::mass; +using DensityMass = forms::mass; + +TEST_CASE( + "Quadrature Policy Computes Base Orders", + tags::unit & tags::quadrature +) { + const quadrature::Policy policy{quadrature::RuleSet{}}; + quadrature::Query query{.form = forms::form_descriptor}; + query.trial_order = 3; + query.test_order = 3; + query.coefficient_order = 2; + query.geometry_weight_order = 1; + CHECK(policy.resolve(query).base_order == 9); + query.trial_order_reduction = 1; + query.test_order_reduction = 2; + CHECK(policy.resolve(query).base_order == 6); + query.trial_order_reduction = 5; + CHECK(policy.resolve(query).base_order == 4); + query.base_order = 7; + CHECK(policy.resolve(query).base_order == 7); +} + +TEST_CASE( + "Quadrature Policy Composes Global Role And Form Boosts", + tags::unit & tags::quadrature +) { + quadrature::RuleSet rules; + rules.set_fallback_control({.boost = 1}); + rules.set_role_control(quadrature::QuadratureRole::preconditioner, {.boost = 2}); + rules.set_form_control({.boost = 3}); + const quadrature::Policy policy{rules}; + const quadrature::Query query{ + .form = forms::form_descriptor, .role = quadrature::QuadratureRole::preconditioner, .base_order = 6 + }; + const auto result = policy.resolve(query); + CHECK(result.base_order == 6); + CHECK(result.boost == 6); + CHECK(result.order == 12); + CHECK_FALSE(result.used_fixed_order); + CHECK(policy.resolve({.form = forms::form_descriptor, .base_order = 4}).order == 5); +} + +TEST_CASE( + "Quadrature Fixed Orders Have Defined Precedence", + tags::unit & tags::quadrature +) { + quadrature::RuleSet rules; + const quadrature::Query query{.form = forms::form_descriptor, .base_order = 6}; + rules.set_fallback_control({.fixed_order = 9, .boost = 100}); + CHECK(quadrature::Policy{rules}.resolve(query).order == 9); + rules.set_role_control(quadrature::QuadratureRole::discretization, {.fixed_order = 11, .boost = 200}); + CHECK(quadrature::Policy{rules}.resolve(query).order == 11); + rules.set_form_control({.fixed_order = 13, .boost = 300}); + const auto result = quadrature::Policy{rules}.resolve(query); + CHECK(result.order == 13); + CHECK(result.used_fixed_order); + CHECK(result.boost == 0); +} + +TEST_CASE( + "Quadrature Policy Rejects Invalid Orders", + tags::unit & tags::quadrature +) { + const quadrature::Policy policy{quadrature::RuleSet{}}; + quadrature::Query query{.form = forms::form_descriptor}; + query.base_order = -1; + CHECK_THROWS_AS(policy.resolve(query), std::invalid_argument); + query.base_order.reset(); + query.trial_order = -1; + CHECK_THROWS_AS(policy.resolve(query), std::invalid_argument); + query.trial_order = 1; + query.test_order_reduction = -1; + CHECK_THROWS_AS(policy.resolve(query), std::invalid_argument); + quadrature::RuleSet rules; + rules.set_form_control({.fixed_order = -1}); + query.test_order_reduction = 0; + CHECK_THROWS_AS(quadrature::Policy{rules}.resolve(query), std::invalid_argument); + rules.set_form_control({.boost = -2}); + CHECK_THROWS_AS(quadrature::Policy{rules}.resolve(query), std::invalid_argument); +} + +TEST_CASE( + "MFEM Rule Factory Includes Geometry And Dynamic Orders", + tags::unit & tags::quadrature +) { + using namespace quadrature::backend::mfem; + const MFEMResolver resolver{quadrature::Policy{quadrature::RuleSet{}}}; + const std::array dynamic_orders{2, 1}; + MFEMQuadratureContext context{ + .geometry = mfem::Geometry::CUBE, .geometry_weight_order = 1, .dynamic_orders = dynamic_orders + }; + const auto rule = resolver.resolve(context); + CHECK(rule.resolution.base_order == 10); + CHECK(rule.integration_rule == &mfem::IntRules.Get(mfem::Geometry::CUBE, 10)); + context.geometry_weight_order = -1; + CHECK_THROWS_AS(resolver.resolve(context), std::invalid_argument); + context.geometry_weight_order = 0; + const std::array invalid_orders{-1}; + context.dynamic_orders = invalid_orders; + CHECK_THROWS_AS(resolver.resolve(context), std::invalid_argument); +} + +TEST_CASE( + "MFEM Quadrature Rule Integrates Tensor Polynomial Exactly", + tags::unit & tags::quadrature +) { + const quadrature::backend::mfem::MFEMResolver resolver{quadrature::Policy{quadrature::RuleSet{}}}; + const auto rule = resolver.resolve({.geometry = mfem::Geometry::CUBE}); + double integral = 0.0; + for (int i = 0; i < rule.integration_rule->GetNPoints(); ++i) { + const auto& point = rule.integration_rule->IntPoint(i); + integral += point.weight * std::pow(point.x, 4) * std::pow(point.y, 2) * point.z; + } + CHECK_THAT(integral, Catch::Matchers::WithinAbs(1.0 / 30.0, 1.0e-14)); +} + +TEST_CASE( + "Policy Controlled Vector Mass Assembly Matches Overintegrated Reference", + tags::unit & tags::quadrature +) { + auto mesh = mfem::Mesh::MakeCartesian3D(1, 1, 1, mfem::Element::HEXAHEDRON, 1.2, 0.9, 1.4); + mfem::H1_FECollection collection(3, 3); + mfem::FiniteElementSpace space(&mesh, &collection, 3); + auto& transformation = *mesh.GetElementTransformation(0); + const auto& element = *space.GetFE(0); + const quadrature::backend::mfem::MFEMResolver resolver{quadrature::Policy{quadrature::RuleSet{}}}; + mfem::VectorMassIntegrator controlled; + const auto resolution = resolver.configure( + controlled, {.geometry = element.GetGeomType(), .geometry_weight_order = transformation.OrderW()} + ); + mfem::VectorMassIntegrator reference; + reference.SetIntRule(&mfem::IntRules.Get(element.GetGeomType(), resolution.order + 8)); + mfem::DenseMatrix actual, expected; + controlled.AssembleElementMatrix(element, transformation, actual); + reference.AssembleElementMatrix(element, transformation, expected); + actual -= expected; + CHECK(actual.FNorm() / expected.FNorm() < 1.0e-12); +} + +TEST_CASE( + "Integration Rule Adapter Supports Each Advertised Signature", + tags::unit & tags::quadrature +) { + struct ReferenceIntegrator { + const mfem::IntegrationRule* selected = nullptr; + void SetIntegrationRule(const mfem::IntegrationRule& rule) { + selected = &rule; + } + } reference; + struct PointerIntegrator { + const mfem::IntegrationRule* selected = nullptr; + void SetIntRule(const mfem::IntegrationRule* rule) { + selected = rule; + } + } pointer; + struct IntRuleReferenceIntegrator { + const mfem::IntegrationRule* selected = nullptr; + void SetIntRule(const mfem::IntegrationRule& rule) { + selected = &rule; + } + } int_reference; + + const quadrature::backend::mfem::MFEMResolver resolver{quadrature::Policy{quadrature::RuleSet{}}}; + const quadrature::backend::mfem::MFEMQuadratureContext context{.geometry = mfem::Geometry::CUBE}; + resolver.configure(reference, context); + resolver.configure(pointer, context); + resolver.configure(int_reference, context); + CHECK(reference.selected == &mfem::IntRules.Get(mfem::Geometry::CUBE, 6)); + CHECK(pointer.selected == reference.selected); + CHECK(int_reference.selected == reference.selected); +} + +TEST_CASE( + "Discrete Functional Space Can Be Formatted", + tags::unit & tags::quadrature +) { + const blocks::displacement::vector::FunctionalSpace space; + CHECK(std::format("{}", space).find("rank=Vector") != std::string::npos); +} diff --git a/tests/quadrature/policy_meanfield.cpp b/tests/quadrature/policy_meanfield.cpp new file mode 100644 index 00000000..5a6a0597 --- /dev/null +++ b/tests/quadrature/policy_meanfield.cpp @@ -0,0 +1,124 @@ +// Preserve MeanField's independent role/form routing and native RT polynomial-order regressions. +#include +#include +#include + +#include + +#include "mfem/fem/bilininteg.hpp" +#include "mfem/fem/fespace.hpp" +#include "serif/tests/equations/test_equations.hpp" +#include "serif/backend/mfem/discretization/quadrature/mfem_resolver.hpp" + +namespace +{ + namespace quadrature = serif::discretization::quadrature; + namespace forms = serif::discretization::forms; + namespace blocks = serif::discretization::blocks; + using Mass = forms::mass; + using Divergence = forms::divergence; + using PressureIntegral = forms::source<::serif::tests::equations::Enthalpy, blocks::barotropic_constant::mass_normalization>; + using PressureForce = forms::divergence; + using EquilibriumConstant = forms::source; + + /** @brief The density projection load has one test operand; its source order is supplied dynamically. */ + struct DensityProjectionSource final : forms::form + { + static constexpr std::string_view name = "density projection source"; + using trial_type = blocks::density::mass; + using test_type = blocks::density::mass; + using operand_types = serif::utils::types::TypeList>; + }; +} + +TEST_CASE("MeanField typed quadrature queries preserve source orders and physical metadata", "[unit][quadrature][meanfield]") +{ + namespace domain = serif::discretization::domain; + const quadrature::Policy policy{quadrature::RuleSet{}}; + const quadrature::backend::mfem::MFEMResolver resolver{policy}; + constexpr int density_order = quadrature::backend::mfem::resolve_operand_order>(); + const auto density = quadrature::make_query(quadrature::QuadratureRole::projection, domain::StellarDomains{}, quadrature::MappingKind::general, 0, density_order, 4, 3); + CHECK(density.form == forms::form_descriptor); + CHECK(density.role == quadrature::QuadratureRole::projection); + CHECK(std::ranges::equal(density.domain.domains(), std::array{domain::CoreDomain::name, domain::EnvelopeDomain::name})); + CHECK(density.mapping == quadrature::MappingKind::general); + CHECK(density.geometry_weight_order == 3); + CHECK(density.trial_order == 0); + CHECK(density.test_order == 3); + CHECK(density.coefficient_order == 4); + CHECK(policy.resolve(density).base_order == 10); + const std::array dynamic_orders{4}; + const auto density_rule = resolver.resolve({.role = density.role, .geometry = mfem::Geometry::CUBE, .geometry_weight_order = density.geometry_weight_order, .dynamic_orders = dynamic_orders, .domain = density.domain, .mapping = density.mapping}); + CHECK(density_rule.resolution.base_order == 10); + CHECK(density_rule.integration_rule == &mfem::IntRules.Get(mfem::Geometry::CUBE, 10)); + + constexpr int constant_order = quadrature::backend::mfem::resolve_operand_order>(); + constexpr int enthalpy_order = quadrature::backend::mfem::resolve_operand_order>(); + const auto equilibrium = quadrature::make_query(quadrature::QuadratureRole::discretization, domain::StellarDomains{}, quadrature::MappingKind::none, constant_order, enthalpy_order, 0, 2); + CHECK(equilibrium.form == forms::form_descriptor); + CHECK(equilibrium.role == quadrature::QuadratureRole::discretization); + CHECK(std::ranges::equal(equilibrium.domain.domains(), std::array{domain::CoreDomain::name, domain::EnvelopeDomain::name})); + CHECK(equilibrium.mapping == quadrature::MappingKind::none); + CHECK(equilibrium.geometry_weight_order == 2); + CHECK(equilibrium.trial_order == 0); + CHECK(equilibrium.test_order == 3); + CHECK(policy.resolve(equilibrium).base_order == 5); + const auto equilibrium_rule = resolver.resolve({.geometry = mfem::Geometry::CUBE, .geometry_weight_order = 2, .domain = equilibrium.domain}); + CHECK(equilibrium_rule.resolution.base_order == 5); + CHECK(equilibrium_rule.integration_rule == &mfem::IntRules.Get(mfem::Geometry::CUBE, 5)); + + CHECK_THROWS_AS(resolver.resolve({.geometry = mfem::Geometry::CUBE, .geometry_weight_order = -1}), std::invalid_argument); + const std::array invalid_orders{-1}; + CHECK_THROWS_AS(resolver.resolve({.geometry = mfem::Geometry::CUBE, .dynamic_orders = invalid_orders}), std::invalid_argument); +} + +TEST_CASE("MeanField pressure integral and pressure force retain independent quadrature controls", "[unit][quadrature][meanfield]") +{ + quadrature::RuleSet rules; + rules.set_form_control({.boost = 3}); + rules.set_form_control({.boost = 5}); + const quadrature::backend::mfem::MFEMResolver resolver{quadrature::Policy{rules}}; + const std::array dynamic_orders{9}; + const auto integral = resolver.resolve({.role = quadrature::QuadratureRole::diagnostic, .geometry = mfem::Geometry::CUBE, .geometry_weight_order = 2, .dynamic_orders = dynamic_orders, .domain = serif::discretization::domain::StellarDomains{}, .mapping = quadrature::MappingKind::general}); + const auto force = resolver.resolve({.geometry = mfem::Geometry::CUBE, .geometry_weight_order = 2, .dynamic_orders = dynamic_orders, .domain = serif::discretization::domain::StellarDomains{}, .mapping = quadrature::MappingKind::general}); + CHECK(integral.resolution.base_order == 14); + CHECK(integral.resolution.boost == 3); + CHECK(integral.resolution.order == 17); + CHECK(force.resolution.base_order == 16); + CHECK(force.resolution.boost == 5); + CHECK(force.resolution.order == 21); + CHECK(integral.integration_rule == &mfem::IntRules.Get(mfem::Geometry::CUBE, 17)); + CHECK(force.integration_rule == &mfem::IntRules.Get(mfem::Geometry::CUBE, 21)); +} + +TEST_CASE("MeanField quadrature routing generalizes to independent typed forms and every numerical role", "[unit][quadrature][meanfield]") +{ + const std::array descriptors{forms::form_descriptor, forms::form_descriptor, forms::form_descriptor, forms::form_descriptor, forms::form_descriptor>, forms::form_descriptor>, forms::form_descriptor>, forms::form_descriptor>}; + const std::array roles{quadrature::QuadratureRole::discretization, quadrature::QuadratureRole::preconditioner, quadrature::QuadratureRole::diagnostic, quadrature::QuadratureRole::projection}; + quadrature::RuleSet rules; + rules.set_fallback_control({.boost = 3}); + for (std::size_t form = 0; form < descriptors.size(); ++form) + { + rules.set_form_control(descriptors[form], {.boost = static_cast(form + 1)}); + } + for (std::size_t role = 0; role < roles.size(); ++role) + { + rules.set_role_control(roles[role], {.boost = static_cast(2 * role)}); + } + const quadrature::Policy policy{rules}; + for (std::size_t form = 0; form < descriptors.size(); ++form) + { + for (std::size_t role = 0; role < roles.size(); ++role) + { + const auto result = policy.resolve({.form = descriptors[form], .role = roles[role], .base_order = 20}); + CHECK(result.base_order == 20); + CHECK(result.boost == static_cast(3 + form + 1 + 2 * role)); + CHECK(result.order == 20 + result.boost); + CHECK_FALSE(result.used_fixed_order); + } + } + // Replacing an existing rule must not leave an earlier rule shadowing the new physical choice. + rules.set_form_control({.fixed_order = 31}); + CHECK(quadrature::Policy{rules}.resolve({.form = forms::form_descriptor, .base_order = 20}).order == 31); + CHECK(quadrature::Policy{rules}.resolve({.form = forms::form_descriptor, .base_order = 20}).order == 26); +} diff --git a/tests/sandbox/complete_sandbox.cpp b/tests/sandbox/complete_sandbox.cpp new file mode 100644 index 00000000..60b76551 --- /dev/null +++ b/tests/sandbox/complete_sandbox.cpp @@ -0,0 +1,654 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "mfem/fem/intrules.hpp" +#include "serif/runtime.hpp" +#include "serif/backend/mfem/serialize/discretization.hpp" +#include "serif/backend/mfem/serialize/top.hpp" +#include "serif/utils/serialize/serialize.hpp" +#include "serif/equations/barotropic_density.hpp" +#include "serif/model/finite_elements.hpp" +#include "serif/solver/equilibrium.hpp" +#include "serif/solver/preconditioner/default_preconditioners.hpp" +#include "serif/backend/mfem/solver/preconditioner/inverse/sparse_direct.hpp" +#include "serif/solver/seed/state.hpp" +#include "serif/utils/laneemden/solution.hpp" +#include "polytropic_model.hpp" +#include "stellar_rayfem.hpp" +#include "serif/tests/solver/virial.hpp" +#include "libRayfem/init.hpp" + +namespace { + // Defaults: the n=2, J=0.1 star (Omega/Omega_K ~0.70, flattening ~0.22), the fastest rotator in the ESTER/TOP grid + // that converges from the spherical seed. L=12 resolves its surface: all 33 reference modes match within 0.55 PLATO + // sigma (L=8 leaves 3.65 sigma). --angular-momentum 0.05 --surface-degree 8 gives the original benchmark star. + struct Arguments { + bool visualize = true; + bool visualize_requested = false; + double angular_momentum = 0.1; + double polytropic_index = 2.0; + std::optional polytropic_constant; + bool surface_power_density = true; + int surface_degree = 12; + int mesh_refinement = 0; + int surface_refinement = 1; + int equatorial_refinement = 1; + double equatorial_band_degrees = 10.0; + int radial_surface_refinement = 3; + /// Compactified infinity surface, in stellar radii. The vacuum map folds once the equatorial bulge reaches + /// 1/(F - 1) stellar radii, so the fastest rotators need F below the library default of 4. + double infinity_radius = 4.0; + /// Converged archive (profile_solve --save-state) with this n, K and discretization to start Newton from. + std::filesystem::path seed_state; + /// Fixed volume quadrature order; empty uses SERiF's automatic rule (~35 on this mesh). Order 11 matches + /// the automatic rule's ESTER/TOP agreement for the benchmark star at ~1/14 of the memory. + std::optional quadrature_order = 11; + /// Potential-block inverse: sparse LU or three fixed BoomerAMG cycles (SERiF's default recipe). Empty (auto) + /// uses LU when this build has a sparse_direct backend that works on this machine. + std::optional potential_inverse_lu; + std::array rotation_axis{0.0, 0.0, 1.0}; + /// Tidally locked companion on +x: its mass (G = M = 1) and separation; zero mass means a single star. + double companion_mass = 0.0; + double separation = 0.0; + /// The star spins about its own axis at the rate J sets and feels only the companion's tide (asynchronous Roche). + bool asynchronous = false; + /// Continuation: solve first at separation_start, then step geometrically to separation, seeding each solve. + double separation_start = 0.0; + int separation_steps = 0; + /// Converged binary surface radius on a (theta, phi) grid, for plotting. + std::filesystem::path surface_table; + bool mesh_only = false; + int viewer_port = 5555; + std::string viewer; + std::filesystem::path top_export; + serif::utils::serialize::TopExportOptions top_options; + }; + + Arguments parse_arguments(int argc, char** argv) { + Arguments args; + for (int i = 1; i < argc; ++i) { + const std::string_view option(argv[i]); + if (option == "--visualize") { args.visualize = args.visualize_requested = true; continue; } + if (option == "--no-visualize") { args.visualize = false; continue; } + if (option == "--surface-power-density") { args.surface_power_density = true; continue; } + if (option == "--ordinary-density") { args.surface_power_density = false; continue; } + if (option == "--mesh-only") { args.mesh_only = true; continue; } + if (option == "--asynchronous") { args.asynchronous = true; continue; } + if (option == "--rotation-axis") { + for (double& component : args.rotation_axis) { + if (++i == argc) throw std::invalid_argument("--rotation-axis requires three components: X Y Z"); + const std::string value(argv[i]); + std::size_t consumed = 0; + component = std::stod(value, &consumed); + if (consumed != value.size() || !std::isfinite(component)) + throw std::invalid_argument("Rotation-axis components must be finite numbers"); + } + continue; + } + if (++i == argc) throw std::invalid_argument("Missing value for " + std::string(option)); + const std::string value(argv[i]); + std::size_t consumed = 0; + if (option == "--export-top") { args.top_export = value; continue; } + if (option == "--surface-table") { args.surface_table = value; continue; } + if (option == "--seed-state") { args.seed_state = value; continue; } + if (option == "--quadrature-order" && value == "auto") { args.quadrature_order.reset(); continue; } + if (option == "--potential-inverse") { + if (value != "lu" && value != "amg" && value != "auto") throw std::invalid_argument("--potential-inverse takes auto, lu or amg"); + if (value == "auto") args.potential_inverse_lu.reset(); + else args.potential_inverse_lu = value == "lu"; + continue; + } + if (option == "--viewer") { args.viewer = value; args.visualize = args.visualize_requested = true; continue; } + if (option == "--angular-momentum") args.angular_momentum = std::stod(value, &consumed); + else if (option == "--polytropic-index") args.polytropic_index = std::stod(value, &consumed); + else if (option == "--K") args.polytropic_constant = std::stod(value, &consumed); + else if (option == "--surface-degree") args.surface_degree = std::stoi(value, &consumed); + else if (option == "--mesh-refinement") args.mesh_refinement = std::stoi(value, &consumed); + else if (option == "--surface-refinement") args.surface_refinement = std::stoi(value, &consumed); + else if (option == "--equatorial-refinement") args.equatorial_refinement = std::stoi(value, &consumed); + else if (option == "--equatorial-band-degrees") args.equatorial_band_degrees = std::stod(value, &consumed); + else if (option == "--infinity-radius") args.infinity_radius = std::stod(value, &consumed); + else if (option == "--radial-surface-refinement") args.radial_surface_refinement = std::stoi(value, &consumed); + else if (option == "--quadrature-order") args.quadrature_order = std::stoi(value, &consumed); + else if (option == "--top-radial-points") args.top_options.radial_points = std::stoi(value, &consumed); + else if (option == "--top-degree") args.top_options.maximum_degree = std::stoi(value, &consumed); + else if (option == "--top-angular-points") args.top_options.angular_points = std::stoi(value, &consumed); + else if (option == "--top-azimuthal-points") args.top_options.azimuthal_points = std::stoi(value, &consumed); + else if (option == "--viewer-port") args.viewer_port = std::stoi(value, &consumed); + else if (option == "--companion-mass") args.companion_mass = std::stod(value, &consumed); + else if (option == "--separation") args.separation = std::stod(value, &consumed); + else if (option == "--separation-start") args.separation_start = std::stod(value, &consumed); + else if (option == "--separation-steps") args.separation_steps = std::stoi(value, &consumed); + else throw std::invalid_argument("Unknown option: " + std::string(option)); + if (consumed != value.size()) throw std::invalid_argument("Invalid value for " + std::string(option)); + } + if (!std::isfinite(args.polytropic_index) || args.polytropic_index < 1.0 || args.polytropic_index >= 3.0) + throw std::invalid_argument("Use a polytropic index in [1,3) for this vacuum benchmark"); + if (!std::isfinite(args.angular_momentum) || args.surface_degree < 0 || + args.viewer_port < 1 || args.viewer_port > 65534) + throw std::invalid_argument("Use finite angular momentum, nonnegative surface degree and viewer port 1..65534"); + if (args.polytropic_constant && (!std::isfinite(*args.polytropic_constant) || *args.polytropic_constant <= 0.0)) + throw std::invalid_argument("K must be finite and positive"); + if (args.quadrature_order && *args.quadrature_order < 0) + throw std::invalid_argument("The volume quadrature order must be nonnegative, or auto"); + if (args.mesh_refinement < 0 || args.surface_refinement < 0 || args.equatorial_refinement < 0 || args.radial_surface_refinement < 0) + throw std::invalid_argument("Mesh, surface, equatorial and radial surface refinement counts must be nonnegative"); + if (!std::isfinite(args.equatorial_band_degrees) || args.equatorial_band_degrees <= 0.0 || args.equatorial_band_degrees > 35.0) + throw std::invalid_argument("The equatorial band half-angle must satisfy 0 < degrees <= 35"); + if (!std::isfinite(args.infinity_radius) || args.infinity_radius <= 1.0) + throw std::invalid_argument("The infinity radius must be a finite number of stellar radii above 1"); + if (std::ranges::all_of(args.rotation_axis, [](double x) { return x == 0.0; })) + throw std::invalid_argument("The rotation axis must be nonzero"); + if (!std::isfinite(args.companion_mass) || !std::isfinite(args.separation) || args.companion_mass < 0.0 || + (args.companion_mass > 0.0) != (args.separation > 0.0)) + throw std::invalid_argument("--companion-mass and --separation go together: a positive mass at a separation of several stellar radii"); + if (args.companion_mass > 0.0 && (args.rotation_axis != std::array{0.0, 0.0, 1.0} || !args.top_export.empty() || !args.seed_state.empty())) + throw std::invalid_argument("A companion orbits about the default z axis; TOP export and seed states are single-star only"); + if (args.separation_steps < 0 || (args.separation_steps > 0 && !(args.companion_mass > 0.0 && std::isfinite(args.separation_start) && args.separation_start > 0.0)) || + (!args.surface_table.empty() && args.companion_mass <= 0.0)) + throw std::invalid_argument("--separation-steps needs a companion and a positive --separation-start; --surface-table needs a companion"); + if (args.asynchronous && args.companion_mass <= 0.0) + throw std::invalid_argument("--asynchronous needs a companion"); + if (args.mesh_only && args.visualize_requested) + throw std::invalid_argument("--mesh-only reports mesh statistics without solving; omit --visualize"); + if (args.mesh_only) args.visualize = false; + return args; + } + + /** + * @brief K that gives the nonrotating polytrope of index n a radius of 1 when G = M = 1. + * @note Lane-Emden scaling: R = xi_1 alpha and M = 4 pi alpha^3 rho_c (-xi_1^2 theta'(xi_1)), with + * alpha^2 = (n+1) K rho_c^(1/n-1) / (4 pi G). For n = 1 this is 2/pi. + */ + double unit_radius_polytropic_constant(const double n) { + // K is a model constant, so integrate well below the seed solver's default step. + const auto lane_emden = serif::utils::laneemden::solve(n, {.step = 1.0e-4, .maximum_steps = 1000000}); + if (!lane_emden || !lane_emden->surface) + throw std::runtime_error("Cannot find the Lane-Emden surface for n = " + std::to_string(n)); + const double xi = lane_emden->surface->xi; + const double alpha = 1.0 / xi; + const double central_density = 1.0 / (4.0 * std::numbers::pi * alpha * alpha * alpha * (-xi * xi * lane_emden->surface->derivative)); + return 4.0 * std::numbers::pi * alpha * alpha * std::pow(central_density, 1.0 - 1.0 / n) / (n + 1.0); + } + + /** @brief Resolve --potential-inverse auto against the compiled sparse_direct backend. */ + bool use_lu(const Arguments& args) { + return args.potential_inverse_lu.value_or(serif::solver::preconditioner::backend::mfem::sparse_direct_available()); + } + + void print_options(const Arguments& args, const double K) { + std::println("complete_sandbox options (G = M = 1; K gives a nonrotating radius of 1 unless --K is set):"); + std::println(" polytropic index n {}", args.polytropic_index); + std::println(" polytropic constant K {:.17g}{}", K, args.polytropic_constant ? " (--K)" : " (computed from Lane-Emden)"); + if (args.companion_mass > 0.0) + std::println(" companion mass {} at separation {} on +x, {}{}", args.companion_mass, args.separation, + args.asynchronous ? "tide only (asynchronous)" : "tidally locked (Omega from force balance)", + args.separation_steps ? std::format("; continued from {} in {} steps", args.separation_start, args.separation_steps) : std::string{}); + if (args.companion_mass <= 0.0 || args.asynchronous) + std::println(" angular momentum J {}", args.angular_momentum); + std::println(" rotation axis ({}, {}, {})", args.rotation_axis[0], args.rotation_axis[1], args.rotation_axis[2]); + std::println(" surface harmonic degree {}", args.surface_degree); + std::println(" density representation {}", args.surface_power_density ? "SurfacePower t^n on surface-adjacent cells" : "ordinary"); + std::println(" mesh refinement {}", args.mesh_refinement); + std::println(" surface refinement {}", args.surface_refinement); + std::println(" equatorial refinement {} (band half-angle {} degrees)", args.equatorial_refinement, args.equatorial_band_degrees); + std::println(" radial surface refinement {}", args.radial_surface_refinement); + std::println(" volume quadrature {}", args.quadrature_order + ? std::format("fixed order {} ({} points per hexahedron)", *args.quadrature_order, + ::mfem::IntRules.Get(::mfem::Geometry::CUBE, *args.quadrature_order).GetNPoints()) + : std::string{"automatic (SERiF's per-form rule)"}); + namespace inverse_backend = serif::solver::preconditioner::backend::mfem; + std::println(" potential inverse {}{}", use_lu(args) ? std::format("sparse LU ({})", inverse_backend::sparse_direct_backend()) : std::string{"3 fixed BoomerAMG cycles"}, + args.potential_inverse_lu ? "" : " (auto)"); + std::println(" visualization {}", args.visualize ? std::format("RayFEM on port {}", args.viewer_port) : "off"); + std::println(" TOP export {}", args.top_export.empty() ? "off" : std::format("{} ({} radial, degree {}, {} angular, {} azimuthal)", + args.top_export.string(), args.top_options.radial_points, args.top_options.maximum_degree, + args.top_options.angular_points, args.top_options.azimuthal_points)); + std::fflush(stdout); + } +} + +/** + * @brief One RayFEM connection shared by every continuation step, so all their frames form one sequence. + * @note Every step uses the same reference mesh, so it is sent once and frame indices keep increasing across steps. + */ +struct LiveStream { + RayFEM::Client client; + std::uint32_t next_frame = 0; + bool mesh_sent = false; + bool enabled = true; + bool continuation = false; + int step = 0; + + /** @brief Start or reuse the viewer on the requested port. */ + static std::unique_ptr open(const Arguments& args) { + RayFEM::WindowParams window{.port = args.viewer_port, .executable = args.viewer}; + #ifdef SERIF_RAYFEM_EXECUTABLE + const char* viewer_override = std::getenv("RAYFEM_EXECUTABLE"); + if (window.executable.empty() && (!viewer_override || !*viewer_override)) + window.executable = SERIF_RAYFEM_EXECUTABLE; + #endif + const auto viewer = RayFEM::init(window); + if (!viewer) throw std::runtime_error("Cannot initialize RayFEM: " + viewer.error); + if (viewer.status == RayFEM::InitStatus::Started) + std::println("Started RayFEM: {}", viewer.executable); + else + std::println("Reusing RayFEM on port {}", args.viewer_port); + auto stream = std::unique_ptr(new LiveStream{RayFEM::Client("127.0.0.1", args.viewer_port)}); + // Frames go out from a background thread: a send returns once the frame is copied, + // so Newton never waits for the viewer or the network. + stream->client.startQueue(); + return stream; + } +}; + +/** + * @brief Build, solve and report one star; Binary adds the tidally locked companion and changes the model's type. + * @note seed (same discretization) replaces the spherical start; converged receives the solved coordinates. + * With a stream, accepted iterates are sent to RayFEM, tagged with the stream's continuation step and the separation. + */ +template +int solve_star(const Arguments& args, const double K, std::vector* converged, const std::vector* seed, + LiveStream* stream) { + namespace model = serif::model; + namespace equations = serif::equations; + namespace dimensions = serif::dimensions; + namespace solver = serif::solver; + namespace normalization = solver::normalization; + + // 1. Describe the star with M=G=1, the requested n and J, and K (by default a nonrotating radius of 1). + // Deferred equations read the same owned prescriptions used by continuation. + // A tidally locked companion sits on +x: rotation is about the barycenter, and Omega's row fixes the star's center of mass. + const double companion_mass = args.companion_mass, separation = args.separation; + const auto stellar_equations = [=](const auto& physics) { + const auto eos = model::borrow_eos(physics.equation_of_state()); + const auto& rotation = physics.template specification(); + const double mass = physics.template specification().target().value(); + std::optional companion; + auto center = rotation.center(); // the star stays at the specification's center; a binary rotates about the barycenter + if (companion_mass > 0.0) companion.emplace(companion_mass, equations::RigidRotation::Vector{separation, 0.0, 0.0}, !Binary); + if constexpr (Binary) center[0] += companion_mass * separation / (mass + companion_mass); + auto omega_row = [&] { + if constexpr (Binary) return equations::BarycenterConstraint<0, serif::discretization::blocks::rotation::angular_velocity>{mass}; + else return equations::AngularMomentumConstraint{rotation.target().value(), rotation.axis(), rotation.center()}; + }(); + return model::EquilibriumEquations{ + .interior_equations = equations::EquationRegistry{ + equations::BarotropicDensityBalance{eos, dimensions::PressureValue{0.0}, rotation.axis(), center, companion}, + equations::PrimalPoissonBalance{4.0 * std::numbers::pi}, + equations::MassNormalization{mass}, + std::move(omega_row) + }, + .shape_force = equations::SurfacePotentialBalance{rotation.axis(), center, companion}, + .surface_condition = serif::geometry::surface::Isobaric{dimensions::PressureValue{0.0}} + }; + }; + + const double n = args.polytropic_index; + const auto stellar_model = model::make_stellar_model(serif::eos::models::Polytrope{n, K}, stellar_equations, + model::FixedTotalMass{dimensions::MassValue{1.0}}, model::FixedAngularMomentum{model::FixedAngularMomentum::Parameters{ + .angular_momentum = dimensions::AngularMomentumValue{Binary ? 0.0 : args.angular_momentum}, .axis = args.rotation_axis}}); + const auto& rotation = stellar_model.template specification(); + + // 2. Obtain a radial initial guess while preserving the EOS and the target mass. + const auto radial = solver::seed::make_polytropic_seed(stellar_model); + if (!radial) { + std::println(stderr, "Cannot prepare the radial star: {}", radial.error()); + return 1; + } + + // 3. Derive field spaces from their term types and own all numerical resources. + // Surface harmonics remain an explicit approximation choice. + namespace deformation = serif::geometry::deformation; + namespace reconstruction = serif::discretization::reconstruction; + using Density = serif::discretization::blocks::density::mass; + const deformation::DomainDeformation moving_boundary{ + .surface = {.maximum_degree = args.surface_degree}, .stellar_interior = {.radial_power = 2.0}, .vacuum = {}}; + const auto finite_elements = model::make_stellar_discretization(stellar_model, + {.radius = radial->scales.radius, .infinity_radius_fraction = args.infinity_radius, .refinement = args.mesh_refinement, + .surface_refinement = {.levels = args.surface_refinement, .equatorial_levels = args.equatorial_refinement, + .equatorial_half_angle_degrees = args.equatorial_band_degrees, .radial_levels = args.radial_surface_refinement, + .axis = rotation.axis()}}, moving_boundary, + reconstruction::SurfacePower{.density_exponent = args.surface_power_density ? n : 1.0}); + const auto& mesh = *finite_elements.owner->resources.meshes.front(); + using Schema = serif::discretization::domain::schema::CoreEnvelopeVacuumDomainSchema; + const int surface_attribute = Schema::boundary_id(); + int surface_faces = 0; + for (int boundary = 0; boundary < mesh.GetNBE(); ++boundary) + surface_faces += mesh.GetBdrAttribute(boundary) == surface_attribute; + std::println("Mesh: {} elements, {} stellar surface faces; rotation axis = ({}, {}, {})", + mesh.GetNE(), surface_faces, rotation.axis()[0], rotation.axis()[1], rotation.axis()[2]); + if (args.mesh_only) return 0; + auto initial_state = solver::seed::make_polytropic_state(stellar_model, finite_elements, *radial); + if (!initial_state) { + std::println(stderr, "Cannot project the radial star: {}", initial_state.error()); + return 1; + } + // Start the orbit at Kepler's rate for two point masses. + const double kepler_omega = std::sqrt((1.0 + companion_mass) / (separation * separation * separation)); + if constexpr (Binary) initial_state->template get()[0] = kepler_omega; + if (seed) std::ranges::copy(*seed, initial_state->values().begin()); + + if (!args.seed_state.empty()) { + // Continuation: replace the spherical seed with a converged star of the same n, K and discretization. + namespace archive = serif::utils::serialize; + using State = std::remove_cvref_t; + const auto saved = archive::Reader::open(args.seed_state); + if (!saved) throw std::runtime_error(saved.error().message); + const auto loaded = saved->read("state"); + const auto old_n = saved->read("n"), old_K = saved->read("K"), old_J = saved->read("J"); + const auto converged = saved->read("converged"); + const auto geometry = saved->node("finite_elements"); + if (!loaded || !old_n || !old_K || !old_J || !converged || !*converged || *old_n != n || *old_K != K || !geometry || + *geometry != serif::backend::mfem::serialize::save_discretization(finite_elements) || loaded->layout() != initial_state->layout()) + throw std::runtime_error("--seed-state must be a converged archive with the same n, K, discretization and layout"); + std::ranges::copy(loaded->values(), initial_state->values().begin()); + std::println("Seeded from {} (converged at J = {})", args.seed_state.string(), *old_J); + } + + // Deduced here: clang does not deduce these class templates inside the generic lambda below. + const auto normalization_options = normalization::NormalizationOptions{ + .scheme = normalization::LaneEmdenApproximation{.n = n, .central_density = radial->scales.central_density}}; + // 4-6. Solve and report with the chosen potential inverse; each choice is its own preconditioner type. + const auto solve_and_report = [&](const auto potential_inverse) -> int { + // 4. Freeze sensible numerical scales independently of the physical prescriptions. + auto configuration = model::make_discretization(model::DiscretizationOptions{ + .model = stellar_model, + .finite_elements = finite_elements, + .normalization = normalization::make_normalization(normalization_options), + .preconditioner = solver::preconditioner::make_primal_preconditioner(initial_state->layout(), potential_inverse) + }); + + // 5. Select numerical work limits and observe accepted stars without managing evaluators or caches. + const auto observe = [](const solver::SolveEvent& event, const auto&, const auto&) { + if (event.kind == solver::EventKind::initial) { + std::println("Initial normalized residual = {}", event.metric->residual_norm); + } else if (event.kind == solver::EventKind::accepted) { + std::println("Newton step {}: normalized residual = {}, accepted step = {}", event.iteration + 1, event.metric->residual_norm, event.step); + } + std::fflush(stdout); + }; + const solver::SolverOptions options{ + .method = {.relative_tolerance = 1.0e-8, .absolute_tolerance = 1.0e-10, .maximum_iterations = 30, + .linear_solve = {.relative_tolerance = 0.03, .maximum_iterations = 200}}, + .linear_solver = {.restart_length = 160} + }; + solver::VolumeOptions volume_options{}; + if (args.quadrature_order) { + namespace quadrature = serif::discretization::quadrature; + quadrature::RuleSet rules; + rules.set_role_control(quadrature::QuadratureRole::discretization, {.fixed_order = *args.quadrature_order}); + volume_options.quadrature_policy = quadrature::Policy{std::move(rules)}; + } + const auto solution = [&] { + if (!stream) + return solver::solve_equilibrium(std::move(configuration), std::move(*initial_state), options, observe, volume_options); + + auto problem = solver::backend::mfem::make_observed_stellar_problem(std::move(configuration), *initial_state, + options.strategies, volume_options, serif::utils::dependencies::DependencyContext<>{}, + serif::utils::dependencies::NoGraphObservers{}); + serif::visualization::backend::mfem::StellarFrame frame(*problem.binding().discretization); + auto& client = stream->client; + constexpr std::uint64_t mesh_id = 1; + // Frames are Newton iterates of one equilibrium, not moments in time. + RayFEM::FrameSchema schema; + schema.values = { + {"Newton step", RayFEM::ValueType::Integer, true, "", "Accepted Newton steps so far; 0 is the initial state"}, + {"residual", RayFEM::ValueType::Real, false, "", "Normalized nonlinear residual"}, + {"step length", RayFEM::ValueType::Real, false, "", "Accepted damped Newton step length (1 is a full step)"}, + {"Omega", RayFEM::ValueType::Real, false, "", "Angular velocity of the rigid rotation"}, + {"virial ratio", RayFEM::ValueType::Real, false, "", "(2T + 3 integral of P)/|W|; 1 in equilibrium"}, + {"virial error", RayFEM::ValueType::Real, false, "", "(2T + W + 3 integral of P)/|W| at the finest quadrature; 0 in equilibrium"}, + {"mass", RayFEM::ValueType::Real, false, "M", "Integrated mass"}, + {"virial", RayFEM::ValueType::Text, false, "", "Why the vacuum virial could not be measured"}}; + schema.tags = {{"initial", "The seeded state before any Newton step"}, + {"resumed", "A state resumed from a checkpoint"}, + {"accepted", "An accepted Newton iterate"}, + {"converged", "Meets the solver's residual tolerances"}}; + schema.has_time = false; + schema.primary = "Newton step"; + if (stream->continuation) { + // Newton steps restart in each continuation step, so frames are ordered by step. + schema.values.insert(schema.values.begin(), { + {"continuation step", RayFEM::ValueType::Integer, true, "", "Continuation step; 0 is the widest separation"}, + {"separation", RayFEM::ValueType::Real, true, "R0", "Orbital separation of the companion"}}); + schema.primary = "continuation step"; + } + // RayFEM orients the view and measures flattening about this axis; a binary rotates about the barycenter. + auto axis_origin = rotation.center(); + if constexpr (Binary) { + const double mass = stellar_model.template specification().target().value(); + axis_origin[0] += companion_mass * separation / (mass + companion_mass); + } + const auto& spin = rotation.axis(); + schema.axes = {{"rotation", {spin[0], spin[1], spin[2]}, {axis_origin[0], axis_origin[1], axis_origin[2]}, + Binary ? "Orbital rotation axis through the barycenter" : "Rigid rotation axis"}}; + // A locked binary's barycenter moves with the separation, so each step re-registers the schema. + if (stream->enabled && !client.registerSchema(schema)) throw std::runtime_error(client.getLastError()); + if (stream->enabled && !stream->mesh_sent) { + if (!client.sendMesh(mesh_id, frame.mesh())) throw std::runtime_error(client.getLastError()); + stream->mesh_sent = true; + } + double initial_residual = 0; + const auto live_observe = [&](const solver::SolveEvent& event, const auto& state, const auto& system) { + observe(event, state, system); + if (!stream->enabled || (event.kind != solver::EventKind::initial && event.kind != solver::EventKind::resumed && + event.kind != solver::EventKind::accepted)) return; + try { + // StandardNewton has just evaluated this state. The cache is unsafe at finished + // after a rejected trial, so only initial/resumed/accepted events are streamed. + frame.update(state, problem.evaluator().cache()); + const std::uint32_t frame_index = stream->next_frame; + RayFEM::FrameMetadata metadata{.frame_index = frame_index, .sim_time = 0.0}; + const double residual = event.metric->residual_norm; + if (event.kind != solver::EventKind::accepted) initial_residual = residual; + const int newton_step = event.kind == solver::EventKind::accepted ? event.iteration + 1 : 0; + if (stream->continuation) + std::snprintf(metadata.label, sizeof(metadata.label), "step %d a=%.4g Newton %d |R|=%.3g", + stream->step, separation, newton_step, residual); + else + std::snprintf(metadata.label, sizeof(metadata.label), "n=%.3g J=%.3g Newton %u |R|=%.3g", + n, args.angular_momentum, frame_index, residual); + const double omega = state.template get()[0]; + metadata.values = {{"Newton step", std::int64_t{newton_step}}, {"residual", residual}, {"Omega", omega}}; + if (stream->continuation) { + metadata.values.push_back({"continuation step", std::int64_t{stream->step}}); + metadata.values.push_back({"separation", separation}); + } + if (event.kind == solver::EventKind::accepted) metadata.values.push_back({"step length", event.step}); + // The physical check, independent of the algebraic residual. + const auto& axis = rotation.axis(); + const auto virial = companion_mass > 0.0 ? std::expected{ + std::unexpect, "the vacuum virial omits the companion's potential"} : + serif::tests::solver::vacuum_virial(*problem.binding().discretization, state, stellar_model.equation_of_state(), + equations::RigidRotation{{omega*axis[0], omega*axis[1], omega*axis[2]}, rotation.center()}); + if (virial) { + const auto& sample = virial->samples.back(); + metadata.values.push_back({"virial ratio", (2.0*sample.kinetic_energy + 3.0*sample.pressure_integral)/std::abs(sample.potential_energy)}); + metadata.values.push_back({"virial error", sample.energy_virial()}); + metadata.values.push_back({"mass", sample.mass}); + } else metadata.values.push_back({"virial", virial.error().substr(0, 1000)}); + metadata.tags = {event.kind == solver::EventKind::initial ? "initial" : event.kind == solver::EventKind::resumed ? "resumed" : "accepted"}; + if (residual <= options.method.absolute_tolerance || residual <= options.method.relative_tolerance*initial_residual) + metadata.tags.push_back("converged"); + const auto send_start = std::chrono::steady_clock::now(); + if (!client.sendFrame(mesh_id, frame.fields(), metadata, &frame.displacement())) + throw std::runtime_error(client.getLastError()); + const std::chrono::duration send_time = std::chrono::steady_clock::now() - send_start; + std::println("RayFEM frame {} (queued in {:.2f} ms): interior elements {}, max nodal displacement {}, virial {}", + frame_index, send_time.count(), frame.mesh().GetNE(), frame.displacement().Normlinf(), + virial ? std::format("ratio {:.12g} error {:.3g}", (2.0*virial->samples.back().kinetic_energy + + 3.0*virial->samples.back().pressure_integral)/std::abs(virial->samples.back().potential_energy), + virial->samples.back().energy_virial()) : "unavailable: " + virial.error()); + ++stream->next_frame; + std::fflush(stdout); + } catch (const std::exception& error) { + stream->enabled = false; + std::println(stderr, "Live visualization disabled: {}", error.what()); + } + }; + auto result = solver::solve_newton(problem, std::move(*initial_state), options, live_observe); + // The last frames, the converged state among them, may still be queued. + if (!client.flush(60000)) std::println(stderr, "RayFEM: queued frames were still unsent after 60 s"); + if (const auto queue = client.queueStats(); queue.failed) + std::println(stderr, "RayFEM: {} queued packets were not sent", queue.failed); + return result; + }(); + if (!solution.converged()) { + std::println(stderr, "Stellar solve stopped: {}", solution.message()); + if (solution.diagnostics().last_linear_solve) { + const auto& linear = *solution.diagnostics().last_linear_solve; + std::println(stderr, "Last linear solve: {} iterations, true relative residual {}", linear.iterations, linear.relative_true_residual_norm); + } + return 1; + } + + // The result retains the physical definition, accepted coefficients and their matching system. + using Omega = serif::discretization::blocks::rotation::angular_velocity; + std::println("Converged after {} steps: Omega = {}, normalized residual = {}", solution.diagnostics().accepted_iterations, + solution.state().template get()[0], *solution.diagnostics().final_residual_norm); + if (companion_mass > 0.0) { + // Radii from the star's center toward the companion (+x), away from it, along the orbit (+y) and along the axis. + namespace deformation_backend = serif::geometry::deformation::backend::mfem; + const auto& state = solution.state(); + const auto inputs = deformation_backend::make_deformation_inputs(finite_elements.deformation.model, + finite_elements.deformation.reference, state.template get()); + using Surface = std::remove_cvref_t; + auto cache = serif::utils::dependencies::make_computation_cache( + deformation_backend::make_deformation_graph(), inputs); + if (!cache.try_prepare(inputs, serif::utils::dependencies::KeyList{})) + throw std::runtime_error("Cannot reconstruct the converged surface"); + serif::backend::mfem::serialize::detail::TopModelSampler sampler(finite_elements.template binding(), + state.template get(), finite_elements.deformation.reference, + cache.template get(), rotation.axis(), {0.0, 0.0, 0.0}); + const auto radius = [&](double mu, double phi) { return sampler.surface_radius(mu, phi).value(); }; + if constexpr (Binary) + std::println("Orbit: Omega / Omega_Kepler(point masses) = {:.9f}", solution.state().template get()[0] / kepler_omega); + else + std::println("Spin: Omega_spin / Omega_orbit(Kepler, point masses) = {:.6f}", solution.state().template get()[0] / kepler_omega); + std::println("Radii: toward companion {:.6f}, away {:.6f}, along orbit {:.6f}, polar {:.6f}", + radius(0.0, 0.0), radius(0.0, std::numbers::pi), radius(0.0, 0.5 * std::numbers::pi), radius(1.0, 0.0)); + if (!args.surface_table.empty()) { + std::ofstream table(args.surface_table); + table << "# theta_deg phi_deg radius (star center at the origin, companion at +x, separation " << separation << ")\n"; + for (int i = 0; i <= 90; ++i) { + for (int j = 0; j < 90; ++j) { + const double theta = 2.0 * i, phi = 4.0 * j; + table << theta << ' ' << phi << ' ' << radius(std::cos(theta * std::numbers::pi / 180.0), phi * std::numbers::pi / 180.0) << '\n'; + } + } + std::println("Surface table written to {}", args.surface_table.string()); + } + } + if (converged) converged->assign(solution.state().values().begin(), solution.state().values().end()); + if (!args.top_export.empty()) { + const auto exported = serif::utils::serialize::export_top(args.top_export, finite_elements, solution, args.top_options); + if (!exported) { + std::println(stderr, "TOP export failed at {}: {}", exported.error().path, exported.error().message); + return 1; + } + std::println("TOP model written to {}: {} radial points, degree {}, angular frequency unit {}", + exported->directory.string(), exported->radial_points, exported->maximum_degree, exported->angular_frequency_unit); + std::println("TOP projection: surface error {}, enthalpy angular truncation {}, surface symmetry error {}, enthalpy symmetry error {}", + exported->maximum_relative_surface_projection_error, exported->maximum_relative_enthalpy_projection_error, + exported->maximum_relative_surface_symmetry_error, + exported->maximum_relative_enthalpy_symmetry_error); + } + return 0; + }; + if (use_lu(args)) return solve_and_report(solver::preconditioner::strategies::inverse::SparseDirect{}); + return solve_and_report(solver::preconditioner::strategies::inverse::FixedAMG{.cycles = 3}); +} + +/** @brief Solve one star, or continue a binary inward in separation, seeding each solve with the previous star. */ +template +int run(const Arguments& args, const double K) { + const auto stream = args.visualize ? LiveStream::open(args) : nullptr; + if (args.separation_steps > 0) { + if (stream) stream->continuation = true; + std::vector state; + for (int step = 0; step <= args.separation_steps; ++step) { + Arguments stage = args; + stage.separation = args.separation_start * std::pow(args.separation / args.separation_start, static_cast(step) / args.separation_steps); + if (step < args.separation_steps) stage.surface_table.clear(); + if (stream) stream->step = step; + std::println("Continuation step {} of {}: separation {}", step, args.separation_steps, stage.separation); + const std::vector seed = state; + if (const int status = solve_star(stage, K, &state, step ? &seed : nullptr, stream.get()); status != 0) return status; + } + return 0; + } + return solve_star(args, K, nullptr, nullptr, stream.get()); +} + +/** @brief Show the complete route from physical equations to an owning converged stellar solution. */ +int main(int argc, char** argv) try { + if (argc == 2 && std::string_view(argv[1]) == "--help") { + std::println("Usage: complete_sandbox [--angular-momentum J] [--surface-degree L]\n" + " [--polytropic-index N] [--K K] [--surface-power-density | --ordinary-density]\n" + " [--mesh-refinement N] [--surface-refinement N]\n" + " [--equatorial-refinement N] [--equatorial-band-degrees D]\n" + " [--radial-surface-refinement N] [--quadrature-order N | auto]\n" + " [--potential-inverse auto | lu | amg]\n" + " [--rotation-axis X Y Z] [--mesh-only] [--infinity-radius F] [--seed-state FILE]\n" + " [--visualize | --no-visualize] [--viewer PATH] [--viewer-port PORT]\n" + " [--export-top DIRECTORY] [--top-radial-points N] [--top-degree L]\n" + " [--top-angular-points N] [--top-azimuthal-points N]\n" + " [--companion-mass MC --separation A [--separation-start A0 --separation-steps N]\n" + " [--surface-table FILE]]\n" + "Defaults: the n=2, J=0.1 rotator (Omega/Omega_K ~0.70) compared with ESTER/TOP: n=2, J=0.1, L=12, SurfacePower density,\n" + "surface refinement 1, equatorial refinement 1 with a 10 degree band, radial surface refinement 3,\n" + "axis=(0,0,1), streamed live to RayFEM. G=M=1 and, unless --K is given, K makes the nonrotating radius 1.\n" + "--angular-momentum 0.05 --surface-degree 8 gives the slower n=2, J=0.05 benchmark star.\n" + "--surface-power-density (default) selects t^n reconstruction on surface-adjacent density elements;\n" + "--ordinary-density selects the plain finite-element density.\n" + "Radial surface refinement halves only the stellar-side surface shell, radially, per level.\n" + "--quadrature-order fixes the volume quadrature order (default 11); auto uses SERiF's automatic rule.\n" + "--potential-inverse lu factors the potential block exactly (build option sparse_direct); amg uses SERiF's\n" + "3-cycle BoomerAMG; auto (default) uses lu when this build and machine support it, else amg.\n" + "Surface degree controls harmonics; refinement controls mesh cells.\n" + "Surface refinement adds isotropic interface layers; equatorial refinement adds latitude splits.\n" + "Equatorial rings conservatively cover the requested band (0 < D <= 35 degrees).\n" + "--infinity-radius places compactified infinity at F stellar radii (default 4); the vacuum map folds once the\n" + "equatorial bulge reaches 1/(F-1), so stars flatter than ~0.33 need F < 4.\n" + "--seed-state starts Newton from a converged profile_solve --save-state archive with the same n, K and\n" + "discretization (any J): continuation for fast rotators that do not converge from the spherical seed.\n" + "--mesh-only prints mesh counts and exits (no viewer). Viewer defaults: enabled, port 5555.\n" + "Live frames show initial and accepted Newton states, not physical time.\n" + "--export-top writes a converged polytrope to a new directory for TOP.\n" + "--companion-mass MC --separation A adds a tidally locked point-mass companion on +x: rotation is about the\n" + "barycenter and Omega follows from force balance (the star's center of mass stays at the origin); J is unused.\n" + "--separation-start A0 --separation-steps N solves first at A0 (from the spherical seed), then at N separations\n" + "stepping geometrically to A, each seeded by the previous star; every step streams to one RayFEM sequence,\n" + "tagged with its continuation step and separation. Close or massive\n" + "companions need this: from a sphere Newton stalls well inside the Roche lobe. Use --infinity-radius 3 near it.\n" + "--asynchronous instead spins the star about its own axis at the rate --angular-momentum sets, with only the\n" + "companion's tide acting (its uniform pull removed): the asynchronous Roche approximation.\n" + "--surface-table FILE writes the converged binary's surface radius on a (theta, phi) grid (phi = 0 faces the companion).\n" + "TOP defaults: 64 radial points, maximum even degree 16, 48 angular and 8 azimuthal samples."); + return 0; + } + const auto args = parse_arguments(argc, argv); + serif::initialize(); + const double K = args.polytropic_constant.value_or(unit_radius_polytropic_constant(args.polytropic_index)); + print_options(args, K); + return args.companion_mass > 0.0 && !args.asynchronous ? run(args, K) : run(args, K); +} catch (const std::exception& error) { + std::println(stderr, "{}", error.what()); + return 1; +} diff --git a/tests/sandbox/deformation_sandbox.cpp b/tests/sandbox/deformation_sandbox.cpp new file mode 100644 index 00000000..b7facc79 --- /dev/null +++ b/tests/sandbox/deformation_sandbox.cpp @@ -0,0 +1,67 @@ +#include +#include +#include "serif/model/finite_elements.hpp" +#include "serif/types.hpp" +#include "serif/geometry/deformation/finite_elements.hpp" +#include "serif/utils/dependencies/computation_cache.hpp" +#include "polytropic_model.hpp" + +/** + * @brief Deform a stellar surface, extend its motion, and reuse the reference calculations for a second shape. + * + * The physical model chooses radial displacement lengths in normalized real harmonics, a quadratic interior response, + * and fixed reference infinity. The owning discretization binds those choices to paired physical and logical meshes. Deformation reference computations wait until try_prepare. Coefficients are borrowed; finite_elements and shape_coefficients must outlive the inputs. + * Changing model options requires rebuilding the lightweight inputs; changing the coefficient vector does not. + * + * The same computations can be appended to an equation graph with make_deformation_computations. They do not own another + * preparation cache. SurfaceMetric is an optional separate reference calculation, independent of the volume extension. + * + * Low-level derivative actions use spans and an explicitly allocated workspace. They do not assemble a composite Jacobian. + * For nonlinear surface or extension models the same interface includes the derivative of the force pullback. + * This MFEM reference compiler currently supports conforming serial three-dimensional nodal spaces with byNODES displacement. + */ +int main() { + namespace deformation = serif::geometry::deformation; + namespace dependencies = serif::utils::dependencies; + namespace keys = deformation::keys; + + const deformation::DomainDeformation model{ + .surface = deformation::SphericalHarmonicSurface{.maximum_degree = 2}, + .stellar_interior = deformation::PowerLawRadialExtension{.radial_power = 2.0}, + .vacuum = deformation::FixedInfinityRadialExtension{} + }; + std::vector shape_coefficients(9, 0.0); + shape_coefficients[0] = 0.02; + const auto finite_elements = serif::model::make_stellar_discretization(serif::examples::make_polytropic_model(), + {.geometry_order = 2}, model); + const auto inputs = deformation::make_deformation_inputs(model, finite_elements.deformation.reference, shape_coefficients); + auto cache = dependencies::make_computation_cache( + deformation::make_deformation_graph(), inputs); + const auto prepared = cache.try_prepare(inputs); + if (!prepared) { + std::println(stderr, "Stellar deformation failed: {}", prepared.error().error.message); + return 1; + } + std::println("{} shape coefficients produce {} volume displacement coefficients.", shape_coefficients.size(), cache.get().size()); + + const auto accepted_shape = cache.get(); + auto workspace = deformation::make_deformation_workspace(*accepted_shape.map); + std::vector shape_direction(shape_coefficients.size(), 0.0); + // Increasing degree, then signed order places the axisymmetric quadrupole Y(2, 0) at coordinate six. + shape_direction[6] = 1.0; + serif::Vector displacement_direction(static_cast(accepted_shape.map->output_size())); + if (const auto action = deformation::apply_jacobian(accepted_shape, shape_direction, displacement_direction, workspace); !action) { + std::println(stderr, "Shape tangent failed: {}", action.error().message); + return 1; + } + + shape_coefficients[6] = 0.01; + const auto updated = cache.try_prepare(inputs); + if (!updated) { + std::println(stderr, "Updated stellar deformation failed: {}", updated.error().error.message); + return 1; + } + const bool reference_reused = updated->status() == dependencies::PreparationStatus::reused; + std::println("Reference radial interpolation reused after changing shape: {}.", reference_reused); + return reference_reused ? 0 : 1; +} diff --git a/tests/sandbox/dependencies_sandbox.cpp b/tests/sandbox/dependencies_sandbox.cpp new file mode 100644 index 00000000..b4a2a3eb --- /dev/null +++ b/tests/sandbox/dependencies_sandbox.cpp @@ -0,0 +1,292 @@ +#include +#include +#include + +#include "serif/utils/dependencies/dependencies.hpp" + + +/** + * Demonstration code for how to use the SERiF computational graph system + * The broad idea here is that there are many calculations which depend on each other but which may not necessarily always need to be recomputed just + * because another part of the model has changed. We model this as a combination of an acyclic computational graph, defined at compile time, and + * a runtime cache of values. The general workflow is that a developer defines the "context" of the computational graph, think of this + * as all of the inputs to the graph, or maybe more specifically, anything that could change due to some external event (e.g. a time step, a newton step, etc...) + * This context is probably the most CS intensive part of this code as the developer needs to think about lifetimes and ownership. + * + * **Context** + * Contexts are made with the make_dependency_context function, this accepts a variadic pack of bindings between a type and a runtime value. + * You can generate these yourself using the binding functions and types; however, we provide two convenience wrappers which generally developers should consider + * using. + * - borrow: Used to bind a type to a reference to a value. This is useful for values which are owned by the caller and which the caller will manage the lifetime of (takes an lvalue). + * - own: Used to bind a type to a value which is owned by the context (copies an lvalue or moves an rvalue) + * Each of these functions accepts a type as a template parameter and a runtime value as an argument. The type is used as an ID, this is key to understand, if we write + * \code{.cpp} + * using namespace serif::utils::dependencies; + * + * double x{1.0}; + * auto context = make_dependency_context(borrow(x)); + * \endcode + * + * we are not saying that x is of type keys::Input, this is clearly not the case. Rather, we are registering in the computational graph a binding + * between the type keys::Input and the runtime value x. Think of this as naming the various inputs to the graph. + * + * **Computations** + * After defining a context we can define a computation, each computation defines a node, while its ordered input keys define the incoming edges. + * Consider the following example (assuming the context from above): + * + * \code{.cpp} + * auto func = make_computation>( + * [](const double _x) -> double { + * return _x * _x; + * } + * ); + * \endcode + * + * what we have defined here is a node which takes a single input tagged keys::Input and produces an output tagged as keys::Output. The input key + * declares an edge from that source to this node. Other nodes can use keys::Output as an input, and a successfully prepared result can be read with get + * or retained with share. These connections allow us to form a larger graph. Consider now the following code + * + * \code{.cpp} + * using namespace serif::utils::dependencies; + * + * struct tags { + * struct X{}; + * struct Y{}; + * + * struct Output1{}; + * struct Output2{}; + * }; + * + * double x{1.0}; + * double y{2.0}; + * auto context = make_dependency_context(borrow(x), borrow(y)); + * + * auto func1 = make_computation>( + * [](const double _x) -> double { + * return _x * _x; + * } + * ); + * + * auto func2 = make_computation>( + * [](const double _x, const double _y) -> double { + * return _x + _y; + * } + * ); + * \endcode + * + * we have now defined two computations, the first takes a single input, whichever input is tagged as tags::X and produces an output tagged as tags::Output1. + * The second computation takes two inputs, the first is the output of the first computation (lookup by the tag) and the second is whichever input is tagged as tags::Y, + * and produces an output tagged as tags::Output2. Note that the order of arguments to the lambda must be the same as the order of the tags in the InputList, the + * dependencies module will automatically order arguments based on the InputList. There is type checking to ensure that the types match, but if you have two + * inputs of the same type that are flipped there is no way for the system to inspect that and warn you. Further, depending on your compiler and flags + * there may be implicit casting between convertable types (e.g. int <-> double). + * + * @todo We may want to remove the ability to have implicit casting between types, this is a bit of a footgun and may lead to subtle bugs. + * + * @note This is primary goal of this system, split computations into small pieces with the ability to cache intermediate results and reuse + * as many of those intermediate results as we can can prove is safe to do. The whole reason this system is so complex is that very purpose. There is a fundamental + * problem with high dimensional stellar models, they are always going to be slow, therefore we need to accept some degree of complexity in order to make them tractable. + * + * **Computation Cache** + * Once we have the computations we need to connect them together, generally use the helper function make_computation_cache which + * takes a context and a variadic pack of computations. There are *a lot* of compile time checks which run to ensure that the graph is valid, the context + * describes what is needed to compute the graph, and that each computation is valid. Generally if a static analysis error or compile error pops up here + * it probably means there is a mistake in the definition of the context or the computations. I've tried to make these errors as clear as possible; however, + * there is only so much that you can do with the C++ compiler. I recommend first checking, probably with a drawn-out diagram, that all the + * tags are correct and what you expect. + * + * A developer can also choose to use the make_computation_graph function to explicitly name a graph. This + * is useful when naming or reusing a collection of computations (make_computation_cache + * calls make_computation_graph and then fixes the source types from the supplied context without retaining the context). Now consider the following code assuming the context and computations from above: + * + * \code{.cpp} + * auto graph_cache = make_computation_cache(context, func1, func2); + * \endcode + * + * At this point we have a cache which we can access various parts of; however, no computations have been run yet. To run a computation we must call try_prepare. + * + * try_prepare returns std::expected: ordinary rejection is a value, and no candidate results or snapshots are published on failure. + * Exceptions from a callable or snapshot policy propagate with the same cache protection. Previously retained results remain valid, + * but get and share require a successful current preparation. Always check the report before reading results. + * + * try_prepare takes the context and evaluates the requested nodes and their prerequisites, reusing unchanged results. By default every result + * is requested, so the first call evaluates the entire graph. RequestedResults can restrict evaluation to a branch. + * + * \code{.cpp} + * auto report = graph_cache.try_prepare(context); + * if (!report) { + * return; // Handle report.error() before reading the cache. + * } + * + * double func1_result = graph_cache.template get(); + * double func2_result = graph_cache.template get(); + * + * auto func1_status = report->template status(); + * auto func2_status = report->template status(); + * + * std::string_view func1_status_string = preparation_status_name_map[func1_status]; // note this is an inline map defined in serif::utils::dependencies + * std::string_view func2_status_string = preparation_status_name_map[func2_status]; + * + * std::println("Func1 Result: {}, Func2 Result: {}, Func1 Status: {}, Func2 Status: {}", func1_result, func2_result, func1_status_string, func2_status_string); + * \endcode + * + * Now what we could do is change only one part of the input context and call try_prepare again. Lets say we change only y, such that only func2 is stale. + * + * \code{.cpp} + * y = 3.0; + * report = graph_cache.try_prepare(context); + * if (!report) { + * return; // Handle report.error() before reading the cache. + * } + * + * double func1_result = graph_cache.template get(); + * double func2_result = graph_cache.template get(); + * + * auto func1_status = report->template status(); + * auto func2_status = report->template status(); + * + * std::string_view func1_status_string = preparation_status_name_map[func1_status]; // note this is an inline map defined in serif::utils::dependencies + * std::string_view func2_status_string = preparation_status_name_map[func2_status]; + * + * std::println("Func1 Result: {}, Func2 Result: {}, Func1 Status: {}, Func2 Status: {}", func1_result, func2_result, func1_status_string, func2_status_string); + * \endcode + */ +int main() { + namespace dependencies = serif::utils::dependencies; + namespace keys = dependencies::keys; + + struct Tags { + struct Inputs { + struct AT{}; + struct BT{}; + struct CT{}; + + struct MT{}; + struct IT{}; + }; + + struct Outputs { + struct QuadOutput{}; + struct LinearOutput{}; + }; + }; + + double A = 1.0; + double B = 2.0; + double C = 3.0; + double x = 1.0; + double m = 2.0; + double i = 3.0; + + const auto graph_context = dependencies::make_dependency_context( + dependencies::borrow(x), + dependencies::borrow(A), + dependencies::borrow(B), + dependencies::borrow(C), + dependencies::borrow(m), + dependencies::borrow(i) + ); + + const auto quadratic_function = dependencies::make_computation>( + [](const double _x, const double i_A, const double i_B, const double i_C) -> double { + double t = 0.0; + for (int i = 0; i < 1000000; ++i) { + t = _x * i_A; + } + return i_A * _x * _x + i_B * _x + i_C; + } + ); + const auto linear_function = dependencies::make_computation>( + [](const double _x, const double _m, const double _b) -> double { + double t = 0.0; + for (int i = 0; i < 10000; ++i) { + t = _x * _m; + } + return _m * _x + _b; + } + ); + + // The cache owns callbacks; the application owns accumulated measurements. + using Clock = std::chrono::steady_clock; + std::chrono::nanoseconds quadratic_elapsed{}; + std::size_t quadratic_executions = 0; + std::size_t quadratic_consumptions = 0; + double consumed_quadratic = 0.0; + const auto observers = dependencies::make_observers( + dependencies::observe_node( + []() { + std::println("Before quadratic node execution"); + return Clock::now(); + }, + [&](Clock::time_point& started, dependencies::NodeOutcome) noexcept { + std::println("After quadratic node execution"); + quadratic_elapsed += std::chrono::duration_cast(Clock::now() - started); + ++quadratic_executions; + } + ), + dependencies::observe_node( + []() { + std::println("Before linear node execution"); + return 1; + }, + [](int& r, dependencies::NodeOutcome result) noexcept { + std::println("After linear node execution, result: {}", result); + } + ), + dependencies::observe_edge( + [&](const double& value) { + consumed_quadratic = value; + ++quadratic_consumptions; + } + ) + ); + const auto graph = dependencies::make_computation_graph(quadratic_function, linear_function); + auto graph_cache = dependencies::make_computation_cache(graph, graph_context, observers); + + auto display_results = [](auto& report, auto& cache, auto& start_time, auto& end_time) -> void { + if (!report) { + std::println(stderr, "Computation failed: {}", report.error().error); + return; + } + + const double result = cache.template get(); + const double quad_result = cache.template get(); + const dependencies::PreparationStatus linear_status = report->template status(); + const dependencies::PreparationStatus quad_status = report->template status(); + const auto duration = std::chrono::duration_cast(end_time - start_time).count(); + + std::string_view linear_status_string = dependencies::preparation_status_name_map[linear_status]; + std::string_view quad_status_string = dependencies::preparation_status_name_map[quad_status]; + + std::println("Linear Result: {}, Quad Result: {}, Linear Status: {}, Quad Status: {} Duration: {} nanoseconds", result, quad_result, linear_status_string, quad_status_string, duration); + }; + + auto start_time = std::chrono::high_resolution_clock::now(); + auto graph_report = graph_cache.try_prepare(graph_context); + auto end_time = std::chrono::high_resolution_clock::now(); + display_results(graph_report, graph_cache, start_time, end_time); + + // Change x and cause the entire graph to recompute + x = 2.0; + start_time = std::chrono::high_resolution_clock::now(); + graph_report = graph_cache.try_prepare(graph_context); + end_time = std::chrono::high_resolution_clock::now(); + display_results(graph_report, graph_cache, start_time, end_time); + + // Change just part of the linear function but allow it to reuse the quadratic result + m = 3.0; + start_time = std::chrono::high_resolution_clock::now(); + graph_report = graph_cache.try_prepare(graph_context); + end_time = std::chrono::high_resolution_clock::now(); + display_results(graph_report, graph_cache, start_time, end_time); + + // change nothing and allow the entire graph to reuse the previous results + start_time = std::chrono::high_resolution_clock::now(); + graph_report = graph_cache.try_prepare(graph_context); + end_time = std::chrono::high_resolution_clock::now(); + display_results(graph_report, graph_cache, start_time, end_time); + + // The third request consumes a reused quadratic value; the fourth executes no hooks. + std::println("Quadratic executions: {}, edge consumptions: {}, last consumed value: {}, total node time: {} nanoseconds", + quadratic_executions, quadratic_consumptions, consumed_quadratic, quadratic_elapsed.count()); +} diff --git a/tests/sandbox/dimensions_sandbox.cpp b/tests/sandbox/dimensions_sandbox.cpp index fedca07c..4dffde91 100644 --- a/tests/sandbox/dimensions_sandbox.cpp +++ b/tests/sandbox/dimensions_sandbox.cpp @@ -1,4 +1,3 @@ -#include "serif/dimensions/runtime/concepts.hpp" #include int main() { diff --git a/tests/sandbox/discritization_sandbox.cpp b/tests/sandbox/discritization_sandbox.cpp index 62cfd677..8ea8b861 100644 --- a/tests/sandbox/discritization_sandbox.cpp +++ b/tests/sandbox/discritization_sandbox.cpp @@ -1,53 +1,22 @@ #include -#include "serif/discretization/domain/schema/schemas.hpp" -#include "mfem.hpp" -#include "serif/discretization/domain/schema/validation/all.hpp" -#include "serif/discretization/domain/schema/validation/results.hpp" -#include "stroid/stroid.h" -#include "serif/discretization/blocks/base.hpp" -#include "serif/dimensions/runtime/runtime.hpp" +#include "serif/model/finite_elements.hpp" +#include "serif/backend/mfem/discretization/schema.hpp" #include "serif/discretization/forms/forms.hpp" -#include "serif/discretization/forms/form_traits.hpp" -#include "serif/discretization/blocks/concepts.hpp" -#include "serif/discretization/forms/concept.hpp" -#include "serif/discretization/forms/runtime.hpp" -#include "serif/discretization/blocks/runtime.hpp" -#include "serif/discretization/blocks/concepts.hpp" -#include "serif/discretization/blocks/traits.hpp" -#include "serif/discretization/blocks/concepts.hpp" -#include "serif/utils/misc/concepts/enumeration.hpp" -#include "serif/discretization/forms/operands.hpp" - -#include "serif/utils/misc/concepts/filter.hpp" +#include "polytropic_model.hpp" +/** @brief Build term-selected spaces and inspect their physical support on an owned stellar mesh. */ int main() { - using serif::discretization::domain::schema::validation::SchemaValidationResult; - using serif::discretization::domain::schema::validation::validate_schema; - using serif::discretization::domain::schema::CoreEnvelopeVacuumDomainSchema; - using namespace serif::discretization::blocks; + namespace blocks = serif::discretization::blocks; + using Schema = serif::discretization::domain::schema::CoreEnvelopeVacuumDomainSchema; - const auto smesh_result = stroid::IO::LoadStroidMesh("sandbox.smesh"); - if (not smesh_result.has_value()) { - throw std::runtime_error("Failed to load sandbox.smesh"); - } - - const stroid::StroidMesh& smesh = smesh_result.value(); - - SchemaValidationResult result = validate_schema(*smesh.mesh); + const auto star = serif::examples::make_polytropic_model(); + const auto finite_elements = serif::model::make_stellar_discretization(star); + const auto& density = finite_elements.template binding(); + const auto result = serif::backend::mfem::discretization::validate_schema(density.mesh()); std::println("{}", result); - - std::println("{}", serif::discretization::forms::mass::name); - - using AllTerms = serif::utils::types::TypeList< - density::mass, - displacement::vector, - surface_deformation::coefficients, - surface_deformation::shape_equilibrium, - gravity::potential, - gravity::gradient, - enthalpy::specific - >; - - using SpatialTerms = serif::utils::misc::concepts::FilterT; -} \ No newline at end of file + std::println("Density: {} coefficients; potential: {} coefficients", density.size(), + finite_elements.template binding().size()); + std::println("{}", serif::discretization::forms::mass::name); + return 0; +} diff --git a/tests/sandbox/eos_sandbox.cpp b/tests/sandbox/eos_sandbox.cpp index 06b2630a..58f02e6a 100644 --- a/tests/sandbox/eos_sandbox.cpp +++ b/tests/sandbox/eos_sandbox.cpp @@ -6,19 +6,8 @@ #include "serif/discretization/blocks/base.hpp" #include "serif/discretization/blocks/fields.hpp" #include "serif/discretization/domain/physical_domains.hpp" -#include "serif/eos/runtime/details.hpp" -#include "serif/eos/runtime/concepts.hpp" -#include "serif/eos/runtime/views.hpp" int main() { const serif::eos::models::Polytrope polytrope(-1, 0.6); // std::println("Density: {}", serif::eos::evaluate(polytrope, serif::dimensions::SpecificEnthalpyValue{1.0}).value()); - // const serif::eos::runtime::EOSView view(polytrope); - // const auto result = view.try_evaluate(serif::dimensions::SpecificEnthalpyValue{1.0}); - // if (result.has_value()) { - // std::println("From runtime view: Density: {}", result->value()); - // } else { - // std::println("From runtime view: EOS view is unavailable."); - // } - // // std::println("{}", serif::discretization::blocks::gravity::name); } \ No newline at end of file diff --git a/tests/sandbox/equations_sandbox.cpp b/tests/sandbox/equations_sandbox.cpp new file mode 100644 index 00000000..83a0dbe6 --- /dev/null +++ b/tests/sandbox/equations_sandbox.cpp @@ -0,0 +1,48 @@ +#include +#include + +#include "serif/eos/evaluation.hpp" +#include "serif/eos/models/polytropic.hpp" +#include "serif/equations/barotropic_density.hpp" +#include "serif/equations/surface_potential.hpp" + +/** @brief Evaluate manufactured interior and surface samples and inspect their exact derivatives. */ +int main() { + namespace equations = serif::equations; + namespace dimensions = serif::dimensions; + + const serif::eos::models::Polytrope eos{1.0, 0.8}; + const double density = 0.5, angular_velocity = 0.3, surface_level = 1.2; + const dimensions::PressureValue surface_pressure{0.0}; + const equations::BarotropicDensityBalance density_balance{eos, surface_pressure}; + const equations::SurfacePotentialBalance surface; + using Balance = decltype(density_balance); + + // Density supplies the pressure potential through the EOS; it is not another solved field. + const auto surface_density = serif::eos::evaluate(eos, surface_pressure); + const double pressure_potential = serif::eos::evaluate(eos, dimensions::DensityValue{density}).value(); + const double surface_pressure_potential = serif::eos::evaluate(eos, surface_density).value(); + equations::EquationPoint mechanics; + mechanics.position = {0.3, 0.4, 0.0}; + mechanics.fields.get() = density; + mechanics.fields.get() = angular_velocity; + mechanics.fields.get() = surface_level; + mechanics.fields.get() = surface_level - pressure_potential + surface_pressure_potential + + angular_velocity * angular_velocity * density_balance.rotation().centrifugal_potential(mechanics.position); + const auto interior_balance = density_balance.evaluate(mechanics); + + equations::EquationPoint boundary; + boundary.position = mechanics.position; + boundary.fields.get() = surface_level + + angular_velocity * angular_velocity * surface.rotation().centrifugal_potential(boundary.position); + boundary.fields.get() = surface_level; + boundary.fields.get() = angular_velocity; + const auto surface_balance = surface.evaluate(boundary); + + std::println("Density balance residual = {:.3e}; angular-velocity derivative = {}", + interior_balance.residual, interior_balance.field_derivatives.get()); + std::println("Surface potential residual = {:.3e}; derivative with respect to surface level = {}", + surface_balance.residual, surface_balance.field_derivatives.get()); + // complete_sandbox assembles these same physical equations on its finite-element mesh. + return std::abs(interior_balance.residual) < 1.0e-12 && std::abs(surface_balance.residual) < 1.0e-12 ? 0 : 1; +} diff --git a/tests/sandbox/geometry_sandbox.cpp b/tests/sandbox/geometry_sandbox.cpp index e69de29b..63bc4fee 100644 --- a/tests/sandbox/geometry_sandbox.cpp +++ b/tests/sandbox/geometry_sandbox.cpp @@ -0,0 +1,10 @@ +#include + +#include "serif/geometry/surface/isobaric.hpp" +#include "serif/utils/dependencies/binding.hpp" +#include "serif/utils/dependencies/key_list.hpp" +#include "serif/geometry/deformation/deformation.hpp" + +int main() { + std::println("Hello, World!"); +} \ No newline at end of file diff --git a/tests/sandbox/lane-emden_sandbox.cpp b/tests/sandbox/lane-emden_sandbox.cpp index e69de29b..a078866a 100644 --- a/tests/sandbox/lane-emden_sandbox.cpp +++ b/tests/sandbox/lane-emden_sandbox.cpp @@ -0,0 +1,17 @@ +#include "serif/utils/laneemden/laneemden.hpp" +#include + +int main() { + const auto solution = serif::utils::laneemden::solve(-3.0, { + .step = 0.01, + .maximum_radius = 100.0, + .maximum_steps = 10000, + .surface_tolerance = 1e-10 + }); + + if (solution.has_value()) { + std::println("{:xi}", *solution); + } else { + std::println("Lane-Emden error: {}", solution.error()); + } +} \ No newline at end of file diff --git a/tests/sandbox/mapping_sandbox.cpp b/tests/sandbox/mapping_sandbox.cpp index 4c64f270..3404c810 100644 --- a/tests/sandbox/mapping_sandbox.cpp +++ b/tests/sandbox/mapping_sandbox.cpp @@ -1,14 +1,36 @@ +#include #include -#include "serif/mapping/context.hpp" -#include "serif/mapping/variation.hpp" -#include "serif/mapping/compactification/compactification.hpp" -#include "serif/mapping/status.hpp" -#include "serif/mapping/compactification/kelvin.hpp" -#include "serif/mapping/domain_mapper.hpp" -#include "serif/mapping/transformations.hpp" +#include "serif/mapping/mapping.hpp" +#include "serif/types.hpp" -#include "serif/utils/user_options/mapping/compactification/kelvin.hpp" +/** @brief Map an exterior reference point toward physical infinity using the selected native vector storage. */ int main() { - std::println("Hello, World!"); -} \ No newline at end of file + namespace mapping = serif::mapping; + namespace compactification = mapping::compactification; + + const compactification::KelvinCompactification exterior({.r_star_ref = 1.0, .r_inf_ref = 4.0}); + serif::Vector position(3), coordinate_gradient(3); + position = 0.0; + position[0] = 2.0; + coordinate_gradient = 0.0; + coordinate_gradient[0] = 1.0 / 3.0; + serif::DenseMatrix displacement_jacobian(3); + displacement_jacobian = 0.0; + for (int dimension = 0; dimension < 3; ++dimension) { + displacement_jacobian(dimension, dimension) = 1.0; + } + const compactification::ExteriorMapInput input{ + .reference_position = position, .displaced_position = position, + .displacement_jacobian = displacement_jacobian, + .compactification_coordinate_gradient = coordinate_gradient, + .compactification_coordinate = 1.0 / 3.0}; + compactification::ExteriorMapResult result; + const auto status = exterior.evaluate(input, result); + if (status != mapping::MappingStatus::VALID) { + std::println(stderr, "Exterior mapping failed."); + return 1; + } + std::println("Reference radius {} maps to physical radius {}", position[0], result.physical_position[0]); + return std::isfinite(result.physical_position[0]) ? 0 : 1; +} diff --git a/tests/sandbox/meson.build b/tests/sandbox/meson.build index 88ff87e7..2c6ee241 100644 --- a/tests/sandbox/meson.build +++ b/tests/sandbox/meson.build @@ -1,11 +1,22 @@ sandboxes = [ + 'rayfem_sandbox', 'discritization_sandbox', 'eos_sandbox', 'finite_sandbox', 'dimensions_sandbox', 'quadrature_sandbox', 'meta_sandbox', - 'mapping_sandbox' + 'mapping_sandbox', + 'equations_sandbox', + 'geometry_sandbox', + 'surface_sandbox', + 'deformation_sandbox', + 'solver_sandbox', + 'complete_sandbox', + 'normalization_sandbox', + 'preconditioner_sandbox', + 'model_sandbox', + 'lane-emden_sandbox', ] expected_errors = {} @@ -18,12 +29,36 @@ foreach entry : get_option('sandbox_expected_errors') endforeach foreach sandbox: sandboxes + sandbox_timeout = sandbox in ['solver_sandbox', 'complete_sandbox'] ? 3600 : 30 + sandbox_cpp_args = [] + sandbox_link_depends = [] + sandbox_dependencies = [serif_dep, nameof_dep] + sandbox_includes = [] + if sandbox == 'complete_sandbox' + viewer_path = '' + if rayfem_project.get_variable('build_viewer') + viewer_target = rayfem_project.get_variable('rayfem_viewer') + viewer_path = viewer_target.full_path() + sandbox_link_depends += [viewer_target] + endif + sandbox_cpp_args += ['-DSERIF_RAYFEM_EXECUTABLE="' + viewer_path + '"'] + # Streamed frames report the vacuum virial measured by the solver tests. + sandbox_dependencies += [stroid_dep] + sandbox_includes += [include_directories('../include')] + endif sandbox_exe = executable( sandbox, sandbox + '.cpp', - dependencies: [serif_dep, stroid_dep, nameof_dep], + dependencies: sandbox_dependencies, + include_directories: sandbox_includes, + cpp_args: sandbox_cpp_args, + link_depends: sandbox_link_depends, build_rpath: mfem_runtime_prefix == '' ? '' : mfem_runtime_prefix / 'lib', ) + if sandbox in ['solver_sandbox', 'complete_sandbox'] and not mfem_has_mpi + # These examples select the default AMG inverse, which requires MPI/Hypre. + continue + endif if expected_errors.has_key(sandbox) test( sandbox, python_build, @@ -32,6 +67,26 @@ foreach sandbox: sandboxes suite: 'sandbox', ) else - test(sandbox, sandbox_exe, workdir: meson.project_source_root(), suite: 'sandbox') + test(sandbox, sandbox_exe, workdir: meson.project_source_root(), suite: 'sandbox', timeout: sandbox_timeout) endif endforeach + +# This example uses only the dependency headers, independently of MFEM and SERiF linking. +dependencies_sandbox = executable( + 'dependencies_sandbox', + 'dependencies_sandbox.cpp', + dependencies: [magic_enum_dep], + include_directories: include_dir, +) +test('dependencies_sandbox', dependencies_sandbox, suite: 'sandbox') + +# Demonstrate archive ownership and continuation without leaving a file in the source tree. +serialization_sandbox = executable( + 'serialization_sandbox', + 'serialization_sandbox.cpp', + dependencies: [serif_dep], + build_rpath: mfem_runtime_prefix == '' ? '' : mfem_runtime_prefix / 'lib', +) +if mfem_has_mpi + test('serialization_sandbox', serialization_sandbox, suite: 'sandbox', timeout: 1800, is_parallel: false) +endif diff --git a/tests/sandbox/meta_sandbox.cpp b/tests/sandbox/meta_sandbox.cpp index 983ff6b9..dfebd6aa 100644 --- a/tests/sandbox/meta_sandbox.cpp +++ b/tests/sandbox/meta_sandbox.cpp @@ -4,5 +4,5 @@ int main() { using namespace serif::utils::errors; puts("Hello, world!\n"); - serif_assert(); + serif_ensure(false, "This is a test error {}", "message"); } \ No newline at end of file diff --git a/tests/sandbox/model_sandbox.cpp b/tests/sandbox/model_sandbox.cpp new file mode 100644 index 00000000..406fedf8 --- /dev/null +++ b/tests/sandbox/model_sandbox.cpp @@ -0,0 +1,67 @@ +#include +#include + +#include "serif/eos/models/polytropic.hpp" +#include "serif/equations/angular_momentum.hpp" +#include "serif/equations/barotropic_density.hpp" +#include "serif/equations/surface_potential.hpp" +#include "serif/equations/mass_normalization.hpp" +#include "serif/equations/primal_gravity.hpp" +#include "serif/equations/registry.hpp" +#include "serif/model/description.hpp" +#include "serif/model/eos_reference.hpp" +#include "serif/geometry/surface/isobaric.hpp" + +namespace +{ + namespace model = serif::model; + namespace equations = serif::equations; + namespace dimensions = serif::dimensions; + namespace surface = serif::geometry::surface; +} + +/** @brief Inspect an explicitly chosen rotating equilibrium formulation without creating a mesh or solving it. */ +int main() +{ + // This list is the physical formulation. The EOS supplies relations; it does not choose equations. + const auto equilibrium = [](const auto& physics) + { + const auto eos = model::borrow_eos(physics.equation_of_state()); + const auto& rotation = physics.template specification(); + + return model::EquilibriumEquations + { + .interior_equations = equations::EquationRegistry + { + equations::BarotropicDensityBalance{eos, dimensions::PressureValue{0.0}, rotation.axis(), rotation.center()}, + equations::PrimalPoissonBalance{4.0 * std::numbers::pi}, + equations::MassNormalization{physics.template specification().target().value()}, + equations::AngularMomentumConstraint{rotation.target().value(), rotation.axis(), rotation.center()} + }, + .shape_force = equations::SurfacePotentialBalance{rotation.axis(), rotation.center()}, + .surface_condition = surface::Isobaric{dimensions::PressureValue{0.0}} + }; + }; + + // Mass and angular momentum are prescribed. Density, potential, surface level, angular velocity and shape are solved. + const auto star = model::StellarModel + { + serif::eos::models::Polytrope{3.0, 0.25}, + equilibrium, + model::FixedTotalMass{dimensions::MassValue{1.0}}, + model::FixedAngularMomentum{dimensions::AngularMomentumValue{0.02}} + }; + + // In this example G = 1 in the chosen physical units, and scalar Poisson balance supplies gravity. + // Rigid rotation uses the same axis and origin in the density equation and on the pressure surface. + // Resolving reads immutable physics once. Keep star alive while the resolved equations borrow its EOS. + const auto chosen = model::resolve_equilibrium_equations(star); + std::println("Interior equations:"); + chosen.interior_equations.for_each([](const auto& equation) + { + std::println(" {}", equation.name); + }); + std::println("Shape equation: {}", chosen.shape_force.name); + std::println("Surface pressure: {}", chosen.surface_condition.target_pressure().value()); + std::println("Prescribed mass: {}; angular momentum: {}", star.specification().target().value(), star.specification().target().value()); +} diff --git a/tests/sandbox/normalization_sandbox.cpp b/tests/sandbox/normalization_sandbox.cpp new file mode 100644 index 00000000..33c03892 --- /dev/null +++ b/tests/sandbox/normalization_sandbox.cpp @@ -0,0 +1,145 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "serif/equations/mass_normalization.hpp" +#include "serif/equations/primal_gravity.hpp" +#include "serif/solver/normalization/normalization.hpp" +#include "serif/utils/dependencies/computation_cache.hpp" + +namespace +{ + namespace normalization = serif::solver::normalization; + namespace dependencies = serif::utils::dependencies; + namespace dimensions = serif::dimensions; + + /** @brief Convert illustrative physical coefficients and assembled residuals into one solve's fixed coordinates. */ + void show_coordinate_conversions(const normalization::DiagonalNormalization& coordinates, const std::array& physical_state, const std::array& physical_residual) + { + // These orders follow the layout below: [potential, surface potential level] and [Poisson balance, mass constraint]. + std::array normalized_state{}; + std::array normalized_residual{}; + coordinates.normalize_state(physical_state, normalized_state); + coordinates.normalize_residual(physical_residual, normalized_residual); + + std::println("Physical potential coefficients: [{:.6g}, {:.6g}]; surface potential level: {:.6g}", physical_state[0], physical_state[1], physical_state[2]); + std::println("Normalized potential coefficients: [{:.6g}, {:.6g}]; surface potential level: {:.6g}", normalized_state[0], normalized_state[1], normalized_state[2]); + std::println("Physical assembled Poisson residual: [{:.6g}, {:.6g}]; mass residual: {:.6g}", physical_residual[0], physical_residual[1], physical_residual[2]); + std::println("Normalized Poisson residual: [{:.6g}, {:.6g}]; mass residual: {:.6g}", normalized_residual[0], normalized_residual[1], normalized_residual[2]); + std::println("Residual norm in these coordinates: {:.6g}", std::sqrt(coordinates.residual_norm_squared(physical_residual))); + + // Physical equations still consume physical coefficients. Convert a solver's coordinates back before evaluation. + std::array recovered_state{}; + coordinates.denormalize_state(normalized_state, recovered_state); + std::println("Recovered physical potential coefficients: [{:.6g}, {:.6g}]; surface potential level: {:.6g}", recovered_state[0], recovered_state[1], recovered_state[2]); + } +} + +/** + * @brief Prepare, apply, reuse, and explicitly replace the normalization for a small selection of stellar coordinates. + * + * Potential and the surface potential level use GM/R, Poisson balance uses GM/R^3, and the mass constraint uses M. + * PhysicalValueRule reads the unknown's Quantity; PhysicalResidualRule reads the equation's ResidualQuantity. + * Those quantities need not agree, even when an equation closes a particular unknown. + * + * The illustrative metric contains two diagonal entries m_i = integral psi_i^2 dV_ref in the field's coefficient order. + * A configured stellar discretization builds reference metrics in its computational graph automatically. + * complete_sandbox demonstrates that path; this small example supplies a diagonal explicitly. + * State factors are sqrt(m_i)/state_scale; assembled weak-form residual factors are 1/(sqrt(m_i)*residual_scale). + * Algebraic coordinates use a unit metric. This is a diagonal metric, not the full mass-matrix norm. + * + * The example starts with supplied coefficients and assembled residuals; it does not assemble a complete stellar model. + * Reference units must be consistent with those physical quantities and the reference geometry. + */ +int main() +{ + using Potential = serif::discretization::blocks::gravity::potential; + using SurfaceLevel = serif::discretization::blocks::barotropic_constant::mass_normalization; + using PoissonBalance = serif::equations::PrimalPoissonBalance; + using MassConstraint = serif::equations::MassNormalization; + using PotentialMetric = normalization::ReferenceVolumeMetric; + + // The plan chooses physical scaling. The layout independently chooses the order and sizes of packed blocks. + using Plan = normalization::NormalizationPlan< + dependencies::Bindings, normalization::PhysicalValueRule>, + dependencies::Bindings, normalization::PhysicalResidualRule>>; + const normalization::CoordinateLayout, dependencies::KeyList> layout{ + .state_sizes = {2, 1}, + .residual_sizes = {2, 1} + }; + + const dimensions::QuantityValue reference_radius{2.0}; + const dimensions::QuantityValue gravitational_constant{1.0}; + auto reference = normalization::prepare_stellar_reference_scales(dimensions::QuantityValue{8.0}, reference_radius, gravitational_constant); + if (!reference) + { + std::println(stderr, "Cannot prepare reference scales: {}", reference.error()); + return 1; + } + normalization::StellarReferenceScales reference_scales = *reference; + const std::vector reference_metric{4.0, 9.0}; + + // Borrowed inputs must outlive the cache. Trial coefficients are absent: changing a candidate cannot change its units. + const auto inputs = dependencies::make_dependency_context( + dependencies::borrow(layout), + dependencies::borrow(reference_scales), + dependencies::borrow(reference_metric)); + + // Factories only declare work. Both coordinate roles share this ordinary dependency cache. + auto computations = normalization::make_normalization_computations(layout); + auto cache = std::apply([&](auto... nodes) + { + return dependencies::make_computation_cache(inputs, std::move(nodes)...); + }, std::move(computations)); + + const auto prepared = cache.try_prepare(inputs); + if (!prepared) + { + std::println(stderr, "Cannot prepare reference normalization: {}", prepared.error().error); + return 1; + } + + const normalization::StellarReferenceScales& scales = reference_scales; + std::println("Reference mass: {:.6g}; radius: {:.6g}; G: {:.6g}", scales.mass.value(), scales.radius.value(), scales.gravitational_constant.value()); + std::println("Potential and surface-potential scale: {:.6g}; Poisson scale: {:.6g}; mass scale: {:.6g}", scales.specific_energy.value(), scales.inverse_time_squared.value(), scales.mass.value()); + + // This owning copy fixes the coordinates for a solve, even if the cache later prepares another reference. + const normalization::DiagonalNormalization solve_coordinates = cache.get(); + const std::array physical_state{-8.0, -12.0, 6.0}; + const std::array physical_residual{0.2, -0.3, 0.4}; + show_coordinate_conversions(solve_coordinates, physical_state, physical_residual); + + const auto unchanged = cache.try_prepare(inputs); + if (!unchanged) + { + std::println(stderr, "Cannot reuse reference normalization: {}", unchanged.error().error); + return 1; + } + std::println("Unchanged reference: {} computations repeated.", unchanged->computed_count()); + + // After this solve, explicitly choose a new reference mass for the next solve. The accepted copy remains valid. + reference = normalization::prepare_stellar_reference_scales(dimensions::QuantityValue{16.0}, reference_radius, gravitational_constant); + if (!reference) + { + std::println(stderr, "Cannot prepare the next reference scales: {}", reference.error()); + return 1; + } + reference_scales = *reference; + const auto updated = cache.try_prepare(inputs); + if (!updated) + { + std::println(stderr, "Cannot prepare the next reference normalization: {}", updated.error().error); + return 1; + } + const normalization::DiagonalNormalization next_solve_coordinates = cache.get(); + std::array old_reference_state{}; + std::array new_reference_state{}; + solve_coordinates.normalize_state(physical_state, old_reference_state); + next_solve_coordinates.normalize_state(physical_state, new_reference_state); + std::println("After doubling reference mass, surface potential level in original coordinates: {:.6g}; next solve's coordinates: {:.6g}", old_reference_state[2], new_reference_state[2]); + return 0; +} diff --git a/tests/sandbox/polytropic_model.hpp b/tests/sandbox/polytropic_model.hpp new file mode 100644 index 00000000..40bbe6e5 --- /dev/null +++ b/tests/sandbox/polytropic_model.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include "serif/eos/models/polytropic.hpp" +#include "serif/equations/angular_momentum.hpp" +#include "serif/equations/barotropic_density.hpp" +#include "serif/equations/mass_normalization.hpp" +#include "serif/equations/primal_gravity.hpp" +#include "serif/equations/surface_potential.hpp" +#include "serif/geometry/surface/isobaric.hpp" +#include "serif/model/eos_reference.hpp" +#include "serif/model/stellar_model.hpp" +#include "serif/model/specifications/fixed_total_mass.hpp" +#include "serif/model/specifications/fixed_angular_momentum.hpp" +#include "serif/utils/error/errors.hpp" + +namespace serif::examples { + /** @brief Solve density mechanics, primal gravity and the isobar shape at fixed mass, EOS and angular momentum. */ + [[nodiscard]] inline auto make_polytropic_model(double n = 1.0, double K = 2.0 / std::numbers::pi, double mass = 1.0, double angular_momentum = 0.0, double G = 1.0, double surface_pressure = 0.0) { + const auto equations = [G, surface_pressure](const auto& physics) { + const auto eos = model::borrow_eos(physics.equation_of_state()); + const double index = physics.equation_of_state().polytropic_index(); + const auto& rotation = physics.template specification(); + // Resolve physical compatibility with the deferred equation definition, without restricting the EOS itself. + utils::errors::serif_ensure( + !(index == 3.0 && rotation.target().value() == 0.0 && surface_pressure == 0.0), + "Fixed K and M do not determine a unique nonrotating n=3 vacuum equilibrium: compatible mass leaves radius free, and incompatible mass has no solution."); + return model::EquilibriumEquations{ + .interior_equations = serif::equations::EquationRegistry{ + serif::equations::BarotropicDensityBalance{eos, dimensions::PressureValue{surface_pressure}, rotation.axis(), rotation.center()}, + serif::equations::PrimalPoissonBalance{4.0 * std::numbers::pi * G}, + serif::equations::MassNormalization{physics.template specification().target().value()}, + serif::equations::AngularMomentumConstraint{rotation.target().value(), rotation.axis(), rotation.center()} + }, + .shape_force = serif::equations::SurfacePotentialBalance{rotation.axis(), rotation.center()}, + .surface_condition = geometry::surface::Isobaric{dimensions::PressureValue{surface_pressure}} + }; + }; + return model::make_stellar_model(eos::models::Polytrope{n, K}, equations, + model::FixedTotalMass{dimensions::MassValue{mass}}, model::FixedAngularMomentum{dimensions::AngularMomentumValue{angular_momentum}}); + } + +} diff --git a/tests/sandbox/preconditioner_sandbox.cpp b/tests/sandbox/preconditioner_sandbox.cpp new file mode 100644 index 00000000..ebe67730 --- /dev/null +++ b/tests/sandbox/preconditioner_sandbox.cpp @@ -0,0 +1,102 @@ +#include +#include +#include +#include + +#include "serif/types.hpp" +#include "serif/operators/linearized_block.hpp" +#include "serif/discretization/blocks/fields.hpp" +#include "serif/solver/preconditioner/preconditioner.hpp" +#include "serif/utils/dependencies/computation_cache.hpp" + +namespace { + /** @brief Make the complete accepted Jacobian of this illustrative two-coordinate example. */ + [[nodiscard]] std::shared_ptr material_jacobian(double density_response) { + auto matrix = std::make_shared(2); + (*matrix)(0, 0) = density_response; + (*matrix)(0, 1) = 3.0; + (*matrix)(1, 0) = 0.0; + (*matrix)(1, 1) = 4.0; + return matrix; + } + + /** @brief Publish one frozen physical block; real discretized equations supply volume_field_block and pressure_field_block instead. */ + [[nodiscard]] serif::operators::LinearizedBlock scalar_block(double entry) { + auto matrix = std::make_shared(1); + (*matrix)(0, 0) = entry; + return serif::operators::linearized_block(std::shared_ptr(std::move(matrix))); + } +} // namespace + +/** @brief Demonstrate public preparation, reuse, MFEM application, and accepted-factor ownership with a transparent 2x2 system. */ +int main() { + namespace preconditioner = serif::solver::preconditioner; + namespace dependencies = serif::utils::dependencies; + namespace inverse = preconditioner::strategies::inverse; + namespace coupling = preconditioner::strategies::coupling; + using Density = serif::discretization::blocks::density::mass; + using Potential = serif::discretization::blocks::gravity::potential; + + std::println("Illustrative 2x2 material block; these coefficients do not define a stellar model."); + // Correct potential first, then subtract its response from the density equation. + const auto density = preconditioner::make_preconditioner(inverse::Diagonal{}); + const auto potential = preconditioner::make_preconditioner(inverse::Diagonal{}); + const auto choice = preconditioner::couple(density, potential, coupling::SecondThenFirst{}); + using Recipe = decltype(choice); + using DensityBlock = preconditioner::keys::Block, dependencies::KeyList>; + + // Physical blocks and the full Jacobian come from one accepted state, in (density, potential) order. + auto accepted_jacobian = material_jacobian(2.0); + auto density_block = scalar_block((*accepted_jacobian)(0, 0)); + const auto context = dependencies::make_dependency_context( + dependencies::borrow(density_block), + preconditioner::physical_block(scalar_block((*accepted_jacobian)(1, 1))), + preconditioner::physical_block(scalar_block((*accepted_jacobian)(0, 1)))); + + // Describing a graph performs no numerical setup; preparation explicitly accepts its inputs. + const auto graph = preconditioner::make_preconditioner_graph(choice); + auto cache = dependencies::make_computation_cache(graph, context); + const auto prepared = cache.try_prepare(context); + if (!prepared) { + std::println(stderr, "Cannot prepare material preconditioner: {}", prepared.error().error.message); + return 1; + } + std::println("Initial preparation: {} computations", prepared->computed_count()); + + // Sharing fixes the numerical factors for this solve, even after the cache accepts another state. + const auto frozen = cache.share(); + preconditioner::Solver original_solver(*frozen, accepted_jacobian); + original_solver.SetOperator(*accepted_jacobian); + serif::Vector residual(2), correction(2); + residual[0] = 10.0; + residual[1] = 8.0; + correction = 99.0; + original_solver.Mult(residual, correction); + std::println("J = [[2, 3], [0, 4]], residual = [10, 8]: correction = [{:.3f}, {:.3f}]", correction[0], correction[1]); + + const auto unchanged = cache.try_prepare(context); + if (!unchanged) { + std::println(stderr, "Cannot reuse material preconditioner: {}", unchanged.error().error.message); + return 1; + } + std::println("Unchanged accepted state: {} computations", unchanged->computed_count()); + + // Replacing an accepted block changes its identity; the shared dependency engine refreshes only its consumers. + accepted_jacobian = material_jacobian(4.0); + density_block = scalar_block((*accepted_jacobian)(0, 0)); + const auto refreshed = cache.try_prepare(context); + if (!refreshed) { + std::println(stderr, "Cannot refresh material preconditioner: {}", refreshed.error().error.message); + return 1; + } + const auto current = cache.share(); + preconditioner::Solver current_solver(*current, accepted_jacobian); + current_solver.SetOperator(*accepted_jacobian); + current_solver.Mult(residual, correction); + std::println("Density response 2 -> 4: {} computations; correction = [{:.3f}, {:.3f}]", refreshed->computed_count(), correction[0], correction[1]); + + // The old adapter still owns both its original Jacobian and original inverse; never rebind it to the new Jacobian. + original_solver.Mult(residual, correction); + std::println("Original frozen solve remains: [{:.3f}, {:.3f}]", correction[0], correction[1]); + return 0; +} diff --git a/tests/sandbox/quadrature_sandbox.cpp b/tests/sandbox/quadrature_sandbox.cpp index 115d6b21..d3b31ea8 100644 --- a/tests/sandbox/quadrature_sandbox.cpp +++ b/tests/sandbox/quadrature_sandbox.cpp @@ -3,24 +3,19 @@ #include "serif/discretization/forms/concept.hpp" #include "serif/discretization/forms/forms.hpp" -#include "serif/discretization/quadrature/rules.hpp" #include "serif/discretization/quadrature/policy.hpp" -#include "serif/discretization/quadrature/backend/mfem/mfem_static_order.hpp" -#include "serif/discretization/quadrature/backend/order.hpp" -#include "serif/discretization/quadrature/backend/mfem/concepts.hpp" -#include "serif/discretization/quadrature/backend/mfem/configure.hpp" -#include "serif/discretization/quadrature/backend/mfem/mfem_resolver.hpp" #include +/** @brief Compare quadrature policies; the deliberately negative order demonstrates input validation. */ int main() { using namespace serif::discretization::quadrature; using namespace serif::discretization::domain; using namespace serif::discretization::forms; - using GravityMassForm = divergence; + using PotentialMassForm = mass; - auto query = make_query( + auto query = make_query( QuadratureRole::discretization, AllDomains{}, MappingKind::affine, @@ -63,7 +58,7 @@ int main() { } ); - boostedRuleSet.set_form_control( + boostedRuleSet.set_form_control( RuleControl{ .fixed_order = std::nullopt, .boost = 3 @@ -99,7 +94,7 @@ int main() { } ); - fixedRuleSet.set_form_control( + fixedRuleSet.set_form_control( RuleControl{ .fixed_order = 11, .boost = 3 diff --git a/tests/sandbox/rayfem_sandbox.cpp b/tests/sandbox/rayfem_sandbox.cpp new file mode 100644 index 00000000..4a1b9a0f --- /dev/null +++ b/tests/sandbox/rayfem_sandbox.cpp @@ -0,0 +1,25 @@ +#include +#include + +#include "mfem.hpp" +#include "libRayfem/io/serif.hpp" +#include "libRayfem/mesh/converter.hpp" + +/** @brief Exercise the visualization dependency without opening a window or running a solve. */ +int main() { + auto mesh = mfem::Mesh::MakeCartesian2D(2, 2, mfem::Element::QUADRILATERAL, true, 1.0, 1.0); + mfem::H1_FECollection collection(2, 2); + mfem::FiniteElementSpace space(&mesh, &collection); + mfem::GridFunction field(&space); + mfem::FunctionCoefficient coefficient([](const mfem::Vector& x) { return x[0] + 2.0 * x[1]; }); + field.ProjectCoefficient(coefficient); + + const auto surface = RayFEM::BuildSurface(mesh, &field); + if (!RayFEM::SerifLoadingAvailable() || surface.values.empty() || + std::abs(surface.min_value) > 1.0e-12 || std::abs(surface.max_value - 3.0) > 1.0e-12) { + std::println(stderr, "RayFEM could not evaluate the parent MFEM field or load SERiF archives."); + return 1; + } + std::println("RayFEM evaluated the MFEM field through serif_dep; SERiF archive support is available."); + return 0; +} diff --git a/tests/sandbox/serialization_sandbox.cpp b/tests/sandbox/serialization_sandbox.cpp new file mode 100644 index 00000000..395f035d --- /dev/null +++ b/tests/sandbox/serialization_sandbox.cpp @@ -0,0 +1,86 @@ +#include +#include +#include +#include +#include +#include + +#include "serif/runtime.hpp" +#include "serif/model/finite_elements.hpp" +#include "serif/backend/mfem/serialize/configuration.hpp" +#include "serif/solver/equilibrium.hpp" +#include "serif/solver/seed/state.hpp" +#include "serif/solver/seed/polytrope.hpp" +#include "polytropic_model.hpp" +#include "serif/utils/serialize/archive.hpp" +#include "serif/utils/serialize/solver.hpp" + +namespace { + /** @brief Keep this example's error reporting separate from its save-and-resume workflow. */ + template + T checked(std::expected result) { + if (!result) { + if constexpr (requires { result.error().path; result.error().message; }) { + serif::utils::errors::serif_ensure(false, + "{}: {}", result.error().path, result.error().message); + } else { + serif::utils::errors::serif_ensure(false, "{}", result.error()); + } + } + if constexpr (!std::is_void_v) { + return std::move(*result); + } + } +} + +/** @brief Stop at an accepted Newton boundary, save the star, and continue with newly reconstructed resources. */ +int main(int argc, char** argv) { + namespace solver = serif::solver; + namespace serialization = serif::utils::serialize; + namespace configuration_io = serif::utils::serialize; + + serif::initialize(); + + // const std::filesystem::path path = argc > 1 ? argv[1] : "stellar_checkpoint.serif"; + // + // const auto star = serif::examples::make_polytropic_model(); + // const auto radial = checked(solver::seed::make_polytropic_seed(star)); + // const auto finite_elements = serif::model::make_stellar_discretization(star, + // {.radius = radial.scales.radius}, + // serif::geometry::deformation::DomainDeformation{ + // .surface = {.maximum_degree = 0}, .stellar_interior = {}, .vacuum = {}}); + // const auto initial = checked(solver::seed::make_polytropic_state(star, finite_elements, radial)); + // const solver::normalization::NormalizationOptions normalization{ + // .scheme = checked(solver::normalization::prepare_stellar_reference_scales( + // serif::dimensions::MassValue{1.0}, serif::dimensions::LengthValue{1.0}))}; + // const auto configuration = serif::model::make_discretization(serif::model::DiscretizationOptions{ + // .model = star, .finite_elements = finite_elements, .normalization = normalization}); + // const solver::SolverOptions options{ + // .linear_solver = { + // .print_level = 1 + // } + // }; + // const auto stop_after_step = [](const solver::SolveEvent& event, const auto&, const auto&) { + // return event.kind == solver::EventKind::accepted ? solver::SolveControl::stop : solver::SolveControl::continue_solve; + // }; + // const auto stopped = solver::solve_equilibrium(configuration, initial, options, stop_after_step); + // + // // Each component remains independently readable. No cache, Jacobian or preconditioner is written. + // checked(serialization::write(path, serialization::pack(configuration, options, stopped), + // {.author = "SERiF example", .comment = "Accepted Newton step; ready to continue"})); + // + // auto reader = checked(serialization::Reader::open(path)); + // using Configuration = std::remove_cvref_t; + // const auto restored = checked(configuration_io::restore_configuration(checked(reader.node("configuration")))); + // const auto checkpoint = checked(reader.read("solution/checkpoint")); + // const auto restored_options = checked(reader.read>()); + // const auto resumed = solver::resume_equilibrium(restored, checkpoint, restored_options); + // std::println("{} ({} accepted iterations, including those before saving)", resumed.message(), resumed.diagnostics().accepted_iterations); + // return resumed.converged() ? 0 : 1; + auto result = serif::utils::serialize::Reader::open("stellar_checkpoint.serif"); + if (result.has_value()) { + std::println("Opened checkpoint from SERiF version: {}", result->serif_version()); + } else { + std::println("Failed to open checkpoint: {} ({})", result.error().message, result.error().path); + } +} diff --git a/tests/sandbox/solver_sandbox.cpp b/tests/sandbox/solver_sandbox.cpp index e69de29b..28838a0e 100644 --- a/tests/sandbox/solver_sandbox.cpp +++ b/tests/sandbox/solver_sandbox.cpp @@ -0,0 +1,47 @@ +#include + +#include "serif/runtime.hpp" +#include "serif/model/finite_elements.hpp" +#include "serif/solver/equilibrium.hpp" +#include "serif/solver/seed/state.hpp" +#include "polytropic_model.hpp" + +/** @brief Solve one nonrotating polytrope with the default Newton, normalization, and inverse choices. */ +int main() { + serif::initialize(); + namespace solver = serif::solver; + namespace normalization = solver::normalization; + + // Physical prescriptions stay fixed while Newton changes the numerical state. + const auto model = serif::examples::make_polytropic_model(); + const auto radial = solver::seed::make_polytropic_seed(model); + if (!radial) { + std::println(stderr, "Cannot prepare the radial star: {}", radial.error()); + return 1; + } + const auto finite_elements = serif::model::make_stellar_discretization(model, + {.radius = radial->scales.radius}, + serif::geometry::deformation::DomainDeformation{ + .surface = {.maximum_degree = 2}, .stellar_interior = {}, .vacuum = {}}); + auto initial = solver::seed::make_polytropic_state(model, finite_elements, *radial); + if (!initial) { + std::println(stderr, "Cannot project the initial star: {}", initial.error()); + return 1; + } + + // The reference is numerical bookkeeping; it does not constrain central density. + auto configuration = serif::model::make_discretization(serif::model::DiscretizationOptions{ + .model = model, + .finite_elements = finite_elements, + .normalization = normalization::make_normalization(normalization::NormalizationOptions{ + .scheme = normalization::LaneEmdenApproximation{.n = 1.0, .central_density = radial->scales.central_density}}) + }); + const auto result = solver::solve_equilibrium(std::move(configuration), std::move(*initial)); + std::println("{}; accepted Newton steps: {}; final normalized residual: {}", result.message(), + result.diagnostics().accepted_iterations, result.diagnostics().final_residual_norm.value_or(-1.0)); + if (!result.converged() && result.diagnostics().last_linear_solve) { + const auto& linear = *result.diagnostics().last_linear_solve; + std::println(stderr, "Last linear solve: {} iterations, true relative residual {}", linear.iterations, linear.relative_true_residual_norm); + } + return result.converged() ? 0 : 1; +} diff --git a/tests/sandbox/stellar_rayfem.hpp b/tests/sandbox/stellar_rayfem.hpp new file mode 100644 index 00000000..e93e511d --- /dev/null +++ b/tests/sandbox/stellar_rayfem.hpp @@ -0,0 +1,152 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "mfem/fem/gridfunc.hpp" +#include "mfem/mesh/submesh/submesh.hpp" +#include "mfem/mesh/submesh/transfermap.hpp" +#include "libRayfem/network/client.hpp" +#include "serif/backend/mfem/discretization/field_values.hpp" +#include "serif/backend/mfem/model/volume_displacement.hpp" +#include "serif/discretization/blocks/fields.hpp" + +namespace serif::visualization::backend::mfem { + /** + * @brief Own visualization copies on the stellar interior's reference submesh. + * @note update() requires the evaluator cache for this exact state. For StandardNewton, + * use initial/resumed/accepted events, never finished or a rejected trial. The existing + * nodal displacement is sent separately; neither the solver mesh nor state is changed. + * Reconstructed density is sampled into a higher-order discontinuous display field. This + * polynomial display approximation never enters the solve or physical diagnostics. + */ + template + class StellarFrame { + using Density = discretization::blocks::density::mass; + using Potential = discretization::blocks::gravity::potential; + using Displacement = discretization::blocks::displacement::vector; + using Shape = discretization::blocks::surface_deformation::coefficients; + + Discretization m_fe; + ::mfem::GridFunction m_parent_density, m_parent_potential, m_parent_displacement; + ::mfem::SubMesh m_mesh; + std::unique_ptr<::mfem::L2_FECollection> m_display_density_collection; + ::mfem::FiniteElementSpace m_density_space, m_potential_space, m_displacement_space; + ::mfem::GridFunction m_density, m_potential, m_displacement; + std::unique_ptr<::mfem::TransferMap> m_density_transfer; + ::mfem::TransferMap m_potential_transfer, m_displacement_transfer; + + /** @brief Extract the material mesh while preserving parent element reference coordinates. */ + static ::mfem::SubMesh interior_mesh(const Discretization& fe) { + const auto* parent = fe.template binding().space().GetMesh(); + if (fe.template binding().space().GetMesh() != parent || + fe.template binding().space().GetMesh() != parent) { + throw std::invalid_argument("Stellar visualization requires a common parent mesh"); + } + ::mfem::Array attributes; + for (int attribute : fe.deformation.reference.stellar_attributes) attributes.Append(attribute); + if (attributes.Size() == 0) throw std::invalid_argument("No stellar material attributes to visualize"); + return ::mfem::SubMesh::CreateFromDomain(*parent, attributes); + } + + /** @brief Use additional display nodes only when an ordinary GridFunction cannot represent density. */ + static std::unique_ptr<::mfem::L2_FECollection> density_display_collection(const Discretization& fe) { + const auto& binding = fe.template binding(); + if (binding.reconstruction() == nullptr) return {}; + return std::make_unique<::mfem::L2_FECollection>(binding.space().GetMaxElementOrder() + 3, 3, ::mfem::BasisType::GaussLobatto); + } + + /** @brief Copy supported physical coefficients into an ordinary visualization field. */ + template + void scatter(const Values& values, ::mfem::GridFunction& field) { + const auto& binding = m_fe.template binding(); + if (values.size() != static_cast(binding.size())) { + throw std::invalid_argument("State and visualization binding differ"); + } + ::mfem::Vector reduced(binding.size()); + for (int i = 0; i < reduced.Size(); ++i) reduced[i] = values[i]; + field = binding.scatter(reduced); + } + + /** @brief Sample reconstructed density into a display-only polynomial on each material element. */ + template + void sample_density(const Values& values) { + using namespace utils::errors; + + const auto& binding = m_fe.template binding(); + serif_ensure(values.size() == static_cast(binding.size()), + "Density state and visualization binding have different coefficient counts."); + ::mfem::Vector reduced(binding.size()); + std::ranges::copy(values, reduced.GetData()); + discretization::backend::mfem::FieldValues density(binding, reduced); + const auto& parent_elements = m_mesh.GetParentElementIDMap(); + ::mfem::Array dofs; + ::mfem::Vector local; + for (int element = 0; element < m_mesh.GetNE(); ++element) { + const auto& nodes = m_density_space.GetFE(element)->GetNodes(); + density.values(parent_elements[element], nodes, local); + m_density_space.GetElementDofs(element, dofs); + m_density.SetSubVector(dofs, local); + } + } + + public: + /** @brief Retain geometry and field copies for successive accepted solver states. */ + explicit StellarFrame(Discretization fe) + : m_fe(std::move(fe)), + m_parent_density(&m_fe.template binding().space()), + m_parent_potential(&m_fe.template binding().space()), + m_parent_displacement(&m_fe.template binding().space()), + m_mesh(interior_mesh(m_fe)), + m_display_density_collection(density_display_collection(m_fe)), + m_density_space(&m_mesh, m_display_density_collection ? m_display_density_collection.get() : m_parent_density.FESpace()->FEColl(), + m_parent_density.FESpace()->GetVDim(), m_parent_density.FESpace()->GetOrdering()), + m_potential_space(&m_mesh, m_parent_potential.FESpace()->FEColl(), + m_parent_potential.FESpace()->GetVDim(), m_parent_potential.FESpace()->GetOrdering()), + m_displacement_space(&m_mesh, m_parent_displacement.FESpace()->FEColl(), + m_parent_displacement.FESpace()->GetVDim(), m_parent_displacement.FESpace()->GetOrdering()), + m_density(&m_density_space), m_potential(&m_potential_space), m_displacement(&m_displacement_space), + m_potential_transfer(m_parent_potential, m_potential), + m_displacement_transfer(m_parent_displacement, m_displacement) { + if (!m_display_density_collection) m_density_transfer = std::make_unique<::mfem::TransferMap>(m_parent_density, m_density); + } + + StellarFrame(const StellarFrame&) = delete; + StellarFrame& operator=(const StellarFrame&) = delete; + StellarFrame(StellarFrame&&) = delete; + StellarFrame& operator=(StellarFrame&&) = delete; + + /** @brief Publish one accepted state through its matching cached displacement. */ + template + void update(const State& state, const Cache& cache) { + namespace deformation = geometry::deformation; + const auto& linearization = cache.template get(); + if (!std::ranges::equal(linearization.parameters, state.template get())) { + throw std::invalid_argument("Visualization state and cached deformation differ"); + } + if (m_density_transfer) { + scatter(state.template get(), m_parent_density); + m_density_transfer->Transfer(m_parent_density, m_density); + } else { + sample_density(state.template get()); + } + scatter(state.template get(), m_parent_potential); + m_parent_displacement = m_fe.template binding().scatter( + cache.template get()); + m_potential_transfer.Transfer(m_parent_potential, m_potential); + m_displacement_transfer.Transfer(m_parent_displacement, m_displacement); + } + + /** @brief Return the unchanged computational mesh sent to the viewer. */ + [[nodiscard]] ::mfem::Mesh& mesh() { return m_mesh; } + /** @brief Return the separate physical displacement applied by the viewer. */ + [[nodiscard]] ::mfem::GridFunction& displacement() { return m_displacement; } + /** @brief Label a reconstructed density display explicitly as sampled data. */ + [[nodiscard]] std::vector fields() { + return {{m_density_transfer ? "density" : "density (sampled)", &m_density}, {"potential", &m_potential}, {"displacement", &m_displacement}}; + } + }; +} diff --git a/tests/sandbox/surface_sandbox.cpp b/tests/sandbox/surface_sandbox.cpp new file mode 100644 index 00000000..a60c4316 --- /dev/null +++ b/tests/sandbox/surface_sandbox.cpp @@ -0,0 +1,48 @@ +#include +#include + +#include "serif/eos/models/polytropic.hpp" +#include "serif/geometry/surface/surface.hpp" +#include "serif/utils/dependencies/computation_cache.hpp" + +/** + * @brief Prescribe one pressure and evaluate its residual over several already sampled boundary points. + * + * The EOS catalog and available state select the quantity enforcing the condition. This example binds density, as the + * stellar model does; other models may bind another state. IsobaricSurface itself only knows the prescribed pressure. + * + * The ordinary dependency cache owns preparation. The EOS response is computed once for this pressure-only relation, + * then reused across all boundary samples and subsequent changes to their density. No EOS evaluation occurs at graph construction. + * Sampling a finite-element field on the boundary is deliberately a separate numerical operation. + */ +int main() { + namespace surface = serif::geometry::surface; + namespace dependencies = serif::utils::dependencies; + namespace quantities = serif::dimensions; + using Density = serif::discretization::blocks::density::mass; + using EOS = serif::eos::models::Polytrope; + using State = dependencies::Bindings>; + + const EOS equation_of_state{3.0, 0.25}; + const surface::IsobaricSurface boundary{quantities::PressureValue{0.03125}}; + const auto context = dependencies::make_dependency_context( + dependencies::borrow(equation_of_state, dependencies::track_by([](const EOS& model) { + return std::pair{model.polytropic_index(), model.polytropic_constant()}; + })), + surface::pressure_condition(boundary), + surface::boundary_values({0.05, 0.1, 0.2}) + ); + + auto graph = surface::make_pressure_surface_graph(); + auto cache = dependencies::make_computation_cache(std::move(graph), context); + const auto preparation = cache.try_prepare(context); + if (!preparation) { + std::println(stderr, "Surface preparation failed: {}", preparation.error().error.message); + return 1; + } + + for (const auto& point : cache.get()) { + std::println("Boundary density residual: {}; derivative: {}", point.residual, point.field_derivatives.get()); + } + return 0; +} diff --git a/tests/serialize/archive.cpp b/tests/serialize/archive.cpp new file mode 100644 index 00000000..0de68ca0 --- /dev/null +++ b/tests/serialize/archive.cpp @@ -0,0 +1,329 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include "serif/config.hpp" +#include "serif/utils/serialize/archive.hpp" +#include "serif/utils/serialize/solver.hpp" + +namespace { + namespace serialization = serif::utils::serialize; + + /** @brief Remove a test archive on success and on an assertion failure. */ + struct File { + std::filesystem::path path = std::filesystem::temp_directory_path() / + ("serif-archive-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + ".serif"); + /** @brief Keep all regression artifacts confined to their temporary lifetime. */ + ~File() { std::error_code error; std::filesystem::remove(path, error); } + }; + + /** @brief Read raw bytes so malformed archives can be tested independently of the writer. */ + std::string bytes(const File& file) { + std::ifstream input(file.path, std::ios::binary); + return {std::istreambuf_iterator{input}, std::istreambuf_iterator{}}; + } + + /** @brief Deliberately replace an archive with a malformed test fixture. */ + void replace(const File& file, std::string_view value) { + std::ofstream output(file.path, std::ios::binary | std::ios::trunc); + output.write(value.data(), value.size()); + REQUIRE(output.good()); + } + + /** @brief Change one public header field while preserving its fixed byte extent. */ + std::string header_field(std::string original, std::string_view key, std::string_view value) { + const auto start = original.find(std::string{key} + ' ') + key.size() + 1; + const auto end = original.find('\n', start); + std::string header = original.substr(0, original.find("END_HEADER\n") + 11); + header.replace(start, end - start, value); + REQUIRE(header.size() < 512); + header.resize(512, ' '); + header.back() = '\n'; + return header + original.substr(512); + } + + /** @brief Repair the ledger checksum after an intentional structurally invalid edit. */ + std::string ledger_checksum(std::string original) { + const std::size_t start = original.find("ledger_bytes ") + 13; + const std::size_t count = std::stoull(original.substr(start, original.find('\n', start) - start)); + return header_field(original, "ledger_checksum", serialization::fingerprint(std::string_view{original}.substr(512, count))); + } +} + +TEST_CASE("Both archive encodings preserve every binary64 bit pattern", "[serialize][archive]") { + std::mt19937_64 random{271828}; + std::vector values{0.0, -0.0, 1.0, -1.0, std::numeric_limits::denorm_min(), + std::numeric_limits::min(), std::numeric_limits::max(), std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), std::bit_cast(0x7ff8123456789abcULL)}; + for (int index = 0; index < 2000; ++index) { + values.push_back(std::bit_cast(random())); + } + for (serialization::Format format : {serialization::Format::binary, serialization::Format::text}) { + const File file; + REQUIRE(serialization::write(file.path, serialization::pack(serialization::named("coefficients", values)), {.format = format})); + auto reader = serialization::Reader::open(file.path); + REQUIRE(reader); + CHECK(reader->format() == format); + const auto restored = reader->read>("coefficients"); + REQUIRE(restored); + REQUIRE(restored->size() == values.size()); + for (std::size_t index = 0; index < values.size(); ++index) { + CHECK(std::bit_cast((*restored)[index]) == std::bit_cast(values[index])); + } + CHECK(reader->validate()); + } +} + +TEST_CASE("Archives carry provenance annotations and an immediately inspectable ledger", "[serialize][archive]") { + const File file; + const auto contents = serialization::pack(serialization::named("surface", serialization::object("harmonics", + serialization::field("coefficients", std::vector{1.0, 2.0}), serialization::field("description", "ξ, θ\n\"quoted\"\\")))); + REQUIRE(serialization::write(file.path, contents, {.format = serialization::Format::text, + .author = "Emily", .comment = "Restart after Newton step 4\nRetain the full surface.", .metadata = {{"study", "ESTER comparison"}}})); + auto reader = serialization::Reader::open(file.path); + REQUIRE(reader); + REQUIRE(reader->contains("surface/coefficients")); + CHECK_FALSE(reader->contains("unprovided_solver_options")); + CHECK(reader->serif_version() == SERIF_VERSION); + CHECK(reader->read("provenance/author") == "Emily"); + CHECK(reader->read("provenance/metadata/study") == "ESTER comparison"); + const auto time = reader->read("provenance/written_utc"); + REQUIRE(time); + CHECK(time->size() == 20); + CHECK(time->back() == 'Z'); + CHECK(reader->contains("provenance/host/architecture")); + CHECK(reader->contains("provenance/libraries/nameof")); + CHECK(reader->contains("provenance/libraries/magic_enum")); + CHECK(reader->read("surface/description") == "ξ, θ\n\"quoted\"\\"); + for (const serialization::Entry& entry : reader->ledger()) { + if (entry.path == "surface/coefficients") { + std::ifstream input(file.path, std::ios::binary); + input.seekg(entry.offset); + double first = 0.0; + input >> first; + CHECK(first == 1.0); + CHECK(entry.count == 2); + } + } +} + +TEST_CASE("Pack handles absence unsupported components and conflicting definitions explicitly", "[serialize][archive]") { + const File file; + struct CustomPhysics {}; + auto contents = serialization::pack(serialization::named("physics", CustomPhysics{}), serialization::named("number", 42)); + REQUIRE(serialization::write(file.path, contents)); + auto reader = serialization::Reader::open(file.path); + REQUIRE(reader); + const auto unknown = reader->node("physics"); + REQUIRE(unknown); + CHECK(unknown->type == "unsupported"); + CHECK_FALSE(reader->read("physics")); + CHECK_FALSE(reader->read("missing")); + const std::string original = bytes(file); + CHECK_FALSE(serialization::write(file.path, serialization::pack(serialization::named("x", 1), serialization::named("x", 2)))); + CHECK(bytes(file) == original); + CHECK(serialization::write(file.path, serialization::pack(serialization::named("x", 1), serialization::named("x", 1)))); + CHECK_FALSE(serialization::write(file.path, serialization::pack(serialization::named("provenance", 1)))); + CHECK_FALSE(serialization::write(file.path, serialization::pack(serialization::named("a/b", 1)))); + CHECK_FALSE(serialization::write(file.path, serialization::pack(serialization::named("zero", 0.0), serialization::named("zero", -0.0)))); + const double nan = std::bit_cast(0x7ff8123456789abcULL); + CHECK(serialization::write(file.path, serialization::pack(serialization::named("nan", nan), serialization::named("nan", nan)))); +} + +TEST_CASE("Readers implement configurable SERiF version tolerance independently of file versions", "[serialize][archive]") { + const File file; + REQUIRE(serialization::write(file.path, serialization::pack())); + const std::string original = bytes(file); + const std::string major = std::to_string(SERIF_VERSION_MAJOR); + replace(file, header_field(original, "serif_version", '"' + major + ".999.17\"")); + CHECK(serialization::Reader::open(file.path)); + CHECK_FALSE(serialization::Reader::open(file.path, {.version_tolerance = serialization::VersionTolerance::exact})); + replace(file, header_field(original, "serif_version", '"' + std::to_string(SERIF_VERSION_MAJOR + 1) + ".0.0\"")); + CHECK_FALSE(serialization::Reader::open(file.path)); + CHECK(serialization::Reader::open(file.path, {.version_tolerance = serialization::VersionTolerance::any})); + std::string future = original; + future[future.find("SERIF_ARCHIVE 1") + 14] = '2'; + replace(file, future); + CHECK_FALSE(serialization::Reader::open(file.path, {.version_tolerance = serialization::VersionTolerance::any})); +} + +TEST_CASE("Indexed reads isolate requested data and report damage when that section is read", "[serialize][archive]") { + const File file; + REQUIRE(serialization::write(file.path, serialization::pack(serialization::named("large", std::vector(10000, 1.25)), + serialization::named("a", serialization::object("group", serialization::field("value", 7))), serialization::named("a.b", 8)))); + const auto index = serialization::Reader::open(file.path); + REQUIRE(index); + std::string damaged = bytes(file); + for (const auto& entry : index->ledger()) { + if (entry.path == "large") { + damaged[entry.offset + 3] ^= 1; + } + } + replace(file, damaged); + auto reader = serialization::Reader::open(file.path); + REQUIRE(reader); + CHECK(reader->metadata()); + const auto group = reader->node("a"); + REQUIRE(group); + CHECK(serialization::read_field(*group, "value") == 7); + CHECK_FALSE(reader->read>("large")); + CHECK_FALSE(reader->validate()); +} + +TEST_CASE("Malformed bounds truncation ledger and allocation requests are rejected", "[serialize][archive]") { + const File file; + REQUIRE(serialization::write(file.path, serialization::pack(serialization::named("value", 3.0)))); + const std::string original = bytes(file); + for (std::size_t length : {std::size_t{0}, std::size_t{511}, std::size_t{512}, original.size() - 1}) { + replace(file, original.substr(0, length)); + CHECK_FALSE(serialization::Reader::open(file.path)); + } + for (std::string key : {"ledger_offset", "ledger_bytes", "data_offset", "file_bytes", "entries"}) { + replace(file, header_field(original, key, "18446744073709551615")); + CHECK_FALSE(serialization::Reader::open(file.path)); + } + replace(file, original); + CHECK_FALSE(serialization::Reader::open(file.path, {.maximum_index_bytes = 1})); + CHECK_FALSE(serialization::Reader::open(file.path, {.maximum_payload_bytes = 1})); + CHECK_FALSE(serialization::Reader::open(file.path, {.maximum_entries = 1})); + CHECK_FALSE(serialization::Reader::open(file.path, {.maximum_depth = 0})); + std::string damaged = original; + damaged[550] ^= 1; + replace(file, damaged); + CHECK_FALSE(serialization::Reader::open(file.path)); +} + +TEST_CASE("Primitive schemas reject wrong extents ranges versions and representations", "[serialize][archive]") { + CHECK_FALSE(serialization::decode(serialization::encode(std::uint64_t{256}))); + CHECK_FALSE(serialization::decode>(serialization::encode(std::vector{1}))); + CHECK_FALSE(serialization::decode>(serialization::encode(std::vector{1.0}))); + CHECK_FALSE(serialization::decode(serialization::encode(1.0))); + auto future = serialization::encode(1.0); + future.version = 2; + CHECK_FALSE(serialization::decode(future)); + const auto optional = serialization::decode>(serialization::encode(std::optional{7})); + REQUIRE(optional); + CHECK(*optional == std::optional{7}); + CHECK(serialization::decode>(serialization::encode(std::tuple{7, std::string{"x"}})) == std::tuple{7, std::string{"x"}}); + for (serialization::Format format : {serialization::Format::text, serialization::Format::binary}) { + const File file; + REQUIRE(serialization::write(file.path, serialization::pack(serialization::named("empty", std::vector{}), + serialization::named("bounds", std::vector{std::numeric_limits::min(), std::numeric_limits::max()}), + serialization::named("flag", true)), {.format = format})); + auto reader = serialization::Reader::open(file.path); + REQUIRE(reader); + CHECK(reader->read>("empty") == std::vector{}); + CHECK(reader->read("flag") == true); + CHECK(reader->read>("bounds") == std::vector{std::numeric_limits::min(), std::numeric_limits::max()}); + } +} + +TEST_CASE("A valid checksum does not hide inconsistent ledger semantics", "[serialize][archive]") { + const File file; + REQUIRE(serialization::write(file.path, serialization::pack(serialization::named("aa", 1.0), serialization::named("bb", 2.0)))); + const std::string original = bytes(file); + SECTION("Duplicate component paths") { + std::string edited = original; + edited.replace(edited.find("\"bb\" \"real\""), 4, "\"aa\""); + replace(file, ledger_checksum(edited)); + CHECK_FALSE(serialization::Reader::open(file.path)); + } + SECTION("Overlapping payloads") { + std::string edited = original; + const std::size_t first = edited.find("\"aa\" \"real\" 1 4 1 ") + 18; + const std::size_t second = edited.find("\"bb\" \"real\" 1 4 1 ") + 18; + edited.replace(second, 20, edited.substr(first, 20)); + replace(file, ledger_checksum(edited)); + CHECK_FALSE(serialization::Reader::open(file.path)); + } + SECTION("Scalar with an array extent") { + std::string edited = original; + edited.replace(edited.find("\"aa\" \"real\" 1 4 1"), 17, "\"aa\" \"real\" 1 4 9"); + replace(file, ledger_checksum(edited)); + auto reader = serialization::Reader::open(file.path); + REQUIRE(reader); + CHECK_FALSE(reader->read("aa")); + } + SECTION("Unknown payload encoding") { + std::string edited = original; + edited.replace(edited.find("\"aa\" \"real\" 1 4 1"), 17, "\"aa\" \"real\" 1 9 1"); + replace(file, ledger_checksum(edited)); + CHECK_FALSE(serialization::Reader::open(file.path)); + } +} + +TEST_CASE("Failed archive publication retains existing destinations", "[serialize][archive]") { + const File directory; + REQUIRE(std::filesystem::create_directory(directory.path)); + CHECK_FALSE(serialization::write(directory.path, serialization::pack(serialization::named("value", 1)))); + CHECK(std::filesystem::is_directory(directory.path)); + CHECK(std::filesystem::is_empty(directory.path)); + CHECK_FALSE(serialization::write(directory.path / "missing" / "star.serif", serialization::pack())); +} + +TEST_CASE("Archive bytes do not depend on the application's numeric locale", "[serialize][archive]") { + struct Punctuation : std::numpunct { + /** @brief Simulate a locale with comma decimals and whitespace-separated digit groups. */ + char do_decimal_point() const override { return ','; } + /** @brief Expose accidental locale-dependent checksum formatting as a ledger parse failure. */ + char do_thousands_sep() const override { return ' '; } + /** @brief Group every third digit in formatted numbers. */ + std::string do_grouping() const override { return "\3"; } + }; + struct RestoreLocale { + std::locale original = std::locale(); + /** @brief Restore the application's locale even when an assertion aborts this case. */ + ~RestoreLocale() { std::locale::global(original); } + } restore; + const std::string expected = serialization::fingerprint("scientific data"); + std::locale::global(std::locale(std::locale::classic(), new Punctuation)); + CHECK(serialization::fingerprint("scientific data") == expected); + const File file; + REQUIRE(serialization::write(file.path, serialization::pack(serialization::named("value", 12345.6789)), {.format = serialization::Format::text})); + std::locale::global(std::locale::classic()); + auto reader = serialization::Reader::open(file.path); + REQUIRE(reader); + CHECK(reader->read("value") == 12345.6789); +} + + +TEST_CASE("Previous Newton diagnostic archives remain readable without solver-owned timers", "[unit][serialize][restart]") { + using Progress = serif::solver::NewtonProgress; + const Progress progress{ + .accepted_metric = {.residual_norm = 0.5, .merit = 0.125}, + .correction = {0.25}, + .next_step = 0.5, + .diagnostics = {.attempted_iterations = 1, .accepted_iterations = 1, .line_search_trials = 2, + .rejected_trials = 1, .initial_residual_norm = 2.0, .final_residual_norm = 0.5, + .convergence_threshold = 1.0e-8, .last_accepted_step = 0.25} + }; + auto previous = serialization::encode(progress); + auto& diagnostics = previous.children.at("diagnostics"); + // These are the exact observational fields written by the preceding numerical-history schema. + diagnostics.children.at("solve_seconds") = serialization::encode(17.0); + diagnostics.children.at("linear_solve_seconds") = serialization::encode(9.0); + for (const auto format : {serialization::Format::binary, serialization::Format::text}) { + const File file; + REQUIRE(serialization::write(file.path, serialization::pack(serialization::named("progress", previous)), {.format = format})); + auto reader = serialization::Reader::open(file.path); + REQUIRE(reader); + const auto restored = reader->read("progress"); + REQUIRE(restored); + CHECK_FALSE(serif::solver::validate_newton_progress(*restored, 1)); + CHECK(restored->correction == progress.correction); + CHECK(restored->next_step == progress.next_step); + CHECK(restored->diagnostics.initial_residual_norm == progress.diagnostics.initial_residual_norm); + CHECK(restored->diagnostics.attempted_iterations == 1); + const auto current = serialization::encode(*restored).children.at("diagnostics"); + CHECK(serialization::decode(current.children.at("solve_seconds")).value() == 0.0); + CHECK(serialization::decode(current.children.at("linear_solve_seconds")).value() == 0.0); + } +} diff --git a/tests/serialize/components.cpp b/tests/serialize/components.cpp new file mode 100644 index 00000000..5ca8bb49 --- /dev/null +++ b/tests/serialize/components.cpp @@ -0,0 +1,280 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "serif/utils/serialize/numerics.hpp" + +namespace { + namespace serialization = serif::utils::serialize; + namespace blocks = serif::discretization::blocks; + namespace dependencies = serif::utils::dependencies; + namespace model = serif::model; + namespace equations = serif::equations; + namespace dimensions = serif::dimensions; + namespace normalization = serif::solver::normalization; + using EOS = serif::eos::models::Polytrope; + using Coordinates = dependencies::KeyList; + using Rows = dependencies::KeyList; + using Layout = blocks::SystemLayout; + + /** @brief Exercise the complete concrete barotropic equation description without a mesh. */ + [[nodiscard]] auto physical_description(const EOS& eos) { + return model::EquilibriumEquations{ + .interior_equations = equations::EquationRegistry{equations::BarotropicDensityBalance{eos, dimensions::PressureValue{0.25}}, + equations::PrimalPoissonBalance{12.5}, equations::MassNormalization{4.0}}, + .shape_force = equations::SurfacePotentialBalance{}, + .surface_condition = serif::geometry::surface::Isobaric{dimensions::PressureValue{0.25}} + }; + } +} + +TEST_CASE("Serialized stellar physics reconstructs every concrete barotropic component", "[unit][serialize][serialize_components]") { + auto original = model::make_stellar_model(EOS{1.5, 2.75}, physical_description(EOS{1.5, 2.75}), + model::FixedTotalMass{dimensions::MassValue{4.0}}, + model::FixedAngularMomentum{{.angular_momentum = dimensions::AngularMomentumValue{0.3}, .axis = {0, 1, 0}, .center = {0.2, -0.4, 0.1}}}); + const auto node = serialization::encode(original); + const auto restored = serialization::decode>(node); + REQUIRE(restored); + CHECK(serialization::encode(*restored) == node); + CHECK(restored->equation_of_state().polytropic_constant() == 2.75); + CHECK(restored->template specification().center()[1] == -0.4); + + auto invalid = node; + invalid.children.at("eos").children.at("index") = serialization::encode(-2.0); + const auto rejected = serialization::decode>(invalid); + REQUIRE_FALSE(rejected); + CHECK(rejected.error().path == "eos"); +} + +TEST_CASE("Scalar equilibrium archives retain the density balance, primal gravity and the isobar level", "[unit][serialize][serialize_components]") { + const EOS eos{1.5, 2.75}; + const std::array axis{0.0, 1.0, 0.0}; + const std::array center{0.2, -0.4, 0.1}; + const auto original = model::make_stellar_model(eos, model::EquilibriumEquations{ + .interior_equations = equations::EquationRegistry{equations::BarotropicDensityBalance{eos, dimensions::PressureValue{0.25}, axis, center}, + equations::PrimalPoissonBalance{12.5}, equations::MassNormalization{4.0}, + equations::AngularMomentumConstraint{0.3, axis, center}}, + .shape_force = equations::SurfacePotentialBalance{axis, center}, + .surface_condition = serif::geometry::surface::Isobaric{dimensions::PressureValue{0.25}}}, + model::FixedTotalMass{dimensions::MassValue{4.0}}, + model::FixedAngularMomentum{{.angular_momentum = dimensions::AngularMomentumValue{0.3}, .axis = axis, .center = center}}); + const auto node = serialization::encode(original); + const auto restored = serialization::decode>(node); + REQUIRE(restored); + CHECK(serialization::encode(*restored) == node); + CHECK(restored->equations().interior_equations.template get().four_pi_G() == 12.5); + CHECK(restored->equations().shape_force.rotation().axis_origin() == center); + CHECK(restored->template specification().target().value() == 4.0); +} + +TEST_CASE("Barotropic density archives preserve surface pressure and a tilted displaced rotation axis", "[unit][serialize][serialize_components][barotropic_density]") { + using Balance = equations::BarotropicDensityBalance; + const std::array axis{0.0, 3.0, 4.0}, center{0.2, -0.4, 0.1}; + const Balance original{EOS{2.0, 0.4}, dimensions::PressureValue{0.05}, axis, center}; + const auto node = serialization::encode(original); + CHECK(node.type == "equation.barotropic_density"); + const auto restored = serialization::decode(node); + REQUIRE(restored); + CHECK(restored->surface_pressure().value() == 0.05); + CHECK(restored->equation_of_state().polytropic_index() == 2.0); + CHECK(restored->equation_of_state().polytropic_constant() == 0.4); + CHECK(restored->rotation().axis_origin() == center); + + equations::EquationPoint point; + point.fields.get() = 1.44; + point.fields.get() = -0.7; + point.fields.get() = -0.9; + point.fields.get() = 0.3; + point.position = {1.2, 0.7, -0.2}; + const auto expected = original.evaluate(point), actual = restored->evaluate(point); + // For n=2, K=0.4, P_surface=0.05: rho_surface=0.25 and A(rho)-A_surface=0.84. + CHECK(actual.residual == Catch::Approx(1.04 - 0.09 * original.rotation().centrifugal_potential(point.position)).margin(2.0e-15)); + CHECK(actual.residual == Catch::Approx(expected.residual).margin(2.0e-15)); + for (std::size_t index = 0; index < actual.field_derivatives.values.size(); ++index) { + CHECK(actual.field_derivatives.values[index] == Catch::Approx(expected.field_derivatives.values[index]).margin(2.0e-15)); + } + for (int component = 0; component < 3; ++component) { + CHECK(restored->rotation().angular_velocity_vector()[component] == Catch::Approx(original.rotation().angular_velocity_vector()[component]).margin(2.0e-15)); + CHECK(actual.position_derivative[component] == Catch::Approx(expected.position_derivative[component]).margin(2.0e-15)); + } + + // Deserialization must own formerly borrowed physics and reconstruct its finite-pressure reference. + const auto borrowed_record = [&]() { + const EOS temporary{2.0, 0.4}; + return serialization::encode(equations::BarotropicDensityBalance{model::borrow_eos(temporary), dimensions::PressureValue{0.05}, axis, center}); + }(); + using Borrowed = equations::BarotropicDensityBalance>; + const auto reowned = serialization::decode(borrowed_record); + REQUIRE(reowned); + CHECK(reowned->equation_of_state().get().polytropic_constant() == 0.4); + CHECK(reowned->evaluate(point).residual == Catch::Approx(expected.residual).margin(2.0e-15)); + CHECK_FALSE(serialization::decode(node)); + auto invalid = node; + invalid.children.at("surface_pressure") = serialization::encode(dimensions::PressureValue{-0.1}); + CHECK_FALSE(serialization::decode(invalid)); + invalid = node; + invalid.children.at("axis") = serialization::encode(std::array{0.0, 0.0, 0.0}); + CHECK_FALSE(serialization::decode(invalid)); +} + +TEST_CASE("Deferred model archives restore current physics without pretending to recreate a callback", "[unit][serialize][serialize_components]") { + const double coefficient = 2.0; + auto original = model::make_stellar_model(EOS{1.0, 1.0}, [coefficient](const auto& star) { + return model::EquilibriumEquations{equations::EquationRegistry{equations::PrimalPoissonBalance{coefficient * star.equation_of_state().polytropic_index()}}}; + }); + const auto node = serialization::encode(original); + CHECK(node.children.at("authored_definition").type == "unsupported"); + CHECK_FALSE(serialization::decode>(node)); + const auto restored = serialization::restore_stellar_model(node); + REQUIRE(restored); + CHECK(restored->equations().interior_equations.template get().four_pi_G() == coefficient); + CHECK(serialization::encode(restored->equations()) == node.children.at("resolved_equations")); +} + +TEST_CASE("Physical state serialization preserves coefficients and rejects mismatched coordinate schemas", "[unit][serialize][serialize_components]") { + const Layout layout{{2, 3, 2, 1}, {2, 3, 2, 1}}; + std::vector coefficients{0.0, -0.0, 1.0e-250, -1.0e200, 2.0, 0.2, -0.3, 1.5}; + const model::StellarState original{layout, coefficients}; + auto node = serialization::encode(original); + const auto restored = serialization::decode>(node); + REQUIRE(restored); + REQUIRE(restored->values().size() == coefficients.size()); + for (std::size_t index = 0; index < coefficients.size(); ++index) { + CHECK(std::bit_cast(restored->values()[index]) == std::bit_cast(coefficients[index])); + } + node.children.at("layout").children.at("values").children.at("0").children.at("id") = serialization::encode(std::string{"gravity.potential"}); + CHECK_FALSE(serialization::decode>(node)); + node = serialization::encode(original); + node.children.at("coefficients") = serialization::encode(std::vector{1.0}); + CHECK_FALSE(serialization::decode>(node)); +} + +TEST_CASE("Default numerical recipes retain resolved parameters rather than selecting fresh defaults", "[unit][serialize][serialize_components]") { + using PrimalValues = dependencies::KeyList; + using PrimalRows = dependencies::KeyList; + const blocks::SystemLayout primal_layout{{2, 3, 1, 1, 2}, {2, 3, 1, 1, 2}}; + const auto primal_inverse = serif::solver::preconditioner::make_primal_preconditioner(primal_layout); + const auto restored_primal = serialization::decode>(serialization::encode(primal_inverse)); + REQUIRE(restored_primal); + CHECK(serialization::encode(*restored_primal) == serialization::encode(primal_inverse)); + + auto options = serif::solver::SolverOptions<>{}; + options.method.maximum_iterations = 123; + options.linear_solver.restart_length = 31; + options.line_search.contraction = 0.4; + const auto restored_options = serialization::decode(serialization::encode(options)); + REQUIRE(restored_options); + CHECK(serialization::encode(*restored_options) == serialization::encode(options)); + + const auto numerical = normalization::make_normalization(normalization::NormalizationOptions{ + .scheme = normalization::LaneEmdenApproximation{.n = 2.5, .mass = dimensions::MassValue{4.}, .central_density = dimensions::DensityValue{2.}}, + .coordinates = normalization::Dimensionless{}, .gravitational_constant = dimensions::QuantityValue{0.75}}); + const auto restored_numerical = serialization::decode>(serialization::encode(numerical)); + REQUIRE(restored_numerical); + CHECK(serialization::encode(*restored_numerical) == serialization::encode(numerical)); + const auto factors = normalization::make_diagonal_normalization({2., 4.}, {0.1, 0.3}); + REQUIRE(factors); + auto saved_factors = serialization::encode(*factors); + REQUIRE(serialization::decode(saved_factors)); + saved_factors.children.at("state") = serialization::encode(std::vector{-1., 4.}); + CHECK_FALSE(serialization::decode(saved_factors)); +} + +TEST_CASE("Surface representations retain physical centers and harmonic selection", "[unit][serialize][serialize_components]") { + using namespace serif::geometry::deformation; + const DomainDeformation original{SphericalHarmonicSurface{.reference_center = {0.1, -0.2, 0.3}, .maximum_degree = 8, .maximum_azimuthal_order = 3}, + PowerLawRadialExtension{.radial_power = 1.75}, FixedInfinityRadialExtension{}}; + const auto restored = serialization::decode>(serialization::encode(original)); + REQUIRE(restored); + CHECK(*restored == original); + const auto nodal = serialization::decode(serialization::encode(NodalRadialSurface{{1., 2., 3.}})); + REQUIRE(nodal); + CHECK(nodal->reference_center[2] == 3.0); +} + +TEST_CASE("Quantity serializers reject incorrect dimensions and unknown schema versions", "[unit][serialize][serialize_components]") { + auto node = serialization::encode(dimensions::MassValue{7.0}); + CHECK_FALSE(serialization::decode(node)); + node.version = 17; + CHECK_FALSE(serialization::decode(node)); +} + +TEST_CASE("Quadrature role controls survive serialization without compiled caches", "[unit][serialize][serialize_components]") { + namespace quadrature = serif::discretization::quadrature; + quadrature::RuleSet rules; + rules.set_role_control(quadrature::QuadratureRole::projection, {.fixed_order = 12, .boost = 2}); + rules.set_fallback_control({.boost = 3}); + const quadrature::Policy original{rules}; + const auto restored = serialization::decode(serialization::encode(original)); + REQUIRE(restored); + CHECK(restored->rules().role_control(quadrature::QuadratureRole::projection).fixed_order == 12); + CHECK(restored->rules().fallback_control().boost == 3); +} + +TEST_CASE("Prescribed input serialization distinguishes owned values from borrowed runtime objects", "[unit][serialize][serialize_components]") { + const auto inputs = dependencies::make_dependency_context(dependencies::own(0.3)); + const auto restored = serialization::decode>(serialization::encode(inputs)); + REQUIRE(restored); + CHECK(restored->template get() == 0.3); + double prescribed = 0.4; + const auto borrowed = dependencies::make_dependency_context(dependencies::borrow(prescribed)); + const auto node = serialization::encode(borrowed); + CHECK(node.children.at("resources").children.at("0").children.at("reconstruction").type == "unsupported"); + CHECK_FALSE(serialization::decode>(node)); +} + +TEST_CASE("Decoded numerical options reject invalid budgets and approximation controls", "[unit][serialize][serialize_components]") { + auto options = serialization::encode(serif::solver::SolverOptions<>{}); + options.children.at("method").children.at("maximum_iterations") = serialization::encode(-3); + const auto invalid_solver = serialization::decode>(options); + REQUIRE_FALSE(invalid_solver); + CHECK(invalid_solver.error().path == "method"); + + using AMG = serif::solver::preconditioner::strategies::inverse::FixedAMG; + CHECK_FALSE(serialization::decode(serialization::encode(AMG{.cycles = 0}))); + using Surface = serif::geometry::deformation::SphericalHarmonicSurface; + CHECK_FALSE(serialization::decode(serialization::encode(Surface{.maximum_degree = 2, .maximum_azimuthal_order = 3}))); + using Integration = serif::utils::laneemden::IntegrationOptions; + CHECK_FALSE(serialization::decode(serialization::encode(Integration{.maximum_steps = 0}))); +} + +TEST_CASE("Custom quadrature form controls remain inspectable when their descriptor cannot be reowned", "[unit][serialize][serialize_components]") { + namespace quadrature = serif::discretization::quadrature; + quadrature::RuleSet rules; + rules.set_form_control({.name = "custom_mass", .trial = {.field = {"density"}, .name = "mass"}, .test = {.field = {"density"}, .name = "mass"}}, {.boost = 7}); + const auto node = serialization::encode(rules); + CHECK(node.children.at("forms").children.at("reconstruction").type == "unsupported"); + const auto boost = serialization::decode(node.children.at("forms").children.at("0").children.at("control").children.at("boost")); + REQUIRE(boost); + CHECK(*boost == 7); + CHECK_FALSE(serialization::decode(node)); +} + +TEST_CASE("Configured discretizations preserve owned normalization inputs and resolved choices", "[unit][serialize][serialize_components]") { + auto star = model::make_stellar_model(EOS{1.5, 2.75}, physical_description(EOS{1.5, 2.75}), + model::FixedTotalMass{dimensions::MassValue{4.0}}); + auto numerical = normalization::NormalizationOptions{ + .scheme = normalization::LaneEmdenApproximation{.central_density = dimensions::DensityValue{2.0}}, + .sources = dependencies::make_dependency_context(dependencies::own(0.3))}; + auto original = model::make_discretization(model::DiscretizationOptions{ + .model = std::move(star), .finite_elements = std::vector{0.0, 0.5, 1.0}, .normalization = std::move(numerical)}); + const auto node = serialization::encode(original); + CHECK(node.type == "model.configured_discretization"); + const auto restored = serialization::decode(node); + REQUIRE(restored); + CHECK(serialization::encode(*restored) == node); + CHECK(restored->normalization.scheme.mass == dimensions::MassValue{4.0}); + CHECK(restored->normalization.sources.template get() == 0.3); +} diff --git a/tests/serialize/meson.build b/tests/serialize/meson.build new file mode 100644 index 00000000..1716cfa2 --- /dev/null +++ b/tests/serialize/meson.build @@ -0,0 +1,24 @@ +serif_serialization_tests = executable( + 'serif_serialization_tests', + ['archive.cpp', 'components.cpp', 'top.cpp'], + dependencies: [serif_core_dep, catch2_dep], +) +test('serif_serialization_tests', serif_serialization_tests, suite: 'serialize', timeout: 120) + +serif_mfem_serialization_tests = executable( + 'serif_mfem_serialization_tests', + ['mfem.cpp', 'restart.cpp', 'top_mfem.cpp'], + dependencies: [serif_dep, catch2_dep, stroid_dep], + include_directories: include_directories('../include'), + build_rpath: mfem_runtime_prefix == '' ? '' : mfem_runtime_prefix / 'lib', +) +test('serif_mfem_serialization_tests', serif_mfem_serialization_tests, args: ['~[moving_restart]'], suite: 'serialize', timeout: 300, is_parallel: false) +test('serif_moving_restart_tests', serif_mfem_serialization_tests, args: ['[moving_restart]'], suite: 'serialize', timeout: 1800, is_parallel: false) + +serialization_fixture = executable('serialization_fixture', 'write_fixture.cpp', dependencies: serif_core_dep) +test('serif_python_archive_reader', python_build, + args: [files('python_reader.py'), serialization_fixture, files('../../tools/read_serif_archive.py')], + suite: 'serialize', timeout: 30) + +# Optional actual TOP-reader check: run top_reader.py in a compatible TOP environment. +top_export_fixture = executable('top_export_fixture', 'write_top_fixture.cpp', dependencies: serif_core_dep) diff --git a/tests/serialize/mfem.cpp b/tests/serialize/mfem.cpp new file mode 100644 index 00000000..01d2163a --- /dev/null +++ b/tests/serialize/mfem.cpp @@ -0,0 +1,308 @@ +#include +#include +#include +#include +#include + +#include + +#include "serif/backend/mfem/serialize/configuration.hpp" +#include "serif/backend/mfem/serialize/options.hpp" +#include "serif/backend/mfem/discretization/element_coordinates.hpp" +#include "serif/tests/solver/stellar_fixture.hpp" +#include "serif/tests/nonconforming.hpp" + +namespace { + namespace fixture = serif::tests::solver; + namespace archive = serif::utils::serialize; + namespace backend = serif::backend::mfem::serialize; + using Discretization = decltype(fixture::make_discretization(std::declval>())); + + /** @brief Exercise simplex orientation independently of the moving spherical mesh generator. */ + struct FixedResources { + ::mfem::Mesh mesh; + ::mfem::H1_FECollection scalar_collection{fixture::Density::FunctionalSpace::order, 3}; + ::mfem::H1_FECollection displacement_collection{fixture::Displacement::FunctionalSpace::order, 3}; + ::mfem::H1_FECollection potential_collection{fixture::Potential::FunctionalSpace::order, 3}; + ::mfem::FiniteElementSpace density_space{&mesh, &scalar_collection}; + ::mfem::FiniteElementSpace displacement_space{&mesh, &displacement_collection, 3, ::mfem::Ordering::byVDIM}; + ::mfem::FiniteElementSpace potential_space{&mesh, &potential_collection}; + ::mfem::GridFunction compactification{&density_space}; + serif::discretization::backend::mfem::TermBinding density{density_space}; + serif::discretization::backend::mfem::TermBinding displacement{displacement_space}; + serif::discretization::backend::mfem::TermBinding potential{potential_space}; + serif::mapping::DomainMapper mapper{{}, std::make_unique(serif::utils::user_options::KelvinCompactificationOptions{})}; + + /** @brief Finalize a supported serial mesh before constructing any term binding. */ + explicit FixedResources(::mfem::Element::Type geometry) : mesh(make_mesh(geometry)) { + compactification = 0.0; + } + + /** @brief Retain nontrivial support and high-order mesh coordinates in the small fixed-domain case. */ + static ::mfem::Mesh make_mesh(::mfem::Element::Type geometry) { + auto mesh = ::mfem::Mesh::MakeCartesian3D(2, 1, 1, geometry); + mesh.GetElement(mesh.GetNE() - 1)->SetAttribute(3); + mesh.SetCurvature(2); + return mesh; + } + }; + + /** @brief Ensure restoration cannot accidentally borrow the application's original resources. */ + archive::Node saved_star(backend::SaveOptions options = {}) { + const auto resources = fixture::make_resources(1.25, 0, 2); + const auto discretization = fixture::make_discretization(resources, 2); + return backend::save_discretization(discretization, options); + } + + /** @brief Keep numerical reference choices together with the model and its native resources. */ + auto make_configuration() { + const auto resources = fixture::make_resources(1.25, 0, 2); + const serif::solver::normalization::NormalizationOptions normalization{ + .scheme = serif::solver::normalization::LaneEmdenApproximation{ + .n = 1.0, .mass = serif::dimensions::MassValue{1.0}, .central_density = serif::dimensions::DensityValue{1.0}} + }; + return serif::model::make_discretization(serif::model::DiscretizationOptions{ + .model = fixture::make_model(), .finite_elements = fixture::make_discretization(resources, 2), .normalization = normalization + }); + } + + /** @brief Compare native element coefficients after restoring their exact basis and orientation. */ + template + void compare_field(const Original& original, const Restored& restored) { + const auto& before = original.template binding(); + const auto& after = restored.template binding(); + REQUIRE(before.size() == after.size()); + ::mfem::Vector coefficients(before.size()); + for (int dof = 0; dof < coefficients.Size(); ++dof) { + coefficients[dof] = 0.125 * static_cast(dof - 7); + } + coefficients[0] = -0.0; + const auto before_elements = before.validated_elements(); + const auto after_elements = after.validated_elements(); + for (int element = 0; element < before.mesh().GetNE(); ++element) { + ::mfem::Vector first, second; + before_elements.element_values(element, coefficients, first); + after_elements.element_values(element, coefficients, second); + REQUIRE(first.Size() == second.Size()); + for (int local = 0; local < first.Size(); ++local) { + CHECK(std::bit_cast(first[local]) == std::bit_cast(second[local])); + } + } + } +} + +TEST_CASE("Fixed MFEM discretizations retain simplex and tensor element orientation", "[unit][serialize][mfem_archive]") { + namespace dependencies = serif::utils::dependencies; + + for (const ::mfem::Element::Type geometry : {::mfem::Element::HEXAHEDRON, ::mfem::Element::TETRAHEDRON}) { + const auto resources = std::make_shared(geometry); + const auto bindings = dependencies::make_dependency_context(dependencies::borrow(resources->density), + dependencies::borrow(resources->potential), dependencies::borrow(resources->displacement)); + const auto original = serif::model::backend::mfem::make_stellar_discretization(resources, bindings, resources->compactification, resources->mapper); + const archive::Node record = backend::save_discretization(original); + auto restored = backend::restore_discretization(record); + INFO((restored ? "restored" : restored.error().path + ": " + restored.error().message)); + REQUIRE(restored); + CHECK(backend::save_discretization(*restored) == record); + compare_field(original, *restored); + compare_field(original, *restored); + compare_field(original, *restored); + } +} + +TEST_CASE("MFEM archives own exact meshes bases support and oriented coefficient interpretations", "[unit][serialize][mfem_archive]") { + const auto resources = fixture::make_resources(1.25, 0, 2); + const auto original = fixture::make_discretization(resources, 2); + const archive::Node record = backend::save_discretization(original); + auto restored = backend::restore_discretization(record); + INFO((restored ? "restored" : restored.error().path + ": " + restored.error().message)); + REQUIRE(restored); + CHECK(backend::save_discretization(*restored) == record); + CHECK(&restored->template binding().mesh() != resources->mesh.mesh.get()); + CHECK(restored->deformation.reference.scalar_space.FEColl() == restored->deformation.reference.displacement_space.FEColl()); + CHECK(restored->deformation.reference.displacement_space.GetOrdering() == ::mfem::Ordering::byNODES); + CHECK(restored->template binding().space().GetOrdering() == ::mfem::Ordering::byVDIM); + CHECK(restored->deformation.model == original.deformation.model); + compare_field(original, *restored); + compare_field(original, *restored); + compare_field(original, *restored); + compare_field(original, *restored); + compare_field(original, *restored); + + // Nothing from the writer's resource owner survives this helper call. + const archive::Node independent_record = saved_star(); + const auto independent = backend::restore_discretization(independent_record); + REQUIRE(independent); + CHECK(backend::save_discretization(*independent) == independent_record); +} + +TEST_CASE("MFEM archives reject incompatible numbering and resource associations", "[unit][serialize][mfem_archive]") { + archive::Node record = saved_star(); + auto& resources = record.children.at("resources"); + SECTION("Mesh contents disagree with their content identity") { + auto& native = resources.children.at("meshes").children.at("0").children.at("native"); + std::get(native.value).append("\n"); + } + SECTION("A matching fingerprint does not make malformed native mesh text valid") { + auto& mesh = resources.children.at("meshes").children.at("0"); + const std::string malformed = "This is not an MFEM mesh.\n"; + mesh.children.at("native") = archive::encode(malformed); + mesh.children.at("identity") = archive::encode(archive::fingerprint(malformed)); + const auto restored = backend::restore_discretization(record); + REQUIRE_FALSE(restored); + CHECK(restored.error().message.starts_with("Cannot reconstruct saved MFEM resources:")); + return; + } + SECTION("A collection's basis changes without changing its nominal polynomial order") { + const auto space = archive::decode(record.children.at("bindings").children.at("0").children.at("space")); + REQUIRE(space); + const auto collection_index = archive::decode( + resources.children.at("spaces").children.at(std::to_string(*space)).children.at("collection")); + REQUIRE(collection_index); + auto& collection = resources.children.at("collections").children.at(std::to_string(*collection_index)); + const auto family = archive::decode(collection.children.at("family")); + REQUIRE(family); + REQUIRE(*family == "H1"); + collection.children.at("closed_basis") = archive::encode(static_cast(::mfem::BasisType::ClosedUniform)); + } + SECTION("An element's signed local numbering changes") { + auto& numbering = resources.children.at("spaces").children.at("0").children.at("element_numbering"); + std::get>(numbering.value)[1] = -100; + } + SECTION("A term is connected to a missing space") { + record.children.at("bindings").children.at("0").children.at("space") = archive::encode(std::uint64_t{900}); + } + SECTION("A term's reduced map is changed") { + auto& reduction = record.children.at("bindings").children.at("0").children.at("coordinates").children.at("full_to_reduced"); + std::get>(reduction.value)[0] = 999; + } + SECTION("A deformation companion is not a displacement space") { + record.children.at("reference").children.at("displacement_space") = record.children.at("reference").children.at("scalar_space"); + } + SECTION("Native MFEM version differs") { + resources.children.at("mfem_version") = archive::encode(std::string{"different"}); + } + CHECK_FALSE(backend::restore_discretization(record)); +} + +TEST_CASE("An omitted MFEM mesh must be resolved by its exact saved identity", "[unit][serialize][mfem_archive]") { + const auto resources = fixture::make_resources(1.25, 0, 2); + const auto original = fixture::make_discretization(resources, 2); + const archive::Node record = backend::save_discretization(original, {.embed_mesh = false}); + CHECK_FALSE(backend::restore_discretization(record)); + std::map> meshes; + for (const ::mfem::Mesh* mesh : {resources->mesh.mesh.get(), resources->mesh.reference_mesh.get()}) { + const std::string text = backend::mesh_text(*mesh); + meshes.emplace(archive::fingerprint(text), text); + } + auto restored = backend::restore_discretization(record, [&](std::string_view identity) -> archive::Result { + const auto found = meshes.find(identity); + if (found == meshes.end()) { + return std::unexpected(archive::Error{"mesh", "Unknown mesh identity."}); + } + return found->second; + }); + REQUIRE(restored); + CHECK(backend::save_discretization(*restored, {.embed_mesh = false}) == record); + CHECK_FALSE(backend::restore_discretization(record, [](std::string_view) -> archive::Result { return std::string{"wrong mesh"}; })); +} + +TEST_CASE("MFEM assembly options retain quadrature controls and reject unknown contraction modes", "[unit][serialize][mfem_archive]") { + namespace quadrature = serif::discretization::quadrature; + using Options = serif::operators::backend::mfem::VolumeOptions; + + quadrature::RuleSet rules; + rules.set_role_control(quadrature::QuadratureRole::discretization, {.fixed_order = 9, .boost = 2}); + const Options options{.quadrature_policy = quadrature::Policy{rules}, .contraction_mode = serif::operators::backend::mfem::ContractionMode::dense}; + archive::Node record = archive::encode(options); + auto restored = archive::decode(record); + REQUIRE(restored); + CHECK(archive::encode(*restored) == record); + record.children.at("contraction") = archive::encode(500); + CHECK_FALSE(archive::decode(record)); +} + +TEST_CASE("Configured MFEM models restore their full numerical prescription with independent ownership", "[unit][serialize][mfem_archive]") { + using Configuration = decltype(make_configuration()); + + archive::Node record; + std::weak_ptr original_owner; + { + const auto original = make_configuration(); + original_owner = original.finite_elements.owner; + record = archive::encode(original); + } + REQUIRE(original_owner.expired()); + REQUIRE(record.type == "model.configured_discretization"); + const auto restored = backend::restore_configuration(record); + INFO((restored ? "restored" : restored.error().path + ": " + restored.error().message)); + REQUIRE(restored); + const archive::Node saved_again = archive::encode(*restored); + CHECK(saved_again.children.at("finite_elements") == record.children.at("finite_elements")); + CHECK(saved_again.children.at("normalization") == record.children.at("normalization")); + CHECK(saved_again.children.at("preconditioner") == record.children.at("preconditioner")); + CHECK(saved_again.children.at("model").children.at("resolved_equations") == record.children.at("model").children.at("resolved_equations")); + + SECTION("Unknown configuration versions cannot be restored") { + record.version = 2; + } + SECTION("Missing numerical choices cannot silently become defaults") { + record.children.erase("normalization"); + } + SECTION("Unsupported preconditioners cannot silently become no preconditioning") { + record.children.at("preconditioner") = archive::Node{.type = "unsupported", .value = std::string{"Custom preconditioner has no serializer."}}; + } + CHECK_FALSE(backend::restore_configuration(record)); +} + +TEST_CASE("MFEM archives refuse native parsing when the application selects abort on error", "[unit][serialize][mfem_archive]") { + const archive::Node record = saved_star(); + struct RestoreErrorAction { + ::mfem::ErrorAction previous{::mfem::get_error_action()}; + /** @brief Restore the application's prior native error policy even if an assertion stops the test. */ + ~RestoreErrorAction() { ::mfem::set_error_action(previous); } + } restore_error_action; + ::mfem::set_error_action(::mfem::MFEM_ERROR_ABORT); + const auto restored = backend::restore_discretization(record); + REQUIRE_FALSE(restored); + CHECK(restored.error().message.find("requires MFEM_ERROR_THROW") != std::string::npos); + CHECK(::mfem::get_error_action() == ::mfem::MFEM_ERROR_ABORT); +} + +TEST_CASE("Nonconforming STROID archives retain independent coordinates after original owners expire", "[unit][serialize][mfem_archive][nonconforming]") { + archive::Node record; + std::weak_ptr original_owner; + const auto restored = [&] { + const auto resources = std::make_shared(serif::tests::nonconforming::mesh(2, 0, 2)); + original_owner = resources; + const auto original = fixture::make_discretization(resources, 2); + REQUIRE(resources->potential_space.GetTrueVSize() < resources->potential_space.GetVSize()); + record = backend::save_discretization(original); + auto result = backend::restore_discretization(record); + if (!result) { + INFO(result.error().path << ": " << result.error().message); + } + REQUIRE(result); + compare_field(original, *result); + compare_field(original, *result); + compare_field(original, *result); + CHECK(&result->template binding().mesh() != resources->mesh.mesh.get()); + return std::move(*result); + }(); + CHECK(original_owner.expired()); + const auto& potential = restored.template binding(); + REQUIRE(potential.space().GetTrueVSize() < potential.space().GetVSize()); + CHECK(potential.space().GetConformingProlongation() != nullptr); + CHECK(restored.deformation.reference.logical_mesh.Nonconforming()); + CHECK(backend::save_discretization(restored) == record); + mfem::Vector coefficients(potential.size()); + for (int index = 0; index < coefficients.Size(); ++index) { + coefficients[index] = 0.125 * (index - 7); + } + const mfem::Vector local = potential.scatter(coefficients); + const mfem::Vector independent = potential.gather(local); + REQUIRE(independent.Size() == coefficients.Size()); + for (int index = 0; index < coefficients.Size(); ++index) { + CHECK(independent[index] == coefficients[index]); + } +} diff --git a/tests/serialize/python_reader.py b/tests/serialize/python_reader.py new file mode 100644 index 00000000..a264d41c --- /dev/null +++ b/tests/serialize/python_reader.py @@ -0,0 +1,53 @@ +"""Verify the public format through an independent Python implementation.""" + +import importlib.util +import math +from pathlib import Path +import struct +import subprocess +import sys +import tempfile + + +def main(): + """Check native/text equality and CSV output using files produced by C++.""" + sys.dont_write_bytecode = True + writer, reader_path = sys.argv[1:] + spec = importlib.util.spec_from_file_location("serif_archive", reader_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + with tempfile.TemporaryDirectory(prefix="serif-python-reader-") as directory: + subprocess.run([writer, directory], check=True) + representations = [] + for encoding in ("binary", "text"): + path = Path(directory) / f"{encoding}.serif" + with module.Archive(path) as archive: + values = archive.read("values") + assert len(values) == 5 + assert math.copysign(1.0, values[0]) < 0 + assert values[1] == 1.2345678901234567 + assert values[2] == float.fromhex("0x0.0000000000001p-1022") + assert values[3] == math.inf + assert struct.pack(" +#include +#include +#include +#include +#include + +#include +#include +#include "mfem/general/communication.hpp" +#include "serif/backend/mfem/serialize/configuration.hpp" +#include "serif/solver/equilibrium.hpp" +#include "serif/backend/mfem/solver/seed/state.hpp" +#include "serif/solver/seed/polytrope.hpp" +#include "serif/tests/solver/stellar_fixture.hpp" +#include "serif/utils/serialize/archive.hpp" +#include "serif/utils/serialize/solver.hpp" + +namespace { + namespace serialization = serif::utils::serialize; + namespace backend_serialization = serif::backend::mfem::serialize; + namespace solver = serif::solver; + namespace fixture = serif::tests::solver; + + /** @brief Keep independently running tests from sharing an archive and remove it on every exit path. */ + struct TemporaryArchive { + std::filesystem::path path = std::filesystem::temp_directory_path() / + ("serif-newton-restart-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + ".serif"); + /** @brief Test failures must not leave temporary scientific files behind. */ + ~TemporaryArchive() { + std::error_code error; + std::filesystem::remove(path, error); + } + }; +} + +TEST_CASE("A moving stellar checkpoint survives destruction, file loading and Newton continuation", "[serialize][restart][moving_restart]") { + if (!::mfem::Mpi::IsInitialized()) { + ::mfem::Mpi::Init(); + } + using Model = decltype(fixture::make_model()); + std::weak_ptr original_resources; + const auto saved = [&] { + const Model model = fixture::make_model(); + const auto radial = solver::seed::make_polytropic_seed(model); + REQUIRE(radial); + const auto finite_elements = serif::model::make_stellar_discretization(model, {.radius = radial->scales.radius}, + serif::geometry::deformation::DomainDeformation{ + .surface = {.maximum_degree = 0}, .stellar_interior = {}, .vacuum = {}}); + original_resources = finite_elements.owner; + const auto initial = solver::seed::backend::mfem::make_polytropic_state(model, finite_elements, *radial); + REQUIRE(initial); + const auto scales = solver::normalization::prepare_stellar_reference_scales( + serif::dimensions::MassValue{1.0}, serif::dimensions::LengthValue{1.0}); + REQUIRE(scales); + const solver::normalization::NormalizationOptions normalization{.scheme = *scales}; + const auto configuration = serif::model::make_discretization(serif::model::DiscretizationOptions{ + .model = model, .finite_elements = finite_elements, .normalization = normalization}); + const solver::SolverOptions options{.method = {.relative_tolerance = 1.0e-8, .absolute_tolerance = 1.0e-10, + .maximum_iterations = 15, .linear_solve = {.relative_tolerance = 1.0e-3, .maximum_iterations = 200}}}; + const auto uninterrupted = solver::solve_equilibrium(configuration, *initial, options); + INFO(uninterrupted.message()); + REQUIRE(uninterrupted.converged()); + const auto stop = [](const solver::SolveEvent& event, const auto&, const auto&) { + return event.kind == solver::EventKind::accepted ? solver::SolveControl::stop : solver::SolveControl::continue_solve; + }; + const auto interrupted = solver::solve_equilibrium(configuration, *initial, options, stop); + INFO(interrupted.message()); + REQUIRE(interrupted.status() == solver::SolveStatus::stopped); + REQUIRE(interrupted.has_checkpoint()); + CHECK(interrupted.diagnostics().accepted_iterations == 1); + const auto checkpoint = interrupted.checkpoint(); + CHECK(std::ranges::any_of(checkpoint.state.template get(), [](double value) { return value != 0.0; })); + auto contents = serialization::pack(configuration, options, interrupted); + return std::tuple{std::move(contents), uninterrupted.state(), uninterrupted.diagnostics(), + std::type_identity>{}, std::type_identity>{}}; + }(); + REQUIRE(original_resources.expired()); + using State = std::remove_cvref_t(saved))>; + using Checkpoint = solver::NewtonCheckpoint; + using Configuration = typename std::remove_cvref_t(saved))>::type; + using Options = typename std::remove_cvref_t(saved))>::type; + const TemporaryArchive archive; + const auto written = serialization::write(archive.path, std::get<0>(saved), {.author = "SERiF regression", .comment = "Accepted moving-star Newton boundary"}); + REQUIRE(written); + auto reader = serialization::Reader::open(archive.path); + REQUIRE(reader); + const auto checkpoint = reader->read("solution/checkpoint"); + if (!checkpoint) { + INFO(checkpoint.error().path << ": " << checkpoint.error().message); + } + REQUIRE(checkpoint); + const TemporaryArchive text_archive; + REQUIRE(serialization::write(text_archive.path, serialization::pack(*checkpoint), {.format = serialization::Format::text})); + auto text_reader = serialization::Reader::open(text_archive.path); + REQUIRE(text_reader); + const auto text_checkpoint = text_reader->read(); + REQUIRE(text_checkpoint); + CHECK(serialization::encode(*text_checkpoint) == serialization::encode(*checkpoint)); + const auto configuration_node = reader->node("configuration"); + REQUIRE(configuration_node); + const auto configuration = backend_serialization::restore_configuration(*configuration_node); + if (!configuration) { + INFO(configuration.error().path << ": " << configuration.error().message); + } + REQUIRE(configuration); + const auto options = reader->read(); + REQUIRE(options); + const auto resumed = solver::resume_equilibrium(*configuration, *checkpoint, *options); + INFO(resumed.message()); + REQUIRE(resumed.converged()); + const State& expected = std::get<1>(saved); + REQUIRE(resumed.state().values().size() == expected.values().size()); + for (std::size_t index = 0; index < expected.values().size(); ++index) { + CHECK(resumed.state().values()[index] == Catch::Approx(expected.values()[index]).margin(2.0e-10).epsilon(2.0e-10)); + } + const solver::SolveDiagnostics& diagnostics = std::get<2>(saved); + CHECK(resumed.diagnostics().initial_residual_norm == diagnostics.initial_residual_norm); + CHECK(resumed.diagnostics().convergence_threshold == diagnostics.convergence_threshold); + CHECK(resumed.diagnostics().accepted_iterations == diagnostics.accepted_iterations); + CHECK(resumed.diagnostics().line_search_trials == diagnostics.line_search_trials); + CHECK(resumed.diagnostics().rejected_trials == diagnostics.rejected_trials); + CHECK(resumed.diagnostics().total_linear_iterations == diagnostics.total_linear_iterations); + CHECK(reader->contains("solution/model")); + CHECK(reader->contains("solution/reference_scales")); + CHECK(reader->contains("solution/coordinates")); + CHECK(reader->read("solution/checkpoint")); + auto corrupted = serialization::encode(*checkpoint); + corrupted.children.at("progress").children.at("correction") = serialization::encode(std::vector{}); + CHECK_FALSE(serialization::decode(corrupted)); + corrupted = serialization::encode(*checkpoint); + corrupted.children.at("strategy") = serialization::encode(std::string{"unknown_stateful_strategy"}); + CHECK_FALSE(serialization::decode(corrupted)); +} diff --git a/tests/serialize/top.cpp b/tests/serialize/top.cpp new file mode 100644 index 00000000..a1efa688 --- /dev/null +++ b/tests/serialize/top.cpp @@ -0,0 +1,267 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include "serif/utils/serialize/top.hpp" + +namespace { + namespace serialization = serif::utils::serialize; + constexpr double pi = std::numbers::pi; + const double monopole_basis = 1/std::sqrt(4*pi); + struct Directory { + std::filesystem::path path; + Directory() { + static std::atomic next{0}; + path = std::filesystem::temp_directory_path()/("serif-top-test-"+ + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())+"-"+std::to_string(next++)); + } + ~Directory() { std::error_code error; std::filesystem::remove_all(path, error); } + }; + serialization::TopPolytropeSource sphere() { + return {.polytropic_index=1., .gravitational_constant=1., .mass=4./pi, .angular_velocity=0., + .central_density=1., .central_enthalpy=4./pi, + .surface_radius=[](double, double)->serialization::Result { return 1.; }, + .enthalpy=[](double radius, double, double)->serialization::Result { + if (radius==0.) { return 4./pi; } + if (radius==1.) { return 0.; } + return (4./pi)*std::sin(pi*radius)/(pi*radius); + }}; + } + serialization::TopExportOptions small_options() { + return {.radial_points=32, .maximum_degree=4, .angular_points=12, .azimuthal_points=4}; + } + struct Data { + int nr{}, degree{}; + std::vector scales, surface; + std::vector> h, hz, hzz; + }; + Data read(const std::filesystem::path& path) { + Data result; + std::ifstream scales(path/"lambda_eps"); + double value; + while (scales >> value) { result.scales.push_back(value); } + REQUIRE(result.scales.size()==9); + std::ifstream surface(path/"domains_boundaries"); + surface >> result.degree; + for (int l=0; l<=result.degree; ++l) { surface >> value; CHECK(value==0.); } + for (int l=0; l<=result.degree; ++l) { surface >> value; result.surface.push_back(value); } + std::ifstream fields(path/"enthalpy_et_der"); + int count; + fields >> result.nr >> count; + REQUIRE(count==result.degree+1); + std::string line; + std::getline(fields, line); std::getline(fields, line); + int index; + for (int k=0; k<=result.degree/2; ++k) { + fields >> index; REQUIRE(index==k+1); + for (int i=0; i> value; } + } + auto block = [&]() { + std::getline(fields, line); std::getline(fields, line); + std::vector> values(result.degree+1, std::vector(result.nr)); + for (int l=0; l<=result.degree; l+=2) { + fields >> index; REQUIRE(index==l); + for (double& sample : values[l]) { fields >> sample; REQUIRE(std::isfinite(sample)); } + } + return values; + }; + result.h=block(); result.hz=block(); result.hzz=block(); + REQUIRE(fields.good()); + return result; + } + bool close(double actual, double expected, double tolerance) { + return std::abs(actual-expected)<=tolerance*std::max(1., std::abs(expected)); + } +} + +TEST_CASE("TOP export reproduces the analytic n1 sphere and Chebyshev derivatives", "[serialize][top]") { + const Directory directory; + const auto result=serialization::export_top(directory.path, sphere(), small_options()); + if (!result) { INFO(result.error().path << ": " << result.error().message); } + REQUIRE(result); + CHECK(close(result->lambda, pi*pi, 2e-14)); + CHECK(close(result->alpha, pi*pi/3, 2e-14)); + CHECK(close(result->angular_frequency_unit, std::sqrt(4*pi), 2e-14)); + CHECK(std::abs(result->flattening)<1e-14); + CHECK(result->maximum_relative_surface_projection_error<1e-13); + CHECK(result->minimum_enthalpy==0.); + const auto data=read(directory.path); + CHECK(close(data.surface[0], 1/monopole_basis, 2e-14)); + CHECK(close(data.h[0][0]*monopole_basis, 1., 2e-14)); + CHECK(data.h[0].back()==0.); + CHECK(std::abs(data.hz[0][0]*monopole_basis)<1e-11); + CHECK(close(data.hzz[0][0]*monopole_basis, -pi*pi/3, 1e-8)); + CHECK(close(data.hz[0].back()*monopole_basis, -1., 1e-11)); + CHECK(close(data.hzz[0].back()*monopole_basis, 2., 1e-8)); + for (int i=0; iserialization::Result { return radius(mu); }, + .enthalpy=[&](double r, double mu, double)->serialization::Result { + const double surface=radius(mu); + if (r==0.) { return 5.; } + if (r==surface) { return 0.; } + double lower=0., upper=1.; + for (int step=0; step<60; ++step) { + const double z=(lower+upper)/2, a=(5*z*z*z-3*std::pow(z,5))/2; + if (1.76*z+a*(surface-1.76)source_equatorial_radius, 2.12, 1e-14)); + CHECK(close(result->equatorial_radius, 2.12, 1e-14)); + CHECK(close(result->polar_radius, 1.76, 1e-14)); + CHECK(result->maximum_relative_surface_projection_error<1e-13); + CHECK(close(result->rotation_over_kepler/std::sqrt(3*result->alpha), .31/std::sqrt(24*pi), 1e-14)); + const auto data=read(directory.path); + CHECK(close(data.surface[0], 2/(2.12*monopole_basis), 1e-13)); + CHECK(close(data.surface[2], -.24*std::sqrt(4*pi/5)/2.12, 1e-13)); + for (int i=0; iserialization::Result { return 3.; }; + scaled.enthalpy=[source](double r, double mu, double phi)->serialization::Result { return 90*source.enthalpy(r/3,mu,phi).value(); }; + const auto a=serialization::export_top(first.path,source,small_options()); + const auto b=serialization::export_top(second.path,scaled,small_options()); + REQUIRE(a); REQUIRE(b); + CHECK(close(a->lambda,b->lambda,1e-13)); CHECK(close(a->alpha,b->alpha,1e-13)); + CHECK(close(a->rotation,b->rotation,1e-13)); CHECK(close(a->rotation_over_kepler,b->rotation_over_kepler,1e-13)); + CHECK(close(b->angular_frequency_unit/a->angular_frequency_unit,std::sqrt(10.),1e-13)); +} + +TEST_CASE("TOP rejects unsupported backgrounds and reports sampler failures before publication", "[serialize][top]") { + const Directory directory; + auto source=sphere(); auto options=small_options(); + SECTION("odd angular degree") { options.maximum_degree=3; } + SECTION("undersampled angular projection") { options.angular_points=4; } + SECTION("invalid resolution") { options.radial_points=1; } + SECTION("invalid tolerance") { options.relative_surface_tolerance=-1; } + SECTION("missing callback") { source.enthalpy={}; } + SECTION("invalid units") { source.gravitational_constant=0; } + SECTION("singular vacuum index") { source.polytropic_index=.5; } + SECTION("nonfinite rotation") { source.angular_velocity=std::numeric_limits::quiet_NaN(); } + SECTION("invalid surface") { source.surface_radius=[](double,double)->serialization::Result { return -1.; }; } + SECTION("nonaxisymmetric surface") { source.surface_radius=[](double,double phi)->serialization::Result { return 1+.1*std::cos(phi); }; } + SECTION("north south asymmetry") { source.surface_radius=[](double mu,double)->serialization::Result { return 1+.1*mu; }; } + SECTION("surface projection loss") { source.surface_radius=[](double mu,double)->serialization::Result { return 1+.1*(231*std::pow(mu,6)-315*std::pow(mu,4)+105*mu*mu-5)/16; }; } + SECTION("degenerate vacuum surface slope") { source.enthalpy=[](double r,double,double)->serialization::Result { return 4/pi*std::pow(1-r*r,2); }; } + SECTION("finite pressure surface") { source.enthalpy=[](double,double,double)->serialization::Result { return 4/pi; }; } + SECTION("negative interior") { source.enthalpy=[](double r,double,double)->serialization::Result { return r==0. ? 4/pi : -.01; }; } + SECTION("zero interior") { source.enthalpy=[](double r,double,double)->serialization::Result { return r==0. ? 4/pi : 0.; }; } + SECTION("nonaxisymmetric enthalpy") { source.enthalpy=[](double r,double,double phi)->serialization::Result { return 4/pi*(1-r*r)*(1+.1*r*std::cos(phi)); }; } + SECTION("nonfinite enthalpy") { source.enthalpy=[](double,double,double)->serialization::Result { return std::numeric_limits::infinity(); }; } + SECTION("sampler error path") { source.enthalpy=[](double,double,double)->serialization::Result { return std::unexpected(serialization::Error{"element/7","Sample unavailable"}); }; } + const auto result=serialization::export_top(directory.path,source,options); + REQUIRE_FALSE(result); + CHECK_FALSE(result.error().path.empty()); + CHECK_FALSE(std::filesystem::exists(directory.path)); +} + +TEST_CASE("TOP export never replaces destinations including a destination created during sampling", "[serialize][top]") { + const Directory directory; + auto source=sphere(); + SECTION("existing empty directory") { std::filesystem::create_directory(directory.path); } + SECTION("existing nonempty directory") { std::filesystem::create_directory(directory.path); std::ofstream(directory.path/"preserved") << "original"; } + SECTION("existing file") { std::ofstream(directory.path) << "original"; } + SECTION("publication race") { + source.roundoff_density_clamps=[&]() { + std::filesystem::create_directory(directory.path); + std::ofstream(directory.path/"preserved") << "original"; + return std::size_t{0}; + }; + } + const auto result=serialization::export_top(directory.path,source,small_options()); + REQUIRE_FALSE(result); + CHECK(std::filesystem::exists(directory.path)); + CHECK_FALSE(std::filesystem::exists(directory.path/"lambda_eps")); + if (std::filesystem::exists(directory.path/"preserved")) { + std::ifstream input(directory.path/"preserved"); std::string text; input >> text; CHECK(text=="original"); + } + bool temporary_remains=false; + for (const auto& entry : std::filesystem::directory_iterator(directory.path.parent_path())) { + temporary_remains |= entry.path().filename().string().starts_with("."+directory.path.filename().string()+".top-tmp-"); + } + CHECK_FALSE(temporary_remains); +} + +TEST_CASE("TOP records explicit adapter roundoff corrections", "[serialize][top]") { + const Directory directory; + auto source=sphere(); source.roundoff_density_clamps=[] { return std::size_t{3}; }; + const auto result=serialization::export_top(directory.path,source,small_options()); + REQUIRE(result); CHECK(result->roundoff_density_clamps==3); +} + +TEST_CASE("TOP records and removes only a tolerated surface defect", "[serialize][top]") { + const Directory directory; + auto source=sphere(); + const auto original=source.enthalpy; + source.enthalpy=[original](double r,double mu,double phi)->serialization::Result { + if (r==1.) { return (4/pi)*1e-8; } + return original(r,mu,phi); + }; + const auto result=serialization::export_top(directory.path,source,small_options()); + REQUIRE(result); + CHECK(close(result->maximum_relative_surface_enthalpy,1e-8,1e-15)); + const auto data=read(directory.path); + for (int l=0;l<=data.degree;l+=2) { CHECK(data.h[l].back()==0.); } +} + +TEST_CASE("TOP separates even enthalpy truncation from symmetry error", "[serialize][top]") { + const Directory truncated, resolved; + auto source=sphere(); + source.enthalpy=[](double r,double mu,double)->serialization::Result { + const double p6=(231*std::pow(mu,6)-315*std::pow(mu,4)+105*mu*mu-5)/16; + return (4/pi)*(1-r*r)*(1+.1*std::pow(r,6)*p6); + }; + auto options=small_options(); + const auto low=serialization::export_top(truncated.path,source,options); + REQUIRE(low); + CHECK(low->maximum_relative_enthalpy_symmetry_error<1e-14); + CHECK(low->maximum_relative_enthalpy_projection_error>1e-3); + options.maximum_degree=6; + const auto high=serialization::export_top(resolved.path,source,options); + REQUIRE(high); + CHECK(high->maximum_relative_enthalpy_symmetry_error<1e-14); + CHECK(high->maximum_relative_enthalpy_projection_error<1e-13); +} diff --git a/tests/serialize/top_mfem.cpp b/tests/serialize/top_mfem.cpp new file mode 100644 index 00000000..2041b1cf --- /dev/null +++ b/tests/serialize/top_mfem.cpp @@ -0,0 +1,366 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "serif/backend/mfem/discretization/field_values.hpp" +#include "serif/backend/mfem/serialize/resources.hpp" +#include "serif/backend/mfem/serialize/top.hpp" +#include "serif/backend/mfem/solver/seed/state.hpp" +#include "serif/model/finite_elements.hpp" +#include "serif/solver/seed/polytrope.hpp" +#include "serif/tests/solver/stellar_fixture.hpp" +#include "../sandbox/stellar_rayfem.hpp" + +namespace { + namespace fixture = serif::tests::solver; + namespace serialization = serif::utils::serialize; + namespace native_archive = serif::backend::mfem::serialize; + namespace seed = serif::solver::seed; + + /** @brief Supply the exact zero displacement and accepted shape used by a manufactured display state. */ + struct StationaryDisplayCache { + struct ShapeSnapshot { std::span parameters; } linearization; + ::mfem::Vector displacement; + + /** @brief Expose only the two accepted-state records consumed by the display adapter. */ + template + [[nodiscard]] const auto& get() const { + if constexpr (std::same_as) { + return linearization; + } else { + static_assert(std::same_as); + return displacement; + } + } + }; + + /** @brief Keep successful exports and rejected partial files within a test's lifetime. */ + struct ExportDirectory { + std::filesystem::path path = std::filesystem::temp_directory_path() / + ("serif-top-mfem-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + ~ExportDirectory() { std::error_code error; std::filesystem::remove_all(path, error); } + }; + + /** @brief Small sampling grids test the adapter; their coarse P2 density is not a seismic accuracy benchmark. */ + serialization::TopExportOptions small_export() { + return {.radial_points = 12, .maximum_degree = 4, .angular_points = 12, .azimuthal_points = 4, + .relative_symmetry_tolerance = 3.0e-2, .relative_surface_tolerance = 1.0e-6, + .relative_surface_projection_tolerance = 1.0e-3}; + } + + /** @brief Evaluate the original FE field at the center independently of the exporter and seed scales. */ + template + double central_density(const Discretization& finite_elements, const State& state) { + const auto& binding = finite_elements.template binding(); + const auto values = state.template get(); + ::mfem::Vector reduced(static_cast(values.size())); + std::ranges::copy(values, reduced.GetData()); + ::mfem::GridFunction density(&binding.space()); + density = binding.scatter(reduced); + ::mfem::Vector center(3); + center = 0.0; + for (int element = 0; element < binding.mesh().GetNE(); ++element) { + if (!binding.supports(element)) continue; + ::mfem::InverseElementTransformation inverse(binding.mesh().GetElementTransformation(element)); + ::mfem::IntegrationPoint point; + if (inverse.Transform(center, point) == ::mfem::InverseElementTransformation::Inside) + return density.GetValue(element, point); + } + FAIL("The independently sampled stellar center must lie in a material element."); + return 0.0; + } + + /** @brief Input preservation includes signed zero, as well as all physical and reference mesh coordinates. */ + template + void check_state_unchanged(const State& state, const std::vector& original) { + REQUIRE(state.values().size() == original.size()); + CHECK(std::ranges::equal(state.values(), original, [](double a, double b) { + return std::bit_cast(a) == std::bit_cast(b); + })); + } + + /** @brief Read the TOP scalar file independently of the serializer's returned report. */ + std::array scalar_file(const std::filesystem::path& directory) { + std::ifstream input(directory / "lambda_eps"); + REQUIRE(input.good()); + std::array values{}; + for (double& value : values) { + REQUIRE(static_cast(input >> value)); + REQUIRE(std::isfinite(value)); + } + return values; + } +} + +TEST_CASE("TOP exports the current deformed FE star without changing its state or either mesh", "[serialize][top_export][mfem]") { + constexpr double G = 0.7, K = 0.3, mass = 1.4; + const auto model = fixture::make_model(1.0, K, mass, 0.03, G); + const auto radial = seed::make_polytropic_seed(model); + REQUIRE(radial); + const auto resources = fixture::make_resources(radial->scales.radius.value()); + const auto finite_elements = fixture::make_discretization(resources, 0); + auto state = seed::backend::mfem::make_polytropic_state(model, finite_elements, *radial); + REQUIRE(state); + // This is a valid manufactured state, not a claimed equilibrium. A nonzero + // monopole makes using the undeformed source mesh detectably wrong. + state->template get()[0] = 0.02 * radial->scales.radius.value(); + const double rho_c = central_density(finite_elements, *state); + REQUIRE(rho_c > 0.0); + const std::vector original_state(state->values().begin(), state->values().end()); + const auto original_mesh = native_archive::mesh_text(*resources->mesh.mesh); + const auto original_reference = native_archive::mesh_text(*resources->mesh.reference_mesh); + const ExportDirectory output; + const auto result = serialization::export_top(output.path, model, finite_elements, *state, small_export()); + INFO((result ? "Export succeeded" : result.error().path + ": " + result.error().message)); + check_state_unchanged(*state, original_state); + CHECK(native_archive::mesh_text(*resources->mesh.mesh) == original_mesh); + CHECK(native_archive::mesh_text(*resources->mesh.reference_mesh) == original_reference); + REQUIRE(result); + CHECK(result->equatorial_radius > radial->scales.radius.value() * 1.003); + CHECK(result->polar_radius == Catch::Approx(result->equatorial_radius).epsilon(5.0e-4)); + CHECK(result->angular_frequency_unit == Catch::Approx(std::sqrt(4.0 * std::numbers::pi * G * rho_c)).epsilon(2.0e-10)); + // At n=1 the density cancels: this tests recovery of both physical G and K. + CHECK(result->lambda == Catch::Approx(4.0 * std::numbers::pi * G * std::pow(result->equatorial_radius, 2) / (2.0 * K)).epsilon(2.0e-12)); + const auto written = scalar_file(output.path); + CHECK(written[0] == result->lambda); + CHECK(written[2] == result->alpha); + CHECK(written[4] == 1.0); + CHECK(written[8] == small_export().radial_points); + CHECK(written[7] / std::sqrt(3.0 * written[2]) == + Catch::Approx(state->template get()[0] / result->angular_frequency_unit).epsilon(2.0e-12)); + CHECK(std::filesystem::file_size(output.path / "enthalpy_et_der") > 0); + CHECK(std::filesystem::file_size(output.path / "domains_boundaries") > 0); + // The EOS and current density determine the export. A different unsatisfied + // mass prescription must not change TOP's background normalization. + const auto other_mass = fixture::make_model(1.0, K, 3.0 * mass, 0.03, G); + const ExportDirectory other_output; + const auto other = serialization::export_top(other_output.path, other_mass, finite_elements, *state, small_export()); + INFO((other ? "Changed-prescription export succeeded" : other.error().path + ": " + other.error().message)); + REQUIRE(other); + CHECK(other->alpha == result->alpha); + CHECK(other->rotation_over_kepler == result->rotation_over_kepler); + check_state_unchanged(*state, original_state); + CHECK(native_archive::mesh_text(*resources->mesh.mesh) == original_mesh); + CHECK(native_archive::mesh_text(*resources->mesh.reference_mesh) == original_reference); +} + +TEST_CASE("TOP sampling honors a tilted rotation axis on a surface and equator refined true-DOF mesh", "[serialize][top_export][mfem][nonconforming]") { + namespace model = serif::model; + namespace deformation = serif::geometry::deformation; + const auto base = fixture::make_model(); + const auto star = model::make_stellar_model(base.equation_of_state(), base.equations(), + model::FixedTotalMass{serif::dimensions::MassValue{1.0}}, + model::FixedAngularMomentum{model::FixedAngularMomentum::Parameters{ + .angular_momentum = serif::dimensions::AngularMomentumValue{0.04}, .axis = {1.0, 2.0, 3.0}}}); + const auto& axis = star.specification().axis(); + const auto radial = seed::make_polytropic_seed(star); + REQUIRE(radial); + const auto finite_elements = model::make_stellar_discretization(star, + {.radius = radial->scales.radius, .geometry_order = 6, + .surface_refinement = {.levels = 1, .equatorial_levels = 1, .axis = axis}}, + deformation::DomainDeformation{ + .surface = {.maximum_degree = 0}, .stellar_interior = {}, .vacuum = {}}); + const auto& binding = finite_elements.template binding(); + REQUIRE(binding.space().GetTrueVSize() < binding.space().GetVSize()); + REQUIRE(binding.constrained()); + auto state = seed::backend::mfem::make_polytropic_state(star, finite_elements, *radial); + REQUIRE(state); + // A positive quadrupolar perturbation makes the chosen axis observable: + // treating this field as axisymmetric about z must not silently pass. + const double radius = radial->scales.radius.value(); + ::mfem::FunctionCoefficient prescribed_density([&](const ::mfem::Vector& position) { + const double r = position.Norml2(); + if (r >= radius) return 0.0; + const double x = std::numbers::pi * r / radius; + const double profile = (x == 0.0 ? 1.0 : std::sin(x) / x) * radial->scales.central_density.value(); + double axial = 0.0; + for (int component = 0; component < 3; ++component) axial += axis[component] * position[component]; + return profile * (1.0 + 0.6 * (3.0 * axial * axial - r * r) / (radius * radius)); + }); + ::mfem::GridFunction density(&binding.space()); + density.ProjectCoefficient(prescribed_density); + const auto reduced = binding.gather(density); + std::copy_n(reduced.GetData(), reduced.Size(), state->template get().begin()); + const auto trace = serif::discretization::backend::mfem::select_boundary_dofs< + serif::discretization::domain::StellarSurfaceBoundary>(binding); + for (const int dof : trace.reduced_dofs()) state->template get()[dof] = 0.0; + const std::vector original_state(state->values().begin(), state->values().end()); + const auto original_mesh = native_archive::mesh_text(binding.mesh()); + const ExportDirectory output; + const auto result = serialization::export_top(output.path, star, finite_elements, *state, small_export()); + INFO((result ? "Export succeeded" : result.error().path + ": " + result.error().message)); + check_state_unchanged(*state, original_state); + CHECK(native_archive::mesh_text(binding.mesh()) == original_mesh); + REQUIRE(result); + CHECK(result->polar_radius == Catch::Approx(radial->scales.radius.value()).epsilon(5.0e-4)); + CHECK(result->equatorial_radius == Catch::Approx(radial->scales.radius.value()).epsilon(5.0e-4)); + CHECK(result->angular_frequency_unit == Catch::Approx(std::sqrt(4.0 * std::numbers::pi * central_density(finite_elements, *state))).epsilon(2.0e-10)); + const auto written = scalar_file(output.path); + CHECK(written[7] / std::sqrt(3.0 * written[2]) == + Catch::Approx(state->template get()[0] / result->angular_frequency_unit).epsilon(2.0e-12)); + const auto wrong_axis = fixture::make_model(1.0, 2.0 / std::numbers::pi, 1.0, 0.04); + const ExportDirectory wrong_output; + const auto rejected = serialization::export_top(wrong_output.path, wrong_axis, finite_elements, *state, small_export()); + REQUIRE_FALSE(rejected); + CHECK_FALSE(std::filesystem::exists(wrong_output.path / "lambda_eps")); +} + +TEST_CASE("TOP rejects a finite pressure stellar boundary before publishing vacuum polytrope files", "[serialize][top_export][mfem]") { + const auto vacuum = fixture::make_model(); + const auto radial = seed::make_polytropic_seed(vacuum); + REQUIRE(radial); + const auto resources = fixture::make_resources(radial->scales.radius.value()); + const auto finite_elements = fixture::make_discretization(resources, 0); + const auto state = seed::backend::mfem::make_polytropic_state(vacuum, finite_elements, *radial); + REQUIRE(state); + const auto pressured = fixture::make_model(1.0, 2.0 / std::numbers::pi, 1.0, 0.0, 1.0, 0.01); + const std::vector original_state(state->values().begin(), state->values().end()); + const auto original_mesh = native_archive::mesh_text(*resources->mesh.mesh); + const ExportDirectory output; + const auto result = serialization::export_top(output.path, pressured, finite_elements, *state, small_export()); + REQUIRE_FALSE(result); + CHECK(result.error().message.find("pressure") != std::string::npos); + CHECK_FALSE(std::filesystem::exists(output.path / "lambda_eps")); + check_state_unchanged(*state, original_state); + CHECK(native_archive::mesh_text(*resources->mesh.mesh) == original_mesh); +} + +TEST_CASE("FieldValues samples reconstructed density and its correctly mapped gradient", "[serialize][mfem][reconstruction]") { + namespace discretization = serif::discretization; + namespace backend = discretization::backend::mfem; + using Density = discretization::blocks::density::mass; + using Schema = discretization::domain::schema::CoreEnvelopeVacuumDomainSchema; + constexpr double exponent = 4.0 / 3.0; + + ::mfem::Mesh mesh = ::mfem::Mesh::MakeCartesian3D(1, 1, 1, ::mfem::Element::HEXAHEDRON, 2.0, 3.0, 4.0); + for (int face = 0; face < mesh.GetNBE(); ++face) { + auto& transformation = *mesh.GetBdrElementTransformation(face); + ::mfem::Vector center(3); + transformation.Transform(::mfem::Geometries.GetCenter(transformation.GetGeometryType()), center); + mesh.GetBdrElement(face)->SetAttribute(center[0] == 0.0 ? 1 : 2); + } + ::mfem::H1_FECollection collection(discretization::blocks::SpatialTermTraits::order, 3); + ::mfem::FiniteElementSpace space(&mesh, &collection); + backend::TermBinding ordinary(space); + auto plan = std::make_shared(ordinary, 1, + std::make_shared(exponent)); + backend::TermBinding reconstructed(space, plan); + ::mfem::FunctionCoefficient profile([](const ::mfem::Vector& position) { + return std::pow(position[0] / 2.0, exponent) * (1.0 + position[1]) * (2.0 - position[2]); + }); + ::mfem::GridFunction nodal(&space); + nodal.ProjectCoefficient(profile); + const ::mfem::Vector coefficients = reconstructed.gather(nodal); + backend::FieldValues values(reconstructed, coefficients); + backend::FieldValues polynomial(ordinary, coefficients); + CHECK_THROWS(backend::FieldValues(ordinary, coefficients)); + ::mfem::IntegrationPoint point; + point.Set3(0.2, 0.3, 0.4); + const double expected = std::pow(point.x, exponent) * 1.9 * 0.4; + CHECK(values.value(0, point) == Catch::Approx(expected).margin(2.0e-13)); + CHECK(std::abs(polynomial.value(0, point) - expected) > 1.0e-4); + CHECK(polynomial.value(0, point) == Catch::Approx(nodal.GetValue(0, point)).margin(2.0e-13)); + const auto gradient = values.gradient(0, point); + CHECK(gradient[0] == Catch::Approx(exponent * std::pow(point.x, exponent - 1.0) * 1.9 * 0.4 / 2.0).margin(2.0e-13)); + CHECK(gradient[1] == Catch::Approx(std::pow(point.x, exponent) * 0.4).margin(2.0e-13)); + CHECK(gradient[2] == Catch::Approx(-std::pow(point.x, exponent) * 1.9).margin(2.0e-13)); + serif::mapping::MappingPointContext dilation; + dilation.inverse_mapping_jacobian.SetSize(3); + dilation.inverse_mapping_jacobian = 0.0; + for (int axis = 0; axis < 3; ++axis) dilation.inverse_mapping_jacobian(axis, axis) = 0.5; + const auto physical_gradient = values.gradient(0, point, dilation); + for (int axis = 0; axis < 3; ++axis) CHECK(physical_gradient[axis] == Catch::Approx(0.5 * gradient[axis]).margin(2.0e-13)); + CHECK(values.value(0, point, dilation) == Catch::Approx(expected).margin(2.0e-13)); + const auto& points = ::mfem::IntRules.Get(::mfem::Geometry::CUBE, 8); + ::mfem::Vector batch; + values.values(0, points, batch); + REQUIRE(batch.Size() == points.GetNPoints()); + for (int sample = 0; sample < points.GetNPoints(); ++sample) { + const auto& at = points.IntPoint(sample); + CHECK(batch[sample] == Catch::Approx(std::pow(at.x, exponent) * (1.0 + 3.0 * at.y) * (2.0 - 4.0 * at.z)).margin(2.0e-12)); + } + point.x = 0.0; + CHECK(values.value(0, point) == Catch::Approx(0.0).margin(2.0e-13)); + for (double component : values.gradient(0, point)) CHECK(component == Catch::Approx(0.0).margin(2.0e-13)); +} + +TEST_CASE("TOP samples the reconstructed field between its density nodes", "[serialize][top_export][mfem][reconstruction]") { + namespace model = serif::model; + namespace deformation = serif::geometry::deformation; + namespace discretization = serif::discretization; + namespace backend = discretization::backend::mfem; + const auto star = fixture::make_model(4.0 / 3.0); + const auto radial = seed::make_polytropic_seed(star); + REQUIRE(radial); + const auto finite_elements = model::make_stellar_discretization(star, + {.radius = radial->scales.radius, .geometry_order = 6}, + deformation::DomainDeformation{ + .surface = {.maximum_degree = 0}, .stellar_interior = {}, .vacuum = {}}, + discretization::reconstruction::SurfacePower{.density_exponent = 4.0 / 3.0}); + const auto state = seed::backend::mfem::make_polytropic_state(star, finite_elements, *radial); + REQUIRE(state); + const auto& binding = finite_elements.template binding(); + const auto density = state->template get(); + ::mfem::Vector coefficients(static_cast(density.size())); + std::ranges::copy(density, coefficients.GetData()); + backend::FieldValues values(binding, coefficients); + ::mfem::GridFunction polynomial(&binding.space()); + polynomial = binding.scatter(coefficients); + const std::vector displacement(finite_elements.deformation.reference.displacement_space.GetVSize(), 0.0); + using Binding = std::remove_cvref_t; + auto temporary_binding = std::make_unique(binding.space(), binding.reconstruction()); + serif::backend::mfem::serialize::detail::TopModelSampler sampler(*temporary_binding, density, + finite_elements.deformation.reference, displacement, {0.0, 0.0, 1.0}, {0.0, 0.0, 0.0}); + temporary_binding.reset(); + int selected = -1; + for (int element = 0; element < binding.mesh().GetNE(); ++element) { + if (binding.reconstruction_element(element) != nullptr) { selected = element; break; } + } + REQUIRE(selected >= 0); + ::mfem::IntegrationPoint point; + point.Set3(0.27, 0.41, 0.69); + ::mfem::Vector position(3); + binding.mesh().GetElementTransformation(selected)->Transform(point, position); + const double radius = position.Norml2(); + const double expected = values.value(selected, point); + REQUIRE(std::abs(expected - polynomial.GetValue(selected, point)) > 1.0e-8 * radial->scales.central_density.value()); + const auto exported = sampler.density(radius, position[2] / radius, std::atan2(position[1], position[0])); + INFO((exported ? "Sample succeeded" : exported.error().message)); + REQUIRE(exported); + CHECK(*exported == Catch::Approx(expected).epsilon(2.0e-10)); + + // Rayfem receives a display approximation sampled from the same physical field. + // This checks the parent/submesh coordinate correspondence without opening a viewer. + serif::visualization::backend::mfem::StellarFrame frame(finite_elements); + StationaryDisplayCache cache{.linearization = {state->template get()}, + .displacement = ::mfem::Vector(finite_elements.template binding().size())}; + cache.displacement = 0.0; + frame.update(*state, cache); + const auto fields = frame.fields(); + REQUIRE(fields.front().name == "density (sampled)"); + const auto& display = *fields.front().gf; + CHECK(display.FESpace()->GetMaxElementOrder() == binding.space().GetMaxElementOrder() + 3); + const auto& parent_elements = static_cast(frame.mesh()).GetParentElementIDMap(); + bool displayed = false; + for (int element = 0; element < parent_elements.Size(); ++element) { + if (parent_elements[element] != selected) continue; + displayed = true; + const auto& nodes = display.FESpace()->GetFE(element)->GetNodes(); + for (int node = 0; node < nodes.GetNPoints(); ++node) { + const auto& at = nodes.IntPoint(node); + CHECK(display.GetValue(element, at) == Catch::Approx(values.value(selected, at)).margin(3.0e-12 * radial->scales.central_density.value())); + } + } + REQUIRE(displayed); +} diff --git a/tests/serialize/top_reader.py b/tests/serialize/top_reader.py new file mode 100644 index 00000000..5593722b --- /dev/null +++ b/tests/serialize/top_reader.py @@ -0,0 +1,78 @@ +"""Optional integration test: run with the recovered TOP Python environment. + +Usage: python tests/serialize/top_reader.py build/tests/serialize/top_export_fixture +The compiled eq_poly_ester_gamma2 module must be available to top.load(). +""" +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile + +import numpy as np +import top + + +def read_model(directory): + dati = directory / 'dati' + dati.write_text('nt = 1\nlres = 40\niparity = 0\niCor = 1\n' + 'dertype = CHEB\norderFD = 2\nmattype = FULL\n' + 'nsol = 1\nshift = 0.6\nrootdir = ./\nm = 0\ntagdir = test\n') + os.chdir(directory) + solver = top.load('eq_poly_ester_gamma2') + solver.read_dati(str(dati)) + model = solver.init_model(str(directory) + '/') + return solver, model + + +def verify(directory, deformed): + solver, model = read_model(directory) + radius, theta = solver.get_grid() + z = solver.get_zeta()[:, None] + mu = np.cos(theta)[None, :] + if deformed: + rs = 1.0 - 0.2 * mu**2 + a = 0.5 * (5.0*z**3 - 3.0*z**5) + expected_radius = 0.8*z + a*(rs-0.8) + p2 = 0.5*(3.0*mu**2-1.0) + h = (1-z*z)*(1+0.1*z*z*p2) + hz = -2*z + 0.1*p2*(2*z-4*z**3) + hzz = -2 + 0.1*p2*(2-12*z*z) + else: + expected_radius = np.broadcast_to(z, radius.shape) + h = np.broadcast_to(np.sinc(z), radius.shape) + with np.errstate(divide='ignore', invalid='ignore'): + hz = np.pi*np.cos(np.pi*z)/(np.pi*z) - np.sin(np.pi*z)/(np.pi*z*z) + hzz = -np.pi*np.sin(np.pi*z)/z - 2*np.cos(np.pi*z)/(z*z) + 2*np.sin(np.pi*z)/(np.pi*z**3) + hz[0] = 0.0 + hzz[0] = -np.pi**2/3 + hz = np.broadcast_to(hz, radius.shape) + hzz = np.broadcast_to(hzz, radius.shape) + np.testing.assert_allclose(radius, expected_radius, rtol=0, atol=2e-12) + np.testing.assert_allclose(model['hh'], h, rtol=0, atol=3e-12) + np.testing.assert_allclose(model['hhz'], hz, rtol=0, atol=3e-9) + np.testing.assert_allclose(model['hhzz'], hzz, rtol=0, atol=2e-5) + np.testing.assert_allclose(model['lambda'], np.pi**2, rtol=3e-13) + np.testing.assert_allclose(model['omga'], 0.2/np.pi if deformed else 0.0, rtol=3e-13, atol=1e-15) + if not deformed: + result = solver.run_arncheb(0.6) + assert result.nsol == 1 + np.testing.assert_allclose(result.valps[0], 0.6168010124, rtol=0, atol=2e-8) + return float(result.valps[0]) + + +if __name__ == '__main__': + if len(sys.argv) != 2: + raise SystemExit(__doc__) + executable = Path(sys.argv[1]).resolve() + original_directory = Path.cwd() + try: + with tempfile.TemporaryDirectory(prefix='serif-top-reader-') as tmp: + directory = Path(tmp) + subprocess.run([str(executable), str(directory)], check=True) + frequency = verify(directory / 'spherical', False) + verify(directory / 'deformed', True) + print(json.dumps({'reader_checks': 'passed', 'radial_frequency_top_units': frequency})) + finally: + os.chdir(original_directory) diff --git a/tests/serialize/write_fixture.cpp b/tests/serialize/write_fixture.cpp new file mode 100644 index 00000000..e1e5889f --- /dev/null +++ b/tests/serialize/write_fixture.cpp @@ -0,0 +1,27 @@ +#include +#include +#include + +#include "serif/utils/serialize/archive.hpp" + +/** @brief Produce independent C++ fixtures for the dependency-free Python data reader. */ +int main(int argc, char** argv) { + namespace serialization = serif::utils::serialize; + if (argc != 2) { + return 2; + } + const auto contents = serialization::pack(serialization::named("values", std::vector{-0.0, 1.2345678901234567, + std::numeric_limits::denorm_min(), std::numeric_limits::infinity(), + std::bit_cast(0x7ff8123456789abcULL)}), serialization::named("empty", std::vector{}), + serialization::named("integers", std::vector{std::numeric_limits::min(), std::numeric_limits::max()}), + serialization::named("label", std::string{"ξ\n\"quote\"\\end"})); + for (serialization::Format format : {serialization::Format::binary, serialization::Format::text}) { + const auto path = std::filesystem::path{argv[1]} / (format == serialization::Format::binary ? "binary.serif" : "text.serif"); + const auto result = serialization::write(path, contents, {.format = format, .author = "Cross-language regression"}); + if (!result) { + std::println(stderr, "{}: {}", result.error().path, result.error().message); + return 1; + } + } + return 0; +} diff --git a/tests/serialize/write_top_fixture.cpp b/tests/serialize/write_top_fixture.cpp new file mode 100644 index 00000000..ac5acda9 --- /dev/null +++ b/tests/serialize/write_top_fixture.cpp @@ -0,0 +1,57 @@ +#include +#include +#include +#include +#include + +#include "serif/utils/serialize/top.hpp" + +/** @brief Write analytic inputs for an optional test against TOP's actual Fortran reader. */ +int main(int argc, char** argv) { + namespace io = serif::utils::serialize; + if (argc != 2) { + std::println(stderr, "Usage: top_export_fixture OUTPUT_DIRECTORY"); + return 2; + } + const std::filesystem::path directory{argv[1]}; + std::filesystem::create_directories(directory); + io::TopPolytropeSource source{ + .polytropic_index = 1.0, .gravitational_constant = 1.0, .mass = 1.0, + .angular_velocity = 0.0, .central_density = std::numbers::pi / 4.0, .central_enthalpy = 1.0, + .surface_radius = [](double, double) -> io::Result { return 1.0; }, + .enthalpy = [](double radius, double, double) -> io::Result { + if (radius == 0.0) return 1.0; + if (radius == 1.0) return 0.0; + return std::sin(std::numbers::pi * radius) / (std::numbers::pi * radius); + } + }; + const io::TopExportOptions options{.radial_points = 64, .maximum_degree = 8, .angular_points = 24, .azimuthal_points = 8}; + const auto spherical = io::export_top(directory / "spherical", source, options); + if (!spherical) { + std::println(stderr, "{}: {}", spherical.error().path, spherical.error().message); + return 1; + } + source.angular_velocity = 0.2; + source.surface_radius = [](double mu, double) -> io::Result { return 1.0 - 0.2 * mu * mu; }; + source.enthalpy = [](double radius, double mu, double) -> io::Result { + const double surface = 1.0 - 0.2 * mu * mu; + if (radius == 0.0) return 1.0; + if (radius >= surface) return 0.0; + double lo = 0.0, hi = 1.0; + for (int i = 0; i < 60; ++i) { + const double z = 0.5 * (lo + hi); + const double a = 0.5 * (5.0 * z * z * z - 3.0 * std::pow(z, 5)); + if (0.8 * z + a * (surface - 0.8) < radius) lo = z; + else hi = z; + } + const double z = 0.5 * (lo + hi); + const double p2 = 0.5 * (3.0 * mu * mu - 1.0); + return (1.0 - z * z) * (1.0 + 0.1 * z * z * p2); + }; + const auto deformed = io::export_top(directory / "deformed", source, options); + if (!deformed) { + std::println(stderr, "{}: {}", deformed.error().path, deformed.error().message); + return 1; + } + return 0; +} diff --git a/tests/serif_tests.cpp b/tests/serif_tests.cpp deleted file mode 100644 index 3f0a4947..00000000 --- a/tests/serif_tests.cpp +++ /dev/null @@ -1,653 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - - -std::string escapeHtml(const std::string &data) { - std::string buffer; - buffer.reserve(data.size()); - for (size_t pos = 0; pos != data.size(); ++pos) { - switch (data[pos]) { - case '&': - buffer.append("&"); - break; - case '\"': - buffer.append("""); - break; - case '\'': - buffer.append("'"); - break; - case '<': - buffer.append("<"); - break; - case '>': - buffer.append(">"); - break; - default: - buffer.append(&data[pos], 1); - break; - } - } - return buffer; -} - -std::string ansiToHtml(const std::string &text) { - // Convert text to HTML-safe first - std::string htmlEscaped = escapeHtml(text); - - std::ostringstream oss; - size_t i = 0; - size_t len = htmlEscaped.length(); - int openSpans = 0; - - auto closeSpans = [&oss, &openSpans]() { - while (openSpans > 0) { - oss << ""; - --openSpans; - } - }; - - while (i < len) { - // Look for ANSI CSI sequence '\033[' or '\x1b[' - if ((htmlEscaped[i] == '\033' || htmlEscaped[i] == '\x1b') && i + 1 < len && htmlEscaped[i + 1] == '[') { - size_t seqStart = i + 2; - size_t seqEnd = htmlEscaped.find('m', seqStart); - - if (seqEnd != std::string::npos) { - std::string codeStr = htmlEscaped.substr(seqStart, seqEnd - seqStart); - i = seqEnd + 1; - - std::istringstream codeStream(codeStr); - std::string codeVal; - - // Defaults if sequence is just \033[m (Reset) - if (codeStr.empty()) { - closeSpans(); - continue; - } - - while (std::getline(codeStream, codeVal, ';')) { - int code = 0; - try { - code = std::stoi(codeVal); - } catch (...) { - continue; - } - - switch (code) { - case 0: // Reset - closeSpans(); - break; - case 1: // Bold - oss << ""; - openSpans++; - break; - case 2: // Dim - oss << ""; - openSpans++; - break; - // Standard Foreground Colors - case 30: - oss << ""; - openSpans++; - break; // Black - case 31: - oss << ""; - openSpans++; - break; // Red - case 32: - oss << ""; - openSpans++; - break; // Green - case 33: - oss << ""; - openSpans++; - break; // Yellow - case 34: - oss << ""; - openSpans++; - break; // Blue - case 35: - oss << ""; - openSpans++; - break; // Magenta - case 36: - oss << ""; - openSpans++; - break; // Cyan - case 37: - oss << ""; - openSpans++; - break; // Light Gray - // Bright Foreground Colors - case 90: - oss << ""; - openSpans++; - break; // Dark Gray - case 91: - oss << ""; - openSpans++; - break; // Bright Red - case 92: - oss << ""; - openSpans++; - break; // Bright Green - case 93: - oss << ""; - openSpans++; - break; // Bright Yellow - case 94: - oss << ""; - openSpans++; - break; // Bright Blue - case 95: - oss << ""; - openSpans++; - break; // Bright Magenta - case 96: - oss << ""; - openSpans++; - break; // Bright Cyan - case 97: - oss << ""; - openSpans++; - break; // White - default: - break; - } - } - continue; - } - } - oss << htmlEscaped[i]; - ++i; - } - - closeSpans(); - return oss.str(); -} - -std::vector wrapText( - const std::string &text, - size_t width -) { - std::vector lines; - std::istringstream words(text); - std::string word, line; - - while (words >> word) { - if (line.length() + word.length() + 1 > width) { - if (!line.empty()) { - lines.push_back(line); - line.clear(); - } - if (word.length() > width) { - lines.push_back(word.substr(0, width - 3) + "..."); - continue; - } - } - if (!line.empty()) - line += " "; - line += word; - } - if (!line.empty()) - lines.push_back(line); - if (lines.empty()) - lines.push_back(""); - - return lines; -} - -class CheckReporter : public Catch::StreamingReporterBase { - struct TestCaseData { - std::string name; - std::string tags; - bool passed; - std::size_t assertionsPassed; - std::size_t assertionsFailed; - double durationSeconds; - std::vector failureMessages; - std::vector infoMessages; - }; - - std::vector m_currentFailures; - std::vector m_currentInfos; - std::unordered_set m_currentInfoSequences; - std::vector m_testRunData; - std::chrono::time_point m_testStartTime; - - static bool isRootProcess() { - int initialized = 0; - int finalized = 0; - MPI_Initialized(&initialized); - if (initialized == 0) { - return true; - } - MPI_Finalized(&finalized); - if (finalized != 0) { - return true; - } - - int rank = 0; - MPI_Comm_rank(MPI_COMM_WORLD, &rank); - return rank == 0; - } - - void captureInfoMessages(Catch::AssertionStats const &assertionStats) { - for (auto const &message : assertionStats.infoMessages) { - if (m_currentInfoSequences.insert(message.sequence).second) { - m_currentInfos.push_back(message.message); - } - } - } - -public: - explicit CheckReporter(Catch::ReporterConfig &&config) : Catch::StreamingReporterBase(std::move(config)) { - // INFO messages are delivered through assertionEnded. Request passing - // assertions as well so HTML logging does not depend on Catch2's -s - // flag. - m_preferences.shouldReportAllAssertions = true; - - // This reporter does not use assertionStarting events. Disabling them - // preserves Catch2's successful-assertion fast path where possible. - m_preferences.shouldReportAllAssertionStarts = false; - } - - static std::string getDescription() { - return "Console reporter with wrapping, tags, live test progress, and collapsible HTML " - "export with ANSI color rendering."; - } - - void testRunStarting(Catch::TestRunInfo const &_testRunInfo) override { - StreamingReporterBase::testRunStarting(_testRunInfo); - - if (!isRootProcess()) { - return; - } - - std::cout << '\n'; - std::cout << std::left << std::setw(85) << "Test Case Name" - << "Status " << std::right << std::setw(8) << "Passed" << std::setw(8) << "Failed" << std::setw(12) - << "Time (s)" << '\n'; - std::cout << std::string(133, '-') << '\n'; - } - - void testCaseStarting(Catch::TestCaseInfo const &testInfo) override { - StreamingReporterBase::testCaseStarting(testInfo); - - m_testStartTime = std::chrono::steady_clock::now(); - if (!isRootProcess()) { - return; - } - - std::string name = testInfo.name; - auto wrappedName = wrapText(name, 83); - - // Print progress line, \r to overwrite later, \033[K to clear till end of line - std::cout << "\r\033[K" << std::left << std::setw(85) << (wrappedName[0] + " ...") << std::flush; - } - - void assertionEnded(Catch::AssertionStats const &assertionStats) override { - StreamingReporterBase::assertionEnded(assertionStats); - - // Capture every INFO message encountered by either a passing or failing - // assertion. Message sequence IDs prevent a scoped INFO from being - // repeated once for every assertion that occurs while it remains - // active. - captureInfoMessages(assertionStats); - - if (!assertionStats.assertionResult.isOk()) { - auto const &result = assertionStats.assertionResult; - std::ostringstream oss; - - oss << " \033[31m-> FAILED:\033[0m " << result.getSourceInfo().file << ":" << result.getSourceInfo().line - << '\n'; - oss << " " << result.getTestMacroName() << "( " << result.getExpression() << " )\n"; - - if (result.hasExpandedExpression()) { - oss << " with expansion:\n" - << " " << result.getExpandedExpression() << '\n'; - } - - for (auto const &msg : assertionStats.infoMessages) { - oss << " \033[36m[INFO]\033[0m " << msg.message << '\n'; - } - - m_currentFailures.push_back(oss.str()); - } - } - - void testCaseEnded(Catch::TestCaseStats const &stats) override { - StreamingReporterBase::testCaseEnded(stats); - - auto endTime = std::chrono::steady_clock::now(); - std::chrono::duration elapsed = endTime - m_testStartTime; - double duration_s = elapsed.count(); - - bool passed = stats.totals.assertions.allPassed(); - std::string mark = passed ? "\033[32m✓\033[0m" : "\033[31m✗\033[0m"; - - std::string name = stats.testInfo->name; - auto wrappedName = wrapText(name, 83); - - if (isRootProcess()) { - // Overwrite the loading line with the actual result - std::cout << "\r\033[K" << std::left << std::setw(85) << wrappedName[0] << mark << " " << std::right - << std::setw(8) << stats.totals.assertions.passed << std::setw(8) - << stats.totals.assertions.failed << std::setw(11) << std::fixed << std::setprecision(3) - << duration_s << "s\n"; - - for (size_t i = 1; i < wrappedName.size(); ++i) { - std::cout << " \033[90m↳ \033[0m" // Dim indent arrow - << std::left << std::setw(81) << wrappedName[i] << '\n'; - } - - std::string tagsStr = stats.testInfo->tagsAsString(); - if (!tagsStr.empty()) { - auto wrappedTags = wrapText("Tags: " + tagsStr, 83); - for (const auto &line : wrappedTags) { - std::cout << " \033[36m" << line << "\033[0m\n"; // Cyan - } - } - - if (!m_currentFailures.empty()) { - std::cout << '\n'; - for (auto const &failure : m_currentFailures) { - std::cout << failure << '\n'; - } - std::cout << std::string(133, '-') << '\n'; - } - } - - std::string tagsStr = stats.testInfo->tagsAsString(); - - m_testRunData.push_back( - {name, tagsStr, passed, stats.totals.assertions.passed, stats.totals.assertions.failed, duration_s, - m_currentFailures, m_currentInfos} - ); - - m_currentFailures.clear(); - m_currentInfos.clear(); - m_currentInfoSequences.clear(); - } - - void testRunEnded(Catch::TestRunStats const &_testRunStats) override { - StreamingReporterBase::testRunEnded(_testRunStats); - - if (!isRootProcess()) { - return; - } - - std::cout << std::string(133, '=') << '\n'; - - auto const &tc = _testRunStats.totals.testCases; - auto const &as = _testRunStats.totals.assertions; - - std::string tc_passed_str = - tc.passed > 0 ? "\033[32m" + std::to_string(tc.passed) + " passed\033[0m" : "0 passed"; - std::string tc_failed_str = - tc.failed > 0 ? "\033[31m" + std::to_string(tc.failed) + " failed\033[0m" : "0 failed"; - - std::string as_passed_str = - as.passed > 0 ? "\033[32m" + std::to_string(as.passed) + " passed\033[0m" : "0 passed"; - std::string as_failed_str = - as.failed > 0 ? "\033[31m" + std::to_string(as.failed) + " failed\033[0m" : "0 failed"; - - std::cout << "Test Cases: " << tc_passed_str << ", " << tc_failed_str << ", " << tc.total() << " total\n"; - std::cout << "Assertions: " << as_passed_str << ", " << as_failed_str << ", " << as.total() << " total\n\n"; - - generateHtmlReport(_testRunStats); - } - -private: - void generateHtmlReport(Catch::TestRunStats const &stats) { - std::ofstream html("test_summary.html"); - if (!html) - return; - - html << "\n\n\n" - << "\n" - << "\n" - << "Test Run Summary\n" - << "\n\n\n"; - - html << "

Test Run Summary

\n"; - - // Summary Cards - html << "
\n"; - html << "

Total Cases

" << stats.totals.testCases.total() << "

\n"; - html << "

Cases Passed

" << stats.totals.testCases.passed - << "

\n"; - html << "

Cases Failed

" << stats.totals.testCases.failed - << "

\n"; - html << "
\n"; - - for (const auto &test : m_testRunData) { - std::string statusClass = test.passed ? "passed" : "failed"; - html << "
\n"; - html << "
\n"; - html << "
\n"; - html << "

" << escapeHtml(test.name) << "

\n"; - if (!test.tags.empty()) { - html << "
" << escapeHtml(test.tags) << "
\n"; - } - html << "
\n"; - html << "
\n"; - html << " ✓ " << test.assertionsPassed << " | "; - html << " ✗ " << test.assertionsFailed << " | "; - html << " ⌛ " << std::fixed << std::setprecision(3) - << test.durationSeconds << "s\n"; - html << "
\n"; - html << "
\n"; - - // Collapsible INFO Messages section with ANSI color rendering - if (!test.infoMessages.empty()) { - html << "
\n"; - html << " Info Logs (" << test.infoMessages.size() << ")\n"; - html << "
";
-                for (const auto &info : test.infoMessages) {
-                    html << "[INFO] " << ansiToHtml(info) << "\n";
-                }
-                html << "
\n"; - html << "
\n"; - } - - // Collapsible Failures section with ANSI color rendering - if (!test.failureMessages.empty()) { - html << "
\n"; - html << " Failure Details (" << test.failureMessages.size() - << ")\n"; - html << "
";
-                for (const auto &msg : test.failureMessages) {
-                    html << ansiToHtml(msg) << "\n";
-                }
-                html << "    
\n"; - html << "
\n"; - } - - html << "
\n"; - } - - html << "\n\n"; - } -}; - -CATCH_REGISTER_REPORTER( - "check", - CheckReporter -) - -int main( - int argc, - char *argv[] -) { - fourdst::config::Config cfg; - CLI::App app{"Mean Field Tests"}; - - app.allow_extras(); - app.set_help_flag("--config-help", "Show mean-field configuration options"); - fourdst::config::register_as_cli(cfg, app); - - std::vector config_arguments; - std::vector forced_catch_arguments; - config_arguments.emplace_back(argv[0]); - - bool parsing_catch_arguments = false; - - for (int i = 1; i < argc; ++i) { - if (std::string_view(argv[i]) == "--catch2") { - parsing_catch_arguments = true; - continue; - } - - if (parsing_catch_arguments) { - forced_catch_arguments.emplace_back(argv[i]); - } else { - config_arguments.emplace_back(argv[i]); - } - } - - std::vector config_argv; - config_argv.reserve(config_arguments.size()); - - for (const std::string &argument : config_arguments) { - config_argv.push_back(argument.c_str()); - } - - try { - app.parse(static_cast(config_argv.size()), config_argv.data()); - } catch (const CLI::ParseError &error) { - return app.exit(error); - } - - std::vector catch_arguments; - catch_arguments.emplace_back(argv[0]); - - for (const std::string &argument : app.remaining()) { - catch_arguments.push_back(argument); - } - - for (const std::string &argument : forced_catch_arguments) { - catch_arguments.push_back(argument); - } - - const auto is_reporter_option = [](const std::string &argument) { - return argument == "-r" || argument == "--reporter" || argument.starts_with("-r=") || - argument.starts_with("--reporter="); - }; - - if (const bool has_reporter = std::ranges::any_of(catch_arguments, is_reporter_option); !has_reporter) { - catch_arguments.emplace_back("--reporter"); - catch_arguments.emplace_back("check"); - } - - std::vector catch_argv; - catch_argv.reserve(catch_arguments.size()); - - for (const std::string &argument : catch_arguments) { - catch_argv.push_back(argument.c_str()); - } - - Catch::Session session; - - if (const int catch_parse_result = session.applyCommandLine(static_cast(catch_argv.size()), catch_argv.data()); - catch_parse_result != 0) { - return catch_parse_result; - } - - mfem::Mpi::Init(argc, argv); - - std::uint32_t synchronized_seed = session.configData().rngSeed; - MPI_Bcast(&synchronized_seed, 1, MPI_UINT32_T, 0, MPI_COMM_WORLD); - session.configData().rngSeed = synchronized_seed; - - constexpr std::string device_config = "cpu"; - mfem::Device device(device_config); - - const int hdiv_max_q1d = mfem::DeviceDofQuadLimits::Get().HDIV_MAX_Q1D; - if (mfem::Mpi::Root()) { - std::cout << "H(div) maximum Q1D = " << hdiv_max_q1d << '\n'; - std::cout << "Approximate maximum safe integration order = " << 2 * hdiv_max_q1d - 1 << '\n'; - } - - mean_field::utils::Args test_args = cfg.main(); - - if (app.count("--mesh_file") == 0) { - test_args.mesh_file = "sandbox.smesh"; - } - - if (app.count("--p.rtol") == 0) { - test_args.p.rtol = 1.0e-12; - } - - if (app.count("--p.atol") == 0) { - test_args.p.atol = 1.0e-12; - } - - test_utils::set_args(std::move(test_args)); - - return session.run(); -} diff --git a/tests/solver/archive.cpp b/tests/solver/archive.cpp new file mode 100644 index 00000000..1082a778 --- /dev/null +++ b/tests/solver/archive.cpp @@ -0,0 +1,38 @@ +#include +#include +#include "serif/tests/solver/archive.hpp" + +TEST_CASE("Validation rechecks reject missing mismatched and repeated coordinate identities", "[unit][solver][validation_archive]") { + namespace archive = serif::tests::solver::archive; + const std::string identity = "coordinates=density_primal_v1;density=0:8;potential=8:8"; + std::istringstream valid("stage=seed\n" + identity + "\nstage=complete_converged\n"); + CHECK_NOTHROW(archive::require_preparation_identity(valid, identity)); + for (const std::string& text : { + std::string{"stage=complete_converged\n"}, + std::string{"coordinates=mixed_gravity_v1;density=0:8;potential=8:8\n"}, + std::string{"coordinates=density_primal_v1;density=8:8;potential=0:8\n"}, + identity + '\n' + identity + '\n'}) { + std::istringstream invalid(text); + CHECK_THROWS(archive::require_preparation_identity(invalid, identity)); + } + const std::string mapping = "mapping=kelvin_mesh_radii_v1,r_star_ref=1,r_inf_ref=5"; + std::istringstream correct_map(mapping + '\n'); + CHECK_NOTHROW(archive::require_preparation_identity(correct_map, mapping)); + std::istringstream wrong_map("mapping=kelvin_mesh_radii_v1,r_star_ref=1,r_inf_ref=2\n"); + CHECK_THROWS(archive::require_preparation_identity(wrong_map, mapping)); + std::istringstream missing_map(identity + '\n'); + CHECK_THROWS(archive::require_preparation_identity(missing_map, mapping)); +} + +TEST_CASE("Validation coordinate identities distinguish equal-length layouts and meshes", "[unit][solver][validation_archive]") { + namespace fixture = serif::tests::solver; + namespace archive = fixture::archive; + using Fields = serif::utils::dependencies::KeyList; + using SwappedFields = serif::utils::dependencies::KeyList; + const serif::discretization::blocks::SystemLayout layout{.state_sizes = {8, 8, 1, 1, 25}, .residual_sizes = {8, 8, 1, 1, 25}}; + const serif::discretization::blocks::SystemLayout swapped{.state_sizes = layout.state_sizes, .residual_sizes = layout.residual_sizes}; + const std::string identity = archive::coordinate_identity(layout, "mesh-a"); + CHECK(identity != archive::coordinate_identity(swapped, "mesh-a")); + CHECK(identity != archive::coordinate_identity(layout, "mesh-b")); + CHECK(identity.find("density=0:8;potential=8:8;surface_level=16:1;angular_velocity=17:1;shape=18:25") != std::string::npos); +} diff --git a/tests/solver/backend_independent.cpp b/tests/solver/backend_independent.cpp new file mode 100644 index 00000000..c0ec9a38 --- /dev/null +++ b/tests/solver/backend_independent.cpp @@ -0,0 +1,197 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include "serif/solver/nonlinear/newton.hpp" + +namespace { + namespace solver = serif::solver; + + /** @brief Keep accepted and trial coordinates independently owned. */ + struct State { + std::vector coefficients; + /** @brief Edit a candidate's physical coordinate. */ + std::span values() { return coefficients; } + /** @brief Inspect an immutable accepted state. */ + std::span values() const { return coefficients; } + }; + + /** @brief Use unequal state and residual scales to expose coordinate mistakes. */ + struct Coordinates { + /** @brief The normalized unknown is twice the physical unknown. */ + void denormalize_state(std::span normalized, std::span physical) const { + physical[0] = normalized[0] / 2.0; + } + }; + + /** @brief Freeze the residual and derivative belonging to one physical candidate. */ + struct System { + std::vector residual_values; + double derivative; + /** @brief Supply the complete normalized residual to the generic merit function. */ + const std::vector& residual() const { return residual_values; } + /** @brief Supply the inverse coordinate transform for physical trial steps. */ + Coordinates coordinates() const { return {}; } + }; + + /** @brief Supply only the preparation and ownership contract required by Newton. */ + struct Problem { + using Vector = std::vector; + using System = ::System; + using Model = solver::NoModel; + std::function(double)> equation = [](double value) { + return std::pair{value * value - 2.0, 2.0 * value}; + }; + /** @brief This algebraic regression deliberately has no stellar description. */ + std::shared_ptr model_owner() const { return {}; } + /** @brief Negative trial states are an ordinary recoverable numerical rejection. */ + std::expected, std::string> evaluate(const State& state) const { + if (state.values()[0] < 0.0) { + return std::unexpected("negative trial"); + } + const auto [residual, derivative] = equation(state.values()[0]); + return std::make_shared(System{.residual_values = {3.0 * residual}, .derivative = 1.5 * derivative}); + } + /** @brief A scalar derivative needs no additional preparation resources. */ + auto prepare(const State& state) const { return evaluate(state); } + /** @brief Every injected error represents numerical rejection, not configuration. */ + bool recoverable(const std::string&) const { return true; } + /** @brief Preserve the concrete reason exposed by preparation. */ + const std::string& failure_message(const std::string& message) const { return message; } + }; + + /** @brief Solve scalar Newton equations exactly, with optional scripted failures or oversized steps. */ + struct ScalarNewton : solver::StandardNewton { + std::shared_ptr> guesses{std::make_shared>()}; + std::function&)> scripted_correction; + /** @brief Record the incoming correction so backtracking reuse is independently observable. */ + solver::linear::Report solve(auto&, const auto&, const auto& prepared, const std::vector& rhs, + std::vector& correction, const auto&) const { + guesses->push_back(correction[0]); + correction[0] = rhs[0] / prepared->derivative; + if (scripted_correction) { + scripted_correction(static_cast(guesses->size()), correction); + } + return {.status = solver::linear::Status::converged, .iterations = 1}; + } + }; +} + +TEST_CASE("Backend independent Newton solves in physical coordinates", "[solver][nonlinear][backend_independent]") { + Problem problem; + std::shared_ptr initial; + const auto observe = [&](const solver::SolveEvent& event, const State&, const auto& system) { + if (event.kind == solver::EventKind::initial) { + initial = system; + } + }; + const auto result = solver::solve_newton(problem, State{{1.0}}, solver::SolverOptions{.strategies = ScalarNewton{}}, observe); + REQUIRE(result.converged()); + CHECK(result.state().values()[0] == Catch::Approx(std::sqrt(2.0)).margin(1.0e-8)); + REQUIRE(initial); + CHECK(initial->residual()[0] == -3.0); + CHECK(result.system() != initial); +} + +TEST_CASE("Backend independent Newton retains safeguarded corrections", "[solver][nonlinear][backend_independent]") { + Problem problem; + problem.equation = [](double value) { return std::pair{value, 1.0}; }; + ScalarNewton strategy; + strategy.scripted_correction = [](int iteration, std::vector& correction) { + correction[0] = iteration == 1 ? -6.0 : -0.5; + }; + const auto result = solver::solve_newton(problem, State{{1.0}}, + solver::SolverOptions{.method = {.maximum_iterations = 2}, .strategies = strategy}); + REQUIRE(result.has_state()); + CHECK(result.status() == solver::SolveStatus::maximum_iterations); + REQUIRE(strategy.guesses->size() == 2); + CHECK((*strategy.guesses)[0] == 0.0); + CHECK((*strategy.guesses)[1] == -4.5); + CHECK(result.diagnostics().rejected_trials == 2); + CHECK(result.state().values()[0] == 0.125); +} + +TEST_CASE("Backend independent Newton checks correction contracts", "[solver][nonlinear][backend_independent]") { + Problem problem; + ScalarNewton strategy; + SECTION("Nonfinite corrections retain the accepted state") { + strategy.scripted_correction = [](int, std::vector& correction) { + correction[0] = std::numeric_limits::infinity(); + }; + const auto result = solver::solve_newton(problem, State{{1.0}}, solver::SolverOptions{.strategies = strategy}); + CHECK(result.status() == solver::SolveStatus::linear_solve_failure); + CHECK(result.state().values()[0] == 1.0); + } + SECTION("Wrong correction sizes reject the backend contract") { + strategy.scripted_correction = [](int, std::vector& correction) { correction.resize(2); }; + CHECK_THROWS_AS(solver::solve_newton(problem, State{{1.0}}, solver::SolverOptions{.strategies = strategy}), std::logic_error); + } +} + +TEST_CASE("Observer lifecycle covers rejected initial states without publishing a fictitious snapshot", "[solver][nonlinear][observer]") { + struct Observer { + int& starts; + int& finishes; + int& events; + solver::SolveStatus& status; + + /** @brief Record entry before the initial physical evaluation. */ + void started() { ++starts; } + /** @brief Receive termination even when there is no accepted state. */ + void finished(solver::SolveStatus value, const solver::SolveDiagnostics&) { + ++finishes; + status = value; + } + /** @brief Count only events that provide a valid physical snapshot. */ + void operator()(const solver::SolveEvent&, const State&, const std::shared_ptr&) { ++events; } + }; + Problem problem; + int starts = 0, finishes = 0, events = 0; + solver::SolveStatus status = solver::SolveStatus::converged; + solver::SolveTiming timing{.seconds = 12.0}; + const auto result = solver::solve_newton(problem, State{{-1.0}}, solver::SolverOptions{.strategies = ScalarNewton{}}, + solver::TimedObserver{timing, Observer{starts, finishes, events, status}}); + CHECK(result.status() == solver::SolveStatus::initial_state_rejected); + CHECK_FALSE(result.has_state()); + CHECK(starts == 1); + CHECK(finishes == 1); + CHECK(events == 0); + CHECK(status == result.status()); + CHECK(timing.seconds >= 12.0); +} + +TEST_CASE("Optional timing preserves callback stop requests and numerical results", "[solver][nonlinear][observer]") { + Problem problem; + const auto stop = [](const solver::SolveEvent& event, const State&, const auto&) { + return event.kind == solver::EventKind::accepted ? solver::SolveControl::stop : solver::SolveControl::continue_solve; + }; + const auto options = solver::SolverOptions{.strategies = ScalarNewton{}}; + const auto ordinary = solver::solve_newton(problem, State{{0.1}}, options, stop); + solver::SolveTiming timing; + const auto observed = solver::solve_newton(problem, State{{0.1}}, options, solver::TimedObserver{timing, stop}); + CHECK(observed.status() == ordinary.status()); + CHECK(observed.state().values()[0] == ordinary.state().values()[0]); + CHECK(observed.diagnostics().attempted_iterations == ordinary.diagnostics().attempted_iterations); + CHECK(observed.diagnostics().line_search_trials == ordinary.diagnostics().line_search_trials); + CHECK(std::isfinite(timing.seconds)); + CHECK(timing.seconds >= 0.0); +} + +TEST_CASE("Timing does not count solves interrupted by the final user callback", "[solver][nonlinear][observer]") { + Problem problem; + solver::SolveTiming timing{.seconds = 7.0}; + const auto reject_reporting = [](const solver::SolveEvent& event, const State&, const auto&) { + serif::utils::errors::serif_ensure( + event.kind != solver::EventKind::finished, "Injected final reporting failure."); + }; + CHECK_THROWS(solver::solve_newton(problem, State{{1.0}}, solver::SolverOptions{.strategies = ScalarNewton{}}, + solver::TimedObserver{timing, reject_reporting})); + CHECK(timing.seconds == 7.0); +} diff --git a/tests/solver/continuation_controller.cpp b/tests/solver/continuation_controller.cpp new file mode 100644 index 00000000..1263a25c --- /dev/null +++ b/tests/solver/continuation_controller.cpp @@ -0,0 +1,294 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "serif/solver/continuation/controller.hpp" + +namespace continuation_controller_test { + using namespace serif::solver::continuation; + using Catch::Approx; + + /** @brief Hold growth fixed to isolate the selected controller behavior. */ + template CoordinateOptions fixed_steps(double initial, double minimum, double maximum) { + CoordinateOptions options; + options.initial_step_fraction.fill(initial); + options.minimum_step_fraction.fill(minimum); + options.maximum_step_fraction.fill(maximum); + options.growth_factor = 1.; + return options; + } + + struct FakeContext { + std::array coordinates; + std::array fields; + int prepared_generation; + }; +} + +TEST_CASE("Continuation proposals preserve accepted coordinates and external contexts until promotion", + "[unit][solver][continuation_controller][transaction]") { + using namespace continuation_controller_test; + auto options = fixed_steps<2>(.5, .125, 1.); + options.order = CoordinateOrder::preferred; + CoordinateController<2> controller({1., 0.}, {2., .1}, options); + auto accepted = std::make_unique(FakeContext{{1., 0.}, {2., 3., 5.}, 7}); + const auto originalAddress = accepted.get(); + const auto p = controller.propose(); + REQUIRE(p); + CHECK(controller.accepted() == controller.start()); + auto trial = std::make_unique(FakeContext{p->coordinates, {97., 98., 99.}, 8}); + controller.resolve(p->id, AttemptOutcome::recoverable_rejection, 3); + trial.reset(); + CHECK(accepted.get() == originalAddress); + CHECK(accepted->fields == std::array{2., 3., 5.}); + CHECK(accepted->prepared_generation == 7); + CHECK(controller.accepted() == controller.start()); + const auto retry = controller.propose(); + REQUIRE(retry); + CHECK(retry->coordinates[0] == Approx(1.25)); + trial = std::make_unique(FakeContext{retry->coordinates, {11., 13., 17.}, 9}); + controller.validate_resolution(retry->id, AttemptOutcome::success, 2); + CHECK(controller.accepted() == controller.start()); + controller.resolve(retry->id, AttemptOutcome::success, 2); + accepted.swap(trial); // No fallible user code between resolution and swap. + CHECK(controller.accepted() == accepted->coordinates); + CHECK(accepted->prepared_generation == 9); + CHECK(controller.start() == std::array{1., 0.}); + CHECK(controller.target() == std::array{2., .1}); + CHECK(controller.counters().attempts == 2); + CHECK(controller.counters().successes == 1); + CHECK(controller.counters().rejections == 1); + CHECK(controller.counters().work == 5); +} + +TEST_CASE("A preferred coordinate can yield to another axis while strict stages cannot", + "[unit][solver][continuation_controller][order]") { + using namespace continuation_controller_test; + auto options = fixed_steps<2>(1., .125, 1.); + options.maximum_rejections_per_axis = 1; + options.order = CoordinateOrder::preferred; + // This corrector's feasible branch requires the second coordinate first. + const auto correct = [](const auto &p) { return p.coordinates[0] <= p.coordinates[1] + ? AttemptOutcome::success : AttemptOutcome::recoverable_rejection; }; + CoordinateController<2> preferred({0., 0.}, {1., 1.}, options); + std::vector attempted; + while (const auto p = preferred.propose()) { + attempted.push_back(p->axis); + preferred.resolve(p->id, correct(*p), 1); + } + CHECK(attempted == std::vector{0, 1, 0}); + CHECK(preferred.status() == ControllerStatus::complete); + CHECK(preferred.accepted() == preferred.target()); + options.order = CoordinateOrder::strict; + CoordinateController<2> strict({0., 0.}, {1., 1.}, options); + const auto blocked = strict.propose(); + REQUIRE(blocked); + strict.resolve(blocked->id, correct(*blocked), 1); + CHECK(strict.status() == ControllerStatus::no_progress); + CHECK_FALSE(strict.propose()); + CHECK(strict.accepted() == strict.start()); + CHECK(strict.counters().attempts == 1); +} + +TEST_CASE("Automatic progress compares fractions while strict order honors the supplied permutation", + "[unit][solver][continuation_controller][order]") { + using namespace continuation_controller_test; + auto options = fixed_steps<2>(.5, .125, .5); + options.axis_order = {1, 0}; + CoordinateController<2> automatic({0., 1000.}, {1., -1000.}, options); + const auto first = automatic.propose(); + REQUIRE(first); + CHECK(first->axis == 1); + CHECK(first->coordinates == std::array{0., 0.}); + automatic.resolve(first->id, AttemptOutcome::success); + const auto second = automatic.propose(); + REQUIRE(second); + CHECK(second->axis == 0); // Largest remaining fraction, independent of units. + options.order = CoordinateOrder::strict; + CoordinateController<2> strict({0., 1000.}, {1., -1000.}, options); + auto p = strict.propose(); + REQUIRE(p); + strict.resolve(p->id, AttemptOutcome::success); + p = strict.propose(); + REQUIRE(p); + CHECK(p->axis == 1); + CHECK(p->coordinates == std::array{0., -1000.}); +} + +TEST_CASE("Adaptive retries shrink actual clipped progress and successful steps grow within limits", + "[unit][solver][continuation_controller][adaptation]") { + using namespace continuation_controller_test; + auto options = fixed_steps<1>(.5, .05, .5); + options.growth_factor = 2.; + CoordinateController<1> controller({0.}, {1.}, options); + auto p = controller.propose(); + REQUIRE(p); + CHECK(p->coordinates[0] == .5); + controller.resolve(p->id, AttemptOutcome::recoverable_rejection, 4); + p = controller.propose(); + REQUIRE(p); + CHECK(p->coordinates[0] == .25); + controller.resolve(p->id, AttemptOutcome::success, 2); + p = controller.propose(); + REQUIRE(p); + CHECK(p->coordinates[0] == .75); + controller.resolve(p->id, AttemptOutcome::success, 2); + p = controller.propose(); + REQUIRE(p); + CHECK(p->coordinates[0] == 1.); + CHECK(p->step_fraction == .25); + controller.resolve(p->id, AttemptOutcome::recoverable_rejection, 3); + p = controller.propose(); + REQUIRE(p); + CHECK(p->coordinates[0] == .875); // Half the clipped .25, not half the nominal .5. + controller.resolve(p->id, AttemptOutcome::success, 1); + p = controller.propose(); + REQUIRE(p); + CHECK(p->coordinates[0] == 1.); + controller.resolve(p->id, AttemptOutcome::success, 1); + CHECK(controller.status() == ControllerStatus::complete); + CHECK(controller.accepted()[0] == controller.target()[0]); + CHECK(controller.counters().work == 13); +} + +TEST_CASE("Continuation handles zero spans exact endpoints and budgets without extra attempts", + "[unit][solver][continuation_controller][bounds]") { + using namespace continuation_controller_test; + CoordinateController<2> zero({2., .1}, {2., .1}); + CHECK(zero.status() == ControllerStatus::complete); + CHECK_FALSE(zero.propose()); + CHECK(zero.counters().attempts == 0); + auto options = fixed_steps<2>(1., .1, 1.); + options.maximum_attempts = 1; + CoordinateController<2> one({2., .1}, {2., -.2}, options); + auto p = one.propose(); + REQUIRE(p); + CHECK(p->axis == 1); + one.resolve(p->id, AttemptOutcome::success); + CHECK(one.status() == ControllerStatus::complete); // Success wins on the last allowed attempt. + options.initial_step_fraction.fill(.5); + CoordinateController<2> bounded({0., 0.}, {1., 1.}, options); + p = bounded.propose(); + REQUIRE(p); + bounded.resolve(p->id, AttemptOutcome::success); + CHECK(bounded.status() == ControllerStatus::attempt_limit); + CHECK_FALSE(bounded.propose()); + CHECK(bounded.accepted() == std::array{.5, 0.}); +} + +TEST_CASE("Repeated rejection terminates at minimum progress and cannot spin on unrepresentable steps", + "[unit][solver][continuation_controller][bounds]") { + using namespace continuation_controller_test; + auto options = fixed_steps<1>(.5, .125, 1.); + CoordinateController<1> reject({0.}, {1.}, options); + std::vector attempted; + while (const auto p = reject.propose()) { + attempted.push_back(p->coordinates[0]); + reject.resolve(p->id, AttemptOutcome::recoverable_rejection, 1); + } + CHECK(attempted == std::vector{.5, .25, .125}); + CHECK(reject.status() == ControllerStatus::no_progress); + CHECK(reject.counters().work == 3); + CHECK(reject.accepted() == reject.start()); + options.initial_step_fraction[0] = .25; + const double start = 1.; + const double target = std::nextafter(start, 2.); + CoordinateController<1> rounded({start}, {target}, options); + CHECK_FALSE(rounded.propose()); + CHECK(rounded.status() == ControllerStatus::no_progress); + CHECK(rounded.counters().attempts == 0); +} + +TEST_CASE("Decimal continuation endpoints remain exact with reverse and zero-length coordinates", + "[unit][solver][continuation_controller][bounds]") { + using namespace continuation_controller_test; + auto options = fixed_steps<3>(.17, .01, .17); + CoordinateController<3> controller({1.1, -.07, 3.}, {3.3, -.13, 3.}, options); + while (const auto p = controller.propose()) { + const auto previous = controller.accepted(); + for (std::size_t i = 0; i < 3; ++i) { + if (i != p->axis) { + CHECK(p->coordinates[i] == previous[i]); + } else { + CHECK(p->coordinates[i] != previous[i]); + CHECK(p->coordinates[i] >= std::min(controller.start()[i], controller.target()[i])); + CHECK(p->coordinates[i] <= std::max(controller.start()[i], controller.target()[i])); + } + } + controller.resolve(p->id, AttemptOutcome::success, 1); + } + CHECK(controller.status() == ControllerStatus::complete); + CHECK(controller.accepted() == controller.target()); + CHECK(controller.counters().attempts <= 14); +} + +TEST_CASE("Invalid continuation resolutions leave pending and accepted state unchanged", + "[unit][solver][continuation_controller][transaction][invalid]") { + using namespace continuation_controller_test; + auto options = fixed_steps<1>(.25, .125, 1.); + CoordinateController<1> controller({0.}, {1.}, options); + CHECK_THROWS_AS(controller.resolve(1, AttemptOutcome::success), std::logic_error); + const auto p = controller.propose(); + REQUIRE(p); + CHECK_THROWS_AS(controller.propose(), std::logic_error); + CHECK_THROWS_AS(controller.resolve(p->id + 1, AttemptOutcome::success), std::logic_error); + CHECK_THROWS_AS(controller.resolve(p->id, static_cast(99)), std::invalid_argument); + CHECK(controller.pending()->id == p->id); + CHECK(controller.accepted() == controller.start()); + CHECK(controller.counters().work == 0); + controller.resolve(p->id, AttemptOutcome::success, std::numeric_limits::max()); + const auto next = controller.propose(); + REQUIRE(next); + const auto accepted = controller.accepted(); + CHECK_THROWS_AS(controller.validate_resolution(next->id, AttemptOutcome::success, 1), std::overflow_error); + CHECK_THROWS_AS(controller.resolve(next->id, AttemptOutcome::success, 1), std::overflow_error); + CHECK(controller.accepted() == accepted); + CHECK(controller.pending()->id == next->id); + CHECK(controller.counters().successes == 1); + CHECK(controller.counters().work == std::numeric_limits::max()); + controller.resolve(next->id, AttemptOutcome::fatal_failure, 0); + CHECK(controller.status() == ControllerStatus::fatal_failure); + CHECK(controller.accepted() == accepted); + CHECK(controller.counters().fatal_failures == 1); + CHECK_FALSE(controller.propose()); + CHECK_THROWS_AS(controller.resolve(next->id, AttemptOutcome::success), std::logic_error); +} + +TEST_CASE("Continuation rejects unsupported controls before proposing or doing work", + "[unit][solver][continuation_controller][invalid]") { + using namespace continuation_controller_test; + const double nan = std::numeric_limits::quiet_NaN(); + const double inf = std::numeric_limits::infinity(); + for (const double bad : {nan, inf, -inf}) { + CHECK_THROWS_AS((CoordinateController<1>({bad}, {1.})), std::invalid_argument); + CHECK_THROWS_AS((CoordinateController<1>({0.}, {bad})), std::invalid_argument); + } + CHECK_THROWS_AS((CoordinateController<1>({-std::numeric_limits::max()}, + {std::numeric_limits::max()})), std::invalid_argument); + const auto invalid = [](auto change) { + CoordinateOptions<2> options; + change(options); + CHECK_THROWS_AS((CoordinateController<2>({0., 0.}, {1., 1.}, options)), std::invalid_argument); + }; + invalid([&](auto &o) { o.initial_step_fraction[0] = nan; }); + invalid([](auto &o) { o.minimum_step_fraction[1] = 0.; }); + invalid([](auto &o) { o.maximum_step_fraction[0] = 1.1; }); + invalid([](auto &o) { o.minimum_step_fraction[0] = .5; }); + invalid([](auto &o) { o.maximum_step_fraction[0] = .1; }); + invalid([](auto &o) { o.growth_factor = .9; }); + invalid([&](auto &o) { o.growth_factor = inf; }); + invalid([](auto &o) { o.shrink_factor = 0.; }); + invalid([](auto &o) { o.shrink_factor = 1.; }); + invalid([](auto &o) { o.maximum_attempts = 0; }); + invalid([](auto &o) { o.maximum_rejections_per_axis = 0; }); + invalid([](auto &o) { o.axis_order = {0, 0}; }); + invalid([](auto &o) { o.axis_order = {0, 2}; }); + invalid([](auto &o) { o.order = static_cast(99); }); +} diff --git a/tests/solver/continuation_driver.cpp b/tests/solver/continuation_driver.cpp new file mode 100644 index 00000000..cf3bf3e8 --- /dev/null +++ b/tests/solver/continuation_driver.cpp @@ -0,0 +1,227 @@ +#include +#include +#include +#include + +#include + +#include "serif/solver/continuation/polytropic.hpp" +#include "serif/solver/continuation/driver.hpp" + +namespace { + namespace family = serif::solver::continuation; + namespace driver = serif::solver::continuation; + using Momentum = serif::model::FixedAngularMomentum; + using State = std::array; + + /** @brief Define physical continuation without finite-element or nonlinear-solver dependencies. */ + [[nodiscard]] auto make_family() { + const auto target = serif::model::make_stellar_model(serif::eos::models::Polytrope{2.0, 0.73}, []() {}, + Momentum{serif::dimensions::AngularMomentumValue{0.1}}, serif::model::FixedTotalMass{serif::dimensions::MassValue{1.0}}); + return family::in(family::PolytropicIndex{}, family::AngularMomentum{}) + .with(family::preserve_pressure_at_reference_density(serif::dimensions::DensityValue{0.2})).bind(target); + } + + struct Diagnostics { + std::uint64_t attempted_iterations{1}; + std::optional final_residual_norm{0.0}; + }; + struct Report { + bool success{true}; + bool current{true}; + State physical{}; + Diagnostics diagnostic{}; + /** @brief Test the same certificate interface as an owning nonlinear result. */ + [[nodiscard]] bool converged() const { return success; } + /** @brief Rejected initial states have no prepared physical snapshot. */ + [[nodiscard]] bool has_state() const { return current; } + /** @brief Transfer physical rather than normalized coordinates. */ + [[nodiscard]] const State& state() const { return physical; } + /** @brief Expose attempted work even when correction fails. */ + [[nodiscard]] const Diagnostics& diagnostics() const { return diagnostic; } + /** @brief Keep driver diagnostic composition independent of solver-specific status enums. */ + [[nodiscard]] std::string message() const { return success ? "converged" : "corrector rejected"; } + }; +} + +TEST_CASE("Continuation qualifies starting models and transfers only successful physical states", "[unit][solver][continuation][driver]") { + const auto bound = make_family(); + auto options = family::make_options(bound, family::starting_at(family::PolytropicIndex{}, 1.0), family::prefer(family::PolytropicIndex{})); + options.steps.initial_step_fraction.fill(1.0); + options.steps.maximum_rejections_per_axis = 1; + const State initial{2.0, 3.0, 5.0}; + std::vector inputs; + std::vector> attempted; + const auto result = driver::run(bound, initial, [&](auto model, State state, driver::CorrectionControls controls) { + CHECK(controls.absolute_tolerance == 1.0e-8); + inputs.push_back(state); + const double index = model.equation_of_state().polytropic_index(); + const double momentum = model.template specification().target().value(); + attempted.push_back({index, momentum}); + if (index == 2.0 && momentum == 0.0) { + return Report{.success = false, .physical = {999.0, 999.0, 999.0}, .diagnostic = {.attempted_iterations = 3, .final_residual_norm = 2.0}}; + } + state[0] += 1.0; + return Report{.physical = state, .diagnostic = {.attempted_iterations = 2, .final_residual_norm = 1.0e-10}}; + }, options, 1.0e-8); + REQUIRE(result.converged()); + REQUIRE(result.last_converged()); + CHECK(attempted == std::vector>{{1.0, 0.0}, {2.0, 0.0}, {1.0, 0.1}, {2.0, 0.1}}); + CHECK(inputs[1] == inputs[2]); + CHECK(inputs[2][0] == 3.0); + CHECK(initial == State{2.0, 3.0, 5.0}); + CHECK(result.last_converged()->state() == State{5.0, 3.0, 5.0}); + CHECK(result.total_work() == 9); + CHECK(result.total_attempts() == 4); + CHECK(result.counters.rejections == 1); + CHECK(result.accepted_coordinates == result.target); +} + +TEST_CASE("Continuation starting fallbacks are bounded and explicit starts disable them", "[unit][solver][continuation][driver]") { + const auto bound = make_family(); + std::vector> attempted; + const auto correct = [&](auto model, State state, driver::CorrectionControls) { + const auto coordinates = bound.coordinates_of(model); + attempted.push_back(coordinates); + return Report{.success = coordinates == bound.target_coordinates(), .physical = state, + .diagnostic = {.attempted_iterations = 1, .final_residual_norm = coordinates == bound.target_coordinates() ? 0.0 : 1.0}}; + }; + const auto result = driver::run(bound, State{}, correct, family::make_options(bound), 1.0e-8); + CHECK(result.converged()); + CHECK(attempted == std::vector>{{1.0, 0.0}, {2.0, 0.0}, {1.0, 0.1}, {2.0, 0.1}}); + CHECK(result.starting_attempts == 4); + CHECK(result.counters.attempts == 0); + attempted.clear(); + const auto stopped = driver::run(bound, State{}, correct, family::make_options(bound, family::starting_at(family::PolytropicIndex{}, 1.0)), 1.0e-8); + CHECK_FALSE(stopped.converged()); + CHECK(stopped.last_converged() == nullptr); + CHECK(stopped.status == driver::Status::starting_star_failed); + CHECK(attempted.size() == 1); +} + +TEST_CASE("Continuation preserves intermediate ownership when attempt work or progress limits stop it", "[unit][solver][continuation][driver]") { + const auto bound = make_family(); + const auto correct = [](auto, State state, driver::CorrectionControls controls) { + CHECK(controls.maximum_work >= 1); + state[1] += 2.0; + return Report{.physical = state}; + }; + auto options = family::make_options(bound); + options.steps.maximum_attempts = 2; + options.maximum_records = 1; + const auto attempts = driver::run(bound, State{}, correct, options, 1.0e-8); + CHECK_FALSE(attempts.converged()); + CHECK(attempts.status == driver::Status::attempt_limit); + REQUIRE(attempts.last_converged()); + CHECK(attempts.last_converged()->state()[1] == 4.0); + CHECK(attempts.stages.size() == 1); + CHECK(attempts.omitted_records == 1); + CHECK(attempts.total_attempts() == 2); + options.steps.maximum_attempts = 128; + options.maximum_work = 2; + const auto work = driver::run(bound, State{}, correct, options, 1.0e-8); + CHECK(work.status == driver::Status::work_limit); + CHECK(work.total_work() == 2); + CHECK(work.last_converged()->state()[1] == 4.0); + CHECK(attempts.last_converged()->state()[1] == 4.0); + options.maximum_work = 100; + options.steps.maximum_rejections_per_axis = 1; + int calls = 0; + const auto blocked = driver::run(bound, State{}, [&](auto, State state, driver::CorrectionControls) { + return Report{.success = calls++ == 0, .physical = state}; + }, options, 1.0e-8); + CHECK(blocked.status == driver::Status::no_progress); + REQUIRE(blocked.last_converged()); + CHECK(blocked.accepted_coordinates == bound.starting_coordinates()); +} + +TEST_CASE("A supplied starting star must belong to the family and qualify at the fixed absolute tolerance", "[unit][solver][continuation][driver]") { + const auto bound = make_family(); + const driver::StartingStar anchor{bound.model_at({1.5, 0.02}), State{7.0, 11.0, 13.0}}; + int calls = 0; + const auto correct = [&](auto model, State state, driver::CorrectionControls controls) { + if (calls++ == 0) { + CHECK(bound.coordinates_of(model) == std::array{1.5, 0.02}); + CHECK(state == anchor.state); + CHECK(controls.absolute_tolerance == 1.0e-9); + } + return Report{.physical = state, .diagnostic = {.attempted_iterations = 1, .final_residual_norm = 1.0e-10}}; + }; + const auto result = driver::run(bound, anchor, correct, family::make_options(bound), 1.0e-9); + CHECK(result.converged()); + CHECK(calls >= 2); + CHECK(anchor.state == State{7.0, 11.0, 13.0}); + CHECK_THROWS_AS(driver::run(bound, anchor, correct, family::make_options(bound, + family::starting_at(family::PolytropicIndex{}, 1.2)), 1.0e-9), std::invalid_argument); + const auto false_certificate = driver::run(bound, anchor, [](auto, State state, driver::CorrectionControls) { + return Report{.physical = state, .diagnostic = {.final_residual_norm = 1.0}}; + }, family::make_options(bound), 1.0e-9); + CHECK_FALSE(false_certificate.converged()); + CHECK_FALSE(false_certificate.last_converged()); + CHECK(false_certificate.starting_attempts == 1); + const auto unrelated = anchor.model.transform_specifications([](const auto& value) { + if constexpr (std::same_as, serif::model::FixedTotalMass>) { + return serif::model::FixedTotalMass{serif::dimensions::MassValue{2.0}}; + } else { + return value; + } + }); + CHECK_THROWS_AS(driver::run(bound, driver::StartingStar{unrelated, anchor.state}, correct, family::make_options(bound), 1.0e-9), std::invalid_argument); +} + +TEST_CASE("Invalid continuation laws stop without replacing the last converged star", "[unit][solver][continuation][driver]") { + const auto prototype = make_family(); + const auto bound = family::in(family::PolytropicIndex{}).with(family::law(family::PolytropicConstant{}, family::depends_on(family::PolytropicIndex{}), [](double index) { + return index == 1.0 || index == 2.0 ? 0.73 : std::numeric_limits::quiet_NaN(); + })).bind(prototype.target_model()); + auto options = family::make_options(bound); + int calls = 0; + const auto result = driver::run(bound, State{}, [&](auto, State state, driver::CorrectionControls) { + ++calls; + return Report{.physical = state}; + }, options, 1.0e-8); + CHECK(result.status == driver::Status::invalid_model); + CHECK(calls == 1); + REQUIRE(result.last_converged()); + CHECK(result.accepted_coordinates == bound.starting_coordinates()); + CHECK(result.counters.fatal_failures == 1); + CHECK(result.total_attempts() == 2); + CHECK_THROWS_AS(driver::run(bound, State{}, [](auto, State state, driver::CorrectionControls) { return Report{.physical = state}; }, options, 0.0), std::invalid_argument); +} + +TEST_CASE("Continuation rejects invalid residual certificates before promoting any physical state", "[unit][solver][continuation][driver]") { + const auto bound = make_family(); + const State initial{2.0, 3.0, 5.0}; + const std::array, 4> invalid_norms{ + std::nullopt, -1.0, std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity()}; + for (const auto invalid : invalid_norms) { + for (const bool reject_initial : {false, true}) { + auto options = family::make_options(bound, family::starting_at(family::PolytropicIndex{}, 1.0)); + options.steps.maximum_attempts = 2; + int calls = 0; + const auto result = driver::run(bound, initial, [&](auto, State state, driver::CorrectionControls) { + if (++calls == 1 && !reject_initial) { + state[0] += 1.0; + return Report{.physical = state}; + } + return Report{.physical = {999.0, 999.0, 999.0}, .diagnostic = {.final_residual_norm = invalid}}; + }, options, 1.0e-8); + CHECK_FALSE(result.converged()); + REQUIRE(result.failed); + REQUIRE_FALSE(result.stages.empty()); + CHECK(result.stages.back().disposition == driver::StageDisposition::not_converged); + CHECK(initial == State{2.0, 3.0, 5.0}); + if (reject_initial) { + CHECK(result.status == driver::Status::starting_star_failed); + CHECK_FALSE(result.last_converged()); + CHECK(calls == 1); + } else { + CHECK(result.status == driver::Status::attempt_limit); + REQUIRE(result.last_converged()); + CHECK(result.last_converged()->state() == State{3.0, 3.0, 5.0}); + CHECK(result.accepted_coordinates == options.start); + CHECK(calls == 2); + } + } + } +} diff --git a/tests/solver/continuation_equilibrium.cpp b/tests/solver/continuation_equilibrium.cpp new file mode 100644 index 00000000..2d29c529 --- /dev/null +++ b/tests/solver/continuation_equilibrium.cpp @@ -0,0 +1,288 @@ +#include +#include +#include +#include + +#include +#include + +#include "serif/solver/continuation/continuation.hpp" +#include "serif/solver/continuation/equilibrium.hpp" +#include "serif/backend/mfem/solver/seed/state.hpp" +#include "serif/solver/seed/polytrope.hpp" +#include "serif/tests/solver/stellar_fixture.hpp" +#include "serif/utils/dependencies/timing.hpp" + +namespace { + namespace family_api = serif::solver::continuation; + namespace solver = serif::solver; + namespace model = serif::model; + namespace backend = model::backend::mfem; + namespace fixture = serif::tests::solver; + namespace dependencies = serif::utils::dependencies; + namespace dimensions = serif::dimensions; + namespace normalization = solver::normalization; + using Density = fixture::Density; + + /** @brief A known density field isolates public continuation orchestration from stellar conditioning. */ + struct DensityEquation : serif::equations::Contribution> { + using Value = serif::discretization::forms::Operand; + using Operands = serif::utils::types::TypeList; + using ResidualQuantity = dimensions::MassDensity; + static constexpr std::string_view name = "continuation density regression"; + double target; + + /** @brief Bind a physical density target read from the fresh stage's EOS. */ + explicit DensityEquation(double density) : target(density) {} + /** @brief Integrate the difference from the exact representable constant field. */ + [[nodiscard]] serif::equations::PointLinearization evaluate(const serif::equations::EquationPoint& point) const { + serif::equations::PointLinearization result; + result.residual = point.fields.template get() - target; + result.field_derivatives.template get() = 1.0; + return result; + } + /** @brief A constant target adds no polynomial order. */ + [[nodiscard]] int additional_order(Value, int) const { return 0; } + }; + + /** @brief Rebuild an explicitly simple equation from the same EOS parameter family as a full star. */ + [[nodiscard]] auto density_model() { + return model::make_stellar_model(serif::eos::models::Polytrope{1.5, 1.0}, [](const auto& physics) { + return model::EquilibriumEquations{.interior_equations = serif::equations::EquationRegistry{ + DensityEquation{physics.equation_of_state().polytropic_index()}}}; + }); + } + + /** @brief Bind only density and prescribed displacement, retaining the real stellar reference mesh. */ + [[nodiscard]] auto density_discretization(const std::shared_ptr& resources) { + return backend::make_stellar_discretization(resources, dependencies::make_dependency_context( + dependencies::borrow(resources->density), dependencies::borrow(resources->displacement)), + *resources->mesh.exterior_coordinate->values, resources->mapper); + } + + /** @brief Change numerical normalization deliberately while preserving physical coefficient meanings. */ + [[nodiscard]] auto numerical_coordinates(double radius) { + return normalization::NormalizationOptions{.scheme = *normalization::prepare_stellar_reference_scales( + dimensions::MassValue{1.0}, dimensions::LengthValue{radius}, dimensions::QuantityValue{1.0})}; + } + +} + +TEST_CASE("Public continuation solves fresh models and transfers physical finite-element coefficients", "[integration][solver][continuation]") { + const auto resources = fixture::make_resources(); + const auto finite_elements = density_discretization(resources); + const auto family = family_api::in(family_api::PolytropicIndex{}).with( + family_api::preserve_pressure_at_reference_density(dimensions::DensityValue{1.0})).bind(density_model()); + using Description = decltype(family.target_model().equations()(family.target_model())); + using Plan = model::EquilibriumPlan; + ::mfem::Vector displacement(resources->displacement.size()); + displacement = 0.0; + auto state = model::StellarState(backend::equilibrium_layout(finite_elements, displacement)); + std::ranges::fill(state.values(), 0.8); + int configurations = 0; + const auto configure = [&](auto physical) { + ++configurations; + const double n = physical.equation_of_state().polytropic_index(); + return ::serif::model::make_discretization(::serif::model::DiscretizationOptions{ + .model = std::move(physical), .finite_elements = finite_elements, .normalization = numerical_coordinates(n), + .preconditioner = solver::preconditioner::make_preconditioner(solver::preconditioner::strategies::inverse::Diagonal{})}); + }; + auto options = family_api::make_options(family); + options.steps.initial_step_fraction = {1.0}; + const auto result = solver::continue_equilibrium(family, configure, state, displacement, options); + REQUIRE(result.converged()); + REQUIRE(result.last_converged() != nullptr); + CHECK(configurations == 2); + CHECK(result.last_converged()->model().equation_of_state().polytropic_index() == 1.5); + CHECK(result.last_converged()->model().equation_of_state().polytropic_constant() == 1.0); + CHECK(*result.last_converged()->diagnostics().convergence_threshold == 1.0e-10); + CHECK(*result.last_converged()->diagnostics().final_residual_norm <= 1.0e-10); + for (const double density : result.last_converged()->state().values()) { + CHECK(density == Catch::Approx(1.5).margin(1.0e-8)); + } + CHECK(std::ranges::all_of(state.values(), [](double value) { return value == 0.8; })); + + SECTION("a same-size different mesh cannot silently reinterpret physical coefficients") { + const auto other_resources = fixture::make_resources(); + int calls = 0; + const auto changed_resources = [&](auto physical) { + auto configuration = configure(std::move(physical)); + return ::serif::model::DiscretizationOptions{.model = std::move(configuration.model), + .finite_elements = ++calls > 1 ? density_discretization(other_resources) : finite_elements, + .normalization = std::move(configuration.normalization), .preconditioner = std::move(configuration.preconditioner)}; + }; + CHECK_THROWS_AS(solver::continue_equilibrium(family, changed_resources, state, displacement, options), std::invalid_argument); + CHECK(calls == 2); + } + SECTION("the configuration factory cannot change an exact physical target") { + const auto wrong_model = [&](auto physical) { + return configure(physical.transform_specifications([](const auto&) { return serif::eos::models::Polytrope{1.7, 1.0}; })); + }; + CHECK_THROWS_AS(solver::continue_equilibrium(family, wrong_model, state, displacement, options), std::invalid_argument); + } + SECTION("configuration callback failures do not become continuation retries") { + int calls = 0; + const auto rejected = [&](auto physical) { + ++calls; + serif::utils::errors::serif_ensure(false, "Deliberately invalid configuration."); + return configure(std::move(physical)); + }; + CHECK_THROWS_AS(solver::continue_equilibrium(family, rejected, state, displacement, options), std::invalid_argument); + CHECK(calls == 1); + } + SECTION("fixed-geometry solves accept reused and consumed configurations") { + auto configuration = configure(family.model_at(family.target_coordinates())); + auto options = solver::continuation::default_solver_options(); + CHECK(solver::solve_equilibrium(configuration, result.last_converged()->state(), displacement, options).converged()); + CHECK(solver::solve_equilibrium(std::move(configuration), result.last_converged()->state(), displacement, std::move(options)).converged()); + } +} + +TEST_CASE("Public continuation qualifies the complete moving stellar assembly before declaring an anchor", "[integration][solver][continuation]") { + const auto original = fixture::make_model(); + const auto target = model::make_stellar_model(original.equation_of_state(), family_api::identified_equations(original.equations(), + dimensions::QuantityValue{1.0}), + original.specification(), original.specification()); + const auto family = family_api::in(family_api::AngularMomentum{}).bind(target); + const auto radial = solver::seed::make_polytropic_seed(target); + REQUIRE(radial); + const auto resources = fixture::make_resources(radial->scales.radius.value()); + const auto finite_elements = fixture::make_discretization(resources, 0); + const auto state = solver::seed::backend::mfem::make_polytropic_state(target, finite_elements, *radial); + REQUIRE(state); + const auto configure = [&](auto physical) { + return ::serif::model::make_discretization(::serif::model::DiscretizationOptions{.model = std::move(physical), + .finite_elements = finite_elements, .normalization = numerical_coordinates(radial->scales.radius.value())}); + }; + auto solver_options = solver::continuation::default_solver_options(); + solver_options.method.maximum_iterations = 0; + const auto result = solver::continue_equilibrium(family, configure, *state, family_api::make_options(family), solver_options); + CHECK_FALSE(result.converged()); + CHECK(result.last_converged() == nullptr); + REQUIRE(result.failed.get() != nullptr); + CHECK(result.failed.get()->has_state()); + CHECK(result.failed.get()->status() == solver::SolveStatus::maximum_iterations); + CHECK(*result.failed.get()->diagnostics().convergence_threshold == 1.0e-10); + auto configuration = configure(family.model_at(family.target_coordinates())); + CHECK(solver::solve_equilibrium(configuration, *state, solver_options).status() == solver::SolveStatus::maximum_iterations); + CHECK(solver::solve_equilibrium(std::move(configuration), *state, std::move(solver_options)).status() == solver::SolveStatus::maximum_iterations); +} + +TEST_CASE("Graph timing forwards through fixed bindings and preserves solve and restart results", "[integration][solver][graph_timing]") { + const auto physical = density_model(); + const auto resources = fixture::make_resources(); + const auto finite_elements = density_discretization(resources); + using Description = decltype(physical.equations()(physical)); + using Plan = model::EquilibriumPlan; + ::mfem::Vector displacement(resources->displacement.size()); + displacement = 0.0; + auto state = model::StellarState(backend::equilibrium_layout(finite_elements, displacement)); + std::ranges::fill(state.values(), 0.8); + const auto configuration = model::make_discretization(model::DiscretizationOptions{ + .model = physical, .finite_elements = finite_elements, .normalization = numerical_coordinates(1.0), + .preconditioner = solver::preconditioner::make_preconditioner(solver::preconditioner::strategies::inverse::Diagonal{})}); + const auto prescribed = dependencies::make_dependency_context(); + + SECTION("physical and configured bindings accept owning states and explicit field views") { + dependencies::GraphTiming timing; + const auto observer_factory = dependencies::time_graph_nodes(timing); + auto physical_state = backend::bind_stellar_model(physical, finite_elements, state, displacement, {}, prescribed, observer_factory); + auto normalized_state = backend::bind_stellar_model(configuration, state, displacement, {}, prescribed, + backend::detail::NoAdditionalComputations{}, observer_factory); + const auto fields = backend::detail::state_coefficients(finite_elements, state, prescribed); + auto physical_views = backend::bind_stellar_model(physical, finite_elements, fields.context(), displacement, {}, observer_factory); + auto normalized_views = backend::bind_stellar_model(configuration, fields.context(), displacement, {}, observer_factory); + REQUIRE(timing.runs().size() == 4); + REQUIRE(physical_state.evaluate(state, displacement)); + REQUIRE(normalized_state.evaluate(state, displacement)); + REQUIRE(physical_views.evaluate(fields.context(), displacement)); + REQUIRE(normalized_views.evaluate(fields.context(), displacement)); + for (const auto& run : timing.runs()) { + CHECK(std::ranges::any_of(run->nodes, [](const auto& node) { return node.completed != 0; })); + CHECK(std::ranges::all_of(run->nodes, [](const auto& node) { return node.rejected == 0 && node.exceptions == 0; })); + } + const auto first = timing.runs().front()->nodes; + REQUIRE(physical_state.evaluate(state, displacement)); + for (const auto& node : timing.runs().front()->nodes) { + CHECK(node.calls == first[node.index].calls); + CHECK(node.seconds == first[node.index].seconds); + } + } + + SECTION("timing does not change physical results, iteration counts, or checkpoint history") { + auto options = solver::continuation::default_solver_options(); + const auto plain = solver::solve_equilibrium(configuration, state, displacement, options); + dependencies::GraphTiming timing; + const auto timed = solver::solve_equilibrium(configuration, state, displacement, options, solver::NoObserver{}, {}, prescribed, + dependencies::time_graph_nodes(timing)); + REQUIRE(plain.converged()); + REQUIRE(timed.converged()); + CHECK(timed.diagnostics().accepted_iterations == plain.diagnostics().accepted_iterations); + CHECK(timed.diagnostics().total_linear_iterations == plain.diagnostics().total_linear_iterations); + CHECK(timed.diagnostics().line_search_trials == plain.diagnostics().line_search_trials); + CHECK(timed.diagnostics().final_residual_norm == plain.diagnostics().final_residual_norm); + CHECK(std::ranges::equal(timed.state().values(), plain.state().values())); + REQUIRE(timing.runs().size() == 1); + CHECK(std::ranges::any_of(timing.runs().front()->nodes, [](const auto& node) { return node.completed > 1; })); + + options.method.maximum_iterations = 0; + const auto stopped = solver::solve_equilibrium(configuration, state, displacement, options); + REQUIRE(stopped.has_checkpoint()); + CHECK(stopped.status() == solver::SolveStatus::maximum_iterations); + options = solver::continuation::default_solver_options(); + const auto resumed = solver::resume_equilibrium(configuration, stopped.checkpoint(), displacement, options, solver::NoObserver{}, {}, prescribed, + dependencies::time_graph_nodes(timing)); + REQUIRE(resumed.converged()); + CHECK(std::ranges::equal(resumed.state().values(), plain.state().values())); + CHECK(resumed.diagnostics().accepted_iterations == plain.diagnostics().accepted_iterations); + CHECK(resumed.diagnostics().final_residual_norm == plain.diagnostics().final_residual_norm); + REQUIRE(timing.runs().size() == 2); + CHECK(timing.runs()[0] != timing.runs()[1]); + } +} + +TEST_CASE("Moving-star node timings remain separate across a rebuilt checkpoint", "[integration][solver][graph_timing]") { + const auto physical = fixture::make_model(); + const auto radial = solver::seed::make_polytropic_seed(physical); + REQUIRE(radial); + const auto resources = fixture::make_resources(radial->scales.radius.value()); + const auto finite_elements = fixture::make_discretization(resources, 0); + const auto state = solver::seed::backend::mfem::make_polytropic_state(physical, finite_elements, *radial); + REQUIRE(state); + const auto configuration = model::make_discretization(model::DiscretizationOptions{ + .model = physical, .finite_elements = finite_elements, .normalization = numerical_coordinates(radial->scales.radius.value())}); + const auto prescribed = dependencies::make_dependency_context(); + dependencies::GraphTiming physical_timing; + auto physical_evaluator = backend::bind_stellar_model(physical, finite_elements, *state, {}, prescribed, + dependencies::time_graph_nodes(physical_timing)); + REQUIRE(physical_timing.runs().size() == 1); + CHECK(std::ranges::all_of(physical_timing.runs().front()->nodes, [](const auto& node) { return node.calls == 0; })); + CHECK(physical_evaluator.layout() == state->layout()); + + auto options = solver::continuation::default_solver_options(); + options.method.maximum_iterations = 0; + const auto plain = solver::solve_equilibrium(configuration, *state, options); + dependencies::GraphTiming timing; + const auto timed = solver::solve_equilibrium(configuration, *state, options, solver::NoObserver{}, {}, prescribed, + dependencies::time_graph_nodes(timing)); + REQUIRE(plain.has_checkpoint()); + REQUIRE(timed.has_checkpoint()); + CHECK(timed.status() == plain.status()); + CHECK(timed.diagnostics().final_residual_norm == plain.diagnostics().final_residual_norm); + CHECK(std::ranges::equal(timed.state().values(), plain.state().values())); + REQUIRE(timing.runs().size() == 1); + const auto before_resume = timing.runs().front()->nodes; + const auto resumed = solver::resume_equilibrium(configuration, timed.checkpoint(), options, solver::NoObserver{}, {}, prescribed, + dependencies::time_graph_nodes(timing)); + REQUIRE(resumed.has_checkpoint()); + CHECK(resumed.status() == plain.status()); + CHECK(resumed.diagnostics().final_residual_norm == plain.diagnostics().final_residual_norm); + CHECK(std::ranges::equal(resumed.state().values(), plain.state().values())); + REQUIRE(timing.runs().size() == 2); + CHECK(timing.runs()[0] != timing.runs()[1]); + for (const auto& node : timing.runs().front()->nodes) { + CHECK(node.calls == before_resume[node.index].calls); + CHECK(node.seconds == before_resume[node.index].seconds); + CHECK(node.calls == timing.runs()[1]->nodes[node.index].calls); + } +} diff --git a/tests/solver/continuation_family.cpp b/tests/solver/continuation_family.cpp new file mode 100644 index 00000000..de8f1d8a --- /dev/null +++ b/tests/solver/continuation_family.cpp @@ -0,0 +1,223 @@ +#include +#include +#include +#include + +#include +#include + +#include "serif/solver/continuation/polytropic.hpp" +#include "serif/solver/continuation/identified_equations.hpp" + +namespace { + namespace family = serif::solver::continuation; + namespace model = serif::model; + namespace dimensions = serif::dimensions; + using Polytrope = serif::eos::models::Polytrope; + + struct Definition { + double pressure{0.01}; + /** @brief State explicitly which captured physical input belongs to family identity. */ + [[nodiscard]] std::array continuation_identity() const { return {pressure}; } + }; + + /** @brief Retain all physical prescriptions while changing selected independent inputs. */ + [[nodiscard]] auto target_model(double index = 3.0, double constant = 0.36029966935264141, double momentum = 0.035, + double mass = 1.0, double pressure = 0.01, + std::array axis = {1.0, 2.0, 3.0}, std::array center = {0.2, -0.3, 0.4}) { + return model::make_stellar_model(Polytrope{index, constant}, Definition{pressure}, + model::FixedTotalMass{dimensions::MassValue{mass}}, + model::FixedAngularMomentum{{.angular_momentum = dimensions::AngularMomentumValue{momentum}, .axis = axis, .center = center}}); + } + + /** @brief Evaluate pressure independently through the EOS relation. */ + [[nodiscard]] double pressure(const Polytrope& eos, double density) { + return eos.evaluate(serif::eos::relations::PressureFromDensity{}, dimensions::DensityValue{density}).value(); + } + + /** @brief Compare values using a relative tolerance that does not privilege one system of units. */ + [[nodiscard]] bool close(double actual, double expected) { + return std::abs(actual - expected) <= 128.0 * std::numeric_limits::epsilon() * std::abs(expected); + } + + class TestEOS; + struct TestPressure { + using Specification = TestEOS; + using Value = dimensions::PressureValue; + static constexpr std::string_view name = "test pressure"; + static constexpr std::string_view stable_id = "test.pressure"; + /** @brief Read the extension's selected physical pressure. */ + [[nodiscard]] static Value read(const TestEOS& value); + /** @brief Test extension domain validation without exceptions during candidate preparation. */ + [[nodiscard]] static bool valid(Value value) { return std::isfinite(value.value()) && value.value() > 0.0; } + }; + + class TestEOS { + public: + using ContinuationParameters = family::ParameterList; + double pressure{3.0}; + bool ignore_updates{false}; + double clone_offset{}; + /** @brief Include all physical values; fault-injection switches are test behavior. */ + [[nodiscard]] std::array continuation_identity() const { return {pressure}; } + /** @brief Deliberately allow a faulty extension to exercise installed-value readback. */ + template + [[nodiscard]] TestEOS with_continuation_parameters(const Values& values) const { + TestEOS result = *this; + if (!ignore_updates) { + result.pressure = values.get(TestPressure{}).value(); + } + return result; + } + /** @brief Deliberately allow a faulty clone to exercise exact target identity. */ + [[nodiscard]] TestEOS clone_for_continuation() const { + TestEOS result = *this; + result.pressure += clone_offset; + return result; + } + }; + + TestPressure::Value TestPressure::read(const TestEOS& value) { return Value{value.pressure}; } + + struct MoveOnlyDefinition { + std::unique_ptr pressure; + /** @brief Owning captures still identify physical family inputs. */ + [[nodiscard]] std::array continuation_identity() const { return {*pressure}; } + }; +} + +TEST_CASE("Physical continuation preserves pressure pivots and exact target prescriptions", "[unit][solver][continuation][family]") { + const auto target = target_model(); + const auto bound = family::in(family::PolytropicIndex{}, family::AngularMomentum{}) + .with(family::preserve_pressure_at_reference_density(dimensions::DensityValue{0.2})).bind(target); + CHECK(bound.target_coordinates() == std::array{3.0, 0.035}); + CHECK(bound.starting_coordinates() == std::array{1.0, 0.0}); + CHECK(bound.parameter_descriptors()[0].stable_id == "polytrope.index"); + CHECK(bound.parameter_descriptors()[1].stable_id == "fixed_angular_momentum.target"); + for (double index : {1.0, 1.5, 2.0, 2.999999, 3.0, 3.5}) { + const auto stage = bound.model_at({index, 0.02}); + CHECK(close(pressure(stage.equation_of_state(), 0.2), pressure(target.equation_of_state(), 0.2))); + CHECK(stage.template specification().target().value() == 1.0); + CHECK(stage.equations().pressure == 0.01); + const auto& rotation = stage.template specification(); + CHECK(rotation.target().value() == 0.02); + CHECK(rotation.axis() == target.template specification().axis()); + CHECK(rotation.center() == target.template specification().center()); + REQUIRE(bound.try_validate_member(stage)); + } + const auto endpoint = bound.model_at(bound.target_coordinates()); + CHECK(endpoint.equation_of_state().polytropic_constant() == target.equation_of_state().polytropic_constant()); + CHECK(bound.model_signature(endpoint) == bound.model_signature(target)); + CHECK(bound.coordinates_of(endpoint) == bound.target_coordinates()); +} + +TEST_CASE("Pressure pivot families are invariant under consistent changes of physical units", "[unit][solver][continuation][family]") { + constexpr double density = 0.4, density_units = 100.0, pressure_units = 0.01, index = 1.5, constant = 0.73; + const double converted = pressure_units * constant / std::pow(density_units, 1.0 + 1.0 / index); + const auto first = family::in(family::PolytropicIndex{}).with(family::preserve_pressure_at_reference_density(dimensions::DensityValue{density})).bind(target_model(index, constant)); + const auto second = family::in(family::PolytropicIndex{}).with(family::preserve_pressure_at_reference_density(dimensions::DensityValue{density * density_units})).bind(target_model(index, converted)); + for (double candidate : {1.0, 2.0, 3.0}) { + const auto original = first.model_at({candidate}), rescaled = second.model_at({candidate}); + for (double sample : {0.01, 0.4, 3.0}) { + CHECK(close(pressure(rescaled.equation_of_state(), sample * density_units), pressure_units * pressure(original.equation_of_state(), sample))); + } + } +} + +TEST_CASE("Continuation family membership checks all unchanged prescriptions and captured equations", "[unit][solver][continuation][family]") { + const auto bound = family::in(family::AngularMomentum{}).bind(target_model()); + CHECK(bound.try_validate_member(target_model(3.0, 0.36029966935264141, 0.01))); + CHECK_FALSE(bound.try_validate_member(target_model(2.0))); + CHECK_FALSE(bound.try_validate_member(target_model(3.0, 0.3))); + CHECK_FALSE(bound.try_validate_member(target_model(3.0, 0.36029966935264141, 0.01, 2.0))); + CHECK_FALSE(bound.try_validate_member(target_model(3.0, 0.36029966935264141, 0.01, 1.0, 0.02))); + CHECK_FALSE(bound.try_validate_member(target_model(3.0, 0.36029966935264141, 0.01, 1.0, 0.01, {0.0, 0.0, 1.0}))); + CHECK_FALSE(bound.try_validate_member(target_model(3.0, 0.36029966935264141, 0.01, 1.0, 0.01, {1.0, 2.0, 3.0}, {}))); + const auto index_family = family::in(family::PolytropicIndex{}) + .with(family::preserve_pressure_at_reference_density(dimensions::DensityValue{0.2})).bind(target_model()); + CHECK_FALSE(index_family.try_validate_member(target_model(1.5, 0.36029966935264141, 0.035))); +} + +TEST_CASE("Continuation laws report invalid numerical candidates and reject inconsistent configuration", "[unit][solver][continuation][family]") { + const double nan = std::numeric_limits::quiet_NaN(); + for (double invalid : {0.0, -1.0, nan, std::numeric_limits::infinity()}) { + CHECK_THROWS_AS(family::preserve_pressure_at_reference_density(dimensions::DensityValue{invalid}), std::invalid_argument); + } + const auto mismatched = family::in(family::PolytropicIndex{}).with(family::law(family::PolytropicConstant{}, family::depends_on(family::PolytropicIndex{}), [](double) { return 2.0; })); + CHECK_THROWS_AS(mismatched.bind(target_model()), std::invalid_argument); + const auto broken = family::in(family::PolytropicIndex{}).with(family::law(family::PolytropicConstant{}, family::depends_on(family::PolytropicIndex{}), + [nan](double index) { return index == 3.0 ? 0.36029966935264141 : nan; })).bind(target_model()); + CHECK_FALSE(broken.try_model_at({2.0})); + CHECK_FALSE(broken.try_model_at({0.5})); + CHECK_FALSE(broken.try_model_at({nan})); + const auto physical = family::in(family::PolytropicIndex{}, family::AngularMomentum{}) + .with(family::preserve_pressure_at_reference_density(dimensions::DensityValue{0.2})).bind(target_model()); + CHECK_FALSE(physical.try_model_at({0.5, 0.01})); + CHECK_FALSE(physical.try_model_at({2.0, -0.01})); + CHECK_FALSE(physical.try_model_at({std::numeric_limits::infinity(), 0.01})); + CHECK_FALSE(physical.try_model_at({2.0, nan})); + const auto overflowing = family::in(family::PolytropicIndex{}).with( + family::preserve_pressure_at_reference_density(dimensions::DensityValue{std::numeric_limits::max()})); + CHECK_THROWS_AS(overflowing.bind(target_model()), std::invalid_argument); + using Function = double (*)(double); + CHECK_THROWS_AS(family::law(family::PolytropicConstant{}, family::depends_on(family::PolytropicIndex{}), static_cast(nullptr)), std::invalid_argument); + const family::ParameterLaw, Function> bypassed{nullptr}; + CHECK_THROWS_AS(family::in(family::PolytropicIndex{}).with(bypassed).bind(target_model()), std::invalid_argument); + using Member = double (dimensions::AngularMomentumValue::*)() const noexcept; + CHECK_THROWS_AS(family::law(family::PolytropicConstant{}, family::depends_on(family::AngularMomentum{}), static_cast(nullptr)), std::invalid_argument); +} + +TEST_CASE("Continuation owns move-only laws and explicitly reproducible equation definitions", "[unit][solver][continuation][family]") { + auto definition = family::in(family::PolytropicIndex{}).with(family::law(family::PolytropicConstant{}, family::depends_on(family::PolytropicIndex{}), + [constant = std::make_unique(0.3)](double index) { return *constant * index / 3.0; })); + const auto bound = std::move(definition).bind(target_model(3.0, 0.3)); + CHECK(bound.model_at({1.5}).equation_of_state().polytropic_constant() == Catch::Approx(0.15)); + CHECK(bound.model_at({3.0}).equation_of_state().polytropic_constant() == 0.3); + auto target = model::make_stellar_model(Polytrope{3.0, 0.3}, MoveOnlyDefinition{std::make_unique(0.01)}, + model::FixedAngularMomentum{dimensions::AngularMomentumValue{0.1}}); + const auto movable = family::in(family::AngularMomentum{}).bind(std::move(target), [](const MoveOnlyDefinition& original) { + return MoveOnlyDefinition{std::make_unique(*original.pressure)}; + }); + auto first = movable.model_at({0.02}), second = movable.model_at({0.04}); + CHECK(first.equations().pressure.get() != second.equations().pressure.get()); + CHECK(movable.try_validate_member(first)); +} + +TEST_CASE("Custom physical families verify installed values and exact target cloning", "[unit][solver][continuation][family]") { + const auto target = model::make_stellar_model(TestEOS{}, Definition{}); + const auto custom = family::in(TestPressure{}).bind(target); + CHECK(custom.starting_coordinates() == std::array{3.0}); + CHECK(custom.target_coordinates() == std::array{3.0}); + const auto stage = custom.model_at({2.0}); + CHECK(stage.equation_of_state().pressure == 2.0); + CHECK(custom.try_validate_member(stage)); + const auto faulty = model::make_stellar_model(TestEOS{.ignore_updates = true}, Definition{}, + model::FixedAngularMomentum{dimensions::AngularMomentumValue{0.1}}); + const auto ignores = family::in(TestPressure{}).bind(faulty); + CHECK(ignores.try_model_at(ignores.target_coordinates())); + CHECK_FALSE(ignores.try_model_at({2.0})); + CHECK_FALSE(ignores.try_model_at({std::nextafter(3.0, 2.0)})); + CHECK(ignores.target_model().equation_of_state().pressure == 3.0); + const auto derived = family::in(family::AngularMomentum{}).with(family::law(TestPressure{}, family::depends_on(family::AngularMomentum{}), + [](dimensions::AngularMomentumValue momentum) { return dimensions::PressureValue{30.0 * momentum.value()}; })).bind(faulty); + CHECK(derived.try_model_at(derived.target_coordinates())); + CHECK_FALSE(derived.try_model_at({0.05})); + CHECK(derived.target_model().equation_of_state().pressure == 3.0); + const auto bad_clone = model::make_stellar_model(TestEOS{.clone_offset = std::numeric_limits::epsilon() * 3.0}, Definition{}, + model::FixedAngularMomentum{dimensions::AngularMomentumValue{0.1}}); + CHECK_THROWS_AS(family::in(family::AngularMomentum{}).bind(bad_clone), std::invalid_argument); + CHECK(bad_clone.equation_of_state().pressure == 3.0); +} + +TEST_CASE("Identified equation definitions retain typed captures and support move-only callables", "[unit][solver][continuation][family]") { + auto equations = family::identified_equations([offset = std::make_unique(2.0)](const auto& model) { + return model.equation_of_state().polytropic_index() + *offset; + }, dimensions::PressureValue{0.02}, dimensions::QuantityValue{1.0}); + STATIC_CHECK_FALSE(std::copy_constructible); + STATIC_CHECK(std::same_as, dimensions::PressureValue>); + CHECK(equations.continuation_identity() == std::array{0.02, 1.0}); + CHECK(equations(target_model()) == 5.0); + auto moved = std::move(equations); + CHECK(moved(target_model(1.5)) == 3.5); + CHECK(moved.continuation_identity() == std::array{0.02, 1.0}); +} diff --git a/tests/solver/continuation_settings.cpp b/tests/solver/continuation_settings.cpp new file mode 100644 index 00000000..7c4d175a --- /dev/null +++ b/tests/solver/continuation_settings.cpp @@ -0,0 +1,116 @@ +#include + +#include + +#include "serif/solver/continuation/polytropic.hpp" +#include "serif/solver/continuation/options.hpp" + +namespace { + namespace family = serif::solver::continuation; + namespace controller = serif::solver::continuation; + /** @brief Make physical settings tests independent of finite-element resources. */ + [[nodiscard]] auto bound_family() { + const auto target = serif::model::make_stellar_model(serif::eos::models::Polytrope{2.0, 0.73}, []() {}, + serif::model::FixedAngularMomentum{serif::dimensions::AngularMomentumValue{0.1}}); + return family::in(family::PolytropicIndex{}, family::AngularMomentum{}) + .with(family::preserve_pressure_at_reference_density(serif::dimensions::DensityValue{0.2})).bind(target); + } +} + +TEST_CASE("Continuation starting settings reject duplicates and invalid domains without mutation", "[unit][solver][continuation][settings]") { + const auto bound = bound_family(); + auto options = family::make_options(bound); + CHECK(options.start == bound.starting_coordinates()); + CHECK_FALSE(options.explicit_start); + family::starting_at(family::PolytropicIndex{}, 1.5).apply(options); + CHECK(options.start == std::array{1.5, 0.0}); + CHECK(options.specified == std::array{true, false}); + CHECK(options.explicit_start); + CHECK_THROWS_AS(family::starting_at(family::PolytropicIndex{}, 1.7).apply(options), std::invalid_argument); + CHECK(options.start == std::array{1.5, 0.0}); + for (double invalid : {0.5, std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity()}) { + auto unchanged = family::make_options(bound); + CHECK_THROWS_AS(family::starting_at(family::PolytropicIndex{}, invalid).apply(unchanged), std::invalid_argument); + CHECK(unchanged.start == bound.starting_coordinates()); + CHECK(unchanged.specified == std::array{false, false}); + CHECK_FALSE(unchanged.explicit_start); + } + CHECK_THROWS_AS(family::starting_at(family::AngularMomentum{}, serif::dimensions::AngularMomentumValue{-0.01}).apply(options), std::invalid_argument); + CHECK(options.specified == std::array{true, false}); + family::starting_at(family::AngularMomentum{}, serif::dimensions::AngularMomentumValue{0.01}).apply(options); + CHECK(options.start == std::array{1.5, 0.01}); + CHECK(options.specified == std::array{true, true}); + CHECK_THROWS_AS(family::starting_at(family::AngularMomentum{}, serif::dimensions::AngularMomentumValue{0.02}).apply(options), std::invalid_argument); + CHECK(options.start == std::array{1.5, 0.01}); +} + +TEST_CASE("Continuation physical ordering and numerical controls are independent of argument order", "[unit][solver][continuation][settings]") { + const auto bound = bound_family(); + controller::CoordinateOptions<2> numerical; + numerical.initial_step_fraction = {0.1, 0.2}; + numerical.minimum_step_fraction = {0.001, 0.002}; + numerical.maximum_step_fraction = {0.5, 0.75}; + numerical.growth_factor = 1.25; + numerical.shrink_factor = 0.4; + numerical.maximum_attempts = 21; + numerical.maximum_rejections_per_axis = 3; + numerical.order = controller::CoordinateOrder::strict; + const auto first = family::make_options(bound, family::prefer(family::AngularMomentum{}), family::numerics(numerical)); + const auto second = family::make_options(bound, family::numerics(numerical), family::prefer(family::AngularMomentum{})); + for (const auto* options : {&first, &second}) { + CHECK(options->has_ordering); + CHECK(options->has_numerics); + CHECK(options->steps.order == controller::CoordinateOrder::preferred); + CHECK(options->steps.axis_order == std::array{1, 0}); + CHECK(options->steps.initial_step_fraction == numerical.initial_step_fraction); + CHECK(options->steps.minimum_step_fraction == numerical.minimum_step_fraction); + CHECK(options->steps.maximum_step_fraction == numerical.maximum_step_fraction); + CHECK(options->steps.growth_factor == numerical.growth_factor); + CHECK(options->steps.shrink_factor == numerical.shrink_factor); + CHECK(options->steps.maximum_attempts == numerical.maximum_attempts); + CHECK(options->steps.maximum_rejections_per_axis == numerical.maximum_rejections_per_axis); + CHECK(options->start == bound.starting_coordinates()); + CHECK_FALSE(options->explicit_start); + } + auto options = first; + CHECK_THROWS_AS(family::prefer(family::PolytropicIndex{}).apply(options), std::invalid_argument); + CHECK_THROWS_AS(family::stages(family::PolytropicIndex{}, family::AngularMomentum{}).apply(options), std::invalid_argument); + numerical.maximum_attempts = 99; + CHECK_THROWS_AS(family::numerics(numerical).apply(options), std::invalid_argument); + CHECK(options.steps.maximum_attempts == 21); + CHECK(options.steps.initial_step_fraction == first.steps.initial_step_fraction); + CHECK(options.steps.axis_order == first.steps.axis_order); + const auto preferred = family::prefer(family::AngularMomentum{}); + const auto strict = family::stages(family::AngularMomentum{}, family::PolytropicIndex{}); + /** @brief A second ordering never partially changes an already selected route. */ + const auto reject_second = [&](const auto& initial, const auto& duplicate) { + auto retained = family::make_options(bound, initial); + const auto order = retained.steps.order; + const auto axes = retained.steps.axis_order; + CHECK_THROWS_AS(duplicate.apply(retained), std::invalid_argument); + CHECK(retained.steps.order == order); + CHECK(retained.steps.axis_order == axes); + }; + reject_second(preferred, preferred); + reject_second(strict, strict); + reject_second(preferred, strict); + reject_second(strict, preferred); +} + +TEST_CASE("Strict physical continuation stages select a complete coordinate route", "[unit][solver][continuation][settings]") { + const auto bound = bound_family(); + auto options = family::make_options(bound, family::stages(family::AngularMomentum{}, family::PolytropicIndex{})); + options.steps.initial_step_fraction.fill(1.0); + controller::CoordinateController<2> route(options.start, bound.target_coordinates(), options.steps); + const auto rotation = route.propose(); + REQUIRE(rotation); + CHECK(rotation->axis == 1); + CHECK(rotation->coordinates == std::array{1.0, 0.1}); + route.resolve(rotation->id, controller::AttemptOutcome::success); + const auto index = route.propose(); + REQUIRE(index); + CHECK(index->axis == 0); + CHECK(index->coordinates == bound.target_coordinates()); + route.resolve(index->id, controller::AttemptOutcome::success); + CHECK(route.status() == controller::ControllerStatus::complete); +} diff --git a/tests/solver/ester_validation.cpp b/tests/solver/ester_validation.cpp new file mode 100644 index 00000000..dfb0ed74 --- /dev/null +++ b/tests/solver/ester_validation.cpp @@ -0,0 +1,292 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include "mfem/general/communication.hpp" +#include "serif/solver/equilibrium.hpp" +#include "serif/solver/seed/state.hpp" +#include "serif/tests/solver/archive.hpp" +#include "serif/tests/solver/physical_summary.hpp" +#include "serif/tests/solver/virial_output.hpp" + +namespace { + /** @brief Reject partial numerical arguments rather than silently selecting different physics. */ + template + [[nodiscard]] Value argument(std::string_view text) { + Value value{}; + const auto parsed = std::from_chars(text.data(), text.data() + text.size(), value); + serif::utils::errors::serif_ensure( + parsed.ec == std::errc{} && parsed.ptr == text.data() + text.size(), "Invalid numerical argument '{}'.", text); + return value; + } + + /** @brief Keep failed-solve diagnostics valid JSON when a measurement is not finite. */ + [[nodiscard]] std::string number(double value) { + return std::isfinite(value) ? std::format("{:.17g}", value) : "null"; + } + + /** + * @brief Keep the selected equations separate from the meanings and ordering of their shared coordinates. + * @note Bump the version if equation semantics change without changing the registered equation names. + */ + template + [[nodiscard]] std::string formulation_identity(const Model& star) { + const auto description = serif::model::resolve_equilibrium_equations(star); + std::string identity = "formulation=stellar_equations_v1"; + description.interior_equations.for_each([&](const Equation&) { + identity += ';'; + identity += Equation::name; + }); + identity += ";shape="; + identity += std::remove_cvref_t::name; + return identity; + } +} + +/** + * @brief Solve the scalar formulation from a radial seed and measure every archived physical request. + * @note The historical archive supplies mesh topology and sampling requests, never physical field values. + * An optional current-layout checkpoint is independently reevaluated at the declared physics with zero Newton updates. + * Its preparation record must identify the same Kelvin map, mesh, formulation and coefficient layout. + * Legacy or unmarked coordinates require a fresh solve; no convergence status is imported. + * Physical n, K and J are explicit inputs; M=G=1 and P_surface=0 match the reference family. + * Outputs distinguish algebraic convergence from the independent 1e-6 vacuum virial acceptance gate. + * accepted_state.txt remains an algebraic checkpoint; only physical_accepted_state.txt passes both checks. + */ +int main(int argc, char** argv) { + using namespace serif::utils::errors; + + if (argc != 9 && argc != 10) { + std::println(stderr, "Usage: ester_validation REPORT CASE OUTPUT n K J NEWTON_CAP KRYLOV_CAP [RECHECK_STATE]"); + return 2; + } +#ifdef MFEM_USE_MPI + mfem::Mpi::Init(argc, argv); +#else + std::println(stderr, "ESTER validation requires the default Hypre/MPI stellar inverse."); + return 2; +#endif + namespace fixture = serif::tests::solver; + namespace archive = fixture::archive; + namespace solver = serif::solver; + namespace model = serif::model; + namespace normalization = solver::normalization; + + const std::filesystem::path report = argv[1], output = argv[3]; + const std::string name = argv[2]; + const double n = argument(argv[4]), K = argument(argv[5]), J = argument(argv[6]); + const int updates = argument(argv[7]), krylov = argument(argv[8]); + const bool recheck = argc == 10; + serif_ensure(!recheck || updates == 0, + "A saved-state recheck must use zero Newton updates; its convergence is evaluated afresh, never imported."); + serif_ensure(std::isfinite(n) && n >= 1.0 && n < 3.0 && + std::isfinite(K) && K > 0.0 && std::isfinite(J) && J >= 0.0 && updates >= 0 && updates <= 30 && + krylov > 0 && krylov <= 600 && !std::filesystem::exists(output), + "Require 1<=n<3, positive K, nonnegative J, bounded iteration limits, and a fresh output directory."); + serif_ensure(std::filesystem::create_directories(output), "Cannot create fresh output directory."); + std::ofstream progress(output / "preparation.txt"); + progress << std::setprecision(17) << "archive=" << std::quoted(report.string()) << "\nrequest_case=" << std::quoted(name) + << "\nn=" << n << "\nK=" << K << "\nM=1\nG=1\nJ=" << J << "\nP_surface=0\nnewton_cap=" << updates + << "\nkrylov_cap=" << krylov << '\n'; + // A last stage without 'complete' is an incomplete run, including an exception or process failure. + const auto stage = [&](std::string_view value) { + progress << "stage=" << value << '\n'; + progress.flush(); + serif_ensure(progress.good(), "Cannot preserve preparation progress."); + }; + stage("metadata"); + const auto source = report / "inputs" / name; + const auto metadata = archive::metadata(source); + const auto parameter = [&](const char* key) { return std::stod(metadata.at(key)); }; + serif_ensure(parameter("central_phase") == 0.0 && parameter("Psurf") == 0.0 && + parameter("M") == 1.0 && parameter("G") == 1.0 && parameter("R_reference") == 1.0 && + parameter("surface_degree") == 4.0 && parameter("rotation_axis_x") == 0.0 && parameter("rotation_axis_y") == 0.0 && + parameter("rotation_axis_z") == 1.0 && parameter("rotation_center_x") == 0.0 && + parameter("rotation_center_y") == 0.0 && parameter("rotation_center_z") == 0.0, + "The reference requests must use the documented fixed-M/G vacuum branch and rotation convention."); + stage("mesh_load"); + auto mesh = stroid::IO::LoadStroidMesh((source / "mesh.smesh").string()); + if (!mesh) { + stage("failed_mesh_load"); + std::println(stderr, "Cannot load the reference mesh: {}", mesh.error()); + return 2; + } + serif_ensure(mesh->config.r_star.has_value() && mesh->config.r_infinity.has_value() && + mesh->config.r_star.value() == 1.0 && mesh->config.r_infinity.value() == 5.0, + "The bounded archive study requires mesh reference radii one and five."); + // Match the existing exterior coordinate's mesh radii. Record this changed map before any state import. + constexpr std::string_view mapping_identity = "mapping=kelvin_mesh_radii_v1,r_star_ref=1,r_inf_ref=5"; + progress << mapping_identity << '\n'; + stage("mapping_identity"); + if (recheck) { + const auto preparation_path = std::filesystem::path(argv[9]).parent_path() / "preparation.txt"; + std::ifstream preparation(preparation_path); + archive::require_preparation_identity(preparation, mapping_identity); + } + const double reference_radius = mesh->config.r_star.value(); + const double infinity_radius = mesh->config.r_infinity.value(); + const auto resources = std::make_shared(std::move(*mesh), reference_radius, infinity_radius); + const auto finite_elements = fixture::make_discretization(resources, 4); + const auto star = fixture::make_model(n, K, 1.0, J, 1.0); + const std::string formulation = formulation_identity(star); + progress << formulation << '\n'; + stage("formulation_identity"); + if (recheck) { + std::ifstream preparation(std::filesystem::path(argv[9]).parent_path() / "preparation.txt"); + archive::require_preparation_identity(preparation, formulation); + } + stage("radial_seed"); + const auto radial = solver::seed::make_polytropic_seed(star, {.integration = {.step = 0.001}}); + if (!radial) { + stage("failed_radial_seed"); + std::println(stderr, "Radial seed failed: {}", radial.error()); + return 2; + } + stage("radial_fit"); + const auto fitted = solver::seed::fit_radial_seed(finite_elements, *radial); + if (!fitted) { + stage("failed_radial_fit"); + std::println(stderr, "Reference-surface fit failed: {}", fitted.error()); + return 2; + } + stage("state_projection"); + auto initial = solver::seed::make_polytropic_state(star, finite_elements, *fitted); + if (!initial) { + stage("failed_state_projection"); + std::println(stderr, "Radial projection failed: {}", initial.error()); + return 2; + } + stage("surface_basis"); + const auto basis_source = std::filesystem::exists(source / "surface_basis.csv") ? source : report / "inputs" / "n1_J0p05"; + const double basis_error = archive::verify_surface_basis(basis_source, finite_elements, *initial); + const std::string coordinates = archive::coordinate_identity(initial->layout(), archive::mesh_identity(source / "mesh.smesh")); + progress << coordinates << '\n'; + stage("coordinate_identity"); + if (recheck) { + stage("saved_state_recheck"); + std::ifstream preparation(std::filesystem::path(argv[9]).parent_path() / "preparation.txt"); + archive::require_preparation_identity(preparation, coordinates); + const auto values = archive::read_coefficients(argv[9], initial->values().size()); + std::copy(values.begin(), values.end(), initial->values().begin()); + progress << "recheck_state=" << std::quoted(std::filesystem::absolute(argv[9]).string()) << '\n'; + progress.flush(); + serif_ensure(progress.good(), "Cannot record the saved-state input."); + } + archive::save_coefficients(output / "initial_state.txt", initial->values()); + std::ofstream seed_summary(output / "seed.json"); + seed_summary << "{\"radial_radius\":" << number(radial->scales.radius.value()) + << ",\"radial_mass\":" << number(radial->scales.mass.value()) + << ",\"radial_central_density\":" << number(radial->scales.central_density.value()) + << ",\"fitted_radius\":" << number(fitted->radius.value()) << ",\"fitted_mass\":" << number(fitted->mass.value()) << "}\n"; + seed_summary.close(); + serif_ensure(seed_summary.good(), "Cannot write radial-seed diagnostics."); + + stage("normalization"); + const auto reference = normalization::prepare_stellar_reference_scales(serif::dimensions::MassValue{1.0}, + serif::dimensions::LengthValue{1.0}, serif::dimensions::QuantityValue{1.0}); + if (!reference) { + stage("failed_normalization"); + std::println(stderr, "Normalization failed: {}", reference.error()); + return 2; + } + auto configuration = model::make_discretization(model::DiscretizationOptions{ + .model = star, .finite_elements = finite_elements, + .normalization = normalization::NormalizationOptions{.scheme = *reference, .coordinates = normalization::Dimensionless{}, + .gravitational_constant = serif::dimensions::QuantityValue{1.0}} + }); + const solver::SolverOptions options{ + .method = {.relative_tolerance = 0.0, .absolute_tolerance = 1.0e-10, .maximum_iterations = updates, + .linear_solve = {.relative_tolerance = 0.003, .absolute_tolerance = 0.0, .maximum_iterations = krylov}}, + .linear_solver = {.restart_length = 160, .print_level = 1} + }; + const auto observe = [&](const solver::SolveEvent& event, const auto& state, const auto& system) { + std::println("event {}, iteration {}, trial {}, residual {}, step {}: {}", static_cast(event.kind), event.iteration, + event.trial, event.metric ? number(event.metric->residual_norm) : "unmeasured", event.step, event.message); + if (system) { + std::size_t offset = 0; + for (const auto size : state.layout().residual_sizes) { + const auto first = system->residual().begin() + offset; + std::println("row offset {}, count {}, norm {}", offset, size, std::sqrt(std::inner_product(first, first + size, first, 0.0))); + offset += size; + } + } + if (event.kind == solver::EventKind::accepted) { + archive::save_coefficients(output / "progress_state.txt", state.values()); + } + std::fflush(stdout); + }; + stage("nonlinear_solve"); + const auto started = std::chrono::steady_clock::now(); + const auto result = solver::solve_equilibrium(std::move(configuration), std::move(*initial), options, observe); + const double seconds = std::chrono::duration(std::chrono::steady_clock::now() - started).count(); + const auto& diagnostics = result.diagnostics(); + stage("vacuum_virial"); + std::expected virial = std::unexpected("The nonlinear solve returned no final state."); + if (result.has_state()) { + archive::save_coefficients(output / "last_state.txt", result.state().values()); + if (result.converged()) { + archive::save_coefficients(output / "accepted_state.txt", result.state().values()); + } + // The driver enforces the physical z axis and origin above; M=R_reference=G=1 fixes the units. + const double omega = result.state().template get()[0]; + const serif::equations::RigidRotation rotation{{0.0, 0.0, omega}}; + virial = fixture::vacuum_virial(finite_elements, result.state(), star.equation_of_state(), rotation); + } + const bool physical_accepted = result.converged() && virial && virial->passes(); + fixture::write_vacuum_virial(output, virial, result.converged()); + std::ofstream summary(output / "solve.json"); + summary << std::setprecision(17) << "{\"n\":" << n << ",\"K\":" << K << ",\"M\":1,\"G\":1,\"J\":" << J + << ",\"P_surface\":0,\"formulation\":\"density_primal\",\"initial_kind\":\"" + << (recheck ? "saved_state_recheck" : "fitted_radial_seed") << "\",\"sample_units\":\"canonical\"" + << ",\"formulation_identity\":" << std::quoted(formulation) + << ",\"seed_case\":\"" << name << "\"" + << ",\"converged\":" << (result.converged() ? "true" : "false") << ",\"status\":" << static_cast(result.status()) + << ",\"converged_meaning\":\"algebraic\",\"accepted_state_meaning\":\"algebraic_checkpoint\"" + << ",\"physical_accepted\":" << (physical_accepted ? "true" : "false") + << ",\"vacuum_virial_valid\":" << (virial ? "true" : "false") + << ",\"energy_virial_signed\":" << (virial ? number(virial->samples.back().energy_virial()) : "null") + << ",\"force_virial_signed\":" << (virial ? number(virial->samples.back().force_virial()) : "null") + << ",\"virial_quadrature_allowance\":" << (virial ? number(virial->quadrature_allowance) : "null") + << ",\"virial_tolerance\":1e-6,\"virial_integration_allowance_limit\":1e-7" + << ",\"newton_cap\":" << updates << ",\"krylov_cap\":" << krylov + << ",\"nonlinear_tolerance\":1e-10,\"linear_relative_tolerance\":0.003,\"restart_length\":160" + << ",\"accepted_iterations\":" << diagnostics.accepted_iterations << ",\"attempted_iterations\":" << diagnostics.attempted_iterations + << ",\"krylov_iterations\":" << diagnostics.total_linear_iterations << ",\"rejected_trials\":" << diagnostics.rejected_trials + << ",\"last_step\":" << number(diagnostics.last_accepted_step) << ",\"solve_seconds\":" << number(seconds) + << ",\"initial_residual\":" << number(diagnostics.initial_residual_norm.value_or(-1.0)) + << ",\"final_residual\":" << number(diagnostics.final_residual_norm.value_or(-1.0)) + << ",\"basis_error\":" << number(basis_error) << ",\"basis_source\":\"" << basis_source.filename().string() << "\""; + if (diagnostics.last_linear_solve) { + summary << ",\"last_linear_relative_residual\":" << number(diagnostics.last_linear_solve->relative_true_residual_norm); + } + summary << "}\n"; + summary.close(); + serif_ensure(summary.good(), "Cannot write complete solve diagnostics."); + std::println("Termination: {}", result.message()); + stage("physical_sampling"); + if (result.has_state()) { + if (physical_accepted) { + archive::save_coefficients(output / "physical_accepted_state.txt", result.state().values()); + } + archive::sample(report / "meanfield_runs" / name / "samples.csv", output / "samples.csv", finite_elements, result.state(), n, K); + archive::sample_summary(output, finite_elements, result.state(), report / "meanfield_runs" / name / "surface.csv"); + } + if (physical_accepted) { stage("complete_physical_accepted"); } + else if (result.converged()) { stage("complete_algebraically_converged_physical_rejected"); } + else { stage("complete_unconverged"); } + std::println("Physical acceptance: {} (full vacuum virial tolerance 1e-6)", physical_accepted ? "passed" : "rejected"); + return physical_accepted ? 0 : 1; +} diff --git a/tests/solver/linear.cpp b/tests/solver/linear.cpp new file mode 100644 index 00000000..f32c524e --- /dev/null +++ b/tests/solver/linear.cpp @@ -0,0 +1,220 @@ +#include +#include +#include + +#include +#include +#include "mfem/linalg/densemat.hpp" +#include "serif/backend/mfem/solver/linear/solve.hpp" +#include "serif/utils/error/errors.hpp" +#include "serif/backend/mfem/operators/actions.hpp" + +namespace { + namespace linear = serif::solver::linear; + constexpr auto fixed = serif::solver::preconditioner::ApplicationContract::fixed_linear; + + /** @brief Keep an exact identity inverse while checking the original operator address is preserved. */ + class IdentityInverse final : public ::mfem::Solver { + public: + /** @brief Bind one test linearization. */ + explicit IdentityInverse(const ::mfem::Operator& operation) : ::mfem::Solver(operation.Width()), m_operation(operation) {} + + /** @brief Reject a counting wrapper or unrelated operator replacing the accepted linearization. */ + void SetOperator(const ::mfem::Operator& operation) override { REQUIRE(&operation == &m_operation); } + + /** @brief Copy the correction without changing its coordinates. */ + void Mult(const ::mfem::Vector& input, ::mfem::Vector& output) const override { output = input; } + + private: + const ::mfem::Operator& m_operation; + }; + + /** @brief Use MeanField's nonsymmetric two-coordinate problem, with solution (0.1, 0.6). */ + [[nodiscard]] ::mfem::DenseMatrix example_matrix() { + ::mfem::DenseMatrix matrix(2); + matrix(0, 0) = 4.0; + matrix(0, 1) = 1.0; + matrix(1, 0) = 2.0; + matrix(1, 1) = 3.0; + return matrix; + } +} + +TEST_CASE("Checked FGMRES preserves restart counts and RHS-relative warm-start accuracy", "[solver][linear]") { + const ::mfem::DenseMatrix matrix = example_matrix(); + IdentityInverse inverse(matrix); + ::mfem::Vector rhs(2), correction(2); + rhs[0] = 1.0; + rhs[1] = 2.0; + correction = 0.0; + const linear::FGMRES options{.restart_length = 1}; + const linear::Control control{.relative_tolerance = 1.0e-12, .absolute_tolerance = 1.0e-14, .maximum_iterations = 200}; + const auto cold = linear::backend::mfem::solve(options, matrix, inverse, fixed, rhs, correction, control); + REQUIRE(cold.converged()); + CHECK(correction[0] == Catch::Approx(0.1).margin(1.0e-11)); + CHECK(correction[1] == Catch::Approx(0.6).margin(1.0e-11)); + CHECK(cold.right_hand_side_norm == Catch::Approx(std::sqrt(5.0))); + CHECK(cold.true_residual_norm <= control.convergence_threshold(cold.right_hand_side_norm)); + CHECK(cold.iterations == static_cast(cold.preconditioner_applications)); + CHECK(cold.iterations > 1); + CHECK(cold.iterations <= control.maximum_iterations); + CHECK(cold.restarts == cold.iterations - 1); + CHECK(cold.operator_applications > cold.preconditioner_applications); + CHECK(cold.solve_seconds >= 0.0); + CHECK(cold.operator_seconds >= 0.0); + CHECK(cold.preconditioner_seconds >= 0.0); + + correction[0] += 1.0e-5; + correction[1] -= 1.0e-5; + const auto warm = linear::backend::mfem::solve(options, matrix, inverse, fixed, rhs, correction, control); + REQUIRE(warm.converged()); + CHECK(warm.initial_residual_norm < cold.initial_residual_norm); + CHECK(warm.true_residual_norm <= control.convergence_threshold(warm.right_hand_side_norm)); +} + +TEST_CASE("Checked FGMRES discards an adverse or overflowing finite guess", "[solver][linear]") { + const ::mfem::DenseMatrix matrix = example_matrix(); + IdentityInverse inverse(matrix); + ::mfem::Vector rhs(2), correction(2); + rhs[0] = 1.0; + rhs[1] = 2.0; + correction = 0.0; + const linear::FGMRES options{.restart_length = 1}; + const linear::Control control{.relative_tolerance = 1.0e-12, .absolute_tolerance = 1.0e-14, .maximum_iterations = 200}; + const auto cold = linear::backend::mfem::solve(options, matrix, inverse, fixed, rhs, correction, control); + REQUIRE(cold.converged()); + SECTION("Finite adverse coefficients") { + correction[0] = 100.0; + correction[1] = -100.0; + } + SECTION("Finite coefficients with overflowing action") { + correction = std::numeric_limits::max() / 2.0; + } + const auto guarded = linear::backend::mfem::solve(options, matrix, inverse, fixed, rhs, correction, control); + REQUIRE(guarded.converged()); + CHECK(guarded.initial_residual_norm == Catch::Approx(cold.right_hand_side_norm)); + CHECK(guarded.iterations == cold.iterations); + CHECK(guarded.operator_applications == cold.operator_applications + 1); +} + +TEST_CASE("Checked FGMRES verifies exact guesses and zero right-hand sides without Krylov iterations", "[solver][linear]") { + const ::mfem::DenseMatrix matrix = example_matrix(); + IdentityInverse inverse(matrix); + ::mfem::Vector rhs(2), correction(2); + correction[0] = 1.0; + correction[1] = 2.0; + matrix.Mult(correction, rhs); + SECTION("Exact nonzero initial guess") { + const auto report = linear::backend::mfem::solve({}, matrix, inverse, fixed, rhs, correction); + REQUIRE(report.converged()); + CHECK(report.iterations == 0); + CHECK(report.restarts == 0); + CHECK(report.operator_applications == 2); + CHECK(report.true_residual_norm == 0.0); + } + SECTION("Zero equation discards a nonzero guess") { + rhs = 0.0; + const auto report = linear::backend::mfem::solve({}, matrix, inverse, fixed, rhs, correction); + REQUIRE(report.converged()); + CHECK(report.iterations == 0); + CHECK(report.relative_true_residual_norm == 0.0); + CHECK(correction.Norml2() == 0.0); + } + SECTION("Variable preconditioner contract is supported") { + const auto report = linear::backend::mfem::solve({}, matrix, inverse, + serif::solver::preconditioner::ApplicationContract::variable, rhs, correction); + CHECK(report.converged()); + } +} + +TEST_CASE("Checked FGMRES reports exhausted budgets and nonfinite numerical results", "[solver][linear]") { + ::mfem::DenseMatrix matrix = example_matrix(); + IdentityInverse inverse(matrix); + ::mfem::Vector rhs(2), correction(2); + rhs[0] = 1.0; + rhs[1] = 2.0; + correction = 0.0; + SECTION("A single Krylov iteration is insufficient") { + const auto report = linear::backend::mfem::solve({}, matrix, inverse, fixed, rhs, correction, + {.relative_tolerance = 1.0e-14, .maximum_iterations = 1}); + CHECK(report.status == linear::Status::maximum_iterations); + CHECK(report.iterations == 1); + CHECK(report.true_residual_norm > report.control.convergence_threshold(report.right_hand_side_norm)); + } + SECTION("Nonfinite RHS is numerical failure") { + rhs[0] = std::numeric_limits::infinity(); + const auto report = linear::backend::mfem::solve({}, matrix, inverse, fixed, rhs, correction); + CHECK(report.status == linear::Status::non_finite); + CHECK(report.operator_applications == 0); + } + SECTION("Nonfinite initial correction is numerical failure") { + correction[0] = std::numeric_limits::quiet_NaN(); + CHECK(linear::backend::mfem::solve({}, matrix, inverse, fixed, rhs, correction).status == linear::Status::non_finite); + } + SECTION("SERiF action overflow is a numerical report, without catching unrelated callback errors") { + const auto action = serif::operators::backend::mfem::linearized_action(2, 2, + [](const ::mfem::Vector&, ::mfem::Vector& output) { output = std::numeric_limits::infinity(); }, + [](const ::mfem::Vector&, ::mfem::Vector& output) { output = std::numeric_limits::infinity(); }); + IdentityInverse action_inverse(*action.action()); + CHECK(linear::backend::mfem::solve({}, *action.action(), action_inverse, fixed, rhs, correction).status == linear::Status::non_finite); + } + SECTION("Nonfinite operator action cannot converge") { + matrix(0, 0) = std::numeric_limits::quiet_NaN(); + CHECK(linear::backend::mfem::solve({}, matrix, inverse, fixed, rhs, correction).status == linear::Status::non_finite); + } +} + +TEST_CASE("Checked FGMRES independently rejects misleading backend convergence", "[solver][linear]") { + /** @brief Mimic a stale callback whose final action disagrees with the Krylov recurrence. */ + class ChangingOperator final : public ::mfem::Operator { + public: + /** @brief Use one coordinate so the recurrence converges in one iteration. */ + ChangingOperator() : ::mfem::Operator(1) {} + /** @brief Change only after MFEM's initial and Arnoldi operator applications. */ + void Mult(const ::mfem::Vector& input, ::mfem::Vector& output) const override { + ++applications; + output[0] = input[0] * (applications > 2 ? 2.0 : 1.0); + } + mutable int applications{0}; + } operation; + IdentityInverse inverse(operation); + ::mfem::Vector rhs(1), correction(1); + rhs = 1.0; + correction = 0.0; + const auto report = linear::backend::mfem::solve({}, operation, inverse, fixed, rhs, correction); + CHECK(report.status == linear::Status::backend_failure); + CHECK(report.reported_residual_norm == 0.0); + CHECK(report.true_residual_norm == 1.0); +} + +TEST_CASE("Checked FGMRES validates configuration and preserves callback errors", "[solver][linear]") { + CHECK_THROWS_AS(linear::FGMRES{.restart_length = 0}.validate(), std::invalid_argument); + CHECK_THROWS_AS(linear::FGMRES{.print_level = 4}.validate(), std::invalid_argument); + for (const double invalid : {-1.0, std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity()}) { + CHECK_THROWS_AS(linear::Control{.relative_tolerance = invalid}.validate(), std::invalid_argument); + CHECK_THROWS_AS(linear::Control{.absolute_tolerance = invalid}.validate(), std::invalid_argument); + CHECK_THROWS_AS(linear::Control{}.convergence_threshold(invalid), std::invalid_argument); + } + CHECK_THROWS_AS(linear::Control{.maximum_iterations = 0}.validate(), std::invalid_argument); + CHECK_THROWS_AS(linear::Control{.relative_tolerance = std::numeric_limits::max()}.convergence_threshold(2.0), std::invalid_argument); + const linear::Control control{.relative_tolerance = 0.25, .absolute_tolerance = 0.5}; + CHECK(control.convergence_threshold(4.0) == 1.0); + CHECK(control.convergence_threshold(0.0) == 0.5); + /** @brief Model a genuine programming error rather than a numerical rejection. */ + class BrokenOperator final : public ::mfem::Operator { + public: + /** @brief Match one valid coefficient. */ + BrokenOperator() : ::mfem::Operator(1) {} + /** @brief Exercise the error boundary without introducing a broad catch in the solver. */ + void Mult(const ::mfem::Vector&, ::mfem::Vector&) const override { + serif::utils::errors::serif_error("Broken callback contract."); + } + } operation; + IdentityInverse inverse(operation); + ::mfem::Vector rhs(1), correction(1); + rhs = 1.0; + correction = 0.0; + CHECK_THROWS_AS(linear::backend::mfem::solve({}, operation, inverse, fixed, rhs, correction), std::logic_error); + correction.SetSize(2); + CHECK_THROWS_AS(linear::backend::mfem::solve({}, operation, inverse, fixed, rhs, correction), std::invalid_argument); +} diff --git a/tests/solver/linear_overflow.cpp b/tests/solver/linear_overflow.cpp new file mode 100644 index 00000000..7edc1bf6 --- /dev/null +++ b/tests/solver/linear_overflow.cpp @@ -0,0 +1,99 @@ +#include +#include +#include +#include +#include +#include "mfem/fem/fe_coll.hpp" +#include "serif/backend/mfem/discretization/dof_map.hpp" +#include "serif/backend/mfem/solver/linear/solve.hpp" +#include "serif/backend/mfem/solver/normalization/operators.hpp" +#include "serif/backend/mfem/operators/actions.hpp" +#include "serif/backend/mfem/geometry/surface/boundary_rows.hpp" + +namespace { + /** @brief Select every serial DOF through the same map used by production term bindings. */ + class FullBinding : public serif::discretization::backend::mfem::DofMap { + /** @brief Use all elements of the test domain. */ + [[nodiscard]] static ::mfem::Array support(const ::mfem::FiniteElementSpace& space) { + ::mfem::Array supported(space.GetNE()); + supported = 1; + return supported; + } + public: + /** @brief Borrow the immutable finite-element space. */ + explicit FullBinding(::mfem::FiniteElementSpace& space) : DofMap(space, support(space)) {} + }; + + struct Fixture { + ::mfem::Mesh mesh{::mfem::Mesh::MakeCartesian3D(2, 2, 1, ::mfem::Element::HEXAHEDRON)}; + ::mfem::H1_FECollection collection{3, 3}; + ::mfem::FiniteElementSpace space{&mesh, &collection}; + FullBinding binding{space}; + }; + + /** @brief Exercise the real physical DOF input check before the outer action adapter can inspect its output. */ + class CheckedPhysicalIdentity final : public ::mfem::Operator { + public: + /** @brief Borrow the same supported finite-element binding used by field actions. */ + explicit CheckedPhysicalIdentity(const FullBinding& binding) : ::mfem::Operator(binding.size()), m_binding(binding) {} + /** @brief Validate dimensional coefficients at the physical action boundary. */ + void Mult(const ::mfem::Vector& input, ::mfem::Vector& output) const override { + m_binding.validate_values(input); + output = input; + } + private: + const FullBinding& m_binding; + }; + /** @brief Keep this failure-classification test independent of inverse conditioning. */ + class IdentityInverse final : public ::mfem::Solver { + public: + /** @brief Match the physical coefficient count. */ + explicit IdentityInverse(int size) : ::mfem::Solver(size) {} + /** @brief The identity inverse does not store a borrowed operator. */ + void SetOperator(const ::mfem::Operator&) override {} + /** @brief Apply the unchanged vector. */ + void Mult(const ::mfem::Vector& input, ::mfem::Vector& output) const override { output = input; } + }; +} + +TEST_CASE("Checked Krylov classifies physical DOF and pressure-boundary overflow without hiding shape errors", "[solver][linear][overflow]") { + namespace solver = serif::solver; + Fixture fixture; + IdentityInverse inverse(fixture.binding.size()); + ::mfem::Vector rhs(fixture.binding.size()), correction(rhs.Size()); + rhs = 0.0; + rhs[0] = 1.0; + SECTION("Overflowing a finite recycled guess inside physical normalization resets it to zero") { + const CheckedPhysicalIdentity physical(fixture.binding); + const double tiny = std::numeric_limits::min(); + const auto coordinates = solver::normalization::make_diagonal_normalization(std::vector(rhs.Size(), tiny), std::vector(rhs.Size(), tiny)); + REQUIRE(coordinates); + solver::normalization::backend::mfem::ScaledJacobianOperator normalized(physical, *coordinates); + correction = 1.0e100; + const auto report = solver::linear::backend::mfem::solve({}, normalized, inverse, + solver::preconditioner::ApplicationContract::fixed_linear, rhs, correction); + REQUIRE(report.converged()); + CHECK(report.initial_residual_norm == 1.0); + CHECK(correction[0] == Catch::Approx(1.0)); + } + SECTION("A finite boundary derivative producing infinity becomes a numerical failure report") { + struct Boundary { std::shared_ptr selected_rows; }; + ::mfem::Array rows{0}; + const Boundary boundary{std::make_shared(fixture.binding, rows)}; + const auto action = [boundary](const auto& input, auto& output) { + output = input; + ::mfem::Vector values(1); + values[0] = input[0] * std::numeric_limits::max(); + values[0] *= std::numeric_limits::max(); + serif::geometry::surface::backend::mfem::detail::replace_boundary_rows(boundary, values, output); + }; + const auto operation = serif::operators::backend::mfem::linearized_action(rhs.Size(), rhs.Size(), action, action); + correction = 0.0; + const auto report = solver::linear::backend::mfem::solve({}, *operation.action(), inverse, + solver::preconditioner::ApplicationContract::fixed_linear, rhs, correction); + CHECK(report.status == solver::linear::Status::non_finite); + } + ::mfem::Vector wrong_size(1); + wrong_size = 0.0; + CHECK_THROWS_AS(fixture.binding.validate_values(wrong_size), std::invalid_argument); +} diff --git a/tests/solver/meson.build b/tests/solver/meson.build new file mode 100644 index 00000000..a40f2bd4 --- /dev/null +++ b/tests/solver/meson.build @@ -0,0 +1,32 @@ +solver_test_sources = files('linear.cpp', 'linear_overflow.cpp', 'seed.cpp', 'nonlinear.cpp', 'nonlinear_parity.cpp', 'strategies.cpp', + 'nonconforming.cpp', 'continuation_controller.cpp', 'continuation_family.cpp', 'continuation_driver.cpp', 'continuation_settings.cpp', 'continuation_equilibrium.cpp', 'archive.cpp', 'virial.cpp') +serif_solver_tests = executable( + 'serif_solver_tests', + solver_test_sources, + dependencies: [serif_dep, catch2_dep, stroid_dep], + include_directories: include_directories('../include'), + build_rpath: mfem_runtime_prefix == '' ? '' : mfem_runtime_prefix / 'lib', +) +# Complete seed regressions assemble real moving stars, including in debug builds. +test('serif_solver_tests', serif_solver_tests, args: ['~[stellar_solve]~[ester]~[nonconforming_stellar_solve]'], suite: 'solver', timeout: 1800, is_parallel: false) + +# Keep the coupled hanging-node regression visible in the normal suite with its own budget. +test('serif_nonconforming_stellar_tests', serif_solver_tests, args: ['[nonconforming_stellar_solve]'], + suite: 'solver', timeout: 3600, is_parallel: false) + +# The archived validation study is an explicit long-running reproduction tool. +ester_validation = executable( + 'ester_validation', + 'ester_validation.cpp', + dependencies: [serif_dep, stroid_dep], + include_directories: include_directories('../include'), + build_rpath: mfem_runtime_prefix == '' ? '' : mfem_runtime_prefix / 'lib', +) + +# Exercise the shared acceptance loop without any finite-element backend. +serif_core_solver_tests = executable( + 'serif_core_solver_tests', + 'backend_independent.cpp', + dependencies: [serif_core_dep, catch2_dep], +) +test('serif_core_solver_tests', serif_core_solver_tests, suite: 'solver', timeout: 30) diff --git a/tests/solver/nonconforming.cpp b/tests/solver/nonconforming.cpp new file mode 100644 index 00000000..518f85e7 --- /dev/null +++ b/tests/solver/nonconforming.cpp @@ -0,0 +1,69 @@ +#include +#include + +#include +#include +#include "mfem/general/communication.hpp" +#include "serif/backend/mfem/solver/seed/state.hpp" +#include "serif/solver/equilibrium.hpp" +#include "serif/solver/seed/polytrope.hpp" +#include "serif/tests/nonconforming.hpp" +#include "serif/tests/solver/stellar_fixture.hpp" +#include "serif/tests/solver/virial.hpp" + +namespace { + namespace solver = serif::solver; + namespace fixture = serif::tests::solver; +} + +TEST_CASE("A fixed STROID nonconforming mesh supports a complete coupled stellar solve", "[solver][nonconforming][nonconforming_stellar_solve]") { + if (!mfem::Mpi::IsInitialized()) { + mfem::Mpi::Init(); + } + const auto model = fixture::make_model(); + const auto seed = solver::seed::make_polytropic_seed(model); + REQUIRE(seed); + CHECK(seed->scales.radius.value() == Catch::Approx(1.0).margin(2.0e-10)); + const auto resources = std::make_shared(serif::tests::nonconforming::mesh(1, 2, 6)); + REQUIRE(resources->potential_space.GetTrueVSize() < resources->potential_space.GetVSize()); + const auto finite_elements = fixture::make_discretization(resources, 0); + const auto initial = solver::seed::backend::mfem::make_polytropic_state(model, finite_elements, *seed); + REQUIRE(initial); + const auto scales = solver::normalization::prepare_stellar_reference_scales( + serif::dimensions::MassValue{1.0}, serif::dimensions::LengthValue{1.0}); + REQUIRE(scales); + const auto configuration = serif::model::make_discretization(serif::model::DiscretizationOptions{ + .model = model, .finite_elements = finite_elements, + .normalization = solver::normalization::NormalizationOptions{.scheme = *scales}}); + const solver::SolverOptions options{ + .method = {.relative_tolerance = 0.0, .absolute_tolerance = 1.0e-10, .maximum_iterations = 20, + .linear_solve = {.relative_tolerance = 1.0e-3, .maximum_iterations = 200}}}; + const auto result = solver::solve_equilibrium(configuration, *initial, options); + INFO(result.message()); + REQUIRE(result.converged()); + REQUIRE(result.diagnostics().final_residual_norm); + CHECK(*result.diagnostics().final_residual_norm <= 1.0e-10); + CHECK(result.diagnostics().accepted_iterations > 0); + + const auto& state = result.state(); + const auto trace = serif::discretization::backend::mfem::select_boundary_dofs< + serif::discretization::domain::StellarSurfaceBoundary>(resources->density); + const auto density = state.template get(); + for (const int dof : trace.reduced_dofs()) { + CHECK(density[dof] == 0.0); + } + const double omega = state.template get()[0]; + const auto virial = fixture::vacuum_virial(finite_elements, state, model.equation_of_state(), + serif::equations::RigidRotation{{0.0, 0.0, omega}}); + if (!virial) { + INFO(virial.error()); + } + REQUIRE(virial); + const auto& physical = virial->samples.back(); + INFO("Full virial = " << physical.energy_virial() << ", quadrature allowance = " << virial->quadrature_allowance + << ", passes unchanged 1e-6 gate = " << virial->passes()); + CHECK(physical.mass == Catch::Approx(1.0).margin(2.0e-9)); + CHECK(std::abs(omega) < 1.0e-12); + CHECK(std::isfinite(physical.energy_virial())); + CHECK(virial->quadrature_allowance < 1.0e-7); +} diff --git a/tests/solver/nonlinear.cpp b/tests/solver/nonlinear.cpp new file mode 100644 index 00000000..66a11751 --- /dev/null +++ b/tests/solver/nonlinear.cpp @@ -0,0 +1,561 @@ +#include "serif/tests/solver/native_problem.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include "mfem/linalg/densemat.hpp" +#include "serif/solver/nonlinear/newton.hpp" +#include "serif/solver/normalization/diagonal.hpp" + +namespace { + namespace solver = serif::solver; + using Catch::Matchers::WithinAbs; + + /** @brief A one-coordinate physical state exercises the same owning-candidate contract as a star. */ + struct State { + std::vector coefficients; + /** @brief Edit the sole physical unknown. */ + std::span values() { return coefficients; } + /** @brief Inspect immutable accepted physical coordinates. */ + std::span values() const { return coefficients; } + }; + + /** @brief Freeze a nonlinear residual and derivative in deliberately nontrivial coordinates. */ + struct System { + std::vector residual_values; + ::mfem::DenseMatrix derivative{1}; + solver::normalization::DiagonalNormalization normalization; + /** @brief Use state factor two and residual factor three to detect coordinate mixups. */ + System(const double residual, const double jacobian) : residual_values{3.0 * residual}, + normalization(*solver::normalization::make_diagonal_normalization({2.0}, {3.0})) { + derivative(0, 0) = 1.5 * jacobian; + } + /** @brief Read the immutable normalized residual. */ + const std::vector& residual() const { return residual_values; } + /** @brief Read the matching normalized derivative. */ + const ::mfem::Operator& jacobian() const { return derivative; } + /** @brief Recover physical trial steps with the accepted coordinate factors. */ + const auto& coordinates() const { return normalization; } + }; + + /** @brief A scalar inverse keeps this regression independent of stellar preconditioning quality. */ + struct Inverse : ::mfem::Solver { + double reciprocal; + /** @brief Freeze the inverse derivative belonging to one prepared state. */ + explicit Inverse(const double value) : ::mfem::Solver(1), reciprocal(1.0 / value) {} + /** @brief Reject rebinding, which would violate frozen preparation ownership. */ + void SetOperator(const ::mfem::Operator&) override {} + /** @brief Apply the exact scalar inverse. */ + void Mult(const ::mfem::Vector& input, ::mfem::Vector& output) const override { output[0] = reciprocal * input[0]; } + }; + + struct Prepared { + std::shared_ptr owner; + mutable Inverse inverse; + /** @brief Retain the system before borrowing its derivative. */ + explicit Prepared(std::shared_ptr system) : owner(std::move(system)), inverse(owner->derivative(0, 0)) {} + /** @brief Inspect the matching frozen residual and Jacobian. */ + const auto& system() const { return owner; } + /** @brief Borrow the inverse while its owner remains alive. */ + ::mfem::Solver& preconditioner() const { return inverse; } + /** @brief This scalar inverse has a fixed linear application. */ + auto contract() const { return solver::preconditioner::ApplicationContract::fixed_linear; } + }; + + /** @brief A positive-domain scalar equation reproduces numerical rejections and preparation failures. */ + struct Problem : serif::tests::solver::NativeProblem { + using System = ::System; + using Model = solver::NoModel; + std::function(double)> equation = [](const double x) { return std::pair{x * x - 2.0, 2.0 * x}; }; + std::function preparation_allowed = [](double) { return true; }; + int evaluations{0}; + bool numerical_rejections{true}; + /** @brief Algebraic tests have no descriptive stellar model. */ + std::shared_ptr model_owner() const { return {}; } + /** @brief Reject nonfinite or negative candidates before publication. */ + std::expected, std::string> evaluate(const State& state) { + ++evaluations; + const double x = state.values()[0]; + if (!std::isfinite(x) || x < 0.0) { + return std::unexpected("outside physical domain"); + } + const auto [residual, derivative] = equation(x); + return std::make_shared(residual, derivative); + } + /** @brief Inject inverse preparation failure without destroying accepted residual handles. */ + std::expected, std::string> prepare(const State& state) { + if (!preparation_allowed(state.values()[0])) { + return std::unexpected("injected preparation failure"); + } + const auto evaluated = evaluate(state); + if (!evaluated) { return std::unexpected(evaluated.error()); } + return std::make_shared(*evaluated); + } + /** @brief Preserve the physical candidate diagnostic. */ + std::string failure_message(const std::string& failure) const { return failure; } + /** @brief A shorter scalar trial can reenter the positive domain. */ + bool recoverable(const std::string&) const { return numerical_rejections; } + }; + + /** @brief Model an affine element determinant using the same physical coordinate as the scalar equation. */ + struct GeometryProblem : Problem { + double determinant_direction{0.0}; + int inverted_trials{0}; + int limit_calls{0}; + /** @brief Validate every candidate even when proactive geometry checks are disabled. */ + std::expected, std::string> evaluate(const State& state) { + if (state.values()[0] <= 0.0) { + ++inverted_trials; + return std::unexpected("inverted element"); + } + return Problem::evaluate(state); + } + /** @brief Capture a physical tangent and return the last safe rung before its determinant vanishes. */ + std::expected geometry_limit( + const State&, std::span direction, double maximum, double safety, bool) { + ++limit_calls; + determinant_direction = direction[0]; + CHECK(maximum == 1.0); + CHECK(safety == 0.9); + return serif::geometry::deformation::GeometryStepLimit{.step = 0.25, .boundary_step = 1.0 / 3.0, .limited_by_geometry = true}; + } + /** @brief Use the same determinant estimate to skip unsafe backtracking rungs after an actual rejection. */ + double next_step(double step, const solver::Backtracking& options, const auto& evaluation) const { + REQUIRE_FALSE(evaluation); + CHECK(evaluation.error() == "inverted element"); + return solver::next_backtracking_step(step, 1.0, 1.0 + determinant_direction * step, true, options); + } + }; + + /** @brief Inject a failed strategy evaluation to check numerical versus configuration errors. */ + struct FailedInitialization : solver::StandardNewton { + /** @brief Mimic a strategy replacing the initial evaluation with a rejected candidate. */ + solver::TrialAssessment initialize(Problem&, State&, std::expected, std::string>& evaluation, const auto&) { + evaluation = std::unexpected("injected initialization failure"); + return {.usable = false}; + } + }; + + /** @brief Return prescribed corrections to isolate orchestration from linear convergence. */ + struct ScriptedBackend { + std::shared_ptr> guesses{std::make_shared>()}; + std::function correction; + /** @brief Capture the safeguarded remainder carried between accepted nonlinear iterations. */ + solver::linear::Report solve(const ::mfem::Operator&, ::mfem::Solver&, solver::preconditioner::ApplicationContract, + const ::mfem::Vector&, ::mfem::Vector& output, solver::linear::Control) const { + guesses->push_back(output[0]); + correction(static_cast(guesses->size()), output); + return {.status = solver::linear::Status::converged}; + } + }; +} + +TEST_CASE("Newton converges in physical coordinates and owns its accepted checkpoint", "[solver][nonlinear]") { + std::shared_ptr first; + Problem problem; + const auto observer = [&](const solver::SolveEvent& event, const State&, const auto& system) { + if (event.kind == solver::EventKind::initial) { first = system; } + }; + auto result = solver::solve_newton(problem, State{{1.0}}, solver::SolverOptions{}, observer); + REQUIRE(result.converged()); + REQUIRE(result.has_state()); + CHECK_THAT(result.state().values()[0], WithinAbs(std::sqrt(2.0), 1.0e-8)); + CHECK(first->residual()[0] == -3.0); + CHECK(result.system() != first); + CHECK(result.diagnostics().accepted_iterations > 0); + CHECK(*result.diagnostics().final_residual_norm <= *result.diagnostics().convergence_threshold); +} + +TEST_CASE("A converged terminal root needs no inverse of its singular Jacobian", "[solver][nonlinear]") { + Problem problem; + problem.equation = [](double value) { + return std::pair{(value - 1.0) * (value - 1.0) * (value + 1.0), 3.0 * value * value - 2.0 * value - 1.0}; + }; + int preparations = 0; + problem.preparation_allowed = [&](double value) { + ++preparations; + return value != 1.0; + }; + const State original{{0.0}}; + const auto result = solver::solve_newton(problem, original); + REQUIRE(result.converged()); + CHECK(result.state().values()[0] == 1.0); + CHECK(result.system()->residual()[0] == 0.0); + CHECK(result.system()->derivative(0, 0) == 0.0); + CHECK(result.diagnostics().accepted_iterations == 1); + CHECK(result.diagnostics().attempted_iterations == 1); + CHECK(preparations == 1); + CHECK(original.values()[0] == 0.0); +} + +TEST_CASE("Newton separates rejected initial guesses from accepted iteration-limit checkpoints", "[solver][nonlinear]") { + Problem problem; + SECTION("rejected initial state") { + const auto result = solver::solve_newton(problem, State{{-1.0}}); + CHECK(result.status() == solver::SolveStatus::initial_state_rejected); + CHECK_FALSE(result.has_state()); + CHECK_THROWS_AS(result.state(), std::logic_error); + } + SECTION("zero budget") { + problem.preparation_allowed = [](double) { return false; }; + const auto result = solver::solve_newton(problem, State{{1.0}}, solver::SolverOptions{.method = {.maximum_iterations = 0}}); + CHECK(result.status() == solver::SolveStatus::maximum_iterations); + CHECK(result.state().values()[0] == 1.0); + CHECK(result.diagnostics().attempted_iterations == 0); + } + SECTION("last accepted iteration") { + const auto result = solver::solve_newton(problem, State{{1.0}}, solver::SolverOptions{.method = {.maximum_iterations = 1}}); + CHECK(result.status() == solver::SolveStatus::maximum_iterations); + CHECK(result.state().values()[0] == 1.5); + CHECK(result.diagnostics().accepted_iterations == 1); + } +} + +TEST_CASE("Newton retains the accepted state when preparing an otherwise acceptable candidate fails", "[solver][nonlinear]") { + Problem problem; + SECTION("a numerical rejection returns the accepted checkpoint") { + problem.preparation_allowed = [](double x) { return x == 1.0; }; + const auto result = solver::solve_newton(problem, State{{1.0}}); + CHECK(result.status() == solver::SolveStatus::preparation_failure); + CHECK(result.state().values()[0] == 1.0); + CHECK(result.system()->residual()[0] == -3.0); + CHECK(result.diagnostics().accepted_iterations == 0); + } + SECTION("a preparation exception propagates before any candidate is accepted") { + const State original{{1.0}}; + std::shared_ptr checkpoint; + int preparations = 0; + int accepted = 0; + problem.preparation_allowed = [&](double x) { + ++preparations; + serif::utils::errors::serif_ensure( + x == 1.0, "injected preconditioner refresh failure"); + return true; + }; + const auto observe = [&](const solver::SolveEvent& event, const State&, const auto& system) { + if (event.kind == solver::EventKind::initial) { + checkpoint = system; + } + if (event.kind == solver::EventKind::accepted) { + ++accepted; + } + }; + CHECK_THROWS_WITH(solver::solve_newton(problem, original, solver::SolverOptions{}, observe), + Catch::Matchers::ContainsSubstring("injected preconditioner refresh failure")); + REQUIRE(checkpoint); + CHECK(preparations == 2); + CHECK(accepted == 0); + CHECK(original.values()[0] == 1.0); + CHECK(checkpoint->residual()[0] == -3.0); + problem.preparation_allowed = [](double) { return true; }; + CHECK(solver::solve_newton(problem, original).converged()); + CHECK(checkpoint->residual()[0] == -3.0); + } +} + +TEST_CASE("Newton rejects trials without altering accepted handles and reuses its damped correction", "[solver][nonlinear]") { + Problem problem; + problem.equation = [](double x) { return std::pair{x, 1.0}; }; + ScriptedBackend backend{.correction = [](int call, ::mfem::Vector& correction) { correction[0] = call == 1 ? -6.0 : -0.5; }}; + std::vector accepted_steps; + std::shared_ptr initial; + const auto observer = [&](const solver::SolveEvent& event, const State&, const auto& system) { + if (event.kind == solver::EventKind::initial) { initial = system; } + if (event.kind == solver::EventKind::trial_rejected) { + CHECK(initial->residual()[0] == 3.0); + CHECK_FALSE(system); + CHECK_FALSE(event.metric.has_value()); + } + if (event.kind == solver::EventKind::accepted) { + REQUIRE(event.metric.has_value()); + CHECK(event.metric->valid()); + accepted_steps.push_back(event.step); + } + }; + const auto result = solver::solve_newton(problem, State{{1.0}}, + solver::SolverOptions{.method = {.relative_tolerance = 0.0, .absolute_tolerance = 1.0e-12, .maximum_iterations = 2}, .linear_solver = backend}, observer); + REQUIRE(accepted_steps.size() == 2); + CHECK(accepted_steps[0] == 0.25); + CHECK(accepted_steps[1] == 0.5); + REQUIRE(backend.guesses->size() == 2); + CHECK((*backend.guesses)[0] == 0.0); + CHECK((*backend.guesses)[1] == -4.5); + CHECK(result.diagnostics().rejected_trials == 2); + CHECK(result.state().values()[0] == 0.125); +} + +TEST_CASE("Newton reports metric failures without accepting the failed metric", "[solver][nonlinear]") { + Problem problem; + int calls = 0; + SECTION("initial nonfinite norm") { + const auto metric = [](std::span) { return solver::MetricEvaluation{.residual_norm = std::numeric_limits::quiet_NaN()}; }; + const auto result = solver::solve_newton(problem, State{{1.0}}, solver::SolverOptions{.metric = metric}); + CHECK(result.status() == solver::SolveStatus::invalid_metric); + CHECK_FALSE(result.has_state()); + } + SECTION("one nonfinite trial merit") { + const auto metric = [&](std::span residual) { + if (++calls == 2) { return solver::MetricEvaluation{.merit = std::numeric_limits::infinity()}; } + return solver::NormalizedResidualMetric{}(residual); + }; + const auto result = solver::solve_newton(problem, State{{1.0}}, solver::SolverOptions{.metric = metric}); + CHECK(result.converged()); + CHECK(result.diagnostics().rejected_trials >= 1); + } +} + +TEST_CASE("Newton checks custom correction dimensions and finiteness", "[solver][nonlinear]") { + Problem problem; + SECTION("nonfinite correction is numerical failure") { + ScriptedBackend backend{.correction = [](int, ::mfem::Vector& correction) { correction[0] = std::numeric_limits::infinity(); }}; + const auto result = solver::solve_newton(problem, State{{1.0}}, solver::SolverOptions{.linear_solver = backend}); + CHECK(result.status() == solver::SolveStatus::linear_solve_failure); + CHECK(result.state().values()[0] == 1.0); + } + SECTION("wrong correction size is a backend contract error") { + ScriptedBackend backend{.correction = [](int, ::mfem::Vector& correction) { correction.SetSize(2); }}; + CHECK_THROWS_AS(solver::solve_newton(problem, State{{1.0}}, solver::SolverOptions{.linear_solver = backend}), std::logic_error); + } +} + +TEST_CASE("Newton propagates observer errors while previously published snapshots remain valid", "[solver][nonlinear]") { + for (const auto failure_phase : {solver::EventKind::initial, solver::EventKind::before_iteration, + solver::EventKind::trial_rejected, solver::EventKind::before_accept, solver::EventKind::accepted, solver::EventKind::finished}) { + CAPTURE(static_cast(failure_phase)); + Problem problem; + problem.equation = [](double value) { return std::pair{value, 1.0}; }; + const State original{{1.0}}; + std::shared_ptr checkpoint; + bool reached_failure = false; + auto observer = [&, token = std::make_unique(19)](const solver::SolveEvent& event, const State&, const auto& system) { + CHECK(*token == 19); + if (event.kind == solver::EventKind::initial) { + checkpoint = system; + } + if (event.kind == failure_phase) { + reached_failure = true; + serif::utils::errors::serif_error( + "injected observer local cause at phase {}", static_cast(failure_phase)); + } + }; + ScriptedBackend backend{.correction = [](int, ::mfem::Vector& correction) { correction[0] = -6.0; }}; + const auto options = solver::SolverOptions{.method = {.maximum_iterations = 1}, .linear_solver = backend}; + CHECK_THROWS_WITH(solver::solve_newton(problem, original, options, std::move(observer)), + Catch::Matchers::ContainsSubstring("injected observer local cause at phase")); + CHECK(reached_failure); + REQUIRE(checkpoint); + CHECK(checkpoint->residual()[0] == 3.0); + CHECK(original.values()[0] == 1.0); + const auto recovered = solver::solve_newton(problem, original); + CHECK(recovered.converged()); + CHECK(recovered.state().values()[0] == 0.0); + CHECK(checkpoint->residual()[0] == 3.0); + } +} + +TEST_CASE("Backtracking geometry estimates skip only unsafe ladder rungs", "[solver][nonlinear][geometry]") { + const solver::Backtracking options; + CHECK(solver::next_backtracking_step(1.0, 1.0, -9.0, true, options) == 0.0625); + CHECK(solver::next_backtracking_step(1.0, 1.0, -9.0, false, options) == 0.5); + CHECK(solver::next_backtracking_step(1.0, 1.0, std::numeric_limits::quiet_NaN(), true, options) == 0.5); + CHECK_THROWS_AS(solver::Backtracking{.contraction = 1.0}.validate(), std::logic_error); + CHECK_THROWS_AS(solver::Newton{.relative_tolerance = -1.0}.validate(), std::logic_error); +} + +TEST_CASE("Newton distinguishes strategy initialization rejection from invalid configuration", "[solver][nonlinear]") { + Problem problem; + const solver::SolverOptions options{.strategies = FailedInitialization{}}; + SECTION("numerical rejection returns an unsuccessful result") { + const auto result = solver::solve_newton(problem, State{{1.0}}, options); + CHECK(result.status() == solver::SolveStatus::initial_state_rejected); + CHECK(result.message() == "injected initialization failure"); + CHECK_FALSE(result.has_state()); + } + SECTION("configuration failure propagates") { + problem.numerical_rejections = false; + CHECK_THROWS_AS(solver::solve_newton(problem, State{{1.0}}, options), std::logic_error); + } +} + +TEST_CASE("Newton geometry safeguards use physical directions and retain validation when preflight is disabled", "[solver][nonlinear][geometry]") { + for (const bool proactive : {false, true}) { + GeometryProblem problem; + problem.equation = [](double value) { return std::pair{value, 1.0}; }; + ScriptedBackend backend{.correction = [](int, ::mfem::Vector& correction) { correction[0] = -6.0; }}; + std::shared_ptr first; + const auto observe = [&](const solver::SolveEvent& event, const State&, const auto& system) { + if (event.kind == solver::EventKind::initial) { + first = system; + } + }; + const auto result = solver::solve_newton(problem, State{{1.0}}, + solver::SolverOptions{.method = {.maximum_iterations = 1}, .linear_solver = backend, + .line_search = {.geometry_preflight = proactive}}, observe); + CHECK(problem.limit_calls == 1); + CHECK(problem.determinant_direction == -3.0); + CHECK(problem.inverted_trials == static_cast(!proactive)); + CHECK(result.diagnostics().rejected_trials == static_cast(!proactive)); + CHECK(result.diagnostics().geometry_limited_iterations == static_cast(proactive)); + CHECK(result.diagnostics().last_accepted_step == 0.25); + CHECK(result.state().values()[0] == 0.25); + REQUIRE(first); + CHECK(first->residual()[0] == 3.0); + } +} + +namespace { + /** @brief Reject one otherwise evaluable trial explicitly, without retaining hidden rejection state. */ + struct RejectFirstTrial : solver::StandardNewton { + int attempts{0}; + /** @brief The second trial uses the original checked direction and may be accepted normally. */ + solver::TrialAssessment trial(auto&, const auto&, const auto&, const auto&, double, auto&, auto&, const auto&, double) { + if (++attempts == 1) { return {.usable = false, .message = "injected numerical trial rejection"}; } + return {}; + } + }; +} + +TEST_CASE("Explicit numerical trial rejection cannot invalidate the following candidate", "[solver][nonlinear]") { + Problem problem; + int rejections = 0; + const auto observe = [&](const solver::SolveEvent& event, const State&, const auto& system) { + if (event.kind == solver::EventKind::trial_rejected) { + ++rejections; + CHECK(event.message == "injected numerical trial rejection"); + CHECK(system != nullptr); + CHECK_FALSE(event.metric.has_value()); + } + }; + const auto result = solver::solve_newton(problem, State{{1.0}}, + solver::SolverOptions{.strategies = RejectFirstTrial{}}, observe); + CHECK(result.converged()); + CHECK(rejections == 1); + CHECK_THAT(result.state().values()[0], WithinAbs(std::sqrt(2.0), 1.0e-8)); +} + +TEST_CASE("Newton resumes the original accepted boundary including damped correction carry", "[solver][nonlinear][restart]") { + Problem uninterrupted_problem; + const solver::SolverOptions options{.method = {.relative_tolerance = 1.0e-10, .maximum_iterations = 20, + .linear_solve = {.relative_tolerance = 1.0e-12}}}; + const auto uninterrupted = solver::solve_newton(uninterrupted_problem, State{{0.1}}, options); + REQUIRE(uninterrupted.converged()); + Problem interrupted_problem; + const auto stop = [](const solver::SolveEvent& event, const auto&, const auto&) { + return event.kind == solver::EventKind::accepted ? solver::SolveControl::stop : solver::SolveControl::continue_solve; + }; + solver::SolveTiming timing; + const auto interrupted = solver::solve_newton(interrupted_problem, State{{0.1}}, options, solver::TimedObserver{timing, stop}); + const double interrupted_seconds = timing.seconds; + REQUIRE(interrupted.status() == solver::SolveStatus::stopped); + REQUIRE(interrupted.has_checkpoint()); + const auto checkpoint = interrupted.checkpoint(); + REQUIRE(checkpoint.progress.diagnostics.accepted_iterations == 1); + CHECK(checkpoint.progress.diagnostics.last_accepted_step < 1.0); + CHECK(checkpoint.progress.correction[0] != 0.0); + CHECK(checkpoint.progress.next_step == checkpoint.progress.diagnostics.last_accepted_step / options.line_search.contraction); + Problem restored_problem; + const auto resumed = solver::resume_newton(restored_problem, checkpoint, options, solver::TimedObserver{timing}); + CHECK(timing.seconds >= interrupted_seconds); + REQUIRE(resumed.converged()); + CHECK(resumed.state().values()[0] == uninterrupted.state().values()[0]); + CHECK(resumed.diagnostics().initial_residual_norm == uninterrupted.diagnostics().initial_residual_norm); + CHECK(resumed.diagnostics().convergence_threshold == uninterrupted.diagnostics().convergence_threshold); + CHECK(resumed.diagnostics().attempted_iterations == uninterrupted.diagnostics().attempted_iterations); + CHECK(resumed.diagnostics().line_search_trials == uninterrupted.diagnostics().line_search_trials); + CHECK(resumed.diagnostics().total_linear_iterations == uninterrupted.diagnostics().total_linear_iterations); + CHECK(resumed.diagnostics().rejected_trials == uninterrupted.diagnostics().rejected_trials); +} + +TEST_CASE("Newton restart retains the consumed budget and validates checkpoint consistency", "[solver][nonlinear][restart]") { + Problem problem; + solver::SolverOptions options{.method = {.maximum_iterations = 1}}; + const auto limited = solver::solve_newton(problem, State{{0.1}}, options); + REQUIRE(limited.status() == solver::SolveStatus::maximum_iterations); + const auto checkpoint = limited.checkpoint(); + SECTION("Resuming consumes only the remaining total budget") { + const auto no_budget = solver::resume_newton(problem, checkpoint, options); + CHECK(no_budget.status() == solver::SolveStatus::maximum_iterations); + CHECK(no_budget.diagnostics().attempted_iterations == 1); + CHECK(no_budget.state().values()[0] == checkpoint.state.values()[0]); + options.method.maximum_iterations = 20; + const auto continued = solver::resume_newton(problem, checkpoint, options); + CHECK(continued.converged()); + CHECK(continued.diagnostics().initial_residual_norm == checkpoint.progress.diagnostics.initial_residual_norm); + } + SECTION("Invalid carried coordinates are rejected before evaluation") { + auto corrupted = checkpoint; + corrupted.progress.correction.clear(); + const int before = problem.evaluations; + CHECK_THROWS_AS(solver::resume_newton(problem, corrupted, options), std::logic_error); + CHECK(problem.evaluations == before); + } + SECTION("Changed convergence tolerances cannot silently start a new convergence reference") { + options.method.relative_tolerance *= 0.1; + CHECK_THROWS_AS(solver::resume_newton(problem, checkpoint, options), std::logic_error); + } + SECTION("A changed physical problem cannot pass off its residual as the saved one") { + problem.equation = [](double value) { return std::pair{value * value - 3.0, 2.0 * value}; }; + CHECK_THROWS_AS(solver::resume_newton(problem, checkpoint, options), std::logic_error); + } +} + +TEST_CASE("Newton stops only at accepted boundaries and does not silently discard strategy history", "[solver][nonlinear][restart]") { + Problem problem; + SECTION("An initial accepted state can be checkpointed before any linear solve") { + const auto stop = [](const solver::SolveEvent&, const auto&, const auto&) { return solver::SolveControl::stop; }; + const auto stopped = solver::solve_newton(problem, State{{0.1}}, solver::SolverOptions{}, stop); + CHECK(stopped.status() == solver::SolveStatus::stopped); + REQUIRE(stopped.has_checkpoint()); + CHECK(stopped.checkpoint().progress.diagnostics.attempted_iterations == 0); + CHECK(stopped.checkpoint().progress.correction[0] == 0.0); + } + SECTION("Existing void observers retain the temporary event calling convention") { + int notifications = 0; + const auto observe = [&](solver::SolveEvent&&, const auto&, const auto&) { ++notifications; }; + const auto result = solver::solve_newton(problem, State{{1.0}}, solver::SolverOptions{}, observe); + CHECK(result.converged()); + CHECK(notifications > 0); + } + SECTION("Trial callbacks cannot interrupt an incomplete Newton step") { + const auto stop = [](const solver::SolveEvent& event, const auto&, const auto&) { + return event.kind == solver::EventKind::trial_rejected || event.kind == solver::EventKind::before_accept ? + solver::SolveControl::stop : solver::SolveControl::continue_solve; + }; + const auto result = solver::solve_newton(problem, State{{0.1}}, solver::SolverOptions{}, stop); + CHECK(result.status() == solver::SolveStatus::stopped); + CHECK(result.diagnostics().accepted_iterations == 1); + CHECK(result.diagnostics().rejected_trials > 0); + CHECK(result.has_checkpoint()); + } + SECTION("A before-accept stop request survives a later continue response") { + int accepted_notifications = 0; + const auto stop = [&](const solver::SolveEvent& event, const auto&, const auto&) { + if (event.kind == solver::EventKind::accepted) { + ++accepted_notifications; + } + return event.kind == solver::EventKind::before_accept ? solver::SolveControl::stop : solver::SolveControl::continue_solve; + }; + const auto result = solver::solve_newton(problem, State{{0.1}}, solver::SolverOptions{}, stop); + CHECK(result.status() == solver::SolveStatus::stopped); + CHECK(accepted_notifications == 1); + REQUIRE(result.has_checkpoint()); + CHECK(result.checkpoint().progress.diagnostics.accepted_iterations == 1); + } + SECTION("Ordinary custom strategies still solve but cannot claim a complete restart") { + struct CustomStrategy : solver::StandardNewton { + int history{0}; + }; + const auto result = solver::solve_newton(problem, State{{1.0}}, solver::SolverOptions{.strategies = CustomStrategy{}}); + CHECK(result.converged()); + CHECK_FALSE(result.has_checkpoint()); + CHECK_THROWS_AS(result.checkpoint(), std::logic_error); + } +} diff --git a/tests/solver/nonlinear_parity.cpp b/tests/solver/nonlinear_parity.cpp new file mode 100644 index 00000000..c45f2fb9 --- /dev/null +++ b/tests/solver/nonlinear_parity.cpp @@ -0,0 +1,195 @@ +#include "serif/tests/solver/native_problem.hpp" +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include "mfem/linalg/densemat.hpp" +#include "serif/solver/nonlinear/newton.hpp" +#include "serif/solver/normalization/diagonal.hpp" + +namespace { + namespace solver = serif::solver; + /** @brief Keep one independent physical value for orchestration contracts. */ + struct State { + std::array coordinates; + /** @brief Edit only a local candidate's physical coordinate. */ + std::span values() { return coordinates; } + /** @brief Observe accepted coordinates without mutating them. */ + std::span values() const { return coordinates; } + }; + /** @brief Freeze one identity equation with dimensionless coordinates. */ + struct System { + std::array residual_values; + ::mfem::DenseMatrix derivative{1}; + solver::normalization::DiagonalNormalization normalization = *solver::normalization::make_diagonal_normalization({1.0}, {1.0}); + /** @brief Retain residual values independently of subsequent evaluations. */ + explicit System(double value) : residual_values{value} { derivative(0, 0) = 1.0; } + /** @brief Expose the complete residual. */ + const auto& residual() const { return residual_values; } + /** @brief Expose its matching linearization. */ + const ::mfem::Operator& jacobian() const { return derivative; } + /** @brief Transfer normalized corrections back to physical coordinates. */ + const auto& coordinates() const { return normalization; } + }; + struct Identity : ::mfem::Solver { + /** @brief The test derivative is exactly one. */ + Identity() : ::mfem::Solver(1) {} + /** @brief A frozen identity does not require operator rebinding. */ + void SetOperator(const ::mfem::Operator&) override {} + /** @brief Apply the exact identity inverse. */ + void Mult(const ::mfem::Vector& input, ::mfem::Vector& output) const override { output = input; } + }; + struct Prepared { + std::shared_ptr owner; + mutable Identity inverse; + /** @brief Retain the system before borrowing its inverse. */ + explicit Prepared(std::shared_ptr system) : owner(std::move(system)) {} + /** @brief Expose the matching residual and derivative owner. */ + const auto& system() const { return owner; } + /** @brief Borrow the retained exact inverse. */ + ::mfem::Solver& preconditioner() const { return inverse; } + /** @brief Identity is a fixed linear operation. */ + auto contract() const { return solver::preconditioner::ApplicationContract::fixed_linear; } + }; + struct Problem : serif::tests::solver::NativeProblem { + using System = ::System; + using Model = solver::NoModel; + int evaluations{0}; + int preparations{0}; + /** @brief This algebraic regression has no physical model. */ + std::shared_ptr model_owner() const { return {}; } + /** @brief Publish immutable snapshots for the identity residual. */ + std::expected, std::string> evaluate(const State& state) { + ++evaluations; + return std::make_shared(state.values()[0]); + } + /** @brief Count preparation independently of residual evaluation. */ + std::expected, std::string> prepare(const State& state) { + ++preparations; + return std::make_shared(*evaluate(state)); + } + /** @brief Preserve a numerical rejection's diagnostic. */ + std::string failure_message(const std::string& error) const { return error; } + /** @brief The scalar fixture reports only numerical failures. */ + bool recoverable(const std::string&) const { return true; } + }; + struct Scripted { + std::function correction; + /** @brief Supply a known correction to test orchestration rather than Krylov algebra. */ + solver::linear::Report solve(const ::mfem::Operator&, ::mfem::Solver&, solver::preconditioner::ApplicationContract, + const ::mfem::Vector&, ::mfem::Vector& output, solver::linear::Control) const { + correction(output); + return {.status = solver::linear::Status::converged, .iterations = 3}; + } + }; +} + +TEST_CASE("Newton numerical options and default metrics retain their migrated contracts", "[unit][solver][nonlinear][parity]") { + solver::Newton options{.relative_tolerance = 0.25, .absolute_tolerance = 0.5}; + CHECK(options.convergence_threshold(4.0) == 1.0); + options.absolute_tolerance = 2.0; + CHECK(options.convergence_threshold(4.0) == 2.0); + CHECK_THROWS_AS(options.convergence_threshold(-1.0), std::logic_error); + CHECK_THROWS_AS(options.convergence_threshold(std::numeric_limits::infinity()), std::logic_error); + for (const auto invalid : {solver::Newton{.relative_tolerance = -1.0}, + solver::Newton{.absolute_tolerance = std::numeric_limits::infinity()}, solver::Newton{.maximum_iterations = -1}, + solver::Newton{.linear_solve = {.maximum_iterations = 0}}}) { + CHECK_THROWS_AS(invalid.validate(), std::logic_error); + } + for (const auto invalid : {solver::Backtracking{.initial_step = 0.0}, solver::Backtracking{.contraction = 1.0}, + solver::Backtracking{.fraction_to_boundary = 1.0}, solver::Backtracking{.sufficient_decrease = 0.0}, + solver::Backtracking{.minimum_step = 2.0}, solver::Backtracking{.maximum_trials = 0}}) { + CHECK_THROWS_AS(invalid.validate(), std::logic_error); + } + const std::array residual{3.0, 4.0}; + const auto metric = solver::NormalizedResidualMetric{}(residual); + CHECK(metric.residual_norm == 5.0); + CHECK(metric.merit == 12.5); + CHECK_FALSE(solver::MetricEvaluation{.residual_norm = -1.0}.valid()); + CHECK_FALSE(solver::MetricEvaluation{.merit = std::numeric_limits::quiet_NaN()}.valid()); +} + +TEST_CASE("A zero Newton work budget evaluates its checkpoint without preparing any inverse", "[unit][solver][nonlinear][parity]") { + Problem problem; + const auto result = solver::solve_newton(problem, State{{2.0}}, solver::SolverOptions{.method = {.maximum_iterations = 0}}); + CHECK(result.status() == solver::SolveStatus::maximum_iterations); + CHECK(result.has_state()); + CHECK(result.state().values()[0] == 2.0); + CHECK(problem.evaluations == 1); + CHECK(problem.preparations == 0); + CHECK(result.diagnostics().attempted_iterations == 0); + CHECK(result.diagnostics().line_search_trials == 0); +} + +TEST_CASE("Newton events and counters distinguish exhausted globalization from an accepted final iteration", "[unit][solver][nonlinear][parity]") { + for (const bool decreasing : {false, true}) { + Problem problem; + std::vector events; + const auto observer = [&](const solver::SolveEvent& event, const State& state, const auto&) { + events.push_back(event.kind); + if (event.kind == solver::EventKind::finished) { + CHECK(event.diagnostics.attempted_iterations == 1); + CHECK(event.diagnostics.accepted_iterations == static_cast(decreasing)); + CHECK(event.diagnostics.total_linear_iterations == 3); + CHECK(state.values()[0] == (decreasing ? 0.5 : 1.0)); + } + }; + const auto result = solver::solve_newton(problem, State{{1.0}}, solver::SolverOptions{ + .method = {.relative_tolerance = 0.0, .absolute_tolerance = 0.0, .maximum_iterations = 1}, + .linear_solver = Scripted{[decreasing](auto& output) { output[0] = decreasing ? -0.5 : 0.0; }}, + .line_search = {.maximum_trials = 1}}, observer); + CHECK(result.status() == (decreasing ? solver::SolveStatus::maximum_iterations : solver::SolveStatus::globalization_failure)); + CHECK(result.diagnostics().line_search_trials == 1); + CHECK(result.diagnostics().rejected_trials == static_cast(!decreasing)); + std::vector expected{solver::EventKind::initial, solver::EventKind::before_iteration}; + if (decreasing) { + expected.push_back(solver::EventKind::before_accept); + expected.push_back(solver::EventKind::accepted); + } else { + expected.push_back(solver::EventKind::trial_rejected); + } + expected.push_back(solver::EventKind::finished); + CHECK(events == expected); + } +} + +TEST_CASE("A trial metric exception preserves published checkpoints and a fresh solve starts independently", "[unit][solver][nonlinear][parity]") { + Problem problem; + const State original{{1.0}}; + std::shared_ptr checkpoint; + int metrics = 0; + const auto metric = [&](std::span residual) { + serif::utils::errors::serif_ensure( + ++metrics != 2, "injected trial metric failure"); + return solver::NormalizedResidualMetric{}(residual); + }; + const auto observer = [&](const solver::SolveEvent& event, const State&, const auto& system) { + if (event.kind == solver::EventKind::initial) { checkpoint = system; } + }; + CHECK_THROWS_AS(solver::solve_newton(problem, original, solver::SolverOptions{.metric = metric}, observer), std::runtime_error); + REQUIRE(checkpoint); + CHECK(checkpoint->residual()[0] == 1.0); + CHECK(original.values()[0] == 1.0); + const auto recovered = solver::solve_newton(problem, original); + CHECK(recovered.converged()); + CHECK(recovered.state().values()[0] == 0.0); + CHECK(checkpoint->residual()[0] == 1.0); +} + +TEST_CASE("A nonfinite Newton correction cannot become the next solve's incoming guess", "[unit][solver][nonlinear][parity]") { + Problem problem; + int calls = 0; + auto options = solver::SolverOptions{.linear_solver = Scripted{[&](auto& output) { + CHECK(output[0] == 0.0); + output[0] = ++calls == 1 ? std::numeric_limits::quiet_NaN() : -1.0; + }}}; + CHECK(solver::solve_newton(problem, State{{1.0}}, options).status() == solver::SolveStatus::linear_solve_failure); + CHECK(solver::solve_newton(problem, State{{1.0}}, options).converged()); + CHECK(calls == 2); +} diff --git a/tests/solver/seed.cpp b/tests/solver/seed.cpp new file mode 100644 index 00000000..cc8e5bb0 --- /dev/null +++ b/tests/solver/seed.cpp @@ -0,0 +1,362 @@ +#include +#include +#include +#include +#include "serif/backend/mfem/discretization/dof_selection.hpp" +#include "serif/backend/mfem/model/equilibrium.hpp" + +#include +#include + +#include "serif/solver/seed/polytrope.hpp" +#include "serif/backend/mfem/solver/seed/state.hpp" +#include "serif/tests/solver/stellar_fixture.hpp" + +namespace { + namespace seed = serif::solver::seed; + namespace dimensions = serif::dimensions; + namespace fixture = serif::tests::solver; +} + +TEST_CASE("Polytropic seeds retain the EOS and solve the requested mass scale", "[unit][solver][seed]") { + const auto star = fixture::make_model(1.0, 2.0 / std::numbers::pi, 1.0, 0.05); + const auto radial = seed::make_polytropic_seed(star, {.integration = {.step = 0.002}}); + REQUIRE(radial); + CHECK(radial->scales.mass.value() == Catch::Approx(1.0).epsilon(2.0e-12)); + CHECK(radial->scales.radius.value() == Catch::Approx(1.0).epsilon(2.0e-9)); + CHECK(radial->scales.central_density.value() == Catch::Approx(std::numbers::pi / 4.0).epsilon(3.0e-9)); + CHECK(radial->scales.central_enthalpy.value() == Catch::Approx(1.0).epsilon(3.0e-9)); + CHECK(*radial->scales.polytropic_constant == Catch::Approx(2.0 / std::numbers::pi).epsilon(3.0e-12)); + const double inertia = 2.0 * (std::numbers::pi * std::numbers::pi - 6.0) / (3.0 * std::numbers::pi * std::numbers::pi); + CHECK(radial->moment_of_inertia == Catch::Approx(inertia).epsilon(3.0e-8)); + CHECK(radial->angular_velocity == Catch::Approx(0.05 / inertia).epsilon(3.0e-8)); + const auto changed_mass = seed::make_polytropic_seed(serif::eos::models::Polytrope{1.5, 0.4}, dimensions::MassValue{2.0}); + REQUIRE(changed_mass); + CHECK(changed_mass->scales.mass.value() == Catch::Approx(2.0).epsilon(3.0e-12)); + CHECK(*changed_mass->scales.polytropic_constant == Catch::Approx(0.4).epsilon(3.0e-12)); +} + +TEST_CASE("Polytropic seeds diagnose missing homology scales and incompatible physical models", "[unit][solver][seed]") { + const serif::eos::models::Polytrope n3{3.0, 1.0}; + CHECK_FALSE(seed::make_polytropic_seed(n3, dimensions::MassValue{1.0})); + CHECK(seed::make_polytropic_seed(n3, dimensions::MassValue{1.0}, {.central_density = dimensions::DensityValue{2.0}})); + const auto dimensionless = serif::utils::laneemden::solve(3.0); + REQUIRE(dimensionless); + const auto scales = serif::utils::laneemden::scale_from_polytropic_constant(*dimensionless, 1.0, dimensions::DensityValue{2.0}, + dimensions::QuantityValue{1.0}); + REQUIRE(scales); + CHECK(seed::make_polytropic_seed(n3, scales->mass, {.central_density = dimensions::DensityValue{2.0}})); + CHECK_FALSE(seed::make_polytropic_seed(serif::eos::models::Polytrope{1.0, 1.0}, dimensions::MassValue{-1.0})); + CHECK_FALSE(seed::make_polytropic_seed(serif::eos::models::Polytrope{1.0, 1.0}, dimensions::MassValue{1.0}, + {.integration = {.maximum_radius = 1.0}})); +} + +TEST_CASE("Spherical seed projection initializes every moving stellar coordinate by physical identity", "[integration][solver][seed]") { + const auto star = fixture::make_model(); + const auto radial = seed::make_polytropic_seed(star); + REQUIRE(radial); + const auto resources = fixture::make_resources(radial->scales.radius.value()); + const auto discretization = fixture::make_discretization(resources, 2); + const auto state = seed::backend::mfem::make_polytropic_state(star, discretization, *radial); + if (!state) { + INFO(state.error()); + } + REQUIRE(state); + CHECK(std::ranges::all_of(state->values(), [](double value) { return std::isfinite(value); })); + CHECK(state->template get().size() == static_cast(resources->density.size())); + CHECK(state->template get().size() == static_cast(resources->potential.size())); + CHECK(state->template get().size() == 9); + CHECK(std::ranges::all_of(state->template get(), [](double value) { return value == 0.0; })); + CHECK(state->template get()[0] == Catch::Approx(-1.0).epsilon(3.0e-7)); + CHECK(state->template get()[0] == 0.0); + CHECK(std::ranges::min(state->template get()) >= 0.0); + CHECK(std::ranges::max(state->template get()) > 0.7); + const auto surface_rows = serif::discretization::backend::mfem::select_boundary_dofs(resources->density); + REQUIRE(surface_rows.size() > 0); + for (const int dof : surface_rows.reduced_dofs()) { + CHECK(state->template get()[dof] == 0.0); + } +} + +TEST_CASE("Central seed density remains an initial guess independent of the model's mass prescription", "[unit][solver][seed]") { + const auto base = fixture::make_model(3.0, 0.25); + const auto model = serif::model::make_stellar_model(base.equation_of_state(), base.equations(), + serif::model::FixedTotalMass{dimensions::MassValue{1.0}}, + base.specification()); + const auto from_model = seed::make_polytropic_seed(model, {.central_density = dimensions::DensityValue{2.0}}); + REQUIRE(from_model); + CHECK(from_model->scales.central_density.value() == 2.0); + CHECK(from_model->scales.central_enthalpy.value() == Catch::Approx(std::cbrt(2.0))); + const seed::PolytropeOptions options{.central_density = dimensions::DensityValue{1.7}}; + const auto explicit_seed = seed::make_polytropic_seed(model, options); + const auto other_mass = seed::make_polytropic_seed(base.equation_of_state(), dimensions::MassValue{9.0}, options); + REQUIRE(explicit_seed); + REQUIRE(other_mass); + CHECK(explicit_seed->scales.central_density.value() == 1.7); + CHECK(model.specification().target().value() == 1.0); + CHECK(explicit_seed->scales.radius == other_mass->scales.radius); + CHECK(explicit_seed->scales.mass == other_mass->scales.mass); + double previous_density = 2.0; + double previous_enthalpy = from_model->scales.central_enthalpy.value(); + for (int index = 0; index < 64; ++index) { + const double fraction = static_cast(index) / 63.0; + const auto physical = serif::utils::laneemden::evaluate_physical(from_model->solution, from_model->scales, + dimensions::LengthValue{fraction * from_model->scales.radius.value()}); + const auto first = serif::utils::laneemden::evaluate_physical(explicit_seed->solution, explicit_seed->scales, + dimensions::LengthValue{fraction * explicit_seed->scales.radius.value()}); + const auto second = serif::utils::laneemden::evaluate_physical(other_mass->solution, other_mass->scales, + dimensions::LengthValue{fraction * other_mass->scales.radius.value()}); + REQUIRE(physical); + REQUIRE(first); + REQUIRE(second); + CHECK(first->density == second->density); + CHECK(first->enthalpy == second->enthalpy); + CHECK(physical->density.value() >= 0.0); + CHECK(physical->enthalpy.value() >= 0.0); + CHECK(physical->density.value() <= previous_density); + CHECK(physical->enthalpy.value() <= previous_enthalpy); + previous_density = physical->density.value(); + previous_enthalpy = physical->enthalpy.value(); + } + CHECK(previous_density == 0.0); + CHECK(previous_enthalpy == 0.0); +} + +TEST_CASE("Seed generation rejects invalid density scales and stars without a finite surface", "[unit][solver][seed]") { + const auto model = fixture::make_model(); + for (const double density : {0.0, -1.0, std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()}) { + CHECK_FALSE(seed::make_polytropic_seed(model, {.central_density = dimensions::DensityValue{density}})); + } + CHECK_FALSE(seed::make_polytropic_seed(serif::eos::models::Polytrope{5.0, 0.25}, dimensions::MassValue{1.0}, + {.central_density = dimensions::DensityValue{1.0}})); +} + +TEST_CASE("Radial seed projection rejects incompatible surfaces and invalid radius tolerances", "[integration][solver][seed]") { + const auto model = fixture::make_model(); + const auto radial = seed::make_polytropic_seed(model); + REQUIRE(radial); + const auto resources = fixture::make_resources(radial->scales.radius.value()); + const auto finite_elements = fixture::make_discretization(resources, 0); + for (const double tolerance : {-1.0, std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()}) { + CHECK_FALSE(seed::backend::mfem::make_polytropic_state(model, finite_elements, *radial, {.surface_radius_relative_tolerance = tolerance})); + } + const auto pressured = fixture::make_model(1.0, 2.0 / std::numbers::pi, 1.0, 0.0, 1.0, 0.01); + CHECK_FALSE(seed::backend::mfem::make_polytropic_state(pressured, finite_elements, *radial)); + const auto wrong_reference = fixture::make_resources(1.1 * radial->scales.radius.value()); + CHECK_FALSE(seed::backend::mfem::make_polytropic_state(model, fixture::make_discretization(wrong_reference, 0), *radial)); +} + +TEST_CASE("Projected radial stars satisfy mass and angular momentum constraints with exact surface and infinity rows", "[integration][solver][seed]") { + namespace model = serif::model; + namespace backend = model::backend::mfem; + namespace dependencies = serif::utils::dependencies; + using Kind = serif::discretization::blocks::RepresentedValueKind; + + const auto star = fixture::make_model(); + const auto radial = seed::make_polytropic_seed(star); + REQUIRE(radial); + const auto resources = fixture::make_resources(radial->scales.radius.value(), 1, 4); + const auto finite_elements = fixture::make_discretization(resources, 0); + const auto state = seed::backend::mfem::make_polytropic_state(star, finite_elements, *radial); + if (!state) { + INFO(state.error()); + } + REQUIRE(state); + using State = std::remove_cvref_t; + static_assert(dependencies::key_count_v == 5); + for (const auto values : {state->template get(), state->template get()}) { + CHECK(std::ranges::any_of(values, [](double value) { return value != 0.0; })); + } + auto evaluator = backend::bind_stellar_model(star, finite_elements, *state); + const auto evaluated = evaluator.evaluate(*state); + REQUIRE(evaluated); + const auto residual = (*evaluated)->residual(); + CHECK(std::ranges::all_of(residual, [](double value) { return std::isfinite(value); })); + CHECK(std::abs(residual[state->layout().template offset()]) < 5.0e-4); + CHECK(std::abs(residual[state->layout().template offset()]) < 1.0e-10); + const auto surface_rows = serif::discretization::backend::mfem::select_boundary_dofs(resources->density); + const auto offset = state->layout().template offset(); + for (const int dof : surface_rows.reduced_dofs()) { + CHECK(state->template get()[dof] == 0.0); + CHECK(residual[offset + dof] == 0.0); + } + const auto infinity_rows = serif::discretization::backend::mfem::select_boundary_dofs(resources->potential); + REQUIRE(infinity_rows.size() > 0); + const auto potential_offset = state->layout().template offset(); + for (const int dof : infinity_rows.reduced_dofs()) { + CHECK(state->template get()[dof] == 0.0); + CHECK(residual[potential_offset + dof] == 0.0); + } +} + +TEST_CASE("Explicit radial fitting preserves thermodynamics and gives the stretched density consistent gravity", "[unit][solver][seed][fitting]") { + const auto star = fixture::make_model(1.0, 2.0 / std::numbers::pi, 1.0, 0.05); + const auto radial = seed::make_polytropic_seed(star, {.integration = {.step = 0.002}}); + REQUIRE(radial); + const seed::RadialSeed original = *radial; + for (const double stretch : {0.5, 1.0, 2.0}) { + const auto fitted = seed::fit_radial_seed(*radial, dimensions::LengthValue{stretch * radial->scales.radius.value()}); + REQUIRE(fitted); + CHECK(fitted->mass.value() == Catch::Approx(radial->scales.mass.value() * std::pow(stretch, 3))); + CHECK(fitted->moment_of_inertia == Catch::Approx(radial->moment_of_inertia * std::pow(stretch, 5))); + CHECK(fitted->angular_velocity * fitted->moment_of_inertia == Catch::Approx(0.05)); + CHECK(fitted->source.scales.radial_scale == original.scales.radial_scale); + CHECK(fitted->source.scales.radius == original.scales.radius); + CHECK(fitted->source.scales.mass == original.scales.mass); + CHECK(fitted->source.scales.polytropic_constant == original.scales.polytropic_constant); + for (const double fraction : {0.0, 0.1, 0.4, 0.9, 1.0, 2.0}) { + const auto source = seed::evaluate_radial_seed(*radial, dimensions::LengthValue{fraction * radial->scales.radius.value()}); + const auto sample = seed::evaluate_radial_seed(*fitted, dimensions::LengthValue{fraction * fitted->radius.value()}); + REQUIRE(source); + REQUIRE(sample); + CHECK(sample->density.value() == Catch::Approx(source->density.value()).margin(1.0e-14)); + CHECK(sample->enthalpy.value() == Catch::Approx(source->enthalpy.value()).margin(1.0e-14)); + CHECK(sample->pressure.value() == Catch::Approx(source->pressure.value()).margin(1.0e-14)); + CHECK(sample->enclosed_mass.value() == Catch::Approx(source->enclosed_mass.value() * std::pow(stretch, 3)).margin(1.0e-14)); + CHECK(sample->potential.value() == Catch::Approx(source->potential.value() * stretch * stretch)); + CHECK(sample->gravity.value() == Catch::Approx(source->gravity.value() * stretch).margin(1.0e-14)); + if (fraction > 0.0) { + const double radius = fraction * fitted->radius.value(); + CHECK(sample->gravity.value() == Catch::Approx(radial->scales.gravitational_constant.value() * sample->enclosed_mass.value() / (radius * radius)).epsilon(1.0e-12)); + } + } + // Independent spherical quadrature checks the mass and inertia assigned to the stretched density. + constexpr int intervals = 2000; + const double width = fitted->radius.value() / intervals; + double mass = 0.0; + double inertia = 0.0; + for (int index = 0; index <= intervals; ++index) { + const double radius = width * index; + const auto sample = seed::evaluate_radial_seed(*fitted, dimensions::LengthValue{radius}); + REQUIRE(sample); + const double weight = (index == 0 || index == intervals) ? 1.0 : (index % 2 == 0 ? 2.0 : 4.0); + mass += weight * sample->density.value() * radius * radius; + inertia += weight * sample->density.value() * std::pow(radius, 4); + } + mass *= width * 4.0 * std::numbers::pi / 3.0; + inertia *= width * 8.0 * std::numbers::pi / 9.0; + CHECK(mass == Catch::Approx(fitted->mass.value()).epsilon(2.0e-8)); + CHECK(inertia == Catch::Approx(fitted->moment_of_inertia).epsilon(2.0e-8)); + } + REQUIRE(radial->solution.samples.size() == original.solution.samples.size()); + for (std::size_t index = 0; index < original.solution.samples.size(); ++index) { + CHECK(radial->solution.samples[index].xi == original.solution.samples[index].xi); + CHECK(radial->solution.samples[index].theta == original.solution.samples[index].theta); + CHECK(radial->solution.samples[index].derivative == original.solution.samples[index].derivative); + } + CHECK(radial->scales.radius == original.scales.radius); + CHECK(radial->scales.central_density == original.scales.central_density); + CHECK(radial->scales.central_enthalpy == original.scales.central_enthalpy); + CHECK(radial->angular_velocity == original.angular_velocity); + CHECK(star.equation_of_state().polytropic_constant() == 2.0 / std::numbers::pi); + CHECK(star.specification().target().value() == 1.0); + CHECK(star.specification().target().value() == 0.05); + // These consistent units keep the final physical quantities finite even though the standalone stretch^5 is not. + for (const double stretch : {1.0e-70, 1.0e70}) { + const double source_radius = stretch > 1.0 ? 1.0e-10 : 1.0e10; + const double source_mass = stretch > 1.0 ? 1.0e-270 : 1.0e270; + const double gravity_constant = stretch > 1.0 ? 1.0e260 : 1.0e-260; + const double constant = 2.0 * gravity_constant * source_radius * source_radius / std::numbers::pi; + auto extreme = seed::make_polytropic_seed(serif::eos::models::Polytrope{1.0, constant}, dimensions::MassValue{source_mass}, + {.gravitational_constant = dimensions::QuantityValue{gravity_constant}, + .central_density = dimensions::DensityValue{std::numbers::pi * source_mass / (4.0 * source_radius * source_radius * source_radius)}}); + REQUIRE(extreme); + extreme->angular_velocity = 1.0 / extreme->moment_of_inertia; + const auto fitted = seed::fit_radial_seed(*extreme, dimensions::LengthValue{stretch * extreme->scales.radius.value()}); + REQUIRE(fitted); + CHECK(fitted->mass.value() == Catch::Approx(extreme->scales.mass.value() * stretch * stretch * stretch).epsilon(1.0e-12)); + CHECK(fitted->moment_of_inertia == Catch::Approx(extreme->moment_of_inertia * stretch * stretch * stretch * stretch * stretch).epsilon(1.0e-12)); + CHECK(fitted->angular_velocity * fitted->moment_of_inertia == Catch::Approx(1.0).epsilon(1.0e-12)); + const auto surface = seed::evaluate_radial_seed(*fitted, fitted->radius); + const auto interior = seed::evaluate_radial_seed(*fitted, dimensions::LengthValue{0.5 * fitted->radius.value()}); + REQUIRE(surface); + REQUIRE(interior); + CHECK(std::isfinite(surface->gravity.value())); + CHECK(std::isfinite(interior->potential.value())); + } +} + +TEST_CASE("Radial fitting rejects invalid profiles and unrepresentable physical scales", "[unit][solver][seed][fitting]") { + const auto radial = seed::make_polytropic_seed(fixture::make_model()); + REQUIRE(radial); + for (const double radius : {0.0, -1.0, std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN(), + std::numeric_limits::max(), std::numeric_limits::denorm_min()}) { + CHECK_FALSE(seed::fit_radial_seed(*radial, dimensions::LengthValue{radius})); + } + auto invalid = *radial; + invalid.solution.samples[2].xi = invalid.solution.samples[1].xi; + CHECK_FALSE(seed::fit_radial_seed(invalid, dimensions::LengthValue{1.0})); + invalid = *radial; + invalid.solution.samples[2].theta = std::numeric_limits::quiet_NaN(); + CHECK_FALSE(seed::fit_radial_seed(invalid, dimensions::LengthValue{1.0})); + invalid = *radial; + invalid.moment_of_inertia = 0.0; + CHECK_FALSE(seed::fit_radial_seed(invalid, dimensions::LengthValue{1.0})); + invalid = *radial; + invalid.angular_velocity = std::numeric_limits::infinity(); + CHECK_FALSE(seed::fit_radial_seed(invalid, dimensions::LengthValue{1.0})); + invalid = *radial; + invalid.center[0] = std::numeric_limits::quiet_NaN(); + CHECK_FALSE(seed::fit_radial_seed(invalid, dimensions::LengthValue{1.0})); + const auto fitted = seed::fit_radial_seed(*radial, dimensions::LengthValue{0.5}); + REQUIRE(fitted); + for (const double radius : {-1.0, std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()}) { + CHECK_FALSE(seed::evaluate_radial_seed(*fitted, dimensions::LengthValue{radius})); + } + CHECK(seed::evaluate_radial_seed(*fitted, dimensions::LengthValue{std::numeric_limits::max()})); + for (const double invalid_scale : {0.0, -1.0, std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()}) { + auto malformed = *fitted; + malformed.mass = dimensions::MassValue{invalid_scale}; + CHECK_FALSE(seed::evaluate_radial_seed(malformed, dimensions::LengthValue{2.0})); + malformed = *fitted; + malformed.source.scales.radius = dimensions::LengthValue{invalid_scale}; + CHECK_FALSE(seed::evaluate_radial_seed(malformed, dimensions::LengthValue{2.0})); + malformed = *fitted; + malformed.source.scales.gravitational_constant = dimensions::QuantityValue{invalid_scale}; + CHECK_FALSE(seed::evaluate_radial_seed(malformed, dimensions::LengthValue{2.0})); + } +} + +TEST_CASE("Reference radius fitting is explicit and rejects nonspherical surfaces", "[integration][solver][seed][fitting]") { + const auto star = fixture::make_model(1.0, 2.0 / std::numbers::pi, 1.0, 0.05); + const auto radial = seed::make_polytropic_seed(star); + REQUIRE(radial); + const auto resources = fixture::make_resources(2.0 * radial->scales.radius.value()); + const auto discretization = fixture::make_discretization(resources, 0); + CHECK_FALSE(seed::backend::mfem::make_polytropic_state(star, discretization, *radial)); + const auto fitted = seed::backend::mfem::fit_radial_seed(discretization, *radial); + if (!fitted) { + INFO(fitted.error()); + } + REQUIRE(fitted); + CHECK(fitted->radius.value() == Catch::Approx(2.0 * radial->scales.radius.value()).epsilon(5.0e-4)); + const auto state = seed::backend::mfem::make_polytropic_state(star, discretization, *fitted); + if (!state) { + INFO(state.error()); + } + REQUIRE(state); + CHECK(std::ranges::all_of(state->values(), [](double value) { return std::isfinite(value); })); + CHECK(state->template get()[0] == Catch::Approx(-radial->scales.gravitational_constant.value() * fitted->mass.value() / fitted->radius.value())); + CHECK(state->template get()[0] == fitted->angular_velocity); + CHECK(std::ranges::all_of(state->template get(), [](double value) { return value == 0.0; })); + const auto surface_rows = serif::discretization::backend::mfem::select_boundary_dofs(resources->density); + REQUIRE(surface_rows.size() > 0); + for (const int dof : surface_rows.reduced_dofs()) { + CHECK(state->template get()[dof] == 0.0); + } + for (const double tolerance : {-1.0, std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()}) { + CHECK_FALSE(seed::backend::mfem::fit_radial_seed(discretization, *radial, tolerance)); + } + const auto ellipsoid = fixture::make_resources(); + /** @brief Both physical and logical meshes describe the same intentionally nonspherical reference. */ + const auto stretch_axis = [](const ::mfem::Vector& point, ::mfem::Vector& mapped) { + mapped = point; + mapped[0] *= 1.2; + }; + ellipsoid->mesh.mesh->Transform(stretch_axis); + ellipsoid->mesh.reference_mesh->Transform(stretch_axis); + const auto nonspherical = seed::backend::mfem::fit_radial_seed(fixture::make_discretization(ellipsoid, 0), *radial); + REQUIRE_FALSE(nonspherical); + CHECK(nonspherical.error().find("spherical") != std::string::npos); + CHECK(radial->scales.radius.value() == Catch::Approx(1.0).epsilon(2.0e-7)); +} diff --git a/tests/solver/strategies.cpp b/tests/solver/strategies.cpp new file mode 100644 index 00000000..dce6e75d --- /dev/null +++ b/tests/solver/strategies.cpp @@ -0,0 +1,173 @@ +#include "serif/tests/equations/test_equations.hpp" +#include "serif/tests/solver/native_problem.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include "mfem/linalg/densemat.hpp" +#include "serif/backend/mfem/model/preconditioned_system.hpp" +#include "serif/discretization/blocks/fields.hpp" +#include "serif/model/state.hpp" +#include "serif/backend/mfem/solver/preconditioner/inverse/dense.hpp" +#include "serif/dimensions/type_alias.hpp" +#include "serif/solver/nonlinear/newton.hpp" + +namespace { + namespace solver = serif::solver; + namespace model = serif::model; + namespace blocks = serif::discretization::blocks; + namespace backend = model::backend::mfem; + namespace keys = serif::utils::dependencies; + using Layout = blocks::SystemLayout, + keys::KeyList>; + using State = model::StellarState; + using System = backend::NormalizedSystem; + using Prepared = backend::PreconditionedSystem; + + /** @brief A curved gravity manifold with reordered rows and nonunit coordinates. */ + struct CurvedProblem : serif::tests::solver::NativeProblem { + using System = ::System; + using Layout = ::Layout; + using Model = solver::NoModel; + Layout coordinates{.state_sizes = {1, 1, 1}, .residual_sizes = {1, 1, 1}}; + int preparations{0}; + + /** @brief The controlled problem has no descriptive stellar physics owner. */ + [[nodiscard]] std::shared_ptr model_owner() const { return {}; } + /** @brief Expose distinct physical value and residual identity orderings. */ + [[nodiscard]] const Layout& layout() const { return coordinates; } + /** @brief Form the full derivative of the nonlinear residual. */ + [[nodiscard]] std::shared_ptr jacobian(const State& state) const { + const double x = state.values()[1]; + auto matrix = std::make_shared<::mfem::DenseMatrix>(3); + *matrix = 0.0; + (*matrix)(0, 0) = 1.0; (*matrix)(0, 1) = 2.0 * x; (*matrix)(0, 2) = 1.0; + (*matrix)(1, 1) = -2.0 * x; (*matrix)(1, 2) = 1.0; + (*matrix)(2, 0) = 2.0; (*matrix)(2, 1) = -2.0 * std::cos(x); + return matrix; + } + /** @brief Preserve the nonlinear residual while publishing a frozen physical and normalized system. */ + [[nodiscard]] std::expected, std::string> evaluate(const State& state) { + const double x = state.values()[1]; + const double potential = state.values()[0]; + const double gradient = state.values()[2]; + if (!std::isfinite(x) || x <= 0.0) { return std::unexpected("outside test domain"); } + const double target = 0.5 + std::sin(0.5); + const std::vector residual{x * x + gradient + potential - target, gradient - x * x, 2.0 * (potential - std::sin(x))}; + const auto physical = backend::make_physical_system(3, 3, + {{.row_offset = 0, .values = residual}}, + {{.row_offset = 0, .column_offset = 0, .block = serif::operators::backend::mfem::linearized_block(jacobian(state))}}); + const auto normalization = solver::normalization::make_diagonal_normalization({2.0, 3.0, 5.0}, {7.0, 11.0, 13.0}); + const auto scales = solver::normalization::prepare_stellar_reference_scales(serif::dimensions::MassValue{1.0}, serif::dimensions::LengthValue{1.0}); + return std::make_shared(physical, *normalization, *scales); + } + /** @brief Give the full Newton solve an exact physical inverse. */ + [[nodiscard]] std::expected, std::string> prepare(const State& state) { + ++preparations; + const auto system = evaluate(state); + if (!system) { return std::unexpected(system.error()); } + auto inverse = solver::preconditioner::backend::mfem::try_dense_inverse(*jacobian(state)); + if (!inverse) { return std::unexpected(inverse.error().message); } + return std::make_shared(*system, std::move(*inverse)); + } + /** @brief Keep test-domain rejection distinguishable from accepted results. */ + [[nodiscard]] static bool recoverable(const std::string&) { return true; } + /** @brief Forward the original failure diagnostic. */ + [[nodiscard]] static std::string failure_message(const std::string& failure) { return failure; } + }; +} + +namespace { + /** @brief Isolate the policy gate from equation convergence and linear work. */ + struct RejectedInitial : solver::StandardNewton { + /** @brief Reject the initial physical candidate even when its residual is exactly zero. */ + [[nodiscard]] solver::TrialAssessment admissible(CurvedProblem&, const State&) const { return {.usable = false, .message = "Test policy rejects this state."}; } + }; +} + +TEST_CASE("A zero residual cannot bypass the initial physical admissibility policy", "[solver][strategies]") { + CurvedProblem problem; + const State equilibrium(problem.layout(), {std::sin(0.5), 0.5, 0.25}); + const auto evaluation = problem.evaluate(equilibrium); + REQUIRE(evaluation); + CHECK(std::ranges::all_of((*evaluation)->residual(), [](double value) { return value == 0.0; })); + const auto result = solver::solve_newton(problem, equilibrium, solver::SolverOptions{.strategies = RejectedInitial{}}); + CHECK(result.status() == solver::SolveStatus::initial_state_rejected); + CHECK_FALSE(result.has_state()); + CHECK_FALSE(result.converged()); + CHECK(problem.preparations == 0); +} + +namespace { + using Border = blocks::rotation::angular_velocity; + using BorderedLayout = blocks::SystemLayout, + keys::KeyList>; + using BorderedState = model::StellarState; + + /** @brief An independently ordered generated border must remain in the retained Newton equation. */ + struct BorderedProblem : serif::tests::solver::NativeProblem { + using System = ::System; + using Layout = BorderedLayout; + using Model = solver::NoModel; + Layout coordinates{.state_sizes = {1, 1, 1, 1}, .residual_sizes = {1, 1, 1, 1}}; + CurvedProblem fields; + + /** @brief Expose the complete layout including its generated scalar coordinate. */ + const Layout& layout() const { return coordinates; } + /** @brief Algebraic regression models have no descriptive physical owner. */ + std::shared_ptr model_owner() const { return {}; } + /** @brief Remove only the generated scalar when evaluating the independent three-field equations. */ + State field_state(const BorderedState& state) const { return State(fields.layout(), {state.values()[0], state.values()[1], state.values()[3]}); } + /** @brief Add the independent scalar constraint while retaining different row and column orderings. */ + std::shared_ptr jacobian(const BorderedState& state) const { + const auto physical = fields.jacobian(field_state(state)); + auto result = std::make_shared<::mfem::DenseMatrix>(4); + *result = 0.0; + (*result)(0, 2) = 1.0; + constexpr int columns[]{0, 1, 3}; + for (int row = 0; row < 3; ++row) { + for (int column = 0; column < 3; ++column) { (*result)(row + 1, columns[column]) = (*physical)(row, column); } + } + return result; + } + /** @brief Prepare immutable physical and normalized rows including the scalar target. */ + std::expected, std::string> evaluate(const BorderedState& state) { + auto evaluation = fields.evaluate(field_state(state)); + if (!evaluation) { return std::unexpected(evaluation.error()); } + const auto original = (*evaluation)->physical()->residual(); + const auto physical = backend::make_physical_system(4, 4, + {{.row_offset = 0, .values = {state.values()[2] - 0.2, original[0], original[1], original[2]}}}, + {{.row_offset = 0, .column_offset = 0, .block = serif::operators::backend::mfem::linearized_block(jacobian(state))}}); + const auto normalization = solver::normalization::make_diagonal_normalization({2.0, 3.0, 17.0, 5.0}, {19.0, 7.0, 11.0, 13.0}); + return std::make_shared(physical, *normalization, (*evaluation)->reference_scales()); + } + /** @brief Apply an exact full inverse. */ + std::expected, std::string> prepare(const BorderedState& state) { + auto evaluation = evaluate(state); + if (!evaluation) { return std::unexpected(evaluation.error()); } + auto inverse = solver::preconditioner::backend::mfem::try_dense_inverse(*jacobian(state)); + if (!inverse) { return std::unexpected(inverse.error().message); } + return std::make_shared(*evaluation, std::move(*inverse)); + } + /** @brief Preserve a numerical rejection's original message. */ + static std::string failure_message(const std::string& failure) { return failure; } + /** @brief Only synthetic domain rejection is recoverable in this regression. */ + static bool recoverable(const std::string&) { return true; } + }; +} + +TEST_CASE("Newton retains independently ordered optional scalar borders", "[solver][strategies]") { + BorderedProblem problem; + const auto result = solver::solve_newton(problem, BorderedState(problem.layout(), {0.0, 0.8, -0.7, 0.0}), + solver::SolverOptions{.method = {.relative_tolerance = 0.0, .absolute_tolerance = 1.0e-9}}); + INFO(result.message()); + REQUIRE(result.converged()); + CHECK(result.state().values()[0] == Catch::Approx(std::sin(0.5)).margin(1.0e-8)); + CHECK(result.state().values()[1] == Catch::Approx(0.5).margin(1.0e-8)); + CHECK(result.state().values()[2] == Catch::Approx(0.2).margin(1.0e-8)); + CHECK(result.state().values()[3] == Catch::Approx(0.25).margin(1.0e-8)); +} diff --git a/tests/solver/virial.cpp b/tests/solver/virial.cpp new file mode 100644 index 00000000..69592757 --- /dev/null +++ b/tests/solver/virial.cpp @@ -0,0 +1,154 @@ +#include +#include + +#include +#include +#include "mfem/mesh/mesh.hpp" +#include "serif/tests/solver/virial.hpp" + +namespace { + namespace diagnostics = serif::tests::solver; + namespace quantities = serif::dimensions; + namespace eos = serif::eos; + + /** @brief A pressure-only EOS demonstrates that diagnostics need no enthalpy or polytropic parameters. */ + struct PressureOnlyEOS { + using Relations = eos::relations::RelationCatalog; + enum class Mode { regular, negative, nonfinite, domain_failure, finite_surface }; + Mode mode{Mode::regular}; + + /** @brief Supply an exactly integrable pressure or a deliberate validation failure. */ + [[nodiscard]] quantities::PressureValue evaluate(eos::relations::PressureFromDensity, const quantities::DensityValue density) const { + if (mode == Mode::finite_surface) { return quantities::PressureValue{1.0 + density.value() * density.value()}; } + if (density.value() == 0.0) { return quantities::PressureValue{0.0}; } + if (mode == Mode::domain_failure) { throw eos::EOSEvaluationError{"Deliberate pressure domain rejection."}; } + if (mode == Mode::negative) { return quantities::PressureValue{-1.0}; } + if (mode == Mode::nonfinite) { return quantities::PressureValue{std::numeric_limits::quiet_NaN()}; } + return quantities::PressureValue{density.value() * density.value()}; + } + }; + + /** @brief Put the complete unit-cube boundary in the fixture's stellar-surface attribute. */ + [[nodiscard]] mfem::Mesh material_cube() { + auto mesh = mfem::Mesh::MakeCartesian3D(1, 1, 1, mfem::Element::HEXAHEDRON); + for (int boundary = 0; boundary < mesh.GetNBE(); ++boundary) { mesh.GetBdrElement(boundary)->SetAttribute(1); } + mesh.SetAttributes(); + return mesh; + } + + /** @brief Use exact tensor-polynomial density on a translated physical cube with a genuine vacuum trace. */ + struct VirialFixture { + mfem::Mesh mesh{material_cube()}; + mfem::H1_FECollection collection{diagnostics::Density::FunctionalSpace::order, 3}; + mfem::H1_FECollection potential_collection{diagnostics::Potential::FunctionalSpace::order, 3}; + mfem::FiniteElementSpace scalar{&mesh, &collection}; + mfem::FiniteElementSpace potential_space{&mesh, &potential_collection}; + mfem::FiniteElementSpace vector{&mesh, &collection, 3, mfem::Ordering::byVDIM}; + serif::discretization::backend::mfem::TermBinding binding{scalar}; + mfem::GridFunction density{&scalar}, potential{&potential_space}, compactification{&scalar}, displacement{&vector}; + serif::mapping::DomainMapper mapper{{}, std::make_unique( + serif::utils::user_options::KelvinCompactificationOptions{})}; + serif::mapping::GridFunctionMappingEvaluator geometry{mapper, displacement, compactification}; + + /** @brief Project rho=216 product[x_i(1-x_i)] and translate the physical domain by (2,-1,1/2). */ + VirialFixture() { + mfem::FunctionCoefficient profile([](const mfem::Vector& point) { + double value = 216.0; + for (int axis = 0; axis < 3; ++axis) { value *= point[axis] * (1.0 - point[axis]); } + return value; + }); + density.ProjectCoefficient(profile); + const auto boundary = serif::discretization::backend::mfem::select_boundary_dofs< + serif::discretization::domain::StellarSurfaceBoundary>(binding); + for (const int dof : boundary.reduced_dofs()) { density[dof] = 0.0; } + potential = -2.0; + compactification = 0.0; + mfem::VectorFunctionCoefficient shift(3, [](const mfem::Vector&, mfem::Vector& value) { + value.SetSize(3); + value[0] = 2.0; value[1] = -1.0; value[2] = 0.5; + }); + displacement.ProjectCoefficient(shift); + } + }; +} + +TEST_CASE("Vacuum virial integrates tilted physical rotation about a displaced axis", "[solver][virial][unit]") { + VirialFixture fixture; + const serif::equations::RigidRotation rotation{{0.2, 0.4, -0.4}, {-1.0, 0.25, 0.75}}; + const auto result = diagnostics::measure_vacuum_virial(fixture.binding, fixture.density, fixture.potential, + fixture.geometry, PressureOnlyEOS{}, rotation); + REQUIRE(result); + REQUIRE(result->boundary_dofs > 0); + // Each normalized coordinate has mean 1/2 and variance 1/20. With the physical translation, + // mean minus axis origin is (3.5,-.75,.25); T=1/2[|omega|^2(2/20+|d|^2)-(omega.d)^2]=2.2905. + for (const auto& sample : result->samples) { + CHECK_THAT(sample.mass, Catch::Matchers::WithinAbs(1.0, 2.0e-13)); + CHECK_THAT(sample.volume, Catch::Matchers::WithinAbs(1.0, 2.0e-13)); + CHECK_THAT(sample.kinetic_energy, Catch::Matchers::WithinAbs(2.2905, 2.0e-12)); + CHECK_THAT(sample.pressure_integral, Catch::Matchers::WithinAbs(1.728, 2.0e-12)); + CHECK_THAT(sample.potential_energy, Catch::Matchers::WithinAbs(-1.0, 2.0e-13)); + CHECK_THAT(sample.gravity_work, Catch::Matchers::WithinAbs(0.0, 2.0e-12)); + CHECK_THAT(sample.energy_virial(), Catch::Matchers::WithinAbs(8.765, 2.0e-11)); + CHECK_THAT(sample.force_virial(), Catch::Matchers::WithinAbs(9.765, 2.0e-11)); + } + CHECK(result->quadrature_allowance < 1.0e-11); + CHECK_FALSE(result->passes()); // This analytically integrable candidate is deliberately not an equilibrium. +} + +TEST_CASE("Vacuum virial rejects unsupported thermodynamics and an uncertified surface", "[solver][virial][unit]") { + VirialFixture fixture; + PressureOnlyEOS equation_of_state; + SECTION("Negative pressure") { equation_of_state.mode = PressureOnlyEOS::Mode::negative; } + SECTION("Nonfinite pressure") { equation_of_state.mode = PressureOnlyEOS::Mode::nonfinite; } + SECTION("EOS domain rejection") { equation_of_state.mode = PressureOnlyEOS::Mode::domain_failure; } + SECTION("Finite pressure at zero density") { equation_of_state.mode = PressureOnlyEOS::Mode::finite_surface; } + SECTION("Even a tiny nonzero density trace is not silently clipped") { + const auto boundary = serif::discretization::backend::mfem::select_boundary_dofs< + serif::discretization::domain::StellarSurfaceBoundary>(fixture.binding); + fixture.density[boundary.reduced_dofs()[0]] = 1.0e-17; + } + SECTION("Negative represented interior density") { fixture.density *= -1.0; } + SECTION("Zero binding energy has no usable normalization") { fixture.potential = 0.0; } + SECTION("Missing stellar boundary cannot certify a vacuum trace") { + for (int face = 0; face < fixture.mesh.GetNBE(); ++face) { fixture.mesh.GetBdrElement(face)->SetAttribute(2); } + fixture.mesh.SetAttributes(); + } + const auto result = diagnostics::measure_vacuum_virial(fixture.binding, fixture.density, fixture.potential, + fixture.geometry, equation_of_state, serif::equations::RigidRotation{{0.0, 0.0, 0.0}}); + REQUIRE_FALSE(result); + CHECK_FALSE(result.error().empty()); +} + +TEST_CASE("Vacuum virial certifies the entire infinity potential trace", "[solver][virial][unit]") { + VirialFixture fixture; + serif::discretization::backend::mfem::TermBinding potential_binding{fixture.potential_space}; + fixture.potential = 0.0; + CHECK_FALSE(diagnostics::has_infinity_zero_gauge(potential_binding, fixture.potential)); + fixture.mesh.GetBdrElement(0)->SetAttribute(2); + fixture.mesh.SetAttributes(); + CHECK(diagnostics::has_infinity_zero_gauge(potential_binding, fixture.potential)); + fixture.potential = -2.0; + CHECK_FALSE(diagnostics::has_infinity_zero_gauge(potential_binding, fixture.potential)); + fixture.potential = 0.0; + const auto boundary = serif::discretization::backend::mfem::select_boundary_dofs< + serif::discretization::domain::InfinitySurfaceBoundary>(potential_binding); + fixture.potential[boundary.reduced_dofs()[boundary.size() / 2]] = 1.0e-17; + CHECK_FALSE(diagnostics::has_infinity_zero_gauge(potential_binding, fixture.potential)); +} + +TEST_CASE("Vacuum virial acceptance reserves room for integration uncertainty", "[solver][virial][unit]") { + diagnostics::VacuumVirialAssessment assessment{}; + auto& final = assessment.samples.back(); + final.potential_energy = -1.0; + final.pressure_integral = 1.0 / 3.0; + assessment.quadrature_allowance = 1.0e-8; + CHECK(assessment.passes()); + final.pressure_integral = (1.0 + 9.95e-7) / 3.0; + CHECK_FALSE(assessment.passes()); // A point estimate below 1e-6 does not leave enough integration margin. + final.pressure_integral = 1.0 / 3.0; + assessment.quadrature_allowance = 1.01e-7; + CHECK_FALSE(assessment.passes()); // The separately bounded quadrature requirement also applies at exact virial balance. + assessment.quadrature_allowance = 0.0; + final.potential_energy = 0.0; + CHECK_FALSE(assessment.passes()); +} diff --git a/tests/surface/automatic.cpp b/tests/surface/automatic.cpp new file mode 100644 index 00000000..cac03959 --- /dev/null +++ b/tests/surface/automatic.cpp @@ -0,0 +1,163 @@ +#include +#include + +#include "serif/tests/equations/test_equations.hpp" +#include "serif/eos/models/polytropic.hpp" +#include "serif/geometry/surface/automatic.hpp" +#include "serif/backend/mfem/geometry/surface/pressure_boundary.hpp" +#include "serif/tests/surface/boundary_fixture.hpp" +#include "serif/tests/surface/models.hpp" +#include "serif/utils/dependencies/computation_cache.hpp" + +namespace +{ + using namespace serif; + using namespace serif::tests::surface; + namespace backend = physics::backend::mfem; + + using ContinuousDensity = ScalarTerm; + using Pressure = ScalarTerm; + + struct ForwardOnly : GasEOS + { + using Relations = eos::relations::RelationCatalog; + }; + + struct SymbolicEOS + { + }; +} + +TEST_CASE("Automatic isobaric compilation preserves the polytropic zero-pressure unit derivative", "[surface][automatic]") +{ + using Fields = dependencies::KeyList; + using Selection = physics::AutomaticPressureSurface>; + using Formulation = Selection::Formulation; + const eos::models::Polytrope equation_of_state{3.0, 0.25}; + physics::SurfacePoint point{}; + point.fields.get() = 0.0; + + const auto result = physics::evaluate_pressure_surface(equation_of_state, physics::Isobaric{quantities::PressureValue{0.0}}, point); + REQUIRE(result); + CHECK(result->residual == 0.0); + CHECK(result->field_derivatives.get() == 1.0); + CHECK(Formulation::resolves_carrier); +} + +TEST_CASE("Automatic isobaric compilation uses inverse or direct EOS relations without enthalpy", "[surface][automatic]") +{ + using Fields = dependencies::KeyList; + using Rows = dependencies::KeyList; + const auto check = [](const EOS& equation_of_state) + { + using Formulation = typename physics::AutomaticPressureSurface::Formulation; + physics::SurfacePoint point{}; + point.fields.template get() = 0.4; + point.fields.template get() = 2.0; + const auto result = physics::evaluate_pressure_surface(equation_of_state, physics::Isobaric{quantities::PressureValue{0.8}}, point); + REQUIRE(result); + CHECK(result->residual == 0.0); + + constexpr double step = 1.0e-6; + auto plus = point; + auto minus = point; + plus.fields.template get() += step * 0.3; + minus.fields.template get() -= step * 0.3; + plus.fields.template get() -= step * 0.2; + minus.fields.template get() += step * 0.2; + const auto forward = physics::evaluate_pressure_surface(equation_of_state, physics::Isobaric{quantities::PressureValue{0.8}}, plus); + const auto backward = physics::evaluate_pressure_surface(equation_of_state, physics::Isobaric{quantities::PressureValue{0.8}}, minus); + REQUIRE(forward); + REQUIRE(backward); + const double derivative = result->field_derivatives.template get() * 0.3 - result->field_derivatives.template get() * 0.2; + CHECK_THAT((forward->residual - backward->residual) / (2.0 * step), Catch::Matchers::WithinAbs(derivative, 2.0e-9)); + }; + check(GasEOS{}); + check(ForwardOnly{}); +} + +TEST_CASE("A pressure unknown needs no EOS relations or EOS graph input", "[surface][automatic]") +{ + using Fields = dependencies::KeyList; + using Formulation = physics::AutomaticPressureSurface::Formulation; + physics::SurfacePoint point{}; + point.fields.get() = 0.2; + const auto single_point = physics::evaluate_pressure_surface(SymbolicEOS{}, physics::Isobaric{quantities::PressureValue{0.2}}, point); + REQUIRE(single_point); + CHECK(single_point->residual == 0.0); + CHECK(single_point->field_derivatives.get() == 1.0); + + const auto inputs = [](const double target) + { + return dependencies::make_dependency_context(physics::pressure_condition(physics::Isobaric{quantities::PressureValue{target}}), physics::boundary_values({0.0, 0.2, 0.8})); + }; + auto cache = dependencies::make_computation_cache(physics::make_pressure_surface_graph(), inputs(0.2)); + REQUIRE(cache.try_prepare(inputs(0.2))); + const auto& values = cache.get(); + REQUIRE(values.size() == 3); + CHECK(values[0].residual == -0.2); + CHECK(values[1].residual == 0.0); + CHECK_THAT(values[2].residual, Catch::Matchers::WithinAbs(0.6, 1.0e-15)); + for (const auto& value : values) + { + CHECK(value.field_derivatives.get() == 1.0); + } + + REQUIRE(cache.try_prepare(inputs(0.8))); + CHECK(cache.get()[2].residual == 0.0); + const auto unchanged = cache.try_prepare(inputs(0.8)); + REQUIRE(unchanged); + CHECK(unchanged->computed_count() == 0); +} + +TEST_CASE("Automatic pressure-field boundaries preserve interior rows and exact transpose actions", "[surface][automatic][backend]") +{ + namespace fixture = serif::tests::surface::boundary; + namespace coefficients = operators::backend::mfem; + using Fields = dependencies::KeyList; + using Formulation = physics::AutomaticPressureSurface::Formulation; + + fixture::Fixture mesh; + discretization::backend::mfem::TermBinding binding{mesh.linear_space}; + const ::mfem::Vector pressure = fixture::sequence(binding.size(), 0.4, 0.2); + const ::mfem::Vector direction = fixture::sequence(binding.size(), -0.1, 0.7); + const ::mfem::Vector dual = fixture::sequence(binding.size(), 0.3, 0.4); + const auto state = coefficients::make_term_coefficients(coefficients::bind(binding, pressure)); + const auto directions = coefficients::make_term_coefficients(coefficients::bind(binding, direction)); + const auto boundary = backend::make_pressure_boundary(state); + const SymbolicEOS equation_of_state; + const auto eos_input = dependencies::borrow(equation_of_state, dependencies::track_by([](const SymbolicEOS&) + { + return 0; + })); + const auto inputs = backend::make_pressure_boundary_inputs(boundary, eos_input, physics::Isobaric{quantities::PressureValue{0.2}}, state); + auto cache = dependencies::make_computation_cache(backend::make_pressure_boundary_graph(), inputs); + REQUIRE(cache.try_prepare(inputs)); + + const auto& linearization = cache.get(); + ::mfem::Vector residual(binding.size()), action(binding.size()), transpose(binding.size()); + residual = 4.0; + action = 0.0; + transpose = 0.0; + backend::apply_residual_rows(*boundary, linearization, residual); + backend::apply_jacobian_rows(*boundary, linearization, directions, action); + backend::add_jacobian_transpose_rows(*boundary, linearization, dual, transpose); + const ::mfem::Vector selected = fixture::boundary_mask(binding); + + for (int row = 0; row < binding.size(); ++row) + { + if (selected[row] != 0.0) + { + CHECK_THAT(residual[row], Catch::Matchers::WithinAbs(pressure[row] - 0.2, 1.0e-14)); + CHECK_THAT(action[row], Catch::Matchers::WithinAbs(direction[row], 1.0e-14)); + CHECK_THAT(transpose[row], Catch::Matchers::WithinAbs(dual[row], 1.0e-14)); + } + else + { + CHECK(residual[row] == 4.0); + CHECK(action[row] == 0.0); + CHECK(transpose[row] == 0.0); + } + } + CHECK_THAT(action * dual, Catch::Matchers::WithinAbs(direction * transpose, 1.0e-14)); +} diff --git a/tests/surface/boundary.cpp b/tests/surface/boundary.cpp new file mode 100644 index 00000000..1c631f68 --- /dev/null +++ b/tests/surface/boundary.cpp @@ -0,0 +1,544 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "serif/backend/mfem/geometry/surface/pressure_boundary.hpp" +#include "serif/tests/surface/boundary_fixture.hpp" +#include "serif/utils/dependencies/computation_cache.hpp" + +namespace +{ + namespace test = serif::tests::surface; + namespace fixture = test::boundary; + namespace physics = serif::geometry::surface; + namespace boundary = physics::backend::mfem; + namespace dependencies = serif::utils::dependencies; + namespace coefficients = serif::operators::backend::mfem; + using fixture::Vector; + using Status = dependencies::PreparationStatus; + using Linearization = physics::keys::Linearization; + using Response = physics::keys::EOSResponse; + using Catch::Matchers::WithinAbs; + + /** @brief Track physical EOS coefficients while excluding observational evaluation counters. */ + [[nodiscard]] auto eos_input(const test::GeneralEOS& model) + { + return dependencies::borrow(model, dependencies::track_by([](const test::GeneralEOS& eos) + { + return eos.scale; + })); + } + + /** @brief The ideal-gas fixture has no mutable physical parameters. */ + [[nodiscard]] auto eos_input(const test::GasEOS& model) + { + return dependencies::borrow(model, dependencies::track_by([](const test::GasEOS&) + { + return 0; + })); + } + + /** @brief Make the prescribed thermodynamic pressure explicit in every candidate. */ + [[nodiscard]] physics::IsobaricSurface pressure(const double value) + { + return physics::IsobaricSurface{test::quantities::PressureValue{value}}; + } + + /** @brief Bind the composition-dependent EOS to three distinct finite-element fields. */ + [[nodiscard]] auto composition_state(const fixture::Fixture& mesh, const Vector& enthalpy, const Vector& entropy, const Vector& composition) + { + return coefficients::make_term_coefficients(coefficients::bind(mesh.enthalpy, enthalpy), coefficients::bind(mesh.entropy, entropy), coefficients::bind(mesh.composition, composition)); + } + + /** @brief Check the same nonlinear gas surface with either carrier-resolved or direct-pressure residuals. */ + template + void check_gas_rows() + { + fixture::Fixture mesh; + test::GasEOS model; + Vector density = fixture::sequence(mesh.density.size(), 0.6, 0.1); + Vector temperature = fixture::sequence(mesh.temperature.size(), 1.3, 0.2); + const Vector density_direction = fixture::sequence(density.Size(), 0.07, 0.3); + const Vector temperature_direction = fixture::sequence(temperature.Size(), -0.11, 0.4); + const auto state = coefficients::make_term_coefficients(coefficients::bind(mesh.density, density), coefficients::bind(mesh.temperature, temperature)); + const auto direction = coefficients::make_term_coefficients(coefficients::bind(mesh.density, density_direction), coefficients::bind(mesh.temperature, temperature_direction)); + const auto selected = boundary::make_pressure_boundary(state); + const auto inputs = boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.8), state); + auto cache = dependencies::make_computation_cache(boundary::make_pressure_boundary_graph(), inputs); + REQUIRE(cache.try_prepare(inputs)); + + const Vector sampled_temperature = fixture::reference_samples(mesh.density, *selected->selected_rows, mesh.temperature, temperature); + const Vector sampled_direction = fixture::reference_samples(mesh.density, *selected->selected_rows, mesh.temperature, temperature_direction); + const auto& physics = cache.template get(); + Vector residual(density.Size()), action(density.Size()); + residual = 4.0; + action = -3.0; + boundary::apply_residual_rows(*selected, physics, residual); + boundary::apply_jacobian_rows(*selected, physics, direction, action); + const Vector mask = fixture::boundary_mask(mesh.density); + for (int index = 0; index < selected->selected_rows->size(); ++index) + { + const int row = selected->selected_rows->reduced_dofs()[index]; + const double local_temperature = sampled_temperature[index]; + const double expected_residual = Formulation::resolves_carrier ? density[row] - 0.8 / local_temperature : density[row] * local_temperature - 0.8; + const double expected_action = Formulation::resolves_carrier ? density_direction[row] + 0.8 * sampled_direction[index] / (local_temperature * local_temperature) : local_temperature * density_direction[row] + density[row] * sampled_direction[index]; + CHECK_THAT(residual[row], WithinAbs(expected_residual, 1.0e-13)); + CHECK_THAT(action[row], WithinAbs(expected_action, 1.0e-13)); + } + for (int row = 0; row < mask.Size(); ++row) + { + if (mask[row] == 0.0) + { + CHECK(residual[row] == 4.0); + CHECK(action[row] == -3.0); + action[row] = 0.0; + } + } + + const Vector dual = fixture::sequence(density.Size(), 0.4, 0.8); + Vector density_force(density.Size()), temperature_force(temperature.Size()); + density_force = 0.0; + temperature_force = 0.0; + boundary::add_jacobian_transpose_rows(*selected, physics, dual, density_force); + boundary::add_jacobian_transpose_rows(*selected, physics, dual, temperature_force); + const double forward_work = action * dual; + const double reverse_work = density_direction * density_force + temperature_direction * temperature_force; + CHECK_THAT(forward_work, WithinAbs(reverse_work, 2.0e-13 * std::max(1.0, std::abs(forward_work)))); + + constexpr double step = 1.0e-7; + const Vector original_density(density), original_temperature(temperature); + Vector plus(density.Size()), minus(density.Size()); + plus = 0.0; + minus = 0.0; + density.Add(step, density_direction); + temperature.Add(step, temperature_direction); + REQUIRE(cache.try_prepare(boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.8), state))); + boundary::apply_residual_rows(*selected, cache.template get(), plus); + density = original_density; + temperature = original_temperature; + density.Add(-step, density_direction); + temperature.Add(-step, temperature_direction); + REQUIRE(cache.try_prepare(boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.8), state))); + boundary::apply_residual_rows(*selected, cache.template get(), minus); + for (const int row : selected->selected_rows->reduced_dofs()) + { + CHECK_THAT((plus[row] - minus[row]) / (2.0 * step), WithinAbs(action[row], 2.0e-9)); + } + } +} + +TEST_CASE("General EOS pressure rows sample every field in its own finite-element basis", "[surface][unit][boundary]") +{ + fixture::Fixture mesh; + test::GeneralEOS model; + Vector enthalpy = fixture::sequence(mesh.enthalpy.size(), 1.7, 0.1); + Vector entropy = fixture::sequence(mesh.entropy.size(), 0.2, 0.3); + Vector composition = fixture::sequence(mesh.composition.size(), 0.1, 0.5); + const Vector enthalpy_direction = fixture::sequence(enthalpy.Size(), 0.7, 0.2); + const Vector entropy_direction = fixture::sequence(entropy.Size(), -0.2, 0.4); + const Vector composition_direction = fixture::sequence(composition.Size(), 0.05, 0.6); + const auto state = composition_state(mesh, enthalpy, entropy, composition); + const auto direction = composition_state(mesh, enthalpy_direction, entropy_direction, composition_direction); + const auto selected = boundary::make_pressure_boundary(state); + const auto inputs = boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.4), state); + auto cache = dependencies::make_computation_cache(boundary::make_pressure_boundary_graph(), inputs); + CHECK(model.evaluations == 0); + REQUIRE(cache.try_prepare(inputs)); + const auto& accepted = cache.get(); + const auto& rows = *selected->selected_rows; + REQUIRE(rows.size() > 0); + REQUIRE(rows.size() < enthalpy.Size()); + const Vector mask = fixture::boundary_mask(mesh.enthalpy); + CHECK(rows.size() == static_cast(mask.Sum())); + + const Vector sampled_entropy = fixture::reference_samples(mesh.enthalpy, rows, mesh.entropy, entropy); + const Vector sampled_composition = fixture::reference_samples(mesh.enthalpy, rows, mesh.composition, composition); + const Vector sampled_entropy_direction = fixture::reference_samples(mesh.enthalpy, rows, mesh.entropy, entropy_direction); + const Vector sampled_composition_direction = fixture::reference_samples(mesh.enthalpy, rows, mesh.composition, composition_direction); + Vector residual(enthalpy.Size()), action(enthalpy.Size()); + residual = 7.0; + action = -4.0; + boundary::apply_residual_rows(*selected, accepted, residual); + boundary::apply_jacobian_rows(*selected, accepted, direction, action); + for (int index = 0; index < rows.size(); ++index) + { + const int row = rows.reduced_dofs()[index]; + CHECK(mask[row] == 1.0); + CHECK_THAT(residual[row], WithinAbs(enthalpy[row] - (0.8 + 3.0 * sampled_entropy[index] + 5.0 * sampled_composition[index]), 1.0e-13)); + CHECK_THAT(action[row], WithinAbs(enthalpy_direction[row] - 3.0 * sampled_entropy_direction[index] - 5.0 * sampled_composition_direction[index], 1.0e-13)); + } + for (int row = 0; row < mask.Size(); ++row) + { + if (mask[row] == 0.0) + { + CHECK(residual[row] == 7.0); + CHECK(action[row] == -4.0); + action[row] = 0.0; + } + } + + const Vector dual = fixture::sequence(enthalpy.Size(), 0.3, 0.9); + Vector enthalpy_force(enthalpy.Size()), entropy_force(entropy.Size()), composition_force(composition.Size()), volume_dual; + enthalpy_force = 0.0; + entropy_force = 0.0; + composition_force = 0.0; + boundary::mask_residual_dual(*selected, dual, volume_dual); + boundary::add_jacobian_transpose_rows(*selected, accepted, dual, enthalpy_force); + boundary::add_jacobian_transpose_rows(*selected, accepted, dual, entropy_force); + boundary::add_jacobian_transpose_rows(*selected, accepted, dual, composition_force); + const double forward_work = dual * action; + const double reverse_work = enthalpy_direction * enthalpy_force + entropy_direction * entropy_force + composition_direction * composition_force; + CHECK_THAT(forward_work, WithinAbs(reverse_work, 2.0e-13 * std::max(1.0, std::abs(forward_work)))); + for (int row = 0; row < dual.Size(); ++row) + { + CHECK(volume_dual[row] == (mask[row] != 0.0 ? 0.0 : dual[row])); + } + + Vector aliased_direction(enthalpy_direction); + const auto aliased_state = composition_state(mesh, aliased_direction, entropy_direction, composition_direction); + boundary::apply_jacobian_rows(*selected, accepted, aliased_state, aliased_direction); + Vector aliased_dual(dual); + boundary::add_jacobian_transpose_rows(*selected, accepted, aliased_dual, aliased_dual); + for (int row = 0; row < enthalpy.Size(); ++row) + { + CHECK(aliased_direction[row] == (mask[row] != 0.0 ? action[row] : enthalpy_direction[row])); + CHECK_THAT(aliased_dual[row], WithinAbs(dual[row] + enthalpy_force[row], 1.0e-15)); + } + + // Mixed-order FE interpolation adds roundoff before residual subtraction; a larger step retains the original absolute tolerance. + // The local EOS regression separately preserves MeanField's original 1e-7 step. + constexpr double step = 1.0e-6; + const Vector original_enthalpy(enthalpy), original_entropy(entropy), original_composition(composition); + Vector plus(enthalpy.Size()), minus(enthalpy.Size()); + plus = 0.0; + minus = 0.0; + enthalpy.Add(step, enthalpy_direction); + entropy.Add(step, entropy_direction); + composition.Add(step, composition_direction); + REQUIRE(cache.try_prepare(boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.4), state))); + boundary::apply_residual_rows(*selected, cache.get(), plus); + enthalpy = original_enthalpy; + entropy = original_entropy; + composition = original_composition; + enthalpy.Add(-step, enthalpy_direction); + entropy.Add(-step, entropy_direction); + composition.Add(-step, composition_direction); + REQUIRE(cache.try_prepare(boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.4), state))); + boundary::apply_residual_rows(*selected, cache.get(), minus); + for (const int row : rows.reduced_dofs()) + { + CHECK_THAT((plus[row] - minus[row]) / (2.0 * step), WithinAbs(action[row], 2.0e-9)); + } +} + +TEST_CASE("A continuous density field enforces isobaric rows without enthalpy", "[surface][unit][boundary]") +{ + SECTION("Density from pressure and temperature") + { + check_gas_rows>(); + } + SECTION("Pressure directly from density and temperature") + { + check_gas_rows>(); + } +} + +TEST_CASE("Uniform EOS inputs scatter their transpose contributions into one global scalar", "[surface][unit][boundary]") +{ + fixture::Fixture mesh; + test::GasEOS model; + Vector density = fixture::sequence(mesh.density.size(), 0.6, 0.1); + Vector temperature(1), density_direction = fixture::sequence(density.Size(), 0.1, 0.4), temperature_direction(1); + temperature[0] = 1.4; + temperature_direction[0] = -0.2; + const auto state = coefficients::make_term_coefficients(coefficients::bind(mesh.density, density), coefficients::bind(mesh.uniform_temperature, temperature)); + const auto direction = coefficients::make_term_coefficients(coefficients::bind(mesh.density, density_direction), coefficients::bind(mesh.uniform_temperature, temperature_direction)); + using Formulation = fixture::DensityFormulation; + const auto selected = boundary::make_pressure_boundary(state); + const auto inputs = boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.8), state); + auto cache = dependencies::make_computation_cache(boundary::make_pressure_boundary_graph(), inputs); + REQUIRE(cache.try_prepare(inputs)); + const auto& accepted = cache.get(); + Vector residual(density.Size()), action(density.Size()), density_force(density.Size()), temperature_force(1); + residual = 0.0; + action = 0.0; + density_force = 0.0; + temperature_force = 0.0; + boundary::apply_residual_rows(*selected, accepted, residual); + boundary::apply_jacobian_rows(*selected, accepted, direction, action); + const Vector dual = fixture::sequence(density.Size(), 0.4, 0.8); + boundary::add_jacobian_transpose_rows(*selected, accepted, dual, density_force); + boundary::add_jacobian_transpose_rows(*selected, accepted, dual, temperature_force); + double expected_temperature_force = 0.0; + for (const int row : selected->selected_rows->reduced_dofs()) + { + CHECK_THAT(residual[row], WithinAbs(density[row] - 0.8 / temperature[0], 1.0e-13)); + expected_temperature_force += 0.8 / (temperature[0] * temperature[0]) * dual[row]; + } + CHECK_THAT(temperature_force[0], WithinAbs(expected_temperature_force, 1.0e-13)); + CHECK_THAT(action * dual, WithinAbs(density_direction * density_force + temperature_direction * temperature_force, 2.0e-13)); +} + +TEST_CASE("Boundary field sampling and EOS work have distinct dependency lifetimes", "[surface][unit][boundary]") +{ + fixture::Fixture mesh; + test::GeneralEOS model; + Vector enthalpy = fixture::sequence(mesh.enthalpy.size(), 1.7, 0.1); + Vector entropy = fixture::sequence(mesh.entropy.size(), 0.2, 0.3); + Vector composition = fixture::sequence(mesh.composition.size(), 0.1, 0.5); + const auto state = composition_state(mesh, enthalpy, entropy, composition); + const auto selected = boundary::make_pressure_boundary(state); + const auto inputs = boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.4), state); + auto cache = dependencies::make_computation_cache(boundary::make_pressure_boundary_graph(), inputs); + CHECK(model.evaluations == 0); + REQUIRE(cache.try_prepare(inputs)); + const int evaluations = model.evaluations; + REQUIRE(evaluations > 0); + const auto repeated = cache.try_prepare(inputs); + REQUIRE(repeated); + CHECK(repeated->computed_count() == 0); + + enthalpy[selected->selected_rows->reduced_dofs()[0]] += 0.1; + const auto carrier_changed = cache.try_prepare(boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.4), state)); + REQUIRE(carrier_changed); + CHECK(carrier_changed->status>() == Status::computed); + CHECK(carrier_changed->status>() == Status::reused); + CHECK(carrier_changed->status() == Status::reused); + CHECK(carrier_changed->status() == Status::computed); + CHECK(model.evaluations == evaluations); + + entropy *= 1.1; + const auto entropy_changed = cache.try_prepare(boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.4), state)); + REQUIRE(entropy_changed); + CHECK(entropy_changed->status>() == Status::computed); + CHECK(entropy_changed->status() == Status::computed); + + const auto pressure_changed = cache.try_prepare(boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.5), state)); + REQUIRE(pressure_changed); + CHECK(pressure_changed->status() == Status::computed); + CHECK(pressure_changed->status>() == Status::reused); + + model.scale = 1.2; + const auto eos_changed = cache.try_prepare(boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.5), state)); + REQUIRE(eos_changed); + CHECK(eos_changed->status() == Status::computed); + CHECK(eos_changed->status>() == Status::reused); + CHECK(cache.get()[0].field_derivatives.get() == -3.0 * model.scale); +} + +TEST_CASE("Direct-pressure boundary changes reuse EOS samples when only prescribed pressure changes", "[surface][unit][boundary]") +{ + fixture::Fixture mesh; + test::GasEOS model; + Vector density = fixture::sequence(mesh.density.size(), 0.6, 0.1); + Vector temperature = fixture::sequence(mesh.temperature.size(), 1.3, 0.2); + const auto state = coefficients::make_term_coefficients(coefficients::bind(mesh.density, density), coefficients::bind(mesh.temperature, temperature)); + using Formulation = fixture::PressureFormulation<>; + const auto selected = boundary::make_pressure_boundary(state); + const auto inputs = boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.8), state); + auto cache = dependencies::make_computation_cache(boundary::make_pressure_boundary_graph(), inputs); + REQUIRE(cache.try_prepare(inputs)); + const int evaluations = model.evaluations; + const double previous = cache.get()[0].residual; + const auto changed = cache.try_prepare(boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.9), state)); + REQUIRE(changed); + CHECK(changed->status() == Status::reused); + CHECK(changed->status() == Status::computed); + CHECK(model.evaluations == evaluations); + CHECK_THAT(cache.get()[0].residual, WithinAbs(previous - 0.1, 1.0e-15)); +} + +TEST_CASE("A rejected boundary candidate hides old results and permits a valid retry", "[surface][unit][boundary]") +{ + fixture::Fixture mesh; + test::GeneralEOS model; + Vector enthalpy = fixture::sequence(mesh.enthalpy.size(), 1.7, 0.1); + Vector entropy = fixture::sequence(mesh.entropy.size(), 0.2, 0.3); + Vector composition = fixture::sequence(mesh.composition.size(), 0.1, 0.5); + const auto state = composition_state(mesh, enthalpy, entropy, composition); + const auto selected = boundary::make_pressure_boundary(state); + const auto inputs = boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.4), state); + auto cache = dependencies::make_computation_cache(boundary::make_pressure_boundary_graph(), inputs); + REQUIRE(cache.try_prepare(inputs)); + const double previous = cache.get()[0].residual; + const Vector original_entropy(entropy); + entropy = std::numeric_limits::quiet_NaN(); + const auto rejected = cache.try_prepare(boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.4), state)); + REQUIRE_FALSE(rejected); + CHECK_FALSE(cache.ready()); + CHECK_THROWS_AS(cache.get(), std::logic_error); + entropy = original_entropy; + REQUIRE(cache.try_prepare(boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.4), state))); + CHECK(cache.get()[0].residual == previous); +} + +TEST_CASE("Pressure boundaries reject stale and unrelated coefficient bindings before publishing rows", "[surface][unit][boundary]") +{ + fixture::Fixture mesh; + test::GeneralEOS model; + Vector enthalpy = fixture::sequence(mesh.enthalpy.size(), 1.7, 0.1); + Vector entropy = fixture::sequence(mesh.entropy.size(), 0.2, 0.3); + Vector composition = fixture::sequence(mesh.composition.size(), 0.1, 0.5); + const auto state = composition_state(mesh, enthalpy, entropy, composition); + const auto selected = boundary::make_pressure_boundary(state); + const auto inputs = boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.4), state); + auto cache = dependencies::make_computation_cache(boundary::make_pressure_boundary_graph(), inputs); + REQUIRE(cache.try_prepare(inputs)); + + SECTION("A same-sized binding cannot silently replace the compiled field") + { + fixture::finite_elements::TermBinding other_entropy(mesh.linear_space); + const auto other_state = coefficients::make_term_coefficients(coefficients::bind(mesh.enthalpy, enthalpy), coefficients::bind(other_entropy, entropy), coefficients::bind(mesh.composition, composition)); + const auto other_inputs = boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.4), other_state); + CHECK_THROWS_AS(cache.try_prepare(other_inputs), std::invalid_argument); + CHECK_FALSE(cache.ready()); + REQUIRE(cache.try_prepare(inputs)); + } + SECTION("The candidate vector must match its finite-element coefficients") + { + Vector short_entropy(entropy.Size() - 1); + short_entropy = 0.2; + const auto other_state = composition_state(mesh, enthalpy, short_entropy, composition); + CHECK_THROWS_AS(cache.try_prepare(boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.4), other_state)), std::invalid_argument); + CHECK_FALSE(cache.ready()); + REQUIRE(cache.try_prepare(inputs)); + } + SECTION("Refinement invalidates traces even if all coefficients are unchanged") + { + mesh.mesh.UniformRefinement(); + CHECK_THROWS_AS(cache.try_prepare(inputs), std::logic_error); + CHECK_FALSE(cache.ready()); + CHECK_THROWS_AS(cache.get(), std::logic_error); + } + SECTION("Boundary labels invalidate the selected pressure rows without a topology revision") + { + const int previous = mesh.mesh.GetBdrAttribute(0); + mesh.mesh.GetBdrElement(0)->SetAttribute(previous == 1 ? 2 : 1); + mesh.mesh.SetAttributes(); + CHECK_THROWS_AS(cache.try_prepare(inputs), std::logic_error); + CHECK_FALSE(cache.ready()); + mesh.mesh.GetBdrElement(0)->SetAttribute(previous); + mesh.mesh.SetAttributes(); + REQUIRE(cache.try_prepare(inputs)); + } + SECTION("Material labels invalidate the supported coefficient binding") + { + mesh.mesh.GetElement(0)->SetAttribute(2); + mesh.mesh.SetAttributes(); + CHECK_THROWS_AS(cache.try_prepare(inputs), std::logic_error); + CHECK_FALSE(cache.ready()); + mesh.mesh.GetElement(0)->SetAttribute(1); + mesh.mesh.SetAttributes(); + REQUIRE(cache.try_prepare(inputs)); + } + SECTION("Curved coordinate edits preserve canonical finite-element traces") + { + const double previous = cache.get()[0].residual; + const int evaluations = model.evaluations; + const Vector original_nodes(*mesh.mesh.GetNodes()); + ::mfem::VectorFunctionCoefficient warp(3, [](const Vector& position, Vector& mapped) + { + mapped = position; + mapped[0] += 0.04 * position[1] * position[1]; + mapped[2] += 0.03 * position[0] * position[1]; + }); + mesh.mesh.Transform(warp); + Vector coordinate_change(*mesh.mesh.GetNodes()); + coordinate_change -= original_nodes; + REQUIRE(coordinate_change.Norml2() > 0.0); + const auto reused = cache.try_prepare(inputs); + REQUIRE(reused); + CHECK(reused->computed_count() == 0); + CHECK(model.evaluations == evaluations); + CHECK(cache.get()[0].residual == previous); + } +} + +TEST_CASE("Pressure row actions reject malformed buffers before replacing any entries", "[surface][unit][boundary]") +{ + fixture::Fixture mesh; + test::GeneralEOS model; + Vector enthalpy = fixture::sequence(mesh.enthalpy.size(), 1.7, 0.1); + Vector entropy = fixture::sequence(mesh.entropy.size(), 0.2, 0.3); + Vector composition = fixture::sequence(mesh.composition.size(), 0.1, 0.5); + const auto state = composition_state(mesh, enthalpy, entropy, composition); + const auto selected = boundary::make_pressure_boundary(state); + const auto inputs = boundary::make_pressure_boundary_inputs(selected, eos_input(model), pressure(0.4), state); + auto cache = dependencies::make_computation_cache(boundary::make_pressure_boundary_graph(), inputs); + REQUIRE(cache.try_prepare(inputs)); + const auto& accepted = cache.get(); + Vector result(enthalpy.Size()); + result = 3.0; + + SECTION("Missing point linearizations cannot index outside the accepted batch") + { + auto incomplete = accepted; + incomplete.pop_back(); + CHECK_THROWS_AS(boundary::apply_residual_rows(*selected, incomplete, result), std::invalid_argument); + CHECK_THROWS_AS(boundary::apply_jacobian_rows(*selected, incomplete, state, result), std::invalid_argument); + CHECK_THROWS_AS(boundary::add_jacobian_transpose_rows(*selected, incomplete, enthalpy, result), std::invalid_argument); + } + SECTION("Short directions cannot index outside an input field") + { + Vector short_entropy(entropy.Size() - 1); + short_entropy = 0.2; + const auto direction = composition_state(mesh, enthalpy, short_entropy, composition); + CHECK_THROWS_AS(boundary::apply_jacobian_rows(*selected, accepted, direction, result), std::invalid_argument); + } + SECTION("A nonfinite direction cannot partially replace the rows") + { + entropy = std::numeric_limits::infinity(); + CHECK_THROWS_AS(boundary::apply_jacobian_rows(*selected, accepted, state, result), std::domain_error); + } + SECTION("Short residual output is rejected before writing") + { + result.SetSize(enthalpy.Size() - 1); + result = 3.0; + CHECK_THROWS_AS(boundary::apply_residual_rows(*selected, accepted, result), std::invalid_argument); + CHECK_THROWS_AS(boundary::apply_jacobian_rows(*selected, accepted, state, result), std::invalid_argument); + CHECK_THROWS_AS(boundary::add_jacobian_transpose_rows(*selected, accepted, enthalpy, result), std::invalid_argument); + } + for (int index = 0; index < result.Size(); ++index) + { + CHECK(result[index] == 3.0); + } +} + +TEST_CASE("Accepted boundary snapshots retain their identity until a replacement is published", "[surface][unit][boundary]") +{ + fixture::Fixture mesh; + test::GeneralEOS model; + Vector enthalpy = fixture::sequence(mesh.enthalpy.size(), 1.7, 0.1); + Vector entropy = fixture::sequence(mesh.entropy.size(), 0.2, 0.3); + Vector composition = fixture::sequence(mesh.composition.size(), 0.1, 0.5); + const auto state = composition_state(mesh, enthalpy, entropy, composition); + using BoundaryPointer = decltype(boundary::make_pressure_boundary(state)); + std::weak_ptr previous_boundary; + auto cache = [&]() + { + const auto original = boundary::make_pressure_boundary(state); + previous_boundary = original; + const auto inputs = boundary::make_pressure_boundary_inputs(original, eos_input(model), pressure(0.4), state); + auto prepared = dependencies::make_computation_cache(boundary::make_pressure_boundary_graph(), inputs); + REQUIRE(prepared.try_prepare(inputs)); + return prepared; + }(); + REQUIRE_FALSE(previous_boundary.expired()); + const double previous_residual = cache.get()[0].residual; + + const auto replacement = boundary::make_pressure_boundary(state); + const auto inputs = boundary::make_pressure_boundary_inputs(replacement, eos_input(model), pressure(0.4), state); + const auto changed = cache.try_prepare(inputs); + REQUIRE(changed); + CHECK(changed->status>() == Status::computed); + CHECK(changed->status>() == Status::computed); + CHECK(changed->status>() == Status::computed); + CHECK(changed->status() == Status::computed); + CHECK(changed->status() == Status::computed); + CHECK(cache.get()[0].residual == previous_residual); + CHECK(previous_boundary.expired()); +} diff --git a/tests/surface/compilation.cpp b/tests/surface/compilation.cpp new file mode 100644 index 00000000..c1d56f04 --- /dev/null +++ b/tests/surface/compilation.cpp @@ -0,0 +1,132 @@ +#include +#include + +#include +#include + +#include "serif/eos/models/polytropic.hpp" +#include "serif/tests/surface/models.hpp" + +namespace { + using namespace serif; + using namespace serif::tests::surface; +} + +TEST_CASE("Isobaric surface prescribes pressure without selecting a carrier", "[surface][unit]") { + // MeanField: Constant Pressure Surface Prescribes Only A Pressure Quantity. + const physics::IsobaricSurface condition{quantities::PressureValue{0.03125}}; + CHECK(condition.target_pressure() == quantities::PressureValue{0.03125}); + CHECK(condition.descriptor().target_pressure == 0.03125); + CHECK(condition.continuation_identity() == std::array{0.03125}); + CHECK(physics::IsobaricSurface{physics::IsobaricSurface::Parameters{.PSurf = quantities::PressureValue{0.4}}}.target_pressure() == quantities::PressureValue{0.4}); + + for (const double invalid : {-0.1, std::numeric_limits::infinity(), -std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()}) { + CHECK_THROWS_AS(physics::IsobaricSurface{quantities::PressureValue{invalid}}, std::invalid_argument); + } +} + +TEST_CASE("Polytropic surface resolves pressure through its declared enthalpy relation", "[surface][unit]") { + // MeanField: Polytropic EOS Resolves Constant Surface Pressure Through Its Enthalpy Relation. + using Formulation = physics::PressureSurfaceFormulation; + const eos::models::Polytrope equation_of_state{3.0, 0.25}; + const physics::IsobaricSurface condition{quantities::PressureValue{0.03125}}; + const double required = eos::evaluate(equation_of_state, condition.target_pressure()).value(); + + physics::SurfacePoint point{}; + point.fields.get() = required; + const auto result = physics::evaluate_pressure_surface(equation_of_state, condition, point); + REQUIRE(result); + CHECK(result->residual == 0.0); + CHECK(result->field_derivatives.get() * -0.19 == -0.19); + CHECK(result->position_derivative == std::array{}); +} + +TEST_CASE("General pressure surface uses the local composition", "[surface][unit]") { + // MeanField: General EOS Resolves Constant Surface Pressure With Local Composition. + const GeneralEOS equation_of_state; + const physics::IsobaricSurface condition{quantities::PressureValue{0.4}}; + const auto first = general_point(2.0 * 0.4 + 3.0 * 0.2 + 5.0 * 0.1, 0.2, 0.1); + const auto second = general_point(2.0 * 0.4 + 3.0 * 0.3 + 5.0 * 0.1, 0.3, 0.1); + REQUIRE(first.fields.get() != second.fields.get()); + + const auto first_result = physics::evaluate_pressure_surface(equation_of_state, condition, first); + const auto second_result = physics::evaluate_pressure_surface(equation_of_state, condition, second); + REQUIRE(first_result); + REQUIRE(second_result); + CHECK(first_result->residual == 0.0); + CHECK(second_result->residual == 0.0); +} + +TEST_CASE("General pressure surface Jacobian retains every MeanField state dependency", "[surface][unit]") { + // MeanField: preserve its finite-difference step, direction, and 2e-9 absolute gate. + const GeneralEOS equation_of_state; + const physics::IsobaricSurface condition{quantities::PressureValue{0.4}}; + constexpr double step = 1.0e-7; + const auto point = general_point(1.7, 0.2, 0.1); + const auto forward = general_point(1.7 + step * 0.7, 0.2 - step * 0.2, 0.1 + step * 0.05); + const auto backward = general_point(1.7 - step * 0.7, 0.2 + step * 0.2, 0.1 - step * 0.05); + + const auto result = physics::evaluate_pressure_surface(equation_of_state, condition, point); + const auto plus = physics::evaluate_pressure_surface(equation_of_state, condition, forward); + const auto minus = physics::evaluate_pressure_surface(equation_of_state, condition, backward); + REQUIRE(result); + REQUIRE(plus); + REQUIRE(minus); + const double action = result->field_derivatives.get() * 0.7 + result->field_derivatives.get() * -0.2 + result->field_derivatives.get() * 0.05; + CHECK(action == 0.7 - 3.0 * -0.2 - 5.0 * 0.05); + CHECK_THAT((plus->residual - minus->residual) / (2.0 * step), Catch::Matchers::WithinAbs(action, 2.0e-9)); +} + +TEST_CASE("Isobaric surface supports density without an enthalpy quantity or EOS relation", "[surface][unit]") { + const GasEOS equation_of_state; + for (const double pressure : {0.0, 0.4, 2.0}) { + for (const double temperature : {0.5, 1.0, 3.0}) { + CAPTURE(pressure, temperature); + const physics::IsobaricSurface condition{quantities::PressureValue{pressure}}; + const auto point = gas_point(pressure / temperature, temperature); + const auto inverse = physics::evaluate_pressure_surface(equation_of_state, condition, point); + const auto direct = physics::evaluate_pressure_surface(equation_of_state, condition, point); + REQUIRE(inverse); + REQUIRE(direct); + CHECK(inverse->residual == 0.0); + CHECK_THAT(direct->residual, Catch::Matchers::WithinAbs(0.0, 1.0e-15)); + CHECK(inverse->field_derivatives.get() == 1.0); + CHECK(inverse->field_derivatives.get() == pressure / (temperature * temperature)); + CHECK(direct->field_derivatives.get() == temperature); + CHECK(direct->field_derivatives.get() == pressure / temperature); + } + } +} + +TEST_CASE("Nonlinear density and direct pressure residuals have independent finite-difference checks", "[surface][unit]") { + const GasEOS equation_of_state; + const physics::IsobaricSurface condition{quantities::PressureValue{0.8}}; + constexpr double step = 1.0e-6; + const auto check = [&]() { + const auto result = physics::evaluate_pressure_surface(equation_of_state, condition, gas_point(0.6, 1.3)); + const auto plus = physics::evaluate_pressure_surface(equation_of_state, condition, gas_point(0.6 + step * 0.31, 1.3 - step * 0.27)); + const auto minus = physics::evaluate_pressure_surface(equation_of_state, condition, gas_point(0.6 - step * 0.31, 1.3 + step * 0.27)); + REQUIRE(result); + REQUIRE(plus); + REQUIRE(minus); + const double action = result->field_derivatives.template get() * 0.31 - result->field_derivatives.template get() * 0.27; + CHECK_THAT((plus->residual - minus->residual) / (2.0 * step), Catch::Matchers::WithinAbs(action, 2.0e-9)); + }; + check.template operator()(); + check.template operator()(); +} + +TEST_CASE("Shared thermodynamic targets accumulate their Jacobian contributions", "[surface][unit]") { + using SharedState = dependencies::Bindings< + dependencies::Binding, + dependencies::Binding, + dependencies::Binding + >; + using Formulation = physics::PressureSurfaceFormulation; + physics::SurfacePoint point{}; + point.fields.get() = 0.2; + const auto result = physics::evaluate_pressure_surface(GeneralEOS{}, physics::IsobaricSurface{quantities::PressureValue{0.4}}, point); + REQUIRE(result); + CHECK_THAT(result->residual, Catch::Matchers::WithinAbs(0.2 - (2.0 * 0.4 + 8.0 * 0.2), 1.0e-15)); + CHECK(result->field_derivatives.get() == 1.0 - 3.0 - 5.0); +} diff --git a/tests/surface/computations.cpp b/tests/surface/computations.cpp new file mode 100644 index 00000000..fc580897 --- /dev/null +++ b/tests/surface/computations.cpp @@ -0,0 +1,169 @@ +#include +#include + +#include "serif/tests/surface/contexts.hpp" + +namespace { + using namespace serif::tests::surface; + using Status = dependencies::PreparationStatus; + using Response = physics::keys::EOSResponse; + using Linearization = physics::keys::Linearization; + using OffsetFormulation = physics::PressureSurfaceFormulation; +} + +TEST_CASE("Surface graph construction performs no physics and pressure-only response is broadcast", "[surface][unit]") { + OffsetEOS model; + const auto context = offset_context(model, 0.4, {0.7, 1.1, 1.4}); + auto graph = physics::make_pressure_surface_graph(); + auto cache = dependencies::make_computation_cache(std::move(graph), context); + CHECK(model.evaluations == 0); + CHECK_FALSE(cache.ready()); + CHECK_THROWS_AS(cache.get(), std::logic_error); + + const auto prepared = cache.try_prepare(context); + REQUIRE(prepared); + CHECK(prepared->computed_count() == 2); + CHECK(model.evaluations == 1); + REQUIRE(cache.get().size() == 1); + const auto& rows = cache.get(); + REQUIRE(rows.size() == 3); + for (std::size_t index = 0; index < rows.size(); ++index) { + const double expected = context.get().values[index] - (0.3 + 2.0 * 0.4); + CHECK(rows[index].residual == expected); + CHECK(rows[index].field_derivatives.get() == 1.0); + } +} + +TEST_CASE("Surface source changes refresh only the calculations that depend on them", "[surface][unit]") { + OffsetEOS model; + auto cache = dependencies::make_computation_cache(physics::make_pressure_surface_graph(), offset_context(model, 0.0, {0.3, 0.7})); + REQUIRE(cache.try_prepare(offset_context(model, 0.0, {0.3, 0.7}))); + CHECK(cache.get()[0].residual == 0.0); + + auto report = cache.try_prepare(offset_context(model, 0.0, {0.3, 0.7})); + REQUIRE(report); + CHECK(report->computed_count() == 0); + CHECK(model.evaluations == 1); + + report = cache.try_prepare(offset_context(model, 0.0, {0.4, 0.9})); + REQUIRE(report); + CHECK(report->status() == Status::reused); + CHECK(report->status() == Status::computed); + CHECK(model.evaluations == 1); + + report = cache.try_prepare(offset_context(model, 0.2, {0.4, 0.9})); + REQUIRE(report); + CHECK(report->computed_count() == 2); + CHECK(model.evaluations == 2); + + model.offset = 0.6; + report = cache.try_prepare(offset_context(model, 0.2, {0.4, 0.9})); + REQUIRE(report); + CHECK(report->computed_count() == 2); + CHECK(cache.get()[0].residual == 0.4 - (0.6 + 2.0 * 0.2)); + + OffsetEOS replacement{.offset = model.offset}; + report = cache.try_prepare(offset_context(replacement, 0.2, {0.4, 0.9})); + REQUIRE(report); + CHECK(report->computed_count() == 2); + CHECK(replacement.evaluations == 1); + + report = cache.try_prepare(offset_context(replacement, 0.2, {0.4, 0.9}), {}, dependencies::PreparationMode::rebuild); + REQUIRE(report); + CHECK(report->computed_count() == 2); +} + +TEST_CASE("Surface composition response caches values and all partial derivatives together", "[surface][unit]") { + GeneralEOS model; + auto cache = dependencies::make_computation_cache(physics::make_pressure_surface_graph(), general_context(model, 0.4, {1.7, 1.9}, {0.2, 0.3}, {0.1, 0.2})); + REQUIRE(cache.try_prepare(general_context(model, 0.4, {1.7, 1.9}, {0.2, 0.3}, {0.1, 0.2}))); + CHECK(model.evaluations == 2); + CHECK(model.partials == 4); + CHECK(cache.get()[0].field_derivatives.get() == -3.0); + CHECK(cache.get()[0].field_derivatives.get() == -5.0); + + auto report = cache.try_prepare(general_context(model, 0.4, {1.8, 2.0}, {0.2, 0.3}, {0.1, 0.2})); + REQUIRE(report); + CHECK(report->status() == Status::reused); + CHECK(model.partials == 4); + + report = cache.try_prepare(general_context(model, 0.4, {1.8, 2.0}, {0.4, 0.3}, {0.1, 0.2})); + REQUIRE(report); + CHECK(report->computed_count() == 2); + CHECK(model.evaluations == 4); + + report = cache.try_prepare(general_context(model, 0.4, {1.8, 2.0}, {0.4, 0.3}, {0.3, 0.2})); + REQUIRE(report); + CHECK(report->computed_count() == 2); + CHECK(model.evaluations == 6); + + model.scale = 2.0; + REQUIRE(cache.try_prepare(general_context(model, 0.4, {1.8, 2.0}, {0.4, 0.3}, {0.3, 0.2}))); + CHECK(cache.get()[0].field_derivatives.get() == -6.0); + CHECK(cache.get()[0].field_derivatives.get() == -10.0); +} + +TEST_CASE("Density surface graph refreshes nonlinear temperature derivatives", "[surface][unit]") { + GasEOS model; + auto cache = dependencies::make_computation_cache(physics::make_pressure_surface_graph(), gas_context(model, 0.8, {0.4, 0.6}, {1.0, 2.0})); + REQUIRE(cache.try_prepare(gas_context(model, 0.8, {0.4, 0.6}, {1.0, 2.0}))); + CHECK(cache.get()[0].field_derivatives.get() == 0.8); + CHECK(cache.get()[1].field_derivatives.get() == 0.2); + + const auto report = cache.try_prepare(gas_context(model, 0.8, {0.4, 0.6}, {2.0, 4.0})); + REQUIRE(report); + CHECK(report->computed_count() == 2); + CHECK(cache.get()[0].field_derivatives.get() == 0.2); + CHECK(cache.get()[1].field_derivatives.get() == 0.05); +} + +TEST_CASE("Direct pressure surface reuses EOS work when prescribed pressure changes", "[surface][unit]") { + GasEOS model; + auto cache = dependencies::make_computation_cache(physics::make_pressure_surface_graph(), gas_context(model, 0.8, {0.4, 0.6}, {1.0, 2.0})); + REQUIRE(cache.try_prepare(gas_context(model, 0.8, {0.4, 0.6}, {1.0, 2.0}))); + const auto report = cache.try_prepare(gas_context(model, 0.9, {0.4, 0.6}, {1.0, 2.0})); + REQUIRE(report); + CHECK(report->status() == Status::reused); + CHECK(report->status() == Status::computed); + CHECK(model.evaluations == 2); + CHECK(cache.get()[0].residual == 0.4 * 1.0 - 0.9); + CHECK(cache.get()[1].field_derivatives.get() == 2.0); + CHECK(cache.get()[1].field_derivatives.get() == 0.6); +} + +TEST_CASE("Surface cache requested results and moves retain the ordinary dependency lifecycle", "[surface][unit]") { + OffsetEOS model; + auto cache = dependencies::make_computation_cache(physics::make_pressure_surface_graph(), offset_context(model, 0.0, {0.5})); + const auto report = cache.try_prepare(offset_context(model, 0.0, {std::numeric_limits::quiet_NaN()}), dependencies::RequestedResults{}); + REQUIRE(report); + CHECK(report->computed_count() == 1); + CHECK_FALSE(cache.contains()); + CHECK_THROWS_AS(cache.get(), std::logic_error); + REQUIRE(cache.try_prepare(offset_context(model, 0.0, {0.5}))); + + auto moved = std::move(cache); + CHECK_FALSE(cache.ready()); + REQUIRE(moved.ready()); + CHECK(moved.get()[0].residual == 0.5 - 0.3); +} + +TEST_CASE("Surface batch with aliased quantities gathers each term once and sums every derivative", "[surface][unit]") { + using SharedState = dependencies::Bindings< + dependencies::Binding, + dependencies::Binding, + dependencies::Binding + >; + GeneralEOS model; + const auto context = dependencies::make_dependency_context( + dependencies::borrow(model, dependencies::track_by([](const GeneralEOS& eos) { + return eos.scale; + })), + physics::pressure_condition(physics::IsobaricSurface{quantities::PressureValue{0.4}}), + physics::boundary_values({0.2, 0.3}) + ); + auto cache = dependencies::make_computation_cache(physics::make_pressure_surface_graph(), context); + REQUIRE(cache.try_prepare(context)); + REQUIRE(cache.get().size() == 2); + CHECK(cache.get()[0].field_derivatives.get() == -7.0); + CHECK(cache.get()[1].field_derivatives.get() == -7.0); +} diff --git a/tests/surface/meson.build b/tests/surface/meson.build new file mode 100644 index 00000000..f7813c05 --- /dev/null +++ b/tests/surface/meson.build @@ -0,0 +1,8 @@ +surface_test_sources = files( + 'automatic.cpp', + 'boundary.cpp', + 'compilation.cpp', + 'computations.cpp', + 'relations.cpp', + 'rejection.cpp', +) diff --git a/tests/surface/rejection.cpp b/tests/surface/rejection.cpp new file mode 100644 index 00000000..23176df7 --- /dev/null +++ b/tests/surface/rejection.cpp @@ -0,0 +1,138 @@ +#include + +#include "serif/tests/surface/contexts.hpp" + +namespace { + using namespace serif; + using namespace serif::tests::surface; + using Linearization = physics::keys::Linearization; + + struct NonFinitePartialEOS : GeneralEOS { + using GeneralEOS::partial_derivative; + + /// An independently bad partial must reject the response even when the EOS value is finite. + [[nodiscard]] quantities::PartialDerivative partial_derivative(EnthalpyFromPressureAndComposition, quantities::WithRespectTo, quantities::PressureValue, quantities::EntropyValue, quantities::QuantityValue) const { + return quantities::PartialDerivative{std::numeric_limits::infinity()}; + } + }; +} + +TEST_CASE("Surface point validation rejects every required non-finite quantity before the EOS", "[surface][unit]") { + GeneralEOS model; + const physics::IsobaricSurface condition{quantities::PressureValue{0.4}}; + for (const double invalid : {std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity(), -std::numeric_limits::infinity()}) { + for (const auto& point : {general_point(invalid, 0.2, 0.1), general_point(1.7, invalid, 0.1), general_point(1.7, 0.2, invalid)}) { + const auto result = physics::evaluate_pressure_surface(model, condition, point); + REQUIRE_FALSE(result); + CHECK(result.error().reason == physics::SurfaceFailureReason::non_finite_state); + } + } + CHECK(model.evaluations == 0); +} + +TEST_CASE("Surface rejects non-finite EOS results and partials without clipping them", "[surface][unit]") { + const physics::IsobaricSurface condition{quantities::PressureValue{0.4}}; + SECTION("EOS value") { + const OffsetEOS model{.offset = std::numeric_limits::infinity()}; + using Formulation = physics::PressureSurfaceFormulation; + physics::SurfacePoint point{}; + point.fields.get() = 0.4; + const auto result = physics::evaluate_pressure_surface(model, condition, point); + REQUIRE_FALSE(result); + CHECK(result.error().reason == physics::SurfaceFailureReason::non_finite_eos_response); + } + SECTION("EOS partial") { + using Formulation = physics::PressureSurfaceFormulation; + const auto result = physics::evaluate_pressure_surface(NonFinitePartialEOS{}, condition, general_point(1.7, 0.2, 0.1)); + REQUIRE_FALSE(result); + CHECK(result.error().reason == physics::SurfaceFailureReason::non_finite_eos_response); + } + SECTION("Residual subtraction overflows after a finite EOS result") { + const OffsetEOS model{.offset = -std::numeric_limits::max()}; + using Formulation = physics::PressureSurfaceFormulation; + physics::SurfacePoint point{}; + point.fields.get() = std::numeric_limits::max(); + const auto result = physics::evaluate_pressure_surface(model, condition, point); + REQUIRE_FALSE(result); + CHECK(result.error().reason == physics::SurfaceFailureReason::non_finite_linearization); + } + SECTION("Finite negative carrier values retain their declared residual") { + using Formulation = physics::PressureSurfaceFormulation; + physics::SurfacePoint point{}; + point.fields.get() = -0.2; + const auto result = physics::evaluate_pressure_surface(OffsetEOS{}, condition, point); + REQUIRE(result); + CHECK(result->residual == -0.2 - (0.3 + 2.0 * 0.4)); + } +} + +TEST_CASE("A rejected surface batch hides all previous results and permits a valid retry", "[surface][unit]") { + GeneralEOS model; + auto cache = dependencies::make_computation_cache(physics::make_pressure_surface_graph(), general_context(model, 0.4, {1.7, 1.9}, {0.2, 0.3}, {0.1, 0.2})); + REQUIRE(cache.try_prepare(general_context(model, 0.4, {1.7, 1.9}, {0.2, 0.3}, {0.1, 0.2}))); + + auto bad_enthalpy = std::vector{1.7, 1.9}; + auto bad_entropy = std::vector{0.2, 0.3}; + SECTION("Carrier rejection after a reusable EOS response") { + bad_enthalpy[1] = std::numeric_limits::quiet_NaN(); + } + SECTION("EOS rejection after the first sample has been evaluated") { + bad_entropy[1] = std::numeric_limits::infinity(); + } + const auto rejected = cache.try_prepare(general_context(model, 0.4, bad_enthalpy, bad_entropy, {0.1, 0.2})); + REQUIRE_FALSE(rejected); + CHECK(rejected.error().error.sample == 1); + CHECK_FALSE(cache.ready()); + CHECK(cache.preparation_count() == 1); + CHECK_THROWS_AS(cache.get(), std::logic_error); + CHECK_THROWS_AS(cache.get(), std::logic_error); + + REQUIRE(cache.try_prepare(general_context(model, 0.4, {1.8, 2.0}, {0.2, 0.3}, {0.1, 0.2}))); + CHECK(cache.preparation_count() == 2); + REQUIRE(cache.get().size() == 2); + CHECK(cache.get()[0].residual == 1.8 - (2.0 * 0.4 + 3.0 * 0.2 + 5.0 * 0.1)); +} + +TEST_CASE("Surface graph rejects mismatched columns instead of broadcasting a state-dependent relation", "[surface][unit]") { + GeneralEOS model; + auto cache = dependencies::make_computation_cache(physics::make_pressure_surface_graph(), general_context(model, 0.4, {1.7, 1.9}, {0.2, 0.3}, {0.1, 0.2})); + + SECTION("EOS columns disagree") { + const auto result = cache.try_prepare(general_context(model, 0.4, {1.7, 1.9}, {0.2, 0.3}, {0.1})); + REQUIRE_FALSE(result); + CHECK(result.error().error.reason == physics::SurfaceFailureReason::inconsistent_sample_count); + CHECK(model.evaluations == 0); + } + SECTION("A singleton state-dependent response is not a constant") { + const auto result = cache.try_prepare(general_context(model, 0.4, {1.7, 1.9}, {0.2}, {0.1})); + REQUIRE_FALSE(result); + CHECK(result.error().error.reason == physics::SurfaceFailureReason::inconsistent_sample_count); + } + SECTION("Empty EOS columns") { + const auto result = cache.try_prepare(general_context(model, 0.4, {}, {}, {})); + REQUIRE_FALSE(result); + CHECK(result.error().error.reason == physics::SurfaceFailureReason::empty_surface); + } + CHECK_FALSE(cache.ready()); + REQUIRE(cache.try_prepare(general_context(model, 0.4, {1.7, 1.9}, {0.2, 0.3}, {0.1, 0.2}))); +} + +TEST_CASE("A pressure-only surface rejects an empty carrier batch", "[surface][unit]") { + OffsetEOS model; + auto cache = dependencies::make_computation_cache(physics::make_pressure_surface_graph(), offset_context(model, 0.0, {})); + const auto result = cache.try_prepare(offset_context(model, 0.0, {})); + REQUIRE_FALSE(result); + CHECK(result.error().error.reason == physics::SurfaceFailureReason::empty_surface); + CHECK_FALSE(cache.ready()); +} + +TEST_CASE("An existing EOS domain exception leaves the surface cache unavailable until recovery", "[surface][unit]") { + GasEOS model; + auto cache = dependencies::make_computation_cache(physics::make_pressure_surface_graph(), gas_context(model, 0.8, {0.4}, {1.0})); + REQUIRE(cache.try_prepare(gas_context(model, 0.8, {0.4}, {1.0}))); + CHECK_THROWS_AS(cache.try_prepare(gas_context(model, 0.8, {0.4}, {0.0})), eos::EOSEvaluationError); + CHECK_FALSE(cache.ready()); + CHECK_THROWS_AS(cache.get(), std::logic_error); + REQUIRE(cache.try_prepare(gas_context(model, 0.8, {0.4}, {2.0}))); + CHECK(cache.get()[0].residual == 0.0); +} diff --git a/tests/surface/relations.cpp b/tests/surface/relations.cpp new file mode 100644 index 00000000..30bd9a14 --- /dev/null +++ b/tests/surface/relations.cpp @@ -0,0 +1,124 @@ +#include +#include + +#include "serif/geometry/surface/evaluation.hpp" +#include "serif/tests/surface/contexts.hpp" + +namespace { + using namespace serif; + using namespace serif::tests::surface; + + using PressureLast = eos::relations::Relation; + + struct PressureLastEOS { + using Relations = eos::relations::RelationCatalog; + + /// Distinct coefficients detect an accidental permutation of pressure and state arguments. + [[nodiscard]] quantities::SpecificEnthalpyValue evaluate(PressureLast, const quantities::EntropyValue entropy, const quantities::PressureValue pressure) const { + return quantities::SpecificEnthalpyValue{3.0 * entropy.value() + 2.0 * pressure.value()}; + } + + /// Pressure is prescribed, so the only partial needed is the entropy derivative. + [[nodiscard]] quantities::PartialDerivative partial_derivative(PressureLast, quantities::WithRespectTo, quantities::EntropyValue, quantities::PressureValue) const { + return quantities::PartialDerivative{3.0}; + } + }; + + using CarrierInput = eos::relations::Relation; + + struct CarrierInputEOS { + using Relations = eos::relations::RelationCatalog; + + /// A manufactured implicit-carrier relation checks the identity contribution against a nonzero EOS carrier partial. + [[nodiscard]] quantities::DensityValue evaluate(CarrierInput, const quantities::PressureValue pressure, const quantities::DensityValue density) const { + return quantities::DensityValue{pressure.value() + 0.5 * density.value()}; + } + + /// The final residual's density partial must be 1 - 0.5. + [[nodiscard]] quantities::PartialDerivative partial_derivative(CarrierInput, quantities::WithRespectTo, quantities::PressureValue, quantities::DensityValue) const { + return quantities::PartialDerivative{0.5}; + } + }; + + struct ForwardOnlyEOS { + using Relations = eos::relations::RelationCatalog; + + /// This model exposes only pressure evaluation; there is no callable density inverse. + [[nodiscard]] quantities::PressureValue evaluate(PressureFromDensityAndTemperature, const quantities::DensityValue density, const quantities::TemperatureValue temperature) const { + return quantities::PressureValue{density.value() * temperature.value()}; + } + + /// The row's density derivative comes directly from the forward relation. + [[nodiscard]] quantities::PartialDerivative partial_derivative(PressureFromDensityAndTemperature, quantities::WithRespectTo, quantities::DensityValue, const quantities::TemperatureValue temperature) const { + return quantities::PartialDerivative{temperature.value()}; + } + + /// The temperature coupling is required even though density supplies the replaced row. + [[nodiscard]] quantities::PartialDerivative partial_derivative(PressureFromDensityAndTemperature, quantities::WithRespectTo, const quantities::DensityValue density, quantities::TemperatureValue) const { + return quantities::PartialDerivative{density.value()}; + } + }; +} + +TEST_CASE("Surface EOS arguments follow relation order even when pressure is last", "[surface][unit]") { + using State = dependencies::Bindings, dependencies::Binding>; + using Formulation = physics::PressureSurfaceFormulation; + physics::SurfacePoint point{}; + point.fields.get() = 1.7; + point.fields.get() = 0.2; + const auto result = physics::evaluate_pressure_surface(PressureLastEOS{}, physics::IsobaricSurface{quantities::PressureValue{0.4}}, point); + REQUIRE(result); + CHECK(result->residual == 1.7 - (3.0 * 0.2 + 2.0 * 0.4)); + CHECK(result->field_derivatives.get() == -3.0); +} + +TEST_CASE("A carrier appearing among EOS inputs retains both derivative contributions", "[surface][unit]") { + using State = dependencies::Bindings>; + using Formulation = physics::PressureSurfaceFormulation; + physics::SurfacePoint point{}; + point.fields.get() = 0.8; + const auto result = physics::evaluate_pressure_surface(CarrierInputEOS{}, physics::IsobaricSurface{quantities::PressureValue{0.4}}, point); + REQUIRE(result); + CHECK(result->residual == 0.0); + CHECK(result->field_derivatives.get() == 0.5); +} + +TEST_CASE("A forward-only EOS can impose the same isobaric condition without inversion", "[surface][unit]") { + using Formulation = physics::DirectPressureSurfaceFormulation; + const auto result = physics::evaluate_pressure_surface(ForwardOnlyEOS{}, physics::IsobaricSurface{quantities::PressureValue{0.8}}, gas_point(0.4, 2.0)); + REQUIRE(result); + CHECK(result->residual == 0.0); + CHECK(result->field_derivatives.get() == 2.0); + CHECK(result->field_derivatives.get() == 0.4); +} + +TEST_CASE("Pressure-only surface relations retain their analytic residual and derivative", "[surface][unit]") { + using Formulation = physics::PressureSurfaceFormulation; + const OffsetEOS model; + for (const double pressure : {0.0, 0.03125, 0.4}) { + const physics::IsobaricSurface condition{quantities::PressureValue{pressure}}; + for (const double enthalpy : {-0.2, 0.0, 0.7}) { + physics::SurfacePoint point{}; + point.fields.get() = enthalpy; + const auto actual = physics::evaluate_pressure_surface(model, condition, point); + REQUIRE(actual); + CHECK(actual->residual == enthalpy - (model.offset + 2.0 * pressure)); + CHECK(actual->field_derivatives.get() == 1.0); + CHECK(actual->position_derivative == discretization::forms::VectorValue{}); + } + } +} + +TEST_CASE("Equal EOS values cannot conceal changed surface derivatives", "[surface][unit]") { + GeneralEOS model; + auto cache = dependencies::make_computation_cache(physics::make_pressure_surface_graph(), general_context(model, 0.0, {0.4}, {0.0}, {0.0})); + REQUIRE(cache.try_prepare(general_context(model, 0.0, {0.4}, {0.0}, {0.0}))); + CHECK(cache.get()[0].value.value() == 0.0); + + model.scale = 2.0; + const auto report = cache.try_prepare(general_context(model, 0.0, {0.4}, {0.0}, {0.0})); + REQUIRE(report); + CHECK(report->computed_count() == 2); + CHECK(cache.get()[0].value.value() == 0.0); + CHECK(cache.get()[0].field_derivatives.get() == -6.0); +} diff --git a/tests/test_cuda_toolchain.py b/tests/test_cuda_toolchain.py deleted file mode 100644 index 14bca3b8..00000000 --- a/tests/test_cuda_toolchain.py +++ /dev/null @@ -1,88 +0,0 @@ -"""CUDA diagnostics and compiler propagation, without requiring a toolkit/GPU.""" -import os -from pathlib import Path -import subprocess -import sys -import tempfile -from types import SimpleNamespace -import unittest -from unittest.mock import patch - -sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'tools')) -from check_cuda_toolchain import check_toolchain -from build_mfem_bundle import mpich_cuda_architectures, prepare_hypre_source - - -class CudaToolchainTest(unittest.TestCase): - def test_probe_checks_both_compilers_without_running_gpu_code(self): - calls = [] - - def run(command, **kwargs): - calls.append(command) - if '--version' not in command: - self.assertNotIn('NVCC_APPEND_FLAGS', kwargs['env']) - self.assertNotIn('NVCC_CCBIN', kwargs['env']) - return subprocess.CompletedProcess(command, 0, 'CUDA test toolkit', '') - - with patch.dict(os.environ, {'NVCC_APPEND_FLAGS': '-allow-unsupported-compiler', - 'NVCC_CCBIN': 'wrong-host'}), patch('subprocess.run', side_effect=run): - check_toolchain('nvcc', 'gcc-compatible', 'g++-compatible') - self.assertEqual(len(calls), 3) - self.assertEqual(calls[1][2], 'gcc-compatible') - self.assertIn('-c', calls[1]) # A C driver need not link the C++ runtime. - self.assertEqual(calls[2][2], 'g++-compatible') - self.assertNotIn('-c', calls[2]) - - def test_mpich_architectures_follow_toolkit_and_user_selection(self): - self.assertEqual(mpich_cuda_architectures('86-real;90-virtual;86', 'nvcc'), '86,90') - self.assertEqual(mpich_cuda_architectures('native', 'nvcc'), 'auto') - with patch('build_mfem_bundle.output', return_value='sm_75\nsm_80\nsm_86\nsm_90'): - self.assertEqual(mpich_cuda_architectures('all-major', 'nvcc'), '75,80,90') - self.assertEqual(mpich_cuda_architectures('all', 'nvcc'), '75,80,86,90') - with self.assertRaisesRegex(RuntimeError, 'Invalid CUDA architecture'): - mpich_cuda_architectures('garbage', 'nvcc') - - def test_hypre_patch_preserves_archive_and_older_cuda_branch(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - original = root / 'original' - utilities = original / 'src' / 'utilities' - utilities.mkdir(parents=True) - header = ('#include \n' - '#define HYPRE_THRUST_NOT(pred) thrust::not1(pred)\n' - 'using namespace thrust::placeholders;\n') - for name in ('device_utils.h', '_hypre_utilities.hpp'): - (utilities / name).write_text(header) - memory = ''.join( - f'HYPRE_CUDA_CALL( cudaMemPrefetchAsync(ptr, size, {device},\n' - ' hypre_HandleComputeStream(hypre_handle())) );\n' - for device in ('hypre_HandleDevice(hypre_handle())', 'cudaCpuDeviceId')) - (utilities / 'memory.c').write_text(memory) - args = SimpleNamespace(hypre_source=original, work_dir=root / 'work') - self.assertEqual(prepare_hypre_source(args, {'cuda': False}), original) - patched = prepare_hypre_source(args, {'cuda': True}) - self.assertEqual((utilities / 'memory.c').read_text(), memory) - updated = (patched / 'src/utilities/memory.c').read_text() - self.assertIn('#if CUDART_VERSION >= 13000', updated) - self.assertIn('cudaMemLocationTypeHost, 0', updated) - self.assertIn('cudaMemPrefetchAsync(ptr, size, cudaCpuDeviceId,', updated) - self.assertIn('#define HYPRE_THRUST_NOT(pred) thrust::not1(pred)', - (patched / 'src/utilities/device_utils.h').read_text()) - prepare_hypre_source(args, {'cuda': True}) - self.assertEqual((patched / 'src/utilities/memory.c').read_text(), updated) - - def test_cxx_failure_reports_toolkit_host_and_recovery(self): - results = [subprocess.CompletedProcess([], 0, 'CUDA test toolkit', ''), - subprocess.CompletedProcess([], 0, '', ''), - subprocess.CompletedProcess([], 1, '', 'unsupported Microsoft Visual Studio version')] - with patch('subprocess.run', side_effect=results): - with self.assertRaises(RuntimeError) as caught: - check_toolchain('nvcc.exe', 'cl.exe', 'cl.exe') - message = str(caught.exception) - for expected in ['C++ compiler: cl.exe', 'CUDA test toolkit', - 'unsupported Microsoft Visual Studio version', 'fresh Meson build directory']: - self.assertIn(expected, message) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/utils/dependencies/context.cpp b/tests/utils/dependencies/context.cpp new file mode 100644 index 00000000..4892275a --- /dev/null +++ b/tests/utils/dependencies/context.cpp @@ -0,0 +1,103 @@ +#include "serif/utils/dependencies/dependencies.hpp" + +#include +#include +#include +#include + +namespace { + namespace dependencies = serif::utils::dependencies; + + struct Owned {}; + struct Borrowed {}; + struct Result {}; +} + +TEST_CASE("Dependency contexts distinguish ownership and borrowed roles", "[unit][utils][dependencies]") { + int value = 3; + auto context = dependencies::make_dependency_context(dependencies::own(value), dependencies::borrow(value)); + auto selection = context.select(dependencies::KeyList{}); + + value = 8; + CHECK(context.get() == 3); + CHECK(context.get() == 8); + CHECK(std::addressof(selection.get()) == std::addressof(value)); + + auto graph = dependencies::make_computation_graph(dependencies::make_computation>([](int input) { return 2 * input; })); + auto cache = dependencies::make_computation_cache(std::move(graph), selection); + REQUIRE(cache.try_prepare(selection)); + CHECK(cache.get() == 16); + + value = 9; + CHECK(cache.get() == 16); // An accepted result remains frozen until explicitly refreshed. + REQUIRE(cache.try_prepare(selection)); + CHECK(cache.get() == 18); +} + +TEST_CASE("Replacing a borrowed source invalidates its identity even at equal value", "[unit][utils][dependencies]") { + int original = 4; + int replacement = 4; + int calls = 0; + auto context = dependencies::make_dependency_context(dependencies::borrow(original)); + auto graph = dependencies::make_computation_graph(dependencies::make_computation>([&calls](int input) { ++calls; return input; })); + auto cache = dependencies::make_computation_cache(std::move(graph), context); + + REQUIRE(cache.try_prepare(context)); + context = dependencies::make_dependency_context(dependencies::borrow(replacement)); + auto report = cache.try_prepare(context); + REQUIRE(report); + CHECK(report->source_changed()); + CHECK(calls == 2); + + original = 7; + REQUIRE(cache.try_prepare(context)); + CHECK(calls == 2); + CHECK(cache.get() == 4); +} + +TEST_CASE("ComputationCache dependency results can move without leaving readable moved-from state", "[unit][utils][dependencies]") { + const auto context = dependencies::make_dependency_context(); + auto graph = dependencies::make_computation_graph(dependencies::make_computation>([coefficient = std::make_unique(7)] { return std::make_unique(*coefficient); })); + auto original = dependencies::make_computation_cache(std::move(graph), context); + REQUIRE(original.try_prepare(context)); + + auto moved = std::move(original); + CHECK_FALSE(original.ready()); + CHECK_FALSE(original.contains()); + CHECK_THROWS_AS(original.get(), std::logic_error); + REQUIRE(moved.contains()); + CHECK(*moved.get() == 7); + const auto report = moved.try_prepare(context); + REQUIRE(report); + CHECK(report->reused_count() == 1); +} + +TEST_CASE("Combining disjoint contexts preserves owned values borrowed identities and selected tracking policies", "[unit][utils][dependencies]") +{ + int value = 4; + const auto borrowed = dependencies::make_dependency_context(dependencies::borrow(value, dependencies::track_by([](const int input) + { + return input / 10; + }))); + const auto context = dependencies::combine_dependency_contexts( + dependencies::make_dependency_context(dependencies::own(3)), borrowed.select(dependencies::KeyList{})); + CHECK(context.get() == 3); + CHECK(&context.get() == &value); + STATIC_CHECK(std::same_as>); + + auto cache = dependencies::make_computation_cache(context, dependencies::make_computation>([](const int owned, const int input) + { + return owned + input / 10; + })); + REQUIRE(cache.try_prepare(context)); + CHECK(cache.get() == 3); + value = 8; + const auto unchanged = cache.try_prepare(context); + REQUIRE(unchanged); + CHECK(unchanged->reused_count() == 1); + value = 12; + const auto changed = cache.try_prepare(context); + REQUIRE(changed); + CHECK(changed->source_changed()); + CHECK(cache.get() == 4); +} diff --git a/tests/utils/dependencies/mixed_errors.cpp b/tests/utils/dependencies/mixed_errors.cpp new file mode 100644 index 00000000..8b749b6a --- /dev/null +++ b/tests/utils/dependencies/mixed_errors.cpp @@ -0,0 +1,189 @@ +#include +#include +#include +#include +#include + +#include + +#include "serif/utils/dependencies/computation_cache.hpp" + +namespace { + namespace dependencies = serif::utils::dependencies; + + struct Source {}; + struct CheckedInput {}; + struct PhysicsResult {}; + struct Output {}; + + /** @brief Preserve the original invalid argument without converting all failures into text. */ + struct InputFailure { + int value; + std::string message; + }; + + /** @brief Preserve downstream numerical diagnostics independently of input validation. */ + struct PhysicsFailure { + double value; + int sample; + std::string message; + }; + + /** @brief A cache error may own diagnostic data that cannot be copied. */ + struct OwnedFailure { + std::unique_ptr value; + }; +} + +TEST_CASE("Explicit error unions preserve each computation's structured failure and transactional recovery", "[unit][utils][dependencies]") { + using Failure = std::variant; + + int input = 2; + int input_calls = 0; + int physics_calls = 0; + int output_calls = 0; + const auto context = dependencies::make_dependency_context(dependencies::borrow(input)); + const auto checked = dependencies::make_computation>( + [&](int value) -> std::expected { + ++input_calls; + if (value < 0) { + return std::unexpected(InputFailure{.value = value, .message = "The input must be nonnegative."}); + } + return 2.0 * value; + } + ); + const auto physics = dependencies::make_computation>( + [&](double value) -> std::expected { + ++physics_calls; + if (value > 10.0) { + return std::unexpected(PhysicsFailure{.value = value, .sample = 3, .message = "The physical state exceeds the admissible range."}); + } + return value + 0.5; + } + ); + const auto output = dependencies::make_computation>( + [&](double value) { + ++output_calls; + return 3.0 * value; + } + ); + auto cache = dependencies::make_computation_cache(context, checked, physics, output); + REQUIRE(cache.try_prepare(context)); + CHECK(cache.get() == 13.5); + CHECK(cache.preparation_count() == 1); + + input = -1; + const auto rejected_input = cache.try_prepare(context); + REQUIRE_FALSE(rejected_input); + CHECK(rejected_input.error().computation_index == 0); + const InputFailure* input_failure = std::get_if(&rejected_input.error().error); + REQUIRE(input_failure != nullptr); + CHECK(input_failure->value == -1); + CHECK(input_failure->message == "The input must be nonnegative."); + CHECK_FALSE(cache.ready()); + CHECK(cache.preparation_count() == 1); + CHECK(input_calls == 2); + CHECK(physics_calls == 1); + CHECK(output_calls == 1); + CHECK_THROWS_AS(cache.get(), std::logic_error); + CHECK_THROWS_AS(cache.get(), std::logic_error); + CHECK_THROWS_AS(cache.get(), std::logic_error); + + input = 3; + const auto recovered_input = cache.try_prepare(context); + REQUIRE(recovered_input); + CHECK(recovered_input->computed_count() == 3); + CHECK(cache.get() == 19.5); + CHECK(cache.preparation_count() == 2); + + input = 6; + const auto rejected_physics = cache.try_prepare(context); + REQUIRE_FALSE(rejected_physics); + CHECK(rejected_physics.error().computation_index == 1); + const PhysicsFailure* physics_failure = std::get_if(&rejected_physics.error().error); + REQUIRE(physics_failure != nullptr); + CHECK(physics_failure->value == 12.0); + CHECK(physics_failure->sample == 3); + CHECK(physics_failure->message == "The physical state exceeds the admissible range."); + CHECK_FALSE(cache.ready()); + CHECK(cache.preparation_count() == 2); + CHECK(input_calls == 4); + CHECK(physics_calls == 3); + CHECK(output_calls == 2); + CHECK_THROWS_AS(cache.get(), std::logic_error); + CHECK_THROWS_AS(cache.get(), std::logic_error); + + // The rejected candidate must not replace the last accepted source or intermediate result. + input = 3; + const auto recovered_physics = cache.try_prepare(context); + REQUIRE(recovered_physics); + CHECK(recovered_physics->computed_count() == 0); + CHECK(cache.get() == 6.0); + CHECK(cache.get() == 6.5); + CHECK(cache.get() == 19.5); + CHECK(input_calls == 4); + CHECK(physics_calls == 3); + CHECK(output_calls == 2); +} + +TEST_CASE("Requested results avoid unrelated errors in a graph with an explicit failure union", "[unit][utils][dependencies]") { + using Failure = std::variant; + + int input = 7; + int physics_calls = 0; + const auto context = dependencies::make_dependency_context(dependencies::borrow(input)); + const auto checked = dependencies::make_computation>( + [](int value) -> std::expected { + return value; + } + ); + const auto physics = dependencies::make_computation>( + [&](int value) -> std::expected { + ++physics_calls; + return std::unexpected(PhysicsFailure{.value = static_cast(value), .sample = 2, .message = "This downstream result is deliberately unavailable."}); + } + ); + auto cache = dependencies::make_computation_cache(context, checked, physics); + const auto requested = cache.try_prepare(context, dependencies::RequestedResults{}); + REQUIRE(requested); + CHECK(requested->computed_count() == 1); + CHECK(requested->status() == dependencies::PreparationStatus::not_required); + CHECK(physics_calls == 0); + CHECK(cache.get() == 7); + CHECK_FALSE(cache.contains()); + + const auto rejected = cache.try_prepare(context); + REQUIRE_FALSE(rejected); + CHECK(std::holds_alternative(rejected.error().error)); + CHECK_FALSE(cache.ready()); + REQUIRE(cache.try_prepare(context, dependencies::RequestedResults{})); + CHECK(cache.get() == 7); + CHECK(physics_calls == 1); +} + +TEST_CASE("Explicit failure unions transfer move-only diagnostic payloads without copying", "[unit][utils][dependencies]") { + using Failure = std::variant; + + int input = -4; + const auto context = dependencies::make_dependency_context(dependencies::borrow(input)); + const auto computation = dependencies::make_computation>( + [](int value) -> std::expected { + if (value < 0) { + return std::unexpected(OwnedFailure{.value = std::make_unique(value)}); + } + return value; + } + ); + auto cache = dependencies::make_computation_cache(context, computation); + const auto rejected = cache.try_prepare(context); + REQUIRE_FALSE(rejected); + const OwnedFailure* failure = std::get_if(&rejected.error().error); + REQUIRE(failure != nullptr); + REQUIRE(failure->value != nullptr); + CHECK(*failure->value == -4); + CHECK_FALSE(cache.ready()); + input = 4; + REQUIRE(cache.try_prepare(context)); + CHECK(cache.get() == 4); + CHECK(*failure->value == -4); +} diff --git a/tests/utils/dependencies/observers.cpp b/tests/utils/dependencies/observers.cpp new file mode 100644 index 00000000..7eb4e4f8 --- /dev/null +++ b/tests/utils/dependencies/observers.cpp @@ -0,0 +1,378 @@ +#include "serif/utils/dependencies/dependencies.hpp" +#include "serif/utils/dependencies/timing.hpp" +#include "serif/utils/error/errors.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + namespace dependencies = serif::utils::dependencies; + using Outcome = dependencies::NodeOutcome; + + struct Source {}; + struct Offset {}; + struct Shared {}; + struct Total {}; + struct Unrequested {}; + + /// Simulate exceptional application failures through the library's error entry point. + void fail_observed_work() { + serif::utils::errors::serif_error("observed work failed"); + } +} + +TEST_CASE("Graph observers follow nested node scopes and declared input order only during execution", "[unit][utils][dependencies][observers]") { + int source = 2; + int offset = 3; + std::vector events; + events.reserve(128); + const auto context = dependencies::make_dependency_context(dependencies::borrow(source), dependencies::borrow(offset)); + auto graph = dependencies::make_computation_graph( + dependencies::make_computation>([&events](int shift, int value) { + events.push_back(70); + return value + shift; + }), + dependencies::make_computation>([&events](int value) { + events.push_back(30); + return 2 * value; + }), + dependencies::make_computation>([&events](int value) { + events.push_back(99); + return value; + }) + ); + auto observers = dependencies::make_observers( + dependencies::observe_node([&events] { + events.push_back(10); + return std::make_unique(40); + }, [&events](std::unique_ptr& state, Outcome outcome) noexcept { + events.push_back(*state); + CHECK(outcome == Outcome::completed); + }), + dependencies::observe_node([&events] { + events.push_back(11); + return 41; + }, [&events](int& state, Outcome outcome) noexcept { + events.push_back(state); + CHECK(outcome == Outcome::completed); + }), + dependencies::observe_node([&events] { + events.push_back(50); + return 80; + }, [&events](int& state, Outcome outcome) noexcept { + events.push_back(state); + CHECK(outcome == Outcome::completed); + }), + dependencies::observe_edge([&events](const int&) { events.push_back(61); }), + dependencies::observe_edge([&events](const int&) { events.push_back(60); }), + dependencies::observe_edge([&events](const int&) { events.push_back(20); }), + dependencies::observe_edge([&events](const int&) { events.push_back(21); }) + ); + auto cache = dependencies::make_computation_cache(std::move(graph), context, std::move(observers)); + REQUIRE(cache.try_prepare(context, dependencies::RequestedResults{})); + CHECK(events == std::vector{10, 11, 20, 21, 30, 41, 40, 50, 60, 61, 70, 80}); + CHECK(cache.get() == 7); + const auto first = cache.share(); + + events.clear(); + REQUIRE(cache.try_prepare(context, dependencies::RequestedResults{})); + REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<>{})); + CHECK(events.empty()); + + source = 4; + REQUIRE(cache.try_prepare(context, dependencies::RequestedResults{})); + CHECK(events == std::vector{10, 11, 20, 21, 30, 41, 40}); + CHECK_FALSE(cache.contains()); + CHECK(*first == 4); + events.clear(); + REQUIRE(cache.try_prepare(context, dependencies::RequestedResults{})); + CHECK(events == std::vector{50, 60, 61, 70, 80}); + CHECK(cache.get() == 11); + + events.clear(); + REQUIRE(cache.try_prepare(context, dependencies::RequestedResults{}, dependencies::PreparationMode::rebuild)); + CHECK(events == std::vector{10, 11, 20, 21, 30, 41, 40, 50, 60, 61, 70, 80}); + events.clear(); + cache.invalidate(); + REQUIRE(cache.try_prepare(context, dependencies::RequestedResults{})); + CHECK(events == std::vector{10, 11, 20, 21, 30, 41, 40}); +} + +TEST_CASE("Node outcomes describe attempted computation while cache publication remains transactional", "[unit][utils][dependencies][observers]") { + int source = 2; + std::vector> outcomes; + outcomes.reserve(32); + const auto context = dependencies::make_dependency_context(dependencies::borrow(source)); + auto graph = dependencies::make_computation_graph( + dependencies::make_computation>([](int value) { return value; }), + dependencies::make_computation>([](int value) -> std::expected { + if (value == -2) { + fail_observed_work(); + } + if (value < 0) { + return std::unexpected("negative input"); + } + return 2 * value; + }), + dependencies::make_computation>([](int value) { return value + 1; }) + ); + auto observers = dependencies::make_observers( + dependencies::observe_node([] { return 1; }, [&outcomes](int& state, Outcome outcome) noexcept { outcomes.emplace_back(state, outcome); }), + dependencies::observe_node([] { return 2; }, [&outcomes](int& state, Outcome outcome) noexcept { outcomes.emplace_back(state, outcome); }), + dependencies::observe_node([] { return 3; }, [&outcomes](int& state, Outcome outcome) noexcept { outcomes.emplace_back(state, outcome); }) + ); + auto cache = dependencies::make_computation_cache(std::move(graph), context, std::move(observers)); + REQUIRE(cache.try_prepare(context)); + const auto accepted = cache.share(); + CHECK(outcomes == std::vector>{{1, Outcome::completed}, {2, Outcome::completed}, {3, Outcome::completed}}); + outcomes.clear(); + + SECTION("A typed rejection completes earlier nodes but does not publish them") { + source = -1; + const auto rejected = cache.try_prepare(context); + REQUIRE_FALSE(rejected); + CHECK(rejected.error().error == "negative input"); + CHECK(outcomes == std::vector>{{1, Outcome::completed}, {2, Outcome::rejected}}); + } + SECTION("An escaping exception closes the active node and propagates") { + source = -2; + CHECK_THROWS_AS(cache.try_prepare(context), std::runtime_error); + CHECK(outcomes == std::vector>{{1, Outcome::completed}, {2, Outcome::exception}}); + } + CHECK_FALSE(cache.ready()); + CHECK_FALSE(cache.contains()); + CHECK(*accepted == 2); + + outcomes.clear(); + source = 2; + const auto recovered = cache.try_prepare(context); + REQUIRE(recovered); + CHECK(recovered->reused_count() == 3); + CHECK(cache.share() == accepted); + CHECK(outcomes.empty()); +} + +TEST_CASE("Observer exceptions close only scopes whose before callback completed", "[unit][utils][dependencies][observers]") { + bool fail_before = false; + int computations = 0; + std::vector events; + events.reserve(16); + const auto context = dependencies::make_dependency_context(dependencies::own(2)); + auto graph = dependencies::make_computation_graph( + dependencies::make_computation>([&computations](int value) { + ++computations; + return value; + }) + ); + auto observers = dependencies::make_observers( + dependencies::observe_node([&events] { + events.push_back(1); + return std::make_unique(-1); + }, [&events](std::unique_ptr& state, Outcome outcome) noexcept { + events.push_back(*state); + CHECK(outcome == Outcome::exception); + }), + dependencies::observe_node([&events, &fail_before] { + events.push_back(2); + if (fail_before) { + fail_observed_work(); + } + return -2; + }, [&events](int& state, Outcome outcome) noexcept { + events.push_back(state); + CHECK(outcome == Outcome::exception); + }), + dependencies::observe_edge([&events](const int&) { + events.push_back(3); + fail_observed_work(); + }) + ); + auto cache = dependencies::make_computation_cache(std::move(graph), context, std::move(observers)); + SECTION("A failed before callback has no matching after callback") { + fail_before = true; + CHECK_THROWS_AS(cache.try_prepare(context), std::runtime_error); + CHECK(events == std::vector{1, 2, -1}); + } + SECTION("An edge failure unwinds every entered node scope") { + CHECK_THROWS_AS(cache.try_prepare(context), std::runtime_error); + CHECK(events == std::vector{1, 2, 3, -2, -1}); + } + CHECK(computations == 0); + CHECK_FALSE(cache.ready()); +} + +TEST_CASE("Observers follow moved caches and read owning edges without copying move-only results", "[unit][utils][dependencies][observers]") { + using Value = std::unique_ptr; + using Inputs = dependencies::InputList; + using Snapshot = std::shared_ptr; + int source = 4; + const Value* observed = nullptr; + std::vector> outcomes; + outcomes.reserve(16); + const auto context = dependencies::make_dependency_context(dependencies::borrow(source)); + auto graph = dependencies::make_computation_graph( + dependencies::make_computation([](int value) -> std::expected { + if (value < 0) { + return std::unexpected("negative input"); + } + return std::make_unique(value); + }), + dependencies::make_owning_computation>([](Snapshot value) { return value; }) + ); + auto observers = dependencies::make_observers( + dependencies::observe_node([calls = std::make_unique(0)] { return ++*calls; }, + [&outcomes](int& invocation, Outcome outcome) noexcept { outcomes.emplace_back(invocation, outcome); }), + dependencies::observe_edge([&observed](const Value& value) { observed = &value; }) + ); + auto cache = dependencies::make_computation_cache(std::move(graph), context, std::move(observers)); + REQUIRE(cache.try_prepare(context)); + const Snapshot first = cache.share(); + CHECK(observed == first.get()); + CHECK(cache.get() == first); + REQUIRE(cache.try_prepare(context)); + CHECK(outcomes == std::vector>{{1, Outcome::completed}}); + + auto moved = std::move(cache); + CHECK_FALSE(cache.ready()); + source = 9; + REQUIRE(moved.try_prepare(context)); + const Snapshot accepted = moved.share(); + CHECK(observed == accepted.get()); + CHECK(**first == 4); + CHECK(**accepted == 9); + + source = -1; + REQUIRE_FALSE(moved.try_prepare(context)); + CHECK(observed == accepted.get()); // The owning consumer and its edge never ran for the rejected candidate. + source = 9; + REQUIRE(moved.try_prepare(context)); + CHECK(moved.share() == accepted); + REQUIRE(moved.try_prepare(context, dependencies::RequestedResults{}, dependencies::PreparationMode::rebuild)); + CHECK(observed == moved.share().get()); + CHECK(outcomes == std::vector>{{1, Outcome::completed}, {2, Outcome::completed}, {3, Outcome::rejected}, {4, Outcome::completed}}); +} + +TEST_CASE("A generic node observer composes with selected scopes without observing cache hits", "[unit][utils][dependencies][observers][timing]") { + int source = 3; + std::vector events; + events.reserve(32); + const auto context = dependencies::make_dependency_context(dependencies::borrow(source)); + auto graph = dependencies::make_computation_graph( + dependencies::make_computation>([](int value) { return value * 2; }), + dependencies::make_computation>([](int value) { return value + 1; })); + auto observers = dependencies::make_observers( + dependencies::observe_nodes([&events](std::type_identity) { + const int identity = std::same_as ? 1 : 2; + events.push_back(identity); + return identity; + }, [&events](int& identity, Outcome outcome) noexcept { + CHECK(outcome == Outcome::completed); + events.push_back(-identity); + }), + dependencies::observe_node([&events] { events.push_back(3); return 3; }, + [&events](int& identity, Outcome) noexcept { events.push_back(-identity); })); + auto cache = dependencies::make_computation_cache(std::move(graph), context, std::move(observers)); + REQUIRE(cache.try_prepare(context)); + CHECK(events == std::vector{1, 3, -3, -1, 2, -2}); + CHECK(cache.get() == 7); + events.clear(); + REQUIRE(cache.try_prepare(context)); + REQUIRE(cache.try_prepare(context, dependencies::RequestedResults<>{})); + CHECK(events.empty()); +} + +TEST_CASE("Graph timing counts attempted work and preserves rollback with a deterministic clock", "[unit][utils][dependencies][observers][timing]") { + using TimePoint = std::chrono::steady_clock::time_point; + TimePoint now{}; + int clock_reads = 0; + int source = 2; + const auto context = dependencies::make_dependency_context(dependencies::borrow(source)); + auto graph = dependencies::make_computation_graph( + dependencies::make_computation>([&now](int value) -> std::expected { + now += std::chrono::seconds{3}; + if (value == -2) { fail_observed_work(); } + if (value < 0) { return std::unexpected("negative input"); } + return 2 * value; + }), + dependencies::make_computation>([&now](int value) { + now += std::chrono::seconds{2}; + return value; + }), + dependencies::make_computation>([&now](int value) { + now += std::chrono::seconds{7}; + return value; + })); + dependencies::GraphTiming timing; + const auto factory = dependencies::time_graph_nodes(timing, [&]() noexcept { ++clock_reads; return now; }); + auto observers = factory(std::type_identity{}); + auto cache = dependencies::make_computation_cache(std::move(graph), context, std::move(observers)); + REQUIRE(timing.runs().size() == 1); + const auto& nodes = timing.runs()[0]->nodes; + REQUIRE(nodes.size() == 3); + CHECK(nodes[0].index == 0); + CHECK(nodes[1].index == 1); + CHECK(nodes[0].key_name != nodes[1].key_name); + REQUIRE(cache.try_prepare(context, dependencies::RequestedResults{})); + const auto accepted = cache.share(); + CHECK(nodes[0].seconds == 3.0); + CHECK(nodes[1].seconds == 2.0); + CHECK(nodes[0].calls == 1); + CHECK(nodes[0].completed == 1); + CHECK(nodes[2].calls == 0); + REQUIRE(cache.try_prepare(context, dependencies::RequestedResults{})); + CHECK(clock_reads == 4); + + source = -1; + const auto rejected = cache.try_prepare(context, dependencies::RequestedResults{}); + REQUIRE_FALSE(rejected); + CHECK(rejected.error().computation_index == 0); + CHECK(nodes[0].rejected == 1); + CHECK(nodes[1].completed == 2); + CHECK(*accepted == 2); + source = 2; + REQUIRE(cache.try_prepare(context, dependencies::RequestedResults{})); + CHECK(clock_reads == 8); + CHECK(cache.share() == accepted); + + auto moved = std::move(cache); + source = -2; + CHECK_THROWS_AS(moved.try_prepare(context, dependencies::RequestedResults{}), std::runtime_error); + CHECK(nodes[0].exceptions == 1); + CHECK(nodes[0].calls == 3); + CHECK(nodes[0].seconds == 9.0); + CHECK(nodes[0].maximum_seconds == 3.0); + CHECK(nodes[1].completed == 3); + CHECK(nodes[2].calls == 0); + CHECK(clock_reads == 12); + + source = 2; + REQUIRE(moved.try_prepare(context, dependencies::RequestedResults{})); + CHECK(clock_reads == 12); + REQUIRE(moved.try_prepare(context, dependencies::RequestedResults{})); + CHECK(nodes[2].calls == 1); + CHECK(nodes[2].seconds == 7.0); + CHECK(clock_reads == 14); +} + +TEST_CASE("Timing observers own their records after the collector is released", "[unit][utils][dependencies][observers][timing]") { + const auto context = dependencies::make_dependency_context(); + auto graph = dependencies::make_computation_graph(dependencies::make_computation>([] { return 7; })); + std::shared_ptr run; + auto observers = [&] { + dependencies::GraphTiming timing; + auto result = dependencies::time_graph_nodes(timing)(std::type_identity{}); + run = timing.runs()[0]; + return result; + }(); + auto cache = dependencies::make_computation_cache(std::move(graph), context, std::move(observers)); + REQUIRE(cache.try_prepare(context)); + CHECK(cache.get() == 7); + CHECK(run->nodes[0].calls == 1); + CHECK(run->nodes[0].completed == 1); + CHECK(run->nodes[0].seconds >= 0.0); +} diff --git a/tests/utils/dependencies/ownership.cpp b/tests/utils/dependencies/ownership.cpp new file mode 100644 index 00000000..77ca6332 --- /dev/null +++ b/tests/utils/dependencies/ownership.cpp @@ -0,0 +1,203 @@ +#include +#include +#include +#include + +#include +#include "serif/utils/dependencies/dependencies.hpp" + +namespace +{ + namespace dependencies = serif::utils::dependencies; + struct Source + { + }; + struct Result + { + }; + struct OtherResult + { + }; + struct FinalResult + { + }; +} // namespace + +TEST_CASE("Shared dependency results survive refresh rejection invalidation and cache destruction", "[unit][utils][dependencies]") +{ + std::shared_ptr> first; + std::shared_ptr> second; + std::weak_ptr> released; + { + int value = 4; + auto context = dependencies::make_dependency_context(dependencies::borrow(value)); + auto graph = dependencies::make_computation_graph(dependencies::make_computation>( + [](const int& input) -> std::expected, std::string> + { + if (input < 0) + { + return std::unexpected("negative trial"); + } + return std::vector{input, input + 1}; + })); + auto cache = dependencies::make_computation_cache(std::move(graph), context); + STATIC_CHECK(std::same_as()), std::shared_ptr>>); + CHECK_THROWS(cache.share()); + REQUIRE(cache.try_prepare(context)); + first = cache.share(); + CHECK((*first == std::vector{4, 5})); + REQUIRE(cache.try_prepare(context)); + CHECK(cache.share() == first); + value = 9; + REQUIRE(cache.try_prepare(context)); + second = cache.share(); + CHECK(second != first); + CHECK((*first == std::vector{4, 5})); + CHECK((*second == std::vector{9, 10})); + value = -1; + CHECK_FALSE(cache.try_prepare(context)); + CHECK_FALSE(cache.ready()); + CHECK_THROWS(cache.share()); + CHECK((*first == std::vector{4, 5})); + CHECK((*second == std::vector{9, 10})); + value = 9; + REQUIRE(cache.try_prepare(context)); + CHECK(cache.share() == second); + cache.invalidate(); + CHECK_THROWS(cache.share()); + CHECK((*second == std::vector{9, 10})); + REQUIRE(cache.try_prepare(context)); + released = cache.share(); + CHECK_FALSE(released.expired()); + } + CHECK(released.expired()); + CHECK((*first == std::vector{4, 5})); + CHECK((*second == std::vector{9, 10})); + released = first; + first.reset(); + CHECK(released.expired()); +} + +TEST_CASE("Only currently requested dependency results can be shared while retained snapshots stay valid", "[unit][utils][dependencies]") +{ + const auto context = dependencies::make_dependency_context(dependencies::own(3)); + auto graph = dependencies::make_computation_graph( + dependencies::make_computation>([](const int& value) + { + return value; + }), + dependencies::make_computation>([](const int& value) + { + return 2 * value; + })); + auto cache = dependencies::make_computation_cache(std::move(graph), context); + REQUIRE(cache.try_prepare(context)); + const auto previous = cache.share(); + REQUIRE(cache.try_prepare(context, dependencies::KeyList{})); + CHECK_THROWS(cache.share()); + CHECK(*previous == 6); + CHECK(*cache.share() == 3); + auto moved = std::move(cache); + CHECK_THROWS(cache.share()); + CHECK(*moved.share() == 3); + CHECK(*previous == 6); +} + +TEST_CASE("Owning computations retain the exact move-only prerequisite across cache generations and owner lifetimes", "[unit][utils][dependencies]") +{ + using Snapshot = std::shared_ptr>; + Snapshot first; + Snapshot second; + std::weak_ptr> unretained; + { + int value = 4; + auto context = dependencies::make_dependency_context(dependencies::borrow(value)); + auto graph = dependencies::make_computation_graph( + dependencies::make_owning_computation>([](Snapshot prerequisite) + { + return prerequisite; + }), + dependencies::make_computation>([](const int input) + { + return std::make_unique(input); + }), + dependencies::make_computation>([](const Snapshot& input) + { + return **input + 1; + })); + auto cache = dependencies::make_computation_cache(std::move(graph), context); + REQUIRE(cache.try_prepare(context)); + first = cache.get(); + CHECK(first == cache.share()); + CHECK(cache.get() == 5); + + const auto reused = cache.try_prepare(context); + REQUIRE(reused); + CHECK(reused->reused_count() == 3); + CHECK(cache.get() == first); + + value = 9; + REQUIRE(cache.try_prepare(context)); + second = cache.get(); + CHECK(second == cache.share()); + CHECK(second != first); + CHECK(**first == 4); + CHECK(**second == 9); + CHECK(cache.get() == 10); + + cache.invalidate(); + CHECK(**first == 4); + CHECK(**second == 9); + REQUIRE(cache.try_prepare(context)); + unretained = cache.share(); + CHECK_FALSE(unretained.expired()); + } + CHECK(unretained.expired()); + CHECK(**first == 4); + CHECK(**second == 9); +} + +TEST_CASE("Rejected owning computations release candidate prerequisites and preserve the accepted generation", "[unit][utils][dependencies]") +{ + using Snapshot = std::shared_ptr>; + int value = 4; + std::weak_ptr> rejected; + auto context = dependencies::make_dependency_context(dependencies::borrow(value)); + auto graph = dependencies::make_computation_graph( + dependencies::make_computation>([](const int input) + { + return std::make_unique(input); + }), + dependencies::make_owning_computation>([&rejected](Snapshot prerequisite) -> std::expected + { + if (**prerequisite < 0) + { + rejected = prerequisite; + return std::unexpected("negative candidate"); + } + return prerequisite; + })); + auto cache = dependencies::make_computation_cache(std::move(graph), context); + REQUIRE(cache.try_prepare(context)); + const Snapshot accepted = cache.get(); + + value = -1; + const auto failure = cache.try_prepare(context); + REQUIRE_FALSE(failure); + CHECK(failure.error().error == "negative candidate"); + CHECK(rejected.expired()); + CHECK_FALSE(cache.ready()); + CHECK(**accepted == 4); + + value = 4; + const auto recovered = cache.try_prepare(context); + REQUIRE(recovered); + CHECK(recovered->reused_count() == 2); + CHECK(cache.get() == accepted); + + const auto rebuilt = cache.try_prepare(context, dependencies::KeyList{}, dependencies::PreparationMode::rebuild); + REQUIRE(rebuilt); + CHECK(rebuilt->computed_count() == 2); + CHECK(cache.get() != accepted); + CHECK(**accepted == 4); +} diff --git a/tests/utils/dependencies/preparation.cpp b/tests/utils/dependencies/preparation.cpp new file mode 100644 index 00000000..e78f293b --- /dev/null +++ b/tests/utils/dependencies/preparation.cpp @@ -0,0 +1,260 @@ +#include "serif/utils/dependencies/dependencies.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + namespace dependencies = serif::utils::dependencies; + + struct Source {}; + struct OtherSource {}; + struct Shared {}; + struct Left {}; + struct Right {}; + struct Total {}; +} + +TEST_CASE("Named computations build a cache directly with general and custom keys", "[unit][utils][dependencies]") { + namespace keys = dependencies::keys; + struct Offset { double unused; }; + + std::array a{1.0, 2.0, 3.0}; + double scale = 2.0; + double offset = 0.5; + std::array calls{}; + const auto context = dependencies::make_dependency_context( + dependencies::borrow(a), dependencies::borrow(scale), dependencies::borrow(offset) + ); + const auto multiply = dependencies::make_computation>( + [&calls](const std::array& input, double factor) { + ++calls[0]; + return std::array{factor * input[0], factor * input[1], factor * input[2]}; + } + ); + const auto sum = dependencies::make_computation>( + [&calls](const std::array& input, double shift) { + ++calls[1]; + return input[0] + input[1] + input[2] + shift; + } + ); + + // No explicit graph or ownership transfer is needed; prerequisites may be supplied last. + auto cache = dependencies::make_computation_cache(context, sum, multiply); + CHECK_FALSE(cache.ready()); + CHECK(calls == std::array{0, 0}); + auto report = cache.try_prepare(context, dependencies::RequestedResults{}); + REQUIRE(report); + CHECK(report->computed_count() == 2); + CHECK(cache.get() == std::array{2.0, 4.0, 6.0}); + CHECK(cache.get() == 12.5); + CHECK(calls == std::array{1, 1}); + + report = cache.try_prepare(context); + REQUIRE(report); + CHECK(report->reused_count() == 2); + CHECK(calls == std::array{1, 1}); + + offset = 1.5; + report = cache.try_prepare(context); + REQUIRE(report); + CHECK(report->status() == dependencies::PreparationStatus::reused); + CHECK(report->status() == dependencies::PreparationStatus::computed); + CHECK(cache.get() == 13.5); + CHECK(calls == std::array{1, 2}); + + scale = 3.0; + report = cache.try_prepare(context); + REQUIRE(report); + CHECK(report->computed_count() == 2); + CHECK(cache.get() == 19.5); + CHECK(calls == std::array{2, 3}); +} + +TEST_CASE("Direct cache construction transfers computations owning noncopyable state", "[unit][utils][dependencies]") { + const auto context = dependencies::make_dependency_context(dependencies::own(4)); + auto computation = dependencies::make_computation>( + [offset = std::make_unique(3)](int input) { return input + *offset; } + ); + auto cache = dependencies::make_computation_cache(context, std::move(computation)); + REQUIRE(cache.try_prepare(context)); + CHECK(cache.get() == 7); + const auto report = cache.try_prepare(context); + REQUIRE(report); + CHECK(report->status() == dependencies::PreparationStatus::reused); +} + +TEST_CASE("A dependency diamond prepares shared work once in prerequisite order", "[unit][utils][dependencies]") { + int source = 2; + std::array calls{}; + auto context = dependencies::make_dependency_context(dependencies::borrow(source)); + auto graph = dependencies::make_computation_graph( + dependencies::make_computation>([&calls](int left, int right) { ++calls[3]; return left + right; }), + dependencies::make_computation>([&calls](int shared) { ++calls[2]; return 3 * shared; }), + dependencies::make_computation>([&calls](int input) { ++calls[0]; return 2 * input; }), + dependencies::make_computation>([&calls](int shared) { ++calls[1]; return shared + 1; }) + ); + auto cache = dependencies::make_computation_cache(graph, context); + CHECK_FALSE(cache.ready()); + CHECK_THROWS_AS(cache.get(), std::logic_error); + + auto report = cache.try_prepare(context, dependencies::RequestedResults{}); + REQUIRE(report); + CHECK(report->computed_count() == 4); + CHECK(report->source_changed()); + CHECK(cache.get() == 17); + CHECK(calls == std::array{1, 1, 1, 1}); + + report = cache.try_prepare(context); + REQUIRE(report); + CHECK(report->reused_count() == 4); + CHECK_FALSE(report->source_changed()); + CHECK(calls == std::array{1, 1, 1, 1}); + + source = 3; + report = cache.try_prepare(context); + REQUIRE(report); + CHECK(report->computed_count() == 4); + CHECK(cache.get() == 25); + CHECK(calls == std::array{2, 2, 2, 2}); + + report = cache.try_prepare(context, dependencies::RequestedResults{}, dependencies::PreparationMode::rebuild); + REQUIRE(report); + CHECK(report->computed_count() == 4); + CHECK(calls == std::array{3, 3, 3, 3}); + + cache.invalidate(); + CHECK_FALSE(cache.ready()); + CHECK_THROWS_AS(cache.get(), std::logic_error); + report = cache.try_prepare(context); + REQUIRE(report); + CHECK(report->computed_count() == 4); + CHECK(calls == std::array{4, 4, 4, 4}); +} + +TEST_CASE("Partial dependency requests retain caches and propagate later changes", "[unit][utils][dependencies]") { + int source = 2; + int other_source = 3; + std::array calls{}; + auto context = dependencies::make_dependency_context(dependencies::borrow(source), dependencies::borrow(other_source)); + auto graph = dependencies::make_computation_graph( + dependencies::make_computation>([&calls](int left, int right) { ++calls[2]; return left + right; }), + dependencies::make_computation>([&calls](int input) { ++calls[0]; return 2 * input; }), + dependencies::make_computation>([&calls](int input) { ++calls[1]; return 2 * input; }) + ); + auto cache = dependencies::make_computation_cache(std::move(graph), context); + REQUIRE(cache.try_prepare(context)); + CHECK(cache.get() == 10); + + source = 4; + auto report = cache.try_prepare(context, dependencies::RequestedResults{}); + REQUIRE(report); + CHECK(calls == std::array{2, 1, 1}); + CHECK(report->status() == dependencies::PreparationStatus::not_required); + CHECK_FALSE(cache.contains()); + CHECK_THROWS_AS(cache.get(), std::logic_error); + + report = cache.try_prepare(context, dependencies::RequestedResults{}); + REQUIRE(report); + CHECK(cache.get() == 14); + CHECK(report->computed_count() == 1); + CHECK(report->reused_count() == 2); + CHECK(calls == std::array{2, 1, 2}); + + other_source = 5; + report = cache.try_prepare(context, dependencies::RequestedResults{}); + REQUIRE(report); + CHECK_FALSE(report->source_changed()); // This source was outside the request and was not inspected. + CHECK(report->reused_count() == 1); + report = cache.try_prepare(context, dependencies::RequestedResults{}); + REQUIRE(report); + CHECK(report->source_changed()); + CHECK(cache.get() == 10); + + report = cache.try_prepare(context); + REQUIRE(report); + CHECK(report->computed_count() == 1); + CHECK(cache.get() == 18); + CHECK(calls == std::array{2, 2, 3}); + + report = cache.try_prepare(context, dependencies::RequestedResults<>{}); + REQUIRE(report); + CHECK(cache.ready()); + CHECK_FALSE(cache.contains()); + CHECK(report->computed_count() == 0); + CHECK(report->reused_count() == 0); +} + +TEST_CASE("Empty dependency graphs and constant calculations need no sources", "[unit][utils][dependencies]") { + const auto context = dependencies::make_dependency_context(); + auto empty = dependencies::make_computation_cache(dependencies::make_computation_graph(), context); + const auto empty_report = empty.try_prepare(context); + REQUIRE(empty_report); + CHECK(empty.ready()); + CHECK(empty_report->computed_count() == 0); + + int calls = 0; + auto graph = dependencies::make_computation_graph(dependencies::make_computation>([&calls] { ++calls; return 42; })); + auto cache = dependencies::make_computation_cache(std::move(graph), context); + REQUIRE(cache.try_prepare(context)); + REQUIRE(cache.try_prepare(context)); + CHECK(calls == 1); + CHECK(cache.get() == 42); + REQUIRE(cache.try_prepare(context, dependencies::RequestedResults{}, dependencies::PreparationMode::rebuild)); + CHECK(calls == 2); +} + +TEST_CASE("Move-only results retain accepted generations across rebuilds and rejection", "[unit][utils][dependencies]") { + using Value = std::unique_ptr; + int source = 4; + int calls = 0; + const auto context = dependencies::make_dependency_context(dependencies::borrow(source)); + const auto computation = dependencies::make_computation>( + [&calls](int input) -> std::expected { + ++calls; + if (input < 0) { + return std::unexpected("negative input"); + } + return std::make_unique(input); + } + ); + auto cache = dependencies::make_computation_cache(context, computation); + + REQUIRE(cache.try_prepare(context)); + const auto first = cache.share(); + CHECK(**first == 4); + const auto reused = cache.try_prepare(context); + REQUIRE(reused); + CHECK(reused->status() == dependencies::PreparationStatus::reused); + CHECK(cache.share() == first); + CHECK(calls == 1); + + const auto forced = cache.try_prepare(context, dependencies::RequestedResults{}, dependencies::PreparationMode::rebuild); + REQUIRE(forced); + CHECK(forced->status() == dependencies::PreparationStatus::computed); + const auto accepted = cache.share(); + CHECK(accepted != first); + CHECK(calls == 2); + CHECK(**accepted == 4); + CHECK(**first == 4); + + source = -1; + const auto rejected = cache.try_prepare(context); + REQUIRE_FALSE(rejected); + CHECK(rejected.error().error == "negative input"); + CHECK_FALSE(cache.contains()); + CHECK(calls == 3); + CHECK(**accepted == 4); + CHECK(**first == 4); + + source = 4; + const auto recovered = cache.try_prepare(context); + REQUIRE(recovered); + CHECK(recovered->status() == dependencies::PreparationStatus::reused); + CHECK(cache.share() == accepted); + CHECK(calls == 3); +} diff --git a/tests/utils/dependencies/reachability.cpp b/tests/utils/dependencies/reachability.cpp new file mode 100644 index 00000000..7efd8509 --- /dev/null +++ b/tests/utils/dependencies/reachability.cpp @@ -0,0 +1,62 @@ +#include +#include + +#include "serif/utils/dependencies/dependencies.hpp" + +namespace +{ + namespace dependencies = serif::utils::dependencies; + struct Source + { + }; + template + struct Stage + { + }; +} + +TEST_CASE("Requested dependency branches follow topology independently of declaration order", "[unit][utils][dependencies]") +{ + int input = 2; + const auto context = dependencies::make_dependency_context(dependencies::borrow(input)); + std::array calls{}; + const auto branch = [&]() + { + return dependencies::make_computation, dependencies::InputList>>([&](const int value) + { + ++calls[Result]; + return value + Result; + }); + }; + const auto graph = dependencies::make_computation_graph( + branch.template operator()<4, 2>(), + dependencies::make_computation, dependencies::InputList, Stage<5>>>([&](const int left, const int right) + { + ++calls[6]; + return left + right; + }), + branch.template operator()<2, 1>(), + branch.template operator()<7, 3>(), + branch.template operator()<5, 3>(), + branch.template operator()<1, 0>(), + dependencies::make_computation, dependencies::InputList>([&](const int value) + { + ++calls[0]; + return value; + }), + branch.template operator()<3, 1>()); + auto cache = dependencies::make_computation_cache(graph, context); + REQUIRE(cache.try_prepare(context, dependencies::RequestedResults>{})); + CHECK(cache.get>() == 9); + CHECK(calls == std::array{1, 1, 1, 0, 1, 0, 0, 0}); + REQUIRE(cache.try_prepare(context, dependencies::RequestedResults>{})); + CHECK(cache.get>() == 20); + CHECK(calls == std::array{1, 1, 1, 1, 1, 1, 1, 0}); + input = 5; + REQUIRE(cache.try_prepare(context, dependencies::RequestedResults, Stage<7>>{})); + CHECK(cache.get>() == 12); + CHECK(cache.get>() == 16); + CHECK(calls == std::array{2, 2, 2, 2, 2, 1, 1, 1}); + CHECK(cache.report().status>() == dependencies::PreparationStatus::not_required); + CHECK(cache.report().status>() == dependencies::PreparationStatus::not_required); +} diff --git a/tests/utils/dependencies/rejection.cpp b/tests/utils/dependencies/rejection.cpp new file mode 100644 index 00000000..fc9ef718 --- /dev/null +++ b/tests/utils/dependencies/rejection.cpp @@ -0,0 +1,193 @@ +#include "serif/utils/error/errors.hpp" +#include "serif/utils/dependencies/dependencies.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + namespace dependencies = serif::utils::dependencies; + + struct Source {}; + struct Intermediate {}; + struct Result {}; + + struct ThrowingSnapshot { + /// Simulate a policy failing while it tries to capture replacement state. + int capture(const int& value) const { + if (value < 0) { + serif::utils::errors::serif_error("source snapshot unavailable"); + } + return value; + } + + /// Compare only successfully captured states. + bool matches(const int& captured, const int& value) const { return captured == value; } + }; +} + +TEST_CASE("Rejected dependency preparation publishes neither candidate results nor snapshots", "[unit][utils][dependencies]") { + int source = 2; + int intermediate_calls = 0; + int result_calls = 0; + auto context = dependencies::make_dependency_context(dependencies::borrow(source)); + auto graph = dependencies::make_computation_graph( + dependencies::make_computation>([&result_calls](int intermediate) -> std::expected { + ++result_calls; + if (intermediate < 0) { return std::unexpected("input must be nonnegative"); } + return intermediate + 1; + }), + dependencies::make_computation>([&intermediate_calls](int input) { ++intermediate_calls; return 2 * input; }) + ); + auto cache = dependencies::make_computation_cache(std::move(graph), context); + REQUIRE(cache.try_prepare(context)); + CHECK(cache.get() == 5); + + source = -3; + auto rejected = cache.try_prepare(context); + REQUIRE_FALSE(rejected); + CHECK(rejected.error().computation_index == 0); + CHECK(rejected.error().error == "input must be nonnegative"); + CHECK_FALSE(cache.ready()); + CHECK_FALSE(cache.contains()); + CHECK_THROWS_AS(cache.get(), std::logic_error); + CHECK(cache.report().computed_count() == 0); + CHECK(cache.report().reused_count() == 0); + CHECK(intermediate_calls == 2); + CHECK(result_calls == 2); + + // Retrying rejected inputs must rebuild the prerequisite: its candidate was never committed. + REQUIRE_FALSE(cache.try_prepare(context)); + CHECK(intermediate_calls == 3); + CHECK(result_calls == 3); + + // The last accepted cache is still usable when the source returns to its accepted state. + source = 2; + const auto recovered = cache.try_prepare(context); + REQUIRE(recovered); + CHECK(recovered->reused_count() == 2); + CHECK(cache.get() == 5); + CHECK(intermediate_calls == 3); + CHECK(result_calls == 3); + + source = 4; + REQUIRE(cache.try_prepare(context)); + CHECK(cache.get() == 9); + CHECK(intermediate_calls == 4); + CHECK(result_calls == 4); +} + +TEST_CASE("Dependency computations can return move-only typed errors", "[unit][utils][dependencies]") { + using Error = std::unique_ptr; + int source = -1; + auto context = dependencies::make_dependency_context(dependencies::borrow(source)); + auto graph = dependencies::make_computation_graph(dependencies::make_computation>([](int value) -> std::expected { + if (value < 0) { return std::unexpected(std::make_unique(value)); } + return value; + })); + auto cache = dependencies::make_computation_cache(std::move(graph), context); + const auto rejected = cache.try_prepare(context); + REQUIRE_FALSE(rejected); + CHECK(*rejected.error().error == -1); + source = 3; + REQUIRE(cache.try_prepare(context)); + CHECK(cache.get() == 3); +} + +TEST_CASE("Exceptions leave dependency results unavailable without replacing the accepted cache", "[unit][utils][dependencies]") { + int source = 2; + int calls = 0; + auto context = dependencies::make_dependency_context(dependencies::borrow(source, ThrowingSnapshot{})); + auto graph = dependencies::make_computation_graph(dependencies::make_computation>([&calls](int value) { + ++calls; + if (value == 3) { + serif::utils::errors::serif_error("calculation failed"); + } + return value; + })); + auto cache = dependencies::make_computation_cache(std::move(graph), context); + REQUIRE(cache.try_prepare(context)); + CHECK(calls == 1); + + SECTION("A source policy throws before evaluation") { + source = -1; + CHECK_THROWS_WITH(cache.try_prepare(context), Catch::Matchers::ContainsSubstring("source snapshot unavailable")); + CHECK(calls == 1); + } + SECTION("The calculation throws after snapshot capture") { + source = 3; + CHECK_THROWS_WITH(cache.try_prepare(context), Catch::Matchers::ContainsSubstring("calculation failed")); + CHECK(calls == 2); + } + CHECK_FALSE(cache.ready()); + CHECK_THROWS_AS(cache.get(), std::logic_error); + source = 2; + const auto recovered = cache.try_prepare(context); + REQUIRE(recovered); + CHECK(recovered->reused_count() == 1); + CHECK(cache.get() == 2); +} + +TEST_CASE("Preparation counts distinguish successful requests from rejected attempts and survive cache moves", "[unit][utils][dependencies]") { + int source = 2; + int evaluations = 0; + const auto context = dependencies::make_dependency_context(dependencies::borrow(source)); + const auto computation = dependencies::make_computation>( + [&evaluations](int input) -> std::expected { + ++evaluations; + if (input < 0) { + return std::unexpected("input must be nonnegative"); + } + + return 2 * input; + } + ); + auto cache = dependencies::make_computation_cache(context, computation); + CHECK(cache.preparation_count() == 0); + + const auto computed = cache.try_prepare(context); + REQUIRE(computed); + CHECK(computed->status() == dependencies::PreparationStatus::computed); + CHECK(cache.preparation_count() == 1); + CHECK(evaluations == 1); + + const auto reused = cache.try_prepare(context); + REQUIRE(reused); + CHECK(reused->status() == dependencies::PreparationStatus::reused); + CHECK(cache.preparation_count() == 2); + CHECK(evaluations == 1); + + source = -1; + const auto rejected = cache.try_prepare(context); + REQUIRE_FALSE(rejected); + CHECK_FALSE(cache.ready()); + CHECK(cache.preparation_count() == 2); + CHECK(evaluations == 2); + + cache.invalidate(); + CHECK_FALSE(cache.ready()); + CHECK(cache.preparation_count() == 2); + source = 2; + const auto recovered = cache.try_prepare(context); + REQUIRE(recovered); + CHECK(recovered->status() == dependencies::PreparationStatus::computed); + CHECK(cache.preparation_count() == 3); + CHECK(evaluations == 3); + + auto moved = std::move(cache); + CHECK(moved.ready()); + CHECK(moved.preparation_count() == 3); + CHECK(moved.get() == 4); + CHECK_FALSE(cache.ready()); + CHECK(cache.preparation_count() == 0); + + const auto moved_report = moved.try_prepare(context); + REQUIRE(moved_report); + CHECK(moved_report->status() == dependencies::PreparationStatus::reused); + CHECK(moved.preparation_count() == 4); + CHECK(evaluations == 3); +} diff --git a/tests/utils/dependencies/snapshots.cpp b/tests/utils/dependencies/snapshots.cpp new file mode 100644 index 00000000..779caeb0 --- /dev/null +++ b/tests/utils/dependencies/snapshots.cpp @@ -0,0 +1,137 @@ +#include "serif/utils/dependencies/dependencies.hpp" + +#include +#include +#include +#include +#include + +namespace { + namespace dependencies = serif::utils::dependencies; + + struct Source {}; + struct Unused {}; + struct Result {}; + struct OtherResult {}; + struct Untrackable {}; + + struct View { + const int& binding; + const int& value; + }; + struct ViewTracking { + /// Snapshot content without retaining the temporary view. + int capture(const View& view) const { return view.value; } + /// Vector storage may move or be copied without changing its value. + bool matches(const int& captured, const View& view) const { return captured == view.value; } + /// The persistent role, rather than its temporary wrapper, defines identity. + const void* identity(const View& view) const { return &view.binding; } + }; + + struct RevisionedSource { + std::unique_ptr value; + unsigned revision{0}; + }; + + struct OwnedSnapshot { + int& captures; + int& comparisons; + + /// Exercise an owning snapshot that cannot itself be copied. + std::unique_ptr capture(const RevisionedSource& source) const { + ++captures; + return std::make_unique(*source.value); + } + + /// Compare the frozen content, independent of source revision counters. + bool matches(const std::unique_ptr& captured, const RevisionedSource& source) const { + ++comparisons; + return *captured == *source.value; + } + }; +} + +TEST_CASE("Dependency views can preserve underlying identity across temporary wrappers", "[unit][utils][dependencies]") { + int binding = 1, other_binding = 1, value = 4, copied_value = 4; + const auto context = dependencies::make_dependency_context(dependencies::own(View{binding, value}, ViewTracking{})); + auto graph = dependencies::make_computation_graph(dependencies::make_computation>([](const View& view) { return view.value; })); + auto cache = dependencies::make_computation_cache(std::move(graph), context); + REQUIRE(cache.try_prepare(context)); + const auto copied = dependencies::make_dependency_context(dependencies::own(View{binding, copied_value}, ViewTracking{})); + auto report = cache.try_prepare(copied); + REQUIRE(report); + CHECK(report->reused_count() == 1); + + const auto rebound = dependencies::make_dependency_context(dependencies::own(View{other_binding, copied_value}, ViewTracking{})); + report = cache.try_prepare(rebound); + REQUIRE(report); + CHECK(report->computed_count() == 1); + copied_value = 8; + REQUIRE(cache.try_prepare(rebound)); + CHECK(cache.get() == 8); +} + +TEST_CASE("Dependency value snapshots detect in-place container changes", "[unit][utils][dependencies]") { + std::vector values{2, 3}; + int calls = 0; + auto context = dependencies::make_dependency_context(dependencies::borrow(values), dependencies::own(Untrackable{})); + auto graph = dependencies::make_computation_graph(dependencies::make_computation>([&calls](const std::vector& input) { ++calls; return std::accumulate(input.begin(), input.end(), 0); })); + auto cache = dependencies::make_computation_cache(std::move(graph), context); + REQUIRE(cache.try_prepare(context)); + CHECK(cache.get() == 5); + + values[1] = 7; + REQUIRE(cache.try_prepare(context)); + CHECK(cache.get() == 9); + CHECK(calls == 2); + values.push_back(4); + REQUIRE(cache.try_prepare(context)); + CHECK(cache.get() == 13); + CHECK(calls == 3); + REQUIRE(cache.try_prepare(context)); + CHECK(calls == 3); +} + +TEST_CASE("A dependency projection tracks noncopyable sources by revision", "[unit][utils][dependencies]") { + RevisionedSource source{.value = std::make_unique(2)}; + int calls = 0; + auto context = dependencies::make_dependency_context(dependencies::borrow(source, + dependencies::track_by([](const RevisionedSource& input) { return input.revision; }))); + auto graph = dependencies::make_computation_graph(dependencies::make_computation>([&calls](const RevisionedSource& input) { ++calls; return *input.value; })); + auto cache = dependencies::make_computation_cache(std::move(graph), context); + + REQUIRE(cache.try_prepare(context)); + REQUIRE(cache.try_prepare(context)); + CHECK(calls == 1); + *source.value = 8; + ++source.revision; + REQUIRE(cache.try_prepare(context)); + CHECK(calls == 2); + CHECK(cache.get() == 8); +} + +TEST_CASE("Required sources are inspected once and can use move-only snapshot state", "[unit][utils][dependencies]") { + RevisionedSource source{.value = std::make_unique(2)}; + int captures = 0; + int comparisons = 0; + auto context = dependencies::make_dependency_context(dependencies::borrow(source, + OwnedSnapshot{.captures = captures, .comparisons = comparisons})); + auto graph = dependencies::make_computation_graph( + dependencies::make_computation>([](const RevisionedSource& input) { return *input.value; }), + dependencies::make_computation>([](const RevisionedSource& input) { return 2 * *input.value; }) + ); + auto cache = dependencies::make_computation_cache(std::move(graph), context); + REQUIRE(cache.try_prepare(context)); + CHECK(captures == 1); + CHECK(comparisons == 0); + REQUIRE(cache.try_prepare(context)); + CHECK(captures == 1); + CHECK(comparisons == 1); + + *source.value = 9; + REQUIRE(cache.try_prepare(context)); + CHECK(captures == 2); + CHECK(comparisons == 2); + CHECK(cache.get() == 9); + CHECK(cache.get() == 18); +} diff --git a/tests/utils/laneemden/formatting.cpp b/tests/utils/laneemden/formatting.cpp new file mode 100644 index 00000000..e575c9f3 --- /dev/null +++ b/tests/utils/laneemden/formatting.cpp @@ -0,0 +1,71 @@ +#include +#include +#include +#include + +#include +#include + +#include "serif/utils/laneemden/solution.hpp" + +namespace { + namespace laneemden = serif::utils::laneemden; + constexpr laneemden::Sample sample{.xi = 2.0, .theta = 0.5, .derivative = -0.25}; + + /** @brief Use distinct profile and surface values so selection forwarding is observable. */ + [[nodiscard]] laneemden::Solution example_solution() { + return { + .n = 1.0, + .samples = {{.xi = 0.0, .theta = 1.0, .derivative = 0.0}, sample}, + .surface = laneemden::Sample{.xi = 3.0, .theta = 0.0, .derivative = -0.5} + }; + } +} + +TEST_CASE("Lane Emden sample formatting selects complete or individual values", "[laneemden][formatting]") { + CHECK(std::format("{}", sample) == "(ξ=2, θ=0.5, dθ/dξ=-0.25)"); + CHECK(std::format("{:}", sample) == std::format("{}", sample)); + CHECK(std::format("{:all}", sample) == std::format("{}", sample)); + CHECK(std::format("{:xi}", sample) == "2"); + CHECK(std::format("{:theta}", sample) == "0.5"); + CHECK(std::format("{:derivative}", sample) == "-0.25"); + CHECK(std::format("{1:theta} / {0:xi}", sample, sample) == "0.5 / 2"); + CHECK(std::format("{{{:xi}}}", sample) == "{2}"); +} + +TEST_CASE("Lane Emden solution formatting forwards selection to its profile and surface", "[laneemden][formatting]") { + const auto solution = example_solution(); + CHECK(std::format("{}", solution) == "Solution(n=1, samples=[(ξ=0, θ=1, dθ/dξ=0), (ξ=2, θ=0.5, dθ/dξ=-0.25)], surface=(ξ=3, θ=0, dθ/dξ=-0.5))"); + CHECK(std::format("{:}", solution) == std::format("{}", solution)); + CHECK(std::format("{:all}", solution) == std::format("{}", solution)); + CHECK(std::format("{:xi}", solution) == "Solution(n=1, samples=[0, 2], surface=3)"); + CHECK(std::format("{:theta}", solution) == "Solution(n=1, samples=[1, 0.5], surface=0)"); + CHECK(std::format("{:derivative}", solution) == "Solution(n=1, samples=[0, -0.25], surface=-0.5)"); + const laneemden::Solution empty{.n = 5.0, .samples = {}, .surface = {}}; + CHECK(std::format("{}", empty) == "Solution(n=5, samples=[], surface=none)"); + CHECK(std::format("{:theta}", empty) == "Solution(n=5, samples=[], surface=none)"); +} + +TEST_CASE("Lane Emden formatters preserve surrounding output and independent selectors", "[laneemden][formatting]") { + const auto solution = example_solution(); + const std::string expected = "prefix Solution(n=1, samples=[1, 0.5], surface=0) | 2 | Solution(n=1, samples=[0, -0.25], surface=-0.5) suffix"; + std::string appended = "prefix "; + std::format_to(std::back_inserter(appended), "{:theta} | {:xi} | {:derivative} suffix", solution, sample, solution); + CHECK(appended == expected); + std::array buffer{}; + const auto end = std::format_to(buffer.data(), "prefix {:theta} | {:xi} | {:derivative} suffix", solution, sample, solution); + CHECK(std::string(buffer.data(), end) == expected); +} + +TEST_CASE("Lane Emden formatters reject unsupported selectors through the central error helper", "[laneemden][formatting]") { + const auto solution = example_solution(); + for (const std::string specifier : {"radius", "xi,theta", "theta ", ".2f", "theta:.2f"}) { + CAPTURE(specifier); + const std::string format = "{:" + specifier + "}"; + CHECK_THROWS_AS(std::vformat(format, std::make_format_args(sample)), std::format_error); + CHECK_THROWS_AS(std::vformat(format, std::make_format_args(solution)), std::format_error); + CHECK_THROWS_WITH(std::vformat(format, std::make_format_args(sample)), Catch::Matchers::ContainsSubstring("Unknown Lane–Emden sample selector")); + } + CHECK_THROWS_AS(std::vformat("{:xi", std::make_format_args(sample)), std::format_error); + CHECK_THROWS_AS(std::vformat("{:theta", std::make_format_args(solution)), std::format_error); +} diff --git a/tests/utils/laneemden/scaling.cpp b/tests/utils/laneemden/scaling.cpp new file mode 100644 index 00000000..c727907d --- /dev/null +++ b/tests/utils/laneemden/scaling.cpp @@ -0,0 +1,152 @@ +#include +#include +#include + +#include +#include + +#include "serif/utils/laneemden/laneemden.hpp" + +namespace { + namespace laneemden = serif::utils::laneemden; + namespace dimensions = serif::dimensions; + using Mass = dimensions::QuantityValue; + using Density = dimensions::QuantityValue; + using Length = dimensions::QuantityValue; + using Enthalpy = dimensions::QuantityValue; + using GravityConstant = dimensions::QuantityValue; +} + +TEST_CASE("Lane–Emden scaling prescriptions describe the same physical polytrope", "[laneemden][scaling]") { + for (double n : {0.5, 1.0, 1.5, 3.0}) { + const auto solution = laneemden::solve(n); + REQUIRE(solution); + const auto mass_scales = laneemden::scale_from_mass(*solution, Mass{2.0}, Density{3.0}, GravityConstant{0.7}); + REQUIRE(mass_scales); + CHECK(mass_scales->mass.value() == Catch::Approx(2.0).epsilon(1e-14)); + REQUIRE(mass_scales->polytropic_constant); + const auto enthalpy_scales = laneemden::scale_from_enthalpy(*solution, mass_scales->central_enthalpy, Density{3.0}, GravityConstant{0.7}); + const auto eos_scales = laneemden::scale_from_polytropic_constant(*solution, *mass_scales->polytropic_constant, Density{3.0}, GravityConstant{0.7}); + REQUIRE(enthalpy_scales); + REQUIRE(eos_scales); + CHECK(enthalpy_scales->radius.value() == Catch::Approx(mass_scales->radius.value()).epsilon(1e-14)); + CHECK(eos_scales->radius.value() == Catch::Approx(mass_scales->radius.value()).epsilon(1e-14)); + CHECK(eos_scales->mass.value() == Catch::Approx(2.0).epsilon(1e-14)); + for (double fraction : {0.0, 0.1, 0.5, 0.9}) { + const auto fields = laneemden::evaluate_physical(*solution, *mass_scales, Length{fraction * mass_scales->radius.value()}); + REQUIRE(fields); + CHECK(fields->pressure.value() == Catch::Approx(*mass_scales->polytropic_constant * std::pow(fields->density.value(), 1.0 + 1.0 / n)).epsilon(2e-13)); + CHECK(fields->enthalpy.value() == Catch::Approx((n + 1.0) * fields->pressure.value() / fields->density.value()).epsilon(2e-13)); + CHECK(fields->potential.value() + fields->enthalpy.value() == Catch::Approx(-0.7 * 2.0 / mass_scales->radius.value()).epsilon(2e-13)); + } + } +} + +TEST_CASE("The n equals three EOS mass is independent of central density", "[laneemden][scaling]") { + const auto solution = laneemden::solve(3.0); + REQUIRE(solution); + const auto first = laneemden::scale_from_polytropic_constant(*solution, 2.0, Density{1.0}, GravityConstant{0.7}); + const auto second = laneemden::scale_from_polytropic_constant(*solution, 2.0, Density{8.0}, GravityConstant{0.7}); + REQUIRE(first); + REQUIRE(second); + CHECK(second->mass.value() == Catch::Approx(first->mass.value()).epsilon(1e-14)); + CHECK(second->radius.value() == Catch::Approx(0.5 * first->radius.value()).epsilon(1e-14)); + CHECK(second->polytropic_constant == first->polytropic_constant); +} + +TEST_CASE("Physical incompressible polytropes recover uniform sphere fields", "[laneemden][scaling][analytic]") { + const auto solution = laneemden::solve(0.0); + REQUIRE(solution); + const auto scales = laneemden::scale_from_mass(*solution, Mass{2.0}, Density{3.0}, GravityConstant{0.7}); + REQUIRE(scales); + CHECK_FALSE(scales->polytropic_constant); + const double surface_radius = std::cbrt(3.0 * 2.0 / (4.0 * std::numbers::pi * 3.0)); + CHECK(scales->radius.value() == Catch::Approx(surface_radius).epsilon(2e-11)); + for (double fraction : {0.0, 0.01, 0.2, 0.6, 0.99}) { + const double radius = fraction * scales->radius.value(); + const auto fields = laneemden::evaluate_physical(*solution, *scales, Length{radius}); + REQUIRE(fields); + CHECK(fields->density.value() == 3.0); + CHECK(fields->enclosed_mass.value() == Catch::Approx(2.0 * fraction * fraction * fraction).margin(1e-10)); + CHECK(fields->gravity.value() == Catch::Approx(4.0 * std::numbers::pi * 0.7 * radius).margin(1e-10)); + const double enthalpy = 2.0 * std::numbers::pi * 0.7 * (surface_radius * surface_radius - radius * radius); + CHECK(fields->enthalpy.value() == Catch::Approx(enthalpy).margin(1e-10)); + CHECK(fields->pressure.value() == Catch::Approx(3.0 * enthalpy).margin(3e-10)); + const double potential = -0.7 * 2.0 * (3.0 - fraction * fraction) / (2.0 * surface_radius); + CHECK(fields->potential.value() == Catch::Approx(potential).margin(1e-10)); + } +} + +TEST_CASE("Physical profiles match the vacuum exterior continuously", "[laneemden][scaling]") { + const auto solution = laneemden::solve(1.5); + REQUIRE(solution); + const auto scales = laneemden::scale_from_mass(*solution, Mass{2.0}, Density{3.0}, GravityConstant{0.7}); + REQUIRE(scales); + for (double fraction : {1.0, 1.5, 100.0}) { + const double radius = fraction * scales->radius.value(); + const auto fields = laneemden::evaluate_physical(*solution, *scales, Length{radius}); + REQUIRE(fields); + CHECK(fields->density.value() == 0.0); + CHECK(fields->pressure.value() == 0.0); + CHECK(fields->enthalpy.value() == 0.0); + CHECK(fields->enclosed_mass.value() == Catch::Approx(2.0)); + CHECK(fields->potential.value() == Catch::Approx(-1.4 / radius)); + CHECK(fields->gravity.value() == Catch::Approx(1.4 / (radius * radius))); + } + const auto inside = laneemden::evaluate_physical(*solution, *scales, Length{scales->radius.value() * (1.0 - 1e-8)}); + const auto surface = laneemden::evaluate_physical(*solution, *scales, scales->radius); + REQUIRE(inside); + REQUIRE(surface); + CHECK(inside->potential.value() == Catch::Approx(surface->potential.value()).epsilon(3e-8)); + CHECK(inside->gravity.value() == Catch::Approx(surface->gravity.value()).epsilon(3e-8)); + CHECK(inside->enclosed_mass.value() == Catch::Approx(surface->enclosed_mass.value()).epsilon(3e-8)); +} + +TEST_CASE("Physical scaling is covariant between metre kilogram and centimetre gram units", "[laneemden][scaling][units]") { + const auto solution = laneemden::solve(3.0); + REQUIRE(solution); + const auto metres = laneemden::scale_from_mass(*solution, Mass{2e30}, Density{1e5}, GravityConstant{6.6743e-11}); + const auto centimetres = laneemden::scale_from_mass(*solution, Mass{2e33}, Density{1e2}, GravityConstant{6.6743e-8}); + REQUIRE(metres); + REQUIRE(centimetres); + CHECK(centimetres->radius.value() == Catch::Approx(100.0 * metres->radius.value()).epsilon(1e-14)); + CHECK(centimetres->central_enthalpy.value() == Catch::Approx(1e4 * metres->central_enthalpy.value()).epsilon(1e-14)); + for (double fraction : {0.0, 0.3, 0.9, 1.0, 3.0}) { + const auto first = laneemden::evaluate_physical(*solution, *metres, metres->radius * fraction); + const auto second = laneemden::evaluate_physical(*solution, *centimetres, centimetres->radius * fraction); + REQUIRE(first); + REQUIRE(second); + CHECK(second->density.value() == Catch::Approx(1e-3 * first->density.value()).margin(1e-12)); + CHECK(second->pressure.value() == Catch::Approx(10.0 * first->pressure.value()).epsilon(1e-13)); + CHECK(second->enthalpy.value() == Catch::Approx(1e4 * first->enthalpy.value()).epsilon(1e-13)); + CHECK(second->potential.value() == Catch::Approx(1e4 * first->potential.value()).epsilon(1e-13)); + CHECK(second->enclosed_mass.value() == Catch::Approx(1e3 * first->enclosed_mass.value()).epsilon(1e-13)); + CHECK(second->gravity.value() == Catch::Approx(100.0 * first->gravity.value()).epsilon(1e-13)); + } +} + +TEST_CASE("Physical scaling rejects invalid data and finite interval profiles without surfaces", "[laneemden][scaling][rejection]") { + const auto solution = laneemden::solve(1.0); + const auto infinite = laneemden::solve(5.0, {.maximum_radius = 2.0}); + REQUIRE(solution); + REQUIRE(infinite); + CHECK_FALSE(laneemden::scale_from_mass(*infinite, Mass{1.0}, Density{1.0}, GravityConstant{1.0})); + CHECK_FALSE(laneemden::scale_from_enthalpy(*infinite, Enthalpy{1.0}, Density{1.0}, GravityConstant{1.0})); + for (double invalid : {0.0, -1.0, std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()}) { + CHECK_FALSE(laneemden::scale_from_mass(*solution, Mass{invalid}, Density{1.0}, GravityConstant{1.0})); + CHECK_FALSE(laneemden::scale_from_mass(*solution, Mass{1.0}, Density{invalid}, GravityConstant{1.0})); + CHECK_FALSE(laneemden::scale_from_mass(*solution, Mass{1.0}, Density{1.0}, GravityConstant{invalid})); + CHECK_FALSE(laneemden::scale_from_enthalpy(*solution, Enthalpy{invalid}, Density{1.0}, GravityConstant{1.0})); + CHECK_FALSE(laneemden::scale_from_polytropic_constant(*solution, invalid, Density{1.0}, GravityConstant{1.0})); + } + const auto incompressible = laneemden::solve(0.0); + REQUIRE(incompressible); + CHECK_FALSE(laneemden::scale_from_polytropic_constant(*incompressible, 1.0, Density{1.0}, GravityConstant{1.0})); + const auto scales = laneemden::scale_from_mass(*solution, Mass{1.0}, Density{1.0}, GravityConstant{1.0}); + REQUIRE(scales); + for (double invalid : {-1.0, std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()}) { + CHECK_FALSE(laneemden::evaluate_physical(*solution, *scales, Length{invalid})); + } + CHECK_FALSE(laneemden::evaluate_physical(*incompressible, *scales, Length{0.0})); + CHECK_FALSE(laneemden::scale_from_enthalpy(*solution, Enthalpy{1e308}, Density{1e-300}, GravityConstant{1e-300})); +} diff --git a/tests/utils/laneemden/solution.cpp b/tests/utils/laneemden/solution.cpp new file mode 100644 index 00000000..a9bde120 --- /dev/null +++ b/tests/utils/laneemden/solution.cpp @@ -0,0 +1,155 @@ +#include +#include +#include + +#include +#include + +#include "serif/utils/laneemden/solution.hpp" + +namespace { + namespace laneemden = serif::utils::laneemden; + + /** @brief The three analytic regular-center Lane–Emden profiles and their derivatives. */ + laneemden::Sample analytic(double n, double xi) { + if (n == 0.0) { + return {.xi = xi, .theta = 1.0 - xi * xi / 6.0, .derivative = -xi / 3.0}; + } + if (n == 1.0) { + if (xi == 0.0) { + return {.xi = 0.0, .theta = 1.0, .derivative = 0.0}; + } + return {.xi = xi, .theta = std::sin(xi) / xi, .derivative = (xi * std::cos(xi) - std::sin(xi)) / (xi * xi)}; + } + const double denominator = 1.0 + xi * xi / 3.0; + return {.xi = xi, .theta = 1.0 / std::sqrt(denominator), .derivative = -xi / (3.0 * std::pow(denominator, 1.5))}; + } +} + +TEST_CASE("Lane–Emden matches all regular-center analytic solutions", "[laneemden][analytic]") { + for (double n : {0.0, 1.0, 5.0}) { + CAPTURE(n); + const auto solution = laneemden::solve(n, {.maximum_radius = 20.0}); + REQUIRE(solution); + REQUIRE(solution->samples.front().xi == 0.0); + REQUIRE(solution->samples.front().theta == 1.0); + REQUIRE(solution->samples.front().derivative == 0.0); + const double endpoint = solution->samples.back().xi; + for (int index = 0; index <= 400; ++index) { + const double xi = endpoint * static_cast(index) / 400.0; + const auto actual = laneemden::evaluate(*solution, xi); + REQUIRE(actual); + const laneemden::Sample exact = analytic(n, xi); + CHECK(actual->theta == Catch::Approx(exact.theta).margin(3e-10)); + CHECK(actual->derivative == Catch::Approx(exact.derivative).margin(3e-9)); + } + if (n < 5.0) { + REQUIRE(solution->surface); + const double exact_radius = n == 0.0 ? std::sqrt(6.0) : std::numbers::pi; + CHECK(solution->surface->xi == Catch::Approx(exact_radius).margin(2e-9)); + CHECK(solution->surface->derivative == Catch::Approx(analytic(n, exact_radius).derivative).margin(2e-9)); + CHECK(solution->samples.back().theta == 0.0); + } else { + CHECK_FALSE(solution->surface); + CHECK(endpoint == 20.0); + } + } +} + +TEST_CASE("Lane–Emden advances the coupled system at fourth order", "[laneemden][convergence]") { + double previous_error = 0.0; + for (double step : {0.08, 0.04, 0.02, 0.01}) { + const auto solution = laneemden::solve(1.0, {.step = step, .maximum_radius = 2.0}); + REQUIRE(solution); + const double error = std::abs(solution->samples.back().theta - analytic(1.0, 2.0).theta); + if (previous_error > 0.0) { + CHECK(previous_error / error > 14.0); + CHECK(previous_error / error < 18.0); + } + previous_error = error; + } +} + +TEST_CASE("Lane–Emden finds stellar surfaces for fractional and centrally concentrated profiles", "[laneemden][surface]") { + struct Reference final { + double n; + double radius; + double mass; + double mass_tolerance; + }; + // Standard first-zero values provide an independent endpoint check beyond the analytic indices. + for (const Reference reference : { + Reference{.n = 0.5, .radius = 2.752698054065, .mass = 3.78865118488, .mass_tolerance = 2e-8}, + Reference{.n = 1.5, .radius = 3.653753736219, .mass = 2.714055120109, .mass_tolerance = 2e-9}, + Reference{.n = 3.0, .radius = 6.896848619377, .mass = 2.018235950966, .mass_tolerance = 2e-9}}) { + CAPTURE(reference.n); + const auto solution = laneemden::solve(reference.n, {.step = 0.0005}); + REQUIRE(solution); + REQUIRE(solution->surface); + const laneemden::Sample surface = *solution->surface; + CHECK(surface.xi == Catch::Approx(reference.radius).margin(2e-9)); + CHECK(-surface.xi * surface.xi * surface.derivative == Catch::Approx(reference.mass).margin(reference.mass_tolerance)); + for (std::size_t index = 1; index < solution->samples.size(); ++index) { + const laneemden::Sample &previous = solution->samples[index - 1]; + const laneemden::Sample ¤t = solution->samples[index]; + REQUIRE(current.xi > previous.xi); + REQUIRE(current.theta >= 0.0); + REQUIRE(current.theta < previous.theta); + REQUIRE(std::isfinite(current.derivative)); + } + } +} + +TEST_CASE("Fractional-index surface values converge without taking nonreal powers", "[laneemden][convergence]") { + double previous_error = 0.0; + for (double step : {0.04, 0.02, 0.01, 0.005}) { + const auto solution = laneemden::solve(0.5, {.step = step}); + REQUIRE(solution); + REQUIRE(solution->surface); + const laneemden::Sample surface = *solution->surface; + const double error = std::abs(-surface.xi * surface.xi * surface.derivative - 3.78865118488); + if (previous_error > 0.0) { + CHECK(error < 0.45 * previous_error); + } + previous_error = error; + } +} + +TEST_CASE("Lane–Emden distinguishes finite interval profiles from failures", "[laneemden]") { + for (double n : {1.0, 5.0, 6.0}) { + const auto solution = laneemden::solve(n, {.maximum_radius = 1.0}); + REQUIRE(solution); + CHECK_FALSE(solution->surface); + CHECK(solution->samples.back().xi == 1.0); + CHECK(solution->samples.back().theta > 0.0); + } + const auto exhausted = laneemden::solve(3.0, {.maximum_steps = 2}); + CHECK_FALSE(exhausted); + const auto tiny_interval = laneemden::solve(1.0, {.maximum_radius = 1e-6}); + REQUIRE(tiny_interval); + CHECK(tiny_interval->samples.size() == 2); + CHECK(tiny_interval->samples.back().derivative == Catch::Approx(-1e-6 / 3.0).margin(1e-18)); +} + +TEST_CASE("Lane–Emden rejects invalid controls and evaluation radii", "[laneemden][rejection]") { + const double infinity = std::numeric_limits::infinity(); + const double nan = std::numeric_limits::quiet_NaN(); + for (double invalid : {-1.0, infinity, nan}) { + CHECK_FALSE(laneemden::solve(invalid)); + } + for (double invalid : {0.0, -1.0, infinity, nan}) { + CHECK_FALSE(laneemden::solve(1.0, {.step = invalid})); + CHECK_FALSE(laneemden::solve(1.0, {.maximum_radius = invalid})); + CHECK_FALSE(laneemden::solve(1.0, {.surface_tolerance = invalid})); + } + CHECK_FALSE(laneemden::solve(1.0, {.maximum_steps = 0})); + CHECK_FALSE(laneemden::solve(1.0, {.surface_tolerance = 0.01})); + CHECK_FALSE(laneemden::solve(1.0, {.central_series_terms = 5})); + CHECK_FALSE(laneemden::solve(1.0, {.central_series_terms = 65})); + const auto solution = laneemden::solve(1.0); + REQUIRE(solution); + for (double invalid : {-1.0, infinity, nan, 4.0}) { + CHECK_FALSE(laneemden::evaluate(*solution, invalid)); + } + CHECK_FALSE(laneemden::evaluate(laneemden::Solution{.n = 1.0, .samples = {}, .surface = {}}, 0.0)); +} diff --git a/tests/utils/meson.build b/tests/utils/meson.build new file mode 100644 index 00000000..d8b7cf8e --- /dev/null +++ b/tests/utils/meson.build @@ -0,0 +1,36 @@ +dependency_test_sources = files( + 'dependencies/ownership.cpp', + 'dependencies/context.cpp', + 'dependencies/preparation.cpp', + 'dependencies/snapshots.cpp', + 'dependencies/rejection.cpp', + 'dependencies/mixed_errors.cpp', + 'dependencies/reachability.cpp', + 'dependencies/observers.cpp', +) + +# The dependency machinery must remain usable without linking MFEM or SERiF. +serif_dependency_tests = executable( + 'serif_dependency_tests', + dependency_test_sources, + include_directories: include_dir, + dependencies: [catch2_dep, magic_enum_dep, nameof_dep], +) +test('serif_dependency_tests', serif_dependency_tests, suite: 'unit', timeout: 30) + +radial_test_sources = files( + 'laneemden/solution.cpp', + 'laneemden/formatting.cpp', + 'laneemden/scaling.cpp', + 'projection/radial.cpp', + 'projection/nonconforming.cpp', +) + +serif_radial_tests = executable( + 'serif_radial_tests', + radial_test_sources, + dependencies: [serif_dep, catch2_dep, stroid_dep], + include_directories: include_directories('../include'), + build_rpath: mfem_runtime_prefix == '' ? '' : mfem_runtime_prefix / 'lib', +) +test('serif_radial_tests', serif_radial_tests, suite: 'unit', timeout: 60) diff --git a/tests/utils/projection/nonconforming.cpp b/tests/utils/projection/nonconforming.cpp new file mode 100644 index 00000000..b3db69c9 --- /dev/null +++ b/tests/utils/projection/nonconforming.cpp @@ -0,0 +1,103 @@ +#include + +#include + +#include "mfem/fem/coefficient.hpp" +#include "mfem/mesh/mesh.hpp" +#include "serif/backend/mfem/projection/radial.hpp" + +namespace { + namespace projection = serif::utils::projection::backend::mfem; + + /** @brief Refine one of two adjacent cells so the shared edge has constrained child coefficients. */ + [[nodiscard]] mfem::Mesh nonconforming_mesh() { + mfem::Mesh mesh = mfem::Mesh::MakeCartesian2D(2, 1, mfem::Element::QUADRILATERAL, true, 2.0, 1.0); + mesh.EnsureNCMesh(); + mfem::Array selected(1); + selected[0] = 0; + mesh.GeneralRefinement(selected); + return mesh; + } + + /** @brief Independently measure the distance from the true finite-element subspace through its constraint matrices. */ + [[nodiscard]] double constraint_defect(const mfem::GridFunction& field) { + const mfem::FiniteElementSpace& space = *field.FESpace(); + const mfem::SparseMatrix* restriction = space.GetConformingRestriction(); + const mfem::SparseMatrix* prolongation = space.GetConformingProlongation(); + REQUIRE(restriction != nullptr); + REQUIRE(prolongation != nullptr); + REQUIRE(space.GetTrueVSize() < space.GetVSize()); + mfem::Vector true_values(restriction->Height()), constrained(space.GetVSize()); + restriction->Mult(field, true_values); + prolongation->Mult(true_values, constrained); + constrained -= field; + return constrained.Norml2(); + } +} + +TEST_CASE("Radial scalar projection satisfies hanging-node constraints after nonlinear sampling", "[projection][nonconforming]") { + mfem::Mesh mesh = nonconforming_mesh(); + mfem::H1_FECollection collection(1, 2); + mfem::FiniteElementSpace space(&mesh, &collection); + mfem::FunctionCoefficient coefficient([](const mfem::Vector& position) { + return position * position; + }); + mfem::GridFunction native(&space); + native.ProjectCoefficient(coefficient); + REQUIRE(constraint_defect(native) > 0.1); + const auto projected = projection::project_scalar(space, [](double radius) { + return radius * radius; + }); + REQUIRE(projected); + CHECK(constraint_defect(*projected) < 1.0e-13); +} + +TEST_CASE("Radial vector projection satisfies hanging-node and component constraints", "[projection][nonconforming]") { + mfem::Mesh mesh = nonconforming_mesh(); + mfem::H1_FECollection vector_collection(1, 2); + mfem::VectorFunctionCoefficient coefficient(2, [](const mfem::Vector& position, mfem::Vector& value) { + value = position; + value *= position * position; + }); + const auto check = [&](mfem::FiniteElementSpace& space) { + mfem::GridFunction native(&space); + native.ProjectCoefficient(coefficient); + REQUIRE(constraint_defect(native) > 1.0e-3); + const auto projected = projection::project_vector(space, [](double radius) { + return radius * radius * radius; + }); + REQUIRE(projected); + CHECK(constraint_defect(*projected) < 1.0e-13); + }; + for (const mfem::Ordering::Type ordering : {mfem::Ordering::byNODES, mfem::Ordering::byVDIM}) { + mfem::FiniteElementSpace vector(&mesh, &vector_collection, 2, ordering); + check(vector); + } +} + +TEST_CASE("Constrained radial interpolation retains representable scalar fields and exact vector divergence", "[projection][nonconforming][math]") { + mfem::Mesh mesh = nonconforming_mesh(); + mfem::H1_FECollection scalar_collection(2, 2); + mfem::H1_FECollection vector_collection(1, 2); + mfem::FiniteElementSpace scalar_space(&mesh, &scalar_collection), flux_space(&mesh, &vector_collection, 2); + const auto scalar = projection::project_scalar(scalar_space, [](double radius) { + return radius * radius; + }); + const auto flux = projection::project_vector(flux_space, [](double radius) { + return radius; + }); + REQUIRE(scalar); + REQUIRE(flux); + mfem::FunctionCoefficient exact_scalar([](const mfem::Vector& position) { + return position * position; + }); + mfem::VectorFunctionCoefficient exact_vector(2, [](const mfem::Vector& position, mfem::Vector& value) { + value = position; + }); + mfem::ConstantCoefficient divergence(2.0); + CHECK(scalar->ComputeL2Error(exact_scalar) < 2.0e-13); + CHECK(flux->ComputeL2Error(exact_vector) < 2.0e-13); + CHECK(flux->ComputeDivError(&divergence) < 2.0e-13); + CHECK(constraint_defect(*scalar) < 1.0e-13); + CHECK(constraint_defect(*flux) < 1.0e-13); +} diff --git a/tests/utils/projection/radial.cpp b/tests/utils/projection/radial.cpp new file mode 100644 index 00000000..ed71dcdc --- /dev/null +++ b/tests/utils/projection/radial.cpp @@ -0,0 +1,423 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "mfem/fem/coefficient.hpp" +#include "serif/backend/mfem/mapping/compactification/kelvin.hpp" +#include "serif/dimensions/type_alias.hpp" +#include "serif/backend/mfem/mapping/evaluator.hpp" +#include "serif/utils/laneemden/laneemden.hpp" +#include "serif/utils/projection/projection.hpp" +#include "serif/tests/deformation/mesh.hpp" +#include "serif/backend/mfem/mapping/transformations.hpp" + +namespace { + namespace projection = serif::utils::projection; + namespace backend = projection::backend::mfem; + namespace laneemden = serif::utils::laneemden; + namespace dimensions = serif::dimensions; + namespace mapping = serif::mapping; + + /** @brief Resolve radius without sharing the production sampler's distance implementation. */ + [[nodiscard]] double radius_squared(const mfem::Vector& point, const std::array& center) { + double squared = 0.0; + for (int component = 0; component < 3; ++component) { + squared += std::pow(point[component] - center[component], 2); + } + return squared; + } + + /** @brief Check physical reconstruction through independent quadrature. */ + void check_linear_vector(mfem::FiniteElementSpace& space, const std::array& center) { + const auto result = backend::project_vector(space, [](const double radius) { return 2.0 * radius; }, {.center = center}); + REQUIRE(result); + mfem::VectorFunctionCoefficient exact(3, [center](const mfem::Vector& point, mfem::Vector& value) { + value.SetSize(3); + for (int component = 0; component < 3; ++component) { + value[component] = 2.0 * (point[component] - center[component]); + } + }); + CHECK(result->ComputeL2Error(exact) < 2.0e-12); + } +} + +TEST_CASE("Radial scalar projection reproduces physical polynomials in H1 and L2", "[unit][projection]") { + const std::array center{0.5, 0.25, 0.75}; + for (const mfem::Element::Type geometry : {mfem::Element::HEXAHEDRON, mfem::Element::TETRAHEDRON}) { + mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(2, 2, 2, geometry); + mfem::H1_FECollection h1(2, 3); + mfem::L2_FECollection l2(2, 3); + mfem::L2_FECollection integral_l2(2, 3, mfem::BasisType::GaussLegendre, mfem::FiniteElement::INTEGRAL); + for (mfem::FiniteElementCollection* collection : std::array{&h1, &l2, &integral_l2}) { + mfem::FiniteElementSpace space(&mesh, collection); + const auto result = backend::project_scalar(space, [](const double radius) { + return 3.0 - radius * radius; + }, {.center = center}); + REQUIRE(result); + CHECK(result->FESpace() == &space); + mfem::FunctionCoefficient exact([center](const mfem::Vector& point) { + return 3.0 - radius_squared(point, center); + }); + CHECK(result->ComputeL2Error(exact) < 2.0e-12); + } + } +} + +TEST_CASE("Radial vectors preserve component order", "[unit][projection]") { + for (const mfem::Element::Type geometry : {mfem::Element::HEXAHEDRON, mfem::Element::TETRAHEDRON}) { + mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(2, 2, 2, geometry); + mfem::H1_FECollection h1(1, 3); + mfem::L2_FECollection l2(1, 3); + for (const mfem::Ordering::Type order : {mfem::Ordering::byNODES, mfem::Ordering::byVDIM}) { + mfem::FiniteElementSpace continuous(&mesh, &h1, 3, order); + mfem::FiniteElementSpace discontinuous(&mesh, &l2, 3, order); + check_linear_vector(continuous, {0.5, 0.5, 0.5}); + check_linear_vector(continuous, {0.0, 0.0, 0.0}); + check_linear_vector(discontinuous, {0.5, 0.5, 0.5}); + } + } +} + +TEST_CASE("Radial projection samples curved physical mesh coordinates", "[unit][projection]") { + mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(2, 2, 2, mfem::Element::HEXAHEDRON); + mesh.SetCurvature(2); + mesh.Transform([](const mfem::Vector& point, mfem::Vector& image) { + image = point; + image[1] += 0.1 * point[0] * point[0]; + image[2] *= 1.0 + 0.1 * point[0]; + }); + mfem::H1_FECollection h1(4, 3); + mfem::FiniteElementSpace space(&mesh, &h1); + const auto result = backend::project_scalar(space, [](const double radius) { return radius * radius; }); + REQUIRE(result); + mfem::FunctionCoefficient exact([](const mfem::Vector& point) { return point * point; }); + CHECK(result->ComputeL2Error(exact) < 3.0e-12); +} + +TEST_CASE("Radial projection pulls physical values back through stellar mapping", "[unit][projection]") { + mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(2, 2, 2, mfem::Element::HEXAHEDRON); + mfem::H1_FECollection h1(2, 3); + mfem::FiniteElementSpace displacement_space(&mesh, &h1, 3); + mfem::FiniteElementSpace scalar_space(&mesh, &h1); + mfem::GridFunction displacement(&displacement_space); + mfem::GridFunction compactification(&scalar_space); + const std::array stretch{1.2, 0.8, 1.1}; + mfem::VectorFunctionCoefficient affine(3, [stretch](const mfem::Vector& point, mfem::Vector& value) { + value.SetSize(3); + for (int component = 0; component < 3; ++component) { + value[component] = (stretch[component] - 1.0) * point[component]; + } + }); + displacement.ProjectCoefficient(affine); + compactification = 0.0; + mapping::DomainMapper mapper( + {.dimension = 3, .vacuum_element_attribute_ID = 3}, + std::make_unique( + serif::utils::user_options::KelvinCompactificationOptions{.r_star_ref = 1.0, .r_inf_ref = 4.0} + ) + ); + mapping::GridFunctionMappingEvaluator evaluator(mapper, displacement, compactification); + CHECK(evaluator.mesh() == &mesh); + // Equal element counts do not make geometry from another mesh interchangeable. + mfem::Mesh other_mesh = mfem::Mesh::MakeCartesian3D(2, 2, 2, mfem::Element::HEXAHEDRON); + mfem::FiniteElementSpace other_scalar(&other_mesh, &h1), other_vector(&other_mesh, &h1, 3); + CHECK_THROWS_AS(backend::project_scalar(other_scalar, [](const double r) { return r; }, {.mapping = &evaluator}), std::invalid_argument); + CHECK_THROWS_AS(backend::project_vector(other_vector, [](const double r) { return r; }, {.mapping = &evaluator}), std::invalid_argument); + const auto scalar = backend::project_scalar(scalar_space, [](const double radius) { return radius * radius; }, {.mapping = &evaluator}); + REQUIRE(scalar); + mfem::FunctionCoefficient exact_scalar([stretch](const mfem::Vector& point) { + double value = 0.0; + for (int component = 0; component < 3; ++component) { + value += std::pow(stretch[component] * point[component], 2); + } + return value; + }); + CHECK(scalar->ComputeL2Error(exact_scalar) < 3.0e-12); + + const auto vector = backend::project_vector(displacement_space, [](const double radius) { return 2.0 * radius; }, {.mapping = &evaluator}); + REQUIRE(vector); + // Vector H1 values are physical: 2 x_phys = 2 A x_mesh. + mfem::VectorFunctionCoefficient exact_vector(3, [stretch](const mfem::Vector& point, mfem::Vector& value) { + value.SetSize(3); + for (int component = 0; component < 3; ++component) { + value[component] = 2.0 * stretch[component] * point[component]; + } + }); + CHECK(vector->ComputeL2Error(exact_vector) < 3.0e-12); + const double determinant = stretch[0] * stretch[1] * stretch[2]; + + mfem::L2_FECollection integral_l2(2, 3, mfem::BasisType::GaussLegendre, mfem::FiniteElement::INTEGRAL); + mfem::FiniteElementSpace density_space(&mesh, &integral_l2); + const auto density = backend::project_scalar(density_space, [](const double) { return 2.0; }, {.mapping = &evaluator}); + REQUIRE(density); + mfem::ConstantCoefficient exact_density(2.0 * determinant); + CHECK(density->ComputeL2Error(exact_density) < 3.0e-12); + + displacement *= -6.0; + evaluator.refresh(); + CHECK_FALSE(backend::project_scalar(scalar_space, [](const double radius) { return radius; }, {.mapping = &evaluator})); + + displacement = 0.0; + compactification = 1.0; + for (int element = 0; element < mesh.GetNE(); ++element) { + mesh.GetElement(element)->SetAttribute(3); + } + mesh.SetAttributes(); + evaluator.refresh(); + const auto infinite = backend::project_scalar(scalar_space, [](const double) { return 0.0; }, {.mapping = &evaluator}); + REQUIRE_FALSE(infinite); + CHECK(infinite.error().find("compactified infinity") != std::string::npos); +} + +TEST_CASE("Lane Emden physical profiles project density enthalpy potential and gravity", "[unit][projection][laneemden]") { + const auto solution = laneemden::solve(0.0); + REQUIRE(solution); + const auto scales = laneemden::scale_from_mass(*solution, + dimensions::QuantityValue{3.0}, + dimensions::QuantityValue{2.0}, + dimensions::QuantityValue{1.5}); + REQUIRE(scales); + const double radius = scales->radius.value(); + const double central_enthalpy = scales->central_enthalpy.value(); + const double potential_at_surface = -1.5 * 3.0 / radius; + const double extent = 0.5 * radius; + mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(2, 2, 2, mfem::Element::HEXAHEDRON, extent, extent, extent); + mfem::H1_FECollection h1(2, 3); + mfem::L2_FECollection l2(2, 3); + mfem::FiniteElementSpace enthalpy_space(&mesh, &h1), density_space(&mesh, &l2), gravity_space(&mesh, &h1, 3); + const auto field = [&](const double physical_radius) { + return laneemden::evaluate_physical(*solution, *scales, dimensions::QuantityValue{physical_radius}); + }; + const auto density = backend::project_scalar(density_space, [&](const double r) -> projection::RadialValue { + return field(r).transform([](const auto& value) { return value.density.value(); }); + }); + const auto enthalpy = backend::project_scalar(enthalpy_space, [&](const double r) -> projection::RadialValue { + return field(r).transform([](const auto& value) { return value.enthalpy.value(); }); + }); + const auto potential = backend::project_scalar(density_space, [&](const double r) -> projection::RadialValue { + return field(r).transform([](const auto& value) { return value.potential.value(); }); + }); + const auto gravity = backend::project_vector(gravity_space, [&](const double r) -> projection::RadialValue { + return field(r).transform([](const auto& value) { return value.gravity.value(); }); + }); + REQUIRE(density); + REQUIRE(enthalpy); + REQUIRE(potential); + REQUIRE(gravity); + mfem::ConstantCoefficient exact_density(2.0); + mfem::FunctionCoefficient exact_enthalpy([=](const mfem::Vector& point) { + return central_enthalpy * (1.0 - (point * point) / (radius * radius)); + }); + mfem::FunctionCoefficient exact_potential([=](const mfem::Vector& point) { + return potential_at_surface - central_enthalpy * (1.0 - (point * point) / (radius * radius)); + }); + mfem::VectorFunctionCoefficient exact_gravity(3, [](const mfem::Vector& point, mfem::Vector& value) { + value = point; + value *= 4.0 * std::numbers::pi * 1.5 * 2.0 / 3.0; + }); + CHECK(density->ComputeL2Error(exact_density) < 1.0e-11); + CHECK(enthalpy->ComputeL2Error(exact_enthalpy) < 1.0e-9); + CHECK(potential->ComputeL2Error(exact_potential) < 1.0e-9); + CHECK(gravity->ComputeL2Error(exact_gravity) < 1.0e-9); +} + +TEST_CASE("Projection retains profile exterior choices and rejects invalid samples atomically", "[unit][projection]") { + mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(2, 2, 2, mfem::Element::HEXAHEDRON); + mfem::H1_FECollection h1(1, 3); + mfem::FiniteElementSpace scalar(&mesh, &h1), vector(&mesh, &h1, 3); + const auto exterior = backend::project_scalar(scalar, [](const double radius) { + return radius > 0.25 ? -7.0 : 3.0; + }); + REQUIRE(exterior); + CHECK(exterior->Min() == -7.0); + CHECK(exterior->Max() == 3.0); + for (const double invalid : {std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity()}) { + CHECK_FALSE(backend::project_scalar(scalar, [invalid](const double) { return invalid; })); + CHECK_FALSE(backend::project_vector(vector, [invalid](const double) { return invalid; })); + CHECK_FALSE(backend::project_scalar(scalar, [](const double r) { return r; }, {.center = {invalid, 0.0, 0.0}})); + } + CHECK_FALSE(backend::project_vector(vector, [](const double) { return 1.0; })); + int successful_samples = 0; + const auto failure = backend::project_scalar(scalar, [&](const double r) -> projection::RadialValue { + if (r > 0.5) { + return std::unexpected("profile is only available inside radius 0.5"); + } + ++successful_samples; + return 2.0; + }); + REQUIRE_FALSE(failure); + CHECK(successful_samples > 0); + CHECK(failure.error().find("profile is only available") != std::string::npos); + const auto recovered = backend::project_scalar(scalar, [](const double) { return 4.0; }); + REQUIRE(recovered); + CHECK(recovered->Min() == 4.0); + CHECK(recovered->Max() == 4.0); + CHECK_THROWS_AS(backend::project_scalar(vector, [](const double r) { return r; }), std::invalid_argument); + CHECK_THROWS_AS(backend::project_vector(scalar, [](const double r) { return r; }), std::invalid_argument); +} + +TEST_CASE("Projected n one polytropes converge to their analytic radial fields", "[unit][projection][laneemden]") { + const auto solution = laneemden::solve(1.0, {.step = 0.002}); + REQUIRE(solution); + const auto scales = laneemden::scale_from_mass(*solution, + dimensions::QuantityValue{2.0}, + dimensions::QuantityValue{1.0}, + dimensions::QuantityValue{1.0}); + REQUIRE(scales); + const double radial_scale = scales->radial_scale; + double coarse_error = 0.0; + for (const int count : {2, 4}) { + const double extent = scales->radius.value() / std::sqrt(3.0); + mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(count, count, count, mfem::Element::HEXAHEDRON, extent, extent, extent); + mfem::H1_FECollection h1(2, 3); + mfem::FiniteElementSpace space(&mesh, &h1); + const auto density = backend::project_scalar(space, [&](const double radius) -> projection::RadialValue { + return laneemden::evaluate_physical(*solution, *scales, dimensions::QuantityValue{radius}) + .transform([](const auto& value) { return value.density.value(); }); + }); + REQUIRE(density); + mfem::FunctionCoefficient exact([radial_scale](const mfem::Vector& point) { + const double xi = point.Norml2() / radial_scale; + if (xi == 0.0) { + return 1.0; + } + return std::sin(xi) / xi; + }); + const double error = density->ComputeL2Error(exact); + if (count == 2) { + coarse_error = error; + CHECK(coarse_error > 1.0e-7); + } else { + CHECK(error < coarse_error / 6.0); + CHECK(error < 2.0e-4 * std::pow(extent, 1.5)); + } + } +} + +TEST_CASE("Radial projection completes the compactified stellar mesh with explicit finite limits", "[unit][projection][laneemden][infinity]") { + auto generated = serif::tests::deformation::make_mesh(0); + mfem::H1_FECollection h1(2, 3); + mfem::FiniteElementSpace scalar_space(generated.mesh.get(), &h1); + mfem::FiniteElementSpace vector_space(generated.mesh.get(), &h1, 3); + mfem::GridFunction displacement(&vector_space); + displacement = 0.0; + mapping::DomainMapper mapper( + {.dimension = 3, .vacuum_element_attribute_ID = 3}, + std::make_unique( + serif::utils::user_options::KelvinCompactificationOptions{.r_star_ref = 1.0, .r_inf_ref = 4.0} + ) + ); + mapping::GridFunctionMappingEvaluator evaluator(mapper, displacement, *generated.exterior_coordinate->values); + const auto solution = laneemden::solve(1.0, {.step = 0.002}); + REQUIRE(solution); + // R = 1/2 leaves the entire curved exterior mesh in the analytic vacuum region. + const auto scales = laneemden::scale_from_mass(*solution, + dimensions::MassValue{2.0}, dimensions::DensityValue{4.0 * std::numbers::pi}, + dimensions::QuantityValue{0.7}); + REQUIRE(scales); + const double gravitational_mass = 0.7 * scales->mass.value(); + const auto fields = [&](double radius) { + return laneemden::evaluate_physical(*solution, *scales, dimensions::QuantityValue{radius}); + }; + int scalar_limits = 0; + int vector_limits = 0; + const auto scalar_limit = [&](const mfem::Vector&) -> projection::RadialValue { + ++scalar_limits; + return 0.0; + }; + const auto vector_limit = [&](const mfem::Vector& point) -> std::expected { + ++vector_limits; + mfem::Vector value(point); + value *= gravitational_mass / std::pow(point.Norml2(), 3); + return value; + }; + const auto potential = backend::project_scalar(scalar_space, + [&](double radius) -> projection::RadialValue { + return fields(radius).transform([](const laneemden::PhysicalSample& sample) { return sample.potential.value(); }); + }, {.mapping = &evaluator, .scalar_at_infinity = scalar_limit}); + const auto gravity = backend::project_vector(vector_space, + [&](double radius) -> projection::RadialValue { + return fields(radius).transform([](const laneemden::PhysicalSample& sample) { return sample.gravity.value(); }); + }, {.mapping = &evaluator, .vector_at_infinity = vector_limit}); + REQUIRE(potential); + REQUIRE(gravity); + CHECK(scalar_limits > 0); + CHECK(vector_limits > 0); + mfem::Array infinity_marker(generated.mesh->bdr_attributes.Max()); + infinity_marker = 0; + infinity_marker[serif::tests::deformation::PairedMesh::infinity_attribute - 1] = 1; + mfem::Array infinity_dofs; + scalar_space.GetEssentialTrueDofs(infinity_marker, infinity_dofs); + REQUIRE(infinity_dofs.Size() > 0); + for (const int dof : infinity_dofs) { + CHECK((*potential)[dof] == 0.0); + } + + CHECK(gravity->Norml2() > 0.0); +} + +TEST_CASE("Compactified projection validates explicit limits before publishing", "[unit][projection][infinity]") { + mfem::Mesh mesh = mfem::Mesh::MakeCartesian3D(1, 1, 1, mfem::Element::HEXAHEDRON); + mesh.GetElement(0)->SetAttribute(3); + mesh.SetAttributes(); + mfem::H1_FECollection h1(1, 3); + mfem::FiniteElementSpace scalar_space(&mesh, &h1), vector_space(&mesh, &h1, 3); + mfem::GridFunction displacement(&vector_space), compactification(&scalar_space); + displacement = 0.0; + compactification = 1.0; + mapping::DomainMapper mapper( + {.dimension = 3, .vacuum_element_attribute_ID = 3}, + std::make_unique( + serif::utils::user_options::KelvinCompactificationOptions{.r_star_ref = 1.0, .r_inf_ref = 4.0} + ) + ); + mapping::GridFunctionMappingEvaluator evaluator(mapper, displacement, compactification); + int ordinary_samples = 0; + const auto profile = [&](double) { + ++ordinary_samples; + return 1.0; + }; + CHECK_FALSE(backend::project_scalar(scalar_space, profile, {.mapping = &evaluator})); + CHECK_FALSE(backend::project_vector(vector_space, profile, {.mapping = &evaluator})); + for (double invalid : {std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity()}) { + CHECK_FALSE(backend::project_scalar(scalar_space, profile, {.mapping = &evaluator, + .scalar_at_infinity = [invalid](const mfem::Vector&) -> projection::RadialValue { return invalid; }})); + CHECK_FALSE(backend::project_vector(vector_space, profile, {.mapping = &evaluator, + .vector_at_infinity = [invalid](const mfem::Vector&) -> std::expected { + mfem::Vector value(3); + value = invalid; + return value; + }})); + } + const auto rejected_scalar = backend::project_scalar(scalar_space, profile, {.mapping = &evaluator, + .scalar_at_infinity = [](const mfem::Vector&) -> projection::RadialValue { + return std::unexpected("scalar asymptote unavailable"); + }}); + const auto rejected_vector = backend::project_vector(vector_space, profile, {.mapping = &evaluator, + .vector_at_infinity = [](const mfem::Vector&) -> std::expected { + return std::unexpected("vector asymptote unavailable"); + }}); + REQUIRE_FALSE(rejected_scalar); + REQUIRE_FALSE(rejected_vector); + CHECK(rejected_scalar.error().find("scalar asymptote unavailable") != std::string::npos); + CHECK(rejected_vector.error().find("vector asymptote unavailable") != std::string::npos); + CHECK_FALSE(backend::project_vector(vector_space, profile, {.mapping = &evaluator, + .vector_at_infinity = [](const mfem::Vector&) -> std::expected { + mfem::Vector value(2); + value = 0.0; + return value; + }})); + const auto recovered = backend::project_scalar(scalar_space, profile, {.mapping = &evaluator, + .scalar_at_infinity = [](const mfem::Vector& point) -> projection::RadialValue { return point[0]; }}); + REQUIRE(recovered); + CHECK(recovered->Min() == 0.0); + CHECK(recovered->Max() == 1.0); + CHECK(ordinary_samples == 0); +}