trigger: a phosphor display keeps the last N acquisitions; and a Force button
Two changes in the trigger pane, landed together. **Persistence.** Trigger mode keeps the last N published acquisitions and draws them fading toward the background, N being a panel control (1..8, default 1) with N=1 meaning today's behaviour exactly, so the knob is the switch. The layers are the accumulate path's own -- one layer per (signal, age) in the row's free trace slots, named with the accumulate vocabulary so `layer_base_name()` strips the suffix and `signal_color()` resolves the base signal -- so a layer is not a signal: the signal list, the colours and the measurements see one row per signal. `ScopeModel` retains the outgoing snapshot in seven atomic `shared_ptr` slots (no lock, no copy; the snapshot already exists) and a layer's channel is resolved **by name inside the snapshot**, because the channel list is renumbered by any structural change since that acquisition was cut. `RowSet::sync_layers()` re-plans from the rows and the depth alone, so a latch costs no rebuild, and `event_data_window()` skips layer slots so an older acquisition can never re-pin the axis. The schedule is 0.5^age -- the acquisition on screen at full brightness and each older one half the previous, independent of N, so raising the depth adds dimmer acquisitions rather than rescaling the ones already on screen. Memory is bounded by the ring's own `--buffer-mb` rather than by the depth, which is the bound that cannot be driven into an OOM, and the panel says when either bound bites. This also wired up `set_trace_alpha()`, which the accumulate path was setting and nothing was reading -- so the documented 0.35 translucency of an accumulation was opaque in fact. **Force.** In the trigger pane beside Arm/Disarm, shown only while trigger mode is on: one acquisition now, with the configured window. It is a synthetic edge rather than a second publisher -- the engine opens one capture at the present moment and everything else is the ordinary latch, so a forced acquisition is cut, pinned, framed, recorded and cached exactly like a latched one, and the button owns no window and no publish. The pre-trigger gate is deliberately skipped (capture now is a request for the history that exists, and the snapshot reports its own shorter pre_s), a capture already filling is refused rather than restarted, and the state afterwards is the mode's: Normal stays armed, Single disarms. The request is one atomic flag written under the model mutex with `armed` -- the producer reads `armed` at the top of a packet and the request at the bottom, so a request written between the two would be dropped -- and consumed at the end of a packet, after the channel loop and before the pending publish. **A defect fixed in passing, in the function Force was modelled on:** `on_arm_clicked()` passed `TriggerConfig::channel` (an index into `channels()`) to `set_signal_enabled()` (an index into `signals()`). With one signal acquired, arming switched on the stream's clock signal and left the watched signal untouched -- and since a signal the panel was not showing is a change, it rebuilt the buffer under the click, dropping the very pre-trigger window the arming, and now the Force, was about to capture. Measured before: `0.0100 s of history behind it`; after: `2.5299 s`. `trigger_signal_index()` is now the one resolution, used by both buttons.
This commit is contained in:
1 parent
fc4700d1c9
commit
e14e1ee069
22 files changed
+1652
-26
No files matched your search
@@ -1051,6 +1051,38 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
||||
}
|
||||
last_stamp_ = last_stamp_used;
|
||||
|
||||
// A Force pressed since the last packet: one acquisition, at the present moment, without waiting
|
||||
// for the condition to hold. Read *here* -- the end of the packet, past the channel loop -- for
|
||||
// the reason the latch itself is deferred out of that loop: `last_stamp_` is the newest stored
|
||||
// sample by now, so the capture opens on the moment the user pressed, and every channel has
|
||||
// pushed that sample, so the cut the latch goes on to make cannot clamp it away
|
||||
// (`publish_event()` clamps to each value ring's `produced()`).
|
||||
//
|
||||
// It opens the capture and nothing else. The latch below -- reached through `engine.process()`
|
||||
// by an edge, through `engine.force()` here -- is what cuts the window, bumps the latch count,
|
||||
// sets the trigger time and applies the mode's re-arm rule, so a forced acquisition is framed,
|
||||
// pinned and recorded exactly like one the condition produced. A capture already in flight is
|
||||
// not cancelled: it *is* the acquisition being asked for, and the engine refuses the request
|
||||
// rather than restarting it.
|
||||
if (force_want_.exchange(false, std::memory_order_acq_rel)) {
|
||||
if (engine.force(last_stamp_)) {
|
||||
std::fprintf(stderr,
|
||||
"[udpscope] trigger: forced acquisition, edge at %.3f s -- want "
|
||||
"[-%.4f..%.4f] s, %.4f s of history behind it, mode=%s\n",
|
||||
last_stamp_, event_span - post_want, post_want, last_stamp_ - oldest_s,
|
||||
tc.mode == TriggerMode::Single ? "single (disarms at the latch)" : "normal");
|
||||
std::fflush(stderr);
|
||||
} else {
|
||||
// Nothing to force. Every cause is a state the user can see from the panel -- a trigger that
|
||||
// is off, a signal that is not acquired, the engine disarmed, a capture already filling --
|
||||
// so it is named on stderr rather than swallowed.
|
||||
std::fprintf(stderr,
|
||||
"[udpscope] trigger: force ignored (enabled=%d armed=%d ch=%d pending=%d)\n",
|
||||
int(tc.enabled), int(engine.armed()), trig_channel, int(engine.pending()));
|
||||
std::fflush(stderr);
|
||||
}
|
||||
}
|
||||
|
||||
model_.trigger_armed().store(engine.armed(), std::memory_order_relaxed);
|
||||
// The edge goes out before the flag that makes the UI read it (release/acquire on
|
||||
// `trigger_pending`), so a tick that sees a capture open sees which edge it is for. A
|
||||
|
||||
@@ -48,6 +48,20 @@ class Acquisition {
|
||||
bool capture_want() const noexcept {
|
||||
return capture_want_.load(std::memory_order_acquire);
|
||||
}
|
||||
|
||||
/// Asks for one **forced trigger acquisition** -- the trigger panel's Force button. The producer
|
||||
/// consumes the request on its next packet and opens a capture at that packet's newest stamp,
|
||||
/// which the engine's ordinary latch then publishes with the configured pre/post window. The
|
||||
/// request is dropped rather than retried when there is nothing to force: trigger mode off, no
|
||||
/// channel resolved, the engine disarmed, or a capture already filling (a capture in flight *is*
|
||||
/// the acquisition being asked for).
|
||||
///
|
||||
/// **Write it under `ScopeModel::mutex()`, together with the `armed` flag the click also sets.**
|
||||
/// The producer holds that lock for a whole packet -- it reads the panel's `armed` at the top of
|
||||
/// one and consumes this request at the bottom -- so a request written between the two would be
|
||||
/// read by a packet whose engine was still disarmed, and then dropped.
|
||||
void request_force() noexcept { force_want_.store(true, std::memory_order_release); }
|
||||
|
||||
/// The capture, for the popover's row and the probe. Counters and errors are read
|
||||
/// cross-thread through PacketCapture's own published fields.
|
||||
const PacketCapture& capture() const noexcept { return capture_; }
|
||||
@@ -110,6 +124,9 @@ class Acquisition {
|
||||
std::atomic<bool> paused_{false};
|
||||
PacketCapture capture_;
|
||||
std::atomic<bool> capture_want_{false};
|
||||
/// The pending Force request (see `request_force()`): a flag and not a queue, because two clicks
|
||||
/// inside one packet are one acquisition, which is what a momentary button means.
|
||||
std::atomic<bool> force_want_{false};
|
||||
std::uint32_t capture_slots_ = 1000;
|
||||
/// Written once, by the GTK thread, before `capture_want_` is ever published true;
|
||||
/// the producer reads it only after the flag, so no lock is needed.
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace udpscope {
|
||||
@@ -63,8 +64,30 @@ struct EventSnapshot {
|
||||
/// already O(n), and the same channel on two rows shares it instead of building and
|
||||
/// storing it twice.
|
||||
std::vector<plot::data::MinMaxPyramid> pyramids;
|
||||
/// The channel *names* `values` is indexed by, so a snapshot can be re-bound after the channel
|
||||
/// list was rebuilt -- and renumbered -- under it: a persistence layer names its signal and
|
||||
/// resolves that name here, exactly as the trigger engine names its channel and for the same
|
||||
/// reason (an index is a position in a list that any enable, disable or CONFIG renumbers).
|
||||
/// Empty on a snapshot nobody re-binds later (a pause view).
|
||||
std::vector<std::string> channel_names;
|
||||
};
|
||||
|
||||
/// Bytes one snapshot holds: both stamp arrays, every channel's samples, the pyramids built for
|
||||
/// them and the names above. The number the persistence retention is measured in -- a retained
|
||||
/// snapshot is a whole window copied out of the ring, so what bounds it is bytes, not a count.
|
||||
inline std::size_t snapshot_bytes(const EventSnapshot& s) {
|
||||
std::size_t bytes = s.stamps.size() * sizeof(f64) + s.abs_stamps.size() * sizeof(f64) +
|
||||
s.y.size() * sizeof(plot::Range) +
|
||||
s.channel_names.size() * sizeof(std::string);
|
||||
for (const std::vector<f32>& v : s.values) bytes += v.size() * sizeof(f32);
|
||||
// A pyramid entry is 32 bytes (`MinMaxPyramid::Entry`), one per level-`l` bucket of
|
||||
// `kBucket << l` samples -- the whole index is about one byte per sample.
|
||||
for (const plot::data::MinMaxPyramid& p : s.pyramids) {
|
||||
for (u32 l = 0; l < p.level_count(); ++l) bytes += p.level_size(l) * 32u;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// A plot::data::Series view of one channel of an EventSnapshot. The stamps are
|
||||
/// near-uniform (the arrival clock), so dx is the mean spacing and x_at returns
|
||||
/// the exact stamp; gather decimates by 2^level like the ring series does.
|
||||
|
||||
@@ -516,6 +516,10 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
||||
num(t.noise_band);
|
||||
} else if (k == "hf") {
|
||||
num(t.hf_fc);
|
||||
} else if (k == "persistence") {
|
||||
double n = 0.0;
|
||||
num(n);
|
||||
t.persistence = static_cast<int>(n);
|
||||
}
|
||||
};
|
||||
if (section == Section::Top) {
|
||||
@@ -718,6 +722,7 @@ bool save_layout(const std::string& path, const LayoutState& state) {
|
||||
o << "trigger_holdoff = " << state.trigger.holdoff_s << "\n";
|
||||
o << "trigger_band = " << state.trigger.noise_band << "\n";
|
||||
o << "trigger_hf = " << state.trigger.hf_fc << "\n";
|
||||
o << "trigger_persistence = " << state.trigger.persistence << "\n";
|
||||
if (!state.comment.empty()) o << "comment = " << toml_escape(state.comment) << "\n";
|
||||
if (state.buffer_mb > 0.0) o << "buffer_mb = " << state.buffer_mb << "\n";
|
||||
o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n";
|
||||
|
||||
@@ -97,6 +97,10 @@ struct LayoutState {
|
||||
double holdoff_s = 0.010;
|
||||
double noise_band = 0.0;
|
||||
double hf_fc = 0.0;
|
||||
/// How many of the most recent acquisitions the trigger mode keeps on screen (1 = the event
|
||||
/// alone). Part of the trigger's own setup, like the window it captures and the shape it waits
|
||||
/// for: it is what the display does with the acquisitions, and there is no second switch.
|
||||
int persistence = 1;
|
||||
/// Whether the file said anything about the level. A session that did keeps it; one that did not
|
||||
/// leaves the panel's own "follow the channel's range" default alone.
|
||||
bool level_given = false;
|
||||
|
||||
@@ -891,9 +891,11 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
|
||||
}
|
||||
|
||||
snap->values.resize(channels_.size());
|
||||
snap->channel_names.resize(channels_.size());
|
||||
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0});
|
||||
snap->pyramids.resize(channels_.size());
|
||||
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
||||
snap->channel_names[c] = channels_[c].name;
|
||||
auto& vals = snap->values[c];
|
||||
vals.resize(static_cast<std::size_t>(n));
|
||||
if (n > 0) src.values(c).copy_from(first, n, vals.data());
|
||||
@@ -918,6 +920,24 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
|
||||
// capture at the first sample past `post_s` rather than exactly on it.
|
||||
snap->pre_s = snap->stamps.empty() ? 0.0 : -static_cast<double>(snap->stamps.front());
|
||||
snap->post_s = snap->stamps.empty() ? 0.0 : static_cast<double>(snap->stamps.back());
|
||||
// The persistence retention: what was on screen until now becomes the *newest of the older*
|
||||
// acquisitions, and the set is cut back to the depth the panel asks for and to the retention
|
||||
// budget. Nothing is retained at depth 1, so the default costs no memory and no copy at all.
|
||||
if (const int depth = persist_depth_locked(); depth > 1) {
|
||||
std::shared_ptr<const EventSnapshot> prev = event_.load(std::memory_order_relaxed);
|
||||
if (prev != nullptr) {
|
||||
// Only the slots the depth wants are shifted: the ones past it are the prune's business, and
|
||||
// moving an acquisition into a slot nothing can ever read is work for a snapshot about to be
|
||||
// dropped.
|
||||
const std::size_t top = std::min(static_cast<std::size_t>(depth - 1), persist_.size());
|
||||
for (std::size_t i = top; i-- > 1;) {
|
||||
persist_[i].store(persist_[i - 1].load(std::memory_order_relaxed),
|
||||
std::memory_order_relaxed);
|
||||
}
|
||||
persist_[0].store(std::move(prev), std::memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
prune_persist_locked(static_cast<std::size_t>(persist_depth_locked() - 1));
|
||||
event_.store(std::move(snap), std::memory_order_release);
|
||||
// Bumped after the snapshot is in place, so a consumer that sees the new sequence
|
||||
// number sees the snapshot it counts.
|
||||
@@ -963,6 +983,48 @@ void ScopeModel::set_trigger_enabled(bool on) {
|
||||
trigger_enabled_.store(on, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void ScopeModel::prune_persist_locked(std::size_t keep) {
|
||||
const std::size_t limit = std::min(keep, persist_.size());
|
||||
const std::size_t budget = persist_budget_bytes();
|
||||
std::size_t bytes = 0;
|
||||
std::size_t kept = 0;
|
||||
std::uint64_t dropped = 0;
|
||||
bool full = false;
|
||||
for (std::size_t i = 0; i < persist_.size(); ++i) {
|
||||
const std::shared_ptr<const EventSnapshot> snap = persist_[i].load(std::memory_order_relaxed);
|
||||
if (snap == nullptr) continue;
|
||||
if (i < limit) {
|
||||
// Either bound can cut the set: the depth the panel asks for, and the budget, which a long
|
||||
// window on a fast stream reaches long before the seventh layer.
|
||||
const std::size_t b = snapshot_bytes(*snap);
|
||||
if (!full && bytes + b <= budget) {
|
||||
bytes += b;
|
||||
++kept;
|
||||
continue;
|
||||
}
|
||||
full = true; // does not fit, and neither does anything older
|
||||
// Only what the *budget* refused is counted: a slot past the depth is not a drop, it is the
|
||||
// depth -- and counting it would make the reading say the memory was the reason when the
|
||||
// panel's own number was.
|
||||
++dropped;
|
||||
}
|
||||
persist_[i].store(nullptr, std::memory_order_relaxed);
|
||||
}
|
||||
persist_count_.store(kept, std::memory_order_relaxed);
|
||||
persist_bytes_.store(bytes, std::memory_order_relaxed);
|
||||
persist_dropped_.store(persist_dropped_.load(std::memory_order_relaxed) + dropped,
|
||||
std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void ScopeModel::set_persist_depth(int depth) {
|
||||
const int n = std::clamp(depth, 1, kMaxPersistDepth);
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
trigger_.persist_depth = n;
|
||||
// Pruned here and not only at the next latch: lowering the depth is the user asking to stop
|
||||
// paying for the layers, and a trigger that has stopped firing never reaches another publish.
|
||||
prune_persist_locked(static_cast<std::size_t>(n - 1));
|
||||
}
|
||||
|
||||
void ScopeModel::set_epoch_if_unset(f64 e) noexcept {
|
||||
if (epoch_ == 0.0) epoch_ = e;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
#include <plot/data/ring_buffer.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <unordered_map>
|
||||
#include <cstdint>
|
||||
@@ -511,6 +513,55 @@ class ScopeModel {
|
||||
/// while the latch count keeps answering "how many events fired".
|
||||
std::uint64_t event_seq() const noexcept { return event_seq_.load(std::memory_order_acquire); }
|
||||
|
||||
// --- persistence ----------------------------------------------------------
|
||||
/// The acquisitions retained for the persistence display, newest first: `persist_event(1)` is
|
||||
/// the event one latch back, `(2)` the one before that. Null for an age nobody kept.
|
||||
///
|
||||
/// Read **without a lock**, like `event()`: the producer publishes these in `publish_event()`
|
||||
/// with the same atomic-`shared_ptr` discipline, and a snapshot is immutable and shared, so a
|
||||
/// reader holds it alive for as long as it draws it. What the *ring* would have given is not
|
||||
/// needed afterwards -- a retained snapshot is a whole window copied out of it.
|
||||
std::shared_ptr<const EventSnapshot> persist_event(std::size_t age) const noexcept {
|
||||
if (age < 1 || age > persist_.size()) return nullptr;
|
||||
return persist_[age - 1].load(std::memory_order_acquire);
|
||||
}
|
||||
/// How many acquisitions are retained right now: what the depth asks for, or fewer -- never more
|
||||
/// -- when the retention budget cut the set short. A reading, so it is counted as the slots that
|
||||
/// hold a snapshot rather than derived from the depth.
|
||||
std::size_t persist_count() const noexcept {
|
||||
return persist_count_.load(std::memory_order_relaxed);
|
||||
}
|
||||
/// What those snapshots hold in bytes, and how many acquisitions the *budget* -- as against the
|
||||
/// depth -- has dropped. Both are producer-written and read as readings.
|
||||
std::size_t persist_bytes() const noexcept {
|
||||
return persist_bytes_.load(std::memory_order_relaxed);
|
||||
}
|
||||
std::uint64_t persist_dropped() const noexcept {
|
||||
return persist_dropped_.load(std::memory_order_relaxed);
|
||||
}
|
||||
/// The bytes persistence may retain, which is the ring's own byte budget (`--buffer-mb`): the
|
||||
/// snapshots are copies *of* what the rings hold, so holding as much again as the buffer does is
|
||||
/// the honest ceiling for a display of it -- and it is the bound that cannot be driven into an
|
||||
/// OOM by a long window on a fast stream, whatever depth the panel asks for.
|
||||
std::size_t persist_budget_bytes() const noexcept {
|
||||
const int mb = cfg_.buffer_mb > 0 ? cfg_.buffer_mb : 0;
|
||||
return static_cast<std::size_t>(mb) * 1024u * 1024u;
|
||||
}
|
||||
|
||||
/// Sets how many acquisitions the persistence display keeps, clamped to `kMaxPersistDepth`, and
|
||||
/// prunes the retained set to it **at once**: lowering the depth is a user asking to stop paying
|
||||
/// for the layers, and the next latch -- when the retention would otherwise catch up -- may never
|
||||
/// come on a trigger that has stopped firing.
|
||||
///
|
||||
/// Takes `mutex()` itself; do not call it holding the lock.
|
||||
void set_persist_depth(int depth);
|
||||
|
||||
/// How many acquisitions the persistence display keeps: the depth, clamped to the cap. Reading
|
||||
/// `trigger()` needs the lock, which every caller of this already holds.
|
||||
int persist_depth_locked() const noexcept {
|
||||
return std::clamp(trigger_.persist_depth, 1, kMaxPersistDepth);
|
||||
}
|
||||
|
||||
/// Publishes the live buffer's new samples to its `ChannelBuffer`s.
|
||||
void acquire_all();
|
||||
|
||||
@@ -742,6 +793,19 @@ class ScopeModel {
|
||||
std::atomic<double> capture_edge_{0.0};
|
||||
std::atomic<std::shared_ptr<const EventSnapshot>> event_; ///< the event on screen
|
||||
std::atomic<std::shared_ptr<const EventSnapshot>> pause_view_; ///< the view frozen at pause
|
||||
/// The retention behind `persist_event()`: one slot per *previous* acquisition, newest first, so
|
||||
/// slot 0 is the event one latch back. `kMaxPersistDepth - 1` of them, because the acquisition on
|
||||
/// screen is `event_` itself and is not retained a second time. Written by the producer in
|
||||
/// `publish_event()` and by `set_persist_depth()`, both under `mutex()`; read without it.
|
||||
std::array<std::atomic<std::shared_ptr<const EventSnapshot>>, kMaxPersistDepth - 1> persist_{};
|
||||
/// The readings `persist_count()`/`persist_bytes()`/`persist_dropped()` answer with, maintained
|
||||
/// where the slots are written.
|
||||
std::atomic<std::size_t> persist_count_{0};
|
||||
std::atomic<std::size_t> persist_bytes_{0};
|
||||
std::atomic<std::uint64_t> persist_dropped_{0};
|
||||
/// Drops the retained snapshots past `keep` (0 empties the set) and recounts the readings.
|
||||
/// Caller holds `mutex()`.
|
||||
void prune_persist_locked(std::size_t keep);
|
||||
std::atomic<bool> paused_{false};
|
||||
/// Per-signal-name calibration (see `set_calibration`).
|
||||
std::unordered_map<std::string, std::pair<double, double>> calibration_;
|
||||
|
||||
+209
-4
@@ -68,8 +68,28 @@ constexpr float kMeasureDashOff = 4.0f;
|
||||
/// stacked frames read as one tall plot with a line through it.
|
||||
constexpr plot::f32 kRowGap = 6.0f;
|
||||
|
||||
/// The suffix that names a persistence layer, in the accumulate path's own vocabulary --
|
||||
/// ` (A)`, ` (B)`, ` #4` -- so that `layer_base_name()` takes it back off, and the signal list, the
|
||||
/// colour resolver, the measurements and the layout writer all keep seeing one signal where the
|
||||
/// plot draws several traces of it.
|
||||
std::string layer_suffix(int age) {
|
||||
if (age == 1) return " (A)";
|
||||
if (age == 2) return " (B)";
|
||||
return " #" + std::to_string(age + 1);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
float RowSet::persist_alpha(int age) noexcept {
|
||||
// Half per acquisition, so the seventh is 1/128 -- and halves, not a slope, because that is what
|
||||
// "exponential decay" means and because it is the one schedule that can be read off a line of
|
||||
// numbers: 1, 0.5, 0.25, 0.125. It does not depend on the depth, so raising N adds dimmer
|
||||
// acquisitions instead of restating the ones already on screen.
|
||||
float a = 1.0f;
|
||||
for (int i = 0; i < age; ++i) a *= 0.5f;
|
||||
return a;
|
||||
}
|
||||
|
||||
std::string layer_base_name(const std::string& name) {
|
||||
const std::size_t a = name.rfind(" (");
|
||||
if (a != std::string::npos && a + 3 < name.size() && name[a + 3] == ')' &&
|
||||
@@ -146,6 +166,11 @@ void RowSet::carry_row(Row& dst, const Row& src) {
|
||||
dst.name = src.name;
|
||||
dst.channel = src.channel;
|
||||
dst.color_index = src.color_index;
|
||||
// A layer travels with the slot that draws it: it *is* part of what that slot holds. The tick's
|
||||
// `sync_layers()` re-plans them all next frame in any case, which is what heals a row that has
|
||||
// moved under a plan made for the old one.
|
||||
dst.layer = src.layer;
|
||||
dst.slot_alpha = src.slot_alpha;
|
||||
|
||||
dst.ruler_y = src.ruler_y;
|
||||
dst.ruler_b_y = src.ruler_b_y;
|
||||
@@ -161,6 +186,8 @@ void RowSet::clear_row(Row& r) {
|
||||
r.name = {};
|
||||
r.channel = {-1, -1, -1, -1};
|
||||
r.color_index = {0, 1, 2, 3};
|
||||
r.layer = {0, 0, 0, 0};
|
||||
r.slot_alpha = {1.0f, 1.0f, 1.0f, 1.0f};
|
||||
r.ruler_y = 0.0;
|
||||
for (SlotSeries& s : r.slots) s.bind(nullptr);
|
||||
}
|
||||
@@ -190,10 +217,24 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
|
||||
{"", {-1.0, 1.0}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
|
||||
r.axis_y = r.plot->add_y_axis(
|
||||
{"", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
|
||||
for (int s = 0; s < kSlotsPerRow; ++s) {
|
||||
// Traces are drawn in the order they were created, and a `Plot` cannot reorder them: the
|
||||
// *dimmest* traces are created first and a signal's own trace last, so the newest acquisition is
|
||||
// the ink on top. A layer drawn over the event would take its colour out of the event's own trace
|
||||
// wherever the two agree -- the opposite of what a persistence display shows.
|
||||
std::array<int, kSlotsPerRow> order{};
|
||||
for (int s = 0; s < kSlotsPerRow; ++s) order[static_cast<std::size_t>(s)] = s;
|
||||
std::stable_sort(order.begin(), order.end(), [&r](int a, int b) {
|
||||
return r.slot_alpha[static_cast<std::size_t>(a)] < r.slot_alpha[static_cast<std::size_t>(b)];
|
||||
});
|
||||
for (const int s : order) {
|
||||
const std::size_t i = static_cast<std::size_t>(s);
|
||||
r.plot->add_trace({&r.slots[i], signal_color(r.signal_name[i], r.color_index[i]), 1.2f,
|
||||
r.axis_x, r.axis_y, r.name[i]});
|
||||
plot::Color color = signal_color(r.signal_name[i], r.color_index[i]);
|
||||
// The slot's own brightness times the figure-wide one the group views set: a persistence
|
||||
// layer's decay and an accumulation's translucency are the same knob, and `set_trace_alpha()`
|
||||
// is what reads it here. It was set and never read before, which is why an accumulation's
|
||||
// traces were opaque.
|
||||
color.a = r.slot_alpha[i] * trace_alpha_;
|
||||
r.plot->add_trace({&r.slots[i], color, 1.2f, r.axis_x, r.axis_y, r.name[i]});
|
||||
}
|
||||
r.cursor_a = r.plot->add_cursor(0.0, plot::Color::hex(kCursorAColor));
|
||||
r.cursor_b = r.plot->add_cursor(0.0, plot::Color::hex(kCursorBColor));
|
||||
@@ -320,9 +361,132 @@ void RowSet::assign(int row_index, int slot, std::string signal_name,
|
||||
r.channel[s] = -1; // re-resolved by the next bind()
|
||||
r.name[s] = std::move(display_name);
|
||||
r.color_index[s] = color_index;
|
||||
// Whatever the slot held, it is a *signal's* trace now: a drop onto a slot a persistence layer
|
||||
// was using is the user's arrangement, and the tick's plan moves the layers around it next frame.
|
||||
r.layer[s] = 0;
|
||||
r.slot_alpha[s] = 1.0f;
|
||||
row_dirty_[static_cast<std::size_t>(row_index)] = true;
|
||||
}
|
||||
|
||||
void RowSet::assign_layer(int row_index, int slot, std::string signal_name,
|
||||
std::string display_name, int color_index, int age, float alpha) {
|
||||
assign(row_index, slot, std::move(signal_name), std::move(display_name), color_index);
|
||||
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return;
|
||||
Row& r = row(row_index);
|
||||
const std::size_t s = static_cast<std::size_t>(slot);
|
||||
r.layer[s] = age;
|
||||
r.slot_alpha[s] = alpha;
|
||||
row_dirty_[static_cast<std::size_t>(row_index)] = true;
|
||||
}
|
||||
|
||||
void RowSet::clear_layers() {
|
||||
for (int i = 0; i < kMaxRows; ++i) {
|
||||
Row& r = row(i);
|
||||
for (int s = 0; s < kSlotsPerRow; ++s) {
|
||||
const std::size_t si = static_cast<std::size_t>(s);
|
||||
if (r.layer[si] == 0) continue;
|
||||
assign(i, s, "", "", r.color_index[si]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int RowSet::sync_layers(int depth, int* want) {
|
||||
const int ages = std::clamp(depth, 1, kMaxPersistDepth) - 1;
|
||||
// What each slot should hold: 0 a signal's own trace (kept as it is), -1 free, >0 a layer age.
|
||||
std::array<std::array<int, kSlotsPerRow>, kMaxRows> plan{};
|
||||
std::array<std::array<std::string, kSlotsPerRow>, kMaxRows> names{};
|
||||
int wanted = 0;
|
||||
for (int r = 0; r < rows_; ++r) {
|
||||
Row& row_state = row(r);
|
||||
for (int s = 0; s < kSlotsPerRow; ++s) {
|
||||
const std::size_t si = static_cast<std::size_t>(s);
|
||||
// A layer slot is free for re-planning; a signal's own trace is not.
|
||||
plan[static_cast<std::size_t>(r)][si] =
|
||||
row_state.layer[si] > 0 ? -1 : (row_state.signal_name[si].empty() ? -1 : 0);
|
||||
}
|
||||
}
|
||||
// Layer by layer, as the accumulate path fills its own: age 1 for every signal first, then age 2,
|
||||
// so a row with room for two layers draws the *sharper* one for each of its signals rather than
|
||||
// four ages of the first one.
|
||||
for (int age = 1; age <= ages; ++age) {
|
||||
for (int r = 0; r < rows_; ++r) {
|
||||
const std::size_t ri = static_cast<std::size_t>(r);
|
||||
Row& row_state = row(r);
|
||||
for (int s = 0; s < kSlotsPerRow; ++s) {
|
||||
const std::size_t si = static_cast<std::size_t>(s);
|
||||
if (plan[ri][si] != 0) continue;
|
||||
++wanted;
|
||||
int target = -1;
|
||||
for (int t = 0; t < kSlotsPerRow; ++t) {
|
||||
if (plan[ri][static_cast<std::size_t>(t)] == -1) {
|
||||
target = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (target < 0) continue; // this row is full: it persists as far as it can
|
||||
const std::size_t ti = static_cast<std::size_t>(target);
|
||||
plan[ri][ti] = age;
|
||||
names[ri][ti] = row_state.signal_name[si] + layer_suffix(age);
|
||||
}
|
||||
}
|
||||
}
|
||||
int placed = 0;
|
||||
for (int r = 0; r < rows_; ++r) {
|
||||
const std::size_t ri = static_cast<std::size_t>(r);
|
||||
Row& row_state = row(r);
|
||||
for (int s = 0; s < kSlotsPerRow; ++s) {
|
||||
const std::size_t si = static_cast<std::size_t>(s);
|
||||
const int now = plan[ri][si];
|
||||
const int had = row_state.layer[si];
|
||||
if (now > 0) {
|
||||
++placed;
|
||||
if (had == now && row_state.signal_name[si] == names[ri][si]) continue; // already right
|
||||
assign_layer(r, s, names[ri][si], names[ri][si], row_state.color_index[si], now,
|
||||
persist_alpha(now));
|
||||
} else if (had > 0) {
|
||||
assign(r, s, "", "", row_state.color_index[si]); // nothing wants this slot any more
|
||||
}
|
||||
}
|
||||
}
|
||||
if (want != nullptr) *want = wanted;
|
||||
return placed;
|
||||
}
|
||||
|
||||
int RowSet::layer_of(int row_index, int slot) const {
|
||||
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return 0;
|
||||
return row(row_index).layer[static_cast<std::size_t>(slot)];
|
||||
}
|
||||
|
||||
float RowSet::slot_alpha_of(int row_index, int slot) const {
|
||||
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return 1.0f;
|
||||
return row(row_index).slot_alpha[static_cast<std::size_t>(slot)];
|
||||
}
|
||||
|
||||
int RowSet::layer_slots() const {
|
||||
int n = 0;
|
||||
for (int i = 0; i < kMaxRows; ++i) {
|
||||
const Row& r = row(i);
|
||||
for (int s = 0; s < kSlotsPerRow; ++s) {
|
||||
if (r.layer[static_cast<std::size_t>(s)] > 0) ++n;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
std::vector<std::pair<int, float>> RowSet::row_layers(int row_index) const {
|
||||
std::vector<std::pair<int, float>> out;
|
||||
if (row_index < 0 || row_index >= kMaxRows) return out;
|
||||
const Row& r = row(row_index);
|
||||
for (int s = 0; s < kSlotsPerRow; ++s) {
|
||||
const std::size_t si = static_cast<std::size_t>(s);
|
||||
if (r.layer[si] > 0) out.push_back({r.layer[si], r.slot_alpha[si]});
|
||||
}
|
||||
// Oldest first: the order the decay has to read in.
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const auto& a, const auto& b) { return a.first > b.first; });
|
||||
return out;
|
||||
}
|
||||
|
||||
bool RowSet::slot_bound(int row_index, int slot) const {
|
||||
if (row_index < 0 || row_index >= kMaxRows || slot < 0 || slot >= kSlotsPerRow) return false;
|
||||
return !row(row_index).signal_name[static_cast<std::size_t>(slot)].empty();
|
||||
@@ -348,9 +512,15 @@ void RowSet::bind(ScopeModel& model, DisplayMode mode) {
|
||||
// Resolve the stable identity every frame: the channel list is rebuilt (and
|
||||
// renumbered) whenever a signal is enabled, disabled or re-described, and a
|
||||
// stored index would then point at a different signal's channel.
|
||||
//
|
||||
// A persistence layer resolves the name *under its suffix*: `sine (A)` is `sine`, one
|
||||
// acquisition back, and it is the same signal's trace -- so it is acquired (or not) with its
|
||||
// signal, drawn in its colour, and counted by nothing that counts signals.
|
||||
const std::string base_name =
|
||||
r.layer[si] > 0 ? layer_base_name(r.signal_name[si]) : r.signal_name[si];
|
||||
int signal_index = -1;
|
||||
for (std::size_t k = 0; k < signals.size(); ++k) {
|
||||
if (signals[k].name == r.signal_name[si]) {
|
||||
if (signals[k].name == base_name) {
|
||||
signal_index = static_cast<int>(k);
|
||||
break;
|
||||
}
|
||||
@@ -364,6 +534,36 @@ void RowSet::bind(ScopeModel& model, DisplayMode mode) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (r.layer[si] > 0) {
|
||||
// A layer holds no channel: every reader that asks "is this slot a trace of a signal"
|
||||
// -- the trace counts, the cursors pane's `mix` mode, the measurement marks, the layout
|
||||
// writer -- tests `channel >= 0`, so a layer is invisible to all of them by construction.
|
||||
r.channel[si] = -1;
|
||||
// It draws only while a *snapshot* is on screen, and only if its signal is still acquired
|
||||
// (switching a signal off takes its traces off every plot, layers included), and it
|
||||
// resolves its channel **by name inside the snapshot**: the channel list may have been
|
||||
// rebuilt and renumbered many times since that acquisition was cut, which is exactly why
|
||||
// the trigger engine names its channel too.
|
||||
std::shared_ptr<const EventSnapshot> snap;
|
||||
if (channel >= 0 && (mode == DisplayMode::Held || mode == DisplayMode::Capturing)) {
|
||||
snap = model.persist_event(static_cast<std::size_t>(r.layer[si]));
|
||||
}
|
||||
std::size_t layer_channel = snap != nullptr ? snap->channel_names.size() : 0;
|
||||
if (snap != nullptr) {
|
||||
for (std::size_t c = 0; c < snap->channel_names.size(); ++c) {
|
||||
if (snap->channel_names[c] == base_name) {
|
||||
layer_channel = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (snap != nullptr && layer_channel < snap->values.size()) {
|
||||
r.slots[si].bind_event(std::move(snap), layer_channel);
|
||||
} else {
|
||||
r.slots[si].bind(nullptr);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
r.channel[si] = channel;
|
||||
if (channel < 0) {
|
||||
// Its signal is present but not acquired: keep the identity (so enabling it
|
||||
@@ -818,6 +1018,11 @@ bool RowSet::event_data_window(double& lo, double& hi) const {
|
||||
const Row& r = row(i);
|
||||
if (!r.plot) continue;
|
||||
for (int s = 0; s < kSlotsPerRow; ++s) {
|
||||
// A persistence layer is bound to an event series too, and it is the *past*: letting one
|
||||
// widen this window would move the pin -- and with it the framing and the numbers a pinned
|
||||
// measurement reads -- every time an older acquisition happened to be wider than the one on
|
||||
// screen. What is pinned is the current event's window, and only that.
|
||||
if (r.layer[static_cast<std::size_t>(s)] > 0) continue;
|
||||
const EventSeries* ev = r.slots[static_cast<std::size_t>(s)].bound_event();
|
||||
if (ev == nullptr || ev->size() < 2) continue;
|
||||
const plot::Range b = ev->x_bounds();
|
||||
|
||||
@@ -112,6 +112,16 @@ class RowSet {
|
||||
/// `add_trace()`, which is why this is stored rather than resolved per frame --
|
||||
/// changing it needs a rebuild, and `assign()` already forces one.
|
||||
std::array<int, kSlotsPerRow> color_index = {0, 1, 2, 3};
|
||||
/// The *persistence layer* this slot draws: 0 for a signal's own trace (whatever the frame's
|
||||
/// display mode binds it to), `a` > 0 for the acquisition `a` latches back, drawn at
|
||||
/// `slot_alpha`. A layer slot holds a suffixed name (`sine (A)`) and no channel at all -- it is
|
||||
/// not a signal, and `channel[]` stays -1 for it (see `assign_layer()`).
|
||||
std::array<int, kSlotsPerRow> layer = {0, 0, 0, 0};
|
||||
/// How bright the slot's trace is built: 1 for a signal's own trace, `persist_alpha(layer)` for
|
||||
/// a persistence layer, multiplied into `trace_alpha_`. It lives on the slot because a `Plot`
|
||||
/// fixes a trace's colour -- alpha included -- at `add_trace()`, so a per-layer brightness is a
|
||||
/// property of the trace's *creation*, which is what a rebuild is for.
|
||||
std::array<float, kSlotsPerRow> slot_alpha = {1.0f, 1.0f, 1.0f, 1.0f};
|
||||
plot::u32 axis_x = 0;
|
||||
plot::u32 axis_y = 0;
|
||||
plot::u32 cursor_a = 0;
|
||||
@@ -200,6 +210,42 @@ class RowSet {
|
||||
/// The channel index a row/slot holds, or -1.
|
||||
int channel_of(int row, int slot) const;
|
||||
|
||||
// --- persistence layers ---------------------------------------------------
|
||||
/// The brightness a persistence layer is drawn at, `a` acquisitions back: the acquisition on
|
||||
/// screen is the full-bright one and each older one is **half** the previous, so the schedule is
|
||||
/// exponential in age -- 1, 0.5, 0.25, 0.125, ... and by the seventh (0.008, `kMaxPersistDepth`)
|
||||
/// the layer is gone. Halving is per acquisition and not per *depth*, so changing N re-cuts how
|
||||
/// many are kept without rescaling the ones already on screen.
|
||||
static float persist_alpha(int age) noexcept;
|
||||
|
||||
/// Binds a row's slot to a persistence *layer*: the acquisition `age` latches back, at `alpha`
|
||||
/// (see `Row::layer`). The name carries the layer's suffix (`sine (A)`, `sine #4`), which is what
|
||||
/// keeps a layer out of the signal list and out of the measurements -- it is not a signal -- while
|
||||
/// `signal_color()` still resolves it to the base signal's own colour.
|
||||
void assign_layer(int row, int slot, std::string signal_name, std::string display_name,
|
||||
int color_index, int age, float alpha);
|
||||
/// Frees every slot bound to a layer. What turning persistence off -- or the trigger -- is.
|
||||
void clear_layers();
|
||||
/// Lays the persistence layers out over the rows' free slots, one per (signal, age): the signal's
|
||||
/// own trace stays where it is and its layers take the free slots of the *same* row, layer by
|
||||
/// layer (age 1 for every signal first, then age 2, as the accumulate path does), so a row that
|
||||
/// runs out of room draws as many layers as it has room for and no more. Every slot the plan does
|
||||
/// not fill is freed, which is what makes the display follow a layout change, a depth change and a
|
||||
/// signal's removal without any caller having to reset anything.
|
||||
///
|
||||
/// Returns the number of layers placed; `want` is how many the depth asked for, so a caller can
|
||||
/// say when a layout could not show them all.
|
||||
int sync_layers(int depth, int* want);
|
||||
/// The layer a slot is drawing, 0 for a signal's own trace.
|
||||
int layer_of(int row, int slot) const;
|
||||
/// The slot's own brightness (1 unless it is a layer).
|
||||
float slot_alpha_of(int row, int slot) const;
|
||||
/// How many slots are layers, over every row: the reading that says the layers exist.
|
||||
int layer_slots() const;
|
||||
/// The layers of one row, oldest first, as `(age, alpha)` pairs -- the ordering a reader has to
|
||||
/// see to know the decay is the right way round.
|
||||
std::vector<std::pair<int, float>> row_layers(int row) const;
|
||||
|
||||
/// Rebinds every slot to what `mode` says it should show this frame. Called every
|
||||
/// frame, because the channel list is renumbered by any structural change.
|
||||
///
|
||||
|
||||
@@ -39,6 +39,20 @@ void TriggerEngine::arm(double now_s) noexcept {
|
||||
detector_.reset();
|
||||
}
|
||||
|
||||
bool TriggerEngine::force(double now_s) noexcept {
|
||||
if (!cfg_.enabled || !armed_ || cfg_.channel < 0 || pending_) return false;
|
||||
// Straight into the pending state `process()` maintains, with the present moment as the event's
|
||||
// own time: the post window is counted from here by the same accumulation an edge's capture uses,
|
||||
// and the latch that ends it is the same latch. `last_trigger_s_` is the edge, so the marker, the
|
||||
// cut's centre and the snapshot's `x_offset` all land on the instant of the press.
|
||||
last_t_s_ = now_s;
|
||||
last_trigger_s_ = now_s;
|
||||
pending_ = true;
|
||||
pending_start_s_ = now_s;
|
||||
post_accum_count_ = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TriggerEngine::process(double value, double now_s) noexcept {
|
||||
if (!cfg_.enabled || !armed_ || cfg_.channel < 0) return false;
|
||||
|
||||
|
||||
@@ -30,12 +30,25 @@ struct TriggerConfig {
|
||||
double pretrigger_pct = 50.0; ///< share of the buffer shown before the event.
|
||||
double holdoff_s = 0.010; ///< default from the spec.
|
||||
TriggerMode mode = TriggerMode::Normal;
|
||||
/// How many of the most recent acquisitions the plot keeps on screen, the older ones drawn
|
||||
/// dimmer (see `RowSet::persist_alpha()`): 1 -- the default -- keeps nothing but the event on
|
||||
/// screen, which is the display without persistence, and there is deliberately no second switch
|
||||
/// for it.
|
||||
int persist_depth = 1;
|
||||
|
||||
/// Seconds of post-trigger data to collect before latching the event, given
|
||||
/// the buffer's span.
|
||||
double post_span_s(double buffer_span_s) const;
|
||||
};
|
||||
|
||||
/// The deepest persistence the trigger panel offers. Seven retained acquisitions is what fits: the
|
||||
/// layer in that position is drawn at half the seventh power (0.8 %, i.e. already gone, see
|
||||
/// `RowSet::persist_alpha()`), and seven is also the number of free slots a row has beside a single
|
||||
/// signal, so a deeper request could not be drawn on the simplest layout there is. It is **not**
|
||||
/// the bound on what persistence may cost in memory -- `ScopeModel::persist_budget_bytes()` is, and
|
||||
/// a window of millions of samples reaches it long before the seventh layer.
|
||||
inline constexpr int kMaxPersistDepth = 8;
|
||||
|
||||
/// The producer-side trigger state machine. Fed one sample at a time in stream
|
||||
/// order; it says when the event buffer should be latched.
|
||||
class TriggerEngine {
|
||||
@@ -75,6 +88,23 @@ class TriggerEngine {
|
||||
void disarm() noexcept { armed_ = false; pending_ = false; }
|
||||
void arm(double now_s) noexcept;
|
||||
|
||||
/// Opens a capture **now**, as if the condition had just fired: `now_s` becomes the event's own
|
||||
/// time, so the engine collects the post window from the samples that follow it and the ordinary
|
||||
/// latch publishes `[-pre, +post]` around that instant. This is the Force button -- the one
|
||||
/// acquisition a scope takes when the edge is not coming -- and it is deliberately not a
|
||||
/// publish of its own: the capture, the cut, the marker, the count and the mode's re-arm rule are
|
||||
/// the edge path's, which is what keeps a forced acquisition framed and pinned exactly like a
|
||||
/// latched one.
|
||||
///
|
||||
/// The pre-trigger gate is the one thing *not* applied: the history behind the present moment is
|
||||
/// whatever the ring holds, and a caller who asks to capture now is asking for that. A snapshot
|
||||
/// shorter than the request reports its own `pre_s`, and the view is pinned to it.
|
||||
///
|
||||
/// Returns false when there is no engine to force -- trigger mode off, no channel resolved, not
|
||||
/// armed -- or when a capture is already filling: a capture in flight *is* the acquisition being
|
||||
/// asked for, and restarting it would discard the post window it has already collected.
|
||||
bool force(double now_s) noexcept;
|
||||
|
||||
/// Feeds one sample of the trigger channel. `now_s` is the sample's timestamp
|
||||
/// in stream seconds. Returns true when the event has just been latched.
|
||||
bool process(double value, double now_s) noexcept;
|
||||
|
||||
+72
-1
@@ -3514,11 +3514,27 @@ gboolean tick(gpointer user) {
|
||||
app->capturing = display == DisplayMode::Capturing;
|
||||
if (new_capture) app->capture_edge = cap_edge;
|
||||
|
||||
// The persistence plan this frame: what the depth asked for and what the rows had room to draw.
|
||||
// Declared outside the lock block because the notice below is a widget write, and the tick's
|
||||
// widget work happens outside the model's lock.
|
||||
const bool live_display = display == DisplayMode::Live || !app->waveform_name.empty();
|
||||
int persist_depth = 1;
|
||||
int persist_want = 0;
|
||||
|
||||
{
|
||||
// Serialise against the acquisition thread's CONFIG handling and against the
|
||||
// UI's own structural changes: the bind takes shared handles into the live
|
||||
// store, and acquire() reads it.
|
||||
std::lock_guard<std::mutex> lock(model.mutex());
|
||||
// Persistence first, so the bind below is what *resolves* the layers: the last N acquisitions
|
||||
// are planned into the rows' free slots, and a depth of 1 -- or the trigger off, or a *file* on
|
||||
// screen, whose data is not a stream of acquisitions at all -- frees every layer slot, which is
|
||||
// the display without the feature. The plan is a function of the rows and the depth and not of
|
||||
// the data, so a latch costs no rebuild and the names and ages on screen stay put.
|
||||
persist_depth = live_display ? 1 : model.persist_depth_locked();
|
||||
const int placed = app->rows->sync_layers(persist_depth, &persist_want);
|
||||
app->persist_want = persist_want;
|
||||
app->persist_placed = placed;
|
||||
app->rows->bind(model, display);
|
||||
app->rows->acquire(model);
|
||||
if (app->comparing && app->rows_b != nullptr) {
|
||||
@@ -3564,6 +3580,28 @@ gboolean tick(gpointer user) {
|
||||
// acquisition thread mutates, so it belongs under this lock and not after it.
|
||||
app->rows->set_invalid_spans(model, display);
|
||||
}
|
||||
// A depth the room or the retention budget could not show in full says so, once per *plan* rather
|
||||
// than once per frame: a user who asked for eight layers and sees two has no other way to tell
|
||||
// "the display is deliberately shallower" from "the feature is broken". Both bounds are named,
|
||||
// because they are different numbers and only one of them is the user's own: a *slot* shortfall is
|
||||
// `placed < want` (the rows had no free trace slot), and a *budget* shortfall is fewer retained
|
||||
// acquisitions than the depth asks for, in which case the slots exist and draw nothing at all.
|
||||
const int want_ages = persist_depth > 1 ? persist_depth - 1 : 0;
|
||||
const int kept_ages = static_cast<int>(std::min<std::size_t>(
|
||||
model.persist_count(), static_cast<std::size_t>(std::max(0, want_ages))));
|
||||
if (persist_depth > 1 &&
|
||||
(app->persist_placed < persist_want || kept_ages < want_ages) &&
|
||||
(app->persist_noted_depth != persist_depth || app->persist_noted_want != persist_want ||
|
||||
app->persist_noted_placed != app->persist_placed)) {
|
||||
app->persist_noted_depth = persist_depth;
|
||||
app->persist_noted_want = persist_want;
|
||||
app->persist_noted_placed = app->persist_placed;
|
||||
set_notice(*app,
|
||||
"persistence: " + std::to_string(app->persist_placed) + " of " +
|
||||
std::to_string(persist_want) + " layers fit, " + std::to_string(kept_ages) +
|
||||
" of " + std::to_string(want_ages) + " older acquisitions retained",
|
||||
5);
|
||||
}
|
||||
// Outside the lock: it reads and writes the two plots' own windows, not the model.
|
||||
sync_compare_axes(*app);
|
||||
sync_events_divider(*app);
|
||||
@@ -5777,6 +5815,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_DISARM_MS")) {
|
||||
g_timeout_add(static_cast<guint>(std::atoi(env)), disarm_probe_step1, &app);
|
||||
}
|
||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_FORCE_MS")) {
|
||||
g_timeout_add(static_cast<guint>(std::atoi(env)), force_probe, &app);
|
||||
}
|
||||
if (const char* env = g_getenv("UDPSCOPE_ARM_TRIGGER_MS")) {
|
||||
g_timeout_add(static_cast<guint>(std::atoi(env)), arm_probe, &app);
|
||||
}
|
||||
@@ -5909,6 +5950,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_RESEQ_MS")) {
|
||||
g_timeout_add(static_cast<guint>(std::atoi(env)), reseq_probe, &app);
|
||||
}
|
||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_PERSIST_MS")) {
|
||||
g_timeout_add(static_cast<guint>(std::atoi(env)), persist_probe, &app);
|
||||
}
|
||||
|
||||
// Our own options were consumed by parse_args(); GTK would reject them, so only
|
||||
// the program name is handed to g_application_run().
|
||||
@@ -6340,7 +6384,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
||||
(app.search_run_button != nullptr) + (app.search_cancel_button != nullptr) +
|
||||
(app.search_prev_button != nullptr) + (app.search_next_button != nullptr);
|
||||
std::printf(
|
||||
"[udpscope] trigger panel: boxes=%d controls_inside=%d/15 search_widgets=%d/4 "
|
||||
"[udpscope] trigger panel: boxes=%d controls_inside=%d/16 search_widgets=%d/4 "
|
||||
"search_inside=%d/4\n",
|
||||
boxes,
|
||||
inside(app.trigger_signal_drop, app.trigger_body) +
|
||||
@@ -6357,6 +6401,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
||||
inside(app.trigger_holdoff_spin, app.trigger_body) +
|
||||
inside(app.trigger_band_spin, app.trigger_body) +
|
||||
inside(app.trigger_hf_spin, app.trigger_body) +
|
||||
inside(app.trigger_persist_spin, app.trigger_body) +
|
||||
inside(app.events_record_switch, app.trigger_body),
|
||||
search_present,
|
||||
inside(app.search_run_button, search_root) + inside(app.search_cancel_button, search_root) +
|
||||
@@ -6600,6 +6645,32 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
||||
static_cast<unsigned long long>(buf != nullptr ? buf->size() : 0));
|
||||
}
|
||||
}
|
||||
{
|
||||
// Persistence: what is retained, what the rows had room to draw, and the decay schedule in
|
||||
// use (oldest first, so the half-per-acquisition fall-off reads straight off the line). The
|
||||
// layer slots are named in the `layout:` line above, as `sine (A)`, and are deliberately
|
||||
// absent from the per-slot channel lines below it: a layer is not a channel.
|
||||
std::lock_guard<std::mutex> lock(model.mutex());
|
||||
const int depth = model.persist_depth_locked();
|
||||
std::printf("[udpscope] persistence: depth=%d kept=%zu bytes=%zu budget=%zu dropped=%llu "
|
||||
"layers=%d want=%d placed=%d schedule=[",
|
||||
depth, model.persist_count(), model.persist_bytes(),
|
||||
model.persist_budget_bytes(),
|
||||
static_cast<unsigned long long>(model.persist_dropped()), rows.layer_slots(),
|
||||
app.persist_want, app.persist_placed);
|
||||
// The decay schedule the depth asks for, oldest first: half per acquisition, so this line
|
||||
// reads the rule and the probe's `alphas=` reads what the rows were actually built with.
|
||||
for (int age = std::min(depth - 1, kMaxPersistDepth - 1); age >= 1; --age) {
|
||||
std::printf("%s%.4f", age != depth - 1 ? "," : "",
|
||||
static_cast<double>(RowSet::persist_alpha(age)));
|
||||
}
|
||||
std::printf("] alphas=[");
|
||||
const std::vector<std::pair<int, float>> layers = rows.row_layers(0);
|
||||
for (std::size_t i = 0; i < layers.size(); ++i) {
|
||||
std::printf("%s%.4f", i != 0 ? "," : "", static_cast<double>(layers[i].second));
|
||||
}
|
||||
std::printf("]\n");
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
+33
-6
@@ -2,10 +2,11 @@
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!--
|
||||
The trigger panel's frame: one labelled row per setting, the two buttons that run it, and the search
|
||||
block. Its *heading* is not here: the heading row carries the switch that turns trigger mode on and
|
||||
off, and this panel's body is what goes insensitive when it is off, so the heading has to be its
|
||||
sibling rather than a child of it (`build_trigger_panel()` returns the two together).
|
||||
The trigger panel's frame: one labelled row per setting, the pair of buttons that run it
|
||||
(Arm/Disarm and Force), and the search block. Its *heading* is not here: the heading row carries the
|
||||
switch that turns trigger mode on and off, and this panel's body is what goes insensitive when it is
|
||||
off, so the heading has to be its sibling rather than a child of it (`build_trigger_panel()` returns
|
||||
the two together).
|
||||
|
||||
Every row here is a label and a place for a control, and the control itself is built in code. A
|
||||
dropdown's model and a spin button's range are the *application's* (the condition list, the level
|
||||
@@ -208,8 +209,34 @@
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="trigger_arm">
|
||||
<property name="label" translatable="yes">Arm</property>
|
||||
<object class="GtkBox" id="persistence_row">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="label" translatable="yes">persistence</property>
|
||||
<property name="halign">start</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox" id="arm_row">
|
||||
<property name="orientation">horizontal</property>
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkButton" id="trigger_arm">
|
||||
<property name="label" translatable="yes">Arm</property>
|
||||
<property name="hexpand">1</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="trigger_force">
|
||||
<property name="label" translatable="yes">Force</property>
|
||||
<property name="hexpand">1</property>
|
||||
<property name="tooltip-text" translatable="yes">take one acquisition now, with the pre/post window above, without waiting for the condition</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
|
||||
@@ -203,6 +203,11 @@ struct App {
|
||||
/// sidebar. "signals" on a first run.
|
||||
std::string side_tab = "signals";
|
||||
GtkWidget* arm_button = nullptr;
|
||||
/// The Force button, beside the Arm/Disarm one: one acquisition now, with the configured pre/post
|
||||
/// window. It carries no state of its own -- `sync_arm_buttons()` states the one it has
|
||||
/// (sensitivity, from trigger mode), so the two buttons beside each other cannot disagree about
|
||||
/// whether the trigger has an engine at all.
|
||||
GtkWidget* force_button = nullptr;
|
||||
GtkWidget* trigger_signal_drop = nullptr;
|
||||
GtkWidget* sidebar_toggle = nullptr;
|
||||
GtkWidget* trigger_edge_drop = nullptr;
|
||||
@@ -226,6 +231,31 @@ struct App {
|
||||
GtkWidget* trigger_holdoff_spin = nullptr;
|
||||
GtkWidget* trigger_band_spin = nullptr; ///< EventCondition::noise_band
|
||||
GtkWidget* trigger_hf_spin = nullptr; ///< EventCondition::hf_fc, 0 is off
|
||||
/// The persistence spin: how many of the most recent triggered acquisitions stay on screen, drawn
|
||||
/// dimmer with age (1 = the event alone, which is the display without the feature). Its row is held
|
||||
/// for a `.ui` that lost it, as the other rows are.
|
||||
GtkWidget* trigger_persist_spin = nullptr;
|
||||
GtkWidget* trigger_persist_row = nullptr;
|
||||
/// What this frame's persistence plan drew, against what the depth asked for: `sync_layers()`
|
||||
/// returns the former and fills the latter, and the two differ exactly when a row had no free
|
||||
/// slot left for a layer or the retention budget refused an acquisition. Read by the exit dump and
|
||||
/// by the persistence probe, and what the "only some layers fit" notice is keyed on.
|
||||
int persist_want = 0;
|
||||
int persist_placed = 0;
|
||||
/// The last plan a notice was raised for, so a plan that stays short says so once rather than
|
||||
/// every frame.
|
||||
int persist_noted_depth = 0;
|
||||
int persist_noted_want = -1;
|
||||
int persist_noted_placed = -1;
|
||||
/// The persistence probe's own state: the depth its first leg asks for (kept between steps,
|
||||
/// because the steps are separate `GSourceFunc`s), how many polls it has spent waiting for the
|
||||
/// retention to fill, and whether that set has stopped growing (which is how a budget that keeps
|
||||
/// fewer acquisitions than the depth asks for still gets a report).
|
||||
int dbg_persist_depth = 4;
|
||||
int dbg_persist_polls = 0;
|
||||
std::size_t dbg_persist_last_kept = 0;
|
||||
int dbg_persist_stable_polls = 0;
|
||||
bool dbg_persist_settled = false;
|
||||
GtkWidget* signal_list = nullptr;
|
||||
GtkWidget* pause_button = nullptr;
|
||||
/// When the pause began, on the **monotonic wall clock**, or 0 while running. The elapsed time
|
||||
@@ -942,6 +972,24 @@ struct App {
|
||||
bool dbg_capturing_at_disarm = false;
|
||||
int dbg_disarm_polls = 0;
|
||||
int dbg_disarm_in_capture = 0;
|
||||
/// The force probe's state: the button's stated state on both sides of the trigger toggle (a
|
||||
/// width read a frame after the state it belongs to), and the two legs of its press. See
|
||||
/// `force_probe()` in the probes' translation unit.
|
||||
int dbg_force_off_sensitive = -1;
|
||||
int dbg_force_off_visible = -1;
|
||||
int dbg_force_off_width = -1; ///< its allocation with the trigger *off*
|
||||
int dbg_force_on_sensitive = -1;
|
||||
int dbg_force_on_width = -1; ///< ... and with the trigger on: the size the user can press
|
||||
int dbg_force_arm_width = -1; ///< the Arm button beside it, so the pair's widths can be compared
|
||||
int dbg_force_label_found = -1; ///< `find_labelled_button("Force")` *is* the panel's button
|
||||
int dbg_force_leg = 0; ///< 1 while leg A is in flight, 2 for leg B
|
||||
int dbg_force_polls = 0;
|
||||
int dbg_force_capture_seen = 0; ///< the axis was read while the forced capture was filling
|
||||
double dbg_force_capture_lo = 0.0; ///< that axis: the *requested* window, before the latch
|
||||
double dbg_force_capture_hi = 0.0;
|
||||
double dbg_force_want_pre = 0.0; ///< the request the engine will cut by, at the moment of the press
|
||||
double dbg_force_want_post = 0.0;
|
||||
std::uint64_t dbg_force_latches_before = 0;
|
||||
int dbg_swaps = 0;
|
||||
int dbg_axes_w_min = 1 << 20;
|
||||
int dbg_axes_w_max = -(1 << 20);
|
||||
@@ -1764,6 +1812,16 @@ gboolean disarm_probe_step2(gpointer user);
|
||||
gboolean disarm_probe_step3(gpointer user);
|
||||
gboolean disarm_probe_wait(gpointer user);
|
||||
gboolean disarm_probe_step1(gpointer user);
|
||||
int persist_probe_depth();
|
||||
void persist_probe_report(App& a, const char* what);
|
||||
void persist_probe_set_depth(App& a, int depth);
|
||||
gboolean persist_probe_wait(gpointer user);
|
||||
gboolean persist_probe(gpointer user);
|
||||
void force_probe_report(App& a, const char* leg);
|
||||
gboolean force_probe_press(gpointer user);
|
||||
gboolean force_probe_poll(gpointer user);
|
||||
gboolean force_probe_step2(gpointer user);
|
||||
gboolean force_probe(gpointer user);
|
||||
void pause_probe_report(App& a, const char* what);
|
||||
gboolean pause_probe(gpointer user);
|
||||
gboolean export_probe(gpointer user);
|
||||
|
||||
+12
-1
@@ -22,7 +22,13 @@ LayoutState collect_layout(App& app) {
|
||||
LayoutState::Row row;
|
||||
const RowSet::Row& r = app.rows->row(i);
|
||||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
||||
row.signals.push_back(r.signal_name[static_cast<std::size_t>(s)]);
|
||||
const std::size_t si = static_cast<std::size_t>(s);
|
||||
// A persistence layer is written as an *empty* slot: it is the display's own artefact, not
|
||||
// the user's arrangement, and a name like `sine (A)` read back would be a slot the next
|
||||
// session could not resolve -- the same reason a group's extra layers are stripped before
|
||||
// they are re-derived.
|
||||
row.signals.push_back(r.layer[si] > 0 ? std::string()
|
||||
: r.signal_name[si]);
|
||||
}
|
||||
const std::vector<float>& weights = app.rows->row_weights();
|
||||
if (static_cast<std::size_t>(i) < weights.size()) row.weight = weights[static_cast<std::size_t>(i)];
|
||||
@@ -81,6 +87,7 @@ LayoutState collect_layout(App& app) {
|
||||
st.trigger.holdoff_s = tc.holdoff_s;
|
||||
st.trigger.noise_band = tc.condition.noise_band;
|
||||
st.trigger.hf_fc = tc.condition.hf_fc;
|
||||
st.trigger.persistence = tc.persist_depth;
|
||||
st.trigger.level_given = true; // written means given, so a load keeps it
|
||||
}
|
||||
// The connection is the session's too: the file's name carries only host and port.
|
||||
@@ -335,6 +342,10 @@ void apply_trigger_layout(App& app, const LayoutState::Trigger& t) {
|
||||
tc.pretrigger_pct = t.pretrigger_pct;
|
||||
tc.holdoff_s = t.holdoff_s;
|
||||
}
|
||||
// The persistence depth is not a field write like the rest: it *prunes* the retained
|
||||
// acquisitions to what the file asks for, so it goes through the one setter (which takes the lock
|
||||
// itself and clamps to the cap).
|
||||
app.model->set_persist_depth(t.persistence);
|
||||
// A file that carried a level keeps it: the panel's own default follows the channel's range, and
|
||||
// `trigger_level_touched` is what says the user has a level of their own.
|
||||
if (t.level_given) app.trigger_level_touched = true;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
#include <sys/stat.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
@@ -2802,6 +2803,186 @@ gboolean disarm_probe_step1(gpointer user) {
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
/// `UDPSCOPE_PROBE_FORCE_MS`: **Force** takes one acquisition at the present moment, with the
|
||||
/// pre/post window the panel is set to, without waiting for the condition to hold.
|
||||
///
|
||||
/// The two legs are the two things the button has to get right, and they need different geometry:
|
||||
///
|
||||
/// - **Leg A** presses it where the ring *can* satisfy the request -- a 2 s window at 50 % pre asks
|
||||
/// for 1 s of history before the edge and 1 s after it, and the probe has let the ring fill past
|
||||
/// that -- so the whole ordinary path is asserted: a latch count that moves by exactly one,
|
||||
/// `display=event(frozen)`, and a snapshot spanning what was asked for.
|
||||
/// - **Leg B** presses it where the ring *cannot*: a 100 s window at 99 % pre asks for 99 s of
|
||||
/// history, and no run this short has it. That is the only state in which "pinned to the
|
||||
/// snapshot, not to the request" is a reading rather than a coincidence, and the axis must come
|
||||
/// out at the snapshot's own `[-pre_got..+post_got]`, a few seconds wide, not at the requested
|
||||
/// hundred.
|
||||
///
|
||||
/// The condition is set to a level the mock never reaches (9 V against +/-1 V signals), so the
|
||||
/// engine cannot latch on its own and every latch the probe sees is the force's -- which is also
|
||||
/// what makes the request *unmet* rather than merely untested: without the button, nothing would be
|
||||
/// captured at all. The `during=` reading is the axis while the forced capture was filling: the
|
||||
/// *requested* window, which is what a capture is framed on before the latch re-pins it.
|
||||
///
|
||||
/// Run it on its own (the trigger must start off, which is the state whose button reading is half
|
||||
/// the point) against the plain mock, with enough time for both legs: `UDPSCOPE_PROBE_FORCE_MS=3000
|
||||
/// UDPSCOPE_EXIT_AFTER_MS=14000`.
|
||||
void force_probe_report(App& a, const char* leg) {
|
||||
const DisplayMode d = a.model->display_mode();
|
||||
auto snap = a.model->event();
|
||||
const RowSet::Row& r = a.rows->row(0);
|
||||
const plot::Range axis = r.plot != nullptr ? r.plot->x_range(r.axis_x) : plot::Range{0.0, 0.0};
|
||||
// `pre_s`/`post_s` are the snapshot's own split as *magnitudes* (`[-pre_s..+post_s]` is the window
|
||||
// it holds), which is the same pair the frame tick states the pin from -- so the axis must equal
|
||||
// `[-pre_s, +post_s]`.
|
||||
const double got_pre = snap ? snap->pre_s : 0.0;
|
||||
const double got_post = snap ? snap->post_s : 0.0;
|
||||
const bool pin_is_snapshot = snap != nullptr &&
|
||||
std::abs(double(axis.min) + got_pre) < 1.0e-3 &&
|
||||
std::abs(double(axis.max) - got_post) < 1.0e-3;
|
||||
// "What was asked for" is satisfied to within a stored interval (the cut's ends are samples), and
|
||||
// leg B *must* fail it: the request is what the ring could not reach back to.
|
||||
const bool matches_request = std::abs(got_pre - a.dbg_force_want_pre) < 0.01 &&
|
||||
std::abs(got_post - a.dbg_force_want_post) < 0.01;
|
||||
std::fprintf(stderr,
|
||||
"[force-probe] leg %s want=[-%.4f..%.4f] during=[%.4f..%.4f] snap=[-%.4f..%.4f] "
|
||||
"n=%llu axis=[%.4f..%.4f] latches=%llu->%llu display=%s matches_request=%d "
|
||||
"pin_is_snapshot=%d\n",
|
||||
leg, a.dbg_force_want_pre, a.dbg_force_want_post, a.dbg_force_capture_lo,
|
||||
a.dbg_force_capture_hi, got_pre, got_post,
|
||||
static_cast<unsigned long long>(snap ? snap->stamps.size() : 0), double(axis.min),
|
||||
double(axis.max),
|
||||
static_cast<unsigned long long>(a.dbg_force_latches_before),
|
||||
static_cast<unsigned long long>(a.model->trigger_latches().load()),
|
||||
d == DisplayMode::Held ? "event(frozen)"
|
||||
: d == DisplayMode::Live ? "live"
|
||||
: d == DisplayMode::Capturing ? "live(capturing)"
|
||||
: "nothing",
|
||||
int(matches_request), int(pin_is_snapshot));
|
||||
std::fflush(stderr);
|
||||
}
|
||||
|
||||
gboolean force_probe_poll(gpointer user) {
|
||||
App* a = static_cast<App*>(user);
|
||||
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||
++a->dbg_force_polls;
|
||||
if (a->model->trigger_pending() && !a->dbg_force_capture_seen) {
|
||||
// The sweep is filling: the axis is the *requested* window now (a capture is framed on what was
|
||||
// asked for, there being no snapshot to measure yet), and this is the one moment it is readable.
|
||||
a->dbg_force_capture_seen = 1;
|
||||
const RowSet::Row& r = a->rows->row(0);
|
||||
const plot::Range ax = r.plot != nullptr ? r.plot->x_range(r.axis_x) : plot::Range{0.0, 0.0};
|
||||
a->dbg_force_capture_lo = ax.min;
|
||||
a->dbg_force_capture_hi = ax.max;
|
||||
}
|
||||
const std::uint64_t latches = a->model->trigger_latches().load();
|
||||
if (latches <= a->dbg_force_latches_before && a->dbg_force_polls < 200) return G_SOURCE_CONTINUE;
|
||||
if (latches <= a->dbg_force_latches_before) {
|
||||
std::fprintf(stderr, "[force-probe] no latch within 10 s of the force (leg %d)\n",
|
||||
a->dbg_force_leg);
|
||||
std::fflush(stderr);
|
||||
}
|
||||
force_probe_report(*a, a->dbg_force_leg == 1 ? "A" : "B");
|
||||
a->dbg_force_polls = 0;
|
||||
if (a->dbg_force_leg == 1) {
|
||||
// Leg B's geometry: a window the ring cannot cover back to the edge, so the cut is clamped and
|
||||
// the pin has to come from the snapshot rather than from the ask. 99 % is the panel's ceiling
|
||||
// (a trigger with no post side at all is not a capture), which leaves 1 s of post window -- the
|
||||
// leg's own latch delay, and the reason the press is a step of its own.
|
||||
a->dbg_force_leg = 2;
|
||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_pre_spin), 99.0);
|
||||
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->window_unit_drop), 2);
|
||||
gtk_editable_set_text(GTK_EDITABLE(a->window_entry), "100");
|
||||
on_window_entry_activate(GTK_ENTRY(a->window_entry), a);
|
||||
g_timeout_add(400, force_probe_press, a);
|
||||
}
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
gboolean force_probe_press(gpointer user) {
|
||||
App* a = static_cast<App*>(user);
|
||||
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||
a->dbg_force_latches_before = a->model->trigger_latches().load();
|
||||
a->dbg_force_polls = 0;
|
||||
a->dbg_force_capture_seen = 0;
|
||||
a->dbg_force_capture_lo = 0.0;
|
||||
a->dbg_force_capture_hi = 0.0;
|
||||
{
|
||||
// The request the engine will cut by, taken from the producer's own expression rather than
|
||||
// recomputed here, so the reading compares the snapshot against what was actually asked for.
|
||||
std::lock_guard<std::mutex> lock(a->model->mutex());
|
||||
const double span = std::max(1.0e-3, a->model->event_span());
|
||||
a->dbg_force_want_post = a->model->trigger().post_span_s(span);
|
||||
a->dbg_force_want_pre = span - a->dbg_force_want_post;
|
||||
}
|
||||
// The button's own click, as every other probe drives a control. `label_found` was checked in
|
||||
// step 2: this is the widget the window would press.
|
||||
if (a->force_button != nullptr) g_signal_emit_by_name(a->force_button, "clicked");
|
||||
g_timeout_add(50, force_probe_poll, a);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
gboolean force_probe_step2(gpointer user) {
|
||||
App* a = static_cast<App*>(user);
|
||||
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||
// The trigger-off state, one frame after step 1 forced it: the *sensitivity* is the rule
|
||||
// `sync_arm_buttons()` states (a trigger that is off has no engine to force), the *width* is the
|
||||
// allocation the layout gave the button, and a width read in the tick it was last stated is 0 --
|
||||
// the trap the compact arm control fell into. `label_found` is the same reading at the user's
|
||||
// level: the button the window shows under the word "Force" is this one.
|
||||
a->dbg_force_off_sensitive =
|
||||
a->force_button != nullptr && gtk_widget_get_sensitive(a->force_button) ? 1 : 0;
|
||||
a->dbg_force_off_visible =
|
||||
a->force_button != nullptr && gtk_widget_is_visible(a->force_button) ? 1 : 0;
|
||||
a->dbg_force_off_width = a->force_button != nullptr ? gtk_widget_get_width(a->force_button) : -1;
|
||||
a->dbg_force_arm_width = a->arm_button != nullptr ? gtk_widget_get_width(a->arm_button) : -1;
|
||||
a->dbg_force_label_found =
|
||||
a->force_button != nullptr && find_labelled_button(a->window, "Force") == a->force_button ? 1
|
||||
: 0;
|
||||
// The geometry both legs start from, set through the real widgets: a level no mock signal
|
||||
// reaches, so nothing latches on its own (every latch the probe sees is then the force's, which
|
||||
// is also what makes the request *unmet* rather than merely untested), and a 2 s window at 50 %
|
||||
// pre, which leg A's ring can fill. The window write may re-plan the buffer and clear the
|
||||
// history, which is why the press is 2.5 s later rather than in this tick.
|
||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_level_spin), 9.0);
|
||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_pre_spin), 50.0);
|
||||
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->window_unit_drop), 2);
|
||||
gtk_editable_set_text(GTK_EDITABLE(a->window_entry), "2");
|
||||
on_window_entry_activate(GTK_ENTRY(a->window_entry), a);
|
||||
// A signal has to be *acquired* for the trigger to have a channel at all (the panel lists the
|
||||
// acquired ones, and on a fresh source none is switched on): the mock's `sine`, which with the
|
||||
// level above is a trigger nothing crosses. Set after the level, which is what marks it
|
||||
// hand-edited -- a rebuild re-defaults an untouched level to the middle of the declared range.
|
||||
a->model->set_signal_enabled(1, true);
|
||||
if (a->trigger_toggle != nullptr)
|
||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
|
||||
a->dbg_force_on_sensitive =
|
||||
a->force_button != nullptr && gtk_widget_get_sensitive(a->force_button) ? 1 : 0;
|
||||
a->dbg_force_on_width = a->force_button != nullptr ? gtk_widget_get_width(a->force_button) : -1;
|
||||
std::fprintf(stderr,
|
||||
"[force-probe] button off: sensitive=%d visible=%d w=%d | on: sensitive=%d w=%d | "
|
||||
"arm w=%d | label_found=%d\n",
|
||||
a->dbg_force_off_sensitive, a->dbg_force_off_visible, a->dbg_force_off_width,
|
||||
a->dbg_force_on_sensitive, a->dbg_force_on_width, a->dbg_force_arm_width,
|
||||
a->dbg_force_label_found);
|
||||
std::fflush(stderr);
|
||||
a->dbg_force_leg = 1;
|
||||
g_timeout_add(2500, force_probe_press, a);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
gboolean force_probe(gpointer user) {
|
||||
App* a = static_cast<App*>(user);
|
||||
if (a->window == nullptr || a->trigger_toggle == nullptr) return G_SOURCE_REMOVE;
|
||||
// Trigger mode *off* first, driven through the toggle as a user would: the state whose reading
|
||||
// step 2 takes, and a state this probe states rather than assumes -- a session file remembers the
|
||||
// trigger, so a second run against the same source would otherwise start armed and never read the
|
||||
// off case at all.
|
||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), FALSE);
|
||||
g_timeout_add(300, force_probe_step2, a);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
void pause_probe_report(App& a, const char* what) {
|
||||
double bounds_lo = 0.0;
|
||||
double bounds_hi = 0.0;
|
||||
@@ -5651,5 +5832,234 @@ gboolean tour_surface_probe_step(gpointer user) {
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
// --- persistence (UDPSCOPE_PROBE_PERSIST_MS) ---------------------------------
|
||||
//
|
||||
// The phosphor display: the last N acquisitions drawn behind the current one, dimmer with age, with
|
||||
// `N = 1` -- the default -- drawn exactly like the display without the feature. One leg, and the
|
||||
// depth comes from the environment, because the *reading* is a pair of runs: `..._PERSIST_DEPTH=4`
|
||||
// must show the layers at the right brightnesses and ages with the framing still the current event's,
|
||||
// and the same command at `=1` must show none of them, the same framing, and nothing retained -- and
|
||||
// the second of those is also the baseline the exit dump's `frame cost:` line is read against.
|
||||
|
||||
/// The depth the first leg asks for: `UDPSCOPE_PROBE_PERSIST_DEPTH`, default 4, clamped to the
|
||||
/// panel's own range. **Set it to 1 and the same run is the unpersisted display**, which is the
|
||||
/// baseline the exit dump's `frame cost:` line has to be read against.
|
||||
int persist_probe_depth() {
|
||||
const char* env = g_getenv("UDPSCOPE_PROBE_PERSIST_DEPTH");
|
||||
const int n = env != nullptr ? std::atoi(env) : 4;
|
||||
return std::clamp(n, 1, kMaxPersistDepth);
|
||||
}
|
||||
|
||||
/// The event window the probe arms with: `UDPSCOPE_PROBE_PERSIST_WINDOW` seconds, default 0.5. A
|
||||
/// *long* window on a fast stream is how the **memory** bound is reached -- one retained acquisition
|
||||
/// is a whole window copied out of the ring -- so the run that has to show `dropped` and a `kept`
|
||||
/// below the depth is `=2` against the fast mock.
|
||||
double persist_probe_window() {
|
||||
const char* env = g_getenv("UDPSCOPE_PROBE_PERSIST_WINDOW");
|
||||
const double s = env != nullptr ? std::atof(env) : 0.5;
|
||||
return s > 0.0 ? s : 0.5;
|
||||
}
|
||||
|
||||
/// One leg's reading: what the model retained, what the rows drew, and whether the framing is still
|
||||
/// the event on screen and nothing else.
|
||||
void persist_probe_report(App& a, const char* what) {
|
||||
int depth = 1;
|
||||
int snap_n = 0;
|
||||
double snap_lo = 0.0;
|
||||
double snap_hi = 0.0;
|
||||
std::size_t kept = 0;
|
||||
std::size_t bytes = 0;
|
||||
std::size_t budget = 0;
|
||||
std::size_t signals = 0;
|
||||
std::size_t channels = 0;
|
||||
unsigned long long dropped = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(a.model->mutex());
|
||||
depth = a.model->persist_depth_locked();
|
||||
kept = a.model->persist_count();
|
||||
bytes = a.model->persist_bytes();
|
||||
budget = a.model->persist_budget_bytes();
|
||||
dropped = static_cast<unsigned long long>(a.model->persist_dropped());
|
||||
signals = a.model->signals().size();
|
||||
channels = a.model->channels().size();
|
||||
if (const std::shared_ptr<const EventSnapshot> snap = a.model->event()) {
|
||||
snap_lo = -snap->pre_s;
|
||||
snap_hi = snap->post_s;
|
||||
snap_n = static_cast<int>(snap->stamps.size());
|
||||
}
|
||||
}
|
||||
// The decay schedule in use, oldest first: the reading's point is that the numbers descend by half
|
||||
// and that the *oldest* is the dimmest.
|
||||
const std::vector<std::pair<int, float>> layers = a.rows->row_layers(0);
|
||||
std::fprintf(stderr,
|
||||
"[persist-probe] %s depth=%d kept=%zu bytes=%zu budget=%zu dropped=%llu signals=%zu "
|
||||
"channels=%zu layers=%d want=%d placed=%d alphas=[",
|
||||
what, depth, kept, bytes, budget, dropped, signals, channels, a.rows->layer_slots(),
|
||||
a.persist_want, a.persist_placed);
|
||||
for (std::size_t i = 0; i < layers.size(); ++i) {
|
||||
std::fprintf(stderr, "%s%.4f", i != 0 ? "," : "", static_cast<double>(layers[i].second));
|
||||
}
|
||||
std::fprintf(stderr, "] ages=[");
|
||||
for (std::size_t i = 0; i < layers.size(); ++i) {
|
||||
std::fprintf(stderr, "%s%d", i != 0 ? "," : "", layers[i].first);
|
||||
}
|
||||
std::fprintf(stderr, "] row0=[");
|
||||
const RowSet::Row& r0 = a.rows->row(0);
|
||||
bool first = true;
|
||||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
||||
const std::size_t si = static_cast<std::size_t>(s);
|
||||
if (r0.layer[si] == 0) continue;
|
||||
std::fprintf(stderr, "%s%d:'%s'@%.4f", first ? "" : " ", s, r0.signal_name[si].c_str(),
|
||||
static_cast<double>(r0.slot_alpha[si]));
|
||||
first = false;
|
||||
}
|
||||
// How many layer slots are actually *bound* to a retained acquisition. It is not the same number
|
||||
// as `layers=`/`placed=`: a slot can be planned for an age the retention budget refused, and it
|
||||
// then draws nothing at all -- so the two pairs together say which of the two bounds bit.
|
||||
int drawn = 0;
|
||||
for (int r = 0; r < a.rows->row_count(); ++r) {
|
||||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
||||
const RowSet::Row& row_state = a.rows->row(r);
|
||||
const std::size_t si = static_cast<std::size_t>(s);
|
||||
if (row_state.layer[si] == 0) continue;
|
||||
if (row_state.slots[si].bound_event() != nullptr) ++drawn;
|
||||
}
|
||||
}
|
||||
std::fprintf(stderr, "] drawn=%d\n", drawn);
|
||||
// The framing. `ev` is what a held event's pin is re-stated from (`event_data_window()`), and it
|
||||
// must be the *snapshot's* own window: layers are bound event series on the same rows, so a union
|
||||
// that had grown with them reads here as `ev_is_snap=0`.
|
||||
const plot::Range axis = a.rows->view_range();
|
||||
const plot::Range pin = a.rows->pinned_range();
|
||||
double ev_lo = 0.0;
|
||||
double ev_hi = 0.0;
|
||||
const bool have_ev = a.rows->event_data_window(ev_lo, ev_hi);
|
||||
const bool ev_is_snap =
|
||||
have_ev && std::abs(ev_lo - snap_lo) < 1.0e-6 && std::abs(ev_hi - snap_hi) < 1.0e-6;
|
||||
const bool pin_is_snap = a.rows->window_pinned() && std::abs(pin.min - snap_lo) < 1.0e-6 &&
|
||||
std::abs(pin.max - snap_hi) < 1.0e-6;
|
||||
std::fprintf(stderr,
|
||||
"[persist-probe] %s axis=[%.4f..%.4f] pin=[%.4f..%.4f] snap=[%.4f..%.4f] n=%d "
|
||||
"ev=[%.4f..%.4f] ev_is_snap=%d pin_is_snap=%d\n",
|
||||
what, double(axis.min), double(axis.max), double(pin.min), double(pin.max), snap_lo,
|
||||
snap_hi, snap_n, ev_lo, ev_hi, ev_is_snap ? 1 : 0, pin_is_snap ? 1 : 0);
|
||||
std::fflush(stderr);
|
||||
}
|
||||
|
||||
/// Sets the panel's own depth control and publishes it: the *widget* is the path a user takes, so the
|
||||
/// probe exercises the handler that reads it and not the model setter.
|
||||
void persist_probe_set_depth(App& a, int depth) {
|
||||
if (a.trigger_persist_spin != nullptr) {
|
||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a.trigger_persist_spin), double(depth));
|
||||
}
|
||||
publish_trigger_fields(&a);
|
||||
}
|
||||
|
||||
/// Step 1: enable the signals **itself** -- one call, the way a restored session does it, and
|
||||
/// deliberately not `UDPSCOPE_ENABLE_ALL_MS`: that probe ends by switching one signal off two seconds
|
||||
/// later, so a run that relied on it would see its first row's signal leave the plots (and its base
|
||||
/// slot go with it) in the middle of the measurement. Then one signal per row -- the arrangement the
|
||||
/// app itself gives an opened file, and the one with room for every age (a row of four signals has one
|
||||
/// free slot per signal, a row of one has seven) -- the depth through the panel's own spin, and the
|
||||
/// trigger armed on the mock's `square`, which crosses its level several times a second so that several
|
||||
/// acquisitions land in a couple of seconds.
|
||||
gboolean persist_probe(gpointer user) {
|
||||
App* a = static_cast<App*>(user);
|
||||
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||
{
|
||||
const std::vector<SignalInfo>& sigs = a->model->signals();
|
||||
std::vector<std::size_t> all(sigs.size());
|
||||
for (std::size_t i = 0; i < all.size(); ++i) all[i] = i;
|
||||
if (!all.empty()) a->model->set_signals_enabled(all, true);
|
||||
}
|
||||
const std::vector<ChannelInfo>& channels = a->model->channels();
|
||||
const std::vector<SignalInfo>& signals = a->model->signals();
|
||||
const int rows = std::min<int>(RowSet::kMaxRows, static_cast<int>(channels.size()));
|
||||
if (rows > 0) {
|
||||
a->rows->set_row_count(rows);
|
||||
for (int r = 0; r < rows; ++r) {
|
||||
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) a->rows->assign(r, s, "", "", 0);
|
||||
a->rows->assign(r, 0, channels[static_cast<std::size_t>(r)].name,
|
||||
channels[static_cast<std::size_t>(r)].name,
|
||||
signal_color_index(signals, channels[static_cast<std::size_t>(r)].signal));
|
||||
}
|
||||
a->relayout_pending = true;
|
||||
}
|
||||
a->dbg_persist_depth = persist_probe_depth();
|
||||
persist_probe_set_depth(*a, a->dbg_persist_depth);
|
||||
int sel = -1;
|
||||
for (std::size_t i = 0; i < channels.size(); ++i) {
|
||||
if (channels[i].name == "square") sel = static_cast<int>(i);
|
||||
}
|
||||
if (sel >= 0) {
|
||||
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_signal_drop), static_cast<guint>(sel));
|
||||
}
|
||||
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_type_drop), 0);
|
||||
gtk_drop_down_set_selected(GTK_DROP_DOWN(a->trigger_edge_drop), 0);
|
||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_level_spin), 0.0);
|
||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_holdoff_spin), 0.0);
|
||||
const double window_s = persist_probe_window();
|
||||
apply_time_window(*a, window_s);
|
||||
publish_trigger_fields(a);
|
||||
std::fprintf(stderr, "[persist-probe] %d row(s), one signal each, depth=%d, window=%.3f s, arming on '%s'\n",
|
||||
rows, a->dbg_persist_depth, window_s,
|
||||
sel >= 0 ? channels[static_cast<std::size_t>(sel)].name.c_str() : "(none)");
|
||||
std::fflush(stderr);
|
||||
if (a->trigger_toggle != nullptr) {
|
||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
|
||||
}
|
||||
a->dbg_persist_polls = 0;
|
||||
g_timeout_add(25, persist_probe_wait, a);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
/// Waits for the retention to be *full*: an event on screen and as many older ones kept as the depth
|
||||
/// asks for. The trigger's cadence is the stream's, so a fixed delay would report whatever happened
|
||||
/// to have latched; the 15 s cap is for a stream that never latches, and the probe still reports.
|
||||
///
|
||||
/// A full set is not always *reachable*: the retention budget can keep fewer acquisitions than the
|
||||
/// depth asks for, and then the condition above can never come true -- which is the state the memory
|
||||
/// bound is read in, so waiting for it would report nothing at all. The wait therefore also ends when
|
||||
/// the set has **settled** (nothing new retained for two seconds) with something in it: that is the
|
||||
/// budget speaking, and the report's `kept`/`dropped`/`drawn` are what say so.
|
||||
///
|
||||
/// The depth is **left where the first leg put it**: the run's own tail is then the display at that
|
||||
/// depth, which is what the exit dump's `frame cost:` line is a mean over -- and the run to read it
|
||||
/// against is the same command with `UDPSCOPE_PROBE_PERSIST_DEPTH=1`, whose two legs are both the
|
||||
/// display without the feature. Reading it in one run that turns the depth off partway would have
|
||||
/// meant comparing two means over different displays.
|
||||
gboolean persist_probe_wait(gpointer user) {
|
||||
App* a = static_cast<App*>(user);
|
||||
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||
++a->dbg_persist_polls;
|
||||
const bool held = a->model->display_mode() == DisplayMode::Held;
|
||||
const std::size_t kept = a->model->persist_count();
|
||||
const std::size_t want = static_cast<std::size_t>(std::max(0, a->dbg_persist_depth - 1));
|
||||
if (kept != a->dbg_persist_last_kept) {
|
||||
a->dbg_persist_last_kept = kept;
|
||||
a->dbg_persist_stable_polls = 0;
|
||||
} else if (++a->dbg_persist_stable_polls > 160) { // 4 s at 25 ms a poll
|
||||
a->dbg_persist_settled = true;
|
||||
}
|
||||
const bool full = kept >= want;
|
||||
// ... and the rows must already be *bound* to it: the report reads what the frame bound, and the
|
||||
// mode can flip to `Held` between two ticks (a latch lands mid-frame), so a reading taken on the
|
||||
// tick boundary can describe a display whose bindings are still the live ring's. `Held`, bound and
|
||||
// full -- the state every reading below is about -- is what the wait is for.
|
||||
double lo = 0.0;
|
||||
double hi = 0.0;
|
||||
const bool bound = a->rows->event_data_window(lo, hi);
|
||||
if ((held && bound && (full || (a->dbg_persist_settled && kept > 0))) ||
|
||||
a->dbg_persist_polls > 600) {
|
||||
if (!full) {
|
||||
std::fprintf(stderr, "[persist-probe] settled: kept=%zu of %zu after %d polls\n", kept, want,
|
||||
a->dbg_persist_polls);
|
||||
}
|
||||
persist_probe_report(*a, "held");
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
} // namespace ui_impl
|
||||
} // namespace udpscope
|
||||
+131
-3
@@ -5,6 +5,8 @@
|
||||
|
||||
#include "ui_app.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
@@ -22,6 +24,13 @@ namespace ui_impl {
|
||||
/// the model lock. Visibility is `enabled && locked`: with trigger mode off the line's own text says
|
||||
/// "stream (trigger off)" and there is no engine to arm, and with the pane open the body's button is the
|
||||
/// one on screen -- never both at once.
|
||||
///
|
||||
/// It states the Force button too, from the same `enabled`, and that is the *only* place either
|
||||
/// button's availability is decided: a trigger that is off has no engine to force, and one that is
|
||||
/// on can be forced whether or not it is armed -- in Single mode the previous acquisition leaves the
|
||||
/// engine disarmed, which is exactly when a user reaches for Force again, so a Force that grays out
|
||||
/// with the engine's armed state would be unavailable for the one thing it is for. Force carries no
|
||||
/// state of its own, so nothing else about it can drift.
|
||||
void sync_arm_buttons(App& app) {
|
||||
const bool armed = app.model->trigger_armed().load(std::memory_order_relaxed);
|
||||
const bool enabled =
|
||||
@@ -32,6 +41,9 @@ void sync_arm_buttons(App& app) {
|
||||
gtk_button_set_label(GTK_BUTTON(app.arm_button), label);
|
||||
gtk_widget_set_sensitive(app.arm_button, enabled);
|
||||
}
|
||||
if (app.force_button != nullptr) {
|
||||
gtk_widget_set_sensitive(app.force_button, enabled);
|
||||
}
|
||||
if (app.trigger_summary_arm != nullptr) {
|
||||
gtk_button_set_label(GTK_BUTTON(app.trigger_summary_arm), label);
|
||||
gtk_widget_set_sensitive(app.trigger_summary_arm, enabled);
|
||||
@@ -39,11 +51,28 @@ void sync_arm_buttons(App& app) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The **signal** the trigger's channel carries, or -1 when the trigger has no channel at all.
|
||||
///
|
||||
/// `TriggerConfig::channel` is an index into `ScopeModel::channels()` -- the *acquired* ones, which
|
||||
/// `rebuild_channels()` renumbers -- while `ScopeModel::set_signal_enabled()` indexes the stream's
|
||||
/// `signals()`. Passing one for the other is not a near miss: with one signal acquired, channel 0 is
|
||||
/// that signal while signal 0 is the stream's *clock*, so arming switched on the time base and left
|
||||
/// the signal the trigger watches exactly as it was -- and a signal the panel was not showing *is* a
|
||||
/// change, so the click also rebuilt the buffer and dropped its history, which is the pre-trigger
|
||||
/// window the arming (or the force) was about to capture. The two indices agree only in the one case
|
||||
/// where every signal is acquired in order.
|
||||
int trigger_signal_index(App& app) {
|
||||
std::lock_guard<std::mutex> lock(app.model->mutex());
|
||||
const int ch = app.model->trigger().channel;
|
||||
const std::vector<ChannelInfo>& channels = app.model->channels();
|
||||
if (ch < 0 || ch >= static_cast<int>(channels.size())) return -1;
|
||||
return static_cast<int>(channels[static_cast<std::size_t>(ch)].signal);
|
||||
}
|
||||
|
||||
void on_arm_clicked(GtkButton* button, gpointer user) {
|
||||
App* app = static_cast<App*>(user);
|
||||
(void)button;
|
||||
bool armed = false;
|
||||
int signal = -1;
|
||||
{
|
||||
// The producer reads (and, on a single-shot latch, writes) these fields under
|
||||
// the same lock; an unguarded read-modify-write here can lose its update.
|
||||
@@ -51,7 +80,6 @@ void on_arm_clicked(GtkButton* button, gpointer user) {
|
||||
TriggerConfig& tc = app->model->trigger();
|
||||
tc.armed = !tc.armed;
|
||||
armed = tc.armed;
|
||||
signal = tc.channel;
|
||||
}
|
||||
if (armed) {
|
||||
app->event_held = false;
|
||||
@@ -61,10 +89,67 @@ void on_arm_clicked(GtkButton* button, gpointer user) {
|
||||
// Arming is a statement that this signal should be watched, so its acquisition
|
||||
// is switched on with it -- otherwise the trigger could never see a sample.
|
||||
// Outside the lock: set_signal_enabled() takes it.
|
||||
const int signal = trigger_signal_index(*app);
|
||||
if (armed && signal >= 0) app->model->set_signal_enabled(static_cast<std::size_t>(signal), true);
|
||||
sync_arm_buttons(*app);
|
||||
}
|
||||
|
||||
/// **Force**: one acquisition, now, with the pre/post window the panel is set to -- what a scope's
|
||||
/// Force button does when the condition is not coming, and the reason it does not wait for one.
|
||||
///
|
||||
/// It states no state of its own. The capture it opens goes through the engine's ordinary latch,
|
||||
/// which is what cuts the window, publishes the snapshot, counts it and applies the mode's re-arm
|
||||
/// rule, so the two buttons above read afterwards exactly as that latch left them: still "Disarm" in
|
||||
/// Normal mode (the trigger goes back to waiting for the next edge), and "Arm" in Single mode, where
|
||||
/// one acquisition is the whole of what the mode means whether an edge produced it or this click.
|
||||
void on_force_clicked(GtkButton* button, gpointer user) {
|
||||
App* app = static_cast<App*>(user);
|
||||
(void)button;
|
||||
// The signal the trigger watches, resolved from its channel -- `set_signal_enabled()` indexes
|
||||
// `signals()` while `TriggerConfig::channel` indexes `channels()` (see the helper) -- and refused
|
||||
// up front when there is none, see the refusal below.
|
||||
const int signal = trigger_signal_index(*app);
|
||||
if (signal < 0) {
|
||||
// The trigger is pointed at nothing -- no signal is being acquired, which the panel's own
|
||||
// dropdown says ("(no acquired channels)") -- so there is no channel to take an acquisition
|
||||
// from. Refused here and not left to the producer, which never looks at a frame at all for a
|
||||
// model with no *streamed* channel: a request made in this state would sit behind that early
|
||||
// return and fire on the first signal switched on afterwards, which is an acquisition the user
|
||||
// asked for minutes ago.
|
||||
std::fprintf(stderr,
|
||||
"[udpscope] trigger: force refused -- no signal is being acquired to trigger on; "
|
||||
"switch one on in the signal list\n");
|
||||
std::fflush(stderr);
|
||||
return;
|
||||
}
|
||||
// A force is an instruction to acquire the signal the trigger watches, so its acquisition is
|
||||
// switched on with it -- exactly as arming switches it on, and *before* the request below: the
|
||||
// enable rebuilds the channel list (which is what gives the trigger's signal a channel at all),
|
||||
// and a packet landing between the two would otherwise consume the request on a trigger whose
|
||||
// channel is still -1, and drop it. It is a no-op when the signal is already acquired, which is
|
||||
// what keeps a force from clearing the very history it is about to capture.
|
||||
app->model->set_signal_enabled(static_cast<std::size_t>(signal), true);
|
||||
{
|
||||
// The model lock, for both writes and together: the producer holds it for a whole packet --
|
||||
// reading the panel's `armed` at the top of one and consuming the request at the bottom -- so a
|
||||
// request written outside it could be read by a packet whose engine was still disarmed, and is
|
||||
// then dropped rather than replayed.
|
||||
std::lock_guard<std::mutex> lock(app->model->mutex());
|
||||
TriggerConfig& tc = app->model->trigger();
|
||||
// A disarmed engine has nothing to acquire with. In Single mode the previous acquisition left it
|
||||
// disarmed, and "capture now" is the whole of what a user means by pressing Force there, so the
|
||||
// click arms it -- the same field the arm button writes, which the producer's next packet turns
|
||||
// into an armed engine and a new latch count.
|
||||
tc.armed = true;
|
||||
if (app->acq != nullptr) app->acq->request_force();
|
||||
}
|
||||
// Nothing is cleared here on purpose: the held event stays on screen until the coming capture is
|
||||
// bound, one packet later, rather than flashing back to live in between. The frame tick clears it
|
||||
// itself when it sees the capture open (`new_capture`), which is also where the axis is pinned to
|
||||
// the requested window for the sweep.
|
||||
sync_arm_buttons(*app);
|
||||
}
|
||||
|
||||
/// The trigger type dropdown's order, as a table rather than a cast: the dropdown lists
|
||||
/// only the conditions that are implemented, so its index is not `TriggerType`'s value.
|
||||
/// The trigger's four enumerations as words, and back. These are the panel's dropdown labels *and*
|
||||
@@ -156,6 +241,13 @@ void write_trigger_widgets(App& app) {
|
||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_timeout_spin), tc.condition.timeout_s);
|
||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_band_spin), tc.condition.noise_band);
|
||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_hf_spin), tc.condition.hf_fc);
|
||||
// Clamped here as well as in the setter: a session file may carry any number, and the spin's own
|
||||
// range would refuse one outside it.
|
||||
if (app.trigger_persist_spin != nullptr) {
|
||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_persist_spin),
|
||||
static_cast<double>(
|
||||
std::clamp(tc.persist_depth, 1, kMaxPersistDepth)));
|
||||
}
|
||||
gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_type_drop),
|
||||
dropdown_index_of(app.trigger_type_drop,
|
||||
trigger_type_text(tc.condition.type)));
|
||||
@@ -299,6 +391,8 @@ void publish_trigger_fields(App* app) {
|
||||
const double timeout = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_timeout_spin));
|
||||
const double band = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_band_spin));
|
||||
const double hf = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_hf_spin));
|
||||
const int persist =
|
||||
static_cast<int>(std::lround(gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_persist_spin))));
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(app->model->mutex());
|
||||
TriggerConfig& tc = app->model->trigger();
|
||||
@@ -318,6 +412,11 @@ void publish_trigger_fields(App* app) {
|
||||
tc.condition.noise_band = band;
|
||||
tc.condition.hf_fc = hf;
|
||||
}
|
||||
// The persistence depth is applied through the one setter rather than written into the config
|
||||
// here: it is clamped to the cap *and* it prunes the retained acquisitions at once, so lowering
|
||||
// it while the trigger is quiet -- when no latch will come along to apply it -- still frees the
|
||||
// memory. It takes the model lock itself, hence outside the block above.
|
||||
app->model->set_persist_depth(persist);
|
||||
}
|
||||
|
||||
/// Shows only the rows the selected condition reads, so the panel never offers a figure the
|
||||
@@ -572,15 +671,44 @@ GtkWidget* build_trigger_panel(App& app) {
|
||||
app.trigger_record_row = record_row;
|
||||
}
|
||||
|
||||
// Persistence: how many of the most recent triggered acquisitions stay on screen at once, each
|
||||
// older one drawn at half the brightness of the one before it. 1 is the event alone, which is the
|
||||
// display without the feature -- so there is no separate switch for it, and its default is what
|
||||
// every run had before it existed.
|
||||
int persist_seed = 1;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(app.model->mutex());
|
||||
persist_seed = app.model->persist_depth_locked();
|
||||
}
|
||||
app.trigger_persist_spin = spin(1.0, static_cast<double>(kMaxPersistDepth), 1.0, 0,
|
||||
static_cast<double>(persist_seed));
|
||||
gtk_widget_set_tooltip_text(
|
||||
app.trigger_persist_spin,
|
||||
"how many of the most recent triggered acquisitions stay on screen, each older one at half "
|
||||
"the previous one's brightness; 1 is the event alone. A layer needs a free trace slot in the "
|
||||
"row that plots its signal, and the retained acquisitions are also bounded by the buffer's "
|
||||
"own size");
|
||||
app.trigger_persist_row = row_for(ui, "persistence_row", app.trigger_persist_spin);
|
||||
|
||||
app.arm_button = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_arm"));
|
||||
if (app.arm_button != nullptr) {
|
||||
g_signal_connect(app.arm_button, "clicked", G_CALLBACK(on_arm_clicked), &app);
|
||||
}
|
||||
// Beside the arm button, and in the *body* rather than in the compact heading with it: the heading's
|
||||
// one control is the runtime *state* a locked pane exists to change, while a force is an action
|
||||
// whose subject -- the condition, the level, the pre/post split and the mode that decides whether
|
||||
// one acquisition is all of it -- is in the body. A second Force in the heading would be a second
|
||||
// control for one action whose settings are off screen, and the heading already carries [edit],
|
||||
// Arm/Disarm and the mode switch.
|
||||
app.force_button = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_force"));
|
||||
if (app.force_button != nullptr) {
|
||||
g_signal_connect(app.force_button, "clicked", G_CALLBACK(on_force_clicked), &app);
|
||||
}
|
||||
|
||||
for (GtkWidget* w : {app.trigger_level_spin, app.trigger_pre_spin, app.trigger_holdoff_spin,
|
||||
app.trigger_width_spin, app.trigger_width2_spin,
|
||||
app.trigger_level2_spin, app.trigger_timeout_spin, app.trigger_band_spin,
|
||||
app.trigger_hf_spin}) {
|
||||
app.trigger_hf_spin, app.trigger_persist_spin}) {
|
||||
g_signal_connect(w, "value-changed", G_CALLBACK(on_trigger_field_changed), &app);
|
||||
}
|
||||
g_signal_connect(app.trigger_edge_drop, "notify::selected",
|
||||
|
||||
Reference in new issue
Block a user