working on todo.md features
This commit is contained in:
1 parent
210d124814
commit
8f54841c2c
110 files changed
+15932
-6576
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user