ezmsg.nwb.scaling#
Apply the scaling an NWB file stores alongside its samples.
An NWB TimeSeries almost never stores the values it means. It stores integer
samples plus the factors that turn them into the unit it declares:
value = data * conversion * channel_conversion[i] + offset
conversion is a scalar, channel_conversion an optional per-channel
vector (ElectricalSeries only), offset an additive constant. Reading
data and ignoring the rest hands the caller raw ADC counts while every
label on the file says otherwise – and nothing raises, because counts and
volts are both just numbers. Any downstream stage with an absolute threshold
(a rail guard, a power clip) is then comparing against the wrong scale.
So this module resolves those three factors into a single gain and offset
(resolve_scaling()) and describes them for the reader to attach to every
message (describe_stream_scaling(), StreamScaling).
Nothing here applies anything. The reader hands back the integers the file
stores, plus this description of what they mean;
NWBScalingUnit does the arithmetic, wherever in the
graph it belongs. Splitting it that way is what keeps the two halves honest: the
reader has one job and states a fact about the file, the transformer has one job
and states what it did. It is also no slower – measured on a real recording at
1 s chunks, reading int16 and converting downstream beats converting during the
read, because the reader then moves half as many bytes and only the messages
that survive get widened.
The declared unit cannot be trusted, and neither can the conversion. All four of these combinations have been seen on files describing the same hardware at the same true scale of 0.25 uV/count:
|
|
|
True scale |
|---|---|---|---|
0.25 |
|
absent |
0.25 uV |
1.0 |
|
5e-08 |
0.25 uV |
0.25 |
|
absent |
0.25 uV |
1.0 |
|
absent |
0.25 uV |
Rows 1 and 3 agree about the number and disagree about the unit, so trusting
the label is a factor of 1e6 either way. Row 2 is the dangerous one: the whole
scale sits in a channel_conversion that is a writing library’s default for
some other amplifier, 5x off for the hardware that actually recorded it –
and no amount of care in a reader can tell a wrong gain from a real one.
That is why the scaling is overridable per stream on the transformer
(scale_override / unit_override): reporting the file verbatim is the
right thing for a reader to do, but a caller who knows a particular writer lies
needs to say so without patching the file.
Separately from what the file means, a pipeline has an opinion about what it
wants on the wire: target_unit converts a voltage stream to a requested
VoltageUnit on top of whatever the file (as corrected) turned out to
be, so a graph can be written against microvolts and fed by files that disagree
about their own scale. It applies only to voltage streams – see
is_voltage_stream() and convert_to_target_unit().
Module Attributes
How many volts one of each unit is. |
|
Unit strings seen in the wild, lowercased, mapped to what they mean. |
|
Spellings of "microvolts", lowercased. |
|
Output dtype once a gain has been applied. |
|
Prefix under which a message reports the scaling that relates it to the file. |
|
Every key |
Functions
- coerce_voltage_unit(value)[source]#
A
VoltageUnitfrom an enum member or any spelling of one.So
target_unit="uV"works as well asVoltageUnit.MICROVOLTS– the caller writing the setting should not have to know which spelling this module canonicalized on.- Return type:
- Parameters:
value (Any)
- convert_to_target_unit(gain, offset, unit, target, *, stream_key='')[source]#
Fold a unit change into an already-resolved
(gain, offset, unit).The samples are
data * gain + offsetin unit; scaling both by the ratio between the units leaves them in target. Offset included – it is an additive constant in the same unit as the values, so a conversion that scaled only the gain would leave a stream with a non-zero offset wrong by a power of ten, which is worse than not converting at all.Applied after the overrides, on purpose:
scale_overrideandunit_overrideestablish what the file actually holds, and this converts from that to what the caller asked for. So a file that lies about both can still be corrected once and then requested in any unit.Raises
ValueErrorwhen unit is a string this module cannot place – the conversion needs a starting point, and passing the data through unconverted while relabelling it target would manufacture exactly the silently-wrong units the rest of this module exists to prevent. An empty unit is the one exception: the NWB schema fixesElectricalSeries.unittovolts, so a writer that stamped nothing has told us volts by omission.
- describe_stream_scaling(dset, channel_conversion=None, *, is_electrical=False)[source]#
What a stream’s stored samples mean, for the reader to report.
Describes; never applies. The reader hands back the integers the file stores and this alongside them, so
convertcan do the arithmetic wherever in the graph it belongs.appliedis therefore always False here – it turns True only once something has actually multiplied.None for a stream with no scaling to speak of: a materialized text column has no gain, and reporting an identity one would invite a consumer to multiply strings.
- Return type:
- Parameters:
- is_identity_scaling(gain, offset)[source]#
Whether applying this scaling would leave every sample unchanged.
Worth checking: a stream that declares no scaling is already in the unit it claims, so wrapping it would only trade its stored dtype for a float32 copy of the same numbers – a needless doubling on an int16 broadband stream, and a surprise for a caller reading an integer marker channel.
The scalar case avoids numpy entirely.
np.all(np.asarray(x) == 1.0)on a Python float costs ~1.5 us – two array allocations and a reduction to answer a question==answers – which is a third of the budget for a 30-sample message and was the single largest per-message cost in the downstream transformer.
- is_voltage_stream(unit, is_electrical)[source]#
Whether
target_unitshould convert a stream declaring unit.Two independent pieces of evidence, either sufficient. is_electrical is a structural fact – an
ElectricalSeriesis voltage by schema, whatever string it carries, including none. A unit thatparse_voltage_unit()places is a declared fact, and it is the only evidence available for a plainTimeSeriesholding voltage: writers store electrical data outsideElectricalSeriesroutinely – a re-timestamped companion of an acquisition stream, pointing at the very same HDF5 dataset, is written as a bareTimeSeries. Converting one and not the other would hand two scales for the same bytes to the same graph, silently, which is the failuretarget_unitexists to remove.Trusting the label here does not contradict distrusting it elsewhere. The unit string is unreliable about magnitude – that is what this module’s table of contradictions is about, and what
scale_override/unit_overrideexist for – but about dimension it is all there is, and a wrong dimension is not a failure mode writers exhibit: no one labels a cursor position in volts. Streams whose unit names something else (pixels,n/a) do not parse, so they are left alone.
- override_for(override, key)[source]#
Resolve a bare-value-or-per-stream-mapping override for one stream.
Both spellings are accepted because both are natural: a single value when every stream in the file shares the problem (one writer wrote them all), a
{stream_key: value}mapping when only one does.
- parse_voltage_unit(unit)[source]#
The
VoltageUnita declared unit string names, or None.None for a string this module does not recognize and for the empty string, which are different problems for the caller: see
convert_to_target_unit().- Return type:
- Parameters:
unit (str)
- read_stored_scaling(dset)[source]#
(conversion, offset, unit)as literally stored on a data dataset.Read off the h5py attributes rather than through
pynwb, because pynwb does not report what the file says:unitis a fixed field onElectricalSeriesin the NWB schema, so pynwb returns the schema’s"volts"for every electrical series regardless of the string on disk. On a recording whosedataattribute readsmicrovolts, that is the difference between a scale factor of 0.25 and one of 2.5e-07.Missing attributes fall back to the NWB defaults (1.0, 0.0, “”).
- resolve_scaling(dset, channel_conversion=None)[source]#
Collapse a stream’s stored scaling into one
(gain, offset, unit).Verbatim: what the file says, with nothing corrected and nothing converted. Overrides and target units belong to
convert, which is where the applying happens; a reader that pre-corrected would leave the reported scaling describing neither the file nor the data.gainisconversion * channel_conversion– a scalar when the per-channel factors are absent or all equal (the common case, and the one that keeps the multiply a scalar broadcast), a vector only when the channels genuinely disagree.
- scaling_fingerprint(attrs)[source]#
A hashable summary of a message’s reported scaling, or None if it has none.
Compares by value. The nested-dict form could be cached on the identity of the one payload object, which is cheaper, but that only worked because the reader built each stream’s attrs once and every message shared it: an upstream stage that rebuilds attrs per message would have thrashed such a cache anyway, and with five separate keys there is no single object left to anchor on.
A vector gain is summarised by its bytes rather than its
id, so that an array rebuilt with the same contents still hits. That costs a pass over the channel axis, but it is a few hundred floats against a message of samples, and it avoids having to pin the array alive to keep its address unique.
- unit_ratio(unit, target, *, stream_key='')[source]#
The factor taking a value in unit to the same value in target.
Split out from
convert_to_target_unit()because a consumer holding values that were already scaled needs the factor alone: for data that isstored * gain + offset, multiplying the values by this ratio is the whole conversion, since it carries the offset along with them. Recomputing a gain and an offset for that case would mean unwinding what was applied.- Return type:
- Parameters:
unit (str)
target (VoltageUnit)
stream_key (str)
Classes
- class StreamScaling(gain, offset, unit, applied, voltage=False)[source]#
Bases:
NamedTupleThe total transformation from a stream’s stored samples to its values.
value = stored * gain + offset, expressed inunit. Total, not the file’s raw attributes: anyscale_overrideandtarget_unitare already folded in, so this describes the numbers a caller actually holds (or, whenappliedis False, the ones they would hold after applying it themselves).- applied: bool#
False when the caller asked for the stored samples (
apply_conversionoff), in which case the data is raw and this is what to do about it.
- voltage: bool#
Whether
target_unitmay convert this stream –is_voltage_stream()decided, at read time.Carried rather than re-derived downstream because one of its two inputs does not survive the trip: an
ElectricalSeriesthat stamped no unit is voltage by schema, but off the file all a consumer sees is an empty string. Deciding once, where both the type and the label are in hand, is what lets a later stage convert exactly the streams the reader would have.
- as_attrs()[source]#
The
nwb_scaling_*entries to merge into a message’s attrs.Flat scalars, so every stage that walks attrs generically can read, compare, and merge them – see
SCALING_ATTR.gainis the one that can still be a non-scalar, when the channels genuinely disagree.
- classmethod from_attrs(attrs)[source]#
Rebuild from a message’s attrs, or None if it reports no scaling.
Keyed on
gain: a message either carries the whole set or none of it, and treating a partial set as absent is the safe reading – inventing a gain for a message that never declared one is the failure this module exists to prevent.- Return type:
- Parameters:
- class VoltageUnit(*values)[source]#
-
A unit a voltage stream can be delivered in.
Voltage only, and deliberately so: an
ElectricalSeriesis voltage by definition (the NWB schema fixes itsunittovolts), so a prefix change is the only conversion that is pure arithmetic on the samples. Anything crossing a dimension – volts to amperes, say – needs a model of the circuit, which belongs in a processing stage that can be told about it, not in a file reader guessing from a label.Subclasses
strso settings can be given as plain strings and survive a YAML round trip:VoltageUnit("microvolts") == "microvolts".- VOLTS = 'volts'#
- MILLIVOLTS = 'millivolts'#
- MICROVOLTS = 'microvolts'#
- NANOVOLTS = 'nanovolts'#