Measuring ECGs¶
ecg_record: signal in, record out¶
ecg_record(ecg, *, sampling_rate, lead_names, amplitude_unit="mV", patient=None,
method="default", profile="summary", config=None, input_mode=None,
record_id=None) -> ECGRecord
| Argument | Meaning |
|---|---|
ecg |
channel-major array (n_leads, n_samples); see Preparing input signals |
sampling_rate |
samples per second of ecg (≥ 100) |
lead_names |
one explicit name per row |
amplitude_unit |
"mV" or "uV" |
patient |
optional mapping or PatientMeta (details) |
method |
"default" or "legacy_dxl_inspired"; both select the same algorithm (recorded as method_resolved = "legacy_dxl_inspired") |
profile |
"summary" (default), "all" or "debug" (profiles) |
config |
optional ECGConfig |
input_mode |
"standard_12" or "limited"; defaults to config.input_mode, or "standard_12" without a config |
record_id |
optional override of the generated id (see Record identity) |
The call runs the whole pipeline and returns an immutable, validated
ECGRecord. It raises an ECGInputError
subclass when the input or configuration is invalid, and
ComputationInvariantError in the rare case that no valid record can be built.
What the pipeline does¶
The pipeline runs eight stages in a fixed order:
| Stage | Work |
|---|---|
| input | validate, normalize units, resample to the internal rate, record the input contract |
| quality | per-lead and record signal quality, acquisition checks, lead-reversal screening |
| ventricular | multilead QRS detection and pacing-spike handling |
| beats | beat grouping (dominant rhythm, ectopy), representative beats |
| delineation | P, QRS and T boundaries and peaks per beat and lead; QRS-tail and T refinement |
| atrial | atrial activity and P-wave assessment |
| measurement | intervals, amplitudes, areas, ST level, global quantities |
| finalize | consensus values, invariant checks, record assembly |
Four policies make the pipeline's contextual decisions explicit and appear in the provenance: pacing, QT rescue, measurement applicability and lead integrity. See the architecture page for the design.
Determinism¶
The pipeline has no randomness. In a given environment, the same signal,
settings, patient metadata and library version give byte-identical records.
Published values are integers (samples, milliseconds, microvolts), so the
last-digit floating-point differences that can occur between NumPy versions or
platforms normally never reach them. On the 156-record golden sentinel set,
NumPy 2.2 and 2.5 produced identical summary and all records; only one
debug-profile diagnostic differed, in its 17th significant digit. The optional
Numba kernel is tested to match the pure-Python path exactly.
Profiles¶
| Profile | Contents | Size |
|---|---|---|
summary (default) |
acquisition, axes, quality grades, provenance; 7 fiducials, 5 intervals, 5 amplitudes, 1 area per beat and lead; the main record-level values (rate, P, PR, QRS, QT, QTc Bazett/Fridericia, axes, atrial rate); 6 median-beat amplitude fields; per-beat RR | ≤ 30,000 bytes for a 10 s, 12-lead record with 10 beats (contract-tested; 24,000 in schema 1.0) |
all |
everything published: adds the JT interval, U amplitude, QTc (Bazett) and wide-QRS duration | no budget |
debug |
all plus selected engine metadata under /debug; no compatibility guarantee |
no budget |
summary ⊂ all ⊂ debug: a value present in two profiles is identical in both.
The profile does not change the record_id. Pick summary for storage and
exchange, all when you need every published quantity, and debug only for
investigating the pipeline itself. /debug content may change in any release.
import numpy as np
from ecgfeat import ecg_record, dumps_record
signal = np.load("ecg.npy")
leads = ["I", "II", "III", "aVR", "aVL", "aVF", "V1", "V2", "V3", "V4", "V5", "V6"]
summary = ecg_record(signal, sampling_rate=500, lead_names=leads)
full = ecg_record(signal, sampling_rate=500, lead_names=leads, profile="all")
print(len(dumps_record(summary)), len(dumps_record(full))) # e.g. 18061 20052
print(summary.record_id == full.record_id) # True
print(sorted(full.measurements["global"])) # includes 'qtc_bazett_ms'
Record identity¶
Unless you pass record_id, the id is rec- followed by a SHA-256 over:
the input fingerprint (samples, rate, names, unit), the resolved configuration
hash, the patient metadata, the resolved method, the library version and the
schema version. Consequences:
- re-running the same input gives the same id, so ids work as cache keys;
- a new library version gives a new id, because it may measure differently;
- the profile is not part of the id.
Pass record_id="my-study/0001" to use your own identifier. It is used in
every address of that record.
Staged API¶
ecg_record is three public stages chained together. Call them separately to
validate input once, measure once and emit several profiles:
import numpy as np
from ecgfeat import ecg_prepare, ecg_measure, ecg_emit
signal = np.load("ecg.npy")
leads = ["I", "II", "III", "aVR", "aVL", "aVF", "V1", "V2", "V3", "V4", "V5", "V6"]
prepared = ecg_prepare(signal, sampling_rate=500, lead_names=leads) # validation only
measured = ecg_measure(prepared) # the expensive part
summary = ecg_emit(measured, profile="summary")
debug = ecg_emit(measured, profile="debug")
print(summary.record_id == debug.record_id) # True
| Function | Returns | Notes |
|---|---|---|
ecg_prepare(ecg, *, sampling_rate, lead_names, amplitude_unit="mV", patient=None, input_mode="standard_12") |
ECGInput |
immutable; holds a private read-only copy of the samples |
ecg_measure(prepared, *, method="default", config=None) |
ECGMeasurements |
config.input_mode must match prepared.input_mode |
ecg_emit(measurements, *, profile="summary") |
ECGRecord |
cheap; validates the record |
ecg_dump(record, destination, *, indent=None) |
None |
writes UTF-8 JSON to a path or text stream |
ECGMeasurements is an intermediate object: keep it in memory, do not persist
it. Its attributes are not part of the stable API.
Reusing a configuration: ECGRecordExtractor¶
For many recordings with one configuration, ECGRecordExtractor holds the
config and exposes a positional extract method:
import numpy as np
from ecgfeat import ECGConfig, ECGRecordExtractor
extractor = ECGRecordExtractor(ECGConfig(mains_frequency_hz=60))
signal = np.load("ecg.npy")
leads = ["I", "II", "III", "aVR", "aVL", "aVF", "V1", "V2", "V3", "V4", "V5", "V6"]
record = extractor.extract(signal, 500, leads, record_id="site-a/0001")
print(record.record_id) # site-a/0001
The extractor keeps no state between calls and can be shared between threads.
Performance¶
One 10 s, 12-lead record at 500 Hz takes about 2.6 s in a warm process on a
16-core Linux x86-64 host (about 4 s for the first call in a new process).
See Performance for batch throughput. The repository
gates these numbers with a performance budget (tools/check_performance.py,
benchmarks/performance/budget.json).
For many records:
- use the
ecg-record batchcommand, which processes a manifest, directory or glob with a pool of worker processes; or - run
ecg_recordin aconcurrent.futures.ProcessPoolExecutor. The work is CPU-bound NumPy/SciPy code, so processes scale better than threads; - install
ecg-records[performance]for Numba-accelerated kernels.
Files, windows and long recordings¶
ecg_record accepts a SignalData from read_signal;
record_from_file(path, start_s=..., end_s=...) reads, windows and measures
in one call, and ecg_record_segments measures a long recording window by
window (Long recordings).