# waveview — design notes and measurements

**Using it: see [docs/USAGE.md](docs/USAGE.md).**  This file is the design
record — why the seams are where they are, and the numbers behind them.

A load-bearing skeleton for an analog waveform viewer, plus the benchmark that
decides whether the architecture survives multi-gigabyte files.

**Run it:** `waveview <file.raw>` opens a window.

Press **`h`** in the viewer for the full key and mouse reference, which opens
in its own window. The bindings live in one table in `crates/waveview/src/main.rs`
(`HELP`) and that table *is* the help window — adding a binding without
documenting it takes deliberate effort.

The essentials: click a signal to plot it, wheel to zoom, right-drag to zoom
to a region, `a`/`b` to place cursors, `m` to measure, `p` for a new pane,
`f` to fit, `q` to quit.

The current pane is drawn with a highlighted frame, so it is always visible
which pane a key or a newly clicked signal will act on.

There is also `wave-plot`, the same renderer without a window, for batch plots
out of a regression run.

## Why these seams

The two waveform viewers most used for this kind of work were studied first,
at the level of "what shape is the data layer" — one is Java, the other C++,
and they are built on entirely different toolkits.

They converge anyway, on the same data architecture: **sectioned block storage
plus a min/max reduction keyed to the resolution of the output device**, screen
or printer. Two independent products, different languages, same answer. The
architecture is the invariant; the language is taste. That is the single most
useful thing to know before starting, and it is what this design copies.

The other lesson is where the code actually goes. In both, the expression
engine — derived waveforms, not the plotter — is the larger half of the
program. That is why `wave-expr` exists here on day one, with one operator and
no parser: it is a seam, reserved early, because retrofitting derived curves
into a viewer that assumes every curve comes from a file is a rewrite.

## Layout

Dependencies point one way only. Nothing below knows about anything above.

```
waveview       interactive viewer (X11)
wave-x11       X11 client, written against the wire protocol
wave-plot      headless plotter CLI  ->  PNG
wave-bench     benchmark + correctness harness
wave-meas      measurements over exact samples.  wave-core ONLY
wave-render    canvas, axes, panes, cursors, PNG encoder.  wave-core ONLY
wave-expr      derived curves — the calculator seam, one operator, no parser
wave-store     two backends: min/max pyramid (real) and exact (complex)
wave-io        WaveReader trait; ngspice raw (binary+ASCII) and CSV
wave-core      Span, Unit, ValueKind, Component, XScale, Reduced, Poll, Curve
```

`wave-render` depends on `wave-core` and nothing else — it cannot see a file,
a reader, an index or a toolkit. That is the seam test: if it ever needs
`wave-io`, the layering has broken.

8574 lines, **zero external crates**, builds on rustc 1.58. The binary links
only libc/libm/libpthread — it will run on a CAD box with no toolkit installed.

## Two storage strategies, chosen by the file

Transient and AC are different problems, and one strategy cannot serve both.

| | real (`.tran`, `.dc`) | complex (`.ac`) |
|---|---|---|
| size | gigabytes | KB to a few MB |
| storage | min/max pyramid, ~6% of file | every sample, exact |
| curves per variable | 1 | 6 — mag, dB, phase, unwrapped phase, real, imag |

The second row follows from the third. A complex sample backs several curves
at once, so collapsing it to `hypot(re, im)` when the index is built — which
this code did in its first version — destroys phase permanently and leaves the
viewer unable to draw a Bode plot.

Reducing first is also *wrong* for those projections. The pyramid is lossless
only for monotone functions of the sample; `|v|` and `20log10|v|` are not, so
`min(|v|) != |min(v)|` as soon as a bucket straddles zero. Computing the
component from exact samples and reducing afterwards is correct by
construction. For the same reason `curve_component` **refuses** a nonlinear
component on real data rather than returning a plausible wrong answer — that
would need its own pyramid.

Unwrapped phase makes the point sharper still: it depends on every earlier
point, so it cannot be computed from a windowed view at all. It is
precomputed once over the whole sweep.

The X scale is read from the file, not guessed: ngspice writes
`grid=<GRIDTYPE>` on the X variable, and `GRID_LOGLOG` (2) / `GRID_XLOG` (3)
mean a log frequency axis.

## The three rules that make it work

1. **`Curve::reduce` is the only bulk data path**, and the caller states how
   many buckets it can draw. Nothing may ask for a raw sample array.
2. **Derived curves implement the same trait as file curves.** Proven by
   `wave-expr`, which the renderer cannot distinguish from a file curve.
3. **`reduce` returns `Poll`**, so moving work off the UI thread later is not
   a rewrite of every caller.

