An open-source decoder and reverse-engineered format specification for
Holter ECG recordings produced by the Meditech CardioMera (FC1) ambulatory
recorder (Meditech Kft, Budapest). It reads the raw SD-card files ECG.DAT and
FC1_PRG.DAT, reconstructs the multi-channel ECG, and exports EDF+ that
opens in any standard ECG viewer (EDFbrowser, WFDB tools, etc.).
Reconstructed 3-lead ECG. This sample is generated from synthetic data
(tests/synthetic.py) — no patient data is included anywhere in this repo.
The vendor's own software (CardioVisions) is Windows-only and there is no public documentation of the on-card file format. This project documents that format and provides a cross-platform (Python) reader so the data is not locked to one application.
Status: the byte-level layout here was reverse-engineered from a real recording and validated by reconstructing physiologically-correct ECG (see Verification). The physical acquisition parameters (resolution, rate, channels, calibration) are independently confirmed by the manufacturer's device manual (see References). Contributions and cross-checks against other CardioMera recordings are very welcome.
Medical. This software is not a medical device and produces no diagnosis. The optional arrhythmia script gives only a crude, rhythm-based estimate (it does not analyze QRS morphology and cannot distinguish ventricular vs supraventricular ectopy vs artifact). Always rely on a clinician and certified Holter analysis software for any clinical decision.
Privacy (PHI). FC1_PRG.DAT stores the patient name and ID in
plaintext, and ECG.DAT is the patient's ECG. Treat both as protected health
information. The provided .gitignore keeps all *.DAT, *.edf, *.npz and
image/PDF exports out of the repository. Do not commit real recordings. All
examples in this README are redacted / synthetic.
- Integrity report (recorded vs pre-allocated blocks, corrupt-block check).
- Lossless reconstruction of the integrated signal from the stored deltas.
- Baseline-drift handling with a pluggable strategy (leaky integrator by default; Butterworth high-pass / moving-average alternatives documented).
- EDF+ export in real microvolts (using the manufacturer's 4 µV/count gain).
- Optional rhythm-based ectopic-burden estimate with a per-hour breakdown.
python3 -m pip install -r requirements.txt
# numpy, scipy, pyEDFlib, matplotlibPut ECG.DAT and FC1_PRG.DAT in the working directory, then:
# Decode + export EDF+ (cardiomera.edf) with an integrity report
python3 meditech_decode.py
# Rough rhythm-based ectopic-burden estimate (writes arrhythmia_events.npz)
python3 meditech_arrhythmia.pyBoth tools take CLI flags so they work on any CardioMera recording, not just the default 3-channel / 300 Hz layout:
python3 meditech_decode.py path/to/ECG.DAT path/to/FC1_PRG.DAT \
-o out.edf --channels 3 --rate 300 --gain 4.0 --baseline butter
python3 meditech_decode.py --report-only # metadata + integrity only
python3 meditech_arrhythmia.py path/to/ECG.DAT --rate 300 --detect-channel 2
python3 meditech_decode.py -h # full option listRun the test suite (uses synthetic data — no real recording needed):
python3 tests/test_smoke.py # or: pytest -qProgrammatic use:
import meditech_decode as m
meta = m.parse_prg("FC1_PRG.DAT") # patient/device/timestamps
integ = m.analyze_integrity("ECG.DAT") # block accounting
sig = m.reconstruct("ECG.DAT") # (samples, 3) int32 ADC counts
sig = m.correct_baseline(sig) # remove slow drift for int16/EDF
m.export_edf(sig, meta, "cardiomera.edf") # EDF+ in microvoltsEverything below was derived by inspection of a real recording and confirmed by reconstructing clean ECG; treat it as a community spec, not a vendor document. All multi-byte integers are little-endian.
A programmed CardioMera card holds (at least) two files, listed in an embedded
FAT-style directory inside FC1_PRG.DAT:
| File | Size (example) | Purpose |
|---|---|---|
FC1_PRG.DAT |
2048 bytes | Programming/configuration + patient data |
ECG.DAT |
N × 1024 bytes | Raw multi-channel ECG |
The FC1 prefix is the model code for CardioMera (per the Meditech device
manual), so FC1_PRG.DAT = "FC1 programming file".
ECG.DAT is a flat array of 1024-byte blocks. The file is pre-allocated
to a fixed number of blocks when recording starts; when the recorder powers off,
the remaining blocks stay all-zero. So:
file size = TOTAL_BLOCKS * 1024
recorded = blocks [0 .. last non-zero block]
unused tail= all-zero blocks (NOT lost data — never written)
Do not treat the zero tail as corruption; it is normal unused buffer.
offset size field
------ ---- ---------------------------------------------------------------
0 1 tag block type / flags: 0x1d, 0x19 or 0x0d
(does NOT change the data layout — data always @13)
1 1 0x00 constant
2 3 counter 24-bit LE, increments by +20 per block
(a device clock/index; NOT a sample count)
5 8 reserved housekeeping; typically 01 02 00 .. (varies slightly)
13 1011 payload 3 interleaved channels of int8 deltas:
c0,c1,c2, c0,c1,c2, ... (337 samples per channel)
Key facts, and why:
- Header is 13 bytes for every block. Data starts at byte 13 regardless of
the
tagvalue. This is proven empirically: using offset 13 yields clean ECG for0x1d,0x19and0x0dblocks alike, and1024 − 13 = 1011 = 3 × 337divides evenly into 3 channels, so the interleave phase is preserved exactly across every block boundary. - The
tagbyte is a type/flag, not the header length. (0x1d=29,0x19=25,0x0d=13 as numbers, but the header is 13 in all cases.) Its exact meaning is not fully decoded; it does not affect sample extraction. - The counter increments by +20 per block and is a device clock/index — it does not equal the sample count (each block holds 337 samples/channel). A handful of blocks show a different step at segment boundaries; these are harmless and cancel out.
Each payload byte is a signed 8-bit delta (int8). The reconstructed signal
is the per-channel cumulative sum:
deltas = payload.reshape(-1, 3).astype(int8) # (337*nblocks, 3)
signal = cumsum(deltas, axis=0) # ADC counts
This is confirmed by the byte histogram, which is a clean Laplacian centered on
0 (value 0 most common, then ±1, ±2, … symmetrically) — the signature of
delta/differential coding, not raw samples. (A raw-int16 interpretation was
tested and rejected: it is noisy and overflows the range.)
Baseline drift. A global cumulative sum is exact but accumulates a small DC
bias (~+36 counts/block), so over a multi-day record it drifts into the millions
and exceeds the int16 range EDF requires. The diagnostic content is in the AC
component and is fully preserved; remove the slow drift with any standard ECG
high-pass. correct_baseline() implements a leaky integrator
(y[n]=α·y[n−1]+Δ, α≈0.999, ≈0.05 Hz) by default; a 0.05 Hz Butterworth
high-pass gives the best ST/T fidelity.
Header + start of the first payload of a real ECG.DAT (the payload bytes are
just ECG deltas, not identifying):
00000000: 1d 00 be 14 00 00 01 3f 02 00 00 00 45 1e 12 00 dd 1f f7 03
^^ ^^ ^^^^^^^^ ^^ ^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^ ...
| | | | | └ payload: int8 deltas
| | | | └ reserved / housekeeping (bytes 5..12)
| | | └ counter byte 3 (high)
| | └ counter = 0x0014be = 5310 (LE, bytes 2..4)
| └ 0x00
└ tag = 0x1d
Decoding: bytes 13..1023 → int8 → reshape to (337, 3) → cumsum → the
first 337 samples of each of the 3 channels.
A fixed 2048-byte structure. Notable fields (offsets from a real, redacted
example; byte values for PHI fields replaced with JOHN DOE / 0 / X):
| Offset | Type / len | Field |
|---|---|---|
0x100 |
packed date (7 bytes) | Year u16, then month, day, hour, min, sec (u8) |
0x201 |
len-prefixed string | Software name, e.g. CardioClip01 (len byte 0x0c) |
0x20d |
packed date-ish | Secondary timestamp block |
0x243 |
len-prefixed string | Patient name (len byte precedes, e.g. 0x0d) |
0x28b |
len-prefixed string | Patient name (second copy) |
0x2af |
len-prefixed string | Patient ID (len byte 0x09) |
0x2cf |
double (TDateTime) |
Delphi timestamp (days since 1899-12-30) |
0x2d7 |
double (TDateTime) |
Delphi timestamp |
0x400 |
8.3 dir entries | FAT-style directory: CARDIOMERA, FC1_PRG DAT, ECG DAT |
0x610 |
ASCII string | Device serial, e.g. 2019FCxxxxxxx |
~0x158 |
ASCII | ATDT modem dial strings (legacy telemetry) |
Timestamps. Two encodings coexist:
- A packed date at
0x100:EA 07 07 01 0A 14 26→ year0x07EA=2026, month07, day01, hour0x0A=10, minute0x14=20, second0x26=38 →2026-07-01 10:20:38. - Delphi
TDateTimedoubles (days since 1899-12-30). These sit near0x2cf/0x2d7and give the recording/setup times. Note these are written at programming time and capture the start; there is no explicit end timestamp — compute the end from the decoded length (recorded_blocks × 337 / sample_rate).
Redacted dump of the key regions:
00000100: ea 07 07 01 0a 14 26 00 ... packed date 2026-07-01 10:20:38
00000200: 0c 43 61 72 64 69 6f 43 6c 69 70 30 31 ea 07 07 .CardioClip01...
00000240: 00 00 0d 4a 4f 48 4e 20 44 4f 45 00 ... patient name (REDACTED)
000002a0: ... 09 30 30 30 30 30 30 30 30 30 ... patient ID (REDACTED)
000002c0: 00 .. 3a 58 26 ae 8d 8f e6 40 70 e7 38 9b 8d 8f e6 40 two TDateTime doubles
00000400: 43 41 52 44 49 4f 4d 45 52 41 20 ... CARDIOMERA (FAT-style directory)
00000420: 46 43 31 5f 50 52 47 20 44 41 54 20 ... FC1_PRG DAT
00000440: 45 43 47 20 20 20 20 20 44 41 54 20 ... ECG DAT
00000610: 32 30 31 39 46 43 58 58 58 58 58 58 58 2019FCXXXXXXX (serial, REDACTED)
Confirmed by the Meditech device manual (see References):
| Parameter | Value |
|---|---|
| ECG channels | up to 3 bipolar (or 5 unipolar); this recording: 3 |
| A/D resolution | 12 bit |
| Sampling (acq.) | 1200 Hz or 600 Hz |
| Storage rate | 600 / 300 / 150 Hz (selectable); this recording: 300 Hz |
| Dynamic range | 16 mV peak-to-valley |
| Sensitivity | 4 µV → ≈ 4 µV per ADC count (16 mV / 4096 = 2¹²) |
| Media / duration | SD/MMC card, up to 96 h |
Microvolt calibration. physical_µV ≈ ADC_count × 4 µV. The decoder uses
ADC_GAIN_UV = 4.0 and writes EDF in µV. Caveat: the manual gives the
hardware LSB; whether ECG.DAT stores exactly raw counts (vs a scaled value)
should ideally be verified against a known 1 mV calibration pulse if one is
present in the recording.
Active channel count and storage rate for a given recording are selectable
and are almost certainly encoded somewhere in FC1_PRG.DAT; the exact field is
not yet decoded, so this tool uses the empirically-determined values (3 ch,
300 Hz). The 300 Hz figure is corroborated physiologically (resting HR of
65–80 bpm falls out only at 300 Hz; 600 Hz would imply ~150 bpm, 150 Hz ~38 bpm).
- Exact semantics of the block
tagbyte (0x1d/0x19/0x0d). - Meaning of the +20 per-block counter's unit and the reserved header bytes 5–12.
- Location of the channel-count and storage-rate fields inside
FC1_PRG.DAT. - Confirmation that samples are raw counts (calibration against a known pulse).
- Whether other CardioMera model codes (
card(X)plore,CardiUp) share this block layout.
The format was validated end-to-end, not just asserted:
- Delta encoding confirmed by the Laplacian byte histogram (0 ≫ ±1 ≫ ±2 …).
- 3-channel interleave confirmed by a smoothness metric: reconstructing as 3 channels yields smooth per-channel waveforms; 1/2/4 channels do not.
- Physiological ECG recovered — sharp QRS complexes with visible P and T waves, and a resting heart rate of 65–80 bpm at 300 Hz.
- Integrity: on the sample recording, every recorded block had a valid tag and no zero/dropout blocks inside the recorded region.
The exported cardiomera.edf is standard EDF+ and opens in:
- EDFbrowser (cross-platform viewer)
- WFDB / wfdb-python (
edf2mitetc.) - most research ECG toolkits (NeuroKit2, etc.)
- Meditech CardioMera — product page and specs: https://www.meditech.hu/en/ecg-holter-monitor.html
- Meditech device manual (model codes incl.
FC1 for CardioMera; 12-bit, 1200/600 Hz acquisition, 600/300/150 Hz storage, 16 mV p-v / 4 µV sensitivity, up to 3 bipolar/5 unipolar channels): http://medusoft.com.au/Manual/Devices_manual_ENG.pdf - CardioVisions software (Windows) and card handling: https://www.meditech.hu/en/holter-management-software.html · https://www.pmsinstruments.co.uk/pdf/CardioVisions_1st_startup_30%2011%2007.pdf
MIT. Not affiliated with or endorsed by Meditech Kft. All trademarks belong to their respective owners.
