improved heart rate calculations

This commit is contained in:
2026-08-24 16:40:02 -07:00
parent d9d5b6d940
commit 2fa3188296
4 changed files with 139 additions and 73 deletions
+1
View File
@@ -11,6 +11,7 @@
- Added description text to the Inter-Group and Intra-Group Brain and Image Viewers, as well as the Functional Connectivity windows to explain what output can be expected
- 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
+96 -41
View File
@@ -4526,9 +4526,9 @@ def short_channel_processing_for_hr(
def calculate_heart_rate_neurokit(sfreq: float, signal_trimmed: NDArray[float64], hr_low_freq, hr_high_freq, max_low_hr, max_high_hr, smoothing_window_hr) -> tuple[NDArray[float64], float]:
def calculate_heart_rate_neurokit(sfreq: float, signal_trimmed: NDArray[float64], hr_low_freq, hr_high_freq, max_low_hr, max_high_hr, smoothing_window_hr, short_channels) -> tuple[NDArray[float64], float]:
"""
Calculate and smooth heart rate from a trimmed signal using NeuroKit.
Calculate and smooths heart rate from a trimmed signal using NeuroKit.
Parameters
----------
@@ -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
View File
@@ -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
View File
@@ -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