Plus two that only show up under measurement: X pyramid must be `f64` (an
`f32` X axis makes cursors jitter at 6M points), and the exact-read budget
must be in **bytes**, not rows (a 1024-variable file costs 16x more per row
than a 64-variable one).

## Results — 2.86 GB, 8 cores, SATA-class disk (538 MB/s by `dd`)

| | 64 vars x 6M points | 1024 vars x 375k points |
|---|---|---|
| index build, cold | 6.04 s @ 509 MB/s | 6.35 s @ 484 MB/s |
| index build, warm | 0.42 s @ 7394 MB/s | — |
| index size | 189 MB = 6.45% | 184 MB = 6.26% |
| reopen from sidecar | 0.41 s cold / 0.09 s warm | — |
| any single-curve query | 0.001 – 0.023 ms | 0.000 – 0.016 ms |
| cursor readout | 0.004 ms | 0.035 ms |

Cold build runs at **96% of the disk's sequential ceiling** — it is I/O-bound,
the reduction compute is free. Warm rate shows the compute alone sustains
7.4 GB/s, so on NVMe this stays disk-bound at ~1 s.

## The window layer

No windowing library was installable on the target machine (no package
downloads), so the X11 wire protocol is spoken directly over the socket:
MIT-MAGIC-COOKIE auth out of `.Xauthority`, one window, `PutImage` to blit a
frame, and an event loop. About 500 lines.

Less mad than it sounds here — the renderer already produces a finished RGB
frame, so a window only has to show it and report input. It also means the
viewer has no runtime dependency beyond libc and an X server.

Interaction is verified the same way the plots are: a small XTEST-based input
driver injects clicks, wheel and keys into a private `Xvfb` display, then the
result is screenshotted and read back. That caught a status-bar text collision
that no test would have described.

## Three data paths, and why

Measurements forced a third method onto `Curve`, and the reason is worth
stating because it is the kind of thing that gets discovered late:

| method | returns | used by |
|---|---|---|
| `reduce(span, n_buckets)` | min/max envelope | rendering |
| `value_at(x)` | one exact sample | cursor readout |
| `for_each_sample(span, f)` | every exact sample, streamed | measurements |

A rise time computed from a bucket envelope is not a rise time — it is the
rise time of the envelope, wrong by however much the current zoom happens to
decimate. Measurements must see real samples or refuse. `for_each_sample`
streams rather than returning a `Vec` because a full-span measurement on a
6M-point run would otherwise materialise ~100 MB the caller reads once.

It is also the one operation that must touch real data, and it costs
accordingly: 6.5 ms over a 120k-sample cursor span, 283 ms for all 6M samples
(warm; cold it is disk-bound). Scope measurements to a cursor span.

## Measurements vs ngspice `.measure`

Same cross-validation approach as AC. Deck: a 1.8 V, 50 MHz pulse with
1 ns edges, RC-filtered copy through 1k/1p.

| measurement | wave-meas | ngspice `.meas` |
|---|---|---|
| avg v(out), 40-160 ns | 9.00000000e-1 | 9.00000e-01 |
| rms v(out) | 1.20283969 | 1.20284 |
| max / min | 1.79985959 / 1.40408945e-4 | 1.79986 / 1.40409e-04 |
| peak-to-peak | 1.79971918 | 1.79972 |
| rise v(out), 0.18->1.62 | 2.36080746e-9 | 2.36081e-09 |
| delay clk->out at 0.9 V | 7.34430375e-10 | 7.34430e-10 |
| period | 2.00000000e-8 | 2.00000e-08 |
| rise v(clk) | 8.00000000e-10 | 8.00000e-10 |

`v(clk)` rise is also the analytic answer: 0.8 x t_r for a 1 ns PULSE edge.

Measured with 10%/90% derived from the signal's own min/max instead of
absolute levels, `out_rise` reads 2.36002 ns — 0.8 ps from ngspice's number.
That gap is a *definition* difference, not an error: ngspice was handed
absolute 0.18/1.62 while the swing actually runs 1.404e-4 to 1.79986. Given
matched thresholds the two agree to every printed digit. Both conventions are
available (`--abs lo hi`).

Averages are time-weighted, not sample-weighted. SPICE timesteps are not
uniform — a run puts a hundred points on an edge and a handful across a quiet
microsecond — so a plain sample mean would over-weight edges by orders of
magnitude and disagree with both ngspice and an oscilloscope.

## Rendering

Cost is set by the window, never by the file. Measured on the 2.86 GB file,
8 traces in 2 panes:

