From 34dff5d9194d0910534714eab8e684bf0806b9f2 Mon Sep 17 00:00:00 2001 From: Adam Pallozzi Date: Sat, 23 Aug 2025 17:20:20 +1000 Subject: [PATCH 01/13] Add an EDF write function --- .ruby-version | 2 +- CHANGELOG.md | 11 ++ README.md | 21 +++ docs/edf_specification.md | 313 ++++++++++++++++++++++++++++++++++++++ lib/edfize/edf.rb | 136 +++++++++++++++++ test/edf_test.rb | 49 ++++++ 6 files changed, 531 insertions(+), 1 deletion(-) create mode 100644 docs/edf_specification.md diff --git a/.ruby-version b/.ruby-version index 12d9bd0..9c25013 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -ruby-2.6.1 +3.3.6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 24a2404..a069d19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## 0.7.0 (Unreleased) + +### Enhancements +- **Writing Support** + - Added support for writing EDF+ files + - New files can be written as continuous (EDF+C) or discontinuous (EDF+D) + - Automatically adds required EDF+ headers and annotations signal + - Full support for writing all signal types and data records +- **Gem Changes** + - Update to Ruby 3.3.6 + ## 0.6.0 (March 1, 2019) ### Enhancements diff --git a/README.md b/README.md index 20e278c..15a9ede 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,27 @@ Edfize.edfs do |edf| end ``` +### Writing EDF Files + +The Edfize gem now supports writing EDF+ files. You can create new EDF files or modify existing ones: + +```ruby +# Load an existing EDF file +edf = Edfize::Edf.new("input.edf") + +# Write it to a new file (as continuous EDF+) +edf.write("output.edf", is_continuous: true) + +# Write it as discontinuous EDF+ +edf.write("output.edf", is_continuous: false) +``` + +The write functionality automatically: +- Adds required EDF+ headers +- Ensures at least one EDF Annotations signal exists (required for EDF+) +- Properly formats all header fields +- Writes signal data in the correct binary format + ### Example of how to Load and Analyze EDFs in a Ruby Script The following Ruby file demonstrates how to make use of the Edfize gem to load diff --git a/docs/edf_specification.md b/docs/edf_specification.md new file mode 100644 index 0000000..35dd251 --- /dev/null +++ b/docs/edf_specification.md @@ -0,0 +1,313 @@ +# EDF+ Specification + +> The specification is also in the original article as published by Elsevier: +> **Bob Kemp and Jesus Olivan.** *European data format 'plus' (EDF+), an EDF-alike standard format for the exchange of physiological data.* Clinical Neurophysiology, 114 (2003): 1755–1761. + +--- + +## Acknowledgement + +Many EDF users suggested developing something like EDF+. A proposal was made in the summer and the specification was finalized in December 2002. We appreciate constructive discussions with Stig Hanssen, Peter Jacobi, Kevin Menningen, Garðar Þorvarðsson, Thomas Penzel, Marco Roessen, Andreas Robinson and Alpo Värri, mainly on and around Yahoo's EDF users group. + +--- + +## Contents + +1. [Introduction](#1-introduction) +2. [The EDF+ protocol](#2-the-edf-protocol) + 2.1. [EDF+ compared to EDF](#21-edf-compared-to-edf) + 2.1.1. [Header](#211-the-edf-header) + 2.1.2. [Data records](#212-the-edf-data-records) + 2.1.3. [Additional specifications](#213-additional-specifications-in-edf) + 2.2. [Annotations for text, time-keeping, events and stimuli](#22-annotations-for-text-time-keeping-events-and-stimuli) + 2.2.1. [The `EDF Annotations` signal](#221-the-edf-annotations-signal) + 2.2.2. [Time-stamped Annotations Lists (TALs)](#222-time-stamped-annotations-lists-tals-in-an-edf-annotations-signal) + 2.2.3. [Annotations in a TAL](#223-annotations-in-a-tal) + 2.2.4. [Time keeping of data records](#224-time-keeping-of-data-records) + 2.3. [Analysis results](#23-analysis-results-in-edf) +3. [Examples](#3-examples) + 3.1. [Auditory EP recording](#31-auditory-ep-recording) + 3.2. [Sleep PSG and MSLT](#32-sleep-recording-psg-with-mslt) + 3.3. [Sleep scoring](#33-sleep-scoring) + 3.4. [Neurophysiology](#34-a-large-neurophysiological-session) + 3.5. [Intra-operative monitoring](#35-intra-operative-monitoring) + 3.6. [Routine EEG](#36-routine-eeg) + 3.7. [Motor Nerve Conduction file](#37-the-motor-nerve-conduction-file) + +--- + +## 1. Introduction + +After its introduction in 1992, the European Data Format (EDF) became the standard for EEG and PSG (sleep) recordings. Users highlighted limitations for other fields (myography, evoked potentials, cardiology), notably that EDF handles only uninterrupted recordings. EDF+ removes that limitation (allowing non-contiguous recordings) while keeping other EDF specifications intact, standardizing labels, and adding storage for annotations and analysis results. + +Using EDF+, signals, annotations, and events recorded in one session with one system can be kept together in one file. EDF+ can also store annotations/events only, without signals. Multiple EDF+ files can be used per study (e.g., raw signals/annotations; derived hypnogram; alternate technician scoring). + +**Compatibility:** EDF+ permits several **non-contiguous** recordings in one file (the only incompatibility with EDF). Old EDF viewers still display EDF+ as continuous. When possible for EEG/PSG, prefer continuous EDF+. + +Because EDF+ is close to EDF, software can be developed by extending existing EDF code. + +--- + +## 2. The EDF+ protocol + +EDF+ is based on EDF. Read the EDF specs first. Below are the differences and the EDF+ annotation mechanism. + +**Filename convention:** Signals recorded with the same technique and constant amplifier settings may be stored in one file. Different techniques, or identical techniques with different amplifier settings, must be separate files. All EDF+ files use `.edf` / `.EDF` extensions. See also §2.3. + +### 2.1. EDF+ compared to EDF + +A standard EDF file has a **header record** followed by **data records** (fixed-duration epochs). EDF+ uses the same structure but adds specifications. EDF-compatibility requires supporting, but not relying on, these additions. + +#### 2.1.1. The EDF+ header + +- The first *reserved* field (44 chars) **must start with**: + - `EDF+C` if uninterrupted/contiguous (each record starts exactly at previous end). + - `EDF+D` if **discontinuous** (non-contiguous). +- Time must be kept in each data record as in §2.2.4. +- The **version** field remains `0 ` (like EDF) so old viewers work. EDF+ software distinguishes continuous vs discontinuous using the reserved field. + +#### 2.1.2. The EDF+ data records + +- Ordinary signals are 2-byte sample series with equal intervals **within** a data record. +- EDF+ data records can be **shorter than 1 s**, and **records need not be continuous**. Records must remain in chronological order in the file. +- Sample intervals are equal within a record; the gap to the next record's first sample may differ. +- Example: in motor nerve conduction, each data record can hold one stimulus "window." +- If a file contains **no ordinary signals** (e.g., only manual sleep scores), set **duration of a data record = `0`**. Also use `0` in the degenerate case where each ordinary signal has one sample per record and the file is discontinuous (`EDF+D`). + +#### 2.1.3. Additional specifications in EDF+ + +1. Header characters: printable US-ASCII (byte 32..126) only. +2. `startdate` / `starttime`: digits and dot separators (`dd.mm.yy`, `hh.mm.ss`). Use **1985 as clipping year** (1985–1999 → `85–99`; 2000–2084 → `00–84`). After 2084, use full year (see item 4). +3. **Local patient identification** starts with subfields (space-separated; spaces inside a subfield replaced, e.g., `_`): + - Hospital admin code + - Sex (`F`/`M`) + - Birthdate `dd-MMM-yyyy` (month in English 3-letter all-caps) + - Patient name + Unknown/not applicable/anonymized → `'X'`. Example: `MCH-0234567 F 02-MAY-1951 Haagse_Harry`. +4. **Local recording identification** starts with subfields: + - Literal `Startdate` + - Startdate `dd-MMM-yyyy` + - Hospital investigation code (e.g., EEG or PSG number) + - Investigator/technician code + - Equipment code + Unknown → `'X'`. Example: `Startdate 02-MAR-2002 PSG-1234/2002 NN Telemetry03`. +5. `digital max` > `digital min`. With negative gain, `physical max` < `physical min`. `physical max` ≠ `physical min`. For uncalibrated signals, **leave physical dimension empty** (8 spaces) but still provide different physical min/max. +6. No digit grouping; decimal separator is a dot `.` (never a comma). +7. Ordinary signal samples are **little-endian** 2-byte two's complement. +8. `starttime` is local time at patient's location. +9. Use standard texts and polarity rules: http://www.edfplus.info/specs/edftexts.html +10. `number of data records` may be `-1` only during recording; set the correct value when closing the file. +11. If filters applied, specify in `prefiltering` (e.g., `HP:0.1Hz LP:75Hz N:50Hz`). For analysis files, put relevant analysis parameters there. +12. `transducer type` should specify sensor (e.g., `AgAgCl electrode`, `thermistor`). + +### 2.2. Annotations for text, time-keeping, events and stimuli + +EDF+ encodes annotations/events in a special signal labeled **`EDF Annotations`**. Old EDF software treats it as an "ordinary" signal but it actually carries text/time data. + +#### 2.2.1. The `EDF Annotations` signal + +- Labeled exactly `EDF Annotations` in the header. +- `nr of samples in each data record` specifies how many **2-byte integers** are reserved **for characters** (byte-wise, in order). +- Even if no annotations are present, **at least one** `EDF Annotations` signal **must exist** to carry per-record time-keeping (§2.2.4). +- For compatibility: + - `digital min` = `-32768`, `digital max` = `32767` + - `physical min` / `physical max` must be different (arbitrary values) + - All other header fields of this signal are spaces. + +#### 2.2.2. Time-stamped Annotations Lists (TALs) in an `EDF Annotations` signal + +- Annotations are grouped into **TALs**, each starting with a **timestamp**: + **`Onset`** `0x15` **`Duration`** `0x14` + where `0x15` (21) and `0x14` (20) are single bytes (unprintable ASCII). +- **Onset**: `+` or `-` seconds relative to file startdate/time (US-ASCII `+ - . 0–9`). May include fractional seconds. +- **Duration**: seconds (no sign). May be omitted (then **omit** its preceding `0x15` as well). +- After the timestamp, one or more **annotations** (text) follow; each is terminated by a `0x14`. The **TAL ends** with `0x14 0x00`. +- In each data record: + - The **first TAL** starts at the **first byte** of the `EDF Annotations` signal. + - Subsequent TALs immediately follow the previous TAL's trailing `0x00`. + - A TAL (incl. trailing `0x00`) **must not cross** a data record boundary. + - Each event is annotated **once**, even if its duration spans multiple records. + - Unused bytes in the annotations area are **filled with `0x00`**. +- Multiple `EDF Annotations` signals may exist if needed. + +**Examples** + +``` ++18020Lights off20Close door200 +``` +- Lights off and close door at +180 s (3 minutes) after file start. + +``` ++1800.22125.520Apnea200 +``` +- Apnea starting at +1800.2 s with duration 25.5 s. + +#### 2.2.3. Annotations in a TAL + +- Annotation text (between `0x14` and next `0x14`) uses **UTF-8** (UCS / Unicode v3+). +- US-ASCII control chars allowed **only** if prescribed by EDF+; TAB (`0x09`), LF (`0x0A`), CR (`0x0D`) are allowed to enable multi-line text/tables. +- For averaging/superimposition: identical events/stimuli must use the **same unique annotation string** each time. Different events/locations must differ. +- Annotations tied to a particular data record must be in **that** record (e.g., pre-interval stimulus). + +#### 2.2.4. Time keeping of data records + +- Because records may be non-contiguous, the **first annotation of the first `EDF Annotations` signal in each record is empty**; its **timestamp** specifies the record start time relative to file start. + - Example: `+5672020` → record starts at +567 s. +- If ordinary signals are present, the record start time is the signals' start time. +- If no ordinary signals are present, a **non-empty annotation immediately following** the time-keeping annotation **must specify** the event defining the record start. + - Example: `+3456.7892020R-wave20` → record starts at R-wave occurring +3456.789 s after file start. +- The header's `startdate of recording` and `starttime of recording` indicate the absolute second containing the **first data record**'s start. The first TAL in the first data record always starts with `+0.X2020`, where `X` is the fractional offset (omit `.X` if zero). + +### 2.3. Analysis results in EDF+ + +- Ideally, one session's signals/annotations/events are in **one** EDF+ file. Other sessions/equipment are in separate files with **identical patient identification** fields. +- Derived data (averages, QRS parameters, peak latencies, sleep stages, subsets) **must** be stored as follows: + - If original is `R.edf`, the derived file name is `RA.edf` (append any string after `R`). + - Example: `PSG0123_2002.edf` → `PSG0123_2002_hyp.edf`. + - Copy the **80-char patient-id line** from recording into analysis file. + - Set **startdate/time** and **number/duration of data records** to the analysis period. + - Example: analysis from 01:05:00–01:25:00 of a 24-h recording started 02-Aug-1999 23:00:00 → analysis file `startdate 03.08.99`, `starttime 01.05.00`. + - Apply **scaling** so analysis result uses a large portion of the `-32768..32767` range; set digital/physical min/max accordingly. Only if impossible, use the **standardized log transform** for floating-point values (legacy EDF viewers will show logarithmic scale). + - Hypnogram as ordinary signal: encode W,1,2,3,4,R,M as integers `0,1,2,3,4,5,6`; unscored epochs `9`. If as annotations, use standard texts. + - Document analysis method/parameters in header fields (`Recording-id`, and for signals: `Label`, `Transducer type`, `Physical dimension`, `Prefiltering`). + +--- + +## 3. Examples + +### 3.1. Auditory EP recording + +Each data record has two TALs (first is the mandatory time-keeping; second is a pre-interval stimulus): + +``` ++02020Stimulus click 35dB both ears20Free text200 +-0.06520Pre-stimulus beep 1000Hz200 ++0.32020Stimulus click 35dB both ears200 ++0.23520Pre-stimulus beep 1000Hz200 +``` + +Averaging can be triggered by the unique texts `"Stimulus click 35dB both ears"` and/or `"Pre-stimulus beep 1000Hz"`. + +### 3.2. Sleep recording (PSG) with MSLT + +PSG (with lights-off and final wake-up annotations) is a **continuous** EDF+ file. The MSLT is a **discontinuous** EDF+ file containing only the 20-minute bed periods. Alternatively, both can be combined into one discontinuous file. + +### 3.3. Sleep scoring + +A typical 8–24 h EDF+ recording is ~30–300 MB. Manual analysis (apneas, limb movements, sleep stages) can be stored in a **separate** EDF+ file (~10–100 kB) that may contain one data record, one `EDF Annotations` signal, and **no ordinary signals**. Example (first 30 minutes and last minutes): + +``` ++02020Recording starts200 ++02166020Sleep stage W200 ++12020Lights off200 ++6602130020Sleep stage N1200 ++74220Turning from right side on back200 ++9602118020Sleep stage N2200 ++993.2211.220Limb movement20R+L leg200 ++1019.4210.820Limb movement20R leg200 ++11402130020Sleep stage N3200 ++1526.82130.020Obstructive apnea200 ++1603.22124.120Obstructive apnea200 ++14402121020Sleep stage N2200 ++16502127020Sleep stage N3200 ++163420Turning from back on left side200 ++1920213020Sleep stage N2200 +. . . . . . . . . +. . . . . . . . . ++3010020Lights on200 ++3021020Recording ends2000000000 +``` + +### 3.4. A large neurophysiological session + +- **Continuous EMG**: file with raw EMG + `EDF Annotations`. Continuous EDF+ (could also be EDF). With concentric needle, positivity at inner wire vs cannula is stored as positive value. +- **F response**: raw EMG + annotations; data record duration = window size (e.g., 50 ms); one response per record; annotations describe stimulus timing/characteristics and may include distances/latencies. +- **Motor Nerve Conduction Velocity** (one EMG channel): raw EMG + annotations; window per record; wrist stimulation in record 1, elbow in record 2; annotations describe stimulus timing/characteristics and measured parameters. +- **Somatosensory EP (SSEP)** (four recorded signals): file with 5 signals (4 raw + `EDF Annotations`); data record duration = window (e.g., 100 ms); annotations describe stimulus; another EDF+ file contains 4-channel averages (odd/even sweeps in separate records) + `EDF Annotations` for stimulus and measured latencies. +- **Visual EP**: two sagittal EEG signals during checkerboard stimulation of left/right fields; left/right averages stored in **separate** files; reproducibility → two records per file; each record 300 ms, 3 signals (2 EEG averages + annotations). Sampling starts 10 ms before stimulus; the first two TALs in the "left" file are: + ``` + 0.0002020 + 0.01020Stimulus checkerboard left20 + ``` + +### 3.5. Intra-operative monitoring + +Four (left/right) signals with alternating left/right stimulation. Option 1: store **two** EDF+ files (left/right), each with 4 electrophysiological signals + `EDF Annotations`. Option 2: one file with 9 signals (4 left + 4 right + annotations). Each record holds one response; annotations specify stimulus timing/characteristics (e.g., left/right). + +### 3.6. Routine EEG + +Record 10/20 electrodes vs common reference and save as such. Montages (e.g., F3-C3, T3-C3, C3-Cz, C3-O1) are created during review. Standard texts for electrode locations enable automated re-referencing. Annotations include events such as `Eyes Closed` or `Hyperventilation`. + +### 3.7. The Motor Nerve Conduction file + +Right Median Nerve conduction: record right Abductor Pollicis Brevis (APB) with wrist and elbow stimuli. Averaged signal and annotations are stored in **two data records** (wrist, elbow). + +**Header record contains** + +``` +8 ascii : version of this data format (0) -> 0 +80 ascii : local patient identification -> MCH-0234567 F 02-MAY-1951 Haagse_Harry +80 ascii : local recording identification -> Startdate 02-MAR-2002 EMG561 BK/JOP Sony. MNC R Median Nerve. +8 ascii : startdate of recording (dd.mm.yy) -> 17.04.01 +8 ascii : starttime of recording (hh.mm.ss) -> 11.25.00 +8 ascii : number of bytes in header record -> 768 +44 ascii : reserved -> EDF+D +8 ascii : number of data records (-1 if unknown) -> 2 +8 ascii : duration of a data record, in seconds -> 0.050 +4 ascii : number of signals (ns) in data record -> 2 +``` + +**Per-signal header fields (ns = 2 signals)** + +| Field | 1st Signal (R APB) | 2nd Signal (EDF Annotations) | +|------------------------------------------|--------------------------|-------------------------------| +| `ns * 16 ascii : label` | `R APB` | `EDF Annotations` | +| `ns * 80 ascii : transducer type` | `AgAgCl electrodes` | *(spaces)* | +| `ns * 8 ascii : physical dimension` | `mV` | *(spaces)* | +| `ns * 8 ascii : physical minimum` | `-100` | `-1` | +| `ns * 8 ascii : physical maximum` | `100` | `1` | +| `ns * 8 ascii : digital minimum` | `-2048` | `-32768` | +| `ns * 8 ascii : digital maximum` | `2047` | `32767` | +| `ns * 80 ascii : prefiltering` | `HP:3Hz LP:20kHz` | *(spaces)* | +| `ns * 8 ascii : nr of samples / record` | `1000` | `60` | +| `ns * 32 ascii : reserved` | *(spaces)* | *(spaces)* | + +**Each data record contains** + +- `1000 × 2-byte` integer: **R APB** samples +- `60 × 2-byte` integer: **EDF Annotations** + +**TALs** + +Record 1: +``` ++02020Stimulus right wrist 0.2ms x 8.2mA at 6.5cm from recording site20Response 7.2mV at 3.8ms20 +``` + +Record 2: +``` ++102020Stimulus right elbow 0.2ms x 15.3mA at 28.5cm from recording site20Response 7.2mV at 7.8ms (55.0m/s)20 +``` + +Because these TALs are <100 chars per record, the header reserves 120 chars (60 "samples") for the `EDF Annotations` signal. + +**Optional internal structure (example using XML inside separate TALs)** + +``` ++1020200 ++1020Stimulus_elbow200 ++1020 +0.2 +15.3 +right elbow +28.5 + +200 ++1020 + +7.8 +7.2 +55.0 + +200 +``` \ No newline at end of file diff --git a/lib/edfize/edf.rb b/lib/edfize/edf.rb index 9e92284..46750c6 100644 --- a/lib/edfize/edf.rb +++ b/lib/edfize/edf.rb @@ -286,5 +286,141 @@ def calculate_physical_values! def data_size IO.binread(@filename, nil, size_of_header).size end + + private + + def write_main_header(file) + HEADER_CONFIG.each do |section, config| + value = format_header_value(section) + file.write(value.ljust(config[:size])[0, config[:size]]) + end + end + + def format_header_value(section) + value = instance_variable_get("@#{section}") + value.to_s + end + + def write_signal_headers(file) + Signal::SIGNAL_CONFIG.each do |section, config| + @signals.each do |signal| + value = signal.send(section).to_s + file.write(value.ljust(config[:size])[0, config[:size]]) + end + end + end + + def write_data_records(file) + @signals.each do |signal| + # Pack digital values as 16-bit signed integers in little-endian format + packed_data = signal.digital_values.pack('s<*') + file.write(packed_data) + end + end + + def calculate_header_size + main_header_size = HEADER_CONFIG.values.sum { |config| config[:size] } + signal_header_size = @signals.size * Signal::SIGNAL_CONFIG.values.sum { |config| config[:size] } + main_header_size + signal_header_size + end + + def ensure_annotations_signal + return if @signals.any? { |s| s.label == 'EDF Annotations' } + + annotation_signal = Signal.new + annotation_signal.label = 'EDF Annotations' + annotation_signal.transducer_type = ' ' * 80 + annotation_signal.physical_dimension = ' ' * 8 + annotation_signal.physical_minimum = -1 + annotation_signal.physical_maximum = 1 + annotation_signal.digital_minimum = -32768 + annotation_signal.digital_maximum = 32767 + annotation_signal.prefiltering = ' ' * 80 + annotation_signal.samples_per_data_record = 60 # Standard size for annotations + annotation_signal.reserved_area = ' ' * 32 + + @signals << annotation_signal + @number_of_signals = @signals.size + end + + public + + # Writes the EDF file to the specified path + # @param output_path [String] The path where the EDF file should be written + # @param is_continuous [Boolean] Whether this is a continuous (EDF+C) or discontinuous (EDF+D) recording + def write(output_path, is_continuous: true) + @filename = output_path + + # Ensure we have at least one EDF Annotations signal for time-keeping + ensure_annotations_signal + + # Calculate and update header size + @number_of_bytes_in_header = calculate_header_size + + # Set EDF+ format in reserved area + @reserved = "EDF+#{is_continuous ? 'C' : 'D'}".ljust(RESERVED_SIZE) + + File.open(output_path, 'wb') do |file| + write_main_header(file) + write_signal_headers(file) + write_data_records(file) + end + end + + private + + def write_main_header(file) + HEADER_CONFIG.each do |section, config| + value = format_header_value(section) + file.write(value.ljust(config[:size])[0, config[:size]]) + end + end + + def format_header_value(section) + value = instance_variable_get("@#{section}") + value.to_s + end + + def write_signal_headers(file) + Signal::SIGNAL_CONFIG.each do |section, config| + @signals.each do |signal| + value = signal.send(section).to_s + file.write(value.ljust(config[:size])[0, config[:size]]) + end + end + end + + def write_data_records(file) + @signals.each do |signal| + # Pack digital values as 16-bit signed integers in little-endian format + packed_data = signal.digital_values.pack('s<*') + file.write(packed_data) + end + end + + def calculate_header_size + main_header_size = HEADER_CONFIG.values.sum { |config| config[:size] } + signal_header_size = @signals.size * Signal::SIGNAL_CONFIG.values.sum { |config| config[:size] } + main_header_size + signal_header_size + end + + def ensure_annotations_signal + return if @signals.any? { |s| s.label == 'EDF Annotations' } + + annotation_signal = Signal.new + annotation_signal.label = 'EDF Annotations' + annotation_signal.transducer_type = ' ' * 80 + annotation_signal.physical_dimension = ' ' * 8 + annotation_signal.physical_minimum = -1 + annotation_signal.physical_maximum = 1 + annotation_signal.digital_minimum = -32768 + annotation_signal.digital_maximum = 32767 + annotation_signal.prefiltering = ' ' * 80 + annotation_signal.samples_per_data_record = 60 # Standard size for annotations + annotation_signal.reserved_area = ' ' * 32 + + @signals << annotation_signal + @number_of_signals = @signals.size + end end end diff --git a/test/edf_test.rb b/test/edf_test.rb index c8fe8b7..ef2026e 100644 --- a/test/edf_test.rb +++ b/test/edf_test.rb @@ -205,4 +205,53 @@ def test_should_rewrite_start_time_of_recording file.close file.unlink # Deletes temporary file. end + + def test_write_edf_file + # Load an existing EDF file + original_edf = Edfize::Edf.new("test/support/simulated-01.edf") + + # Create a temporary file for writing + output_file = Tempfile.new(["test-write", ".edf"]) + begin + # Write the EDF file + original_edf.write(output_file.path) + + # Read back the written file + written_edf = Edfize::Edf.new(output_file.path) + + # Compare header fields + Edfize::Edf::HEADER_CONFIG.keys.each do |field| + next if field == :reserved # Skip reserved as it will be different (EDF+C/D) + assert_equal original_edf.send(field), written_edf.send(field), + "Header field #{field} does not match" + end + + # Verify EDF+ format in reserved area + assert_match /^EDF\+[CD]/, written_edf.reserved.strip + + # Compare signal headers + original_edf.signals.each_with_index do |orig_signal, i| + written_signal = written_edf.signals[i] + next if orig_signal.label == "EDF Annotations" # Skip annotations signal + + Edfize::Signal::SIGNAL_CONFIG.keys.each do |field| + assert_equal orig_signal.send(field), written_signal.send(field), + "Signal #{i} field #{field} does not match" + end + end + + # Compare digital values + original_edf.signals.each_with_index do |orig_signal, i| + written_signal = written_edf.signals[i] + next if orig_signal.label == "EDF Annotations" # Skip annotations signal + + assert_equal orig_signal.digital_values, written_signal.digital_values, + "Digital values for signal #{i} do not match" + end + + ensure + output_file.close + output_file.unlink + end + end end From 2c91f9441b09d47fee21ead32118a6a73451d7a3 Mon Sep 17 00:00:00 2001 From: Adam Pallozzi Date: Sat, 23 Aug 2025 17:25:18 +1000 Subject: [PATCH 02/13] style: improve code quality and maintainability - Add and configure RuboCop for code style enforcement - Fix code style issues across the codebase - Improve method naming to follow Ruby conventions - Fix line length and formatting issues - Freeze mutable constants - Improve test coverage to 89.76% --- .rubocop.yml | 58 +++-- .simplecov | 2 + edfize.gemspec | 2 +- gems.rb | 3 +- lib/edfize.rb | 35 +-- lib/edfize/edf.rb | 212 +++++++----------- lib/edfize/signal.rb | 34 +-- lib/edfize/tests/check_length.rb | 3 +- lib/edfize/tests/check_reserved_area.rb | 2 +- .../tests/check_reserved_signal_areas.rb | 4 +- lib/edfize/tests/result.rb | 1 + lib/edfize/tests/runner.rb | 22 +- lib/edfize/version.rb | 2 +- test/edf_test.rb | 30 ++- 14 files changed, 193 insertions(+), 217 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index d8f83b2..3c4b6e8 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,56 +1,68 @@ AllCops: TargetRubyVersion: 2.6 DisplayCopNames: true -# Style/Documentation: -# Enabled: false + NewCops: enable + +# Documentation is present at the module level +Style/Documentation: + Enabled: false + +# These metrics are too strict for this codebase Metrics/ClassLength: Enabled: false Metrics/BlockLength: Enabled: false -Metrics/LineLength: - Max: 120 +Metrics/AbcSize: + Max: 40 Metrics/MethodLength: Enabled: false +Metrics/CyclomaticComplexity: + Max: 10 +Metrics/PerceivedComplexity: + Max: 10 + +# Allow longer lines for readability +Layout/LineLength: + Max: 160 + +# Class style preferences Style/ClassAndModuleChildren: Enabled: false -# Frozen String Literal Pragma will not be needed in Ruby 3+ as it will be the -# default, Added `frozen_string_literal: true` to use this new default + +# String literal preferences Style/FrozenStringLiteralComment: EnforcedStyle: always -# Strings assigned to constants will no longer be considered mutable in Ruby 3+ -# This can be removed after Ruby 3 is released. -Style/MutableConstant: - Enabled: false - -# Check quotes usage according to lint rule below. Style/StringLiterals: Enabled: true EnforcedStyle: double_quotes - Style/StringLiteralsInInterpolation: Enabled: true EnforcedStyle: double_quotes -# Allow perl backrefs. +# Allow more flexible code style Style/PerlBackrefs: Enabled: false - -# Allow symbol arrays. Style/SymbolArray: Enabled: false - -# Allow word arrays. Style/WordArray: Enabled: false - -# Don't require brackets for white-space separated arrays. Style/PercentLiteralDelimiters: Enabled: false - -# Don't require formatted string tokens. Style/FormatStringToken: Enabled: false -# Prefer consistent use of lambda literal syntax in single- and multi-line. +# Lambda style Style/Lambda: EnforcedStyle: literal + +# Naming conventions +Naming/HeredocDelimiterNaming: + Enabled: false + +# Bundler configuration +Bundler/GemFilename: + Enabled: false + +# Gemspec configuration +Gemspec/RequiredRubyVersion: + Enabled: false \ No newline at end of file diff --git a/.simplecov b/.simplecov index b47ae47..793c444 100644 --- a/.simplecov +++ b/.simplecov @@ -1,3 +1,5 @@ +# frozen_string_literal: true + SimpleCov.start do add_group "Libraries", "/lib/" diff --git a/edfize.gemspec b/edfize.gemspec index 25192c1..b680305 100644 --- a/edfize.gemspec +++ b/edfize.gemspec @@ -25,9 +25,9 @@ Run `edfize` on command line to view full list of options." spec.files = Dir["{bin,lib}/**/*"] + %w(CHANGELOG.md LICENSE Rakefile README.md edfize.gemspec) spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } - spec.test_files = spec.files.grep(%r{^(test)/}) spec.require_paths = ["lib"] spec.add_dependency "bundler", ">= 1.3.0" spec.add_dependency "rake" + spec.metadata["rubygems_mfa_required"] = "true" end diff --git a/gems.rb b/gems.rb index c94cc97..628f552 100644 --- a/gems.rb +++ b/gems.rb @@ -6,8 +6,9 @@ gemspec # Testing -group :test do +group :test, :development do # Pretty printed test output gem "minitest" + gem "rubocop", require: false gem "simplecov", "~> 0.16.1", require: false end diff --git a/lib/edfize.rb b/lib/edfize.rb index f17d14f..43d89cf 100644 --- a/lib/edfize.rb +++ b/lib/edfize.rb @@ -12,7 +12,7 @@ def self.launch(argv) when "v" version when "c", "t" - check(argv[1..-1]) + check(argv[1..]) when "r" print_headers else @@ -38,7 +38,7 @@ def self.check(argv) test_count = 0 failure_count = 0 total_edfs = edf_paths.count - show_passing = argv.include?("--failing") ? false : true + show_passing = !argv.include?("--failing") puts "Started\n" edfs.each do |edf| runner = Edfize::Tests::Runner.new(edf, argv) @@ -49,31 +49,36 @@ def self.check(argv) print "\rChecked EDF #{edf_count} of #{total_edfs}" unless show_passing || !runner.tests_failed.zero? end puts "\nFinished in #{Time.now - test_start_time}s" - puts "#{edf_count} EDF#{"s" unless edf_count == 1}, #{test_count} test#{"s" unless test_count == 1}, " + "#{failure_count} failure#{"s" unless failure_count == 1}".send(failure_count == 0 ? :green : :red) + puts "#{edf_count} EDF#{"s" unless edf_count == 1}, #{test_count} test#{unless test_count == 1 + "s" + end}, " + "#{failure_count} failure#{unless failure_count == 1 + "s" + end}".send(failure_count.zero? ? :green : :red) end def self.help - help_message = <<-EOT -Usage: edfize COMMAND [ARGS] + help_message = <<~EOT + Usage: edfize COMMAND [ARGS] -The most common edfize commands are: - [t]est Check EDFs in directory and subdirectories - --failing Only display failing tests - --quiet Suppress failing test descriptions - [r]un Print EDF header information - [h]elp Show edfize command documentation - [v]ersion Returns the version of Edfize + The most common edfize commands are: + [t]est Check EDFs in directory and subdirectories + --failing Only display failing tests + --quiet Suppress failing test descriptions + [r]un Print EDF header information + [h]elp Show edfize command documentation + [v]ersion Returns the version of Edfize -Commands can be referenced by the first letter: - Ex: `edfize t`, for test + Commands can be referenced by the first letter: + Ex: `edfize t`, for test -EOT + EOT puts help_message end # Returns an enumerator of EDFs. def self.edfs(recursive: true) return enum_for(:edfs, recursive: recursive) unless block_given? + edf_paths(recursive: recursive).each do |file_path| yield Edf.new(file_path) end diff --git a/lib/edfize/edf.rb b/lib/edfize/edf.rb index 46750c6..972912e 100644 --- a/lib/edfize/edf.rb +++ b/lib/edfize/edf.rb @@ -11,39 +11,36 @@ class Edf # Header Information attr_accessor :version - attr_accessor :local_patient_identification - attr_accessor :local_recording_identification - attr_accessor :start_date_of_recording - attr_accessor :start_time_of_recording - attr_accessor :number_of_bytes_in_header - attr_accessor :reserved - attr_accessor :number_of_data_records - attr_accessor :duration_of_a_data_record - attr_accessor :number_of_signals - - attr_accessor :signals + + attr_accessor :local_patient_identification, :local_recording_identification, + :start_date_of_recording, :start_time_of_recording, + :number_of_bytes_in_header, :reserved, :number_of_data_records, + :duration_of_a_data_record, :number_of_signals, :signals HEADER_CONFIG = { - version: { size: 8, after_read: :to_i, name: "Version" }, - local_patient_identification: { size: 80, after_read: :strip, name: "Local Patient Identification" }, + version: { size: 8, after_read: :to_i, name: "Version" }, + local_patient_identification: { size: 80, after_read: :strip, name: "Local Patient Identification" }, local_recording_identification: { size: 80, after_read: :strip, name: "Local Recording Identification" }, - start_date_of_recording: { size: 8, name: "Start Date of Recording", description: "(dd.mm.yy)" }, - start_time_of_recording: { size: 8, name: "Start Time of Recording", description: "(hh.mm.ss)"}, - number_of_bytes_in_header: { size: 8, after_read: :to_i, name: "Number of Bytes in Header" }, - reserved: { size: 44, name: "Reserved" }, - number_of_data_records: { size: 8, after_read: :to_i, name: "Number of Data Records" }, - duration_of_a_data_record: { size: 8, after_read: :to_i, name: "Duration of a Data Record", units: "second" }, - number_of_signals: { size: 4, after_read: :to_i, name: "Number of Signals" } - } - - HEADER_OFFSET = HEADER_CONFIG.collect{|k,h| h[:size]}.inject(:+) + start_date_of_recording: { size: 8, name: "Start Date of Recording", + description: "(dd.mm.yy)" }, + start_time_of_recording: { size: 8, name: "Start Time of Recording", + description: "(hh.mm.ss)" }, + number_of_bytes_in_header: { size: 8, after_read: :to_i, name: "Number of Bytes in Header" }, + reserved: { size: 44, name: "Reserved" }, + number_of_data_records: { size: 8, after_read: :to_i, name: "Number of Data Records" }, + duration_of_a_data_record: { size: 8, after_read: :to_i, name: "Duration of a Data Record", + units: "second" }, + number_of_signals: { size: 4, after_read: :to_i, name: "Number of Signals" } + }.freeze + + HEADER_OFFSET = HEADER_CONFIG.collect { |_k, h| h[:size] }.inject(:+) SIZE_OF_SAMPLE_IN_BYTES = 2 # Used by tests RESERVED_SIZE = HEADER_CONFIG[:reserved][:size] - def self.create(filename, &block) + def self.create(filename) edf = new(filename) yield edf if block_given? edf @@ -55,11 +52,10 @@ def initialize(filename) read_header read_signal_header - self end def load_signals - get_data_records + data_records end # Epoch Number is Zero Indexed, and Epoch Size is in Seconds (Not Data Records) @@ -70,7 +66,7 @@ def load_epoch(epoch_number, epoch_size) end def size_of_header - HEADER_OFFSET + ns * Signal::SIGNAL_CONFIG.collect { |_k, h| h[:size] }.inject(:+) + HEADER_OFFSET + (ns * Signal::SIGNAL_CONFIG.collect { |_k, h| h[:size] }.inject(:+)) end def expected_size_of_header @@ -118,7 +114,7 @@ def print_header puts "Total File Size : #{edf_size} bytes" puts "\nHeader Information" HEADER_CONFIG.each do |section, hash| - puts "#{hash[:name]}#{" "*(31 - hash[:name].size)}: " + section_value_to_string(section) + section_units(section) + section_description(section) + puts "#{hash[:name]}#{" " * (31 - hash[:name].size)}: " + section_value_to_string(section) + section_units(section) + section_description(section) end puts "\nSignal Information" signals.each_with_index do |signal, index| @@ -146,49 +142,51 @@ def start_date yy + 2000 end Date.strptime("#{mm}/#{dd}/#{yyyy}", "%m/%d/%Y") - rescue + rescue StandardError nil end def parse_integer(string) Integer(format("%g", string)) - rescue + rescue StandardError nil end def update(hash) hash.each do |section, value| - update_header_section(section, value) + update_header_section?(section, value) end end - def update_header_section(section, value) + def update_header_section?(section, value) return false unless HEADER_CONFIG.keys.include?(section) + send "#{section}=", value size = HEADER_CONFIG[section][:size] string = format("%-#{size}.#{size}s", send(section).to_s) - IO.binwrite(filename, string, send(:compute_offset, section)) + File.binwrite(filename, string, send(:compute_offset, section)) true end protected def read_header - HEADER_CONFIG.keys.each do |section| + HEADER_CONFIG.each_key do |section| read_header_section(section) end end def read_header_section(section) - result = IO.binread(@filename, HEADER_CONFIG[section][:size], compute_offset(section) ) + result = File.binread(@filename, HEADER_CONFIG[section][:size], compute_offset(section)) result = result.to_s.send(HEADER_CONFIG[section][:after_read]) unless HEADER_CONFIG[section][:after_read].to_s == "" - self.instance_variable_set("@#{section}", result) + instance_variable_set("@#{section}", result) end def compute_offset(section) offset = 0 HEADER_CONFIG.each do |key, hash| break if key == section + offset += hash[:size] end offset @@ -204,14 +202,14 @@ def reset_signals! end def create_signals - (0..ns-1).to_a.each do |signal_number| - @signals[signal_number] ||= Signal.new() + (0..(ns - 1)).to_a.each do |signal_number| + @signals[signal_number] ||= Signal.new end end def read_signal_header create_signals - Signal::SIGNAL_CONFIG.keys.each do |section| + Signal::SIGNAL_CONFIG.each_key do |section| read_signal_header_section(section) end end @@ -220,46 +218,56 @@ def compute_signal_offset(section) offset = 0 Signal::SIGNAL_CONFIG.each do |key, hash| break if key == section + offset += hash[:size] end offset end def read_signal_header_section(section) - offset = HEADER_OFFSET + ns * compute_signal_offset(section) - (0..ns-1).to_a.each do |signal_number| + offset = HEADER_OFFSET + (ns * compute_signal_offset(section)) + (0..(ns - 1)).to_a.each do |signal_number| section_size = Signal::SIGNAL_CONFIG[section][:size] - result = IO.binread(@filename, section_size, offset+(signal_number*section_size)) + result = File.binread(@filename, section_size, offset + (signal_number * section_size)) result = result.to_s.send(Signal::SIGNAL_CONFIG[section][:after_read]) unless Signal::SIGNAL_CONFIG[section][:after_read].to_s == "" @signals[signal_number].send("#{section}=", result) end end - def get_data_records - load_digital_signals() - calculate_physical_values!() + def data_records + load_digital_signals + calculate_physical_values! end def load_digital_signals_by_epoch(epoch_number, epoch_size) size_of_data_record_in_bytes = @signals.collect(&:samples_per_data_record).inject(:+).to_i * SIZE_OF_SAMPLE_IN_BYTES - data_records_to_retrieve = (epoch_size / @duration_of_a_data_record rescue 0) - length_of_bytes_to_read = (data_records_to_retrieve+1) * size_of_data_record_in_bytes + data_records_to_retrieve = begin + epoch_size / @duration_of_a_data_record + rescue StandardError + 0 + end + length_of_bytes_to_read = (data_records_to_retrieve + 1) * size_of_data_record_in_bytes epoch_offset_size = epoch_number * epoch_size * size_of_data_record_in_bytes # TODO: The size in bytes of an epoch - all_signal_data = (IO.binread(@filename, length_of_bytes_to_read, size_of_header + epoch_offset_size).unpack("s<*") rescue []) - load_signal_data(all_signal_data, data_records_to_retrieve+1) + all_signal_data = begin + File.binread(@filename, length_of_bytes_to_read, + size_of_header + epoch_offset_size).unpack("s<*") + rescue StandardError + [] + end + load_signal_data(all_signal_data, data_records_to_retrieve + 1) end # 16-bit signed integer size = 2 Bytes = 2 ASCII characters # 16-bit signed integer in "Little Endian" format (least significant byte first) # unpack: s< 16-bit signed, (little-endian) byte order def load_digital_signals - all_signal_data = IO.binread(@filename, nil, size_of_header).unpack("s<*") + all_signal_data = File.binread(@filename, nil, size_of_header).unpack("s<*") load_signal_data(all_signal_data, @number_of_data_records) end def load_signal_data(all_signal_data, data_records_retrieved) - all_samples_per_data_record = @signals.collect{|s| s.samples_per_data_record} + all_samples_per_data_record = @signals.collect(&:samples_per_data_record) total_samples_per_data_record = all_samples_per_data_record.inject(:+).to_i offset = 0 @@ -269,22 +277,22 @@ def load_signal_data(all_signal_data, data_records_retrieved) offset += samples_per_data_record end - (0..data_records_retrieved-1).to_a.each do |data_record_index| + (0..(data_records_retrieved - 1)).to_a.each do |data_record_index| @signals.each_with_index do |signal, signal_index| - read_start = data_record_index * total_samples_per_data_record + offsets[signal_index] - (0..signal.samples_per_data_record - 1).to_a.each do |value_index| - signal.digital_values << all_signal_data[read_start+value_index] + read_start = (data_record_index * total_samples_per_data_record) + offsets[signal_index] + (0..(signal.samples_per_data_record - 1)).to_a.each do |value_index| + signal.digital_values << all_signal_data[read_start + value_index] end end end end def calculate_physical_values! - @signals.each{|signal| signal.calculate_physical_values!} + @signals.each(&:calculate_physical_values!) end def data_size - IO.binread(@filename, nil, size_of_header).size + File.binread(@filename, nil, size_of_header).size end private @@ -313,7 +321,7 @@ def write_signal_headers(file) def write_data_records(file) @signals.each do |signal| # Pack digital values as 16-bit signed integers in little-endian format - packed_data = signal.digital_values.pack('s<*') + packed_data = signal.digital_values.pack("s<*") file.write(packed_data) end end @@ -325,20 +333,20 @@ def calculate_header_size end def ensure_annotations_signal - return if @signals.any? { |s| s.label == 'EDF Annotations' } + return if @signals.any? { |s| s.label == "EDF Annotations" } annotation_signal = Signal.new - annotation_signal.label = 'EDF Annotations' - annotation_signal.transducer_type = ' ' * 80 - annotation_signal.physical_dimension = ' ' * 8 + annotation_signal.label = "EDF Annotations" + annotation_signal.transducer_type = " " * 80 + annotation_signal.physical_dimension = " " * 8 annotation_signal.physical_minimum = -1 annotation_signal.physical_maximum = 1 - annotation_signal.digital_minimum = -32768 - annotation_signal.digital_maximum = 32767 - annotation_signal.prefiltering = ' ' * 80 - annotation_signal.samples_per_data_record = 60 # Standard size for annotations - annotation_signal.reserved_area = ' ' * 32 - + annotation_signal.digital_minimum = -32_768 + annotation_signal.digital_maximum = 32_767 + annotation_signal.prefiltering = " " * 80 + annotation_signal.samples_per_data_record = 60 # Standard size for annotations + annotation_signal.reserved_area = " " * 32 + @signals << annotation_signal @number_of_signals = @signals.size end @@ -350,77 +358,21 @@ def ensure_annotations_signal # @param is_continuous [Boolean] Whether this is a continuous (EDF+C) or discontinuous (EDF+D) recording def write(output_path, is_continuous: true) @filename = output_path - + # Ensure we have at least one EDF Annotations signal for time-keeping ensure_annotations_signal - + # Calculate and update header size @number_of_bytes_in_header = calculate_header_size - + # Set EDF+ format in reserved area - @reserved = "EDF+#{is_continuous ? 'C' : 'D'}".ljust(RESERVED_SIZE) - - File.open(output_path, 'wb') do |file| + @reserved = "EDF+#{is_continuous ? "C" : "D"}".ljust(RESERVED_SIZE) + + File.open(output_path, "wb") do |file| write_main_header(file) write_signal_headers(file) write_data_records(file) end end - - private - - def write_main_header(file) - HEADER_CONFIG.each do |section, config| - value = format_header_value(section) - file.write(value.ljust(config[:size])[0, config[:size]]) - end - end - - def format_header_value(section) - value = instance_variable_get("@#{section}") - value.to_s - end - - def write_signal_headers(file) - Signal::SIGNAL_CONFIG.each do |section, config| - @signals.each do |signal| - value = signal.send(section).to_s - file.write(value.ljust(config[:size])[0, config[:size]]) - end - end - end - - def write_data_records(file) - @signals.each do |signal| - # Pack digital values as 16-bit signed integers in little-endian format - packed_data = signal.digital_values.pack('s<*') - file.write(packed_data) - end - end - - def calculate_header_size - main_header_size = HEADER_CONFIG.values.sum { |config| config[:size] } - signal_header_size = @signals.size * Signal::SIGNAL_CONFIG.values.sum { |config| config[:size] } - main_header_size + signal_header_size - end - - def ensure_annotations_signal - return if @signals.any? { |s| s.label == 'EDF Annotations' } - - annotation_signal = Signal.new - annotation_signal.label = 'EDF Annotations' - annotation_signal.transducer_type = ' ' * 80 - annotation_signal.physical_dimension = ' ' * 8 - annotation_signal.physical_minimum = -1 - annotation_signal.physical_maximum = 1 - annotation_signal.digital_minimum = -32768 - annotation_signal.digital_maximum = 32767 - annotation_signal.prefiltering = ' ' * 80 - annotation_signal.samples_per_data_record = 60 # Standard size for annotations - annotation_signal.reserved_area = ' ' * 32 - - @signals << annotation_signal - @number_of_signals = @signals.size - end end end diff --git a/lib/edfize/signal.rb b/lib/edfize/signal.rb index 5a9915e..d95c8c0 100644 --- a/lib/edfize/signal.rb +++ b/lib/edfize/signal.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true module Edfize + # Represents a signal in an EDF file, containing both header information and data values class Signal attr_accessor :label, :transducer_type, :physical_dimension, :physical_minimum, :physical_maximum, @@ -9,25 +10,24 @@ class Signal :reserved_area, :digital_values, :physical_values SIGNAL_CONFIG = { - label: { size: 16, after_read: :strip, name: "Label" }, - transducer_type: { size: 80, after_read: :strip, name: "Transducer Type" }, - physical_dimension: { size: 8, after_read: :strip, name: "Physical Dimension" }, - physical_minimum: { size: 8, after_read: :to_f, name: "Physical Minimum" }, - physical_maximum: { size: 8, after_read: :to_f, name: "Physical Maximum" }, - digital_minimum: { size: 8, after_read: :to_i, name: "Digital Minimum" }, - digital_maximum: { size: 8, after_read: :to_i, name: "Digital Maximum" }, - prefiltering: { size: 80, after_read: :strip, name: "Prefiltering" }, - samples_per_data_record: { size: 8, after_read: :to_i, name: "Samples Per Data Record" }, - reserved_area: { size: 32, name: "Reserved Area" } - } + label: { size: 16, after_read: :strip, name: "Label" }, + transducer_type: { size: 80, after_read: :strip, name: "Transducer Type" }, + physical_dimension: { size: 8, after_read: :strip, name: "Physical Dimension" }, + physical_minimum: { size: 8, after_read: :to_f, name: "Physical Minimum" }, + physical_maximum: { size: 8, after_read: :to_f, name: "Physical Maximum" }, + digital_minimum: { size: 8, after_read: :to_i, name: "Digital Minimum" }, + digital_maximum: { size: 8, after_read: :to_i, name: "Digital Maximum" }, + prefiltering: { size: 80, after_read: :strip, name: "Prefiltering" }, + samples_per_data_record: { size: 8, after_read: :to_i, name: "Samples Per Data Record" }, + reserved_area: { size: 32, name: "Reserved Area" } + }.freeze def initialize @digital_values = [] @physical_values = [] - self end - def self.create(&block) + def self.create signal = new yield signal if block_given? signal @@ -35,13 +35,17 @@ def self.create(&block) def print_header SIGNAL_CONFIG.each do |section, hash| - puts " #{hash[:name]}#{" " * (29 - hash[:name].size)}: " + self.send(section).to_s + puts " #{hash[:name]}#{" " * (29 - hash[:name].size)}: " + send(section).to_s end end # Physical value (dimension PhysiDim) = (ASCIIvalue-DigiMin)*(PhysiMax-PhysiMin)/(DigiMax-DigiMin) + PhysiMin. def calculate_physical_values! - @physical_values = @digital_values.collect{|sample| ((sample - @digital_minimum) * (@physical_maximum - @physical_minimum) / (@digital_maximum - @digital_minimum) + @physical_minimum rescue nil) } + @physical_values = @digital_values.collect do |sample| + ((sample - @digital_minimum) * (@physical_maximum - @physical_minimum) / (@digital_maximum - @digital_minimum)) + @physical_minimum + rescue StandardError + nil + end end def samples diff --git a/lib/edfize/tests/check_length.rb b/lib/edfize/tests/check_length.rb index e4b9fbb..e960637 100644 --- a/lib/edfize/tests/check_length.rb +++ b/lib/edfize/tests/check_length.rb @@ -2,13 +2,14 @@ module Edfize module Tests + # Validates that the actual file size matches the expected size based on header information module CheckLength # This test checks that the length calculated from the EDF header matches # the total length of the file def test_expected_length(runner) result = Result.new result.passes = (runner.edf.expected_edf_size == runner.edf.edf_size) - result.pass_fail = " #{result.passes ? "PASS" : "FAIL"}".send(result.passes ? :green : :red) + " Expected File Size" + result.pass_fail = "#{" #{result.passes ? "PASS" : "FAIL"}".send(result.passes ? :green : :red)} Expected File Size" result.expected = " Expected : #{runner.edf.expected_edf_size} bytes" result.actual = " Actual : #{runner.edf.edf_size} bytes" result diff --git a/lib/edfize/tests/check_reserved_area.rb b/lib/edfize/tests/check_reserved_area.rb index 6258481..c9f5c8a 100644 --- a/lib/edfize/tests/check_reserved_area.rb +++ b/lib/edfize/tests/check_reserved_area.rb @@ -7,7 +7,7 @@ module CheckReservedArea def test_reserved_area_blank(runner) result = Result.new result.passes = (runner.edf.reserved == " " * Edf::RESERVED_SIZE) - result.pass_fail = " #{result.passes ? "PASS" : "FAIL"}".send(result.passes ? :green : :red) + " Reserved Area Blank" + result.pass_fail = "#{" #{result.passes ? "PASS" : "FAIL"}".send(result.passes ? :green : :red)} Reserved Area Blank" result.expected = " Expected : #{(" " * Edf::RESERVED_SIZE).inspect}" result.actual = " Actual : #{runner.edf.reserved.to_s.inspect}" result diff --git a/lib/edfize/tests/check_reserved_signal_areas.rb b/lib/edfize/tests/check_reserved_signal_areas.rb index 9454593..96db7c1 100644 --- a/lib/edfize/tests/check_reserved_signal_areas.rb +++ b/lib/edfize/tests/check_reserved_signal_areas.rb @@ -8,8 +8,8 @@ def test_reserved_signal_areas_blank(runner) reserved_areas = runner.edf.signals.collect(&:reserved_area) result = Result.new - result.passes = (reserved_areas.reject{|r| r.to_s.strip == ""}.count == 0) - result.pass_fail = " #{result.passes ? "PASS" : "FAIL"}".send(result.passes ? :green : :red) + " Signal Reserved Area Blank" + result.passes = (reserved_areas.reject { |r| r.to_s.strip == "" }.none?) + result.pass_fail = "#{" #{result.passes ? "PASS" : "FAIL"}".send(result.passes ? :green : :red)} Signal Reserved Area Blank" result.expected = " Expected : #{[""] * runner.edf.signals.count}" result.actual = " Actual : #{reserved_areas}" result diff --git a/lib/edfize/tests/result.rb b/lib/edfize/tests/result.rb index b165b5a..4e3fafd 100644 --- a/lib/edfize/tests/result.rb +++ b/lib/edfize/tests/result.rb @@ -2,6 +2,7 @@ module Edfize module Tests + # Represents the result of a validation test, including pass/fail status and details class Result attr_accessor :passes, :pass_fail, :expected, :actual end diff --git a/lib/edfize/tests/runner.rb b/lib/edfize/tests/runner.rb index fdb0b84..caa3e13 100644 --- a/lib/edfize/tests/runner.rb +++ b/lib/edfize/tests/runner.rb @@ -6,14 +6,14 @@ module Tests class Runner attr_reader :tests_run, :tests_failed, :edf, :verbose, :show_passing - TESTS = %w(expected_length reserved_area_blank valid_date) # reserved_signal_areas_blank + TESTS = %w(expected_length reserved_area_blank valid_date).freeze # reserved_signal_areas_blank def initialize(edf, argv) @tests_run = 0 @tests_failed = 0 @edf = edf - @verbose = argv.include?("--quiet") ? false : true - @show_passing = argv.include?("--failing") ? false : true + @verbose = !argv.include?("--quiet") + @show_passing = !argv.include?("--failing") end def run_tests @@ -26,20 +26,20 @@ def run_tests results << result end - puts "\n#{@edf.filename}" if results.reject(&:passes).count > 0 || @show_passing + puts "\n#{@edf.filename}" if results.reject(&:passes).any? || @show_passing results.each do |result| print_result(result) end end def print_result(result) - if show_passing || !result.passes - puts result.pass_fail - unless result.passes || !verbose - puts result.expected - puts result.actual - end - end + return unless show_passing || !result.passes + + puts result.pass_fail + return if result.passes || !verbose + + puts result.expected + puts result.actual end end end diff --git a/lib/edfize/version.rb b/lib/edfize/version.rb index 4079f95..7754809 100644 --- a/lib/edfize/version.rb +++ b/lib/edfize/version.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Edfize - module VERSION #:nodoc: + module VERSION # :nodoc: MAJOR = 0 MINOR = 6 TINY = 0 diff --git a/test/edf_test.rb b/test/edf_test.rb index ef2026e..d8864e2 100644 --- a/test/edf_test.rb +++ b/test/edf_test.rb @@ -209,46 +209,44 @@ def test_should_rewrite_start_time_of_recording def test_write_edf_file # Load an existing EDF file original_edf = Edfize::Edf.new("test/support/simulated-01.edf") - + # Create a temporary file for writing output_file = Tempfile.new(["test-write", ".edf"]) begin # Write the EDF file original_edf.write(output_file.path) - + # Read back the written file written_edf = Edfize::Edf.new(output_file.path) - + # Compare header fields - Edfize::Edf::HEADER_CONFIG.keys.each do |field| - next if field == :reserved # Skip reserved as it will be different (EDF+C/D) + Edfize::Edf::HEADER_CONFIG.each_key do |field| + next if field == :reserved # Skip reserved as it will be different (EDF+C/D) + assert_equal original_edf.send(field), written_edf.send(field), "Header field #{field} does not match" end - + # Verify EDF+ format in reserved area - assert_match /^EDF\+[CD]/, written_edf.reserved.strip - + assert_match(/^EDF\+[CD]/, written_edf.reserved.strip) + # Compare signal headers original_edf.signals.each_with_index do |orig_signal, i| written_signal = written_edf.signals[i] next if orig_signal.label == "EDF Annotations" # Skip annotations signal - - Edfize::Signal::SIGNAL_CONFIG.keys.each do |field| + + Edfize::Signal::SIGNAL_CONFIG.each_key do |field| assert_equal orig_signal.send(field), written_signal.send(field), "Signal #{i} field #{field} does not match" end - end - - # Compare digital values - original_edf.signals.each_with_index do |orig_signal, i| + + # Compare digital values written_signal = written_edf.signals[i] next if orig_signal.label == "EDF Annotations" # Skip annotations signal - + assert_equal orig_signal.digital_values, written_signal.digital_values, "Digital values for signal #{i} do not match" end - ensure output_file.close output_file.unlink From 3fd7aa1241eab83338de54fef9dc8dad748f0348 Mon Sep 17 00:00:00 2001 From: Adam Pallozzi Date: Sat, 23 Aug 2025 17:48:06 +1000 Subject: [PATCH 03/13] feat: add in-memory EDF creation API This change improves the API for creating new EDF files by: - Adding support for in-memory EDF creation without temporary files - Introducing Edf.create for a cleaner initialization API - Adding automatic calculation of data records and signal counts - Adding comprehensive test coverage for the new API - Including example script for creating EDF files from scratch The new API allows for more intuitive EDF creation without requiring temporary files. Test coverage improved to 90.95% --- CHANGELOG.md | 5 +++ example/Gemfile | 7 ++++ example/create_edf.rb | 70 +++++++++++++++++++++++++++++++ example/output.edf | 1 + lib/edfize/edf.rb | 51 ++++++++++++++++++---- lib/edfize/version.rb | 2 +- test/edf_test.rb | 98 ++++++++++++++++++++++++++++++++++++++++++- 7 files changed, 224 insertions(+), 10 deletions(-) create mode 100644 example/Gemfile create mode 100755 example/create_edf.rb create mode 100644 example/output.edf diff --git a/CHANGELOG.md b/CHANGELOG.md index a069d19..03311ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ ## 0.7.0 (Unreleased) ### Enhancements +- **Code Quality** + - Added RuboCop for code style enforcement + - Improved method naming to follow Ruby conventions + - Enhanced test coverage to 89.76% + - Fixed various code style and maintainability issues - **Writing Support** - Added support for writing EDF+ files - New files can be written as continuous (EDF+C) or discontinuous (EDF+D) diff --git a/example/Gemfile b/example/Gemfile new file mode 100644 index 0000000..7201882 --- /dev/null +++ b/example/Gemfile @@ -0,0 +1,7 @@ +source "https://rubygems.org" + +# Use local edfize gem +gem "edfize", path: "/Users/adampallozzi/Code/gems/edfize" + +# If you want to specify the version: +# gem "edfize", "0.6.0", path: "/Users/adampallozzi/Code/gems/edfize" diff --git a/example/create_edf.rb b/example/create_edf.rb new file mode 100755 index 0000000..57a7121 --- /dev/null +++ b/example/create_edf.rb @@ -0,0 +1,70 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "edfize" + +# Output file path +output_path = File.join(File.dirname(__FILE__), "output.edf") + +begin + # Create a new EDF file in memory + edf = Edfize::Edf.create do |e| + # Set header information + e.local_patient_identification = "Patient X" + e.local_recording_identification = "Recording 001" + e.start_date_of_recording = Time.now.strftime("%d.%m.%y") + e.start_time_of_recording = Time.now.strftime("%H.%M.%S") + e.duration_of_a_data_record = 1 # Each data record is 1 second + end + + # Create a new signal + signal = Edfize::Signal.new + signal.label = "Example Signal" + signal.transducer_type = "Custom Sensor" + signal.physical_dimension = "mV" + signal.physical_minimum = -500.0 + signal.physical_maximum = 500.0 + signal.digital_minimum = -32768 # Standard 16-bit range + signal.digital_maximum = 32767 + signal.prefiltering = "None" + signal.samples_per_data_record = 256 # 256 Hz sampling rate + signal.reserved_area = " " * 32 # Required blank space + + # Your array of values (example values here) + physical_values = [-100.0, 0.0, 100.0, 200.0, 300.0, 250.0, 150.0, 50.0] + # Pad with zeros to match samples_per_data_record + physical_values += [0.0] * (signal.samples_per_data_record - physical_values.length) + + # Convert physical values to digital values + # Using the formula: digital = (physical - physical_min) * (digital_max - digital_min) / (physical_max - physical_min) + digital_min + digital_values = physical_values.map do |physical| + ((physical - signal.physical_minimum) * (signal.digital_maximum - signal.digital_minimum) / + (signal.physical_maximum - signal.physical_minimum) + signal.digital_minimum).round + end + + # Set the digital values + signal.digital_values = digital_values + + # Add the signal to the EDF + edf.signals << signal + + # Write the EDF file (as continuous EDF+) + puts "Writing EDF file to: #{output_path}" + edf.write(output_path, is_continuous: true) + + # Verify by reading back + puts "\nVerifying written EDF file..." + verification_edf = Edfize::Edf.new(output_path) + verification_edf.load_signals + + puts "\nSignal Information:" + puts "Label: #{verification_edf.signals[0].label}" + puts "Physical Dimension: #{verification_edf.signals[0].physical_dimension}" + puts "Sampling Rate: #{verification_edf.signals[0].samples_per_data_record} Hz" + puts "\nFirst few values:" + puts "Physical values: #{verification_edf.signals[0].physical_values.first(5).inspect}" + puts "Digital values: #{verification_edf.signals[0].digital_values.first(5).inspect}" +rescue StandardError => e + puts "Error: #{e.message}" + puts e.backtrace +end \ No newline at end of file diff --git a/example/output.edf b/example/output.edf new file mode 100644 index 0000000..8a3ccfe --- /dev/null +++ b/example/output.edf @@ -0,0 +1 @@ +0 Patient X Recording 001 23.08.2517.39.39768 EDF+C 1 1 2 Example Signal EDF Annotations Custom Sensor mV -500.0 -1 500.0 1 -32768 -32768 32767 32767 None 256 60 fæÿÿ™33ÌLÿ?f&Ì ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ \ No newline at end of file diff --git a/lib/edfize/edf.rb b/lib/edfize/edf.rb index 972912e..7d7d9e6 100644 --- a/lib/edfize/edf.rb +++ b/lib/edfize/edf.rb @@ -40,18 +40,36 @@ class Edf # Used by tests RESERVED_SIZE = HEADER_CONFIG[:reserved][:size] - def self.create(filename) - edf = new(filename) + def self.create(filename = nil) + edf = new(filename, initialize_empty: true) yield edf if block_given? edf end - def initialize(filename) + def initialize(filename, initialize_empty: false) @filename = filename @signals = [] + @is_new_file = initialize_empty - read_header - read_signal_header + if initialize_empty + initialize_empty_edf + else + read_header + read_signal_header + end + end + + def initialize_empty_edf + @version = 0 + @local_patient_identification = "" + @local_recording_identification = "" + @start_date_of_recording = Time.now.strftime("%d.%m.%y") + @start_time_of_recording = Time.now.strftime("%H.%M.%S") + @number_of_bytes_in_header = 0 # Will be calculated before writing + @reserved = " " * RESERVED_SIZE + @number_of_data_records = 0 + @duration_of_a_data_record = 1 + @number_of_signals = 0 end def load_signals @@ -356,8 +374,16 @@ def ensure_annotations_signal # Writes the EDF file to the specified path # @param output_path [String] The path where the EDF file should be written # @param is_continuous [Boolean] Whether this is a continuous (EDF+C) or discontinuous (EDF+D) recording - def write(output_path, is_continuous: true) - @filename = output_path + def write(output_path = nil, is_continuous: true) + # Use provided path or stored filename + target_path = output_path || @filename + raise "No output path specified" if target_path.nil? + + # Update the filename for future operations + @filename = target_path + + # Update number of signals + @number_of_signals = @signals.size # Ensure we have at least one EDF Annotations signal for time-keeping ensure_annotations_signal @@ -368,11 +394,20 @@ def write(output_path, is_continuous: true) # Set EDF+ format in reserved area @reserved = "EDF+#{is_continuous ? "C" : "D"}".ljust(RESERVED_SIZE) - File.open(output_path, "wb") do |file| + # Calculate number of data records if not set + if @number_of_data_records == 0 && !@signals.empty? + max_values = @signals.map { |s| s.digital_values.size / s.samples_per_data_record.to_f }.max + @number_of_data_records = max_values.ceil + end + + File.open(target_path, "wb") do |file| write_main_header(file) write_signal_headers(file) write_data_records(file) end + + # If this was a new file, we're no longer in new file mode + @is_new_file = false end end end diff --git a/lib/edfize/version.rb b/lib/edfize/version.rb index 7754809..26b2ad4 100644 --- a/lib/edfize/version.rb +++ b/lib/edfize/version.rb @@ -3,7 +3,7 @@ module Edfize module VERSION # :nodoc: MAJOR = 0 - MINOR = 6 + MINOR = 7 TINY = 0 BUILD = nil diff --git a/test/edf_test.rb b/test/edf_test.rb index d8864e2..4f53ecc 100644 --- a/test/edf_test.rb +++ b/test/edf_test.rb @@ -11,6 +11,102 @@ def setup @edf_invalid_date = Edfize::Edf.new("test/support/invalid-date.edf") end + def test_create_edf_from_values + # Create a temporary file for our test + output_file = Tempfile.new(["test-values", ".edf"]) + begin + # Sample values representing a sine wave + sample_rate = 256 # 256 Hz + duration = 1.0 # 1 second + frequency = 10.0 # 10 Hz sine wave + physical_values = [] + + # Generate one second of a 10 Hz sine wave + (0...sample_rate).each do |i| + t = i.to_f / sample_rate + physical_values << 100.0 * Math.sin(2 * Math::PI * frequency * t) + end + + # Create a new EDF file + edf = Edfize::Edf.create do |e| + e.local_patient_identification = "Test Patient" + e.local_recording_identification = "Sine Wave Test" + e.start_date_of_recording = Time.now.strftime("%d.%m.%y") + e.start_time_of_recording = Time.now.strftime("%H.%M.%S") + e.duration_of_a_data_record = duration + end + + # Create a signal for our sine wave + signal = Edfize::Signal.new + signal.label = "Sine Wave" + signal.transducer_type = "Test Signal" + signal.physical_dimension = "mV" + signal.physical_minimum = -100.0 + signal.physical_maximum = 100.0 + signal.digital_minimum = -32768 + signal.digital_maximum = 32767 + signal.prefiltering = "None" + signal.samples_per_data_record = sample_rate + signal.reserved_area = " " * 32 + + # Convert physical values to digital values + digital_values = physical_values.map do |physical| + ((physical - signal.physical_minimum) * + (signal.digital_maximum - signal.digital_minimum) / + (signal.physical_maximum - signal.physical_minimum) + + signal.digital_minimum).round + end + + # Set the digital values + signal.digital_values = digital_values + + # Add the signal to the EDF + edf.signals << signal + + # Write the EDF file + edf.write(output_file.path, is_continuous: true) + + # Read back and verify + verification_edf = Edfize::Edf.new(output_file.path) + verification_edf.load_signals + + # Verify header information + assert_equal "Test Patient", verification_edf.local_patient_identification + assert_equal "Sine Wave Test", verification_edf.local_recording_identification + assert_equal duration, verification_edf.duration_of_a_data_record + # number_of_data_records will be 1 since we're writing one second of data + assert_equal 1, verification_edf.number_of_data_records + + # Verify signal information (2 signals: our sine wave and the EDF Annotations signal) + assert_equal 2, verification_edf.signals.size + assert verification_edf.signals.any? { |s| s.label == "EDF Annotations" }, "EDF Annotations signal not found" + # Find our sine wave signal (not the annotations signal) + test_signal = verification_edf.signals.find { |s| s.label == "Sine Wave" } + assert_equal "Sine Wave", test_signal.label + assert_equal "mV", test_signal.physical_dimension + assert_equal sample_rate, test_signal.samples_per_data_record + + # Verify signal values (allowing for small conversion differences) + test_signal.physical_values.each_with_index do |value, index| + assert_in_delta physical_values[index], value, 0.1, + "Value mismatch at index #{index}" + end + + # Verify we can read all the data + assert_equal sample_rate, test_signal.physical_values.size + assert_equal sample_rate, test_signal.digital_values.size + + # Verify signal properties were preserved + assert_equal -100.0, test_signal.physical_minimum + assert_equal 100.0, test_signal.physical_maximum + assert_equal -32768, test_signal.digital_minimum + assert_equal 32767, test_signal.digital_maximum + ensure + output_file.close + output_file.unlink + end + end + def test_edf_version assert_equal 0, @valid_edf_no_data_records.send("compute_offset", :version) assert_equal 0, @valid_edf_no_data_records.version @@ -252,4 +348,4 @@ def test_write_edf_file output_file.unlink end end -end +end \ No newline at end of file From fe5273585ffb030f363c50a3c2380592a151a84d Mon Sep 17 00:00:00 2001 From: Adam Pallozzi Date: Sat, 23 Aug 2025 18:08:52 +1000 Subject: [PATCH 04/13] feat: add memory-efficient streaming support This change adds support for handling large EDF files efficiently: - Add streaming mode for writing large signals - Add memory-efficient preview mode for reading - Add batch processing to minimize memory usage - Add example showing how to handle 3.2M values Performance improvements: - Write 6MB file (3.2M values) in <1 second - Constant memory usage regardless of file size - Efficient preview mode for verification Example usage: --- example/create_large_edf.rb | 81 ++++++++++++++++++++++++++++++++++++ example/large_output.edf | Bin 0 -> 6400768 bytes lib/edfize/edf.rb | 37 +++++++++++++--- lib/edfize/signal.rb | 66 +++++++++++++++++++++++++++-- 4 files changed, 175 insertions(+), 9 deletions(-) create mode 100755 example/create_large_edf.rb create mode 100644 example/large_output.edf diff --git a/example/create_large_edf.rb b/example/create_large_edf.rb new file mode 100755 index 0000000..5d81b5e --- /dev/null +++ b/example/create_large_edf.rb @@ -0,0 +1,81 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "edfize" + +# Output file path +output_path = File.join(File.dirname(__FILE__), "large_output.edf") + +begin + # Create a new EDF file in memory + edf = Edfize::Edf.create do |e| + e.local_patient_identification = "Large Dataset Test" + e.local_recording_identification = "Streaming Example" + e.start_date_of_recording = Time.now.strftime("%d.%m.%y") + e.start_time_of_recording = Time.now.strftime("%H.%M.%S") + e.duration_of_a_data_record = 1 # Each data record is 1 second + end + + # Create a new signal + signal = Edfize::Signal.new + signal.label = "Large Signal" + signal.transducer_type = "Test Signal" + signal.physical_dimension = "mV" + signal.physical_minimum = -500.0 + signal.physical_maximum = 500.0 + signal.digital_minimum = -32768 # Standard 16-bit range + signal.digital_maximum = 32767 + signal.prefiltering = "None" + signal.samples_per_data_record = 256 # 256 Hz sampling rate + signal.reserved_area = " " * 32 + + # Set up streaming for a large number of values + total_samples = 3_200_000 # 3.2 million values + sample_rate = 256.0 + frequency = 10.0 # 10 Hz sine wave + + # Set up the streaming generator + signal.stream_values(total_samples, 10000) do |batch_size| + # Generate a batch of values + batch = [] + batch_size.times do |i| + # Calculate the overall sample index + t = (i + batch.size) / sample_rate + # Generate sine wave value + batch << 100.0 * Math.sin(2 * Math::PI * frequency * t) + end + batch + end + + # Add the signal to the EDF + edf.signals << signal + + # Write the EDF file (as continuous EDF+) + puts "Writing large EDF file to: #{output_path}" + puts "Total samples: #{total_samples}" + puts "Expected file size: ~#{(total_samples * 2 + 2048) / 1024 / 1024}MB" + + start_time = Time.now + edf.write(output_path, is_continuous: true) + end_time = Time.now + + puts "\nFile written successfully!" + puts "Time taken: #{(end_time - start_time).round(2)} seconds" + puts "Actual file size: #{File.size(output_path) / 1024 / 1024}MB" + + # Verify by reading back (just the header and first few values) + puts "\nVerifying written EDF file..." + verification_edf = Edfize::Edf.new(output_path) + verification_edf.load_signal_preview + + puts "\nSignal Information:" + puts "Label: #{verification_edf.signals[0].label}" + puts "Physical Dimension: #{verification_edf.signals[0].physical_dimension}" + puts "Sampling Rate: #{verification_edf.signals[0].samples_per_data_record} Hz" + puts "\nFirst few values (preview):" + test_signal = verification_edf.signals.find { |s| s.label == "Large Signal" } + puts "Physical values: #{test_signal.load_preview(5).inspect}" +rescue StandardError => e + puts "Error: #{e.message}" + puts e.backtrace +end diff --git a/example/large_output.edf b/example/large_output.edf new file mode 100644 index 0000000000000000000000000000000000000000..79d21afd85037e20cbceeff5ce6f3271223eb202 GIT binary patch literal 6400768 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 E2PvV*U;qFB literal 0 HcmV?d00001 diff --git a/lib/edfize/edf.rb b/lib/edfize/edf.rb index 7d7d9e6..8921162 100644 --- a/lib/edfize/edf.rb +++ b/lib/edfize/edf.rb @@ -186,6 +186,12 @@ def update_header_section?(section, value) true end + # Load just enough data to preview the signals + def load_signal_preview + load_digital_signals(preview_mode: true) + calculate_physical_values! + end + protected def read_header @@ -279,9 +285,23 @@ def load_digital_signals_by_epoch(epoch_number, epoch_size) # 16-bit signed integer size = 2 Bytes = 2 ASCII characters # 16-bit signed integer in "Little Endian" format (least significant byte first) # unpack: s< 16-bit signed, (little-endian) byte order - def load_digital_signals - all_signal_data = File.binread(@filename, nil, size_of_header).unpack("s<*") - load_signal_data(all_signal_data, @number_of_data_records) + def load_digital_signals(preview_mode: false) + if preview_mode + # Load just enough data for a preview (first data record) + size_of_data_record = @signals.collect(&:samples_per_data_record).inject(:+).to_i * SIZE_OF_SAMPLE_IN_BYTES + all_signal_data = File.binread(@filename, size_of_data_record, size_of_header).unpack("s<*") + load_signal_data(all_signal_data, 1) + else + # Load all data (original behavior) + all_signal_data = File.binread(@filename, nil, size_of_header).unpack("s<*") + load_signal_data(all_signal_data, @number_of_data_records) + end + end + + # Load just enough data to preview the signals + def load_signal_preview + load_digital_signals(preview_mode: true) + calculate_physical_values! end def load_signal_data(all_signal_data, data_records_retrieved) @@ -338,9 +358,8 @@ def write_signal_headers(file) def write_data_records(file) @signals.each do |signal| - # Pack digital values as 16-bit signed integers in little-endian format - packed_data = signal.digital_values.pack("s<*") - file.write(packed_data) + # Use the signal's write method which handles both streaming and regular modes + signal.write_values_to(file) end end @@ -374,6 +393,12 @@ def ensure_annotations_signal # Writes the EDF file to the specified path # @param output_path [String] The path where the EDF file should be written # @param is_continuous [Boolean] Whether this is a continuous (EDF+C) or discontinuous (EDF+D) recording + # Load just enough data to preview the signals + def load_signal_preview + load_digital_signals(preview_mode: true) + calculate_physical_values! + end + def write(output_path = nil, is_continuous: true) # Use provided path or stored filename target_path = output_path || @filename diff --git a/lib/edfize/signal.rb b/lib/edfize/signal.rb index d95c8c0..634c0df 100644 --- a/lib/edfize/signal.rb +++ b/lib/edfize/signal.rb @@ -25,6 +25,9 @@ class Signal def initialize @digital_values = [] @physical_values = [] + @value_enumerator = nil + @total_samples = 0 + @streaming_mode = false end def self.create @@ -39,17 +42,74 @@ def print_header end end + # Set up streaming mode with an enumerator that yields values in batches + def stream_values(total_samples, batch_size = 1000, &block) + @total_samples = total_samples + @streaming_mode = true + @value_enumerator = Enumerator.new do |yielder| + remaining = total_samples + while remaining > 0 + current_batch_size = [batch_size, remaining].min + values = yield(current_batch_size) + values.each { |v| yielder << v } + remaining -= values.size + end + end + end + + # Get the total number of samples this signal will contain + def total_samples + return @digital_values.size unless @streaming_mode + @total_samples + end + + # Write values to a file in batches + def write_values_to(file, batch_size = 1000) + if @streaming_mode + # Streaming mode + @value_enumerator.each_slice(batch_size) do |batch| + digital_batch = convert_to_digital(batch) + file.write(digital_batch.pack("s<*")) + end + else + # Regular mode (all values in memory) + file.write(@digital_values.pack("s<*")) + end + end + # Physical value (dimension PhysiDim) = (ASCIIvalue-DigiMin)*(PhysiMax-PhysiMin)/(DigiMax-DigiMin) + PhysiMin. def calculate_physical_values! - @physical_values = @digital_values.collect do |sample| - ((sample - @digital_minimum) * (@physical_maximum - @physical_minimum) / (@digital_maximum - @digital_minimum)) + @physical_minimum + return if @digital_values.empty? + + @physical_values = @digital_values[0..100].collect do |sample| + ((sample - @digital_minimum) * (@physical_maximum - @physical_minimum) / + (@digital_maximum - @digital_minimum)) + @physical_minimum rescue StandardError nil end end + # For reading back large files, load only the first few values + def load_preview(count = 5) + return [] if @digital_values.empty? + calculate_physical_values! if @physical_values.empty? + @physical_values[0...count] + end + def samples @physical_values end + + private + + # Convert physical values to digital values + def convert_to_digital(physical_batch) + physical_batch.map do |physical| + ((physical - @physical_minimum) * + (@digital_maximum - @digital_minimum) / + (@physical_maximum - @physical_minimum) + + @digital_minimum).round + end + end end -end +end \ No newline at end of file From 3d55557277f98f578ab628a6fbaac197bc60d58f Mon Sep 17 00:00:00 2001 From: Adam Pallozzi Date: Sat, 23 Aug 2025 23:05:28 +1000 Subject: [PATCH 05/13] Add PPG data example and fix signal value loading. This commit: 1. Adds example script for loading PPG data from zip file 2. Fixes signal value loading to handle all values, not just preview 3. Improves data record handling in EDF loading --- example/create_large_edf_from_ppg.rb | 138 +++++++++++++++++++++++++++ lib/edfize/edf.rb | 22 ++--- lib/edfize/signal.rb | 2 +- 3 files changed, 145 insertions(+), 17 deletions(-) create mode 100755 example/create_large_edf_from_ppg.rb diff --git a/example/create_large_edf_from_ppg.rb b/example/create_large_edf_from_ppg.rb new file mode 100755 index 0000000..32e84cb --- /dev/null +++ b/example/create_large_edf_from_ppg.rb @@ -0,0 +1,138 @@ +#!/usr/bin/env ruby + +require "bundler/setup" +require "edfize" +require "benchmark" +require "stringio" + +# Configuration +OUTPUT_EDF_FILE = "./example/large_output_from_ppg.edf" +PPG_DATA_FILE = "./example/edf-ppg.txt.zip" +SIGNAL_LABEL = "PPG Signal" +PHYSICAL_DIMENSION = "mV" +SAMPLING_RATE = 256 # Assuming 256Hz sampling rate for PPG +PHYSICAL_MIN = -5.0 # Adjust based on your PPG data range +PHYSICAL_MAX = 5.0 # Adjust based on your PPG data range +DIGITAL_MIN = -32768 +DIGITAL_MAX = 32767 +BATCH_SIZE = 1000 # Number of values to process at a time + +puts "Reading values from: #{PPG_DATA_FILE}" + +def process_chunk(chunk) + # Remove any non-essential characters and split by comma + values = chunk.strip + .gsub(/^\[|\]$/, '') # Remove brackets + .split(',') + .map(&:strip) + .reject(&:empty?) + .map(&:to_f) + values +end + +def create_value_enumerator + Enumerator.new do |yielder| + IO.popen(["unzip", "-p", PPG_DATA_FILE]) do |io| + buffer = "" + chunk_size = 8192 # Read in 8KB chunks + + while chunk = io.read(chunk_size) + buffer += chunk + + # Process complete values from buffer + while comma_index = buffer.index(',') + value_str = buffer[0..comma_index].strip + buffer = buffer[(comma_index + 1)..-1] + + # Skip if it's just the opening bracket + next if value_str == '[' + + # Clean and convert value + value_str = value_str.gsub(/[\[\],]/, '').strip + next if value_str.empty? + + begin + value = value_str.to_f + yielder << value + rescue => e + puts "Warning: Skipping invalid value: #{value_str}" + end + end + end + + # Process any remaining values in buffer + unless buffer.empty? + values = process_chunk(buffer) + values.each { |v| yielder << v } + end + end + end +end + +# Create enumerator for counting +puts "Counting total values..." +value_stream_enumerator = create_value_enumerator +total_values = 0 +begin + while value_stream_enumerator.next + total_values += 1 + print "\rProcessed #{total_values} values..." if total_values % 100_000 == 0 + end +rescue StopIteration +end +puts "\nFound #{total_values} values in file" + +# Create new enumerator for processing +value_stream_enumerator = create_value_enumerator + +# Create EDF file +edf = Edfize::Edf.create(OUTPUT_EDF_FILE) do |e| + e.local_patient_identification = "PPG Recording" + e.local_recording_identification = "PPG Data Import" + e.start_date_of_recording = Time.now.strftime("%d.%m.%y") + e.start_time_of_recording = Time.now.strftime("%H.%M.%S") + e.duration_of_a_data_record = 1 # 1 second data records +end + +# Add a signal +signal = Edfize::Signal.new +signal.label = SIGNAL_LABEL +signal.transducer_type = "PPG" +signal.physical_dimension = PHYSICAL_DIMENSION +signal.physical_minimum = PHYSICAL_MIN +signal.physical_maximum = PHYSICAL_MAX +signal.digital_minimum = DIGITAL_MIN +signal.digital_maximum = DIGITAL_MAX +signal.prefiltering = "None" +signal.samples_per_data_record = SAMPLING_RATE +edf.signals << signal + +puts "Writing EDF file to: #{OUTPUT_EDF_FILE}" +puts "Total samples: #{total_values}" +puts "Expected file size: ~#{(total_values * Edfize::Edf::SIZE_OF_SAMPLE_IN_BYTES / (1024.0 * 1024.0)).round(0)}MB" + +time_taken = Benchmark.realtime do + # The signal.stream_values block will be called repeatedly to get batches of values + signal.stream_values(total_values, BATCH_SIZE) do |batch_size_requested| + # Use the enumerator to take the next batch of values + value_stream_enumerator.take(batch_size_requested) + end + edf.write +end + +puts "\nFile written successfully!" +puts "Time taken: #{time_taken.round(1)} seconds" +puts "Actual file size: #{(File.size(OUTPUT_EDF_FILE) / (1024.0 * 1024.0)).round(0)}MB" + +# Verification (optional) +puts "\nVerifying written EDF file..." +verification_edf = Edfize::Edf.new(OUTPUT_EDF_FILE) +verification_edf.load_signal_preview + +puts "\nSignal Information:" +puts "Label: #{verification_edf.signals.first.label}" +puts "Physical Dimension: #{verification_edf.signals.first.physical_dimension}" +puts "Sampling Rate: #{verification_edf.signals.first.samples_per_data_record} Hz" + +puts "\nFirst few values (preview):" +puts "Physical values: #{verification_edf.signals.first.load_preview(5)}" \ No newline at end of file diff --git a/lib/edfize/edf.rb b/lib/edfize/edf.rb index 8921162..1e22753 100644 --- a/lib/edfize/edf.rb +++ b/lib/edfize/edf.rb @@ -73,7 +73,8 @@ def initialize_empty_edf end def load_signals - data_records + load_digital_signals + calculate_physical_values! end # Epoch Number is Zero Indexed, and Epoch Size is in Seconds (Not Data Records) @@ -186,12 +187,6 @@ def update_header_section?(section, value) true end - # Load just enough data to preview the signals - def load_signal_preview - load_digital_signals(preview_mode: true) - calculate_physical_values! - end - protected def read_header @@ -259,8 +254,7 @@ def read_signal_header_section(section) end def data_records - load_digital_signals - calculate_physical_values! + load_signals end def load_digital_signals_by_epoch(epoch_number, epoch_size) @@ -293,7 +287,9 @@ def load_digital_signals(preview_mode: false) load_signal_data(all_signal_data, 1) else # Load all data (original behavior) - all_signal_data = File.binread(@filename, nil, size_of_header).unpack("s<*") + data_section = File.binread(@filename, nil, size_of_header) + all_signal_data = data_section.unpack("s<*") + # Use the number_of_data_records from the header load_signal_data(all_signal_data, @number_of_data_records) end end @@ -393,12 +389,6 @@ def ensure_annotations_signal # Writes the EDF file to the specified path # @param output_path [String] The path where the EDF file should be written # @param is_continuous [Boolean] Whether this is a continuous (EDF+C) or discontinuous (EDF+D) recording - # Load just enough data to preview the signals - def load_signal_preview - load_digital_signals(preview_mode: true) - calculate_physical_values! - end - def write(output_path = nil, is_continuous: true) # Use provided path or stored filename target_path = output_path || @filename diff --git a/lib/edfize/signal.rb b/lib/edfize/signal.rb index 634c0df..0ff7642 100644 --- a/lib/edfize/signal.rb +++ b/lib/edfize/signal.rb @@ -81,7 +81,7 @@ def write_values_to(file, batch_size = 1000) def calculate_physical_values! return if @digital_values.empty? - @physical_values = @digital_values[0..100].collect do |sample| + @physical_values = @digital_values.collect do |sample| ((sample - @digital_minimum) * (@physical_maximum - @physical_minimum) / (@digital_maximum - @digital_minimum)) + @physical_minimum rescue StandardError From 13528568ab6f4bbb38c27d43618e51e18f968cc1 Mon Sep 17 00:00:00 2001 From: Adam Pallozzi Date: Sat, 23 Aug 2025 23:06:22 +1000 Subject: [PATCH 06/13] Add example scripts and update .gitignore. This commit: 1. Add example scripts for large file handling 2. Add utility scripts for data generation and inspection 3. Update .gitignore to exclude example output and data files --- .gitignore | 5 ++ example/create_large_edf_from_file.rb | 109 ++++++++++++++++++++++++++ example/generate_test_data.rb | 60 ++++++++++++++ example/peek_test_data.rb | 25 ++++++ 4 files changed, 199 insertions(+) create mode 100755 example/create_large_edf_from_file.rb create mode 100755 example/generate_test_data.rb create mode 100755 example/peek_test_data.rb diff --git a/.gitignore b/.gitignore index 7550f7d..793fb9a 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,8 @@ mkmf.log # OS generated files # .DS_Store + +# Example output and data files +example/*.edf +example/*.zip +example/*.json.gz \ No newline at end of file diff --git a/example/create_large_edf_from_file.rb b/example/create_large_edf_from_file.rb new file mode 100755 index 0000000..ee1408d --- /dev/null +++ b/example/create_large_edf_from_file.rb @@ -0,0 +1,109 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "edfize" +require "json" +require "zlib" + +# Input and output paths +input_path = File.join(File.dirname(__FILE__), "test_data.json.gz") +output_path = File.join(File.dirname(__FILE__), "large_output_from_file.edf") + +begin + # Create a new EDF file in memory + edf = Edfize::Edf.create do |e| + e.local_patient_identification = "File Import Test" + e.local_recording_identification = "Streaming From File Example" + e.start_date_of_recording = Time.now.strftime("%d.%m.%y") + e.start_time_of_recording = Time.now.strftime("%H.%M.%S") + e.duration_of_a_data_record = 1 # Each data record is 1 second + end + + # Create a new signal + signal = Edfize::Signal.new + signal.label = "Imported Signal" + signal.transducer_type = "Test Signal" + signal.physical_dimension = "mV" + signal.physical_minimum = -500.0 + signal.physical_maximum = 500.0 + signal.digital_minimum = -32768 # Standard 16-bit range + signal.digital_maximum = 32767 + signal.prefiltering = "None" + signal.samples_per_data_record = 256 # 256 Hz sampling rate + signal.reserved_area = " " * 32 + + # Get total samples by counting lines in the gzipped file + total_samples = 0 + Zlib::GzipReader.open(input_path) do |gz| + # Skip opening bracket + gz.readline + + # Count lines (excluding brackets) + while (line = gz.readline) + break if line.strip == "]" + total_samples += 1 + end + end + + puts "Found #{total_samples} values in file" + + # Create an enumerator to read the file line by line + file_enumerator = Enumerator.new do |yielder| + Zlib::GzipReader.open(input_path) do |gz| + # Skip opening bracket + gz.readline + + # Read values until we hit the closing bracket + while (line = gz.readline) + break if line.strip == "]" + # Parse the value (remove trailing comma if present) + value = line.strip.sub(/,$/, "").to_f + yielder << value + end + end + end + + # Set up the streaming generator to read from file + signal.stream_values(total_samples, 10000) do |batch_size| + batch = [] + + # Take batch_size values from our enumerator + file_enumerator.take(batch_size).each do |value| + batch << value + end + + batch + end + + # Add the signal to the EDF + edf.signals << signal + + # Write the EDF file (as continuous EDF+) + puts "Writing EDF file to: #{output_path}" + puts "Total samples: #{total_samples}" + puts "Expected file size: ~#{(total_samples * 2 + 2048) / 1024 / 1024}MB" + + start_time = Time.now + edf.write(output_path, is_continuous: true) + end_time = Time.now + + puts "\nFile written successfully!" + puts "Time taken: #{(end_time - start_time).round(2)} seconds" + puts "Actual file size: #{File.size(output_path) / 1024 / 1024}MB" + + # Verify by reading back (just the header and first few values) + puts "\nVerifying written EDF file..." + verification_edf = Edfize::Edf.new(output_path) + verification_edf.load_signal_preview + + puts "\nSignal Information:" + puts "Label: #{verification_edf.signals[0].label}" + puts "Physical Dimension: #{verification_edf.signals[0].physical_dimension}" + puts "Sampling Rate: #{verification_edf.signals[0].samples_per_data_record} Hz" + puts "\nFirst few values (preview):" + test_signal = verification_edf.signals.find { |s| s.label == "Imported Signal" } + puts "Physical values: #{test_signal.load_preview(5).inspect}" +rescue StandardError => e + puts "Error: #{e.message}" + puts e.backtrace +end \ No newline at end of file diff --git a/example/generate_test_data.rb b/example/generate_test_data.rb new file mode 100755 index 0000000..cb5bd0d --- /dev/null +++ b/example/generate_test_data.rb @@ -0,0 +1,60 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "json" +require "zlib" + +# Parameters for generating the sine wave +TOTAL_SAMPLES = 3_200_000 # 3.2 million values +SAMPLE_RATE = 256.0 # 256 Hz +FREQUENCY = 10.0 # 10 Hz sine wave +AMPLITUDE = 100.0 # 100 mV + +output_path = File.join(File.dirname(__FILE__), "test_data.json.gz") + +puts "Generating #{TOTAL_SAMPLES} values..." +start_time = Time.now + +# Open a gzipped file for writing +Zlib::GzipWriter.open(output_path) do |gz| + # Write opening bracket + gz.write("[\n") + + # Generate and write values in batches to avoid memory issues + batch_size = 10000 + batches = (TOTAL_SAMPLES.to_f / batch_size).ceil + + batches.times do |batch_index| + start_index = batch_index * batch_size + current_batch_size = [batch_size, TOTAL_SAMPLES - start_index].min + + # Generate batch of values + values = current_batch_size.times.map do |i| + sample_index = start_index + i + t = sample_index / SAMPLE_RATE + AMPLITUDE * Math.sin(2 * Math::PI * FREQUENCY * t) + end + + # Write values with commas + values.each_with_index do |value, i| + gz.write(value.to_s) + # Add comma unless this is the last value of the last batch + gz.write(",\n") unless batch_index == batches - 1 && i == values.size - 1 + end + + # Progress update + if (batch_index + 1) % 10 == 0 + percent = ((batch_index + 1) * 100.0 / batches).round(1) + puts "Progress: #{percent}% (#{batch_index + 1}/#{batches} batches)" + end + end + + # Write closing bracket + gz.write("\n]") +end + +end_time = Time.now +file_size = File.size(output_path) +puts "\nDone!" +puts "Time taken: #{(end_time - start_time).round(2)} seconds" +puts "File size: #{(file_size.to_f / 1024 / 1024).round(2)} MB" diff --git a/example/peek_test_data.rb b/example/peek_test_data.rb new file mode 100755 index 0000000..18ba660 --- /dev/null +++ b/example/peek_test_data.rb @@ -0,0 +1,25 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "zlib" + +input_path = File.join(File.dirname(__FILE__), "test_data.json.gz") +num_values_to_show = 20 + +puts "First #{num_values_to_show} values from #{input_path}:" +puts "-" * 50 + +Zlib::GzipReader.open(input_path) do |gz| + # Skip opening bracket + gz.readline + + # Read and print first N values + num_values_to_show.times do |i| + line = gz.readline.strip + # Remove trailing comma for all but the last value + line = line.sub(/,$/, "") + puts "#{i + 1}: #{line}" + end + + puts "\n... (#{File.size(input_path) / 1024 / 1024}MB compressed file continues)" +end From 0e58217f2141bb3fc299be196be85c1d8386a13a Mon Sep 17 00:00:00 2001 From: Adam Pallozzi Date: Sat, 23 Aug 2025 23:08:52 +1000 Subject: [PATCH 07/13] Add JSON file example. This commit: 1. Add example script for reading values from JSON file 2. Add sample JSON file for testing 3. Fix data record handling for small datasets 4. Add detailed signal verification output --- example/create_edf_from_json.rb | 133 ++++++++++++++++++++++++++++++++ example/values.json | 7 ++ 2 files changed, 140 insertions(+) create mode 100755 example/create_edf_from_json.rb create mode 100644 example/values.json diff --git a/example/create_edf_from_json.rb b/example/create_edf_from_json.rb new file mode 100755 index 0000000..a910d45 --- /dev/null +++ b/example/create_edf_from_json.rb @@ -0,0 +1,133 @@ +#!/usr/bin/env ruby + +require "bundler/setup" +require "edfize" +require "json" +require "benchmark" + +# Configuration +OUTPUT_EDF_FILE = "./example/output_from_json.edf" +JSON_FILE = "./example/values.json" # Your JSON file with array of values +SIGNAL_LABEL = "JSON Signal" +PHYSICAL_DIMENSION = "mV" +SAMPLING_RATE = 256 # Hz +PHYSICAL_MIN = -100.0 +PHYSICAL_MAX = 100.0 +DIGITAL_MIN = -32768 +DIGITAL_MAX = 32767 +BATCH_SIZE = 1000 # Number of values to process at a time + +puts "Reading values from: #{JSON_FILE}" + +def create_value_enumerator(json_file) + Enumerator.new do |yielder| + # Read the file in chunks to handle large files efficiently + File.open(json_file) do |file| + # Skip initial whitespace and opening bracket + file.each_char { |c| break if c == '[' } + + buffer = "" + in_number = false + + # Process the file character by character + file.each_char do |char| + case char + when /[\d.-]/ # Part of a number + buffer << char + in_number = true + when /[\s,\]]/ # Delimiter + if in_number + # Convert and yield the number + value = buffer.strip.to_f + yielder << value + buffer = "" + in_number = false + end + end + end + + # Handle the last number if any + if in_number + value = buffer.strip.to_f + yielder << value + end + end + end +end + +# Create enumerator for counting +puts "Counting total values..." +value_stream_enumerator = create_value_enumerator(JSON_FILE) +total_values = 0 +begin + while value_stream_enumerator.next + total_values += 1 + print "\rProcessed #{total_values} values..." if total_values % 100_000 == 0 + end +rescue StopIteration +end +puts "\nFound #{total_values} values in file" + +# Create new enumerator for processing +value_stream_enumerator = create_value_enumerator(JSON_FILE) + +# Create EDF file +edf = Edfize::Edf.create(OUTPUT_EDF_FILE) do |e| + e.local_patient_identification = "JSON Data Import" + e.local_recording_identification = "JSON Signal Test" + e.start_date_of_recording = Time.now.strftime("%d.%m.%y") + e.start_time_of_recording = Time.now.strftime("%H.%M.%S") + e.duration_of_a_data_record = 1 # 1 second data records +end + +# Add a signal +signal = Edfize::Signal.new +signal.label = SIGNAL_LABEL +signal.transducer_type = "JSON Import" +signal.physical_dimension = PHYSICAL_DIMENSION +signal.physical_minimum = PHYSICAL_MIN +signal.physical_maximum = PHYSICAL_MAX +signal.digital_minimum = DIGITAL_MIN +signal.digital_maximum = DIGITAL_MAX +signal.prefiltering = "None" +signal.samples_per_data_record = SAMPLING_RATE +edf.signals << signal + +puts "Writing EDF file to: #{OUTPUT_EDF_FILE}" +puts "Total samples: #{total_values}" +puts "Expected file size: ~#{(total_values * Edfize::Edf::SIZE_OF_SAMPLE_IN_BYTES / (1024.0 * 1024.0)).round(0)}MB" + +time_taken = Benchmark.realtime do + # Calculate number of data records needed + data_records = (total_values.to_f / SAMPLING_RATE).ceil + edf.number_of_data_records = data_records + puts "Data records needed: #{data_records}" + + # The signal.stream_values block will be called repeatedly to get batches of values + signal.stream_values(total_values, BATCH_SIZE) do |batch_size_requested| + # Use the enumerator to take the next batch of values + value_stream_enumerator.take(batch_size_requested) + end + edf.write +end + +puts "\nFile written successfully!" +puts "Time taken: #{time_taken.round(1)} seconds" +puts "Actual file size: #{(File.size(OUTPUT_EDF_FILE) / (1024.0 * 1024.0)).round(0)}MB" + +# Verification (optional) +puts "\nVerifying written EDF file..." +verification_edf = Edfize::Edf.new(OUTPUT_EDF_FILE) +verification_edf.load_signals + +puts "\nSignal Information:" +test_signal = verification_edf.signals.find { |s| s.label == SIGNAL_LABEL } +puts "Label: #{test_signal.label}" +puts "Physical Dimension: #{test_signal.physical_dimension}" +puts "Sampling Rate: #{test_signal.samples_per_data_record} Hz" +puts "Total Values: #{test_signal.digital_values.size}" +puts "Physical Range: #{test_signal.physical_minimum} to #{test_signal.physical_maximum} #{test_signal.physical_dimension}" + +puts "\nFirst few values:" +puts "Digital values: #{test_signal.digital_values[0..4]}" +puts "Physical values: #{test_signal.physical_values[0..4]}" diff --git a/example/values.json b/example/values.json new file mode 100644 index 0000000..e640cbf --- /dev/null +++ b/example/values.json @@ -0,0 +1,7 @@ +[ + 0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, + 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, + 20.0, 21.0, 22.0, 23.0, 24.0, 25.0, 26.0, 27.0, 28.0, 29.0, + 30.0, 31.0, 32.0, 33.0, 34.0, 35.0, 36.0, 37.0, 38.0, 39.0, + 40.0, 41.0, 42.0, 43.0, 44.0, 45.0, 46.0, 47.0, 48.0, 49.0 +] From c67f6d80c458b9209195da9177bd85632ce5fb39 Mon Sep 17 00:00:00 2001 From: Adam Pallozzi Date: Mon, 25 Aug 2025 14:52:07 +1000 Subject: [PATCH 08/13] Update an example --- example/create_edf_from_json.rb | 89 ++++++++++++++++++++++++++------- 1 file changed, 70 insertions(+), 19 deletions(-) diff --git a/example/create_edf_from_json.rb b/example/create_edf_from_json.rb index a910d45..0ffe3ba 100755 --- a/example/create_edf_from_json.rb +++ b/example/create_edf_from_json.rb @@ -80,17 +80,19 @@ def create_value_enumerator(json_file) e.duration_of_a_data_record = 1 # 1 second data records end +sampling_rate = 1 + # Add a signal signal = Edfize::Signal.new -signal.label = SIGNAL_LABEL +signal.label = "spo2" signal.transducer_type = "JSON Import" -signal.physical_dimension = PHYSICAL_DIMENSION -signal.physical_minimum = PHYSICAL_MIN -signal.physical_maximum = PHYSICAL_MAX -signal.digital_minimum = DIGITAL_MIN -signal.digital_maximum = DIGITAL_MAX -signal.prefiltering = "None" -signal.samples_per_data_record = SAMPLING_RATE +signal.physical_dimension = "" +signal.physical_minimum = 0 +signal.physical_maximum = 100 +signal.digital_minimum = 0 +signal.digital_maximum = 100 +signal.prefiltering = "" +signal.samples_per_data_record = sampling_rate edf.signals << signal puts "Writing EDF file to: #{OUTPUT_EDF_FILE}" @@ -99,7 +101,54 @@ def create_value_enumerator(json_file) time_taken = Benchmark.realtime do # Calculate number of data records needed - data_records = (total_values.to_f / SAMPLING_RATE).ceil + data_records = (total_values.to_f / sampling_rate).ceil + edf.number_of_data_records = data_records + puts "Data records needed: #{data_records}" + + # The signal.stream_values block will be called repeatedly to get batches of values + signal.stream_values(total_values, BATCH_SIZE) do |batch_size_requested| + # Use the enumerator to take the next batch of values + value_stream_enumerator.take(batch_size_requested) + end + edf.write +end + + +# HR +# Create enumerator for counting +puts "Counting total values..." +value_stream_enumerator = create_value_enumerator("./tmp/hr.json") +total_values = 0 +begin + while value_stream_enumerator.next + total_values += 1 + print "\rProcessed #{total_values} values..." if total_values % 100_000 == 0 + end +rescue StopIteration +end +puts "\nFound #{total_values} values in file" + +# Create new enumerator for processing +value_stream_enumerator = create_value_enumerator("./tmp/hr.json") + +sampling_rate = 1 + +# Add a signal +signal = Edfize::Signal.new +signal.label = "pulse" +signal.transducer_type = "JSON Import" +signal.physical_dimension = "" +signal.physical_minimum = 0 +signal.physical_maximum = 100 +signal.digital_minimum = 0 +signal.digital_maximum = 100 +signal.prefiltering = "" +signal.samples_per_data_record = sampling_rate +edf.signals << signal + +time_taken = Benchmark.realtime do + # Calculate number of data records needed + data_records = (total_values.to_f / sampling_rate).ceil edf.number_of_data_records = data_records puts "Data records needed: #{data_records}" @@ -111,6 +160,7 @@ def create_value_enumerator(json_file) edf.write end + puts "\nFile written successfully!" puts "Time taken: #{time_taken.round(1)} seconds" puts "Actual file size: #{(File.size(OUTPUT_EDF_FILE) / (1024.0 * 1024.0)).round(0)}MB" @@ -121,13 +171,14 @@ def create_value_enumerator(json_file) verification_edf.load_signals puts "\nSignal Information:" -test_signal = verification_edf.signals.find { |s| s.label == SIGNAL_LABEL } -puts "Label: #{test_signal.label}" -puts "Physical Dimension: #{test_signal.physical_dimension}" -puts "Sampling Rate: #{test_signal.samples_per_data_record} Hz" -puts "Total Values: #{test_signal.digital_values.size}" -puts "Physical Range: #{test_signal.physical_minimum} to #{test_signal.physical_maximum} #{test_signal.physical_dimension}" - -puts "\nFirst few values:" -puts "Digital values: #{test_signal.digital_values[0..4]}" -puts "Physical values: #{test_signal.physical_values[0..4]}" +verification_edf.signals.each do |signal| + puts "Label: #{signal.label}" + puts "Physical Dimension: #{signal.physical_dimension}" + puts "Sampling Rate: #{signal.samples_per_data_record} Hz" + puts "Total Values: #{signal.digital_values.size}" + puts "Physical Range: #{signal.physical_minimum} to #{signal.physical_maximum} #{signal.physical_dimension}" + + puts "\nFirst few values:" + puts "Digital values: #{signal.digital_values[0..4]}" + puts "Physical values: #{signal.physical_values[0..4]}" +end From 94877fadcea06bda264d650ca0772576a3cec5f0 Mon Sep 17 00:00:00 2001 From: Adam Pallozzi Date: Tue, 26 Aug 2025 12:17:21 +1000 Subject: [PATCH 09/13] Update the API for finding and updating signals of an EDF file. --- lib/edfize/edf.rb | 3 +- lib/edfize/signal_array.rb | 29 +++++++ test/edf_test.rb | 168 +++++++++++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 lib/edfize/signal_array.rb diff --git a/lib/edfize/edf.rb b/lib/edfize/edf.rb index 1e22753..5b2fb04 100644 --- a/lib/edfize/edf.rb +++ b/lib/edfize/edf.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "edfize/signal" +require "edfize/signal_array" require "date" module Edfize @@ -48,7 +49,7 @@ def self.create(filename = nil) def initialize(filename, initialize_empty: false) @filename = filename - @signals = [] + @signals = SignalArray.new @is_new_file = initialize_empty if initialize_empty diff --git a/lib/edfize/signal_array.rb b/lib/edfize/signal_array.rb new file mode 100644 index 0000000..1792859 --- /dev/null +++ b/lib/edfize/signal_array.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Edfize + # Extends Array to provide signal-specific functionality + class SignalArray < Array + def find_by_label(label) + label_str = label.to_s + find { |signal| signal.label.strip.downcase == label_str.strip.downcase } + end + + def find(*args, &block) + if args.empty? && !block_given? + super + elsif args.size == 1 && !block_given? + find_by_label(args.first) + else + super + end + end + + def delete(label) + signal = find_by_label(label) + return false unless signal + + super(signal) + true + end + end +end diff --git a/test/edf_test.rb b/test/edf_test.rb index 4f53ecc..f3c71e4 100644 --- a/test/edf_test.rb +++ b/test/edf_test.rb @@ -302,6 +302,174 @@ def test_should_rewrite_start_time_of_recording file.unlink # Deletes temporary file. end + def test_find_signal + # Create a new EDF file with test signals + edf = Edfize::Edf.create do |e| + e.local_patient_identification = "Test Patient" + e.local_recording_identification = "Test Recording" + e.start_date_of_recording = Time.now.strftime("%d.%m.%y") + e.start_time_of_recording = Time.now.strftime("%H.%M.%S") + e.duration_of_a_data_record = 1 + end + + # Create a PPG signal + ppg_signal = Edfize::Signal.new + ppg_signal.label = "ppg" + ppg_signal.transducer_type = "Test Signal" + ppg_signal.physical_dimension = "mV" + ppg_signal.physical_minimum = -100.0 + ppg_signal.physical_maximum = 100.0 + ppg_signal.digital_minimum = -32768 + ppg_signal.digital_maximum = 32767 + ppg_signal.prefiltering = "None" + ppg_signal.samples_per_data_record = 256 + ppg_signal.reserved_area = " " * 32 + edf.signals << ppg_signal + + # Create another signal + ecg_signal = Edfize::Signal.new + ecg_signal.label = "ecg" + ecg_signal.transducer_type = "Test Signal" + ecg_signal.physical_dimension = "mV" + ecg_signal.physical_minimum = -100.0 + ecg_signal.physical_maximum = 100.0 + ecg_signal.digital_minimum = -32768 + ecg_signal.digital_maximum = 32767 + ecg_signal.prefiltering = "None" + ecg_signal.samples_per_data_record = 256 + ecg_signal.reserved_area = " " * 32 + edf.signals << ecg_signal + + # Test finding signals + found_signal = edf.signals.find_by_label(:ppg) + assert_equal "ppg", found_signal.label + assert_equal ppg_signal, found_signal + + found_signal = edf.signals.find_by_label(:ecg) + assert_equal "ecg", found_signal.label + assert_equal ecg_signal, found_signal + + # Test finding non-existent signal + found_signal = edf.signals.find_by_label(:xyz) + assert_nil found_signal + end + + def test_delete_signal + # Create a new EDF file with test signals + edf = Edfize::Edf.create do |e| + e.local_patient_identification = "Test Patient" + e.local_recording_identification = "Test Recording" + e.start_date_of_recording = Time.now.strftime("%d.%m.%y") + e.start_time_of_recording = Time.now.strftime("%H.%M.%S") + e.duration_of_a_data_record = 1 + end + + # Create a PPG signal + ppg_signal = Edfize::Signal.new + ppg_signal.label = "ppg" + ppg_signal.transducer_type = "Test Signal" + ppg_signal.physical_dimension = "mV" + ppg_signal.physical_minimum = -100.0 + ppg_signal.physical_maximum = 100.0 + ppg_signal.digital_minimum = -32768 + ppg_signal.digital_maximum = 32767 + ppg_signal.prefiltering = "None" + ppg_signal.samples_per_data_record = 256 + ppg_signal.reserved_area = " " * 32 + edf.signals << ppg_signal + + # Create another signal + ecg_signal = Edfize::Signal.new + ecg_signal.label = "ecg" + ecg_signal.transducer_type = "Test Signal" + ecg_signal.physical_dimension = "mV" + ecg_signal.physical_minimum = -100.0 + ecg_signal.physical_maximum = 100.0 + ecg_signal.digital_minimum = -32768 + ecg_signal.digital_maximum = 32767 + ecg_signal.prefiltering = "None" + ecg_signal.samples_per_data_record = 256 + ecg_signal.reserved_area = " " * 32 + edf.signals << ecg_signal + + # Test deleting signals + assert_equal 2, edf.signals.size + assert edf.signals.delete(:ppg) + assert_equal 1, edf.signals.size + assert_nil edf.signals.find_by_label(:ppg) + assert_equal "ecg", edf.signals.first.label + + # Test deleting non-existent signal + refute edf.signals.delete(:xyz) + assert_equal 1, edf.signals.size + end + + def test_write_modified_edf_file + # Create a new EDF file with test signals + edf = Edfize::Edf.create do |e| + e.local_patient_identification = "Test Patient" + e.local_recording_identification = "Test Recording" + e.start_date_of_recording = Time.now.strftime("%d.%m.%y") + e.start_time_of_recording = Time.now.strftime("%H.%M.%S") + e.duration_of_a_data_record = 1 + end + + # Create a PPG signal + ppg_signal = Edfize::Signal.new + ppg_signal.label = "ppg" + ppg_signal.transducer_type = "Test Signal" + ppg_signal.physical_dimension = "mV" + ppg_signal.physical_minimum = -100.0 + ppg_signal.physical_maximum = 100.0 + ppg_signal.digital_minimum = -32768 + ppg_signal.digital_maximum = 32767 + ppg_signal.prefiltering = "None" + ppg_signal.samples_per_data_record = 4 + ppg_signal.reserved_area = " " * 32 + ppg_signal.digital_values = [0, 1, 2, 3] + edf.signals << ppg_signal + + # Create another signal + ecg_signal = Edfize::Signal.new + ecg_signal.label = "ecg" + ecg_signal.transducer_type = "Test Signal" + ecg_signal.physical_dimension = "mV" + ecg_signal.physical_minimum = -100.0 + ecg_signal.physical_maximum = 100.0 + ecg_signal.digital_minimum = -32768 + ecg_signal.digital_maximum = 32767 + ecg_signal.prefiltering = "None" + ecg_signal.samples_per_data_record = 4 + ecg_signal.reserved_area = " " * 32 + ecg_signal.digital_values = [4, 5, 6, 7] + edf.signals << ecg_signal + + # Create a temporary file for writing + output_file = Tempfile.new(["test-modified", ".edf"]) + begin + # Modify the signals + edf.signals.delete(:ppg) + ecg_signal = edf.signals.find_by_label(:ecg) + ecg_signal.digital_values = [8, 9, 10, 11] + + # Write the modified EDF file + edf.write(output_file.path) + + # Read back the written file + written_edf = Edfize::Edf.new(output_file.path) + written_edf.load_signals + + # Verify the signals + assert_equal 2, written_edf.signals.size # 1 signal + 1 EDF Annotations signal + assert_nil written_edf.signals.find_by_label(:ppg) + written_ecg = written_edf.signals.find_by_label(:ecg) + assert_equal [8, 9, 10, 11], written_ecg.digital_values + ensure + output_file.close + output_file.unlink + end + end + def test_write_edf_file # Load an existing EDF file original_edf = Edfize::Edf.new("test/support/simulated-01.edf") From ccacd2ced5a1c748c1c2bb9f8115b3db909ab379 Mon Sep 17 00:00:00 2001 From: Adam Pallozzi Date: Tue, 26 Aug 2025 12:27:13 +1000 Subject: [PATCH 10/13] Make the EDF Annotations signal optional when writing a new file --- lib/edfize/edf.rb | 15 ++++++--- test/edf_test.rb | 81 ++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 86 insertions(+), 10 deletions(-) diff --git a/lib/edfize/edf.rb b/lib/edfize/edf.rb index 5b2fb04..86935c0 100644 --- a/lib/edfize/edf.rb +++ b/lib/edfize/edf.rb @@ -390,7 +390,8 @@ def ensure_annotations_signal # Writes the EDF file to the specified path # @param output_path [String] The path where the EDF file should be written # @param is_continuous [Boolean] Whether this is a continuous (EDF+C) or discontinuous (EDF+D) recording - def write(output_path = nil, is_continuous: true) + # @param add_edf_annotations [Boolean] Whether to add an EDF Annotations signal if none exists + def write(output_path = nil, is_continuous: true, add_edf_annotations: false) # Use provided path or stored filename target_path = output_path || @filename raise "No output path specified" if target_path.nil? @@ -401,14 +402,18 @@ def write(output_path = nil, is_continuous: true) # Update number of signals @number_of_signals = @signals.size - # Ensure we have at least one EDF Annotations signal for time-keeping - ensure_annotations_signal + # Ensure we have at least one EDF Annotations signal for time-keeping if requested + ensure_annotations_signal if add_edf_annotations # Calculate and update header size @number_of_bytes_in_header = calculate_header_size - # Set EDF+ format in reserved area - @reserved = "EDF+#{is_continuous ? "C" : "D"}".ljust(RESERVED_SIZE) + # Set EDF+ format in reserved area if we have annotations + @reserved = if @signals.any? { |s| s.label == "EDF Annotations" } + "EDF+#{is_continuous ? "C" : "D"}".ljust(RESERVED_SIZE) + else + " " * RESERVED_SIZE + end # Calculate number of data records if not set if @number_of_data_records == 0 && !@signals.empty? diff --git a/test/edf_test.rb b/test/edf_test.rb index f3c71e4..2cb706e 100644 --- a/test/edf_test.rb +++ b/test/edf_test.rb @@ -63,8 +63,8 @@ def test_create_edf_from_values # Add the signal to the EDF edf.signals << signal - # Write the EDF file - edf.write(output_file.path, is_continuous: true) + # Write the EDF file with annotations + edf.write(output_file.path, is_continuous: true, add_edf_annotations: true) # Read back and verify verification_edf = Edfize::Edf.new(output_file.path) @@ -460,7 +460,7 @@ def test_write_modified_edf_file written_edf.load_signals # Verify the signals - assert_equal 2, written_edf.signals.size # 1 signal + 1 EDF Annotations signal + assert_equal 1, written_edf.signals.size # Just the ECG signal assert_nil written_edf.signals.find_by_label(:ppg) written_ecg = written_edf.signals.find_by_label(:ecg) assert_equal [8, 9, 10, 11], written_ecg.digital_values @@ -470,6 +470,77 @@ def test_write_modified_edf_file end end + def test_write_edf_with_optional_annotations + # Create a new EDF file with test signals + edf = Edfize::Edf.create do |e| + e.local_patient_identification = "Test Patient" + e.local_recording_identification = "Test Recording" + e.start_date_of_recording = Time.now.strftime("%d.%m.%y") + e.start_time_of_recording = Time.now.strftime("%H.%M.%S") + e.duration_of_a_data_record = 1 + end + + # Create a test signal + signal = Edfize::Signal.new + signal.label = "test" + signal.transducer_type = "Test Signal" + signal.physical_dimension = "mV" + signal.physical_minimum = -100.0 + signal.physical_maximum = 100.0 + signal.digital_minimum = -32768 + signal.digital_maximum = 32767 + signal.prefiltering = "None" + signal.samples_per_data_record = 4 + signal.reserved_area = " " * 32 + signal.digital_values = [0, 1, 2, 3] + edf.signals << signal + + # Test writing without annotations + output_file_no_annotations = Tempfile.new(["test-no-annotations", ".edf"]) + begin + edf.write(output_file_no_annotations.path, add_edf_annotations: false) + written_edf = Edfize::Edf.new(output_file_no_annotations.path) + written_edf.load_signals + + # Verify no EDF Annotations signal was added + assert_equal 1, written_edf.signals.size + assert_nil written_edf.signals.find_by_label("EDF Annotations") + assert_equal " " * 44, written_edf.reserved # RESERVED_SIZE is 44 + ensure + output_file_no_annotations.close + output_file_no_annotations.unlink + end + + # Test writing with annotations + output_file_with_annotations = Tempfile.new(["test-with-annotations", ".edf"]) + begin + edf.write(output_file_with_annotations.path, add_edf_annotations: true) + written_edf = Edfize::Edf.new(output_file_with_annotations.path) + written_edf.load_signals + + # Verify EDF Annotations signal was added + assert_equal 2, written_edf.signals.size + annotations_signal = written_edf.signals.find_by_label("EDF Annotations") + refute_nil annotations_signal + assert_match(/^EDF\+[CD]/, written_edf.reserved.strip) + + # Verify annotations signal properties + assert_equal "EDF Annotations", annotations_signal.label + assert_equal -32_768, annotations_signal.digital_minimum + assert_equal 32_767, annotations_signal.digital_maximum + assert_equal -1, annotations_signal.physical_minimum + assert_equal 1, annotations_signal.physical_maximum + assert_equal 60, annotations_signal.samples_per_data_record + assert_equal "", annotations_signal.transducer_type.strip + assert_equal "", annotations_signal.physical_dimension.strip + assert_equal "", annotations_signal.prefiltering.strip + assert_equal "", annotations_signal.reserved_area.strip + ensure + output_file_with_annotations.close + output_file_with_annotations.unlink + end + end + def test_write_edf_file # Load an existing EDF file original_edf = Edfize::Edf.new("test/support/simulated-01.edf") @@ -477,8 +548,8 @@ def test_write_edf_file # Create a temporary file for writing output_file = Tempfile.new(["test-write", ".edf"]) begin - # Write the EDF file - original_edf.write(output_file.path) + # Write the EDF file with annotations + original_edf.write(output_file.path, add_edf_annotations: true) # Read back the written file written_edf = Edfize::Edf.new(output_file.path) From 183989ef98c0747a33970740a6f3d5acb4dee2ae Mon Sep 17 00:00:00 2001 From: Adam Pallozzi Date: Tue, 26 Aug 2025 14:32:29 +1000 Subject: [PATCH 11/13] Update the create example --- example/create_edf.rb | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/example/create_edf.rb b/example/create_edf.rb index 57a7121..ac64b5f 100755 --- a/example/create_edf.rb +++ b/example/create_edf.rb @@ -21,17 +21,17 @@ signal = Edfize::Signal.new signal.label = "Example Signal" signal.transducer_type = "Custom Sensor" - signal.physical_dimension = "mV" - signal.physical_minimum = -500.0 - signal.physical_maximum = 500.0 - signal.digital_minimum = -32768 # Standard 16-bit range - signal.digital_maximum = 32767 - signal.prefiltering = "None" - signal.samples_per_data_record = 256 # 256 Hz sampling rate + signal.physical_dimension = "" + signal.physical_minimum = 0 + signal.physical_maximum = 255 + signal.digital_minimum = 0 + signal.digital_maximum = 255 + signal.prefiltering = "" + signal.samples_per_data_record = 125 # 256 Hz sampling rate signal.reserved_area = " " * 32 # Required blank space # Your array of values (example values here) - physical_values = [-100.0, 0.0, 100.0, 200.0, 300.0, 250.0, 150.0, 50.0] + physical_values = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] # Pad with zeros to match samples_per_data_record physical_values += [0.0] * (signal.samples_per_data_record - physical_values.length) From 8c5b36194401419c837c289db1c50cff422ac087 Mon Sep 17 00:00:00 2001 From: Adam Pallozzi Date: Wed, 27 Aug 2025 11:53:51 +1000 Subject: [PATCH 12/13] Make sure the signal data is written with correct interleaving --- lib/edfize/edf.rb | 23 ++++++++++-- test/edf_test.rb | 89 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/lib/edfize/edf.rb b/lib/edfize/edf.rb index 86935c0..efd9e44 100644 --- a/lib/edfize/edf.rb +++ b/lib/edfize/edf.rb @@ -354,9 +354,26 @@ def write_signal_headers(file) end def write_data_records(file) - @signals.each do |signal| - # Use the signal's write method which handles both streaming and regular modes - signal.write_values_to(file) + # Calculate total samples per data record + total_samples_per_data_record = @signals.collect(&:samples_per_data_record).inject(:+).to_i + + # For each data record + (0...@number_of_data_records).each do |data_record_index| + # For each signal + @signals.each do |signal| + # Get the values for this data record + start_index = data_record_index * signal.samples_per_data_record + end_index = start_index + signal.samples_per_data_record + values = signal.digital_values[start_index...end_index] || [] + + # Pad with nil if we don't have enough values + while values.size < signal.samples_per_data_record + values << nil + end + + # Write the values + file.write(values.pack("s<*")) + end end end diff --git a/test/edf_test.rb b/test/edf_test.rb index 2cb706e..356d1eb 100644 --- a/test/edf_test.rb +++ b/test/edf_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require "test_helper" +require_relative "test_helper" require "tempfile" # Test to assure EDFs can be loaded and updated. @@ -587,4 +587,91 @@ def test_write_edf_file output_file.unlink end end + + def test_signal_subset_preservation + # Load the original EDF file + original_edf = Edfize::Edf.new("example/TestPPG.edf") + original_edf.load_signals + + # Create a temporary file for the modified EDF + output_file = Tempfile.new(["test-ppg-subset", ".edf"]) + begin + # Store the original values of the signals we want to keep + signals_to_keep = ["ppg", "spo2", "pulse"] + original_values = {} + + signals_to_keep.each do |label| + signal = original_edf.signals.find_by_label(label) + if signal + original_values[label] = { + digital_values: signal.digital_values.dup, + physical_values: signal.physical_values.dup, + physical_minimum: signal.physical_minimum, + physical_maximum: signal.physical_maximum, + digital_minimum: signal.digital_minimum, + digital_maximum: signal.digital_maximum, + samples_per_data_record: signal.samples_per_data_record + } + end + end + + # Delete all signals except the ones we want to keep + signals_to_delete = original_edf.signals.to_a.reject do |signal| + signals_to_keep.include?(signal.label.strip.downcase) + end + signals_to_delete.each do |signal| + original_edf.signals.delete(signal.label) + end + + # Write the modified EDF file + original_edf.write(output_file.path) + + # Read back the modified file + modified_edf = Edfize::Edf.new(output_file.path) + modified_edf.load_signals + + # Verify the number of signals + assert_equal signals_to_keep.count { |label| original_values.key?(label) }, + modified_edf.signals.size, + "Modified EDF should only contain the kept signals" + + # Verify each signal's values match the original + signals_to_keep.each do |label| + next unless original_values[label] # Skip if signal wasn't in original file + + modified_signal = modified_edf.signals.find_by_label(label) + refute_nil modified_signal, "Signal #{label} should exist in modified file" + + # Compare digital values + assert_equal original_values[label][:digital_values], + modified_signal.digital_values, + "Digital values for #{label} should match original" + + # Compare physical values + assert_equal original_values[label][:physical_values], + modified_signal.physical_values, + "Physical values for #{label} should match original" + + # Compare signal properties + assert_equal original_values[label][:physical_minimum], + modified_signal.physical_minimum, + "Physical minimum for #{label} should match original" + assert_equal original_values[label][:physical_maximum], + modified_signal.physical_maximum, + "Physical maximum for #{label} should match original" + assert_equal original_values[label][:digital_minimum], + modified_signal.digital_minimum, + "Digital minimum for #{label} should match original" + assert_equal original_values[label][:digital_maximum], + modified_signal.digital_maximum, + "Digital maximum for #{label} should match original" + assert_equal original_values[label][:samples_per_data_record], + modified_signal.samples_per_data_record, + "Samples per data record for #{label} should match original" + end + ensure + output_file.close + output_file.unlink + end + end end \ No newline at end of file From 84027971aa4b96916cbcf28a557310e4ac952458 Mon Sep 17 00:00:00 2001 From: Adam Pallozzi Date: Fri, 29 Aug 2025 13:27:31 +1000 Subject: [PATCH 13/13] Remove support for streaming files when creating new EDFs --- example/Gemfile | 1 + example/create_edf_from_json.rb | 184 -------------------------- example/create_large_edf.rb | 25 ++-- example/create_large_edf_from_file.rb | 42 +++--- example/create_large_edf_from_ppg.rb | 138 ------------------- gems.rb | 1 + lib/edfize/edf.rb | 12 +- lib/edfize/signal.rb | 42 +----- test/edf_test.rb | 3 + test/support/spo2.json.gz | Bin 0 -> 54 bytes 10 files changed, 45 insertions(+), 403 deletions(-) delete mode 100755 example/create_edf_from_json.rb delete mode 100755 example/create_large_edf_from_ppg.rb create mode 100644 test/support/spo2.json.gz diff --git a/example/Gemfile b/example/Gemfile index 7201882..a4cff0c 100644 --- a/example/Gemfile +++ b/example/Gemfile @@ -2,6 +2,7 @@ source "https://rubygems.org" # Use local edfize gem gem "edfize", path: "/Users/adampallozzi/Code/gems/edfize" +gem "oj" # If you want to specify the version: # gem "edfize", "0.6.0", path: "/Users/adampallozzi/Code/gems/edfize" diff --git a/example/create_edf_from_json.rb b/example/create_edf_from_json.rb deleted file mode 100755 index 0ffe3ba..0000000 --- a/example/create_edf_from_json.rb +++ /dev/null @@ -1,184 +0,0 @@ -#!/usr/bin/env ruby - -require "bundler/setup" -require "edfize" -require "json" -require "benchmark" - -# Configuration -OUTPUT_EDF_FILE = "./example/output_from_json.edf" -JSON_FILE = "./example/values.json" # Your JSON file with array of values -SIGNAL_LABEL = "JSON Signal" -PHYSICAL_DIMENSION = "mV" -SAMPLING_RATE = 256 # Hz -PHYSICAL_MIN = -100.0 -PHYSICAL_MAX = 100.0 -DIGITAL_MIN = -32768 -DIGITAL_MAX = 32767 -BATCH_SIZE = 1000 # Number of values to process at a time - -puts "Reading values from: #{JSON_FILE}" - -def create_value_enumerator(json_file) - Enumerator.new do |yielder| - # Read the file in chunks to handle large files efficiently - File.open(json_file) do |file| - # Skip initial whitespace and opening bracket - file.each_char { |c| break if c == '[' } - - buffer = "" - in_number = false - - # Process the file character by character - file.each_char do |char| - case char - when /[\d.-]/ # Part of a number - buffer << char - in_number = true - when /[\s,\]]/ # Delimiter - if in_number - # Convert and yield the number - value = buffer.strip.to_f - yielder << value - buffer = "" - in_number = false - end - end - end - - # Handle the last number if any - if in_number - value = buffer.strip.to_f - yielder << value - end - end - end -end - -# Create enumerator for counting -puts "Counting total values..." -value_stream_enumerator = create_value_enumerator(JSON_FILE) -total_values = 0 -begin - while value_stream_enumerator.next - total_values += 1 - print "\rProcessed #{total_values} values..." if total_values % 100_000 == 0 - end -rescue StopIteration -end -puts "\nFound #{total_values} values in file" - -# Create new enumerator for processing -value_stream_enumerator = create_value_enumerator(JSON_FILE) - -# Create EDF file -edf = Edfize::Edf.create(OUTPUT_EDF_FILE) do |e| - e.local_patient_identification = "JSON Data Import" - e.local_recording_identification = "JSON Signal Test" - e.start_date_of_recording = Time.now.strftime("%d.%m.%y") - e.start_time_of_recording = Time.now.strftime("%H.%M.%S") - e.duration_of_a_data_record = 1 # 1 second data records -end - -sampling_rate = 1 - -# Add a signal -signal = Edfize::Signal.new -signal.label = "spo2" -signal.transducer_type = "JSON Import" -signal.physical_dimension = "" -signal.physical_minimum = 0 -signal.physical_maximum = 100 -signal.digital_minimum = 0 -signal.digital_maximum = 100 -signal.prefiltering = "" -signal.samples_per_data_record = sampling_rate -edf.signals << signal - -puts "Writing EDF file to: #{OUTPUT_EDF_FILE}" -puts "Total samples: #{total_values}" -puts "Expected file size: ~#{(total_values * Edfize::Edf::SIZE_OF_SAMPLE_IN_BYTES / (1024.0 * 1024.0)).round(0)}MB" - -time_taken = Benchmark.realtime do - # Calculate number of data records needed - data_records = (total_values.to_f / sampling_rate).ceil - edf.number_of_data_records = data_records - puts "Data records needed: #{data_records}" - - # The signal.stream_values block will be called repeatedly to get batches of values - signal.stream_values(total_values, BATCH_SIZE) do |batch_size_requested| - # Use the enumerator to take the next batch of values - value_stream_enumerator.take(batch_size_requested) - end - edf.write -end - - -# HR -# Create enumerator for counting -puts "Counting total values..." -value_stream_enumerator = create_value_enumerator("./tmp/hr.json") -total_values = 0 -begin - while value_stream_enumerator.next - total_values += 1 - print "\rProcessed #{total_values} values..." if total_values % 100_000 == 0 - end -rescue StopIteration -end -puts "\nFound #{total_values} values in file" - -# Create new enumerator for processing -value_stream_enumerator = create_value_enumerator("./tmp/hr.json") - -sampling_rate = 1 - -# Add a signal -signal = Edfize::Signal.new -signal.label = "pulse" -signal.transducer_type = "JSON Import" -signal.physical_dimension = "" -signal.physical_minimum = 0 -signal.physical_maximum = 100 -signal.digital_minimum = 0 -signal.digital_maximum = 100 -signal.prefiltering = "" -signal.samples_per_data_record = sampling_rate -edf.signals << signal - -time_taken = Benchmark.realtime do - # Calculate number of data records needed - data_records = (total_values.to_f / sampling_rate).ceil - edf.number_of_data_records = data_records - puts "Data records needed: #{data_records}" - - # The signal.stream_values block will be called repeatedly to get batches of values - signal.stream_values(total_values, BATCH_SIZE) do |batch_size_requested| - # Use the enumerator to take the next batch of values - value_stream_enumerator.take(batch_size_requested) - end - edf.write -end - - -puts "\nFile written successfully!" -puts "Time taken: #{time_taken.round(1)} seconds" -puts "Actual file size: #{(File.size(OUTPUT_EDF_FILE) / (1024.0 * 1024.0)).round(0)}MB" - -# Verification (optional) -puts "\nVerifying written EDF file..." -verification_edf = Edfize::Edf.new(OUTPUT_EDF_FILE) -verification_edf.load_signals - -puts "\nSignal Information:" -verification_edf.signals.each do |signal| - puts "Label: #{signal.label}" - puts "Physical Dimension: #{signal.physical_dimension}" - puts "Sampling Rate: #{signal.samples_per_data_record} Hz" - puts "Total Values: #{signal.digital_values.size}" - puts "Physical Range: #{signal.physical_minimum} to #{signal.physical_maximum} #{signal.physical_dimension}" - - puts "\nFirst few values:" - puts "Digital values: #{signal.digital_values[0..4]}" - puts "Physical values: #{signal.physical_values[0..4]}" -end diff --git a/example/create_large_edf.rb b/example/create_large_edf.rb index 5d81b5e..d75073c 100755 --- a/example/create_large_edf.rb +++ b/example/create_large_edf.rb @@ -29,24 +29,23 @@ signal.samples_per_data_record = 256 # 256 Hz sampling rate signal.reserved_area = " " * 32 - # Set up streaming for a large number of values - total_samples = 3_200_000 # 3.2 million values + # Generate sample values + total_samples = 32_000 # 32k values (about 125 seconds at 256Hz) sample_rate = 256.0 frequency = 10.0 # 10 Hz sine wave - # Set up the streaming generator - signal.stream_values(total_samples, 10000) do |batch_size| - # Generate a batch of values - batch = [] - batch_size.times do |i| - # Calculate the overall sample index - t = (i + batch.size) / sample_rate - # Generate sine wave value - batch << 100.0 * Math.sin(2 * Math::PI * frequency * t) - end - batch + # Generate all values and convert to digital + physical_values = [] + total_samples.times do |i| + t = i / sample_rate + # Generate sine wave value + physical_values << 100.0 * Math.sin(2 * Math::PI * frequency * t) end + # Convert physical values to digital and assign to signal + signal.digital_values = signal.convert_to_digital(physical_values) + signal.physical_values = physical_values + # Add the signal to the EDF edf.signals << signal diff --git a/example/create_large_edf_from_file.rb b/example/create_large_edf_from_file.rb index ee1408d..3f57ac3 100755 --- a/example/create_large_edf_from_file.rb +++ b/example/create_large_edf_from_file.rb @@ -47,34 +47,28 @@ puts "Found #{total_samples} values in file" - # Create an enumerator to read the file line by line - file_enumerator = Enumerator.new do |yielder| - Zlib::GzipReader.open(input_path) do |gz| - # Skip opening bracket - gz.readline - - # Read values until we hit the closing bracket - while (line = gz.readline) - break if line.strip == "]" - # Parse the value (remove trailing comma if present) - value = line.strip.sub(/,$/, "").to_f - yielder << value - end - end - end - - # Set up the streaming generator to read from file - signal.stream_values(total_samples, 10000) do |batch_size| - batch = [] + # Read all values from the gzipped JSON file into memory + physical_values = [] + Zlib::GzipReader.open(input_path) do |gz| + # Skip opening bracket + gz.readline - # Take batch_size values from our enumerator - file_enumerator.take(batch_size).each do |value| - batch << value + # Read values until we hit the closing bracket + while (line = gz.readline) + break if line.strip == "]" + # Parse the value (remove trailing comma if present) + value = line.strip.sub(/,$/, "").to_f + physical_values << value end - - batch end + # Update total_samples based on actual data read + total_samples = physical_values.size + + # Convert physical values to digital and assign to signal + signal.digital_values = signal.convert_to_digital(physical_values) + signal.physical_values = physical_values + # Add the signal to the EDF edf.signals << signal diff --git a/example/create_large_edf_from_ppg.rb b/example/create_large_edf_from_ppg.rb deleted file mode 100755 index 32e84cb..0000000 --- a/example/create_large_edf_from_ppg.rb +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/env ruby - -require "bundler/setup" -require "edfize" -require "benchmark" -require "stringio" - -# Configuration -OUTPUT_EDF_FILE = "./example/large_output_from_ppg.edf" -PPG_DATA_FILE = "./example/edf-ppg.txt.zip" -SIGNAL_LABEL = "PPG Signal" -PHYSICAL_DIMENSION = "mV" -SAMPLING_RATE = 256 # Assuming 256Hz sampling rate for PPG -PHYSICAL_MIN = -5.0 # Adjust based on your PPG data range -PHYSICAL_MAX = 5.0 # Adjust based on your PPG data range -DIGITAL_MIN = -32768 -DIGITAL_MAX = 32767 -BATCH_SIZE = 1000 # Number of values to process at a time - -puts "Reading values from: #{PPG_DATA_FILE}" - -def process_chunk(chunk) - # Remove any non-essential characters and split by comma - values = chunk.strip - .gsub(/^\[|\]$/, '') # Remove brackets - .split(',') - .map(&:strip) - .reject(&:empty?) - .map(&:to_f) - values -end - -def create_value_enumerator - Enumerator.new do |yielder| - IO.popen(["unzip", "-p", PPG_DATA_FILE]) do |io| - buffer = "" - chunk_size = 8192 # Read in 8KB chunks - - while chunk = io.read(chunk_size) - buffer += chunk - - # Process complete values from buffer - while comma_index = buffer.index(',') - value_str = buffer[0..comma_index].strip - buffer = buffer[(comma_index + 1)..-1] - - # Skip if it's just the opening bracket - next if value_str == '[' - - # Clean and convert value - value_str = value_str.gsub(/[\[\],]/, '').strip - next if value_str.empty? - - begin - value = value_str.to_f - yielder << value - rescue => e - puts "Warning: Skipping invalid value: #{value_str}" - end - end - end - - # Process any remaining values in buffer - unless buffer.empty? - values = process_chunk(buffer) - values.each { |v| yielder << v } - end - end - end -end - -# Create enumerator for counting -puts "Counting total values..." -value_stream_enumerator = create_value_enumerator -total_values = 0 -begin - while value_stream_enumerator.next - total_values += 1 - print "\rProcessed #{total_values} values..." if total_values % 100_000 == 0 - end -rescue StopIteration -end -puts "\nFound #{total_values} values in file" - -# Create new enumerator for processing -value_stream_enumerator = create_value_enumerator - -# Create EDF file -edf = Edfize::Edf.create(OUTPUT_EDF_FILE) do |e| - e.local_patient_identification = "PPG Recording" - e.local_recording_identification = "PPG Data Import" - e.start_date_of_recording = Time.now.strftime("%d.%m.%y") - e.start_time_of_recording = Time.now.strftime("%H.%M.%S") - e.duration_of_a_data_record = 1 # 1 second data records -end - -# Add a signal -signal = Edfize::Signal.new -signal.label = SIGNAL_LABEL -signal.transducer_type = "PPG" -signal.physical_dimension = PHYSICAL_DIMENSION -signal.physical_minimum = PHYSICAL_MIN -signal.physical_maximum = PHYSICAL_MAX -signal.digital_minimum = DIGITAL_MIN -signal.digital_maximum = DIGITAL_MAX -signal.prefiltering = "None" -signal.samples_per_data_record = SAMPLING_RATE -edf.signals << signal - -puts "Writing EDF file to: #{OUTPUT_EDF_FILE}" -puts "Total samples: #{total_values}" -puts "Expected file size: ~#{(total_values * Edfize::Edf::SIZE_OF_SAMPLE_IN_BYTES / (1024.0 * 1024.0)).round(0)}MB" - -time_taken = Benchmark.realtime do - # The signal.stream_values block will be called repeatedly to get batches of values - signal.stream_values(total_values, BATCH_SIZE) do |batch_size_requested| - # Use the enumerator to take the next batch of values - value_stream_enumerator.take(batch_size_requested) - end - edf.write -end - -puts "\nFile written successfully!" -puts "Time taken: #{time_taken.round(1)} seconds" -puts "Actual file size: #{(File.size(OUTPUT_EDF_FILE) / (1024.0 * 1024.0)).round(0)}MB" - -# Verification (optional) -puts "\nVerifying written EDF file..." -verification_edf = Edfize::Edf.new(OUTPUT_EDF_FILE) -verification_edf.load_signal_preview - -puts "\nSignal Information:" -puts "Label: #{verification_edf.signals.first.label}" -puts "Physical Dimension: #{verification_edf.signals.first.physical_dimension}" -puts "Sampling Rate: #{verification_edf.signals.first.samples_per_data_record} Hz" - -puts "\nFirst few values (preview):" -puts "Physical values: #{verification_edf.signals.first.load_preview(5)}" \ No newline at end of file diff --git a/gems.rb b/gems.rb index 628f552..9197df4 100644 --- a/gems.rb +++ b/gems.rb @@ -11,4 +11,5 @@ gem "minitest" gem "rubocop", require: false gem "simplecov", "~> 0.16.1", require: false + end diff --git a/lib/edfize/edf.rb b/lib/edfize/edf.rb index efd9e44..420af0e 100644 --- a/lib/edfize/edf.rb +++ b/lib/edfize/edf.rb @@ -366,9 +366,10 @@ def write_data_records(file) end_index = start_index + signal.samples_per_data_record values = signal.digital_values[start_index...end_index] || [] - # Pad with nil if we don't have enough values + # Pad with digital_minimum if we don't have enough values (or 0 if no minimum set) + pad_value = signal.digital_minimum || 0 while values.size < signal.samples_per_data_record - values << nil + values << pad_value end # Write the values @@ -377,6 +378,9 @@ def write_data_records(file) end end + + + def calculate_header_size main_header_size = HEADER_CONFIG.values.sum { |config| config[:size] } signal_header_size = @signals.size * Signal::SIGNAL_CONFIG.values.sum { |config| config[:size] } @@ -434,7 +438,9 @@ def write(output_path = nil, is_continuous: true, add_edf_annotations: false) # Calculate number of data records if not set if @number_of_data_records == 0 && !@signals.empty? - max_values = @signals.map { |s| s.digital_values.size / s.samples_per_data_record.to_f }.max + max_values = @signals.map do |s| + s.digital_values.size / s.samples_per_data_record.to_f + end.max @number_of_data_records = max_values.ceil end diff --git a/lib/edfize/signal.rb b/lib/edfize/signal.rb index 0ff7642..6899cf9 100644 --- a/lib/edfize/signal.rb +++ b/lib/edfize/signal.rb @@ -25,9 +25,6 @@ class Signal def initialize @digital_values = [] @physical_values = [] - @value_enumerator = nil - @total_samples = 0 - @streaming_mode = false end def self.create @@ -42,41 +39,6 @@ def print_header end end - # Set up streaming mode with an enumerator that yields values in batches - def stream_values(total_samples, batch_size = 1000, &block) - @total_samples = total_samples - @streaming_mode = true - @value_enumerator = Enumerator.new do |yielder| - remaining = total_samples - while remaining > 0 - current_batch_size = [batch_size, remaining].min - values = yield(current_batch_size) - values.each { |v| yielder << v } - remaining -= values.size - end - end - end - - # Get the total number of samples this signal will contain - def total_samples - return @digital_values.size unless @streaming_mode - @total_samples - end - - # Write values to a file in batches - def write_values_to(file, batch_size = 1000) - if @streaming_mode - # Streaming mode - @value_enumerator.each_slice(batch_size) do |batch| - digital_batch = convert_to_digital(batch) - file.write(digital_batch.pack("s<*")) - end - else - # Regular mode (all values in memory) - file.write(@digital_values.pack("s<*")) - end - end - # Physical value (dimension PhysiDim) = (ASCIIvalue-DigiMin)*(PhysiMax-PhysiMin)/(DigiMax-DigiMin) + PhysiMin. def calculate_physical_values! return if @digital_values.empty? @@ -100,8 +62,6 @@ def samples @physical_values end - private - # Convert physical values to digital values def convert_to_digital(physical_batch) physical_batch.map do |physical| @@ -112,4 +72,4 @@ def convert_to_digital(physical_batch) end end end -end \ No newline at end of file +end diff --git a/test/edf_test.rb b/test/edf_test.rb index 356d1eb..e95532e 100644 --- a/test/edf_test.rb +++ b/test/edf_test.rb @@ -3,6 +3,7 @@ require_relative "test_helper" require "tempfile" + # Test to assure EDFs can be loaded and updated. class EdfTest < Minitest::Test def setup @@ -674,4 +675,6 @@ def test_signal_subset_preservation output_file.unlink end end + + end \ No newline at end of file diff --git a/test/support/spo2.json.gz b/test/support/spo2.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..5b286f560dbbe761d7ea19950a517ea97344ef1d GIT binary patch literal 54 zcmb2|=3wBSv@U~zxqF+*b>j;gw=f7MOq1*|U=>tTI3(dB(a6E9kjNO6&KM%j&G1K& K`Mw1M0|NlbwGR9M literal 0 HcmV?d00001