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/.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/.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/.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/CHANGELOG.md b/CHANGELOG.md
index 24a2404..03311ca 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,19 @@
+## 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)
+ - 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/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/example/Gemfile b/example/Gemfile
new file mode 100644
index 0000000..a4cff0c
--- /dev/null
+++ b/example/Gemfile
@@ -0,0 +1,8 @@
+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.rb b/example/create_edf.rb
new file mode 100755
index 0000000..ac64b5f
--- /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 = ""
+ 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 = [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)
+
+ # 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/create_large_edf.rb b/example/create_large_edf.rb
new file mode 100755
index 0000000..d75073c
--- /dev/null
+++ b/example/create_large_edf.rb
@@ -0,0 +1,80 @@
+#!/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
+
+ # 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
+
+ # 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
+
+ # 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/create_large_edf_from_file.rb b/example/create_large_edf_from_file.rb
new file mode 100755
index 0000000..3f57ac3
--- /dev/null
+++ b/example/create_large_edf_from_file.rb
@@ -0,0 +1,103 @@
+#!/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"
+
+ # Read all values from the gzipped JSON file into memory
+ physical_values = []
+ 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
+ physical_values << value
+ end
+ 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
+
+ # 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/large_output.edf b/example/large_output.edf
new file mode 100644
index 0000000..79d21af
Binary files /dev/null and b/example/large_output.edf differ
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/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
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
+]
diff --git a/gems.rb b/gems.rb
index c94cc97..9197df4 100644
--- a/gems.rb
+++ b/gems.rb
@@ -6,8 +6,10 @@
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 9e92284..420af0e 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
@@ -11,55 +12,70 @@ 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)
- 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 = []
+ @signals = SignalArray.new
+ @is_new_file = initialize_empty
- read_header
- read_signal_header
- self
+ 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
- get_data_records
+ load_digital_signals
+ calculate_physical_values!
end
# Epoch Number is Zero Indexed, and Epoch Size is in Seconds (Not Data Records)
@@ -70,7 +86,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 +134,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 +162,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 +222,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 +238,71 @@ 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_signals
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<*")
- 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)
+ 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
+
+ # 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)
- 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 +312,146 @@ 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
+
+ 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)
+ # 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 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 << pad_value
+ end
+
+ # Write the values
+ file.write(values.pack("s<*"))
+ end
+ 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 = -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
+
+ 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
+ # @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?
+
+ # 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 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 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?
+ 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
+
+ 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/signal.rb b/lib/edfize/signal.rb
index 5a9915e..6899cf9 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,17 +35,41 @@ 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) }
+ return if @digital_values.empty?
+
+ @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
+
+ # 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
+
+ # 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
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/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..26b2ad4 100644
--- a/lib/edfize/version.rb
+++ b/lib/edfize/version.rb
@@ -1,9 +1,9 @@
# frozen_string_literal: true
module Edfize
- module VERSION #:nodoc:
+ 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 c8fe8b7..e95532e 100644
--- a/test/edf_test.rb
+++ b/test/edf_test.rb
@@ -1,8 +1,9 @@
# frozen_string_literal: true
-require "test_helper"
+require_relative "test_helper"
require "tempfile"
+
# Test to assure EDFs can be loaded and updated.
class EdfTest < Minitest::Test
def setup
@@ -11,6 +12,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 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)
+ 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
@@ -205,4 +302,379 @@ def test_should_rewrite_start_time_of_recording
file.close
file.unlink # Deletes temporary file.
end
-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 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
+ ensure
+ output_file.close
+ output_file.unlink
+ 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")
+
+ # Create a temporary file for writing
+ output_file = Tempfile.new(["test-write", ".edf"])
+ begin
+ # 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)
+
+ # Compare header fields
+ 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)
+
+ # 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.each_key do |field|
+ assert_equal orig_signal.send(field), written_signal.send(field),
+ "Signal #{i} field #{field} does not match"
+ end
+
+ # 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
+ 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
diff --git a/test/support/spo2.json.gz b/test/support/spo2.json.gz
new file mode 100644
index 0000000..5b286f5
Binary files /dev/null and b/test/support/spo2.json.gz differ