Skip to content

Commit 8197a43

Browse files
kkekerclaude
andcommitted
Add CLI, CI, tests, CITATION, badges and synthetic ECG sample
- meditech_decode.py / meditech_arrhythmia.py: argparse CLI (input paths, --channels/--rate/--gain/--header-len/--baseline, --report-only); no more hardcoded recording params, works on any CardioMera layout. Start time is read from FC1_PRG.DAT. Robust length-prefixed metadata parsing. - tests/synthetic.py: PHI-free synthetic recording generator in device format. - tests/test_smoke.py: decode roundtrip + beat-detection smoke tests. - .github/workflows/ci.yml + ruff.toml: lint + tests on py3.10/3.12. - README: MIT/Python/CI badges + synthetic 3-lead ECG screenshot; CLI docs. - CITATION.cff for scholarly citation. - .gitignore: allow only the synthetic docs image through the *.png rule. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 37bf29e commit 8197a43

10 files changed

Lines changed: 473 additions & 112 deletions

File tree

.github/workflows/ci.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
workflow_dispatch:
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
fail-fast: false
14+
matrix:
15+
python-version: ["3.10", "3.12"]
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- name: Set up Python ${{ matrix.python-version }}
20+
uses: actions/setup-python@v5
21+
with:
22+
python-version: ${{ matrix.python-version }}
23+
cache: pip
24+
25+
- name: Install dependencies
26+
run: |
27+
python -m pip install --upgrade pip
28+
pip install -r requirements.txt
29+
pip install ruff pytest
30+
31+
- name: Lint (ruff)
32+
run: ruff check .
33+
34+
- name: Smoke test (synthetic data — no PHI)
35+
run: pytest -q

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ FC1_PRG.DAT
1313
*.csv
1414
*.png
1515
*.pdf
16+
# ...except the synthetic (PHI-free) documentation image
17+
!docs/sample_ecg.png
1618

1719
# ── Python ───────────────────────────────────────────────────────────────────
1820
__pycache__/

CITATION.cff

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
cff-version: 1.2.0
2+
message: "If you use this software, please cite it as below."
3+
title: "meditech-cardiomera-decoder: an open decoder and format specification for Meditech CardioMera (FC1) Holter ECG recordings"
4+
abstract: >-
5+
An open-source Python decoder and reverse-engineered file-format specification
6+
for Holter ECG recordings produced by the Meditech CardioMera (FC1) ambulatory
7+
recorder. It parses the proprietary on-card files (ECG.DAT and FC1_PRG.DAT),
8+
losslessly reconstructs the multi-channel ECG from the device's int8
9+
delta-encoded 1024-byte blocks, and exports EDF+ in microvolts for use in any
10+
standard ECG viewer. Includes a rough, rhythm-based ectopic-burden estimate.
11+
type: software
12+
authors:
13+
- given-names: Klaus
14+
alias: kkeker
15+
affiliation: "NexaTech Ltd"
16+
repository-code: "https://github.com/nexatech-ltd/meditech-cardiomera-decoder"
17+
url: "https://github.com/nexatech-ltd/meditech-cardiomera-decoder"
18+
license: MIT
19+
version: "1.0.0"
20+
date-released: "2026-07-02"
21+
keywords:
22+
- Holter
23+
- ECG
24+
- electrocardiogram
25+
- Meditech
26+
- CardioMera
27+
- CardioVisions
28+
- EDF
29+
- file format
30+
- reverse engineering
31+
- medical devices

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
# meditech-cardiomera-decoder
22

