Skip to content

Report what an NWB file's samples mean, and convert them in the graph - #22

Merged
cboulay merged 8 commits into
devfrom
feature/apply-stored-conversion
Aug 29, 2026
Merged

Report what an NWB file's samples mean, and convert them in the graph#22
cboulay merged 8 commits into
devfrom
feature/apply-stored-conversion

Conversation

@cboulay

@cboulay cboulay commented Aug 28, 2026

Copy link
Copy Markdown
Member

An NWB TimeSeries stores integer samples plus the factors that turn them into the unit it declares:

value = data * conversion * channel_conversion[i] + offset

This package read data and ignored the other three. Nothing raises, because counts and volts are both just numbers — but any downstream stage with an absolute threshold (a rail guard, a power clip) is comparing against the wrong scale, and a chain fed from a live source and one fed from a recording of that same source silently disagree by the gain.

The split

The reader describes; one transformer applies.

NWBSlicer still returns the samples the file stores — unchanged from dev — but every message now carries what they mean:

attrs["nwb_scaling"] = {"gain": 2.5e-07, "offset": 0.0, "unit": "volts",
                        "applied": False, "voltage": True}

NWBScalingUnit acts on that, anywhere in the graph:

NWBScalingUnit(settings=NWBScalingSettings(target_unit="microvolts"))

Keeping conversion out of the reader is both cleaner and marginally faster. Measured end-to-end on a 5.7 GB recording at 1 s chunks: 4.15 ms/chunk converting downstream vs 4.43 ms/chunk converting during the read, over a 4.06 ms floor for reading int16 and not converting. The graph moves int16 until something actually needs volts, and only the messages that survive get widened.

Not a breaking change

The reader returns exactly what dev returns. This PR is purely additive to its behaviour: messages gain attrs["nwb_scaling"], and conversion is one line you opt into. iterator.py and clockdriven.py are byte-identical to dev; slicer.py is +51 lines.

The trade-off is that a naive read still yields counts. That is smaller than it looks: the failure mode being removed was counts labelled volts, and now nothing claims a unit until something has made it true. attrs["unit"] appears only after the transformer runs.

NWBScalingSettings

conversion_dtype Output dtype (default float32)
scale_override Replace the gain, for a file whose recorded factors are wrong
unit_override Replace the unit without touching the gain
target_unit Deliver voltage streams in this VoltageUnit

Each accepts a bare value or a {stream_key: value} mapping keyed on msg.key. They layer deliberately: the overrides establish what the file actually holds, then target_unit converts from that to what you want. So unit_override="volts", target_unit="microvolts" is a net 1e6, where believing the file's own "microvolts" label would have been a no-op.

Why the unit is read off h5py, not pynwb

unit is a fixed field on ElectricalSeries in the NWB schema, so pynwb answers "volts" for every electrical series regardless of the string on disk. Believing it is a factor of 1e6.

Neither the declared unit nor the conversion can be trusted in general. All four of these have been seen on files describing the same hardware at the same 0.25 µV/count: (0.25, microvolts), (1.0, volts, channel_conversion 5e-08), (0.25, volts), (1.0, uV). The second is the dangerous one — a writing library's default gain for a different amplifier, indistinguishable by any reader from a real one. Hence the overrides.

Scope of target_unit

Voltage only. A prefix change is pure arithmetic; anything crossing a dimension needs a model of the circuit and belongs in a stage that can be told about it. A stream qualifies if it is an ElectricalSeries or declares a voltage unit — the latter covers a *_device_ts companion, which points at the very same HDF5 dataset as its acquisition partner but is written as a plain TimeSeries. Gating on type alone gave two scales for the same bytes. The voltage flag is decided at read time and carried, because an ElectricalSeries that stamped no unit is voltage by schema and that fact doesn't survive the trip off the file.

The offset scales with the gain: it is additive in the same unit as the values, so converting only the gain would leave a non-zero-offset stream wrong by a power of ten — and looking converted.

Idempotence and errors

The transformer acts on the applied flag, never on the dtype, so two in a chain cannot double-scale. Inferring "has this been scaled?" from whether the data looks integral is the same silent guessing this PR removes.

Raises rather than guessing: an unrecognized declared unit (pointing at unit_override), a scale_override/unit_override aimed at a message already scaled upstream, and a per-channel gain whose length no longer fits the data because a stage dropped channels.

Performance

Per message, 256 ch int16 @ 30 kHz, after caching a resolved plan per stream:

before after multiply-only floor
1 s (30000×256) 2702 µs 2529 µs 2427 µs
30 smp (30×256) 8.36 µs 3.99 µs 2.90 µs

