ecg-plotter 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. ecg_plotter-0.1.0/.github/workflows/ci.yml +29 -0
  2. ecg_plotter-0.1.0/CITATION.cff +26 -0
  3. ecg_plotter-0.1.0/LICENSE +21 -0
  4. ecg_plotter-0.1.0/MANIFEST.in +21 -0
  5. ecg_plotter-0.1.0/PKG-INFO +231 -0
  6. ecg_plotter-0.1.0/README.md +197 -0
  7. ecg_plotter-0.1.0/ecg_plotter/__init__.py +63 -0
  8. ecg_plotter-0.1.0/ecg_plotter/_version.py +3 -0
  9. ecg_plotter-0.1.0/ecg_plotter/cli.py +172 -0
  10. ecg_plotter-0.1.0/ecg_plotter/filters.py +142 -0
  11. ecg_plotter-0.1.0/ecg_plotter/io.py +169 -0
  12. ecg_plotter-0.1.0/ecg_plotter/leads.py +195 -0
  13. ecg_plotter-0.1.0/ecg_plotter/py.typed +0 -0
  14. ecg_plotter-0.1.0/ecg_plotter/sheet.py +489 -0
  15. ecg_plotter-0.1.0/ecg_plotter.egg-info/SOURCES.txt +40 -0
  16. ecg_plotter-0.1.0/examples/data/README.md +15 -0
  17. ecg_plotter-0.1.0/examples/data/generate_synthetic.py +158 -0
  18. ecg_plotter-0.1.0/examples/data/synthetic_data.jsonl +3 -0
  19. ecg_plotter-0.1.0/examples/data/synthetic_records/synth_0001.dat +0 -0
  20. ecg_plotter-0.1.0/examples/data/synthetic_records/synth_0001.hea +15 -0
  21. ecg_plotter-0.1.0/examples/data/synthetic_records/synth_0002.dat +0 -0
  22. ecg_plotter-0.1.0/examples/data/synthetic_records/synth_0002.hea +15 -0
  23. ecg_plotter-0.1.0/examples/data/synthetic_records/synth_0003.dat +0 -0
  24. ecg_plotter-0.1.0/examples/data/synthetic_records/synth_0003.hea +15 -0
  25. ecg_plotter-0.1.0/examples/data/synthetic_records.csv +4 -0
  26. ecg_plotter-0.1.0/examples/plot_colour_and_greyscale.py +51 -0
  27. ecg_plotter-0.1.0/examples/plot_from_dataframe.py +68 -0
  28. ecg_plotter-0.1.0/examples/plot_from_jsonl.py +35 -0
  29. ecg_plotter-0.1.0/examples/plot_from_wfdb.py +32 -0
  30. ecg_plotter-0.1.0/examples/plot_low_dpi.py +33 -0
  31. ecg_plotter-0.1.0/examples/plot_nonstandard_calibration.py +43 -0
  32. ecg_plotter-0.1.0/examples/plot_pdf_and_svg.py +44 -0
  33. ecg_plotter-0.1.0/examples/plot_with_standalone_filters.py +45 -0
  34. ecg_plotter-0.1.0/pyproject.toml +62 -0
  35. ecg_plotter-0.1.0/setup.cfg +4 -0
  36. ecg_plotter-0.1.0/tests/conftest.py +41 -0
  37. ecg_plotter-0.1.0/tests/test_calibration.py +45 -0
  38. ecg_plotter-0.1.0/tests/test_cli_and_formats.py +360 -0
  39. ecg_plotter-0.1.0/tests/test_filters.py +104 -0
  40. ecg_plotter-0.1.0/tests/test_io.py +271 -0
  41. ecg_plotter-0.1.0/tests/test_leads.py +96 -0
  42. ecg_plotter-0.1.0/tests/test_plot_errors.py +142 -0
  43. ecg_plotter-0.1.0/tests/test_plot_output.py +117 -0
