heart rate improvements
This commit is contained in:
@@ -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.")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user