Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions include/numsim-materials/io/record_buffer.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
#ifndef NUMSIM_MATERIALS_RECORD_BUFFER_H
#define NUMSIM_MATERIALS_RECORD_BUFFER_H

#include <charconv>
#include <cstddef>
#include <ostream>
#include <stdexcept>
#include <string>
#include <vector>

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
/// 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.
///
/// 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.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.at(c);
}
/// The whole column `c` (size == rows()).
[[nodiscard]] std::vector<double> const& column(std::size_t c) const {
return m_columns.at(c);
}
[[nodiscard]] double at(std::size_t row, std::size_t col) const {
return m_columns.at(col).at(row);
}

private:
std::vector<std::string> m_names;
std::vector<std::vector<double>> 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. 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 {
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 << ',';
detail::write_double(os, buffer.at(r, c));
}
os << '\n';
}
}
};

/// 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.)
///
/// 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 {
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) {
detail::write_double(os, static_cast<double>(r));
os << " 0 0\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) {
detail::write_double(os, buffer.at(r, c));
os << '\n';
}
}
}
};

} // namespace numsim::materials

#endif // NUMSIM_MATERIALS_RECORD_BUFFER_H
165 changes: 165 additions & 0 deletions include/numsim-materials/postprocessing/property_recorder.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
#ifndef NUMSIM_MATERIALS_PROPERTY_RECORDER_H
#define NUMSIM_MATERIALS_PROPERTY_RECORDER_H

#include <cstddef>
#include <fstream>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>

#include <tmech/tmech.h>

#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
/// `<name>_ij` (full storage; symmetric-only reduction is a
/// later option).
///
/// Usage:
/// param.insert<std::string>("name", "recorder");
/// param.insert<std::vector<std::string>>("scalar_sources", {"solver::dgamma"});
/// param.insert<std::vector<std::string>>("tensor_sources", {"J2::stress"});
/// auto& rec = ctx.create<property_recorder<P>>(param);
/// ctx.finalize();
/// for (...) ctx.update(); // one row buffered per step
/// rec.write(csv_writer{}, "history.csv"); // or vtk_timeseries_writer{}
template <typename Traits>
class property_recorder final
: public material_base<property_recorder<Traits>, Traits> {
public:
using base = material_base<property_recorder<Traits>, 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<value_type, Dim, 2>;

template <typename... Args>
property_recorder(Args&&... args) : base(std::forward<Args>(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<std::vector<std::string>>("scalar_sources");
auto const& tensor_srcs =
base::template get_parameter<std::vector<std::string>>("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. 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<int>("_record_tick", &property_recorder::update);

// Scalar sources → one column each.
for (auto const& s : scalar_srcs) {
auto src = connection_source::parse(s);
m_scalar_inputs.push_back(
&base::template add_input<value_type>(src.material, src.property,
EdgeKind::Global));
m_buffer.declare_column(sanitize(s));
}
// Tensor sources → Dim*Dim component columns `<name>_ij`.
for (auto const& s : tensor_srcs) {
auto src = connection_source::parse(s);
m_tensor_inputs.push_back(
&base::template add_input<tensor2>(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<std::vector<std::string>>("scalar_sources")
.template add<set_default>(std::vector<std::string>{});
para.template insert<std::vector<std::string>>("tensor_sources")
.template add<set_default>(std::vector<std::string>{});
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<double>(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<double>(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);
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:
/// 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 input_property<value_type, property_traits>*> m_scalar_inputs;
std::vector<const input_property<tensor2, property_traits>*> m_tensor_inputs;
record_buffer m_buffer;
};

} // namespace numsim::materials

#endif // NUMSIM_MATERIALS_PROPERTY_RECORDER_H
1 change: 1 addition & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ 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)
add_numsim_test(test_j2_plasticity test_j2_plasticity.cpp)
add_numsim_test(test_rk_integrator test_rk_integrator.cpp)
add_numsim_test(test_drucker_prager test_drucker_prager.cpp)
Expand Down
Loading
Loading