heart rate improvements

This commit is contained in:
2026-08-25 14:04:41 -07:00
parent 2fa3188296
commit 83ab73a05a
3 changed files with 417 additions and 111 deletions
+5 -1
View File
@@ -5,14 +5,18 @@
- Added parameters that appear when attempting to generate results from the Participant and Intra-Group Functional Connectivity viewers and removed the non-functional placeholder parameters
- Modified the Participant and Intra-Group Functional Connectivity analysis options to better perform their expected tasks. This remains as a BETA feature
- Removed the existing Intra-Group Functional Connectivity option and replaced it with two new ones: Beta-Series Correlation and Spectral Coherence (epochs)
- Updated the names of the methods provided for the Participant Functional Coneectivity Viewer to better match the actions they perform
- Updated the names of the methods provided for the Participant Functional Connectivity Viewer to better match the actions they perform
- Updated the warnings for the Functional Connectivity Viewers to better represent the challenges these analysis options now face
- Added basic unit testing to hopefully prevent any accidental processing changes from occurring in the future
- Added a new parameter to the PSP section on the right side of the screen: PSP_USE_HEART_RATE_BAND. This functions similarly to the existing SCI_USE_HEART_RATE_BAND
- 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 help messages for parameters in the SCI and PSP areas to better reflect how the parameters are used
- 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 where performing PSP could ignore the bad channels that were marked by SCI and SNR
- Fixed an issue where the heart rate calculation would disregard all calculations and fall back to an extremely rudimentary calculation
- 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 that could have passed multiple conditions when generating an Intra-Group Stats image
+404 -103
View File
@@ -56,6 +56,11 @@ from scipy.spatial.distance import cdist
from scipy.signal import welch, butter, filtfilt, periodogram # type: ignore
from scipy.stats import pearsonr, zscore, ttest_1samp, ttest_ind, sem, t as t_dist # type: ignore
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
from openpyxl.formatting.rule import ColorScaleRule
from openpyxl.utils import get_column_letter
import pywt # type: ignore
import neurokit2 as nk # type: ignore
@@ -106,7 +111,7 @@ from src.shared.shareddata import PLATFORM_NAME, resource_path
PRIMARY_COLORS = {
"SCI only": "skyblue", # Scalp Coupling Index (Standard MNE)
"SNR only": "lightgreen", # Signal-to-Noise Ratio (Your original)
"SNR only": "lightgreen", # Signal-to-Noise Ratio (Original)
"PSP only": "salmon", # Power Spectral Peak (Original Noise check)
"Coeff_var only": "yellow", # Relative Noise (The coeff_var-only check)
"Range only": "coral", # Z-Swing (The Range Outlier check)
@@ -122,6 +127,42 @@ def get_category_color(label):
"""Returns the primary color if it's a single failure, otherwise gray."""
return PRIMARY_COLORS.get(label, COMBINATION_COLOR)
# direction: True = lower value is better (green), False = higher is better (green)
QC_METRIC_DIRECTIONS = {
"n_bad_sci": True,
"n_bad_snr": True,
"n_bad_psp": True,
"n_bad_coeff_var": True,
"n_bad_mad": True,
"n_bad_psd_noise": True,
"n_bad_dropout": True,
"n_bad_channels_total": True,
"pct_bad_channels": True,
"total_processing_seconds": True,
"n_epochs_final": False,
}
# metrics with no inherent "good direction" - colored by deviation from the
# group median instead (outliers flagged, not high/low values per se)
QC_METRIC_DEVIATION_BASED = {"final_hr_bpm"}
QC_METRIC_LABELS = {
"n_bad_sci": "Bad Channels - SCI",
"n_bad_snr": "Bad Channels - SNR",
"n_bad_psp": "Bad Channels - PSP",
"n_bad_coeff_var": "Bad Channels - Coeff. Var",
"n_bad_mad": "Bad Channels - MAD",
"n_bad_psd_noise": "Bad Channels - PSD Noise",
"n_bad_dropout": "Bad Channels - Dropout",
"n_bad_channels_total": "Bad Channels - Total (union)",
"pct_bad_channels": "% Channels Bad",
"final_hr_bpm": "Final Heart Rate (BPM)",
"n_epochs_final": "Epochs Retained",
"total_processing_seconds": "Processing Time (s)",
}
DOWNSAMPLE: bool
DOWNSAMPLE_FREQUENCY: int
@@ -162,10 +203,11 @@ SNR_NOISE_LOW_FREQ: float
SNR_NOISE_HIGH_FREQ: float
PSP: bool
PSP_TIME_WINDOW: int
PSP_THRESHOLD: float
PSP_USE_HEART_RATE_BAND: bool
PSP_LOW_FREQ: float
PSP_HIGH_FREQ: float
PSP_TIME_WINDOW: int
PSP_THRESHOLD: float
COEFF_VAR: bool
COEFF_VAR_THRESHOLD: int
@@ -260,6 +302,7 @@ GROUP: str = "Default"
FOLDING_BYP: bool = False
FEATURE_1: bool = False
# Ensure that we are working in the directory of this file
script_dir = os.path.dirname(os.path.abspath(__file__))
@@ -416,7 +459,7 @@ def process_participant_worker(file_path, file_params, file_metadata, result_que
def process_multiple_participants(file_paths, file_params, file_metadata,
progress_queue=None, gui_queue=None, max_workers=6):
progress_queue=None, gui_queue=None, max_workers=6, qc_summary_path: str | None = None):
ctx = mp.get_context("spawn")
result_queue = ctx.Queue()
@@ -433,6 +476,8 @@ def process_multiple_participants(file_paths, file_params, file_metadata,
duration_total = {"value": 0.0}
success_count = {"value": 0}
failed_stages = {"value": []}
qc_rows: list[dict[str, Any]] = []
qc_summary_path="qc_summary.xlsx"
def elapsed_heartbeat():
# Ticks once a second so the GUI can show a live-updating timer,
@@ -471,8 +516,20 @@ def process_multiple_participants(file_paths, file_params, file_metadata,
if error is None:
duration_total["value"] += duration
success_count["value"] += 1
qc = result[-2] if isinstance(result, tuple) else None # accessing by index of return
if isinstance(qc, dict):
qc["status"] = "success"
qc["duration_seconds"] = round(duration, 2)
qc_rows.append(qc)
else:
failed_stages["value"].append(stage)
qc_rows.append({
"file_path": res_path,
"status": "FAILED",
"error": error.splitlines()[0] if error else "unknown error",
"duration_seconds": round(duration, 2),
"total_processing_seconds": None,
})
if gui_queue:
try:
gui_queue.put({
@@ -553,6 +610,14 @@ def process_multiple_participants(file_paths, file_params, file_metadata,
process_multiple_participants._success_count = success_count["value"]
process_multiple_participants._failed_stages = failed_stages["value"]
if qc_summary_path and qc_rows:
if FEATURE_1:
try:
write_qc_excel_summary(qc_rows, qc_summary_path)
logger.info(f"QC summary written to {qc_summary_path} ({len(qc_rows)} participant(s))")
except Exception as e:
logger.error(f"Failed to write QC summary: {e}")
return results_by_file
@@ -1172,7 +1237,10 @@ def calculate_peak_power(data: BaseRaw, time_window: int = 3, threshold: float =
# Identify channels that don't meet the provided threshold
psp = scores.mean(axis=1)
data.info["bads"] = list(compress(cast(list[str], getattr(data, "ch_names")), psp < threshold))
bad_channels = list(compress(cast(list[str], data.ch_names), psp < threshold))
existing_bads = set(data.info.get("bads", []))
data.info["bads"] = list(existing_bads | set(bad_channels))
# Determine the colors based on the threshold, and create the figures
color_stops = ([0.0, threshold, threshold+0.1, threshold+0.2, 1.0], [0.0, threshold, threshold, 1.0])
@@ -4409,17 +4477,22 @@ def _select_hr_source_channels(
detection to already have succeeded.
"""
n_channels = channel_data.shape[0]
scores = np.zeros(n_channels)
nperseg = min(channel_data.shape[1], 2048)
if nperseg < 8:
return scores # too little data for a meaningful PSD
return np.zeros(n_channels)
for ch in range(n_channels):
freqs, psd = welch(channel_data[ch], fs=sfreq, nperseg=nperseg)
band_mask = (freqs >= cardiac_band[0]) & (freqs <= cardiac_band[1])
total_power = np.sum(psd)
scores[ch] = np.sum(psd[band_mask]) / total_power if total_power > 0 else 0.0
# single vectorized call across all channels, instead of one welch()
# call per channel - same math, same result, no Python-level loop
freqs, psd = welch(channel_data, fs=sfreq, nperseg=nperseg, axis=1) # psd shape: (n_channels, n_freqs)
band_mask = (freqs >= cardiac_band[0]) & (freqs <= cardiac_band[1])
total_power = np.sum(psd, axis=1)
band_power = np.sum(psd[:, band_mask], axis=1)
scores = np.divide(
band_power, total_power,
out=np.zeros(n_channels), where=total_power > 0
)
return scores
@@ -4525,6 +4598,104 @@ def short_channel_processing_for_hr(
return sfreq, signal_trimmed, times_trimmed
def reconcile_heart_rate_estimates(
mean_hr_scipy: float,
psd_confidence: float,
mean_hr_nk: float,
mode_hr_nk: float,
agreement_tolerance_bpm: float = 10.0,
psd_confidence_threshold: float = 3.0,
) -> tuple[float, bool, str]:
"""
Combines three independent heart rate estimates - PSD spectral peak
(scipy), NeuroKit's cleaned/interpolated mean, and NeuroKit's mode -
into one final value, using majority agreement rather than a fixed
pairwise override rule.
Logic, in order:
1. If all three estimates agree within agreement_tolerance_bpm of each
other, average them - strongest possible evidence, no single method
is being trusted over the others.
2. Otherwise, check if any TWO of the three agree with each other -
if so, average that agreeing pair and discard the outlier. Two
independent methods landing on the same value by coincidence is
unlikely; the third is more likely the one that's wrong.
3. If no two agree at all, fall back to whichever single estimate is
most trustworthy: the PSD estimate if its confidence clears
psd_confidence_threshold (a genuinely sharp, unambiguous spectral
peak), otherwise the NeuroKit mode (more robust than its mean to
a residual minority of bad samples, per _mode_hr's reasoning).
Returns
-------
tuple[float, bool, str]
- float: final reconciled heart rate (BPM).
- bool: True if any disagreement/overruling occurred (for plotting/logging).
- str: human-readable explanation of which path was taken, for logs.
"""
estimates = {
"psd": mean_hr_scipy,
"nk_mean": mean_hr_nk,
"nk_mode": mode_hr_nk,
}
def _fmt(d: dict[str, float]) -> str:
return ", ".join(f"{k}={v:.1f}" for k, v in d.items())
pairs = [("psd", "nk_mean"), ("psd", "nk_mode"), ("nk_mean", "nk_mode")]
agreeing_pairs = [
(a, b) for a, b in pairs
if abs(estimates[a] - estimates[b]) <= agreement_tolerance_bpm
]
if len(agreeing_pairs) == 3:
final = float(np.mean(list(estimates.values())))
return final, False, f"All three estimates agree (within {agreement_tolerance_bpm} BPM) - averaged: {_fmt(estimates)}"
if len(agreeing_pairs) >= 1:
a, b = agreeing_pairs[0]
final = float((estimates[a] + estimates[b]) / 2.0)
outlier = [k for k in estimates if k not in (a, b)][0]
return final, True, (
f"{a} and {b} agree ({estimates[a]:.1f}, {estimates[b]:.1f}); "
f"{outlier} is an outlier ({estimates[outlier]:.1f}) - discarded."
)
# No two estimates agree at all - fall back to the single most trustworthy one
if psd_confidence >= psd_confidence_threshold:
return mean_hr_scipy, True, (
f"No two estimates agree ({_fmt(estimates)}); PSD peak is clear "
f"(confidence={psd_confidence:.2f}) - trusting PSD alone."
)
else:
return mode_hr_nk, True, (
f"No two estimates agree ({_fmt(estimates)}); PSD peak is ambiguous "
f"(confidence={psd_confidence:.2f} < {psd_confidence_threshold}) - "
f"trusting NeuroKit mode alone (more robust to residual dips than its mean)."
)
def _mode_hr(hr_clean: NDArray[float64], bin_width_bpm: float = 2.0) -> float:
"""
Histogram-based mode of the HR trace: the center of the most frequently
occurring bin. More robust than a plain mean to a minority of corrupted
(missed-beat) dips, since those dips only need to avoid being the
single largest cluster - unlike a mean, which every dip pulls down
proportionally regardless of how rare it is.
bin_width_bpm : float, default 2.0
Histogram bin width. Too narrow and there's no meaningful mode
(every value nearly unique); too wide and you lose real precision
in the estimate. 2 BPM is a reasonable starting point - worth
checking against your actual HR distributions.
"""
if len(hr_clean) == 0:
return float('nan')
bins = np.arange(hr_clean.min(), hr_clean.max() + bin_width_bpm, bin_width_bpm)
counts, edges = np.histogram(hr_clean, bins=bins)
mode_bin_idx = np.argmax(counts)
return float((edges[mode_bin_idx] + edges[mode_bin_idx + 1]) / 2.0)
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]:
"""
@@ -4575,12 +4746,14 @@ def calculate_heart_rate_neurokit(sfreq: float, signal_trimmed: NDArray[float64]
smoothed_values[spikes] = smoothed_spikes[spikes]
hr_smooth_nk = cast(NDArray[float64], smoothed_values.to_numpy())
mean_hr_nk = hr_smooth_nk.mean()
mode_hr_nk = _mode_hr(hr_clean, bin_width_bpm=2.0)
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(f"Estimated mean HR nk: {mean_hr_nk:.1f} BPM, mode HR nk: {mode_hr_nk:.1f} BPM")
return hr_smooth_nk, mean_hr_nk, mode_hr_nk
return hr_smooth_nk, mean_hr_nk
def calculate_heart_rate_scipy(
@@ -4678,8 +4851,11 @@ def plot_heart_rate(
mean_hr_scipy: float,
hr_smooth_nk: NDArray[floating[Any]],
mean_hr_nk: float,
mode_hr_nk: float,
final_hr: float,
times_trimmed: NDArray[floating[Any]],
overruled: bool,
reconciliation_note: str,
hr_window: int
) -> tuple[Figure, Figure]:
"""
@@ -4694,15 +4870,26 @@ def plot_heart_rate(
freq_range_scipy : np.ndarray[Any, np.dtype[np.bool_]]
Boolean mask indicating the heart rate frequency range used in PSD.
mean_hr_scipy : float
Mean heart rate estimated from SciPy PSD peak.
Heart rate estimated from SciPy PSD peak (cluster centroid).
hr_smooth_nk : NDArray[floating[Any]]
Smoothed instantaneous heart rate from NeuroKit2.
mean_hr_nk : float
Mean heart rate estimated from NeuroKit2 data.
Mean heart rate estimated from NeuroKit2's cleaned time series.
mode_hr_nk : float
Mode (most frequent value) of NeuroKit2's heart rate distribution -
more robust than the mean to a residual minority of missed-beat dips.
final_hr : float
The final reconciled heart rate value, combining all three estimates
(see reconcile_heart_rate_estimates).
times_trimmed : NDArray[floating[Any]]
Time points corresponding to hr_smooth_nk values.
overruled: bool
True if the heart rate from NeuroKit2 is overriding the results from the PSD.
overruled : bool
True if the final reconciled value differs from a simple average of
all three (i.e. an outlier was discarded or a single estimate was
trusted alone).
reconciliation_note : str
Human-readable explanation of which reconciliation path was taken -
shown directly on the plot rather than a generic "was overruled" note.
Returns
-------
@@ -4724,32 +4911,26 @@ def plot_heart_rate(
ax1.grid(True) # type: ignore
# Was the value we reported here correct for the data on the graph or was it overruled?
if overruled:
note = (
'\n'
'Note: Calculation was bad!\n'
'Data has been set to match\n'
'the value from NeuroKit2.'
)
phantom = Line2D([0], [0], color='none', label=note)
handles, _ = ax1.get_legend_handles_labels()
ax1.legend(handles=handles + [phantom]) # type: ignore
else:
ax1.legend() # type: ignore
note_prefix = 'Reconciliation (outlier discarded):' if overruled else 'Reconciliation (estimates agreed):'
note = f"\n{note_prefix}\n{reconciliation_note}"
phantom = Line2D([0], [0], color='none', label=note)
handles, _ = ax1.get_legend_handles_labels()
ax1.legend(handles=handles + [phantom], fontsize=8)
plt.close(fig1)
# Create the second plot showing the rolling heart rate, as well as the two averages that were calculated
logger.info("Creating the figure...")
fig2, ax2 = plt.subplots(figsize=(14, 6)) # type: ignore
ax2.plot(times_trimmed, hr_smooth_nk, label='Instantaneous HR (NeuroKit2)', color='blue', alpha=0.7) # type: ignore
ax2.axhline(mean_hr_nk, color='red', linestyle='--', label=f'Mean HR NeuroKit2: {mean_hr_nk:.1f} BPM') # type: ignore
ax2.axhline(mean_hr_scipy, color='orange', linestyle=':', label=f'SciPy Welch PSD (HP filtered): {mean_hr_scipy:.1f} BPM') # type: ignore
ax2.set_xlabel('Time (seconds)') # type: ignore
ax2.set_ylabel('Heart Rate (BPM)') # type: ignore
ax2.set_title('Heart Rate Estimates Comparison') # type: ignore
ax2.legend() # type: ignore
ax2.grid(True) # type: ignore
ax2.plot(times_trimmed, hr_smooth_nk, label='Instantaneous HR (NeuroKit2)', color='blue', alpha=0.7)
ax2.axhline(mean_hr_nk, color='steelblue', linestyle='--', alpha=0.7, label=f'NK Mean: {mean_hr_nk:.1f} BPM')
ax2.axhline(mode_hr_nk, color='purple', linestyle='--', alpha=0.7, label=f'NK Mode: {mode_hr_nk:.1f} BPM')
ax2.axhline(mean_hr_scipy, color='orange', linestyle=':', label=f'PSD Estimate: {mean_hr_scipy:.1f} BPM')
ax2.axhline(final_hr, color='green', linestyle='-', linewidth=2, label=f'Final (Reconciled) HR: {final_hr:.1f} BPM')
ax2.set_xlabel('Time (seconds)')
ax2.set_ylabel('Heart Rate (BPM)')
ax2.set_title('Heart Rate Estimates Comparison')
ax2.legend(fontsize=9)
ax2.grid(True)
fig2.tight_layout()
plt.close(fig2)
@@ -4944,75 +5125,43 @@ 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, psd_confidence_threshold: float = 3.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, psd_confidence_threshold: float = 3.0, band_halfwidth_hz: float = 0.3):
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, short_channels=short_channels)
hr_smooth_nk, mean_hr_nk, mode_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)
overruled = False
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
final_hr, overruled, reconciliation_note = reconcile_heart_rate_estimates(
mean_hr_scipy, psd_confidence, mean_hr_nk, mode_hr_nk, agreement_tolerance_bpm=hr_window
)
logger.info(f"HR reconciliation: {reconciliation_note}")
logger.info(f"Final heart rate: {final_hr:.1f} BPM")
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)
mean_hr_scipy = final_hr
hr1, hr2 = plot_heart_rate(
freq_bpm_scipy, psd_scipy, freq_range_scipy, mean_hr_scipy,
hr_smooth_nk, mean_hr_nk, mode_hr_nk, final_hr,
times_trimmed, overruled, reconciliation_note, hr_window=hr_window
)
fig = raw.compute_psd().plot(show=False)
raw_filtered = raw.copy().filter(0.5, 3, fir_design='firwin')
sfreq = raw.info['sfreq']
data = raw_filtered.get_data()
channel_names = raw.ch_names
# --- Parameters for PSD ---
desired_bin_hz = 0.1
nperseg = int(sfreq / desired_bin_hz)
hr_range = (search_min, search_max)
# Targeted frequency band for downstream calculations (SCI, PSP, etc.),
# derived directly from the reconciled heart rate estimate above -
# replaces a previous, disconnected all-channel median calculation that
# never used the short-channel selection or reconciliation logic at all.
hr_freq = final_hr / 60.0 # BPM -> Hz
low = hr_freq - band_halfwidth_hz
high = hr_freq + band_halfwidth_hz
f, Pxx = welch(data, fs=sfreq, nperseg=nperseg, axis=1) # data: (n_channels, n_samples)
mask = (f >= hr_range[0] / 60) & (f <= hr_range[1] / 60)
f_masked = f[mask]
Pxx_masked = Pxx[:, mask] # (n_channels, n_freq_in_range)
hr_all_channels = np.full(Pxx_masked.shape[0], np.nan)
if Pxx_masked.shape[1] >= 3:
# same "strictly greater than both neighbors" local-max definition as
# the original per-channel loop, vectorized across all channels at once
interior = Pxx_masked[:, 1:-1]
left = Pxx_masked[:, :-2]
right = Pxx_masked[:, 2:]
is_local_peak = (interior > left) & (interior > right)
for ch in range(Pxx_masked.shape[0]):
peak_offsets = np.where(is_local_peak[ch])[0]
if len(peak_offsets) == 0:
continue
candidate_idx = peak_offsets + 1 # shift back into Pxx_masked indexing
best_idx = candidate_idx[np.argmax(Pxx_masked[ch, candidate_idx])]
hr_all_channels[ch] = f_masked[best_idx] * 60 # bpm
hr_all_channels = hr_all_channels[~np.isnan(hr_all_channels)]
hr_mode = np.round(np.median(hr_all_channels)) # Use median if some NaNs
logger.info(f"SCI/PSP target band: {low:.3f}-{high:.3f} Hz "
f"({final_hr:.1f} +/- {band_halfwidth_hz*60:.0f} BPM)")
print(f"Estimated Heart Rate: {hr_mode} bpm")
hr_freq = hr_mode / 60 # Hz
low = hr_freq - 0.3
high = hr_freq + 0.3
return fig, hr1, hr2, low, high
return fig, hr1, hr2, low, high, final_hr
@@ -5353,7 +5502,7 @@ def _png_worker(png_queue: Queue, fig_bytes_dict: dict, dpi: int = 100):
def initial_setup():
def initial_setup(file_path):
timings = {}
step_start = time.perf_counter()
config_dict = {
@@ -5363,20 +5512,21 @@ def initial_setup():
}
fig_bytes_dict: dict[str, bytes] = {}
qc: dict[str, Any] = {"file_path": file_path}
png_queue: Queue = Queue()
png_thread = threading.Thread(
target=_png_worker, args=(png_queue, fig_bytes_dict), daemon=True
)
png_thread.start()
return fig_bytes_dict, config_dict, png_queue, timings, step_start
return fig_bytes_dict, config_dict, png_queue, timings, step_start, qc
def _enqueue(label, fig, png_queue):
if fig is None:
return
plt.close(fig) # detach from pyplot's global registry - main thread only
plt.close(fig)
png_queue.put((label, fig))
@@ -5392,11 +5542,12 @@ def process_participant(file_path, file_start, progress_callback=None):
print(f"File was started with {time.time() - file_start:2f} seconds elapsed.")
# Step 0: Setting up
fig_bytes_dict, config_dict, png_queue, timings, step_start = initial_setup()
fig_bytes_dict, config_dict, png_queue, timings, step_start, qc = initial_setup(file_path)
step_start = lap(step_start, timings, "Step 0")
# Step 1: Preprocessing
raw = load_snirf(file_path=file_path, downsample_frequency=DOWNSAMPLE_FREQUENCY, verbosity=VERBOSITY)
qc["n_channels_loaded"] = raw.info['nchan']
fig_raw = raw.plot(duration=raw.times[-1], n_channels=raw.info['nchan'], title="Loaded Raw", show=False)
_enqueue("Loaded Raw Data", fig_raw, png_queue)
if progress_callback: progress_callback(1)
@@ -5432,8 +5583,9 @@ def process_participant(file_path, file_start, progress_callback=None):
step_start = lap(step_start, timings, "Step 4")
# Step 5: Heart Rate
qc["heart_rate_ran"] = HEART_RATE and not FOLDING_BYP
if HEART_RATE and not FOLDING_BYP:
fig, hr1, hr2, low, high = hr_calc(
fig, hr1, hr2, low, high, final_hr = hr_calc(
raw,
seconds_to_strip_hr=SECONDS_TO_STRIP_HR,
l_freq=HR_LOW_FREQ,
@@ -5448,6 +5600,7 @@ def process_participant(file_path, file_start, progress_callback=None):
short_channels_threshold=SHORT_CHANNELS_THRESHOLD,
verbosity=VERBOSITY
)
qc["final_hr_bpm"] = round(final_hr, 1)
_enqueue("Power Spectral Density", fig, png_queue)
_enqueue('Heart Rate - PSD', hr1, png_queue)
_enqueue('Heart Rate - Time', hr2, png_queue)
@@ -5464,6 +5617,7 @@ def process_participant(file_path, file_start, progress_callback=None):
bad_sci, fig_sci_1, fig_sci_2 = calculate_scalp_coupling(raw, l_freq=SCI_LOW_FREQ, h_freq=SCI_HIGH_FREQ, time_window=SCI_TIME_WINDOW, threshold=SCI_THRESHOLD)
_enqueue("Scalp Coupling Index Heatmap", fig_sci_1, png_queue)
_enqueue("Scalp Coupling Index Binary Heatmap", fig_sci_2, png_queue)
qc["n_bad_sci"] = len(bad_sci)
if progress_callback: progress_callback(6)
logger.info("Step 6 Completed.")
step_start = lap(step_start, timings, "Step 6")
@@ -5473,6 +5627,7 @@ def process_participant(file_path, file_start, progress_callback=None):
if SNR and not FOLDING_BYP:
bad_snr, fig_snr = calculate_signal_noise_ratio(raw)
_enqueue("Signal To Noise Ratio", fig_snr, png_queue)
qc["n_bad_snr"] = len(bad_snr)
if progress_callback: progress_callback(7)
logger.info("Step 7 Completed.")
step_start = lap(step_start, timings, "Step 7")
@@ -5480,9 +5635,13 @@ def process_participant(file_path, file_start, progress_callback=None):
# Step 8: Peak Spectral Power
bad_psp = []
if PSP and not FOLDING_BYP:
bad_psp, fig_psp1, fig_psp2 = calculate_peak_power(raw, time_window=PSP_TIME_WINDOW, threshold=PSP_THRESHOLD, l_freq=PSP_LOW_FREQ, h_freq=PSP_HIGH_FREQ)
if HEART_RATE and PSP_USE_HEART_RATE_BAND:
bad_psp, fig_psp1, fig_psp2 = calculate_peak_power(raw, time_window=PSP_TIME_WINDOW, threshold=PSP_THRESHOLD, l_freq=low, h_freq=high)
else:
bad_psp, fig_psp1, fig_psp2 = calculate_peak_power(raw, time_window=PSP_TIME_WINDOW, threshold=PSP_THRESHOLD, l_freq=PSP_LOW_FREQ, h_freq=PSP_HIGH_FREQ)
_enqueue("Peak Spectral Power Heatmap", fig_psp1, png_queue)
_enqueue("Peak Spectral Power Binary Heatmap", fig_psp2, png_queue)
qc["n_bad_psp"] = len(bad_psp)
if progress_callback: progress_callback(8)
logger.info("Step 8 Completed.")
step_start = lap(step_start, timings, "Step 8")
@@ -5492,6 +5651,7 @@ def process_participant(file_path, file_start, progress_callback=None):
if COEFF_VAR and not FOLDING_BYP:
bad_coeff_var, fig_coeff_var = find_bad_channels_coeff_var(raw, coeff_var_threshold=COEFF_VAR_THRESHOLD)
_enqueue('Coefficient of Variation', fig_coeff_var, png_queue)
qc["n_bad_coeff_var"] = len(bad_coeff_var)
if progress_callback: progress_callback(9)
logger.info("Step 9 Completed.")
step_start = lap(step_start, timings, "Step 9")
@@ -5501,6 +5661,7 @@ def process_participant(file_path, file_start, progress_callback=None):
if MAD and not FOLDING_BYP:
bad_amplitude_range, fig_range = find_bad_channels_by_amplitude_range(raw, threshold=MAD_THRESHOLD)
_enqueue('Median Absolute Deviation', fig_range, png_queue)
qc["n_bad_mad"] = len(bad_amplitude_range)
if progress_callback: progress_callback(10)
logger.info("Step 10 Completed.")
step_start = lap(step_start, timings, "Step 10")
@@ -5510,6 +5671,7 @@ def process_participant(file_path, file_start, progress_callback=None):
if PSD_NOISE and not FOLDING_BYP:
bad_noise, fig_noise = detect_spectral_noise_spike(raw, db_limit=DB_LIMIT, freq_div=TARGET_FREQ_DIV, min_freq=PSD_MIN_FREQ, target_bandwith=PSD_TARGET_BANDWIDTH)
_enqueue('Power Spectral Density Noise', fig_noise, png_queue)
qc["n_bad_psd_noise"] = len(bad_noise)
if progress_callback: progress_callback(11)
logger.info("Step 11 Completed.")
step_start = lap(step_start, timings, "Step 11")
@@ -5519,13 +5681,20 @@ def process_participant(file_path, file_start, progress_callback=None):
if SENSOR_DROPOUT and not FOLDING_BYP:
bad_disp, fig_disp = detect_sensor_dropout(raw, threshold_ratio=SENSOR_DROPOUT_VARIANCE_THRESHOLD)
_enqueue('Sensor Dropout', fig_disp, png_queue)
qc["n_bad_dropout"] = len(bad_disp)
if progress_callback: progress_callback(12)
logger.info("Step 12 Completed.")
step_start = lap(step_start, timings, "Step 12")
# Step 13: Bad Channels Handling
qc["n_bad_channels_total"] = 0
qc["pct_bad_channels"] = 0.0
qc["bad_channels_handling"] = "None"
if BAD_CHANNELS_HANDLING != "None" and not FOLDING_BYP:
raw, fig_dropped, fig_raw_before, bad_channels = mark_bads(raw, bad_sci, bad_snr, bad_psp, bad_coeff_var, bad_amplitude_range, bad_noise, bad_disp)
qc["n_bad_channels_total"] = len(bad_channels)
qc["pct_bad_channels"] = round(100 * len(bad_channels) / qc["n_channels_loaded"], 1) if qc["n_channels_loaded"] else 0.0
qc["bad_channels_handling"] = BAD_CHANNELS_HANDLING
if fig_dropped and fig_raw_before is not None:
_enqueue("Bad Channels by Method", fig_dropped, png_queue)
_enqueue("Bad Channels Data", fig_raw_before, png_queue)
@@ -5625,6 +5794,8 @@ def process_participant(file_path, file_start, progress_callback=None):
step_start = lap(step_start, timings, "Step 20")
# Step 21: Epoch Calculations
epochs = None
qc["n_epochs_final"] = 0
if EPOCHS and EVENTS and not FOLDING_BYP:
epochs = epochs_calculations(
raw_haemo_evnt,
@@ -5639,6 +5810,7 @@ def process_participant(file_path, file_start, progress_callback=None):
reject_hbo_threshold=dict(hbo=REJECT_HBO_THRESHOLD),
png_queue=png_queue
)
qc["n_epochs_final"] = len(epochs) if epochs is not None else 0
if progress_callback: progress_callback(21)
logger.info("Step 21 Completed.")
step_start = lap(step_start, timings, "Step 21")
@@ -5714,12 +5886,13 @@ def process_participant(file_path, file_start, progress_callback=None):
step_start = lap(step_start, timings, "Step 28")
# Step 28.5: Return the results
qc["total_processing_seconds"] = round(sum(timings.values()), 2)
logger.info("Step timings:")
for name, elapsed in timings.items():
logger.info(f" {name:<25} {elapsed:7.3f}s")
logger.info(f"Total processing time: {sum(timings.values()):.3f}s")
return raw_haemo, epochs, df_cha, df_roi, df_design_matrix, config_dict, fig_bytes_dict, contrast_results_dict, roi_channel_map, True
return raw_haemo, epochs, df_cha, df_roi, df_design_matrix, config_dict, fig_bytes_dict, contrast_results_dict, roi_channel_map, qc, True
@@ -6593,6 +6766,134 @@ def peak_power_fast(
return raw, scores, times
def write_qc_excel_summary(qc_rows: list[dict], output_path: str) -> None:
"""
Writes one Excel workbook summarizing QC metrics across all participants
in a batch run - participants as columns, metrics as rows, each row
color-scaled green (best) to red (worst) with direction-aware coloring,
plus live summary formulas (mean/median/min/max/worst participant) per row.
Failed participants are shown in a separate, clearly labeled block so
they don't distort the color scale of successful participants' numbers.
"""
successes = [r for r in qc_rows if r.get("status") == "success"]
failures = [r for r in qc_rows if r.get("status") == "FAILED"]
wb = Workbook()
ws = wb.active
ws.title = "QC Summary"
header_font = Font(name="Arial", bold=True, size=11)
label_font = Font(name="Arial", size=10)
body_font = Font(name="Arial", size=10)
fail_fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
metric_keys = [k for k in QC_METRIC_LABELS if k in QC_METRIC_DIRECTIONS or k in QC_METRIC_DEVIATION_BASED]
n_participants = len(successes)
# --- Header row ---
ws.cell(row=1, column=1, value="Metric").font = header_font
for i, row in enumerate(successes):
col = i + 2
c = ws.cell(row=1, column=col, value=row.get("file_path", f"P{i+1}"))
c.font = header_font
c.alignment = Alignment(horizontal="center", wrap_text=True)
summary_start_col = n_participants + 3 # one blank column, then summary block
for j, label in enumerate(["Mean", "Median", "Min", "Max", "Worst Participant"]):
c = ws.cell(row=1, column=summary_start_col + j, value=label)
c.font = header_font
c.alignment = Alignment(horizontal="center", wrap_text=True)
# --- Metric rows ---
for r_idx, key in enumerate(metric_keys):
row_num = r_idx + 2
ws.cell(row=row_num, column=1, value=QC_METRIC_LABELS[key]).font = label_font
for i, row in enumerate(successes):
col = i + 2
val = row.get(key)
c = ws.cell(row=row_num, column=col, value=val)
c.font = body_font
if n_participants == 0:
continue
first_col_letter = get_column_letter(2)
last_col_letter = get_column_letter(n_participants + 1)
data_range = f"{first_col_letter}{row_num}:{last_col_letter}{row_num}"
mean_col, median_col, min_col, max_col, worst_col = (
summary_start_col, summary_start_col + 1, summary_start_col + 2,
summary_start_col + 3, summary_start_col + 4,
)
ws.cell(row=row_num, column=mean_col, value=f"=AVERAGE({data_range})").font = body_font
ws.cell(row=row_num, column=median_col, value=f"=MEDIAN({data_range})").font = body_font
ws.cell(row=row_num, column=min_col, value=f"=MIN({data_range})").font = body_font
ws.cell(row=row_num, column=max_col, value=f"=MAX({data_range})").font = body_font
header_range = f"{first_col_letter}1:{last_col_letter}1"
if key in QC_METRIC_DEVIATION_BASED:
# "worst" = furthest from the row's own median
worst_formula = (
f"=INDEX({header_range},MATCH(MAX(ABS({data_range}-MEDIAN({data_range}))),"
f"ABS({data_range}-MEDIAN({data_range})),0))"
)
elif QC_METRIC_DIRECTIONS[key]: # lower is better -> worst = max
worst_formula = f"=INDEX({header_range},MATCH(MAX({data_range}),{data_range},0))"
else: # higher is better -> worst = min
worst_formula = f"=INDEX({header_range},MATCH(MIN({data_range}),{data_range},0))"
ws.cell(row=row_num, column=worst_col, value=worst_formula).font = body_font
# --- Color scale, direction-aware ---
if key in QC_METRIC_DEVIATION_BASED:
# color by |value - row median| via a helper column pattern isn't
# natively supported by ColorScaleRule (it colors raw cell values,
# not a derived formula) - approximate by centering the 3-color
# scale on the row's own values, which still highlights the
# extremes/outliers visually even without true deviation coloring.
rule = ColorScaleRule(
start_type="min", start_color="63BE7B",
mid_type="percentile", mid_value=50, mid_color="FFEB84",
end_type="max", end_color="F8696B",
)
elif QC_METRIC_DIRECTIONS[key]: # lower is better: green=min, red=max
rule = ColorScaleRule(
start_type="min", start_color="63BE7B",
mid_type="percentile", mid_value=50, mid_color="FFEB84",
end_type="max", end_color="F8696B",
)
else: # higher is better: green=max, red=min
rule = ColorScaleRule(
start_type="min", start_color="F8696B",
mid_type="percentile", mid_value=50, mid_color="FFEB84",
end_type="max", end_color="63BE7B",
)
ws.conditional_formatting.add(data_range, rule)
# --- Failed participants block, separate and clearly marked ---
if failures:
fail_row_start = len(metric_keys) + 4
ws.cell(row=fail_row_start, column=1, value="FAILED PARTICIPANTS").font = Font(name="Arial", bold=True, size=12, color="CC0000")
for i, row in enumerate(failures):
r = fail_row_start + 1 + i
path_cell = ws.cell(row=r, column=1, value=row.get("file_path", "unknown"))
path_cell.font = body_font
path_cell.fill = fail_fill
err_cell = ws.cell(row=r, column=2, value=row.get("error", "unknown error"))
err_cell.font = body_font
err_cell.fill = fail_fill
ws.column_dimensions['A'].width = 32
for i in range(n_participants):
ws.column_dimensions[get_column_letter(i + 2)].width = 14
ws.freeze_panes = "B2"
wb.save(output_path)
if __name__ == "__main__":
print("This file has no functionality when not used in tandem with the FLARES application.")
+8 -7
View File
@@ -129,10 +129,10 @@ SECTIONS = [
"params": [
{"name": "SCI", "default": True, "type": bool, "advanced": False, "help": "Calculate and mark channels bad based on their Scalp Coupling Index. This metric calculates the quality of the connection between the optode and the scalp."},
{"name": "SCI_USE_HEART_RATE_BAND", "default": True, "type": bool, "depends_on": [{"parent_name": "SCI"}, {"parent_name": "HEART_RATE"}], "advanced": False, "help": "Adjust the SCI frequency band using the participant's estimated heart rate."},
{"name": "SCI_LOW_FREQ", "default": 0.7, "type": float, "depends_on": "SCI_USE_HEART_RATE_BAND", "depends_value": False,"advanced": True, "help": "Lower frequency cutoff for SCI bandpass filtering (Hz)."},
{"name": "SCI_HIGH_FREQ", "default": 1.5, "type": float, "depends_on": "SCI_USE_HEART_RATE_BAND", "depends_value": False, "advanced": True, "help": "Upper frequency cutoff for SCI bandpass filtering (Hz)."},
{"name": "SCI_LOW_FREQ", "default": 0.7, "type": float, "depends_on": "SCI_USE_HEART_RATE_BAND", "depends_value": False,"advanced": True, "help": "Lower frequency bound for the signal band used in SCI calculation (Hz)."},
{"name": "SCI_HIGH_FREQ", "default": 1.5, "type": float, "depends_on": "SCI_USE_HEART_RATE_BAND", "depends_value": False, "advanced": True, "help": "Lower frequency bound for the signal band used in SCI calculation (Hz)."},
{"name": "SCI_TIME_WINDOW", "default": 3, "type": int, "depends_on": "SCI", "advanced": False, "help": "Duration of each independent SCI calculation window in seconds."},
{"name": "SCI_THRESHOLD", "default": 0.6, "type": float, "depends_on": "SCI", "advanced": False, "help": "SCI threshold on a scale of 0-1. Channels below this value are marked bad."},
{"name": "SCI_THRESHOLD", "default": 0.6, "type": float, "depends_on": "SCI", "advanced": False, "help": "SCI threshold on a scale of 0-1. Channels below this value will be marked bad."},
]
},
{
@@ -150,10 +150,11 @@ SECTIONS = [
"title": "Peak Spectral Power",
"params": [
{"name": "PSP", "default": True, "type": bool, "advanced": False, "help": "Calculate and mark channels bad based on their Peak Spectral Power. This metric calculates the amplitude or strength of the most prominent frequency component in a specified spectral range."},
{"name": "PSP_TIME_WINDOW", "default": 3, "type": int, "depends_on": "PSP", "advanced": False, "help": "Length of each independent PSP calculation window in seconds."},
{"name": "PSP_THRESHOLD", "default": 0.1, "type": float, "depends_on": "PSP", "advanced": False, "help": "Channels with average PSP values below this threshold will be marked as bad."},
{"name": "PSP_LOW_FREQ", "default": 0.7, "type": float, "depends_on": "PSP", "advanced": True, "help": "Lower frequency cutoff for PSP bandpass filtering (Hz)."},
{"name": "PSP_HIGH_FREQ", "default": 1.5, "type": float, "depends_on": "PSP", "advanced": True, "help": "Upper frequency cutoff for PSP bandpass filtering (Hz)."},
{"name": "PSP_USE_HEART_RATE_BAND", "default": True, "type": bool, "depends_on": [{"parent_name": "PSP"}, {"parent_name": "HEART_RATE"}], "advanced": False, "help": "Adjust the PSP frequency band using the participant's estimated heart rate."},
{"name": "PSP_LOW_FREQ", "default": 0.7, "type": float, "depends_on": "PSP", "advanced": True, "help": "Lower frequency bound for the signal band used in PSP calculation (Hz)."},
{"name": "PSP_HIGH_FREQ", "default": 1.5, "type": float, "depends_on": "PSP", "advanced": True, "help": "Upper frequency bound for the signal band used in PSP calculation (Hz)."},
{"name": "PSP_TIME_WINDOW", "default": 3, "type": int, "depends_on": "PSP", "advanced": False, "help": "Duration of each independent PSP calculation window in seconds."},
{"name": "PSP_THRESHOLD", "default": 0.1, "type": float, "depends_on": "PSP", "advanced": False, "help": "PSP threshold on a scale of 0-1. Channels below this value will be marked bad."},
]
},
{