feat(linear-static-3d-euler-beam): step 22 - result-recovery

This commit is contained in:
KOKO\Mimi
2026-08-09 21:15:25 +09:00
parent df84903745
commit 084e6b0be1
6 changed files with 1358 additions and 0 deletions
@@ -1021,3 +1021,112 @@
substitution, reconstruction, recovery, and output sequencing. substitution, reconstruction, recovery, and output sequencing.
- concerns: none; no critical implementation, environment, backend, or - concerns: none; no critical implementation, environment, backend, or
upstream-contract conflict was found. upstream-contract conflict was found.
## Step 22 — result-recovery
- task_id: `TASK-22`
- status: `completed`
- changed_files: `include/fesa/results/result_recovery.hpp`,
`src/fesa/results/result_recovery.cpp`,
`tests/unit/results/result_recovery_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`
- requirement_ids: `FESA-REQ-LS3DEB-004`, `FESA-REQ-LS3DEB-027`,
`FESA-REQ-LS3DEB-031`, `FESA-REQ-LS3DEB-032`,
`FESA-REQ-LS3DEB-034`, `FESA-REQ-LS3DEB-035`,
`FESA-REQ-LS3DEB-042`
- test_ids: `T22-RECOVERY-001`, `T22-RECOVERY-002`,
`T22-RECOVERY-003`, `T22-RECOVERY-004`, `T22-RECOVERY-005`,
`T22-RECOVERY-006`
| stage | exact command | exit_code | expected_or_observed_result | evidence_tail |
| --- | --- | ---: | --- | --- |
| RED-build | `cmake --build .harness/build --config Debug --target fesa_tests` | 1 | Exactly six planned tests were registered before production and the recovery public API was absent | MSVC C1083 reported missing `fesa/results/result_recovery.hpp` from `result_recovery_test.cpp` |
| GREEN-build | `cmake --build .harness/build --config Debug --target fesa_tests` | 0 | Recovery, station normalization, six tests, solver library, and unit executable compile and link | `result_recovery.cpp`, its test, `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 ResultRecovery --output-on-failure` | 0 | Residual reaction, residual criterion, distinct result types, signs, stress order, and station policy pass | 6/6 exact `ResultRecovery` tests passed |
| RED-state-consistency | `cmake --build .harness/build --config Debug --target fesa_tests; ctest --test-dir .harness/build -C Debug -R ResultRecovery.EnforcesNormalizedFreeResidual --output-on-failure` | 1 | A constrained displacement inconsistent with prescribed `dc` must fail before residual evaluation | Test expected `invalid-recovery-state` but observed later `free-residual-tolerance-failure`, proving missing prevalidation |
| GREEN-state-consistency | same focused build/test command after the minimum validation change | 0 | Prescribed-order/state consistency fails before recovery and the exact zero-load/zero-displacement equilibrium remains valid | Focused test passed; subsequent exact suite passed 6/6 |
| 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 10.0.26100.0, oneMKL 2026.1 ILP64/dynamic, oneTBB, and HDF5 resolved; configure/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 ResultRecovery --output-on-failure` | 0 | Focused Step 22 suite remains green | 6/6 exact `ResultRecovery` tests passed |
| VERIFY-discovery | `ctest --test-dir .harness/build -C Debug --show-only=json-v1` | 0 | CTest discovers the accumulated suite and all six exact names | 68 tests discovered, including 6 `ResultRecovery` tests |
| VERIFY-full | `ctest --test-dir .harness/build -C Debug --output-on-failure` | 0 | Full accumulated C++ suite has zero failures | 68/68 tests passed |
| VERIFY-contract-scans | Exact-test-count, public-backend, forbidden shear/averaging, whitespace, and reference diff/status scans | 0 | Recovery scope and adapter/reference boundaries remain isolated | exact tests 6; public backend leaks 0; forbidden scope 0; whitespace clean; reference unchanged |
- contract_checks: full `internalForce=K*d` and
`residual=internalForce-externalForce` are computed as private candidates;
the full residual is preserved in reaction so free residual evidence remains
visible and constrained entries are the physical support reactions. No
endpoint-action summation participates in reaction recovery.
- contract_checks: the free residual uses
`||r_f||/max(||Fint_f||,||Fext_f||)` with no unit floor. A positive physical
denominator must meet `1e-10`; exact zero numerator/denominator is accepted
as zero-load equilibrium, while nonfinite arithmetic fails closed.
- contract_checks: dimensions, finite input, stable free/constrained order,
exact prescribed displacement, active entity references, and twelve-DOF
scatters are checked before computing candidates. Internal/residual/reaction
vectors and endpoint/Gauss/stress rows replace AnalysisState only after all
elements recover finite results, so failure is atomic.
- contract_checks: active element order produces endpoint `0,1`, Gauss `1,2`,
and element/Gauss/section stress order. Equilibrium end action
`[FX,FY,FZ,MX,MY,MZ]`, endpoint `[N,T,My,Mz]`, Gauss strain/resultant, and
section-point/default-centroid `S11` remain distinct.
- contract_checks: node-station projection requires exact endpoint row
identity, a two-endpoint unloaded interior chain, identical section and
local axes, finite component tolerances `[N,T,My,Mz]`, and agreement before
selecting the smaller stable element ID. Positive-local-x section-cut rows
compare directly and are never averaged; reversed, branched, loaded,
section-jump, nonfinite, and mismatch cases fail structurally.
- 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: Step 23 can serialize the stable nodal vectors and distinct
endpoint/Gauss/stress rows; Step 24 reference tooling can reuse
`normalizeSectionResultantsToNodeStations` without backend or HDF5 types.
- concerns: none; no critical implementation, environment, numerical, or
upstream-contract conflict was found.
### Step 22 Review Fix Round 1 — full-space free-residual visibility
- review_trigger: the approved I/O contract requires Reaction at every node
and component, including the accepted free-DOF residual; the AnalysisState
storage comment likewise makes reaction a full-index residual view.
- RED-test: `cmake --build .harness/build --config Debug --target fesa_tests;
ctest --test-dir .harness/build -C Debug -R
'^ResultRecovery.EnforcesNormalizedFreeResidual$' --output-on-failure`
built successfully and exited 1 in CTest. The accepted below-tolerance free
residual was `-1.000000082740371e-09`, while its reaction entry was `0`.
- production_fix: the private reaction candidate is now a full copy of
`residual=K*d-F`. Constrained entries remain the physical reactions, free
entries retain residual evidence, and element end actions are still never
summed into reaction.
- GREEN-focused: the same focused build/test command exited 0 and passed 1/1
after the minimum production change.
- VERIFY-build: `cmake --build .harness/build --config Debug` exited 0 under
MSVC x64 Debug with no new FESA warning.
- VERIFY-targeted: `ctest --test-dir .harness/build -C Debug -R
ResultRecovery --output-on-failure` exited 0 and passed the unchanged exact
six names 6/6.
- VERIFY-discovery-full: `ctest --test-dir .harness/build -C Debug
--show-only=json-v1` discovered 68 tests, exactly six ResultRecovery tests;
full `ctest --test-dir .harness/build -C Debug --output-on-failure` exited 0
and passed 68/68.
- failure_atomicity: the existing tolerance-failure branch in
`ResultRecovery.EnforcesNormalizedFreeResidual` still proves prior internal,
residual, reaction, and endpoint state remains unchanged on failure.
- supersession: this review section records the historical pre-fix finding
that implementation and report originally kept only constrained residual
entries. The final Step 22 contract above now states that reaction is the
full residual dataset; only constrained entries are physical reactions.
- phase_index: the review-fix worker intentionally left phase metadata
unchanged. After independent root verification, finalization preserved exact
`started_at=2026-08-09T20:42:15+0900`, added
`completed_at=2026-08-09T21:14:47+0900`, and updated the summary to state
the corrected full-residual reaction contract.
- evidence_hygiene: an initial parallel VERIFY/audit wrapper terminated before
product commands because its PowerShell count expression was malformed. It
is excluded from TDD and product VERIFY evidence; the recorded evidence is
the subsequent uncontended serial build, targeted, discovery, full sequence
followed by a separate successful audit command.
- concerns: none; no upstream-contract conflict or critical blocker remains.
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include "fesa/analysis/analysis_model.hpp"
#include "fesa/analysis/analysis_state.hpp"
#include "fesa/core/status.hpp"
#include "fesa/fem/dof_manager.hpp"
#include "fesa/math/sparse_matrix.hpp"
#include <array>
#include <vector>
namespace fesa {
struct NodeStationResultRow {
SourceEntityId node;
EntityIndex representativeElement;
std::array<double, 4> sectionResultant;
};
// Recovers full-space equilibrium and the V0 beam output rows without
// exposing element or sparse-backend details to result consumers.
class ResultRecovery {
public:
static Status recover(const AnalysisModel& model,
const DofManager& dofs,
const SparseMatrix& fullStiffness,
AnalysisState& state);
static Result<std::vector<NodeStationResultRow>>
normalizeSectionResultantsToNodeStations(
const AnalysisModel& model,
const std::vector<EndpointResultRow>& endpointRows,
const std::array<double, 4>& componentTolerances);
};
} // namespace fesa
+1
View File
@@ -18,6 +18,7 @@ add_library(
math/sparse_matrix.cpp math/sparse_matrix.cpp
math/vector.cpp math/vector.cpp
model/domain.cpp model/domain.cpp
results/result_recovery.cpp
solvers/linear/mkl_pardiso_solver.cpp solvers/linear/mkl_pardiso_solver.cpp
) )
+740
View File
@@ -0,0 +1,740 @@
#include "fesa/results/result_recovery.hpp"
#include "fesa/elements/euler_beam_3d.hpp"
#include <algorithm>
#include <array>
#include <charconv>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <optional>
#include <stdexcept>
#include <string>
#include <system_error>
#include <utility>
#include <vector>
namespace fesa {
namespace {
constexpr std::size_t kDofsPerNode = 6U;
constexpr std::size_t kElementDofCount = 12U;
constexpr double kFreeResidualTolerance = 1.0e-10;
constexpr double kAxisTolerance = 1.0e-12;
using AxisSet = std::array<std::array<double, 3>, 3>;
Status recoveryFailure(const std::string& code,
const SourceLocation& location,
const std::string& identity,
const std::string& message) {
return Status::failure(
FailureCategory::model,
{{Severity::error,
code,
location,
"RESULT_RECOVERY",
identity,
message}});
}
template<class T>
Result<T> recoveryResultFailure(const std::string& code,
const SourceLocation& location,
const std::string& identity,
const std::string& message) {
return Result<T>::failure(
recoveryFailure(code, location, identity, message));
}
bool sameSourceIdentity(const SourceEntityId& left,
const SourceEntityId& right) {
return left.instanceName == right.instanceName &&
left.sourceLabel == right.sourceLabel &&
left.sourceLabelText == right.sourceLabelText;
}
bool finite(const std::array<double, 4>& values) {
return std::all_of(
values.begin(), values.end(),
[](const double value) { return std::isfinite(value); });
}
bool finite(const std::array<double, 6>& values) {
return std::all_of(
values.begin(), values.end(),
[](const double value) { return std::isfinite(value); });
}
bool finite(const Vector& values) {
for (std::size_t index = 0U; index < values.size(); ++index) {
if (!std::isfinite(values[index])) {
return false;
}
}
return true;
}
double indexedNorm(const Vector& values,
const std::vector<std::size_t>& indices) {
double result = 0.0;
for (const std::size_t index : indices) {
result = std::hypot(result, values[index]);
}
return result;
}
bool strictlyIncreasing(const std::vector<std::size_t>& values) {
return std::adjacent_find(
values.begin(), values.end(),
[](const std::size_t left, const std::size_t right) {
return left >= right;
}) == values.end();
}
Status validateRecoveryInputs(const AnalysisModel& model,
const DofManager& dofs,
const SparseMatrix& fullStiffness,
const AnalysisState& state) {
const Domain& domain = model.domain();
if (domain.nodes().size() >
(std::numeric_limits<std::size_t>::max)() / kDofsPerNode) {
return recoveryFailure(
"invalid-recovery-dimensions",
{domain.sourcePath(), 0U},
domain.sourceContentIdentity(),
"The semantic node count cannot be represented in full-DOF space.");
}
const std::size_t fullCount = domain.nodes().size() * kDofsPerNode;
if (dofs.fullDofCount() != fullCount ||
fullStiffness.rows() != fullCount ||
fullStiffness.columns() != fullCount ||
state.displacement().size() != fullCount ||
state.externalForce().size() != fullCount ||
state.internalForce().size() != fullCount ||
state.residual().size() != fullCount ||
state.reaction().size() != fullCount) {
return recoveryFailure(
"invalid-recovery-dimensions",
{domain.sourcePath(), 0U},
domain.sourceContentIdentity(),
"Model, DOF, stiffness, and AnalysisState full-space dimensions must agree.");
}
const Status matrixStatus = fullStiffness.validate();
if (!matrixStatus.isOk()) {
return matrixStatus;
}
if (!finite(state.displacement()) || !finite(state.externalForce())) {
return recoveryFailure(
"nonfinite-recovery-value",
{domain.sourcePath(), 0U},
domain.sourceContentIdentity(),
"Displacement and external-force inputs must be finite.");
}
const auto& freeDofs = dofs.freeDofs();
const auto& constrainedDofs = dofs.constrainedDofs();
if (freeDofs.size() != dofs.freeDofCount() ||
constrainedDofs.size() != dofs.constrainedDofCount() ||
dofs.prescribedValues().size() != constrainedDofs.size() ||
freeDofs.size() + constrainedDofs.size() != fullCount ||
!strictlyIncreasing(freeDofs) ||
!strictlyIncreasing(constrainedDofs)) {
return recoveryFailure(
"invalid-recovery-order",
{domain.sourcePath(), 0U},
domain.sourceContentIdentity(),
"Free and constrained DOFs must form stable increasing full-space orders.");
}
std::vector<unsigned char> ownership(fullCount, 0U);
try {
for (std::size_t equation = 0U; equation < freeDofs.size(); ++equation) {
const std::size_t fullDof = freeDofs[equation];
if (fullDof >= fullCount || ownership[fullDof] != 0U ||
dofs.freeEquation(fullDof) != equation) {
return recoveryFailure(
"invalid-recovery-order",
{domain.sourcePath(), 0U},
std::to_string(fullDof),
"Free equations must match stable full-DOF order.");
}
ownership[fullDof] = 1U;
}
for (std::size_t constrained = 0U;
constrained < constrainedDofs.size();
++constrained) {
const std::size_t fullDof = constrainedDofs[constrained];
if (fullDof >= fullCount || ownership[fullDof] != 0U ||
dofs.freeEquation(fullDof).has_value()) {
return recoveryFailure(
"invalid-recovery-order",
{domain.sourcePath(), 0U},
std::to_string(fullDof),
"Constrained DOFs must be unique and absent from free equations.");
}
if (!std::isfinite(dofs.prescribedValues()[constrained]) ||
state.displacement()[fullDof] !=
dofs.prescribedValues()[constrained]) {
return recoveryFailure(
"invalid-recovery-state",
{domain.sourcePath(), 0U},
std::to_string(fullDof),
"Constrained displacement must equal its prescribed value before recovery.");
}
ownership[fullDof] = 2U;
}
} catch (const std::out_of_range&) {
return recoveryFailure(
"invalid-recovery-dimensions",
{domain.sourcePath(), 0U},
domain.sourceContentIdentity(),
"DofManager equation storage must cover every full DOF.");
}
if (std::find(ownership.begin(), ownership.end(), 0U) != ownership.end()) {
return recoveryFailure(
"invalid-recovery-order",
{domain.sourcePath(), 0U},
domain.sourceContentIdentity(),
"Free and constrained DOFs must partition the full range.");
}
EntityIndex previousElement = 0U;
bool firstElement = true;
for (const EntityIndex element : model.activeElements()) {
if (element >= domain.elements().size() ||
(!firstElement && element <= previousElement)) {
return recoveryFailure(
"invalid-recovery-entity",
{domain.sourcePath(), 0U},
std::to_string(element),
"Active elements must be unique in stable internal-index order.");
}
firstElement = false;
previousElement = element;
const auto& definition = domain.elements()[element];
if (definition.nodeIndices[0U] >= domain.nodes().size() ||
definition.nodeIndices[1U] >= domain.nodes().size() ||
definition.materialIndex >= domain.materials().size() ||
definition.sectionIndex >= domain.sections().size()) {
return recoveryFailure(
"invalid-recovery-entity",
definition.location,
definition.sourceId.sourceLabelText,
"Active beam references must resolve before recovery.");
}
try {
const auto& scatter = dofs.elementScatter(element);
for (std::size_t endpoint = 0U; endpoint < 2U; ++endpoint) {
for (std::size_t component = 0U;
component < kDofsPerNode;
++component) {
const std::size_t expected =
static_cast<std::size_t>(definition.nodeIndices[endpoint]) *
kDofsPerNode +
component;
if (scatter[endpoint * kDofsPerNode + component] != expected ||
expected >= fullCount) {
return recoveryFailure(
"invalid-recovery-order",
definition.location,
definition.sourceId.sourceLabelText,
"Element scatter must preserve endpoint/component full-DOF order.");
}
}
}
} catch (const std::out_of_range&) {
return recoveryFailure(
"invalid-recovery-entity",
definition.location,
definition.sourceId.sourceLabelText,
"Every active element requires one twelve-DOF scatter map.");
}
}
return Status::ok();
}
char asciiLower(const char value) {
if (value >= 'A' && value <= 'Z') {
return static_cast<char>(value + ('a' - 'A'));
}
return value;
}
bool equalName(const std::string& left, const std::string& right) {
return left.size() == right.size() &&
std::equal(
left.begin(), left.end(), right.begin(),
[](const char leftValue, const char rightValue) {
return asciiLower(leftValue) == asciiLower(rightValue);
});
}
bool tryPositiveInteger(const std::string& text, std::int64_t& value) {
const char* const first = text.data();
const char* const last = first + text.size();
const auto parsed = std::from_chars(first, last, value);
return parsed.ec == std::errc{} && parsed.ptr == last && value > 0;
}
Result<std::vector<EntityIndex>> resolveLoadTarget(const Domain& domain,
const NodalLoad& load) {
std::vector<const NodeSet*> sets;
for (const auto& set : domain.nodeSets()) {
if (equalName(set.name, load.target)) {
sets.push_back(&set);
}
}
std::vector<EntityIndex> nodes;
std::int64_t sourceLabel = 0;
if (tryPositiveInteger(load.target, sourceLabel)) {
for (std::size_t node = 0U; node < domain.nodes().size(); ++node) {
if (domain.nodes()[node].sourceId.sourceLabel == sourceLabel) {
nodes.push_back(static_cast<EntityIndex>(node));
}
}
}
if (sets.size() > 1U || nodes.size() > 1U ||
(!sets.empty() && !nodes.empty())) {
return recoveryResultFailure<std::vector<EntityIndex>>(
"invalid-node-station-entity",
load.location,
load.target,
"A station-eligibility load target must resolve unambiguously.");
}
if (!sets.empty()) {
std::vector<unsigned char> seen(domain.nodes().size(), 0U);
for (const EntityIndex node : sets.front()->nodeIndices) {
if (node >= domain.nodes().size() || seen[node] != 0U) {
return recoveryResultFailure<std::vector<EntityIndex>>(
"invalid-node-station-entity",
load.location,
load.target,
"A station-eligibility node set must contain unique valid nodes.");
}
seen[node] = 1U;
}
return Result<std::vector<EntityIndex>>::success(
sets.front()->nodeIndices);
}
if (!nodes.empty()) {
return Result<std::vector<EntityIndex>>::success(std::move(nodes));
}
return recoveryResultFailure<std::vector<EntityIndex>>(
"invalid-node-station-entity",
load.location,
load.target,
"A station-eligibility load target must resolve to a node or node set.");
}
std::array<double, 3> cross(const std::array<double, 3>& left,
const std::array<double, 3>& right) {
return {
left[1U] * right[2U] - left[2U] * right[1U],
left[2U] * right[0U] - left[0U] * right[2U],
left[0U] * right[1U] - left[1U] * right[0U]};
}
double dot(const std::array<double, 3>& left,
const std::array<double, 3>& right) {
return left[0U] * right[0U] + left[1U] * right[1U] +
left[2U] * right[2U];
}
double norm(const std::array<double, 3>& value) {
return std::hypot(value[0U], value[1U], value[2U]);
}
std::optional<AxisSet> localAxes(const Domain& domain,
const EulerBeam3DDefinition& element) {
const auto& first = domain.nodes()[element.nodeIndices[0U]].coordinates;
const auto& second = domain.nodes()[element.nodeIndices[1U]].coordinates;
const std::array<double, 3> delta = {
second[0U] - first[0U],
second[1U] - first[1U],
second[2U] - first[2U]};
const double length = norm(delta);
if (!std::isfinite(length) || !(length > 0.0)) {
return std::nullopt;
}
const std::array<double, 3> ex = {
delta[0U] / length, delta[1U] / length, delta[2U] / length};
const auto& guide = domain.sections()[element.sectionIndex].firstAxis;
const double projection = dot(guide, ex);
const std::array<double, 3> eyTrial = {
guide[0U] - projection * ex[0U],
guide[1U] - projection * ex[1U],
guide[2U] - projection * ex[2U]};
const double eyNorm = norm(eyTrial);
if (!std::isfinite(eyNorm) || !(eyNorm > 0.0)) {
return std::nullopt;
}
const std::array<double, 3> ey = {
eyTrial[0U] / eyNorm,
eyTrial[1U] / eyNorm,
eyTrial[2U] / eyNorm};
const std::array<double, 3> ez = cross(ex, ey);
const AxisSet axes = {ex, ey, ez};
for (const auto& axis : axes) {
for (const double component : axis) {
if (!std::isfinite(component)) {
return std::nullopt;
}
}
}
return axes;
}
bool sameAxes(const AxisSet& left, const AxisSet& right) {
for (std::size_t axis = 0U; axis < left.size(); ++axis) {
for (std::size_t component = 0U;
component < left[axis].size();
++component) {
if (std::abs(left[axis][component] - right[axis][component]) >
kAxisTolerance) {
return false;
}
}
}
return true;
}
} // namespace
Status ResultRecovery::recover(const AnalysisModel& model,
const DofManager& dofs,
const SparseMatrix& fullStiffness,
AnalysisState& state) {
const Status inputStatus =
validateRecoveryInputs(model, dofs, fullStiffness, state);
if (!inputStatus.isOk()) {
return inputStatus;
}
Vector internalForce = fullStiffness.multiply(state.displacement());
if (!finite(internalForce)) {
return recoveryFailure(
"nonfinite-recovery-value",
{model.domain().sourcePath(), 0U},
model.domain().sourceContentIdentity(),
"Full stiffness multiplication must produce finite internal force.");
}
Vector residual{dofs.fullDofCount()};
for (std::size_t fullDof = 0U; fullDof < residual.size(); ++fullDof) {
residual[fullDof] =
internalForce[fullDof] - state.externalForce()[fullDof];
if (!std::isfinite(residual[fullDof])) {
return recoveryFailure(
"nonfinite-recovery-value",
{model.domain().sourcePath(), 0U},
std::to_string(fullDof),
"Internal-minus-external residual must remain finite.");
}
}
const double residualNorm = indexedNorm(residual, dofs.freeDofs());
const double internalNorm = indexedNorm(internalForce, dofs.freeDofs());
const double externalNorm =
indexedNorm(state.externalForce(), dofs.freeDofs());
const double denominator = (std::max)(internalNorm, externalNorm);
if (!std::isfinite(residualNorm) || !std::isfinite(denominator)) {
return recoveryFailure(
"nonfinite-recovery-value",
{model.domain().sourcePath(), 0U},
"free-residual",
"Free residual and its physical normalization scale must be finite.");
}
// An exact zero-load equilibrium is well-defined as zero. No unit floor is
// introduced; a nonzero residual with zero physical scale fails closed.
const double normalizedResidual = denominator == 0.0
? (residualNorm == 0.0
? 0.0
: (std::numeric_limits<double>::infinity)())
: residualNorm / denominator;
if (!std::isfinite(normalizedResidual) ||
normalizedResidual > kFreeResidualTolerance) {
return recoveryFailure(
"free-residual-tolerance-failure",
{model.domain().sourcePath(), 0U},
"free-residual",
"The normalized free residual exceeds 1e-10.");
}
// The full-space reaction dataset preserves free residual evidence while
// its constrained entries are the physical reactions from K*d-F. Element
// end actions remain distinct output and are never re-summed here.
Vector reaction = residual;
std::vector<EndpointResultRow> endpointRows;
std::vector<GaussResultRow> gaussRows;
std::vector<StressS11Row> stressRows;
endpointRows.reserve(model.activeElements().size() * 2U);
gaussRows.reserve(model.activeElements().size() * 2U);
const Domain& domain = model.domain();
for (const EntityIndex elementIndex : model.activeElements()) {
const auto& definition = domain.elements()[elementIndex];
auto beam = EulerBeam3D::create(
domain.nodes()[definition.nodeIndices[0U]],
domain.nodes()[definition.nodeIndices[1U]],
domain.sections()[definition.sectionIndex],
domain.materials()[definition.materialIndex]);
if (!beam.hasValue()) {
return beam.status();
}
Vector elementDisplacement{kElementDofCount};
const auto& scatter = dofs.elementScatter(elementIndex);
for (std::size_t localDof = 0U;
localDof < kElementDofCount;
++localDof) {
elementDisplacement[localDof] =
state.displacement()[scatter[localDof]];
}
const BeamRecovery recovered =
beam.value().recover(elementDisplacement);
for (std::size_t endpoint = 0U; endpoint < 2U; ++endpoint) {
if (!finite(recovered.equilibriumEndActions[endpoint]) ||
!finite(recovered.endpointSectionResultants[endpoint])) {
return recoveryFailure(
"nonfinite-recovery-value",
definition.location,
definition.sourceId.sourceLabelText,
"Endpoint recovery values must be finite.");
}
endpointRows.push_back({
elementIndex,
static_cast<int>(endpoint),
domain.nodes()[definition.nodeIndices[endpoint]].sourceId,
recovered.equilibriumEndActions[endpoint],
recovered.endpointSectionResultants[endpoint]});
}
for (std::size_t gauss = 0U; gauss < 2U; ++gauss) {
if (!finite(recovered.gaussGeneralizedStrains[gauss]) ||
!finite(recovered.gaussGeneralizedResultants[gauss])) {
return recoveryFailure(
"nonfinite-recovery-value",
definition.location,
definition.sourceId.sourceLabelText,
"Gauss recovery values must be finite.");
}
gaussRows.push_back({
elementIndex,
static_cast<int>(gauss + 1U),
recovered.gaussGeneralizedStrains[gauss],
recovered.gaussGeneralizedResultants[gauss]});
}
for (const auto& point : recovered.stressPoints) {
if ((point.gaussPoint != 1 && point.gaussPoint != 2) ||
!std::isfinite(point.x1) || !std::isfinite(point.x2) ||
!std::isfinite(point.s11)) {
return recoveryFailure(
"nonfinite-recovery-value",
definition.location,
definition.sourceId.sourceLabelText,
"Stress recovery identity and values must be finite and ordered.");
}
stressRows.push_back({
elementIndex,
point.gaussPoint,
point.sectionPoint,
point.x1,
point.x2,
point.s11,
point.source});
}
}
// Commit only after all validation and element recovery succeeds so a
// failed recovery cannot leave a partially updated AnalysisState.
state.internalForce() = std::move(internalForce);
state.residual() = std::move(residual);
state.reaction() = std::move(reaction);
state.endpointResults() = std::move(endpointRows);
state.gaussResults() = std::move(gaussRows);
state.stressResults() = std::move(stressRows);
return Status::ok();
}
Result<std::vector<NodeStationResultRow>>
ResultRecovery::normalizeSectionResultantsToNodeStations(
const AnalysisModel& model,
const std::vector<EndpointResultRow>& endpointRows,
const std::array<double, 4>& componentTolerances) {
const Domain& domain = model.domain();
for (const double tolerance : componentTolerances) {
if (!std::isfinite(tolerance) || tolerance < 0.0) {
return recoveryResultFailure<std::vector<NodeStationResultRow>>(
"invalid-node-station-tolerance",
{domain.sourcePath(), 0U},
"component-tolerances",
"Node-station component tolerances must be finite and nonnegative.");
}
}
if (model.activeElements().size() >
(std::numeric_limits<std::size_t>::max)() / 2U ||
endpointRows.size() != model.activeElements().size() * 2U) {
return recoveryResultFailure<std::vector<NodeStationResultRow>>(
"invalid-node-station-shape",
{domain.sourcePath(), 0U},
std::to_string(endpointRows.size()),
"Endpoint rows must contain exactly two rows per active element.");
}
std::vector<std::vector<const EndpointResultRow*>> rowsByNode(
domain.nodes().size());
for (std::size_t order = 0U;
order < model.activeElements().size();
++order) {
const EntityIndex elementIndex = model.activeElements()[order];
if (elementIndex >= domain.elements().size()) {
return recoveryResultFailure<std::vector<NodeStationResultRow>>(
"invalid-node-station-entity",
{domain.sourcePath(), 0U},
std::to_string(elementIndex),
"Every active station element must be a valid stable entity.");
}
const auto& definition = domain.elements()[elementIndex];
for (std::size_t endpoint = 0U; endpoint < 2U; ++endpoint) {
const auto& row = endpointRows[order * 2U + endpoint];
const EntityIndex nodeIndex = definition.nodeIndices[endpoint];
if (nodeIndex >= domain.nodes().size() ||
row.element != elementIndex ||
row.endpoint != static_cast<int>(endpoint) ||
!sameSourceIdentity(row.node, domain.nodes()[nodeIndex].sourceId)) {
return recoveryResultFailure<std::vector<NodeStationResultRow>>(
"invalid-node-station-entity",
definition.location,
definition.sourceId.sourceLabelText,
"Endpoint rows must preserve active element, endpoint, and source-node order.");
}
if (!finite(row.sectionResultant)) {
return recoveryResultFailure<std::vector<NodeStationResultRow>>(
"nonfinite-node-station-value",
definition.location,
definition.sourceId.sourceLabelText,
"Node-station section resultants must be finite.");
}
rowsByNode[nodeIndex].push_back(&row);
}
}
std::vector<unsigned char> loadedNodes(domain.nodes().size(), 0U);
if (model.activeLoads().size() != model.step().loads.size()) {
return recoveryResultFailure<std::vector<NodeStationResultRow>>(
"invalid-node-station-entity",
model.step().location,
model.step().name,
"The active load view must preserve every sole-step load.");
}
for (std::size_t order = 0U; order < model.activeLoads().size(); ++order) {
const EntityIndex loadIndex = model.activeLoads()[order];
if (loadIndex != order || loadIndex >= model.step().loads.size()) {
return recoveryResultFailure<std::vector<NodeStationResultRow>>(
"invalid-node-station-entity",
model.step().location,
std::to_string(loadIndex),
"Active loads must remain in stable source order.");
}
const auto& load = model.step().loads[loadIndex];
if (!std::isfinite(load.magnitude)) {
return recoveryResultFailure<std::vector<NodeStationResultRow>>(
"nonfinite-node-station-value",
load.location,
load.target,
"Station eligibility requires finite concentrated loads.");
}
auto targets = resolveLoadTarget(domain, load);
if (!targets.hasValue()) {
return Result<std::vector<NodeStationResultRow>>::failure(
targets.status());
}
if (load.magnitude != 0.0) {
for (const EntityIndex node : targets.value()) {
loadedNodes[node] = 1U;
}
}
}
std::vector<NodeStationResultRow> stations;
stations.reserve(domain.nodes().size());
for (std::size_t nodeIndex = 0U;
nodeIndex < rowsByNode.size();
++nodeIndex) {
const auto& incident = rowsByNode[nodeIndex];
if (incident.empty()) {
continue;
}
if (incident.size() == 1U) {
stations.push_back({
domain.nodes()[nodeIndex].sourceId,
incident.front()->element,
incident.front()->sectionResultant});
continue;
}
if (incident.size() != 2U || loadedNodes[nodeIndex] != 0U) {
return recoveryResultFailure<std::vector<NodeStationResultRow>>(
"ineligible-node-station",
domain.nodes()[nodeIndex].location,
domain.nodes()[nodeIndex].sourceId.sourceLabelText,
"Interior station collapse requires exactly two unloaded endpoints.");
}
const auto& firstElement = domain.elements()[incident[0U]->element];
const auto& secondElement = domain.elements()[incident[1U]->element];
const bool chainOrientation =
incident[0U]->endpoint != incident[1U]->endpoint &&
((incident[0U]->endpoint == 1 && incident[1U]->endpoint == 0) ||
(incident[0U]->endpoint == 0 && incident[1U]->endpoint == 1));
const auto firstAxes = localAxes(domain, firstElement);
const auto secondAxes = localAxes(domain, secondElement);
if (!chainOrientation ||
firstElement.sectionIndex != secondElement.sectionIndex ||
!firstAxes.has_value() || !secondAxes.has_value() ||
!sameAxes(*firstAxes, *secondAxes)) {
return recoveryResultFailure<std::vector<NodeStationResultRow>>(
"ineligible-node-station",
domain.nodes()[nodeIndex].location,
domain.nodes()[nodeIndex].sourceId.sourceLabelText,
"Interior station endpoints require one consistent section and local-axis chain.");
}
// Endpoint sectionResultant rows already use the positive-local-x cut
// convention. Once common orientation is proven, no outward-action
// endpoint sign is applied and the values are directly comparable.
for (std::size_t component = 0U;
component < componentTolerances.size();
++component) {
const double difference = std::abs(
incident[0U]->sectionResultant[component] -
incident[1U]->sectionResultant[component]);
if (!std::isfinite(difference)) {
return recoveryResultFailure<std::vector<NodeStationResultRow>>(
"nonfinite-node-station-value",
domain.nodes()[nodeIndex].location,
domain.nodes()[nodeIndex].sourceId.sourceLabelText,
"Endpoint comparison must produce a finite difference.");
}
if (difference > componentTolerances[component]) {
return recoveryResultFailure<std::vector<NodeStationResultRow>>(
"node-station-tolerance-failure",
domain.nodes()[nodeIndex].location,
domain.nodes()[nodeIndex].sourceId.sourceLabelText,
"Interior endpoint resultants disagree beyond component tolerance.");
}
}
const EndpointResultRow* representative =
incident[0U]->element < incident[1U]->element
? incident[0U]
: incident[1U];
stations.push_back({
domain.nodes()[nodeIndex].sourceId,
representative->element,
representative->sectionResultant});
}
return Result<std::vector<NodeStationResultRow>>::success(
std::move(stations));
}
} // namespace fesa
+1
View File
@@ -23,6 +23,7 @@ add_executable(
unit/model/domain_test.cpp unit/model/domain_test.cpp
unit/model/model_types_test.cpp unit/model/model_types_test.cpp
unit/results/result_records_test.cpp unit/results/result_records_test.cpp
unit/results/result_recovery_test.cpp
unit/solvers/linear/linear_solver_test.cpp unit/solvers/linear/linear_solver_test.cpp
unit/solvers/linear/mkl_pardiso_solver_test.cpp unit/solvers/linear/mkl_pardiso_solver_test.cpp
) )
+471
View File
@@ -0,0 +1,471 @@
#include "fesa/results/result_recovery.hpp"
#include "fesa/analysis/analysis_model.hpp"
#include "fesa/analysis/analysis_state.hpp"
#include "fesa/assembly/parallel_for.hpp"
#include "fesa/assembly/sparse_assembler.hpp"
#include "fesa/fem/dof_manager.hpp"
#include "fesa/model/domain.hpp"
#include <gtest/gtest.h>
#include <array>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <limits>
#include <memory>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
namespace {
constexpr double kYoungsModulus = 100.0;
constexpr double kPoissonRatio = 0.25;
constexpr double kLength = 2.0;
struct RecoveryFixture {
std::unique_ptr<fesa::Domain> domain;
std::unique_ptr<fesa::AnalysisModel> model;
std::unique_ptr<fesa::DofManager> dofs;
std::unique_ptr<fesa::SparseMatrix> stiffness;
};
fesa::ModelDefinition makeDefinition(
const bool twoElements = false,
std::vector<std::array<double, 2>> sectionPoints = {},
std::vector<fesa::NodalLoad> loads = {},
const bool reverseSecond = false,
const bool sectionJump = false,
const bool nonzeroPrescription = true) {
const std::filesystem::path source{"models/result-recovery.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"}, {kLength, 0.0, 0.0}, {source, 11U}}};
if (twoElements) {
definition.nodes.push_back(
{{"Beam-1", 3, "3"}, {2.0 * kLength, 0.0, 0.0}, {source, 12U}});
}
definition.materials = {
{"Material", kYoungsModulus, kPoissonRatio, {source, 20U}}};
definition.sections = {{
"Section",
2.0,
3.0,
0.0,
4.0,
5.0,
{0.0, 1.0, 0.0},
std::move(sectionPoints),
{source, 30U}}};
if (sectionJump) {
auto secondSection = definition.sections.front();
secondSection.name = "Section-2";
secondSection.area = 2.5;
secondSection.location.line = 31U;
definition.sections.push_back(std::move(secondSection));
}
definition.elements = {
{{"Beam-1", 10, "10"}, {0U, 1U}, 0U, 0U, {source, 40U}}};
if (twoElements) {
definition.elements.push_back({
{"Beam-1", 20, "20"},
reverseSecond ? std::array<fesa::EntityIndex, 2>{2U, 1U}
: std::array<fesa::EntityIndex, 2>{1U, 2U},
0U,
sectionJump ? 1U : 0U,
{source, 41U}});
}
definition.steps = {{
"Step-1",
{{"1", 1, 1, nonzeroPrescription ? 0.1 : 0.0, {source, 50U}},
{"1", 2, 6, 0.0, {source, 51U}}},
std::move(loads),
0.1,
1.0,
0.01,
1.0,
{source, 49U}}};
return definition;
}
RecoveryFixture makeFixture(
const bool twoElements = false,
std::vector<std::array<double, 2>> sectionPoints = {},
std::vector<fesa::NodalLoad> loads = {},
const bool reverseSecond = false,
const bool sectionJump = false,
const bool nonzeroPrescription = true) {
auto domainResult = fesa::Domain::create(makeDefinition(
twoElements,
std::move(sectionPoints),
std::move(loads),
reverseSecond,
sectionJump,
nonzeroPrescription));
if (!domainResult.hasValue()) {
throw std::runtime_error{"Recovery fixture Domain construction failed."};
}
auto domain = std::make_unique<fesa::Domain>(
std::move(domainResult.value()));
auto modelResult = fesa::AnalysisModel::create(*domain);
if (!modelResult.hasValue()) {
throw std::runtime_error{"Recovery fixture AnalysisModel construction failed."};
}
auto model = std::make_unique<fesa::AnalysisModel>(
std::move(modelResult.value()));
auto dofsResult = fesa::DofManager::create(*model);
if (!dofsResult.hasValue()) {
throw std::runtime_error{"Recovery fixture DofManager construction failed."};
}
auto dofs = std::make_unique<fesa::DofManager>(
std::move(dofsResult.value()));
fesa::SerialParallelFor serial;
auto stiffnessResult = fesa::SparseAssembler::assembleStiffness(
*model, *dofs, serial);
if (!stiffnessResult.hasValue()) {
throw std::runtime_error{"Recovery fixture stiffness assembly failed."};
}
auto stiffness = std::make_unique<fesa::SparseMatrix>(
std::move(stiffnessResult.value()));
return {
std::move(domain),
std::move(model),
std::move(dofs),
std::move(stiffness)};
}
fesa::AnalysisState makeAxialEquilibriumState(const RecoveryFixture& fixture) {
auto state = fesa::AnalysisState::create(
*fixture.dofs, {"Step-1", 0U});
state.displacement()[0U] = 0.1;
state.displacement()[6U] = 0.3;
const fesa::Vector internal = fixture.stiffness->multiply(state.displacement());
for (const std::size_t fullDof : fixture.dofs->freeDofs()) {
state.externalForce()[fullDof] = internal[fullDof];
}
return state;
}
fesa::AnalysisState makePatchState(
const RecoveryFixture& fixture,
const double epsilon,
const double twist,
const double kappaY,
const double kappaZ) {
auto state = fesa::AnalysisState::create(
*fixture.dofs, {"Step-1", 0U});
state.displacement()[0U] = 0.1;
state.displacement()[6U] = 0.1 + epsilon * kLength;
state.displacement()[7U] = 0.5 * kappaZ * kLength * kLength;
state.displacement()[8U] = -0.5 * kappaY * kLength * kLength;
state.displacement()[9U] = twist * kLength;
state.displacement()[10U] = kappaY * kLength;
state.displacement()[11U] = kappaZ * kLength;
state.externalForce() = fixture.stiffness->multiply(state.displacement());
return state;
}
void expectStatusCode(const fesa::Status& status, const std::string& code) {
ASSERT_FALSE(status.isOk());
EXPECT_EQ(status.failureCategory(), fesa::FailureCategory::model);
ASSERT_EQ(status.diagnostics().size(), 1U);
EXPECT_EQ(status.diagnostics()[0U].code, code);
}
void expectScaledNear(
const double actual,
const double expected,
const double relativeTolerance = 1.0e-12) {
ASSERT_TRUE(std::isfinite(actual));
ASSERT_TRUE(std::isfinite(expected));
EXPECT_LE(
std::abs(actual - expected),
relativeTolerance * (std::max)(std::abs(expected), 1.0));
}
std::vector<fesa::EndpointResultRow> makeStationRows(
const RecoveryFixture& fixture) {
const auto& nodes = fixture.domain->nodes();
const auto& elements = fixture.domain->elements();
return {
{0U, 0, nodes[elements[0U].nodeIndices[0U]].sourceId,
{0.0, 0.0, 0.0, 0.0, 0.0, 0.0},
{1.0, 2.0, 3.0, 4.0}},
{0U, 1, nodes[elements[0U].nodeIndices[1U]].sourceId,
{0.0, 0.0, 0.0, 0.0, 0.0, 0.0},
{5.0, 6.0, 7.0, 8.0}},
{1U, 0, nodes[elements[1U].nodeIndices[0U]].sourceId,
{0.0, 0.0, 0.0, 0.0, 0.0, 0.0},
{5.0, 6.0, 7.0, 8.0}},
{1U, 1, nodes[elements[1U].nodeIndices[1U]].sourceId,
{0.0, 0.0, 0.0, 0.0, 0.0, 0.0},
{9.0, 10.0, 11.0, 12.0}}};
}
} // namespace
TEST(ResultRecovery, ComputesResidualReactionForNonzeroPrescription) {
const auto fixture = makeFixture();
auto state = makeAxialEquilibriumState(fixture);
const fesa::Status status = fesa::ResultRecovery::recover(
*fixture.model, *fixture.dofs, *fixture.stiffness, state);
ASSERT_TRUE(status.isOk());
EXPECT_DOUBLE_EQ(state.internalForce()[0U], -20.0);
EXPECT_DOUBLE_EQ(state.internalForce()[6U], 20.0);
EXPECT_DOUBLE_EQ(state.residual()[0U], -20.0);
EXPECT_DOUBLE_EQ(state.residual()[6U], 0.0);
EXPECT_DOUBLE_EQ(state.reaction()[0U], -20.0);
for (const std::size_t fullDof : fixture.dofs->freeDofs()) {
EXPECT_DOUBLE_EQ(state.reaction()[fullDof], 0.0);
}
}
TEST(ResultRecovery, EnforcesNormalizedFreeResidual) {
const auto fixture = makeFixture();
auto failed = makeAxialEquilibriumState(fixture);
failed.internalForce()[0U] = 91.0;
failed.residual()[0U] = 92.0;
failed.reaction()[0U] = 93.0;
failed.reaction()[6U] = 94.0;
failed.endpointResults().push_back({});
failed.externalForce()[6U] += 1.0e-7;
expectStatusCode(
fesa::ResultRecovery::recover(
*fixture.model, *fixture.dofs, *fixture.stiffness, failed),
"free-residual-tolerance-failure");
EXPECT_DOUBLE_EQ(failed.internalForce()[0U], 91.0);
EXPECT_DOUBLE_EQ(failed.residual()[0U], 92.0);
EXPECT_DOUBLE_EQ(failed.reaction()[0U], 93.0);
EXPECT_DOUBLE_EQ(failed.reaction()[6U], 94.0);
EXPECT_EQ(failed.endpointResults().size(), 1U);
auto thresholdPass = makeAxialEquilibriumState(fixture);
thresholdPass.externalForce()[6U] += 1.0e-10 * 20.0 * 0.5;
const fesa::Status thresholdStatus = fesa::ResultRecovery::recover(
*fixture.model,
*fixture.dofs,
*fixture.stiffness,
thresholdPass);
ASSERT_TRUE(thresholdStatus.isOk());
EXPECT_NE(thresholdPass.residual()[6U], 0.0);
EXPECT_DOUBLE_EQ(
thresholdPass.reaction()[6U], thresholdPass.residual()[6U]);
const auto zeroFixture = makeFixture(false, {}, {}, false, false, false);
auto zeroEquilibrium = fesa::AnalysisState::create(
*zeroFixture.dofs, {"Step-1", 0U});
EXPECT_TRUE(fesa::ResultRecovery::recover(
*zeroFixture.model,
*zeroFixture.dofs,
*zeroFixture.stiffness,
zeroEquilibrium)
.isOk());
auto wrongPrescription = makeAxialEquilibriumState(fixture);
wrongPrescription.displacement()[0U] = 0.0;
expectStatusCode(
fesa::ResultRecovery::recover(
*fixture.model,
*fixture.dofs,
*fixture.stiffness,
wrongPrescription),
"invalid-recovery-state");
auto nonfinite = makeAxialEquilibriumState(fixture);
nonfinite.displacement()[6U] = std::numeric_limits<double>::quiet_NaN();
expectStatusCode(
fesa::ResultRecovery::recover(
*fixture.model, *fixture.dofs, *fixture.stiffness, nonfinite),
"nonfinite-recovery-value");
const auto wrongFixture = makeFixture(true);
auto wrongState = fesa::AnalysisState::create(
*wrongFixture.dofs, {"Step-1", 0U});
expectStatusCode(
fesa::ResultRecovery::recover(
*fixture.model, *fixture.dofs, *fixture.stiffness, wrongState),
"invalid-recovery-dimensions");
}
TEST(ResultRecovery, KeepsEndActionSectionAndGaussResultsDistinct) {
const auto fixture = makeFixture();
auto state = makePatchState(fixture, 0.02, 0.03, -0.04, 0.05);
ASSERT_TRUE(fesa::ResultRecovery::recover(
*fixture.model, *fixture.dofs, *fixture.stiffness, state)
.isOk());
ASSERT_EQ(state.endpointResults().size(), 2U);
ASSERT_EQ(state.gaussResults().size(), 2U);
EXPECT_EQ(state.endpointResults()[0U].endpoint, 0);
EXPECT_EQ(state.endpointResults()[1U].endpoint, 1);
EXPECT_EQ(state.gaussResults()[0U].gaussPoint, 1);
EXPECT_EQ(state.gaussResults()[1U].gaussPoint, 2);
EXPECT_DOUBLE_EQ(
state.endpointResults()[0U].endAction[0U],
-state.endpointResults()[0U].sectionResultant[0U]);
EXPECT_DOUBLE_EQ(
state.endpointResults()[1U].endAction[0U],
state.endpointResults()[1U].sectionResultant[0U]);
EXPECT_DOUBLE_EQ(
state.gaussResults()[0U].generalizedResultant[0U],
state.endpointResults()[0U].sectionResultant[0U]);
}
TEST(ResultRecovery, MatchesAxialTorsionAndTwoPlaneEndSigns) {
const auto fixture = makeFixture();
const double epsilon = 0.02;
const double twist = -0.03;
const double kappaY = 0.04;
const double kappaZ = -0.05;
auto state = makePatchState(fixture, epsilon, twist, kappaY, kappaZ);
ASSERT_TRUE(fesa::ResultRecovery::recover(
*fixture.model, *fixture.dofs, *fixture.stiffness, state)
.isOk());
const double shearModulus =
kYoungsModulus / (2.0 * (1.0 + kPoissonRatio));
const std::array<double, 4> expected = {
kYoungsModulus * 2.0 * epsilon,
shearModulus * 5.0 * twist,
kYoungsModulus * 3.0 * kappaY,
kYoungsModulus * 4.0 * kappaZ};
const std::array<std::size_t, 4> endComponents = {0U, 3U, 4U, 5U};
for (std::size_t component = 0U; component < expected.size(); ++component) {
expectScaledNear(
state.endpointResults()[0U].sectionResultant[component],
expected[component]);
expectScaledNear(
state.endpointResults()[1U].sectionResultant[component],
expected[component]);
expectScaledNear(
state.endpointResults()[0U].endAction[endComponents[component]],
-expected[component]);
expectScaledNear(
state.endpointResults()[1U].endAction[endComponents[component]],
expected[component]);
}
}
TEST(ResultRecovery, OrdersStressPointsAndDefaultCentroid) {
const std::vector<std::array<double, 2>> sectionPoints = {
{0.25, -0.5}, {-0.4, 0.3}};
const auto fixture = makeFixture(false, sectionPoints);
auto state = makePatchState(fixture, 0.01, 0.0, 0.02, -0.03);
ASSERT_TRUE(fesa::ResultRecovery::recover(
*fixture.model, *fixture.dofs, *fixture.stiffness, state)
.isOk());
ASSERT_EQ(state.stressResults().size(), 4U);
for (std::size_t gauss = 0U; gauss < 2U; ++gauss) {
for (std::size_t point = 0U; point < sectionPoints.size(); ++point) {
const auto& row = state.stressResults()[gauss * 2U + point];
EXPECT_EQ(row.element, 0U);
EXPECT_EQ(row.gaussPoint, static_cast<int>(gauss + 1U));
EXPECT_EQ(row.sectionPoint, point + 1U);
EXPECT_DOUBLE_EQ(row.x1, sectionPoints[point][0U]);
EXPECT_DOUBLE_EQ(row.x2, sectionPoints[point][1U]);
EXPECT_EQ(row.source, "input");
expectScaledNear(
row.s11,
kYoungsModulus *
(0.01 + row.x2 * 0.02 - row.x1 * -0.03));
}
}
const auto defaultFixture = makeFixture();
auto defaultState = makePatchState(defaultFixture, 0.01, 0.0, 0.0, 0.0);
ASSERT_TRUE(fesa::ResultRecovery::recover(
*defaultFixture.model,
*defaultFixture.dofs,
*defaultFixture.stiffness,
defaultState)
.isOk());
ASSERT_EQ(defaultState.stressResults().size(), 2U);
for (const auto& row : defaultState.stressResults()) {
EXPECT_EQ(row.sectionPoint, 0U);
EXPECT_DOUBLE_EQ(row.x1, 0.0);
EXPECT_DOUBLE_EQ(row.x2, 0.0);
EXPECT_EQ(row.source, "fesa-default");
}
}
TEST(ResultRecovery, RequiresInteriorEndpointConsistencyWithoutAveraging) {
const auto fixture = makeFixture(true);
const std::array<double, 4> tolerances = {1.0e-6, 1.0e-6, 1.0e-6, 1.0e-6};
auto rows = makeStationRows(fixture);
rows[2U].sectionResultant[0U] += 0.5e-6;
auto normalized =
fesa::ResultRecovery::normalizeSectionResultantsToNodeStations(
*fixture.model, rows, tolerances);
ASSERT_TRUE(normalized.hasValue());
ASSERT_EQ(normalized.value().size(), 3U);
EXPECT_EQ(normalized.value()[1U].representativeElement, 0U);
EXPECT_DOUBLE_EQ(normalized.value()[1U].sectionResultant[0U], 5.0);
rows[2U].sectionResultant[0U] = 5.0 + 2.0e-6;
auto mismatch =
fesa::ResultRecovery::normalizeSectionResultantsToNodeStations(
*fixture.model, rows, tolerances);
ASSERT_FALSE(mismatch.hasValue());
expectStatusCode(mismatch.status(), "node-station-tolerance-failure");
rows = makeStationRows(fixture);
rows[2U].sectionResultant[1U] =
std::numeric_limits<double>::infinity();
auto nonfinite =
fesa::ResultRecovery::normalizeSectionResultantsToNodeStations(
*fixture.model, rows, tolerances);
ASSERT_FALSE(nonfinite.hasValue());
expectStatusCode(nonfinite.status(), "nonfinite-node-station-value");
auto invalidTolerance =
fesa::ResultRecovery::normalizeSectionResultantsToNodeStations(
*fixture.model,
makeStationRows(fixture),
{1.0e-6, -1.0, 1.0e-6, 1.0e-6});
ASSERT_FALSE(invalidTolerance.hasValue());
expectStatusCode(
invalidTolerance.status(), "invalid-node-station-tolerance");
const std::filesystem::path source{"models/result-recovery.inp"};
const auto loadedFixture = makeFixture(
true, {}, {{"2", 2, 1.0, {source, 60U}}});
auto loaded =
fesa::ResultRecovery::normalizeSectionResultantsToNodeStations(
*loadedFixture.model,
makeStationRows(loadedFixture),
tolerances);
ASSERT_FALSE(loaded.hasValue());
expectStatusCode(loaded.status(), "ineligible-node-station");
const auto reversedFixture = makeFixture(true, {}, {}, true);
auto reversed =
fesa::ResultRecovery::normalizeSectionResultantsToNodeStations(
*reversedFixture.model,
makeStationRows(reversedFixture),
tolerances);
ASSERT_FALSE(reversed.hasValue());
expectStatusCode(reversed.status(), "ineligible-node-station");
const auto jumpFixture = makeFixture(true, {}, {}, false, true);
auto jumped =
fesa::ResultRecovery::normalizeSectionResultantsToNodeStations(
*jumpFixture.model,
makeStationRows(jumpFixture),
tolerances);
ASSERT_FALSE(jumped.hasValue());
expectStatusCode(jumped.status(), "ineligible-node-station");
}