| window | pixels | render |
|---|---|---|
| 640x400 | 0.26 M | 1.3 ms |
| 1280x720 | 0.92 M | 4.8 ms |
| 1920x1080 | 2.07 M | 10.5 ms |
| 3840x2160 | 8.29 M | 41.7 ms |

Linear in pixels to within a few percent, and flat in file size: a 6M-point
full-span view of five traces renders in 5.9 ms after an 81 ms open. At 1080p
that is ~95 fps for a full repaint, which is the interactive budget with room
to spare.

The PNG encoder is hand-written (adaptive filtering, fixed-Huffman deflate,
greedy LZ77) to keep the tree dependency-free. Output verified by decoding
with an independent implementation: CRCs check and the zlib stream inflates to
exactly `w*h*3 + h` bytes. Plots compress ~95x.

One defect the images caught that no unit test would have: buckets do not map
1:1 onto pixel columns, so drawing each bucket only at its rounded x dropped
**54 of 1390 columns** on a full-span view. A bucket now paints the whole
column range it covers. Verified by counting trace pixels per column: 0 gaps.

## Correctness — AC

Every complex projection is checked against ngspice's own `mag`, `db`, `ph`,
`cph`, `real` and `imag` on a 1801-point RLC sweep:

```
comp      max abs err in half-ULP of ref    verdict
mag         4.999e-11              1.000         OK
db           4.979e-7              1.000         OK
ph           4.998e-7              1.000         OK
cph          4.999e-7              1.000         OK
real        5.000e-11              1.000         OK
imag        4.971e-11              0.997         OK
```

One half-ULP is exact agreement to the last digit the reference text file
carries — the residual is the `%.8e` print format, not the arithmetic. `cph`
matching confirms the unwrap convention is ngspice's; wrapped and unwrapped
phase diverge by up to 360 deg across this sweep, so it is not cosmetic.

## Correctness — transient

`wave-bench verify` cross-checks the index against a brute-force scan:
global min/max per variable, envelope containment, and that every pyramid
level agrees (folding min/max is lossless, so it must). Run against real
ngspice output — ASCII and binary, including bus-syntax names like `v(ctl<0>)`
and `v(counter_dec[0])` — plus the CSV reader. Worst error 2.6e-8 to 4.8e-8,
which is exactly f32 epsilon: the deliberate Y storage choice and nothing else.

## Usage

```sh
cargo build --release
wave-bench gen  big.raw 64 6000000     # synthetic 2.86 GB raw
wave-bench evict big.raw               # drop page cache (no root needed)
wave-bench index  big.raw              # timed build + sidecar
wave-bench reopen big.raw              # sidecar load time
wave-bench query  big.raw 1            # latency across the zoom range
wave-bench verify big.raw              # index vs brute force
wave-bench expr   big.raw 1 2          # derived-curve seam probe
wave-bench ac     ac.raw acref.csv 'v(out)'   # complex components vs ngspice

# plotting
wave-plot big.raw -o t.png -s 'v(a)' -s 'v(b)' --pane -s 'v(c)' \
          --cursor 2e-5 --cursor 6e-5 --size 1500x800
wave-plot big.raw -o z.png -s 'v(a)' --from 4.99e-5 --to 5.01e-5
wave-plot ac.raw  -o bode.png -s 'v(out)' --bode      # db + unwrapped phase
wave-plot big.raw -o d.png --sub 'v(a)' 'v(b)'        # derived trace

# measurements
wave-bench meas meas.raw 'v(out)' --from 40e-9 --to 160e-9
wave-bench meas meas.raw 'v(out)' --abs 0.18 1.62      # ngspice-style levels
wave-bench meas meas.raw 'v(clk)' --delay 'v(out)'
```

## Not done

No interactive UI, no expression parser, no session files.
`levels()` takes high/low from the span extremes rather than a bimodal
histogram, so a ringing signal inflates `high` and biases 10/90% timings —
use `--abs` there.
The renderer has no anti-aliasing, no digital/bus lane rendering, and no
per-pane independent X.
Nonlinear components (dB, magnitude) on *real* data are refused rather than
served from their own pyramids.
Curves from *different* files do not yet share an X grid — `BinOpCurve`
truncates to the shorter operand instead of resampling, which is the next
piece of real work. `reduce_many` is a plain loop on the pyramid path and
allocates per curve; it only earns its keep on the exact path.

## License

GPL-2.0-only — see [LICENSE](LICENSE) for the full text. Every source file
carries an `SPDX-License-Identifier` line, so the license is machine-readable
and cannot drift from this paragraph.

The choice is deliberate: this is a viewer for ngspice output, and it belongs
to the same free-software ecosystem as the simulator whose files it reads.
