From 95ba2c5957509953ad956b2bbd80ebaf0f3bde42 Mon Sep 17 00:00:00 2001 From: "KOKO\\Mimi" Date: Tue, 29 Sep 2026 22:41:17 +0900 Subject: [PATCH] test(thermal): capture blocking NT11 reference comparison --- .../thermal_reference_cases_test.cpp | 60 +++ .../thermal_reference_comparison.cpp | 417 ++++++++++++++++++ .../reference/thermal_reference_comparison.h | 55 +++ .../thermal_reference_comparison_test.cpp | 330 ++++++++++++++ 4 files changed, 862 insertions(+) create mode 100644 tests/reference/thermal_reference_cases_test.cpp create mode 100644 tests/reference/thermal_reference_comparison.cpp create mode 100644 tests/reference/thermal_reference_comparison.h create mode 100644 tests/reference/thermal_reference_comparison_test.cpp diff --git a/tests/reference/thermal_reference_cases_test.cpp b/tests/reference/thermal_reference_cases_test.cpp new file mode 100644 index 0000000..77c2aba --- /dev/null +++ b/tests/reference/thermal_reference_cases_test.cpp @@ -0,0 +1,60 @@ +#include + +#include +#include +#include +#include + +#include "fesa/app/fesa_application.h" +#include "thermal_reference_comparison.h" + +namespace { +std::string Bytes(const std::filesystem::path& path) { + std::ifstream in{path, std::ios::binary}; + return {std::istreambuf_iterator{in}, std::istreambuf_iterator{}}; +} +TEST(ThermalReference, OriginalAbaqusNt11AtExactTime37) { + const auto folder = + std::filesystem::path{FESA_TEST_SOURCE_DIR} / "reference/shell-thermal"; + const auto input = folder / "shell-thermal.inp"; + const auto csv = folder / "shell-thermal NodalTemperature.csv"; + const auto before_input = Bytes(input), before_csv = Bytes(csv); + ASSERT_FALSE(before_input.empty()); + ASSERT_FALSE(before_csv.empty()); + const int exit = fesa::FesaApplication{}.Run({input.string()}); + EXPECT_EQ(Bytes(input), before_input); + EXPECT_EQ(Bytes(csv), before_csv); + ASSERT_EQ(exit, 0); + const auto result = fesa::test::CompareThermalReferenceFiles( + input, csv, folder / "shell-thermal_results.h5"); + ASSERT_TRUE(result.HasValue()) + << (result.HasValue() ? "" : result.GetStatus().Diagnostics()[0].message); + const auto& r = result.Value(); + ASSERT_EQ(r.rows.size(), 729U); + const auto output = std::filesystem::path{FESA_TEST_BINARY_DIR} / + "thermal-reference-metrics.csv"; + std::ofstream rows{output}; + rows + << "instance,label,dof,actual,reference,absolute_error,threshold,passed\n" + << std::setprecision(17); + std::size_t failed = 0; + for (const auto& row : r.rows) { + failed += !row.tolerance.passed; + rows << row.instance << ',' << row.label << ',' << row.source_dof << ',' + << row.actual << ',' << row.reference << ',' + << row.tolerance.absolute_error << ',' << row.tolerance.threshold + << ',' << row.tolerance.passed << '\n'; + } + ASSERT_TRUE(rows.good()); + std::cout << std::setprecision(17) + << "THERMAL_REFERENCE rows=" << r.rows.size() + << " S=" << r.family.reference_scale + << " B=" << r.family.near_zero_band + << " RMS/S=" << r.family.relative_rms + << " max_abs=" << r.family.maximum_absolute_error + << " failed_rows=" << failed << " passed=" << r.passed << '\n'; + EXPECT_EQ(r.family.reference_scale, 1000); + EXPECT_EQ(r.family.near_zero_band, 10); + EXPECT_TRUE(r.passed); +} +} // namespace diff --git a/tests/reference/thermal_reference_comparison.cpp b/tests/reference/thermal_reference_comparison.cpp new file mode 100644 index 0000000..d57b15e --- /dev/null +++ b/tests/reference/thermal_reference_comparison.cpp @@ -0,0 +1,417 @@ +#include "thermal_reference_comparison.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "fesa/analysis/prepared_analysis.h" + +namespace fesa::test { +namespace { +Status Failure(const std::string& message) { + return Status::Failure(FailureCategory::kModel, + {{Severity::kError, + "invalid-thermal-reference", + {}, + "HEAT TRANSFER", + "shell-thermal/Step-1/t=37/NT11", + message}}); +} +void Require(bool condition, const std::string& message) { + if (!condition) throw std::runtime_error{message}; +} +class H { + public: + H(hid_t id, herr_t (*close)(hid_t)) : id_(id), close_(close) { + Require(id >= 0, "Missing or invalid HDF5 object."); + } + ~H() { close_(id_); } + H(const H&) = delete; + H& operator=(const H&) = delete; + hid_t Id() const { return id_; } + + private: + hid_t id_; + herr_t (*close_)(hid_t); +}; +std::vector Shape(hid_t dataset) { + H space{H5Dget_space(dataset), H5Sclose}; + const int n = H5Sget_simple_extent_ndims(space.Id()); + Require(n >= 0, "Invalid dataset dataspace."); + std::vector dims(static_cast(n)); + Require(H5Sget_simple_extent_dims(space.Id(), dims.data(), nullptr) >= 0, + "Invalid dimensions."); + return dims; +} +std::string Attr(hid_t object, const char* name) { + H a{H5Aopen(object, name, H5P_DEFAULT), H5Aclose}; + H t{H5Aget_type(a.Id()), H5Tclose}; + H s{H5Aget_space(a.Id()), H5Sclose}; + Require(H5Sget_simple_extent_type(s.Id()) == H5S_SCALAR && + H5Tget_class(t.Id()) == H5T_STRING && + H5Tget_cset(t.Id()) == H5T_CSET_UTF8, + "Invalid UTF8 scalar attribute."); + if (H5Tis_variable_str(t.Id()) > 0) { + char* p = nullptr; + Require(H5Aread(a.Id(), t.Id(), &p) >= 0 && p != nullptr, + "Cannot read string."); + std::string result{p}; + H5free_memory(p); + return result; + } + std::vector p(H5Tget_size(t.Id()) + 1, 0); + Require(H5Aread(a.Id(), t.Id(), p.data()) >= 0, "Cannot read fixed string."); + return std::string{p.data()}; +} +template +T Scalar(hid_t object, const char* name, hid_t disk, hid_t memory) { + H a{H5Aopen(object, name, H5P_DEFAULT), H5Aclose}; + H t{H5Aget_type(a.Id()), H5Tclose}; + H s{H5Aget_space(a.Id()), H5Sclose}; + Require(H5Tequal(t.Id(), disk) > 0 && + H5Sget_simple_extent_type(s.Id()) == H5S_SCALAR, + "Invalid scalar type/shape."); + T value{}; + Require(H5Aread(a.Id(), memory, &value) >= 0, "Cannot read scalar."); + return value; +} +template +std::vector Primitive(hid_t file, const char* path, + const std::vector& dims, hid_t disk, + hid_t memory) { + H d{H5Dopen2(file, path, H5P_DEFAULT), H5Dclose}; + H t{H5Dget_type(d.Id()), H5Tclose}; + Require(Shape(d.Id()) == dims && H5Tequal(t.Id(), disk) > 0, + "Wrong dataset primitive/extent."); + std::size_t n = 1; + for (auto extent : dims) n *= static_cast(extent); + std::vector values(n); + Require(H5Dread(d.Id(), memory, H5S_ALL, H5S_ALL, H5P_DEFAULT, + values.data()) >= 0, + "Cannot read primitive."); + return values; +} +void Field(hid_t compound, const char* name, hid_t expected) { + Require(H5Tget_class(compound) == H5T_COMPOUND, "Expected compound."); + const int index = H5Tget_member_index(compound, name); + Require(index >= 0, "Missing compound field."); + H type{H5Tget_member_type(compound, static_cast(index)), H5Tclose}; + Require(H5Tequal(type.Id(), expected) > 0, + "Invalid compound field primitive."); +} +std::uint64_t Label(const std::string& text) { + std::uint64_t value = 0; + const auto parsed = + std::from_chars(text.data(), text.data() + text.size(), value); + Require(parsed.ec == std::errc{} && parsed.ptr == text.data() + text.size() && + value > 0, + "Invalid node label."); + return value; +} +std::string Trim(const std::string& text) { + const auto begin = text.find_first_not_of(" \t\r\n"); + if (begin == std::string::npos) return {}; + return text.substr(begin, text.find_last_not_of(" \t\r\n") - begin + 1); +} +} // namespace + +Result CompareThermalSnapshots( + const ThermalReferenceSnapshot& actual, + const ThermalReferenceSnapshot& reference, + const std::vector& expected_labels, + const std::string& instance) { + try { + const std::set labels(expected_labels.begin(), + expected_labels.end()); + Require(!labels.empty() && labels.size() == expected_labels.size() && + !instance.empty(), + "Invalid input node inventory."); + auto mapped = [&](const ThermalReferenceSnapshot& s) { + Require(s.step == "Step-1" && s.time == 37 && s.increment == 37 && + s.source_dof == 11, + "Wrong source step/time/increment/station."); + std::map result; + for (const auto& row : s.rows) { + Require(row.instance == instance && labels.count(row.label) == 1 && + std::isfinite(row.value), + "Wrong instance/extra label/nonfinite value."); + Require(result.emplace(row.label, row.value).second, + "Duplicate node identity."); + } + Require(result.size() == labels.size(), "Missing node identity."); + return result; + }; + const auto a = mapped(actual), r = mapped(reference); + ReferenceToleranceFamily family{ + "shell-thermal/Step-1/time=37/NT11/temperature/user-consistent", + {"NT11"}, + true, + {}}; + for (auto label : labels) family.rows.push_back({a.at(label), r.at(label)}); + const auto evaluation = ReferenceTolerancePolicy::Evaluate({family}); + Require(evaluation.valid && evaluation.families.size() == 1, + "Invalid tolerance evaluation."); + ThermalReferenceReport report{ + evaluation.passed, {}, evaluation.families[0]}; + std::size_t index = 0; + for (auto label : labels) + report.rows.push_back({instance, label, 11, a.at(label), r.at(label), + report.family.rows[index++]}); + return Result::Success(std::move(report)); + } catch (const std::exception& error) { + return Result::Failure(Failure(error.what())); + } +} + +Result ReadThermalReferenceCsv( + const std::filesystem::path& path, const std::string& sole_instance) { + try { + Require(!sole_instance.empty(), "CSV needs a proven sole input instance."); + std::ifstream input{path}; + Require(input.good(), "Cannot read original CSV."); + std::string line; + Require(static_cast(std::getline(input, line)), + "Missing CSV header."); + auto split = [](const std::string& row) { + std::vector values; + std::istringstream in{row}; + std::string value; + while (std::getline(in, value, ',')) values.push_back(Trim(value)); + return values; + }; + Require(split(line) == + std::vector{"Step", "Frame", "Node Label", "NT11"}, + "Wrong CSV columns/station."); + ThermalReferenceSnapshot result{"Step-1", 37, 37, 11, {}}; + const std::regex frame{R"(^Increment\s+37:\s+Step Time\s*=\s*37\.00$)"}; + while (std::getline(input, line)) { + const auto row = split(line); + Require(row.size() == 4 && row[0] == "Step-1" && + std::regex_match(row[1], frame), + "Wrong CSV source frame."); + std::size_t consumed = 0; + const double value = std::stod(row[3], &consumed); + Require(consumed == row[3].size() && std::isfinite(value), + "Invalid CSV value."); + result.rows.push_back({sole_instance, Label(row[2]), value}); + } + Require(input.eof(), "CSV read failed."); + return Result::Success(std::move(result)); + } catch (const std::exception& error) { + return Result::Failure(Failure(error.what())); + } +} + +Result ReadThermalReferenceHdf5( + const std::filesystem::path& path) { + try { + H file{H5Fopen(path.string().c_str(), H5F_ACC_RDONLY, H5P_DEFAULT), + H5Fclose}; + H meta{H5Gopen2(file.Id(), "/metadata", H5P_DEFAULT), H5Gclose}; + Require( + Scalar(meta.Id(), "schema_version", H5T_STD_U64LE, + H5T_NATIVE_UINT64) == 1 && + Attr(meta.Id(), "feature_id") == "shell-transient-heat-transfer" && + Attr(meta.Id(), "analysis_procedure") == + "uncoupled-transient-heat-transfer" && + Attr(meta.Id(), "source_step_name") == "Step-1" && + Attr(meta.Id(), "unit_system_label") == + "user-consistent-unspecified", + "Wrong thermal metadata."); + Require(Primitive(file.Id(), "/model/temperature_dofs", {5}, + H5T_STD_U64LE, H5T_NATIVE_UINT64) == + std::vector{11, 12, 13, 14, 15}, + "Wrong station DOFs."); + Require(Primitive(file.Id(), "/model/temperature_zeta", {5}, + H5T_IEEE_F64LE, H5T_NATIVE_DOUBLE) == + std::vector{-1, -.5, 0, .5, 1}, + "Wrong station coordinates."); + H nodes{H5Dopen2(file.Id(), "/model/nodes", H5P_DEFAULT), H5Dclose}; + H nt{H5Dget_type(nodes.Id()), H5Tclose}; + const auto shape = Shape(nodes.Id()); + Require(shape.size() == 1 && shape[0] > 0, "Invalid nodes shape."); + Field(nt.Id(), "internal_node_id", H5T_STD_U64LE); + struct NodeRow { + std::uint64_t id; + char* instance; + char* label; + }; + H string{H5Tcopy(H5T_C_S1), H5Tclose}; + H5Tset_size(string.Id(), H5T_VARIABLE); + H5Tset_cset(string.Id(), H5T_CSET_UTF8); + for (const auto* name : {"instance_name", "source_label"}) { + const int index = H5Tget_member_index(nt.Id(), name); + Require(index >= 0, "Missing identity field."); + H type{H5Tget_member_type(nt.Id(), static_cast(index)), + H5Tclose}; + Require(H5Tget_class(type.Id()) == H5T_STRING && + H5Tget_cset(type.Id()) == H5T_CSET_UTF8, + "Wrong identity type."); + } + H memory{H5Tcreate(H5T_COMPOUND, sizeof(NodeRow)), H5Tclose}; + H5Tinsert(memory.Id(), "internal_node_id", HOFFSET(NodeRow, id), + H5T_NATIVE_UINT64); + H5Tinsert(memory.Id(), "instance_name", HOFFSET(NodeRow, instance), + string.Id()); + H5Tinsert(memory.Id(), "source_label", HOFFSET(NodeRow, label), + string.Id()); + std::vector raw(static_cast(shape[0])); + Require(H5Dread(nodes.Id(), memory.Id(), H5S_ALL, H5S_ALL, H5P_DEFAULT, + raw.data()) >= 0, + "Cannot read node identities."); + std::map identities; + bool valid = true; + for (const auto& row : raw) { + if (!row.instance || !row.label) + valid = false; + else { + try { + valid = identities + .emplace(row.id, ThermalReferenceRow{row.instance, + Label(row.label), 0}) + .second && + valid; + } catch (...) { + valid = false; + } + } + } + H ns{H5Dget_space(nodes.Id()), H5Sclose}; + Require( + H5Treclaim(memory.Id(), ns.Id(), H5P_DEFAULT, raw.data()) >= 0 && valid, + "Invalid node identity inventory."); + const auto active = + Primitive(file.Id(), "/model/thermal_node_ids", shape, + H5T_STD_U64LE, H5T_NATIVE_UINT64); + Require(std::set(active.begin(), active.end()).size() == + active.size(), + "Duplicate active IDs."); + struct Frame { + std::uint64_t frame, increment; + double time, delta; + std::uint8_t initial; + }; + H fd{H5Dopen2(file.Id(), "/steps/Step-1/accepted_frames", H5P_DEFAULT), + H5Dclose}; + H ft{H5Dget_type(fd.Id()), H5Tclose}; + H fm{H5Tcreate(H5T_COMPOUND, sizeof(Frame)), H5Tclose}; + for (const auto* name : {"frame_index", "increment_index"}) + Field(ft.Id(), name, H5T_STD_U64LE); + for (const auto* name : {"time", "time_increment"}) + Field(ft.Id(), name, H5T_IEEE_F64LE); + Field(ft.Id(), "is_initial", H5T_STD_U8LE); + H5Tinsert(fm.Id(), "frame_index", HOFFSET(Frame, frame), H5T_NATIVE_UINT64); + H5Tinsert(fm.Id(), "increment_index", HOFFSET(Frame, increment), + H5T_NATIVE_UINT64); + H5Tinsert(fm.Id(), "time", HOFFSET(Frame, time), H5T_NATIVE_DOUBLE); + H5Tinsert(fm.Id(), "time_increment", HOFFSET(Frame, delta), + H5T_NATIVE_DOUBLE); + H5Tinsert(fm.Id(), "is_initial", HOFFSET(Frame, initial), H5T_NATIVE_UINT8); + const auto fs = Shape(fd.Id()); + Require(fs == std::vector{101}, "Missing/extra reference frames."); + std::vector frames(static_cast(fs[0])); + Require(H5Dread(fd.Id(), fm.Id(), H5S_ALL, H5S_ALL, H5P_DEFAULT, + frames.data()) >= 0, + "Cannot read frames."); + std::size_t matches = 0; + for (std::size_t i = 0; i < frames.size(); ++i) { + const auto& f = frames[i]; + Require(f.frame == i && f.increment == i && f.initial == (i == 0) && + std::isfinite(f.time) && std::isfinite(f.delta), + "Invalid/duplicate frame inventory."); + Require(f.time == static_cast(i) && f.delta == (i == 0 ? 0. : 1.), + "Wrong frame schedule."); + if (f.time == 37) { + ++matches; + Require(f.increment == 37, "Wrong reference increment."); + } + } + Require(matches == 1 && frames[37].time == 37, + "No unique exact t=37 frame."); + H group{H5Gopen2(file.Id(), "/steps/Step-1/frames/37", H5P_DEFAULT), + H5Gclose}; + Require(Scalar(group.Id(), "time", H5T_IEEE_F64LE, + H5T_NATIVE_DOUBLE) == 37 && + Scalar(group.Id(), "frame_index", H5T_STD_U64LE, + H5T_NATIVE_UINT64) == 37 && + Scalar(group.Id(), "increment_index", + H5T_STD_U64LE, H5T_NATIVE_UINT64) == 37 && + Scalar(group.Id(), "is_initial", H5T_STD_U8LE, + H5T_NATIVE_UINT8) == 0, + "Wrong selected frame attributes."); + const auto values = Primitive( + file.Id(), "/steps/Step-1/frames/37/nodal/temperature", {shape[0], 5}, + H5T_IEEE_F64LE, H5T_NATIVE_DOUBLE); + ThermalReferenceSnapshot result{"Step-1", 37, 37, 11, {}}; + result.source_input_identity = Attr(meta.Id(), "source_input_identity"); + H temperature{ + H5Dopen2(file.Id(), "/steps/Step-1/frames/37/nodal/temperature", + H5P_DEFAULT), + H5Dclose}; + Require( + Attr(temperature.Id(), "component_order") == + "NT11,NT12,NT13,NT14,NT15" && + Attr(temperature.Id(), "units_label") == "temperature" && + Attr(temperature.Id(), "coordinate_system") == "scalar" && + Attr(temperature.Id(), "location") == "nodal-thickness-stations", + "Wrong temperature component/units/location metadata."); + for (double value : values) + Require(std::isfinite(value), "Nonfinite temperature station."); + for (std::size_t i = 0; i < active.size(); ++i) { + Require(identities.count(active[i]) == 1, "Unknown active node ID."); + auto row = identities.at(active[i]); + row.value = values[5 * i]; + result.rows.push_back(std::move(row)); + } + return Result::Success(std::move(result)); + } catch (const std::exception& error) { + return Result::Failure(Failure(error.what())); + } +} + +Result CompareThermalReferenceFiles( + const std::filesystem::path& input, const std::filesystem::path& csv, + const std::filesystem::path& hdf5) { + auto prepared = PrepareAnalysisInput(input); + if (!prepared.HasValue()) + return Result::Failure(prepared.GetStatus()); + const auto& domain = prepared.Value().domain; + if (!domain.ThermalStep() || domain.ThermalStep()->name != "Step-1" || + domain.Nodes().size() != 729 || domain.ThermalElements().Size() != 676) + return Result::Failure( + Failure("Unexpected reference input inventory.")); + std::vector labels; + for (const auto& node : domain.Nodes()) { + if (node.source_id.instance_name != "Part-1-1") + return Result::Failure( + Failure("CSV requires the sole Part-1-1 input instance.")); + labels.push_back(static_cast(node.source_id.source_label)); + } + const auto actual = ReadThermalReferenceHdf5(hdf5), + reference = ReadThermalReferenceCsv(csv, "Part-1-1"); + if (!actual.HasValue()) + return Result::Failure(actual.GetStatus()); + if (!reference.HasValue()) + return Result::Failure(reference.GetStatus()); + const std::string expected_identity = + "path=" + + std::filesystem::absolute(domain.SourcePath()) + .lexically_normal() + .generic_u8string() + + ";content_identity=" + domain.SourceContentIdentity(); + if (actual.Value().source_input_identity != expected_identity) + return Result::Failure( + Failure("Wrong source input identity.")); + return CompareThermalSnapshots(actual.Value(), reference.Value(), labels, + "Part-1-1"); +} +} // namespace fesa::test diff --git a/tests/reference/thermal_reference_comparison.h b/tests/reference/thermal_reference_comparison.h new file mode 100644 index 0000000..58ab065 --- /dev/null +++ b/tests/reference/thermal_reference_comparison.h @@ -0,0 +1,55 @@ +#ifndef FESA_TESTS_REFERENCE_THERMAL_REFERENCE_COMPARISON_H_ +#define FESA_TESTS_REFERENCE_THERMAL_REFERENCE_COMPARISON_H_ + +#include +#include +#include +#include + +#include "fesa/core/status.h" +#include "reference_tolerance_policy.h" + +namespace fesa::test { +struct ThermalReferenceRow { + std::string instance; + std::uint64_t label; + double value; +}; +struct ThermalReferenceSnapshot { + std::string step; + double time = 0; + std::uint64_t increment = 0; + int source_dof = 0; + std::vector rows; + std::string source_input_identity; +}; +struct ThermalReferenceRowDecision { + std::string instance; + std::uint64_t label; + int source_dof; + double actual; + double reference; + ReferenceToleranceRowDecision tolerance; +}; +struct ThermalReferenceReport { + bool passed = false; + std::vector rows; + ReferenceToleranceFamilyDecision family; +}; + +/// Identity checks precede the unchanged common tolerance policy. Expected +/// labels come from the actual input Domain, never CSV row order. +Result CompareThermalSnapshots( + const ThermalReferenceSnapshot& actual, + const ThermalReferenceSnapshot& reference, + const std::vector& expected_labels, + const std::string& instance); +Result ReadThermalReferenceCsv( + const std::filesystem::path& path, const std::string& sole_instance); +Result ReadThermalReferenceHdf5( + const std::filesystem::path& path); +Result CompareThermalReferenceFiles( + const std::filesystem::path& input, const std::filesystem::path& csv, + const std::filesystem::path& hdf5); +} // namespace fesa::test +#endif diff --git a/tests/reference/thermal_reference_comparison_test.cpp b/tests/reference/thermal_reference_comparison_test.cpp new file mode 100644 index 0000000..a4845c4 --- /dev/null +++ b/tests/reference/thermal_reference_comparison_test.cpp @@ -0,0 +1,330 @@ +#include "thermal_reference_comparison.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +using fesa::test::ThermalReferenceSnapshot; +class TemporaryFolder { + public: + TemporaryFolder() { + static std::atomic next{0}; + do { + path = + std::filesystem::temp_directory_path() / + ("fesa-thermal-reference-" + std::to_string(std::random_device{}()) + + "-" + std::to_string(next++)); + } while (!std::filesystem::create_directory(path)); + } + ~TemporaryFolder() { + std::error_code error; + std::filesystem::remove_all(path, error); + } + std::filesystem::path path; +}; +class Handle { + public: + Handle(hid_t id, herr_t (*closer)(hid_t)) : id(id), closer(closer) { + if (id < 0) throw std::runtime_error{"HDF fixture creation failed."}; + } + ~Handle() { closer(id); } + Handle(const Handle&) = delete; + Handle& operator=(const Handle&) = delete; + hid_t id; + + private: + herr_t (*closer)(hid_t); +}; +void StringAttribute(hid_t object, const char* name, const std::string& value) { + Handle type{H5Tcopy(H5T_C_S1), H5Tclose}; + ASSERT_GE(H5Tset_size(type.id, H5T_VARIABLE), 0); + ASSERT_GE(H5Tset_cset(type.id, H5T_CSET_UTF8), 0); + Handle space{H5Screate(H5S_SCALAR), H5Sclose}; + Handle attr{ + H5Acreate2(object, name, type.id, space.id, H5P_DEFAULT, H5P_DEFAULT), + H5Aclose}; + const char* text = value.c_str(); + ASSERT_GE(H5Awrite(attr.id, type.id, &text), 0); +} +template +void ScalarAttribute(hid_t object, const char* name, hid_t type, T value) { + Handle space{H5Screate(H5S_SCALAR), H5Sclose}; + Handle attr{ + H5Acreate2(object, name, type, space.id, H5P_DEFAULT, H5P_DEFAULT), + H5Aclose}; + ASSERT_GE(H5Awrite(attr.id, type, &value), 0); +} +void Dataset(hid_t file, const char* path, hid_t type, + const std::vector& shape, const void* data) { + Handle space{ + H5Screate_simple(static_cast(shape.size()), shape.data(), nullptr), + H5Sclose}; + Handle dataset{H5Dcreate2(file, path, type, space.id, H5P_DEFAULT, + H5P_DEFAULT, H5P_DEFAULT), + H5Dclose}; + ASSERT_GE(H5Dwrite(dataset.id, type, H5S_ALL, H5S_ALL, H5P_DEFAULT, data), 0); +} +// Minimal independent schema fixture for the comparator (not a solver output +// or a replacement for the immutable Abaqus artifact). +void HdfFixture(const std::filesystem::path& path, int defect) { + Handle file{ + H5Fcreate(path.string().c_str(), H5F_ACC_TRUNC, H5P_DEFAULT, H5P_DEFAULT), + H5Fclose}; + for (const auto* name : {"/metadata", "/model", "/steps", "/steps/Step-1", + "/steps/Step-1/frames", "/steps/Step-1/frames/37", + "/steps/Step-1/frames/37/nodal"}) { + Handle group{ + H5Gcreate2(file.id, name, H5P_DEFAULT, H5P_DEFAULT, H5P_DEFAULT), + H5Gclose}; + } + Handle metadata{H5Gopen2(file.id, "/metadata", H5P_DEFAULT), H5Gclose}; + ScalarAttribute(metadata.id, "schema_version", H5T_STD_U64LE, + 1); + StringAttribute(metadata.id, "feature_id", "shell-transient-heat-transfer"); + StringAttribute(metadata.id, "analysis_procedure", + "uncoupled-transient-heat-transfer"); + StringAttribute(metadata.id, "source_step_name", "Step-1"); + StringAttribute(metadata.id, "source_input_identity", + "synthetic-comparator-fixture"); + StringAttribute(metadata.id, "unit_system_label", + "user-consistent-unspecified"); + std::uint64_t dofs[5] = {11, 12, 13, 14, 15}; + if (defect == 1) dofs[0] = 15; + Dataset(file.id, "/model/temperature_dofs", H5T_STD_U64LE, {5}, dofs); + double zeta[5] = {-1, -.5, 0, .5, 1}; + if (defect == 2) zeta[0] = 0; + Dataset(file.id, "/model/temperature_zeta", H5T_IEEE_F64LE, {5}, zeta); + const std::uint64_t active[2] = {1, 0}; + Dataset(file.id, "/model/thermal_node_ids", H5T_STD_U64LE, {2}, active); + struct Node { + std::uint64_t id; + const char* instance; + const char* label; + }; + Node nodes[2] = {{0, "Part-1-1", "7"}, {1, "Part-1-1", "20"}}; + if (defect == 9) nodes[1].id = 0; + if (defect == 10) nodes[1].instance = "Wrong"; + if (defect == 11) nodes[1].label = "7"; + Handle string{H5Tcopy(H5T_C_S1), H5Tclose}; + H5Tset_size(string.id, H5T_VARIABLE); + H5Tset_cset(string.id, H5T_CSET_UTF8); + Handle nt{H5Tcreate(H5T_COMPOUND, sizeof(Node)), H5Tclose}; + H5Tinsert(nt.id, "internal_node_id", HOFFSET(Node, id), H5T_STD_U64LE); + H5Tinsert(nt.id, "instance_name", HOFFSET(Node, instance), string.id); + if (defect != 14) + H5Tinsert(nt.id, "source_label", HOFFSET(Node, label), string.id); + Dataset(file.id, "/model/nodes", nt.id, {2}, nodes); + struct Frame { + std::uint64_t frame, increment; + double time, delta; + std::uint8_t initial; + }; + std::vector frames; + for (std::uint64_t i = 0; i < 101; ++i) + frames.push_back({i, i, static_cast(i), i ? 1. : 0., + static_cast(i == 0)}); + if (defect == 6) frames[37].frame = 36; + if (defect == 7) frames.pop_back(); + if (defect == 8) frames.push_back(frames.back()); + Handle ft{H5Tcreate(H5T_COMPOUND, sizeof(Frame)), H5Tclose}; + H5Tinsert(ft.id, "frame_index", HOFFSET(Frame, frame), H5T_STD_U64LE); + H5Tinsert(ft.id, "increment_index", HOFFSET(Frame, increment), H5T_STD_U64LE); + H5Tinsert(ft.id, "time", HOFFSET(Frame, time), H5T_IEEE_F64LE); + H5Tinsert(ft.id, "time_increment", HOFFSET(Frame, delta), H5T_IEEE_F64LE); + H5Tinsert(ft.id, "is_initial", HOFFSET(Frame, initial), H5T_STD_U8LE); + Dataset(file.id, "/steps/Step-1/accepted_frames", ft.id, {frames.size()}, + frames.data()); + Handle group{H5Gopen2(file.id, "/steps/Step-1/frames/37", H5P_DEFAULT), + H5Gclose}; + ScalarAttribute(group.id, "time", H5T_IEEE_F64LE, + defect == 12 ? 38. : 37.); + ScalarAttribute(group.id, "frame_index", H5T_STD_U64LE, 37); + ScalarAttribute(group.id, "increment_index", H5T_STD_U64LE, + 37); + ScalarAttribute(group.id, "is_initial", H5T_STD_U8LE, 0); + double values[10] = {1000, 1, 2, 3, 4, -15.5742216, 5, 6, 7, 8}; + if (defect == 5) values[8] = std::numeric_limits::quiet_NaN(); + Dataset(file.id, "/steps/Step-1/frames/37/nodal/temperature", + defect == 4 ? H5T_STD_U64LE : H5T_IEEE_F64LE, + {2, defect == 3 ? 4U : 5U}, values); + Handle temperature{ + H5Dopen2(file.id, "/steps/Step-1/frames/37/nodal/temperature", + H5P_DEFAULT), + H5Dclose}; + StringAttribute( + temperature.id, "component_order", + defect == 13 ? "NT15,NT14,NT13,NT12,NT11" : "NT11,NT12,NT13,NT14,NT15"); + StringAttribute(temperature.id, "units_label", "temperature"); + StringAttribute(temperature.id, "coordinate_system", "scalar"); + StringAttribute(temperature.id, "location", "nodal-thickness-stations"); +} + +TEST(ThermalReferenceComparison, + HdfSchemaMappingRejectsMalformedStationFrameAndNodeData) { + TemporaryFolder folder; + const auto path = folder.path / "fixture.h5"; + for (int defect = 0; defect <= 14; ++defect) { + SCOPED_TRACE(defect); + HdfFixture(path, defect); + const auto read = fesa::test::ReadThermalReferenceHdf5(path); + if (defect == 0 || defect == 10 || defect == 11) { + ASSERT_TRUE(read.HasValue()) + << (read.HasValue() ? "" : read.GetStatus().Diagnostics()[0].message); + ThermalReferenceSnapshot reference{ + "Step-1", + 37, + 37, + 11, + {{"Part-1-1", 7, -15.5742216}, {"Part-1-1", 20, 1000}}}; + const auto result = fesa::test::CompareThermalSnapshots( + read.Value(), reference, {7, 20}, "Part-1-1"); + EXPECT_EQ(result.HasValue(), defect == 0); + if (result.HasValue()) EXPECT_TRUE(result.Value().passed); + } else + EXPECT_FALSE(read.HasValue()); + } +} +ThermalReferenceSnapshot Snapshot() { + return {"Step-1", + 37, + 37, + 11, + {{"Part-1-1", 7, -15.5742216}, + {"Part-1-1", 20, 1000}, + {"Part-1-1", 3, 0}}}; +} +TEST(ThermalReferenceComparison, + MapsSourceLabelsWithoutRowOrderAndPreservesSignedValues) { + auto actual = Snapshot(), reference = Snapshot(); + std::reverse(actual.rows.begin(), actual.rows.end()); + const auto result = fesa::test::CompareThermalSnapshots( + actual, reference, {3, 7, 20}, "Part-1-1"); + ASSERT_TRUE(result.HasValue()); + EXPECT_TRUE(result.Value().passed); + ASSERT_EQ(result.Value().rows.size(), 3U); + EXPECT_EQ(result.Value().rows[1].label, 7U); + EXPECT_EQ(result.Value().rows[1].reference, -15.5742216); + EXPECT_EQ(result.Value().rows[1].tolerance.branch, + fesa::test::ReferenceToleranceBranch::kRelative); + EXPECT_EQ(result.Value().family.reference_scale, 1000); + EXPECT_EQ(result.Value().family.near_zero_band, 10); + EXPECT_TRUE(result.Value().family.blocking); +} +TEST(ThermalReferenceComparison, + RejectsMissingExtraDuplicateAndNonfiniteBeforeTolerance) { + for (int mode = 0; mode < 5; ++mode) { + for (bool mutate_actual : {false, true}) { + auto actual = Snapshot(), reference = Snapshot(); + auto& rows = mutate_actual ? actual.rows : reference.rows; + switch (mode) { + case 0: + rows.pop_back(); + break; + case 1: + rows.push_back({"Part-1-1", 99, 0}); + break; + case 2: + rows.push_back(rows[0]); + break; + case 3: + rows[0].value = std::numeric_limits::infinity(); + break; + default: + rows[0].value = std::numeric_limits::quiet_NaN(); + break; + } + SCOPED_TRACE(mode); + SCOPED_TRACE(mutate_actual); + EXPECT_FALSE(fesa::test::CompareThermalSnapshots(actual, reference, + {3, 7, 20}, "Part-1-1") + .HasValue()); + } + } +} +TEST(ThermalReferenceComparison, + RejectsWrongStepTimeIncrementInstanceAndStation) { + for (int mode = 0; mode < 5; ++mode) { + for (bool mutate_actual : {false, true}) { + auto actual = Snapshot(), reference = Snapshot(); + auto& row = mutate_actual ? actual : reference; + switch (mode) { + case 0: + row.step = "Step-2"; + break; + case 1: + row.time = std::nextafter(37., 38.); + break; + case 2: + row.increment = 38; + break; + case 3: + row.rows[0].instance = "Other"; + break; + default: + row.source_dof = 15; + break; + } + SCOPED_TRACE(mode); + SCOPED_TRACE(mutate_actual); + EXPECT_FALSE(fesa::test::CompareThermalSnapshots(actual, reference, + {3, 7, 20}, "Part-1-1") + .HasValue()); + } + } +} +TEST(ThermalReferenceComparison, + UsesUnmodifiedFivePercentOnePercentAndExactZeroPolicy) { + auto actual = Snapshot(), reference = Snapshot(); + actual.rows[0].value = 0; + auto result = fesa::test::CompareThermalSnapshots(actual, reference, + {3, 7, 20}, "Part-1-1"); + ASSERT_TRUE(result.HasValue()); + EXPECT_FALSE(result.Value().passed); + for (auto& row : reference.rows) row.value = 0; + actual = reference; + actual.rows[0].value = std::numeric_limits::denorm_min(); + result = fesa::test::CompareThermalSnapshots(actual, reference, {3, 7, 20}, + "Part-1-1"); + ASSERT_TRUE(result.HasValue()); + EXPECT_FALSE(result.Value().passed); + EXPECT_EQ(result.Value().family.reference_scale, 0); +} +TEST(ThermalReferenceComparison, + CsvRequiresExactHeaderFrameFiniteValuesAndSoleInstance) { + static std::atomic next{0}; + std::filesystem::path directory; + do { + directory = std::filesystem::temp_directory_path() / + ("fesa-thermal-csv-" + std::to_string(std::random_device{}()) + + "-" + std::to_string(next++)); + } while (!std::filesystem::create_directory(directory)); + const auto path = directory / "fixture.csv"; + const std::string header = "Step,Frame,Node Label,NT11\n"; + const std::string valid = + "Step-1,Increment 37: Step Time = 37.00,7,-15.5742216\n"; + for (const auto& text : + {header + valid, std::string{"Step,Frame,Node Label,NT15\n"} + valid, + header + "Step-1,Increment 38: Step Time = 37.00,7,0\n", + header + "Step-2,Increment 37: Step Time = 37.00,7,0\n", + header + "Step-1,Increment 37: Step Time = 37.00,7,nan\n", + header + "Step-1,Increment 37: Step Time = 37.00,7,0,extra\n"}) { + { + std::ofstream out{path}; + out << text; + } + const auto result = fesa::test::ReadThermalReferenceCsv(path, "Part-1-1"); + EXPECT_EQ(result.HasValue(), text == header + valid); + EXPECT_FALSE(fesa::test::ReadThermalReferenceCsv(path, "").HasValue()); + } + std::filesystem::remove_all(directory); +} +} // namespace