From f91358fe69dd520f568ea63c1a7a3929b99bab69 Mon Sep 17 00:00:00 2001 From: petlenz Date: Wed, 29 Jul 2026 14:27:06 +0200 Subject: [PATCH 1/2] Add property_recorder post-processor: buffer graph properties, write CSV / time-series VTK (Refs #18) --- include/numsim-materials/io/record_buffer.h | 143 ++++++++++++++++ .../postprocessing/property_recorder.h | 155 ++++++++++++++++++ tests/CMakeLists.txt | 1 + tests/test_property_recorder.cpp | 141 ++++++++++++++++ 4 files changed, 440 insertions(+) create mode 100644 include/numsim-materials/io/record_buffer.h create mode 100644 include/numsim-materials/postprocessing/property_recorder.h create mode 100644 tests/test_property_recorder.cpp diff --git a/include/numsim-materials/io/record_buffer.h b/include/numsim-materials/io/record_buffer.h new file mode 100644 index 0000000..2a1f963 --- /dev/null +++ b/include/numsim-materials/io/record_buffer.h @@ -0,0 +1,143 @@ +#ifndef NUMSIM_MATERIALS_RECORD_BUFFER_H +#define NUMSIM_MATERIALS_RECORD_BUFFER_H + +#include +#include +#include +#include + +namespace numsim::materials { + +/// Format-agnostic, column-major record of scalar values captured over a run. +/// +/// A post-processor (see postprocessing/property_recorder.h) declares one +/// column per recorded scalar component up front, then appends one row per +/// step. Column-major storage means each column IS a contiguous array — the +/// natural shape for a CSV column or a VTK DataArray, so writers consume it +/// with no repacking. +/// +/// This type deliberately knows nothing about tmech / properties / the graph: +/// tensor sources are flattened to component columns (e.g. `stress_00`, +/// `stress_01`, …) by the recorder before they reach the buffer. +class record_buffer { +public: + /// Declare a column. All columns must be declared before the first row. + void declare_column(std::string name) { + m_names.push_back(std::move(name)); + m_columns.emplace_back(); + } + + /// Append one value to column `c`. Call once per column per row, in order. + void push(std::size_t c, double value) { m_columns[c].push_back(value); } + + [[nodiscard]] std::size_t cols() const noexcept { return m_names.size(); } + [[nodiscard]] std::size_t rows() const noexcept { + return m_columns.empty() ? 0 : m_columns.front().size(); + } + [[nodiscard]] std::string const& name(std::size_t c) const { + return m_names[c]; + } + /// The whole column `c` (size == rows()). + [[nodiscard]] std::vector const& column(std::size_t c) const { + return m_columns[c]; + } + [[nodiscard]] double at(std::size_t row, std::size_t col) const { + return m_columns[col][row]; + } + +private: + std::vector m_names; + std::vector> m_columns; // m_columns[col][row] +}; + +/// Pluggable serialization backend for a record_buffer — the file-output analog +/// of `postprocessing/plot_backend`. Concrete writers (CSV, VTK, …) serialize +/// the same buffer to different formats; the recorder is written once against +/// this interface. +class output_writer { +public: + virtual ~output_writer() = default; + virtual void write(record_buffer const& buffer, std::ostream& os) const = 0; +}; + +/// Comma-separated values: a header row of column names, then one row per step. +/// Row index is not emitted as a column — add an explicit source for it if +/// wanted. Full double precision so a round-trip is lossless. +class csv_writer final : public output_writer { +public: + void write(record_buffer const& buffer, std::ostream& os) const override { + auto const cols = buffer.cols(); + for (std::size_t c = 0; c < cols; ++c) { + if (c) os << ','; + os << buffer.name(c); + } + os << '\n'; + auto const rows = buffer.rows(); + for (std::size_t r = 0; r < rows; ++r) { + for (std::size_t c = 0; c < cols; ++c) { + if (c) os << ','; + write_double(os, buffer.at(r, c)); + } + os << '\n'; + } + } + +private: + static void write_double(std::ostream& os, double v) { + // 17 significant digits round-trips an IEEE-754 double exactly. + auto const prec = os.precision(); + os.precision(17); + os << v; + os.precision(prec); + } +}; + +/// Legacy-ASCII VTK PolyData time series: N points laid out along the x-axis at +/// (step, 0, 0), with every recorded column attached as a scalar POINT_DATA +/// array. Opens in ParaView as a poly-line whose point-data are the recorded +/// histories — the natural view for a single-material-point driver. (Spatial / +/// mesh-based VTK, where points come from an external mesh, is a later path.) +class vtk_timeseries_writer final : public output_writer { +public: + void write(record_buffer const& buffer, std::ostream& os) const override { + auto const rows = buffer.rows(); + auto const cols = buffer.cols(); + + os << "# vtk DataFile Version 3.0\n"; + os << "numsim-materials property_recorder time series\n"; + os << "ASCII\n"; + os << "DATASET POLYDATA\n"; + os << "POINTS " << rows << " double\n"; + for (std::size_t r = 0; r < rows; ++r) { + write_double(os, static_cast(r)); + os << " 0 0\n"; + } + // One line cell threading the points, so ParaView draws a curve. + if (rows > 0) { + os << "LINES 1 " << (rows + 1) << "\n" << rows; + for (std::size_t r = 0; r < rows; ++r) os << ' ' << r; + os << '\n'; + } + os << "POINT_DATA " << rows << "\n"; + for (std::size_t c = 0; c < cols; ++c) { + os << "SCALARS " << buffer.name(c) << " double 1\n"; + os << "LOOKUP_TABLE default\n"; + for (std::size_t r = 0; r < rows; ++r) { + write_double(os, buffer.at(r, c)); + os << '\n'; + } + } + } + +private: + static void write_double(std::ostream& os, double v) { + auto const prec = os.precision(); + os.precision(17); + os << v; + os.precision(prec); + } +}; + +} // namespace numsim::materials + +#endif // NUMSIM_MATERIALS_RECORD_BUFFER_H diff --git a/include/numsim-materials/postprocessing/property_recorder.h b/include/numsim-materials/postprocessing/property_recorder.h new file mode 100644 index 0000000..4d8b3bb --- /dev/null +++ b/include/numsim-materials/postprocessing/property_recorder.h @@ -0,0 +1,155 @@ +#ifndef NUMSIM_MATERIALS_PROPERTY_RECORDER_H +#define NUMSIM_MATERIALS_PROPERTY_RECORDER_H + +#include +#include +#include +#include + +#include + +#include "numsim-materials/core/material_base.h" +#include "numsim-materials/io/record_buffer.h" + +namespace numsim::materials { + +/// Post-processor material that BUFFERS consumed properties to memory over a run +/// and serializes them to a file. The recording analog of `property_plot`: same +/// wiring (named `"material::property"` sources, a dummy output so the engine +/// calls `update()` each step, one `add_input` per source), but instead of +/// pushing to a live plot it accumulates a `record_buffer` the caller writes out +/// with any `output_writer` (CSV, VTK, …) after the run. +/// +/// Parameters: +/// "name": material name +/// "scalar_sources": list of "material::property" scalar sources (optional) +/// "tensor_sources": list of "material::property" rank-2 tensor sources +/// (optional) — flattened to Dim*Dim component columns +/// `_ij` (full storage; symmetric-only reduction is a +/// later option). +/// +/// Usage: +/// param.insert("name", "recorder"); +/// param.insert>("scalar_sources", {"solver::dgamma"}); +/// param.insert>("tensor_sources", {"J2::stress"}); +/// auto& rec = ctx.create>(param); +/// ctx.finalize(); +/// for (...) ctx.update(); // one row buffered per step +/// rec.write(csv_writer{}, "history.csv"); // or vtk_timeseries_writer{} +template +class property_recorder final + : public material_base, Traits> { +public: + using base = material_base, Traits>; + using value_type = typename base::value_type; + using input_parameter_controller = typename base::input_parameter_controller; + static constexpr std::size_t Dim = base::Dim; + using tensor2 = tmech::tensor; + + template + property_recorder(Args&&... args) + : base(std::forward(args)...), + m_scalar_source_strs(base::template get_parameter>( + "scalar_sources")), + m_tensor_source_strs(base::template get_parameter>( + "tensor_sources")) { + if (m_scalar_source_strs.empty() && m_tensor_source_strs.empty()) + throw std::runtime_error( + "property_recorder '" + base::name() + + "': at least one of scalar_sources / tensor_sources must be set."); + + // Drive update() each step via a dummy output (as property_plot does). + base::template add_output("_record_tick", &property_recorder::update); + + // Scalar sources → one column each. + for (auto const& s : m_scalar_source_strs) { + auto src = connection_source::parse(s); + m_scalar_inputs.push_back( + &base::template add_input(src.material, src.property, + EdgeKind::Global)); + m_buffer.declare_column(sanitize(s)); + } + // Tensor sources → Dim*Dim component columns `_ij`. + for (auto const& s : m_tensor_source_strs) { + auto src = connection_source::parse(s); + m_tensor_inputs.push_back( + &base::template add_input(src.material, src.property, + EdgeKind::Global)); + auto const stem = sanitize(s); + for (std::size_t i = 0; i < Dim; ++i) + for (std::size_t j = 0; j < Dim; ++j) + m_buffer.declare_column(stem + "_" + std::to_string(i) + + std::to_string(j)); + } + } + + static input_parameter_controller parameters() { + input_parameter_controller para{base::parameters()}; + para.template insert>("scalar_sources") + .template add(std::vector{}); + para.template insert>("tensor_sources") + .template add(std::vector{}); + return para; + } + + /// Append one row: the current value of every source. Called by the engine. + void update() override { + std::size_t c = 0; + for (auto const* in : m_scalar_inputs) + m_buffer.push(c++, static_cast(in->get())); + for (auto const* in : m_tensor_inputs) { + auto const t = in->get(); + for (std::size_t i = 0; i < Dim; ++i) + for (std::size_t j = 0; j < Dim; ++j) + m_buffer.push(c++, static_cast(t(i, j))); + } + } + + /// The accumulated data (rows == number of update() calls). + [[nodiscard]] record_buffer const& buffer() const noexcept { return m_buffer; } + + /// Serialize the buffer to `path` with the given writer. + void write(output_writer const& writer, std::string const& path) const { + std::ofstream os(path); + if (!os) + throw std::runtime_error("property_recorder '" + base::name() + + "': cannot open '" + path + "' for writing."); + writer.write(m_buffer, os); + } + +private: + /// A "material::property" source is not a valid CSV/VTK array name; map each + /// run of non-alphanumeric characters to a single '_' (so `mat::prop` becomes + /// `mat_prop`, not `mat__prop`) and trim a leading/trailing '_'. + static std::string sanitize(std::string const& s) { + auto is_alnum = [](char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9'); + }; + std::string out; + out.reserve(s.size()); + bool prev_us = false; + for (char ch : s) { + if (is_alnum(ch)) { + out.push_back(ch); + prev_us = false; + } else if (!prev_us && !out.empty()) { + out.push_back('_'); + prev_us = true; + } + } + while (!out.empty() && out.back() == '_') + out.pop_back(); + return out; + } + + std::vector const& m_scalar_source_strs; + std::vector const& m_tensor_source_strs; + std::vector*> m_scalar_inputs; + std::vector*> m_tensor_inputs; + record_buffer m_buffer; +}; + +} // namespace numsim::materials + +#endif // NUMSIM_MATERIALS_PROPERTY_RECORDER_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b2ec874..38b53f1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -10,3 +10,4 @@ endmacro() add_numsim_test(test_property_graph test_property_graph.cpp) add_numsim_test(test_materials test_materials.cpp) add_numsim_test(test_damage test_damage.cpp) +add_numsim_test(test_property_recorder test_property_recorder.cpp) diff --git a/tests/test_property_recorder.cpp b/tests/test_property_recorder.cpp new file mode 100644 index 0000000..e8146f0 --- /dev/null +++ b/tests/test_property_recorder.cpp @@ -0,0 +1,141 @@ +#include + +#include +#include +#include + +#include + +#include "numsim-materials/core/material_context.h" +#include "numsim-materials/materials/scalar_stepper.h" +#include "numsim-materials/materials/tensor_component_stepper.h" +#include "numsim-materials/postprocessing/property_recorder.h" +#include "numsim-materials/default_materials.h" + +namespace { + +using policy = numsim::materials::material_policy_default; +using T = policy::value_type; +using ctx_type = numsim::materials::material_context; +using param_type = policy::ParameterHandler; +using tensor2 = tmech::tensor; +using numsim::materials::csv_writer; +using numsim::materials::property_recorder; +using numsim::materials::vtk_timeseries_writer; + +// Build: scalar_stepper (sca::state) + tensor_component_stepper (ten::strain) +// feeding a property_recorder, run `steps` updates, and capture the per-step +// expected values by reading the graph after each update. +struct Fixture { + ctx_type ctx; + property_recorder* rec = nullptr; + std::vector exp_scalar; + std::vector exp_s00; // strain(0,0) + + explicit Fixture(int steps) { + param_type p; + p.insert("name", "sca"); + p.insert("increment", T{0.5}); + ctx.create>(p); + + p.clear(); + p.insert("name", "ten"); + p.insert("increment", T{0.1}); + p.insert>("indices", {0, 0}); + ctx.create>(p); + + p.clear(); + p.insert("name", "rec"); + p.insert>("scalar_sources", {"sca::state"}); + p.insert>("tensor_sources", {"ten::strain"}); + // ctx.create returns a reference to the created material (see property_plot). + rec = &ctx.create>(p); + + ctx.finalize(); + for (int i = 0; i < steps; ++i) { + ctx.update(); + exp_scalar.push_back(ctx.get("sca", "state")); + exp_s00.push_back(ctx.get("ten", "strain")(0, 0)); + } + } + + property_recorder& recorder() { return *rec; } +}; + +TEST(PropertyRecorder, BuffersOneRowPerUpdateWithCorrectValues) { + Fixture f(3); + auto const& buf = f.recorder().buffer(); + + EXPECT_EQ(buf.rows(), 3u); + // 1 scalar column + 9 tensor component columns. + EXPECT_EQ(buf.cols(), 10u); + EXPECT_EQ(buf.name(0), "sca_state"); + EXPECT_EQ(buf.name(1), "ten_strain_00"); + EXPECT_EQ(buf.name(9), "ten_strain_22"); + + for (std::size_t r = 0; r < 3; ++r) { + EXPECT_NEAR(buf.at(r, 0), f.exp_scalar[r], 1e-12) << "row " << r; + // strain(0,0) is component (i=0,j=0) → the first tensor column, index 1. + EXPECT_NEAR(buf.at(r, 1), f.exp_s00[r], 1e-12) << "row " << r; + } +} + +TEST(PropertyRecorder, RejectsRecipeWithNoSources) { + ctx_type ctx; + param_type p; + p.insert("name", "rec"); + // both source lists default to empty + EXPECT_THROW(ctx.create>(p), std::runtime_error); +} + +TEST(PropertyRecorder, CsvWriterRoundTrips) { + Fixture f(3); + std::ostringstream os; + csv_writer{}.write(f.recorder().buffer(), os); + auto const& buf = f.recorder().buffer(); + + std::istringstream is(os.str()); + std::string line; + + // Header: exactly the column names, comma-separated. + ASSERT_TRUE(std::getline(is, line)); + { + std::string expected; + for (std::size_t c = 0; c < buf.cols(); ++c) + expected += (c ? "," : "") + buf.name(c); + EXPECT_EQ(line, expected); + } + // Rows: parse back the doubles and compare exactly (17-digit round-trip). + for (std::size_t r = 0; r < buf.rows(); ++r) { + ASSERT_TRUE(std::getline(is, line)) << "missing row " << r; + std::istringstream ls(line); + std::string cell; + for (std::size_t c = 0; c < buf.cols(); ++c) { + ASSERT_TRUE(std::getline(ls, cell, ',')); + EXPECT_DOUBLE_EQ(std::stod(cell), buf.at(r, c)) << "cell " << r << "," << c; + } + } + EXPECT_FALSE(std::getline(is, line)) << "trailing content after last row"; +} + +TEST(PropertyRecorder, VtkWriterEmitsWellFormedPolyData) { + Fixture f(3); + std::ostringstream os; + vtk_timeseries_writer{}.write(f.recorder().buffer(), os); + auto const src = os.str(); + + EXPECT_NE(src.find("# vtk DataFile Version"), std::string::npos) << src; + EXPECT_NE(src.find("ASCII"), std::string::npos) << src; + EXPECT_NE(src.find("DATASET POLYDATA"), std::string::npos) << src; + // One VTK point per recorded row. + EXPECT_NE(src.find("POINTS 3 double"), std::string::npos) << src; + EXPECT_NE(src.find("POINT_DATA 3"), std::string::npos) << src; + // Every column becomes a named scalar array. + EXPECT_NE(src.find("SCALARS sca_state double 1"), std::string::npos) << src; + EXPECT_NE(src.find("SCALARS ten_strain_00 double 1"), std::string::npos) + << src; + EXPECT_NE(src.find("SCALARS ten_strain_22 double 1"), std::string::npos) + << src; +} + +} // namespace From a7ba6076a0da82834e945d27b288f5ea0e661919 Mon Sep 17 00:00:00 2001 From: petlenz Date: Wed, 29 Jul 2026 15:49:45 +0200 Subject: [PATCH 2/2] Address property_recorder review: locale-independent double formatting, reject colliding column names, harden buffer/IO (Refs #18) --- include/numsim-materials/io/record_buffer.h | 80 ++++++++++++------- .../postprocessing/property_recorder.h | 34 +++++--- tests/test_property_recorder.cpp | 30 +++++++ 3 files changed, 101 insertions(+), 43 deletions(-) diff --git a/include/numsim-materials/io/record_buffer.h b/include/numsim-materials/io/record_buffer.h index 2a1f963..58fdd5a 100644 --- a/include/numsim-materials/io/record_buffer.h +++ b/include/numsim-materials/io/record_buffer.h @@ -1,13 +1,30 @@ #ifndef NUMSIM_MATERIALS_RECORD_BUFFER_H #define NUMSIM_MATERIALS_RECORD_BUFFER_H +#include #include #include +#include #include #include namespace numsim::materials { +namespace detail { + +/// Serialize a double locale- and stream-state-independently: `std::to_chars` +/// emits the shortest representation that round-trips exactly (max_digits10), +/// always with a '.' decimal separator and never honoring a sticky +/// `std::fixed`/`std::scientific` or a non-"C" locale imbued on `os` — both of +/// which silently corrupt CSV/VTK when the raw `os << v` idiom is used. +inline void write_double(std::ostream& os, double v) { + char buf[64]; // ample: the longest shortest-round-trip double is ~24 chars + auto const res = std::to_chars(buf, buf + sizeof(buf), v); + os.write(buf, res.ptr - buf); // ec is never set for a 64-byte buffer +} + +} // namespace detail + /// Format-agnostic, column-major record of scalar values captured over a run. /// /// A post-processor (see postprocessing/property_recorder.h) declares one @@ -16,33 +33,46 @@ namespace numsim::materials { /// natural shape for a CSV column or a VTK DataArray, so writers consume it /// with no repacking. /// +/// Row/push contract: after all columns are declared, each row is added by +/// exactly one `push(c, ·)` per column `c`, in `0..cols()-1` order. The buffer +/// does not police cross-column balance beyond bounds-checking; keeping the +/// per-row push complete is the caller's responsibility (the recorder does). +/// /// This type deliberately knows nothing about tmech / properties / the graph: /// tensor sources are flattened to component columns (e.g. `stress_00`, /// `stress_01`, …) by the recorder before they reach the buffer. class record_buffer { public: /// Declare a column. All columns must be declared before the first row. + /// Rejects empty and duplicate names — they would produce nameless / shadowed + /// arrays in CSV/VTK output (a VTK reader keys point-data by name). void declare_column(std::string name) { + if (name.empty()) + throw std::runtime_error("record_buffer: empty column name."); + for (auto const& existing : m_names) + if (existing == name) + throw std::runtime_error("record_buffer: duplicate column name '" + + name + "'."); m_names.push_back(std::move(name)); m_columns.emplace_back(); } /// Append one value to column `c`. Call once per column per row, in order. - void push(std::size_t c, double value) { m_columns[c].push_back(value); } + void push(std::size_t c, double value) { m_columns.at(c).push_back(value); } [[nodiscard]] std::size_t cols() const noexcept { return m_names.size(); } [[nodiscard]] std::size_t rows() const noexcept { return m_columns.empty() ? 0 : m_columns.front().size(); } [[nodiscard]] std::string const& name(std::size_t c) const { - return m_names[c]; + return m_names.at(c); } /// The whole column `c` (size == rows()). [[nodiscard]] std::vector const& column(std::size_t c) const { - return m_columns[c]; + return m_columns.at(c); } [[nodiscard]] double at(std::size_t row, std::size_t col) const { - return m_columns[col][row]; + return m_columns.at(col).at(row); } private: @@ -62,7 +92,7 @@ class output_writer { /// Comma-separated values: a header row of column names, then one row per step. /// Row index is not emitted as a column — add an explicit source for it if -/// wanted. Full double precision so a round-trip is lossless. +/// wanted. Values are `std::to_chars`-formatted (lossless, locale-independent). class csv_writer final : public output_writer { public: void write(record_buffer const& buffer, std::ostream& os) const override { @@ -76,20 +106,11 @@ class csv_writer final : public output_writer { for (std::size_t r = 0; r < rows; ++r) { for (std::size_t c = 0; c < cols; ++c) { if (c) os << ','; - write_double(os, buffer.at(r, c)); + detail::write_double(os, buffer.at(r, c)); } os << '\n'; } } - -private: - static void write_double(std::ostream& os, double v) { - // 17 significant digits round-trips an IEEE-754 double exactly. - auto const prec = os.precision(); - os.precision(17); - os << v; - os.precision(prec); - } }; /// Legacy-ASCII VTK PolyData time series: N points laid out along the x-axis at @@ -97,6 +118,10 @@ class csv_writer final : public output_writer { /// array. Opens in ParaView as a poly-line whose point-data are the recorded /// histories — the natural view for a single-material-point driver. (Spatial / /// mesh-based VTK, where points come from an external mesh, is a later path.) +/// +/// An empty buffer (rows() == 0) emits a valid, point-free PolyData with no +/// LINES / POINT_DATA blocks (degenerate 0-point datasets otherwise trip strict +/// readers). class vtk_timeseries_writer final : public output_writer { public: void write(record_buffer const& buffer, std::ostream& os) const override { @@ -109,33 +134,26 @@ class vtk_timeseries_writer final : public output_writer { os << "DATASET POLYDATA\n"; os << "POINTS " << rows << " double\n"; for (std::size_t r = 0; r < rows; ++r) { - write_double(os, static_cast(r)); + detail::write_double(os, static_cast(r)); os << " 0 0\n"; } - // One line cell threading the points, so ParaView draws a curve. - if (rows > 0) { - os << "LINES 1 " << (rows + 1) << "\n" << rows; - for (std::size_t r = 0; r < rows; ++r) os << ' ' << r; - os << '\n'; - } + if (rows == 0) return; // valid empty PolyData — no LINES / POINT_DATA + + // One poly-line threading the points, so ParaView draws a curve. + os << "LINES 1 " << (rows + 1) << "\n" << rows; + for (std::size_t r = 0; r < rows; ++r) os << ' ' << r; + os << '\n'; + os << "POINT_DATA " << rows << "\n"; for (std::size_t c = 0; c < cols; ++c) { os << "SCALARS " << buffer.name(c) << " double 1\n"; os << "LOOKUP_TABLE default\n"; for (std::size_t r = 0; r < rows; ++r) { - write_double(os, buffer.at(r, c)); + detail::write_double(os, buffer.at(r, c)); os << '\n'; } } } - -private: - static void write_double(std::ostream& os, double v) { - auto const prec = os.precision(); - os.precision(17); - os << v; - os.precision(prec); - } }; } // namespace numsim::materials diff --git a/include/numsim-materials/postprocessing/property_recorder.h b/include/numsim-materials/postprocessing/property_recorder.h index 4d8b3bb..c548f9d 100644 --- a/include/numsim-materials/postprocessing/property_recorder.h +++ b/include/numsim-materials/postprocessing/property_recorder.h @@ -1,9 +1,11 @@ #ifndef NUMSIM_MATERIALS_PROPERTY_RECORDER_H #define NUMSIM_MATERIALS_PROPERTY_RECORDER_H +#include #include #include #include +#include #include #include @@ -47,22 +49,28 @@ class property_recorder final using tensor2 = tmech::tensor; template - property_recorder(Args&&... args) - : base(std::forward(args)...), - m_scalar_source_strs(base::template get_parameter>( - "scalar_sources")), - m_tensor_source_strs(base::template get_parameter>( - "tensor_sources")) { - if (m_scalar_source_strs.empty() && m_tensor_source_strs.empty()) + property_recorder(Args&&... args) : base(std::forward(args)...) { + // The source lists are only needed to build the inputs + columns here, so + // bind them as ctor locals (they live in the material's own copied + // parameter_handler; keeping long-lived reference members buys nothing). + auto const& scalar_srcs = + base::template get_parameter>("scalar_sources"); + auto const& tensor_srcs = + base::template get_parameter>("tensor_sources"); + + if (scalar_srcs.empty() && tensor_srcs.empty()) throw std::runtime_error( "property_recorder '" + base::name() + "': at least one of scalar_sources / tensor_sources must be set."); - // Drive update() each step via a dummy output (as property_plot does). + // Drive update() each step via a dummy output. The property engine evaluates + // EVERY property in topological order (no consumed-only / dead-code pruning), + // so this consumer-less output's callback fires once per ctx.update() — the + // invariant the whole recorder (and property_plot) relies on. base::template add_output("_record_tick", &property_recorder::update); // Scalar sources → one column each. - for (auto const& s : m_scalar_source_strs) { + for (auto const& s : scalar_srcs) { auto src = connection_source::parse(s); m_scalar_inputs.push_back( &base::template add_input(src.material, src.property, @@ -70,7 +78,7 @@ class property_recorder final m_buffer.declare_column(sanitize(s)); } // Tensor sources → Dim*Dim component columns `_ij`. - for (auto const& s : m_tensor_source_strs) { + for (auto const& s : tensor_srcs) { auto src = connection_source::parse(s); m_tensor_inputs.push_back( &base::template add_input(src.material, src.property, @@ -115,6 +123,10 @@ class property_recorder final throw std::runtime_error("property_recorder '" + base::name() + "': cannot open '" + path + "' for writing."); writer.write(m_buffer, os); + os.flush(); + if (!os) // disk-full / write error would otherwise leave a silent truncation + throw std::runtime_error("property_recorder '" + base::name() + + "': write to '" + path + "' failed."); } private: @@ -143,8 +155,6 @@ class property_recorder final return out; } - std::vector const& m_scalar_source_strs; - std::vector const& m_tensor_source_strs; std::vector*> m_scalar_inputs; std::vector*> m_tensor_inputs; record_buffer m_buffer; diff --git a/tests/test_property_recorder.cpp b/tests/test_property_recorder.cpp index e8146f0..9c2aebb 100644 --- a/tests/test_property_recorder.cpp +++ b/tests/test_property_recorder.cpp @@ -1,5 +1,6 @@ #include +#include #include #include #include @@ -118,6 +119,35 @@ TEST(PropertyRecorder, CsvWriterRoundTrips) { EXPECT_FALSE(std::getline(is, line)) << "trailing content after last row"; } +// A source string with no alphanumerics, or two sources that sanitize to the +// same column name, must be rejected — otherwise CSV emits nameless/ambiguous +// columns and VTK emits shadowed (silently overwritten) point-data arrays. +TEST(PropertyRecorder, RejectsCollidingColumnNames) { + ctx_type ctx; + param_type p; + p.insert("name", "rec"); + // Two sources that map to the same sanitized column name ("a_b"). + p.insert>("scalar_sources", {"a::b", "a::b"}); + EXPECT_THROW(ctx.create>(p), std::runtime_error); +} + +// H1/M1: number formatting must be independent of the stream's locale AND its +// sticky float flags. to_chars ignores both; the old `os << v` honored both and +// silently corrupted CSV/VTK (comma decimal separator / fixed-notation clipping). +TEST(PropertyRecorder, DoubleFormattingIgnoresLocaleAndFlags) { + struct comma_numpunct : std::numpunct { + char do_decimal_point() const override { return ','; } + }; + std::ostringstream os; + os.imbue(std::locale(os.getloc(), new comma_numpunct)); + os << std::fixed; // sticky flag that would clip small magnitudes with << + numsim::materials::detail::write_double(os, 3.5); + os << '|'; + numsim::materials::detail::write_double(os, 1e-20); + // '.' decimal separator (not ','), and shortest round-trip (not fixed-clipped). + EXPECT_EQ(os.str(), "3.5|1e-20"); +} + TEST(PropertyRecorder, VtkWriterEmitsWellFormedPolyData) { Fixture f(3); std::ostringstream os;