atriakit 0.1.0__py3-none-any.whl

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 (39) hide show
  1. atriakit/__init__.py +65 -0
  2. atriakit/configs/annotations_loader_config.py +24 -0
  3. atriakit/configs/feature_computation_config.py +45 -0
  4. atriakit/configs/pipeline_config.py +32 -0
  5. atriakit/configs/segment_config.py +11 -0
  6. atriakit/configs/signal_preprocessor_config.py +48 -0
  7. atriakit/constants.py +18 -0
  8. atriakit/datasets.py +286 -0
  9. atriakit/feature_calculator.py +1403 -0
  10. atriakit/features/__init__.py +0 -0
  11. atriakit/features/amplitude.py +19 -0
  12. atriakit/features/area.py +104 -0
  13. atriakit/features/complexity.py +51 -0
  14. atriakit/features/entropy.py +51 -0
  15. atriakit/features/fragments.py +117 -0
  16. atriakit/features/morphology.py +189 -0
  17. atriakit/features/vcg.py +81 -0
  18. atriakit/io/__init__.py +13 -0
  19. atriakit/io/annotations_loader.py +223 -0
  20. atriakit/io/base_loader.py +15 -0
  21. atriakit/io/dicom_loader.py +66 -0
  22. atriakit/io/ecg_loader.py +47 -0
  23. atriakit/models/__init__.py +3 -0
  24. atriakit/models/annotation_row.py +18 -0
  25. atriakit/models/annotation_schema.py +25 -0
  26. atriakit/models/annotations.py +193 -0
  27. atriakit/models/ecg_data.py +94 -0
  28. atriakit/preprocessing/__init__.py +9 -0
  29. atriakit/preprocessing/annotations.py +43 -0
  30. atriakit/preprocessing/signals.py +247 -0
  31. atriakit/processing/dataset.py +215 -0
  32. atriakit/processing/pipeline.py +542 -0
  33. atriakit/processing/segment_processor.py +122 -0
  34. atriakit/utils.py +180 -0
  35. atriakit-0.1.0.dist-info/METADATA +330 -0
  36. atriakit-0.1.0.dist-info/RECORD +39 -0
  37. atriakit-0.1.0.dist-info/WHEEL +5 -0
  38. atriakit-0.1.0.dist-info/licenses/LICENSE +21 -0
  39. atriakit-0.1.0.dist-info/top_level.txt +1 -0