The largest single cost was is_identity_scaling at 1.59 µs — np.all(np.asarray(gain) == 1.0) on a Python float, two array allocations to answer what == answers. The plan cache is per stream key, since one reader publishes every stream on one output and a single cached plan would thrash on interleaved streams.

Tests

test_scaling.py covers what the reader reports; test_convert.py covers conversion, checked against the NWB definition directly rather than against a second implementation. The fixture makes all three factors non-trivial — a non-unit conversion, a non-zero offset, and a channel_conversion whose entries differ — since each is separately easy to drop and a 1.0/0.0 value can't tell you whether it was applied. It also carries a voltage TimeSeries and a pixels one to pin both halves of the voltage gate.

Also fixes a latent bug: NWBSlicer.close read self._io directly, so __del__ on an object whose __init__ raised turned the real error into an AttributeError from a destructor.

246 tests pass; ruff clean. Verified against a real 5.7 GB recording.

🤖 Generated with Claude Code

An NWB TimeSeries stores integer samples plus the factors that turn them
into the unit it declares: value = data * conversion * channel_conversion
+ offset. This package read data and ignored the other three, handing
every caller raw ADC counts labelled as volts. Nothing raises, because
counts and volts are both just numbers -- but any downstream stage with
an absolute threshold (a rail guard, a power clip) is then comparing
against the wrong scale, and a chain fed from a live source and one fed
from a recording of that same source silently disagree by the gain.

NWBSlicer now resolves those factors per stream and applies them lazily
on read, via a wrapper around the h5py dataset rather than a conversion
at each call site -- the iterator's chunk builder and both slice methods
all go through info.dset, so one wrapper covers them and none can forget.
float32, not float64: broadband is ~80 GB per recording-hour and float32
already carries more mantissa than the int16 going in.

The declared unit cannot be trusted, and neither can the conversion. All
four of (0.25, microvolts), (1.0, volts, with channel_conversion 5e-08),
(0.25, volts) and (1.0, uV) have been seen on files describing the same
hardware at the same 0.25 uV/count -- the second being a writing
library's default gain for some other amplifier, 5x off here and
indistinguishable by any reader from a real one. So scale_override and
unit_override let a caller who knows a writer lies say so, per stream or
for the whole file, without patching the file.

The unit is read off the h5py attribute rather than through pynwb on
purpose: unit is a fixed field on ElectricalSeries in the NWB schema, so
pynwb answers "volts" for every electrical series regardless of what is
on disk. Believing it is a factor of 1e6.

apply_conversion defaults to True. It is a breaking change for anyone
relying on getting counts, and it is the right default anyway: the
failure mode it removes is silently-wrong units, which is exactly the
class of bug that does not announce itself. Pass False to reproduce a
result computed before this.

A stream whose scaling is the identity is left alone rather than copied
into float32, so integer marker channels keep their dtype.

test_writer_roundtrip_continuous now asserts the round trip is
unit-correct (NWBSink declares conversion=1e-6, so a write-read cycle
returns volts for the microvolts it was given) plus a second read with
apply_conversion=False showing the stored samples are untouched.
The previous commit made reads honour what a file says its samples mean.
That still leaves every file dictating the unit on the wire, which is
backwards for a pipeline: a graph is written against one scale, and the
files feeding it disagree -- some volts, some microvolts, all describing
the same amplifier. Correcting that downstream means every consumer
carrying a per-file factor, which is the same silently-wrong-units
failure one layer up.

target_unit converts an electrical stream to a requested VoltageUnit
(volts, millivolts, microvolts, nanovolts) after the file's own factors
resolve. The ratio folds into the gain, so it costs nothing on read --
the same single multiply, with a different constant.

Voltage only, and only on ElectricalSeries. A prefix change is pure
arithmetic on the samples; anything crossing a dimension needs a model of
the circuit and belongs in a processing stage that can be told about it,
not in a reader inferring one from a label. Whether a stream qualifies is
asked of the object rather than of its declared unit -- being an
ElectricalSeries is a structural fact about the file, while the unit
string is exactly the thing this module does not trust. A marker stream
declaring "n/a" is therefore left alone rather than erroring or, worse,
being scaled by 1e6.

Applied after scale_override and unit_override, not instead of them: the
overrides establish what the file actually holds, target_unit converts
from that to what the caller asked for. So a file that lies about both
its gain and its label can be corrected once and then requested in any
unit -- unit_override="volts" with target_unit="microvolts" is a net 1e6,
where believing the file's "microvolts" would have been a no-op.

The offset is scaled along with the gain. It is an additive constant in
the same unit as the values, so converting only the gain would leave a
stream with a non-zero offset wrong by a power of ten -- worse than not
converting, because it looks converted.

