diff --git a/include/fesa/analysis/eigen_analysis_state.h b/include/fesa/analysis/eigen_analysis_state.h new file mode 100644 index 0000000..9229144 --- /dev/null +++ b/include/fesa/analysis/eigen_analysis_state.h @@ -0,0 +1,78 @@ +#ifndef FESA_ANALYSIS_EIGEN_ANALYSIS_STATE_H_ +#define FESA_ANALYSIS_EIGEN_ANALYSIS_STATE_H_ + +#include +#include +#include +#include +#include + +#include "fesa/math/matrix.h" +#include "fesa/solvers/eigen/mkl_feast_eigen_solver.h" + +namespace fesa { + +/// @brief Identifies one requested mode without conflating equal eigenvalues. +struct EigenMode { + std::size_t mode_id; + double eigenvalue; + std::uint8_t classification; + std::size_t cluster_id; + std::optional frequency; +}; + +/// @brief Records the analytic mass split at one source-ordered node. +struct EigenNodalReductionRecord { + std::uint8_t free_count; + std::uint8_t null_count; + std::uint8_t positive_count; + bool rcond_available; + double rcond_inf; +}; + +/// @brief Stores independently evaluated evidence for every computed pair. +struct EigenpairCheck { + std::size_t sorted_rank; + std::size_t solver_column; + double eigenvalue; + double modal_mass; + double rayleigh_quotient; + double eta_r; + double eta_w; + double rho_b; + double eta_norm; + double eta_energy; + bool backend_res_available; + double backend_res; +}; + +/// @brief Owns a validated modal candidate independently of static state. +/// @note Vectors are requested-mode by full source DOF; they are not response +/// displacements. Backend work vectors are released after all-c validation. +struct EigenAnalysisState { + explicit EigenAnalysisState(FeastEigenResult backend_result) + : backend{std::move(backend_result)} {} + + std::size_t full_dof_count{0U}; + std::size_t free_dof_count{0U}; + std::size_t mass_null_dimension{0U}; + std::size_t finite_dimension{0U}; + double characteristic_length{0.0}; + double eta_gram{0.0}; + double max_eta_orth{0.0}; + bool null_checks_applicable{false}; + double eta_mz{0.0}; + double eta_kz{0.0}; + double eta_pz{0.0}; + std::vector constraint_mask; + std::vector prescribed_displacement; + std::vector nodal_reduction; + std::vector modes; + Matrix vectors{0U, 0U}; + std::vector eigenpair_checks; + FeastEigenResult backend; +}; + +} // namespace fesa + +#endif // FESA_ANALYSIS_EIGEN_ANALYSIS_STATE_H_ diff --git a/include/fesa/analysis/linear_frequency_procedure.h b/include/fesa/analysis/linear_frequency_procedure.h new file mode 100644 index 0000000..a6039b6 --- /dev/null +++ b/include/fesa/analysis/linear_frequency_procedure.h @@ -0,0 +1,20 @@ +#ifndef FESA_ANALYSIS_LINEAR_FREQUENCY_PROCEDURE_H_ +#define FESA_ANALYSIS_LINEAR_FREQUENCY_PROCEDURE_H_ + +#include "fesa/analysis/eigen_analysis_state.h" + +namespace fesa { + +class Domain; + +/// @brief Executes the supported linear MITC4 frequency procedure. +class LinearFrequencyProcedureExecutor final { + public: + /// @brief Builds, solves, and validates one frequency modal candidate. + /// @return Requested physical modes plus all-c validation evidence. + Result Execute(const Domain& domain) const; +}; + +} // namespace fesa + +#endif // FESA_ANALYSIS_LINEAR_FREQUENCY_PROCEDURE_H_ diff --git a/include/fesa/analysis/prepared_analysis.h b/include/fesa/analysis/prepared_analysis.h index 22d7b4b..b2a24d7 100644 --- a/include/fesa/analysis/prepared_analysis.h +++ b/include/fesa/analysis/prepared_analysis.h @@ -6,6 +6,7 @@ #include #include "fesa/analysis/analysis_state.h" +#include "fesa/analysis/eigen_analysis_state.h" #include "fesa/analysis/nonlinear_analysis_state.h" #include "fesa/core/diagnostic.h" #include "fesa/core/status.h" @@ -14,6 +15,8 @@ namespace fesa { class LinearStaticProcedureExecutor; +class LinearFrequencyProcedureExecutor; +class EigenResultsWriter; class NewtonIterationLogWriter; class NonlinearResultsWriter; class NonlinearStaticProcedureExecutor; @@ -47,7 +50,8 @@ struct NonlinearStaticExecutionResult { }; using PreparedAnalysisOutcome = - std::variant; + std::variant; /// @brief Selects the approved procedure from a prepared Domain. /// @pre The executors and log outlive this call. @@ -55,6 +59,7 @@ using PreparedAnalysisOutcome = Result ExecutePreparedAnalysis( const Domain& domain, LinearStaticProcedureExecutor& linear_executor, NonlinearStaticProcedureExecutor& nonlinear_executor, + const LinearFrequencyProcedureExecutor& frequency_executor, NewtonIterationLogWriter& log); /// @brief Writes an already computed outcome to its authoritative result file. @@ -64,7 +69,7 @@ Status FinalizePreparedAnalysisResult( const DerivedOutputPaths& paths, const Domain& domain, const PreparedAnalysisOutcome& outcome, const std::vector& diagnostics, ResultsWriter& linear_writer, - NonlinearResultsWriter& nonlinear_writer); + NonlinearResultsWriter& nonlinear_writer, EigenResultsWriter& eigen_writer); } // namespace fesa diff --git a/include/fesa/elements/mitc4_shell.h b/include/fesa/elements/mitc4_shell.h index 58ec2c0..ec16e5e 100644 --- a/include/fesa/elements/mitc4_shell.h +++ b/include/fesa/elements/mitc4_shell.h @@ -98,6 +98,14 @@ struct Mitc4Stiffness { double drilling_stiffness; }; +/// @brief Stores consistent scalar moments and their derived MITC4 masses. +struct Mitc4Mass { + std::array consistent_moments; + Matrix consistent_global24; + Matrix lumped_global24; + std::array, 4> nodal_moments; +}; + /// @brief Stores physical shell recovery at one midsurface Gauss location. struct Mitc4PhysicalRecoveryPoint { std::array natural_coordinates; @@ -187,6 +195,11 @@ class Mitc4Shell final : public Element { /// @return Finite stiffness matrices or a structured model failure. [[nodiscard]] Result Stiffness() const; + /// @brief Computes consistent and scalar-row-sum lumped MITC4 mass. + /// @param density Constant material density in mass per volume. + /// @return Finite mass matrices or a structured model failure. + [[nodiscard]] Result Mass(double density) const; + /// @brief Recovers physical shell quantities without drilling results. /// @param global_element_displacement24 Global element DOFs in node order. /// @return Physical recovery rows or a structured model failure. diff --git a/include/fesa/io/hdf5/eigen_hdf5_results_writer.h b/include/fesa/io/hdf5/eigen_hdf5_results_writer.h new file mode 100644 index 0000000..cc4d94a --- /dev/null +++ b/include/fesa/io/hdf5/eigen_hdf5_results_writer.h @@ -0,0 +1,24 @@ +#ifndef FESA_IO_HDF5_EIGEN_HDF5_RESULTS_WRITER_H_ +#define FESA_IO_HDF5_EIGEN_HDF5_RESULTS_WRITER_H_ + +#include +#include + +#include "fesa/results/eigen_results_writer.h" + +namespace fesa { + +/// @brief Atomically writes validated linear-frequency HDF5 results. +class EigenHdf5ResultsWriter final : public EigenResultsWriter { + public: + /// @brief Serializes an accepted modal state after closed-file self-check. + /// @return An output failure when the candidate cannot be validated or + /// finalized; an existing final file is then preserved. + Status Write(const std::filesystem::path& output_path, const Domain& domain, + const EigenAnalysisState& state, + const std::vector& diagnostics) override; +}; + +} // namespace fesa + +#endif // FESA_IO_HDF5_EIGEN_HDF5_RESULTS_WRITER_H_ diff --git a/include/fesa/model/domain.h b/include/fesa/model/domain.h index 21e6a0e..42819ee 100644 --- a/include/fesa/model/domain.h +++ b/include/fesa/model/domain.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -47,7 +48,7 @@ class DomainCollectionView { std::vector entries_; }; -/// @brief Owns one immutable static-step semantic definition. +/// @brief Owns one immutable static or frequency-step semantic definition. /// @note Loads retain source order and are owned polymorphically by unique /// pointers. class StepDefinition { @@ -84,6 +85,12 @@ class StepDefinition { /// @brief Reports whether this step uses the nonlinear static procedure. bool IsNonlinearStatic() const noexcept; + /// @brief Reports whether this step defines the frequency procedure. + bool IsFrequency() const noexcept; + + /// @brief Returns frequency source semantics when this is a frequency step. + const FrequencyStepDefinition* Frequency() const noexcept; + /// @brief Reports the source NLGEOM setting for this static step. bool UsesNonlinearGeometry() const noexcept; @@ -108,6 +115,9 @@ class StepDefinition { /// @brief Converts one parsed static-step record to owned semantic objects. explicit StepDefinition(StaticStepDefinition definition); + /// @brief Converts one parsed frequency-step record to owned semantics. + explicit StepDefinition(FrequencyStepDefinition definition); + std::string name_; std::vector> boundary_conditions_; BoundaryConditionView boundary_conditions_view_; @@ -124,6 +134,7 @@ class StepDefinition { double minimum_increment_; double maximum_increment_; SourceLocation location_; + std::optional frequency_; }; /// @brief Owns the complete immutable semantic model definition. @@ -184,9 +195,15 @@ class Domain { /// @brief Returns assembly element surfaces in stable declaration order. const std::vector& ElementSurfaces() const noexcept; - /// @brief Returns static steps in stable declaration order. + /// @brief Returns procedure steps in stable declaration order. const DomainCollectionView& Steps() const noexcept; + /// @brief Returns resolved density records in stable material-index order. + const std::vector& Densities() const noexcept; + + /// @brief Returns the sole frequency source definition when present. + const FrequencyStepDefinition* Frequency() const noexcept; + /// @brief Returns sorted nonfatal mapping diagnostics. const std::vector& Warnings() const noexcept; diff --git a/include/fesa/model/model_types.h b/include/fesa/model/model_types.h index 75e509a..d6ca825 100644 --- a/include/fesa/model/model_types.h +++ b/include/fesa/model/model_types.h @@ -117,18 +117,15 @@ struct StaticStepDefinition { std::vector step_boundaries, std::vector step_loads, std::vector step_surface_pressure_loads, - StaticProcedureKind step_procedure_kind, - bool step_nlgeom, - double step_initial_increment, - double step_time_period, - double step_minimum_increment, - double step_maximum_increment, + StaticProcedureKind step_procedure_kind, bool step_nlgeom, + double step_initial_increment, double step_time_period, + double step_minimum_increment, double step_maximum_increment, SourceLocation step_location) - : name{std::move(step_name)}, - boundaries{std::move(step_boundaries)}, - loads{std::move(step_loads)}, - surface_pressure_loads{std::move(step_surface_pressure_loads)}, - procedure_kind{step_procedure_kind}, + : name{std::move(step_name)}, + boundaries{std::move(step_boundaries)}, + loads{std::move(step_loads)}, + surface_pressure_loads{std::move(step_surface_pressure_loads)}, + procedure_kind{step_procedure_kind}, nlgeom{step_nlgeom}, initial_increment{step_initial_increment}, time_period{step_time_period}, @@ -141,35 +138,30 @@ struct StaticStepDefinition { std::string step_name, std::vector step_boundaries, std::vector step_loads, - StaticProcedureKind step_procedure_kind, - bool step_nlgeom, - double step_initial_increment, - double step_time_period, - double step_minimum_increment, - double step_maximum_increment, + StaticProcedureKind step_procedure_kind, bool step_nlgeom, + double step_initial_increment, double step_time_period, + double step_minimum_increment, double step_maximum_increment, SourceLocation step_location) - : StaticStepDefinition( - std::move(step_name), std::move(step_boundaries), - std::move(step_loads), {}, step_procedure_kind, step_nlgeom, - step_initial_increment, step_time_period, step_minimum_increment, - step_maximum_increment, std::move(step_location)) {} + : StaticStepDefinition(std::move(step_name), std::move(step_boundaries), + std::move(step_loads), {}, step_procedure_kind, + step_nlgeom, step_initial_increment, + step_time_period, step_minimum_increment, + step_maximum_increment, std::move(step_location)) { + } /// @brief Creates a legacy linear static step definition. StaticStepDefinition( std::string step_name, std::vector step_boundaries, - std::vector step_loads, - double step_initial_increment, - double step_time_period, - double step_minimum_increment, - double step_maximum_increment, - SourceLocation step_location) - : StaticStepDefinition( - std::move(step_name), std::move(step_boundaries), - std::move(step_loads), - StaticProcedureKind::kLinearSmallDisplacement, false, - step_initial_increment, step_time_period, step_minimum_increment, - step_maximum_increment, std::move(step_location)) {} + std::vector step_loads, double step_initial_increment, + double step_time_period, double step_minimum_increment, + double step_maximum_increment, SourceLocation step_location) + : StaticStepDefinition(std::move(step_name), std::move(step_boundaries), + std::move(step_loads), + StaticProcedureKind::kLinearSmallDisplacement, + false, step_initial_increment, step_time_period, + step_minimum_increment, step_maximum_increment, + std::move(step_location)) {} std::string name; std::vector boundaries; @@ -184,6 +176,38 @@ struct StaticStepDefinition { SourceLocation location; }; +/// @brief Identifies the accepted source eigensolver selection. +enum class FrequencyEigensolver { kLanczos }; + +/// @brief Identifies the accepted modal normalization selection. +enum class FrequencyNormalization { kMass }; + +/// @brief Stores one resolved constant material mass density. +struct MaterialDensityDefinition { + EntityIndex material_index; + double rho; + SourceLocation location; +}; + +/// @brief Stores the sole supported frequency-step source semantics. +struct FrequencyStepDefinition { + std::string source_step_name_raw; + std::string source_step_name_canonical; + SourceLocation step_location; + SourceLocation frequency_location; + SourceLocation data_location; + std::int64_t requested_mode_count; + bool nlgeom_explicit; + bool perturbation_explicit; + bool eigensolver_explicit; + bool sim_explicit; + bool acoustic_coupling_explicit; + bool normalization_explicit; + FrequencyEigensolver eigensolver; + FrequencyNormalization normalization; + std::vector boundaries; +}; + /// @brief Stores a stable resolved node-set membership list. struct NodeSet { std::string name; @@ -245,6 +269,8 @@ struct ModelDefinition { std::vector instances; std::vector element_surfaces; std::vector steps; + std::vector densities; + std::optional frequency_step; std::vector warnings; }; diff --git a/include/fesa/results/eigen_results_writer.h b/include/fesa/results/eigen_results_writer.h new file mode 100644 index 0000000..64e8d4a --- /dev/null +++ b/include/fesa/results/eigen_results_writer.h @@ -0,0 +1,29 @@ +#ifndef FESA_RESULTS_EIGEN_RESULTS_WRITER_H_ +#define FESA_RESULTS_EIGEN_RESULTS_WRITER_H_ + +#include +#include + +#include "fesa/analysis/eigen_analysis_state.h" +#include "fesa/core/diagnostic.h" +#include "fesa/core/status.h" + +namespace fesa { + +class Domain; + +/// @brief Isolates authoritative modal storage from procedure execution. +class EigenResultsWriter { + public: + virtual ~EigenResultsWriter() = default; + + /// @brief Validates and atomically publishes the complete modal candidate. + /// @return Success only after closed-file validation and final replacement. + virtual Status Write(const std::filesystem::path& output_path, + const Domain& domain, const EigenAnalysisState& state, + const std::vector& diagnostics) = 0; +}; + +} // namespace fesa + +#endif // FESA_RESULTS_EIGEN_RESULTS_WRITER_H_ diff --git a/include/fesa/solvers/eigen/mkl_feast_eigen_solver.h b/include/fesa/solvers/eigen/mkl_feast_eigen_solver.h new file mode 100644 index 0000000..7efbcc5 --- /dev/null +++ b/include/fesa/solvers/eigen/mkl_feast_eigen_solver.h @@ -0,0 +1,83 @@ +#ifndef FESA_SOLVERS_EIGEN_MKL_FEAST_EIGEN_SOLVER_H_ +#define FESA_SOLVERS_EIGEN_MKL_FEAST_EIGEN_SOLVER_H_ + +#include +#include +#include +#include + +#include "fesa/core/status.h" +#include "fesa/math/matrix.h" + +namespace fesa { + +class SparseMatrix; + +/// @brief Records one deterministic private-backend invocation. +struct FeastBackendCall { + std::size_t ordinal; + std::string kind; + std::string purpose; + double lower; + double upper; + std::size_t allocated_m0; + int info; + bool count_available; + std::size_t count; + bool loop_available; + int loop; + bool epsout_available; + double epsout; + std::string outcome; + std::string decision_reason; +}; + +/// @brief Owns the complete validated FEAST interval result before public N +/// selection. +struct FeastEigenResult { + std::size_t requested_modes; + std::vector eigenvalues; + Matrix eigenvectors; + double operator_scale; + std::vector column_ids; + std::vector backend_res_available; + std::vector backend_res; + std::vector normalized_eigenvalues; + std::vector whitened_residuals; + double gram_error; + std::vector backend_calls; + std::vector base_fpm; + std::string runtime_version; + std::string integer_abi; + std::size_t thread_count; + std::uint64_t seed; + double enclosure_lower; + double enclosure_upper; + double solve_lower; + double solve_upper; + std::string upper_search_termination; +}; + +/// @brief Solves a whitened real-symmetric eigenproblem with oneMKL FEAST. +class MklFeastEigenSolver final { + public: + /// @brief Computes all validated interval pairs for a requested lowest-mode + /// prefix. + /// @param whitened_operator Symmetric physical-lambda operator after + /// whitening. + /// @param requested_modes Number of lowest modes eventually exposed by the + /// caller. + /// @param zero_operator_scale Positive physical scale used only when A is + /// exactly zero. + /// @return Sorted all-c eigenpairs in physical lambda units or a solver + /// failure. + /// @note The implementation fixes oneMKL local thread count to one during + /// every call. + Result Solve(const SparseMatrix& whitened_operator, + std::size_t requested_modes, + double zero_operator_scale) const; +}; + +} // namespace fesa + +#endif // FESA_SOLVERS_EIGEN_MKL_FEAST_EIGEN_SOLVER_H_ diff --git a/src/fesa/CMakeLists.txt b/src/fesa/CMakeLists.txt index b6e94bf..f96b4ab 100644 --- a/src/fesa/CMakeLists.txt +++ b/src/fesa/CMakeLists.txt @@ -3,6 +3,8 @@ add_library( STATIC analysis/analysis_model.cpp analysis/analysis_state.cpp + analysis/eigen_reduction.cpp + analysis/linear_frequency_procedure.cpp analysis/linear_static_analysis.cpp analysis/linear_static_procedure.cpp analysis/newton_iteration_log.cpp @@ -42,6 +44,8 @@ add_library( io/hdf5/hdf5_primitives.cpp io/hdf5/hdf5_result_writer.cpp io/hdf5/hdf5_results_writer.cpp + io/hdf5/eigen_hdf5_results_writer.cpp + io/hdf5/eigen_hdf5_self_check.cpp io/hdf5/hdf5_self_check.cpp loads/concentrated_nodal_load.cpp loads/shell_surface_pressure.cpp @@ -61,6 +65,7 @@ add_library( results/recovery_candidate.cpp results/result_recovery.cpp results/shell_result_recovery.cpp + solvers/eigen/mkl_feast_eigen_solver.cpp solvers/linear/mkl_pardiso_solver.cpp solvers/linear/mkl_pardiso_tangent_solver.cpp ) diff --git a/src/fesa/analysis/eigen_reduction.cpp b/src/fesa/analysis/eigen_reduction.cpp new file mode 100644 index 0000000..c02514e --- /dev/null +++ b/src/fesa/analysis/eigen_reduction.cpp @@ -0,0 +1,411 @@ +#include "analysis/eigen_reduction.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "fesa/fem/dof_manager.h" + +namespace fesa::eigen_internal { +namespace { + +constexpr double kAlgebraLimit = 1e-12; +constexpr double kConditionLimit = 0x1p-32; + +Status Failure(const std::string& code, const std::size_t node, + const std::string& message) { + return Status::Failure(FailureCategory::kSolver, {{Severity::kError, + code, + {}, + "FREQUENCY", + std::to_string(node), + message}}); +} + +double Norm(const Matrix& matrix) { + double norm = 0.0; + for (std::size_t i = 0; i < matrix.Rows(); ++i) { + for (std::size_t j = 0; j < matrix.Columns(); ++j) { + norm = std::hypot(norm, matrix(i, j)); + } + } + return norm; +} + +double Ratio(const double numerator, const double denominator) { + if (denominator > 0.0 && std::isfinite(denominator)) { + return numerator / denominator; + } + return numerator == 0.0 && denominator == 0.0 + ? 0.0 + : (std::numeric_limits::infinity)(); +} + +double Scale(const std::size_t component, const double length) { + return component < 3U ? length : 1.0; +} + +Matrix ScaledMass(const Matrix& mass, const double length) { + Matrix scaled(6U, 6U); + for (std::size_t i = 0; i < 6U; ++i) { + for (std::size_t j = 0; j < 6U; ++j) { + scaled(i, j) = Scale(i, length) * mass(i, j) * Scale(j, length); + } + } + return scaled; +} + +Result MakeLift(const Matrix& mass, + const ShellNodeInitialFrame& frame, + const std::vector& mask, + const double length, const std::size_t offset) { + const std::size_t node = frame.node_index; + std::vector translations; + std::vector rotations; + bool has_null = true; + for (std::size_t c = 0; c < 6U; ++c) { + if (mask[6U * node + c] == 0U) { + (c < 3U ? translations : rotations).push_back(c); + } else if (c >= 3U && frame.director[c - 3U] != 0.0) { + has_null = false; + } + } + has_null = has_null && !rotations.empty(); + const std::size_t free = translations.size() + rotations.size(); + const std::size_t positive = free - static_cast(has_null); + NodalLift result; + result.offset = offset; + result.record = {static_cast(free), + static_cast(has_null), + static_cast(positive), positive != 0U, 0.0}; + result.positive_basis = Matrix(6U, positive); + auto& basis = result.positive_basis; + std::size_t column = 0U; + for (const auto c : translations) basis(c, column++) = 1.0; + if (!has_null) { + for (const auto c : rotations) basis(c, column++) = 1.0; + } else { + for (std::size_t c = 0; c < 3U; ++c) { + result.null_vector[c + 3U] = frame.director[c]; + } + if (rotations.size() == 3U) { + for (std::size_t c = 0; c < 3U; ++c) { + basis(c + 3U, column) = frame.tangent_a[c]; + basis(c + 3U, column + 1U) = frame.tangent_b[c]; + } + } else if (rotations.size() == 2U) { + const auto a = rotations[0]; + const auto b = rotations[1]; + const double norm = + std::hypot(frame.director[a - 3U], frame.director[b - 3U]); + basis(a, column) = -frame.director[b - 3U] / norm; + basis(b, column) = frame.director[a - 3U] / norm; + const auto pivot = + std::abs(basis(a, column)) >= std::abs(basis(b, column)) ? a : b; + if (basis(pivot, column) < 0.0) { + basis(a, column) = -basis(a, column); + basis(b, column) = -basis(b, column); + } + } + } + result.scaled_lift = Matrix(6U, positive); + if (positive == 0U) return Result::Success(std::move(result)); + + const Matrix scaled = ScaledMass(mass, length); + Matrix mp(positive, positive); + double scale = 0.0; + double basis_error = 0.0; + for (std::size_t i = 0; i < positive; ++i) { + for (std::size_t j = 0; j < positive; ++j) { + double gram = 0.0; + for (std::size_t a = 0; a < 6U; ++a) { + gram += basis(a, i) * basis(a, j); + for (std::size_t b = 0; b < 6U; ++b) { + mp(i, j) += basis(a, i) * scaled(a, b) * basis(b, j); + } + } + basis_error = std::hypot(basis_error, gram - (i == j ? 1.0 : 0.0)); + scale = (std::max)(scale, std::abs(mp(i, j))); + } + } + if (!(basis_error <= kAlgebraLimit) || !(scale > 0.0) || + !std::isfinite(scale)) { + return Result::Failure(Failure("invalid-eigen-mass-space", node, + "Invalid positive mass basis.")); + } + Matrix lower(positive, positive); + for (std::size_t i = 0; i < positive; ++i) { + for (std::size_t j = 0; j <= i; ++j) { + double value = mp(i, j) / scale; + for (std::size_t k = 0; k < j; ++k) value -= lower(i, k) * lower(j, k); + if (i == j) { + if (!(value > 0.0) || !std::isfinite(value)) { + return Result::Failure( + Failure("eigen-mass-conditioning", node, + "A positive-space mass pivot is not resolvable.")); + } + lower(i, j) = std::sqrt(value); + } else { + lower(i, j) = value / lower(j, j); + } + } + } + Matrix inverse_lower(positive, positive); + for (std::size_t j = 0; j < positive; ++j) { + for (std::size_t i = 0; i < positive; ++i) { + double value = i == j ? 1.0 : 0.0; + for (std::size_t k = 0; k < i; ++k) + value -= lower(i, k) * inverse_lower(k, j); + inverse_lower(i, j) = value / lower(i, i); + } + } + double norm_mp = 0.0; + double norm_inverse = 0.0; + double reconstruction = 0.0; + double scaled_mass_norm = 0.0; + double rebuilt_mass_norm = 0.0; + for (std::size_t i = 0; i < positive; ++i) { + double row_mp = 0.0; + double row_inverse = 0.0; + for (std::size_t j = 0; j < positive; ++j) { + double inverse = 0.0; + double rebuilt = 0.0; + for (std::size_t k = 0; k < positive; ++k) { + inverse += inverse_lower(k, i) * inverse_lower(k, j); + rebuilt += lower(i, k) * lower(j, k); + } + row_inverse += std::abs(inverse); + row_mp += std::abs(mp(i, j) / scale); + reconstruction = std::hypot(reconstruction, rebuilt - mp(i, j) / scale); + scaled_mass_norm = std::hypot(scaled_mass_norm, mp(i, j) / scale); + rebuilt_mass_norm = std::hypot(rebuilt_mass_norm, rebuilt); + } + norm_mp = (std::max)(norm_mp, row_mp); + norm_inverse = (std::max)(norm_inverse, row_inverse); + } + result.record.rcond_inf = (1.0 / norm_mp) / norm_inverse; + if (!(result.record.rcond_inf >= kConditionLimit) || + !(Ratio(reconstruction, scaled_mass_norm + rebuilt_mass_norm) <= + kAlgebraLimit)) { + return Result::Failure(Failure( + "eigen-mass-conditioning", node, + "Nodal mass fails rcond_inf >= 2^-32 or Cholesky reconstruction.")); + } + const double root_scale = std::sqrt(scale); + for (std::size_t c = 0; c < 6U; ++c) { + for (std::size_t j = 0; j < positive; ++j) { + for (std::size_t k = 0; k < positive; ++k) { + result.scaled_lift(c, j) += + basis(c, k) * inverse_lower(j, k) / root_scale; + } + if (!std::isfinite(result.scaled_lift(c, j))) { + return Result::Failure(Failure( + "eigen-mass-conditioning", node, "Nodal whitening is nonfinite.")); + } + } + } + return Result::Success(std::move(result)); +} + +} // namespace + +Result BuildEigenReduction( + const SparseMatrix& stiffness, const std::vector& mass, + const std::vector& frames, + const std::vector& mask, const std::vector& drilling, + const double length) { + const std::size_t count = mass.size(); + const Status valid = stiffness.Validate(); + if (!valid.IsOk()) return Result::Failure(valid); + if (count > (std::numeric_limits::max)() / 6U || + stiffness.Rows() != 6U * count || stiffness.Columns() != 6U * count || + frames.size() != count || drilling.size() != count || + mask.size() != 6U * count || !(length > 0.0) || !std::isfinite(length)) { + return Result::Failure(Failure( + "invalid-eigen-mass-space", 0U, "Incompatible mass-space inventory.")); + } + std::vector lifts; + std::size_t dimension = 0U; + std::size_t free_count = 0U; + std::size_t null_count = 0U; + double mass_norm = 0.0; + double mass_null_error = 0.0; + double null_norm = 0.0; + double expected_kz_norm = 0.0; + double pz_norm = 0.0; + for (std::size_t node = 0; node < count; ++node) { + if (mass[node].Rows() != 6U || mass[node].Columns() != 6U || + frames[node].node_index != node || !(drilling[node] > 0.0) || + !std::isfinite(drilling[node])) { + return Result::Failure( + Failure("invalid-eigen-mass-space", node, + "Invalid nodal mass or drill inventory.")); + } + const auto scaled = ScaledMass(mass[node], length); + double symmetry = 0.0; + double director_norm = 0.0; + for (const auto value : frames[node].director) + director_norm = std::hypot(director_norm, value); + for (std::size_t a = 0; a < 6U; ++a) { + if (mask[6U * node + a] > 1U) { + return Result::Failure( + Failure("invalid-eigen-mass-space", node, + "Constraint mask must be binary.")); + } + for (std::size_t b = 0; b < 6U; ++b) { + if (!std::isfinite(scaled(a, b))) { + return Result::Failure( + Failure("invalid-eigen-mass-space", node, "Mass is nonfinite.")); + } + symmetry = std::hypot(symmetry, scaled(a, b) - scaled(b, a)); + if (mask[6U * node + a] == 0U && mask[6U * node + b] == 0U) { + mass_norm = std::hypot(mass_norm, scaled(a, b)); + } + } + } + if (!(std::abs(director_norm - 1.0) <= kAlgebraLimit) || + !(Ratio(symmetry, Norm(scaled)) <= kAlgebraLimit)) { + return Result::Failure( + Failure("invalid-eigen-mass-space", node, + "Invalid director or mass symmetry.")); + } + auto lift = MakeLift(mass[node], frames[node], mask, length, dimension); + if (!lift.HasValue()) + return Result::Failure(lift.GetStatus()); + dimension += lift.Value().record.positive_count; + free_count += lift.Value().record.free_count; + null_count += lift.Value().record.null_count; + const auto& z = lift.Value().null_vector; + for (std::size_t a = 0; a < 6U; ++a) { + null_norm = std::hypot(null_norm, z[a]); + expected_kz_norm = std::hypot(expected_kz_norm, drilling[node] * z[a]); + if (mask[6U * node + a] != 0U) continue; + double action = 0.0; + for (std::size_t b = 0; b < 6U; ++b) action += scaled(a, b) * z[b]; + mass_null_error = std::hypot(mass_null_error, action); + } + for (std::size_t j = 0; j < lift.Value().positive_basis.Columns(); ++j) { + double dot = 0.0; + for (std::size_t a = 0; a < 6U; ++a) + dot += lift.Value().positive_basis(a, j) * z[a]; + pz_norm = std::hypot(pz_norm, dot); + } + lifts.push_back(std::move(lift.Value())); + } + if (dimension == 0U) { + return Result::Failure(Status::Failure( + FailureCategory::kModel, {{Severity::kError, + "empty-eigenproblem", + {}, + "FREQUENCY", + {}, + "No finite mass coordinates remain."}})); + } + double stiffness_norm = 0.0; + double kz_error = 0.0; + const auto& offsets = stiffness.RowOffsets(); + const auto& columns = stiffness.ColumnIndices(); + const auto& values = stiffness.Values(); + SparsePattern pattern; + pattern.row_offsets.push_back(0U); + std::vector contributions; + for (std::size_t node = 0; node < count; ++node) { + std::map blocks; + for (std::size_t a = 0; a < 6U; ++a) { + const auto row = node * 6U + a; + if (mask[row] != 0U) continue; + std::map null_action; + if (lifts[node].record.null_count != 0U) { + null_action[node] = -drilling[node] * lifts[node].null_vector[a]; + } + for (std::size_t pos = offsets[row]; pos < offsets[row + 1U]; ++pos) { + const auto col = columns[pos]; + if (mask[col] != 0U) continue; + const auto other = col / 6U; + const auto b = col % 6U; + const double value = Scale(a, length) * values[pos] * Scale(b, length); + if (!std::isfinite(value)) { + return Result::Failure( + Failure("invalid-eigen-mass-space", node, + "Scaled stiffness is nonfinite.")); + } + stiffness_norm = std::hypot(stiffness_norm, value); + null_action[other] += value * lifts[other].null_vector[b]; + auto block = blocks.try_emplace(other, 6U, 6U).first; + block->second(a, b) = value; + } + for (const auto& item : null_action) + kz_error = std::hypot(kz_error, item.second); + } + const auto& left = lifts[node]; + for (std::size_t i = 0; i < left.scaled_lift.Columns(); ++i) { + for (const auto& entry : blocks) { + const auto& right = lifts[entry.first]; + for (std::size_t j = 0; j < right.scaled_lift.Columns(); ++j) { + double value = 0.0; + for (std::size_t a = 0; a < 6U; ++a) { + for (std::size_t b = 0; b < 6U; ++b) { + value += left.scaled_lift(a, i) * entry.second(a, b) * + right.scaled_lift(b, j); + } + } + pattern.column_indices.push_back(right.offset + j); + contributions.push_back({left.offset + i, right.offset + j, value, + node, pattern.column_indices.size() - 1U}); + } + } + pattern.row_offsets.push_back(pattern.column_indices.size()); + } + } + const double eta_mz = Ratio(mass_null_error, mass_norm * null_norm); + const double eta_kz = + Ratio(kz_error, stiffness_norm * null_norm + expected_kz_norm); + if (!(eta_mz <= kAlgebraLimit) || !(eta_kz <= kAlgebraLimit) || + !(pz_norm <= kAlgebraLimit)) { + return Result::Failure(Failure( + "invalid-eigen-mass-space", 0U, + "Original constrained MZ, KZ or PZ violates the analytic null split.")); + } + auto reduced = SparseMatrix::FromCoo(dimension, dimension, + std::move(contributions), pattern); + if (!reduced.HasValue()) + return Result::Failure(reduced.GetStatus()); + return Result::Success( + {std::move(reduced.Value()), std::move(lifts), free_count, null_count, + length, eta_mz, eta_kz, pz_norm}); +} + +Vector RestoreScaledMode(const EigenReduction& reduction, const Matrix& vectors, + const std::size_t column) { + Vector result(reduction.nodes.size() * 6U); + for (std::size_t node = 0; node < reduction.nodes.size(); ++node) { + const auto& lift = reduction.nodes[node]; + for (std::size_t a = 0; a < 6U; ++a) { + for (std::size_t j = 0; j < lift.scaled_lift.Columns(); ++j) { + result[6U * node + a] += + lift.scaled_lift(a, j) * vectors(lift.offset + j, column); + } + } + } + return result; +} + +Vector MultiplyNodalMass(const std::vector& mass, + const Vector& vector) { + Vector result(vector.Size()); + for (std::size_t node = 0; node < mass.size(); ++node) { + for (std::size_t a = 0; a < 6U; ++a) { + for (std::size_t b = 0; b < 6U; ++b) { + result[6U * node + a] += mass[node](a, b) * vector[6U * node + b]; + } + } + } + return result; +} + +} // namespace fesa::eigen_internal diff --git a/src/fesa/analysis/eigen_reduction.h b/src/fesa/analysis/eigen_reduction.h new file mode 100644 index 0000000..13ea6af --- /dev/null +++ b/src/fesa/analysis/eigen_reduction.h @@ -0,0 +1,57 @@ +#ifndef FESA_ANALYSIS_EIGEN_REDUCTION_H_ +#define FESA_ANALYSIS_EIGEN_REDUCTION_H_ + +#include +#include +#include +#include + +#include "fesa/analysis/eigen_analysis_state.h" +#include "fesa/core/status.h" +#include "fesa/math/matrix.h" +#include "fesa/math/sparse_matrix.h" +#include "fesa/math/vector.h" +#include "fesa/model/model_types.h" + +namespace fesa::eigen_internal { + +/// @brief Stores one constraint-first nodal lift from whitened coordinates. +struct NodalLift { + Matrix positive_basis{6U, 0U}; + Matrix scaled_lift{6U, 0U}; + std::array null_vector{}; + std::size_t offset{0U}; + EigenNodalReductionRecord record{}; +}; + +/// @brief Preserves the original sparse pencil and its checked nodal reduction. +struct EigenReduction { + SparseMatrix whitened_operator; + std::vector nodes; + std::size_t free_count; + std::size_t null_count; + double characteristic_length; + double eta_mz; + double eta_kz; + double eta_pz; +}; + +/// @brief Applies actual component constraints before analytic null removal. +Result BuildEigenReduction( + const SparseMatrix& full_stiffness, const std::vector& nodal_mass, + const std::vector& frames, + const std::vector& constraint_mask, + const std::vector& nodal_drilling_stiffness, + double characteristic_length); + +/// @brief Restores scaled global coordinates with constrained entries zero. +Vector RestoreScaledMode(const EigenReduction& reduction, + const Matrix& whitened_vectors, std::size_t column); + +/// @brief Applies the assembled nodal mass in physical global coordinates. +Vector MultiplyNodalMass(const std::vector& nodal_mass, + const Vector& vector); + +} // namespace fesa::eigen_internal + +#endif // FESA_ANALYSIS_EIGEN_REDUCTION_H_ diff --git a/src/fesa/analysis/linear_frequency_procedure.cpp b/src/fesa/analysis/linear_frequency_procedure.cpp new file mode 100644 index 0000000..1064c4e --- /dev/null +++ b/src/fesa/analysis/linear_frequency_procedure.cpp @@ -0,0 +1,427 @@ +#include "fesa/analysis/linear_frequency_procedure.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "analysis/eigen_reduction.h" +#include "fesa/analysis/analysis_model.h" +#include "fesa/assembly/parallel_for.h" +#include "fesa/assembly/sparse_assembler.h" +#include "fesa/elements/element_factory.h" +#include "fesa/elements/mitc4_shell.h" +#include "fesa/fem/dof_manager.h" +#include "fesa/math/sparse_matrix.h" + +namespace fesa { +namespace { + +Status Failure(const std::string& code, const std::string& identity, + const std::string& message) { + return Status::Failure( + FailureCategory::kSolver, + {{Severity::kError, code, {}, "FREQUENCY", identity, message}}); +} + +double Dot(const Vector& left, const Vector& right) { + double result = 0.0; + for (std::size_t index = 0U; index < left.Size(); ++index) { + result += left[index] * right[index]; + } + return result; +} + +double Norm(const Vector& vector) { + double result = 0.0; + for (std::size_t i = 0; i < vector.Size(); ++i) + result = std::hypot(result, vector[i]); + return result; +} + +double Ratio(double numerator, double denominator) { + if (std::isfinite(numerator) && std::isfinite(denominator) && + denominator > 0.0) + return numerator / denominator; + return numerator == 0.0 && denominator == 0.0 + ? 0.0 + : (std::numeric_limits::infinity)(); +} + +double D(std::size_t dof, double length) { + return dof % 6U < 3U ? length : 1.0; +} + +Status MetricFailure(const char* metric, double value, std::size_t column) { + std::ostringstream message; + message << std::setprecision(17) << metric << "=" << value << "; limit=1e-10"; + return Failure("eigenpair-validation-failed", + "solver-column=" + std::to_string(column), message.str()); +} + +double FrobeniusNorm(const SparseMatrix& matrix) { + double norm = 0.0; + for (const double value : matrix.Values()) norm = std::hypot(norm, value); + return norm; +} + +double MaximumEdgeLength(const Domain& domain) { + double maximum = 0.0; + for (std::size_t shell = 0U; shell < domain.ShellElements().Size(); ++shell) { + const auto& nodes = domain.ShellElements()[shell].NodeIndices(); + for (std::size_t edge = 0U; edge < 4U; ++edge) { + const auto& first = domain.Nodes()[nodes[edge]].coordinates; + const auto& second = domain.Nodes()[nodes[(edge + 1U) % 4U]].coordinates; + maximum = + (std::max)(maximum, std::hypot(std::hypot(first[0U] - second[0U], + first[1U] - second[1U]), + first[2U] - second[2U])); + } + } + return maximum; +} + +Result ExecuteCandidate(const Domain& domain) { + const FrequencyStepDefinition* frequency = domain.Frequency(); + if (frequency == nullptr || domain.Steps().Size() != 1U) { + return Result::Failure( + Failure("invalid-frequency-procedure", "step", + "The domain has no sole frequency step.")); + } + if (frequency->requested_mode_count <= 0) { + return Result::Failure( + Failure("invalid-frequency-request", "requested-mode-count", + "Frequency requested mode count must be positive.")); + } + Result model = AnalysisModel::Create(domain); + if (!model.HasValue()) + return Result::Failure(model.GetStatus()); + const ElementFactory factory; + std::vector> owned; + ElementView view; + owned.reserve(model.Value().ActiveElements().size()); + view.reserve(model.Value().ActiveElements().size()); + for (const EntityIndex index : model.Value().ActiveElements()) { + auto element = factory.Create(domain.Elements()[index], domain); + if (!element.HasValue()) + return Result::Failure(element.GetStatus()); + if (dynamic_cast(element.Value().get()) == nullptr) { + return Result::Failure(Failure( + "unsupported-frequency-element", std::to_string(index), + "The frequency procedure supports MITC4 shell elements only.")); + } + view.emplace_back(*element.Value()); + owned.push_back(std::move(element.Value())); + } + DofManager dofs; + const Status dof_status = dofs.Build(model.Value(), view); + if (!dof_status.IsOk()) + return Result::Failure(dof_status); + const SerialParallelFor serial; + auto stiffness = SparseAssembler::Assemble(view, dofs, serial); + if (!stiffness.HasValue()) + return Result::Failure(stiffness.GetStatus()); + + std::vector density(domain.Materials().Size(), 0.0); + for (const auto& item : domain.Densities()) { + if (item.material_index >= density.size() || + density[item.material_index] != 0.0 || !(item.rho > 0.0) || + !std::isfinite(item.rho)) { + return Result::Failure(Failure( + "invalid-material-density", std::to_string(item.material_index), + "Invalid or duplicate material density.")); + } + density[item.material_index] = item.rho; + } + for (std::size_t i = 0; i < dofs.PrescribedValues().Size(); ++i) { + if (dofs.PrescribedValues()[i] != 0.0) { + return Result::Failure( + Status::Failure(FailureCategory::kInput, + {{Severity::kError, + "unsupported-eigen-boundary", + frequency->frequency_location, + "BOUNDARY", + {}, + "Frequency requires homogeneous constraints."}})); + } + } + std::vector nodal_mass(domain.Nodes().size(), Matrix(6U, 6U)); + std::vector drilling(domain.Nodes().size(), 0.0); + for (std::size_t shell = 0U; shell < domain.ShellElements().Size(); ++shell) { + const auto& definition = domain.ShellElements()[shell]; + if (!(density[definition.MaterialIndex()] > 0.0)) { + return Result::Failure( + Failure("missing-frequency-density", + std::to_string(definition.MaterialIndex()), + "Every active shell material requires a positive density.")); + } + const auto* element = dynamic_cast(owned[shell].get()); + auto mass = element->Mass(density[definition.MaterialIndex()]); + auto shell_stiffness = element->Stiffness(); + if (!mass.HasValue()) + return Result::Failure(mass.GetStatus()); + if (!shell_stiffness.HasValue()) + return Result::Failure(shell_stiffness.GetStatus()); + for (std::size_t local_node = 0U; local_node < 4U; ++local_node) { + const std::size_t node = definition.NodeIndices()[local_node]; + drilling[node] += shell_stiffness.Value().drilling_stiffness; + for (std::size_t a = 0U; a < 6U; ++a) { + for (std::size_t b = 0U; b < 6U; ++b) { + nodal_mass[node](a, b) += mass.Value().lumped_global24( + 6U * local_node + a, 6U * local_node + b); + } + } + } + } + const double length = MaximumEdgeLength(domain); + std::vector mask(dofs.FullDofCount(), 0U); + for (const std::size_t index : dofs.ConstrainedDofs()) mask[index] = 1U; + auto reduction = eigen_internal::BuildEigenReduction( + stiffness.Value(), nodal_mass, domain.ShellNodeInitialFrames(), mask, + drilling, length); + if (!reduction.HasValue()) + return Result::Failure(reduction.GetStatus()); + const std::size_t request = + static_cast(frequency->requested_mode_count); + if (request > reduction.Value().whitened_operator.Rows()) { + return Result::Failure(Status::Failure( + FailureCategory::kModel, + {{Severity::kError, "insufficient-finite-modes", + frequency->frequency_location, "FREQUENCY", std::to_string(request), + "Requested modes exceed finite dimension " + + std::to_string(reduction.Value().whitened_operator.Rows())}})); + } + double zero_scale = (std::numeric_limits::infinity)(); + for (std::size_t e = 0; e < domain.ShellElements().Size(); ++e) { + const auto material_id = domain.ShellElements()[e].MaterialIndex(); + const auto* material = dynamic_cast( + &domain.Materials()[material_id]); + if (material == nullptr) + return Result::Failure( + Failure("invalid-eigenvalue-result", "material", + "Unsupported frequency material.")); + zero_scale = (std::min)(zero_scale, + (material->YoungsModulus() / density[material_id]) / + length / length); + } + auto backend = MklFeastEigenSolver{}.Solve( + reduction.Value().whitened_operator, request, zero_scale); + if (!backend.HasValue()) + return Result::Failure(backend.GetStatus()); + EigenAnalysisState state(std::move(backend.Value())); + state.full_dof_count = dofs.FullDofCount(); + state.free_dof_count = reduction.Value().free_count; + state.mass_null_dimension = reduction.Value().null_count; + state.finite_dimension = reduction.Value().whitened_operator.Rows(); + state.characteristic_length = length; + state.eta_mz = reduction.Value().eta_mz; + state.eta_kz = reduction.Value().eta_kz; + state.eta_pz = reduction.Value().eta_pz; + state.null_checks_applicable = state.mass_null_dimension != 0U; + state.constraint_mask = mask; + state.prescribed_displacement.assign(state.full_dof_count, 0.0); + for (const auto& node : reduction.Value().nodes) + state.nodal_reduction.push_back(node.record); + + // The controls use the actual constrained, length-scaled pencil, including + // positive partial-BC drilling stiffness. Full constrained rows are + // reactions. + const SparseMatrix& k = stiffness.Value(); + const SparseMatrix& a = reduction.Value().whitened_operator; + double stiffness_norm = 0.0; + for (const auto row : dofs.FreeDofs()) { + for (std::size_t entry = k.RowOffsets()[row]; + entry < k.RowOffsets()[row + 1U]; ++entry) { + const auto col = k.ColumnIndices()[entry]; + if (!mask[col]) + stiffness_norm = + std::hypot(stiffness_norm, + D(row, length) * k.Values()[entry] * D(col, length)); + } + } + double mass_norm = 0.0; + for (std::size_t node = 0; node < nodal_mass.size(); ++node) { + for (std::size_t row = 0; row < 6U; ++row) { + for (std::size_t col = 0; col < 6U; ++col) { + if (!mask[6U * node + row] && !mask[6U * node + col]) { + mass_norm = std::hypot( + mass_norm, + D(row, length) * nodal_mass[node](row, col) * D(col, length)); + } + } + } + } + const double a_norm = FrobeniusNorm(a); + state.vectors = Matrix(request, state.full_dof_count); + const std::size_t count = state.backend.eigenvalues.size(); + std::vector all_modes; + std::vector all_mass_times; + all_modes.reserve(count); + all_mass_times.reserve(count); + std::size_t cluster = 1U; + for (std::size_t column = 0; column < count; ++column) { + const double lambda = state.backend.eigenvalues[column]; + const double mu = lambda / state.backend.operator_scale; + if (column != 0U && mu - state.backend.eigenvalues[column - 1U] / + state.backend.operator_scale > + 1e-8) + ++cluster; + Vector y = eigen_internal::RestoreScaledMode( + reduction.Value(), state.backend.eigenvectors, column); + Vector physical(y.Size()); + std::size_t pivot = 0U; + for (std::size_t dof = 0; dof < y.Size(); ++dof) { + physical[dof] = D(dof, length) * y[dof]; + if (std::abs(y[dof]) > std::abs(y[pivot])) pivot = dof; + } + Vector mass_times = eigen_internal::MultiplyNodalMass(nodal_mass, physical); + const double initial_mass = Dot(physical, mass_times); + if (!(initial_mass > 0.0) || !std::isfinite(initial_mass)) + return Result::Failure( + MetricFailure("initial_modal_mass", initial_mass, + state.backend.column_ids[column])); + const double normalization = + (y[pivot] < 0.0 ? -1.0 : 1.0) / std::sqrt(initial_mass); + for (std::size_t dof = 0; dof < physical.Size(); ++dof) { + physical[dof] *= normalization; + if (!std::isfinite(physical[dof]) || + (mask[dof] && physical[dof] != 0.0)) { + return Result::Failure( + Failure("invalid-eigenvalue-result", std::to_string(column), + "Recovered mode is nonfinite or violates constraints.")); + } + y[dof] = physical[dof] / D(dof, length); + } + // All stored physical metrics are recomputed from the final normalized phi. + mass_times = eigen_internal::MultiplyNodalMass(nodal_mass, physical); + const double modal_mass = Dot(physical, mass_times); + Vector ky = k.Multiply(physical); + Vector residual(physical.Size()); + for (std::size_t dof = 0; dof < physical.Size(); ++dof) { + ky[dof] = mask[dof] ? 0.0 : D(dof, length) * ky[dof]; + residual[dof] = + mask[dof] ? 0.0 : ky[dof] - lambda * D(dof, length) * mass_times[dof]; + } + const double energy = Dot(y, ky); + const double y_norm = Norm(y); + const double pencil_scale = stiffness_norm + std::abs(lambda) * mass_norm; + const double eta_r = Ratio(Norm(residual), pencil_scale * y_norm); + const double eta_norm = std::abs(modal_mass - 1.0); + const double eta_energy = Ratio(std::abs(energy - lambda * modal_mass), + (pencil_scale * y_norm) * y_norm); + const double rayleigh = energy / modal_mass; + Vector x(a.Rows()); + for (std::size_t row = 0; row < x.Size(); ++row) + x[row] = state.backend.eigenvectors(row, column) * normalization; + Vector ax = a.Multiply(x); + Vector whitened_residual = ax; + whitened_residual.Axpy(-lambda, x); + const double x_norm = Norm(x); + const double eta_w = + Ratio(Norm(whitened_residual), (a_norm + std::abs(lambda)) * x_norm); + for (std::size_t row = 0; row < ax.Size(); ++row) + ax[row] = ax[row] / state.backend.operator_scale - mu * x[row]; + const double rho_b = Ratio(Norm(ax), x_norm); + for (const auto& metric : + std::initializer_list>{ + {"eta_r", eta_r}, + {"eta_w", eta_w}, + {"rho_b", rho_b}, + {"eta_norm", eta_norm}, + {"eta_energy", eta_energy}}) { + if (!std::isfinite(metric.second) || metric.second > 1e-10) + return Result::Failure(MetricFailure( + metric.first, metric.second, state.backend.column_ids[column])); + } + if (!std::isfinite(rayleigh)) + return Result::Failure( + Failure("invalid-eigenvalue-result", std::to_string(column), + "Nonfinite Rayleigh quotient.")); + state.eigenpair_checks.push_back( + {column + 1U, state.backend.column_ids[column], lambda, modal_mass, + rayleigh, eta_r, eta_w, rho_b, eta_norm, eta_energy, + state.backend.backend_res_available[column], + state.backend.backend_res[column]}); + if (column < request) { + std::optional frequency_value; + if (lambda >= 0.0) { + frequency_value = std::sqrt(lambda) / (2.0 * std::acos(-1.0)); + if (!std::isfinite(*frequency_value) || + (lambda > 0.0 && *frequency_value == 0.0)) + return Result::Failure( + Failure("invalid-eigenvalue-result", std::to_string(column), + "Cycle frequency conversion failed.")); + } + state.modes.push_back( + {column + 1U, lambda, + static_cast(std::abs(mu) <= 1e-9 ? 0U : 1U), cluster, + frequency_value}); + for (std::size_t dof = 0; dof < physical.Size(); ++dof) + state.vectors(column, dof) = physical[dof]; + } + all_modes.push_back(std::move(physical)); + all_mass_times.push_back(std::move(mass_times)); + } + double gram_norm = 0.0; + for (std::size_t left = 0; left < count; ++left) { + for (std::size_t right = 0; right < count; ++right) { + const double gram = Dot(all_modes[left], all_mass_times[right]); + gram_norm = std::hypot(gram_norm, gram - (left == right ? 1.0 : 0.0)); + if (left != right) + state.max_eta_orth = + (std::max)(state.max_eta_orth, + Ratio(std::abs(gram), + std::sqrt( + state.eigenpair_checks[left].modal_mass * + state.eigenpair_checks[right].modal_mass))); + } + } + state.eta_gram = gram_norm / std::sqrt(static_cast(count)); + if (!std::isfinite(state.eta_gram) || state.eta_gram > 1e-10 || + !std::isfinite(state.max_eta_orth) || state.max_eta_orth > 1e-10) { + return Result::Failure( + MetricFailure("eta_gram", state.eta_gram, 0U)); + } + state.backend.eigenvectors = Matrix(0U, 0U); + return Result::Success(std::move(state)); +} + +} // namespace + +Result LinearFrequencyProcedureExecutor::Execute( + const Domain& domain) const { + try { + auto result = ExecuteCandidate(domain); + if (!result.HasValue()) { + auto diagnostics = result.GetStatus().Diagnostics(); + for (auto& diagnostic : diagnostics) { + if (diagnostic.location.file.empty()) + diagnostic.location = domain.Frequency() != nullptr + ? domain.Frequency()->frequency_location + : SourceLocation{domain.SourcePath(), 0U}; + } + return Result::Failure(Status::Failure( + result.GetStatus().Category().value_or(FailureCategory::kSolver), + std::move(diagnostics))); + } + return result; + } catch (const std::bad_alloc&) { + return Result::Failure( + Failure("eigen-allocation-failed", "modal-workspace", + "Allocation failed after dimension checks; requested modes " + "were not reduced.")); + } catch (const std::length_error&) { + return Result::Failure( + Failure("eigen-index-overflow", "modal-workspace", + "Requested workspace exceeds container capacity.")); + } +} + +} // namespace fesa diff --git a/src/fesa/analysis/linear_static_procedure.cpp b/src/fesa/analysis/linear_static_procedure.cpp index ed1aafa..f107523 100644 --- a/src/fesa/analysis/linear_static_procedure.cpp +++ b/src/fesa/analysis/linear_static_procedure.cpp @@ -26,6 +26,16 @@ LinearStaticProcedureExecutor::LinearStaticProcedureExecutor( Result LinearStaticProcedureExecutor::Execute( const Domain& domain) const { + if (domain.Frequency() != nullptr) { + return Result::Failure(Status::Failure( + FailureCategory::kModel, + {{Severity::kError, + "invalid-analysis-procedure", + domain.Frequency()->frequency_location, + "FREQUENCY", + {}, + "Linear-static execution cannot consume a frequency step."}})); + } Result model = AnalysisModel::Create(domain); if (!model.HasValue()) { return Result::Failure(model.GetStatus()); diff --git a/src/fesa/analysis/prepared_analysis.cpp b/src/fesa/analysis/prepared_analysis.cpp index ce2042d..c1ee97f 100644 --- a/src/fesa/analysis/prepared_analysis.cpp +++ b/src/fesa/analysis/prepared_analysis.cpp @@ -2,12 +2,14 @@ #include +#include "fesa/analysis/linear_frequency_procedure.h" #include "fesa/analysis/linear_static_procedure.h" #include "fesa/analysis/newton_iteration_log.h" #include "fesa/analysis/nonlinear_static_procedure.h" #include "fesa/app/output_paths.h" #include "fesa/io/abaqus/domain_mapper.h" #include "fesa/io/abaqus/input_reader.h" +#include "fesa/results/eigen_results_writer.h" #include "fesa/results/nonlinear_results_writer.h" #include "fesa/results/results_writer.h" @@ -34,12 +36,26 @@ Result PrepareAnalysisInput( Result ExecutePreparedAnalysis( const Domain& domain, LinearStaticProcedureExecutor& linear_executor, NonlinearStaticProcedureExecutor& nonlinear_executor, + const LinearFrequencyProcedureExecutor& frequency_executor, NewtonIterationLogWriter& log) { if (domain.Steps().Size() != 1U) { return Result::Failure(Status::Failure( FailureCategory::kInput, - {{Severity::kError, "invalid-analysis-step-count", {}, "*STEP", {}, - "Prepared analysis requires exactly one static step."}})); + {{Severity::kError, + "invalid-analysis-step-count", + {}, + "*STEP", + {}, + "Prepared analysis requires exactly one analysis step."}})); + } + + if (domain.Steps()[0U].IsFrequency()) { + auto eigen = frequency_executor.Execute(domain); + if (!eigen.HasValue()) { + return Result::Failure(eigen.GetStatus()); + } + return Result::Success( + PreparedAnalysisOutcome{std::move(eigen.Value())}); } if (domain.Steps()[0U].IsNonlinearStatic()) { @@ -64,7 +80,13 @@ Status FinalizePreparedAnalysisResult( const DerivedOutputPaths& paths, const Domain& domain, const PreparedAnalysisOutcome& outcome, const std::vector& diagnostics, ResultsWriter& linear_writer, - NonlinearResultsWriter& nonlinear_writer) { + NonlinearResultsWriter& nonlinear_writer, + EigenResultsWriter& eigen_writer) { + if (std::holds_alternative(outcome)) { + return eigen_writer.Write(paths.results_path, domain, + std::get(outcome), + diagnostics); + } if (std::holds_alternative(outcome)) { return linear_writer.Write( paths.results_path, domain, diff --git a/src/fesa/app/fesa_application.cpp b/src/fesa/app/fesa_application.cpp index 5ac723b..1e54035 100644 --- a/src/fesa/app/fesa_application.cpp +++ b/src/fesa/app/fesa_application.cpp @@ -5,6 +5,7 @@ #include #include +#include "fesa/analysis/linear_frequency_procedure.h" #include "fesa/analysis/linear_static_procedure.h" #include "fesa/analysis/newton_iteration_log.h" #include "fesa/analysis/nonlinear_static_procedure.h" @@ -12,6 +13,7 @@ #include "fesa/app/output_paths.h" #include "fesa/assembly/parallel_for.h" #include "fesa/core/diagnostic.h" +#include "fesa/io/hdf5/eigen_hdf5_results_writer.h" #include "fesa/io/hdf5/hdf5_results_writer.h" #include "fesa/io/hdf5/nonlinear_hdf5_results_writer.h" #include "fesa/solvers/linear/mkl_pardiso_solver.h" @@ -63,8 +65,8 @@ int ExitCodeFor(const Status& status) { } // namespace int FesaApplication::Run(const std::vector& arguments) { - const auto paths = DeriveSingleInputOutputPaths( - arguments, std::filesystem::current_path()); + const auto paths = + DeriveSingleInputOutputPaths(arguments, std::filesystem::current_path()); if (!paths.HasValue()) { WriteDiagnostics(paths.GetStatus().Diagnostics()); return kUsageExitCode; @@ -81,6 +83,7 @@ int FesaApplication::Run(const std::vector& arguments) { MklPardisoTangentSolver tangent_solver; Hdf5ResultsWriter linear_writer; NonlinearHdf5ResultsWriter nonlinear_writer; + EigenHdf5ResultsWriter eigen_writer; NewtonIterationLogWriter log; Status status = log.Open(paths.Value().log_path); @@ -96,9 +99,11 @@ int FesaApplication::Run(const std::vector& arguments) { } else { LinearStaticProcedureExecutor linear_executor(parallel_for, linear_solver); NonlinearStaticProcedureExecutor nonlinear_executor(parallel_for, - tangent_solver); - Result outcome = ExecutePreparedAnalysis( - prepared.Value().domain, linear_executor, nonlinear_executor, log); + tangent_solver); + const LinearFrequencyProcedureExecutor frequency_executor; + Result outcome = + ExecutePreparedAnalysis(prepared.Value().domain, linear_executor, + nonlinear_executor, frequency_executor, log); if (!outcome.HasValue()) { status = outcome.GetStatus(); } else { @@ -109,7 +114,8 @@ int FesaApplication::Run(const std::vector& arguments) { } status = FinalizePreparedAnalysisResult( paths.Value(), prepared.Value().domain, outcome.Value(), - prepared.Value().diagnostics, linear_writer, nonlinear_writer); + prepared.Value().diagnostics, linear_writer, nonlinear_writer, + eigen_writer); if (status.IsOk()) { return kSuccessExitCode; } diff --git a/src/fesa/elements/mitc4_shell.cpp b/src/fesa/elements/mitc4_shell.cpp index 0fd37d9..a4f3aa5 100644 --- a/src/fesa/elements/mitc4_shell.cpp +++ b/src/fesa/elements/mitc4_shell.cpp @@ -279,6 +279,27 @@ Result StiffnessFailure(const SourceLocation& location, "*ELEMENT", identity, std::move(message)}})); } +Result MassFailure(const SourceLocation& location, + const std::string& identity, + std::string message) { + return Result::Failure( + Status::Failure(FailureCategory::kModel, + {{Severity::kError, "invalid-shell-mass", location, + "*ELEMENT", identity, std::move(message)}})); +} + +/// @brief Returns S=-[director]_cross for theta cross director. +Matrix DirectorSkew(const Vector3& director) { + Matrix result{3U, 3U}; + result(0U, 1U) = director.Z(); + result(0U, 2U) = -director.Y(); + result(1U, 0U) = -director.Z(); + result(1U, 2U) = director.X(); + result(2U, 0U) = director.Y(); + result(2U, 1U) = -director.X(); + return result; +} + Result RecoveryFailure(const SourceLocation& location, const std::string& identity, std::string message) { @@ -625,24 +646,29 @@ Result Mitc4Shell::Stiffness() const { } } - double drilling_reference = (std::numeric_limits::max)(); - bool has_drilling_reference = false; + double maximum_rotary_diagonal = 0.0; + std::array rotary_diagonals{}; + std::size_t rotary_index = 0U; for (std::size_t node = 0U; node < kNodeCount; ++node) { const std::size_t offset = node * kPhysicalDofsPerNode; for (std::size_t rotation = 3U; rotation < 5U; ++rotation) { const double diagonal = physical_local(offset + rotation, offset + rotation); - if (std::isfinite(diagonal) && diagonal > 0.0) { - drilling_reference = (std::min)(drilling_reference, diagonal); - has_drilling_reference = true; + if (!std::isfinite(diagonal) || diagonal < 0.0) { + return StiffnessFailure( + source_location_, identity_, + "MITC4 drilling stabilization requires finite nonnegative " + "physical tangent-rotation diagonals."); } + rotary_diagonals[rotary_index++] = diagonal; + maximum_rotary_diagonal = (std::max)(maximum_rotary_diagonal, diagonal); } } - if (!has_drilling_reference) { + if (!(maximum_rotary_diagonal > 0.0)) { return StiffnessFailure( source_location_, identity_, - "MITC4 drilling stabilization requires a finite positive physical " - "tangent-rotation diagonal."); + "MITC4 drilling stabilization requires a positive physical " + "tangent-rotation diagonal inventory."); } if (!IsFinite(physical_local)) { return StiffnessFailure( @@ -650,7 +676,12 @@ Result Mitc4Shell::Stiffness() const { "MITC4 physical stiffness must contain only finite values."); } - const double drilling_stiffness = 1.0e-3 * drilling_reference; + double normalized_diagonal_sum = 0.0; + for (const double diagonal : rotary_diagonals) { + normalized_diagonal_sum += diagonal / maximum_rotary_diagonal; + } + const double drilling_stiffness = + 1.0e-3 * maximum_rotary_diagonal * (normalized_diagonal_sum / 8.0); if (!std::isfinite(drilling_stiffness) || !(drilling_stiffness > 0.0)) { return StiffnessFailure( source_location_, identity_, @@ -685,6 +716,144 @@ Result Mitc4Shell::Stiffness() const { drilling_stiffness}); } +Result Mitc4Shell::Mass(const double density) const { + if (!std::isfinite(density) || !(density > 0.0)) { + return MassFailure(source_location_, identity_, + "MITC4 mass requires finite density > 0."); + } + + std::array moments{Matrix{kNodeCount, kNodeCount}, + Matrix{kNodeCount, kNodeCount}, + Matrix{kNodeCount, kNodeCount}}; + constexpr double kGaussCoordinate = 0.77459666924148337704; + constexpr std::array kGaussPoints{-kGaussCoordinate, 0.0, + kGaussCoordinate}; + constexpr std::array kGaussWeights{5.0 / 9.0, 8.0 / 9.0, + 5.0 / 9.0}; + for (std::size_t xi_index = 0U; xi_index < kGaussPoints.size(); ++xi_index) { + for (std::size_t eta_index = 0U; eta_index < kGaussPoints.size(); + ++eta_index) { + const Mitc4ShapeFunctions shape = + ShapeFunctions(kGaussPoints[xi_index], kGaussPoints[eta_index]); + for (std::size_t zeta_index = 0U; zeta_index < kGaussPoints.size(); + ++zeta_index) { + GeometryData geometry{}; + const double zeta = kGaussPoints[zeta_index]; + if (!EvaluateGeometry(kGaussPoints[xi_index], kGaussPoints[eta_index], + zeta, geometry)) { + return MassFailure(source_location_, identity_, + "MITC4 mass quadrature geometry is invalid."); + } + const double z = 0.5 * thickness_ * zeta; + const double weight = + density * geometry.jacobian * kGaussWeights[xi_index] * + kGaussWeights[eta_index] * kGaussWeights[zeta_index]; + if (!std::isfinite(z) || !std::isfinite(weight)) { + return MassFailure( + source_location_, identity_, + "MITC4 mass quadrature contribution is nonfinite."); + } + const std::array z_powers{1.0, z, z * z}; + for (std::size_t row = 0U; row < kNodeCount; ++row) { + for (std::size_t column = row; column < kNodeCount; ++column) { + const double shape_product = + shape.values[row] * shape.values[column]; + for (std::size_t moment = 0U; moment < moments.size(); ++moment) { + const double contribution = + weight * shape_product * z_powers[moment]; + moments[moment](row, column) += contribution; + if (row != column) { + moments[moment](column, row) += contribution; + } + } + } + } + } + } + } + for (const Matrix& moment : moments) { + if (!IsFinite(moment)) { + return MassFailure(source_location_, identity_, + "MITC4 consistent moments must be finite."); + } + } + + Matrix consistent{kGlobalDofCount, kGlobalDofCount}; + for (std::size_t row_node = 0U; row_node < kNodeCount; ++row_node) { + const Matrix row_skew = DirectorSkew(directors_[row_node]); + for (std::size_t column_node = 0U; column_node < kNodeCount; + ++column_node) { + const Matrix column_skew = DirectorSkew(directors_[column_node]); + for (std::size_t row = 0U; row < 3U; ++row) { + for (std::size_t column = 0U; column < 3U; ++column) { + const std::size_t global_row = kGlobalDofsPerNode * row_node + row; + const std::size_t global_column = + kGlobalDofsPerNode * column_node + column; + consistent(global_row, global_column) = + moments[0U](row_node, column_node) * (row == column ? 1.0 : 0.0); + consistent(global_row, global_column + 3U) = + moments[1U](row_node, column_node) * column_skew(row, column); + consistent(global_row + 3U, global_column) = + moments[1U](row_node, column_node) * row_skew(column, row); + double rotary = 0.0; + for (std::size_t axis = 0U; axis < 3U; ++axis) { + rotary += row_skew(axis, row) * column_skew(axis, column); + } + consistent(global_row + 3U, global_column + 3U) = + moments[2U](row_node, column_node) * rotary; + } + } + } + } + + Matrix lumped{kGlobalDofCount, kGlobalDofCount}; + std::array, kNodeCount> nodal_moments{}; + for (std::size_t node = 0U; node < kNodeCount; ++node) { + double mass = 0.0; + double first_moment = 0.0; + double second_moment = 0.0; + for (std::size_t column = 0U; column < kNodeCount; ++column) { + mass += moments[0U](node, column); + first_moment += moments[1U](node, column); + second_moment += moments[2U](node, column); + } + const double centered_inertia = + second_moment - first_moment * (first_moment / mass); + if (!std::isfinite(mass) || !std::isfinite(first_moment) || + !std::isfinite(second_moment) || !std::isfinite(centered_inertia) || + !(mass > 0.0) || !(second_moment > 0.0) || !(centered_inertia > 0.0)) { + return MassFailure( + source_location_, identity_, + "MITC4 nodal mass moments must be finite and positive."); + } + nodal_moments[node] = {mass, first_moment, second_moment}; + const Matrix skew = DirectorSkew(directors_[node]); + const std::size_t offset = kGlobalDofsPerNode * node; + for (std::size_t row = 0U; row < 3U; ++row) { + for (std::size_t column = 0U; column < 3U; ++column) { + lumped(offset + row, offset + column) = + mass * (row == column ? 1.0 : 0.0); + lumped(offset + row, offset + column + 3U) = + first_moment * skew(row, column); + lumped(offset + row + 3U, offset + column) = + first_moment * skew(column, row); + double projector = row == column ? 1.0 : 0.0; + projector -= directors_[node][row] * directors_[node][column]; + lumped(offset + row + 3U, offset + column + 3U) = + second_moment * projector; + } + } + } + if (!IsFinite(consistent) || !IsFinite(lumped)) { + return MassFailure( + source_location_, identity_, + "MITC4 transformed mass must contain only finite values."); + } + return Result::Success( + Mitc4Mass{std::move(moments), std::move(consistent), std::move(lumped), + std::move(nodal_moments)}); +} + Result Mitc4Shell::RecoverPhysical( const Vector& global_element_displacement24) const { if (global_element_displacement24.Size() != kGlobalDofCount) { diff --git a/src/fesa/io/abaqus/domain_mapping_context.cpp b/src/fesa/io/abaqus/domain_mapping_context.cpp index 37f9071..21f4b97 100644 --- a/src/fesa/io/abaqus/domain_mapping_context.cpp +++ b/src/fesa/io/abaqus/domain_mapping_context.cpp @@ -2,8 +2,8 @@ #include #include -#include #include +#include #include #include #include @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -135,8 +136,10 @@ struct RawMaterial { double youngs_modulus{0.0}; double poisson_ratio{0.0}; bool has_elastic{false}; + std::optional density; SourceLocation location; SourceLocation elastic_location; + SourceLocation density_location; }; struct RawStep { @@ -144,10 +147,13 @@ struct RawStep { std::string name_raw; std::string name_canonical; bool has_static{false}; + bool perturbation_explicit{false}; + bool nlgeom_explicit{false}; StaticProcedureKind procedure_kind{ StaticProcedureKind::kLinearSmallDisplacement}; bool nlgeom{false}; std::array static_values{}; + std::optional frequency; std::vector boundaries; std::vector loads; std::vector surface_pressure_loads; @@ -184,6 +190,9 @@ class DomainMappingContext::Impl { if (!failure_) { FinalizePartsAndSections(); } + if (!failure_) { + FinalizeDensities(); + } return CurrentStatus(); } @@ -313,10 +322,9 @@ class DomainMappingContext::Impl { block.canonical_name, "", std::move(message)); } - bool ValidateParameters(const KeywordBlock& block, - const std::vector& allowed, - std::string_view unsupported_code = - "invalid-keyword-parameter") { + bool ValidateParameters( + const KeywordBlock& block, const std::vector& allowed, + std::string_view unsupported_code = "invalid-keyword-parameter") { std::set seen; for (const auto& candidate : block.parameters) { if (!seen.insert(candidate.name).second) { @@ -402,8 +410,7 @@ class DomainMappingContext::Impl { ++cursor; } const std::size_t integer_start = cursor; - while (cursor < text.size() && text[cursor] >= '0' && - text[cursor] <= '9') { + while (cursor < text.size() && text[cursor] >= '0' && text[cursor] <= '9') { ++cursor; } const bool has_integer_digits = cursor != integer_start; @@ -423,7 +430,8 @@ class DomainMappingContext::Impl { } if (cursor < text.size() && (text[cursor] == 'E' || text[cursor] == 'e')) { ++cursor; - if (cursor < text.size() && (text[cursor] == '+' || text[cursor] == '-')) { + if (cursor < text.size() && + (text[cursor] == '+' || text[cursor] == '-')) { ++cursor; } const std::size_t exponent_start = cursor; @@ -443,8 +451,8 @@ class DomainMappingContext::Impl { const char* const begin = text.data(); const char* const end = begin + text.size(); const char* const numeric_begin = *begin == '+' ? begin + 1U : begin; - const auto parsed = std::from_chars(numeric_begin, end, value, - std::chars_format::general); + const auto parsed = + std::from_chars(numeric_begin, end, value, std::chars_format::general); if (parsed.ec != std::errc{} || parsed.ptr != end || !std::isfinite(value)) { return InputFailure("invalid-numeric-value", location, keyword, text, @@ -458,8 +466,7 @@ class DomainMappingContext::Impl { const std::string& keyword) { if (text.empty()) { return InputFailure("invalid-nonlinear-static-data", location, keyword, - text, - "A nonlinear STATIC field cannot be empty."); + text, "A nonlinear STATIC field cannot be empty."); } char* end = nullptr; errno = 0; @@ -538,11 +545,10 @@ class DomainMappingContext::Impl { } bool ContainsSurfaceName(const std::string& name) const { - return std::any_of( - element_surfaces_.begin(), element_surfaces_.end(), - [&name](const RawElementSurface& surface) { - return AsciiCaseInsensitiveEquals(surface.name, name); - }); + return std::any_of(element_surfaces_.begin(), element_surfaces_.end(), + [&name](const RawElementSurface& surface) { + return AsciiCaseInsensitiveEquals(surface.name, name); + }); } bool IsPressureNameToken(const std::string_view text) const noexcept { @@ -550,8 +556,8 @@ class DomainMappingContext::Impl { return false; } const auto is_initial = [](const char value) { - return (value >= 'A' && value <= 'Z') || - (value >= 'a' && value <= 'z') || value == '_'; + return (value >= 'A' && value <= 'Z') || (value >= 'a' && value <= 'z') || + value == '_'; }; const auto is_subsequent = [is_initial](const char value) { return is_initial(value) || (value >= '0' && value <= '9') || @@ -602,7 +608,8 @@ class DomainMappingContext::Impl { return; } - if (block.canonical_name != "ELASTIC") { + if (block.canonical_name != "ELASTIC" && + block.canonical_name != "DENSITY") { material_eligible_.reset(); } pending_section_.reset(); @@ -655,6 +662,14 @@ class DomainMappingContext::Impl { ParseElastic(block); return; } + if (block.canonical_name == "DENSITY") { + if (!material_eligible_) { + RejectUnknown(block); + return; + } + ParseDensity(block); + return; + } if (block.canonical_name == "BOUNDARY") { if (!assembly_seen_ || materials_.empty()) { InvalidKeywordLocation( @@ -1367,8 +1382,8 @@ class DomainMappingContext::Impl { const std::string_view name_parameter = node_set ? "NSET" : "ELSET"; const std::vector allowed_parameters = node_set ? std::vector{"NSET", "INSTANCE", "GENERATE"} - : std::vector{"ELSET", "INSTANCE", "GENERATE", - "INTERNAL"}; + : std::vector{"ELSET", "INSTANCE", + "GENERATE", "INTERNAL"}; if (!ValidateParameters(block, allowed_parameters)) { return; } @@ -1379,10 +1394,10 @@ class DomainMappingContext::Impl { const auto* instance_parameter = Parameter(block, "INSTANCE"); if (instance_parameter == nullptr || !instance_parameter->value || instance_parameter->value->empty()) { - InputFailure(node_set ? "invalid-keyword-parameter" - : "implicit-instance-elset", - block.location, block.canonical_name, "INSTANCE", - "Assembly element sets require an explicit INSTANCE."); + InputFailure( + node_set ? "invalid-keyword-parameter" : "implicit-instance-elset", + block.location, block.canonical_name, "INSTANCE", + "Assembly element sets require an explicit INSTANCE."); return; } const std::string& instance = *instance_parameter->value; @@ -1413,12 +1428,11 @@ class DomainMappingContext::Impl { if (field.empty()) { continue; } - const bool numeric = !field.empty() && - std::all_of(field.begin(), field.end(), - [](const char value) { - return value >= '0' && - value <= '9'; - }); + const bool numeric = + !field.empty() && + std::all_of(field.begin(), field.end(), [](const char value) { + return value >= '0' && value <= '9'; + }); if (!numeric) { const std::string code = AsciiCaseInsensitiveEquals(field, *name) ? "cyclic-elset" @@ -1438,8 +1452,7 @@ class DomainMappingContext::Impl { } void ParseElementSurface(const KeywordBlock& block) { - if (!ValidateParameters(block, {"TYPE", "NAME"}, - "unsupported-parameter")) { + if (!ValidateParameters(block, {"TYPE", "NAME"}, "unsupported-parameter")) { return; } const auto* type = RequiredParameterValue(block, "TYPE"); @@ -1448,8 +1461,8 @@ class DomainMappingContext::Impl { return; } if (!IsPressureNameToken(*name)) { - InputFailure("invalid-surface-name", block.location, - block.canonical_name, *name, + InputFailure("invalid-surface-name", block.location, block.canonical_name, + *name, "Surface names must use the approved ASCII name syntax."); return; } @@ -1461,17 +1474,18 @@ class DomainMappingContext::Impl { } if (ContainsSurfaceName(*name)) { InputFailure("duplicate-entity", block.location, block.canonical_name, - *name, "Surface names are unique in the assembly namespace."); + *name, + "Surface names are unique in the assembly namespace."); return; } if (block.data.empty()) { - InputFailure("empty-surface", block.location, block.canonical_name, - *name, "An element surface requires at least one member."); + InputFailure("empty-surface", block.location, block.canonical_name, *name, + "An element surface requires at least one member."); return; } - RawElementSurface surface{*name, block.location, element_surfaces_.size(), - {}}; + RawElementSurface surface{ + *name, block.location, element_surfaces_.size(), {}}; for (std::size_t ordinal = 0U; ordinal < block.data.size(); ++ordinal) { const auto& row = block.data[ordinal]; if (row.fields.size() != 2U || row.fields[0].empty() || @@ -1516,7 +1530,8 @@ class DomainMappingContext::Impl { *name, "Material names are globally unique."); return; } - materials_.push_back({*name, 0.0, 0.0, false, block.location, {}}); + materials_.push_back( + {*name, 0.0, 0.0, false, std::nullopt, block.location, {}, {}}); material_eligible_ = materials_.size() - 1U; } @@ -1560,6 +1575,111 @@ class DomainMappingContext::Impl { material.elastic_location = block.data[0].location; } + bool ParseDensityValue(const std::string& text, double& value, + const SourceLocation& location) { + const std::string canonical = CanonicalizeAscii(text); + if (canonical == "NAN" || canonical == "+NAN" || canonical == "-NAN" || + canonical == "INF" || canonical == "+INF" || canonical == "-INF" || + canonical == "INFINITY" || canonical == "+INFINITY" || + canonical == "-INFINITY") { + return ModelFailure( + "invalid-material-density", location, "DENSITY", text, + "Density must convert to a finite value greater than zero."); + } + std::size_t cursor = 0U; + if (cursor < text.size() && (text[cursor] == '+' || text[cursor] == '-')) { + ++cursor; + } + const std::size_t integer_start = cursor; + while (cursor < text.size() && text[cursor] >= '0' && text[cursor] <= '9') { + ++cursor; + } + const bool has_integer_digits = cursor != integer_start; + bool has_fraction_digits = false; + if (cursor < text.size() && text[cursor] == '.') { + ++cursor; + const std::size_t fraction_start = cursor; + while (cursor < text.size() && text[cursor] >= '0' && + text[cursor] <= '9') { + ++cursor; + } + has_fraction_digits = cursor != fraction_start; + } + if (!has_integer_digits && !has_fraction_digits) { + return InputFailure("invalid-density-data", location, "DENSITY", text, + "Density requires C-locale decimal syntax."); + } + if (cursor < text.size() && (text[cursor] == 'E' || text[cursor] == 'e')) { + ++cursor; + if (cursor < text.size() && + (text[cursor] == '+' || text[cursor] == '-')) { + ++cursor; + } + const std::size_t exponent_start = cursor; + while (cursor < text.size() && text[cursor] >= '0' && + text[cursor] <= '9') { + ++cursor; + } + if (cursor == exponent_start) { + return InputFailure("invalid-density-data", location, "DENSITY", text, + "Density exponents require one or more digits."); + } + } + if (cursor != text.size()) { + return InputFailure("invalid-density-data", location, "DENSITY", text, + "Density requires C-locale decimal syntax."); + } + const char* begin = text.data(); + if (*begin == '+') { + ++begin; + } + double parsed = 0.0; + const auto conversion = std::from_chars(begin, text.data() + text.size(), + parsed, std::chars_format::general); + if (conversion.ec != std::errc{} || + conversion.ptr != text.data() + text.size() || !std::isfinite(parsed) || + !(parsed > 0.0)) { + return ModelFailure( + "invalid-material-density", location, "DENSITY", text, + "Density must convert to a finite value greater than zero."); + } + value = parsed; + return true; + } + + void ParseDensity(const KeywordBlock& block) { + if (!material_eligible_) { + InputFailure("invalid-density-data", block.location, block.canonical_name, + "", "DENSITY must be declared inside one MATERIAL."); + return; + } + if (!ValidateParameters(block, {}, "unsupported-density-option")) { + return; + } + RawMaterial& material = materials_[*material_eligible_]; + if (material.density) { + InputFailure("duplicate-density", block.location, block.canonical_name, + material.name, "A material accepts one DENSITY definition."); + return; + } + if (block.data.size() != 1U || + (block.data[0].fields.size() != 1U && + (block.data[0].fields.size() != 2U || + !block.data[0].fields[1].empty())) || + block.data[0].fields[0].empty()) { + InputFailure("invalid-density-data", block.location, block.canonical_name, + material.name, "DENSITY requires exactly rho or rho,."); + return; + } + double rho = 0.0; + if (!ParseDensityValue(block.data[0].fields[0], rho, + block.data[0].location)) { + return; + } + material.density = rho; + material.density_location = block.data[0].location; + } + void ParseBoundary( const KeywordBlock& block, std::vector& destination) { @@ -1658,8 +1778,8 @@ class DomainMappingContext::Impl { return; } if (!IsPressureNameToken(row.fields[0])) { - InputFailure("invalid-surface-name", row.location, - block.canonical_name, row.fields[0], + InputFailure("invalid-surface-name", row.location, block.canonical_name, + row.fields[0], "DSLOAD surface names must use the approved ASCII name " "syntax."); return; @@ -1683,19 +1803,26 @@ class DomainMappingContext::Impl { void ParseStepStart(const KeywordBlock& block) { if (step_seen_) { - const auto* nlgeom = Parameter(block, "NLGEOM"); - const bool nonlinear_second_step = - nlgeom != nullptr && nlgeom->value && - AsciiCaseInsensitiveEquals(*nlgeom->value, "YES"); - if (step_.nlgeom || nonlinear_second_step) { - InputFailure("unsupported-analysis-procedure", block.location, - block.canonical_name, "", - "The nonlinear static subset accepts exactly one " - "procedure step."); + if (step_.frequency) { + InputFailure( + "unsupported-analysis-procedure", block.location, + block.canonical_name, "", + "The frequency subset accepts exactly one procedure step."); } else { - InputFailure("unsupported-multiple-step", block.location, - block.canonical_name, "", - "V0 accepts exactly one analysis step."); + const auto* nlgeom = Parameter(block, "NLGEOM"); + const bool nonlinear_second_step = + nlgeom != nullptr && nlgeom->value && + AsciiCaseInsensitiveEquals(*nlgeom->value, "YES"); + if (step_.nlgeom || nonlinear_second_step) { + InputFailure("unsupported-analysis-procedure", block.location, + block.canonical_name, "", + "The nonlinear static subset accepts exactly one " + "procedure step."); + } else { + InputFailure("unsupported-multiple-step", block.location, + block.canonical_name, "", + "V0 accepts exactly one analysis step."); + } } return; } @@ -1705,7 +1832,7 @@ class DomainMappingContext::Impl { "material definitions."); return; } - if (!ValidateParameters(block, {"NAME", "NLGEOM"}) || + if (!ValidateParameters(block, {"NAME", "NLGEOM", "PERTURBATION"}) || !RequireNoData(block)) { return; } @@ -1736,6 +1863,13 @@ class DomainMappingContext::Impl { return; } } + const auto* perturbation = Parameter(block, "PERTURBATION"); + if (perturbation != nullptr && perturbation->value) { + InputFailure("unsupported-analysis-procedure", block.location, + block.canonical_name, "PERTURBATION", + "PERTURBATION is accepted only as a value-less flag."); + return; + } const auto* name = Parameter(block, "NAME"); if (name != nullptr && (!name->value || name->value->empty())) { InputFailure("invalid-keyword-parameter", block.location, @@ -1751,6 +1885,8 @@ class DomainMappingContext::Impl { step_.name_canonical = CanonicalizeAscii(step_.name_raw); step_.procedure_kind = procedure_kind; step_.nlgeom = nlgeom; + step_.nlgeom_explicit = nlgeom_parameter != nullptr; + step_.perturbation_explicit = perturbation != nullptr; step_load_seen_ = false; step_no_op_seen_ = false; } @@ -1761,10 +1897,9 @@ class DomainMappingContext::Impl { if (!ValidateParameters(block, {}) || !RequireNoData(block)) { return; } - if (!step_.has_static) { + if (!step_.has_static && !step_.frequency) { InvalidKeywordLocation( - block, - "The sole step requires exactly one leading STATIC procedure."); + block, "The sole step requires one supported procedure keyword."); return; } in_step_ = false; @@ -1779,16 +1914,26 @@ class DomainMappingContext::Impl { if (block.canonical_name == "STATIC") { active_output_ = false; if (!step_.boundaries.empty() || !step_.loads.empty() || - !step_.surface_pressure_loads.empty() || - step_no_op_seen_) { + !step_.surface_pressure_loads.empty() || step_no_op_seen_) { InvalidKeywordLocation( block, "STATIC must be the first keyword in the sole step."); return; } ParseStatic(block); + } else if (block.canonical_name == "FREQUENCY") { + active_output_ = false; + if (step_.has_static || step_.frequency || !step_.boundaries.empty() || + !step_.loads.empty() || !step_.surface_pressure_loads.empty() || + step_no_op_seen_) { + InvalidKeywordLocation( + block, "FREQUENCY must be the first procedure in the sole step."); + return; + } + ParseFrequency(block); } else if (block.canonical_name == "BOUNDARY") { active_output_ = false; - if (!step_.has_static || step_load_seen_ || step_no_op_seen_) { + if ((!step_.has_static && !step_.frequency) || step_load_seen_ || + step_no_op_seen_) { InvalidKeywordLocation( block, "Step boundaries follow STATIC and precede loads " @@ -1796,6 +1941,9 @@ class DomainMappingContext::Impl { return; } ParseBoundary(block, step_.boundaries); + if (!failure_ && step_.frequency) { + ValidateEigenBoundaries(step_.boundaries); + } } else if (block.canonical_name == "CLOAD") { active_output_ = false; if (!step_.has_static || step_no_op_seen_) { @@ -1812,26 +1960,40 @@ class DomainMappingContext::Impl { } else if (block.canonical_name == "DSLOAD") { active_output_ = false; if (!step_.has_static || step_no_op_seen_) { - InvalidKeywordLocation( - block, - "DSLOAD follows STATIC and boundaries and precedes no-op requests."); + InvalidKeywordLocation(block, + "DSLOAD follows STATIC and boundaries and " + "precedes no-op requests."); return; } ParseDsload(block); step_load_seen_ = !failure_; } else if (block.canonical_name == "RESTART") { active_output_ = false; - if (!step_.has_static) { + if (!step_.has_static && !step_.frequency) { InvalidKeywordLocation( block, "Step no-op requests follow STATIC, boundaries, and loads."); return; } step_no_op_seen_ = true; + if (step_.frequency) { + const auto* write = Parameter(block, "WRITE"); + const auto* frequency = Parameter(block, "FREQUENCY"); + if (!ValidateParameters(block, {"WRITE", "FREQUENCY"}) || + write == nullptr || write->value || frequency == nullptr || + !frequency->value || *frequency->value != "0") { + if (!failure_) { + InputFailure("unsupported-keyword", block.location, + block.canonical_name, "", + "Frequency RESTART accepts only WRITE, FREQUENCY=0."); + } + return; + } + } if (RequireNoData(block)) { AddIgnoredWarning(block); } } else if (block.canonical_name == "OUTPUT") { - if (!step_.has_static) { + if (!step_.has_static && !step_.frequency) { InvalidKeywordLocation( block, "Step no-op requests follow STATIC, boundaries, and loads."); return; @@ -1840,12 +2002,12 @@ class DomainMappingContext::Impl { ParseOutputRoot(block); } else if (block.canonical_name == "NODE OUTPUT" || block.canonical_name == "ELEMENT OUTPUT" || - block.canonical_name == "CONTACT OUTPUT") { + block.canonical_name == "CONTACT OUTPUT" || + block.canonical_name == "ENERGY OUTPUT") { ParseOutputChild(block); } else { active_output_ = false; - if (step_.procedure_kind == - StaticProcedureKind::kNonlinearFullNewton && + if (step_.procedure_kind == StaticProcedureKind::kNonlinearFullNewton && !step_.has_static) { InputFailure("unsupported-analysis-procedure", block.location, block.canonical_name, "", @@ -1856,36 +2018,181 @@ class DomainMappingContext::Impl { } } + bool ParseFrequencyModeCount(const std::string& text, std::int64_t& value, + const SourceLocation& location) { + if (text.empty() || + !std::all_of(text.begin(), text.end(), [](char character) { + return character >= '0' && character <= '9'; + })) { + return InputFailure( + "invalid-frequency-data", location, "FREQUENCY", text, + "The requested mode count must be positive decimal digits."); + } + const auto parsed = + std::from_chars(text.data(), text.data() + text.size(), value); + if (parsed.ec != std::errc{} || parsed.ptr != text.data() + text.size() || + value <= 0) { + return InputFailure( + "invalid-frequency-data", location, "FREQUENCY", text, + "The requested mode count must be a positive int64 value."); + } + return true; + } + + void ParseFrequency(const KeywordBlock& block) { + if (step_.nlgeom || (step_.perturbation_explicit && step_.has_static) || + !ValidateParameters( + block, {"EIGENSOLVER", "SIM", "ACOUSTIC COUPLING", "NORMALIZATION"}, + "unsupported-frequency-option")) { + if (!failure_) { + InputFailure("unsupported-analysis-procedure", block.location, + block.canonical_name, "", + "FREQUENCY requires the original linear configuration."); + } + return; + } + if (block.data.size() != 1U || block.data[0].fields.empty() || + block.data[0].fields.size() > 7U) { + InputFailure("invalid-frequency-data", block.location, + block.canonical_name, "", + "FREQUENCY requires one data row with one to seven fields."); + return; + } + for (std::size_t field = 1U; field < block.data[0].fields.size(); ++field) { + if (!block.data[0].fields[field].empty()) { + InputFailure("invalid-frequency-data", block.data[0].location, + block.canonical_name, std::to_string(field + 1U), + "Only the requested mode-count field may be non-empty."); + return; + } + } + FrequencyStepDefinition frequency{step_.name_raw, + step_.name_canonical, + step_.location, + block.location, + block.data[0].location, + 0, + step_.nlgeom_explicit, + step_.perturbation_explicit, + false, + false, + false, + false, + FrequencyEigensolver::kLanczos, + FrequencyNormalization::kMass, + {}}; + if (!ParseFrequencyModeCount(block.data[0].fields[0], + frequency.requested_mode_count, + block.data[0].location)) { + return; + } + const auto* eigensolver = Parameter(block, "EIGENSOLVER"); + if (eigensolver != nullptr) { + if (!eigensolver->value || + !AsciiCaseInsensitiveEquals(*eigensolver->value, "LANCZOS")) { + InputFailure("unsupported-frequency-option", block.location, + block.canonical_name, "EIGENSOLVER", + "Only EIGENSOLVER=LANCZOS is accepted."); + return; + } + frequency.eigensolver_explicit = true; + } + const auto* sim = Parameter(block, "SIM"); + if (sim != nullptr) { + if (sim->value && !AsciiCaseInsensitiveEquals(*sim->value, "YES")) { + InputFailure("unsupported-frequency-option", block.location, + block.canonical_name, "SIM", + "SIM accepts only a flag or YES."); + return; + } + frequency.sim_explicit = true; + } + const auto* acoustic = Parameter(block, "ACOUSTIC COUPLING"); + if (acoustic != nullptr) { + if (!acoustic->value || + !AsciiCaseInsensitiveEquals(*acoustic->value, "ON")) { + InputFailure("unsupported-frequency-option", block.location, + block.canonical_name, "ACOUSTIC COUPLING", + "Only ACOUSTIC COUPLING=ON is accepted."); + return; + } + frequency.acoustic_coupling_explicit = true; + } + const auto* normalization = Parameter(block, "NORMALIZATION"); + if (normalization != nullptr) { + if (!normalization->value || + !AsciiCaseInsensitiveEquals(*normalization->value, "MASS")) { + InputFailure("unsupported-frequency-option", block.location, + block.canonical_name, "NORMALIZATION", + "Only NORMALIZATION=MASS is accepted."); + return; + } + frequency.normalization_explicit = true; + } + step_.frequency = std::move(frequency); + definition_.warnings.push_back( + {Severity::kWarning, "source-eigensolver-mapped-to-feast", + block.location, block.canonical_name, step_.name_raw, + "The accepted source eigensolver is mapped to the FEAST backend."}); + if (step_.frequency->sim_explicit) { + definition_.warnings.push_back( + {Severity::kWarning, "source-sim-option-not-applied", block.location, + block.canonical_name, step_.name_raw, + "The accepted source SIM option has no FESA runtime effect."}); + } + if (step_.frequency->acoustic_coupling_explicit) { + definition_.warnings.push_back( + {Severity::kWarning, "acoustic-option-inapplicable-structural-only", + block.location, block.canonical_name, step_.name_raw, + "Acoustic coupling is inapplicable to this structural-only model."}); + } + } + + void ValidateEigenBoundaries( + const std::vector& boundaries) { + for (const auto& boundary : boundaries) { + if (boundary.value != 0.0) { + InputFailure("unsupported-eigen-boundary", boundary.location, + "BOUNDARY", boundary.target, + "Frequency boundaries must prescribe exact zero."); + return; + } + } + } + void ParseStatic(const KeywordBlock& block) { + if (step_.perturbation_explicit || step_.frequency) { + InputFailure("unsupported-analysis-procedure", block.location, + block.canonical_name, "PERTURBATION", + "PERTURBATION is supported only with FREQUENCY."); + return; + } if (step_.has_static || !ValidateParameters(block, {}) || block.data.size() != 1U || block.data[0].fields.size() != 4U) { if (!failure_) { InputFailure(step_.nlgeom ? "invalid-nonlinear-static-data" : "invalid-static-data", - block.location, - block.canonical_name, "", + block.location, block.canonical_name, "", "STATIC requires exactly one row of four values."); } return; } for (std::size_t field = 0U; field < 4U; ++field) { const std::string& text = block.data[0].fields[field]; - const bool parsed = step_.nlgeom - ? ParseNonlinearStaticDouble( - text, step_.static_values[field], - block.data[0].location, - block.canonical_name) - : ParseDouble(text, step_.static_values[field], - block.data[0].location, - block.canonical_name); + const bool parsed = + step_.nlgeom + ? ParseNonlinearStaticDouble(text, step_.static_values[field], + block.data[0].location, + block.canonical_name) + : ParseDouble(text, step_.static_values[field], + block.data[0].location, block.canonical_name); if (!parsed) { return; } if (step_.static_values[field] <= 0.0) { InputFailure(step_.nlgeom ? "invalid-nonlinear-static-data" : "invalid-static-data", - block.data[0].location, - block.canonical_name, "", + block.data[0].location, block.canonical_name, "", "All four STATIC fields must be positive."); return; } @@ -1893,8 +2200,7 @@ class DomainMappingContext::Impl { if (step_.static_values[2] > step_.static_values[3]) { InputFailure(step_.nlgeom ? "invalid-nonlinear-static-data" : "invalid-static-data", - block.data[0].location, - block.canonical_name, "", + block.data[0].location, block.canonical_name, "", "STATIC minimum increment cannot exceed maximum increment."); return; } @@ -1917,10 +2223,10 @@ class DomainMappingContext::Impl { if ((field == nullptr) == (history == nullptr) || (field != nullptr && field->value) || (history != nullptr && history->value)) { - InputFailure(step_.nlgeom ? "invalid-keyword-parameter" - : "unsupported-keyword", - block.location, block.canonical_name, "", - "OUTPUT must select exactly FIELD or HISTORY."); + InputFailure( + step_.nlgeom ? "invalid-keyword-parameter" : "unsupported-keyword", + block.location, block.canonical_name, "", + "OUTPUT must select exactly FIELD or HISTORY."); return; } if (step_.nlgeom && @@ -1931,7 +2237,20 @@ class DomainMappingContext::Impl { "Nonlinear OUTPUT requires VARIABLE=PRESELECT."); return; } + if (step_.frequency && + ((variable != nullptr && + (!variable->value || + !AsciiCaseInsensitiveEquals(*variable->value, "PRESELECT"))) || + !RequireNoData(block))) { + if (!failure_) { + InputFailure("unsupported-keyword", block.location, + block.canonical_name, "", + "Frequency OUTPUT accepts only VARIABLE=PRESELECT."); + } + return; + } active_output_ = true; + active_output_is_field_ = field != nullptr; AddIgnoredWarning(block); } @@ -1942,16 +2261,37 @@ class DomainMappingContext::Impl { "Output variable keywords require an active OUTPUT request."); return; } + if (step_.frequency) { + const std::string_view required_variable = + block.canonical_name == "NODE OUTPUT" ? "PRESELECT" : "ALL"; + const auto* variable = Parameter(block, "VARIABLE"); + if ((block.canonical_name != "NODE OUTPUT" && + block.canonical_name != "ENERGY OUTPUT") || + (block.canonical_name == "NODE OUTPUT" && !active_output_is_field_) || + (block.canonical_name == "ENERGY OUTPUT" && + active_output_is_field_) || + !ValidateParameters(block, {"VARIABLE"}) || variable == nullptr || + !variable->value || + !AsciiCaseInsensitiveEquals(*variable->value, required_variable) || + !RequireNoData(block)) { + if (!failure_) { + InputFailure( + "unsupported-keyword", block.location, block.canonical_name, "", + "Frequency output child is outside the approved subset."); + } + return; + } + } AddIgnoredWarning(block); } void AddIgnoredWarning(const KeywordBlock& block) { // One warning per allowlisted keyword keeps no-op provenance stable; // subordinate variable rows remain attached to that keyword record. - definition_.warnings.push_back( - {Severity::kWarning, "ignored-input-keyword", block.location, - block.canonical_name, "", - "The allowlisted Abaqus keyword is ignored without semantic effect."}); + definition_.warnings.push_back({Severity::kWarning, "ignored-input-keyword", + block.location, block.canonical_name, "", + "The allowlisted Abaqus keyword is " + "ignored without semantic effect."}); } void RejectUnknown(const KeywordBlock& block) { @@ -2018,13 +2358,14 @@ class DomainMappingContext::Impl { void ValidateCardinality() { if (parts_.empty() || !assembly_seen_ || instances_.empty() || - materials_.empty() || !step_seen_ || !step_.has_static) { + materials_.empty() || !step_seen_ || + (!step_.has_static && !step_.frequency)) { const auto location = input_.blocks.empty() ? SourceLocation{input_.source_path, 0U} : input_.blocks.back().location; InputFailure("invalid-model-cardinality", location, "", "", "The model requires part, assembly, identity instance, " - "material, and one STATIC step."); + "material, and one supported procedure step."); } } @@ -2068,6 +2409,89 @@ class DomainMappingContext::Impl { } } + void FinalizeDensities() { + if (!step_.frequency) { + if (std::any_of(materials_.begin(), materials_.end(), + [](const RawMaterial& material) { + return material.density.has_value(); + })) { + InputFailure("invalid-density-data", step_.location, "DENSITY", "", + "DENSITY is supported only by the FREQUENCY procedure."); + } + return; + } + ValidateFrequencyPreprint(); + if (failure_) { + return; + } + if (model_element_family_ != ElementFamily::kShell) { + InputFailure("unsupported-analysis-procedure", step_.location, + "FREQUENCY", step_.name_raw, + "FREQUENCY requires the approved shell model family."); + return; + } + if (!element_surfaces_.empty()) { + InputFailure("unsupported-keyword", element_surfaces_.front().location, + "SURFACE", element_surfaces_.front().name, + "Frequency models do not support element surfaces."); + return; + } + for (std::size_t index = 0U; index < materials_.size(); ++index) { + const RawMaterial& material = materials_[index]; + if (!material.density) { + bool active = false; + for (const auto& section : definition_.shell_sections) { + if (section.material_index == static_cast(index)) { + active = true; + break; + } + } + if (active) { + ModelFailure("missing-material-density", material.location, + "MATERIAL", material.name, + "Each active frequency material requires DENSITY."); + return; + } + continue; + } + definition_.densities.push_back({static_cast(index), + *material.density, + material.density_location}); + } + } + + void ValidateFrequencyPreprint() { + for (const auto& block : input_.blocks) { + if (block.canonical_name != "PREPRINT") { + continue; + } + if (!ValidateParameters(block, {"ECHO", "MODEL", "HISTORY", "CONTACT"}) || + block.parameters.size() != 4U) { + if (!failure_) { + InputFailure("unsupported-keyword", block.location, + block.canonical_name, "", + "FREQUENCY requires PREPRINT with ECHO, MODEL, HISTORY, " + "and CONTACT parameters."); + } + return; + } + for (const auto& parameter : block.parameters) { + if (!parameter.value || + !AsciiCaseInsensitiveEquals(*parameter.value, "NO")) { + InputFailure("unsupported-keyword", block.location, + block.canonical_name, parameter.name, + "FREQUENCY requires PREPRINT parameters to equal NO."); + return; + } + } + } + for (auto& warning : definition_.warnings) { + if (warning.keyword == "PREPRINT") { + warning.message = "mandatory-eigen-output-unchanged"; + } + } + } + void FinalizePartsAndSections() { part_section_assignments_.resize(parts_.size()); part_shell_section_assignments_.resize(parts_.size()); @@ -2499,9 +2923,8 @@ class DomainMappingContext::Impl { bool AppendSurfaceFacet( const RawSurfaceMember& member, const RawInstance& raw_instance, - std::size_t instance_ordinal, - EntityIndex element_index, SurfaceMemberKind member_kind, - const std::string& elset_name_raw, + std::size_t instance_ordinal, EntityIndex element_index, + SurfaceMemberKind member_kind, const std::string& elset_name_raw, std::vector>* candidates) { if (static_cast(element_index) >= @@ -2513,31 +2936,30 @@ class DomainMappingContext::Impl { } const RawPart* part = FindPart(raw_instance.part_name); if (part == nullptr) { - return InputFailure("unresolved-reference", member.location, "SURFACE", - member.target, - "The surface instance must resolve to its source part."); + return InputFailure( + "unresolved-reference", member.location, "SURFACE", member.target, + "The surface instance must resolve to its source part."); } const auto& element = definition_.shell_elements[element_index]; const std::string source_type = element.source_type == ShellSourceElementType::kS4 ? "S4" : "S4R"; - ElementSurfaceFacetDefinition facet{ - element_index, - 0U, - part->name, - CanonicalizeAscii(part->name), - raw_instance.name, - CanonicalizeAscii(raw_instance.name), - element.source_id.source_label, - element.source_id.source_label_text, - source_type, - member.side, - member.location, - member.ordinal, - member_kind, - elset_name_raw, - member_kind == SurfaceMemberKind::kElset - ? CanonicalizeAscii(elset_name_raw) - : ""}; + ElementSurfaceFacetDefinition facet{element_index, + 0U, + part->name, + CanonicalizeAscii(part->name), + raw_instance.name, + CanonicalizeAscii(raw_instance.name), + element.source_id.source_label, + element.source_id.source_label_text, + source_type, + member.side, + member.location, + member.ordinal, + member_kind, + elset_name_raw, + member_kind == SurfaceMemberKind::kElset + ? CanonicalizeAscii(elset_name_raw) + : ""}; candidates->push_back({std::move(facet), instance_ordinal}); return true; } @@ -2567,7 +2989,8 @@ class DomainMappingContext::Impl { return false; } const auto raw_instance = std::find_if( - instances_.begin(), instances_.end(), [&instance_name](const RawInstance& value) { + instances_.begin(), instances_.end(), + [&instance_name](const RawInstance& value) { return AsciiCaseInsensitiveEquals(value.name, instance_name); }); if (raw_instance == instances_.end()) { @@ -2589,16 +3012,16 @@ class DomainMappingContext::Impl { value.source_id.source_label == source_label; }); if (element == definition_.shell_elements.end()) { - return InputFailure("unresolved-reference", member.location, "SURFACE", - member.target, - "A direct surface member must resolve to an S4 or S4R."); + return InputFailure( + "unresolved-reference", member.location, "SURFACE", member.target, + "A direct surface member must resolve to an S4 or S4R."); } - return AppendSurfaceFacet( - member, *raw_instance, - static_cast(std::distance(instances_.begin(), raw_instance)), - static_cast( - std::distance(definition_.shell_elements.begin(), element)), - SurfaceMemberKind::kDirect, "", candidates); + return AppendSurfaceFacet(member, *raw_instance, + static_cast(std::distance( + instances_.begin(), raw_instance)), + static_cast(std::distance( + definition_.shell_elements.begin(), element)), + SurfaceMemberKind::kDirect, "", candidates); } bool ResolveElsetSurfaceMember( @@ -2611,7 +3034,8 @@ class DomainMappingContext::Impl { "A surface ELSET must use the approved name syntax."); } const auto raw_set = std::find_if( - assembly_sets_.begin(), assembly_sets_.end(), [&member](const RawAssemblySet& value) { + assembly_sets_.begin(), assembly_sets_.end(), + [&member](const RawAssemblySet& value) { return !value.is_node_set && AsciiCaseInsensitiveEquals(value.name, member.target); }); @@ -2630,12 +3054,14 @@ class DomainMappingContext::Impl { "A surface ELSET must be assembly-scoped and explicit-instance."); } const auto raw_instance = std::find_if( - instances_.begin(), instances_.end(), [&raw_set](const RawInstance& value) { + instances_.begin(), instances_.end(), + [&raw_set](const RawInstance& value) { return AsciiCaseInsensitiveEquals(value.name, raw_set->instance_name); }); if (raw_instance == instances_.end()) { return InputFailure("unresolved-reference", member.location, "SURFACE", - member.target, "The surface ELSET instance must resolve."); + member.target, + "The surface ELSET instance must resolve."); } const auto element_set = std::find_if( definition_.element_sets.begin(), definition_.element_sets.end(), @@ -2646,17 +3072,16 @@ class DomainMappingContext::Impl { raw_set->instance_name); }); if (element_set == definition_.element_sets.end()) { - return InputFailure("unresolved-reference", member.location, "SURFACE", - member.target, - "The surface ELSET must expand in its explicit instance."); + return InputFailure( + "unresolved-reference", member.location, "SURFACE", member.target, + "The surface ELSET must expand in its explicit instance."); } for (const EntityIndex element_index : element_set->element_indices) { - if (!AppendSurfaceFacet( - member, *raw_instance, - static_cast( - std::distance(instances_.begin(), raw_instance)), - element_index, SurfaceMemberKind::kElset, member.target, - candidates)) { + if (!AppendSurfaceFacet(member, *raw_instance, + static_cast(std::distance( + instances_.begin(), raw_instance)), + element_index, SurfaceMemberKind::kElset, + member.target, candidates)) { return false; } } @@ -2671,9 +3096,8 @@ class DomainMappingContext::Impl { candidates; for (const auto& member : surface.members) { const bool is_direct = member.target.find('.') != std::string::npos; - if (!(is_direct - ? ResolveDirectSurfaceMember(member, &candidates) - : ResolveElsetSurfaceMember(member, &candidates))) { + if (!(is_direct ? ResolveDirectSurfaceMember(member, &candidates) + : ResolveElsetSurfaceMember(member, &candidates))) { return; } } @@ -2686,28 +3110,25 @@ class DomainMappingContext::Impl { std::vector> unique_facets; for (auto& candidate : candidates) { - const auto same_element = std::find_if( - unique_facets.begin(), unique_facets.end(), - [&candidate](const auto& existing) { - return existing.first.instance_name_canonical == - candidate.first.instance_name_canonical && - existing.first.source_element_label == - candidate.first.source_element_label; - }); + const auto same_element = + std::find_if(unique_facets.begin(), unique_facets.end(), + [&candidate](const auto& existing) { + return existing.first.instance_name_canonical == + candidate.first.instance_name_canonical && + existing.first.source_element_label == + candidate.first.source_element_label; + }); if (same_element != unique_facets.end()) { if (same_element->first.side != candidate.first.side) { - InputFailure("conflicting-surface-membership", - candidate.first.first_member_location, "SURFACE", - surface.name, - "One shell element cannot appear on both surface sides."); + InputFailure( + "conflicting-surface-membership", + candidate.first.first_member_location, "SURFACE", surface.name, + "One shell element cannot appear on both surface sides."); return; } definition_.warnings.push_back( - {Severity::kWarning, - "duplicate-surface-member-normalized", - candidate.first.first_member_location, - "SURFACE", - surface.name, + {Severity::kWarning, "duplicate-surface-member-normalized", + candidate.first.first_member_location, "SURFACE", surface.name, "A repeated same-side surface member was normalized once."}); continue; } @@ -2725,11 +3146,14 @@ class DomainMappingContext::Impl { rhs_side); }); - ElementSurfaceDefinition resolved{surface.name, CanonicalizeAscii(surface.name), - {}, surface.location, + ElementSurfaceDefinition resolved{surface.name, + CanonicalizeAscii(surface.name), + {}, + surface.location, surface.declaration_ordinal}; resolved.facets.reserve(unique_facets.size()); - for (std::size_t ordinal = 0U; ordinal < unique_facets.size(); ++ordinal) { + for (std::size_t ordinal = 0U; ordinal < unique_facets.size(); + ++ordinal) { unique_facets[ordinal].first.facet_ordinal = ordinal; resolved.facets.push_back(std::move(unique_facets[ordinal].first)); } @@ -2783,6 +3207,12 @@ class DomainMappingContext::Impl { model_boundaries_; boundaries.insert(boundaries.end(), step_.boundaries.begin(), step_.boundaries.end()); + if (step_.frequency) { + ValidateEigenBoundaries(boundaries); + if (failure_) { + return; + } + } const SourceTargetIndex target_index = BuildSourceTargetIndex(); const SourceTargetResolver target_resolver{target_index}; @@ -2815,6 +3245,11 @@ class DomainMappingContext::Impl { return; } } + if (step_.frequency) { + step_.frequency->boundaries = std::move(boundaries); + definition_.frequency_step = std::move(step_.frequency); + return; + } std::vector surface_pressure_loads; surface_pressure_loads.reserve(step_.surface_pressure_loads.size()); for (const auto& load : step_.surface_pressure_loads) { @@ -2831,19 +3266,12 @@ class DomainMappingContext::Impl { return; } surface_pressure_loads.push_back( - {step_.name_raw, - step_.name_canonical, + {step_.name_raw, step_.name_canonical, static_cast( std::distance(definition_.element_surfaces.begin(), surface)), - load.surface_name, - CanonicalizeAscii(load.surface_name), - "P", - load.magnitude, - load.magnitude_raw, - load.keyword_location, - load.data_location, - load.data_ordinal, - load.load_ordinal}); + load.surface_name, CanonicalizeAscii(load.surface_name), "P", + load.magnitude, load.magnitude_raw, load.keyword_location, + load.data_location, load.data_ordinal, load.load_ordinal}); } // Static time-control values are provenance only: V0 creates exactly @@ -2890,6 +3318,7 @@ class DomainMappingContext::Impl { bool step_load_seen_{false}; bool step_no_op_seen_{false}; bool active_output_{false}; + bool active_output_is_field_{false}; }; DomainMappingContext::DomainMappingContext(const ParsedInput& input) diff --git a/src/fesa/io/hdf5/eigen_hdf5_results_writer.cpp b/src/fesa/io/hdf5/eigen_hdf5_results_writer.cpp new file mode 100644 index 0000000..6da8782 --- /dev/null +++ b/src/fesa/io/hdf5/eigen_hdf5_results_writer.cpp @@ -0,0 +1,974 @@ +#include "fesa/io/hdf5/eigen_hdf5_results_writer.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "fesa/analysis/analysis_model.h" +#include "fesa/build_info.h" +#include "fesa/fem/dof_manager.h" +#include "fesa/model/domain.h" +#include "io/hdf5/eigen_hdf5_self_check.h" +#include "io/hdf5/hdf5_atomic_file.h" +#include "io/hdf5/hdf5_model_writer.h" +#include "io/hdf5/hdf5_primitives.h" +#include "io/hdf5/hdf5_raii.h" + +namespace fesa { +namespace { + +constexpr const char* kEigenRoot = "/steps/Step-1/eigen"; + +void WriteQuantityAttributes(hid_t dataset, const std::string& components, + const std::string& units, + const std::string& coordinates, + const std::string& location) { + hdf5_internal::WriteStringAttribute(dataset, "component_names", components); + hdf5_internal::WriteStringAttribute(dataset, "component_unit_dimensions", + units); + hdf5_internal::WriteStringAttribute(dataset, "coordinate_system", + coordinates); + hdf5_internal::WriteStringAttribute(dataset, "location", location); +} + +void WriteModalAttributes(hid_t dataset, const std::string& source_step) { + hdf5_internal::WriteUint64Attribute(dataset, "eigen_schema_version", 1U); + hdf5_internal::WriteStringAttribute(dataset, "source_step_name", source_step); + hdf5_internal::WriteStringAttribute(dataset, "domain", "mode"); +} + +bool IsFinite(const Matrix& matrix) { + for (std::size_t row = 0U; row < matrix.Rows(); ++row) { + for (std::size_t column = 0U; column < matrix.Columns(); ++column) { + if (!std::isfinite(matrix(row, column))) { + return false; + } + } + } + return true; +} + +Status Validate(const Domain& domain, const EigenAnalysisState& state, + const std::vector& diagnostics) { + const FrequencyStepDefinition* frequency = domain.Frequency(); + if (frequency == nullptr || !hdf5_internal::IsShellDomain(domain)) { + return hdf5_internal::OutputFailure( + "invalid-eigen-result-state", + "Eigen HDF5 output requires one shell frequency domain."); + } + std::set density_materials; + EntityIndex previous_density_material = 0U; + bool first_density = true; + for (const MaterialDensityDefinition& density : domain.Densities()) { + if (density.material_index >= domain.Materials().Size() || + !std::isfinite(density.rho) || !(density.rho > 0.0) || + (!first_density && + density.material_index <= previous_density_material)) { + return hdf5_internal::OutputFailure("invalid-eigen-result-state", + "Eigen material density inventory " + "must be finite and source ordered."); + } + density_materials.insert(density.material_index); + previous_density_material = density.material_index; + first_density = false; + } + for (std::size_t index = 0U; index < domain.ShellElements().Size(); ++index) { + if (density_materials.count( + domain.ShellElements()[index].MaterialIndex()) == 0U) { + return hdf5_internal::OutputFailure( + "invalid-eigen-result-state", + "Every active shell material requires one positive density."); + } + } + const std::size_t node_count = domain.Nodes().size(); + if (node_count > (std::numeric_limits::max)() / 6U || + state.full_dof_count != 6U * node_count || + state.free_dof_count != + state.mass_null_dimension + state.finite_dimension || + state.modes.size() != + static_cast(frequency->requested_mode_count) || + state.vectors.Rows() != state.modes.size() || + state.vectors.Columns() != 6U * node_count || + state.nodal_reduction.size() != node_count || + state.constraint_mask.size() != 6U * node_count || + state.prescribed_displacement.size() != 6U * node_count || + state.eigenpair_checks.size() < state.modes.size() || + state.eigenpair_checks.size() > state.finite_dimension || + !IsFinite(state.vectors) || !std::isfinite(state.characteristic_length) || + !(state.characteristic_length > 0.0) || + !std::isfinite(state.backend.operator_scale) || + !(state.backend.operator_scale > 0.0)) { + return hdf5_internal::OutputFailure( + "invalid-eigen-result-state", + "Eigen state does not satisfy the durable result inventory."); + } + const auto metric_ok = [](double value, double limit) { + return std::isfinite(value) && value >= 0.0 && value <= limit; + }; + const auto& backend = state.backend; + if (backend.base_fpm.size() != 128U || backend.backend_calls.empty() || + backend.backend_calls.size() > 512U || backend.thread_count != 1U || + backend.seed != 0x4D495443344D4645ULL || + backend.runtime_version.empty() || + (backend.integer_abi != "LP64" && backend.integer_abi != "ILP64") || + backend.requested_modes != state.modes.size() || + !metric_ok(backend.gram_error, 1e-10) || + !std::isfinite(backend.enclosure_lower) || + !std::isfinite(backend.enclosure_upper) || + !std::isfinite(backend.solve_lower) || + !std::isfinite(backend.solve_upper) || + !(backend.enclosure_lower < backend.enclosure_upper) || + backend.solve_lower != backend.enclosure_lower || + !(backend.solve_lower < backend.solve_upper) || + backend.solve_upper > backend.enclosure_upper) { + return hdf5_internal::OutputFailure("invalid-eigen-result-state", + "Backend inventory is invalid."); + } + for (const auto& control : + std::array, 11>{{{0U, 0}, + {1U, 16}, + {2U, 12}, + {3U, 20}, + {4U, 1}, + {5U, 1}, + {13U, 0}, + {26U, 1}, + {27U, 1}, + {49U, 0}, + {63U, 0}}}) { + if (backend.base_fpm[control.first] != control.second) + return hdf5_internal::OutputFailure( + "invalid-eigen-result-state", + "Backend controls differ from the numerical contract."); + } + std::size_t solve_calls = 0U; + bool enclosed = false; + for (std::size_t i = 0U; i < backend.backend_calls.size(); ++i) { + const auto& call = backend.backend_calls[i]; + const bool count = call.kind == "count", solve = call.kind == "solve"; + if (solve) ++solve_calls; + if (call.ordinal != i || (!count && !solve) || !std::isfinite(call.lower) || + !std::isfinite(call.upper) || !(call.lower < call.upper) || + call.allocated_m0 == 0U || call.allocated_m0 > state.finite_dimension || + (count && call.allocated_m0 != state.finite_dimension) || + (call.count_available && call.count > call.allocated_m0) || + (!call.count_available && call.count != 0U) || + (call.loop_available && call.loop < 0) || + (!call.loop_available && call.loop != 0) || + (call.epsout_available && !std::isfinite(call.epsout)) || + (!call.epsout_available && call.epsout != 0.0) || + (call.outcome != "accepted" && call.outcome != "retry" && + call.outcome != "optional-abandoned") || + (call.outcome == "accepted" && + (call.info != 0 || !call.count_available)) || + call.decision_reason.empty() || + (enclosed && call.lower != backend.enclosure_lower)) { + return hdf5_internal::OutputFailure( + "invalid-eigen-result-state", + "Backend call history is inconsistent."); + } + if (call.purpose == "total-enclosure" && call.outcome == "accepted" && + call.count == state.finite_dimension) + enclosed = true; + } + const auto& last_call = backend.backend_calls.back(); + if (!enclosed || solve_calls == 0U || solve_calls > 3U || + last_call.kind != "solve" || last_call.outcome != "accepted" || + last_call.count != state.eigenpair_checks.size() || + !last_call.loop_available || !last_call.epsout_available || + last_call.lower != backend.solve_lower || + last_call.upper != backend.solve_upper) { + return hdf5_internal::OutputFailure( + "invalid-eigen-result-state", "Final all-c solve evidence is invalid."); + } + if (!metric_ok(state.eta_gram, 1e-10) || + !metric_ok(state.max_eta_orth, 1e-10) || + !metric_ok(state.eta_mz, 1e-12) || !metric_ok(state.eta_kz, 1e-12) || + !metric_ok(state.eta_pz, 1e-12) || + state.null_checks_applicable != (state.mass_null_dimension != 0U) || + (!state.null_checks_applicable && + (state.eta_mz != 0.0 || state.eta_kz != 0.0 || state.eta_pz != 0.0))) { + return hdf5_internal::OutputFailure( + "invalid-eigen-result-state", "Global modal verification is invalid."); + } + const auto model = AnalysisModel::Create(domain); + if (!model.HasValue()) return model.GetStatus(); + const auto dofs = DofManager::Create(model.Value()); + if (!dofs.HasValue()) return dofs.GetStatus(); + for (std::size_t i = 0U; i < state.full_dof_count; ++i) { + const auto constrained = + static_cast(!dofs.Value().FreeEquation(i).has_value()); + if (state.constraint_mask[i] != constrained) + return hdf5_internal::OutputFailure( + "invalid-eigen-result-state", "Constraint mask differs from Domain."); + if (constrained != 0U) { + for (std::size_t mode = 0U; mode < state.vectors.Rows(); ++mode) + if (state.vectors(mode, i) != 0.0) + return hdf5_internal::OutputFailure( + "invalid-eigen-result-state", + "Constrained modal component is nonzero."); + } + } + std::size_t cluster = 1U; + for (std::size_t index = 0U; index < state.modes.size(); ++index) { + const EigenMode& mode = state.modes[index]; + const double mu = mode.eigenvalue / state.backend.operator_scale; + if (index > 0U && + mu - state.modes[index - 1U].eigenvalue / state.backend.operator_scale > + 1e-8) + ++cluster; + if (mode.mode_id != index + 1U || !std::isfinite(mode.eigenvalue) || + !std::isfinite(mu) || mu < -1e-9 || mode.cluster_id != cluster || + mode.classification != static_cast(std::abs(mu) > 1e-9) || + (index > 0U && mode.eigenvalue < state.modes[index - 1U].eigenvalue) || + (mode.eigenvalue < 0.0 && mode.frequency.has_value()) || + (mode.eigenvalue >= 0.0 && !mode.frequency.has_value()) || + (mode.frequency && + (!std::isfinite(*mode.frequency) || *mode.frequency < 0.0 || + (mode.eigenvalue > 0.0 && *mode.frequency == 0.0) || + *mode.frequency != + (mode.eigenvalue == 0.0 + ? 0.0 + : std::sqrt(mode.eigenvalue) / (2.0 * std::acos(-1.0)))))) { + return hdf5_internal::OutputFailure("invalid-eigen-result-state", + "Eigen mode inventory is invalid."); + } + } + std::size_t free_count = 0U; + std::size_t null_count = 0U; + std::size_t positive_count = 0U; + for (const EigenNodalReductionRecord& record : state.nodal_reduction) { + if (record.free_count != record.null_count + record.positive_count || + record.free_count > 6U || record.null_count > 1U || + record.rcond_available != (record.positive_count != 0U) || + (record.rcond_available && + (!std::isfinite(record.rcond_inf) || + record.rcond_inf < std::ldexp(1.0, -32) || record.rcond_inf > 1.0)) || + (!record.rcond_available && record.rcond_inf != 0.0)) { + return hdf5_internal::OutputFailure( + "invalid-eigen-result-state", + "Nodal mass-reduction evidence is invalid."); + } + free_count += record.free_count; + null_count += record.null_count; + positive_count += record.positive_count; + } + if (free_count != state.free_dof_count || + null_count != state.mass_null_dimension || + positive_count != state.finite_dimension) { + return hdf5_internal::OutputFailure( + "invalid-eigen-result-state", + "Nodal mass-reduction counts do not match state dimensions."); + } + std::set columns; + for (std::size_t index = 0U; index < state.eigenpair_checks.size(); ++index) { + const EigenpairCheck& check = state.eigenpair_checks[index]; + if (check.sorted_rank != index + 1U || !std::isfinite(check.eigenvalue) || + check.solver_column >= state.eigenpair_checks.size() || + !columns.insert(check.solver_column).second || + (index < state.modes.size() && + check.eigenvalue != state.modes[index].eigenvalue) || + (index > 0U && + check.eigenvalue < state.eigenpair_checks[index - 1U].eigenvalue) || + !std::isfinite(check.modal_mass) || + std::abs(check.modal_mass - 1.0) > 1e-10 || + !std::isfinite(check.rayleigh_quotient) || + !std::isfinite(check.eta_r) || !std::isfinite(check.eta_w) || + !std::isfinite(check.rho_b) || !std::isfinite(check.eta_norm) || + !metric_ok(check.eta_r, 1e-10) || !metric_ok(check.eta_w, 1e-10) || + !metric_ok(check.rho_b, 1e-10) || !metric_ok(check.eta_norm, 1e-10) || + !metric_ok(check.eta_energy, 1e-10) || + (check.backend_res_available && !std::isfinite(check.backend_res)) || + (!check.backend_res_available && check.backend_res != 0.0)) { + return hdf5_internal::OutputFailure( + "invalid-eigen-result-state", + "Eigenpair verification evidence is invalid."); + } + } + for (std::uint8_t value : state.constraint_mask) { + if (value > 1U) { + return hdf5_internal::OutputFailure("invalid-eigen-result-state", + "Constraint mask must be binary."); + } + } + for (double value : state.prescribed_displacement) { + if (value != 0.0) { + return hdf5_internal::OutputFailure( + "invalid-eigen-result-state", + "Eigen prescribed displacements must be exact zero."); + } + } + for (const Diagnostic& diagnostic : diagnostics) { + if (diagnostic.severity != Severity::kWarning) { + return hdf5_internal::OutputFailure( + "invalid-eigen-diagnostics", + "Successful eigen output stores warnings only."); + } + } + return Status::Ok(); +} + +void ReplaceStringAttribute(hid_t object, const char* name, + const std::string& value) { + if (H5Aexists(object, name) > 0) { + hdf5_internal::RequireHdf5(H5Adelete(object, name), + "Unable to replace HDF5 metadata attribute."); + } + hdf5_internal::WriteStringAttribute(object, name, value); +} + +void WriteEigenMetadata(hid_t file) { + hdf5_internal::Hdf5Handle metadata{ + hdf5_internal::RequireHdf5Id(H5Gopen2(file, "/metadata", H5P_DEFAULT), + "Unable to open metadata."), + H5Gclose}; + ReplaceStringAttribute(metadata.Get(), "feature_id", + "mitc4-shell-feast-eigen2"); + ReplaceStringAttribute(metadata.Get(), "analysis_procedure", + "linear-frequency"); + ReplaceStringAttribute(metadata.Get(), "coordinate_convention", + "global-cartesian; shell-local=initial-reference; " + "positive-thickness=+zeta"); + hdf5_internal::WriteUint64Attribute(metadata.Get(), "eigen_schema_version", + 1U); + hdf5_internal::WriteStringAttribute(metadata.Get(), "mass_integration_rule", + "3x3x3-gauss"); + hdf5_internal::WriteStringAttribute(metadata.Get(), "stabilization_rule", + "initial-physical-rotary-trace-mean"); + hdf5_internal::WriteStringAttribute(metadata.Get(), "mass_variant", + "derived-lumped"); + hdf5_internal::WriteStringAttribute(metadata.Get(), "mass_origin", + "consistent-moment-row-sum"); + hdf5_internal::WriteStringAttribute(metadata.Get(), "normalization", "mass"); + hdf5_internal::WriteStringAttribute(metadata.Get(), "eigensolver_backend", + "MKL FEAST"); + hdf5_internal::WriteStringAttribute(metadata.Get(), "eigensolver_routine", + "dfeast_scsrgv"); + hdf5_internal::WriteStringAttribute( + metadata.Get(), "backend_problem", + "scaled-whitened-lumped-with-identity-mass"); + hdf5_internal::WriteStringAttribute(metadata.Get(), "numerical_contract", + "mitc4-shell-feast-eigen2:r1"); +} + +void WriteStep(hid_t file, const Domain& domain, + const EigenAnalysisState& state) { + const FrequencyStepDefinition& source = *domain.Frequency(); + auto step = hdf5_internal::CreateGroup(file, "/steps/Step-1"); + hdf5_internal::WriteStringAttribute(step.Get(), "source_step_name", + source.source_step_name_raw); + hdf5_internal::WriteStringAttribute(step.Get(), "source_step_name_canonical", + source.source_step_name_canonical); + hdf5_internal::WriteUint64Attribute(step.Get(), "step_source_line", + source.step_location.line); + hdf5_internal::WriteUint64Attribute(step.Get(), "frequency_source_line", + source.frequency_location.line); + hdf5_internal::WriteUint64Attribute(step.Get(), "frequency_data_line", + source.data_location.line); + hdf5_internal::WriteUint8Attribute(step.Get(), "source_nlgeom", 0U); + hdf5_internal::WriteUint8Attribute( + step.Get(), "source_perturbation_present", + static_cast(source.perturbation_explicit)); + const std::array presence{ + static_cast(source.eigensolver_explicit), + static_cast(source.sim_explicit), + static_cast(source.acoustic_coupling_explicit), + static_cast(source.normalization_explicit)}; + auto presence_space = hdf5_internal::CreateDatasetSpace({4U}); + hdf5_internal::Hdf5Handle presence_attr{ + hdf5_internal::RequireHdf5Id( + H5Acreate2(step.Get(), "source_option_presence", H5T_STD_U8LE, + presence_space.Get(), H5P_DEFAULT, H5P_DEFAULT), + "Unable to create source option presence."), + H5Aclose}; + hdf5_internal::RequireHdf5( + H5Awrite(presence_attr.Get(), H5T_NATIVE_UINT8, presence.data()), + "Unable to write source option presence."); + hdf5_internal::WriteStringAttribute(step.Get(), "source_eigensolver", + "LANCZOS"); + hdf5_internal::WriteStringAttribute(step.Get(), "source_sim", "YES"); + hdf5_internal::WriteStringAttribute(step.Get(), "source_acoustic_coupling", + "ON"); + hdf5_internal::WriteStringAttribute(step.Get(), "source_normalization", + "MASS"); + hdf5_internal::WriteUint64Attribute(step.Get(), "requested_mode_count", + state.modes.size()); + hdf5_internal::WriteUint64Attribute(step.Get(), "returned_mode_count", + state.modes.size()); + hdf5_internal::WriteUint64Attribute(step.Get(), "full_dof_count", + state.full_dof_count); + hdf5_internal::WriteUint64Attribute(step.Get(), "free_dof_count", + state.free_dof_count); + hdf5_internal::WriteUint64Attribute(step.Get(), "mass_null_dimension", + state.mass_null_dimension); + hdf5_internal::WriteUint64Attribute(step.Get(), "finite_dimension", + state.finite_dimension); + hdf5_internal::WriteUint64Attribute(step.Get(), "validated_eigenpair_count", + state.eigenpair_checks.size()); + hdf5_internal::WriteStringAttribute(step.Get(), "frame_indexing", + "mode-rank-one-based"); +} + +struct ModeRow { + std::uint64_t mode_id; + double eigenvalue; + std::uint8_t classification; + std::uint64_t cluster_id; +}; +struct FrequencyRow { + std::uint64_t mode_id; + double frequency; +}; +struct BackendCallRow { + std::uint64_t ordinal; + const char* kind; + const char* purpose; + double lower; + double upper; + std::uint64_t allocated_m0; + std::int64_t info; + std::uint8_t count_available; + std::uint64_t count; + std::uint8_t loop_available; + std::uint64_t loop; + std::uint8_t epsout_available; + double epsout; + const char* outcome; + const char* decision_reason; +}; +struct DensityRow { + std::uint64_t material_internal_id; + double rho; + const char* source_file; + std::uint64_t source_line; +}; +struct NodalReductionRow { + std::uint8_t free_count; + std::uint8_t null_count; + std::uint8_t positive_count; + std::uint8_t rcond_available; + double rcond_inf; +}; +struct EigenpairCheckRow { + std::uint64_t sorted_rank; + std::uint64_t solver_column; + double eigenvalue; + double modal_mass; + double rayleigh_quotient; + double eta_r; + double eta_w; + double rho_b; + double eta_norm; + double eta_energy; + std::uint8_t backend_res_available; + double backend_res; +}; +struct DiagnosticRow { + const char* severity; + const char* code; + const char* file; + std::uint64_t line; + const char* keyword; + const char* entity_identity; + const char* message; +}; + +template +hdf5_internal::Hdf5Handle MakeCompoundType() { + return hdf5_internal::Hdf5Handle{ + hdf5_internal::RequireHdf5Id(H5Tcreate(H5T_COMPOUND, sizeof(Row)), + "Unable to create compound type."), + H5Tclose}; +} + +void WriteModes(hid_t file, const EigenAnalysisState& state, + const std::string& source_step) { + std::vector modes; + std::vector frequencies; + modes.reserve(state.modes.size()); + for (const EigenMode& mode : state.modes) { + modes.push_back( + {mode.mode_id, mode.eigenvalue, mode.classification, mode.cluster_id}); + if (mode.frequency) frequencies.push_back({mode.mode_id, *mode.frequency}); + } + auto mode_file = MakeCompoundType(); + auto mode_memory = MakeCompoundType(); + for (hid_t type : {mode_file.Get(), mode_memory.Get()}) { + hdf5_internal::RequireHdf5( + H5Tinsert(type, "mode_id", HOFFSET(ModeRow, mode_id), H5T_STD_U64LE), + "Unable to define mode ID."); + hdf5_internal::RequireHdf5( + H5Tinsert(type, "eigenvalue", HOFFSET(ModeRow, eigenvalue), + H5T_IEEE_F64LE), + "Unable to define eigenvalue."); + hdf5_internal::RequireHdf5( + H5Tinsert(type, "classification", HOFFSET(ModeRow, classification), + H5T_STD_U8LE), + "Unable to define classification."); + hdf5_internal::RequireHdf5( + H5Tinsert(type, "cluster_id", HOFFSET(ModeRow, cluster_id), + H5T_STD_U64LE), + "Unable to define cluster ID."); + } + auto dataset = hdf5_internal::WriteCompoundDataset( + file, "/steps/Step-1/eigen/modes", modes.size(), mode_file.Get(), + mode_memory.Get(), modes.data()); + WriteQuantityAttributes(dataset.Get(), "Value", "time^-2", "scalar", + "modal-frame"); + WriteModalAttributes(dataset.Get(), source_step); + hdf5_internal::WriteStringAttribute(dataset.Get(), "quantity", "eigenvalue"); + auto frequency_file = MakeCompoundType(); + auto frequency_memory = MakeCompoundType(); + for (hid_t type : {frequency_file.Get(), frequency_memory.Get()}) { + hdf5_internal::RequireHdf5( + H5Tinsert(type, "mode_id", HOFFSET(FrequencyRow, mode_id), + H5T_STD_U64LE), + "Unable to define frequency mode ID."); + hdf5_internal::RequireHdf5( + H5Tinsert(type, "frequency", HOFFSET(FrequencyRow, frequency), + H5T_IEEE_F64LE), + "Unable to define frequency."); + } + auto frequency_dataset = hdf5_internal::WriteCompoundDataset( + file, "/steps/Step-1/eigen/frequencies", frequencies.size(), + frequency_file.Get(), frequency_memory.Get(), frequencies.data()); + WriteQuantityAttributes(frequency_dataset.Get(), "Freq", "time^-1", "scalar", + "modal-frame"); + WriteModalAttributes(frequency_dataset.Get(), source_step); + hdf5_internal::WriteStringAttribute(frequency_dataset.Get(), "quantity", + "cycle-frequency"); + hdf5_internal::WriteStringAttribute( + frequency_dataset.Get(), "unavailable_rule", "negative-raw-eigenvalue"); +} + +void WriteBaseFpm(hid_t file, const std::vector& values) { + const std::vector dimensions{static_cast(values.size())}; + auto space = hdf5_internal::CreateDatasetSpace(dimensions); + hdf5_internal::Hdf5Handle dataset{ + hdf5_internal::RequireHdf5Id( + H5Dcreate2(file, "/steps/Step-1/eigen/backend/base_fpm", + H5T_STD_I64LE, space.Get(), H5P_DEFAULT, H5P_DEFAULT, + H5P_DEFAULT), + "Unable to create eigen backend control dataset."), + H5Dclose}; + if (!values.empty()) { + hdf5_internal::RequireHdf5( + H5Dwrite(dataset.Get(), H5T_NATIVE_INT64, H5S_ALL, H5S_ALL, H5P_DEFAULT, + values.data()), + "Unable to write eigen backend control dataset."); + } + WriteQuantityAttributes(dataset.Get(), "fpm", "1", "none", + "backend-parameter"); +} + +void WriteBackendCalls(hid_t file, const std::vector& calls) { + std::vector rows; + rows.reserve(calls.size()); + for (const FeastBackendCall& call : calls) { + rows.push_back({call.ordinal, call.kind.c_str(), call.purpose.c_str(), + call.lower, call.upper, call.allocated_m0, call.info, + static_cast(call.count_available), call.count, + static_cast(call.loop_available), + static_cast(call.loop), + static_cast(call.epsout_available), + call.epsout, call.outcome.c_str(), + call.decision_reason.c_str()}); + } + auto string_type = hdf5_internal::MakeUtf8StringType(); + auto file_type = MakeCompoundType(); + auto memory_type = MakeCompoundType(); + const auto add_fields = [&](hid_t type, hid_t u64, hid_t i64, hid_t f64) { + hdf5_internal::RequireHdf5( + H5Tinsert(type, "ordinal", HOFFSET(BackendCallRow, ordinal), u64), + "Unable to define backend ordinal."); + hdf5_internal::RequireHdf5( + H5Tinsert(type, "kind", HOFFSET(BackendCallRow, kind), + string_type.Get()), + "Unable to define backend kind."); + hdf5_internal::RequireHdf5( + H5Tinsert(type, "purpose", HOFFSET(BackendCallRow, purpose), + string_type.Get()), + "Unable to define backend purpose."); + hdf5_internal::RequireHdf5( + H5Tinsert(type, "lower", HOFFSET(BackendCallRow, lower), f64), + "Unable to define backend lower."); + hdf5_internal::RequireHdf5( + H5Tinsert(type, "upper", HOFFSET(BackendCallRow, upper), f64), + "Unable to define backend upper."); + hdf5_internal::RequireHdf5( + H5Tinsert(type, "allocated_m0", HOFFSET(BackendCallRow, allocated_m0), + u64), + "Unable to define backend capacity."); + hdf5_internal::RequireHdf5( + H5Tinsert(type, "info", HOFFSET(BackendCallRow, info), i64), + "Unable to define backend info."); + hdf5_internal::RequireHdf5( + H5Tinsert(type, "count_available", + HOFFSET(BackendCallRow, count_available), H5T_STD_U8LE), + "Unable to define count flag."); + hdf5_internal::RequireHdf5( + H5Tinsert(type, "count", HOFFSET(BackendCallRow, count), u64), + "Unable to define count."); + hdf5_internal::RequireHdf5( + H5Tinsert(type, "loop_available", + HOFFSET(BackendCallRow, loop_available), H5T_STD_U8LE), + "Unable to define loop flag."); + hdf5_internal::RequireHdf5( + H5Tinsert(type, "loop", HOFFSET(BackendCallRow, loop), u64), + "Unable to define loop."); + hdf5_internal::RequireHdf5( + H5Tinsert(type, "epsout_available", + HOFFSET(BackendCallRow, epsout_available), H5T_STD_U8LE), + "Unable to define epsout flag."); + hdf5_internal::RequireHdf5( + H5Tinsert(type, "epsout", HOFFSET(BackendCallRow, epsout), f64), + "Unable to define epsout."); + hdf5_internal::RequireHdf5( + H5Tinsert(type, "outcome", HOFFSET(BackendCallRow, outcome), + string_type.Get()), + "Unable to define outcome."); + hdf5_internal::RequireHdf5( + H5Tinsert(type, "decision_reason", + HOFFSET(BackendCallRow, decision_reason), string_type.Get()), + "Unable to define decision reason."); + }; + add_fields(file_type.Get(), H5T_STD_U64LE, H5T_STD_I64LE, H5T_IEEE_F64LE); + add_fields(memory_type.Get(), H5T_NATIVE_UINT64, H5T_NATIVE_INT64, + H5T_NATIVE_DOUBLE); + auto dataset = hdf5_internal::WriteCompoundDataset( + file, "/steps/Step-1/eigen/backend/calls", rows.size(), file_type.Get(), + memory_type.Get(), rows.data()); + WriteQuantityAttributes(dataset.Get(), "lower,upper,epsout", "1,1,1", + "scalar", "backend-call"); +} + +void WriteBackendAttributes(hid_t file, const EigenAnalysisState& state) { + hdf5_internal::Hdf5Handle backend{ + hdf5_internal::RequireHdf5Id( + H5Gopen2(file, "/steps/Step-1/eigen/backend", H5P_DEFAULT), + "Unable to open eigen backend group."), + H5Gclose}; + const FeastEigenResult& result = state.backend; + hdf5_internal::WriteStringAttribute(backend.Get(), "runtime_version", + result.runtime_version); + hdf5_internal::WriteStringAttribute(backend.Get(), "integer_abi", + result.integer_abi); + hdf5_internal::WriteUint64Attribute(backend.Get(), "thread_count", + result.thread_count); + hdf5_internal::WriteUint64Attribute(backend.Get(), "seed", result.seed); + hdf5_internal::WriteDoubleAttribute(backend.Get(), "enclosure_lower", + result.enclosure_lower); + hdf5_internal::WriteDoubleAttribute(backend.Get(), "enclosure_upper", + result.enclosure_upper); + hdf5_internal::WriteDoubleAttribute(backend.Get(), "solve_lower", + result.solve_lower); + hdf5_internal::WriteDoubleAttribute(backend.Get(), "solve_upper", + result.solve_upper); + std::size_t count_calls = 0U; + std::size_t solve_calls = 0U; + std::size_t final_solve_ordinal = 0U; + for (const FeastBackendCall& call : result.backend_calls) { + if (call.kind == "count") ++count_calls; + if (call.kind == "solve") { + ++solve_calls; + final_solve_ordinal = call.ordinal; + } + } + hdf5_internal::WriteUint64Attribute(backend.Get(), "count_calls", + count_calls); + hdf5_internal::WriteUint64Attribute(backend.Get(), "solve_calls", + solve_calls); + hdf5_internal::WriteUint64Attribute(backend.Get(), "final_solve_ordinal", + final_solve_ordinal); + hdf5_internal::WriteUint64Attribute(backend.Get(), "final_count", + state.eigenpair_checks.size()); + hdf5_internal::WriteStringAttribute(backend.Get(), "upper_search_termination", + result.upper_search_termination); +} + +void WriteDensities(hid_t file, const Domain& domain) { + std::vector rows; + std::vector source_paths; + rows.reserve(domain.Densities().size()); + source_paths.reserve(domain.Densities().size()); + for (const MaterialDensityDefinition& density : domain.Densities()) { + source_paths.push_back( + hdf5_internal::NormalizedPathString(density.location.file)); + rows.push_back({density.material_index, density.rho, + source_paths.back().c_str(), density.location.line}); + } + auto string_type = hdf5_internal::MakeUtf8StringType(); + auto file_type = MakeCompoundType(); + auto memory_type = MakeCompoundType(); + const auto add_fields = [&](hid_t type, hid_t u64, hid_t f64) { + hdf5_internal::RequireHdf5( + H5Tinsert(type, "material_internal_id", + HOFFSET(DensityRow, material_internal_id), u64), + "Unable to define density material ID."); + hdf5_internal::RequireHdf5( + H5Tinsert(type, "rho", HOFFSET(DensityRow, rho), f64), + "Unable to define density value."); + hdf5_internal::RequireHdf5( + H5Tinsert(type, "source_file", HOFFSET(DensityRow, source_file), + string_type.Get()), + "Unable to define density source file."); + hdf5_internal::RequireHdf5( + H5Tinsert(type, "source_line", HOFFSET(DensityRow, source_line), u64), + "Unable to define density source line."); + }; + add_fields(file_type.Get(), H5T_STD_U64LE, H5T_IEEE_F64LE); + add_fields(memory_type.Get(), H5T_NATIVE_UINT64, H5T_NATIVE_DOUBLE); + auto dataset = hdf5_internal::WriteCompoundDataset( + file, "/model/shell/densities", rows.size(), file_type.Get(), + memory_type.Get(), rows.data()); + WriteQuantityAttributes(dataset.Get(), "rho", "mass/length^3", "scalar", + "model-material"); +} + +void WriteVerification(hid_t file, const EigenAnalysisState& state) { + auto verification = + hdf5_internal::CreateGroup(file, "/steps/Step-1/eigen/verification"); + hdf5_internal::WriteDoubleAttribute( + verification.Get(), "characteristic_length", state.characteristic_length); + hdf5_internal::WriteDoubleAttribute(verification.Get(), "operator_scale", + state.backend.operator_scale); + hdf5_internal::WriteStringAttribute(verification.Get(), "validation_scope", + "all-computed-c-columns"); + hdf5_internal::WriteDoubleAttribute(verification.Get(), "eta_gram", + state.eta_gram); + hdf5_internal::WriteDoubleAttribute(verification.Get(), "max_eta_orth", + state.max_eta_orth); + hdf5_internal::WriteUint8Attribute( + verification.Get(), "null_checks_applicable", + static_cast(state.null_checks_applicable)); + hdf5_internal::WriteDoubleAttribute(verification.Get(), "eta_mz", + state.eta_mz); + hdf5_internal::WriteDoubleAttribute(verification.Get(), "eta_kz", + state.eta_kz); + hdf5_internal::WriteDoubleAttribute(verification.Get(), "eta_pz", + state.eta_pz); + + std::vector reductions; + reductions.reserve(state.nodal_reduction.size()); + for (const EigenNodalReductionRecord& record : state.nodal_reduction) { + reductions.push_back( + {record.free_count, record.null_count, record.positive_count, + static_cast(record.rcond_available), record.rcond_inf}); + } + auto reduction_file = MakeCompoundType(); + auto reduction_memory = MakeCompoundType(); + const auto add_reduction = [](hid_t type, hid_t f64) { + hdf5_internal::RequireHdf5( + H5Tinsert(type, "free_count", HOFFSET(NodalReductionRow, free_count), + H5T_STD_U8LE), + "Unable to define free count."); + hdf5_internal::RequireHdf5( + H5Tinsert(type, "null_count", HOFFSET(NodalReductionRow, null_count), + H5T_STD_U8LE), + "Unable to define null count."); + hdf5_internal::RequireHdf5( + H5Tinsert(type, "positive_count", + HOFFSET(NodalReductionRow, positive_count), H5T_STD_U8LE), + "Unable to define positive count."); + hdf5_internal::RequireHdf5( + H5Tinsert(type, "rcond_available", + HOFFSET(NodalReductionRow, rcond_available), H5T_STD_U8LE), + "Unable to define rcond availability."); + hdf5_internal::RequireHdf5( + H5Tinsert(type, "rcond_inf", HOFFSET(NodalReductionRow, rcond_inf), + f64), + "Unable to define rcond."); + }; + add_reduction(reduction_file.Get(), H5T_IEEE_F64LE); + add_reduction(reduction_memory.Get(), H5T_NATIVE_DOUBLE); + auto reduction_dataset = hdf5_internal::WriteCompoundDataset( + file, "/steps/Step-1/eigen/verification/nodal_reduction", + reductions.size(), reduction_file.Get(), reduction_memory.Get(), + reductions.data()); + WriteQuantityAttributes(reduction_dataset.Get(), "rcond_inf", "1", "scalar", + "nodal-mass-reduction"); + + std::vector checks; + checks.reserve(state.eigenpair_checks.size()); + for (const EigenpairCheck& check : state.eigenpair_checks) { + checks.push_back({check.sorted_rank, check.solver_column, check.eigenvalue, + check.modal_mass, check.rayleigh_quotient, check.eta_r, + check.eta_w, check.rho_b, check.eta_norm, + check.eta_energy, + static_cast(check.backend_res_available), + check.backend_res}); + } + auto check_file = MakeCompoundType(); + auto check_memory = MakeCompoundType(); + const auto add_check = [](hid_t type, hid_t u64, hid_t f64) { + hdf5_internal::RequireHdf5( + H5Tinsert(type, "sorted_rank", HOFFSET(EigenpairCheckRow, sorted_rank), + u64), + "Unable to define sorted rank."); + hdf5_internal::RequireHdf5( + H5Tinsert(type, "solver_column", + HOFFSET(EigenpairCheckRow, solver_column), u64), + "Unable to define solver column."); + for (const auto& field : std::array, 8>{ + {{"eigenvalue", HOFFSET(EigenpairCheckRow, eigenvalue)}, + {"modal_mass", HOFFSET(EigenpairCheckRow, modal_mass)}, + {"rayleigh_quotient", + HOFFSET(EigenpairCheckRow, rayleigh_quotient)}, + {"eta_r", HOFFSET(EigenpairCheckRow, eta_r)}, + {"eta_w", HOFFSET(EigenpairCheckRow, eta_w)}, + {"rho_b", HOFFSET(EigenpairCheckRow, rho_b)}, + {"eta_norm", HOFFSET(EigenpairCheckRow, eta_norm)}, + {"eta_energy", HOFFSET(EigenpairCheckRow, eta_energy)}}}) { + hdf5_internal::RequireHdf5( + H5Tinsert(type, field.first, field.second, f64), + "Unable to define eigenpair metric."); + } + hdf5_internal::RequireHdf5( + H5Tinsert(type, "backend_res_available", + HOFFSET(EigenpairCheckRow, backend_res_available), + H5T_STD_U8LE), + "Unable to define backend residual flag."); + hdf5_internal::RequireHdf5( + H5Tinsert(type, "backend_res", HOFFSET(EigenpairCheckRow, backend_res), + f64), + "Unable to define backend residual."); + }; + add_check(check_file.Get(), H5T_STD_U64LE, H5T_IEEE_F64LE); + add_check(check_memory.Get(), H5T_NATIVE_UINT64, H5T_NATIVE_DOUBLE); + auto check_dataset = hdf5_internal::WriteCompoundDataset( + file, "/steps/Step-1/eigen/verification/eigenpair_checks", checks.size(), + check_file.Get(), check_memory.Get(), checks.data()); + WriteQuantityAttributes(check_dataset.Get(), + "eigenvalue,modal_mass,rayleigh_quotient,eta_r," + "eta_w,rho_b,eta_norm,eta_energy,backend_res", + "time^-2,1,time^-2,1,1,1,1,1,1", "scalar", + "eigenpair-verification"); +} + +void WriteDiagnostics(hid_t file, const std::vector& diagnostics) { + std::vector files; + files.reserve(diagnostics.size()); + std::vector rows; + rows.reserve(diagnostics.size()); + for (const Diagnostic& diagnostic : diagnostics) { + files.push_back( + hdf5_internal::NormalizedPathString(diagnostic.location.file)); + rows.push_back({"warning", diagnostic.code.c_str(), files.back().c_str(), + diagnostic.location.line, diagnostic.keyword.c_str(), + diagnostic.entity_identity.c_str(), + diagnostic.message.c_str()}); + } + auto string_type = hdf5_internal::MakeUtf8StringType(); + auto file_type = MakeCompoundType(); + auto memory_type = MakeCompoundType(); + const auto add_fields = [&](hid_t type, hid_t u64) { + for (const auto& field : std::array, 6>{ + {{"severity", HOFFSET(DiagnosticRow, severity)}, + {"code", HOFFSET(DiagnosticRow, code)}, + {"file", HOFFSET(DiagnosticRow, file)}, + {"keyword", HOFFSET(DiagnosticRow, keyword)}, + {"entity_identity", HOFFSET(DiagnosticRow, entity_identity)}, + {"message", HOFFSET(DiagnosticRow, message)}}}) { + hdf5_internal::RequireHdf5( + H5Tinsert(type, field.first, field.second, string_type.Get()), + "Unable to define diagnostic text field."); + } + hdf5_internal::RequireHdf5( + H5Tinsert(type, "line", HOFFSET(DiagnosticRow, line), u64), + "Unable to define diagnostic line."); + }; + add_fields(file_type.Get(), H5T_STD_U64LE); + add_fields(memory_type.Get(), H5T_NATIVE_UINT64); + (void)hdf5_internal::WriteCompoundDataset(file, "/diagnostics", rows.size(), + file_type.Get(), memory_type.Get(), + rows.data()); +} + +} // namespace + +Status EigenHdf5ResultsWriter::Write( + const std::filesystem::path& output_path, const Domain& domain, + const EigenAnalysisState& state, + const std::vector& diagnostics) { + const Status valid = Validate(domain, state, diagnostics); + if (!valid.IsOk()) return valid; + try { + hdf5_internal::Hdf5ErrorSilencer silence; + const auto candidate_path = hdf5_internal::MakeCandidatePath(output_path); + hdf5_internal::CandidateFileGuard cleanup{candidate_path}; + hdf5_internal::Hdf5Handle file{ + hdf5_internal::RequireHdf5Id( + H5Fcreate(candidate_path.string().c_str(), H5F_ACC_EXCL, + H5P_DEFAULT, H5P_DEFAULT), + "Unable to create eigen candidate."), + H5Fclose}; + hdf5_internal::WriterModelData model{ + {}, state.constraint_mask, state.prescribed_displacement}; + (void)hdf5_internal::CreateGroup(file.Get(), "/model"); + (void)hdf5_internal::CreateGroup(file.Get(), "/model/shell"); + hdf5_internal::WriteModel(file.Get(), domain, model); + WriteDensities(file.Get(), domain); + WriteDiagnostics(file.Get(), diagnostics); + WriteEigenMetadata(file.Get()); + WriteStep(file.Get(), domain, state); + (void)hdf5_internal::CreateGroup(file.Get(), kEigenRoot); + WriteModes(file.Get(), state, domain.Frequency()->source_step_name_raw); + std::vector vector_values; + vector_values.reserve(state.vectors.Rows() * state.vectors.Columns()); + for (std::size_t row = 0U; row < state.vectors.Rows(); ++row) + for (std::size_t column = 0U; column < state.vectors.Columns(); ++column) + vector_values.push_back(state.vectors(row, column)); + hdf5_internal::WriteModelDoubleDataset( + file.Get(), "/steps/Step-1/eigen/vectors", + {static_cast(state.modes.size()), + static_cast(domain.Nodes().size()), 6U}, + vector_values.data(), vector_values.size(), "UX,UY,UZ,URX,URY,URZ", + "mass^-1/2,mass^-1/2,mass^-1/2,mass^-1/2*length^-1,mass^-1/" + "2*length^-1,mass^-1/2*length^-1", + "global-cartesian", "nodal-modal-vector"); + { + hdf5_internal::Hdf5Handle vectors{ + hdf5_internal::RequireHdf5Id( + H5Dopen2(file.Get(), "/steps/Step-1/eigen/vectors", H5P_DEFAULT), + "Unable to open modal vectors."), + H5Dclose}; + WriteModalAttributes(vectors.Get(), + domain.Frequency()->source_step_name_raw); + hdf5_internal::WriteStringAttribute(vectors.Get(), "normalization", + "mass"); + hdf5_internal::WriteStringAttribute(vectors.Get(), "mass_variant", + "derived-lumped"); + hdf5_internal::WriteStringAttribute( + vectors.Get(), "interpretation", + "normalized-mode-not-response-displacement"); + } + WriteVerification(file.Get(), state); + (void)hdf5_internal::CreateGroup(file.Get(), "/steps/Step-1/eigen/backend"); + WriteBackendAttributes(file.Get(), state); + WriteBaseFpm(file.Get(), state.backend.base_fpm); + WriteBackendCalls(file.Get(), state.backend.backend_calls); + hdf5_internal::RequireHdf5(H5Fflush(file.Get(), H5F_SCOPE_GLOBAL), + "Unable to flush eigen candidate."); + hdf5_internal::RequireHdf5(file.CloseChecked(), + "Unable to close eigen candidate."); + const Status checked = hdf5_internal::SelfCheckEigenFile( + candidate_path, domain, state, diagnostics); + const Status result = hdf5_internal::AtomicFinalizeValidatedCandidate( + candidate_path, output_path, checked); + if (result.IsOk()) cleanup.Release(); + return result; + } catch (const hdf5_internal::Hdf5Failure& failure) { + return hdf5_internal::OutputFailure("hdf5-write-failure", failure.what()); + } catch (const std::exception& failure) { + return hdf5_internal::OutputFailure("hdf5-write-failure", failure.what()); + } +} + +} // namespace fesa diff --git a/src/fesa/io/hdf5/eigen_hdf5_self_check.cpp b/src/fesa/io/hdf5/eigen_hdf5_self_check.cpp new file mode 100644 index 0000000..c54c43e --- /dev/null +++ b/src/fesa/io/hdf5/eigen_hdf5_self_check.cpp @@ -0,0 +1,827 @@ +#include "io/hdf5/eigen_hdf5_self_check.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "fesa/build_info.h" +#include "io/hdf5/hdf5_primitives.h" +#include "io/hdf5/hdf5_raii.h" + +namespace fesa::hdf5_internal { +namespace { + +Status Failure(const std::string& message) { + return OutputFailure("invalid-eigen-hdf5-candidate", message); +} + +bool HasPath(hid_t file, const char* path) { + return H5Lexists(file, path, H5P_DEFAULT) > 0; +} + +bool HasNoStaticResults(hid_t file) { + for (const char* path : {"/steps/Step-1/frames", "/steps/Step-1/global", + "/steps/Step-1/element"}) { + if (HasPath(file, path)) return false; + } + return true; +} + +bool ReadUint64Attribute(hid_t object, const char* name, + std::uint64_t expected) { + if (H5Aexists(object, name) <= 0) return false; + Hdf5Handle attribute{H5Aopen(object, name, H5P_DEFAULT), H5Aclose}; + if (attribute.Get() < 0) return false; + Hdf5Handle type{H5Aget_type(attribute.Get()), H5Tclose}; + Hdf5Handle space{H5Aget_space(attribute.Get()), H5Sclose}; + if (type.Get() < 0 || H5Tequal(type.Get(), H5T_STD_U64LE) <= 0 || + space.Get() < 0 || H5Sget_simple_extent_type(space.Get()) != H5S_SCALAR) + return false; + std::uint64_t value = 0U; + return H5Aread(attribute.Get(), H5T_NATIVE_UINT64, &value) >= 0 && + value == expected; +} + +bool ReadStringAttribute(hid_t object, const char* name, const char* expected) { + if (H5Aexists(object, name) <= 0) return false; + Hdf5Handle attribute{H5Aopen(object, name, H5P_DEFAULT), H5Aclose}; + if (attribute.Get() < 0) return false; + Hdf5Handle type{H5Aget_type(attribute.Get()), H5Tclose}; + Hdf5Handle space{H5Aget_space(attribute.Get()), H5Sclose}; + if (type.Get() < 0 || H5Tget_class(type.Get()) != H5T_STRING || + H5Tis_variable_str(type.Get()) <= 0 || + H5Tget_cset(type.Get()) != H5T_CSET_UTF8 || space.Get() < 0 || + H5Sget_simple_extent_type(space.Get()) != H5S_SCALAR) + return false; + char* value = nullptr; + if (H5Aread(attribute.Get(), type.Get(), &value) < 0 || value == nullptr) + return false; + const bool matches = std::string{value} == expected; + H5free_memory(value); + return matches; +} + +bool DatasetShape(hid_t file, const char* path, + const std::vector& expected_dimensions) { + Hdf5Handle dataset{H5Dopen2(file, path, H5P_DEFAULT), H5Dclose}; + if (dataset.Get() < 0) return false; + Hdf5Handle space{H5Dget_space(dataset.Get()), H5Sclose}; + if (space.Get() < 0 || H5Sget_simple_extent_ndims(space.Get()) != + static_cast(expected_dimensions.size())) + return false; + std::vector dimensions(expected_dimensions.size()); + if (H5Sget_simple_extent_dims(space.Get(), dimensions.data(), nullptr) < 0) + return false; + return dimensions == expected_dimensions; +} + +bool VerifyVectors(hid_t file, const Domain& domain, + const EigenAnalysisState& state) { + const std::size_t count = state.vectors.Rows() * state.vectors.Columns(); + if (!DatasetShape(file, "/steps/Step-1/eigen/vectors", + {static_cast(state.modes.size()), + static_cast(domain.Nodes().size()), 6U})) + return false; + std::vector values(count); + Hdf5Handle dataset{H5Dopen2(file, "/steps/Step-1/eigen/vectors", H5P_DEFAULT), + H5Dclose}; + Hdf5Handle type{H5Dget_type(dataset.Get()), H5Tclose}; + if (dataset.Get() < 0 || H5Tequal(type.Get(), H5T_IEEE_F64LE) <= 0 || + (count != 0U && H5Dread(dataset.Get(), H5T_NATIVE_DOUBLE, H5S_ALL, + H5S_ALL, H5P_DEFAULT, values.data()) < 0)) + return false; + for (std::size_t row = 0U; row < state.vectors.Rows(); ++row) { + for (std::size_t column = 0U; column < state.vectors.Columns(); ++column) { + const double value = values[row * state.vectors.Columns() + column]; + if (!std::isfinite(value) || value != state.vectors(row, column)) + return false; + if (state.constraint_mask[column] != 0U && value != 0.0) return false; + } + } + return true; +} + +template +bool CheckMember(hid_t file, const char* path, const char* name, + hid_t file_type, hid_t memory_type, const Rows& rows, + Getter get) { + if (!DatasetShape(file, path, {static_cast(rows.size())})) + return false; + Hdf5Handle dataset{H5Dopen2(file, path, H5P_DEFAULT), H5Dclose}; + Hdf5Handle type{H5Dget_type(dataset.Get()), H5Tclose}; + if (type.Get() < 0 || H5Tget_class(type.Get()) != H5T_COMPOUND) return false; + const int member = H5Tget_member_index(type.Get(), name); + if (member < 0) return false; + Hdf5Handle actual_type{ + H5Tget_member_type(type.Get(), static_cast(member)), H5Tclose}; + if (H5Tequal(actual_type.Get(), file_type) <= 0) return false; + Hdf5Handle memory{H5Tcreate(H5T_COMPOUND, sizeof(T)), H5Tclose}; + if (memory.Get() < 0 || H5Tinsert(memory.Get(), name, 0U, memory_type) < 0) + return false; + std::vector values(rows.size()); + if (!values.empty() && H5Dread(dataset.Get(), memory.Get(), H5S_ALL, H5S_ALL, + H5P_DEFAULT, values.data()) < 0) + return false; + for (std::size_t i = 0; i < values.size(); ++i) + if (values[i] != static_cast(get(rows[i]))) return false; + return true; +} + +template +bool CheckTextMember(hid_t file, const char* path, const char* name, + const Rows& rows, Getter get) { + if (!DatasetShape(file, path, {static_cast(rows.size())})) + return false; + Hdf5Handle dataset{H5Dopen2(file, path, H5P_DEFAULT), H5Dclose}; + Hdf5Handle type{H5Dget_type(dataset.Get()), H5Tclose}; + if (type.Get() < 0 || H5Tget_class(type.Get()) != H5T_COMPOUND) return false; + const int member = H5Tget_member_index(type.Get(), name); + if (member < 0) return false; + Hdf5Handle member_type{ + H5Tget_member_type(type.Get(), static_cast(member)), H5Tclose}; + if (H5Tget_class(member_type.Get()) != H5T_STRING || + H5Tis_variable_str(member_type.Get()) <= 0 || + H5Tget_cset(member_type.Get()) != H5T_CSET_UTF8) + return false; + Hdf5Handle memory{H5Tcreate(H5T_COMPOUND, sizeof(char*)), H5Tclose}; + if (H5Tinsert(memory.Get(), name, 0U, member_type.Get()) < 0) return false; + std::vector values(rows.size(), nullptr); + if (!values.empty() && H5Dread(dataset.Get(), memory.Get(), H5S_ALL, H5S_ALL, + H5P_DEFAULT, values.data()) < 0) + return false; + bool matches = true; + for (std::size_t i = 0; i < values.size(); ++i) { + if (values[i] == nullptr || std::string(values[i]) != get(rows[i])) + matches = false; + if (values[i] != nullptr && H5free_memory(values[i]) < 0) matches = false; + } + return matches; +} + +template +bool CheckScalar(hid_t object, const char* name, hid_t file_type, + hid_t memory_type, T expected) { + Hdf5Handle attribute{H5Aopen(object, name, H5P_DEFAULT), H5Aclose}; + if (attribute.Get() < 0) return false; + Hdf5Handle type{H5Aget_type(attribute.Get()), H5Tclose}; + Hdf5Handle space{H5Aget_space(attribute.Get()), H5Sclose}; + if (H5Tequal(type.Get(), file_type) <= 0 || + H5Sget_simple_extent_type(space.Get()) != H5S_SCALAR) + return false; + T value{}; + return H5Aread(attribute.Get(), memory_type, &value) >= 0 && + value == expected; +} + +template +bool CheckArray(hid_t file, const char* path, hid_t file_type, + hid_t memory_type, const std::vector& shape, + const Values& expected) { + if (!DatasetShape(file, path, shape)) return false; + Hdf5Handle dataset{H5Dopen2(file, path, H5P_DEFAULT), H5Dclose}; + Hdf5Handle type{H5Dget_type(dataset.Get()), H5Tclose}; + if (H5Tequal(type.Get(), file_type) <= 0) return false; + std::vector actual(expected.size()); + if (!actual.empty() && H5Dread(dataset.Get(), memory_type, H5S_ALL, H5S_ALL, + H5P_DEFAULT, actual.data()) < 0) + return false; + for (std::size_t i = 0U; i < actual.size(); ++i) + if (actual[i] != expected[i]) return false; + return true; +} + +bool CheckTextAttributes( + hid_t object, + std::initializer_list> attributes) { + for (const auto& attribute : attributes) + if (!ReadStringAttribute(object, attribute.first, attribute.second.c_str())) + return false; + return true; +} + +bool CheckAttributes(hid_t file, const Domain& domain, + const EigenAnalysisState& state) { + Hdf5Handle metadata{H5Gopen2(file, "/metadata", H5P_DEFAULT), H5Gclose}; + if (!CheckTextAttributes( + metadata.Get(), + {{"solver_version", std::string{SolverVersion()}}, + {"source_input_identity", SourceInputIdentity(domain)}, + {"unit_system_label", "user-consistent-unspecified"}, + {"coordinate_convention", + "global-cartesian; shell-local=initial-reference; " + "positive-thickness=+zeta"}, + {"internal_formulation", "FESA-MITC4"}, + {"integration_rule", "2x2x2-gauss; mitc4-edge-midpoint-shear"}, + {"mass_integration_rule", "3x3x3-gauss"}, + {"stabilization_rule", "initial-physical-rotary-trace-mean"}, + {"mass_variant", "derived-lumped"}, + {"mass_origin", "consistent-moment-row-sum"}, + {"normalization", "mass"}, + {"eigensolver_backend", "MKL FEAST"}, + {"eigensolver_routine", "dfeast_scsrgv"}, + {"backend_problem", "scaled-whitened-lumped-with-identity-mass"}, + {"numerical_contract", "mitc4-shell-feast-eigen2:r1"}})) + return false; + const auto& source = *domain.Frequency(); + Hdf5Handle step{H5Gopen2(file, "/steps/Step-1", H5P_DEFAULT), H5Gclose}; + if (!CheckTextAttributes( + step.Get(), + {{"source_step_name", source.source_step_name_raw}, + {"source_step_name_canonical", source.source_step_name_canonical}, + {"source_eigensolver", "LANCZOS"}, + {"source_sim", "YES"}, + {"source_acoustic_coupling", "ON"}, + {"source_normalization", "MASS"}, + {"frame_indexing", "mode-rank-one-based"}}) || + !ReadUint64Attribute(step.Get(), "step_source_line", + source.step_location.line) || + !ReadUint64Attribute(step.Get(), "frequency_source_line", + source.frequency_location.line) || + !ReadUint64Attribute(step.Get(), "frequency_data_line", + source.data_location.line) || + !CheckScalar(step.Get(), "source_nlgeom", H5T_STD_U8LE, + H5T_NATIVE_UINT8, 0U) || + !CheckScalar( + step.Get(), "source_perturbation_present", H5T_STD_U8LE, + H5T_NATIVE_UINT8, + static_cast(source.perturbation_explicit))) + return false; + Hdf5Handle presence{ + H5Aopen(step.Get(), "source_option_presence", H5P_DEFAULT), H5Aclose}; + Hdf5Handle presence_type{H5Aget_type(presence.Get()), H5Tclose}; + Hdf5Handle presence_space{H5Aget_space(presence.Get()), H5Sclose}; + hsize_t extent = 0U; + std::array actual_presence{}; + const std::array expected_presence{ + static_cast(source.eigensolver_explicit), + static_cast(source.sim_explicit), + static_cast(source.acoustic_coupling_explicit), + static_cast(source.normalization_explicit)}; + if (presence.Get() < 0 || H5Tequal(presence_type.Get(), H5T_STD_U8LE) <= 0 || + H5Sget_simple_extent_ndims(presence_space.Get()) != 1 || + H5Sget_simple_extent_dims(presence_space.Get(), &extent, nullptr) < 0 || + extent != 4U || + H5Aread(presence.Get(), H5T_NATIVE_UINT8, actual_presence.data()) < 0 || + actual_presence != expected_presence) + return false; + for (const char* leaf : {"modes", "frequencies", "vectors"}) { + const std::string path = std::string{"/steps/Step-1/eigen/"} + leaf; + Hdf5Handle dataset{H5Dopen2(file, path.c_str(), H5P_DEFAULT), H5Dclose}; + if (!ReadUint64Attribute(dataset.Get(), "eigen_schema_version", 1U) || + !CheckTextAttributes(dataset.Get(), + {{"source_step_name", source.source_step_name_raw}, + {"domain", "mode"}})) + return false; + if (std::string{leaf} == "vectors") { + if (!CheckTextAttributes( + dataset.Get(), + {{"component_names", "UX,UY,UZ,URX,URY,URZ"}, + {"component_unit_dimensions", + "mass^-1/2,mass^-1/2,mass^-1/2,mass^-1/2*length^-1,mass^-1/" + "2*length^-1,mass^-1/2*length^-1"}, + {"coordinate_system", "global-cartesian"}, + {"location", "nodal-modal-vector"}, + {"normalization", "mass"}, + {"mass_variant", "derived-lumped"}, + {"interpretation", + "normalized-mode-not-response-displacement"}})) + return false; + } else { + const bool modes = std::string{leaf} == "modes"; + if (!CheckTextAttributes( + dataset.Get(), + {{"quantity", modes ? "eigenvalue" : "cycle-frequency"}, + {"component_names", modes ? "Value" : "Freq"}, + {"component_unit_dimensions", modes ? "time^-2" : "time^-1"}, + {"coordinate_system", "scalar"}, + {"location", "modal-frame"}})) + return false; + if (!modes && !ReadStringAttribute(dataset.Get(), "unavailable_rule", + "negative-raw-eigenvalue")) + return false; + } + } + Hdf5Handle verification{ + H5Gopen2(file, "/steps/Step-1/eigen/verification", H5P_DEFAULT), + H5Gclose}; + for (const auto& metric : + std::initializer_list>{ + {"characteristic_length", state.characteristic_length}, + {"operator_scale", state.backend.operator_scale}, + {"eta_gram", state.eta_gram}, + {"max_eta_orth", state.max_eta_orth}, + {"eta_mz", state.eta_mz}, + {"eta_kz", state.eta_kz}, + {"eta_pz", state.eta_pz}}) + if (!CheckScalar(verification.Get(), metric.first, H5T_IEEE_F64LE, + H5T_NATIVE_DOUBLE, metric.second)) + return false; + if (!ReadStringAttribute(verification.Get(), "validation_scope", + "all-computed-c-columns") || + !CheckScalar( + verification.Get(), "null_checks_applicable", H5T_STD_U8LE, + H5T_NATIVE_UINT8, + static_cast(state.null_checks_applicable))) + return false; + Hdf5Handle backend{H5Gopen2(file, "/steps/Step-1/eigen/backend", H5P_DEFAULT), + H5Gclose}; + const auto& b = state.backend; + if (!CheckTextAttributes( + backend.Get(), + {{"runtime_version", b.runtime_version}, + {"integer_abi", b.integer_abi}, + {"upper_search_termination", b.upper_search_termination}}) || + !ReadUint64Attribute(backend.Get(), "thread_count", b.thread_count) || + !ReadUint64Attribute(backend.Get(), "seed", b.seed) || + !ReadUint64Attribute(backend.Get(), "final_count", + state.eigenpair_checks.size())) + return false; + for (const auto& bound : + std::initializer_list>{ + {"enclosure_lower", b.enclosure_lower}, + {"enclosure_upper", b.enclosure_upper}, + {"solve_lower", b.solve_lower}, + {"solve_upper", b.solve_upper}}) + if (!CheckScalar(backend.Get(), bound.first, H5T_IEEE_F64LE, + H5T_NATIVE_DOUBLE, bound.second)) + return false; + std::size_t counts = 0U, solves = 0U, final_ordinal = 0U; + for (const auto& call : b.backend_calls) { + if (call.kind == "count") ++counts; + if (call.kind == "solve") { + ++solves; + final_ordinal = call.ordinal; + } + } + return ReadUint64Attribute(backend.Get(), "count_calls", counts) && + ReadUint64Attribute(backend.Get(), "solve_calls", solves) && + ReadUint64Attribute(backend.Get(), "final_solve_ordinal", + final_ordinal) && + CheckArray(file, "/steps/Step-1/eigen/backend/base_fpm", + H5T_STD_I64LE, H5T_NATIVE_INT64, {128U}, + b.base_fpm) && + CheckArray( + file, "/model/nodal_constraint_mask", H5T_STD_U8LE, + H5T_NATIVE_UINT8, + {static_cast(domain.Nodes().size()), 6U}, + state.constraint_mask) && + CheckArray(file, "/model/prescribed_displacement", + H5T_IEEE_F64LE, H5T_NATIVE_DOUBLE, + {static_cast(domain.Nodes().size()), 6U}, + state.prescribed_displacement); +} + +bool CheckModelRows(hid_t file, const Domain& domain) { + std::vector indices(domain.Nodes().size()); + std::iota(indices.begin(), indices.end(), 0U); + const char* path = "/model/nodes"; + const hsize_t three = 3U, four = 4U; + Hdf5Handle coordinates_file{H5Tarray_create2(H5T_IEEE_F64LE, 1, &three), + H5Tclose}; + Hdf5Handle coordinates_memory{H5Tarray_create2(H5T_NATIVE_DOUBLE, 1, &three), + H5Tclose}; + if (!CheckMember(file, path, "internal_node_id", H5T_STD_U64LE, + H5T_NATIVE_UINT64, indices, + [](auto i) { return i; }) || + !CheckTextMember( + file, path, "instance_name", domain.Nodes(), + [](const auto& row) { return row.source_id.instance_name; }) || + !CheckTextMember( + file, path, "source_label", domain.Nodes(), + [](const auto& row) { return row.source_id.source_label_text; }) || + !CheckMember>( + file, path, "coordinates", coordinates_file.Get(), + coordinates_memory.Get(), domain.Nodes(), + [](const auto& row) { return row.coordinates; })) + return false; + indices.resize(domain.ShellElements().Size()); + std::iota(indices.begin(), indices.end(), 0U); + Hdf5Handle nodes_file{H5Tarray_create2(H5T_STD_U64LE, 1, &four), H5Tclose}; + Hdf5Handle nodes_memory{H5Tarray_create2(H5T_NATIVE_UINT64, 1, &four), + H5Tclose}; + path = "/model/elements"; + if (!CheckMember(file, path, "internal_element_id", + H5T_STD_U64LE, H5T_NATIVE_UINT64, indices, + [](auto i) { return i; }) || + !CheckTextMember( + file, path, "instance_name", indices, + [&](auto i) { + return domain.ShellElements()[i].source_id.instance_name; + }) || + !CheckTextMember( + file, path, "source_label", indices, + [&](auto i) { + return domain.ShellElements()[i].source_id.source_label_text; + }) || + !CheckTextMember(file, path, "source_element_type", indices, + [&](auto i) { + return ShellSourceTypeName( + domain.ShellElements()[i].source_type); + }) || + !CheckTextMember(file, path, "internal_formulation", indices, + [](auto) { return std::string{"FESA-MITC4"}; }) || + !CheckMember( + file, path, "material_internal_id", H5T_STD_U64LE, H5T_NATIVE_UINT64, + indices, + [&](auto i) { return domain.ShellElements()[i].material_index; }) || + !CheckMember( + file, path, "shell_section_internal_id", H5T_STD_U64LE, + H5T_NATIVE_UINT64, indices, + [&](auto i) { return domain.ShellElements()[i].section_index; }) || + !CheckMember>( + file, path, "node_internal_ids", nodes_file.Get(), nodes_memory.Get(), + indices, [&](auto i) { + const auto& nodes = domain.ShellElements()[i].node_indices; + return std::array{nodes[0], nodes[1], nodes[2], + nodes[3]}; + })) + return false; + indices.resize(domain.LinearElasticMaterials().Size()); + std::iota(indices.begin(), indices.end(), 0U); + path = "/model/shell/materials"; + if (!CheckMember(file, path, "internal_material_id", + H5T_STD_U64LE, H5T_NATIVE_UINT64, indices, + [](auto i) { return i; }) || + !CheckTextMember( + file, path, "name", indices, + [&](auto i) { return domain.LinearElasticMaterials()[i].name; }) || + !CheckMember( + file, path, "E", H5T_IEEE_F64LE, H5T_NATIVE_DOUBLE, indices, + [&](auto i) { + return domain.LinearElasticMaterials()[i].youngs_modulus; + }) || + !CheckMember( + file, path, "nu", H5T_IEEE_F64LE, H5T_NATIVE_DOUBLE, indices, + [&](auto i) { + return domain.LinearElasticMaterials()[i].poisson_ratio; + })) + return false; + indices.resize(domain.ShellSections().Size()); + std::iota(indices.begin(), indices.end(), 0U); + path = "/model/shell/sections"; + if (!CheckMember(file, path, "internal_section_id", + H5T_STD_U64LE, H5T_NATIVE_UINT64, indices, + [](auto i) { return i; }) || + !CheckMember( + file, path, "material_internal_id", H5T_STD_U64LE, H5T_NATIVE_UINT64, + indices, + [&](auto i) { return domain.ShellSections()[i].material_index; }) || + !CheckMember( + file, path, "source_line", H5T_STD_U64LE, H5T_NATIVE_UINT64, indices, + [&](auto i) { return domain.ShellSections()[i].location.line; }) || + !CheckTextMember(file, path, "source_file", indices, + [&](auto i) { + return NormalizedPathString( + domain.ShellSections()[i].location.file); + }) || + !CheckTextMember( + file, path, "source_elset", indices, + [&](auto i) { return domain.ShellSections()[i].name; }) || + !CheckMember( + file, path, "thickness", H5T_IEEE_F64LE, H5T_NATIVE_DOUBLE, indices, + [&](auto i) { return domain.ShellSections()[i].thickness; })) + return false; + std::vector directors, frames; + for (const auto& frame : domain.ShellNodeInitialFrames()) { + directors.insert(directors.end(), frame.director.begin(), + frame.director.end()); + frames.insert(frames.end(), frame.tangent_a.begin(), frame.tangent_a.end()); + frames.insert(frames.end(), frame.tangent_b.begin(), frame.tangent_b.end()); + frames.insert(frames.end(), frame.director.begin(), frame.director.end()); + } + return CheckArray(file, "/model/shell/nodal_director", H5T_IEEE_F64LE, + H5T_NATIVE_DOUBLE, + {static_cast(domain.Nodes().size()), 3U}, + directors) && + CheckArray( + file, "/model/shell/nodal_frame", H5T_IEEE_F64LE, + H5T_NATIVE_DOUBLE, + {static_cast(domain.Nodes().size()), 3U, 3U}, frames); +} + +bool CheckModalRows(hid_t file, const Domain& domain, + const EigenAnalysisState& state, + const std::vector& diagnostics) { + const char* path = "/steps/Step-1/eigen/modes"; + if (!CheckMember(file, path, "mode_id", H5T_STD_U64LE, + H5T_NATIVE_UINT64, state.modes, + [](const auto& row) { return row.mode_id; })) + return false; + if (!CheckMember(file, path, "eigenvalue", H5T_IEEE_F64LE, + H5T_NATIVE_DOUBLE, state.modes, + [](const auto& row) { return row.eigenvalue; })) + return false; + if (!CheckMember( + file, path, "classification", H5T_STD_U8LE, H5T_NATIVE_UINT8, + state.modes, [](const auto& row) { return row.classification; })) + return false; + if (!CheckMember( + file, path, "cluster_id", H5T_STD_U64LE, H5T_NATIVE_UINT64, + state.modes, [](const auto& row) { return row.cluster_id; })) + return false; + std::vector frequencies; + for (const auto& mode : state.modes) + if (mode.frequency) frequencies.push_back(mode); + path = "/steps/Step-1/eigen/frequencies"; + if (!CheckMember(file, path, "mode_id", H5T_STD_U64LE, + H5T_NATIVE_UINT64, frequencies, + [](const auto& row) { return row.mode_id; })) + return false; + if (!CheckMember(file, path, "frequency", H5T_IEEE_F64LE, + H5T_NATIVE_DOUBLE, frequencies, + [](const auto& row) { return *row.frequency; })) + return false; + path = "/steps/Step-1/eigen/verification/nodal_reduction"; + if (!CheckMember( + file, path, "free_count", H5T_STD_U8LE, H5T_NATIVE_UINT8, + state.nodal_reduction, + [](const auto& row) { return row.free_count; })) + return false; + if (!CheckMember( + file, path, "null_count", H5T_STD_U8LE, H5T_NATIVE_UINT8, + state.nodal_reduction, + [](const auto& row) { return row.null_count; })) + return false; + if (!CheckMember( + file, path, "positive_count", H5T_STD_U8LE, H5T_NATIVE_UINT8, + state.nodal_reduction, + [](const auto& row) { return row.positive_count; })) + return false; + if (!CheckMember( + file, path, "rcond_available", H5T_STD_U8LE, H5T_NATIVE_UINT8, + state.nodal_reduction, + [](const auto& row) { return row.rcond_available; })) + return false; + if (!CheckMember(file, path, "rcond_inf", H5T_IEEE_F64LE, + H5T_NATIVE_DOUBLE, state.nodal_reduction, + [](const auto& row) { return row.rcond_inf; })) + return false; + path = "/steps/Step-1/eigen/verification/eigenpair_checks"; + if (!CheckMember( + file, path, "sorted_rank", H5T_STD_U64LE, H5T_NATIVE_UINT64, + state.eigenpair_checks, + [](const auto& row) { return row.sorted_rank; })) + return false; + if (!CheckMember( + file, path, "solver_column", H5T_STD_U64LE, H5T_NATIVE_UINT64, + state.eigenpair_checks, + [](const auto& row) { return row.solver_column; })) + return false; + if (!CheckMember(file, path, "eigenvalue", H5T_IEEE_F64LE, + H5T_NATIVE_DOUBLE, state.eigenpair_checks, + [](const auto& row) { return row.eigenvalue; })) + return false; + if (!CheckMember(file, path, "modal_mass", H5T_IEEE_F64LE, + H5T_NATIVE_DOUBLE, state.eigenpair_checks, + [](const auto& row) { return row.modal_mass; })) + return false; + if (!CheckMember( + file, path, "rayleigh_quotient", H5T_IEEE_F64LE, H5T_NATIVE_DOUBLE, + state.eigenpair_checks, + [](const auto& row) { return row.rayleigh_quotient; })) + return false; + if (!CheckMember(file, path, "eta_r", H5T_IEEE_F64LE, + H5T_NATIVE_DOUBLE, state.eigenpair_checks, + [](const auto& row) { return row.eta_r; })) + return false; + if (!CheckMember(file, path, "eta_w", H5T_IEEE_F64LE, + H5T_NATIVE_DOUBLE, state.eigenpair_checks, + [](const auto& row) { return row.eta_w; })) + return false; + if (!CheckMember(file, path, "rho_b", H5T_IEEE_F64LE, + H5T_NATIVE_DOUBLE, state.eigenpair_checks, + [](const auto& row) { return row.rho_b; })) + return false; + if (!CheckMember(file, path, "eta_norm", H5T_IEEE_F64LE, + H5T_NATIVE_DOUBLE, state.eigenpair_checks, + [](const auto& row) { return row.eta_norm; })) + return false; + if (!CheckMember(file, path, "eta_energy", H5T_IEEE_F64LE, + H5T_NATIVE_DOUBLE, state.eigenpair_checks, + [](const auto& row) { return row.eta_energy; })) + return false; + if (!CheckMember( + file, path, "backend_res_available", H5T_STD_U8LE, H5T_NATIVE_UINT8, + state.eigenpair_checks, + [](const auto& row) { return row.backend_res_available; })) + return false; + if (!CheckMember(file, path, "backend_res", H5T_IEEE_F64LE, + H5T_NATIVE_DOUBLE, state.eigenpair_checks, + [](const auto& row) { return row.backend_res; })) + return false; + path = "/steps/Step-1/eigen/backend/calls"; + if (!CheckMember(file, path, "ordinal", H5T_STD_U64LE, + H5T_NATIVE_UINT64, + state.backend.backend_calls, + [](const auto& row) { return row.ordinal; })) + return false; + if (!CheckMember(file, path, "lower", H5T_IEEE_F64LE, + H5T_NATIVE_DOUBLE, state.backend.backend_calls, + [](const auto& row) { return row.lower; })) + return false; + if (!CheckMember(file, path, "upper", H5T_IEEE_F64LE, + H5T_NATIVE_DOUBLE, state.backend.backend_calls, + [](const auto& row) { return row.upper; })) + return false; + if (!CheckMember( + file, path, "allocated_m0", H5T_STD_U64LE, H5T_NATIVE_UINT64, + state.backend.backend_calls, + [](const auto& row) { return row.allocated_m0; })) + return false; + if (!CheckMember(file, path, "info", H5T_STD_I64LE, + H5T_NATIVE_INT64, state.backend.backend_calls, + [](const auto& row) { return row.info; })) + return false; + if (!CheckMember( + file, path, "count_available", H5T_STD_U8LE, H5T_NATIVE_UINT8, + state.backend.backend_calls, + [](const auto& row) { return row.count_available; })) + return false; + if (!CheckMember(file, path, "count", H5T_STD_U64LE, + H5T_NATIVE_UINT64, + state.backend.backend_calls, + [](const auto& row) { return row.count; })) + return false; + if (!CheckMember( + file, path, "loop_available", H5T_STD_U8LE, H5T_NATIVE_UINT8, + state.backend.backend_calls, + [](const auto& row) { return row.loop_available; })) + return false; + if (!CheckMember(file, path, "loop", H5T_STD_U64LE, + H5T_NATIVE_UINT64, + state.backend.backend_calls, + [](const auto& row) { return row.loop; })) + return false; + if (!CheckMember( + file, path, "epsout_available", H5T_STD_U8LE, H5T_NATIVE_UINT8, + state.backend.backend_calls, + [](const auto& row) { return row.epsout_available; })) + return false; + if (!CheckMember(file, path, "epsout", H5T_IEEE_F64LE, + H5T_NATIVE_DOUBLE, state.backend.backend_calls, + [](const auto& row) { return row.epsout; })) + return false; + if (!CheckTextMember(file, path, "kind", state.backend.backend_calls, + [](const auto& row) { return row.kind; })) + return false; + if (!CheckTextMember(file, path, "purpose", state.backend.backend_calls, + [](const auto& row) { return row.purpose; })) + return false; + if (!CheckTextMember(file, path, "outcome", state.backend.backend_calls, + [](const auto& row) { return row.outcome; })) + return false; + if (!CheckTextMember(file, path, "decision_reason", + state.backend.backend_calls, + [](const auto& row) { return row.decision_reason; })) + return false; + path = "/model/shell/densities"; + if (!CheckMember( + file, path, "material_internal_id", H5T_STD_U64LE, H5T_NATIVE_UINT64, + domain.Densities(), + [](const auto& row) { return row.material_index; })) + return false; + if (!CheckMember(file, path, "rho", H5T_IEEE_F64LE, H5T_NATIVE_DOUBLE, + domain.Densities(), + [](const auto& row) { return row.rho; })) + return false; + if (!CheckMember( + file, path, "source_line", H5T_STD_U64LE, H5T_NATIVE_UINT64, + domain.Densities(), + [](const auto& row) { return row.location.line; })) + return false; + if (!CheckTextMember(file, path, "source_file", domain.Densities(), + [](const auto& row) { + return NormalizedPathString(row.location.file); + })) + return false; + path = "/diagnostics"; + if (!CheckMember( + file, path, "line", H5T_STD_U64LE, H5T_NATIVE_UINT64, diagnostics, + [](const auto& row) { return row.location.line; })) + return false; + if (!CheckTextMember(file, path, "severity", diagnostics, + [](const auto&) { return std::string{"warning"}; })) + return false; + if (!CheckTextMember(file, path, "file", diagnostics, [](const auto& row) { + return NormalizedPathString(row.location.file); + })) + return false; + if (!CheckTextMember(file, path, "code", diagnostics, + [](const auto& row) { return row.code; })) + return false; + if (!CheckTextMember(file, path, "keyword", diagnostics, + [](const auto& row) { return row.keyword; })) + return false; + if (!CheckTextMember(file, path, "entity_identity", diagnostics, + [](const auto& row) { return row.entity_identity; })) + return false; + if (!CheckTextMember(file, path, "message", diagnostics, + [](const auto& row) { return row.message; })) + return false; + return true; +} + +} // namespace + +Status SelfCheckEigenFile(const std::filesystem::path& path, + const Domain& domain, const EigenAnalysisState& state, + const std::vector& diagnostics) { + try { + Hdf5ErrorSilencer silence; + Hdf5Handle file{RequireHdf5Id(H5Fopen(path.string().c_str(), H5F_ACC_RDONLY, + H5P_DEFAULT), + "Unable to reopen eigen candidate."), + H5Fclose}; + for (const char* required : + {"/metadata", "/model/nodes", "/model/elements", + "/model/shell/densities", "/diagnostics", "/steps/Step-1/eigen/modes", + "/steps/Step-1/eigen/frequencies", "/steps/Step-1/eigen/vectors", + "/steps/Step-1/eigen/verification/nodal_reduction", + "/steps/Step-1/eigen/verification/eigenpair_checks", + "/steps/Step-1/eigen/backend/base_fpm", + "/steps/Step-1/eigen/backend/calls"}) { + if (!HasPath(file.Get(), required)) + return Failure("Mandatory eigen path is missing."); + } + if (!HasNoStaticResults(file.Get())) + return Failure("Eigen output contains static result paths."); + Hdf5Handle metadata{H5Gopen2(file.Get(), "/metadata", H5P_DEFAULT), + H5Gclose}; + Hdf5Handle step{H5Gopen2(file.Get(), "/steps/Step-1", H5P_DEFAULT), + H5Gclose}; + if (metadata.Get() < 0 || step.Get() < 0 || + !ReadUint64Attribute(metadata.Get(), "schema_version", 0U) || + !ReadUint64Attribute(metadata.Get(), "eigen_schema_version", 1U) || + !ReadStringAttribute(metadata.Get(), "feature_id", + "mitc4-shell-feast-eigen2") || + !ReadStringAttribute(metadata.Get(), "analysis_procedure", + "linear-frequency") || + !ReadUint64Attribute(step.Get(), "requested_mode_count", + state.modes.size()) || + !ReadUint64Attribute(step.Get(), "returned_mode_count", + state.modes.size()) || + !ReadUint64Attribute(step.Get(), "full_dof_count", + state.full_dof_count) || + !ReadUint64Attribute(step.Get(), "free_dof_count", + state.free_dof_count) || + !ReadUint64Attribute(step.Get(), "mass_null_dimension", + state.mass_null_dimension) || + !ReadUint64Attribute(step.Get(), "finite_dimension", + state.finite_dimension) || + !ReadUint64Attribute(step.Get(), "validated_eigenpair_count", + state.eigenpair_checks.size())) { + return Failure("Eigen metadata or step inventory differs from state."); + } + if (!DatasetShape(file.Get(), "/steps/Step-1/eigen/modes", + {static_cast(state.modes.size())}) || + !DatasetShape(file.Get(), "/steps/Step-1/eigen/frequencies", + {static_cast([&state] { + std::size_t count = 0U; + for (const auto& mode : state.modes) + if (mode.frequency) ++count; + return count; + }())}) || + !DatasetShape(file.Get(), + "/steps/Step-1/eigen/verification/nodal_reduction", + {static_cast(domain.Nodes().size())}) || + !DatasetShape(file.Get(), + "/steps/Step-1/eigen/verification/eigenpair_checks", + {static_cast(state.eigenpair_checks.size())}) || + !DatasetShape(file.Get(), "/steps/Step-1/eigen/backend/base_fpm", + {128U}) || + !DatasetShape( + file.Get(), "/steps/Step-1/eigen/backend/calls", + {static_cast(state.backend.backend_calls.size())}) || + !VerifyVectors(file.Get(), domain, state)) { + return Failure("Eigen dataset shape or vector data differs from state."); + } + if (!CheckModalRows(file.Get(), domain, state, diagnostics)) + return Failure( + "Compound type, source identity or value differs from candidate " + "state."); + if (!CheckAttributes(file.Get(), domain, state)) + return Failure( + "Mandatory attributes, controls or boundary data differ from " + "candidate state."); + if (!CheckModelRows(file.Get(), domain)) + return Failure( + "Model source identities, connectivity or physical data differ from " + "Domain."); + if (metadata.CloseChecked() < 0 || step.CloseChecked() < 0) + return Failure("Unable to close candidate groups."); + if (file.CloseChecked() < 0) + return Failure("Unable to close checked eigen candidate."); + return Status::Ok(); + } catch (const Hdf5Failure& failure) { + return Failure(failure.what()); + } +} + +} // namespace fesa::hdf5_internal diff --git a/src/fesa/io/hdf5/eigen_hdf5_self_check.h b/src/fesa/io/hdf5/eigen_hdf5_self_check.h new file mode 100644 index 0000000..da7458e --- /dev/null +++ b/src/fesa/io/hdf5/eigen_hdf5_self_check.h @@ -0,0 +1,24 @@ +#ifndef FESA_IO_HDF5_EIGEN_HDF5_SELF_CHECK_H_ +#define FESA_IO_HDF5_EIGEN_HDF5_SELF_CHECK_H_ + +#include +#include + +#include "fesa/analysis/eigen_analysis_state.h" +#include "fesa/core/status.h" +#include "fesa/model/domain.h" + +namespace fesa { +struct Diagnostic; +} + +namespace fesa::hdf5_internal { + +/// @brief Reopens and validates a closed authoritative eigen HDF5 candidate. +Status SelfCheckEigenFile(const std::filesystem::path& path, + const Domain& domain, const EigenAnalysisState& state, + const std::vector& diagnostics); + +} // namespace fesa::hdf5_internal + +#endif // FESA_IO_HDF5_EIGEN_HDF5_SELF_CHECK_H_ diff --git a/src/fesa/model/domain.cpp b/src/fesa/model/domain.cpp index f1c7ff9..a351d2d 100644 --- a/src/fesa/model/domain.cpp +++ b/src/fesa/model/domain.cpp @@ -44,6 +44,31 @@ StepDefinition::StepDefinition(StaticStepDefinition definition) surface_pressure_loads_ = std::move(definition.surface_pressure_loads); } +StepDefinition::StepDefinition(FrequencyStepDefinition definition) + : name_{definition.source_step_name_raw}, + procedure_kind_{StaticProcedureKind::kLinearSmallDisplacement}, + nlgeom_{false}, + initial_increment_{0.0}, + time_period_{0.0}, + minimum_increment_{0.0}, + maximum_increment_{0.0}, + location_{definition.step_location}, + frequency_{std::move(definition)} { + for (const auto& boundary : frequency_->boundaries) { + for (int source_dof = boundary.first_dof; source_dof <= boundary.last_dof; + ++source_dof) { + const std::size_t source_order = boundary_conditions_.size(); + auto owned = std::make_unique( + SourceTargetQuery{SourceEntityKind::kNode, "", boundary.target}, + static_cast(source_dof - 1), boundary.value, + source_order, boundary.location); + boundary_conditions_view_.push_back(std::cref(*owned)); + prescribed_displacements_view_.Add(*owned); + boundary_conditions_.push_back(std::move(owned)); + } + } +} + const std::string& StepDefinition::Name() const noexcept { return name_; } const BoundaryConditionView& StepDefinition::BoundaryConditions() @@ -76,6 +101,14 @@ bool StepDefinition::IsNonlinearStatic() const noexcept { return procedure_kind_ == StaticProcedureKind::kNonlinearFullNewton; } +bool StepDefinition::IsFrequency() const noexcept { + return frequency_.has_value(); +} + +const FrequencyStepDefinition* StepDefinition::Frequency() const noexcept { + return frequency_ ? &*frequency_ : nullptr; +} + bool StepDefinition::UsesNonlinearGeometry() const noexcept { return nlgeom_; } double StepDefinition::InitialIncrement() const noexcept { @@ -165,6 +198,15 @@ const DomainCollectionView& Domain::Steps() const noexcept { return steps_view_; } +const std::vector& Domain::Densities() + const noexcept { + return definition_.densities; +} + +const FrequencyStepDefinition* Domain::Frequency() const noexcept { + return steps_view_.Size() == 1U ? steps_view_[0U].Frequency() : nullptr; +} + const std::vector& Domain::Warnings() const noexcept { return definition_.warnings; } @@ -227,7 +269,8 @@ Domain::Domain(ModelDefinition definition) } definition_.shell_elements.clear(); - step_definitions_.reserve(definition_.steps.size()); + step_definitions_.reserve(definition_.steps.size() + + (definition_.frequency_step ? 1U : 0U)); for (auto& step : definition_.steps) { auto owned = std::unique_ptr(new StepDefinition(std::move(step))); @@ -235,6 +278,12 @@ Domain::Domain(ModelDefinition definition) step_definitions_.push_back(std::move(owned)); } definition_.steps.clear(); + if (definition_.frequency_step) { + auto owned = std::unique_ptr( + new StepDefinition(std::move(*definition_.frequency_step))); + steps_view_.Add(*owned); + step_definitions_.push_back(std::move(owned)); + } } } // namespace fesa diff --git a/src/fesa/solvers/eigen/mkl_feast_eigen_solver.cpp b/src/fesa/solvers/eigen/mkl_feast_eigen_solver.cpp new file mode 100644 index 0000000..c6c68ed --- /dev/null +++ b/src/fesa/solvers/eigen/mkl_feast_eigen_solver.cpp @@ -0,0 +1,1005 @@ +#include "fesa/solvers/eigen/mkl_feast_eigen_solver.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "fesa/math/sparse_matrix.h" + +namespace fesa { +namespace { + +constexpr std::uint64_t kSeed = 0x4D495443344D4645ULL; +constexpr std::size_t kMaximumCountCalls = 512U; + +Status Failure(const std::string& code, const std::string& identity, + const std::string& message) { + return Status::Failure( + FailureCategory::kSolver, + {{Severity::kError, code, {{}, 0U}, "FEAST", identity, message}}); +} + +bool FitsMkl(const std::size_t value) { + return value <= + static_cast((std::numeric_limits::max)()); +} + +bool ProductFits(const std::size_t left, const std::size_t right) { + return left == 0U || + right <= (std::numeric_limits::max)() / left; +} + +bool FitsDoubleStorage(const std::size_t count) { + return count <= std::vector{}.max_size() && + count <= static_cast( + (std::numeric_limits::max)()) / + sizeof(double); +} + +double NextUp(const double value) { + return std::nextafter(value, (std::numeric_limits::infinity)()); +} + +double Delta(const double value, const double beta) { + const double up = + std::nextafter(value, (std::numeric_limits::infinity)()); + const double down = + std::nextafter(value, -(std::numeric_limits::infinity)()); + const double ulp = (std::max)(std::abs(up - value), std::abs(value - down)); + return 1.0e-8 * beta + 64.0 * ulp; +} + +/// @brief Restores the calling thread's oneMKL local-thread setting. +class LocalThreadGuard { + public: + LocalThreadGuard() : previous_(mkl_set_num_threads_local(1)) {} + ~LocalThreadGuard() { + static_cast(mkl_set_num_threads_local(previous_)); + } + + private: + int previous_; +}; + +struct Csr { + std::vector offsets; + std::vector columns; + std::vector values; +}; + +Result CopyUpperOneBased(const SparseMatrix& matrix, const double scale) { + Csr copy; + copy.offsets.reserve(matrix.Rows() + 1U); + copy.offsets.push_back(1); + const auto& offsets = matrix.RowOffsets(); + const auto& columns = matrix.ColumnIndices(); + const auto& values = matrix.Values(); + for (std::size_t row = 0U; row < matrix.Rows(); ++row) { + for (std::size_t position = offsets[row]; position < offsets[row + 1U]; + ++position) { + const std::size_t column = columns[position]; + if (column < row) { + continue; + } + if (!FitsMkl(column + 1U) || !FitsMkl(copy.values.size() + 1U)) { + return Result::Failure(Failure( + "feast-integer-overflow", "csr-index", + "The public CSR index exceeds the oneMKL ILP64 integer range.")); + } + copy.columns.push_back(static_cast(column + 1U)); + copy.values.push_back(values[position] / scale); + } + if (!FitsMkl(copy.values.size() + 1U)) { + return Result::Failure(Failure("feast-integer-overflow", + "csr-offset", + "The converted one-based CSR offset " + "exceeds the oneMKL integer range.")); + } + copy.offsets.push_back(static_cast(copy.values.size() + 1U)); + } + return Result::Success(std::move(copy)); +} + +Result IdentityOneBased(const std::size_t size) { + if (!FitsMkl(size) || !FitsMkl(size + 1U)) { + return Result::Failure( + Failure("feast-integer-overflow", "identity", + "The identity CSR exceeds oneMKL range.")); + } + Csr identity; + identity.offsets.reserve(size + 1U); + identity.columns.reserve(size); + identity.values.assign(size, 1.0); + for (std::size_t index = 0U; index <= size; ++index) { + identity.offsets.push_back(static_cast(index + 1U)); + if (index < size) { + identity.columns.push_back(static_cast(index + 1U)); + } + } + return Result::Success(std::move(identity)); +} + +Status ValidateSymmetry(const SparseMatrix& matrix) { + const auto& offsets = matrix.RowOffsets(); + const auto& columns = matrix.ColumnIndices(); + const auto& values = matrix.Values(); + double scale = 0.0; + for (const double value : values) scale = (std::max)(scale, std::abs(value)); + double norm = 0.0; + double asymmetry = 0.0; + for (std::size_t row = 0U; row < matrix.Rows(); ++row) { + for (std::size_t p = offsets[row]; p < offsets[row + 1U]; ++p) { + const std::size_t column = columns[p]; + const auto begin = + columns.begin() + static_cast(offsets[column]); + const auto end = + columns.begin() + static_cast(offsets[column + 1U]); + const auto found = std::lower_bound(begin, end, row); + if (found == end || *found != row) { + return Failure("feast-nonsymmetric-csr", + std::to_string(row) + ":" + std::to_string(column), + "FEAST requires a full symmetric public CSR before " + "upper extraction."); + } + const std::size_t q = static_cast(found - columns.begin()); + if (scale != 0.0) { + norm = std::hypot(norm, values[p] / scale); + asymmetry = + std::hypot(asymmetry, values[p] / scale - values[q] / scale); + } + } + } + if (norm > 0.0 && asymmetry / norm > 1e-12) + return Failure("feast-nonsymmetric-csr", "whitened-operator", + "The public CSR violates the Frobenius symmetry contract."); + return Status::Ok(); +} + +void SetControls(std::array& fpm, const bool count) { + feastinit(fpm.data()); + fpm[0] = 0; + fpm[1] = 16; + fpm[2] = 12; + fpm[3] = 20; + fpm[4] = 1; + fpm[5] = 1; + fpm[13] = 0; + fpm[26] = 1; + fpm[27] = 1; + fpm[49] = count ? 1 : 0; + fpm[63] = 0; +} + +std::string FormatDouble(const double value) { + std::ostringstream stream; + stream << std::scientific << std::setprecision(17) << value; + return stream.str(); +} + +std::string CallHistory(const std::vector& calls) { + std::string history; + for (const FeastBackendCall& call : calls) { + if (!history.empty()) history += ";"; + history += std::to_string(call.ordinal) + ":" + call.kind + + ":info=" + std::to_string(call.info) + + ":m0=" + std::to_string(call.allocated_m0) + ":(" + + FormatDouble(call.lower) + "," + FormatDouble(call.upper) + ")"; + } + return history; +} + +bool FillInitialBasis(std::vector& vectors, const std::size_t n, + const std::size_t columns) { + std::fill(vectors.begin(), vectors.end(), 0.0); + if (columns == n) { + for (std::size_t i = 0U; i < n; ++i) vectors[i + i * n] = 1.0; + return true; + } + std::uint64_t state = kSeed; + for (double& value : vectors) { + state = 6364136223846793005ULL * state + 1442695040888963407ULL; + value = 2.0 * static_cast(state >> 11U) * 0x1.0p-53 - 1.0; + } + std::vector> reflectors; + std::vector signs; + reflectors.reserve(columns); + signs.reserve(columns); + for (std::size_t column = 0U; column < columns; ++column) { + double* current = vectors.data() + column * n; + double norm = 0.0; + double column_scale = 0.0; + for (std::size_t row = 0U; row < n; ++row) { + column_scale = std::hypot(column_scale, current[row]); + } + for (std::size_t row = column; row < n; ++row) { + norm = std::hypot(norm, current[row]); + } + if (!(column_scale > 0.0) || !(norm > 1.0e-12 * column_scale) || + !std::isfinite(norm)) { + return false; + } + // Avoid cancellation in the reflector. Adjust Q's column sign below so + // R has a positive diagonal without subtracting nearly equal numbers. + const double alpha = -std::copysign(norm, current[column]); + signs.push_back(std::copysign(1.0, alpha)); + std::vector reflector(n - column, 0.0); + reflector[0U] = current[column] - alpha; + for (std::size_t row = column + 1U; row < n; ++row) { + reflector[row - column] = current[row]; + } + double reflector_norm = 0.0; + for (const double value : reflector) + reflector_norm = std::hypot(reflector_norm, value); + if (!(reflector_norm > 0.0) || !std::isfinite(reflector_norm)) { + return false; + } + for (double& value : reflector) value /= reflector_norm; + for (std::size_t target = column; target < columns; ++target) { + double dot = 0.0; + for (std::size_t row = column; row < n; ++row) { + dot += reflector[row - column] * vectors[row + target * n]; + } + for (std::size_t row = column; row < n; ++row) { + vectors[row + target * n] -= 2.0 * reflector[row - column] * dot; + } + } + reflectors.push_back(std::move(reflector)); + } + std::fill(vectors.begin(), vectors.end(), 0.0); + for (std::size_t column = 0U; column < columns; ++column) + vectors[column + column * n] = 1.0; + for (std::size_t index = reflectors.size(); index > 0U; --index) { + const std::size_t column = index - 1U; + const auto& reflector = reflectors[column]; + for (std::size_t target = 0U; target < columns; ++target) { + double dot = 0.0; + for (std::size_t row = column; row < n; ++row) { + dot += reflector[row - column] * vectors[row + target * n]; + } + for (std::size_t row = column; row < n; ++row) { + vectors[row + target * n] -= 2.0 * reflector[row - column] * dot; + } + } + } + for (std::size_t column = 0U; column < columns; ++column) + for (std::size_t row = 0U; row < n; ++row) + vectors[row + column * n] *= signs[column]; + double gram_error = 0.0; + for (std::size_t left = 0U; left < columns; ++left) { + for (std::size_t right = 0U; right < columns; ++right) { + double dot = 0.0; + for (std::size_t row = 0U; row < n; ++row) { + dot += vectors[row + left * n] * vectors[row + right * n]; + } + gram_error = std::hypot(gram_error, dot - (left == right ? 1.0 : 0.0)); + } + } + return std::isfinite(gram_error) && + gram_error / std::sqrt(static_cast(columns)) <= 1e-12; +} + +} // namespace + +Result MklFeastEigenSolver::Solve( + const SparseMatrix& whitened_operator, const std::size_t requested_modes, + const double zero_operator_scale) const { + const Status csr_status = whitened_operator.Validate(); + if (!csr_status.IsOk()) + return Result::Failure( + Failure("feast-invalid-csr", "public-csr", + "The public sparse matrix failed CSR validation.")); + const std::size_t n = whitened_operator.Rows(); + if (n == 0U || whitened_operator.Columns() != n || requested_modes == 0U || + requested_modes > n) { + return Result::Failure(Failure( + "feast-invalid-dimension", "matrix-or-request", + "FEAST requires 1 <= requested modes <= square matrix dimension.")); + } + if (!FitsMkl(n) || !ProductFits(n, n)) { + return Result::Failure(Failure( + "feast-integer-overflow", "matrix-shape", + "FEAST workspace dimensions exceed the private backend range.")); + } + if (!FitsDoubleStorage(n) || !FitsDoubleStorage(n * n)) { + return Result::Failure(Failure( + "eigen-allocation-failed", "count-workspace", + "The required count workspace exceeds addressable double storage.")); + } + const MKL_INT mkl_n = static_cast(n); + const Status symmetry = ValidateSymmetry(whitened_operator); + if (!symmetry.IsOk()) return Result::Failure(symmetry); + + double infinity_norm = 0.0; + const auto& offsets = whitened_operator.RowOffsets(); + const auto& values = whitened_operator.Values(); + for (std::size_t row = 0U; row < n; ++row) { + double sum = 0.0; + for (std::size_t p = offsets[row]; p < offsets[row + 1U]; ++p) { + sum += std::abs(values[p]); + } + if (!std::isfinite(sum)) { + return Result::Failure(Failure( + "feast-nonfinite-operator-scale", "operator-scale", + "The operator infinity norm overflowed while computing its scale.")); + } + infinity_norm = (std::max)(infinity_norm, sum); + } + const bool zero_operator = infinity_norm == 0.0; + if (zero_operator && + (!(zero_operator_scale > 0.0) || !std::isfinite(zero_operator_scale))) { + return Result::Failure(Failure( + "feast-invalid-zero-scale", "zero-operator-scale", + "An exactly zero operator requires a finite positive physical scale.")); + } + const double operator_scale = + zero_operator ? zero_operator_scale : infinity_norm; + if (!std::isfinite(operator_scale) || !(operator_scale > 0.0)) { + return Result::Failure( + Failure("feast-nonfinite-operator-scale", "operator-scale", + "The operator infinity norm is not a finite positive scale.")); + } + auto a = CopyUpperOneBased(whitened_operator, operator_scale); + if (!a.HasValue()) return Result::Failure(a.GetStatus()); + auto b = IdentityOneBased(n); + if (!b.HasValue()) return Result::Failure(b.GetStatus()); + + double lower = -1.0; + double upper = 1.0; + double g_lower = 0.0; + double g_upper = 0.0; + double beta = 1.0; + if (!zero_operator) { + g_lower = (std::numeric_limits::infinity)(); + g_upper = -(std::numeric_limits::infinity)(); + const auto& columns = whitened_operator.ColumnIndices(); + for (std::size_t row = 0U; row < n; ++row) { + double off_diagonal = 0.0; + double diagonal = 0.0; + for (std::size_t p = offsets[row]; p < offsets[row + 1U]; ++p) { + const double value = values[p] / operator_scale; + if (columns[p] == row) + diagonal = value; + else + off_diagonal = NextUp(off_diagonal + std::abs(value)); + } + g_lower = + (std::min)(g_lower, std::nextafter( + diagonal - off_diagonal, + -(std::numeric_limits::infinity)())); + g_upper = (std::max)(g_upper, NextUp(diagonal + off_diagonal)); + beta = (std::max)(beta, NextUp(std::abs(diagonal) + off_diagonal)); + } + const double lower_delta = Delta(g_lower, beta); + const double upper_delta = Delta(g_upper, beta); + lower = std::nextafter(g_lower - 16.0 * lower_delta, + -(std::numeric_limits::infinity)()); + upper = NextUp(g_upper + 16.0 * upper_delta); + } + if (!std::isfinite(lower) || !std::isfinite(upper) || !(lower < upper)) { + return Result::Failure( + Failure("feast-invalid-enclosure", "gershgorin", + "A strict finite FEAST enclosure could not be constructed.")); + } + + LocalThreadGuard threads; + std::vector calls; + std::array snapshot{}; + SetControls(snapshot, false); + std::vector base_fpm; + base_fpm.reserve(snapshot.size()); + for (const MKL_INT value : snapshot) + base_fpm.push_back(static_cast(value)); + std::vector count_e; + std::vector count_res; + std::vector count_x; + try { + count_e.assign(n, 0.0); + count_res.assign(n, 0.0); + count_x.assign(n * n, 0.0); + } catch (const std::bad_alloc&) { + return Result::Failure( + Failure("eigen-allocation-failed", "count-workspace", + "Allocation of the required FEAST count workspace failed.")); + } + + auto Count = [&](const double call_lower, const double call_upper, + const std::string& purpose, std::size_t* count) -> Status { + if (calls.size() >= kMaximumCountCalls) + return Failure("eigen-search-budget-exhausted", "count-calls", + "The bounded FEAST count-call budget was exhausted."); + std::array fpm{}; + SetControls(fpm, true); + MKL_INT m0 = static_cast(n); + MKL_INT returned = 0; + MKL_INT loop = 0; + MKL_INT info = 0; + double epsout = 0.0; + if (!FillInitialBasis(count_x, n, n)) { + return Failure( + "eigenpair-validation-failed", "count-initial-basis", + "The prescribed count initial basis failed QR validation."); + } + const char uplo = 'U'; + dfeast_scsrgv(&uplo, &mkl_n, a.Value().values.data(), + a.Value().offsets.data(), a.Value().columns.data(), + b.Value().values.data(), b.Value().offsets.data(), + b.Value().columns.data(), fpm.data(), &epsout, &loop, + &call_lower, &call_upper, &m0, count_e.data(), count_x.data(), + &returned, count_res.data(), &info); + const bool count_available = + info == 0 && m0 >= 0 && static_cast(m0) <= n; + FeastBackendCall event{calls.size(), + "count", + purpose, + call_lower, + call_upper, + n, + static_cast(info), + count_available, + 0U, + false, + 0, + false, + 0.0, + "", + ""}; + if (count_available) { + event.count = static_cast(m0); + } + event.outcome = count_available ? "accepted" : "failed"; + event.decision_reason = + count_available ? "accepted" : "vendor-info=" + std::to_string(info); + calls.push_back(std::move(event)); + if (info != 0) + return Failure("eigen-feast-failed", "info=" + std::to_string(info), + "FEAST did not certify the requested count interval."); + if (m0 < 0 || static_cast(m0) > n) { + return Failure("eigen-count-inconsistent", "count-output", + "FEAST count output is outside [0,n]."); + } + *count = static_cast(m0); + for (std::size_t i = 0U; i + 1U < calls.size(); ++i) { + const auto& prior = calls[i]; + if (prior.kind != "count" || !prior.count_available || + prior.lower != call_lower) + continue; + if ((prior.upper == call_upper && prior.count != *count) || + (prior.upper < call_upper && prior.count > *count) || + (prior.upper > call_upper && prior.count < *count)) + return Failure( + "eigen-count-inconsistent", "count-history", + "Successful prefix counts violate identity or monotonicity."); + } + return Status::Ok(); + }; + + std::size_t count = 0U; + Status count_status = Status::Ok(); + bool enclosure_certified = false; + for (std::size_t attempt = 0U; attempt < 3U; ++attempt) { + if (attempt > 0U) { + if (zero_operator) { + const double extent = std::ldexp(1.0, static_cast(attempt)); + lower = -extent; + upper = extent; + } else { + const double multiplier = std::ldexp(1.0, static_cast(attempt)); + lower = + std::nextafter(g_lower - 16.0 * multiplier * Delta(g_lower, beta), + -(std::numeric_limits::infinity)()); + upper = NextUp(g_upper + 16.0 * multiplier * Delta(g_upper, beta)); + } + } + count_status = Count(lower, upper, "total-enclosure", &count); + if (count_status.IsOk() && count == n) { + enclosure_certified = true; + break; + } + const int info = calls.back().info; + const bool may_retry = + info == 1 || info == -5 || (count_status.IsOk() && count != n); + if (may_retry && attempt + 1U < 3U) { + calls.back().outcome = "retry"; + calls.back().decision_reason = + count_status.IsOk() ? "count-mismatch" + : "retryable-vendor-info=" + std::to_string(info); + } + if (!may_retry) break; + } + if (!enclosure_certified) { + return Result::Failure( + !count_status.IsOk() + ? count_status + : Failure( + "eigen-count-inconsistent", "total-enclosure", + "The enclosing FEAST count did not equal matrix dimension.")); + } + const double fixed_lower = lower; + const double enclosure_upper = upper; + double solve_upper = enclosure_upper; + std::string upper_search_termination = + n <= (std::min)(n, 2U * requested_modes + 8U) ? "count-target" + : "optimization-budget"; + double search_lower = fixed_lower; + std::size_t certified_count = n; + std::vector certified_uppers{enclosure_upper}; + for (std::size_t midpoint_attempt = 0U; + upper_search_termination != "count-target" && midpoint_attempt < 32U; + ++midpoint_attempt) { + if (calls.size() + 21U > 384U) { + upper_search_termination = "optimization-budget"; + break; + } + const double midpoint = 0.5 * (search_lower + solve_upper); + if (!std::isfinite(midpoint) || midpoint == search_lower || + midpoint == solve_upper) { + upper_search_termination = "no-midpoint"; + break; + } + const double midpoint_delta = Delta(midpoint, beta); + const std::array collision_offsets{0.0, 1.0, -1.0, 2.0, + -2.0, 4.0, -4.0}; + bool certified = false; + bool optional_abandoned = false; + std::size_t candidate_count = 0U; + for (const double offset : collision_offsets) { + const double candidate = midpoint + offset * midpoint_delta; + const double margin = Delta(candidate, beta); + const std::array guards{candidate - margin, candidate, + candidate + margin}; + if (!(fixed_lower < guards[0U] && guards[2U] < enclosure_upper)) continue; + std::array guard_counts{}; + bool successful = true; + for (std::size_t guard = 0U; guard < guards.size(); ++guard) { + const Status guard_status = Count(fixed_lower, guards[guard], + "upper-guard", &guard_counts[guard]); + if (!guard_status.IsOk()) { + calls.back().outcome = "optional-abandoned"; + const int optional_info = calls.back().info; + calls.back().decision_reason = + "optional-status=" + std::to_string(optional_info); + if (optional_info != 1 && optional_info != 2 && optional_info != 3 && + optional_info != -5) { + return Result::Failure(guard_status); + } + optional_abandoned = true; + successful = false; + break; + } + } + if (optional_abandoned) break; + if (!successful || guard_counts[0U] != guard_counts[1U] || + guard_counts[1U] != guard_counts[2U]) + continue; + candidate_count = guard_counts[1U]; + certified = true; + if (candidate_count >= requested_modes) { + solve_upper = candidate; + certified_uppers.push_back(candidate); + } + break; + } + if (optional_abandoned) { + upper_search_termination = "optional-status"; + break; + } + if (!certified) { + upper_search_termination = "collision-exhausted"; + break; + } + if (candidate_count >= requested_modes) { + certified_count = candidate_count; + if (candidate_count <= (std::min)(n, 2U * requested_modes + 8U)) { + upper_search_termination = "count-target"; + break; + } + } else { + search_lower = midpoint; + } + if (solve_upper - search_lower <= 8.0 * Delta(midpoint, beta)) { + upper_search_termination = "width-limit"; + break; + } + } + count = 0U; + count_status = Count(fixed_lower, solve_upper, "final-count", &count); + if (!count_status.IsOk() || count < requested_modes || + count != certified_count) { + return Result::Failure(Failure( + "eigen-count-inconsistent", "final-count", + "The final FEAST interval count is not the certified guarded count.")); + } + if (solve_upper != enclosure_upper) { + const double margin = Delta(solve_upper, beta); + std::size_t below = 0U; + std::size_t above = 0U; + const Status below_status = + Count(fixed_lower, solve_upper - margin, "final-count", &below); + const Status above_status = + Count(fixed_lower, solve_upper + margin, "final-count", &above); + if (!below_status.IsOk() || !above_status.IsOk() || below != count || + above != count) { + return Result::Failure( + Failure("eigen-count-inconsistent", "final-guard", + "The final interior endpoint is not guarded by equal " + "successful counts.")); + } + } + if (count > (std::numeric_limits::max)() - 8U || + count > ((std::numeric_limits::max)() - 1U) / 3U) { + return Result::Failure( + Failure("eigen-allocation-failed", "solve-capacity", + "FEAST solve-capacity padding overflows size_t.")); + } + const std::size_t padded_count = + (std::max)(count + 8U, (3U * count + 1U) / 2U); + std::size_t capacity = count == n ? n : (std::min)(n, padded_count); + if (!ProductFits(n, capacity)) + return Result::Failure( + Failure("feast-workspace-overflow", "solve-workspace", + "The FEAST solve workspace size overflows.")); + if (!FitsDoubleStorage(n * capacity) || !FitsDoubleStorage(capacity)) { + return Result::Failure(Failure( + "eigen-allocation-failed", "solve-workspace", + "The required solve workspace exceeds addressable double storage.")); + } + std::vector eigenvalues; + std::vector residuals; + std::vector vectors; + MKL_INT returned = 0; + MKL_INT info = 0; + bool solve_accepted = false; + std::size_t solve_capacity = capacity; + for (std::size_t attempt = 0U; attempt < 3U; ++attempt) { + if (calls.size() >= kMaximumCountCalls) + return Result::Failure( + Failure("eigen-search-budget-exhausted", "solve-call", + "The total backend call budget is exhausted before solve.")); + try { + eigenvalues.assign(solve_capacity, 0.0); + residuals.assign(solve_capacity, 0.0); + vectors.assign(n * solve_capacity, 0.0); + } catch (const std::bad_alloc&) { + return Result::Failure( + Failure("eigen-allocation-failed", "solve-workspace", + "Allocation of the required FEAST solve workspace failed.")); + } + if (!FillInitialBasis(vectors, n, solve_capacity)) { + return Result::Failure( + Failure("eigenpair-validation-failed", "solve-initial-basis", + "The prescribed solve initial basis failed QR validation.")); + } + std::array fpm{}; + SetControls(fpm, false); + MKL_INT m0 = static_cast(solve_capacity); + returned = 0; + MKL_INT loop = 0; + info = 0; + double epsout = 0.0; + const char uplo = 'U'; + dfeast_scsrgv(&uplo, &mkl_n, a.Value().values.data(), + a.Value().offsets.data(), a.Value().columns.data(), + b.Value().values.data(), b.Value().offsets.data(), + b.Value().columns.data(), fpm.data(), &epsout, &loop, + &fixed_lower, &solve_upper, &m0, eigenvalues.data(), + vectors.data(), &returned, residuals.data(), &info); + const bool solve_outputs_available = + info == 0 && loop >= 0 && std::isfinite(epsout) && returned >= 0 && + static_cast(returned) <= solve_capacity; + calls.push_back( + {calls.size(), "solve", "solve", fixed_lower, solve_upper, + solve_capacity, static_cast(info), solve_outputs_available, + solve_outputs_available ? static_cast(returned) : 0U, + solve_outputs_available, + solve_outputs_available ? static_cast(loop) : 0, + solve_outputs_available, solve_outputs_available ? epsout : 0.0, + solve_outputs_available ? "accepted" : "failed", + solve_outputs_available ? "accepted" + : "vendor-info=" + std::to_string(info)}); + if (info == 0 && (!std::isfinite(epsout) || loop < 0)) { + return Result::Failure( + Failure("invalid-eigenvalue-result", "solve-output", + "A successful FEAST solve returned nonfinite epsout or " + "invalid loop.")); + } + if (info == 0 && returned >= 0 && + static_cast(returned) <= solve_capacity) { + for (std::size_t column = 0U; column < static_cast(returned); + ++column) { + if (!std::isfinite(eigenvalues[column])) + return Result::Failure(Failure( + "invalid-eigenvalue-result", std::to_string(column), + "A nonfinite eigenvalue cannot trigger an interval retry.")); + for (std::size_t row = 0U; row < n; ++row) + if (!std::isfinite(vectors[row + column * n])) + return Result::Failure(Failure( + "invalid-eigenvalue-result", std::to_string(column), + "A nonfinite eigenvector cannot trigger an interval retry.")); + } + } + bool endpoint_separated = info == 0 && returned >= 0 && + static_cast(returned) == count; + if (endpoint_separated) { + for (std::size_t index = 0U; index < count; ++index) { + if (!std::isfinite(eigenvalues[index])) { + return Result::Failure( + Failure("invalid-eigenvalue-result", std::to_string(index), + "FEAST returned a nonfinite eigenvalue before endpoint " + "validation.")); + } + endpoint_separated = + endpoint_separated && + eigenvalues[index] - fixed_lower >= Delta(fixed_lower, beta) && + solve_upper - eigenvalues[index] >= Delta(solve_upper, beta); + } + } + if (endpoint_separated) { + solve_accepted = true; + break; + } + const bool retryable = + !endpoint_separated && + (info == 1 || info == 2 || info == 3 || + (info == 0 && + (returned < 0 || static_cast(returned) != count || + !endpoint_separated))); + if (!retryable || attempt == 2U) break; + calls.back().outcome = "retry"; + calls.back().decision_reason = + info == 0 ? "endpoint-or-count-mismatch" + : "retryable-vendor-info=" + std::to_string(info); + double larger_upper = enclosure_upper; + for (const double certified : certified_uppers) { + if (certified > solve_upper && certified < larger_upper) + larger_upper = certified; + } + const bool interval_changed = larger_upper > solve_upper; + if (interval_changed) { + solve_upper = larger_upper; + std::size_t recounted = 0U; + const Status recount_status = + Count(fixed_lower, solve_upper, "final-count", &recounted); + if (!recount_status.IsOk() || recounted < requested_modes) { + return Result::Failure(Failure( + "eigen-count-inconsistent", "solve-retry", + "A larger certified solve interval could not be recounted.")); + } + count = recounted; + if (solve_upper != enclosure_upper) { + const double retry_margin = Delta(solve_upper, beta); + std::size_t below = 0U; + std::size_t above = 0U; + const Status below_status = Count( + fixed_lower, solve_upper - retry_margin, "final-count", &below); + const Status above_status = Count( + fixed_lower, solve_upper + retry_margin, "final-count", &above); + if (!below_status.IsOk() || !above_status.IsOk() || below != count || + above != count) { + return Result::Failure(Failure( + "eigen-count-inconsistent", "solve-retry-guard", + "The larger retry interval is not guarded by matching counts.")); + } + } + if (count > (std::numeric_limits::max)() - 8U || + count > ((std::numeric_limits::max)() - 1U) / 3U) { + return Result::Failure( + Failure("eigen-allocation-failed", "solve-capacity", + "FEAST retry capacity padding overflows size_t.")); + } + const std::size_t retry_padding = + (std::max)(count + 8U, (3U * count + 1U) / 2U); + capacity = count == n ? n : (std::min)(n, retry_padding); + } + const std::size_t previous_capacity = solve_capacity; + const std::size_t doubled = + solve_capacity > n / 2U ? n : 2U * solve_capacity; + solve_capacity = (std::max)(capacity, doubled); + if (solve_capacity == previous_capacity && !interval_changed) break; + } + if (!solve_accepted) { + return Result::Failure( + Failure("eigen-feast-failed", "info=" + std::to_string(info), + "FEAST solve did not return the certified interval count " + "within its bounded retries.")); + } + + for (std::size_t index = 0U; index < count; ++index) { + if (!std::isfinite(eigenvalues[index])) { + return Result::Failure( + Failure("invalid-eigenvalue-result", std::to_string(index), + "FEAST returned a nonfinite eigenvalue before sorting.")); + } + for (std::size_t row = 0U; row < n; ++row) { + if (!std::isfinite(vectors[row + index * n])) { + return Result::Failure( + Failure("invalid-eigenvalue-result", std::to_string(index), + "FEAST returned a nonfinite eigenvector before sorting.")); + } + } + } + + std::vector order(count); + std::iota(order.begin(), order.end(), 0U); + std::stable_sort(order.begin(), order.end(), + [&](const std::size_t left, const std::size_t right) { + return eigenvalues[left] < eigenvalues[right]; + }); + Matrix ordered_vectors(n, count); + std::vector ordered_values; + std::vector ordered_backend_available; + std::vector ordered_backend_res; + std::vector ordered_mu; + std::vector ordered_rho_b; + ordered_values.reserve(count); + ordered_backend_available.reserve(count); + ordered_backend_res.reserve(count); + ordered_mu.reserve(count); + ordered_rho_b.reserve(count); + for (std::size_t output = 0U; output < count; ++output) { + const std::size_t input = order[output]; + const double mu = eigenvalues[input]; + if (!std::isfinite(mu)) { + return Result::Failure( + Failure("invalid-eigenvalue-result", std::to_string(input), + "FEAST returned a nonfinite eigenvalue.")); + } + if (mu < -1.0e-9) { + return Result::Failure(Failure( + "negative-eigenvalue-spectrum", std::to_string(input), + "The raw normalized FEAST eigenvalue is significantly negative.")); + } + if (mu - fixed_lower < Delta(fixed_lower, beta) || + solve_upper - mu < Delta(solve_upper, beta)) { + return Result::Failure(Failure( + "eigen-endpoint-separation", std::to_string(input), + "A returned FEAST eigenvalue is too close to a solve endpoint.")); + } + double norm = 0.0; + for (std::size_t row = 0U; row < n; ++row) + norm += vectors[row + input * n] * vectors[row + input * n]; + norm = std::sqrt(norm); + if (!(norm > 0.0) || !std::isfinite(norm)) + return Result::Failure( + Failure("eigenpair-validation-failed", std::to_string(input), + "FEAST returned a non-normalizable eigenvector.")); + double residual_norm = 0.0; + for (std::size_t row = 0U; row < n; ++row) { + double ax = 0.0; + for (std::size_t p = offsets[row]; p < offsets[row + 1U]; ++p) { + ax += values[p] / operator_scale * + vectors[whitened_operator.ColumnIndices()[p] + input * n]; + } + const double difference = ax - mu * vectors[row + input * n]; + residual_norm += difference * difference; + ordered_vectors(row, output) = vectors[row + input * n] / norm; + } + const double rho_b = std::sqrt(residual_norm) / norm; + if (rho_b > 1.0e-10) { + const FeastBackendCall& last_call = calls.back(); + std::vector mirrored_action(n, 0.0); + std::vector full_action(n, 0.0); + for (std::size_t row = 0U; row < n; ++row) { + for (std::size_t p = offsets[row]; p < offsets[row + 1U]; ++p) { + const std::size_t column = whitened_operator.ColumnIndices()[p]; + const double entry = values[p] / operator_scale; + full_action[row] += entry * vectors[column + input * n]; + if (column < row) continue; + mirrored_action[row] += entry * vectors[column + input * n]; + if (column != row) { + mirrored_action[column] += entry * vectors[row + input * n]; + } + } + } + double mirrored_residual_squared = 0.0; + double action_difference_squared = 0.0; + for (std::size_t row = 0U; row < n; ++row) { + const double mirrored_residual = + mirrored_action[row] - mu * vectors[row + input * n]; + mirrored_residual_squared += mirrored_residual * mirrored_residual; + const double action_difference = + full_action[row] - mirrored_action[row]; + action_difference_squared += action_difference * action_difference; + } + const double rho_b_upper = std::sqrt(mirrored_residual_squared) / norm; + const double action_difference_norm = + std::sqrt(action_difference_squared); + return Result::Failure(Failure( + "eigenpair-validation-failed", std::to_string(input), + "Independent whitened residual exceeds 1e-10: rho_b=" + + FormatDouble(rho_b) + ", mu=" + FormatDouble(mu) + + ", norm=" + FormatDouble(norm) + + ", backend_res=" + FormatDouble(residuals[input]) + + ", info=" + std::to_string(last_call.info) + + ", epsout=" + FormatDouble(last_call.epsout) + + ", loop=" + std::to_string(last_call.loop) + + ", count=" + std::to_string(count) + ", m0=" + + std::to_string(last_call.allocated_m0) + ", interval=(" + + FormatDouble(fixed_lower) + "," + FormatDouble(solve_upper) + + "), calls=" + std::to_string(calls.size()) + + ", history=" + CallHistory(calls) + + ", rho_b_upper=" + FormatDouble(rho_b_upper) + + ", full_upper_action_difference_norm=" + + FormatDouble(action_difference_norm) + ".")); + } + const double lambda = mu * operator_scale; + if (!std::isfinite(lambda) || (mu != 0.0 && lambda == 0.0)) { + return Result::Failure( + Failure("invalid-eigenvalue-result", std::to_string(input), + "Physical eigenvalue conversion overflowed or underflowed.")); + } + ordered_values.push_back(lambda); + const bool backend_res_finite = std::isfinite(residuals[input]); + if (!backend_res_finite && mu != 0.0) { + return Result::Failure( + Failure("invalid-eigenvalue-result", std::to_string(input), + "A nonzero FEAST mode has a nonfinite backend residual.")); + } + ordered_backend_available.push_back(backend_res_finite); + ordered_backend_res.push_back(backend_res_finite ? residuals[input] : 0.0); + ordered_mu.push_back(mu); + ordered_rho_b.push_back(rho_b); + } + double gram_error = 0.0; + double gram_squared = 0.0; + for (std::size_t left = 0U; left < count; ++left) { + for (std::size_t right = 0U; right < count; ++right) { + double dot = 0.0; + for (std::size_t row = 0U; row < n; ++row) + dot += ordered_vectors(row, left) * ordered_vectors(row, right); + const double expected = left == right ? 1.0 : 0.0; + const double difference = std::abs(dot - expected); + gram_squared += difference * difference; + gram_error = (std::max)(gram_error, difference); + if (std::abs(dot - expected) > 1.0e-10) + return Result::Failure(Failure( + "eigenpair-validation-failed", + std::to_string(left) + ":" + std::to_string(right), + "The all-c FEAST vectors fail the independent Gram check.")); + } + } + gram_error = std::sqrt(gram_squared / static_cast(count)); + if (gram_error > 1.0e-10) { + return Result::Failure( + Failure("eigenpair-validation-failed", "gram", + "The normalized all-c Frobenius Gram error exceeds 1e-10: " + + FormatDouble(gram_error) + ".")); + } + char version[256]{}; + mkl_get_version_string(version, static_cast(sizeof(version))); + FeastEigenResult result{requested_modes, + std::move(ordered_values), + std::move(ordered_vectors), + operator_scale, + order, + std::move(ordered_backend_available), + std::move(ordered_backend_res), + std::move(ordered_mu), + std::move(ordered_rho_b), + gram_error, + std::move(calls), + std::move(base_fpm), + version, + "ILP64", + 1U, + kSeed, + fixed_lower, + enclosure_upper, + fixed_lower, + solve_upper, + upper_search_termination}; + return Result::Success(std::move(result)); +} + +} // namespace fesa diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2926538..dde1cc8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -6,6 +6,7 @@ add_executable( unit/analysis/analysis_test.cpp unit/analysis/analysis_model_test.cpp unit/analysis/analysis_state_test.cpp + unit/analysis/eigen_reduction_test.cpp unit/analysis/nonlinear_analysis_state_test.cpp unit/analysis/nonlinear_static_control_test.cpp unit/analysis/newton_iteration_log_test.cpp @@ -24,6 +25,7 @@ add_executable( unit/elements/euler_beam_3d_test.cpp unit/elements/finite_rotation_test.cpp unit/elements/mitc4_shell_test.cpp + unit/elements/mitc4_shell_mass_contract_test.cpp unit/elements/nonlinear_mitc4_shell_test.cpp unit/elements/nonlinear_mitc4_shell_tangent_test.cpp unit/fem/dof_manager_test.cpp @@ -34,10 +36,12 @@ add_executable( unit/math/vector_test.cpp unit/io/abaqus/domain_mapping_components_test.cpp unit/io/abaqus/domain_mapper_test.cpp + unit/io/abaqus/eigen_domain_test.cpp unit/io/abaqus/input_reader_test.cpp unit/io/abaqus/input_syntax_test.cpp unit/io/abaqus/shell_surface_pressure_domain_test.cpp unit/io/hdf5/hdf5_results_writer_test.cpp + unit/io/hdf5/eigen_hdf5_results_writer_test.cpp unit/io/hdf5/nonlinear_hdf5_results_writer_test.cpp unit/io/hdf5/hdf5_writer_components_test.cpp unit/loads/load_test.cpp @@ -52,6 +56,7 @@ add_executable( unit/results/result_recovery_components_test.cpp unit/results/result_recovery_test.cpp unit/results/results_writer_test.cpp + unit/solvers/eigen/mkl_feast_eigen_solver_test.cpp unit/solvers/linear/linear_solver_test.cpp unit/solvers/linear/mkl_pardiso_solver_test.cpp unit/solvers/linear/tangent_solver_test.cpp @@ -75,10 +80,12 @@ target_link_libraries( add_executable( fesa_integration_tests integration/analysis/linear_static_analysis_test.cpp + integration/analysis/linear_frequency_procedure_test.cpp integration/analysis/shell_surface_pressure_linear_test.cpp integration/analysis/shell_surface_pressure_nonlinear_test.cpp integration/analysis/nonlinear_static_procedure_test.cpp integration/app/fesa_application_test.cpp + integration/app/eigen_application_test.cpp integration/app/shell_surface_pressure_application_test.cpp ) @@ -93,6 +100,9 @@ target_link_libraries( add_executable( fesa_reference_tests reference/reference_tolerance_policy.cpp + reference/eigen_reference_comparison.cpp + reference/eigen_reference_comparison_test.cpp + reference/eigen_reference_cases_test.cpp reference/reference_tolerance_policy_test.cpp reference/reference_comparison.cpp reference/reference_comparison_test.cpp @@ -178,18 +188,21 @@ endif() gtest_discover_tests( fesa_unit_tests + DISCOVERY_MODE PRE_TEST PROPERTIES LABELS "linear-static-3d-euler-beam;unit" ) gtest_discover_tests( fesa_integration_tests + DISCOVERY_MODE PRE_TEST PROPERTIES LABELS "linear-static-3d-euler-beam;integration" ) gtest_discover_tests( fesa_reference_tests + DISCOVERY_MODE PRE_TEST PROPERTIES LABELS "linear-static-3d-euler-beam;reference" ) diff --git a/tests/integration/analysis/linear_frequency_procedure_test.cpp b/tests/integration/analysis/linear_frequency_procedure_test.cpp new file mode 100644 index 0000000..cd15396 --- /dev/null +++ b/tests/integration/analysis/linear_frequency_procedure_test.cpp @@ -0,0 +1,280 @@ +#include "fesa/analysis/linear_frequency_procedure.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "fesa/analysis/analysis_model.h" +#include "fesa/analysis/linear_static_procedure.h" +#include "fesa/analysis/prepared_analysis.h" +#include "fesa/assembly/parallel_for.h" +#include "fesa/assembly/sparse_assembler.h" +#include "fesa/fem/dof_manager.h" +#include "fesa/model/domain.h" +#include "fesa/model/model_types.h" +#include "fesa/solvers/linear/mkl_pardiso_solver.h" + +TEST(LinearFrequencyProcedureExecutor, RejectsDomainWithoutFrequencyStep) { + fesa::ModelDefinition definition; + definition.source_path = "frequency-missing.inp"; + definition.source_content_identity = "frequency-missing"; + auto domain = fesa::Domain::Create(std::move(definition)); + ASSERT_TRUE(domain.HasValue()); + + const fesa::LinearFrequencyProcedureExecutor executor; + const auto result = executor.Execute(domain.Value()); + + ASSERT_FALSE(result.HasValue()); + EXPECT_EQ(result.GetStatus().Category(), fesa::FailureCategory::kSolver); + ASSERT_EQ(result.GetStatus().Diagnostics().size(), 1U); + EXPECT_EQ(result.GetStatus().Diagnostics()[0U].code, + "invalid-frequency-procedure"); +} + +namespace { + +std::string StatusText(const fesa::Status& status) { + std::string text; + for (const auto& d : status.Diagnostics()) + text += d.code + " " + d.message + "\n"; + return text; +} + +fesa::Result PanelDomain(bool rotated, bool free_shell, + bool statics = false, + std::size_t requested = 1U, + double length_scale = 1.0, + double density = 1.0, + double modulus = 12.0) { + const std::array, 4> points{{{{0.8, -2.0, -0.6}}, + {{0.8, 2.0, -0.6}}, + {{-0.8, 2.0, 0.6}}, + {{-0.8, -2.0, 0.6}}}}; + std::ostringstream deck; + deck << std::setprecision(17) << "*Part,name=Panel\n*Node\n"; + const double c = std::sqrt(0.5); + for (std::size_t i = 0; i < points.size(); ++i) { + const auto& p = points[i]; + deck << 11U * (i + 1U) << ',' + << length_scale * (rotated ? c * (p[0] - p[1]) : p[0]) << ',' + << length_scale * (rotated ? c * (p[0] + p[1]) : p[1]) << ',' + << length_scale * p[2] << '\n'; + } + deck << R"inp(*Element,type=S4 +71,11,22,33,44 +*Elset,elset=Shell +71 +*Shell Section,elset=Shell,material=Steel +)inp" << length_scale + << R"inp( +*End Part +*Assembly,name=Assembly +*Instance,name=Panel-1,part=Panel +*End Instance +*Nset,nset=Fixed,instance=Panel-1 +22,33,44 +*Nset,nset=Tip,instance=Panel-1 +11 +*End Assembly +*Material,name=Steel +*Elastic +)inp" << modulus + << ",0.\n"; + if (!statics) deck << "*Density\n" << density << '\n'; + if (!free_shell) deck << "*Boundary\nFixed,1,6\nTip,1,5\n"; + if (statics) { + deck << "*Step,name=Static,nlgeom=NO\n*Static\n0.1,1.,0.01,1.\n*Cload\nTip," + "4," + << (-4.0 / 3.0) * (rotated ? c : 1.0) << "\nTip,5," + << (rotated ? (-4.0 / 3.0) * c : 0.0) << "\nTip,6,1.\n"; + } else + deck << "*Step,name=Step-2,nlgeom=NO,perturbation\n*Frequency\n" + << requested << '\n'; + deck << "*End Step\n"; + static std::atomic sequence{0U}; + const auto path = + std::filesystem::temp_directory_path() / + ("fesa-eigen-procedure-" + + std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count()) + + "-" + std::to_string(sequence.fetch_add(1U)) + ".inp"); + { + std::ofstream stream{path}; + stream << deck.str(); + } + auto prepared = fesa::PrepareAnalysisInput(path); + std::error_code ignored; + std::filesystem::remove(path, ignored); + if (!prepared.HasValue()) + return fesa::Result::Failure(prepared.GetStatus()); + return fesa::Result::Success( + std::move(prepared.Value().domain)); +} + +} // namespace + +TEST(LinearFrequencyProcedureExecutor, + MatchesPartialBoundaryPencilAndFullResidualCovariance) { + std::array original_residual{}; + for (const bool rotated : {false, true}) { + auto domain = PanelDomain(rotated, false); + ASSERT_TRUE(domain.HasValue()) << StatusText(domain.GetStatus()); + const auto result = + fesa::LinearFrequencyProcedureExecutor{}.Execute(domain.Value()); + ASSERT_TRUE(result.HasValue()) << StatusText(result.GetStatus()); + const auto& state = result.Value(); + ASSERT_EQ(state.modes.size(), 1U); + EXPECT_EQ(state.mass_null_dimension, 0U); + EXPECT_EQ(state.finite_dimension, 1U); + EXPECT_NEAR(state.modes[0].eigenvalue, 10369.0 / 450.0, 1e-10); + EXPECT_EQ(state.modes[0].classification, 1U); + EXPECT_EQ(state.vectors.Rows(), 1U); + EXPECT_EQ(state.vectors.Columns(), 24U); + EXPECT_NEAR(state.vectors(0U, 5U), std::sqrt(50.0 / 3.0), 1e-12); + for (std::size_t dof = 0; dof < 24U; ++dof) + if (dof != 5U) EXPECT_EQ(state.vectors(0U, dof), 0.0); + ASSERT_EQ(state.eigenpair_checks.size(), 1U); + EXPECT_NEAR(state.eigenpair_checks[0].modal_mass, 1.0, 1e-12); + EXPECT_LE(state.eigenpair_checks[0].eta_r, 1e-10); + EXPECT_LE(state.eigenpair_checks[0].eta_energy, 1e-10); + EXPECT_LE(state.eigenpair_checks[0].rho_b, 1e-10); + EXPECT_EQ(state.backend.eigenvectors.Rows(), 0U); + auto static_domain = PanelDomain(rotated, false, true); + ASSERT_TRUE(static_domain.HasValue()) + << StatusText(static_domain.GetStatus()); + const fesa::SerialParallelFor serial; + const auto model = fesa::AnalysisModel::Create(static_domain.Value()); + ASSERT_TRUE(model.HasValue()); + const auto dofs = fesa::DofManager::Create(model.Value()); + ASSERT_TRUE(dofs.HasValue()); + const auto stiffness = fesa::SparseAssembler::AssembleStiffness( + model.Value(), dofs.Value(), serial); + ASSERT_TRUE(stiffness.HasValue()); + fesa::Vector unit{24U}; + unit[5U] = 1.0; + const double kff = stiffness.Value().Multiply(unit)[5U]; + EXPECT_NEAR(kff, 10369.0 / 7500.0, 1e-12); + fesa::Vector displacement{24U}; + displacement[5U] = 1.0 / kff; + EXPECT_NEAR(displacement[5U], 7500.0 / 10369.0, 1e-12); + auto residual = stiffness.Value().Multiply(displacement); + const double c = std::sqrt(0.5); + residual[3U] -= (-4.0 / 3.0) * (rotated ? c : 1.0); + residual[4U] -= rotated ? (-4.0 / 3.0) * c : 0.0; + residual[5U] -= 1.0; + EXPECT_NEAR(residual[5U], 0.0, 1e-12); + for (std::size_t i = 0U; i < 24U; ++i) { + if (!rotated) + original_residual[i] = residual[i]; + else { + const std::size_t base = (i / 3U) * 3U; + const double expected = + i % 3U == 0U + ? c * (original_residual[base] - original_residual[base + 1U]) + : (i % 3U == 1U ? c * (original_residual[base] + + original_residual[base + 1U]) + : original_residual[i]); + EXPECT_NEAR(residual[i], expected, 1e-12); + } + } + } +} + +// E2-C09 is a pencil oracle. The public static contract conflict is recorded +// separately; direct covariance does not certify spatial moment equilibrium. +TEST(LinearFrequencyProcedureExecutor, + PartialBoundaryStaticRejectsUnbalancedDrillingMoment) { + const auto domain = PanelDomain(false, false, true); + ASSERT_TRUE(domain.HasValue()); + const fesa::SerialParallelFor serial; + fesa::MklPardisoSolver solver; + const auto result = + fesa::LinearStaticProcedureExecutor{serial, solver}.Execute( + domain.Value()); + ASSERT_FALSE(result.HasValue()); + EXPECT_NE(StatusText(result.GetStatus()) + .find("global-equilibrium-tolerance-failure"), + std::string::npos); +} + +TEST(LinearFrequencyProcedureExecutor, + FreePanelRetainsSixRigidModesAndAllComputedEvidence) { + auto domain = PanelDomain(false, true, false, 6U); + ASSERT_TRUE(domain.HasValue()) << StatusText(domain.GetStatus()); + const auto result = + fesa::LinearFrequencyProcedureExecutor{}.Execute(domain.Value()); + ASSERT_TRUE(result.HasValue()) << StatusText(result.GetStatus()); + const auto& state = result.Value(); + EXPECT_EQ(state.mass_null_dimension, 4U); + EXPECT_EQ(state.finite_dimension, 20U); + ASSERT_EQ(state.modes.size(), 6U); + EXPECT_GE(state.eigenpair_checks.size(), 6U); + for (const auto& mode : state.modes) { + EXPECT_EQ(mode.classification, 0U); + EXPECT_EQ(mode.cluster_id, 1U); + } + EXPECT_LE(state.eta_gram, 1e-10); + EXPECT_LE(state.max_eta_orth, 1e-10); + EXPECT_LE(state.eta_mz, 1e-12); + EXPECT_LE(state.eta_kz, 1e-12); + for (const auto& check : state.eigenpair_checks) { + EXPECT_LE(check.eta_r, 1e-10); + EXPECT_LE(check.eta_norm, 1e-10); + EXPECT_LE(check.eta_energy, 1e-10); + } +} + +TEST(LinearFrequencyProcedureExecutor, + ExcessRequestedModesFailsWithSourceContext) { + auto domain = PanelDomain(false, false, false, 2U); + ASSERT_TRUE(domain.HasValue()) << StatusText(domain.GetStatus()); + const auto result = + fesa::LinearFrequencyProcedureExecutor{}.Execute(domain.Value()); + ASSERT_FALSE(result.HasValue()); + EXPECT_EQ(result.GetStatus().Category(), fesa::FailureCategory::kModel); + ASSERT_FALSE(result.GetStatus().Diagnostics().empty()); + EXPECT_EQ(result.GetStatus().Diagnostics()[0].code, + "insufficient-finite-modes"); + EXPECT_GT(result.GetStatus().Diagnostics()[0].location.line, 0U); +} + +TEST(LinearFrequencyProcedureExecutor, PreservesDensityAndPhysicalUnitScaling) { + const auto baseline_domain = PanelDomain(false, false); + const auto dense_domain = PanelDomain(false, false, false, 1U, 1.0, 4.0); + constexpr double length = 1024.0; + const auto units_domain = + PanelDomain(false, false, false, 1U, length, + 1.0 / (length * length * length), 12.0 / length); + ASSERT_TRUE(baseline_domain.HasValue()); + ASSERT_TRUE(dense_domain.HasValue()); + ASSERT_TRUE(units_domain.HasValue()); + const fesa::LinearFrequencyProcedureExecutor executor; + const auto baseline = executor.Execute(baseline_domain.Value()); + const auto dense = executor.Execute(dense_domain.Value()); + const auto units = executor.Execute(units_domain.Value()); + ASSERT_TRUE(baseline.HasValue()) << StatusText(baseline.GetStatus()); + ASSERT_TRUE(dense.HasValue()) << StatusText(dense.GetStatus()); + ASSERT_TRUE(units.HasValue()) << StatusText(units.GetStatus()); + EXPECT_NEAR( + dense.Value().modes[0].eigenvalue / baseline.Value().modes[0].eigenvalue, + 0.25, 1e-12); + EXPECT_NEAR(dense.Value().vectors(0U, 5U) / baseline.Value().vectors(0U, 5U), + 0.5, 1e-12); + EXPECT_NEAR( + units.Value().modes[0].eigenvalue / baseline.Value().modes[0].eigenvalue, + 1.0, 1e-12); + EXPECT_NEAR( + units.Value().vectors(0U, 5U) * length / baseline.Value().vectors(0U, 5U), + 1.0, 1e-12); + EXPECT_LE(units.Value().eigenpair_checks[0].eta_r, 1e-10); + EXPECT_LE(units.Value().eigenpair_checks[0].eta_energy, 1e-10); +} diff --git a/tests/integration/app/eigen_application_test.cpp b/tests/integration/app/eigen_application_test.cpp new file mode 100644 index 0000000..2f8d8b6 --- /dev/null +++ b/tests/integration/app/eigen_application_test.cpp @@ -0,0 +1,111 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "fesa/app/fesa_application.h" + +namespace { + +class EigenApplicationTest : public ::testing::Test { + protected: + void SetUp() override { + static std::atomic sequence{0U}; + folder_ = + std::filesystem::temp_directory_path() / + ("fesa-eigen-app-" + + std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count()) + + "-" + std::to_string(sequence.fetch_add(1U))); + ASSERT_TRUE(std::filesystem::create_directory(folder_)); + input_ = folder_ / "Panel.Modes.inp"; + output_ = folder_ / "Panel.Modes_results.h5"; + log_ = folder_ / "Panel.Modes_log.txt"; + } + + void TearDown() override { + std::error_code ignored; + std::filesystem::remove_all(folder_, ignored); + } + + void WriteDeck(const bool empty = false) { + std::ofstream file{input_}; + file << R"inp(*Part, name=Panel +*Node +11,0,0,0 +22,1,0,0 +33,1,1,0 +44,0,1,0 +*Element,type=S4 +71,11,22,33,44 +*Elset,elset=Shell +71 +*Shell Section,elset=Shell,material=Steel +0.5 +*End Part +*Assembly,name=Assembly +*Instance,name=Panel-1,part=Panel +*End Instance +*Nset,nset=Fixed,instance=Panel-1 +11,22,33 +*Nset,nset=Tip,instance=Panel-1 +44 +*End Assembly +*Material,name=Steel +*Density +1. +*Elastic +12.,0. +*Boundary +Fixed,1,6 +Tip,1,2 +Tip,4,6 +)inp"; + if (empty) file << "Tip,3,3\n"; + file << R"inp(*Step,name=Step-2,nlgeom=NO,perturbation +*Frequency,eigensolver=Lanczos,normalization=mass +1 +*End Step +)inp"; + ASSERT_TRUE(file.good()); + } + + std::filesystem::path folder_, input_, output_, log_; +}; + +TEST_F(EigenApplicationTest, DispatchesFrequencyAndWritesModalSibling) { + WriteDeck(); + ASSERT_EQ(fesa::FesaApplication{}.Run({input_.string()}), 0); + ASSERT_TRUE(std::filesystem::exists(output_)); + ASSERT_TRUE(std::filesystem::exists(log_)); + EXPECT_EQ(std::filesystem::file_size(log_), 0U); + const hid_t file = + H5Fopen(output_.string().c_str(), H5F_ACC_RDONLY, H5P_DEFAULT); + ASSERT_GE(file, 0); + EXPECT_GT(H5Lexists(file, "/steps/Step-1/eigen/modes", H5P_DEFAULT), 0); + EXPECT_GT(H5Lexists(file, "/steps/Step-1/eigen/vectors", H5P_DEFAULT), 0); + EXPECT_EQ(H5Lexists(file, "/steps/Step-1/frames", H5P_DEFAULT), 0); + EXPECT_GE(H5Fclose(file), 0); +} + +TEST_F(EigenApplicationTest, EmptyPencilPreservesPreviousFinalAndEmptyLog) { + WriteDeck(true); + const std::string original = "previous authoritative file"; + { + std::ofstream previous{output_, std::ios::binary}; + previous << original; + } + EXPECT_EQ(fesa::FesaApplication{}.Run({input_.string()}), 4); + std::ifstream previous{output_, std::ios::binary}; + EXPECT_TRUE(std::string(std::istreambuf_iterator{previous}, {}) == + original); + EXPECT_EQ(std::filesystem::file_size(log_), 0U); +} + +} // namespace diff --git a/tests/reference/eigen_reference_cases_test.cpp b/tests/reference/eigen_reference_cases_test.cpp new file mode 100644 index 0000000..97aee97 --- /dev/null +++ b/tests/reference/eigen_reference_cases_test.cpp @@ -0,0 +1,70 @@ +#include + +#include +#include +#include +#include +#include + +#include "eigen_reference_comparison.h" +#include "fesa/app/fesa_application.h" + +#ifndef FESA_TEST_SOURCE_DIR +#error FESA_TEST_SOURCE_DIR must identify the repository root. +#endif + +#ifndef FESA_TEST_BINARY_DIR +#error FESA_TEST_BINARY_DIR must identify the CMake binary root. +#endif + +namespace { + +std::string ReadBytes(const std::filesystem::path& path) { + std::ifstream stream{path, std::ios::binary}; + if (!stream) { + throw std::runtime_error{"Unable to read declared eigen artifact."}; + } + return {std::istreambuf_iterator{stream}, + std::istreambuf_iterator{}}; +} + +// E2-C25. +TEST(EigenReference, RunsOriginalInputAndComparesAllModalFrameIdentities) { + const std::filesystem::path source_root{FESA_TEST_SOURCE_DIR}; + const std::filesystem::path reference_directory = + source_root / "reference" / "shellNL-eigen-meshfine"; + const std::filesystem::path input = + reference_directory / "shellNL-eigen-meshfine.inp"; + const std::filesystem::path text = + reference_directory / "shellNL-eigen-meshfine eigenvalues.txt"; + const std::string input_before = ReadBytes(input); + const std::string text_before = ReadBytes(text); + + fesa::FesaApplication application; + const int exit_code = application.Run({input.string()}); + EXPECT_EQ(ReadBytes(input), input_before); + EXPECT_EQ(ReadBytes(text), text_before); + ASSERT_EQ(exit_code, 0); + const std::filesystem::path results = + reference_directory / "shellNL-eigen-meshfine_results.h5"; + ASSERT_TRUE(std::filesystem::is_regular_file(results)); + const auto comparison = fesa::test::EigenReferenceComparison::Compare( + {"shellnl-eigen-meshfine", input, text, results}); + ASSERT_TRUE(comparison.HasValue()); + EXPECT_TRUE(comparison.Value().passed); + EXPECT_EQ(comparison.Value().source_step_name, "Step-2"); + EXPECT_EQ(comparison.Value().requested_mode_count, 10U); + ASSERT_EQ(comparison.Value().rows.size(), 10U); + ASSERT_EQ(comparison.Value().families.size(), 10U); + for (std::size_t index = 0U; index < comparison.Value().rows.size(); + ++index) { + EXPECT_EQ(comparison.Value().rows[index].mode_id, index + 1U); + EXPECT_EQ(comparison.Value().rows[index].component, "Value"); + EXPECT_EQ(comparison.Value().families[index].row_count, 1U); + EXPECT_TRUE(comparison.Value().families[index].passed); + } + EXPECT_EQ(ReadBytes(input), input_before); + EXPECT_EQ(ReadBytes(text), text_before); +} + +} // namespace diff --git a/tests/reference/eigen_reference_comparison.cpp b/tests/reference/eigen_reference_comparison.cpp new file mode 100644 index 0000000..d1e6209 --- /dev/null +++ b/tests/reference/eigen_reference_comparison.cpp @@ -0,0 +1,432 @@ +#include "eigen_reference_comparison.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "fesa/io/abaqus/domain_mapper.h" +#include "fesa/io/abaqus/input_reader.h" + +namespace fesa::test { +namespace { + +constexpr char kModesPath[] = "/steps/Step-1/eigen/modes"; + +class ComparisonFailure final : public std::runtime_error { + public: + ComparisonFailure(std::string code, std::string message) + : std::runtime_error{std::move(message)}, code_{std::move(code)} {} + + const std::string& Code() const noexcept { return code_; } + + private: + std::string code_; +}; + +[[noreturn]] void Fail(const std::string& code, const std::string& message) { + throw ComparisonFailure{code, message}; +} + +Status FailureStatus(const EigenReferenceCase& reference_case, + const std::string& code, const std::string& message) { + return Status::Failure(FailureCategory::kModel, {{Severity::kError, + code, + {}, + "FREQUENCY", + reference_case.case_id, + message}}); +} + +class Hdf5Handle { + public: + using Closer = herr_t (*)(hid_t); + + Hdf5Handle() = default; + Hdf5Handle(hid_t value, Closer closer) : value_{value}, closer_{closer} {} + Hdf5Handle(const Hdf5Handle&) = delete; + Hdf5Handle& operator=(const Hdf5Handle&) = delete; + Hdf5Handle(Hdf5Handle&& other) noexcept + : value_{other.value_}, closer_{other.closer_} { + other.value_ = -1; + } + ~Hdf5Handle() { + if (value_ >= 0 && closer_ != nullptr) { + (void)closer_(value_); + } + } + + hid_t Get() const noexcept { return value_; } + + private: + hid_t value_{-1}; + Closer closer_{nullptr}; +}; + +hid_t RequireId(hid_t value, const char* message) { + if (value < 0) { + Fail("invalid-eigen-hdf5-schema", message); + } + return value; +} + +void RequireHdf(herr_t value, const char* message) { + if (value < 0) { + Fail("invalid-eigen-hdf5-schema", message); + } +} + +std::string ReadUtf8Attribute(hid_t object, const char* name) { + Hdf5Handle attribute{RequireId(H5Aopen(object, name, H5P_DEFAULT), + "A required HDF5 attribute is missing."), + H5Aclose}; + Hdf5Handle type{RequireId(H5Aget_type(attribute.Get()), + "Unable to inspect an HDF5 attribute type."), + H5Tclose}; + if (H5Tget_class(type.Get()) != H5T_STRING || + H5Tget_cset(type.Get()) != H5T_CSET_UTF8 || + H5Tis_variable_str(type.Get()) <= 0) { + Fail("invalid-eigen-hdf5-schema", + "A required HDF5 string attribute has the wrong type."); + } + Hdf5Handle space{RequireId(H5Aget_space(attribute.Get()), + "Unable to inspect an HDF5 attribute shape."), + H5Sclose}; + if (H5Sget_simple_extent_ndims(space.Get()) != 0) { + Fail("invalid-eigen-hdf5-schema", + "A required HDF5 string attribute is not scalar."); + } + char* value = nullptr; + RequireHdf(H5Aread(attribute.Get(), type.Get(), &value), + "Unable to read an HDF5 string attribute."); + if (value == nullptr) { + Fail("invalid-eigen-hdf5-schema", "An HDF5 string attribute is null."); + } + const std::string result{value}; + RequireHdf(H5free_memory(value), "Unable to release HDF5 string memory."); + return result; +} + +std::uint64_t ReadUint64Attribute(hid_t object, const char* name) { + Hdf5Handle attribute{RequireId(H5Aopen(object, name, H5P_DEFAULT), + "A required HDF5 attribute is missing."), + H5Aclose}; + Hdf5Handle type{RequireId(H5Aget_type(attribute.Get()), + "Unable to inspect an HDF5 attribute type."), + H5Tclose}; + if (H5Tget_class(type.Get()) != H5T_INTEGER || + H5Tget_size(type.Get()) != sizeof(std::uint64_t) || + H5Tget_sign(type.Get()) != H5T_SGN_NONE) { + Fail("invalid-eigen-hdf5-schema", + "A required HDF5 integer attribute has the wrong type."); + } + Hdf5Handle space{RequireId(H5Aget_space(attribute.Get()), + "Unable to inspect an HDF5 attribute shape."), + H5Sclose}; + if (H5Sget_simple_extent_ndims(space.Get()) != 0) { + Fail("invalid-eigen-hdf5-schema", + "A required HDF5 integer attribute is not scalar."); + } + std::uint64_t value = 0U; + RequireHdf(H5Aread(attribute.Get(), H5T_NATIVE_UINT64, &value), + "Unable to read an HDF5 integer attribute."); + return value; +} + +struct Hdf5Mode { + std::uint64_t mode_id; + double eigenvalue; + std::uint8_t classification; + std::uint64_t cluster_id; +}; + +std::vector ReadModes(hid_t file, std::uint64_t expected_count, + const std::string& source_step_name) { + Hdf5Handle dataset{RequireId(H5Dopen2(file, kModesPath, H5P_DEFAULT), + "The eigen modes dataset is missing."), + H5Dclose}; + Hdf5Handle space{RequireId(H5Dget_space(dataset.Get()), + "Unable to inspect the modes dataspace."), + H5Sclose}; + if (H5Sget_simple_extent_ndims(space.Get()) != 1) { + Fail("invalid-eigen-hdf5-schema", "The modes dataset must be rank one."); + } + hsize_t count = 0U; + RequireHdf(H5Sget_simple_extent_dims(space.Get(), &count, nullptr), + "Unable to inspect the modes dataset extent."); + if (count != expected_count) { + Fail("eigen-mode-identity-mismatch", + "The modes dataset count does not equal requested mode count."); + } + Hdf5Handle type{RequireId(H5Dget_type(dataset.Get()), + "Unable to inspect the modes dataset type."), + H5Tclose}; + if (H5Tget_class(type.Get()) != H5T_COMPOUND || + H5Tget_nmembers(type.Get()) != 4 || + H5Tget_member_index(type.Get(), "mode_id") < 0 || + H5Tget_member_index(type.Get(), "eigenvalue") < 0 || + H5Tget_member_index(type.Get(), "classification") < 0 || + H5Tget_member_index(type.Get(), "cluster_id") < 0) { + Fail("invalid-eigen-hdf5-schema", "The modes compound schema is invalid."); + } + const std::array expected_types{H5T_STD_U64LE, H5T_IEEE_F64LE, + H5T_STD_U8LE, H5T_STD_U64LE}; + const std::array names{"mode_id", "eigenvalue", + "classification", "cluster_id"}; + for (std::size_t index = 0U; index < names.size(); ++index) { + Hdf5Handle member{ + RequireId(H5Tget_member_type(type.Get(), static_cast(index)), + "Unable to inspect a modes compound member."), + H5Tclose}; + char* raw_name = + H5Tget_member_name(type.Get(), static_cast(index)); + if (raw_name == nullptr) { + Fail("invalid-eigen-hdf5-schema", + "Unable to inspect a modes member name."); + } + const std::string member_name{raw_name}; + RequireHdf(H5free_memory(raw_name), + "Unable to release a modes member name."); + if (member_name != names[index] || + H5Tequal(member.Get(), expected_types[index]) <= 0) { + Fail("invalid-eigen-hdf5-schema", + "The modes compound member type or order is invalid."); + } + } + Hdf5Handle memory_type{RequireId(H5Tcreate(H5T_COMPOUND, sizeof(Hdf5Mode)), + "Unable to create a modes memory type."), + H5Tclose}; + RequireHdf(H5Tinsert(memory_type.Get(), "mode_id", HOFFSET(Hdf5Mode, mode_id), + H5T_NATIVE_UINT64), + "Unable to define the mode identifier field."); + RequireHdf(H5Tinsert(memory_type.Get(), "eigenvalue", + HOFFSET(Hdf5Mode, eigenvalue), H5T_NATIVE_DOUBLE), + "Unable to define the eigenvalue field."); + RequireHdf(H5Tinsert(memory_type.Get(), "classification", + HOFFSET(Hdf5Mode, classification), H5T_NATIVE_UINT8), + "Unable to define the classification field."); + RequireHdf(H5Tinsert(memory_type.Get(), "cluster_id", + HOFFSET(Hdf5Mode, cluster_id), H5T_NATIVE_UINT64), + "Unable to define the cluster field."); + std::vector modes(static_cast(count)); + RequireHdf(H5Dread(dataset.Get(), memory_type.Get(), H5S_ALL, H5S_ALL, + H5P_DEFAULT, modes.data()), + "Unable to read the modes dataset."); + for (std::size_t index = 0U; index < modes.size(); ++index) { + if (modes[index].mode_id != index + 1U || + modes[index].mode_id > static_cast(( + std::numeric_limits::max)()) || + !std::isfinite(modes[index].eigenvalue)) { + Fail("eigen-mode-identity-mismatch", + "Mode IDs must be ordered one through requested mode count."); + } + } + if (ReadUtf8Attribute(dataset.Get(), "quantity") != "eigenvalue" || + ReadUint64Attribute(dataset.Get(), "eigen_schema_version") != 1U || + ReadUtf8Attribute(dataset.Get(), "source_step_name") != + source_step_name || + ReadUtf8Attribute(dataset.Get(), "domain") != "mode" || + ReadUtf8Attribute(dataset.Get(), "component_names") != "Value" || + ReadUtf8Attribute(dataset.Get(), "component_unit_dimensions") != + "time^-2" || + ReadUtf8Attribute(dataset.Get(), "coordinate_system") != "scalar" || + ReadUtf8Attribute(dataset.Get(), "location") != "modal-frame") { + Fail("invalid-eigen-hdf5-schema", "Modes dataset metadata is invalid."); + } + return modes; +} + +double ParseFiniteNumber(const std::string& token) { + static const std::regex number{ + R"([+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[Ee][+-]?[0-9]+)?)"}; + if (!std::regex_match(token, number)) { + Fail("invalid-eigen-reference-text", "A TXT numeric token is malformed."); + } + double value = 0.0; + const char* first = token.data() + (token.front() == '+' ? 1U : 0U); + const auto conversion = + std::from_chars(first, token.data() + token.size(), value); + if (conversion.ec != std::errc{} || + conversion.ptr != token.data() + token.size() || !std::isfinite(value)) { + Fail("invalid-eigen-reference-text", "A TXT Value is nonfinite."); + } + return value; +} + +void RequireNumberSyntax(const std::string& token) { + static const std::regex number{ + R"([+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[Ee][+-]?[0-9]+)?)"}; + if (!std::regex_match(token, number)) { + Fail("invalid-eigen-reference-text", "A TXT numeric token is malformed."); + } +} + +std::map ReadReferenceText( + const std::filesystem::path& path) { + std::ifstream stream{path, std::ios::binary}; + if (!stream) { + Fail("needs-reference-artifacts", + "The eigenvalue TXT artifact is missing."); + } + const std::regex row{ + R"(^[ \t]*Frame:[ \t]+Mode[ \t]+([0-9]+):[ \t]+Value[ \t]*=[ \t]*([+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[Ee][+-]?[0-9]+)?)[ \t]+Freq[ \t]*=[ \t]*([+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[Ee][+-]?[0-9]+)?)[ \t]+\(cycles/time\)[ \t]*$)"}; + std::map rows; + std::string line; + while (std::getline(stream, line)) { + if (!line.empty() && line.back() == '\r') { + line.pop_back(); + } + if (line.find_first_not_of(" \t") == std::string::npos) { + continue; + } + std::smatch match; + if (!std::regex_match(line, match, row)) { + Fail("invalid-eigen-reference-text", + "A TXT row is not exact Frame/Mode syntax."); + } + std::uint64_t mode_id = 0U; + const std::string mode_text = match[1].str(); + const auto parsed = std::from_chars( + mode_text.data(), mode_text.data() + mode_text.size(), mode_id); + if (parsed.ec != std::errc{} || + parsed.ptr != mode_text.data() + mode_text.size() || mode_id == 0U || + mode_id > static_cast( + (std::numeric_limits::max)()) || + !rows.emplace(mode_id, ParseFiniteNumber(match[2].str())).second) { + Fail("eigen-mode-identity-mismatch", + "TXT mode IDs must be unique positive integers."); + } + RequireNumberSyntax(match[3].str()); + } + if (rows.empty()) { + Fail("invalid-eigen-reference-text", + "The eigenvalue TXT artifact is empty."); + } + return rows; +} + +} // namespace + +Result EigenReferenceComparison::Compare( + const EigenReferenceCase& reference_case) { + try { + auto parsed = AbaqusInputReader{}.Read(reference_case.input_path); + if (!parsed.HasValue()) { + Fail("needs-reference-artifacts", "The eigen input cannot be parsed."); + } + auto domain = AbaqusDomainMapper{}.Map(parsed.Value()); + if (!domain.HasValue() || domain.Value().Frequency() == nullptr) { + Fail("needs-reference-artifacts", + "The input has no sole frequency step."); + } + const FrequencyStepDefinition& frequency = *domain.Value().Frequency(); + const std::uint64_t requested = + static_cast(frequency.requested_mode_count); + const auto reference = + ReadReferenceText(reference_case.eigenvalues_text_path); + Hdf5Handle file{ + RequireId(H5Fopen(reference_case.results_hdf5_path.string().c_str(), + H5F_ACC_RDONLY, H5P_DEFAULT), + "The eigen HDF5 result is missing."), + H5Fclose}; + Hdf5Handle metadata{ + RequireId(H5Gopen2(file.Get(), "/metadata", H5P_DEFAULT), + "The metadata group is missing."), + H5Gclose}; + if (ReadUint64Attribute(metadata.Get(), "schema_version") != 0U || + ReadUint64Attribute(metadata.Get(), "eigen_schema_version") != 1U || + ReadUtf8Attribute(metadata.Get(), "feature_id") != + "mitc4-shell-feast-eigen2" || + ReadUtf8Attribute(metadata.Get(), "analysis_procedure") != + "linear-frequency" || + ReadUtf8Attribute(metadata.Get(), "mass_variant") != "derived-lumped" || + ReadUtf8Attribute(metadata.Get(), "normalization") != "mass" || + ReadUtf8Attribute(metadata.Get(), "eigensolver_backend") != + "MKL FEAST") { + Fail("invalid-eigen-hdf5-schema", + "The eigen result discriminator is invalid."); + } + const std::string expected_identity = + "path=" + parsed.Value().source_path.generic_string() + + ";content_identity=" + parsed.Value().source_content_identity; + if (ReadUtf8Attribute(metadata.Get(), "source_input_identity") != + expected_identity) { + Fail("eigen-source-identity-mismatch", + "The HDF5 source input identity differs from the executed input."); + } + Hdf5Handle step{ + RequireId(H5Gopen2(file.Get(), "/steps/Step-1", H5P_DEFAULT), + "The eigen storage step is missing."), + H5Gclose}; + if (ReadUtf8Attribute(step.Get(), "source_step_name") != + frequency.source_step_name_raw || + ReadUint64Attribute(step.Get(), "requested_mode_count") != requested || + ReadUint64Attribute(step.Get(), "returned_mode_count") != requested) { + Fail("eigen-mode-identity-mismatch", + "The HDF5 source step or requested mode count is inconsistent."); + } + const auto modes = + ReadModes(file.Get(), requested, frequency.source_step_name_raw); + if (reference.size() != modes.size()) { + Fail("eigen-mode-identity-mismatch", + "TXT and HDF5 mode inventories have different sizes."); + } + + EigenReferenceComparisonReport report{}; + report.case_id = reference_case.case_id; + report.source_step_name = frequency.source_step_name_raw; + report.requested_mode_count = requested; + std::vector families; + families.reserve(modes.size()); + for (const auto& mode : modes) { + const auto row = reference.find(mode.mode_id); + if (row == reference.end()) { + Fail("eigen-mode-identity-mismatch", + "A HDF5 mode is missing from TXT."); + } + families.push_back({"case=" + reference_case.case_id + + ";source_step=" + frequency.source_step_name_raw + + ";mode_id=" + std::to_string(mode.mode_id) + + ";component=Value", + {"Value"}, + true, + {{mode.eigenvalue, row->second}}}); + } + const auto evaluation = ReferenceTolerancePolicy::Evaluate(families); + if (!evaluation.valid || evaluation.families.size() != families.size()) { + Fail("nonfinite-comparison-value", + "Tolerance evaluation rejected modal values."); + } + report.families = evaluation.families; + report.passed = evaluation.passed; + for (std::size_t index = 0U; index < modes.size(); ++index) { + const auto& policy = report.families[index]; + report.rows.push_back( + {frequency.source_step_name_raw, modes[index].mode_id, "Value", + modes[index].eigenvalue, reference.at(modes[index].mode_id), + policy.rows[0U]}); + } + return Result::Success(std::move(report)); + } catch (const ComparisonFailure& exception) { + return Result::Failure( + FailureStatus(reference_case, exception.Code(), exception.what())); + } catch (const std::exception& exception) { + return Result::Failure( + FailureStatus(reference_case, "eigen-reference-comparison-failure", + exception.what())); + } +} + +} // namespace fesa::test diff --git a/tests/reference/eigen_reference_comparison.h b/tests/reference/eigen_reference_comparison.h new file mode 100644 index 0000000..be4be6e --- /dev/null +++ b/tests/reference/eigen_reference_comparison.h @@ -0,0 +1,49 @@ +#ifndef FESA_TESTS_REFERENCE_EIGEN_REFERENCE_COMPARISON_H_ +#define FESA_TESTS_REFERENCE_EIGEN_REFERENCE_COMPARISON_H_ + +#include +#include +#include +#include + +#include "fesa/core/status.h" +#include "reference_tolerance_policy.h" + +namespace fesa::test { + +struct EigenReferenceCase { + std::string case_id; + std::filesystem::path input_path; + std::filesystem::path eigenvalues_text_path; + std::filesystem::path results_hdf5_path; +}; + +struct EigenReferenceRowDecision { + std::string source_step_name; + std::uint64_t mode_id; + std::string component; + double fesa_value; + double reference_value; + ReferenceToleranceRowDecision tolerance; +}; + +struct EigenReferenceComparisonReport { + std::string case_id; + std::string source_step_name; + std::uint64_t requested_mode_count; + std::vector rows; + std::vector families; + bool passed; +}; + +/// @brief Compares modal eigenvalues without changing the approved input or +/// TXT. +class EigenReferenceComparison { + public: + static Result Compare( + const EigenReferenceCase& reference_case); +}; + +} // namespace fesa::test + +#endif // FESA_TESTS_REFERENCE_EIGEN_REFERENCE_COMPARISON_H_ diff --git a/tests/reference/eigen_reference_comparison_test.cpp b/tests/reference/eigen_reference_comparison_test.cpp new file mode 100644 index 0000000..e61c479 --- /dev/null +++ b/tests/reference/eigen_reference_comparison_test.cpp @@ -0,0 +1,165 @@ +#include "eigen_reference_comparison.h" + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "fesa/io/abaqus/input_reader.h" + +// The direct fixture is intentionally kept in this test file: it exercises the +// comparator contract without depending on the application or HDF5 writer. +namespace { +std::filesystem::path UniqueFixtureDirectory() { + static std::atomic serial{0U}; + const auto ticks = + std::chrono::steady_clock::now().time_since_epoch().count(); + return std::filesystem::temp_directory_path() / + ("fesa-eigen-direct-" + std::to_string(ticks) + "-" + + std::to_string(serial.fetch_add(1U))); +} + +struct Mode { + std::uint64_t mode_id; + double eigenvalue; + std::uint8_t classification; + std::uint64_t cluster_id; +}; +void Str(hid_t o, const char* n, const std::string& v) { + hid_t t = H5Tcopy(H5T_C_S1); + H5Tset_size(t, H5T_VARIABLE); + H5Tset_cset(t, H5T_CSET_UTF8); + hid_t s = H5Screate(H5S_SCALAR), + a = H5Acreate2(o, n, t, s, H5P_DEFAULT, H5P_DEFAULT); + const char* p = v.c_str(); + H5Awrite(a, t, &p); + H5Aclose(a); + H5Sclose(s); + H5Tclose(t); +} +void U(hid_t o, const char* n, std::uint64_t v) { + hid_t s = H5Screate(H5S_SCALAR), + a = H5Acreate2(o, n, H5T_STD_U64LE, s, H5P_DEFAULT, H5P_DEFAULT); + H5Awrite(a, H5T_NATIVE_UINT64, &v); + H5Aclose(a); + H5Sclose(s); +} +TEST(EigenReferenceComparison, + DirectSchemaFixtureAcceptsRepeatedReorderedModes) { + const auto d = UniqueFixtureDirectory(); + std::error_code e; + std::filesystem::create_directories(d, e); + const auto inp = d / "x.inp", txt = d / "x.txt", h5 = d / "x.h5"; + std::ofstream(inp) << "*Part,name=P\n*Node\n1,0,0,0\n2,1,0,0\n3,1,1,0\n4,0,1," + "0\n*Element,type=S4\n1,1,2,3,4\n*Elset,elset=E\n1\n*" + "Shell Section,elset=E,material=M\n1.\n*End " + "Part\n*Assembly,name=A\n*Instance,name=I,part=P\n*End " + "Instance\n*Nset,nset=R,instance=I\n1\n*End " + "Assembly\n*Material,name=M\n*Density\n1.\n*Elastic\n1." + ",0.3\n*Boundary\nR,1,6\n*Step,name=Step-2,nlgeom=NO\n*" + "Frequency\n2\n*End Step\n"; + std::ofstream(txt) + << "Frame: Mode 2: Value = 100 Freq = 1e999999 (cycles/time)\nFrame: " + "Mode 1: Value = 100 Freq = 1 (cycles/time)\n"; + auto p = fesa::AbaqusInputReader{}.Read(inp); + ASSERT_TRUE(p.HasValue()); + hid_t f = H5Fcreate(h5.string().c_str(), H5F_ACC_TRUNC, H5P_DEFAULT, + H5P_DEFAULT), + m = H5Gcreate2(f, "/metadata", H5P_DEFAULT, H5P_DEFAULT, H5P_DEFAULT); + U(m, "schema_version", 0); + U(m, "eigen_schema_version", 1); + Str(m, "feature_id", "mitc4-shell-feast-eigen2"); + Str(m, "analysis_procedure", "linear-frequency"); + Str(m, "mass_variant", "derived-lumped"); + Str(m, "normalization", "mass"); + Str(m, "eigensolver_backend", "MKL FEAST"); + Str(m, "source_input_identity", + "path=" + p.Value().source_path.generic_string() + + ";content_identity=" + p.Value().source_content_identity); + H5Gclose(m); + hid_t ss = H5Gcreate2(f, "/steps", H5P_DEFAULT, H5P_DEFAULT, H5P_DEFAULT), + s = H5Gcreate2(ss, "Step-1", H5P_DEFAULT, H5P_DEFAULT, H5P_DEFAULT); + Str(s, "source_step_name", "Step-2"); + U(s, "requested_mode_count", 2); + U(s, "returned_mode_count", 2); + hid_t g = H5Gcreate2(s, "eigen", H5P_DEFAULT, H5P_DEFAULT, H5P_DEFAULT); + hsize_t n = 2; + hid_t sp = H5Screate_simple(1, &n, nullptr), + t = H5Tcreate(H5T_COMPOUND, sizeof(Mode)); + H5Tinsert(t, "mode_id", HOFFSET(Mode, mode_id), H5T_STD_U64LE); + H5Tinsert(t, "eigenvalue", HOFFSET(Mode, eigenvalue), H5T_IEEE_F64LE); + H5Tinsert(t, "classification", HOFFSET(Mode, classification), H5T_STD_U8LE); + H5Tinsert(t, "cluster_id", HOFFSET(Mode, cluster_id), H5T_STD_U64LE); + hid_t ds = + H5Dcreate2(g, "modes", t, sp, H5P_DEFAULT, H5P_DEFAULT, H5P_DEFAULT); + Mode v[] = {{1, 100, 1, 1}, {2, 100, 1, 1}}; + H5Dwrite(ds, t, H5S_ALL, H5S_ALL, H5P_DEFAULT, v); + U(ds, "eigen_schema_version", 1); + Str(ds, "source_step_name", "Step-2"); + Str(ds, "domain", "mode"); + Str(ds, "quantity", "eigenvalue"); + Str(ds, "component_names", "Value"); + Str(ds, "component_unit_dimensions", "time^-2"); + Str(ds, "coordinate_system", "scalar"); + Str(ds, "location", "modal-frame"); + H5Dclose(ds); + H5Tclose(t); + H5Sclose(sp); + H5Gclose(g); + H5Gclose(s); + H5Gclose(ss); + H5Fclose(f); + auto reference_case = fesa::test::EigenReferenceCase{"x", inp, txt, h5}; + auto ok = fesa::test::EigenReferenceComparison::Compare(reference_case); + ASSERT_TRUE(ok.HasValue()); + EXPECT_EQ(ok.Value().families.size(), 2U); + struct TextMutation { + std::string text; + std::string expected_code; + }; + const std::vector invalid_texts{ + {"Frame: Mode 1: Value = 100 Freq = 1 (cycles/time)\n", + "eigen-mode-identity-mismatch"}, + {"Frame: Mode 1: Value = 100 Freq = 1 (cycles/time)\n" + "Frame: Mode 1: Value = 100 Freq = 1 (cycles/time)\n", + "eigen-mode-identity-mismatch"}, + {"Frame: Mode 1: Value = nan Freq = 1 (cycles/time)\n" + "Frame: Mode 2: Value = 100 Freq = 1 (cycles/time)\n", + "invalid-eigen-reference-text"}, + {"Frame: Mode 184467440737095516160: Value = 100 Freq = 1 (cycles/time)\n" + "Frame: Mode 2: Value = 100 Freq = 1 (cycles/time)\n", + "eigen-mode-identity-mismatch"}, + {"Frame: Mode -1: Value = 100 Freq = 1 (cycles/time)\n" + "Frame: Mode 2: Value = 100 Freq = 1 (cycles/time)\n", + "invalid-eigen-reference-text"}, + {"Frame: Mode 3: Value = 100 Freq = 1 (cycles/time)\n" + "Frame: Mode 2: Value = 100 Freq = 1 (cycles/time)\n", + "eigen-mode-identity-mismatch"}}; + for (const TextMutation& invalid_text : invalid_texts) { + std::ofstream(txt, std::ios::trunc) << invalid_text.text; + const auto comparison = + fesa::test::EigenReferenceComparison::Compare(reference_case); + ASSERT_FALSE(comparison.HasValue()); + ASSERT_EQ(comparison.GetStatus().Diagnostics().size(), 1U); + EXPECT_EQ(comparison.GetStatus().Diagnostics()[0U].code, + invalid_text.expected_code); + } + std::ofstream(txt, std::ios::trunc) + << "Frame: Mode 1: Value = 100 Freq = 1 (cycles/time)\nFrame: Mode 2: " + "Value = 100 Freq = 1 (cycles/time)\n"; + f = H5Fopen(h5.string().c_str(), H5F_ACC_RDWR, H5P_DEFAULT); + m = H5Gopen2(f, "/metadata", H5P_DEFAULT); + H5Adelete(m, "source_input_identity"); + Str(m, "source_input_identity", "wrong"); + H5Gclose(m); + H5Fclose(f); + EXPECT_FALSE( + fesa::test::EigenReferenceComparison::Compare(reference_case).HasValue()); + std::filesystem::remove_all(d, e); +} +} // namespace diff --git a/tests/reference/nonlinear_static_reference_cases_test.cpp b/tests/reference/nonlinear_static_reference_cases_test.cpp index 6140549..0bf9c0d 100644 --- a/tests/reference/nonlinear_static_reference_cases_test.cpp +++ b/tests/reference/nonlinear_static_reference_cases_test.cpp @@ -1,5 +1,3 @@ -#include "nonlinear_static_reference_comparison.h" - #include #include @@ -13,6 +11,8 @@ #include #include +#include "nonlinear_static_reference_comparison.h" + #ifndef FESA_TEST_SOURCE_DIR #error FESA_TEST_SOURCE_DIR must identify the repository root. #endif @@ -34,13 +34,10 @@ std::string ReadBytes(const std::filesystem::path& path) { fesa::test::NonlinearReferenceCase MakeCase(const std::string& case_id) { const std::filesystem::path root{FESA_TEST_SOURCE_DIR}; - const bool fine = case_id == "shellnl-meshfine-s4-final"; - const bool nonlinear = case_id == "shellnl-s4-final" || fine; + const bool nonlinear = case_id == "shellnl-s4-final"; const std::filesystem::path directory = - root / "reference" / (fine ? "shellNL-meshfine" - : (nonlinear ? "shellNL" : "shell")); - const std::string stem = fine ? "shellNL-meshfine" - : (nonlinear ? "shellNL" : "shell"); + root / "reference" / (nonlinear ? "shellNL" : "shell"); + const std::string stem = nonlinear ? "shellNL" : "shell"; return {case_id, directory / (stem + ".inp"), directory / (stem + " displacements.csv"), @@ -105,9 +102,8 @@ void ExpectInternalFamiliesPassed( << family.family_identity << " rows=" << family.row_count << " numerator=" << family.numerator << " scale=" << family.scale << " metric=" - << (family.normalized_metric.has_value() - ? *family.normalized_metric - : -1.0) + << (family.normalized_metric.has_value() ? *family.normalized_metric + : -1.0) << " diagnostic=" << family.diagnostic_code; } } @@ -159,14 +155,14 @@ TEST(NonlinearStaticReference, EXPECT_EQ(generated.Value().copied_input_path, copied.Value()); EXPECT_TRUE(std::filesystem::is_regular_file( generated.Value().generated_results_hdf5)); - EXPECT_TRUE(std::filesystem::is_regular_file( - generated.Value().generated_log_path)); + EXPECT_TRUE( + std::filesystem::is_regular_file(generated.Value().generated_log_path)); EXPECT_EQ(generated.Value().generated_results_hdf5.filename(), case_id == "shellnl-s4-final" ? "shellNL_results.h5" - : "shell_results.h5"); - EXPECT_EQ(generated.Value().generated_log_path.filename(), - case_id == "shellnl-s4-final" ? "shellNL_log.txt" - : "shell_log.txt"); + : "shell_results.h5"); + EXPECT_EQ( + generated.Value().generated_log_path.filename(), + case_id == "shellnl-s4-final" ? "shellNL_log.txt" : "shell_log.txt"); const auto comparison = fesa::test::CompareHdf5ToReferenceCsv( reference_case, generated.Value().generated_results_hdf5); @@ -182,13 +178,12 @@ TEST(NonlinearStaticReference, ExpectFamily(comparison.Value(), case_id == "shellnl-s4-final" ? "coarse.translation" - : "linear.translation", + : "linear.translation", 147U, fesa::test::NonlinearReferenceDisposition::kBlocking); - ExpectFamily(comparison.Value(), - case_id == "shellnl-s4-final" ? "coarse.rotation" - : "linear.rotation", - 147U, - fesa::test::NonlinearReferenceDisposition::kWarningOnly); + ExpectFamily( + comparison.Value(), + case_id == "shellnl-s4-final" ? "coarse.rotation" : "linear.rotation", + 147U, fesa::test::NonlinearReferenceDisposition::kWarningOnly); if (case_id == "shellnl-s4-final") { ExpectFamily(comparison.Value(), "coarse.local_reaction_inplane_force", 48U, fesa::test::NonlinearReferenceDisposition::kReviewOnly); @@ -219,9 +214,9 @@ TEST(NonlinearStaticReference, std::ofstream stream{invalid_hdf5, std::ios::binary | std::ios::trunc}; stream << "not an HDF5 file"; stream.close(); - EXPECT_FALSE(fesa::test::CompareHdf5ToReferenceCsv(reference_case, - invalid_hdf5) - .HasValue()); + EXPECT_FALSE( + fesa::test::CompareHdf5ToReferenceCsv(reference_case, invalid_hdf5) + .HasValue()); fesa::test::NonlinearReferenceComparisonReport report{}; report.blocking_passed = true; @@ -230,17 +225,29 @@ TEST(NonlinearStaticReference, {"UR1", "UR2", "UR3"}, fesa::test::NonlinearReferenceDisposition::kWarningOnly, 147U, - {"nonlinear.rotation", {"UR1", "UR2", "UR3"}, false, 1.0, 0.01, - 0U, 0.02, 0.02, 0U, false, false, "relative-rms-exceeded", {}, 147U}}); - EXPECT_EQ(fesa::test::ClassifyReferenceComparison(report), - fesa::test::NonlinearReferenceClassification::kWarningOnlyDifferences); + {"nonlinear.rotation", + {"UR1", "UR2", "UR3"}, + false, + 1.0, + 0.01, + 0U, + 0.02, + 0.02, + 0U, + false, + false, + "relative-rms-exceeded", + {}, + 147U}}); + EXPECT_EQ( + fesa::test::ClassifyReferenceComparison(report), + fesa::test::NonlinearReferenceClassification::kWarningOnlyDifferences); } // NSNR-REF-020. -TEST(NonlinearStaticReference, AuditsFinePrimaryAndCoarseCompanionArtifacts) { - const std::array case_ids{ - "shellnl-meshfine-s4-final", "shellnl-s4-final", - "shell-s4-linear-limit"}; +TEST(NonlinearStaticReference, AuditsRetainedStaticArtifacts) { + const std::array case_ids{"shellnl-s4-final", + "shell-s4-linear-limit"}; for (const auto& case_id : case_ids) { const auto reference_case = MakeCase(case_id); const auto before = fesa::test::ArtifactCheck(reference_case); @@ -281,16 +288,14 @@ TEST(NonlinearStaticReference, fesa::test::NonlinearReferenceDisposition::kReviewOnly); ExpectFamily(comparison.Value(), "coarse.local_reaction_transverse_force", 24U, fesa::test::NonlinearReferenceDisposition::kReviewOnly); - ExpectFamily(comparison.Value(), "coarse.local_reaction_physical_moment", - 48U, fesa::test::NonlinearReferenceDisposition::kReviewOnly); + ExpectFamily(comparison.Value(), "coarse.local_reaction_physical_moment", 48U, + fesa::test::NonlinearReferenceDisposition::kReviewOnly); EXPECT_TRUE(comparison.Value().external_blocking_passed); } // NSNR-REF-022. -TEST(NonlinearStaticReference, - BlocksOnGlobalBalanceAndAcceptedFreeResidual) { - for (const std::string case_id : - {"shellnl-meshfine-s4-final", "shellnl-s4-final"}) { +TEST(NonlinearStaticReference, BlocksOnGlobalBalanceAndAcceptedFreeResidual) { + for (const std::string case_id : {"shellnl-s4-final"}) { const auto reference_case = MakeCase(case_id); const auto copied = fesa::test::CopyInputToExecutionDirectory(reference_case); @@ -302,9 +307,7 @@ TEST(NonlinearStaticReference, reference_case, generated.Value().generated_results_hdf5); ASSERT_TRUE(comparison.HasValue()) << case_id; - const std::string prefix = case_id == "shellnl-meshfine-s4-final" - ? "fine." - : "coarse."; + const std::string prefix = "coarse."; const std::array identities{ prefix + "global_force_balance", prefix + "global_moment_balance", prefix + "accepted_free_force_residual", @@ -313,7 +316,8 @@ TEST(NonlinearStaticReference, const auto found = std::find_if( comparison.Value().internal_equilibrium.begin(), comparison.Value().internal_equilibrium.end(), - [&identity](const fesa::test::NonlinearInternalEquilibriumReport& row) { + [&identity]( + const fesa::test::NonlinearInternalEquilibriumReport& row) { return row.family_identity == identity; }); ASSERT_NE(found, comparison.Value().internal_equilibrium.end()) @@ -321,9 +325,8 @@ TEST(NonlinearStaticReference, EXPECT_TRUE(found->passed) << identity << " numerator=" << found->numerator << " scale=" << found->scale << " metric=" - << (found->normalized_metric.has_value() - ? *found->normalized_metric - : -1.0) + << (found->normalized_metric.has_value() ? *found->normalized_metric + : -1.0) << " diagnostic=" << found->diagnostic_code; } EXPECT_TRUE(comparison.Value().internal_blocking_passed); @@ -334,19 +337,26 @@ TEST(NonlinearStaticReference, } // NSNR-REF-023. -TEST(NonlinearStaticReference, - RejectsMissingExtraDuplicateAndNonfiniteRowsBeforeToleranceAndRecordsReviewOnlyFindings) { +TEST( + NonlinearStaticReference, + RejectsMissingExtraDuplicateAndNonfiniteRowsBeforeToleranceAndRecordsReviewOnlyFindings) { fesa::test::NonlinearReferenceComparisonReport report{}; report.blocking_passed = true; report.external_blocking_passed = true; report.internal_blocking_passed = true; report.precheck_findings = { - {"blocking", fesa::test::NonlinearReferenceDisposition::kBlocking, - "missing-row", {}}, - {"warning", fesa::test::NonlinearReferenceDisposition::kWarningOnly, - "duplicate-row", {}}, - {"review", fesa::test::NonlinearReferenceDisposition::kReviewOnly, - "nonfinite-row", {}}}; + {"blocking", + fesa::test::NonlinearReferenceDisposition::kBlocking, + "missing-row", + {}}, + {"warning", + fesa::test::NonlinearReferenceDisposition::kWarningOnly, + "duplicate-row", + {}}, + {"review", + fesa::test::NonlinearReferenceDisposition::kReviewOnly, + "nonfinite-row", + {}}}; EXPECT_EQ(fesa::test::ClassifyReferenceComparison(report), fesa::test::NonlinearReferenceClassification::kFailed); @@ -359,22 +369,22 @@ TEST(NonlinearStaticReference, GlobalForceAndMomentBalanceUseApprovedInclusiveOneEighthGate) { for (const std::string& identity : {"fine.global_force_balance", "fine.global_moment_balance"}) { - const auto below = fesa::test::EvaluateGlobalBalance( - identity, 5.0e-9, 1.0, 0.0); + const auto below = + fesa::test::EvaluateGlobalBalance(identity, 5.0e-9, 1.0, 0.0); EXPECT_TRUE(below.passed) << identity; ASSERT_TRUE(below.normalized_metric.has_value()) << identity; EXPECT_EQ(*below.normalized_metric, 5.0e-9) << identity; - const auto boundary = fesa::test::EvaluateGlobalBalance( - identity, 1.0e-8, 1.0, 0.0); + const auto boundary = + fesa::test::EvaluateGlobalBalance(identity, 1.0e-8, 1.0, 0.0); EXPECT_TRUE(boundary.passed) << identity; ASSERT_TRUE(boundary.normalized_metric.has_value()) << identity; EXPECT_EQ(*boundary.normalized_metric, 1.0e-8) << identity; const auto above = fesa::test::EvaluateGlobalBalance( - identity, std::nextafter(1.0e-8, - std::numeric_limits::infinity()), - 1.0, 0.0); + identity, + std::nextafter(1.0e-8, std::numeric_limits::infinity()), 1.0, + 0.0); EXPECT_FALSE(above.passed) << identity; ASSERT_TRUE(above.normalized_metric.has_value()) << identity; EXPECT_GT(*above.normalized_metric, 1.0e-8) << identity; diff --git a/tests/reference/shell_surface_pressure_reference_test.cpp b/tests/reference/shell_surface_pressure_reference_test.cpp index c502ffc..d54a90f 100644 --- a/tests/reference/shell_surface_pressure_reference_test.cpp +++ b/tests/reference/shell_surface_pressure_reference_test.cpp @@ -1,198 +1,15 @@ #include -#include -#include -#include -#include -#include -#include -#include - #include "nonlinear_static_reference_comparison.h" -#ifndef FESA_TEST_SOURCE_DIR -#error FESA_TEST_SOURCE_DIR must identify the repository root. -#endif - -#ifndef FESA_TEST_BINARY_DIR -#error FESA_TEST_BINARY_DIR must identify the CMake binary root. -#endif - namespace { -fesa::test::NonlinearReferenceCase MakePressureCase() { - const std::filesystem::path root{FESA_TEST_SOURCE_DIR}; - const std::filesystem::path directory = - root / "reference" / "shellNL-meshfine-pressure"; - return {"shellnl-meshfine-pressure-s4-follower", - directory / "shellNL-meshfine-pressure.inp", - directory / "shellNL-meshfine-pressure displacements.csv", - directory / "shellNL-meshfine-pressure reactions.csv", - directory / "shellNL-meshfine-pressure stresses.csv", - std::filesystem::path{FESA_TEST_BINARY_DIR} / "reference" / - "shell-surface-pressure-load", - fesa::test::NonlinearReferenceArtifactDisposition::kRequired, - fesa::test::NonlinearReferenceArtifactDisposition::kRequired, - fesa::test::NonlinearReferenceArtifactDisposition::kReviewOnly, - fesa::test::NonlinearReferenceArtifactDisposition::kExcluded}; -} - -std::optional FindCachedResults( - const fesa::test::NonlinearReferenceCase& reference_case) { - const std::filesystem::path solver_library = - std::filesystem::path{FESA_TEST_BINARY_DIR} / "src" / "fesa" / "Debug" / - "fesa_solver.lib"; - std::error_code error; - const auto solver_time = - std::filesystem::last_write_time(solver_library, error); - if (error || - !std::filesystem::is_directory(reference_case.execution_directory)) { - return std::nullopt; - } - std::vector candidates; - for (const auto& entry : std::filesystem::recursive_directory_iterator( - reference_case.execution_directory, error)) { - if (error) { - return std::nullopt; - } - if (entry.path().filename() == "shellNL-meshfine-pressure_results.h5" && - std::filesystem::is_regular_file(entry.path()) && - std::filesystem::last_write_time(entry.path(), error) >= solver_time && - !error) { - candidates.push_back(entry.path()); - } - } - std::sort(candidates.begin(), candidates.end()); - return candidates.empty() - ? std::nullopt - : std::optional{candidates.back()}; -} - -const fesa::test::NonlinearReferenceComparisonReport& RunPressureComparison() { - static const auto report = [] { - const auto reference_case = MakePressureCase(); - const auto cached = FindCachedResults(reference_case); - if (cached.has_value()) { - const auto comparison = - fesa::test::CompareHdf5ToReferenceCsv(reference_case, *cached); - if (comparison.HasValue()) { - return comparison.Value(); - } - } - const auto copied = - fesa::test::CopyInputToExecutionDirectory(reference_case); - if (!copied.HasValue()) { - throw std::runtime_error{"Unable to copy pressure reference input."}; - } - const auto generated = fesa::test::RunFesaApplicationOnCopiedInput( - reference_case, copied.Value()); - if (!generated.HasValue()) { - throw std::runtime_error{"Unable to run pressure reference input."}; - } - const auto comparison = fesa::test::CompareHdf5ToReferenceCsv( - reference_case, generated.Value().generated_results_hdf5); - if (!comparison.HasValue()) { - throw std::runtime_error{"Unable to compare pressure reference output."}; - } - return comparison.Value(); - }(); - return report; -} - -const fesa::test::NonlinearReferenceFamilyReport& FindFamily( - const fesa::test::NonlinearReferenceComparisonReport& report, - const std::string& identity) { - const auto found = std::find_if( - report.families.begin(), report.families.end(), - [&identity](const fesa::test::NonlinearReferenceFamilyReport& family) { - return family.family_identity == identity; - }); - if (found == report.families.end()) { - throw std::runtime_error{"Required comparison family is absent."}; - } - return *found; -} - fesa::test::ReferenceToleranceFamilyDecision MakeTolerance(const bool passed) { fesa::test::ReferenceToleranceFamilyDecision result{}; result.passed = passed; return result; } -} // namespace - -TEST(ShellSurfacePressureReference, - AuditsExactRequiredArtifactsAndIgnoresOptionalReviewArtifacts) { - const auto artifacts = fesa::test::ArtifactCheck(MakePressureCase()); - - ASSERT_TRUE(artifacts.HasValue()); - EXPECT_EQ(MakePressureCase().reaction_artifact_disposition, - fesa::test::NonlinearReferenceArtifactDisposition::kReviewOnly); - EXPECT_EQ(MakePressureCase().stress_artifact_disposition, - fesa::test::NonlinearReferenceArtifactDisposition::kExcluded); - ASSERT_EQ(artifacts.Value().size(), 2U); - EXPECT_EQ(artifacts.Value()[0U].byte_size, 56665U); - EXPECT_EQ(artifacts.Value()[0U].sha256, - "53718CCC9B41C23FCC1B509A51D66F1347E87ACA43A74F1D6CEC4D2085F4B90E"); - EXPECT_EQ(artifacts.Value()[1U].byte_size, 111182U); - EXPECT_EQ(artifacts.Value()[1U].sha256, - "2FE53F98EAC9F04779100721C3EDF8049EB1DBFE733EBCBD819AEEED03B81198"); -} - -TEST(ShellSurfacePressureReference, - SelectsUniqueSchemaOneFactorOneAcceptedFrame) { - const auto& report = RunPressureComparison(); - - EXPECT_TRUE(report.blocking_passed); - EXPECT_EQ(report.case_id, "shellnl-meshfine-pressure-s4-follower"); -} - -TEST(ShellSurfacePressureReference, - Projects729WideRowsTo2187LogicalRowsPerFamily) { - const auto& report = RunPressureComparison(); - - EXPECT_EQ(FindFamily(report, "pressure_nodal_translation_blocking").row_count, - 2187U); - EXPECT_EQ(FindFamily(report, "pressure_nodal_rotation_warning").row_count, - 2187U); -} - -TEST(ShellSurfacePressureReference, - MatchesByInstanceNodeLabelAndComponentNotRowOrder) { - const auto& report = RunPressureComparison(); - - const auto& translation = - FindFamily(report, "pressure_nodal_translation_blocking"); - EXPECT_EQ(translation.components, - (std::vector{"U1", "U2", "U3"})); - EXPECT_EQ(translation.tolerance.rows.size(), 2187U); -} - -TEST( - ShellSurfacePressureReference, - RejectsSchemaIdentityCardinalityMissingExtraDuplicateAndNonfiniteBeforeTolerance) { - const auto& report = RunPressureComparison(); - const std::filesystem::path invalid_hdf5 = - report.generated_results_hdf5.parent_path() / "invalid-pressure.h5"; - { - std::ofstream stream{invalid_hdf5, std::ios::binary | std::ios::trunc}; - stream << "not an HDF5 file"; - } - EXPECT_FALSE( - fesa::test::CompareHdf5ToReferenceCsv(MakePressureCase(), invalid_hdf5) - .HasValue()); - - fesa::test::NonlinearReferenceComparisonReport precheck_report{}; - precheck_report.blocking_passed = true; - precheck_report.precheck_findings = { - {"pressure_nodal_translation_blocking", - fesa::test::NonlinearReferenceDisposition::kBlocking, - "missing-extra-duplicate-or-nonfinite", - {}}}; - EXPECT_EQ(fesa::test::ClassifyReferenceComparison(precheck_report), - fesa::test::NonlinearReferenceClassification::kFailed); -} - TEST(ShellSurfacePressureReference, ClassifiesTranslationBlockingAndRotationWarningOnly) { fesa::test::NonlinearReferenceComparisonReport report{}; @@ -207,55 +24,9 @@ TEST(ShellSurfacePressureReference, fesa::test::NonlinearReferenceDisposition::kWarningOnly, 2187U, MakeTolerance(false)}}; - EXPECT_EQ( fesa::test::ClassifyReferenceComparison(report), fesa::test::NonlinearReferenceClassification::kWarningOnlyDifferences); } -TEST(ShellSurfacePressureReference, - ExcludesReactionAndStressFamiliesFromVerdict) { - const auto& report = RunPressureComparison(); - - EXPECT_EQ(report.families.size(), 2U); - EXPECT_EQ( - FindFamily(report, "pressure_nodal_translation_blocking").disposition, - fesa::test::NonlinearReferenceDisposition::kBlocking); - EXPECT_EQ(FindFamily(report, "pressure_nodal_rotation_warning").disposition, - fesa::test::NonlinearReferenceDisposition::kWarningOnly); -} - -TEST(ShellSurfacePressureReference, - UsesUnchangedCommonScaleNearZeroRelativeAndRmsPolicy) { - const auto& translation = FindFamily(RunPressureComparison(), - "pressure_nodal_translation_blocking"); - - EXPECT_EQ(translation.tolerance.near_zero_band, - fesa::test::ReferenceTolerancePolicy::kNearZeroRatio * - translation.tolerance.reference_scale); - EXPECT_LE(translation.tolerance.relative_rms, - fesa::test::ReferenceTolerancePolicy::kRelativeRmsTolerance); - EXPECT_TRUE(translation.tolerance.rms_passed); -} - -TEST(ShellSurfacePressureReference, WritesDeterministicComparisonReport) { - const auto& first = RunPressureComparison(); - const auto comparison = fesa::test::CompareHdf5ToReferenceCsv( - MakePressureCase(), first.generated_results_hdf5); - - ASSERT_TRUE(comparison.HasValue()); - ASSERT_EQ(first.families.size(), comparison.Value().families.size()); - for (std::size_t index = 0U; index < first.families.size(); ++index) { - const auto& expected = first.families[index]; - const auto& actual = comparison.Value().families[index]; - EXPECT_EQ(actual.family_identity, expected.family_identity); - EXPECT_EQ(actual.components, expected.components); - EXPECT_EQ(actual.disposition, expected.disposition); - EXPECT_EQ(actual.row_count, expected.row_count); - EXPECT_DOUBLE_EQ(actual.tolerance.reference_scale, - expected.tolerance.reference_scale); - EXPECT_DOUBLE_EQ(actual.tolerance.relative_rms, - expected.tolerance.relative_rms); - EXPECT_EQ(actual.tolerance.worst_row, expected.tolerance.worst_row); - } -} +} // namespace diff --git a/tests/unit/analysis/eigen_reduction_test.cpp b/tests/unit/analysis/eigen_reduction_test.cpp new file mode 100644 index 0000000..bc31dd3 --- /dev/null +++ b/tests/unit/analysis/eigen_reduction_test.cpp @@ -0,0 +1,184 @@ +#include "analysis/eigen_reduction.h" + +#include + +#include +#include +#include +#include +#include +#include + +#include "fesa/fem/dof_manager.h" +#include "fesa/solvers/eigen/mkl_feast_eigen_solver.h" + +namespace { + +fesa::SparseMatrix Sparse(const fesa::Matrix& dense) { + fesa::SparsePattern pattern; + std::vector entries; + pattern.row_offsets.push_back(0U); + for (std::size_t i = 0; i < dense.Rows(); ++i) { + for (std::size_t j = 0; j < dense.Columns(); ++j) { + pattern.column_indices.push_back(j); + entries.push_back({i, j, dense(i, j), 0U, i * dense.Columns() + j}); + } + pattern.row_offsets.push_back(pattern.column_indices.size()); + } + auto result = fesa::SparseMatrix::FromCoo(dense.Rows(), dense.Columns(), + std::move(entries), pattern); + EXPECT_TRUE(result.HasValue()); + return std::move(result.Value()); +} + +fesa::Matrix NodalMass(const std::array& d) { + fesa::Matrix mass(6U, 6U); + for (std::size_t i = 0; i < 3U; ++i) { + mass(i, i) = 2.0; + for (std::size_t j = 0; j < 3U; ++j) { + mass(i + 3U, j + 3U) = (static_cast(i == j) - d[i] * d[j]) / 6.0; + } + } + return mass; +} + +fesa::ShellNodeInitialFrame TiltedFrame() { + return {0U, {0.6, 0.0, 0.8}, {0.0, 1.0, 0.0}, {-0.8, 0.0, 0.6}}; +} + +} // namespace + +TEST(EigenReduction, KeepsPositivePartialGlobalRotationAndOriginalPencil) { + const auto frame = TiltedFrame(); + fesa::Matrix stiffness(6U, 6U); + stiffness(5U, 5U) = 10369.0 / 7500.0; + std::vector mask(6U, 1U); + mask[5U] = 0U; + const auto result = fesa::eigen_internal::BuildEigenReduction( + Sparse(stiffness), {NodalMass(frame.director)}, {frame}, mask, + {19.0 / 4800.0}, 4.0); + ASSERT_TRUE(result.HasValue()); + EXPECT_EQ(result.Value().null_count, 0U); + EXPECT_EQ(result.Value().whitened_operator.Rows(), 1U); + EXPECT_NEAR(result.Value().whitened_operator.Values()[0], 10369.0 / 450.0, + 1e-12); + fesa::Matrix x(1U, 1U, 1.0); + const auto y = fesa::eigen_internal::RestoreScaledMode(result.Value(), x, 0U); + for (std::size_t i = 0; i < 5U; ++i) EXPECT_DOUBLE_EQ(y[i], 0.0); + EXPECT_NEAR(y[5U] * y[5U] * (3.0 / 50.0), 1.0, 1e-12); +} + +TEST(EigenReduction, RemovesOnlyActualFreeDirectorNull) { + const fesa::ShellNodeInitialFrame frame{0U, {0, 0, 1}, {1, 0, 0}, {0, 1, 0}}; + auto mass = NodalMass(frame.director); + fesa::Matrix stiffness(6U, 6U); + for (std::size_t i = 0; i < 5U; ++i) stiffness(i, i) = mass(i, i) * 7.0; + stiffness(5U, 5U) = 0.25; + const auto result = fesa::eigen_internal::BuildEigenReduction( + Sparse(stiffness), {mass}, {frame}, std::vector(6U, 0U), + {0.25}, 2.0); + ASSERT_TRUE(result.HasValue()); + EXPECT_EQ(result.Value().free_count, 6U); + EXPECT_EQ(result.Value().null_count, 1U); + EXPECT_EQ(result.Value().whitened_operator.Rows(), 5U); + fesa::Vector ones(5U, 1.0); + const auto action = result.Value().whitened_operator.Multiply(ones); + for (std::size_t i = 0; i < 5U; ++i) EXPECT_NEAR(action[i], 7.0, 1e-12); + EXPECT_LE(result.Value().eta_kz, 1e-12); + stiffness(0U, 5U) = stiffness(5U, 0U) = 0.1; + EXPECT_FALSE(fesa::eigen_internal::BuildEigenReduction( + Sparse(stiffness), {mass}, {frame}, + std::vector(6U, 0U), {0.25}, 2.0) + .HasValue()); +} + +TEST(EigenReduction, RejectsPositiveButUnresolvableMassWithoutDeletingDofs) { + const double p = 1e-8; + const std::array d{2 * p / (1 + p * p), 0, + (1 - p * p) / (1 + p * p)}; + const fesa::ShellNodeInitialFrame frame{0U, d, {0, 1, 0}, {-d[2], 0, d[0]}}; + fesa::Matrix stiffness(6U, 6U); + for (std::size_t i = 0; i < 6U; ++i) stiffness(i, i) = 1.0; + std::vector mask(6U, 0U); + mask[3U] = 1U; + const auto result = fesa::eigen_internal::BuildEigenReduction( + Sparse(stiffness), {NodalMass(d)}, {frame}, mask, {0.25}, 1.0); + ASSERT_FALSE(result.HasValue()); + EXPECT_EQ(result.GetStatus().Diagnostics()[0].code, + "eigen-mass-conditioning"); +} + +TEST(EigenReduction, WhitensOffDiagonalMassAndRestoresOriginalPencil) { + const fesa::ShellNodeInitialFrame frame{0U, {0, 0, 1}, {1, 0, 0}, {0, 1, 0}}; + auto mass = NodalMass(frame.director); + mass(0, 1) = mass(1, 0) = 1.0; + fesa::Matrix stiffness(6U, 6U); + stiffness(0, 0) = stiffness(1, 1) = 6.0; + stiffness(0, 1) = stiffness(1, 0) = 4.0; + std::vector mask{0U, 0U, 1U, 1U, 1U, 1U}; + const auto reduction = fesa::eigen_internal::BuildEigenReduction( + Sparse(stiffness), {mass}, {frame}, mask, {0.25}, 3.0); + ASSERT_TRUE(reduction.HasValue()); + const auto eigen = fesa::MklFeastEigenSolver{}.Solve( + reduction.Value().whitened_operator, 2U, 1.0); + ASSERT_TRUE(eigen.HasValue()); + ASSERT_EQ(eigen.Value().eigenvalues.size(), 2U); + EXPECT_NEAR(eigen.Value().eigenvalues[0], 2.0, 1e-12); + EXPECT_NEAR(eigen.Value().eigenvalues[1], 10.0 / 3.0, 1e-12); + for (std::size_t mode = 0; mode < 2U; ++mode) { + auto y = fesa::eigen_internal::RestoreScaledMode( + reduction.Value(), eigen.Value().eigenvectors, mode); + fesa::Vector q = y; + for (std::size_t i = 0; i < 3U; ++i) q[i] *= 3.0; + const auto mq = mass.Multiply(q); + EXPECT_NEAR(q.Dot(mq), 1.0, 1e-12); + auto residual = stiffness.Multiply(q); + residual.Axpy(-eigen.Value().eigenvalues[mode], mq); + EXPECT_LE(residual.Norm(), 1e-12); + } +} + +TEST(EigenReduction, PreservesSmallResolvablePositivePartialRotationMass) { + const double p = 1.0 / 2000.0; + const std::array d{2 * p / (1 + p * p), 0, + (1 - p * p) / (1 + p * p)}; + const fesa::ShellNodeInitialFrame frame{0U, d, {0, 1, 0}, {-d[2], 0, d[0]}}; + fesa::Matrix stiffness(6U, 6U); + for (std::size_t i = 0; i < 6U; ++i) stiffness(i, i) = 1.0; + std::vector mask(6U, 0U); + mask[3U] = 1U; + const auto result = fesa::eigen_internal::BuildEigenReduction( + Sparse(stiffness), {NodalMass(d)}, {frame}, mask, {0.25}, 1.0); + ASSERT_TRUE(result.HasValue()); + EXPECT_EQ(result.Value().null_count, 0U); + EXPECT_EQ(result.Value().whitened_operator.Rows(), 5U); + EXPECT_GE(result.Value().nodes[0].record.rcond_inf, 0x1p-32); +} + +TEST(EigenReduction, HandlesTwoFreeRotationsAndRejectsEmptyFiniteSpace) { + const fesa::ShellNodeInitialFrame frame{0U, {0, 0, 1}, {1, 0, 0}, {0, 1, 0}}; + auto mass = NodalMass(frame.director); + fesa::Matrix stiffness(6U, 6U); + stiffness(3U, 3U) = 1.0; + stiffness(5U, 5U) = 0.25; + std::vector mask{1U, 1U, 1U, 0U, 1U, 0U}; + auto result = fesa::eigen_internal::BuildEigenReduction( + Sparse(stiffness), {mass}, {frame}, mask, {0.25}, 1.0); + ASSERT_TRUE(result.HasValue()); + EXPECT_EQ(result.Value().null_count, 1U); + EXPECT_EQ(result.Value().whitened_operator.Rows(), 1U); + mass(5U, 5U) = 0.1; + EXPECT_FALSE(fesa::eigen_internal::BuildEigenReduction( + Sparse(stiffness), {mass}, {frame}, mask, {0.25}, 1.0) + .HasValue()); + mass(5U, 5U) = 0.0; + for (const bool drill_free : {false, true}) { + mask.assign(6U, 1U); + mask[5U] = drill_free ? 0U : 1U; + const auto empty = fesa::eigen_internal::BuildEigenReduction( + Sparse(stiffness), {mass}, {frame}, mask, {0.25}, 1.0); + ASSERT_FALSE(empty.HasValue()); + EXPECT_EQ(empty.GetStatus().Category(), fesa::FailureCategory::kModel); + EXPECT_EQ(empty.GetStatus().Diagnostics()[0].code, "empty-eigenproblem"); + } +} diff --git a/tests/unit/elements/mitc4_shell_mass_contract_test.cpp b/tests/unit/elements/mitc4_shell_mass_contract_test.cpp new file mode 100644 index 0000000..c847150 --- /dev/null +++ b/tests/unit/elements/mitc4_shell_mass_contract_test.cpp @@ -0,0 +1,119 @@ +#include + +#include +#include +#include +#include + +#include "fesa/elements/mitc4_shell.h" +#include "fesa/model/model_types.h" + +namespace { + +using Coordinates = std::array; + +fesa::Node Node(std::int64_t label, Coordinates coordinates) { + return {{"I", label, std::to_string(label)}, coordinates, {"mass.inp", 1U}}; +} + +std::array Pointers( + const std::array& nodes) { + return {&nodes[0], &nodes[1], &nodes[2], &nodes[3]}; +} + +fesa::Mitc4Shell WarpedShell() { + const std::array nodes{ + Node(1, {-1.0, -1.0, 0.1}), Node(2, {1.0, -1.0, -0.1}), + Node(3, {1.0, 1.0, 0.2}), Node(4, {-1.0, 1.0, -0.1})}; + const std::array directors{ + {{{0.0, 0.0, 1.0}}, + {{0.6, 0.0, 0.8}}, + {{2.0 / 3.0, 1.0 / 3.0, 2.0 / 3.0}}, + {{0.0, 0.6, 0.8}}}}; + const fesa::ShellSection section{"S", 0.5, 0U, {"mass.inp", 2U}}; + const fesa::LinearElasticMaterial material{"M", 12.0, 0.0, {"mass.inp", 3U}}; + const auto result = + fesa::Mitc4Shell::Create(Pointers(nodes), directors, section, material); + EXPECT_TRUE(result.HasValue()); + return result.Value(); +} + +double Quadratic(const fesa::Matrix& matrix, const fesa::Vector& vector) { + return vector.Dot(matrix.Multiply(vector)); +} + +// E2-C02: values derive independently in numerical-review-evidence.md. +TEST(Mitc4ShellMassContract, PreservesWarpedMomentsRowsAndFirstSpatialMoment) { + const auto mass = WarpedShell().Mass(3.0); + ASSERT_TRUE(mass.HasValue()); + const auto& value = mass.Value(); + EXPECT_NEAR(value.consistent_moments[0U](0U, 0U), 17609.0 / 28800.0, 1e-12); + EXPECT_NEAR(value.consistent_moments[1U](0U, 0U), 11003.0 / 1440000.0, 1e-12); + EXPECT_NEAR(value.consistent_moments[2U](0U, 0U), 88177.0 / 6912000.0, 1e-12); + double total_m = 0.0, total_h = 0.0, total_j = 0.0; + for (const auto& row : value.nodal_moments) { + total_m += row[0U]; + total_h += row[1U]; + total_j += row[2U]; + } + EXPECT_NEAR(total_m, 7743.0 / 1600.0, 1e-12); + EXPECT_NEAR(total_h, 739.0 / 12000.0, 1e-12); + EXPECT_NEAR(total_j, 517.0 / 5120.0, 1e-12); +} + +// E2-C03: scalar row-sum lumping retains uniform translation energy and is +// never below the consistent kinetic energy. +TEST(Mitc4ShellMassContract, SatisfiesUniformAndJensenEnergyContracts) { + const auto mass = WarpedShell().Mass(3.0); + ASSERT_TRUE(mass.HasValue()); + fesa::Vector translation{24U}; + fesa::Vector field{24U}; + for (std::size_t node = 0U; node < 4U; ++node) { + translation[6U * node] = 1.0; + field[6U * node] = static_cast(node + 1U); + field[6U * node + 1U] = -0.5 * static_cast(node); + field[6U * node + 3U] = 0.25; + } + EXPECT_NEAR(Quadratic(mass.Value().consistent_global24, translation), + Quadratic(mass.Value().lumped_global24, translation), 1e-12); + EXPECT_GE(Quadratic(mass.Value().lumped_global24, field) + 1e-12, + Quadratic(mass.Value().consistent_global24, field)); +} + +// E2-C04: assemble the physical velocity work over successively finer meshes. +TEST(Mitc4ShellMassContract, ReproducesAffineSquareVelocityWork) { + const std::array directors{{{{0.0, 0.0, 1.0}}, + {{0.0, 0.0, 1.0}}, + {{0.0, 0.0, 1.0}}, + {{0.0, 0.0, 1.0}}}}; + const fesa::ShellSection section{"S", 1.0, 0U, {"mass.inp", 2U}}; + const fesa::LinearElasticMaterial material{"M", 12.0, 0.0, {"mass.inp", 3U}}; + for (const int mesh : {1, 2, 4}) { + double consistent_work = 0.0, lumped_work = 0.0; + const double edge = 2.0 / mesh; + for (int row = 0; row < mesh; ++row) { + for (int column = 0; column < mesh; ++column) { + const double x = -1.0 + edge * column, y = -1.0 + edge * row; + const std::array nodes{ + Node(1, {x, y, 0.0}), Node(2, {x + edge, y, 0.0}), + Node(3, {x + edge, y + edge, 0.0}), Node(4, {x, y + edge, 0.0})}; + const auto shell = fesa::Mitc4Shell::Create(Pointers(nodes), directors, + section, material); + ASSERT_TRUE(shell.HasValue()); + const auto mass = shell.Value().Mass(1.0); + ASSERT_TRUE(mass.HasValue()); + fesa::Vector velocity{24U}; + for (std::size_t node = 0U; node < 4U; ++node) + velocity[6U * node] = nodes[node].coordinates[0U]; + consistent_work += + Quadratic(mass.Value().consistent_global24, velocity); + lumped_work += Quadratic(mass.Value().lumped_global24, velocity); + } + } + EXPECT_NEAR(consistent_work, 4.0 / 3.0, 1e-12) << mesh; + EXPECT_NEAR(lumped_work - consistent_work, 8.0 / (3.0 * mesh * mesh), 1e-12) + << mesh; + } +} + +} // namespace diff --git a/tests/unit/elements/mitc4_shell_test.cpp b/tests/unit/elements/mitc4_shell_test.cpp index 30d7ae9..d90edc2 100644 --- a/tests/unit/elements/mitc4_shell_test.cpp +++ b/tests/unit/elements/mitc4_shell_test.cpp @@ -53,6 +53,12 @@ Vector3 Cross(const Vector3& left, const Vector3& right) { left[0] * right[1] - left[1] * right[0]}; } +Vector3 RotateAboutGlobalZ(const Vector3& value) { + constexpr double kInverseSqrtTwo = 0.70710678118654752440; + return {kInverseSqrtTwo * (value[0U] - value[1U]), + kInverseSqrtTwo * (value[0U] + value[1U]), value[2U]}; +} + double Norm(const Vector3& value) { return std::sqrt(Dot(value, value)); } void ExpectVectorNear(const Vector3& actual, const Vector3& expected, @@ -636,7 +642,6 @@ TEST(Mitc4ShellKernel, EXPECT_DOUBLE_EQ(stiffness.physical_local20(0U, 0U), 0x1.d555555555554p+6); EXPECT_DOUBLE_EQ(stiffness.physical_local20(0U, 4U), 0.0); EXPECT_DOUBLE_EQ(stiffness.physical_global24(2U, 2U), 0x1.aaaaaaaaaaaadp+5); - EXPECT_DOUBLE_EQ(stiffness.drilling_stiffness, 0x1.0d6cffc5beeb5p-4); EXPECT_DOUBLE_EQ(recovery.strain_energy, 0x1.20a3d70a3d70bp+6); constexpr std::array kExpectedStrain{ 0x1.999999999999ap-4, -0x1.999999999999bp-5, 0x1.9999999999998p-3, @@ -791,8 +796,7 @@ TEST(Mitc4ShellPatch, ReproducesIndependentMembraneBendingShearAndTwistFields) { } // MITC4-KERNEL-005 -TEST(Mitc4ShellDrilling, - UsesOnlyEightPositivePhysicalRotationDiagonalsAndFixedFactor) { +TEST(Mitc4ShellDrilling, UsesAllPhysicalRotationDiagonalsAndFixedFactor) { const std::array nodes{ Node(1, {-50.0, -50.0, 0.0}), Node(2, {50.0, -50.0, 0.0}), Node(3, {50.0, 50.0, 0.0}), Node(4, {-50.0, 50.0, 0.0})}; @@ -803,27 +807,222 @@ TEST(Mitc4ShellDrilling, ASSERT_TRUE(stiffness_candidate.HasValue()); const auto& stiffness = stiffness_candidate.Value(); - double expected_reference = (std::numeric_limits::max)(); - double all_diagonal_minimum = (std::numeric_limits::max)(); + double expected_sum = 0.0; for (std::size_t node_index = 0U; node_index < 4U; ++node_index) { for (std::size_t rotation = 3U; rotation < 5U; ++rotation) { const std::size_t index = 5U * node_index + rotation; const double diagonal = stiffness.physical_local20(index, index); ASSERT_TRUE(std::isfinite(diagonal)); - ASSERT_GT(diagonal, 0.0); - expected_reference = (std::min)(expected_reference, diagonal); - } - for (std::size_t component = 0U; component < 5U; ++component) { - const std::size_t index = 5U * node_index + component; - const double diagonal = stiffness.physical_local20(index, index); - if (std::isfinite(diagonal) && diagonal > 0.0) { - all_diagonal_minimum = (std::min)(all_diagonal_minimum, diagonal); - } + ASSERT_GE(diagonal, 0.0); + expected_sum += diagonal; } } - EXPECT_DOUBLE_EQ(stiffness.drilling_stiffness, 1.0e-3 * expected_reference); - EXPECT_LT(all_diagonal_minimum, expected_reference); - EXPECT_NE(stiffness.drilling_stiffness, 1.0e-3 * all_diagonal_minimum); + EXPECT_DOUBLE_EQ(stiffness.drilling_stiffness, 1.0e-3 * expected_sum / 8.0); +} + +TEST(Mitc4ShellMass, IntegratesFlatConsistentAndScalarRowSumLumpedMass) { + const auto nodes = PlanarNodes(); + const auto shell_candidate = fesa::Mitc4Shell::Create( + NodePointers(nodes), Directors(), Section(), Material()); + ASSERT_TRUE(shell_candidate.HasValue()); + + const auto mass_candidate = shell_candidate.Value().Mass(3.0); + ASSERT_TRUE(mass_candidate.HasValue()); + const auto& mass = mass_candidate.Value(); + const std::array, 4> pattern{{{{4.0, 2.0, 1.0, 2.0}}, + {{2.0, 4.0, 2.0, 1.0}}, + {{1.0, 2.0, 4.0, 2.0}}, + {{2.0, 1.0, 2.0, 4.0}}}}; + for (std::size_t row = 0U; row < 4U; ++row) { + EXPECT_NEAR(mass.nodal_moments[row][0U], 6.0, 1.0e-12); + EXPECT_NEAR(mass.nodal_moments[row][1U], 0.0, 1.0e-12); + EXPECT_NEAR(mass.nodal_moments[row][2U], 2.0, 1.0e-12); + for (std::size_t column = 0U; column < 4U; ++column) { + EXPECT_NEAR(mass.consistent_moments[0U](row, column), + pattern[row][column] * (2.0 / 3.0), 1.0e-12); + EXPECT_NEAR(mass.consistent_moments[1U](row, column), 0.0, 1.0e-12); + EXPECT_NEAR(mass.consistent_moments[2U](row, column), + pattern[row][column] * (2.0 / 9.0), 1.0e-12); + } + for (std::size_t component = 0U; component < 3U; ++component) { + EXPECT_NEAR( + mass.lumped_global24(6U * row + component, 6U * row + component), 6.0, + 1.0e-12); + EXPECT_NEAR(mass.lumped_global24(6U * row + 3U + component, + 6U * row + 3U + component), + component == 2U ? 0.0 : 2.0, 1.0e-12); + } + } + ExpectSymmetric(mass.consistent_global24); + ExpectSymmetric(mass.lumped_global24); +} + +TEST(Mitc4ShellMass, RejectsNonpositiveAndNonfiniteDensity) { + const auto nodes = PlanarNodes(); + const auto shell_candidate = fesa::Mitc4Shell::Create( + NodePointers(nodes), Directors(), Section(), Material()); + ASSERT_TRUE(shell_candidate.HasValue()); + for (const double density : + {0.0, -1.0, std::numeric_limits::infinity()}) { + const auto mass = shell_candidate.Value().Mass(density); + ASSERT_FALSE(mass.HasValue()); + ASSERT_EQ(mass.GetStatus().Diagnostics().size(), 1U); + EXPECT_EQ(mass.GetStatus().Diagnostics()[0U].code, "invalid-shell-mass"); + } +} + +TEST(Mitc4ShellMass, PreservesFiniteNodalVarianceAcrossDensityScale) { + const auto nodes = PlanarNodes(); + const auto shell_candidate = fesa::Mitc4Shell::Create( + NodePointers(nodes), Directors(), Section(), Material()); + ASSERT_TRUE(shell_candidate.HasValue()); + for (const double density : {1.0e-180, 1.0e180}) { + const auto mass_candidate = shell_candidate.Value().Mass(density); + ASSERT_TRUE(mass_candidate.HasValue()); + for (const auto& moments : mass_candidate.Value().nodal_moments) { + EXPECT_TRUE(std::isfinite(moments[0U])); + EXPECT_TRUE(std::isfinite(moments[1U])); + EXPECT_TRUE(std::isfinite(moments[2U])); + EXPECT_GT(moments[2U] - moments[1U] * (moments[1U] / moments[0U]), 0.0); + } + } +} + +void ExpectGlobalZCovariance(const fesa::Matrix& original, + const fesa::Matrix& rotated) { + ASSERT_EQ(original.Rows(), 24U); + ASSERT_EQ(original.Columns(), 24U); + ASSERT_EQ(rotated.Rows(), 24U); + ASSERT_EQ(rotated.Columns(), 24U); + constexpr double kInverseSqrtTwo = 0.70710678118654752440; + const std::array, 3> transform{ + {{{kInverseSqrtTwo, -kInverseSqrtTwo, 0.0}}, + {{kInverseSqrtTwo, kInverseSqrtTwo, 0.0}}, + {{0.0, 0.0, 1.0}}}}; + for (std::size_t row = 0U; row < 24U; ++row) { + for (std::size_t column = 0U; column < 24U; ++column) { + double expected = 0.0; + const std::size_t row_offset = + 6U * (row / 6U) + (row % 6U < 3U ? 0U : 3U); + const std::size_t column_offset = + 6U * (column / 6U) + (column % 6U < 3U ? 0U : 3U); + const std::size_t row_component = row % 3U; + const std::size_t column_component = column % 3U; + for (std::size_t first = 0U; first < 3U; ++first) { + for (std::size_t second = 0U; second < 3U; ++second) { + expected += transform[row_component][first] * + original(row_offset + first, column_offset + second) * + transform[column_component][second]; + } + } + EXPECT_NEAR(rotated(row, column), expected, 1.0e-12) + << "at (" << row << ", " << column << ")"; + } + } +} + +TEST(Mitc4ShellMass, PreservesIndependentWarpedMomentOracle) { + const std::array nodes{ + Node(1, {-1.0, -1.0, 0.1}), Node(2, {1.0, -1.0, -0.1}), + Node(3, {1.0, 1.0, 0.2}), Node(4, {-1.0, 1.0, -0.1})}; + const std::array directors{{{{0.0, 0.0, 1.0}}, + {{0.6, 0.0, 0.8}}, + {{2.0 / 3.0, 1.0 / 3.0, 2.0 / 3.0}}, + {{0.0, 0.6, 0.8}}}}; + const auto shell_candidate = fesa::Mitc4Shell::Create( + NodePointers(nodes), directors, Section(0.5), Material()); + ASSERT_TRUE(shell_candidate.HasValue()); + const auto mass_candidate = shell_candidate.Value().Mass(3.0); + ASSERT_TRUE(mass_candidate.HasValue()); + const auto& mass = mass_candidate.Value(); + + EXPECT_NEAR(mass.consistent_moments[0U](0U, 0U), 17609.0 / 28800.0, 1.0e-12); + EXPECT_NEAR(mass.consistent_moments[1U](0U, 0U), 11003.0 / 1440000.0, + 1.0e-12); + EXPECT_NEAR(mass.consistent_moments[2U](0U, 0U), 88177.0 / 6912000.0, + 1.0e-12); + double total_mass = 0.0; + double total_first_moment = 0.0; + double total_second_moment = 0.0; + for (const auto& moments : mass.nodal_moments) { + total_mass += moments[0U]; + total_first_moment += moments[1U]; + total_second_moment += moments[2U]; + } + EXPECT_NEAR(total_mass, 7743.0 / 1600.0, 1.0e-12); + EXPECT_NEAR(total_first_moment, 739.0 / 12000.0, 1.0e-12); + EXPECT_NEAR(total_second_moment, 517.0 / 5120.0, 1.0e-12); +} + +TEST(Mitc4ShellMass, MeetsPartialBoundaryCompleteMatrixTargets) { + const Vector3 tangent_a{0.0, 1.0, 0.0}; + const Vector3 tangent_b{-0.8, 0.0, 0.6}; + const std::array nodes{ + Node(1, {-2.0 * tangent_a[0U] - tangent_b[0U], + -2.0 * tangent_a[1U] - tangent_b[1U], + -2.0 * tangent_a[2U] - tangent_b[2U]}), + Node(2, {2.0 * tangent_a[0U] - tangent_b[0U], + 2.0 * tangent_a[1U] - tangent_b[1U], + 2.0 * tangent_a[2U] - tangent_b[2U]}), + Node(3, {2.0 * tangent_a[0U] + tangent_b[0U], + 2.0 * tangent_a[1U] + tangent_b[1U], + 2.0 * tangent_a[2U] + tangent_b[2U]}), + Node(4, {-2.0 * tangent_a[0U] + tangent_b[0U], + -2.0 * tangent_a[1U] + tangent_b[1U], + -2.0 * tangent_a[2U] + tangent_b[2U]})}; + const auto shell_candidate = + fesa::Mitc4Shell::Create(NodePointers(nodes), Directors({0.6, 0.0, 0.8}), + Section(1.0), Material(12.0, 0.0)); + ASSERT_TRUE(shell_candidate.HasValue()); + const auto stiffness_candidate = shell_candidate.Value().Stiffness(); + const auto mass_candidate = shell_candidate.Value().Mass(1.0); + ASSERT_TRUE(stiffness_candidate.HasValue()); + ASSERT_TRUE(mass_candidate.HasValue()); + const double complete_stiffness = + stiffness_candidate.Value().stabilized_global24(5U, 5U); + const double lumped_mass = mass_candidate.Value().lumped_global24(5U, 5U); + EXPECT_NEAR(stiffness_candidate.Value().drilling_stiffness, 19.0 / 4800.0, + 1.0e-12); + EXPECT_NEAR(complete_stiffness, 10369.0 / 7500.0, 1.0e-12); + EXPECT_NEAR(lumped_mass, 3.0 / 50.0, 1.0e-12); + EXPECT_NEAR(complete_stiffness / lumped_mass, 10369.0 / 450.0, 1.0e-12); +} + +TEST(Mitc4ShellMass, RecomputesCompleteMassAndStiffnessCovariantly) { + const std::array nodes{ + Node(1, {-1.0, -1.0, 0.1}), Node(2, {1.0, -1.0, -0.1}), + Node(3, {1.0, 1.0, 0.2}), Node(4, {-1.0, 1.0, -0.1})}; + const std::array directors{{{{0.0, 0.0, 1.0}}, + {{0.6, 0.0, 0.8}}, + {{2.0 / 3.0, 1.0 / 3.0, 2.0 / 3.0}}, + {{0.0, 0.6, 0.8}}}}; + std::array rotated_nodes = nodes; + std::array rotated_directors{}; + for (std::size_t node = 0U; node < nodes.size(); ++node) { + rotated_nodes[node].coordinates = + RotateAboutGlobalZ(nodes[node].coordinates); + rotated_directors[node] = RotateAboutGlobalZ(directors[node]); + } + const auto original = fesa::Mitc4Shell::Create(NodePointers(nodes), directors, + Section(0.5), Material()); + const auto rotated = fesa::Mitc4Shell::Create( + NodePointers(rotated_nodes), rotated_directors, Section(0.5), Material()); + ASSERT_TRUE(original.HasValue()); + ASSERT_TRUE(rotated.HasValue()); + const auto original_stiffness = original.Value().Stiffness(); + const auto rotated_stiffness = rotated.Value().Stiffness(); + const auto original_mass = original.Value().Mass(3.0); + const auto rotated_mass = rotated.Value().Mass(3.0); + ASSERT_TRUE(original_stiffness.HasValue()); + ASSERT_TRUE(rotated_stiffness.HasValue()); + ASSERT_TRUE(original_mass.HasValue()); + ASSERT_TRUE(rotated_mass.HasValue()); + ExpectGlobalZCovariance(original_stiffness.Value().stabilized_global24, + rotated_stiffness.Value().stabilized_global24); + ExpectGlobalZCovariance(original_mass.Value().consistent_global24, + rotated_mass.Value().consistent_global24); + ExpectGlobalZCovariance(original_mass.Value().lumped_global24, + rotated_mass.Value().lumped_global24); } // MITC4-KERNEL-006 diff --git a/tests/unit/io/abaqus/domain_mapping_components_test.cpp b/tests/unit/io/abaqus/domain_mapping_components_test.cpp index 3524551..64d2c0c 100644 --- a/tests/unit/io/abaqus/domain_mapping_components_test.cpp +++ b/tests/unit/io/abaqus/domain_mapping_components_test.cpp @@ -157,6 +157,9 @@ TEST(DomainMappingComponents, EXPECT_EQ(domain.Steps()[0].Loads().size(), 1U); ASSERT_EQ(domain.Warnings().size(), 3U); EXPECT_EQ(domain.Warnings()[0].keyword, "PREPRINT"); + EXPECT_EQ( + domain.Warnings()[0].message, + "The allowlisted Abaqus keyword is ignored without semantic effect."); EXPECT_EQ(domain.Warnings()[1].keyword, "OUTPUT"); EXPECT_EQ(domain.Warnings()[2].keyword, "NODE OUTPUT"); } diff --git a/tests/unit/io/abaqus/eigen_domain_test.cpp b/tests/unit/io/abaqus/eigen_domain_test.cpp new file mode 100644 index 0000000..355d1d6 --- /dev/null +++ b/tests/unit/io/abaqus/eigen_domain_test.cpp @@ -0,0 +1,207 @@ +#include + +#include +#include +#include +#include +#include + +#include "fesa/io/abaqus/domain_mapper.h" +#include "fesa/io/abaqus/input_reader.h" + +namespace { + +class TemporaryInputFile { + public: + TemporaryInputFile(const std::string& stem, const std::string& content) + : path_{std::filesystem::temp_directory_path() / + ("fesa-eigen-domain-" + stem + ".inp")} { + std::ofstream stream{path_, std::ios::binary | std::ios::trunc}; + stream.write(content.data(), static_cast(content.size())); + if (!stream) { + throw std::runtime_error{"Unable to create eigen domain fixture."}; + } + } + + ~TemporaryInputFile() { + std::error_code error; + std::filesystem::remove(path_, error); + } + + const std::filesystem::path& Path() const noexcept { return path_; } + + private: + std::filesystem::path path_; +}; + +fesa::Result MapText(const std::string& stem, + const std::string& content) { + const TemporaryInputFile input{stem, content}; + auto parsed = fesa::AbaqusInputReader{}.Read(input.Path()); + if (!parsed.HasValue()) { + return fesa::Result::Failure(parsed.GetStatus()); + } + return fesa::AbaqusDomainMapper{}.Map(parsed.Value()); +} + +std::string EigenDeck(const std::string& density = "2700.,", + const std::string& frequency = "10, , , , ,") { + return R"inp(*Part, name=ShellPart +*Node +1, 0., 0., 0. +2, 1., 0., 0. +3, 1., 1., 0. +4, 0., 1., 0. +*Element, type=S4R +1, 1, 2, 3, 4 +*Elset, elset=ShellSet +1 +*Shell Section, elset=ShellSet, material=Steel +0.5, 5 +*End Part +*Assembly, name=Assembly +*Instance, name=Shell-1, part=ShellPart +*End Instance +*Nset, nset=Root, instance=Shell-1 +1 +*End Assembly +*Material, name=Steel +*Density +)inp" + density + + R"inp( +*Elastic +2.1e11, 0.3 +*Boundary +Root, 1, 6 +*Step, name=Step-2, nlgeom=NO, perturbation +*Frequency, eigensolver=Lanczos, sim, acoustic coupling=on, normalization=mass +)inp" + frequency + + R"inp( +*Restart, write, frequency=0 +*Output, field +*Node Output, variable=PRESELECT +*Output, history +*Energy Output, variable=ALL +*End Step +)inp"; +} + +const fesa::Diagnostic* FindDiagnostic(const fesa::Status& status, + const std::string& code) { + for (const auto& diagnostic : status.Diagnostics()) { + if (diagnostic.code == code) { + return &diagnostic; + } + } + return nullptr; +} + +TEST(EigenDomainMapping, PreservesFrequencyDensityAndSourceStepIdentity) { + auto result = MapText("accepted", EigenDeck()); + ASSERT_TRUE(result.HasValue()); + const fesa::Domain& domain = result.Value(); + + ASSERT_EQ(domain.Densities().size(), 1U); + EXPECT_EQ(domain.Densities()[0].material_index, fesa::EntityIndex{0U}); + EXPECT_DOUBLE_EQ(domain.Densities()[0].rho, 2700.0); + ASSERT_NE(domain.Frequency(), nullptr); + EXPECT_EQ(domain.Frequency()->source_step_name_raw, "Step-2"); + EXPECT_EQ(domain.Frequency()->source_step_name_canonical, "STEP-2"); + EXPECT_EQ(domain.Frequency()->requested_mode_count, 10); + EXPECT_TRUE(domain.Frequency()->perturbation_explicit); + EXPECT_TRUE(domain.Frequency()->sim_explicit); + ASSERT_EQ(domain.Steps().Size(), 1U); + EXPECT_TRUE(domain.Steps()[0U].IsFrequency()); + EXPECT_EQ(domain.Steps()[0U].Frequency(), domain.Frequency()); + EXPECT_EQ(domain.Steps()[0U].Name(), "Step-2"); + ASSERT_EQ(domain.Steps()[0U].BoundaryConditions().size(), 6U); + EXPECT_TRUE(domain.Steps()[0U].Loads().empty()); +} + +TEST(EigenDomainMapping, RejectsInvalidDensityAndFrequencySlots) { + auto density = MapText("bad-density", EigenDeck("2700., 1.")); + ASSERT_FALSE(density.HasValue()); + EXPECT_NE(FindDiagnostic(density.GetStatus(), "invalid-density-data"), + nullptr); + + auto slots = MapText("bad-slots", EigenDeck("2700.,", "10, 0")); + ASSERT_FALSE(slots.HasValue()); + EXPECT_NE(FindDiagnostic(slots.GetStatus(), "invalid-frequency-data"), + nullptr); +} + +TEST(EigenDomainMapping, RejectsMissingActiveDensityAndNonzeroBoundary) { + std::string missing_deck = EigenDeck(); + const std::string density_block = "*Density\n2700.,\n"; + const std::size_t density_location = missing_deck.find(density_block); + ASSERT_NE(density_location, std::string::npos); + missing_deck.erase(density_location, density_block.size()); + auto missing = MapText("missing-density", missing_deck); + ASSERT_FALSE(missing.HasValue()); + EXPECT_NE(FindDiagnostic(missing.GetStatus(), "missing-material-density"), + nullptr); + + std::string nonzero = EigenDeck(); + const std::string source = "Root, 1, 6"; + const std::size_t location = nonzero.rfind(source); + ASSERT_NE(location, std::string::npos); + nonzero.replace(location, source.size(), "Root, 1, 6, 1."); + auto boundary = MapText("nonzero-boundary", nonzero); + ASSERT_FALSE(boundary.HasValue()); + EXPECT_NE(FindDiagnostic(boundary.GetStatus(), "unsupported-eigen-boundary"), + nullptr); +} + +TEST(EigenDomainMapping, RejectsMixedProceduresInvalidOutputAndSurface) { + std::string mixed = EigenDeck(); + mixed.insert(mixed.find("*Restart"), "*Static\n0.1, 1., 0.01, 1.\n"); + auto mixed_result = MapText("mixed-procedures", mixed); + ASSERT_FALSE(mixed_result.HasValue()); + EXPECT_NE(FindDiagnostic(mixed_result.GetStatus(), + "unsupported-analysis-procedure"), + nullptr); + + auto wrong_output = MapText( + "wrong-output", + EigenDeck().replace(EigenDeck().find("*Output, field\n*Node Output"), + std::string{"*Output, field\n*Node Output"}.size(), + "*Output, history\n*Node Output")); + ASSERT_FALSE(wrong_output.HasValue()); + EXPECT_NE(FindDiagnostic(wrong_output.GetStatus(), "unsupported-keyword"), + nullptr); + + std::string surface = EigenDeck(); + surface.insert(surface.find("*Material"), + "*Surface, name=Face, type=ELEMENT\n"); + auto surface_result = MapText("surface", surface); + ASSERT_FALSE(surface_result.HasValue()); +} + +TEST(EigenDomainMapping, RejectsInvalidPreprintAndAcceptsStrictNoOp) { + const std::string preprint = + "*Preprint, echo=NO, model=NO, history=NO, contact=NO\n"; + auto accepted = MapText("preprint", preprint + EigenDeck()); + ASSERT_TRUE(accepted.HasValue()); + const auto warning = std::find_if(accepted.Value().Warnings().begin(), + accepted.Value().Warnings().end(), + [](const fesa::Diagnostic& diagnostic) { + return diagnostic.keyword == "PREPRINT"; + }); + ASSERT_NE(warning, accepted.Value().Warnings().end()); + EXPECT_EQ(warning->message, "mandatory-eigen-output-unchanged"); + + auto rejected = + MapText("bad-preprint", "*Preprint, echo=YES\n" + EigenDeck()); + ASSERT_FALSE(rejected.HasValue()); + EXPECT_NE(FindDiagnostic(rejected.GetStatus(), "unsupported-keyword"), + nullptr); + + auto missing_parameters = + MapText("bare-preprint", "*Preprint\n" + EigenDeck()); + ASSERT_FALSE(missing_parameters.HasValue()); + EXPECT_NE( + FindDiagnostic(missing_parameters.GetStatus(), "unsupported-keyword"), + nullptr); +} + +} // namespace diff --git a/tests/unit/io/hdf5/eigen_hdf5_results_writer_test.cpp b/tests/unit/io/hdf5/eigen_hdf5_results_writer_test.cpp new file mode 100644 index 0000000..2c2b255 --- /dev/null +++ b/tests/unit/io/hdf5/eigen_hdf5_results_writer_test.cpp @@ -0,0 +1,125 @@ +#include "fesa/io/hdf5/eigen_hdf5_results_writer.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "fesa/analysis/linear_frequency_procedure.h" +#include "fesa/model/domain.h" +#include "io/hdf5/eigen_hdf5_self_check.h" + +namespace { + +fesa::ModelDefinition Definition(const std::filesystem::path& source) { + fesa::ModelDefinition definition{}; + definition.source_path = source; + definition.source_content_identity = "fnv1a64:0123456789abcdef"; + definition.nodes = {{{"I", 1, "1"}, {-1.0, -1.0, 0.0}, {source, 1U}}, + {{"I", 2, "2"}, {1.0, -1.0, 0.0}, {source, 2U}}, + {{"I", 3, "3"}, {1.0, 1.0, 0.0}, {source, 3U}}, + {{"I", 4, "4"}, {-1.0, 1.0, 0.0}, {source, 4U}}}; + definition.materials = {{"Steel", 12.0, 0.0, {source, 5U}}}; + definition.shell_sections = {{"S", 1.0, 0U, {source, 6U}}}; + definition.shell_elements = {{{"I", 1, "1"}, + fesa::ShellSourceElementType::kS4, + {0U, 1U, 2U, 3U}, + 0U, + 0U, + {source, 7U}}}; + for (std::size_t node = 0U; node < 4U; ++node) { + definition.shell_node_initial_frames.push_back( + {static_cast(node), + {0.0, 0.0, 1.0}, + {1.0, 0.0, 0.0}, + {0.0, 1.0, 0.0}}); + } + definition.densities = {{0U, 1.0, {source, 8U}}}; + definition.frequency_step = + fesa::FrequencyStepDefinition{"Step-2", + "STEP-2", + {source, 9U}, + {source, 10U}, + {source, 11U}, + 1, + false, + false, + false, + false, + false, + false, + fesa::FrequencyEigensolver::kLanczos, + fesa::FrequencyNormalization::kMass, + {}}; + return definition; +} + +TEST(EigenHdf5ResultsWriter, WritesModalSchemaAndRejectsTamperedState) { + const auto path = + std::filesystem::temp_directory_path() / "fesa-eigen-writer-test.h5"; + std::error_code ignored; + std::filesystem::remove(path, ignored); + const auto source = path.parent_path() / "fesa-eigen-writer-test.inp"; + auto domain_result = fesa::Domain::Create(Definition(source)); + ASSERT_TRUE(domain_result.HasValue()); + fesa::Domain domain{std::move(domain_result.Value())}; + fesa::EigenHdf5ResultsWriter writer; + auto execution = fesa::LinearFrequencyProcedureExecutor{}.Execute(domain); + ASSERT_TRUE(execution.HasValue()); + fesa::EigenAnalysisState state = std::move(execution.Value()); + ASSERT_TRUE(writer.Write(path, domain, state, {}).IsOk()); + const hid_t file = + H5Fopen(path.string().c_str(), H5F_ACC_RDONLY, H5P_DEFAULT); + ASSERT_GE(file, 0); + EXPECT_GT(H5Lexists(file, "/steps/Step-1/eigen/modes", H5P_DEFAULT), 0); + EXPECT_GE(H5Fclose(file), 0); + std::ifstream original_file{path, std::ios::binary}; + const std::string original{std::istreambuf_iterator{original_file}, {}}; + original_file.close(); + const std::vector> mutations{ + [](auto& candidate) { + candidate.vectors(0U, 0U) = std::numeric_limits::quiet_NaN(); + }, + [](auto& candidate) { candidate.eigenpair_checks[0].rho_b = 1e-5; }, + [](auto& candidate) { candidate.eigenpair_checks[0].eigenvalue += 1.0; }, + [](auto& candidate) { candidate.modes[0].cluster_id = 7U; }, + [](auto& candidate) { candidate.constraint_mask[0] = 1U; }, + [](auto& candidate) { candidate.nodal_reduction[0].rcond_inf = 1e-15; }, + [](auto& candidate) { candidate.eta_gram = 1e-5; }}; + for (const auto& mutate : mutations) { + auto candidate = state; + mutate(candidate); + EXPECT_FALSE(writer.Write(path, domain, candidate, {}).IsOk()); + } + std::ifstream retained_file{path, std::ios::binary}; + const std::string retained{std::istreambuf_iterator{retained_file}, {}}; + EXPECT_TRUE(retained == original); + retained_file.close(); + ASSERT_TRUE( + fesa::hdf5_internal::SelfCheckEigenFile(path, domain, state, {}).IsOk()); + const hid_t corrupt_file = + H5Fopen(path.string().c_str(), H5F_ACC_RDWR, H5P_DEFAULT); + ASSERT_GE(corrupt_file, 0); + const hid_t nodes = H5Dopen2(corrupt_file, "/model/nodes", H5P_DEFAULT); + ASSERT_GE(nodes, 0); + const hid_t id_type = H5Tcreate(H5T_COMPOUND, sizeof(std::uint64_t)); + ASSERT_GE(H5Tinsert(id_type, "internal_node_id", 0U, H5T_NATIVE_UINT64), 0); + const std::array duplicate_ids{0U, 0U, 2U, 3U}; + ASSERT_GE(H5Dwrite(nodes, id_type, H5S_ALL, H5S_ALL, H5P_DEFAULT, + duplicate_ids.data()), + 0); + EXPECT_GE(H5Tclose(id_type), 0); + EXPECT_GE(H5Dclose(nodes), 0); + EXPECT_GE(H5Fclose(corrupt_file), 0); + EXPECT_FALSE( + fesa::hdf5_internal::SelfCheckEigenFile(path, domain, state, {}).IsOk()); + std::filesystem::remove(path, ignored); +} + +} // namespace diff --git a/tests/unit/solvers/eigen/mkl_feast_eigen_solver_test.cpp b/tests/unit/solvers/eigen/mkl_feast_eigen_solver_test.cpp new file mode 100644 index 0000000..5748f6b --- /dev/null +++ b/tests/unit/solvers/eigen/mkl_feast_eigen_solver_test.cpp @@ -0,0 +1,251 @@ +#include "fesa/solvers/eigen/mkl_feast_eigen_solver.h" + +#include +#include + +#include +#include +#include +#include + +#include "fesa/fem/dof_manager.h" +#include "fesa/math/sparse_matrix.h" + +namespace { + +fesa::SparseMatrix MakeDenseCsr(const std::size_t size, + const std::vector& values) { + EXPECT_EQ(values.size(), size * size); + fesa::SparsePattern pattern; + std::vector contributions; + pattern.row_offsets.push_back(0U); + for (std::size_t row = 0U; row < size; ++row) { + for (std::size_t column = 0U; column < size; ++column) { + pattern.column_indices.push_back(column); + contributions.push_back( + {row, column, values[row * size + column], row, column}); + } + pattern.row_offsets.push_back(pattern.column_indices.size()); + } + auto matrix = fesa::SparseMatrix::FromCoo(size, size, + std::move(contributions), pattern); + EXPECT_TRUE(matrix.HasValue()); + return std::move(matrix.Value()); +} + +void ExpectUnitGram(const fesa::Matrix& vectors) { + for (std::size_t left = 0U; left < vectors.Columns(); ++left) { + for (std::size_t right = 0U; right < vectors.Columns(); ++right) { + double dot = 0.0; + for (std::size_t row = 0U; row < vectors.Rows(); ++row) { + dot += vectors(row, left) * vectors(row, right); + } + EXPECT_NEAR(dot, left == right ? 1.0 : 0.0, 1.0e-10); + } + } +} + +void RequireSuccess(const fesa::Result& result) { + if (result.HasValue()) return; + const auto& diagnostics = result.GetStatus().Diagnostics(); + ASSERT_FALSE(diagnostics.empty()); + ADD_FAILURE() << diagnostics.front().code << " " + << diagnostics.front().entity_identity << " " + << diagnostics.front().message; +} + +} // namespace + +TEST(MklFeastEigenSolver, SolvesN1AndRetainsBackendEvidence) { + const auto matrix = MakeDenseCsr(1U, {4.0}); + fesa::MklFeastEigenSolver solver; + + const auto result = solver.Solve(matrix, 1U, 1.0); + + RequireSuccess(result); + if (!result.HasValue()) return; + EXPECT_EQ(result.Value().requested_modes, 1U); + ASSERT_EQ(result.Value().eigenvalues.size(), 1U); + EXPECT_NEAR(result.Value().eigenvalues[0U], 4.0, 1.0e-10); + EXPECT_DOUBLE_EQ(result.Value().operator_scale, 4.0); + EXPECT_EQ(result.Value().eigenvectors.Rows(), 1U); + EXPECT_EQ(result.Value().eigenvectors.Columns(), 1U); + EXPECT_EQ(result.Value().thread_count, 1U); + ASSERT_FALSE(result.Value().backend_calls.empty()); + EXPECT_EQ(result.Value().backend_calls.front().purpose, "total-enclosure"); + EXPECT_EQ(result.Value().backend_calls.back().kind, "solve"); + EXPECT_TRUE(result.Value().backend_calls.back().count_available); + EXPECT_EQ(result.Value().backend_calls.back().count, + result.Value().eigenvalues.size()); + EXPECT_TRUE(result.Value().backend_calls.back().loop_available); + EXPECT_TRUE(result.Value().backend_calls.back().epsout_available); + EXPECT_EQ(result.Value().backend_calls.back().outcome, "accepted"); + EXPECT_EQ(result.Value().backend_calls.back().decision_reason, "accepted"); + EXPECT_FALSE(result.Value().upper_search_termination.empty()); + ExpectUnitGram(result.Value().eigenvectors); +} + +TEST(MklFeastEigenSolver, SolvesAllZeroWithExplicitPhysicalScale) { + const auto matrix = MakeDenseCsr(2U, {0.0, 0.0, 0.0, 0.0}); + fesa::MklFeastEigenSolver solver; + + const auto result = solver.Solve(matrix, 2U, 7.0); + + RequireSuccess(result); + if (!result.HasValue()) return; + ASSERT_EQ(result.Value().eigenvalues.size(), 2U); + EXPECT_DOUBLE_EQ(result.Value().operator_scale, 7.0); + EXPECT_DOUBLE_EQ(result.Value().eigenvalues[0U], 0.0); + EXPECT_DOUBLE_EQ(result.Value().eigenvalues[1U], 0.0); + ExpectUnitGram(result.Value().eigenvectors); +} + +TEST(MklFeastEigenSolver, PreservesRepeatedMultiplicityAndStableOrder) { + const auto matrix = + MakeDenseCsr(3U, {2.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 5.0}); + fesa::MklFeastEigenSolver solver; + + const auto first = solver.Solve(matrix, 3U, 1.0); + const auto second = solver.Solve(matrix, 3U, 1.0); + + RequireSuccess(first); + RequireSuccess(second); + if (!first.HasValue() || !second.HasValue()) return; + ASSERT_EQ(first.Value().eigenvalues.size(), 3U); + EXPECT_NEAR(first.Value().eigenvalues[0U], 2.0, 1.0e-10); + EXPECT_NEAR(first.Value().eigenvalues[1U], 2.0, 1.0e-10); + EXPECT_NEAR(first.Value().eigenvalues[2U], 5.0, 1.0e-10); + EXPECT_EQ(first.Value().column_ids, second.Value().column_ids); + ExpectUnitGram(first.Value().eigenvectors); +} + +TEST(MklFeastEigenSolver, UsesGuardedPrefixWithoutDroppingRepeatedCluster) { + constexpr std::size_t kSize = 64U; + std::vector values(kSize * kSize, 0.0); + for (std::size_t index = 0U; index < kSize; ++index) { + values[index * kSize + index] = index < 40U ? 1.0 : index - 38U; + } + const auto matrix = MakeDenseCsr(kSize, values); + fesa::MklFeastEigenSolver solver; + + const auto one_mode = solver.Solve(matrix, 1U, 1.0); + const auto forty_first = solver.Solve(matrix, 41U, 1.0); + + RequireSuccess(one_mode); + RequireSuccess(forty_first); + if (!one_mode.HasValue() || !forty_first.HasValue()) return; + EXPECT_GE(one_mode.Value().eigenvalues.size(), 40U); + EXPECT_GE(forty_first.Value().eigenvalues.size(), 41U); + for (std::size_t index = 0U; index < 40U; ++index) { + EXPECT_NEAR(one_mode.Value().eigenvalues[index], 1.0, 1.0e-10); + } + EXPECT_NEAR(forty_first.Value().eigenvalues[40U], 2.0, 1.0e-10); + EXPECT_EQ(one_mode.Value().backend_calls.back().kind, "solve"); + EXPECT_EQ(forty_first.Value().backend_calls.back().kind, "solve"); +} + +TEST(MklFeastEigenSolver, RejectsSignificantNegativeSpectrum) { + const auto matrix = MakeDenseCsr(1U, {-2.0}); + fesa::MklFeastEigenSolver solver; + + const auto result = solver.Solve(matrix, 1U, 1.0); + + ASSERT_FALSE(result.HasValue()); + EXPECT_EQ(result.GetStatus().Category(), fesa::FailureCategory::kSolver); + ASSERT_EQ(result.GetStatus().Diagnostics().size(), 1U); + EXPECT_EQ(result.GetStatus().Diagnostics()[0U].code, + "negative-eigenvalue-spectrum"); +} + +TEST(MklFeastEigenSolver, KeepsCloseDistinctModesSeparated) { + const auto matrix = MakeDenseCsr(2U, {1.0, 0.0, 0.0, 1.001}); + fesa::MklFeastEigenSolver solver; + + const auto result = solver.Solve(matrix, 2U, 1.0); + + RequireSuccess(result); + if (!result.HasValue()) return; + ASSERT_EQ(result.Value().eigenvalues.size(), 2U); + EXPECT_NEAR(result.Value().eigenvalues[0U], 1.0, 1.0e-10); + EXPECT_NEAR(result.Value().eigenvalues[1U], 1.001, 1.0e-10); + EXPECT_NEAR(std::abs(result.Value().eigenvectors(0U, 0U)), 1.0, 1.0e-10); + EXPECT_NEAR(std::abs(result.Value().eigenvectors(1U, 1U)), 1.0, 1.0e-10); +} + +TEST(MklFeastEigenSolver, RestoresCallingThreadLocalSetting) { + const auto matrix = MakeDenseCsr(1U, {4.0}); + const int prior = mkl_set_num_threads_local(2); + fesa::MklFeastEigenSolver solver; + + const auto result = solver.Solve(matrix, 1U, 1.0); + const int restored = mkl_set_num_threads_local(2); + static_cast(mkl_set_num_threads_local(prior)); + + RequireSuccess(result); + if (!result.HasValue()) return; + EXPECT_EQ(restored, 2); +} + +TEST(MklFeastEigenSolver, RejectsInvalidRequestAndZeroScale) { + const auto matrix = MakeDenseCsr(1U, {0.0}); + fesa::MklFeastEigenSolver solver; + + const auto zero_request = solver.Solve(matrix, 0U, 1.0); + ASSERT_FALSE(zero_request.HasValue()); + EXPECT_EQ(zero_request.GetStatus().Diagnostics()[0U].code, + "feast-invalid-dimension"); + const auto invalid_scale = solver.Solve(matrix, 1U, 0.0); + ASSERT_FALSE(invalid_scale.HasValue()); + EXPECT_EQ(invalid_scale.GetStatus().Diagnostics()[0U].code, + "feast-invalid-zero-scale"); +} + +TEST(MklFeastEigenSolver, PreservesLowestPrefixForZeroAndPositiveSpectra) { + const std::vector> spectra{{2.0}, + {0.0, 0.0, 0.0, 2.0, 5.0, 9.0}, + {1.0, 2.0, 4.0, 8.0}, + {0.0, 0.0, 0.0, 0.0}}; + const std::vector> requests{ + {1U}, {1U, 4U, 6U}, {1U, 2U, 4U}, {1U, 4U}}; + const fesa::MklFeastEigenSolver solver; + for (std::size_t case_index = 0U; case_index < spectra.size(); ++case_index) { + const auto& spectrum = spectra[case_index]; + const auto n = spectrum.size(); + std::vector values(n * n, 0.0); + for (std::size_t i = 0U; i < n; ++i) values[i * n + i] = spectrum[i]; + const auto matrix = MakeDenseCsr(n, values); + for (const auto requested : requests[case_index]) { + const auto result = solver.Solve(matrix, requested, 1.0); + RequireSuccess(result); + ASSERT_TRUE(result.HasValue()); + ASSERT_GE(result.Value().eigenvalues.size(), requested); + for (std::size_t i = 0U; i < requested; ++i) + EXPECT_NEAR(result.Value().eigenvalues[i], spectrum[i], 1e-10); + ExpectUnitGram(result.Value().eigenvectors); + } + } +} + +TEST(MklFeastEigenSolver, ResolvesCloseDistinctSpectrumAfterGivensCongruence) { + const std::array spectrum{1.0, 1.0 + std::ldexp(1.0, -28), + 1.0 + std::ldexp(1.0, -27), 8.0}; + const std::array, 4> q{{{{0.6, 0.0, 0.0, -0.8}}, + {{0.0, 0.6, -0.8, 0.0}}, + {{0.0, 0.8, 0.6, 0.0}}, + {{0.8, 0.0, 0.0, 0.6}}}}; + std::vector values(16U, 0.0); + for (std::size_t i = 0U; i < 4U; ++i) + for (std::size_t j = 0U; j < 4U; ++j) + for (std::size_t k = 0U; k < 4U; ++k) + values[4U * i + j] += q[i][k] * spectrum[k] * q[j][k]; + const auto result = + fesa::MklFeastEigenSolver{}.Solve(MakeDenseCsr(4U, values), 2U, 1.0); + RequireSuccess(result); + ASSERT_TRUE(result.HasValue()); + ASSERT_EQ(result.Value().eigenvalues.size(), 4U); + for (std::size_t i = 0U; i < 4U; ++i) + EXPECT_NEAR(result.Value().eigenvalues[i], spectrum[i], 1e-12); + EXPECT_GT(result.Value().eigenvalues[1U] - result.Value().eigenvalues[0U], + 1e-9); + ExpectUnitGram(result.Value().eigenvectors); +}