feat(linear-static-3d-euler-beam): step 13 - analysis-model

This commit is contained in:
KOKO\Mimi
2026-08-09 16:50:28 +09:00
parent b1e78bc4cc
commit 9ce36cf42d
6 changed files with 332 additions and 0 deletions
@@ -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.
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include "fesa/model/domain.hpp"
#include <vector>
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<AnalysisModel> create(const Domain& domain);
const Domain& domain() const noexcept;
const StaticStepDefinition& step() const noexcept;
const std::vector<EntityIndex>& activeElements() const noexcept;
const std::vector<EntityIndex>& activeMaterials() const noexcept;
const std::vector<EntityIndex>& activeSections() const noexcept;
const std::vector<EntityIndex>& activeBoundaryConditions() const noexcept;
const std::vector<EntityIndex>& activeLoads() const noexcept;
private:
explicit AnalysisModel(const Domain& domain);
const Domain* domain_;
std::vector<EntityIndex> activeElements_;
std::vector<EntityIndex> activeMaterials_;
std::vector<EntityIndex> activeSections_;
std::vector<EntityIndex> activeBoundaryConditions_;
std::vector<EntityIndex> activeLoads_;
};
} // namespace fesa
+1
View File
@@ -1,6 +1,7 @@
add_library(
fesa_solver
STATIC
analysis/analysis_model.cpp
build_info.cpp
core/diagnostic.cpp
core/status.cpp
+96
View File
@@ -0,0 +1,96 @@
#include "fesa/analysis/analysis_model.hpp"
#include <string>
#include <vector>
namespace fesa {
Result<AnalysisModel> AnalysisModel::create(const Domain& domain) {
if (domain.steps().empty()) {
return Result<AnalysisModel>::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<AnalysisModel>::failure(Status::failure(
FailureCategory::input,
{{Severity::error,
"unsupported-multiple-step",
secondStep.location,
"STEP",
secondStep.name,
"AnalysisModel does not support multiple steps."}}));
}
return Result<AnalysisModel>::success(AnalysisModel{domain});
}
const Domain& AnalysisModel::domain() const noexcept {
return *domain_;
}
const StaticStepDefinition& AnalysisModel::step() const noexcept {
return domain_->steps().front();
}
const std::vector<EntityIndex>& AnalysisModel::activeElements() const noexcept {
return activeElements_;
}
const std::vector<EntityIndex>& AnalysisModel::activeMaterials() const noexcept {
return activeMaterials_;
}
const std::vector<EntityIndex>& AnalysisModel::activeSections() const noexcept {
return activeSections_;
}
const std::vector<EntityIndex>&
AnalysisModel::activeBoundaryConditions() const noexcept {
return activeBoundaryConditions_;
}
const std::vector<EntityIndex>& AnalysisModel::activeLoads() const noexcept {
return activeLoads_;
}
AnalysisModel::AnalysisModel(const Domain& domain) : domain_{&domain} {
std::vector<bool> reachableMaterials(domain.materials().size(), false);
std::vector<bool> 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<EntityIndex>(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<EntityIndex>(index));
}
}
for (std::size_t index = 0U; index < reachableSections.size(); ++index) {
if (reachableSections[index]) {
activeSections_.push_back(static_cast<EntityIndex>(index));
}
}
for (std::size_t index = 0U;
index < step().boundaries.size();
++index) {
activeBoundaryConditions_.push_back(static_cast<EntityIndex>(index));
}
for (std::size_t index = 0U; index < step().loads.size(); ++index) {
activeLoads_.push_back(static_cast<EntityIndex>(index));
}
}
} // namespace fesa
+1
View File
@@ -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
+154
View File
@@ -0,0 +1,154 @@
#include "fesa/analysis/analysis_model.hpp"
#include <gtest/gtest.h>
#include <filesystem>
#include <string>
#include <utility>
#include <vector>
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<fesa::EntityIndex>{0U, 1U, 2U, 3U}));
EXPECT_EQ(
model.activeMaterials(),
(std::vector<fesa::EntityIndex>{0U, 1U, 2U}));
EXPECT_EQ(
model.activeSections(),
(std::vector<fesa::EntityIndex>{0U, 1U, 2U}));
EXPECT_EQ(
model.activeBoundaryConditions(),
(std::vector<fesa::EntityIndex>{0U, 1U}));
EXPECT_EQ(
model.activeLoads(),
(std::vector<fesa::EntityIndex>{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);
}