Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions examples/benchmark/benchmark_detectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
benchmark = "runtime"
elif len(sys.argv) > 2:
print("Usage: python benchmark_detectors.py [<benchmark>]")
exit()
sys.exit()
else:
benchmark = sys.argv[1]

Expand All @@ -30,7 +30,9 @@
try:
cfg = cfg[benchmark]
except KeyError:
raise ValueError(f"Invalid benchmark: {benchmark!r}, available: {list(cfg)}.") from None
raise ValueError(
f"Invalid benchmark: {benchmark!r}, available: {list(cfg)}."
) from None

if cfg.get("suppress_warnings", False):
warnings.filterwarnings("ignore")
Expand Down
10 changes: 7 additions & 3 deletions examples/benchmark/plot_benchmark_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

if len(sys.argv) != 2:
print("Usage: python plot_benchmark_results.py <results>.csv")
exit()
sys.exit()

results_filepath = Path(sys.argv[1])
benchmark, db_slug, *_ = results_filepath.stem.split("__")
Expand All @@ -39,7 +39,9 @@
.apply(lambda x: 1 / x) # reverse order
.to_dict()
)
results = results.sort_values(by=["detector", "signal_len"], key=lambda x: x.map(order))
results = results.sort_values(
by=["detector", "signal_len"], key=lambda x: x.map(order)
)

