improved heart rate calculations

This commit is contained in:
2026-08-24 16:40:02 -07:00
parent d9d5b6d940
commit 2fa3188296
4 changed files with 139 additions and 73 deletions
+1
View File
@@ -11,6 +11,7 @@
- Added description text to the Inter-Group and Intra-Group Brain and Image Viewers, as well as the Functional Connectivity windows to explain what output can be expected - Added description text to the Inter-Group and Intra-Group Brain and Image Viewers, as well as the Functional Connectivity windows to explain what output can be expected
- Removed image index 1 (Significance) from the Intra-Group Brain and Image Viewer as it is now provided more in depth with the Stats viewers - Removed image index 1 (Significance) from the Intra-Group Brain and Image Viewer as it is now provided more in depth with the Stats viewers
- Modified the timeout when waiting for the application to close while performing updates down to a reasonable number - Modified the timeout when waiting for the application to close while performing updates down to a reasonable number
- Modified the heart rate calculation to be more precise and correct when dealing with good data and not messy data
- Modified the heart rate calculation to not take only one channel in the data to use, but rather an average of channels. This still prefers short channels if they are present - Modified the heart rate calculation to not take only one channel in the data to use, but rather an average of channels. This still prefers short channels if they are present
- Fixed an issue that could prevent log file generation while the application was in the middle of an update - Fixed an issue that could prevent log file generation while the application was in the middle of an update
- Fixed an issue where a rare crash could occur while the application was in the middle of an update - Fixed an issue where a rare crash could occur while the application was in the middle of an update
+96 -41
View File
@@ -4526,9 +4526,9 @@ def short_channel_processing_for_hr(
def calculate_heart_rate_neurokit(sfreq: float, signal_trimmed: NDArray[float64], hr_low_freq, hr_high_freq, max_low_hr, max_high_hr, smoothing_window_hr) -> tuple[NDArray[float64], float]: def calculate_heart_rate_neurokit(sfreq: float, signal_trimmed: NDArray[float64], hr_low_freq, hr_high_freq, max_low_hr, max_high_hr, smoothing_window_hr, short_channels) -> tuple[NDArray[float64], float]:
""" """
Calculate and smooth heart rate from a trimmed signal using NeuroKit. Calculate and smooths heart rate from a trimmed signal using NeuroKit.
Parameters Parameters
---------- ----------
@@ -4543,39 +4543,50 @@ def calculate_heart_rate_neurokit(sfreq: float, signal_trimmed: NDArray[float64]
- NDArray[float64]: Smoothed heart rate time series (BPM). - NDArray[float64]: Smoothed heart rate time series (BPM).
- float: Mean heart rate. - float: Mean heart rate.
""" """
logger.info("Calculating heart rate using NeuroKit...") logger.info("Calculating heart rate using NeuroKit...")
# Filter signal to isolate heart rate frequencies and detect peaks logger.info("Filtering the signal and detecting pulsatile peaks...")
logger.info("Filtering the signal and detecting peaks...") signal_filtered = cast(NDArray[float64], nk.signal_filter(signal_trimmed, sampling_rate=sfreq, lowcut=hr_low_freq, highcut=hr_high_freq))
signal_filtered = cast(NDArray[float64], nk.signal_filter(signal_trimmed, sampling_rate=sfreq, lowcut=hr_low_freq, highcut=hr_high_freq)) # type: ignore
peaks_dict = cast(dict[str, Any], nk.signal_findpeaks(signal_filtered)) # type: ignore # bishop works better with challenging datasets, but like it is super slow on good datasets?
peaks = peaks_dict['Peaks'] if short_channels:
hr = cast(NDArray[float64], nk.signal_rate(peaks, sampling_rate=sfreq, desired_length=len(signal_trimmed))) # type: ignore peaks_dict = cast(dict[str, Any], nk.ppg_findpeaks(signal_filtered, sampling_rate=sfreq, method="elgendi"))
else:
peaks_dict = cast(dict[str, Any], nk.ppg_findpeaks(signal_filtered, sampling_rate=sfreq, method="bishop"))
peaks = peaks_dict['PPG_Peaks']
logger.info(f"ppg_findpeaks found {len(peaks)} peaks over {len(signal_trimmed)/sfreq:.1f}s "
f"(~{len(peaks) / (len(signal_trimmed)/sfreq) * 60:.1f} BPM implied by peak count alone)")
if len(peaks) < 2:
logger.warning("ppg_findpeaks found fewer than 2 peaks - heart rate estimate is unreliable.")
return np.full(len(signal_trimmed), np.nan), float('nan')
hr = cast(NDArray[float64], nk.signal_rate(peaks, sampling_rate=sfreq, desired_length=len(signal_trimmed)))
logger.info(f"Pre-clip HR range: min={hr.min():.1f}, max={hr.max():.1f} BPM (max_low_hr={max_low_hr}, max_high_hr={max_high_hr})")
hr_clean = np.clip(hr, max_low_hr, max_high_hr) hr_clean = np.clip(hr, max_low_hr, max_high_hr)
# Smooth heart rate time series by replacing spikes with local rolling mean and calculate the mean
logger.info("Smoothing the signal and calculating the mean...") logger.info("Smoothing the signal and calculating the mean...")
hr_series = pd.Series(hr_clean) hr_series = pd.Series(hr_clean)
local_median = hr_series.rolling(window=smoothing_window_hr, center=True, min_periods=1).median() local_median = hr_series.rolling(window=smoothing_window_hr, center=True, min_periods=1).median()
spikes = hr_series > (local_median + 10) spikes = (hr_series > local_median + 10) | (hr_series < local_median - 10) # was upward-only; catches drops too
smoothed_values = hr_series.copy() smoothed_values = hr_series.copy()
smoothed_spikes = hr_series.rolling(window=smoothing_window_hr, center=True, min_periods=1).mean() smoothed_spikes = hr_series.rolling(window=smoothing_window_hr, center=True, min_periods=1).mean()
smoothed_values[spikes] = smoothed_spikes[spikes] smoothed_values[spikes] = smoothed_spikes[spikes]
hr_smooth_nk = cast(NDArray[float64], smoothed_values.to_numpy()) # type: ignore hr_smooth_nk = cast(NDArray[float64], smoothed_values.to_numpy())
mean_hr_nk = hr_smooth_nk.mean() mean_hr_nk = hr_smooth_nk.mean()
logger.info("Original HR min/max: %f, %f", hr_clean.min(), hr_clean.max()) logger.info("Original HR min/max: %f, %f", hr_clean.min(), hr_clean.max())
logger.info("Smoothed HR min/max:%f, %f", hr_smooth_nk.min(), hr_smooth_nk.max()) logger.info("Smoothed HR min/max:%f, %f", hr_smooth_nk.min(), hr_smooth_nk.max())
logger.info(f"Estimated mean HR nk: {mean_hr_nk:.1f} BPM") logger.info(f"Estimated mean HR nk: {mean_hr_nk:.1f} BPM")
logger.info("Successfully calculated heart rate using NeuroKit.")
return hr_smooth_nk, mean_hr_nk return hr_smooth_nk, mean_hr_nk
def calculate_heart_rate_scipy(
def calculate_heart_rate_scipy(sfreq: float, signal_trimmed: NDArray[float64], search_min, search_max) -> tuple[NDArray[floating[Any]], NDArray[float64], np.ndarray[Any, np.dtype[np.bool_]], float]: sfreq: float, signal_trimmed: NDArray[float64], search_min, search_max,
cluster_window_bpm: float = 6.0,
) -> tuple[NDArray[floating[Any]], NDArray[float64], np.ndarray[Any, np.dtype[np.bool_]], float, float]:
""" """
Estimate heart rate using spectral analysis on a high-pass filtered signal. Estimate heart rate using spectral analysis on a high-pass filtered signal.
@@ -4585,6 +4596,13 @@ def calculate_heart_rate_scipy(sfreq: float, signal_trimmed: NDArray[float64], s
Sampling frequency of the input signal. Sampling frequency of the input signal.
signal_trimmed : NDArray[float64] signal_trimmed : NDArray[float64]
Trimmed fNIRS signal to analyze. Trimmed fNIRS signal to analyze.
cluster_window_bpm : float, default 6.0
Width (in BPM, +/- from the argmax) used to find nearby local peaks
that likely belong to the same underlying cardiac frequency (spread
by natural heart-rate variability/frequency modulation) rather than
being genuinely separate candidates. The reported HR is the
power-weighted centroid of all local peaks within this window of
the strongest bin, not just the single tallest bin.
Returns Returns
------- -------
@@ -4594,33 +4612,63 @@ def calculate_heart_rate_scipy(sfreq: float, signal_trimmed: NDArray[float64], s
- np.ndarray[Any, np.dtype[np.bool_]]: Boolean mask indicating frequencies within heart rate range. - np.ndarray[Any, np.dtype[np.bool_]]: Boolean mask indicating frequencies within heart rate range.
- float: Estimated mean heart rate in BPM corresponding to the PSD peak within the range. - float: Estimated mean heart rate in BPM corresponding to the PSD peak within the range.
""" """
logger.info("Calculating heart rate using SciPy...") logger.info("Calculating heart rate using SciPy...")
# Apply a high-pass Butterworth filter to remove slow trends below 0.5 Hz from the trimmed signal (actual data)
logger.info("Applying a butterworth filter...")
b, a = cast(tuple[NDArray[float64], NDArray[float64]], butter(2, 0.5 / (sfreq / 2), btype='high')) b, a = cast(tuple[NDArray[float64], NDArray[float64]], butter(2, 0.5 / (sfreq / 2), btype='high'))
signal_hp = cast(NDArray[float64],filtfilt(b, a, signal_trimmed)) signal_hp = cast(NDArray[float64], filtfilt(b, a, signal_trimmed))
# Calculate the Power Spectral Density (PSD) of the filtered signal using Welch's method
logger.info("Calculating the PSD...")
nperseg = min(len(signal_hp), 4096) nperseg = min(len(signal_hp), 4096)
frequencies_scipy, psd_scipy = cast(tuple[NDArray[float64], NDArray[float64]], welch(signal_hp, fs=sfreq, nperseg=nperseg, noverlap=nperseg//2)) frequencies_scipy, psd_scipy = cast(tuple[NDArray[float64], NDArray[float64]], welch(signal_hp, fs=sfreq, nperseg=nperseg, noverlap=nperseg // 2))
# Convert frequency values to beats per minute (BPM) and set a heart rate range
logger.info("Converting to BPM...")
freq_bpm_scipy = frequencies_scipy * 60 freq_bpm_scipy = frequencies_scipy * 60
freq_range_scipy = (freq_bpm_scipy > search_min) & (freq_bpm_scipy < search_max) freq_range_scipy = (freq_bpm_scipy > search_min) & (freq_bpm_scipy < search_max)
# Identify the peak frequency within the heart rate range and estimate the mean heart rate in BPM band_bpm = freq_bpm_scipy[freq_range_scipy]
logger.info("Finding the mean...") band_psd = psd_scipy[freq_range_scipy]
peak_index = np.argmax(psd_scipy[freq_range_scipy]) if len(band_psd) == 0:
mean_hr_scipy = freq_bpm_scipy[freq_range_scipy][peak_index] raise ValueError(f"No frequency bins fall within the search range ({search_min}-{search_max} BPM).")
# Find ALL local peaks in the band (bins strictly greater than both neighbors)
local_peak_mask = np.zeros(len(band_psd), dtype=bool)
if len(band_psd) >= 3:
local_peak_mask[1:-1] = (band_psd[1:-1] > band_psd[:-2]) & (band_psd[1:-1] > band_psd[2:])
# Edge bins can't be evaluated as local peaks by this rule; if the true
# peak sits at the very edge of the search range, argmax below still
# catches it as a fallback.
peak_indices = np.where(local_peak_mask)[0]
if len(peak_indices) == 0:
peak_indices = np.array([np.argmax(band_psd)])
strongest_idx = peak_indices[np.argmax(band_psd[peak_indices])]
strongest_bpm = band_bpm[strongest_idx]
# Cluster: local peaks within cluster_window_bpm of the strongest one
cluster_mask = np.abs(band_bpm[peak_indices] - strongest_bpm) <= cluster_window_bpm
cluster_indices = peak_indices[cluster_mask]
cluster_bpm = band_bpm[cluster_indices]
cluster_power = band_psd[cluster_indices]
# Power-weighted centroid across the cluster - this is the actual fix:
# a tight group of near-equal peaks now contributes to ONE combined
# estimate near their shared center, instead of a coin-flip winner-take-all.
mean_hr_scipy = float(np.average(cluster_bpm, weights=cluster_power))
# Confidence: strongest cluster's TOTAL power vs. the median power of
# everything OUTSIDE the cluster - reflects how dominant the whole
# cluster is, not just one bin within it. A 4-peak near-tie spread
# across the band now scores lower confidence than a single sharp,
# isolated peak of similar height, even though argmax alone couldn't
# tell them apart.
outside_cluster = np.setdiff1d(np.arange(len(band_psd)), cluster_indices)
baseline_power = np.median(band_psd[outside_cluster]) if len(outside_cluster) > 0 else np.median(band_psd)
cluster_total_power = cluster_power.sum()
peak_confidence = cluster_total_power / baseline_power if baseline_power > 0 else 0.0
logger.info(f"PSD: {len(cluster_indices)} peak(s) in cluster near {strongest_bpm:.1f} BPM, "
f"centroid={mean_hr_scipy:.1f} BPM, confidence={peak_confidence:.2f}x baseline")
logger.info("Successfully calculated heart rate using SciPy.") logger.info("Successfully calculated heart rate using SciPy.")
return freq_bpm_scipy, psd_scipy, freq_range_scipy, mean_hr_scipy return freq_bpm_scipy, psd_scipy, freq_range_scipy, mean_hr_scipy, peak_confidence
def plot_heart_rate( def plot_heart_rate(
@@ -4896,23 +4944,30 @@ def find_bad_channels_coeff_var(raw, coeff_var_threshold=25.0):
def hr_calc(raw, seconds_to_strip_hr, l_freq, h_freq, search_min, search_max, max_low_hr, max_high_hr, smoothing_window_hr, hr_window, short_channels, short_channels_threshold, verbosity): def hr_calc(raw, seconds_to_strip_hr, l_freq, h_freq, search_min, search_max, max_low_hr, max_high_hr, smoothing_window_hr, hr_window, short_channels, short_channels_threshold, verbosity, psd_confidence_threshold: float = 3.0):
if short_channels: if short_channels:
short_chans = get_short_channels(raw, max_dist=short_channels_threshold) short_chans = get_short_channels(raw, max_dist=short_channels_threshold)
else: else:
short_chans = None short_chans = None
sfreq, signal_trimmed, times_trimmed = short_channel_processing_for_hr(raw, short_chans, seconds_to_strip_hr=seconds_to_strip_hr, verbosity=verbosity) sfreq, signal_trimmed, times_trimmed = short_channel_processing_for_hr(raw, short_chans, seconds_to_strip_hr=seconds_to_strip_hr, verbosity=verbosity)
hr_smooth_nk, mean_hr_nk = calculate_heart_rate_neurokit(sfreq, signal_trimmed, hr_low_freq=l_freq, hr_high_freq=h_freq, max_low_hr=max_low_hr, max_high_hr=max_high_hr, smoothing_window_hr=smoothing_window_hr) hr_smooth_nk, mean_hr_nk = calculate_heart_rate_neurokit(sfreq, signal_trimmed, hr_low_freq=l_freq, hr_high_freq=h_freq, max_low_hr=max_low_hr, max_high_hr=max_high_hr, smoothing_window_hr=smoothing_window_hr, short_channels=short_channels)
freq_bpm_scipy, psd_scipy, freq_range_scipy, mean_hr_scipy = calculate_heart_rate_scipy(sfreq, signal_trimmed, search_min=search_min, search_max=search_max) freq_bpm_scipy, psd_scipy, freq_range_scipy, mean_hr_scipy, psd_confidence = calculate_heart_rate_scipy(sfreq, signal_trimmed, search_min=search_min, search_max=search_max)
# HACK: This sucks but looking at the graphs I trust neurokit2 more
overruled = False overruled = False
if mean_hr_scipy < mean_hr_nk - 15: disagreement = abs(mean_hr_scipy - mean_hr_nk) > 15
mean_hr_scipy = mean_hr_nk
overruled = True if disagreement:
if mean_hr_scipy > mean_hr_nk + 15: if psd_confidence >= psd_confidence_threshold:
mean_hr_scipy = mean_hr_nk logger.info(f"HR estimates disagree ({mean_hr_scipy:.1f} vs {mean_hr_nk:.1f} BPM) - "
overruled = True f"PSD peak is clear (confidence={psd_confidence:.2f}), trusting PSD. Overruling NeuroKit.")
mean_hr_nk = mean_hr_scipy
overruled = True
else:
logger.info(f"HR estimates disagree ({mean_hr_scipy:.1f} vs {mean_hr_nk:.1f} BPM) - "
f"PSD peak is ambiguous (confidence={psd_confidence:.2f} < {psd_confidence_threshold}), "
f"trusting NeuroKit instead. Overruling PSD.")
mean_hr_scipy = mean_hr_nk
overruled = True
hr1, hr2 = plot_heart_rate(freq_bpm_scipy, psd_scipy, freq_range_scipy, mean_hr_scipy, hr_smooth_nk, mean_hr_nk, times_trimmed, overruled, hr_window=hr_window) hr1, hr2 = plot_heart_rate(freq_bpm_scipy, psd_scipy, freq_range_scipy, mean_hr_scipy, hr_smooth_nk, mean_hr_nk, times_trimmed, overruled, hr_window=hr_window)
+40 -30
View File
@@ -6,18 +6,22 @@ Author: Tyler de Zeeuw
License: GPL-3.0 License: GPL-3.0
""" """
from __future__ import annotations
# Built-in imports # Built-in imports
import os import os
import sys import sys
import copy import copy
import pickle import pickle
import concurrent import concurrent
import configparser
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
from typing import TYPE_CHECKING, Any, List, Optional, Union
# External library imports # External library imports
import pandas as pd import pandas as pd
from PySide6.QtWidgets import QMessageBox, QVBoxLayout, QFileDialog, QLabel, QDialog from PySide6.QtWidgets import QMessageBox, QVBoxLayout, QFileDialog, QLabel, QDialog, QWidget
from PySide6.QtCore import QThread, Signal, Qt, QTimer from PySide6.QtCore import QThread, Signal, Qt, QTimer
from PySide6.QtGui import QAction from PySide6.QtGui import QAction
@@ -29,17 +33,20 @@ from src.shared.flaresbasewidget import ProgressBubble
from src.shared.shareddata import APP_NAME, CURRENT_VERSION, PLATFORM_NAME, DATA_SCHEMA from src.shared.shareddata import APP_NAME, CURRENT_VERSION, PLATFORM_NAME, DATA_SCHEMA
if TYPE_CHECKING:
from main import MainApplication
class SaveProjectThread(QThread): class SaveProjectThread(QThread):
finished_signal = Signal(str) finished_signal = Signal(str)
error_signal = Signal(str) error_signal = Signal(str)
def __init__(self, filename, project_data): def __init__(self, filename: str, project_data: dict[str, Any]) -> None:
super().__init__() super().__init__()
self.filename = filename self.filename = filename
self.project_data = project_data self.project_data = project_data
def run(self): def run(self) -> None:
try: try:
with open(self.filename, "wb") as f: with open(self.filename, "wb") as f:
pickle.dump(self.project_data, f) pickle.dump(self.project_data, f)
@@ -50,7 +57,7 @@ class SaveProjectThread(QThread):
class SavingOverlay(QDialog): class SavingOverlay(QDialog):
def __init__(self, parent=None): def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent) super().__init__(parent)
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint) self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
self.setModal(True) self.setModal(True)
@@ -75,7 +82,11 @@ class ProjectManager:
- State baseline synchronization (dirty tracking) - State baseline synchronization (dirty tracking)
""" """
def __init__(self, app, file_cfg, cfg_path): def __init__(self,
app: MainApplication,
file_cfg: configparser.ConfigParser,
cfg_path: str,
) -> None:
self.app = app self.app = app
self.file_cfg = file_cfg self.file_cfg = file_cfg
self.cfg_path = cfg_path self.cfg_path = cfg_path
@@ -83,7 +94,7 @@ class ProjectManager:
# ========================================================================= # =========================================================================
# Path Utilities # Path Utilities
# ========================================================================= # =========================================================================
def get_safe_path(self, target_path, project_dir): def get_safe_path(self, target_path: Union[str, Path], project_dir: Union[str, Path]) -> str:
"""Converts an absolute file path to a relative path relative to project_dir.""" """Converts an absolute file path to a relative path relative to project_dir."""
try: try:
target = Path(target_path).resolve() target = Path(target_path).resolve()
@@ -96,7 +107,7 @@ class ProjectManager:
# ========================================================================= # =========================================================================
# File & Folder Opening Dialogs # File & Folder Opening Dialogs
# ========================================================================= # =========================================================================
def open_file_dialog(self): def open_file_dialog(self) -> None:
"""Opens dialog to pick a single .snirf file.""" """Opens dialog to pick a single .snirf file."""
file_path, _ = QFileDialog.getOpenFileName( file_path, _ = QFileDialog.getOpenFileName(
self.app, "Open File", "", "SNIRF Files (*.snirf);;All Files (*)" self.app, "Open File", "", "SNIRF Files (*.snirf);;All Files (*)"
@@ -104,14 +115,14 @@ class ProjectManager:
if file_path: if file_path:
self._load_files_into_pipeline([os.path.normpath(file_path)]) self._load_files_into_pipeline([os.path.normpath(file_path)])
def open_folder_dialog(self): def open_folder_dialog(self)-> None:
"""Recursively finds all .snirf files in a selected directory.""" """Recursively finds all .snirf files in a selected directory."""
folder_path = QFileDialog.getExistingDirectory(self.app, "Select Folder", "") folder_path = QFileDialog.getExistingDirectory(self.app, "Select Folder", "")
if folder_path: if folder_path:
snirf_files = [os.path.normpath(str(f)) for f in Path(folder_path).rglob("*.snirf")] snirf_files = [os.path.normpath(str(f)) for f in Path(folder_path).rglob("*.snirf")]
self._load_files_into_pipeline(snirf_files) self._load_files_into_pipeline(snirf_files)
def _load_files_into_pipeline(self, file_paths): def _load_files_into_pipeline(self, file_paths: List[str]) -> None:
"""Loads .snirf files into UI using chunked batches and background workers.""" """Loads .snirf files into UI using chunked batches and background workers."""
app = self.app app = self.app
if not file_paths: if not file_paths:
@@ -151,7 +162,7 @@ class ProjectManager:
# Queue chunked widget creation # Queue chunked widget creation
CHUNK_SIZE = 10 CHUNK_SIZE = 10
def process_chunk(file_queue): def process_chunk(file_queue: List[str]) -> None:
chunk = file_queue[:CHUNK_SIZE] chunk = file_queue[:CHUNK_SIZE]
remaining = file_queue[CHUNK_SIZE:] remaining = file_queue[CHUNK_SIZE:]
@@ -187,7 +198,7 @@ class ProjectManager:
process_chunk(new_files) process_chunk(new_files)
def add_files_to_project(self, file_paths): def add_files_to_project(self, file_paths: List[str]) -> None:
"""Adds file paths to the application state, creating bubble UI items.""" """Adds file paths to the application state, creating bubble UI items."""
app = self.app app = self.app
normalized_paths = [os.path.normpath(p) for p in file_paths] normalized_paths = [os.path.normpath(p) for p in file_paths]
@@ -220,7 +231,7 @@ class ProjectManager:
# ========================================================================= # =========================================================================
# Project Loading # Project Loading
# ========================================================================= # =========================================================================
def load_project_dialog(self): def load_project_dialog(self) -> None:
"""Prompts for a project file and loads it.""" """Prompts for a project file and loads it."""
app = self.app app = self.app
filename, _ = QFileDialog.getOpenFileName( filename, _ = QFileDialog.getOpenFileName(
@@ -229,7 +240,7 @@ class ProjectManager:
if filename: if filename:
self.load_project(filename) self.load_project(filename)
def load_project(self, filename): def load_project(self, filename: str) -> None:
"""Loads a .flare project file into the application.""" """Loads a .flare project file into the application."""
app = self.app app = self.app
try: try:
@@ -343,7 +354,7 @@ class ProjectManager:
# ========================================================================= # =========================================================================
# Project Saving (Save / Save As) # Project Saving (Save / Save As)
# ========================================================================= # =========================================================================
def save_project(self, onCrash=False, ask=False): def save_project(self, onCrash: bool = False, ask: bool = False) -> None:
""" """
Saves the project to disk. Saves the project to disk.
- ask=False: Quick Save to self.app.current_project_path (prompts if unsaved). - ask=False: Quick Save to self.app.current_project_path (prompts if unsaved).
@@ -436,7 +447,7 @@ class ProjectManager:
current_params = app.config_dict[first_file] current_params = app.config_dict[first_file]
# 5. Build Serialized Payload # 5. Build Serialized Payload
project_data = { project_data: dict[str, Any] = {
item["key"]: getattr(app, item["key"], {}) for item in DATA_SCHEMA item["key"]: getattr(app, item["key"], {}) for item in DATA_SCHEMA
} }
project_data.update({ project_data.update({
@@ -448,7 +459,7 @@ class ProjectManager:
"current_ui_params": current_params, "current_ui_params": current_params,
}) })
def sanitize(obj): def sanitize(obj: Any) -> Any:
if isinstance(obj, Path): if isinstance(obj, Path):
return str(PurePosixPath(obj)) return str(PurePosixPath(obj))
elif isinstance(obj, dict): elif isinstance(obj, dict):
@@ -469,7 +480,7 @@ class ProjectManager:
app.save_thread = SaveProjectThread(filename, project_data) app.save_thread = SaveProjectThread(filename, project_data)
def _on_save_success(saved_file): def _on_save_success(saved_file: str) -> None:
if hasattr(app, "saving_overlay"): if hasattr(app, "saving_overlay"):
app.saving_overlay.close() app.saving_overlay.close()
@@ -483,7 +494,7 @@ class ProjectManager:
app, "Success", f"Project saved to:\n{saved_file}" app, "Success", f"Project saved to:\n{saved_file}"
) )
def _on_save_error(error_msg): def _on_save_error(error_msg: str) -> None:
if hasattr(app, "saving_overlay"): if hasattr(app, "saving_overlay"):
app.saving_overlay.close() app.saving_overlay.close()
if not onCrash: if not onCrash:
@@ -500,7 +511,7 @@ class ProjectManager:
QMessageBox.critical(app, "Error", f"Failed to save project:\n{e}") QMessageBox.critical(app, "Error", f"Failed to save project:\n{e}")
def update_recent_projects_menu(self): def update_recent_projects_menu(self) -> None:
"""Clears and rebuilds the Recent Projects submenu items.""" """Clears and rebuilds the Recent Projects submenu items."""
app = self.app app = self.app
if not hasattr(app, "recent_projects_menu"): if not hasattr(app, "recent_projects_menu"):
@@ -526,7 +537,7 @@ class ProjectManager:
) )
app.recent_projects_menu.addAction(action) app.recent_projects_menu.addAction(action)
def add_to_recent_projects(self, project_path): def add_to_recent_projects(self, project_path: str) -> None:
"""Adds a project path, moves it to the top, and hard caps at 10.""" """Adds a project path, moves it to the top, and hard caps at 10."""
raw_projects = self.file_cfg.get("File", "recent_projects", fallback="") raw_projects = self.file_cfg.get("File", "recent_projects", fallback="")
projects = [p.strip() for p in raw_projects.split(",") if p.strip()] projects = [p.strip() for p in raw_projects.split(",") if p.strip()]
@@ -546,7 +557,7 @@ class ProjectManager:
self.update_recent_projects_menu() self.update_recent_projects_menu()
def open_recent_project(self, project_path): def open_recent_project(self, project_path: str) -> None:
"""The slot that executes when a recent project entry is clicked.""" """The slot that executes when a recent project entry is clicked."""
if os.path.exists(project_path): if os.path.exists(project_path):
print(f"Opening recent project: {project_path}") print(f"Opening recent project: {project_path}")
@@ -577,7 +588,7 @@ class ProjectManager:
# ========================================================================= # =========================================================================
# Recent Files Operations # Recent Files Operations
# ========================================================================= # =========================================================================
def update_recent_files_menu(self): def update_recent_files_menu(self) -> None:
"""Clears and rebuilds the Recent Files submenu items.""" """Clears and rebuilds the Recent Files submenu items."""
app = self.app app = self.app
if not hasattr(app, "recent_files_menu"): if not hasattr(app, "recent_files_menu"):
@@ -600,7 +611,7 @@ class ProjectManager:
) )
app.recent_files_menu.addAction(action) app.recent_files_menu.addAction(action)
def add_to_recent_files(self, file_path): def add_to_recent_files(self, file_path: str) -> None:
"""Adds a path, moves it to the top, and hard caps the list at 10.""" """Adds a path, moves it to the top, and hard caps the list at 10."""
raw_files = self.file_cfg.get("File", "recent_files", fallback="") raw_files = self.file_cfg.get("File", "recent_files", fallback="")
files = [f.strip() for f in raw_files.split(",") if f.strip()] files = [f.strip() for f in raw_files.split(",") if f.strip()]
@@ -620,7 +631,7 @@ class ProjectManager:
self.update_recent_files_menu() self.update_recent_files_menu()
def open_recent_file(self, file_path): def open_recent_file(self, file_path: str) -> None:
"""The slot that executes when someone clicks a recent file entry.""" """The slot that executes when someone clicks a recent file entry."""
if os.path.exists(file_path): if os.path.exists(file_path):
print(f"Opening recent file: {file_path}") print(f"Opening recent file: {file_path}")
@@ -647,11 +658,11 @@ class ProjectManager:
# ========================================================================= # =========================================================================
# Baseline Synchronization & Dirty Checks # Baseline Synchronization & Dirty Checks
# ========================================================================= # =========================================================================
def sync_metadata_baseline(self): def sync_metadata_baseline(self) -> None:
"""Captures current file_metadata state as baseline.""" """Captures current file_metadata state as baseline."""
self.app.saved_file_metadata = copy.deepcopy(getattr(self.app, "file_metadata", {})) self.app.saved_file_metadata = copy.deepcopy(getattr(self.app, "file_metadata", {}))
def is_metadata_dirty(self): def is_metadata_dirty(self) -> bool:
"""Returns True if file metadata has been modified relative to saved baseline.""" """Returns True if file metadata has been modified relative to saved baseline."""
current_meta = getattr(self.app, "file_metadata", {}) current_meta = getattr(self.app, "file_metadata", {})
saved_meta = getattr(self.app, "saved_file_metadata", {}) saved_meta = getattr(self.app, "saved_file_metadata", {})
@@ -671,7 +682,7 @@ class ProjectManager:
return False return False
def reset_all_dirty_states(self): def reset_all_dirty_states(self) -> None:
"""Resets parameter, file, and metadata baselines after load/save.""" """Resets parameter, file, and metadata baselines after load/save."""
# 1. Sync file list baseline # 1. Sync file list baseline
self.app.saved_selected_paths = copy.deepcopy(getattr(self.app, "selected_paths", [])) self.app.saved_selected_paths = copy.deepcopy(getattr(self.app, "selected_paths", []))
@@ -692,7 +703,6 @@ class ProjectManager:
def _get_bids_demographics(snirf_path: str) -> dict[str, str]: def _get_bids_demographics(snirf_path: str) -> dict[str, str]:
"""Traverses the path of a SNIRF file to extract age/sex/hand from BIDS TSV files. """Traverses the path of a SNIRF file to extract age/sex/hand from BIDS TSV files.
'hand' is only included if a value is present and isn't 'n/a' (case-insensitive) - 'hand' is only included if a value is present and isn't 'n/a' (case-insensitive) -
@@ -708,7 +718,7 @@ def _get_bids_demographics(snirf_path: str) -> dict[str, str]:
if not sub_id: if not sub_id:
return {} return {}
def _row_to_dict(row) -> dict[str, str]: def _row_to_dict(row: pd.Series) -> dict[str, str]:
result = {} result = {}
for field in fields: for field in fields:
if field not in row: if field not in row:
@@ -756,7 +766,7 @@ def _get_bids_demographics(snirf_path: str) -> dict[str, str]:
def extract_metadata_worker(file_name): def extract_metadata_worker(file_name: str) -> dict[str, Any]:
"""Runs in the separate worker process. Returns a clean dict.""" """Runs in the separate worker process. Returns a clean dict."""
# 1. Use preload=False! We only need metadata. # 1. Use preload=False! We only need metadata.
+2 -2
View File
@@ -3,5 +3,5 @@ src\shared\flaresbasewidget.py 1001+
src\window\updateevents.py 83 src\window\updateevents.py 83
flares.py 1001+ flares.py 1001+
main_unit_tests.py 153 main_unit_tests.py 153
main.py 705 main.py 691
project_manager.py 405 project_manager.py 113