Files
plotter/include/plot/data/stream_series.hpp
T
Martino Ferrari 38ef459897
CI / debug (tests + all adapters) (push) Canceled after 0s
CI / coverage (floor + measured table) (push) Canceled after 0s
CI / asan (AddressSanitizer) (push) Canceled after 0s
feat(plot)!: harden pipeline, XY series, dash/markers, touch, packaging
Breaking: Series::gather emits x relative to x0() and takes a GatherMode;
consumers reconstruct absolute x in f64 before mapping (series.hpp documents
the contract).

Bugs, each pinned by a fail-before regression test:
- render: f32 staging quantised epoch-time x into 128 px columns; every
  data-space draw now stages to_space(x) - to_space(origin) with the origin
  folded into the offset uniform in f64 (epoch/zero-origin twins rasterise
  identically)
- ticks: major test was ULP-fragile and blanked epoch axes; majors are an
  integer-index test and are never truncated by the minor cap
- gather: LOD envelope emitted buckets straddling [begin,end); ceil/floor
  bucket edges + raw lead/trail spans
- live follow: log x axes held a linear span and zoomed every frame; span and
  offset now live in axis space (plot + both figure link-group paths)
- input: a zero-notch wheel disarmed auto_scale on every axis; dy==0 pans x,
  factor == 1 / inf refused in wheel and drag zoom
- render: the single text flush painted legend names over the crosshair
  readout panel; two batches, under flushed before the panel
- data: NaN dropped from min/max unless it seeded the fold; non-finite
  samples are now excluded from every aggregate and from cached bounds

Robustness: viewport normalisation (no clamp UB), non-finite range refusal,
pyramid level cap at 57 (no shift/div UB through public gather), rounded ring
capacity documented + pre-sized, GL blend func and UNPACK_ALIGNMENT
save/restore, Renderer::release_gl() teardown, u64 buffer epochs, annotation
captions clamped, button-aware GLFW row capture, cached figure row rects (no
per-event layout), ruler-pair measurement refused, span-nonfinite guards in
axis_ends.

Features: XY series (sorted x, binary-search neighbours/snap, LOD over sample
index); StreamSeries::freeze(); ArraySeries::push(); per-trace dash and
markers (scatter at width 0) with auto or literal stride; GatherMode
Envelope/Nearest/Mean via bucket_sum/sumsq; Style::line_aa_px; per-glyph text
colour (readout values in trace colour); wheel-dx x pan; arrow-key pan;
Escape cancels a drag and restores captured ranges; modifier-masked box zoom
(Shift both, Ctrl x, Alt y); double-click reset via PointerEvent::time_s;
ImGui key repeat unified; pinch gestures on Qt and GTK; pixelDelta wheel path
and DevicePixelRatioChange handling; GNUInstallDirs, SOVERSION,
SameMinorVersion, generated plot/version.hpp, relocatable plot.pc, licence
install, FILES_MATCHING, GitHub Actions CI (debug/coverage/asan).

Suite 283 -> 350 cases, 0 failed; coverage 93.9% (3730/3971) over 24 files,
floor 80/90 held; ASAN preset suite green; all four demos run headless (the
GTK scripted-resize check needs a window manager and fails identically on the
parent commit); readme_main.cpp committed and compiles with the README
command; pkg-config and find_package consumption verified from a relocated
install prefix.
2026-09-30 17:32:06 +02:00

