diff --git a/include/fesa/analysis/newton_iteration_log.h b/include/fesa/analysis/newton_iteration_log.h new file mode 100644 index 0000000..6c2b9c4 --- /dev/null +++ b/include/fesa/analysis/newton_iteration_log.h @@ -0,0 +1,89 @@ +#ifndef FESA_ANALYSIS_NEWTON_ITERATION_LOG_H_ +#define FESA_ANALYSIS_NEWTON_ITERATION_LOG_H_ + +#include +#include +#include +#include + +#include "fesa/analysis/nonlinear_static_control.h" +#include "fesa/core/status.h" + +namespace fesa { + +/// @brief Identifies the latest Newton procedure stage reached by a record. +enum class NewtonIterationStage { + kEntered, + kKinematicsEvaluated, + kInternalAssembled, + kExternalAssembled, + kSolvedCorrection, + kConvergenceEvaluated, + kAccepted, + kFailed, +}; + +/// @brief Carries the controlled failure that ended one Newton iteration. +struct NewtonIterationFailure { + bool present; + NewtonIterationStage stage; + std::string diagnostic_code; + std::string reason; +}; + +/// @brief Carries one fixed-schema Newton iteration diagnostic record. +struct NewtonIterationLogRecord { + std::string step_name; + std::uint32_t increment_index; + double load_factor; + std::uint32_t iteration; + NewtonIterationStage last_completed_stage; + NewtonIterationFailure failure; + std::string transition; + std::string outcome; + std::string diagnostic_code; + NewtonChannelDecision force_residual; + NewtonChannelDecision moment_residual; + NewtonChannelDecision translation_correction; + NewtonChannelDecision rotation_correction; + bool combined_passed; +}; + +/// @brief Writes deterministic, line-oriented Newton iteration diagnostics. +/// @note A write, flush, or close failure permanently prevents a successful +/// log lifecycle for this writer instance. +class NewtonIterationLogWriter { + public: + /// @brief Opens and truncates the diagnostic log before Newton execution. + /// @param log_path Derived application-owned diagnostic-log path. + /// @return Success when the writer owns an empty binary log, otherwise an + /// output failure with code `log-output-failure`. + Status Open(const std::filesystem::path& log_path); + + /// @brief Writes one complete LF-terminated Newton iteration record. + /// @param record Explicit stage, outcome, and typed convergence decision. + /// @return Success after writing a complete record, otherwise an output + /// failure with code `log-output-failure`. + Status Write(const NewtonIterationLogRecord& record); + + /// @brief Flushes every complete record written so far to the log. + /// @return Success after the stream flush, otherwise an output failure with + /// code `log-output-failure`. + Status Flush(); + + /// @brief Closes the log before authoritative result finalization. + /// @return Success after close, otherwise an output failure with code + /// `log-output-failure`. + Status Close(); + + private: + enum class State { kReady, kOpen, kClosed, kFailed }; + + std::ofstream stream_; + std::filesystem::path log_path_; + State state_{State::kReady}; +}; + +} // namespace fesa + +#endif // FESA_ANALYSIS_NEWTON_ITERATION_LOG_H_ diff --git a/src/fesa/CMakeLists.txt b/src/fesa/CMakeLists.txt index 11580a7..f075443 100644 --- a/src/fesa/CMakeLists.txt +++ b/src/fesa/CMakeLists.txt @@ -4,6 +4,7 @@ add_library( analysis/analysis_model.cpp analysis/analysis_state.cpp analysis/linear_static_analysis.cpp + analysis/newton_iteration_log.cpp analysis/nonlinear_static_control.cpp app/fesa_application.cpp app/output_paths.cpp diff --git a/src/fesa/analysis/newton_iteration_log.cpp b/src/fesa/analysis/newton_iteration_log.cpp new file mode 100644 index 0000000..9c96ee7 --- /dev/null +++ b/src/fesa/analysis/newton_iteration_log.cpp @@ -0,0 +1,328 @@ +#include "fesa/analysis/newton_iteration_log.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fesa { +namespace { + +struct ChannelSpec { + std::string_view label_prefix; + std::string_view identity; +}; + +Status LogOutputFailure(const std::filesystem::path& log_path, + const std::string& message) { + return Status::Failure( + FailureCategory::kOutput, + {{Severity::kError, + "log-output-failure", + {{}, 0U}, + "", + log_path.generic_u8string(), + message}}); +} + +std::string EscapeText(const std::string_view value) { + constexpr char kHexDigits[] = "0123456789ABCDEF"; + std::string escaped; + escaped.reserve(value.size()); + for (const unsigned char byte : value) { + switch (byte) { + case '\\': + escaped += "\\\\"; + break; + case '\t': + escaped += "\\t"; + break; + case '\n': + escaped += "\\n"; + break; + case '\r': + escaped += "\\r"; + break; + case '\0': + escaped += "\\0"; + break; + default: + if (byte < 0x20U || byte == 0x7FU) { + escaped += "\\x"; + escaped += kHexDigits[byte >> 4U]; + escaped += kHexDigits[byte & 0x0FU]; + } else { + escaped += static_cast(byte); + } + break; + } + } + return escaped; +} + +Result FormatFiniteNumber(const double value, + const std::filesystem::path& log_path) { + if (!std::isfinite(value)) { + return Result::Failure( + LogOutputFailure(log_path, "A Newton log numeric value is nonfinite.")); + } + + std::array buffer{}; + const auto conversion = std::to_chars( + buffer.data(), buffer.data() + buffer.size(), value, + std::chars_format::general, std::numeric_limits::max_digits10); + if (conversion.ec != std::errc{}) { + return Result::Failure( + LogOutputFailure(log_path, "A Newton log numeric value could not format.")); + } + return Result::Success( + std::string{buffer.data(), conversion.ptr}); +} + +Result BranchLabel( + const NewtonConvergenceBranch branch, + const std::filesystem::path& log_path) { + switch (branch) { + case NewtonConvergenceBranch::kRelative: + return Result::Success("relative"); + case NewtonConvergenceBranch::kZeroScale: + return Result::Success("zero-scale"); + } + return Result::Failure(LogOutputFailure( + log_path, "A Newton log convergence branch is not supported.")); +} + +Result UnitLabel(const NewtonChannelUnit unit, + const std::filesystem::path& log_path) { + switch (unit) { + case NewtonChannelUnit::kForce: + return Result::Success("force"); + case NewtonChannelUnit::kForceLength: + return Result::Success("force*length"); + case NewtonChannelUnit::kLength: + return Result::Success("length"); + case NewtonChannelUnit::kDimensionless: + return Result::Success("1"); + } + return Result::Failure(LogOutputFailure( + log_path, "A Newton log threshold unit is not supported.")); +} + +std::string StageLabel(const NewtonIterationStage stage) { + switch (stage) { + case NewtonIterationStage::kEntered: + return "iteration-entered"; + case NewtonIterationStage::kKinematicsEvaluated: + return "trial-state"; + case NewtonIterationStage::kInternalAssembled: + return "residual"; + case NewtonIterationStage::kExternalAssembled: + return "load-boundary"; + case NewtonIterationStage::kSolvedCorrection: + return "correction"; + case NewtonIterationStage::kConvergenceEvaluated: + return "convergence-gates"; + case NewtonIterationStage::kAccepted: + return "commit"; + case NewtonIterationStage::kFailed: + return "failed"; + } + return "failed"; +} + +bool AreChannelsEvaluated(const NewtonIterationLogRecord& record) { + if (!record.failure.present) { + return true; + } + return record.last_completed_stage == + NewtonIterationStage::kConvergenceEvaluated || + record.last_completed_stage == NewtonIterationStage::kAccepted; +} + +void AppendField(std::vector* fields, const std::string_view label, + const std::string& value) { + fields->push_back(std::string{label} + "=" + value); +} + +Status AppendChannelFields( + std::vector* fields, const ChannelSpec spec, + const NewtonChannelDecision& decision, const bool evaluated, + const std::string& not_evaluated, const std::filesystem::path& log_path) { + const std::string prefix{spec.label_prefix}; + if (!evaluated) { + for (const std::string_view suffix : + {"channel", "numerator_norm", "denominator_scale", "branch", + "threshold", "threshold_unit", "pass"}) { + AppendField(fields, prefix + "_" + std::string{suffix}, not_evaluated); + } + return Status::Ok(); + } + + if (decision.identity != spec.identity) { + return LogOutputFailure(log_path, + "A Newton log channel identity is invalid."); + } + const auto numerator = FormatFiniteNumber(decision.numerator_norm, log_path); + if (!numerator.HasValue()) { + return numerator.GetStatus(); + } + const auto denominator = + FormatFiniteNumber(decision.denominator_scale, log_path); + if (!denominator.HasValue()) { + return denominator.GetStatus(); + } + const auto branch = BranchLabel(decision.branch, log_path); + if (!branch.HasValue()) { + return branch.GetStatus(); + } + const auto threshold = FormatFiniteNumber(decision.threshold, log_path); + if (!threshold.HasValue()) { + return threshold.GetStatus(); + } + const auto unit = UnitLabel(decision.threshold_unit, log_path); + if (!unit.HasValue()) { + return unit.GetStatus(); + } + + AppendField(fields, prefix + "_channel", decision.identity); + AppendField(fields, prefix + "_numerator_norm", numerator.Value()); + AppendField(fields, prefix + "_denominator_scale", denominator.Value()); + AppendField(fields, prefix + "_branch", std::string{branch.Value()}); + AppendField(fields, prefix + "_threshold", threshold.Value()); + AppendField(fields, prefix + "_threshold_unit", std::string{unit.Value()}); + AppendField(fields, prefix + "_pass", decision.passed ? "1" : "0"); + return Status::Ok(); +} + +Result SerializeRecord(const NewtonIterationLogRecord& record, + const std::filesystem::path& log_path) { + const auto load_factor = FormatFiniteNumber(record.load_factor, log_path); + if (!load_factor.HasValue()) { + return Result::Failure(load_factor.GetStatus()); + } + + const bool channels_evaluated = AreChannelsEvaluated(record); + const std::string not_evaluated = + "not-evaluated(" + EscapeText(record.failure.reason) + ")"; + std::vector fields; + fields.reserve(39U); + AppendField(&fields, "record", "newton-iteration"); + AppendField(&fields, "schema", "nsnr-log-v1"); + AppendField(&fields, "increment", std::to_string(record.increment_index)); + AppendField(&fields, "target_factor", load_factor.Value()); + AppendField(&fields, "iteration", std::to_string(record.iteration)); + AppendField(&fields, "last_completed_stage", + StageLabel(record.last_completed_stage)); + AppendField(&fields, "failing_stage", + record.failure.present ? StageLabel(record.failure.stage) : "none"); + + const std::array specs{{ + {"residual_force", "residual_force"}, + {"residual_moment", "residual_moment"}, + {"correction_translation", "correction_translation"}, + {"correction_rotation", "correction_rotation"}, + }}; + const std::array decisions{{ + &record.force_residual, + &record.moment_residual, + &record.translation_correction, + &record.rotation_correction, + }}; + for (std::size_t index = 0U; index < specs.size(); ++index) { + const Status channel_status = + AppendChannelFields(&fields, specs[index], *decisions[index], + channels_evaluated, not_evaluated, log_path); + if (!channel_status.IsOk()) { + return Result::Failure(channel_status); + } + } + AppendField(&fields, "combined_pass", + channels_evaluated ? (record.combined_passed ? "1" : "0") + : not_evaluated); + AppendField(&fields, "transition", EscapeText(record.transition)); + AppendField(&fields, "outcome", EscapeText(record.outcome)); + AppendField(&fields, "diagnostic_code", EscapeText(record.diagnostic_code)); + + std::string line; + for (std::size_t index = 0U; index < fields.size(); ++index) { + if (index != 0U) { + line += '\t'; + } + line += fields[index]; + } + line += '\n'; + return Result::Success(std::move(line)); +} + +} // namespace + +Status NewtonIterationLogWriter::Open(const std::filesystem::path& log_path) { + if (state_ != State::kReady) { + state_ = State::kFailed; + return LogOutputFailure(log_path_, "The Newton log writer is not ready."); + } + + log_path_ = log_path; + stream_.open(log_path_, std::ios::binary | std::ios::out | std::ios::trunc); + if (!stream_.is_open() || !stream_) { + state_ = State::kFailed; + return LogOutputFailure(log_path_, "The Newton log could not open."); + } + state_ = State::kOpen; + return Status::Ok(); +} + +Status NewtonIterationLogWriter::Write(const NewtonIterationLogRecord& record) { + if (state_ != State::kOpen) { + state_ = State::kFailed; + return LogOutputFailure(log_path_, "The Newton log writer is not open."); + } + + const auto line = SerializeRecord(record, log_path_); + if (!line.HasValue()) { + state_ = State::kFailed; + return line.GetStatus(); + } + stream_.write(line.Value().data(), + static_cast(line.Value().size())); + if (!stream_) { + state_ = State::kFailed; + return LogOutputFailure(log_path_, "The Newton log record could not write."); + } + return Status::Ok(); +} + +Status NewtonIterationLogWriter::Flush() { + if (state_ != State::kOpen) { + state_ = State::kFailed; + return LogOutputFailure(log_path_, "The Newton log writer is not open."); + } + + stream_.flush(); + if (!stream_) { + state_ = State::kFailed; + return LogOutputFailure(log_path_, "The Newton log could not flush."); + } + return Status::Ok(); +} + +Status NewtonIterationLogWriter::Close() { + if (state_ != State::kOpen) { + state_ = State::kFailed; + return LogOutputFailure(log_path_, "The Newton log writer is not open."); + } + + stream_.close(); + if (stream_.fail()) { + state_ = State::kFailed; + return LogOutputFailure(log_path_, "The Newton log could not close."); + } + state_ = State::kClosed; + return Status::Ok(); +} + +} // namespace fesa diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a561bba..f8738c6 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -7,6 +7,7 @@ add_executable( unit/analysis/analysis_model_test.cpp unit/analysis/analysis_state_test.cpp unit/analysis/nonlinear_static_control_test.cpp + unit/analysis/newton_iteration_log_test.cpp unit/assembly/parallel_for_test.cpp unit/assembly/load_assembler_test.cpp unit/assembly/sparse_assembler_test.cpp diff --git a/tests/unit/analysis/newton_iteration_log_test.cpp b/tests/unit/analysis/newton_iteration_log_test.cpp new file mode 100644 index 0000000..e12d4df --- /dev/null +++ b/tests/unit/analysis/newton_iteration_log_test.cpp @@ -0,0 +1,327 @@ +#include "fesa/analysis/newton_iteration_log.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +class TempDirectory { + public: + explicit TempDirectory(const std::string& label) { + const auto tick = + std::chrono::steady_clock::now().time_since_epoch().count(); + path_ = std::filesystem::temp_directory_path() / + ("fesa-newton-log-" + label + "-" + std::to_string(tick)); + std::error_code error; + if (!std::filesystem::create_directory(path_, error) || error) { + throw std::runtime_error{"Unable to create Newton log test fixture."}; + } + } + + TempDirectory(const TempDirectory&) = delete; + TempDirectory& operator=(const TempDirectory&) = delete; + + ~TempDirectory() { + std::error_code ignored; + std::filesystem::remove_all(path_, ignored); + } + + const std::filesystem::path& Path() const noexcept { return path_; } + + private: + std::filesystem::path path_; +}; + +void ExpectLogOutputFailure(const fesa::Status& status) { + ASSERT_FALSE(status.IsOk()); + ASSERT_EQ(status.Category(), fesa::FailureCategory::kOutput); + ASSERT_EQ(status.Diagnostics().size(), 1U); + EXPECT_EQ(status.Diagnostics().front().code, "log-output-failure"); +} + +std::vector SplitTabs(const std::string& line) { + std::vector fields; + std::size_t start = 0U; + while (start <= line.size()) { + const std::size_t end = line.find('\t', start); + if (end == std::string::npos) { + fields.push_back(line.substr(start)); + return fields; + } + fields.push_back(line.substr(start, end - start)); + start = end + 1U; + } + return fields; +} + +const std::string& FieldValue(const std::vector& fields, + const std::string_view label) { + const std::string prefix = std::string{label} + "="; + for (const std::string& field : fields) { + if (field.compare(0U, prefix.size(), prefix) == 0) { + return field; + } + } + throw std::runtime_error{"Newton log field is missing."}; +} + +std::string ValueAfterEquals(const std::string& field) { + return field.substr(field.find('=') + 1U); +} + +fesa::NewtonChannelDecision MakeDecision(const std::string& identity) { + return {identity, + 0.0012345678901234567, + 1.2345678901234567, + fesa::NewtonConvergenceBranch::kRelative, + fesa::kNewtonRelativeThreshold, + fesa::NewtonChannelUnit::kDimensionless, + true}; +} + +fesa::NewtonIterationLogRecord CompleteRecord() { + return {"Step-1", + 3U, + 0.12345678901234567, + 2U, + fesa::NewtonIterationStage::kConvergenceEvaluated, + {false, fesa::NewtonIterationStage::kFailed, "", ""}, + "accept", + "accepted", + "none", + MakeDecision("residual_force"), + MakeDecision("residual_moment"), + MakeDecision("correction_translation"), + MakeDecision("correction_rotation"), + true}; +} + +std::vector WriteAndRead( + const std::filesystem::path& path, + const fesa::NewtonIterationLogRecord& record) { + fesa::NewtonIterationLogWriter writer; + if (!writer.Open(path).IsOk() || !writer.Write(record).IsOk() || + !writer.Flush().IsOk() || !writer.Close().IsOk()) { + throw std::runtime_error{"Unable to write Newton log test record."}; + } + + std::ifstream input{path, std::ios::binary}; + if (!input.is_open()) { + throw std::runtime_error{"Unable to read Newton log test record."}; + } + const std::string contents{std::istreambuf_iterator{input}, + std::istreambuf_iterator{}}; + if (contents.empty()) { + throw std::runtime_error{"Newton log test record is empty."}; + } + EXPECT_EQ(contents.back(), '\n'); + EXPECT_EQ(std::count(contents.begin(), contents.end(), '\n'), 1); + EXPECT_EQ(contents.find('\r'), std::string::npos); + EXPECT_FALSE(contents.size() >= 3U && + static_cast(contents[0U]) == 0xEFU && + static_cast(contents[1U]) == 0xBBU && + static_cast(contents[2U]) == 0xBFU); + return SplitTabs(contents.substr(0U, contents.size() - 1U)); +} + +// NSNR-LOG-001, NSNR-LOG-002, and NSNR-LOG-003 +TEST(NewtonIterationLog, WritesOneLfTerminated39FieldRecordInApprovedOrder) { + TempDirectory directory{"complete"}; + const auto fields = + WriteAndRead(directory.Path() / "model_log.txt", CompleteRecord()); + + const std::vector labels{ + "record", "schema", "increment", "target_factor", "iteration", + "last_completed_stage", "failing_stage", "residual_force_channel", + "residual_force_numerator_norm", "residual_force_denominator_scale", + "residual_force_branch", "residual_force_threshold", + "residual_force_threshold_unit", "residual_force_pass", + "residual_moment_channel", "residual_moment_numerator_norm", + "residual_moment_denominator_scale", "residual_moment_branch", + "residual_moment_threshold", "residual_moment_threshold_unit", + "residual_moment_pass", "correction_translation_channel", + "correction_translation_numerator_norm", + "correction_translation_denominator_scale", + "correction_translation_branch", "correction_translation_threshold", + "correction_translation_threshold_unit", "correction_translation_pass", + "correction_rotation_channel", "correction_rotation_numerator_norm", + "correction_rotation_denominator_scale", "correction_rotation_branch", + "correction_rotation_threshold", "correction_rotation_threshold_unit", + "correction_rotation_pass", "combined_pass", "transition", "outcome", + "diagnostic_code"}; + + ASSERT_EQ(fields.size(), labels.size()); + for (std::size_t index = 0U; index < labels.size(); ++index) { + EXPECT_EQ(fields[index].substr(0U, fields[index].find('=')), labels[index]); + } + EXPECT_EQ(FieldValue(fields, "record"), "record=newton-iteration"); + EXPECT_EQ(FieldValue(fields, "schema"), "schema=nsnr-log-v1"); +} + +// NSNR-LOG-004 +TEST(NewtonIterationLog, FormatsFiniteNumbersForLocaleIndependentRoundTrip) { + TempDirectory directory{"numbers"}; + const auto fields = + WriteAndRead(directory.Path() / "model_log.txt", CompleteRecord()); + const std::string value = ValueAfterEquals(FieldValue(fields, "target_factor")); + + double parsed = 0.0; + const auto result = std::from_chars(value.data(), value.data() + value.size(), + parsed, std::chars_format::general); + EXPECT_EQ(result.ec, std::errc{}); + EXPECT_EQ(result.ptr, value.data() + value.size()); + EXPECT_DOUBLE_EQ(parsed, 0.12345678901234567); + EXPECT_NE(value.find('.'), std::string::npos); + EXPECT_EQ(value.find(','), std::string::npos); +} + +// NSNR-LOG-005 and NSNR-LOG-007 +TEST(NewtonIterationLog, SerializesChannelDecisionsAndExplicitOutcomeFields) { + TempDirectory directory{"channels"}; + const auto fields = + WriteAndRead(directory.Path() / "model_log.txt", CompleteRecord()); + + for (const std::string_view prefix : + {"residual_force", "residual_moment", "correction_translation", + "correction_rotation"}) { + EXPECT_EQ(FieldValue(fields, std::string{prefix} + "_channel"), + std::string{prefix} + "_channel=" + std::string{prefix}); + EXPECT_EQ(FieldValue(fields, std::string{prefix} + "_numerator_norm"), + std::string{prefix} + + "_numerator_norm=0.0012345678901234567"); + EXPECT_EQ(FieldValue(fields, std::string{prefix} + "_denominator_scale"), + std::string{prefix} + + "_denominator_scale=1.2345678901234567"); + EXPECT_EQ(FieldValue(fields, std::string{prefix} + "_branch"), + std::string{prefix} + "_branch=relative"); + EXPECT_EQ(FieldValue(fields, std::string{prefix} + "_threshold"), + std::string{prefix} + "_threshold=0.01"); + EXPECT_EQ(FieldValue(fields, std::string{prefix} + "_threshold_unit"), + std::string{prefix} + "_threshold_unit=1"); + EXPECT_EQ(FieldValue(fields, std::string{prefix} + "_pass"), + std::string{prefix} + "_pass=1"); + } + EXPECT_EQ(FieldValue(fields, "last_completed_stage"), + "last_completed_stage=convergence-gates"); + EXPECT_EQ(FieldValue(fields, "failing_stage"), "failing_stage=none"); + EXPECT_EQ(FieldValue(fields, "combined_pass"), "combined_pass=1"); + EXPECT_EQ(FieldValue(fields, "transition"), "transition=accept"); + EXPECT_EQ(FieldValue(fields, "outcome"), "outcome=accepted"); + EXPECT_EQ(FieldValue(fields, "diagnostic_code"), "diagnostic_code=none"); +} + +// NSNR-LOG-006 +TEST(NewtonIterationLog, SerializesEmptyChannelsAsTypedZeroScaleDecisions) { + TempDirectory directory{"zero-scale"}; + auto record = CompleteRecord(); + const std::vector units{ + fesa::NewtonChannelUnit::kForce, fesa::NewtonChannelUnit::kForceLength, + fesa::NewtonChannelUnit::kLength, fesa::NewtonChannelUnit::kDimensionless}; + fesa::NewtonChannelDecision* decisions[]{ + &record.force_residual, &record.moment_residual, + &record.translation_correction, &record.rotation_correction}; + for (std::size_t index = 0U; index < std::size(decisions); ++index) { + decisions[index]->numerator_norm = 0.0; + decisions[index]->denominator_scale = 0.0; + decisions[index]->branch = fesa::NewtonConvergenceBranch::kZeroScale; + decisions[index]->threshold = fesa::kNewtonZeroScaleThreshold; + decisions[index]->threshold_unit = units[index]; + decisions[index]->passed = true; + } + + const auto fields = WriteAndRead(directory.Path() / "model_log.txt", record); + for (const std::string_view prefix : + {"residual_force", "residual_moment", "correction_translation", + "correction_rotation"}) { + EXPECT_EQ(FieldValue(fields, std::string{prefix} + "_numerator_norm"), + std::string{prefix} + "_numerator_norm=0"); + EXPECT_EQ(FieldValue(fields, std::string{prefix} + "_denominator_scale"), + std::string{prefix} + "_denominator_scale=0"); + EXPECT_EQ(FieldValue(fields, std::string{prefix} + "_branch"), + std::string{prefix} + "_branch=zero-scale"); + EXPECT_EQ(FieldValue(fields, std::string{prefix} + "_threshold"), + std::string{prefix} + "_threshold=0.001"); + } + EXPECT_EQ(FieldValue(fields, "residual_force_threshold_unit"), + "residual_force_threshold_unit=force"); + EXPECT_EQ(FieldValue(fields, "residual_moment_threshold_unit"), + "residual_moment_threshold_unit=force*length"); + EXPECT_EQ(FieldValue(fields, "correction_translation_threshold_unit"), + "correction_translation_threshold_unit=length"); + EXPECT_EQ(FieldValue(fields, "correction_rotation_threshold_unit"), + "correction_rotation_threshold_unit=1"); +} + +// NSNR-LOG-008 and NSNR-LOG-009 +TEST(NewtonIterationLog, EscapesStringsAndOmitsUnevaluatedFailureFields) { + TempDirectory directory{"failure"}; + auto record = CompleteRecord(); + record.last_completed_stage = fesa::NewtonIterationStage::kInternalAssembled; + record.failure = {true, + fesa::NewtonIterationStage::kExternalAssembled, + "load\\failure", + std::string{"external\tload\nmissing\r"} + '\0'}; + record.transition = "fail\ttransition"; + record.outcome = "failed\nrecord"; + record.diagnostic_code = std::string{"external\\load\r"} + '\0'; + const auto fields = WriteAndRead(directory.Path() / "model_log.txt", record); + + const std::string not_evaluated = + "not-evaluated(external\\tload\\nmissing\\r\\0)"; + for (std::size_t index = 7U; index < 36U; ++index) { + EXPECT_EQ(ValueAfterEquals(fields[index]), not_evaluated); + } + EXPECT_EQ(FieldValue(fields, "failing_stage"), + "failing_stage=load-boundary"); + EXPECT_EQ(FieldValue(fields, "transition"), + "transition=fail\\ttransition"); + EXPECT_EQ(FieldValue(fields, "outcome"), "outcome=failed\\nrecord"); + EXPECT_EQ(FieldValue(fields, "diagnostic_code"), + "diagnostic_code=external\\\\load\\r\\0"); +} + +// NSNR-LOG-010 +TEST(NewtonIterationLog, ReportsWriteFlushAndCloseStateFailures) { + TempDirectory directory{"failures"}; + const auto path = directory.Path() / "model_log.txt"; + fesa::NewtonIterationLogWriter writer; + ExpectLogOutputFailure(writer.Write(CompleteRecord())); + ExpectLogOutputFailure(writer.Flush()); + ExpectLogOutputFailure(writer.Close()); + + fesa::NewtonIterationLogWriter invalid_open_writer; + ExpectLogOutputFailure( + invalid_open_writer.Open(directory.Path() / "missing" / "log.txt")); + + fesa::NewtonIterationLogWriter closed_writer; + ASSERT_TRUE(closed_writer.Open(path).IsOk()); + ASSERT_TRUE(closed_writer.Close().IsOk()); + ExpectLogOutputFailure(closed_writer.Write(CompleteRecord())); + ExpectLogOutputFailure(closed_writer.Flush()); + ExpectLogOutputFailure(closed_writer.Close()); +} + +// NSNR-LOG-011 +TEST(NewtonIterationLog, LeavesZeroByteLogForNonNewtonControlledFailurePath) { + TempDirectory directory{"zero-byte"}; + const auto path = directory.Path() / "model_log.txt"; + fesa::NewtonIterationLogWriter writer; + + ASSERT_TRUE(writer.Open(path).IsOk()); + ASSERT_TRUE(writer.Close().IsOk()); + EXPECT_TRUE(std::filesystem::exists(path)); + EXPECT_EQ(std::filesystem::file_size(path), 0U); +} + +} // namespace