An unrecognized declared unit raises rather than passing the data through
under the requested label; a caller who knows better has unit_override.
Empty is the exception: the NWB schema fixes ElectricalSeries.unit to
volts, so a writer that stamped nothing has said volts by omission.
target_unit with apply_conversion=False raises too -- they ask for
opposite things and raw counts have no unit to convert from.

VoltageUnit subclasses str so settings survive a YAML round trip, and any
spelling is accepted (uV, µV, microvolt) so nobody has to guess which one
this module canonicalized on. MICROVOLT_UNITS now derives from that same
table instead of duplicating it.

NWBSlicer.close now reads _io via getattr: __del__ also runs on an object
whose __init__ raised, and reading the attribute directly turned the real
error into an AttributeError from a destructor, surfacing as an unrelated
warning at an unrelated time.
…lSeries

target_unit gated on isinstance(child, ElectricalSeries), reasoning that
the declared unit is the thing this module does not trust. That conflates
two claims the unit string makes. It is unreliable about magnitude --
that is what the table of contradictions is about, and what
scale_override and unit_override exist for -- but about dimension it is
all there is, and a wrong dimension is not a failure mode writers
exhibit. Nobody labels a cursor position in volts.

Gating on the type alone therefore missed voltage that is not an
ElectricalSeries, which is not a corner case: a re-timestamped companion
of an acquisition stream is written as a bare TimeSeries pointing at the
very same HDF5 dataset. Asking for microvolts converted the acquisition
stream and left its companion in volts -- the same bytes on disk
delivered at two scales to the same graph, with nothing raised. That is
the failure target_unit was added to remove, reintroduced by the gate
meant to make it safe.

A stream is convertible now if it is an ElectricalSeries or its declared
unit parses as a voltage. The two pieces of evidence are independent and
either suffices: the type is structural and covers a series that declares
nothing (an ElectricalSeries is voltage by schema), the label is the only
evidence a plain TimeSeries offers. Streams naming something else
(pixels, n/a) still parse to nothing and are still left alone, so the
narrowing that mattered is kept while the one that hurt is dropped.

The benefit of the doubt does not extend to an unstamped plain
TimeSeries: an ElectricalSeries with no unit means volts by schema, but
an empty unit on a bare TimeSeries says nothing, and assuming volts there
would invent a dimension rather than read one.

The fixture grows a voltage TimeSeries and a pixels one, so the two
halves of the gate are pinned by a test each. The pixels stream carries a
non-identity conversion on purpose -- otherwise "left alone" would pass
whether the unit was consulted or not.
apply_conversion=False handed back stored counts with an empty attrs: no
gain, no offset, no unit, no marker saying the values were unscaled. The
factors were not merely withheld, they were never read -- maybe_scale was
skipped entirely -- so the mode was a one-way door. A stage wanting to
scale later, after decimation or on a GPU, had to reopen the NWB file to
recover what the reader had already held.

Messages now carry attrs["nwb_scaling"] = {gain, offset, unit, applied}
in every mode. "Don't apply this" and "forget this" are different
requests and only the first was ever being asked.

The reported figures are the total transformation from stored samples to
delivered values, with scale_override and target_unit folded in, not the
file's raw attributes. A stage that trusted raw attributes would undo the
correction it was handed; what a consumer needs is the relationship
between the numbers it holds and the file, which is what this is.

attrs["unit"] stays absent when nothing was applied. It describes the
data, and the data is counts -- naming them volts is the bug this module
exists to prevent. The unit the values would reach lives inside
nwb_scaling, where it reads as part of a pending conversion rather than
as a claim about what the caller is holding.

gain is a scalar or a vector, mirroring resolve_scaling: uniform
per-channel factors collapse, so the common case reports one number
instead of an array a downstream stage would have to keep aligned with
the ch axis across every channel selection. When the channels genuinely
disagree it stays a vector, since summarizing it would be a lie. That
puts a numpy array inside attrs, a shape nothing else in these messages
has, so a test pins that it survives the message codec.

Namespaced under one key rather than flat conversion/offset/unit entries:
attrs is shared with every stage downstream and three unqualified generic
words are a collision waiting to happen.

maybe_scale becomes resolve_stream_scaling, taking apply as an argument
and returning (dset, unit, scaling). The resolve-always/apply-sometimes
split now lives in one function instead of being spread across a caller's
if-statement, which is what let the no-apply path silently skip
resolution in the first place. StreamInfo carries the same object.
apply_conversion=False now reports its factors, but nothing consumed
them. NWBScalingUnit does: it reads attrs["nwb_scaling"] and does exactly
what the reader would have, wherever it sits in the graph.

