Commit Graph
22 Commits
Author SHA1 Message Date
Martino FerrariandClaude Opus 4.6 84c158ff21 perf: the producer's group test is a mask, not a 64-bit division
`plan_partition()` only ever chooses powers of two for `time_stride`, and the
comment there already claimed the test was cheap -- but the stride is a runtime
value, so `tick % period` compiled to a real division that the hot loop paid
once per channel per wire sample. The per-channel masks are built where the
partition is already reacted to, and the shared clock's group end is computed
once per sample instead of three times.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-13 14:50:48 +02:00
Martino Ferrari 5eb20f3cdb perf: editing a math graph recomputes that graph, not every channel
`set_math_channels()` rebuilt the store for any change, and a rebuild replaces the buffer:
every channel in the window started again, which is the "modifying one expression invalidates
all the others" report.

The plan is a *partition over the channels*, so it only moves when the set moves. When the
set is unchanged -- same channels, names and enables -- nothing is rebuilt now: the store is
untouched, every other channel keeps its samples and its runtimes, and the surgical path
prepares and recomputes only the affected graphs. The affected set is the edited channels plus
whatever *names* them, because one channel's output is another's input, and the recompute
computes the whole list in order (the inputs have to be there) while writing only those in
the mask -- which is why the step-loop is now `math_apply_locked(sample, write_mask, timed)`.

Adding, removing or renaming a channel still moves the plan, and still rebuilds.

    [math-probe] graph edit: stored_slots 800 -> 900 (want the second at least the first, and non-zero)

The count grows because the stream keeps filling while the edit runs; the point is that it
never falls to 0, and that every channel afterwards still reads `N samples vs M time slots`
with N == M.
2026-10-12 22:19:17 +02:00
Martino Ferrari 47d49e75ff perf: a file view re-reads the file only when a structural change really dropped it
Three tightenings, all measured with the reloads now reported on stderr.

`set_math_channels()` treats an identical list as no change at all. It rebuilt
unconditionally, and a rebuild replaces the buffer and drops its history -- which a file
view then has to re-read from disk. The comparison is the graphs', and it leaves the nodes'
`x`/`y` out deliberately: those are the canvas's own, no op reads them, and a graph that
merely *moved* should not cost the buffer.

`reload_file_view()` fires only when the store is actually empty. A change that really
happened leaves it so in a file view (the rebuild replaces it, and there is no producer to
refill it); one that found nothing to do leaves it full, and reading a 200 MB file again for
that is the waste. `ScopeModel::stored_slots()` is the one-line accessor it needed.

Every reload now says so, because a second read of a file is worth accounting for:

    [udpscope] reloaded <path> after a structural change: 402 samples back

Measured, `--open` a file:

    session with no graphs            0 reloads
    the math probe (3 real changes)   3 reloads, and all the readings stay equal
                                      (`math x2 402 samples vs 402 time slots`)

I had offered this as "one extra read at `--open` startup", and that was wrong: with no
session there is no `apply_math_channels()` call at all, so there was never an extra read
to remove. What the tightenings buy is that a no-op application is now free, and that a
reload cannot happen unless the buffer was really lost. Each *genuine* change still costs
one re-read -- a rebuild drops the history by design, and preserving it across a rebuild is
a model change rather than this one.
2026-10-12 19:55:31 +02:00
Martino Ferrari b090cc2a21 fix: math in a file view -- the buffer was emptied, and the derived ring filled twice
Two defects, both reached by adding a math channel to an open waveform or a replayed
event, and both measured before and after.

**A file view has no producer, so a structural change left it empty.** `set_math_channels()`
rebuilds the channels *and* the buffer, and a rebuild drops the history: right for a stream,
whose producer refills it from the next packet, and wrong for a file, which has nothing to
refill it with. The `--open` dump read `0 samples vs 0 time slots` for *every* channel,
the file's own `sine`/`square`/`ramp`/`noise` included, though the load had reported 402 --
that is what "adding a math node breaks the plotter" was. `reload_file_view()` re-reads the
file after such a change, which is also what computes the derived channels over it: the
load's own pushes run the math once the set is in place, so `--open` with a session that
defines graphs works for the first time.

