Connect HHT field convergence to production acceptance and v2 logs

This commit is contained in:
KOKO\Mimi
2026-10-19 02:49:53 +09:00
parent 0e392a7364
commit dd54b0ae1e
9 changed files with 811 additions and 23 deletions
@@ -8,9 +8,16 @@
#include <fstream>
#include <iterator>
#include <optional>
#include <sstream>
#include <map>
#include <string>
#include "fesa/analysis/newton_iteration_log.h"
#include "fesa/analysis/analysis_model.h"
#include "analysis/hht_dynamic_internal.h"
#include "analysis/hht_reduced_integrator.h"
#include "analysis/hht_reduced_procedure.h"
#include "analysis/equilibrium_algorithm.h"
#include "fesa/assembly/parallel_for.h"
#include "fesa/solvers/linear/mkl_pardiso_solver.h"
#include "fesa/solvers/linear/mkl_pardiso_tangent_solver.h"
@@ -569,3 +576,355 @@ TEST(HhtDynamicProcedure,
}
} // namespace
namespace {
std::map<std::string, std::string> LogFields(const std::string& line) {
std::map<std::string, std::string> fields;
std::istringstream input(line);
std::string token;
while (std::getline(input, token, '\t')) {
const auto equal = token.find('=');
if (equal != std::string::npos) fields.emplace(token.substr(0, equal), token.substr(equal + 1));
}
return fields;
}
}
namespace {
fesa::ModelDefinition SymmetricExtension() {
auto definition = MakeDefinition(false);
definition.dynamic_step->initial_increment = 0.1;
definition.dynamic_step->time_period = 0.1;
definition.dynamic_step->increment_count = 1U;
definition.node_sets.push_back({"Left", std::nullopt, {0U, 3U}, {"dynamic-state.inp", 30U}});
definition.node_sets.push_back({"Right", std::nullopt, {1U, 2U}, {"dynamic-state.inp", 31U}});
definition.dynamic_step->boundaries = {
{"All", 2, 6, 0.0, {"dynamic-state.inp", 32U}}};
definition.dynamic_step->loads = {
{"Left", 1, -1.0, {"dynamic-state.inp", 33U}},
{"Right", 1, 1.0, {"dynamic-state.inp", 34U}}};
return definition;
}
}
TEST(HhtDynamicProcedure, FieldPolicyActuallyTerminatesSymmetricExtensionBeforeStrict) {
auto definition = SymmetricExtension();
fesa::SerialParallelFor parallel;
auto run = RunRigidTranslation(definition, parallel, "field-policy-extension");
ASSERT_TRUE(run.HasValue()) << (run.HasValue() ? "" : FailureText(run.GetStatus()));
const auto& frame = run.Value().execution.state.CommittedState();
ASSERT_GT(frame.iteration_count, 0U);
ASSERT_LE(frame.iteration_count, 100U);
const auto& text = run.Value().log;
const auto accepted = text.rfind("record=dynamic-newton-iteration");
ASSERT_NE(accepted, std::string::npos);
const auto line = text.substr(accepted);
EXPECT_NE(line.find("\tschema=hht-log-v2\t"), std::string::npos);
EXPECT_NE(line.find("\tpolicy_id=abaqus2025-field-on-fesa-v1\t"), std::string::npos);
EXPECT_NE(line.find("\tstep=Transient Load\t"), std::string::npos);
EXPECT_NE(line.find("\tfield_pass=1\taccepted=1\t"), std::string::npos);
EXPECT_NE(line.find("\tstrict_correction_pass=0"), std::string::npos);
for (const auto value : frame.global_equilibrium) EXPECT_LE(std::abs(value), 1e-8);
auto fields = LogFields(line.substr(0, line.find('\n')));
double rho = 0, increment = 0, flux = 4.0;
for (std::size_t node = 0; node < 4; ++node) {
rho = (std::max)(rho, std::abs(frame.residual[6 * node]));
increment = (std::max)(increment, std::abs(frame.generalized_coordinates[6 * node]));
for (std::size_t c = 0; c < 3; ++c)
flux += std::abs(frame.internal_force[6 * node + c] + frame.inertia_force[6 * node + c]);
}
EXPECT_DOUBLE_EQ(std::stod(fields.at("force_residual_numerator")), rho);
EXPECT_DOUBLE_EQ(std::stod(fields.at("force_increment_norm")), increment);
EXPECT_DOUBLE_EQ(std::stod(fields.at("force_spatial_flux")), flux / 24.0);
EXPECT_DOUBLE_EQ(std::stod(fields.at("force_time_average_flux")), flux / 24.0);
EXPECT_LE(rho, .005 * std::stod(fields.at("force_residual_scale")));
EXPECT_DOUBLE_EQ(std::stod(fields.at("force_residual_threshold")), .005);
EXPECT_DOUBLE_EQ(std::stod(fields.at("force_correction_threshold")), .01);
EXPECT_EQ(fields.at("force_residual_pass"), "1");
EXPECT_EQ(fields.at("force_correction_pass"), "1");
const double c = std::stod(fields.at("force_correction_numerator"));
const auto& estimated = fields.at("force_estimated_correction");
EXPECT_TRUE(c <= .01 * increment ||
(estimated != "not-evaluated" && std::stod(estimated) <= .01 * increment));
EXPECT_EQ(fields.at("force_branch"), "normal");
EXPECT_EQ(fields.at("moment_branch"), "not-evaluated");
// Symmetric monotone extension has q=(-D,+D,+D,-D). This independent
// endpoint oracle includes the selected iteration-zero start, not the lift.
std::istringstream records(text);
std::string row;
double previous_D = 0;
bool first = true;
while (std::getline(records, row)) {
const auto values = LogFields(row);
if (values.at("schema") != "hht-log-v2") continue;
const double D = std::stod(values.at("force_increment_norm"));
if (first) {
EXPECT_EQ(values.at("force_correction_numerator"), "not-evaluated");
EXPECT_EQ(values.at("force_estimated_correction"), "not-evaluated");
EXPECT_EQ(values.at("field_pass"), "0");
} else {
EXPECT_NEAR(std::stod(values.at("force_correction_numerator")),
std::abs(D - previous_D), 1e-16);
}
previous_D = D;
first = false;
}
}
TEST(HhtDynamicProcedure, FieldPassDoesNotBypassPhysicalAcceptanceAndLogsActualCause) {
auto definition = MakeDefinition(false);
definition.materials[0].youngs_modulus = 1e9;
definition.dynamic_step->increment_count = 1;
definition.dynamic_step->time_period = definition.dynamic_step->initial_increment;
definition.node_sets[0].node_indices = {0U, 3U};
definition.node_sets[1].node_indices = {2U};
definition.dynamic_step->boundaries = {{"All", 1, 6, 0, {"dynamic-state.inp", 11U}}};
definition.dynamic_step->loads = {{"Loaded", 3, -40000, {"dynamic-state.inp", 12U}}};
auto domain = fesa::Domain::Create(definition);
ASSERT_TRUE(domain.HasValue());
fesa::SerialParallelFor parallel;
fesa::MklPardisoSolver mass;
fesa::MklPardisoTangentSolver tangent;
fesa::NewtonIterationLogWriter log;
const auto path = std::filesystem::temp_directory_path() / "fesa-field-post-gate.log";
ASSERT_TRUE(log.Open(path).IsOk());
auto result = fesa::HhtDynamicProcedureExecutor(parallel, mass, tangent).Execute(domain.Value(), log);
ASSERT_TRUE(log.Close().IsOk());
std::ifstream input(path, std::ios::binary);
const std::string text{std::istreambuf_iterator<char>{input}, {}};
input.close();
std::filesystem::remove(path);
ASSERT_FALSE(result.HasValue());
const auto row = text.substr(text.rfind("record="));
const auto fields = LogFields(row.substr(0, row.find('\n')));
EXPECT_EQ(fields.at("field_pass"), "1");
EXPECT_EQ(fields.at("accepted"), "0");
EXPECT_EQ(fields.at("outcome"), "failed");
const auto cause = std::find_if(result.GetStatus().Diagnostics().begin(),
result.GetStatus().Diagnostics().end(), [](const auto& d) {
return d.code != "dynamic-procedure-context";
});
ASSERT_NE(cause, result.GetStatus().Diagnostics().end());
EXPECT_EQ(fields.at("failure_code"), cause->code) << FailureText(result.GetStatus());
EXPECT_EQ(text.find("\taccepted=1"), std::string::npos);
}
TEST(HhtDynamicProcedure, AcceptedFluxHistoryCountsEachIncrementExactlyOnce) {
auto definition = SymmetricExtension();
definition.dynamic_step->time_period = .2;
definition.dynamic_step->increment_count = 2;
fesa::SerialParallelFor parallel;
auto run = RunRigidTranslation(definition, parallel, "field-history");
ASSERT_TRUE(run.HasValue()) << (run.HasValue() ? "" : FailureText(run.GetStatus()));
const auto& frames = run.Value().execution.state.AcceptedFrames();
ASSERT_EQ(frames.size(), 3U);
double sum = 0, maximum_sum = 0;
std::size_t n = 0;
std::istringstream input(run.Value().log);
std::string row;
while (std::getline(input, row)) {
const auto fields = LogFields(row);
if (fields.at("schema") != "hht-log-v2" || fields.at("accepted") != "1") continue;
++n;
const auto& frame = frames[n];
std::vector<double> flux;
for (std::size_t node = 0; node < 4; ++node)
for (std::size_t c = 0; c < 3; ++c)
flux.push_back(std::abs(frame.internal_force[6 * node + c] + frame.inertia_force[6 * node + c]));
for (int node = 0; node < 4; ++node) {
flux.push_back(1); flux.push_back(0); flux.push_back(0);
}
const double m = *std::max_element(flux.begin(), flux.end());
const double H = n == 1 ? 0 : maximum_sum / static_cast<double>(n - 1);
double selected_sum = 0;
std::size_t count = 0;
for (double x : flux)
if (m < .1 * H || x >= 1e-5 * H) { selected_sum += x; ++count; }
ASSERT_GT(count, 0U);
const double spatial = selected_sum / static_cast<double>(count);
sum += spatial; maximum_sum += m;
EXPECT_DOUBLE_EQ(std::stod(fields.at("force_spatial_flux")), spatial);
EXPECT_DOUBLE_EQ(std::stod(fields.at("force_time_average_flux")), sum / static_cast<double>(n));
EXPECT_EQ(std::stoul(fields.at("iteration")), frame.iteration_count);
}
EXPECT_EQ(n, 2U);
}
TEST(HhtDynamicProcedure, LoadFluxPreservesSourceCancellationAndExplicitZeroEntries) {
auto definition = MakeDefinition(true);
definition.dynamic_step->loads = {
{"Loaded", 1, 3, {"dynamic-state.inp", 30U}},
{"Loaded", 1, -3, {"dynamic-state.inp", 31U}},
{"Loaded", 2, 0, {"dynamic-state.inp", 32U}}};
auto domain = fesa::Domain::Create(definition);
ASSERT_TRUE(domain.HasValue());
auto model = fesa::AnalysisModel::Create(domain.Value());
ASSERT_TRUE(model.HasValue());
auto dofs = fesa::DofManager::Create(model.Value());
ASSERT_TRUE(dofs.HasValue());
std::array<std::vector<fesa::hht_field_convergence::FluxOccurrence>, 2> flux;
const auto loads = fesa::hht_internal::AssembleLoads(domain.Value(), dofs.Value(), &flux);
ASSERT_TRUE(loads.HasValue());
EXPECT_DOUBLE_EQ(loads.Value()[0][0], 0);
EXPECT_DOUBLE_EQ(loads.Value()[1][0], 6);
ASSERT_EQ(flux[0].size(), 9U);
ASSERT_EQ(flux[1].size(), 9U);
const std::array<double, 9> expected{3, 0, 0, -3, 0, 0, 0, 0, 0};
for (std::size_t i = 0; i < expected.size(); ++i) {
EXPECT_DOUBLE_EQ(flux[0][i].internal, expected[i]);
EXPECT_DOUBLE_EQ(flux[0][i].inertia, 0);
EXPECT_DOUBLE_EQ(flux[1][i].internal, 0);
}
}
namespace {
// All-fixed fixture has no private coordinates. This adapter exercises the
// real production acceptance/output transaction without a test solver path.
class EmptyCoordinateIntegrator final : public fesa::hht_reduced_internal::ShellHhtIntegrator {
public:
fesa::hht_reduced_internal::ReducedShellPredictor Predict(
const fesa::hht_reduced_internal::ReducedHistory& history, double,
const fesa::HhtParameters&) const override {
EXPECT_EQ(history.p.Size(), 0U);
return {};
}
void EvaluateRates(const fesa::hht_reduced_internal::ReducedShellRateRequest&,
fesa::Vector& v, fesa::Vector& a) const override {
EXPECT_EQ(v.Size(), 0U); EXPECT_EQ(a.Size(), 0U);
}
fesa::Result<fesa::hht_reduced_internal::ReconstructedRates> ReconstructPhysicalRates(
const fesa::hht_reduced_internal::FixedChart& chart,
const fesa::hht_reduced_internal::EmbeddingRoot& root,
const fesa::Vector& v, const fesa::Vector& a,
fesa::TangentSolver& solver) const override {
return fesa::hht_reduced_internal::ReconstructPhysicalRates(chart, root, v, a, solver);
}
};
class CloseAtCandidate final : public fesa::EquilibriumAlgorithm {
public:
explicit CloseAtCandidate(fesa::NewtonIterationLogWriter& log) : log_(log) {}
fesa::Status SolveCurrentStep(std::uint32_t first, std::uint32_t last,
const Evaluation& evaluate) override {
EXPECT_EQ(first, 0U); EXPECT_EQ(last, 100U);
auto value = evaluate(first);
if (!value.HasValue()) return value.GetStatus();
EXPECT_TRUE(value.Value());
return log_.Close();
}
private:
fesa::NewtonIterationLogWriter& log_;
};
}
TEST(HhtDynamicProcedure, FailedAcceptanceOutputPublishesNoCandidateAndFreshRetryIsUnchanged) {
auto domain = fesa::Domain::Create(MakeDefinition());
ASSERT_TRUE(domain.HasValue());
fesa::SerialParallelFor parallel;
fesa::MklPardisoSolver mass;
fesa::MklPardisoTangentSolver tangent;
fesa::NewtonIterationLogWriter log;
const auto path = std::filesystem::temp_directory_path() / "fesa-field-output-fail.log";
ASSERT_TRUE(log.Open(path).IsOk());
EmptyCoordinateIntegrator integrator;
CloseAtCandidate algorithm(log);
const auto result = fesa::ExecuteReducedHht(domain.Value(), log, parallel, mass, tangent, integrator, algorithm);
ASSERT_FALSE(result.HasValue());
EXPECT_EQ(result.GetStatus().Category(), fesa::FailureCategory::kOutput);
std::ifstream input(path, std::ios::binary);
const std::string text{std::istreambuf_iterator<char>{input}, {}};
input.close(); std::filesystem::remove(path);
EXPECT_EQ(text.find("accepted=1"), std::string::npos);
auto first = RunRigidTranslation(MakeDefinition(), parallel, "field-retry");
auto second = RunRigidTranslation(MakeDefinition(), parallel, "field-retry-repeat");
ASSERT_TRUE(first.HasValue()); ASSERT_TRUE(second.HasValue());
ExpectSameDynamicHistory(first.Value(), second.Value());
}
namespace {
class FailNextSelectedTrial final : public fesa::TangentSolver {
public:
fesa::Status FactorizeFresh(const fesa::SparseMatrix& matrix) override {
if (outer_solved_) return fesa::Status::Failure(fesa::FailureCategory::kSolver,
{{fesa::Severity::kError, "injected-next-trial", {}, "DYNAMIC", {}, "Fail before the next selected field evaluation."}});
large_ = matrix.Rows() > 4;
return solver_.FactorizeFresh(matrix);
}
fesa::Status SolveCorrection(const fesa::Vector& rhs, fesa::Vector& solution) const override {
auto status = solver_.SolveCorrection(rhs, solution);
if (status.IsOk() && large_) outer_solved_ = true;
return status;
}
private:
fesa::MklPardisoTangentSolver solver_;
bool large_ = false;
mutable bool outer_solved_ = false;
};
}
TEST(HhtDynamicProcedure, FailureBeforeNextEvaluationDoesNotReusePreviousFieldEvidence) {
auto domain = fesa::Domain::Create(MakeFiniteRotationDefinition());
ASSERT_TRUE(domain.HasValue());
fesa::SerialParallelFor parallel;
fesa::MklPardisoSolver mass;
FailNextSelectedTrial tangent;
fesa::NewtonIterationLogWriter log;
const auto path = std::filesystem::temp_directory_path() / "fesa-field-next-fail.log";
ASSERT_TRUE(log.Open(path).IsOk());
const auto result = fesa::HhtDynamicProcedureExecutor(parallel, mass, tangent).Execute(domain.Value(), log);
ASSERT_TRUE(log.Close().IsOk());
std::ifstream input(path, std::ios::binary);
const std::string text{std::istreambuf_iterator<char>{input}, {}};
input.close(); std::filesystem::remove(path);
ASSERT_FALSE(result.HasValue());
const auto row = text.substr(text.rfind("record="));
const auto fields = LogFields(row.substr(0, row.find('\n')));
EXPECT_EQ(fields.at("failure_code"), "injected-next-trial") << FailureText(result.GetStatus());
EXPECT_EQ(fields.at("iteration"), "1");
EXPECT_EQ(fields.at("force_residual_numerator"), "not-evaluated");
EXPECT_EQ(fields.at("moment_residual_scale"), "not-evaluated");
EXPECT_EQ(fields.at("field_pass"), "not-evaluated");
EXPECT_EQ(fields.at("accepted"), "0");
}
namespace {
class EvaluateEndpoint100 final : public fesa::EquilibriumAlgorithm {
public:
fesa::Status SolveCurrentStep(std::uint32_t first, std::uint32_t last,
const Evaluation& evaluate) override {
EXPECT_EQ(first, 0U); EXPECT_EQ(last, 100U);
auto value = evaluate(last);
if (!value.HasValue()) return value.GetStatus();
EXPECT_TRUE(value.Value());
return fesa::Status::Ok();
}
};
}
TEST(HhtDynamicProcedure, FieldAcceptanceEvaluatesProductionEndpoint100BeforeCorrectionBudget) {
auto domain = fesa::Domain::Create(MakeDefinition());
ASSERT_TRUE(domain.HasValue());
fesa::SerialParallelFor parallel;
fesa::MklPardisoSolver mass;
fesa::MklPardisoTangentSolver tangent;
fesa::NewtonIterationLogWriter log;
const auto path = std::filesystem::temp_directory_path() / "fesa-field-endpoint100.log";
ASSERT_TRUE(log.Open(path).IsOk());
EmptyCoordinateIntegrator integrator;
EvaluateEndpoint100 algorithm;
// Tests the real endpoint callback at the inclusive boundary. No physical
// trajectory is forced through 100 corrections; all fixed coordinates stay zero.
const auto result = fesa::ExecuteReducedHht(domain.Value(), log, parallel, mass, tangent, integrator, algorithm);
ASSERT_TRUE(log.Close().IsOk());
std::ifstream input(path, std::ios::binary);
const std::string text{std::istreambuf_iterator<char>{input}, {}};
input.close(); std::filesystem::remove(path);
ASSERT_TRUE(result.HasValue()) << (result.HasValue() ? "" : FailureText(result.GetStatus()));
ASSERT_EQ(result.Value().state.AcceptedFrames().size(), 3U);
EXPECT_EQ(result.Value().state.AcceptedFrames()[1].iteration_count, 100U);
EXPECT_EQ(result.Value().state.AcceptedFrames()[2].iteration_count, 100U);
const auto row = text.substr(text.rfind("record="));
const auto fields = LogFields(row.substr(0, row.find('\n')));
EXPECT_EQ(fields.at("iteration"), "100");
EXPECT_EQ(fields.at("field_pass"), "1");
EXPECT_EQ(fields.at("accepted"), "1");
}