Deferring is worth doing because reading raw is the only way to keep
broadband int16 through the stages that do not care about units. Scaling
at the reader doubles every message to float32 immediately -- ~80 GB per
recording-hour becoming ~160 -- and a graph that decimates, subsets
channels, or windows before it needs volts pays that on samples it is
about to discard. Put this after those stages and only the survivors are
converted.

Settings mirror the reader's names and forms (conversion_dtype,
scale_override, unit_override, target_unit, each a bare value or a
{stream_key: value} mapping keyed on msg.key), so moving the work down
the graph is moving the settings rather than rewriting them. Tests pin
the equivalence directly: read raw, transform, and compare against
reading with the same settings applied at the source.

Stateless and idempotent, off the applied flag rather than the dtype. Two
in a chain, or one behind a reader that already converted, cannot
double-scale. Guessing from whether the data looks integral would be the
same silent-inference this whole line of work removes.

target_unit is honoured on an already-scaled message where the gain
overrides are not. A prefix change on top of a known unit is one
multiply: for value = stored * gain + offset, scaling the values carries
the offset with them, so nothing is unwound -- hence unit_ratio, split
out of convert_to_target_unit for the case where only the factor is
wanted. Correcting a gain already applied would instead mean dividing it
back out, a pass over the data and accumulated float error spent fixing
something the reader was better placed to fix, so scale_override and
unit_override raise there and say where to set them.

StreamScaling gains a voltage flag, decided by is_voltage_stream at read
time and carried on the message. One of that predicate's two inputs does
not survive the trip: an ElectricalSeries that stamped no unit is voltage
by schema, but off the file a consumer sees only an empty string.
Deciding where both the type and the label are in hand lets a later stage
convert exactly the streams the reader would have -- the cursor stream
stays pixels.

A per-channel gain is positional, so a stage that dropped or reordered
channels upstream has invalidated it. Length is the only check available
and it catches the common case; a misaligned multiply that happened to
broadcast would be silently wrong, so a mismatch raises and names the
cause.

The scaled_nwb_path fixture moves to conftest since two modules now read
it -- which is the point, as the equivalence being asserted only means
something against one file.
Measured on 256-channel int16 at 30 kHz, per message:

                        before    after    multiply-only floor
  1 s   (30000x256)    2702 us   2529 us   2427 us
  100ms ( 3000x256)     237 us    244 us    230 us
  30smp (   30x256)    8.36 us   3.99 us   2.90 us

Offline was never the problem: at 1 s chunks the overhead was already
under 10% and is now noise against the multiply. The 30-sample case is
where it mattered -- 5.3 us of bookkeeping around 3.1 us of arithmetic --
and that is down to 1.1 us.

The suspect was not the culprit. StreamScaling.from_attr costs 0.33 us of
the 5.3; the single largest item was is_identity_scaling at 1.59 us,
which called np.all(np.asarray(gain) == 1.0) on a Python float -- two
array allocations and a reduction to answer a question == answers. It now
short-circuits on the scalar case, 1.59 -> 0.04 us, and that fix helps
every caller including the reader.

The rest was per-message re-derivation of things that cannot change
between messages: resolving overrides, parsing the target unit, rebuilding
the output attrs dict. NWBScalingTransformer becomes a
BaseStatefulTransformer that caches a resolved plan -- gain pre-cast to
the output dtype and pre-reshaped for broadcast, offset as None when zero
so the common case skips a pass, and the complete output attrs prebuilt.
_process is now a dict lookup, one multiply, and a replace.

The plan cache is per stream key rather than a single current plan,
because one reader publishes every stream on one output. With a single
plan two interleaved streams would evict each other on every message and
the cache would cost more than it saved; verified on a real recording that
two interleaved 256- and 128-channel streams come out bit-identical to
scaling at the reader.

Cache validation holds a reference to the attrs payload it was built from
and compares identity. Holding it is the point: an object we hold cannot
be freed and have its address reused by a different payload, which is the
trap in caching on a bare id(). _hash_message may therefore be as sloppy
as it likes -- it uses id() -- because a collision costs one dict lookup
in _reset_state, which re-validates, and not a wrong scaling.

The per-channel length check stays per message rather than moving into the
plan: the plan is keyed on the reported scaling, and an upstream channel
selection invalidates a positional gain without changing what the message
reports about it.