3+
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
4+
[![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/)
5+
[![CI](https://github.com/nexatech-ltd/meditech-cardiomera-decoder/actions/workflows/ci.yml/badge.svg)](https://github.com/nexatech-ltd/meditech-cardiomera-decoder/actions/workflows/ci.yml)
6+
37
An open-source decoder and **reverse-engineered format specification** for
48
Holter ECG recordings produced by the **Meditech CardioMera (FC1)** ambulatory
59
recorder (Meditech Kft, Budapest). It reads the raw SD-card files `ECG.DAT` and
610
`FC1_PRG.DAT`, reconstructs the multi-channel ECG, and exports **EDF+** that
711
opens in any standard ECG viewer (EDFbrowser, WFDB tools, etc.).
812

13+
![Reconstructed 3-lead ECG (synthetic sample)](docs/sample_ecg.png)
14+
15+
*Reconstructed 3-lead ECG. This sample is generated from **synthetic** data
16+
(`tests/synthetic.py`) — no patient data is included anywhere in this repo.*
17+
918
The vendor's own software (**CardioVisions**) is Windows-only and there is **no
1019
public documentation of the on-card file format**. This project documents that
1120
format and provides a cross-platform (Python) reader so the data is not locked
@@ -64,6 +73,24 @@ python3 meditech_decode.py
6473
python3 meditech_arrhythmia.py
6574
```
6675

76+
Both tools take CLI flags so they work on any CardioMera recording, not just
77+
the default 3-channel / 300 Hz layout:
78+
79+
```bash
80+
python3 meditech_decode.py path/to/ECG.DAT path/to/FC1_PRG.DAT \
81+
-o out.edf --channels 3 --rate 300 --gain 4.0 --baseline butter
82+
python3 meditech_decode.py --report-only # metadata + integrity only
83+
84+
python3 meditech_arrhythmia.py path/to/ECG.DAT --rate 300 --detect-channel 2
85+
python3 meditech_decode.py -h # full option list
86+
```
87+
88+
Run the test suite (uses synthetic data — no real recording needed):
89+
90+
```bash
91+
python3 tests/test_smoke.py # or: pytest -q
92+
```
93+
6794
Programmatic use:
6895

6996
```python

docs/sample_ecg.png

96.5 KB
Loading

meditech_arrhythmia.py

Lines changed: 73 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,23 @@
88
and a clinician / certified Holter software. Use this only to get a sense of the
99
order of magnitude.
1010
11+
Note: the ectopic percentage is derived from R-R timing with a self-scaling
12+
detection threshold, so it is invariant to the ADC gain -- microvolt calibration
13+
does not change these numbers.
14+
1115
Pipeline:
12-
1. Reconstruct one channel (CH3 -- sharpest R waves, fewest artifacts) in
13-
chunks with a continuous cumulative sum.
16+
1. Reconstruct one channel in chunks with a continuous cumulative sum.
1417
2. Band-pass 5-25 Hz (QRS enhancement; removes drift/respiration/noise).
1518
3. R-peak detection: adaptive threshold + 250 ms refractory + T-wave rejection.
16-
4. Per-beat classification against the local R-R:
17-
- premature (ectopic): RR < PREMATURE * local-median RR, and the next
18-
RR > COMPENSATORY * local-median (compensatory pause);
19-
- pause: RR > PAUSE_S seconds.
19+
4. Per-beat classification against the local R-R (premature + compensatory pause).
2020
5. Percentages + per-hour breakdown.
2121
"""
22-
import numpy as np
22+
import argparse
2323
import datetime
24+
import numpy as np
2425
from scipy.signal import butter, filtfilt, find_peaks
2526

26-
BLK, HDR, NCH = 1024, 13, 3
27-
SPB = (BLK - HDR) // NCH # 337
28-
FS = 300
29-
CH = 2 # CH3 (index 2) -- cleanest for detection
30-
START = datetime.datetime(2026, 7, 1, 10, 12, 17)
27+
BLOCK_SIZE = 1024
3128

3229
# rhythm-classification thresholds
3330
PREMATURE = 0.80 # RR shorter than 80% of local median = premature
@@ -37,27 +34,30 @@
3734
TWAVE_RATIO = 0.55 # a peak below 55% of the previous one within the window = likely T
3835

3936

40-
def bandpass(x, lo=5, hi=25):
41-
b, a = butter(2, [lo / (FS / 2), hi / (FS / 2)], "band")
37+
def bandpass(x, fs, lo=5, hi=25):
38+
b, a = butter(2, [lo / (fs / 2), hi / (fs / 2)], "band")
4239
return filtfilt(b, a, x)
4340

4441

45-
def detect_all(path="ECG.DAT"):
46-
mm = np.fromfile(path, dtype=np.uint8).reshape(-1, BLK)
47-
nblk = int(np.where(mm.any(axis=1))[0].max()) + 1
48-
payload = mm[:nblk, HDR:HDR + SPB * NCH].reshape(-1, NCH)[:, CH].astype(np.int8).astype(np.int32)
42+
def detect_all(path, fs, channels, header_len, detect_channel):
43+
mm = np.fromfile(path, dtype=np.uint8).reshape(-1, BLOCK_SIZE)
44+
nz = np.where(mm.any(axis=1))[0]
45+
nblk = int(nz.max()) + 1 if nz.size else 0
46+
spb = (BLOCK_SIZE - header_len) // channels
47+
payload = (mm[:nblk, header_len:header_len + spb * channels]
48+
.reshape(-1, channels)[:, detect_channel].astype(np.int8).astype(np.int32))
4949
total = payload.size
5050

51-
CHUNK, OVER = 300_000, 3_000 # ~1000 s chunks, overlap to keep edge beats
51+
CHUNK, OVER = 300_000, 3_000
5252
acc, pos = 0, 0
5353
peaks, amps = [], []
5454
while pos < total:
5555
a, b = max(0, pos - OVER), min(total, pos + CHUNK)
5656
base = acc - int(payload[a:pos].sum()) if pos > a else acc
5757
sig = np.cumsum(payload[a:b]) + base
58-
f = bandpass(sig.astype(float))
58+
f = bandpass(sig.astype(float), fs)
5959
thr = max(4.0 * np.median(np.abs(f)) / 0.6745, 1.0)
60-
pk, props = find_peaks(f, height=thr, distance=int(0.25 * FS))
60+
pk, props = find_peaks(f, height=thr, distance=int(0.25 * fs))
6161
gpk = pk + a
6262
keep = gpk >= pos
6363
peaks.extend(gpk[keep].tolist())
@@ -68,16 +68,15 @@ def detect_all(path="ECG.DAT"):
6868
order = np.argsort(peaks)
6969
peaks, amps = peaks[order], amps[order]
7070

71-
# T-wave rejection: a peak soon after a much larger one
72-
keep = np.ones(peaks.size, bool)
71+
keep = np.ones(peaks.size, bool) # T-wave rejection
7372
for i in range(1, peaks.size):
74-
if (peaks[i] - peaks[i - 1]) / FS < TWAVE_WIN and amps[i] < TWAVE_RATIO * amps[i - 1]:
73+
if (peaks[i] - peaks[i - 1]) / fs < TWAVE_WIN and amps[i] < TWAVE_RATIO * amps[i - 1]:
7574
keep[i] = False
7675
return peaks[keep], amps[keep], total
7776

7877

79-
def classify(peaks):
80-
rr = np.diff(peaks) / FS
78+
def classify(peaks, fs):
79+
rr = np.diff(peaks) / fs
8180
n = peaks.size
8281
med = np.empty(rr.size)
8382
for i in range(rr.size):
@@ -92,10 +91,9 @@ def classify(peaks):
9291
return rr, premature, pause
9392

9493

95-
def signal_quality_mask(peaks, rr):
96-
"""Flag ~10 s windows as unreliable (noise): extreme R-R variability, an
97-
implausible sustained rate, or too many physiologically-fast intervals."""
98-
WIN = 10 * FS
94+
def signal_quality_mask(peaks, rr, fs):
95+
"""Flag ~10 s windows as unreliable (noise)."""
96+
WIN = 10 * fs
9997
good = np.ones(rr.size, bool)
10098
for e in np.arange(0, peaks[-1] + WIN, WIN):
10199
m = (peaks[:-1] >= e) & (peaks[:-1] < e + WIN)
@@ -109,41 +107,71 @@ def signal_quality_mask(peaks, rr):
109107
return good
110108

111109

112-
def main():
113-
print("== Ectopic-burden estimate (rhythm-based) ==")
114-
peaks, amps, nsamp = detect_all()
115-
dur_h = nsamp / FS / 3600
116-
rr, premature, pause = classify(peaks)
117-
good = signal_quality_mask(peaks, rr)
110+
def _resolve_start(args):
111+
if args.start:
112+
return datetime.datetime.fromisoformat(args.start)
113+
try: # read from FC1_PRG.DAT if present
114+
import meditech_decode
115+
s = meditech_decode.parse_prg(args.prg).get("recording_start")
116+
if s:
117+
return s
118+
except Exception:
119+
pass
120+
return datetime.datetime(1970, 1, 1)
121+
122+
123+
def _parse_args(argv=None):
124+
p = argparse.ArgumentParser(description="Rough rhythm-based ectopic-burden estimate "
125+
"for a CardioMera Holter recording (NOT a diagnosis).")
126+
p.add_argument("ecg", nargs="?", default="ECG.DAT")
127+
p.add_argument("--prg", default="FC1_PRG.DAT", help="config file for the start timestamp")
128+
p.add_argument("--start", default=None, help="override start time (ISO 8601)")
129+
p.add_argument("--channels", type=int, default=3)
130+
p.add_argument("--rate", type=int, default=300)
131+
p.add_argument("--header-len", type=int, default=13)
132+
p.add_argument("--detect-channel", type=int, default=2, help="channel index for R detection")
133+
p.add_argument("--events-out", default="arrhythmia_events.npz")
134+
return p.parse_args(argv)
135+
136+
137+
def main(argv=None):
138+
a = _parse_args(argv)
139+
start = _resolve_start(a)
140+
print("== Ectopic-burden estimate (rhythm-based; NOT a diagnosis) ==")
141+
peaks, amps, nsamp = detect_all(a.ecg, a.rate, a.channels, a.header_len, a.detect_channel)
142+
dur_h = nsamp / a.rate / 3600
143+
rr, premature, pause = classify(peaks, a.rate)
144+
good = signal_quality_mask(peaks, rr, a.rate)
118145
total = peaks.size
119146
hr = 60 / rr
120-
121147
npre_all = int(premature.sum())
122148
npre_clean = int((premature[:-1] & good).sum())
123149
nbeat_clean = int(good.sum())
124150

125151
print(f"Length: {dur_h:.2f} h, total beats: {total:,}")
126-
print(f"Median HR: {np.median(hr):.0f} bpm (p2 {np.percentile(hr,2):.0f}, p98 {np.percentile(hr,98):.0f})")
127-
print(f"Premature beats (all): {npre_all:,} = {100*npre_all/total:.2f}% of beats")
128-
print(f"Premature beats (quality-gated): {npre_clean:,} = {100*npre_clean/max(nbeat_clean,1):.2f}%")
152+
print(f"Median HR: {np.median(hr):.0f} bpm (p2 {np.percentile(hr,2):.0f}, "
153+
f"p98 {np.percentile(hr,98):.0f})")
154+
print(f"Premature beats (all): {npre_all:,} = {100*npre_all/max(total,1):.2f}% of beats")
155+
print(f"Premature beats (quality-gated): {npre_clean:,} = "
156+
f"{100*npre_clean/max(nbeat_clean,1):.2f}%")
129157
print(f"Excluded as noise/unreliable: {100*(1-good.mean()):.1f}% of intervals")
130158
print(f"Pauses > {PAUSE_S:.0f} s: {int(pause.sum())}")
131159

132-
hours = (peaks / FS / 3600).astype(int)
160+
hours = (peaks / a.rate / 3600).astype(int)
133161
print("\nPer-hour burden (recording hour : beats : ectopics : %):")
134162
for hh in range(int(dur_h) + 1):
135163
m = hours == hh
136164
tb = int(m.sum())
137165
if tb == 0:
138166
continue
139167
eb = int(premature[m].sum())
140-
ts = (START + datetime.timedelta(hours=hh)).strftime("%m-%d %H:%M")
168+
ts = (start + datetime.timedelta(hours=hh)).strftime("%m-%d %H:%M")
141169
bar = "#" * min(40, int(60 * eb / max(tb, 1)))
142170
print(f" h{hh:2d} [{ts}]: {tb:5d} : {eb:4d} : {100*eb/tb:5.2f}% {bar}")
143171

144-
np.savez("arrhythmia_events.npz", peaks=peaks, premature=premature,
145-
pause=pause, good=np.append(good, False), fs=FS)
146-
print("\nEvents saved: arrhythmia_events.npz")
172+
np.savez(a.events_out, peaks=peaks, premature=premature, pause=pause,
173+
good=np.append(good, False), fs=a.rate)
174+
print(f"\nEvents saved: {a.events_out}")
147175

148176

149177
if __name__ == "__main__":

0 commit comments

Comments
 (0)