feat(thermal): publish fully validated HDF5 histories atomically

This commit is contained in:
KOKO\Mimi
2026-09-29 22:02:55 +09:00
parent 983e2febda
commit 7f11d5332b
8 changed files with 2119 additions and 0 deletions
@@ -0,0 +1,993 @@
#include "fesa/io/hdf5/thermal_hdf5_results_writer.h"
#include <gtest/gtest.h>
#include <hdf5.h>
#include <atomic>
#include <filesystem>
#include <fstream>
#include <functional>
#include <iterator>
#include <limits>
#include <memory>
#include <string>
#include "fesa/analysis/analysis_model.h"
#include "fesa/assembly/thermal_assembler.h"
#include "fesa/fem/dof_manager.h"
#include "io/hdf5/hdf5_raii.h"
#include "io/hdf5/thermal_hdf5_self_check.h"
namespace {
fesa::ModelDefinition Definition() {
fesa::ModelDefinition m{};
const std::filesystem::path file{"thermal-writer.inp"};
m.source_path = file;
m.source_content_identity = "thermal-writer-source";
m.nodes = {{{"unused", 90, "0090"}, {9, 9, 9}, {file, 1}},
{{"part", 7, "0007"}, {0, 0, 0}, {file, 2}},
{{"part", 2, "0002"}, {1, 0, 0}, {file, 3}},
{{"part", 9, "0009"}, {1, 1, 0}, {file, 4}},
{{"part", 1, "0001"}, {0, 1, 0}, {file, 5}}};
// The structural row makes the thermal material's global ID nonzero.
m.materials = {{"structural", 1, .25, {file, 6}}};
m.thermal_materials = {{{"part", 1, "1"}, "thermal", 2, 3, 4, {file, 7}}};
m.thermal_sections = {{{"part", 1, "1"}, "ELSET", .2, 0, {file, 8}}};
m.thermal_elements = {{{"part", 13, "0013"}, {1, 2, 3, 4}, 0, {file, 9}}};
m.thermal_initial_conditions = {{1, -2, {file, 10}}};
m.thermal_step = fesa::ThermalStepDefinition{"raw Step", .3, .7, 3};
return m;
}
struct Fixture {
std::unique_ptr<fesa::Domain> domain;
fesa::ThermalAnalysisState state;
};
Fixture Make(fesa::ModelDefinition definition = Definition(),
bool complete = true) {
auto d = fesa::Domain::Create(std::move(definition));
if (!d.HasValue()) throw std::runtime_error{"domain"};
auto domain = std::make_unique<fesa::Domain>(std::move(d.Value()));
auto m = fesa::AnalysisModel::Create(*domain);
auto dofs = fesa::DofManager::CreateThermal(m.Value());
auto a =
fesa::AssembleThermal(m.Value(), dofs.Value(), fesa::SerialParallelFor{});
auto state =
fesa::ThermalAnalysisState::Create(m.Value(), dofs.Value(), a.Value());
if (!state.HasValue()) throw std::runtime_error{"state"};
for (std::uint32_t i = 1; complete && i <= state.Value().TimeGrid().Count();
++i) {
fesa::Vector t{20U};
fesa::Vector r{state.Value().ReactionDofs().size()};
for (std::size_t row = 0; row < t.Size(); ++row)
t[row] = static_cast<double>(100 * i + row) - 200;
for (std::size_t row = 0; row < dofs.Value().ConstrainedDofs().size();
++row)
t[dofs.Value().ConstrainedDofs()[row]] =
dofs.Value().PrescribedValues()[row];
for (std::size_t row = 0; row < r.Size(); ++row)
r[row] = -10. * i + static_cast<double>(row);
std::vector<fesa::ThermalElementFlux> q(1);
for (auto& e : q)
for (std::size_t x = 0; x < 24; ++x)
e[x] = fesa::Vector3{double(x), -double(x) - i, 3. * i};
auto frame = state.Value().MakeCompletedFrame(i, std::move(t), std::move(r),
std::move(q));
if (!frame.HasValue() ||
!state.Value().CommitAcceptedFrame(frame.Value()).IsOk())
throw std::runtime_error{"frame"};
}
return {std::move(domain), std::move(state.Value())};
}
class Temp {
public:
Temp() {
static std::atomic<unsigned> next{0};
do {
directory_ = std::filesystem::temp_directory_path() /
("fesa-thermal-writer-" + std::to_string(next.fetch_add(1)));
} while (!std::filesystem::create_directory(directory_));
p_ = directory_ / "result.h5";
}
~Temp() {
std::error_code e;
std::filesystem::remove_all(directory_, e);
}
const std::filesystem::path& Path() const { return p_; }
private:
std::filesystem::path directory_;
std::filesystem::path p_;
};
using fesa::hdf5_internal::Hdf5Handle;
std::string Message(const fesa::Status& status) {
return status.Diagnostics().empty() ? "" : status.Diagnostics()[0].message;
}
std::vector<char> Bytes(const std::filesystem::path& path) {
std::ifstream stream(path, std::ios::binary);
return {std::istreambuf_iterator<char>(stream),
std::istreambuf_iterator<char>()};
}
std::vector<fesa::Diagnostic> Diagnostics() {
return {{fesa::Severity::kError,
"later",
{"b.inp", 9},
"HEAT TRANSFER",
"part.0013",
"late message"},
{fesa::Severity::kWarning,
"early",
{"a.inp", 2},
"NODE",
"part.0007",
u8"\uD55C\uAE00 warning"}};
}
herr_t CollectAttribute(hid_t, const char* name, const H5A_info_t*,
void* data) {
static_cast<std::vector<std::string>*>(data)->push_back(name);
return 0;
}
void CopyAttributes(hid_t from, hid_t to) {
std::vector<std::string> names;
hsize_t at = 0;
ASSERT_GE(H5Aiterate2(from, H5_INDEX_NAME, H5_ITER_INC, &at, CollectAttribute,
&names),
0);
for (const auto& name : names) {
Hdf5Handle a{H5Aopen(from, name.c_str(), H5P_DEFAULT), H5Aclose};
Hdf5Handle t{H5Aget_type(a.Get()), H5Tclose},
s{H5Aget_space(a.Get()), H5Sclose};
std::vector<char> value(
static_cast<std::size_t>(H5Sget_simple_extent_npoints(s.Get())) *
H5Tget_size(t.Get()));
ASSERT_GE(H5Aread(a.Get(), t.Get(), value.data()), 0);
Hdf5Handle b{H5Acreate2(to, name.c_str(), t.Get(), s.Get(), H5P_DEFAULT,
H5P_DEFAULT),
H5Aclose};
ASSERT_GE(H5Awrite(b.Get(), t.Get(), value.data()), 0);
if (H5Tis_variable_str(t.Get()) > 0 ||
H5Tdetect_class(t.Get(), H5T_VLEN) > 0)
ASSERT_GE(H5Dvlen_reclaim(t.Get(), s.Get(), H5P_DEFAULT, value.data()),
0);
}
}
void RewriteCompound(hid_t file, const char* path, const std::string& omit = "",
const std::string& change = "", hid_t changed_type = -1,
bool additive = false) {
Hdf5Handle old{H5Dopen2(file, path, H5P_DEFAULT), H5Dclose};
Hdf5Handle type{H5Dget_type(old.Get()), H5Tclose},
space{H5Dget_space(old.Get()), H5Sclose};
const int count = H5Tget_nmembers(type.Get());
ASSERT_GE(count, 0);
std::vector<std::string> names;
std::vector<Hdf5Handle> fields;
std::size_t width = 0;
// Reverse field order and repack offsets; never preserve writer C++ padding.
for (int i = count - 1; i >= 0; --i) {
char* raw = H5Tget_member_name(type.Get(), static_cast<unsigned>(i));
ASSERT_NE(raw, nullptr);
const std::string name{raw};
ASSERT_GE(H5free_memory(raw), 0);
if (name == omit) continue;
names.push_back(name);
fields.emplace_back(
name == change
? H5Tcopy(changed_type)
: H5Tget_member_type(type.Get(), static_cast<unsigned>(i)),
H5Tclose);
width += H5Tget_size(fields.back().Get());
}
if (additive) {
names.push_back("optional_future_value");
fields.emplace_back(H5Tcopy(H5T_STD_U64LE), H5Tclose);
width += 8;
}
Hdf5Handle replacement{H5Tcreate(H5T_COMPOUND, width), H5Tclose};
std::size_t offset = 0;
for (std::size_t i = 0; i < names.size(); ++i) {
ASSERT_GE(
H5Tinsert(replacement.Get(), names[i].c_str(), offset, fields[i].Get()),
0);
offset += H5Tget_size(fields[i].Get());
}
const auto rows =
static_cast<std::size_t>(H5Sget_simple_extent_npoints(space.Get()));
std::vector<char> data(rows * width, 0);
if (rows) {
std::size_t field_offset = 0;
for (std::size_t i = 0; i < names.size(); ++i) {
if (names[i] != "optional_future_value") {
Hdf5Handle projection{H5Tcreate(H5T_COMPOUND, width), H5Tclose};
ASSERT_GE(H5Tinsert(projection.Get(), names[i].c_str(), field_offset,
fields[i].Get()),
0);
const herr_t read = H5Dread(old.Get(), projection.Get(), H5S_ALL,
H5S_ALL, H5P_DEFAULT, data.data());
if (names[i] != change) ASSERT_GE(read, 0);
}
field_offset += H5Tget_size(fields[i].Get());
}
}
ASSERT_GE(H5Ldelete(file, path, H5P_DEFAULT), 0);
Hdf5Handle properties{H5Pcreate(H5P_DATASET_CREATE), H5Pclose};
const hsize_t chunk = 1;
ASSERT_GE(H5Pset_chunk(properties.Get(), 1, &chunk), 0);
Hdf5Handle rewritten{H5Dcreate2(file, path, replacement.Get(), space.Get(),
H5P_DEFAULT, properties.Get(), H5P_DEFAULT),
H5Dclose};
ASSERT_GE(rewritten.Get(), 0);
if (rows)
ASSERT_GE(H5Dwrite(rewritten.Get(), replacement.Get(), H5S_ALL, H5S_ALL,
H5P_DEFAULT, data.data()),
0);
CopyAttributes(old.Get(), rewritten.Get());
if (rows && H5Tdetect_class(replacement.Get(), H5T_VLEN) > 0)
ASSERT_GE(H5Dvlen_reclaim(replacement.Get(), space.Get(), H5P_DEFAULT,
data.data()),
0);
else if (rows) {
// H5Tdetect_class does not classify variable strings as H5T_VLEN.
bool strings = false;
for (const auto& field : fields)
strings = strings || H5Tis_variable_str(field.Get()) > 0;
if (strings)
ASSERT_GE(H5Dvlen_reclaim(replacement.Get(), space.Get(), H5P_DEFAULT,
data.data()),
0);
}
}
template <class T>
void WriteColumn(hid_t file, const char* path, const char* field, hid_t native,
const std::vector<T>& values) {
Hdf5Handle d{H5Dopen2(file, path, H5P_DEFAULT), H5Dclose};
Hdf5Handle t{H5Tcreate(H5T_COMPOUND, sizeof(T)), H5Tclose};
ASSERT_GE(H5Tinsert(t.Get(), field, 0, native), 0);
ASSERT_GE(
H5Dwrite(d.Get(), t.Get(), H5S_ALL, H5S_ALL, H5P_DEFAULT, values.data()),
0);
}
void StringAttribute(hid_t file, const char* path, const char* name,
const std::string& value,
H5T_cset_t charset = H5T_CSET_UTF8, bool scalar = true) {
Hdf5Handle object{H5Oopen(file, path, H5P_DEFAULT), H5Oclose};
ASSERT_GE(H5Adelete(object.Get(), name), 0);
Hdf5Handle t{H5Tcopy(H5T_C_S1), H5Tclose};
ASSERT_GE(H5Tset_size(t.Get(), value.size() + 1), 0);
ASSERT_GE(H5Tset_cset(t.Get(), charset), 0);
const hsize_t two = 2;
Hdf5Handle s{
scalar ? H5Screate(H5S_SCALAR) : H5Screate_simple(1, &two, nullptr),
H5Sclose};
Hdf5Handle a{H5Acreate2(object.Get(), name, t.Get(), s.Get(), H5P_DEFAULT,
H5P_DEFAULT),
H5Aclose};
std::vector<char> data((value.size() + 1) * (scalar ? 1 : 2), 0);
std::copy(value.begin(), value.end(), data.begin());
ASSERT_GE(H5Awrite(a.Get(), t.Get(), data.data()), 0);
}
void ReplacePrimitive(hid_t file, const std::string& path, bool wrong_shape) {
Hdf5Handle old{H5Dopen2(file, path.c_str(), H5P_DEFAULT), H5Dclose};
Hdf5Handle old_type{H5Dget_type(old.Get()), H5Tclose},
old_space{H5Dget_space(old.Get()), H5Sclose};
const int rank = H5Sget_simple_extent_ndims(old_space.Get());
ASSERT_GT(rank, 0);
std::vector<hsize_t> shape(static_cast<std::size_t>(rank));
ASSERT_GE(H5Sget_simple_extent_dims(old_space.Get(), shape.data(), nullptr),
0);
if (wrong_shape) ++shape[0];
Hdf5Handle space{H5Screate_simple(rank, shape.data(), nullptr), H5Sclose};
ASSERT_GE(H5Ldelete(file, path.c_str(), H5P_DEFAULT), 0);
Hdf5Handle replaced{
H5Dcreate2(file, path.c_str(),
wrong_shape ? old_type.Get() : H5T_STD_I32LE, space.Get(),
H5P_DEFAULT, H5P_DEFAULT, H5P_DEFAULT),
H5Dclose};
ASSERT_GE(replaced.Get(), 0);
CopyAttributes(old.Get(), replaced.Get());
}
void ChangePrimitive(hid_t file, const std::string& path) {
Hdf5Handle d{H5Dopen2(file, path.c_str(), H5P_DEFAULT), H5Dclose};
Hdf5Handle t{H5Dget_type(d.Get()), H5Tclose},
s{H5Dget_space(d.Get()), H5Sclose};
const auto count = H5Sget_simple_extent_npoints(s.Get());
ASSERT_GT(count, 0);
if (H5Tget_class(t.Get()) == H5T_FLOAT) {
std::vector<double> values(static_cast<std::size_t>(count));
ASSERT_GE(H5Dread(d.Get(), H5T_NATIVE_DOUBLE, H5S_ALL, H5S_ALL, H5P_DEFAULT,
values.data()),
0);
values[0] += 1;
ASSERT_GE(H5Dwrite(d.Get(), H5T_NATIVE_DOUBLE, H5S_ALL, H5S_ALL,
H5P_DEFAULT, values.data()),
0);
} else {
std::vector<std::int64_t> values(static_cast<std::size_t>(count));
ASSERT_GE(H5Dread(d.Get(), H5T_NATIVE_INT64, H5S_ALL, H5S_ALL, H5P_DEFAULT,
values.data()),
0);
values[0] += 1;
ASSERT_GE(H5Dwrite(d.Get(), H5T_NATIVE_INT64, H5S_ALL, H5S_ALL, H5P_DEFAULT,
values.data()),
0);
}
}
TEST(ThermalHdf5ResultsWriter,
WritesRawInitialAndCompletedZeroReactionHistory) {
auto x = Make();
Temp out;
fesa::ThermalHdf5ResultsWriter writer;
const auto result = writer.Write(
out.Path(), *x.domain, x.state,
{{fesa::Severity::kWarning, "fixture", {}, "", "", "fixture"}});
ASSERT_TRUE(result.IsOk())
<< (result.Diagnostics().empty() ? "no diagnostic"
: result.Diagnostics()[0].message);
hid_t file =
H5Fopen(out.Path().string().c_str(), H5F_ACC_RDONLY, H5P_DEFAULT);
ASSERT_GE(file, 0);
EXPECT_GT(
H5Lexists(file, "/steps/Step-1/frames/0/nodal/temperature", H5P_DEFAULT),
0);
EXPECT_LE(H5Lexists(file, "/steps/Step-1/frames/0/nodal/reaction_heat_rate",
H5P_DEFAULT),
0);
EXPECT_LE(
H5Lexists(file, "/steps/Step-1/frames/0/element/heat_flux", H5P_DEFAULT),
0);
EXPECT_GT(H5Lexists(file, "/steps/Step-1/frames/3/nodal/reaction_heat_rate",
H5P_DEFAULT),
0);
EXPECT_GT(
H5Lexists(file, "/steps/Step-1/frames/3/element/heat_flux", H5P_DEFAULT),
0);
EXPECT_GT(H5Fclose(file), -1);
}
TEST(ThermalHdf5ResultsWriter, SectionMaterialUsesGlobalMaterialIdentity) {
auto x = Make();
Temp out;
ASSERT_TRUE(fesa::ThermalHdf5ResultsWriter{}
.Write(out.Path(), *x.domain, x.state, {})
.IsOk());
using fesa::hdf5_internal::Hdf5Handle;
Hdf5Handle f{
H5Fopen(out.Path().string().c_str(), H5F_ACC_RDONLY, H5P_DEFAULT),
H5Fclose};
Hdf5Handle d{H5Dopen2(f.Get(), "/model/shell_sections", H5P_DEFAULT),
H5Dclose};
Hdf5Handle t{H5Tcreate(H5T_COMPOUND, sizeof(std::uint64_t)), H5Tclose};
ASSERT_GE(H5Tinsert(t.Get(), "material_internal_id", 0, H5T_NATIVE_UINT64),
0);
std::uint64_t id = 99;
ASSERT_GE(H5Dread(d.Get(), t.Get(), H5S_ALL, H5S_ALL, H5P_DEFAULT, &id), 0);
EXPECT_EQ(id, 1U);
Hdf5Handle utf8{H5Tcopy(H5T_C_S1), H5Tclose};
ASSERT_GE(H5Tset_size(utf8.Get(), H5T_VARIABLE), 0);
ASSERT_GE(H5Tset_cset(utf8.Get(), H5T_CSET_UTF8), 0);
Hdf5Handle source_type{H5Tcreate(H5T_COMPOUND, sizeof(char*)), H5Tclose};
ASSERT_GE(H5Tinsert(source_type.Get(), "source_file", 0, utf8.Get()), 0);
char* source = nullptr;
ASSERT_GE(H5Dread(d.Get(), source_type.Get(), H5S_ALL, H5S_ALL, H5P_DEFAULT,
&source),
0);
ASSERT_NE(source, nullptr);
EXPECT_EQ(std::string{source}, std::filesystem::absolute("thermal-writer.inp")
.lexically_normal()
.generic_u8string());
EXPECT_GE(H5free_memory(source), 0);
}
TEST(ThermalHdf5ResultsWriter,
RejectsMissingMandatoryAttributesAndChangedValues) {
const auto x = Make();
for (const auto& mutation : std::vector<std::function<void(hid_t)>>{
[](hid_t f) {
hid_t m = H5Gopen2(f, "/metadata", H5P_DEFAULT);
EXPECT_GE(H5Adelete(m, "source_input_identity"), 0);
H5Gclose(m);
},
[](hid_t f) {
hid_t d = H5Dopen2(f, "/steps/Step-1/frames/1/nodal/temperature",
H5P_DEFAULT);
EXPECT_GE(H5Adelete(d, "units_label"), 0);
H5Dclose(d);
},
[](hid_t f) {
hid_t d = H5Dopen2(f, "/model/thermal_node_ids", H5P_DEFAULT);
std::uint64_t ids[4] = {1, 1, 3, 4};
EXPECT_GE(H5Dwrite(d, H5T_NATIVE_UINT64, H5S_ALL, H5S_ALL,
H5P_DEFAULT, ids),
0);
H5Dclose(d);
},
[](hid_t f) {
hid_t d = H5Dopen2(f, "/steps/Step-1/frames/1/nodal/temperature",
H5P_DEFAULT);
double v[20] = {4};
EXPECT_GE(H5Dwrite(d, H5T_NATIVE_DOUBLE, H5S_ALL, H5S_ALL,
H5P_DEFAULT, v),
0);
H5Dclose(d);
}}) {
Temp out;
ASSERT_TRUE(fesa::ThermalHdf5ResultsWriter{}
.Write(out.Path(), *x.domain, x.state, {})
.IsOk());
hid_t f = H5Fopen(out.Path().string().c_str(), H5F_ACC_RDWR, H5P_DEFAULT);
ASSERT_GE(f, 0);
mutation(f);
ASSERT_GE(H5Fclose(f), 0);
EXPECT_FALSE(fesa::hdf5_internal::SelfCheckThermalFile(
out.Path(), *x.domain, x.state, {})
.IsOk());
}
}
TEST(ThermalHdf5ResultsWriter,
EveryRequiredAttributeIsCheckedBeforePublication) {
const auto x = Make();
std::vector<std::pair<std::string, std::string>> required;
const auto add = [&](const std::string& path,
std::initializer_list<const char*> names) {
for (const auto* name : names) required.emplace_back(path, name);
};
add("/metadata",
{"schema_version", "feature_id", "analysis_procedure", "source_step_name",
"solver_version", "source_input_identity", "unit_system_label",
"internal_formulation", "integration_rule", "time_integration",
"nominal_time_increment", "time_period"});
add("/model/nodes", {"units_label"});
add("/model/shell_sections", {"thickness_units_label"});
add("/model/thermal_materials",
{"conductivity_units_label", "density_units_label",
"specific_heat_units_label"});
add("/steps/Step-1/accepted_frames", {"time_units_label"});
for (int frame = 0; frame < 4; ++frame) {
const auto path = "/steps/Step-1/frames/" + std::to_string(frame);
add(path, {"frame_index", "increment_index", "time", "time_increment",
"is_initial"});
add(path + "/nodal/temperature",
{"component_order", "units_label", "coordinate_system", "location"});
if (frame != 0) {
add(path + "/nodal/reaction_heat_rate",
{"component_order", "units_label", "coordinate_system", "location",
"positive_direction"});
add(path + "/element/heat_flux",
{"component_order", "units_label", "coordinate_system", "location"});
}
}
for (const auto& attribute : required) {
SCOPED_TRACE(attribute.first + ":" + attribute.second);
Temp out;
ASSERT_TRUE(fesa::ThermalHdf5ResultsWriter{}
.Write(out.Path(), *x.domain, x.state, {})
.IsOk());
const hid_t file =
H5Fopen(out.Path().string().c_str(), H5F_ACC_RDWR, H5P_DEFAULT);
ASSERT_GE(file, 0);
const hid_t object = H5Oopen(file, attribute.first.c_str(), H5P_DEFAULT);
ASSERT_GE(object, 0);
ASSERT_GE(H5Adelete(object, attribute.second.c_str()), 0);
ASSERT_GE(H5Oclose(object), 0);
ASSERT_GE(H5Fclose(file), 0);
EXPECT_FALSE(fesa::hdf5_internal::SelfCheckThermalFile(
out.Path(), *x.domain, x.state, {})
.IsOk());
}
}
TEST(ThermalHdf5ResultsWriter, EveryCompoundFieldAndEveryDatasetIsMandatory) {
const auto x = Make();
const auto diagnostics = Diagnostics();
const std::vector<std::pair<const char*, std::vector<const char*>>> tables = {
{"/model/nodes",
{"internal_node_id", "instance_name", "source_label", "coordinates"}},
{"/model/elements",
{"internal_element_id", "instance_name", "source_label",
"source_element_type", "internal_formulation", "node_internal_ids",
"shell_section_internal_id", "material_internal_id"}},
{"/model/thermal_materials",
{"internal_material_id", "name", "conductivity", "density",
"specific_heat"}},
{"/model/shell_sections",
{"internal_section_id", "source_file", "source_line", "source_elset",
"material_internal_id", "thickness"}},
{"/model/thermal_reaction_dofs", {"internal_node_id", "source_dof"}},
{"/steps/Step-1/accepted_frames",
{"frame_index", "increment_index", "time", "time_increment",
"is_initial"}},
{"/diagnostics",
{"severity", "code", "file", "line", "keyword", "entity_identity",
"message"}}};
for (const auto& table : tables)
for (const auto* field : table.second) {
SCOPED_TRACE(std::string{table.first} + ":" + field);
Temp out;
ASSERT_TRUE(fesa::ThermalHdf5ResultsWriter{}
.Write(out.Path(), *x.domain, x.state, diagnostics)
.IsOk());
{
Hdf5Handle file{
H5Fopen(out.Path().string().c_str(), H5F_ACC_RDWR, H5P_DEFAULT),
H5Fclose};
RewriteCompound(file.Get(), table.first, field);
}
const auto checked = fesa::hdf5_internal::SelfCheckThermalFile(
out.Path(), *x.domain, x.state, diagnostics);
EXPECT_FALSE(checked.IsOk());
EXPECT_NE(Message(checked).find("Missing compound field"),
std::string::npos);
}
std::vector<std::string> datasets;
for (const auto& table : tables) datasets.push_back(table.first);
for (const auto* name :
{"thermal_node_ids", "thermal_element_ids", "temperature_dofs",
"temperature_zeta", "thermal_constraint_mask",
"thermal_flux_inplane_coordinates", "thermal_flux_zeta",
"thermal_flux_trace_side", "thermal_flux_local_frames"})
datasets.push_back(std::string{"/model/"} + name);
for (std::size_t i = 0; i < x.state.AcceptedFrames().size(); ++i) {
const auto root = "/steps/Step-1/frames/" + std::to_string(i);
datasets.push_back(root + "/nodal/temperature");
if (i) {
datasets.push_back(root + "/nodal/reaction_heat_rate");
datasets.push_back(root + "/element/heat_flux");
}
}
for (const auto& path : datasets) {
SCOPED_TRACE(path);
Temp out;
ASSERT_TRUE(fesa::ThermalHdf5ResultsWriter{}
.Write(out.Path(), *x.domain, x.state, diagnostics)
.IsOk());
{
Hdf5Handle file{
H5Fopen(out.Path().string().c_str(), H5F_ACC_RDWR, H5P_DEFAULT),
H5Fclose};
ASSERT_GE(H5Ldelete(file.Get(), path.c_str(), H5P_DEFAULT), 0);
}
EXPECT_FALSE(fesa::hdf5_internal::SelfCheckThermalFile(
out.Path(), *x.domain, x.state, diagnostics)
.IsOk());
}
}
TEST(ThermalHdf5ResultsWriter,
ReorderedPackedAdditiveCompoundsAndFixedUtf8AreAccepted) {
auto x = Make();
const auto diagnostics = Diagnostics();
Temp out;
ASSERT_TRUE(fesa::ThermalHdf5ResultsWriter{}
.Write(out.Path(), *x.domain, x.state, diagnostics)
.IsOk());
{
Hdf5Handle file{
H5Fopen(out.Path().string().c_str(), H5F_ACC_RDWR, H5P_DEFAULT),
H5Fclose};
for (const auto* path :
{"/model/nodes", "/model/elements", "/model/thermal_materials",
"/model/shell_sections", "/model/thermal_reaction_dofs",
"/steps/Step-1/accepted_frames", "/diagnostics"})
RewriteCompound(file.Get(), path, "", "", -1, true);
StringAttribute(file.Get(), "/metadata", "source_step_name", "raw Step");
StringAttribute(file.Get(), "/steps/Step-1/frames/0/nodal/temperature",
"units_label", "temperature");
Hdf5Handle fixed{H5Tcopy(H5T_C_S1), H5Tclose};
ASSERT_GE(H5Tset_size(fixed.Get(), 16), 0);
ASSERT_GE(H5Tset_cset(fixed.Get(), H5T_CSET_UTF8), 0);
{
fesa::hdf5_internal::Hdf5ErrorSilencer silent;
RewriteCompound(file.Get(), "/model/thermal_materials", "", "name",
fixed.Get());
}
std::array<char, 16> name{};
std::copy_n("thermal", 7, name.data());
WriteColumn(file.Get(), "/model/thermal_materials", "name", fixed.Get(),
std::vector<std::array<char, 16>>{name});
}
const auto checked = fesa::hdf5_internal::SelfCheckThermalFile(
out.Path(), *x.domain, x.state, diagnostics);
EXPECT_TRUE(checked.IsOk()) << Message(checked);
}
TEST(ThermalHdf5ResultsWriter,
RejectsWrongTypesArrayExtentsNonscalarAndNonUtf8Attributes) {
auto x = Make();
const hsize_t wrong_extent = 3;
Hdf5Handle array{H5Tarray_create2(H5T_STD_U64LE, 1, &wrong_extent), H5Tclose};
const std::vector<std::function<void(hid_t)>> mutations = {
[](hid_t f) {
RewriteCompound(f, "/model/thermal_materials", "", "conductivity",
H5T_IEEE_F32LE);
},
[](hid_t f) {
RewriteCompound(f, "/model/elements", "", "internal_element_id",
H5T_STD_U32LE);
},
[&](hid_t f) {
RewriteCompound(f, "/model/elements", "", "node_internal_ids",
array.Get());
},
[](hid_t f) {
StringAttribute(f, "/metadata", "feature_id",
"shell-transient-heat-transfer", H5T_CSET_ASCII);
},
[](hid_t f) {
StringAttribute(f, "/metadata", "source_step_name", "raw Step",
H5T_CSET_UTF8, false);
},
[](hid_t f) {
Hdf5Handle g{H5Gopen2(f, "/steps/Step-1/frames/1", H5P_DEFAULT),
H5Gclose};
ASSERT_GE(H5Adelete(g.Get(), "is_initial"), 0);
const hsize_t count = 2;
Hdf5Handle s{H5Screate_simple(1, &count, nullptr), H5Sclose};
Hdf5Handle a{H5Acreate2(g.Get(), "is_initial", H5T_STD_U8LE, s.Get(),
H5P_DEFAULT, H5P_DEFAULT),
H5Aclose};
const std::uint8_t data[2] = {0, 0};
ASSERT_GE(H5Awrite(a.Get(), H5T_NATIVE_UINT8, data), 0);
},
[](hid_t f) {
Hdf5Handle g{H5Gopen2(f, "/metadata", H5P_DEFAULT), H5Gclose};
ASSERT_GE(H5Adelete(g.Get(), "time_period"), 0);
const hsize_t count = 2;
Hdf5Handle s{H5Screate_simple(1, &count, nullptr), H5Sclose};
Hdf5Handle a{H5Acreate2(g.Get(), "time_period", H5T_IEEE_F64LE, s.Get(),
H5P_DEFAULT, H5P_DEFAULT),
H5Aclose};
const double data[2] = {.7, .7};
ASSERT_GE(H5Awrite(a.Get(), H5T_NATIVE_DOUBLE, data), 0);
}};
for (std::size_t i = 0; i < mutations.size(); ++i) {
SCOPED_TRACE(i);
Temp out;
ASSERT_TRUE(fesa::ThermalHdf5ResultsWriter{}
.Write(out.Path(), *x.domain, x.state, {})
.IsOk());
{
fesa::hdf5_internal::Hdf5ErrorSilencer silent;
Hdf5Handle file{
H5Fopen(out.Path().string().c_str(), H5F_ACC_RDWR, H5P_DEFAULT),
H5Fclose};
mutations[i](file.Get());
}
const auto checked = fesa::hdf5_internal::SelfCheckThermalFile(
out.Path(), *x.domain, x.state, {});
EXPECT_FALSE(checked.IsOk());
if (i < 3)
EXPECT_NE(Message(checked).find("type/extent"), std::string::npos)
<< Message(checked);
}
}
TEST(ThermalHdf5ResultsWriter,
RejectsWrongValuesFrameInventoriesNonfiniteAndDiagnostics) {
auto definition = Definition();
definition.thermal_model_boundaries = {{1, 11, 5, {}}, {4, 15, -4, {}}};
const auto x = Make(std::move(definition));
const auto diagnostics = Diagnostics();
std::vector<std::function<void(hid_t)>> mutations = {
[](hid_t f) {
WriteColumn(f, "/model/nodes", "internal_node_id", H5T_NATIVE_UINT64,
std::vector<std::uint64_t>{0, 1, 1, 3, 4});
},
[](hid_t f) {
WriteColumn(f, "/model/elements", "shell_section_internal_id",
H5T_NATIVE_UINT64, std::vector<std::uint64_t>{77});
},
[](hid_t f) {
WriteColumn(f, "/model/thermal_materials", "conductivity",
H5T_NATIVE_DOUBLE, std::vector<double>{3});
},
[](hid_t f) {
WriteColumn(f, "/model/shell_sections", "source_line",
H5T_NATIVE_UINT64, std::vector<std::uint64_t>{99});
},
[](hid_t f) {
WriteColumn(f, "/model/thermal_reaction_dofs", "source_dof",
H5T_NATIVE_UINT64, std::vector<std::uint64_t>{12, 15});
},
[](hid_t f) {
WriteColumn(f, "/steps/Step-1/accepted_frames", "frame_index",
H5T_NATIVE_UINT64, std::vector<std::uint64_t>{0, 1, 1, 3});
},
[](hid_t f) {
WriteColumn(f, "/steps/Step-1/accepted_frames", "time_increment",
H5T_NATIVE_DOUBLE, std::vector<double>{0, .3, .3, .3});
},
[](hid_t f) {
WriteColumn(f, "/diagnostics", "line", H5T_NATIVE_UINT64,
std::vector<std::uint64_t>{9, 2});
},
[](hid_t f) {
Hdf5Handle g{H5Gcreate2(f, "/steps/Step-1/frames/99", H5P_DEFAULT,
H5P_DEFAULT, H5P_DEFAULT),
H5Gclose};
ASSERT_GE(g.Get(), 0);
},
[](hid_t f) {
ASSERT_GE(H5Ldelete(f, "/steps/Step-1/frames/2", H5P_DEFAULT), 0);
},
[](hid_t f) {
ASSERT_GE(
H5Lcreate_soft("/missing", f, "/steps/Step-1/frames/0/element",
H5P_DEFAULT, H5P_DEFAULT),
0);
},
[](hid_t f) {
Hdf5Handle d{
H5Dopen2(f, "/steps/Step-1/frames/1/nodal/reaction_heat_rate",
H5P_DEFAULT),
H5Dclose};
const double values[] = {std::numeric_limits<double>::quiet_NaN(), -9};
ASSERT_GE(H5Dwrite(d.Get(), H5T_NATIVE_DOUBLE, H5S_ALL, H5S_ALL,
H5P_DEFAULT, values),
0);
},
[](hid_t f) {
StringAttribute(f, "/metadata", "source_input_identity",
"wrong-input-bytes");
}};
for (std::size_t i = 0; i < mutations.size(); ++i) {
SCOPED_TRACE(i);
Temp out;
ASSERT_TRUE(fesa::ThermalHdf5ResultsWriter{}
.Write(out.Path(), *x.domain, x.state, diagnostics)
.IsOk());
{
Hdf5Handle file{
H5Fopen(out.Path().string().c_str(), H5F_ACC_RDWR, H5P_DEFAULT),
H5Fclose};
mutations[i](file.Get());
}
EXPECT_FALSE(fesa::hdf5_internal::SelfCheckThermalFile(
out.Path(), *x.domain, x.state, diagnostics)
.IsOk());
}
}
TEST(ThermalHdf5ResultsWriter,
RotatedFramesAndEndpointCloseScheduleRoundTripIndependently) {
auto definition = Definition();
definition.thermal_step->time_period = .9;
// Positive 90-degree rotation about X: columns
// e1=(1,0,0),e2=(0,0,1),n=(0,-1,0).
for (std::size_t i = 1; i < 5; ++i) {
const double y = definition.nodes[i].coordinates[1];
definition.nodes[i].coordinates[1] = 0;
definition.nodes[i].coordinates[2] = y;
}
const auto x = Make(std::move(definition));
Temp out;
ASSERT_TRUE(fesa::ThermalHdf5ResultsWriter{}
.Write(out.Path(), *x.domain, x.state, {})
.IsOk());
Hdf5Handle f{
H5Fopen(out.Path().string().c_str(), H5F_ACC_RDONLY, H5P_DEFAULT),
H5Fclose};
Hdf5Handle d{
H5Dopen2(f.Get(), "/model/thermal_flux_local_frames", H5P_DEFAULT),
H5Dclose};
std::vector<double> frames(36);
ASSERT_GE(H5Dread(d.Get(), H5T_NATIVE_DOUBLE, H5S_ALL, H5S_ALL, H5P_DEFAULT,
frames.data()),
0);
const double expected[] = {1, 0, 0, 0, 0, -1, 0, 1, 0};
for (std::size_t i = 0; i < frames.size(); ++i)
EXPECT_DOUBLE_EQ(frames[i], expected[i % 9]);
Hdf5Handle g{H5Gopen2(f.Get(), "/steps/Step-1/frames/3", H5P_DEFAULT),
H5Gclose};
Hdf5Handle a{H5Aopen(g.Get(), "time_increment", H5P_DEFAULT), H5Aclose};
double actual = 0;
ASSERT_GE(H5Aread(a.Get(), H5T_NATIVE_DOUBLE, &actual), 0);
EXPECT_EQ(actual, .3);
EXPECT_NE(actual, .9 - .6);
Hdf5Handle initial{
H5Dopen2(f.Get(), "/steps/Step-1/frames/0/nodal/temperature",
H5P_DEFAULT),
H5Dclose};
double t[20];
ASSERT_GE(H5Dread(initial.Get(), H5T_NATIVE_DOUBLE, H5S_ALL, H5S_ALL,
H5P_DEFAULT, t),
0);
for (std::size_t i = 0; i < 20; ++i) EXPECT_EQ(t[i], i < 5 ? -2 : 0);
Hdf5Handle flux{H5Dopen2(f.Get(), "/steps/Step-1/frames/3/element/heat_flux",
H5P_DEFAULT),
H5Dclose};
double q[72];
ASSERT_GE(
H5Dread(flux.Get(), H5T_NATIVE_DOUBLE, H5S_ALL, H5S_ALL, H5P_DEFAULT, q),
0);
for (std::size_t point = 0; point < 4; ++point)
for (std::size_t trace = 0; trace < 6; ++trace) {
const auto sample = point * 6 + trace;
EXPECT_EQ(q[3 * sample], static_cast<double>(sample));
EXPECT_EQ(q[3 * sample + 1], -static_cast<double>(sample) - 3);
EXPECT_EQ(q[3 * sample + 2], 9);
}
}
herr_t FailedClose(hid_t file) {
const auto closed = H5Fclose(file);
EXPECT_GE(closed, 0);
return -1;
}
TEST(ThermalHdf5ResultsWriter,
EveryFailedLifecycleBoundaryPreservesOldFinalAndCleansCandidate) {
const auto x = Make();
Temp out;
ASSERT_TRUE(fesa::ThermalHdf5ResultsWriter{}
.Write(out.Path(), *x.domain, x.state, {})
.IsOk());
const auto previous = Bytes(out.Path());
std::vector<fesa::hdf5_internal::ThermalFileHooks> failures(5);
failures[0].before_write = [](hid_t file) {
const hsize_t size = 1;
Hdf5Handle s{H5Screate_simple(1, &size, nullptr), H5Sclose};
Hdf5Handle d{H5Dcreate2(file, "/metadata", H5T_STD_U8LE, s.Get(),
H5P_DEFAULT, H5P_DEFAULT, H5P_DEFAULT),
H5Dclose};
ASSERT_GE(d.Get(), 0);
};
failures[1].candidate_close = FailedClose;
failures[2].before_self_check = [](const auto& candidate) {
Hdf5Handle f{H5Fopen(candidate.string().c_str(), H5F_ACC_RDWR, H5P_DEFAULT),
H5Fclose};
Hdf5Handle m{H5Gopen2(f.Get(), "/metadata", H5P_DEFAULT), H5Gclose};
ASSERT_GE(H5Adelete(m.Get(), "schema_version"), 0);
};
failures[3].reader_close = FailedClose;
failures[4].before_finalize = [](const auto& candidate) {
EXPECT_TRUE(std::filesystem::remove(candidate));
};
for (std::size_t i = 0; i < failures.size(); ++i) {
SCOPED_TRACE(i);
EXPECT_FALSE(fesa::hdf5_internal::WriteThermalFile(out.Path(), *x.domain,
x.state, {}, failures[i])
.IsOk());
EXPECT_EQ(Bytes(out.Path()), previous);
std::size_t files = 0;
for (const auto& entry :
std::filesystem::directory_iterator(out.Path().parent_path())) {
++files;
EXPECT_EQ(entry.path(), out.Path());
}
EXPECT_EQ(files, 1U);
}
}
TEST(ThermalHdf5ResultsWriter,
DomainMismatchAndIncompleteHistoryCannotReplaceFinal) {
const auto x = Make();
Temp out;
ASSERT_TRUE(fesa::ThermalHdf5ResultsWriter{}
.Write(out.Path(), *x.domain, x.state, {})
.IsOk());
const auto previous = Bytes(out.Path());
auto definition = Definition();
definition.nodes[1].source_id = {"part", 8, "0008"};
auto different = fesa::Domain::Create(std::move(definition));
ASSERT_TRUE(different.HasValue());
EXPECT_FALSE(fesa::ThermalHdf5ResultsWriter{}
.Write(out.Path(), different.Value(), x.state, {})
.IsOk());
const auto partial = Make(Definition(), false);
EXPECT_FALSE(fesa::ThermalHdf5ResultsWriter{}
.Write(out.Path(), *partial.domain, partial.state, {})
.IsOk());
EXPECT_EQ(Bytes(out.Path()), previous);
auto constrained = Definition();
constrained.thermal_model_boundaries = {{1, 11, 5, {}}};
const auto prescribed = Make(constrained);
constrained.thermal_model_boundaries[0].value = 6;
auto changed_g = fesa::Domain::Create(std::move(constrained));
ASSERT_TRUE(changed_g.HasValue());
EXPECT_FALSE(fesa::ThermalHdf5ResultsWriter{}
.Write(out.Path(), changed_g.Value(), prescribed.state, {})
.IsOk());
EXPECT_EQ(Bytes(out.Path()), previous);
}
TEST(ThermalHdf5ResultsWriter, EveryPrimitiveDatasetChecksShapeTypeAndValue) {
auto definition = Definition();
definition.thermal_model_boundaries = {{1, 11, 5, {}}};
const auto x = Make(std::move(definition));
std::vector<std::string> datasets;
for (const auto* name :
{"thermal_node_ids", "thermal_element_ids", "temperature_dofs",
"temperature_zeta", "thermal_constraint_mask",
"thermal_flux_inplane_coordinates", "thermal_flux_zeta",
"thermal_flux_trace_side", "thermal_flux_local_frames"})
datasets.push_back(std::string{"/model/"} + name);
for (std::size_t i = 0; i < x.state.AcceptedFrames().size(); ++i) {
const auto root = "/steps/Step-1/frames/" + std::to_string(i);
datasets.push_back(root + "/nodal/temperature");
if (i) {
datasets.push_back(root + "/nodal/reaction_heat_rate");
datasets.push_back(root + "/element/heat_flux");
}
}
for (const auto& path : datasets)
for (int mutation = 0; mutation < 3; ++mutation) {
SCOPED_TRACE(path + ":" + std::to_string(mutation));
Temp out;
ASSERT_TRUE(fesa::ThermalHdf5ResultsWriter{}
.Write(out.Path(), *x.domain, x.state, {})
.IsOk());
{
Hdf5Handle f{
H5Fopen(out.Path().string().c_str(), H5F_ACC_RDWR, H5P_DEFAULT),
H5Fclose};
if (mutation == 2)
ChangePrimitive(f.Get(), path);
else
ReplacePrimitive(f.Get(), path, mutation == 0);
}
const auto checked = fesa::hdf5_internal::SelfCheckThermalFile(
out.Path(), *x.domain, x.state, {});
EXPECT_FALSE(checked.IsOk());
if (mutation == 0)
EXPECT_NE(Message(checked).find("extent"), std::string::npos)
<< Message(checked);
if (mutation == 1)
EXPECT_NE(Message(checked).find("primitive type"), std::string::npos)
<< Message(checked);
}
for (const auto* path :
{"/model/nodes", "/model/elements", "/model/thermal_materials",
"/model/shell_sections", "/model/thermal_reaction_dofs",
"/steps/Step-1/accepted_frames", "/diagnostics"}) {
SCOPED_TRACE(path);
Temp out;
ASSERT_TRUE(fesa::ThermalHdf5ResultsWriter{}
.Write(out.Path(), *x.domain, x.state, {})
.IsOk());
{
Hdf5Handle f{
H5Fopen(out.Path().string().c_str(), H5F_ACC_RDWR, H5P_DEFAULT),
H5Fclose};
ReplacePrimitive(f.Get(), path, true);
}
const auto checked = fesa::hdf5_internal::SelfCheckThermalFile(
out.Path(), *x.domain, x.state, {});
EXPECT_FALSE(checked.IsOk());
EXPECT_NE(Message(checked).find("extent"), std::string::npos)
<< Message(checked);
}
}
TEST(ThermalHdf5ResultsWriter,
InitialRecoveryCannotBeFilledWithCompletedZeros) {
const auto x = Make();
for (const bool flux : {false, true}) {
Temp out;
ASSERT_TRUE(fesa::ThermalHdf5ResultsWriter{}
.Write(out.Path(), *x.domain, x.state, {})
.IsOk());
{
Hdf5Handle f{
H5Fopen(out.Path().string().c_str(), H5F_ACC_RDWR, H5P_DEFAULT),
H5Fclose};
if (flux) {
Hdf5Handle g{H5Gcreate2(f.Get(), "/steps/Step-1/frames/0/element",
H5P_DEFAULT, H5P_DEFAULT, H5P_DEFAULT),
H5Gclose};
ASSERT_GE(g.Get(), 0);
ASSERT_GE(H5Ocopy(f.Get(), "/steps/Step-1/frames/1/element/heat_flux",
f.Get(), "/steps/Step-1/frames/0/element/heat_flux",
H5P_DEFAULT, H5P_DEFAULT),
0);
} else
ASSERT_GE(
H5Ocopy(f.Get(), "/steps/Step-1/frames/1/nodal/reaction_heat_rate",
f.Get(), "/steps/Step-1/frames/0/nodal/reaction_heat_rate",
H5P_DEFAULT, H5P_DEFAULT),
0);
}
EXPECT_FALSE(fesa::hdf5_internal::SelfCheckThermalFile(
out.Path(), *x.domain, x.state, {})
.IsOk());
}
}
} // namespace