On the question of whether the iterator could stop scaling and defer to
this module: end to end on a real recording at 1 s chunks, reading raw and
transforming is 4.15 ms/chunk against 4.43 ms/chunk scaling at the reader,
over a 4.06 ms floor for reading int16 and not scaling at all. So routing
through convert.py is not a tax -- it is marginally cheaper, since the
iterator then moves int16 rather than float32 and only the surviving
message is widened. That removes the performance argument for the change,
which leaves only the correctness one, and that argument runs the other
way: apply_conversion=True is the default because silently-wrong units is
the failure being designed out. Keeping both, with the reader's default
intact, costs nothing measurable.
Two implementations of one conversion, five settings duplicated across
three classes, and a reader that had to be told which of two things it
was. Since the measurement showed deferring costs nothing -- 4.15 vs 4.43
ms/chunk at 1 s, because the graph then moves int16 until something
actually needs volts -- the duplicate can go.

NWBSlicer, NWBIteratorSettings and NWBClockDrivenSettings lose
apply_conversion, conversion_dtype, scale_override, unit_override and
target_unit. ScaledDataset and unwrap_dataset go with them, and with the
wrapper gone so does its subtlety: _data_addr no longer has to unwrap
before comparing HDF5 addresses, a step whose omission would have quietly
demoted the dejitter pass's structural pairing check to the name
convention it exists to back up. resolve_scaling drops its override and
target arguments and reports the file verbatim.

iterator.py and clockdriven.py are now byte-identical to dev. slicer.py's
net addition drops from 125 lines to 51 -- a StreamInfo field, the
describe call, and a docstring. The whole conversion lives in one module
that does nothing else.

This also removes the breaking change. Those five settings only ever
existed on this branch, so deleting them leaves the reader returning
exactly what dev returns: stored counts. The difference is now purely
additive -- messages carry attrs["nwb_scaling"] -- and nobody's existing
results move. What used to be a silent behaviour change becomes an opt-in
one line in the graph.

The cost is that a naive read still gets counts, which is the bug this
line of work started from. It is a smaller cost than it looks: the failure
mode before was counts labelled volts, and now nothing claims to be volts
until something has made it so. attrs["unit"] appears only after the
transformer runs, and until then the message says what it is and what it
would take. Reader docstrings say so at the top.

resolve_stream_scaling becomes describe_stream_scaling, which returns a
StreamScaling or None and cannot apply anything. applied is always False
coming out of the reader; it turns True only where a multiply happened.

Tests split the same way. test_scaling.py checks what the reader reports
and never that it converts; test_convert.py checks conversion against the
NWB definition directly -- data * conversion * channel_conversion + offset
-- rather than against a second implementation, since there is no longer
one to agree with. The writer round trip asserts the file's samples come
back unchanged and that running the transformer is what makes them volts.
@cboulay cboulay changed the title Apply the scaling an NWB file stores, and let the graph pick the unit Report what an NWB file's samples mean, and convert them in the graph Aug 29, 2026
…shing

test_nwbiterator_unit_system failed intermittently in CI -- twice in
recent runs, once on Windows and once on macOS, always as
`assert 2000 == 3000`, one 1000-sample chunk short. It passes 25/25
locally and 12/12 under 12x CPU load, so the trigger is runner speed
rather than anything reproducible here.

The cause is not in this branch. `NWBIteratorUnit` yields its last chunk
and immediately raises NormalTermination, which sets ezmsg's terminate
event; monitor_termination then cancels every task outright. There is no
drain phase, and the MessageLogger is in another process, so a message
still in flight when the cancel lands is simply gone. That is a real
defect in self_terminating and it predates this branch -- the test and
both units it exercises are untouched here. Scaling changed the timing
around it (int16 rather than float32 on the wire, one more attrs entry to
serialize), which is enough to move a race but not to create one.

So the test now terminates the way the clock-driven test two functions
below already does: TerminateOnTotal counting messages out of the sink,
with the source told not to self-terminate. That is deterministic rather
than merely luckier -- MessageLogger writes and flushes each message
before forwarding it, so when the count reaches the total every message is
already on disk.

TerminateOnTimeout is wired alongside as a backstop, not as a second
termination path. Waiting for a count converts a lost message from a
failure into a hang, and a hung job is a worse signal than a red one: it
burns the CI timeout and says nothing. Verified by asking for four
messages from a three-message stream -- the run ends in 2.1 s and the
assertions report 3.

The assertion tightens from `len(messages) > 0` to `== n_messages`, since
the count is now known exactly.

This makes CI green. It does not fix the underlying shutdown race, and it
no longer covers it; a source that self-terminates can still drop trailing
messages, which is worth raising with ezmsg core.
@cboulay
cboulay merged commit efb1176 into dev Aug 29, 2026
14 checks passed
@cboulay
cboulay deleted the feature/apply-stored-conversion branch August 29, 2026 06:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant