test(thermal): capture blocking NT11 reference comparison
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
|
||||
#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<char>{in}, std::istreambuf_iterator<char>{}};
|
||||
}
|
||||
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
|
||||
@@ -0,0 +1,417 @@
|
||||
#include "thermal_reference_comparison.h"
|
||||
|
||||
#include <hdf5.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <charconv>
|
||||
#include <cmath>
|
||||
#include <fstream>
|
||||
#include <map>
|
||||
#include <regex>
|
||||
#include <set>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
|
||||
#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<hsize_t> 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<hsize_t> dims(static_cast<std::size_t>(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<char> 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 <class T>
|
||||
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 <class T>
|
||||
std::vector<T> Primitive(hid_t file, const char* path,
|
||||
const std::vector<hsize_t>& 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<std::size_t>(extent);
|
||||
std::vector<T> 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<unsigned>(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<ThermalReferenceReport> CompareThermalSnapshots(
|
||||
const ThermalReferenceSnapshot& actual,
|
||||
const ThermalReferenceSnapshot& reference,
|
||||
const std::vector<std::uint64_t>& expected_labels,
|
||||
const std::string& instance) {
|
||||
try {
|
||||
const std::set<std::uint64_t> 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<std::uint64_t, double> 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<ThermalReferenceReport>::Success(std::move(report));
|
||||
} catch (const std::exception& error) {
|
||||
return Result<ThermalReferenceReport>::Failure(Failure(error.what()));
|
||||
}
|
||||
}
|
||||
|
||||
Result<ThermalReferenceSnapshot> 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<bool>(std::getline(input, line)),
|
||||
"Missing CSV header.");
|
||||
auto split = [](const std::string& row) {
|
||||
std::vector<std::string> 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<std::string>{"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<ThermalReferenceSnapshot>::Success(std::move(result));
|
||||
} catch (const std::exception& error) {
|
||||
return Result<ThermalReferenceSnapshot>::Failure(Failure(error.what()));
|
||||
}
|
||||
}
|
||||
|
||||
Result<ThermalReferenceSnapshot> 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<std::uint64_t>(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<std::uint64_t>(file.Id(), "/model/temperature_dofs", {5},
|
||||
H5T_STD_U64LE, H5T_NATIVE_UINT64) ==
|
||||
std::vector<std::uint64_t>{11, 12, 13, 14, 15},
|
||||
"Wrong station DOFs.");
|
||||
Require(Primitive<double>(file.Id(), "/model/temperature_zeta", {5},
|
||||
H5T_IEEE_F64LE, H5T_NATIVE_DOUBLE) ==
|
||||
std::vector<double>{-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<unsigned>(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<NodeRow> raw(static_cast<std::size_t>(shape[0]));
|
||||
Require(H5Dread(nodes.Id(), memory.Id(), H5S_ALL, H5S_ALL, H5P_DEFAULT,
|
||||
raw.data()) >= 0,
|
||||
"Cannot read node identities.");
|
||||
std::map<std::uint64_t, ThermalReferenceRow> 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<std::uint64_t>(file.Id(), "/model/thermal_node_ids", shape,
|
||||
H5T_STD_U64LE, H5T_NATIVE_UINT64);
|
||||
Require(std::set<std::uint64_t>(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<hsize_t>{101}, "Missing/extra reference frames.");
|
||||
std::vector<Frame> frames(static_cast<std::size_t>(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<double>(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<double>(group.Id(), "time", H5T_IEEE_F64LE,
|
||||
H5T_NATIVE_DOUBLE) == 37 &&
|
||||
Scalar<std::uint64_t>(group.Id(), "frame_index", H5T_STD_U64LE,
|
||||
H5T_NATIVE_UINT64) == 37 &&
|
||||
Scalar<std::uint64_t>(group.Id(), "increment_index",
|
||||
H5T_STD_U64LE, H5T_NATIVE_UINT64) == 37 &&
|
||||
Scalar<std::uint8_t>(group.Id(), "is_initial", H5T_STD_U8LE,
|
||||
H5T_NATIVE_UINT8) == 0,
|
||||
"Wrong selected frame attributes.");
|
||||
const auto values = Primitive<double>(
|
||||
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<ThermalReferenceSnapshot>::Success(std::move(result));
|
||||
} catch (const std::exception& error) {
|
||||
return Result<ThermalReferenceSnapshot>::Failure(Failure(error.what()));
|
||||
}
|
||||
}
|
||||
|
||||
Result<ThermalReferenceReport> 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<ThermalReferenceReport>::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<ThermalReferenceReport>::Failure(
|
||||
Failure("Unexpected reference input inventory."));
|
||||
std::vector<std::uint64_t> labels;
|
||||
for (const auto& node : domain.Nodes()) {
|
||||
if (node.source_id.instance_name != "Part-1-1")
|
||||
return Result<ThermalReferenceReport>::Failure(
|
||||
Failure("CSV requires the sole Part-1-1 input instance."));
|
||||
labels.push_back(static_cast<std::uint64_t>(node.source_id.source_label));
|
||||
}
|
||||
const auto actual = ReadThermalReferenceHdf5(hdf5),
|
||||
reference = ReadThermalReferenceCsv(csv, "Part-1-1");
|
||||
if (!actual.HasValue())
|
||||
return Result<ThermalReferenceReport>::Failure(actual.GetStatus());
|
||||
if (!reference.HasValue())
|
||||
return Result<ThermalReferenceReport>::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<ThermalReferenceReport>::Failure(
|
||||
Failure("Wrong source input identity."));
|
||||
return CompareThermalSnapshots(actual.Value(), reference.Value(), labels,
|
||||
"Part-1-1");
|
||||
}
|
||||
} // namespace fesa::test
|
||||
@@ -0,0 +1,55 @@
|
||||
#ifndef FESA_TESTS_REFERENCE_THERMAL_REFERENCE_COMPARISON_H_
|
||||
#define FESA_TESTS_REFERENCE_THERMAL_REFERENCE_COMPARISON_H_
|
||||
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<ThermalReferenceRow> 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<ThermalReferenceRowDecision> rows;
|
||||
ReferenceToleranceFamilyDecision family;
|
||||
};
|
||||
|
||||
/// Identity checks precede the unchanged common tolerance policy. Expected
|
||||
/// labels come from the actual input Domain, never CSV row order.
|
||||
Result<ThermalReferenceReport> CompareThermalSnapshots(
|
||||
const ThermalReferenceSnapshot& actual,
|
||||
const ThermalReferenceSnapshot& reference,
|
||||
const std::vector<std::uint64_t>& expected_labels,
|
||||
const std::string& instance);
|
||||
Result<ThermalReferenceSnapshot> ReadThermalReferenceCsv(
|
||||
const std::filesystem::path& path, const std::string& sole_instance);
|
||||
Result<ThermalReferenceSnapshot> ReadThermalReferenceHdf5(
|
||||
const std::filesystem::path& path);
|
||||
Result<ThermalReferenceReport> CompareThermalReferenceFiles(
|
||||
const std::filesystem::path& input, const std::filesystem::path& csv,
|
||||
const std::filesystem::path& hdf5);
|
||||
} // namespace fesa::test
|
||||
#endif
|
||||
@@ -0,0 +1,330 @@
|
||||
#include "thermal_reference_comparison.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <hdf5.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <limits>
|
||||
#include <random>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace {
|
||||
using fesa::test::ThermalReferenceSnapshot;
|
||||
class TemporaryFolder {
|
||||
public:
|
||||
TemporaryFolder() {
|
||||
static std::atomic<unsigned> 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 <class T>
|
||||
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<hsize_t>& shape, const void* data) {
|
||||
Handle space{
|
||||
H5Screate_simple(static_cast<int>(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<std::uint64_t>(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<Frame> frames;
|
||||
for (std::uint64_t i = 0; i < 101; ++i)
|
||||
frames.push_back({i, i, static_cast<double>(i), i ? 1. : 0.,
|
||||
static_cast<std::uint8_t>(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<double>(group.id, "time", H5T_IEEE_F64LE,
|
||||
defect == 12 ? 38. : 37.);
|
||||
ScalarAttribute<std::uint64_t>(group.id, "frame_index", H5T_STD_U64LE, 37);
|
||||
ScalarAttribute<std::uint64_t>(group.id, "increment_index", H5T_STD_U64LE,
|
||||
37);
|
||||
ScalarAttribute<std::uint8_t>(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<double>::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<double>::infinity();
|
||||
break;
|
||||
default:
|
||||
rows[0].value = std::numeric_limits<double>::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<double>::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<unsigned> 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
|
||||
Reference in New Issue
Block a user