From b505706d92b70d1f4a3433e4ba124b2494f3e626 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 5 Oct 2026 17:28:51 +0200 Subject: [PATCH] fix: a readout keeps its metric prefix when the stream declares no unit The SI prefix ladder in format_measure() was gated on a non-empty unit, so two decimals of a small unitless value printed `0.00` -- the reading gone, not merely coarse. That is a whole class of channels rather than a corner: a CONFIG need not declare a unit, and a math channel never has one. Below 1e-12 the same loss happened even with a unit, so that range falls back to three significant digits. With no unit the prefix goes tight against the number (`585.24m`), since the space exists to separate the number from its unit and a spaced `m` alone reads as metres. The mock declared `unit="V"` for every data signal, which is why no probe could see this; it now takes `--unit ''`. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 13 ++++++++++++ README.md | 11 +++++++++- src/help/09-cursors.txt | 6 ++++++ src/help/10-measurements.txt | 6 +++++- src/ui.cpp | 41 ++++++++++++++++++++---------------- tools/mock_udpstreamer.py | 14 +++++++----- 6 files changed, 66 insertions(+), 25 deletions(-) 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("