116 lines
5.7 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <vector>
#include <plot/core/types.hpp>
#include <plot/data/array_series.hpp>
#include <plot/data/minmax_pyramid.hpp>
#include <plot/data/ring_buffer.hpp>
#include <plot/data/series.hpp>
namespace plot::data {
/// Single-producer / single-consumer streaming series.
///
/// The producer thread only ever calls push()/push(ys, n) (and produced()): those
/// touch the ring buffer and nothing else -- no lock, no pyramid, no GL, so the
/// acquisition thread can never stall the renderer. The consumer thread calls
/// acquire() to publish a coherent snapshot, then queries it.
///
/// Indexing is the *retained window published by the last acquire()*: size(),
/// reduce(), sum(), sumsq(), gather() and pyramid() all use it, and index 0 is
/// the oldest sample still held.
///
/// The pyramid behind those queries is indexed in absolute sample space, so a
/// window that slides only makes it drop the leading buckets the ring has
/// overwritten and append the samples produced since the last acquire(): acquire()
/// costs O(samples produced), not O(capacity()), and the aggregates that stay in
/// the window are never recomputed. The one exception is a producer that laps the
/// consumer -- more than capacity() samples between two acquires -- where the
/// samples in between were never appended and the pyramid restarts at the new
/// base, so that acquire re-reads the whole window.
///
/// x0() is therefore the x of the oldest retained sample, x_bounds().min == x0(),
/// and y_bounds() covers exactly the published samples. `streaming()` says true:
/// this is the window that slides, the one `XFollow::Live` follows.
///
/// THREADING NOTE. The ring overwrites the oldest samples -- that is what keeps a
/// live stream showing the newest data, and what the drop test pins down -- so a
/// producer can overwrite the slots a consumer is reading even while it holds a
/// snapshot. Those slots are relaxed atomics (see RingBuffer), so this is not a
/// data race and no value tears; what it is not is a frozen window.
///
/// The bound is the sample count, not the geometry. A snapshot count fixes *which
/// absolute indices* a read walks, not that the slots still hold those samples:
/// every push after the snapshot overwrites exactly one slot, the one belonging to
/// the sample capacity() positions behind it. So if the producer pushes k samples
/// while a read is in flight, the first k positions of that read window can hold
/// one of those k newer samples (or a still-newer sample, once the producer has
/// lapped again) instead of the sample the snapshot names. Nothing else moves: the
/// count, the absolute index -> x mapping and the newest end of the window are
/// derived from one published count, and the next acquire() slides the pyramid's
/// window to the base it observes (dropping the leading buckets, appending what is
/// new), so the pyramid never mixes two windows. Ingest with no reuse
/// underneath a read at all needs the producer to stop before unread data
/// (drop-on-full) or a claim protocol, i.e. a different contract from the one this
/// class specifies.
class StreamSeries final : public Series {
public:
/// `capacity` is the sample count to retain; it is rounded up to a power of
/// two by the ring (see `RingBuffer`), and `size()` saturates at that rounded
/// `capacity()` -- a `StreamSeries(100)` retains up to 128 samples. The
/// pyramid's level table is reserved for the rounded capacity up front, so a
/// saturated stream never grows it.
explicit StreamSeries(u64 capacity, f64 x0, f64 dx);
/// Producer thread only.
void push(f32 y);
/// Producer thread only.
void push(const f32* ys, u64 n);
/// Producer thread only: number of samples ever pushed (monotonic).
u64 produced() const;
/// Consumer thread only: snapshots the produced count and brings the pyramid
/// and bounds up to it.
void acquire();
/// Consumer thread only: copies the window published by the *last acquire()*
/// into a fresh `ArraySeries` with this window's `x0()`/`dx()`. The frozen
/// series never changes again, no matter what the producer pushes afterwards
/// -- it answers from its own copy, not from the ring -- which is what a
/// consumer that needs a stable window for longer than one read wants. (The
/// ring itself keeps overwriting; only the copy is frozen, so this costs one
/// window's worth of samples. It reads through `copy_raw()`, so like every
/// read it is a snapshot of *which* absolute indices to walk, not a guarantee
/// about slots a producer is overwriting -- call it on the consumer thread,
/// interleaved with `acquire()`, per the class note.)
ArraySeries freeze() const;
u64 size() const override;
f64 x0() const override;
f64 dx() const override;
Range x_bounds() const override;
Range y_bounds() const override;
const MinMaxPyramid& pyramid() const override;
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
GatherMode mode = GatherMode::Envelope) const override;
/// Always true: this series' `x_bounds()` is the retained ring window, which
/// slides with every `acquire()` -- the property `XFollow::Live` follows.
bool streaming() const noexcept override { return true; }
protected:
void copy_raw(u64 begin, u64 end, f32* out) const override;
private:
RingBuffer<f32> ring_;
f64 x0_ = 0.0;
f64 dx_ = 1.0;
// The pyramid owns the published window: its window_base() is the absolute
// index of retained index 0 and its size()/window_end() are the snapshot's
// extent, so there is no second copy of that state to keep in step.
MinMaxPyramid pyramid_;
Range y_bounds_{};
};
} // namespace plot::data