edf2csv 0.4.64 → 0.4.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/CHANGELOG.md +1253 -0
  2. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,1259 @@
3
3
  Notable changes to edf2csv. Versions follow [semantic versioning](https://semver.org); while the
4
4
  major version is 0, a minor bump may contain breaking changes.
5
5
 
6
+ ## 0.4.65
7
+
8
+ ### Fixed: the changelog stopped forty-five versions ago
9
+
10
+ This file records what each version changed, and its newest entry was 0.4.19 while the
11
+ package was at 0.4.64. Every release since had notes on GitHub and nothing here — in the one
12
+ place the repository presents as the record, and the one a reader without a browser has.
13
+
14
+ The forty-five entries are written from the commit messages, which were composed in this
15
+ file's voice and carry the same reproductions. Nothing is reconstructed from memory: each
16
+ entry says what its commit said.
17
+
18
+ A test now compares the newest heading here against the version in `package.json`, so the
19
+ file can fall behind by the release being prepared and no further. Two of the numbers on the
20
+ correctness page had drifted the same way and were caught the same way, twice; the difference
21
+ between a number that stays true and one that rots is whether something reads it.
22
+
23
+ ## 0.4.64
24
+
25
+ ### Fixed: the correctness page says how long its own suite takes
26
+
27
+ ```
28
+ `npm test` ... runs the three test files ... It finishes in about a second on a laptop
29
+ ```
30
+
31
+ There are six files and it takes about twenty seconds. Both halves drifted while the page's
32
+ test counts were being corrected twice, because those were the numbers a test was watching
33
+ and these were not.
34
+
35
+ Twenty seconds is not a regression, and saying so is the point: almost all of it sits in
36
+ three files that do expensive things on purpose. cli.test.js spawns the built binary as a
37
+ subprocess for every case and interrupts a thirty-file batch to watch it stop. large.test.js
38
+ builds and reads multi-gigabyte recordings. stdout-audit.test.js creates and mounts a disk
39
+ image so it can fill it up. The parser, the planning, the CSV contents and the documentation
40
+ checks together still run in about a second, which is the number the old sentence was
41
+ describing before those three arrived.
42
+
43
+ The file count is now checked, spelled out in words the way the sentence reads. The duration
44
+ is not: it is a property of the machine, and a test asserting a wall-clock figure fails on a
45
+ slow CI box for no reason anyone can act on.
46
+
47
+ ## 0.4.63
48
+
49
+ ### Added: the promise that the digital codes are recoverable is now checked
50
+
51
+ The FAQ says "the rounding recovers the original integer exactly, because the written
52
+ decimals are always fine enough to keep adjacent digital codes distinct", and prints the
53
+ arithmetic. It is the reason the tool offers no raw-digital output mode: the claim is that
54
+ you do not need one. Nothing checked it.
55
+
56
+ `npm run roundtrip` does — 12,096 cells over 756 combinations of digital and physical bounds,
57
+ EDF and BDF, each converted and then recovered with exactly the arithmetic the page prints.
58
+ Every one comes back as the code the file holds. Narrowing the derived precision by two places
59
+ makes it fail, so it is a check rather than a demonstration.
60
+
61
+ It also found two conditions the page did not state, both of which will bite someone.
62
+
63
+ Take the gain from channels.csv, not from what you believe the range to be. EDF's physical
64
+ bound fields are 8 characters, so a header asked for -0.000001 stores -0. The first version of
65
+ this harness computed the gain from the values it had passed to the writer and reported 620
66
+ failures, every one of them its own — the tool was right and the test was measuring its own
67
+ intent.
68
+
69
+ And leave --decimals alone. The promise is about the precision derived per channel; force a
70
+ coarser one and the codes stop being recoverable with nothing to indicate it. --decimals 0 on
71
+ a 256 Hz EEG channel gets 645 of 768 samples wrong. The page warned that flag would not give
72
+ you integers; it did not say it silently breaks the recipe printed below it.
73
+
74
+ ## 0.4.62
75
+
76
+ ### Fixed: the fallback for an inexact rate reaches far enough to do its job
77
+
78
+ A rate whose sample interval has no terminating decimal expansion falls back to, in the
79
+ comment's own words, "enough places to keep consecutive samples distinct". It stopped at
80
+ nine. At 3e10 Hz the interval is 3.3e-11, so nine places rounded every sample in a record to
81
+ the same timestamp — a column that cannot tell two samples apart is not keeping them
82
+ distinct, which is the one thing that branch exists to do.
83
+
84
+ Both halves of the function now reach fifteen: the search for an exact expansion, since
85
+ 0.4.59, and the fallback. 3e10 Hz gets fourteen places and six distinct timestamps where it
86
+ had three.
87
+
88
+ TIME_RESOLUTION keeps a trigger, because a bound that nothing reaches is a warning nobody
89
+ maintains. It now takes a rate whose interval is finer than fifteen places can express —
90
+ 3e15 Hz — which is nine orders of magnitude past anything that records biosignals, and the
91
+ fixture moved there so the warning still has something that raises it.
92
+
93
+ ## 0.4.61
94
+
95
+ ### Fixed: three things the FAQ told people that its own examples contradict
96
+
97
+ The digital-recovery snippet reads sleep-study_csv/signals.csv, from the recording the same
98
+ page opens by showing as a mixed-rate file whose 256 Hz channels land in signals_256hz.csv.
99
+ Two earlier snippets on the page read that name correctly. This one, the only snippet doing
100
+ arithmetic worth checking, pointed at a file that recording does not produce, so pasting it
101
+ gets a FileNotFoundError. It now reads the right table and says why, and running it against
102
+ mixed-rates.edf recovers the digital codes exactly — [0, 74, 147, 219, 290], which is what
103
+ the file holds.
104
+
105
+ The leftover-files section is headed "Why is there a leftover signals_256hz.csv next to my
106
+ new signals.csv?" and its example warning lists signals_128hz.csv and signals_1hz.csv. The
107
+ one file the reader came to that section about was missing from the message. The tool names
108
+ all three; so does the example now.
109
+
110
+ And the CSV size factor was given as four here and seven in recipes.md. Neither is a
111
+ constant: every row carries one time_s cell however many channels share it, so a 23-channel
112
+ 256 Hz montage measures 4.3x while a single-channel recording of the same length is 10x.
113
+ Both pages now say that, and say what it turns on, rather than picking a number.
114
+
115
+ ## 0.4.60
116
+
117
+ ### Fixed: one recording's length stops being rendered two ways in one session
118
+
119
+ ```
120
+ $ edf2csv rec.edf --info
121
+ Duration 6m 40s (400 records of 1s)
122
+ ```
123
+
124
+ ```
125
+ $ edf2csv rec.edf --start 10m --out csv
126
+ error: --start "10m" is at or past the end of this 400s recording.
127
+ ```
128
+
129
+ The message whose job is to say how long the recording actually is gave a bare number of
130
+ seconds, while --info gave the humanised form for the same file. On an overnight recording it
131
+ read "7950s recording", leaving the reader to divide by 3600 to judge whether their --start
132
+ was reasonable — which is the one question the message exists to answer. cli-reference.md has
133
+ documented it humanised since it was written ("2h 12m 30s"), a form no input could produce.
134
+
135
+ It now uses the same formatter --info does. The typed value keeps its quotation marks, since
136
+ that is the user's own text and should come back exactly as written, and a recording short
137
+ enough that the two renderings coincide is unchanged — which is the case the reference's
138
+ other example uses.
139
+
140
+ The neighbouring row in the usage-error table was stale in the other direction. Quoting the
141
+ window values was deliberate, added along with the quoting for --start, and only one of the
142
+ two adjacent rows was updated at the time. The table is maintained as exact strings, so the
143
+ row now carries the quotation marks the tool emits.
144
+
145
+ ## 0.4.59
146
+
147
+ ### Fixed: the time column is exact at 1024 Hz and 2048 Hz, which is what a BioSemi records
148
+
149
+ timeDecimals looks for the number of decimal places in which 1/rate terminates, so sample
150
+ times are written exactly and `time_s * rate` comes back a whole number. The search stopped
151
+ at nine places, and the comment beside it said "every rate in common use clears this — 256 Hz
152
+ needs 8 places, 512 Hz needs 9".
153
+
154
+ The next two powers of two do not. 1/1024 is 0.0009765625, which needs ten; 1/2048 needs
155
+ eleven. Both fell through to the rounding fallback and were written as 0.0009766 and
156
+ 0.0004883 — precisely the behaviour the function exists to avoid, on the rates an ActiveTwo
157
+ records at by default.
158
+
159
+ The search now goes to fifteen places, which covers every power of two through 32768 Hz.
160
+ Fifteen and not more because the test has to stay exact: 10^16 is past 2^53, and
161
+ `Number.isInteger(10 ** 17 / 3)` is true, so a larger bound would claim a terminating
162
+ expansion for 3 Hz and ask for seventeen decimals of a number that repeats forever.
163
+
164
+ It also subsumes most of what 0.4.55 warned about. A 1e10 Hz recording terminates at ten
165
+ places, so it now gets a column that separates every sample rather than twenty rows sharing
166
+ three timestamps. TIME_RESOLUTION still has a real trigger — a rate above a gigahertz whose
167
+ expansion repeats, 3e10 Hz say — and a fixture now holds one, since a warning nothing can
168
+ raise is a warning nobody will maintain.
169
+
170
+ ## 0.4.58
171
+
172
+ ### Added: the header length the file claims is available beside the one that is used
173
+
174
+ api.md says "Every field is read straight from the 256-byte fixed header plus the per-signal
175
+ block. Nothing is normalised except where noted", and lists headerBytes with no note. It is
176
+ the one field that is computed rather than read: 256 for the fixed header plus 256 per
177
+ signal.
178
+
179
+ The computation is right, and has to be. Every data record offset is derived from it, a
180
+ writer that fills the length field in carelessly is common enough to have its own warning,
181
+ and believing the field over the arithmetic would put every sample at the wrong offset. What
182
+ was missing is the field's own value — which is a fact about the file, and is the thing
183
+ HEADER_BYTES_MISMATCH is comparing against.
184
+
185
+ `declaredHeaderBytes` now carries it, exactly as `declaredRecordCount` already carries what
186
+ the record-count field said. A caller auditing how a recording was written can see both; a
187
+ well-formed file has them equal, which is what makes the pair worth having.
188
+
189
+ ## 0.4.57
190
+
191
+ ### Fixed: the reference describes the inversion the code actually looks for
192
+
193
+ ```
194
+ INVERTED_PHYSICAL_RANGE — A channel's physical minimum sits above its physical maximum
195
+ ```
196
+
197
+ That is not the condition. The gain is (physMax - physMin) / (digMax - digMin), so what
198
+ inverts a channel is the sign of that fraction. Reversing exactly one of the two pairs makes
199
+ it negative; reversing both leaves it positive, and such a channel is not inverted at all.
200
+ The code has known this for some versions — its comment says warning on the physical pair
201
+ alone "was wrong in both directions" — and the page went on stating the version that was
202
+ wrong, in two places.
203
+
204
+ Someone matching on the described condition would raise a warning on a file with both pairs
205
+ reversed, which is correct data, and miss one with only its digital bounds reversed, which is
206
+ genuinely sign-flipped EEG. The page now gives the gain, a table of the three cases, and a
207
+ note that the message names whichever pair is actually reversed.
208
+
209
+ A fixture holds all three so the distinction is checked rather than described. Two of its
210
+ columns are constant, because the fixture writer clamps samples to the declared digital
211
+ range and a reversed range clamps every one of them to the same value — said so in the
212
+ fixture and in the test, since a constant column that looks like a converter bug and is not
213
+ one is worth labelling.
214
+
215
+ ## 0.4.56
216
+
217
+ ### Fixed: a channel with no samples stops being described as one nobody asked for
218
+
219
+ ```
220
+ $ edf2csv rec.edf --info --channels unused
221
+ 1 unused unused uV 0 Hz -100 to 100 (not selected)
222
+ ```
223
+
224
+ ```
225
+ warning: Signal 1 ("unused") carries no samples at all (0 per data record).
226
+ ```
227
+
228
+ The channel named on --channels was reported as not selected, in a table printed directly
229
+ above a warning explaining why it has nothing to contribute. It was selected; the file gives
230
+ it nothing. The column now says "(no samples)", which is true whether or not it was asked
231
+ for, and a channel that does carry samples and was not chosen still reads "(not selected)".
232
+
233
+ The conversion had the same gap from the other side. Selecting only such a channel leaves no
234
+ table to write, so the run produced channels.csv and metadata.json and no signals.csv —
235
+ while output-files.md says signals.csv is written unless --annotations-only was passed.
236
+ Nothing accounted for the missing file. It does now, and the page carries the exception.
237
+
238
+ Same shape as 0.4.51: a report describing what it measures rather than what the run did.
239
+
240
+ ## 0.4.55
241
+
242
+ ### Fixed: the slack at a window boundary stops being wider than the samples
243
+
244
+ Deciding which samples fall inside a requested window used a flat nanosecond of slack,
245
+ applied whatever the sampling rate. A nanosecond is far below any real interval — 20 kHz is
246
+ 50 microseconds — but the format does not oblige it to be: EDF's record duration is an
247
+ 8-character field that accepts 1e-9.
248
+
249
+ A recording of two 1 ns records holding ten samples each wrote ten of its twenty rows. The
250
+ window ends at 2e-9, the comparison asked for `time < 2e-9 - 1e-9`, and the whole second
251
+ record failed it. Exit 0, no warning, half the samples gone.
252
+
253
+ Slack that reaches the next sample is not slack, so it is now capped at half a sample
254
+ interval. Same lesson as 0.4.45: a constant chosen for one scale is a bug at another, and the
255
+ scale that matters is what the recording can express.
256
+
257
+ Fixing that exposes the other half. Sample times are written to at most nine decimal places,
258
+ so above a gigahertz the column repeats — those twenty rows carry three distinct times.
259
+ Nothing is lost, and every sample is written in order, but `time_s` stops identifying a row,
260
+ so joining or plotting on it collapses samples that are genuinely distinct. TIME_RESOLUTION
261
+ now says so and points at the row number instead. No shipped fixture raises it, and the
262
+ whole fixture set was checked to be sure.
263
+
264
+ ## 0.4.54
265
+
266
+ ### Fixed: the row buffer is emptied when it fills, not when the record ends
267
+
268
+ BufferedLineWriter exists to hold a bounded amount of formatted text, and the loop drained it
269
+ once per data record. Within a record nothing emptied it, so memory followed
270
+ samples-per-record rather than the buffer's own threshold.
271
+
272
+ ```
273
+ 16,000 records of 1,000 samples -> 283 MB CSV, converts under a 256 MB heap
274
+ 1 record of 16,000,000 samples -> JavaScript heap out of memory
275
+ ```
276
+
277
+ The same 32 MB of samples either way. The format allows both layouts and says nothing about
278
+ which to expect: samples-per-record is an 8-character field, and a writer that puts an entire
279
+ recording in one record is producing a legal file.
280
+
281
+ The buffer is now emptied wherever it fills. `full` is a synchronous read of the pending size,
282
+ so the twenty million rows that are not at a boundary cost a comparison rather than a
283
+ microtask each — a 92 MB conversion times the same as before.
284
+
285
+ The test caps the heap at 256 MB, because without a limit the machine's own memory lets the
286
+ old code through and the test becomes a demonstration of nothing.
287
+
288
+ The FAQ said one thing scales with the recording. Two do, and neither is its length: the
289
+ annotation list, and one record held whole while it is read. Both are now named there.
290
+
291
+ ## 0.4.53
292
+
293
+ ### Fixed: a data record larger than one read no longer takes the process down
294
+
295
+ ```
296
+ $ edf2csv rec.edf --out csv
297
+ node[87678]: void node::fs::Read(...) at ../src/node_file.cc:2632
298
+ Assertion failed: args[3]->IsInt32()
299
+ ----- Native stack trace -----
300
+ $ echo $?
301
+ 134
302
+ ```
303
+
304
+ Nothing written, and exit 134 — which is none of the three codes this tool documents. Worse
305
+ through the library: the assertion is raised in C++, not thrown, so neither a try/catch
306
+ around readRecords nor an uncaughtException handler ever runs. A consumer's whole process
307
+ goes down with it.
308
+
309
+ A record is read in one call when it exceeds the chunk budget, there being nothing smaller to
310
+ divide it by: a record is the unit the format is addressed in. EDF's samples-per-record field
311
+ is 8 characters, so eleven channels at 99,999,999 samples make a record of 2,199,999,978
312
+ bytes — and a long record duration at ordinary rates reaches the same place. `fs.read` takes
313
+ a length that must fit in a signed 32-bit integer, and asserts rather than throwing when it
314
+ does not.
315
+
316
+ readFully already looped to handle a short read, so the fix is to cap what it asks for at a
317
+ gigabyte and go round again. The 2.2 GB record now converts in 1.2s. The boundary is exact,
318
+ so the test checks both sides of it: 2,199,999,978 bytes must convert, and 2,134,000,000 —
319
+ which always worked — must keep working, since a fix that refused large records rather than
320
+ reading them would pass the first and fail the second.
321
+
322
+ The FAQ said memory does not scale with the recording, with the annotation list as the one
323
+ exception. There is a second: one record is held whole. It is now named there.
324
+
325
+ ## 0.4.52
326
+
327
+ ### Added: the metadata.json the documentation prints is checked against one a conversion writes
328
+
329
+ output-files.md prints a whole metadata.json as its explanation of the format, and that
330
+ transcript is what someone reads before writing code against it. A key added to the record
331
+ and not to the page reads as a key that does not exist; one removed reads as a key they can
332
+ rely on. api.md had exactly that happen to its `window` object, which lost two fields for
333
+ several versions before an audit noticed — and the only reason this record has not is that
334
+ nobody has changed it lately.
335
+
336
+ The docs test now converts a fixture and compares the key structure of the result against
337
+ the sample on the page. The shape, not the values: the sample describes an eight-hour sleep
338
+ study that is not in this repository, and rewriting it to match a two-record fixture would
339
+ make it a worse explanation of the format, which is what it is there for.
340
+
341
+ The fixture is the discontinuous one, chosen so every part of the record the page shows is
342
+ populated — it raises a diagnostic, so `notes` is not empty, and carries annotations, so the
343
+ annotation fields are real. An empty array cannot say what its entries look like, and that is
344
+ the half of the shape worth checking.
345
+
346
+ Confirmed capable of failing: deleting one key from the sample fails the test.
347
+
348
+ ## 0.4.51
349
+
350
+ ### Fixed: --info stops promising nothing for a run that writes a file
351
+
352
+ ```
353
+ $ edf2csv annotations.edf --info --annotations-only
354
+ Would write 0 rows, roughly 0 B.
355
+ ```
356
+
357
+ ```
358
+ $ edf2csv annotations.edf --out csv --annotations-only
359
+ Wrote csv
360
+ annotations.csv 3 rows
361
+ channels.csv 1 rows
362
+ ```
363
+
364
+ The estimate describes the signal tables, and under --annotations-only there are none, so
365
+ zero was true of what it measures and false of the run. --info exists to say what a
366
+ conversion will do before you commit to it; asserting it will write nothing, when it will
367
+ write a file, is the one thing it must not do.
368
+
369
+ How many events there are cannot be answered from the header — the annotation channel has to
370
+ be read record by record, which is the scan --info exists to avoid. So it names the files and
371
+ says the count is not knowable this cheaply, rather than inventing a zero:
372
+
373
+ ```
374
+ Would write annotations.csv and channels.csv, and no signal data. How many events there
375
+ are cannot be told from the header.
376
+ ```
377
+
378
+ A recording with no annotation channel is told that instead, since it gets no annotations.csv
379
+ at all. An ordinary --info is untouched, --gzip keeps its "before compression" note, and a
380
+ test that had encoded "Would write 0 rows" as correct is replaced by one that checks the run
381
+ it describes really does write three events.
382
+
383
+ ## 0.4.50
384
+
385
+ ### Added: what --info predicts is checked against what a conversion writes
386
+
387
+ Two promises live in that one line, and they are not the same promise. The row count is
388
+ arithmetic on the header, so a conversion doing the same arithmetic has to land on the same
389
+ number — close is wrong. The byte count is documented as an approximation, and the direction
390
+ it errs in is the whole point: people read it to decide whether they have room, so reading
391
+ low is a defect even though "approximate" would excuse it.
392
+
393
+ Neither had anything checking it. `npm run estimate` crosses every fixture with every option
394
+ combination — 192 predictions over 34 recordings — and asserts the row count exactly and the
395
+ byte count as a bound. Sizes read 20% high on average, which is the side to be on.
396
+
397
+ It found the header row being measured as the raw labels rather than as the line that gets
398
+ written. A column name is quoted when it contains a comma, a quote, a newline or an edge
399
+ space, and every quote inside it is doubled; three channels labelled `a,b,c,d,e`, `x"y` and
400
+ `plain` write a 32-byte header and were budgeted 27. EDF labels are free text, so commas in
401
+ them are ordinary. csvRow writes that line, so csvRow now measures it — nothing else is in a
402
+ position to stay right when the quoting rules change.
403
+
404
+ The sweep carries no allowlist. There is a case the estimate cannot bound — a recording whose
405
+ samples fall outside the digital range its own header declares — but no fixture does that, and
406
+ an exemption nobody has to earn is how a regression gets in wearing the name of a known case.
407
+
408
+ ## 0.4.49
409
+
410
+ ### Fixed: two names a macOS filesystem makes one are refused, not merged
411
+
412
+ The guard that refuses two recordings landing in the same directory case-folded and stopped
413
+ there. HFS+ and APFS fold Unicode normalisation as well, so `café` written as e + U+0301 and
414
+ `café` written as U+00E9 are one directory — while remaining two different JavaScript
415
+ strings, which is all the guard compared.
416
+
417
+ ```
418
+ study/café.edf (NFC) -> csv/café/signals.csv
419
+ study/café.bdf (NFD) -> csv/café/signals_256hz.csv, _128hz, _1hz
420
+ ```
421
+
422
+ One directory holding two recordings, under a single metadata.json naming one of them,
423
+ reported as "Converted 2 of 2 recordings" and exit 0 under --force. The extensions differ,
424
+ which is what lets both files exist while their stems collide.
425
+
426
+ Without --force the second conversion happened to hit "already exists" — the accidental save
427
+ rather than the check doing its job, and it named the wrong problem: a directory in the way,
428
+ rather than two recordings claiming one name. It now says what is actually wrong, before
429
+ anything is written, with or without --force.
430
+
431
+ Folded only on darwin. On Linux those are genuinely two directories and refusing them would
432
+ be inventing a collision that is not there; Windows preserves normalisation too. That is the
433
+ same platform-shaped assumption the case fold already made, and it keeps the same limit — a
434
+ volume that normalises while the running platform does not is covered by neither.
435
+
436
+ ## 0.4.48
437
+
438
+ ### Fixed: the read buffer is the size of the data, not the size of the budget
439
+
440
+ `chunkBytes` is a ceiling on how much to read at once. The buffer was however many records
441
+ would fit in it — whether or not the file had that many.
442
+
443
+ ```
444
+ a 848-byte fixture, 2 records, read with a 512 MB budget
445
+ allocated 536,870,880 bytes
446
+ ```
447
+
448
+ and every ordinary read of a small recording reserved the full 8 MB default for a file that
449
+ might be a tenth of a kilobyte. Nothing was wrong with the data; the memory simply had
450
+ nothing to do with it. A batch of five hundred short recordings paid it five hundred times.
451
+
452
+ Bounded now by what is actually going to be read, so the fixture above allocates 80 bytes and
453
+ a windowed read of two records allocates two records. Large files are untouched: their record
454
+ count exceeds what the budget allows either way, so the budget is still what decides.
455
+
456
+ The samples cannot move, and are checked not to: the same five recordings read with a
457
+ one-byte budget, a one-megabyte budget and a 512 MB budget produce identical sample
458
+ sequences. The suite already held that check for two of those; this adds the third.
459
+
460
+ ## 0.4.47
461
+
462
+ ### Fixed: two ways a batch still depended on things the command did not say
463
+
464
+ --out went back to meaning the output directory itself when a named folder turned out to
465
+ hold nothing.
466
+
467
+ ```
468
+ edf2csv study named.edf --out csv study holds recordings -> csv/named/signals.csv
469
+ edf2csv blank named.edf --out csv blank holds none -> csv/signals.csv
470
+ ```
471
+
472
+ 0.4.20 took this decision off the recording count, and the flag it left behind answered "did
473
+ any input come from a directory" rather than "was a directory named" — the same question
474
+ only when the directory yielded something. So whether an unrelated folder happened to
475
+ contain anything decided where a different recording's output went, silently, exit 0. It is
476
+ now decided by what was named, which is what 0.4.20 said it was.
477
+
478
+ And a child was handed its options as two arguments each, so a value beginning with a dash
479
+ became another option in the child's parser:
480
+
481
+ ```
482
+ edf2csv study --out ./-nightly converts
483
+ edf2csv study --out ./-nightly --jobs 2 Option '--out' argument is ambiguous.
484
+ ```
485
+
486
+ The same command, converting everything one way and nothing the other. A leading dash is not
487
+ exotic — path.join produces one from a folder given as `.`, and directories get named after
488
+ dates and flags. 0.4.19 fixed this for the recording's own path with a `--` separator; every
489
+ option that carries a value had it too, and they now go over as `--flag=value`, which cannot
490
+ be misread. Serial and parallel produce identical bytes with such a destination.
491
+
492
+ ## 0.4.46
493
+
494
+ ### Fixed: --info and the conversion agree again about where a recording starts
495
+
496
+ 0.4.41 taught a conversion to take a continuous recording's origin from whichever record
497
+ first states one, so a single unreadable timekeeping entry no longer costs the file its
498
+ position. --info was left reading record 0 and stopping there, so from that version the two
499
+ halves of the tool described the same file differently.
500
+
501
+ ```
502
+ $ edf2csv rec.edf --info --start 3
503
+ error: --start "3" is at or past the end of this 3s recording.
504
+ ```
505
+
506
+ ```
507
+ $ edf2csv rec.edf --out csv --start 3
508
+ Wrote csv
509
+ signals.csv 2 rows
510
+ ```
511
+
512
+ --info found nothing at record 0 and reported a recording starting at zero; the conversion
513
+ took the origin from record 1 and timed it from 0.5s. The flag exists to tell you what a
514
+ conversion will do before you commit to it, so disagreeing with the conversion is the one
515
+ thing it must not do.
516
+
517
+ It now reads on past record 0 until a record states its own time, up to sixteen of them.
518
+ Continuity is what makes that cheap and correct: record i beginning at t puts the origin at
519
+ t - i * duration, so any one of them settles it, and sixteen small reads keep --info a header
520
+ read rather than the per-record scan it was deliberately spared. On the 205 KB fixture it
521
+ still returns in 0.05s.
522
+
523
+ readFirstRecordStart is now readOrigin, since it returns the recording's origin rather than
524
+ one record's start. It was never documented.
525
+
526
+ ## 0.4.45
527
+
528
+ ### Fixed: an ordinary recording stops being accused of contradicting its own continuity
529
+
530
+ 0.4.41 added a check that an EDF+C file's records sit where continuity puts them, and wrote
531
+ it as an equality between two doubles. They are not equal. A recording of 0.1s records
532
+ sitting at 0.1, 0.2, 0.3 ... is contiguous by construction, and 0.1 + 2 * 0.1 is
533
+ 0.30000000000000004.
534
+
535
+ ```
536
+ warning: This file is marked continuous (EDF+C), but 2 of its 8 data records say they
537
+ start somewhere other than where continuity puts them.
538
+ ```
539
+
540
+ On a file with nothing whatever wrong with it — and under --strict, a failed run. Fractional
541
+ record durations are ordinary; the format has an 8-character field for exactly that.
542
+
543
+ The comparison is now against what the recording can express: one sample of its fastest
544
+ channel is the shortest span it distinguishes, and anything below half of that is arithmetic
545
+ rather than a gap. canCarry already refuses origins where the double spacing swamps that
546
+ interval, so the representation error is under the tolerance by construction rather than by
547
+ hope — the two checks now share the same notion of "the finest thing this file can say".
548
+
549
+ A record that really is somewhere else is still caught: a file marked EDF+C whose records
550
+ jump from 1.5s to 10.5s reports one of three.
551
+
552
+ Shipped three versions ago and mine. The lesson is the ordinary one about comparing computed
553
+ floating point for equality, which is why the fixture that catches it is now in the suite in
554
+ both directions.
555
+
556
+ ## 0.4.44
557
+
558
+ ### Fixed: the numbers the documentation prints are numbers this repository produces
559
+
560
+ Five figures, each verified by running the thing that produces it.
561
+
562
+ The --info footer said "roughly 27.4 KB" on two pages where the tool says 22.2 KB, and
563
+ api.md printed estimate.bytes of 28095 against an actual 22749. The estimate changed and the
564
+ transcripts did not.
565
+
566
+ The convert() example in api.md was wrong three ways in one block. Its `window` object had
567
+ lost `recordingStartSeconds` and `recordingEndSeconds`, added to ResolvedRange since. Its
568
+ estimate said 6165 against an actual 5172. And its warning read "3 different sampling rates
569
+ (256 Hz, 128 Hz, 1 Hz)" for a call that selects two channels at two rates — where the prose
570
+ directly below it says "Two channels were requested at two different rates". That is the
571
+ 0.4.x change making the mixed-rate warning describe the conversion rather than the file, and
572
+ the sample output was never updated, so the page contradicted itself in adjacent lines. The
573
+ note explaining why it is two rather than three is now there.
574
+
575
+ And the correctness page's test counts, wrong again: 179 against 197, having been 148 against
576
+ 179 before 0.4.34 corrected them. So this time the docs test checks them — per file, against
577
+ the summary, and against the prose that repeats the total. It caught its own new test on the
578
+ first run, which is what it is for.
579
+
580
+ ## 0.4.43
581
+
582
+ ### Added: the documentation's lists are checked against the source's
583
+
584
+ Four times in the 0.4 line a new diagnostic shipped and one of the three places that
585
+ enumerate them was not updated — the table in warnings-and-errors.md, the code list in
586
+ cli-reference.md, the block in api.md. Nothing failed, because nothing checked. The lists
587
+ are prose, and prose does not compile.
588
+
589
+ Now a test reads both directions: every code in the source is named on all three pages, and
590
+ every code the pages list exists. Same for the conversion error codes against api.md, every
591
+ flag in --help against the README and the reference, and the set of exit codes against the
592
+ table that explains them.
593
+
594
+ It reads the three enumerating constructs rather than sweeping the pages for upper-case
595
+ words, because those are also --info column headings, errno names and the format's own
596
+ vocabulary — a check that caught those would need an allowlist that grows with the prose,
597
+ which is the kind of test people delete.
598
+
599
+ On its first run it found NONSTANDARD_UNIT declared in DiagnosticCode and named on two pages
600
+ out of three. It has been dead since 0.1: reserved for a physical dimension outside the set
601
+ the spec recommends, never raised, and a warning on every unusual unit would fire on most
602
+ real recordings, which is presumably why. 0.4.37 took the other dead code, NONPRINTABLE_LABEL,
603
+ and implemented it. This one is removed instead, so the type stops offering a value nothing
604
+ can produce and the reference loses its "codes that exist but are never raised" section
605
+ along with the last thing in it.
606
+
607
+ Nothing that ever occurred at runtime changes: no conversion could produce that value.
608
+
609
+ ## 0.4.42
610
+
611
+ ### Fixed: the reference stops describing a version of the tool that no longer exists
612
+
613
+ ```
614
+ The input path must be a regular file that can be read. A directory, a missing path or
615
+ a special file is a file error (exit 1), not a usage error.
616
+ ```
617
+
618
+ Every clause of that is wrong for a directory, and has been since folders became inputs. A
619
+ folder of recordings converts and exits 0. A folder holding none exits 2, not 1. The exit-1
620
+ list said the same thing a second time — "the input can't be read: ... it's a directory" —
621
+ and the UNREADABLE section printed an example the command line cannot produce.
622
+
623
+ That last one is worth keeping rather than deleting: `EdfFile.open` does still refuse a
624
+ directory, because the library takes one recording and a directory is not one. What changed
625
+ is that the CLI expands a directory before it gets there. The section now says which of the
626
+ two you are looking at.
627
+
628
+ A page that describes the wrong tool is worse than a page with a gap in it — the gap sends
629
+ you to try it, and this sent you to conclude the feature was not there. A test pins the
630
+ contract in all four of its parts: a folder converts, an empty folder is 2, a missing file
631
+ is 1, and the library still refuses a directory.
632
+
633
+ ## 0.4.41
634
+
635
+ ### Fixed: one unreadable timekeeping entry stops costing a recording its origin
636
+
637
+ The EDF+C origin came from recordStarts[0] and nowhere else. When that one entry could not
638
+ be decoded, the whole file was timed from zero — while records 1 and 2, saying plainly that
639
+ they begin at 1.5s and 2.5s, went unread.
640
+
641
+ A recording whose records sit at 0.5s, 1.5s and 2.5s came out with every sample half a
642
+ second earlier than the file states, against annotation onsets that kept their true values,
643
+ so an event at +0.75 fell between rows. That is exactly the mismatch 0.4.9 fixed, arriving
644
+ through the one hole left in it — and the byte-identical EDF+D twin timed it correctly,
645
+ which is what gives it away.
646
+
647
+ Continuity is what makes it recoverable: record i sits at origin + i * duration, so any
648
+ readable record fixes the origin for all of them. The EDF+D fallback for a record with no
649
+ readable time now starts from that origin too, rather than assuming zero, so the twins agree
650
+ about record 0 as well.
651
+
652
+ Two things fall out of reading the other records. A file marked EDF+C whose records say they
653
+ sit somewhere other than where continuity puts them is now reported rather than quietly
654
+ timed as contiguous. And a timekeeping entry is counted apart from the events, because
655
+ calling it an annotation that "could not be exported" was wrong twice: a file with one bad
656
+ timekeeping entry and three good events reported one entry lost while exporting all three,
657
+ and never mentioned the timing that had actually gone. The EDF+D path keeps its own
658
+ per-record message, which is more specific; saying both would report one problem twice.
659
+
660
+ ## 0.4.40
661
+
662
+ ### Fixed: a --stdout failure stops describing files it never wrote
663
+
664
+ ```
665
+ error: Writing to stdout failed: ENOSPC: no space left on device, write
666
+ The files written so far are incomplete and should not be used. The destination
667
+ is out of space; free some up or choose another with --out.
668
+ ```
669
+
670
+ Both halves of that hint were written for --out. There are no "files written so far" on this
671
+ path — the conversion writes to a stream the shell redirected — and --out is the flag whose
672
+ absence is the reason the message exists at all. Someone following it goes looking for a
673
+ partial directory that was never created, and is pointed at a mode they deliberately did not
674
+ use.
675
+
676
+ It now names what there actually is:
677
+
678
+ ```
679
+ What reached stdout before it failed is incomplete and should not be used. The
680
+ destination is out of space; free some up or redirect it somewhere else.
681
+ ```
682
+
683
+ Same class as the disk-space hint 0.4.36 replaced, one flag over: advice that fits one path
684
+ being given on another. The --out wording is untouched, and every errno keeps the sentence
685
+ 0.4.36 gave it.
686
+
687
+ ## 0.4.39
688
+
689
+ ### Fixed: --stdout stops reporting success for bytes that never arrived
690
+
691
+ ```
692
+ $ edf2csv long-stream.edf --stdout > /Volumes/small/sig.csv
693
+ Wrote 102,400 rows to stdout.
694
+ $ echo $?
695
+ 0
696
+ ```
697
+
698
+ 94,977 rows on disk. The file ends mid-row at "371.00390625," with no trailing newline,
699
+ 150,904 bytes short, and the line on stderr overstates the count by 7,423 rows. The same
700
+ recording onto the same volume through --out fails correctly — exit 1, ENOSPC named — which
701
+ is what gives it away.
702
+
703
+ POSIX write returns a short count rather than an error when the filesystem fills partway
704
+ through a single call; only the NEXT write raises ENOSPC. --out always has a next write,
705
+ since channels.csv and metadata.json come after the samples, so it always finds out.
706
+ --stdout has nothing after it. And when fd 1 is a regular file, Node's stdout is a
707
+ SyncWriteStream whose _write discards the count writeSync returns, so no error is raised at
708
+ all — which means checking the stream's recorded failure would not have caught it either.
709
+
710
+ What can be checked is the descriptor: how much it grew against how much it was handed. Only
711
+ for a regular file, since a pipe or a terminal has no size to compare and cannot lose a
712
+ write this way without saying so; appending with >> is fine, because the starting size is
713
+ taken first. Under --gzip the bytes counted are the compressor's output rather than the
714
+ CSV's, since those are what stdout is given.
715
+
716
+ Two guards keep it off the healthy paths: the audit declines anything that is not a regular
717
+ file, and it is skipped entirely when the reader hung up, because `--stdout | head -1` is a
718
+ shell idiom and not a failure. Reporting a failure for a command that worked would be worse
719
+ than the bug.
720
+
721
+ BufferedLineWriter.end() also returned early for stdout without consulting the failure the
722
+ stream's error listener had recorded, so an error not yet surfaced by a later flush was
723
+ dropped. It looks now.
724
+
725
+ This is a narrower window than "any full disk": when an earlier flush crosses the boundary a
726
+ later write does raise ENOSPC and that was always handled. It is the case where the output
727
+ very nearly fits.
728
+
729
+ ## 0.4.38
730
+
731
+ ### Fixed: the library stops blaming the destination for the caller's own failures
732
+
733
+ Two places where an answer came from the wrong layer.
734
+
735
+ `changedSinceOpen` returned false on a closed file. `convert` closes the file before it
736
+ returns, so `result.file.changedSinceOpen()` denied the very change the INPUT_CHANGED
737
+ diagnostic in the same result object had just reported — one object, two answers. False is
738
+ not something a closed descriptor can know. It now remembers the last answer it computed,
739
+ and `convert` always asks on the way out, so the result agrees with itself. A file closed
740
+ without ever being asked throws instead of guessing.
741
+
742
+ A progress callback that threw came back as
743
+
744
+ ```
745
+ ConversionError WRITE_FAILED: Writing to "out" failed: caller bug
746
+ The files written so far are incomplete and should not be used. Check the destination
747
+ and run the conversion again.
748
+ ```
749
+
750
+ The destination was working perfectly; the caller's own callback threw. onProgress ran
751
+ inside the same try that turns a stream failure into WRITE_FAILED, which is the same
752
+ misattribution the write hints carried until 0.4.36, one layer up. It now raises
753
+ CALLBACK_FAILED naming the callback, keeps the original as `cause` so the stack that matters
754
+ survives, and still stops the conversion — writing on into a directory whose owner has just
755
+ failed is not an improvement. A ConversionError that arrives at the outer catch already
756
+ saying what went wrong is passed through rather than wrapped a second time.
757
+
758
+ Found by an auditor looking at the library surface; a third report from the same pass — a
759
+ raw ENOENT escaping when the input is deleted mid-run — did not reproduce, since an open
760
+ descriptor outlives both unlink and rename.
761
+
762
+ ## 0.4.37
763
+
764
+ ### Fixed: a label made of control characters is reported instead of passed on in silence
765
+
766
+ NONPRINTABLE_LABEL has been declared in the source and documented as reserved since 0.1.
767
+ Nothing raised it.
768
+
769
+ `--info` has escaped control bytes since it was written, on the reasoning that an ANSI
770
+ escape in a header can drive the reader's terminal — \x1b[2J clears the screen, which is
771
+ enough to hide the rest of the output. The CSV had no such protection and needs none for
772
+ correctness: quoting makes any byte safe for a parser, and passing the label through exactly
773
+ as the file has it is the right call, since losing what the header says is not an
774
+ improvement.
775
+
776
+ What was missing is the sentence saying so. A recording whose channel is labelled
777
+ \x1b[2Jgone converted with no warning at all, and `cat signals.csv` then cleared the
778
+ terminal — while a script referencing that column by name carried an invisible control
779
+ character in it. The two halves of the tool disagreed about whether this was worth
780
+ mentioning.
781
+
782
+ Now every affected channel gets a warning naming the bytes, escaped, and saying to address
783
+ it by position with --channels "#N" since the name cannot be typed. Tab is included: it is
784
+ harmless to a terminal but it makes a column name nothing can match reliably. Nothing is
785
+ rewritten — the label still reaches the CSV as the file has it.
786
+
787
+ That leaves NONSTANDARD_UNIT as the only code still declared and never raised, and the
788
+ documentation now says so rather than saying two.
789
+
790
+ ## 0.4.36
791
+
792
+ ### Fixed: a failed write says what went wrong, not "free up space" every time
793
+
794
+ Every write failure carried one hint: "Free up space or choose another destination with
795
+ --out". That fits exactly one errno. A directory sitting where signals.csv belongs produced
796
+
797
+ ```
798
+ error: Writing to "csv" failed: EISDIR: illegal operation on a directory, open "csv/signals.csv"
799
+ The files written so far are incomplete and should not be used. Free up space or
800
+ choose another destination with --out, then run the conversion again.
801
+ ```
802
+
803
+ and so did a read-only volume, a permission denial, a path too long for the filesystem, and
804
+ running out of file descriptors. Wrong advice is worse than none: it sends someone to check
805
+ `df` on a disk that is fine, while the thing that actually failed stays unexamined.
806
+
807
+ The errno is the one part of the failure that names the cause, so it is what picks the
808
+ sentence now — out of space, over quota, no permission, read-only, a directory in the way,
809
+ a path that vanished, a name too long, too many open files (which a recording with many
810
+ sampling rates can reach, since it opens one output file per rate, so --channels narrows
811
+ it). Anything unrecognised keeps the general form rather than guessing.
812
+
813
+ What does not change is the part that matters: whatever the cause, the files written so far
814
+ are incomplete and must not be used.
815
+
816
+ ## 0.4.35
817
+
818
+ ### Fixed: a window that lands where there is no data says so
819
+
820
+ `--start` at or past the end of the recording is a usage error and stops the run. This is
821
+ the narrower case it leaves behind: a window that lies inside the recording but selects
822
+ nothing. Between the last sample and the nominal end of the last record —
823
+
824
+ ```
825
+ edf2csv tiny.edf --start 1.95 --out csv
826
+ ```
827
+
828
+ — or, on a discontinuous file whose records sit at 0s, 1s and 10s, anywhere in the eight
829
+ second gap:
830
+
831
+ ```
832
+ edf2csv study.edf --start 2 --end 10 --out csv
833
+ ```
834
+
835
+ Both produced a signals.csv holding its header and nothing else, exit 0, no warning, and
836
+ --strict passing. That is exactly what a successful extraction of an empty range looks like.
837
+ The summary does print "signals.csv 0 rows" and --json carries rows: 0, so it was not
838
+ invisible — but everywhere else that a request produces nothing, this tool says so: a
839
+ --channels term matching nothing is an error, and --annotations-only on a file with no
840
+ events raises NO_ANNOTATIONS.
841
+
842
+ EMPTY_WINDOW now covers it, quoting the window back and pointing at --info to see where the
843
+ records really sit. A warning rather than an error, because a batch of five hundred
844
+ recordings should not stop for the one whose gap lines up with the window; --strict makes it
845
+ a failure for those who want that.
846
+
847
+ The paths that write no signal table by design are left alone: --annotations-only, and a
848
+ file whose only channel carries annotations.
849
+
850
+ ## 0.4.34
851
+
852
+ ### Fixed: the correctness page states numbers this repository can produce
853
+
854
+ Five things had drifted apart from what the commands actually print.
855
+
856
+ ```
857
+ "Three separate claims", followed by five numbered ones. The list grew as the batch and
858
+ fuzz harnesses were added and the heading did not.
859
+ ```
860
+
861
+ ```
862
+ "4,000 runs over 1,000 corrupted recordings" for `npm run fuzz`, which does 1,200 over 300
863
+ at its default seed. The larger figure is reachable — `npm run fuzz -- 42 2000` — so the
864
+ page now says which is the default and how to ask for more.
865
+ ```
866
+
867
+ ```
868
+ "ℹ tests 148" and a per-file table adding to 148, against a suite of 179. The table is
869
+ regenerated from what the files hold: 35, 59, 85.
870
+ ```
871
+
872
+ ```
873
+ 129,536 sample values in the README's accuracy section, a number from a recording set that
874
+ is not in this repository. Both places now say 16,943 across the 75 recordings that are.
875
+ ```
876
+
877
+ ```
878
+ The README put `npm test` under the pyEDFlib claim, and `npm test` has never run that
879
+ check — it is deliberately kept out so the package can stay dependency-free. It now shows
880
+ `pip install pyedflib && npm run crossvalidate`, which is the command that does it.
881
+ ```
882
+
883
+ A number nobody can reproduce is worse than no number, on a page whose subject is what has
884
+ actually been verified.
885
+
886
+ ## 0.4.33
887
+
888
+ ### Fixed: the library checks its options too, before anything is written
889
+
890
+ The command line has always rejected these values — `--decimals 1.5` is a usage error and
891
+ always has been — and the library did not, so the same value behaved differently depending
892
+ on how it arrived:
893
+
894
+ ```
895
+ decimals: NaN resolved successfully, having written whole numbers into a column the
896
+ caller had asked for decimals in. No error, no warning, and output that
897
+ looks like a deliberate choice.
898
+ decimals: -1 came back as a bare RangeError from inside toFixed, naming nothing the
899
+ caller had written.
900
+ start: NaN created the output directory, wrote signals.csv, and then failed saying
901
+ the input was unreadable — a partial conversion, blamed on the file.
902
+ ```
903
+
904
+ The first is the one that matters: a conversion that succeeds and is wrong.
905
+
906
+ `assertOptions` now runs at the top of buildPlan, which every path goes through before a
907
+ directory is created or a stream is opened, so a rejected option leaves nothing behind. It
908
+ throws `OptionError`, which the CLI already declared privately and now shares with the
909
+ library and exports, so a bad option is one error type whichever way it arrived — and the
910
+ CLI keeps exit 2 for it.
911
+
912
+ ## 0.4.32
913
+
914
+ ### Changed: the bit-for-bit cross-check actually compares bits
915
+
916
+ The correctness page has said, since it was written, that edf2csv's physical values match
917
+ pyEDFlib's "to the last bit ... not equal to within a tolerance, not numpy.allclose". It even
918
+ printed the method: dump the doubles through the API, compare the 64 bits.
919
+
920
+ `npm run crossvalidate` did something else. It converted with --decimals 20, parsed the cells
921
+ back into floats, and accepted anything within abs(reference) * 1e-9 — and skipped empty
922
+ cells without counting them as anything. That cannot be exact whatever the tolerance: a cell
923
+ is a rounded decimal rendering, so reading it back gives the nearest double to the printed
924
+ digits rather than the double that was computed. The page described a check that did not
925
+ exist.
926
+
927
+ The recipe the page prints is now checked in as test/crossvalidate/dump-doubles.mjs and is
928
+ what the checker runs, so the documented method and the executed one are the same code. Every
929
+ value is compared as its 64 bits, addressed by the signal's position rather than its label —
930
+ labels need not be unique, and matching on them could compare one channel against another's
931
+ samples.
932
+
933
+ Confirmed capable of failing before being trusted: flipping the lowest mantissa bit of every
934
+ scaled value is caught on the first sample of every recording, including differences no
935
+ decimal rendering shows — pyEDFlib -1.0 against edf2csv -1.0000000000000002, which the old
936
+ tolerance passed in silence.
937
+
938
+ ```
939
+ Compared 16,943 sample values bit for bit, and 120 annotations, across 75 recordings.
940
+ Every value agreed.
941
+ ```
942
+
943
+ The README and the correctness page said 129,536 values, a number from a recording set that
944
+ is not in the repository. Both now state what the shipped command actually compares, and the
945
+ README no longer files the pyEDFlib claim under `npm test`, which never ran it.
946
+
947
+ ## 0.4.31
948
+
949
+ ### Fixed: metadata describes the file that was converted, or admits it cannot
950
+
951
+ The size, timestamp and SHA-256 in metadata.json came from re-opening the input path once
952
+ the CSVs were written. That describes whatever answers to that name by then, which need not
953
+ be what was converted. A recording still being written grew from 2,000 records to 3,000
954
+ mid-conversion, and metadata.json recorded:
955
+
956
+ ```
957
+ "bytes": 1536512, "sha256": "28535bb4...", <- the 3,000-record file
958
+ "data_records": 2000 <- what the CSV holds
959
+ ```
960
+
961
+ Two halves of one provenance record describing two different files, with nothing to say so.
962
+ Replacing the file at that path did the same thing more thoroughly. That is the opposite of
963
+ what --checksum is for.
964
+
965
+ `bytes` and `modified` now come from the descriptor state at open — the same size every
966
+ record count and window in the output was derived from — so the record is internally
967
+ consistent whatever happens to the path.
968
+
969
+ The hash needed more than that. A file overwritten in place keeps its inode, so the open
970
+ descriptor sees the new bytes too and the converted ones are gone; no post-hoc hash can
971
+ recover them. So it is taken before the first record is read, and published only if the file
972
+ held still: if size or modification time moved at any point, `sha256` is null and the run
973
+ raises INPUT_CHANGED saying why. A plausible hash of the wrong bytes is worse than none.
974
+ `sha256` present now means the file demonstrably did not change while it was read.
975
+
976
+ The change is reported even without --checksum, since a conversion of a file that moved
977
+ under it is worth knowing about on its own. The CSVs stay correct for the records that were
978
+ read either way.
979
+
980
+ ## 0.4.30
981
+
982
+ ### Fixed: the folder above the recording stops choosing its name by accident too
983
+
984
+ 0.4.29 settled which of two names for one recording the output is called after, and left the
985
+ directory above it deciding the same question the old way. The walk was a stack popped from
986
+ the back, so which of two names for one folder was visited first came down to the order
987
+ readdir returned them — and the loser was skipped as already seen, taking its name out of
988
+ the run.
989
+
990
+ ```
991
+ study/aaa-real/rec.edf
992
+ study/zzz-alias -> study/aaa-real
993
+ ```
994
+
995
+ ```
996
+ edf2csv study --out ./out -> out/zzz-alias/rec/signals.csv
997
+ ```
998
+
999
+ The link's name, chosen by a hash order that differs between filesystems. So the release
1000
+ note for 0.4.29 was true of files and not of the folders holding them, which is the same
1001
+ defect one level up.
1002
+
1003
+ Breadth first now, with each directory's children entered real names before links and
1004
+ alphabetically within each, so the surviving name is a property of the tree: the shallowest,
1005
+ then the one that is not a link, then the first in sort order. Both orderings of the names
1006
+ are tested — aaa-real beside zzz-alias, and zzz-real beside aaa-alias — so passing cannot be
1007
+ the sort order agreeing with the answer by luck.
1008
+
1009
+ ## 0.4.29
1010
+
1011
+ ### Fixed: one recording, two names — the output directory stops depending on the order
1012
+
1013
+ A recording reachable more than one way is converted once, and the surviving name was
1014
+ whichever arrived first. That made the output directory a function of enumeration order:
1015
+
1016
+ ```
1017
+ edf2csv data/one.edf data/alias.edf --out ./out -> out/one
1018
+ edf2csv data/alias.edf data/one.edf --out ./out -> out/alias
1019
+ ```
1020
+
1021
+ A shell orders a glob however it likes. Inside a folder it was whatever readdir returned,
1022
+ which is hash order on APFS, a different hash on ext4, and creation order elsewhere — so
1023
+ copying a study to another machine could rename its output, and a script that read
1024
+ out/one/signals.csv found nothing there.
1025
+
1026
+ The winner is now decided by the names themselves. A name the recording actually has beats
1027
+ a link pointing at it, since that is the name the file has; two links are settled by the
1028
+ path that sorts first. Both are properties of the file set, so the answer does not move.
1029
+
1030
+ One behaviour change falls out of it: a recording reachable as `link.edf` and as
1031
+ `linkdir/actual.edf` now converts into `<out>/linkdir/actual` rather than `<out>/link`,
1032
+ which is also the layout the folder has.
1033
+
1034
+ ## 0.4.28
1035
+
1036
+ ### Changed: a request that cannot be carried out exits 2, like every other one
1037
+
1038
+ Exit 1 means "the file or the destination is the problem"; exit 2 means "the command line
1039
+ is the problem". Both --stdout refusals were filed under 1:
1040
+
1041
+ ```
1042
+ error: --stdout has no signal data to write because --annotations-only was given.
1043
+ Drop one of the two flags.
1044
+ ```
1045
+
1046
+ ```
1047
+ error: --stdout needs exactly one table, but this recording produces 3, one for each
1048
+ sampling rate its channels use (256 Hz, 128 Hz, 1 Hz).
1049
+ Narrow it to one rate with --channels, or convert to a directory instead.
1050
+ ```
1051
+
1052
+ Both hints say, in as many words, to change the flags — and a script reading the exit code
1053
+ went looking at the disk instead. `--stdout --json` was already 2 for exactly this reason,
1054
+ so the three conflicts disagreed with each other.
1055
+
1056
+ Exit 2 has always covered checks that need the header first: a --channels term matching
1057
+ nothing, a --start past the end of the recording. These belong with them. The library keeps
1058
+ throwing a ConversionError so nothing about its API changes, under a new code
1059
+ UNSUPPORTED_REQUEST that says which kind of problem it is; a destination that genuinely
1060
+ cannot be written is still exit 1.
1061
+
1062
+ ## 0.4.27
1063
+
1064
+ ### Changed: --json is shaped by what you named, and the JSON Lines batch is documented
1065
+
1066
+ Two things, one cause.
1067
+
1068
+ The batch flag was `inputs.length > 1`, so the shape of --json depended on the contents of a
1069
+ folder rather than on the command. A study holding one night printed an indented document;
1070
+ the same study holding two printed JSON Lines. A script written against one broke on the
1071
+ other — on the day a recording was added, not the day the script changed — and an input
1072
+ going missing did it in reverse. That is the same count 0.4.20 took out of --out, for the
1073
+ same reason, and it is now decided the same way: naming a folder means a batch whether it
1074
+ holds one recording or fifty.
1075
+
1076
+ And the documentation never mentioned JSON Lines. It said "the whole result of a successful
1077
+ run is one parseable document on stdout", which is true of one recording and false of a
1078
+ folder: `json.load` fails on the second line. One object per line as each recording finishes
1079
+ is the right shape for a batch — five hundred recordings can be consumed while the run is
1080
+ still going, and `jq` reads it a record at a time — so the docs now say so, and say what to
1081
+ use in Python.
1082
+
1083
+ ## 0.4.26
1084
+
1085
+ ### Fixed: the signal count is read the way every other header number is read
1086
+
1087
+ EdfFile.open has to know how many signals there are before it can know how much header to
1088
+ read, and it worked that out with its own Number(). That parse tolerated the NUL padding
1089
+ sloppy writers emit — the reason it existed — but not the comma decimal separator, which
1090
+ every other numeric field here accepts and which the documentation lists this field among.
1091
+
1092
+ So a header written with a comma never got its signal headers read at all, and the file
1093
+ died on a message whose arithmetic refuted itself:
1094
+
1095
+ ```
1096
+ error: File declares 2 signals, which needs a 768-byte header, but the file is
1097
+ only 848 bytes.
1098
+ ```
1099
+
1100
+ 848 is larger than 768. Following that message leads to a truncation that is not there,
1101
+ while parseHeader, handed the same bytes directly, read the file correctly and raised
1102
+ COMMA_DECIMAL. The reader and the parser disagreed about which files were readable.
1103
+
1104
+ Both now share one parse, so they cannot drift apart again. The size error also names
1105
+ whichever of the two is actually short — the file, or the bytes the parser was handed.
1106
+
1107
+ ## 0.4.25
1108
+
1109
+ ### Fixed: the _ch suffix is checked against the file, not only against the label it fixes
1110
+
1111
+ Duplicated labels get a `_ch<index>` suffix, which is unique among the channels sharing that
1112
+ label — and nothing stopped it from landing on a label some other channel already had. EDF
1113
+ labels are free text and nothing enforces uniqueness, so a file carrying T8, T8 and a third
1114
+ channel genuinely labelled T8_ch0 is well-formed. It produced:
1115
+
1116
+ ```
1117
+ time_s,T8_ch0,T8_ch1,T8_ch0
1118
+ ```
1119
+
1120
+ Two columns, one name, and the warning beside it said the suffix kept them
1121
+ "distinguishable". channels.csv listed T8_ch0 against two signal indices, so the join it
1122
+ exists for could not resolve it either; metadata.json recorded the name twice; and a
1123
+ name-based lookup returns one of the two with nothing to say which. Exit 0 throughout.
1124
+
1125
+ Names are now made unique across the whole file: anything still shared after the first pass
1126
+ takes its own position too, which is unique by construction. And the channel that lost its
1127
+ own label to another's suffix is named, because its column is the one thing in the output
1128
+ that no longer matches the file:
1129
+
1130
+ ```
1131
+ warning: Signal 2 is labelled "T8_ch0", which is also the column name another channel's
1132
+ "_ch" suffix produces, so its column is "T8_ch0_ch2".
1133
+ ```
1134
+
1135
+ ## 0.4.24
1136
+
1137
+ ### Fixed: an origin the file's own arithmetic cannot hold is reported, not acted on
1138
+
1139
+ 0.4.9 taught continuous recordings to honour the first timekeeping TAL, which is right, but
1140
+ it took the number at face value. A double spaces its values further apart the larger they
1141
+ get: at 1e16 the gap is two seconds, so `t + 1` is `t`. Past that point an origin stops
1142
+ being a position and becomes a wall.
1143
+
1144
+ Two silent failures came out of that. At 1e16 the collapse is partial, and the test for
1145
+ "does this record overlap the window" — `start + recordDuration > windowStart` — is false
1146
+ for every record that rounded onto its neighbour. A twelve-row recording wrote four rows,
1147
+ exit 0, no warning; the eight that vanished looked exactly like a file that never had them.
1148
+
1149
+ At 1e17 every record lands on one instant, so the recording measures zero seconds long, and
1150
+ the window resolver had no reason to suspect the recording rather than the request:
1151
+
1152
+ ```
1153
+ error: --start 100000000000000000s is at or past the end of this 100000000000000000s
1154
+ recording.
1155
+ ```
1156
+
1157
+ --start was never passed.
1158
+
1159
+ Both paths now check that the origin can still separate two consecutive samples of the
1160
+ fastest channel. When it cannot, the recording is timed from zero — what it did before
1161
+ 0.4.9, and the only column that can hold distinct values at that magnitude — and says so,
1162
+ pointing at annotations.csv for the absolute onsets. An origin merely large is kept: at 1e15
1163
+ the gap is an eighth of a second, so a 4 Hz recording's quarter-second steps survive.
1164
+
1165
+ ## 0.4.23
1166
+
1167
+ ### Fixed: one offset cache for the whole conversion, not one per sampling rate
1168
+
1169
+ The time-column cache was capped at 2^20 offsets per rate group. A file may hold as many
1170
+ rate groups as it has channels, so nothing bounded the total.
1171
+
1172
+ Twelve channels at twelve rates just under the cap — a 25 MB file — peaked at 1.66 GB and
1173
+ took 36 seconds. A 92 MB file at a single rate, four times the data, peaks at 283 MB and
1174
+ finishes in a fraction of that. Twenty-four rates never finished at all: it spent two
1175
+ minutes swapping and was killed. A per-group limit is not a limit.
1176
+
1177
+ One budget for the conversion, spent in the order the groups ask. They are already sorted
1178
+ fastest-rate first, so the cache goes to the tables with the most rows to write and the
1179
+ ones that miss out are the ones that would have gained least from it — they format the
1180
+ same text the slow way.
1181
+
1182
+ ```
1183
+ 12 rates, 25 MB 1.66 GB / 36.0s -> 1.02 GB / 5.5s
1184
+ 24 rates, 50 MB did not finish -> 1.20 GB / 8.5s
1185
+ ```
1186
+
1187
+ A file with one rate group behaves exactly as it did: same budget, same cache, and 75
1188
+ fixture and flag combinations byte-identical to 0.4.22.
1189
+
1190
+ ## 0.4.22
1191
+
1192
+ ### Fixed: the time column stops writing "1e+21.000"
1193
+
1194
+ `fixed` has guarded value cells against the 1e21 cliff since 0.3.x, where toFixed switches
1195
+ to exponent notation. The time column lost that guard in 0.4.1, when the per-row toFixed
1196
+ was replaced by a cached decomposition: whole seconds plus printed fraction, concatenated.
1197
+ The concatenation is an implicit Number-to-String, which switches to exponent form at the
1198
+ same 1e21 — and the cached fraction is then glued onto the end of it:
1199
+
1200
+ ```
1201
+ time_s,ch1
1202
+ 750000000000000100000.000,0.300
1203
+ 1e+21.000,0.400
1204
+ 1.25e+21.000,0.500
1205
+ ```
1206
+
1207
+ "1e+21.000" is not a number in any notation. pandas and R both read it as NaN, in a column
1208
+ whose every other cell is plain fixed-decimal, so a reader has no reason to look for it.
1209
+
1210
+ Reachable because EDF's record-duration field is 8 characters and exponent form fits, so a
1211
+ header may legitimately say 1e21; three records get there. One comparison per row hands
1212
+ those to the slow path, which already expands them with BigInt, and leaves the cache doing
1213
+ its job for the other twenty million.
1214
+
1215
+ ## 0.4.21
1216
+
1217
+ ### Fixed: a conversion killed mid-flight names itself, and the directory it left
1218
+
1219
+ A process that dies by signal exits with a null code and prints nothing on its way out.
1220
+ The parent read that as an ordinary failure with empty output, so a batch whose child was
1221
+ killed printed "Converted 1 of 2 recordings; 1 failed." and nothing else — not which
1222
+ recording, not why, and not that out/b held a 194 MB signals.csv cut off mid-row with no
1223
+ channels.csv beside it. Half a CSV opens in pandas exactly like a whole one.
1224
+
1225
+ The out-of-memory killer, a job scheduler's time limit and `kill` all arrive this way, and
1226
+ they arrive on the machines where batches are largest. The close handler now reads the
1227
+ signal it was given and says so, in the same words the interrupt handler uses for a run
1228
+ stopped from the keyboard:
1229
+
1230
+ ```
1231
+ error: in/b.edf: stopped by SIGKILL before it finished.
1232
+ Incomplete, and should not be used: out/b
1233
+ ```
1234
+
1235
+ Ctrl-C keeps its single message: the interrupt handler names every abandoned directory at
1236
+ once, and a per-child line under it would repeat that once per job.
1237
+
1238
+ ## 0.4.20
1239
+
1240
+ ### Changed: a link that leads nowhere is reported, and --out stops depending on what was found
1241
+
1242
+ A study kept as one folder per night, with one night linked to an external drive that
1243
+ happened not to be mounted, converted the nights that were there and said nothing about
1244
+ the one that was not. The walk only reported entries whose names ended in .edf or .bdf,
1245
+ and a directory carries no such name.
1246
+
1247
+ Losing that input made it worse than a silent omission. --out decided between "the output
1248
+ directory" and "a parent to fill" by counting the recordings, so dropping one left a single
1249
+ recording and moved the survivor as well: csv/signals.csv instead of csv/night-01/rec/.
1250
+ Whether a drive was mounted changed both what was converted and where it went, exit 0.
1251
+
1252
+ Two changes. The walk reports anything it cannot inspect, whatever it is called, and that
1253
+ counts against the run. And what --out means is now decided by what was named rather than
1254
+ by what was found: one recording names the output directory itself, a folder or several
1255
+ recordings name a parent. `edf2csv study --out csv` writes csv/night-01/rec/ whether the
1256
+ study holds one night or fifty — adding a second night no longer moves the first one's
1257
+ output, and neither does an input going missing.
1258
+
6
1259
  ## 0.4.19
7
1260
 
8
1261
  ### Fixed: the nesting guard could be stepped past by a sibling
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edf2csv",
3
- "version": "0.4.64",
3
+ "version": "0.4.65",
4
4
  "description": "Convert EDF, EDF+ and BDF biosignal recordings (European Data Format) to CSV from the command line. Local, streaming, and never resamples or alters units.",
5
5
  "keywords": [
6
6
  "edf",