fix: a loaded recording is filtered into its buffer, not truncated

The loader pushed one slot per file row while the plan decimated by
eight, so a 10 M-row file overran the ring and only its last 4.19 s
survived -- a 20 s recording that read as a 4 s one. Each group of
`time_stride` rows is now averaged into one slot at the group's mean
stamp, as the packet path already does, and the load line reports
rows and decimation so a filtered file cannot be mistaken for a
truncated one.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-14 00:54:31 +02:00
1 parent 50a1ddca62
commit 345145c52b
7 files changed
+104 -11

No files matched your search

+46 -4
View File
@@ -8,6 +8,7 @@
#include <cmath>
#include <cstdlib>
#include <cstring>
#include <mutex>
#include <sys/stat.h>
#include "layout_store.hpp"
@@ -283,18 +284,57 @@ bool load_waveform_into_model(ScopeModel& model, const std::string& path,
if (error != nullptr) *error = "'" + path + "': the model built no channels for its columns";
return false;
}
// The plan the enables above built may **decimate**, and then the file has to be filtered into the
// ring exactly as the packet path filters the wire: one stored slot per `time_stride` rows,
// carrying their mean. A recording longer than a few seconds at a high rate does not fit a buffer
// sized in megabytes -- 20 s of 10 channels at 500 kS/s is 480 MB of rings -- so the planner
// decimates, and pushing every row regardless overruns the ring and leaves the file as *its own
// tail*: `stored=10000000` against a `published=2097152` ring read as "I loaded a 20 s file and
// only the last 4 s are there". Averaging and not striding, for the reason
// `invariants-data-path.md` gives: a stride aliases whatever is above the stored Nyquist.
std::uint64_t decim = 1;
{
std::lock_guard<std::mutex> lock(model.mutex());
if (model.partition().time_stride > 0) decim = model.partition().time_stride;
}
std::vector<float> values(reader.names().size(), 0.0f);
std::vector<float> mean(reader.names().size(), 0.0f);
std::vector<double> acc(reader.names().size(), 0.0);
std::size_t pushed = 0;
std::size_t rows_read = 0;
std::uint64_t in_group = 0;
double t_acc = 0.0;
double abs_acc = 0.0;
double stamp = 0.0;
double abs_stamp = 0.0;
double first = 0.0;
double last = 0.0;
while (reader.next(&stamp, &abs_stamp, values.data())) {
if (pushed == 0) first = stamp;
last = stamp;
model.push_loaded_slot(values, stamp, abs_stamp);
// The group's mean value at its mean stamp, which is where the ingest filter puts it too: a slot
// stamped at the group's *first* sample would shift the whole trace half a group early.
const auto flush_group = [&]() {
const double n = static_cast<double>(in_group);
for (std::size_t i = 0; i < acc.size(); ++i) {
mean[i] = static_cast<float>(acc[i] / n);
acc[i] = 0.0;
}
model.push_loaded_slot(mean, t_acc / n, abs_acc / n);
++pushed;
t_acc = 0.0;
abs_acc = 0.0;
in_group = 0;
};
while (reader.next(&stamp, &abs_stamp, values.data())) {
if (rows_read == 0) first = stamp;
last = stamp;
++rows_read;
for (std::size_t i = 0; i < acc.size(); ++i) acc[i] += values[i];
t_acc += stamp;
abs_acc += abs_stamp;
if (++in_group >= decim) flush_group();
}
// The last group need not be whole, and dropping it would shorten the record by up to one slot --
// which on a one-slot file (`decim` larger than the whole recording) is the entire file.
if (in_group > 0) flush_group();
reader.close();
model.set_epoch_if_unset(first);
@@ -305,6 +345,8 @@ bool load_waveform_into_model(ScopeModel& model, const std::string& path,
if (report != nullptr) {
report->signals = channels;
report->samples = pushed;
report->rows = rows_read;
report->decimation = decim;
report->span_s = pushed > 0 ? std::abs(last - first) : 0.0;
report->rate_hz = rate;
report->first_time = first;