@@ -0,0 +1,29 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main, master]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ python-version: ["3.9", "3.11", "3.12"]
15
+
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 package with test deps
26
+ run: pip install -e ".[dev]"
27
+
28
+ - name: Run tests
29
+ run: pytest --tb=short
@@ -0,0 +1,26 @@
1
+ cff-version: 1.2.0
2
+ message: "If you use this software, please cite it as below."
3
+ title: "ecg-plotter"
4
+ version: "0.1.0"
5
+ date-released: 2026-11-05
6
+ doi: 10.5281/zenodo.23984258
7
+ url: "https://github.com/JarodPH/ecg-plotter"
8
+ repository-code: "https://github.com/JarodPH/ecg-plotter"
9
+
10
+ type: software
11
+ authors:
12
+ - family-names: Hartley
13
+ given-names: Jarod P.
14
+ email: jarodph.contact@gmail.com
15
+ orcid: "https://orcid.org/0009-0006-0736-5100"
16
+ affiliation: Swansea University
17
+ license: MIT
18
+ abstract: >-
19
+ Plot 12-lead ECGs at settable physical size or pixel resolution and DPI,
20
+ with optional signal filters, for research, teaching, and ML dataset
21
+ creation. Not intended for diagnostic use or clinical decision-making.
22
+ keywords:
23
+ - ecg
24
+ - electrocardiogram
25
+ - plot
26
+ - wfdb
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jarod P. Hartley
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,21 @@
1
+ include README.md
2
+ include LICENSE
3
+ include pyproject.toml
4
+ include CITATION.cff
5
+ include ecg_plotter/py.typed
6
+ recursive-include ecg_plotter *.py
7
+ recursive-include tests *.py
8
+ recursive-include examples *.py
9
+ recursive-include examples *.md
10
+ recursive-include examples *.jsonl
11
+ recursive-include examples *.csv
12
+ recursive-include examples *.hea
13
+ recursive-include examples *.dat
14
+ recursive-include .github *.yml
15
+
16
+ global-exclude *.py[cod]
17
+ prune examples/output
18
+ prune dist
19
+ prune build
20
+ prune .pytest_cache
21
+ prune ecg_plotter.egg-info
@@ -0,0 +1,231 @@
1
+ Metadata-Version: 2.4
2
+ Name: ecg-plotter
3
+ Version: 0.1.0
4
+ Summary: Research/visualisation ECG sheets at settable size and DPI (not for diagnostic use).
5
+ Author-email: "Jarod P. Hartley" <jarodph.contact@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/JarodPH/ecg-plotter
8
+ Project-URL: Repository, https://github.com/JarodPH/ecg-plotter
9
+ Project-URL: Zenodo, https://doi.org/10.5281/zenodo.23984258
10
+ Project-URL: ORCID, https://orcid.org/0009-0006-0736-5100
11
+ Keywords: ecg,electrocardiogram,plot,wfdb
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
21
+ Classifier: Topic :: Scientific/Engineering :: Visualization
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: numpy>=1.24
26
+ Requires-Dist: pandas>=2.0
27
+ Requires-Dist: scipy>=1.10
28
+ Requires-Dist: matplotlib>=3.7
29
+ Requires-Dist: Pillow>=9.0
30
+ Requires-Dist: wfdb>=4.1
31
+ Provides-Extra: dev
32
+ Requires-Dist: pytest>=7.0; extra == "dev"
33
+ Dynamic: license-file
34
+
35
+ # ECG Plotter
36
+
37
+ Plot 12-lead ECGs as classic clinical-style sheets with **settable physical size or pixel resolution**, **DPI**, optional **signal filters**, and accurate **mm/s / mm/mV** footer reporting.
38
+
39
+ **Research / visualisation only — not for diagnostic use.** This package renders ECG traces for research, teaching, and ML dataset creation. It is not a medical device and must not be used for clinical diagnosis or patient care.
40
+
41
+ **Author:** Jarod P. Hartley ([ORCID](https://orcid.org/0009-0006-0736-5100)), Swansea University — jarodph.contact@gmail.com
42
+
43
+ ![Colour sheet, 9.84 by 6.3 inches at 300 DPI](examples/output/size_colour_inches.png)
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install ecg-plotter
49
+ ```
50
+
51
+ Dependencies, installed automatically: `numpy`, `pandas`, `scipy`, `matplotlib`, `Pillow`, `wfdb`.
52
+
53
+ ## Quick start
54
+
55
+ ```python
56
+ import ecg_plotter as ecg
57
+
58
+ data = ecg.load_wfdb("path/to/record") # without .hea/.dat extension
59
+ leads, fs = data["leads"], data["fs"]
60
+
61
+ # Option A: filter inside plot (notch/bandpass from the same kwargs)
62
+ ecg.plot(
63
+ leads,
64
+ "out.png",
65
+ inches=(9.84, 6.3), # or resolution=(2952, 1890)
66
+ dpi=300, # default 300
67
+ fs=fs,
68
+ colour=True, # pink RGB paper; False → 1-channel greyscale
69
+ id=data["record_name"],
70
+ anonymise=True,
71
+ filter=True, # apply notch/bandpass; False → footer text only
72
+ notch_hz=60,
73
+ bandpass_hz=(0.05, 150),
74
+ )
75
+
76
+ # Option B: filter yourself first (median / custom chains)
77
+ # leads = ecg.apply_to_leads(leads, ecg.median_baseline, kernel_size=201)
78
+ # ecg.plot(leads, "out.png", ..., filter=False, notch_hz=60, bandpass_hz=(0.05, 150))
79
+ ```
80
+
81
+ ## Size and calibration
82
+
83
+ Provide **either** `inches=(width, height)` **or** `resolution=(width_px, height_px)`.
84
+
85
+ - Resolution → inches via `pixels / dpi`
86
+ - Default DPI is **300**
87
+ - Footer speed / gain are computed from the **saved** geometry:
88
+
89
+ ```text
90
+ duration_s = n_samples / fs
91
+ speed_mm/s = (width_px / dpi) * 25.4 / duration_s
92
+ gain_mm/mV = (height_px / dpi) * 25.4 / 16
93
+ ```
94
+
95
+ At a fixed recording length, a larger page increases mm/s. A taller page increases mm/mV. The footer prints those computed values.
96
+
97
+ ## Filters
98
+
99
+ | Function / option | Role |
100
+ |---|---|
101
+ | `plot(..., filter=True)` | Apply `notch_hz` / `bandpass_hz` inside `plot`; with `filter=False` those kwargs only set the footer text |
102
+ | `bandpass` | Butterworth band-pass (default 0.05–150 Hz) |
103
+ | `notch` | IIR mains notch (50/60 Hz) |
104
+ | `median_baseline` | Median-filter baseline subtraction |
105
+ | `apply_to_leads` | Map any 1-D filter across leads (dict / array / DataFrame) |
106
+
107
+ Notch and band-pass run forward and back, so the QRS is not shifted in time. The default band-pass needs more than about 28 samples. A shorter trace raises `ValueError`.
108
+
109
+ ## Colour vs greyscale
110
+
111
+ - `colour=True` → pink ECG-paper grid
112
+ - `colour=False` → greyscale grid
113
+ - PNG colour is RGB (3 channels); PNG greyscale is mode `L` (1 channel)
114
+
115
+ ## Output formats
116
+
117
+ `plot` writes **PNG**, **PDF**, or **SVG** (inferred from the file extension, or set `format=`):
118
+
119
+ ```python
120
+ ecg.plot(leads, "sheet.pdf", inches=(9.84, 6.3), fs=fs) # vector PDF
121
+ ecg.plot(leads, "sheet.svg", inches=(9.84, 6.3), fs=fs)
122
+ ecg.plot(leads, "sheet.png", resolution=(2952, 1890), dpi=300, fs=fs)
123
+ ```
124
+
125
+ ## CLI
126
+
127
+ ```bash
128
+ ecg-plotter path/to/record -o out.pdf --inches 9.84 6.3 --anonymise --filter --notch-hz 60 --bandpass-hz 0.05 150
129
+ ecg-plotter --jsonl examples/data/synthetic_data.jsonl --index 0 -o out.svg --greyscale
130
+ ecg-plotter --version
131
+ ```
132
+
133
+ ## Anonymisation
134
+
135
+ For ML dataset creation, set `anonymise=True` so the footer ID is always the fixed placeholder `15030416` instead of a real record ID (which models can overfit on).
136
+
137
+ ## Input formats
138
+
139
+ Amplitudes are in millivolts. `load_wfdb` reads WFDB `p_signal`.
140
+
141
+ `plot` and `apply_to_leads` accept:
142
+
143
+ - `dict[str, array]` with lead names (`I`, `II`, … `V6`)
144
+ - `ndarray` of shape `(12, n)` or `(n, 12)` (default name order: `LEAD_NAMES`)
145
+ - `pandas.DataFrame` with lead columns:
146
+ - **wide**: rows = samples, columns = lead names
147
+ - **single-row**: one row whose lead cells contain arrays/lists (e.g. from JSONL)
148
+ - **12 columns and no lead names**: the columns are taken in order as `LEAD_NAMES`, and a warning is issued. Pass `lead_names=` if that order is wrong. Any other unlabelled table is rejected.
149
+
150
+ ```python
151
+ import pandas as pd
152
+
153
+ df = pd.DataFrame(
154
+ {
155
+ "I": signal["I"],
156
+ "II": signal["II"],
157
+ "III": signal["III"],
158
+ "aVR": signal["aVR"],
159
+ "aVL": signal["aVL"],
160
+ "aVF": signal["aVF"],
161
+ "V1": signal["V1"],
162
+ "V2": signal["V2"],
163
+ "V3": signal["V3"],
164
+ "V4": signal["V4"],
165
+ "V5": signal["V5"],
166
+ "V6": signal["V6"],
167
+ }
168
+ ) # shape: (n_samples, 12)
169
+ ecg.plot(df, "out.png", inches=(9.84, 6.3), fs=500)
170
+ ```
171
+
172
+ JSON Lines records from `load_jsonl` must include a positive ``fs`` field (Hz). The CLI uses that value unless ``--fs`` overrides it.
173
+
174
+ ## Layout
175
+
176
+ Classic sheet:
177
+
178
+ ```text
179
+ I aVR V1 V4
180
+ II aVL V2 V5
181
+ III aVF V3 V6
182
+ ─── II rhythm ────
183
+ ```
184
+
185
+ Column width and sample count scale with the recording length (split into four equal segments). If the sample count is not divisible by 4, the last 1–3 samples are dropped so every column has the same duration, and a warning is raised. Sampling rate drives grid spacing (major ≈ 0.2 s).
186
+
187
+ ## Examples
188
+
189
+ Sample data in `examples/data/` is **synthetic only** (no real patient recordings).
190
+ Regenerate with `python examples/data/generate_synthetic.py`.
191
+
192
+ ```bash
193
+ python examples/plot_from_wfdb.py
194
+ python examples/plot_from_dataframe.py
195
+ python examples/plot_from_jsonl.py
196
+ python examples/plot_colour_and_greyscale.py
197
+ python examples/plot_low_dpi.py
198
+ python examples/plot_nonstandard_calibration.py
199
+ python examples/plot_with_standalone_filters.py
200
+ python examples/plot_pdf_and_svg.py
201
+ ```
202
+
203
+ | Script | Shows |
204
+ |---|---|
205
+ | `plot_from_wfdb.py` | Load synthetic WFDB `synth_0001`, filter inside `plot`, colour PNG |
206
+ | `plot_from_dataframe.py` | Build an explicit lead DataFrame and plot it |
207
+ | `plot_from_jsonl.py` | Load the first record from `synthetic_data.jsonl` |
208
+ | `plot_colour_and_greyscale.py` | Inches vs resolution, colour vs greyscale |
209
+ | `plot_low_dpi.py` | Same physical size at 150 DPI (smaller pixel dimensions) |
210
+ | `plot_nonstandard_calibration.py` | Non-standard sizes. Footers report about 50 mm/s with 5 mm/mV, and about 20 mm/s with 12.5 mm/mV |
211
+ | `plot_with_standalone_filters.py` | Filter with `apply_to_leads`, then `plot(..., filter=False)` |
212
+ | `plot_pdf_and_svg.py` | Vector outputs: colour PDF and greyscale SVG |
213
+
214
+ Outputs are written to `examples/output/`.
215
+
216
+ ![Greyscale sheet, same page size](examples/output/size_grey_resolution.png)
217
+
218
+ ## How to cite
219
+
220
+ If you use this software, please cite it:
221
+
222
+ Hartley, J. P. (2026). *ecg-plotter*. Zenodo. https://doi.org/10.5281/zenodo.23984258
223
+
224
+ ## Tests
225
+
226
+ ```bash
227
+ pip install -e ".[dev]"
228
+ pytest
229
+ ```
230
+
231
+ GitHub Actions runs these commands on Python 3.9, 3.11, and 3.12.
@@ -0,0 +1,197 @@
1
+ # ECG Plotter
2
+
3
+ Plot 12-lead ECGs as classic clinical-style sheets with **settable physical size or pixel resolution**, **DPI**, optional **signal filters**, and accurate **mm/s / mm/mV** footer reporting.
4
+
5
+ **Research / visualisation only — not for diagnostic use.** This package renders ECG traces for research, teaching, and ML dataset creation. It is not a medical device and must not be used for clinical diagnosis or patient care.
6
+
7
+ **Author:** Jarod P. Hartley ([ORCID](https://orcid.org/0009-0006-0736-5100)), Swansea University — jarodph.contact@gmail.com
8
+
9
+ ![Colour sheet, 9.84 by 6.3 inches at 300 DPI](examples/output/size_colour_inches.png)
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install ecg-plotter
15
+ ```
16
+
17
+ Dependencies, installed automatically: `numpy`, `pandas`, `scipy`, `matplotlib`, `Pillow`, `wfdb`.
18
+
19
+ ## Quick start
20
+
21
+ ```python
22
+ import ecg_plotter as ecg
23
+
24
+ data = ecg.load_wfdb("path/to/record") # without .hea/.dat extension
25
+ leads, fs = data["leads"], data["fs"]
26
+
27
+ # Option A: filter inside plot (notch/bandpass from the same kwargs)
28
+ ecg.plot(
29
+ leads,
30
+ "out.png",
31
+ inches=(9.84, 6.3), # or resolution=(2952, 1890)
32
+ dpi=300, # default 300
33
+ fs=fs,
34
+ colour=True, # pink RGB paper; False → 1-channel greyscale
35
+ id=data["record_name"],
36
+ anonymise=True,
37
+ filter=True, # apply notch/bandpass; False → footer text only
38
+ notch_hz=60,
39
+ bandpass_hz=(0.05, 150),
40
+ )
41
+
42
+ # Option B: filter yourself first (median / custom chains)
43
+ # leads = ecg.apply_to_leads(leads, ecg.median_baseline, kernel_size=201)
44
+ # ecg.plot(leads, "out.png", ..., filter=False, notch_hz=60, bandpass_hz=(0.05, 150))
45
+ ```
46
+
47
+ ## Size and calibration
48
+
49
+ Provide **either** `inches=(width, height)` **or** `resolution=(width_px, height_px)`.
50
+
51
+ - Resolution → inches via `pixels / dpi`
52
+ - Default DPI is **300**
53
+ - Footer speed / gain are computed from the **saved** geometry:
54
+
55
+ ```text
56
+ duration_s = n_samples / fs
57
+ speed_mm/s = (width_px / dpi) * 25.4 / duration_s
58
+ gain_mm/mV = (height_px / dpi) * 25.4 / 16
59
+ ```
60
+
61
+ At a fixed recording length, a larger page increases mm/s. A taller page increases mm/mV. The footer prints those computed values.
62
+
63
+ ## Filters
64
+
65
+ | Function / option | Role |
66
+ |---|---|
67
+ | `plot(..., filter=True)` | Apply `notch_hz` / `bandpass_hz` inside `plot`; with `filter=False` those kwargs only set the footer text |
68
+ | `bandpass` | Butterworth band-pass (default 0.05–150 Hz) |
69
+ | `notch` | IIR mains notch (50/60 Hz) |
70
+ | `median_baseline` | Median-filter baseline subtraction |
71
+ | `apply_to_leads` | Map any 1-D filter across leads (dict / array / DataFrame) |
72
+
73
+ Notch and band-pass run forward and back, so the QRS is not shifted in time. The default band-pass needs more than about 28 samples. A shorter trace raises `ValueError`.
74
+
75
+ ## Colour vs greyscale
76
+
77
+ - `colour=True` → pink ECG-paper grid
78
+ - `colour=False` → greyscale grid
79
+ - PNG colour is RGB (3 channels); PNG greyscale is mode `L` (1 channel)
80
+
81
+ ## Output formats
82
+
83
+ `plot` writes **PNG**, **PDF**, or **SVG** (inferred from the file extension, or set `format=`):
84
+
85
+ ```python
86
+ ecg.plot(leads, "sheet.pdf", inches=(9.84, 6.3), fs=fs) # vector PDF
87
+ ecg.plot(leads, "sheet.svg", inches=(9.84, 6.3), fs=fs)
88
+ ecg.plot(leads, "sheet.png", resolution=(2952, 1890), dpi=300, fs=fs)
89
+ ```
90
+
91
+ ## CLI
92
+
93
+ ```bash
94
+ ecg-plotter path/to/record -o out.pdf --inches 9.84 6.3 --anonymise --filter --notch-hz 60 --bandpass-hz 0.05 150
95
+ ecg-plotter --jsonl examples/data/synthetic_data.jsonl --index 0 -o out.svg --greyscale
96
+ ecg-plotter --version
97
+ ```
98
+
99
+ ## Anonymisation
100
+
101
+ For ML dataset creation, set `anonymise=True` so the footer ID is always the fixed placeholder `15030416` instead of a real record ID (which models can overfit on).
102
+
103
+ ## Input formats
104
+
105
+ Amplitudes are in millivolts. `load_wfdb` reads WFDB `p_signal`.
106
+
107
+ `plot` and `apply_to_leads` accept:
108
+
109
+ - `dict[str, array]` with lead names (`I`, `II`, … `V6`)
110
+ - `ndarray` of shape `(12, n)` or `(n, 12)` (default name order: `LEAD_NAMES`)
111
+ - `pandas.DataFrame` with lead columns:
112
+ - **wide**: rows = samples, columns = lead names
113
+ - **single-row**: one row whose lead cells contain arrays/lists (e.g. from JSONL)
114
+ - **12 columns and no lead names**: the columns are taken in order as `LEAD_NAMES`, and a warning is issued. Pass `lead_names=` if that order is wrong. Any other unlabelled table is rejected.
115
+
116
+ ```python
117
+ import pandas as pd
118
+
119
+ df = pd.DataFrame(
120
+ {
121
+ "I": signal["I"],
122
+ "II": signal["II"],
123
+ "III": signal["III"],
124
+ "aVR": signal["aVR"],
125
+ "aVL": signal["aVL"],
126
+ "aVF": signal["aVF"],
127
+ "V1": signal["V1"],
128
+ "V2": signal["V2"],
129
+ "V3": signal["V3"],
130
+ "V4": signal["V4"],
131
+ "V5": signal["V5"],
132
+ "V6": signal["V6"],
133
+ }
134
+ ) # shape: (n_samples, 12)
135
+ ecg.plot(df, "out.png", inches=(9.84, 6.3), fs=500)
136
+ ```
137
+
138
+ JSON Lines records from `load_jsonl` must include a positive ``fs`` field (Hz). The CLI uses that value unless ``--fs`` overrides it.
139
+
140
+ ## Layout
141
+
142
+ Classic sheet:
143
+
144
+ ```text
145
+ I aVR V1 V4
146
+ II aVL V2 V5
147
+ III aVF V3 V6
148
+ ─── II rhythm ────
149
+ ```
150
+
151
+ Column width and sample count scale with the recording length (split into four equal segments). If the sample count is not divisible by 4, the last 1–3 samples are dropped so every column has the same duration, and a warning is raised. Sampling rate drives grid spacing (major ≈ 0.2 s).
152
+
153
+ ## Examples
154
+
155
+ Sample data in `examples/data/` is **synthetic only** (no real patient recordings).
156
+ Regenerate with `python examples/data/generate_synthetic.py`.
157
+
158
+ ```bash
159
+ python examples/plot_from_wfdb.py
160
+ python examples/plot_from_dataframe.py
161
+ python examples/plot_from_jsonl.py
162
+ python examples/plot_colour_and_greyscale.py
163
+ python examples/plot_low_dpi.py
164
+ python examples/plot_nonstandard_calibration.py
165
+ python examples/plot_with_standalone_filters.py
166
+ python examples/plot_pdf_and_svg.py
167
+ ```
168
+
169
+ | Script | Shows |
170
+ |---|---|
171
+ | `plot_from_wfdb.py` | Load synthetic WFDB `synth_0001`, filter inside `plot`, colour PNG |
172
+ | `plot_from_dataframe.py` | Build an explicit lead DataFrame and plot it |
173
+ | `plot_from_jsonl.py` | Load the first record from `synthetic_data.jsonl` |
174
+ | `plot_colour_and_greyscale.py` | Inches vs resolution, colour vs greyscale |
175
+ | `plot_low_dpi.py` | Same physical size at 150 DPI (smaller pixel dimensions) |
176
+ | `plot_nonstandard_calibration.py` | Non-standard sizes. Footers report about 50 mm/s with 5 mm/mV, and about 20 mm/s with 12.5 mm/mV |
177
+ | `plot_with_standalone_filters.py` | Filter with `apply_to_leads`, then `plot(..., filter=False)` |
178
+ | `plot_pdf_and_svg.py` | Vector outputs: colour PDF and greyscale SVG |
179
+
180
+ Outputs are written to `examples/output/`.
181
+
182
+ ![Greyscale sheet, same page size](examples/output/size_grey_resolution.png)
183
+
184
+ ## How to cite
185
+
186
+ If you use this software, please cite it:
187
+
188
+ Hartley, J. P. (2026). *ecg-plotter*. Zenodo. https://doi.org/10.5281/zenodo.23984258
189
+
190
+ ## Tests
191
+
192
+ ```bash
193
+ pip install -e ".[dev]"
194
+ pytest
195
+ ```
196
+
197
+ GitHub Actions runs these commands on Python 3.9, 3.11, and 3.12.
@@ -0,0 +1,63 @@
1
+ """ECG Plotter — filter and render 12-lead ECG traces for research / visualisation.
2
+
3
+ Not intended for diagnostic use. Dependencies such as matplotlib, scipy, wfdb,
4
+ pandas, and Pillow are imported lazily when needed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from importlib import import_module
10
+ from typing import Any
11
+
12
+ from ecg_plotter._version import __version__
13
+
14
+ __all__ = [
15
+ "ANONYMOUS_ID",
16
+ "LEAD_NAMES",
17
+ "STANDARD_LAYOUT",
18
+ "__version__",
19
+ "apply_to_leads",
20
+ "as_lead_dict",
21
+ "bandpass",
22
+ "leads_to_array",
23
+ "load_jsonl",
24
+ "load_wfdb",
25
+ "median_baseline",
26
+ "notch",
27
+ "paper_calibration",
28
+ "plot",
29
+ ]
30
+
31
+ # name -> (module, attribute)
32
+ # Drawing lives in ``sheet``, not a module named ``plot``. A submodule called
33
+ # ``plot`` would replace the public ``ecg.plot`` function as soon as that
34
+ # submodule was imported.
35
+ _LAZY_ATTRS: dict[str, tuple[str, str]] = {
36
+ "ANONYMOUS_ID": ("ecg_plotter.sheet", "ANONYMOUS_ID"),
37
+ "LEAD_NAMES": ("ecg_plotter.leads", "LEAD_NAMES"),
38
+ "STANDARD_LAYOUT": ("ecg_plotter.leads", "STANDARD_LAYOUT"),
39
+ "apply_to_leads": ("ecg_plotter.filters", "apply_to_leads"),
40
+ "as_lead_dict": ("ecg_plotter.leads", "as_lead_dict"),
41
+ "bandpass": ("ecg_plotter.filters", "bandpass"),
42
+ "leads_to_array": ("ecg_plotter.io", "leads_to_array"),
43
+ "load_jsonl": ("ecg_plotter.io", "load_jsonl"),
44
+ "load_wfdb": ("ecg_plotter.io", "load_wfdb"),
45
+ "median_baseline": ("ecg_plotter.filters", "median_baseline"),
46
+ "notch": ("ecg_plotter.filters", "notch"),
47
+ "paper_calibration": ("ecg_plotter.sheet", "paper_calibration"),
48
+ "plot": ("ecg_plotter.sheet", "plot"),
49
+ }
50
+
51
+
52
+ def __getattr__(name: str) -> Any:
53
+ try:
54
+ module_name, attr_name = _LAZY_ATTRS[name]
55
+ except KeyError as exc:
56
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
57
+ value = getattr(import_module(module_name), attr_name)
58
+ globals()[name] = value
59
+ return value
60
+
61
+
62
+ def __dir__() -> list[str]:
63
+ return sorted(set(globals()) | set(__all__))
@@ -0,0 +1,3 @@
1
+ """Package version — single source of truth for runtime and packaging."""
2
+
3
+ __version__ = "0.1.0"