diff --git a/docs/implementation-plans/linear-static-3d-euler-beam-implementation-report.md b/docs/implementation-plans/linear-static-3d-euler-beam-implementation-report.md index 8943d70..55991b9 100644 --- a/docs/implementation-plans/linear-static-3d-euler-beam-implementation-report.md +++ b/docs/implementation-plans/linear-static-3d-euler-beam-implementation-report.md @@ -359,3 +359,49 @@ - deferred_scope: the separate known-but-misplaced keyword diagnostic Minor was deliberately not changed in this fix round. No phase-index change and no commit were made. + +## Step 13 — analysis-model + +- task_id: `TASK-13` +- status: `completed` +- changed_files: `include/fesa/analysis/analysis_model.hpp`, + `src/fesa/analysis/analysis_model.cpp`, + `tests/unit/analysis/analysis_model_test.cpp`, `src/fesa/CMakeLists.txt`, + `tests/CMakeLists.txt`, + `docs/implementation-plans/linear-static-3d-euler-beam-implementation-report.md`, + `phases/linear-static-3d-euler-beam/index.json`, + `.superpowers/sdd/linear-static-3d-euler-beam/task-13-report.md` +- requirement_ids: `FESA-REQ-LS3DEB-001`, `FESA-REQ-LS3DEB-021`, + `FESA-REQ-LS3DEB-034` +- test_ids: `T13-MODEL-001`, `T13-MODEL-002`, `T13-MODEL-003` + +| stage | exact command | exit_code | expected_or_observed_result | evidence_tail | +| --- | --- | ---: | --- | --- | +| RED-build | `cmake --build .harness/build --config Debug --target fesa_tests` | 1 | Exactly three planned tests were registered before production and the AnalysisModel API was absent | MSVC C1083 reported missing `fesa/analysis/analysis_model.hpp` from `analysis_model_test.cpp` | +| RED-test | `ctest --test-dir .harness/build -C Debug -R AnalysisModel --output-on-failure` | 0 | No focused test was runnable because the test executable could not rebuild | CTest reported `No tests were found` after the implementation-owned compile RED | +| GREEN-build | `cmake --build .harness/build --config Debug --target fesa_tests` | 0 | Minimal non-owning active view and all three tests compile and link | `analysis_model.cpp`, `analysis_model_test.cpp`, `fesa_solver.lib`, and `fesa_unit_tests.exe` built without a FESA warning under `/W4 /WX` | +| GREEN-test | `ctest --test-dir .harness/build -C Debug -R AnalysisModel --output-on-failure` | 0 | Active classification, address identity/immutability, and cardinality diagnostics pass | 3/3 exact `AnalysisModel` tests passed | +| VERIFY-configure | `cmake -S . -B .harness/build -A x64 -DFESA_GTEST_SOURCE_DIR=C:/git/googletest "-DMKL_DIR=C:/Program Files (x86)/Intel/oneAPI/mkl/2026.1/lib/cmake/mkl" "-DTBB_DIR=C:/Program Files (x86)/Intel/oneAPI/tbb/2023.1/lib/cmake/tbb" "-DHDF5_DIR=C:/Program Files/HDF_Group/HDF5/2.1.1/cmake"` | 0 | Approved explicit-dependency MSVC x64 build tree generates | Windows SDK and oneMKL 2026.1 resolved; configure and generate completed | +| VERIFY-build | `cmake --build .harness/build --config Debug` | 0 | Full Debug build passes without a new FESA warning | `fesa_solver.lib` and `fesa_unit_tests.exe` built under `/W4 /WX` | +| VERIFY-targeted | `ctest --test-dir .harness/build -C Debug -R AnalysisModel --output-on-failure` | 0 | Focused Step 13 suite remains green | 3/3 exact `AnalysisModel` tests passed | +| VERIFY-discovery | `ctest --test-dir .harness/build -C Debug --show-only=json-v1` | 0 | CTest discovers the accumulated suite and all three exact AnalysisModel names | 22 tests discovered with feature/unit labels | +| VERIFY-full | `ctest --test-dir .harness/build -C Debug --output-on-failure` | 0 | Full accumulated C++ suite has zero failures | 22/22 tests passed | +| VERIFY-dependency-direction | Backend public-header, upward-analysis dependency, DOF/numeric-state, exact-test-count, registration, and trailing-whitespace scans using fail-on-match `rg` wrappers | 0 | AnalysisModel stays backend-free and below later solver layers, with no DOF/state ownership | backend leaks 0; upward dependencies 0; DOF/numeric-state leaks 0; tests 3; CMake registrations 1/1; new-file trailing whitespace 0 | +| VERIFY-diff | `git diff --check` | 0 | Tracked patch has no whitespace errors | Exit 0; only informational Git LF-to-CRLF working-copy notices were emitted | +| VERIFY-reference | `git diff --exit-code -- reference/`; `git status --short -- reference/` | 0 | Approved legacy reference artifacts remain read-only and unchanged | Reference diff exit 0 and reference status empty | + +- contract_checks: `AnalysisModel` stores one non-owning `const Domain*` plus + stable `EntityIndex` vectors only. All Domain elements are active in stable + internal order; reachable material and section IDs are deduplicated by an + ascending internal-index scan; boundary/load IDs are the sole step's vector + positions. `domain()` and `step()` preserve exact address identity, and the + tests verify semantic storage addresses and values remain unchanged. Missing + and multiple steps return categorized `invalid-model-cardinality` and + `unsupported-multiple-step` diagnostics. No equation numbering, constraint + propagation, multi-step activation, or numeric analysis state was added. +- generated_evidence: `.harness/build/src/fesa/Debug/fesa_solver.lib`, + `.harness/build/tests/Debug/fesa_unit_tests.exe` +- reference_diff: unchanged; `git diff --exit-code -- reference/` exit 0 +- handoff: the exact AnalysisModel ledger API, sole-step reference, and stable + active element/material/section/boundary/load IDs are available to Step 14 + `DofManager` without copying or mutating Domain entities. diff --git a/include/fesa/analysis/analysis_model.hpp b/include/fesa/analysis/analysis_model.hpp new file mode 100644 index 0000000..3c1f4a2 --- /dev/null +++ b/include/fesa/analysis/analysis_model.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include "fesa/model/domain.hpp" + +#include + +namespace fesa { + +// Provides the sole active-step view while the referenced Domain retains all +// semantic ownership and must outlive this object. +class AnalysisModel { +public: + static Result create(const Domain& domain); + + const Domain& domain() const noexcept; + const StaticStepDefinition& step() const noexcept; + const std::vector& activeElements() const noexcept; + const std::vector& activeMaterials() const noexcept; + const std::vector& activeSections() const noexcept; + const std::vector& activeBoundaryConditions() const noexcept; + const std::vector& activeLoads() const noexcept; + +private: + explicit AnalysisModel(const Domain& domain); + + const Domain* domain_; + std::vector activeElements_; + std::vector activeMaterials_; + std::vector activeSections_; + std::vector activeBoundaryConditions_; + std::vector activeLoads_; +}; + +} // namespace fesa diff --git a/src/fesa/CMakeLists.txt b/src/fesa/CMakeLists.txt index b173ac6..b63c4ca 100644 --- a/src/fesa/CMakeLists.txt +++ b/src/fesa/CMakeLists.txt @@ -1,6 +1,7 @@ add_library( fesa_solver STATIC + analysis/analysis_model.cpp build_info.cpp core/diagnostic.cpp core/status.cpp diff --git a/src/fesa/analysis/analysis_model.cpp b/src/fesa/analysis/analysis_model.cpp new file mode 100644 index 0000000..b4a22f3 --- /dev/null +++ b/src/fesa/analysis/analysis_model.cpp @@ -0,0 +1,96 @@ +#include "fesa/analysis/analysis_model.hpp" + +#include +#include + +namespace fesa { + +Result AnalysisModel::create(const Domain& domain) { + if (domain.steps().empty()) { + return Result::failure(Status::failure( + FailureCategory::input, + {{Severity::error, + "invalid-model-cardinality", + {domain.sourcePath(), 0U}, + "STEP", + "0", + "AnalysisModel requires exactly one static step."}})); + } + if (domain.steps().size() > 1U) { + const auto& secondStep = domain.steps()[1]; + return Result::failure(Status::failure( + FailureCategory::input, + {{Severity::error, + "unsupported-multiple-step", + secondStep.location, + "STEP", + secondStep.name, + "AnalysisModel does not support multiple steps."}})); + } + return Result::success(AnalysisModel{domain}); +} + +const Domain& AnalysisModel::domain() const noexcept { + return *domain_; +} + +const StaticStepDefinition& AnalysisModel::step() const noexcept { + return domain_->steps().front(); +} + +const std::vector& AnalysisModel::activeElements() const noexcept { + return activeElements_; +} + +const std::vector& AnalysisModel::activeMaterials() const noexcept { + return activeMaterials_; +} + +const std::vector& AnalysisModel::activeSections() const noexcept { + return activeSections_; +} + +const std::vector& +AnalysisModel::activeBoundaryConditions() const noexcept { + return activeBoundaryConditions_; +} + +const std::vector& AnalysisModel::activeLoads() const noexcept { + return activeLoads_; +} + +AnalysisModel::AnalysisModel(const Domain& domain) : domain_{&domain} { + std::vector reachableMaterials(domain.materials().size(), false); + std::vector reachableSections(domain.sections().size(), false); + + for (std::size_t index = 0U; index < domain.elements().size(); ++index) { + const auto& element = domain.elements()[index]; + activeElements_.push_back(static_cast(index)); + reachableMaterials[element.materialIndex] = true; + reachableSections[element.sectionIndex] = true; + } + + // Ascending vector positions are the stable internal order, independent + // of first reachability or duplicate element assignments. + for (std::size_t index = 0U; index < reachableMaterials.size(); ++index) { + if (reachableMaterials[index]) { + activeMaterials_.push_back(static_cast(index)); + } + } + for (std::size_t index = 0U; index < reachableSections.size(); ++index) { + if (reachableSections[index]) { + activeSections_.push_back(static_cast(index)); + } + } + + for (std::size_t index = 0U; + index < step().boundaries.size(); + ++index) { + activeBoundaryConditions_.push_back(static_cast(index)); + } + for (std::size_t index = 0U; index < step().loads.size(); ++index) { + activeLoads_.push_back(static_cast(index)); + } +} + +} // namespace fesa diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0accc78..c95509a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -3,6 +3,7 @@ include(GoogleTest) add_executable( fesa_unit_tests unit/build_info_test.cpp + unit/analysis/analysis_model_test.cpp unit/core/diagnostic_test.cpp unit/core/source_identity_test.cpp unit/core/status_test.cpp diff --git a/tests/unit/analysis/analysis_model_test.cpp b/tests/unit/analysis/analysis_model_test.cpp new file mode 100644 index 0000000..dedd0ed --- /dev/null +++ b/tests/unit/analysis/analysis_model_test.cpp @@ -0,0 +1,154 @@ +#include "fesa/analysis/analysis_model.hpp" + +#include + +#include +#include +#include +#include + +namespace { + +fesa::ModelDefinition makeDefinition() { + const std::filesystem::path source{"models/analysis-model.inp"}; + fesa::ModelDefinition definition{}; + definition.sourcePath = source; + definition.sourceContentIdentity = "fnv1a64:0123456789abcdef"; + definition.nodes = { + {{"Beam-1", 1, "1"}, {0.0, 0.0, 0.0}, {source, 10U}}, + {{"Beam-1", 2, "2"}, {1.0, 0.0, 0.0}, {source, 11U}}, + {{"Beam-1", 3, "3"}, {2.0, 0.0, 0.0}, {source, 12U}}, + {{"Beam-1", 4, "4"}, {3.0, 0.0, 0.0}, {source, 13U}}, + {{"Beam-1", 5, "5"}, {4.0, 0.0, 0.0}, {source, 14U}}}; + definition.materials = { + {"Material-0", 100.0, 0.20, {source, 20U}}, + {"Material-1", 200.0, 0.25, {source, 21U}}, + {"Material-2", 300.0, 0.30, {source, 22U}}, + {"Unused-Material", 400.0, 0.35, {source, 23U}}}; + definition.sections = { + {"Section-0", 1.0, 1.0, 0.0, 1.0, 1.0, + {0.0, 1.0, 0.0}, {}, {source, 30U}}, + {"Section-1", 2.0, 2.0, 0.0, 2.0, 2.0, + {0.0, 1.0, 0.0}, {}, {source, 31U}}, + {"Section-2", 3.0, 3.0, 0.0, 3.0, 3.0, + {0.0, 1.0, 0.0}, {}, {source, 32U}}, + {"Unused-Section", 4.0, 4.0, 0.0, 4.0, 4.0, + {0.0, 1.0, 0.0}, {}, {source, 33U}}}; + definition.elements = { + {{"Beam-1", 1, "1"}, {0U, 1U}, 2U, 2U, {source, 40U}}, + {{"Beam-1", 2, "2"}, {1U, 2U}, 0U, 1U, {source, 41U}}, + {{"Beam-1", 3, "3"}, {2U, 3U}, 2U, 2U, {source, 42U}}, + {{"Beam-1", 4, "4"}, {3U, 4U}, 1U, 0U, {source, 43U}}}; + definition.steps = {{ + "Step-1", + {{"Root", 1, 3, 0.0, {source, 51U}}, + {"Root", 4, 6, 0.0, {source, 52U}}}, + {{"Tip", 1, 10.0, {source, 53U}}, + {"Tip", 2, -20.0, {source, 54U}}, + {"Tip", 6, 30.0, {source, 55U}}}, + 0.1, + 1.0, + 0.01, + 1.0, + {source, 50U}}}; + return definition; +} + +} // namespace + +TEST(AnalysisModel, ClassifiesActiveEntitiesInStableOrder) { + auto domainResult = fesa::Domain::create(makeDefinition()); + ASSERT_TRUE(domainResult.hasValue()); + + auto modelResult = fesa::AnalysisModel::create(domainResult.value()); + ASSERT_TRUE(modelResult.hasValue()); + const auto& model = modelResult.value(); + + EXPECT_EQ( + model.activeElements(), + (std::vector{0U, 1U, 2U, 3U})); + EXPECT_EQ( + model.activeMaterials(), + (std::vector{0U, 1U, 2U})); + EXPECT_EQ( + model.activeSections(), + (std::vector{0U, 1U, 2U})); + EXPECT_EQ( + model.activeBoundaryConditions(), + (std::vector{0U, 1U})); + EXPECT_EQ( + model.activeLoads(), + (std::vector{0U, 1U, 2U})); +} + +TEST(AnalysisModel, ReferencesWithoutCopyingOrMutatingDomain) { + auto domainResult = fesa::Domain::create(makeDefinition()); + ASSERT_TRUE(domainResult.hasValue()); + const fesa::Domain& domain = domainResult.value(); + const auto* const elementAddress = domain.elements().data(); + const auto* const materialAddress = domain.materials().data(); + const auto* const sectionAddress = domain.sections().data(); + const std::string stepName = domain.steps()[0].name; + const double firstLoadMagnitude = domain.steps()[0].loads[0].magnitude; + + auto modelResult = fesa::AnalysisModel::create(domain); + ASSERT_TRUE(modelResult.hasValue()); + const auto& model = modelResult.value(); + + EXPECT_EQ(&model.domain(), &domain); + EXPECT_EQ(&model.step(), &domain.steps()[0]); + EXPECT_EQ(model.domain().elements().data(), elementAddress); + EXPECT_EQ(model.domain().materials().data(), materialAddress); + EXPECT_EQ(model.domain().sections().data(), sectionAddress); + EXPECT_EQ( + &model.domain().elements()[model.activeElements()[1]], + &domain.elements()[1]); + EXPECT_EQ( + &model.domain().materials()[model.activeMaterials()[2]], + &domain.materials()[2]); + EXPECT_EQ( + &model.domain().sections()[model.activeSections()[1]], + &domain.sections()[1]); + EXPECT_EQ(domain.steps()[0].name, stepName); + EXPECT_DOUBLE_EQ(domain.steps()[0].loads[0].magnitude, firstLoadMagnitude); +} + +TEST(AnalysisModel, RejectsMissingOrMultipleStep) { + auto missingDefinition = makeDefinition(); + missingDefinition.steps.clear(); + auto missingDomain = fesa::Domain::create(std::move(missingDefinition)); + ASSERT_TRUE(missingDomain.hasValue()); + + auto missing = fesa::AnalysisModel::create(missingDomain.value()); + ASSERT_FALSE(missing.hasValue()); + EXPECT_EQ( + missing.status().failureCategory(), + fesa::FailureCategory::input); + ASSERT_EQ(missing.status().diagnostics().size(), 1U); + EXPECT_EQ( + missing.status().diagnostics()[0].code, + "invalid-model-cardinality"); + EXPECT_EQ(missing.status().diagnostics()[0].keyword, "STEP"); + EXPECT_EQ(missing.status().diagnostics()[0].entityIdentity, "0"); + + auto multipleDefinition = makeDefinition(); + auto secondStep = multipleDefinition.steps.front(); + secondStep.name = "Step-2"; + secondStep.location.line = 60U; + multipleDefinition.steps.push_back(std::move(secondStep)); + auto multipleDomain = fesa::Domain::create(std::move(multipleDefinition)); + ASSERT_TRUE(multipleDomain.hasValue()); + + auto multiple = fesa::AnalysisModel::create(multipleDomain.value()); + ASSERT_FALSE(multiple.hasValue()); + EXPECT_EQ( + multiple.status().failureCategory(), + fesa::FailureCategory::input); + ASSERT_EQ(multiple.status().diagnostics().size(), 1U); + EXPECT_EQ( + multiple.status().diagnostics()[0].code, + "unsupported-multiple-step"); + EXPECT_EQ(multiple.status().diagnostics()[0].keyword, "STEP"); + EXPECT_EQ(multiple.status().diagnostics()[0].entityIdentity, "Step-2"); + EXPECT_EQ(multiple.status().diagnostics()[0].location.line, 60U); +}