Files
udpscope/src/trigger.cpp
T
Martino Ferrari e14e1ee069 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.
2026-10-16 16:46:17 +02:00

119 lines
4.5 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "trigger.hpp"
#include <algorithm>
#include <cmath>
namespace udpscope {
double TriggerConfig::post_span_s(double buffer_span_s) const {
const double pre = std::clamp(pretrigger_pct, 0.0, 100.0) / 100.0;
return buffer_span_s * (1.0 - pre);
}
void TriggerEngine::configure(const TriggerConfig& cfg, const std::string& channel_name,
double post_seconds, double pre_seconds, double oldest_s,
double dt) {
const bool channel_changed = channel_name != channel_name_;
if (channel_changed) channel_name_ = channel_name;
cfg_ = cfg;
post_seconds_ = post_seconds > 0.0 ? post_seconds : 0.0;
pre_seconds_ = pre_seconds > 0.0 ? pre_seconds : 0.0;
oldest_s_ = oldest_s;
// A no-op unless the condition actually changed: this runs on every packet.
detector_.configure(cfg_.condition, dt);
if (channel_changed) {
detector_.reset();
pending_ = false;
post_accum_count_ = 0;
}
}
void TriggerEngine::arm(double now_s) noexcept {
armed_ = true;
pending_ = false;
post_accum_count_ = 0.0;
holdoff_until_s_ = now_s;
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;
// The detector is fed **first and unconditionally**, on every sample the engine sees --
// including during the hold-off and during an open capture, where its answer is thrown
// away. It is a sequential machine: a pulse whose opening edge fell in a skipped stretch
// can never be closed, and the symptom would be a width condition that silently stops
// firing after the first hold-off.
const double event_t = detector_.feed(value, now_s);
// Hold-off: settled, waiting for the next event.
if (!pending_ && now_s < holdoff_until_s_) {
last_t_s_ = now_s;
return false;
}
last_t_s_ = now_s;
if (pending_) {
// Accumulate the post-trigger window; latch once the stream clock has advanced
// past it. Time, not a sample count: `now_s` is the same clock the x axis reads,
// so the capture covers exactly the requested span whatever the cadence is.
++post_accum_count_;
if (now_s - pending_start_s_ >= post_seconds_) {
pending_ = false;
post_accum_count_ = 0;
if (cfg_.mode == TriggerMode::Single) {
armed_ = false;
} else {
holdoff_until_s_ = now_s + std::max(0.0, cfg_.holdoff_s);
}
return true;
}
return false;
}
if (!std::isfinite(event_t)) return false;
// The pre-trigger window has to already be behind the event, or the capture opens on a
// half-filled pre window and the event is short on its left side -- the plot then shows a
// window that is correctly sized but only partly drawn. The buffer fills whether or not the
// trigger is armed, so this normally passes on the first event; it only bites just after a
// rebuild (a CONFIG, or enabling the signal), when the history genuinely is not there yet.
//
// Measured from the *event's own* time, not from `now_s`: for a qualified condition the two
// differ (a pulse is reported at its closing edge), and the pre window belongs to the event.
if (event_t - oldest_s_ < pre_seconds_) return false;
// The event's own time, which is what a trigger marker marks: the latch itself may happen
// a post-window later.
last_trigger_s_ = event_t;
// The event always opens a capture; there is no "latch on the event itself" case.
// A zero-length post window latches on the very next sample, which is the least a
// capture can hold, and every longer window is just more samples -- no conversion
// from seconds to a sample count, so no conversion to get wrong.
pending_ = true;
pending_start_s_ = event_t;
post_accum_count_ = 0;
return false;
}
} // namespace udpscope