test: the noise probe reads the trigger's band and HF reject
Six legs over one retained buffer, each a full rising-edge search through `BufferSearch` at
level 0, run back to back without yielding so every leg sees the same samples: the bare
comparator on a spiked sine, a 0.8 band, a 50 Hz reject, both, and the reject's group delay
on `square` -- which carries no impulses, so a one-pole's crossing of zero after a step is
exactly `tau*ln2` and the delay is an exact prediction rather than a coincidence.
trigger band: bare=49/2 band=7/2 ratio=7.00
trigger hf: hf=7/2 band+hf=7/2 band=7
trigger delay: clean=4 filtered=4 delay=0.002206 want=0.002206 ratio=1.000
`ratio >= 5`, both HF legs within 2 of the band, `ratio` between 0.7 and 1.4, `state=2` on
every leg, no Gtk-CRITICAL. `bare` and `band` are proportional to the retained buffer, so
neither is a fixed number; the ratios and the agreement between legs are the assertions.
The probe enables the signals it measures itself rather than relying on another probe's side
effects -- `enable_all_probe` ends by switching one off again, which is how `sine` was missing
from the first run of this one -- and arms in two phases because that enable is a structural
change that replaces the store, so the ring has to fill before there is anything to search.
Its "no stream" line now names the channels that *are* there: an index alone says a probe
found nothing, not what it saw.
This commit is contained in:
1 parent
1583741e03
commit
fec0a1032e
5 files changed
+184
No files matched your search
@@ -1276,3 +1276,36 @@ which must be untouched:
|
|||||||
channels and an edit does not move it, so the store survives: the count *grows* (the stream keeps filling
|
channels and an edit does not move it, so the store survives: the count *grows* (the stream keeps filling
|
||||||
while the edit runs) and never falls to 0, which is what a rebuild would do. Every channel then still reads
|
while the edit runs) and never falls to 0, which is what a rebuild would do. Every channel then still reads
|
||||||
`N samples vs M time slots` with `N == M`, so no channel was left behind either.
|
`N samples vs M time slots` with `N == M`, so no channel was left behind either.
|
||||||
|
|
||||||
|
## `[noise-probe]`
|
||||||
|
|
||||||
|
```
|
||||||
|
[noise-probe] trigger band: bare=49/2 band=7/2 ratio=7.00
|
||||||
|
[noise-probe] trigger hf: hf=7/2 band+hf=7/2 band=7
|
||||||
|
[noise-probe] trigger delay: clean=4 filtered=4 delay=0.002206 want=0.002206 ratio=1.000
|
||||||
|
```
|
||||||
|
|
||||||
|
`UDPSCOPE_PROBE_NOISE_MS`, and it needs the mock's **spiked** sine -- `--spike-every 0.005 --spike-amp 0.5
|
||||||
|
--spike-width 2` -- where every other probe uses the plain mock. It arms in two phases: the first enables the
|
||||||
|
signals it measures *itself*, because a probe must not depend on another probe's side effects
|
||||||
|
(`enable_all_probe` ends by switching one off again, which is how `sine` went missing from the first run of
|
||||||
|
this one), and waits 2.5 s for the ring to refill after that structural change. The legs then each search the
|
||||||
|
whole retained buffer through `BufferSearch`, one after another **without yielding**, so all six see the same
|
||||||
|
buffer.
|
||||||
|
|
||||||
|
- **`trigger band:`** -- `bare` is the bare comparator on the spiked sine (every impulse that lands near zero
|
||||||
|
carries it across, so the count is dominated by spikes), `band` the same with a 0.8 band. **`ratio` must be
|
||||||
|
at or above 5**, and `band` about the number of sine cycles retained. Neither count is a *fixed* number:
|
||||||
|
both scale with the retained buffer's length (7 at a 2.5 s buffer, ~30 at 6 s), so the ratio and the
|
||||||
|
agreement between the legs are the assertions. A `ratio` near 1 means the band is not being applied --
|
||||||
|
`decide()` is not reached from `feed_edge`.
|
||||||
|
- **`trigger hf:`** -- `hf` (no band, 50 Hz reject) and `band+hf` (both) must be **within 2 of `band`**: the
|
||||||
|
reject removes the impulses on its own. `hf` near `bare` means the pole never left `a == 1` (check
|
||||||
|
`configure()` recomputes it, and that `hf_fc` reached the condition); `band+hf` near 0 means the reject is
|
||||||
|
attenuating the sine itself, which would be the Nyquist clamp clamping 50 Hz.
|
||||||
|
- **`trigger delay:`** -- the reject's group delay, measured on **`square`**, which carries no impulses: a
|
||||||
|
one-pole turns a -1 -> +1 step into a zero crossing at exactly `tau*ln2`, so `delay` must read
|
||||||
|
`want = 0.002206 s` and **`ratio` between 0.7 and 1.4**. A `ratio` near 0 is a pass-through; a large one
|
||||||
|
means `a` is far too small, i.e. `dt` never arrived at the detector.
|
||||||
|
- `state` is `2` (Done) or `4` (Capped) on every leg. `3` (Truncated) on more than one leg means the pump loop
|
||||||
|
is being overtaken -- nothing to fix in the feature, but say so.
|
||||||
@@ -522,3 +522,27 @@ hold ports.
|
|||||||
|
|
||||||
The probes are the regression net — when changing UI or plot-host behaviour, extend
|
The probes are the regression net — when changing UI or plot-host behaviour, extend
|
||||||
the probe/dump in `src/ui.cpp` rather than adding a separate harness.
|
the probe/dump in `src/ui.cpp` rather than adding a separate harness.
|
||||||
|
|
||||||
|
## The noise probe needs a spiked mock
|
||||||
|
|
||||||
|
Every other probe runs against the plain mock. The noise probe reads what a *band* and an *HF reject* do to
|
||||||
|
impulsive interference, so its mock must have some: an alternating-sign impulse train of 2 samples at
|
||||||
|
amplitude 0.5 every 5 ms, added to `sine` alone (so a spiked channel can be read against a clean one in the
|
||||||
|
same run).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/martino/Projects/tools/udpscope
|
||||||
|
python3 tools/mock_udpstreamer.py --port 8108 --rate 1300000 --batch 1000 --duration 16 \
|
||||||
|
--spike-every 0.005 --spike-amp 0.5 --spike-width 2 > /tmp/noise-mock.log 2>&1 &
|
||||||
|
sleep 1.5
|
||||||
|
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=14000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||||||
|
UDPSCOPE_PROBE_NOISE_MS=6000 \
|
||||||
|
./build/udpscope --server 127.0.0.1 --port 8108 2>&1 | grep -E 'noise-probe|CRITICAL'
|
||||||
|
```
|
||||||
|
|
||||||
|
The probe arms at 6 s rather than the 8 s a single-phase probe would use: its first phase enables the signals
|
||||||
|
it measures and waits 2.5 s for the ring to refill after that structural change. `--spike-every`/`--spike-amp`/
|
||||||
|
`--spike-width` are the mock's own; `--batch` must stay at or below 1000 (2000 samples x 5 signals x f64 is
|
||||||
|
80 kB, past UDP's ~65507-byte payload, and the mock dies on its first `sendto` while the run still exits 0).
|
||||||
|
|
||||||
|
The three lines and what each must read are in `probe-readings.md`.
|
||||||
@@ -5395,6 +5395,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
|||||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_DROP_MS")) {
|
if (const char* env = g_getenv("UDPSCOPE_PROBE_DROP_MS")) {
|
||||||
g_timeout_add(static_cast<guint>(std::atoi(env)), drop_probe, &app);
|
g_timeout_add(static_cast<guint>(std::atoi(env)), drop_probe, &app);
|
||||||
}
|
}
|
||||||
|
if (const char* env = g_getenv("UDPSCOPE_PROBE_NOISE_MS")) {
|
||||||
|
g_timeout_add(static_cast<guint>(std::atoi(env)), noise_probe, &app);
|
||||||
|
}
|
||||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_ROWMENU_MS")) {
|
if (const char* env = g_getenv("UDPSCOPE_PROBE_ROWMENU_MS")) {
|
||||||
rowmenu_probe_next(app, 0, static_cast<guint>(std::atoi(env)));
|
rowmenu_probe_next(app, 0, static_cast<guint>(std::atoi(env)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1404,6 +1404,7 @@ gboolean events_probe_step(gpointer user);
|
|||||||
gboolean events_group_probe_step(gpointer user);
|
gboolean events_group_probe_step(gpointer user);
|
||||||
void events_group_probe_next(App& a, int step, guint delay_ms);
|
void events_group_probe_next(App& a, int step, guint delay_ms);
|
||||||
gboolean drop_probe(gpointer user);
|
gboolean drop_probe(gpointer user);
|
||||||
|
gboolean noise_probe(gpointer user);
|
||||||
void collect_spins(GtkWidget* w, std::vector<double>* out);
|
void collect_spins(GtkWidget* w, std::vector<double>* out);
|
||||||
gboolean defaults_probe(gpointer user);
|
gboolean defaults_probe(gpointer user);
|
||||||
gboolean keys_probe(gpointer user);
|
gboolean keys_probe(gpointer user);
|
||||||
|
|||||||
@@ -3422,6 +3422,129 @@ void events_group_probe_next(App& a, int step, guint delay_ms) {
|
|||||||
g_timeout_add(delay_ms, events_group_probe_step, &a);
|
g_timeout_add(delay_ms, events_group_probe_step, &a);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct NoiseLeg {
|
||||||
|
std::size_t hits = 0;
|
||||||
|
double first = std::numeric_limits<double>::quiet_NaN();
|
||||||
|
int state = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// One rising-edge search of the whole retained buffer at level 0.
|
||||||
|
///
|
||||||
|
/// Synchronous on purpose. Every leg must see the *same* buffer, and yielding to the main loop between legs
|
||||||
|
/// would let the producer append between them, so the counts would no longer be comparable. The bound is a
|
||||||
|
/// safety net: a chunk is `BufferSearch::kChunk` samples, so 20000 pumps cover 160 M of them, far more than
|
||||||
|
/// the ring holds.
|
||||||
|
NoiseLeg noise_leg(App* a, int channel, double dt, double band, double hf) {
|
||||||
|
EventCondition c;
|
||||||
|
c.type = TriggerType::Edge;
|
||||||
|
c.edge = TriggerEdge::Rising;
|
||||||
|
c.level = 0.0;
|
||||||
|
c.noise_band = band;
|
||||||
|
c.hf_fc = hf;
|
||||||
|
a->search.start(c, channel, dt);
|
||||||
|
for (int i = 0; i < 20000 && a->search.pump(*a->model); ++i) {
|
||||||
|
}
|
||||||
|
NoiseLeg r;
|
||||||
|
r.hits = a->search.hits().size();
|
||||||
|
if (r.hits > 0) r.first = a->search.hits().front();
|
||||||
|
r.state = static_cast<int>(a->search.state());
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The difference of two first-hit stamps, folded into +/- half a period, so a pair whose first hits landed
|
||||||
|
/// on different cycles still reads as a delay.
|
||||||
|
double noise_fold(double d, double period) {
|
||||||
|
const double half = 0.5 * period;
|
||||||
|
d = std::fmod(d, period);
|
||||||
|
if (d > half) d -= period;
|
||||||
|
if (d < -half) d += period;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
int noise_channel(App* a, const char* name) {
|
||||||
|
const std::vector<ChannelInfo>& channels = a->model->channels();
|
||||||
|
for (std::size_t i = 0; i < channels.size(); ++i) {
|
||||||
|
if (channels[i].name == name) return static_cast<int>(i);
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
gboolean noise_probe(gpointer user) {
|
||||||
|
static int phase = 0;
|
||||||
|
App* a = static_cast<App*>(user);
|
||||||
|
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||||
|
if (phase == 0) {
|
||||||
|
// The signals this probe measures are enabled *here*. Not because it is tidier, but because it must not
|
||||||
|
// depend on another probe's side effects: `enable_all_probe` ends by switching one signal off again (it
|
||||||
|
// reads what a disabled signal does to the plots), and a run that arms both would take `sine` away.
|
||||||
|
const std::vector<SignalInfo>& signals = a->model->signals();
|
||||||
|
for (std::size_t i = 0; i < signals.size(); ++i) {
|
||||||
|
if (signals[i].name == "sine" || signals[i].name == "square") {
|
||||||
|
a->model->set_signal_enabled(i, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
phase = 1;
|
||||||
|
// The enable is a structural change: it replaces the store, so the ring has to fill again before there
|
||||||
|
// is anything to search.
|
||||||
|
g_timeout_add(2500, noise_probe, a);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
const int sine = noise_channel(a, "sine");
|
||||||
|
const int square = noise_channel(a, "square");
|
||||||
|
double dt = 0.0;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(a->model->mutex());
|
||||||
|
dt = a->model->partition().slot_dt();
|
||||||
|
}
|
||||||
|
if (sine < 0 || square < 0 || !(dt > 0.0)) {
|
||||||
|
// Name the channels that *are* there: an index alone says a probe found nothing, not what it saw.
|
||||||
|
std::string have;
|
||||||
|
for (const ChannelInfo& c : a->model->channels()) have += c.name + " ";
|
||||||
|
std::fprintf(stderr, "[noise-probe] no stream: sine=%d square=%d dt=%g channels=[%s]\n", sine, square,
|
||||||
|
dt, have.c_str());
|
||||||
|
std::fflush(stderr);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A: the bare comparator on a spiked sine. Every impulse landing where the sine is within the impulse's own
|
||||||
|
// amplitude of zero fires a crossing.
|
||||||
|
const NoiseLeg a_leg = noise_leg(a, sine, dt, 0.0, 0.0);
|
||||||
|
// B: a 0.8 band. A +/-0.5 impulse cannot carry the signal across a 1.6-wide band, so only the sine's own
|
||||||
|
// cycles are left.
|
||||||
|
const NoiseLeg b_leg = noise_leg(a, sine, dt, 0.8, 0.0);
|
||||||
|
// C: no band, a 50 Hz reject. A 2-sample 0.5 impulse moves a pole with a = 1 - exp(-2*pi*50*dt) by about
|
||||||
|
// 5e-4, which cannot reach the level.
|
||||||
|
const NoiseLeg c_leg = noise_leg(a, sine, dt, 0.0, 50.0);
|
||||||
|
// D: both. The reject attenuates a 5 Hz sine by only 1/sqrt(1 + (5/50)^2), so its peaks still clear the band
|
||||||
|
// and the count is unchanged.
|
||||||
|
const NoiseLeg d_leg = noise_leg(a, sine, dt, 0.8, 50.0);
|
||||||
|
// E/F: the reject's group delay, on `square`, which carries no impulses. A one-pole turns a -1 -> +1 step
|
||||||
|
// into -1 + 2*(1 - exp(-t/tau)), which crosses zero at tau*ln2 -- exact, and the same on every edge.
|
||||||
|
const NoiseLeg e_leg = noise_leg(a, square, dt, 0.0, 0.0);
|
||||||
|
const NoiseLeg f_leg = noise_leg(a, square, dt, 0.0, 50.0);
|
||||||
|
|
||||||
|
const double period = 0.2; // the mock's default --freq is 5 Hz.
|
||||||
|
const double delay = noise_fold(f_leg.first - e_leg.first, period);
|
||||||
|
const double want = std::log(2.0) / (2.0 * 3.14159265358979323846 * 50.0);
|
||||||
|
|
||||||
|
std::fprintf(stderr, "[noise-probe] trigger band: bare=%zu/%d band=%zu/%d ratio=%.2f\n", a_leg.hits,
|
||||||
|
a_leg.state, b_leg.hits, b_leg.state,
|
||||||
|
b_leg.hits > 0 ? static_cast<double>(a_leg.hits) / static_cast<double>(b_leg.hits) : 0.0);
|
||||||
|
std::fprintf(stderr, "[noise-probe] trigger hf: hf=%zu/%d band+hf=%zu/%d band=%zu\n", c_leg.hits,
|
||||||
|
c_leg.state, d_leg.hits, d_leg.state, b_leg.hits);
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[noise-probe] trigger delay: clean=%zu filtered=%zu delay=%.6f want=%.6f ratio=%.3f\n",
|
||||||
|
e_leg.hits, f_leg.hits, delay, want, want > 0.0 ? delay / want : 0.0);
|
||||||
|
std::fflush(stderr);
|
||||||
|
a->search.clear();
|
||||||
|
update_search_panel(*a);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
gboolean drop_probe(gpointer user) {
|
gboolean drop_probe(gpointer user) {
|
||||||
App* a = static_cast<App*>(user);
|
App* a = static_cast<App*>(user);
|
||||||
if (a->window == nullptr || a->model->signals().empty()) return G_SOURCE_REMOVE;
|
if (a->window == nullptr || a->model->signals().empty()) return G_SOURCE_REMOVE;
|
||||||
|
|||||||
Reference in new issue
Block a user