#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