# each detector should have the same color in each benchmark
colors = [px.colors.qualitative.Plotly[i] for i in [0, 1, 7, 5, 8, 4, 2, 3, 9, 6]]
Expand Down Expand Up @@ -72,7 +74,9 @@
results["f1"] = 2 / (results["recall"] ** -1 + results["precision"] ** -1)
fig = (
px.box(
results.melt(id_vars=["detector"], value_vars=["precision", "recall", "f1"]),
results.melt(
id_vars=["detector"], value_vars=["precision", "recall", "f1"]
),
color="detector",
y="value",
labels={"value": ""},
Expand Down
8 changes: 3 additions & 5 deletions examples/benchmark/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@
class HeartpyWarning(Warning):
"""Warning for all Heartpy-related warnings."""

pass


def reader_dispatch(db_slug: str, data_dir: str | Path) -> Iterator[ECGRecord]:
"""
Expand Down Expand Up @@ -106,9 +104,9 @@ def detector_dispatch(ecg: np.ndarray, fs: float, detector: str) -> np.ndarray:
import neurokit2

clean_ecg = neurokit2.ecg.ecg_clean(ecg, int(fs), method="kalidas2017")
detection = neurokit2.ecg.ecg_findpeaks(clean_ecg, int(fs), method="kalidas2017")[
"ECG_R_Peaks"
]
detection = neurokit2.ecg.ecg_findpeaks(
clean_ecg, int(fs), method="kalidas2017"
)["ECG_R_Peaks"]
elif detector == "sleepecg-c":
detection = sleepecg.detect_heartbeats(ecg, fs, backend="c")
elif detector == "sleepecg-numba":
Expand Down
11 changes: 4 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -108,17 +108,14 @@ pretty = true
markers = ["c_extension"]
filterwarnings = ["error"]

[tool.ruff]
line-length = 92
exclude = ["setup.py"]

[tool.ruff.lint]
select = ["C4", "D", "E", "F", "FURB", "I", "PERF", "W", "UP"]
ignore = ["D105"]
extend-select = ["C4", "D", "PERF", "W"]

@DimitriPapadopoulos DimitriPapadopoulos Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cbrnr Not 100 % sure FURB, I or UP should be removed. From scientific-python/cookie#843:

Ruff 0.16 turns on 413 rules without configuration. Compared the default set against the full rule index: BLE, DTZ, FA, FLY, INT, PIE, and YTT are fully covered (including preview rules), and I is covered except for I002, which needs the lint.isort.required-imports setting. No other group is fully covered.
[...]

  • Removed the RF102 check ("isort must be selected"). RF101 (B) and RF103 (UP) stay, because those groups are not fully default.

But then the defaults may be good enough for us.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I thought these were included by default? https://docs.astral.sh/ruff/default-rules/

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not all rules, but I guess the ruff authors made a sensible choice.

Nevertheless, I'd like to understand which rules in B and UP they left out, and why they left out I002 — it should be a no-op anyway without required-imports.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I think the defaults should be fine, but feel free to add specific rules that you think are important.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's the B and UP rules that are not selected by default still:

🤖 AI text below 🤖

Checked against ruff 0.16.2 (--show-settings enabled list vs ruff rule --all).

B (flake8-bugbear): 43 rules, 29 on by default, 14 not.

Stable rules you only get by selecting B:

Code Rule
B007 unused-loop-control-variable
B011 assert-false
B024 abstract-base-class-without-abstract-method
B027 empty-method-without-abstract-decorator
B028 no-explicit-stacklevel
B034 re-sub-positional-args
B904 raise-without-from-inside-except
B905 zip-without-explicit-strict
B911 batched-without-explicit-strict
B912 map-without-explicit-strict

Preview-only, so selecting B still does not turn them on: B043, B901, B903, B909.

UP (pyupgrade): 48 rules, 42 on by default, 6 not.

Stable gains: UP013 (convert-typed-dict-functional-to-class), UP015 (redundant-open-modes), UP042 (replace-str-enum).
Preview-only: UP051. Removed from ruff entirely: UP027 (0.8.0), UP038 (0.13.0).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO, there are still good codes not enabled by bugbear, but pyupgrade is pretty well covered. Not enough that I'd drop UP, but not enough for me to recommend it as a dedicated check anymore, I think.

ignore = ["D105", "DTZ"]

[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"]
"__init__.py" = ["F401", "F403"]
"**/examples/*.py" = ["D100"]
"setup.py" = ["D10"]

[tool.ruff.lint.pydocstyle]
convention = "numpy"
2 changes: 1 addition & 1 deletion src/sleepecg/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from sleepecg.config import get_config, get_config_value, set_config
from sleepecg.feature_extraction import extract_features, preprocess_rri
from sleepecg.heartbeats import compare_heartbeats, detect_heartbeats, rri_similarity
from sleepecg.io import * # noqa: F403
from sleepecg.io import *
from sleepecg.plot import plot_ecg, plot_hypnogram
from sleepecg.utils import get_toy_ecg

Expand Down
26 changes: 14 additions & 12 deletions src/sleepecg/classification.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,18 +347,20 @@ def list_classifiers(classifiers_dir: str | Path | None = None) -> None:
classifiers_dir = Path(classifiers_dir).expanduser()

for classifier_filepath in classifiers_dir.glob("*.zip"):
with ZipFile(classifier_filepath, "r") as zip_file:
with zip_file.open("info.yml") as infofile:
classifier_info = yaml.safe_load(infofile)
features = ", ".join(
classifier_info["feature_extraction_params"]["feature_selection"]
)
print(
f" {classifier_filepath.stem}\n"
f" stages_mode: {classifier_info['stages_mode'].upper()}\n"
f" model type: {classifier_info['model_type']}\n"
f" features: {features}\n"
)
with (
ZipFile(classifier_filepath, "r") as zip_file,
zip_file.open("info.yml") as infofile,
):
classifier_info = yaml.safe_load(infofile)
features = ", ".join(
classifier_info["feature_extraction_params"]["feature_selection"]
)
print(
f" {classifier_filepath.stem}\n"
f" stages_mode: {classifier_info['stages_mode'].upper()}\n"
f" model type: {classifier_info['model_type']}\n"
f" features: {features}\n"
)


def _confusion_matrix(y_true: np.ndarray, y_pred: np.ndarray, N: int) -> np.ndarray:
Expand Down
2 changes: 1 addition & 1 deletion src/sleepecg/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def _read_yaml(path: Path) -> dict[str, Any]:
if cfg is None:
return {}
if not isinstance(cfg, dict):
raise ValueError(f"Invalid YAML config file at {path}")
raise ValueError(f"Invalid YAML config file at {path}") # noqa: TRY004
return cfg


Expand Down
33 changes: 25 additions & 8 deletions src/sleepecg/feature_extraction.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@
"metadata": ("recording_start_time", "age", "gender", "weight"),
"actigraphy": ("activity_counts",),
}
_FEATURE_ID_TO_GROUP = {id: group for group, ids in _FEATURE_GROUPS.items() for id in ids}
_FEATURE_ID_TO_GROUP = {
id: group for group, ids in _FEATURE_GROUPS.items() for id in ids
}

_TIME_DOMAIN_EXPECTED_WARNING_MESSAGES = (
"All-NaN slice encountered",
Expand Down Expand Up @@ -137,7 +139,9 @@ def _split_into_windows(
return windows


def _nanpsd(x: np.ndarray, fs: float, max_nans: float = 0) -> tuple[np.ndarray, np.ndarray]:
def _nanpsd(
x: np.ndarray, fs: float, max_nans: float = 0
) -> tuple[np.ndarray, np.ndarray]:
"""
Compute power spectral density (PSD) along axis 1, ignoring NaNs.

Expand Down Expand Up @@ -172,7 +176,9 @@ def _nanpsd(x: np.ndarray, fs: float, max_nans: float = 0) -> tuple[np.ndarray,

# remaining rows with less than max_nans NaNs
empty_rows_mask = nan_fraction == 1
for i in np.where((nan_fraction <= max_nans) & ~(full_rows_mask | empty_rows_mask))[0]:
for i in np.where((nan_fraction <= max_nans) & ~(full_rows_mask | empty_rows_mask))[
0
]:
semi_valid_window = x[i]
valid_part = semi_valid_window[~np.isnan(semi_valid_window)]
_, Pxx[i] = periodogram(valid_part, fs=fs, window="hann", nfft=nfft)
Expand Down Expand Up @@ -467,12 +473,16 @@ def _parse_feature_selection(

duplicate_ids = {x for x in feature_ids if feature_ids.count(x) > 1}
if duplicate_ids:
warnings.warn(f"Duplicates in feature selection: {duplicate_ids}", RuntimeWarning)
warnings.warn(
f"Duplicates in feature selection: {duplicate_ids}", RuntimeWarning
)

return list(required_groups), feature_ids, selected_cols


def _check_frequencydomain_window_time(window_time: int, feature_ids: list[str]) -> None:
def _check_frequencydomain_window_time(
window_time: int, feature_ids: list[str]
) -> None:
"""
Warn if the duration of the analysis window is too short for a frequency domain feature.

Expand Down Expand Up @@ -676,11 +686,16 @@ def _extract_features_single(
X.append(record.activity_counts.reshape(-1, 1))
features = np.hstack(X)[:, col_indices]

if record.sleep_stages is None or sleep_stage_duration == record.sleep_stage_duration:
if (
record.sleep_stages is None
or sleep_stage_duration == record.sleep_stage_duration
):
stages = record.sleep_stages
else:
if record.sleep_stage_duration is None:
raise ValueError(f"sleep_stage_duration not available for record {record.id}")
raise ValueError(
f"sleep_stage_duration not available for record {record.id}"
)
stages = interp1d(
np.arange(len(record.sleep_stages)) * record.sleep_stage_duration,
record.sleep_stages,
Expand Down Expand Up @@ -771,7 +786,9 @@ def extract_features(
if feature_selection is None:
feature_selection = list(_FEATURE_GROUPS)

required_groups, feature_ids, col_indices = _parse_feature_selection(feature_selection)
required_groups, feature_ids, col_indices = _parse_feature_selection(
feature_selection
)
_check_frequencydomain_window_time(lookback + lookforward, feature_ids)

# _extract_features_single has two return values, so the list returned by _parallel
Expand Down
50 changes: 28 additions & 22 deletions src/sleepecg/heartbeats.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,9 @@ def detect_heartbeats(ecg: np.ndarray, fs: float, backend: str = "c") -> np.ndar
integrated_ecg = _squared_moving_integration(derivative, moving_window_width)
beat_mask = _thresholding(filtered_ecg, integrated_ecg, fs)
elif backend == "numba":
integrated_ecg = _squared_moving_integration_numba(derivative, moving_window_width)
integrated_ecg = _squared_moving_integration_numba(
derivative, moving_window_width
)
beat_mask = _thresholding_numba(filtered_ecg, integrated_ecg, fs)
elif backend == "python":
integrated_ecg = np.convolve(
Expand Down Expand Up @@ -457,21 +459,25 @@ def _thresholding_py(
# one signal is between the reduced and original threshold, the
# other one above the reduced threshold
if (
threshold_F1 / searchback_divisor < PEAKF
and PEAKF < threshold_F1
and threshold_I1 / searchback_divisor < PEAKI
) or (
threshold_I1 / searchback_divisor < PEAKI
and PEAKI < threshold_I1
and threshold_F1 / searchback_divisor < PEAKF
):
if PEAKF > best_candidate_amplitude:
# highest one so far
best_searchback_index = searchback_index
best_candidate_amplitude = filtered_ecg[
searchback_index
]
found_a_candidate = True
(
threshold_F1 / searchback_divisor
< PEAKF
< threshold_F1
and threshold_I1 / searchback_divisor < PEAKI
)
or (
threshold_I1 / searchback_divisor
< PEAKI
< threshold_I1
and threshold_F1 / searchback_divisor < PEAKF
)
) and PEAKF > best_candidate_amplitude:
# highest one so far
best_searchback_index = searchback_index
best_candidate_amplitude = filtered_ecg[
searchback_index
]
found_a_candidate = True

# the amplitude of the next sample is lower, so it can't be a peak
# -> skip it
Expand Down Expand Up @@ -539,8 +545,7 @@ def _thresholding_py(
if amplitude_before > amplitude_here:
break
slope = amplitude_here - amplitude_before
if slope > max_slope_in_this_peak:
max_slope_in_this_peak = slope
max_slope_in_this_peak = max(max_slope_in_this_peak, slope)
reverse_index -= 1

reverse_index = previous_peak_index
Expand All @@ -551,8 +556,7 @@ def _thresholding_py(
if amplitude_before > amplitude_here:
break
slope = amplitude_here - amplitude_before
if slope > max_slope_in_previous_peak:
max_slope_in_previous_peak = slope
max_slope_in_previous_peak = max(max_slope_in_previous_peak, slope)
reverse_index -= 1

if max_slope_in_this_peak < max_slope_in_previous_peak / 2.0:
Expand Down Expand Up @@ -596,7 +600,7 @@ def _thresholding_py(
irregular = False
for i in range(num_peaks_found, 1, -1):
RR_n = RR_intervals[i]
if RR_low_limit < RR_n and RR_n < RR_high_limit:
if RR_low_limit < RR_n < RR_high_limit:
RR_sum += RR_n
RR_count += 1
if RR_count >= 8:
Expand Down Expand Up @@ -639,5 +643,7 @@ def _thresholding_py(


if "numba" in _available_backends:
_squared_moving_integration_numba = jit(_squared_moving_integration_py, nopython=True)
_squared_moving_integration_numba = jit(
_squared_moving_integration_py, nopython=True
)
_thresholding_numba = jit(_thresholding_py, nopython=True)
8 changes: 6 additions & 2 deletions src/sleepecg/io/ecg_readers.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ def plot(self, **kwargs: np.ndarray) -> tuple[Figure, Axes]:
ax : matplotlib.axes.Axes
The axes in the figure.
"""
return plot_ecg(self.ecg, self.fs, title=self.id, beats=self.annotation, **kwargs)
return plot_ecg(
self.ecg, self.fs, title=self.id, beats=self.annotation, **kwargs
)


def export_ecg_record(record: ECGRecord, filename: str | Path) -> None:
Expand Down Expand Up @@ -306,7 +308,9 @@ def read_gudb(
lead="chest",
id=f"{subject_id:02}_{experiment}",
)
annotations_chest_file = db_dir / experiment_subdir / "annotation_cables.tsv"
annotations_chest_file = (
db_dir / experiment_subdir / "annotation_cables.tsv"
)
if annotations_chest_file.is_file():
annotations = np.loadtxt(annotations_chest_file, dtype=np.int32)
for lead in ("II", "III"):
Expand Down
6 changes: 5 additions & 1 deletion src/sleepecg/io/gudb.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,11 @@ def _generate_gudb_md5(data_dir: str | Path | None = None) -> dict[str, str]:
for subject_id in range(25):
for experiment in EXPERIMENTS:
experiment_subdir = f"subject_{subject_id:02}/{experiment}"
for tsv_filename in ("ECG.tsv", "annotation_cs.tsv", "annotation_cables.tsv"):
for tsv_filename in (
"ECG.tsv",
"annotation_cs.tsv",
"annotation_cables.tsv",
):
target_filepath = db_dir / experiment_subdir / tsv_filename
try:
checksum = _calculate_checksum(target_filepath, "md5")
Expand Down
4 changes: 3 additions & 1 deletion src/sleepecg/io/physionet.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ def _list_physionet(

records_filepath = data_dir / db_slug / _RECORDS_FILENAME
records_url = f"{_PHYSIONET_FILES_URL}/{db_slug}/{db_version}/{_RECORDS_FILENAME}"
checksum = _get_physionet_checksums(data_dir, db_slug, db_version)[_RECORDS_FILENAME]
checksum = _get_physionet_checksums(data_dir, db_slug, db_version)[
_RECORDS_FILENAME
]

if not records_filepath.is_file():
_download_file(records_url, records_filepath, checksum, _CHECKSUM_TYPE)
Expand Down
Loading