working on todo.md features

This commit is contained in:
Martino Ferrari committed 2026-10-10 22:52:12 +02:00
1 parent 210d124814
commit 8f54841c2c
110 files changed
+15932 -6576

No files matched your search

+329
View File
@@ -0,0 +1,329 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "event_store.hpp"
#include <dirent.h>
#include <sys/stat.h>
#include <unistd.h>
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <ctime>
#include "layout_store.hpp"
namespace udpscope {
namespace {
constexpr const char* kEventsSubdir = "/events";
void mkdirs(const std::string& path) {
std::string cur;
for (std::size_t i = 0; i < path.size(); ++i) {
cur.push_back(path[i]);
if (path[i] != '/' && i + 1 != path.size()) continue;
::mkdir(cur.c_str(), 0755);
}
}
/// `2026-10-09T11:32:00` in local time, plus the sequence number: two events inside one second are
/// ordinary (a 5 Hz trigger latches five times a second), and a stem that collides would overwrite
/// the event it collided with.
std::string event_stem(double wall_time, std::uint64_t seq) {
const std::time_t t = static_cast<std::time_t>(wall_time);
std::tm tm{};
localtime_r(&t, &tm);
char buf[64];
std::strftime(buf, sizeof(buf), "%Y%m%d-%H%M%S", &tm);
char stem[96];
std::snprintf(stem, sizeof(stem), "%s-%04llu", buf,
static_cast<unsigned long long>(seq % 10000ull));
return stem;
}
/// `a.npy` -> `a`; anything else -> "".
std::string stem_of(const std::string& file, const char* ext) {
const std::size_t n = std::strlen(ext);
if (file.size() <= n || file.compare(file.size() - n, n, ext) != 0) return {};
return file.substr(0, file.size() - n);
}
/// The event's picture, drawn from its samples: one horizontal strip per signal, min/max per pixel
/// column, the trigger edge as a brighter line. A 24-bit BMP -- the header is nine words and the rest
/// is the pixels, so it needs no encoder, and a person can open it with anything.
///
/// The palette is fixed rather than the plots': this is navigation, and what has to be told apart at
/// a glance is one *event* from another, not one signal from another (for which the names are on the
/// row above).
bool write_preview(const ExportTable& table, const std::string& path, int width = 480, int height = 160) {
if (table.empty() || table.columns.empty()) return false;
const std::size_t rows = table.rows();
const std::size_t strips = table.columns.size();
const int strip_h = std::max(6, height / static_cast<int>(strips));
const int total_h = strip_h * static_cast<int>(strips);
static const std::uint32_t kPalette[] = {0x4FA3FFu, 0xFF7A4Fu, 0x6EE07Au, 0xE06ED0u,
0xFFD24Fu, 0x6FD8E0u, 0xB9A0FFu, 0xE0E0E0u};
// One byte per pixel per channel, bottom-up (BMP's own order), padded to a 4-byte row.
const int row_bytes = ((width * 3) + 3) & ~3;
std::vector<unsigned char> pixels(std::size_t(row_bytes) * total_h, 0);
const auto put = [&](int x, int y, std::uint32_t rgb, double alpha) {
if (x < 0 || y < 0 || x >= width || y >= total_h) return;
unsigned char* p = &pixels[std::size_t(total_h - 1 - y) * row_bytes + std::size_t(x) * 3];
const double a = std::clamp(alpha, 0.0, 1.0);
p[0] = static_cast<unsigned char>(p[0] * (1.0 - a) + ((rgb >> 16) & 0xFF) * a);
p[1] = static_cast<unsigned char>(p[1] * (1.0 - a) + ((rgb >> 8) & 0xFF) * a);
p[2] = static_cast<unsigned char>(p[2] * (1.0 - a) + (rgb & 0xFF) * a);
};
// The background: a dark field, so the traces read the way they do on screen.
for (int y = 0; y < total_h; ++y) {
for (int x = 0; x < width; ++x) put(x, y, 0x101418u, 1.0);
}
for (std::size_t c = 0; c < strips; ++c) {
const std::vector<f32>& values = table.columns[c].values;
if (values.empty()) continue;
double lo = values[0];
double hi = values[0];
for (f32 v : values) {
if (!std::isfinite(v)) continue;
lo = std::min(lo, double(v));
hi = std::max(hi, double(v));
}
if (!(hi > lo)) {
hi = lo + 1.0;
}
const int base = static_cast<int>(c) * strip_h;
const std::uint32_t colour = kPalette[c % (sizeof(kPalette) / sizeof(kPalette[0]))];
for (int x = 0; x < width; ++x) {
const std::size_t from = rows * std::size_t(x) / std::size_t(width);
const std::size_t to = std::max(from + 1, rows * std::size_t(x + 1) / std::size_t(width));
double vmin = 1e300;
double vmax = -1e300;
for (std::size_t i = from; i < to && i < rows; ++i) {
const double v = values[i];
if (!std::isfinite(v)) continue;
vmin = std::min(vmin, v);
vmax = std::max(vmax, v);
}
if (vmin > vmax) continue;
const auto to_y = [&](double v) {
const double frac = (v - lo) / (hi - lo);
return base + strip_h - 1 - static_cast<int>(frac * (strip_h - 1));
};
const int y0 = std::clamp(to_y(vmax), base, base + strip_h - 1);
const int y1 = std::clamp(to_y(vmin), base, base + strip_h - 1);
for (int y = y0; y <= y1; ++y) put(x, y, colour, 1.0);
}
// The strip's own baseline, so an empty stretch reads as a flat trace and not as a gap.
for (int x = 0; x < width; ++x) {
const int y = base + strip_h - 1;
put(x, y, 0x303840u, 1.0);
}
}
// The trigger edge: the snapshot's x = 0, which is at the same fraction of every event's window.
if (!table.time.empty()) {
const double t0 = table.time.front();
const double t1 = table.time.back();
if (t1 > t0) {
const int x = static_cast<int>((0.0 - t0) / (t1 - t0) * (width - 1));
for (int y = 0; y < total_h; ++y) put(x, y, 0xFFE9A0u, 0.85);
}
}
std::FILE* f = std::fopen(path.c_str(), "wb");
if (f == nullptr) return false;
const std::uint32_t image_bytes = std::uint32_t(row_bytes) * std::uint32_t(total_h);
const std::uint32_t file_bytes = 54u + image_bytes;
unsigned char header[54] = {};
header[0] = 'B';
header[1] = 'M';
const auto put32 = [&](int at, std::uint32_t v) {
header[at] = static_cast<unsigned char>(v & 0xFF);
header[at + 1] = static_cast<unsigned char>((v >> 8) & 0xFF);
header[at + 2] = static_cast<unsigned char>((v >> 16) & 0xFF);
header[at + 3] = static_cast<unsigned char>((v >> 24) & 0xFF);
};
put32(2, file_bytes);
put32(10, 54u);
put32(14, 40u);
put32(18, std::uint32_t(width));
put32(22, std::uint32_t(total_h));
header[26] = 1; // planes
header[28] = 24; // bits per pixel
put32(34, image_bytes);
put32(38, 2835u); // 72 dpi, in pixels per metre
put32(42, 2835u);
std::fwrite(header, 1, sizeof(header), f);
std::fwrite(pixels.data(), 1, pixels.size(), f);
std::fclose(f);
return true;
}
} // namespace
std::string event_meta_text(const EventRecord& r) {
char buf[512];
std::string out;
std::snprintf(buf, sizeof(buf), "wall_time = %.6f\n", r.wall_time);
out += buf;
std::snprintf(buf, sizeof(buf), "duration_s = %.6f\n", r.duration_s);
out += buf;
std::snprintf(buf, sizeof(buf), "pre_s = %.6f\n", r.pre_s);
out += buf;
std::snprintf(buf, sizeof(buf), "post_s = %.6f\n", r.post_s);
out += buf;
std::snprintf(buf, sizeof(buf), "edge = %.6f\n", r.edge);
out += buf;
std::snprintf(buf, sizeof(buf), "rate_hz = %.6g\n", r.rate_hz);
out += buf;
std::snprintf(buf, sizeof(buf), "signals = %zu\n", r.signals);
out += buf;
std::snprintf(buf, sizeof(buf), "samples = %zu\n", r.samples);
out += buf;
std::snprintf(buf, sizeof(buf), "seq = %llu\n", static_cast<unsigned long long>(r.seq));
out += buf;
return out;
}
bool event_meta_parse(const std::string& text, EventRecord* out) {
if (out == nullptr) return false;
bool any = false;
std::size_t i = 0;
while (i < text.size()) {
const std::size_t end = text.find('\n', i);
const std::string line = text.substr(i, end == std::string::npos ? std::string::npos : end - i);
i = end == std::string::npos ? text.size() : end + 1;
const std::size_t eq = line.find('=');
if (eq == std::string::npos) continue;
std::string key = line.substr(0, eq);
std::string value = line.substr(eq + 1);
while (!key.empty() && (key.back() == ' ' || key.back() == '\t')) key.pop_back();
while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) value.erase(0, 1);
const double number = std::atof(value.c_str());
if (key == "wall_time") {
out->wall_time = number;
any = true;
} else if (key == "duration_s") {
out->duration_s = number;
any = true;
} else if (key == "pre_s") {
out->pre_s = number;
any = true;
} else if (key == "post_s") {
out->post_s = number;
any = true;
} else if (key == "edge") {
out->edge = number;
any = true;
} else if (key == "rate_hz") {
out->rate_hz = number;
any = true;
} else if (key == "signals") {
out->signals = static_cast<std::size_t>(std::atoll(value.c_str()));
any = true;
} else if (key == "samples") {
out->samples = static_cast<std::size_t>(std::atoll(value.c_str()));
any = true;
} else if (key == "seq") {
out->seq = static_cast<std::uint64_t>(std::strtoull(value.c_str(), nullptr, 10));
any = true;
}
}
return any;
}
EventStore::EventStore(std::string source) {
if (source.empty()) return;
std::string safe;
for (char c : source) {
const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
c == '.' || c == '_' || c == '-';
safe.push_back(ok ? c : '_');
}
dir_ = cache_dir() + "/" + safe + kEventsSubdir;
}
std::string EventStore::save(const ExportTable& table, const EventRecord& meta, std::size_t keep) {
if (dir_.empty() || table.empty()) return {};
mkdirs(dir_);
const std::string stem = event_stem(meta.wall_time > 0.0 ? meta.wall_time : double(std::time(nullptr)),
meta.seq);
const std::string samples = dir_ + "/" + stem + ".npy";
std::string error;
if (!write_npy(samples, table, error)) {
std::fprintf(stderr, "[udpscope] event cache: %s\n", error.c_str());
std::fflush(stderr);
return {};
}
EventRecord record = meta;
record.stem = stem;
std::string text = event_meta_text(record);
if (std::FILE* f = std::fopen((dir_ + "/" + stem + ".txt").c_str(), "wb"); f != nullptr) {
std::fwrite(text.data(), 1, text.size(), f);
std::fclose(f);
}
write_preview(table, dir_ + "/" + stem + ".bmp");
prune(keep);
return samples;
}
std::vector<EventRecord> EventStore::list() const {
std::vector<EventRecord> out;
if (dir_.empty()) return out;
DIR* d = ::opendir(dir_.c_str());
if (d == nullptr) return out;
while (const dirent* e = ::readdir(d)) {
const std::string stem = stem_of(e->d_name, ".txt");
if (stem.empty()) continue;
std::FILE* f = std::fopen((dir_ + "/" + e->d_name).c_str(), "rb");
if (f == nullptr) continue;
std::string text;
char buf[512];
std::size_t n = 0;
while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) text.append(buf, n);
std::fclose(f);
EventRecord r;
if (!event_meta_parse(text, &r)) continue;
r.stem = stem;
r.file = dir_ + "/" + stem + ".npy";
if (::access(r.file.c_str(), R_OK) != 0) continue; // a sidecar without its samples
struct stat sb {};
if (::stat((dir_ + "/" + stem + ".bmp").c_str(), &sb) == 0) {
r.thumbnail = dir_ + "/" + stem + ".bmp";
}
out.push_back(std::move(r));
}
::closedir(d);
// Newest first by the *record's* time, not the file's: a copy or a touch changes the mtime, and the
// list is about when the event happened.
std::stable_sort(out.begin(), out.end(), [](const EventRecord& a, const EventRecord& b) {
if (a.wall_time != b.wall_time) return a.wall_time > b.wall_time;
return a.seq > b.seq;
});
return out;
}
bool EventStore::forget(const std::string& stem) {
if (dir_.empty() || stem.empty()) return false;
const std::string base = dir_ + "/" + stem;
const bool gone = ::unlink((base + ".npy").c_str()) == 0;
::unlink((base + ".txt").c_str());
::unlink((base + ".bmp").c_str());
return gone;
}
void EventStore::prune(std::size_t keep) const {
const std::vector<EventRecord> all = list();
for (std::size_t i = keep; i < all.size(); ++i) {
const std::string base = dir_ + "/" + all[i].stem;
::unlink((base + ".npy").c_str());
::unlink((base + ".txt").c_str());
::unlink((base + ".bmp").c_str());
}
}
} // namespace udpscope