From 360f8df01dc6a13e266b17ca1f45f9d6823e485d Mon Sep 17 00:00:00 2001 From: "KOKO\\Mimi" Date: Tue, 29 Sep 2026 20:38:07 +0900 Subject: [PATCH] fix(abaqus): enforce thermal input subset and preserve provenance --- src/fesa/io/abaqus/domain_mapping_context.cpp | 204 +++-- tests/unit/io/abaqus/thermal_domain_test.cpp | 743 +++++++++++++++++- 2 files changed, 890 insertions(+), 57 deletions(-) diff --git a/src/fesa/io/abaqus/domain_mapping_context.cpp b/src/fesa/io/abaqus/domain_mapping_context.cpp index 11b636b..7a7afe9 100644 --- a/src/fesa/io/abaqus/domain_mapping_context.cpp +++ b/src/fesa/io/abaqus/domain_mapping_context.cpp @@ -432,6 +432,31 @@ class DomainMappingContext::Impl { return true; } + /// @brief Parses finite thermal decimal scalars without strtod underflow + /// flags. + /// @note from_chars preserves representable subnormals and rejects values + /// outside binary64 range; structural scalar parsing remains unchanged. + bool ParseThermalDouble(const std::string& text, double& value, + const SourceLocation& location, + const std::string& keyword) { + if (text.empty() || + (text.front() == '+' && + (text.size() == 1U || text[1U] == '+' || text[1U] == '-'))) { + return InputFailure("invalid-numeric-value", location, keyword, text, + "A thermal scalar requires finite decimal syntax."); + } + const char* begin = text.data() + (text.front() == '+' ? 1U : 0U); + const char* end = text.data() + text.size(); + const auto parsed = + std::from_chars(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, + "A thermal scalar must be representable and finite."); + } + return true; + } + bool ParseSurfacePressureDouble(const std::string& text, double& value, const SourceLocation& location, const std::string& keyword) { @@ -705,8 +730,12 @@ class DomainMappingContext::Impl { RequiredParameterValue(block, "ABSOLUTE ZERO"); if (absolute_zero == nullptr) return; double value = 0.0; - ParseDouble(*absolute_zero, value, block.location, block.canonical_name); - physical_constants_seen_ = !failure_; + if (ParseThermalDouble(*absolute_zero, value, block.location, + block.canonical_name)) { + if (!physical_constants_location_) + physical_constants_location_ = block.location; + AddIgnoredWarning(block); + } return; } if (block.canonical_name == "PART") { @@ -1107,10 +1136,15 @@ class DomainMappingContext::Impl { return; } double thickness = 0.0; - if (!ParseModelDouble(block.data[0].fields[0], thickness, - block.data[0].location, block.canonical_name, - "invalid-shell-thickness", - "Shell thickness must be finite and positive.")) { + const bool parsed_thickness = + model_element_family_ == ElementFamily::kThermalShell + ? ParseThermalDouble(block.data[0].fields[0], thickness, + block.data[0].location, block.canonical_name) + : ParseModelDouble(block.data[0].fields[0], thickness, + block.data[0].location, block.canonical_name, + "invalid-shell-thickness", + "Shell thickness must be finite and positive."); + if (!parsed_thickness) { return; } if (!(thickness > 0.0)) { @@ -1201,6 +1235,15 @@ class DomainMappingContext::Impl { } } std::set unique; + if (model_element_family_ == ElementFamily::kThermalShell) { + // Thermal sets expand each source member once, in first-seen order. + members.erase(std::remove_if(members.begin(), members.end(), + [&unique](const auto member) { + return !unique.insert(member).second; + }), + members.end()); + return true; + } for (const auto member : members) { if (!unique.insert(member).second) { return InputFailure("duplicate-entity", block.location, @@ -1604,6 +1647,8 @@ class DomainMappingContext::Impl { surface.members.push_back({row.fields[0], side, row.location, ordinal}); } element_surfaces_.push_back(std::move(surface)); + if (model_element_family_ == ElementFamily::kThermalShell) + AddIgnoredWarning(block); } void ParseMaterial(const KeywordBlock& block) { @@ -1811,10 +1856,8 @@ class DomainMappingContext::Impl { return; } double value = 0.0; - if (!ParseModelDouble( - block.data[0].fields[0], value, block.data[0].location, - block.canonical_name, "invalid-thermal-material", - "Thermal material values must be finite and positive.") || + if (!ParseThermalDouble(block.data[0].fields[0], value, + block.data[0].location, block.canonical_name) || !(value > 0.0)) { if (!failure_) { ModelFailure("invalid-thermal-material", block.data[0].location, @@ -1850,8 +1893,8 @@ class DomainMappingContext::Impl { return; } double value = 0.0; - if (!ParseDouble(row.fields[1], value, row.location, - block.canonical_name)) + if (!ParseThermalDouble(row.fields[1], value, row.location, + block.canonical_name)) return; thermal_initial_conditions_.push_back( {row.fields[0], value, row.location}); @@ -1886,8 +1929,8 @@ class DomainMappingContext::Impl { } double value = 0.0; if (row.fields.size() == 4U && - !ParseDouble(row.fields[3], value, row.location, - block.canonical_name)) + !ParseThermalDouble(row.fields[3], value, row.location, + block.canonical_name)) return; destination.push_back( {row.fields[0], static_cast(first), value, row.location}); @@ -2150,6 +2193,15 @@ class DomainMappingContext::Impl { } void ParseStepBlock(const KeywordBlock& block) { + if (model_element_family_ == ElementFamily::kThermalShell && + (block.canonical_name == "STATIC" || + block.canonical_name == "FREQUENCY" || + block.canonical_name == "DYNAMIC")) { + InputFailure("unsupported-analysis-procedure", block.location, + block.canonical_name, step_.name_raw, + "DS4 supports only one HEAT TRANSFER procedure."); + return; + } if (block.canonical_name == "END STEP") { active_output_ = false; if (!ValidateParameters(block, {}) || !RequireNoData(block)) { @@ -2278,7 +2330,7 @@ class DomainMappingContext::Impl { return; } step_no_op_seen_ = true; - if (step_.frequency) { + if (step_.frequency || step_.has_heat_transfer) { const auto* write = Parameter(block, "WRITE"); const auto* frequency = Parameter(block, "FREQUENCY"); if (!ValidateParameters(block, {"WRITE", "FREQUENCY"}) || @@ -2583,10 +2635,10 @@ class DomainMappingContext::Impl { "HEAT TRANSFER requires increment and time period."); return; } - if (!ParseDouble(fields[0], step_.thermal_increment, block.data[0].location, - block.canonical_name) || - !ParseDouble(fields[1], step_.thermal_period, block.data[0].location, - block.canonical_name) || + if (!ParseThermalDouble(fields[0], step_.thermal_increment, + block.data[0].location, block.canonical_name) || + !ParseThermalDouble(fields[1], step_.thermal_period, + block.data[0].location, block.canonical_name) || !(step_.thermal_increment > 0.0) || !(step_.thermal_period > 0.0)) { if (!failure_) InputFailure( @@ -2599,9 +2651,13 @@ class DomainMappingContext::Impl { ThermalTimeGrid::Create(step_.thermal_period, step_.thermal_increment, step_.maximum_increments); if (!grid.HasValue()) { + auto diagnostic = grid.GetStatus().Diagnostics().front(); + diagnostic.location = block.data[0].location; + diagnostic.keyword = block.canonical_name; + diagnostic.entity_identity = step_.name_raw; failure_ = MappingFailure{ grid.GetStatus().Category().value_or(FailureCategory::kInput), - grid.GetStatus().Diagnostics().front()}; + std::move(diagnostic)}; return; } step_.has_heat_transfer = true; @@ -2624,8 +2680,8 @@ class DomainMappingContext::Impl { double power = 0.0; if (!ParseInteger(row.fields[1], dof, row.location, block.canonical_name, true) || - !ParseDouble(row.fields[2], power, row.location, - block.canonical_name) || + !ParseThermalDouble(row.fields[2], power, row.location, + block.canonical_name) || (dof != 11 && dof != 15)) { InputFailure("invalid-dof", row.location, block.canonical_name, row.fields[0], "CFLUX supports DOF 11 or 15."); @@ -2691,6 +2747,27 @@ class DomainMappingContext::Impl { } void ParseOutputRoot(const KeywordBlock& block) { + if (step_.has_heat_transfer) { + const auto* field = Parameter(block, "FIELD"); + const auto* history = Parameter(block, "HISTORY"); + const auto* variable = Parameter(block, "VARIABLE"); + const auto* frequency = Parameter(block, "FREQUENCY"); + const bool valid_field = + field && !field->value && variable && variable->value && + AsciiCaseInsensitiveEquals(*variable->value, "PRESELECT"); + const bool valid_history = history && !history->value && frequency && + frequency->value && *frequency->value == "0"; + if (!RequireNoData(block)) return; + if (block.parameters.size() != 2U || (!valid_field && !valid_history)) { + InputFailure("unsupported-keyword", block.location, + block.canonical_name, "", + "Thermal OUTPUT accepts FIELD, VARIABLE=PRESELECT or " + "HISTORY, FREQUENCY=0."); + return; + } + AddIgnoredWarning(block); + return; + } if (!ValidateParameters( block, (step_.dynamic || step_.has_heat_transfer) ? std::vector{"FIELD", "HISTORY", @@ -2957,9 +3034,9 @@ class DomainMappingContext::Impl { } return; } - if (physical_constants_seen_) { - InputFailure("unsupported-keyword", step_.location, "PHYSICAL CONSTANTS", - "", + if (physical_constants_location_) { + InputFailure("unsupported-keyword", *physical_constants_location_, + "PHYSICAL CONSTANTS", "", "PHYSICAL CONSTANTS is supported only by thermal input."); return; } @@ -3556,8 +3633,10 @@ class DomainMappingContext::Impl { SurfaceMemberKind member_kind, const std::string& elset_name_raw, std::vector>* candidates) { - if (static_cast(element_index) >= - definition_.shell_elements.size()) { + const bool thermal = model_element_family_ == ElementFamily::kThermalShell; + const auto element_count = thermal ? definition_.thermal_elements.size() + : definition_.shell_elements.size(); + if (static_cast(element_index) >= element_count) { return InputFailure( "unsupported-element-formulation", member.location, "SURFACE", member.target, @@ -3569,17 +3648,23 @@ class DomainMappingContext::Impl { "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 auto& source_id = + thermal ? definition_.thermal_elements[element_index].source_id + : definition_.shell_elements[element_index].source_id; const std::string source_type = - element.source_type == ShellSourceElementType::kS4 ? "S4" : "S4R"; + thermal ? "DS4" + : (definition_.shell_elements[element_index].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_id.source_label, + source_id.source_label_text, source_type, member.side, member.location, @@ -3633,23 +3718,25 @@ class DomainMappingContext::Impl { member.target, "A direct surface member instance must resolve."); } - const auto element = std::find_if( - definition_.shell_elements.begin(), definition_.shell_elements.end(), - [&raw_instance, source_label](const Mitc4ShellDefinition& value) { - return AsciiCaseInsensitiveEquals(value.source_id.instance_name, - raw_instance->name) && - value.source_id.source_label == source_label; + const auto instance = std::find_if( + definition_.instances.begin(), definition_.instances.end(), + [&raw_instance](const InstanceDefinition& value) { + return AsciiCaseInsensitiveEquals(value.name, raw_instance->name); }); - if (element == definition_.shell_elements.end()) { + const auto element = std::find_if( + instance->element_mappings.begin(), instance->element_mappings.end(), + [source_label](const SourceIndexMapping& value) { + return value.source_label == source_label; + }); + if (element == instance->element_mappings.end()) { return InputFailure( "unresolved-reference", member.location, "SURFACE", member.target, - "A direct surface member must resolve to an S4 or S4R."); + "A direct surface member must resolve to a supported shell element."); } return AppendSurfaceFacet(member, *raw_instance, static_cast(std::distance( instances_.begin(), raw_instance)), - static_cast(std::distance( - definition_.shell_elements.begin(), element)), + element->internal_index, SurfaceMemberKind::kDirect, "", candidates); } @@ -3718,9 +3805,6 @@ class DomainMappingContext::Impl { } void ResolveElementSurfaces() { - if (model_element_family_ == ElementFamily::kThermalShell) { - return; - } std::vector resolved_surfaces; resolved_surfaces.reserve(element_surfaces_.size()); for (const auto& surface : element_surfaces_) { @@ -3758,10 +3842,12 @@ class DomainMappingContext::Impl { "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, - "A repeated same-side surface member was normalized once."}); + if (model_element_family_ != ElementFamily::kThermalShell) { + definition_.warnings.push_back( + {Severity::kWarning, "duplicate-surface-member-normalized", + candidate.first.first_member_location, "SURFACE", surface.name, + "A repeated same-side surface member was normalized once."}); + } continue; } unique_facets.push_back(std::move(candidate)); @@ -3829,6 +3915,12 @@ class DomainMappingContext::Impl { std::vector indices; indices.reserve(resolved.Value().size()); for (const auto& entry : resolved.Value()) { + if (model_element_family_ == ElementFamily::kThermalShell && + !thermal_active_nodes_[entry.entity_index]) { + ModelFailure("inactive-thermal-target", location, keyword, target, + "Thermal targets must connect to an active DS4 element."); + return std::nullopt; + } indices.push_back(entry.entity_index); } return indices; @@ -3836,6 +3928,10 @@ class DomainMappingContext::Impl { void FinalizeStep() { if (step_.has_heat_transfer) { + thermal_active_nodes_.assign(definition_.nodes.size(), false); + for (const auto& element : definition_.thermal_elements) + for (const auto node : element.node_indices) + thermal_active_nodes_[node] = true; const SourceTargetIndex target_index = BuildSourceTargetIndex(); const SourceTargetResolver resolver{target_index}; const auto append_boundaries = @@ -3872,11 +3968,20 @@ class DomainMappingContext::Impl { if (!append_boundaries(step_.thermal_boundaries, &step_values, &step_boundaries)) return; + std::map initial_values; for (const auto& row : thermal_initial_conditions_) { const auto nodes = ResolveNodeTarget(resolver, row.target, row.location, "INITIAL CONDITIONS"); if (!nodes) return; for (const EntityIndex node : *nodes) { + const auto inserted = initial_values.emplace(node, row.value); + if (!inserted.second && inserted.first->second != row.value) { + InputFailure( + "conflicting-initial-temperature", row.location, + "INITIAL CONDITIONS", row.target, + "Expanded initial temperatures prescribe different values."); + return; + } definition_.thermal_initial_conditions.push_back( {node, row.value, row.location}); } @@ -4005,6 +4110,7 @@ class DomainMappingContext::Impl { std::vector model_boundaries_; std::vector thermal_model_boundaries_; std::vector thermal_initial_conditions_; + std::vector thermal_active_nodes_; RawStep step_{}; std::vector>> part_section_assignments_; @@ -4031,7 +4137,7 @@ class DomainMappingContext::Impl { bool step_no_op_seen_{false}; bool active_output_{false}; bool active_output_is_field_{false}; - bool physical_constants_seen_{false}; + std::optional physical_constants_location_; }; DomainMappingContext::DomainMappingContext(const ParsedInput& input) diff --git a/tests/unit/io/abaqus/thermal_domain_test.cpp b/tests/unit/io/abaqus/thermal_domain_test.cpp index 1748c4c..e0588e6 100644 --- a/tests/unit/io/abaqus/thermal_domain_test.cpp +++ b/tests/unit/io/abaqus/thermal_domain_test.cpp @@ -1,11 +1,23 @@ #include +#include +#include #include +#include #include #include +#include +#include +#include +#include #include #include +#include +#include +#include "fesa/analysis/analysis_model.h" +#include "fesa/analysis/thermal_time_grid.h" +#include "fesa/fem/dof_manager.h" #include "fesa/io/abaqus/domain_mapper.h" #include "fesa/io/abaqus/input_reader.h" @@ -13,10 +25,15 @@ namespace { class TemporaryInput final { public: - explicit TemporaryInput(const std::string& content) - : path_{std::filesystem::temp_directory_path() / - ("fesa-thermal-domain-" + std::to_string(next_id_.fetch_add(1U)) + - ".inp")} { + explicit TemporaryInput(const std::string& content) { + // Atomically claim a directory: separate CTest processes never share files. + do { + directory_ = + std::filesystem::temp_directory_path() / + ("fesa-thermal-domain-" + std::to_string(std::random_device{}()) + + "-" + std::to_string(next_id_.fetch_add(1U))); + } while (!std::filesystem::create_directory(directory_)); + path_ = directory_ / "model.inp"; std::ofstream output{path_, std::ios::binary | std::ios::trunc}; output << content; if (!output) throw std::runtime_error{"Unable to write thermal fixture."}; @@ -24,11 +41,13 @@ class TemporaryInput final { ~TemporaryInput() { std::error_code error; std::filesystem::remove(path_, error); + std::filesystem::remove(directory_, error); } const std::filesystem::path& Path() const noexcept { return path_; } private: static std::atomic next_id_; + std::filesystem::path directory_; std::filesystem::path path_; }; @@ -87,6 +106,44 @@ Bottom, 11, -3. )inp"; } +std::string Replace(std::string deck, const std::string& from, + const std::string& to) { + const auto position = deck.find(from); + if (position == std::string::npos) + throw std::runtime_error{"Missing fixture token."}; + deck.replace(position, from.size(), to); + return deck; +} + +std::size_t LineOf(const std::string& deck, const std::string& token) { + const auto position = deck.find(token); + if (position == std::string::npos) + throw std::runtime_error{"Missing source token."}; + return 1U + static_cast( + std::count(deck.begin(), deck.begin() + position, '\n')); +} + +void ExpectRejected(const std::string& deck, const std::string& keyword = "") { + const TemporaryInput input{deck}; + const auto parsed = fesa::AbaqusInputReader{}.Read(input.Path()); + ASSERT_TRUE(parsed.HasValue()); + const auto result = fesa::AbaqusDomainMapper{}.Map(parsed.Value()); + ASSERT_FALSE(result.HasValue()); + ASSERT_FALSE(result.GetStatus().Diagnostics().empty()); + const auto& diagnostic = result.GetStatus().Diagnostics().front(); + EXPECT_EQ(diagnostic.severity, fesa::Severity::kError); + EXPECT_EQ(diagnostic.location.file, input.Path()); + EXPECT_GT(diagnostic.location.line, 0U); + if (!keyword.empty()) EXPECT_EQ(diagnostic.keyword, keyword); +} + +std::string Number(double value) { + std::ostringstream stream; + stream << std::setprecision(std::numeric_limits::max_digits10) + << value; + return stream.str(); +} + TEST(ThermalDomainMapping, MapsDs4MaterialsTargetsAndAdditiveFluxes) { const auto result = MapText(ThermalDeck()); ASSERT_TRUE(result.HasValue()) @@ -203,10 +260,680 @@ TEST(ThermalDomainMapping, MapsApprovedShellThermalReference) { ASSERT_TRUE(result.HasValue()) << (result.HasValue() ? "" : result.GetStatus().Diagnostics().front().message); - EXPECT_EQ(result.Value().Nodes().size(), 729U); - EXPECT_EQ(result.Value().ThermalElements().Size(), 676U); - ASSERT_NE(result.Value().ThermalStep(), nullptr); - EXPECT_EQ(result.Value().ThermalStep()->maximum_increments, 100000U); + const auto& domain = result.Value(); + ASSERT_EQ(domain.Nodes().size(), 729U); + ASSERT_EQ(domain.ThermalElements().Size(), 676U); + ASSERT_EQ(domain.ThermalMaterials().Size(), 1U); + ASSERT_EQ(domain.ThermalSections().size(), 1U); + ASSERT_NE(domain.ThermalStep(), nullptr); + const auto& step = *domain.ThermalStep(); + EXPECT_EQ(step.name, "Step-1"); + EXPECT_EQ(step.maximum_increments, 100000U); + EXPECT_EQ(step.nominal_increment, 1.); + EXPECT_EQ(step.time_period, 100.); + const auto grid = fesa::ThermalTimeGrid::Create( + step.time_period, step.nominal_increment, step.maximum_increments); + ASSERT_TRUE(grid.HasValue()); + EXPECT_EQ(grid.Value().Count(), 100U); + EXPECT_EQ(grid.Value().Time(37U), 37.); + EXPECT_EQ(domain.ThermalMaterials()[0].Name(), "Material-1"); + EXPECT_EQ(domain.ThermalMaterials()[0].Conductivity(), 54.); + EXPECT_EQ(domain.ThermalMaterials()[0].Density(), 7850.); + EXPECT_EQ(domain.ThermalMaterials()[0].SpecificHeat(), 460.); + EXPECT_EQ(domain.ThermalSections()[0].name, "Set-1"); + EXPECT_EQ(domain.ThermalSections()[0].thickness, .5); + EXPECT_EQ(domain.ThermalSections()[0].location.file, path); + EXPECT_EQ(domain.ThermalSections()[0].location.line, 1421U); + for (std::size_t i = 0; i < domain.Nodes().size(); ++i) { + EXPECT_EQ(domain.Nodes()[i].source_id.instance_name, "Part-1-1"); + EXPECT_EQ(domain.Nodes()[i].source_id.source_label, + static_cast(i + 1U)); + } + for (std::size_t i = 0; i < domain.ThermalElements().Size(); ++i) { + EXPECT_EQ(domain.ThermalElements()[i].SourceId().instance_name, "Part-1-1"); + EXPECT_EQ(domain.ThermalElements()[i].SourceId().source_label, + static_cast(i + 1U)); + } + std::vector expected_boundary_labels{2, 5, 6, 7}; + for (std::int64_t label = 58; label <= 105; ++label) + expected_boundary_labels.push_back(label); + for (std::int64_t label = 298; label <= 441; ++label) + expected_boundary_labels.push_back(label); + ASSERT_EQ(domain.ThermalModelBoundaries().size(), + expected_boundary_labels.size()); + ASSERT_EQ(domain.ThermalModelBoundaries().size(), step.Boundaries().size()); + for (std::size_t i = 0; i < step.Boundaries().size(); ++i) { + const auto& model_bc = domain.ThermalModelBoundaries()[i]; + const auto& step_bc = step.Boundaries()[i]; + EXPECT_EQ(model_bc.node_index, step_bc.node_index); + EXPECT_EQ(domain.Nodes()[model_bc.node_index].source_id.source_label, + expected_boundary_labels[i]); + EXPECT_EQ(model_bc.dof, 11); + EXPECT_EQ(step_bc.dof, 11); + EXPECT_EQ(model_bc.value, 0.); + EXPECT_EQ(step_bc.value, 1000.); + EXPECT_EQ(model_bc.location.file, path); + EXPECT_LT(model_bc.location.line, step_bc.location.line); + } + ASSERT_EQ(domain.ElementSurfaces().size(), 1U); + ASSERT_EQ(domain.ElementSurfaces()[0].facets.size(), 169U); + EXPECT_EQ(domain.ElementSurfaces()[0].facets.front().source_element_label, + 170); + EXPECT_EQ(domain.ElementSurfaces()[0].facets.back().source_element_label, + 338); + EXPECT_EQ(domain.ElementSurfaces()[0].facets[0].source_element_type, "DS4"); + ASSERT_EQ(domain.Warnings().size(), 6U); + for (const auto& block : parsed.Value().blocks) { + if (block.canonical_name != "PREPRINT" && + block.canonical_name != "PHYSICAL CONSTANTS" && + block.canonical_name != "SURFACE" && + block.canonical_name != "RESTART" && block.canonical_name != "OUTPUT") + continue; + EXPECT_EQ(std::count_if(domain.Warnings().begin(), domain.Warnings().end(), + [&](const auto& warning) { + return warning.location.file == path && + warning.location.line == + block.location.line && + warning.keyword == block.canonical_name; + }), + 1); + } +} + +TEST(ThermalDomainMapping, StrictOutputAndRestartNoOps) { + for (const std::string block : + {"*Output, field\n", "*Output, field, variable=ALL\n", + "*Output, field, variable=PRESELECT, frequency=0\n", + "*Output, history\n", "*Output, history, frequency=7\n", + "*Output, history, frequency=0, variable=PRESELECT\n", + "*Output, field, history, variable=PRESELECT\n", + "*Output, field=YES, variable=PRESELECT\n", + "*Output, field, variable=PRESELECT\nNT\n", + "*Output, history, frequency=0\nNT\n", "*Restart\n", + "*Restart, write\n", "*Restart, frequency=0\n", + "*Restart, write=YES, frequency=0\n", "*Restart, write, frequency=1\n", + "*Restart, write, frequency=0, overlay\n", + "*Restart, write, frequency=0\n0\n"}) { + SCOPED_TRACE(block); + ExpectRejected(Replace(ThermalDeck(), "*End Step", block + "*End Step")); + } +} + +TEST(ThermalDomainMapping, EveryAcceptedNoOpHasExactlyOneSourceWarning) { + const std::vector> blocks{ + {"*Preprint, echo=NO, model=NO, history=NO, contact=NO\n", "PREPRINT"}, + {"*Physical Constants, absolute zero=-273.15\n", "PHYSICAL CONSTANTS"}, + {"*Surface, type=ELEMENT, name=S\nI.1, SPOS\n", "SURFACE"}, + {"*Restart, write, frequency=0\n", "RESTART"}, + {"*Output, field, variable=PRESELECT\n", "OUTPUT"}, + {"*Output, history, frequency=0\n", "OUTPUT"}}; + auto deck = ThermalDeck(); + deck = blocks[0].first + deck; + deck = Replace(deck, "*Material", blocks[1].first + "*Material"); + deck = Replace(deck, "*End Assembly", blocks[2].first + "*End Assembly"); + for (std::size_t i = 3U; i < blocks.size(); ++i) + deck = Replace(deck, "*End Step", blocks[i].first + "*End Step"); + const TemporaryInput input{deck}; + const auto parsed = fesa::AbaqusInputReader{}.Read(input.Path()); + ASSERT_TRUE(parsed.HasValue()); + const auto result = fesa::AbaqusDomainMapper{}.Map(parsed.Value()); + ASSERT_TRUE(result.HasValue()); + const auto& warnings = result.Value().Warnings(); + ASSERT_EQ(warnings.size(), blocks.size()); + for (const auto& block : blocks) { + const auto line = LineOf(deck, block.first); + EXPECT_EQ(std::count_if(warnings.begin(), warnings.end(), + [&](const auto& w) { + return w.severity == fesa::Severity::kWarning && + w.keyword == block.second && + w.location.file == input.Path() && + w.location.line == line; + }), + 1); + } + EXPECT_DOUBLE_EQ( + result.Value().ThermalInitialConditions()[0].station_values[0], 20.); +} + +TEST(ThermalDomainMapping, SurfaceFacetsAreResolvedAndPreserved) { + for (const std::string side : {"SPOS", "SNEG"}) { + auto deck = Replace( + ThermalDeck(), "*End Assembly", + "*Elset, elset=Faces, instance=I\n1\n" + "*Surface, type=ELEMENT, name=Direct\nI.1, " + + side + "\n*Surface, type=ELEMENT, name=SetSurface\nFaces, " + side + + "\n*End Assembly"); + const auto result = MapText(deck); + ASSERT_TRUE(result.HasValue()); + const auto& surfaces = result.Value().ElementSurfaces(); + ASSERT_EQ(surfaces.size(), 2U); + for (std::size_t i = 0; i < surfaces.size(); ++i) { + ASSERT_EQ(surfaces[i].facets.size(), 1U); + const auto& facet = surfaces[i].facets[0]; + EXPECT_EQ(facet.element_index, 0U); + EXPECT_EQ(facet.source_element_label, 1); + EXPECT_EQ(facet.instance_name_raw, "I"); + EXPECT_EQ(facet.part_name_raw, "P"); + EXPECT_EQ(facet.source_element_type, "DS4"); + EXPECT_EQ(facet.side, side == "SPOS" ? fesa::ShellSurfaceSide::kSpos + : fesa::ShellSurfaceSide::kSneg); + EXPECT_EQ(facet.member_kind, i == 0U ? fesa::SurfaceMemberKind::kDirect + : fesa::SurfaceMemberKind::kElset); + EXPECT_EQ(facet.first_member_location.line, + LineOf(deck, (i == 0U ? "I.1, " : "Faces, ") + side)); + } + } +} + +TEST(ThermalDomainMapping, RejectsUnresolvedOrInvalidSurfaces) { + for (const std::string row : + {"Missing.1, SPOS", "I.99, SPOS", "Missing, SPOS", "E, SPOS", + "P.1, SPOS", "I.E, SPOS", "I.1, S1", "I.1", "I.1, SPOS, 1"}) { + SCOPED_TRACE(row); + ExpectRejected( + Replace(ThermalDeck(), "*End Assembly", + "*Surface, type=ELEMENT, name=S\n" + row + "\n*End Assembly"), + "SURFACE"); + } + for (const std::string header : + {"*Surface, name=S", "*Surface, type=ELEMENT", + "*Surface, type=NODE, name=S", + "*Surface, type=ELEMENT, name=S, internal"}) { + ExpectRejected(Replace(ThermalDeck(), "*End Assembly", + header + "\nI.1, SPOS\n*End Assembly"), + "SURFACE"); + } + ExpectRejected(Replace(ThermalDeck(), "*End Assembly", + "*Surface, type=ELEMENT, name=S\n*End Assembly"), + "SURFACE"); +} + +TEST(ThermalDomainMapping, + PreprintAndPhysicalConstantsAreStrictAndSourceAware) { + for (const std::string block : + {"*Preprint\n", "*Preprint, echo=NO, model=NO, history=NO\n", + "*Preprint, echo=NO, model=NO, history=NO, contact=YES\n", + "*Preprint, echo=NO, model=NO, history=NO, contact\n", + "*Preprint, echo=NO, model=NO, history=NO, contact=NO, unknown=NO\n", + "*Preprint, echo=NO, model=NO, history=NO, contact=NO\ndata\n", + "*Physical Constants\n", "*Physical Constants, absolute zero=nan\n", + "*Physical Constants, absolute zero=inf\n", + "*Physical Constants, absolute zero=\n", + "*Physical Constants, absolute zero=-273.15, stefan boltzmann=1\n", + "*Physical Constants, absolute zero=-273.15\ndata\n"}) { + SCOPED_TRACE(block); + ExpectRejected(block + ThermalDeck()); + } + auto structural = Replace(ThermalDeck(), "type=DS4", "type=S4"); + structural = + Replace(structural, + "*Density\n7850.,\n*Conductivity\n54.,\n*Specific Heat\n460.,\n", + "*Elastic\n210000., .3\n"); + structural = Replace( + structural, "*Initial Conditions, type=TEMPERATURE\nBottom, 20.\n", ""); + structural = Replace(structural, "Bottom, 11, 11", "Bottom, 1, 1"); + structural = Replace(structural, "*Heat Transfer\n1., 100.", + "*Static\n1., 1., 1., 1."); + structural = Replace(structural, + "*Boundary\nBottom, 15, 15, 30.\n*Cflux\nBottom, 11, " + "-2.\n*Cflux\nBottom, 11, -3.\n", + ""); + const std::string block = "*Physical Constants, absolute zero=-273.15\n"; + structural = block + structural; + const auto result = MapText(structural); + ASSERT_FALSE(result.HasValue()); + EXPECT_EQ(result.GetStatus().Diagnostics()[0].keyword, "PHYSICAL CONSTANTS"); + EXPECT_EQ(result.GetStatus().Diagnostics()[0].location.line, 1U); +} + +TEST(ThermalDomainMapping, RejectsChildOutputUnknownAndWrongNoOpScopes) { + const std::vector blocks{ + "*Preprint, echo=NO, model=NO, history=NO, contact=NO\n", + "*Physical Constants, absolute zero=-273.15\n", + "*Restart, write, frequency=0\n", "*Output, field, variable=PRESELECT\n", + "*Surface, type=ELEMENT, name=S\nI.1, SPOS\n"}; + for (std::size_t i = 0; i < blocks.size(); ++i) { + ExpectRejected( + Replace(ThermalDeck(), "*End Part", blocks[i] + "*End Part")); + const auto target = i < 2U || i == 4U ? "*End Step" : "*Material"; + ExpectRejected(Replace(ThermalDeck(), target, blocks[i] + target)); + } + for (const std::string child : + {"NODE OUTPUT", "ELEMENT OUTPUT", "CONTACT OUTPUT", "ENERGY OUTPUT"}) { + ExpectRejected(Replace(ThermalDeck(), "*End Step", + "*Output, field, variable=PRESELECT\n*" + child + + "\n*End Step"), + child); + } + ExpectRejected(Replace(ThermalDeck(), "*End Step", + "*Unknown Thermal Keyword\n*End Step")); +} + +TEST(ThermalDomainMapping, MaterialConstantsAndSectionLinksAreStrict) { + for (const auto& property : std::vector>{ + {"Density", "7850."}, + {"Conductivity", "54."}, + {"Specific Heat", "460."}}) { + const std::string original = + "*" + property.first + "\n" + property.second + ",\n"; + for (const std::string tail : {"\n", ",\n"}) { + const auto result = MapText( + Replace(ThermalDeck(), original, + "*" + property.first + "\n" + property.second + tail)); + ASSERT_TRUE(result.HasValue()) << property.first << tail; + } + for (const std::string value : + {"0", "-1", "nan", "inf", "1e400", "", "1, 20", "1,,", "1\n2"}) { + SCOPED_TRACE(property.first + ":" + value); + ExpectRejected(Replace(ThermalDeck(), original, + "*" + property.first + "\n" + value + "\n")); + } + for (const std::string option : {"dependencies=1", "type=ANISOTROPIC", + "type=ORTHOTROPIC", "unknown=1"}) + ExpectRejected(Replace(ThermalDeck(), "*" + property.first + "\n", + "*" + property.first + ", " + option + "\n")); + ExpectRejected(Replace(ThermalDeck(), original, ""), "MATERIAL"); + ExpectRejected(Replace(ThermalDeck(), original, original + original)); + } + for (const std::string row : + {"0", "-1", "nan", "inf", ".5,", ".5, 5.0", ".5, 3", ".5,5,", ".5\n.5"}) + ExpectRejected(Replace(ThermalDeck(), ".5, 5\n", row + "\n"), + "SHELL SECTION"); + for (const std::string option : + {"offset=0", "offset=SPOS", "composite", "nodal thickness"}) + ExpectRejected(Replace(ThermalDeck(), "*Shell Section, elset=E, material=M", + "*Shell Section, elset=E, material=M, " + option), + "SHELL SECTION"); + ExpectRejected(Replace(ThermalDeck(), "material=M", "material=Missing"), + "SHELL SECTION"); + ExpectRejected(Replace(ThermalDeck(), "*Shell Section, elset=E", + "*Shell Section, elset=Missing"), + "SHELL SECTION"); + ExpectRejected(Replace(ThermalDeck(), + "*Shell Section, elset=E, material=M\n.5, 5\n", ""), + "ELEMENT"); + ExpectRejected(Replace(ThermalDeck(), "*End Part", + "*Shell Section, elset=E, material=M\n.5\n*End Part"), + "SHELL SECTION"); +} + +TEST(ThermalDomainMapping, InitialTemperatureDefaultsAndOverlaps) { + const auto unspecified = MapText( + Replace(ThermalDeck(), + "*Initial Conditions, type=TEMPERATURE\nBottom, 20.\n", "")); + ASSERT_TRUE(unspecified.HasValue()); + EXPECT_TRUE(unspecified.Value().ThermalInitialConditions().empty()); + for (const std::string row : {"1, 20.", "Bottom, 20."}) { + const auto result = MapText( + Replace(ThermalDeck(), "Bottom, 20.\n", "Bottom, 20.\n" + row + "\n")); + ASSERT_TRUE(result.HasValue()); + for (const auto& initial : result.Value().ThermalInitialConditions()) + for (const auto temperature : initial.station_values) + EXPECT_DOUBLE_EQ(temperature, 20.); + } + ExpectRejected( + Replace(ThermalDeck(), "Bottom, 20.\n", "Bottom, 20.\n1, -20.\n"), + "INITIAL CONDITIONS"); + for (const std::string row : + {"Missing, 20.", "99, 20.", "1", "1, 20., 30.", "1, nan", ", 20."}) + ExpectRejected(Replace(ThermalDeck(), "Bottom, 20.\n", row + "\n"), + "INITIAL CONDITIONS"); + for (const std::string header : + {"*Initial Conditions", "*Initial Conditions, type=STRESS", + "*Initial Conditions, type=TEMPERATURE, amplitude=A"}) + ExpectRejected( + Replace(ThermalDeck(), "*Initial Conditions, type=TEMPERATURE", header), + "INITIAL CONDITIONS"); + ExpectRejected( + Replace(ThermalDeck(), "*End Step", + "*Initial Conditions, type=TEMPERATURE\n1, 1\n*End Step"), + "INITIAL CONDITIONS"); +} + +TEST(ThermalDomainMapping, InactiveTargetsFailWithOriginalKeywordAndIdentity) { + const auto inactive = Replace(ThermalDeck(), "*Element, type=DS4", + "99, 2., 2., 0.\n*Element, type=DS4"); + const auto accepted = MapText(inactive); + ASSERT_TRUE(accepted.HasValue()); + EXPECT_EQ(accepted.Value().Nodes().size(), 5U); + const auto view = fesa::AnalysisModel::Create(accepted.Value()); + ASSERT_TRUE(view.HasValue()); + EXPECT_EQ(view.Value().ActiveThermalNodes().size(), 4U); + for (const auto& replacement : + std::vector>{ + {"Bottom, 20.", "99, 20."}, + {"Bottom, 11, 11", "99, 11, 11"}, + {"Bottom, 15, 15, 30.", "99, 15, 15, 30."}, + {"Bottom, 11, -2.", "99, 11, -2."}}) { + const auto deck = Replace(inactive, replacement.first, replacement.second); + const auto result = MapText(deck); + ASSERT_FALSE(result.HasValue()); + const auto& diagnostic = result.GetStatus().Diagnostics()[0]; + EXPECT_EQ(diagnostic.entity_identity, "99"); + EXPECT_EQ(diagnostic.location.line, LineOf(deck, replacement.second)); + EXPECT_NE(diagnostic.keyword, "THERMAL"); + } +} + +TEST(ThermalDomainMapping, + ModelBoundaryInheritanceAndStepOverrideReachDofManager) { + auto deck = Replace(ThermalDeck(), "Bottom, 11, 11\n", + "Bottom, 11, 11, 7.\n4, 15, 15\n1, 11, 11, 7.\n"); + deck = Replace(deck, "Bottom, 15, 15, 30.", + "1, 11, 11, -30.\n1, 11, 11, -30.\n2, 15, 15"); + const auto result = MapText(deck); + ASSERT_TRUE(result.HasValue()); + const auto& domain = result.Value(); + ASSERT_EQ(domain.ThermalModelBoundaries().size(), 4U); + ASSERT_EQ(domain.ThermalStep()->Boundaries().size(), 3U); + EXPECT_DOUBLE_EQ(domain.ThermalModelBoundaries()[0].value, 7.); + EXPECT_DOUBLE_EQ(domain.ThermalStep()->Boundaries()[0].value, -30.); + const auto model = fesa::AnalysisModel::Create(domain); + ASSERT_TRUE(model.HasValue()); + const auto dofs = fesa::DofManager::CreateThermal(model.Value()); + ASSERT_TRUE(dofs.HasValue()); + const std::vector expected_dofs{0U, 5U, 9U, 19U}; + EXPECT_EQ(dofs.Value().ConstrainedDofs(), expected_dofs); + const std::array expected_values{-30., 7., 0., 0.}; + for (std::size_t i = 0; i < expected_values.size(); ++i) + EXPECT_DOUBLE_EQ(dofs.Value().PrescribedValues()[i], expected_values[i]); +} + +TEST(ThermalDomainMapping, + BoundariesRejectConflictsAndUnsupportedRowsAtBothLevels) { + for (const std::string original : + {"Bottom, 11, 11\n", "Bottom, 15, 15, 30.\n"}) { + const std::string equal = original.find("15") == std::string::npos + ? "1, 11, 11, 0.\n" + : "1, 15, 15, 30.\n"; + EXPECT_TRUE( + MapText(Replace(ThermalDeck(), original, original + equal)).HasValue()); + const std::string conflict = original.find("15") == std::string::npos + ? "1, 11, 11, 2.\n" + : "1, 15, 15, 2.\n"; + ExpectRejected(Replace(ThermalDeck(), original, original + conflict), + "BOUNDARY"); + for (const std::string row : + {"Bottom, 12, 12", "Bottom, 13, 13", "Bottom, 14, 14", + "Bottom, 11, 15", "Bottom, 11", "Bottom, 11, 11,", + "Bottom, 11, 11, 1, 2", "Bottom, 15, 15, inf", "Missing, 11, 11"}) + ExpectRejected(Replace(ThermalDeck(), original, row + "\n"), "BOUNDARY"); + for (const std::string option : {"OP=NEW", "AMPLITUDE=A"}) + ExpectRejected(Replace(ThermalDeck(), "*Boundary\n" + original, + "*Boundary, " + option + "\n" + original), + "BOUNDARY"); + } + ExpectRejected( + Replace(ThermalDeck(), "*End Part", "*Boundary\n1, 11, 11\n*End Part"), + "BOUNDARY"); + ExpectRejected(Replace(ThermalDeck(), "*Heat Transfer", + "*Boundary\n1, 11, 11\n*Heat Transfer"), + "BOUNDARY"); +} + +TEST(ThermalDomainMapping, + SignedFluxRetainsRowOrderDuplicatesAndConstrainedTargets) { + auto deck = + Replace(ThermalDeck(), "1\n2\n*End Assembly", "2, 1, 2\n*End Assembly"); + deck = Replace( + deck, "*Cflux\nBottom, 11, -2.\n*Cflux\nBottom, 11, -3.", + "*Cflux\n2, 15, 9.\nBottom, 11, -2.\n1, 11, 3.\n*Cflux\n1, 11, 3."); + const auto result = MapText(deck); + ASSERT_TRUE(result.HasValue()); + const auto& loads = result.Value().ThermalStep()->Loads(); + ASSERT_EQ(loads.size(), 5U); + const std::array nodes{1, 1, 0, 0, 0}; + const std::array dofs{15, 11, 11, 11, 11}; + const std::array powers{9., -2., -2., 3., 3.}; + for (std::size_t i = 0; i < loads.size(); ++i) { + EXPECT_EQ(loads[i].node_index, nodes[i]); + EXPECT_EQ(loads[i].dof, dofs[i]); + EXPECT_DOUBLE_EQ(loads[i].power, powers[i]); + EXPECT_EQ(loads[i].source_order, i); + } + EXPECT_EQ(loads[1].location.line, loads[2].location.line); + EXPECT_LT(loads[3].location.line, loads[4].location.line); +} + +TEST(ThermalDomainMapping, FluxRejectsOptionsRowsAndWrongScopes) { + for (const std::string row : + {"Bottom, 12, 1", "Bottom, 13, 1", "Bottom, 14, 1", "Bottom, 11", + "Bottom, 11, 1,", "Bottom, 11, inf", "Bottom, 11, nan", + "Missing, 11, 1", ", 11, 1"}) + ExpectRejected(Replace(ThermalDeck(), "Bottom, 11, -2.", row), "CFLUX"); + for (const std::string option : {"OP=NEW", "AMPLITUDE=A", "unknown=1"}) + ExpectRejected(Replace(ThermalDeck(), "*Cflux", "*Cflux, " + option), + "CFLUX"); + for (const std::string target : + {"*Part", "*End Part", "*End Assembly", "*Step", "*Heat Transfer"}) + ExpectRejected( + Replace(ThermalDeck(), target, "*Cflux\n1, 11, 1\n" + target), "CFLUX"); +} + +TEST(ThermalDomainMapping, + TimeControlsUseSharedGridIncludingInputCellBoundaries) { + struct Case { + double increment; + double period; + unsigned count; + double last; + }; + const double tiny = std::numeric_limits::denorm_min(); + const std::vector cases{ + {1., 100., 100, 1.}, + {3., 10., 4, 1.}, + {3., 2., 1, 2.}, + {2., 2., 1, 2.}, + {.3, .9, 3, .3}, + {.3, std::nextafter(.9, 1.), 4, 1.6653345369377348e-16}, + {6 * tiny, 20 * tiny, 3, 6 * tiny}, + {5 * tiny, 17 * tiny, 4, 2 * tiny}}; + for (const auto& item : cases) { + SCOPED_TRACE(Number(item.increment) + ", " + Number(item.period)); + auto deck = Replace(ThermalDeck(), "1., 100.", + Number(item.increment) + ", " + Number(item.period)); + deck = Replace(deck, "inc=100", "inc=" + std::to_string(item.count)); + const auto result = MapText(deck); + ASSERT_TRUE(result.HasValue()); + const auto& step = *result.Value().ThermalStep(); + EXPECT_EQ(step.nominal_increment, item.increment); + EXPECT_EQ(step.time_period, item.period); + const auto grid = fesa::ThermalTimeGrid::Create( + step.time_period, step.nominal_increment, step.maximum_increments); + ASSERT_TRUE(grid.HasValue()); + EXPECT_EQ(grid.Value().Count(), item.count); + EXPECT_DOUBLE_EQ(grid.Value().Time(item.count), item.period); + EXPECT_EQ(grid.Value().Delta(item.count), item.last); + if (item.count > 1U) + ExpectRejected(Replace(deck, "inc=" + std::to_string(item.count), + "inc=" + std::to_string(item.count - 1U)), + "HEAT TRANSFER"); + } + auto largest_grid = Replace(ThermalDeck(), "1., 100.", "1., 4294967295."); + largest_grid = Replace(largest_grid, "inc=100", "inc=4294967295"); + const auto upper = MapText(largest_grid); + ASSERT_TRUE(upper.HasValue()); + EXPECT_EQ(upper.Value().ThermalStep()->maximum_increments, 4294967295U); + ExpectRejected(Replace(largest_grid, "1., 4294967295.", "1., 4294967296."), + "HEAT TRANSFER"); + const auto defaults = + MapText(Replace(ThermalDeck(), ", inc=100", ", nlgeom=NO")); + ASSERT_TRUE(defaults.HasValue()); + EXPECT_EQ(defaults.Value().ThermalStep()->maximum_increments, 100U); + for (const std::string option : {"", ", end=PERIOD"}) { + for (int empty = 0; empty <= 3; ++empty) { + auto deck = Replace( + ThermalDeck(), "*Heat Transfer\n1., 100.", + "*Heat Transfer" + option + "\n1., 100." + std::string(empty, ',')); + EXPECT_TRUE(MapText(deck).HasValue()) << deck; + } + } +} + +TEST(ThermalDomainMapping, RejectsInvalidTimeAndMixedProceduresInEitherOrder) { + for (const std::string row : + {"1, 100,,,,", "1, 100, 0", "1, 100,, 2", "1", "1,", "0, 1", "-1, 1", + "1, 0", "1, -1", "nan, 1", "1, inf", "1e-300, 1e300", "1, 100\n1, 100"}) + ExpectRejected(Replace(ThermalDeck(), "1., 100.", row), "HEAT TRANSFER"); + for (const std::string option : + {"steady state", "deltmx=1", "end=SS", "unknown=1"}) + ExpectRejected( + Replace(ThermalDeck(), "*Heat Transfer", "*Heat Transfer, " + option), + "HEAT TRANSFER"); + for (const std::string option : + {"inc=0", "inc=-1", "inc=4294967296", "inc=1.5", "inc="}) + ExpectRejected(Replace(ThermalDeck(), "inc=100", option), "STEP"); + for (const std::string procedure : + {"*Static\n1, 1, 1, 1\n", "*Frequency\n1\n", + "*Dynamic, direct\n1, 100\n", "*Heat Transfer\n1, 100\n"}) { + ExpectRejected(Replace(ThermalDeck(), "*Heat Transfer\n", + procedure + "*Heat Transfer\n")); + ExpectRejected( + Replace(ThermalDeck(), "1., 100.\n", "1., 100.\n" + procedure)); + } + ExpectRejected(ThermalDeck() + "*Step\n*Heat Transfer\n1,1\n*End Step\n", + "STEP"); +} + +TEST(ThermalDomainMapping, ThermalMaterialAndSourceProvenanceArePreserved) { + const auto deck = Replace( + ThermalDeck(), "*Material, name=M", + "*Material, name=Unused\n*Density\n2\n*Conductivity\n3\n*Specific " + "Heat\n4\n*Material, name=M"); + const TemporaryInput input{deck}; + const auto parsed = fesa::AbaqusInputReader{}.Read(input.Path()); + ASSERT_TRUE(parsed.HasValue()); + const auto result = fesa::AbaqusDomainMapper{}.Map(parsed.Value()); + ASSERT_TRUE(result.HasValue()); + const auto& domain = result.Value(); + ASSERT_EQ(domain.ThermalMaterials().Size(), 2U); + ASSERT_EQ(domain.ThermalSections().size(), 1U); + const auto& section = domain.ThermalSections()[0]; + EXPECT_EQ(section.material_index, 1U); + EXPECT_EQ(section.name, "E"); + EXPECT_EQ(section.source_id.source_label_text, "E"); + EXPECT_EQ(section.location.file, input.Path()); + EXPECT_EQ(section.location.line, LineOf(deck, "*Shell Section")); + EXPECT_DOUBLE_EQ(section.thickness, .5); + const auto& material = domain.ThermalMaterials()[section.material_index]; + EXPECT_EQ(material.Name(), "M"); + EXPECT_DOUBLE_EQ(material.Conductivity(), 54.); + EXPECT_DOUBLE_EQ(material.Density(), 7850.); + EXPECT_DOUBLE_EQ(material.SpecificHeat(), 460.); + EXPECT_EQ(material.Location().line, LineOf(deck, "*Material, name=M")); + EXPECT_EQ(domain.ThermalElements()[0].SourceId().instance_name, "I"); + EXPECT_EQ(domain.ThermalElements()[0].SourceId().source_label, 1); + EXPECT_EQ(domain.ThermalStep()->name, "Heat"); + EXPECT_EQ(domain.ThermalStep()->location.file, input.Path()); + EXPECT_EQ(domain.ThermalStep()->location.line, LineOf(deck, "*Step")); +} + +TEST(ThermalDomainMapping, + FiniteThermalScalarsPreserveSubnormalAndSignedValues) { + const double tiny = std::numeric_limits::denorm_min(); + const std::string text = Number(tiny); + for (const auto& item : std::vector>{ + {"54.,", text}, + {"7850.,", text}, + {"460.,", text}, + {".5, 5", text + ",5"}, + {"Bottom, 20.", "Bottom, -" + text}, + {"Bottom, 15, 15, 30.", "Bottom, 15, 15, -" + text}, + {"Bottom, 11, -2.", "Bottom, 11, -" + text}}) { + SCOPED_TRACE(item.first); + const auto result = + MapText(Replace(ThermalDeck(), item.first, item.second)); + ASSERT_TRUE(result.HasValue()); + const auto& domain = result.Value(); + if (item.first == "54.,") + EXPECT_EQ(domain.ThermalMaterials()[0].Conductivity(), tiny); + if (item.first == "7850.,") + EXPECT_EQ(domain.ThermalMaterials()[0].Density(), tiny); + if (item.first == "460.,") + EXPECT_EQ(domain.ThermalMaterials()[0].SpecificHeat(), tiny); + if (item.first == ".5, 5") + EXPECT_EQ(domain.ThermalSections()[0].thickness, tiny); + if (item.first == "Bottom, 20.") + EXPECT_EQ(domain.ThermalInitialConditions()[0].station_values[4], -tiny); + if (item.first == "Bottom, 15, 15, 30.") + EXPECT_EQ(domain.ThermalStep()->Boundaries()[0].value, -tiny); + if (item.first == "Bottom, 11, -2.") + EXPECT_EQ(domain.ThermalStep()->Loads()[0].power, -tiny); + } + for (const std::string value : + {"+2.5", "-0", "0", "-20.25", "1.7976931348623157e308"}) { + const auto result = + MapText(Replace(ThermalDeck(), "Bottom, 20.", "Bottom, " + value)); + ASSERT_TRUE(result.HasValue()); + } + for (const std::string value : {"1e-999", "1e999", "0x1p0", "+-1", "1D2"}) { + for (const std::string original : + {"54.,", "460.,", ".5, 5", "Bottom, 20.", "Bottom, 11, -2."}) { + const auto replacement = + original == "Bottom, 20." + ? "Bottom, " + value + : (original == "Bottom, 11, -2." ? "Bottom, 11, " + value + : value); + ExpectRejected(Replace(ThermalDeck(), original, replacement)); + } + } +} + +TEST(ThermalDomainMapping, ThermalSetAndSurfaceDuplicatesExpandOnce) { + auto deck = + Replace(ThermalDeck(), "*Elset, elset=E\n1\n", "*Elset, elset=E\n1,1\n"); + deck = Replace( + deck, "*End Assembly", + "*Surface, type=ELEMENT, name=S\nI.1, SPOS\nI.1, SPOS\n*End Assembly"); + const auto result = MapText(deck); + ASSERT_TRUE(result.HasValue()); + EXPECT_EQ(result.Value().ThermalElements().Size(), 1U); + ASSERT_EQ(result.Value().ElementSurfaces().size(), 1U); + EXPECT_EQ(result.Value().ElementSurfaces()[0].facets.size(), 1U); + EXPECT_EQ(result.Value().Warnings().size(), 1U); + ExpectRejected(Replace(deck, "I.1, SPOS\nI.1, SPOS", "I.1, SPOS\nI.1, SNEG"), + "SURFACE"); +} + +TEST(ThermalDomainMapping, + DeferredFailuresRetainExactInputLocationAndIdentity) { + auto deck = Replace(ThermalDeck(), "inc=100", "inc=99"); + const TemporaryInput input{deck}; + const auto parsed = fesa::AbaqusInputReader{}.Read(input.Path()); + ASSERT_TRUE(parsed.HasValue()); + const auto result = fesa::AbaqusDomainMapper{}.Map(parsed.Value()); + ASSERT_FALSE(result.HasValue()); + const auto& diagnostic = result.GetStatus().Diagnostics()[0]; + EXPECT_EQ(diagnostic.location.file, input.Path()); + EXPECT_EQ(diagnostic.location.line, LineOf(deck, "1., 100.")); + EXPECT_EQ(diagnostic.keyword, "HEAT TRANSFER"); + EXPECT_EQ(diagnostic.entity_identity, "Heat"); + auto preprint = + std::string{"*Preprint, echo=NO, model=NO, history=NO\n"} + ThermalDeck(); + const auto missing_contact = MapText(preprint); + ASSERT_FALSE(missing_contact.HasValue()); + EXPECT_EQ(missing_contact.GetStatus().Diagnostics()[0].location.line, 1U); + EXPECT_EQ(missing_contact.GetStatus().Diagnostics()[0].keyword, "PREPRINT"); +} + +TEST(ThermalDomainMapping, RejectsMixedElementsAndWrongThermalMaterialScopes) { + ExpectRejected(Replace(ThermalDeck(), "*Elset, elset=E", + "*Element, type=S4\n2,1,2,3,4\n*Elset, elset=E"), + "ELEMENT"); + for (const std::string block : + {"*Conductivity\n54\n", "*Specific Heat\n460\n", + "*Initial Conditions, type=TEMPERATURE\n1, 1\n"}) { + ExpectRejected(Replace(ThermalDeck(), "*End Part", block + "*End Part")); + ExpectRejected( + Replace(ThermalDeck(), "*End Assembly", block + "*End Assembly")); + ExpectRejected(Replace(ThermalDeck(), "*End Step", block + "*End Step")); + } + for (const std::string property : + {"*Conductivity\n54.,\n", "*Specific Heat\n460.,\n"}) { + auto deck = Replace(ThermalDeck(), property, ""); + deck = Replace(deck, "*Material", property + "*Material"); + ExpectRejected(deck); + } } } // namespace