mef3io 2.0.0¶
Released 2026-10-26.
A major release: it removes the per-call n_threads argument and the
durability option, and changes what a few calls return. Read
Upgrading from 1.x before you update. The detailed
sections follow it.
What's new¶
- Opening a session is parallel, with one thread rule for everything. An
object's
n_threadscovers opening, file access, decoding, decrypting and encoding (0= automatic,1= no extra threads) and can be changed at runtime. On a slow network share a 209-channel session opens in 10.9 s instead of 173 s. See Threads. - Read many channels in one call.
r.read(["ch1", "ch2"], t0, t1)returns a 2-D array, each row identical to the single-channel read, anddtype="float32"halves the memory. - Conversion factors are per segment. A channel whose segments store
different factors now reads back correctly, and
info(),segments()andread_raw()say which factor applies where. anonymize_sessionkeeps the dates and times unless you give a shift. The identity is removed exactly as before. A kept channel named after the patient is now refused before anything is copied.precision="step"stores data from an amplifier (for example 0.195 µV per count) as its original counts: 1.52 MB instead of 6.03 MB for an hour at 512 Hz.- Out-of-range samples are clamped, with a warning, instead of wrapping silently into a wrong value.
durability=is gone.Writer.flush(),close()and the opt-inwait_for_disk=Truereplace it; see When is my data on disk?.- Recovery covers every crash state a power cut can leave after an append, and re-derives the declarations itself.
Upgrading from 1.x¶
Each item says what changed and what to do. The details are in the sections below.
Threads¶
| What changed | What to do |
|---|---|
Reader.read / read_raw (and MultiReader) no longer take n_threads= |
Set r.n_threads = N instead; it applies from the next call. Passing the argument raises TypeError. |
n_threads must be an int >= 0; a bool or float raises TypeError |
Pass 0 for automatic. |
Negative counts are errors everywhere (before: "all cores"): ValueError, C++ std::invalid_argument, C ABI MEF3IO_ERR_ARGUMENT |
Use 0. |
MultiReader(n_threads=) is a total shared by its recordings, not a count per recording |
Raise it if you relied on N threads per recording. |
n_threads=1 makes the open serial too |
To keep only decoding serial, open automatically and set r.n_threads = 1 afterwards. |
| The default open starts at least 8 threads per open pass | An application opening many Readers from its own pool should pass a small n_threads. |
C++: Reader::read / read_raw take (channel, t0, t1, ctl); Reader::kUseDefaultThreads is gone; cancel=/progress= are keyword-only in the C++ binding |
Update the calls; set the count with set_threads. |
Writing and durability¶
| What changed | What to do |
|---|---|
Writer(durability=) / MefWriter(durability=) removed (TypeError); also C++ SessionWriter::set_durable / durable() / SegmentSpec::durable, C ABI mef3io_writer_set_durable, MEX writer_set_durable, MATLAB Writer(Durability=) |
durability="fast": remove it (now the default). durability="full": wait_for_disk=True, or the default plus w.flush() at the points that must survive a power cut (cheaper). |
close() and leaving a with block now flush, and raise if the flush fails |
Nothing, unless you relied on close() ignoring a flush error. C++ destructor and mef3io_writer_close cannot report a failure: call flush() first. |
A failed flush is remembered: every later flush()/close() raises again |
Treat the first failure as final. |
The default no longer flushes the two headers an append patches, so the .tmet can reach the disk before the data it describes |
After an unclean shutdown, run python -m mef3io recover SESSION.mefd (dry run, lists what it would fix), then add --apply. |
recover --apply no longer runs a session-wide repair, and --no-repair is gone |
Run repair by explicit check id for findings on healthy segments. |
recover (dry run and --apply) now also finds and re-derives stale declarations in each channel's last segment, and recovers an index that fails its own CRC; RecoveredSegment.declarations is new |
Nothing to change; re-run the dry run on sessions you had marked healthy (it can now list segments 1.x called healthy). |
Reading and conversion factors¶
| What changed | What to do |
|---|---|
info(ch)["units_conversion_factor"] is None when a channel's segments store different factors (C ABI and MATLAB: NaN; C++ std::optional) |
Handle None; use segments() for each segment's factor. |
read_raw()'s units_conversion_factor is None when the window mixes factors; units_conversion_factors gives the spans |
Do not compute samples * units_conversion_factor on a mixed window: use the spans, or read(). |
MultiReader.read_raw() no longer refuses recordings with different factors |
Use the spans. |
Session.read_runs() runs carry the factor and split where it changes; the compat MefReader's ufact is [None] for such a channel |
Update code that assumed one run per gap-free stretch. |
A segment storing factor 0 ("not set") reads as the raw counts, with UnscaledSamplesWarning (it read as all zeros before) |
Check your files (r.segments(ch)); if you know the real factor, scale read_raw counts yourself. |
A NaN or infinite stored factor raises FormatError (C ABI MEF3IO_ERR_FORMAT) on read()/block_envelope() |
Repair or rewrite the file. |
| A channel with different per-segment factors read back wrong before (first segment's factor for all) | Re-read; nothing to change in files. |
block_envelope(): min and max are no longer swapped for a negative factor |
None. |
MefReader.get_data(list) uses the multi-channel read |
None (same arrays). |
Writing signals¶
| What changed | What to do |
|---|---|
Sampling frequency 0, negative, NaN or infinite, and ufact 0, NaN or infinite raise ValueError before anything is written (C++ std::invalid_argument, C ABI MEF3IO_ERR_ARGUMENT, MATLAB error). A negative factor is still accepted (polarity inversion) |
Pass the real factor (or 1.0) instead of ufact=0. |
Block length is capped at 858 993 396 samples (ValueError); rates below 0.1 Hz write one sample per block |
Pass a smaller block_length. |
A write_int32 valid mask of another length raises ValueError |
Fix the mask. |
Float samples outside int32 at the factor, and +-inf, are clamped to ±(2**31 - 1) counts with one ClampedSamplesWarning (before: wrapped silently); the write summary gains clamped and clamped_infinite; inferred precision requires both ends of the data to fit (so one-sided large data can store a smaller precision, and a different conversion factor, than in 1.x) and ignores +-inf (it used to drag the precision to 0) |
Replace such samples with NaN before writing if you want them as gaps. Pass precision= explicitly if you need the 1.x stored factor. |
| A default-precision append to a channel whose factor is not a power of ten is refused | Use "step" or write_int32. |
De-identification¶
| What changed | What to do |
|---|---|
anonymize_session(src, dst) needs no shift: without one the copy keeps every date and time (shift_us=None; C++ std::optional<si8>; CLI works without --shift-us/--reference-date) |
If the dates must not be shared, pass shift_us (or --reference-date/--shift-us) explicitly: a script that treated a missing shift as an error now makes a copy with the real dates. |
anonymize.check(src, dst) without a shift checks the copy that keeps the times, so it no longer reports rto-disputed, time-before-rto and the other shift-only refusals; verify likewise |
Pass the shift you will use to check or verify a shifted copy. |
anonymize.redact_text takes no shift, so the shift-only refusals are gone from it |
Use anonymize.check(..., shift_us=...) as the pre-check for a shifted copy. |
shift_us=0 is still refused (shift-zero) |
Leave the shift out to keep the times. |
C++: check_anonymize lost its check_shift parameter; AnonymizeReport::shift_us is optional |
Update the calls. |
New refusal codes channel-name-identity (a kept channel named after the patient, raised before copying; code that caught verify-failed for it now gets this, same WriteConflictError) and time-range-offset-disputed |
channel-name-identity (anonymize only): leave that channel out with exclude_channels, or rename it. time-range-offset-disputed (in both anonymize.REFUSAL_CODES and transform.REFUSAL_CODES): copy without time_range. |
Text callbacks (text=callable) get info["time"] = None for a record whose time cannot be known (no shift, segments disagree on the offset); AnonymizeReport.source_recording_time_offset is 0 when segments disagree |
Handle None in callbacks that do arithmetic on info["time"]. |
An unclosed Writer that fails to flush when garbage-collected emits WriterFlushWarning; the compat MefWriter.mef_block_len no longer keeps a refused value |
Close writers (or use with). |
Cuts (time_range) place each segment and annotation file by its own recording-time offset |
None; a transform's cut over disputed offsets is now correct. |
Files, cache and bindings¶
| What changed | What to do |
|---|---|
cache="auto" snapshots are named <hash>.mef3cache, not <session name>-<hash>.mef3cache (the name could identify the subject); the first auto-cache open after upgrading deletes the session's old-named snapshot and builds a new one |
Nothing. |
C ABI: mef3io_writer_flush, mef3io_writer_set_wait_for_disk, mef3io_reader_set_threads / mef3io_reader_threads, mef3io_writer_threads, mef3io_reader_read_raw_factors, mef3io_reader_segment_units_conversion_factor, mef3io_check_block_length / mef3io_check_signal_parameters, MEF3IO_MAX_BLOCK_SAMPLES added; mef3io_writer_set_durable removed; mef3io_channel_info's factor is NaN for mixed factors; negative thread counts return MEF3IO_ERR_ARGUMENT |
Rebuild against the new header. |
MATLAB: Writer(Durability=) removed (now an error); Writer(WaitForDisk=true), w.flush(), setThreads / threads added; info gives NaN for mixed factors; readRaw has units_conversion_factors (1-based, inclusive); Writer.write / writeInt32 accept sparse input, and the MEX refuses sparse or complex array arguments elsewhere |
Remove Durability; update readRaw users. |
C++: Session's constructors take an optional n_threads after strict; constructors taking a shared_ptr<const SessionSource> skip the .mefd/.mefd.tar naming rule (internal, for tests) |
Update calls that passed positional arguments. |
Details¶
Breaking: one thread count per object (migration)¶
- The per-call
n_threadsargument ofReader.readandReader.read_rawis removed (also onMultiReader), with no deprecation period. Set the object's count instead, which is now a settable property:
# before
x = r.read("ch1", t0, t1, n_threads=1)
# now
r.n_threads = 1 # from the next call on
x = r.read("ch1", t0, t1)
cancel= and progress= are keyword-only on the C++ binding's
read/read_raw (the Python Reader already required that).
- n_threads must be an int >= 0 (0 = automatic). A bool or float
raises TypeError; for negative values see below.
- MultiReader(n_threads=) is a total, shared by its recordings and never
multiplied by them: the open splits it between the recordings it opens at
once, a read between the recordings it touches, and a read inside one
recording gets all of it. Before, every recording was opened with the full
count, so a MultiReader of 10 recordings could run 10 times its budget.
Setting MultiReader.n_threads re-splits from the next call.
- n_threads=1 now makes the open serial too, not just decoding. If you
passed 1 for comparisons with meflib or pymef, the open on a network share
is now as slow as before this release's parallel open. Leave the open
automatic and set r.n_threads = 1 after opening if you only want serial
decoding.
- The default open starts threads: at least 8 per open pass, also on
small machines (file access waits on round trips, not on cores). An
application that opens many Readers from its own thread pool multiplies
that; pass a small n_threads there. If a thread cannot be started (a
process or container thread limit), the work continues on the threads
already running, and in the worst case on the calling thread alone. It
never aborts.
- Negative counts are errors everywhere: ValueError in Python,
std::invalid_argument in C++ (Session, Reader, set_threads,
SessionWriter::set_threads), MEF3IO_ERR_ARGUMENT from the C ABI
(mef3io_reader_open/_open_ex, mef3io_reader_set_threads,
mef3io_writer_set_threads). Before, a negative count meant "all cores".
- C++: Reader::read / read_raw take (channel, t0, t1, ctl), and
Reader::kUseDefaultThreads is gone. Session's constructors take an
optional n_threads after strict. The constructors taking a
shared_ptr<const SessionSource> are internal (for tests): they skip the
.mefd/.mefd.tar naming rule.
See Threads for the rule.
Breaking: the durability option is replaced by flush() / wait_for_disk (migration)¶
Writer(durability=)andMefWriter(durability=)are gone, with no deprecation period: passing it raisesTypeError(before anything is written, sooverwrite=Truenever removes a session first). Also removed: C++SessionWriter::set_durable/durable()andSegmentSpec::durable, the C ABI'smef3io_writer_set_durable, the MEX commandwriter_set_durable, and MATLAB'sWriter(Durability=)(now an unknown-option error).- When is my data on disk? (new section in Long recordings):
write()returns once its data is handed to the operating system;- new
Writer.flush()returns only when everything the Writer wrote so far is on disk, in ONE combined wait for all channels (C++SessionWriter::flush, C ABImef3io_writer_flush, MATLABw.flush(),MefWriter.flush()); close()(and leaving awithblock) now flushes too — it returns only when everything is on disk, and raises if the flush fails (the C++ destructor andmef3io_writer_closeflush but cannot report a failure: callflush()first);- new opt-in
Writer(wait_for_disk=True): everywrite()returns only when its own data is on disk (one combined wait per call; C++set_wait_for_disk, C ABImef3io_writer_set_wait_for_disk, MATLABWaitForDisk=true,MefWriter(wait_for_disk=True)). Migrating from 1.x:durability="fast"→ nothing (the default);durability="full"→wait_for_disk=True, or the default plusflush()at the points that must survive a power cut (cheaper). - What the default write still flushes: each append's
.tmetbefore it replaces the old one, and the.tmetof a channel's first segment (or of a new segment or channel in a session that already held data) — a short.tmetwould make the whole session fail to open. Nothing else (1.x's default"fast"still flushed the two headers an append patches, and with them the appended data and index; that is gone, so the.tmetcan now reach the disk BEFORE the data it describes). - What a power cut can do before a flush: leave the last append of a
segment in any mix of old and new bytes — the
.tmetnewer or older than the data and index, the.tidxheader and entries from different writes, entries pointing at data that never landed, a torn.tdattail, stale header counts, a new segment's.tidx/.tdatempty.recover_sessionrepairs every one of them by itself (python -m mef3io recover SESSION --apply,mef3io.recoverSession(path, Apply=true)in MATLAB), leaving a session that validates clean and holds every sample whose bytes reached the disk; a recorder can then re-send what was lost. Earlier appends are never at risk: nothing rewrites them. The per-state table is in Long recordings. - A failed flush is remembered. After one failed flush, every later
flush()/close()of that writer raises again (C++, C ABI, MATLAB, Python): the OS reports a write error once, so a retry that "succeeded" would claim data that is gone. An unclosed PythonWriterthat cannot flush when it is garbage-collected emits aWriterFlushWarning(the flush now runs with the GIL released). wait_for_disk=Truealso makes the published.tmetdurable beforewrite()returns (one more combined wait after the rename), andflush()/close()cover every file and new folder a write touched in every mode. macOS flushes folders withF_FULLFSYNC; Windows flushes them through a directory handle where NTFS allows it (see long recordings).- Recovery: publishes a complete, CRC-valid
.tmet.mef3io-tmpwhen the.tmetis lost (even a channel's first segment); rebuilds a MISSING.tidxlike an empty one; replaces a zeroed.tdatheader on a full-size segment instead of re-sealing it; and numbers a rebuilt index's samples channel-wide. - A write,
flush()orclose()fails if a flush it waits for fails. 1.x's default ignored flush errors; now the OS error is raised. The flush cost was measured on ext4 only; macOS (F_FULLFSYNC) and Windows (FlushFileBuffers) are unmeasured and typically slower per call. - Measured (ext4, 128 channels × 10-minute blocks at 256 Hz): a round of
appends takes 2.2–2.3 s by default, 2.5–2.6 s with one
flush()per round, 3.6–3.7 s withwait_for_disk=True— against 3.8 s with 1.x's default and 6.0–6.5 s with"full". A fresh session's first write: 2.4–2.5 s (0.85 s with no flush at all).
Recovery covers every crash state (behaviour change)¶
recover_session(apply=True)re-derives the declarations itself for every segment it changes and for every channel's last segment whose.tmetdeclares other block, sample or entry counts than its index (a.tmetpublished before or after its data). It goes throughrepair_session(the same repairs, backups under<session>.repair-backup/and provenance). The dry run reports such a segment as needing recovery; 1.x called it healthy. New fieldRecoveredSegment.declarationslists the check ids.- An index that fails its own CRC is no longer refused: entries the
stored CRC covers are kept, then each entry whose RED block header agrees,
and the rest is rebuilt from the CRC-checked blocks in
.tdat(streaming: one 304-byte header per unverified entry). Intact samples become readable again. - In each channel's last segment, the blocks of the last append (back to its first block, at most 64 MiB) are CRC-checked against their entries, so entries pointing at data that never landed are dropped.
- A later segment whose
.tmetis empty or short but whose data and index landed (a long-lived Writer's new segment cut off by a power cut, which failed the strict open of the whole session) gets its.tmetREBUILT from the previous segment's plus its own files, then its declarations re-derived. A channel's last segment whose files are missing, empty or short and whose data holds no block is REMOVED (moved to the backup). A channel's first segment with a lost.tmetis skipped with advice; its.tmetis now flushed when written (once per channel). A short or empty.tidx(or a zeroed.tdatheader) is REBUILT from the CRC-checked blocks in.tdat, under a header taken from the segment's.tmet. python -m mef3io recover --applyno longer runs a session-wide repair afterwards, and--no-repairis gone: recovery re-derives the declarations of exactly the segments it reports (the dry run lists the check ids), and a healthy segment's findings are left torepair, by explicit id. A healthy legacy session is left byte for byte.
New¶
- One thread rule, everywhere. An object's
n_threadsis how many threads it may use for everything it does: opening, file access, decoding, decrypting, encoding.0= automatic,1= no extra threads. "Automatic" is decided in one place in the core: hardware concurrency for CPU work, at least 8 for file access. It is changeable at runtime asReader.n_threads,MultiReader.n_threadsandWriter.n_threads(Python),set_threads(C++, now atomic),mef3io_reader_set_threads/mef3io_reader_threads/mef3io_writer_threads(C ABI;mef3io_writer_set_threadsexisted), andsetThreads/threadson the MATLABReaderandWriter. A change applies from the next call; calls in flight keep their count. - Sharing a Reader is safe in every case now (#110). A
Readeropened withcache=builds its backend once, whichever thread asks first (before, racing threads each built one).close()racing that first build stays closed (before, the session could silently reopen). A skipped record file or one opened through the old password mapping is warned about once per Reader, not once per concurrent call. C++set_threadsandset_index_cache_bytesare atomic, and the metadata loaded at open is read without a lock afterwards.
Reading many channels in one call (#90; part of #68)¶
Reader.read(channels, t0, t1)takes a list of channel names and returns a 2-D array, one row per name in the order given. Fixes #90. Every row is byte-identical toread()of that channel alone (same grid, NaN gaps, per-segment scaling, warnings, errors); the channels must share one sampling rate (ValueErrorotherwise: read each rate in its own call). Every channel's index is read at once on the Reader's threads, then the data a group of channels at a time with one decode pool per group: with 20 ms per file request (a slow share, simulated), 209 channels x 10 minutes read in 1.2 s on 16 threads instead of 13.5 s for a loop; locally 1.3-1.9x faster from 4 threads on, and 2.9x faster than a Python loop withnp.stackon 16 threads (benchmarks).cancel=andprogress=cover the whole call. Withoutt0/t1the window is the union of the listed channels' spans. An error is the one a loop raises (the first failing channel in list order), and nothing is started for the channels after a channel known to fail.dtype="float32"onread(one channel or many): the float64 value rounded to the nearest float32, exactlyread(...).astype(numpy.float32), in half the memory.block_envelope(channels)takes a list too and returns a dict keyed by channel name, each value the single-channel dict; the indexes are read concurrently, still index only. For #68 (a multi-channel form for viewers).MultiReaderhas the same arguments and returns what a Reader of the merged session returns (it reads the listed channels one after the other, within its thread budget). The legacyMefReader.get_data(list)now uses the multi-channel read (one call per sampling rate and window), with the same arrays as before.- C++:
Reader::read_many/read_many_f32(returningmef3io::ChannelRows<T>: one row-major buffer),Reader::read_f32,Reader::block_envelope_many.read_rawstays one channel per call. Not in the C ABI or MATLAB yet (Python and C++ only).
De-identified copies keep the times unless you ask for a shift¶
anonymize_session no longer requires a shift. Without one (the new
default) the copy keeps every date and time exactly, so it still shows when
the recording was made; the patient's identity is removed exactly as before
(names, ID, location, session name, descriptions, annotation text by default,
new identifiers, other programs' private data cleared, provenance
anonymize). Give a shift, as before, to hide the dates.
- Python:
mef3io.anonymize_session(src, dst, shift_us=None, ...),mef3io.anonymize.check(src, dst, shift_us=None, ...)andmef3io.anonymize.verify(src, dst, shift_us=None, ...).None(or leaving the argument out) = times kept; an int = every time moved by it, with all the rules that applied before.AnonymizeReport.shift_usisNonefor a copy that kept the times, andsummary()says so. - Command line:
python -m mef3io anonymize SRC DSTnow works without--shift-us/--reference-dateand keeps the times. - C++:
AnonymizeOptions::shift_usandAnonymizeReport::shift_usarestd::optional<si8>(unset = times kept);verify_anonymizedtakes astd::optional<si8>;check_anonymizelost itscheck_shiftparameter (the options' shift decides). - What a copy that keeps the times holds: every stored time byte for
byte as in the source, and each segment's
recording_time_offset(the stored times are read against it, so keeping it is what keeps the times).GMT_offsetand the daylight-saving fields are still cleared: they can say where the recording was made, and no time a reader returns depends on them. Its verification requires every time (file headers, block index, data block headers, annotation headers and index, the times inside annotations) and every offset to equal the source's. - Only with a shift: the shift rules (
shift-zero,shift-below-floor,shift-not-positive), the recording-time-offset refusals (rto-disputed,rto-unreadable,time-before-rto),id-number-time,time-overflowand the time leak scan. A copy that keeps the times copies each time and offset as stored, so a session with segments that disagree on the offset, or with times in the ambiguous legacy convention, can now be de-identified without a shift (its times read back exactly as the source's). - Cuts under a disputed offset (
time_range, bothanonymize_sessionandtransform_session): each segment, and each annotation file, is now placed by the offset ITS file is stored against (a segment's own; for a channel or the session, the one all their segments agree on, as the Reader reads it). Before, a transform's cut read everything against one session-wide offset (0 when segments disagree), which could leave out a channel or an annotation inside the window while the copy still passed verification. A channel- or session-level annotation file whose time depends on an offset its segments disagree on cannot be placed and is refused with the new shared codetime-range-offset-disputed(copy it withouttime_range). - Verification of cuts now checks every cut segment's header start and
end against the blocks it holds itself (the validator's time checks stand
down when a recording-time offset is set), and that every channel left out
for "no samples in the window" really has none. A standalone
verify(..., time_range=)notes a source channel with samples in the window that the copy does not hold (it may have been excluded on purpose). - Text callbacks (
text=callable) getinfo["time"] = Nonefor a record whose time cannot be known: without a shift, a time stored against an offset the file's segments disagree on.AnonymizeReport. source_recording_time_offsetis 0 when the segments disagree (there is no single one), and a note says so.
Migration¶
- Code that always passes a shift is unaffected: a shifted copy is made, checked and verified exactly as before.
- Anything that relied on the shift being required (a script or form
that treated a missing shift as an error) now makes a copy with the REAL
dates. If the dates must not be shared, pass
shift_us(or--reference-date/--shift-us) explicitly. anonymize.check(src, dst)without a shift changed meaning. It used to check a shifted copy with the shift still to be chosen; it now checks the copy that keeps the times, so it no longer reports the shift-only refusals listed above (for examplerto-disputedortime-before-rto). To check a shifted copy, pass the shift you will use.anonymize.verify(src, dst)without a shift checks a copy that keeps the times; pass the shift to verify a shifted copy (it was required before).shift_us=0is still refused (shift-zero); leave the shift out to keep the times.anonymize.redact_texttakes no shift, so it now follows the copy that keeps the times: it no longer raises the shift-only refusals (rto-disputed,rto-unreadable,time-before-rto) for such sources. A caller that used it as a pre-check for a shifted copy should callanonymize.check(..., shift_us=...)instead.
A kept channel named after the patient is refused before copying¶
A kept channel whose name holds the patient's name or ID, the recording
location or the original session name (in a form the copy's check refuses:
"Roe" or "Jane_C3" for the patient Jane Roe, "Roe1", "JaneRoe") used to pass
anonymize.check and was refused only by the copy's verification, after
every block had been copied (verify-failed). It is now refused before
anything is written, by check, anonymize_session and redact_text
alike, with the new code channel-name-identity
(WriteConflictError, details["channel"] = the channel). The pre-flight
applies the verification's own channel-name rule to each kept channel's
name and to the folder and file names the copy makes from it (#143).
Overlaps that the rule only notes ("12" in "LAH12", "P3", a session called
"EEG" vs "EEG1") still pass. Leave such a channel out with
exclude_channels. Transforms are unaffected.
- Segment numbers are no longer read as the subject ID. The copy's
check matched the number in a segment folder's name (
Fz-000001.segd) against the ID's digits, so a subjectPT0001with more than one segment per channel could not be anonymized at all (verify-failed). The number is mef3io's own counter and is checked on its own; only the channel part of those names is searched now, by the check and by the verification. - Migration: code that caught
verify-failedfor this case now getschannel-name-identity, raised as the sameWriteConflictError, before the copy starts.
Faster¶
- Opening a session is parallel (#141). The channel listings, the
segment file checks and every metadata file's read and parse run on the
Reader's threads. With 20 ms per request (a slow network share, simulated),
209 channels x 10 segments opens in 10.9 s on 16 threads instead of 173 s on
one, and 64 x 1 in 0.46 s instead of 6.5 s
(benchmarks). The result
does not depend on the thread count. The channels, metadata, the lenient
problems()(in the same order) and the strict error (the first in listing order, as a serial open meets it) are all the same. - Repeated windowed reads no longer walk every block index (#110). For each index it holds in memory, a Reader remembers the time span the index covers, and skips an index that misses the window. The span is forgotten with its index, so the result is always the same as walking it. Repeated one-second reads of a 2,400-segment channel went from 4.7 ms to 0.2-0.28 ms each; the first read still walks every index once.
-
Fixed:
block_envelopecould leave out blocks of a segment whose metadata states an end time earlier than its data, or a start later than it. This happens with files from writers that store a shorter end, and after an interrupted append that was followed by a new segment. It skipped segments by those times; no metadata time is used to skip any more. -
Opening a session reads each segment's metadata file with one bounded read of its fixed record size. There is no longer a separate seek to find the file's size, and padding that other writers append after the record is not read. Locally this removes three system calls per segment. On a network file system that re-checks file attributes, it saves one request per segment: about 6% on a simulated slow mount. Results and messages are unchanged. Part of #141.
- Opening a
.mefd.tararchive no longer scans the whole archive for every folder it lists (the open lists the root and each channel folder once). Folder listings are now indexed once when the archive is opened. - C++:
SessionandReadercan be opened from aSessionSource, andSessionSource::read_head(path, n)reads the firstnbytes of a file in one request.
Changed — check before upgrading (2.0.0)¶
- Conversion factors are per segment (#150). MEF stores the
units-conversion factor in each segment's section 2, and segments of one
channel may store different ones (a
new_segment=Truewrite with anotherprecision, or a foreign recorder). The API now says so: Reader.info(ch)["units_conversion_factor"](andMultiReader.info) isNonewhen the channel's segments store different factors (meflib's channel aggregate readsNO_ENTRYthere). C++ChannelInfo:: units_conversion_factoris astd::optional; the C ABI'smef3io_channel_infoand MATLAB'sinfogive NaN.Reader.segments(ch)has each segment'sunits_conversion_factor(C ABImef3io_reader_segment_units_conversion_factor, MATLABsegments);inspect()'s segment summaries too, andChannelSummary. units_conversion_factorisNonewhen they differ.read_raw()gainsunits_conversion_factors: which factor applies to which samples, asstart_index/end_index/units_conversion_factorspans. Itsunits_conversion_factorisNonewhen the window holds samples of segments with different factors (C++RawData:: units_conversion_factoris astd::optional,RawData::factor_spansthe spans; C ABImef3io_reader_read_raw_factors; MATLABreadRaw'sunits_conversion_factors, 1-based and inclusive). Code that computedsamples * units_conversion_factormust use the spans, orread().MultiReader.read_raw()no longer refuses a window over recordings with different factors: it returns the same spans aReaderof the merged session does.Session.read_runs()runs carryunits_conversion_factorand are split where the factor changes.- The compat
MefReader'sufactis[None]for such a channel, andget_datascales each sample by its own segment's factor. - A segment whose conversion factor is not set reads as raw counts, with
a warning; NaN or infinite factors are refused. 0 is MEF's "no entry"
for the factor ("not set").
read(),block_envelope(),MultiReaderand the compatMefReader.get_datanow return such a segment's samples as the raw stored counts (factor 1) and emit one newmef3io.UnscaledSamplesWarningper Reader and channel the first time such a segment is read. Before, they read back as all zeros. In a channel that mixes such a segment with real factors, every segment is scaled by its own (1 for the unset one).info(),segments()andread_raw()'sunits_conversion_factorskeep reporting the stored value (0). A factor that is NaN or infinite is invalid:read()andblock_envelope()raisemef3io.FormatError(C ABIMEF3IO_ERR_FORMAT) for a window holding such samples, before anything is decoded.read_raw()andtoc()still return the counts, and a lenient Reader does not count it as a damaged segment. The C ABI and MATLAB read unset factors as raw counts too, without a warning (C++:Session::unscaled_channels()).
Such files exist — check yours (migration). 0 is what meflib's and
pymef's section-2 templates hold until a writer sets the factor
(meflib.c:4564; pymef's MefSession._initialize_tmd2). So a session
written through pymef or by a meflib-based recorder that never set it,
or by mef3io before 2.0 with write_int32(ufact=0), stores 0. mef_tools
read those as all zeros (counts * 0), and so did mef3io before 2.0; now
they read as the raw counts, with the warning. mef_tools and mef3io's own
writers never store 0 by default (mef_tools writes 10**-precision, 1.0
for precision 0). If you know the real factor, scale the counts yourself:
raw = r.read_raw("ch1", t0, t1)
x = np.where(raw["valid"] == 1, raw["samples"] * known_factor, np.nan)
r.segments("ch1") shows which segments store 0
(units_conversion_factor).
- Invalid sampling frequencies and conversion factors are refused (#83).
Every write path (
Writer.write,write_int32, appends, the C ABI and MATLAB) now raisesValueError(C++std::invalid_argument, C ABIMEF3IO_ERR_ARGUMENT, a MATLAB error) before anything is written when the sampling frequency is 0, negative, NaN or infinite, or the conversion factor (ufact) is 0, NaN or infinite. Before, a rate of 0 or NaN made the write never return, andufact=0was stored, after which every sample read back as 0: the signal could not be recovered. 0 is also meflib's "no entry" value for the conversion factor (meflib.h:439). A negative conversion factor is still accepted: it is a polarity inversion, and pymef handles it explicitly (pymef3_file.c:972-978); mef3io, pymef and mef_tools read it back identically (test-pinned). Code that wroteufact=0as a placeholder must pass the real factor (or1.0). - Block length is capped at 858 993 396 samples (a block's byte size is a
32-bit field; the worst case is 5 bytes per sample).
Writer(block_length=)above it, or a rate whose default block would be above it (for example 1e12 Hz withoutblock_length), raisesValueError. Rates below 0.1 Hz, whose default block is less than one sample, now write one sample per block instead of never returning. The MATLABWriterchecksBlockLengthbefore it opens (withOverwrite=true, opening removes the existing session); C callers can do the same with the newmef3io_check_block_length/mef3io_check_signal_parameters(andMEF3IO_MAX_BLOCK_SAMPLES). The compatMefWriter.mef_block_lenno longer keeps a refused value. - A
write_int32validmask whose length differs from the data raisesValueError(it was read past its end). - Auto-cache file names no longer contain the session name (#82). The
cache="auto"snapshot is now<hash>.mef3cachein the OS cache folder, not<session name>-<hash>.mef3cache: session folders are often named after the subject. The firstcache="auto"open of a session after upgrading deletes that session's old-named snapshot (exactly that file, and only if it is a mef3io snapshot) and builds a new one.
Float writes never wrap a count beyond int32: out-of-range samples are clamped¶
Before, a float write whose value needed a count outside int32 at the
conversion factor stored a wrapped, wrong value silently (an append of
3.0e6 to a channel at factor 0.001 read back as -1294967.296), and an
infinite value wrapped the same way.
Now every float write (Writer.write, MefWriter.write_data, C++
write_float), with the default precision or precision="step", stores
such a sample, and +inf/-inf, as the nearest storable value:
±(2**31 - 1) counts, meflib's own RED_POSITIVE_INFINITY /
RED_NEGATIVE_INFINITY, which is also what meflib's encoder clamps to. The
write succeeds; one out-of-range artifact (say a saturation spike in an
append) no longer costs the chunk. The write summary gains clamped and
clamped_infinite (C++ WriteSummary::clamped / clamped_infinite), and
a write that clamped anything emits ONE ClampedSamplesWarning (a
MefWarning; counts only in its text, details per channel). NaN is
unchanged: a gap, never clamped. If you prefer to mark such samples as
missing, replace them with NaN before writing.
The inferred precision now requires both ends of the data to fit (it used
to accept data where only one end overflowed, as mef_tools did), so a
default write of one-sided large data picks a smaller precision instead of
needing a clamp, and +-inf no longer drags the inferred precision to 0
(it is ignored there, like NaN). precision="step" ignores +-inf when
estimating the step too.
Smaller files for data from an amplifier: precision="step" (#81)¶
Writer.write(..., precision="step") (also MefWriter.write_data) finds the
data's own quantization step, such as an amplifier's 0.195 µV per count, and
stores the original counts with that step as the conversion factor. One hour
at 512 Hz of 16-bit counts × 0.195: 1.52 MB instead of 6.03 MB with the
default path, the same as write_int32 of the counts. Opt-in; the default is
unchanged.
- The step is estimated from a fixed-size sample (about 10 ms on 50 million
samples) and every value is checked during the conversion the write makes
anyway: a value stored on its step reads back exactly when the data was
counts * step, and within float64 rounding otherwise. The whole write was faster than the default one in our measurements (10 million samples: 0.36 s against 0.57 s). - Data not on one step (filtered, resampled, noise), with too few distinct
values to establish one (a DC level, a square wave), or whose counts would
not fit int32, is written with the default path, with a new
StepFallbackWarning. - A channel keeps one conversion factor: appends and new segments with
"step"store data on the channel's factor exactly, and refuse (WriteConflictError, nothing written) data off it rather than round it. A default-precision append to a channel whose factor is not a power of ten is refused with a message saying to use"step"orwrite_int32. - The write summary has three new keys for every write:
units_conversion_factor,quantization("step","decimal"or"int32") andstep_fallback. C++:SessionWriter::write_floattakes aPrecision(anintstill works;Precision::detect_step()for the step),WriteSummarygains the same fields, plusdetect_step/quantize_to_step. Not in the C ABI or MATLAB.
Fixed¶
-
A channel whose segments store different conversion factors read back wrong (#150). The reader scaled the whole channel by its FIRST segment's factor, so after
write(..., precision=2)andwrite(..., precision=4, new_segment=True)the second segment read999.0where9.99was written, without a warning.read(),block_envelope()and every value derived from them now scale each block by its own segment's factor (negative factors included); pymef agrees (test-pinned on a session with 0.01, 0.195 and -0.5 in one channel). Copies (transform_sessioncuts,anonymize_session,merge_sessions) already kept each segment's factor; a cut segment with a negative factor now declares its section-2 native maximum and minimum the right way round. -
Appending to a segment folder whose number is not zero-padded (
ch1-5.segd, as some writers name them) now extends that folder; it used to look forch1-000005.segdand refuse. New segments keep the<channel>-NNNNNNform. - Recovery at a non-zero recording-time offset now sets the end time. At a
non-zero offset (meflib's date-masking)
repair_sessionleaves times alone, so afterrecover_sessioninfo()still reported the end from before the recovery. Recovery now corrects it from the last block in the three universal headers: only an end that lies before the data's last block (or before rebuilt blocks), never moving it earlier, each header in its own stored form; it notes what it leaves alone. A healthy session is untouched. - Recovery keeps the
.tdatchecksum when it fixes only headers. It used to reset it to "not computed" whenever it rewrote the.tdatheader. block_envelope()returnedminabovemaxfor a channel with a negative conversion factor; the pair is now swapped after scaling, as the section-2 native extremes already were.- MATLAB:
Writer.write/writeInt32with a sparse array (data orValidmask) crashed MATLAB or stored the wrong validity; they now take sparse input, and the MEX refuses sparse or complex array arguments with a clear error (sparse scalars, such as an element of a sparse matrix, are accepted).writeAnnotationsaccepts string-class ("...")type,textand channel.