improved heart rate calculations
This commit is contained in:
@@ -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
|
||||
- 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 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
|
||||
- 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
|
||||
|
||||
@@ -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
|
||||
----------
|
||||
@@ -4543,39 +4543,50 @@ def calculate_heart_rate_neurokit(sfreq: float, signal_trimmed: NDArray[float64]
|
||||
- NDArray[float64]: Smoothed heart rate time series (BPM).
|
||||
- float: Mean heart rate.
|
||||
"""
|
||||
|
||||
logger.info("Calculating heart rate using NeuroKit...")
|
||||
|
||||
# Filter signal to isolate heart rate frequencies and detect 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)) # type: ignore
|
||||
peaks_dict = cast(dict[str, Any], nk.signal_findpeaks(signal_filtered)) # type: ignore
|
||||
peaks = peaks_dict['Peaks']
|
||||
hr = cast(NDArray[float64], nk.signal_rate(peaks, sampling_rate=sfreq, desired_length=len(signal_trimmed))) # type: ignore
|
||||
logger.info("Filtering the signal and detecting pulsatile peaks...")
|
||||
signal_filtered = cast(NDArray[float64], nk.signal_filter(signal_trimmed, sampling_rate=sfreq, lowcut=hr_low_freq, highcut=hr_high_freq))
|
||||
|
||||
# bishop works better with challenging datasets, but like it is super slow on good datasets?
|
||||
if short_channels:
|
||||
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)
|
||||
|
||||
# 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...")
|
||||
hr_series = pd.Series(hr_clean)
|
||||
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_spikes = hr_series.rolling(window=smoothing_window_hr, center=True, min_periods=1).mean()
|
||||
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()
|
||||
|
||||
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(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
|
||||
|
||||
|
||||
|
||||
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]:
|
||||
def calculate_heart_rate_scipy(
|
||||
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.
|
||||
|
||||
@@ -4585,6 +4596,13 @@ def calculate_heart_rate_scipy(sfreq: float, signal_trimmed: NDArray[float64], s
|
||||
Sampling frequency of the input signal.
|
||||
signal_trimmed : NDArray[float64]
|
||||
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
|
||||
-------
|
||||
@@ -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.
|
||||
- float: Estimated mean heart rate in BPM corresponding to the PSD peak within the range.
|
||||
"""
|
||||
|
||||
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'))
|
||||
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)
|
||||
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_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
|
||||
logger.info("Finding the mean...")
|
||||
peak_index = np.argmax(psd_scipy[freq_range_scipy])
|
||||
mean_hr_scipy = freq_bpm_scipy[freq_range_scipy][peak_index]
|
||||
band_bpm = freq_bpm_scipy[freq_range_scipy]
|
||||
band_psd = psd_scipy[freq_range_scipy]
|
||||
if len(band_psd) == 0:
|
||||
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.")
|
||||
|
||||
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(
|
||||
@@ -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:
|
||||
short_chans = get_short_channels(raw, max_dist=short_channels_threshold)
|
||||
else:
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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, 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
|
||||
if mean_hr_scipy < mean_hr_nk - 15:
|
||||
mean_hr_scipy = mean_hr_nk
|
||||
overruled = True
|
||||
if mean_hr_scipy > mean_hr_nk + 15:
|
||||
mean_hr_scipy = mean_hr_nk
|
||||
overruled = True
|
||||
disagreement = abs(mean_hr_scipy - mean_hr_nk) > 15
|
||||
|
||||
if disagreement:
|
||||
if psd_confidence >= psd_confidence_threshold:
|
||||
logger.info(f"HR estimates disagree ({mean_hr_scipy:.1f} vs {mean_hr_nk:.1f} BPM) - "
|
||||
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)
|
||||
|
||||
|
||||
+40
-30
@@ -6,18 +6,22 @@ Author: Tyler de Zeeuw
|
||||
License: GPL-3.0
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Built-in imports
|
||||
import os
|
||||
import sys
|
||||
import copy
|
||||
import pickle
|
||||
import concurrent
|
||||
import configparser
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import TYPE_CHECKING, Any, List, Optional, Union
|
||||
|
||||
# External library imports
|
||||
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.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
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from main import MainApplication
|
||||
|
||||
|
||||
class SaveProjectThread(QThread):
|
||||
finished_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__()
|
||||
self.filename = filename
|
||||
self.project_data = project_data
|
||||
|
||||
def run(self):
|
||||
def run(self) -> None:
|
||||
try:
|
||||
with open(self.filename, "wb") as f:
|
||||
pickle.dump(self.project_data, f)
|
||||
@@ -50,7 +57,7 @@ class SaveProjectThread(QThread):
|
||||
|
||||
|
||||
class SavingOverlay(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
|
||||
self.setModal(True)
|
||||
@@ -75,7 +82,11 @@ class ProjectManager:
|
||||
- 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.file_cfg = file_cfg
|
||||
self.cfg_path = cfg_path
|
||||
@@ -83,7 +94,7 @@ class ProjectManager:
|
||||
# =========================================================================
|
||||
# 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."""
|
||||
try:
|
||||
target = Path(target_path).resolve()
|
||||
@@ -96,7 +107,7 @@ class ProjectManager:
|
||||
# =========================================================================
|
||||
# File & Folder Opening Dialogs
|
||||
# =========================================================================
|
||||
def open_file_dialog(self):
|
||||
def open_file_dialog(self) -> None:
|
||||
"""Opens dialog to pick a single .snirf file."""
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self.app, "Open File", "", "SNIRF Files (*.snirf);;All Files (*)"
|
||||
@@ -104,14 +115,14 @@ class ProjectManager:
|
||||
if 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."""
|
||||
folder_path = QFileDialog.getExistingDirectory(self.app, "Select Folder", "")
|
||||
if folder_path:
|
||||
snirf_files = [os.path.normpath(str(f)) for f in Path(folder_path).rglob("*.snirf")]
|
||||
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."""
|
||||
app = self.app
|
||||
if not file_paths:
|
||||
@@ -151,7 +162,7 @@ class ProjectManager:
|
||||
# Queue chunked widget creation
|
||||
CHUNK_SIZE = 10
|
||||
|
||||
def process_chunk(file_queue):
|
||||
def process_chunk(file_queue: List[str]) -> None:
|
||||
chunk = file_queue[:CHUNK_SIZE]
|
||||
remaining = file_queue[CHUNK_SIZE:]
|
||||
|
||||
@@ -187,7 +198,7 @@ class ProjectManager:
|
||||
|
||||
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."""
|
||||
app = self.app
|
||||
normalized_paths = [os.path.normpath(p) for p in file_paths]
|
||||
@@ -220,7 +231,7 @@ class ProjectManager:
|
||||
# =========================================================================
|
||||
# Project Loading
|
||||
# =========================================================================
|
||||
def load_project_dialog(self):
|
||||
def load_project_dialog(self) -> None:
|
||||
"""Prompts for a project file and loads it."""
|
||||
app = self.app
|
||||
filename, _ = QFileDialog.getOpenFileName(
|
||||
@@ -229,7 +240,7 @@ class ProjectManager:
|
||||
if 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."""
|
||||
app = self.app
|
||||
try:
|
||||
@@ -343,7 +354,7 @@ class ProjectManager:
|
||||
# =========================================================================
|
||||
# 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.
|
||||
- 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]
|
||||
|
||||
# 5. Build Serialized Payload
|
||||
project_data = {
|
||||
project_data: dict[str, Any] = {
|
||||
item["key"]: getattr(app, item["key"], {}) for item in DATA_SCHEMA
|
||||
}
|
||||
project_data.update({
|
||||
@@ -448,7 +459,7 @@ class ProjectManager:
|
||||
"current_ui_params": current_params,
|
||||
})
|
||||
|
||||
def sanitize(obj):
|
||||
def sanitize(obj: Any) -> Any:
|
||||
if isinstance(obj, Path):
|
||||
return str(PurePosixPath(obj))
|
||||
elif isinstance(obj, dict):
|
||||
@@ -469,7 +480,7 @@ class ProjectManager:
|
||||
|
||||
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"):
|
||||
app.saving_overlay.close()
|
||||
|
||||
@@ -483,7 +494,7 @@ class ProjectManager:
|
||||
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"):
|
||||
app.saving_overlay.close()
|
||||
if not onCrash:
|
||||
@@ -500,7 +511,7 @@ class ProjectManager:
|
||||
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."""
|
||||
app = self.app
|
||||
if not hasattr(app, "recent_projects_menu"):
|
||||
@@ -526,7 +537,7 @@ class ProjectManager:
|
||||
)
|
||||
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."""
|
||||
raw_projects = self.file_cfg.get("File", "recent_projects", fallback="")
|
||||
projects = [p.strip() for p in raw_projects.split(",") if p.strip()]
|
||||
@@ -546,7 +557,7 @@ class ProjectManager:
|
||||
|
||||
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."""
|
||||
if os.path.exists(project_path):
|
||||
print(f"Opening recent project: {project_path}")
|
||||
@@ -577,7 +588,7 @@ class ProjectManager:
|
||||
# =========================================================================
|
||||
# Recent Files Operations
|
||||
# =========================================================================
|
||||
def update_recent_files_menu(self):
|
||||
def update_recent_files_menu(self) -> None:
|
||||
"""Clears and rebuilds the Recent Files submenu items."""
|
||||
app = self.app
|
||||
if not hasattr(app, "recent_files_menu"):
|
||||
@@ -600,7 +611,7 @@ class ProjectManager:
|
||||
)
|
||||
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."""
|
||||
raw_files = self.file_cfg.get("File", "recent_files", fallback="")
|
||||
files = [f.strip() for f in raw_files.split(",") if f.strip()]
|
||||
@@ -620,7 +631,7 @@ class ProjectManager:
|
||||
|
||||
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."""
|
||||
if os.path.exists(file_path):
|
||||
print(f"Opening recent file: {file_path}")
|
||||
@@ -647,11 +658,11 @@ class ProjectManager:
|
||||
# =========================================================================
|
||||
# Baseline Synchronization & Dirty Checks
|
||||
# =========================================================================
|
||||
def sync_metadata_baseline(self):
|
||||
def sync_metadata_baseline(self) -> None:
|
||||
"""Captures current file_metadata state as baseline."""
|
||||
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."""
|
||||
current_meta = getattr(self.app, "file_metadata", {})
|
||||
saved_meta = getattr(self.app, "saved_file_metadata", {})
|
||||
@@ -671,7 +682,7 @@ class ProjectManager:
|
||||
|
||||
return False
|
||||
|
||||
def reset_all_dirty_states(self):
|
||||
def reset_all_dirty_states(self) -> None:
|
||||
"""Resets parameter, file, and metadata baselines after load/save."""
|
||||
# 1. Sync file list baseline
|
||||
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]:
|
||||
"""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) -
|
||||
@@ -708,7 +718,7 @@ def _get_bids_demographics(snirf_path: str) -> dict[str, str]:
|
||||
if not sub_id:
|
||||
return {}
|
||||
|
||||
def _row_to_dict(row) -> dict[str, str]:
|
||||
def _row_to_dict(row: pd.Series) -> dict[str, str]:
|
||||
result = {}
|
||||
for field in fields:
|
||||
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."""
|
||||
|
||||
# 1. Use preload=False! We only need metadata.
|
||||
|
||||
+2
-2
@@ -3,5 +3,5 @@ src\shared\flaresbasewidget.py 1001+
|
||||
src\window\updateevents.py 83
|
||||
flares.py 1001+
|
||||
main_unit_tests.py 153
|
||||
main.py 705
|
||||
project_manager.py 405
|
||||
main.py 691
|
||||
project_manager.py 113
|
||||
Reference in New Issue
Block a user