From a21c11a8251b45ec99f3a4fa019918a4d37c867c Mon Sep 17 00:00:00 2001 From: "KOKO\\Mimi" Date: Tue, 29 Sep 2026 20:09:54 +0900 Subject: [PATCH 1/5] feat: map thermal DS4 Abaqus input --- src/fesa/io/abaqus/domain_mapping_context.cpp | 592 ++++++++++++++++-- tests/unit/io/abaqus/thermal_domain_test.cpp | 136 ++++ 2 files changed, 661 insertions(+), 67 deletions(-) create mode 100644 tests/unit/io/abaqus/thermal_domain_test.cpp diff --git a/src/fesa/io/abaqus/domain_mapping_context.cpp b/src/fesa/io/abaqus/domain_mapping_context.cpp index d9649a8..9f1cc89 100644 --- a/src/fesa/io/abaqus/domain_mapping_context.cpp +++ b/src/fesa/io/abaqus/domain_mapping_context.cpp @@ -17,6 +17,7 @@ #include #include +#include "fesa/analysis/thermal_time_grid.h" #include "fesa/core/ascii.h" #include "fesa/math/vector3.h" #include "fesa/model/shell_geometry.h" @@ -49,7 +50,7 @@ struct RawNode { }; struct RawElement { - enum class Type { kB33, kS4, kS4r }; + enum class Type { kB33, kS4, kS4r, kDs4 }; std::int64_t label; std::string label_text; @@ -58,7 +59,7 @@ struct RawElement { SourceLocation location; }; -enum class ElementFamily { kBeam, kShell }; +enum class ElementFamily { kBeam, kShell, kThermalShell }; struct RawSet { std::string name; @@ -137,11 +138,33 @@ struct RawMaterial { double poisson_ratio{0.0}; bool has_elastic{false}; std::optional density; + std::optional conductivity; + std::optional specific_heat; SourceLocation location; SourceLocation elastic_location; SourceLocation density_location; }; +struct RawThermalBoundary { + std::string target; + int dof; + double value; + SourceLocation location; +}; + +struct RawThermalInitial { + std::string target; + double value; + SourceLocation location; +}; + +struct RawThermalFlux { + std::string target; + int dof; + double power; + SourceLocation location; +}; + struct RawStep { SourceLocation location; std::string name_raw; @@ -156,6 +179,11 @@ struct RawStep { std::uint32_t maximum_increments{0U}; std::array static_values{}; std::optional frequency; + bool has_heat_transfer{false}; + double thermal_increment{0.0}; + double thermal_period{0.0}; + std::vector thermal_boundaries; + std::vector thermal_fluxes; std::vector boundaries; std::vector loads; std::vector surface_pressure_loads; @@ -630,7 +658,9 @@ class DomainMappingContext::Impl { } if (block.canonical_name != "ELASTIC" && - block.canonical_name != "DENSITY") { + block.canonical_name != "DENSITY" && + block.canonical_name != "CONDUCTIVITY" && + block.canonical_name != "SPECIFIC HEAT") { material_eligible_.reset(); } pending_section_.reset(); @@ -667,6 +697,18 @@ class DomainMappingContext::Impl { AddIgnoredWarning(block); return; } + if (block.canonical_name == "PHYSICAL CONSTANTS") { + if (!ValidateParameters(block, {"ABSOLUTE ZERO"}) || + !RequireNoData(block)) + return; + const auto* absolute_zero = + 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_; + return; + } if (block.canonical_name == "PART") { ParsePartStart(block); return; @@ -691,6 +733,18 @@ class DomainMappingContext::Impl { ParseDensity(block); return; } + if (block.canonical_name == "CONDUCTIVITY") { + ParseThermalProperty(block, true); + return; + } + if (block.canonical_name == "SPECIFIC HEAT") { + ParseThermalProperty(block, false); + return; + } + if (block.canonical_name == "INITIAL CONDITIONS") { + ParseInitialConditions(block); + return; + } if (block.canonical_name == "BOUNDARY") { if (!assembly_seen_ || materials_.empty()) { InvalidKeywordLocation( @@ -698,7 +752,11 @@ class DomainMappingContext::Impl { return; } model_boundary_seen_ = true; - ParseBoundary(block, model_boundaries_); + if (model_element_family_ == ElementFamily::kThermalShell) { + ParseThermalBoundary(block, thermal_model_boundaries_); + } else { + ParseBoundary(block, model_boundaries_); + } return; } if (block.canonical_name == "STEP") { @@ -750,7 +808,9 @@ class DomainMappingContext::Impl { return; } const RawPart& part = parts_[*current_part_]; - const bool shell_part = model_element_family_ == ElementFamily::kShell; + const bool shell_part = + model_element_family_ == ElementFamily::kShell || + model_element_family_ == ElementFamily::kThermalShell; if (part.nodes.empty() || part.elements.empty() || (!shell_part && part.sections.empty())) { InvalidKeywordLocation(block, @@ -939,12 +999,16 @@ class DomainMappingContext::Impl { element_type = RawElement::Type::kS4r; element_family = ElementFamily::kShell; expected_field_count = 5U; + } else if (AsciiCaseInsensitiveEquals(*type, "DS4")) { + element_type = RawElement::Type::kDs4; + element_family = ElementFamily::kThermalShell; + expected_field_count = 5U; } else { - InputFailure( - "unsupported-element-formulation", block.location, - block.canonical_name, *type, - "Only TYPE=B33, TYPE=S4, and TYPE=S4R belong to the approved " - "element subsets."); + InputFailure("unsupported-element-formulation", block.location, + block.canonical_name, *type, + "Only TYPE=B33, TYPE=S4, TYPE=S4R, and TYPE=DS4 belong to " + "the approved " + "element subsets."); return; } if (model_element_family_ && *model_element_family_ != element_family) { @@ -992,7 +1056,7 @@ class DomainMappingContext::Impl { } element.node_labels.push_back(node_label); } - if (element_family == ElementFamily::kShell) { + if (element_family != ElementFamily::kBeam) { const std::set distinct_nodes{element.node_labels.begin(), element.node_labels.end()}; if (distinct_nodes.size() != element.node_labels.size()) { @@ -1056,13 +1120,23 @@ class DomainMappingContext::Impl { return; } if (block.data[0].fields.size() == 2U) { - if (!ParsePositiveSourceLabel(block.data[0].fields[1]).HasValue()) { + const auto integration_points = + ParsePositiveSourceLabel(block.data[0].fields[1]); + if (!integration_points.HasValue()) { InputFailure("unsupported-shell-section-option", block.data[0].location, block.canonical_name, block.data[0].fields[1], "The optional shell integration-point field must be a " "positive integer."); return; } + if (model_element_family_ == ElementFamily::kThermalShell && + integration_points.Value() != 5) { + InputFailure( + "unsupported-shell-section-option", block.data[0].location, + block.canonical_name, block.data[0].fields[1], + "Thermal DS4 sections require exactly five integration points."); + return; + } } part.shell_sections.push_back( {*element_set, *material, thickness, block.location}); @@ -1551,8 +1625,16 @@ class DomainMappingContext::Impl { *name, "Material names are globally unique."); return; } - materials_.push_back( - {*name, 0.0, 0.0, false, std::nullopt, block.location, {}, {}}); + materials_.push_back({*name, + 0.0, + 0.0, + false, + std::nullopt, + std::nullopt, + std::nullopt, + block.location, + {}, + {}}); material_eligible_ = materials_.size() - 1U; } @@ -1701,15 +1783,123 @@ class DomainMappingContext::Impl { material.density_location = block.data[0].location; } + void ParseThermalProperty(const KeywordBlock& block, bool conductivity) { + if (model_element_family_ != ElementFamily::kThermalShell || + !material_eligible_ || !ValidateParameters(block, {})) { + if (!failure_) { + InputFailure("invalid-keyword-location", block.location, + block.canonical_name, "", + "Thermal properties belong to a DS4 material."); + } + return; + } + RawMaterial& material = materials_[*material_eligible_]; + std::optional& destination = + conductivity ? material.conductivity : material.specific_heat; + if (destination || block.data.size() != 1U || + (block.data[0].fields.size() != 1U && + (block.data[0].fields.size() != 2U || + !block.data[0].fields[1].empty()))) { + InputFailure("invalid-data-arity", block.location, block.canonical_name, + material.name, + "Thermal property requires one constant row."); + 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.") || + !(value > 0.0)) { + if (!failure_) { + ModelFailure("invalid-thermal-material", block.data[0].location, + block.canonical_name, material.name, + "Thermal material values must be finite and positive."); + } + return; + } + destination = value; + } + + void ParseInitialConditions(const KeywordBlock& block) { + if (model_element_family_ != ElementFamily::kThermalShell || + !ValidateParameters(block, {"TYPE"}) || block.data.empty()) { + if (!failure_) { + InputFailure("invalid-data-arity", block.location, block.canonical_name, + "", "Thermal initial conditions require data rows."); + } + return; + } + const auto* type = RequiredParameterValue(block, "TYPE"); + if (type == nullptr || !AsciiCaseInsensitiveEquals(*type, "TEMPERATURE")) { + if (!failure_) + InputFailure("unsupported-keyword", block.location, + block.canonical_name, "TYPE", + "Only TYPE=TEMPERATURE is supported."); + return; + } + for (const auto& row : block.data) { + if (row.fields.size() != 2U || row.fields[0].empty()) { + InputFailure("invalid-data-arity", row.location, block.canonical_name, + "", "Initial temperatures require target, value."); + return; + } + double value = 0.0; + if (!ParseDouble(row.fields[1], value, row.location, + block.canonical_name)) + return; + thermal_initial_conditions_.push_back( + {row.fields[0], value, row.location}); + } + } + + void ParseThermalBoundary(const KeywordBlock& block, + std::vector& destination) { + if (!ValidateParameters(block, {}) || block.data.empty()) { + if (!failure_) + InputFailure("invalid-data-arity", block.location, block.canonical_name, + "", "Thermal BOUNDARY requires data rows."); + return; + } + for (const auto& row : block.data) { + if ((row.fields.size() != 3U && row.fields.size() != 4U) || + row.fields[0].empty()) { + InputFailure("invalid-data-arity", row.location, block.canonical_name, + "", "Thermal boundaries require target, DOF, DOF, value."); + return; + } + std::int64_t first = 0; + std::int64_t last = 0; + if (!ParseInteger(row.fields[1], first, row.location, + block.canonical_name, true) || + !ParseInteger(row.fields[2], last, row.location, block.canonical_name, + true) || + first != last || (first != 11 && first != 15)) { + InputFailure("invalid-dof", row.location, block.canonical_name, + row.fields[0], "Thermal boundaries support DOF 11 or 15."); + return; + } + double value = 0.0; + if (row.fields.size() == 4U && + !ParseDouble(row.fields[3], value, row.location, + block.canonical_name)) + return; + destination.push_back( + {row.fields[0], static_cast(first), value, row.location}); + } + } + void ParseBoundary( const KeywordBlock& block, std::vector& destination) { 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()) { + 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."); @@ -1790,7 +1980,8 @@ class DomainMappingContext::Impl { 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."); + "Dynamic CLOAD supports global translational DOF 1 " + "through 3 only."); return; } loads.push_back( @@ -1927,8 +2118,8 @@ class DomainMappingContext::Impl { 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())) { + parsed_limit > static_cast( + std::numeric_limits::max())) { if (!failure_) { InputFailure("invalid-dynamic-data", block.location, block.canonical_name, "INC", @@ -1959,7 +2150,8 @@ class DomainMappingContext::Impl { if (!ValidateParameters(block, {}) || !RequireNoData(block)) { return; } - if (!step_.has_static && !step_.frequency && !step_.dynamic) { + if (!step_.has_static && !step_.frequency && !step_.dynamic && + !step_.has_heat_transfer) { InvalidKeywordLocation( block, "The sole step requires one supported procedure keyword."); return; @@ -1973,7 +2165,17 @@ class DomainMappingContext::Impl { "A second or nested step is unsupported."); return; } - if (block.canonical_name == "STATIC") { + if (block.canonical_name == "HEAT TRANSFER") { + active_output_ = false; + if (step_.has_static || step_.frequency || step_.dynamic || + step_.has_heat_transfer || !step_.boundaries.empty() || + !step_.loads.empty() || step_no_op_seen_) { + InvalidKeywordLocation(block, + "HEAT TRANSFER must be the first procedure."); + return; + } + ParseHeatTransfer(block); + } else if (block.canonical_name == "STATIC") { active_output_ = false; if (!step_.boundaries.empty() || !step_.loads.empty() || !step_.surface_pressure_loads.empty() || step_no_op_seen_) { @@ -2004,8 +2206,9 @@ class DomainMappingContext::Impl { ParseDynamic(block); } else if (block.canonical_name == "BOUNDARY") { active_output_ = false; - if ((!step_.has_static && !step_.frequency && !step_.dynamic) || step_load_seen_ || - step_no_op_seen_) { + if ((!step_.has_static && !step_.frequency && !step_.dynamic && + !step_.has_heat_transfer) || + step_load_seen_ || step_no_op_seen_) { InvalidKeywordLocation( block, "Step boundaries follow STATIC and precede loads " @@ -2018,7 +2221,11 @@ class DomainMappingContext::Impl { "Dynamic BOUNDARY does not support keyword parameters."); return; } - ParseBoundary(block, step_.boundaries); + if (step_.has_heat_transfer) { + ParseThermalBoundary(block, step_.thermal_boundaries); + } else { + ParseBoundary(block, step_.boundaries); + } if (!failure_ && step_.frequency) { ValidateEigenBoundaries(step_.boundaries); } @@ -2038,6 +2245,15 @@ class DomainMappingContext::Impl { step_.next_load_ordinal += block.data.size(); } step_load_seen_ = !failure_; + } else if (block.canonical_name == "CFLUX") { + active_output_ = false; + if (!step_.has_heat_transfer || step_no_op_seen_) { + InvalidKeywordLocation(block, + "CFLUX follows HEAT TRANSFER and boundaries."); + return; + } + ParseCflux(block); + step_load_seen_ = !failure_; } else if (block.canonical_name == "DSLOAD") { active_output_ = false; if (!step_.has_static || step_.dynamic || step_no_op_seen_) { @@ -2050,7 +2266,8 @@ class DomainMappingContext::Impl { step_load_seen_ = !failure_; } else if (block.canonical_name == "RESTART") { active_output_ = false; - if (!step_.has_static && !step_.frequency && !step_.dynamic) { + if (!step_.has_static && !step_.frequency && !step_.dynamic && + !step_.has_heat_transfer) { InvalidKeywordLocation( block, "Step no-op requests follow STATIC, boundaries, and loads."); return; @@ -2065,7 +2282,8 @@ class DomainMappingContext::Impl { if (!failure_) { InputFailure("unsupported-keyword", block.location, block.canonical_name, "", - "Dynamic and frequency RESTART accept only WRITE, FREQUENCY=0."); + "Dynamic and frequency RESTART accept only WRITE, " + "FREQUENCY=0."); } return; } @@ -2074,7 +2292,8 @@ class DomainMappingContext::Impl { AddIgnoredWarning(block); } } else if (block.canonical_name == "OUTPUT") { - if (!step_.has_static && !step_.frequency && !step_.dynamic) { + if (!step_.has_static && !step_.frequency && !step_.dynamic && + !step_.has_heat_transfer) { InvalidKeywordLocation( block, "Step no-op requests follow STATIC, boundaries, and loads."); return; @@ -2256,11 +2475,13 @@ class DomainMappingContext::Impl { void ParseDynamic(const KeywordBlock& block) { if (step_.perturbation_explicit || !step_.nlgeom || model_element_family_ != ElementFamily::kShell || - !ValidateParameters(block, {"DIRECT"}, "unsupported-dynamic-parameter")) { + !ValidateParameters(block, {"DIRECT"}, + "unsupported-dynamic-parameter")) { if (!failure_) { - InputFailure("unsupported-analysis-procedure", block.location, - block.canonical_name, "", - "DYNAMIC requires the approved nonlinear shell configuration."); + InputFailure( + "unsupported-analysis-procedure", block.location, + block.canonical_name, "", + "DYNAMIC requires the approved nonlinear shell configuration."); } return; } @@ -2270,8 +2491,9 @@ class DomainMappingContext::Impl { 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."); + 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, @@ -2294,9 +2516,10 @@ class DomainMappingContext::Impl { 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."); + InputFailure( + "invalid-dynamic-data", block.data[0].location, + block.canonical_name, "", + "Dynamic increment and time period must be finite and positive."); } return; } @@ -2308,7 +2531,8 @@ class DomainMappingContext::Impl { 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."); + "DYNAMIC,DIRECT requires an exact positive integer number " + "of increments."); return; } dynamic.increment_count = static_cast(rounded_count); @@ -2321,6 +2545,85 @@ class DomainMappingContext::Impl { step_.dynamic = std::move(dynamic); } + void ParseHeatTransfer(const KeywordBlock& block) { + if (model_element_family_ != ElementFamily::kThermalShell || step_.nlgeom || + step_.perturbation_explicit || + !ValidateParameters(block, {"END"}, "unsupported-thermal-option") || + block.data.size() != 1U) { + if (!failure_) + InputFailure("unsupported-analysis-procedure", block.location, + block.canonical_name, "", + "HEAT TRANSFER requires the DS4 thermal subset."); + return; + } + const auto* end = Parameter(block, "END"); + if (end != nullptr && + (!end->value || !AsciiCaseInsensitiveEquals(*end->value, "PERIOD"))) { + InputFailure("unsupported-thermal-option", block.location, + block.canonical_name, "END", + "HEAT TRANSFER accepts only END=PERIOD."); + return; + } + const auto fields = WithoutTrailingEmpty(block.data[0].fields); + if (fields.size() != 2U) { + InputFailure("invalid-thermal-time-grid", block.data[0].location, + block.canonical_name, "", + "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) || + !(step_.thermal_increment > 0.0) || !(step_.thermal_period > 0.0)) { + if (!failure_) + InputFailure( + "invalid-thermal-time-grid", block.data[0].location, + block.canonical_name, "", + "Thermal increment and period must be finite and positive."); + return; + } + const auto grid = + ThermalTimeGrid::Create(step_.thermal_period, step_.thermal_increment, + step_.maximum_increments); + if (!grid.HasValue()) { + failure_ = MappingFailure{ + grid.GetStatus().Category().value_or(FailureCategory::kInput), + grid.GetStatus().Diagnostics().front()}; + return; + } + step_.has_heat_transfer = true; + } + + void ParseCflux(const KeywordBlock& block) { + if (!ValidateParameters(block, {}) || block.data.empty()) { + if (!failure_) + InputFailure("invalid-data-arity", block.location, block.canonical_name, + "", "CFLUX requires target, DOF, power rows."); + return; + } + for (const auto& row : block.data) { + if (row.fields.size() != 3U || row.fields[0].empty()) { + InputFailure("invalid-data-arity", row.location, block.canonical_name, + "", "CFLUX rows require target, DOF, power."); + return; + } + std::int64_t dof = 0; + 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) || + (dof != 11 && dof != 15)) { + InputFailure("invalid-dof", row.location, block.canonical_name, + row.fields[0], "CFLUX supports DOF 11 or 15."); + return; + } + step_.thermal_fluxes.push_back( + {row.fields[0], static_cast(dof), power, row.location}); + } + } + void ParseStatic(const KeywordBlock& block) { if (step_.perturbation_explicit || step_.frequency) { InputFailure("unsupported-analysis-procedure", block.location, @@ -2376,9 +2679,11 @@ class DomainMappingContext::Impl { void ParseOutputRoot(const KeywordBlock& block) { if (!ValidateParameters( - block, step_.dynamic - ? std::vector{"FIELD", "HISTORY", "VARIABLE", "FREQUENCY"} - : std::vector{"FIELD", "HISTORY", "VARIABLE"})) { + block, (step_.dynamic || step_.has_heat_transfer) + ? std::vector{"FIELD", "HISTORY", + "VARIABLE", "FREQUENCY"} + : std::vector{"FIELD", "HISTORY", + "VARIABLE"})) { return; } const auto* field = Parameter(block, "FIELD"); @@ -2403,24 +2708,25 @@ class DomainMappingContext::Impl { } if (step_.dynamic) { const auto* frequency = Parameter(block, "FREQUENCY"); - if (!ValidateParameters(block, {"FIELD", "HISTORY", "VARIABLE", "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."); + 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"))) || - !RequireNoData(block))) { + ((variable != nullptr && + (!variable->value || !AsciiCaseInsensitiveEquals( + *variable->value, "PRESELECT"))) || + !RequireNoData(block))) { if (!failure_) { InputFailure("unsupported-keyword", block.location, block.canonical_name, "", @@ -2467,12 +2773,12 @@ 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, - step_.dynamic ? "ignored-output-request" - : "ignored-input-keyword", - block.location, block.canonical_name, "", - "The allowlisted Abaqus keyword is " - "ignored without semantic effect."}); + 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."}); } void RejectUnknown(const KeywordBlock& block) { @@ -2540,7 +2846,8 @@ class DomainMappingContext::Impl { void ValidateCardinality() { if (parts_.empty() || !assembly_seen_ || instances_.empty() || materials_.empty() || !step_seen_ || - (!step_.has_static && !step_.frequency && !step_.dynamic)) { + (!step_.has_static && !step_.frequency && !step_.dynamic && + !step_.has_heat_transfer)) { const auto location = input_.blocks.empty() ? SourceLocation{input_.source_path, 0U} : input_.blocks.back().location; @@ -2552,6 +2859,23 @@ class DomainMappingContext::Impl { void FinalizeMaterials() { for (const auto& material : materials_) { + if (model_element_family_ == ElementFamily::kThermalShell) { + if (!material.density || !material.conductivity || + !material.specific_heat) { + ModelFailure("missing-thermal-material-property", material.location, + "MATERIAL", material.name, + "DS4 materials require DENSITY, CONDUCTIVITY, and " + "SPECIFIC HEAT."); + return; + } + definition_.thermal_materials.push_back({{"", 0, material.name}, + material.name, + *material.conductivity, + *material.density, + *material.specific_heat, + material.location}); + continue; + } if (!material.has_elastic) { InputFailure("unresolved-reference", material.location, "MATERIAL", material.name, @@ -2591,6 +2915,31 @@ class DomainMappingContext::Impl { } void FinalizeDensities() { + if (model_element_family_ == ElementFamily::kThermalShell) { + for (const auto& block : input_.blocks) { + if (block.canonical_name != "PREPRINT") continue; + if (!ValidateParameters(block, + {"ECHO", "MODEL", "HISTORY", "CONTACT"}) || + block.parameters.size() != 4U) + return; + for (const auto& parameter : block.parameters) { + if (!parameter.value || + !AsciiCaseInsensitiveEquals(*parameter.value, "NO")) { + InputFailure("unsupported-keyword", block.location, + block.canonical_name, parameter.name, + "Thermal PREPRINT parameters must equal NO."); + return; + } + } + } + return; + } + if (physical_constants_seen_) { + InputFailure("unsupported-keyword", step_.location, "PHYSICAL CONSTANTS", + "", + "PHYSICAL CONSTANTS is supported only by thermal input."); + return; + } if (!step_.frequency && !step_.dynamic) { if (std::any_of(materials_.begin(), materials_.end(), [](const RawMaterial& material) { @@ -2641,9 +2990,10 @@ class DomainMappingContext::Impl { } } if (active) { - ModelFailure("missing-material-density", material.location, - "MATERIAL", material.name, - "Each active dynamic or frequency material requires DENSITY."); + ModelFailure( + "missing-material-density", material.location, "MATERIAL", + material.name, + "Each active dynamic or frequency material requires DENSITY."); return; } continue; @@ -2696,7 +3046,8 @@ class DomainMappingContext::Impl { if (!failure_) { InputFailure("unsupported-keyword", block.location, block.canonical_name, "", - "DYNAMIC requires PREPRINT with ECHO, MODEL, HISTORY, and CONTACT parameters."); + "DYNAMIC requires PREPRINT with ECHO, MODEL, HISTORY, " + "and CONTACT parameters."); } return; } @@ -2715,7 +3066,9 @@ class DomainMappingContext::Impl { void FinalizePartsAndSections() { part_section_assignments_.resize(parts_.size()); part_shell_section_assignments_.resize(parts_.size()); - const bool shell_model = model_element_family_ == ElementFamily::kShell; + const bool shell_model = + model_element_family_ == ElementFamily::kShell || + model_element_family_ == ElementFamily::kThermalShell; for (std::size_t part_index = 0U; part_index < parts_.size(); ++part_index) { const RawPart& part = parts_[part_index]; @@ -2783,10 +3136,23 @@ class DomainMappingContext::Impl { return; } const EntityIndex section_index = - static_cast(definition_.shell_sections.size()); - definition_.shell_sections.push_back( - {section.element_set_name, section.thickness, *material_index, - section.location}); + model_element_family_ == ElementFamily::kThermalShell + ? static_cast( + definition_.thermal_sections.size()) + : static_cast(definition_.shell_sections.size()); + if (model_element_family_ == ElementFamily::kThermalShell) { + definition_.thermal_sections.push_back( + {{part.name, static_cast(section_index), + section.element_set_name}, + section.element_set_name, + section.thickness, + *material_index, + section.location}); + } else { + definition_.shell_sections.push_back( + {section.element_set_name, section.thickness, *material_index, + section.location}); + } for (const auto element_label : element_set->members) { if (!assignments .emplace(element_label, @@ -3008,6 +3374,26 @@ class DomainMappingContext::Impl { "Expanded shell section assignment must resolve exactly once."); return; } + if (raw_element.type == RawElement::Type::kDs4) { + if (definition_.thermal_elements.size() > + std::numeric_limits::max()) { + InputFailure("entity-index-overflow", raw_element.location, + "ELEMENT", raw_element.label_text, + "Expanded thermal element count exceeds EntityIndex " + "capacity."); + return; + } + index = + static_cast(definition_.thermal_elements.size()); + definition_.thermal_elements.push_back( + {{raw_instance.name, raw_element.label, raw_element.label_text}, + connected_nodes, + assignment->second.first, + raw_element.location}); + element_indices.emplace(raw_element.label, index); + instance.element_mappings.push_back({raw_element.label, index}); + continue; + } if (definition_.shell_elements.size() > std::numeric_limits::max()) { InputFailure( @@ -3309,6 +3695,9 @@ 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_) { @@ -3423,6 +3812,72 @@ class DomainMappingContext::Impl { } void FinalizeStep() { + if (step_.has_heat_transfer) { + const SourceTargetIndex target_index = BuildSourceTargetIndex(); + const SourceTargetResolver resolver{target_index}; + const auto append_boundaries = + [this, &resolver]( + const std::vector& rows, + std::map, double>* values, + std::vector* out) { + for (const auto& row : rows) { + const auto nodes = ResolveNodeTarget(resolver, row.target, + row.location, "BOUNDARY"); + if (!nodes) return false; + for (const EntityIndex node : *nodes) { + const auto key = std::make_pair(node, row.dof); + const auto existing = values->find(key); + if (existing != values->end() && + existing->second != row.value) { + return InputFailure("conflicting-boundary-condition", + row.location, "BOUNDARY", row.target, + "Expanded thermal boundaries prescribe " + "different values."); + } + (*values)[key] = row.value; + out->push_back({node, row.dof, row.value, row.location}); + } + } + return true; + }; + std::map, double> model_values; + if (!append_boundaries(thermal_model_boundaries_, &model_values, + &definition_.thermal_model_boundaries)) + return; + std::vector step_boundaries; + std::map, double> step_values; + if (!append_boundaries(step_.thermal_boundaries, &step_values, + &step_boundaries)) + return; + 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) { + definition_.thermal_initial_conditions.push_back( + {node, row.value, row.location}); + } + } + std::vector loads; + for (const auto& row : step_.thermal_fluxes) { + const auto nodes = + ResolveNodeTarget(resolver, row.target, row.location, "CFLUX"); + if (!nodes) return; + for (const EntityIndex node : *nodes) { + loads.push_back( + {node, row.dof, row.power, row.location, loads.size()}); + } + } + definition_.thermal_step = {step_.name_raw, + step_.thermal_increment, + step_.thermal_period, + step_.maximum_increments, + false, + std::move(step_boundaries), + std::move(loads), + step_.location}; + return; + } std::vector boundaries = model_boundaries_; boundaries.insert(boundaries.end(), step_.boundaries.begin(), @@ -3525,6 +3980,8 @@ class DomainMappingContext::Impl { std::vector element_surfaces_; std::vector materials_; std::vector model_boundaries_; + std::vector thermal_model_boundaries_; + std::vector thermal_initial_conditions_; RawStep step_{}; std::vector>> part_section_assignments_; @@ -3551,6 +4008,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}; }; 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 new file mode 100644 index 0000000..40106e5 --- /dev/null +++ b/tests/unit/io/abaqus/thermal_domain_test.cpp @@ -0,0 +1,136 @@ +#include + +#include +#include +#include +#include + +#include "fesa/io/abaqus/domain_mapper.h" +#include "fesa/io/abaqus/input_reader.h" + +namespace { + +class TemporaryInput final { + public: + explicit TemporaryInput(const std::string& content) + : path_{std::filesystem::temp_directory_path() / + "fesa-thermal-domain.inp"} { + std::ofstream output{path_, std::ios::binary | std::ios::trunc}; + output << content; + if (!output) throw std::runtime_error{"Unable to write thermal fixture."}; + } + ~TemporaryInput() { + 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& text) { + const TemporaryInput input{text}; + const auto parsed = fesa::AbaqusInputReader{}.Read(input.Path()); + if (!parsed.HasValue()) + return fesa::Result::Failure(parsed.GetStatus()); + return fesa::AbaqusDomainMapper{}.Map(parsed.Value()); +} + +std::string ThermalDeck() { + return R"inp(*Part, name=P +*Node +1, 0., 0., 0. +2, 1., 0., 0. +3, 1., 1., 0. +4, 0., 1., 0. +*Element, type=DS4 +1, 1, 2, 3, 4 +*Elset, elset=E +1 +*Shell Section, elset=E, material=M +.5, 5 +*End Part +*Assembly, name=A +*Instance, name=I, part=P +*End Instance +*Nset, nset=Bottom, instance=I +1 +2 +*End Assembly +*Material, name=M +*Density +7850., +*Conductivity +54., +*Specific Heat +460., +*Initial Conditions, type=TEMPERATURE +Bottom, 20. +*Boundary +Bottom, 11, 11 +*Step, name=Heat, inc=100 +*Heat Transfer +1., 100. +*Boundary +Bottom, 15, 15, 30. +*Cflux +Bottom, 11, -2. +*Cflux +Bottom, 11, -3. +*End Step +)inp"; +} + +TEST(ThermalDomainMapping, MapsDs4MaterialsTargetsAndAdditiveFluxes) { + const auto result = MapText(ThermalDeck()); + ASSERT_TRUE(result.HasValue()) + << (result.HasValue() ? "" + : result.GetStatus().Diagnostics().front().message); + const auto& domain = result.Value(); + ASSERT_EQ(domain.ThermalElements().Size(), 1U); + ASSERT_EQ(domain.ThermalMaterials().Size(), 1U); + EXPECT_DOUBLE_EQ(domain.ThermalMaterials()[0U].Conductivity(), 54.0); + ASSERT_NE(domain.ThermalStep(), nullptr); + EXPECT_EQ(domain.ThermalStep()->name, "Heat"); + EXPECT_EQ(domain.ThermalStep()->maximum_increments, 100U); + EXPECT_EQ(domain.ThermalModelBoundaries().size(), 2U); + EXPECT_EQ(domain.ThermalStep()->Boundaries().size(), 2U); + ASSERT_EQ(domain.ThermalStep()->Loads().size(), 4U); + EXPECT_DOUBLE_EQ(domain.ThermalStep()->Loads()[0U].power, -2.0); + EXPECT_DOUBLE_EQ(domain.ThermalStep()->Loads()[3U].power, -3.0); +} + +TEST(ThermalDomainMapping, RejectsInteriorAndConflictingThermalBoundaries) { + auto interior = ThermalDeck(); + interior.replace(interior.find("Bottom, 11, 11\n"), 14U, "Bottom, 12, 12\n"); + const auto invalid_dof = MapText(interior); + ASSERT_FALSE(invalid_dof.HasValue()); + EXPECT_EQ(invalid_dof.GetStatus().Diagnostics()[0U].code, "invalid-dof"); + + auto conflicting = ThermalDeck(); + conflicting.replace(conflicting.find("*Cflux"), 0U, + "*Boundary\nBottom, 15, 15, 99.\n"); + const auto invalid_conflict = MapText(conflicting); + ASSERT_FALSE(invalid_conflict.HasValue()); + EXPECT_EQ(invalid_conflict.GetStatus().Diagnostics()[0U].code, + "conflicting-boundary-condition"); +} + +TEST(ThermalDomainMapping, MapsApprovedShellThermalReference) { + auto path = std::filesystem::path{__FILE__}.parent_path(); + for (int index = 0; index < 4; ++index) path = path.parent_path(); + path /= "reference/shell-thermal/shell-thermal.inp"; + const auto parsed = fesa::AbaqusInputReader{}.Read(path); + ASSERT_TRUE(parsed.HasValue()); + const auto result = fesa::AbaqusDomainMapper{}.Map(parsed.Value()); + 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); +} + +} // namespace From 71b85379cd28c965b824b600573366dee11d0d4a Mon Sep 17 00:00:00 2001 From: "KOKO\\Mimi" Date: Tue, 29 Sep 2026 20:14:32 +0900 Subject: [PATCH 2/5] test: expand thermal input mapping coverage --- tests/unit/io/abaqus/thermal_domain_test.cpp | 61 +++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/tests/unit/io/abaqus/thermal_domain_test.cpp b/tests/unit/io/abaqus/thermal_domain_test.cpp index 40106e5..90bb15f 100644 --- a/tests/unit/io/abaqus/thermal_domain_test.cpp +++ b/tests/unit/io/abaqus/thermal_domain_test.cpp @@ -1,5 +1,6 @@ #include +#include #include #include #include @@ -14,7 +15,8 @@ class TemporaryInput final { public: explicit TemporaryInput(const std::string& content) : path_{std::filesystem::temp_directory_path() / - "fesa-thermal-domain.inp"} { + ("fesa-thermal-domain-" + std::to_string(next_id_.fetch_add(1U)) + + ".inp")} { std::ofstream output{path_, std::ios::binary | std::ios::trunc}; output << content; if (!output) throw std::runtime_error{"Unable to write thermal fixture."}; @@ -26,9 +28,12 @@ class TemporaryInput final { const std::filesystem::path& Path() const noexcept { return path_; } private: + static std::atomic next_id_; std::filesystem::path path_; }; +std::atomic TemporaryInput::next_id_{0U}; + fesa::Result MapText(const std::string& text) { const TemporaryInput input{text}; const auto parsed = fesa::AbaqusInputReader{}.Read(input.Path()); @@ -117,6 +122,60 @@ TEST(ThermalDomainMapping, RejectsInteriorAndConflictingThermalBoundaries) { "conflicting-boundary-condition"); } +TEST(ThermalDomainMapping, + AcceptsSectionPointSpellingsAndRejectsUnsupportedForms) { + const std::vector> cases{ + {".5\n", true}, {".5, 5\n", true}, {".5, 05\n", true}, + {".5, 4\n", false}, {".5, 6\n", false}, {".5, 5, 0.\n", false}}; + for (const auto& test_case : cases) { + SCOPED_TRACE(test_case.first); + auto deck = ThermalDeck(); + const auto start = deck.find(".5, 5\n"); + deck.replace(start, std::string{".5, 5\n"}.size(), test_case.first); + EXPECT_EQ(MapText(deck).HasValue(), test_case.second); + } +} + +TEST(ThermalDomainMapping, RejectsThermalMaterialAndControlForms) { + struct Case { + std::string from; + std::string to; + }; + const std::vector cases{ + {"54.,\n", "54., 1.\n"}, + {"460.,\n", "460.\n461.\n"}, + {"7850.,\n", "-1.,\n"}, + {"*Conductivity\n", "*Conductivity, dependencies=1\n"}, + {"*Specific Heat\n460.,\n", ""}, + {"*Heat Transfer\n1., 100.\n", + "*Heat Transfer, steady state\n1., 100.\n"}, + {"*Heat Transfer\n1., 100.\n", "*Heat Transfer\n0., 100.\n"}, + {"inc=100", "inc=99"}, + {"*Step, name=Heat, inc=100", "*Step, name=Heat, nlgeom=YES, inc=100"}, + {"*Step, name=Heat, inc=100", "*Step, name=Heat, perturbation, inc=100"}}; + for (const auto& test_case : cases) { + SCOPED_TRACE(test_case.to); + auto deck = ThermalDeck(); + const auto position = deck.find(test_case.from); + ASSERT_NE(position, std::string::npos); + deck.replace(position, test_case.from.size(), test_case.to); + EXPECT_FALSE(MapText(deck).HasValue()); + } +} + +TEST(ThermalDomainMapping, AppliesSameDofStepOverrideAndDefaultValues) { + auto deck = ThermalDeck(); + const auto first = deck.find("Bottom, 15, 15, 30."); + deck.replace(first, std::string{"Bottom, 15, 15, 30."}.size(), + "Bottom, 11, 11, 30."); + const auto result = MapText(deck); + ASSERT_TRUE(result.HasValue()); + ASSERT_EQ(result.Value().ThermalModelBoundaries().size(), 2U); + ASSERT_EQ(result.Value().ThermalStep()->Boundaries().size(), 2U); + EXPECT_DOUBLE_EQ(result.Value().ThermalModelBoundaries()[0U].value, 0.0); + EXPECT_DOUBLE_EQ(result.Value().ThermalStep()->Boundaries()[0U].value, 30.0); +} + TEST(ThermalDomainMapping, MapsApprovedShellThermalReference) { auto path = std::filesystem::path{__FILE__}.parent_path(); for (int index = 0; index < 4; ++index) path = path.parent_path(); From ba57a469c7ab5d59274b96b16d600183a127a5b7 Mon Sep 17 00:00:00 2001 From: "KOKO\\Mimi" Date: Tue, 29 Sep 2026 20:18:00 +0900 Subject: [PATCH 3/5] fix: tighten thermal input diagnostics --- src/fesa/io/abaqus/domain_mapping_context.cpp | 33 ++++++++++++++++--- tests/unit/io/abaqus/thermal_domain_test.cpp | 17 ++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/fesa/io/abaqus/domain_mapping_context.cpp b/src/fesa/io/abaqus/domain_mapping_context.cpp index 9f1cc89..11b636b 100644 --- a/src/fesa/io/abaqus/domain_mapping_context.cpp +++ b/src/fesa/io/abaqus/domain_mapping_context.cpp @@ -1639,6 +1639,11 @@ class DomainMappingContext::Impl { } void ParseElastic(const KeywordBlock& block) { + if (model_element_family_ == ElementFamily::kThermalShell) { + InputFailure("unsupported-keyword", block.location, block.canonical_name, + "", "ELASTIC is outside the thermal material subset."); + return; + } if (!material_eligible_) { InputFailure("invalid-keyword-location", block.location, block.canonical_name, "", @@ -2564,6 +2569,13 @@ class DomainMappingContext::Impl { "HEAT TRANSFER accepts only END=PERIOD."); return; } + if (block.data[0].fields.size() > 5U) { + InputFailure( + "invalid-thermal-time-grid", block.data[0].location, + block.canonical_name, "", + "HEAT TRANSFER permits at most three trailing empty fields."); + return; + } const auto fields = WithoutTrailingEmpty(block.data[0].fields); if (fields.size() != 2U) { InputFailure("invalid-thermal-time-grid", block.data[0].location, @@ -2631,8 +2643,9 @@ class DomainMappingContext::Impl { "PERTURBATION is supported only with FREQUENCY."); return; } - if (step_.has_static || !ValidateParameters(block, {}) || - block.data.size() != 1U || block.data[0].fields.size() != 4U) { + if (step_.has_heat_transfer || 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", @@ -2740,6 +2753,12 @@ class DomainMappingContext::Impl { } void ParseOutputChild(const KeywordBlock& block) { + if (step_.has_heat_transfer) { + InputFailure("unsupported-keyword", block.location, block.canonical_name, + "", + "Thermal output children are outside the approved subset."); + return; + } if (!active_output_) { InputFailure( "invalid-keyword-location", block.location, block.canonical_name, "", @@ -2918,10 +2937,14 @@ class DomainMappingContext::Impl { if (model_element_family_ == ElementFamily::kThermalShell) { for (const auto& block : input_.blocks) { if (block.canonical_name != "PREPRINT") continue; - if (!ValidateParameters(block, - {"ECHO", "MODEL", "HISTORY", "CONTACT"}) || - block.parameters.size() != 4U) + if (!ValidateParameters(block, {"ECHO", "MODEL", "HISTORY", "CONTACT"})) return; + if (block.parameters.size() != 4U) { + InputFailure("unsupported-keyword", block.location, + block.canonical_name, "", + "Thermal PREPRINT requires four NO parameters."); + return; + } for (const auto& parameter : block.parameters) { if (!parameter.value || !AsciiCaseInsensitiveEquals(*parameter.value, "NO")) { diff --git a/tests/unit/io/abaqus/thermal_domain_test.cpp b/tests/unit/io/abaqus/thermal_domain_test.cpp index 90bb15f..1748c4c 100644 --- a/tests/unit/io/abaqus/thermal_domain_test.cpp +++ b/tests/unit/io/abaqus/thermal_domain_test.cpp @@ -147,6 +147,7 @@ TEST(ThermalDomainMapping, RejectsThermalMaterialAndControlForms) { {"7850.,\n", "-1.,\n"}, {"*Conductivity\n", "*Conductivity, dependencies=1\n"}, {"*Specific Heat\n460.,\n", ""}, + {"*Specific Heat\n460.,\n", "*Specific Heat\n460.,\n*Elastic\n1., .3\n"}, {"*Heat Transfer\n1., 100.\n", "*Heat Transfer, steady state\n1., 100.\n"}, {"*Heat Transfer\n1., 100.\n", "*Heat Transfer\n0., 100.\n"}, @@ -163,6 +164,22 @@ TEST(ThermalDomainMapping, RejectsThermalMaterialAndControlForms) { } } +TEST(ThermalDomainMapping, RejectsThermalNoOpAndProcedureViolations) { + const std::vector> cases{ + {"*Part, name=P", + "*Preprint, echo=NO, model=NO, history=NO\n*Part, name=P"}, + {"1., 100.\n", "1., 100., , , ,\n"}, + {"*Cflux", "*Static\n.1, 1., .1, 1.\n*Cflux"}, + {"*End Step", "*Output, field\n*Node Output\nNT\n*End Step"}}; + for (const auto& test_case : cases) { + auto deck = ThermalDeck(); + const auto position = deck.find(test_case.first); + ASSERT_NE(position, std::string::npos); + deck.replace(position, test_case.first.size(), test_case.second); + EXPECT_FALSE(MapText(deck).HasValue()); + } +} + TEST(ThermalDomainMapping, AppliesSameDofStepOverrideAndDefaultValues) { auto deck = ThermalDeck(); const auto first = deck.find("Bottom, 15, 15, 30."); From 360f8df01dc6a13e266b17ca1f45f9d6823e485d Mon Sep 17 00:00:00 2001 From: "KOKO\\Mimi" Date: Tue, 29 Sep 2026 20:38:07 +0900 Subject: [PATCH 4/5] 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 From 203d068951df63cbdfdbc1b1c01d8bbe0a290b7f Mon Sep 17 00:00:00 2001 From: "KOKO\\Mimi" Date: Tue, 29 Sep 2026 20:47:08 +0900 Subject: [PATCH 5/5] fix(abaqus): map inline DS4 sets and reject beam sections --- src/fesa/io/abaqus/domain_mapping_context.cpp | 27 ++++- tests/unit/io/abaqus/thermal_domain_test.cpp | 111 ++++++++++++++++++ 2 files changed, 136 insertions(+), 2 deletions(-) diff --git a/src/fesa/io/abaqus/domain_mapping_context.cpp b/src/fesa/io/abaqus/domain_mapping_context.cpp index 7a7afe9..9a6c235 100644 --- a/src/fesa/io/abaqus/domain_mapping_context.cpp +++ b/src/fesa/io/abaqus/domain_mapping_context.cpp @@ -916,7 +916,8 @@ class DomainMappingContext::Impl { "BEAM GENERAL SECTION follows the part mesh and optional sets."); return; } - if (model_element_family_ == ElementFamily::kShell) { + if (model_element_family_ == ElementFamily::kShell || + model_element_family_ == ElementFamily::kThermalShell) { InputFailure( "unsupported-mixed-element-model", block.location, block.canonical_name, "", @@ -1006,7 +1007,14 @@ class DomainMappingContext::Impl { } void ParseElements(const KeywordBlock& block, RawPart& part) { - if (!ValidateParameters(block, {"TYPE"})) { + const auto* type_parameter = Parameter(block, "TYPE"); + const bool thermal_block = + type_parameter && type_parameter->value && + AsciiCaseInsensitiveEquals(*type_parameter->value, "DS4"); + if (!ValidateParameters(block, + thermal_block + ? std::vector{"TYPE", "ELSET"} + : std::vector{"TYPE"})) { return; } const auto* type = RequiredParameterValue(block, "TYPE"); @@ -1047,6 +1055,17 @@ class DomainMappingContext::Impl { return; } model_element_family_ = element_family; + std::optional inline_set; + if (Parameter(block, "ELSET")) { + const auto* name = RequiredParameterValue(block, "ELSET"); + if (name == nullptr) return; + if (ContainsSetName(part.element_sets, *name)) { + InputFailure("duplicate-entity", block.location, block.canonical_name, + *name, "Element-set names are unique within their part."); + return; + } + inline_set = RawSet{*name, {}, block.location}; + } if (block.data.empty()) { InputFailure(element_family == ElementFamily::kShell ? "invalid-shell-connectivity" @@ -1105,8 +1124,12 @@ class DomainMappingContext::Impl { "Element labels are unique within a part."); return; } + if (inline_set) inline_set->members.push_back(element.label); part.elements.push_back(std::move(element)); } + // Inline membership follows the block's source rows and uses the same + // part-set namespace and section resolution as an explicit ELSET. + if (inline_set) part.element_sets.push_back(std::move(*inline_set)); } void ParseShellSection(const KeywordBlock& block, RawPart& part) { diff --git a/tests/unit/io/abaqus/thermal_domain_test.cpp b/tests/unit/io/abaqus/thermal_domain_test.cpp index e0588e6..118a211 100644 --- a/tests/unit/io/abaqus/thermal_domain_test.cpp +++ b/tests/unit/io/abaqus/thermal_domain_test.cpp @@ -936,4 +936,115 @@ TEST(ThermalDomainMapping, RejectsMixedElementsAndWrongThermalMaterialScopes) { } } +TEST(ThermalDomainMapping, InlineElementSetResolvesSectionCaseInsensitively) { + auto deck = Replace(ThermalDeck(), "*Element, type=DS4", + "*Element, type=ds4, elset=inlineShells"); + deck = Replace(deck, "*Elset, elset=E\n1\n", ""); + deck = Replace(deck, "*Shell Section, elset=E", + "*Shell Section, elset=INLINESHELLS"); + 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()) + << result.GetStatus().Diagnostics().front().message; + const auto& domain = result.Value(); + ASSERT_EQ(domain.ElementSets().size(), 1U); + const auto& set = domain.ElementSets()[0]; + EXPECT_EQ(set.name, "inlineShells"); + EXPECT_EQ(set.instance_name, "I"); + EXPECT_EQ(set.element_indices, std::vector{0U}); + EXPECT_EQ(set.location.file, input.Path()); + EXPECT_EQ(set.location.line, LineOf(deck, "*Element")); + ASSERT_EQ(domain.ThermalElements().Size(), 1U); + EXPECT_EQ(domain.ThermalElements()[0].ThermalSectionIndex(), 0U); + EXPECT_EQ(domain.ThermalSections()[0].name, "INLINESHELLS"); + EXPECT_EQ(domain.ThermalSections()[0].location.line, + LineOf(deck, "*Shell Section")); +} + +TEST(ThermalDomainMapping, InlineElementSetPreservesElementRowOrder) { + auto deck = Replace(ThermalDeck(), "*Element, type=DS4\n1, 1, 2, 3, 4", + "5,2,0,0\n6,3,0,0\n7,3,1,0\n8,2,1,0\n" + "*Element, type=DS4, elset=E\n7,1,2,3,4\n3,5,6,7,8"); + deck = Replace(deck, "*Elset, elset=E\n1\n", ""); + const auto result = MapText(deck); + ASSERT_TRUE(result.HasValue()) + << result.GetStatus().Diagnostics().front().message; + const auto& domain = result.Value(); + ASSERT_EQ(domain.ElementSets().size(), 1U); + ASSERT_EQ(domain.ElementSets()[0].element_indices.size(), 2U); + EXPECT_EQ(domain.ElementSets()[0].element_indices, + (std::vector{0U, 1U})); + EXPECT_EQ(domain.ThermalElements()[0].SourceId().source_label, 7); + EXPECT_EQ(domain.ThermalElements()[1].SourceId().source_label, 3); + EXPECT_EQ(domain.ThermalElements()[0].ThermalSectionIndex(), 0U); + EXPECT_EQ(domain.ThermalElements()[1].ThermalSectionIndex(), 0U); +} + +TEST(ThermalDomainMapping, + InlineElementSetRejectsInvalidAndDuplicateDefinitions) { + auto deck = Replace(ThermalDeck(), "*Elset, elset=E\n1\n", ""); + for (const std::string option : + {"elset", "elset=", "elset=E, unknown=1", "elset=E, elset=e"}) { + SCOPED_TRACE(option); + const auto result = MapText( + Replace(deck, "*Element, type=DS4", "*Element, type=DS4, " + option)); + ASSERT_FALSE(result.HasValue()); + EXPECT_EQ(result.GetStatus().Diagnostics()[0].keyword, "ELEMENT"); + EXPECT_EQ(result.GetStatus().Diagnostics()[0].location.line, + LineOf(deck, "*Element")); + } + ExpectRejected(Replace(ThermalDeck(), "*Element, type=DS4", + "*Element, type=DS4, elset=e"), + "ELSET"); + deck = Replace(deck, "*Element, type=DS4", "*Element, type=DS4, elset=E"); + ExpectRejected( + Replace(deck, "*Shell Section", + "*Element, type=DS4, elset=e\n2,1,2,3,4\n*Shell Section"), + "ELEMENT"); + auto conflicting = + Replace(deck, "*Shell Section", "*Elset, elset=Other\n1\n*Shell Section"); + conflicting = + Replace(conflicting, "*End Part", + "*Shell Section, elset=Other, material=M\n.5\n*End Part"); + ExpectRejected(conflicting, "SHELL SECTION"); +} + +TEST(ThermalDomainMapping, RejectsBeamSectionAndBeamOnlyChildrenAtTheirSource) { + const std::string beam = + "*Beam General Section, elset=E, material=M, " + "section=GENERAL\n1,1,0,1,1\n1,0,0\n"; + for (const std::string suffix : + {"", "*Section Points\n0,0\n", "*Transverse Shear Stiffness\n1,1,1\n"}) { + const auto deck = + Replace(ThermalDeck(), "*End Part", beam + suffix + "*End Part"); + const auto result = MapText(deck); + ASSERT_FALSE(result.HasValue()); + EXPECT_EQ(result.GetStatus().Diagnostics()[0].keyword, + "BEAM GENERAL SECTION"); + EXPECT_EQ(result.GetStatus().Diagnostics()[0].location.line, + LineOf(deck, "*Beam General Section")); + } + ExpectRejected( + Replace(ThermalDeck(), "*End Part", "*Section Points\n0,0\n*End Part"), + "SECTION POINTS"); + ExpectRejected(Replace(ThermalDeck(), "*End Part", + "*Transverse Shear Stiffness\n1,1,1\n*End Part"), + "TRANSVERSE SHEAR STIFFNESS"); +} + +TEST(ThermalDomainMapping, RejectsMalformedDs4ConnectivityAndElementOptions) { + for (const std::string row : + {"1,1,2,3", "1,1,2,3,4,1", "1,1,2,3,3", "1,0,2,3,4", "1,-1,2,3,4", + "1,1.5,2,3,4", "1,1,2,3,99", "0,1,2,3,4", "1.5,1,2,3,4", + "1,1,2,3,4\n1,1,2,3,4"}) { + SCOPED_TRACE(row); + ExpectRejected(Replace(ThermalDeck(), "1, 1, 2, 3, 4", row), "ELEMENT"); + } + ExpectRejected(Replace(ThermalDeck(), "*Element, type=DS4", + "*Element, type=DS4, unknown=1"), + "ELEMENT"); +} + } // namespace