diff --git a/CMakeLists.txt b/CMakeLists.txt index 345d858..e86164a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -27,6 +27,8 @@ include(cmake/FesaDependencies.cmake) add_library(fesa_core STATIC src/fesa/core/version.cpp + src/fesa/model/domain.cpp + src/fesa/model/domain_builder.cpp ) target_include_directories(fesa_core diff --git a/include/fesa/model/domain.hpp b/include/fesa/model/domain.hpp index 7ec46db..49f9620 100644 --- a/include/fesa/model/domain.hpp +++ b/include/fesa/model/domain.hpp @@ -1,8 +1,11 @@ #pragma once -#include +#include +#include +#include #include -#include +#include +#include #include #include @@ -15,23 +18,14 @@ namespace fesa { +class DomainBuilder; + class Domain final { public: - Domain( - std::vector nodes, - std::vector beam_elements, - std::vector materials, - std::vector sections, - std::vector node_sets, - std::vector element_sets, - StepDefinition step) - : nodes_{std::move(nodes)}, - beam_elements_{std::move(beam_elements)}, - materials_{std::move(materials)}, - sections_{std::move(sections)}, - node_sets_{std::move(node_sets)}, - element_sets_{std::move(element_sets)}, - step_{std::move(step)} {} + Domain(const Domain&) = default; + Domain(Domain&&) noexcept = default; + Domain& operator=(const Domain&) = default; + Domain& operator=(Domain&&) noexcept = default; [[nodiscard]] std::span nodes() const noexcept { return nodes_; @@ -61,29 +55,25 @@ public: return step_; } - [[nodiscard]] const Node& node(const NodeId id) const { - const auto found = std::find_if( - nodes_.begin(), nodes_.end(), - [id](const Node& candidate) { return candidate.id == id; }); - if (found == nodes_.end()) { - throw std::out_of_range{"Node ID is not present in the Domain."}; - } - return *found; - } - - [[nodiscard]] const Node& node(const EntityOrigin& origin) const { - const auto found = std::find_if( - nodes_.begin(), nodes_.end(), [&origin](const Node& candidate) { - return candidate.origin == origin; - }); - if (found == nodes_.end()) { - throw std::out_of_range{ - "Node origin is not present in the Domain."}; - } - return *found; - } + [[nodiscard]] const Node& node(NodeId id) const; + [[nodiscard]] const Node& node(const EntityOrigin& origin) const; private: + friend class DomainBuilder; + + using OriginKey = std::pair; + + Domain( + std::vector nodes, + std::vector beam_elements, + std::vector materials, + std::vector sections, + std::vector node_sets, + std::vector element_sets, + StepDefinition step); + + [[nodiscard]] static OriginKey origin_key(const EntityOrigin& origin); + std::vector nodes_; std::vector beam_elements_; std::vector materials_; @@ -91,6 +81,12 @@ private: std::vector node_sets_; std::vector element_sets_; StepDefinition step_; + std::unordered_map node_indices_; + std::map node_origin_indices_; + std::unordered_map beam_element_indices_; + std::map beam_element_origin_indices_; + std::unordered_map material_indices_; + std::unordered_map section_indices_; }; } // namespace fesa diff --git a/include/fesa/model/domain_builder.hpp b/include/fesa/model/domain_builder.hpp new file mode 100644 index 0000000..861bef5 --- /dev/null +++ b/include/fesa/model/domain_builder.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include +#include + +#include +#include + +namespace fesa { + +struct DomainBuildResult final { + std::optional domain; + std::vector diagnostics; +}; + +class DomainBuilder final { +public: + void add_node(Node value); + void add_material(IsotropicElastic value); + void add_section(BeamSection value); + void add_beam_element(BeamElement value); + void add_node_set(NodeSet value); + void add_element_set(ElementSet value); + void set_step(StepDefinition value); + [[nodiscard]] DomainBuildResult build() &&; + +private: + std::vector nodes_; + std::vector beam_elements_; + std::vector materials_; + std::vector sections_; + std::vector node_sets_; + std::vector element_sets_; + std::optional step_; +}; + +} // namespace fesa diff --git a/src/fesa/model/domain.cpp b/src/fesa/model/domain.cpp new file mode 100644 index 0000000..fbc965d --- /dev/null +++ b/src/fesa/model/domain.cpp @@ -0,0 +1,62 @@ +#include + +#include +#include + +namespace fesa { + +Domain::Domain( + std::vector nodes, + std::vector beam_elements, + std::vector materials, + std::vector sections, + std::vector node_sets, + std::vector element_sets, + StepDefinition step) + : nodes_{std::move(nodes)}, + beam_elements_{std::move(beam_elements)}, + materials_{std::move(materials)}, + sections_{std::move(sections)}, + node_sets_{std::move(node_sets)}, + element_sets_{std::move(element_sets)}, + step_{std::move(step)} { + for (std::size_t index = 0; index < nodes_.size(); ++index) { + node_indices_.emplace(nodes_[index].id.value(), index); + node_origin_indices_.emplace(origin_key(nodes_[index].origin), index); + } + for (std::size_t index = 0; index < beam_elements_.size(); ++index) { + beam_element_indices_.emplace( + beam_elements_[index].id.value(), index); + beam_element_origin_indices_.emplace( + origin_key(beam_elements_[index].origin), index); + } + for (std::size_t index = 0; index < materials_.size(); ++index) { + material_indices_.emplace(materials_[index].id.value(), index); + } + for (std::size_t index = 0; index < sections_.size(); ++index) { + section_indices_.emplace(sections_[index].id.value(), index); + } +} + +const Node& Domain::node(const NodeId id) const { + const auto found = node_indices_.find(id.value()); + if (found == node_indices_.end()) { + throw std::out_of_range{"Node ID is not present in the Domain."}; + } + return nodes_[found->second]; +} + +const Node& Domain::node(const EntityOrigin& origin) const { + const auto found = node_origin_indices_.find(origin_key(origin)); + if (found == node_origin_indices_.end()) { + throw std::out_of_range{ + "Node origin is not present in the Domain."}; + } + return nodes_[found->second]; +} + +Domain::OriginKey Domain::origin_key(const EntityOrigin& origin) { + return {origin.instance_name, origin.local_label}; +} + +} // namespace fesa diff --git a/src/fesa/model/domain_builder.cpp b/src/fesa/model/domain_builder.cpp new file mode 100644 index 0000000..b8b6f90 --- /dev/null +++ b/src/fesa/model/domain_builder.cpp @@ -0,0 +1,477 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fesa { +namespace { + +using IndexLookup = std::unordered_map; +using OriginKey = std::pair; + +void add_error( + std::vector& diagnostics, + std::string code, + std::string message) { + diagnostics.push_back({ + DiagnosticStage::model, + Severity::error, + std::move(code), + std::move(message), + std::nullopt, + }); +} + +OriginKey origin_key(const EntityOrigin& origin) { + return {origin.instance_name, origin.local_label}; +} + +template +IndexLookup collect_ids( + const std::vector& entities, + IdAccessor id_of, + const std::string_view duplicate_code, + const std::string_view entity_name, + std::vector& diagnostics) { + IndexLookup indices; + for (std::size_t index = 0; index < entities.size(); ++index) { + const std::int64_t id = id_of(entities[index]).value(); + if (!indices.emplace(id, index).second) { + add_error( + diagnostics, + std::string{duplicate_code}, + "Duplicate " + std::string{entity_name} + + " internal ID " + std::to_string(id) + "."); + } + } + return indices; +} + +template +void collect_duplicate_origins( + const std::vector& entities, + const std::string_view duplicate_code, + const std::string_view entity_name, + std::vector& diagnostics) { + std::map origins; + for (std::size_t index = 0; index < entities.size(); ++index) { + const bool inserted = + origins.emplace(origin_key(entities[index].origin), index).second; + if (!inserted) { + const EntityOrigin& origin = entities[index].origin; + add_error( + diagnostics, + std::string{duplicate_code}, + "Duplicate " + std::string{entity_name} + " origin (" + + origin.instance_name + ", " + + std::to_string(origin.local_label) + ")."); + } + } +} + +bool has_id(const IndexLookup& indices, const std::int64_t id) { + return indices.contains(id); +} + +bool is_finite(const std::array& value) { + return std::isfinite(value[0]) && std::isfinite(value[1]); +} + +bool is_finite(const std::array& value) { + return std::ranges::all_of( + value, [](const double component) { + return std::isfinite(component); + }); +} + +void validate_section_property( + const BeamSection& section, + const double value, + const std::string_view property_name, + std::vector& diagnostics) { + if (!std::isfinite(value)) { + add_error( + diagnostics, + "model.nonfinite_value", + "Section " + std::to_string(section.id.value()) + " has a " + "nonfinite " + + std::string{property_name} + "."); + } else if (value <= 0.0) { + add_error( + diagnostics, + "model.invalid_section", + "Section " + std::to_string(section.id.value()) + " requires " + + std::string{property_name} + " > 0."); + } +} + +double length(const Vec3 value) { + return std::hypot(value.x, value.y, value.z); +} + +bool is_parallel(const Vec3 first, const Vec3 second) { + const double first_length = length(first); + const double second_length = length(second); + if (first_length == 0.0 || second_length == 0.0) { + return false; + } + + const Vec3 first_unit{ + first.x / first_length, + first.y / first_length, + first.z / first_length, + }; + const Vec3 second_unit{ + second.x / second_length, + second.y / second_length, + second.z / second_length, + }; + const Vec3 cross{ + first_unit.y * second_unit.z - + first_unit.z * second_unit.y, + first_unit.z * second_unit.x - + first_unit.x * second_unit.z, + first_unit.x * second_unit.y - + first_unit.y * second_unit.x, + }; + return length(cross) <= + 64.0 * std::numeric_limits::epsilon(); +} + +} // namespace + +void DomainBuilder::add_node(Node value) { + nodes_.push_back(std::move(value)); +} + +void DomainBuilder::add_material(IsotropicElastic value) { + materials_.push_back(std::move(value)); +} + +void DomainBuilder::add_section(BeamSection value) { + sections_.push_back(std::move(value)); +} + +void DomainBuilder::add_beam_element(BeamElement value) { + beam_elements_.push_back(std::move(value)); +} + +void DomainBuilder::add_node_set(NodeSet value) { + node_sets_.push_back(std::move(value)); +} + +void DomainBuilder::add_element_set(ElementSet value) { + element_sets_.push_back(std::move(value)); +} + +void DomainBuilder::set_step(StepDefinition value) { + step_ = std::move(value); +} + +DomainBuildResult DomainBuilder::build() && { + std::vector diagnostics; + + const IndexLookup node_indices = collect_ids( + nodes_, + [](const Node& node) { return node.id; }, + "model.duplicate_node_id", + "node", + diagnostics); + const IndexLookup material_indices = collect_ids( + materials_, + [](const IsotropicElastic& material) { return material.id; }, + "model.duplicate_material_id", + "material", + diagnostics); + const IndexLookup section_indices = collect_ids( + sections_, + [](const BeamSection& section) { return section.id; }, + "model.duplicate_section_id", + "section", + diagnostics); + const IndexLookup element_indices = collect_ids( + beam_elements_, + [](const BeamElement& element) { return element.id; }, + "model.duplicate_element_id", + "Beam element", + diagnostics); + + collect_duplicate_origins( + nodes_, + "model.duplicate_node_origin", + "node", + diagnostics); + collect_duplicate_origins( + beam_elements_, + "model.duplicate_element_origin", + "Beam element", + diagnostics); + + for (const Node& node : nodes_) { + if (!is_finite(node.position)) { + add_error( + diagnostics, + "model.nonfinite_value", + "Node " + std::to_string(node.id.value()) + + " has a nonfinite coordinate."); + } + } + + for (const IsotropicElastic& material : materials_) { + if (!std::isfinite(material.young)) { + add_error( + diagnostics, + "model.nonfinite_value", + "Material " + std::to_string(material.id.value()) + + " has a nonfinite Young's modulus."); + } else if (material.young <= 0.0) { + add_error( + diagnostics, + "model.invalid_material", + "Material " + std::to_string(material.id.value()) + + " requires E > 0."); + } + + if (!std::isfinite(material.poisson)) { + add_error( + diagnostics, + "model.nonfinite_value", + "Material " + std::to_string(material.id.value()) + + " has a nonfinite Poisson ratio."); + } else if ( + material.poisson <= -1.0 || material.poisson >= 0.5) { + add_error( + diagnostics, + "model.invalid_material", + "Material " + std::to_string(material.id.value()) + + " requires -1 < nu < 0.5."); + } + } + + for (const BeamSection& section : sections_) { + validate_section_property( + section, section.area, "A", diagnostics); + validate_section_property(section, section.iy, "Iy", diagnostics); + validate_section_property(section, section.iz, "Iz", diagnostics); + validate_section_property( + section, section.torsion_j, "J", diagnostics); + validate_section_property( + section, section.shear_area_y, "Asy", diagnostics); + validate_section_property( + section, section.shear_area_z, "Asz", diagnostics); + + if (!is_finite(section.orientation)) { + add_error( + diagnostics, + "model.nonfinite_value", + "Section " + std::to_string(section.id.value()) + + " has a nonfinite orientation."); + } else if (length(section.orientation) == 0.0) { + add_error( + diagnostics, + "model.invalid_orientation", + "Section " + std::to_string(section.id.value()) + + " has a zero orientation vector."); + } + + for (const auto& recovery_point : section.recovery_points) { + if (!is_finite(recovery_point)) { + add_error( + diagnostics, + "model.nonfinite_value", + "Section " + std::to_string(section.id.value()) + + " has a nonfinite recovery point."); + } + } + } + + for (const BeamElement& element : beam_elements_) { + const bool has_first_node = + has_id(node_indices, element.nodes[0].value()); + const bool has_second_node = + has_id(node_indices, element.nodes[1].value()); + if (!has_first_node) { + add_error( + diagnostics, + "model.missing_node_reference", + "Beam element " + std::to_string(element.id.value()) + + " references missing node " + + std::to_string(element.nodes[0].value()) + "."); + } + if (!has_second_node) { + add_error( + diagnostics, + "model.missing_node_reference", + "Beam element " + std::to_string(element.id.value()) + + " references missing node " + + std::to_string(element.nodes[1].value()) + "."); + } + if (!has_id(material_indices, element.material.value())) { + add_error( + diagnostics, + "model.missing_material_reference", + "Beam element " + std::to_string(element.id.value()) + + " references missing material " + + std::to_string(element.material.value()) + "."); + } + if (!has_id(section_indices, element.section.value())) { + add_error( + diagnostics, + "model.missing_section_reference", + "Beam element " + std::to_string(element.id.value()) + + " references missing section " + + std::to_string(element.section.value()) + "."); + } + + if (!has_first_node || !has_second_node) { + continue; + } + + const Node& first = nodes_[node_indices.at(element.nodes[0].value())]; + const Node& second = + nodes_[node_indices.at(element.nodes[1].value())]; + if (!is_finite(first.position) || !is_finite(second.position)) { + continue; + } + + const Vec3 axis{ + second.position.x - first.position.x, + second.position.y - first.position.y, + second.position.z - first.position.z, + }; + if (length(axis) == 0.0) { + add_error( + diagnostics, + "model.zero_length_element", + "Beam element " + std::to_string(element.id.value()) + + " has zero length."); + continue; + } + + const auto section_found = + section_indices.find(element.section.value()); + if (section_found == section_indices.end()) { + continue; + } + const Vec3 orientation = + sections_[section_found->second].orientation; + if (is_finite(orientation) && length(orientation) > 0.0 && + is_parallel(axis, orientation)) { + add_error( + diagnostics, + "model.invalid_orientation", + "Beam element " + std::to_string(element.id.value()) + + " has an orientation parallel to its axis."); + } + } + + for (const NodeSet& node_set : node_sets_) { + for (const NodeId member : node_set.members) { + if (!has_id(node_indices, member.value())) { + add_error( + diagnostics, + "model.missing_node_reference", + "Node set " + node_set.name + + " references missing node " + + std::to_string(member.value()) + "."); + } + } + } + + for (const ElementSet& element_set : element_sets_) { + for (const ElementId member : element_set.members) { + if (!has_id(element_indices, member.value())) { + add_error( + diagnostics, + "model.missing_element_reference", + "Element set " + element_set.name + + " references missing element " + + std::to_string(member.value()) + "."); + } + } + } + + if (!step_.has_value()) { + add_error( + diagnostics, + "model.missing_step", + "The Domain requires one linear static step."); + } else { + std::map, double> + prescribed_values; + for (const PrescribedDof& prescribed : step_->prescribed_dofs) { + if (!has_id(node_indices, prescribed.node.value())) { + add_error( + diagnostics, + "model.missing_node_reference", + "Boundary condition references missing node " + + std::to_string(prescribed.node.value()) + "."); + } + if (prescribed.dof < 1 || prescribed.dof > 6) { + add_error( + diagnostics, + "model.invalid_dof", + "Boundary condition DOF must be in [1, 6]."); + } + if (!std::isfinite(prescribed.value)) { + add_error( + diagnostics, + "model.nonfinite_value", + "Boundary condition has a nonfinite value."); + } + + const auto key = + std::pair{prescribed.node.value(), prescribed.dof}; + if (!prescribed_values.emplace(key, prescribed.value).second) { + add_error( + diagnostics, + "model.conflicting_boundary_condition", + "A node DOF has more than one prescribed value."); + } + } + + for (const NodalLoad& load : step_->nodal_loads) { + if (!has_id(node_indices, load.node.value())) { + add_error( + diagnostics, + "model.missing_node_reference", + "Concentrated load references missing node " + + std::to_string(load.node.value()) + "."); + } + if (!is_finite(load.values)) { + add_error( + diagnostics, + "model.nonfinite_value", + "Concentrated load has a nonfinite component."); + } + } + } + + if (!diagnostics.empty()) { + return {std::nullopt, std::move(diagnostics)}; + } + + return { + Domain{ + std::move(nodes_), + std::move(beam_elements_), + std::move(materials_), + std::move(sections_), + std::move(node_sets_), + std::move(element_sets_), + std::move(*step_), + }, + {}, + }; +} + +} // namespace fesa diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 17e47b6..a87e8b2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -77,6 +77,7 @@ add_test( ) add_executable(fesa_model_value_tests + unit/model/domain_builder_test.cpp unit/model/entity_origin_test.cpp unit/model/model_types_test.cpp ) @@ -99,3 +100,13 @@ add_test( NAME EntityOrigin COMMAND "$" --gtest_filter=EntityOrigin.* ) + +add_test( + NAME DomainBuilder + COMMAND "$" --gtest_filter=DomainBuilder.* +) + +add_test( + NAME DomainValidation + COMMAND "$" --gtest_filter=*DomainValidation* +) diff --git a/tests/unit/model/domain_builder_test.cpp b/tests/unit/model/domain_builder_test.cpp new file mode 100644 index 0000000..6fc2cdb --- /dev/null +++ b/tests/unit/model/domain_builder_test.cpp @@ -0,0 +1,572 @@ +#include + +#include +#include +#include +#include +#include + +#include + +namespace { + +fesa::Node first_node() { + return { + fesa::NodeId{0}, + fesa::EntityOrigin{"BeamPart", "Beam-1", 1}, + fesa::Vec3{0.0, 0.0, 0.0}, + }; +} + +fesa::Node second_node() { + return { + fesa::NodeId{1}, + fesa::EntityOrigin{"BeamPart", "Beam-1", 2}, + fesa::Vec3{2.0, 0.0, 0.0}, + }; +} + +fesa::IsotropicElastic valid_material() { + return { + fesa::MaterialId{0}, + "Steel", + 210.0e9, + 0.3, + }; +} + +fesa::BeamSection valid_section() { + return { + fesa::SectionId{0}, + "General", + 0.04, + 1.2e-4, + 1.4e-4, + 2.0e-4, + 0.03, + 0.031, + fesa::ShearPropertySource::input, + fesa::Vec3{0.0, 1.0, 0.0}, + {{-0.1, 0.0}, {0.1, 0.0}}, + }; +} + +fesa::BeamElement valid_element() { + return { + fesa::ElementId{0}, + fesa::EntityOrigin{"BeamPart", "Beam-1", 1}, + {fesa::NodeId{0}, fesa::NodeId{1}}, + fesa::MaterialId{0}, + fesa::SectionId{0}, + }; +} + +fesa::StepDefinition valid_step() { + return { + "Load", + {{fesa::NodeId{0}, 1, 0.0}}, + {{fesa::NodeId{1}, {0.0, -100.0, 0.0, 0.0, 0.0, 0.0}}}, + }; +} + +fesa::DomainBuilder make_builder( + fesa::IsotropicElastic material, + fesa::BeamSection section, + fesa::BeamElement element, + fesa::StepDefinition step) { + fesa::DomainBuilder builder; + builder.add_node(first_node()); + builder.add_node(second_node()); + builder.add_material(std::move(material)); + builder.add_section(std::move(section)); + builder.add_beam_element(std::move(element)); + builder.add_node_set({"Fixed", {fesa::NodeId{0}}}); + builder.add_element_set({"Beam", {fesa::ElementId{0}}}); + builder.set_step(std::move(step)); + return builder; +} + +fesa::DomainBuilder make_valid_builder() { + return make_builder( + valid_material(), valid_section(), valid_element(), valid_step()); +} + +bool has_diagnostic( + const fesa::DomainBuildResult& result, + const std::string_view code) { + return std::ranges::any_of( + result.diagnostics, + [code](const fesa::Diagnostic& diagnostic) { + return diagnostic.code == code; + }); +} + +std::size_t diagnostic_count( + const fesa::DomainBuildResult& result, + const std::string_view code) { + return static_cast(std::ranges::count_if( + result.diagnostics, + [code](const fesa::Diagnostic& diagnostic) { + return diagnostic.code == code; + })); +} + +TEST(DomainBuilder, BuildsImmutableDomainAndDenseNodeLookups) { + auto result = std::move(make_valid_builder()).build(); + + ASSERT_TRUE(result.domain.has_value()); + EXPECT_TRUE(result.diagnostics.empty()); + + const fesa::Domain& domain = *result.domain; + ASSERT_EQ(domain.nodes().size(), 2); + ASSERT_EQ(domain.beam_elements().size(), 1); + ASSERT_EQ(domain.materials().size(), 1); + ASSERT_EQ(domain.sections().size(), 1); + ASSERT_EQ(domain.node_sets().size(), 1); + ASSERT_EQ(domain.element_sets().size(), 1); + EXPECT_EQ(domain.step().name, "Load"); + EXPECT_EQ(&domain.node(fesa::NodeId{1}), &domain.nodes()[1]); + EXPECT_EQ( + &domain.node(fesa::EntityOrigin{"BeamPart", "Beam-1", 2}), + &domain.nodes()[1]); +} + +TEST(DomainValidation, RejectsDuplicateInternalId) { + auto builder = make_valid_builder(); + builder.add_node({ + fesa::NodeId{1}, + fesa::EntityOrigin{"BeamPart", "Beam-1", 3}, + fesa::Vec3{3.0, 0.0, 0.0}, + }); + + const auto result = std::move(builder).build(); + + EXPECT_FALSE(result.domain.has_value()); + EXPECT_TRUE(has_diagnostic(result, "model.duplicate_node_id")); +} + +TEST(DomainValidation, RejectsDuplicateMaterialId) { + auto builder = make_valid_builder(); + auto material = valid_material(); + material.name = "Duplicate"; + builder.add_material(std::move(material)); + + const auto result = std::move(builder).build(); + + EXPECT_FALSE(result.domain.has_value()); + EXPECT_TRUE(has_diagnostic(result, "model.duplicate_material_id")); +} + +TEST(DomainValidation, RejectsDuplicateSectionId) { + auto builder = make_valid_builder(); + auto section = valid_section(); + section.name = "Duplicate"; + builder.add_section(std::move(section)); + + const auto result = std::move(builder).build(); + + EXPECT_FALSE(result.domain.has_value()); + EXPECT_TRUE(has_diagnostic(result, "model.duplicate_section_id")); +} + +TEST(DomainValidation, RejectsDuplicateElementId) { + auto builder = make_valid_builder(); + auto element = valid_element(); + element.origin.local_label = 2; + builder.add_beam_element(std::move(element)); + + const auto result = std::move(builder).build(); + + EXPECT_FALSE(result.domain.has_value()); + EXPECT_TRUE(has_diagnostic(result, "model.duplicate_element_id")); +} + +TEST(DomainValidation, RejectsDuplicateOriginWithinEntityKind) { + auto builder = make_valid_builder(); + builder.add_node({ + fesa::NodeId{2}, + fesa::EntityOrigin{"OtherPart", "Beam-1", 2}, + fesa::Vec3{3.0, 0.0, 0.0}, + }); + + const auto result = std::move(builder).build(); + + EXPECT_FALSE(result.domain.has_value()); + EXPECT_TRUE(has_diagnostic(result, "model.duplicate_node_origin")); +} + +TEST(DomainValidation, RejectsDuplicateElementOriginWithinEntityKind) { + auto builder = make_valid_builder(); + auto element = valid_element(); + element.id = fesa::ElementId{1}; + element.origin.part_name = "OtherPart"; + builder.add_beam_element(std::move(element)); + + const auto result = std::move(builder).build(); + + EXPECT_FALSE(result.domain.has_value()); + EXPECT_TRUE(has_diagnostic(result, "model.duplicate_element_origin")); +} + +TEST(DomainValidation, CollectsMissingReferencesAcrossSemanticEntities) { + auto builder = make_valid_builder(); + builder.add_beam_element({ + fesa::ElementId{1}, + fesa::EntityOrigin{"BeamPart", "Beam-1", 2}, + {fesa::NodeId{90}, fesa::NodeId{91}}, + fesa::MaterialId{92}, + fesa::SectionId{93}, + }); + builder.add_node_set({"MissingNodes", {fesa::NodeId{94}}}); + builder.add_element_set({"MissingElements", {fesa::ElementId{95}}}); + builder.set_step({ + "Load", + {{fesa::NodeId{96}, 1, 0.0}}, + {{fesa::NodeId{97}, {1.0, 0.0, 0.0, 0.0, 0.0, 0.0}}}, + }); + + const auto result = std::move(builder).build(); + + EXPECT_FALSE(result.domain.has_value()); + EXPECT_TRUE(has_diagnostic(result, "model.missing_node_reference")); + EXPECT_TRUE(has_diagnostic(result, "model.missing_material_reference")); + EXPECT_TRUE(has_diagnostic(result, "model.missing_section_reference")); + EXPECT_TRUE(has_diagnostic(result, "model.missing_element_reference")); +} + +TEST(DomainValidation, RejectsNonpositiveYoungsModulus) { + auto material = valid_material(); + material.young = 0.0; + + const auto result = std::move(make_builder( + material, + valid_section(), + valid_element(), + valid_step())) + .build(); + + EXPECT_FALSE(result.domain.has_value()); + EXPECT_TRUE(has_diagnostic(result, "model.invalid_material")); +} + +TEST(DomainValidation, RejectsPoissonRatioOutsideOpenPhysicalRange) { + for (const double poisson : {-1.0, 0.5}) { + auto material = valid_material(); + material.poisson = poisson; + + const auto result = std::move(make_builder( + material, + valid_section(), + valid_element(), + valid_step())) + .build(); + + EXPECT_FALSE(result.domain.has_value()) << "poisson=" << poisson; + EXPECT_TRUE(has_diagnostic(result, "model.invalid_material")) + << "poisson=" << poisson; + } +} + +struct InvalidSectionProperty final { + const char* name; + double fesa::BeamSection::*member; +}; + +class DomainValidationInvalidSection + : public testing::TestWithParam {}; + +TEST_P(DomainValidationInvalidSection, RejectsNonpositiveProperty) { + auto section = valid_section(); + section.*(GetParam().member) = 0.0; + + const auto result = std::move(make_builder( + valid_material(), + section, + valid_element(), + valid_step())) + .build(); + + EXPECT_FALSE(result.domain.has_value()); + EXPECT_TRUE(has_diagnostic(result, "model.invalid_section")); +} + +INSTANTIATE_TEST_SUITE_P( + SectionProperties, + DomainValidationInvalidSection, + testing::Values( + InvalidSectionProperty{"Area", &fesa::BeamSection::area}, + InvalidSectionProperty{"Iy", &fesa::BeamSection::iy}, + InvalidSectionProperty{"Iz", &fesa::BeamSection::iz}, + InvalidSectionProperty{"TorsionJ", &fesa::BeamSection::torsion_j}, + InvalidSectionProperty{ + "ShearAreaY", &fesa::BeamSection::shear_area_y}, + InvalidSectionProperty{ + "ShearAreaZ", &fesa::BeamSection::shear_area_z}), + [](const testing::TestParamInfo& info) { + return info.param.name; + }); + +TEST(DomainValidation, RejectsNonfiniteNodeCoordinate) { + const double nan = std::numeric_limits::quiet_NaN(); + auto builder = make_valid_builder(); + builder.add_node({ + fesa::NodeId{2}, + fesa::EntityOrigin{"BeamPart", "Beam-1", 3}, + fesa::Vec3{nan, 0.0, 0.0}, + }); + + const auto result = std::move(builder).build(); + + EXPECT_FALSE(result.domain.has_value()); + EXPECT_TRUE(has_diagnostic(result, "model.nonfinite_value")); +} + +TEST(DomainValidation, RejectsNonfiniteMaterialConstant) { + auto material = valid_material(); + material.young = std::numeric_limits::infinity(); + + const auto result = std::move(make_builder( + material, + valid_section(), + valid_element(), + valid_step())) + .build(); + + EXPECT_FALSE(result.domain.has_value()); + EXPECT_TRUE(has_diagnostic(result, "model.nonfinite_value")); +} + +TEST(DomainValidation, RejectsNonfinitePoissonRatio) { + auto material = valid_material(); + material.poisson = std::numeric_limits::quiet_NaN(); + + const auto result = std::move(make_builder( + material, + valid_section(), + valid_element(), + valid_step())) + .build(); + + EXPECT_FALSE(result.domain.has_value()); + EXPECT_TRUE(has_diagnostic(result, "model.nonfinite_value")); +} + +TEST(DomainValidation, RejectsNonfiniteSectionProperty) { + auto section = valid_section(); + section.area = std::numeric_limits::infinity(); + + const auto result = std::move(make_builder( + valid_material(), + section, + valid_element(), + valid_step())) + .build(); + + EXPECT_FALSE(result.domain.has_value()); + EXPECT_TRUE(has_diagnostic(result, "model.nonfinite_value")); +} + +TEST(DomainValidation, RejectsNonfiniteSectionOrientation) { + auto section = valid_section(); + section.orientation.y = std::numeric_limits::quiet_NaN(); + + const auto result = std::move(make_builder( + valid_material(), + section, + valid_element(), + valid_step())) + .build(); + + EXPECT_FALSE(result.domain.has_value()); + EXPECT_TRUE(has_diagnostic(result, "model.nonfinite_value")); +} + +TEST(DomainValidation, RejectsNonfiniteRecoveryPoint) { + auto section = valid_section(); + const double nan = std::numeric_limits::quiet_NaN(); + section.recovery_points = {{nan, 0.0}}; + + const auto result = std::move(make_builder( + valid_material(), + section, + valid_element(), + valid_step())) + .build(); + + EXPECT_FALSE(result.domain.has_value()); + EXPECT_TRUE(has_diagnostic(result, "model.nonfinite_value")); +} + +TEST(DomainValidation, RejectsNonfinitePrescribedValue) { + auto step = valid_step(); + step.prescribed_dofs[0].value = + std::numeric_limits::infinity(); + + const auto result = std::move(make_builder( + valid_material(), + valid_section(), + valid_element(), + step)) + .build(); + + EXPECT_FALSE(result.domain.has_value()); + EXPECT_TRUE(has_diagnostic(result, "model.nonfinite_value")); +} + +TEST(DomainValidation, RejectsNonfiniteLoadComponent) { + auto step = valid_step(); + step.nodal_loads[0].values[0] = + std::numeric_limits::quiet_NaN(); + + const auto result = std::move(make_builder( + valid_material(), + valid_section(), + valid_element(), + step)) + .build(); + + EXPECT_FALSE(result.domain.has_value()); + EXPECT_TRUE(has_diagnostic(result, "model.nonfinite_value")); +} + +TEST(DomainValidation, CollectsIndependentMaterialPropertyDiagnostics) { + auto material = valid_material(); + material.young = std::numeric_limits::quiet_NaN(); + material.poisson = 0.5; + + const auto result = std::move(make_builder( + material, + valid_section(), + valid_element(), + valid_step())) + .build(); + + EXPECT_FALSE(result.domain.has_value()); + EXPECT_TRUE(has_diagnostic(result, "model.nonfinite_value")); + EXPECT_TRUE(has_diagnostic(result, "model.invalid_material")); +} + +TEST(DomainValidation, CollectsIndependentSectionPropertyDiagnostics) { + auto section = valid_section(); + section.area = -1.0; + section.iy = std::numeric_limits::quiet_NaN(); + + const auto result = std::move(make_builder( + valid_material(), + section, + valid_element(), + valid_step())) + .build(); + + EXPECT_FALSE(result.domain.has_value()); + EXPECT_TRUE(has_diagnostic(result, "model.nonfinite_value")); + EXPECT_TRUE(has_diagnostic(result, "model.invalid_section")); +} + +TEST(DomainValidation, CollectsNonfiniteStepValuesIndependently) { + const double nan = std::numeric_limits::quiet_NaN(); + const double infinity = std::numeric_limits::infinity(); + auto step = fesa::StepDefinition{ + "Load", + {{fesa::NodeId{0}, 1, infinity}}, + {{fesa::NodeId{1}, {nan, 0.0, 0.0, 0.0, 0.0, 0.0}}}, + }; + + const auto result = std::move(make_builder( + valid_material(), + valid_section(), + valid_element(), + step)) + .build(); + + EXPECT_FALSE(result.domain.has_value()); + EXPECT_EQ(diagnostic_count(result, "model.nonfinite_value"), 2); +} + +TEST(DomainValidation, RejectsZeroLengthElement) { + auto element = valid_element(); + element.nodes[1] = fesa::NodeId{0}; + + const auto result = std::move(make_builder( + valid_material(), + valid_section(), + element, + valid_step())) + .build(); + + EXPECT_FALSE(result.domain.has_value()); + EXPECT_TRUE(has_diagnostic(result, "model.zero_length_element")); +} + +TEST(DomainValidation, RejectsZeroAndElementParallelOrientation) { + for (const fesa::Vec3 orientation : + {fesa::Vec3{0.0, 0.0, 0.0}, fesa::Vec3{1.0, 0.0, 0.0}}) { + auto section = valid_section(); + section.orientation = orientation; + + const auto result = std::move(make_builder( + valid_material(), + section, + valid_element(), + valid_step())) + .build(); + + EXPECT_FALSE(result.domain.has_value()); + EXPECT_TRUE(has_diagnostic(result, "model.invalid_orientation")); + } +} + +TEST(DomainValidation, RejectsElementWithoutMaterialAndSectionAssignments) { + auto element = valid_element(); + element.material = fesa::MaterialId{8}; + element.section = fesa::SectionId{9}; + + const auto result = std::move(make_builder( + valid_material(), + valid_section(), + element, + valid_step())) + .build(); + + EXPECT_FALSE(result.domain.has_value()); + EXPECT_TRUE(has_diagnostic(result, "model.missing_material_reference")); + EXPECT_TRUE(has_diagnostic(result, "model.missing_section_reference")); +} + +TEST(DomainValidation, RejectsConflictingBoundaryConditions) { + auto step = valid_step(); + step.prescribed_dofs.push_back({fesa::NodeId{0}, 1, 0.25}); + + const auto result = std::move(make_builder( + valid_material(), + valid_section(), + valid_element(), + step)) + .build(); + + EXPECT_FALSE(result.domain.has_value()); + EXPECT_TRUE( + has_diagnostic(result, "model.conflicting_boundary_condition")); +} + +TEST(DomainValidation, CollectsIndependentDiagnosticsWithoutEarlyExit) { + auto material = valid_material(); + material.young = -1.0; + auto section = valid_section(); + section.area = -1.0; + section.orientation = fesa::Vec3{1.0, 0.0, 0.0}; + + const auto result = std::move(make_builder( + material, + section, + valid_element(), + valid_step())) + .build(); + + EXPECT_FALSE(result.domain.has_value()); + EXPECT_TRUE(has_diagnostic(result, "model.invalid_material")); + EXPECT_TRUE(has_diagnostic(result, "model.invalid_section")); + EXPECT_TRUE(has_diagnostic(result, "model.invalid_orientation")); +} + +} // namespace diff --git a/tests/unit/model/entity_origin_test.cpp b/tests/unit/model/entity_origin_test.cpp index 955c872..9d05869 100644 --- a/tests/unit/model/entity_origin_test.cpp +++ b/tests/unit/model/entity_origin_test.cpp @@ -1,10 +1,8 @@ -#include +#include #include #include #include -#include - #include namespace { @@ -31,15 +29,12 @@ TEST(EntityOrigin, FindsHierarchicalNodeByCompositeOrigin) { fesa::EntityOrigin{"BeamPart", "Beam-1", 101}, fesa::Vec3{1.0, 2.0, 3.0}, }; - const fesa::Domain domain{ - std::vector{node}, - {}, - {}, - {}, - {}, - {}, - fesa::StepDefinition{"Load", {}, {}}, - }; + fesa::DomainBuilder builder; + builder.add_node(node); + builder.set_step(fesa::StepDefinition{"Load", {}, {}}); + auto result = std::move(builder).build(); + ASSERT_TRUE(result.domain.has_value()); + const fesa::Domain& domain = *result.domain; const fesa::EntityOrigin lookup{"BeamPart", "Beam-1", 101}; const fesa::Node& found_by_origin = domain.node(lookup);