From 0820259d50d957dde34dbf671171157e4724110c Mon Sep 17 00:00:00 2001 From: "KOKO\\Mimi" Date: Tue, 22 Sep 2026 21:18:15 +0900 Subject: [PATCH] feat: map fixed-step HHT shell dynamics input --- include/fesa/model/domain.h | 13 + include/fesa/model/model_types.h | 19 ++ src/fesa/io/abaqus/domain_mapping_context.cpp | 276 ++++++++++++++++-- src/fesa/model/domain.cpp | 59 +++- tests/CMakeLists.txt | 1 + tests/unit/io/abaqus/domain_mapper_test.cpp | 5 +- .../io/abaqus/hht_dynamic_domain_test.cpp | 206 +++++++++++++ 7 files changed, 554 insertions(+), 25 deletions(-) create mode 100644 tests/unit/io/abaqus/hht_dynamic_domain_test.cpp diff --git a/include/fesa/model/domain.h b/include/fesa/model/domain.h index 42819ee..c63f8cd 100644 --- a/include/fesa/model/domain.h +++ b/include/fesa/model/domain.h @@ -88,9 +88,15 @@ class StepDefinition { /// @brief Reports whether this step defines the frequency procedure. bool IsFrequency() const noexcept; + /// @brief Reports whether this step defines the HHT dynamic procedure. + bool IsDynamic() const noexcept; + /// @brief Returns frequency source semantics when this is a frequency step. const FrequencyStepDefinition* Frequency() const noexcept; + /// @brief Returns dynamic source semantics when this is a dynamic step. + const DynamicStepDefinition* Dynamic() const noexcept; + /// @brief Reports the source NLGEOM setting for this static step. bool UsesNonlinearGeometry() const noexcept; @@ -118,6 +124,9 @@ class StepDefinition { /// @brief Converts one parsed frequency-step record to owned semantics. explicit StepDefinition(FrequencyStepDefinition definition); + /// @brief Converts one parsed dynamic-step record to owned semantics. + explicit StepDefinition(DynamicStepDefinition definition); + std::string name_; std::vector> boundary_conditions_; BoundaryConditionView boundary_conditions_view_; @@ -135,6 +144,7 @@ class StepDefinition { double maximum_increment_; SourceLocation location_; std::optional frequency_; + std::optional dynamic_; }; /// @brief Owns the complete immutable semantic model definition. @@ -204,6 +214,9 @@ class Domain { /// @brief Returns the sole frequency source definition when present. const FrequencyStepDefinition* Frequency() const noexcept; + /// @brief Returns the sole dynamic source definition when present. + const DynamicStepDefinition* Dynamic() 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 d6ca825..fdfd184 100644 --- a/include/fesa/model/model_types.h +++ b/include/fesa/model/model_types.h @@ -208,6 +208,24 @@ struct FrequencyStepDefinition { std::vector boundaries; }; +/// @brief Stores the sole supported HHT direct-integration step semantics. +struct DynamicStepDefinition { + std::string source_step_name_raw; + std::string source_step_name_canonical; + SourceLocation step_location; + SourceLocation dynamic_location; + SourceLocation data_location; + double initial_increment{0.0}; + double time_period{0.0}; + std::uint32_t increment_count{0U}; + std::uint32_t maximum_increments{100U}; + double alpha{-0.05}; + double beta{0.275625}; + double gamma{0.55}; + std::vector boundaries; + std::vector loads; +}; + /// @brief Stores a stable resolved node-set membership list. struct NodeSet { std::string name; @@ -271,6 +289,7 @@ struct ModelDefinition { std::vector steps; std::vector densities; std::optional frequency_step; + std::optional dynamic_step; std::vector warnings; }; diff --git a/src/fesa/io/abaqus/domain_mapping_context.cpp b/src/fesa/io/abaqus/domain_mapping_context.cpp index 21f4b97..d9649a8 100644 --- a/src/fesa/io/abaqus/domain_mapping_context.cpp +++ b/src/fesa/io/abaqus/domain_mapping_context.cpp @@ -147,11 +147,13 @@ struct RawStep { std::string name_raw; std::string name_canonical; bool has_static{false}; + std::optional dynamic; bool perturbation_explicit{false}; bool nlgeom_explicit{false}; StaticProcedureKind procedure_kind{ StaticProcedureKind::kLinearSmallDisplacement}; bool nlgeom{false}; + std::uint32_t maximum_increments{0U}; std::array static_values{}; std::optional frequency; std::vector boundaries; @@ -481,6 +483,25 @@ class DomainMappingContext::Impl { return true; } + bool ParseDynamicDouble(const std::string& text, double& value, + const SourceLocation& location, + const std::string& keyword) { + if (text.empty()) { + return InputFailure("invalid-dynamic-data", location, keyword, text, + "Dynamic data fields cannot be empty."); + } + char* end = nullptr; + errno = 0; + const double parsed = std::strtod(text.c_str(), &end); + if (errno == ERANGE || end != text.c_str() + text.size() || + !std::isfinite(parsed)) { + return InputFailure("invalid-dynamic-data", location, keyword, text, + "Dynamic data fields must be finite."); + } + value = parsed; + return true; + } + bool ParseModelDouble(const std::string& text, double& value, const SourceLocation& location, const std::string& keyword, @@ -1683,7 +1704,12 @@ class DomainMappingContext::Impl { void ParseBoundary( const KeywordBlock& block, std::vector& destination) { - if (!ValidateParameters(block, {}) || block.data.empty()) { + const bool dynamic_input = std::any_of( + input_.blocks.begin(), input_.blocks.end(), + [](const auto& candidate) { return candidate.canonical_name == "DYNAMIC"; }); + if (!ValidateParameters(block, {}, dynamic_input + ? "unsupported-dynamic-boundary" + : "invalid-keyword-parameter") || block.data.empty()) { if (!failure_) { InputFailure("invalid-data-arity", block.location, block.canonical_name, "", "BOUNDARY requires one or more data rows."); @@ -1729,6 +1755,12 @@ class DomainMappingContext::Impl { } void ParseCload(const KeywordBlock& block, std::vector& loads) { + if (step_.dynamic && !block.parameters.empty()) { + InputFailure("unsupported-dynamic-load", block.location, + block.canonical_name, "", + "Dynamic CLOAD does not support keyword parameters."); + return; + } if (!ValidateParameters(block, {}) || block.data.empty()) { if (!failure_) { InputFailure("invalid-data-arity", block.location, block.canonical_name, @@ -1755,6 +1787,12 @@ class DomainMappingContext::Impl { row.fields[0], "CLOAD DOF must be in 1..6."); return; } + if (step_.dynamic && dof > 3) { + InputFailure("unsupported-dynamic-load", row.location, + block.canonical_name, row.fields[1], + "Dynamic CLOAD supports global translational DOF 1 through 3 only."); + return; + } loads.push_back( {row.fields[0], static_cast(dof), magnitude, row.location}); } @@ -1803,7 +1841,7 @@ class DomainMappingContext::Impl { void ParseStepStart(const KeywordBlock& block) { if (step_seen_) { - if (step_.frequency) { + if (step_.frequency || step_.dynamic) { InputFailure( "unsupported-analysis-procedure", block.location, block.canonical_name, "", @@ -1832,7 +1870,7 @@ class DomainMappingContext::Impl { "material definitions."); return; } - if (!ValidateParameters(block, {"NAME", "NLGEOM", "PERTURBATION"}) || + if (!ValidateParameters(block, {"NAME", "NLGEOM", "PERTURBATION", "INC"}) || !RequireNoData(block)) { return; } @@ -1877,6 +1915,29 @@ class DomainMappingContext::Impl { "STEP NAME must not be empty when it is present."); return; } + const auto* increment_limit = Parameter(block, "INC"); + std::uint32_t maximum_increments = 100U; + if (increment_limit != nullptr) { + if (!increment_limit->value || increment_limit->value->empty()) { + InputFailure("invalid-dynamic-data", block.location, + block.canonical_name, "INC", + "INC requires a positive uint32 value."); + return; + } + std::int64_t parsed_limit = 0; + if (!ParseInteger(*increment_limit->value, parsed_limit, block.location, + block.canonical_name, true) || + parsed_limit > + static_cast(std::numeric_limits::max())) { + if (!failure_) { + InputFailure("invalid-dynamic-data", block.location, + block.canonical_name, "INC", + "INC requires a positive uint32 value."); + } + return; + } + maximum_increments = static_cast(parsed_limit); + } step_seen_ = true; in_step_ = true; step_ = {}; @@ -1887,6 +1948,7 @@ class DomainMappingContext::Impl { step_.nlgeom = nlgeom; step_.nlgeom_explicit = nlgeom_parameter != nullptr; step_.perturbation_explicit = perturbation != nullptr; + step_.maximum_increments = maximum_increments; step_load_seen_ = false; step_no_op_seen_ = false; } @@ -1897,7 +1959,7 @@ class DomainMappingContext::Impl { if (!ValidateParameters(block, {}) || !RequireNoData(block)) { return; } - if (!step_.has_static && !step_.frequency) { + if (!step_.has_static && !step_.frequency && !step_.dynamic) { InvalidKeywordLocation( block, "The sole step requires one supported procedure keyword."); return; @@ -1930,9 +1992,19 @@ class DomainMappingContext::Impl { return; } ParseFrequency(block); + } else if (block.canonical_name == "DYNAMIC") { + active_output_ = false; + if (step_.has_static || step_.frequency || step_.dynamic || + !step_.boundaries.empty() || !step_.loads.empty() || + !step_.surface_pressure_loads.empty() || step_no_op_seen_) { + InvalidKeywordLocation( + block, "DYNAMIC must be the first procedure in the sole step."); + return; + } + ParseDynamic(block); } else if (block.canonical_name == "BOUNDARY") { active_output_ = false; - if ((!step_.has_static && !step_.frequency) || step_load_seen_ || + if ((!step_.has_static && !step_.frequency && !step_.dynamic) || step_load_seen_ || step_no_op_seen_) { InvalidKeywordLocation( block, @@ -1940,13 +2012,22 @@ class DomainMappingContext::Impl { "and no-op requests."); return; } + if (step_.dynamic && !block.parameters.empty()) { + InputFailure("unsupported-dynamic-boundary", block.location, + block.canonical_name, "", + "Dynamic BOUNDARY does not support keyword parameters."); + return; + } ParseBoundary(block, step_.boundaries); if (!failure_ && step_.frequency) { ValidateEigenBoundaries(step_.boundaries); } + if (!failure_ && step_.dynamic) { + ValidateDynamicBoundaries(step_.boundaries); + } } else if (block.canonical_name == "CLOAD") { active_output_ = false; - if (!step_.has_static || step_no_op_seen_) { + if ((!step_.has_static && !step_.dynamic) || step_no_op_seen_) { InvalidKeywordLocation( block, "CLOAD follows STATIC and boundaries and precedes no-op requests."); @@ -1959,7 +2040,7 @@ class DomainMappingContext::Impl { step_load_seen_ = !failure_; } else if (block.canonical_name == "DSLOAD") { active_output_ = false; - if (!step_.has_static || step_no_op_seen_) { + if (!step_.has_static || step_.dynamic || step_no_op_seen_) { InvalidKeywordLocation(block, "DSLOAD follows STATIC and boundaries and " "precedes no-op requests."); @@ -1969,7 +2050,7 @@ class DomainMappingContext::Impl { step_load_seen_ = !failure_; } else if (block.canonical_name == "RESTART") { active_output_ = false; - if (!step_.has_static && !step_.frequency) { + if (!step_.has_static && !step_.frequency && !step_.dynamic) { InvalidKeywordLocation( block, "Step no-op requests follow STATIC, boundaries, and loads."); return; @@ -1984,7 +2065,7 @@ class DomainMappingContext::Impl { if (!failure_) { InputFailure("unsupported-keyword", block.location, block.canonical_name, "", - "Frequency RESTART accepts only WRITE, FREQUENCY=0."); + "Dynamic and frequency RESTART accept only WRITE, FREQUENCY=0."); } return; } @@ -1993,7 +2074,7 @@ class DomainMappingContext::Impl { AddIgnoredWarning(block); } } else if (block.canonical_name == "OUTPUT") { - if (!step_.has_static && !step_.frequency) { + if (!step_.has_static && !step_.frequency && !step_.dynamic) { InvalidKeywordLocation( block, "Step no-op requests follow STATIC, boundaries, and loads."); return; @@ -2160,6 +2241,86 @@ class DomainMappingContext::Impl { } } + void ValidateDynamicBoundaries( + const std::vector& boundaries) { + for (const auto& boundary : boundaries) { + if (boundary.value != 0.0) { + InputFailure("unsupported-dynamic-boundary", boundary.location, + "BOUNDARY", boundary.target, + "Dynamic boundaries must be homogeneous and constant."); + return; + } + } + } + + void ParseDynamic(const KeywordBlock& block) { + if (step_.perturbation_explicit || !step_.nlgeom || + model_element_family_ != ElementFamily::kShell || + !ValidateParameters(block, {"DIRECT"}, "unsupported-dynamic-parameter")) { + if (!failure_) { + InputFailure("unsupported-analysis-procedure", block.location, + block.canonical_name, "", + "DYNAMIC requires the approved nonlinear shell configuration."); + } + return; + } + const auto* direct = Parameter(block, "DIRECT"); + if (direct == nullptr || direct->value || block.data.size() != 1U || + block.data[0].fields.size() < 2U || + std::any_of(block.data[0].fields.begin() + 2, + block.data[0].fields.end(), + [](const auto& field) { return !field.empty(); })) { + InputFailure("invalid-dynamic-data", block.location, block.canonical_name, + "", "DYNAMIC,DIRECT requires one row with increment and time period."); + return; + } + DynamicStepDefinition dynamic{step_.name_raw, + step_.name_canonical, + step_.location, + block.location, + block.data[0].location, + 0.0, + 0.0, + 0U, + step_.maximum_increments, + -0.05, + 0.275625, + 0.55, + {}, + {}}; + if (!ParseDynamicDouble(block.data[0].fields[0], dynamic.initial_increment, + block.data[0].location, block.canonical_name) || + !ParseDynamicDouble(block.data[0].fields[1], dynamic.time_period, + block.data[0].location, block.canonical_name) || + !(dynamic.initial_increment > 0.0) || !(dynamic.time_period > 0.0)) { + if (!failure_) { + InputFailure("invalid-dynamic-data", block.data[0].location, + block.canonical_name, "", + "Dynamic increment and time period must be finite and positive."); + } + return; + } + const double count = dynamic.time_period / dynamic.initial_increment; + const double rounded_count = std::round(count); + if (!std::isfinite(count) || rounded_count < 1.0 || + rounded_count > + static_cast(std::numeric_limits::max()) || + dynamic.initial_increment * rounded_count != dynamic.time_period) { + InputFailure("invalid-dynamic-schedule", block.data[0].location, + block.canonical_name, "", + "DYNAMIC,DIRECT requires an exact positive integer number of increments."); + return; + } + dynamic.increment_count = static_cast(rounded_count); + if (dynamic.increment_count > dynamic.maximum_increments) { + InputFailure("invalid-dynamic-schedule", block.data[0].location, + block.canonical_name, "", + "DYNAMIC,DIRECT increment count cannot exceed STEP INC."); + return; + } + step_.dynamic = std::move(dynamic); + } + void ParseStatic(const KeywordBlock& block) { if (step_.perturbation_explicit || step_.frequency) { InputFailure("unsupported-analysis-procedure", block.location, @@ -2214,7 +2375,10 @@ class DomainMappingContext::Impl { } void ParseOutputRoot(const KeywordBlock& block) { - if (!ValidateParameters(block, {"FIELD", "HISTORY", "VARIABLE"})) { + if (!ValidateParameters( + block, step_.dynamic + ? std::vector{"FIELD", "HISTORY", "VARIABLE", "FREQUENCY"} + : std::vector{"FIELD", "HISTORY", "VARIABLE"})) { return; } const auto* field = Parameter(block, "FIELD"); @@ -2237,7 +2401,22 @@ class DomainMappingContext::Impl { "Nonlinear OUTPUT requires VARIABLE=PRESELECT."); return; } - if (step_.frequency && + if (step_.dynamic) { + const auto* frequency = Parameter(block, "FREQUENCY"); + if (!ValidateParameters(block, {"FIELD", "HISTORY", "VARIABLE", "FREQUENCY"}) || + variable == nullptr || !variable->value || + !AsciiCaseInsensitiveEquals(*variable->value, "PRESELECT") || + (frequency != nullptr && + (!frequency->value || *frequency->value != "1")) || + !RequireNoData(block)) { + if (!failure_) { + InputFailure("unsupported-keyword", block.location, + block.canonical_name, "", + "Dynamic OUTPUT accepts only VARIABLE=PRESELECT, FREQUENCY=1."); + } + return; + } + } else if (step_.frequency && ((variable != nullptr && (!variable->value || !AsciiCaseInsensitiveEquals(*variable->value, "PRESELECT"))) || @@ -2288,7 +2467,9 @@ class DomainMappingContext::Impl { 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", + definition_.warnings.push_back({Severity::kWarning, + step_.dynamic ? "ignored-output-request" + : "ignored-input-keyword", block.location, block.canonical_name, "", "The allowlisted Abaqus keyword is " "ignored without semantic effect."}); @@ -2359,7 +2540,7 @@ class DomainMappingContext::Impl { void ValidateCardinality() { if (parts_.empty() || !assembly_seen_ || instances_.empty() || materials_.empty() || !step_seen_ || - (!step_.has_static && !step_.frequency)) { + (!step_.has_static && !step_.frequency && !step_.dynamic)) { const auto location = input_.blocks.empty() ? SourceLocation{input_.source_path, 0U} : input_.blocks.back().location; @@ -2410,7 +2591,7 @@ class DomainMappingContext::Impl { } void FinalizeDensities() { - if (!step_.frequency) { + if (!step_.frequency && !step_.dynamic) { if (std::any_of(materials_.begin(), materials_.end(), [](const RawMaterial& material) { return material.density.has_value(); @@ -2420,20 +2601,33 @@ class DomainMappingContext::Impl { } return; } - ValidateFrequencyPreprint(); - if (failure_) { - return; + if (step_.frequency) { + ValidateFrequencyPreprint(); + if (failure_) { + return; + } + } + if (step_.dynamic) { + ValidateDynamicPreprint(); + if (failure_) { + return; + } + for (auto& warning : definition_.warnings) { + if (warning.keyword == "PREPRINT") { + warning.code = "ignored-output-request"; + } + } } if (model_element_family_ != ElementFamily::kShell) { InputFailure("unsupported-analysis-procedure", step_.location, - "FREQUENCY", step_.name_raw, - "FREQUENCY requires the approved shell model family."); + step_.frequency ? "FREQUENCY" : "DYNAMIC", step_.name_raw, + "The procedure 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."); + "The selected procedure does not support element surfaces."); return; } for (std::size_t index = 0U; index < materials_.size(); ++index) { @@ -2449,7 +2643,7 @@ class DomainMappingContext::Impl { if (active) { ModelFailure("missing-material-density", material.location, "MATERIAL", material.name, - "Each active frequency material requires DENSITY."); + "Each active dynamic or frequency material requires DENSITY."); return; } continue; @@ -2492,6 +2686,32 @@ class DomainMappingContext::Impl { } } + void ValidateDynamicPreprint() { + 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, "", + "DYNAMIC 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, + "DYNAMIC requires PREPRINT parameters to equal NO."); + return; + } + } + } + } + void FinalizePartsAndSections() { part_section_assignments_.resize(parts_.size()); part_shell_section_assignments_.resize(parts_.size()); @@ -3213,6 +3433,12 @@ class DomainMappingContext::Impl { return; } } + if (step_.dynamic) { + ValidateDynamicBoundaries(boundaries); + if (failure_) { + return; + } + } const SourceTargetIndex target_index = BuildSourceTargetIndex(); const SourceTargetResolver target_resolver{target_index}; @@ -3250,6 +3476,12 @@ class DomainMappingContext::Impl { definition_.frequency_step = std::move(step_.frequency); return; } + if (step_.dynamic) { + step_.dynamic->boundaries = std::move(boundaries); + step_.dynamic->loads = step_.loads; + definition_.dynamic_step = std::move(step_.dynamic); + return; + } std::vector surface_pressure_loads; surface_pressure_loads.reserve(step_.surface_pressure_loads.size()); for (const auto& load : step_.surface_pressure_loads) { diff --git a/src/fesa/model/domain.cpp b/src/fesa/model/domain.cpp index a351d2d..9048948 100644 --- a/src/fesa/model/domain.cpp +++ b/src/fesa/model/domain.cpp @@ -69,6 +69,43 @@ StepDefinition::StepDefinition(FrequencyStepDefinition definition) } } +StepDefinition::StepDefinition(DynamicStepDefinition definition) + : name_{definition.source_step_name_raw}, + procedure_kind_{StaticProcedureKind::kNonlinearFullNewton}, + nlgeom_{true}, + initial_increment_{definition.initial_increment}, + time_period_{definition.time_period}, + minimum_increment_{definition.initial_increment}, + maximum_increment_{definition.initial_increment}, + location_{definition.step_location}, + dynamic_{std::move(definition)} { + for (const auto& boundary : dynamic_->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)); + } + } + loads_.reserve(dynamic_->loads.size()); + loads_view_.reserve(dynamic_->loads.size()); + for (std::size_t source_order = 0U; source_order < dynamic_->loads.size(); + ++source_order) { + auto& load = dynamic_->loads[source_order]; + auto owned = std::make_unique( + SourceTargetQuery{SourceEntityKind::kNode, "", load.target}, load.dof, + load.magnitude, source_order, std::move(load.location)); + loads_view_.push_back(std::cref(*owned)); + concentrated_loads_view_.Add(*owned); + loads_.push_back(std::move(owned)); + } +} + const std::string& StepDefinition::Name() const noexcept { return name_; } const BoundaryConditionView& StepDefinition::BoundaryConditions() @@ -98,17 +135,24 @@ StaticProcedureKind StepDefinition::ProcedureKind() const noexcept { } bool StepDefinition::IsNonlinearStatic() const noexcept { - return procedure_kind_ == StaticProcedureKind::kNonlinearFullNewton; + return !dynamic_ && + procedure_kind_ == StaticProcedureKind::kNonlinearFullNewton; } bool StepDefinition::IsFrequency() const noexcept { return frequency_.has_value(); } +bool StepDefinition::IsDynamic() const noexcept { return dynamic_.has_value(); } + const FrequencyStepDefinition* StepDefinition::Frequency() const noexcept { return frequency_ ? &*frequency_ : nullptr; } +const DynamicStepDefinition* StepDefinition::Dynamic() const noexcept { + return dynamic_ ? &*dynamic_ : nullptr; +} + bool StepDefinition::UsesNonlinearGeometry() const noexcept { return nlgeom_; } double StepDefinition::InitialIncrement() const noexcept { @@ -207,6 +251,10 @@ const FrequencyStepDefinition* Domain::Frequency() const noexcept { return steps_view_.Size() == 1U ? steps_view_[0U].Frequency() : nullptr; } +const DynamicStepDefinition* Domain::Dynamic() const noexcept { + return steps_view_.Size() == 1U ? steps_view_[0U].Dynamic() : nullptr; +} + const std::vector& Domain::Warnings() const noexcept { return definition_.warnings; } @@ -270,7 +318,8 @@ Domain::Domain(ModelDefinition definition) definition_.shell_elements.clear(); step_definitions_.reserve(definition_.steps.size() + - (definition_.frequency_step ? 1U : 0U)); + (definition_.frequency_step ? 1U : 0U) + + (definition_.dynamic_step ? 1U : 0U)); for (auto& step : definition_.steps) { auto owned = std::unique_ptr(new StepDefinition(std::move(step))); @@ -284,6 +333,12 @@ Domain::Domain(ModelDefinition definition) steps_view_.Add(*owned); step_definitions_.push_back(std::move(owned)); } + if (definition_.dynamic_step) { + auto owned = std::unique_ptr( + new StepDefinition(std::move(*definition_.dynamic_step))); + steps_view_.Add(*owned); + step_definitions_.push_back(std::move(owned)); + } } } // namespace fesa diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4a08197..58b6f91 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -38,6 +38,7 @@ add_executable( 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/hht_dynamic_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 diff --git a/tests/unit/io/abaqus/domain_mapper_test.cpp b/tests/unit/io/abaqus/domain_mapper_test.cpp index 89f0c73..07d6d86 100644 --- a/tests/unit/io/abaqus/domain_mapper_test.cpp +++ b/tests/unit/io/abaqus/domain_mapper_test.cpp @@ -660,7 +660,10 @@ TEST(InpDomainMapping, RejectsUnsupportedNonlinearProceduresAndMixedModels) { test_case.second); ASSERT_FALSE(result.HasValue()); EXPECT_EQ(result.GetStatus().Category(), fesa::FailureCategory::kInput); - ASSERT_NE(FindDiagnostic(result.GetStatus(), "unsupported-analysis-procedure"), + ASSERT_NE(FindDiagnostic(result.GetStatus(), + test_case.first == "dynamic" + ? "invalid-dynamic-data" + : "unsupported-analysis-procedure"), nullptr); } diff --git a/tests/unit/io/abaqus/hht_dynamic_domain_test.cpp b/tests/unit/io/abaqus/hht_dynamic_domain_test.cpp new file mode 100644 index 0000000..fd9f863 --- /dev/null +++ b/tests/unit/io/abaqus/hht_dynamic_domain_test.cpp @@ -0,0 +1,206 @@ +#include + +#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-hht-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 dynamic input 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()); +} + +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; +} + +std::string DynamicDeck() { + return R"inp(*Preprint, echo=NO, model=NO, history=NO, contact=NO +*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 +*Nset, nset=Tip, instance=Shell-1 +3 +*End Assembly +*Material, name=Steel +*Density +2700., +*Elastic +2.1e11, 0.3 +*Boundary +Root, 1, 6 +*Step, name=Dynamic-Load, nlgeom=YES, inc=1000 +*Dynamic, direct +0.1, 10., +*Cload +Tip, 1, -1000. +*Restart, write, frequency=0 +*Output, field, variable=PRESELECT, frequency=1 +*Output, history, variable=PRESELECT +*End Step +)inp"; +} + +std::string ReplaceOnce(std::string text, const std::string& from, + const std::string& to) { + const auto position = text.find(from); + if (position == std::string::npos) { + throw std::logic_error{"Dynamic fixture mutation source was not found."}; + } + text.replace(position, from.size(), to); + return text; +} + +std::filesystem::path RepositoryRoot() { + auto path = std::filesystem::path{__FILE__}.parent_path(); + for (int parent = 0; parent < 4; ++parent) { + path = path.parent_path(); + } + return path; +} + +TEST(HhtDynamicDomainMapping, MapsDirectScheduleDensityAndSourceOrder) { + auto result = MapText("accepted", DynamicDeck()); + ASSERT_TRUE(result.HasValue()) + << (result.HasValue() ? "" + : result.GetStatus().Diagnostics().front().message); + const fesa::Domain& domain = result.Value(); + + ASSERT_NE(domain.Dynamic(), nullptr); + const auto& dynamic = *domain.Dynamic(); + EXPECT_EQ(dynamic.source_step_name_raw, "Dynamic-Load"); + EXPECT_EQ(dynamic.source_step_name_canonical, "DYNAMIC-LOAD"); + EXPECT_DOUBLE_EQ(dynamic.initial_increment, 0.1); + EXPECT_DOUBLE_EQ(dynamic.time_period, 10.0); + EXPECT_EQ(dynamic.increment_count, 100U); + EXPECT_EQ(dynamic.maximum_increments, 1000U); + EXPECT_DOUBLE_EQ(dynamic.alpha, -0.05); + EXPECT_DOUBLE_EQ(dynamic.beta, 0.275625); + EXPECT_DOUBLE_EQ(dynamic.gamma, 0.55); + ASSERT_EQ(dynamic.boundaries.size(), 1U); + ASSERT_EQ(dynamic.loads.size(), 1U); + EXPECT_EQ(dynamic.loads[0U].dof, 1); + + ASSERT_EQ(domain.Densities().size(), 1U); + EXPECT_DOUBLE_EQ(domain.Densities()[0U].rho, 2700.0); + ASSERT_EQ(domain.Steps().Size(), 1U); + EXPECT_TRUE(domain.Steps()[0U].IsDynamic()); + EXPECT_FALSE(domain.Steps()[0U].IsNonlinearStatic()); + EXPECT_EQ(domain.Steps()[0U].Dynamic(), domain.Dynamic()); + ASSERT_EQ(domain.Warnings().size(), 4U); + for (const auto& warning : domain.Warnings()) { + EXPECT_EQ(warning.code, "ignored-output-request"); + } +} + +TEST(HhtDynamicDomainMapping, MapsOriginalReferenceDeckWithoutMutation) { + const auto input_path = RepositoryRoot() / "reference" / + "shellNL-dyn-meshfine" / "shellNL-dyn-meshfine.inp"; + auto parsed = fesa::AbaqusInputReader{}.Read(input_path); + ASSERT_TRUE(parsed.HasValue()); + auto result = fesa::AbaqusDomainMapper{}.Map(parsed.Value()); + ASSERT_TRUE(result.HasValue()) + << (result.HasValue() ? "" + : result.GetStatus().Diagnostics().front().message); + ASSERT_NE(result.Value().Dynamic(), nullptr); + EXPECT_EQ(result.Value().Dynamic()->increment_count, 100U); + EXPECT_EQ(result.Value().Dynamic()->maximum_increments, 1000U); + EXPECT_EQ(result.Value().Dynamic()->loads.size(), 3U); +} + +TEST(HhtDynamicDomainMapping, RejectsMalformedScheduleAndUnsupportedControls) { + struct InvalidCase { + std::string name; + std::string deck; + std::string diagnostic; + }; + const std::string base = DynamicDeck(); + const std::vector cases{ + {"noninteger-grid", ReplaceOnce(base, "0.1, 10.,", "0.3, 10.,"), + "invalid-dynamic-schedule"}, + {"increment-limit", ReplaceOnce(base, "inc=1000", "inc=99"), + "invalid-dynamic-schedule"}, + {"nonpositive-data", ReplaceOnce(base, "0.1, 10.,", "0., 10.,"), + "invalid-dynamic-data"}, + {"integrator-override", + ReplaceOnce(base, "*Dynamic, direct", "*Dynamic, direct, alpha=-0.1"), + "unsupported-dynamic-parameter"}, + {"rotational-load", ReplaceOnce(base, "Tip, 1, -1000.", "Tip, 4, 0."), + "unsupported-dynamic-load"}, + {"nonzero-boundary", ReplaceOnce(base, "Root, 1, 6", "Root, 1, 6, 1."), + "unsupported-dynamic-boundary"}, + {"amplitude-boundary", + ReplaceOnce(base, "*Boundary\n", "*Boundary, amplitude=A\n"), + "unsupported-dynamic-boundary"}, + {"missing-density", ReplaceOnce(base, "*Density\n2700.,\n", ""), + "missing-material-density"}, + {"unresolved-load", + ReplaceOnce(base, "Tip, 1, -1000.", "Missing, 1, -1000."), + "unresolved-reference"}}; + + for (const auto& test_case : cases) { + SCOPED_TRACE(test_case.name); + auto result = MapText(test_case.name, test_case.deck); + ASSERT_FALSE(result.HasValue()); + ASSERT_NE(FindDiagnostic(result.GetStatus(), test_case.diagnostic), + nullptr); + } +} + +} // namespace