**The file path pushed into the derived rings, and so did the engine.** `push_loaded_slot()`
did not honour the rule `ChannelInfo::math` states for the packet path ("the ingest must skip
it"), so each loaded slot put a value in a derived ring and `push_math_sample()` then put the
computed one: two samples per slot, a trace at double rate -- the derived channels read
`804 samples vs 402 time slots`. Skipping them there is the whole fix.

Readings now, all equal, which is the assertion this file's own invariant makes:

    --open the file:  math x2  402 samples vs 402 time slots, y=[-1.9021, 0.0063]  (2x the sine)
                      math lp  402 vs 402, mean=-0.4900 (a real filter output, not zero)
    live:             math x2  25100 vs 25100
    replayed event:   math x2  4000 vs 4000

I first tried filtering the file's *derived columns* out by name, since an export writes
every plotted channel. It removed the double count and zeroed `math lp`, because the column
it dropped was the source a session graph reads -- so it is reverted: those columns are
legitimate data, and the fix belongs in the push, not the loader.
2026-10-12 19:49:39 +02:00
Martino Ferrari 0e256764d5 udpscope: an oscilloscope view for a UDPStreamer source
GTK4 and libadwaita. It receives one UDP stream through the vendored
udpsclient, buffers it in a large ring, and draws it with the vendored
plotter: a trigger with a pre/post event window, a pause that freezes the
window, cursors, measurements, math channels in a node editor, an event cache,
named configurations, preferences, and an export of the visible window to
.npy/.csv. Waveform files can be opened and analysed in place.

The interface is two threads -- a producer that owns the socket and the GTK
main thread that owns every widget and the GL context -- sharing one model
under one mutex, with samples crossing through lock-free rings.

deps/plotter is a submodule pinned to the commit this tree builds against;
deps/udpsclient is vendored in place. docs/claude/ documents the rules that
cost a defect to learn, and SPECS.md is the requirement document.
2026-10-11 15:21:40 +02:00
Martino Ferrari 8f54841c2c working on todo.md features 2026-10-10 22:52:12 +02:00
Martino FerrariandClaude Opus 4.6 8320988d96 fix: a re-plan drops the holes it invalidated and restarts the tick they are addressed by
rebuild_locked() replaced the store, so produced() restarted at 0 while the
producer's tick_ kept counting: a hole's slot and tick addressing then described
a buffer that was gone, forget_holes_before() could not retire it, and a
backfill's amend fell outside the ring -- repairing nothing while the hole was
still marked repaired, which is the band reporting the opposite of the truth. A
re-plan generation is the signal rather than the stride/channel-count guard,
which a reconnect with an identical CONFIG leaves unchanged. slot_base_ is left
alone: it is relative mode's clock origin and must not walk backwards when the
user retypes the window.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-09 06:51:29 +02:00
Martino Ferrari 5915fc8421 fix: a lost packet reserves the samples it owed instead of vanishing from the timeline 2026-10-08 18:18:12 +02:00
Martino Ferrari 57e2f6aa8b feat: relative mode keeps the stream's absolute clock for the export 2026-10-08 17:20:47 +02:00
Martino FerrariandClaude Opus 4.6 f179fbbb44 fix: a clock glitch is bridged and a channel is known by name, so a capture keeps its t0
Two defects behind one report ("a trigger during a capture overrides the first,
and the hold-off does not apply"); the second is also what made a spike in one
channel look like the acquisition resetting.

The trigger engine keyed its invalidation on the trigger channel's *index*, which
every rebuild of the channel list renumbers -- enabling a signal that sorts before
it is enough. Any rebuild therefore abandoned an open capture and set no hold-off,
so the next edge inside the post window became t0. It keys on the channel's name
now, which changes only when the source genuinely does.

A packet whose stamps jump more than 256 packet spans ahead is now rejected as a
fault and *bridged* at the declared cadence, instead of poisoning the monotonic
mark (which pinned every later packet flat at the glitched value) and condemning
the next good packet. Only four consecutive failures abandon the declared time
signal, and that fallback no longer wipes the rings: the arrival clock is anchored
to continue from the monotonic mark, so the history is still on one timeline --
the wipe is what made one glitching packet look like the stream restarting from 0.
The declared unit is resolved before those checks, since they weigh the stamps
against a cadence in seconds.

Guards: UDPSCOPE_PROBE_TRIGRENUM_MS prints t0_held=1 through the real widgets,
and the mock's --time-glitch-at/--time-glitch-by injects the clock fault.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-07 10:29:05 +02:00
Martino FerrariandClaude Opus 4.6 f307d27e41 docs: a pause publish costs no packets, and the look is checked with frame path
An A/B of the same fast-mock run with and without a pause reads gaps=0 both
ways, the paused one having published in 86 ms -- so the gaps=73 recorded
yesterday as "a pause costs packet gaps, unexplained" was incidental load on
one run. It never scaled with the publish length and has not reproduced.

Record the publish figures as the range they actually span across runs
(event 69-115 ms, pause 22-86 ms) rather than as single readings, and name
`frame path:` across a pause as the check that the paused trace is drawn like
a live one -- raw=1 lod=555 against raw=0 lod=373, where the defect read
raw=21 lod=118.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 20:45:13 +02:00
Martino FerrariandClaude Opus 4.6 d2c51f2448 fix: a paused view carries its own LOD pyramid, so it is drawn like a held event
publish_pause_view() filled stamps, values and y and left pyramids empty, so
level_for() descended to raw samples and a frozen pause was re-gathered from
every sample it held on every frame. It cost draw time (12.34 -> 18.18 ms mean)
and, because stage_trace_density() normalises each column against its own peak,
it also changed how the trace looked -- which is how it was noticed.

Both publishes are now timed and reported as `publish cost:`, counted apart
because they stall opposite threads: the event publish runs on the producer
under mutex() and blocks the UI, the pause publish runs on the UI thread and
blocks the producer. That is the number a second live ring has to be judged
against.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 20:36:27 +02:00
Martino FerrariandClaude Opus 4.6 b313cac46e feat: the x axis is relative to now, and an export carries both clocks
Three asked-for changes, one commit because they are one coordinate system.

The live axis is a fixed [-window, 0] with the newest sample at 0, so x reads
as an age and the labels stand still while the trace rolls under them. That
makes the display clock distinct from the stream clock for the first time, so
`ScopeModel::stream_at_x0()` becomes the single conversion between them
(`display_x = stream_t - stream_at_x0()` in all four modes) and a snapshot
carries its own origin in `EventSnapshot::x_offset`. The pause view is now
rebased like an event's, reversing an earlier deliberate choice: a frozen
absolute-stamped picture would sit a whole uptime right of a newest-relative
axis. That also fixed a latent bug, the export adding `trigger_time()` to a
*paused* view's stamps.

The status bar says how long you have been paused -- wall time, since the
producer never stops, through `format_elapsed()` so the field cannot widen.

The export writes two time columns: `time`, the axis as displayed, and
`abs_seconds`, the stream's own clock for the same sample. f64 seconds and
not the wire's integer, because `udps_frame_t::values` is a `const double*`
and the integer never exists in this process.

Cursors and measurement marks move in opposite directions under the rolling
axis, deliberately: a cursor is a position on screen and stays put, a mark
names a sample and travels with it. `App::measure_window` is stream-clocked
for a sharper reason -- a display-coordinate live window never moves, so the
"the window moved" recompute would never fire again.

Moves the deps/plotter pin to 58473db, which stops a time label's mantissa
reaching its own radix: a one-second tick differenced off a live axis read
`1e+3ms`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 21:49:12 +02:00
Martino FerrariandClaude Opus 4.6 ec93a9831d fix: store the stream clock as f64, so a stamp's precision does not decay with uptime
The time ring was f32 like the values, holding each stamp relative to an epoch
fixed at the first sample ever seen -- so the number stored grew for as long as
the app ran while the interval it had to resolve stayed one sample period. At
1 MS/s the f32 ULP reaches the sample period after about sixteen seconds, so two
to four consecutive samples rounded onto one stamp after half a minute and eight
after a minute. On screen that is a plateau the LOD envelope draws as a
zero-width segment -- a vertical spike where the waveform should be -- and in an
export it was three distinct diffs at 2, 2.5 and 3 ULP instead of one clock.

A value's precision is a property of the signal; a timestamp's is a property of
how long the scope has been running. Only the clock widens: the values are the
memory budget. That makes the two slices different sizes per slot, so
plan_partition() weighs its growth loop in bytes rather than in slots (and
bytes_for() is what bounds the doubling); live_window_locked() loses the f32
round-trip of its bounds, which existed only to keep a sample sitting exactly on
one; and EventSnapshot::stamps is f64 because a pause view is not rebased and so
carries full stream time.

Verified against the fast mock: np.diff over two million exported rows now holds
exactly one step (1e-06, 1999999 times), no duplicate or backwards stamps, and
the 200 MB ring still spans 8.39 s. Every documented probe assertion unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 10:41:57 +02:00
Martino FerrariandClaude Opus 4.6 a92a6b5bf0 fixed plot flicker, measurement cost and toolbar icons
The flicker ("no data" at no constant frequency, worse with the measurements
and cursors panes up) was a torn read of the window's first stamp: acquire()
pins the window to the ring's oldest slot, and reading it at paint time races
the producer lapping it, returning a stamp one ring span too new. x0() is the
origin every trace point is reconstructed from, so one torn read produced a
refused neighbours(), a filter that dropped three quarters of the points, and
a polyline shifted off its axis. It is cached by acquire() under the lock now.

EventSeries::neighbours() answered from the (x - x0)/dx grid, whose drift from
the real arrival stamps arrives as a constant offset in seconds -- a doubling
share of the screen per zoom step, which is the "zooming into a held event
clips it more from the left" report. Both series binary-search the stamps now.

The measurements pane went through the LOD pyramid rather than reading its
window raw (19.7 ms per recompute to 1.2 ms, 0.1% of a 2 M-sample window) and
recomputes on a cadence; the math engine lost a per-slot heap allocation, a
per-sample source lookup by name and a fetch_add that cost more than the
arithmetic it counted.

The cursors pane's mix mode drew a line per *named* slot, so a slot holding a
signal that can never produce a channel -- the stream's timestamp -- got a row
that could only read "--", and such names were re-saved every session. It
counts bound traces now and apply_layout() refuses them.

Four toolbar icons are drawn for this app and compiled in as a GResource: the
stock ones were a record dot for the trigger and three magnifiers for cursors,
measurements and math.

New probes and dump lines for all of it (trace ink, measure cost, math cost,
event zoom, toolbar icons); README and CLAUDE.md record what they cure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-03 23:36:07 +02:00
Martino Ferrari aa2508e431 added math signals 2026-10-03 17:28:17 +02:00
Martino Ferrari 02620ca946 improved measuraments ,cursors, zooming and user interactions 2026-10-03 16:05:45 +02:00
Martino Ferrari 44fc4cdf36 improved timing acq 2026-10-02 14:03:57 +02:00
Martino Ferrari 2218aed839 Updated local storage 2026-10-02 11:30:54 +02:00
Martino Ferrari 30db44eaa0 Correct downsampling and better layout 2026-10-02 08:19:10 +02:00
Martino Ferrari 0d741c401b Fixed triggering mode change when acquiring 2026-10-01 17:54:17 +02:00
Martino Ferrari 8059b90de7 Initial commit 2026-10-01 15:42:48 +02:00