/** * Number formatting for CSV cells. * * A one-hour, 23-channel, 256 Hz recording is about 21 million numeric cells, so * this is the hottest code in a conversion. Two things keep it cheap: * * - Every sample in a channel comes from a bounded set of integers (digitalMin to * digitalMax, typically 4096 distinct values for a 12-bit ADC). The formatted * text for a digital code never changes, so it is computed once and reused. * - The cache fills lazily. Real recordings visit only a fraction of the range, * and a channel with an implausibly wide range falls back to direct formatting * rather than reserving memory it will never use. */ import type { EdfSignal } from '../edf/header.js'; /** * How many cached sample slots a conversion has left to spend. * * MAX_CACHED_SPAN is a bound on one channel, and a bound on one channel is not a bound: a * file may declare as many channels as it likes, and each was handed its own cache. A * channel declaring the ordinary full 16-bit digital range takes the whole 512 KB, so a * 256-channel montage reserved 134 MB of pointers before writing a row — a 7.9 MB * recording that needed a 192 MB heap and died with a V8 out-of-memory fatal error under * anything smaller. The caches were the live set; nothing else in the conversion came near * them. It is the same shape of mistake the offset budget below was made to fix, one level * over: there the unbounded count was rate groups, here it is channels. * * One budget for the whole conversion leaves the ordinary recording exactly as it was and * puts a ceiling on the dense montage: the same 256-channel file now holds its caches to * 16 MB and converts under a 48 MB heap. Channels ask in the order the groups are written, * which is fastest rate first, so the cache goes to the channels with the most cells to * format. The ones that miss out fall back to formatting directly, which produces * identical text — the output is byte-for-byte what it was. */ export interface SampleCacheBudget { remaining: number; } export declare function newSampleCacheBudget(): SampleCacheBudget; /** * Format with a fixed number of decimals, normalising negative zero. * * Without this, a sample that scales to a very small negative value prints as * "-0.000", which looks like a distinct measurement but is not. */ export declare function fixed(value: number, decimals: number): string; /** * A number as plain decimal text, at any magnitude. * * `String()` switches to exponent notation twice — above 1e21 and below 1e-6 — and * annotations.csv wrote its `onset_s` and `duration_s` through it. An EDF+ TAL states its * onset as ordinary decimal text, so a file saying `+0.0000001` came back as `1e-7` in a * column whose every other cell is a plain decimal, beside a `time_s` the documentation says * it "joins directly" with. It does not: pandas reads the column as object rather than * float64 once one cell is exponent text, and a `merge` on it matches nothing. * * `fixed` cannot answer this. It needs a decimal count, and these two columns are documented * as carrying "their natural numeric form ... without padding to a fixed decimal count" — * asking for enough places to hold 1e-7 would rewrite `0.1` as `0.10000000000000000555`. * Expanding the notation instead touches only the values that are in it and leaves every * other cell byte-for-byte what it was. */ export declare function plain(value: number): string; /** Maps a raw digital sample to its formatted physical value. */ export type SampleFormatter = (digital: number) => string; export declare function makeSampleFormatter(signal: EdfSignal, decimals: number, budget?: SampleCacheBudget): SampleFormatter; export declare function timeDecimals(samplingRate: number): number; /** Human-readable byte size for warnings and summaries. */ export declare function formatBytes(bytes: number): string; /** Human-readable duration: 1h 05m 12s. */ export declare function formatDuration(seconds: number): string; export declare function plainSeconds(seconds: number): string; /** * The same ceiling, for a rendering that is not `plain`'s. * * `formatDuration` decomposes into hours and minutes, and past 2^53 prints the seconds through * `fixed`; `formatSeconds` in time-range.ts rounds to three places and trims. Both state a * length of seconds out of a header and neither goes through `plain`, so neither took the * ceiling 0.8.98 applied to the four that do — and one data record of 1e308 seconds is a finite * duration past 2^53, which `fixed` expanded to three hundred and ten characters on the line * whose parenthetical had already been capped. */ export declare function withinLine(text: string, seconds: number): string; /** * How many cached offsets a conversion has left to spend. * * The cap used to be per rate group, and a file may hold as many rate groups as it has * channels. Twelve channels at twelve rates just under the cap — a 25 MB file — took * 1.66 GB and 36 seconds, where a 92 MB file at one rate takes 283 MB and finishes in a * fraction of that; twenty-four of them never finished at all. A per-group limit is not a * limit, since nothing bounds the number of groups. * * One budget for the whole conversion makes the single-group case identical to what it was * and the many-group case bounded. Groups ask in order of rate, fastest first, so the cache * goes to the tables with the most rows to write and the ones that miss out are the ones * that would have gained least. */ export interface OffsetBudget { remaining: number; } export declare function newOffsetBudget(): OffsetBudget; /** * Formats the time column, reusing the part of it that repeats. * * Every value cell is already cached — a channel has at most `digitalMax - digitalMin + 1` * distinct readings, so the same handful of strings serve millions of rows. The time column * had no such luck: it rises monotonically, so no two rows share a string and `toFixed` ran * once per row. On a ten-million-row conversion that was a third of the total time, more * than reading the file and writing the CSV put together. * * What repeats is the offset within a record. Sample `s` sits at `s / rate` from the start of * whichever record holds it, and there are only `samplesPerRecord` such offsets in the whole * recording. Splitting each into whole seconds and printed fraction turns the per-row work * into one integer addition and a concatenation: * * record starting at 42s, sample 7 of a 100 Hz record * -> 42 + 0 whole seconds, fraction ".070" -> "42.070" * * The decomposition is only valid when the record starts on a whole, non-negative second, * which is what lets the fraction come entirely from the offset. A record starting at 0.5 s would mix the * two, so those fall back to formatting the sum directly. Continuous recordings start every * record at `index * recordDuration`, so this holds for all of them whose record duration is * a whole number of seconds, and for discontinuous files it holds per record depending on * where that record actually starts. */ export declare function makeTimeFormatter(samplesPerRecord: number, rate: number, decimals: number, budget?: OffsetBudget): (recordStart: number, sample: number) => string;