diff --git a/include/fesa/io/hdf5/thermal_hdf5_results_writer.h b/include/fesa/io/hdf5/thermal_hdf5_results_writer.h new file mode 100644 index 0000000..2bf3b8d --- /dev/null +++ b/include/fesa/io/hdf5/thermal_hdf5_results_writer.h @@ -0,0 +1,19 @@ +#ifndef FESA_IO_HDF5_THERMAL_HDF5_RESULTS_WRITER_H_ +#define FESA_IO_HDF5_THERMAL_HDF5_RESULTS_WRITER_H_ + +#include "fesa/results/thermal_results_writer.h" + +namespace fesa { +/// @brief Writes schema-v1 shell transient-heat results after closed-file +/// validation. +class ThermalHdf5ResultsWriter final : public ThermalResultsWriter { + public: + /// @brief Writes the complete schema-v1 thermal artifact without replacing a + /// prior final file until closed-file validation succeeds. + Status Write(const std::filesystem::path& output_path, const Domain& domain, + const ThermalAnalysisState& state, + const std::vector& diagnostics) override; +}; +} // namespace fesa + +#endif // FESA_IO_HDF5_THERMAL_HDF5_RESULTS_WRITER_H_ diff --git a/include/fesa/results/thermal_results_writer.h b/include/fesa/results/thermal_results_writer.h new file mode 100644 index 0000000..0db642a --- /dev/null +++ b/include/fesa/results/thermal_results_writer.h @@ -0,0 +1,26 @@ +#ifndef FESA_RESULTS_THERMAL_RESULTS_WRITER_H_ +#define FESA_RESULTS_THERMAL_RESULTS_WRITER_H_ + +#include +#include + +#include "fesa/analysis/thermal_analysis_state.h" +#include "fesa/core/diagnostic.h" +#include "fesa/core/status.h" + +namespace fesa { +class Domain; + +/// @brief Persists a complete accepted thermal history atomically. +class ThermalResultsWriter { + public: + virtual ~ThermalResultsWriter() = default; + /// @brief Writes every accepted thermal frame and diagnostics atomically. + /// @note The Domain and immutable state must outlive this call. + virtual Status Write(const std::filesystem::path& output_path, + const Domain& domain, const ThermalAnalysisState& state, + const std::vector& diagnostics) = 0; +}; +} // namespace fesa + +#endif // FESA_RESULTS_THERMAL_RESULTS_WRITER_H_ diff --git a/src/fesa/io/hdf5/hdf5_model_writer.h b/src/fesa/io/hdf5/hdf5_model_writer.h index 18e57ee..38fbdb4 100644 --- a/src/fesa/io/hdf5/hdf5_model_writer.h +++ b/src/fesa/io/hdf5/hdf5_model_writer.h @@ -8,6 +8,9 @@ namespace fesa::hdf5_internal { +/// @brief Writes every Domain node preserving its source identity. +void WriteNodes(hid_t file, const Domain& domain); + /// @brief Writes metadata and exact model/source identity datasets. void WriteModel(hid_t file, const Domain& domain, const WriterModelData& model_data); diff --git a/src/fesa/io/hdf5/hdf5_result_writer.h b/src/fesa/io/hdf5/hdf5_result_writer.h index 802dc70..5afaaab 100644 --- a/src/fesa/io/hdf5/hdf5_result_writer.h +++ b/src/fesa/io/hdf5/hdf5_result_writer.h @@ -14,6 +14,9 @@ namespace fesa::hdf5_internal { +/// @brief Writes deterministically ordered diagnostics using the common schema. +void WriteDiagnostics(hid_t file, const std::vector& diagnostics); + /// @brief Builds the complete backend-neutral result dataset inventory. Result BuildWriterPlan(const std::filesystem::path& output_path, const Domain& domain, diff --git a/src/fesa/io/hdf5/thermal_hdf5_results_writer.cpp b/src/fesa/io/hdf5/thermal_hdf5_results_writer.cpp new file mode 100644 index 0000000..feb5c23 --- /dev/null +++ b/src/fesa/io/hdf5/thermal_hdf5_results_writer.cpp @@ -0,0 +1,445 @@ +#include "fesa/io/hdf5/thermal_hdf5_results_writer.h" + +#include +#include +#include +#include +#include +#include + +#include "fesa/build_info.h" +#include "io/hdf5/hdf5_atomic_file.h" +#include "io/hdf5/hdf5_model_writer.h" +#include "io/hdf5/hdf5_primitives.h" +#include "io/hdf5/hdf5_raii.h" +#include "io/hdf5/hdf5_result_writer.h" +#include "io/hdf5/thermal_hdf5_self_check.h" + +namespace fesa { +namespace { +using hdf5_internal::Hdf5Handle; + +/// @brief Carries node/source-DOF reaction identities in stable row order. +struct ReactionRow { + std::uint64_t internal_node_id; + std::uint64_t source_dof; +}; +/// @brief Carries accepted schedule values independently of stored-time +/// differences. +struct FrameRow { + std::uint64_t frame_index; + std::uint64_t increment_index; + double time; + double time_increment; + std::uint8_t is_initial; +}; +/// @brief Carries thermal properties with global Domain material identities. +struct MaterialRow { + std::uint64_t internal_material_id; + const char* name; + double conductivity; + double density; + double specific_heat; +}; +/// @brief Carries section provenance and global material linkage. +struct SectionRow { + std::uint64_t internal_section_id; + const char* source_file; + std::uint64_t source_line; + const char* source_elset; + std::uint64_t material_internal_id; + double thickness; +}; +/// @brief Carries DS4 source identities and fixed four-node connectivity. +struct ElementRow { + std::uint64_t internal_element_id; + const char* instance_name; + const char* source_label; + const char* source_element_type; + const char* internal_formulation; + std::uint64_t node_internal_ids[4]; + std::uint64_t shell_section_internal_id; + std::uint64_t material_internal_id; +}; + +/// @brief Writes fixed named fields, preserving zero-length reaction tables. +template +Hdf5Handle Compound(hid_t file, const char* path, const std::vector& rows, + hid_t file_type, hid_t memory_type) { + if (rows.empty()) { + const hsize_t zero[] = {0U}; + const hsize_t unlimited[] = {H5S_UNLIMITED}; + Hdf5Handle space{ + hdf5_internal::RequireHdf5Id(H5Screate_simple(1, zero, unlimited), + "Unable to create empty compound space."), + H5Sclose}; + Hdf5Handle properties{hdf5_internal::RequireHdf5Id( + H5Pcreate(H5P_DATASET_CREATE), + "Unable to create empty compound properties."), + H5Pclose}; + const hsize_t chunk[] = {1U}; + hdf5_internal::RequireHdf5(H5Pset_chunk(properties.Get(), 1, chunk), + "Unable to configure empty compound chunk."); + return Hdf5Handle{ + hdf5_internal::RequireHdf5Id( + H5Dcreate2(file, path, file_type, space.Get(), H5P_DEFAULT, + properties.Get(), H5P_DEFAULT), + "Unable to create empty compound HDF5 dataset."), + H5Dclose}; + } + return hdf5_internal::WriteCompoundDataset(file, path, rows.size(), file_type, + memory_type, rows.data()); +} +/// @brief Defines an explicit named field without making padding a read +/// contract. +void Member(hid_t t, const char* n, size_t off, hid_t f) { + hdf5_internal::RequireHdf5(H5Tinsert(t, n, off, f), + "Unable to define compound field."); +} +/// @brief Builds fixed-width connectivity array datatypes. +Hdf5Handle Array(hid_t type, int rank, const hsize_t* dims) { + return Hdf5Handle{ + hdf5_internal::RequireHdf5Id(H5Tarray_create2(type, rank, dims), + "Unable to create array type."), + H5Tclose}; +} +/// @brief Separates fixed little-endian file fields from native memory fields. +template +void Types(Hdf5Handle& ft, Hdf5Handle& mt, Init init) { + ft = Hdf5Handle{ + hdf5_internal::RequireHdf5Id(H5Tcreate(H5T_COMPOUND, sizeof(Row)), + "Unable to create file compound."), + H5Tclose}; + mt = Hdf5Handle{ + hdf5_internal::RequireHdf5Id(H5Tcreate(H5T_COMPOUND, sizeof(Row)), + "Unable to create memory compound."), + H5Tclose}; + init(ft.Get(), mt.Get()); +} +/// @brief Writes all twelve thermal discriminator, source and schedule +/// attributes. +void Metadata(hid_t file, const Domain& domain, + const ThermalAnalysisState& state) { + auto g = hdf5_internal::CreateGroup(file, "/metadata"); + auto a = [&](const char* n, const std::string& v) { + hdf5_internal::WriteStringAttribute(g.Get(), n, v); + }; + hdf5_internal::WriteUint64Attribute(g.Get(), "schema_version", 1); + a("feature_id", "shell-transient-heat-transfer"); + a("analysis_procedure", "uncoupled-transient-heat-transfer"); + a("source_step_name", + domain.ThermalStep()->name.empty() ? "Step-1" : domain.ThermalStep()->name); + a("solver_version", std::string{SolverVersion()}); + a("source_input_identity", hdf5_internal::SourceInputIdentity(domain)); + a("unit_system_label", "user-consistent-unspecified"); + a("internal_formulation", "FESA-Q4-P2x2-thermal-shell"); + a("integration_rule", "surface-3x3-gauss; thickness-exact-P2x2"); + a("time_integration", "backward-euler"); + hdf5_internal::WriteDoubleAttribute(g.Get(), "nominal_time_increment", + state.NominalTimeIncrement()); + hdf5_internal::WriteDoubleAttribute(g.Get(), "time_period", + state.TimePeriod()); +} +/// @brief Writes all geometry with separate active thermal row maps and +/// recovery axes. +void WriteThermalModel(hid_t file, const Domain& d, + const ThermalAnalysisState& s) { + auto model = hdf5_internal::CreateGroup(file, "/model"); + (void)model; + // Nodes retain all Domain rows; active result mappings remain separate. + Hdf5Handle ft, mt; + auto st = hdf5_internal::MakeUtf8StringType(); + hdf5_internal::WriteNodes(file, d); + std::vector elements; + for (size_t i = 0; i < d.ThermalElements().Size(); ++i) { + const auto& e = d.ThermalElements()[i]; + elements.push_back({i, + e.SourceId().instance_name.c_str(), + e.SourceId().source_label_text.c_str(), + "DS4", + "FESA-Q4-P2x2-thermal-shell", + {e.NodeIndices()[0], e.NodeIndices()[1], + e.NodeIndices()[2], e.NodeIndices()[3]}, + e.ThermalSectionIndex(), + e.MaterialIndex()}); + } + hsize_t four[] = {4}; + auto fu = Array(H5T_STD_U64LE, 1, four), + mu = Array(H5T_NATIVE_UINT64, 1, four); + Types(ft, mt, [&](hid_t f, hid_t m) { + auto add = [&](hid_t t, hid_t u, hid_t ar) { + Member(t, "internal_element_id", HOFFSET(ElementRow, internal_element_id), + u); + Member(t, "instance_name", HOFFSET(ElementRow, instance_name), st.Get()); + Member(t, "source_label", HOFFSET(ElementRow, source_label), st.Get()); + Member(t, "source_element_type", HOFFSET(ElementRow, source_element_type), + st.Get()); + Member(t, "internal_formulation", + HOFFSET(ElementRow, internal_formulation), st.Get()); + Member(t, "node_internal_ids", HOFFSET(ElementRow, node_internal_ids), + ar); + Member(t, "shell_section_internal_id", + HOFFSET(ElementRow, shell_section_internal_id), u); + Member(t, "material_internal_id", + HOFFSET(ElementRow, material_internal_id), u); + }; + add(f, H5T_STD_U64LE, fu.Get()); + add(m, H5T_NATIVE_UINT64, mu.Get()); + }); + (void)Compound(file, "/model/elements", elements, ft.Get(), mt.Get()); + std::vector mats; + for (size_t i = 0; i < d.ThermalMaterials().Size(); ++i) { + const auto& m = d.ThermalMaterials()[i]; + size_t id = 0; + for (; id < d.Materials().Size(); ++id) + if (&d.Materials()[id] == &m) break; + mats.push_back({id, m.Name().c_str(), m.Conductivity(), m.Density(), + m.SpecificHeat()}); + } + Types(ft, mt, [&](hid_t f, hid_t m) { + auto add = [&](hid_t t, hid_t u, hid_t q) { + Member(t, "internal_material_id", + HOFFSET(MaterialRow, internal_material_id), u); + Member(t, "name", HOFFSET(MaterialRow, name), st.Get()); + Member(t, "conductivity", HOFFSET(MaterialRow, conductivity), q); + Member(t, "density", HOFFSET(MaterialRow, density), q); + Member(t, "specific_heat", HOFFSET(MaterialRow, specific_heat), q); + }; + add(f, H5T_STD_U64LE, H5T_IEEE_F64LE); + add(m, H5T_NATIVE_UINT64, H5T_NATIVE_DOUBLE); + }); + auto md = + Compound(file, "/model/thermal_materials", mats, ft.Get(), mt.Get()); + hdf5_internal::WriteStringAttribute(md.Get(), "conductivity_units_label", + "power/(length*temperature)"); + hdf5_internal::WriteStringAttribute(md.Get(), "density_units_label", + "mass/volume"); + hdf5_internal::WriteStringAttribute(md.Get(), "specific_heat_units_label", + "energy/(mass*temperature)"); + std::vector secs; + std::vector files; + files.reserve(d.ThermalSections().size()); + + for (size_t i = 0; i < d.ThermalSections().size(); ++i) { + const auto& q = d.ThermalSections()[i]; + files.push_back(hdf5_internal::NormalizedPathString(q.location.file)); + secs.push_back({i, files.back().c_str(), q.location.line, q.name.c_str(), + hdf5_internal::GlobalThermalMaterialId(d, q.material_index), + q.thickness}); + } + Types(ft, mt, [&](hid_t f, hid_t m) { + auto add = [&](hid_t t, hid_t u, hid_t q) { + Member(t, "internal_section_id", HOFFSET(SectionRow, internal_section_id), + u); + Member(t, "source_file", HOFFSET(SectionRow, source_file), st.Get()); + Member(t, "source_line", HOFFSET(SectionRow, source_line), u); + Member(t, "source_elset", HOFFSET(SectionRow, source_elset), st.Get()); + Member(t, "material_internal_id", + HOFFSET(SectionRow, material_internal_id), u); + Member(t, "thickness", HOFFSET(SectionRow, thickness), q); + }; + add(f, H5T_STD_U64LE, H5T_IEEE_F64LE); + add(m, H5T_NATIVE_UINT64, H5T_NATIVE_DOUBLE); + }); + auto sd = Compound(file, "/model/shell_sections", secs, ft.Get(), mt.Get()); + hdf5_internal::WriteStringAttribute(sd.Get(), "thickness_units_label", + "length"); + std::vector nids(s.NodeOrder().begin(), s.NodeOrder().end()), + eids(s.ElementOrder().begin(), s.ElementOrder().end()); + auto U = [&](const char* p, const std::vector& v) { + auto sp = hdf5_internal::CreateDatasetSpace({v.size()}); + Hdf5Handle ds{hdf5_internal::RequireHdf5Id( + H5Dcreate2(file, p, H5T_STD_U64LE, sp.Get(), H5P_DEFAULT, + H5P_DEFAULT, H5P_DEFAULT), + "Unable to create IDs."), + H5Dclose}; + if (!v.empty()) + hdf5_internal::RequireHdf5(H5Dwrite(ds.Get(), H5T_NATIVE_UINT64, H5S_ALL, + H5S_ALL, H5P_DEFAULT, v.data()), + "Unable to write IDs."); + }; + U("/model/thermal_node_ids", nids); + U("/model/thermal_element_ids", eids); + std::vector dofs = {11, 12, 13, 14, 15}; + U("/model/temperature_dofs", dofs); + const std::vector zeta = {-1, -.5, 0, .5, 1}, + coords = {-1 / std::sqrt(3.), -1 / std::sqrt(3.), + 1 / std::sqrt(3.), -1 / std::sqrt(3.), + 1 / std::sqrt(3.), 1 / std::sqrt(3.), + -1 / std::sqrt(3.), 1 / std::sqrt(3.)}, + fz = {-1, -.5, 0, 0, .5, 1}; + auto D = [&](const char* p, std::vector sh, + const std::vector& v) { + (void)hdf5_internal::WriteDoubleValues(file, p, sh, v.data(), v.size()); + }; + D("/model/temperature_zeta", {5}, zeta); + D("/model/thermal_flux_inplane_coordinates", {4, 2}, coords); + D("/model/thermal_flux_zeta", {6}, fz); + std::vector side = {1, 0, -1, 1, 0, -1}; + auto sp = hdf5_internal::CreateDatasetSpace({6}); + Hdf5Handle sid{ + hdf5_internal::RequireHdf5Id( + H5Dcreate2(file, "/model/thermal_flux_trace_side", H5T_STD_I8LE, + sp.Get(), H5P_DEFAULT, H5P_DEFAULT, H5P_DEFAULT), + "Unable to create trace side."), + H5Dclose}; + hdf5_internal::RequireHdf5(H5Dwrite(sid.Get(), H5T_NATIVE_INT8, H5S_ALL, + H5S_ALL, H5P_DEFAULT, side.data()), + "Unable to write trace side."); + std::vector mask; + for (const auto& r : s.ConstraintMask()) + mask.insert(mask.end(), r.begin(), r.end()); + hdf5_internal::WriteUint8Dataset(file, "/model/thermal_constraint_mask", + {s.NodeOrder().size(), 5}, mask.data(), + mask.size()); + std::vector reactions; + for (const auto& r : s.ReactionDofs()) + reactions.push_back({r.node, (std::uint64_t)r.source_dof}); + Types(ft, mt, [&](hid_t f, hid_t m) { + Member(f, "internal_node_id", HOFFSET(ReactionRow, internal_node_id), + H5T_STD_U64LE); + Member(f, "source_dof", HOFFSET(ReactionRow, source_dof), H5T_STD_U64LE); + Member(m, "internal_node_id", HOFFSET(ReactionRow, internal_node_id), + H5T_NATIVE_UINT64); + Member(m, "source_dof", HOFFSET(ReactionRow, source_dof), + H5T_NATIVE_UINT64); + }); + (void)Compound(file, "/model/thermal_reaction_dofs", reactions, ft.Get(), + mt.Get()); + std::vector frames; + for (const auto& e : s.FluxLocalFrames()) + for (const auto& p : e) + for (size_t g = 0; g < 3; ++g) + for (size_t a = 0; a < 3; ++a) frames.push_back(p[a][g]); + D("/model/thermal_flux_local_frames", {s.ElementOrder().size(), 4, 3, 3}, + frames); +} +/// @brief Writes raw initial temperature and completed-only reactions and +/// fluxes. +void WriteFrames(hid_t file, const ThermalAnalysisState& s) { + (void)hdf5_internal::CreateGroup(file, "/steps/Step-1"); + std::vector rows; + for (const auto& f : s.AcceptedFrames()) + rows.push_back({f.frame_index, f.increment_index, f.time, f.time_increment, + (std::uint8_t)f.is_initial}); + Hdf5Handle ft, mt; + Types(ft, mt, [&](hid_t x, hid_t y) { + auto a = [&](hid_t t, hid_t u, hid_t q) { + Member(t, "frame_index", HOFFSET(FrameRow, frame_index), u); + Member(t, "increment_index", HOFFSET(FrameRow, increment_index), u); + Member(t, "time", HOFFSET(FrameRow, time), q); + Member(t, "time_increment", HOFFSET(FrameRow, time_increment), q); + Member(t, "is_initial", HOFFSET(FrameRow, is_initial), H5T_STD_U8LE); + }; + a(x, H5T_STD_U64LE, H5T_IEEE_F64LE); + a(y, H5T_NATIVE_UINT64, H5T_NATIVE_DOUBLE); + }); + auto af = + Compound(file, "/steps/Step-1/accepted_frames", rows, ft.Get(), mt.Get()); + hdf5_internal::WriteStringAttribute(af.Get(), "time_units_label", "time"); + for (const auto& f : s.AcceptedFrames()) { + std::string root = "/steps/Step-1/frames/" + std::to_string(f.frame_index); + auto g = hdf5_internal::CreateGroup(file, root.c_str()); + (void)hdf5_internal::CreateGroup(file, (root + "/nodal").c_str()); + hdf5_internal::WriteUint64Attribute(g.Get(), "frame_index", f.frame_index); + hdf5_internal::WriteUint64Attribute(g.Get(), "increment_index", + f.increment_index); + hdf5_internal::WriteDoubleAttribute(g.Get(), "time", f.time); + hdf5_internal::WriteDoubleAttribute(g.Get(), "time_increment", + f.time_increment); + hdf5_internal::WriteUint8Attribute(g.Get(), "is_initial", f.is_initial); + auto td = hdf5_internal::WriteDoubleValues( + file, root + "/nodal/temperature", {s.NodeOrder().size(), 5}, + f.temperature.Data(), f.temperature.Size()); + hdf5_internal::WriteStringAttribute(td.Get(), "component_order", + "NT11,NT12,NT13,NT14,NT15"); + hdf5_internal::WriteStringAttribute(td.Get(), "units_label", "temperature"); + hdf5_internal::WriteStringAttribute(td.Get(), "coordinate_system", + "scalar"); + hdf5_internal::WriteStringAttribute(td.Get(), "location", + "nodal-thickness-stations"); + if (f.completed) { + (void)hdf5_internal::CreateGroup(file, (root + "/element").c_str()); + auto rd = hdf5_internal::WriteDoubleValues( + file, root + "/nodal/reaction_heat_rate", {s.ReactionDofs().size()}, + f.completed->reaction_heat_rate.Data(), + f.completed->reaction_heat_rate.Size()); + hdf5_internal::WriteStringAttribute(rd.Get(), "component_order", "RFL"); + hdf5_internal::WriteStringAttribute(rd.Get(), "units_label", "power"); + hdf5_internal::WriteStringAttribute(rd.Get(), "coordinate_system", + "scalar"); + hdf5_internal::WriteStringAttribute(rd.Get(), "location", + "prescribed-temperature-dofs"); + hdf5_internal::WriteStringAttribute(rd.Get(), "positive_direction", + "into-model"); + std::vector q; + for (const auto& e : f.completed->global_heat_flux) + for (size_t p = 0; p < 4; ++p) + for (size_t z = 0; z < 6; ++z) + for (size_t c = 0; c < 3; ++c) q.push_back(e[6 * p + z][c]); + auto fd = hdf5_internal::WriteDoubleValues( + file, root + "/element/heat_flux", {s.ElementOrder().size(), 4, 6, 3}, + q.data(), q.size()); + hdf5_internal::WriteStringAttribute(fd.Get(), "component_order", + "QX,QY,QZ"); + hdf5_internal::WriteStringAttribute(fd.Get(), "units_label", + "power/area"); + hdf5_internal::WriteStringAttribute(fd.Get(), "coordinate_system", + "global-cartesian"); + hdf5_internal::WriteStringAttribute(fd.Get(), "location", + "element-inplane-thickness-traces"); + } + } +} +/// @brief Converts adapter failures into the existing output-failure category. +Status Fail(const char* m) { + return hdf5_internal::OutputFailure("thermal-output-schema", m); +} +} // namespace + +Status hdf5_internal::WriteThermalFile( + const std::filesystem::path& output_path, const Domain& domain, + const ThermalAnalysisState& state, + const std::vector& diagnostics, const ThermalFileHooks& hooks) { + const auto inventory = ValidateThermalOutput(domain, state); + if (!inventory.IsOk()) return inventory; + if (!hooks.candidate_close || !hooks.reader_close) + return Fail("Missing thermal file closer."); + try { + hdf5_internal::Hdf5ErrorSilencer silence; + auto candidate = hdf5_internal::MakeCandidatePath(output_path); + hdf5_internal::CandidateFileGuard guard{candidate}; + Hdf5Handle file{hdf5_internal::RequireHdf5Id( + H5Fcreate(candidate.string().c_str(), H5F_ACC_EXCL, + H5P_DEFAULT, H5P_DEFAULT), + "Unable to create thermal candidate."), + hooks.candidate_close}; + if (hooks.before_write) hooks.before_write(file.Get()); + Metadata(file.Get(), domain, state); + WriteThermalModel(file.Get(), domain, state); + WriteFrames(file.Get(), state); + hdf5_internal::WriteDiagnostics(file.Get(), diagnostics); + hdf5_internal::RequireHdf5(H5Fflush(file.Get(), H5F_SCOPE_GLOBAL), + "Unable to flush thermal candidate."); + hdf5_internal::RequireHdf5(file.CloseChecked(), + "Unable to close thermal candidate."); + if (hooks.before_self_check) hooks.before_self_check(candidate); + const Status ok = hdf5_internal::SelfCheckThermalFile( + candidate, domain, state, diagnostics, hooks.reader_close); + if (ok.IsOk() && hooks.before_finalize) hooks.before_finalize(candidate); + const Status out = hdf5_internal::AtomicFinalizeValidatedCandidate( + candidate, output_path, ok); + if (out.IsOk()) guard.Release(); + return out; + } catch (const std::exception& e) { + return Fail(e.what()); + } +} +Status ThermalHdf5ResultsWriter::Write( + const std::filesystem::path& output_path, const Domain& domain, + const ThermalAnalysisState& state, + const std::vector& diagnostics) { + return hdf5_internal::WriteThermalFile(output_path, domain, state, + diagnostics); +} +} // namespace fesa diff --git a/src/fesa/io/hdf5/thermal_hdf5_self_check.cpp b/src/fesa/io/hdf5/thermal_hdf5_self_check.cpp new file mode 100644 index 0000000..fb19698 --- /dev/null +++ b/src/fesa/io/hdf5/thermal_hdf5_self_check.cpp @@ -0,0 +1,581 @@ +#include "io/hdf5/thermal_hdf5_self_check.h" + +#include +#include +#include +#include +#include +#include + +#include "fesa/build_info.h" +#include "io/hdf5/hdf5_primitives.h" +#include "io/hdf5/hdf5_raii.h" + +namespace fesa::hdf5_internal { +namespace { +using U64 = std::uint64_t; +/// @brief Rejects malformed mandatory schema without exposing HDF5 diagnostics. +void Require(bool condition, const std::string& message) { + if (!condition) throw Hdf5Failure{message}; +} +/// @brief Opens one mandatory dataset and checks its entire declared extent. +Hdf5Handle Dataset(hid_t file, const std::string& path, + const std::vector& shape) { + Hdf5Handle d{ + RequireHdf5Id(H5Dopen2(file, path.c_str(), H5P_DEFAULT), path.c_str()), + H5Dclose}; + Hdf5Handle space{RequireHdf5Id(H5Dget_space(d.Get()), "Missing dataspace."), + H5Sclose}; + Require( + H5Sget_simple_extent_ndims(space.Get()) == static_cast(shape.size()), + path + ": wrong rank"); + std::vector actual(shape.size()); + Require(H5Sget_simple_extent_dims(space.Get(), actual.data(), nullptr) >= 0 && + actual == shape, + path + ": wrong extent"); + return d; +} +/// @brief Compares every typed scalar against an independent expected +/// inventory. +template +void Values(hid_t d, hid_t file_type, hid_t memory_type, + const std::vector& expected) { + Hdf5Handle type{RequireHdf5Id(H5Dget_type(d), "Missing dataset type."), + H5Tclose}; + Require(H5Tequal(type.Get(), file_type) > 0, + "Wrong mandatory dataset primitive type."); + std::vector values(expected.size()); + if (!values.empty()) + RequireHdf5( + H5Dread(d, memory_type, H5S_ALL, H5S_ALL, H5P_DEFAULT, values.data()), + "Cannot read dataset."); + Require( + values == expected, + "Dataset values or ordered identities differ from the supplied history."); +} +/// @brief Checks a scalar numeric attribute before reading its one-value +/// buffer. +template +void Number(hid_t object, const char* name, hid_t file_type, hid_t memory_type, + T expected) { + Hdf5Handle a{RequireHdf5Id(H5Aopen(object, name, H5P_DEFAULT), name), + H5Aclose}; + Hdf5Handle t{RequireHdf5Id(H5Aget_type(a.Get()), "Missing attribute type."), + H5Tclose}; + Hdf5Handle s{RequireHdf5Id(H5Aget_space(a.Get()), "Missing attribute space."), + H5Sclose}; + Require(H5Tequal(t.Get(), file_type) > 0 && + H5Sget_simple_extent_type(s.Get()) == H5S_SCALAR, + std::string{name} + ": wrong scalar attribute type/space"); + T value{}; + RequireHdf5(H5Aread(a.Get(), memory_type, &value), "Cannot read attribute."); + Require(value == expected, std::string{name} + ": wrong attribute value"); +} +/// @brief Reads either fixed or variable UTF-8 strings, preserving schema +/// freedom. +void Strings(hid_t string_type, const std::vector& expected, + const std::function& read, bool compound, + const char* field) { + Require(H5Tget_class(string_type) == H5T_STRING && + H5Tget_cset(string_type) == H5T_CSET_UTF8, + "Mandatory string is not UTF-8."); + const auto variable = H5Tis_variable_str(string_type); + Require(variable >= 0, "Cannot inspect string storage."); + const std::size_t width = variable ? sizeof(char*) : H5Tget_size(string_type); + Require(width > 0, "Invalid fixed string width."); + Hdf5Handle memory; + hid_t read_type = string_type; + if (compound) { + memory = Hdf5Handle{RequireHdf5Id(H5Tcreate(H5T_COMPOUND, width), + "Cannot create string projection."), + H5Tclose}; + RequireHdf5(H5Tinsert(memory.Get(), field, 0, string_type), + "Cannot project string field."); + read_type = memory.Get(); + } + bool equal = true; + if (variable) { + std::vector values(expected.size(), nullptr); + const herr_t status = values.empty() ? 0 : read(read_type, values.data()); + for (std::size_t i = 0; i < values.size(); ++i) { + if (!values[i] || !IsValidUtf8(values[i]) || expected[i] != values[i]) + equal = false; + if (values[i] && H5free_memory(values[i]) < 0) equal = false; + } + RequireHdf5(status, "Cannot read variable strings."); + } else { + std::size_t bytes = 0; + Require(SizeProductFits(width, expected.size(), bytes), + "String extent overflows memory."); + std::vector values(bytes); + if (!values.empty()) + RequireHdf5(read(read_type, values.data()), "Cannot read fixed strings."); + for (std::size_t i = 0; i < expected.size(); ++i) { + std::string value(values.data() + i * width, width); + const auto end = value.find('\0'); + if (end != std::string::npos) + value.resize(end); + else if (H5Tget_strpad(string_type) == H5T_STR_SPACEPAD) + while (!value.empty() && value.back() == ' ') value.pop_back(); + if (!IsValidUtf8(value) || value != expected[i]) equal = false; + } + } + Require(equal, + "String field/attribute differs from the expected source identity or " + "value."); +} +/// @brief Checks a mandatory scalar UTF-8 attribute, including its exact value. +void Text(hid_t object, const char* name, const std::string& expected) { + Hdf5Handle a{RequireHdf5Id(H5Aopen(object, name, H5P_DEFAULT), name), + H5Aclose}; + Hdf5Handle t{RequireHdf5Id(H5Aget_type(a.Get()), "Missing text type."), + H5Tclose}; + Hdf5Handle s{RequireHdf5Id(H5Aget_space(a.Get()), "Missing text space."), + H5Sclose}; + Require(H5Sget_simple_extent_type(s.Get()) == H5S_SCALAR, + "Text attribute is not scalar."); + Strings( + t.Get(), {expected}, + [&](hid_t type, void* data) { return H5Aread(a.Get(), type, data); }, + false, name); +} +/// @brief Locates a named field independently of compound order and padding. +Hdf5Handle Field(hid_t dataset, const char* name) { + Hdf5Handle t{RequireHdf5Id(H5Dget_type(dataset), "Missing compound type."), + H5Tclose}; + Require(H5Tget_class(t.Get()) == H5T_COMPOUND, + "Mandatory table is not compound."); + const int index = H5Tget_member_index(t.Get(), name); + Require(index >= 0, std::string{"Missing compound field: "} + name); + return { + RequireHdf5Id(H5Tget_member_type(t.Get(), static_cast(index)), + "Cannot read compound member."), + H5Tclose}; +} +/// @brief Checks typed compound columns, including fixed-array element extents. +template +void Column(hid_t d, const char* name, const std::vector& expected, + hid_t primitive, hid_t native, hsize_t extent = 1) { + auto disk = Field(d, name); + Hdf5Handle fa, ma; + hid_t ft = primitive, mt = native; + if (extent != 1) { + fa = {RequireHdf5Id(H5Tarray_create2(primitive, 1, &extent), + "Cannot create file array projection."), + H5Tclose}; + ma = {RequireHdf5Id(H5Tarray_create2(native, 1, &extent), + "Cannot create memory array projection."), + H5Tclose}; + ft = fa.Get(); + mt = ma.Get(); + } + Require(H5Tequal(disk.Get(), ft) > 0, + std::string{name} + ": wrong compound member type/extent"); + Hdf5Handle memory{ + RequireHdf5Id( + H5Tcreate(H5T_COMPOUND, sizeof(T) * static_cast(extent)), + "Cannot create numeric projection."), + H5Tclose}; + RequireHdf5(H5Tinsert(memory.Get(), name, 0, mt), + "Cannot project numeric field."); + std::vector values(expected.size()); + if (!values.empty()) + RequireHdf5( + H5Dread(d, memory.Get(), H5S_ALL, H5S_ALL, H5P_DEFAULT, values.data()), + "Cannot read compound column."); + Require(values == expected, + std::string{name} + ": compound values or identities differ"); +} +/// @brief Checks a named string column with independent expected source +/// strings. +void ColumnText(hid_t d, const char* name, + const std::vector& expected) { + auto type = Field(d, name); + Strings( + type.Get(), expected, + [&](hid_t t, void* p) { + return H5Dread(d, t, H5S_ALL, H5S_ALL, H5P_DEFAULT, p); + }, + true, name); +} +/// @brief Checks unsigned identity columns without depending on C++ row layout. +void Id(hid_t d, const char* name, const std::vector& expected) { + Column(d, name, expected, H5T_STD_U64LE, H5T_NATIVE_UINT64); +} +/// @brief Checks finite double columns against immutable Domain/history values. +void Real(hid_t d, const char* name, const std::vector& expected) { + Column(d, name, expected, H5T_IEEE_F64LE, H5T_NATIVE_DOUBLE); +} +/// @brief Requires exact optional-child absence without treating API errors as +/// absence. +void Absent(hid_t file, const std::string& parent, const char* child) { + const htri_t exists = H5Lexists(file, parent.c_str(), H5P_DEFAULT); + Require(exists >= 0, "Cannot inspect optional recovery parent."); + if (exists == 0) return; + Require(H5Lexists(file, (parent + "/" + child).c_str(), H5P_DEFAULT) == 0, + "Initial recovery is present or cannot be inspected."); +} +/// @brief Checks source tables against Domain rows, including global material +/// IDs. +void ModelTables(hid_t f, const Domain& domain) { + std::vector ids, connectivity, sections, materials, lines; + std::vector instances, labels, types, formulations, names, files, + elsets; + std::vector xyz, k, rho, c, h; + for (std::size_t i = 0; i < domain.Nodes().size(); ++i) { + const auto& node = domain.Nodes()[i]; + ids.push_back(i); + instances.push_back(node.source_id.instance_name); + labels.push_back(node.source_id.source_label_text); + xyz.insert(xyz.end(), node.coordinates.begin(), node.coordinates.end()); + } + auto nodes = Dataset(f, "/model/nodes", {ids.size()}); + Id(nodes.Get(), "internal_node_id", ids); + ColumnText(nodes.Get(), "instance_name", instances); + ColumnText(nodes.Get(), "source_label", labels); + Column(nodes.Get(), "coordinates", xyz, H5T_IEEE_F64LE, H5T_NATIVE_DOUBLE, 3); + Text(nodes.Get(), "units_label", "length"); + ids.clear(); + instances.clear(); + labels.clear(); + for (std::size_t i = 0; i < domain.ThermalElements().Size(); ++i) { + const auto& e = domain.ThermalElements()[i]; + ids.push_back(i); + instances.push_back(e.SourceId().instance_name); + labels.push_back(e.SourceId().source_label_text); + types.push_back("DS4"); + formulations.push_back("FESA-Q4-P2x2-thermal-shell"); + for (const auto node : e.NodeIndices()) connectivity.push_back(node); + sections.push_back(e.ThermalSectionIndex()); + materials.push_back(e.MaterialIndex()); + } + auto elements = Dataset(f, "/model/elements", {ids.size()}); + Id(elements.Get(), "internal_element_id", ids); + ColumnText(elements.Get(), "instance_name", instances); + ColumnText(elements.Get(), "source_label", labels); + ColumnText(elements.Get(), "source_element_type", types); + ColumnText(elements.Get(), "internal_formulation", formulations); + Column(elements.Get(), "node_internal_ids", connectivity, H5T_STD_U64LE, + H5T_NATIVE_UINT64, 4); + Id(elements.Get(), "shell_section_internal_id", sections); + Id(elements.Get(), "material_internal_id", materials); + ids.clear(); + for (std::size_t i = 0; i < domain.ThermalMaterials().Size(); ++i) { + const auto& material = domain.ThermalMaterials()[i]; + ids.push_back(GlobalThermalMaterialId(domain, i)); + names.push_back(material.Name()); + k.push_back(material.Conductivity()); + rho.push_back(material.Density()); + c.push_back(material.SpecificHeat()); + } + auto mat = Dataset(f, "/model/thermal_materials", {ids.size()}); + Id(mat.Get(), "internal_material_id", ids); + ColumnText(mat.Get(), "name", names); + Real(mat.Get(), "conductivity", k); + Real(mat.Get(), "density", rho); + Real(mat.Get(), "specific_heat", c); + Text(mat.Get(), "conductivity_units_label", "power/(length*temperature)"); + Text(mat.Get(), "density_units_label", "mass/volume"); + Text(mat.Get(), "specific_heat_units_label", "energy/(mass*temperature)"); + ids.clear(); + materials.clear(); + for (std::size_t i = 0; i < domain.ThermalSections().size(); ++i) { + const auto& section = domain.ThermalSections()[i]; + ids.push_back(i); + files.push_back(NormalizedPathString(section.location.file)); + lines.push_back(section.location.line); + elsets.push_back(section.name); + materials.push_back( + GlobalThermalMaterialId(domain, section.material_index)); + h.push_back(section.thickness); + } + auto sec = Dataset(f, "/model/shell_sections", {ids.size()}); + Id(sec.Get(), "internal_section_id", ids); + ColumnText(sec.Get(), "source_file", files); + Id(sec.Get(), "source_line", lines); + ColumnText(sec.Get(), "source_elset", elsets); + Id(sec.Get(), "material_internal_id", materials); + Real(sec.Get(), "thickness", h); + Text(sec.Get(), "thickness_units_label", "length"); +} +/// @brief Checks all scalar metadata values, including actual input byte +/// identity. +void Metadata(hid_t file, const Domain& domain, + const ThermalAnalysisState& state) { + Hdf5Handle m{RequireHdf5Id(H5Gopen2(file, "/metadata", H5P_DEFAULT), + "Missing metadata."), + H5Gclose}; + Number(m.Get(), "schema_version", H5T_STD_U64LE, H5T_NATIVE_UINT64, U64{1}); + Text(m.Get(), "feature_id", "shell-transient-heat-transfer"); + Text(m.Get(), "analysis_procedure", "uncoupled-transient-heat-transfer"); + Text(m.Get(), "source_step_name", + domain.ThermalStep()->name.empty() ? "Step-1" + : domain.ThermalStep()->name); + Text(m.Get(), "solver_version", std::string{SolverVersion()}); + Text(m.Get(), "source_input_identity", SourceInputIdentity(domain)); + Text(m.Get(), "unit_system_label", "user-consistent-unspecified"); + Text(m.Get(), "internal_formulation", "FESA-Q4-P2x2-thermal-shell"); + Text(m.Get(), "integration_rule", "surface-3x3-gauss; thickness-exact-P2x2"); + Text(m.Get(), "time_integration", "backward-euler"); + Number(m.Get(), "nominal_time_increment", H5T_IEEE_F64LE, H5T_NATIVE_DOUBLE, + state.NominalTimeIncrement()); + Number(m.Get(), "time_period", H5T_IEEE_F64LE, H5T_NATIVE_DOUBLE, + state.TimePeriod()); +} +/// @brief Checks active maps, station/trace conventions and the global-axis R +/// layout. +void Mappings(hid_t f, const ThermalAnalysisState& s) { + const auto u = [&](const char* p, std::vector v) { + auto d = Dataset(f, p, {v.size()}); + Values(d.Get(), H5T_STD_U64LE, H5T_NATIVE_UINT64, v); + }; + const auto real = [&](const char* p, std::vector sh, + const std::vector& v) { + auto d = Dataset(f, p, sh); + Values(d.Get(), H5T_IEEE_F64LE, H5T_NATIVE_DOUBLE, v); + }; + u("/model/thermal_node_ids", {s.NodeOrder().begin(), s.NodeOrder().end()}); + u("/model/thermal_element_ids", + {s.ElementOrder().begin(), s.ElementOrder().end()}); + u("/model/temperature_dofs", {11, 12, 13, 14, 15}); + real("/model/temperature_zeta", {5}, {-1, -.5, 0, .5, 1}); + std::vector mask; + for (const auto& row : s.ConstraintMask()) + mask.insert(mask.end(), row.begin(), row.end()); + auto md = + Dataset(f, "/model/thermal_constraint_mask", {s.NodeOrder().size(), 5}); + Values(md.Get(), H5T_STD_U8LE, H5T_NATIVE_UINT8, mask); + std::vector nodes, dofs; + for (const auto& r : s.ReactionDofs()) { + nodes.push_back(r.node); + dofs.push_back(r.source_dof); + } + auto reaction = Dataset(f, "/model/thermal_reaction_dofs", {nodes.size()}); + Id(reaction.Get(), "internal_node_id", nodes); + Id(reaction.Get(), "source_dof", dofs); + const double g = 1 / std::sqrt(3.); + real("/model/thermal_flux_inplane_coordinates", {4, 2}, + {-g, -g, g, -g, g, g, -g, g}); + real("/model/thermal_flux_zeta", {6}, {-1, -.5, 0, 0, .5, 1}); + auto side = Dataset(f, "/model/thermal_flux_trace_side", {6}); + Values(side.Get(), H5T_STD_I8LE, H5T_NATIVE_INT8, + std::vector{1, 0, -1, 1, 0, -1}); + std::vector frames; + for (const auto& element : s.FluxLocalFrames()) + for (const auto& point : element) + for (std::size_t component = 0; component < 3; ++component) + for (std::size_t axis = 0; axis < 3; ++axis) + frames.push_back(point[axis][component]); + real("/model/thermal_flux_local_frames", {s.ElementOrder().size(), 4, 3, 3}, + frames); +} +/// @brief Checks result values and exact component/unit/location attributes. +void ResultValues(hid_t file, const std::string& path, + std::vector shape, const std::vector& values, + const char* components, const char* units, + const char* coordinates, const char* location) { + auto dataset = Dataset(file, path, shape); + Values(dataset.Get(), H5T_IEEE_F64LE, H5T_NATIVE_DOUBLE, values); + Text(dataset.Get(), "component_order", components); + Text(dataset.Get(), "units_label", units); + Text(dataset.Get(), "coordinate_system", coordinates); + Text(dataset.Get(), "location", location); +} +/// @brief Checks frame inventory and actual equation deltas without recomputing +/// time differences. +void Frames(hid_t f, const ThermalAnalysisState& s) { + std::vector index, increment; + std::vector time, delta; + std::vector initial; + for (const auto& row : s.AcceptedFrames()) { + index.push_back(row.frame_index); + increment.push_back(row.increment_index); + time.push_back(row.time); + delta.push_back(row.time_increment); + initial.push_back(row.is_initial); + } + auto table = Dataset(f, "/steps/Step-1/accepted_frames", {index.size()}); + Id(table.Get(), "frame_index", index); + Id(table.Get(), "increment_index", increment); + Real(table.Get(), "time", time); + Real(table.Get(), "time_increment", delta); + Column(table.Get(), "is_initial", initial, H5T_STD_U8LE, H5T_NATIVE_UINT8); + Text(table.Get(), "time_units_label", "time"); + Hdf5Handle group{ + RequireHdf5Id(H5Gopen2(f, "/steps/Step-1/frames", H5P_DEFAULT), + "Missing frames group."), + H5Gclose}; + H5G_info_t info{}; + RequireHdf5(H5Gget_info(group.Get(), &info), "Cannot enumerate frames."); + Require(info.nlinks == index.size(), "Missing or extra frame groups."); + for (const auto& frame : s.AcceptedFrames()) { + const auto root = + "/steps/Step-1/frames/" + std::to_string(frame.frame_index); + Hdf5Handle g{ + RequireHdf5Id(H5Gopen2(f, root.c_str(), H5P_DEFAULT), "Missing frame."), + H5Gclose}; + Number(g.Get(), "frame_index", H5T_STD_U64LE, H5T_NATIVE_UINT64, + frame.frame_index); + Number(g.Get(), "increment_index", H5T_STD_U64LE, H5T_NATIVE_UINT64, + frame.increment_index); + Number(g.Get(), "time", H5T_IEEE_F64LE, H5T_NATIVE_DOUBLE, frame.time); + Number(g.Get(), "time_increment", H5T_IEEE_F64LE, H5T_NATIVE_DOUBLE, + frame.time_increment); + Number(g.Get(), "is_initial", H5T_STD_U8LE, H5T_NATIVE_UINT8, + static_cast(frame.is_initial)); + ResultValues(f, root + "/nodal/temperature", {s.NodeOrder().size(), 5}, + {frame.temperature.Data(), + frame.temperature.Data() + frame.temperature.Size()}, + "NT11,NT12,NT13,NT14,NT15", "temperature", "scalar", + "nodal-thickness-stations"); + if (frame.is_initial) { + Absent(f, root + "/nodal", "reaction_heat_rate"); + Absent(f, root + "/element", "heat_flux"); + } else { + const auto& payload = *frame.completed; + std::vector reaction(payload.reaction_heat_rate.Size()); + for (std::size_t i = 0; i < reaction.size(); ++i) + reaction[i] = payload.reaction_heat_rate[i]; + ResultValues(f, root + "/nodal/reaction_heat_rate", {reaction.size()}, + reaction, "RFL", "power", "scalar", + "prescribed-temperature-dofs"); + auto rd = + Dataset(f, root + "/nodal/reaction_heat_rate", {reaction.size()}); + Text(rd.Get(), "positive_direction", "into-model"); + std::vector flux; + for (const auto& element : payload.global_heat_flux) + for (const auto& q : element) + for (const double value : q.Components()) flux.push_back(value); + ResultValues(f, root + "/element/heat_flux", + {s.ElementOrder().size(), 4, 6, 3}, flux, "QX,QY,QZ", + "power/area", "global-cartesian", + "element-inplane-thickness-traces"); + } + } +} +/// @brief Checks sorted diagnostics with all established provenance fields. +void Diagnostics(hid_t f, std::vector diagnostics) { + SortDiagnostics(diagnostics); + std::vector severity, code, file, keyword, entity, message; + std::vector line; + for (const auto& d : diagnostics) { + severity.push_back(d.severity == Severity::kWarning ? "warning" : "error"); + code.push_back(d.code); + file.push_back(NormalizedPathString(d.location.file)); + line.push_back(d.location.line); + keyword.push_back(d.keyword); + entity.push_back(d.entity_identity); + message.push_back(d.message); + } + auto dataset = Dataset(f, "/diagnostics", {diagnostics.size()}); + ColumnText(dataset.Get(), "severity", severity); + ColumnText(dataset.Get(), "code", code); + ColumnText(dataset.Get(), "file", file); + Id(dataset.Get(), "line", line); + ColumnText(dataset.Get(), "keyword", keyword); + ColumnText(dataset.Get(), "entity_identity", entity); + ColumnText(dataset.Get(), "message", message); +} +} // namespace + +U64 GlobalThermalMaterialId(const Domain& domain, std::size_t local) { + Require(local < domain.ThermalMaterials().Size(), + "Invalid local thermal material index."); + const auto* thermal = &domain.ThermalMaterials()[local]; + for (std::size_t i = 0; i < domain.Materials().Size(); ++i) + if (&domain.Materials()[i] == thermal) return i; + throw Hdf5Failure{"Thermal material has no global Domain identity."}; +} + +Status ValidateThermalOutput(const Domain& domain, + const ThermalAnalysisState& state) { + try { + Require(state.IsComplete() && domain.ThermalStep() != nullptr && + domain.Steps().Size() == 0, + "Thermal output requires one complete thermal history."); + auto model = AnalysisModel::Create(domain); + Require(model.HasValue() && model.Value().IsThermal(), + "Invalid thermal Domain."); + auto dofs = DofManager::CreateThermal(model.Value()); + Require(dofs.HasValue(), "Invalid thermal equation inventory."); + Require(state.NodeOrder() == model.Value().ActiveThermalNodes() && + state.ElementOrder() == model.Value().ActiveThermalElements(), + "Active Domain/state inventories differ."); + Require(state.NominalTimeIncrement() == + domain.ThermalStep()->nominal_increment && + state.TimePeriod() == domain.ThermalStep()->time_period, + "Domain/state schedules differ."); + const auto grid = + ThermalTimeGrid::Create(domain.ThermalStep()->time_period, + domain.ThermalStep()->nominal_increment, + domain.ThermalStep()->maximum_increments); + Require(grid.HasValue() && grid.Value().Count() == state.TimeGrid().Count(), + "Domain/state increment inventory differs."); + for (std::size_t i = 0; i < state.NodeOrder().size(); ++i) + Require(SameIdentity(state.NodeSourceIds()[i], + domain.Nodes()[state.NodeOrder()[i]].source_id), + "Node source identity differs."); + for (std::size_t i = 0; i < state.ElementOrder().size(); ++i) + Require(SameIdentity( + state.ElementSourceIds()[i], + domain.ThermalElements()[state.ElementOrder()[i]].SourceId()), + "Element source identity differs."); + std::vector> mask(state.NodeOrder().size()); + for (const auto row : dofs.Value().ConstrainedDofs()) + mask[row / 5][row % 5] = 1; + Require(mask == state.ConstraintMask(), + "Prescribed temperature inventory differs."); + for (const auto& frame : state.AcceptedFrames()) + if (!frame.is_initial) + for (std::size_t i = 0; i < dofs.Value().ConstrainedDofs().size(); ++i) + Require(frame.temperature[dofs.Value().ConstrainedDofs()[i]] == + dofs.Value().PrescribedValues()[i], + "Completed prescribed temperature differs from Domain."); + Vector raw{dofs.Value().FullDofCount()}; + for (const auto& initial : domain.ThermalInitialConditions()) { + const auto at = std::find(state.NodeOrder().begin(), + state.NodeOrder().end(), initial.node_index); + if (at != state.NodeOrder().end()) { + const auto row = + static_cast(at - state.NodeOrder().begin()); + for (std::size_t station = 0; station < 5; ++station) + raw[5 * row + station] = initial.station_values[station]; + } + } + const auto& actual = state.AcceptedFrames().front().temperature; + Require(actual.Size() == raw.Size(), "Raw initial shape differs."); + for (std::size_t i = 0; i < raw.Size(); ++i) + Require(actual[i] == raw[i], + "Raw initial temperatures differ from Domain."); + return Status::Ok(); + } catch (const std::exception& e) { + return OutputFailure("invalid-thermal-output-inventory", e.what()); + } +} + +Status SelfCheckThermalFile(const std::filesystem::path& path, + const Domain& domain, + const ThermalAnalysisState& state, + const std::vector& diagnostics, + herr_t (*close_reader)(hid_t)) { + const auto inventory = ValidateThermalOutput(domain, state); + if (!inventory.IsOk()) return inventory; + try { + Hdf5ErrorSilencer silence; + Require(close_reader != nullptr, "Missing thermal reader closer."); + Hdf5Handle file{RequireHdf5Id(H5Fopen(path.string().c_str(), H5F_ACC_RDONLY, + H5P_DEFAULT), + "Cannot reopen thermal candidate."), + close_reader}; + Metadata(file.Get(), domain, state); + ModelTables(file.Get(), domain); + Mappings(file.Get(), state); + Frames(file.Get(), state); + Diagnostics(file.Get(), diagnostics); + RequireHdf5(file.CloseChecked(), "Cannot close checked thermal candidate."); + return Status::Ok(); + } catch (const std::exception& e) { + return OutputFailure("invalid-thermal-hdf5-candidate", e.what()); + } +} +} // namespace fesa::hdf5_internal diff --git a/src/fesa/io/hdf5/thermal_hdf5_self_check.h b/src/fesa/io/hdf5/thermal_hdf5_self_check.h new file mode 100644 index 0000000..048d5cf --- /dev/null +++ b/src/fesa/io/hdf5/thermal_hdf5_self_check.h @@ -0,0 +1,49 @@ +#ifndef FESA_IO_HDF5_THERMAL_HDF5_SELF_CHECK_H_ +#define FESA_IO_HDF5_THERMAL_HDF5_SELF_CHECK_H_ + +#include + +#include +#include +#include + +#include "fesa/analysis/thermal_analysis_state.h" +#include "fesa/core/status.h" +#include "fesa/model/domain.h" + +namespace fesa { +struct Diagnostic; +} +namespace fesa::hdf5_internal { +/// @brief Supplies private lifecycle fault seams around the real HDF5 +/// operations. +/// @note Production uses default closers and no callbacks. Test closers must +/// release their handle even when reporting an injected close failure. +struct ThermalFileHooks { + herr_t (*candidate_close)(hid_t) = H5Fclose; + herr_t (*reader_close)(hid_t) = H5Fclose; + std::function before_write; + std::function before_self_check; + std::function before_finalize; +}; +/// @brief Executes the actual candidate/write/check/close/finalize lifecycle. +Status WriteThermalFile(const std::filesystem::path& output_path, + const Domain& domain, const ThermalAnalysisState& state, + const std::vector& diagnostics, + const ThermalFileHooks& hooks = {}); +/// @brief Maps section-local thermal material indices into the global Domain +/// table. +std::uint64_t GlobalThermalMaterialId(const Domain& domain, std::size_t local); +/// @brief Checks supplied immutable source/initial/schedule inventories before +/// writing. +Status ValidateThermalOutput(const Domain& domain, + const ThermalAnalysisState& state); +/// @brief Checks every mandatory thermal field, value and attribute before +/// publication. +Status SelfCheckThermalFile(const std::filesystem::path& path, + const Domain& domain, + const ThermalAnalysisState& state, + const std::vector& diagnostics, + herr_t (*close_reader)(hid_t) = H5Fclose); +} // namespace fesa::hdf5_internal +#endif // FESA_IO_HDF5_THERMAL_HDF5_SELF_CHECK_H_ diff --git a/tests/unit/io/hdf5/thermal_hdf5_results_writer_test.cpp b/tests/unit/io/hdf5/thermal_hdf5_results_writer_test.cpp new file mode 100644 index 0000000..e475d61 --- /dev/null +++ b/tests/unit/io/hdf5/thermal_hdf5_results_writer_test.cpp @@ -0,0 +1,993 @@ +#include "fesa/io/hdf5/thermal_hdf5_results_writer.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#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 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(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(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(row); + std::vector 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 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 Bytes(const std::filesystem::path& path) { + std::ifstream stream(path, std::ios::binary); + return {std::istreambuf_iterator(stream), + std::istreambuf_iterator()}; +} +std::vector 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*>(data)->push_back(name); + return 0; +} +void CopyAttributes(hid_t from, hid_t to) { + std::vector 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 value( + static_cast(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 names; + std::vector 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(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(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(H5Sget_simple_extent_npoints(space.Get())); + std::vector 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 +void WriteColumn(hid_t file, const char* path, const char* field, hid_t native, + const std::vector& 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 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 shape(static_cast(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 values(static_cast(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 values(static_cast(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>{ + [](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> required; + const auto add = [&](const std::string& path, + std::initializer_list 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>> 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 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 name{}; + std::copy_n("thermal", 7, name.data()); + WriteColumn(file.Get(), "/model/thermal_materials", "name", fixed.Get(), + std::vector>{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> 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> mutations = { + [](hid_t f) { + WriteColumn(f, "/model/nodes", "internal_node_id", H5T_NATIVE_UINT64, + std::vector{0, 1, 1, 3, 4}); + }, + [](hid_t f) { + WriteColumn(f, "/model/elements", "shell_section_internal_id", + H5T_NATIVE_UINT64, std::vector{77}); + }, + [](hid_t f) { + WriteColumn(f, "/model/thermal_materials", "conductivity", + H5T_NATIVE_DOUBLE, std::vector{3}); + }, + [](hid_t f) { + WriteColumn(f, "/model/shell_sections", "source_line", + H5T_NATIVE_UINT64, std::vector{99}); + }, + [](hid_t f) { + WriteColumn(f, "/model/thermal_reaction_dofs", "source_dof", + H5T_NATIVE_UINT64, std::vector{12, 15}); + }, + [](hid_t f) { + WriteColumn(f, "/steps/Step-1/accepted_frames", "frame_index", + H5T_NATIVE_UINT64, std::vector{0, 1, 1, 3}); + }, + [](hid_t f) { + WriteColumn(f, "/steps/Step-1/accepted_frames", "time_increment", + H5T_NATIVE_DOUBLE, std::vector{0, .3, .3, .3}); + }, + [](hid_t f) { + WriteColumn(f, "/diagnostics", "line", H5T_NATIVE_UINT64, + std::vector{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::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 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(sample)); + EXPECT_EQ(q[3 * sample + 1], -static_cast(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 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 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