diff --git a/CLAUDE.md b/CLAUDE.md
index 7a58581..cb97c64 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -792,6 +792,19 @@ far the largest file.
`crosshair_interpolated_mode_blends_the_two_samples` and
`the_crosshair_mark_is_bigger_than_a_sample_dot`, plus `[cursors-probe]`'s `first mark at x=..
y=..` agreeing with the pane's first value.
+- **`format_measure()` is the one formatter for both panes, and its SI prefix does not depend on
+ there being a unit.** The cursors pane's values and the measurements pane's `text=` go through
+ it, so a change reaches both. The prefix ladder used to be inside `if (!unit.empty())`, which
+ made two decimals of a small unitless value read `0.00` — the reading gone, not merely coarse —
+ and that is a whole class of channels, not a corner: `measure_unit()` returns `""` for any signal
+ whose CONFIG declares no unit, and a **math channel never has one**. The regression net could not
+ see it because `tools/mock_udpstreamer.py` declared `unit="V"` for every data signal; it now takes
+ **`--unit ''`**, and that is the flag to run the measures and cursors probes under when touching
+ this (`text=[636.62m]`, `● sine=585.24m`). With no unit the prefix is written **tight** against
+ the number — `1.34m`, not `1.34 m` — because the space exists to separate the number from its
+ unit and a spaced `m` alone reads as metres; with one it stays spaced and the prefix joins the
+ unit (`636.62 mV`). Below `1e-12` the ladder runs out, and `%.2f` would print `0.00` again, so
+ that range falls back to `%.3g`.
- **The cursors are read back from the plots only while a gesture is on that row.** `Cursor::x`
is the *free* pointer position while a drag runs and the snapped value otherwise -- which is
what the app itself wrote a frame earlier, or the snap of it. A tick that reads a row on
diff --git a/README.md b/README.md
index e373652..669071b 100644
--- a/README.md
+++ b/README.md
@@ -1185,7 +1185,16 @@ Every readout is a **measurement**, not a raw number: at most two decimals, scal
SI prefix so the digits sit in `1..999`, and the unit the stream declared appended —
`10.52 mV`, `-9.8 V`, `dT -398.67 ms`, `1.06 pV`. Two decimals is the resolution the
instruments can claim (a marker lands on a sample, and the sample is an `f32`), and a
-trailing zero is dropped, so `1.50 V` reads `1.5 V`. A row's rulers use its signals' unit
+trailing zero is dropped, so `1.50 V` reads `1.5 V`. The prefix is applied **whether or not
+the stream declared a unit**, and that is not a detail: a signal whose CONFIG declares none is
+ordinary, a *math* channel never has one, and two decimals of an unprefixed small value is
+`0.00` — the reading was simply gone for an entire class of channels, which went unnoticed
+because the mock declares `V` for everything (it now takes `--unit ''`, which is the only way
+a probe can see this). With no unit the prefix is written *tight* against the number —
+`585.24m`, not `585.24 m` — because the space is there to separate the number from its unit,
+and a spaced `m` with nothing after it reads as metres. Below `1e-12`, where no prefix can
+help, the value falls back to three significant digits in whatever form fits rather than to
+`0.00`. A row's rulers use its signals' unit
when they agree on one and print bare digits when they do not: a ruler is on the row's y
axis, and two units on one axis make "the" unit a guess. A trace's line carries the
signal's **colour chip and its name** — `● sine 616.49 mV -24.57 mV -641.06 mV` — and the
diff --git a/src/help/09-cursors.txt b/src/help/09-cursors.txt
index ea706e1..300e25a 100644
--- a/src/help/09-cursors.txt
+++ b/src/help/09-cursors.txt
@@ -22,6 +22,12 @@ owns a Reset button. Drag a marker to move it. The mode dropdown offers f
• A value is the nearest stored sample's, never a number between two samples. Zoom in past
the sample spacing and the line between two samples is something the plot drew, not data —
so a reading off it would be the drawing. The hover crosshair reads the same way.
+• Every reading carries a metric prefix so the digits sit in 1..999:
+ 10.52 mV, not 0.01 V. Two decimals is all an instrument landing on an
+ f32 sample can claim, so without the prefix a small value would read 0.00
+ and the reading would be gone. A stream that declares no unit gets the prefix anyway, written
+ tight against the number (585.24m) — the space separates the number from its unit,
+ and a spaced m with nothing after it would read as metres.
• A marker past the newest sample reads --. With only one marker over data the row
reads partial: a difference between a number and nothing is not a number.
• In mix each marker also places a crossing annotation per trace, which is what lets
diff --git a/src/help/10-measurements.txt b/src/help/10-measurements.txt
index 59d20d9..151dfa9 100644
--- a/src/help/10-measurements.txt
+++ b/src/help/10-measurements.txt
@@ -27,7 +27,11 @@ set, with Save and Delete.
• A window with too few crossings reads invalid / -- rather than a plausible
invented number. Frequency over a window holding one edge is the usual case.
• frequency is reported in Hz and period/pulse duration in seconds;
- every other type carries the channel's own unit.
+ every other type carries the channel's own unit. A value is written with a metric prefix
+ so its digits sit in 1..999 — 636.62 mV, not 0.64 V — including when
+ the stream declares no unit at all, which is always the case for a math channel: there the
+ prefix goes tight against the number (636.62m), since two decimals of an unprefixed
+ small value is 0.00.
• Cost is bounded by reading the summary pyramid the producer already maintains, not the raw
window. The values are still exact: a bucket's sum is a sum.
diff --git a/src/ui.cpp b/src/ui.cpp
index a0de078..6f10f6c 100644
--- a/src/ui.cpp
+++ b/src/ui.cpp
@@ -922,6 +922,13 @@ const char* badge_markup(BadgePhase phase) {
/// sample is an `f32` of a value the CONFIG may scale arbitrarily, so more digits would be
/// noise dressed as precision. Trailing zeros go, so `1.50 V` reads `1.5 V` -- "at most
/// two decimals", not exactly two.
+///
+/// The prefix is applied **whether or not a unit is declared**, and that is the point: two
+/// decimals of an unprefixed small value is `0.00`, i.e. the reading is simply gone. A signal
+/// whose CONFIG declares no unit is ordinary (and a *math* channel never has one), so the pane
+/// showed `0.00` for every value under 5 mV of an entire class of channels. A bare prefix is
+/// written tight against the number -- `1.34m`, not `1.34 m` -- because the space is there to
+/// separate the number from its unit, and with no unit to separate, a spaced `m` reads as metres.
std::string format_measure(double v, const std::string& unit) {
if (!std::isfinite(v)) return "--";
static const struct {
@@ -934,22 +941,22 @@ std::string format_measure(double v, const std::string& unit) {
const double mag = std::fabs(v);
const char* prefix = "";
double scale = 1.0;
- // No unit, no prefix: `10.52 m` is a prefix with nothing to prefix, which reads as
- // metres. A bare number stays a bare number and simply keeps its decimals.
- if (!unit.empty()) {
- for (const auto& p : kPrefixes) {
- if (mag >= p.scale) {
- prefix = p.prefix;
- scale = p.scale;
- break;
- }
- }
- if (mag > 0.0 && mag < 1.0e-12) { // below the ladder: keep it in plain digits
- prefix = "";
- scale = 1.0;
+ for (const auto& p : kPrefixes) {
+ if (mag >= p.scale) {
+ prefix = p.prefix;
+ scale = p.scale;
+ break;
}
}
char buf[64];
+ if (mag > 0.0 && mag < 1.0e-12) {
+ // Below the ladder, where scaling cannot help and `%.2f` would print `0.00` again. Three
+ // significant digits in whatever form fits, so the reading survives as a reading.
+ std::snprintf(buf, sizeof(buf), "%.3g", v);
+ std::string out = buf;
+ if (!unit.empty()) out += " " + unit;
+ return out;
+ }
std::snprintf(buf, sizeof(buf), "%.2f", v / scale);
std::string num = buf;
if (num.find('.') != std::string::npos) { // at most two decimals, not exactly two
@@ -958,11 +965,9 @@ std::string format_measure(double v, const std::string& unit) {
}
if (num == "-0") num = "0";
std::string out = num;
- if (prefix[0] != '\0') out += std::string(" ") + prefix;
- if (!unit.empty()) {
- if (prefix[0] == '\0') out += " ";
- out += unit;
- }
+ if (!unit.empty()) out += " "; // the space separates the number from its unit, nothing else
+ out += prefix;
+ out += unit;
return out;
}
diff --git a/tools/mock_udpstreamer.py b/tools/mock_udpstreamer.py
index 10f7bd8..f22f831 100644
--- a/tools/mock_udpstreamer.py
+++ b/tools/mock_udpstreamer.py
@@ -50,16 +50,16 @@ def signal_desc(name, *, rate, time_mode, time_idx, unit, rows=1, cols=1, dims=0
return bytes(d)
-def build_config(rate, time_mode=TIME_FULL_ARRAY):
+def build_config(rate, time_mode=TIME_FULL_ARRAY, unit="V"):
sigs = [
# The stream's clock: referenced by every data signal, so udpscope treats
# it as the time base rather than a plottable channel. Its *own* time_mode
# is what udpscope switches on, so `--time-mode first` has to land here.
signal_desc("time", rate=0.0, time_mode=time_mode,
time_idx=NO_TIME_SIGNAL, unit="s"),
- signal_desc("sine", rate=rate, time_mode=time_mode, time_idx=0, unit="V"),
- signal_desc("square", rate=rate, time_mode=time_mode, time_idx=0, unit="V"),
- signal_desc("ramp", rate=rate, time_mode=time_mode, time_idx=0, unit="V"),
+ signal_desc("sine", rate=rate, time_mode=time_mode, time_idx=0, unit=unit),
+ signal_desc("square", rate=rate, time_mode=time_mode, time_idx=0, unit=unit),
+ signal_desc("ramp", rate=rate, time_mode=time_mode, time_idx=0, unit=unit),
]
payload = struct.pack("