atriakit/__init__.py ADDED
@@ -0,0 +1,65 @@
1
+ """Public package interface for the ECG feature library."""
2
+
3
+ from importlib import import_module
4
+
5
+ try:
6
+ from importlib.metadata import version, PackageNotFoundError
7
+ __version__ = version("atriakit")
8
+ except PackageNotFoundError:
9
+ __version__ = "unknown"
10
+
11
+ __all__ = [
12
+ "create_demo_data",
13
+ "AnnotationRow",
14
+ "Annotations",
15
+ "AnnotationsLoaderConfig",
16
+ "AnnotationsLoader",
17
+ "BaseLoader",
18
+ "DicomLoader",
19
+ "ECGData",
20
+ "ECGDataset",
21
+ "ECGLoader",
22
+ "FeatureCalculators",
23
+ "FeatureComputationConfig",
24
+ "Pipeline",
25
+ "PipelineConfig",
26
+ "SegmentConfig",
27
+ "SignalPreprocessor",
28
+ "SignalPreprocessorConfig",
29
+ ]
30
+
31
+ _PUBLIC_IMPORTS = {
32
+ "create_demo_data": ("atriakit.datasets", "generate"),
33
+ "AnnotationRow": ("atriakit.models.annotation_row", "AnnotationRow"),
34
+ "Annotations": ("atriakit.models.annotations", "Annotations"),
35
+ "AnnotationsLoaderConfig": ("atriakit.configs.annotations_loader_config", "AnnotationsLoaderConfig"),
36
+ "AnnotationsLoader": ("atriakit.io", "AnnotationsLoader"),
37
+ "BaseLoader": ("atriakit.io", "BaseLoader"),
38
+ "DicomLoader": ("atriakit.io", "DicomLoader"),
39
+ "ECGData": ("atriakit.models.ecg_data", "ECGData"),
40
+ "ECGDataset": ("atriakit.processing.dataset", "ECGDataset"),
41
+ "ECGLoader": ("atriakit.io", "ECGLoader"),
42
+ "FeatureCalculators": ("atriakit.feature_calculator", "FeatureCalculators"),
43
+ "FeatureComputationConfig": (
44
+ "atriakit.configs.feature_computation_config",
45
+ "FeatureComputationConfig",
46
+ ),
47
+ "Pipeline": ("atriakit.processing.pipeline", "Pipeline"),
48
+ "PipelineConfig": ("atriakit.configs.pipeline_config", "PipelineConfig"),
49
+ "SegmentConfig": ("atriakit.configs.segment_config", "SegmentConfig"),
50
+ "SignalPreprocessor": ("atriakit.preprocessing", "SignalPreprocessor"),
51
+ "SignalPreprocessorConfig": (
52
+ "atriakit.configs.signal_preprocessor_config",
53
+ "SignalPreprocessorConfig",
54
+ ),
55
+ }
56
+
57
+
58
+ def __getattr__(name):
59
+ if name not in _PUBLIC_IMPORTS:
60
+ raise AttributeError(f"module 'atriakit' has no attribute {name!r}")
61
+
62
+ module_name, attr_name = _PUBLIC_IMPORTS[name]
63
+ value = getattr(import_module(module_name), attr_name)
64
+ globals()[name] = value
65
+ return value
@@ -0,0 +1,24 @@
1
+ from dataclasses import dataclass
2
+ from typing import Literal
3
+
4
+
5
+ @dataclass(slots=True)
6
+ class AnnotationsLoaderConfig:
7
+ """Configuration for the AnnotationsLoader.
8
+
9
+ Parameters
10
+ ----------
11
+ boundary_mode : {"per_lead", "cross_lead"}
12
+ Controls which onset/offset values are treated as active after loading.
13
+
14
+ ``"cross_lead"`` (default)
15
+ ``onset`` and ``offset`` hold the multilead consensus values
16
+ (cross-lead minimum onset, cross-lead maximum offset).
17
+ No substitution is performed.
18
+
19
+ ``"per_lead"``
20
+ ``onset`` and ``offset`` are replaced by ``onset_original`` and
21
+ ``offset_original`` — the raw per-lead annotation values.
22
+ """
23
+
24
+ boundary_mode: Literal["per_lead", "cross_lead"] = "cross_lead"
@@ -0,0 +1,45 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(slots=True)
5
+ class FeatureComputationConfig:
6
+ """Feature-computation parameters passed to FeatureCalculators.compute_all()."""
7
+
8
+ # Entropy
9
+ shannon_entropy_n_bins: int = 32 # number of histogram bins for Shannon entropy
10
+ shannon_entropy_bin_range: tuple[float, float] | None = (
11
+ None # fixed amplitude range for the histogram; None uses per-signal min/max. For comparable results should be set to the same value across the datasets.
12
+ )
13
+ sample_entropy_m: int = (
14
+ 2 # embedding dimension (template length) for sample entropy
15
+ )
16
+ sample_entropy_r_factor: float = (
17
+ 0.25 # tolerance as fraction of segment SD; controls how strictly templates must match
18
+ )
19
+
20
+ # Complexity / extrema
21
+ extrema_threshold_multiplier: float = (
22
+ 0.1 # peak must exceed this fraction of max amplitude to be counted (complexity feature)
23
+ )
24
+
25
+ # Noise & morphology
26
+ noise_sd_multiplier: float = (
27
+ 3.0 # a signal deflection must exceed this many noise SDs to be counted as a fragment or extremum
28
+ )
29
+ morphology_min_phase_fraction: float = (
30
+ 0.1 # minimum fraction of segment length a sign group must span to count as a distinct morphology phase (0.1 = at least 10% of P-wave)
31
+ )
32
+ morphology_noise_sd_multiplier: float = (
33
+ 3.0 # same as noise_sd_multiplier but applied only inside the morphology classifier
34
+ )
35
+
36
+ # Fragmentation
37
+ fragment_noise_multiplier: float = (
38
+ 3.0 # a fragment's amplitude change must exceed this many noise SDs to count as a valid fragment boundary
39
+ )
40
+ min_fragment_length_ms: float = (
41
+ 0.0 # fragments shorter than this (ms) are discarded
42
+ )
43
+ normalize_by_duration: bool = (
44
+ False # normalise fragment metrics per 100ms of P-wave duration; True matches the original paper
45
+ )
@@ -0,0 +1,32 @@
1
+ from dataclasses import dataclass, field
2
+
3
+ from atriakit.configs.feature_computation_config import FeatureComputationConfig
4
+ from atriakit.configs.segment_config import SegmentConfig
5
+ from atriakit.configs.signal_preprocessor_config import (
6
+ SignalPreprocessorConfig,
7
+ default_morphology_preprocessor_config,
8
+ default_signal_preprocessor_config,
9
+ )
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class PipelineConfig:
14
+ """Batch pipeline parameters."""
15
+
16
+ feature_computation: FeatureComputationConfig = field(
17
+ default_factory=FeatureComputationConfig # settings passed to FeatureCalculators.compute_all(); see FeatureComputationConfig
18
+ )
19
+ segment_config: SegmentConfig = field(
20
+ default_factory=SegmentConfig # segment-boundary and baseline-correction settings; see SegmentConfig
21
+ )
22
+ signal_preprocessor_config: SignalPreprocessorConfig = field(
23
+ default_factory=default_signal_preprocessor_config # preprocessing for amplitude and duration features
24
+ )
25
+ morphology_preprocessor_config: SignalPreprocessorConfig = field(
26
+ default_factory=default_morphology_preprocessor_config # preprocessing for shape-based features
27
+ )
28
+
29
+ # Grouping
30
+ group_tolerance_ms: int = (
31
+ 200 # max time gap (ms) between P-wave peaks across leads to be grouped as the same beat
32
+ )
@@ -0,0 +1,11 @@
1
+ from typing import Literal
2
+
3
+ from pydantic.dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class SegmentConfig:
8
+ """Segment-boundary and baseline-correction settings for ``SegmentProcessor``."""
9
+
10
+ baseline_correction_type: Literal["none", "linear", "onset"] = "none" # Baseline correction applied to extracted segments before feature calculation.
11
+ skip_first_ms: int = 0 # Drops the first samples of each annotated segment before feature extraction.
@@ -0,0 +1,48 @@
1
+ from dataclasses import dataclass
2
+ from typing import Literal
3
+
4
+ import numpy as np
5
+
6
+
7
+ @dataclass(slots=True)
8
+ class SignalPreprocessorConfig:
9
+ """Default parameters for ECG signal preprocessing."""
10
+
11
+ lowcut: float | None = (
12
+ None # Low cutoff for the bandpass filter; `None` disables bandpass filtering.
13
+ )
14
+ highcut: float | None = (
15
+ None # High cutoff for the bandpass filter; `None` disables bandpass filtering.
16
+ )
17
+ notch_freq: float | list[float] | None = (
18
+ 50.0 # Line-noise notch frequency/frequencies in Hz; set to `None` to skip the notch filter.
19
+ )
20
+ order: int = 2 # Butterworth bandpass filter order.
21
+ quality_factor: float = (
22
+ 20.0 # Q factor for the notch filter; higher values make the notch narrower.
23
+ )
24
+ global_baseline_mode: Literal["none", "p_onset_spline"] = (
25
+ "p_onset_spline" # Full-recording baseline correction mode applied before segment extraction.
26
+ )
27
+ global_baseline_smoothing_factor: float = (
28
+ 0 # Dimensionless smoothing strength for the SciPy spline fit; 0 keeps exact interpolation, larger values follow only slower baseline drift.
29
+ )
30
+ normalization_type: Literal["none", "zscore"] = (
31
+ "none" # Dataset-level normalization to apply after filtering; use `"none"` to disable it.
32
+ )
33
+ mean: np.ndarray | None = (
34
+ None # Per-lead mean used for zscore normalization; Either provide or compute with ECGDataset. Required if `normalization_type` is `"zscore"`.
35
+ )
36
+ std: np.ndarray | None = (
37
+ None # Per-lead standard deviation used for zscore normalization; Either provide or compute with ECGDataset. Required if `normalization_type` is `"zscore"`.
38
+ )
39
+
40
+
41
+ def default_signal_preprocessor_config() -> SignalPreprocessorConfig:
42
+ """Low-pass at 120 Hz with 50/100 Hz notch. Used for amplitude and duration features."""
43
+ return SignalPreprocessorConfig(lowcut=None, highcut=120, notch_freq=[50.0, 100.0])
44
+
45
+
46
+ def default_morphology_preprocessor_config() -> SignalPreprocessorConfig:
47
+ """Low-pass at 30 Hz with 50/100 Hz notch. Used for P-wave shape classification and inflection detection."""
48
+ return SignalPreprocessorConfig(lowcut=None, highcut=30, notch_freq=[50.0, 100.0])
atriakit/constants.py ADDED
@@ -0,0 +1,18 @@
1
+ """Package-level constants shared across modules."""
2
+
3
+ # Leads required by the Kors regression matrix to compute a 3-lead VCG.
4
+ VCG_LEADS: list[str] = ["I", "II", "V1", "V2", "V3", "V4", "V5", "V6"]
5
+
6
+ # Output columns produced by VCG feature computation.
7
+ VCG_FEATURE_COLUMNS: list[str] = [
8
+ "vcg_eigenvalues_1", "vcg_eigenvalues_2", "vcg_eigenvalues_3",
9
+ "vcg_roundness", "vcg_flatness", "vcg_area",
10
+ "vcg_axis_elevation", "vcg_axis_azimuth",
11
+ "vcg_sum_fragment_count", "vcg_sum_fragment_width", "vcg_sum_fragment_height",
12
+ "vcg_x_fragment_count", "vcg_x_fragment_width", "vcg_x_fragment_height",
13
+ "vcg_y_fragment_count", "vcg_y_fragment_width", "vcg_y_fragment_height",
14
+ "vcg_z_fragment_count", "vcg_z_fragment_width", "vcg_z_fragment_height",
15
+ ]
16
+
17
+ # Leads required to compute the frontal P-wave axis.
18
+ AXIS_LEADS: list[str] = ["I", "aVF"]
atriakit/datasets.py ADDED
@@ -0,0 +1,286 @@
1
+ """Synthetic demo ECG data for AtriaKit.
2
+
3
+ Requires neurokit2, which is not installed by default::
4
+
5
+ pip install neurokit2
6
+ from atriakit import create_demo_data
7
+ create_demo_data() # writes to ./demo_data/
8
+ create_demo_data(output_dir="./my_data")
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ import numpy as np
16
+ import pandas as pd
17
+ import pydicom
18
+ from pydicom.dataset import Dataset, FileDataset, FileMetaDataset
19
+ from pydicom.sequence import Sequence
20
+ from pydicom.uid import generate_uid
21
+
22
+ from atriakit.models.annotation_schema import AnnotationSchema
23
+
24
+ LEADS = ["I", "II", "III", "aVR", "aVL", "aVF", "V1", "V2", "V3", "V4", "V5", "V6"]
25
+ FS = 500
26
+ DURATION = 10 # seconds
27
+
28
+ _LEAD_SCALES = {
29
+ "I": 0.70, "II": 1.00, "III": 0.40,
30
+ "aVR": -0.80, "aVL": 0.30, "aVF": 0.70,
31
+ "V1": -0.30, "V2": 0.20, "V3": 0.80,
32
+ "V4": 1.20, "V5": 1.10, "V6": 0.90,
33
+ }
34
+ _MV_TO_RAW = 1000 # 1 mV → 1000 int16 units; sensitivity = 0.001 mV/unit
35
+
36
+ DEMO_RECORDINGS = [
37
+ {
38
+ "heart_rate": 60,
39
+ AnnotationSchema.PATIENT_ID: "DEMO001",
40
+ "seed": 0,
41
+ "date": "20240101",
42
+ "time": "090000",
43
+ },
44
+ {
45
+ "heart_rate": 75,
46
+ AnnotationSchema.PATIENT_ID: "DEMO002",
47
+ "seed": 1,
48
+ "date": "20240615",
49
+ "time": "143000",
50
+ },
51
+ {
52
+ "heart_rate": 90,
53
+ AnnotationSchema.PATIENT_ID: "DEMO003",
54
+ "seed": 2,
55
+ "date": "20241201",
56
+ "time": "111500",
57
+ },
58
+ ]
59
+
60
+
61
+ def _require_neurokit():
62
+ try:
63
+ import neurokit2 as nk
64
+
65
+ return nk
66
+ except ImportError:
67
+ raise SystemExit(
68
+ "Demo data generation requires neurokit2.\n"
69
+ "Install it with: pip install neurokit2"
70
+ )
71
+
72
+
73
+ def simulate_12lead(heart_rate: int, noise: float = 0.02, seed: int = 0) -> np.ndarray:
74
+ """Simulate a 12-lead ECG and return a ``(12, n_samples)`` array in mV."""
75
+ nk = _require_neurokit()
76
+ rng = np.random.default_rng(seed)
77
+ lead_ii = np.array(
78
+ nk.ecg_simulate(
79
+ duration=DURATION, sampling_rate=FS, heart_rate=heart_rate, noise=noise
80
+ )
81
+ )
82
+ ecg = np.zeros((12, len(lead_ii)))
83
+ for i, lead in enumerate(LEADS):
84
+ per_lead_noise = rng.normal(0, noise * 0.05, len(lead_ii))
85
+ ecg[i] = _LEAD_SCALES[lead] * lead_ii + per_lead_noise
86
+ return ecg
87
+
88
+
89
+ def to_dicom(
90
+ ecg: np.ndarray,
91
+ patient_id: str,
92
+ date: str = "20240101",
93
+ time: str = "120000",
94
+ leads: list[str] | None = None,
95
+ ) -> FileDataset:
96
+ """Pack an ECG array into a DICOM FileDataset.
97
+
98
+ Args:
99
+ ecg: Signal array with shape ``(n_leads, n_samples)`` in mV.
100
+ patient_id: Patient identifier written into DICOM metadata.
101
+ date: Study date in ``YYYYMMDD`` format.
102
+ time: Study time in ``HHMMSS`` format.
103
+ leads: Channel labels matching the row order of ``ecg``.
104
+ Defaults to the standard 12-lead ``LEADS`` list.
105
+
106
+ Returns:
107
+ A ``pydicom.FileDataset`` ready to save with ``ds.save_as(path)``.
108
+ """
109
+ if leads is None:
110
+ leads = LEADS
111
+ sop_uid = generate_uid()
112
+
113
+ file_meta = FileMetaDataset()
114
+ file_meta.MediaStorageSOPClassUID = "1.2.840.10008.5.1.4.1.1.9.1.1"
115
+ file_meta.MediaStorageSOPInstanceUID = sop_uid
116
+ file_meta.TransferSyntaxUID = pydicom.uid.ExplicitVRLittleEndian
117
+
118
+ ds = FileDataset(None, {}, file_meta=file_meta, preamble=b"\0" * 128)
119
+ ds.is_implicit_VR = False
120
+ ds.is_little_endian = True
121
+
122
+ ds.SOPClassUID = file_meta.MediaStorageSOPClassUID
123
+ ds.SOPInstanceUID = sop_uid
124
+ ds.PatientID = patient_id
125
+ ds.PatientName = f"Demo^{patient_id}"
126
+ ds.ContentDate = date
127
+ ds.ContentTime = time
128
+ ds.StudyDate = date
129
+ ds.StudyTime = time
130
+
131
+ wf = Dataset()
132
+ wf.MultiplexGroupLabel = "ECG"
133
+ wf.SamplingFrequency = float(FS)
134
+
135
+ n_ch, n_samples = ecg.shape
136
+ wf.add_new(pydicom.tag.Tag(0x003A, 0x0005), "US", n_ch) # NumberOfChannels
137
+ wf.add_new(
138
+ pydicom.tag.Tag(0x003A, 0x0010), "UL", n_samples
139
+ ) # NumberOfWaveformSamples
140
+ wf.WaveformBitsAllocated = 16
141
+ wf.WaveformSampleInterpretation = "SS"
142
+
143
+ channels = []
144
+ for lead in leads:
145
+ ch = Dataset()
146
+ ch.ChannelLabel = lead
147
+ units = Dataset()
148
+ units.CodeValue = "mV"
149
+ units.CodingSchemeDesignator = "UCUM"
150
+ units.CodeMeaning = "millivolt"
151
+ ch.ChannelSensitivityUnitsSequence = Sequence([units])
152
+ ch.ChannelSensitivity = str(1.0 / _MV_TO_RAW)
153
+ ch.ChannelSensitivityCorrectionFactor = "1.0"
154
+ ch.WaveformBitsStored = 16
155
+ channels.append(ch)
156
+ wf.ChannelDefinitionSequence = Sequence(channels)
157
+
158
+ raw = np.clip((ecg * _MV_TO_RAW).T, -32768, 32767).astype(np.int16)
159
+ wf.WaveformData = raw.tobytes()
160
+
161
+ ds.WaveformSequence = Sequence([wf])
162
+ return ds
163
+
164
+
165
+ def detect_p_waves(ecg_12lead: np.ndarray) -> list[dict]:
166
+ """Detect P-wave onsets and offsets on Lead II using NeuroKit2.
167
+
168
+ Returns a list of dicts with keys ``onset``, ``offset``, and ``qrs_onset``
169
+ (sample indices at the pipeline's sampling rate).
170
+ """
171
+ nk = _require_neurokit()
172
+ lead_ii = ecg_12lead[1]
173
+ signals, _ = nk.ecg_process(lead_ii, sampling_rate=FS)
174
+
175
+ p_onset_mask = (
176
+ signals.get("ECG_P_Onsets", pd.Series(0, index=signals.index))
177
+ .fillna(0)
178
+ .astype(int)
179
+ )
180
+ p_offset_mask = (
181
+ signals.get("ECG_P_Offsets", pd.Series(0, index=signals.index))
182
+ .fillna(0)
183
+ .astype(int)
184
+ )
185
+ r_peak_mask = (
186
+ signals.get("ECG_R_Peaks", pd.Series(0, index=signals.index))
187
+ .fillna(0)
188
+ .astype(int)
189
+ )
190
+
191
+ onsets = np.where(p_onset_mask == 1)[0]
192
+ offsets = np.where(p_offset_mask == 1)[0]
193
+ r_peaks = np.where(r_peak_mask == 1)[0]
194
+
195
+ records: list[dict] = []
196
+ used_offsets: set[int] = set()
197
+ for onset in onsets:
198
+ future_offsets = offsets[offsets > onset]
199
+ if not len(future_offsets):
200
+ continue
201
+ offset = int(future_offsets[0])
202
+ if offset in used_offsets:
203
+ continue
204
+ used_offsets.add(offset)
205
+ following_r = r_peaks[r_peaks > offset]
206
+ qrs_onset = int(following_r[0]) - 20 if len(following_r) else -1
207
+ records.append(
208
+ {
209
+ AnnotationSchema.ONSET: int(onset),
210
+ AnnotationSchema.OFFSET: int(offset),
211
+ AnnotationSchema.QRS_ONSET: qrs_onset,
212
+ }
213
+ )
214
+
215
+ return records
216
+
217
+
218
+ def build_annotations(
219
+ file_path: str,
220
+ patient_id: str,
221
+ p_waves: list[dict],
222
+ seed: int = 42,
223
+ ) -> pd.DataFrame:
224
+ """Expand Lead II P-wave detections into a per-lead annotations DataFrame.
225
+
226
+ Adds small random jitter to onset/offset per lead to simulate realistic
227
+ inter-lead variation.
228
+ """
229
+ rng = np.random.default_rng(seed)
230
+ rows = []
231
+ for p_wave_id, pw in enumerate(p_waves):
232
+ for lead in LEADS:
233
+ rows.append(
234
+ {
235
+ AnnotationSchema.PATIENT_ID: patient_id,
236
+ AnnotationSchema.LEAD: lead,
237
+ AnnotationSchema.P_WAVE_ID: p_wave_id,
238
+ AnnotationSchema.ONSET: pw[AnnotationSchema.ONSET]
239
+ + int(rng.integers(-2, 3)),
240
+ AnnotationSchema.OFFSET: pw[AnnotationSchema.OFFSET]
241
+ + int(rng.integers(-2, 3)),
242
+ AnnotationSchema.QRS_ONSET: pw[AnnotationSchema.QRS_ONSET],
243
+ AnnotationSchema.IGNORE: False,
244
+ AnnotationSchema.FILE_PATH: file_path,
245
+ }
246
+ )
247
+ return pd.DataFrame(rows)
248
+
249
+
250
+ def generate(output_dir: str | Path) -> Path:
251
+ """Generate demo DICOM files and an annotations CSV into ``output_dir``.
252
+
253
+ Creates one DICOM per entry in ``DEMO_RECORDINGS`` and a combined
254
+ ``demo_annotations.csv``. Returns the output directory path.
255
+ """
256
+ out = Path(output_dir)
257
+ out.mkdir(parents=True, exist_ok=True)
258
+
259
+ all_annotations: list[pd.DataFrame] = []
260
+ for cfg in DEMO_RECORDINGS:
261
+ patient_id = cfg[AnnotationSchema.PATIENT_ID]
262
+ print(f"[{patient_id}] Simulating 12-lead ECG at {cfg['heart_rate']} bpm …")
263
+
264
+ ecg = simulate_12lead(cfg["heart_rate"], seed=cfg["seed"])
265
+
266
+ dcm = to_dicom(ecg, patient_id=patient_id, date=cfg["date"], time=cfg["time"])
267
+ dcm_path = out / f"{patient_id}.dcm"
268
+ dcm.save_as(str(dcm_path))
269
+ print(f" Saved {dcm_path}")
270
+
271
+ p_waves = detect_p_waves(ecg)
272
+ print(f" Detected {len(p_waves)} P-waves")
273
+
274
+ anns = build_annotations(
275
+ f"{patient_id}.dcm", patient_id, p_waves, seed=cfg["seed"]
276
+ )
277
+ all_annotations.append(anns)
278
+
279
+ combined = pd.concat(all_annotations, ignore_index=True)
280
+ ann_path = out / "demo_annotations.csv"
281
+ combined.to_csv(ann_path, index=False)
282
+ print(
283
+ f"\nSaved {ann_path} "
284
+ f"({len(combined)} rows across {combined[AnnotationSchema.PATIENT_ID].nunique()} patients)"
285
+ )
286
+ return out