From e1255f53c3d74b54e24029a9edc81620400eefad Mon Sep 17 00:00:00 2001 From: lawhead Date: Mon, 18 Nov 2024 10:36:07 -0800 Subject: [PATCH 01/76] Fixed simulator regression --- bcipy/simulator/task/copy_phrase.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bcipy/simulator/task/copy_phrase.py b/bcipy/simulator/task/copy_phrase.py index 40f242f0a..03e99660a 100644 --- a/bcipy/simulator/task/copy_phrase.py +++ b/bcipy/simulator/task/copy_phrase.py @@ -1,7 +1,7 @@ # mypy: disable-error-code="union-attr" """Simulates the Copy Phrase task""" -from typing import Any, Dict, List, Optional, Tuple import logging +from typing import Any, Dict, List, Optional, Tuple from bcipy.display.main import Display from bcipy.feedback.visual.visual_feedback import VisualFeedback @@ -12,9 +12,9 @@ from bcipy.simulator.data.sampler import Sampler from bcipy.simulator.task.null_display import NullDisplay from bcipy.simulator.util.state import SimState +from bcipy.task import TaskMode from bcipy.task.control.evidence import EvidenceEvaluator from bcipy.task.data import EvidenceType -from bcipy.task import TaskMode from bcipy.task.paradigm.rsvp.copy_phrase import RSVPCopyPhraseTask DEFAULT_EVIDENCE_TYPE = EvidenceType.ERP @@ -133,7 +133,7 @@ def compute_device_evidence( # This assumes that sampling is independent. Changes to the sampler API are needed if # we need to provide the trial context of the last sample. sampled_data = sampler.sample_data(current_state) - evidence = model.compute_class_probabilities( + evidence = model.compute_likelihood_ratio( sampled_data, self.current_symbols(), self.alp) From 99d7d5ee2ddddfa0837d6691bf5e785b7dbe9e54 Mon Sep 17 00:00:00 2001 From: lawhead Date: Mon, 18 Nov 2024 10:37:06 -0800 Subject: [PATCH 02/76] Added temporary script to update parameters files to our latest format --- scripts/python/update_params.py | 65 +++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 scripts/python/update_params.py diff --git a/scripts/python/update_params.py b/scripts/python/update_params.py new file mode 100644 index 000000000..4cd9c8713 --- /dev/null +++ b/scripts/python/update_params.py @@ -0,0 +1,65 @@ +"""Update parameters files to latest.""" + +import argparse +import json +import shutil +from pathlib import Path + +from bcipy.helpers.parameters import DEFAULT_PARAMETERS_PATH, Parameters + + +def update(params_path: str) -> None: + """Update the parameters at the given path""" + default_params = Parameters(DEFAULT_PARAMETERS_PATH, cast_values=True) + + # open as json, not Parameters, to avoid validation checks + with open(params_path, 'r', encoding='utf8') as json_file: + params = json.load(json_file) + + # rename attributes if needed + mapping = {"readableName": "name", "recommended_values": "recommended"} + + updated_params = {} + for key, entry in params.items(): + editable = False + if key in default_params: + editable = default_params.get_entry(key)['editable'] + value = {mapping.get(k, k): v for k, v in entry.items()} + + if "editable" not in value: + value["editable"] = editable + updated_params[key] = value + + # overwrite json file if needed + original_path = Path(params_path) + shutil.copyfile(original_path, + Path(original_path.parent, "parameters_original.json")) + + with open(params_path, 'w', encoding='utf8') as json_path: + json.dump(updated_params, json_path, ensure_ascii=False, indent=2) + + # load from overwritten file + parameters = Parameters(source=params_path, cast_values=True) + + added_params = [ + key for key, change in default_params.diff(parameters).items() + if change.original_value is None + ] + + if added_params: + print( + f"Adding missing parameters using default values: {added_params}") + parameters.add_missing_items(default_params) + + parameters.save() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("-p", + "--parameters", + type=Path, + required=False, + help="Parameter File to be used") + args = parser.parse_args() + update(args.parameters) From 4a5b466917c9c635bf2146e9b81b777d253b536f Mon Sep 17 00:00:00 2001 From: lawhead Date: Tue, 19 Nov 2024 16:11:22 -0800 Subject: [PATCH 03/76] Added an interactive command line tool for running the simulator --- bcipy/helpers/load.py | 15 +- bcipy/helpers/parameters.py | 7 +- bcipy/simulator/task/task_factory.py | 40 +++-- bcipy/simulator/task/task_runner.py | 62 ++++--- bcipy/simulator/ui/cli.py | 247 +++++++++++++++++++++++++++ requirements.txt | 1 + 6 files changed, 329 insertions(+), 43 deletions(-) create mode 100644 bcipy/simulator/ui/cli.py diff --git a/bcipy/helpers/load.py b/bcipy/helpers/load.py index 22454c5bc..2779e88b1 100644 --- a/bcipy/helpers/load.py +++ b/bcipy/helpers/load.py @@ -11,10 +11,9 @@ from bcipy.config import (DEFAULT_ENCODING, DEFAULT_EXPERIMENT_PATH, DEFAULT_FIELD_PATH, DEFAULT_PARAMETERS_PATH, EXPERIMENT_FILENAME, FIELD_FILENAME, ROOT, - SIGNAL_MODEL_FILE_SUFFIX, SESSION_LOG_FILENAME) + SESSION_LOG_FILENAME, SIGNAL_MODEL_FILE_SUFFIX) +from bcipy.exceptions import BciPyCoreException, InvalidExperimentException from bcipy.gui.file_dialog import ask_directory, ask_filename -from bcipy.exceptions import (BciPyCoreException, - InvalidExperimentException) from bcipy.helpers.parameters import Parameters from bcipy.helpers.raw_data import RawData from bcipy.preferences import preferences @@ -203,6 +202,16 @@ def choose_signal_models(device_types: List[str]) -> List[SignalModel]: ] +def choose_model_paths(device_types: List[str]) -> List[Path]: + """Select a model for each device and return a list of paths.""" + return [ + ask_filename(file_types=f"*{SIGNAL_MODEL_FILE_SUFFIX}", + directory=preferences.signal_model_directory, + prompt=f"Select the {device_type} signal model") + for device_type in device_types + ] + + def load_signal_model(file_path: str) -> SignalModel: """Load signal model from persisted file. diff --git a/bcipy/helpers/parameters.py b/bcipy/helpers/parameters.py index 3be5f3d48..dbcf03009 100644 --- a/bcipy/helpers/parameters.py +++ b/bcipy/helpers/parameters.py @@ -4,7 +4,7 @@ from json import dump, load from pathlib import Path from re import fullmatch -from typing import Optional, Any, Dict, NamedTuple, Tuple +from typing import Any, Dict, NamedTuple, Optional, Tuple from bcipy.config import DEFAULT_ENCODING, DEFAULT_PARAMETERS_PATH @@ -79,10 +79,7 @@ def __init__(self, source: Optional[str] = None, cast_values: bool = False): self.source = source self.cast_values = cast_values - self.required_keys = set([ - 'value', 'section', 'name', 'helpTip', - 'recommended', 'editable', 'type' - ]) + self.required_keys = set(Parameter._fields) self.conversions = { 'int': int, 'float': float, diff --git a/bcipy/simulator/task/task_factory.py b/bcipy/simulator/task/task_factory.py index 4de38fe0b..3e7c4b7e8 100644 --- a/bcipy/simulator/task/task_factory.py +++ b/bcipy/simulator/task/task_factory.py @@ -3,8 +3,8 @@ from typing import Dict, List, Type from bcipy.helpers.language_model import init_language_model -from bcipy.helpers.load import load_json_parameters, load_signal_models -from bcipy.helpers.parameters import DEFAULT_PARAMETERS_PATH +from bcipy.helpers.load import load_json_parameters, load_signal_model +from bcipy.helpers.parameters import DEFAULT_PARAMETERS_PATH, Parameters from bcipy.signal.model.base_model import SignalModel from bcipy.simulator.data.data_engine import RawDataEngine from bcipy.simulator.data.data_process import init_data_processor @@ -15,18 +15,34 @@ logger = logging.getLogger(TOP_LEVEL_LOGGER_NAME) +def update_latest_params(parameters: Parameters) -> None: + """Update the given parameters with the latest missing values""" + default_params = load_json_parameters(DEFAULT_PARAMETERS_PATH, + value_cast=True) + added_params = [ + key for key, change in default_params.diff(parameters).items() + if change.original_value is None + ] + if added_params: + logger.info( + f"Added missing parameters using default values: {added_params}") + parameters.add_missing_items(default_params) + + class TaskFactory(): """Constructs the hierarchy of objects necessary for initializing a task.""" def __init__( self, params_path: str, - model_path: str, source_dirs: List[str], + signal_model_paths: List[str], sampling_strategy: Type[Sampler] = TargetNontargetSampler, task: Type[SimulatorCopyPhraseTask] = SimulatorCopyPhraseTask): + self.params_path = params_path - self.model_path = model_path + self.signal_model_paths = signal_model_paths + self.source_dirs = source_dirs self.sampling_strategy = sampling_strategy self.simulation_task = task @@ -34,20 +50,12 @@ def __init__( logger.info("Loading parameters") self.parameters = load_json_parameters(self.params_path, value_cast=True) - default_params = load_json_parameters(DEFAULT_PARAMETERS_PATH, - value_cast=True) - - added_params = [ - key - for key, change in default_params.diff(self.parameters).items() - if change.original_value is None - ] - logger.info( - f"Added missing parameters using default values: {added_params}") - self.parameters.add_missing_items(default_params) + update_latest_params(self.parameters) logger.info("Loading signal models") - self.signal_models = load_signal_models(directory=self.model_path) + self.signal_models = [ + load_signal_model(path) for path in signal_model_paths + ] logger.debug(self.signal_models) logger.info("Initializing language model") diff --git a/bcipy/simulator/task/task_runner.py b/bcipy/simulator/task/task_runner.py index 17cacce27..3c2bbf771 100644 --- a/bcipy/simulator/task/task_runner.py +++ b/bcipy/simulator/task/task_runner.py @@ -8,7 +8,9 @@ from bcipy.simulator.data.sampler import TargetNontargetSampler from bcipy.simulator.task.copy_phrase import SimulatorCopyPhraseTask from bcipy.simulator.task.task_factory import TaskFactory -from bcipy.simulator.util.artifact import (TOP_LEVEL_LOGGER_NAME, +from bcipy.simulator.ui import cli +from bcipy.simulator.util.artifact import (DEFAULT_SAVE_LOCATION, + TOP_LEVEL_LOGGER_NAME, configure_run_directory, init_simulation_dir) @@ -29,7 +31,7 @@ def __init__(self, def run(self) -> None: """Run one or more simulations""" - self.task_factory.parameters.save(self.sim_dir) + self.task_factory.parameters.save(self.sim_dir, 'parameters.json') for i in range(self.runs): logger.info(f"Executing task {i+1}") self.do_run(i + 1) @@ -46,44 +48,66 @@ def do_run(self, run: int): def main(): """Run the task""" glob_help = ('glob pattern to select a subset of data folders' - ' Ex. "*RSVP_Copy_Phrase*"') + ' Ex. "*RSVP_Copy_Phrase*"' + ' Used with a single data_folder') parser = argparse.ArgumentParser() + parser.add_argument("-i", + "--interactive", + required=False, + default=False, + action='store_true', + help="Use interactive mode for selecting simulator inputs") parser.add_argument("-d", "--data_folder", type=Path, - required=True, - help="Raw data folders to be processed.") + required=False, + action='append', + help="Raw data folders to be processed. Multiple values can be provided, or a single parent folder.") parser.add_argument('-g', '--glob_pattern', help=glob_help, default="*") parser.add_argument("-m", "--model_path", type=Path, - required=True, - help="Signal models to be used") + action='append', + required=False, + help="Signal models to be used. Multiple models can be provided.") parser.add_argument("-p", "--parameters", type=Path, - required=True, + required=False, help="Parameter File to be used") parser.add_argument("-n", type=int, required=False, default=1, help="Number of times to run the simulation") + parser.add_argument("-o", + "--output", + type=Path, + required=False, + default=DEFAULT_SAVE_LOCATION, + help="Number of times to run the simulation") args = parser.parse_args() sim_args = vars(args) - sim_args['source_dirs'] = [ - Path(d) for d in glob(str(Path(args.data_folder, args.glob_pattern))) - if Path(d).is_dir() - ] - - sim_dir = init_simulation_dir() + sim_dir = init_simulation_dir(save_location=sim_args['output']) logger.info(sim_dir) - task_factory = TaskFactory(params_path=sim_args['parameters'], - model_path=sim_args['model_path'], - source_dirs=sim_args['source_dirs'], - sampling_strategy=TargetNontargetSampler, - task=SimulatorCopyPhraseTask) + + if args.interactive: + task_factory = cli.main(sim_args) + else: + source_dirs = sim_args['data_folder'] + if len(source_dirs) == 1: + parent = source_dirs[0] + source_dirs = [ + Path(d) for d in glob(str(Path(parent, sim_args['glob_pattern']))) + if Path(d).is_dir() + ] + task_factory = TaskFactory(params_path=sim_args['parameters'], + source_dirs=source_dirs, + signal_model_paths=sim_args['model_path'], + sampling_strategy=TargetNontargetSampler, + task=SimulatorCopyPhraseTask) + runner = TaskRunner(save_dir=sim_dir, task_factory=task_factory, runs=sim_args['n']) diff --git a/bcipy/simulator/ui/cli.py b/bcipy/simulator/ui/cli.py new file mode 100644 index 000000000..122949e68 --- /dev/null +++ b/bcipy/simulator/ui/cli.py @@ -0,0 +1,247 @@ +"""Command line interface for the simulator.""" + +import json +import logging +from glob import glob +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + +from rich import print as rprint +from rich.filesize import decimal +from rich.markup import escape +from rich.prompt import Confirm, Prompt, PromptBase +from rich.text import Text +from rich.tree import Tree + +from bcipy.gui.file_dialog import ask_directory, ask_filename +from bcipy.helpers.acquisition import active_content_types +from bcipy.helpers.load import choose_model_paths +from bcipy.simulator.data.sampler import (InquirySampler, Sampler, + TargetNontargetSampler) +from bcipy.simulator.task.copy_phrase import SimulatorCopyPhraseTask +from bcipy.simulator.task.task_factory import TaskFactory +from bcipy.simulator.util.artifact import TOP_LEVEL_LOGGER_NAME +from bcipy.task.paradigm.rsvp.copy_phrase import RSVPCopyPhraseTask + +logger = logging.getLogger(TOP_LEVEL_LOGGER_NAME) + + +def do_directories(parent: Path, + fn: Callable[[Path], Any], + max_depth: int = 3, + current_depth: int = 1) -> None: + """Recursively walk a tree of directories, calling the provided + function on each path.""" + + paths = sorted( + Path(parent).iterdir(), + key=lambda path: (path.is_file(), path.name.lower()), + ) + for path in paths: + # Remove hidden files + if path.name.startswith("."): + continue + if path.is_dir(): + fn(path) + if (current_depth + 1) <= max_depth: + do_directories(path, fn, max_depth, current_depth + 1) + + +def walk_directory(directory: Path, + tree: Tree, + show_files: bool = True, + skip_dir: Callable[[Path], bool] = lambda p: False) -> None: + """Recursively build a Tree with directory contents. + Adapted from rich library examples/tree.py + + Parameters + ---------- + directory - root directory of the tree + tree - Tree object that gets recursively updated + show_files - boolean indicating if files should be displayed + skip_dir - predicate to determine if a directory should be skipped + """ + # Sort dirs first then by filename + paths = sorted( + Path(directory).iterdir(), + key=lambda path: (path.is_file(), path.name.lower()), + ) + for path in paths: + # Remove hidden files + + if path.name.startswith(".") or (path.is_dir() and skip_dir(path)): + continue + if path.is_dir(): + style = "dim" if path.name.startswith("__") else "" + branch = tree.add( + f"[blue]:open_file_folder: [link file://{path}]{escape(path.name)}", + style=style, + guide_style=style, + ) + walk_directory(path, branch, show_files, skip_dir) + else: + if show_files: + text_filename = Text(path.name) + text_filename.stylize(f"link file://{path}") + file_size = path.stat().st_size + text_filename.append(f" ({decimal(file_size)})", "blue") + tree.add(text_filename) + else: + continue + + +def print_directories(parent, paths: List[Path]) -> None: + """Print the list of directories to terminal as a tree.""" + assert paths, "Paths are required" + paths = sorted(paths) + + def pred(p): + return p not in paths + + show_tree(parent, pred) + + +def excluded(path: Path) -> bool: + """Predicate to determine if a path should not be presented.""" + return path.is_file() or path.name == 'logs' or 'Action' in path.name + + +def select_directories(parent: Path) -> List[Path]: + """Select all directories of interest within the parent path. + Traverses all directories and prompts for each.""" + accum = [] + + def do_prompt(path: Path): + if not excluded(path) and Confirm.ask(str(path.relative_to(parent))): + accum.append(path) + + do_directories(parent, do_prompt, max_depth=2) + return accum + + +def select_subset(parent, paths: List[Path]) -> List[Path]: + """Prompt the user to confirm the subset of paths that should be retained.""" + return [ + path for path in paths + if not excluded(path) and Confirm.ask(str(path.relative_to(parent))) + ] + + +def show_tree(directory: Path, + skip_pred: Callable[[Path], bool] = lambda p: False): + """Prints a tree representation of a directory. + + Parameters + ---------- + directory - root path to display + skip_pred - callable function to determine if a branch in the tree should + be skipped during rendering. + """ + tree = Tree( + f":open_file_folder: [link file://{directory}]{directory}", + guide_style="bold bright_blue", + ) + walk_directory(Path(directory), tree, show_files=False, skip_dir=skip_pred) + rprint(tree) + + +class PromptPath(PromptBase[Path]): + """A prompt that returns a Path. + + Example: + >>> name = PromptPath.ask("Enter a file name") + """ + + response_type = Path + + def process_response(self, value: str) -> Path: + value = value.strip("'\"") + return super().process_response(value) + + +def select_data_folder() -> Path: + """Select a root data folder for simulation.""" + # PromptPath.ask("Input a data folder") + return Path(ask_directory("Select a data folder")) + + +def prompt_filter() -> str: + """Prompt for an optional glob filter.""" + return Prompt.ask("Enter a glob filter (optional)", default="*") + + +def select_data(parent_folder: Optional[str] = None) -> List[Path]: + """Select data to use for the simulation.""" + + path = Path(parent_folder or select_data_folder()) + glob_pattern = prompt_filter() + paths = sorted([ + Path(p) for p in glob(str(Path(path, glob_pattern))) + if not excluded(Path(p)) + ]) + print_directories(path, paths) + if not Confirm.ask("Use all of these?"): + paths = select_subset(path, paths) + print_directories(path, paths) + return paths + + +def choose_sampling_strategy() -> Sampler: + """Choose a sampling strategy""" + classes = [InquirySampler, TargetNontargetSampler] + options = {klass.__name__: klass for klass in classes} + selected = Prompt.ask("Choose a sampling strategy", + choices=options.keys(), + default='TargetNonTargetSampler') + return options[selected] + + +def choose_task() -> RSVPCopyPhraseTask: + """Choose a task to simulate""" + classes = [SimulatorCopyPhraseTask] + options = {klass.__name__: klass for klass in classes} + selected = Prompt.ask("Choose a simulation task", + choices=options.keys(), + default='SimulatorCopyPhraseTask') + return options.get(selected, SimulatorCopyPhraseTask) + + +def select_models(acq_mode: str) -> List[str]: + """Select the signal models to use in simulation.""" + signal_models = choose_model_paths(active_content_types(acq_mode)) + return signal_models + + +def select_parameters_path() -> str: + """Select the parameters path""" + return ask_filename(file_types="*.json", prompt="Select Parameters file") + + +def get_acq_mode(params_path: str): + """Extract the acquisition mode field from the json file at the given path.""" + with open(params_path, 'r', encoding='utf8') as json_file: + params = json.load(json_file) + return params['acq_mode'].get('value', 'EEG') + + +def main(args: Dict[str, Any]) -> TaskFactory: + """Main function""" + params = args.get('parameters', None) + if not params: + params = select_parameters_path() + + model_paths = args.get('model_path', None) + if not model_paths: + model_paths = select_models(get_acq_mode(params)) + + source_dirs = select_data(args.get('data_folder', None)) + + return TaskFactory(params_path=params, + source_dirs=source_dirs, + signal_model_paths=model_paths, + sampling_strategy=TargetNontargetSampler, + task=SimulatorCopyPhraseTask) + + +if __name__ == '__main__': + main({}) diff --git a/requirements.txt b/requirements.txt index b6c87fecc..4610bad99 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,5 +24,6 @@ pyopengl==3.1.7 PyQt6==6.7.1 pywavelets==1.4.1 tqdm==4.62.2 +rich==13.9.4 reportlab==4.2.0 tables==3.7.0 \ No newline at end of file From dd85fb6dd4711b916b1df8b4e1572496310d6e20 Mon Sep 17 00:00:00 2001 From: lawhead Date: Thu, 21 Nov 2024 16:52:12 -0800 Subject: [PATCH 04/76] Initial implementation for simulator GUI --- bcipy/gui/main.py | 6 +- bcipy/simulator/ui/cli.py | 17 +- bcipy/simulator/ui/gui.py | 626 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 645 insertions(+), 4 deletions(-) create mode 100644 bcipy/simulator/ui/gui.py diff --git a/bcipy/gui/main.py b/bcipy/gui/main.py index b6455338c..a67a3c4f4 100644 --- a/bcipy/gui/main.py +++ b/bcipy/gui/main.py @@ -224,7 +224,7 @@ def __init__(self, value: str, help_tip: Optional[str] = None, options: Optional[List[str]] = None, - editable: bool = True, + editable: Optional[bool] = True, help_size: int = 12, help_color: str = 'darkgray', should_display: bool = True): @@ -273,8 +273,10 @@ def init_control(self, value) -> QWidget: # Default is a text input return QLineEdit(value) - def init_editable(self, value: bool) -> QWidget: + def init_editable(self, value: bool) -> Optional[QWidget]: "Override. Another checkbox is needed for editable" + if value is None: + return None editable_checkbox = QCheckBox("Editable") editable_checkbox.setChecked(value) editable_checkbox.setFont(font(size=12)) diff --git a/bcipy/simulator/ui/cli.py b/bcipy/simulator/ui/cli.py index 122949e68..83c309cac 100644 --- a/bcipy/simulator/ui/cli.py +++ b/bcipy/simulator/ui/cli.py @@ -172,11 +172,15 @@ def prompt_filter() -> str: def select_data(parent_folder: Optional[str] = None) -> List[Path]: """Select data to use for the simulation.""" + if parent_folder: + path = Path(parent_folder) + else: + selected = select_data_folder() + path = Path(selected) - path = Path(parent_folder or select_data_folder()) glob_pattern = prompt_filter() paths = sorted([ - Path(p) for p in glob(str(Path(path, glob_pattern))) + Path(p) for p in glob(str(Path(path, glob_pattern)), recursive=True) if not excluded(Path(p)) ]) print_directories(path, paths) @@ -224,6 +228,14 @@ def get_acq_mode(params_path: str): return params['acq_mode'].get('value', 'EEG') +def command(params: str, models: List[str], source_dirs: List[str]) -> None: + """Command equivalent to to the result of the interactive selection of + simulator inputs.""" + model_args = ' '.join([f"-m {path}" for path in models]) + dir_args = ' '.join(f"-d {source}" for source in source_dirs) + return f"bcipy-sim -p {params} {model_args} {dir_args}" + + def main(args: Dict[str, Any]) -> TaskFactory: """Main function""" params = args.get('parameters', None) @@ -236,6 +248,7 @@ def main(args: Dict[str, Any]) -> TaskFactory: source_dirs = select_data(args.get('data_folder', None)) + logger.info(command(params, model_paths, source_dirs)) return TaskFactory(params_path=params, source_dirs=source_dirs, signal_model_paths=model_paths, diff --git a/bcipy/simulator/ui/gui.py b/bcipy/simulator/ui/gui.py new file mode 100644 index 000000000..20e474e89 --- /dev/null +++ b/bcipy/simulator/ui/gui.py @@ -0,0 +1,626 @@ +"""Graphical User Interface for configuring the Simulator.""" + +# pylint: disable=E0611 +import argparse +import fnmatch +import os +import sys +from datetime import datetime +from pathlib import Path +from typing import Callable, List, Optional, Tuple, Union + +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import (QApplication, QCheckBox, QFileDialog, QHBoxLayout, + QLabel, QLineEdit, QPushButton, QScrollArea, + QTreeWidget, QTreeWidgetItem, QVBoxLayout, + QWidget) + +from bcipy.config import DEFAULT_PARAMETERS_PATH +from bcipy.gui.file_dialog import FileDialog +from bcipy.gui.main import (DirectoryInput, FileInput, IntegerInput, + static_text_control) +from bcipy.helpers.acquisition import active_content_types +from bcipy.helpers.parameters import Parameters +from bcipy.preferences import preferences +from bcipy.simulator.ui.cli import excluded + + +def walk_directory(directory: Path, + tree: Union[QTreeWidget, QTreeWidgetItem], + skip_dir: Callable[[Path], bool] = lambda p: False) -> None: + """Recursively build a Tree with directory contents. + Adapted from rich library examples/tree.py + + Parameters + ---------- + directory - root directory of the tree + tree - Tree object that gets recursively updated + skip_dir - predicate to determine if a directory should be skipped + """ + # Sort dirs first then by filename + paths = sorted( + Path(directory).iterdir(), + key=lambda path: (path.is_file(), path.name.lower()), + ) + for path in paths: + # Remove hidden files + if path.name.startswith(".") or path.is_file() or (path.is_dir() + and skip_dir(path)): + continue + if path.is_dir(): + branch = QTreeWidgetItem(tree, [path.name]) + walk_directory(path, branch, skip_dir) + + +def is_ancestor(directory: Path, paths: List[Path]) -> bool: + """Check if the given directory is the ancestor of any of the provided paths.""" + for path in paths: + if directory in path.parents: + return True + return False + + +class DirectoryTree(QWidget): + """Display a tree of directories""" + + def __init__(self, + parent_directory: Optional[str] = None, + selected_subdirectories: Optional[List[str]] = None): + super().__init__() + self.parent_directory = parent_directory + self.paths = selected_subdirectories or [] + + self.tree = self.make_tree() + self.layout = QVBoxLayout() + self.layout.addWidget(self.tree) + self.setLayout(self.layout) + self.hide() + + def update(self, parent_directory: str, + selected_subdirectories: List[str]) -> None: + """Update the widget.""" + self.parent_directory = parent_directory + self.paths = selected_subdirectories or [] + + self.layout.removeWidget(self.tree) + if self.parent_directory and self.paths: + self.tree = self.make_tree() + self.layout.addWidget(self.tree) + self.show() + else: + self.hide() + + def make_tree(self) -> QTreeWidget: + """Initialize the tree widget""" + + tree = QTreeWidget() + if self.parent_directory: + tree.setColumnCount(1) + tree.setHeaderLabels([self.parent_directory]) + walk_directory(Path(self.parent_directory), + tree, + skip_dir=self.skip_path) + return tree + + def skip_path(self, directory: Path) -> bool: + """Should the given directory be skipped""" + if directory in self.paths or is_ancestor(directory, self.paths): + return False + return True + + def show(self): + """Show this widget, and all child widgets.""" + self.tree.setVisible(True) + + def hide(self): + """Hide this widget, and all child widgets.""" + self.tree.setVisible(False) + + +class ParameterFileInput(FileInput): + """Prompts for a parameters file.""" + + def __init__(self, param_change_event: Callable, **kwargs): + super().__init__(**kwargs) + self.parameters: Optional[Parameters] = None + self.param_change_event = param_change_event + self.control.textChanged.connect(self.on_parameter_change) + + def prompt_path(self) -> str: + """Prompts the user with a FileDialog. Returns the selected value or None.""" + dialog = FileDialog() + directory = preferences.last_directory + filename = dialog.ask_file("*.json", + directory, + prompt="Select a parameters file") + + # update last directory preference + path = Path(filename) + if filename and path.is_file(): + preferences.last_directory = str(path.parent) + return filename + + def on_parameter_change(self): + """Connected to a change in the user input parameters.""" + if self.value() is not None and Path(self.value()).is_file(): + self.parameters = Parameters(self.value(), cast_values=True) + else: + self.parameters = None + self.param_change_event() + + +class ParentDirectoryInput(DirectoryInput): + """Input for the parent directory. Notifies on change.""" + + def __init__(self, change_event: Optional[Callable], **kwargs): + super().__init__(**kwargs) + self.change_event = change_event + self.control.textChanged.connect(self.change) + + def prompt_path(self): + dialog = FileDialog() + directory = '' + if preferences.last_directory: + directory = str(Path(preferences.last_directory).parent) + name = dialog.ask_directory(directory, + prompt="Select a parent data directory") + if name and Path(name).is_dir(): + preferences.last_directory = name + return name + + def separator(self) -> QWidget: + line = QLabel() + line.setFixedHeight(0) + return line + + def change(self): + if self.change_event: + self.change_event() + + +class DataDirectorySelect(QWidget): + """Widget that facilitates the selection of data directories. + Includes an input for a parent directory and optional filters. + """ + + def __init__(self, parent_directory: Optional[str] = None): + super().__init__() + self.layout = QVBoxLayout() + + self.parent_directory_control = ParentDirectoryInput( + change_event=self.update_directory_tree, + label="Data Parent Directory", + value=parent_directory, + help_tip="Parent directory for data folders", + editable=None) + + self.nested_filter_control = QCheckBox("Include nested directories") + self.nested_filter_control.setChecked(True) + self.nested_filter_control.checkStateChanged.connect( + self.update_directory_tree) + + self.name_filter_control_label = QLabel("Name contains") + self.name_filter_control = QLineEdit("") + self.name_filter_control.textChanged.connect( + self.update_directory_tree) + + self.directory_tree = DirectoryTree() + + self.layout.addWidget(self.parent_directory_control) + + self.layout.addWidget(self.nested_filter_control) + self.layout.addWidget(self.name_filter_control_label) + self.layout.addWidget(self.name_filter_control) + self.layout.addWidget( + static_text_control(self, label="Selected Data Directories")) + self.layout.addWidget(self.directory_tree) + + self.setLayout(self.layout) + + def parent_directory(self) -> Optional[str]: + """Parent directory""" + parent = self.parent_directory_control.value() + if parent: + return parent.strip() + + def match_pattern(self) -> str: + """Pattern used to match a directory with fnmatch.""" + if self.name_filter_control.text(): + return f"*{self.name_filter_control.text().strip(' *')}*" + return "*" + + def data_directories(self) -> Optional[List[Path]]: + """Compute the data directories of interest.""" + parent = self.parent_directory() + if not parent: + return None + + pattern = self.match_pattern() + subdirs = [] + if self.nested_filter_control.isChecked(): + for cur_path, directories, _files in os.walk(parent, topdown=True): + for name in fnmatch.filter(directories, pattern): + path = Path(cur_path, name) + if not excluded(path): + subdirs.append(path) + else: + cur_path, directories, _files = next(os.walk(parent)) + for name in fnmatch.filter(directories, pattern): + path = Path(cur_path, name) + if not excluded(path): + subdirs.append(path) + return sorted(subdirs) + + def update_directory_tree(self): + """Update the directory tree""" + self.directory_tree.update(self.parent_directory(), + self.data_directories()) + + +class ModelFileInput(FileInput): + """Prompts for a parameters file.""" + + def prompt_path(self) -> str: + """Prompts the user with a FileDialog. Returns the selected value or None.""" + name, _ = QFileDialog.getOpenFileName(caption='Select a model', + filter='*.pkl') + return name + + +class ModelInputs(QWidget): + """Provide a model path input for each configured content type.""" + + def __init__(self, content_types: Optional[List[str]] = None): + super().__init__() + + self.layout = QVBoxLayout() + self.controls = self._create_inputs(content_types or ['EEG']) + self._add_controls() + self.setLayout(self.layout) + + def set_content_types(self, value: List[str]): + """Set the content_content types and re-generate inputs.""" + self.controls = self._create_inputs(value) + self._add_controls() + + def _create_inputs(self, content_types: List[str]) -> List[QWidget]: + """Create a path input for each model based on the configured acq_mode.""" + return [ + ModelFileInput(label=f"{content_type} Model", + value=None, + help_tip=f"Path to the {content_type} model.", + editable=None) for content_type in content_types + ] + + def _add_controls(self) -> None: + """Add each model input to the layout.""" + clear_layout(self.layout) + for control in self.controls: + self.layout.addWidget(control) + + +class SimConfigForm(QWidget): + """The InputForm class is a QWidget that creates controls/inputs for each simulation parameter + + Parameters: + ----------- + json_file - path of parameters file to be edited. + width - optional; used to set the width of the form controls. + """ + + def __init__(self, width: int = 400): + super().__init__() + + self.layout = QVBoxLayout() + self.setLayout(self.layout) + self.setFixedWidth(width) + + self.parameter_control = ParameterFileInput( + param_change_event=self.update_parameters, + label="Parameters", + value=None, + help_tip="Path to the simulation parameters.json file.", + editable=None) + + self.directory_control = DataDirectorySelect() + self.model_input_control = ModelInputs() + self.runs_control = IntegerInput(label='Simulation Runs', + value=1, + editable=None) + + self.layout.addWidget(self.parameter_control) + self.layout.addWidget(self.model_input_control) + self.layout.addWidget(self.directory_control) + self.layout.addWidget(self.runs_control) + self.show() + + @property + def parameters(self) -> Optional[Parameters]: + """Configured parameters""" + return self.parameter_control.parameters + + def command(self, params: str, models: List[str], + source_dirs: List[str]) -> None: + """Command equivalent to to the result of the interactive selection of + simulator inputs.""" + + model_args = ' '.join([f"-m {path}" for path in models]) + dir_args = ' '.join(f"-d {source}" for source in source_dirs) + return f"bcipy-sim -p {params} {model_args} {dir_args}" + + def update_parameters(self) -> None: + """When simulation parameters are updated include an input for each model.""" + print("Updating parameters") + params = self.parameter_control.parameters + if params: + content_types = active_content_types(params.get('acq_mode', 'EEG')) + else: + content_types = ['EEG'] + print("content types:") + print(content_types) + self.model_input_control.set_content_types(content_types) + + +def clear_layout(layout): + """Clear all items from a layout. + https://stackoverflow.com/questions/9374063/remove-all-items-from-a-layout + """ + while layout.count(): + child = layout.takeAt(0) + if child.widget() is not None: + child.widget().setVisible(False) + child.widget().deleteLater() + elif child.layout() is not None: + clear_layout(child.layout()) + + +def do_to_widgets(layout, action): + """Perform some action on all widgets, including those nested in + child layouts.""" + for i in range(layout.count()): + child = layout.itemAt(i) + if child.widget() is not None: + action(child.widget()) + elif child.layout() is not None: + do_to_widgets(child.layout(), action) + + +def show_all(layout): + """Show all items in a layout""" + do_to_widgets(layout, lambda widget: widget.setVisible(True)) + + +def hide_all(layout): + """Hide all items in a layout""" + do_to_widgets(layout, lambda widget: widget.setVisible(False)) + + +# class ChangeItems(QWidget): +# """Line items showing parameter changes.""" + +# def __init__(self, json_file: str): +# super().__init__() +# self.changes = changes_from_default(json_file) +# self.layout = QVBoxLayout() +# self._init_changes() +# self.setLayout(self.layout) + +# def _init_changes(self): +# """Initialize the line items for changes.""" +# if not self.changes: +# self.layout.addWidget( +# static_text_control(None, +# label="None", +# size=12, +# color='darkgray')) + +# for _key, param_change in self.changes.items(): +# param = param_change.parameter +# hbox = QHBoxLayout() + +# lbl = static_text_control( +# None, +# label=f"* {param['name']}: {param['value']}", +# size=13, +# color="darkgreen") + +# original_value = static_text_control( +# None, +# label=f"(default: {param_change.original_value})", +# color='darkgray', +# size=12) +# hbox.addWidget(lbl) +# hbox.addWidget(original_value) +# self.layout.addLayout(hbox) + +# @property +# def is_empty(self) -> bool: +# """Boolean indicating whether there are any changes""" +# return not bool(self.changes) + +# def update_changes(self, json_file: str): +# """Update the changed items""" +# self.clear() +# self.changes = changes_from_default(json_file) +# self._init_changes() + +# def show(self): +# """Show the changes""" +# show_all(self.layout) + +# def hide(self): +# """Hide the changes""" +# hide_all(self.layout) + +# def clear(self): +# """Clear items""" +# clear_layout(self.layout) + +# class ParamsChanges(QWidget): +# """Displays customizations from the default parameters in a scroll area +# with buttons to show / hide the list of items.""" + +# def __init__(self, json_file: str): +# super().__init__() +# self.change_items = ChangeItems(json_file) +# self.collapsed = self.change_items.is_empty + +# self.show_label = '[+]' +# self.hide_label = '[-]' + +# self.layout = QVBoxLayout() + +# self.changes_area = QScrollArea() +# self.changes_area.setVerticalScrollBarPolicy( +# Qt.ScrollBarPolicy.ScrollBarAlwaysOn) +# self.changes_area.setHorizontalScrollBarPolicy( +# Qt.ScrollBarPolicy.ScrollBarAlwaysOff) +# self.changes_area.setWidgetResizable(True) +# self.changes_area.setWidget(self.change_items) +# self.changes_area.setVisible(not self.collapsed) + +# control_box = QHBoxLayout() +# control_box.addWidget( +# static_text_control(None, label='Changed Parameters:')) +# self.toggle_button = QPushButton( +# self.show_label if self.collapsed else self.hide_label) +# self.toggle_button.setFlat(True) +# self.toggle_button.setFixedWidth(40) +# self.toggle_button.clicked.connect(self.toggle) +# control_box.addWidget(self.toggle_button) + +# self.layout.addLayout(control_box) +# self.layout.addWidget(self.changes_area) +# self.setLayout(self.layout) + +# def update_changes(self, json_file: str): +# """Update the changed items""" +# self.change_items.update_changes(json_file) +# self.changes_area.repaint() + +# def toggle(self): +# """Toggle visibility of items""" +# if self.collapsed: +# self.show() +# else: +# self.collapse() +# self.collapsed = not self.collapsed + +# def show(self): +# """Show the changes""" +# self.changes_area.setVisible(True) +# self.toggle_button.setText(self.hide_label) + +# def collapse(self): +# """Hide the changes""" +# self.changes_area.setVisible(False) +# self.toggle_button.setText(self.show_label) + + +class MainPanel(QWidget): + """Main GUI window. + Parameters: + ----------- + json_file: path to the parameters file. + title: window title + size: window size; (width, height) + """ + + def __init__(self, title: str, size: Tuple[int, int]): + super().__init__() + self.title = title + self.size = size + + self.command = '' + + self.form = SimConfigForm(width=self.size[0]) + # self.changes = ParamsChanges(self.json_file) + self.flash_msg = '' + self.initUI() + + def initUI(self): + """Initialize the UI""" + vbox = QVBoxLayout() + + header_box = QHBoxLayout() + header_box.addSpacing(5) + self.edit_msg = static_text_control(self, + label='Simulation Configuration', + size=14, + color='dimgray') + header_box.addWidget(self.edit_msg) + vbox.addLayout(header_box) + + # self.changes_panel = QHBoxLayout() + # self.changes_panel.addWidget(self.changes) + # vbox.addLayout(self.changes_panel) + + self.form_panel = QScrollArea() + self.form_panel.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOn) + self.form_panel.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.form_panel.setWidgetResizable(True) + self.form_panel.setFixedWidth(self.size[0]) + self.form_panel.setWidget(self.form) + + vbox.addWidget(self.form_panel) + vbox.addSpacing(5) + + # Controls + control_box = QHBoxLayout() + control_box.addStretch() + run_button = QPushButton('Run') + run_button.setFixedWidth(80) + run_button.clicked.connect(self.on_run) + control_box.addWidget(run_button) + + vbox.addLayout(control_box) + + self.save_msg = static_text_control(self, + label='', + size=12, + color='darkgray') + vbox.addWidget(self.save_msg) + self.setLayout(vbox) + self.setFixedHeight(self.size[1]) + self.setWindowTitle(self.title) + self.show() + + def on_run(self): + """Event handler for running the simulation.""" + if self.form.save(): + self.save_msg.setText(f'Last saved: {datetime.now()}') + self.save_msg.repaint() + + # self.changes.update_changes(self.json_file) + # self.changes.repaint() + + +def init(title='BCI Simulator', size=(750, 800)) -> str: + """Set up the GUI components and start the main loop.""" + app = QApplication(sys.argv) + panel = MainPanel(title, size) + app.exec() + command = panel.command + app.quit() + return command + + +def main(): + """Process command line arguments and initialize the GUI.""" + parser = argparse.ArgumentParser() + + # Command line utility for adding arguments/ paths via command line + parser.add_argument('-p', + '--parameters', + default=DEFAULT_PARAMETERS_PATH, + help='Path to parameters.json configuration file.') + args = parser.parse_args() + # Note that this write to stdout is important for the interaction with + # the BCInterface main GUI. + print(init(args.parameters), file=sys.stdout) + + +if __name__ == '__main__': + main() From 797e8f99b0966c6899ad44da18bf718dc672f5cf Mon Sep 17 00:00:00 2001 From: lawhead Date: Fri, 22 Nov 2024 18:03:27 -0800 Subject: [PATCH 05/76] GUI Refactoring --- bcipy/simulator/ui/gui.py | 509 ++++++++++++++++++-------------------- 1 file changed, 235 insertions(+), 274 deletions(-) diff --git a/bcipy/simulator/ui/gui.py b/bcipy/simulator/ui/gui.py index 20e474e89..544d6d39e 100644 --- a/bcipy/simulator/ui/gui.py +++ b/bcipy/simulator/ui/gui.py @@ -5,24 +5,23 @@ import fnmatch import os import sys -from datetime import datetime from pathlib import Path from typing import Callable, List, Optional, Tuple, Union from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import (QApplication, QCheckBox, QFileDialog, QHBoxLayout, - QLabel, QLineEdit, QPushButton, QScrollArea, +from PyQt6.QtWidgets import (QApplication, QCheckBox, QFormLayout, QHBoxLayout, + QLineEdit, QPushButton, QScrollArea, QSpinBox, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget) from bcipy.config import DEFAULT_PARAMETERS_PATH from bcipy.gui.file_dialog import FileDialog -from bcipy.gui.main import (DirectoryInput, FileInput, IntegerInput, - static_text_control) +from bcipy.gui.main import static_text_control from bcipy.helpers.acquisition import active_content_types from bcipy.helpers.parameters import Parameters from bcipy.preferences import preferences from bcipy.simulator.ui.cli import excluded +from bcipy.simulator.util.artifact import DEFAULT_SAVE_LOCATION def walk_directory(directory: Path, @@ -70,11 +69,12 @@ def __init__(self, self.parent_directory = parent_directory self.paths = selected_subdirectories or [] + self.label = "Selected Data Dictionaries" self.tree = self.make_tree() self.layout = QVBoxLayout() + self.layout.addWidget(LabeledWidget(self.label, self.tree)) self.layout.addWidget(self.tree) self.setLayout(self.layout) - self.hide() def update(self, parent_directory: str, selected_subdirectories: List[str]) -> None: @@ -82,13 +82,10 @@ def update(self, parent_directory: str, self.parent_directory = parent_directory self.paths = selected_subdirectories or [] - self.layout.removeWidget(self.tree) - if self.parent_directory and self.paths: + clear_layout(self.layout) + if self.parent_directory: self.tree = self.make_tree() - self.layout.addWidget(self.tree) - self.show() - else: - self.hide() + self.layout.addWidget(LabeledWidget(self.label, self.tree)) def make_tree(self) -> QTreeWidget: """Initialize the tree widget""" @@ -108,72 +105,126 @@ def skip_path(self, directory: Path) -> bool: return False return True - def show(self): - """Show this widget, and all child widgets.""" - self.tree.setVisible(True) - def hide(self): - """Hide this widget, and all child widgets.""" - self.tree.setVisible(False) +class ChooseFileInput(QWidget): + """Text field and button which pops open a file selection dialog.""" + def __init__(self, + value: Optional[str] = None, + file_selector: str = "*", + prompt: str = "Select a file", + change_event: Optional[Callable] = None): -class ParameterFileInput(FileInput): - """Prompts for a parameters file.""" + super().__init__() + self.file_selector = file_selector + self.prompt = prompt + self.change_event = change_event + self.control = QLineEdit(value) + self.control.textChanged.connect(self.change) - def __init__(self, param_change_event: Callable, **kwargs): - super().__init__(**kwargs) - self.parameters: Optional[Parameters] = None - self.param_change_event = param_change_event - self.control.textChanged.connect(self.on_parameter_change) + btn = QPushButton('...') + btn.setFixedWidth(40) + btn.clicked.connect(self.update_path) + hbox = QHBoxLayout() + hbox.setContentsMargins(0, 0, 0, 0) + hbox.addWidget(self.control) + hbox.addWidget(btn) + self.setLayout(hbox) def prompt_path(self) -> str: """Prompts the user with a FileDialog. Returns the selected value or None.""" dialog = FileDialog() directory = preferences.last_directory - filename = dialog.ask_file("*.json", + filename = dialog.ask_file(self.file_selector, directory, - prompt="Select a parameters file") - - # update last directory preference + prompt=self.prompt) path = Path(filename) if filename and path.is_file(): preferences.last_directory = str(path.parent) return filename - def on_parameter_change(self): - """Connected to a change in the user input parameters.""" - if self.value() is not None and Path(self.value()).is_file(): - self.parameters = Parameters(self.value(), cast_values=True) - else: - self.parameters = None - self.param_change_event() + def update_path(self) -> None: + """Prompts the user for a value and updates the control's value if one was input.""" + name = self.prompt_path() + if name: + self.control.setText(name) + def change(self): + """Called when the path changes""" + if self.change_event: + self.change_event() -class ParentDirectoryInput(DirectoryInput): - """Input for the parent directory. Notifies on change.""" + def value(self) -> Optional[str]: + """Path value""" + return self.control.text() - def __init__(self, change_event: Optional[Callable], **kwargs): - super().__init__(**kwargs) - self.change_event = change_event - self.control.textChanged.connect(self.change) + +class ChooseDirectoryInput(ChooseFileInput): + """Text field and button which pops open a directory selection dialog.""" + + def __init__(self, + value: Optional[str] = None, + prompt: str = "Select a directory", + change_event: Optional[Callable] = None): + super().__init__(value=value, prompt=prompt, change_event=change_event) def prompt_path(self): dialog = FileDialog() directory = '' if preferences.last_directory: directory = str(Path(preferences.last_directory).parent) - name = dialog.ask_directory(directory, - prompt="Select a parent data directory") + name = dialog.ask_directory(directory, prompt=self.prompt) if name and Path(name).is_dir(): preferences.last_directory = name return name - def separator(self) -> QWidget: - line = QLabel() - line.setFixedHeight(0) - return line + +class ParameterFileInput(ChooseFileInput): + """Prompts for a parameters file.""" + + def __init__(self, **kwargs): + kwargs['file_selector'] = "*.json" + kwargs['prompt'] = "Select a parameters file" + super().__init__(**kwargs) + self.parameters: Optional[Parameters] = None def change(self): + """Connected to a change in the user input parameters.""" + value = self.value() + if value is not None and Path(value).is_file(): + self.parameters = Parameters(value, cast_values=True) + else: + self.parameters = None + if self.change_event: + self.change_event() + + +class DirectoryFilters(QWidget): + """Fields that filter the selected directories""" + + def __init__(self, change_event: Optional[Callable], **kwargs): + super().__init__(**kwargs) + self.change_event = change_event + self.layout = QVBoxLayout() + + self.nested_filter_control = QCheckBox("Include nested directories") + self.nested_filter_control.setChecked(True) + self.nested_filter_control.checkStateChanged.connect(self.change) + + # self.name_filter_control_label = QLabel("Name contains") + self.name_filter_control = QLineEdit("") + self.name_filter_control.textChanged.connect(self.change) + self.layout.addWidget(self.nested_filter_control) + + form = QFormLayout() + form.setFormAlignment(Qt.AlignmentFlag.AlignLeft) + form.addRow("Name contains", self.name_filter_control) + + self.layout.addLayout(form) + self.setLayout(self.layout) + + def change(self): + """Called when a filter field is modified.""" if self.change_event: self.change_event() @@ -183,36 +234,31 @@ class DataDirectorySelect(QWidget): Includes an input for a parent directory and optional filters. """ - def __init__(self, parent_directory: Optional[str] = None): + def __init__(self, + parent_directory: Optional[str] = None, + change_event: Optional[Callable] = None): super().__init__() self.layout = QVBoxLayout() + self.change_event = change_event - self.parent_directory_control = ParentDirectoryInput( + self.parent_directory_control = ChooseDirectoryInput( change_event=self.update_directory_tree, - label="Data Parent Directory", - value=parent_directory, - help_tip="Parent directory for data folders", - editable=None) - - self.nested_filter_control = QCheckBox("Include nested directories") - self.nested_filter_control.setChecked(True) - self.nested_filter_control.checkStateChanged.connect( - self.update_directory_tree) - - self.name_filter_control_label = QLabel("Name contains") - self.name_filter_control = QLineEdit("") - self.name_filter_control.textChanged.connect( - self.update_directory_tree) + prompt="Select a parent data directory", + value=parent_directory) + self.directory_filter_control = DirectoryFilters( + change_event=self.update_directory_tree) self.directory_tree = DirectoryTree() - self.layout.addWidget(self.parent_directory_control) + self.parent_directory_control.layout().setContentsMargins(0, 0, 0, 0) + self.directory_filter_control.layout.setContentsMargins(0, 0, 0, 0) + # self.directory_filter_control.setVisible(False) + # self.directory_tree.setVisible(False) - self.layout.addWidget(self.nested_filter_control) - self.layout.addWidget(self.name_filter_control_label) - self.layout.addWidget(self.name_filter_control) self.layout.addWidget( - static_text_control(self, label="Selected Data Directories")) + LabeledWidget("Data Parent Directory", + self.parent_directory_control)) + self.layout.addWidget(self.directory_filter_control) self.layout.addWidget(self.directory_tree) self.setLayout(self.layout) @@ -225,10 +271,15 @@ def parent_directory(self) -> Optional[str]: def match_pattern(self) -> str: """Pattern used to match a directory with fnmatch.""" - if self.name_filter_control.text(): - return f"*{self.name_filter_control.text().strip(' *')}*" + name_filter = self.directory_filter_control.name_filter_control.text() + if name_filter: + return f"*{name_filter.strip(' *')}*" return "*" + def use_nested(self) -> bool: + """Include nested directories""" + return self.directory_filter_control.nested_filter_control.isChecked() + def data_directories(self) -> Optional[List[Path]]: """Compute the data directories of interest.""" parent = self.parent_directory() @@ -237,7 +288,7 @@ def data_directories(self) -> Optional[List[Path]]: pattern = self.match_pattern() subdirs = [] - if self.nested_filter_control.isChecked(): + if self.use_nested(): for cur_path, directories, _files in os.walk(parent, topdown=True): for name in fnmatch.filter(directories, pattern): path = Path(cur_path, name) @@ -253,18 +304,30 @@ def data_directories(self) -> Optional[List[Path]]: def update_directory_tree(self): """Update the directory tree""" - self.directory_tree.update(self.parent_directory(), - self.data_directories()) + # self.directory_filter_control.setVisible(False) + # self.directory_tree.setVisible(False) + if self.parent_directory(): + # self.directory_filter_control.setVisible(True) + # self.directory_tree.setVisible(True) + self.directory_tree.update(self.parent_directory(), + self.data_directories()) + # notify any listeners of the change + if self.change_event: + self.change_event() -class ModelFileInput(FileInput): +class ModelFileInput(ChooseFileInput): """Prompts for a parameters file.""" - def prompt_path(self) -> str: - """Prompts the user with a FileDialog. Returns the selected value or None.""" - name, _ = QFileDialog.getOpenFileName(caption='Select a model', - filter='*.pkl') - return name + def __init__(self, + value: Optional[str] = None, + file_selector: str = "*.pkl", + prompt: str = "Select a model", + change_event: Optional[Callable] = None): + super().__init__(value=value, + file_selector=file_selector, + prompt=prompt, + change_event=change_event) class ModelInputs(QWidget): @@ -273,7 +336,11 @@ class ModelInputs(QWidget): def __init__(self, content_types: Optional[List[str]] = None): super().__init__() - self.layout = QVBoxLayout() + self.layout = QFormLayout() + self.layout.setFormAlignment(Qt.AlignmentFlag.AlignLeft + | Qt.AlignmentFlag.AlignVCenter) + self.layout.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) self.controls = self._create_inputs(content_types or ['EEG']) self._add_controls() self.setLayout(self.layout) @@ -283,20 +350,43 @@ def set_content_types(self, value: List[str]): self.controls = self._create_inputs(value) self._add_controls() - def _create_inputs(self, content_types: List[str]) -> List[QWidget]: + def _create_inputs(self, + content_types: List[str]) -> List[Tuple[str, QWidget]]: """Create a path input for each model based on the configured acq_mode.""" - return [ - ModelFileInput(label=f"{content_type} Model", - value=None, - help_tip=f"Path to the {content_type} model.", - editable=None) for content_type in content_types - ] + return [(f"{content_type} Model", ModelFileInput()) + for content_type in content_types] def _add_controls(self) -> None: """Add each model input to the layout.""" clear_layout(self.layout) - for control in self.controls: - self.layout.addWidget(control) + for (label, control) in self.controls: + self.layout.addRow(label, control) + + +class LabeledWidget(QWidget): + """Renders a widget with a label above it.""" + + def __init__(self, label: str, widget: QWidget, label_size: int = 14): + super().__init__() + vbox = QVBoxLayout() + label = static_text_control(None, label, size=label_size) + label.setMargin(0) + vbox.setSpacing(0) + vbox.setContentsMargins(-1, 0, -1, -1) + vbox.addWidget(label) + vbox.addWidget(widget) + self.setLayout(vbox) + + +def sim_runs_control(value: int = 1) -> QWidget: + """Create a widget for entering simulation runs.""" + # TODO: event + spin_box = QSpinBox() + spin_box.setMinimum(1) + spin_box.setMaximum(1000) + spin_box.wheelEvent = lambda event: None # disable scroll wheel + spin_box.setValue(value) + return spin_box class SimConfigForm(QWidget): @@ -313,25 +403,39 @@ def __init__(self, width: int = 400): self.layout = QVBoxLayout() self.setLayout(self.layout) - self.setFixedWidth(width) + # self.setFixedWidth(width) self.parameter_control = ParameterFileInput( - param_change_event=self.update_parameters, - label="Parameters", - value=None, - help_tip="Path to the simulation parameters.json file.", - editable=None) + change_event=self.update_parameters) self.directory_control = DataDirectorySelect() self.model_input_control = ModelInputs() - self.runs_control = IntegerInput(label='Simulation Runs', - value=1, - editable=None) - - self.layout.addWidget(self.parameter_control) + self.runs_control = sim_runs_control() + self.output_control = ChooseDirectoryInput(value=DEFAULT_SAVE_LOCATION) + + form = QFormLayout() + form.setFormAlignment(Qt.AlignmentFlag.AlignLeft + | Qt.AlignmentFlag.AlignVCenter) + form.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + select_file = ChooseFileInput(file_selector="*.json", + prompt="Select a parameters file") + form.addRow("Simulation Runs:", sim_runs_control()) + form.addRow("Output Directory:", self.output_control) + form.addRow("Sim Parameters:", self.parameter_control) + + # self.layout.addWidget( + # static_text_control(None, "Simulation Parameters", size=14)) + # self.layout.addWidget(LabeledWidget("Simulation Parameters", self.parameter_control)) + + self.layout.addLayout(form) self.layout.addWidget(self.model_input_control) self.layout.addWidget(self.directory_control) - self.layout.addWidget(self.runs_control) + # self.layout.addWidget(self.output_control) + # self.layout.addWidget(self.runs_control) + + # self.layout.setSpacing(0) + # self.layout.setContentsMargins(0, 0, 0, 0) self.show() @property @@ -339,10 +443,17 @@ def parameters(self) -> Optional[Parameters]: """Configured parameters""" return self.parameter_control.parameters - def command(self, params: str, models: List[str], - source_dirs: List[str]) -> None: + def command_valid(self) -> bool: + """Returns True if all necessary fields are input.""" + # TODO: + return False + + def command(self) -> str: """Command equivalent to to the result of the interactive selection of simulator inputs.""" + params = '' + models = [] + source_dirs = [] model_args = ' '.join([f"-m {path}" for path in models]) dir_args = ' '.join(f"-d {source}" for source in source_dirs) @@ -374,149 +485,6 @@ def clear_layout(layout): clear_layout(child.layout()) -def do_to_widgets(layout, action): - """Perform some action on all widgets, including those nested in - child layouts.""" - for i in range(layout.count()): - child = layout.itemAt(i) - if child.widget() is not None: - action(child.widget()) - elif child.layout() is not None: - do_to_widgets(child.layout(), action) - - -def show_all(layout): - """Show all items in a layout""" - do_to_widgets(layout, lambda widget: widget.setVisible(True)) - - -def hide_all(layout): - """Hide all items in a layout""" - do_to_widgets(layout, lambda widget: widget.setVisible(False)) - - -# class ChangeItems(QWidget): -# """Line items showing parameter changes.""" - -# def __init__(self, json_file: str): -# super().__init__() -# self.changes = changes_from_default(json_file) -# self.layout = QVBoxLayout() -# self._init_changes() -# self.setLayout(self.layout) - -# def _init_changes(self): -# """Initialize the line items for changes.""" -# if not self.changes: -# self.layout.addWidget( -# static_text_control(None, -# label="None", -# size=12, -# color='darkgray')) - -# for _key, param_change in self.changes.items(): -# param = param_change.parameter -# hbox = QHBoxLayout() - -# lbl = static_text_control( -# None, -# label=f"* {param['name']}: {param['value']}", -# size=13, -# color="darkgreen") - -# original_value = static_text_control( -# None, -# label=f"(default: {param_change.original_value})", -# color='darkgray', -# size=12) -# hbox.addWidget(lbl) -# hbox.addWidget(original_value) -# self.layout.addLayout(hbox) - -# @property -# def is_empty(self) -> bool: -# """Boolean indicating whether there are any changes""" -# return not bool(self.changes) - -# def update_changes(self, json_file: str): -# """Update the changed items""" -# self.clear() -# self.changes = changes_from_default(json_file) -# self._init_changes() - -# def show(self): -# """Show the changes""" -# show_all(self.layout) - -# def hide(self): -# """Hide the changes""" -# hide_all(self.layout) - -# def clear(self): -# """Clear items""" -# clear_layout(self.layout) - -# class ParamsChanges(QWidget): -# """Displays customizations from the default parameters in a scroll area -# with buttons to show / hide the list of items.""" - -# def __init__(self, json_file: str): -# super().__init__() -# self.change_items = ChangeItems(json_file) -# self.collapsed = self.change_items.is_empty - -# self.show_label = '[+]' -# self.hide_label = '[-]' - -# self.layout = QVBoxLayout() - -# self.changes_area = QScrollArea() -# self.changes_area.setVerticalScrollBarPolicy( -# Qt.ScrollBarPolicy.ScrollBarAlwaysOn) -# self.changes_area.setHorizontalScrollBarPolicy( -# Qt.ScrollBarPolicy.ScrollBarAlwaysOff) -# self.changes_area.setWidgetResizable(True) -# self.changes_area.setWidget(self.change_items) -# self.changes_area.setVisible(not self.collapsed) - -# control_box = QHBoxLayout() -# control_box.addWidget( -# static_text_control(None, label='Changed Parameters:')) -# self.toggle_button = QPushButton( -# self.show_label if self.collapsed else self.hide_label) -# self.toggle_button.setFlat(True) -# self.toggle_button.setFixedWidth(40) -# self.toggle_button.clicked.connect(self.toggle) -# control_box.addWidget(self.toggle_button) - -# self.layout.addLayout(control_box) -# self.layout.addWidget(self.changes_area) -# self.setLayout(self.layout) - -# def update_changes(self, json_file: str): -# """Update the changed items""" -# self.change_items.update_changes(json_file) -# self.changes_area.repaint() - -# def toggle(self): -# """Toggle visibility of items""" -# if self.collapsed: -# self.show() -# else: -# self.collapse() -# self.collapsed = not self.collapsed - -# def show(self): -# """Show the changes""" -# self.changes_area.setVisible(True) -# self.toggle_button.setText(self.hide_label) - -# def collapse(self): -# """Hide the changes""" -# self.changes_area.setVisible(False) -# self.toggle_button.setText(self.show_label) - - class MainPanel(QWidget): """Main GUI window. Parameters: @@ -534,38 +502,33 @@ def __init__(self, title: str, size: Tuple[int, int]): self.command = '' self.form = SimConfigForm(width=self.size[0]) - # self.changes = ParamsChanges(self.json_file) self.flash_msg = '' - self.initUI() + self.init_ui() - def initUI(self): + def init_ui(self): """Initialize the UI""" vbox = QVBoxLayout() header_box = QHBoxLayout() - header_box.addSpacing(5) - self.edit_msg = static_text_control(self, - label='Simulation Configuration', - size=14, - color='dimgray') - header_box.addWidget(self.edit_msg) + header_box.addSpacing(4) + header_box.addWidget( + static_text_control(self, + label='BciPy Simulator Configuration', + size=16, + color='dimgray')) vbox.addLayout(header_box) - # self.changes_panel = QHBoxLayout() - # self.changes_panel.addWidget(self.changes) - # vbox.addLayout(self.changes_panel) - self.form_panel = QScrollArea() self.form_panel.setVerticalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAlwaysOn) + Qt.ScrollBarPolicy.ScrollBarAsNeeded) self.form_panel.setHorizontalScrollBarPolicy( Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.form_panel.setWidgetResizable(True) - self.form_panel.setFixedWidth(self.size[0]) + self.form_panel.setMinimumWidth(self.size[0]) self.form_panel.setWidget(self.form) vbox.addWidget(self.form_panel) - vbox.addSpacing(5) + vbox.addSpacing(4) # Controls control_box = QHBoxLayout() @@ -577,11 +540,11 @@ def initUI(self): vbox.addLayout(control_box) - self.save_msg = static_text_control(self, - label='', - size=12, - color='darkgray') - vbox.addWidget(self.save_msg) + self.command_msg = static_text_control(self, + label='', + size=12, + color='darkgray') + vbox.addWidget(self.command_msg) self.setLayout(vbox) self.setFixedHeight(self.size[1]) self.setWindowTitle(self.title) @@ -589,15 +552,13 @@ def initUI(self): def on_run(self): """Event handler for running the simulation.""" - if self.form.save(): - self.save_msg.setText(f'Last saved: {datetime.now()}') - self.save_msg.repaint() - # self.changes.update_changes(self.json_file) - # self.changes.repaint() + if self.form.command_valid(): + self.command_msg.setText(self.form.command()) + self.command_msg.repaint() -def init(title='BCI Simulator', size=(750, 800)) -> str: +def init(title='BCI Simulator', size=(750, 600)) -> str: """Set up the GUI components and start the main loop.""" app = QApplication(sys.argv) panel = MainPanel(title, size) From 9f89065678b3f7f7e9e6a3c92cdcc44b63969496 Mon Sep 17 00:00:00 2001 From: lawhead Date: Mon, 25 Nov 2024 11:15:17 -0800 Subject: [PATCH 06/76] Added parent widgets --- bcipy/simulator/ui/gui.py | 42 ++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/bcipy/simulator/ui/gui.py b/bcipy/simulator/ui/gui.py index 544d6d39e..401b9dc5d 100644 --- a/bcipy/simulator/ui/gui.py +++ b/bcipy/simulator/ui/gui.py @@ -63,9 +63,10 @@ class DirectoryTree(QWidget): """Display a tree of directories""" def __init__(self, + parent: Optional[QWidget] = None, parent_directory: Optional[str] = None, selected_subdirectories: Optional[List[str]] = None): - super().__init__() + super().__init__(parent=parent) self.parent_directory = parent_directory self.paths = selected_subdirectories or [] @@ -110,12 +111,13 @@ class ChooseFileInput(QWidget): """Text field and button which pops open a file selection dialog.""" def __init__(self, + parent: Optional[QWidget] = None, value: Optional[str] = None, file_selector: str = "*", prompt: str = "Select a file", change_event: Optional[Callable] = None): - super().__init__() + super().__init__(parent=parent) self.file_selector = file_selector self.prompt = prompt self.change_event = change_event @@ -163,10 +165,14 @@ class ChooseDirectoryInput(ChooseFileInput): """Text field and button which pops open a directory selection dialog.""" def __init__(self, + parent: Optional[QWidget] = None, value: Optional[str] = None, prompt: str = "Select a directory", change_event: Optional[Callable] = None): - super().__init__(value=value, prompt=prompt, change_event=change_event) + super().__init__(parent=parent, + value=value, + prompt=prompt, + change_event=change_event) def prompt_path(self): dialog = FileDialog() @@ -202,8 +208,11 @@ def change(self): class DirectoryFilters(QWidget): """Fields that filter the selected directories""" - def __init__(self, change_event: Optional[Callable], **kwargs): - super().__init__(**kwargs) + def __init__(self, + parent: Optional[QWidget] = None, + change_event: Optional[Callable] = None, + **kwargs): + super().__init__(parent=parent, **kwargs) self.change_event = change_event self.layout = QVBoxLayout() @@ -235,9 +244,10 @@ class DataDirectorySelect(QWidget): """ def __init__(self, + parent: Optional[QWidget] = None, parent_directory: Optional[str] = None, change_event: Optional[Callable] = None): - super().__init__() + super().__init__(parent=parent) self.layout = QVBoxLayout() self.change_event = change_event @@ -320,11 +330,13 @@ class ModelFileInput(ChooseFileInput): """Prompts for a parameters file.""" def __init__(self, + parent: Optional[QWidget] = None, value: Optional[str] = None, file_selector: str = "*.pkl", prompt: str = "Select a model", change_event: Optional[Callable] = None): - super().__init__(value=value, + super().__init__(parent=parent, + value=value, file_selector=file_selector, prompt=prompt, change_event=change_event) @@ -333,8 +345,10 @@ def __init__(self, class ModelInputs(QWidget): """Provide a model path input for each configured content type.""" - def __init__(self, content_types: Optional[List[str]] = None): - super().__init__() + def __init__(self, + parent: Optional[QWidget] = None, + content_types: Optional[List[str]] = None): + super().__init__(parent=parent) self.layout = QFormLayout() self.layout.setFormAlignment(Qt.AlignmentFlag.AlignLeft @@ -353,7 +367,7 @@ def set_content_types(self, value: List[str]): def _create_inputs(self, content_types: List[str]) -> List[Tuple[str, QWidget]]: """Create a path input for each model based on the configured acq_mode.""" - return [(f"{content_type} Model", ModelFileInput()) + return [(f"{content_type} Model", ModelFileInput(parent=self)) for content_type in content_types] def _add_controls(self) -> None: @@ -366,8 +380,12 @@ def _add_controls(self) -> None: class LabeledWidget(QWidget): """Renders a widget with a label above it.""" - def __init__(self, label: str, widget: QWidget, label_size: int = 14): - super().__init__() + def __init__(self, + label: str, + widget: QWidget, + label_size: int = 14, + parent: Optional[QWidget] = None): + super().__init__(parent=parent) vbox = QVBoxLayout() label = static_text_control(None, label, size=label_size) label.setMargin(0) From 7f2a714c44fe5bfbf7a8a53d63612bab895394b8 Mon Sep 17 00:00:00 2001 From: lawhead Date: Mon, 25 Nov 2024 14:15:18 -0800 Subject: [PATCH 07/76] Fixes to output a command --- bcipy/simulator/ui/gui.py | 204 +++++++++++++++++++++++++------------- 1 file changed, 135 insertions(+), 69 deletions(-) diff --git a/bcipy/simulator/ui/gui.py b/bcipy/simulator/ui/gui.py index 401b9dc5d..5a7f5d791 100644 --- a/bcipy/simulator/ui/gui.py +++ b/bcipy/simulator/ui/gui.py @@ -9,10 +9,10 @@ from typing import Callable, List, Optional, Tuple, Union from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import (QApplication, QCheckBox, QFormLayout, QHBoxLayout, - QLineEdit, QPushButton, QScrollArea, QSpinBox, - QTreeWidget, QTreeWidgetItem, QVBoxLayout, - QWidget) +from PyQt6.QtWidgets import (QApplication, QCheckBox, QFormLayout, QGroupBox, + QHBoxLayout, QLineEdit, QPushButton, QScrollArea, + QSpinBox, QTreeWidget, QTreeWidgetItem, + QVBoxLayout, QWidget) from bcipy.config import DEFAULT_PARAMETERS_PATH from bcipy.gui.file_dialog import FileDialog @@ -248,7 +248,7 @@ def __init__(self, parent_directory: Optional[str] = None, change_event: Optional[Callable] = None): super().__init__(parent=parent) - self.layout = QVBoxLayout() + vbox = QVBoxLayout() self.change_event = change_event self.parent_directory_control = ChooseDirectoryInput( @@ -262,16 +262,15 @@ def __init__(self, self.parent_directory_control.layout().setContentsMargins(0, 0, 0, 0) self.directory_filter_control.layout.setContentsMargins(0, 0, 0, 0) - # self.directory_filter_control.setVisible(False) - # self.directory_tree.setVisible(False) + self.directory_filter_control.setEnabled(False) + self.directory_tree.setEnabled(False) - self.layout.addWidget( + vbox.addWidget( LabeledWidget("Data Parent Directory", self.parent_directory_control)) - self.layout.addWidget(self.directory_filter_control) - self.layout.addWidget(self.directory_tree) - - self.setLayout(self.layout) + vbox.addWidget(self.directory_filter_control) + vbox.addWidget(self.directory_tree) + self.setLayout(vbox) def parent_directory(self) -> Optional[str]: """Parent directory""" @@ -314,11 +313,11 @@ def data_directories(self) -> Optional[List[Path]]: def update_directory_tree(self): """Update the directory tree""" - # self.directory_filter_control.setVisible(False) - # self.directory_tree.setVisible(False) + self.directory_filter_control.setEnabled(False) + self.directory_tree.setEnabled(False) if self.parent_directory(): - # self.directory_filter_control.setVisible(True) - # self.directory_tree.setVisible(True) + self.directory_filter_control.setEnabled(True) + self.directory_tree.setEnabled(True) self.directory_tree.update(self.parent_directory(), self.data_directories()) # notify any listeners of the change @@ -347,28 +346,52 @@ class ModelInputs(QWidget): def __init__(self, parent: Optional[QWidget] = None, - content_types: Optional[List[str]] = None): + content_types: Optional[List[str]] = None, + change_event: Optional[Callable] = None): super().__init__(parent=parent) - + self.change_event = change_event self.layout = QFormLayout() self.layout.setFormAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) self.layout.setFieldGrowthPolicy( QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) - self.controls = self._create_inputs(content_types or ['EEG']) + self.content_types = content_types or ['EEG'] + self.controls = self._create_inputs() self._add_controls() self.setLayout(self.layout) + def value(self) -> List[str]: + """Model paths""" + return [control[1].value() for control in self.controls] + def set_content_types(self, value: List[str]): """Set the content_content types and re-generate inputs.""" - self.controls = self._create_inputs(value) + self.content_types = value + self.controls = self._create_inputs(initialized=True) self._add_controls() def _create_inputs(self, - content_types: List[str]) -> List[Tuple[str, QWidget]]: - """Create a path input for each model based on the configured acq_mode.""" - return [(f"{content_type} Model", ModelFileInput(parent=self)) - for content_type in content_types] + initialized: bool = False) -> List[Tuple[str, QWidget]]: + """Create a path input to a model for each content type""" + return [(self._label(content_type), + ModelFileInput( + parent=self, + change_event=self.change, + value=self._path(content_type) if initialized else None)) + for content_type in self.content_types] + + def _path(self, content_type: str) -> Optional[str]: + """Get the input path for the provided content type""" + matches = [ + control for control in self.controls + if control[0] == self._label(content_type) + ] + if matches: + return matches[0][1].value() + return None + + def _label(self, content_type: str) -> str: + return f"{content_type} Model" def _add_controls(self) -> None: """Add each model input to the layout.""" @@ -376,6 +399,11 @@ def _add_controls(self) -> None: for (label, control) in self.controls: self.layout.addRow(label, control) + def change(self): + """Called when a model path changes""" + if self.change_event: + self.change_event() + class LabeledWidget(QWidget): """Renders a widget with a label above it.""" @@ -398,7 +426,6 @@ def __init__(self, def sim_runs_control(value: int = 1) -> QWidget: """Create a widget for entering simulation runs.""" - # TODO: event spin_box = QSpinBox() spin_box.setMinimum(1) spin_box.setMaximum(1000) @@ -416,78 +443,109 @@ class SimConfigForm(QWidget): width - optional; used to set the width of the form controls. """ - def __init__(self, width: int = 400): + def __init__(self, + width: int = 400, + change_event: Optional[Callable] = None): super().__init__() - self.layout = QVBoxLayout() - self.setLayout(self.layout) - # self.setFixedWidth(width) + vbox = QVBoxLayout() + self.setFixedWidth(width) - self.parameter_control = ParameterFileInput( - change_event=self.update_parameters) + self.change_event = change_event + self.parameters_path = None + self.model_paths = [] + self.data_paths = [] - self.directory_control = DataDirectorySelect() - self.model_input_control = ModelInputs() self.runs_control = sim_runs_control() - self.output_control = ChooseDirectoryInput(value=DEFAULT_SAVE_LOCATION) + self.output_control = ChooseDirectoryInput( + value=DEFAULT_SAVE_LOCATION, change_event=self.update_data_paths) + + self.parameter_control = ParameterFileInput( + change_event=self.update_parameters) + self.directory_control = DataDirectorySelect( + change_event=self.update_data_paths) + self.model_input_control = ModelInputs(change_event=self.update_models) form = QFormLayout() form.setFormAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) form.setFieldGrowthPolicy( QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) - select_file = ChooseFileInput(file_selector="*.json", - prompt="Select a parameters file") - form.addRow("Simulation Runs:", sim_runs_control()) + form.addRow("Simulation Runs:", self.runs_control) form.addRow("Output Directory:", self.output_control) form.addRow("Sim Parameters:", self.parameter_control) - # self.layout.addWidget( - # static_text_control(None, "Simulation Parameters", size=14)) - # self.layout.addWidget(LabeledWidget("Simulation Parameters", self.parameter_control)) - - self.layout.addLayout(form) - self.layout.addWidget(self.model_input_control) - self.layout.addWidget(self.directory_control) - # self.layout.addWidget(self.output_control) - # self.layout.addWidget(self.runs_control) - - # self.layout.setSpacing(0) - # self.layout.setContentsMargins(0, 0, 0, 0) + vbox.addLayout(form) + vbox.addWidget(self.create_model_group()) + vbox.addWidget(self.create_data_group()) + self.setLayout(vbox) self.show() - @property - def parameters(self) -> Optional[Parameters]: - """Configured parameters""" - return self.parameter_control.parameters + def create_data_group(self) -> QWidget: + """Create a group box for data inputs.""" + group_box = QGroupBox("Data") + vbox = QVBoxLayout() + vbox.addWidget(self.directory_control) + group_box.setLayout(vbox) + return group_box + + def create_model_group(self) -> QWidget: + """Create a group box for model inputs""" + group = QGroupBox("Models") + vbox = QVBoxLayout() + vbox.addWidget(self.model_input_control) + group.setLayout(vbox) + return group def command_valid(self) -> bool: """Returns True if all necessary fields are input.""" - # TODO: - return False + return bool(self.parameters_path and self.model_paths + and self.data_paths) def command(self) -> str: """Command equivalent to to the result of the interactive selection of simulator inputs.""" - params = '' - models = [] - source_dirs = [] + if not self.command_valid: + return '' + + outdir = self.output_control.value() + args = [] + if outdir: + args.append(f"-o {self.output_control.value()}") + args.append(f"-p {self.parameters_path}") + args.extend([f"-m {path}" for path in self.model_paths]) + args.extend([f"-d {source}" for source in self.data_paths]) - model_args = ' '.join([f"-m {path}" for path in models]) - dir_args = ' '.join(f"-d {source}" for source in source_dirs) - return f"bcipy-sim -p {params} {model_args} {dir_args}" + runs = self.runs_control.text() + args.append(f"-n {runs}") + + return f"bcipy-sim {' '.join(args)}" def update_parameters(self) -> None: """When simulation parameters are updated include an input for each model.""" - print("Updating parameters") + self.parameters_path = self.parameter_control.value() params = self.parameter_control.parameters if params: content_types = active_content_types(params.get('acq_mode', 'EEG')) else: content_types = ['EEG'] - print("content types:") - print(content_types) self.model_input_control.set_content_types(content_types) + self.change() + + def update_models(self) -> None: + """Update the model paths from the input controls""" + self.model_paths = self.model_input_control.value() + self.change() + + def update_data_paths(self) -> None: + """Update the data paths from the directory inputs""" + self.data_paths = self.directory_control.data_directories() + self.change() + + def change(self): + """Announce change to any registered change events.""" + if self.change_event: + self.change_event() def clear_layout(layout): @@ -519,7 +577,8 @@ def __init__(self, title: str, size: Tuple[int, int]): self.command = '' - self.form = SimConfigForm(width=self.size[0]) + self.form = SimConfigForm(width=self.size[0], + change_event=self.on_params_change) self.flash_msg = '' self.init_ui() @@ -551,10 +610,11 @@ def init_ui(self): # Controls control_box = QHBoxLayout() control_box.addStretch() - run_button = QPushButton('Run') - run_button.setFixedWidth(80) - run_button.clicked.connect(self.on_run) - control_box.addWidget(run_button) + self.run_button = QPushButton('Run') + self.run_button.setFixedWidth(80) + self.run_button.clicked.connect(self.on_run) + self.run_button.setEnabled(False) + control_box.addWidget(self.run_button) vbox.addLayout(control_box) @@ -575,6 +635,12 @@ def on_run(self): self.command_msg.setText(self.form.command()) self.command_msg.repaint() + def on_params_change(self) -> None: + if self.form.command_valid(): + self.run_button.setEnabled(True) + else: + self.run_button.setEnabled(False) + def init(title='BCI Simulator', size=(750, 600)) -> str: """Set up the GUI components and start the main loop.""" From 83931378cdfb4a99eaeefbfe5678990fec38be12 Mon Sep 17 00:00:00 2001 From: lawhead Date: Mon, 25 Nov 2024 16:28:04 -0800 Subject: [PATCH 08/76] Sim GUI cleanup; added a bcipy command; type fixes and linting --- bcipy/simulator/task/task_runner.py | 42 +++++++++------- bcipy/simulator/ui/cli.py | 14 +++--- bcipy/simulator/ui/gui.py | 76 ++++++++++++++++------------- setup.py | 1 + 4 files changed, 75 insertions(+), 58 deletions(-) diff --git a/bcipy/simulator/task/task_runner.py b/bcipy/simulator/task/task_runner.py index 3c2bbf771..7aa4f88dd 100644 --- a/bcipy/simulator/task/task_runner.py +++ b/bcipy/simulator/task/task_runner.py @@ -50,26 +50,31 @@ def main(): glob_help = ('glob pattern to select a subset of data folders' ' Ex. "*RSVP_Copy_Phrase*"' ' Used with a single data_folder') + data_help = ( + 'Raw data folders to be processed.' + ' Multiple values can be provided, or a single parent folder.') parser = argparse.ArgumentParser() - parser.add_argument("-i", - "--interactive", - required=False, - default=False, - action='store_true', - help="Use interactive mode for selecting simulator inputs") + parser.add_argument( + "-i", + "--interactive", + required=False, + default=False, + action='store_true', + help="Use interactive mode for selecting simulator inputs") parser.add_argument("-d", "--data_folder", type=Path, required=False, action='append', - help="Raw data folders to be processed. Multiple values can be provided, or a single parent folder.") + help=data_help) parser.add_argument('-g', '--glob_pattern', help=glob_help, default="*") - parser.add_argument("-m", - "--model_path", - type=Path, - action='append', - required=False, - help="Signal models to be used. Multiple models can be provided.") + parser.add_argument( + "-m", + "--model_path", + type=Path, + action='append', + required=False, + help="Signal models to be used. Multiple models can be provided.") parser.add_argument("-p", "--parameters", type=Path, @@ -99,14 +104,15 @@ def main(): if len(source_dirs) == 1: parent = source_dirs[0] source_dirs = [ - Path(d) for d in glob(str(Path(parent, sim_args['glob_pattern']))) + Path(d) + for d in glob(str(Path(parent, sim_args['glob_pattern']))) if Path(d).is_dir() ] task_factory = TaskFactory(params_path=sim_args['parameters'], - source_dirs=source_dirs, - signal_model_paths=sim_args['model_path'], - sampling_strategy=TargetNontargetSampler, - task=SimulatorCopyPhraseTask) + source_dirs=source_dirs, + signal_model_paths=sim_args['model_path'], + sampling_strategy=TargetNontargetSampler, + task=SimulatorCopyPhraseTask) runner = TaskRunner(save_dir=sim_dir, task_factory=task_factory, diff --git a/bcipy/simulator/ui/cli.py b/bcipy/simulator/ui/cli.py index 83c309cac..18ac335ff 100644 --- a/bcipy/simulator/ui/cli.py +++ b/bcipy/simulator/ui/cli.py @@ -190,7 +190,7 @@ def select_data(parent_folder: Optional[str] = None) -> List[Path]: return paths -def choose_sampling_strategy() -> Sampler: +def choose_sampling_strategy() -> type[Sampler]: """Choose a sampling strategy""" classes = [InquirySampler, TargetNontargetSampler] options = {klass.__name__: klass for klass in classes} @@ -200,7 +200,7 @@ def choose_sampling_strategy() -> Sampler: return options[selected] -def choose_task() -> RSVPCopyPhraseTask: +def choose_task() -> type[RSVPCopyPhraseTask]: """Choose a task to simulate""" classes = [SimulatorCopyPhraseTask] options = {klass.__name__: klass for klass in classes} @@ -210,7 +210,7 @@ def choose_task() -> RSVPCopyPhraseTask: return options.get(selected, SimulatorCopyPhraseTask) -def select_models(acq_mode: str) -> List[str]: +def select_models(acq_mode: str) -> List[Path]: """Select the signal models to use in simulation.""" signal_models = choose_model_paths(active_content_types(acq_mode)) return signal_models @@ -228,7 +228,7 @@ def get_acq_mode(params_path: str): return params['acq_mode'].get('value', 'EEG') -def command(params: str, models: List[str], source_dirs: List[str]) -> None: +def command(params: str, models: List[str], source_dirs: List[str]) -> str: """Command equivalent to to the result of the interactive selection of simulator inputs.""" model_args = ' '.join([f"-m {path}" for path in models]) @@ -246,9 +246,11 @@ def main(args: Dict[str, Any]) -> TaskFactory: if not model_paths: model_paths = select_models(get_acq_mode(params)) - source_dirs = select_data(args.get('data_folder', None)) + source_dirs = [ + str(path) for path in select_data(args.get('data_folder', None)) + ] - logger.info(command(params, model_paths, source_dirs)) + print(command(params, model_paths, source_dirs)) return TaskFactory(params_path=params, source_dirs=source_dirs, signal_model_paths=model_paths, diff --git a/bcipy/simulator/ui/gui.py b/bcipy/simulator/ui/gui.py index 5a7f5d791..eb0f32dde 100644 --- a/bcipy/simulator/ui/gui.py +++ b/bcipy/simulator/ui/gui.py @@ -4,17 +4,18 @@ import argparse import fnmatch import os +import subprocess import sys from pathlib import Path from typing import Callable, List, Optional, Tuple, Union from PyQt6.QtCore import Qt +from PyQt6.QtGui import QFont from PyQt6.QtWidgets import (QApplication, QCheckBox, QFormLayout, QGroupBox, - QHBoxLayout, QLineEdit, QPushButton, QScrollArea, + QHBoxLayout, QLabel, QLineEdit, QPushButton, QSpinBox, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget) -from bcipy.config import DEFAULT_PARAMETERS_PATH from bcipy.gui.file_dialog import FileDialog from bcipy.gui.main import static_text_control from bcipy.helpers.acquisition import active_content_types @@ -70,10 +71,11 @@ def __init__(self, self.parent_directory = parent_directory self.paths = selected_subdirectories or [] - self.label = "Selected Data Dictionaries" + self.label = "Selected Directories" self.tree = self.make_tree() self.layout = QVBoxLayout() - self.layout.addWidget(LabeledWidget(self.label, self.tree)) + self.layout.addWidget( + LabeledWidget(self.label, self.tree, label_size=12)) self.layout.addWidget(self.tree) self.setLayout(self.layout) @@ -86,7 +88,8 @@ def update(self, parent_directory: str, clear_layout(self.layout) if self.parent_directory: self.tree = self.make_tree() - self.layout.addWidget(LabeledWidget(self.label, self.tree)) + self.layout.addWidget( + LabeledWidget(self.label, self.tree, label_size=12)) def make_tree(self) -> QTreeWidget: """Initialize the tree widget""" @@ -265,9 +268,13 @@ def __init__(self, self.directory_filter_control.setEnabled(False) self.directory_tree.setEnabled(False) - vbox.addWidget( - LabeledWidget("Data Parent Directory", - self.parent_directory_control)) + form = QFormLayout() + form.setFormAlignment(Qt.AlignmentFlag.AlignLeft + | Qt.AlignmentFlag.AlignVCenter) + form.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + form.addRow("Data Parent Directory", self.parent_directory_control) + vbox.addLayout(form) vbox.addWidget(self.directory_filter_control) vbox.addWidget(self.directory_tree) self.setLayout(vbox) @@ -415,11 +422,13 @@ def __init__(self, parent: Optional[QWidget] = None): super().__init__(parent=parent) vbox = QVBoxLayout() - label = static_text_control(None, label, size=label_size) - label.setMargin(0) - vbox.setSpacing(0) - vbox.setContentsMargins(-1, 0, -1, -1) - vbox.addWidget(label) + lbl = QLabel() + lbl.setText(label) + if label_size: + lbl.setFont(QFont(None, label_size)) + + vbox.setContentsMargins(0, 0, 0, 0) + vbox.addWidget(lbl) vbox.addWidget(widget) self.setLayout(vbox) @@ -476,7 +485,9 @@ def __init__(self, form.addRow("Sim Parameters:", self.parameter_control) vbox.addLayout(form) + vbox.addSpacing(2) vbox.addWidget(self.create_model_group()) + vbox.addSpacing(2) vbox.addWidget(self.create_data_group()) self.setLayout(vbox) self.show() @@ -594,17 +605,7 @@ def init_ui(self): size=16, color='dimgray')) vbox.addLayout(header_box) - - self.form_panel = QScrollArea() - self.form_panel.setVerticalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAsNeeded) - self.form_panel.setHorizontalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self.form_panel.setWidgetResizable(True) - self.form_panel.setMinimumWidth(self.size[0]) - self.form_panel.setWidget(self.form) - - vbox.addWidget(self.form_panel) + vbox.addWidget(self.form) vbox.addSpacing(4) # Controls @@ -628,14 +629,22 @@ def init_ui(self): self.setWindowTitle(self.title) self.show() + def cli_output(self) -> str: + """Returns the command with arguments.""" + if self.form.command_valid(): + return self.form.command() + return '' + def on_run(self): """Event handler for running the simulation.""" if self.form.command_valid(): - self.command_msg.setText(self.form.command()) + self.command_msg.setText(self.cli_output()) self.command_msg.repaint() + self.close() def on_params_change(self) -> None: + """Event performed when form parameters change.""" if self.form.command_valid(): self.run_button.setEnabled(True) else: @@ -647,8 +656,11 @@ def init(title='BCI Simulator', size=(750, 600)) -> str: app = QApplication(sys.argv) panel = MainPanel(title, size) app.exec() - command = panel.command + command = panel.cli_output() app.quit() + if command: + print(command, file=sys.stdout) + subprocess.run(command, shell=True) return command @@ -656,15 +668,11 @@ def main(): """Process command line arguments and initialize the GUI.""" parser = argparse.ArgumentParser() - # Command line utility for adding arguments/ paths via command line - parser.add_argument('-p', - '--parameters', - default=DEFAULT_PARAMETERS_PATH, - help='Path to parameters.json configuration file.') + parser.add_argument('--width', default=600, type=int) + parser.add_argument('--height', default=750, type=int) + args = parser.parse_args() - # Note that this write to stdout is important for the interaction with - # the BCInterface main GUI. - print(init(args.parameters), file=sys.stdout) + init(size=(args.width, args.height)) if __name__ == '__main__': diff --git a/setup.py b/setup.py index 33cddd581..59188eb9a 100644 --- a/setup.py +++ b/setup.py @@ -110,6 +110,7 @@ def run(self): 'bcipy = bcipy.main:bcipy_main', 'bcipy-erp-viz = bcipy.helpers.visualization:erp', 'bcipy-sim = bcipy.simulator:main', + 'bcipy-sim-gui = bcipy.simulator.ui.gui:main', 'bcipy-train = bcipy.signal.model.offline_analysis:main', 'bcipy-params = bcipy.gui.parameters.params_form:main' ], From f2eec3b998a50c1d5fe81ef050b7ed7b96912e43 Mon Sep 17 00:00:00 2001 From: lawhead Date: Mon, 25 Nov 2024 17:36:04 -0800 Subject: [PATCH 09/76] Fix types for Python 3.8 --- bcipy/simulator/ui/cli.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bcipy/simulator/ui/cli.py b/bcipy/simulator/ui/cli.py index 18ac335ff..fce1d77c9 100644 --- a/bcipy/simulator/ui/cli.py +++ b/bcipy/simulator/ui/cli.py @@ -4,7 +4,7 @@ import logging from glob import glob from pathlib import Path -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional, Type from rich import print as rprint from rich.filesize import decimal @@ -190,7 +190,7 @@ def select_data(parent_folder: Optional[str] = None) -> List[Path]: return paths -def choose_sampling_strategy() -> type[Sampler]: +def choose_sampling_strategy() -> Type[Sampler]: """Choose a sampling strategy""" classes = [InquirySampler, TargetNontargetSampler] options = {klass.__name__: klass for klass in classes} @@ -200,7 +200,7 @@ def choose_sampling_strategy() -> type[Sampler]: return options[selected] -def choose_task() -> type[RSVPCopyPhraseTask]: +def choose_task() -> Type[RSVPCopyPhraseTask]: """Choose a task to simulate""" classes = [SimulatorCopyPhraseTask] options = {klass.__name__: klass for klass in classes} From 49f9bbb0ed6d7a7bf29b17e3e8c4ff3886f22075 Mon Sep 17 00:00:00 2001 From: lawhead Date: Tue, 26 Nov 2024 13:55:32 -0800 Subject: [PATCH 10/76] Bug fix in directory filter --- bcipy/simulator/ui/gui.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/bcipy/simulator/ui/gui.py b/bcipy/simulator/ui/gui.py index eb0f32dde..25c885e51 100644 --- a/bcipy/simulator/ui/gui.py +++ b/bcipy/simulator/ui/gui.py @@ -253,7 +253,7 @@ def __init__(self, super().__init__(parent=parent) vbox = QVBoxLayout() self.change_event = change_event - + self.filters_enabled = False self.parent_directory_control = ChooseDirectoryInput( change_event=self.update_directory_tree, prompt="Select a parent data directory", @@ -265,8 +265,8 @@ def __init__(self, self.parent_directory_control.layout().setContentsMargins(0, 0, 0, 0) self.directory_filter_control.layout.setContentsMargins(0, 0, 0, 0) - self.directory_filter_control.setEnabled(False) - self.directory_tree.setEnabled(False) + self.directory_filter_control.setEnabled(self.filters_enabled) + self.directory_tree.setEnabled(self.filters_enabled) form = QFormLayout() form.setFormAlignment(Qt.AlignmentFlag.AlignLeft @@ -320,13 +320,18 @@ def data_directories(self) -> Optional[List[Path]]: def update_directory_tree(self): """Update the directory tree""" - self.directory_filter_control.setEnabled(False) - self.directory_tree.setEnabled(False) + if self.parent_directory(): - self.directory_filter_control.setEnabled(True) - self.directory_tree.setEnabled(True) + if not self.filters_enabled: + self.filters_enabled = True + self.directory_filter_control.setEnabled(True) + self.directory_tree.setEnabled(True) self.directory_tree.update(self.parent_directory(), self.data_directories()) + else: + self.filters_enabled = False + self.directory_filter_control.setEnabled(False) + self.directory_tree.setEnabled(False) # notify any listeners of the change if self.change_event: self.change_event() From a8e7fb8f7c074a0ad80362385aee8e4eca644c99 Mon Sep 17 00:00:00 2001 From: lawhead Date: Mon, 2 Dec 2024 15:49:52 -0800 Subject: [PATCH 11/76] Fix bug in data engine --- bcipy/simulator/data/data_engine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bcipy/simulator/data/data_engine.py b/bcipy/simulator/data/data_engine.py index fe591db85..3b4fc3160 100644 --- a/bcipy/simulator/data/data_engine.py +++ b/bcipy/simulator/data/data_engine.py @@ -183,7 +183,7 @@ def query(self, for filt in filters), "Filters must all be valid" assert samples >= 1, "Insufficient number of samples requested" - expr = 'and '.join([self.query_condition(filt) for filt in filters]) + expr = ' and '.join([self.query_condition(filt) for filt in filters]) filtered_data = self._trials_df.query(expr) if filtered_data is None or len(filtered_data) < samples: raise TaskConfigurationException( From bc651c092d3414269ce62aab627f0e2f1c32d6a5 Mon Sep 17 00:00:00 2001 From: lawhead Date: Mon, 2 Dec 2024 15:50:10 -0800 Subject: [PATCH 12/76] Fix linting issue --- bcipy/simulator/data/sampler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bcipy/simulator/data/sampler.py b/bcipy/simulator/data/sampler.py index 5424c95e2..20b8701d1 100644 --- a/bcipy/simulator/data/sampler.py +++ b/bcipy/simulator/data/sampler.py @@ -196,6 +196,7 @@ def sample(self, state: SimState) -> List[Trial]: inquiry_target_position = inquiry_df.index.tolist().index( inquiry_target_index) + new_target_position = target_position if inquiry_target_position == target_position: # target already in the correct location, no need to adjust new_target_position = inquiry_target_position + 0.0 From 3990c4bdbc337aede0ccdddce29dd0e6cc3d98d3 Mon Sep 17 00:00:00 2001 From: lawhead Date: Mon, 2 Dec 2024 15:51:03 -0800 Subject: [PATCH 13/76] Added sim parameter for sampler --- bcipy/simulator/task/task_runner.py | 30 ++++++++++++++++------------- bcipy/simulator/ui/cli.py | 13 ++++++++----- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/bcipy/simulator/task/task_runner.py b/bcipy/simulator/task/task_runner.py index 7aa4f88dd..3bc5e9c13 100644 --- a/bcipy/simulator/task/task_runner.py +++ b/bcipy/simulator/task/task_runner.py @@ -2,10 +2,12 @@ import argparse import logging -from glob import glob +import sys from pathlib import Path -from bcipy.simulator.data.sampler import TargetNontargetSampler +# pylint: disable=unused-import +# flake8: noqa +from bcipy.simulator.data.sampler import Sampler, TargetNontargetSampler from bcipy.simulator.task.copy_phrase import SimulatorCopyPhraseTask from bcipy.simulator.task.task_factory import TaskFactory from bcipy.simulator.ui import cli @@ -17,6 +19,11 @@ logger = logging.getLogger(TOP_LEVEL_LOGGER_NAME) +def classify(classname): + """Convert the given class name to a class.""" + return getattr(sys.modules[__name__], classname) + + class TaskRunner(): """Responsible for executing a task a given number of times.""" @@ -67,7 +74,6 @@ def main(): required=False, action='append', help=data_help) - parser.add_argument('-g', '--glob_pattern', help=glob_help, default="*") parser.add_argument( "-m", "--model_path", @@ -85,6 +91,12 @@ def main(): required=False, default=1, help="Number of times to run the simulation") + parser.add_argument("-s", + "--sampler", + type=str, + required=False, + default='TargetNontargetSampler', + help="Sampling strategy") parser.add_argument("-o", "--output", type=Path, @@ -100,18 +112,10 @@ def main(): if args.interactive: task_factory = cli.main(sim_args) else: - source_dirs = sim_args['data_folder'] - if len(source_dirs) == 1: - parent = source_dirs[0] - source_dirs = [ - Path(d) - for d in glob(str(Path(parent, sim_args['glob_pattern']))) - if Path(d).is_dir() - ] task_factory = TaskFactory(params_path=sim_args['parameters'], - source_dirs=source_dirs, + source_dirs=sim_args['data_folder'], signal_model_paths=sim_args['model_path'], - sampling_strategy=TargetNontargetSampler, + sampling_strategy=classify(sim_args['sampler']), task=SimulatorCopyPhraseTask) runner = TaskRunner(save_dir=sim_dir, diff --git a/bcipy/simulator/ui/cli.py b/bcipy/simulator/ui/cli.py index fce1d77c9..8784482d8 100644 --- a/bcipy/simulator/ui/cli.py +++ b/bcipy/simulator/ui/cli.py @@ -196,7 +196,7 @@ def choose_sampling_strategy() -> Type[Sampler]: options = {klass.__name__: klass for klass in classes} selected = Prompt.ask("Choose a sampling strategy", choices=options.keys(), - default='TargetNonTargetSampler') + default='TargetNontargetSampler') return options[selected] @@ -228,12 +228,13 @@ def get_acq_mode(params_path: str): return params['acq_mode'].get('value', 'EEG') -def command(params: str, models: List[str], source_dirs: List[str]) -> str: +def command(params: str, models: List[str], source_dirs: List[str], sampler: Type[Sampler]) -> str: """Command equivalent to to the result of the interactive selection of simulator inputs.""" model_args = ' '.join([f"-m {path}" for path in models]) dir_args = ' '.join(f"-d {source}" for source in source_dirs) - return f"bcipy-sim -p {params} {model_args} {dir_args}" + sampler_args = f"-s {sampler.__name__}" + return f"bcipy-sim -p {params} {model_args} {sampler_args} {dir_args}" def main(args: Dict[str, Any]) -> TaskFactory: @@ -250,11 +251,13 @@ def main(args: Dict[str, Any]) -> TaskFactory: str(path) for path in select_data(args.get('data_folder', None)) ] - print(command(params, model_paths, source_dirs)) + sampler = choose_sampling_strategy() + + print(command(params, model_paths, source_dirs, sampler)) return TaskFactory(params_path=params, source_dirs=source_dirs, signal_model_paths=model_paths, - sampling_strategy=TargetNontargetSampler, + sampling_strategy=sampler, task=SimulatorCopyPhraseTask) From 931123bbdf8b6cceb5caaeac8dab26f019a8b230 Mon Sep 17 00:00:00 2001 From: lawhead Date: Mon, 2 Dec 2024 16:13:58 -0800 Subject: [PATCH 14/76] Fix type issue --- bcipy/simulator/data/sampler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bcipy/simulator/data/sampler.py b/bcipy/simulator/data/sampler.py index 20b8701d1..5df4bdc9d 100644 --- a/bcipy/simulator/data/sampler.py +++ b/bcipy/simulator/data/sampler.py @@ -196,7 +196,7 @@ def sample(self, state: SimState) -> List[Trial]: inquiry_target_position = inquiry_df.index.tolist().index( inquiry_target_index) - new_target_position = target_position + new_target_position = float(target_position) if inquiry_target_position == target_position: # target already in the correct location, no need to adjust new_target_position = inquiry_target_position + 0.0 From d751f480e2c94e9639584020866f0e887e2c9723 Mon Sep 17 00:00:00 2001 From: lawhead Date: Mon, 2 Dec 2024 17:12:21 -0800 Subject: [PATCH 15/76] Refactored to remove bcipy-sim-gui setup script --- bcipy/simulator/task/task_runner.py | 30 ++++++++++++----- bcipy/simulator/ui/gui.py | 52 +++++++++++++++++++++++++---- setup.py | 1 - 3 files changed, 66 insertions(+), 17 deletions(-) diff --git a/bcipy/simulator/task/task_runner.py b/bcipy/simulator/task/task_runner.py index 3bc5e9c13..50f3ec834 100644 --- a/bcipy/simulator/task/task_runner.py +++ b/bcipy/simulator/task/task_runner.py @@ -10,7 +10,7 @@ from bcipy.simulator.data.sampler import Sampler, TargetNontargetSampler from bcipy.simulator.task.copy_phrase import SimulatorCopyPhraseTask from bcipy.simulator.task.task_factory import TaskFactory -from bcipy.simulator.ui import cli +from bcipy.simulator.ui import cli, gui from bcipy.simulator.util.artifact import (DEFAULT_SAVE_LOCATION, TOP_LEVEL_LOGGER_NAME, configure_run_directory, @@ -67,7 +67,13 @@ def main(): required=False, default=False, action='store_true', - help="Use interactive mode for selecting simulator inputs") + help="Use interactive command line for selecting simulator inputs") + parser.add_argument( + "--gui", + required=False, + default=False, + action='store_true', + help="Use interactive GUI for selecting simulator inputs") parser.add_argument("-d", "--data_folder", type=Path, @@ -106,10 +112,12 @@ def main(): args = parser.parse_args() sim_args = vars(args) - sim_dir = init_simulation_dir(save_location=sim_args['output']) - logger.info(sim_dir) + runs = sim_args['n'] + outdir = sim_args['output'] - if args.interactive: + if args.gui: + runs, outdir, task_factory = gui.configure() + elif args.interactive: task_factory = cli.main(sim_args) else: task_factory = TaskFactory(params_path=sim_args['parameters'], @@ -118,10 +126,14 @@ def main(): sampling_strategy=classify(sim_args['sampler']), task=SimulatorCopyPhraseTask) - runner = TaskRunner(save_dir=sim_dir, - task_factory=task_factory, - runs=sim_args['n']) - runner.run() + if task_factory: + sim_dir = init_simulation_dir(save_location=outdir) + logger.info(sim_dir) + + runner = TaskRunner(save_dir=sim_dir, + task_factory=task_factory, + runs=runs) + runner.run() if __name__ == '__main__': diff --git a/bcipy/simulator/ui/gui.py b/bcipy/simulator/ui/gui.py index 25c885e51..36cbe3c90 100644 --- a/bcipy/simulator/ui/gui.py +++ b/bcipy/simulator/ui/gui.py @@ -21,6 +21,9 @@ from bcipy.helpers.acquisition import active_content_types from bcipy.helpers.parameters import Parameters from bcipy.preferences import preferences +from bcipy.simulator.data.sampler import TargetNontargetSampler +from bcipy.simulator.task.copy_phrase import SimulatorCopyPhraseTask +from bcipy.simulator.task.task_factory import TaskFactory from bcipy.simulator.ui.cli import excluded from bcipy.simulator.util.artifact import DEFAULT_SAVE_LOCATION @@ -513,6 +516,14 @@ def create_model_group(self) -> QWidget: group.setLayout(vbox) return group + def runs(self) -> int: + """Number of runs""" + return int(self.runs_control.text()) + + def outdir(self) -> str: + """Output directory""" + return self.output_control.value() + def command_valid(self) -> bool: """Returns True if all necessary fields are input.""" return bool(self.parameters_path and self.model_paths @@ -524,16 +535,14 @@ def command(self) -> str: if not self.command_valid: return '' - outdir = self.output_control.value() args = [] - if outdir: - args.append(f"-o {self.output_control.value()}") + if self.outdir(): + args.append(f"-o {self.outdir()}") args.append(f"-p {self.parameters_path}") args.extend([f"-m {path}" for path in self.model_paths]) args.extend([f"-d {source}" for source in self.data_paths]) - runs = self.runs_control.text() - args.append(f"-n {runs}") + args.append(f"-n {self.runs()}") return f"bcipy-sim {' '.join(args)}" @@ -669,7 +678,36 @@ def init(title='BCI Simulator', size=(750, 600)) -> str: return command -def main(): +def configure( + title='BCI Simulator', + size=(600, 750) +) -> Tuple[int, str, Optional[TaskFactory]]: + """Main function""" + app = QApplication(sys.argv) + panel = MainPanel(title, size) + app.exec() + command = panel.cli_output() + + runs = panel.form.runs() + outdir = panel.form.outdir() + params = panel.form.parameters_path + data_paths = panel.form.data_paths + model_paths = panel.form.model_paths + + app.quit() + + factory = None + if command: + print(command, file=sys.stdout) + factory = TaskFactory(params_path=params, + source_dirs=data_paths, + signal_model_paths=model_paths, + sampling_strategy=TargetNontargetSampler, + task=SimulatorCopyPhraseTask) + return (runs, outdir, factory) + + +def run(): """Process command line arguments and initialize the GUI.""" parser = argparse.ArgumentParser() @@ -681,4 +719,4 @@ def main(): if __name__ == '__main__': - main() + run() diff --git a/setup.py b/setup.py index 59188eb9a..33cddd581 100644 --- a/setup.py +++ b/setup.py @@ -110,7 +110,6 @@ def run(self): 'bcipy = bcipy.main:bcipy_main', 'bcipy-erp-viz = bcipy.helpers.visualization:erp', 'bcipy-sim = bcipy.simulator:main', - 'bcipy-sim-gui = bcipy.simulator.ui.gui:main', 'bcipy-train = bcipy.signal.model.offline_analysis:main', 'bcipy-params = bcipy.gui.parameters.params_form:main' ], From 1d08e1de51cbf38f199663b697ea9a20dcf3e5da Mon Sep 17 00:00:00 2001 From: lawhead Date: Mon, 2 Dec 2024 17:14:54 -0800 Subject: [PATCH 16/76] Updated type --- bcipy/gui/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bcipy/gui/main.py b/bcipy/gui/main.py index a67a3c4f4..aecdee22f 100644 --- a/bcipy/gui/main.py +++ b/bcipy/gui/main.py @@ -273,7 +273,7 @@ def init_control(self, value) -> QWidget: # Default is a text input return QLineEdit(value) - def init_editable(self, value: bool) -> Optional[QWidget]: + def init_editable(self, value: Optional[bool]) -> Optional[QWidget]: "Override. Another checkbox is needed for editable" if value is None: return None From 88b9245448268e1de44bded42ce34f2f8109fbcc Mon Sep 17 00:00:00 2001 From: tab-cmd Date: Mon, 25 Nov 2024 13:44:45 -0800 Subject: [PATCH 17/76] BIDS export and refactor modules (#362) --- .gitignore | 8 +- CHANGELOG.md | 18 + README.md | 14 +- .../acquisition/demo/demo_eeg_with_switch.py | 2 +- bcipy/acquisition/multimodal.py | 2 +- .../acquisition/protocols/lsl/lsl_recorder.py | 5 +- .../tests/protocols/lsl/test_lsl_recorder.py | 2 +- bcipy/core/README.md | 167 +++++ bcipy/core/__init__.py | 0 bcipy/{helpers => core}/demo/demo_report.py | 6 +- .../demo/demo_session_tools.py | 4 +- .../demo/demo_stimuli_generation.py | 2 +- bcipy/{helpers => core}/list.py | 0 bcipy/{helpers => core}/parameters.py | 0 bcipy/{helpers => core}/raw_data.py | 0 bcipy/{helpers => core}/report.py | 0 bcipy/{helpers => core}/session.py | 4 +- bcipy/{helpers => core}/stimuli.py | 4 +- bcipy/{helpers => core}/symbols.py | 0 .../resources/mock_session/parameters.json | 0 .../tests/resources/mock_session/session.csv | 0 .../tests/resources/mock_session/session.json | 0 bcipy/{helpers => core}/tests/test_list.py | 2 +- .../tests/test_parameters.py | 2 +- .../{helpers => core}/tests/test_raw_data.py | 4 +- bcipy/{helpers => core}/tests/test_report.py | 4 +- bcipy/{helpers => core}/tests/test_session.py | 2 +- bcipy/{helpers => core}/tests/test_stimuli.py | 24 +- bcipy/{helpers => core}/tests/test_symbols.py | 2 +- .../{helpers => core}/tests/test_triggers.py | 38 +- bcipy/{helpers => core}/triggers.py | 4 +- bcipy/display/demo/components/demo_layouts.py | 2 +- .../display/demo/vep/demo_calibration_vep.py | 2 +- bcipy/display/main.py | 2 +- bcipy/display/paradigm/matrix/display.py | 6 +- bcipy/display/paradigm/rsvp/display.py | 8 +- .../display/paradigm/rsvp/mode/calibration.py | 2 +- .../display/paradigm/rsvp/mode/copy_phrase.py | 4 +- bcipy/display/paradigm/vep/display.py | 8 +- bcipy/feedback/demo/demo_sound_feedback.py | 2 +- bcipy/feedback/demo/demo_visual_feedback.py | 2 +- bcipy/feedback/visual/visual_feedback.py | 2 +- bcipy/gui/BCInterface.py | 4 +- bcipy/gui/experiments/ExperimentField.py | 4 +- bcipy/gui/experiments/ExperimentRegistry.py | 4 +- bcipy/gui/experiments/FieldRegistry.py | 4 +- .../demo/demo_experiment_field_collection.py | 2 +- bcipy/gui/main.py | 2 +- bcipy/gui/parameters/params_form.py | 2 +- bcipy/gui/viewer/data_source/file_streamer.py | 2 +- bcipy/gui/viewer/data_viewer.py | 4 +- bcipy/helpers/README.md | 167 +---- bcipy/helpers/acquisition.py | 3 +- bcipy/helpers/convert.py | 593 --------------- bcipy/helpers/copy_phrase_wrapper.py | 4 +- bcipy/helpers/demo/demo_convert.py | 51 -- .../helpers/demo/demo_copy_phrase_wrapper.py | 2 +- bcipy/helpers/demo/demo_sound_card.py | 28 - bcipy/helpers/demo/demo_visualization.py | 6 +- bcipy/helpers/language_model.py | 2 +- bcipy/helpers/load.py | 342 --------- bcipy/helpers/offset.py | 6 +- bcipy/helpers/task.py | 2 +- bcipy/helpers/tests/test_acquisition.py | 3 +- bcipy/helpers/tests/test_convert.py | 683 ------------------ .../helpers/tests/test_copy_phrase_wrapper.py | 2 +- bcipy/helpers/tests/test_offset.py | 6 +- bcipy/helpers/tests/test_system_utils.py | 2 +- bcipy/helpers/tests/test_visualization.py | 4 +- bcipy/helpers/{system_utils.py => utils.py} | 0 bcipy/helpers/validate.py | 4 +- bcipy/helpers/visualization.py | 30 +- bcipy/io/README.md | 7 + bcipy/io/__init__.py | 0 bcipy/io/convert.py | 403 +++++++++++ bcipy/io/demo/demo_convert.py | 77 ++ bcipy/io/load.py | 619 ++++++++++++++++ bcipy/{helpers => io}/save.py | 0 bcipy/io/tests/test_convert.py | 449 ++++++++++++ bcipy/{helpers => io}/tests/test_load.py | 148 +++- bcipy/{helpers => io}/tests/test_save.py | 4 +- bcipy/language/demo/demo_causal.py | 2 +- bcipy/language/demo/demo_kenlm.py | 2 +- bcipy/language/demo/demo_mixture.py | 2 +- bcipy/language/demo/demo_unigram.py | 2 +- bcipy/language/main.py | 2 +- bcipy/language/model/causal.py | 2 +- bcipy/language/model/kenlm.py | 2 +- bcipy/language/model/oracle.py | 2 +- bcipy/language/model/unigram.py | 2 +- bcipy/language/tests/test_causal.py | 2 +- bcipy/language/tests/test_kenlm.py | 2 +- bcipy/language/tests/test_mixture.py | 2 +- bcipy/language/tests/test_unigram.py | 2 +- bcipy/main.py | 2 +- bcipy/signal/evaluate/artifact.py | 10 +- bcipy/signal/model/base_model.py | 2 +- .../gaussian_mixture/gaussian_mixture.py | 2 +- bcipy/signal/model/offline_analysis.py | 17 +- bcipy/signal/model/pca_rda_kde/pca_rda_kde.py | 2 +- bcipy/signal/model/rda_kde/rda_kde.py | 2 +- .../model/pca_rda_kde/test_pca_rda_kde.py | 6 +- .../tests/model/rda_kde/test_rda_kde.py | 2 +- .../tests/model/test_offline_analysis.py | 3 +- bcipy/simulator/data/data_engine.py | 2 +- bcipy/simulator/data/data_process.py | 12 +- bcipy/simulator/task/copy_phrase.py | 4 +- bcipy/simulator/task/task_factory.py | 4 +- bcipy/simulator/util/state.py | 2 +- bcipy/task/actions.py | 10 +- bcipy/task/calibration.py | 18 +- bcipy/task/control/evidence.py | 2 +- bcipy/task/control/handler.py | 4 +- bcipy/task/control/query.py | 2 +- .../demo/actions/demo_calibration_report.py | 2 +- bcipy/task/main.py | 4 +- bcipy/task/orchestrator/orchestrator.py | 6 +- bcipy/task/paradigm/matrix/calibration.py | 6 +- bcipy/task/paradigm/matrix/copy_phrase.py | 2 +- .../paradigm/matrix/timing_verification.py | 4 +- .../paradigm/rsvp/calibration/calibration.py | 2 +- .../rsvp/calibration/timing_verification.py | 6 +- bcipy/task/paradigm/rsvp/copy_phrase.py | 20 +- bcipy/task/paradigm/vep/calibration.py | 4 +- bcipy/task/paradigm/vep/stim_generation.py | 6 +- bcipy/task/tests/core/test_handler.py | 2 +- .../tests/orchestrator/test_orchestrator.py | 2 +- .../matrix/test_matrix_calibration.py | 4 +- .../rsvp/calibration/test_rsvp_calibration.py | 4 +- .../tests/paradigm/rsvp/test_copy_phrase.py | 6 +- .../rsvp/test_copy_phrase_task_summary.py | 2 +- .../tests/paradigm/vep/test_stimuli_vep.py | 2 +- mypy.ini | 2 +- requirements.txt | 6 +- scripts/python/lm_eval.py | 2 +- scripts/python/replay_session.py | 10 +- 136 files changed, 2125 insertions(+), 2148 deletions(-) create mode 100644 bcipy/core/README.md create mode 100644 bcipy/core/__init__.py rename bcipy/{helpers => core}/demo/demo_report.py (95%) rename bcipy/{helpers => core}/demo/demo_session_tools.py (92%) rename bcipy/{helpers => core}/demo/demo_stimuli_generation.py (97%) rename bcipy/{helpers => core}/list.py (100%) rename bcipy/{helpers => core}/parameters.py (100%) rename bcipy/{helpers => core}/raw_data.py (100%) rename bcipy/{helpers => core}/report.py (100%) rename bcipy/{helpers => core}/session.py (98%) rename bcipy/{helpers => core}/stimuli.py (99%) rename bcipy/{helpers => core}/symbols.py (100%) rename bcipy/{helpers => core}/tests/resources/mock_session/parameters.json (100%) rename bcipy/{helpers => core}/tests/resources/mock_session/session.csv (100%) rename bcipy/{helpers => core}/tests/resources/mock_session/session.json (100%) rename bcipy/{helpers => core}/tests/test_list.py (97%) rename bcipy/{helpers => core}/tests/test_parameters.py (99%) rename bcipy/{helpers => core}/tests/test_raw_data.py (99%) rename bcipy/{helpers => core}/tests/test_report.py (96%) rename bcipy/{helpers => core}/tests/test_session.py (95%) rename bcipy/{helpers => core}/tests/test_stimuli.py (98%) rename bcipy/{helpers => core}/tests/test_symbols.py (95%) rename bcipy/{helpers => core}/tests/test_triggers.py (95%) rename bcipy/{helpers => core}/triggers.py (96%) delete mode 100644 bcipy/helpers/convert.py delete mode 100644 bcipy/helpers/demo/demo_convert.py delete mode 100644 bcipy/helpers/demo/demo_sound_card.py delete mode 100644 bcipy/helpers/load.py delete mode 100644 bcipy/helpers/tests/test_convert.py rename bcipy/helpers/{system_utils.py => utils.py} (100%) create mode 100644 bcipy/io/README.md create mode 100644 bcipy/io/__init__.py create mode 100644 bcipy/io/convert.py create mode 100644 bcipy/io/demo/demo_convert.py create mode 100644 bcipy/io/load.py rename bcipy/{helpers => io}/save.py (100%) create mode 100644 bcipy/io/tests/test_convert.py rename bcipy/{helpers => io}/tests/test_load.py (60%) rename bcipy/{helpers => io}/tests/test_save.py (94%) diff --git a/.gitignore b/.gitignore index 1fdadd6e1..20656324d 100644 --- a/.gitignore +++ b/.gitignore @@ -19,14 +19,12 @@ __pycache__/ .cache/ # BciPy files and directories -*.csv *.pdf buffer.db lmwrap.log env.txt .idea bcipy.egg-info/ -data/ build/ dist/ bcipy/parameters/parameters_* @@ -35,6 +33,8 @@ bcipy/language/lms/lm_dec19_char_large_12gram.* bcipy/language/lms/out_* bcipy/language/out/ - bcipy/simulator/tests/resource/ -!bcipy/simulator/data \ No newline at end of file +# Ignore the data directory, which contains the raw data files, but not the data modules +data/ +bids/ +!bcipy/simulator/data diff --git a/CHANGELOG.md b/CHANGELOG.md index b2c2f1c38..fe07fbcb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +# 2.0.0 + +## Contributions + +- BIDS + - Bundling support and refactor of `convert` module. See `demo_convert.py` #362 +- Library Refactor + - Refactor `helpers` into `io` and `core` #362 +- Dependencies + - Upgrade + - `seaborn` #362 + - Add + - `mne-bids` #362 + - `pybv` #362 + - `EDFlib-Python` #362 + - Remove + - `pyedflib` #362 + # 2.0.1-rc.4 Patch on final release candidate diff --git a/README.md b/README.md index 9f7a048ec..f37abfc99 100644 --- a/README.md +++ b/README.md @@ -181,18 +181,20 @@ make bci-gui This a list of the major modules and their functionality. Each module will contain its own README, demo and tests. Please check them out for more information! - `acquisition`: acquires data, gives back desired time series, saves to file at end of session. +- `config`: configuration parameters for the application, including paths and data filenames. +- `core`: core data structures and methods needed for BciPy operation. These include triggers, parameters, and raw data. - `display`: handles display of stimuli on screen and passes back stimuli timing. -- `signal`: eeg signal models, gaze signal models, filters, processing, evaluators and viewers. +- `feedback`: feedback mechanisms for sound and visual stimuli. - `gui`: end-user interface into registered bci tasks and parameter editing. See BCInterface.py. -- `helpers`: helpful functions needed for interactions between modules, basic I/O, and data visualization. +- `helpers`: helpful functions needed for interactions between modules and general utility. +- `io`: load, save, and convert data files. Ex. BrainVision, EDF, MNE, CSV, JSON, etc. - `language`: gives probabilities of next symbols during typing. +- `main`: executor of experiments. Main entry point into the application - `parameters`: location of json parameters. This includes parameters.json (main experiment / app configuration) and device.json (device registry and configuration). +- `signal`: eeg signal models, gaze signal models, filters, processing, evaluators and viewers. +- `simulator`: provides support for running simulations based off of previously collected data. - `static`: image and sound stimuli, misc manuals, and readable texts for gui. - `task`: bcipy implemented user tasks. Main collection of bci modules for use during various experimentation. Ex. RSVP Calibration. -- `feedback`: feedback mechanisms for sound and visual stimuli. -- `main`: executor of experiments. Main entry point into the application -- `config`: configuration parameters for the application, including paths and data filenames. -- `simulator`: provides support for running simulations based off of previously collected data. ## Paradigms diff --git a/bcipy/acquisition/demo/demo_eeg_with_switch.py b/bcipy/acquisition/demo/demo_eeg_with_switch.py index 3ce2d5fb3..08e2b11ba 100644 --- a/bcipy/acquisition/demo/demo_eeg_with_switch.py +++ b/bcipy/acquisition/demo/demo_eeg_with_switch.py @@ -6,7 +6,7 @@ from bcipy.acquisition.datastream.mock.switch import switch_device from bcipy.acquisition.devices import preconfigured_device from bcipy.acquisition import LslAcquisitionClient, LslDataServer, await_start -from bcipy.helpers.system_utils import log_to_stdout +from bcipy.helpers.utils import log_to_stdout def start_switch(): diff --git a/bcipy/acquisition/multimodal.py b/bcipy/acquisition/multimodal.py index f39d0c266..7d0df0fe5 100644 --- a/bcipy/acquisition/multimodal.py +++ b/bcipy/acquisition/multimodal.py @@ -8,7 +8,7 @@ UnsupportedContentType) from bcipy.acquisition.protocols.lsl.lsl_client import LslAcquisitionClient from bcipy.acquisition.record import Record -from bcipy.helpers.system_utils import AutoNumberEnum +from bcipy.helpers.utils import AutoNumberEnum from bcipy.config import SESSION_LOG_FILENAME logger = logging.getLogger(SESSION_LOG_FILENAME) diff --git a/bcipy/acquisition/protocols/lsl/lsl_recorder.py b/bcipy/acquisition/protocols/lsl/lsl_recorder.py index 75ddee170..d80d2bade 100644 --- a/bcipy/acquisition/protocols/lsl/lsl_recorder.py +++ b/bcipy/acquisition/protocols/lsl/lsl_recorder.py @@ -14,7 +14,8 @@ check_device) from bcipy.acquisition.util import StoppableProcess from bcipy.config import SESSION_LOG_FILENAME -from bcipy.helpers.raw_data import RawDataWriter +from bcipy.core.raw_data import RawDataWriter +from bcipy.helpers.utils import log_to_stdout log = logging.getLogger(SESSION_LOG_FILENAME) @@ -239,8 +240,6 @@ def main(path: str, seconds: int = 5, debug: bool = False): """Function to demo the LslRecorder. Expects LSL data streams to be already running.""" if debug: - # pylint: disable=import-outside-toplevel - from bcipy.helpers.system_utils import log_to_stdout log_to_stdout() recorder = LslRecorder(path) print(f"\nCollecting data for {seconds}s...") diff --git a/bcipy/acquisition/tests/protocols/lsl/test_lsl_recorder.py b/bcipy/acquisition/tests/protocols/lsl/test_lsl_recorder.py index e0649e3d2..57bef2483 100644 --- a/bcipy/acquisition/tests/protocols/lsl/test_lsl_recorder.py +++ b/bcipy/acquisition/tests/protocols/lsl/test_lsl_recorder.py @@ -12,7 +12,7 @@ eye_tracker_server from bcipy.acquisition.devices import preconfigured_device from bcipy.acquisition.protocols.lsl.lsl_recorder import LslRecorder -from bcipy.helpers.raw_data import TIMESTAMP_COLUMN, load +from bcipy.core.raw_data import TIMESTAMP_COLUMN, load log = logging.getLogger(__name__) diff --git a/bcipy/core/README.md b/bcipy/core/README.md new file mode 100644 index 000000000..ea6af701c --- /dev/null +++ b/bcipy/core/README.md @@ -0,0 +1,167 @@ +# BciPy Core Module + +Core data strucutres and methods needed for BciPy operation. These include triggers, parameters, and raw data. + +## Contents + +- `list`: utility methods for list processing +- `parameters`: module for functionality related to system configuration via the parameters.json file +- `raw_data`: functionality for reading and writing raw signal data +- `report`: methods for generating BciPy PDF reports +- `session`: methods for managing and parsing session.json data +- `stimuli`: methods for generating stimuli and inquiries for presentation +- `symbols`: methods for working with symbols and symbol lists. Ex. Alphabet, QWERTY, etc. +- `triggers`: methods and data classes defining BciPy internal triggering + + +## Triggers + +Triggers consist of a label, type and a timestamp. These are what is used to align events both in real-time and after a session for further processing or model training. During operation of a BciPy task, Triggers are generated when display or other events of interest occur. These are timestamped using a monotonic clock (See helpers/clock.py). + +A new Trigger may defined as follows: +```python +from bcipy.core.triggers import Trigger, TriggerType + +# label can be any utf-8 compliant string +nontarget_trigger = Trigger('nontarget_label', TriggerType.NONTARGET, 1.0111) +``` + +### Trigger Types + +The supported internal trigger types are as follows: + +```python +NONTARGET = "nontarget" +TARGET = "target" +FIXATION = "fixation" +PROMPT = "prompt" +SYSTEM = "system" +OFFSET = "offset" +EVENT = "event" +PREVIEW = "preview" +``` + +### TriggerHandler + +You can use the BciPy TriggerHandler to read and write any Triggers! + +### Writing + +You will need three pieces of information to create a new handler for writing: + +1. path where trigger data should be written +2. name of the trigger file without extension +3. a defined FlushFrequency. This sets how often the handler should write the data. Incrementally (FlushFrequency.EVERY) or at session end (FlushFrequency.END). + +```python +from bcipy.core.triggers import TriggerHandler, FlushFrequency + +path_to_trigger_save_location = '.' +trigger_file_name = 'triggers' # BciPy will add the correct extension. Currently, .txt is used. +flush = FlushFrequency.END + +handler = TriggerHandler(path_to_trigger_save_location, trigger_file_name, flush) + +triggers = [ + Trigger('test_trigger', TriggerType.SYSTEM, 1.0111), + Trigger('target', TriggerType.TARGET, 2), + Trigger('nontarget', TriggerType.NONTARGET, 3), +] + +handler.add_triggers(triggers) +handler.close() # this will call write one final time on any triggers added since last flush +``` + + +### Loading + +To load a BciPy triggers.txt file, the TriggerHandler load method can be used. Because it is a staticmethod, the class does not need to be initialized before the load method is used. + +```python +from bcipy.core.triggers import TriggerHandler + +path_to_trigger_save_location = './triggers.txt' + +triggers = TriggerHandler.load(path_to_trigger_save_location) +# it will load in data like this: +# [Trigger: label=[starting_offset] type=[offset] time=[-13149613.488788936], Trigger: label=[x] type=[prompt] time=[4.96745322458446]] +``` + +Alternately, you can pass offset and exclusion as keyword arguments to modify the behavior of `TriggerHandler.load`. Offset will add time to every timestamp loaded. Use this to correct any static system offsets! The default value for offset is 0.0. Exclusions can be used to pre-filter any unwanted Triggers, such as SYSTEM. + +```python +from bcipy.core.triggers import TriggerHandler + +path_to_trigger_save_location = './triggers.txt' + +# exclude system triggers +triggers = TriggerHandler.load(path_to_trigger_save_location, exclusion=[TriggerType.SYSTEM]) + +# apply a 2 second offset to all timestamps loaded +triggers = TriggerHandler.load(path_to_trigger_save_location, offset=2.0) +``` + +### Trigger File Format + +Triggers as written from BciPy tasks are assumed to have the following structure, + +1. A single trigger is written per line with three columns (label type timestamp). +2. If a clock offset is present and subsequent triggers should be corrected, it may be written with the trigger type `offset`. The value of that Trigger will be added to any timestamp values written in the file. When using various clocks that don't start at zero, this can be used to align the data to similar t=0. If time should be subtracted, write a negative value as demonstrated below for both offset values. BciPy will only apply the first instance of offset, but will return all Triggers and the additional offsets may be applied as desired. In the example below, only starting_offset would be applied to all other triggers; another_offset would be returned. +3. Only a valid TriggerType may be read. + +``` +starting_offset offset -3421.2852307 +another_offset offset -2 +N prompt 3490.3607581 ++ fixation 3491.3668763 +Y nontarget 3491.8722132 +P nontarget 3492.0780858 +J nontarget 3492.2839911 +F nontarget 3492.4900032 +D nontarget 3492.6959695 +K nontarget 3492.9021379 +X nontarget 3493.1079959 +< nontarget 3493.3139829 +M nontarget 3493.5198677 +N target 3493.7257014 +X prompt 3495.4642608 ++ fixation 3496.4697921 +Z nontarget 3496.9749562 +``` + +### Multiple Devices + +When multiple devices are in use, each device should provide its own starting_offset trigger. The trigger time should be the timestamp associated with the first sample in the data for that device. + +```python +from bcipy.core.triggers import Trigger, TriggerType, offset_label + +# label can be any utf-8 compliant string +eeg_offset = Trigger(offset_label(device_type='EEG'), TriggerType.OFFSET, -3400.0) +eyetracker_offset = Trigger(offset_label(device_type='EYETRACKER'), TriggerType.OFFSET, -3450.0) +``` + +After adding other triggers, the resulting file will look something like: + +``` +starting_offset offset -3400.0 +starting_offset_EYETRACKER offset -3450.0 +N prompt 3490.3607581 ++ fixation 3491.3668763 +Y nontarget 3491.8722132 +... +``` + +Triggers can then be loaded with timestamps relative to a device's start. + +```python +from bcipy.core.triggers import trigger_decoder, TriggerType + +types, times, labels = trigger_decoder('triggers.txt', device_type='EEG') +# types == ['prompt', 'fixation', 'prompt'] +# times == [90.36075810000011, 91.36687630000006, 91.8722131999998] +# labels == ['N', '+', 'Y'] + +_, times, _ = trigger_decoder('triggers.txt', device_type='EYETRACKER') +# times == [40.36075810000011, 41.36687630000006, 41.872213199999806] +``` diff --git a/bcipy/core/__init__.py b/bcipy/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bcipy/helpers/demo/demo_report.py b/bcipy/core/demo/demo_report.py similarity index 95% rename from bcipy/helpers/demo/demo_report.py rename to bcipy/core/demo/demo_report.py index 1ee4ff363..862e08e20 100644 --- a/bcipy/helpers/demo/demo_report.py +++ b/bcipy/core/demo/demo_report.py @@ -1,6 +1,6 @@ from pathlib import Path -from bcipy.helpers.load import load_json_parameters, load_raw_data, load_experimental_data -from bcipy.helpers.triggers import trigger_decoder, TriggerType +from bcipy.io.load import load_json_parameters, load_raw_data, load_experimental_data +from bcipy.core.triggers import trigger_decoder, TriggerType from bcipy.config import ( BCIPY_ROOT, DEFAULT_PARAMETERS_FILENAME, @@ -13,7 +13,7 @@ from bcipy.helpers.visualization import visualize_erp from bcipy.signal.process import get_default_transform from bcipy.signal.evaluate.artifact import ArtifactDetection -from bcipy.helpers.report import Report, SignalReportSection, SessionReportSection +from bcipy.core.report import Report, SignalReportSection, SessionReportSection if __name__ == "__main__": diff --git a/bcipy/helpers/demo/demo_session_tools.py b/bcipy/core/demo/demo_session_tools.py similarity index 92% rename from bcipy/helpers/demo/demo_session_tools.py rename to bcipy/core/demo/demo_session_tools.py index e9711fe57..a6721a7e2 100644 --- a/bcipy/helpers/demo/demo_session_tools.py +++ b/bcipy/core/demo/demo_session_tools.py @@ -5,8 +5,8 @@ from bcipy.config import SESSION_DATA_FILENAME, SESSION_SUMMARY_FILENAME from bcipy.gui.file_dialog import ask_directory -from bcipy.helpers.session import (read_session, session_csv, session_data, - session_db, session_excel) +from bcipy.core.session import (read_session, session_csv, session_data, + session_db, session_excel) def main(data_dir: str): diff --git a/bcipy/helpers/demo/demo_stimuli_generation.py b/bcipy/core/demo/demo_stimuli_generation.py similarity index 97% rename from bcipy/helpers/demo/demo_stimuli_generation.py rename to bcipy/core/demo/demo_stimuli_generation.py index 0518fb206..d5449b598 100644 --- a/bcipy/helpers/demo/demo_stimuli_generation.py +++ b/bcipy/core/demo/demo_stimuli_generation.py @@ -1,4 +1,4 @@ -from bcipy.helpers.stimuli import StimuliOrder, generate_calibration_inquiries, best_case_rsvp_inq_gen +from bcipy.core.stimuli import StimuliOrder, generate_calibration_inquiries, best_case_rsvp_inq_gen import numpy as np diff --git a/bcipy/helpers/list.py b/bcipy/core/list.py similarity index 100% rename from bcipy/helpers/list.py rename to bcipy/core/list.py diff --git a/bcipy/helpers/parameters.py b/bcipy/core/parameters.py similarity index 100% rename from bcipy/helpers/parameters.py rename to bcipy/core/parameters.py diff --git a/bcipy/helpers/raw_data.py b/bcipy/core/raw_data.py similarity index 100% rename from bcipy/helpers/raw_data.py rename to bcipy/core/raw_data.py diff --git a/bcipy/helpers/report.py b/bcipy/core/report.py similarity index 100% rename from bcipy/helpers/report.py rename to bcipy/core/report.py diff --git a/bcipy/helpers/session.py b/bcipy/core/session.py similarity index 98% rename from bcipy/helpers/session.py rename to bcipy/core/session.py index d447d17f6..a052a1338 100644 --- a/bcipy/helpers/session.py +++ b/bcipy/core/session.py @@ -19,8 +19,8 @@ from bcipy.config import (BCIPY_ROOT, DEFAULT_ENCODING, DEFAULT_PARAMETERS_FILENAME, EXPERIMENT_DATA_FILENAME, SESSION_DATA_FILENAME, SESSION_SUMMARY_FILENAME) -from bcipy.helpers.load import (load_experiment_fields, load_experiments, - load_json_parameters) +from bcipy.io.load import (load_experiment_fields, load_experiments, + load_json_parameters) from bcipy.helpers.validate import validate_field_data_written from bcipy.task.data import Session diff --git a/bcipy/helpers/stimuli.py b/bcipy/core/stimuli.py similarity index 99% rename from bcipy/helpers/stimuli.py rename to bcipy/core/stimuli.py index 558a513e2..43ce5feb5 100644 --- a/bcipy/helpers/stimuli.py +++ b/bcipy/core/stimuli.py @@ -23,8 +23,8 @@ from bcipy.config import DEFAULT_FIXATION_PATH, DEFAULT_TEXT_FIXATION, SESSION_LOG_FILENAME from bcipy.exceptions import BciPyCoreException -from bcipy.helpers.list import grouper -from bcipy.helpers.symbols import alphabet +from bcipy.core.list import grouper +from bcipy.core.symbols import alphabet # Prevents pillow from filling the console with debug info logging.getLogger('PIL').setLevel(logging.WARNING) diff --git a/bcipy/helpers/symbols.py b/bcipy/core/symbols.py similarity index 100% rename from bcipy/helpers/symbols.py rename to bcipy/core/symbols.py diff --git a/bcipy/helpers/tests/resources/mock_session/parameters.json b/bcipy/core/tests/resources/mock_session/parameters.json similarity index 100% rename from bcipy/helpers/tests/resources/mock_session/parameters.json rename to bcipy/core/tests/resources/mock_session/parameters.json diff --git a/bcipy/helpers/tests/resources/mock_session/session.csv b/bcipy/core/tests/resources/mock_session/session.csv similarity index 100% rename from bcipy/helpers/tests/resources/mock_session/session.csv rename to bcipy/core/tests/resources/mock_session/session.csv diff --git a/bcipy/helpers/tests/resources/mock_session/session.json b/bcipy/core/tests/resources/mock_session/session.json similarity index 100% rename from bcipy/helpers/tests/resources/mock_session/session.json rename to bcipy/core/tests/resources/mock_session/session.json diff --git a/bcipy/helpers/tests/test_list.py b/bcipy/core/tests/test_list.py similarity index 97% rename from bcipy/helpers/tests/test_list.py rename to bcipy/core/tests/test_list.py index 5fcc397cb..49f17f837 100644 --- a/bcipy/helpers/tests/test_list.py +++ b/bcipy/core/tests/test_list.py @@ -1,6 +1,6 @@ """Tests for list processing utilities""" import unittest -from bcipy.helpers.list import destutter, grouper +from bcipy.core.list import destutter, grouper class TestListUtilities(unittest.TestCase): diff --git a/bcipy/helpers/tests/test_parameters.py b/bcipy/core/tests/test_parameters.py similarity index 99% rename from bcipy/helpers/tests/test_parameters.py rename to bcipy/core/tests/test_parameters.py index 58b0acbd9..7a70a9a60 100644 --- a/bcipy/helpers/tests/test_parameters.py +++ b/bcipy/core/tests/test_parameters.py @@ -6,7 +6,7 @@ from pathlib import Path from bcipy.config import DEFAULT_PARAMETERS_PATH -from bcipy.helpers.parameters import Parameters, parse_range +from bcipy.core.parameters import Parameters, parse_range class TestParameters(unittest.TestCase): diff --git a/bcipy/helpers/tests/test_raw_data.py b/bcipy/core/tests/test_raw_data.py similarity index 99% rename from bcipy/helpers/tests/test_raw_data.py rename to bcipy/core/tests/test_raw_data.py index 12fc67bb5..71093157e 100644 --- a/bcipy/helpers/tests/test_raw_data.py +++ b/bcipy/core/tests/test_raw_data.py @@ -11,8 +11,8 @@ from mockito import any, mock, when, verify, unstub from bcipy.exceptions import BciPyCoreException -from bcipy.helpers.raw_data import (RawData, RawDataReader, RawDataWriter, - load, sample_data, settings, write) +from bcipy.core.raw_data import (RawData, RawDataReader, RawDataWriter, + load, sample_data, settings, write) class TestRawData(unittest.TestCase): diff --git a/bcipy/helpers/tests/test_report.py b/bcipy/core/tests/test_report.py similarity index 96% rename from bcipy/helpers/tests/test_report.py rename to bcipy/core/tests/test_report.py index 3d28833d4..79a25915c 100644 --- a/bcipy/helpers/tests/test_report.py +++ b/bcipy/core/tests/test_report.py @@ -9,7 +9,7 @@ from reportlab.platypus import Paragraph, Image from reportlab.platypus import Flowable, KeepTogether -from bcipy.helpers.report import Report, SessionReportSection, ReportSection, SignalReportSection +from bcipy.core.report import Report, SessionReportSection, ReportSection, SignalReportSection class TestReport(unittest.TestCase): @@ -90,7 +90,7 @@ def test_complile_adds_section_and_header(self): self.assertEqual(len(report.elements), 2) def test_create_header_is_called_once_compile(self): - with patch('bcipy.helpers.report.Report._construct_report_header') as mock_construct_header: + with patch('bcipy.core.report.Report._construct_report_header') as mock_construct_header: report = Report(self.temp_dir) summary = { 'session': 'session_name', diff --git a/bcipy/helpers/tests/test_session.py b/bcipy/core/tests/test_session.py similarity index 95% rename from bcipy/helpers/tests/test_session.py rename to bcipy/core/tests/test_session.py index 0554fffe7..28f42cb31 100644 --- a/bcipy/helpers/tests/test_session.py +++ b/bcipy/core/tests/test_session.py @@ -8,7 +8,7 @@ from pathlib import Path from bcipy.config import SESSION_DATA_FILENAME -from bcipy.helpers.session import read_session, session_csv, session_data +from bcipy.core.session import read_session, session_csv, session_data class TestSessionHelper(unittest.TestCase): diff --git a/bcipy/helpers/tests/test_stimuli.py b/bcipy/core/tests/test_stimuli.py similarity index 98% rename from bcipy/helpers/tests/test_stimuli.py rename to bcipy/core/tests/test_stimuli.py index 2c1dc5b2f..4bef764d7 100644 --- a/bcipy/helpers/tests/test_stimuli.py +++ b/bcipy/core/tests/test_stimuli.py @@ -10,18 +10,18 @@ from psychopy import core from bcipy.exceptions import BciPyCoreException -from bcipy.helpers.stimuli import (DEFAULT_FIXATION_PATH, InquiryReshaper, - StimuliOrder, TargetPositions, - TrialReshaper, alphabetize, - best_case_rsvp_inq_gen, best_selection, - distributed_target_positions, - generate_calibration_inquiries, - generate_inquiry, generate_targets, - get_fixation, inquiry_nontarget_counts, - inquiry_target, inquiry_target_counts, - jittered_timing, play_sound, - random_target_positions, soundfiles, - target_index, update_inquiry_timing) +from bcipy.core.stimuli import (DEFAULT_FIXATION_PATH, InquiryReshaper, + StimuliOrder, TargetPositions, + TrialReshaper, alphabetize, + best_case_rsvp_inq_gen, best_selection, + distributed_target_positions, + generate_calibration_inquiries, + generate_inquiry, generate_targets, + get_fixation, inquiry_nontarget_counts, + inquiry_target, inquiry_target_counts, + jittered_timing, play_sound, + random_target_positions, soundfiles, + target_index, update_inquiry_timing) MOCK_FS = 44100 diff --git a/bcipy/helpers/tests/test_symbols.py b/bcipy/core/tests/test_symbols.py similarity index 95% rename from bcipy/helpers/tests/test_symbols.py rename to bcipy/core/tests/test_symbols.py index 8fda98230..f3cfce7a0 100644 --- a/bcipy/helpers/tests/test_symbols.py +++ b/bcipy/core/tests/test_symbols.py @@ -1,6 +1,6 @@ import unittest -from bcipy.helpers.symbols import alphabet +from bcipy.core.symbols import alphabet class TestAlphabet(unittest.TestCase): diff --git a/bcipy/helpers/tests/test_triggers.py b/bcipy/core/tests/test_triggers.py similarity index 95% rename from bcipy/helpers/tests/test_triggers.py rename to bcipy/core/tests/test_triggers.py index 478015733..2ac6500d6 100644 --- a/bcipy/helpers/tests/test_triggers.py +++ b/bcipy/core/tests/test_triggers.py @@ -6,13 +6,13 @@ from mockito import any, mock, unstub, verify, when from bcipy.exceptions import BciPyCoreException -from bcipy.helpers.triggers import (FlushFrequency, Trigger, TriggerHandler, - TriggerType, _calibration_trigger, - apply_offsets, exclude_types, - find_starting_offset, offset_device, - offset_label, read, read_data, - starting_offsets_by_device, - trigger_decoder) +from bcipy.core.triggers import (FlushFrequency, Trigger, TriggerHandler, + TriggerType, _calibration_trigger, + apply_offsets, exclude_types, + find_starting_offset, offset_device, + offset_label, read, read_data, + starting_offsets_by_device, + trigger_decoder) class TestCalibrationTrigger(unittest.TestCase): @@ -205,7 +205,7 @@ def test_add_triggers_calls_write_when_flush_every(self): verify(self.handler, times=1).write() - @patch('bcipy.helpers.triggers.os.path.exists') + @patch('bcipy.core.triggers.os.path.exists') def test_load_returns_list_of_triggers(self, path_exists_mock): """Test static load method""" path_exists_mock.returnValue = True @@ -217,7 +217,7 @@ def test_load_returns_list_of_triggers(self, path_exists_mock): response = self.handler.load('test_path_not_real.txt') self.assertEqual(response[0], trg) - @patch('bcipy.helpers.triggers.os.path.exists') + @patch('bcipy.core.triggers.os.path.exists') def test_load_applies_offset(self, path_exists_mock): """Test that static load method applies offsets""" path_exists_mock.returnValue = True @@ -228,7 +228,7 @@ def test_load_applies_offset(self, path_exists_mock): response = self.handler.load('test_path_not_real.txt', offset=1) self.assertEqual(response[0].time, 2.5) - @patch('bcipy.helpers.triggers.os.path.exists') + @patch('bcipy.core.triggers.os.path.exists') def test_load_exclusion(self, path_exists_mock): """Test that static load can exclude trigger types""" path_exists_mock.returnValue = True @@ -241,7 +241,7 @@ def test_load_exclusion(self, path_exists_mock): exclusion=[TriggerType.NONTARGET]) self.assertEqual(response[0], fixation_trg) - @patch('bcipy.helpers.triggers.os.path.exists') + @patch('bcipy.core.triggers.os.path.exists') def test_read_data(self, path_exists_mock): """Test that trigger data is correctly read.""" trg_data = '''starting_offset offset 3.47 @@ -263,7 +263,7 @@ def test_read_data(self, path_exists_mock): self.assertEqual(triggers[1].time, 6.15) self.assertEqual(offset, 3.47) - @patch('bcipy.helpers.triggers.os.path.exists') + @patch('bcipy.core.triggers.os.path.exists') def test_read_data_bad_format(self, path_exists_mock): """Test that exception is thrown when trigger type doesn't exist.""" trg_data = '''start_offset offset @@ -278,7 +278,7 @@ def test_read_data_bad_format(self, path_exists_mock): self.assertIn("line 1", ctx.exception.message) - @patch('bcipy.helpers.triggers.os.path.exists') + @patch('bcipy.core.triggers.os.path.exists') def test_read_data_bad_trigger_type(self, path_exists_mock): """Test that exception is thrown when trigger type doesn't exist.""" trg_data = '''start_offset offset 3.47 @@ -293,7 +293,7 @@ def test_read_data_bad_trigger_type(self, path_exists_mock): self.assertIn("line 3", ctx.exception.message) - @patch('bcipy.helpers.triggers.os.path.exists') + @patch('bcipy.core.triggers.os.path.exists') def test_read_data_bad_timestamp(self, path_exists_mock): """Test that exception is thrown when timestamp can't be converted.""" trg_data = '''starting_offset offset 3.47 @@ -309,7 +309,7 @@ def test_read_data_bad_timestamp(self, path_exists_mock): self.assertIn("line 2", ctx.exception.message) - @patch('bcipy.helpers.triggers.os.path.exists') + @patch('bcipy.core.triggers.os.path.exists') def test_read_data_unicode(self, path_exists_mock): """Test that trigger data is correctly read.""" trg_data = '''starting_offset offset 3.47 @@ -391,7 +391,7 @@ def test_read_data_exception(self): with self.assertRaises(BciPyCoreException): read_data(trg_data.split('\n')) - @patch('bcipy.helpers.triggers.os.path.exists') + @patch('bcipy.core.triggers.os.path.exists') def test_read_from_file(self, path_exists_mock): """Test reading triggers from a file""" trg_data = '''starting_offset offset 3.47 @@ -540,7 +540,7 @@ def test_exclude_types(self): all(trg.type in [TriggerType.NONTARGET, TriggerType.TARGET] for trg in filtered)) - @patch('bcipy.helpers.triggers.os.path.exists') + @patch('bcipy.core.triggers.os.path.exists') def test_trigger_decoder_defaults(self, path_exists_mock): """Test trigger decoder""" trg_data = '''starting_offset offset -3.47 @@ -566,7 +566,7 @@ def test_trigger_decoder_defaults(self, path_exists_mock): self.assertAlmostEqual(times[0], 5.11) self.assertAlmostEqual(times[-1], 7.83) - @patch('bcipy.helpers.triggers.os.path.exists') + @patch('bcipy.core.triggers.os.path.exists') def test_trigger_decoder_with_device(self, path_exists_mock): """Test trigger decoder""" trg_data = '''starting_offset offset -3.47 @@ -593,7 +593,7 @@ def test_trigger_decoder_with_device(self, path_exists_mock): self.assertAlmostEqual(times[0], 4.13) self.assertAlmostEqual(times[-1], 6.85) - @patch('bcipy.helpers.triggers.os.path.exists') + @patch('bcipy.core.triggers.os.path.exists') def test_trigger_decoder_without_starting_offset(self, path_exists_mock): """Test trigger decoder""" trg_data = '''starting_offset offset -3.47 diff --git a/bcipy/helpers/triggers.py b/bcipy/core/triggers.py similarity index 96% rename from bcipy/helpers/triggers.py rename to bcipy/core/triggers.py index 47624ffc3..b829aa083 100644 --- a/bcipy/helpers/triggers.py +++ b/bcipy/core/triggers.py @@ -8,8 +8,8 @@ from bcipy.config import DEFAULT_ENCODING, SESSION_LOG_FILENAME from bcipy.helpers.clock import Clock from bcipy.exceptions import BciPyCoreException -from bcipy.helpers.parameters import Parameters -from bcipy.helpers.stimuli import resize_image +from bcipy.core.parameters import Parameters +from bcipy.core.stimuli import resize_image log = logging.getLogger(SESSION_LOG_FILENAME) diff --git a/bcipy/display/demo/components/demo_layouts.py b/bcipy/display/demo/components/demo_layouts.py index 08521c73f..6fbcd78c9 100644 --- a/bcipy/display/demo/components/demo_layouts.py +++ b/bcipy/display/demo/components/demo_layouts.py @@ -15,7 +15,7 @@ scaled_size) from bcipy.display.paradigm.matrix.layout import symbol_positions from bcipy.display.paradigm.vep.layout import BoxConfiguration, checkerboard -from bcipy.helpers.symbols import alphabet +from bcipy.core.symbols import alphabet def make_window(): diff --git a/bcipy/display/demo/vep/demo_calibration_vep.py b/bcipy/display/demo/vep/demo_calibration_vep.py index c0a53b4ea..69bd54829 100644 --- a/bcipy/display/demo/vep/demo_calibration_vep.py +++ b/bcipy/display/demo/vep/demo_calibration_vep.py @@ -11,7 +11,7 @@ from bcipy.display.paradigm.vep.display import VEPDisplay from bcipy.display.paradigm.vep.layout import BoxConfiguration from bcipy.helpers.clock import Clock -from bcipy.helpers.system_utils import get_screen_info +from bcipy.helpers.utils import get_screen_info root = logging.getLogger() root.setLevel(logging.DEBUG) diff --git a/bcipy/display/main.py b/bcipy/display/main.py index bb8edd33f..650173078 100644 --- a/bcipy/display/main.py +++ b/bcipy/display/main.py @@ -9,7 +9,7 @@ AcceptButtonPressHandler, ButtonPressHandler, PreviewOnlyButtonPressHandler, RejectButtonPressHandler) from bcipy.helpers.clock import Clock -from bcipy.helpers.system_utils import get_screen_info +from bcipy.helpers.utils import get_screen_info class Display(ABC): diff --git a/bcipy/display/paradigm/matrix/display.py b/bcipy/display/paradigm/matrix/display.py index f05f2b3e2..a3ab6f284 100644 --- a/bcipy/display/paradigm/matrix/display.py +++ b/bcipy/display/paradigm/matrix/display.py @@ -11,9 +11,9 @@ from bcipy.display.components.task_bar import TaskBar from bcipy.display.main import PreviewParams, init_preview_button_handler from bcipy.display.paradigm.matrix.layout import symbol_positions -from bcipy.helpers.stimuli import resize_image -from bcipy.helpers.symbols import alphabet, frequency_order, qwerty_order -from bcipy.helpers.triggers import _calibration_trigger +from bcipy.core.stimuli import resize_image +from bcipy.core.symbols import alphabet, frequency_order, qwerty_order +from bcipy.core.triggers import _calibration_trigger logger = logging.getLogger(SESSION_LOG_FILENAME) diff --git a/bcipy/display/paradigm/rsvp/display.py b/bcipy/display/paradigm/rsvp/display.py index 2a696b12d..fdf5d40f7 100644 --- a/bcipy/display/paradigm/rsvp/display.py +++ b/bcipy/display/paradigm/rsvp/display.py @@ -9,10 +9,10 @@ from bcipy.display.components.task_bar import TaskBar from bcipy.display.main import PreviewParams, init_preview_button_handler from bcipy.helpers.clock import Clock -from bcipy.helpers.stimuli import resize_image -from bcipy.helpers.symbols import SPACE_CHAR -from bcipy.helpers.system_utils import get_screen_info -from bcipy.helpers.triggers import TriggerCallback, _calibration_trigger +from bcipy.core.stimuli import resize_image +from bcipy.core.symbols import SPACE_CHAR +from bcipy.helpers.utils import get_screen_info +from bcipy.core.triggers import TriggerCallback, _calibration_trigger class RSVPDisplay(Display): diff --git a/bcipy/display/paradigm/rsvp/mode/calibration.py b/bcipy/display/paradigm/rsvp/mode/calibration.py index df261ddef..8ab5caab8 100644 --- a/bcipy/display/paradigm/rsvp/mode/calibration.py +++ b/bcipy/display/paradigm/rsvp/mode/calibration.py @@ -1,5 +1,5 @@ from bcipy.display.paradigm.rsvp.display import RSVPDisplay -from bcipy.helpers.symbols import SPACE_CHAR +from bcipy.core.symbols import SPACE_CHAR class CalibrationDisplay(RSVPDisplay): diff --git a/bcipy/display/paradigm/rsvp/mode/copy_phrase.py b/bcipy/display/paradigm/rsvp/mode/copy_phrase.py index 051a14557..1dc8ec287 100644 --- a/bcipy/display/paradigm/rsvp/mode/copy_phrase.py +++ b/bcipy/display/paradigm/rsvp/mode/copy_phrase.py @@ -1,8 +1,8 @@ from psychopy import visual from bcipy.display.paradigm.rsvp.display import BCIPY_LOGO_PATH, RSVPDisplay -from bcipy.helpers.stimuli import resize_image -from bcipy.helpers.symbols import SPACE_CHAR +from bcipy.core.stimuli import resize_image +from bcipy.core.symbols import SPACE_CHAR """Note: diff --git a/bcipy/display/paradigm/vep/display.py b/bcipy/display/paradigm/vep/display.py index f3fb0afeb..4ca6a062c 100644 --- a/bcipy/display/paradigm/vep/display.py +++ b/bcipy/display/paradigm/vep/display.py @@ -17,10 +17,10 @@ from bcipy.display.paradigm.vep.layout import BoxConfiguration, animation_path from bcipy.display.paradigm.vep.vep_stim import VEPStim from bcipy.helpers.clock import Clock -from bcipy.helpers.list import expanded -from bcipy.helpers.stimuli import resize_image -from bcipy.helpers.symbols import alphabet -from bcipy.helpers.triggers import _calibration_trigger +from bcipy.core.list import expanded +from bcipy.core.stimuli import resize_image +from bcipy.core.symbols import alphabet +from bcipy.core.triggers import _calibration_trigger class StimTime(NamedTuple): diff --git a/bcipy/feedback/demo/demo_sound_feedback.py b/bcipy/feedback/demo/demo_sound_feedback.py index c4ae5b4b3..52caeb7bb 100644 --- a/bcipy/feedback/demo/demo_sound_feedback.py +++ b/bcipy/feedback/demo/demo_sound_feedback.py @@ -4,7 +4,7 @@ from bcipy.config import DEFAULT_PARAMETERS_PATH from bcipy.feedback.sound.auditory_feedback import AuditoryFeedback from bcipy.helpers.clock import Clock -from bcipy.helpers.load import load_json_parameters +from bcipy.io.load import load_json_parameters # Load a parameters file parameters = load_json_parameters(DEFAULT_PARAMETERS_PATH, value_cast=True) diff --git a/bcipy/feedback/demo/demo_visual_feedback.py b/bcipy/feedback/demo/demo_visual_feedback.py index 625df1b39..626e29bee 100644 --- a/bcipy/feedback/demo/demo_visual_feedback.py +++ b/bcipy/feedback/demo/demo_visual_feedback.py @@ -2,7 +2,7 @@ from bcipy.display import init_display_window from bcipy.feedback.visual.visual_feedback import VisualFeedback from bcipy.helpers.clock import Clock -from bcipy.helpers.load import load_json_parameters +from bcipy.io.load import load_json_parameters # Load a parameters file parameters = load_json_parameters(DEFAULT_PARAMETERS_PATH, value_cast=True) diff --git a/bcipy/feedback/visual/visual_feedback.py b/bcipy/feedback/visual/visual_feedback.py index 95e70f44e..8db136d01 100644 --- a/bcipy/feedback/visual/visual_feedback.py +++ b/bcipy/feedback/visual/visual_feedback.py @@ -6,7 +6,7 @@ from bcipy.feedback.feedback import Feedback from bcipy.helpers.clock import Clock -from bcipy.helpers.stimuli import resize_image +from bcipy.core.stimuli import resize_image class FeedbackType(Enum): diff --git a/bcipy/gui/BCInterface.py b/bcipy/gui/BCInterface.py index bfacafad4..509157a43 100644 --- a/bcipy/gui/BCInterface.py +++ b/bcipy/gui/BCInterface.py @@ -9,8 +9,8 @@ AlertResponse, BCIGui, app, contains_special_characters, contains_whitespaces, invalid_length) -from bcipy.helpers.load import (copy_parameters, load_experiments, - load_json_parameters, load_users) +from bcipy.io.load import (copy_parameters, load_experiments, + load_json_parameters, load_users) from bcipy.task import TaskRegistry logger = logging.getLogger(PROTOCOL_LOG_FILENAME) diff --git a/bcipy/gui/experiments/ExperimentField.py b/bcipy/gui/experiments/ExperimentField.py index 4ec1e51a0..13201957e 100644 --- a/bcipy/gui/experiments/ExperimentField.py +++ b/bcipy/gui/experiments/ExperimentField.py @@ -31,8 +31,8 @@ ) from bcipy.config import EXPERIMENT_DATA_FILENAME from bcipy.helpers.validate import validate_experiment, validate_field_data_written -from bcipy.helpers.load import load_experiments, load_fields -from bcipy.helpers.save import save_experiment_field_data +from bcipy.io.load import load_experiments, load_fields +from bcipy.io.save import save_experiment_field_data class ExperimentFieldCollection(QWidget): diff --git a/bcipy/gui/experiments/ExperimentRegistry.py b/bcipy/gui/experiments/ExperimentRegistry.py index b1bd74de5..250d03efc 100644 --- a/bcipy/gui/experiments/ExperimentRegistry.py +++ b/bcipy/gui/experiments/ExperimentRegistry.py @@ -9,8 +9,8 @@ QScrollArea, ) from bcipy.gui.bciui import BCIUI, DynamicItem, DynamicList, SmallButton, run_bciui -from bcipy.helpers.load import load_fields, load_experiments -from bcipy.helpers.save import save_experiment_data +from bcipy.io.load import load_fields, load_experiments +from bcipy.io.save import save_experiment_data from bcipy.config import ( DEFAULT_ENCODING, DEFAULT_EXPERIMENT_PATH, diff --git a/bcipy/gui/experiments/FieldRegistry.py b/bcipy/gui/experiments/FieldRegistry.py index 54cb4741f..d39da51e2 100644 --- a/bcipy/gui/experiments/FieldRegistry.py +++ b/bcipy/gui/experiments/FieldRegistry.py @@ -2,8 +2,8 @@ from bcipy.gui.main import BCIGui, app, AlertMessageType, AlertMessageResponse from bcipy.config import DEFAULT_FIELD_PATH, FIELD_FILENAME -from bcipy.helpers.load import load_fields -from bcipy.helpers.save import save_field_data +from bcipy.io.load import load_fields +from bcipy.io.save import save_field_data class FieldRegistry(BCIGui): diff --git a/bcipy/gui/experiments/demo/demo_experiment_field_collection.py b/bcipy/gui/experiments/demo/demo_experiment_field_collection.py index 438eea056..5f93b9d95 100644 --- a/bcipy/gui/experiments/demo/demo_experiment_field_collection.py +++ b/bcipy/gui/experiments/demo/demo_experiment_field_collection.py @@ -1,4 +1,4 @@ -from bcipy.helpers.session import collect_experiment_field_data +from bcipy.core.session import collect_experiment_field_data from bcipy.config import DEFAULT_EXPERIMENT_ID diff --git a/bcipy/gui/main.py b/bcipy/gui/main.py index aecdee22f..747c54d1f 100644 --- a/bcipy/gui/main.py +++ b/bcipy/gui/main.py @@ -14,7 +14,7 @@ QLineEdit, QMessageBox, QPushButton, QScrollArea, QSpinBox, QVBoxLayout, QWidget) -from bcipy.helpers.parameters import parse_range +from bcipy.core.parameters import parse_range def font(size: int = 16, font_family: str = 'Helvetica') -> QFont: diff --git a/bcipy/gui/parameters/params_form.py b/bcipy/gui/parameters/params_form.py index 4c243f76f..188d849f4 100644 --- a/bcipy/gui/parameters/params_form.py +++ b/bcipy/gui/parameters/params_form.py @@ -14,7 +14,7 @@ from bcipy.gui.main import (BoolInput, DirectoryInput, FileInput, FloatInput, FormInput, IntegerInput, RangeInput, SearchInput, SelectionInput, TextInput, static_text_control) -from bcipy.helpers.parameters import Parameters, changes_from_default +from bcipy.core.parameters import Parameters, changes_from_default class ParamsForm(QWidget): diff --git a/bcipy/gui/viewer/data_source/file_streamer.py b/bcipy/gui/viewer/data_source/file_streamer.py index ff6d33c7d..af5e11bca 100644 --- a/bcipy/gui/viewer/data_source/file_streamer.py +++ b/bcipy/gui/viewer/data_source/file_streamer.py @@ -3,7 +3,7 @@ import time from bcipy.acquisition.util import StoppableThread from bcipy.config import SESSION_LOG_FILENAME -from bcipy.helpers.raw_data import RawDataReader +from bcipy.core.raw_data import RawDataReader log = logging.getLogger(SESSION_LOG_FILENAME) diff --git a/bcipy/gui/viewer/data_viewer.py b/bcipy/gui/viewer/data_viewer.py index 820c93866..d36c81d36 100644 --- a/bcipy/gui/viewer/data_viewer.py +++ b/bcipy/gui/viewer/data_viewer.py @@ -24,8 +24,8 @@ from bcipy.gui.viewer.data_source.file_streamer import FileStreamer from bcipy.gui.viewer.data_source.lsl_data_source import LslDataSource from bcipy.gui.viewer.ring_buffer import RingBuffer -from bcipy.helpers.parameters import DEFAULT_PARAMETERS_PATH, Parameters -from bcipy.helpers.raw_data import settings +from bcipy.core.parameters import DEFAULT_PARAMETERS_PATH, Parameters +from bcipy.core.raw_data import settings from bcipy.signal.process.transform import Downsample, get_default_transform diff --git a/bcipy/helpers/README.md b/bcipy/helpers/README.md index bca1658a5..08ddcc922 100644 --- a/bcipy/helpers/README.md +++ b/bcipy/helpers/README.md @@ -6,174 +6,9 @@ Modules necessary for BciPy system operation. These range from system utilities - `acquisition`: helper methods for working with the acquisition module. Initializes EEG acquisition given parameters - `clock`: clocks that provides high resolution timestamps -- `convert`: functionality for converting the bcipy raw data output to other formats (currently, EDF) - `copy_phrase_wrapper`: basic copy phrase task duty cycle wrapper. Coordinates activities around evidence management, decision-making, generation of inquiries, etc -- `exceptions`: BciPy core internal exceptions - `language_model`: helper methods for working with the language module -- `list`: utility methods for list processing -- `load`: methods for loading most BciPy data. For loading of triggers, see triggers.py -- `parameters`: module for functionality related to system configuration via the parameters.json file -- `raw_data`: functionality for reading and writing raw signal data -- `report`: methods for generating BciPy PDF reports -- `save`: methods for saving BciPy data in supported formats. For saving of triggers, see triggers.py -- `session`: methods for managing and parsing session.json data -- `stimuli`: methods for generating stimuli and inquiries for presentation -- `symbols`: methods for working with symbols and symbol lists. Ex. Alphabet, QWERTY, etc. -- `system_utils`: utilities for extracting git version, system information and handling of logging +- `utils`: utilities for extracting git version, system information and handling of logging - `task`: common task methods and utilities, including Trial and InquiryReshaper. -- `triggers`: methods and data classes defining BciPy internal triggering - `validate`: methods for validating experiments and fields - `visualization`: method for visualizing EEG data collected in BciPy. Used in offline_analysis.py. - - -## Triggers - -Triggers consist of a label, type and a timestamp. These are what is used to align events both in real-time and after a session for further processing or model training. During operation of a BciPy task, Triggers are generated when display or other events of interest occur. These are timestamped using a monotonic clock (See helpers/clock.py). - -A new Trigger may defined as follows: -```python -from bcipy.helpers.triggers import Trigger, TriggerType - -# label can be any utf-8 compliant string -nontarget_trigger = Trigger('nontarget_label', TriggerType.NONTARGET, 1.0111) -``` - -### Trigger Types - -The supported internal trigger types are as follows: - -```python -NONTARGET = "nontarget" -TARGET = "target" -FIXATION = "fixation" -PROMPT = "prompt" -SYSTEM = "system" -OFFSET = "offset" -EVENT = "event" -PREVIEW = "preview" -``` - -### TriggerHandler - -You can use the BciPy TriggerHandler to read and write any Triggers! - -### Writing - -You will need three pieces of information to create a new handler for writing: - -1. path where trigger data should be written -2. name of the trigger file without extension -3. a defined FlushFrequency. This sets how often the handler should write the data. Incrementally (FlushFrequency.EVERY) or at session end (FlushFrequency.END). - -```python -from bcipy.helpers.triggers import TriggerHandler, FlushFrequency - -path_to_trigger_save_location = '.' -trigger_file_name = 'triggers' # BciPy will add the correct extension. Currently, .txt is used. -flush = FlushFrequency.END - -handler = TriggerHandler(path_to_trigger_save_location, trigger_file_name, flush) - -triggers = [ - Trigger('test_trigger', TriggerType.SYSTEM, 1.0111), - Trigger('target', TriggerType.TARGET, 2), - Trigger('nontarget', TriggerType.NONTARGET, 3), -] - -handler.add_triggers(triggers) -handler.close() # this will call write one final time on any triggers added since last flush -``` - - -### Loading - -To load a BciPy triggers.txt file, the TriggerHandler load method can be used. Because it is a staticmethod, the class does not need to be initialized before the load method is used. - -```python -from bcipy.helpers.triggers import TriggerHandler - -path_to_trigger_save_location = './triggers.txt' - -triggers = TriggerHandler.load(path_to_trigger_save_location) -# it will load in data like this: -# [Trigger: label=[starting_offset] type=[offset] time=[-13149613.488788936], Trigger: label=[x] type=[prompt] time=[4.96745322458446]] -``` - -Alternately, you can pass offset and exclusion as keyword arguments to modify the behavior of `TriggerHandler.load`. Offset will add time to every timestamp loaded. Use this to correct any static system offsets! The default value for offset is 0.0. Exclusions can be used to pre-filter any unwanted Triggers, such as SYSTEM. - -```python -from bcipy.helpers.triggers import TriggerHandler - -path_to_trigger_save_location = './triggers.txt' - -# exclude system triggers -triggers = TriggerHandler.load(path_to_trigger_save_location, exclusion=[TriggerType.SYSTEM]) - -# apply a 2 second offset to all timestamps loaded -triggers = TriggerHandler.load(path_to_trigger_save_location, offset=2.0) -``` - -### Trigger File Format - -Triggers as written from BciPy tasks are assumed to have the following structure, - -1. A single trigger is written per line with three columns (label type timestamp). -2. If a clock offset is present and subsequent triggers should be corrected, it may be written with the trigger type `offset`. The value of that Trigger will be added to any timestamp values written in the file. When using various clocks that don't start at zero, this can be used to align the data to similar t=0. If time should be subtracted, write a negative value as demonstrated below for both offset values. BciPy will only apply the first instance of offset, but will return all Triggers and the additional offsets may be applied as desired. In the example below, only starting_offset would be applied to all other triggers; another_offset would be returned. -3. Only a valid TriggerType may be read. - -``` -starting_offset offset -3421.2852307 -another_offset offset -2 -N prompt 3490.3607581 -+ fixation 3491.3668763 -Y nontarget 3491.8722132 -P nontarget 3492.0780858 -J nontarget 3492.2839911 -F nontarget 3492.4900032 -D nontarget 3492.6959695 -K nontarget 3492.9021379 -X nontarget 3493.1079959 -< nontarget 3493.3139829 -M nontarget 3493.5198677 -N target 3493.7257014 -X prompt 3495.4642608 -+ fixation 3496.4697921 -Z nontarget 3496.9749562 -``` - -### Multiple Devices - -When multiple devices are in use, each device should provide its own starting_offset trigger. The trigger time should be the timestamp associated with the first sample in the data for that device. - -```python -from bcipy.helpers.triggers import Trigger, TriggerType, offset_label - -# label can be any utf-8 compliant string -eeg_offset = Trigger(offset_label(device_type='EEG'), TriggerType.OFFSET, -3400.0) -eyetracker_offset = Trigger(offset_label(device_type='EYETRACKER'), TriggerType.OFFSET, -3450.0) -``` - -After adding other triggers, the resulting file will look something like: - -``` -starting_offset offset -3400.0 -starting_offset_EYETRACKER offset -3450.0 -N prompt 3490.3607581 -+ fixation 3491.3668763 -Y nontarget 3491.8722132 -... -``` - -Triggers can then be loaded with timestamps relative to a device's start. - -```python -from bcipy.helpers.triggers import trigger_decoder, TriggerType - -types, times, labels = trigger_decoder('triggers.txt', device_type='EEG') -# types == ['prompt', 'fixation', 'prompt'] -# times == [90.36075810000011, 91.36687630000006, 91.8722131999998] -# labels == ['N', '+', 'Y'] - -_, times, _ = trigger_decoder('triggers.txt', device_type='EYETRACKER') -# times == [40.36075810000011, 41.36687630000006, 41.872213199999806] -``` diff --git a/bcipy/helpers/acquisition.py b/bcipy/helpers/acquisition.py index 2e60ded8d..9b9cf6c1a 100644 --- a/bcipy/helpers/acquisition.py +++ b/bcipy/helpers/acquisition.py @@ -13,8 +13,7 @@ preconfigured_device, with_content_type) from bcipy.config import BCIPY_ROOT from bcipy.config import DEFAULT_DEVICE_SPEC_FILENAME as spec_name -from bcipy.config import RAW_DATA_FILENAME, SESSION_LOG_FILENAME -from bcipy.helpers.save import save_device_specs +from bcipy.io.save import save_device_specs logger = logging.getLogger(SESSION_LOG_FILENAME) diff --git a/bcipy/helpers/convert.py b/bcipy/helpers/convert.py deleted file mode 100644 index 4367dc8cd..000000000 --- a/bcipy/helpers/convert.py +++ /dev/null @@ -1,593 +0,0 @@ -# mypy: disable-error-code="no-redef" -"""Functionality for converting the bcipy raw data output to other formats""" -import logging -import os -import tarfile -from pathlib import Path -from typing import Dict, List, Optional, Tuple, Union - -import mne -import numpy as np -from mne.io import RawArray -from pyedflib import FILETYPE_BDFPLUS, FILETYPE_EDFPLUS, EdfWriter -from tqdm import tqdm - -from bcipy.acquisition.devices import preconfigured_device -from bcipy.config import (DEFAULT_PARAMETERS_FILENAME, RAW_DATA_FILENAME, - TRIGGER_FILENAME, SESSION_LOG_FILENAME) -from bcipy.helpers.load import load_json_parameters, load_raw_data -from bcipy.helpers.raw_data import RawData -from bcipy.helpers.triggers import trigger_decoder, trigger_durations -from bcipy.signal.process import Composition, get_default_transform - -logger = logging.getLogger(SESSION_LOG_FILENAME) - -FILE_LENGTH_LIMIT = 150 - - -def convert_to_edf(data_dir: str, - edf_path: Optional[str] = None, - overwrite: bool = False, - write_targetness: bool = False, - use_event_durations: bool = False, - remove_pre_fixation: bool = False, - pre_filter: bool = False) -> str: - """ Converts BciPy data to the EDF+ filetype using pyEDFlib. - - See https://www.edfplus.info/ for more detailed information about the edf specification. - See https://www.teuniz.net/edfbrowser/ for a free EDF/BDF viewer. - - Parameters - ---------- - data_dir - directory which contains the data to be converted. This location must contain: - 1. a parameter file. - 2. a raw_data.csv file. - 3. a trigger.txt file. - edf_path - optional; path to write converted data; defaults to writing a file named raw_data.edf in the data_dir. - Must end in .edf. - overwrite - If True, the destination file (if it exists) will be overwritten. If False (default), an error will - be raised if the file exists. - write_targetness - If True, and targetness information is available, write that instead of the stimuli markers. - False by default. - use_event_durations - optional; if True assigns a duration to each event. - remove_pre_fixation - optional; if True removes the non-inquiry based markers from the trigger data. - pre_filter - optional; if True, apply the default filter to the data using to loaded parameters file. - - Returns - ------- - Path to new edf file - """ - if not edf_path: - edf_path = str(Path(data_dir, f'{RAW_DATA_FILENAME}.edf').resolve()) - if not edf_path.endswith('.edf'): - raise ValueError(f'edf_path=[{edf_path}] must end in .edf') - - data, channels, fs, events, annotation_channels, filt = pyedf_convert( - data_dir, - write_targetness=write_targetness, - use_event_durations=use_event_durations, - remove_pre_fixation=remove_pre_fixation, - pre_filter=pre_filter) - - return write_pyedf( - edf_path, - data, - channels, - fs, - events, - FILETYPE_EDFPLUS, - overwrite, - annotation_channels, - filt) - - -def convert_to_bdf(data_dir: str, - bdf_path: Optional[str] = None, - overwrite: bool = False, - write_targetness: bool = False, - use_event_durations: bool = False, - remove_pre_fixation: bool = False, - pre_filter: bool = False) -> Union[Path, str]: - """ Converts BciPy data to the BDF+ filetype using pyEDFlib. - - See https://www.biosemi.com/faq/file_format.htm for more detailed information about the BDF specification. - See https://www.teuniz.net/edfbrowser/ for a free EDF/BDF viewer. - - Parameters - ---------- - data_dir - directory which contains the data to be converted. This location must contain: - 1. a parameter file. - 2. a raw_data.csv file. - 3. a trigger.txt file. - bdf_path - optional; path to write converted data; defaults to writing a file named raw_data.edf in the data_dir. - Must end in .edf. - overwrite - If True, the destination file (if it exists) will be overwritten. If False (default), an error will - be raised if the file exists. - write_targetness - If True, and targetness information is available, write that instead of the stimuli markers. - False by default. - use_event_durations - optional; if True assigns a duration to each event. - remove_pre_fixation - optional; if True removes the non-inquiry based markers from the trigger data. - pre_filter - optional; if True, apply the default filter to the data using to loaded parameters file. - - Returns - ------- - Path to new bdf file - """ - if not bdf_path: - bdf_path = str(Path(data_dir, f'{RAW_DATA_FILENAME}.bdf').resolve()) - if not bdf_path.endswith('.bdf'): - raise ValueError(f'bdf_path=[{bdf_path}] must end in .bdf') - - data, channels, fs, events, annotation_channels, filt = pyedf_convert( - data_dir, - write_targetness=write_targetness, - use_event_durations=use_event_durations, - remove_pre_fixation=remove_pre_fixation, - pre_filter=pre_filter) - - return write_pyedf( - bdf_path, - data, - channels, - fs, - events, - FILETYPE_BDFPLUS, - overwrite, - annotation_channels, - filt) - - -def pyedf_convert(data_dir: str, - write_targetness: bool = False, - use_event_durations: bool = False, - remove_pre_fixation: bool = False, - pre_filter: bool = False) -> Tuple[ - np.ndarray, - List[str], - int, - List[Tuple[float, float, str]], - int, - Union[bool, str]]: - """ Converts BciPy data to formats that can be used by pyEDFlib. - - Parameters - ---------- - - data_dir - directory which contains the data to be converted. This location must contain: - 1. a parameter file. - 2. a raw_data.csv file. - 3. a trigger.txt file. - write_targetness - If True, and targetness information is available, write that instead of the stimuli markers. - False by default. - use_event_durations - optional; if True assigns a duration to each event. - remove_pre_fixation - optional; if True removes the non-inquiry based markers from the trigger data. - pre_filter - optional; if True, apply the default filter to the data using to loaded parameters file. - - Returns - ------- - data - raw data - channels - list of channel names - fs - sampling rate - events - list of events - annotation_channels - number of annotation channels - pre_filter - False if no filter was applied, - otherwise a string of the filter parameters used to filter the data - """ - - params = load_json_parameters(str(Path(data_dir, DEFAULT_PARAMETERS_FILENAME)), - value_cast=True) - data = load_raw_data(str(Path(data_dir, f'{RAW_DATA_FILENAME}.csv'))) - fs = data.sample_rate - device_spec = preconfigured_device(data.daq_type) - if pre_filter: - default_transform = get_default_transform( - sample_rate_hz=data.sample_rate, - notch_freq_hz=params.get("notch_filter_frequency"), - bandpass_low=params.get("filter_low"), - bandpass_high=params.get("filter_high"), - bandpass_order=params.get("filter_order"), - downsample_factor=params.get("down_sampling_rate"), - ) - raw_data, fs = data.by_channel(transform=default_transform) - pre_filter: str = ( - f"HP:{params.get('filter_low')} LP:{params.get('filter_high')}" - f"N:{params.get('notch_filter_frequency')} D:{params.get('down_sampling_rate')} " - f"O:{params.get('filter_order')}") - else: - raw_data, _ = data.by_channel() - durations = trigger_durations(params) if use_event_durations else {} - static_offset = device_spec.static_offset - logger.info(f'Static offset: {static_offset}') - - trigger_type, trigger_timing, trigger_label = trigger_decoder( - str(Path(data_dir, TRIGGER_FILENAME)), - remove_pre_fixation=remove_pre_fixation, - offset=static_offset) - - # validate annotation parameters given data length and trigger count, up the annotation limit byt increasing - # annotation_channels if needed - annotation_channels = validate_annotations(len(raw_data[0]) / data.sample_rate, len(trigger_type)) - - triggers = compile_triggers( - trigger_label, trigger_type, trigger_timing, write_targetness) - - events = compile_annotations(triggers, durations) - - return raw_data, data.channels, fs, events, annotation_channels, pre_filter - - -def validate_annotations(record_time: float, trigger_count: int) -> int: - """Validate Annotations. - - Using the pyedflib library, it is recommended the number of triggers (or annotations) not exceed the recording - time in seconds. This may result in an unsuccessful export. The best workaround is to increase the number of - annotation channels. This function will return the number of annotation channels needed to export the triggers - successfully. The maximum number of annotation channels is 64 and it is not advised to use more than required - due to fize size restrictions. - - See https://github.com/holgern/pyedflib/issues/34 for more information. - """ - if trigger_count > record_time: - logger.warning( - f'\n*Warning* The number of triggers [{trigger_count}] exceeds recording time [{record_time}]. ') - annotation_channels = round(trigger_count / record_time) + 1 - logger.warning(f'\nIncreasing annotation channels to {annotation_channels} to compensate.') - return annotation_channels - return 1 - - -def compile_triggers(labels: List[str], targetness: List[str], timing: List[float], - write_targetness: bool) -> List[Tuple[str, str, float]]: - """Compile Triggers. - - Compile trigger information in a way that we edf conversion can easily digest. (label, targetness, timing). - If write targetness is true, use the targetness as a label. - """ - triggers = [] - i = 0 - for label in labels: - # if targetness information available and the flag set to true, write another trigger with target information - if write_targetness: - triggers.append((targetness[i], targetness[i], timing[i])) - else: - triggers.append((label, targetness[i], timing[i])) - i += 1 - return triggers - - -def write_pyedf(output_path: str, - raw_data: np.ndarray, - ch_names: List[str], - sample_rate: float, - events: List[Tuple[float, float, str]], - file_type: str = FILETYPE_EDFPLUS, - overwrite: bool = False, - annotation_channels: int = 1, - pre_filter: Union[bool, str] = False) -> str: - """ - Converts BciPy raw_data to the EDF+ or BDF+ filetype using pyEDFlib. - - Adapted from: https://github.com/holgern/pyedflib - - Parameters - ---------- - output_path - optional path to write converted data; defaults to writing - a file named raw.edf in the raw_data_dir. - raw_data - raw data with a row for each channel - ch_names - names of the channels - sample_rate - sample frequency - events - List[Tuple(onset_in_seconds: float, duration_in_seconds: float, description: str)] - overwrite - If True, the destination file (if it exists) will be overwritten. - If False (default), an error will be raised if the file exists. - annotation_channels - number of annotation channels to use - pre_filter - optional; if provided, add string of filters applied to channel data - - Returns - ------- - Path to new edf or bdf file - """ - if not overwrite and os.path.exists(output_path): - raise OSError(f'{output_path} already exists.') - - physical_min, physical_max = [raw_data.min(), raw_data.max()] - n_channels = len(raw_data) - - try: - writer = EdfWriter(output_path, n_channels=n_channels, file_type=file_type) - - channel_info = [] - data_list = [] - - for i in range(n_channels): - ch_dict = { - 'label': ch_names[i], - 'dimension': 'uV', - 'sample_frequency': sample_rate, - 'physical_min': physical_min, - 'physical_max': physical_max, - } - - if pre_filter: - ch_dict['prefilter'] = pre_filter - - channel_info.append(ch_dict) - data_list.append(raw_data[i]) - - if events: - writer.set_number_of_annotation_signals(annotation_channels) - for onset, duration, label in events: - writer.writeAnnotation(onset, duration, label) - - writer.setSignalHeaders(channel_info) - writer.writeSamples(data_list) - - except Exception as error: - logger.info(error) - raise error - finally: - writer.close() - return output_path - - -def compile_annotations(triggers: List[Tuple[str, str, float]], - durations: Dict[str, float] = {} - ) -> List[Tuple[float, float, str]]: - """Convert bcipy triggers to the format expected by pyedflib for writing annotations. - - Parameters - ---------- - triggers - trigger data in the format (symbol, targetness, stamp), - where stamp has been converted to acquisition clock units. - durations - optional map defining the duration (seconds) of each - trigger type. The default is to assign 0.0 seconds. - Returns - ------- - List[Tuple(onset_in_seconds, duration_in_seconds, description)] - """ - return [(timestamp, durations.get(targetness, 0.0), label) - for (label, targetness, timestamp) in triggers] - - -def compress(tar_file_name: str, members: List[str]) -> None: - """ - File compression and archiving. - - Adds files to a tar archive and compresses them using the gzip compression - format. - - Parameters - ---------- - tar_file_name (str): name of resulting compressed tar archive. Input string - can either include or not include file extension (.tar.gz), as this will - be checked. - members (List[str]): list of files and folders to be compressed into archive. - Each file or folder requires the relative file path and full file extension. - Individual file paths and names that are very long may throw an error - upon extraction, proceed with caution. - - Returns - ------- - None - """ - - for member in members: - # Checks if file exists - if not os.path.exists(member): - raise FileNotFoundError(f"This file or folder, '{member}', " - "does not exist!\nPlease rerun program") - # Checks for file names that may be too long. OS level restriction. - if len(member) > FILE_LENGTH_LIMIT: - logger.warning( - f'File length exceeds compression limit=[{FILE_LENGTH_LIMIT}]. ' - 'This may cause issues later with extraction. Please proceed at your own discretion.') - - full_tar_name = tar_name_checker(tar_file_name) - # Warns user that reopening tar archive that already exists will overwrite it - if os.path.exists(full_tar_name): - raise Exception(f"This tar archive=[{full_tar_name}] already exists, continuing will " - "overwrite anything in the existing archive.") - - # Opens file for gzip (gz) compressed writing (w) - # Uses default compression level 9 (highest compression, slowest speed) - with tarfile.open(full_tar_name, mode="w:gz") as tar: - # Sets progress bar through tqdm - progress = tqdm(members) - for member in progress: - # Adds file/folder to the tar file and compresses it - tar.add(member) - # Sets progress description of progress bar - progress.set_description(f"Compressing {member}") - - -def decompress(tar_file: str, path: str) -> None: - """ - Archive decompression and extraction. - - Takes .tar.gz archive, decompresses, and extracts its contents. Should only be - used with files created by compress() method. - - Parameters - ---------- - tar_file (str): name of .tar.gz archive to be decompressed. Input string - can either include or not include file extension (.tar.gz), as this will - be checked. - path (str): file path and name of folder for the extracted contents of the - archive. If only a name is entered, by default it will place the folder - in the current working directory. (ex. "extracted" creates a new folder - in the current directory and extracts the archive contents to it) - - Returns - ------- - None - """ - - full_tar_name = tar_name_checker(tar_file) - - # Checks if file exists - if not os.path.exists(full_tar_name): - raise FileNotFoundError(f"This file or folder, '{tar_file}', " - "does not exist!\nPlease rerun program") - - # Opens file for gzip (gz) compressed reading (r) - with tarfile.open(full_tar_name, mode="r:gz") as tar: - members = tar.getmembers() - - # Sets progress bar through tqdm - progress = tqdm(members) - for member in progress: - tar.extract(member, path=path) - # Sets progress description of progress bar - progress.set_description(f"Extracting {member.name}") - - -def archive_list(tar_file: str) -> List[str]: - """ - Returns contents of tar archive. - - Takes .tar.gz archive and returns a full list of its contents, including - folders, subfolders, and files, with their full relative paths and names. - - Parameters - ---------- - tar_file (str): name of desired .tar.gz archive. Input string can either - include or not include file extension (.tar.gz), as this will be checked. - - Returns - ------- - List[str] of all items in archive - """ - - full_tar_name = tar_name_checker(tar_file) - - # Checks if file exists - if not os.path.exists(full_tar_name): - raise FileNotFoundError(f"This file or folder, '{tar_file}', " - "does not exist!\nPlease rerun program") - - # Adds names of archive contents to list - with tarfile.open(full_tar_name, mode="r") as tar: - tar_list = tar.getnames() - return tar_list - - -def tar_name_checker(tar_file_name: str) -> str: - """ - Checks and modifies file name for tar archive. - - Helper method that takes a tar file name and checks it for the appropriate - file extension (".tar.gz" for now), returning it if it already does, and - adding the extension and then returning it if it does not. - - Parameters - ---------- - tar_file_name (str): name for archive being checked. - - Returns - ------- - String of properly formatted tar archive name - """ - - if tar_file_name.endswith('.tar.gz'): - return tar_file_name - return f'{tar_file_name}.tar.gz' - - -def convert_to_mne( - raw_data: RawData, - channel_map: Optional[List[int]] = None, - channel_types: Optional[List[str]] = None, - transform: Optional[Composition] = None, - montage: str = 'standard_1020', - volts: bool = False) -> RawArray: - """Convert to MNE. - - Returns BciPy RawData as an MNE RawArray. This assumes all channel names - are reflective of provided montage locations. - - Parameters - ---------- - raw_data - BciPy RawData object - channel_map - optional list of channels to include in the MNE RawArray [1, 0, 1, 0, 1, 0, 1, 0]. - 1 indicates the channel should be included, 0 indicates it should be excluded. - Must be the same length as the number of channels in the raw_data. - channel_types - list of channel types to include in the MNE RawArray. - If None, all channels will be assumed to be eeg. - See: https://mne.tools/stable/overview/implementation.html#supported-channel-types - transform - optional transform to apply to the data - montage - name of the channel location montage to use. - See https://mne.tools/dev/generated/mne.channels.make_standard_montage.html - volts - if True, assume data is already in volts. If false, assume data is in microvolts and convert to volts. - MNE expects data to be in volts. - See: https://mne.tools/dev/overview/implementation.html#internal-representation-units - """ - # if no channel map provided, assume all channels are included - if not channel_map: - channel_map = [1] * len(raw_data.channels) - - data, channels, fs = raw_data.by_channel_map(channel_map, transform) - - # if no channel types provided, assume all channels are eeg - if not channel_types: - channel_types = ['eeg'] * len(channels) - - # check that number of channel types matches number of channels in the case custom channel types are provided - assert len(channel_types) == len(channels), \ - f'Number of channel types ({len(channel_types)}) must match number of channels ({len(channels)})' - - info = mne.create_info(channels, fs, channel_types) - mne_data = RawArray(data, info) - ten_twenty_montage = mne.channels.make_standard_montage(montage) - mne_data.set_montage(ten_twenty_montage) - - # convert to volts if necessary (the default for many systems is microvolts) - if not volts: - mne_data = mne_data.apply_function(lambda x: x * 1e-6) - - return mne_data - - -def tobii_to_norm(tobii_units: Tuple[float, float]) -> Tuple[float, float]: - """Tobii to PsychoPy's 'norm' units. - - https://developer.tobiipro.com/commonconcepts/coordinatesystems.html - https://www.psychopy.org/general/units.html - - Tobii uses an Active Display Coordinate System. - The point (0, 0) denotes the upper left corner and (1, 1) the lower right corner of it. - - PsychoPy uses several coordinate systems, the normalized window unit is assumed here. - The point (0, 0) denotes the center of the screen and (-1, -1) the upper left corner - and (1, 1) the lower right corner. - - """ - # check that Tobii units are within the expected range - assert 0 <= tobii_units[0] <= 1, "Tobii x coordinate must be between 0 and 1" - assert 0 <= tobii_units[1] <= 1, "Tobii y coordinate must be between 0 and 1" - - # convert Tobii units to Psychopy units - norm_x = (tobii_units[0] - 0.5) * 2 - norm_y = (tobii_units[1] - 0.5) * 2 * -1 - return (norm_x, norm_y) - - -def norm_to_tobii(norm_units: Tuple[float, float]) -> Tuple[float, float]: - """PsychoPy's 'norm' units to Tobii. - - https://developer.tobiipro.com/commonconcepts/coordinatesystems.html - https://www.psychopy.org/general/units.html - - Tobii uses an Active Display Coordinate System. - The point (0, 0) denotes the upper left corner and (1, 1) the lower right corner of it. - - PsychoPy uses several coordinate systems, the normalized window unit is assumed here. - The point (0, 0) denotes the center of the screen and (-1, -1) the upper left corner - and (1, 1) the lower right corner. - """ - # check that the coordinates are within the bounds of the screen - assert norm_units[0] >= -1 and norm_units[0] <= 1, "X coordinate must be between -1 and 1" - assert norm_units[1] >= -1 and norm_units[1] <= 1, "Y coordinate must be between -1 and 1" - - # convert PsychoPy norm units to Tobii units - tobii_x = (norm_units[0] / 2) + 0.5 - tobii_y = ((norm_units[1] * -1) / 2) + 0.5 - return (tobii_x, tobii_y) diff --git a/bcipy/helpers/copy_phrase_wrapper.py b/bcipy/helpers/copy_phrase_wrapper.py index aa546948e..e94f22278 100644 --- a/bcipy/helpers/copy_phrase_wrapper.py +++ b/bcipy/helpers/copy_phrase_wrapper.py @@ -8,8 +8,8 @@ from bcipy.config import SESSION_LOG_FILENAME from bcipy.exceptions import BciPyCoreException from bcipy.helpers.language_model import histogram, with_min_prob -from bcipy.helpers.stimuli import InquirySchedule, StimuliOrder -from bcipy.helpers.symbols import BACKSPACE_CHAR +from bcipy.core.stimuli import InquirySchedule, StimuliOrder +from bcipy.core.symbols import BACKSPACE_CHAR from bcipy.language.main import LanguageModel from bcipy.task.control.criteria import (CriteriaEvaluator, MaxIterationsCriteria, diff --git a/bcipy/helpers/demo/demo_convert.py b/bcipy/helpers/demo/demo_convert.py deleted file mode 100644 index b15918021..000000000 --- a/bcipy/helpers/demo/demo_convert.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Demonstrates converting BciPy data output to other EEG formats. - -To use at bcipy root, - - `python bcipy/helpers/demo/demo_convert.py -d "path://to/bcipy/data/folder"` -""" -from bcipy.helpers.convert import convert_to_edf, convert_to_bdf -from bcipy.helpers.load import load_experimental_data -from bcipy.helpers.visualization import plot_edf - - -if __name__ == '__main__': - import argparse - - parser = argparse.ArgumentParser() - parser.add_argument( - '-d', - '--directory', - help='Path to the directory with raw_data to be converted', - required=False) - parser.add_argument( - '-p', - '--plot', - help='whether to plot the resulting edf file', - required=False - ) - args = parser.parse_args() - - path = args.directory - if not path: - path = load_experimental_data() - - edf_path = convert_to_edf( - path, - use_event_durations=False, - write_targetness=True, - overwrite=True, - pre_filter=True) - - bdf_path = convert_to_bdf( - path, - use_event_durations=False, - write_targetness=True, - overwrite=True, - pre_filter=True) - - if args.plot: - plot_edf(edf_path) # comment if not in an iPython notebook to plot using MNE - - print(f"\nWrote edf file to {edf_path}") - print(f"\nWrote bdf file to {bdf_path}") diff --git a/bcipy/helpers/demo/demo_copy_phrase_wrapper.py b/bcipy/helpers/demo/demo_copy_phrase_wrapper.py index ca6fac117..7a11ea34e 100644 --- a/bcipy/helpers/demo/demo_copy_phrase_wrapper.py +++ b/bcipy/helpers/demo/demo_copy_phrase_wrapper.py @@ -1,7 +1,7 @@ import numpy as np from bcipy.acquisition.devices import preconfigured_device from bcipy.helpers.copy_phrase_wrapper import CopyPhraseWrapper -from bcipy.helpers.symbols import alphabet +from bcipy.core.symbols import alphabet from bcipy.language.model.uniform import UniformLanguageModel from bcipy.signal.model import PcaRdaKdeModel diff --git a/bcipy/helpers/demo/demo_sound_card.py b/bcipy/helpers/demo/demo_sound_card.py deleted file mode 100644 index 5e0533531..000000000 --- a/bcipy/helpers/demo/demo_sound_card.py +++ /dev/null @@ -1,28 +0,0 @@ -'''demo for using sound device and sound file: - Taken from: https://python-sounddevice.readthedocs.io/en/0.2.1/examples.html -''' - -import argparse -import logging -log = logging.getLogger(__name__) -# To use, cd into helpers directory, run >> python demo/sound_card_demo.py "filename" -# Example: python demo/sound_card_demo.py "../static/sounds/chime.wav" - -parser = argparse.ArgumentParser(description=__doc__) -parser.add_argument("filename", help="audio file to be played back") -parser.add_argument("-d", "--device", type=int, help="device ID") -args = parser.parse_args() - -try: - import sounddevice as sd - import soundfile as sf - devices = sd.query_devices() - print(devices) - data, fs = sf.read(args.filename, dtype='float32') - sd.play(data, fs, device=args.device, blocking=True) - status = sd.get_status() - if status: - log.warning(str(status)) -except BaseException as e: - # This avoids printing the traceback, especially if Ctrl-C is used. - raise SystemExit(str(e)) diff --git a/bcipy/helpers/demo/demo_visualization.py b/bcipy/helpers/demo/demo_visualization.py index 078db292a..2cfc3a040 100644 --- a/bcipy/helpers/demo/demo_visualization.py +++ b/bcipy/helpers/demo/demo_visualization.py @@ -18,9 +18,9 @@ DEFAULT_PARAMETERS_FILENAME, RAW_DATA_FILENAME, TRIGGER_FILENAME) from bcipy.helpers.acquisition import analysis_channels -from bcipy.helpers.load import (load_experimental_data, load_json_parameters, - load_raw_data) -from bcipy.helpers.triggers import TriggerType, trigger_decoder +from bcipy.io.load import (load_experimental_data, load_json_parameters, + load_raw_data) +from bcipy.core.triggers import TriggerType, trigger_decoder from bcipy.helpers.visualization import visualize_erp from bcipy.signal.process import get_default_transform diff --git a/bcipy/helpers/language_model.py b/bcipy/helpers/language_model.py index b1b77a05d..be1a0ca06 100644 --- a/bcipy/helpers/language_model.py +++ b/bcipy/helpers/language_model.py @@ -5,7 +5,7 @@ import numpy as np -from bcipy.helpers.symbols import alphabet +from bcipy.core.symbols import alphabet from bcipy.language.main import LanguageModel, ResponseType # pylint: disable=unused-import diff --git a/bcipy/helpers/load.py b/bcipy/helpers/load.py deleted file mode 100644 index 2779e88b1..000000000 --- a/bcipy/helpers/load.py +++ /dev/null @@ -1,342 +0,0 @@ -# mypy: disable-error-code="arg-type, union-attr" -import json -import logging -import os -import pickle -from pathlib import Path -from shutil import copyfile -from time import localtime, strftime -from typing import List, Optional, Union - -from bcipy.config import (DEFAULT_ENCODING, DEFAULT_EXPERIMENT_PATH, - DEFAULT_FIELD_PATH, DEFAULT_PARAMETERS_PATH, - EXPERIMENT_FILENAME, FIELD_FILENAME, ROOT, - SESSION_LOG_FILENAME, SIGNAL_MODEL_FILE_SUFFIX) -from bcipy.exceptions import BciPyCoreException, InvalidExperimentException -from bcipy.gui.file_dialog import ask_directory, ask_filename -from bcipy.helpers.parameters import Parameters -from bcipy.helpers.raw_data import RawData -from bcipy.preferences import preferences -from bcipy.signal.model import SignalModel - -log = logging.getLogger(SESSION_LOG_FILENAME) - - -def copy_parameters(path: str = DEFAULT_PARAMETERS_PATH, - destination: Optional[str] = None) -> str: - """Creates a copy of the given configuration (parameters.json) to the - given directory and returns the path. - - Parameters: - ----------- - path: str - optional path of parameters file to copy; used default if not provided. - destination: str - optional destination directory; default is the same - directory as the default parameters. - Returns: - -------- - path to the new file. - """ - default_dir = str(Path(DEFAULT_PARAMETERS_PATH).parent) - - destination = default_dir if destination is None else destination - filename = strftime('parameters_%Y-%m-%d_%Hh%Mm%Ss.json', localtime()) - - path = str(Path(destination, filename)) - copyfile(DEFAULT_PARAMETERS_PATH, path) - return path - - -def load_experiments(path: str = f'{DEFAULT_EXPERIMENT_PATH}/{EXPERIMENT_FILENAME}') -> dict: - """Load Experiments. - - PARAMETERS - ---------- - :param: path: string path to the experiments file. - - Returns - ------- - A dictionary of experiments, with the following format: - { name: { fields : {name: '', required: bool, anonymize: bool}, summary: '' } } - - """ - with open(path, 'r', encoding=DEFAULT_ENCODING) as json_file: - return json.load(json_file) - - -def extract_mode(bcipy_data_directory: str) -> str: - """Extract Mode. - - This method extracts the task mode from a BciPy data save directory. This is important for - trigger conversions and extracting targeteness. - - *note*: this is not compatible with older versions of BciPy (pre 1.5.0) where - the tasks and modes were considered together using integers (1, 2, 3). - - PARAMETERS - ---------- - :param: bcipy_data_directory: string path to the data directory - """ - directory = bcipy_data_directory.lower() - if 'calibration' in directory: - return 'calibration' - elif 'copy' in directory: - return 'copy_phrase' - raise BciPyCoreException(f'No valid mode could be extracted from [{directory}]') - - -def load_fields(path: str = f'{DEFAULT_FIELD_PATH}/{FIELD_FILENAME}') -> dict: - """Load Fields. - - PARAMETERS - ---------- - :param: path: string path to the fields file. - - Returns - ------- - A dictionary of fields, with the following format: - { - "field_name": { - "help_text": "", - "type": "" - } - - """ - with open(path, 'r', encoding=DEFAULT_ENCODING) as json_file: - return json.load(json_file) - - -def load_experiment_fields(experiment: dict) -> list: - """Load Experiment Fields. - - { - 'fields': [{}, {}], - 'summary': '' - } - - Using the experiment dictionary, loop over the field keys and put them in a list. - """ - if isinstance(experiment, dict): - try: - return [name for field in experiment['fields'] for name in field.keys()] - except KeyError: - raise InvalidExperimentException( - 'Experiment is not formatted correctly. It should be passed as a dictionary with the fields and' - f' summary keys. Fields is a list of dictionaries. Summary is a string. \n experiment=[{experiment}]') - raise TypeError('Unsupported experiment type. It should be passed as a dictionary with the fields and summary keys') - - -def load_json_parameters(path: str, value_cast: bool = False) -> Parameters: - """Load JSON Parameters. - - Given a path to a json of parameters, convert to a dictionary and optionally - cast the type. - - Expects the following format: - "fake_data": { - "value": "true", - "section": "bci_config", - "name": "Fake Data Sessions", - "helpTip": "If true, fake data server used", - "recommended": "", - "editable": "true", - "type": "bool" - } - - PARAMETERS - ---------- - :param: path: string path to the parameters file. - :param: value_case: True/False cast values to specified type. - - Returns - ------- - a Parameters object that behaves like a dict. - """ - return Parameters(source=path, cast_values=value_cast) - - -def load_experimental_data() -> str: - filename = ask_directory() # show dialog box and return the path - if not filename: - raise BciPyCoreException('No file selected in GUI. Exiting...') - log.info("Loaded Experimental Data From: %s" % filename) - return filename - - -def load_signal_models(directory: Optional[str] = None) -> List[SignalModel]: - """Load all signal models in a given directory. - - Models are assumed to have been written using bcipy.helpers.save.save_model - function and should be serialized as pickled files. Note that reading - pickled files is a potential security concern so only load from trusted - directories. - - Args: - dirname (str, optional): Location of pretrained models. If not - provided the user will be prompted for a location. - """ - if not directory or Path(directory).is_file(): - directory = ask_directory() - - # update preferences - path = Path(directory) - preferences.signal_model_directory = str(path) - - models = [] - for file_path in path.glob(f"*{SIGNAL_MODEL_FILE_SUFFIX}"): - with open(file_path, "rb") as signal_file: - model = pickle.load(signal_file) - log.info(f"Loading model {model}") - models.append(model) - return models - - -def choose_signal_models(device_types: List[str]) -> List[SignalModel]: - """Prompt the user to load a signal model for each provided device. - - Parameters - ---------- - device_types - list of device content types (ex. 'EEG') - """ - return [ - model for model in map(choose_signal_model, set(device_types)) if model - ] - - -def choose_model_paths(device_types: List[str]) -> List[Path]: - """Select a model for each device and return a list of paths.""" - return [ - ask_filename(file_types=f"*{SIGNAL_MODEL_FILE_SUFFIX}", - directory=preferences.signal_model_directory, - prompt=f"Select the {device_type} signal model") - for device_type in device_types - ] - - -def load_signal_model(file_path: str) -> SignalModel: - """Load signal model from persisted file. - - Models are assumed to have been written using bcipy.helpers.save.save_model - function and should be serialized as pickled files. Note that reading - pickled files is a potential security concern so only load from trusted - directories.""" - - with open(file_path, "rb") as signal_file: - model = pickle.load(signal_file) - log.info(f"Loading model {model}") - return model - - -def choose_signal_model(device_type: str) -> Optional[SignalModel]: - """Present a file dialog prompting the user to select a signal model for - the given device. - - Parameters - ---------- - device_type - ex. 'EEG' or 'Eyetracker'; this should correspond with - the content_type of the DeviceSpec of the model. - """ - - file_path = ask_filename(file_types=f"*{SIGNAL_MODEL_FILE_SUFFIX}", - directory=preferences.signal_model_directory, - prompt=f"Select the {device_type} signal model") - - if file_path: - # update preferences - path = Path(file_path) - preferences.signal_model_directory = str(path) - return load_signal_model(str(path)) - return None - - -def choose_csv_file(filename: Optional[str] = None) -> Optional[str]: - """GUI prompt to select a csv file from the file system. - - Parameters - ---------- - - filename : optional filename to use; if provided the GUI is not shown. - - Returns - ------- - file name of selected file; throws an exception if the file is not a csv. - """ - if not filename: - filename = ask_filename('*.csv') - - # get the last part of the path to determine file type - file_name = filename.split('/')[-1] - - if 'csv' not in file_name: - raise TypeError( - 'File type unrecognized. Please use a supported csv type') - - return filename - - -def load_raw_data(filename: Union[Path, str]) -> RawData: - """Reads the data (.csv) file written by data acquisition. - - Parameters - ---------- - - filename : path to the serialized data (csv file) - - Returns - ------- - RawData object with data held in memory - """ - return RawData.load(filename) - - -def load_txt_data() -> str: - filename = ask_filename('*.txt') # show dialog box and return the path - file_name = filename.split('/')[-1] - - if 'txt' not in file_name: - raise TypeError( - 'File type unrecognized. Please use a supported text type') - - return filename - - -def load_users(data_save_loc: str) -> List[str]: - """Load Users. - - Loads user directory names below experiments from the data path defined and returns them as a list. - If the save data directory is not found, this method returns an empty list assuming no experiments - have been run yet. - """ - # build a saved users list, pull out the data save location from parameters - saved_users: List[str] = [] - - # check the directory is valid, if it is, set path as data save location - if os.path.isdir(data_save_loc): - path = data_save_loc - - # check the directory is valid after adding bcipy, if it is, set path as data save location - elif os.path.isdir(f'{ROOT}/{data_save_loc}'): - path = f'{ROOT}/{data_save_loc}' - - else: - log.info(f'User save data location not found at [{data_save_loc}]! Returning empty user list.') - return saved_users - - # grab all experiments in the directory and iterate over them to get the users - users = fast_scandir(path, return_path=True) - - for user in users: - if user not in saved_users: - saved_users.append(user.split('/')[-1]) - - return saved_users - - -def fast_scandir(directory_name: str, return_path: bool = True) -> List[str]: - """Fast Scan Directory. - - directory_name: name of the directory to be scanned - return_path: whether or not to return the scanned directories as a relative path or name. - False will return the directory name only. - """ - if return_path: - return [f.path for f in os.scandir(directory_name) if f.is_dir()] - - return [f.name for f in os.scandir(directory_name) if f.is_dir()] diff --git a/bcipy/helpers/offset.py b/bcipy/helpers/offset.py index 0858aadc3..09b8814c8 100644 --- a/bcipy/helpers/offset.py +++ b/bcipy/helpers/offset.py @@ -7,9 +7,9 @@ from scipy.stats import normaltest -from bcipy.helpers.load import load_raw_data, ask_directory, load_json_parameters -from bcipy.helpers.raw_data import RawData -from bcipy.helpers.triggers import trigger_decoder, TriggerType +from bcipy.io.load import load_raw_data, ask_directory, load_json_parameters +from bcipy.core.raw_data import RawData +from bcipy.core.triggers import trigger_decoder, TriggerType from bcipy.config import ( TRIGGER_FILENAME, diff --git a/bcipy/helpers/task.py b/bcipy/helpers/task.py index 983dec8ec..b0aaa3ea5 100644 --- a/bcipy/helpers/task.py +++ b/bcipy/helpers/task.py @@ -10,7 +10,7 @@ from bcipy.acquisition.record import Record from bcipy.config import MAX_PAUSE_SECONDS, SESSION_COMPLETE_MESSAGE, SESSION_LOG_FILENAME from bcipy.helpers.clock import Clock -from bcipy.helpers.stimuli import get_fixation +from bcipy.core.stimuli import get_fixation from bcipy.task.exceptions import InsufficientDataException log = logging.getLogger(SESSION_LOG_FILENAME) diff --git a/bcipy/helpers/tests/test_acquisition.py b/bcipy/helpers/tests/test_acquisition.py index c72df3a46..ea7c66106 100644 --- a/bcipy/helpers/tests/test_acquisition.py +++ b/bcipy/helpers/tests/test_acquisition.py @@ -10,7 +10,8 @@ max_inquiry_duration, parse_stream_type, raw_data_filename, server_spec, stream_types) -from bcipy.helpers.parameters import Parameters +from bcipy.io.load import load_json_parameters +from bcipy.io.save import init_save_data_structure class TestAcquisition(unittest.TestCase): diff --git a/bcipy/helpers/tests/test_convert.py b/bcipy/helpers/tests/test_convert.py deleted file mode 100644 index 28aade773..000000000 --- a/bcipy/helpers/tests/test_convert.py +++ /dev/null @@ -1,683 +0,0 @@ -"""Tests for data conversion related functionality.""" -import os -import shutil -import tempfile -import unittest -import warnings -from pathlib import Path -from typing import Tuple, Union - -from mne.io import read_raw_bdf, read_raw_edf -from mockito import any as any_value -from mockito import mock, unstub, verify, verifyNoMoreInteractions, when - -import bcipy.acquisition.devices as devices -from bcipy.config import (DEFAULT_ENCODING, DEFAULT_PARAMETERS_FILENAME, - RAW_DATA_FILENAME, TRIGGER_FILENAME) -from bcipy.helpers import convert -from bcipy.helpers.convert import (archive_list, compress, convert_to_bdf, - convert_to_edf, convert_to_mne, decompress, - norm_to_tobii, pyedf_convert, tobii_to_norm) -from bcipy.helpers.parameters import Parameters -from bcipy.helpers.raw_data import RawData, sample_data, write -from bcipy.helpers.triggers import MOCK_TRIGGER_DATA -from bcipy.signal.generator.generator import gen_random_data - - -def create_bcipy_session_artifacts( - write_dir: str, - channels: Union[int, list] = 3, - sample_rate: int = 300, - samples: int = 5000, - filter_settings: dict = { - 'filter_low': 0.5, - 'filter_high': 30, - 'filter_order': 5, - 'notch_filter_frequency': 60, - 'down_sampling_rate': 3 - }, -) -> Tuple[str, RawData, Parameters]: - """Write BciPy session artifacts to a temporary directory. - - This includes a raw data file, trigger file, and a parameters file. - """ - trg_data = MOCK_TRIGGER_DATA - if isinstance(channels, int): - channels = [f'ch{i}' for i in range(channels)] - data = sample_data(ch_names=channels, daq_type='SampleDevice', sample_rate=sample_rate, rows=samples) - devices.register(devices.DeviceSpec('SampleDevice', channels=channels, sample_rate=sample_rate)) - - with open(Path(write_dir, TRIGGER_FILENAME), 'w', encoding=DEFAULT_ENCODING) as trg_file: - trg_file.write(trg_data) - - write(data, Path(write_dir, f'{RAW_DATA_FILENAME}.csv')) - - params = Parameters.from_cast_values(raw_data_name=f'{RAW_DATA_FILENAME}.csv', - trigger_file_name=TRIGGER_FILENAME, - preview_inquiry_length=0.5, - time_fixation=0.5, - time_prompt=0.5, - time_flash=0.5, - # define filter settings - down_sampling_rate=filter_settings['down_sampling_rate'], - notch_filter_frequency=filter_settings['notch_filter_frequency'], - filter_high=filter_settings['filter_high'], - filter_low=filter_settings['filter_low'], - filter_order=filter_settings['filter_order']) - params.save(write_dir, DEFAULT_PARAMETERS_FILENAME) - return trg_data, data, params - - -class TestEDFConvert(unittest.TestCase): - """Tests for EDF data format conversions.""" - - def setUp(self): - self.temp_dir = tempfile.mkdtemp() - _, data, _ = create_bcipy_session_artifacts(self.temp_dir) - self.channels = data.channels - - def tearDown(self): - shutil.rmtree(self.temp_dir) - verifyNoMoreInteractions() - unstub() - - def test_convert_to_edf_defaults(self): - """Test default behavior""" - path = convert_to_edf(self.temp_dir) - self.assertTrue(os.path.exists(path)) - - with warnings.catch_warnings(): - warnings.simplefilter('ignore') - edf = read_raw_edf(path, preload=True) - - self.assertTrue(len(edf.get_data()) > 0) - - for ch_name in self.channels: - self.assertTrue(ch_name in edf.ch_names) - - def test_convert_to_edf_overwrite_false(self): - """Test overwriting fails""" - - convert_to_edf(self.temp_dir) - with self.assertRaises(OSError): - convert_to_edf(self.temp_dir, overwrite=False) - - def test_convert_to_edf_overwrite_true(self): - """Test that overwriting can be configured""" - - convert_to_edf(self.temp_dir) - convert_to_edf(self.temp_dir, overwrite=True) - - def test_convert_to_edf_with_custom_path(self): - """Test creating the EDF using a custom edf path""" - path = convert_to_edf(self.temp_dir, - edf_path=str(Path(self.temp_dir, 'mydata.edf').resolve())) - - self.assertEqual(Path(path).name, 'mydata.edf') - - def test_convert_to_edf_raises_value_error_with_invalid_edfpath(self): - """Test that an value error is raised when the edf path is invalid""" - edf_path = 'invalid.pdf' - with self.assertRaises(ValueError): - convert_to_edf(self.temp_dir, edf_path=edf_path) - - def test_convert_to_edf_with_write_targetness(self): - """Test creating the EDF using targetness for event annotations""" - path = convert_to_edf(self.temp_dir, - write_targetness=True) - - with warnings.catch_warnings(): - warnings.simplefilter('ignore') - edf = read_raw_edf(path, preload=True) - - self.assertTrue('target' in edf.annotations.description) - self.assertTrue('nontarget' in edf.annotations.description) - - def test_convert_to_edf_without_write_targetness(self): - """Test creating the EDF with labels as event annotations""" - path = convert_to_edf(self.temp_dir, - write_targetness=False) - - with warnings.catch_warnings(): - warnings.simplefilter('ignore') - edf = read_raw_edf(path, preload=True) - - self.assertNotIn('target', edf.annotations.description) - self.assertNotIn('nontarget', edf.annotations.description) - self.assertIn('+', edf.annotations.description) - - def test_convert_to_edf_with_pre_filter(self): - """Test creating the EDF with pre-filtering""" - - data = mock() - channels = mock() - fs = 100 - events = mock() - annotation_channels = 1 - return_path = mock() - when(convert).pyedf_convert( - any_value(str), - write_targetness=any_value(bool), - use_event_durations=any_value(bool), - remove_pre_fixation=any_value(bool), - pre_filter=True, - ).thenReturn(( - data, - channels, - fs, - events, - annotation_channels, - 'filter_settings' - )) - when(convert).write_pyedf( - any_value(str), - data, - channels, - fs, - events, - any_value(int), - any_value(bool), - annotation_channels, - any_value(str)).thenReturn(return_path) - path = convert_to_edf(self.temp_dir, - pre_filter=True) - - verify(convert, times=1).pyedf_convert( - any_value(str), - write_targetness=any_value(bool), - use_event_durations=any_value(bool), - remove_pre_fixation=any_value(bool), - pre_filter=True, - ) - verify(convert, times=1).write_pyedf( - any_value(str), - data, - channels, - fs, - events, - any_value(int), - any_value(bool), - annotation_channels, - any_value(str)) - self.assertEqual(path, return_path) - - -class TestBDFConvert(unittest.TestCase): - """Tests for BDF data format conversions.""" - - def setUp(self): - """Override; set up the needed path for load functions.""" - - self.temp_dir = tempfile.mkdtemp() - _, data, _ = create_bcipy_session_artifacts(self.temp_dir) - self.channels = data.channels - - def tearDown(self): - """Override""" - shutil.rmtree(self.temp_dir) - verifyNoMoreInteractions() - unstub() - - def test_convert_to_bdf_defaults(self): - """Test default convert to bdf behavior""" - path = convert_to_bdf(self.temp_dir) - self.assertTrue(os.path.exists(path)) - - with warnings.catch_warnings(): - warnings.simplefilter('ignore') - bdf = read_raw_bdf(path, preload=True) - - self.assertTrue(len(bdf.get_data()) > 0) - - for ch_name in self.channels: - self.assertTrue(ch_name in bdf.ch_names) - - def test_convert_to_bdf_overwrite_false(self): - """Test overwriting fails if not configured""" - - convert_to_bdf(self.temp_dir) - with self.assertRaises(OSError): - convert_to_bdf(self.temp_dir, overwrite=False) - - def test_convert_to_bdf_overwrite_true(self): - """Test that overwriting can be configured""" - - convert_to_bdf(self.temp_dir) - convert_to_bdf(self.temp_dir, overwrite=True) - - def test_convert_to_bdf_with_custom_path(self): - """Test creating the EDF using a custom edf path""" - path = convert_to_bdf(self.temp_dir, - bdf_path=str(Path(self.temp_dir, 'mydata.bdf').resolve())) - - self.assertEqual(Path(path).name, 'mydata.bdf') - - def test_convert_to_bdf_raises_value_error_with_invalid_edfpath(self): - """Test that a value error is raised with the bdf path is invalid""" - bdf_path = 'invalid.pdf' - with self.assertRaises(ValueError): - convert_to_bdf(self.temp_dir, bdf_path=bdf_path) - - def test_convert_to_bdf_with_write_targetness(self): - """Test creating the EDF using targetness for event annotations""" - path = convert_to_bdf(self.temp_dir, - write_targetness=True) - - with warnings.catch_warnings(): - warnings.simplefilter('ignore') - edf = read_raw_bdf(path, preload=True) - - self.assertIn('target', edf.annotations.description) - self.assertIn('nontarget', edf.annotations.description) - - def test_convert_to_bdf_without_write_targetness(self): - """Test creating the EDF with labels as event annotations""" - path = convert_to_bdf(self.temp_dir, - write_targetness=False) - - with warnings.catch_warnings(): - warnings.simplefilter('ignore') - edf = read_raw_bdf(path, preload=True) - - self.assertNotIn('target', edf.annotations.description) - self.assertNotIn('nontarget', edf.annotations.description) - self.assertIn('+', edf.annotations.description) - - def test_convert_to_bdf_with_pre_filter(self): - """Test creating the BDF with pre-filtering""" - - data = mock() - channels = mock() - fs = 100 - events = mock() - annotation_channels = 1 - return_path = mock() - when(convert).pyedf_convert( - any_value(str), - write_targetness=any_value(bool), - use_event_durations=any_value(bool), - remove_pre_fixation=any_value(bool), - pre_filter=True, - ).thenReturn(( - data, - channels, - fs, - events, - annotation_channels, - 'filter_settings' - )) - when(convert).write_pyedf( - any_value(str), - data, - channels, - fs, - events, - any_value(int), - any_value(bool), - annotation_channels, - any_value(str)).thenReturn(return_path) - path = convert_to_bdf(self.temp_dir, - pre_filter=True) - - verify(convert, times=1).pyedf_convert( - any_value(str), - write_targetness=any_value(bool), - use_event_durations=any_value(bool), - remove_pre_fixation=any_value(bool), - pre_filter=True, - ) - verify(convert, times=1).write_pyedf( - any_value(str), - data, - channels, - fs, - events, - any_value(int), - any_value(bool), - annotation_channels, - any_value(str)) - self.assertEqual(path, return_path) - - -class TestPyedfconvert(unittest.TestCase): - - def setUp(self) -> None: - self.temp_dir = tempfile.mkdtemp() - self.sample_rate = 300 - self.filter_settings = { - 'filter_low': 0.5, - 'filter_high': 30, - 'filter_order': 5, - 'notch_filter_frequency': 60, - 'down_sampling_rate': 3 - } - create_bcipy_session_artifacts( - self.temp_dir, - sample_rate=self.sample_rate, - filter_settings=self.filter_settings, - samples=10000) # sample number determines the annotation count, set it high here! - - def tearDown(self) -> None: - shutil.rmtree(self.temp_dir) - verifyNoMoreInteractions() - unstub() - - def test_pyedf_convert_defaults(self): - """Test the pyedf_convert function""" - data, channels, fs, events, annotation_channels, pre_filter = pyedf_convert( - self.temp_dir) - - self.assertTrue(len(data) > 0) - self.assertTrue(len(channels) > 0) - self.assertEqual(fs, self.sample_rate) - self.assertTrue(len(events) > 2) - # see the validate_annotations function for details on how this is calculated - self.assertEqual(annotation_channels, 1) - self.assertFalse(pre_filter) - - def test_pyedf_convert_with_pre_filter(self): - """Test the pyedf_convert function with pre-filtering""" - data, channels, fs, events, annotation_channels, pre_filter = pyedf_convert( - self.temp_dir, pre_filter=True) - - self.assertTrue(len(data) > 0) - self.assertTrue(len(channels) > 0) - self.assertEqual(fs, self.sample_rate / self.filter_settings['down_sampling_rate']) - self.assertTrue(len(events) > 2) - # see the validate_annotations function for details on how this is calculated - # with less samples, the number of annotation channels increases - self.assertEqual(annotation_channels, 2) - self.assertIsInstance(pre_filter, str) - - def test_pyedf_convert_with_write_targetness(self): - """Test the pyedf_convert function with targetness for event annotations""" - data, channels, fs, events, annotation_channels, _ = pyedf_convert( - self.temp_dir, write_targetness=True, remove_pre_fixation=True) - - self.assertTrue(len(data) > 0) - self.assertTrue(len(channels) > 0) - self.assertEqual(fs, self.sample_rate) - self.assertTrue(len(events) > 2) - # see the validate_annotations function for details on how this is calculated - self.assertEqual(annotation_channels, 1) - for event in events: - _, _, label = event - # in this case label and targetness should not be the same - self.assertTrue(label in ['target', 'nontarget']) - - def test_pyedf_convert_without_write_targetness(self): - """Test the pyedf_convert function with labels as event annotations""" - data, channels, fs, events, annotation_channels, _ = pyedf_convert( - self.temp_dir, write_targetness=False) - - self.assertTrue(len(data) > 0) - self.assertTrue(len(channels) > 0) - self.assertEqual(fs, self.sample_rate) - self.assertTrue(len(events) > 2) - # see the validate_annotations function for details on how this is calculated - self.assertEqual(annotation_channels, 1) - for event in events: - _, _, label = event - self.assertTrue(label not in ['target', 'nontarget']) - - def test_pyedf_convert_with_use_event_durations(self): - """Test the pyedf_convert function with event durations""" - data, channels, fs, events, annotation_channels, _ = pyedf_convert( - self.temp_dir, use_event_durations=True) - - self.assertTrue(len(data) > 0) - self.assertTrue(len(channels) > 0) - self.assertEqual(fs, self.sample_rate) - self.assertTrue(len(events) > 2) - # see the validate_annotations function for details on how this is calculated - self.assertEqual(annotation_channels, 1) - for _, duration, _ in events: - self.assertTrue(duration > 0) - - def test_pyedf_convert_without_use_event_durations(self): - """Test the pyedf_convert function without event durations""" - data, channels, fs, events, annotation_channels, _ = pyedf_convert( - self.temp_dir, use_event_durations=False) - - self.assertTrue(len(data) > 0) - self.assertTrue(len(channels) > 0) - self.assertEqual(fs, self.sample_rate) - self.assertTrue(len(events) > 2) - # see the validate_annotations function for details on how this is calculated - self.assertEqual(annotation_channels, 1) - for _, duration, _ in events: - self.assertEqual(duration, 0) - - def test_pyedf_convert_with_remove_pre_fixation(self): - """Test the pyedf_convert function with pre-fixation removal""" - data, channels, fs, events, annotation_channels, _ = pyedf_convert( - self.temp_dir, remove_pre_fixation=True) - - self.assertTrue(len(data) > 0) - self.assertTrue(len(channels) > 0) - self.assertEqual(fs, self.sample_rate) - self.assertTrue(len(events) > 2) - # see the validate_annotations function for details on how this is calculated - self.assertEqual(annotation_channels, 1) - for event in events: - self.assertNotIn('fixation', event) - self.assertNotIn('prompt', event) - - -class TestMNEConvert(unittest.TestCase): - """Test the convert_to_mne function, which converts bcipy RawData into an mne format""" - - def setUp(self): - """Set up the test case with a temporary directory and sample data""" - self.temp_dir = tempfile.mkdtemp() - self.sample_rate = 300 - self.filter_settings = { - 'filter_low': 0.5, - 'filter_high': 30, - 'filter_order': 5, - 'notch_filter_frequency': 60, - 'down_sampling_rate': 3 - } - self.channels = ['timestamp', 'O1', 'O2', 'Pz'] - self.raw_data = RawData('SampleDevice', self.sample_rate, self.channels) - devices.register(devices.DeviceSpec('SampleDevice', channels=self.channels, sample_rate=self.sample_rate)) - - # generate 100 random samples of data - for _ in range(0, 100): - channel_data = gen_random_data(low=-1000, - high=1000, - channel_count=len(self.channels)) - self.raw_data.append(channel_data) - - def tearDown(self): - """Remove the temporary directory and its contents after each test""" - shutil.rmtree(self.temp_dir) - - def test_convert_to_mne_defaults(self): - """Test the convert_to_mne function with default parameters""" - data = convert_to_mne(self.raw_data) - - self.assertTrue(len(data) > 0) - self.assertEqual(data.ch_names, self.channels[1:]) - self.assertEqual(data.info['sfreq'], self.sample_rate) - - def test_convert_to_mne_with_channel_map(self): - """Test the convert_to_mne function with channel mapping""" - # here we know only three channels are generated, using the channel map let's only use the last one - channel_map = [0, 0, 1] - data = convert_to_mne(self.raw_data, channel_map=channel_map) - - self.assertTrue(len(data) > 0) - self.assertTrue(len(data.ch_names) == 1) # this is the main assertion! - self.assertEqual(data.info['sfreq'], self.sample_rate) - - def test_convert_to_mne_with_channel_types(self): - """Test the convert_to_mne function with channel types""" - channel_types = ['eeg', 'eeg', 'seeg'] - data = convert_to_mne(self.raw_data, channel_types=channel_types) - - self.assertTrue(len(data) > 0) - self.assertEqual(data.ch_names, self.channels[1:]) - self.assertEqual(data.info['sfreq'], self.sample_rate) - self.assertTrue(data.get_channel_types()[2] == 'seeg') - - def test_convert_to_mne_with_transform(self): - """Test the convert_to_mne function with a transform""" - multiplier = 2 - - def transform(x, fs): - return x * multiplier, fs - - data = convert_to_mne(self.raw_data, transform=transform, volts=True) - - self.assertTrue(len(data) > 0) - self.assertEqual(data.ch_names, self.channels[1:]) - self.assertEqual(data.info['sfreq'], self.sample_rate) - - # apply the transform to the first data point and compare to data returned - expected_first_data_point = self.raw_data.channel_data[0][0] * multiplier - self.assertTrue(data.get_data()[0][0] == expected_first_data_point) - - def test_convert_to_mne_with_mv_conversion(self): - """Test the convert_to_mne function with a mv conversion""" - data = convert_to_mne(self.raw_data, volts=False) - - self.assertTrue(len(data) > 0) - self.assertEqual(data.ch_names, self.channels[1:]) - self.assertEqual(data.info['sfreq'], self.sample_rate) - - # apply the transform to the first data point and compare to data returned - expected_first_data_point = self.raw_data.channel_data[0][0] * 1e-6 - self.assertTrue(data.get_data()[0][0] == expected_first_data_point) - - def test_convert_to_mne_with_custom_montage(self): - """Test the convert_to_mne function with a custom montage""" - - # see https://mne.tools/stable/auto_tutorials/intro/40_sensor_locations.html - # for more information on montages and available defaults - montage_type = 'biosemi64' - data = convert_to_mne(self.raw_data, montage=montage_type) - - self.assertTrue(len(data) > 0) - self.assertEqual(data.ch_names, self.channels[1:]) - self.assertEqual(data.info['sfreq'], self.sample_rate) - - -class TestCompressionSupport(unittest.TestCase): - - def setUp(self): - self.dir_name = 'test/' - self.tar_file_name = 'test_file' - self.tar_file_full_name = f'{self.tar_file_name}.tar.gz' - self.test_file_name = 'test.text' - with open(self.test_file_name, 'w', encoding=DEFAULT_ENCODING) as fp: - pass - - def tearDown(self): - os.remove(self.test_file_name) - - if os.path.exists(self.tar_file_full_name): - os.remove(self.tar_file_full_name) - - def test_compression_writes_tar_gz_no_extension(self): - # Test with no extension on tar name - compress(self.tar_file_name, [self.test_file_name]) - # Assert correct file was written - self.assertTrue(os.path.exists(self.tar_file_full_name)) - - def test_compression_writes_tar_gz_with_extension(self): - # Test with extension on tar name - compress(self.tar_file_full_name, [self.test_file_name]) - # Assert correct file was written - self.assertTrue(os.path.exists(self.tar_file_full_name)) - - def test_decompression_extracts_file_no_extension(self): - # Test with no extension on tar name - compress(self.tar_file_name, [self.test_file_name]) - decompress(self.tar_file_name, ".") - self.assertTrue(os.path.exists(self.test_file_name)) - - def test_decompression_extracts_file_with_extension(self): - # Test with extension on tar name - compress(self.tar_file_name, [self.test_file_name]) - decompress(self.tar_file_full_name, ".") - self.assertTrue(os.path.exists(self.test_file_name)) - - def test_file_not_found_error_thrown_on_compression(self): - garbage_name = 'not_possible_to_exist.biz' - with self.assertRaises(FileNotFoundError): - compress(self.tar_file_name, [garbage_name]) - - def test_file_list_returns_compressed_file_name_no_extension(self): - # Test with no extension on tar name - compress(self.tar_file_name, [self.test_file_name]) - tar_list = archive_list(self.tar_file_name) - self.assertTrue(tar_list[0] == self.test_file_name) - - def test_file_list_returns_compressed_file_name_with_extension(self): - # Test with extension on tar name - compress(self.tar_file_name, [self.test_file_name]) - tar_list = archive_list(self.tar_file_full_name) - self.assertTrue(tar_list[0] == self.test_file_name) - - -class TestConvertTobii(unittest.TestCase): - - def test_tobii_to_norm(self): - """Test the tobii_to_norm function""" - tobii_data = (0.5, 0.5) # center of screen in tobii coordinates - excepted_norm_data = (0, 0) # center of screen in norm coordinates - norm_data = tobii_to_norm(tobii_data) - self.assertEqual(norm_data, excepted_norm_data) - - tobii_data = (0, 0) # top left of screen in tobii coordinates - excepted_norm_data = (-1, 1) # top left of screen in norm coordinates - norm_data = tobii_to_norm(tobii_data) - self.assertEqual(norm_data, excepted_norm_data) - - tobii_data = (1, 1) # bottom right of screen in tobii coordinates - excepted_norm_data = (1, -1) # bottom right of screen in norm coordinates - norm_data = tobii_to_norm(tobii_data) - self.assertEqual(norm_data, excepted_norm_data) - - def test_tobii_to_norm_raises_error_with_invalid_units(self): - """Test the tobii_to_norm function raises an error with invalid units""" - tobii_data = (-1, 1) # invalid tobii coordinates - with self.assertRaises(AssertionError): - tobii_to_norm(tobii_data) - - tobii_data = (1, 11) # invalid tobii coordinates - - with self.assertRaises(AssertionError): - tobii_to_norm(tobii_data) - - def test_norm_to_tobii(self): - """Test the norm_to_tobii function""" - norm_data = (0, 0) # center of screen in norm coordinates - excepted_tobii_data = (0.5, 0.5) # center of screen in tobii coordinates - tobii_data = norm_to_tobii(norm_data) - self.assertEqual(tobii_data, excepted_tobii_data) - - norm_data = (-1, 1) # top left of screen in norm coordinates - excepted_tobii_data = (0, 0) # top left of screen in tobii coordinates - tobii_data = norm_to_tobii(norm_data) - self.assertEqual(tobii_data, excepted_tobii_data) - - norm_data = (1, -1) # bottom right of screen in norm coordinates - excepted_tobii_data = (1, 1) # bottom right of screen in tobii coordinates - tobii_data = norm_to_tobii(norm_data) - self.assertEqual(tobii_data, excepted_tobii_data) - - def test_norm_to_tobii_raises_error_with_invalid_units(self): - """Test the norm_to_tobii function raises an error with invalid units""" - norm_data = (-1.1, 1) - with self.assertRaises(AssertionError): - norm_to_tobii(norm_data) - - norm_data = (1, 1.1) - with self.assertRaises(AssertionError): - norm_to_tobii(norm_data) - - -if __name__ == '__main__': - unittest.main() diff --git a/bcipy/helpers/tests/test_copy_phrase_wrapper.py b/bcipy/helpers/tests/test_copy_phrase_wrapper.py index f04066d5e..ee08a6a57 100644 --- a/bcipy/helpers/tests/test_copy_phrase_wrapper.py +++ b/bcipy/helpers/tests/test_copy_phrase_wrapper.py @@ -1,7 +1,7 @@ import unittest from bcipy.helpers.copy_phrase_wrapper import CopyPhraseWrapper -from bcipy.helpers.symbols import alphabet +from bcipy.core.symbols import alphabet from bcipy.language.model.uniform import UniformLanguageModel from bcipy.task.data import EvidenceType diff --git a/bcipy/helpers/tests/test_offset.py b/bcipy/helpers/tests/test_offset.py index 58a240b95..7f0928af1 100644 --- a/bcipy/helpers/tests/test_offset.py +++ b/bcipy/helpers/tests/test_offset.py @@ -16,9 +16,9 @@ sample_rate_diffs, lsl_timestamp_diffs ) -from bcipy.helpers.load import load_raw_data -from bcipy.helpers.raw_data import RawData -from bcipy.helpers.triggers import trigger_decoder, TriggerType +from bcipy.io.load import load_raw_data +from bcipy.core.raw_data import RawData +from bcipy.core.triggers import trigger_decoder, TriggerType from bcipy.config import RAW_DATA_FILENAME, TRIGGER_FILENAME pwd = Path(__file__).absolute().parent diff --git a/bcipy/helpers/tests/test_system_utils.py b/bcipy/helpers/tests/test_system_utils.py index 29dfe9291..ead20a28b 100644 --- a/bcipy/helpers/tests/test_system_utils.py +++ b/bcipy/helpers/tests/test_system_utils.py @@ -1,5 +1,5 @@ import unittest -from bcipy.helpers.system_utils import ( +from bcipy.helpers.utils import ( is_connected, is_battery_powered, is_screen_refresh_rate_low, diff --git a/bcipy/helpers/tests/test_visualization.py b/bcipy/helpers/tests/test_visualization.py index e285a929d..49649bb0f 100644 --- a/bcipy/helpers/tests/test_visualization.py +++ b/bcipy/helpers/tests/test_visualization.py @@ -7,8 +7,8 @@ from bcipy.helpers.visualization import visualize_session_data from bcipy.helpers import visualization -from bcipy.helpers.raw_data import RawData -from bcipy.helpers.load import load_json_parameters +from bcipy.core.raw_data import RawData +from bcipy.io.load import load_json_parameters from bcipy.config import DEFAULT_PARAMETERS_PATH diff --git a/bcipy/helpers/system_utils.py b/bcipy/helpers/utils.py similarity index 100% rename from bcipy/helpers/system_utils.py rename to bcipy/helpers/utils.py diff --git a/bcipy/helpers/validate.py b/bcipy/helpers/validate.py index 7e6e75520..abfc50564 100644 --- a/bcipy/helpers/validate.py +++ b/bcipy/helpers/validate.py @@ -5,8 +5,8 @@ DEFAULT_FIELD_PATH, EXPERIMENT_FILENAME, FIELD_FILENAME) -from bcipy.helpers.load import load_experiments, load_fields -from bcipy.helpers.system_utils import is_battery_powered, is_connected, is_screen_refresh_rate_low +from bcipy.io.load import load_experiments, load_fields +from bcipy.helpers.utils import is_battery_powered, is_connected, is_screen_refresh_rate_low from bcipy.exceptions import (InvalidFieldException, InvalidExperimentException, UnregisteredExperimentException, diff --git a/bcipy/helpers/visualization.py b/bcipy/helpers/visualization.py index 8f4c14a3e..7eee8a30a 100644 --- a/bcipy/helpers/visualization.py +++ b/bcipy/helpers/visualization.py @@ -13,7 +13,6 @@ from matplotlib.figure import Figure from matplotlib.patches import Ellipse from mne import Epochs -from mne.io import read_raw_edf from scipy import linalg import bcipy.acquisition.devices as devices @@ -22,12 +21,12 @@ TRIGGER_FILENAME, SESSION_LOG_FILENAME, DEFAULT_PARAMETERS_PATH) from bcipy.helpers.acquisition import analysis_channels -from bcipy.helpers.convert import convert_to_mne -from bcipy.helpers.load import choose_csv_file, load_raw_data, load_json_parameters -from bcipy.helpers.parameters import Parameters -from bcipy.helpers.raw_data import RawData -from bcipy.helpers.stimuli import mne_epochs -from bcipy.helpers.triggers import TriggerType, trigger_decoder +from bcipy.io.convert import convert_to_mne +from bcipy.io.load import choose_csv_file, load_raw_data, load_json_parameters +from bcipy.core.parameters import Parameters +from bcipy.core.raw_data import RawData +from bcipy.core.stimuli import mne_epochs +from bcipy.core.triggers import TriggerType, trigger_decoder from bcipy.signal.process import (Composition, ERPTransformParams, get_default_transform) @@ -618,23 +617,6 @@ def visualize_results_all_symbols( return fig -def plot_edf(edf_path: str, auto_scale: Optional[bool] = False): - """Plot data from the raw edf file. Note: this works from an iPython - session but seems to throw errors when provided in a script. - - Parameters - ---------- - edf_path - full path to the generated edf file - auto_scale - optional; if True will scale the EEG data; this is - useful for fake (random) data but makes real data hard to read. - """ - edf = read_raw_edf(edf_path, preload=True) - if auto_scale: - edf.plot(scalings='auto') - else: - edf.plot() - - def visualize_csv_eeg_triggers(trigger_col: Optional[int] = None): """Visualize CSV EEG Triggers. diff --git a/bcipy/io/README.md b/bcipy/io/README.md new file mode 100644 index 000000000..38e1541d0 --- /dev/null +++ b/bcipy/io/README.md @@ -0,0 +1,7 @@ +# BciPy IO + +The BciPy IO module contains functionality for loading and saving data in various formats. This includes the ability to convert data to BIDS format, load and save raw data, and load and save triggers. + +- `convert`: functionality for converting the bcipy raw data output to other formats (currently, EDF) +- `load`: methods for loading most BciPy data. For loading of triggers, see triggers.py +- `save`: methods for saving BciPy data in supported formats. For saving of triggers, see triggers.py diff --git a/bcipy/io/__init__.py b/bcipy/io/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bcipy/io/convert.py b/bcipy/io/convert.py new file mode 100644 index 000000000..33adbe2ac --- /dev/null +++ b/bcipy/io/convert.py @@ -0,0 +1,403 @@ +# mypy: disable-error-code="no-redef" +"""Functionality for converting the bcipy raw data output to other formats""" +import logging +import os +import tarfile +from typing import List, Optional, Tuple + +import mne +from enum import Enum +from mne.io import RawArray +from mne_bids import BIDSPath, write_raw_bids +from tqdm import tqdm + +from bcipy.acquisition.devices import preconfigured_device +from bcipy.config import (DEFAULT_PARAMETERS_FILENAME, RAW_DATA_FILENAME, + TRIGGER_FILENAME, SESSION_LOG_FILENAME) +from bcipy.io.load import load_json_parameters, load_raw_data +from bcipy.core.raw_data import RawData +from bcipy.core.triggers import trigger_decoder +from bcipy.signal.process import Composition +# from bcipy.signal.process import get_default_transform + +logger = logging.getLogger(SESSION_LOG_FILENAME) + +FILE_LENGTH_LIMIT = 150 + + +class ConvertFormat(Enum): + + BV = 'BrainVision' + EDF = 'EDF' + FIF = 'FIF' + EEGLAB = 'EEGLAB' + + def __str__(self): + return self.value + + @staticmethod + def all(): + return [format for format in ConvertFormat] + + @staticmethod + def values(): + return [format.value for format in ConvertFormat] + + +def convert_to_bids( + data_dir: str, + participant_id: str, + session_id: str, + run_id: str, + output_dir: str, + task_name: Optional[str] = None, + line_frequency: float = 60, + format: ConvertFormat = ConvertFormat.BV) -> str: + """Convert to BIDS. + + Convert the raw data to the Brain Imaging Data Structure (BIDS) format. + The BIDS format is a standard for organizing and describing neuroimaging data. + See: https://bids.neuroimaging.io/. + + Currently, this function only supports EEG data. + + Parameters + ---------- + data_dir - path to the directory containing the raw data, triggers, and parameters + participant_id - the participant ID + output_dir - the directory to save the BIDS formatted data + + Returns + ------- + The path to the BIDS formatted data + """ + # validate the inputs + if not os.path.exists(data_dir): + raise FileNotFoundError(f"Data directory={data_dir} does not exist") + if not os.path.exists(output_dir): + try: + os.mkdir(output_dir) + except OSError as e: + raise OSError(f"Failed to create output directory={output_dir}") from e + if format not in ConvertFormat.all(): + raise ValueError(f"Unsupported format={format}") + if line_frequency not in [50, 60]: + raise ValueError("Line frequency must be 50 or 60 Hz") + + # create file paths for raw data, triggers, and parameters + raw_data_file = os.path.join(data_dir, f'{RAW_DATA_FILENAME}.csv') + trigger_file = os.path.join(data_dir, TRIGGER_FILENAME) + parameters_file = os.path.join(data_dir, DEFAULT_PARAMETERS_FILENAME) + + # load the raw data + raw_data = load_raw_data(raw_data_file) + device_spec = preconfigured_device(raw_data.daq_type) + volts = True if device_spec.channel_specs[0].units == 'volts' else False + + # load the parameters + parameters = load_json_parameters(parameters_file, value_cast=True) + trial_window = parameters.get("trial_window", (0.0, 0.5)) + + if task_name is None: + task_name = parameters.get("task") + if task_name is None: + raise ValueError("Task name must be provided or specified in the parameters") + + window_length = trial_window[1] - trial_window[0] + + # load the triggers without removing any triggers other than system triggers (default) + trigger_targetness, trigger_timing, trigger_labels = trigger_decoder( + trigger_path=trigger_file, + offset=device_spec.static_offset, + device_type='EEG', + ) + + # convert the raw data to MNE format + mne_data = convert_to_mne(raw_data, volts=volts, remove_system_channels=True) + targetness_annotations = mne.Annotations( + onset=trigger_timing, + duration=[window_length] * len(trigger_timing), + description=trigger_targetness, + ) + label_annotations = mne.Annotations( + onset=trigger_timing, + duration=[window_length] * len(trigger_timing), + description=trigger_labels, + ) + mne_data.set_annotations(targetness_annotations + label_annotations) + mne_data.info["line_freq"] = line_frequency # set the line frequency to 60 Hz + bids_path = BIDSPath( + subject=participant_id, + session=session_id, + task=task_name, + run=run_id, + datatype="eeg", + root=output_dir + ) + + # use the BIDS conversion function from MNE-BIDS + write_raw_bids( + mne_data, + bids_path, + format=format.value, + allow_preload=True, + overwrite=True) + + return bids_path.root + + +def compress(tar_file_name: str, members: List[str]) -> None: + """ + File compression and archiving. + + Adds files to a tar archive and compresses them using the gzip compression + format. + + Parameters + ---------- + tar_file_name (str): name of resulting compressed tar archive. Input string + can either include or not include file extension (.tar.gz), as this will + be checked. + members (List[str]): list of files and folders to be compressed into archive. + Each file or folder requires the relative file path and full file extension. + Individual file paths and names that are very long may throw an error + upon extraction, proceed with caution. + + Returns + ------- + None + """ + + for member in members: + # Checks if file exists + if not os.path.exists(member): + raise FileNotFoundError(f"This file or folder, '{member}', " + "does not exist!\nPlease rerun program") + # Checks for file names that may be too long. OS level restriction. + if len(member) > FILE_LENGTH_LIMIT: + logger.warning( + f'File length exceeds compression limit=[{FILE_LENGTH_LIMIT}]. ' + 'This may cause issues later with extraction. Please proceed at your own discretion.') + + full_tar_name = tar_name_checker(tar_file_name) + # Warns user that reopening tar archive that already exists will overwrite it + if os.path.exists(full_tar_name): + raise Exception(f"This tar archive=[{full_tar_name}] already exists, continuing will " + "overwrite anything in the existing archive.") + + # Opens file for gzip (gz) compressed writing (w) + # Uses default compression level 9 (highest compression, slowest speed) + with tarfile.open(full_tar_name, mode="w:gz") as tar: + # Sets progress bar through tqdm + progress = tqdm(members) + for member in progress: + # Adds file/folder to the tar file and compresses it + tar.add(member) + # Sets progress description of progress bar + progress.set_description(f"Compressing {member}") + + +def decompress(tar_file: str, path: str) -> None: + """ + Archive decompression and extraction. + + Takes .tar.gz archive, decompresses, and extracts its contents. Should only be + used with files created by compress() method. + + Parameters + ---------- + tar_file (str): name of .tar.gz archive to be decompressed. Input string + can either include or not include file extension (.tar.gz), as this will + be checked. + path (str): file path and name of folder for the extracted contents of the + archive. If only a name is entered, by default it will place the folder + in the current working directory. (ex. "extracted" creates a new folder + in the current directory and extracts the archive contents to it) + + Returns + ------- + None + """ + + full_tar_name = tar_name_checker(tar_file) + + # Checks if file exists + if not os.path.exists(full_tar_name): + raise FileNotFoundError(f"This file or folder, '{tar_file}', " + "does not exist!\nPlease rerun program") + + # Opens file for gzip (gz) compressed reading (r) + with tarfile.open(full_tar_name, mode="r:gz") as tar: + members = tar.getmembers() + + # Sets progress bar through tqdm + progress = tqdm(members) + for member in progress: + tar.extract(member, path=path) + # Sets progress description of progress bar + progress.set_description(f"Extracting {member.name}") + + +def archive_list(tar_file: str) -> List[str]: + """ + Returns contents of tar archive. + + Takes .tar.gz archive and returns a full list of its contents, including + folders, subfolders, and files, with their full relative paths and names. + + Parameters + ---------- + tar_file (str): name of desired .tar.gz archive. Input string can either + include or not include file extension (.tar.gz), as this will be checked. + + Returns + ------- + List[str] of all items in archive + """ + + full_tar_name = tar_name_checker(tar_file) + + # Checks if file exists + if not os.path.exists(full_tar_name): + raise FileNotFoundError(f"This file or folder, '{tar_file}', " + "does not exist!\nPlease rerun program") + + # Adds names of archive contents to list + with tarfile.open(full_tar_name, mode="r") as tar: + tar_list = tar.getnames() + return tar_list + + +def tar_name_checker(tar_file_name: str) -> str: + """ + Checks and modifies file name for tar archive. + + Helper method that takes a tar file name and checks it for the appropriate + file extension (".tar.gz" for now), returning it if it already does, and + adding the extension and then returning it if it does not. + + Parameters + ---------- + tar_file_name (str): name for archive being checked. + + Returns + ------- + String of properly formatted tar archive name + """ + + if tar_file_name.endswith('.tar.gz'): + return tar_file_name + return f'{tar_file_name}.tar.gz' + + +def convert_to_mne( + raw_data: RawData, + channel_map: Optional[List[int]] = None, + channel_types: Optional[List[str]] = None, + transform: Optional[Composition] = None, + montage: str = 'standard_1020', + volts: bool = False, + remove_system_channels: bool = False) -> RawArray: + """Convert to MNE. + + Returns BciPy RawData as an MNE RawArray. This assumes all channel names + are reflective of provided montage locations. + + Parameters + ---------- + raw_data - BciPy RawData object + channel_map - optional list of channels to include in the MNE RawArray [1, 0, 1, 0, 1, 0, 1, 0]. + 1 indicates the channel should be included, 0 indicates it should be excluded. + Must be the same length as the number of channels in the raw_data. + channel_types - list of channel types to include in the MNE RawArray. + If None, all channels will be assumed to be eeg. + See: https://mne.tools/stable/overview/implementation.html#supported-channel-types + transform - optional transform to apply to the data + montage - name of the channel location montage to use. + See https://mne.tools/dev/generated/mne.channels.make_standard_montage.html + volts - if True, assume data is already in volts. If false, assume data is in microvolts and convert to volts. + MNE expects data to be in volts. + See: https://mne.tools/dev/overview/implementation.html#internal-representation-units + remove_system_channels - if True, exclude the system and trigger channels from the MNE RawArray + (last two channels in BciPy data). + + Returns + ------- + MNE RawArray + """ + # if no channel map provided, assume all channels are included + if not channel_map: + # if remove_system_channels is True, exclude the system and trigger channels (last two channels) + if remove_system_channels: + channel_map = [1] * (len(raw_data.channels) - 2) + channel_map.extend([0, 0]) # exclude the system and trigger channels + else: + channel_map = [1] * len(raw_data.channels) + + data, channels, fs = raw_data.by_channel_map(channel_map, transform) + + # if no channel types provided, assume all channels are eeg + if not channel_types: + channel_types = ['eeg'] * len(channels) + + # check that number of channel types matches number of channels in the case custom channel types are provided + assert len(channel_types) == len(channels), \ + f'Number of channel types ({len(channel_types)}) must match number of channels ({len(channels)})' + + info = mne.create_info(channels, fs, channel_types) + mne_data = RawArray(data, info) + ten_twenty_montage = mne.channels.make_standard_montage(montage) + mne_data.set_montage(ten_twenty_montage) + + # convert to volts if necessary (the default for many systems is microvolts) + if not volts: + mne_data = mne_data.apply_function(lambda x: x * 1e-6) + + return mne_data + + +def tobii_to_norm(tobii_units: Tuple[float, float]) -> Tuple[float, float]: + """Tobii to PsychoPy's 'norm' units. + + https://developer.tobiipro.com/commonconcepts/coordinatesystems.html + https://www.psychopy.org/general/units.html + + Tobii uses an Active Display Coordinate System. + The point (0, 0) denotes the upper left corner and (1, 1) the lower right corner of it. + + PsychoPy uses several coordinate systems, the normalized window unit is assumed here. + The point (0, 0) denotes the center of the screen and (-1, -1) the upper left corner + and (1, 1) the lower right corner. + + """ + # check that Tobii units are within the expected range + assert 0 <= tobii_units[0] <= 1, "Tobii x coordinate must be between 0 and 1" + assert 0 <= tobii_units[1] <= 1, "Tobii y coordinate must be between 0 and 1" + + # convert Tobii units to Psychopy units + norm_x = (tobii_units[0] - 0.5) * 2 + norm_y = (tobii_units[1] - 0.5) * 2 * -1 + return (norm_x, norm_y) + + +def norm_to_tobii(norm_units: Tuple[float, float]) -> Tuple[float, float]: + """PsychoPy's 'norm' units to Tobii. + + https://developer.tobiipro.com/commonconcepts/coordinatesystems.html + https://www.psychopy.org/general/units.html + + Tobii uses an Active Display Coordinate System. + The point (0, 0) denotes the upper left corner and (1, 1) the lower right corner of it. + + PsychoPy uses several coordinate systems, the normalized window unit is assumed here. + The point (0, 0) denotes the center of the screen and (-1, -1) the upper left corner + and (1, 1) the lower right corner. + """ + # check that the coordinates are within the bounds of the screen + assert norm_units[0] >= -1 and norm_units[0] <= 1, "X coordinate must be between -1 and 1" + assert norm_units[1] >= -1 and norm_units[1] <= 1, "Y coordinate must be between -1 and 1" + + # convert PsychoPy norm units to Tobii units + tobii_x = (norm_units[0] / 2) + 0.5 + tobii_y = ((norm_units[1] * -1) / 2) + 0.5 + return (tobii_x, tobii_y) diff --git a/bcipy/io/demo/demo_convert.py b/bcipy/io/demo/demo_convert.py new file mode 100644 index 000000000..37e988215 --- /dev/null +++ b/bcipy/io/demo/demo_convert.py @@ -0,0 +1,77 @@ +"""Demonstrates converting BciPy data output to other EEG formats. + +To use at bcipy root, + + `python bcipy/io/demo/demo_convert.py -d "path://to/bcipy/data/folder"` +""" +from typing import Optional +from bcipy.io.convert import convert_to_bids, ConvertFormat +from bcipy.gui.file_dialog import ask_directory +from bcipy.io.load import load_bcipy_data + +EXCLUDED_TASKS = ['Report', 'Offline', 'Intertask', 'BAD'] + + +def convert_experiment_to_bids( + directory: str, + experiment_id: str, + format: ConvertFormat = ConvertFormat.BV, + output_dir: Optional[str] = None) -> None: + """Converts the data in the study folder to BIDS format.""" + + experiment_data = load_bcipy_data( + directory, + experiment_id=experiment_id, + excluded_tasks=EXCLUDED_TASKS, + anonymize=False) + if not output_dir: + output_dir = directory + + errors = [] + for data in experiment_data: + try: + convert_to_bids( + data_dir=data.path, + participant_id=data.user_id, + session_id=data.session_id, + run_id=data.run, + task_name=data.task_name, + output_dir=f'{output_dir}/bids_{experiment_id}/', + format=format + ) + except Exception as e: + print(f"Error converting {data.path} - {e}") + errors.append(str(data.path)) + + print("--------------------") + if errors: + print(f"Errors converting the following data: {errors}") + + print(f"\nData converted to BIDS format in {output_dir}/bids_{experiment_id}/") + print("--------------------") + + +if __name__ == '__main__': + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument( + '-d', + '--directory', + help='Path to the directory with raw_data to be converted', + required=False) + parser.add_argument( + '-e', + '--experiment', + help='Experiment ID to convert', + default='SCRD', + ) + + args = parser.parse_args() + + path = args.directory + if not path: + path = ask_directory("Select the directory with data to be converted") + + # convert a study to BIDS format + convert_experiment_to_bids(path, args.experiment, ConvertFormat.BV) diff --git a/bcipy/io/load.py b/bcipy/io/load.py new file mode 100644 index 000000000..691bc7476 --- /dev/null +++ b/bcipy/io/load.py @@ -0,0 +1,619 @@ +# mypy: disable-error-code="arg-type, union-attr" +import json +import logging +import os +import pickle +from pathlib import Path +from shutil import copyfile +from time import localtime, strftime +from typing import List, Optional, Union + +from bcipy.config import (DEFAULT_ENCODING, DEFAULT_EXPERIMENT_PATH, + DEFAULT_FIELD_PATH, DEFAULT_PARAMETERS_PATH, + DEFAULT_PARAMETERS_FILENAME, + EXPERIMENT_FILENAME, FIELD_FILENAME, + SIGNAL_MODEL_FILE_SUFFIX, SESSION_LOG_FILENAME) +from bcipy.gui.file_dialog import ask_directory, ask_filename +from bcipy.exceptions import (BciPyCoreException, + InvalidExperimentException) +from bcipy.core.parameters import Parameters +from bcipy.core.raw_data import RawData +from bcipy.preferences import preferences +from bcipy.signal.model import SignalModel + +log = logging.getLogger(SESSION_LOG_FILENAME) + + +def copy_parameters(path: str = DEFAULT_PARAMETERS_PATH, + destination: Optional[str] = None) -> str: + """Creates a copy of the given configuration (parameters.json) to the + given directory and returns the path. + + Parameters: + ----------- + path: str - optional path of parameters file to copy; used default if not provided. + destination: str - optional destination directory; default is the same + directory as the default parameters. + Returns: + -------- + path to the new file. + """ + default_dir = str(Path(DEFAULT_PARAMETERS_PATH).parent) + + destination = default_dir if destination is None else destination + filename = strftime('parameters_%Y-%m-%d_%Hh%Mm%Ss.json', localtime()) + + path = str(Path(destination, filename)) + copyfile(DEFAULT_PARAMETERS_PATH, path) + return path + + +def load_experiments(path: str = f'{DEFAULT_EXPERIMENT_PATH}/{EXPERIMENT_FILENAME}') -> dict: + """Load Experiments. + + PARAMETERS + ---------- + :param: path: string path to the experiments file. + + Returns + ------- + A dictionary of experiments, with the following format: + { name: { fields : {name: '', required: bool, anonymize: bool}, summary: '' } } + + """ + with open(path, 'r', encoding=DEFAULT_ENCODING) as json_file: + return json.load(json_file) + + +def extract_mode(bcipy_data_directory: str) -> str: + """Extract Mode. + + This method extracts the task mode from a BciPy data save directory. This is important for + trigger conversions and extracting targeteness. + + *note*: this is not compatible with older versions of BciPy (pre 1.5.0) where + the tasks and modes were considered together using integers (1, 2, 3). + + PARAMETERS + ---------- + :param: bcipy_data_directory: string path to the data directory + """ + directory = bcipy_data_directory.lower() + if 'calibration' in directory: + return 'calibration' + elif 'copy' in directory: + return 'copy_phrase' + raise BciPyCoreException(f'No valid mode could be extracted from [{directory}]') + + +def load_fields(path: str = f'{DEFAULT_FIELD_PATH}/{FIELD_FILENAME}') -> dict: + """Load Fields. + + PARAMETERS + ---------- + :param: path: string path to the fields file. + + Returns + ------- + A dictionary of fields, with the following format: + { + "field_name": { + "help_text": "", + "type": "" + } + + """ + with open(path, 'r', encoding=DEFAULT_ENCODING) as json_file: + return json.load(json_file) + + +def load_experiment_fields(experiment: dict) -> list: + """Load Experiment Fields. + + { + 'fields': [{}, {}], + 'summary': '' + } + + Using the experiment dictionary, loop over the field keys and put them in a list. + """ + if isinstance(experiment, dict): + try: + return [name for field in experiment['fields'] for name in field.keys()] + except KeyError: + raise InvalidExperimentException( + 'Experiment is not formatted correctly. It should be passed as a dictionary with the fields and' + f' summary keys. Fields is a list of dictionaries. Summary is a string. \n experiment=[{experiment}]') + raise TypeError('Unsupported experiment type. It should be passed as a dictionary with the fields and summary keys') + + +def load_json_parameters(path: str, value_cast: bool = False) -> Parameters: + """Load JSON Parameters. + + Given a path to a json of parameters, convert to a dictionary and optionally + cast the type. + + Expects the following format: + "fake_data": { + "value": "true", + "section": "bci_config", + "name": "Fake Data Sessions", + "helpTip": "If true, fake data server used", + "recommended": "", + "editable": "true", + "type": "bool" + } + + PARAMETERS + ---------- + :param: path: string path to the parameters file. + :param: value_case: True/False cast values to specified type. + + Returns + ------- + a Parameters object that behaves like a dict. + """ + return Parameters(source=path, cast_values=value_cast) + + +def load_experimental_data() -> str: + filename = ask_directory() # show dialog box and return the path + log.info("Loaded Experimental Data From: %s" % filename) + return filename + + +def load_signal_models(directory: Optional[str] = None) -> List[SignalModel]: + """Load all signal models in a given directory. + + Models are assumed to have been written using bcipy.helpers.save.save_model + function and should be serialized as pickled files. Note that reading + pickled files is a potential security concern so only load from trusted + directories. + + Args: + dirname (str, optional): Location of pretrained models. If not + provided the user will be prompted for a location. + """ + if not directory or Path(directory).is_file(): + directory = ask_directory() + + # update preferences + path = Path(directory) + preferences.signal_model_directory = str(path) + + models = [] + for file_path in path.glob(f"*{SIGNAL_MODEL_FILE_SUFFIX}"): + with open(file_path, "rb") as signal_file: + model = pickle.load(signal_file) + log.info(f"Loading model {model}") + models.append(model) + return models + + +def choose_signal_models(device_types: List[str]) -> List[SignalModel]: + """Prompt the user to load a signal model for each provided device. + + Parameters + ---------- + device_types - list of device content types (ex. 'EEG') + """ + return [ + model for model in map(choose_signal_model, set(device_types)) if model + ] + + +def load_signal_model(file_path: str) -> SignalModel: + """Load signal model from persisted file. + + Models are assumed to have been written using bcipy.io.save.save_model + function and should be serialized as pickled files. Note that reading + pickled files is a potential security concern so only load from trusted + directories.""" + + with open(file_path, "rb") as signal_file: + model = pickle.load(signal_file) + log.info(f"Loading model {model}") + return model + + +def choose_signal_model(device_type: str) -> Optional[SignalModel]: + """Present a file dialog prompting the user to select a signal model for + the given device. + + Parameters + ---------- + device_type - ex. 'EEG' or 'Eyetracker'; this should correspond with + the content_type of the DeviceSpec of the model. + """ + + file_path = ask_filename(file_types=f"*{SIGNAL_MODEL_FILE_SUFFIX}", + directory=preferences.signal_model_directory, + prompt=f"Select the {device_type} signal model") + + if file_path: + # update preferences + path = Path(file_path) + preferences.signal_model_directory = str(path) + return load_signal_model(str(path)) + return None + + +def choose_csv_file(filename: Optional[str] = None) -> Optional[str]: + """GUI prompt to select a csv file from the file system. + + Parameters + ---------- + - filename : optional filename to use; if provided the GUI is not shown. + + Returns + ------- + file name of selected file; throws an exception if the file is not a csv. + """ + if not filename: + filename = ask_filename('*.csv') + + # get the last part of the path to determine file type + file_name = filename.split('/')[-1] + + if 'csv' not in file_name: + raise Exception( + 'File type unrecognized. Please use a supported csv type') + + return filename + + +def load_raw_data(filename: Union[Path, str]) -> RawData: + """Reads the data (.csv) file written by data acquisition. + + Parameters + ---------- + - filename : path to the serialized data (csv file) + + Returns + ------- + RawData object with data held in memory + """ + return RawData.load(filename) + + +def load_users(data_save_loc: str) -> List[str]: + """Load Users. + + Loads user directory names below experiments from the data path defined and returns them as a list. + If the save data directory is not found, this method returns an empty list assuming no experiments + have been run yet. + """ + try: + bcipy_data = BciPyCollection(data_directory=data_save_loc) + bcipy_data.load_users() + except FileNotFoundError: + return [] + return bcipy_data.users + + +def fast_scandir(directory_name: str, return_path: bool = True) -> List[str]: + """Fast Scan Directory. + + directory_name: name of the directory to be scanned + return_path: whether or not to return the scanned directories as a relative path or name. + False will return the directory name only. + """ + if return_path: + return [f.path for f in os.scandir(directory_name) if f.is_dir()] + + return [f.name for f in os.scandir(directory_name) if f.is_dir()] + + +class BciPySessionTaskData: + """Session Task Data. + + This class is used to represent a single task data session. It is used to store the + path to the task data, as well as the parameters and other information about the task. + + ////// + protocol.json + / + parameters.json + **task_data** + + """ + + def __init__( + self, + path: str, + user_id: str, + date: str, + experiment_id: str, + date_time: str, + task: str, + run: int = 1) -> None: + + self.user_id = user_id + self.date = date + self.experiment_id = experiment_id.replace('_', '') + self.date_time = date_time.replace("_", "").replace("-", "") + self.session_id = f'{self.experiment_id}{self.date_time}' + self.date_time = date_time + self.task = task + self.run = str(run) + self.path = path + self.parameters = self.get_parameters() + self.task_name = self.parameters.get('task', 'unknown').replace(' ', '') + self.info = { + 'user_id': user_id, + 'date': date, + 'experiment_id': self.experiment_id, + 'date_time': self.date_time, + 'task': task, + 'task_name': self.task_name, + 'run': run, + 'path': path + } + + def get_parameters(self) -> Parameters: + return load_json_parameters( + f'{self.path}/{DEFAULT_PARAMETERS_FILENAME}', + value_cast=True) + + def __str__(self): + return f'BciPySessionTaskData: {self.info=}' + + def __repr__(self): + return f'BciPySessionTaskData: {self.info=}' + + +class BciPyCollection: + """BciPy Data. + + This class is used to represent a full BciPy data collection. It is used to collect data from the + data directory and filter based on the provided filters. + """ + + def __init__( + self, + data_directory: str, + experiment_id_filter: Optional[str] = None, + user_id_filter: Optional[str] = None, + date_filter: Optional[str] = None, + date_time_filter: Optional[str] = None, + excluded_tasks: Optional[List[str]] = None, + anonymize: bool = False) -> None: + if not os.path.isdir(data_directory): + raise FileNotFoundError( + f'Data directory not found at [{data_directory}]') + self.data_directory = data_directory + + self.experiment_id_filter = experiment_id_filter + self.user_id_filter = user_id_filter + self.date_filter = date_filter + self.date_time_filter = date_time_filter + self.excluded_tasks = excluded_tasks + self.anonymize = anonymize + + # Initialize the data lists + self.session_task_data: List[BciPySessionTaskData] = [] + self.user_paths: List[str] = [] + self.date_paths: List[str] = [] + self.experiment_paths: List[str] = [] + self.date_time_paths: List[str] = [] + self.task_paths: List[str] = [] + + def __repr__(self): + return f'BciPyCollection: {self.data_directory=}' + + def __str__(self): + return f'BciPyCollection: {self.data_directory=}' + + @property + def users(self) -> List[str]: + return [user.split('/')[-1] for user in self.user_paths] + + @property + def experiments(self) -> List[str]: + experiments = [experiment.split('/')[-1] for experiment in self.experiment_paths] + # remove duplicates from the list + return list(set(experiments)) + + @property + def dates(self) -> List[str]: + dates = [date.split('/')[-1] for date in self.date_paths] + # remove duplicates from the list + return list(set(dates)) + + @property + def date_times(self) -> List[str]: + date_times = [date_time.split('/')[-1] for date_time in self.date_time_paths] + # remove duplicates from the list + return list(set(date_times)) + + @property + def tasks(self) -> List[str]: + tasks = [task.task_name for task in self.session_task_data] + # remove duplicates from the list + return list(set(tasks)) + + def collect(self) -> List[BciPySessionTaskData]: + """Collect. + + Collects the BciPy data from the data directory and returns a list of BciPySessionTaskData objects. + """ + if not self.session_task_data: + self.load_tasks() + + # if anonymize is set, return the anonymized data. Make a map of user_id to anonymized id + if self.anonymize: + user_map = {} + user_id_increment = 1 + for task in self.session_task_data: + if task.user_id not in user_map: + user_map[task.user_id] = f'ID{user_id_increment}' + user_id_increment += 1 + task.user_id = user_map[task.user_id] + log.info(f"Anonymized user ids: {user_map}") + + return self.session_task_data + + def load_users(self) -> None: + """Load Users. + + Walks the data directory and sets the user paths. It will filter by the user id if provided. + """ + user_paths = fast_scandir(self.data_directory, return_path=True) + if self.user_id_filter: + self.user_paths = [user for user in user_paths if self.user_id_filter in user] + else: + self.user_paths = user_paths + + def load_dates(self) -> None: + """Load Dates. + + Walks the data directory and sets the date paths. It will filter by the date if provided. + """ + if not self.user_paths: + self.load_users() + + for user in self.user_paths: + data_paths = fast_scandir(user, return_path=True) + if self.date_filter: + self.date_paths.extend([data for data in data_paths if self.date_filter in data]) + else: + self.date_paths.extend(data_paths) + + def load_experiments(self) -> None: + """Load Experiments. + + Walks the data directory and sets the experiment paths. It will filter by the experiment id if provided. + """ + if not self.date_paths: + self.load_dates() + + for date in self.date_paths: + experiment_paths = fast_scandir(date, return_path=True) + if self.experiment_id_filter: + self.experiment_paths.extend([data for data in experiment_paths if self.experiment_id_filter in data]) + else: + self.experiment_paths.extend(experiment_paths) + + def load_date_times(self) -> None: + """Load Date Times. + + Walks the data directory and sets the date time paths. It will filter by the date time if provided. + """ + if not self.experiment_paths: + self.load_experiments() + + for experiment in self.experiment_paths: + data_paths = fast_scandir(experiment, return_path=True) + if self.date_time_filter: + self.date_time_paths.extend([data for data in data_paths if self.date_time_filter in data]) + else: + self.date_time_paths.extend(data_paths) + + def sort_tasks(self, tasks: List[str]) -> List[str]: + """Sort Tasks. + + Sorts the tasks in the order they were run using the timestamp at the end of the task path. + """ + return sorted(tasks, key=lambda x: x.split('_')[-1]) + + def load_tasks(self) -> None: + """Load Tasks. + + Walks the data directory and sets the session_task_data representing the experiment data. + It will exclude tasks that are in the excluded_tasks list. + """ + if not self.date_time_paths: + self.load_date_times() + + for date_time in self.date_time_paths: + tasks = fast_scandir(date_time, return_path=True) + run = 1 + tasks = self.sort_tasks(tasks) + for task in tasks: + task_path = Path(task) + task = task_path.parts[-1] + + # skip excluded tasks + for excluded_task in self.excluded_tasks: + if excluded_task in task: + log.info(f'Skipping excluded task [{task}]') + skip = True + break + else: + skip = False + + if skip: + continue + + user_id = task_path.parts[-5] + date = task_path.parts[-4] + experiment_id = task_path.parts[-3] + date_time = task_path.parts[-2] + self.session_task_data.append( + BciPySessionTaskData( + path=task_path, + user_id=user_id, + date=date, + experiment_id=experiment_id, + date_time=date_time, + run=run, + task=task + ) + ) + run += 1 + + self.task_paths = [task.path for task in self.session_task_data] + + +def load_bcipy_data( + data_directory: str, + experiment_id: Optional[str] = None, + user_id: Optional[str] = None, + date: Optional[str] = None, + date_time: Optional[str] = None, + excluded_tasks: Optional[List[str]] = None, + anonymize: bool = False) -> List[BciPySessionTaskData]: + """Load BciPy Data. + + Walks a data directory and returns a list of data paths for the given experiment id, user id, and date. + + The BciPy data directory is structured as follows: + data_directory/ + user_ids/ + dates/ + experiment_ids/ + datetimes/ + protocol.json + logs/ + tasks/ + raw_data.csv + triggers.txt + + data_directory: the bcipy data directory to walk + experiment_id: the experiment id to filter by + user_id: the user id to filter by + date: the date to filter by + date_time: the date time to filter by + excluded_tasks: a list of tasks to exclude from the returned list of experiment data + anonymize: whether or not to anonymize the user ids + + Returns: + -------- + a list of BciPySessionTaskData objects representing the experiment data + """ + if not excluded_tasks: + excluded_tasks = [] + + # add logs to the excluded tasks + excluded_tasks.append('logs') + + bcipy_data = BciPyCollection( + data_directory=data_directory, + experiment_id_filter=experiment_id, + user_id_filter=user_id, + date_filter=date, + date_time_filter=date_time, + excluded_tasks=excluded_tasks, + anonymize=anonymize + ) + return bcipy_data.collect() diff --git a/bcipy/helpers/save.py b/bcipy/io/save.py similarity index 100% rename from bcipy/helpers/save.py rename to bcipy/io/save.py diff --git a/bcipy/io/tests/test_convert.py b/bcipy/io/tests/test_convert.py new file mode 100644 index 000000000..3898e5801 --- /dev/null +++ b/bcipy/io/tests/test_convert.py @@ -0,0 +1,449 @@ +"""Tests for data conversion related functionality.""" +import os +import shutil +import tempfile +import unittest +from pathlib import Path +from typing import Tuple, Union + +import bcipy.acquisition.devices as devices +from bcipy.config import (DEFAULT_ENCODING, DEFAULT_PARAMETERS_FILENAME, + RAW_DATA_FILENAME, TRIGGER_FILENAME) +# from bcipy.io import convert +from bcipy.io.convert import ( + archive_list, + compress, + ConvertFormat, + convert_to_bids, + convert_to_mne, + decompress, + norm_to_tobii, + tobii_to_norm +) +from bcipy.core.parameters import Parameters +from bcipy.core.raw_data import RawData, sample_data, write +from bcipy.core.triggers import MOCK_TRIGGER_DATA +from bcipy.signal.generator.generator import gen_random_data + + +CHANNEL_NAMES = [ + 'Fp1', 'Fp2', 'F3', 'F4', 'C3', 'C4', 'P3', 'P4', + 'O1', 'O2', 'F7', 'F8', 'T3', 'T4', 'T5', 'T6', 'Fz', 'Cz', 'Pz'] + + +def create_bcipy_session_artifacts( + write_dir: str, + channels: Union[int, list] = 3, + sample_rate: int = 300, + samples: int = 5000, + filter_settings: dict = { + 'filter_low': 0.5, + 'filter_high': 30, + 'filter_order': 5, + 'notch_filter_frequency': 60, + 'down_sampling_rate': 3 + }, +) -> Tuple[str, RawData, Parameters]: + """Write BciPy session artifacts to a temporary directory. + + This includes a raw data file, trigger file, and a parameters file. + """ + trg_data = MOCK_TRIGGER_DATA + if isinstance(channels, int): + + channels = [CHANNEL_NAMES[i] for i in range(channels)] + data = sample_data(ch_names=channels, daq_type='SampleDevice', sample_rate=sample_rate, rows=samples) + devices.register(devices.DeviceSpec('SampleDevice', channels=channels, sample_rate=sample_rate)) + + with open(Path(write_dir, TRIGGER_FILENAME), 'w', encoding=DEFAULT_ENCODING) as trg_file: + trg_file.write(trg_data) + + write(data, Path(write_dir, f'{RAW_DATA_FILENAME}.csv')) + + params = Parameters.from_cast_values(raw_data_name=f'{RAW_DATA_FILENAME}.csv', + trigger_file_name=TRIGGER_FILENAME, + preview_inquiry_length=0.5, + time_fixation=0.5, + time_prompt=0.5, + time_flash=0.5, + # define filter settings + down_sampling_rate=filter_settings['down_sampling_rate'], + notch_filter_frequency=filter_settings['notch_filter_frequency'], + filter_high=filter_settings['filter_high'], + filter_low=filter_settings['filter_low'], + filter_order=filter_settings['filter_order']) + params.save(write_dir, DEFAULT_PARAMETERS_FILENAME) + return trg_data, data, params + + +class TestBIDSConversion(unittest.TestCase): + + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + self.trg_data, self.data, self.params = create_bcipy_session_artifacts(self.temp_dir) + + def tearDown(self): + shutil.rmtree(self.temp_dir) + + def test_convert_to_bids_generates_bids_strucutre(self): + """Test the convert_to_bids function""" + response = convert_to_bids( + f"{self.temp_dir}", + participant_id='01', + session_id='01', + run_id='01', + task_name='TestTask', + output_dir=self.temp_dir, + ) + self.assertTrue(os.path.exists(response)) + # Assert that the BIDS structure was created + self.assertTrue(os.path.exists(f"{self.temp_dir}")) + # Assert the session directory was created with eeg + self.assertTrue(os.path.exists(f"{self.temp_dir}/sub-01/ses-01/eeg/")) + # Assert the eeg file was created (default of BV format) + self.assertTrue(os.path.exists( + f"{self.temp_dir}/sub-01/ses-01/eeg/sub-01_ses-01_task-TestTask_run-01_eeg.vhdr")) + # Assert the events file was created + self.assertTrue(os.path.exists( + f"{self.temp_dir}/sub-01/ses-01/eeg/sub-01_ses-01_task-TestTask_run-01_events.tsv")) + # Assert the channels file was created + self.assertTrue(os.path.exists( + f"{self.temp_dir}/sub-01/ses-01/eeg/sub-01_ses-01_task-TestTask_run-01_channels.tsv")) + + def test_convert_to_bids_reflects_participant_id(self): + """Test the convert_to_bids function with a participant id""" + response = convert_to_bids( + f"{self.temp_dir}", + participant_id='100', + session_id='01', + run_id='01', + task_name='TestTask', + output_dir=self.temp_dir, + ) + self.assertTrue(os.path.exists(response)) + self.assertTrue(os.path.exists(f"{self.temp_dir}/sub-100/")) + + def test_convert_to_bids_reflects_session_id(self): + """Test the convert_to_bids function with a session id""" + response = convert_to_bids( + f"{self.temp_dir}", + participant_id='01', + session_id='100', + run_id='01', + task_name='TestTask', + output_dir=self.temp_dir, + ) + self.assertTrue(os.path.exists(response)) + self.assertTrue(os.path.exists(f"{self.temp_dir}/sub-01/ses-100/")) + + def test_convert_to_bids_reflects_run_id(self): + """Test the convert_to_bids function with a run id""" + response = convert_to_bids( + f"{self.temp_dir}", + participant_id='01', + session_id='01', + run_id='100', + task_name='TestTask', + output_dir=self.temp_dir, + ) + self.assertTrue(os.path.exists(response)) + self.assertTrue(os.path.exists( + f"{self.temp_dir}/sub-01/ses-01/eeg/sub-01_ses-01_task-TestTask_run-100_eeg.vhdr")) + + def test_convert_to_bids_reflects_task_name(self): + """Test the convert_to_bids function with a task name""" + response = convert_to_bids( + f"{self.temp_dir}", + participant_id='01', + session_id='01', + run_id='01', + task_name='TestTaskEtc', + output_dir=self.temp_dir, + ) + self.assertTrue(os.path.exists(response)) + self.assertTrue(os.path.exists( + f"{self.temp_dir}/sub-01/ses-01/eeg/sub-01_ses-01_task-TestTaskEtc_run-01_eeg.vhdr")) + + def test_convert_to_bids_edf(self): + """Test the convert_to_bids function with edf format""" + response = convert_to_bids( + f"{self.temp_dir}", + participant_id='01', + session_id='01', + run_id='01', + task_name='TestTask', + output_dir=self.temp_dir, + format=ConvertFormat.EDF + ) + + self.assertTrue(os.path.exists(response)) + # Assert that the BIDS structure was created + self.assertTrue(os.path.exists(f"{self.temp_dir}")) + # Assert the session directory was created with eeg + self.assertTrue(os.path.exists(f"{self.temp_dir}/sub-01/ses-01/eeg/")) + # Assert the eeg file was created (edf format) + self.assertTrue(os.path.exists( + f"{self.temp_dir}/sub-01/ses-01/eeg/sub-01_ses-01_task-TestTask_run-01_eeg.edf")) + # Assert the events file was created + self.assertTrue(os.path.exists( + f"{self.temp_dir}/sub-01/ses-01/eeg/sub-01_ses-01_task-TestTask_run-01_events.tsv")) + # Assert the channels file was created + self.assertTrue(os.path.exists( + f"{self.temp_dir}/sub-01/ses-01/eeg/sub-01_ses-01_task-TestTask_run-01_channels.tsv")) + + def test_convert_to_bids_raises_error_with_invalid_format(self): + """Test the convert_to_bids function raises an error with invalid format""" + with self.assertRaises(ValueError): + convert_to_bids( + f"{self.temp_dir}", + participant_id='01', + session_id='01', + run_id='01', + task_name='TestTask', + output_dir=self.temp_dir, + format='invalid_format' + ) + + def test_convert_to_bids_raises_error_with_invalid_data_dir(self): + """Test the convert_to_bids function raises an error with invalid output directory""" + with self.assertRaises(FileNotFoundError): + convert_to_bids( + 'invalid_data_dir', + participant_id='01', + session_id='01', + run_id='01', + task_name='TestTask', + output_dir=self.temp_dir + ) + + def test_convert_to_bids_raises_error_with_invalid_line_freq(self): + """Test the convert_to_bids function raises an error with invalid line frequency""" + with self.assertRaises(ValueError): + convert_to_bids( + f"{self.temp_dir}", + participant_id='01', + session_id='01', + run_id='01', + task_name='TestTask', + output_dir=self.temp_dir, + line_frequency=0 + ) + + +class TestMNEConvert(unittest.TestCase): + """Test the convert_to_mne function, which converts bcipy RawData into an mne format""" + + def setUp(self): + """Set up the test case with a temporary directory and sample data""" + self.temp_dir = tempfile.mkdtemp() + self.sample_rate = 300 + self.filter_settings = { + 'filter_low': 0.5, + 'filter_high': 30, + 'filter_order': 5, + 'notch_filter_frequency': 60, + 'down_sampling_rate': 3 + } + self.channels = ['timestamp', 'O1', 'O2', 'Pz'] + self.raw_data = RawData('SampleDevice', self.sample_rate, self.channels) + devices.register(devices.DeviceSpec('SampleDevice', channels=self.channels, sample_rate=self.sample_rate)) + + # generate 100 random samples of data + for _ in range(0, 100): + channel_data = gen_random_data(low=-1000, + high=1000, + channel_count=len(self.channels)) + self.raw_data.append(channel_data) + + def tearDown(self): + """Remove the temporary directory and its contents after each test""" + shutil.rmtree(self.temp_dir) + + def test_convert_to_mne_defaults(self): + """Test the convert_to_mne function with default parameters""" + data = convert_to_mne(self.raw_data) + + self.assertTrue(len(data) > 0) + self.assertEqual(data.ch_names, self.channels[1:]) + self.assertEqual(data.info['sfreq'], self.sample_rate) + + def test_convert_to_mne_with_channel_map(self): + """Test the convert_to_mne function with channel mapping""" + # here we know only three channels are generated, using the channel map let's only use the last one + channel_map = [0, 0, 1] + data = convert_to_mne(self.raw_data, channel_map=channel_map) + + self.assertTrue(len(data) > 0) + self.assertTrue(len(data.ch_names) == 1) # this is the main assertion! + self.assertEqual(data.info['sfreq'], self.sample_rate) + + def test_convert_to_mne_with_channel_types(self): + """Test the convert_to_mne function with channel types""" + channel_types = ['eeg', 'eeg', 'seeg'] + data = convert_to_mne(self.raw_data, channel_types=channel_types) + + self.assertTrue(len(data) > 0) + self.assertEqual(data.ch_names, self.channels[1:]) + self.assertEqual(data.info['sfreq'], self.sample_rate) + self.assertTrue(data.get_channel_types()[2] == 'seeg') + + def test_convert_to_mne_with_transform(self): + """Test the convert_to_mne function with a transform""" + multiplier = 2 + + def transform(x, fs): + return x * multiplier, fs + + data = convert_to_mne(self.raw_data, transform=transform, volts=True) + + self.assertTrue(len(data) > 0) + self.assertEqual(data.ch_names, self.channels[1:]) + self.assertEqual(data.info['sfreq'], self.sample_rate) + + # apply the transform to the first data point and compare to data returned + expected_first_data_point = self.raw_data.channel_data[0][0] * multiplier + self.assertTrue(data.get_data()[0][0] == expected_first_data_point) + + def test_convert_to_mne_with_mv_conversion(self): + """Test the convert_to_mne function with a mv conversion""" + data = convert_to_mne(self.raw_data, volts=False) + + self.assertTrue(len(data) > 0) + self.assertEqual(data.ch_names, self.channels[1:]) + self.assertEqual(data.info['sfreq'], self.sample_rate) + + # apply the transform to the first data point and compare to data returned + expected_first_data_point = self.raw_data.channel_data[0][0] * 1e-6 + self.assertTrue(data.get_data()[0][0] == expected_first_data_point) + + def test_convert_to_mne_with_custom_montage(self): + """Test the convert_to_mne function with a custom montage""" + + # see https://mne.tools/stable/auto_tutorials/intro/40_sensor_locations.html + # for more information on montages and available defaults + montage_type = 'biosemi64' + data = convert_to_mne(self.raw_data, montage=montage_type) + + self.assertTrue(len(data) > 0) + self.assertEqual(data.ch_names, self.channels[1:]) + self.assertEqual(data.info['sfreq'], self.sample_rate) + + +class TestCompressionSupport(unittest.TestCase): + + def setUp(self): + self.dir_name = 'test/' + self.tar_file_name = 'test_file' + self.tar_file_full_name = f'{self.tar_file_name}.tar.gz' + self.test_file_name = 'test.text' + with open(self.test_file_name, 'w', encoding=DEFAULT_ENCODING) as fp: + pass + + def tearDown(self): + os.remove(self.test_file_name) + + if os.path.exists(self.tar_file_full_name): + os.remove(self.tar_file_full_name) + + def test_compression_writes_tar_gz_no_extension(self): + # Test with no extension on tar name + compress(self.tar_file_name, [self.test_file_name]) + # Assert correct file was written + self.assertTrue(os.path.exists(self.tar_file_full_name)) + + def test_compression_writes_tar_gz_with_extension(self): + # Test with extension on tar name + compress(self.tar_file_full_name, [self.test_file_name]) + # Assert correct file was written + self.assertTrue(os.path.exists(self.tar_file_full_name)) + + def test_decompression_extracts_file_no_extension(self): + # Test with no extension on tar name + compress(self.tar_file_name, [self.test_file_name]) + decompress(self.tar_file_name, ".") + self.assertTrue(os.path.exists(self.test_file_name)) + + def test_decompression_extracts_file_with_extension(self): + # Test with extension on tar name + compress(self.tar_file_name, [self.test_file_name]) + decompress(self.tar_file_full_name, ".") + self.assertTrue(os.path.exists(self.test_file_name)) + + def test_file_not_found_error_thrown_on_compression(self): + garbage_name = 'not_possible_to_exist.biz' + with self.assertRaises(FileNotFoundError): + compress(self.tar_file_name, [garbage_name]) + + def test_file_list_returns_compressed_file_name_no_extension(self): + # Test with no extension on tar name + compress(self.tar_file_name, [self.test_file_name]) + tar_list = archive_list(self.tar_file_name) + self.assertTrue(tar_list[0] == self.test_file_name) + + def test_file_list_returns_compressed_file_name_with_extension(self): + # Test with extension on tar name + compress(self.tar_file_name, [self.test_file_name]) + tar_list = archive_list(self.tar_file_full_name) + self.assertTrue(tar_list[0] == self.test_file_name) + + +class TestConvertTobii(unittest.TestCase): + + def test_tobii_to_norm(self): + """Test the tobii_to_norm function""" + tobii_data = (0.5, 0.5) # center of screen in tobii coordinates + excepted_norm_data = (0, 0) # center of screen in norm coordinates + norm_data = tobii_to_norm(tobii_data) + self.assertEqual(norm_data, excepted_norm_data) + + tobii_data = (0, 0) # top left of screen in tobii coordinates + excepted_norm_data = (-1, 1) # top left of screen in norm coordinates + norm_data = tobii_to_norm(tobii_data) + self.assertEqual(norm_data, excepted_norm_data) + + tobii_data = (1, 1) # bottom right of screen in tobii coordinates + excepted_norm_data = (1, -1) # bottom right of screen in norm coordinates + norm_data = tobii_to_norm(tobii_data) + self.assertEqual(norm_data, excepted_norm_data) + + def test_tobii_to_norm_raises_error_with_invalid_units(self): + """Test the tobii_to_norm function raises an error with invalid units""" + tobii_data = (-1, 1) # invalid tobii coordinates + with self.assertRaises(AssertionError): + tobii_to_norm(tobii_data) + + tobii_data = (1, 11) # invalid tobii coordinates + + with self.assertRaises(AssertionError): + tobii_to_norm(tobii_data) + + def test_norm_to_tobii(self): + """Test the norm_to_tobii function""" + norm_data = (0, 0) # center of screen in norm coordinates + excepted_tobii_data = (0.5, 0.5) # center of screen in tobii coordinates + tobii_data = norm_to_tobii(norm_data) + self.assertEqual(tobii_data, excepted_tobii_data) + + norm_data = (-1, 1) # top left of screen in norm coordinates + excepted_tobii_data = (0, 0) # top left of screen in tobii coordinates + tobii_data = norm_to_tobii(norm_data) + self.assertEqual(tobii_data, excepted_tobii_data) + + norm_data = (1, -1) # bottom right of screen in norm coordinates + excepted_tobii_data = (1, 1) # bottom right of screen in tobii coordinates + tobii_data = norm_to_tobii(norm_data) + self.assertEqual(tobii_data, excepted_tobii_data) + + def test_norm_to_tobii_raises_error_with_invalid_units(self): + """Test the norm_to_tobii function raises an error with invalid units""" + norm_data = (-1.1, 1) + with self.assertRaises(AssertionError): + norm_to_tobii(norm_data) + + norm_data = (1, 1.1) + with self.assertRaises(AssertionError): + norm_to_tobii(norm_data) + + +if __name__ == '__main__': + unittest.main() diff --git a/bcipy/helpers/tests/test_load.py b/bcipy/io/tests/test_load.py similarity index 60% rename from bcipy/helpers/tests/test_load.py rename to bcipy/io/tests/test_load.py index ceaaf6890..cb479eee5 100644 --- a/bcipy/helpers/tests/test_load.py +++ b/bcipy/io/tests/test_load.py @@ -12,12 +12,14 @@ DEFAULT_FIELD_PATH, DEFAULT_PARAMETERS_PATH, EXPERIMENT_FILENAME, FIELD_FILENAME) from bcipy.exceptions import BciPyCoreException, InvalidExperimentException -from bcipy.helpers.load import (choose_signal_model, choose_signal_models, - copy_parameters, extract_mode, - load_experiment_fields, load_experiments, - load_fields, load_json_parameters, - load_signal_model, load_users) -from bcipy.helpers.parameters import Parameters +from bcipy.io.load import (choose_signal_model, choose_signal_models, + copy_parameters, extract_mode, + load_experiment_fields, load_experiments, + load_bcipy_data, + load_fields, load_json_parameters, + load_signal_model, load_users) +from bcipy.io.tests.test_convert import create_bcipy_session_artifacts +from bcipy.core.parameters import Parameters MOCK_EXPERIMENT = { "test": { @@ -213,8 +215,8 @@ def test_extract_mode_without_mode_defined(self): class TestModelLoad(unittest.TestCase): """Test loading one or more signal models""" - @patch("bcipy.helpers.load.pickle.load") - @patch("bcipy.helpers.load.open") + @patch("bcipy.io.load.pickle.load") + @patch("bcipy.io.load.open") def test_load_model(self, open_mock, pickle_mock): """Test loading a signal model""" @@ -222,9 +224,9 @@ def test_load_model(self, open_mock, pickle_mock): open_mock.assert_called_with("test-directory", 'rb') pickle_mock.assert_called_once() - @patch("bcipy.helpers.load.load_signal_model") - @patch("bcipy.helpers.load.ask_filename") - @patch("bcipy.helpers.load.preferences") + @patch("bcipy.io.load.load_signal_model") + @patch("bcipy.io.load.ask_filename") + @patch("bcipy.io.load.preferences") def test_choose_model(self, preferences_mock, ask_file_mock, load_signal_model_mock): """Test choosing a model""" @@ -245,9 +247,9 @@ def test_choose_model(self, preferences_mock, ask_file_mock, preferences_mock.signal_model_directory, msg="Should have updated the preferences") - @patch("bcipy.helpers.load.load_signal_model") - @patch("bcipy.helpers.load.ask_filename") - @patch("bcipy.helpers.load.preferences") + @patch("bcipy.io.load.load_signal_model") + @patch("bcipy.io.load.ask_filename") + @patch("bcipy.io.load.preferences") def test_choose_model_with_cancel(self, preferences_mock, ask_file_mock, load_signal_model_mock): """Test choosing a model""" @@ -268,7 +270,7 @@ def test_choose_model_with_cancel(self, preferences_mock, ask_file_mock, preferences_mock.signal_model_directory, msg="Should not have updated the preferences") - @patch("bcipy.helpers.load.choose_signal_model") + @patch("bcipy.io.load.choose_signal_model") def test_choose_signal_models(self, choose_signal_model_mock): """Test choosing signal models""" eeg_mock = Mock() @@ -278,7 +280,7 @@ def test_choose_signal_models(self, choose_signal_model_mock): models = choose_signal_models(['EEG', 'Eyetracker']) self.assertListEqual([eeg_mock, eyetracker_mock], models) - @patch("bcipy.helpers.load.choose_signal_model") + @patch("bcipy.io.load.choose_signal_model") def test_choose_signal_models_missing_model(self, choose_signal_model_mock): """Test choosing signal models""" @@ -290,5 +292,119 @@ def test_choose_signal_models_missing_model(self, self.assertListEqual([eyetracker_mock], models) +class TestLoadBciPyData(unittest.TestCase): + """data_directory/ + user_ids/ + dates/ + experiment_ids/ + datetimes/ + protocol.json + logs/ + tasks/ + *task_data*""" + + def setUp(self): + # make a temporary directory mimicking the structure of the data directory + self.data_dir = tempfile.mkdtemp() + self.user_ids = ['user1', 'user2'] + self.dates = ['2024-10-31', '2024-10-32'] + self.experiment_ids = ['experiment1', 'experiment2'] + self.datetimes = ['2024-10-31_15-01-05', '2024-10-32_15-01-07'] + self.tasks = ['task1', 'task2'] + + # create the directory structure + for user_id in self.user_ids: + user_dir = os.path.join(self.data_dir, user_id) + os.makedirs(user_dir) + for date in self.dates: + date_dir = os.path.join(user_dir, date) + os.makedirs(date_dir) + for experiment_id in self.experiment_ids: + experiment_dir = os.path.join(date_dir, experiment_id) + os.makedirs(experiment_dir) + for datetime in self.datetimes: + datetime_dir = os.path.join(experiment_dir, datetime) + os.makedirs(datetime_dir) + for task in self.tasks: + task_dir = os.path.join(datetime_dir, task) + os.makedirs(task_dir) + # create artificial files + create_bcipy_session_artifacts(task_dir) + + def tearDown(self): + shutil.rmtree(self.data_dir) + + def test_load_bcipy_data(self): + total_expected_files = len(self.user_ids) * len(self.dates) * len(self.experiment_ids) * \ + len(self.datetimes) * len(self.tasks) + response = load_bcipy_data(self.data_dir) + self.assertEqual(len(response), total_expected_files) + + def test_load_bcipy_data_with_invalid_directory(self): + with self.assertRaises(FileNotFoundError): + load_bcipy_data('') + + def test_load_bcipy_data_with_user_id_filter(self): + # pick one user id to filter + user_id = self.user_ids[0] + total_expected_files = len(self.dates) * len(self.experiment_ids) * \ + len(self.datetimes) * len(self.tasks) + response = load_bcipy_data(self.data_dir, user_id=user_id) + self.assertEqual(len(response), total_expected_files) + + def test_load_bcipy_data_with_task_filter(self): + excluded_task = [self.tasks[0]] + total_expected_files = len(self.user_ids) * len(self.dates) * len(self.experiment_ids) * \ + len(self.datetimes) * (len(self.tasks) - 1) + response = load_bcipy_data(self.data_dir, excluded_tasks=excluded_task) + self.assertEqual(len(response), total_expected_files) + + def test_load_bcipy_data_with_date_filter(self): + desired_date = self.dates[0] + + total_expected_files = len(self.user_ids) * (len(self.dates) - 1) * len(self.experiment_ids) * \ + len(self.datetimes) * len(self.tasks) + + response = load_bcipy_data(self.data_dir, date=desired_date) + self.assertEqual(len(response), total_expected_files) + # check that the date is in the response + dates = [file.date for file in response] + self.assertIn(desired_date, dates) + + def test_load_bcipy_data_with_experiment_id_filter(self): + desired_experiment_id = self.experiment_ids[0] + + total_expected_files = len(self.user_ids) * len(self.dates) * (len(self.experiment_ids) - 1) * \ + len(self.datetimes) * len(self.tasks) + response = load_bcipy_data(self.data_dir, experiment_id=desired_experiment_id) + + self.assertEqual(len(response), total_expected_files) + experiments = [file.experiment_id for file in response] + # check that the experiment id is in the response + self.assertIn(desired_experiment_id, experiments) + self.assertNotIn(self.experiment_ids[1], experiments) + + def test_load_bcipy_data_with_datetime_filter(self): + desired_datetime = self.datetimes[0] + + total_expected_files = len(self.user_ids) * len(self.dates) * len(self.experiment_ids) * \ + (len(self.datetimes) - 1) * len(self.tasks) + response = load_bcipy_data(self.data_dir, date_time=desired_datetime) + + self.assertEqual(len(response), total_expected_files) + # check that the datetime is in the response + datetimes = [file.date_time for file in response] + self.assertIn(desired_datetime, datetimes) + self.assertNotIn(self.datetimes[1], datetimes) + + def test_load_bcipy_data_with_anonymize(self): + """Test that the user ids are not in the response when anonymize is True""" + response = load_bcipy_data(self.data_dir, anonymize=True) + + users = [file.user_id for file in response] + self.assertNotIn(self.user_ids[0], users) + self.assertNotIn(self.user_ids[1], users) + + if __name__ == '__main__': unittest.main() diff --git a/bcipy/helpers/tests/test_save.py b/bcipy/io/tests/test_save.py similarity index 94% rename from bcipy/helpers/tests/test_save.py rename to bcipy/io/tests/test_save.py index ba46b4a25..f4ca79481 100644 --- a/bcipy/helpers/tests/test_save.py +++ b/bcipy/io/tests/test_save.py @@ -9,8 +9,8 @@ DEFAULT_PARAMETERS_FILENAME, DEFAULT_EXPERIMENT_ID, STIMULI_POSITIONS_FILENAME) -from bcipy.helpers import save -from bcipy.helpers.save import init_save_data_structure, save_stimuli_position_info +from bcipy.io import save +from bcipy.io.save import init_save_data_structure, save_stimuli_position_info from mockito import any, unstub, when diff --git a/bcipy/language/demo/demo_causal.py b/bcipy/language/demo/demo_causal.py index e73324a46..486b228e6 100644 --- a/bcipy/language/demo/demo_causal.py +++ b/bcipy/language/demo/demo_causal.py @@ -1,5 +1,5 @@ from bcipy.language.model.causal import CausalLanguageModel -from bcipy.helpers.symbols import alphabet +from bcipy.core.symbols import alphabet from bcipy.language.main import ResponseType diff --git a/bcipy/language/demo/demo_kenlm.py b/bcipy/language/demo/demo_kenlm.py index d90421e42..cf42c503a 100644 --- a/bcipy/language/demo/demo_kenlm.py +++ b/bcipy/language/demo/demo_kenlm.py @@ -1,7 +1,7 @@ # Basic sanity test of using KenLM to predict a sentence using a 12-gram character model. from bcipy.language.model.kenlm import KenLMLanguageModel -from bcipy.helpers.symbols import alphabet +from bcipy.core.symbols import alphabet from bcipy.language.main import ResponseType from bcipy.config import LM_PATH from bcipy.exceptions import KenLMInstallationException diff --git a/bcipy/language/demo/demo_mixture.py b/bcipy/language/demo/demo_mixture.py index a7746153d..fee828e44 100644 --- a/bcipy/language/demo/demo_mixture.py +++ b/bcipy/language/demo/demo_mixture.py @@ -1,5 +1,5 @@ from bcipy.language.model.mixture import MixtureLanguageModel -from bcipy.helpers.symbols import alphabet +from bcipy.core.symbols import alphabet from bcipy.language.main import ResponseType diff --git a/bcipy/language/demo/demo_unigram.py b/bcipy/language/demo/demo_unigram.py index d1db94ee1..b69bbdb0e 100644 --- a/bcipy/language/demo/demo_unigram.py +++ b/bcipy/language/demo/demo_unigram.py @@ -1,5 +1,5 @@ from bcipy.language.model.unigram import UnigramLanguageModel -from bcipy.helpers.symbols import alphabet +from bcipy.core.symbols import alphabet from bcipy.language.main import ResponseType diff --git a/bcipy/language/main.py b/bcipy/language/main.py index 8a1922338..ed3bfdacb 100644 --- a/bcipy/language/main.py +++ b/bcipy/language/main.py @@ -5,7 +5,7 @@ import json from bcipy.exceptions import UnsupportedResponseType -from bcipy.helpers.symbols import DEFAULT_SYMBOL_SET +from bcipy.core.symbols import DEFAULT_SYMBOL_SET from bcipy.config import DEFAULT_LM_PARAMETERS_PATH diff --git a/bcipy/language/model/causal.py b/bcipy/language/model/causal.py index 677215369..60b84274d 100644 --- a/bcipy/language/model/causal.py +++ b/bcipy/language/model/causal.py @@ -5,7 +5,7 @@ import itertools import heapq -from bcipy.helpers.symbols import BACKSPACE_CHAR, SPACE_CHAR +from bcipy.core.symbols import BACKSPACE_CHAR, SPACE_CHAR from bcipy.language.main import LanguageModel, ResponseType from bcipy.exceptions import InvalidLanguageModelException diff --git a/bcipy/language/model/kenlm.py b/bcipy/language/model/kenlm.py index 1c95241e7..05b9bbf13 100644 --- a/bcipy/language/model/kenlm.py +++ b/bcipy/language/model/kenlm.py @@ -1,6 +1,6 @@ from collections import Counter from typing import Optional, List, Tuple -from bcipy.helpers.symbols import BACKSPACE_CHAR, SPACE_CHAR +from bcipy.core.symbols import BACKSPACE_CHAR, SPACE_CHAR from bcipy.language.main import LanguageModel, ResponseType from bcipy.exceptions import InvalidLanguageModelException, KenLMInstallationException from bcipy.config import LM_PATH diff --git a/bcipy/language/model/oracle.py b/bcipy/language/model/oracle.py index a23e4789e..73896fb5a 100644 --- a/bcipy/language/model/oracle.py +++ b/bcipy/language/model/oracle.py @@ -5,7 +5,7 @@ import numpy as np from bcipy.config import SESSION_LOG_FILENAME -from bcipy.helpers.symbols import BACKSPACE_CHAR +from bcipy.core.symbols import BACKSPACE_CHAR from bcipy.language.main import LanguageModel, ResponseType from bcipy.language.model.uniform import equally_probable diff --git a/bcipy/language/model/unigram.py b/bcipy/language/model/unigram.py index 9e61e213c..13d85633f 100644 --- a/bcipy/language/model/unigram.py +++ b/bcipy/language/model/unigram.py @@ -1,5 +1,5 @@ from typing import Optional, List, Tuple -from bcipy.helpers.symbols import BACKSPACE_CHAR, SPACE_CHAR +from bcipy.core.symbols import BACKSPACE_CHAR, SPACE_CHAR from bcipy.language.main import LanguageModel, ResponseType from bcipy.exceptions import InvalidLanguageModelException import json diff --git a/bcipy/language/tests/test_causal.py b/bcipy/language/tests/test_causal.py index b1d7ed191..da685cb84 100644 --- a/bcipy/language/tests/test_causal.py +++ b/bcipy/language/tests/test_causal.py @@ -5,7 +5,7 @@ from operator import itemgetter from bcipy.exceptions import UnsupportedResponseType, InvalidLanguageModelException -from bcipy.helpers.symbols import alphabet, BACKSPACE_CHAR, SPACE_CHAR +from bcipy.core.symbols import alphabet, BACKSPACE_CHAR, SPACE_CHAR from bcipy.language.model.causal import CausalLanguageModel from bcipy.language.main import ResponseType diff --git a/bcipy/language/tests/test_kenlm.py b/bcipy/language/tests/test_kenlm.py index a0cc4805b..6a460d38c 100644 --- a/bcipy/language/tests/test_kenlm.py +++ b/bcipy/language/tests/test_kenlm.py @@ -6,7 +6,7 @@ from operator import itemgetter from bcipy.exceptions import UnsupportedResponseType, InvalidLanguageModelException -from bcipy.helpers.symbols import alphabet, BACKSPACE_CHAR, SPACE_CHAR +from bcipy.core.symbols import alphabet, BACKSPACE_CHAR, SPACE_CHAR from bcipy.language.model.kenlm import KenLMLanguageModel from bcipy.language.main import ResponseType diff --git a/bcipy/language/tests/test_mixture.py b/bcipy/language/tests/test_mixture.py index 53c160212..eca69f1ae 100644 --- a/bcipy/language/tests/test_mixture.py +++ b/bcipy/language/tests/test_mixture.py @@ -6,7 +6,7 @@ from operator import itemgetter from bcipy.exceptions import UnsupportedResponseType, InvalidLanguageModelException -from bcipy.helpers.symbols import alphabet, BACKSPACE_CHAR, SPACE_CHAR +from bcipy.core.symbols import alphabet, BACKSPACE_CHAR, SPACE_CHAR from bcipy.language.model.mixture import MixtureLanguageModel from bcipy.language.main import ResponseType diff --git a/bcipy/language/tests/test_unigram.py b/bcipy/language/tests/test_unigram.py index 7a07b002e..453734e9c 100644 --- a/bcipy/language/tests/test_unigram.py +++ b/bcipy/language/tests/test_unigram.py @@ -5,7 +5,7 @@ import os from bcipy.exceptions import UnsupportedResponseType, InvalidLanguageModelException -from bcipy.helpers.symbols import alphabet, BACKSPACE_CHAR +from bcipy.core.symbols import alphabet, BACKSPACE_CHAR from bcipy.language.model.unigram import UnigramLanguageModel from bcipy.language.main import ResponseType diff --git a/bcipy/main.py b/bcipy/main.py index 8d7d47b97..fe8d8fa37 100644 --- a/bcipy/main.py +++ b/bcipy/main.py @@ -5,7 +5,7 @@ from bcipy.config import CUSTOM_TASK_EXPERIMENT_ID, DEFAULT_PARAMETERS_PATH from bcipy.exceptions import BciPyCoreException -from bcipy.helpers.load import load_experiments, load_json_parameters +from bcipy.io.load import load_experiments, load_json_parameters from bcipy.helpers.validate import validate_bcipy_session, validate_experiment from bcipy.task import Task, TaskRegistry from bcipy.task.orchestrator import SessionOrchestrator diff --git a/bcipy/signal/evaluate/artifact.py b/bcipy/signal/evaluate/artifact.py index f4bc7bde0..afffaebcf 100644 --- a/bcipy/signal/evaluate/artifact.py +++ b/bcipy/signal/evaluate/artifact.py @@ -15,16 +15,16 @@ SESSION_LOG_FILENAME ) from bcipy.helpers.acquisition import analysis_channels -from bcipy.helpers.load import ( +from bcipy.io.load import ( load_experimental_data, load_json_parameters, load_raw_data, ) -from bcipy.helpers.stimuli import mne_epochs -from bcipy.helpers.convert import convert_to_mne -from bcipy.helpers.raw_data import RawData +from bcipy.core.stimuli import mne_epochs +from bcipy.io.convert import convert_to_mne +from bcipy.core.raw_data import RawData from bcipy.signal.process import get_default_transform -from bcipy.helpers.triggers import TriggerType, trigger_decoder +from bcipy.core.triggers import TriggerType, trigger_decoder import bcipy.acquisition.devices as devices from bcipy.acquisition.devices import DeviceSpec diff --git a/bcipy/signal/model/base_model.py b/bcipy/signal/model/base_model.py index af9f7028a..3f779b125 100644 --- a/bcipy/signal/model/base_model.py +++ b/bcipy/signal/model/base_model.py @@ -5,7 +5,7 @@ import numpy as np from bcipy.acquisition.devices import DeviceSpec -from bcipy.helpers.stimuli import Reshaper +from bcipy.core.stimuli import Reshaper from bcipy.signal.process import Composition diff --git a/bcipy/signal/model/gaussian_mixture/gaussian_mixture.py b/bcipy/signal/model/gaussian_mixture/gaussian_mixture.py index 634e131b4..73e64b75a 100644 --- a/bcipy/signal/model/gaussian_mixture/gaussian_mixture.py +++ b/bcipy/signal/model/gaussian_mixture/gaussian_mixture.py @@ -2,7 +2,7 @@ from typing import List from enum import Enum -from bcipy.helpers.stimuli import GazeReshaper +from bcipy.core.stimuli import GazeReshaper from bcipy.signal.model import SignalModel from sklearn.mixture import GaussianMixture diff --git a/bcipy/signal/model/offline_analysis.py b/bcipy/signal/model/offline_analysis.py index ab0b3f9da..e9fe3f04d 100644 --- a/bcipy/signal/model/offline_analysis.py +++ b/bcipy/signal/model/offline_analysis.py @@ -17,16 +17,15 @@ TRIGGER_FILENAME, SESSION_LOG_FILENAME, STIMULI_POSITIONS_FILENAME) from bcipy.helpers.acquisition import analysis_channels, raw_data_filename -from bcipy.helpers.load import (load_experimental_data, load_json_parameters, - load_raw_data) +from bcipy.io.load import (load_experimental_data, load_json_parameters, + load_raw_data) from bcipy.gui.alert import confirm -from bcipy.helpers.parameters import Parameters -from bcipy.helpers.save import save_model -from bcipy.helpers.stimuli import update_inquiry_timing -from bcipy.helpers.symbols import alphabet -from bcipy.helpers.system_utils import report_execution_time -from bcipy.helpers.triggers import TriggerType, trigger_decoder -from bcipy.helpers.raw_data import RawData +from bcipy.core.parameters import Parameters +from bcipy.io.save import save_model +from bcipy.core.stimuli import update_inquiry_timing +from bcipy.core.symbols import alphabet +from bcipy.helpers.utils import report_execution_time +from bcipy.core.triggers import TriggerType, trigger_decoder from bcipy.preferences import preferences from bcipy.signal.model.base_model import SignalModel, SignalModelMetadata from bcipy.signal.model.gaussian_mixture import (GazeModelResolver) diff --git a/bcipy/signal/model/pca_rda_kde/pca_rda_kde.py b/bcipy/signal/model/pca_rda_kde/pca_rda_kde.py index 255ccc0c4..509ec03dc 100644 --- a/bcipy/signal/model/pca_rda_kde/pca_rda_kde.py +++ b/bcipy/signal/model/pca_rda_kde/pca_rda_kde.py @@ -5,7 +5,7 @@ import numpy as np from bcipy.exceptions import SignalException -from bcipy.helpers.stimuli import InquiryReshaper +from bcipy.core.stimuli import InquiryReshaper from bcipy.signal.model import ModelEvaluationReport, SignalModel from bcipy.signal.model.classifier import RegularizedDiscriminantAnalysis from bcipy.signal.model.cross_validation import (cost_cross_validation_auc, diff --git a/bcipy/signal/model/rda_kde/rda_kde.py b/bcipy/signal/model/rda_kde/rda_kde.py index 6c716d569..eb6f4b565 100644 --- a/bcipy/signal/model/rda_kde/rda_kde.py +++ b/bcipy/signal/model/rda_kde/rda_kde.py @@ -10,7 +10,7 @@ from bcipy.signal.model.dimensionality_reduction import MockPCA from bcipy.signal.model.pipeline import Pipeline from bcipy.exceptions import SignalException -from bcipy.helpers.stimuli import InquiryReshaper +from bcipy.core.stimuli import InquiryReshaper class RdaKdeModel(SignalModel): diff --git a/bcipy/signal/tests/model/pca_rda_kde/test_pca_rda_kde.py b/bcipy/signal/tests/model/pca_rda_kde/test_pca_rda_kde.py index 51c62e145..7aed8d9fb 100644 --- a/bcipy/signal/tests/model/pca_rda_kde/test_pca_rda_kde.py +++ b/bcipy/signal/tests/model/pca_rda_kde/test_pca_rda_kde.py @@ -9,9 +9,9 @@ from scipy.stats import norm from bcipy.exceptions import SignalException -from bcipy.helpers.load import load_signal_models -from bcipy.helpers.save import save_model -from bcipy.helpers.symbols import alphabet +from bcipy.io.load import load_signal_models +from bcipy.io.save import save_model +from bcipy.core.symbols import alphabet from bcipy.signal.model import ModelEvaluationReport, PcaRdaKdeModel from bcipy.signal.model.classifier import RegularizedDiscriminantAnalysis from bcipy.signal.model.cross_validation import cross_validation diff --git a/bcipy/signal/tests/model/rda_kde/test_rda_kde.py b/bcipy/signal/tests/model/rda_kde/test_rda_kde.py index c09e53e0c..4dfcea21c 100644 --- a/bcipy/signal/tests/model/rda_kde/test_rda_kde.py +++ b/bcipy/signal/tests/model/rda_kde/test_rda_kde.py @@ -8,7 +8,7 @@ import pytest from scipy.stats import norm -from bcipy.helpers.symbols import alphabet +from bcipy.core.symbols import alphabet from bcipy.signal.model import ModelEvaluationReport, RdaKdeModel from bcipy.signal.model.classifier import RegularizedDiscriminantAnalysis from bcipy.signal.model.cross_validation import cross_validation diff --git a/bcipy/signal/tests/model/test_offline_analysis.py b/bcipy/signal/tests/model/test_offline_analysis.py index 5f0a8502c..999807c55 100644 --- a/bcipy/signal/tests/model/test_offline_analysis.py +++ b/bcipy/signal/tests/model/test_offline_analysis.py @@ -10,8 +10,7 @@ import gzip from bcipy.config import RAW_DATA_FILENAME, DEFAULT_PARAMETERS_FILENAME, TRIGGER_FILENAME, DEFAULT_DEVICE_SPEC_FILENAME -from bcipy.helpers.load import load_json_parameters -from bcipy.signal.model import SignalModel +from bcipy.io.load import load_json_parameters from bcipy.signal.model.offline_analysis import offline_analysis pwd = Path(__file__).absolute().parent diff --git a/bcipy/simulator/data/data_engine.py b/bcipy/simulator/data/data_engine.py index 3b4fc3160..431b4fcac 100644 --- a/bcipy/simulator/data/data_engine.py +++ b/bcipy/simulator/data/data_engine.py @@ -8,7 +8,7 @@ import pandas as pd from bcipy.exceptions import TaskConfigurationException -from bcipy.helpers.parameters import Parameters +from bcipy.core.parameters import Parameters from bcipy.simulator.data import data_process from bcipy.simulator.data.data_process import (ExtractedExperimentData, RawDataProcessor) diff --git a/bcipy/simulator/data/data_process.py b/bcipy/simulator/data/data_process.py index d56445cc8..bcd731c02 100644 --- a/bcipy/simulator/data/data_process.py +++ b/bcipy/simulator/data/data_process.py @@ -16,12 +16,12 @@ from bcipy.config import (DEFAULT_DEVICE_SPEC_FILENAME, DEFAULT_PARAMETERS_FILENAME, TRIGGER_FILENAME) from bcipy.helpers.acquisition import analysis_channels, raw_data_filename -from bcipy.helpers.list import grouper -from bcipy.helpers.load import load_json_parameters, load_raw_data -from bcipy.helpers.parameters import Parameters -from bcipy.helpers.raw_data import RawData -from bcipy.helpers.stimuli import update_inquiry_timing -from bcipy.helpers.triggers import TriggerType, trigger_decoder +from bcipy.core.list import grouper +from bcipy.io.load import load_json_parameters, load_raw_data +from bcipy.core.parameters import Parameters +from bcipy.core.raw_data import RawData +from bcipy.core.stimuli import update_inquiry_timing +from bcipy.core.triggers import TriggerType, trigger_decoder from bcipy.signal.model.base_model import SignalModel from bcipy.signal.process import (ERPTransformParams, filter_inquiries, get_default_transform) diff --git a/bcipy/simulator/task/copy_phrase.py b/bcipy/simulator/task/copy_phrase.py index 03e99660a..4388f93f9 100644 --- a/bcipy/simulator/task/copy_phrase.py +++ b/bcipy/simulator/task/copy_phrase.py @@ -5,8 +5,8 @@ from bcipy.display.main import Display from bcipy.feedback.visual.visual_feedback import VisualFeedback -from bcipy.helpers.parameters import Parameters -from bcipy.helpers.stimuli import InquirySchedule +from bcipy.core.parameters import Parameters +from bcipy.core.stimuli import InquirySchedule from bcipy.language.main import LanguageModel from bcipy.signal.model.base_model import SignalModel from bcipy.simulator.data.sampler import Sampler diff --git a/bcipy/simulator/task/task_factory.py b/bcipy/simulator/task/task_factory.py index 3e7c4b7e8..1959a41d9 100644 --- a/bcipy/simulator/task/task_factory.py +++ b/bcipy/simulator/task/task_factory.py @@ -3,8 +3,8 @@ from typing import Dict, List, Type from bcipy.helpers.language_model import init_language_model -from bcipy.helpers.load import load_json_parameters, load_signal_model -from bcipy.helpers.parameters import DEFAULT_PARAMETERS_PATH, Parameters +from bcipy.io.load import load_json_parameters, load_signal_models +from bcipy.core.parameters import DEFAULT_PARAMETERS_PATH, Parameters from bcipy.signal.model.base_model import SignalModel from bcipy.simulator.data.data_engine import RawDataEngine from bcipy.simulator.data.data_process import init_data_processor diff --git a/bcipy/simulator/util/state.py b/bcipy/simulator/util/state.py index a048dd8a7..5ae25ba43 100644 --- a/bcipy/simulator/util/state.py +++ b/bcipy/simulator/util/state.py @@ -5,7 +5,7 @@ from typing import Any, Dict, List from bcipy.config import SESSION_DATA_FILENAME -from bcipy.helpers.session import read_session +from bcipy.core.session import read_session @dataclass diff --git a/bcipy/task/actions.py b/bcipy/task/actions.py index 907e7456a..b2429a947 100644 --- a/bcipy/task/actions.py +++ b/bcipy/task/actions.py @@ -12,16 +12,16 @@ from bcipy.gui.intertask_gui import IntertaskGUI from bcipy.gui.experiments.ExperimentField import start_experiment_field_collection_gui from bcipy.task import Task, TaskMode, TaskData -from bcipy.helpers.triggers import trigger_decoder, TriggerType +from bcipy.core.triggers import trigger_decoder, TriggerType from bcipy.acquisition import devices from bcipy.helpers.acquisition import analysis_channels -from bcipy.helpers.parameters import Parameters +from bcipy.core.parameters import Parameters from bcipy.acquisition.devices import DeviceSpec -from bcipy.helpers.load import load_raw_data -from bcipy.helpers.raw_data import RawData +from bcipy.io.load import load_raw_data +from bcipy.core.raw_data import RawData from bcipy.signal.process import get_default_transform -from bcipy.helpers.report import SignalReportSection, SessionReportSection, Report, ReportSection +from bcipy.core.report import SignalReportSection, SessionReportSection, Report, ReportSection from bcipy.config import SESSION_LOG_FILENAME, RAW_DATA_FILENAME, TRIGGER_FILENAME from bcipy.helpers.visualization import visualize_erp from bcipy.signal.evaluate.artifact import ArtifactDetection diff --git a/bcipy/task/calibration.py b/bcipy/task/calibration.py index c80496480..d0829cfab 100644 --- a/bcipy/task/calibration.py +++ b/bcipy/task/calibration.py @@ -12,17 +12,17 @@ from bcipy.helpers.acquisition import init_acquisition, LslDataServer from bcipy.display import init_display_window, Display from bcipy.helpers.clock import Clock -from bcipy.helpers.parameters import Parameters -from bcipy.helpers.save import _save_session_related_data -from bcipy.helpers.stimuli import (DEFAULT_TEXT_FIXATION, StimuliOrder, - TargetPositions, - generate_calibration_inquiries) -from bcipy.helpers.symbols import alphabet +from bcipy.core.parameters import Parameters +from bcipy.io.save import _save_session_related_data +from bcipy.core.stimuli import (DEFAULT_TEXT_FIXATION, StimuliOrder, + TargetPositions, + generate_calibration_inquiries) +from bcipy.core.symbols import alphabet from bcipy.helpers.task import (get_user_input, pause_calibration, trial_complete_message) -from bcipy.helpers.triggers import (FlushFrequency, Trigger, TriggerHandler, - TriggerType, convert_timing_triggers, - offset_label) +from bcipy.core.triggers import (FlushFrequency, Trigger, TriggerHandler, + TriggerType, convert_timing_triggers, + offset_label) from bcipy.task import Task, TaskData, TaskMode import logging diff --git a/bcipy/task/control/evidence.py b/bcipy/task/control/evidence.py index bb8a828eb..97b2986dc 100644 --- a/bcipy/task/control/evidence.py +++ b/bcipy/task/control/evidence.py @@ -8,7 +8,7 @@ from bcipy.acquisition.multimodal import ContentType from bcipy.config import SESSION_LOG_FILENAME from bcipy.helpers.acquisition import analysis_channels -from bcipy.helpers.stimuli import TrialReshaper +from bcipy.core.stimuli import TrialReshaper from bcipy.signal.model import SignalModel from bcipy.task.data import EvidenceType from bcipy.task.exceptions import MissingEvidenceEvaluator diff --git a/bcipy/task/control/handler.py b/bcipy/task/control/handler.py index fbda582db..3e67cdfd7 100644 --- a/bcipy/task/control/handler.py +++ b/bcipy/task/control/handler.py @@ -5,8 +5,8 @@ import numpy as np from bcipy.config import SESSION_LOG_FILENAME -from bcipy.helpers.stimuli import InquirySchedule, inq_generator, StimuliOrder -from bcipy.helpers.symbols import SPACE_CHAR, BACKSPACE_CHAR +from bcipy.core.stimuli import InquirySchedule, inq_generator, StimuliOrder +from bcipy.core.symbols import SPACE_CHAR, BACKSPACE_CHAR from bcipy.task.control.query import RandomStimuliAgent, StimuliAgent from bcipy.task.control.criteria import CriteriaEvaluator from bcipy.task.data import EvidenceType diff --git a/bcipy/task/control/query.py b/bcipy/task/control/query.py index 617d029b6..3e8b3c867 100644 --- a/bcipy/task/control/query.py +++ b/bcipy/task/control/query.py @@ -4,7 +4,7 @@ import numpy as np -from bcipy.helpers.stimuli import best_selection +from bcipy.core.stimuli import best_selection class StimuliAgent(ABC): diff --git a/bcipy/task/demo/actions/demo_calibration_report.py b/bcipy/task/demo/actions/demo_calibration_report.py index bce16274c..560461028 100644 --- a/bcipy/task/demo/actions/demo_calibration_report.py +++ b/bcipy/task/demo/actions/demo_calibration_report.py @@ -1,7 +1,7 @@ import logging from bcipy.task.actions import BciPyCalibrationReportAction from bcipy.config import DEFAULT_PARAMETERS_PATH -from bcipy.helpers.load import load_json_parameters +from bcipy.io.load import load_json_parameters logging.basicConfig(level=logging.INFO) diff --git a/bcipy/task/main.py b/bcipy/task/main.py index 65083f8d1..bd6d0f4c0 100644 --- a/bcipy/task/main.py +++ b/bcipy/task/main.py @@ -1,10 +1,10 @@ from dataclasses import dataclass from typing import Optional -from bcipy.helpers.parameters import Parameters +from bcipy.core.parameters import Parameters from abc import ABC, abstractmethod from enum import Enum -from bcipy.helpers.stimuli import play_sound +from bcipy.core.stimuli import play_sound from bcipy.config import STATIC_AUDIO_PATH diff --git a/bcipy/task/orchestrator/orchestrator.py b/bcipy/task/orchestrator/orchestrator.py index 1f07fb82a..e5c2c6756 100644 --- a/bcipy/task/orchestrator/orchestrator.py +++ b/bcipy/task/orchestrator/orchestrator.py @@ -10,8 +10,8 @@ from logging import Logger from typing import List, Type, Optional -from bcipy.helpers.parameters import Parameters -from bcipy.helpers.system_utils import get_system_info, configure_logger +from bcipy.core.parameters import Parameters +from bcipy.helpers.utils import get_system_info, configure_logger from bcipy.task import Task, TaskData, TaskMode from bcipy.config import ( DEFAULT_EXPERIMENT_ID, @@ -23,7 +23,7 @@ PROTOCOL_LOG_FILENAME, SESSION_LOG_FILENAME, ) -from bcipy.helpers.load import load_json_parameters +from bcipy.io.load import load_json_parameters class SessionOrchestrator: diff --git a/bcipy/task/paradigm/matrix/calibration.py b/bcipy/task/paradigm/matrix/calibration.py index a16cc6305..21d005c32 100644 --- a/bcipy/task/paradigm/matrix/calibration.py +++ b/bcipy/task/paradigm/matrix/calibration.py @@ -7,9 +7,9 @@ from bcipy.display.main import PreviewParams from bcipy.display.paradigm.matrix.display import MatrixDisplay from bcipy.helpers.clock import Clock -from bcipy.helpers.parameters import Parameters -from bcipy.helpers.save import save_stimuli_position_info -from bcipy.helpers.system_utils import get_screen_info +from bcipy.core.parameters import Parameters +from bcipy.io.save import save_stimuli_position_info +from bcipy.helpers.utils import get_screen_info from bcipy.task.calibration import BaseCalibrationTask diff --git a/bcipy/task/paradigm/matrix/copy_phrase.py b/bcipy/task/paradigm/matrix/copy_phrase.py index 56f89d8bc..b9856e9e8 100644 --- a/bcipy/task/paradigm/matrix/copy_phrase.py +++ b/bcipy/task/paradigm/matrix/copy_phrase.py @@ -7,7 +7,7 @@ from bcipy.display.paradigm.matrix.display import MatrixDisplay from bcipy.task import TaskMode from bcipy.task.paradigm.rsvp.copy_phrase import RSVPCopyPhraseTask -from bcipy.helpers.parameters import Parameters +from bcipy.core.parameters import Parameters from bcipy.helpers.clock import Clock diff --git a/bcipy/task/paradigm/matrix/timing_verification.py b/bcipy/task/paradigm/matrix/timing_verification.py index a26c20876..33cfc6901 100644 --- a/bcipy/task/paradigm/matrix/timing_verification.py +++ b/bcipy/task/paradigm/matrix/timing_verification.py @@ -1,8 +1,8 @@ from itertools import cycle, islice, repeat from typing import Iterator, List -from bcipy.helpers.stimuli import (PhotoDiodeStimuli, get_fixation, - jittered_timing) +from bcipy.core.stimuli import (PhotoDiodeStimuli, get_fixation, + jittered_timing) from bcipy.task.calibration import Inquiry from bcipy.task import TaskMode from bcipy.task.paradigm.matrix.calibration import (MatrixCalibrationTask, diff --git a/bcipy/task/paradigm/rsvp/calibration/calibration.py b/bcipy/task/paradigm/rsvp/calibration/calibration.py index cd8fa0b25..db188908c 100644 --- a/bcipy/task/paradigm/rsvp/calibration/calibration.py +++ b/bcipy/task/paradigm/rsvp/calibration/calibration.py @@ -5,7 +5,7 @@ from bcipy.display.main import PreviewParams from bcipy.display.paradigm.rsvp.mode.calibration import CalibrationDisplay from bcipy.helpers.clock import Clock -from bcipy.helpers.parameters import Parameters +from bcipy.core.parameters import Parameters from bcipy.task.calibration import BaseCalibrationTask diff --git a/bcipy/task/paradigm/rsvp/calibration/timing_verification.py b/bcipy/task/paradigm/rsvp/calibration/timing_verification.py index 07bcbba74..9d4f502bf 100644 --- a/bcipy/task/paradigm/rsvp/calibration/timing_verification.py +++ b/bcipy/task/paradigm/rsvp/calibration/timing_verification.py @@ -2,9 +2,9 @@ from itertools import cycle, islice, repeat from typing import Any, Iterator, List -from bcipy.helpers.parameters import Parameters -from bcipy.helpers.stimuli import (PhotoDiodeStimuli, get_fixation, - jittered_timing) +from bcipy.core.parameters import Parameters +from bcipy.core.stimuli import (PhotoDiodeStimuli, get_fixation, + jittered_timing) from bcipy.task.calibration import Inquiry from bcipy.task import TaskMode from bcipy.task.paradigm.rsvp.calibration.calibration import \ diff --git a/bcipy/task/paradigm/rsvp/copy_phrase.py b/bcipy/task/paradigm/rsvp/copy_phrase.py index a7f335dbd..b69df658f 100644 --- a/bcipy/task/paradigm/rsvp/copy_phrase.py +++ b/bcipy/task/paradigm/rsvp/copy_phrase.py @@ -21,21 +21,21 @@ from bcipy.helpers.clock import Clock from bcipy.helpers.copy_phrase_wrapper import CopyPhraseWrapper from bcipy.helpers.language_model import init_language_model -from bcipy.helpers.list import destutter -from bcipy.helpers.load import choose_signal_models -from bcipy.helpers.parameters import Parameters -from bcipy.helpers.save import _save_session_related_data -from bcipy.helpers.session import session_excel -from bcipy.helpers.stimuli import InquirySchedule, StimuliOrder -from bcipy.helpers.symbols import BACKSPACE_CHAR, alphabet +from bcipy.core.list import destutter +from bcipy.io.load import choose_signal_models +from bcipy.core.parameters import Parameters +from bcipy.io.save import _save_session_related_data +from bcipy.core.session import session_excel +from bcipy.core.stimuli import InquirySchedule, StimuliOrder +from bcipy.core.symbols import BACKSPACE_CHAR, alphabet from bcipy.helpers.task import (consecutive_incorrect, construct_triggers, fake_copy_phrase_decision, get_device_data_for_decision, get_user_input, relative_triggers, target_info, trial_complete_message) -from bcipy.helpers.triggers import (FlushFrequency, Trigger, TriggerHandler, - TriggerType, convert_timing_triggers, - offset_label) +from bcipy.core.triggers import (FlushFrequency, Trigger, TriggerHandler, + TriggerType, convert_timing_triggers, + offset_label) from bcipy.language.main import LanguageModel from bcipy.signal.model import SignalModel from bcipy.signal.model.inquiry_preview import compute_probs_after_preview diff --git a/bcipy/task/paradigm/vep/calibration.py b/bcipy/task/paradigm/vep/calibration.py index 34cf9909e..8dca8b6bc 100644 --- a/bcipy/task/paradigm/vep/calibration.py +++ b/bcipy/task/paradigm/vep/calibration.py @@ -12,8 +12,8 @@ from bcipy.display.paradigm.vep.display import VEPDisplay from bcipy.display.paradigm.vep.layout import BoxConfiguration from bcipy.helpers.clock import Clock -from bcipy.helpers.parameters import Parameters -from bcipy.helpers.triggers import TriggerType +from bcipy.core.parameters import Parameters +from bcipy.core.triggers import TriggerType from bcipy.task.calibration import BaseCalibrationTask, Inquiry from bcipy.task.paradigm.vep.stim_generation import \ generate_vep_calibration_inquiries diff --git a/bcipy/task/paradigm/vep/stim_generation.py b/bcipy/task/paradigm/vep/stim_generation.py index e5111f8e6..b39a173c4 100644 --- a/bcipy/task/paradigm/vep/stim_generation.py +++ b/bcipy/task/paradigm/vep/stim_generation.py @@ -4,9 +4,9 @@ import random from typing import Any, List, Optional -from bcipy.helpers.list import find_index, swapped -from bcipy.helpers.stimuli import (InquirySchedule, get_fixation, - random_target_positions) +from bcipy.core.list import find_index, swapped +from bcipy.core.stimuli import (InquirySchedule, get_fixation, + random_target_positions) def generate_vep_calibration_inquiries(alp: List[str], diff --git a/bcipy/task/tests/core/test_handler.py b/bcipy/task/tests/core/test_handler.py index b34914649..237f7cc51 100644 --- a/bcipy/task/tests/core/test_handler.py +++ b/bcipy/task/tests/core/test_handler.py @@ -5,7 +5,7 @@ from bcipy.task.control.criteria import CriteriaEvaluator, \ MaxIterationsCriteria, MinIterationsCriteria, ProbThresholdCriteria from bcipy.task.control.query import NBestStimuliAgent -from bcipy.helpers.symbols import alphabet +from bcipy.core.symbols import alphabet class TestDecisionMaker(unittest.TestCase): diff --git a/bcipy/task/tests/orchestrator/test_orchestrator.py b/bcipy/task/tests/orchestrator/test_orchestrator.py index f2805a849..adcc6102a 100644 --- a/bcipy/task/tests/orchestrator/test_orchestrator.py +++ b/bcipy/task/tests/orchestrator/test_orchestrator.py @@ -6,7 +6,7 @@ from bcipy.task.orchestrator import SessionOrchestrator from bcipy.task import Task, TaskData from bcipy.config import DEFAULT_PARAMETERS_PATH -from bcipy.helpers.load import load_json_parameters +from bcipy.io.load import load_json_parameters class TestSessionOrchestrator(unittest.TestCase): diff --git a/bcipy/task/tests/paradigm/matrix/test_matrix_calibration.py b/bcipy/task/tests/paradigm/matrix/test_matrix_calibration.py index 1e4c9b073..ef7878de7 100644 --- a/bcipy/task/tests/paradigm/matrix/test_matrix_calibration.py +++ b/bcipy/task/tests/paradigm/matrix/test_matrix_calibration.py @@ -10,8 +10,8 @@ from bcipy.acquisition.devices import DeviceSpec from bcipy.acquisition.multimodal import ContentType from bcipy.display.paradigm.matrix import MatrixDisplay -from bcipy.helpers.parameters import Parameters -from bcipy.helpers.triggers import TriggerHandler, TriggerType +from bcipy.core.parameters import Parameters +from bcipy.core.triggers import TriggerHandler, TriggerType from bcipy.task.paradigm.matrix.calibration import MatrixCalibrationTask diff --git a/bcipy/task/tests/paradigm/rsvp/calibration/test_rsvp_calibration.py b/bcipy/task/tests/paradigm/rsvp/calibration/test_rsvp_calibration.py index 2ab8fc9dd..9fcbbc339 100644 --- a/bcipy/task/tests/paradigm/rsvp/calibration/test_rsvp_calibration.py +++ b/bcipy/task/tests/paradigm/rsvp/calibration/test_rsvp_calibration.py @@ -9,8 +9,8 @@ from bcipy.acquisition import LslAcquisitionClient from bcipy.acquisition.devices import DeviceSpec from bcipy.acquisition.multimodal import ContentType -from bcipy.helpers.parameters import Parameters -from bcipy.helpers.triggers import TriggerHandler, TriggerType +from bcipy.core.parameters import Parameters +from bcipy.core.triggers import TriggerHandler, TriggerType from bcipy.task.paradigm.rsvp.calibration.calibration import \ RSVPCalibrationTask diff --git a/bcipy/task/tests/paradigm/rsvp/test_copy_phrase.py b/bcipy/task/tests/paradigm/rsvp/test_copy_phrase.py index d5465fe29..cba6e320b 100644 --- a/bcipy/task/tests/paradigm/rsvp/test_copy_phrase.py +++ b/bcipy/task/tests/paradigm/rsvp/test_copy_phrase.py @@ -15,9 +15,9 @@ from bcipy.config import DEFAULT_ENCODING from bcipy.exceptions import TaskConfigurationException from bcipy.helpers.copy_phrase_wrapper import CopyPhraseWrapper -from bcipy.helpers.parameters import Parameters -from bcipy.helpers.stimuli import InquirySchedule -from bcipy.helpers.triggers import TriggerHandler +from bcipy.core.parameters import Parameters +from bcipy.core.stimuli import InquirySchedule +from bcipy.core.triggers import TriggerHandler from bcipy.task.data import EvidenceType, Session from bcipy.task.paradigm.rsvp.copy_phrase import RSVPCopyPhraseTask diff --git a/bcipy/task/tests/paradigm/rsvp/test_copy_phrase_task_summary.py b/bcipy/task/tests/paradigm/rsvp/test_copy_phrase_task_summary.py index a98840db8..87573239d 100644 --- a/bcipy/task/tests/paradigm/rsvp/test_copy_phrase_task_summary.py +++ b/bcipy/task/tests/paradigm/rsvp/test_copy_phrase_task_summary.py @@ -4,7 +4,7 @@ from mock import patch -from bcipy.helpers.triggers import Trigger +from bcipy.core.triggers import Trigger from bcipy.task.data import EvidenceType, Inquiry, Session from bcipy.task.paradigm.rsvp.copy_phrase import TaskSummary diff --git a/bcipy/task/tests/paradigm/vep/test_stimuli_vep.py b/bcipy/task/tests/paradigm/vep/test_stimuli_vep.py index b7f4d5170..f2d292b40 100644 --- a/bcipy/task/tests/paradigm/vep/test_stimuli_vep.py +++ b/bcipy/task/tests/paradigm/vep/test_stimuli_vep.py @@ -1,6 +1,6 @@ import unittest -from bcipy.helpers.symbols import alphabet +from bcipy.core.symbols import alphabet from bcipy.task.paradigm.vep.stim_generation import ( generate_vep_calibration_inquiries, generate_vep_inquiry, stim_per_box) diff --git a/mypy.ini b/mypy.ini index 8015d10e0..4b7d9c01c 100644 --- a/mypy.ini +++ b/mypy.ini @@ -8,4 +8,4 @@ strict_optional = True disallow_incomplete_defs = False check_untyped_defs = True no_implicit_optional = True -exclude = tests|demo|gui|scripts|acquisition|display.paradigm|language|signal.model|task.control|helpers.parameters +exclude = tests|demo|gui|scripts|acquisition|display.paradigm|language|signal.model|task.control|core.parameters diff --git a/requirements.txt b/requirements.txt index 4610bad99..f49cbb0f9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,11 @@ attrdict3==2.0.2 +EDFlib-Python==1.0.8 transformers==4.26.0 torch==2.0.0 construct==2.8.14 mne==1.6.1 +mne-bids==0.14.0 +pybv==0.7.5 pyo==1.0.5 pyglet<=1.5.27,>=1.4 PsychoPy==2024.2.1 @@ -12,14 +15,13 @@ sounddevice==0.4.4 SoundFile==0.12.1 scipy==1.10.1 scikit-learn==1.2.2 -seaborn==0.9.0 +seaborn==0.13.2 matplotlib==3.7.5 pylsl==1.16.2 pandas==2.0.3 psutil==5.7.2 Pillow==9.4.0 py-cpuinfo==9.0.0 -pyedflib==0.1.34 pyopengl==3.1.7 PyQt6==6.7.1 pywavelets==1.4.1 diff --git a/scripts/python/lm_eval.py b/scripts/python/lm_eval.py index bc377f7b6..1e626b3da 100644 --- a/scripts/python/lm_eval.py +++ b/scripts/python/lm_eval.py @@ -5,7 +5,7 @@ from bcipy.language.main import ResponseType from math import log10 from timeit import default_timer as timer -from bcipy.helpers.symbols import SPACE_CHAR, alphabet +from bcipy.core.symbols import SPACE_CHAR, alphabet import argparse import numpy as np import sys diff --git a/scripts/python/replay_session.py b/scripts/python/replay_session.py index 053f4abd2..dc24b6da0 100644 --- a/scripts/python/replay_session.py +++ b/scripts/python/replay_session.py @@ -15,11 +15,11 @@ DEFAULT_PARAMETERS_FILENAME, RAW_DATA_FILENAME, SESSION_DATA_FILENAME, TRIGGER_FILENAME) from bcipy.helpers.acquisition import analysis_channels -from bcipy.helpers.list import grouper -from bcipy.helpers.load import load_json_parameters, load_raw_data -from bcipy.helpers.stimuli import InquiryReshaper, update_inquiry_timing -from bcipy.helpers.symbols import alphabet -from bcipy.helpers.triggers import TriggerType, trigger_decoder +from bcipy.core.list import grouper +from bcipy.io.load import load_json_parameters, load_raw_data +from bcipy.core.stimuli import InquiryReshaper, update_inquiry_timing +from bcipy.core.symbols import alphabet +from bcipy.core.triggers import TriggerType, trigger_decoder from bcipy.signal.model import PcaRdaKdeModel, SignalModel from bcipy.signal.process import (ERPTransformParams, filter_inquiries, get_default_transform) From 69e3e0d8cb7cb5e357ce17389f568016738b7457 Mon Sep 17 00:00:00 2001 From: Tab Memmott Date: Mon, 9 Dec 2024 14:32:30 -0800 Subject: [PATCH 18/76] update imports --- bcipy/helpers/acquisition.py | 1 + bcipy/helpers/tests/test_acquisition.py | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bcipy/helpers/acquisition.py b/bcipy/helpers/acquisition.py index 9b9cf6c1a..95ece417c 100644 --- a/bcipy/helpers/acquisition.py +++ b/bcipy/helpers/acquisition.py @@ -13,6 +13,7 @@ preconfigured_device, with_content_type) from bcipy.config import BCIPY_ROOT from bcipy.config import DEFAULT_DEVICE_SPEC_FILENAME as spec_name +from bcipy.config import RAW_DATA_FILENAME, SESSION_LOG_FILENAME from bcipy.io.save import save_device_specs logger = logging.getLogger(SESSION_LOG_FILENAME) diff --git a/bcipy/helpers/tests/test_acquisition.py b/bcipy/helpers/tests/test_acquisition.py index ea7c66106..bfa6a1f71 100644 --- a/bcipy/helpers/tests/test_acquisition.py +++ b/bcipy/helpers/tests/test_acquisition.py @@ -10,8 +10,7 @@ max_inquiry_duration, parse_stream_type, raw_data_filename, server_spec, stream_types) -from bcipy.io.load import load_json_parameters -from bcipy.io.save import init_save_data_structure +from bcipy.core.parameters import Parameters class TestAcquisition(unittest.TestCase): From 6013d2fc9dcf66c67817f3884782249398d7875d Mon Sep 17 00:00:00 2001 From: tab-cmd Date: Mon, 9 Dec 2024 17:52:48 -0800 Subject: [PATCH 19/76] BIDS DSI-24, ET Data, and 1020 support (#369) --- CHANGELOG.md | 2 +- README.md | 2 +- bcipy/core/demo/demo_report.py | 5 +- bcipy/core/demo/demo_session_tools.py | 2 +- bcipy/core/raw_data.py | 31 ++++ bcipy/core/tests/test_raw_data.py | 23 ++- bcipy/gui/file_dialog.py | 46 ++++-- bcipy/helpers/demo/demo_visualization.py | 5 +- bcipy/helpers/offset.py | 2 +- bcipy/io/README.md | 6 +- bcipy/io/convert.py | 135 +++++++++++++---- bcipy/io/demo/demo_convert.py | 103 +++++++++++-- bcipy/io/load.py | 51 +++---- bcipy/io/tests/test_convert.py | 183 ++++++++++++++++++++++- bcipy/signal/evaluate/artifact.py | 3 +- bcipy/signal/model/offline_analysis.py | 4 +- bcipy/task/actions.py | 3 +- requirements.txt | 1 + 18 files changed, 502 insertions(+), 105 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe07fbcb4..4746f2d14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## Contributions - BIDS - - Bundling support and refactor of `convert` module. See `demo_convert.py` #362 + - Bundling support and refactor of `convert` module. See `demo_convert.py` #362 Add support for 1020 channels and eye tracker data #369 - Library Refactor - Refactor `helpers` into `io` and `core` #362 - Dependencies diff --git a/README.md b/README.md index f37abfc99..5eed97bc5 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ This a list of the major modules and their functionality. Each module will conta - `feedback`: feedback mechanisms for sound and visual stimuli. - `gui`: end-user interface into registered bci tasks and parameter editing. See BCInterface.py. - `helpers`: helpful functions needed for interactions between modules and general utility. -- `io`: load, save, and convert data files. Ex. BrainVision, EDF, MNE, CSV, JSON, etc. +- `io`: load, save, and convert data files. Ex. BIDS, BrainVision, EDF, MNE, CSV, JSON, etc. - `language`: gives probabilities of next symbols during typing. - `main`: executor of experiments. Main entry point into the application - `parameters`: location of json parameters. This includes parameters.json (main experiment / app configuration) and device.json (device registry and configuration). diff --git a/bcipy/core/demo/demo_report.py b/bcipy/core/demo/demo_report.py index 862e08e20..3a2771c75 100644 --- a/bcipy/core/demo/demo_report.py +++ b/bcipy/core/demo/demo_report.py @@ -31,7 +31,10 @@ # if no path is provided, prompt for one using a GUI path = args.path if not path: - path = load_experimental_data() + path = load_experimental_data( + message="Select the folder containing the raw_data.csv, parameters.json and triggers.txt", + strict=True + ) trial_window = (0, 1.0) diff --git a/bcipy/core/demo/demo_session_tools.py b/bcipy/core/demo/demo_session_tools.py index a6721a7e2..b3c06dd51 100644 --- a/bcipy/core/demo/demo_session_tools.py +++ b/bcipy/core/demo/demo_session_tools.py @@ -39,7 +39,7 @@ def main(data_dir: str): args = parser.parse_args() path = args.path if not path: - path = ask_directory() + path = ask_directory(strict=True) if args.db or args.csv or args.charts: session = read_session(Path(path, SESSION_DATA_FILENAME)) diff --git a/bcipy/core/raw_data.py b/bcipy/core/raw_data.py index d94ac2174..297ac1e10 100644 --- a/bcipy/core/raw_data.py +++ b/bcipy/core/raw_data.py @@ -402,3 +402,34 @@ def sample_data(rows: int = 1000, data.append([timestamp] + channel_data + [trg]) return data + + +def get_1020_channels() -> List[str]: + """Returns the standard 10-20 channel names. + + Note: The 10-20 system is a standard for EEG electrode placement. The following is not a complete list of all + possible channels, but the most common ones used in BCI research. This excludes the reference and ground channels. + + Returns + ------- + list of channel names + """ + return [ + 'Fp1', 'Fp2', 'F7', 'F3', 'Fz', 'F4', 'F8', 'T3', 'C3', 'Cz', 'C4', + 'T4', 'T5', 'P3', 'Pz', 'P4', 'T6', 'O1', 'O2' + ] + + +def get_1020_channel_map(channels_name: List[str]) -> List[int]: + """Returns a list of 1s and 0s indicating if the channel name is in the 10-20 system. + + Parameters + ---------- + channels_name : list of channel names + + Returns + ------- + list of 1s and 0s indicating if the channel name is in the 10-20 system + """ + valid_channels = get_1020_channels() + return [1 if name in valid_channels else 0 for name in channels_name] diff --git a/bcipy/core/tests/test_raw_data.py b/bcipy/core/tests/test_raw_data.py index 71093157e..2ba537c71 100644 --- a/bcipy/core/tests/test_raw_data.py +++ b/bcipy/core/tests/test_raw_data.py @@ -12,7 +12,8 @@ from bcipy.exceptions import BciPyCoreException from bcipy.core.raw_data import (RawData, RawDataReader, RawDataWriter, - load, sample_data, settings, write) + load, sample_data, settings, write, + get_1020_channel_map, get_1020_channels) class TestRawData(unittest.TestCase): @@ -377,5 +378,25 @@ def test_data_by_channel_map_applies_transformation(self): verify(RawData, times=1).by_channel(transform) +class Test1020(unittest.TestCase): + """Tests for 10-20 channel mapping functions.""" + + def test_get_1020_channels(self): + """Tests that the 10-20 channel map is correctly generated.""" + channels = get_1020_channels() + self.assertEqual(19, len(channels)) + self.assertTrue(isinstance(channels[0], str)) + + def test_get_1020_channel_map(self): + """Tests that the 10-20 channel map is correctly generated.""" + # all but the last channel are valid 10-20 channels + channels = ['Fp1', 'Fp2', 'F3', 'F4', 'C3', 'C4', 'P3', 'P4', 'O1', 'invalid'] + channel_map = get_1020_channel_map(channels) + self.assertEqual(10, len(channel_map)) + self.assertEqual(0, channel_map[-1]) + for i in range(len(channels) - 1): + self.assertEqual(1, channel_map[i]) + + if __name__ == '__main__': unittest.main() diff --git a/bcipy/gui/file_dialog.py b/bcipy/gui/file_dialog.py index 715b223b9..f8271fc6e 100644 --- a/bcipy/gui/file_dialog.py +++ b/bcipy/gui/file_dialog.py @@ -1,11 +1,13 @@ # pylint: disable=no-name-in-module,missing-docstring,too-few-public-methods import sys from pathlib import Path +from typing import Union from PyQt6 import QtGui from PyQt6.QtWidgets import QApplication, QFileDialog, QWidget from bcipy.preferences import preferences +from bcipy.exceptions import BciPyCoreException DEFAULT_FILE_TYPES = "All Files (*)" @@ -61,18 +63,22 @@ def ask_directory(self, directory: str = "", prompt: str = "Select Directory") - def ask_filename( file_types: str = DEFAULT_FILE_TYPES, directory: str = "", - prompt: str = "Select File") -> str: - """Prompt for a file. + prompt: str = "Select File", + strict: bool = False) -> Union[str, BciPyCoreException]: + """Prompt for a file using a GUI. Parameters ---------- - file_types : optional file type filters; Examples: 'Text files (*.txt)' or 'Image files (*.jpg *.gif)' or '*.csv;;*.pkl' - directory : optional directory + - prompt : optional prompt message to display to users + - strict : optional flag to raise an exception if the user cancels the dialog. Default is False. + If False, an empty string is returned. Returns ------- - path to file or None if the user cancelled the dialog. + path to file or raises an exception if the user cancels the dialog. """ app = QApplication(sys.argv) dialog = FileDialog() @@ -84,18 +90,29 @@ def ask_filename( if filename and path.is_file(): preferences.last_directory = str(path.parent) - # Alternatively, we could use `app.closeAllWindows()` - app.quit() + # Alternatively, we could use `app.closeAllWindows()` + app.quit() + + return filename + + if strict: + raise BciPyCoreException('No file selected.') + + return '' - return filename +def ask_directory(prompt: str = "Select Directory", strict: bool = False) -> Union[str, BciPyCoreException]: + """Prompt for a directory using a GUI. -def ask_directory(prompt: str = "Select Directory") -> str: - """Prompt for a directory. + Parameters + ---------- + prompt : optional prompt message to display to users + strict : optional flag to raise an exception if the user cancels the dialog. Default is False. + If False, an empty string is returned. Returns ------- - path to directory or None if the user cancelled the dialog. + path to directory or raises an exception if the user cancels the dialog. """ app = QApplication(sys.argv) @@ -107,7 +124,12 @@ def ask_directory(prompt: str = "Select Directory") -> str: if name and Path(name).is_dir(): preferences.last_directory = name - # Alternatively, we could use `app.closeAllWindows()` - app.quit() + # Alternatively, we could use `app.closeAllWindows()` + app.quit() + + return name + + if strict: + raise BciPyCoreException('No directory selected.') - return name + return '' diff --git a/bcipy/helpers/demo/demo_visualization.py b/bcipy/helpers/demo/demo_visualization.py index 2cfc3a040..192a0b01f 100644 --- a/bcipy/helpers/demo/demo_visualization.py +++ b/bcipy/helpers/demo/demo_visualization.py @@ -42,7 +42,10 @@ path = args.path if not path: - path = load_experimental_data() + path = load_experimental_data( + message="Select the folder containing the raw_data.csv, parameters.json and triggers.txt", + strict=True + ) parameters = load_json_parameters(f'{path}/{DEFAULT_PARAMETERS_FILENAME}', value_cast=True) diff --git a/bcipy/helpers/offset.py b/bcipy/helpers/offset.py index 09b8814c8..5e543e56b 100644 --- a/bcipy/helpers/offset.py +++ b/bcipy/helpers/offset.py @@ -340,7 +340,7 @@ def extract_data_latency_calculation( args = parser.parse_args() data_path = args.data_path if not data_path: - data_path = ask_directory() + data_path = ask_directory(prompt="Please select a BciPy time test directory..", strict=True) # grab the stim length from the data directory parameters stim_length = load_json_parameters(f'{data_path}/{DEFAULT_PARAMETERS_FILENAME}', value_cast=True)['stim_length'] diff --git a/bcipy/io/README.md b/bcipy/io/README.md index 38e1541d0..8b04bd539 100644 --- a/bcipy/io/README.md +++ b/bcipy/io/README.md @@ -2,6 +2,6 @@ The BciPy IO module contains functionality for loading and saving data in various formats. This includes the ability to convert data to BIDS format, load and save raw data, and load and save triggers. -- `convert`: functionality for converting the bcipy raw data output to other formats (currently, EDF) -- `load`: methods for loading most BciPy data. For loading of triggers, see triggers.py -- `save`: methods for saving BciPy data in supported formats. For saving of triggers, see triggers.py +- `convert`: functionality for converting the bcipy raw data output to other formats (currently, BrainVision and EDF), and for converting the raw data to BIDS format. +- `load`: methods for loading most BciPy data formats, including raw data and triggers. +- `save`: methods for saving BciPy data in supported formats. diff --git a/bcipy/io/convert.py b/bcipy/io/convert.py index 33adbe2ac..03c03e0e8 100644 --- a/bcipy/io/convert.py +++ b/bcipy/io/convert.py @@ -4,6 +4,7 @@ import os import tarfile from typing import List, Optional, Tuple +import glob import mne from enum import Enum @@ -12,13 +13,12 @@ from tqdm import tqdm from bcipy.acquisition.devices import preconfigured_device -from bcipy.config import (DEFAULT_PARAMETERS_FILENAME, RAW_DATA_FILENAME, +from bcipy.config import (RAW_DATA_FILENAME, TRIGGER_FILENAME, SESSION_LOG_FILENAME) -from bcipy.io.load import load_json_parameters, load_raw_data -from bcipy.core.raw_data import RawData +from bcipy.io.load import load_raw_data +from bcipy.core.raw_data import RawData, get_1020_channel_map from bcipy.core.triggers import trigger_decoder from bcipy.signal.process import Composition -# from bcipy.signal.process import get_default_transform logger = logging.getLogger(SESSION_LOG_FILENAME) @@ -52,7 +52,9 @@ def convert_to_bids( output_dir: str, task_name: Optional[str] = None, line_frequency: float = 60, - format: ConvertFormat = ConvertFormat.BV) -> str: + format: ConvertFormat = ConvertFormat.BV, + label_duration: float = 0.5, + full_labels: bool = True) -> str: """Convert to BIDS. Convert the raw data to the Brain Imaging Data Structure (BIDS) format. @@ -65,13 +67,21 @@ def convert_to_bids( ---------- data_dir - path to the directory containing the raw data, triggers, and parameters participant_id - the participant ID + session_id - the session ID + run_id - the run ID output_dir - the directory to save the BIDS formatted data + task_name - the name of the task + line_frequency - the line frequency of the data (50 or 60 Hz) + format - the format to convert the data to (BrainVision, EDF, FIF, or EEGLAB) + label_duration - the duration of the trigger labels in seconds. Default is 0.5 seconds. + full_labels - if True, include the full trigger labels in the BIDS data. Default is True. If False, only include + the targetness labels (target/non-target). Returns ------- The path to the BIDS formatted data """ - # validate the inputs + # validate the inputs before proceeding if not os.path.exists(data_dir): raise FileNotFoundError(f"Data directory={data_dir} does not exist") if not os.path.exists(output_dir): @@ -87,24 +97,13 @@ def convert_to_bids( # create file paths for raw data, triggers, and parameters raw_data_file = os.path.join(data_dir, f'{RAW_DATA_FILENAME}.csv') trigger_file = os.path.join(data_dir, TRIGGER_FILENAME) - parameters_file = os.path.join(data_dir, DEFAULT_PARAMETERS_FILENAME) - # load the raw data + # load the raw data and specifications for the device used to collect the data raw_data = load_raw_data(raw_data_file) + channel_map = get_1020_channel_map(raw_data.channels) device_spec = preconfigured_device(raw_data.daq_type) volts = True if device_spec.channel_specs[0].units == 'volts' else False - # load the parameters - parameters = load_json_parameters(parameters_file, value_cast=True) - trial_window = parameters.get("trial_window", (0.0, 0.5)) - - if task_name is None: - task_name = parameters.get("task") - if task_name is None: - raise ValueError("Task name must be provided or specified in the parameters") - - window_length = trial_window[1] - trial_window[0] - # load the triggers without removing any triggers other than system triggers (default) trigger_targetness, trigger_timing, trigger_labels = trigger_decoder( trigger_path=trigger_file, @@ -113,19 +112,28 @@ def convert_to_bids( ) # convert the raw data to MNE format - mne_data = convert_to_mne(raw_data, volts=volts, remove_system_channels=True) + mne_data = convert_to_mne(raw_data, volts=volts, channel_map=channel_map) + + # add the trigger annotations to the MNE data targetness_annotations = mne.Annotations( onset=trigger_timing, - duration=[window_length] * len(trigger_timing), + duration=[label_duration] * len(trigger_timing), description=trigger_targetness, ) - label_annotations = mne.Annotations( - onset=trigger_timing, - duration=[window_length] * len(trigger_timing), - description=trigger_labels, - ) - mne_data.set_annotations(targetness_annotations + label_annotations) - mne_data.info["line_freq"] = line_frequency # set the line frequency to 60 Hz + + if full_labels: + label_annotations = mne.Annotations( + onset=trigger_timing, + duration=[label_duration] * len(trigger_timing), + description=trigger_labels, + ) + mne_data.set_annotations(targetness_annotations + label_annotations) + else: + mne_data.set_annotations(targetness_annotations) + # add the line frequency to the MNE data + mne_data.info["line_freq"] = line_frequency + + # create the BIDS path for the data bids_path = BIDSPath( subject=participant_id, session=session_id, @@ -143,7 +151,73 @@ def convert_to_bids( allow_preload=True, overwrite=True) - return bids_path.root + return bids_path.directory + + +def convert_eyetracking_to_bids( + raw_data_path, + output_dir, + participant_id, + session_id, + run_id, + task_name) -> str: + """Converts the raw eye tracking data to BIDS format. + + There is currently no standard for eye tracking data in BIDS. This function will write the raw eye tracking data + to a tsv file in a BIDS-style format. + + Parameters + ---------- + raw_data_path : str + Path to the raw eye tracking data + output_dir : str + Path to the output directory. + This should be where other BIDS formatted data is stored for the participant, session, and run. + participant_id : str + Participant ID, e.g. 'S01' + session_id : str + Session ID, e.g. '01' + run_id : str + Run ID, e.g. '01' + task_name : str + Task name. Example: 'RSVPCalibration' + + Returns + ------- + str + Path to the BIDS formatted eye tracking data + """ + # check that the raw data path exists + if not os.path.exists(raw_data_path): + raise FileNotFoundError(f"Raw eye tracking data path={raw_data_path} does not exist") + + if not os.path.exists(output_dir): + raise FileNotFoundError(f"Output directory={output_dir} does not exist") + + found_files = glob.glob(f"{raw_data_path}/eyetracker*.csv") + if len(found_files) == 0: + raise FileNotFoundError(f"No raw eye tracking data found in directory={raw_data_path}") + if len(found_files) > 1: + raise ValueError(f"Multiple raw eye tracking data files found in directory={raw_data_path}") + + eye_tracking_file = found_files[0] + logger.info(f"Found raw eye tracking data file={eye_tracking_file}") + + # load the raw eye tracking data + raw_data = load_raw_data(eye_tracking_file) + # get the data as a pandas DataFrame + data = raw_data.dataframe + + # make the et subdirectory + et_dir = os.path.join(output_dir, 'et') + os.makedirs(et_dir, exist_ok=True) + + # write the dataframe as a tsv file to the output directory + output_filename = f'sub-{participant_id}_ses-{session_id}_task-{task_name}_run-{run_id}_eyetracking.tsv' + output_path = os.path.join(et_dir, output_filename) + data.to_csv(output_path, sep='\t', index=False) + logger.info(f"Eye tracking data saved to {output_path}") + return output_path def compress(tar_file_name: str, members: List[str]) -> None: @@ -297,7 +371,7 @@ def convert_to_mne( transform: Optional[Composition] = None, montage: str = 'standard_1020', volts: bool = False, - remove_system_channels: bool = False) -> RawArray: + remove_system_channels: bool = True) -> RawArray: """Convert to MNE. Returns BciPy RawData as an MNE RawArray. This assumes all channel names @@ -338,6 +412,7 @@ def convert_to_mne( # if no channel types provided, assume all channels are eeg if not channel_types: + logger.warning("No channel types provided. Assuming all channels are EEG.") channel_types = ['eeg'] * len(channels) # check that number of channel types matches number of channels in the case custom channel types are provided diff --git a/bcipy/io/demo/demo_convert.py b/bcipy/io/demo/demo_convert.py index 37e988215..fd08b71b7 100644 --- a/bcipy/io/demo/demo_convert.py +++ b/bcipy/io/demo/demo_convert.py @@ -4,33 +4,80 @@ `python bcipy/io/demo/demo_convert.py -d "path://to/bcipy/data/folder"` """ -from typing import Optional -from bcipy.io.convert import convert_to_bids, ConvertFormat +from typing import Optional, List +from pathlib import Path +from bcipy.io.convert import convert_to_bids, ConvertFormat, convert_eyetracking_to_bids from bcipy.gui.file_dialog import ask_directory -from bcipy.io.load import load_bcipy_data +from bcipy.io.load import BciPySessionTaskData, load_bcipy_data EXCLUDED_TASKS = ['Report', 'Offline', 'Intertask', 'BAD'] +def load_historical_bcipy_data(directory: str, experiment_id: str, + task_name: str = 'MatrixCalibration') -> List[BciPySessionTaskData]: + """Load the data from the given directory. + + The expected directory structure is: + + directory/ + user1/ + task_run/ + raw_data.csv + user2/ + task_run/ + raw_data.csv + """ + + data = Path(directory) + experiment_data = [] + for participant in data.iterdir(): + + # Skip files + if not participant.is_dir(): + continue + + # pull out the user id. This is the name of the folder + user_id = participant.name + + for task_run in participant.iterdir(): + if not task_run.is_dir(): + continue + + session_data = BciPySessionTaskData( + path=task_run, + user_id=user_id, + experiment_id=experiment_id, + session_id=1, + run=1, + task_name=task_name + ) + experiment_data.append(session_data) + return experiment_data + + def convert_experiment_to_bids( directory: str, experiment_id: str, format: ConvertFormat = ConvertFormat.BV, - output_dir: Optional[str] = None) -> None: + output_dir: Optional[str] = None, + include_eye_tracker: bool = False) -> None: """Converts the data in the study folder to BIDS format.""" - experiment_data = load_bcipy_data( - directory, - experiment_id=experiment_id, - excluded_tasks=EXCLUDED_TASKS, - anonymize=False) + # Use for data pre-2.0rc4 + # experiment_data = load_historical_bcipy_data( + # directory, + # experiment_id=experiment_id) + + # Use for data post-2.0rc4 + experiment_data = load_bcipy_data(directory, experiment_id, excluded_tasks=EXCLUDED_TASKS) + if not output_dir: output_dir = directory errors = [] for data in experiment_data: try: - convert_to_bids( + bids_path = convert_to_bids( data_dir=data.path, participant_id=data.user_id, session_id=data.session_id, @@ -39,6 +86,24 @@ def convert_experiment_to_bids( output_dir=f'{output_dir}/bids_{experiment_id}/', format=format ) + + if include_eye_tracker: + # Convert the eye tracker data + # The eye tracker data is in the same folder as the EEG data, but should be saved in a different folder + bids_path = Path(bids_path).parent + try: + convert_eyetracking_to_bids( + raw_data_path=data.path, + participant_id=data.user_id, + session_id=data.session_id, + run_id=data.run, + output_dir=bids_path, + task_name=data.task_name + ) + except Exception as e: + print(f"Error converting eye tracker data for {data.path} - {e}") + errors.append(f"Error converting eye tracker data for {data.path}") + except Exception as e: print(f"Error converting {data.path} - {e}") errors.append(str(data.path)) @@ -64,14 +129,26 @@ def convert_experiment_to_bids( '-e', '--experiment', help='Experiment ID to convert', - default='SCRD', + default='Matrix Multimodal Experiment', + ) + parser.add_argument( + '-et', + '--eye_tracker', + help='Include eye tracker data', + default=False, + action='store_true', ) args = parser.parse_args() path = args.directory if not path: - path = ask_directory("Select the directory with data to be converted") + path = ask_directory("Select the directory with data to be converted", strict=True) # convert a study to BIDS format - convert_experiment_to_bids(path, args.experiment, ConvertFormat.BV) + convert_experiment_to_bids( + path, + args.experiment, + ConvertFormat.BV, + output_dir=".", + include_eye_tracker=args.eye_tracker) diff --git a/bcipy/io/load.py b/bcipy/io/load.py index 691bc7476..9bbf1e5ae 100644 --- a/bcipy/io/load.py +++ b/bcipy/io/load.py @@ -10,7 +10,6 @@ from bcipy.config import (DEFAULT_ENCODING, DEFAULT_EXPERIMENT_PATH, DEFAULT_FIELD_PATH, DEFAULT_PARAMETERS_PATH, - DEFAULT_PARAMETERS_FILENAME, EXPERIMENT_FILENAME, FIELD_FILENAME, SIGNAL_MODEL_FILE_SUFFIX, SESSION_LOG_FILENAME) from bcipy.gui.file_dialog import ask_directory, ask_filename @@ -156,8 +155,8 @@ def load_json_parameters(path: str, value_cast: bool = False) -> Parameters: return Parameters(source=path, cast_values=value_cast) -def load_experimental_data() -> str: - filename = ask_directory() # show dialog box and return the path +def load_experimental_data(message='', strict=False) -> str: + filename = ask_directory(prompt=message, strict=strict) # show dialog box and return the path log.info("Loaded Experimental Data From: %s" % filename) return filename @@ -307,10 +306,10 @@ def fast_scandir(directory_name: str, return_path: bool = True) -> List[str]: class BciPySessionTaskData: """Session Task Data. - This class is used to represent a single task data session. It is used to store the + This class is used to represent a single task session. It is used to store the path to the task data, as well as the parameters and other information about the task. - ////// + // protocol.json / parameters.json @@ -322,39 +321,32 @@ def __init__( self, path: str, user_id: str, - date: str, experiment_id: str, - date_time: str, - task: str, + date_time: Optional[str] = None, + date: Optional[str] = None, + task_name: Optional[str] = None, + session_id: int = 1, run: int = 1) -> None: self.user_id = user_id - self.date = date self.experiment_id = experiment_id.replace('_', '') - self.date_time = date_time.replace("_", "").replace("-", "") - self.session_id = f'{self.experiment_id}{self.date_time}' + self.session_id = f'0{str(session_id)}' if session_id < 10 else str(session_id) self.date_time = date_time - self.task = task - self.run = str(run) + self.date = date + self.run = f'0{str(run)}' if run < 10 else str(run) self.path = path - self.parameters = self.get_parameters() - self.task_name = self.parameters.get('task', 'unknown').replace(' ', '') + self.task_name = task_name self.info = { - 'user_id': user_id, - 'date': date, + 'user_id': self.user_id, 'experiment_id': self.experiment_id, - 'date_time': self.date_time, - 'task': task, 'task_name': self.task_name, - 'run': run, - 'path': path + 'session_id': self.session_id, + 'run': self.run, + 'date': self.date, + 'date_time': self.date_time, + 'path': self.path } - def get_parameters(self) -> Parameters: - return load_json_parameters( - f'{self.path}/{DEFAULT_PARAMETERS_FILENAME}', - value_cast=True) - def __str__(self): return f'BciPySessionTaskData: {self.info=}' @@ -549,15 +541,16 @@ def load_tasks(self) -> None: date = task_path.parts[-4] experiment_id = task_path.parts[-3] date_time = task_path.parts[-2] + task_name = task.split(date)[0].strip('_') self.session_task_data.append( BciPySessionTaskData( path=task_path, user_id=user_id, + date_time=date_time, date=date, experiment_id=experiment_id, - date_time=date_time, run=run, - task=task + task_name=task_name ) ) run += 1 @@ -578,7 +571,7 @@ def load_bcipy_data( Walks a data directory and returns a list of data paths for the given experiment id, user id, and date. The BciPy data directory is structured as follows: - data_directory/ + data/ user_ids/ dates/ experiment_ids/ diff --git a/bcipy/io/tests/test_convert.py b/bcipy/io/tests/test_convert.py index 3898e5801..d9e6d8d76 100644 --- a/bcipy/io/tests/test_convert.py +++ b/bcipy/io/tests/test_convert.py @@ -18,7 +18,8 @@ convert_to_mne, decompress, norm_to_tobii, - tobii_to_norm + tobii_to_norm, + convert_eyetracking_to_bids ) from bcipy.core.parameters import Parameters from bcipy.core.raw_data import RawData, sample_data, write @@ -244,7 +245,7 @@ def setUp(self): 'notch_filter_frequency': 60, 'down_sampling_rate': 3 } - self.channels = ['timestamp', 'O1', 'O2', 'Pz'] + self.channels = ['timestamp', 'O1', 'O2', 'Pz', 'TRG', 'lsl_timestamp'] self.raw_data = RawData('SampleDevice', self.sample_rate, self.channels) devices.register(devices.DeviceSpec('SampleDevice', channels=self.channels, sample_rate=self.sample_rate)) @@ -264,26 +265,46 @@ def test_convert_to_mne_defaults(self): data = convert_to_mne(self.raw_data) self.assertTrue(len(data) > 0) - self.assertEqual(data.ch_names, self.channels[1:]) + self.assertEqual(data.ch_names, self.channels[1:-2]) self.assertEqual(data.info['sfreq'], self.sample_rate) def test_convert_to_mne_with_channel_map(self): """Test the convert_to_mne function with channel mapping""" # here we know only three channels are generated, using the channel map let's only use the last one - channel_map = [0, 0, 1] + channel_map = [0, 0, 1, 0, 0] data = convert_to_mne(self.raw_data, channel_map=channel_map) self.assertTrue(len(data) > 0) self.assertTrue(len(data.ch_names) == 1) # this is the main assertion! self.assertEqual(data.info['sfreq'], self.sample_rate) + def test_convert_to_mne_with_remove_system_channels(self): + """Test the convert_to_mne function with system channels removed""" + data = convert_to_mne(self.raw_data, remove_system_channels=True) + + self.assertTrue(len(data) > 0) + self.assertEqual(data.ch_names, self.channels[1:-2]) + self.assertEqual(data.info['sfreq'], self.sample_rate) + + def test_convert_to_mne_without_remove_system_channels_throws_error(self): + """Test the convert_to_mne function with system channels removed raises an error. + + This is due to MNE requiring the channels be EEG. The system channels are not EEG. + """ + with self.assertRaises(ValueError): + data = convert_to_mne(self.raw_data, remove_system_channels=False) + + self.assertTrue(len(data) > 0) + self.assertEqual(data.ch_names, self.channels[1:]) + self.assertEqual(data.info['sfreq'], self.sample_rate) + def test_convert_to_mne_with_channel_types(self): """Test the convert_to_mne function with channel types""" channel_types = ['eeg', 'eeg', 'seeg'] data = convert_to_mne(self.raw_data, channel_types=channel_types) self.assertTrue(len(data) > 0) - self.assertEqual(data.ch_names, self.channels[1:]) + self.assertEqual(data.ch_names, self.channels[1:-2]) self.assertEqual(data.info['sfreq'], self.sample_rate) self.assertTrue(data.get_channel_types()[2] == 'seeg') @@ -297,7 +318,7 @@ def transform(x, fs): data = convert_to_mne(self.raw_data, transform=transform, volts=True) self.assertTrue(len(data) > 0) - self.assertEqual(data.ch_names, self.channels[1:]) + self.assertEqual(data.ch_names, self.channels[1:-2]) self.assertEqual(data.info['sfreq'], self.sample_rate) # apply the transform to the first data point and compare to data returned @@ -309,7 +330,7 @@ def test_convert_to_mne_with_mv_conversion(self): data = convert_to_mne(self.raw_data, volts=False) self.assertTrue(len(data) > 0) - self.assertEqual(data.ch_names, self.channels[1:]) + self.assertEqual(data.ch_names, self.channels[1:-2]) self.assertEqual(data.info['sfreq'], self.sample_rate) # apply the transform to the first data point and compare to data returned @@ -325,7 +346,7 @@ def test_convert_to_mne_with_custom_montage(self): data = convert_to_mne(self.raw_data, montage=montage_type) self.assertTrue(len(data) > 0) - self.assertEqual(data.ch_names, self.channels[1:]) + self.assertEqual(data.ch_names, self.channels[1:-2]) self.assertEqual(data.info['sfreq'], self.sample_rate) @@ -445,5 +466,151 @@ def test_norm_to_tobii_raises_error_with_invalid_units(self): norm_to_tobii(norm_data) +class TestConvertETBIDS(unittest.TestCase): + + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + self.trg_data, self.data, self.params = create_bcipy_session_artifacts(self.temp_dir, channels=3) + self.eyetracking_data = sample_data( + ch_names=[ + 'timestamp', + 'x', + 'y', + 'pupil'], + daq_type='Gaze', + sample_rate=60, + rows=5000) + devices.register(devices.DeviceSpec('Gaze', channels=['timestamp', 'x', 'y', 'pupil'], sample_rate=60)) + + write(self.eyetracking_data, Path(self.temp_dir, 'eyetracker.csv')) + + def tearDown(self): + shutil.rmtree(self.temp_dir) + + def test_convert_eyetracking_to_bids_generates_bids_strucutre(self): + """Test the convert_eyetracking_to_bids function""" + response = convert_eyetracking_to_bids( + f"{self.temp_dir}/", + participant_id='01', + session_id='01', + run_id='01', + task_name='TestTask', + output_dir=self.temp_dir, + ) + self.assertTrue(os.path.exists(response)) + # Assert the session directory was created with et + self.assertTrue(os.path.exists(f"{self.temp_dir}/et/")) + # Assert the et tsv file was created with the correct name + self.assertTrue(os.path.exists(f"{self.temp_dir}/et/sub-01_ses-01_task-TestTask_run-01_eyetracking.tsv")) + + def test_convert_eyetracking_to_bids_reflects_participant_id(self): + """Test the convert_eyetracking_to_bids function with a participant id""" + response = convert_eyetracking_to_bids( + f"{self.temp_dir}/", + participant_id='100', + session_id='01', + run_id='01', + task_name='TestTask', + output_dir=self.temp_dir, + ) + self.assertTrue(os.path.exists(response)) + # Assert the et tsv file was created with the correct name + self.assertTrue(os.path.exists(f"{self.temp_dir}/et/sub-100_ses-01_task-TestTask_run-01_eyetracking.tsv")) + + def test_convert_eyetracking_to_bids_reflects_session_id(self): + """Test the convert_eyetracking_to_bids function with a session id""" + response = convert_eyetracking_to_bids( + f"{self.temp_dir}/", + participant_id='01', + session_id='100', + run_id='01', + task_name='TestTask', + output_dir=self.temp_dir, + ) + self.assertTrue(os.path.exists(response)) + # Assert the et tsv file was created with the correct name + self.assertTrue(os.path.exists(f"{self.temp_dir}/et/sub-01_ses-100_task-TestTask_run-01_eyetracking.tsv")) + + def test_convert_eyetracking_to_bids_reflects_run_id(self): + """Test the convert_eyetracking_to_bids function with a run id""" + response = convert_eyetracking_to_bids( + f"{self.temp_dir}/", + participant_id='01', + session_id='01', + run_id='100', + task_name='TestTask', + output_dir=self.temp_dir, + ) + self.assertTrue(os.path.exists(response)) + # Assert the et tsv file was created with the correct name + self.assertTrue(os.path.exists(f"{self.temp_dir}/et/sub-01_ses-01_task-TestTask_run-100_eyetracking.tsv")) + + def test_convert_eyetracking_to_bids_reflects_task_name(self): + """Test the convert_eyetracking_to_bids function with a task name""" + response = convert_eyetracking_to_bids( + f"{self.temp_dir}/", + participant_id='01', + session_id='01', + run_id='01', + task_name='TestTaskEtc', + output_dir=self.temp_dir, + ) + self.assertTrue(os.path.exists(response)) + # Assert the et tsv file was created with the correct name + self.assertTrue(os.path.exists(f"{self.temp_dir}/et/sub-01_ses-01_task-TestTaskEtc_run-01_eyetracking.tsv")) + + def test_convert_et_raises_error_with_invalid_data_dir(self): + """Test the convert_eyetracking_to_bids function raises an error with invalid output directory""" + with self.assertRaises(FileNotFoundError): + convert_eyetracking_to_bids( + 'invalid_data_dir', + participant_id='01', + session_id='01', + run_id='01', + task_name='TestTask', + output_dir=self.temp_dir + ) + + def test_convert_et_raises_error_with_output_dir_not_exist(self): + """Test the convert_eyetracking_to_bids function raises an error with invalid output directory""" + with self.assertRaises(FileNotFoundError): + convert_eyetracking_to_bids( + f"{self.temp_dir}/", + participant_id='01', + session_id='01', + run_id='01', + task_name='TestTask', + output_dir='invalid_output_dir' + ) + + def test_convert_et_raises_error_with_no_data_file(self): + """Test the convert_eyetracking_to_bids function raises an error with no data file""" + # remove the csv file + os.remove(f"{self.temp_dir}/eyetracker.csv") + with self.assertRaises(FileNotFoundError): + convert_eyetracking_to_bids( + f"{self.temp_dir}/", + participant_id='01', + session_id='01', + run_id='01', + task_name='TestTask', + output_dir=self.temp_dir, + ) + + def test_convert_et_raises_error_with_multiple_data_files(self): + """Test the convert_eyetracking_to_bids function raises an error with multiple data files""" + # create a second data file + write(self.eyetracking_data, Path(self.temp_dir, 'eyetracker_2.csv')) + with self.assertRaises(ValueError): + convert_eyetracking_to_bids( + f"{self.temp_dir}/", + participant_id='01', + session_id='01', + run_id='01', + task_name='TestTask', + output_dir=self.temp_dir, + ) + + if __name__ == '__main__': unittest.main() diff --git a/bcipy/signal/evaluate/artifact.py b/bcipy/signal/evaluate/artifact.py index afffaebcf..3c09e1d04 100644 --- a/bcipy/signal/evaluate/artifact.py +++ b/bcipy/signal/evaluate/artifact.py @@ -566,7 +566,8 @@ def write_mne_annotations( # if no path is provided, prompt for one using a GUI path = args.path if not path: - path = load_experimental_data() + path = load_experimental_data( + message='Select the directory with the sessions to analyze for artifacts', strict=True) positions = None for session in Path(path).iterdir(): diff --git a/bcipy/signal/model/offline_analysis.py b/bcipy/signal/model/offline_analysis.py index e9fe3f04d..70d0068ca 100644 --- a/bcipy/signal/model/offline_analysis.py +++ b/bcipy/signal/model/offline_analysis.py @@ -453,7 +453,9 @@ def offline_analysis( """ assert parameters, "Parameters are required for offline analysis." if not data_folder: - data_folder = load_experimental_data() + data_folder = load_experimental_data( + message="Select the folder containing the data to be analyzed.", + strict=True) # Load default devices which are used for training the model with different channels, etc. devices_by_name = devices.load( diff --git a/bcipy/task/actions.py b/bcipy/task/actions.py index b2429a947..2937a35ff 100644 --- a/bcipy/task/actions.py +++ b/bcipy/task/actions.py @@ -217,7 +217,8 @@ def __init__( if not protocol_path: protocol_path = ask_directory( - prompt="Select BciPy protocol directory with calibration data...") + prompt="Select BciPy protocol directory with calibration data...", + strict=True) self.protocol_path = protocol_path self.last_task_dir = last_task_dir self.trial_window = (-0.2, 1.0) diff --git a/requirements.txt b/requirements.txt index f49cbb0f9..0dd55dca2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,6 +24,7 @@ Pillow==9.4.0 py-cpuinfo==9.0.0 pyopengl==3.1.7 PyQt6==6.7.1 +PyQt6-sip==13.8 pywavelets==1.4.1 tqdm==4.62.2 rich==13.9.4 From 8d7e4dbe60b9867d300ad4193452a4875ca1e638 Mon Sep 17 00:00:00 2001 From: tab-cmd Date: Mon, 9 Dec 2024 20:50:07 -0800 Subject: [PATCH 20/76] Update to use pyproject.toml (#367) --- .bcipy/README.md | 36 --- .../pyWinhook-1.6.2-cp39-cp39-win_amd64.whl | Bin 29981 -> 0 bytes .coveragerc | 25 -- .github/workflows/main.yml | 17 +- .gitignore | 1 + Makefile | 25 +- README.md | 10 +- bcipy/core/session.py | 25 +- .../helpers/demo/demo_copy_phrase_wrapper.py | 57 ----- bcipy/helpers/offset.py | 1 + bcipy/task/demo/control/demo_handler.py | 2 - dev_requirements.txt | 12 - mypy.ini | 11 - pyproject.toml | 184 +++++++++++++++ pytest.ini | 3 - requirements-winmac.txt | 1 - requirements.txt | 32 --- scripts/python/lm_eval.py | 214 ------------------ scripts/python/lsl_buffer_test.py | 92 -------- scripts/python/mixture_tuning.py | 77 ------- scripts/shell/connect_dsi.sh | 27 --- scripts/shell/linux_requirements.sh | 3 +- scripts/shell/m2chip_install.sh | 5 +- setup.cfg | 20 -- setup.py | 136 ----------- 25 files changed, 226 insertions(+), 790 deletions(-) delete mode 100644 .bcipy/README.md delete mode 100644 .bcipy/downloads/pyWinhook-1.6.2-cp39-cp39-win_amd64.whl delete mode 100644 .coveragerc delete mode 100644 bcipy/helpers/demo/demo_copy_phrase_wrapper.py delete mode 100644 dev_requirements.txt delete mode 100644 mypy.ini create mode 100644 pyproject.toml delete mode 100644 pytest.ini delete mode 100644 requirements-winmac.txt delete mode 100644 requirements.txt delete mode 100644 scripts/python/lm_eval.py delete mode 100644 scripts/python/lsl_buffer_test.py delete mode 100644 scripts/python/mixture_tuning.py delete mode 100644 scripts/shell/connect_dsi.sh delete mode 100644 setup.cfg delete mode 100644 setup.py diff --git a/.bcipy/README.md b/.bcipy/README.md deleted file mode 100644 index e1349f633..000000000 --- a/.bcipy/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Experiments and Fields - -BciPy experiments and fields allow users to collect metadata that may be integrated alongside core BciPy data. This is particularly useful when the experiment has completed and a researcher wants to curate their files for sharing with the community. - -## Experiments - -An experiment defines the name, summary and fields to collect. At this level, the requirement for collection during every task and whether or not to anonymize later will be set. The anonymization does not encrypt the data at rest, but instead defines how to handle the field later when uploading/sharing. The registered experiments are defined in `.bcipy/experiment/experiments.json` in the following format: - -```js -{ - name: { - fields : { - name: "", - required: bool, - anonymize: bool - }, - summary: "" - } -} -``` - -## Fields - -A field is a unit of data collection for an experiment. It has a name, help text and type. The type will determine how it is collected and validated. The registered fields are defined in `.bcipy/field/fields.json` in the following format: - - -```js -{ - name: { - help_text: "", - type: "FieldType" - } -} -``` - -where FieldType may be str, bool, int, or float. \ No newline at end of file diff --git a/.bcipy/downloads/pyWinhook-1.6.2-cp39-cp39-win_amd64.whl b/.bcipy/downloads/pyWinhook-1.6.2-cp39-cp39-win_amd64.whl deleted file mode 100644 index d46b4ca0810fd091918ffdbb1d1245d459c35cb4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 29981 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. We also include the 64-bit wheel file in the `.bcipy/downloads/` directory. ### Mac @@ -45,9 +44,8 @@ To install for use locally and use of the GUI: 1. Git clone . 2. Change directory in your terminal to the repo directory. -3. Install the kenlm language model package. `pip install kenlm==0.1 --global-option="--max_order=12"`. -4. Install PsychoPy with no dependencies. `pip install psychopy==2023.2.1 --no-deps`. -5. Install BciPy in development mode. `pip install -e .`. +3. [Optional] Install the kenlm language model package. `pip install kenlm==0.1 --global-option="--max_order=12"`. +4. Install BciPy in development mode. `pip install -e .` If wanting the latest version from PyPi and to build using modules: diff --git a/bcipy/core/session.py b/bcipy/core/session.py index a052a1338..1c4cc5dab 100644 --- a/bcipy/core/session.py +++ b/bcipy/core/session.py @@ -1,10 +1,10 @@ +# mypy: disable-error-code="arg-type" """Helper functions for managing and parsing session.json data.""" import csv import itertools import json import os import sqlite3 -import subprocess from dataclasses import dataclass, fields from typing import Any, Dict, List, Optional @@ -16,12 +16,10 @@ BLACK = COLOR_INDEX[0] WHITE = COLOR_INDEX[1] YELLOW = COLOR_INDEX[5] -from bcipy.config import (BCIPY_ROOT, DEFAULT_ENCODING, - DEFAULT_PARAMETERS_FILENAME, EXPERIMENT_DATA_FILENAME, +from bcipy.config import (DEFAULT_ENCODING, + DEFAULT_PARAMETERS_FILENAME, SESSION_DATA_FILENAME, SESSION_SUMMARY_FILENAME) -from bcipy.io.load import (load_experiment_fields, load_experiments, - load_json_parameters) -from bcipy.helpers.validate import validate_field_data_written +from bcipy.io.load import load_json_parameters from bcipy.task.data import Session @@ -45,21 +43,6 @@ def session_data(data_dir: str) -> Dict: return data -def collect_experiment_field_data(experiment_name: str, - save_path: str, - file_name: str = EXPERIMENT_DATA_FILENAME) -> None: - experiment = load_experiments()[experiment_name] - experiment_fields = load_experiment_fields(experiment) - - if experiment_fields: - cmd = (f'python {BCIPY_ROOT}/gui/experiments/ExperimentField.py -e' - f' "{experiment_name}" -p "{save_path}" -f "{file_name}"') - subprocess.check_call(cmd, shell=True) - # verify data was written before moving on - if not validate_field_data_written(save_path, file_name): - raise Exception('Field data not collected!') - - @dataclass(frozen=True) class EvidenceRecord: """Record summarizing Inquiry evidence.""" diff --git a/bcipy/helpers/demo/demo_copy_phrase_wrapper.py b/bcipy/helpers/demo/demo_copy_phrase_wrapper.py deleted file mode 100644 index 7a11ea34e..000000000 --- a/bcipy/helpers/demo/demo_copy_phrase_wrapper.py +++ /dev/null @@ -1,57 +0,0 @@ -import numpy as np -from bcipy.acquisition.devices import preconfigured_device -from bcipy.helpers.copy_phrase_wrapper import CopyPhraseWrapper -from bcipy.core.symbols import alphabet -from bcipy.language.model.uniform import UniformLanguageModel -from bcipy.signal.model import PcaRdaKdeModel - -channel_map = [0] + [1] * 16 + [0, 0, 1, 1, 0, 1, 1, 1, 0] -dim_x = 5 -num_ch = len(channel_map) - -# Make up some distributions that data come from -mean_pos = .5 -var_pos = .5 -mean_neg = 0 -var_neg = .5 - - -def demo_copy_phrase_wrapper(): - # We need to train a dummy model - num_x_p = 100 - num_x_n = 900 - - x_p = mean_pos + var_pos * np.random.randn(num_ch, num_x_p, dim_x) - x_n = mean_neg + var_neg * np.random.randn(num_ch, num_x_n, dim_x) - y_p = [1] * num_x_p - y_n = [0] * num_x_n - - train_x = np.concatenate((x_p, x_n), 1) - train_y = np.concatenate((y_p, y_n), 0) - permutation = np.random.permutation(train_x.shape[1]) - train_x = train_x[:, permutation, :] - train_y = train_y[permutation] - - train_x = train_x[list(np.where(np.asarray(channel_map) == 1)[0]), :, :] - - model = PcaRdaKdeModel(k_folds=10) - model.fit(train_x, train_y) - - # Define task and operate - task_list = [('I_LOVE_COOKIES', 'I_LOVE_'), - ('THIS_IS_A_DEMO', 'THIS_IS_A_')] - - task = CopyPhraseWrapper(min_num_inq=1, - max_num_inq=25, - lmodel=UniformLanguageModel(), - device_spec=preconfigured_device('DSI-24'), - signal_model=model, - k=1, - alp=alphabet(), - task_list=task_list) - - print(task) - - -if __name__ == '__main__': - demo_copy_phrase_wrapper() diff --git a/bcipy/helpers/offset.py b/bcipy/helpers/offset.py index 5e543e56b..f0309d34f 100644 --- a/bcipy/helpers/offset.py +++ b/bcipy/helpers/offset.py @@ -111,6 +111,7 @@ def calculate_latency(raw_data: RawData, if value < 1 and diode_enc: diode_enc = False + trigger_diodes_timestamps = [] # Plot triggers.txt data if present; vertical line for each value. if triggers: trigger_diodes_timestamps = [ diff --git a/bcipy/task/demo/control/demo_handler.py b/bcipy/task/demo/control/demo_handler.py index cf8b8e0f9..987d2bf36 100644 --- a/bcipy/task/demo/control/demo_handler.py +++ b/bcipy/task/demo/control/demo_handler.py @@ -39,8 +39,6 @@ def _demo_decision_maker(): conjugator = EvidenceFusion(evidence_names, len_dist=len_alp) decision_maker = DecisionMaker( - min_num_inq=1, - max_num_inq=10, state='', alphabet=alp) diff --git a/dev_requirements.txt b/dev_requirements.txt deleted file mode 100644 index 2ee6263fc..000000000 --- a/dev_requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ -autopep8==1.6.0 -coverage>=6.0 -flake8==5.0.4 -mypy==1.6.1 -lxml -mock -mockito -pylint -pytest -pytest-mpl -wheel -twine==3.2.0 \ No newline at end of file diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index 4b7d9c01c..000000000 --- a/mypy.ini +++ /dev/null @@ -1,11 +0,0 @@ -# Global options: -[mypy] -warn_return_any = False -warn_unused_configs = False -ignore_missing_imports = True -follow_imports = skip -strict_optional = True -disallow_incomplete_defs = False -check_untyped_defs = True -no_implicit_optional = True -exclude = tests|demo|gui|scripts|acquisition|display.paradigm|language|signal.model|task.control|core.parameters diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..71e119c2f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,184 @@ +[build-system] +build-backend = "setuptools.build_meta" +requires = ["setuptools>=61.0", "wheel>=0.37.1"] + +[project] +name = "bcipy" +version = "2.0.0" +authors = [ + {name="CAMBI", email="cambi_support@googlegroups.com"}, +] +description = "Python Software for Brain-Computer Interface Development." +readme = "README.md" +requires-python = ">3.7,<3.11" +classifiers = [ + 'License :: Other/Proprietary License', + 'Topic :: Scientific/Engineering :: Human Machine Interfaces', + 'Intended Audience :: Science/Research', + 'Intended Audience :: End Users/Desktop', + 'Intended Audience :: Developers', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', +] + +dependencies = [ + "attrdict3==2.0.2", + "EDFlib-Python==1.0.8", + "transformers==4.36.0", + "torch==2.2.0", + "construct==2.8.14", + "mne==1.6.1", + "mne-bids==0.14.0", + "pybv==0.7.5", + "pyo==1.0.5", + "pyglet<=1.5.27,>=1.4", + "PsychoPy==2024.2.1", + "openpyxl==3.1.2", + "numpy==1.24.4", + "sounddevice==0.4.4", + "SoundFile==0.12.1", + "scipy==1.10.1", + "scikit-learn==1.2.2", + "seaborn==0.13.2", + "matplotlib==3.7.5", + "pylsl==1.16.2", + "pandas==2.0.3", + "psutil==5.7.2", + "Pillow==10.2.0", + "py-cpuinfo==9.0.0", + "pyopengl==3.1.7", + "PyQt6==6.7.1", + "PyQt6-sip==13.8", + "pywavelets==1.4.1", + "tqdm==4.62.2", + "rich==13.9.4", + "reportlab==4.2.0", + "tables==3.7.0", + "kenlm==0.1", + "pyWinhook==1.6.2;python_version=='3.9' and platform_system=='Windows'", + "WxPython==4.2.1;platform_system!='Linux'", +] + +[project.license] +file = "LICENSE.md" + +[project.optional-dependencies] +dev = [ + "autopep8==1.5.7", + "coverage>=7.0", + "flake8==5.0.4", + "Flake8-pyproject==1.2.3", + "mypy==1.13", + "lxml", + "mock", + "mockito", + "pylint", + "pytest", + "pytest-mpl", +] +release = [ + "twine==3.2.0", + "build==1.2.2.post1", + "wheel==0.43.0", +] + +[project.scripts] +bcipy = "bcipy.main:bcipy_main" +bcipy-erp-viz = "bcipy.helpers.visualization:erp" +bcipy-sim = "bcipy.simulator:main" +bcipy-train = "bcipy.signal.model.offline_analysis:main" + +[project.urls] +"Homepage" = "https://www.cambi.tech/" +"Documentation" = "https://bcipy.github.io/" +"Source" = "https://github.com/CAMBI-tech/BciPy" + +[tool.setuptools] +# https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html +platforms = ["Linux", "Windows", "Mac OS-X"] +include-package-data = true +zip-safe = true + +# Python modules and packages that are included in the +# distribution package (and therefore become importable) +[tool.setuptools.packages.find] +exclude = ["tests", "demo", "data"] + +[tool.distutils.bdist_wheel] +universal = true + +[tool.coverage.run] +omit = [ + "*__init__*", + "*__main__*", + "*usr/local/lib*", + "*tests*", + "*test*", + "*demo*", + "*exceptions.py", + "*visualization.py", + "*/gui/*", +] +source = ["bcipy"] +branch = true + +[tool.coverage.report] +exclude_also = [ + "pragma: no cover", + "def __repr__", + "def __str__", + "raise NotImplementedError", + "@abstract", + "if __name__ == .__main__.:", + "logging.getLogger(__name__)", + "raise AssertionError", + "@(abc\\.)?abstractmethod", + "pass", +] + + +[tool.flake8] + +max_complexity = 10 +max_line_length = 120 +application_import_names = "bcipy" + +ignore = [ + "D205", + "D400", + "F841", + "F821", + "E402", + "E501", + "C901", + "W503", + "W504", +] + +[tool.pytest.ini] +markers = "slow" + +[tool.mypy] + +warn_return_any = false +warn_unused_configs = false +ignore_missing_imports = true +follow_imports = "skip" +strict_optional = true +disallow_incomplete_defs = false +check_untyped_defs = true +no_implicit_optional = true +exclude = [ + "tests", + "demo", + "gui", + "scripts", + "acquisition", + "display.paradigm", + "language", + "signal.model", + "task.control", + "core.parameters", +] diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 95697f21b..000000000 --- a/pytest.ini +++ /dev/null @@ -1,3 +0,0 @@ -[pytest] -markers = - slow: marks tests as slow (deselect with '-m "not slow"') \ No newline at end of file diff --git a/requirements-winmac.txt b/requirements-winmac.txt deleted file mode 100644 index 36616d5f4..000000000 --- a/requirements-winmac.txt +++ /dev/null @@ -1 +0,0 @@ -WxPython==4.2.1 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 0dd55dca2..000000000 --- a/requirements.txt +++ /dev/null @@ -1,32 +0,0 @@ -attrdict3==2.0.2 -EDFlib-Python==1.0.8 -transformers==4.26.0 -torch==2.0.0 -construct==2.8.14 -mne==1.6.1 -mne-bids==0.14.0 -pybv==0.7.5 -pyo==1.0.5 -pyglet<=1.5.27,>=1.4 -PsychoPy==2024.2.1 -openpyxl==3.1.2 -numpy==1.24.4 -sounddevice==0.4.4 -SoundFile==0.12.1 -scipy==1.10.1 -scikit-learn==1.2.2 -seaborn==0.13.2 -matplotlib==3.7.5 -pylsl==1.16.2 -pandas==2.0.3 -psutil==5.7.2 -Pillow==9.4.0 -py-cpuinfo==9.0.0 -pyopengl==3.1.7 -PyQt6==6.7.1 -PyQt6-sip==13.8 -pywavelets==1.4.1 -tqdm==4.62.2 -rich==13.9.4 -reportlab==4.2.0 -tables==3.7.0 \ No newline at end of file diff --git a/scripts/python/lm_eval.py b/scripts/python/lm_eval.py deleted file mode 100644 index 1e626b3da..000000000 --- a/scripts/python/lm_eval.py +++ /dev/null @@ -1,214 +0,0 @@ -from bcipy.language.model.kenlm import KenLMLanguageModel -from bcipy.language.model.mixture import MixtureLanguageModel -from bcipy.language.model.unigram import UnigramLanguageModel -from bcipy.language.model.causal import CausalLanguageModel -from bcipy.language.main import ResponseType -from math import log10 -from timeit import default_timer as timer -from bcipy.core.symbols import SPACE_CHAR, alphabet -import argparse -import numpy as np -import sys - -if __name__ == "__main__": - - parser = argparse.ArgumentParser() - parser.add_argument('--verbose', dest='verbose', type=int, required=True, - help=('0: Only output model averages\n1: Output results from each phrase\n2: ' - 'Output results from each character')) - - parser.add_argument('--model', dest='model', type=int, required=True, - help=('1: Unigram\n2: Mixture (80/20 Causal GPT-2/Unigram)\n3: ' - 'KenLM n-gram\n4: Causal Hugging Face')) - - parser.add_argument('--phrases', dest='phrases', type=str, required=True, - help='Phrase set filename') - - parser.add_argument('--model-name', dest='model_name') - parser.add_argument('--model-dir', - dest='model_dir', - help="Local directory to load fine-tuned causal model") - parser.add_argument("--use-mps", - action="store_true", - help="Use MPS Apple Silicon GPU during inference") - parser.add_argument("--use-cuda", - action="store_true", - help="Use CUDA GPU during inference") - parser.add_argument("--left-context", help="left language model context for causal model", default="") - - args = parser.parse_args() - - verbose = args.verbose - model = args.model - phrases = args.phrases - - if model == 3 and not args.model_dir: - print("ERROR: For KenLM n-gram model you must specify filename of model using --model-dir") - sys.exit(1) - - if model == 4 and not args.model_name: - print("ERROR: For causal model you must specify name of model using --model-name") - sys.exit(1) - - # Allow passing in of space characters in the context using word - args.left_context = args.left_context.replace("", " ") - - device = "cpu" - if args.use_mps: - device = "mps" - elif args.use_cuda: - device = "cuda" - - # Read in the phrase file - phrase_file = open(phrases, "r") - phrases = phrase_file.readlines() - phrase_file.close() - - lm = None - - start = timer() - - symbol_set = alphabet() - response_type = ResponseType.SYMBOL - if model == 1: - lm = UnigramLanguageModel(response_type, symbol_set) - elif model == 2: - lm = MixtureLanguageModel(response_type, symbol_set) - elif model == 3: - lm = KenLMLanguageModel(response_type, symbol_set, args.model_dir) - elif model == 4: - lm = CausalLanguageModel(response_type=response_type, - symbol_set=symbol_set, - lang_model_name=args.model_name, - lm_device=device, - lm_path=args.model_dir, - lm_left_context=args.left_context) - else: - parser.print_help() - exit() - - print(f"Model load time = {timer() - start:.6f}") - - phrase_count = 0 - sum_per_symbol_logprob = 0.0 - zero_prob = 0 - overall_predict_time_arr = np.array([]) - overall_predict_details_arr = np.array([]) - - start = timer() - - # Iterate over phrases - for phrase in phrases: - sentence = phrase.strip() - if len(sentence) > 0: - accum = 0.0 - - # Phrase-level output - if verbose >= 1: - print(f"sentence = '{sentence}'") - - # Split into characters - tokens = sentence.split() - symbols = len(tokens) - - # Initial previous token is the start symbol, initial context empty - prev_token = "" - context = "" - - predict_time_arr = np.array([]) - predict_details_arr = np.array([]) - - # Iterate over characters in phrase - for (i, token) in enumerate(tokens): - start_predict = timer() - correct_char = "" - - # BciPy treats space as underscore - if (token == ""): - token = SPACE_CHAR - correct_char = SPACE_CHAR - else: - correct_char = token.upper() - score = 0.0 - next_char_pred = lm.state_update(list(context)) - - predict_time = timer() - start_predict - predict_time_arr = np.append(predict_time_arr, predict_time) - predict_details_arr = np.append(predict_details_arr, - f"sentence = {sentence}, index = {i}, p( {token} | {prev_token} )") - - # Find the probability for the correct character - p = next_char_pred[[c[0] for c in next_char_pred].index(correct_char)][1] - if p == 0: - zero_prob += 1 - accum = 1 - if verbose >= 2: - print(f"p( {token} | {prev_token} ...) = 0") - print(f"prediction time = {predict_time:.6f}") - break - else: - score = log10(p) - - # Character-level output - if verbose >= 2: - print(f"p( {token} | {prev_token} ...) = {p:.6f} [ {score:.6f} ]") - print(f"prediction time = {predict_time:.6f}") - accum += score - prev_token = token - context += token - - # Compute summary stats on prediction times for this phrase - per_symbol_time = np.average(predict_time_arr) - phrase_std = np.std(predict_time_arr) - phrase_max = np.max(predict_time_arr) - phrase_min = np.min(predict_time_arr) - - # Add this phrase's prediction times to overall array - overall_predict_time_arr = np.append(overall_predict_time_arr, predict_time_arr, axis=None) - overall_predict_details_arr = np.append(overall_predict_details_arr, predict_details_arr, axis=None) - - if accum == 1: - if verbose >= 1: - print("Zero-prob event encountered, terminating phrase") - print(f"per-symbol prediction time = {per_symbol_time:.6f} +/- {phrase_std:.6f} [{phrase_min:.6f}, " - f"{phrase_max:.6f}]\n") - else: - per_symbol_logprob = accum / symbols - - # Phrase-level output - if verbose >= 1: - print(f"sum logprob = {accum:.4f}, per-symbol logprob = {per_symbol_logprob:.4f}, " - f"ppl = {pow(10,-1 * per_symbol_logprob):.4f}") - print(f"per-symbol prediction time = {per_symbol_time:.6f} +/- {phrase_std:.6f} " - f"[{phrase_min:.6f}, {phrase_max:.6f}]\n") - - sum_per_symbol_logprob += per_symbol_logprob - phrase_count += 1 - - average_per_symbol_logprob = sum_per_symbol_logprob / phrase_count - - overall_per_symbol_time = np.average(overall_predict_time_arr) - overall_std_time = np.std(overall_predict_time_arr) - overall_min_time = np.min(overall_predict_time_arr) - overall_max_time = np.max(overall_predict_time_arr) - - ci_floor = overall_per_symbol_time - (3 * overall_std_time) - ci_ceiling = overall_per_symbol_time + (3 * overall_std_time) - - # Model-level output - print(f"OVERALL \ - \nphrases = {phrase_count}, \ - \naverage per symbol logprob = {average_per_symbol_logprob:.4f}, \ - \nppl = {pow(10,-1 * average_per_symbol_logprob):.4f}, \ - \nzero-prob events = {zero_prob} \ - \nper-symbol prediction time = {overall_per_symbol_time:.6f} +/- {overall_std_time:.6f} \ - [{overall_min_time:.6f}, {overall_max_time:.6f}] \ - \n95% CI = [{ci_floor}, {ci_ceiling}]\n") - - print(f"Inference time = {timer() - start:.6f}") - - for (i, time) in enumerate(overall_predict_time_arr): - if time < ci_floor: - print(f"LOW OUTLIER: {overall_predict_details_arr[i]}, predict time = {time:.6f}\n") - if time > ci_ceiling: - print(f"HIGH OUTLIER: {overall_predict_details_arr[i]}, predict time = {time:.6f}\n") diff --git a/scripts/python/lsl_buffer_test.py b/scripts/python/lsl_buffer_test.py deleted file mode 100644 index 426b9137d..000000000 --- a/scripts/python/lsl_buffer_test.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Example to demonstrate behavior when the StreamInlet max_buflen is exceeded. - -Prior to running, start a data streamer, such as the SendData.py example -script included in pylsl. - -Example usage: -$ python3 lsl_buffer_test.py --buffer=1 --seconds=2 -""" - -import time - -from pylsl import StreamInlet, resolve_stream - - -def pull_all_data(inlet: StreamInlet, max_samples: int) -> None: - """Pull all buffered data in the given stream inlet.""" - - print("\nPulling buffered data:") - count = pull_chunk(inlet, max_samples) - while count == max_samples: - count = pull_chunk(inlet, max_samples) - - -def pull_chunk(inlet: StreamInlet, max_samples: int) -> int: - """Pull a chunk of samples from a stream inlet. - Returns the count of samples pulled.""" - - _chunk, timestamps = inlet.pull_chunk(timeout=0.0, max_samples=max_samples) - count = len(timestamps) - - time_range = "" - if timestamps: - time_range = f"{round(timestamps[0], 3)} to {round(timestamps[-1], 3)}" - - print(f"-> pulled {count} samples: {time_range};", - f"sorted: {timestamps == sorted(timestamps)}") - - return count - - -def main(max_buflen: int, max_samples: int, sleep_seconds: int) -> None: - """Continuously read from a StreamInlet, pausing for a given time between - reads and then consuming all data in the stream. - - Parameters - ---------- - max_buflen - StreamInlet max_buflen; buffer size in seconds - max_samples - maximum samples to pull in a single chunk - sleep_seconds - seconds to sleep between data pulls - """ - - # first resolve an EEG stream on the lab network - print("looking for an EEG stream...") - streams = resolve_stream('type', 'EEG') - - # create a new inlet to read from the stream - print("\n---") - print(f"max_buflen seconds: {max_buflen}") - print(f"max_samples: {max_samples}") - print(f"sleep_seconds: {sleep_seconds}") - print("---") - inlet = StreamInlet(streams[0], max_buflen=max_buflen) - - while True: - # pull all available samples - pull_all_data(inlet, max_samples) - time.sleep(sleep_seconds) - - -if __name__ == '__main__': - import argparse - - parser = argparse.ArgumentParser() - parser.add_argument('-b', - '--buffer', - help='StreamInlet max_buflen (seconds)', - default=360, - type=int) - parser.add_argument('-c', - '--chunk', - help='Maximum samples per chunk', - default=1024, - type=int) - parser.add_argument('-s', - '--seconds', - help='Seconds to sleep between data pulls', - default=2, - type=int) - args = parser.parse_args() - main(max_buflen=args.buffer, - max_samples=args.chunk, - sleep_seconds=args.seconds) diff --git a/scripts/python/mixture_tuning.py b/scripts/python/mixture_tuning.py deleted file mode 100644 index e8e5e172f..000000000 --- a/scripts/python/mixture_tuning.py +++ /dev/null @@ -1,77 +0,0 @@ -import argparse -from itertools import permutations -import numpy as np -from math import log10 - -if __name__ == "__main__": - parser = argparse.ArgumentParser() - parser.add_argument('--step', dest='step', type=int, required=True, - help='The number of weight steps to be used in the sweep search') - - parser.add_argument('--in', dest='files', type=str, required=True, nargs='+', - help="Two or more paths pointing to output files from the lm_eval script with --verbose 2") - - args = parser.parse_args() - - step = 1.0 / args.step - possible_weights = np.round(list(np.arange(0, 1.0 + step, step)), 5) - perms = list(permutations(possible_weights, len(args.files))) - - weights = [perm for perm in perms if (np.sum(perm) == 1)] - - files = [] - for f in args.files: - files.append(open(f, 'r')) - - lines = [] - for f in files: - lines.append(f.readlines()) - f.close() - - probs = [] - sentence_pslp_arr = [] - for line in zip(*lines): - if line[0].startswith("sentence = "): - for elm in line: - if not elm.startswith("sentence = "): - print("Line mismatch. Please ensure all files were run on the same phrase set.") - exit() - probs = [] - elif line[0].startswith("p( "): - for elm in line: - if not elm.startswith("p( "): - print("Line mismatch. Please ensure all files were run on the same phrase set.") - exit() - if elm[-5:] == " = 0\n": - print("Error: Component model zero prob.") - exit() - component_probs = [] - mixed_probs = [] - for elm in line: - component_probs.append(float(elm[elm.index("=") + 2:elm.index("[") - 1])) - for weight in weights: - mixed_probs.append(np.dot(weight, component_probs)) - probs.append(mixed_probs) - elif line[0].startswith("sum logprob = "): - for elm in line: - if not elm.startswith("sum logprob = "): - print("Line mismatch. Please ensure all files were run on the same phrase set.") - exit() - logprobs = [] - for prob in probs: - log_list = [log10(i) for i in prob] - logprobs.append(log_list) - sentence_pslp_arr.append([np.average(w) for w in zip(*logprobs)]) - elif line[0].startswith("OVERALL"): - for elm in line: - if not elm.startswith("OVERALL"): - print("Line mismatch. Please ensure all files were run on the same phrase set.") - exit() - - overall_ppl_arr = [pow(10, -1 * np.average(w)) for w in zip(*sentence_pslp_arr)] - print("Weight Distribution\tAverage Char Perplexity") - for weight, ppl in zip(weights, overall_ppl_arr): - print(f"{weight}\t\t{ppl:.3f}") - - print(f"\nBEST: {weights[np.argmin(overall_ppl_arr)]} {np.min(overall_ppl_arr):.3f}") - break diff --git a/scripts/shell/connect_dsi.sh b/scripts/shell/connect_dsi.sh deleted file mode 100644 index 01a7a3a32..000000000 --- a/scripts/shell/connect_dsi.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/bash -###### Connect DSI LSL. ####### -# Make sure DSI cap is on and paired via bluetooth or connection to a port. -# You can locate what port is paired to the device in the Device Manager in Windows. -# Change directory to the location of the DSI App - -sname_prompt="Select the DSI headset type:" -options=("DSI" "DSI_VR300") -echo "DSI headset types" -PS3="$sname_prompt " -select opt in "${options[@]}"; do -case $REPLY in -1 ) sname="$opt"; break;; -2 ) sname="$opt"; break;; -*) echo "Invalid option. Try another one (Ctrl+C to exit)."; continue;; -esac -done - -read -p "Enter a COM port [COM3]: " com -com=${com:-COM3} - -read -rsp $"Press Enter to Connect to $sname" -printf "\nStarting connection to $sname on $com.. Press Ctrl+C to end session." -eval "./dsi2lsl.exe --lsl-stream-name=$sname --port=$com" - -read -rsp $"Error. Please address messages above. Press enter to exit." -printf "disconnected" diff --git a/scripts/shell/linux_requirements.sh b/scripts/shell/linux_requirements.sh index 661347507..5ac9dec6e 100755 --- a/scripts/shell/linux_requirements.sh +++ b/scripts/shell/linux_requirements.sh @@ -21,5 +21,4 @@ sudo apt-get install xvfb # sudo apt-get install python3.9-tk python3.9-dev # run headless without a display connected. All commands must have xvfb-run preceeding them. -# ex. $ xvfb-run pytest -# sudo apt-get install xvfb \ No newline at end of file +# ex. $ xvfb-run pytest \ No newline at end of file diff --git a/scripts/shell/m2chip_install.sh b/scripts/shell/m2chip_install.sh index 6b8977099..2aa3e9cd4 100644 --- a/scripts/shell/m2chip_install.sh +++ b/scripts/shell/m2chip_install.sh @@ -26,7 +26,6 @@ export HDF5_DIR=/opt/homebrew/opt/hdf5 # portaudio installation for soundfile and pyo python -m pip install pyaudio # your path may be different to the portaudio version -pip install --global-option='build_ext' --global-option='-I/usr/local/Cellar/portaudio/19.7.0/include' --global-option='-L/usr/local/Cellar/portaudio/19.7.0/lib' pyo -pip install Psychopy==2024.2.1 --no-deps # install psychopy without dependencies +pip install --global-option='build_ext' --global-option='-I/usr/local/Cellar/portaudio/19.7.0/include' --global-option='-L/usr/local/Cellar/portaudio/19.7.0/lib' pyo +pip install kenlm==0.1 --global-option="--max_order=12" # install kenlm with max_order=12 for language model. Not required for BciPy to run in most places. pip install -e . -pip install kenlm==0.1 --global-option="--max_order=12" # install kenlm with max_order=12 for language model. Not required for BciPy to run in most places. \ No newline at end of file diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 162e9c4e5..000000000 --- a/setup.cfg +++ /dev/null @@ -1,20 +0,0 @@ -# Flake8 Configuration - -[flake8] - -max_complexity = 10 - -max_line_length = 120 - -application_import_names = bcipy - -ignore = - - # Ignoring D205, F841, F821, and D400 because of false positives - - D205, D400, F841, F821, E402, C901 - - # Ignoring W503, W504 : line break before binary operator, line break after - # binary operator - - W503, W504 diff --git a/setup.py b/setup.py deleted file mode 100644 index 33cddd581..000000000 --- a/setup.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Setup for bcipy package. - -Installation: - To install locally, run: - pip install -e . - To install as a package, run: - python setup.py install - To create a distribution, run: - python setup.py sdist bdist_wheel - -Release: - *credentials required* - PyPi: https://pypi.org/project/bcipy/ - GitHub: https://github.com/CAMBI-tech/BciPy - To upload to PyPi and create git tags, run: - python setup.py upload -""" - -import os -import platform -import sys -from shutil import rmtree - -from setuptools import Command, find_packages, setup - -# Package meta-data. -NAME = 'bcipy' -DESCRIPTION = 'Python Software for Brain-Computer Interface.' -URL = 'https://github.com/CAMBI-tech/BciPy' -EMAIL = 'cambi_support@googlegroups.com' -AUTHOR = 'CAMBI' -REQUIRES_PYTHON = '>3.7,<3.11' - -VERSION = '2.0.1rc4' - -REQUIRED = [] - -# What packages are required for this module to be executed? -if platform.system() == 'Windows' or platform.system() == 'Darwin': - with open('requirements-winmac.txt', 'r', encoding='utf-8') as f: - REQUIRED += f.read().splitlines() -with open('requirements.txt', 'r', encoding='utf-8') as f: - REQUIRED += f.read().splitlines() - -here = os.path.abspath(os.path.dirname(__file__)) - -long_description = 'Python Brain-Computer Interface Software' - -about = {'__version__': VERSION} - - -class UploadCommand(Command): - """Support setup.py upload. - - Modified from https://github.com/kennethreitz/setup.py - """ - - description = 'Build and publish the package.' - user_options = [] - - @staticmethod - def status(s): - """Prints things in bold.""" - print('\033[1m{0}\033[0m'.format(s)) - - def initialize_options(self): - pass - - def finalize_options(self): - pass - - def run(self): - try: - self.status('Removing previous builds…') - rmtree(os.path.join(here, 'dist')) - except OSError: - pass - - self.status('Building Source and Wheel (universal) distribution…') - os.system('{0} setup.py sdist bdist_wheel --universal'.format(sys.executable)) - - self.status('Uploading the package to PyPi via Twine…') - os.system('twine upload dist/*') - - self.status('Pushing git tags…') - os.system('git tag v{0}'.format(about['__version__'])) - os.system('git push --tags') - - sys.exit() - - -# Where the magic happens: -setup( - name=NAME, - version=about['__version__'], - description=DESCRIPTION, - long_description=long_description, - long_description_content_type='text/markdown', - author=AUTHOR, - author_email=EMAIL, - python_requires=REQUIRES_PYTHON, - url=URL, - packages=find_packages(exclude=( - 'tests', - 'demo', - 'data', - )), - entry_points={ - 'console_scripts': [ - 'bcipy = bcipy.main:bcipy_main', - 'bcipy-erp-viz = bcipy.helpers.visualization:erp', - 'bcipy-sim = bcipy.simulator:main', - 'bcipy-train = bcipy.signal.model.offline_analysis:main', - 'bcipy-params = bcipy.gui.parameters.params_form:main' - ], - }, - install_requires=REQUIRED, - include_package_data=True, - license='Hippocratic License 2.1', - classifiers=[ - # Trove classifiers - # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers - 'License :: Other/Proprietary License', - 'Topic :: Scientific/Engineering :: Human Machine Interfaces', - 'Intended Audience :: Science/Research', - 'Intended Audience :: End Users/Desktop', - 'Intended Audience :: Developers', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - ], - # $ setup.py publish support. - cmdclass={ - 'upload': UploadCommand, - }, -) From 3c7eb2ce851a2bc85e3ac4cadc9cfa8b7dde25aa Mon Sep 17 00:00:00 2001 From: lawhead Date: Tue, 10 Dec 2024 16:06:08 -0800 Subject: [PATCH 21/76] Adjusted the coverage tools --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 71e119c2f..ffd952340 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,7 +99,7 @@ bcipy-train = "bcipy.signal.model.offline_analysis:main" # https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html platforms = ["Linux", "Windows", "Mac OS-X"] include-package-data = true -zip-safe = true +zip-safe = true # Python modules and packages that are included in the # distribution package (and therefore become importable) @@ -120,6 +120,7 @@ omit = [ "*exceptions.py", "*visualization.py", "*/gui/*", + "*/simulator/ui/* ] source = ["bcipy"] branch = true From 2661379d1e38b4a92e2590989019ac573f830616 Mon Sep 17 00:00:00 2001 From: lawhead Date: Tue, 10 Dec 2024 16:12:15 -0800 Subject: [PATCH 22/76] Fix toml syntax --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ffd952340..459bb42a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,7 +120,7 @@ omit = [ "*exceptions.py", "*visualization.py", "*/gui/*", - "*/simulator/ui/* + "*/simulator/ui/*" ] source = ["bcipy"] branch = true From 613f69c7411326e37e4686cfb849a4a713f14298 Mon Sep 17 00:00:00 2001 From: lawhead Date: Mon, 16 Dec 2024 14:23:34 -0800 Subject: [PATCH 23/76] Added simulator metrics across runs --- bcipy/simulator/metrics.py | 114 ++++++++++++++++++++++ bcipy/simulator/task/task_runner.py | 4 +- bcipy/simulator/tests/test_metrics.py | 131 ++++++++++++++++++++++++++ 3 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 bcipy/simulator/metrics.py create mode 100644 bcipy/simulator/tests/test_metrics.py diff --git a/bcipy/simulator/metrics.py b/bcipy/simulator/metrics.py new file mode 100644 index 000000000..78589b43a --- /dev/null +++ b/bcipy/simulator/metrics.py @@ -0,0 +1,114 @@ +"""Summarize metrics across simulator runs.""" + +import logging +from collections import Counter +from json import load +from pathlib import Path +from typing import Dict, List + +import pandas as pd + +from bcipy.config import DEFAULT_ENCODING +from bcipy.io.save import save_json_data +from bcipy.simulator.util.artifact import TOP_LEVEL_LOGGER_NAME + +logger = logging.getLogger(TOP_LEVEL_LOGGER_NAME) + +SUMMARY_DATA_FILE_NAME = "summary_data.json" +TASK_SUMMARY_PREFIX = "task_summary__" + + +def add_item(container: Dict[str, List], key: str, value: float): + """Add or append value""" + if key in container: + container[key].append(value) + else: + container[key] = [value] + + +def get_final_typed(session_dict: Dict) -> str: + """Get the final typed word""" + all_series = session_dict['series'] + if all_series.keys(): + last_series_key = str(max(map(int, all_series.keys()))) + inquiries = all_series.get(last_series_key, {}) + if inquiries.keys(): + last_inquiry_key = str(max(map(int, inquiries.keys()))) + return inquiries.get(last_inquiry_key).get('next_display_state', + '') + return '' + + +def add_items(combined_metrics: Dict, session_dict: Dict): + """Add all items of interest from the session data.""" + keys = [ + 'total_number_series', 'total_inquiries', 'total_selections', + 'inquiries_per_selection' + ] + for key in keys: + add_item(combined_metrics, key, session_dict[key]) + + for key in session_dict['task_summary'].keys(): + if 'switch' not in key and 'rate' not in key: + add_item(combined_metrics, f"{TASK_SUMMARY_PREFIX}{key}", + session_dict['task_summary'][key]) + add_item(combined_metrics, 'typed', get_final_typed(session_dict)) + + +def session_paths(sim_dir: str) -> List[Path]: + """List of paths to the session.json files""" + return [ + Path(run_dir, "session.json") + for run_dir in sorted(Path(sim_dir).glob("run*/")) + ] + + +def summarize(sim_dir: str) -> Dict: + """Summarize all session runs.""" + combined_metrics = {} + for session_path in session_paths(sim_dir): + with open(session_path, 'r', encoding=DEFAULT_ENCODING) as json_file: + print("Loading: " + session_path) + session_dict = load(json_file) + add_items(combined_metrics, session_dict) + return combined_metrics + + +def rename_df_column(colname: str) -> str: + """Rename dataframe columns for reporting""" + if colname.startswith(TASK_SUMMARY_PREFIX): + return colname[len(TASK_SUMMARY_PREFIX):] + return colname + + +def log_descriptive_stats(df: pd.DataFrame) -> None: + """Log the descriptive statistics for the given dataframe. + + See https://stackoverflow.com/questions/25351968/how-can-i-display-full-non-truncated-dataframe-information-in-html-when-conver + """ + df.rename(columns=rename_df_column, inplace=True) + + pd.set_option('display.max_columns', None) + pd.set_option('display.width', 2000) + pd.set_option('display.float_format', "{:20,.2f}".format) + pd.set_option('display.max_colwidth', None) + + logger.info(f"Metrics:\n{df.describe()}") + + pd.reset_option('display.max_columns') + pd.reset_option('display.width') + pd.reset_option('display.float_format') + pd.reset_option('display.max_colwidth') + + +def report(sim_dir: str) -> None: + """Summarize the data, write as a JSON file, and output a summary to + the top level log file.""" + summary = summarize(sim_dir) + save_json_data(summarize(sim_dir), sim_dir, SUMMARY_DATA_FILE_NAME) + + df = pd.DataFrame(summary) + log_descriptive_stats(df) + + logger.info("Typed:") + logger.info(Counter(summary['typed'])) diff --git a/bcipy/simulator/task/task_runner.py b/bcipy/simulator/task/task_runner.py index 50f3ec834..83fb34245 100644 --- a/bcipy/simulator/task/task_runner.py +++ b/bcipy/simulator/task/task_runner.py @@ -5,6 +5,7 @@ import sys from pathlib import Path +import bcipy.simulator.metrics as metrics # pylint: disable=unused-import # flake8: noqa from bcipy.simulator.data.sampler import Sampler, TargetNontargetSampler @@ -108,7 +109,7 @@ def main(): type=Path, required=False, default=DEFAULT_SAVE_LOCATION, - help="Number of times to run the simulation") + help="Sim output path") args = parser.parse_args() sim_args = vars(args) @@ -134,6 +135,7 @@ def main(): task_factory=task_factory, runs=runs) runner.run() + metrics.report(sim_dir) if __name__ == '__main__': diff --git a/bcipy/simulator/tests/test_metrics.py b/bcipy/simulator/tests/test_metrics.py new file mode 100644 index 000000000..c88bf4714 --- /dev/null +++ b/bcipy/simulator/tests/test_metrics.py @@ -0,0 +1,131 @@ +"""Test simulator metrics""" +import unittest +from unittest.mock import patch + +from bcipy.simulator.metrics import (add_item, add_items, get_final_typed, + rename_df_column, summarize) + +SAMPLE_SERIES_DATA = { + "1": { + "0": { + "next_display_state": "HELL" + } + }, + "2": { + "0": { + "next_display_state": "HELL" + }, + "1": { + "next_display_state": "HELLO" + } + } +} + +SAMPLE_SESSION1 = { + "series": SAMPLE_SERIES_DATA, + "total_time_spent": 0.0, + "total_minutes": 0.0, + "total_number_series": 14, + "total_inquiries": 45, + "total_selections": 14, + "inquiries_per_selection": 3.2, + "task_summary": { + "selections_correct": 14, + "selections_incorrect": 0, + "selections_correct_symbols": 14, + "switch_total": 0, + "switch_per_selection": 0.0, + "switch_response_time": None, + "typing_accuracy": 1.0, + "correct_rate": 0, + "copy_rate": 0 + } +} + +SAMPLE_SESSION2 = { + "series": SAMPLE_SERIES_DATA, + "total_time_spent": 0.0, + "total_minutes": 0.0, + "total_number_series": 14, + "total_inquiries": 16, + "total_selections": 14, + "inquiries_per_selection": 1.1428571428571428, + "task_summary": { + "selections_correct": 14, + "selections_incorrect": 0, + "selections_correct_symbols": 14, + "switch_total": 0, + "switch_per_selection": 0.0, + "switch_response_time": None, + "typing_accuracy": 1.0, + "correct_rate": 0, + "copy_rate": 0 + } +} + + +class TestSimMetrics(unittest.TestCase): + """Tests for simulator metrics""" + + def test_add_item(self): + """Test adding an item to a dict of sequences.""" + container = {'data1': [1]} + add_item(container, 'data2', 10) + self.assertTrue('data2' in container) + self.assertEqual([10], container['data2']) + + add_item(container, 'data1', 2) + self.assertEqual([1, 2], container['data1']) + + def test_get_final_typed(self): + """Get final typed text from the series data""" + session_data = {'series': SAMPLE_SERIES_DATA} + self.assertEqual("HELLO", get_final_typed(session_data)) + + def test_add_session_items(self): + """Test session metrics accumulation""" + combined = {} + session_data = SAMPLE_SESSION1 + add_items(combined, session_data) + for key in [ + "total_number_series", "total_inquiries", "total_selections", + "inquiries_per_selection", "task_summary__selections_correct", + "task_summary__selections_incorrect", + "task_summary__selections_correct_symbols", + "task_summary__typing_accuracy", "typed" + ]: + self.assertTrue(key in combined) + self.assertEqual(1, len(combined[key])) + + @patch('bcipy.simulator.metrics.load') + @patch('bcipy.simulator.metrics.open') + @patch('bcipy.simulator.metrics.session_paths') + def test_summarize(self, session_path_mock, open_mock, json_mock): + """Test summary function""" + session_path_mock.return_value = [ + 'run1/session.json', 'run2/session.json' + ] + json_mock.side_effect = [SAMPLE_SESSION1, SAMPLE_SESSION2] + result = summarize("sim-dir") + + self.assertEqual(open_mock.call_count, 2) + for key in [ + "total_number_series", "total_inquiries", "total_selections", + "inquiries_per_selection", "task_summary__selections_correct", + "task_summary__selections_incorrect", + "task_summary__selections_correct_symbols", + "task_summary__typing_accuracy", "typed" + ]: + self.assertTrue(key in result) + self.assertEqual(2, len(result[key])) + + def test_rename_df_column(self): + """Test renaming columns for output""" + self.assertEqual("total_inquiries", + rename_df_column("total_inquiries")) + self.assertEqual("selections_correct", + rename_df_column("task_summary__selections_correct")) + + +if __name__ == '__main__': + unittest.main() From 2eccf904dffd360598834371422b386709b82318 Mon Sep 17 00:00:00 2001 From: lawhead Date: Mon, 16 Dec 2024 14:58:11 -0800 Subject: [PATCH 24/76] Fixed typing issues --- bcipy/simulator/metrics.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/bcipy/simulator/metrics.py b/bcipy/simulator/metrics.py index 78589b43a..eb906a228 100644 --- a/bcipy/simulator/metrics.py +++ b/bcipy/simulator/metrics.py @@ -4,7 +4,7 @@ from collections import Counter from json import load from pathlib import Path -from typing import Dict, List +from typing import Any, Dict, List import pandas as pd @@ -18,7 +18,7 @@ TASK_SUMMARY_PREFIX = "task_summary__" -def add_item(container: Dict[str, List], key: str, value: float): +def add_item(container: Dict[str, List], key: str, value: Any): """Add or append value""" if key in container: container[key].append(value) @@ -63,12 +63,11 @@ def session_paths(sim_dir: str) -> List[Path]: ] -def summarize(sim_dir: str) -> Dict: +def summarize(sim_dir: str) -> Dict[str, List[Any]]: """Summarize all session runs.""" - combined_metrics = {} + combined_metrics: Dict[str, List[Any]] = {} for session_path in session_paths(sim_dir): with open(session_path, 'r', encoding=DEFAULT_ENCODING) as json_file: - print("Loading: " + session_path) session_dict = load(json_file) add_items(combined_metrics, session_dict) return combined_metrics From 6cb091c746d7c1833e7f895c2feae994a8c1977d Mon Sep 17 00:00:00 2001 From: lawhead Date: Mon, 16 Dec 2024 16:43:23 -0800 Subject: [PATCH 25/76] Changed sim logging level; added boxplots --- bcipy/simulator/data/sampler.py | 4 ++-- bcipy/simulator/metrics.py | 28 +++++++++++++++++++++++++++- bcipy/simulator/task/task_factory.py | 6 +++--- bcipy/simulator/util/artifact.py | 4 ++-- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/bcipy/simulator/data/sampler.py b/bcipy/simulator/data/sampler.py index 5df4bdc9d..48b256a79 100644 --- a/bcipy/simulator/data/sampler.py +++ b/bcipy/simulator/data/sampler.py @@ -103,7 +103,7 @@ def sample(self, state: SimState) -> List[Trial]: filtered_data = self.data_engine.query(filters, samples=1) sample_rows.append(filtered_data[0]) - log.debug(f"Samples:\n{format_samples(sample_rows)}") + log.info(f"Samples:\n{format_samples(sample_rows)}") return sample_rows def query_filters(self, symbol: str, is_target: bool) -> List[QueryFilter]: @@ -221,7 +221,7 @@ def sample(self, state: SimState) -> List[Trial]: Trial(**sorted_inquiry_df.iloc[i]) for i in range(len(sorted_inquiry_df)) ] - log.debug(f"EEG Samples:\n{format_samples(rows)}") + log.info(f"EEG Samples:\n{format_samples(rows)}") return rows diff --git a/bcipy/simulator/metrics.py b/bcipy/simulator/metrics.py index eb906a228..9a10171b7 100644 --- a/bcipy/simulator/metrics.py +++ b/bcipy/simulator/metrics.py @@ -1,11 +1,13 @@ """Summarize metrics across simulator runs.""" import logging +import sys from collections import Counter from json import load from pathlib import Path -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional +import matplotlib.pyplot as plt import pandas as pd from bcipy.config import DEFAULT_ENCODING @@ -100,6 +102,25 @@ def log_descriptive_stats(df: pd.DataFrame) -> None: pd.reset_option('display.max_colwidth') +def plot_save_path(sim_dir: str) -> str: + """Save path for plots.""" + return f"{sim_dir}/metrics.png" + + +def plot_results(df: pd.DataFrame, + save_path: Optional[str] = None, + show: bool = True) -> None: + """Plot the dataframe""" + df.boxplot(column=[ + 'total_number_series', 'total_inquiries', 'inquiries_per_selection', 'selections_correct', + 'selections_incorrect' + ], figsize=(11, 8.5)) + if save_path: + plt.savefig(save_path) + if show: + plt.show() + + def report(sim_dir: str) -> None: """Summarize the data, write as a JSON file, and output a summary to the top level log file.""" @@ -111,3 +132,8 @@ def report(sim_dir: str) -> None: logger.info("Typed:") logger.info(Counter(summary['typed'])) + plot_results(df, save_path=plot_save_path(sim_dir)) + + +if __name__ == '__main__': + report(sim_dir=sys.argv[1]) diff --git a/bcipy/simulator/task/task_factory.py b/bcipy/simulator/task/task_factory.py index 1959a41d9..589bdeb00 100644 --- a/bcipy/simulator/task/task_factory.py +++ b/bcipy/simulator/task/task_factory.py @@ -56,14 +56,14 @@ def __init__( self.signal_models = [ load_signal_model(path) for path in signal_model_paths ] - logger.debug(self.signal_models) + logger.info(self.signal_models) logger.info("Initializing language model") self.language_model = init_language_model(self.parameters) - logger.debug(self.language_model) + logger.info(self.language_model) self.samplers = self.init_samplers(self.signal_models) - logger.debug(self.samplers) + logger.info(self.samplers) def init_samplers( self, diff --git a/bcipy/simulator/util/artifact.py b/bcipy/simulator/util/artifact.py index dcc39f434..0de5e38f7 100644 --- a/bcipy/simulator/util/artifact.py +++ b/bcipy/simulator/util/artifact.py @@ -30,14 +30,14 @@ def configure_logger(log_path: str, """ log = logging.getLogger(logger_name) # configuring root logger - log.setLevel(logging.DEBUG) + log.setLevel(logging.INFO) # Create handlers for logging to the standard output and a file stdout_handler = logging.StreamHandler(stream=sys.stdout) file_handler = logging.FileHandler(f"{log_path}/{file_name}") # Set the log levels on the handlers stdout_handler.setLevel(logging.INFO) - file_handler.setLevel(logging.DEBUG) + file_handler.setLevel(logging.INFO) fmt = '[%(asctime)s][%(name)s][%(levelname)s]: %(message)s' fmt_file = logging.Formatter(fmt) From e71c244d1b4afbeb1f85b51082c454467a34777f Mon Sep 17 00:00:00 2001 From: lawhead Date: Tue, 17 Dec 2024 13:05:04 -0800 Subject: [PATCH 26/76] Add duration metrics --- bcipy/simulator/metrics.py | 37 +++++++++++++++++++++------ bcipy/simulator/tests/test_metrics.py | 22 +++++++++++++--- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/bcipy/simulator/metrics.py b/bcipy/simulator/metrics.py index 9a10171b7..f3933a4a5 100644 --- a/bcipy/simulator/metrics.py +++ b/bcipy/simulator/metrics.py @@ -11,6 +11,8 @@ import pandas as pd from bcipy.config import DEFAULT_ENCODING +from bcipy.helpers.acquisition import max_inquiry_duration +from bcipy.io.load import load_json_parameters from bcipy.io.save import save_json_data from bcipy.simulator.util.artifact import TOP_LEVEL_LOGGER_NAME @@ -41,7 +43,13 @@ def get_final_typed(session_dict: Dict) -> str: return '' -def add_items(combined_metrics: Dict, session_dict: Dict): +def calculate_duration(inquiry_count: int, inquiry_seconds: float) -> float: + """Compute the total number of seconds.""" + return inquiry_count * inquiry_seconds + + +def add_items(combined_metrics: Dict, session_dict: Dict, + inquiry_seconds: float): """Add all items of interest from the session data.""" keys = [ 'total_number_series', 'total_inquiries', 'total_selections', @@ -54,7 +62,11 @@ def add_items(combined_metrics: Dict, session_dict: Dict): if 'switch' not in key and 'rate' not in key: add_item(combined_metrics, f"{TASK_SUMMARY_PREFIX}{key}", session_dict['task_summary'][key]) + add_item(combined_metrics, 'typed', get_final_typed(session_dict)) + add_item( + combined_metrics, 'total_seconds', + calculate_duration(session_dict['total_inquiries'], inquiry_seconds)) def session_paths(sim_dir: str) -> List[Path]: @@ -65,13 +77,19 @@ def session_paths(sim_dir: str) -> List[Path]: ] +def sim_parameters(sim_dir: str) -> Dict[str, Any]: + """Simulation parameters""" + return load_json_parameters(str(Path(sim_dir, "parameters.json")), True) + + def summarize(sim_dir: str) -> Dict[str, List[Any]]: """Summarize all session runs.""" + inquiry_seconds = max_inquiry_duration(sim_parameters(sim_dir)) combined_metrics: Dict[str, List[Any]] = {} for session_path in session_paths(sim_dir): with open(session_path, 'r', encoding=DEFAULT_ENCODING) as json_file: session_dict = load(json_file) - add_items(combined_metrics, session_dict) + add_items(combined_metrics, session_dict, inquiry_seconds) return combined_metrics @@ -111,17 +129,18 @@ def plot_results(df: pd.DataFrame, save_path: Optional[str] = None, show: bool = True) -> None: """Plot the dataframe""" - df.boxplot(column=[ - 'total_number_series', 'total_inquiries', 'inquiries_per_selection', 'selections_correct', - 'selections_incorrect' - ], figsize=(11, 8.5)) + cols = [ + 'total_selections', 'total_inquiries', 'inquiries_per_selection', + 'selections_correct', 'selections_incorrect' + ] + df.boxplot(column=cols, figsize=(11, 8.5)) if save_path: plt.savefig(save_path) if show: plt.show() -def report(sim_dir: str) -> None: +def report(sim_dir: str, show_plots: bool = False) -> None: """Summarize the data, write as a JSON file, and output a summary to the top level log file.""" summary = summarize(sim_dir) @@ -132,7 +151,9 @@ def report(sim_dir: str) -> None: logger.info("Typed:") logger.info(Counter(summary['typed'])) - plot_results(df, save_path=plot_save_path(sim_dir)) + ave_minutes = round((df['total_seconds'] / 60).mean(), 2) + logger.info(f"Average duration: {ave_minutes} minutes") + plot_results(df, save_path=plot_save_path(sim_dir), show=show_plots) if __name__ == '__main__': diff --git a/bcipy/simulator/tests/test_metrics.py b/bcipy/simulator/tests/test_metrics.py index c88bf4714..820ee51a8 100644 --- a/bcipy/simulator/tests/test_metrics.py +++ b/bcipy/simulator/tests/test_metrics.py @@ -2,7 +2,8 @@ import unittest from unittest.mock import patch -from bcipy.simulator.metrics import (add_item, add_items, get_final_typed, +from bcipy.simulator.metrics import (add_item, add_items, calculate_duration, + get_final_typed, plot_save_path, rename_df_column, summarize) SAMPLE_SERIES_DATA = { @@ -86,7 +87,7 @@ def test_add_session_items(self): """Test session metrics accumulation""" combined = {} session_data = SAMPLE_SESSION1 - add_items(combined, session_data) + add_items(combined, session_data, inquiry_seconds=2) for key in [ "total_number_series", "total_inquiries", "total_selections", "inquiries_per_selection", "task_summary__selections_correct", @@ -97,17 +98,23 @@ def test_add_session_items(self): self.assertTrue(key in combined) self.assertEqual(1, len(combined[key])) + @patch('bcipy.simulator.metrics.max_inquiry_duration') + @patch('bcipy.simulator.metrics.sim_parameters') @patch('bcipy.simulator.metrics.load') @patch('bcipy.simulator.metrics.open') @patch('bcipy.simulator.metrics.session_paths') - def test_summarize(self, session_path_mock, open_mock, json_mock): + def test_summarize(self, session_path_mock, open_mock, json_mock, + sim_parameters_mock, max_inquiry_duration_mock): """Test summary function""" session_path_mock.return_value = [ 'run1/session.json', 'run2/session.json' ] json_mock.side_effect = [SAMPLE_SESSION1, SAMPLE_SESSION2] + max_inquiry_duration_mock.return_value = 2 result = summarize("sim-dir") + sim_parameters_mock.assert_called_once_with("sim-dir") + max_inquiry_duration_mock.assert_called_once() self.assertEqual(open_mock.call_count, 2) for key in [ "total_number_series", "total_inquiries", "total_selections", @@ -126,6 +133,15 @@ def test_rename_df_column(self): self.assertEqual("selections_correct", rename_df_column("task_summary__selections_correct")) + def test_plot_name(self): + """Test naming for plots""" + self.assertEqual("sim_dir/metrics.png", plot_save_path("sim_dir")) + + def test_compute_duration(self): + """Test the calculation of the overall session duration""" + self.assertEqual( + 250, calculate_duration(inquiry_count=100, inquiry_seconds=2.5)) + if __name__ == '__main__': unittest.main() From 2cf2b75373c43e876803760ab05a79ed95284a38 Mon Sep 17 00:00:00 2001 From: lawhead Date: Tue, 17 Dec 2024 14:25:01 -0800 Subject: [PATCH 27/76] Increase test coverage --- bcipy/simulator/metrics.py | 4 +- bcipy/simulator/tests/test_metrics.py | 73 +++++++++++++++++++++++++-- 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/bcipy/simulator/metrics.py b/bcipy/simulator/metrics.py index f3933a4a5..92ef36ce0 100644 --- a/bcipy/simulator/metrics.py +++ b/bcipy/simulator/metrics.py @@ -144,13 +144,13 @@ def report(sim_dir: str, show_plots: bool = False) -> None: """Summarize the data, write as a JSON file, and output a summary to the top level log file.""" summary = summarize(sim_dir) - save_json_data(summarize(sim_dir), sim_dir, SUMMARY_DATA_FILE_NAME) + save_json_data(summary, sim_dir, SUMMARY_DATA_FILE_NAME) df = pd.DataFrame(summary) log_descriptive_stats(df) logger.info("Typed:") - logger.info(Counter(summary['typed'])) + logger.info(Counter(summary.get('typed'))) ave_minutes = round((df['total_seconds'] / 60).mean(), 2) logger.info(f"Average duration: {ave_minutes} minutes") plot_results(df, save_path=plot_save_path(sim_dir), show=show_plots) diff --git a/bcipy/simulator/tests/test_metrics.py b/bcipy/simulator/tests/test_metrics.py index 820ee51a8..df1f76f6f 100644 --- a/bcipy/simulator/tests/test_metrics.py +++ b/bcipy/simulator/tests/test_metrics.py @@ -1,10 +1,12 @@ """Test simulator metrics""" import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch -from bcipy.simulator.metrics import (add_item, add_items, calculate_duration, - get_final_typed, plot_save_path, - rename_df_column, summarize) +from bcipy.simulator.metrics import (SUMMARY_DATA_FILE_NAME, add_item, + add_items, calculate_duration, + get_final_typed, log_descriptive_stats, + plot_results, plot_save_path, + rename_df_column, report, summarize) SAMPLE_SERIES_DATA = { "1": { @@ -64,6 +66,19 @@ } } +SUMMARY = { + "total_number_series": [11, 19], + "total_inquiries": [54, 90], + "total_selections": [11, 19], + "inquiries_per_selection": [5, 4], + "task_summary__selections_correct": [11, 15], + "task_summary__selections_incorrect": [0, 4], + "task_summary__selections_correct_symbols": [11, 11], + "task_summary__typing_accuracy": [1.0, 0.7894736842105263], + "typed": ["HELLO_WORLD", "HELLO_WORLD"], + "total_seconds": [405.0, 675.0] +} + class TestSimMetrics(unittest.TestCase): """Tests for simulator metrics""" @@ -142,6 +157,56 @@ def test_compute_duration(self): self.assertEqual( 250, calculate_duration(inquiry_count=100, inquiry_seconds=2.5)) + @patch("bcipy.simulator.metrics.plt.show") + @patch("bcipy.simulator.metrics.plt.savefig") + def test_plot_results_no_save(self, savefig_mock, show_mock): + """Test plotting without saving""" + mock_df = Mock() + plot_results(mock_df) + savefig_mock.assert_not_called() + show_mock.assert_called_once() + + @patch("bcipy.simulator.metrics.plt.show") + @patch("bcipy.simulator.metrics.plt.savefig") + def test_plot_results_no_show(self, savefig_mock, show_mock): + """Test plotting without saving""" + mock_df = Mock() + plot_results(mock_df, show=False) + savefig_mock.assert_not_called() + show_mock.assert_not_called() + + @patch("bcipy.simulator.metrics.plt.show") + @patch("bcipy.simulator.metrics.plt.savefig") + def test_plot_results_with_save(self, savefig_mock, show_mock): + """Test plotting without saving""" + mock_df = Mock() + plot_results(mock_df, save_path=".") + savefig_mock.assert_called_once() + show_mock.assert_called_once() + + def test_log_descriptive_stats(self): + """Test logging stats""" + mock_df = Mock() + log_descriptive_stats(mock_df) + mock_df.rename.assert_called_once() + mock_df.describe.assert_called_once() + + @patch("bcipy.simulator.metrics.plot_results") + @patch("bcipy.simulator.metrics.log_descriptive_stats") + @patch("bcipy.simulator.metrics.save_json_data") + @patch("bcipy.simulator.metrics.summarize") + def test_report(self, summarize_mock, save_json_data_mock, log_stats_mock, + plot_results_mock): + """Test reporting""" + summarize_mock.return_value = SUMMARY + report("test_dir") + + summarize_mock.assert_called_once() + save_json_data_mock.assert_called_with(SUMMARY, "test_dir", + SUMMARY_DATA_FILE_NAME) + log_stats_mock.assert_called_once() + plot_results_mock.assert_called_once() + if __name__ == '__main__': unittest.main() From 9c3ebebece7fdc2d32737d9ece3579c2352d2ae3 Mon Sep 17 00:00:00 2001 From: lawhead Date: Wed, 18 Dec 2024 16:11:35 -0800 Subject: [PATCH 28/76] Cleanup and documentation --- bcipy/simulator/README.md | 33 ++++++++++++-------- bcipy/simulator/task/task_runner.py | 2 +- bcipy/simulator/tests/test_metrics.py | 41 ++++++++++++------------- bcipy/simulator/ui/gui.py | 8 ++--- bcipy/simulator/util/artifact.py | 3 +- bcipy/simulator/{ => util}/metrics.py | 43 ++++++++++++++++++++++----- scripts/python/update_params.py | 2 +- 7 files changed, 84 insertions(+), 48 deletions(-) rename bcipy/simulator/{ => util}/metrics.py (78%) diff --git a/bcipy/simulator/README.md b/bcipy/simulator/README.md index e3854a46c..f3f51a65b 100644 --- a/bcipy/simulator/README.md +++ b/bcipy/simulator/README.md @@ -9,20 +9,24 @@ This Simulator module aims to automate experimentation by sampling EEG data from `main.py` is the entry point for program. After following `BciPy` readme steps for setup, run the module from terminal: ``` -(venv) $ python bcipy/simulator -h -usage: simulator [-h] -d DATA_FOLDER [-g GLOB_PATTERN] -m MODEL_PATH -p PARAMETERS [-n N] +(venv) $ bcipy-sim -h +usage: bcipy-sim [-h] [-i] [--gui] [-d DATA_FOLDER] [-m MODEL_PATH] [-p PARAMETERS] [-n N] [-s SAMPLER] [-o OUTPUT] optional arguments: -h, --help show this help message and exit + -i, --interactive Use interactive command line for selecting simulator inputs + --gui Use interactive GUI for selecting simulator inputs -d DATA_FOLDER, --data_folder DATA_FOLDER - Raw data folders to be processed. - -g GLOB_PATTERN, --glob_pattern GLOB_PATTERN - glob pattern to select a subset of data folders Ex. "*RSVP_Copy_Phrase*" + Raw data folders to be processed. Multiple values can be provided, or a single parent folder. -m MODEL_PATH, --model_path MODEL_PATH - Signal models to be used + Signal models to be used. Multiple models can be provided. -p PARAMETERS, --parameters PARAMETERS Parameter File to be used -n N Number of times to run the simulation + -s SAMPLER, --sampler SAMPLER + Sampling strategy + -o OUTPUT, --output OUTPUT + Sim output path ``` For example, @@ -30,21 +34,24 @@ For example, #### Program Args -- `d` : the data wrapper folder argument is necessary. This folder is expected to contain 1 or more session folders. Each session folder should contain +- `i` : Interactive command line interface. Provide this flag by itself to be prompted for each parameter. +- `gui`: A graphical user interface for configuring a simulation. This mode will output the command line arguments which can be used to repeat the simulation. +- `d` : Raw data folders to be processed. One ore more values can be provided. Each session data folder should contain _raw_data.csv_, _triggers.txt_, _parameters.json_. These files will be used to construct a data pool from which simulator will sample EEG and other device responses. The parameters file in each data folder will be used to check compatibility with the simulation/model parameters. -- `g` : optional glob filter that can be used to select a subset of data within the wrapper directory. - - Ex. `"*Matrix_Copy*Jan_2024*"` will select all data for all Matrix Copy Phrase sessions recorded in January of 2024 (assuming the BciPy folder naming convention). - - Glob patterns can also include nested directories (ex. `"*/*Matrix_Copy*"`). - `p` : path to the parameters.json file used to run the simulation. These parameters will be applied to all raw_data files when loading. This file can specify various aspects of the simulation, including the language model to be used, the text to be spelled, etc. Timing-related parameters should generally match the parameters file used for training the signal model(s). -- `m`: all pickle (.pkl) files in this directory will be loaded as signal models. +- `m`: Path to a pickled (.pkl) signal model. One or more models can be provided. +- `n`: Number of simulation runs +- `o`: Output directory for all simulation artifacts. #### Sim Output Details -Output folders are generally located in the `simulator/generated` directory. Each simulation will create a new directory. The directory name will be prefixed with `SIM` and will include the current date and time. +Output folders are generally located in the `data/simulator` directory, but can be configured per simulation. Each simulation will create a new directory. The directory name will be prefixed with `SIM` and will include the current date and time. - `parameters.json` captures params used for the simulation. -- `sim.log` is a log file for the simulation +- `sim.log` is a log file for the simulation; metrics will be output here. +- `summary_data.json` summarizes session data from each of the runs into a single data structure. +- `metrics.png` boxplots for several metrics summarizing all simulation runs. A directory is created for each simulation run. The directory contents are similar to the session output in a normal bcipy task. Each run directory contains: diff --git a/bcipy/simulator/task/task_runner.py b/bcipy/simulator/task/task_runner.py index 83fb34245..4897e4145 100644 --- a/bcipy/simulator/task/task_runner.py +++ b/bcipy/simulator/task/task_runner.py @@ -5,7 +5,7 @@ import sys from pathlib import Path -import bcipy.simulator.metrics as metrics +import bcipy.simulator.util.metrics as metrics # pylint: disable=unused-import # flake8: noqa from bcipy.simulator.data.sampler import Sampler, TargetNontargetSampler diff --git a/bcipy/simulator/tests/test_metrics.py b/bcipy/simulator/tests/test_metrics.py index df1f76f6f..e1ce6c2d2 100644 --- a/bcipy/simulator/tests/test_metrics.py +++ b/bcipy/simulator/tests/test_metrics.py @@ -2,11 +2,12 @@ import unittest from unittest.mock import Mock, patch -from bcipy.simulator.metrics import (SUMMARY_DATA_FILE_NAME, add_item, - add_items, calculate_duration, - get_final_typed, log_descriptive_stats, - plot_results, plot_save_path, - rename_df_column, report, summarize) +from bcipy.simulator.util.metrics import (SUMMARY_DATA_FILE_NAME, add_item, + add_items, calculate_duration, + get_final_typed, + log_descriptive_stats, plot_results, + plot_save_path, rename_df_column, + report, summarize) SAMPLE_SERIES_DATA = { "1": { @@ -113,11 +114,11 @@ def test_add_session_items(self): self.assertTrue(key in combined) self.assertEqual(1, len(combined[key])) - @patch('bcipy.simulator.metrics.max_inquiry_duration') - @patch('bcipy.simulator.metrics.sim_parameters') - @patch('bcipy.simulator.metrics.load') - @patch('bcipy.simulator.metrics.open') - @patch('bcipy.simulator.metrics.session_paths') + @patch('bcipy.simulator.util.metrics.max_inquiry_duration') + @patch('bcipy.simulator.util.metrics.sim_parameters') + @patch('bcipy.simulator.util.metrics.load') + @patch('bcipy.simulator.util.metrics.open') + @patch('bcipy.simulator.util.metrics.session_paths') def test_summarize(self, session_path_mock, open_mock, json_mock, sim_parameters_mock, max_inquiry_duration_mock): """Test summary function""" @@ -157,8 +158,8 @@ def test_compute_duration(self): self.assertEqual( 250, calculate_duration(inquiry_count=100, inquiry_seconds=2.5)) - @patch("bcipy.simulator.metrics.plt.show") - @patch("bcipy.simulator.metrics.plt.savefig") + @patch("bcipy.simulator.util.metrics.plt.show") + @patch("bcipy.simulator.util.metrics.plt.savefig") def test_plot_results_no_save(self, savefig_mock, show_mock): """Test plotting without saving""" mock_df = Mock() @@ -166,8 +167,8 @@ def test_plot_results_no_save(self, savefig_mock, show_mock): savefig_mock.assert_not_called() show_mock.assert_called_once() - @patch("bcipy.simulator.metrics.plt.show") - @patch("bcipy.simulator.metrics.plt.savefig") + @patch("bcipy.simulator.util.metrics.plt.show") + @patch("bcipy.simulator.util.metrics.plt.savefig") def test_plot_results_no_show(self, savefig_mock, show_mock): """Test plotting without saving""" mock_df = Mock() @@ -175,8 +176,8 @@ def test_plot_results_no_show(self, savefig_mock, show_mock): savefig_mock.assert_not_called() show_mock.assert_not_called() - @patch("bcipy.simulator.metrics.plt.show") - @patch("bcipy.simulator.metrics.plt.savefig") + @patch("bcipy.simulator.util.metrics.plt.show") + @patch("bcipy.simulator.util.metrics.plt.savefig") def test_plot_results_with_save(self, savefig_mock, show_mock): """Test plotting without saving""" mock_df = Mock() @@ -191,10 +192,10 @@ def test_log_descriptive_stats(self): mock_df.rename.assert_called_once() mock_df.describe.assert_called_once() - @patch("bcipy.simulator.metrics.plot_results") - @patch("bcipy.simulator.metrics.log_descriptive_stats") - @patch("bcipy.simulator.metrics.save_json_data") - @patch("bcipy.simulator.metrics.summarize") + @patch("bcipy.simulator.util.metrics.plot_results") + @patch("bcipy.simulator.util.metrics.log_descriptive_stats") + @patch("bcipy.simulator.util.metrics.save_json_data") + @patch("bcipy.simulator.util.metrics.summarize") def test_report(self, summarize_mock, save_json_data_mock, log_stats_mock, plot_results_mock): """Test reporting""" diff --git a/bcipy/simulator/ui/gui.py b/bcipy/simulator/ui/gui.py index 36cbe3c90..b22a56f21 100644 --- a/bcipy/simulator/ui/gui.py +++ b/bcipy/simulator/ui/gui.py @@ -537,10 +537,10 @@ def command(self) -> str: args = [] if self.outdir(): - args.append(f"-o {self.outdir()}") - args.append(f"-p {self.parameters_path}") - args.extend([f"-m {path}" for path in self.model_paths]) - args.extend([f"-d {source}" for source in self.data_paths]) + args.append(f"-o '{self.outdir()}'") + args.append(f"-p '{self.parameters_path}'") + args.extend([f"-m '{path}'" for path in self.model_paths]) + args.extend([f"-d '{source}'" for source in self.data_paths]) args.append(f"-n {self.runs()}") diff --git a/bcipy/simulator/util/artifact.py b/bcipy/simulator/util/artifact.py index 0de5e38f7..51b0f9a5d 100644 --- a/bcipy/simulator/util/artifact.py +++ b/bcipy/simulator/util/artifact.py @@ -13,6 +13,7 @@ TOP_LEVEL_LOGGER_NAME = 'sim_logger' DEFAULT_LOGFILE_NAME = 'sim.log' DEFAULT_SAVE_LOCATION = f"{ROOT}/data/simulator" +RUN_PREFIX = "run_" def configure_logger(log_path: str, @@ -78,7 +79,7 @@ def configure_run_directory(sim_dir: str, run: int) -> str: """Create the necessary directories and configure the logger. Returns the run directory. """ - run_name = f"run_{run}" + run_name = f"{RUN_PREFIX}{run}" path = f"{sim_dir}/{run_name}" os.mkdir(path) configure_logger(log_path=path, diff --git a/bcipy/simulator/metrics.py b/bcipy/simulator/util/metrics.py similarity index 78% rename from bcipy/simulator/metrics.py rename to bcipy/simulator/util/metrics.py index 92ef36ce0..b4b7a91b0 100644 --- a/bcipy/simulator/metrics.py +++ b/bcipy/simulator/util/metrics.py @@ -10,11 +10,13 @@ import matplotlib.pyplot as plt import pandas as pd -from bcipy.config import DEFAULT_ENCODING +from bcipy.config import (DEFAULT_ENCODING, DEFAULT_PARAMETERS_FILENAME, + SESSION_DATA_FILENAME) +from bcipy.core.parameters import Parameters from bcipy.helpers.acquisition import max_inquiry_duration from bcipy.io.load import load_json_parameters from bcipy.io.save import save_json_data -from bcipy.simulator.util.artifact import TOP_LEVEL_LOGGER_NAME +from bcipy.simulator.util.artifact import RUN_PREFIX, TOP_LEVEL_LOGGER_NAME logger = logging.getLogger(TOP_LEVEL_LOGGER_NAME) @@ -31,7 +33,17 @@ def add_item(container: Dict[str, List], key: str, value: Any): def get_final_typed(session_dict: Dict) -> str: - """Get the final typed word""" + """Get the final typed word + + Parameters + ---------- + session_dict - Session data from a single run. + Equivalent to reading the Session and calling as_dict(). + + Returns + ------- + the final spelled text (from the last inquiry) + """ all_series = session_dict['series'] if all_series.keys(): last_series_key = str(max(map(int, all_series.keys()))) @@ -58,6 +70,9 @@ def add_items(combined_metrics: Dict, session_dict: Dict, for key in keys: add_item(combined_metrics, key, session_dict[key]) + # Summarize task_summary data. Task summaries may be different depending on + # task. Note that switch presses and typing rates are not accurately reflected + # in simulation so are removed from metrics. for key in session_dict['task_summary'].keys(): if 'switch' not in key and 'rate' not in key: add_item(combined_metrics, f"{TASK_SUMMARY_PREFIX}{key}", @@ -72,18 +87,30 @@ def add_items(combined_metrics: Dict, session_dict: Dict, def session_paths(sim_dir: str) -> List[Path]: """List of paths to the session.json files""" return [ - Path(run_dir, "session.json") - for run_dir in sorted(Path(sim_dir).glob("run*/")) + Path(run_dir, SESSION_DATA_FILENAME) + for run_dir in sorted(Path(sim_dir).glob(f"{RUN_PREFIX}*/")) ] -def sim_parameters(sim_dir: str) -> Dict[str, Any]: +def sim_parameters(sim_dir: str) -> Parameters: """Simulation parameters""" - return load_json_parameters(str(Path(sim_dir, "parameters.json")), True) + return load_json_parameters( + str(Path(sim_dir, DEFAULT_PARAMETERS_FILENAME)), True) def summarize(sim_dir: str) -> Dict[str, List[Any]]: - """Summarize all session runs.""" + """Summarize all session runs. + + Parameters + ---------- + sim_dir - path to the simulation directory with 1 or more runs. + + Returns + ------- + a dict with a key for each metric of interest and a value which + is a list accumulating the value of that metric from each run. + ex. {'total_number_series': [14, 22, 12], ...} + """ inquiry_seconds = max_inquiry_duration(sim_parameters(sim_dir)) combined_metrics: Dict[str, List[Any]] = {} for session_path in session_paths(sim_dir): diff --git a/scripts/python/update_params.py b/scripts/python/update_params.py index 4cd9c8713..01f1515a1 100644 --- a/scripts/python/update_params.py +++ b/scripts/python/update_params.py @@ -5,7 +5,7 @@ import shutil from pathlib import Path -from bcipy.helpers.parameters import DEFAULT_PARAMETERS_PATH, Parameters +from bcipy.core.parameters import DEFAULT_PARAMETERS_PATH, Parameters def update(params_path: str) -> None: From 8176fe0847cf0a177625a5bb186769a1573342d4 Mon Sep 17 00:00:00 2001 From: lawhead Date: Wed, 18 Dec 2024 17:17:25 -0800 Subject: [PATCH 29/76] #188673150 ; add session and session_inquiry to simulator Trials data to enable more sampling options --- bcipy/core/tests/test_list.py | 38 ++++++- bcipy/simulator/data/data_engine.py | 60 +---------- bcipy/simulator/data/sampler.py | 3 +- bcipy/simulator/data/trial.py | 128 ++++++++++++++++++++++++ bcipy/simulator/tests/test_trial.py | 148 ++++++++++++++++++++++++++++ 5 files changed, 316 insertions(+), 61 deletions(-) create mode 100644 bcipy/simulator/data/trial.py create mode 100644 bcipy/simulator/tests/test_trial.py diff --git a/bcipy/core/tests/test_list.py b/bcipy/core/tests/test_list.py index 49f17f837..650d6aba7 100644 --- a/bcipy/core/tests/test_list.py +++ b/bcipy/core/tests/test_list.py @@ -1,6 +1,7 @@ """Tests for list processing utilities""" import unittest -from bcipy.core.list import destutter, grouper + +from bcipy.core.list import destutter, expanded, find_index, grouper, swapped class TestListUtilities(unittest.TestCase): @@ -48,7 +49,8 @@ def test_grouper_incomplete_ignore(self): for resp, exp in zip(response, expected): self.assertEqual(resp, exp) - def test_grouper_incomplete_value_error_unsupported_incompelte_mode_defined(self): + def test_grouper_incomplete_value_error_unsupported_incompelte_mode_defined( + self): iterable = 'ABCDEFG' chunk_size = 2 incomplete = 'not_defined' @@ -56,6 +58,38 @@ def test_grouper_incomplete_value_error_unsupported_incompelte_mode_defined(self with self.assertRaises(ValueError): grouper(iterable, chunk_size, incomplete=incomplete) + def test_find_index(self): + """Test find index of item""" + self.assertEqual(2, find_index([1, 2, 3, 4], 3)) + + def test_find_index_using_key(self): + """Find index using the key arg""" + item1 = dict(a=1, b=1) + item2 = dict(a=1, b=2) + item3 = dict(a=2, b=1) + item4 = dict(a=2, b=2) + self.assertEqual( + 1, + find_index([item1, item2, item3, item4], + match_item=2, + key=lambda item: item['b'])) + + def test_find_index_matching_predicate(self): + """Find the index of the first item matching a predicate""" + values = [5, 7, 9, 12] + self.assertEqual(1, find_index(values, match_item=lambda val: val > 6)) + + def test_swapped(self): + """Test swapped function.""" + self.assertEqual([1, 2, 3, 4, 5], swapped([1, 2, 3, 4, 5], 0, 0)) + self.assertEqual([1, 4, 3, 2, 5], swapped([1, 2, 3, 4, 5], 1, 3)) + self.assertEqual([1, 2, 3, 4, 5], swapped([1, 2, 3, 4, 5], 4, 4)) + self.assertEqual([1, 2, 3, 4, 5], swapped([5, 2, 3, 4, 1], 0, 4)) + + def test_expanded(self): + """Test expanded function.""" + self.assertEqual([1, 2, 3, 3, 3], expanded([1, 2, 3], length=5)) + if __name__ == '__main__': unittest.main() diff --git a/bcipy/simulator/data/data_engine.py b/bcipy/simulator/data/data_engine.py index 431b4fcac..7e744b33c 100644 --- a/bcipy/simulator/data/data_engine.py +++ b/bcipy/simulator/data/data_engine.py @@ -4,50 +4,19 @@ from pathlib import Path from typing import Any, List, NamedTuple, Union -import numpy as np import pandas as pd -from bcipy.exceptions import TaskConfigurationException from bcipy.core.parameters import Parameters +from bcipy.exceptions import TaskConfigurationException from bcipy.simulator.data import data_process from bcipy.simulator.data.data_process import (ExtractedExperimentData, RawDataProcessor) +from bcipy.simulator.data.trial import Trial, convert_trials from bcipy.simulator.util.artifact import TOP_LEVEL_LOGGER_NAME log = logging.getLogger(TOP_LEVEL_LOGGER_NAME) -class Trial(NamedTuple): - """Data for a given trial (a symbol within an Inquiry). - - Attrs - ----- - source - directory of the data source - inquiry_n - starts at 0; does not reset at each series - inquiry_pos - starts at 1; position in which the symbol was presented - symbol - alphabet symbol that was presented - target - 1 or 0 indicating a boolean of whether this was a target symbol - eeg - EEG data associated with this trial - """ - source: str - inquiry_n: int - inquiry_pos: int - symbol: str - target: int - eeg: np.ndarray # Channels by Samples ; ndarray.shape = (channel_n, sample_n) - - def __str__(self): - fields = [ - f"source='{self.source}'", f"inquiry_n={self.inquiry_n}", - f"inquiry_pos={self.inquiry_pos}", f"symbol='{self.symbol}'", - f"target={self.target}", f"eeg={self.eeg.shape}" - ] - return f"Trial({', '.join(fields)})" - - def __repr__(self): - return str(self) - - class QueryFilter(NamedTuple): """Provides an API used to query a data engine for data.""" field: str @@ -85,31 +54,6 @@ def query(self, """Query the data.""" -def convert_trials(data_source: ExtractedExperimentData) -> List[Trial]: - """Convert extracted data from a single data source to a list of Trials.""" - trials = [] - symbols_by_inquiry = data_source.symbols_by_inquiry - labels_by_inquiry = data_source.labels_by_inquiry - - for i, inquiry_eeg in enumerate(data_source.trials_by_inquiry): - # iterate through each inquiry - inquiry_symbols = symbols_by_inquiry[i] - inquiry_labels = labels_by_inquiry[i] - - for sym_i, symbol in enumerate(inquiry_symbols): - # iterate through each symbol in the inquiry - eeg_samples = [channel[sym_i] - for channel in inquiry_eeg] # (channel_n, sample_n) - trials.append( - Trial(source=data_source.source_dir, - inquiry_n=i, - inquiry_pos=sym_i + 1, - symbol=symbol, - target=inquiry_labels[sym_i], - eeg=np.array(eeg_samples))) - return trials - - class RawDataEngine(DataEngine): """ Object that loads in list of session data folders and transforms data into diff --git a/bcipy/simulator/data/sampler.py b/bcipy/simulator/data/sampler.py index 48b256a79..6856bb437 100644 --- a/bcipy/simulator/data/sampler.py +++ b/bcipy/simulator/data/sampler.py @@ -8,7 +8,8 @@ import numpy as np import pandas as pd -from bcipy.simulator.data.data_engine import QueryFilter, RawDataEngine, Trial +from bcipy.simulator.data.data_engine import QueryFilter, RawDataEngine +from bcipy.simulator.data.trial import Trial from bcipy.simulator.util.state import SimState log = logging.getLogger(__name__) diff --git a/bcipy/simulator/data/trial.py b/bcipy/simulator/data/trial.py new file mode 100644 index 000000000..e7480fdd5 --- /dev/null +++ b/bcipy/simulator/data/trial.py @@ -0,0 +1,128 @@ +"""Functions for trial-related data.""" +import itertools as it +from pathlib import Path +from typing import Callable, List, NamedTuple, Optional, Tuple + +import numpy as np + +from bcipy.config import SESSION_DATA_FILENAME +from bcipy.core.list import find_index +from bcipy.core.session import read_session +from bcipy.simulator.data.data_process import ExtractedExperimentData + + +class Trial(NamedTuple): + """Data for a given trial (a symbol within an Inquiry). + + Attrs + ----- + source - directory of the data source + series - starts at 1; computed from session.json + series_inquiry - starts at 0; inquiry within the series; computed + from session.json + inquiry_n - starts at 0; value from the start of the session (does not + reset at each series); can be computed from raw_data. + inquiry_pos - starts at 1; position in which the symbol was presented + symbol - alphabet symbol that was presented + target - 1 or 0 indicating a boolean of whether this was a target symbol + eeg - EEG data associated with this trial + """ + source: str + series: Optional[int] + series_inquiry: Optional[int] + inquiry_n: int + inquiry_pos: int + symbol: str + target: int + eeg: np.ndarray # Channels by Samples ; ndarray.shape = (channel_n, sample_n) + + def __str__(self): + fields = [ + f"source='{self.source}'", f"series={self.series}", + f"series_inquiry={self.series_inquiry}", + f"inquiry_n={self.inquiry_n}", f"inquiry_pos={self.inquiry_pos}", + f"symbol='{self.symbol}'", f"target={self.target}", + f"eeg={self.eeg.shape}" + ] + return f"Trial({', '.join(fields)})" + + def __repr__(self): + return str(self) + + +def session_series_counts(source_dir: str) -> List[int]: + """Read the session.json file in the provided directory + and compute the number of inquiries per series.""" + session_path = Path(source_dir, SESSION_DATA_FILENAME) + if session_path.exists(): + session = read_session(str(session_path)) + return [len(series) for series in session.series] + return [] + + +def series_inquiry(series_counts: List[int], + inquiry_n: int) -> Tuple[Optional[int], Optional[int]]: + """Given the number of inquiries per series and the inquiry from the start of the session, + compute the series and inquiry relative to the start of that series. + + Parameters + ---------- + series_counts - number of inquiries for each series in the session. + inquiry_n - index of the inquiry from the start of the session + starts at 0. + Returns + ------- + a tuple of the series (1-based), inquiry_index (0-based) relative to series. + """ + if series_counts: + accumulations = list(it.accumulate(series_counts)) + series_index = find_index(accumulations, + match_item=lambda val: val > inquiry_n) + if series_index is not None: + if series_index > 0: + inq_index = inquiry_n - accumulations[series_index - 1] + else: + inq_index = inquiry_n + return (series_index + 1, inq_index) + return (None, None) + + +def convert_trials( + data_source: ExtractedExperimentData, + get_series_counts: Callable[[str], List[int]] = session_series_counts +) -> List[Trial]: + """Convert extracted data from a single data source to a list of Trials. + + Parameters + ---------- + data_source - Data from an acquisition device after reshaping and filtering. + get_series_counts - function to get the session metadata needed to assign + series and relative_inquiry information to each trial. The function should + take the source_directory path as an input and return the number of inquiries + in each series. By default this is read from the session.json in the source_dir. + """ + trials = [] + symbols_by_inquiry = data_source.symbols_by_inquiry + labels_by_inquiry = data_source.labels_by_inquiry + series_counts = get_series_counts(data_source.source_dir) + + for i, inquiry_eeg in enumerate(data_source.trials_by_inquiry): + # iterate through each inquiry + inquiry_symbols = symbols_by_inquiry[i] + inquiry_labels = labels_by_inquiry[i] + + for sym_i, symbol in enumerate(inquiry_symbols): + # iterate through each symbol in the inquiry + series, inquiry = series_inquiry(series_counts, i) + eeg_samples = [channel[sym_i] + for channel in inquiry_eeg] # (channel_n, sample_n) + trials.append( + Trial(source=data_source.source_dir, + series=series, + series_inquiry=inquiry, + inquiry_n=i, + inquiry_pos=sym_i + 1, + symbol=symbol, + target=inquiry_labels[sym_i], + eeg=np.array(eeg_samples))) + return trials diff --git a/bcipy/simulator/tests/test_trial.py b/bcipy/simulator/tests/test_trial.py new file mode 100644 index 000000000..4dbef41f6 --- /dev/null +++ b/bcipy/simulator/tests/test_trial.py @@ -0,0 +1,148 @@ +"""Test for trial data""" + +import unittest +from unittest.mock import Mock, patch + +import numpy as np + +from bcipy.simulator.data.data_process import (DecodedTriggers, + ExtractedExperimentData) +from bcipy.simulator.data.trial import (Trial, convert_trials, series_inquiry, + session_series_counts) + + +class TestTrial(unittest.TestCase): + """Test for trial class and functions.""" + + def test_trial_str(self): + """Test representation of a trial""" + trial = Trial(source="path-to-source", + series=1, + series_inquiry=2, + inquiry_n=2, + inquiry_pos=5, + symbol='M', + target=1, + eeg=np.zeros(shape=(3, 2))) + self.assertTrue('series=1' in str(trial)) + self.assertTrue('eeg=(3, 2)' in str(trial)) + + def test_trial_without_optional(self): + """Test without the optional data""" + trial = Trial(source="path-to-source", + series=None, + series_inquiry=None, + inquiry_n=2, + inquiry_pos=5, + symbol='M', + target=1, + eeg=np.zeros(shape=(3, 2))) + self.assertTrue('series=None' in str(trial)) + + @patch("bcipy.simulator.data.trial.read_session") + @patch("bcipy.simulator.data.trial.Path") + def test_session_series_counts(self, path_constructor_mock, + read_session_mock): + """Test computing the series counts from the session data when the + session path exists.""" + path_mock = Mock() + path_mock.exists.return_value = True + path_constructor_mock.return_value = path_mock + session_series_counts("example-data-directory") + read_session_mock.assert_called_once() + + @patch("bcipy.simulator.data.trial.read_session") + @patch("bcipy.simulator.data.trial.Path") + def test_session_series_counts_bad_path(self, path_constructor_mock, + read_session_mock): + """Test computing the series counts from the session data when session + data is not present.""" + path_mock = Mock() + path_mock.exists.return_value = False + path_constructor_mock.return_value = path_mock + session_series_counts("no-session") + read_session_mock.assert_not_called() + + def test_series_inquiry(self): + """Test series inquiry calculation""" + counts = [5, 2, 4] + self.assertEqual((1, 0), series_inquiry(counts, 0)) + self.assertEqual((1, 4), series_inquiry(counts, 4)) + self.assertEqual((2, 0), series_inquiry(counts, 5)) + self.assertEqual((2, 1), series_inquiry(counts, 6)) + self.assertEqual((3, 0), series_inquiry(counts, 7)) + self.assertEqual((3, 2), series_inquiry(counts, 9)) + self.assertEqual((3, 3), series_inquiry(counts, 10)) + + self.assertEqual((None, None), series_inquiry([], 0)) + + def test_convert_trials(self): + """Test converting data to trials.""" + + sample_data = ExtractedExperimentData( + source_dir="test-src", + inquiries=np.ones((6, 4, 436)), + trials=np.ones((6, 12, 75)), + labels=[[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 1]], + inquiry_timing=[[150, 180, 210], [150, 180, 210], [150, 180, 210], + [150, 180, 210]], + decoded_triggers=DecodedTriggers( + targetness=[ + 'nontarget', 'nontarget', 'nontarget', 'nontarget', + 'nontarget', 'nontarget', 'nontarget', 'nontarget', + 'nontarget', 'nontarget', 'nontarget', 'target' + ], + times=[ + 10.515330292284489, 10.717246917076409, 10.919030375313014, + 15.644844584167004, 15.846348999999464, 16.047540083993226, + 20.790782417170703, 20.992313000373542, 21.193465375341475, + 25.914405125193298, 26.115663417149335, 26.31681716721505 + ], + symbols=[ + '<', 'N', 'J', '<', 'N', 'J', 'J', 'B', '<', '<', 'J', 'B' + ], + corrected_times=[ + 10.515330292284489, 10.717246917076409, 10.919030375313014, + 15.644844584167004, 15.846348999999464, 16.047540083993226, + 20.790782417170703, 20.992313000373542, 21.193465375341475, + 25.914405125193298, 26.115663417149335, 26.31681716721505 + ]), + trials_per_inquiry=3) + + trials = convert_trials(sample_data, get_series_counts=lambda x: [4]) + self.assertEqual(12, len(trials)) + + # check properties for first element + self.assertEqual(trials[0].source, "test-src") + self.assertEqual(trials[0].series, 1) + self.assertEqual(trials[0].series_inquiry, 0) + self.assertEqual(trials[0].inquiry_n, 0) + self.assertEqual(trials[0].inquiry_pos, 1) + self.assertEqual(trials[0].symbol, '<') + self.assertEqual(trials[0].target, 0) + self.assertEqual(trials[0].eeg.shape, (6, 75)) + + # check properties for last element + self.assertEqual(trials[-1].source, "test-src") + self.assertEqual(trials[-1].series, 1) + self.assertEqual(trials[-1].series_inquiry, 3) + self.assertEqual(trials[-1].inquiry_n, 3) + self.assertEqual(trials[-1].inquiry_pos, 3) + self.assertEqual(trials[-1].symbol, 'B') + self.assertEqual(trials[-1].target, 1) + self.assertEqual(trials[-1].eeg.shape, (6, 75)) + + # check properties when series_counts are different + trials = convert_trials(sample_data, + get_series_counts=lambda x: [3, 1]) + self.assertEqual(trials[-1].series, 2) + self.assertEqual(trials[-1].series_inquiry, 0) + self.assertEqual(trials[-1].inquiry_n, 3) + self.assertEqual(trials[-1].inquiry_pos, 3) + self.assertEqual(trials[-1].symbol, 'B') + self.assertEqual(trials[-1].target, 1) + self.assertEqual(trials[-1].eeg.shape, (6, 75)) + + +if __name__ == '__main__': + unittest.main() From 5345fbf0c3d563212ae83081c67ba22dbc2e2eb0 Mon Sep 17 00:00:00 2001 From: lawhead Date: Mon, 23 Dec 2024 09:48:05 -0800 Subject: [PATCH 30/76] Dedup list test --- bcipy/core/tests/test_list.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bcipy/core/tests/test_list.py b/bcipy/core/tests/test_list.py index 650d6aba7..8e986f714 100644 --- a/bcipy/core/tests/test_list.py +++ b/bcipy/core/tests/test_list.py @@ -64,14 +64,14 @@ def test_find_index(self): def test_find_index_using_key(self): """Find index using the key arg""" - item1 = dict(a=1, b=1) - item2 = dict(a=1, b=2) - item3 = dict(a=2, b=1) - item4 = dict(a=2, b=2) + item1 = dict(a=10, b=10) + item2 = dict(a=10, b=20) + item3 = dict(a=20, b=10) + item4 = dict(a=20, b=20) self.assertEqual( 1, find_index([item1, item2, item3, item4], - match_item=2, + match_item=20, key=lambda item: item['b'])) def test_find_index_matching_predicate(self): From 7d354b18808b425921faf41656a0f5ef52c39501 Mon Sep 17 00:00:00 2001 From: Tab Memmott Date: Fri, 3 Jan 2025 12:46:09 -0800 Subject: [PATCH 31/76] fixes from rebase --- bcipy/helpers/tests/__init__.py | 3 --- bcipy/io/load.py | 10 ++++++++++ bcipy/signal/evaluate/fusion.py | 8 ++++---- bcipy/signal/model/offline_analysis.py | 1 + bcipy/simulator/task/task_factory.py | 2 +- bcipy/simulator/ui/cli.py | 2 +- bcipy/simulator/ui/gui.py | 2 +- pyproject.toml | 2 +- 8 files changed, 19 insertions(+), 11 deletions(-) delete mode 100644 bcipy/helpers/tests/__init__.py diff --git a/bcipy/helpers/tests/__init__.py b/bcipy/helpers/tests/__init__.py deleted file mode 100644 index aa3dec7fc..000000000 --- a/bcipy/helpers/tests/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -import sys -from os.path import dirname -sys.path.append(dirname(__file__)) diff --git a/bcipy/io/load.py b/bcipy/io/load.py index 9bbf1e5ae..fe3fe268f 100644 --- a/bcipy/io/load.py +++ b/bcipy/io/load.py @@ -237,6 +237,16 @@ def choose_signal_model(device_type: str) -> Optional[SignalModel]: return None +def choose_model_paths(device_types: List[str]) -> List[Path]: + """Select a model for each device and return a list of paths.""" + return [ + ask_filename(file_types=f"*{SIGNAL_MODEL_FILE_SUFFIX}", + directory=preferences.signal_model_directory, + prompt=f"Select the {device_type} signal model") + for device_type in device_types + ] + + def choose_csv_file(filename: Optional[str] = None) -> Optional[str]: """GUI prompt to select a csv file from the file system. diff --git a/bcipy/signal/evaluate/fusion.py b/bcipy/signal/evaluate/fusion.py index 6d91cddb5..c23ba928f 100644 --- a/bcipy/signal/evaluate/fusion.py +++ b/bcipy/signal/evaluate/fusion.py @@ -7,9 +7,9 @@ from bcipy.config import (TRIGGER_FILENAME, SESSION_LOG_FILENAME) from bcipy.helpers.acquisition import analysis_channels -from bcipy.helpers.raw_data import RawData -from bcipy.helpers.stimuli import update_inquiry_timing -from bcipy.helpers.triggers import TriggerType, trigger_decoder +from bcipy.core.raw_data import RawData +from bcipy.core.stimuli import update_inquiry_timing +from bcipy.core.triggers import TriggerType, trigger_decoder from bcipy.preferences import preferences from bcipy.signal.model.base_model import SignalModelMetadata from bcipy.signal.model.base_model import SignalModel @@ -18,7 +18,7 @@ from bcipy.signal.process import (ERPTransformParams, extract_eye_info, filter_inquiries, get_default_transform) from bcipy.acquisition.devices import DeviceSpec -from bcipy.helpers.parameters import Parameters +from bcipy.core.parameters import Parameters logger = logging.getLogger(SESSION_LOG_FILENAME) diff --git a/bcipy/signal/model/offline_analysis.py b/bcipy/signal/model/offline_analysis.py index 70d0068ca..aab4c49f5 100644 --- a/bcipy/signal/model/offline_analysis.py +++ b/bcipy/signal/model/offline_analysis.py @@ -24,6 +24,7 @@ from bcipy.io.save import save_model from bcipy.core.stimuli import update_inquiry_timing from bcipy.core.symbols import alphabet +from bcipy.core.raw_data import RawData from bcipy.helpers.utils import report_execution_time from bcipy.core.triggers import TriggerType, trigger_decoder from bcipy.preferences import preferences diff --git a/bcipy/simulator/task/task_factory.py b/bcipy/simulator/task/task_factory.py index 589bdeb00..827fe123e 100644 --- a/bcipy/simulator/task/task_factory.py +++ b/bcipy/simulator/task/task_factory.py @@ -3,7 +3,7 @@ from typing import Dict, List, Type from bcipy.helpers.language_model import init_language_model -from bcipy.io.load import load_json_parameters, load_signal_models +from bcipy.io.load import load_json_parameters, load_signal_model from bcipy.core.parameters import DEFAULT_PARAMETERS_PATH, Parameters from bcipy.signal.model.base_model import SignalModel from bcipy.simulator.data.data_engine import RawDataEngine diff --git a/bcipy/simulator/ui/cli.py b/bcipy/simulator/ui/cli.py index 8784482d8..4ffaca8a7 100644 --- a/bcipy/simulator/ui/cli.py +++ b/bcipy/simulator/ui/cli.py @@ -15,7 +15,7 @@ from bcipy.gui.file_dialog import ask_directory, ask_filename from bcipy.helpers.acquisition import active_content_types -from bcipy.helpers.load import choose_model_paths +from bcipy.io.load import choose_model_paths from bcipy.simulator.data.sampler import (InquirySampler, Sampler, TargetNontargetSampler) from bcipy.simulator.task.copy_phrase import SimulatorCopyPhraseTask diff --git a/bcipy/simulator/ui/gui.py b/bcipy/simulator/ui/gui.py index b22a56f21..2d9056a15 100644 --- a/bcipy/simulator/ui/gui.py +++ b/bcipy/simulator/ui/gui.py @@ -19,7 +19,7 @@ from bcipy.gui.file_dialog import FileDialog from bcipy.gui.main import static_text_control from bcipy.helpers.acquisition import active_content_types -from bcipy.helpers.parameters import Parameters +from bcipy.core.parameters import Parameters from bcipy.preferences import preferences from bcipy.simulator.data.sampler import TargetNontargetSampler from bcipy.simulator.task.copy_phrase import SimulatorCopyPhraseTask diff --git a/pyproject.toml b/pyproject.toml index 459bb42a4..8e12b6379 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,7 +104,7 @@ zip-safe = true # Python modules and packages that are included in the # distribution package (and therefore become importable) [tool.setuptools.packages.find] -exclude = ["tests", "demo", "data"] +exclude = ["tests", "demo", "scripts"] [tool.distutils.bdist_wheel] universal = true From bfbd1961a1f0b1c0299440b15d07d2552a986f1c Mon Sep 17 00:00:00 2001 From: lawhead Date: Tue, 7 Jan 2025 18:15:13 -0800 Subject: [PATCH 32/76] Fixed issues with simulator logging --- bcipy/simulator/README.md | 22 +++++- bcipy/simulator/task/copy_phrase.py | 7 +- bcipy/simulator/task/task_runner.py | 8 ++- bcipy/simulator/tests/test_sim_artifact.py | 80 ++++++++++++++++++++++ bcipy/simulator/util/artifact.py | 44 ++++++++---- 5 files changed, 139 insertions(+), 22 deletions(-) create mode 100644 bcipy/simulator/tests/test_sim_artifact.py diff --git a/bcipy/simulator/README.md b/bcipy/simulator/README.md index f3f51a65b..e54dd443a 100644 --- a/bcipy/simulator/README.md +++ b/bcipy/simulator/README.md @@ -72,9 +72,27 @@ A directory is created for each simulation run. The directory contents are simil The simulator is structured to support evidence from multiple devices (multimodal). However, it currently only includes processing for EEG device data. To provide support for models trained on data from other devices (ex. Gaze), a `RawDataProcessor` must be added for that device. The Processor pre-processes data collected from that device and prepares it for sampling. A `RawDataProcessor` is matched up to a given signal model using that model's metadata (metadata.device_spec.content_type). See the `data_process` module for more details. +## Parameters + +The parameters file is used to configure various aspects of the of the simulation. Timing-related parameters should generally match the parameters file used for training the signal model(s). Following are some specific parameters that you may want to modify, depending on the goals of a particular simulation: + +* `task_text` - the text to spell. +* `lang_model_type` - language model to use in the simulation. +* `summarize_session` - if set to true a session.xlsx summary will be generated for each simulation run. + +### Stoppage Criteria + +Parameters which define task stoppage criteria are important to ensure that the simulation runs to completion without getting stuck in an infinite loop. The values for these parameters may also affect analysis of results. + +* `min_inq_len` - Specifies the minimum number of inquiries to present before making a decision in copy/spelling tasks. +* `max_inq_len` - maximum number of inquiries to display before stopping the task. +* `max_selections` - The maximum number of selections for copy/spelling tasks. The task will end if this number is reached. +* `max_incorrect` - The maximum number of consecutive incorrect selections for copy/spelling tasks. The task will end if this number is reached. +* `max_inq_per_series` - Specifies the maximum number of inquiries to present before making a decision in copy/spelling tasks + + ## Current Limitations * Only provides EEG support * Only one sampler maybe provided for all devices. Ideally we should support a different sampling strategy for each device. -* Only Copy Phrase is currently supported. -* Metrics are collected per run, but not summarized across all runs. \ No newline at end of file +* Only Copy Phrase is currently supported. \ No newline at end of file diff --git a/bcipy/simulator/task/copy_phrase.py b/bcipy/simulator/task/copy_phrase.py index 4388f93f9..f1215023c 100644 --- a/bcipy/simulator/task/copy_phrase.py +++ b/bcipy/simulator/task/copy_phrase.py @@ -3,10 +3,10 @@ import logging from typing import Any, Dict, List, Optional, Tuple -from bcipy.display.main import Display -from bcipy.feedback.visual.visual_feedback import VisualFeedback from bcipy.core.parameters import Parameters from bcipy.core.stimuli import InquirySchedule +from bcipy.display.main import Display +from bcipy.feedback.visual.visual_feedback import VisualFeedback from bcipy.language.main import LanguageModel from bcipy.signal.model.base_model import SignalModel from bcipy.simulator.data.sampler import Sampler @@ -141,6 +141,9 @@ def compute_device_evidence( evidences.append((evidence_type, evidence)) return evidences + def cleanup(self): + self.save_session_data() + def exit_display(self) -> None: """Close the UI and cleanup.""" diff --git a/bcipy/simulator/task/task_runner.py b/bcipy/simulator/task/task_runner.py index 4897e4145..2a5a9660c 100644 --- a/bcipy/simulator/task/task_runner.py +++ b/bcipy/simulator/task/task_runner.py @@ -15,7 +15,8 @@ from bcipy.simulator.util.artifact import (DEFAULT_SAVE_LOCATION, TOP_LEVEL_LOGGER_NAME, configure_run_directory, - init_simulation_dir) + init_simulation_dir, + remove_file_logger) logger = logging.getLogger(TOP_LEVEL_LOGGER_NAME) @@ -41,16 +42,17 @@ def run(self) -> None: """Run one or more simulations""" self.task_factory.parameters.save(self.sim_dir, 'parameters.json') for i in range(self.runs): - logger.info(f"Executing task {i+1}") self.do_run(i + 1) - logger.info("Task complete") def do_run(self, run: int): """Execute a simulation run.""" + logger.info(f"Executing task {run}") run_dir = configure_run_directory(self.sim_dir, run) logger.info(run_dir) task = self.task_factory.make_task(run_dir) task.execute() + logger.info("Task complete") + remove_file_logger(self.sim_dir, run) def main(): diff --git a/bcipy/simulator/tests/test_sim_artifact.py b/bcipy/simulator/tests/test_sim_artifact.py new file mode 100644 index 000000000..8374a913f --- /dev/null +++ b/bcipy/simulator/tests/test_sim_artifact.py @@ -0,0 +1,80 @@ +"""Tests for simulator logging and directory functions.""" +import logging +import shutil +import tempfile +import unittest +from pathlib import Path + +from bcipy.simulator.util.artifact import (RUN_PREFIX, configure_logger, + remove_file_logger) + + +class TestSimArtifact(unittest.TestCase): + """Test for simulator artifact functions.""" + + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.temp_dir) + + def test_configure_named_logger(self): + """Test configure named logger""" + + configure_logger(self.temp_dir, + file_name='test_1a.log', + logger_name='TEST_LOGGER', + use_stdout=True) + + log = logging.getLogger('TEST_LOGGER') + self.assertEqual(2, len(log.handlers)) + self.assertEqual('TEST_LOGGER', log.name) + log.info("testing 1 2 3") + self.assertTrue(Path(self.temp_dir, "test_1a.log").exists()) + + self.assertFalse(Path(self.temp_dir, "test_1b.log").exists()) + configure_logger(self.temp_dir, + file_name='test_1b.log', + logger_name='TEST_LOGGER', + use_stdout=True) + + log.info("testing 4 5 6") + self.assertTrue(Path(self.temp_dir, "test_1b.log").exists()) + self.assertEqual(2, len(log.handlers)) + + def test_configure_named_logger_without_stdout(self): + """Test the named logger without logging to stdout.""" + + configure_logger(self.temp_dir, + file_name='test_2.log', + logger_name='TEST_LOGGER', + use_stdout=False) + + log = logging.getLogger('TEST_LOGGER') + self.assertEqual(1, len(log.handlers)) + self.assertTrue(isinstance(log.handlers[0], logging.FileHandler)) + + def test_configure_root_logger(self): + """Test configure root logger""" + configure_logger(self.temp_dir, + file_name='test_root_logger.log', + logger_name=None, + use_stdout=False) + log = logging.getLogger() + self.assertEqual(1, len(log.handlers)) + self.assertTrue(isinstance(log.handlers[0], logging.FileHandler)) + + def test_remove_file_logger(self): + """Test removal of file handler from root logger""" + configure_logger(self.temp_dir, + file_name=f"{RUN_PREFIX}99", + logger_name=None, + use_stdout=False) + log = logging.getLogger() + self.assertEqual(1, len(log.handlers)) + remove_file_logger(self.temp_dir, run=99) + self.assertEqual(0, len(log.handlers)) + + +if __name__ == '__main__': + unittest.main() diff --git a/bcipy/simulator/util/artifact.py b/bcipy/simulator/util/artifact.py index 51b0f9a5d..decf59451 100644 --- a/bcipy/simulator/util/artifact.py +++ b/bcipy/simulator/util/artifact.py @@ -31,27 +31,27 @@ def configure_logger(log_path: str, """ log = logging.getLogger(logger_name) # configuring root logger - log.setLevel(logging.INFO) - # Create handlers for logging to the standard output and a file - stdout_handler = logging.StreamHandler(stream=sys.stdout) - file_handler = logging.FileHandler(f"{log_path}/{file_name}") - # Set the log levels on the handlers - stdout_handler.setLevel(logging.INFO) - file_handler.setLevel(logging.INFO) + # clear existing handlers + handlers = log.handlers[:] # make copy + for handler in handlers: + # call the method, which handles locking. + log.removeHandler(handler) + if isinstance(handler, logging.FileHandler): + handler.close() + log.setLevel(logging.INFO) fmt = '[%(asctime)s][%(name)s][%(levelname)s]: %(message)s' - fmt_file = logging.Formatter(fmt) - fmt_stdout = logging.Formatter("%(message)s") - - # Set the log format on each handler - stdout_handler.setFormatter(fmt_stdout) - file_handler.setFormatter(fmt_file) - # Add each handler to the Logger object - if use_stdout and stdout_handler not in log.handlers: + if use_stdout: + stdout_handler = logging.StreamHandler(stream=sys.stdout) + stdout_handler.setLevel(logging.INFO) + stdout_handler.setFormatter(logging.Formatter("%(message)s")) log.addHandler(stdout_handler) + file_handler = logging.FileHandler(f"{log_path}/{file_name}") + file_handler.setLevel(logging.INFO) + file_handler.setFormatter(logging.Formatter(fmt)) log.addHandler(file_handler) @@ -84,10 +84,24 @@ def configure_run_directory(sim_dir: str, run: int) -> str: os.mkdir(path) configure_logger(log_path=path, file_name=f"{run_name}.log", + logger_name=None, use_stdout=False) return path +def remove_file_logger(sim_dir: str, run: int) -> None: + """Disable any configured file handler for the root logger.""" + path = f"{sim_dir}/{RUN_PREFIX}{run}" + + log = logging.getLogger() + handlers = log.handlers[:] + for handler in handlers: + if isinstance(handler, + logging.FileHandler) and path in handler.baseFilename: + log.removeHandler(handler) + handler.close() + + def directory_name() -> str: """Name of the directory for a new simulation run.""" return datetime.datetime.now().strftime("SIM_%m-%d-%Y_%H_%M_%S") From ec419b71b0700903466fd2ea504949cd67fa65b2 Mon Sep 17 00:00:00 2001 From: lawhead Date: Wed, 8 Jan 2025 10:34:11 -0800 Subject: [PATCH 33/76] Attempting to fix a Windows bug --- bcipy/simulator/tests/test_sim_artifact.py | 2 +- bcipy/simulator/util/artifact.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bcipy/simulator/tests/test_sim_artifact.py b/bcipy/simulator/tests/test_sim_artifact.py index 8374a913f..a604930c7 100644 --- a/bcipy/simulator/tests/test_sim_artifact.py +++ b/bcipy/simulator/tests/test_sim_artifact.py @@ -67,7 +67,7 @@ def test_configure_root_logger(self): def test_remove_file_logger(self): """Test removal of file handler from root logger""" configure_logger(self.temp_dir, - file_name=f"{RUN_PREFIX}99", + file_name=f"{RUN_PREFIX}99.log", logger_name=None, use_stdout=False) log = logging.getLogger() diff --git a/bcipy/simulator/util/artifact.py b/bcipy/simulator/util/artifact.py index decf59451..ca9898dad 100644 --- a/bcipy/simulator/util/artifact.py +++ b/bcipy/simulator/util/artifact.py @@ -49,7 +49,7 @@ def configure_logger(log_path: str, stdout_handler.setFormatter(logging.Formatter("%(message)s")) log.addHandler(stdout_handler) - file_handler = logging.FileHandler(f"{log_path}/{file_name}") + file_handler = logging.FileHandler(f"{log_path}/{file_name}", delay=True) file_handler.setLevel(logging.INFO) file_handler.setFormatter(logging.Formatter(fmt)) log.addHandler(file_handler) From 3d71d8f91234571989c595af795e3c6ee6cac878 Mon Sep 17 00:00:00 2001 From: lawhead Date: Wed, 8 Jan 2025 11:08:26 -0800 Subject: [PATCH 34/76] Added code to clean up file handlers in logging tests --- bcipy/simulator/tests/test_sim_artifact.py | 6 +++++- bcipy/simulator/util/artifact.py | 23 +++++++++++++--------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/bcipy/simulator/tests/test_sim_artifact.py b/bcipy/simulator/tests/test_sim_artifact.py index a604930c7..dc7871eff 100644 --- a/bcipy/simulator/tests/test_sim_artifact.py +++ b/bcipy/simulator/tests/test_sim_artifact.py @@ -6,7 +6,7 @@ from pathlib import Path from bcipy.simulator.util.artifact import (RUN_PREFIX, configure_logger, - remove_file_logger) + remove_file_logger, remove_handlers) class TestSimArtifact(unittest.TestCase): @@ -41,6 +41,7 @@ def test_configure_named_logger(self): log.info("testing 4 5 6") self.assertTrue(Path(self.temp_dir, "test_1b.log").exists()) self.assertEqual(2, len(log.handlers)) + remove_handlers(log) def test_configure_named_logger_without_stdout(self): """Test the named logger without logging to stdout.""" @@ -53,6 +54,7 @@ def test_configure_named_logger_without_stdout(self): log = logging.getLogger('TEST_LOGGER') self.assertEqual(1, len(log.handlers)) self.assertTrue(isinstance(log.handlers[0], logging.FileHandler)) + remove_handlers(log) def test_configure_root_logger(self): """Test configure root logger""" @@ -63,6 +65,7 @@ def test_configure_root_logger(self): log = logging.getLogger() self.assertEqual(1, len(log.handlers)) self.assertTrue(isinstance(log.handlers[0], logging.FileHandler)) + remove_handlers(log) def test_remove_file_logger(self): """Test removal of file handler from root logger""" @@ -74,6 +77,7 @@ def test_remove_file_logger(self): self.assertEqual(1, len(log.handlers)) remove_file_logger(self.temp_dir, run=99) self.assertEqual(0, len(log.handlers)) + remove_handlers(log) if __name__ == '__main__': diff --git a/bcipy/simulator/util/artifact.py b/bcipy/simulator/util/artifact.py index ca9898dad..fde04e019 100644 --- a/bcipy/simulator/util/artifact.py +++ b/bcipy/simulator/util/artifact.py @@ -16,10 +16,20 @@ RUN_PREFIX = "run_" +def remove_handlers(log: logging.Logger) -> None: + """Remove any file handlers from the provided logger.""" + handlers = log.handlers[:] # make copy + for handler in handlers: + # call the method, which handles locking. + log.removeHandler(handler) + if isinstance(handler, logging.FileHandler): + handler.close() + + def configure_logger(log_path: str, file_name: str, logger_name: Optional[str] = None, - use_stdout: bool = True): + use_stdout: bool = True) -> logging.Logger: """Configures logger for standard out and file output. Parameters @@ -32,13 +42,7 @@ def configure_logger(log_path: str, log = logging.getLogger(logger_name) # configuring root logger - # clear existing handlers - handlers = log.handlers[:] # make copy - for handler in handlers: - # call the method, which handles locking. - log.removeHandler(handler) - if isinstance(handler, logging.FileHandler): - handler.close() + remove_handlers(log) log.setLevel(logging.INFO) fmt = '[%(asctime)s][%(name)s][%(levelname)s]: %(message)s' @@ -49,10 +53,11 @@ def configure_logger(log_path: str, stdout_handler.setFormatter(logging.Formatter("%(message)s")) log.addHandler(stdout_handler) - file_handler = logging.FileHandler(f"{log_path}/{file_name}", delay=True) + file_handler = logging.FileHandler(f"{log_path}/{file_name}") file_handler.setLevel(logging.INFO) file_handler.setFormatter(logging.Formatter(fmt)) log.addHandler(file_handler) + return log def init_simulation_dir(save_location: str = DEFAULT_SAVE_LOCATION, From f5d4e2c32e977925186dd6a397b54fc54ecb4de5 Mon Sep 17 00:00:00 2001 From: lawhead Date: Wed, 8 Jan 2025 11:27:14 -0800 Subject: [PATCH 35/76] Refactor to address Windows bug --- bcipy/simulator/task/task_runner.py | 6 +++--- bcipy/simulator/tests/test_sim_artifact.py | 15 +-------------- bcipy/simulator/util/artifact.py | 15 ++++++++------- 3 files changed, 12 insertions(+), 24 deletions(-) diff --git a/bcipy/simulator/task/task_runner.py b/bcipy/simulator/task/task_runner.py index 2a5a9660c..57bb1f009 100644 --- a/bcipy/simulator/task/task_runner.py +++ b/bcipy/simulator/task/task_runner.py @@ -16,7 +16,7 @@ TOP_LEVEL_LOGGER_NAME, configure_run_directory, init_simulation_dir, - remove_file_logger) + remove_handlers) logger = logging.getLogger(TOP_LEVEL_LOGGER_NAME) @@ -47,12 +47,12 @@ def run(self) -> None: def do_run(self, run: int): """Execute a simulation run.""" logger.info(f"Executing task {run}") - run_dir = configure_run_directory(self.sim_dir, run) + run_dir, run_log = configure_run_directory(self.sim_dir, run) logger.info(run_dir) task = self.task_factory.make_task(run_dir) task.execute() logger.info("Task complete") - remove_file_logger(self.sim_dir, run) + remove_handlers(run_log) def main(): diff --git a/bcipy/simulator/tests/test_sim_artifact.py b/bcipy/simulator/tests/test_sim_artifact.py index dc7871eff..f2c684622 100644 --- a/bcipy/simulator/tests/test_sim_artifact.py +++ b/bcipy/simulator/tests/test_sim_artifact.py @@ -5,8 +5,7 @@ import unittest from pathlib import Path -from bcipy.simulator.util.artifact import (RUN_PREFIX, configure_logger, - remove_file_logger, remove_handlers) +from bcipy.simulator.util.artifact import configure_logger, remove_handlers class TestSimArtifact(unittest.TestCase): @@ -67,18 +66,6 @@ def test_configure_root_logger(self): self.assertTrue(isinstance(log.handlers[0], logging.FileHandler)) remove_handlers(log) - def test_remove_file_logger(self): - """Test removal of file handler from root logger""" - configure_logger(self.temp_dir, - file_name=f"{RUN_PREFIX}99.log", - logger_name=None, - use_stdout=False) - log = logging.getLogger() - self.assertEqual(1, len(log.handlers)) - remove_file_logger(self.temp_dir, run=99) - self.assertEqual(0, len(log.handlers)) - remove_handlers(log) - if __name__ == '__main__': unittest.main() diff --git a/bcipy/simulator/util/artifact.py b/bcipy/simulator/util/artifact.py index fde04e019..ff59b97b3 100644 --- a/bcipy/simulator/util/artifact.py +++ b/bcipy/simulator/util/artifact.py @@ -3,7 +3,7 @@ import logging import os import sys -from typing import Optional +from typing import Optional, Tuple from bcipy.config import ROOT @@ -80,18 +80,19 @@ def init_simulation_dir(save_location: str = DEFAULT_SAVE_LOCATION, return save_dir -def configure_run_directory(sim_dir: str, run: int) -> str: +def configure_run_directory(sim_dir: str, + run: int) -> Tuple[str, logging.Logger]: """Create the necessary directories and configure the logger. Returns the run directory. """ run_name = f"{RUN_PREFIX}{run}" path = f"{sim_dir}/{run_name}" os.mkdir(path) - configure_logger(log_path=path, - file_name=f"{run_name}.log", - logger_name=None, - use_stdout=False) - return path + log = configure_logger(log_path=path, + file_name=f"{run_name}.log", + logger_name=None, + use_stdout=False) + return path, log def remove_file_logger(sim_dir: str, run: int) -> None: From 6389f7a19973d171122d61e6ae43bbfa5744d0a6 Mon Sep 17 00:00:00 2001 From: lawhead Date: Thu, 9 Jan 2025 16:33:46 -0800 Subject: [PATCH 36/76] Restructured simulation samplers; added a sampler for limiting which inquiries are used --- bcipy/simulator/README.md | 1 + bcipy/simulator/data/data_engine.py | 15 ++- bcipy/simulator/data/sampler/__init__.py | 10 ++ bcipy/simulator/data/sampler/base_sampler.py | 91 ++++++++++++++ .../data/sampler/inquiry_range_sampler.py | 30 +++++ .../inquiry_sampler.py} | 114 +----------------- .../data/sampler/target_nontarget_sampler.py | 29 +++++ bcipy/simulator/task/task_runner.py | 4 +- .../tests/test_inquiry_range_sampler.py | 42 +++++++ 9 files changed, 222 insertions(+), 114 deletions(-) create mode 100644 bcipy/simulator/data/sampler/__init__.py create mode 100644 bcipy/simulator/data/sampler/base_sampler.py create mode 100644 bcipy/simulator/data/sampler/inquiry_range_sampler.py rename bcipy/simulator/data/{sampler.py => sampler/inquiry_sampler.py} (57%) create mode 100644 bcipy/simulator/data/sampler/target_nontarget_sampler.py create mode 100644 bcipy/simulator/tests/test_inquiry_range_sampler.py diff --git a/bcipy/simulator/README.md b/bcipy/simulator/README.md index e54dd443a..1fa097bb6 100644 --- a/bcipy/simulator/README.md +++ b/bcipy/simulator/README.md @@ -43,6 +43,7 @@ For example, - `m`: Path to a pickled (.pkl) signal model. One or more models can be provided. - `n`: Number of simulation runs - `o`: Output directory for all simulation artifacts. +- `s`: Sampling strategy to use; by default the TargetNonTargetSampler is used. The value provided should be the class name of a Sampler. #### Sim Output Details diff --git a/bcipy/simulator/data/data_engine.py b/bcipy/simulator/data/data_engine.py index 7e744b33c..303945e9c 100644 --- a/bcipy/simulator/data/data_engine.py +++ b/bcipy/simulator/data/data_engine.py @@ -2,7 +2,7 @@ import logging from abc import ABC, abstractmethod from pathlib import Path -from typing import Any, List, NamedTuple, Union +from typing import Any, List, NamedTuple, Union, get_args, get_origin import pandas as pd @@ -26,8 +26,17 @@ class QueryFilter(NamedTuple): def is_valid(self) -> bool: """Check if the filter is valid.""" # pylint: disable=no-member - return self.field in Trial._fields and self.operator in self.valid_operators and isinstance( - self.value, Trial.__annotations__[self.field]) + field_type = Trial.__annotations__[self.field] + + # can't check isinstance of a subscriptable type, such as Optional. + origin = get_origin(field_type) + if origin: + options = get_args(field_type) + is_correct_type = any(isinstance(self.value, ftype) for ftype in options) + else: + is_correct_type = isinstance(self.value, field_type) + + return self.field in Trial._fields and self.operator in self.valid_operators and is_correct_type @property def valid_operators(self) -> List[str]: diff --git a/bcipy/simulator/data/sampler/__init__.py b/bcipy/simulator/data/sampler/__init__.py new file mode 100644 index 000000000..8e8a9f048 --- /dev/null +++ b/bcipy/simulator/data/sampler/__init__.py @@ -0,0 +1,10 @@ +from bcipy.simulator.data.sampler.base_sampler import Sampler +from bcipy.simulator.data.sampler.inquiry_range_sampler import \ + InquiryRangeSampler +from bcipy.simulator.data.sampler.inquiry_sampler import InquirySampler +from bcipy.simulator.data.sampler.target_nontarget_sampler import \ + TargetNontargetSampler + +__all__ = [ + 'Sampler', 'InquirySampler', 'TargetNontargetSampler', 'InquiryRangeSampler' +] diff --git a/bcipy/simulator/data/sampler/base_sampler.py b/bcipy/simulator/data/sampler/base_sampler.py new file mode 100644 index 000000000..18f35b1a1 --- /dev/null +++ b/bcipy/simulator/data/sampler/base_sampler.py @@ -0,0 +1,91 @@ +from abc import ABC, abstractmethod +from typing import Callable, List, Tuple + +import numpy as np + +from bcipy.simulator.data.data_engine import RawDataEngine +from bcipy.simulator.data.trial import Trial +from bcipy.simulator.util.state import SimState + + +def default_reshaper(eeg_responses: List[np.ndarray]) -> np.ndarray: + """Default data reshaper. + + Returns + ------- + ndarray with shape (channel_n, trial_n, sample_n) + """ + + channels_eeg: List[List[np.ndarray]] = [ + [] for i in range(len(eeg_responses[0])) + ] + + for _i, trial_channels_eeg in enumerate(eeg_responses): + for c_i, channel_eeg in enumerate(trial_channels_eeg): + channels_eeg[c_i].append(channel_eeg) + + return np.array(channels_eeg) + + +def format_samples(sample_rows: List[Trial]) -> str: + """Returns a tabular representation of the sample rows.""" + return '\n'.join([str(row) for row in sample_rows]) + + +class Sampler(ABC): + """Represents a strategy for sampling signal model data from a DataEngine + comprised of signal data from one or more data collection sessions.""" + + def __init__(self, data_engine: RawDataEngine): + self.data_engine: RawDataEngine = data_engine + self.model_input_reshaper: Callable = default_reshaper + + @abstractmethod + def sample(self, state: SimState) -> List[Trial]: + """ + Query the data engine for a list of trials corresponding to each + currently displayed symbol. + + Parameters + ---------- + state - specifies the target symbol and current inquiry (displayed symbols). + + Returns + ------- + a list of Trials with an item for each symbol in the current inquiry. + """ + raise NotImplementedError + + def sample_data(self, state: SimState) -> np.ndarray: + """ + Query for trials and reshape for signal model input according to the + provided reshaper. + + Return: + ndarray of shape (n_channel, n_trial, n_sample) + """ + trials = self.sample(state) + return self.reshaped(trials) + + def sample_with_context(self, + state: SimState) -> Tuple[np.ndarray, List[Trial]]: + """ + Returns + ------- + A tuple of the reshaped data (ndarray of shape (n_channel, n_trial, n_sample)) as + well as a list of Trial data (metadata and data not reshaped) for context. + """ + trials = self.sample(state) + data = self.reshaped(trials) + return data, trials + + def reshaped(self, sample_rows: List[Trial]) -> np.ndarray: + """Returns the queried trials reshaped into a format that a model can predict.""" + return self.model_input_reshaper([trial.eeg for trial in sample_rows]) + + def set_reshaper(self, reshaper: Callable): + """Set the reshaper""" + self.model_input_reshaper = reshaper + + def __str__(self): + return f"<{self.__class__.__name__}>" diff --git a/bcipy/simulator/data/sampler/inquiry_range_sampler.py b/bcipy/simulator/data/sampler/inquiry_range_sampler.py new file mode 100644 index 000000000..11c6fb5ca --- /dev/null +++ b/bcipy/simulator/data/sampler/inquiry_range_sampler.py @@ -0,0 +1,30 @@ +import logging +from typing import List + +from bcipy.simulator.data.data_engine import QueryFilter, RawDataEngine +from bcipy.simulator.data.sampler.target_nontarget_sampler import \ + TargetNontargetSampler + +log = logging.getLogger(__name__) + + +class InquiryRangeSampler(TargetNontargetSampler): + """Sampler that queries a subset of inquiries for each series.""" + + def __init__(self, + data_engine: RawDataEngine, + inquiry_end: int = 3, + inquiry_start: int = 0): + super().__init__(data_engine) + assert inquiry_start >= 0, "Inquiry_start can't be negative" + assert inquiry_end >= inquiry_start, "Range definition error" + self.inquiry_start = inquiry_start + self.inquiry_end = inquiry_end + + def query_filters(self, symbol: str, is_target: bool) -> List[QueryFilter]: + """Expression used to query for a single sample.""" + return [ + QueryFilter('target', '==', int(is_target)), + QueryFilter('series_inquiry', '>=', self.inquiry_start), + QueryFilter('series_inquiry', '<=', self.inquiry_end) + ] diff --git a/bcipy/simulator/data/sampler.py b/bcipy/simulator/data/sampler/inquiry_sampler.py similarity index 57% rename from bcipy/simulator/data/sampler.py rename to bcipy/simulator/data/sampler/inquiry_sampler.py index 6856bb437..b9873dd32 100644 --- a/bcipy/simulator/data/sampler.py +++ b/bcipy/simulator/data/sampler/inquiry_sampler.py @@ -1,118 +1,19 @@ import logging import random -from abc import ABC, abstractmethod from collections import defaultdict from pathlib import Path -from typing import Callable, Dict, List, Tuple +from typing import Dict, List, Tuple -import numpy as np import pandas as pd -from bcipy.simulator.data.data_engine import QueryFilter, RawDataEngine +from bcipy.simulator.data.data_engine import RawDataEngine +from bcipy.simulator.data.sampler.base_sampler import Sampler, format_samples from bcipy.simulator.data.trial import Trial from bcipy.simulator.util.state import SimState log = logging.getLogger(__name__) -def default_reshaper(eeg_responses: List[np.ndarray]) -> np.ndarray: - """Default data reshaper. - - Returns - ------- - ndarray with shape (channel_n, trial_n, sample_n) - """ - - channels_eeg: List[List[np.ndarray]] = [ - [] for i in range(len(eeg_responses[0])) - ] - - for _i, trial_channels_eeg in enumerate(eeg_responses): - for c_i, channel_eeg in enumerate(trial_channels_eeg): - channels_eeg[c_i].append(channel_eeg) - - return np.array(channels_eeg) - - -class Sampler(ABC): - """Represents a strategy for sampling signal model data from a DataEngine - comprised of signal data from one or more data collection sessions.""" - - def __init__(self, data_engine: RawDataEngine): - self.data_engine: RawDataEngine = data_engine - self.model_input_reshaper: Callable = default_reshaper - - @abstractmethod - def sample(self, state: SimState) -> List[Trial]: - """ - Query the data engine for a list of trials corresponding to each - currently displayed symbol. - - Parameters - ---------- - state - specifies the target symbol and current inquiry (displayed symbols). - - Returns - ------- - a list of Trials with an item for each symbol in the current inquiry. - """ - raise NotImplementedError - - def sample_data(self, state: SimState) -> np.ndarray: - """ - Query for trials and reshape for signal model input according to the - provided reshaper. - - Return: - ndarray of shape (n_channel, n_trial, n_sample) - """ - trials = self.sample(state) - return self.reshaped(trials) - - def sample_with_context(self, - state: SimState) -> Tuple[np.ndarray, List[Trial]]: - """ - Returns - ------- - A tuple of the reshaped data (ndarray of shape (n_channel, n_trial, n_sample)) as - well as a list of Trial data (metadata and data not reshaped) for context. - """ - trials = self.sample(state) - data = self.reshaped(trials) - return data, trials - - def reshaped(self, sample_rows: List[Trial]) -> np.ndarray: - """Returns the queried trials reshaped into a format that a model can predict.""" - return self.model_input_reshaper([trial.eeg for trial in sample_rows]) - - def set_reshaper(self, reshaper: Callable): - """Set the reshaper""" - self.model_input_reshaper = reshaper - - def __str__(self): - return f"<{self.__class__.__name__}>" - - -class TargetNontargetSampler(Sampler): - """Sampler that that queries based on target/non-target label.""" - - def sample(self, state: SimState) -> List[Trial]: - sample_rows = [] - for symbol in state.display_alphabet: - filters = self.query_filters( - symbol, is_target=(symbol == state.target_symbol)) - filtered_data = self.data_engine.query(filters, samples=1) - sample_rows.append(filtered_data[0]) - - log.info(f"Samples:\n{format_samples(sample_rows)}") - return sample_rows - - def query_filters(self, symbol: str, is_target: bool) -> List[QueryFilter]: - """Expression used to query for a single sample.""" - # QueryFilter('symbol', '==', symbol) - return [QueryFilter('target', '==', int(is_target))] - - class InquirySampler(Sampler): """Samples an inquiry of data at a time.""" @@ -181,8 +82,8 @@ def sample(self, state: SimState) -> List[Trial]: inquiry_n = random.choice(source_inquiries[data_source]) # select all trials for the data_source and inquiry - inquiry_df = self.data.loc[(self.data['source'] == data_source) & - (self.data['inquiry_n'] == inquiry_n)] + inquiry_df = self.data.loc[(self.data['source'] == data_source) + & (self.data['inquiry_n'] == inquiry_n)] assert len(inquiry_df) == len( inquiry_letter_subset), f"Invalid data source {data_source}" @@ -224,8 +125,3 @@ def sample(self, state: SimState) -> List[Trial]: ] log.info(f"EEG Samples:\n{format_samples(rows)}") return rows - - -def format_samples(sample_rows: List[Trial]) -> str: - """Returns a tabular representation of the sample rows.""" - return '\n'.join([str(row) for row in sample_rows]) diff --git a/bcipy/simulator/data/sampler/target_nontarget_sampler.py b/bcipy/simulator/data/sampler/target_nontarget_sampler.py new file mode 100644 index 000000000..234b9ff8a --- /dev/null +++ b/bcipy/simulator/data/sampler/target_nontarget_sampler.py @@ -0,0 +1,29 @@ +import logging +from typing import List + +from bcipy.simulator.data.data_engine import QueryFilter +from bcipy.simulator.data.sampler.base_sampler import Sampler, format_samples +from bcipy.simulator.data.trial import Trial +from bcipy.simulator.util.state import SimState + +log = logging.getLogger(__name__) + + +class TargetNontargetSampler(Sampler): + """Sampler that that queries based on target/non-target label.""" + + def sample(self, state: SimState) -> List[Trial]: + sample_rows = [] + for symbol in state.display_alphabet: + filters = self.query_filters( + symbol, is_target=(symbol == state.target_symbol)) + filtered_data = self.data_engine.query(filters, samples=1) + sample_rows.append(filtered_data[0]) + + log.info(f"Samples:\n{format_samples(sample_rows)}") + return sample_rows + + def query_filters(self, symbol: str, is_target: bool) -> List[QueryFilter]: + """Expression used to query for a single sample.""" + # QueryFilter('symbol', '==', symbol) + return [QueryFilter('target', '==', int(is_target))] diff --git a/bcipy/simulator/task/task_runner.py b/bcipy/simulator/task/task_runner.py index 57bb1f009..a18592faf 100644 --- a/bcipy/simulator/task/task_runner.py +++ b/bcipy/simulator/task/task_runner.py @@ -6,9 +6,9 @@ from pathlib import Path import bcipy.simulator.util.metrics as metrics -# pylint: disable=unused-import +# pylint: disable=wildcard-import,unused-wildcard-import # flake8: noqa -from bcipy.simulator.data.sampler import Sampler, TargetNontargetSampler +from bcipy.simulator.data.sampler import * from bcipy.simulator.task.copy_phrase import SimulatorCopyPhraseTask from bcipy.simulator.task.task_factory import TaskFactory from bcipy.simulator.ui import cli, gui diff --git a/bcipy/simulator/tests/test_inquiry_range_sampler.py b/bcipy/simulator/tests/test_inquiry_range_sampler.py new file mode 100644 index 000000000..2ff45eb54 --- /dev/null +++ b/bcipy/simulator/tests/test_inquiry_range_sampler.py @@ -0,0 +1,42 @@ +"""Tests for inquiry range sampler""" +import unittest +from unittest.mock import Mock + +from bcipy.simulator.data.data_engine import QueryFilter +from bcipy.simulator.data.sampler.inquiry_range_sampler import \ + InquiryRangeSampler + + +class InquiryRangeSamplerTest(unittest.TestCase): + """Tests for InquiryRangeSampler""" + + def test_init(self): + """Test initialization""" + sampler = InquiryRangeSampler(data_engine=Mock(), inquiry_end=3) + + self.assertEqual(0, sampler.inquiry_start) + self.assertEqual(3, sampler.inquiry_end) + + self.assertRaises( + AssertionError, lambda: InquiryRangeSampler( + data_engine=Mock(), inquiry_end=3, inquiry_start=-1)) + + self.assertRaises( + AssertionError, lambda: InquiryRangeSampler( + data_engine=Mock(), inquiry_end=1, inquiry_start=2)) + + def test_query_filters(self): + """Test that queries are limited to the configured range""" + sampler = InquiryRangeSampler(data_engine=Mock(), inquiry_end=3) + + filters = sampler.query_filters(symbol='A', is_target=True) + + start_filter = QueryFilter('series_inquiry', '>=', 0) + end_filter = QueryFilter('series_inquiry', '<=', 3) + + self.assertTrue(start_filter in filters) + self.assertTrue(end_filter in filters) + + +if __name__ == '__main__': + unittest.main() From 0daa272ec50dfeec3829f5ad2bf793abe4b3b000 Mon Sep 17 00:00:00 2001 From: lawhead Date: Fri, 10 Jan 2025 16:24:59 -0800 Subject: [PATCH 37/76] Added sampler filtering for series start and end --- .../data/sampler/inquiry_range_sampler.py | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/bcipy/simulator/data/sampler/inquiry_range_sampler.py b/bcipy/simulator/data/sampler/inquiry_range_sampler.py index 11c6fb5ca..7d897154a 100644 --- a/bcipy/simulator/data/sampler/inquiry_range_sampler.py +++ b/bcipy/simulator/data/sampler/inquiry_range_sampler.py @@ -1,5 +1,5 @@ import logging -from typing import List +from typing import List, Optional from bcipy.simulator.data.data_engine import QueryFilter, RawDataEngine from bcipy.simulator.data.sampler.target_nontarget_sampler import \ @@ -14,17 +14,37 @@ class InquiryRangeSampler(TargetNontargetSampler): def __init__(self, data_engine: RawDataEngine, inquiry_end: int = 3, - inquiry_start: int = 0): + inquiry_start: int = 0, + series_start: int = 1, + series_end: Optional[int] = None): super().__init__(data_engine) assert inquiry_start >= 0, "Inquiry_start can't be negative" assert inquiry_end >= inquiry_start, "Range definition error" self.inquiry_start = inquiry_start self.inquiry_end = inquiry_end + self.series_start = series_start + self.series_end = series_end def query_filters(self, symbol: str, is_target: bool) -> List[QueryFilter]: """Expression used to query for a single sample.""" - return [ + filters = [ QueryFilter('target', '==', int(is_target)), QueryFilter('series_inquiry', '>=', self.inquiry_start), - QueryFilter('series_inquiry', '<=', self.inquiry_end) + QueryFilter('series_inquiry', '<=', self.inquiry_end), + QueryFilter('series', '>=', self.series_start) ] + if self.series_end: + filters.append(QueryFilter('series', '<=', self.series_end)) + return filters + + def __str__(self): + fields = [ + f"inquiry_start='{self.inquiry_start}'", + f"inquiry_end={self.inquiry_end}", + f"series_start={self.series_start}", + f"series_end={self.series_end}" + ] + return f"InquiryRangeSampler({', '.join(fields)})" + + def __repr__(self): + return str(self) From 4eab3e80299ecff9c9678adc9276be9633c4e330 Mon Sep 17 00:00:00 2001 From: lawhead Date: Fri, 10 Jan 2025 16:26:47 -0800 Subject: [PATCH 38/76] Added simulator command line support for providing parameters to the configured sampler --- bcipy/simulator/README.md | 10 ++++++++ bcipy/simulator/task/task_factory.py | 34 ++++++++++++++++------------ bcipy/simulator/task/task_runner.py | 14 +++++++++--- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/bcipy/simulator/README.md b/bcipy/simulator/README.md index 1fa097bb6..45d6ab95b 100644 --- a/bcipy/simulator/README.md +++ b/bcipy/simulator/README.md @@ -25,6 +25,8 @@ optional arguments: -n N Number of times to run the simulation -s SAMPLER, --sampler SAMPLER Sampling strategy + --sampler_args SAMPLER_ARGS + Sampler args structured as a JSON string. -o OUTPUT, --output OUTPUT Sim output path ``` @@ -44,6 +46,7 @@ For example, - `n`: Number of simulation runs - `o`: Output directory for all simulation artifacts. - `s`: Sampling strategy to use; by default the TargetNonTargetSampler is used. The value provided should be the class name of a Sampler. +- `sampler_args`: Arguments to pass in to the selected Sampler. Some samplers can be customized with further parameters. These should be structured as a JSON dictionary mapping keys to values. For example: `--sampler_args='{"inquiry_end": 4}'` #### Sim Output Details @@ -91,6 +94,13 @@ Parameters which define task stoppage criteria are important to ensure that the * `max_incorrect` - The maximum number of consecutive incorrect selections for copy/spelling tasks. The task will end if this number is reached. * `max_inq_per_series` - Specifies the maximum number of inquiries to present before making a decision in copy/spelling tasks +## GUI + +A simulation can be started using a graphical user interface. + +`$ bcipy-sim --gui` + +This provides a way to explore the file system when providing the parameters.json file, simulation model, and input data sources. After all required inputs have been provided the user can initiate the simulation from the GUI interface. The command line used to run the simulation are output to the console prior to the run making it easier to start subsequent simulations with the same set of arguments. ## Current Limitations diff --git a/bcipy/simulator/task/task_factory.py b/bcipy/simulator/task/task_factory.py index 827fe123e..c2aeef75b 100644 --- a/bcipy/simulator/task/task_factory.py +++ b/bcipy/simulator/task/task_factory.py @@ -1,10 +1,10 @@ """Classes and functions for building a simulation.""" import logging -from typing import Dict, List, Type +from typing import Any, Dict, List, Optional, Type +from bcipy.core.parameters import DEFAULT_PARAMETERS_PATH, Parameters from bcipy.helpers.language_model import init_language_model from bcipy.io.load import load_json_parameters, load_signal_model -from bcipy.core.parameters import DEFAULT_PARAMETERS_PATH, Parameters from bcipy.signal.model.base_model import SignalModel from bcipy.simulator.data.data_engine import RawDataEngine from bcipy.simulator.data.data_process import init_data_processor @@ -32,19 +32,20 @@ def update_latest_params(parameters: Parameters) -> None: class TaskFactory(): """Constructs the hierarchy of objects necessary for initializing a task.""" - def __init__( - self, - params_path: str, - source_dirs: List[str], - signal_model_paths: List[str], - sampling_strategy: Type[Sampler] = TargetNontargetSampler, - task: Type[SimulatorCopyPhraseTask] = SimulatorCopyPhraseTask): + def __init__(self, + params_path: str, + source_dirs: List[str], + signal_model_paths: List[str], + sampling_strategy: Type[Sampler] = TargetNontargetSampler, + task: Type[SimulatorCopyPhraseTask] = SimulatorCopyPhraseTask, + sampler_args: Optional[Dict[str, Any]] = None): self.params_path = params_path self.signal_model_paths = signal_model_paths self.source_dirs = source_dirs self.sampling_strategy = sampling_strategy + self.sampler_args = sampler_args if sampler_args else {} self.simulation_task = task logger.info("Loading parameters") @@ -56,14 +57,19 @@ def __init__( self.signal_models = [ load_signal_model(path) for path in signal_model_paths ] - logger.info(self.signal_models) logger.info("Initializing language model") self.language_model = init_language_model(self.parameters) - logger.info(self.language_model) - self.samplers = self.init_samplers(self.signal_models) - logger.info(self.samplers) + + def log_state(self): + """Log configured objects of interest. This should be done after the + sim directory has been created and TOP_LEVEL_LOGGER has been configured, + which may happen some time after object construction.""" + logger.info("Language model:") + logger.info(f"\t{repr(self.language_model)}") + logger.info("Models -> Samplers:") + logger.info(f"\t{self.samplers}") def init_samplers( self, @@ -81,7 +87,7 @@ def init_samplers( engine = RawDataEngine(list(map(str, self.source_dirs)), self.parameters, data_processor=processor) - sampler = self.sampling_strategy(engine) + sampler = self.sampling_strategy(engine, **self.sampler_args) samplers[model] = sampler return samplers diff --git a/bcipy/simulator/task/task_runner.py b/bcipy/simulator/task/task_runner.py index a18592faf..6da31d7c7 100644 --- a/bcipy/simulator/task/task_runner.py +++ b/bcipy/simulator/task/task_runner.py @@ -1,6 +1,6 @@ """Setup and run the task""" - import argparse +import json import logging import sys from pathlib import Path @@ -106,6 +106,11 @@ def main(): required=False, default='TargetNontargetSampler', help="Sampling strategy") + parser.add_argument("--sampler_args", + type=str, + required=False, + default="{}", + help="Sampler args structured as a JSON string.") parser.add_argument("-o", "--output", type=Path, @@ -126,12 +131,15 @@ def main(): task_factory = TaskFactory(params_path=sim_args['parameters'], source_dirs=sim_args['data_folder'], signal_model_paths=sim_args['model_path'], - sampling_strategy=classify(sim_args['sampler']), - task=SimulatorCopyPhraseTask) + sampling_strategy=classify( + sim_args['sampler']), + task=SimulatorCopyPhraseTask, + sampler_args=json.loads(sim_args['sampler_args'])) if task_factory: sim_dir = init_simulation_dir(save_location=outdir) logger.info(sim_dir) + task_factory.log_state() runner = TaskRunner(save_dir=sim_dir, task_factory=task_factory, From a60abf59167fc5f6f8b7e597e32bcff82740cdce Mon Sep 17 00:00:00 2001 From: lawhead Date: Fri, 10 Jan 2025 16:27:44 -0800 Subject: [PATCH 39/76] Added GUI utilities for dynamically adding inputs for sampler args --- bcipy/simulator/tests/test_gui_utils.py | 49 ++++++++++++++++ bcipy/simulator/ui/gui_utils.py | 78 +++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 bcipy/simulator/tests/test_gui_utils.py create mode 100644 bcipy/simulator/ui/gui_utils.py diff --git a/bcipy/simulator/tests/test_gui_utils.py b/bcipy/simulator/tests/test_gui_utils.py new file mode 100644 index 000000000..316c70bb5 --- /dev/null +++ b/bcipy/simulator/tests/test_gui_utils.py @@ -0,0 +1,49 @@ +"""Tests for GUI utils""" +import unittest +from typing import Optional + +from bcipy.simulator.data.sampler.base_sampler import Sampler +from bcipy.simulator.ui.gui_utils import InputField, get_inputs + + +class TestSampler(Sampler): + """Sampler used for testing""" + + def __init__(self, + data_engine, + a: int, + b: str, + c: int = 10, + d: Optional[str] = None): + super().__init__(data_engine) + self.a = a + self.b = b + self.c = c + self.d = d + + +class GuiUtilsTest(unittest.TestCase): + """Tests for GUI utility functions.""" + + def test_sampler_params(self): + """Test that introspecting a Sampler object produces the correct input + parameters.""" + + inputs = get_inputs(TestSampler) + + self.assertTrue( + InputField(name="a", input_type="int", value=None) in inputs, + "param a should be an int") + self.assertTrue( + InputField(name="b", input_type="str", value=None) in inputs, + "param b should be a str") + self.assertTrue( + InputField(name="c", input_type="int", value=10) in inputs, + "param c should be an int with value") + self.assertTrue( + InputField(name="d", input_type="str", value=None, required=False) + in inputs, "param d should be an optional str") + + +if __name__ == '__main__': + unittest.main() diff --git a/bcipy/simulator/ui/gui_utils.py b/bcipy/simulator/ui/gui_utils.py new file mode 100644 index 000000000..18782ffdf --- /dev/null +++ b/bcipy/simulator/ui/gui_utils.py @@ -0,0 +1,78 @@ +"""Helper functions for the GUI code""" +import inspect +from typing import (Any, List, NamedTuple, Optional, Tuple, Type, Union, + get_args, get_origin) + +from bcipy.simulator.data.sampler.base_sampler import Sampler + +SUPPORTED_INPUT_TYPES = ['int', 'str', 'float'] + + +class InputField(NamedTuple): + """Represents a GUI input parameter.""" + name: str + input_type: str + value: Optional[Any] = None + required: bool = True + + +def get_param_value(param: inspect.Parameter) -> Any: + """Default value of the argument.""" + is_empty = param.default is None or param.default == inspect.Parameter.empty + if is_empty: + return None + return param.default + + +def is_annotated(param: inspect.Parameter) -> bool: + """Test if an arg is annotated""" + return param.annotation != inspect.Parameter.empty + + +def get_input_type(param: inspect.Parameter) -> Tuple[str, bool]: + """For a given constructor parameter, determine the GUI input type. For + subscriptable types such as Optional, the first type is returned (except None). + + Returns a tuple of (type str, required) + """ + input_type = None + required = True + + # if this is a subscriptable type, such as Optional or Union, get the first sub-type. + origin = get_origin(param.annotation) + if origin: + # only check for Optional types. we don't currently provide GUI inputs for other + # subscriptable types such as Lists or Dicts. + if origin == Union: + sub_types = get_args(param.annotation) + if type(None) in sub_types: + required = False + input_type = next(sub_type for sub_type in sub_types + if not isinstance(sub_type, type(None))).__name__ + else: + input_type = param.annotation.__name__ + return input_type, required + + +def get_inputs(sampler_type: Type[Sampler]) -> List[InputField]: + """Given a sampler, determine the input parameters for GUI prompts. + Only outputs parameters with a type annotation where that type can be + input through a GUI (int, float, str). + """ + + # introspect the model arguments to determine what parameters to pass. + params = inspect.signature(sampler_type).parameters + + inputs = [] + for key in params.keys(): + param = params[key] + if is_annotated(param): + input_type, required = get_input_type(param) + # filter on types that we can input through the GUI + if input_type in SUPPORTED_INPUT_TYPES: + inputs.append( + InputField(name=param.name, + input_type=input_type, + value=get_param_value(param), + required=required)) + return inputs From 93c7f5a325e7065bf2aceba2fe0bb3872a0772aa Mon Sep 17 00:00:00 2001 From: lawhead Date: Fri, 17 Jan 2025 17:51:58 -0800 Subject: [PATCH 40/76] Added GUI support for selecting a sampler with arguments --- bcipy/simulator/tests/test_gui_utils.py | 17 ++- bcipy/simulator/tests/test_obj_args_widget.py | 97 +++++++++++++++ bcipy/simulator/ui/gui.py | 94 ++++++++++++-- bcipy/simulator/ui/gui_utils.py | 49 +++++++- bcipy/simulator/ui/obj_args_widget.py | 116 ++++++++++++++++++ 5 files changed, 354 insertions(+), 19 deletions(-) create mode 100644 bcipy/simulator/tests/test_obj_args_widget.py create mode 100644 bcipy/simulator/ui/obj_args_widget.py diff --git a/bcipy/simulator/tests/test_gui_utils.py b/bcipy/simulator/tests/test_gui_utils.py index 316c70bb5..469fe5b11 100644 --- a/bcipy/simulator/tests/test_gui_utils.py +++ b/bcipy/simulator/tests/test_gui_utils.py @@ -3,7 +3,9 @@ from typing import Optional from bcipy.simulator.data.sampler.base_sampler import Sampler -from bcipy.simulator.ui.gui_utils import InputField, get_inputs +from bcipy.simulator.data.sampler.inquiry_sampler import InquirySampler +from bcipy.simulator.ui.gui_utils import (InputField, get_inputs, + sampler_options) class TestSampler(Sampler): @@ -25,8 +27,8 @@ def __init__(self, class GuiUtilsTest(unittest.TestCase): """Tests for GUI utility functions.""" - def test_sampler_params(self): - """Test that introspecting a Sampler object produces the correct input + def test_input_params(self): + """Test that introspecting an object produces the correct input parameters.""" inputs = get_inputs(TestSampler) @@ -44,6 +46,15 @@ def test_sampler_params(self): InputField(name="d", input_type="str", value=None, required=False) in inputs, "param d should be an optional str") + def test_sampler_options(self): + """Test sampler options puts the default value first""" + opts = sampler_options() + self.assertEqual("TargetNontargetSampler", list(opts.keys())[0]) + + opts = sampler_options(default=InquirySampler) + self.assertEqual("InquirySampler", list(opts.keys())[0]) + self.assertTrue("TargetNontargetSampler" in opts.keys()) + if __name__ == '__main__': unittest.main() diff --git a/bcipy/simulator/tests/test_obj_args_widget.py b/bcipy/simulator/tests/test_obj_args_widget.py new file mode 100644 index 000000000..197362cf1 --- /dev/null +++ b/bcipy/simulator/tests/test_obj_args_widget.py @@ -0,0 +1,97 @@ +"""Test functionality of UI component""" + +import sys +import unittest +from typing import Optional + +# pylint: disable=E0611 +from PyQt6.QtWidgets import QApplication + +from bcipy.simulator.ui.obj_args_widget import ObjectArgInputs + +app = QApplication(sys.argv) + + +class NoArgs: + """Object without args""" + + +class ArgsObj: + """Object with args used for testing""" + + def __init__(self, a: int, b: str, c: int = 10, d: Optional[str] = None): + self.a = a + self.b = b + self.c = c + self.d = d + + +class ObjectArgsWidgetTest(unittest.TestCase): + """Tests for component.""" + + def test_no_args(self): + """Test object with no args""" + widget = ObjectArgInputs(object_type=NoArgs) + self.assertEqual(0, len(widget.controls)) + + def test_with_args(self): + """Test object with args""" + widget = ObjectArgInputs(object_type=ArgsObj) + self.assertEqual(4, len(widget.controls)) + + labels = [control[0] for control in widget.controls] + self.assertTrue('a' in labels) + self.assertTrue('b' in labels) + self.assertTrue('c' in labels) + self.assertTrue('d' in labels) + + def test_set_obj(self): + """Test that the widget updates""" + widget = ObjectArgInputs(object_type=NoArgs) + widget.set_object_type(ArgsObj) + self.assertEqual(4, len(widget.controls)) + + def test_value_no_args(self): + """Test getting value with no args""" + widget = ObjectArgInputs(object_type=NoArgs) + self.assertEqual('{}', widget.value()) + + def test_value_with_args(self): + """Test get value with default args""" + widget = ObjectArgInputs(object_type=ArgsObj) + self.assertEqual('{"a": null, "b": null, "c": 10, "d": null}', + widget.value()) + + def test_value_set_args(self): + """Test get value after updating inputs""" + widget = ObjectArgInputs(object_type=ArgsObj) + b_control = None + for label, control in widget.controls: + if label == "b": + b_control = control + break + self.assertIsNotNone(b_control) + b_control.setText("hello") + + self.assertEqual('{"a": null, "b": "hello", "c": 10, "d": null}', + widget.value()) + + def test_required_inputs(self): + """Test check for required inputs""" + widget = ObjectArgInputs(object_type=ArgsObj) + [a, b, _c, _d] = [control for _name, control in widget.controls] + + self.assertFalse(widget.required_inputs_provided()) + + a.setText("10") + b.setText("hi") + self.assertTrue(widget.required_inputs_provided()) + + def test_required_inputs_no_args(self): + """Test check for required inputs with no args""" + widget = ObjectArgInputs(object_type=NoArgs) + self.assertTrue(widget.required_inputs_provided()) + + +if __name__ == '__main__': + unittest.main() diff --git a/bcipy/simulator/ui/gui.py b/bcipy/simulator/ui/gui.py index 2d9056a15..887515b5c 100644 --- a/bcipy/simulator/ui/gui.py +++ b/bcipy/simulator/ui/gui.py @@ -3,6 +3,7 @@ # pylint: disable=E0611 import argparse import fnmatch +import json import os import subprocess import sys @@ -11,20 +12,22 @@ from PyQt6.QtCore import Qt from PyQt6.QtGui import QFont -from PyQt6.QtWidgets import (QApplication, QCheckBox, QFormLayout, QGroupBox, - QHBoxLayout, QLabel, QLineEdit, QPushButton, - QSpinBox, QTreeWidget, QTreeWidgetItem, - QVBoxLayout, QWidget) +from PyQt6.QtWidgets import (QApplication, QCheckBox, QComboBox, QFormLayout, + QGroupBox, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QSpinBox, QTreeWidget, + QTreeWidgetItem, QVBoxLayout, QWidget) +from bcipy.core.parameters import Parameters from bcipy.gui.file_dialog import FileDialog from bcipy.gui.main import static_text_control from bcipy.helpers.acquisition import active_content_types -from bcipy.core.parameters import Parameters from bcipy.preferences import preferences from bcipy.simulator.data.sampler import TargetNontargetSampler from bcipy.simulator.task.copy_phrase import SimulatorCopyPhraseTask from bcipy.simulator.task.task_factory import TaskFactory from bcipy.simulator.ui.cli import excluded +from bcipy.simulator.ui.gui_utils import sampler_options +from bcipy.simulator.ui.obj_args_widget import ObjectArgInputs from bcipy.simulator.util.artifact import DEFAULT_SAVE_LOCATION @@ -420,6 +423,35 @@ def change(self): self.change_event() +class SelectionList(QWidget): + """Widget for selecting a sampler.""" + + def __init__(self, + parent: Optional[QWidget] = None, + change_event: Optional[Callable] = None, + items: List[str] = None): + super().__init__(parent=parent) + self.change_event = change_event + self.layout = QFormLayout() + self.control = QComboBox() + self.control.addItems(items) + self.control.currentTextChanged.connect(self.change) + + hbox = QHBoxLayout() + hbox.setContentsMargins(0, 0, 0, 0) + hbox.addWidget(self.control) + self.setLayout(hbox) + + def value(self) -> str: + """Selected value""" + return self.control.currentText() + + def change(self): + """Called when a model path changes""" + if self.change_event: + self.change_event() + + class LabeledWidget(QWidget): """Renders a widget with a label above it.""" @@ -473,6 +505,10 @@ def __init__(self, self.model_paths = [] self.data_paths = [] + self.sampler = TargetNontargetSampler + self.sampler_options = sampler_options(default=self.sampler) + self.sampler_args = '{}' + self.runs_control = sim_runs_control() self.output_control = ChooseDirectoryInput( value=DEFAULT_SAVE_LOCATION, change_event=self.update_data_paths) @@ -482,6 +518,12 @@ def __init__(self, self.directory_control = DataDirectorySelect( change_event=self.update_data_paths) self.model_input_control = ModelInputs(change_event=self.update_models) + self.sampler_input_control = SelectionList( + change_event=self.update_sampler, + items=self.sampler_options.keys()) + self.sampler_args_control = ObjectArgInputs( + change_event=self.update_sampler_args, + object_type=self.sampler) form = QFormLayout() form.setFormAlignment(Qt.AlignmentFlag.AlignLeft @@ -496,10 +538,21 @@ def __init__(self, vbox.addSpacing(2) vbox.addWidget(self.create_model_group()) vbox.addSpacing(2) + vbox.addWidget(self.create_sampler_group()) + vbox.addSpacing(2) vbox.addWidget(self.create_data_group()) self.setLayout(vbox) self.show() + def create_sampler_group(self) -> QWidget: + """Create a group box for model inputs""" + group = QGroupBox("Sampler") + vbox = QVBoxLayout() + vbox.addWidget(self.sampler_input_control) + vbox.addWidget(self.sampler_args_control) + group.setLayout(vbox) + return group + def create_data_group(self) -> QWidget: """Create a group box for data inputs.""" group_box = QGroupBox("Data") @@ -527,7 +580,7 @@ def outdir(self) -> str: def command_valid(self) -> bool: """Returns True if all necessary fields are input.""" return bool(self.parameters_path and self.model_paths - and self.data_paths) + and self.data_paths and self.sampler and self.sampler_args) def command(self) -> str: """Command equivalent to to the result of the interactive selection of @@ -543,6 +596,8 @@ def command(self) -> str: args.extend([f"-d '{source}'" for source in self.data_paths]) args.append(f"-n {self.runs()}") + args.append(f"-s {self.sampler.__name__}") + args.append(f"--sampler_args='{self.sampler_args}'") return f"bcipy-sim {' '.join(args)}" @@ -567,6 +622,22 @@ def update_data_paths(self) -> None: self.data_paths = self.directory_control.data_directories() self.change() + def update_sampler(self) -> None: + """Called when the sampler is updated. Updates the sampler args input""" + selected_sampler = self.sampler_options.get( + self.sampler_input_control.value(), None) + self.sampler = selected_sampler + self.sampler_args_control.set_object_type(selected_sampler) + self.change() + + def update_sampler_args(self) -> None: + """Update the sampler args""" + if self.sampler_args_control.required_inputs_provided(): + self.sampler_args = self.sampler_args_control.value() + else: + self.sampler_args = '' + self.change() + def change(self): """Announce change to any registered change events.""" if self.change_event: @@ -665,7 +736,7 @@ def on_params_change(self) -> None: self.run_button.setEnabled(False) -def init(title='BCI Simulator', size=(750, 600)) -> str: +def init(title='BCI Simulator', size=(900, 600)) -> str: """Set up the GUI components and start the main loop.""" app = QApplication(sys.argv) panel = MainPanel(title, size) @@ -680,7 +751,7 @@ def init(title='BCI Simulator', size=(750, 600)) -> str: def configure( title='BCI Simulator', - size=(600, 750) + size=(600, 900) ) -> Tuple[int, str, Optional[TaskFactory]]: """Main function""" app = QApplication(sys.argv) @@ -693,6 +764,8 @@ def configure( params = panel.form.parameters_path data_paths = panel.form.data_paths model_paths = panel.form.model_paths + sampler = panel.form.sampler + sampler_args = panel.form.sampler_args app.quit() @@ -702,7 +775,8 @@ def configure( factory = TaskFactory(params_path=params, source_dirs=data_paths, signal_model_paths=model_paths, - sampling_strategy=TargetNontargetSampler, + sampling_strategy=sampler, + sampler_args=json.loads(sampler_args), task=SimulatorCopyPhraseTask) return (runs, outdir, factory) @@ -712,7 +786,7 @@ def run(): parser = argparse.ArgumentParser() parser.add_argument('--width', default=600, type=int) - parser.add_argument('--height', default=750, type=int) + parser.add_argument('--height', default=900, type=int) args = parser.parse_args() init(size=(args.width, args.height)) diff --git a/bcipy/simulator/ui/gui_utils.py b/bcipy/simulator/ui/gui_utils.py index 18782ffdf..a90cf906c 100644 --- a/bcipy/simulator/ui/gui_utils.py +++ b/bcipy/simulator/ui/gui_utils.py @@ -1,10 +1,14 @@ """Helper functions for the GUI code""" import inspect -from typing import (Any, List, NamedTuple, Optional, Tuple, Type, Union, +from typing import (Any, Dict, List, NamedTuple, Optional, Tuple, Type, Union, get_args, get_origin) -from bcipy.simulator.data.sampler.base_sampler import Sampler +# pylint: disable=wildcard-import,unused-wildcard-import +# flake8: noqa: F403 +from bcipy.simulator.data.sampler import * +from bcipy.simulator.data.sampler import Sampler, TargetNontargetSampler +# This can be expanded as needed (ex. Path support). SUPPORTED_INPUT_TYPES = ['int', 'str', 'float'] @@ -54,14 +58,14 @@ def get_input_type(param: inspect.Parameter) -> Tuple[str, bool]: return input_type, required -def get_inputs(sampler_type: Type[Sampler]) -> List[InputField]: - """Given a sampler, determine the input parameters for GUI prompts. - Only outputs parameters with a type annotation where that type can be +def get_inputs(input_class: Type[Any]) -> List[InputField]: + """Given the class, determine the input parameters for GUI prompts. + Only outputs parameters with a type annotation for basic types that can be input through a GUI (int, float, str). """ # introspect the model arguments to determine what parameters to pass. - params = inspect.signature(sampler_type).parameters + params = inspect.signature(input_class).parameters inputs = [] for key in params.keys(): @@ -76,3 +80,36 @@ def get_inputs(sampler_type: Type[Sampler]) -> List[InputField]: value=get_param_value(param), required=required)) return inputs + + +def all_subclasses(parent_cls: Type[Any]): + """Get all subclasses of the given class. Note that a class can only see + a subclass if the module with the sub has been loaded. + See: https://stackoverflow.com/questions/3862310/how-to-find-all-the-subclasses-of-a-class-given-its-name""" + return set(parent_cls.__subclasses__()).union([ + sub for cls in parent_cls.__subclasses__() + for sub in all_subclasses(cls) + ]) + + +def sampler_options( + default: Type[Sampler] = TargetNontargetSampler +) -> Dict[str, Type[Sampler]]: + """Returns available samplers as a name -> class dict. + Orders with the default item first, then sorted alphabetically.""" + + subclasses = all_subclasses(Sampler) + if default in subclasses: + subclasses.remove(default) + subclasses = [ + default, *sorted(subclasses, key=lambda cls: cls.__name__) + ] + else: + subclasses = sorted(subclasses, key=lambda cls: cls.__name__) + return {cls.__name__: cls for cls in subclasses} + + +def sampler_inputs(sampler_class: Type[Sampler]) -> List[InputField]: + """Returns the list of inputs needed to initialize instances of the given + sampler class.""" + return get_inputs(sampler_class) diff --git a/bcipy/simulator/ui/obj_args_widget.py b/bcipy/simulator/ui/obj_args_widget.py new file mode 100644 index 000000000..9f6c1cfd4 --- /dev/null +++ b/bcipy/simulator/ui/obj_args_widget.py @@ -0,0 +1,116 @@ +from typing import Any, Callable, List, Optional, Tuple, Type + +# pylint: disable=E0611 +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QFormLayout, QLineEdit, QWidget + +from bcipy.core.list import find_index +from bcipy.gui.parameters.params_form import clear_layout +from bcipy.simulator.ui.gui_utils import InputField, get_inputs + + +class ObjectArgInputs(QWidget): + """Widget with inputs for parameters needed to instantiate an object for a + given class.""" + + def __init__(self, + parent: Optional[QWidget] = None, + change_event: Optional[Callable] = None, + object_type: Optional[Type[Any]] = None): + super().__init__(parent=parent) + self.change_event = change_event + self.form_layout = QFormLayout() + self.form_layout.setFormAlignment(Qt.AlignmentFlag.AlignLeft + | Qt.AlignmentFlag.AlignVCenter) + self.form_layout.setFieldGrowthPolicy( + QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) + + self.object_type = object_type + self.input_fields = self._init_input_fields() + self.controls = self._create_inputs() + self._add_controls() + self.setLayout(self.form_layout) + + def set_object_type(self, object_type: Type[Any]) -> None: + """Set the class for which we should provide inputs.""" + self.object_type = object_type + self.input_fields = self._init_input_fields() + self.controls = self._create_inputs() + self._add_controls() + + def required_inputs_provided(self) -> bool: + """Check if required inputs have been filled out.""" + for label, control in self.controls: + field = self._field_definition(label) + if field.required and not self._input_value(field, control): + return False + return True + + def value(self) -> str: + """Returns a json string representing a dict of name: value for each input.""" + vals = ", ".join([ + self._json_name_value(name, control) + for (name, control) in self.controls + ]) + return f"{{{vals}}}" + + def _init_input_fields(self) -> List[InputField]: + """Determine the input fields for the configured object type.""" + if self.object_type: + return get_inputs(self.object_type) + return [] + + def _field_definition(self, name: str) -> InputField: + """Get the InputField for the control with the given name.""" + index = find_index(self.input_fields, name, key=lambda item: item.name) + if index is None: + raise KeyError(f"Not found: {name}") + return self.input_fields[index] + + def _json_name_value(self, name: str, control: QWidget) -> str: + """Returns a json partial for a name: value, quoting the value according + to the input_type""" + field = self._field_definition(name) + value = self._input_value(field, control) + + if not value: + return f'"{name}": null' + if field.input_type == 'str': + value = f'"{value}"' + + return f'"{name}": {value}' + + def _create_inputs(self) -> List[Tuple[str, QWidget]]: + """Create inputs for the inputs for associated args.""" + return [(input_field.name, self._create_input(input_field)) + for input_field in self.input_fields] + + def _create_input(self, input_field: InputField) -> QWidget: + """Create an input for the given InputField""" + # currently only supports int, str, and float. Text inputs are used + # since the spinbox inputs do not allow a None value and default to + # 0, which is not always correct. + input_map = {'str': QLineEdit, 'int': QLineEdit, 'float': QLineEdit} + + make_control = input_map[input_field.input_type] + control = make_control() + if input_field.value is not None: + control.setText(str(input_field.value)) + control.textChanged.connect(self.change) + return control + + def _input_value(self, field: InputField, control: QWidget) -> Any: + """Get the cast value for the provided input.""" + # TODO: cast this according to its type + return control.text() + + def _add_controls(self) -> None: + """Add each input to the layout.""" + clear_layout(self.form_layout) + for (label, control) in self.controls: + self.form_layout.addRow(label, control) + + def change(self): + """Called when the sampler or any inputs change.""" + if self.change_event: + self.change_event() From 51c9d210c7db8058cc0d2f0ee2a104cca2958455 Mon Sep 17 00:00:00 2001 From: lawhead Date: Thu, 30 Jan 2025 10:49:53 -0800 Subject: [PATCH 41/76] Refactored args for InquiryRangeSampler for better understandability --- .../data/sampler/inquiry_range_sampler.py | 46 ++++++++++------ .../tests/test_inquiry_range_sampler.py | 54 +++++++++++++++---- 2 files changed, 76 insertions(+), 24 deletions(-) diff --git a/bcipy/simulator/data/sampler/inquiry_range_sampler.py b/bcipy/simulator/data/sampler/inquiry_range_sampler.py index 7d897154a..c9ca0502d 100644 --- a/bcipy/simulator/data/sampler/inquiry_range_sampler.py +++ b/bcipy/simulator/data/sampler/inquiry_range_sampler.py @@ -9,40 +9,56 @@ class InquiryRangeSampler(TargetNontargetSampler): - """Sampler that queries a subset of inquiries for each series.""" + """Sampler that queries a subset of inquiries for each series. + + Parameters + ---------- + data_engine - data repository to be queried + inquiry_start - index of first inquiry to include (starting at 1) + inquiry_count - number of inquiries to include + series_start - index of first series to include (starting at 1) + series_end - optional index of last series to include + """ def __init__(self, data_engine: RawDataEngine, - inquiry_end: int = 3, - inquiry_start: int = 0, + inquiry_start: int = 1, + inquiry_count: int = 3, series_start: int = 1, - series_end: Optional[int] = None): + series_count: Optional[int] = None): super().__init__(data_engine) - assert inquiry_start >= 0, "Inquiry_start can't be negative" - assert inquiry_end >= inquiry_start, "Range definition error" + if inquiry_start < 1: + raise ValueError("inquiry_start can't be less than 1") + if inquiry_count < 1: + raise ValueError("inquiry_count must be at least 1") + self.inquiry_start = inquiry_start - self.inquiry_end = inquiry_end + self.inquiry_count = inquiry_count self.series_start = series_start - self.series_end = series_end + self.series_count = series_count def query_filters(self, symbol: str, is_target: bool) -> List[QueryFilter]: """Expression used to query for a single sample.""" + # In the data, inquiries are 0-indexed. + inq_start = self.inquiry_start - 1 + inq_end = inq_start + self.inquiry_count filters = [ QueryFilter('target', '==', int(is_target)), - QueryFilter('series_inquiry', '>=', self.inquiry_start), - QueryFilter('series_inquiry', '<=', self.inquiry_end), + QueryFilter('series_inquiry', '>=', inq_start), + QueryFilter('series_inquiry', '<', inq_end), QueryFilter('series', '>=', self.series_start) ] - if self.series_end: - filters.append(QueryFilter('series', '<=', self.series_end)) + if self.series_count: + series_end = self.series_start + self.series_count + filters.append(QueryFilter('series', '<', series_end)) return filters def __str__(self): fields = [ - f"inquiry_start='{self.inquiry_start}'", - f"inquiry_end={self.inquiry_end}", + f"inquiry_start={self.inquiry_start}", + f"inquiry_count={self.inquiry_count}", f"series_start={self.series_start}", - f"series_end={self.series_end}" + f"series_count={self.series_count}" ] return f"InquiryRangeSampler({', '.join(fields)})" diff --git a/bcipy/simulator/tests/test_inquiry_range_sampler.py b/bcipy/simulator/tests/test_inquiry_range_sampler.py index 2ff45eb54..2fc78a907 100644 --- a/bcipy/simulator/tests/test_inquiry_range_sampler.py +++ b/bcipy/simulator/tests/test_inquiry_range_sampler.py @@ -12,27 +12,63 @@ class InquiryRangeSamplerTest(unittest.TestCase): def test_init(self): """Test initialization""" - sampler = InquiryRangeSampler(data_engine=Mock(), inquiry_end=3) + sampler = InquiryRangeSampler(data_engine=Mock(), inquiry_count=3) - self.assertEqual(0, sampler.inquiry_start) - self.assertEqual(3, sampler.inquiry_end) + self.assertEqual(1, sampler.inquiry_start) + self.assertEqual(3, sampler.inquiry_count) self.assertRaises( - AssertionError, lambda: InquiryRangeSampler( - data_engine=Mock(), inquiry_end=3, inquiry_start=-1)) + ValueError, lambda: InquiryRangeSampler( + data_engine=Mock(), inquiry_count=3, inquiry_start=-1)) self.assertRaises( - AssertionError, lambda: InquiryRangeSampler( - data_engine=Mock(), inquiry_end=1, inquiry_start=2)) + ValueError, lambda: InquiryRangeSampler( + data_engine=Mock(), inquiry_count=0, inquiry_start=2)) def test_query_filters(self): """Test that queries are limited to the configured range""" - sampler = InquiryRangeSampler(data_engine=Mock(), inquiry_end=3) + sampler = InquiryRangeSampler(data_engine=Mock(), inquiry_count=3) filters = sampler.query_filters(symbol='A', is_target=True) + target_filter = QueryFilter('target', '==', 1) + nontarget_filter = QueryFilter('target', '==', 0) start_filter = QueryFilter('series_inquiry', '>=', 0) - end_filter = QueryFilter('series_inquiry', '<=', 3) + end_filter = QueryFilter('series_inquiry', '<', 3) + series_start_filter = QueryFilter('series', '>=', 1) + + self.assertEqual(4, len(filters)) + self.assertTrue(target_filter in filters) + self.assertFalse(nontarget_filter in filters) + self.assertTrue(start_filter in filters) + self.assertTrue(end_filter in filters) + self.assertTrue(series_start_filter in filters) + + filters = sampler.query_filters(symbol='A', is_target=False) + self.assertFalse(target_filter in filters) + self.assertTrue(nontarget_filter in filters) + + def test_filters_with_series_count(self): + """Test when a series count is provided.""" + sampler = InquiryRangeSampler(data_engine=Mock(), + inquiry_count=3, + series_start=2, + series_count=2) + series_start_filter = QueryFilter('series', '>=', 2) + series_end_filter = QueryFilter('series', '<', 4) + filters = sampler.query_filters(symbol='A', is_target=True) + self.assertEqual(5, len(filters)) + self.assertTrue(series_start_filter in filters) + self.assertTrue(series_end_filter in filters) + + def test_filter_with_nondefault_start(self): + """Test with non-defaults""" + sampler = InquiryRangeSampler(data_engine=Mock(), + inquiry_start=5, + inquiry_count=1) + start_filter = QueryFilter('series_inquiry', '>=', 4) + end_filter = QueryFilter('series_inquiry', '<', 5) + filters = sampler.query_filters(symbol='A', is_target=True) self.assertTrue(start_filter in filters) self.assertTrue(end_filter in filters) From 813ef2757e7a506a2cb8631321b0c0368d9ba425 Mon Sep 17 00:00:00 2001 From: lawhead Date: Thu, 30 Jan 2025 10:50:34 -0800 Subject: [PATCH 42/76] Refactored task_runner to make it more testable --- bcipy/simulator/task/task_runner.py | 26 +++++++++++++++++------ bcipy/simulator/tests/test_task_runner.py | 19 +++++++++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 bcipy/simulator/tests/test_task_runner.py diff --git a/bcipy/simulator/task/task_runner.py b/bcipy/simulator/task/task_runner.py index 6da31d7c7..8f9be2b60 100644 --- a/bcipy/simulator/task/task_runner.py +++ b/bcipy/simulator/task/task_runner.py @@ -4,6 +4,7 @@ import logging import sys from pathlib import Path +from typing import Any, Dict import bcipy.simulator.util.metrics as metrics # pylint: disable=wildcard-import,unused-wildcard-import @@ -26,6 +27,17 @@ def classify(classname): return getattr(sys.modules[__name__], classname) +def parse_args(args: str) -> Dict[str, Any]: + """Converts sampler command line args to a dictionary of parameters to be + passed to the constructor. + + Parameters + ---------- + args - str formatted as a valid JSON object. + """ + return json.loads(args) + + class TaskRunner(): """Responsible for executing a task a given number of times.""" @@ -128,13 +140,13 @@ def main(): elif args.interactive: task_factory = cli.main(sim_args) else: - task_factory = TaskFactory(params_path=sim_args['parameters'], - source_dirs=sim_args['data_folder'], - signal_model_paths=sim_args['model_path'], - sampling_strategy=classify( - sim_args['sampler']), - task=SimulatorCopyPhraseTask, - sampler_args=json.loads(sim_args['sampler_args'])) + task_factory = TaskFactory( + params_path=sim_args['parameters'], + source_dirs=sim_args['data_folder'], + signal_model_paths=sim_args['model_path'], + sampling_strategy=classify(sim_args['sampler']), + task=SimulatorCopyPhraseTask, + sampler_args=parse_args(sim_args['sampler_args'])) if task_factory: sim_dir = init_simulation_dir(save_location=outdir) diff --git a/bcipy/simulator/tests/test_task_runner.py b/bcipy/simulator/tests/test_task_runner.py new file mode 100644 index 000000000..cf27b2e63 --- /dev/null +++ b/bcipy/simulator/tests/test_task_runner.py @@ -0,0 +1,19 @@ +import unittest + +from bcipy.simulator.task.task_runner import (TargetNontargetSampler, classify, + parse_args) + + +class TaskRunnerTest(unittest.TestCase): + """Unit tests for task_runner module.""" + + def test_classify(self): + """Test classifying a string""" + self.assertEqual(TargetNontargetSampler, + classify("TargetNontargetSampler")) + + def test_parse_args(self): + """Test parsing sampler args""" + expected = dict(a=1, b=2, c="hello") + self.assertEqual(expected, + parse_args('{"a": 1, "b": 2.0, "c": "hello"}')) From 247fc0efbf42460bb9fcdc243e7f84424cf50155 Mon Sep 17 00:00:00 2001 From: lawhead Date: Thu, 30 Jan 2025 10:51:23 -0800 Subject: [PATCH 43/76] Fix mypy issues --- bcipy/simulator/ui/gui.py | 95 +++++++++++++++------------ bcipy/simulator/ui/gui_utils.py | 2 +- bcipy/simulator/ui/obj_args_widget.py | 2 +- 3 files changed, 54 insertions(+), 45 deletions(-) diff --git a/bcipy/simulator/ui/gui.py b/bcipy/simulator/ui/gui.py index 887515b5c..aaa954926 100644 --- a/bcipy/simulator/ui/gui.py +++ b/bcipy/simulator/ui/gui.py @@ -23,6 +23,7 @@ from bcipy.helpers.acquisition import active_content_types from bcipy.preferences import preferences from bcipy.simulator.data.sampler import TargetNontargetSampler +from bcipy.simulator.data.sampler.base_sampler import Sampler from bcipy.simulator.task.copy_phrase import SimulatorCopyPhraseTask from bcipy.simulator.task.task_factory import TaskFactory from bcipy.simulator.ui.cli import excluded @@ -72,29 +73,29 @@ class DirectoryTree(QWidget): def __init__(self, parent: Optional[QWidget] = None, parent_directory: Optional[str] = None, - selected_subdirectories: Optional[List[str]] = None): + selected_subdirectories: Optional[List[Path]] = None): super().__init__(parent=parent) self.parent_directory = parent_directory self.paths = selected_subdirectories or [] self.label = "Selected Directories" self.tree = self.make_tree() - self.layout = QVBoxLayout() - self.layout.addWidget( + self.box_layout = QVBoxLayout() + self.box_layout.addWidget( LabeledWidget(self.label, self.tree, label_size=12)) - self.layout.addWidget(self.tree) - self.setLayout(self.layout) + self.box_layout.addWidget(self.tree) + self.setLayout(self.box_layout) - def update(self, parent_directory: str, - selected_subdirectories: List[str]) -> None: + def update_tree(self, parent_directory: Optional[str], + selected_subdirectories: Optional[List[Path]]) -> None: """Update the widget.""" self.parent_directory = parent_directory self.paths = selected_subdirectories or [] - clear_layout(self.layout) + clear_layout(self.box_layout) if self.parent_directory: self.tree = self.make_tree() - self.layout.addWidget( + self.box_layout.addWidget( LabeledWidget(self.label, self.tree, label_size=12)) def make_tree(self) -> QTreeWidget: @@ -223,7 +224,7 @@ def __init__(self, **kwargs): super().__init__(parent=parent, **kwargs) self.change_event = change_event - self.layout = QVBoxLayout() + self.box_layout = QVBoxLayout() self.nested_filter_control = QCheckBox("Include nested directories") self.nested_filter_control.setChecked(True) @@ -232,14 +233,14 @@ def __init__(self, # self.name_filter_control_label = QLabel("Name contains") self.name_filter_control = QLineEdit("") self.name_filter_control.textChanged.connect(self.change) - self.layout.addWidget(self.nested_filter_control) + self.box_layout.addWidget(self.nested_filter_control) form = QFormLayout() form.setFormAlignment(Qt.AlignmentFlag.AlignLeft) form.addRow("Name contains", self.name_filter_control) - self.layout.addLayout(form) - self.setLayout(self.layout) + self.box_layout.addLayout(form) + self.setLayout(self.box_layout) def change(self): """Called when a filter field is modified.""" @@ -269,8 +270,12 @@ def __init__(self, change_event=self.update_directory_tree) self.directory_tree = DirectoryTree() - self.parent_directory_control.layout().setContentsMargins(0, 0, 0, 0) - self.directory_filter_control.layout.setContentsMargins(0, 0, 0, 0) + parent_layout = self.parent_directory_control.layout() + filter_layout = self.directory_filter_control.layout() + if parent_layout: + parent_layout.setContentsMargins(0, 0, 0, 0) + if filter_layout: + filter_layout.setContentsMargins(0, 0, 0, 0) self.directory_filter_control.setEnabled(self.filters_enabled) self.directory_tree.setEnabled(self.filters_enabled) @@ -290,6 +295,7 @@ def parent_directory(self) -> Optional[str]: parent = self.parent_directory_control.value() if parent: return parent.strip() + return None def match_pattern(self) -> str: """Pattern used to match a directory with fnmatch.""" @@ -332,8 +338,8 @@ def update_directory_tree(self): self.filters_enabled = True self.directory_filter_control.setEnabled(True) self.directory_tree.setEnabled(True) - self.directory_tree.update(self.parent_directory(), - self.data_directories()) + self.directory_tree.update_tree(self.parent_directory(), + self.data_directories()) else: self.filters_enabled = False self.directory_filter_control.setEnabled(False) @@ -368,15 +374,15 @@ def __init__(self, change_event: Optional[Callable] = None): super().__init__(parent=parent) self.change_event = change_event - self.layout = QFormLayout() - self.layout.setFormAlignment(Qt.AlignmentFlag.AlignLeft - | Qt.AlignmentFlag.AlignVCenter) - self.layout.setFieldGrowthPolicy( + self.form_layout = QFormLayout() + self.form_layout.setFormAlignment(Qt.AlignmentFlag.AlignLeft + | Qt.AlignmentFlag.AlignVCenter) + self.form_layout.setFieldGrowthPolicy( QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow) self.content_types = content_types or ['EEG'] self.controls = self._create_inputs() self._add_controls() - self.setLayout(self.layout) + self.setLayout(self.form_layout) def value(self) -> List[str]: """Model paths""" @@ -413,9 +419,9 @@ def _label(self, content_type: str) -> str: def _add_controls(self) -> None: """Add each model input to the layout.""" - clear_layout(self.layout) + clear_layout(self.form_layout) for (label, control) in self.controls: - self.layout.addRow(label, control) + self.form_layout.addRow(label, control) def change(self): """Called when a model path changes""" @@ -429,12 +435,13 @@ class SelectionList(QWidget): def __init__(self, parent: Optional[QWidget] = None, change_event: Optional[Callable] = None, - items: List[str] = None): + items: Optional[List[str]] = None): super().__init__(parent=parent) self.change_event = change_event - self.layout = QFormLayout() + self.form_layout = QFormLayout() self.control = QComboBox() - self.control.addItems(items) + if items: + self.control.addItems(items) self.control.currentTextChanged.connect(self.change) hbox = QHBoxLayout() @@ -478,7 +485,8 @@ def sim_runs_control(value: int = 1) -> QWidget: spin_box = QSpinBox() spin_box.setMinimum(1) spin_box.setMaximum(1000) - spin_box.wheelEvent = lambda event: None # disable scroll wheel + # disable scroll wheel + spin_box.wheelEvent = lambda event: None # type: ignore spin_box.setValue(value) return spin_box @@ -501,11 +509,11 @@ def __init__(self, self.setFixedWidth(width) self.change_event = change_event - self.parameters_path = None - self.model_paths = [] - self.data_paths = [] + self.parameters_path: Optional[str] = None + self.model_paths: List[str] = [] + self.data_paths: Optional[List[Path]] = [] - self.sampler = TargetNontargetSampler + self.sampler: Optional[Sampler] = TargetNontargetSampler self.sampler_options = sampler_options(default=self.sampler) self.sampler_args = '{}' @@ -520,10 +528,9 @@ def __init__(self, self.model_input_control = ModelInputs(change_event=self.update_models) self.sampler_input_control = SelectionList( change_event=self.update_sampler, - items=self.sampler_options.keys()) + items=list(self.sampler_options.keys())) self.sampler_args_control = ObjectArgInputs( - change_event=self.update_sampler_args, - object_type=self.sampler) + change_event=self.update_sampler_args, object_type=self.sampler) form = QFormLayout() form.setFormAlignment(Qt.AlignmentFlag.AlignLeft @@ -573,7 +580,7 @@ def runs(self) -> int: """Number of runs""" return int(self.runs_control.text()) - def outdir(self) -> str: + def outdir(self) -> Optional[str]: """Output directory""" return self.output_control.value() @@ -585,7 +592,7 @@ def command_valid(self) -> bool: def command(self) -> str: """Command equivalent to to the result of the interactive selection of simulator inputs.""" - if not self.command_valid: + if not self.command_valid(): return '' args = [] @@ -593,10 +600,12 @@ def command(self) -> str: args.append(f"-o '{self.outdir()}'") args.append(f"-p '{self.parameters_path}'") args.extend([f"-m '{path}'" for path in self.model_paths]) - args.extend([f"-d '{source}'" for source in self.data_paths]) + if self.data_paths: + args.extend([f"-d '{source}'" for source in self.data_paths]) args.append(f"-n {self.runs()}") - args.append(f"-s {self.sampler.__name__}") + if self.sampler is not None: + args.append(f"-s {self.sampler.__name__}") args.append(f"--sampler_args='{self.sampler_args}'") return f"bcipy-sim {' '.join(args)}" @@ -669,11 +678,11 @@ class MainPanel(QWidget): def __init__(self, title: str, size: Tuple[int, int]): super().__init__() self.title = title - self.size = size + self.w_size = size self.command = '' - self.form = SimConfigForm(width=self.size[0], + self.form = SimConfigForm(width=self.w_size[0], change_event=self.on_params_change) self.flash_msg = '' self.init_ui() @@ -710,7 +719,7 @@ def init_ui(self): color='darkgray') vbox.addWidget(self.command_msg) self.setLayout(vbox) - self.setFixedHeight(self.size[1]) + self.setFixedHeight(self.w_size[1]) self.setWindowTitle(self.title) self.show() @@ -752,7 +761,7 @@ def init(title='BCI Simulator', size=(900, 600)) -> str: def configure( title='BCI Simulator', size=(600, 900) -) -> Tuple[int, str, Optional[TaskFactory]]: +) -> Tuple[int, Optional[str], Optional[TaskFactory]]: """Main function""" app = QApplication(sys.argv) panel = MainPanel(title, size) diff --git a/bcipy/simulator/ui/gui_utils.py b/bcipy/simulator/ui/gui_utils.py index a90cf906c..c7bd84336 100644 --- a/bcipy/simulator/ui/gui_utils.py +++ b/bcipy/simulator/ui/gui_utils.py @@ -33,7 +33,7 @@ def is_annotated(param: inspect.Parameter) -> bool: return param.annotation != inspect.Parameter.empty -def get_input_type(param: inspect.Parameter) -> Tuple[str, bool]: +def get_input_type(param: inspect.Parameter) -> Tuple[Optional[str], bool]: """For a given constructor parameter, determine the GUI input type. For subscriptable types such as Optional, the first type is returned (except None). diff --git a/bcipy/simulator/ui/obj_args_widget.py b/bcipy/simulator/ui/obj_args_widget.py index 9f6c1cfd4..d59113a53 100644 --- a/bcipy/simulator/ui/obj_args_widget.py +++ b/bcipy/simulator/ui/obj_args_widget.py @@ -31,7 +31,7 @@ def __init__(self, self._add_controls() self.setLayout(self.form_layout) - def set_object_type(self, object_type: Type[Any]) -> None: + def set_object_type(self, object_type: Optional[Type[Any]]) -> None: """Set the class for which we should provide inputs.""" self.object_type = object_type self.input_fields = self._init_input_fields() From 73b928959c334b8885a4c9f87c3e855ec605dfac Mon Sep 17 00:00:00 2001 From: lawhead Date: Fri, 31 Jan 2025 15:23:11 -0800 Subject: [PATCH 44/76] Simulator change to remove empty triggers.txt file at the end of each task run --- bcipy/simulator/task/copy_phrase.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bcipy/simulator/task/copy_phrase.py b/bcipy/simulator/task/copy_phrase.py index f1215023c..8169ca59d 100644 --- a/bcipy/simulator/task/copy_phrase.py +++ b/bcipy/simulator/task/copy_phrase.py @@ -1,6 +1,7 @@ # mypy: disable-error-code="union-attr" """Simulates the Copy Phrase task""" import logging +from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from bcipy.core.parameters import Parameters @@ -143,6 +144,12 @@ def compute_device_evidence( def cleanup(self): self.save_session_data() + trigger_path = Path(self.trigger_handler.file_path) + self.trigger_handler.close() + + # delete empty triggers.txt file + if trigger_path.exists() and trigger_path.is_file(): + trigger_path.unlink(missing_ok=True) def exit_display(self) -> None: """Close the UI and cleanup.""" From c3df0ea5c0211cea167aed832fa1069a5e5c7baa Mon Sep 17 00:00:00 2001 From: lawhead Date: Wed, 5 Feb 2025 16:54:22 -0800 Subject: [PATCH 45/76] Added verbosity setting to simulator to control logging --- bcipy/helpers/copy_phrase_wrapper.py | 6 ++-- .../simulator/data/sampler/inquiry_sampler.py | 2 +- .../data/sampler/target_nontarget_sampler.py | 2 +- bcipy/simulator/task/copy_phrase.py | 4 +-- bcipy/simulator/task/task_factory.py | 8 ++--- bcipy/simulator/task/task_runner.py | 22 ++++++++---- bcipy/simulator/util/artifact.py | 35 ++++++++++++++----- 7 files changed, 54 insertions(+), 25 deletions(-) diff --git a/bcipy/helpers/copy_phrase_wrapper.py b/bcipy/helpers/copy_phrase_wrapper.py index e94f22278..cfa40aa86 100644 --- a/bcipy/helpers/copy_phrase_wrapper.py +++ b/bcipy/helpers/copy_phrase_wrapper.py @@ -6,10 +6,10 @@ import numpy as np from bcipy.config import SESSION_LOG_FILENAME -from bcipy.exceptions import BciPyCoreException -from bcipy.helpers.language_model import histogram, with_min_prob from bcipy.core.stimuli import InquirySchedule, StimuliOrder from bcipy.core.symbols import BACKSPACE_CHAR +from bcipy.exceptions import BciPyCoreException +from bcipy.helpers.language_model import histogram, with_min_prob from bcipy.language.main import LanguageModel from bcipy.task.control.criteria import (CriteriaEvaluator, MaxIterationsCriteria, @@ -206,7 +206,7 @@ def initialize_series(self) -> Tuple[bool, InquirySchedule]: ] # display histogram of LM probabilities - log.info(histogram(lm_letter_prior)) + log.debug(histogram(lm_letter_prior)) # Try fusing the lmodel evidence try: diff --git a/bcipy/simulator/data/sampler/inquiry_sampler.py b/bcipy/simulator/data/sampler/inquiry_sampler.py index b9873dd32..540ae2611 100644 --- a/bcipy/simulator/data/sampler/inquiry_sampler.py +++ b/bcipy/simulator/data/sampler/inquiry_sampler.py @@ -123,5 +123,5 @@ def sample(self, state: SimState) -> List[Trial]: Trial(**sorted_inquiry_df.iloc[i]) for i in range(len(sorted_inquiry_df)) ] - log.info(f"EEG Samples:\n{format_samples(rows)}") + log.debug(f"EEG Samples:\n{format_samples(rows)}") return rows diff --git a/bcipy/simulator/data/sampler/target_nontarget_sampler.py b/bcipy/simulator/data/sampler/target_nontarget_sampler.py index 234b9ff8a..59cce32bd 100644 --- a/bcipy/simulator/data/sampler/target_nontarget_sampler.py +++ b/bcipy/simulator/data/sampler/target_nontarget_sampler.py @@ -20,7 +20,7 @@ def sample(self, state: SimState) -> List[Trial]: filtered_data = self.data_engine.query(filters, samples=1) sample_rows.append(filtered_data[0]) - log.info(f"Samples:\n{format_samples(sample_rows)}") + log.debug(f"Samples:\n{format_samples(sample_rows)}") return sample_rows def query_filters(self, symbol: str, is_target: bool) -> List[QueryFilter]: diff --git a/bcipy/simulator/task/copy_phrase.py b/bcipy/simulator/task/copy_phrase.py index 8169ca59d..838fdd314 100644 --- a/bcipy/simulator/task/copy_phrase.py +++ b/bcipy/simulator/task/copy_phrase.py @@ -124,8 +124,8 @@ def compute_device_evidence( proceed: bool = True) -> List[Tuple[EvidenceType, List[float]]]: current_state = self.get_sim_state() - self.logger.info("Computing evidence with sim_state:") - self.logger.info(current_state) + self.logger.debug("Computing evidence with sim_state:") + self.logger.debug(current_state) evidences = [] diff --git a/bcipy/simulator/task/task_factory.py b/bcipy/simulator/task/task_factory.py index c2aeef75b..7b5bd4cf0 100644 --- a/bcipy/simulator/task/task_factory.py +++ b/bcipy/simulator/task/task_factory.py @@ -66,10 +66,10 @@ def log_state(self): """Log configured objects of interest. This should be done after the sim directory has been created and TOP_LEVEL_LOGGER has been configured, which may happen some time after object construction.""" - logger.info("Language model:") - logger.info(f"\t{repr(self.language_model)}") - logger.info("Models -> Samplers:") - logger.info(f"\t{self.samplers}") + logger.debug("Language model:") + logger.debug(f"\t{repr(self.language_model)}") + logger.debug("Models -> Samplers:") + logger.debug(f"\t{self.samplers}") def init_samplers( self, diff --git a/bcipy/simulator/task/task_runner.py b/bcipy/simulator/task/task_runner.py index 8f9be2b60..c582dce31 100644 --- a/bcipy/simulator/task/task_runner.py +++ b/bcipy/simulator/task/task_runner.py @@ -6,6 +6,8 @@ from pathlib import Path from typing import Any, Dict +from rich.progress import track + import bcipy.simulator.util.metrics as metrics # pylint: disable=wildcard-import,unused-wildcard-import # flake8: noqa @@ -17,7 +19,7 @@ TOP_LEVEL_LOGGER_NAME, configure_run_directory, init_simulation_dir, - remove_handlers) + remove_handlers, set_verbose) logger = logging.getLogger(TOP_LEVEL_LOGGER_NAME) @@ -44,26 +46,28 @@ class TaskRunner(): def __init__(self, save_dir: str, task_factory: TaskFactory, - runs: int = 1): + runs: int = 1, + verbose: bool = True): self.task_factory = task_factory self.sim_dir = save_dir self.runs = runs + self.verbose = verbose def run(self) -> None: """Run one or more simulations""" self.task_factory.parameters.save(self.sim_dir, 'parameters.json') - for i in range(self.runs): + for i in track(range(self.runs), description="Processing..."): self.do_run(i + 1) def do_run(self, run: int): """Execute a simulation run.""" - logger.info(f"Executing task {run}") + logger.debug(f"Executing task {run}") run_dir, run_log = configure_run_directory(self.sim_dir, run) - logger.info(run_dir) + logger.debug(run_dir) task = self.task_factory.make_task(run_dir) task.execute() - logger.info("Task complete") + logger.debug("Task complete") remove_handlers(run_log) @@ -129,6 +133,10 @@ def main(): required=False, default=DEFAULT_SAVE_LOCATION, help="Sim output path") + parser.add_argument("-v", + "--verbose", + action='store_true', + help="Verbose mode for more detailed logging.") args = parser.parse_args() sim_args = vars(args) @@ -148,6 +156,8 @@ def main(): task=SimulatorCopyPhraseTask, sampler_args=parse_args(sim_args['sampler_args'])) + if sim_args['verbose']: + set_verbose(True) if task_factory: sim_dir = init_simulation_dir(save_location=outdir) logger.info(sim_dir) diff --git a/bcipy/simulator/util/artifact.py b/bcipy/simulator/util/artifact.py index ff59b97b3..e36fe7837 100644 --- a/bcipy/simulator/util/artifact.py +++ b/bcipy/simulator/util/artifact.py @@ -15,6 +15,15 @@ DEFAULT_SAVE_LOCATION = f"{ROOT}/data/simulator" RUN_PREFIX = "run_" +DEFAULT_VERBOSE = False + + +def set_verbose(value: bool) -> None: + """Set default verbose mode""" + # pylint: disable=global-statement + global DEFAULT_VERBOSE + DEFAULT_VERBOSE = value + def remove_handlers(log: logging.Logger) -> None: """Remove any file handlers from the provided logger.""" @@ -29,7 +38,8 @@ def remove_handlers(log: logging.Logger) -> None: def configure_logger(log_path: str, file_name: str, logger_name: Optional[str] = None, - use_stdout: bool = True) -> logging.Logger: + use_stdout: bool = True, + verbose: Optional[bool] = None) -> logging.Logger: """Configures logger for standard out and file output. Parameters @@ -38,13 +48,17 @@ def configure_logger(log_path: str, file_name - name of the log file logger_name - None configures the root logger; otherwise configures a named logger. use_stdout - if True, INFO messages will be output to stdout. + verbose - determines the log level; set True to log as DEBUG """ log = logging.getLogger(logger_name) # configuring root logger remove_handlers(log) - log.setLevel(logging.INFO) + if verbose is None: + verbose = DEFAULT_VERBOSE + level = logging.DEBUG if verbose else logging.INFO + log.setLevel(level) fmt = '[%(asctime)s][%(name)s][%(levelname)s]: %(message)s' if use_stdout: @@ -54,14 +68,15 @@ def configure_logger(log_path: str, log.addHandler(stdout_handler) file_handler = logging.FileHandler(f"{log_path}/{file_name}") - file_handler.setLevel(logging.INFO) + file_handler.setLevel(level) file_handler.setFormatter(logging.Formatter(fmt)) log.addHandler(file_handler) return log def init_simulation_dir(save_location: str = DEFAULT_SAVE_LOCATION, - logfile_name: str = DEFAULT_LOGFILE_NAME) -> str: + logfile_name: str = DEFAULT_LOGFILE_NAME, + verbose: Optional[bool] = None) -> str: """Setup the folder structure and logging for a simulation. Parameters @@ -76,12 +91,15 @@ def init_simulation_dir(save_location: str = DEFAULT_SAVE_LOCATION, configure_logger(save_dir, logfile_name, logger_name=TOP_LEVEL_LOGGER_NAME, - use_stdout=True) + use_stdout=True, + verbose=verbose) return save_dir -def configure_run_directory(sim_dir: str, - run: int) -> Tuple[str, logging.Logger]: +def configure_run_directory( + sim_dir: str, + run: int, + verbose: Optional[bool] = None) -> Tuple[str, logging.Logger]: """Create the necessary directories and configure the logger. Returns the run directory. """ @@ -91,7 +109,8 @@ def configure_run_directory(sim_dir: str, log = configure_logger(log_path=path, file_name=f"{run_name}.log", logger_name=None, - use_stdout=False) + use_stdout=False, + verbose=verbose) return path, log From ffadfa3e3e08287ea50d38e61e51edacd22455ba Mon Sep 17 00:00:00 2001 From: lawhead Date: Wed, 5 Feb 2025 17:03:39 -0800 Subject: [PATCH 46/76] Updated sim readme with new parameter --- bcipy/simulator/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/bcipy/simulator/README.md b/bcipy/simulator/README.md index 45d6ab95b..228a90e68 100644 --- a/bcipy/simulator/README.md +++ b/bcipy/simulator/README.md @@ -47,6 +47,7 @@ For example, - `o`: Output directory for all simulation artifacts. - `s`: Sampling strategy to use; by default the TargetNonTargetSampler is used. The value provided should be the class name of a Sampler. - `sampler_args`: Arguments to pass in to the selected Sampler. Some samplers can be customized with further parameters. These should be structured as a JSON dictionary mapping keys to values. For example: `--sampler_args='{"inquiry_end": 4}'` +- `-v` or `--verbose`: Execute the simulation in verbose mode for more detailed logging. Useful for debugging. #### Sim Output Details From 41aedd750e251288ed6c66a851d47ea65638d785 Mon Sep 17 00:00:00 2001 From: lawhead Date: Wed, 12 Feb 2025 14:48:41 -0800 Subject: [PATCH 47/76] #188805610 integrate replay script into simulator --- .../simulator/data/sampler/replay_sampler.py | 61 +++ bcipy/simulator/exceptions.py | 4 + bcipy/simulator/task/replay_session.py | 70 ++++ bcipy/simulator/task/replay_task.py | 140 +++++++ bcipy/simulator/tests/resources/session.json | 386 ++++++++++++++++++ .../simulator/tests/test_replay_comparison.py | 52 +++ bcipy/simulator/tests/test_replay_sampler.py | 83 ++++ bcipy/simulator/util/replay_comparison.py | 132 ++++++ 8 files changed, 928 insertions(+) create mode 100644 bcipy/simulator/data/sampler/replay_sampler.py create mode 100644 bcipy/simulator/task/replay_session.py create mode 100644 bcipy/simulator/task/replay_task.py create mode 100644 bcipy/simulator/tests/resources/session.json create mode 100644 bcipy/simulator/tests/test_replay_comparison.py create mode 100644 bcipy/simulator/tests/test_replay_sampler.py create mode 100644 bcipy/simulator/util/replay_comparison.py diff --git a/bcipy/simulator/data/sampler/replay_sampler.py b/bcipy/simulator/data/sampler/replay_sampler.py new file mode 100644 index 000000000..084fbb5a1 --- /dev/null +++ b/bcipy/simulator/data/sampler/replay_sampler.py @@ -0,0 +1,61 @@ +import logging +from typing import List + +from bcipy.simulator.data.data_engine import QueryFilter, RawDataEngine, Trial +from bcipy.simulator.data.sampler.base_sampler import Sampler, format_samples +from bcipy.simulator.exceptions import IncompatibleData +from bcipy.simulator.util.state import SimState + +log = logging.getLogger(__name__) + + +class ReplaySampler(Sampler): + """Sampler that provides inquiries for replaying a session. + + Assumes that the state is sending sample requests sequentially and that the + order of symbols match the original session. + """ + + def __init__(self, data_engine: RawDataEngine): + super().__init__(data_engine) + + source_count = len(self.data_engine.source_dirs) + if source_count > 1: + log.warning("Only 1 dataset is supported.") + if source_count == 0: + raise IncompatibleData("At least one data source is required.") + self.current_source_index = 0 + + @property + def current_source(self) -> str: + """Current data source""" + return self.data_engine.source_dirs[self.current_source_index] + + def sample(self, state: SimState) -> List[Trial]: + """Sample data that exactly matches the provided state.""" + + sample_rows = [] + for symbol in state.display_alphabet: + filters = self.query_filters(state, symbol) + filtered_data = self.data_engine.query(filters, samples=1) + sample_rows.append(filtered_data[0]) + log.info(f"Samples:\n{format_samples(sample_rows)}") + + return sample_rows + + def query_filters(self, state: SimState, symbol: str) -> List[QueryFilter]: + """Expression used to query for a single sample.""" + return [ + QueryFilter('source', '==', self.current_source), + QueryFilter('series', '==', state.series_n), + QueryFilter('series_inquiry', '==', state.inquiry_n), + QueryFilter('symbol', '==', symbol) + ] + + def next_source(self) -> None: + """Increment to query from the next data source.""" + last_index = len(self.data_engine.source_dirs) - 1 + if self.current_source_index < last_index: + self.current_source_index += 1 + else: + self.current_source_index = 0 diff --git a/bcipy/simulator/exceptions.py b/bcipy/simulator/exceptions.py index a7f3bad15..f95f5f1a6 100644 --- a/bcipy/simulator/exceptions.py +++ b/bcipy/simulator/exceptions.py @@ -16,3 +16,7 @@ class IncompatibleDeviceSpec(IncompatibleData): class IncompatibleParameters(IncompatibleData): """Thrown when the timing parameters used for data collection are incompatible with the timing parameters of the simulation.""" + + +class IncompatibleSampler(Exception): + """Thrown when the provided sampler is incompatible with a given task.""" diff --git a/bcipy/simulator/task/replay_session.py b/bcipy/simulator/task/replay_session.py new file mode 100644 index 000000000..60b1c8a9a --- /dev/null +++ b/bcipy/simulator/task/replay_session.py @@ -0,0 +1,70 @@ +"""Task that will replay sessions to compare model predictions on that data. +Used for testing if changes to a model result in more easily differentiated signals.""" +import argparse +import logging +from pathlib import Path + +from bcipy.simulator.data.sampler.replay_sampler import ReplaySampler +from bcipy.simulator.task.replay_task import ReplayTask +from bcipy.simulator.task.task_factory import TaskFactory +from bcipy.simulator.task.task_runner import TaskRunner +from bcipy.simulator.util.artifact import (DEFAULT_SAVE_LOCATION, + TOP_LEVEL_LOGGER_NAME, + init_simulation_dir) +from bcipy.simulator.util.replay_comparison import comparison_metrics + +logger = logging.getLogger(TOP_LEVEL_LOGGER_NAME) + + +def main(): + """Run the task""" + parser = argparse.ArgumentParser() + data_help = ( + 'Raw data folders to be processed.' + ' Multiple values can be provided, or a single parent folder.') + parser.add_argument("-d", + "--data_folder", + type=Path, + required=False, + action='append', + help=data_help) + parser.add_argument("-m", + "--model_path", + type=Path, + required=True, + help="Signal model to be used.") + parser.add_argument("-p", + "--parameters", + type=Path, + required=False, + help="Parameter File to be used") + parser.add_argument("-o", + "--output", + type=Path, + required=False, + default=DEFAULT_SAVE_LOCATION, + help="Sim output path") + args = parser.parse_args() + sim_args = vars(args) + + runs = len(sim_args['data_folder']) + outdir = sim_args['output'] + + task_factory = TaskFactory(params_path=sim_args['parameters'], + source_dirs=sim_args['data_folder'], + signal_model_paths=[sim_args['model_path']], + sampling_strategy=ReplaySampler, + task=ReplayTask) + + sim_dir = init_simulation_dir(save_location=outdir) + logger.info(sim_dir) + task_factory.log_state() + + runner = TaskRunner(save_dir=sim_dir, task_factory=task_factory, runs=runs) + runner.run() + + comparison_metrics(sim_dir, sim_args['data_folder']) + + +if __name__ == '__main__': + main() diff --git a/bcipy/simulator/task/replay_task.py b/bcipy/simulator/task/replay_task.py new file mode 100644 index 000000000..84035e212 --- /dev/null +++ b/bcipy/simulator/task/replay_task.py @@ -0,0 +1,140 @@ +from pathlib import Path +from typing import Dict, List, Tuple + +from bcipy.config import SESSION_DATA_FILENAME +from bcipy.core.parameters import Parameters +from bcipy.core.session import read_session +from bcipy.core.stimuli import InquirySchedule +from bcipy.language.main import LanguageModel +from bcipy.signal.model.base_model import SignalModel +from bcipy.simulator.data.sampler.base_sampler import Sampler +from bcipy.simulator.data.sampler.replay_sampler import ReplaySampler +from bcipy.simulator.exceptions import IncompatibleSampler +from bcipy.simulator.task.copy_phrase import SimulatorCopyPhraseTask +from bcipy.simulator.util.state import SimState +from bcipy.task.data import EvidenceType, Inquiry +from bcipy.task.main import TaskData, TaskMode + + +def clone_inquiry(original_inquiry: Inquiry) -> Inquiry: + """Construct a new inquiry data record.""" + new_inquiry = Inquiry( + original_inquiry.stimuli, + timing=original_inquiry.timing, + triggers=original_inquiry.triggers, + target_info=original_inquiry.target_info, + target_letter=original_inquiry.target_letter, + current_text=original_inquiry.current_text, + target_text=original_inquiry.target_text, + selection=original_inquiry.selection, + next_display_state=original_inquiry.next_display_state) + new_inquiry.precision = original_inquiry.precision + return new_inquiry + + +class ReplayTask(SimulatorCopyPhraseTask): + """Task to replay a session and record predictions on that data for a new model.""" + + name = "Replay Session" + mode = TaskMode.COPYPHRASE + + def __init__(self, parameters: Parameters, file_save: str, + signal_models: List[SignalModel], + language_model: LanguageModel, samplers: Dict[SignalModel, + Sampler]): + super().__init__(parameters, file_save, signal_models, language_model, + samplers) + + if len(signal_models) > 1: + self.logger.warning("Only the first provided model will be used.") + + self.signal_model = signal_models[0] + self.sampler = samplers[self.signal_model] + + if not isinstance(self.sampler, ReplaySampler): + raise IncompatibleSampler( + "ReplaySampler is the only supported sampler for this task") + + def execute(self) -> TaskData: + """Executes the task. + + Returns + ------- + data save location (triggers.txt, session.json) + """ + # assertion to avoid typing error + assert isinstance(self.sampler, + ReplaySampler), "Sampler must be ReplaySampler" + + self.logger.info("Starting Replay Task!") + data_folder = self.sampler.current_source + original_session = read_session( + str(Path(data_folder, SESSION_DATA_FILENAME))) + + # Iterate through original session. + for series_n, series in enumerate(original_session.series): + for inquiry_n, inquiry in enumerate(series): + + # Variables used to generate sim state for sampling. + self.current_inquiry = InquirySchedule( + stimuli=inquiry.stimuli, + durations=inquiry.timing, + colors=[]) + self.series_n = series_n + 1 # 1-based index + self.inquiry_n = inquiry_n + self.inquiry = inquiry + + evidence_types = self.add_evidence(stim_times=[], proceed=True) + + # we only need to evaluate_evidence to involve the copy phrase wrapper + # for combined likelihood. If that's not used it can be deleted. + _decision = self.evaluate_evidence() + + data = clone_inquiry(inquiry) + evidence = self.copy_phrase_task.conjugator.latest_evidence + data.evidences = { + ev_type: ev_data if ev_type in evidence_types else [] + for ev_type, ev_data in evidence.items() + } + data.likelihood = list( + self.copy_phrase_task.conjugator.likelihood) + + # Set decision_made to False since we are manually calling add_series. + self.update_session_data(data, save=True, decision_made=False) + self.session.add_series() + + self.sampler.next_source() + self.cleanup() + + return TaskData(save_path=self.file_save, + task_dict=self.session.as_dict()) + + def compute_device_evidence( + self, + stim_times: List[List], + proceed: bool = True) -> List[Tuple[EvidenceType, List[float]]]: + """Override to compute the model evidence based on the current state.""" + assert self.signal_model, "Signal model is required" + + current_state = self.get_sim_state() + self.logger.info("Computing evidence with sim_state:") + self.logger.info(current_state) + + sampled_data = self.sampler.sample_data(current_state) + evidence = self.signal_model.compute_likelihood_ratio( + sampled_data, current_state.display_alphabet, self.alp) + evidences = [(EvidenceType.ERP, evidence)] + + return evidences + + def get_sim_state(self) -> SimState: + """Get the current state in the format expected by the simulation tools.""" + + return SimState( + target_symbol=self.inquiry.target_letter or '', + current_sentence=self.inquiry.current_text or '', + target_sentence=self.inquiry.target_text or '', + # without the '+' fixation + display_alphabet=self.inquiry.stimuli[1:], + inquiry_n=self.inquiry_n, + series_n=self.series_n) diff --git a/bcipy/simulator/tests/resources/session.json b/bcipy/simulator/tests/resources/session.json new file mode 100644 index 000000000..388b22d1a --- /dev/null +++ b/bcipy/simulator/tests/resources/session.json @@ -0,0 +1,386 @@ +{ + "session": "/Users/lawhead/workspace/bci/BciPy/data/simulator/SIM_12-18-2024_17_12_58/run_1", + "task": "Copy Phrase", + "mode": "copy phrase", + "symbol_set": [ + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "N", + "O", + "P", + "Q", + "R", + "S", + "T", + "U", + "V", + "W", + "X", + "Y", + "Z", + "<", + "_" + ], + "decision_threshold": 0.8, + "series": { + "1": { + "0": { + "stimuli": [ + [ + "+", + "S", + "D", + "X", + "K", + "H", + "_", + "E", + "C", + "R", + "W" + ] + ], + "timing": [ + [ + 0.5, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2 + ] + ], + "triggers": [], + "target_info": [], + "target_letter": "A", + "current_text": "", + "target_text": "ASK_A_QUESTION", + "selection": "", + "next_display_state": "", + "lm_evidence": [ + 0.03571, + 0.03571, + 0.03571, + 0.03571, + 0.03571, + 0.03571, + 0.03571, + 0.03571, + 0.03571, + 0.03571, + 0.03571, + 0.03571, + 0.03571, + 0.03571, + 0.03571, + 0.03571, + 0.03571, + 0.03571, + 0.03571, + 0.03571, + 0.03571, + 0.03571, + 0.03571, + 0.03571, + 0.03571, + 0.03571, + 0.03571, + 0.03571 + ], + "eeg_evidence": [ + 1.0, + 1.0, + 0.0145, + 0.94859, + 0.01, + 1.0, + 1.0, + 0.39689, + 1.0, + 1.0, + 0.31747, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 0.11467, + 0.34407, + 1.0, + 1.0, + 1.0, + 0.44074, + 0.14787, + 1.0, + 1.0, + 1.0, + 0.43638 + ], + "likelihood": [ + 0.04723, + 0.04723, + 0.00068, + 0.04481, + 0.00047, + 0.04723, + 0.04723, + 0.01875, + 0.04723, + 0.04723, + 0.015, + 0.04723, + 0.04723, + 0.04723, + 0.04723, + 0.04723, + 0.04723, + 0.00542, + 0.01625, + 0.04723, + 0.04723, + 0.04723, + 0.02082, + 0.00698, + 0.04723, + 0.04723, + 0.04723, + 0.02061 + ] + }, + "1": { + "stimuli": [ + [ + "+", + "Y", + "Z", + "O", + "I", + "M", + "<", + "P", + "G", + "N", + "B" + ] + ], + "timing": [ + [ + 0.5, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2 + ] + ], + "triggers": [], + "target_info": [], + "target_letter": "A", + "current_text": "", + "target_text": "ASK_A_QUESTION", + "selection": "", + "next_display_state": "", + "lm_evidence": [], + "eeg_evidence": [ + 1.0, + 0.12321, + 1.0, + 1.0, + 1.0, + 1.0, + 0.01, + 1.0, + 0.01269, + 1.0, + 1.0, + 1.0, + 0.43755, + 0.40155, + 0.02363, + 0.37681, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0, + 0.05845, + 0.39573, + 0.43228, + 1.0 + ], + "likelihood": [ + 0.07439, + 0.00917, + 0.00108, + 0.07056, + 0.00074, + 0.07439, + 0.00074, + 0.02952, + 0.00094, + 0.07439, + 0.02362, + 0.07439, + 0.03255, + 0.02987, + 0.00176, + 0.02803, + 0.07439, + 0.00853, + 0.02559, + 0.07439, + 0.07439, + 0.07439, + 0.03279, + 0.011, + 0.00435, + 0.02944, + 0.03216, + 0.03246 + ] + }, + "2": { + "stimuli": [ + [ + "+", + "T", + "J", + "W", + "D", + "L", + "V", + "U", + "A", + "F", + "Q" + ] + ], + "timing": [ + [ + 0.5, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2, + 0.2 + ] + ], + "triggers": [], + "target_info": [], + "target_letter": "A", + "current_text": "", + "target_text": "ASK_A_QUESTION", + "selection": "A", + "next_display_state": "A", + "lm_evidence": [], + "eeg_evidence": [ + 100.0, + 1.0, + 1.0, + 0.14948, + 1.0, + 0.04914, + 1.0, + 1.0, + 1.0, + 0.01, + 1.0, + 0.01, + 1.0, + 1.0, + 1.0, + 1.0, + 0.13505, + 1.0, + 1.0, + 2.77386, + 0.065, + 0.19065, + 0.27223, + 1.0, + 1.0, + 1.0, + 1.0, + 1.0 + ], + "likelihood": [ + 0.92981, + 0.00115, + 0.00013, + 0.00132, + 9e-05, + 0.00046, + 9e-05, + 0.00369, + 0.00012, + 9e-05, + 0.00295, + 9e-05, + 0.00407, + 0.00373, + 0.00022, + 0.0035, + 0.00126, + 0.00107, + 0.0032, + 0.02579, + 0.0006, + 0.00177, + 0.00112, + 0.00137, + 0.00054, + 0.00368, + 0.00402, + 0.00406 + ] + } + } + }, + "total_time_spent": 0.0, + "total_minutes": 0.0, + "total_number_series": 18, + "total_inquiries": 41, + "total_selections": 18, + "inquiries_per_selection": 2.2777777777777777, + "task_summary": { + "selections_correct": 16, + "selections_incorrect": 2, + "selections_correct_symbols": 14, + "switch_total": 0, + "switch_per_selection": 0.0, + "switch_response_time": null, + "typing_accuracy": 0.8888888888888888, + "correct_rate": 0, + "copy_rate": 0 + } + } \ No newline at end of file diff --git a/bcipy/simulator/tests/test_replay_comparison.py b/bcipy/simulator/tests/test_replay_comparison.py new file mode 100644 index 000000000..79f9a37d5 --- /dev/null +++ b/bcipy/simulator/tests/test_replay_comparison.py @@ -0,0 +1,52 @@ +import os +import shutil +import tempfile +import unittest +from pathlib import Path + +from bcipy.simulator.util.replay_comparison import (load_eeg_records, + prepare_data) + + +class ReplayComparisonTest(unittest.TestCase): + """Tests comparing evidence from identical sessions.""" + + def setUp(self): + """Override; set up the needed path for load functions.""" + self.data_dir = f"{os.path.dirname(__file__)}/resources/" + self.temp_dir = tempfile.mkdtemp() + + def tearDown(self): + """Override""" + shutil.rmtree(self.temp_dir) + + def test_load_eeg_evidence(self): + """Test loading evidence""" + + path = Path(self.data_dir, "session.json") + records = load_eeg_records(str(path), is_original_model=False) + + target_eeg = [ + record.response_value for record in records + if record.which_model == "new_target" + ] + nontarget_eeg = [ + record.response_value for record in records + if record.which_model == "new_nontarget" + ] + msg = "Target was not shown in first two inquiries but had a high response in the third." + self.assertEqual([1.0, 1.0, 100.0], target_eeg, msg) + num_symbols = 28 + num_inquiries = 3 + self.assertEqual((num_symbols - 1) * num_inquiries, len(nontarget_eeg)) + + def test_prepare_data(self): + """Test data prep""" + records = prepare_data(sim_dir=self.data_dir, data_folders=[]) + self.assertTrue( + all(record.which_model in ["new_target", "new_nontarget"] + for record in records)) + + +if __name__ == '__main__': + unittest.main() diff --git a/bcipy/simulator/tests/test_replay_sampler.py b/bcipy/simulator/tests/test_replay_sampler.py new file mode 100644 index 000000000..74284d5cb --- /dev/null +++ b/bcipy/simulator/tests/test_replay_sampler.py @@ -0,0 +1,83 @@ +import unittest +from unittest.mock import Mock + +from bcipy.simulator.data.data_engine import QueryFilter +from bcipy.simulator.data.sampler.replay_sampler import ReplaySampler +from bcipy.simulator.data.trial import Trial +from bcipy.simulator.exceptions import IncompatibleData +from bcipy.simulator.util.state import SimState + + +class ReplaySamplerTest(unittest.TestCase): + """Tests for Replay sampler class.""" + + def test_init(self): + """Test initialization""" + data_source = Mock() + data_source.source_dirs = [] + self.assertRaises(IncompatibleData, lambda: ReplaySampler(data_source)) + + data_source.source_dirs = ['source1'] + sampler = ReplaySampler(data_source) + self.assertEqual('source1', sampler.current_source) + + def test_filters(self): + """Test query filters""" + data_source = Mock() + data_source.source_dirs = ['source1'] + sampler = ReplaySampler(data_source) + + state = SimState(target_symbol='H', + current_sentence='', + target_sentence='HELLO', + display_alphabet=['A', 'B', 'C'], + inquiry_n=0, + series_n=1) + + expected = [ + QueryFilter('source', '==', 'source1'), + QueryFilter('series', '==', 1), + QueryFilter('series_inquiry', '==', 0), + QueryFilter('symbol', '==', 'A') + ] + self.assertEqual(expected, sampler.query_filters(state, 'A')) + + def test_sample(self): + """Test sampling""" + data_source = Mock() + data_source.source_dirs = ['source1'] + data_source.query.return_value = [ + Trial(source='source1', + series=1, + series_inquiry=0, + inquiry_n=0, + inquiry_pos=0, + symbol='A', + target=0, + eeg=Mock()) + ] + + sampler = ReplaySampler(data_source) + + state = SimState(target_symbol='H', + current_sentence='', + target_sentence='HELLO', + display_alphabet=['A', 'B', 'C'], + inquiry_n=0, + series_n=1) + sampler.sample(state) + self.assertEqual( + 3, + data_source.query.call_count, + msg="should have been called for each displayed symbol") + + def test_next_source(self): + """Test incrementing the source""" + data_source = Mock() + + data_source.source_dirs = ['source1', 'source2'] + sampler = ReplaySampler(data_source) + self.assertEqual('source1', sampler.current_source) + + sampler.next_source() + self.assertEqual('source2', sampler.current_source) diff --git a/bcipy/simulator/util/replay_comparison.py b/bcipy/simulator/util/replay_comparison.py new file mode 100644 index 000000000..f13d7b342 --- /dev/null +++ b/bcipy/simulator/util/replay_comparison.py @@ -0,0 +1,132 @@ +"""Generates plots for replay session.""" +import logging +from pathlib import Path +from typing import List, NamedTuple + +import matplotlib.pyplot as plt +import pandas as pd +import seaborn as sns + +from bcipy.config import SESSION_DATA_FILENAME +from bcipy.core.session import evidence_records, read_session +from bcipy.simulator.util.artifact import TOP_LEVEL_LOGGER_NAME +from bcipy.simulator.util.metrics import session_paths + +logger = logging.getLogger(TOP_LEVEL_LOGGER_NAME) + + +class Record(NamedTuple): + """Record for plotting.""" + which_model: str + response_value: float + + +def make_record(is_original_model: bool, is_target: bool, + response_value: float) -> Record: + """Construct a new Record""" + prefix = "old" if is_original_model else "new" + ev_type = "target" if is_target else "nontarget" + + return Record(f"{prefix}_{ev_type}", response_value) + + +def load_eeg_records(session_json: Path, + is_original_model: bool) -> List[Record]: + """Load EEG evidence from session.json file for comparison with replay outputs. + + Parameters + ---------- + session_json - path to session.json file + """ + session = read_session(str(session_json)) + return [ + make_record(is_original_model=is_original_model, + is_target=bool(ev_record.is_target), + response_value=ev_record.eeg) + for ev_record in evidence_records(session) + ] + + +def prepare_data(sim_dir: str, data_folders: List[str]) -> List[Record]: + """Prepare the data for plotting. + + Parameters + ---------- + sim_dir - simulation directory path + data_folders - data folders used in the simulation + """ + + original_sessions = [ + Path(folder, SESSION_DATA_FILENAME) for folder in data_folders + ] + simulated_sessions = session_paths(sim_dir) + + records = [] + for session_path in simulated_sessions: + records.extend(load_eeg_records(session_path, is_original_model=False)) + for session_path in original_sessions: + records.extend(load_eeg_records(session_path, is_original_model=True)) + + return records + + +def plot(sim_dir: str, data: List[Record]) -> None: + """Plot the summary + + Parameters + ---------- + sim_dir - simulation directory; figures will be written here. + df - dataframe with the evidence data + """ + df = pd.DataFrame(data) + logger.info(f"{df.describe()}") + data_order = ["old_target", "new_target", "old_nontarget", "new_nontarget"] + + ax = sns.stripplot( + x="which_model", + y="response_value", + data=df, + order=data_order, + ) + sns.boxplot( + showmeans=True, + meanline=True, + meanprops={ + "color": "k", + "ls": "-", + "lw": 2 + }, + medianprops={"visible": False}, + whiskerprops={"visible": False}, + zorder=10, + x="which_model", + y="response_value", + data=df, + showfliers=False, + showbox=False, + showcaps=False, + ax=ax, + order=data_order, + ) + + ax.set(yscale="log") + plt.savefig(Path(sim_dir, "response_values.stripplot.png"), + dpi=150, + bbox_inches="tight") + plt.close() + ax = sns.boxplot( + x="which_model", + y="response_value", + data=df, + order=data_order, + ) + ax.set(yscale="log") + plt.savefig(Path(sim_dir, "response_values.boxplot.png"), + dpi=150, + bbox_inches="tight") + + +def comparison_metrics(sim_dir, data_folders: List[str]) -> None: + """Compare the simulated results with the original.""" + data = prepare_data(sim_dir, data_folders) + plot(sim_dir, data) From 0649ec1156ea0fde09dc5a8c365055b4ba7f440a Mon Sep 17 00:00:00 2001 From: lawhead Date: Wed, 12 Feb 2025 15:13:20 -0800 Subject: [PATCH 48/76] Add documentation --- bcipy/simulator/README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/bcipy/simulator/README.md b/bcipy/simulator/README.md index 45d6ab95b..6072f3df2 100644 --- a/bcipy/simulator/README.md +++ b/bcipy/simulator/README.md @@ -102,6 +102,28 @@ A simulation can be started using a graphical user interface. This provides a way to explore the file system when providing the parameters.json file, simulation model, and input data sources. After all required inputs have been provided the user can initiate the simulation from the GUI interface. The command line used to run the simulation are output to the console prior to the run making it easier to start subsequent simulations with the same set of arguments. +## Replay Session + +The simulator also includes a Task for replaying a recorded session using a different signal model. This is used for testing if changes to a model result in more easily differentiated signals. + +This functionality currently has a different entry point. + +``` +(venv) $ python bcipy/simulator/task/replay_session.py -h +usage: replay_session.py [-h] [-d DATA_FOLDER] -m MODEL_PATH [-p PARAMETERS] [-o OUTPUT] + +optional arguments: + -h, --help show this help message and exit + -d DATA_FOLDER, --data_folder DATA_FOLDER + Raw data folders to be processed. Multiple values can be provided, or a single parent folder. + -m MODEL_PATH, --model_path MODEL_PATH + Signal model to be used. + -p PARAMETERS, --parameters PARAMETERS + Parameter File to be used + -o OUTPUT, --output OUTPUT + Sim output path +``` + ## Current Limitations * Only provides EEG support From 1b035a665fddfd0bb430ad6af49ac170fc437117 Mon Sep 17 00:00:00 2001 From: lawhead Date: Tue, 18 Feb 2025 10:03:23 -0800 Subject: [PATCH 49/76] Added console script for replay session; updated documentation --- bcipy/simulator/README.md | 4 ++-- bcipy/simulator/task/replay_session.py | 5 ++--- pyproject.toml | 1 + 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bcipy/simulator/README.md b/bcipy/simulator/README.md index 38184e636..bebaeadc8 100644 --- a/bcipy/simulator/README.md +++ b/bcipy/simulator/README.md @@ -110,8 +110,8 @@ The simulator also includes a Task for replaying a recorded session using a diff This functionality currently has a different entry point. ``` -(venv) $ python bcipy/simulator/task/replay_session.py -h -usage: replay_session.py [-h] [-d DATA_FOLDER] -m MODEL_PATH [-p PARAMETERS] [-o OUTPUT] +(venv) $ bcipy-replay -h +usage: bcipy-replay [-h] [-d DATA_FOLDER] -m MODEL_PATH [-p PARAMETERS] [-o OUTPUT] optional arguments: -h, --help show this help message and exit diff --git a/bcipy/simulator/task/replay_session.py b/bcipy/simulator/task/replay_session.py index 60b1c8a9a..ab8a6167c 100644 --- a/bcipy/simulator/task/replay_session.py +++ b/bcipy/simulator/task/replay_session.py @@ -19,9 +19,8 @@ def main(): """Run the task""" parser = argparse.ArgumentParser() - data_help = ( - 'Raw data folders to be processed.' - ' Multiple values can be provided, or a single parent folder.') + data_help = ('Raw data folders to be processed.' + ' Multiple values can be provided.') parser.add_argument("-d", "--data_folder", type=Path, diff --git a/pyproject.toml b/pyproject.toml index 8e12b6379..fed92986c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,6 +87,7 @@ release = [ [project.scripts] bcipy = "bcipy.main:bcipy_main" bcipy-erp-viz = "bcipy.helpers.visualization:erp" +bcipy-replay = "bcipy.simulator.task.replay_session:main" bcipy-sim = "bcipy.simulator:main" bcipy-train = "bcipy.signal.model.offline_analysis:main" From 27974f49cdc64ac2f4c1177570a32a840c0d4616 Mon Sep 17 00:00:00 2001 From: Dylan Gaines Date: Tue, 4 Mar 2025 14:49:16 -0600 Subject: [PATCH 50/76] Lm cleanup (#378) * Removed boston derivatives due to licensing * Deprecated the unigram model since there are far better options --- bcipy/helpers/language_model.py | 1 - bcipy/language/demo/demo_unigram.py | 22 - bcipy/language/lms/unigram.json | 30 - bcipy/language/model/mixture.py | 3 +- bcipy/language/model/unigram.py | 69 - bcipy/language/sets/boston.tokenized | 1341 ------------------- bcipy/language/sets/boston_subset.tokenized | 20 - bcipy/language/tests/test_mixture.py | 16 +- bcipy/language/tests/test_unigram.py | 114 -- 9 files changed, 9 insertions(+), 1607 deletions(-) delete mode 100644 bcipy/language/demo/demo_unigram.py delete mode 100644 bcipy/language/lms/unigram.json delete mode 100644 bcipy/language/model/unigram.py delete mode 100644 bcipy/language/sets/boston.tokenized delete mode 100644 bcipy/language/sets/boston_subset.tokenized delete mode 100644 bcipy/language/tests/test_unigram.py diff --git a/bcipy/helpers/language_model.py b/bcipy/helpers/language_model.py index be1a0ca06..4e1cb1745 100644 --- a/bcipy/helpers/language_model.py +++ b/bcipy/helpers/language_model.py @@ -19,7 +19,6 @@ from bcipy.language.model.mixture import MixtureLanguageModel from bcipy.language.model.oracle import OracleLanguageModel from bcipy.language.model.uniform import UniformLanguageModel -from bcipy.language.model.unigram import UnigramLanguageModel def language_models_by_name() -> Dict[str, LanguageModel]: diff --git a/bcipy/language/demo/demo_unigram.py b/bcipy/language/demo/demo_unigram.py deleted file mode 100644 index b69bbdb0e..000000000 --- a/bcipy/language/demo/demo_unigram.py +++ /dev/null @@ -1,22 +0,0 @@ -from bcipy.language.model.unigram import UnigramLanguageModel -from bcipy.core.symbols import alphabet -from bcipy.language.main import ResponseType - - -if __name__ == "__main__": - symbol_set = alphabet() - response_type = ResponseType.SYMBOL - lm = UnigramLanguageModel(response_type, symbol_set) - - next_char_pred = lm.state_update(list("does_it_make_sen")) - print(next_char_pred) - correct_char_rank = [c[0] for c in next_char_pred].index("S") + 1 - print(correct_char_rank) - next_char_pred = lm.state_update(list("does_it_make_sens")) - print(next_char_pred) - correct_char_rank = [c[0] for c in next_char_pred].index("E") + 1 - print(correct_char_rank) - next_char_pred = lm.state_update(list("does_it_make_sense")) - print(next_char_pred) - correct_char_rank = [c[0] for c in next_char_pred].index("_") + 1 - print(correct_char_rank) diff --git a/bcipy/language/lms/unigram.json b/bcipy/language/lms/unigram.json deleted file mode 100644 index c5432973d..000000000 --- a/bcipy/language/lms/unigram.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "E": 0.0998, - "T": 0.096, - "O": 0.0946, - "I": 0.0835, - "A": 0.0774, - "N": 0.0554, - "SPACE_CHAR": 0.0523, - "H": 0.0504, - "S": 0.0435, - "L": 0.0408, - "R": 0.0387, - "U": 0.0379, - "D": 0.0358, - "Y": 0.0324, - "W": 0.0288, - "M": 0.0266, - "G": 0.0221, - "C": 0.018, - "K": 0.016, - "P": 0.0145, - "F": 0.0117, - "B": 0.0113, - "V": 0.0091, - "J": 0.0016, - "X": 0.0008, - "Z": 0.0005, - "Q": 0.0002, - "BACKSPACE_CHAR": 0.0 -} \ No newline at end of file diff --git a/bcipy/language/model/mixture.py b/bcipy/language/model/mixture.py index 2220c029a..36e8cdd43 100644 --- a/bcipy/language/model/mixture.py +++ b/bcipy/language/model/mixture.py @@ -11,7 +11,6 @@ """All supported models must be imported""" from bcipy.language.model.causal import CausalLanguageModel from bcipy.language.model.kenlm import KenLMLanguageModel -from bcipy.language.model.unigram import UnigramLanguageModel class MixtureLanguageModel(LanguageModel): @@ -19,7 +18,7 @@ class MixtureLanguageModel(LanguageModel): Character language model that mixes any combination of other models """ - supported_lm_types = ["CAUSAL", "UNIGRAM", "KENLM"] + supported_lm_types = ["CAUSAL", "KENLM"] @staticmethod def language_models_by_name() -> Dict[str, LanguageModel]: diff --git a/bcipy/language/model/unigram.py b/bcipy/language/model/unigram.py deleted file mode 100644 index 13d85633f..000000000 --- a/bcipy/language/model/unigram.py +++ /dev/null @@ -1,69 +0,0 @@ -from typing import Optional, List, Tuple -from bcipy.core.symbols import BACKSPACE_CHAR, SPACE_CHAR -from bcipy.language.main import LanguageModel, ResponseType -from bcipy.exceptions import InvalidLanguageModelException -import json -from bcipy.config import LM_PATH - - -class UnigramLanguageModel(LanguageModel): - """Character language model based on trained unigram weights""" - - def __init__(self, response_type: ResponseType, symbol_set: List[str], lm_path: Optional[str] = None): - - super().__init__(response_type=response_type, symbol_set=symbol_set) - self.model = None - self.lm_path = lm_path or f"{LM_PATH}/unigram.json" - - try: - with open(self.lm_path) as json_file: - self.unigram_lm = json.load(json_file) - except BaseException: - raise InvalidLanguageModelException("Unable to load Unigram model from file") - - self.unigram_lm[SPACE_CHAR] = self.unigram_lm.pop("SPACE_CHAR") - self.unigram_lm[BACKSPACE_CHAR] = self.unigram_lm.pop("BACKSPACE_CHAR") - - if not set(self.unigram_lm.keys()) == set(self.symbol_set): - raise InvalidLanguageModelException("Invalid unigram model symbol set!") - - self.unigram_lm = sorted(self.unigram_lm.items(), key=lambda item: item[1], reverse=True) - - self.load() - - def supported_response_types(self) -> List[ResponseType]: - return [ResponseType.SYMBOL] - - def predict(self, evidence: List[str]) -> List[Tuple]: - """ - Given an evidence of typed string, predict the probability distribution of - the next symbol - Args: - evidence - a list of characters (typed by the user) - Response: - A list of symbols with probability - """ - - return self.unigram_lm - - def update(self) -> None: - """Update the model state""" - ... - - def load(self) -> None: - """ - Load the language model and tokenizer, initialize class variables - Args: - path: language model file path, can be just "unigram" - """ - - def state_update(self, evidence: List[str]) -> List[Tuple]: - """ - Wrapper method that takes in evidence text, and output probability distribution - of next character - evidence does not matter for unigram model - Args: - evidence - a list of characters (typed by the user) - Response: - A list of symbol with probability - """ - return self.unigram_lm diff --git a/bcipy/language/sets/boston.tokenized b/bcipy/language/sets/boston.tokenized deleted file mode 100644 index c31f7da5d..000000000 --- a/bcipy/language/sets/boston.tokenized +++ /dev/null @@ -1,1341 +0,0 @@ -c a n y o u l i s t e n t o m e p l e a s e l o o k i n m y e y e s -i t s u d d e n l y b e c o m e s i m p o r t a n t -y o u k n o w s o m e t h i n g -w i l l y o u c a l l t h e m -i t h a p p e n s t o b e m y f a v o r i t e -i a m r e a l l y s o r r y -i e n j o y e d s e e i n g y o u t h a n k s -s o i s m i n e -w h a t d i d h e t h i n k a b o u t t h a t -w h e n i a m t i r e d -t h e s u n i s s h i n i n g -w h e n w i l l i s e e y o u n e x t -a l o n g t i m e a g o -i h a d a r e a l b u s y d a y -p l e a s e c o m e r i g h t b a c k -y o u a r e t h e g r e a t e s t k i d s i n t h e w o r l d -i w o u l d l i k e t o e a t s o m e f i s h -h o w l o n g d o e s i t t a k e -i w a s o n l y j o k i n g -w h a t -i s t i l l h a v e t o -t a l k t o y o u s o o n -i h a v e a l s -e x p l a i n t h a t t o m e -c a n i m u n c h o n s o m e n u t s -w h e n y o u c o m e b a c k -w h o c a l l e d -p l e a s e t u r n o n t h e t v -c a n w e g o t o t h e m o v i e s -y o u k n o w w h a t i m e a n -c a n y o u r e p e a t w h a t y o u j u s t s a i d -y o u c a n s a y t h a t a g a i n -s h o u l d i t a k e i t -i w o u l d l i k e t o h a v e s o m e y o g u r t a n d b e r r i e s -c a n y o u h e l p m e w i t h t h e c o m p u t e r -y o u b e t t e r h u r r y -c a n i h a v e a g l a s s o f w a t e r -i t f e e l s g r e a t -w e n e e d t o t h a v e t h e c o m p u t e r g u y f i x o u r c o m p u t e r -i a m r e a d y t o g o b a c k -y o u k n o w w h a t i t h i n k -i m i s s e d y o u -w h y d i d h e g o t h e r e -t u r n t h a t o n f o r m e -j u s t o n e c o m m e n t -i w a n t t o l a y o n m y s t o m a c h -h o w m u c h w i l l i t c o s t -c a n y o u h e l p m e t a k e a s h o w e r -t h a t w a s t h e v o i c e m a c h i n e t a l k i n g n o t m e -i h a v e a h e a d a c h e -c a n y o u h e l p m e p u t o n s o m e m a k e u p -c a n y o u k e e p m e c o m p a n y f o r a w h i l e -i w a n t t o w a t c h t h e n e w s -w h e n d o w e s t a r t -y o u w a n n a b e t -i w a s j u s t w o n d e r i n g -t h e w e a t h e r i s b e a u t i f u l t o d a y -t h a n k y o u f o r y o u r g o o d w i s h e s -p l e a s e h u r r y u p -i n e e d m o r e i n f o r m a t i o n -c a n y o u h e l p m e w a s h m y h a n d s -i t h i n k w e o u g h t t o d o t h a t -t h a t s j u s t g r e a t -h o w c a n i g e t a h o l d o f y o u -h e l p m e m o v e t h i s -y o u t h i n k s o -d o y o u k n o w h o w m u c h i l o v e y o u -h o w c a n i h e l p y o u -w h a t i s n e x t -b e f o r e y o u g o -i a l w a y s d o -y o u m i s s e d t h e b o a t -b e l i e v e m e i t i s -i j u s t r e m e m b e r e d -p l e a s e s i t d o w n -c a n w e t a l k -f o r a l l i k n o w -i t w o u l d b e m u c h e a s i e r -w o u l d y o u m i n d s p e l l i n g t h a t f o r m e -y o u c o u l d h e l p a l i t t l e -h o w l o n g -i c a n h a n d l e i t -u h h u h -t h i s i s a n t i c i p a t e d -g e t t h e h e l l a w a y f r o m m e -b l o w s t h a t t h e o r y t o h e l l -y o u m i s s e d t h e b o a t -i h a v e s o m e t h i n g t o s a y a b o u t t h a t -w o u l d y o u m a k e a p h o n e c a l l f o r m e -c a n y o u p l e a s e r e p e a t t h a t -y o u a l w a y s m a k e t h e t h i n g s i l i k e t o e a t -t h a n k f o r t h e r i d e -g o o d b y e -w h e n i f e e l l i k e i t -c o u l d i h a v e s o m e -w h a t d o y o u m e a n -i s t i l l h a v e t h e t i m e -t h e m o o n i s s o b r i g h t -w h a t h a v e y o u b e e n d o i n g -f o r a w h i l e -i t w a s s o g o o d -i l o v e i t -i s t h e r e a n y d o u b t -i o n l y e a t v e g e t a b l e s -c a n i h a v e a s p r i t z o f p e r f u m e -i n e e d t o s h a v e m y l e g s -w h e r e i s e v e r y o n e -i g u e s s i b e t t e r b e g o i n g g r e a t t o s e e y o u -t h i s i s a r e q u e s t -y o u k n o w h o w i t g o e s -w h a t c a n i h a v e t o e a t t h i s m o r n i n g -h o w o f t e n d o y o u d o t h i s -y o u a r e t h e b e s t p a r t n e r -g r e a t t o s e e y o u a g a i n -i t i s d i f f e r e n t a l r i g h t -i w a n t t o g e t m y e y e b r o w s w a x e d -i w a n t t o u s e t h e l a p t o p -c a n y o u s a v e t h i s f o r a n o t h e r t i m e -i h a v e a n i t c h o n m y h a n d -i t i s v e r y u n c o m f o r t a b l e w h e n i t c r a m p s -t h e y t o l d m e t h a t -i l i k e i t b e t t e r -w o u l d y o u s t a r t t h e s h o w e r -g i v e m e a b r e a k -h e l p m e o u t -c a n y o u o p e n t h e d o o r t o l e t t h e b r e e z e i n -i f e e l f r i g h t e n e d s o m e t i m e s -y e s y o u c a n -i g e t i t -i s t h e r e s o m e t h i n g w r o n g w i t h t h a t -i g u e s s n o t -d o w e h a v e a n y c h i p s -i c o u l d u s e y o u r i n p u t -a r e y o u c r a z y -i a m s o h a p p y t o b e a p a r t o f t o d a y -p l e a s e t u r n o n t h e h e a t -w o u l d a n y o n e l i k e t o p l a y a g a m e -w o u l d y o u l i k e m e t o h e l p p r e p a r e b r e a k f a s t -t u r n i t d o w n a l i t t l e b i t p l e a s e -s o l o n g -g e t h e l p n o w -m a y b e t h e y d o -l o o k s l i k e i t -n o t u s u a l l y b u t i n t h i s c a s e -i t w a s g o o d o f y o u t o c o m e t o d a y -g e t m e u p e a r l i e r -k i s s m y g r i t s -w h e r e d i d t h a t c o m e f r o m -o h d a r n -b e c a r e f u l -w i l l i t h u r t -i h a v e a s p e e c h p r o b l e m i u s e t h i s m a c h i n e t o t a l k -i s t h a t y o u r r e a l n a m e -i c h a n g e d m y m i n d -i a m n o t h u n g r y -h o w w o u l d y o u l i k e t o c e l e b r a t e o u r a n n i v e r s a r y -c o m e b a c k a g a i n s o o n -y o u c a n t r y b u t i d o u b t i t -i f e e l t h e s a m e w a y a b o u t i t -u n f o r t u n a t e l y y e s -t h e r e w a s s o m e t h i n g e l s e -t h i s w e e k e n d -i h a v e n e w s -w h a t a r e y o u r p l a n s f o r -a r e y o u c r a z y -t h a t w i l l b e f u n -n e v e r m i n d -h a v e a n i c e d a y -p l e a s e g e t o u t o f h e r e -i w i l l a l w a y s b e i n y o u r h e a r t -i t h i n k i m e s s e d i t u p -j u s t f o r a s h o r t w h i l e -c a n y o u d r y m y h a i r -u n b e l i e v a b l e -h a p p y a n n i v e r s a r y -t h i s i l l n e s s c a n r e a l l y g e t y o u d o w n -g i v e m e t h a t p l e a s e -d i d y o u k n o w t h a t -w h a t d o y o u t h i n k -n o w i t i s -h o w l o n g w i l l y o u b e g o n e -a r e y o u w o r k i n g t o m o r r o w -i w a n t o n e -i l i k e t o l i s t e n t o m u s i c -p l e a s e s c r a t c h l e f t -i h a d a g r e a t d a y -i w o u l d l i k e t o l i s t e n t o s o m e m u s i c -h o w a r e t h i n g s g o i n g f o r y o u -i n e e d s u c t i o n -i l i k e a l o t o f d i f f e r e n t s p o r t s -a r e y o u d o i n g a n y t h i n g s p e c i a l t o n i g h t -i r e a l l y l i k e t o t a l k a b o u t i t -y o u k n o w h o w i t w o r k s -i h o p e y o u c e l e b r a t e m a n y m o r e -c a n y o u h e l p m e r e o r d e r c h e c k s f o r m y c h e c k i n g a c c o u n t -i h a v e a l w a y s l i k e d t h a t p l a c e -h u h -i l i k e t o h a v e c o m p a n y c o m e t o v i s i t -w h a t k i n d i s i t -i w i l l t a l k t o y o u s o o n -w h o a r e y o u l o o k i n g f o r -l e a v e m e a l o n e -w h a t a b o u t y o u -w h a t d o y o u t h i n k a b o u t t h a t -p l e a s e s c r a t c h s o f t e r -c h a n g e t h e s u b j e c t -o n c e a g a i n -i u n d e r s t a n d t h a t -p l e a s e s t o p -f i n e h o w a r e y o u -c a n y o u h e l p m e b a l a n c e t h e a c c o u n t -i w a s s u p p o s e d t o -i h o p e e v e r y t h i n g t u r n s o u t o k a y -w h a t w e r e t h e y -d i d y o u h a v e a g o o d w e e k e n d -i t d e p e n d s -a l i t t l e m o r e -s t o p f i d g e t i n g -h o w h a v e y o u b e e n -w h a t a r e y o u t a l k i n g a b o u t -i h a d t o g o b a c k -s o m e t i m e s i f e e l n o o n e c a n u n d e r s t a n d w h a t i t f e e l s l i k e t o g o t h r o u g h t h i s -w h a t d o y o u t h i n k w e s h o u l d d o -i r e a l l y t r i e d -w h y d i d y o u g e t s o m a n y -e x c u s e m e -h a v e y o u e v e r h a d t h o s e -w h o a r e y o u t a l k i n g a b o u t -w o u l d y o u l i k e t o g o s o m e w h e r e o v e r n i g h t -p l e a s e m a k e t h i s a g a i n -w h a t a r e y o u t r y i n g t o d o -t h a t d e p e n d s -w h a t h a p p e n e d t h i s t i m e -i t c e r t a i n l y d i d -w o u l d y o u b e w i l l i n g t o t r y t o g o o n a v a c a t i o n -i n e v e r l i k e d t h e m -h o w n i c e t o m e e t y o u -s w e e t d r e a m s -h o w l o n g d o y o u t h i n k i t w i l l b e -i t h i n k y o u a r e w r o n g -t e l l m e a b o u t i t -w h o w a s t h a t -h o w i s y o u r d a y g o i n g s o f a r -c a n y o u h e l p t o c l e a n u p -w h e r e a r e t h e y -h o w c o m e -s h o w m e -i t h o u g h t s o -i c a m e e a r l y -t h a n k y o u -c a n y o u m a k e m e s o m e t h i n g t o e a t -c a n y o u h e l p m e m a k e a d e p o s i t i n t h e b a n k -b o n v o y a g e -a r e y o u d o i n g a n y t h i n g s p e c i a l t h i s w e e k e n d -t h a n k s f o r s t o p p i n g b y -i l i k e t o l o o k a t t h e s u n s e t -n o l o n g e r -i n e e d t o c a r e f o r f e m i n i n e i s s u e s c a n y o u h e l p m e -c o n g r a t u l a t i o n s o n y o u r g r a d u a t i o n -i t h i n k t h i s t a s t e s s o g o o d -t h a t w a s f u n -h a v e a s a f e f l i g h t -h o n e s t t o g o o d n e s s -i h a v e t h e c h o i c e -h e y g o o d l o o k i n g -d o y o u r e a l l y t h i n k s o -h o w a b o u t t h a t -w h e r e a r e y o u s i t t i n g -i k n o w i t -w h a t a r e y o u t r y i n g t o t e l l m e -w i l l y o u s t a y f o r a w h i l e -p l e a s e g e t m e s o m e -i r e a l l y n e e d t o t a l k w i t h y o u -i l i k e t o w a t c h s p o r t s o n t v -i f e e l l i k e i t -i w o u l d l i k e t o h a v e v e g e t a b l e s w i t h m y d i n n e r -m a y i g o w i t h y o u -i l i k e t h a t p l a c e -i w i s h t h e m l u c k -i n e e d a h a i r c u t -w o u l d y o u l i k e t o t a k e a r o a d t r i p -w h a t t i m e t o m o r r o w -p l e a s e b u t t o n m y s h i r t -g o o d n i g h t i h o p e y o u s l e e p w e l l -i m i g h t a s w e l l -i t s o u n d s g o o d t o m e -p l e a s e k e e p i n m i n d i a m a p e r s o n -t h a n k y o u f o r v i s i t i n g w i t h m e -o f c o u r s e -d o y o u l i k e y o u r k i n d o f w o r k -i a m g e t t i n g a l o n g -i s t h a t o k a y -w h a t a r e y o u w a i t i n g f o r -m o s t o f t h e t i m e -w e a r e s o g o o d t o g e t h e r -w i s h m e l u c k -o n e m o r e m i n u t e -w h a t a b e a u t i f u l d a y -c a n i g e t a n e w o n e -i n e e d t h e u r i n a l -i t i s t i m e t o l e a v e -w h a t t o o k y o u s o l o n g -p l e a s e r u b m y n e c k -i g e t s c a r e d t o d e a t h -i w o u l d l i k e t o h a v e s o m e r a i s i n s -i t w i l l b e a w h i l e -w e o u g h t t o t r y t h a t -i l i k e t o w a t c h t v -c a n y o u m a i l t h i s l e t t e r f o r m e -w i s h m e l u c k -y o u a r e m y b e s t f r i e n d -i t s t i l l h u r t s -h a v e a g r e a t t r i p -i t o l d y o u -i w a n t t o t a k e a s h o w e r -i h a v e s o m e t h i n g t o s a y a b o u t t h a t -i f e e l a n g r y -w h a t a r e y o u t r y i n g t o s a y t o m e -j u s t g r e a t -b o y a m i g l a d -p l e a s e h e l p m e g e t d r e s s e d -i a m n o t h u n g r y a n y m o r e -a m a z i n g -t o o f u n n y -i n e e d a p i l l o w -w h a t a m i d o i n g -w h a t a r e y o u g o i n g t o d o t o d a y -h o w d i d y o u k n o w -w h a t h a p p e n e d t o y o u -c a n y o u m a k e a n a d j u s t m e n t -w i l l y o u p r e p a r e d i n n e r -b o y d o i e v e r k n o w -h e l p m e p u t t h i s o n -i n e e d t h e b e d p a n -y o u a r e i n c o r r e c t -c a n y o u t u r n m e o v e r -i w o u l d l i k e y o u t o s e e y o u -i c a n h e a r a n d u n d e r s t a n d e v e r y t h i n g t h a t y o u a r e s a y i n g -i t t a k e s t i m e -h o w m u c h -i l i k e t h a t -a r e t h e y a n y g o o d -d i d y o u g e t m y m e s s a g e -b e f o r e i g o o n -c a n y o u t h i n k o f s o m e t h i n g i c a n d o t o k e e p b u s y -p l e a s e r u b m y s h o u l d e r s -y o u l o o k g r e a t -w h e r e w o u l d y o u l i k e t o g o f o r d i n n e r -w h i c h o n e i s i t -c o u l d b e b e t t e r -j u s t a m i n u t e l e t m e t h i n k a b o u t t h a t -d o y o u r e a l l y t h i n k s o -c h a n g e t h e s u b j e c t p l e a s e -l e t m e t e l l y o u w h a t i d i d -i k n o w -t h a n k y o u f o r b e i n g s u c h a g o o d f r i e n d -i l o v e y o u -w h a t c a n w e d o t o m a k e y o u r d a y m o r e s p e c i a l -a r e y o u o u t o f y o u r m i n d -c a n y o u t a k e m e t o t h e s t o r e -i a m l u c k y t o h a v e y o u i n m y l i f e -t e l l m e w h a t y o u t h i n k i s a i d -u n f o r t u n a t e l y -w e w i l l d o i t t o m o r r o w -i l o o k f o r w a r d t o t a l k i n g t o y o u s o o n -i h a v e a n i t c h o n m y b a c k -i w o u l d l i k e t o h a v e a s o d a -i w a n t a k i s s -m a y b e t o m o r r o w -i w i s h i c o u l d c h a n g e t h i n g s f o r y o u -p o o r t h i n g -i w a n t t o d o i t -a c c e p t m y d e c i s i o n -i d o n o t h a v e a n y i d e a -w h e n i t a l l t o o k p l a c e -s h o u l d i b e c o n c e r n e d -i l i k e t o g o t h e r e -y o u a r e t h e b e s t -t h i s i s a n e m e r g e n c y -s t o p t a l k i n g -h a v e a n i c e d a y -i c o u l d p r o b a b l y d o b e t t e r -i w a n t t o t a l k t o y o u a b o u t i t -c a n y o u w a s h m y h a i r -c a n y o u b e a t t h a t -d o e s a n y o n e w a n t t o g o f o r a w a l k -a r e y o u w o r k i n g t o d a y -d i d i t h u r t -i t w a s w r o n g -y o u k n o w w h a t i t t a k e s -y e s i a g r e e -i n e e d s o m e t o o t h p a s t e -t h e s t a r s a r e t w i n k l i n g -h o w a b o u t i f w e l i g h t a f i r e i n t h e f i r e p l a c e -w h a t d o y o u t h i n k o f i t -y o u c o u l d h a v e h e l p e d o u t a l i t t l e b i t -i w o u l d l i k e a p i e c e o f c a k e -i f i c o u l d i w o u l d -i g u e s s s o -i g a v e m y f i n a l w o r d -g i v e m e a c a l l i n a f e w m i n u t e s -w h a t t i m e a m i g o i n g -w e l l i t s u r e f e e l s l i k e i t -i n e e d m y h a i r t o b e e a s y t o c a r e f o r -w h a t d i d i t s a y -t h i s i s a n e m e r g e n c y -i n e e d t o h a v e m y w h e e l c h a i r f i x e d -t r y n o t t o b e s o s a d -w o u l d y o u g e t t h e m a i l -s t o p t h a t -a r e y o u s e r i o u s -t e l l m e m o r e -t h a t w o u l d b e a b i g h e l p -w h a t c a n i d o -i t l o o k s l i k e a b l i z z a r d -i t m i g h t w o r k -w h o s e t u r n i s i t t o w a l k t h e d o g -i f o r g o t a b o u t t h a t -c a n i t a k e t h e s e w i t h m e -d o y o u k n o w w h e r e i a m g o i n g -w h o s a i d s o -h e l p m e p u t o n m y p a n t s -g o b a c k t o b e d -a r e y o u b u s y -n o j o k e -p l e a s e l e t m e f i n i s h -c a n i h e l p -w h a t i s i t n o w -i w o u l d l i k e t o l i s t e n t o t h e i p o d -w h e n d o w e e a t -h o w d o y o u f e e l -i n e e d t o l a y d o w n -p u t t h a t h e r e -w h y n o t -h a p p y c h a n u k a h -p r e t t y u g l y h u h -i f o u n d i t -t h a t w o u l d b e g r e a t -w e a r e g o i n g -g o o d m o r n i n g -c a n w e p l a n o u r d a y f o r t o m o r r o w -w h a t a r e m y c h o i c e s -k e e p g o i n g -i a m f u l l -h e l l o -c a n i h a v e s o m e c h i p s a n d d i p -i w a n t t o s t a y -w a i t a m i n u t e -w h e r e d i d i g e t t h i s -i w a s o n t i m e -i g o t o w o r k -i w a s j u s t c u r i o u s -a t l u n c h -w h a t g r a d e a r e y o u i n -n o t m u c h a t a l l -w h a t d o y o u t h i n k i s a i d -s u r e i a m -i w a n t t o t r y a n e w h a i r c u t -w h a t o t h e r c h o i c e d o i h a v e -i g u e s s -m a y i s a y s o m e t h i n g -o n e n e v e r k n o w s -i h a t e t h i s l i f e -t h a n k s a l o t i t r e a l l y h e l p s -a l i t t l e b e t t e r -y o u a r e w r o n g a b o u t t h a t -f u c k l o u g e h r i g -w h o s a i d s o -a r e y o u s e r i o u s -w h e n -i t h i n k i a m r e a d y t o g o -h a n d m e t h a t p l e a s e -i s t h a t r i g h t -i u s e t h i s m a c h i n e t o c o m m u n i c a t e -i a m n o t h u n g r y a t a l l -w h a t a j e r k -h a v e a h e a r t -i w o u l d l i k e s o m e m o r e -i t t a k e s m e a l i t t l e l o n g e r t o a n s w e r p l e a s e b e p a t i e n t -p l e a s e h e l p m e w a s h m y h a n d s -w h e n y o u a r e h e r e -y o u m i s u n d e r s t o o d m e -w i l l i s e e y o u l a t e r -t u r n i t o v e r -a b s o l u t e l y r i d i c u l o u s -w h a t e l s e a m i g o i n g t o d o -w e h a v e t o w a i t -i a m s t a r v i n g -i f e e l t e r r i b l e t h a t y o u a r e s o u n h a p p y -w h a t s h o u l d w e d o f o r y o u r b i r t h d a y -i h a d a g r e a t t i m e -g o o d i d e a -p l e a s e h e l p m e w a s h m y f a c e -i t s o u n d s c o n f u s i n g -i h o p e i t i s -i a g r e e -i l o v e t o e a t t h i s -a r e w e s t i l l g o i n g -a n y t h i n g i c a n d o -t e l l m e a b o u t y o u r f a m i l y -i n e v e r l i k e d t h a t -i w a n t i t t o b e o v e r -e x c u s e m e f o r i n t e r r u p t i n g -i w a s o n l y k i d d i n g -o h m y g o d -t o o e a r l y f o r m e -c a n y o u u n d e r s t a n d m e o k a y -w h e n y o u g e t b a c k -g o o d b y e -c a n y o u t u r n o n t h e c o m p u t e r -y o u m e a n s o m u c h t o m e -c a n y o u w a i t a m o m e n t -i n e e d s o m e t h i n g t o d r i n k -w h o s e i s t h a t -i w a n t t o g o o n a d a t e -h i -i w o r r y a b o u t t h a t -c h a n g e t h e s u b j e c t -p l e a s e g e t m e m y c a s e -i w o u l d l i k e t o h a v e s o m e d e s s e r t n o w -p l e a s e b r u s h m y h a i r -a s s o o n a s p o s s i b l e -y o u m a k e m e s o h a p p y -o o o o p s -p l e a s e h e l p m e g e t r e a d y -t e l l m e w h a t y o u a r e d o i n g -i h e a r y o u -i w o u l d l i k e t o g o f o r a w a l k -w h e r e i s t h e r e s t r o o m -t h a t w a s n i c e o f h i m -i h a v e a g r e a t i d e a -p l e a s e c h a n g e t h e p o s i t i o n o f t h e b e d -t a l k t o y o u l a t e r -e a s y f o r y o u t o s a y -g o o d n i g h t i l o v e y o u -a r e y o u g e t t i n g r e a d y f o r b e d -i n e e d a t i s s u e -h o w m a n y m o r e -i t f e e l s g o o d -c a n y o u m a k e i t w a r m e r i n h e r e -i r e a l l y f e e l i c a n d o i t -h o w m u c h i s i t -g o o d n i g h t -i l i k e h e r -a l s s u c k s -y o u k n o w t h a t -w i l l y o u s t a y a n d h e l p m e f i n i s h t h i s -i h a t e i t -w i l l i s e e y o u n e x t -w o u l d y o u u n l o a d t h e g r o c e r i e s f r o m t h e c a r -i n e e d h e l p n o w -i t h i n k i a l r e a d y s a i d t h a t -i w o u l d l i k e t o g e t a m a n i c u r e -w h a t w e r e y o u t h i n k i n g -l e t m e s e e i t -p l e a s e o p e n t h e w i n d o w a n d g e t s o m e a i r i n h e r e -c a n y o u p l e a s e h o l d o n f o r a m o m e n t i h a v e a n o t h e r c a l l -i a g r e e w i t h y o u -w h e n w i l l y o u b e b a c k -i w i s h i c o u l d h e l p y o u -c a n y o u h e l p m e s h a v e -w e l l a n y t h i n g i s p o s s i b l e -w a i t f o r t h e r e s t o f t h e m -h a v e a g o o d d a y -y e s i c a n -i h a v e t o g e t g o i n g -i g o t h e o p p o s i t e w a y -i f e e l l i k e e a t i n g s o m e t h i n g s w e e t -i n e e d h e l p m o v i n g t h i s -i t h a s t o b e -g i v e m e a b r e a k -g o o d b y e -h o w a r e y o u d o i n g -d i d y o u h a v e a g o o d w e e k e n d -o v e r a n d o u t -w h a t h a p p e n e d a t s c h o o l t o d a y -w h a t d i d y o u d o o n t h e w e e k e n d -i w a n t t o h u r r y u p -i w o u l d l i k e t o h a v e s o m e b l u e b e r r i e s -i w i l l a l w a y s b e w i t h y o u -i l i k e t o w a t c h t h e c o o k i n g s h o w s o n t v -i k n o w t h a t -j u s t l o o k i n g -i n o t i c e d i t -e x c u s e m e m a y i s a y s o m e t h i n g -w e h a v e t o s t o p -c a n y o u b r i n g m e m y i p o d -i w o u l d l i k e t o c o m b m y h a i r -p l e a s e t a k e m e o u t s i d e -i w a s s o g l a d -h o w f a s t -w h a t e l s e i s o n -m u c h o b l i g e d -d o y o u k n o w w h y -c a n y o u h e l p m e g e t r e a d y f o r b e d -i a g r e e w i t h t h a t -i h a t e t o e a t t h i s -i m i s s t h e r h y t h m o f c o n v e r s a t i o n -t o o m u c h f o r m e -i f e e l s o r r y a b o u t t h a t -t a k e y o u r t i m e h a v e s o m e p a t i e n c e -p l e a s e c l o s e t h e w i n d o w -w h e r e d o y o u l i v e -a b s o l u t e l y n o t -t h a n k s f o r c a l l i n g -d o y o u t h i n k s o -h o w m u c h m o r e i s t h e r e -i w o u l d l i k e t o g e t a p e d i c u r e -w o u l d y o u l i k e t o w a t c h s o m e t v -p l e a s e f i n d o u t -p l e a s e p u t a p i l l o w b e t w e e n m y k n e e s -w h a t a r e y o u h e r e f o r -i w o n d e r i f i c o u l d g e t a n e w o n e -w h a t t i m e i s i t -i c a n u n d e r s t a n d y o u i t j u s t t a k e s m e l o n g e r t o a n s w e r -i w i l l t r y h a r d e r -t h a n k s f o r t e l l i n g m e -a s m u c h a s i c a n -i t w o u l d b e e a s i e r f o r y o u -w h a t h a v e y o u b e e n u p t o -p l e a s e c u t m y f i n g e r n a i l s -h o w d o y o u f e e l a b o u t t h a t -w h e n y o u f i n d o u t l e t m e k n o w -i w a n t t o b e t o g e t h e r f o r e v e r -c a n y o u h e l p m e -d o y o u w a n t m e t o h e l p y o u w i t h y o u r h o m e w o r k -d o y o u m i n d -i t h o u g h t y o u w e r e g o i n g t o -t h a t h e l p e d t r e m e n d o u s l y -g o o d -i t j u s t s e e m s t o a l w a y s h a p p e n l i k e t h a t -w h a t a r e y o u d o i n g t h i s s u m m e r -i m i s s h o l d i n g y o u -w h a t t i m e w o u l d i b e d o n e -w h o i s g o i n g t o d o t h e g r o c e r y s h o p p i n g -w h o s e t u r n i s i t t o w a s h t h e d i s h e s -i w o u l d r e a l l y l i k e t o d i s c u s s t h a t f u r t h e r -i h o p e t o s e e y o u a g a i n -i w a n t t o t a k e a b a t h -w h e r e s h o u l d i g o -p l e a s e s c r a t c h l o w e r -c o u l d y o u r e p e a t w h a t y o u s a i d -i c o u l d n e v e r h a v e d o n e t h i s w i t h o u t y o u r h e l p a n d s u p p o r t -i w o u l d l i k e t o h a v e a n o r a n g e -i a m s o g r a t e f u l f o r a l l y o u h a v e d o n e f o r m e -t h a n k y o u f o r h e l p i n g m e -i n a w h i l e -a r a t h e r r e f r e s h i n g o u t l o o k -s o m e t i m e s i j u s t n e e d t o f e e l s o r r y f o r m y s e l f -t h e r e u s e d t o b e -h o w a r e y o u -w h o p r o g r a m m e d t h i s v o i c e m a c h i n e -e i t h e r t o d a y o r t o m o r r o w -h o w c o u l d i b e m a d a t y o u -w h a t d i d y o u s a y -i w a n t t o l a y o n m y b a c k -s o m e o n e h e l p m e -i w a n t a h u g -w h a t d i d y o u d o t h i s m o r n i n g -c a n i h a v e s o m e m o r e p l e a s e -t h e r e i s n o r a i n i n t h e f o r e c a s t -d i d y o u u n d e r s t a n d -p l e a s e r e m o v e t h a t -i w o u l d l i k e t o h a v e a s n a c k -y o u r h e l p m e a n s s o m u c h t o m e -i c e r t a i n l y d i d -y e s i c a n d o t h a t -d o e s a n y o n e h a v e a n y i d e a s -p l e a s e t u r n o f f t h e l i g h t s -c o n g r a t u l a t i o n s o n y o u r w e d d i n g -i t h a p p e n e d a g a i n -i t i s v e r y i m p o r t a n t -f u c k a l s -i t i s j u s t y o u r i m a g i n a t i o n -i w o u l d l i k e t o p u t o n m y p a j a m a s -h a p p y n e w y e a r -d i d y o u d o t h e l a u n d r y -h e c a n d o i t -i t m i g h t w o r k o u t -i w o u l d l i k e t o h a v e d e s s e r t l a t e r -p l e a s e s c r a t c h r i g h t -y o u h e l p e d a l o t -i w o u l d l i k e t o b r u s h m y t e e t h -i n e e d s o m e h e l p w i t h t r y i n g t o f i g u r e o u t t h e b e s t w a y t o c o m m u n i c a t e w i t h y o u -p l e a s e m o v e t h e p i l l o w u n d e r m y n e c k -h e j u s t t o o k o f f -i r e a l l y l i k e c h o c o l a t e -h o w d o y o u k n o w -h o w d o t h e y k n o w -g e t h e l p n o w -p u t t h a t t h e r e -i n e e d h e l p p u t t i n g o n m y s h o e s -a c c e p t m y d e c i s i o n -w h a t i s y o u r s c h e d u l e -i w a s d o i n g e x c e l l e n t -i k n o w i t -m a y b e l a t e r -g i v e m e s o m e -a s c a r e f u l a s y o u c a n -w h a t a t u r k e y -h i h o w a r e t h i n g s g o i n g -i a g r e e -c a n i h a v e a s p o o n -i t m a k e s m e v e r y n e r v o u s -i r e a l l y g e t o f f o n i t -t h a t i s n o t f a i r -n o k i d d i n g -t h a n k y o u a n y w a y -t h e l a s t t i m e -i n e e d t o r e s t -n o i m e a n t s o m e t h i n g e l s e -a r e y o u r e a d y -w o w -c a n i h a v e s o m e m a y o n n a i s e o n m y s a n d w i c h -m a y i h a v e s o m e t h i n g t o e a t -m a k e s m e m a d -w h a t i s t h e n e x t p l a n -c a n y o u r e a d m e a b o o k -i f e e l g r e a t -t h i s i s d e l i c i o u s -s t o p w h a t y o u a r e d o i n g -i s u p p o s e s o -l e t m e a n s w e r -i w a s o n l y j o k i n g -w h a t c a u s e d i t -i n m y r o o m -t h i s i s d i f f e r e n t -i d i d o k a y -p e r f e c t -w i l l y o u b e t h e r e -c a n y o u p l e a s e p i c k t h a t u p o f f t h e f l o o r i d r o p p e d i t b y a c c i d e n t -d o y o u h a v e a n y b r o t h e r s o r s i s t e r s -i h o p e y o u f e e l b e t t e r -w o u l d y o u u n p a c k t h e g r o c e r i e s -y o u a r e t h e m o s t s p e c i a l p e r s o n -a n o t h e r r a i n y d a y -w h a t d i d y o u d o l a s t n i g h t -p l e a s e t u r n t h a t o f f f o r m e -h o w a b o u t y o u -r e a l l y -c a n i d o t h i s -i s h o u l d e x p l a i n -h i m o m -i t h i n k t h a t i s c o r r e c t -t e l l m e h o w m u c h -t h a n k s f o r v i s i t i n g -g o o d n i g h t -i b l e w i t -i n e e d a n a p p o i n t m e n t t o h a v e m y e y e b r o w s w a x e d -w o u l d y o u l i k e t o t r y t h e n e w r e s t a u r a n t -w h a t d o y o u t h i n k o f m y a r t i f i c i a l v o i c e -i w a s g o i n g t o -i n e e d a b l a n k e t -e x c e e d i n g l y g o o d -i w a s s o d i s a p p o i n t e d -w o u l d y o u m i n d i f i t a l k w h i l e y o u a r e i n t e r r u p t i n g -w h e n a r e w e l e a v i n g -i t h o u g h t i w a s t h r o u g h -i d i s a g r e e -j u s t p l a i n g o o d -a l o t o f f u n -i a m s o r r y -i a m s o h u n g r y -l a t e a t n i g h t -c a n y o u h e l p m e w i t h m y b r e a k f a s t -c a n i t a l k t o y o u -i w a n t t o k n o w w h a t i t i s -w h a t t i m e d o y o u t h i n k -h o w d o y o u k n o w -h o w l o n g a g o -i w a n t t o k n o w -b u t g u e s s w h a t -s s s s s s i w a s t r y i n g t o w h i s t l e -i w o u l d l i k e t o h e l p i f i c a n -i a m g e t t i n g i t -s e e y o u s o o n -i w o u l d r a t h e r e a t t h a t -i h a v e a n i t c h o n m y f o o t -w h a t t i m e w i l l y o u b e h o m e -i l i k e t o s e e t h e w a v e s i n t h e o c e a n -h o w a r e y o u -g o o d n i g h t s l e e p t i g h t -y o u g o a h e a d -p e r f e c t l y s a i d -h e l p -i d o u b t t h a t -i w i l l c a l l y o u s o o n -i w o n -d o i t -g e t o u t o f h e r e -a n o t h e r g o r g e o u s d a y -c e r t a i n l y -o h m y g o d -w h e n a r e t h e y c o m i n g -y o u a r e k i d d i n g -i t s o u n d s g o o d -y o u k n o w w h a t i t h i n k -p l e a s e t u r n o f f t h e t v -y o u h e l p e d a l o t -i s t h a t a y e s o r n o -i w o u l d l i k e s o m e p r e t z e l s -t h a n k t h e l o r d f o r s m a l l f a v o r s -i w i l l n o t -t h a t i s a m a z i n g -i a m t i r e d -w e w i l l g e t t o i t t o m o r r o w -c o n g r a t u l a t i o n s o n y o u r e n g a g e m e n t -y o u c a n d o i t -s a y t h a t a g a i n i m i s s e d t h a t -i h a v e n o t i c e d t h a t -i a m h e r e f o r y o u -w o u l d y o u l i k e t o g o t o s e e a s h o w -a r e y o u k i d d i n g m e -i t r u s t y o u -w o u l d y o u d o i t -j u s t a n s w e r m y q u e s t i o n -c a n y o u h e l p t o p u t t h e g r o c e r i e s a w a y -w h y a r e y o u c o n c e r n e d -w h a t d i d y o u c o m e w i t h -w h a t k i n d o f j o b d o y o u h a v e -h o w o l d a r e y o u -o v e r a n d o v e r a g a i n -i l i k e t o d o i t -m e r r y c h r i s t m a s -i m a y n o t g o b a c k -i t c o u l d h a v e b e e n -h o w d i d i t t u r n o u t -c a n y o u h e l p m e g e t d r i e d o f f -i g o o f e d -w o u l d y o u c o m e -i w i s h i k n e w w h a t h a p p e n e d -i s a i d i w a s -p l e a s e s c r a t c h h i g h e r -t h a t h u r t s -w h a t d o y o u h a v e t o d o -y o u a r e r i g h t a b o u t t h a t -y e a h -i w a n t t o g o -b e v e r y v e r y c a r e f u l -c a n i h a v e s o m e c h i c k e n -p l e a s e u s e c o n d i t i o n e r t o t a k e o u t t h e t a n g l e s i n m y h a i r -a r e y o u a l o n e -w h e r e d o y o u g o t o s c h o o l -e x p l a i n t h a t t o m e -w h e r e a r e y o u g o i n g -i l i k e t o g o o n c a r r i d e s -i w i l l s t i l l n e e d i t -a r e y o u g o i n g t o p r e p a r e d i n n e r -s o r r y t o k e e p p o p p i n g o f f -p l e a s e g e t m e m y h a n d b a g -i n e e d i t -h o w d a r e y o u -t h e r e w a s s o m e t h i n g e l s e i w a s g o i n g t o a s k y o u -i a m s o r r y t o h e a r t h a t -d i d y o u p a c k y o u r l u n c h -p l e a s e t u r n o n t h e l i g h t s -c a n y o u g e t t h e n e w s p a p e r f r o m o u t s i d e -i w a n t t o s h a r e t h e r e s t o f m y l i f e w i t h y o u -w h y w h a t d o y o u m e a n -n o b o d y t o l d m e t h a t -w h a t t h e n -d o y o u a g r e e -w i l l i b e a b l e t o g e t a n o t h e r o n e -p l e a s e w a i t f o r m e -d o y o u k n o w w h a t h e d i d -c a n i h a v e a t i s s u e -w h y a r e y o u h e r e -c o n g r a t u l a t i o n s o n t h e b i r t h o f y o u r b a b y -l e t m e s e e t h a t -p l e a s e w a i t u n t i l i f i n i s h w h a t i a m t r y i n g t o s a y -a r e y o u c o m i n g -h o w l o n g h a v e y o u b e e n d o i n g t h a t -h o w a b o u t i t -i t f r u s t r a t e s m e -i w i l l -w h e r e i s i t -i n e e d y o u t o c a l l t h e n u r s e -o k a y -i n e e d s o m e s o a p i n t h e s h o w e r -t h a t s h o u l d b e h a r d t o d o -i r e f u s e t h a t t r e a t m e n t -a l s a f f e c t s t h e b o d y i t d o e s n o t a f f e c t t h e m i n d -w h e r e w e r e y o u -p l e a s e b r u s h m y t e e t h -g u e s s w h a t i w a n t -d o y o u n e e d m e t o h e l p y o u -a r e y o u l i s t e n i n g t o m e -d o y o u n e e d m y h e l p t o e x p l a i n t h i s -o k a y g o r g e o u s -w e n e e d t o t a l k -i r e a l l y t r i e d -t a k e t h a t a w a y f r o m m e -p l e a s e t e l l m e i f y o u u n d e r s t a n d w h a t i a m s a y i n g -w a i t u n t i l t o m o r r o w -i r e m e m b e r t h e l a s t t i m e i d i d t h a t -p l e a s e t u r n t h a t o n f o r m e -t h a t m a k e s m e m a d -b o y t h a t f e e l s g o o d -i t s h o o k m e u p -y o u w i l l l o v e i t -h o w f a b u l o u s f o r y o u -i t i s n o t t h a t i m p o r t a n t -i w o u l d l i k e t o e a t a s t e a k -y o u a r e n o t g o i n g t o b e l i e v e t h i s -i a m d o i n g i t -i a m h a v i n g t h e b e s t t i m e -i r e a l l y l i k e t o e a t p i z z a -c a n i u s e i t -p l e a s e h e l p m e m o v e o v e r t h e r e -i a m a l l e r g i c t o i t -i h a v e a n i t c h -p l e a s e r e a d t o m e -t h a n k y o u f o r y o u r t h o u g h t f u l n e s s -e v e r y t h i n g b o t h e r s m e -c a n y o u p l a y a d v d -r e a d y a n d w a i t i n g -i w o u l d l i k e s o m e i c e c r e a m -i w a n t t o g o w i t h y o u -n o t v e r y g o o d t o d a y -w h e n w i l l y o u c o m e b a c k -w h a t t i m e -a l r i g h t -c o m e b a c k l a t e r -c o m e t a l k w i t h m e -m y f e e t a r e c o l d c a n i h a v e m y s l i p p e r s -w h y n o t d o i t r i g h t a w a y -w o u l d y o u h e l p m e w i t h m y c l o t h i n g p l e a s e -i s t h e r e a n y p i e -w o u l d y o u l i k e t o g o o u t s i d e -w h e n i w a n t t o -c a n i h a v e s o m e t h i n g e l s e -i n e e d m y g l a s s e s -y o u a r e a m a z i n g -p l e a s e s c r a t c h h a r d e r -c a n y o u c u t t h e f o o d f o r m e -c o m e r i g h t b a c k p l e a s e -i c a n f o l d t h e l a u n d r y -b o y a m i g l a d y o u c a l l e d -i w a n t t o t h a n k y o u -i w a s t r y i n g t o t e l l y o u -g o o d t o s e e y o u -i t w o r k s o n c e i n a w h i l e -h o w l o n g w i l l i t t a k e -i g r e a t l y a p p r e c i a t e t h a t -i w o u l d l i k e t o h a v e a b a n a n a -g o o d m o r n i n g -i l i k e t o g o t o t h e m o v i e s -j u s t t e l l t h e t r u t h -y o u h a v e t o w a i t -p a r d o n m e -i w a s j u s t k i d d i n g -h a p p y b i r t h d a y -a n o t h e r s n o w y d a y -w h e r e c a n i g e t i t -y o u c a n g i v e i t t o m e -n o t s o g o o d t o d a y -w h a t d o y o u w a n t t o d o -g r a b o n e o f t h o s e -b r i n g t h a t o v e r h e r e -a s u s u a l -p l e a s e k e e p i n m i n d i a m a h u m a n b e i n g -w a i t l e t m e r e p h r a s e t h a t -d i d y o u s t r a i g h t e n u p y o u r r o o m -s i t o v e r h e r e w i t h m e -m y f e e t a r e c o l d c a n i h a v e m y s l i p p e r s -d i d y o u s t a r t y o u r h o m e w o r k -y o u m a k e m y l i f e b e t t e r -i s u r e h o p e s o -i n e e d t o s h a v e -s o m e t h i n g l i k e t h a t -c a n y o u h e l p m e p l e a s e -d i d y o u s e e t h a t -h a v e a s a f e t r i p -w h a t d o e s t h a t m e a n -d o y o u u n d e r s t a n d m e -y o u r f r i e n d s h i p m e a n s s o m u c h t o m e -y o u c a n c o m e u p w i t h s o m e t h i n g b e t t e r -w h e r e -c o u l d y o u r e p e a t t h a t -c a n y o u t u r n t h e t v o n -i t h a p p e n s e v e r y s o o f t e n -a r e y o u p i c k i n g u p t h e k i d s -a r e y o u c o m f o r t a b l e -h o w f a s t c a n y o u d o i t -d o y o u u n d e r s t a n d m e -w h e n d o w e g o -c a n y o u h e l p m e w r i t e a c h e c k -s e e y o u l a t e r -i m i s s t a l k i n g t o y o u -w h a t c o u l d p o s s i b l y h a p p e n -i t h i n k h e i s k i n d o f c u t e -i t t o o k s o l o n g -s o m e t i m e s i j u s t w a n t t o s c r e a m -i a m g o i n g h o m e -t h a n k s a l o t -i t h i n k t h a t i s u n n e c e s s a r y -t h a t m a k e s m e l a u g h -i w o u l d l i k e t o h a v e a n i c e d t e a -i w a n t t o e a t n o w -i t h i n k i d i d i t w r o n g -a l l t h e t i m e -d o y o u k n o w w h e r e w e a r e g o i n g -i r e a l l y t r y -d o i t e l l y o u o f t e n e n o u g h h o w m u c h i l o v e y o u -c o m e t o t h i n k a b o u t i t -g u e s s w h a t -y o u a r e w r o n g -i c a n h e a r a n d u n d e r s t a n d e v e r y t h i n g y o u s a y -m a y i t a k e a m e s s a g e -h o w w i l l y o u k n o w -w h a t w e r e y o u a b o u t t o t e l l m e -m a y b e m a y b e n o t -p u t t h a t d o w n -w h e n i g e t d o n e -m a y i i n t e r r u p t y o u -i t r e a l l y h e l p s -d o y o u w a n t t h a t -i r e a l l y l i k e i t -t e l l m e a b o u t y o u r s e l f -y o u w o r k a l o t -i t h i n k y o u a r e r i g h t -w a n t t o b e t -i w o u l d l i k e a s a l a d -o u c h -o n c e i n a w h i l e -s o o n e r o r l a t e r -t h a n k y o u f o r j u s t b e i n g y o u -i g e t c r a n k y -w h a t a r e y o u g o i n g t o d o t o n i g h t -h o w w a s y o u r d a y a t s c h o o l -y o u b e t t e r c o m e -c o u l d y o u s p e a k u p a l i t t l e -c a l l m e b a c k w h e n y o u c a n -w i l l y o u d o m e a f a v o r -w h a t k i n d o f c o o k i e s d o y o u h a v e -l e t m e t e l l y o u w h y i w a s u p s e t -l i s t e n t o t h a t -a h n o w i s e e -c a n y o u d o i t r e a l q u i c k -i w o u l d l i k e a f e w c h e r r i e s -i f t i m e a l l o w s -n o t a n y m o r e -w h a t l u c k -i s h e g o n e -d o y o u w a n t m e t o t a k e c a r e o f t h a t -j u s t f i n e -i n e e d t o b e w i p e d -h i d a d -e x c u s e m e -c a n y o u g e t m e p i l l o w -h o w o f t e n c a n i g e t i t -i g u a r a n t e e i t -i e n j o y e d o u r p h o n e c a l l -c a n i a s k a f a v o r -c o m e t a l k w i t h m e -h o w d i d i t g o -n o t n o w -w h a t a r e y o u l o o k i n g f o r -c a n y o u b r i n g i n t h e m a i l -a n y p a r t i c u l a r r e a s o n -d o i h a v e e v e r y t h i n g -f o r g o o d -c a n i g i v e y o u m y f i n a l d e c i s i o n l a t e r -t h a t d o e s t h e t r i c k -i r e a l l y f e e l b a d -c a n y o u f i l l u p t h e c a r w i t h g a s -i t h i n k s o t o o -t e l l m e a b o u t y o u r d a y -y e s i w a s -i b e l i e v e w h a t y o u a r e s a y i n g -i w o u l d h a v e t o q u e s t i o n t h a t i d e a -g e t o u t o f h e r e -h o w a r e y o u t o d a y -w h e r e d o y o u w o r k -c a n w e m a k e p l a n s f o r t h e w e e k e n d -p l e a s e o p e n t h e w i n d o w -h e s o u n d s r e a l l y n i c e -w a t c h o u t -d o y o u h a v e t h e t i m e t o p l a y -d o y o u k n o w h o w m u c h i a p p r e c i a t e a l l y o u d o -c a n y o u h e l p m e m o v e -p r e t t y p l e a s e -i s t h e r e a n y t h i n g i c a n d o -w h e n i s i t s c h e d u l e d -i n e e d t o r e l a x -i n e e d t o e x p l a i n -w a t c h o u t -i w o u l d l i k e t o h a v e a p e a r -b e a t s m e -d i d y o u b r i n g t h e c l o t h e s t o t h e c l e a n e r s -i n e e d a m a s s a g e -t h a t s u r p r i s e s m e -t h a n k y o u v e r y m u c h -y o u a r e m y s o u l m a t e -c h a n g e t h e s u b j e c t p l e a s e -i h o p e i c a n c e l e b r a t e m a n y m o r e w i t h y o u -w h a t i s i t -d o e s a n y o n e w a n t t o g o t o t h e b e a c h -i w o u l d l i k e t o u s e s o m e m o u t h w a s h -t h a n k y o u f o r m a k i n g t h a t f o r m e -t h a n k g o d -w h a t h a p p e n s a f t e r w a r d s -y o u r e a l l y b e l i e v e t h a t -j u s t o n e m o r e t h i n g -h e l p m e p u t o n m y s o c k s -c a n y o u b e l i e v e t h a t -i n e e d a s h o w e r -g o o d t o t a l k t o y o u -c a n y o u h e l p m e g e t d r e s s e d -l e t m e t e l l y o u w h y -w h a t i s w r o n g -i h a v e a s p e e c h p r o b l e m i u s e a m a c h i n e t o t a l k p l e a s e b e p a t i e n t -p l e a s e c h a n g e t h e p o s i t i o n o f t h e c h a i r -b e f o r e d i n n e r -i w a n t t o k n o w w h a t i t i s -i w i l l b e r e a d y t o e a t i n a l i t t l e w h i l e -i s s h e c o m i n g -b e f o r e i g o -t h e r e i s n o t h i n g i c a n d o a b o u t i t -r e w i n d t h e t a p e p l e a s e -w h y -i p r o m i s e -a s f a s t a s i c a n -d o y o u w a n t t o v i s i t y o u r r e l a t i v e s -i f e e l t e r r i b l e -a m i r i g h t o r w r o n g -y o u k n o w t h e y d o -l o o s e n u p -c a n y o u t u r n o n t h e t v -i w o u l d l i k e t o h a v e a n a p p l e -p l e a s e k e e p i n m i n d i w a s v e r y h e a l t h y j u s t a s h o r t t i m e a g o -g e t a l i f e -n o w a y -w h a t d i d s h e s a y -c a n i t r y o n e -h o w a b o u t i f w e j u s t s t a y i n s i d e -i f e e l s a d -i f o r g o t -i h o p e e v e r y t h i n g w o r k s o u t -l e t m e g o -a r e y o u o k a y -h a v e f u n a t t h e p a r t y -i a m s o p r o u d o f y o u -i w i s h i c o u l d m a k e t h i n g s b e t t e r f o r y o u -y o u a r e s u c h a g o o d p e r s o n t o h e l p m e -k e e p g o i n g i n e e d a b r e a k -i w o n d e r -p l e a s e s t o p n a g g i n g -w h a t m o r e c o u l d i h a v e e v e r w a n t e d t h a n y o u -j u s t w a i t a n d f i n d o u t -e n j o y t h e p a r t y -i n e e d h e l p n o w -y o u r s u p p o r t m e a n s s o m u c h t o m e -c a n w e p l a n o u r d a y -c a n y o u m a k e a p o t o f c o f f e e -h o w w o u l d y o u f e e l -c a n i h a v e s o m e p i c k l e s -c a n y o u p l e a s e c o m b m y h a i r -i n e e d t o u s e t h e b a t h r o o m -n o t h a n k s -l e t m e -w h a t a n i d e a -y o u b e t t e r n o t -i n e e d t o b l o w m y n o s e -s h o u l d i m a k e t h a t p h o n e c a l l -e x c e l l e n t -i u s e a m a c h i n e t o h e l p m e t a l k -i t h a n k y o u f o r e v e r y t h i n g -y o u c o u l d n o t p o s s i b l y u n d e r s t a n d -w h a t h a p p e n e d -i w o u l d l i k e a c a n d y b a r -t h a t i s i n t e r e s t i n g -k i n d o f t i r e d -j u s t a m i n u t e -i f e e l l i k e e a t i n g s o m e t h i n g s a l t y -d o y o u h a v e a n y c h o c o l a t e -r e a l l y -i h a v e t o g o -h a v e y o u b e e n t h e r e -i b e l i e v e i t -w h a t w a s h e d o i n g -h o w d i d i d o -l e t m e t e l l y o u a b o u t i t -w o u l d y o u l i k e s o m e t h i n g t o d r i n k -d o y o u u n d e r s t a n d w h a t i m e a n -h e l p y o u r s e l f -w h a t a r e y o u d o i n g -t h e n w h a t a f t e r t h i s -l i s t e n t o m e -h a v e y o u h e a r d a n y t h i n g -i w o u l d d o s o m e t h i n g l i k e t h a t -a n y o n e s i t t i n g h e r e -h o w i s t h e w e a t h e r -n o b o d y s e e m s t o u n d e r s t a n d -p l e a s e -i m i g h t c h e c k t h a t o u t t o m o r r o w -i a m s o h a p p y t o h a v e y o u a s a f r i e n d -l o o k o u t w o r l d h e r e i c o m e -w h a t d o i d o n o w -a r e w e f i n i s h e d y e t -o f c o u r s e i d o -i l i k e i c e c r e a m -y o u s h o u l d h a v e t o l d m e -i s e e -i n e e d y o u t o c a l l m y f a m i l y -i h o p e y o u k n o w h o w m u c h i a p p r e c i a t e y o u -a r e y o u h u n g r y -i u s e d t o -t a k e t h a t a w a y -s i d e b y s i d e -f o r g e t a b o u t i t -i w a n t t o l a y o n m y r i g h t s i d e -w h a t i s t h a t -t o m o r r o w m o r n i n g -i t w a s n o g o o d -i w o u l d l i k e t o e a t s o m e p o t a t o e s -w e w i l l g e t t o i t l a t e r -t h a n k s f o r e v e r y t h i n g -d o e s t h a t m a k e s e n s e -i a l r e a d y d i d -i w i l l s e e y o u -i w a n t t o l a y o n m y l e f t s i d e -i h a v e a l s a n d i h a v e t r o u b l e s p e a k i n g -p l e a s e t u r n o f f t h e h e a t -w h e n w a s t h e l a s t t i m e t h a t h a p p e n e d -i w i s h i c o u l d m a k e i t e a s i e r -c a n y o u a n s w e r t h e p h o n e -h o w o l d a r e y o u -a r e y o u s i c k -d o y o u w a n t m e t o -s o w h a t -g o o d l u c k -t h i s i s a g r e a t p a r t y -h o w w a s y o u r w e e k e n d -d o y o u l i k e h i m -i w i s h y o u w e l l -c a n y o u t a k e o u t m y c o n t a c t l e n s e s -i l i k e t o l i s t e n t o m y i p o d -w e l l i t s u r e l o o k s l i k e i t -i n e e d a n a p k i n -i t h o u g h t i t w o u l d b e g o o d f o r m e -a n y m i n u t e n o w -w h a t d o y o u w a n t -w h a t a r e y o u h e r e f o r -t h i s i s g r e a t -m a k e s y o u w o n d e r -i w o u l d l i k e o t l a y f l a t -c a n y o u h e l p m e s e t u p m y i p a d -t h e r e y o u a r e -i e n j o y e d m y s e l f -i k n o w w h a t y o u m e a n -e n j o y y o u r v a c a t i o n -a b s o l u t e l y n o t -i k n o w w h y -c a n i a s k a f a v o r -i u n d e r s t a n d -w h a t k i n d o f s h o w s a r e o n -n o t h i n g w r o n g w i t h t h a t -p l e a s e b r i n g t h a t o v e r h e r e -t h i s i s a b i t t o o h o t -d i d y o u p u t y o u r c l o t h e s a w a y -w a s t h e r e s o m e t h i n g e l s e -i t h i n k n o t -i s i t a d e a l -t h e y d o t h e s t r a n g e s t t h i n g s -i l o v e y o u m o r e t h a n i c a n s a y -w h a t d o e s i t m a t t e r -c a n y o u h o l d t h i s -y o u b e t y o u r b r i t c h e s -y o u a r e a l l i c o u l d h a v e a s k e d f o r -i w o u l d l i k e t o h a v e a p i e c e o f f r u i t -w h a t d i d y o u m a k e a t s c h o o l t o d a y -i n e e d y o u r h e l p p i c k i n g t h a t u p -i c o u l d e a t t h i s e v e r y d a y -c a n y o u h e l p m e w a s h m y f a c e -c a r e f u l y o u a r e h u r t i n g m e -i f y o u w a n t t o -t h e y u s u a l l y d o -i w o u l d l i k e t o w a s h m y h a i r -i t h i n k t h i s i s p r e t t y g o o d -i r e a l l y d o -p l e a s e p u t a p i l l o w u n d e r m y a r m -i h o p e y o u k n o w h o w m u c h y o u m e a n t o m e -y o u n e v e r k n o w -t h a n k s f o r y o u r h e l p -m y h e a d h u r t s -d r i v e c a r e f u l l y -i s i t o k a y -w h o s e t u r n i s i t t o f e e d t h e c a t -h o w l o n g d i d i t t a k e -c a n i h a v e a b a c k r u b -t h i s t h i n g -n i c e t o t a l k t o y o u -p l e a s e s e t t h e t a b l e -i u s e t h i s m a c h i n e t o c o m m u n i c a t e -c a n y o u m a k e i t c o o l e r i n h e r e -y o u h a v e a g o o d d a y -w o u l d y o u -i h a v e n o i d e a -i n e v e r w a n t t o e a t t h a t a g a i n -i n e e d y o u -i t i s a b s o l u t e l y r i d i c u l o u s -a r e y o u g l a d -a l i t t l e m o r e o f t e n -h o w a r e y o u t h i s m o r n i n g -m a y i h a v e s o m e t h i n g t o d r i n k -w h a t a r e y o u g o i n g t o d o n e x t -i n e e d t o m o v e -w h a t t i m e i s i t -t u r n i t o f f p l e a s e -y o u b e t t e r b e l i e v e i t -w h y d i d t h e y g o t o a l l t h a t t r o u b l e -i j u s t w a n t e d t o f i n d o u t -i d o u b t i t -i w o u l d l i k e t o h a v e s o m e s o u p -g i v e m e a f e w m i n u t e s -y o u a r e r i g h t -i t i s i m p o r t a n t t o m e -i n e e d t o t a k e m y m e d i c i n e -o n c e i n a b l u e m o o n -h e l p m e u n d e r s t a n d t h a t -h e l p m e g e t d r e s s e d -a r e y o u e x c i t e d -w o u l d y o u l i k e t o g o w i t h m e -a b o u t a w e e k -n o w a y -i n e e d a n a p p o i n t m e n t w i t h t h e d e n t i s t -i f o r g o t t o m e n t i o n -g i v e m e a c a l l t o n i g h t -o n c e o r t w i c e -i f t h e r e i s t i m e -i w o u l d l i k e a c o o k i e -i w o u l d l i k e t o g e t a m a n i c u r e -n o n o n o -c a n y o u m o v e m e o v e r t h e r e -d o y o u h a v e a f a v o r i t e s p o r t -i w a n t t o s h o w y o u s o m e t h i n g -n o w a y -t h a n k s a l o t -n o t q u i t e y e t -w h e r e d o i g o diff --git a/bcipy/language/sets/boston_subset.tokenized b/bcipy/language/sets/boston_subset.tokenized deleted file mode 100644 index c621ffa22..000000000 --- a/bcipy/language/sets/boston_subset.tokenized +++ /dev/null @@ -1,20 +0,0 @@ -c a n y o u l i s t e n t o m e p l e a s e l o o k i n m y e y e s -i t s u d d e n l y b e c o m e s i m p o r t a n t -y o u k n o w s o m e t h i n g -w i l l y o u c a l l t h e m -i t h a p p e n s t o b e m y f a v o r i t e -i a m r e a l l y s o r r y -i e n j o y e d s e e i n g y o u t h a n k s -s o i s m i n e -w h a t d i d h e t h i n k a b o u t t h a t -w h e n i a m t i r e d -t h e s u n i s s h i n i n g -w h e n w i l l i s e e y o u n e x t -a l o n g t i m e a g o -i h a d a r e a l b u s y d a y -p l e a s e c o m e r i g h t b a c k -y o u a r e t h e g r e a t e s t k i d s i n t h e w o r l d -i w o u l d l i k e t o e a t s o m e f i s h -h o w l o n g d o e s i t t a k e -i w a s o n l y j o k i n g -w h a t \ No newline at end of file diff --git a/bcipy/language/tests/test_mixture.py b/bcipy/language/tests/test_mixture.py index eca69f1ae..aefe5b3a8 100644 --- a/bcipy/language/tests/test_mixture.py +++ b/bcipy/language/tests/test_mixture.py @@ -61,21 +61,21 @@ def test_invalid_model_weights(self): """Test that the proper exception is thrown if given an improper number of lm_weights""" with self.assertRaises(InvalidLanguageModelException, msg="Exception not thrown when too few weights given"): MixtureLanguageModel(response_type=ResponseType.SYMBOL, symbol_set=alphabet(), - lm_types=["UNIGRAM", "CAUSAL"], lm_weights=[0.5], - lm_params=[{}, {"lang_model_name": "gpt2"}]) + lm_types=["KENLM", "CAUSAL"], lm_weights=[0.5], + lm_params=self.lm_params) with self.assertRaises(InvalidLanguageModelException, msg="Exception not thrown when no weights given"): MixtureLanguageModel(response_type=ResponseType.SYMBOL, symbol_set=alphabet(), - lm_types=["UNIGRAM", "CAUSAL"], lm_weights=None, - lm_params=[{}, {"lang_model_name": "gpt2"}]) + lm_types=["KENLM", "CAUSAL"], lm_weights=None, + lm_params=self.lm_params) with self.assertRaises(InvalidLanguageModelException, msg="Exception not thrown when too many weights given"): MixtureLanguageModel(response_type=ResponseType.SYMBOL, symbol_set=alphabet(), - lm_types=["UNIGRAM", "CAUSAL"], lm_weights=[0.2, 0.3, 0.5], - lm_params=[{}, {"lang_model_name": "gpt2"}]) + lm_types=["KENLM", "CAUSAL"], lm_weights=[0.2, 0.3, 0.5], + lm_params=self.lm_params) with self.assertRaises(InvalidLanguageModelException, msg="Exception not thrown when weights given do not \ sum to 1"): MixtureLanguageModel(response_type=ResponseType.SYMBOL, symbol_set=alphabet(), - lm_types=["UNIGRAM", "CAUSAL"], lm_weights=[0.5, 0.8], - lm_params=[{}, {"lang_model_name": "gpt2"}]) + lm_types=["KENLM", "CAUSAL"], lm_weights=[0.5, 0.8], + lm_params=self.lm_params) def test_non_mutable_evidence(self): """Test that the model does not change the evidence variable passed in.""" diff --git a/bcipy/language/tests/test_unigram.py b/bcipy/language/tests/test_unigram.py deleted file mode 100644 index 453734e9c..000000000 --- a/bcipy/language/tests/test_unigram.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Tests for UNIGRAM Language Model""" - -import pytest -import unittest -import os - -from bcipy.exceptions import UnsupportedResponseType, InvalidLanguageModelException -from bcipy.core.symbols import alphabet, BACKSPACE_CHAR -from bcipy.language.model.unigram import UnigramLanguageModel -from bcipy.language.main import ResponseType - - -@pytest.mark.slow -class TestUnigramLanguageModel(unittest.TestCase): - """Tests for language model""" - @classmethod - def setUpClass(cls): - cls.lmodel = UnigramLanguageModel(response_type=ResponseType.SYMBOL, - symbol_set=alphabet()) - - def test_init(self): - """Test default parameters""" - self.assertEqual(self.lmodel.response_type, ResponseType.SYMBOL) - self.assertEqual(self.lmodel.symbol_set, alphabet()) - self.assertTrue( - ResponseType.SYMBOL in self.lmodel.supported_response_types()) - - def test_name(self): - """Test model name.""" - self.assertEqual("UNIGRAM", UnigramLanguageModel.name()) - - def test_unsupported_response_type(self): - """Unsupported responses should raise an exception""" - with self.assertRaises(UnsupportedResponseType): - UnigramLanguageModel(response_type=ResponseType.WORD, symbol_set=alphabet()) - - def test_invalid_model_path(self): - """Test that the proper exception is thrown if given an invalid lm_path""" - with self.assertRaises(InvalidLanguageModelException): - UnigramLanguageModel(response_type=ResponseType.SYMBOL, symbol_set=alphabet(), - lm_path="phonymodel.txt") - - def test_invalid_model(self): - with self.assertRaises(InvalidLanguageModelException): - dirname = os.path.dirname(__file__) or '.' - lm_path = f"{dirname}/resources/invalid_unigram.json" - UnigramLanguageModel(response_type=ResponseType.SYMBOL, symbol_set=alphabet(), - lm_path=lm_path) - - def test_non_mutable_evidence(self): - """Test that the model does not change the evidence variable passed in. - This could impact the mixture model if failed""" - evidence = list("Test_test") - evidence2 = list("Test_test") - self.lmodel.predict(evidence) - self.assertEqual(evidence, evidence2) - - def test_identical_evidence(self): - """Ensure predictions are the same for subsequent queries with the same evidence.""" - query1 = self.lmodel.predict(list("evidenc")) - query2 = self.lmodel.predict(list("evidenc")) - for ((sym1, prob1), (sym2, prob2)) in zip(query1, query2): - self.assertAlmostEqual(prob1, prob2, places=5) - self.assertEqual(sym1, sym2) - - def test_identical_predictions(self): - """Ensure predictions are the same for subsequent queries with different evidence.""" - query1 = self.lmodel.predict(list("evi")) - query2 = self.lmodel.predict(list("evidenc")) - for ((sym1, prob1), (sym2, prob2)) in zip(query1, query2): - self.assertAlmostEqual(prob1, prob2, places=5) - self.assertEqual(sym1, sym2) - - def test_upper_lower_case(self): - """Ensure predictions are the same for upper or lower case evidence.""" - lc = self.lmodel.predict(list("EVIDENC")) - uc = self.lmodel.predict(list("evidenc")) - for ((l_sym, l_prob), (u_sym, u_prob)) in zip(lc, uc): - self.assertAlmostEqual(l_prob, u_prob, places=5) - self.assertEqual(l_sym, u_sym) - - def test_predict_start_of_word(self): - """Test the predict method with no prior evidence.""" - symbol_probs = self.lmodel.predict(evidence=[]) - probs = [prob for sym, prob in symbol_probs] - - self.assertTrue( - len(set(probs)) > 1, - "All values should not be the same probability") - # Consider whether the following assertion should be True - # backspace_prob = next(prob for sym, prob in probs if sym == BACKSPACE_CHAR) - # self.assertEqual(0, backspace_prob) - for prob in probs: - self.assertTrue(0 <= prob < 1) - self.assertAlmostEqual(sum(probs), 1, places=3) - - def test_predict_middle_of_word(self): - """Test the predict method in the middle of a word.""" - symbol_probs = self.lmodel.predict(evidence=list("TH")) - probs = [prob for sym, prob in symbol_probs] - - self.assertTrue( - len(set(probs)) > 1, - "All values should not be the same probability") - for prob in probs: - self.assertTrue(0 <= prob < 1) - self.assertAlmostEqual(sum(probs), 1, places=3) - - def test_nonzero_prob(self): - """Test that all letters in the alphabet have nonzero probability except for backspace""" - symbol_probs = self.lmodel.predict(list("does_it_make_sens")) - prob_values = [item[1] for item in symbol_probs if item[0] != BACKSPACE_CHAR] - for value in prob_values: - self.assertTrue(value > 0) From 37b1f6e296068cb473b802c3c0165ccf39113249 Mon Sep 17 00:00:00 2001 From: lawhead Date: Fri, 7 Mar 2025 17:01:41 -0800 Subject: [PATCH 51/76] Updated tests for button press calculations for easier understanding. --- bcipy/display/main.py | 2 +- .../tests/model/test_inquiry_preview.py | 82 +++++++++++++++---- 2 files changed, 65 insertions(+), 19 deletions(-) diff --git a/bcipy/display/main.py b/bcipy/display/main.py index 650173078..49f5fa074 100644 --- a/bcipy/display/main.py +++ b/bcipy/display/main.py @@ -265,7 +265,7 @@ class PreviewParams(NamedTuple): preview_box_text_size: float @property - def button_press_mode(self): + def button_press_mode(self) -> ButtonPressMode: """Mode indicated by the inquiry progress method.""" return ButtonPressMode(self.preview_inquiry_progress_method) diff --git a/bcipy/signal/tests/model/test_inquiry_preview.py b/bcipy/signal/tests/model/test_inquiry_preview.py index 9b506b160..2e2696079 100644 --- a/bcipy/signal/tests/model/test_inquiry_preview.py +++ b/bcipy/signal/tests/model/test_inquiry_preview.py @@ -1,40 +1,86 @@ import unittest +from string import ascii_uppercase + import numpy as np + from bcipy.signal.model.inquiry_preview import compute_probs_after_preview -from string import ascii_uppercase class TestInquiryPreview(unittest.TestCase): + """Test button press probabilities under various conditions.""" + def setUp(self): self.symbol_set = list(ascii_uppercase) + self.error_prob = 0.05 - def _test(self, inquiry_len, error_prob, user_likes: bool): - if user_likes: - p_shown = 1 - error_prob - p_not_shown = error_prob - else: - p_shown = error_prob - p_not_shown = 1 - error_prob - - inquiry = self.symbol_set[:inquiry_len] - results = compute_probs_after_preview(inquiry, self.symbol_set, error_prob, user_likes) + def test_user_likes_short_inquiry(self): + """Test short inquiry where user wants to proceed with the inquiry. + Should provide support for symbols in inquiry and downvote others.""" - expected = np.array(inquiry_len * [p_shown] + (len(self.symbol_set) - inquiry_len) * [p_not_shown]) + inquiry = ['A', 'B', 'C', 'D', 'E'] + user_likes = True + expected = [ + 0.95, 0.95, 0.95, 0.95, 0.95, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, + 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, + 0.05, 0.05, 0.05, 0.05 + ] + results = compute_probs_after_preview(inquiry, self.symbol_set, + self.error_prob, user_likes) self.assertTrue(np.allclose(results, expected)) - def test_user_likes_short_inquiry(self): - self._test(5, 0.95, True) - def test_user_likes_long_inquiry(self): - self._test(15, 0.95, True) + """Test long inquiry where user wants to proceed with the inquiry.""" + inquiry = [ + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', + 'N', 'O' + ] + expected = [ + 0.95, 0.95, 0.95, 0.95, 0.95, 0.95, 0.95, 0.95, 0.95, 0.95, 0.95, + 0.95, 0.95, 0.95, 0.95, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, + 0.05, 0.05, 0.05, 0.05 + ] + results = compute_probs_after_preview(inquiry, + self.symbol_set, + self.error_prob, + proceed=True) + + self.assertTrue(np.allclose(results, expected)) def test_user_dislikes_short_inquiry(self): - self._test(5, 0.95, False) + """Test probabilities for short inquiry where user does not want to proceed. + Should downvote letters in the inquiry and upvote everything else.""" + + inquiry = ['A', 'B', 'C', 'D', 'E'] + expected = [ + 0.05, 0.05, 0.05, 0.05, 0.05, 0.95, 0.95, 0.95, 0.95, 0.95, 0.95, + 0.95, 0.95, 0.95, 0.95, 0.95, 0.95, 0.95, 0.95, 0.95, 0.95, 0.95, + 0.95, 0.95, 0.95, 0.95 + ] + results = compute_probs_after_preview(inquiry, + self.symbol_set, + self.error_prob, + proceed=False) + self.assertTrue(np.allclose(results, expected)) def test_user_dislikes_long_inquiry(self): - self._test(15, 0.95, False) + """Test probabilities for long inquiry where user does not want to proceed.""" + inquiry = [ + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', + 'N', 'O' + ] + expected = [ + 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, + 0.05, 0.05, 0.05, 0.05, 0.95, 0.95, 0.95, 0.95, 0.95, 0.95, 0.95, + 0.95, 0.95, 0.95, 0.95 + ] + results = compute_probs_after_preview(inquiry, + self.symbol_set, + self.error_prob, + proceed=False) + self.assertTrue(np.allclose(results, expected)) def test_invalid_error_prob(self): + """Test error probability out of range""" inquiry = self.symbol_set[:10] with self.assertRaises(ValueError): compute_probs_after_preview(inquiry, self.symbol_set, -0.01, True) From ae35c7037362974f1a21b6dfb4be8f8c3d502d12 Mon Sep 17 00:00:00 2001 From: lawhead Date: Fri, 7 Mar 2025 17:02:00 -0800 Subject: [PATCH 52/76] Added missing flag to README --- bcipy/simulator/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/bcipy/simulator/README.md b/bcipy/simulator/README.md index bebaeadc8..f6cbb0f3a 100644 --- a/bcipy/simulator/README.md +++ b/bcipy/simulator/README.md @@ -29,6 +29,7 @@ optional arguments: Sampler args structured as a JSON string. -o OUTPUT, --output OUTPUT Sim output path + -v, --verbose Verbose mode for more detailed logging. ``` For example, From 6c9f62bc14be5918832dd6f864761a7de43d984b Mon Sep 17 00:00:00 2001 From: lawhead Date: Fri, 7 Mar 2025 17:02:40 -0800 Subject: [PATCH 53/76] Fixed data_engine type signature --- bcipy/simulator/data/data_engine.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bcipy/simulator/data/data_engine.py b/bcipy/simulator/data/data_engine.py index 303945e9c..a9635f243 100644 --- a/bcipy/simulator/data/data_engine.py +++ b/bcipy/simulator/data/data_engine.py @@ -2,13 +2,12 @@ import logging from abc import ABC, abstractmethod from pathlib import Path -from typing import Any, List, NamedTuple, Union, get_args, get_origin +from typing import Any, List, NamedTuple, get_args, get_origin import pandas as pd from bcipy.core.parameters import Parameters from bcipy.exceptions import TaskConfigurationException -from bcipy.simulator.data import data_process from bcipy.simulator.data.data_process import (ExtractedExperimentData, RawDataProcessor) from bcipy.simulator.data.trial import Trial, convert_trials @@ -75,8 +74,7 @@ def __init__(self, source_dirs: List[str], parameters: Parameters, self.parameters: Parameters = parameters self.data_processor = data_processor - self.data: List[Union[ExtractedExperimentData, - data_process.ExtractedExperimentData]] = [] + self.data: List[ExtractedExperimentData] = [] self._trials_df = pd.DataFrame() self.load() From 5de0b2975f81b15e38fa60e07442e6bf24e334f5 Mon Sep 17 00:00:00 2001 From: lawhead Date: Fri, 7 Mar 2025 17:03:07 -0800 Subject: [PATCH 54/76] Added missing verbose flag to simulator GUI --- bcipy/simulator/ui/gui.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bcipy/simulator/ui/gui.py b/bcipy/simulator/ui/gui.py index aaa954926..26447f947 100644 --- a/bcipy/simulator/ui/gui.py +++ b/bcipy/simulator/ui/gui.py @@ -607,6 +607,7 @@ def command(self) -> str: if self.sampler is not None: args.append(f"-s {self.sampler.__name__}") args.append(f"--sampler_args='{self.sampler_args}'") + args.append("-v") return f"bcipy-sim {' '.join(args)}" From 4486fe65dffdf186c6635d154deccce56bab647d Mon Sep 17 00:00:00 2001 From: lawhead Date: Fri, 7 Mar 2025 17:03:55 -0800 Subject: [PATCH 55/76] Refactoring RawDataProcessor to support more subclass specialization --- bcipy/simulator/data/data_process.py | 39 +++++++++++++++++++++------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/bcipy/simulator/data/data_process.py b/bcipy/simulator/data/data_process.py index bcd731c02..2cf217685 100644 --- a/bcipy/simulator/data/data_process.py +++ b/bcipy/simulator/data/data_process.py @@ -15,13 +15,13 @@ from bcipy.acquisition.multimodal import ContentType from bcipy.config import (DEFAULT_DEVICE_SPEC_FILENAME, DEFAULT_PARAMETERS_FILENAME, TRIGGER_FILENAME) -from bcipy.helpers.acquisition import analysis_channels, raw_data_filename from bcipy.core.list import grouper -from bcipy.io.load import load_json_parameters, load_raw_data from bcipy.core.parameters import Parameters from bcipy.core.raw_data import RawData from bcipy.core.stimuli import update_inquiry_timing -from bcipy.core.triggers import TriggerType, trigger_decoder +from bcipy.core.triggers import Trigger, TriggerType, trigger_decoder +from bcipy.helpers.acquisition import analysis_channels, raw_data_filename +from bcipy.io.load import load_json_parameters, load_raw_data from bcipy.signal.model.base_model import SignalModel from bcipy.signal.process import (ERPTransformParams, filter_inquiries, get_default_transform) @@ -79,6 +79,16 @@ class DecodedTriggers(NamedTuple): symbols: List[str] # symbol corrected_times: List[float] + @property + def triggers(self) -> List[Trigger]: + """List of triggers""" + return [ + Trigger(label=self.symbols[i], + type=TriggerType(self.targetness[i]), + time=self.corrected_times[i]) + for i in range(len(self.symbols)) + ] + @dataclass() class ExtractedExperimentData: @@ -88,7 +98,6 @@ class ExtractedExperimentData: trials: np.ndarray labels: List inquiry_timing: List - decoded_triggers: DecodedTriggers trials_per_inquiry: int @@ -267,8 +276,7 @@ def process(self, data_folder: str, parameters - parameters.json file for the calibration session used to train the model / run the simulation. """ - raw_data, device_spec = load_device_data(data_folder, - self.content_type.name) + raw_data, device_spec = self.load_device_data(data_folder, parameters) data_parameters = load_json_parameters( f"{data_folder}/{DEFAULT_PARAMETERS_FILENAME}", value_cast=True) @@ -279,9 +287,8 @@ def process(self, data_folder: str, sim_timing_params=timing_params, data_timing_params=data_parameters.instantiate(TimingParams)) - decoded_triggers = decode_triggers(data_folder, timing_params, - device_spec.static_offset, - self.excluded_triggers()) + decoded_triggers = self.decode_triggers(data_folder, parameters, + device_spec.static_offset) reshaped_data = self.reshape_data(raw_data, decoded_triggers, timing_params) @@ -300,6 +307,20 @@ def process(self, data_folder: str, decoded_triggers=decoded_triggers, trials_per_inquiry=timing_params.trials_per_inquiry) + def load_device_data(self, data_folder: str, + parameters: Parameters) -> Tuple[RawData, DeviceSpec]: + """Load the device data""" + return load_device_data(data_folder, self.content_type.name) + + def decode_triggers(self, + data_folder: str, + parameters: Parameters, + device_offset: float = 0.0) -> DecodedTriggers: + """Decode the triggers.txt file in the given directory.""" + timing_params = parameters.instantiate(TimingParams) + return decode_triggers(data_folder, timing_params, device_offset, + self.excluded_triggers()) + @abstractmethod def reshape_data(self, raw_data: RawData, decoded_triggers: DecodedTriggers, From 507fbb5831c284b44bab06782f95eda5e5563bd7 Mon Sep 17 00:00:00 2001 From: lawhead Date: Fri, 7 Mar 2025 17:04:57 -0800 Subject: [PATCH 56/76] Added demo modules for simulating a button press model to test multimodal simulation --- .../demo/button_press_data_processor.py | 129 ++++++++++++++++++ bcipy/simulator/demo/button_press_model.py | 72 ++++++++++ bcipy/simulator/demo/button_press_utils.py | 120 ++++++++++++++++ 3 files changed, 321 insertions(+) create mode 100644 bcipy/simulator/demo/button_press_data_processor.py create mode 100644 bcipy/simulator/demo/button_press_model.py create mode 100644 bcipy/simulator/demo/button_press_utils.py diff --git a/bcipy/simulator/demo/button_press_data_processor.py b/bcipy/simulator/demo/button_press_data_processor.py new file mode 100644 index 000000000..7b0b97c47 --- /dev/null +++ b/bcipy/simulator/demo/button_press_data_processor.py @@ -0,0 +1,129 @@ +"""Process raw button press data.""" + +from typing import List, Tuple + +import numpy as np + +from bcipy.acquisition import devices +from bcipy.acquisition.devices import DeviceSpec +from bcipy.acquisition.multimodal import ContentType +from bcipy.core.list import grouper +from bcipy.core.parameters import Parameters +from bcipy.core.raw_data import RawData +from bcipy.core.triggers import TriggerType +from bcipy.display.main import PreviewParams +from bcipy.io.load import load_raw_data +from bcipy.simulator.data.data_process import (DecodedTriggers, + ExtractedExperimentData, + RawDataProcessor, ReshapedData, + TimingParams) +from bcipy.simulator.demo.button_press_utils import (should_press_button, + simulate_raw_data, + switch_device) +from bcipy.task.data import EvidenceType + + +class ButtonPressDataProcessor(RawDataProcessor): + """Data Processor for button press data. + + Note that this class does not read raw data, but instead simulates what a + user may have done. If a target was present in an inquiry and the button press + mode is 'press to accept', all trials in that inquiry are given data of 1.0, + otherwise the data value is 0.0. If the mode is 'press to skip', trials within + inquiries that do not contain the target are given 1.0 values. + """ + consumes = ContentType.MARKERS + produces = EvidenceType.BTN + + def process(self, data_folder: str, + parameters: Parameters) -> ExtractedExperimentData: + """Load and process the data. + + Parameters + ---------- + data_folder - session directory for a given dataset + (contains raw_data, devices.json, and parameters.json). + parameters - parameters.json file for the calibration session used + to train the model / run the simulation. + """ + + timing_params = parameters.instantiate(TimingParams) + button_press_mode = parameters.instantiate( + PreviewParams).button_press_mode + decoded_triggers = self.decode_triggers(data_folder, parameters) + + inquiry_data: List[List[float]] = [] + target_labels: List[List[int]] = [] + + # chunk triggers into inquiries and iterate. + for inquiry_triggers in grouper(decoded_triggers.triggers, + timing_params.trials_per_inquiry, + incomplete='ignore'): + inquiry_trial_data = [] + inquiry_target_labels = [] + # collect data for each trial in the inquiry + for trg in inquiry_triggers: + target_lbl = 1 if trg.type == TriggerType.TARGET else 0 + data = 1.0 if should_press_button(inquiry_triggers, + button_press_mode) else 0.0 + inquiry_trial_data.append(data) + inquiry_target_labels.append(target_lbl) + + inquiry_data.append(inquiry_trial_data) + target_labels.append(inquiry_target_labels) + + # shape: (channels/1 , inquiries/inquiry_count, samples/trials_per_inquiry) + inquiry_arr = np.array([inquiry_data]) + trial_count = inquiry_arr.shape[1] * timing_params.trials_per_inquiry + + # shape: (channels/1, trials/trial_count, samples/1) + trial_arr = inquiry_arr.reshape((1, trial_count, 1)) + + return ExtractedExperimentData( + source_dir=data_folder, + inquiries=inquiry_arr, + trials=trial_arr, + labels=target_labels, + inquiry_timing=[], # timing data not used + decoded_triggers=decoded_triggers, + trials_per_inquiry=timing_params.trials_per_inquiry) + + def load_device_data(self, data_folder: str, + parameters: Parameters) -> Tuple[RawData, DeviceSpec]: + """Load the device data""" + raw_data_path = simulate_raw_data(data_folder, parameters) + raw_data = load_raw_data(str(raw_data_path)) + device_spec = switch_device() + return raw_data, device_spec + + def reshape_data(self, raw_data: RawData, + decoded_triggers: DecodedTriggers, + timing_params: TimingParams) -> ReshapedData: + """Use the configured reshaper to reshape the data.""" + raise NotImplementedError + + def apply_filters(self, reshaped_data: ReshapedData, + parameters: Parameters, + data_sample_rate: int) -> Tuple[ReshapedData, int]: + """Apply any filters to the reshaped data. + + Returns + ------- + inquiry data after filtering, updated sample_rate + """ + return reshaped_data, devices.IRREGULAR_RATE + + def extract_trials(self, filtered_reshaped_data: ReshapedData, + timing_params: TimingParams, + updated_sample_rate: float) -> np.ndarray: + """Extract trial data from the filtered, reshaped data. + + Returns + ------- + np.ndarray of shape (Channels, Trials, Samples) + """ + raise NotImplementedError + + def excluded_triggers(self): + """Trigger types to exclude when decoding""" + return [TriggerType.PREVIEW, TriggerType.EVENT, TriggerType.FIXATION] diff --git a/bcipy/simulator/demo/button_press_model.py b/bcipy/simulator/demo/button_press_model.py new file mode 100644 index 000000000..ef939d186 --- /dev/null +++ b/bcipy/simulator/demo/button_press_model.py @@ -0,0 +1,72 @@ +"""Signal model for button presses.""" +from pathlib import Path +from typing import List + +import numpy as np + +from bcipy.core.stimuli import InquiryReshaper +from bcipy.signal.model.base_model import SignalModel +from bcipy.signal.model.inquiry_preview import compute_probs_after_preview + + +class ButtonPressModel(SignalModel): + """Signal model that classifies button presses. + + Parameters + ---------- + error_prob - Specifies the probability of a button press error. + TODO: compute this during training. + """ + + name = "ButtonPressModel" + reshaper: InquiryReshaper = InquiryReshaper() + + def __init__(self, error_prob: float = 0.05): + self.error_prob = error_prob + + def fit(self, training_data: np.ndarray, training_labels: np.ndarray): + """ + @override + """ + return self + + def evaluate(self, test_data: np.ndarray, test_labels: np.ndarray): + """@override""" + + def predict(self, data: np.ndarray, inquiry: List[str], + symbol_set: List[str]) -> np.ndarray: + """@override""" + return np.ones(data.shape) + + def compute_likelihood_ratio(self, data: np.array, inquiry: List[str], + symbol_set: List[str]) -> np.array: + """ + For each trial in `data`, compute a likelihood ratio to update that symbol's probability. + + Args: + data (np.array): button press data data a single element of 0 or 1; shape (1,) + inquiry (List[str]): List describing the symbol shown in each trial. + symbol_set (List[str]): The set of all possible symbols. + + Returns: + np.array: multiplicative update term (likelihood ratios) for each symbol in the `symbol_set`. + """ + proceed = np.ones(data.shape) == data + compute_probs_after_preview(inquiry=inquiry, + symbol_set=symbol_set, + user_error_prob=self.error_prob, + proceed=proceed) + + def compute_class_probabilities(self, data: np.ndarray) -> np.ndarray: + """@override""" + return np.ones(0) + + def evaluate_likelihood(self, data: np.ndarray) -> np.ndarray: + """@override""" + return np.ones(0) + + def save(self, path: Path) -> None: + """@override""" + + def load(self, path: Path) -> None: + """@override""" diff --git a/bcipy/simulator/demo/button_press_utils.py b/bcipy/simulator/demo/button_press_utils.py new file mode 100644 index 000000000..8b0f8096c --- /dev/null +++ b/bcipy/simulator/demo/button_press_utils.py @@ -0,0 +1,120 @@ +"""Utilities for working with button press data.""" + +# Input data folder and parameters + +# could check for raw button data + +import random +from pathlib import Path +from typing import List, Tuple + +from bcipy import config +from bcipy.acquisition.datastream.mock.switch import switch_device +from bcipy.core.parameters import Parameters +from bcipy.core.raw_data import RawDataWriter +from bcipy.core.triggers import Trigger, TriggerType, trigger_decoder +from bcipy.display.main import ButtonPressMode, PreviewParams +from bcipy.helpers.acquisition import raw_data_filename + + +def pairwise(iterable): + """ + pairwise('ABCDEFG') → AB BC CD DE EF FG + https://docs.python.org/3/library/itertools.html#itertools.pairwise + """ + iterator = iter(iterable) + a = next(iterator, None) + for b in iterator: + yield a, b + a = b + + +def partition_triggers(trigger_path: Path) -> List[List[Trigger]]: + """Partition the triggers into inquiries. + + Returns + ------- + list of entries where each entry represents the triggers for an inquiry. + """ + trg_types, trg_times, trg_labels = trigger_decoder( + str(trigger_path), + remove_pre_fixation=False, + apply_starting_offset=False) + triggers = [ + Trigger(label=trg_labels[i], + type=TriggerType(trg_types[i]), + time=trg_times[i]) for i in range(len(trg_types)) + ] + # index for each prompt trigger + inq_start_indices = [ + i for i, trg in enumerate(triggers) if trg.type == TriggerType.FIXATION + ] + inquiry_triggers = [] + for i, j in pairwise(inq_start_indices): + inquiry_triggers.append(triggers[i:j]) + inquiry_triggers.append(triggers[inq_start_indices[-1]:]) + + return inquiry_triggers + + +def has_target(inquiry_triggers: List[Trigger]) -> bool: + """Check if the list of inquiries contains a target.""" + for trg in inquiry_triggers: + if trg.type == TriggerType.TARGET: + return True + return False + + +def time_range(inquiry_triggers: List[Trigger], + time_flash: float) -> Tuple[float, float]: + """Given a list of triggers for a given inquiry, determine the start and + end timestamps of that inquiry.""" + return (inquiry_triggers[0].time, inquiry_triggers[-1].time + time_flash) + + +def should_press_button(inquiry_triggers: List[Trigger], + button_press_mode: ButtonPressMode) -> bool: + """Determine if a marker should be written for the given inquiry + depending on the presence of a target and the button press mode.""" + return (button_press_mode == ButtonPressMode.ACCEPT + and has_target(inquiry_triggers)) or ( + button_press_mode == ButtonPressMode.REJECT + and not has_target(inquiry_triggers)) + + +def timestamp_within_inquiry(inquiry_triggers: List[Trigger], + parameters: Parameters) -> float: + """Timestamp is a random value between inquiry start and end.""" + inq_start, inq_end = time_range(inquiry_triggers, parameters['time_flash']) + return random.uniform(inq_start, inq_end) + + +def simulate_raw_data(data_dir: Path, parameters: Parameters): + """Read through trigger data. For inquiries with target, output one or + more markers within the inquiry timestamp range. Or if press to reject, + output markers for inquiries without target.""" + + spec = switch_device() + raw_data_path = Path(data_dir, raw_data_filename(spec)) + + if raw_data_path.exists(): + return raw_data_path + + # determine button mode from parameters + button_press_mode = parameters.instantiate(PreviewParams).button_press_mode + + columns = ['timestamp', 'Marker', 'lsl_timestamp'] + rownum = 0 + with RawDataWriter(raw_data_path, + daq_type=spec.content_type, + sample_rate=spec.sample_rate, + columns=columns) as writer: + for inquiry_triggers in partition_triggers( + Path(data_dir, config.TRIGGER_FILENAME)): + if should_press_button(inquiry_triggers, button_press_mode): + rownum += 1 + stamp = timestamp_within_inquiry(inquiry_triggers, parameters) + + writer.writerow([rownum, 1.0, stamp]) + + return raw_data_path From a49124f6d6fa8e3bd94fc3d5e8ddda240e6b1c9a Mon Sep 17 00:00:00 2001 From: lawhead Date: Mon, 10 Mar 2025 15:59:29 -0700 Subject: [PATCH 57/76] Refactor for multimodal simulation; documentation; bug fixes --- bcipy/parameters/devices.json | 12 +++ bcipy/simulator/README.md | 5 +- bcipy/simulator/data/data_process.py | 49 ------------ bcipy/simulator/data/processor_registry.py | 75 ++++++++++++++++++ .../simulator/data/sampler/inquiry_sampler.py | 2 +- bcipy/simulator/demo/README.md | 47 +++++++++++ bcipy/simulator/demo/button_press_model.py | 5 +- bcipy/simulator/demo/button_press_utils.py | 4 - .../simulator/demo/resources/button_model.pkl | Bin 0 -> 475 bytes bcipy/simulator/task/task_factory.py | 2 +- 10 files changed, 143 insertions(+), 58 deletions(-) create mode 100644 bcipy/simulator/data/processor_registry.py create mode 100644 bcipy/simulator/demo/README.md create mode 100644 bcipy/simulator/demo/resources/button_model.pkl diff --git a/bcipy/parameters/devices.json b/bcipy/parameters/devices.json index 63aca8295..66caab5fb 100644 --- a/bcipy/parameters/devices.json +++ b/bcipy/parameters/devices.json @@ -120,5 +120,17 @@ "excluded_from_analysis": ["device_ts", "system_ts", "left_pupil", "right_pupil"], "status": "active", "static_offset": 0.0 + }, + { + "name": "Switch", + "content_type": "MARKERS", + "channels": [ + { "name": "Marker", "label": "Marker" } + ], + "sample_rate": 0.0, + "description": "Switch used for button press inputs", + "excluded_from_analysis": [], + "status": "active", + "static_offset": 0.0 } ] \ No newline at end of file diff --git a/bcipy/simulator/README.md b/bcipy/simulator/README.md index f6cbb0f3a..c82b3d504 100644 --- a/bcipy/simulator/README.md +++ b/bcipy/simulator/README.md @@ -126,8 +126,11 @@ optional arguments: Sim output path ``` +## Demo + +The simulator demo module contains code with explorations for future directions which can be adapted to meet various needs. View the README in that directory for more information. + ## Current Limitations -* Only provides EEG support * Only one sampler maybe provided for all devices. Ideally we should support a different sampling strategy for each device. * Only Copy Phrase is currently supported. \ No newline at end of file diff --git a/bcipy/simulator/data/data_process.py b/bcipy/simulator/data/data_process.py index 2cf217685..789174931 100644 --- a/bcipy/simulator/data/data_process.py +++ b/bcipy/simulator/data/data_process.py @@ -447,52 +447,3 @@ def get_transform(self, transform_params: ERPTransformParams, bandpass_order=transform_params.filter_order, downsample_factor=transform_params.down_sampling_rate, ) - - -# Module functions for matching a model to the correct processor. -def get_processor( - data_source: ContentType, - evidence_type: Optional[EvidenceType] = None -) -> Type[RawDataProcessor]: - """Returns the matching processor class. - - Parameters - ---------- - data_source - type of data that the processor should consume - evidence_type - type of evidence that the processor should produce. - """ - matches = [ - cls for cls in RawDataProcessor.__subclasses__() - if cls.consumes == data_source and ( - evidence_type is None or cls.produces == evidence_type) - ] - if matches: - return matches[0] - else: - msg = f"Data processor not found for {data_source.name}" - if evidence_type: - msg += f" -> {evidence_type.name}" - raise Exception(msg) - - -def find_data_processor(model: SignalModel) -> Type[RawDataProcessor]: - """Get the DataProcessor appropriate for the given model.""" - content_type = ContentType(model.metadata.device_spec.content_type) - # Metadata may provide an EvidenceType with a model so the same data source can - # be used to produce multiple types of evidence (ex. alpha) - evidence_type = None - model_output = model.metadata.evidence_type - if model_output: - try: - evidence_type = EvidenceType(model_output.upper()) - except ValueError: - logger.error(f"Unsupported evidence type: {model_output}") - - return get_processor(content_type, evidence_type) - - -def init_data_processor(signal_model: SignalModel) -> RawDataProcessor: - """Find an DataProcessor that matches the given signal_model and - initialize it.""" - processor_class = find_data_processor(signal_model) - return processor_class(signal_model) diff --git a/bcipy/simulator/data/processor_registry.py b/bcipy/simulator/data/processor_registry.py new file mode 100644 index 000000000..a128e23b6 --- /dev/null +++ b/bcipy/simulator/data/processor_registry.py @@ -0,0 +1,75 @@ +"""Registry for data processors.""" +import logging as logger +from typing import List, Optional, Type + +from bcipy.acquisition.multimodal import ContentType +from bcipy.signal.model.base_model import SignalModel +# flake8: noqa +from bcipy.simulator.data.data_process import (EegRawDataProcessor, + RawDataProcessor) +from bcipy.simulator.demo.button_press_data_processor import \ + ButtonPressDataProcessor +from bcipy.task.data import EvidenceType + +log = logger.getLogger(__name__) + +PROCESSOR_TYPES = [EegRawDataProcessor, ButtonPressDataProcessor] + + +def get_processors() -> List[Type[RawDataProcessor]]: + """Returns a list of all available raw data processors.""" + return PROCESSOR_TYPES + + +def register_processor(processor_type: Type[RawDataProcessor]) -> None: + """Register a new data processor to be used in a simulation.""" + if not processor_type in PROCESSOR_TYPES: + PROCESSOR_TYPES.append(processor_type) + + +# Module functions for matching a model to the correct processor. +def get_processor( + data_source: ContentType, + evidence_type: Optional[EvidenceType] = None +) -> Type[RawDataProcessor]: + """Returns the matching processor class. + + Parameters + ---------- + data_source - type of data that the processor should consume + evidence_type - type of evidence that the processor should produce. + """ + matches = [ + cls for cls in PROCESSOR_TYPES if cls.consumes == data_source and ( + evidence_type is None or cls.produces == evidence_type) + ] + if matches: + return matches[0] + else: + msg = f"Data processor not found for {data_source.name}" + if evidence_type: + msg += f" -> {evidence_type.name}" + raise Exception(msg) + + +def find_data_processor(model: SignalModel) -> Type[RawDataProcessor]: + """Get the DataProcessor appropriate for the given model.""" + content_type = ContentType(model.metadata.device_spec.content_type) + # Metadata may provide an EvidenceType with a model so the same data source can + # be used to produce multiple types of evidence (ex. alpha) + evidence_type = None + model_output = model.metadata.evidence_type + if model_output: + try: + evidence_type = EvidenceType(model_output.upper()) + except ValueError: + logger.error(f"Unsupported evidence type: {model_output}") + + return get_processor(content_type, evidence_type) + + +def init_data_processor(signal_model: SignalModel) -> RawDataProcessor: + """Find an DataProcessor that matches the given signal_model and + initialize it.""" + processor_class = find_data_processor(signal_model) + return processor_class(signal_model) diff --git a/bcipy/simulator/data/sampler/inquiry_sampler.py b/bcipy/simulator/data/sampler/inquiry_sampler.py index 540ae2611..72b1a1133 100644 --- a/bcipy/simulator/data/sampler/inquiry_sampler.py +++ b/bcipy/simulator/data/sampler/inquiry_sampler.py @@ -123,5 +123,5 @@ def sample(self, state: SimState) -> List[Trial]: Trial(**sorted_inquiry_df.iloc[i]) for i in range(len(sorted_inquiry_df)) ] - log.debug(f"EEG Samples:\n{format_samples(rows)}") + log.debug(f"Samples:\n{format_samples(rows)}") return rows diff --git a/bcipy/simulator/demo/README.md b/bcipy/simulator/demo/README.md new file mode 100644 index 000000000..1d78fdb41 --- /dev/null +++ b/bcipy/simulator/demo/README.md @@ -0,0 +1,47 @@ +# Simulator Demo + +This module demonstrates how the simulator can be extended for various use cases. + + +# Multimodal + +The `button_data_processor` and `button_press_model` are used to demonstrate a multimodal simulations using a button/switch as an example. To run a simulation with these inputs, you will need to perform the following steps: + +1. Ensure that the button signal model can be loaded or create a button signal model. To create a new one: + +``` +from pathlib import Path +from bcipy.acquisition.datastream.mock.switch import switch_device +from bcipy.io.save import save_model +from bcipy.simulator.demo.button_press_model import ButtonPressModel +from bcipy.signal.model.base_model import SignalModel, SignalModelMetadata + +dirname = "" # TODO: enter the directory +model = ButtonPressModel() +model.metadata = SignalModelMetadata(device_spec=switch_device(), evidence_type="BTN", transform=None) +save_model(model, Path(dirname, "button_model.pkl")) +``` +2. Ensure that the devices.json file has an entry for a switch + +``` +{ + "name": "Switch", + "content_type": "MARKERS", + "channels": [ + { "name": "Marker", "label": "Marker" } + ], + "sample_rate": 0.0, + "description": "Switch used for button press inputs", + "excluded_from_analysis": [], + "status": "active", + "static_offset": 0.0 +} +``` + +3. In your simulation parameters.json file, set the `acq_mode` parameter to 'EEG+MARKERS'. + - You may also want to set the `summarize_session` parameter to `true` to see how the evidences get combined during decision-making. +4. Run a simulation. + - Select both the EEG and the Button models + - Use the InquirySampler + +Run with verbose mode and inspect the detailed run logs to ensure that the evidence is being sampled correctly. \ No newline at end of file diff --git a/bcipy/simulator/demo/button_press_model.py b/bcipy/simulator/demo/button_press_model.py index ef939d186..3e921ae54 100644 --- a/bcipy/simulator/demo/button_press_model.py +++ b/bcipy/simulator/demo/button_press_model.py @@ -51,8 +51,9 @@ def compute_likelihood_ratio(self, data: np.array, inquiry: List[str], Returns: np.array: multiplicative update term (likelihood ratios) for each symbol in the `symbol_set`. """ - proceed = np.ones(data.shape) == data - compute_probs_after_preview(inquiry=inquiry, + + proceed = np.any(data) + return compute_probs_after_preview(inquiry=inquiry, symbol_set=symbol_set, user_error_prob=self.error_prob, proceed=proceed) diff --git a/bcipy/simulator/demo/button_press_utils.py b/bcipy/simulator/demo/button_press_utils.py index 8b0f8096c..3a1a2ecb5 100644 --- a/bcipy/simulator/demo/button_press_utils.py +++ b/bcipy/simulator/demo/button_press_utils.py @@ -1,9 +1,5 @@ """Utilities for working with button press data.""" -# Input data folder and parameters - -# could check for raw button data - import random from pathlib import Path from typing import List, Tuple diff --git a/bcipy/simulator/demo/resources/button_model.pkl b/bcipy/simulator/demo/resources/button_model.pkl new file mode 100644 index 0000000000000000000000000000000000000000..64838da8fa499ce5421553cd7b0917012dc38ace GIT binary patch literal 475 zcmX|7%TB{E5Tv3i>I10~0*M1>$|VQ*0HlJ%p=yLES7bTPhFBzVus1*tNc6zXw=M_x z13rPj;FGYEhCNy1nc10r>wf%pw(7CRk&xvwFfy+)j!FkH`6rFWj<{OmRFrC3^rbBCBGES)x_Wxa+z!(#NqqgB&a4bB#8I8emPc&O6<> e3Z5YaQ%Pa~-GzUe3s+HSx2Lxug-z-z3jP3*<-SM& literal 0 HcmV?d00001 diff --git a/bcipy/simulator/task/task_factory.py b/bcipy/simulator/task/task_factory.py index 7b5bd4cf0..94ea0097c 100644 --- a/bcipy/simulator/task/task_factory.py +++ b/bcipy/simulator/task/task_factory.py @@ -7,7 +7,7 @@ from bcipy.io.load import load_json_parameters, load_signal_model from bcipy.signal.model.base_model import SignalModel from bcipy.simulator.data.data_engine import RawDataEngine -from bcipy.simulator.data.data_process import init_data_processor +from bcipy.simulator.data.processor_registry import init_data_processor from bcipy.simulator.data.sampler import Sampler, TargetNontargetSampler from bcipy.simulator.task.copy_phrase import SimulatorCopyPhraseTask from bcipy.simulator.util.artifact import TOP_LEVEL_LOGGER_NAME From 26355675381175f197cd06fddc0daff2051a9368 Mon Sep 17 00:00:00 2001 From: lawhead Date: Fri, 14 Mar 2025 18:00:36 -0700 Subject: [PATCH 58/76] Documentation and linting --- bcipy/simulator/data/data_process.py | 2 +- bcipy/simulator/demo/button_press_data_processor.py | 5 +++++ bcipy/simulator/demo/button_press_model.py | 6 +++--- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/bcipy/simulator/data/data_process.py b/bcipy/simulator/data/data_process.py index 789174931..e9be31777 100644 --- a/bcipy/simulator/data/data_process.py +++ b/bcipy/simulator/data/data_process.py @@ -6,7 +6,7 @@ from abc import abstractmethod from dataclasses import dataclass from pathlib import Path -from typing import List, NamedTuple, Optional, Tuple, Type +from typing import List, NamedTuple, Optional, Tuple import numpy as np diff --git a/bcipy/simulator/demo/button_press_data_processor.py b/bcipy/simulator/demo/button_press_data_processor.py index 7b0b97c47..525d01fdf 100644 --- a/bcipy/simulator/demo/button_press_data_processor.py +++ b/bcipy/simulator/demo/button_press_data_processor.py @@ -31,6 +31,9 @@ class ButtonPressDataProcessor(RawDataProcessor): mode is 'press to accept', all trials in that inquiry are given data of 1.0, otherwise the data value is 0.0. If the mode is 'press to skip', trials within inquiries that do not contain the target are given 1.0 values. + + This also means that this processor should only be used in conjunction with the + InquirySampler. """ consumes = ContentType.MARKERS produces = EvidenceType.BTN @@ -64,6 +67,8 @@ def process(self, data_folder: str, # collect data for each trial in the inquiry for trg in inquiry_triggers: target_lbl = 1 if trg.type == TriggerType.TARGET else 0 + # Returns the same value for all trials in the inquiry. Implies that + # the button was pressed some time during the inquiry. data = 1.0 if should_press_button(inquiry_triggers, button_press_mode) else 0.0 inquiry_trial_data.append(data) diff --git a/bcipy/simulator/demo/button_press_model.py b/bcipy/simulator/demo/button_press_model.py index 3e921ae54..b47126a44 100644 --- a/bcipy/simulator/demo/button_press_model.py +++ b/bcipy/simulator/demo/button_press_model.py @@ -54,9 +54,9 @@ def compute_likelihood_ratio(self, data: np.array, inquiry: List[str], proceed = np.any(data) return compute_probs_after_preview(inquiry=inquiry, - symbol_set=symbol_set, - user_error_prob=self.error_prob, - proceed=proceed) + symbol_set=symbol_set, + user_error_prob=self.error_prob, + proceed=proceed) def compute_class_probabilities(self, data: np.ndarray) -> np.ndarray: """@override""" From df4451c7bd096390f91ed1dac0a0f513caba4727 Mon Sep 17 00:00:00 2001 From: lawhead Date: Mon, 17 Mar 2025 10:51:31 -0700 Subject: [PATCH 59/76] Added documentation --- bcipy/simulator/demo/README.md | 24 +++++++++++++++++-- .../demo/button_press_data_processor.py | 7 +++++- bcipy/simulator/demo/button_press_model.py | 7 +++++- bcipy/simulator/demo/button_press_utils.py | 7 ++++-- 4 files changed, 39 insertions(+), 6 deletions(-) diff --git a/bcipy/simulator/demo/README.md b/bcipy/simulator/demo/README.md index 1d78fdb41..84e98b0e7 100644 --- a/bcipy/simulator/demo/README.md +++ b/bcipy/simulator/demo/README.md @@ -38,10 +38,30 @@ save_model(model, Path(dirname, "button_model.pkl")) } ``` -3. In your simulation parameters.json file, set the `acq_mode` parameter to 'EEG+MARKERS'. +3. Set the appropriate simulation parameters in the parameters.json file. + - set the `acq_mode` parameter to 'EEG+MARKERS'. + - ensure that `preview_inquiry_progress_method` parameter is set to "1" or "2". - You may also want to set the `summarize_session` parameter to `true` to see how the evidences get combined during decision-making. 4. Run a simulation. - Select both the EEG and the Button models - Use the InquirySampler -Run with verbose mode and inspect the detailed run logs to ensure that the evidence is being sampled correctly. \ No newline at end of file +Run with verbose mode and inspect the detailed run logs to ensure that the evidence is being sampled correctly. + +## Expected Behavior + +Along with the 'eeg' evidence, the output session.json (and session.xlsx) should record 'btn' evidence for each inquiry. These evidences should be fused +to provide the 'likelihood' values. + +For inquiries in which the target is shown: +- evidence values for symbols in the inquiry should be boosted relative to non-inquiry symbols (default values are 0.95 for boosted and 0.05 for degraded). + +For inquiries in which the target not shown: +- evidence values for symbols in inquiry should be degraded + +Note that the progress method (`preview_inquiry_progress_method` parameter) doesn't matter if it is set to "press to accept" or "press to skip", since the ButtonDataProcessor interprets this and outputs a 1.0 for inquiries that should be supported and 0.0 for those that shouldn't. + +## Limitations + +- A `preview_inquiry_progress_method` of 0 is currently not supported and an exception will be thrown. Ideally, all inquiries should get an evidence value of 1.0 (no change) with this mode. +- Button evidence only works correctly with the InquirySampler. This is due to all trials in the same inquiry receiving the same value. diff --git a/bcipy/simulator/demo/button_press_data_processor.py b/bcipy/simulator/demo/button_press_data_processor.py index 525d01fdf..99d20cd93 100644 --- a/bcipy/simulator/demo/button_press_data_processor.py +++ b/bcipy/simulator/demo/button_press_data_processor.py @@ -11,7 +11,7 @@ from bcipy.core.parameters import Parameters from bcipy.core.raw_data import RawData from bcipy.core.triggers import TriggerType -from bcipy.display.main import PreviewParams +from bcipy.display.main import ButtonPressMode, PreviewParams from bcipy.io.load import load_raw_data from bcipy.simulator.data.data_process import (DecodedTriggers, ExtractedExperimentData, @@ -20,6 +20,7 @@ from bcipy.simulator.demo.button_press_utils import (should_press_button, simulate_raw_data, switch_device) +from bcipy.simulator.exceptions import IncompatibleParameters from bcipy.task.data import EvidenceType @@ -53,6 +54,10 @@ def process(self, data_folder: str, timing_params = parameters.instantiate(TimingParams) button_press_mode = parameters.instantiate( PreviewParams).button_press_mode + + if button_press_mode is ButtonPressMode.NOTHING: + raise IncompatibleParameters("Button press mode must be set.") + decoded_triggers = self.decode_triggers(data_folder, parameters) inquiry_data: List[List[float]] = [] diff --git a/bcipy/simulator/demo/button_press_model.py b/bcipy/simulator/demo/button_press_model.py index b47126a44..fa2e2a34e 100644 --- a/bcipy/simulator/demo/button_press_model.py +++ b/bcipy/simulator/demo/button_press_model.py @@ -12,10 +12,15 @@ class ButtonPressModel(SignalModel): """Signal model that classifies button presses. + This is a demo model. The provided data should be comprised of ones and zeros, + where a 1 indicates a button press occurred. If a 1.0 occurs any time within + an inquiry, all symbols in the inquiry are supported and all non-inquiry symbols + are downgraded. Otherwise the opposite happens. See tests for + inquiry_preview.compute_probs_after_preview. + Parameters ---------- error_prob - Specifies the probability of a button press error. - TODO: compute this during training. """ name = "ButtonPressModel" diff --git a/bcipy/simulator/demo/button_press_utils.py b/bcipy/simulator/demo/button_press_utils.py index 3a1a2ecb5..a59cf4966 100644 --- a/bcipy/simulator/demo/button_press_utils.py +++ b/bcipy/simulator/demo/button_press_utils.py @@ -86,9 +86,12 @@ def timestamp_within_inquiry(inquiry_triggers: List[Trigger], def simulate_raw_data(data_dir: Path, parameters: Parameters): - """Read through trigger data. For inquiries with target, output one or + """Simulate what the raw_data file for a switch would generate. + + Reads through trigger data. For inquiries with target, outputs one or more markers within the inquiry timestamp range. Or if press to reject, - output markers for inquiries without target.""" + output markers for inquiries without target. + """ spec = switch_device() raw_data_path = Path(data_dir, raw_data_filename(spec)) From 19b764f6d55d53f529c2927333ae1a7c3cb7c8d2 Mon Sep 17 00:00:00 2001 From: lawhead Date: Mon, 17 Mar 2025 15:24:05 -0700 Subject: [PATCH 60/76] Added tests for demo code --- .../resources/btn_subset_parameters.json | 127 ++++++++++++++++++ bcipy/simulator/tests/resources/triggers.txt | 23 ++++ .../tests/test_button_data_processor.py | 53 ++++++++ .../tests/test_button_press_model.py | 71 ++++++++++ 4 files changed, 274 insertions(+) create mode 100644 bcipy/simulator/tests/resources/btn_subset_parameters.json create mode 100644 bcipy/simulator/tests/resources/triggers.txt create mode 100644 bcipy/simulator/tests/test_button_data_processor.py create mode 100644 bcipy/simulator/tests/test_button_press_model.py diff --git a/bcipy/simulator/tests/resources/btn_subset_parameters.json b/bcipy/simulator/tests/resources/btn_subset_parameters.json new file mode 100644 index 000000000..c8048d991 --- /dev/null +++ b/bcipy/simulator/tests/resources/btn_subset_parameters.json @@ -0,0 +1,127 @@ +{ + "acq_mode": { + "value": "EEG+MARKERS", + "section": "acq_config", + "name": "Acquisition Mode", + "helpTip": "Specifies the hardware device(s) used for data collection. Default: EEG", + "recommended": [ + "EEG", + "EEG/DSI-24", + "Eyetracker", + "EEG+Eyetracker" + ], + "type": "str", + "editable": false + }, + "trial_window": { + "value": "0.2:0.7", + "section": "bci_config", + "name": "Trial Classification Window Length", + "helpTip": "Specifies the window (in seconds) of the EEG data collection window after each stimulus presentation. Default: 0.0:0.5", + "recommended": [ + "0.0:0.5", + "0.0:0.8", + "0.2:0.8" + ], + "type": "range", + "editable": false + }, + "prestim_length": { + "value": "1", + "section": "bci_config", + "name": "Prestimulus Window Length", + "helpTip": "Specifies the length (in seconds) of the EEG data window to return before inquiry presentation. Default: 1", + "recommended": "", + "type": "float", + "editable": false + }, + "task_buffer_length": { + "value": "2", + "section": "bci_config", + "name": "Inter-inquiry Interval", + "helpTip": "Specifies the delay time (in seconds) between the final stimulus in one inquiry and the beginning (target stimulus or fixation cross) of the next inquiry in a task. Default: 2", + "recommended": "", + "type": "float", + "editable": false + }, + "stim_length": { + "value": "10", + "section": "bci_config", + "name": "Stimuli Per inquiry", + "helpTip": "Specifies the number of stimuli to present in each inquiry. Default: 10", + "recommended": "", + "type": "int", + "editable": false + }, + "time_flash": { + "value": "0.20", + "section": "bci_config", + "name": "Stimulus Presentation Duration", + "helpTip": "Specifies the duration of time (in seconds) that each stimulus is displayed in an inquiry.", + "recommended": "", + "type": "float", + "editable": false + }, + "show_preview_inquiry": { + "value": "false", + "section": "bci_config", + "name": "Preview Inquiry On/Off", + "helpTip": "If \u2018true\u2019, the inquiry will be previewed as applicable for the task. *Note* Not all tasks will have this enabled!", + "recommended": "", + "type": "bool", + "editable": false + }, + "preview_inquiry_progress_method": { + "value": "1", + "section": "bci_config", + "name": "Preview Inquiry Progression Method", + "helpTip": "If show_preview_inquiry true, this will determine how to proceed after a key hit. 0 = preview only; 1 = press to confirm; 2 = press to skip to another inquiry", + "recommended": [ + "0", + "1", + "2" + ], + "type": "int", + "editable": false + }, + "preview_inquiry_length": { + "value": "5", + "section": "bci_config", + "name": "Preview Inquiry Display Length", + "helpTip": "Length of time in seconds to present an inquiry preview to the user.", + "recommended": "", + "type": "float", + "editable": false + }, + "preview_inquiry_key_input": { + "value": "return", + "section": "bci_config", + "name": "Preview Inquiry Display Key Input Method", + "helpTip": "Defines the key used to engage with inquiry preview.", + "recommended": [ + "space", + "escape", + "return" + ], + "type": "str", + "editable": false + }, + "preview_inquiry_isi": { + "value": "1", + "section": "bci_config", + "name": "Preview Inquiry Inter-Stimulus Interval", + "helpTip": "The time between previewing an inquiry and the start of an inquiry.", + "recommended": "", + "type": "float", + "editable": false + }, + "preview_box_text_size": { + "value": "0.1", + "section": "task_config", + "name": "Preview Inquiry Box Text Size", + "helpTip": "Specifies the height of text stimuli in the preview inquiry box. See https://www.psychopy.org/general/units.html. Default: 0.1", + "recommended": "", + "editable": false, + "type": "float" + } +} \ No newline at end of file diff --git a/bcipy/simulator/tests/resources/triggers.txt b/bcipy/simulator/tests/resources/triggers.txt new file mode 100644 index 000000000..a2294ed00 --- /dev/null +++ b/bcipy/simulator/tests/resources/triggers.txt @@ -0,0 +1,23 @@ +starting_offset offset -2487.8116187 ++ fixation 2496.0808361 +C nontarget 2496.6508996 +H nontarget 2496.8879651 +E nontarget 2497.123811 +G target 2497.358904 +< nontarget 2497.5953845 +B nontarget 2497.8305515 +D nontarget 2498.0661673 +I nontarget 2498.3010261 +F nontarget 2498.5363697 +A nontarget 2498.7704075 ++ fixation 2503.0619242 +P nontarget 2503.6330463 +S nontarget 2503.8674911 +O nontarget 2504.1034164 +R nontarget 2504.3382754 +J nontarget 2504.5739756 +L nontarget 2504.8098963 +M nontarget 2505.0455712 +K nontarget 2505.2799301 +Q nontarget 2505.5152481 +N nontarget 2505.7499999 \ No newline at end of file diff --git a/bcipy/simulator/tests/test_button_data_processor.py b/bcipy/simulator/tests/test_button_data_processor.py new file mode 100644 index 000000000..eddd2efc6 --- /dev/null +++ b/bcipy/simulator/tests/test_button_data_processor.py @@ -0,0 +1,53 @@ +"""Tests for Button Press data processor.""" +import os +import tempfile +import unittest +from pathlib import Path + +import numpy as np + +from bcipy.acquisition.datastream.mock.switch import switch_device +from bcipy.io.load import load_json_parameters +from bcipy.signal.model.base_model import SignalModelMetadata +from bcipy.simulator.demo.button_press_data_processor import \ + ButtonPressDataProcessor +from bcipy.simulator.demo.button_press_model import ButtonPressModel + + +class ButtonPressProcessorTest(unittest.TestCase): + """Tests for Button Press data processor.""" + + def setUp(self): + """Override.""" + self.data_dir = f"{os.path.dirname(__file__)}/resources/" + self.temp_dir = tempfile.mkdtemp() + self.model = ButtonPressModel() + self.model.metadata = SignalModelMetadata(device_spec=switch_device(), + evidence_type="BTN", + transform=None) + + self.params_path = Path(self.data_dir, "btn_subset_parameters.json") + + def test_extracted_data(self): + """Test extracted data.""" + processor = ButtonPressDataProcessor(model=self.model) + params = load_json_parameters(self.params_path) + + extracted_data = processor.process(self.data_dir, params) + self.assertEqual( + [[0, 0, 0, 1, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]], + extracted_data.labels) + self.assertEqual((1, 2, 10), extracted_data.inquiries.shape) + self.assertEqual((1, 20, 1), extracted_data.trials.shape) + + data = np.array([[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0], + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]]) + expected_inquiries = data.reshape((1, 2, 10)) + expected_trials = data.reshape((1, 20, 1)) + self.assertTrue( + np.allclose(expected_inquiries, extracted_data.inquiries)) + self.assertTrue(np.allclose(expected_trials, extracted_data.trials)) + + +if __name__ == '__main__': + unittest.main() diff --git a/bcipy/simulator/tests/test_button_press_model.py b/bcipy/simulator/tests/test_button_press_model.py new file mode 100644 index 000000000..95402fdc6 --- /dev/null +++ b/bcipy/simulator/tests/test_button_press_model.py @@ -0,0 +1,71 @@ +"""Tests for Button Press Model""" +import unittest +from string import ascii_uppercase + +import numpy as np + +from bcipy.simulator.demo.button_press_model import ButtonPressModel + + +class ButtonPressModelTest(unittest.TestCase): + """Tests for Button Press Model.""" + + def setUp(self): + """Override; set up model.""" + self.model = ButtonPressModel() + self.symbol_set = list(ascii_uppercase) + + def test_all_positive_inquiry(self): + """Test model results when the data indicates that the inquiry should + be supported. All inquiry symbols have value of 1.0""" + inquiry = ['A', 'B', 'C', 'D', 'E'] + + expected = [ + 0.95, 0.95, 0.95, 0.95, 0.95, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, + 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, + 0.05, 0.05, 0.05, 0.05 + ] + results = self.model.compute_likelihood_ratio( + data=np.array([1.0, 1.0, 1.0, 1.0, 1.0]), + inquiry=inquiry, + symbol_set=self.symbol_set) + + self.assertTrue(np.allclose(results, expected)) + + def test_any_positive_inquiry(self): + """Test model results when the data indicates that the inquiry should + be supported. Some inquiry symbols have value of 1.0""" + inquiry = ['A', 'B', 'C', 'D', 'E'] + + expected = [ + 0.95, 0.95, 0.95, 0.95, 0.95, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, + 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, + 0.05, 0.05, 0.05, 0.05 + ] + results = self.model.compute_likelihood_ratio( + data=np.array([0.0, 1.0, 0.0, 0.0, 0.0]), + inquiry=inquiry, + symbol_set=self.symbol_set) + + self.assertTrue(np.allclose(results, expected)) + + def test_all_negative_inquiry(self): + """Test model results when the data indicates that the inquiry symbols + should be downgraded. All inquiry symbols have value of 0.0""" + inquiry = ['A', 'B', 'C', 'D', 'E'] + + expected = [ + 0.05, 0.05, 0.05, 0.05, 0.05, 0.95, 0.95, 0.95, 0.95, 0.95, 0.95, + 0.95, 0.95, 0.95, 0.95, 0.95, 0.95, 0.95, 0.95, 0.95, 0.95, 0.95, + 0.95, 0.95, 0.95, 0.95 + ] + results = self.model.compute_likelihood_ratio( + data=np.array([0.0, 0.0, 0.0, 0.0, 0.0]), + inquiry=inquiry, + symbol_set=self.symbol_set) + + self.assertTrue(np.allclose(results, expected)) + + +if __name__ == '__main__': + unittest.main() From 11362f7721a67c1e1c3ffc0f939d57601cf31093 Mon Sep 17 00:00:00 2001 From: lawhead Date: Mon, 17 Mar 2025 16:14:48 -0700 Subject: [PATCH 61/76] Fix README markup --- bcipy/simulator/demo/README.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/bcipy/simulator/demo/README.md b/bcipy/simulator/demo/README.md index 84e98b0e7..9f6d0d96a 100644 --- a/bcipy/simulator/demo/README.md +++ b/bcipy/simulator/demo/README.md @@ -3,7 +3,7 @@ This module demonstrates how the simulator can be extended for various use cases. -# Multimodal +## Multimodal The `button_data_processor` and `button_press_model` are used to demonstrate a multimodal simulations using a button/switch as an example. To run a simulation with these inputs, you will need to perform the following steps: @@ -21,6 +21,7 @@ model = ButtonPressModel() model.metadata = SignalModelMetadata(device_spec=switch_device(), evidence_type="BTN", transform=None) save_model(model, Path(dirname, "button_model.pkl")) ``` + 2. Ensure that the devices.json file has an entry for a switch ``` @@ -39,29 +40,34 @@ save_model(model, Path(dirname, "button_model.pkl")) ``` 3. Set the appropriate simulation parameters in the parameters.json file. + - set the `acq_mode` parameter to 'EEG+MARKERS'. - ensure that `preview_inquiry_progress_method` parameter is set to "1" or "2". - You may also want to set the `summarize_session` parameter to `true` to see how the evidences get combined during decision-making. + 4. Run a simulation. + - Select both the EEG and the Button models - Use the InquirySampler Run with verbose mode and inspect the detailed run logs to ensure that the evidence is being sampled correctly. -## Expected Behavior +### Expected Behavior Along with the 'eeg' evidence, the output session.json (and session.xlsx) should record 'btn' evidence for each inquiry. These evidences should be fused to provide the 'likelihood' values. For inquiries in which the target is shown: + - evidence values for symbols in the inquiry should be boosted relative to non-inquiry symbols (default values are 0.95 for boosted and 0.05 for degraded). For inquiries in which the target not shown: + - evidence values for symbols in inquiry should be degraded Note that the progress method (`preview_inquiry_progress_method` parameter) doesn't matter if it is set to "press to accept" or "press to skip", since the ButtonDataProcessor interprets this and outputs a 1.0 for inquiries that should be supported and 0.0 for those that shouldn't. -## Limitations +### Limitations - A `preview_inquiry_progress_method` of 0 is currently not supported and an exception will be thrown. Ideally, all inquiries should get an evidence value of 1.0 (no change) with this mode. - Button evidence only works correctly with the InquirySampler. This is due to all trials in the same inquiry receiving the same value. From 3e5556fa23dcdc57eceeb8c27aeee063d528939c Mon Sep 17 00:00:00 2001 From: lawhead Date: Mon, 17 Mar 2025 16:28:23 -0700 Subject: [PATCH 62/76] Fix README markup --- bcipy/simulator/demo/README.md | 52 +++++++++++++++++----------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/bcipy/simulator/demo/README.md b/bcipy/simulator/demo/README.md index 9f6d0d96a..cc948c9d2 100644 --- a/bcipy/simulator/demo/README.md +++ b/bcipy/simulator/demo/README.md @@ -9,35 +9,35 @@ The `button_data_processor` and `button_press_model` are used to demonstrate a m 1. Ensure that the button signal model can be loaded or create a button signal model. To create a new one: -``` -from pathlib import Path -from bcipy.acquisition.datastream.mock.switch import switch_device -from bcipy.io.save import save_model -from bcipy.simulator.demo.button_press_model import ButtonPressModel -from bcipy.signal.model.base_model import SignalModel, SignalModelMetadata - -dirname = "" # TODO: enter the directory -model = ButtonPressModel() -model.metadata = SignalModelMetadata(device_spec=switch_device(), evidence_type="BTN", transform=None) -save_model(model, Path(dirname, "button_model.pkl")) -``` + ``` + from pathlib import Path + from bcipy.acquisition.datastream.mock.switch import switch_device + from bcipy.io.save import save_model + from bcipy.simulator.demo.button_press_model import ButtonPressModel + from bcipy.signal.model.base_model import SignalModel, SignalModelMetadata + + dirname = "" # TODO: enter the directory + model = ButtonPressModel() + model.metadata = SignalModelMetadata(device_spec=switch_device(), evidence_type="BTN", transform=None) + save_model(model, Path(dirname, "button_model.pkl")) + ``` 2. Ensure that the devices.json file has an entry for a switch -``` -{ - "name": "Switch", - "content_type": "MARKERS", - "channels": [ - { "name": "Marker", "label": "Marker" } - ], - "sample_rate": 0.0, - "description": "Switch used for button press inputs", - "excluded_from_analysis": [], - "status": "active", - "static_offset": 0.0 -} -``` + ``` + { + "name": "Switch", + "content_type": "MARKERS", + "channels": [ + { "name": "Marker", "label": "Marker" } + ], + "sample_rate": 0.0, + "description": "Switch used for button press inputs", + "excluded_from_analysis": [], + "status": "active", + "static_offset": 0.0 + } + ``` 3. Set the appropriate simulation parameters in the parameters.json file. From aa216ca8db575232a7507db1d0994d8d6f13d203 Mon Sep 17 00:00:00 2001 From: tab-cmd Date: Thu, 20 Mar 2025 01:27:49 -0700 Subject: [PATCH 63/76] Causal LM Integration (#377) --- .github/workflows/main.yml | 12 +- Makefile | 1 - bcipy/helpers/language_model.py | 11 +- bcipy/language/model/causal.py | 473 ++- bcipy/parameters/lm_params.json | 14 +- bcipy/parameters/parameters.json | 6 +- .../process/decomposition/resources/data.txt | 3000 ----------------- .../decomposition/test_decomposition.py | 101 - bcipy/simulator/README.md | 52 +- bcipy/simulator/data/data_process.py | 2 +- bcipy/simulator/demo/demo_group_simulation.py | 202 ++ bcipy/simulator/task/copy_phrase.py | 9 +- bcipy/simulator/task/null_daq.py | 18 + bcipy/simulator/task/null_display.py | 3 + bcipy/simulator/task/replay_session.py | 5 +- bcipy/simulator/task/task_factory.py | 47 +- bcipy/simulator/task/task_runner.py | 4 +- bcipy/simulator/ui/cli.py | 6 +- bcipy/simulator/ui/gui.py | 4 +- bcipy/simulator/util/artifact.py | 7 +- pyproject.toml | 1 + scripts/python/replay_session.py | 364 -- scripts/python/update_params.py | 21 +- 23 files changed, 689 insertions(+), 3674 deletions(-) delete mode 100644 bcipy/signal/tests/process/decomposition/resources/data.txt delete mode 100644 bcipy/signal/tests/process/decomposition/test_decomposition.py create mode 100644 bcipy/simulator/demo/demo_group_simulation.py create mode 100644 bcipy/simulator/task/null_daq.py delete mode 100644 scripts/python/replay_session.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f5f11eded..bab7d7316 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -18,7 +18,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [3.8, 3.9, 3.10.6] + python-version: [3.9, 3.10.6] steps: - uses: actions/checkout@v2 @@ -69,7 +69,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [3.8, 3.9, 3.10.6] + python-version: [3.9, 3.10.6] steps: - uses: actions/checkout@v2 @@ -93,9 +93,6 @@ jobs: - name: lint run: | make lint - - name: integration-test - run: | - make integration-test - name: build run: | make build @@ -106,7 +103,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [3.8, 3.9, 3.10.6] + python-version: [3.9, 3.10.6] steps: - uses: actions/checkout@v4 @@ -133,9 +130,6 @@ jobs: - name: lint run: | make lint - - name: integration-test - run: | - make integration-test - name: build run: | make build diff --git a/Makefile b/Makefile index 109d4e997..f350c89e5 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,6 @@ test-all: make coverage-report make type make lint - make integration-test unit-test: pytest --mpl -k "not slow" diff --git a/bcipy/helpers/language_model.py b/bcipy/helpers/language_model.py index 4e1cb1745..2c0bcd4d0 100644 --- a/bcipy/helpers/language_model.py +++ b/bcipy/helpers/language_model.py @@ -28,7 +28,8 @@ def language_models_by_name() -> Dict[str, LanguageModel]: def init_language_model(parameters: dict) -> LanguageModel: """ - Init Language Model configured in the parameters. + Init Language Model configured in the parameters. If no language model is + specified, a uniform language model is returned. Parameters ---------- @@ -48,9 +49,11 @@ def init_language_model(parameters: dict) -> LanguageModel: # select the relevant parameters into a dict. params = {key: parameters[key] for key in args & parameters.keys()} - return model(response_type=ResponseType.SYMBOL, - symbol_set=alphabet(parameters), - **params) + + return model( + response_type=ResponseType.SYMBOL, + symbol_set=alphabet(parameters), + **params) def norm_domain(priors: List[Tuple[str, float]]) -> List[Tuple[str, float]]: diff --git a/bcipy/language/model/causal.py b/bcipy/language/model/causal.py index 60b84274d..4a2a475fd 100644 --- a/bcipy/language/model/causal.py +++ b/bcipy/language/model/causal.py @@ -1,4 +1,3 @@ -from collections import Counter import torch from typing import Optional, List, Tuple from transformers import AutoModelForCausalLM, AutoTokenizer @@ -7,12 +6,13 @@ from bcipy.core.symbols import BACKSPACE_CHAR, SPACE_CHAR from bcipy.language.main import LanguageModel, ResponseType - from bcipy.exceptions import InvalidLanguageModelException from scipy.special import logsumexp from scipy.special import softmax - +import time +from collections import defaultdict +from typing import Final from bcipy.config import LM_PATH @@ -26,45 +26,78 @@ def __init__(self, lm_path: Optional[str] = None, lm_device: str = "cpu", lm_left_context: str = "", + beam_width: int = None, + fp16: bool = True, + mixed_case_context: bool = True, + case_simple: bool = True, + max_completed: int = None, ): """ Initialize instance variables and load the language model with given path Args: response_type - SYMBOL only - symbol_set - list of symbol strings + symbol_set - list of symbol strings lang_model_name - name of the Hugging Face casual language model to load - lm_path - load fine-tuned model from specified directory - lm_device - device to use for making predictions (cpu, mps, or cuda) - lm_left_context - text to condition start of sentence on + lm_path - load fine-tuned model from specified directory + lm_device - device to use for making predictions (cpu, mps, or cuda) + lm_left_context - text to condition start of sentence on + beam_width - how many hypotheses to keep during the search, None=off + fp16 - convert model to fp16 to save memory/compute on CUDA + mixed_case_context - use mixed case for language model left context + case_simple - simple fixing of left context case + max_completed - stop search once we reach this many completed hypotheses, None=don't prune """ super().__init__(response_type=response_type, symbol_set=symbol_set) + + causal_params = self.parameters['causal'] + self.model = None self.tokenizer = None self.vocab_size = 0 self.valid_vocab = [] - self.vocab = {} - self.longest_token = 0 - self.index_to_word = {} - self.index_to_word_lower = {} + self.vocab = defaultdict(list) + # Since subword token ids are integers, use a list instead of a + # dictionary + self.index_to_word = [] + self.index_to_word_lower = [] self.symbol_set_lower = None self.device = lm_device self.left_context = lm_left_context + self.beam_width = beam_width or int(causal_params['beam_width']['value']) + self.fp16 = fp16 + self.mixed_case_context = mixed_case_context + self.case_simple = case_simple + self.max_completed = max_completed or int(causal_params['max_completed']['value']) + + if not self.max_completed and not self.beam_width: + print("WARNING: using causal language model without any pruning, this can be slow!") + else: + print(f"Causal language model, beam_width {self.beam_width}, max_completed {self.max_completed}") # We optionally load the model from a local directory, but if this is not # specified, we load a Hugging Face model - causal_params = self.parameters['causal'] self.model_name = lang_model_name or causal_params['model_name']['value'] local_model_path = lm_path or causal_params['model_path']['value'] self.model_dir = f"{LM_PATH}/{local_model_path}" if local_model_path != "" else self.model_name - # parameters for search - self.beam_width = 8 - self.batch_size = 8 + # Parameters for the search + + # Simple heuristic to correct case in the LM context + self.simple_upper_words = {"i": "I", + "i'll": "I'll", + "i've": "I've", + "i'd": "I'd", + "i'm": "I'm"} + + # Track how much time spent in different parts of the predict function + self.predict_total_ns = 0 + self.predict_inference_ns = 0 - # backoff to the previous space - self.token_backoff = -1 + # Are we a model that automatically inserts a start token that we need + # to get rid of + self.drop_first_token = False self.load() @@ -76,14 +109,21 @@ def _build_vocab(self) -> None: Build a vocabulary table mapping token index to word strings """ + # Loop over all the subword tokens in the LLM for i in range(self.vocab_size): + # Create a map from the subword token integer ID to the mixed and + # lowercase string versions word = self.tokenizer.decode([i]) word_lower = word.lower() - self.index_to_word[i] = word - self.index_to_word_lower[i] = word_lower + self.index_to_word += word, + self.index_to_word_lower += word_lower, + + # Check if all the characters in the subword token are in our valid + # symbol set valid = True for ch in word_lower: - # The space char is only valid once we convert spaces to the space char + # The space char is only valid once we convert spaces to the + # space char if ch == SPACE_CHAR: valid = False break @@ -92,33 +132,77 @@ def _build_vocab(self) -> None: elif ch not in self.symbol_set_lower: valid = False break + + # If the subword token symbols are all valid, then add it to the + # list of valid token IDs if valid: self.valid_vocab += i, - length = len(word) - if length > self.longest_token: - self.longest_token = length - for j in range(length): + # Add this token ID to all lists for its valid text prefixes + for j in range(len(word)): key = word_lower[0:j + 1].replace(' ', SPACE_CHAR) - if key not in self.vocab: - self.vocab[key] = [] self.vocab[key] += i, + # When done, self.vocab can be used to map to possible following subword tokens given some text, e.g.: + # self.vocab["cyclo"] = [47495, 49484] + # self.index_to_word[self.vocab["cyclo"][0]] = cyclop + # self.index_to_word[self.vocab["cyclo"][1]] = cyclopedia + + (self.model_name.startswith("facebook/opt") + or self.model_name.startswith("figmtu/opt") + or "Llama-3.1" in self.model_name) + # Get the index we use for the start or end pseudo-word if self.left_context == "": if "gpt2" in self.model_name: self.left_context = "<|endoftext|>" + elif "Llama-3.1" in self.model_name: + self.left_context = "<|begin_of_text|>" + # Seems to have both sentence start and end tokens: + # https://docs.mistral.ai/guides/tokenization/ + elif "Mistral" in self.model_name: + self.left_context = "" else: self.left_context = "" + + # OPT, Llama and Mistral all insert start token + self.drop_first_token = (self.model_name.startswith("facebook/opt") or + self.model_name.startswith("figmtu/opt") or + "Llama-3.1" in self.model_name or + "Mistral" in self.model_name) + # Get token id(s) for the left context we condition all sentences on self.left_context_tokens = self._encode(self.left_context) + print(f"Causal: left_context = '{self.left_context}', left_context_tokens = {self.left_context_tokens}") def _encode(self, text: str) -> List[int]: tokens = self.tokenizer.encode(text) - if len(tokens) > 1 and self.model_name.startswith("facebook/opt"): + # Both OPT and Llama automatically insert a start token which we want + # to control ourselves + if len(tokens) > 1 and self.drop_first_token: tokens = tokens[1:] return tokens + def _sequence_string(self, sequence: List[int]) -> str: + """ + Convert a sequence of subword token IDs into a string with each token in ()'s + :param sequence: List of subword token IDs + :return: String + """ + return "".join([f"({self.index_to_word[x]})" for x in sequence]) + + def get_all_tokens_text(self): + """ + Return an array with the text of all subword tokens. + The array is in order by the integer index into the vocabulary. + This is mostly just for exploring the tokens in different LLMs. + :return: Array of subword token text strings. + """ + result = [] + for i in range(self.vocab_size): + result.append(self.tokenizer.decode([i])) + return result + def predict(self, evidence: List[str]) -> List[Tuple]: """ Given an evidence of typed string, predict the probability distribution of @@ -130,121 +214,215 @@ def predict(self, evidence: List[str]) -> List[Tuple]: """ assert self.model is not None, "language model does not exist!" + start_ns = time.time_ns() converted_context = "".join(evidence) converted_context_lower = converted_context.lower() - context = converted_context.replace(SPACE_CHAR, ' ') + + # If using the simple case feature, we need to go through the actual + # left context and capitalize the first letter in the sentence as + # well as any word in our list of words that should be capitalized. + if self.case_simple and len(context) > 0: + cased_context = "" + words = context.split() + for i, word in enumerate(words): + if i == 0 and word[0] >= 'a' and word[0] <= 'z': + word = word[0].upper() + word[1:] + if i > 0: + if word in self.simple_upper_words: + word = self.simple_upper_words[word] + cased_context += " " + cased_context += word + # Handle ending space in the context + if context[-1] == ' ': + cased_context += " " + context = cased_context + context_lower = context.lower() - # Index in the hypothesis string that is the next character after our context + # Index in the hypothesis string that is the next character after our + # context target_pos = len(context_lower) - # Remove token_backoff tokens from the end of the context - # If token_backoff is -1 or goes beyond a word boundary, then - # search from the last space character in the context - # If no space, then from the very beginning - tokens = self._encode(context_lower) + # For stats purposes track length of the prefix we are extending from space to match + # prefix_len = target_pos - # Look for the last space in the context, or -1 if no begin_text in context yet + # Look for the last space in the context, or -1 if no begin_text in + # context yet pos = context_lower.rfind(" ") + tokens = [] + tokens.extend(self.left_context_tokens) if pos >= 0: - truncated_context = context_lower[0:pos] - truncated_tokens = self._encode(truncated_context) - # Insert the left context tokens at the start of the sequence - truncated_tokens[0:0] = self.left_context_tokens - else: - # Didn't find space so start inference with just the left context tokens - truncated_tokens = self.left_context_tokens + # Optionally, we condition on upper and lower case left context + if self.mixed_case_context: + truncated_context = context[0:pos] + else: + truncated_context = context_lower[0:pos] + tokens.extend(self._encode(truncated_context)) + # prefix_len -= pos + + # print(f"DEBUG, {context_lower} pos {pos}, prefix_len {prefix_len}") + + # Constant indexes for use with the hypotheses tuples + LOGP: Final[int] = 0 + SEQ: Final[int] = 1 + LEN: Final[int] = 2 + + # Our starting hypothesis that we'll be extending. + # Format is (log likelihood, token id sequence, text length). + # Note: we only include tokens after any in left context. + start_length = 0 + for x in tokens[len(self.left_context_tokens):]: + start_length += len(self.index_to_word_lower[x]) + current_hypos = [(0.0, tokens, start_length)] + + # We use a priority queue to track the top hypotheses during the beam search. + # For a beam of 8, empirical testing showed this was about the same amount + # of time as a simpler list that used a linear search to replace when + # full. + heapq.heapify(current_hypos) + + # Create a hash mapping each valid following character to a list of log + # probabilities + char_to_log_probs = defaultdict(list) + + # Add new extended hypotheses to this heap + next_hypos = [] + + # Tracks count of completed hypotheses + completed = 0 + + # Used to signal to while loop to stop the search + done = False + + # Start a beam search forward from the backed off token sequence. + # Each iteration of this while loop extends hypotheses by all valid tokens. + # We only keep at most self.beam_width hypotheses in the valid heap. + # Stop extending search once we reach our max completed target. + while len(current_hypos) > 0 and not done: + # We'll explore hypothesis in order from most probable to least. + # This has little impact on how long it takes since this is only sorting a small number of things. + # But it is important with max_completed pruning since we want to + # bias for completing high probability things. + current_hypos.sort(reverse=True) + + # Work on the hypotheses from the last round of extension. + # Create the torch tensor for the inference with a row for each + # hypothesis. + tokens_tensor = torch.tensor([x[SEQ] for x in current_hypos]).reshape( + len(current_hypos), -1).to(self.device) + + before_inference_ns = time.time_ns() + # Ask the LLM to predict tokens that come after our current set of + # hypotheses + with torch.no_grad(): + # Compute the probabilities from the logits + log_probs = torch.log_softmax(self.model( + tokens_tensor).logits[:, -1, :], dim=1) + + # Create a big 2D tensor where each row is that hypothesis' current likelihood. + # First create a list of just the hypotheses' likelihoods. + # Then reshape to be a column vector. + # Then duplicate the column based on the number of subword + # tokens in the LLM. + add_tensor = torch.tensor([x[LOGP] for x in current_hypos]).reshape( + (log_probs.size()[0], 1)).repeat(1, log_probs.size()[1]).to(self.device) + + # Add the current likelihoods with each subtoken's probability. + # Move it back to the CPU and convert to numpy since this makes + # it a lot faster to access for some reason. + new_log_probs = torch.add( + log_probs, add_tensor).detach().cpu().numpy() + self.predict_inference_ns += time.time_ns() - before_inference_ns + + for current_index, current in enumerate(current_hypos): + vocab = [] + extra_vocab = [] + # Extending this hypothesis must match the remaining text + remaining_context = converted_context_lower[current[LEN]:] + if len(remaining_context) == 0: + # There is no remaining context thus all subword tokens that are valid under our symbol set + # should be considered when computing the probability of + # the next character. + vocab = self.valid_vocab + else: + if remaining_context in self.vocab: + # We have a list of subword tokens that match the remaining text. + # They could be the same length as the remaining text + # or longer and have the remaining text as a prefix. + vocab = self.vocab[remaining_context] + + # We may need to use a subword token that doesn't completely consume the remaining text. + # Find these by tokenizing all possible lengths of text + # starting from the current position. + for i in range(1, len(remaining_context)): + tokenization = self._encode( + context_lower[current[LEN]:current[LEN] + i]) + # Ignore tokenizations involving multiple tokens since + # they involve an ID we would have already added. + if len(tokenization) == 1: + extra_vocab += tokenization[0], + + # The below code takes the most time, results from pprofile on 5 phrases on an 2080 GPU: + # 299| 22484582| 89.5763| 3.9839e-06| 14.24%| for token_id in itertools.chain(vocab, extra_vocab): + # 300| 0| 0| 0| 0.00%| # For a hypothesis to finish it must extend beyond the existing typed context + # 301| 22483271| 93.7939| 4.17172e-06| 14.91%| subword_len = len(self.index_to_word_lower[token_id]) + # 302| 22483271| 92.8608| 4.13022e-06| 14.76%| if (current[LEN] + subword_len) > len(context): + # 303| 0| 0| 0| 0.00%| # Add this likelihood to the list for the character at the prediction position. + # 304| 0| 0| 0| 0.00%| # Tracking the list and doing logsumpexp later was faster than doing it for each add. + # 305| 22480431| 106.353| 4.73094e-06| 16.90%| char_to_log_probs[self.index_to_word_lower[token_id][target_pos - current[LEN]]] += new_log_probs[current_index][token_id], + # 306| 22480431| 92.689| 4.1231e-06| 14.73%| completed += 1 + # 307| 2840| 0.0124488| 4.38338e-06| 0.00%| elif not self.beam_width or len(next_hypos) < + # + # Tuning notes: + # - With a beam of 8 and max completed of 32,000, getting around 5x speedup on written dev set. + # - This results in a PPL increase of 0.0025 versus old results using only beam of >= 8. + # - Pruning based on log probability difference and based on minimum number of hypotheses per symbol in alphabet did worse. + # - Code for these other pruning methods was removed. + # Possible ways to make it faster: + # - Stop part way through the below for loop over (vocab, extra_vocab). But this seems weird since the token IDs are in + # no particular order, we'd be just stopping early on the last hypothesis being explored by the enclosing loop. + # - Sort the rows in the log prob results on the GPU. Use these to limit which token IDs we explore in the below + # for loop. Is it possible to do this without introducing too + # much extra work to limit to the high probability ones? + + # Create a list of token indexes that are a prefix of the target text. + # We go over all the integer IDs in the vocab and extra_vocab + # lists. + for token_id in itertools.chain(vocab, extra_vocab): + # For a hypothesis to finish it must extend beyond the + # existing typed context + subword_len = len(self.index_to_word_lower[token_id]) + if (current[LEN] + subword_len) > len(context): + # Add this likelihood to the list for the character at the prediction position. + # Tracking the list and doing logsumpexp later was + # faster than doing it for each add. + char_to_log_probs[self.index_to_word_lower[token_id][target_pos - + current[LEN]]] += new_log_probs[current_index][token_id], + completed += 1 + elif not self.beam_width or len(next_hypos) < self.beam_width: + # If we are under the beam limit then just add it + heapq.heappush(next_hypos, + (new_log_probs[current_index][token_id], + current[SEQ] + [token_id], + current[LEN] + subword_len)) + elif new_log_probs[current_index][token_id] > next_hypos[0][LOGP]: + # Or replace the worst hypotheses with the new one + heapq.heappushpop(next_hypos, + (new_log_probs[current_index][token_id], + current[SEQ] + [token_id], + current[LEN] + subword_len)) + + # Break out of the for loop over hypotheses and while loop if + # we reach our max completed goal + if self.max_completed and completed >= self.max_completed: + done = True + break - if self.token_backoff == -1 or len(tokens) - self.token_backoff < len(truncated_tokens): - tokens = truncated_tokens - else: - tokens = tokens[:-self.token_backoff] - - # Build up the sequence text for the context - # Start after the left context tokens - sequence_text = "" - for i in range(len(self.left_context_tokens), len(tokens)): - sequence_text = sequence_text + self.index_to_word_lower[tokens[i]] - valid = [(0.0, tokens, sequence_text)] - heapq.heapify(valid) - - # Create a hash mapping each valid following character to a list of log probabilities - char_to_log_probs = {} - - while len(valid) > 0: - # Only work on the top hypotheses from the last round of extension - current = list(valid) - - # Add new extended hypotheses to this list - valid.clear() - - # Keep going until we have extended all hypotheses in the current set - while len(current) > 0: - current_batch = 0 - batch_tensors = [] - batch_sequences = [] - batch_likelihoods = [] - batch_seq_text = [] - while len(current) > 0 and current_batch < self.batch_size: - # Get the new sequence to work on - (current_likelihood, sequence, sequence_text) = current.pop(0) - batch_tensors += torch.tensor(sequence), - batch_sequences += sequence, - batch_likelihoods += current_likelihood, - batch_seq_text += sequence_text, - current_batch += 1 - - tokens_tensor = torch.stack(tuple(batch_tensors)).to(self.device) - - with torch.no_grad(): - logits = self.model(tokens_tensor).logits - log_probs = torch.log_softmax(logits[:, -1, :], dim=1).to("cpu") - - for j in range(current_batch): - sequence_text = batch_seq_text[j] - vocab = [] - extra_vocab = [] - - remaining_context = converted_context_lower[len(sequence_text):] - if len(remaining_context) == 0: - vocab = self.valid_vocab - else: - if remaining_context in self.vocab: - vocab = self.vocab[remaining_context] - for i in range(1, len(remaining_context)): - tokenization = self._encode(context_lower[len(sequence_text):len(sequence_text) + i]) - if len(tokenization) == 1: - extra_vocab += tokenization[0], - - # Create a list of token indexes that are a prefix of target text - # We go over all the integer IDs in the vocab and extra_vocab lists - for i in itertools.chain(vocab, extra_vocab): - hypo_str = sequence_text + self.index_to_word_lower[i] - hypo_seq = batch_sequences[j].copy() - hypo_seq += i, - - # Add the log prob of this token to the previous running total - # For some reason the float cast makes it run faster - likelihood = batch_likelihoods[j] + float(log_probs[j][i]) - # If we have extended to a space following the context, then that hypothesis gets to be done - # This takes a lot longer that just requiring extending beyond existing context - # Just require hypotheses to extend beyond the existing typed context - if len(hypo_str) > len(context): - ch = hypo_str[target_pos] - - # Create an empty list if we haven't seen this character before - if ch not in char_to_log_probs: - char_to_log_probs[ch] = [] - char_to_log_probs[ch] += likelihood, - - else: - hypo = (likelihood, hypo_seq, hypo_str) - if len(valid) < self.beam_width: - heapq.heappush(valid, hypo) - else: - heapq.heappushpop(valid, hypo) + # Swap in the extended set as the new current working set + current_hypos = next_hypos + next_hypos = [] # Parallel array to symbol_set for storing the marginals char_probs = [] @@ -260,20 +438,29 @@ def predict(self, evidence: List[str]) -> List[Tuple]: char_probs += logsumexp(char_to_log_probs[target_ch]), else: char_probs += float("-inf"), + # Normalize to a distribution that sums to 1 char_probs = softmax(char_probs) - next_char_pred = Counter() - + next_char_pred = {} for i, ch in enumerate(self.symbol_set_lower): if ch is SPACE_CHAR: next_char_pred[ch] = char_probs[i] else: next_char_pred[ch.upper()] = char_probs[i] - next_char_pred[BACKSPACE_CHAR] = 0.0 - return list(sorted(next_char_pred.items(), key=lambda item: item[1], reverse=True)) + end_ns = time.time_ns() + self.predict_total_ns += end_ns - start_ns + + return list(sorted(next_char_pred.items(), + key=lambda item: item[1], reverse=True)) + + def dump_predict_times(self) -> None: + """Print some stats about the prediction timing""" + if self.predict_total_ns > 0: + print(f"Predict %: " + f"inference {self.predict_inference_ns / self.predict_total_ns * 100.0:.3f}") def update(self) -> None: """Update the model state""" @@ -284,12 +471,16 @@ def load(self) -> None: Load the language model and tokenizer, initialize class variables """ try: - self.tokenizer = AutoTokenizer.from_pretrained(self.model_name, use_fast=False) + self.tokenizer = AutoTokenizer.from_pretrained( + self.model_name, use_fast=False) except BaseException: - raise InvalidLanguageModelException(f"{self.model_name} is not a valid model identifier on HuggingFace.") + raise InvalidLanguageModelException( + f"{self.model_name} is not a valid model identifier on HuggingFace.") self.vocab_size = self.tokenizer.vocab_size try: self.model = AutoModelForCausalLM.from_pretrained(self.model_dir) + if self.fp16 and self.device == "cuda": + self.model = self.model.half() except BaseException: raise InvalidLanguageModelException( f"{self.model_dir} is not a valid local folder or model identifier on HuggingFace.") @@ -299,6 +490,7 @@ def load(self) -> None: self.model.to(self.device) self.symbol_set_lower = [] + for ch in self.symbol_set: if ch is SPACE_CHAR: self.symbol_set_lower.append(SPACE_CHAR) @@ -309,6 +501,15 @@ def load(self) -> None: self._build_vocab() + def get_num_parameters(self) -> int: + """ + Find out how many parameters the loaded model has + Args: + Response: + Integer number of parameters in the transformer model + """ + return sum(p.numel() for p in self.model.parameters()) + def state_update(self, evidence: List[str]) -> List[Tuple]: """ Wrapper method that takes in evidence text, and output probability distribution diff --git a/bcipy/parameters/lm_params.json b/bcipy/parameters/lm_params.json index a9871fc23..02f934725 100644 --- a/bcipy/parameters/lm_params.json +++ b/bcipy/parameters/lm_params.json @@ -2,20 +2,30 @@ "kenlm": { "model_file": { "description": "Name of the pretrained model file", - "value": "lm_dec19_char_tiny_12gram.kenlm", + "value": "lm_dec19_char_large_12gram.kenlm", "type": "filepath" } }, "causal": { "model_name": { "description": "Name of the HuggingFace model to load. Required for the Causal model.", - "value": "facebook/opt-125m", + "value": "figmtu/opt-350m-aac", "type": "str" }, "model_path": { "description": "Pretrained model path, relative to causal.py directory. Blank if using HuggingFace model", "value": "", "type": "directorypath" + }, + "beam_width": { + "description": "Name of the HuggingFace model to load. Required for the Causal model.", + "value": "8", + "type": "int" + }, + "max_completed": { + "description": "stop search once we reach this many completed hypotheses, None=don't prune", + "value": "32000", + "type": "int" } }, "mixture": { diff --git a/bcipy/parameters/parameters.json b/bcipy/parameters/parameters.json index 74a8b5697..790225272 100755 --- a/bcipy/parameters/parameters.json +++ b/bcipy/parameters/parameters.json @@ -568,7 +568,7 @@ "type": "str" }, "copy_phrases_location": { - "value": "bcipy/parameters/experiment/phrases.json", + "value": "", "section": "online_config", "name": "Copy Phrases Location", "helpTip": "Specifies a list of copy phrases to execute during Task Orchestration. If provided, any copy phrases in the protocol will be executed in order pulling task text and starting locations from the file.", @@ -790,10 +790,10 @@ "type": "str" }, "lm_backspace_prob": { - "value": "0.0", + "value": "0.035", "section": "lang_model_config", "name": "Backspace Probability", - "helpTip": "Specifies the minimum probability assigned to the backspace character in the language model. Possible value range: 0.0-1.0. Default: 0.0", + "helpTip": "Specifies the minimum probability assigned to the backspace character in the language model. Possible value range: 0.0-1.0. Default: 0.035", "recommended": "0.05", "editable": false, "type": "float" diff --git a/bcipy/signal/tests/process/decomposition/resources/data.txt b/bcipy/signal/tests/process/decomposition/resources/data.txt deleted file mode 100644 index a0e031dfc..000000000 --- a/bcipy/signal/tests/process/decomposition/resources/data.txt +++ /dev/null @@ -1,3000 +0,0 @@ --31.140106818529 --29.307741130369 --27.702282955172 --24.823603667431 --19.734471382648 --17.468192604636 --16.524288084323 --15.436249764616 --13.459321158283 --9.690314296181 --7.307730549914 --8.545384193233 --11.804049880313 --16.310102779910 --20.104006874542 --21.868478624425 --17.557436342863 --10.063806086243 --5.038226597238 --1.185631607446 --0.587361308779 --0.961864470934 --1.789073380698 --6.129384837605 --8.641557776034 --6.205573570806 --6.692657213358 --12.213464085439 --15.764797460062 --20.123516002134 --27.929091718619 --36.505209574756 --41.478517637308 --43.718818126428 --45.025099911649 --44.323620651527 --41.311648607131 --33.783861483749 --25.403725927537 --15.696230341103 --4.702594566959 -5.355401722362 -15.613833823884 -23.062460090029 -28.096303307686 -30.940777856961 -30.644399684298 -26.519428522975 -23.085737909361 -20.213538043938 -19.272518585244 -24.617422754158 -32.579435447261 -35.725371077029 -29.088735703706 -18.344268950794 -11.493204512504 -8.252775851866 -6.840521104964 -9.632266473454 -16.607591569580 -20.997855793232 -16.795443551340 -9.602882275213 -3.110269688376 --0.340963517374 -4.841492623433 -14.760031447745 -21.630444392455 -21.351651923807 -19.234474249513 -21.003662095448 -25.441189705328 -31.832842966007 -36.523339386723 -33.053330197342 -24.123280547134 -13.369527805867 -4.503585690492 -0.907139057466 -2.540654127280 -8.745471700621 -10.656130158154 -5.623289380366 --0.094589863484 --3.273801325080 -0.793055052185 -7.296196855427 -17.824677755474 -33.200767249055 -34.907894737314 -22.957640141157 -9.278611769433 -4.182238673060 -6.752511645711 -5.972221857576 -8.281264944609 -13.155805076988 -10.025888754374 -1.687967347298 --4.223957792484 --2.493489446595 -2.279432153643 -8.922329645685 -20.961865550818 -26.244550696437 -21.649465055864 -13.981905522757 -3.612665791221 --3.499616385242 --2.869102882887 -4.952067314497 -13.048812285193 -13.074760665869 -11.095524145646 -7.313011618855 -3.309045210393 -3.551172851359 -6.923389720151 -15.419901078019 -21.622026278577 -20.824441042981 -17.426907956055 -15.051889453992 -15.636247877738 -16.078979934864 -19.151137894970 -25.894087875206 -32.114887781770 -33.529162537978 -28.892622920833 -21.461004390326 -14.642536984379 -7.438455023146 -2.605609508836 -3.410878515912 -7.083397931407 -7.468005585724 -3.230316968095 -2.328353415645 -6.799487081663 -14.800850750319 -23.756457363007 -30.548887444408 -30.205670487929 -28.066857560793 -25.742237773949 -19.058549260832 -8.865875041217 --1.883476836099 --6.534460711100 --7.099114527734 --7.563694896604 --10.382117320263 --17.602463642803 --25.352144820169 --29.968557737270 --31.639773503831 --28.336921109635 --20.330662868728 --10.179147662715 --3.310747982867 --1.292238354418 --4.196465879747 --11.592746034306 --20.183733148692 --28.200050410521 --29.866715509571 --23.804995807513 --17.444774168194 --12.475665287605 --6.965693391124 --0.461889722216 -4.067858831687 -3.903132962499 -2.763821415865 --0.273903347736 --5.301916633918 --10.180384149938 --11.741873093507 --9.950949339124 --8.934492775701 --8.885409860088 --11.492281572357 --13.310589417356 --14.714772063537 --15.280463772897 --16.001352212712 --16.110723415967 --8.854797639050 --4.355340228339 --4.227481559151 --0.222962228061 -1.970985641614 -0.540261882705 -0.265908995723 -1.948284834074 -1.281406353672 --2.890944518253 --3.956615370837 -0.205493791105 -8.212951739775 -12.522073826799 -12.501785233796 -14.970568883440 -12.206322108035 -9.041902232023 -8.320501241198 -6.367354532237 -6.586350682193 -4.947366614834 --0.324663767111 --6.941679178216 --11.179382121139 --9.981813044065 --8.768751635892 --9.408826859020 --9.279967876327 --8.557363034911 --10.388786673619 --13.812744336133 --12.362237077354 --10.579723017667 --9.799679389631 --10.217619575777 --12.789466057414 --12.876966271070 --11.625508811031 --11.009083883019 --8.867974917789 --4.874928792984 --3.163482919520 --4.064510174786 --6.915018112907 --12.535145856007 --18.044038868081 --20.833330055072 --19.094067191593 --16.126016969085 --13.243832184486 --11.228586021106 --14.207585515638 --16.296867139129 --12.693058250358 --7.089432732231 --1.749072916845 --1.066061463990 --3.707409785366 --6.083672136028 --8.576375994921 --7.460806678430 --6.132165169048 --4.886594233454 -0.042284746579 -8.458589812942 -12.549320251474 -6.467655571015 --4.867601846212 --14.386347104183 --19.854276615730 --23.420105644903 --25.485020939929 --23.755448224980 --19.213189224837 --16.953661993874 --17.170112725426 --18.709391222819 --19.920571893714 --17.948994762656 --11.546031006454 --3.688548406790 -2.587719707677 -6.433263867904 -6.886754539912 -6.724272713696 -4.199739966218 --0.327242076791 --2.517482332667 --5.797193236276 --5.786881383597 -2.292870121993 -7.464230447538 -6.407149252086 -2.209521117210 -2.010720985908 -3.328188861442 -0.747697891466 --3.415374972413 --4.679255608677 -4.228165589975 -11.213773114423 -11.863823512569 -15.744309634800 -18.577219595336 -17.022149916257 -12.306310189732 -9.548971430891 -8.195688720500 -5.573387861634 -3.838952232344 -1.831052330206 --0.583726978651 --1.292351424753 --3.353060814326 --2.505360322641 -0.285329178398 -3.033809498828 -8.908017819510 -13.694698027018 -17.085444129552 -20.885498173143 -23.153581308250 -21.056752318106 -15.154526784545 -10.157158397921 -6.532381355994 -4.927690861469 -6.604991318622 -7.810158390709 -5.253315090514 --2.610931125264 --13.115758466688 --21.972527083647 --27.678155572977 --31.271937715348 --35.479901988379 --35.886062595437 --33.816917642419 --31.304416772174 --27.601671332084 --27.117014105994 --27.539507224056 --25.080226272815 --22.291402458058 --17.373771324287 --11.379833108335 --9.078938162004 --13.610178351369 --21.534406832153 --25.221705323977 --25.925383250979 --24.353396825930 --20.951021144466 --18.151830613783 --14.806642704609 --13.771465376433 --17.562832506590 --20.761683718172 --18.273766425900 --12.229202180518 --8.687446763945 --8.588254958785 --9.968822067385 --14.092003831524 --20.715515324313 --28.007364425746 --32.452163886223 --31.684640541363 --27.165663468021 --19.786910599435 --10.347907786964 -1.227933753611 -11.460635725431 -15.458932927223 -18.544216516614 -26.687545846506 -36.311895306584 -40.966412464607 -43.097481870365 -48.004035172682 -51.230881339929 -48.897560460568 -47.345621322548 -47.516322813136 -50.030417554063 -51.688989893364 -51.895025965735 -51.047777370793 -45.936212707247 -39.692759424760 -35.770462509110 -34.111215254985 -32.662645316305 -29.824654745557 -26.395094646743 -22.520401092533 -16.782345902559 -11.641846563997 -9.103724936624 -10.388717286045 -12.169137420689 -13.086437232818 -16.401199906091 -17.146887956533 -14.487837565776 -14.904319690298 -20.670023760731 -25.104337229069 -27.827127450083 -32.566566189372 -34.637270206290 -29.854663533076 -20.253870120313 -13.761055226447 -16.291026527627 -19.574280226094 -20.652231339676 -24.628967501890 -25.174936960689 -18.093875873220 -10.607493000624 -7.737964271693 -5.850638874082 --0.313657711797 --7.462916609292 --10.853282374931 --13.654275808219 --18.661248110758 --24.196153952064 --26.831382336009 --27.638072569773 --28.382462985477 --24.930505468423 --19.198484450488 --14.547370347508 --12.552015498853 --12.966047665019 --13.460823782639 --13.668069419543 --14.278785360499 --16.968639224917 --18.727508476154 --16.286464860766 --12.137813384648 --8.994088851808 --5.861304593577 --4.961658574620 --7.174582165242 --11.288207471896 --12.402098849141 --7.583742732696 -0.184196692982 -6.884912421137 -10.499633307270 -10.731071826377 -7.017718580962 --0.267335098172 --9.207977856936 --14.554583265369 --15.961708212712 --12.042302297738 --4.279225327634 -4.616424553672 -11.161687918112 -11.898782921539 -10.754345104190 -8.376532429853 -5.503326507021 -6.161674987798 -9.781467443758 -13.350635478044 -13.401044149481 -11.182015589198 -8.826404724527 -10.789753459380 -11.972338686802 -8.682258131331 -7.492936924643 -6.284881149742 -6.472066060459 -8.036183138380 -9.924708742914 -14.976107598937 -18.141699844216 -15.080536501248 -6.110205416452 --2.808034296912 --7.294937462239 --7.132611225539 --1.908138954385 -6.702669394209 -16.797926244814 -19.821451029304 -9.994553762783 -4.137606856230 -5.758362817348 -9.339275495772 -8.829400539709 -4.608437018354 -7.863592621677 -7.931989530312 -2.682375290776 -1.442220013788 -1.262426106963 -2.106538279924 --1.967774295785 --9.326304850882 --16.169039939590 --24.405888021763 --24.722042539869 --20.471486489527 --14.658243737348 --8.602645031407 --6.359516278830 --3.943586733231 --5.512043682784 --8.942406691366 --9.747791230548 --9.420399660295 --8.281375698802 --8.672920135613 --12.806263218633 --20.968233623536 --30.481863797263 --36.575992247144 --39.697987348199 --37.580542106794 --33.710365287447 --34.204534562751 --39.149502604998 --41.066772080000 --35.054068787258 --27.142084175862 --19.871928131663 --12.491483501457 --11.516537729154 --18.636316243738 --27.230736016745 --31.209443769069 --28.423401873302 --20.600361572398 --10.670488081549 --4.015585769365 --5.712185639138 --14.736982049404 --23.665273340618 --26.729239698611 --24.945312820033 --20.595058749951 --14.267702194011 --8.385943188430 --4.776521707317 -2.203540466139 -10.464418296617 -15.282415862590 -17.656747428436 -17.784625330719 -16.424478254609 -15.719422782058 -16.904519504941 -16.824452204408 -14.756323316850 -11.945854761442 -7.675270996151 -4.661583386066 -3.445179580242 -1.516883723548 -0.046734361577 -1.122708854364 -3.682492121153 -1.577077681671 --3.634908010113 --5.103253571860 --6.393998222910 --8.270707942433 --7.120200326854 --4.999191222904 --5.390301542260 --5.973270256502 --3.028179703287 -2.397934908809 -6.575257804473 -8.389397142082 -7.429382877552 -3.654475822972 --3.453562962910 --8.430727534000 --9.114235803380 --8.898855601918 --5.976488875541 --1.925259053963 -5.186627283451 -10.154049117094 -7.836496802187 -7.777247736772 -10.733982218460 -13.454805379087 -17.921074539626 -21.154232711911 -27.223245115325 -34.195547069595 -35.909957082395 -36.282493144052 -33.061042661427 -26.818472861394 -19.185544802114 -15.614789795446 -14.248820066985 -11.424054198465 -13.533006405364 -17.871524708527 -19.336305335001 -19.733267854278 -21.605847807143 -28.532765843357 -31.477068278194 -27.484807602747 -24.622614743703 -26.470480512646 -27.115458519113 -19.839374565982 -15.751258437179 -16.379748053538 -15.882481500336 -18.604756126054 -26.827394253313 -34.423905248471 -36.629808737949 -36.526205344029 -30.823608055352 -18.828042438402 -9.435627734774 -7.663528616140 -12.073654764564 -17.216383248075 -23.243932434485 -28.082794727269 -25.649454270472 -16.938025682106 -6.486208692967 --0.386748482178 --5.317214596046 --8.258575961835 --11.930451082234 --17.820679885547 --24.972288696650 --32.263679408082 --36.842734941932 --38.690841419332 --36.271003291676 --34.101998561375 --33.368490357923 --33.786775037754 --39.857213582311 --43.381475705128 --40.402592342495 --34.997439842424 --27.871011683696 --20.177564202984 --12.652401736997 --8.220492549242 --4.302799123162 -0.617429149840 -2.189085267789 -0.200130408912 --4.422499792895 --8.900495881229 --10.558679041278 --7.616367212559 --2.090494062790 -1.874514406999 -3.204689528562 -1.109395642330 --3.842139226911 --7.102946047410 --6.377187782819 --4.288707760574 --2.534456270848 --3.790873851731 --7.880165614516 --13.702864751880 --20.856961719727 --26.499566675294 --28.362313730593 --24.156921867596 --17.604186040554 --13.724574463555 --13.270779054840 --15.013149909452 --16.470628513982 --19.145685777778 --20.003622013017 --20.036712999882 --23.124927001907 --22.860935093004 --23.031506719383 --25.011255795706 --25.265395458836 --25.298851082440 --18.661575043103 --13.306550097688 --13.987903006635 --10.231315765926 --6.642797656371 --6.069989652224 --8.774245287719 --10.018020413851 --8.157934302415 --9.727958583291 --10.117520568304 --10.593112707482 --13.752943013078 --16.530781000747 --18.452271558597 --14.737156464583 --13.541592407428 --15.022029787332 --10.478329059526 --6.565063725149 --6.540420898586 --10.580718132909 --14.894536694088 --14.874097981411 --14.934141307996 --14.015990125241 --9.327721051038 --5.267955354363 --4.579856005712 --6.943550358035 --10.309265795030 --12.945927258540 --13.654388708353 --11.928110514498 --7.368947721618 -0.694064034404 -9.566210021009 -12.284348692765 -8.450906773324 -4.247454572176 -1.370951253398 -0.177901052635 -0.072744986480 -1.977496947734 -6.817169736630 -13.800754705364 -18.067997828342 -18.149716942978 -20.188386989707 -22.520475564516 -22.930613213001 -25.239683985651 -29.006809461904 -32.369612930632 -32.692660398860 -31.058628549496 -26.081713658830 -16.776215716295 -9.797786962635 -5.990942246427 -4.093845984468 -3.407258749536 -4.766173158866 -4.719429282346 -2.009305397062 -1.872896764404 -4.218019823748 -8.216535792275 -13.246299807146 -19.897838445458 -28.035315658326 -32.906969316258 -33.293934845680 -33.289479893144 -34.673206617598 -37.483040787605 -40.922946109986 -43.636538177393 -44.310028152253 -41.409761665982 -36.087231869251 -30.454548175608 -26.821592772461 -28.310090374098 -30.370738808730 -28.030425398948 -22.221660436865 -19.613760512851 -21.151776952132 -22.311867698766 -21.673577510421 -18.138243196893 -15.923931748872 -13.529770732895 -5.513843383437 --1.810548346937 --7.717952280210 --13.361306088201 --15.069441474947 --12.341739814863 --4.415655756433 -0.784885300698 --0.007508571505 -1.339755617490 -4.424802280928 -5.597864023130 -6.107836627051 -10.280094840943 -17.677953688944 -22.374683500799 -24.131566745779 -25.360483295757 -24.959649667117 -23.899407548009 -20.104572298611 -15.784103655778 -11.120347072084 -3.146368987470 --1.962721536150 --4.251558531113 --6.917435838447 --9.932171268679 --11.501466034991 --11.986889553518 --13.849096662497 --14.092675176145 --13.051668268051 --7.816614510022 --4.285754445727 --7.644241697820 --11.480644771767 --16.125458445620 --19.497881079369 --18.638026279402 --15.123334539750 --9.421664864076 --3.614354896671 --0.032610246195 -0.272953988929 --4.830302684941 --9.717518621554 --11.855813131282 --13.113602403075 --11.068879492819 --5.637299373040 --2.637103797668 --3.054759421355 --5.596556643602 --9.819274657115 --13.620686017820 --15.563376114415 --15.036335219611 --14.542221949924 --14.303845240707 --16.692285286864 --17.877590894966 --18.890653574786 --20.955003037873 --20.349431936341 --17.746185196585 --13.056262513460 --5.318337675838 -3.967105626065 -6.802748614825 -0.683940019121 --2.491053322305 --1.435296674505 --2.284438023556 --5.546223144779 --9.904203872762 --11.922822738265 --13.220438694435 --14.432018729158 --13.129658139136 --9.093348164951 --4.441482900054 --0.364366219455 -0.175180375071 --5.827611299471 --14.761175965684 --21.675685121614 --26.440517158685 --28.221842042932 --27.408744942624 --25.068978505237 --20.307896956336 --18.988039403358 --16.724013373740 --11.612866008917 --11.623650749677 --12.894057651785 --13.628593534693 --11.528330076442 --7.216089437814 --10.464312824311 --11.294052100253 --5.224293066618 --3.988046388238 --5.955092596469 --3.906976526140 -4.760620016892 -10.900844825477 -10.285707414399 -9.964726131483 -9.395602628874 -8.948885055333 -8.660307110118 -10.518168035012 -15.718511261626 -18.551770035303 -18.788376539821 -16.386341833248 -11.729232205805 -6.497774000279 --0.145903083501 --4.414985741022 --9.633510702685 --16.557833813647 --15.123767127260 --8.368992574641 --5.868625066207 --5.139692007684 --0.615748972458 -4.979851863918 -10.286699236256 -16.518191680933 -19.238013901409 -15.672712129221 -9.343773254612 -3.136906229200 --1.915844802779 --6.374366991118 --6.634730315530 --1.148731064251 -4.747621614272 -8.020691877515 -6.924132722879 -5.207273525142 -4.738594135442 -2.328781562512 -2.563952509783 -5.720490657212 -9.170952549096 -12.192375780565 -11.709506122491 -11.394655509579 -10.755404709757 -9.550498357613 -11.152531156875 -13.723686002629 -18.322658359192 -23.199662385435 -25.828582054580 -28.196603687718 -27.267083661715 -25.989263034208 -24.906257590596 -24.297920812961 -25.599778585931 -23.370433260461 -18.851065200958 -13.920735167742 -10.221599943363 -6.964483752519 -4.574914249041 -1.480936609268 --3.141370601333 --5.801139146112 --6.576818171214 --3.932563924667 -1.047778262915 -5.683726622549 -12.515501805571 -17.356121839033 -19.128556463362 -19.927017404473 -17.311011728752 -14.788704401989 -13.957099188584 -11.724524774521 -9.520929894809 -10.000105620909 -7.418179340568 -0.506719687046 --5.382858555356 --8.175570732431 --9.597649418679 --10.477639518018 --10.398461413717 --6.679076354978 --0.353218192204 -4.606706143918 -7.856432399023 -8.957566890527 -6.584572859840 -6.372209919288 -6.348921693548 --0.098533808991 --7.612943955089 --13.782798122646 --13.466339082531 --10.725341270677 --14.176220139522 --15.430918673924 --15.031051339553 --17.163453804030 --19.393936440396 --20.051451971413 --18.372590489970 --20.059928743128 --22.987194079688 --25.273138981233 --30.924533981343 --35.777956130058 --41.986250234118 --44.544779479828 --45.329412164750 --45.717957667899 --40.627660875594 --35.953948319606 --33.725258263837 --37.259478094919 --41.417021688712 --36.626038044838 --25.510287847273 --13.723965825306 --2.493020203263 -3.323084686492 -1.344107487242 --4.695536302196 --12.385478791495 --20.184235445759 --22.496743161443 --16.675351204735 --6.236942528745 -2.390186331352 -6.079598791267 -8.923295395944 -7.089170276781 -0.473899909838 --4.303868526738 --8.536224172814 --11.195457073263 --13.743070372934 --16.901493866305 --14.903749526862 --11.117450598386 --9.935909160545 --7.673126931117 --3.676774884488 --0.982059954940 -0.935073964142 --0.893041374371 --5.098240719035 --10.164968802864 --14.202604322327 --12.355902222012 --7.970451558762 --2.793910054714 --1.000225596420 -2.567446005201 -5.496783000534 -2.262196954481 -2.593877980857 -3.614568819378 -4.651455677933 -6.393450335858 -4.445662659024 -5.169113560344 -8.624691207574 -12.490501606921 -13.518868074005 -8.972005839528 -1.101606680808 --6.119172964820 --7.255564606233 --2.093561198482 -4.876352307846 -11.145676778578 -15.225698992442 -12.587514907281 -5.164599690392 --3.280493863296 --7.983362352486 --10.675878689478 --13.520284396945 --13.687157307164 --10.311124098504 --8.648236628182 --11.277645200098 --12.748802248287 --13.346385250407 --11.087611021965 --6.896074235661 --7.113784065594 --4.714594250338 -2.187078120005 -4.750145512221 -2.382191120188 --2.112512115216 --0.950804601058 -1.865891019579 -1.824632225357 -2.812293544224 --0.273853377627 --4.840245620099 --8.025617189487 --8.725713370319 --5.531460572084 --4.565015150397 --2.364856759151 -3.433793962447 -8.534243254744 -12.673659726510 -13.619420351250 -14.428320886188 -13.089236750951 -6.592171574785 -6.949449830538 -14.753671539660 -23.670554433443 -28.293757348789 -26.061432892823 -23.192074562309 -22.963659068978 -26.240491095696 -28.562710336987 -28.523141264713 -28.198264153104 -25.380676108706 -18.945058629425 -6.400300502624 --6.114743269971 --11.264155464066 --8.917584248238 --2.657277552589 -3.922228586159 -10.122319637841 -15.927943056873 -18.992456304281 -18.026210486227 -18.612379445434 -17.411948350679 -15.036168151281 -14.526814271312 -16.995090028431 -21.627151791868 -21.796813888411 -16.902745195752 -11.277206005732 -6.916861055417 -1.524640585046 --3.597090404064 --4.029201565445 -0.290328757717 -6.890928717696 -14.290310330133 -17.464293641301 -15.528108417769 -11.791596805795 -11.715185023457 -14.081245914527 -15.443665285788 -19.326282744602 -20.291859009242 -18.451567438586 -17.031077730708 -13.752862078954 -13.218455399442 -16.234386790150 -16.488560871552 -14.658251400937 -15.254213152101 -15.916182713130 -13.411905343111 -11.395736760122 -15.722081728292 -21.113411958146 -20.003959100560 -14.473675579354 -10.583271356192 -10.548141871075 -10.415110659419 -12.763226122846 -14.265090406641 -13.528297986852 -13.102323973432 -10.990972172434 -8.018692983704 -6.364167872158 -8.040072542094 -9.421134015854 -9.279043370180 -7.917988949130 -7.099457516995 -9.999840299393 -12.582268806821 -10.554929578196 -10.421815555897 -11.683945247960 -13.194379084544 -14.663913261811 -11.124887864174 -12.694210234375 -14.658119069795 -14.412377848501 -15.871848160719 -15.229936966953 -19.317156703844 -23.022836865816 -24.965588568559 -28.029219248561 -28.102581062058 -27.884822043811 -21.484999591860 -13.501607272691 -9.536765889493 -6.521448394455 -10.181862261675 -10.763203539081 -1.965701874344 --9.440996601785 --13.953962956595 --8.209329677597 --3.839515004465 --2.398272572677 --6.122523453208 --15.090657164247 --21.472920413200 --25.913338830292 --27.156821870133 --21.598395535004 --13.147085216235 --7.929442830752 --10.751062134533 --17.465009957955 --23.026061088134 --26.825286855220 --27.988306726335 --28.820615367334 --30.184511453245 --31.946180790984 --33.545034719931 --33.897511594234 --37.409378347446 --43.409474453990 --49.699073931448 --53.502930216741 --52.733761746185 --52.704629780593 --54.150714179547 --56.198009072766 --58.787578865519 --59.611099426552 --58.087540050500 --55.358517532505 --54.575878496407 --53.230032323879 --47.222412771877 --42.344096834758 --38.448666656045 --35.575144318605 --36.888684472503 --40.696383013952 --44.296601791229 --45.014458610832 --43.896994485393 --43.217828050002 --41.664824195753 --40.462090471057 --42.943049662851 --46.172331944666 --45.992759320205 --41.448137178681 --32.667874486172 --21.468687687481 --11.416104834156 --3.463316213415 --0.162949627551 -0.598472665240 -0.936995856533 -3.503592162310 -8.181145902496 -11.610650967043 -12.333887479960 -10.943509458788 -10.334183570885 -10.387203839308 -12.092430509108 -15.014376086685 -16.045504045038 -11.690929429588 -8.207839751865 -14.517517237297 -31.593442361233 -44.204747125804 -48.024371631352 -49.494718653156 -51.082784112272 -52.448109796090 -54.307516864446 -55.911667927166 -53.079969438361 -51.063264467956 -49.798191081195 -50.105954522872 -46.984852050269 -38.126490917393 -30.352439274395 -26.057403008524 -27.312300342087 -32.717058459322 -35.578646459672 -34.339114071948 -31.123005443466 -32.622709739830 -37.105006577563 -36.105652295199 -33.662158167027 -31.646361089422 -26.829824001835 -19.020112478299 -13.141110067012 -7.875606063867 -3.165933764372 -2.329648653692 -3.737460767758 -7.036919830550 -8.758918616016 -5.587797786108 --0.200937160855 --8.181065505629 --14.542479543245 --13.135040260947 --5.471385192720 -0.980894389024 -1.263527064524 --1.080704261797 --4.268800340965 --8.363694294067 --9.972742729433 --7.944128026816 --4.406806444652 --1.765000127495 --1.455231537407 --1.963894452544 --2.851667872261 --4.190119999143 --2.782359827956 --2.379324023327 --4.925370527945 --4.394800605103 --4.507389524087 --4.015897809489 -0.137834571370 -2.857720547584 -4.997312548239 -3.147790483719 --1.381274136114 --4.523541922002 --6.269593850813 --6.536933945941 --7.337854138911 --4.959116894348 --1.127235801399 -0.494680041033 -3.081413257896 -7.098354500079 -9.301872648020 -5.770814703759 -1.159727995629 -0.101615031956 --2.049845675569 --4.014084681665 --2.381388644271 --1.954224735925 --3.795589471541 --6.532852579106 --11.678580452601 --17.122193308084 --19.135275457602 --17.968116104636 --15.680885842686 --13.020962214116 --10.012287656452 --4.548672148768 -0.109466067246 --0.268363955835 --1.512598547513 --4.023950952133 --10.009958940957 --15.662816361407 --20.519078676163 --22.033437329916 --22.264935157295 --21.796228107721 --19.397155265288 --20.418698692186 --22.849153005454 --24.118061715298 --19.858354084782 --7.944881690608 -1.259622432366 -8.095464043573 -13.108002590797 -13.623372227123 -13.893189847308 -12.550163711983 -11.735378970704 -8.693580229881 -5.415205461192 -5.321780352204 -0.829934532719 --6.346095915557 --12.574961295163 --10.598840998397 --2.124442814292 -4.098400205211 -9.384382699702 -9.522327045576 -2.724071640186 --8.593568286330 --21.370998331380 --26.876202053326 --26.711233190726 --19.355532081011 --9.148933647042 --1.928567786637 -4.434659192754 -8.593570209425 -9.875392394351 -5.329351843741 --2.041087935613 --3.844911299925 --2.748926916271 --3.202847579884 --2.936765528630 --0.043621829061 -2.672519497036 -2.499039362181 -1.313006579197 --0.466570055506 -0.389435895971 -1.986527373468 -2.904072825947 -4.147112966653 -3.414239177179 -1.006109280348 --3.282914345263 --12.299302892096 --21.252876072560 --20.905135543295 --12.818905241645 --4.606565764436 --0.099970225095 -0.308683100603 --2.212590474454 --7.708549351149 --14.264566237199 --17.465625178308 --18.427692501334 --19.155054076185 --17.778565979715 --15.861530011586 --15.659574423800 --18.395858990164 --19.699733021145 --20.019373012912 --21.100382437883 --20.702484863738 --21.133178089528 --20.849241937641 --20.302258855041 --18.065939586547 --13.356098433332 --9.744398128316 --4.828596433701 -0.245569646920 -4.483539894822 -9.457214600195 -16.176898425868 -25.776200369102 -32.256213830881 -34.667283211553 -33.302721048577 -26.891038397318 -16.315791755605 -3.743388323378 --2.037723883782 --4.743314174604 --4.064811979743 --2.738447237802 --0.984776350868 -4.488399233281 -5.715534871463 -10.997391448427 -17.531415105434 -20.183338314610 -22.787156820613 -24.069877158704 -25.760146446712 -27.809030282620 -19.065212160671 -5.992233544622 -0.552225298816 --0.900280177583 --3.568134702979 --10.392429833654 --15.348533897384 --18.778574930204 --18.852962669076 --14.186956292542 --9.617828210990 --4.997298902116 --0.908699147908 -4.350214101617 -6.834811300341 -2.830862141354 --0.902037367777 --0.059432255426 -4.131420697745 -7.674114063658 -12.557968990263 -21.102503430810 -25.660972867783 -25.664710971736 -24.812614161262 -23.406427381805 -23.620137496396 -26.495219904221 -32.656041691083 -35.919534885542 -31.495870841356 -21.621129479109 -11.015627685197 -1.814706137048 --0.779702436585 -6.234583481626 -12.224542521885 -14.281684216854 -17.118636106388 -19.195431791921 -15.664796304563 -5.088655593644 --7.564438275555 --12.191047966664 --15.301723318956 --21.505130670041 --26.936527998088 --32.418831036109 --36.732967763828 --39.312379072091 --41.600911678924 --42.083928233260 --40.179056109164 --39.614184596906 --35.774218140014 --28.974967668140 --25.166486064394 --22.558355264761 --20.804859831918 --20.661297924294 --15.731041761069 --5.810465342261 -2.649091565770 -11.748672514865 -22.215758982235 -30.076798951316 -37.039189307376 -44.189733214946 -51.641243272321 -56.506062091571 -51.348420506522 -41.623443920616 -32.753844528782 -25.377064379061 -19.089809079758 -11.928534235707 -7.869138294136 -9.124740846060 -11.412292651941 -13.809461545475 -21.352686192075 -29.628820611014 -33.317024819945 -33.242418304003 -30.083832700215 -26.445366325435 -23.983206681393 -21.193820833548 -16.439069255327 -11.603852089401 -6.754349623713 -7.411802204541 -11.974938942437 -15.582189374057 -21.830386054021 -27.205697621308 -30.664410018133 -27.912960990899 -25.822071515943 -31.165851228947 -33.134054071711 -31.909328745205 -30.044958332656 -26.488404789709 -21.184667212352 -9.038195192277 --4.459471468094 --16.983461319345 --23.243521647593 --18.721807121582 --9.490549097287 -1.099647451286 -2.742132788880 --3.441877523836 --10.639884863998 --21.428777024212 --28.089762943048 --28.744625023220 --25.970033132009 --22.812233968898 --23.250858003554 --22.604271221868 --18.798626258879 --12.487500011312 --6.971253657882 --4.942640833297 --4.977010466373 --5.637751819085 --4.470479196333 --0.593427691135 -1.265532103492 --0.093366900056 --3.483096947873 --8.229223478131 --11.551703337989 --17.961943671732 --23.459962681724 --22.229708435453 --19.218405647152 --18.805333572638 --20.743926343781 --21.886908792335 --22.040403441637 --24.721354005287 --26.152272042488 --22.610196289579 --17.449343618462 --13.919910399305 --14.124185912837 --11.856164647591 --9.121058597864 --10.306296899049 --11.511526172850 --11.364894305926 --8.269534759500 --7.244335159025 --4.732115068438 -2.281704361166 -5.183751675109 -7.688518309520 -12.248995859491 -18.313348527184 -22.273739209415 -18.227615618295 -10.688882507819 -3.004569927814 --4.347343827510 --9.798843203709 --11.009606175239 --9.507656379170 --9.056617020388 --8.214933055456 --6.409131183458 --5.648227016348 --2.657475895469 --0.748114469647 --2.354437093288 --5.269161494147 --13.330129905237 --24.303300856283 --33.044128217016 --40.465417077715 --43.299496345920 --39.171880765083 --34.239720648330 --29.550342042420 --22.772115944611 --17.687123858401 --18.536066081587 --20.357463917512 --17.476627044633 --11.898282391111 --9.255346013957 --9.312430404321 --7.557475306500 --6.295566116558 --8.580089002152 --10.433979304668 --5.853179705431 -4.736841903407 -11.739422301693 -17.702057587074 -25.936805632526 -30.797971536082 -31.149651390929 -28.710285074151 -27.307104207193 -21.829777635493 -13.041365756071 -10.040178158875 -11.500433351290 -15.513521697803 -21.556251594495 -27.316824763188 -32.948369154281 -34.819556434956 -35.613613668130 -34.078935860845 -29.893700825349 -26.499571926990 -24.714056943603 -24.516665952382 -21.962085807309 -19.616131283846 -18.258337978928 -14.169020642755 -4.325798310273 -0.279415961043 -0.656363368150 --1.613234928270 --2.906817735723 --3.480928586400 --6.377470072109 --10.472280520225 --14.629911787972 --18.433090616189 --20.905099827878 --22.411690860743 --22.472112199912 --25.977561237548 --33.679440081069 --42.543787253694 --50.237097505287 --51.955027075630 --43.633742595343 --32.645550357082 --26.323896060069 --23.039805660776 --20.679334553993 --14.644007271763 --10.939310473242 --11.363465656201 --13.176723417969 --18.849532902745 --27.510213912770 --34.710820674084 --34.925495598444 --30.066049680313 --25.085861362381 --20.199631917715 --18.139599569398 --16.217515809112 --14.442688584606 --11.197828070304 --1.819719668891 -7.946271678226 -15.142218859562 -18.918467000972 -19.196517418313 -18.394787298782 -18.939856707164 -24.214707363702 -31.785169207422 -39.345730635838 -44.664930035880 -45.629030615752 -44.251843302865 -41.892961389452 -37.618577294975 -32.945785088222 -32.574734288228 -32.194059614793 -26.937447783583 -20.448625846999 -15.264591965408 -10.764668199198 -10.792563751148 -13.227323164201 -15.068132786968 -17.852505244718 -16.230192101200 -10.899206987803 --0.094717762699 --13.408813107544 --19.323342911508 --21.412536097441 --20.299043404802 --19.404513367846 --18.376187311708 --15.525682452301 --19.624593950659 --25.474965994790 --27.333629143671 --25.208342470410 --23.028029659614 --22.797042149070 --21.323368768558 --21.006097050505 --18.663973888402 --10.659014221749 --4.328479078655 --1.998633125317 --0.875169669551 -1.022423501506 -3.641717085676 -3.111109169991 -2.269730644720 -0.509846412998 -1.762783487001 -2.753158965462 -1.358593069811 -2.580827105172 -2.576136116635 -1.187290282014 --1.226525523488 --3.731094779422 --7.051894495549 --8.267802931648 --5.075120636974 --0.912207131308 -4.753774932306 -14.016879351143 -20.612187024572 -21.842831680323 -18.950250215938 -15.717485995409 -13.170284365506 -11.508033299142 -8.673304196775 -7.606151171103 -11.569800080949 -11.660916047889 -9.281923457656 -6.470162468386 -1.827344791752 --2.883808949233 --5.752640230653 --6.418686789678 --4.989051513368 --3.300810827140 --4.251441446070 --8.256225997384 --15.268856647778 --20.439403024163 --20.147328739095 --18.863569002411 --16.415562668261 --16.507938233535 --20.408430690068 --26.169494325471 --32.155532545271 --35.203718120316 --37.029728827890 --38.893898616850 --39.120373579104 --34.152916703766 --26.496647114389 --16.705275679315 --10.046906959516 --4.229471591673 -2.415817183176 -12.563843124664 -26.816676040472 -36.853568376159 -37.304282762206 -28.908153656467 -20.577014479283 -14.815950681642 -11.331803274613 -11.681423941143 -14.968334228926 -16.512422044004 -14.771328235140 -10.209234799438 -9.788978463060 -13.220900443376 -18.124179888851 -25.565259839959 -34.445970097912 -43.366059656904 -49.702244200786 -52.956290759118 -49.356529838376 -45.195740123469 -44.718321931352 -39.987219417714 -32.370138364084 -24.860614033997 -20.997946371517 -21.043823753890 -12.653900922142 -1.822677015300 --5.245603397489 --9.810992020336 --9.259628678574 --7.547112914296 --3.524788537840 --0.602394268290 --3.033247986527 --2.913114487588 --3.868881485153 --6.787075865247 --11.958595262495 --17.786333165272 --17.615206084030 --18.428301254180 --18.527126977347 --14.779428730563 --8.019207071219 --2.754395003849 --2.463737191629 --3.992061576228 --6.696426757666 --7.654993964213 --5.186444644459 --2.749868940488 --0.922362803930 --4.188296299558 --10.085336855336 --11.686516272164 --12.208351114238 --9.307396733926 --3.821302310529 -1.734800836973 -6.808139512130 -8.353175043052 -4.275022671668 --2.784402182874 --6.113108194527 --3.907768738879 --0.894620862877 -2.401358163093 -5.233348179120 -4.126944560670 --2.599145012337 --14.306202637639 --20.765707169980 --20.032413418939 --15.771934319022 --7.799564808847 --4.288310716549 --9.234702300168 --21.717672858109 --32.295956812863 --32.900338882371 --28.704980529378 --23.056651884573 --15.706558436240 --9.262365831957 --5.880376157420 --8.478165921421 --12.265756248826 --12.890666643883 --13.287676030687 --12.720816135092 --15.727520645543 --20.284204211769 --20.614457005184 --19.872688543591 --20.359215782266 --22.080863252339 --19.219569325856 --10.372915158870 --2.700351503498 --0.704926847425 --2.987491337723 --3.056707359763 --2.376439400594 --0.847261483004 -4.760427651218 -12.767460198998 -22.519509827420 -30.825200145458 -37.769113391691 -40.100796286871 -34.751047733742 -27.034489382951 -19.842547536638 -15.671662314401 -15.659801793885 -17.114869857355 -22.858183409486 -25.228696957857 -22.169273358792 -26.792252453602 -32.373006900039 -33.539185062628 -33.692003669654 -35.650706757545 -39.337249415710 -34.345548250731 -27.516285582189 -25.524543803247 -25.886893217048 -25.770091306223 -27.761092005148 -35.951852830370 -41.154925776320 -41.417563670974 -44.290418878211 -45.078807290032 -44.794898285870 -46.724765449381 -50.110505838660 -54.267739723320 -52.320794948183 -49.963486663548 -47.639350398477 -39.526479124070 -27.849401180430 -18.625896826165 -14.832846941233 -13.018614939015 -10.929719276977 -4.452391303955 --8.514170012607 --21.798067236107 --29.402293263871 --30.671407016224 --28.336870362086 --26.154385416668 --27.076402828840 --28.811295840215 --32.614071072792 --39.022032871770 --42.534147631406 --41.469411275492 --39.759534304325 --39.787922289146 --40.076856637290 --39.943834604235 --39.154624120352 --36.494734187018 --31.547530939886 --27.452517187951 --23.758993852868 --21.005279782739 --22.142768884192 --25.388356078135 --25.188006479037 --21.818173478531 --18.611673421204 --17.394696025878 --18.917885326246 --19.110444899018 --21.610322386382 --26.586764174922 --26.883532951461 --21.984507625719 --14.827051567971 --9.617031584102 --10.136482238046 --14.004721069439 --15.060881296426 --15.409952209235 --16.105312648936 --15.923636894043 --14.944501007606 --11.391546881897 --8.033127977677 --4.208356246595 --0.480707479634 -3.107901840420 -9.169208723955 -15.132770890069 -22.334048822402 -28.667516728677 -28.627305451946 -23.388592228928 -15.601167311180 -8.767037533922 -4.865794237187 -1.009549442331 --1.759486997923 --6.190278919418 --12.935318004584 --16.533004139997 --18.391411394660 --19.727935638984 --17.869200074202 --15.162926743559 --16.663452443516 --23.458876703295 --28.734038240216 --27.531573331970 --28.845289982795 --33.784861732183 --36.922454048335 --37.488872622169 --33.108535631882 --30.412834094646 --29.060167505302 --24.463707586763 --22.880685409611 --21.929221988698 --22.609696570835 --19.972214977742 --10.918894081674 --3.334459164305 -4.113855713711 -9.320300767491 -11.030417018603 -7.479582727475 -2.739820681045 -1.006991786575 --3.257314697524 --8.849385986127 --9.934930017079 --7.708870642416 --6.852272241645 --8.577109121017 --9.136737618903 --8.402562443407 --8.003354779223 --5.203439964482 --0.597611645867 -4.941049382976 -10.322525567378 -14.130686678325 -18.752242759343 -25.721524954853 -32.895857581908 -38.526148806376 -38.885423745906 -34.674176882528 -30.971764838612 -28.343410185150 -26.259111274530 -24.598271900906 -26.555662035442 -29.356412681834 -32.093158699665 -34.009995074016 -32.387425329130 -31.390348261061 -31.728504384924 -31.825907904502 -29.371898786793 -23.310082069482 -18.825205073452 -15.719782922966 -11.970662023959 -12.113330724906 -17.689568110855 -21.663613187993 -17.779488103287 -11.786949578976 -7.743129772341 -6.349893350284 -7.344866533464 -11.704617192565 -18.082294700442 -20.901075764778 -16.550455719845 -5.214291441337 --7.737924607726 --15.951472649378 --19.405272969139 --20.518502290698 --16.973345661827 --8.623474751369 --0.288973018646 -6.160646868544 -9.868731868890 -11.014085079902 -8.926064927129 -3.959531148723 --1.841238238331 --10.178170640258 --18.610347068296 --21.873015630959 --19.243778250759 --14.840285616115 --9.912094125636 --7.573970645827 --8.403793699065 --8.492975874288 --7.236937461441 --5.316534734227 --5.215193759453 --5.234614421885 --1.794294449013 -2.102215117234 -5.298077029823 -10.441191643338 -13.849870856625 -15.848451466789 -14.013693791337 -10.808529900578 -11.309024136863 -10.734571689525 -11.448630885170 -11.155788474437 -7.223665249380 -6.224492310008 -4.862732559413 -5.288215790513 -6.297475991503 -3.773486062102 -3.901492898437 -4.721384326004 -4.589831697795 --3.131138881397 --14.982577013765 --17.712018777766 --19.817361384824 --23.957947577288 --25.247411294768 --27.077869329456 --29.450040701465 --31.542310958061 --29.668307608201 --25.975405734965 --22.246904414577 --16.160930531309 --13.736881400751 --18.535506354449 --26.522185236560 --29.797252585491 --29.279334191248 --25.174124285418 --18.248557009824 --14.221495478307 --15.601179442449 --19.386737495272 --21.373619921985 --21.088889805273 --20.428639717365 --20.773421956004 --21.837512986259 --23.911272613249 --24.693330362013 --23.857808740721 --25.238364711106 --27.819712699786 --29.597245357687 --25.604432823712 --19.479730175065 --20.148030286645 --19.251087736717 --14.532935089147 --12.300939307044 --13.067086182083 --13.395769273510 --9.380713520476 --2.116595596424 -2.536728871402 -2.923070090730 -1.020464824190 -0.384879528429 -0.497355922663 -0.459065398916 -2.150517010943 -4.033666819796 -4.358462564527 -3.212078603750 -2.563550205386 -1.248703695899 --0.772102294945 --0.651052473802 -1.532789812101 -5.231048398280 -7.177609018755 -6.583664842775 -5.227545900408 -4.451426799925 -5.201188205290 -8.936909613729 -15.691116510293 -23.935106017494 -30.235081088143 -36.575562676706 -42.799245191342 -46.100068521523 -45.239872402172 -43.732350784945 -44.219336795073 -43.359745885634 -43.538260722420 -46.063577354838 -48.653423369624 -50.782594994232 -49.113094545802 -46.123898671139 -45.838496009366 -41.592617023421 -33.993488395498 -27.491626793630 -25.219898585975 -29.399007586210 -27.830985968623 -22.087742043932 -20.278089784404 -17.776742014331 -12.436090413028 -4.692437179284 -4.672650185679 -9.595923675288 -11.607420883569 -17.897758283171 -20.774808165215 -15.335795691121 -5.034702105686 --5.325808861715 --10.530657814473 --15.128688439152 --18.542998961204 --18.717003110706 --19.580496694509 --22.707434581529 --24.507647967270 --22.263959692162 --22.064835242700 --24.980861535976 --26.958243768095 --25.859002746377 --21.518360940888 --20.960801450054 --21.611629783625 --21.724491893939 --22.643643665770 --25.208976117093 --27.139249241935 --22.744367569465 --15.642572341337 --11.632625103480 --10.978757758712 --8.621458208204 --4.518359422011 --0.932773140185 -5.259159705586 -12.499437267858 -12.481907259529 -4.802712748234 --3.674584551492 --7.298650038886 --5.374186000259 --2.973288638157 --3.588980940656 --4.502496103810 --8.634658562314 --13.358295284122 --15.450012781673 --12.844439294026 --4.666135930676 -1.763968494673 -6.924842121264 -9.798182872978 -7.917827369243 -2.326515457466 --3.455518801479 --6.360226850552 --9.522464874797 --11.760958946488 --12.495827673377 --11.917928545868 --10.382004274451 --7.814673159564 --2.617391369672 -1.495527377107 -1.402434405511 --0.690515988388 --5.013053494209 --10.061737493303 --9.375029731421 --4.907455527430 --1.254914489393 -0.304608484196 -1.815462120722 -2.685426667624 -2.237628215775 -4.020248525364 -6.839476959101 -9.727899375333 -10.982355801321 -11.988908694922 -13.110085637997 -12.731314325833 -12.668549441660 -10.143181740232 -4.927279309937 -3.287368992054 -2.500437785531 -0.846978737354 -4.044859114972 -11.408867605620 -22.852805603352 -30.186047434066 -31.166347854640 -34.051851083789 -34.411720162138 -31.352297220597 -28.683057261208 -25.917268775899 -26.575935139770 -27.807454781421 -28.752147635953 -29.165110473879 -28.274335037183 -28.079048198992 -19.810694787319 -7.264318474359 --3.266780782132 --7.919932908266 --3.288370664985 --0.052923828720 --0.194515931508 --1.706896166077 --6.235470149650 --10.434306447328 --14.570231386974 --15.434722589828 --12.728861426320 --10.698593790081 --9.425217431638 --13.343961152944 --19.415416662387 --23.635185596402 --23.096202532476 --22.106172116408 --22.930370132594 --18.843047303796 --16.301767609787 --22.436600729462 --27.947957267595 --22.937030631893 --9.342134147233 -4.864436644541 -15.098475788391 -18.182199667273 -13.439842484298 -5.455157380054 --3.757520377635 --7.653415743716 --2.437286072920 -4.266692907578 -8.108211698729 -8.842281791387 -4.612578982489 --1.980681019073 --7.839906791226 --10.281109463706 --10.642304225724 --9.448852888978 --7.700093711729 --4.148777732382 -1.422973272971 -4.894032889435 -6.378121529728 -3.829895961912 -2.653961439075 -4.138927616894 -2.971507705541 --1.138733161664 --4.902952359117 --6.891093507837 --8.827573472885 --11.456009798683 --11.790224227949 --12.052545581067 --11.648314756011 --8.476276728119 --2.618735244853 -4.208146792284 -7.515790843819 -9.395744336716 -8.913684597502 -4.224748111283 --3.458490017351 --7.664667740808 --10.268714971838 --13.937335430436 --16.580123231677 --17.109159071790 --14.904579900330 --12.952531157501 --9.583539520091 --5.387137417850 --2.359314328297 --4.136538189578 --7.154523952488 --6.293695557768 --6.512103082805 --10.186973869875 --10.170318442720 --6.614527200069 --3.302440493152 --2.659540738137 --4.068345819601 --0.081129763593 -2.917340262770 -1.540539733121 -1.784670744478 -2.308169244120 -0.883697294466 -0.468640660457 -4.128814624254 -9.305792311915 -13.300885349038 -15.028173817414 -13.362577043465 -10.432848390723 -5.481643563108 -2.037517917615 -6.251363179591 -8.760147782808 -8.767640334573 -9.909844154508 -10.187809683182 -9.019073285939 -3.106809793477 --3.767578866704 --3.971774789119 -0.079330493769 -2.708686038211 -0.260722412847 --6.638016035301 --10.912800648792 --13.741413152648 --13.070261790837 --9.212725599620 --3.939726781185 -3.998920134077 -6.858594617732 -1.321050797012 --7.385499386324 --13.814238150441 --17.592625110840 --20.969162073572 --20.583873846003 --17.507203306004 --13.196230883484 --3.904123406779 -5.590575253347 -11.055536372383 -12.562286576533 -11.332552144147 -10.404148799303 -9.420082487146 -7.478829599196 -8.685175974302 -9.903021868374 -7.495802475544 -2.793183648835 --1.103815363193 --1.308463921814 --1.596409454580 --1.935943989257 -0.831990928018 -1.500053675818 --4.006980441178 --11.803814271035 --19.088087446572 --19.545119258736 --12.375639095539 --4.348741451993 -0.212347286322 -3.085820652521 -4.175660735629 -2.450485166947 -2.006242530732 -6.784986869898 -16.652908065683 -27.067270543772 -31.360882556286 -25.091903281908 -14.722770650336 -6.422431321452 --1.602808700072 --10.566194360234 --14.767857823603 --10.982598199844 --7.541605084942 --9.264166149629 --10.879466380243 --11.138434506672 --11.829524889462 --11.801025257874 --11.396291711366 --8.616849092983 --5.811053901183 --4.027691759490 --3.785292352531 --4.838093772943 --2.644195895175 --0.012407789449 --0.656938152997 --5.131930430368 --12.402274374461 --17.478029873802 --21.462645113543 --24.262819018201 --22.906455714308 --20.286669887468 --19.049992108579 --19.400070222373 --19.601638615838 --21.463683400005 --25.653178807088 --25.366031040190 --24.081041553826 --29.591576592029 --37.435072126299 --40.696911587212 --39.316523445174 --42.693256680190 --47.962996687037 --47.148055059471 --42.124463755598 --32.689666941919 --19.243918427916 --3.339694412775 -6.056687251339 -5.724567603263 -3.175046966847 --2.650400654514 --9.480605050735 --14.094344623957 --13.605955007423 --4.132213838295 -6.919608830888 -18.519317015561 -28.739448816115 -30.304190303952 -27.585098832718 -24.866340465724 -22.452349994474 -23.307526620007 -27.190152345114 -32.160365915817 -36.749585270843 -41.403603405101 -49.340408245247 -55.662280552744 -54.214114748767 -52.207347600791 -51.707666120505 -49.216983241565 -45.712094823043 -43.578586026069 -41.660405085668 -37.682923670156 -34.379713168610 -30.681909553599 -28.430470520529 -29.038707256866 -27.509714389089 -25.503232073161 -25.624397185328 -25.997776599975 -25.556785320870 -23.701853907051 -17.837308071954 -9.847211942360 -1.022661194846 --6.208010569477 --8.876731449151 --6.005978591844 --0.601873895332 -4.685345007630 -9.387754238862 -12.868054603215 -15.504477180654 -13.432804037681 -9.387272484015 -6.012081674784 -4.085238029634 -2.598019597013 --1.031683730040 --3.497422356726 --5.464775607050 --7.737362859228 --7.750219291254 --6.427393893664 --5.520622106109 --4.514425030049 --2.955888584891 --1.471784385178 --0.375941953851 --0.876096476191 --3.364755359588 --2.995226651627 --1.138976381089 --3.765198953310 --3.549085580685 --3.837241245023 --7.835603992743 --11.424946045464 --16.832516583576 --17.463157512701 --18.698446781745 --20.301073936896 --15.366539138719 --9.090520317686 --7.252030219500 --11.751251545546 --14.988767862965 --14.093609510078 --9.311034392832 -1.943967216594 -12.382937780543 -14.574879089394 -7.553167125207 --3.053680910202 --8.450814936726 --11.838988938631 --11.573202282562 --5.736810124455 --1.822401252783 --4.451818588289 --11.426403595352 --21.413288040531 --33.270585518269 --42.118718110678 --45.299004458406 --45.443301236335 --46.017545546878 --46.136945679833 --41.752029578248 --33.758753994782 --26.157849823192 --22.778183933835 --18.966638235060 --12.436654312955 --9.124582957436 --8.094168870912 --3.506705572103 -0.701582240659 -2.255787160254 -6.018527514525 -10.067109321645 -12.577030789844 -13.195944846939 -12.736738569987 -11.329080014185 -6.319864429368 -0.697382245212 --0.925533915663 --2.601386219339 --3.362798666430 --1.970216824825 -0.546194116283 -5.042726358522 -4.494945272879 -4.328295411697 -8.926607070439 -13.504491930003 -14.187892428220 -10.555627461731 -9.227229912322 -12.152509624471 -15.878140695131 -19.369914910341 -26.172745594295 -35.215314257924 -40.664024737822 -41.752769447694 -42.347453617518 -44.414202626787 -42.932021985211 -35.463168709810 -31.595544735452 -33.611467589328 -35.344888712431 -33.317597362735 -31.400519203186 -32.301869440118 -31.144514407702 -28.577427296520 -27.580652697882 -30.642798379089 -36.520427067423 -39.015595890271 -37.279747616107 -33.283898334837 -29.878005897358 -27.691052224478 -25.232336176644 -21.529378504706 -18.006988161984 -14.880325019181 -15.054648621264 -15.206085437610 -7.775796104874 -5.412352204385 -11.686882033558 -17.923124478427 -18.508737322726 -13.506316927559 -11.174006315361 -4.630621923061 --5.549308623934 --9.269718734558 --9.975706145115 --8.604500275259 --7.238083220420 --3.698837862785 --1.415394277298 --7.844290591516 --14.264797579231 --19.995715094216 --24.988692651760 --29.135772321556 --31.816254268666 --28.796760223199 --25.226230925556 --22.228703097670 --17.670423992479 --16.076865609931 --19.754502527818 --27.132558993135 --34.497006245754 --38.733548381788 --40.338311484188 --35.105397750046 --30.179691063319 --33.405382458496 --40.756810238359 --47.398417983715 --48.078876861460 --49.162102282222 --49.566160881259 --45.675338137305 --45.037168090836 --49.601830695760 --54.460676295213 --53.429766915071 --51.197602338523 --51.594186621251 --48.658831164476 --40.682913990009 --32.490956404604 --24.493706706906 --18.772938534554 --16.258410258177 --12.642418348574 --5.998923110707 -3.731866684222 -4.629209499617 --2.213051215598 --6.376366595155 --11.447489850605 --16.510991323023 --14.244689368293 --6.122991298700 -1.501305947488 -2.309573276687 -0.885772008849 -3.146180314872 -2.161448425351 --0.131895292480 -1.789000995499 -3.404192571141 -4.577738119789 -7.356811094872 -9.114764157023 -7.681485137500 -2.726708743288 -1.417503982882 -6.917988576656 -8.693728522311 -3.613521473877 -0.153219909057 -1.006109982794 -5.065555653222 -9.288760431119 -13.253191242730 -18.006236677949 -19.127402813245 -13.945187155300 -4.270769471727 --1.211728991760 --1.123279712428 --1.175123462996 --2.426736044839 --4.388100599949 --4.840029308501 --0.294264791107 -5.522152157724 -10.889251535888 -13.259124612407 -12.416554626907 -7.893260127361 -1.032138068364 --3.765831658221 --7.181118589043 --5.889795148378 -1.793103782332 -11.449434359424 -21.278249104464 -27.554709450135 -28.990924097438 -31.798524496223 -31.684534047173 -27.911258888297 -23.221615760861 -20.426970258760 -20.860602151401 -21.040786813660 -22.333112953986 -20.908602132732 -16.629109576594 -13.054659550218 -12.670354025701 -15.152121380371 -13.593833558017 -12.516508980991 -17.471527380144 -19.822503570959 -18.515973875552 -14.903804379169 -11.622167996123 -10.284071168303 -8.384205924416 -6.197550565890 -2.812889597479 --1.680432930870 --6.218089172981 --7.111132362704 --5.966586275400 --7.557733992120 --9.705084423691 --11.207157503826 --14.465368375242 --17.038423035182 --16.653137381208 --11.870578685686 --7.442880862072 --5.404895012122 --5.272413539997 --6.599326901586 --9.501115320055 --13.053430619192 --12.787364196190 --9.307911117339 --5.676240780619 --1.334110455001 -2.476546304646 -1.961858445120 --2.874590095317 --5.453019013578 --5.606643349828 --6.983218195044 --8.094978456355 --7.503657202897 --6.739321979188 --6.630232118648 --4.778178281893 --2.183146378992 --1.680857447479 --3.990257994524 --6.239385903623 --5.226594652636 -0.094842092749 -6.908730709679 -9.498997546216 -9.811564736049 -10.082627953766 -8.703664979074 -4.548484273353 -0.234728781592 --2.421067593087 --4.389745422244 --3.594722688858 --2.788740851628 --5.611935270059 --7.481728828265 --4.992648308883 --0.895196293189 -1.014560435240 --1.161539054867 --4.661020646880 --7.602120554450 --8.776741381466 --9.430733795010 --9.533635790452 --9.210657221026 --9.754541524225 --13.062802259416 --20.484476857623 --27.741813281091 --30.526902140197 --27.270828441966 --22.494095543505 --14.419342282265 --6.266834585591 --4.154811737502 --5.358676302737 --9.012456419051 --5.923641144819 -3.128530872478 -11.964518334133 -23.272774828298 -30.504414158208 -33.791162209664 -32.569193436147 -29.305814248842 -28.572269071212 -22.615105189271 diff --git a/bcipy/signal/tests/process/decomposition/test_decomposition.py b/bcipy/signal/tests/process/decomposition/test_decomposition.py deleted file mode 100644 index 6f6adf887..000000000 --- a/bcipy/signal/tests/process/decomposition/test_decomposition.py +++ /dev/null @@ -1,101 +0,0 @@ -import unittest - -from bcipy.config import BCIPY_ROOT -from bcipy.exceptions import SignalException -from bcipy.signal.process.decomposition import continuous_wavelet_transform -from bcipy.signal.process.decomposition.psd import power_spectral_density, PSD_TYPE -import numpy as np - - -class TestDecomposition(unittest.TestCase): - - def setUp(self) -> None: - self.fs = 100 - data = np.loadtxt(f'{BCIPY_ROOT}/signal/tests/process/decomposition/resources/data.txt') - np.arange(data.size) / self.fs - self.data = data - self.band = (8, 12) - - def test_cwt(self): - # create test data to pass to cwt - # test data should be 3d array of shape (trials, channels, time) - data = np.random.rand(10, 2, 1000) - freq = 8 - fs = 100 - wavelet = "cmor1.5-1.0" - data, scales = continuous_wavelet_transform(data, freq, fs, wavelet) - self.assertEqual(data.shape, (10, 2, 1000)) - self.assertEqual(scales, fs / freq) - - def test_cwt_with_bad_wavelet(self): - data = np.random.rand(10, 2, 1000) - freq = 8 - fs = 100 - wavelet = "bad_wavelet" - with self.assertRaises(ValueError): - continuous_wavelet_transform(data, freq, fs, wavelet) - - def test_psd_relative(self): - response = power_spectral_density( - self.data, - self.band, - sampling_rate=self.fs, - relative=True, - plot=False) - - self.assertIsInstance(response, float) - self.assertTrue(0 <= response <= 1) - - def test_psd_welch(self): - response = power_spectral_density( - self.data, - self.band, - sampling_rate=self.fs, - method=PSD_TYPE.WELCH, - plot=False) - - self.assertIsInstance(response, float) - - def test_psd_multitaper(self): - response = power_spectral_density( - self.data, - self.band, - sampling_rate=self.fs, - method=PSD_TYPE.MULTITAPER, - plot=False) - - self.assertIsInstance(response, float) - - def test_psd_bad_method(self): - with self.assertRaises(SignalException): - power_spectral_density( - self.data, - self.band, - sampling_rate=self.fs, - method="bad_method", - plot=False) - - def test_psd_bad_band(self): - # create test data to pass to psd low band > high band - band = (12, 8) - - with self.assertRaises(IndexError): - power_spectral_density( - self.data, - band, - sampling_rate=self.fs, - plot=False) - - def test_psd_bad_data(self): - data = np.random.rand(10, 2, 1000) # no frequency dimension - - with self.assertRaises(IndexError): - power_spectral_density( - data, - self.band, - sampling_rate=self.fs, - plot=False) - - -if __name__ == '__main__': - unittest.main() diff --git a/bcipy/simulator/README.md b/bcipy/simulator/README.md index bebaeadc8..ed9204120 100644 --- a/bcipy/simulator/README.md +++ b/bcipy/simulator/README.md @@ -1,6 +1,6 @@ -## RSVP Simulator +# RSVP Simulator -### Overview +## Overview This Simulator module aims to automate experimentation by sampling EEG data from prior sessions and running given models in a task loop, thus simulating a live session. @@ -8,7 +8,7 @@ This Simulator module aims to automate experimentation by sampling EEG data from `main.py` is the entry point for program. After following `BciPy` readme steps for setup, run the module from terminal: -``` +```bash (venv) $ bcipy-sim -h usage: bcipy-sim [-h] [-i] [--gui] [-d DATA_FOLDER] [-m MODEL_PATH] [-p PARAMETERS] [-n N] [-s SAMPLER] [-o OUTPUT] @@ -66,12 +66,12 @@ A directory is created for each simulation run. The directory contents are simil ## Main Components -* Task - a simulation task to be run (ex. RSVP Copy Phrase) -* TaskRunner - runs one or more iterations of a simulation -* TaskFactory - constructs the hierarchy of objects needed for the simulation. -* DataEngine - loads data to be used in a simulation and provides an API to query for data. -* DataProcessor - used by the DataEngine to pre-process data. Pre-processed data can be classified by a signal model. -* Sampler - strategy for sampling data from the data pool stored in the DataEngine. +- Task - a simulation task to be run (ex. RSVP Copy Phrase) +- TaskRunner - runs one or more iterations of a simulation +- TaskFactory - constructs the hierarchy of objects needed for the simulation. +- DataEngine - loads data to be used in a simulation and provides an API to query for data. +- DataProcessor - used by the DataEngine to pre-process data. Pre-processed data can be classified by a signal model. +- Sampler - strategy for sampling data from the data pool stored in the DataEngine. ## Device Support @@ -81,19 +81,19 @@ The simulator is structured to support evidence from multiple devices (multimoda The parameters file is used to configure various aspects of the of the simulation. Timing-related parameters should generally match the parameters file used for training the signal model(s). Following are some specific parameters that you may want to modify, depending on the goals of a particular simulation: -* `task_text` - the text to spell. -* `lang_model_type` - language model to use in the simulation. -* `summarize_session` - if set to true a session.xlsx summary will be generated for each simulation run. +- `task_text` - the text to spell. +- `lang_model_type` - language model to use in the simulation. +- `summarize_session` - if set to true a session.xlsx summary will be generated for each simulation run. ### Stoppage Criteria Parameters which define task stoppage criteria are important to ensure that the simulation runs to completion without getting stuck in an infinite loop. The values for these parameters may also affect analysis of results. -* `min_inq_len` - Specifies the minimum number of inquiries to present before making a decision in copy/spelling tasks. -* `max_inq_len` - maximum number of inquiries to display before stopping the task. -* `max_selections` - The maximum number of selections for copy/spelling tasks. The task will end if this number is reached. -* `max_incorrect` - The maximum number of consecutive incorrect selections for copy/spelling tasks. The task will end if this number is reached. -* `max_inq_per_series` - Specifies the maximum number of inquiries to present before making a decision in copy/spelling tasks +- `min_inq_len` - Specifies the minimum number of inquiries to present before making a decision in copy/spelling tasks. +- `max_inq_len` - maximum number of inquiries to display before stopping the task. +- `max_selections` - The maximum number of selections for copy/spelling tasks. The task will end if this number is reached. +- `max_incorrect` - The maximum number of consecutive incorrect selections for copy/spelling tasks. The task will end if this number is reached. +- `max_inq_per_series` - Specifies the maximum number of inquiries to present before making a decision in copy/spelling tasks ## GUI @@ -109,7 +109,7 @@ The simulator also includes a Task for replaying a recorded session using a diff This functionality currently has a different entry point. -``` +```bash (venv) $ bcipy-replay -h usage: bcipy-replay [-h] [-d DATA_FOLDER] -m MODEL_PATH [-p PARAMETERS] [-o OUTPUT] @@ -127,6 +127,16 @@ optional arguments: ## Current Limitations -* Only provides EEG support -* Only one sampler maybe provided for all devices. Ideally we should support a different sampling strategy for each device. -* Only Copy Phrase is currently supported. \ No newline at end of file +- Only provides EEG support +- Only one sampler maybe provided for all devices. Ideally we should support a different sampling strategy for each device. +- Only Copy Phrase is currently supported. + +## Group Demo + +A group simulation demo is provided in the `demo` directory. This demo includes the ability to run a simulation across multiple users, phrases, and language_models. The demo is run using the following command while in a virtual environment with the bcipy package installed: + +```bash +python bcipy/simulator/demo/demo_group_simulation.py +``` + +See the demo file for more details on how to configure the simulation. diff --git a/bcipy/simulator/data/data_process.py b/bcipy/simulator/data/data_process.py index bcd731c02..b2ccca4c5 100644 --- a/bcipy/simulator/data/data_process.py +++ b/bcipy/simulator/data/data_process.py @@ -252,7 +252,7 @@ def check_data_compatibility(self, data_device: DeviceSpec, if data_device.static_offset == devices.DEFAULT_STATIC_OFFSET: log.warning(' '.join([ - f"Using the default static offset to decode triggers for {data_device.name}.", + f"Using the default static offset [{devices.DEFAULT_STATIC_OFFSET}] for {data_device.name}.", "Please make sure the correct offset is included in the devices.json file." ])) diff --git a/bcipy/simulator/demo/demo_group_simulation.py b/bcipy/simulator/demo/demo_group_simulation.py new file mode 100644 index 000000000..ef5f215a6 --- /dev/null +++ b/bcipy/simulator/demo/demo_group_simulation.py @@ -0,0 +1,202 @@ +""" +This script will loop through all users in the experimental data directory and run simulations using copy phrase data. The primary outcome is to observe how +the task performance changes with different language models across a variety of phrases. This script will output results to a directory defined by OUTPUT_DIR. + +The results will be saved in a directory with the following format: + +```text + +output_dir/ + user1/ + phrase1/ + language_model1/ + user1_phrase1_language_model1_SIM_datetime/ + run_1/ + run_1.log + session.json + session.xlsx + triggers.txt + run_2/ + ... + summary_data.csv + metrics.png + phrase2/ + language_model1/ + user1_phrase2_language_model1_SIM_datetime/ + ... + user2/ + ... + +``` + +The summary data at the simulation run level (summary_data.json) will be the the data used to compare across users, phrases, language models. + +""" +from typing import Optional +from pathlib import Path +from bcipy.io.load import load_json_parameters +from bcipy.simulator.task.task_factory import TaskFactory +from bcipy.simulator.task.task_runner import TaskRunner, init_simulation_dir +import bcipy.simulator.util.metrics as metrics + +# Define the phrases, starting indeces, and language models to use for the simulation + +# Add phrases and starting indeces to this list +PHRASES = [ + ("I_LOVE_YOU", 0), + ("SEE_YOU_LATER", 4), + ("I_NEED_SOME_HELP", 7), +] +# Add registered BciPy language models by name here +LANGUAGE_MODELS = ["UNIFORM"] + +# Number of runs for each simulation +RUN_COUNT = 2 + +# add a custom output path for simulation output +OUTPUT_DIR = "./" + +# Filter data by file name that will be loaded and used for the simulation +MODE = "RSVP" +DATA_PATTERN = f"{MODE}_Copy_Phrase" + + +def run_simulation( + data_dir: Path, + user: str, + phrase: str, + starting_index: int, + language_model: str, + signal_model_path: Optional[str] = None) -> None: + """Run a simulation for a user, phrase, and language model. + + Optionally, a signal model path can be provided to use a specific signal model for the simulation. + If not provided, the script will search for a signal model in the mode calibration directory for the user. + + Parameters + ---------- + data_dir : Path + The path to the experimental data directory. + user : str + The user name. + phrase : str + The phrase to type. + starting_index : int + The starting index of the phrase. + language_model : str + The language model to use. + signal_model_path : Optional[str], optional + The path to the signal model to use for the simulation, by default None. + If not provided, the script will search for a signal model in the mode calibration directory. + Note*: The output structure in `init_simulation_dir` below should be updated to reflect the + signal model used if varying signal models. + + Raises + ------ + FileNotFoundError + I. If the signal model path is not provided and a signal model cannot be found in the mode calibration directory. + II. If a data directory cannot be found for the user. + + Outputs + ------- + The simulation results will be saved in the output directory defined by OUTPUT_DIR. + """ + print(f"Running simulation for {user} with phrase {phrase} and language model {language_model}") + + model_path = None + params_path = None + mode_calibration_dir = None + # load the parameters and model for the simulation from the mode calibration directory + + if not signal_model_path: + + for file in data_dir.iterdir(): + if file.is_dir() and MODE in file.name and "Calibration" in file.name: + mode_calibration_dir = file + # search for a pkl file + for pkl_file in mode_calibration_dir.iterdir(): + if pkl_file.is_file() and pkl_file.suffix == ".pkl": + model_path = pkl_file + break + if not model_path: + raise FileNotFoundError(f"Could not find a model file in {mode_calibration_dir}") + else: + model_path = signal_model_path + + print(f"Using signal model: {model_path}") + + # load the parameters + params_path = mode_calibration_dir / "parameters.json" + parameters = load_json_parameters(params_path, value_cast=True) + + # update the parameters with the new phrase, starting index, and language model + phrase_length = len(phrase) - starting_index + print(f"Processing {user}:{phrase}:{language_model} of phrase length: {phrase_length}") + parameters["task_text"] = phrase + parameters["spelled_letters_count"] = starting_index + parameters["lang_model_type"] = language_model + + # Below are task constraints that impact typing speed and letter selection. Here we use criteria based on phrase length + # and some sensible defaults. + parameters["max_inq_len"] = phrase_length * 8 + parameters["max_selections"] = phrase_length * 2 # This should be 2 * the length of the phrase to type + parameters["min_inq_per_series"] = 1 + parameters["max_inq_per_series"] = 8 + parameters["backspace_always_shown"] = True + parameters["summarize_session"] = False + parameters["lm_backspace_prob"] = 0.03571 + # signal model decision threshold for letter selection + parameters["decision_threshold"] = 0.8 + parameters["max_minutes"] = 120 # This is not used in the simulation. But we set it high to avoid any issues. + parameters["max_incorrect"] = int(phrase_length / 2) # This should be half the length of the phrase to type + + # get the correct list of source directories from the data directory + source_dirs = [str(file) for file in data_dir.iterdir() if file.is_dir() and DATA_PATTERN in file.name] + if not source_dirs: + raise FileNotFoundError(f"Could not find a data directory for {user}") + + # Construct the simulation task factory and output directory strucutre + task_factory = TaskFactory(source_dirs=source_dirs, + signal_model_paths=[str(model_path)], + parameters=parameters) + sim_dir = init_simulation_dir( + save_location=Path(OUTPUT_DIR), + prefix=f"{user}/{language_model}/{phrase}/{user}_{phrase}_{language_model}_") + runner = TaskRunner(save_dir=sim_dir, + task_factory=task_factory, + runs=RUN_COUNT) + # run the simulation and output the metrics + try: + runner.run() + metrics.report(sim_dir) + except Exception as e: + print(f"Error running simulation for {user} with phrase {phrase} and language model {language_model}") + print(e) + + +if __name__ == "__main__": + from bcipy.io.load import load_experimental_data + from pathlib import Path + from tqdm import tqdm + # load experimental directory. This will have users/run/data + data_dir = Path(load_experimental_data()) + + progress_bar = tqdm( + Path(data_dir).iterdir(), + total=len(list(Path(data_dir).iterdir())), + bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt} [est. {remaining}][ela. {elapsed}]\n", + colour='MAGENTA') + + # Loop over all the users, phrases and language models defined. + # Optionally, a signal model could be provided using the signal_model_path parameter. + # This is available in case the trained model is not in the mode calibration directory. It could be used to evaluate different signal models. + # We recommend changing the init_simulation_dir with some model name + # indicator to help with organizing the output for analysis. + for user in progress_bar: + if user.is_dir(): + progress_bar.set_description(f"Processing {user.name}") + for phrase, starting_index in PHRASES: + for language_model in LANGUAGE_MODELS: + run_simulation(user, user.name, phrase, starting_index, language_model) + + progress_bar.close() diff --git a/bcipy/simulator/task/copy_phrase.py b/bcipy/simulator/task/copy_phrase.py index 838fdd314..164a8d13a 100644 --- a/bcipy/simulator/task/copy_phrase.py +++ b/bcipy/simulator/task/copy_phrase.py @@ -12,6 +12,9 @@ from bcipy.signal.model.base_model import SignalModel from bcipy.simulator.data.sampler import Sampler from bcipy.simulator.task.null_display import NullDisplay +from bcipy.simulator.task.null_daq import NullDAQ +from bcipy.acquisition.multimodal import ClientManager + from bcipy.simulator.util.state import SimState from bcipy.task import TaskMode from bcipy.task.control.evidence import EvidenceEvaluator @@ -65,7 +68,7 @@ def setup( data_save_location: str, fake: bool = False) -> Tuple[Any, Any, Display]: """Override the setup method to avoid initializing the data acquisition.""" - daq = None + daq = self.init_acquisition() server = None display = self.init_display() self.initalized = True @@ -95,6 +98,10 @@ def init_evidence_types( def init_display(self) -> Display: return NullDisplay() + def init_acquisition(self) -> ClientManager: + """Override to do nothing""" + return NullDAQ() + def init_feedback(self) -> Optional[VisualFeedback]: return None diff --git a/bcipy/simulator/task/null_daq.py b/bcipy/simulator/task/null_daq.py new file mode 100644 index 000000000..4aaed7e4b --- /dev/null +++ b/bcipy/simulator/task/null_daq.py @@ -0,0 +1,18 @@ +"""DAQ that doesn't do anything""" +from bcipy.acquisition.multimodal import ClientManager + + +class NullDAQ(ClientManager): + """DAQ that doesn't do anything.""" + + def start_acquisition(self) -> None: + """Do nothing""" + + def stop_acquisition(self) -> None: + """Do nothing""" + + def cleanup(self) -> None: + """Do nothing""" + + def get_data_by_device(self, *args, **kwargs) -> None: + """Do nothing""" diff --git a/bcipy/simulator/task/null_display.py b/bcipy/simulator/task/null_display.py index e59c07f02..4eef7b50a 100644 --- a/bcipy/simulator/task/null_display.py +++ b/bcipy/simulator/task/null_display.py @@ -16,3 +16,6 @@ def wait_screen(self, *args, **kwargs) -> None: def update_task_bar(self, *args, **kwargs) -> None: """Do nothing""" + + def close(self) -> None: + """Do nothing""" diff --git a/bcipy/simulator/task/replay_session.py b/bcipy/simulator/task/replay_session.py index ab8a6167c..995f322e9 100644 --- a/bcipy/simulator/task/replay_session.py +++ b/bcipy/simulator/task/replay_session.py @@ -4,6 +4,7 @@ import logging from pathlib import Path +from bcipy.io.load import load_json_parameters from bcipy.simulator.data.sampler.replay_sampler import ReplaySampler from bcipy.simulator.task.replay_task import ReplayTask from bcipy.simulator.task.task_factory import TaskFactory @@ -49,7 +50,9 @@ def main(): runs = len(sim_args['data_folder']) outdir = sim_args['output'] - task_factory = TaskFactory(params_path=sim_args['parameters'], + parameters = load_json_parameters(sim_args['parameters'], value_cast=True) + + task_factory = TaskFactory(parameters=parameters, source_dirs=sim_args['data_folder'], signal_model_paths=[sim_args['model_path']], sampling_strategy=ReplaySampler, diff --git a/bcipy/simulator/task/task_factory.py b/bcipy/simulator/task/task_factory.py index 7b5bd4cf0..668e0e38d 100644 --- a/bcipy/simulator/task/task_factory.py +++ b/bcipy/simulator/task/task_factory.py @@ -29,18 +29,36 @@ def update_latest_params(parameters: Parameters) -> None: parameters.add_missing_items(default_params) -class TaskFactory(): - """Constructs the hierarchy of objects necessary for initializing a task.""" - - def __init__(self, - params_path: str, - source_dirs: List[str], - signal_model_paths: List[str], - sampling_strategy: Type[Sampler] = TargetNontargetSampler, - task: Type[SimulatorCopyPhraseTask] = SimulatorCopyPhraseTask, - sampler_args: Optional[Dict[str, Any]] = None): - - self.params_path = params_path +class TaskFactory: + """Constructs the hierarchy of objects necessary for initializing a task. + + + Parameters + ---------- + parameters : Parameters + The parameters to use for the simulation. + source_dirs : List[str] + The directories containing the raw data to be used in the simulation. + signal_model_paths : List[str] + The paths to the signal models to be used for data loaded via source_dirs in the simulation. + sampling_strategy : Type[Sampler], optional + The data sampling strategy to use, by default TargetNontargetSampler. + task : Type[SimulatorCopyPhraseTask], optional + The task to use for simulation, by default SimulatorCopyPhraseTask. This should ideally be + the corresponding task used to generate the data in the source_dirs. + sampler_args : Optional[Dict[str, Any]], optional + Additional arguments to pass to the sampler constructor, by default None. + """ + + def __init__( + self, + parameters: Parameters, + source_dirs: List[str], + signal_model_paths: List[str], + sampling_strategy: Type[Sampler] = TargetNontargetSampler, + task: Type[SimulatorCopyPhraseTask] = SimulatorCopyPhraseTask, + sampler_args: Optional[Dict[str, Any]] = None): + self.signal_model_paths = signal_model_paths self.source_dirs = source_dirs @@ -48,9 +66,8 @@ def __init__(self, self.sampler_args = sampler_args if sampler_args else {} self.simulation_task = task - logger.info("Loading parameters") - self.parameters = load_json_parameters(self.params_path, - value_cast=True) + self.parameters = parameters + update_latest_params(self.parameters) logger.info("Loading signal models") diff --git a/bcipy/simulator/task/task_runner.py b/bcipy/simulator/task/task_runner.py index c582dce31..0326af450 100644 --- a/bcipy/simulator/task/task_runner.py +++ b/bcipy/simulator/task/task_runner.py @@ -8,6 +8,7 @@ from rich.progress import track +from bcipy.io.load import load_json_parameters import bcipy.simulator.util.metrics as metrics # pylint: disable=wildcard-import,unused-wildcard-import # flake8: noqa @@ -148,8 +149,9 @@ def main(): elif args.interactive: task_factory = cli.main(sim_args) else: + parameters = load_json_parameters(sim_args['parameters'], value_cast=True) task_factory = TaskFactory( - params_path=sim_args['parameters'], + parameters=parameters, source_dirs=sim_args['data_folder'], signal_model_paths=sim_args['model_path'], sampling_strategy=classify(sim_args['sampler']), diff --git a/bcipy/simulator/ui/cli.py b/bcipy/simulator/ui/cli.py index 4ffaca8a7..a99cb5ce8 100644 --- a/bcipy/simulator/ui/cli.py +++ b/bcipy/simulator/ui/cli.py @@ -15,7 +15,7 @@ from bcipy.gui.file_dialog import ask_directory, ask_filename from bcipy.helpers.acquisition import active_content_types -from bcipy.io.load import choose_model_paths +from bcipy.io.load import choose_model_paths, load_json_parameters from bcipy.simulator.data.sampler import (InquirySampler, Sampler, TargetNontargetSampler) from bcipy.simulator.task.copy_phrase import SimulatorCopyPhraseTask @@ -254,7 +254,9 @@ def main(args: Dict[str, Any]) -> TaskFactory: sampler = choose_sampling_strategy() print(command(params, model_paths, source_dirs, sampler)) - return TaskFactory(params_path=params, + + parameters = load_json_parameters(params, value_cast=True) + return TaskFactory(parameters=parameters, source_dirs=source_dirs, signal_model_paths=model_paths, sampling_strategy=sampler, diff --git a/bcipy/simulator/ui/gui.py b/bcipy/simulator/ui/gui.py index aaa954926..d070c0c90 100644 --- a/bcipy/simulator/ui/gui.py +++ b/bcipy/simulator/ui/gui.py @@ -21,6 +21,7 @@ from bcipy.gui.file_dialog import FileDialog from bcipy.gui.main import static_text_control from bcipy.helpers.acquisition import active_content_types +from bcipy.io.load import load_json_parameters from bcipy.preferences import preferences from bcipy.simulator.data.sampler import TargetNontargetSampler from bcipy.simulator.data.sampler.base_sampler import Sampler @@ -781,7 +782,8 @@ def configure( factory = None if command: print(command, file=sys.stdout) - factory = TaskFactory(params_path=params, + parameters = load_json_parameters(params, value_cast=True) + factory = TaskFactory(parameters=parameters, source_dirs=data_paths, signal_model_paths=model_paths, sampling_strategy=sampler, diff --git a/bcipy/simulator/util/artifact.py b/bcipy/simulator/util/artifact.py index e36fe7837..03329863a 100644 --- a/bcipy/simulator/util/artifact.py +++ b/bcipy/simulator/util/artifact.py @@ -76,17 +76,20 @@ def configure_logger(log_path: str, def init_simulation_dir(save_location: str = DEFAULT_SAVE_LOCATION, logfile_name: str = DEFAULT_LOGFILE_NAME, - verbose: Optional[bool] = None) -> str: + verbose: Optional[bool] = None, + prefix: str = '') -> str: """Setup the folder structure and logging for a simulation. Parameters ---------- save_location - optional path in which new simulation directory will be created. logfile_name - optional name of the top level logfile within that directory. + verbose - optional flag to set the log level to DEBUG. + prefix - optional prefix for the simulation directory. Returns the path of the simulation directory. """ - save_dir = f"{save_location}/{directory_name()}" + save_dir = f"{save_location}/{prefix}{directory_name()}" os.makedirs(save_dir) configure_logger(save_dir, logfile_name, diff --git a/pyproject.toml b/pyproject.toml index fed92986c..16cfb2fc8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,6 +90,7 @@ bcipy-erp-viz = "bcipy.helpers.visualization:erp" bcipy-replay = "bcipy.simulator.task.replay_session:main" bcipy-sim = "bcipy.simulator:main" bcipy-train = "bcipy.signal.model.offline_analysis:main" +bcipy-params = "bcipy.gui.parameters.params_form:main" [project.urls] "Homepage" = "https://www.cambi.tech/" diff --git a/scripts/python/replay_session.py b/scripts/python/replay_session.py deleted file mode 100644 index dc24b6da0..000000000 --- a/scripts/python/replay_session.py +++ /dev/null @@ -1,364 +0,0 @@ -"""Script that will replay sessions and allow us to simulate new model predictions on that data.""" -import json -import logging as logger -import pickle -from pathlib import Path -from typing import Tuple - -import matplotlib.pyplot as plt -import numpy as np -import pandas as pd -import seaborn as sns - -import bcipy.acquisition.devices as devices -from bcipy.config import (DEFAULT_DEVICE_SPEC_FILENAME, - DEFAULT_PARAMETERS_FILENAME, RAW_DATA_FILENAME, - SESSION_DATA_FILENAME, TRIGGER_FILENAME) -from bcipy.helpers.acquisition import analysis_channels -from bcipy.core.list import grouper -from bcipy.io.load import load_json_parameters, load_raw_data -from bcipy.core.stimuli import InquiryReshaper, update_inquiry_timing -from bcipy.core.symbols import alphabet -from bcipy.core.triggers import TriggerType, trigger_decoder -from bcipy.signal.model import PcaRdaKdeModel, SignalModel -from bcipy.signal.process import (ERPTransformParams, filter_inquiries, - get_default_transform) - -logger.getLogger().setLevel(logger.INFO) - - -def load_model(model_path: Path, k_folds: int, model_class=PcaRdaKdeModel): - """Load the model at the given path""" - with open(model_path, "rb") as f: - model = pickle.load(f) - return model - - -def generate_replay_outputs( - data_folder, - parameters, - model_path: Path, - model_class=PcaRdaKdeModel, - symbol_set=alphabet(), - write_output=False) -> Tuple[dict, list, list]: - """Replay a session and generate outputs for the provided model. - - Parameters - ---------- - data_folder: Path - Path to the data folder containing the session data (session.json, raw_data.csv, triggers.txt) - parameters: dict - Parameters to use for the replay session. - model_path: Path - Path to the model to use for the replay session. - model_class: class - Class of the model to use for the replay session. By default, this is PcaRdaKdeModel. - symbol_set: list - List of symbols to use for the replay session. By default, this is the alphabet. - write_output: bool - Whether or not to write the output to a file. By default, this is False. - - Returns - ------- - tuple - new_model_outputs, old_model_target_output, old_model_nontarget_output - """ - k_folds = parameters.get("k_folds") - model: SignalModel = load_model(model_path, k_folds, model_class) - logger.info(f"Loaded model from {model_path}") - - # get trial information; to make backwards compatible, we will try to get the trial length - # from the parameters file (old), but if it's not there, we use the trial window and adjust timing (>2.0). - adjust_trials_by_window = False - trial_length = parameters.get("trial_length", None) - if trial_length is None: - trial_window = parameters.get("trial_window") - trial_length = trial_window[1] - trial_window[0] - adjust_trials_by_window = True - logger.info(f"Trial Window: {trial_window[0]}-{trial_window[1]}s") - - trials_per_inquiry = parameters.get("stim_length") - prestim_length = parameters.get("prestim_length", trial_length) - buffer_length = int(parameters.get("task_buffer_length") / 2) - - # get signal filtering information - static_offset = parameters.get("static_trigger_offset") - k_folds = parameters.get("k_folds") - transform_params = parameters.instantiate(ERPTransformParams) - downsample_rate = transform_params.down_sampling_rate - - logger.info( - f"\nData processing settings: \n" - f"{str(transform_params)} \n" - f"Trial Length: {trial_length}s, Trials per Inquiry: {trials_per_inquiry} \n" - f"Prestimulus Buffer: {prestim_length}s, Poststimulus Buffer: {buffer_length}s \n" - f"Static offset: {static_offset}" - ) - - data_list = load_raw_data(data_folder, [f"{RAW_DATA_FILENAME}.csv"]) - # NOTE: With the current inputs this function only loads the EEG data. Update needed for eyetracker data. - raw_data = data_list[0] - channels = raw_data.channels - type_amp = raw_data.daq_type - sample_rate = raw_data.sample_rate - devices.load(Path(data_folder, DEFAULT_DEVICE_SPEC_FILENAME)) - device_spec = devices.preconfigured_device(raw_data.daq_type) - channel_map = analysis_channels(channels, device_spec) - - # extract the raw data for the channels we care about. The channel map will further reduce this if necessary. - # by default, only the sample number and internal trigger channels are excluded. - raw_data, _ = raw_data.by_channel() - - logger.info(f"Device type: {type_amp}") - logger.info(f"Channels read from csv: {channels}") - channels_used = [channels[i] for i, keep in enumerate(channel_map) if keep == 1] - logger.info(f'Channels used in analysis: {channels_used}') - - trigger_targetness, trigger_timing, trigger_symbols = trigger_decoder( - offset=static_offset, - trigger_path=f"{data_folder}/{TRIGGER_FILENAME}", - exclusion=[TriggerType.PREVIEW, TriggerType.EVENT], - ) - - # Adjust trigger timing by trial window if necessary - if adjust_trials_by_window: - trigger_timing = [timing + trial_window[0] for timing in trigger_timing] - - inquiries, inquiry_labels, inquiry_timing = InquiryReshaper()( - trial_targetness_label=trigger_targetness, - timing_info=trigger_timing, - eeg_data=raw_data, - sample_rate=sample_rate, - trials_per_inquiry=trials_per_inquiry, - channel_map=channel_map, - poststimulus_length=trial_length, - prestimulus_length=prestim_length, - transformation_buffer=buffer_length, - ) - - default_transform = get_default_transform( - sample_rate_hz=sample_rate, - notch_freq_hz=transform_params.notch_filter_frequency, - bandpass_low=transform_params.filter_low, - bandpass_high=transform_params.filter_high, - bandpass_order=transform_params.filter_order, - downsample_factor=transform_params.down_sampling_rate, - ) - - inquiries, transformed_sample_rate = filter_inquiries(inquiries, default_transform, sample_rate) - trial_duration_samples = int(trial_length * transformed_sample_rate) - inquiry_timing = update_inquiry_timing(inquiry_timing, downsample_rate) - trials = InquiryReshaper.extract_trials(inquiries, trial_duration_samples, inquiry_timing) - - # get the model outputs using the reshaped data - outputs = {} - inquiry_worth_of_trials = np.split(trials, inquiries.shape[1], 1) - inquiry_worth_of_letters = grouper(trigger_symbols, trials_per_inquiry, incomplete="ignore") - for i, (inquiry_trials, this_inquiry_letters, this_inquiry_labels) in enumerate( - zip(inquiry_worth_of_trials, inquiry_worth_of_letters, inquiry_labels) - ): - response = model.compute_likelihood_ratio(inquiry_trials, this_inquiry_letters, symbol_set=symbol_set) - if np.any(this_inquiry_labels == 1): - index_of_target_trial = np.argwhere(this_inquiry_labels == 1)[0][0] - target_letter = this_inquiry_letters[index_of_target_trial] - target_index_in_alphabet = symbol_set.index(target_letter) - - nontarget_idx_in_alphabet = [symbol_set.index(q) for q in this_inquiry_letters if q != target_letter] - else: - target_index_in_alphabet = None - nontarget_idx_in_alphabet = [symbol_set.index(q) for q in this_inquiry_letters] - - outputs[i] = { - "eeg_evidence": list(response), - "target_idx": target_index_in_alphabet, - "nontarget_idx": nontarget_idx_in_alphabet, - } - - if write_output: - with open(data_folder / "replay_outputs.json", "w") as f: - json.dump(outputs, f, indent=2) - - # Get values computed during actual experiment from session.json for comparison - session_json = data_folder / SESSION_DATA_FILENAME - all_target_eeg, all_nontarget_eeg = load_eeg_evidence_from_session_json(session_json, symbol_set) - - return outputs, all_target_eeg, all_nontarget_eeg - - -def load_eeg_evidence_from_session_json(session_json, symbol_set) -> Tuple[list, list]: - """Load EEG evidence from session.json file for comparison with replay outputs. - - Parameters - ---------- - session_json : str - Path to session.json file - symbol_set : list - List of symbols used in experiment - - Returns - ------- - all_target_eeg : list - List of EEG evidence for target stimuli - all_nontarget_eeg : list - List of EEG evidence for nontarget stimuli - """ - with open(session_json, "r") as f: - contents = json.load(f) - series = contents["series"] - - all_target_eeg = [] - all_nontarget_eeg = [] - - for inquiries in series.values(): - for inquiry in inquiries.values(): - if len(inquiry["eeg_evidence"]) < 1: - continue - else: - stim_label = inquiry["stimuli"][0] # name of symbols presented - stim_label.pop(0) # remove fixation cross - stim_indices = [symbol_set.index(sym) for sym in stim_label] - targetness = inquiry["target_info"] # targetness of stimuli - targetness.pop(0) # remove fixation cross - target = [index for index, label in zip(stim_indices, targetness) if label == "target"] - nontarget = [index for index, label in zip(stim_indices, targetness) if label == "nontarget"] - all_target_eeg.extend([inquiry["eeg_evidence"][pos] for pos in target]) - all_nontarget_eeg.extend([inquiry["eeg_evidence"][pos] for pos in nontarget]) - - return all_target_eeg, all_nontarget_eeg - - -def plot_collected_outputs( - outputs_with_new_model: dict, - targets_with_old_model: list, - nontargets_with_old_model: list, - outdir: str) -> None: - """Plot collected outputs from replay experiment. - - Parameters - ---------- - outputs_with_new_model : dict - List of outputs from replay experiment using new model - targets_with_old_model : list - List of outputs from the session data using old model for target stimuli - nontargets_with_old_model : list - List of outputs from the session using old model for nontarget stimuli - outdir : str - Path to directory to save plots - """ - records = [] - for output in outputs_with_new_model: # each session - for _, inquiry_contents in output.items(): # each inquiry - target_idx = inquiry_contents["target_idx"] - if target_idx is not None: - records.append( - { - "which_model": "new_target", - "response_value": inquiry_contents["eeg_evidence"][target_idx], - } - ) - - nontarget_idx = inquiry_contents["nontarget_idx"] - for i in nontarget_idx: - records.append({"which_model": "new_nontarget", "response_value": inquiry_contents["eeg_evidence"][i]}) - - for target_response in targets_with_old_model: - records.append({"which_model": "old_target", "response_value": target_response}) - for nontarget_response in nontargets_with_old_model: - records.append({"which_model": "old_nontarget", "response_value": nontarget_response}) - - df = pd.DataFrame.from_records(records) - logger.info(f"{df.describe()}") - ax = sns.stripplot( - x="which_model", - y="response_value", - data=df, - order=["old_target", "new_target", "old_nontarget", "new_nontarget"], - ) - sns.boxplot( - showmeans=True, - meanline=True, - meanprops={"color": "k", "ls": "-", "lw": 2}, - medianprops={"visible": False}, - whiskerprops={"visible": False}, - zorder=10, - x="which_model", - y="response_value", - data=df, - showfliers=False, - showbox=False, - showcaps=False, - ax=ax, - order=["old_target", "new_target", "old_nontarget", "new_nontarget"], - ) - - ax.set(yscale="log") - plt.savefig(outdir / "response_values.stripplot.png", dpi=150, bbox_inches="tight") - plt.close() - ax = sns.boxplot( - x="which_model", - y="response_value", - data=df, - order=["old_target", "new_target", "old_nontarget", "new_nontarget"], - ) - ax.set(yscale="log") - plt.savefig(outdir / "response_values.boxplot.png", dpi=150, bbox_inches="tight") - - -if __name__ == "__main__": - import argparse - - parser = argparse.ArgumentParser() - parser.add_argument( - "-d", - "--data_folders", - action="append", - type=Path, - required=True, - help="Session data folders to be processed. This argument can be repeated to accumulate sessions data.") - parser.add_argument("-m", "--model_file", required=True) - parser.add_argument("-o", "--outdir", type=Path, default=None) - parser.add_argument( - "-p", - "--parameter_file", - type=Path, - default=None, - help="Parameter file to be used for replay. If none, the session parameter file will be used.") - - args = parser.parse_args() - - assert len(set(args.data_folders)) == len(args.data_folders), "Duplicated data folders" - - if args.outdir is None: - args.outdir = Path(__file__).resolve().parent - - logger.info(f"Outdir: {args.outdir}") - - outputs_with_new_model = [] - targets_with_old_model = [] - nontargets_with_old_model = [] - for data_folder in args.data_folders: - logger.info(f"Processing {data_folder}") - - if args.parameter_file is not None: - params_file = args.parameter_file - else: - params_file = Path(data_folder, DEFAULT_PARAMETERS_FILENAME) - logger.info(f"Loading params from {params_file}") - params = load_json_parameters(params_file, value_cast=True) - - # Generate replay outputs using the model provided against the session data in data_folder - outputs, all_target_eeg, all_nontarget_eeg = generate_replay_outputs( - data_folder, params, args.model_file, write_output=False - ) - outputs_with_new_model.append(outputs) - targets_with_old_model.extend(all_target_eeg) - nontargets_with_old_model.extend(all_nontarget_eeg) - - plot_collected_outputs( - outputs_with_new_model, - targets_with_old_model, - nontargets_with_old_model, - args.outdir) - - # breakpoint() - - logger.info("Replay complete.") \ No newline at end of file diff --git a/scripts/python/update_params.py b/scripts/python/update_params.py index 01f1515a1..a3b74d288 100644 --- a/scripts/python/update_params.py +++ b/scripts/python/update_params.py @@ -1,6 +1,5 @@ """Update parameters files to latest.""" -import argparse import json import shutil from pathlib import Path @@ -55,11 +54,15 @@ def update(params_path: str) -> None: if __name__ == "__main__": - parser = argparse.ArgumentParser() - parser.add_argument("-p", - "--parameters", - type=Path, - required=False, - help="Parameter File to be used") - args = parser.parse_args() - update(args.parameters) + from bcipy.io.load import load_experimental_data + # load experimental directory. This will have users/run/data + data_dir = Path(load_experimental_data()) + + # loop over all the users + for user in data_dir.iterdir(): + if user.is_dir(): + for run in user.iterdir(): + if run.is_dir(): + for file in run.iterdir(): + if file.name == "parameters.json": + update(str(file)) From cb3849472061059f256f781bf4b3c5255963223f Mon Sep 17 00:00:00 2001 From: lawhead Date: Thu, 27 Mar 2025 15:43:32 -0700 Subject: [PATCH 64/76] Renamed button press to switch --- bcipy/simulator/data/processor_registry.py | 5 ++--- bcipy/simulator/demo/README.md | 12 ++++++------ .../{button_model.pkl => switch_model.pkl} | Bin 475 -> 464 bytes ...ta_processor.py => switch_data_processor.py} | 10 +++++----- .../{button_press_model.py => switch_model.py} | 4 ++-- .../{button_press_utils.py => switch_utils.py} | 6 +++--- ...ocessor.py => test_switch_data_processor.py} | 13 ++++++------- ...tton_press_model.py => test_switch_model.py} | 8 ++++---- 8 files changed, 28 insertions(+), 30 deletions(-) rename bcipy/simulator/demo/resources/{button_model.pkl => switch_model.pkl} (82%) rename bcipy/simulator/demo/{button_press_data_processor.py => switch_data_processor.py} (94%) rename bcipy/simulator/demo/{button_press_model.py => switch_model.py} (97%) rename bcipy/simulator/demo/{button_press_utils.py => switch_utils.py} (95%) rename bcipy/simulator/tests/{test_button_data_processor.py => test_switch_data_processor.py} (82%) rename bcipy/simulator/tests/{test_button_press_model.py => test_switch_model.py} (92%) diff --git a/bcipy/simulator/data/processor_registry.py b/bcipy/simulator/data/processor_registry.py index a128e23b6..7b6f33bec 100644 --- a/bcipy/simulator/data/processor_registry.py +++ b/bcipy/simulator/data/processor_registry.py @@ -7,13 +7,12 @@ # flake8: noqa from bcipy.simulator.data.data_process import (EegRawDataProcessor, RawDataProcessor) -from bcipy.simulator.demo.button_press_data_processor import \ - ButtonPressDataProcessor +from bcipy.simulator.demo.switch_data_processor import SwitchDataProcessor from bcipy.task.data import EvidenceType log = logger.getLogger(__name__) -PROCESSOR_TYPES = [EegRawDataProcessor, ButtonPressDataProcessor] +PROCESSOR_TYPES = [EegRawDataProcessor, SwitchDataProcessor] def get_processors() -> List[Type[RawDataProcessor]]: diff --git a/bcipy/simulator/demo/README.md b/bcipy/simulator/demo/README.md index cc948c9d2..e9a2837a6 100644 --- a/bcipy/simulator/demo/README.md +++ b/bcipy/simulator/demo/README.md @@ -5,21 +5,21 @@ This module demonstrates how the simulator can be extended for various use cases ## Multimodal -The `button_data_processor` and `button_press_model` are used to demonstrate a multimodal simulations using a button/switch as an example. To run a simulation with these inputs, you will need to perform the following steps: +The `switch_data_processor` and `switch_model` are used to demonstrate a multimodal simulations using a button/switch as an example. To run a simulation with these inputs, you will need to perform the following steps: -1. Ensure that the button signal model can be loaded or create a button signal model. To create a new one: +1. Ensure that the switch signal model can be loaded or create a switch signal model. To create a new one: ``` from pathlib import Path from bcipy.acquisition.datastream.mock.switch import switch_device from bcipy.io.save import save_model - from bcipy.simulator.demo.button_press_model import ButtonPressModel + from bcipy.simulator.demo.switch_model import SwitchModel from bcipy.signal.model.base_model import SignalModel, SignalModelMetadata dirname = "" # TODO: enter the directory - model = ButtonPressModel() + model = SwitchModel() model.metadata = SignalModelMetadata(device_spec=switch_device(), evidence_type="BTN", transform=None) - save_model(model, Path(dirname, "button_model.pkl")) + save_model(model, Path(dirname, "switch_model.pkl")) ``` 2. Ensure that the devices.json file has an entry for a switch @@ -65,7 +65,7 @@ For inquiries in which the target not shown: - evidence values for symbols in inquiry should be degraded -Note that the progress method (`preview_inquiry_progress_method` parameter) doesn't matter if it is set to "press to accept" or "press to skip", since the ButtonDataProcessor interprets this and outputs a 1.0 for inquiries that should be supported and 0.0 for those that shouldn't. +Note that the progress method (`preview_inquiry_progress_method` parameter) doesn't matter if it is set to "press to accept" or "press to skip", since the SwitchDataProcessor interprets this and outputs a 1.0 for inquiries that should be supported and 0.0 for those that shouldn't. ### Limitations diff --git a/bcipy/simulator/demo/resources/button_model.pkl b/bcipy/simulator/demo/resources/switch_model.pkl similarity index 82% rename from bcipy/simulator/demo/resources/button_model.pkl rename to bcipy/simulator/demo/resources/switch_model.pkl index 64838da8fa499ce5421553cd7b0917012dc38ace..35b2e0fb13b6fca93a79d8978c1a7bdd75cc82de 100644 GIT binary patch delta 64 zcmcc3e1X}#fo1AZMg}nGQA|qCEU45g&de>%Ni4}P(o0Fr&DSd~&n!vKh|kSWNzIwk N!yOFfZgiT-2mrEI7RLYp delta 75 zcmcb>e4E*+fo19iMg}nGQBO+FEU45g&de>%Ni4}P(o0Fr&DTpREh)**i!UfjEiR7F W%}+_qnbITR1QrYciET8W$p`?{AsdPS diff --git a/bcipy/simulator/demo/button_press_data_processor.py b/bcipy/simulator/demo/switch_data_processor.py similarity index 94% rename from bcipy/simulator/demo/button_press_data_processor.py rename to bcipy/simulator/demo/switch_data_processor.py index 99d20cd93..fb218d084 100644 --- a/bcipy/simulator/demo/button_press_data_processor.py +++ b/bcipy/simulator/demo/switch_data_processor.py @@ -17,14 +17,14 @@ ExtractedExperimentData, RawDataProcessor, ReshapedData, TimingParams) -from bcipy.simulator.demo.button_press_utils import (should_press_button, - simulate_raw_data, - switch_device) +from bcipy.simulator.demo.switch_utils import (should_press_switch, + simulate_raw_data, + switch_device) from bcipy.simulator.exceptions import IncompatibleParameters from bcipy.task.data import EvidenceType -class ButtonPressDataProcessor(RawDataProcessor): +class SwitchDataProcessor(RawDataProcessor): """Data Processor for button press data. Note that this class does not read raw data, but instead simulates what a @@ -74,7 +74,7 @@ def process(self, data_folder: str, target_lbl = 1 if trg.type == TriggerType.TARGET else 0 # Returns the same value for all trials in the inquiry. Implies that # the button was pressed some time during the inquiry. - data = 1.0 if should_press_button(inquiry_triggers, + data = 1.0 if should_press_switch(inquiry_triggers, button_press_mode) else 0.0 inquiry_trial_data.append(data) inquiry_target_labels.append(target_lbl) diff --git a/bcipy/simulator/demo/button_press_model.py b/bcipy/simulator/demo/switch_model.py similarity index 97% rename from bcipy/simulator/demo/button_press_model.py rename to bcipy/simulator/demo/switch_model.py index fa2e2a34e..d05b58ced 100644 --- a/bcipy/simulator/demo/button_press_model.py +++ b/bcipy/simulator/demo/switch_model.py @@ -9,7 +9,7 @@ from bcipy.signal.model.inquiry_preview import compute_probs_after_preview -class ButtonPressModel(SignalModel): +class SwitchModel(SignalModel): """Signal model that classifies button presses. This is a demo model. The provided data should be comprised of ones and zeros, @@ -23,7 +23,7 @@ class ButtonPressModel(SignalModel): error_prob - Specifies the probability of a button press error. """ - name = "ButtonPressModel" + name = "SwitchModel" reshaper: InquiryReshaper = InquiryReshaper() def __init__(self, error_prob: float = 0.05): diff --git a/bcipy/simulator/demo/button_press_utils.py b/bcipy/simulator/demo/switch_utils.py similarity index 95% rename from bcipy/simulator/demo/button_press_utils.py rename to bcipy/simulator/demo/switch_utils.py index a59cf4966..ae9207ae5 100644 --- a/bcipy/simulator/demo/button_press_utils.py +++ b/bcipy/simulator/demo/switch_utils.py @@ -1,4 +1,4 @@ -"""Utilities for working with button press data.""" +"""Utilities for working with switch data.""" import random from pathlib import Path @@ -68,7 +68,7 @@ def time_range(inquiry_triggers: List[Trigger], return (inquiry_triggers[0].time, inquiry_triggers[-1].time + time_flash) -def should_press_button(inquiry_triggers: List[Trigger], +def should_press_switch(inquiry_triggers: List[Trigger], button_press_mode: ButtonPressMode) -> bool: """Determine if a marker should be written for the given inquiry depending on the presence of a target and the button press mode.""" @@ -110,7 +110,7 @@ def simulate_raw_data(data_dir: Path, parameters: Parameters): columns=columns) as writer: for inquiry_triggers in partition_triggers( Path(data_dir, config.TRIGGER_FILENAME)): - if should_press_button(inquiry_triggers, button_press_mode): + if should_press_switch(inquiry_triggers, button_press_mode): rownum += 1 stamp = timestamp_within_inquiry(inquiry_triggers, parameters) diff --git a/bcipy/simulator/tests/test_button_data_processor.py b/bcipy/simulator/tests/test_switch_data_processor.py similarity index 82% rename from bcipy/simulator/tests/test_button_data_processor.py rename to bcipy/simulator/tests/test_switch_data_processor.py index eddd2efc6..258173fdb 100644 --- a/bcipy/simulator/tests/test_button_data_processor.py +++ b/bcipy/simulator/tests/test_switch_data_processor.py @@ -9,19 +9,18 @@ from bcipy.acquisition.datastream.mock.switch import switch_device from bcipy.io.load import load_json_parameters from bcipy.signal.model.base_model import SignalModelMetadata -from bcipy.simulator.demo.button_press_data_processor import \ - ButtonPressDataProcessor -from bcipy.simulator.demo.button_press_model import ButtonPressModel +from bcipy.simulator.demo.switch_data_processor import SwitchDataProcessor +from bcipy.simulator.demo.switch_model import SwitchModel -class ButtonPressProcessorTest(unittest.TestCase): - """Tests for Button Press data processor.""" +class SwitchProcessorTest(unittest.TestCase): + """Tests for Switch data processor.""" def setUp(self): """Override.""" self.data_dir = f"{os.path.dirname(__file__)}/resources/" self.temp_dir = tempfile.mkdtemp() - self.model = ButtonPressModel() + self.model = SwitchModel() self.model.metadata = SignalModelMetadata(device_spec=switch_device(), evidence_type="BTN", transform=None) @@ -30,7 +29,7 @@ def setUp(self): def test_extracted_data(self): """Test extracted data.""" - processor = ButtonPressDataProcessor(model=self.model) + processor = SwitchDataProcessor(model=self.model) params = load_json_parameters(self.params_path) extracted_data = processor.process(self.data_dir, params) diff --git a/bcipy/simulator/tests/test_button_press_model.py b/bcipy/simulator/tests/test_switch_model.py similarity index 92% rename from bcipy/simulator/tests/test_button_press_model.py rename to bcipy/simulator/tests/test_switch_model.py index 95402fdc6..ed8359dcb 100644 --- a/bcipy/simulator/tests/test_button_press_model.py +++ b/bcipy/simulator/tests/test_switch_model.py @@ -4,15 +4,15 @@ import numpy as np -from bcipy.simulator.demo.button_press_model import ButtonPressModel +from bcipy.simulator.demo.switch_model import SwitchModel -class ButtonPressModelTest(unittest.TestCase): - """Tests for Button Press Model.""" +class SwitchModelTest(unittest.TestCase): + """Tests for Switch Model.""" def setUp(self): """Override; set up model.""" - self.model = ButtonPressModel() + self.model = SwitchModel() self.symbol_set = list(ascii_uppercase) def test_all_positive_inquiry(self): From e29a1113d55764e85aa17f7b7ffec282f6901e3e Mon Sep 17 00:00:00 2001 From: lawhead Date: Fri, 28 Mar 2025 15:03:12 -0700 Subject: [PATCH 65/76] Added code to generate raw data files for switch data; added convenience method for trigger decoding --- bcipy/core/triggers.py | 53 ++++++++++++++----- bcipy/simulator/demo/switch_utils.py | 76 +++++++++++++++++++++++----- 2 files changed, 103 insertions(+), 26 deletions(-) diff --git a/bcipy/core/triggers.py b/bcipy/core/triggers.py index b829aa083..906982ee1 100644 --- a/bcipy/core/triggers.py +++ b/bcipy/core/triggers.py @@ -6,10 +6,10 @@ from psychopy import core, visual from bcipy.config import DEFAULT_ENCODING, SESSION_LOG_FILENAME -from bcipy.helpers.clock import Clock -from bcipy.exceptions import BciPyCoreException from bcipy.core.parameters import Parameters from bcipy.core.stimuli import resize_image +from bcipy.exceptions import BciPyCoreException +from bcipy.helpers.clock import Clock log = logging.getLogger(SESSION_LOG_FILENAME) @@ -495,16 +495,15 @@ def convert_timing_triggers(timing: List[Tuple[str, float]], target_stimuli: str ] -def trigger_decoder( - trigger_path: str, - remove_pre_fixation: bool = True, - offset: float = 0.0, - exclusion: Optional[List[TriggerType]] = None, - device_type: Optional[str] = None, - apply_starting_offset: bool = True) -> Tuple[list, list, list]: +def load_triggers(trigger_path: str, + remove_pre_fixation: bool = True, + offset: float = 0.0, + exclusion: Optional[List[TriggerType]] = None, + device_type: Optional[str] = None, + apply_starting_offset: bool = True) -> List[Trigger]: """Trigger Decoder. - Given a path to trigger data, this method loads valid Triggers and returns their type, timing and label. + Given a path to trigger data, this method loads valid Triggers. Parameters ---------- @@ -518,7 +517,7 @@ def trigger_decoder( the given device_type. Returns ------- - tuple: trigger_type, trigger_timing, trigger_label + list of Triggers """ excluded_types = exclusion or [] excluded_types += TriggerType.pre_fixation() if remove_pre_fixation else [ @@ -532,6 +531,36 @@ def trigger_decoder( filtered = exclude_types(triggers, excluded_types) corrected = apply_offsets(filtered, starting_offset, static_offset=offset) + return corrected + + +def trigger_decoder( + trigger_path: str, + remove_pre_fixation: bool = True, + offset: float = 0.0, + exclusion: Optional[List[TriggerType]] = None, + device_type: Optional[str] = None, + apply_starting_offset: bool = True) -> Tuple[list, list, list]: + """Trigger Decoder. + + Given a path to trigger data, this method loads valid Triggers and returns their type, timing and label. + + Parameters + ---------- + trigger_path: path to triggers file + remove_pre_fixation: boolean to determine whether any stimuli before a fixation + system should be removed + offset: static offset value to apply to triggers. + exclusion: any TriggerTypes to be filtered from data returned + device_type: used to determine which starting_offset value to use; if + a 'starting_offset' trigger is found it will be applied. + apply_starting_offset: if False, does not apply the starting offset for + the given device_type. + Returns + ------- + tuple: trigger_type, trigger_timing, trigger_label + """ + triggers = load_triggers(trigger_path, remove_pre_fixation, offset, + exclusion, device_type, apply_starting_offset) - labels, types, times = zip(*corrected) + labels, types, times = zip(*triggers) return list(map(str, types)), list(times), list(labels) diff --git a/bcipy/simulator/demo/switch_utils.py b/bcipy/simulator/demo/switch_utils.py index ae9207ae5..844b8259f 100644 --- a/bcipy/simulator/demo/switch_utils.py +++ b/bcipy/simulator/demo/switch_utils.py @@ -1,5 +1,6 @@ """Utilities for working with switch data.""" +import argparse import random from pathlib import Path from typing import List, Tuple @@ -8,7 +9,7 @@ from bcipy.acquisition.datastream.mock.switch import switch_device from bcipy.core.parameters import Parameters from bcipy.core.raw_data import RawDataWriter -from bcipy.core.triggers import Trigger, TriggerType, trigger_decoder +from bcipy.core.triggers import Trigger, TriggerType, load_triggers from bcipy.display.main import ButtonPressMode, PreviewParams from bcipy.helpers.acquisition import raw_data_filename @@ -32,15 +33,10 @@ def partition_triggers(trigger_path: Path) -> List[List[Trigger]]: ------- list of entries where each entry represents the triggers for an inquiry. """ - trg_types, trg_times, trg_labels = trigger_decoder( - str(trigger_path), - remove_pre_fixation=False, - apply_starting_offset=False) - triggers = [ - Trigger(label=trg_labels[i], - type=TriggerType(trg_types[i]), - time=trg_times[i]) for i in range(len(trg_types)) - ] + triggers = load_triggers(str(trigger_path), + remove_pre_fixation=False, + apply_starting_offset=False) + # index for each prompt trigger inq_start_indices = [ i for i, trg in enumerate(triggers) if trg.type == TriggerType.FIXATION @@ -53,9 +49,9 @@ def partition_triggers(trigger_path: Path) -> List[List[Trigger]]: return inquiry_triggers -def has_target(inquiry_triggers: List[Trigger]) -> bool: +def has_target(triggers: List[Trigger]) -> bool: """Check if the list of inquiries contains a target.""" - for trg in inquiry_triggers: + for trg in triggers: if trg.type == TriggerType.TARGET: return True return False @@ -105,7 +101,7 @@ def simulate_raw_data(data_dir: Path, parameters: Parameters): columns = ['timestamp', 'Marker', 'lsl_timestamp'] rownum = 0 with RawDataWriter(raw_data_path, - daq_type=spec.content_type, + daq_type=spec.name, sample_rate=spec.sample_rate, columns=columns) as writer: for inquiry_triggers in partition_triggers( @@ -113,7 +109,59 @@ def simulate_raw_data(data_dir: Path, parameters: Parameters): if should_press_switch(inquiry_triggers, button_press_mode): rownum += 1 stamp = timestamp_within_inquiry(inquiry_triggers, parameters) - writer.writerow([rownum, 1.0, stamp]) return raw_data_path + + +def generate_raw_data(data_dir: Path, + event_label_prefix: str = 'bcipy_key_press'): + """Given a data directory for a task where the Inquiry Preview feature was used, + output a raw_data file for the switch data. + + Parameters + ---------- + data_dir - data directory for a task which should include a triggers.txt file. + event_label_prefix - optional label given to key press events (see task.py get_key_press). + """ + + spec = switch_device() + raw_data_path = Path(data_dir, raw_data_filename(spec)) + + if raw_data_path.exists(): + return raw_data_path + + trigger_path = Path(data_dir, config.TRIGGER_FILENAME) + triggers = load_triggers(str(trigger_path), apply_starting_offset=False) + + key_press_triggers = [ + trg for trg in triggers + if trg.type == TriggerType.EVENT and event_label_prefix in trg.label + ] + + columns = ['timestamp', 'Marker', 'lsl_timestamp'] + with RawDataWriter(raw_data_path, + daq_type=spec.name, + sample_rate=spec.sample_rate, + columns=columns) as writer: + for i, trg in enumerate(key_press_triggers): + writer.writerow([i + 1, 1.0, trg.time]) + + return raw_data_path + + +def main(): + """"Main method used to generate a raw data file for a switch device.""" + parser = argparse.ArgumentParser() + parser.add_argument("-d", + "--data_folder", + type=Path, + required=True, + action='append', + help="Data directory (must contain triggers.txt file)") + args = parser.parse_args() + print(generate_raw_data(args.data_dir)) + + +if __name__ == '__main__': + main() From 913a1d59f971b787495d766a1bc21a1643fb22ca Mon Sep 17 00:00:00 2001 From: lawhead Date: Tue, 1 Apr 2025 10:38:36 -0700 Subject: [PATCH 66/76] Refactored switch data processor to parse switch raw data --- bcipy/simulator/demo/switch_data_processor.py | 77 ++++++++++++++----- bcipy/simulator/demo/switch_utils.py | 15 +++- .../tests/resources/markers_data_switch.csv | 17 ++++ bcipy/simulator/tests/resources/triggers.txt | 49 ++++++------ .../tests/test_switch_data_processor.py | 45 ++++++++++- 5 files changed, 157 insertions(+), 46 deletions(-) create mode 100644 bcipy/simulator/tests/resources/markers_data_switch.csv diff --git a/bcipy/simulator/demo/switch_data_processor.py b/bcipy/simulator/demo/switch_data_processor.py index fb218d084..8ee3235d8 100644 --- a/bcipy/simulator/demo/switch_data_processor.py +++ b/bcipy/simulator/demo/switch_data_processor.py @@ -1,5 +1,6 @@ """Process raw button press data.""" +from pathlib import Path from typing import List, Tuple import numpy as np @@ -7,31 +8,41 @@ from bcipy.acquisition import devices from bcipy.acquisition.devices import DeviceSpec from bcipy.acquisition.multimodal import ContentType +from bcipy.config import TRIGGER_FILENAME from bcipy.core.list import grouper from bcipy.core.parameters import Parameters from bcipy.core.raw_data import RawData from bcipy.core.triggers import TriggerType from bcipy.display.main import ButtonPressMode, PreviewParams +from bcipy.helpers.acquisition import raw_data_filename from bcipy.io.load import load_raw_data from bcipy.simulator.data.data_process import (DecodedTriggers, ExtractedExperimentData, RawDataProcessor, ReshapedData, TimingParams) -from bcipy.simulator.demo.switch_utils import (should_press_switch, - simulate_raw_data, - switch_device) -from bcipy.simulator.exceptions import IncompatibleParameters +from bcipy.simulator.demo.switch_utils import inquiry_windows, switch_device +from bcipy.simulator.exceptions import IncompatibleData, IncompatibleParameters from bcipy.task.data import EvidenceType class SwitchDataProcessor(RawDataProcessor): """Data Processor for button press data. - Note that this class does not read raw data, but instead simulates what a - user may have done. If a target was present in an inquiry and the button press - mode is 'press to accept', all trials in that inquiry are given data of 1.0, - otherwise the data value is 0.0. If the mode is 'press to skip', trials within - inquiries that do not contain the target are given 1.0 values. + Note that this class reads raw switch data. If this is not present in the + data_folder it can be generated from Inquiry Preview triggers or simulated. + See the switch_utils module for details. + + The processed data values associated with a switch press depend on the button + press mode configured parameters. + + If the mode is 'press to accept/confirm' and the switch is pressed, all trials + in that inquiry are given a data value of 1.0. Otherwise, the data value is 0.0. + + If the mode is 'press to skip', all trials in inquiries with a button press are + given a value 0.0. Otherwise, the data value is 1.0. + + The switch model will upvote all symbols in an inquiry if any of the symbols have + a value of 1.0, and will downvote if all are 0.0. This also means that this processor should only be used in conjunction with the InquirySampler. @@ -39,6 +50,29 @@ class SwitchDataProcessor(RawDataProcessor): consumes = ContentType.MARKERS produces = EvidenceType.BTN + def data_value(self, raw_data: RawData, inq_start: float, inq_stop: float, + button_press_mode: ButtonPressMode) -> float: + """Returns the data value for all trials in the inquiry. + + The switch was pressed some time during the inquiry but not necessarily + coinciding with a specific symbol, so all symbols are assigned the same + value (1.0 or 0.0). + + The value depends on the button press mode and indicates whether the + symbols in the inquiry should be upgraded or downgraded. + """ + times = raw_data.dataframe['lsl_timestamp'] + switch_was_pressed = any(inq_start <= time <= inq_stop + for time in times) + + rules = { + ButtonPressMode.NOTHING: lambda: 1.0, + ButtonPressMode.ACCEPT: lambda: 1.0 if switch_was_pressed else 0.0, + ButtonPressMode.REJECT: lambda: 1.0 + if not switch_was_pressed else 0.0 + } + return rules[button_press_mode]() + def process(self, data_folder: str, parameters: Parameters) -> ExtractedExperimentData: """Load and process the data. @@ -50,6 +84,7 @@ def process(self, data_folder: str, parameters - parameters.json file for the calibration session used to train the model / run the simulation. """ + raw_data, _spec = self.load_device_data(data_folder, parameters) timing_params = parameters.instantiate(TimingParams) button_press_mode = parameters.instantiate( @@ -59,23 +94,26 @@ def process(self, data_folder: str, raise IncompatibleParameters("Button press mode must be set.") decoded_triggers = self.decode_triggers(data_folder, parameters) + time_ranges = inquiry_windows(f"{data_folder}/{TRIGGER_FILENAME}", timing_params.time_flash) inquiry_data: List[List[float]] = [] target_labels: List[List[int]] = [] # chunk triggers into inquiries and iterate. - for inquiry_triggers in grouper(decoded_triggers.triggers, - timing_params.trials_per_inquiry, - incomplete='ignore'): + for i, inquiry_triggers in enumerate( + grouper(decoded_triggers.triggers, + timing_params.trials_per_inquiry, + incomplete='ignore')): inquiry_trial_data = [] inquiry_target_labels = [] + + # Returns the same value for all trials in the inquiry. + start, stop = time_ranges[i] + data = self.data_value(raw_data, start, stop, button_press_mode) + # collect data for each trial in the inquiry for trg in inquiry_triggers: target_lbl = 1 if trg.type == TriggerType.TARGET else 0 - # Returns the same value for all trials in the inquiry. Implies that - # the button was pressed some time during the inquiry. - data = 1.0 if should_press_switch(inquiry_triggers, - button_press_mode) else 0.0 inquiry_trial_data.append(data) inquiry_target_labels.append(target_lbl) @@ -101,9 +139,12 @@ def process(self, data_folder: str, def load_device_data(self, data_folder: str, parameters: Parameters) -> Tuple[RawData, DeviceSpec]: """Load the device data""" - raw_data_path = simulate_raw_data(data_folder, parameters) - raw_data = load_raw_data(str(raw_data_path)) device_spec = switch_device() + raw_data_path = Path(data_folder, raw_data_filename(device_spec)) + if not raw_data_path.exists(): + raise IncompatibleData( + "Missing raw data for switch. Use switch_utils to generate.") + raw_data = load_raw_data(str(raw_data_path)) return raw_data, device_spec def reshape_data(self, raw_data: RawData, diff --git a/bcipy/simulator/demo/switch_utils.py b/bcipy/simulator/demo/switch_utils.py index 844b8259f..02fa0558d 100644 --- a/bcipy/simulator/demo/switch_utils.py +++ b/bcipy/simulator/demo/switch_utils.py @@ -36,10 +36,12 @@ def partition_triggers(trigger_path: Path) -> List[List[Trigger]]: triggers = load_triggers(str(trigger_path), remove_pre_fixation=False, apply_starting_offset=False) + includes_preview = any(trg.type == TriggerType.PREVIEW for trg in triggers) + start_type = TriggerType.PREVIEW if includes_preview else TriggerType.FIXATION # index for each prompt trigger inq_start_indices = [ - i for i, trg in enumerate(triggers) if trg.type == TriggerType.FIXATION + i for i, trg in enumerate(triggers) if trg.type == start_type ] inquiry_triggers = [] for i, j in pairwise(inq_start_indices): @@ -64,6 +66,17 @@ def time_range(inquiry_triggers: List[Trigger], return (inquiry_triggers[0].time, inquiry_triggers[-1].time + time_flash) +def inquiry_windows(trigger_path: Path, + time_flash: float) -> List[Tuple[float, float]]: + """Returns a list of (inquiry_start, inquiry_stop) timestamp pairs for + all inquiries in the trigger file.""" + + return [ + time_range(inq_triggers, time_flash) + for inq_triggers in partition_triggers(trigger_path) + ] + + def should_press_switch(inquiry_triggers: List[Trigger], button_press_mode: ButtonPressMode) -> bool: """Determine if a marker should be written for the given inquiry diff --git a/bcipy/simulator/tests/resources/markers_data_switch.csv b/bcipy/simulator/tests/resources/markers_data_switch.csv new file mode 100644 index 000000000..d5a390f00 --- /dev/null +++ b/bcipy/simulator/tests/resources/markers_data_switch.csv @@ -0,0 +1,17 @@ +daq_type,Switch +sample_rate,0 +timestamp,Marker,lsl_timestamp +1,1.0,6409.3349438000005 +2,1.0,6415.4949134 +3,1.0,6421.6229766 +4,1.0,6427.7268517 +5,1.0,6433.8069707 +6,1.0,6441.9749359 +7,1.0,6450.3189464 +8,1.0,6456.342932199999 +9,1.0,6462.4309103000005 +10,1.0,6468.4149259 +11,1.0,6474.414876 +12,1.0,6480.2709153999995 +13,1.0,6488.694970699999 +14,1.0,6500.3589364 \ No newline at end of file diff --git a/bcipy/simulator/tests/resources/triggers.txt b/bcipy/simulator/tests/resources/triggers.txt index a2294ed00..2e420fa21 100644 --- a/bcipy/simulator/tests/resources/triggers.txt +++ b/bcipy/simulator/tests/resources/triggers.txt @@ -1,23 +1,26 @@ -starting_offset offset -2487.8116187 -+ fixation 2496.0808361 -C nontarget 2496.6508996 -H nontarget 2496.8879651 -E nontarget 2497.123811 -G target 2497.358904 -< nontarget 2497.5953845 -B nontarget 2497.8305515 -D nontarget 2498.0661673 -I nontarget 2498.3010261 -F nontarget 2498.5363697 -A nontarget 2498.7704075 -+ fixation 2503.0619242 -P nontarget 2503.6330463 -S nontarget 2503.8674911 -O nontarget 2504.1034164 -R nontarget 2504.3382754 -J nontarget 2504.5739756 -L nontarget 2504.8098963 -M nontarget 2505.0455712 -K nontarget 2505.2799301 -Q nontarget 2505.5152481 -N nontarget 2505.7499999 \ No newline at end of file +starting_offset offset -6377.0265653 +inquiry_preview preview 6400.5528593 ++ fixation 6404.0877378 +T nontarget 6404.5932127 +D nontarget 6404.7993812 +B nontarget 6405.0073678 +L nontarget 6405.2136213 +W nontarget 6405.4196753 +A nontarget 6405.6272747 +R nontarget 6405.8345703 +N nontarget 6406.0418707 +_ nontarget 6406.2494323 +< nontarget 6406.4567316 +inquiry_preview preview 6408.2049968 +bcipy_key_press_space event 6409.3349438000005 ++ fixation 6410.361287 +< nontarget 6410.8665035 +A nontarget 6411.0721914 +R nontarget 6411.278046 +B nontarget 6411.4836817 +D nontarget 6411.6893584 +L nontarget 6411.8949886 +N nontarget 6412.1007098 +W nontarget 6412.3064079 +_ nontarget 6412.5120152 +S target 6412.7176601 \ No newline at end of file diff --git a/bcipy/simulator/tests/test_switch_data_processor.py b/bcipy/simulator/tests/test_switch_data_processor.py index 258173fdb..1edc38945 100644 --- a/bcipy/simulator/tests/test_switch_data_processor.py +++ b/bcipy/simulator/tests/test_switch_data_processor.py @@ -7,6 +7,8 @@ import numpy as np from bcipy.acquisition.datastream.mock.switch import switch_device +from bcipy.core.raw_data import RawData +from bcipy.display.main import ButtonPressMode from bcipy.io.load import load_json_parameters from bcipy.signal.model.base_model import SignalModelMetadata from bcipy.simulator.demo.switch_data_processor import SwitchDataProcessor @@ -24,7 +26,6 @@ def setUp(self): self.model.metadata = SignalModelMetadata(device_spec=switch_device(), evidence_type="BTN", transform=None) - self.params_path = Path(self.data_dir, "btn_subset_parameters.json") def test_extracted_data(self): @@ -34,19 +35,55 @@ def test_extracted_data(self): extracted_data = processor.process(self.data_dir, params) self.assertEqual( - [[0, 0, 0, 1, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]], + [[0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0, 1]], extracted_data.labels) self.assertEqual((1, 2, 10), extracted_data.inquiries.shape) self.assertEqual((1, 20, 1), extracted_data.trials.shape) - data = np.array([[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0], - [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]]) + data = np.array([[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]]) expected_inquiries = data.reshape((1, 2, 10)) expected_trials = data.reshape((1, 20, 1)) self.assertTrue( np.allclose(expected_inquiries, extracted_data.inquiries)) self.assertTrue(np.allclose(expected_trials, extracted_data.trials)) + def test_data_values(self): + """Test data values with different button press modes""" + processor = SwitchDataProcessor(model=self.model) + sample_data = RawData(daq_type='Switch', + sample_rate=0, + columns=['timestamp', 'Marker', 'lsl_timestamp']) + sample_data.append([1, 1.0, 6409.33]) + + self.assertEqual( + 1.0, + processor.data_value(sample_data, + inq_start=6408.20, + inq_stop=6412.91, + button_press_mode=ButtonPressMode.ACCEPT)) + + self.assertEqual( + 0.0, + processor.data_value(sample_data, + inq_start=6400.20, + inq_stop=6406.91, + button_press_mode=ButtonPressMode.ACCEPT)) + + self.assertEqual( + 0.0, + processor.data_value(sample_data, + inq_start=6408.20, + inq_stop=6412.91, + button_press_mode=ButtonPressMode.REJECT)) + + self.assertEqual( + 1.0, + processor.data_value(sample_data, + inq_start=6400.20, + inq_stop=6406.91, + button_press_mode=ButtonPressMode.REJECT)) + if __name__ == '__main__': unittest.main() From 0d3601fa6fcd3594bfc0ad59bbd3e61a7e38db0d Mon Sep 17 00:00:00 2001 From: lawhead Date: Tue, 1 Apr 2025 11:12:39 -0700 Subject: [PATCH 67/76] Refactored switch-related code into different modules --- bcipy/core/list.py | 12 ++++++++++++ bcipy/core/tests/test_list.py | 10 +++++++++- .../demo => signal/model}/switch_model.py | 0 .../tests/model}/test_switch_model.py | 2 +- bcipy/simulator/data/processor_registry.py | 2 +- .../{demo => data}/switch_data_processor.py | 5 +++-- .../tests/test_switch_data_processor.py | 4 ++-- bcipy/simulator/{demo => util}/switch_utils.py | 17 +++-------------- 8 files changed, 31 insertions(+), 21 deletions(-) rename bcipy/{simulator/demo => signal/model}/switch_model.py (100%) rename bcipy/{simulator/tests => signal/tests/model}/test_switch_model.py (97%) rename bcipy/simulator/{demo => data}/switch_data_processor.py (97%) rename bcipy/simulator/{demo => util}/switch_utils.py (94%) diff --git a/bcipy/core/list.py b/bcipy/core/list.py index a4d4e7d2c..64c643617 100644 --- a/bcipy/core/list.py +++ b/bcipy/core/list.py @@ -78,3 +78,15 @@ def expanded(lst: List[Any], item = fill(lst) if callable(fill) else fill return lst + ([item] * times) return lst + + +def pairwise(iterable): + """ + pairwise('ABCDEFG') → AB BC CD DE EF FG + https://docs.python.org/3/library/itertools.html#itertools.pairwise + """ + iterator = iter(iterable) + a = next(iterator, None) + for b in iterator: + yield a, b + a = b diff --git a/bcipy/core/tests/test_list.py b/bcipy/core/tests/test_list.py index 8e986f714..88dcbd3e7 100644 --- a/bcipy/core/tests/test_list.py +++ b/bcipy/core/tests/test_list.py @@ -1,7 +1,8 @@ """Tests for list processing utilities""" import unittest -from bcipy.core.list import destutter, expanded, find_index, grouper, swapped +from bcipy.core.list import (destutter, expanded, find_index, grouper, + pairwise, swapped) class TestListUtilities(unittest.TestCase): @@ -90,6 +91,13 @@ def test_expanded(self): """Test expanded function.""" self.assertEqual([1, 2, 3, 3, 3], expanded([1, 2, 3], length=5)) + def test_pairwise(self): + """Test pairwise iterator""" + iterable = 'ABCDEFG' + response = pairwise(iterable) + expected = [('A', 'B'), ('B', 'C'), ('C', 'D'), ('D', 'E'), ('E', 'F'), ('F', 'G')] + self.assertListEqual(expected, list(response)) + if __name__ == '__main__': unittest.main() diff --git a/bcipy/simulator/demo/switch_model.py b/bcipy/signal/model/switch_model.py similarity index 100% rename from bcipy/simulator/demo/switch_model.py rename to bcipy/signal/model/switch_model.py diff --git a/bcipy/simulator/tests/test_switch_model.py b/bcipy/signal/tests/model/test_switch_model.py similarity index 97% rename from bcipy/simulator/tests/test_switch_model.py rename to bcipy/signal/tests/model/test_switch_model.py index ed8359dcb..7ae9943b9 100644 --- a/bcipy/simulator/tests/test_switch_model.py +++ b/bcipy/signal/tests/model/test_switch_model.py @@ -4,7 +4,7 @@ import numpy as np -from bcipy.simulator.demo.switch_model import SwitchModel +from bcipy.signal.model.switch_model import SwitchModel class SwitchModelTest(unittest.TestCase): diff --git a/bcipy/simulator/data/processor_registry.py b/bcipy/simulator/data/processor_registry.py index 7b6f33bec..838492a07 100644 --- a/bcipy/simulator/data/processor_registry.py +++ b/bcipy/simulator/data/processor_registry.py @@ -7,7 +7,7 @@ # flake8: noqa from bcipy.simulator.data.data_process import (EegRawDataProcessor, RawDataProcessor) -from bcipy.simulator.demo.switch_data_processor import SwitchDataProcessor +from bcipy.simulator.data.switch_data_processor import SwitchDataProcessor from bcipy.task.data import EvidenceType log = logger.getLogger(__name__) diff --git a/bcipy/simulator/demo/switch_data_processor.py b/bcipy/simulator/data/switch_data_processor.py similarity index 97% rename from bcipy/simulator/demo/switch_data_processor.py rename to bcipy/simulator/data/switch_data_processor.py index 8ee3235d8..7616b8360 100644 --- a/bcipy/simulator/demo/switch_data_processor.py +++ b/bcipy/simulator/data/switch_data_processor.py @@ -20,8 +20,8 @@ ExtractedExperimentData, RawDataProcessor, ReshapedData, TimingParams) -from bcipy.simulator.demo.switch_utils import inquiry_windows, switch_device from bcipy.simulator.exceptions import IncompatibleData, IncompatibleParameters +from bcipy.simulator.util.switch_utils import inquiry_windows, switch_device from bcipy.task.data import EvidenceType @@ -94,7 +94,8 @@ def process(self, data_folder: str, raise IncompatibleParameters("Button press mode must be set.") decoded_triggers = self.decode_triggers(data_folder, parameters) - time_ranges = inquiry_windows(f"{data_folder}/{TRIGGER_FILENAME}", timing_params.time_flash) + time_ranges = inquiry_windows(Path(data_folder, TRIGGER_FILENAME), + timing_params.time_flash) inquiry_data: List[List[float]] = [] target_labels: List[List[int]] = [] diff --git a/bcipy/simulator/tests/test_switch_data_processor.py b/bcipy/simulator/tests/test_switch_data_processor.py index 1edc38945..b9ff52922 100644 --- a/bcipy/simulator/tests/test_switch_data_processor.py +++ b/bcipy/simulator/tests/test_switch_data_processor.py @@ -11,8 +11,8 @@ from bcipy.display.main import ButtonPressMode from bcipy.io.load import load_json_parameters from bcipy.signal.model.base_model import SignalModelMetadata -from bcipy.simulator.demo.switch_data_processor import SwitchDataProcessor -from bcipy.simulator.demo.switch_model import SwitchModel +from bcipy.signal.model.switch_model import SwitchModel +from bcipy.simulator.data.switch_data_processor import SwitchDataProcessor class SwitchProcessorTest(unittest.TestCase): diff --git a/bcipy/simulator/demo/switch_utils.py b/bcipy/simulator/util/switch_utils.py similarity index 94% rename from bcipy/simulator/demo/switch_utils.py rename to bcipy/simulator/util/switch_utils.py index 02fa0558d..2adfd5c8e 100644 --- a/bcipy/simulator/demo/switch_utils.py +++ b/bcipy/simulator/util/switch_utils.py @@ -7,6 +7,7 @@ from bcipy import config from bcipy.acquisition.datastream.mock.switch import switch_device +from bcipy.core.list import pairwise from bcipy.core.parameters import Parameters from bcipy.core.raw_data import RawDataWriter from bcipy.core.triggers import Trigger, TriggerType, load_triggers @@ -14,18 +15,6 @@ from bcipy.helpers.acquisition import raw_data_filename -def pairwise(iterable): - """ - pairwise('ABCDEFG') → AB BC CD DE EF FG - https://docs.python.org/3/library/itertools.html#itertools.pairwise - """ - iterator = iter(iterable) - a = next(iterator, None) - for b in iterator: - yield a, b - a = b - - def partition_triggers(trigger_path: Path) -> List[List[Trigger]]: """Partition the triggers into inquiries. @@ -113,7 +102,7 @@ def simulate_raw_data(data_dir: Path, parameters: Parameters): columns = ['timestamp', 'Marker', 'lsl_timestamp'] rownum = 0 - with RawDataWriter(raw_data_path, + with RawDataWriter(str(raw_data_path), daq_type=spec.name, sample_rate=spec.sample_rate, columns=columns) as writer: @@ -153,7 +142,7 @@ def generate_raw_data(data_dir: Path, ] columns = ['timestamp', 'Marker', 'lsl_timestamp'] - with RawDataWriter(raw_data_path, + with RawDataWriter(str(raw_data_path), daq_type=spec.name, sample_rate=spec.sample_rate, columns=columns) as writer: From a42e738597a5e150ef31eeb9be6d9045aa2dc570 Mon Sep 17 00:00:00 2001 From: lawhead Date: Tue, 1 Apr 2025 16:08:37 -0700 Subject: [PATCH 68/76] Added parameter to evidence evaluators for passing parameters. Added an evaluator for Switch data --- bcipy/task/control/evidence.py | 95 +++++++++++++++++-- .../task/tests/core/test_switch_evaluator.py | 61 ++++++++++++ 2 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 bcipy/task/tests/core/test_switch_evaluator.py diff --git a/bcipy/task/control/evidence.py b/bcipy/task/control/evidence.py index 97b2986dc..6c804093c 100644 --- a/bcipy/task/control/evidence.py +++ b/bcipy/task/control/evidence.py @@ -7,8 +7,10 @@ from bcipy.acquisition.multimodal import ContentType from bcipy.config import SESSION_LOG_FILENAME -from bcipy.helpers.acquisition import analysis_channels +from bcipy.core.parameters import Parameters from bcipy.core.stimuli import TrialReshaper +from bcipy.display.main import ButtonPressMode +from bcipy.helpers.acquisition import analysis_channels from bcipy.signal.model import SignalModel from bcipy.task.data import EvidenceType from bcipy.task.exceptions import MissingEvidenceEvaluator @@ -27,7 +29,10 @@ class EvidenceEvaluator: signal_model: model trained using a calibration session of the same user. """ - def __init__(self, symbol_set: List[str], signal_model: SignalModel): + def __init__(self, + symbol_set: List[str], + signal_model: SignalModel, + parameters: Optional[Parameters] = None): assert signal_model.metadata, "Metadata missing from signal model." device_spec = signal_model.metadata.device_spec assert ContentType( @@ -60,8 +65,11 @@ class EEGEvaluator(EvidenceEvaluator): consumes = ContentType.EEG produces = EvidenceType.ERP - def __init__(self, symbol_set: List[str], signal_model: SignalModel): - super().__init__(symbol_set, signal_model) + def __init__(self, + symbol_set: List[str], + signal_model: SignalModel, + parameters: Optional[Parameters] = None): + super().__init__(symbol_set, signal_model, parameters) self.channel_map = analysis_channels(self.device_spec.channels, self.device_spec) @@ -111,7 +119,8 @@ def evaluate(self, raw_data: np.ndarray, symbols: List[str], window_length - The length of the time between stimuli presentation """ data = self.preprocess(raw_data, times, target_info, window_length) - return self.signal_model.compute_likelihood_ratio(data, symbols, self.symbol_set) + return self.signal_model.compute_likelihood_ratio( + data, symbols, self.symbol_set) class GazeEvaluator(EvidenceEvaluator): @@ -125,8 +134,11 @@ class GazeEvaluator(EvidenceEvaluator): consumes = ContentType.EYETRACKER produces = EvidenceType.EYE - def __init__(self, symbol_set: List[str], signal_model: SignalModel): - super().__init__(symbol_set, signal_model) + def __init__(self, + symbol_set: List[str], + signal_model: SignalModel, + parameters: Optional[Parameters] = None): + super().__init__(symbol_set, signal_model, parameters) self.channel_map = analysis_channels(self.device_spec.channels, self.device_spec) @@ -178,7 +190,74 @@ def evaluate(self, raw_data: np.ndarray, symbols: List[str], data = self.preprocess(raw_data, times, target_info, window_length) # We need the likelihoods in the form of p(label | gaze). predict returns the argmax of the likelihoods. # Therefore we need predict_proba method to get the likelihoods. - return self.signal_model.evaluate_likelihood(data) # multiplication over the inquiry + return self.signal_model.evaluate_likelihood( + data) # multiplication over the inquiry + + +class SwitchEvaluator(EvidenceEvaluator): + """EvidenceEvaluator that extracts symbol likelihoods from raw Switch data. + + Parameters + ---------- + symbol_set: set of possible symbols presented + signal_model: trained signal model + """ + consumes = ContentType.MARKERS + produces = EvidenceType.BTN + + def __init__(self, + symbol_set: List[str], + signal_model: SignalModel, + parameters: Optional[Parameters] = None): + super().__init__(symbol_set, signal_model, parameters) + self.button_press_mode = ButtonPressMode( + parameters.get('preview_inquiry_progress_method')) + self.trial_count = parameters.get('stim_length') + + def preprocess(self, raw_data: np.ndarray, times: List[float], + target_info: List[str], window_length: float) -> np.ndarray: + """Preprocess the inquiry data. + + Determines the return data based on whether the switch was pressed + during the inquiry and the configured ButtonPressMode. + """ + switch_was_pressed = np.any(raw_data) + + # shape: (channels/1, trials/trial_count, samples/1) + data_shape = (1, self.trial_count, 1) + ones = np.ones(data_shape) + zeros = np.zeros(data_shape) + + # Inquiries with 1.0s will be upgraded/supported by the model probabilities. + rules = { + ButtonPressMode.NOTHING: + lambda: ones, + ButtonPressMode.ACCEPT: + lambda: ones if switch_was_pressed else zeros, + ButtonPressMode.REJECT: + lambda: ones if not switch_was_pressed else zeros + } + return rules[self.button_press_mode]() + + # pylint: disable=arguments-differ + def evaluate(self, raw_data: np.ndarray, symbols: List[str], + times: List[float], target_info: List[str], + window_length: float) -> np.ndarray: + """Evaluate the evidence. + + Parameters + ---------- + raw_data - C x L eeg data where C is number of channels and L is the + signal length + symbols - symbols displayed in the inquiry + times - timestamps associated with each symbol + target_info - target information about the stimuli; + ex. ['nontarget', 'nontarget', ...] + window_length - The length of the time between stimuli presentation + """ + data = self.preprocess(raw_data, times, target_info, window_length) + return self.signal_model.compute_likelihood_ratio( + data, symbols, self.symbol_set) def get_evaluator( diff --git a/bcipy/task/tests/core/test_switch_evaluator.py b/bcipy/task/tests/core/test_switch_evaluator.py new file mode 100644 index 000000000..65166014c --- /dev/null +++ b/bcipy/task/tests/core/test_switch_evaluator.py @@ -0,0 +1,61 @@ +"""Tests for EEG evidence evaluator""" + +import unittest +from unittest.mock import Mock + +import numpy as np + +from bcipy.core.parameters import Parameters +from bcipy.task.control.evidence import SwitchEvaluator + + +class TestSwitchEvaluator(unittest.TestCase): + """Tests for Switch evidence evaluator.""" + + def setUp(self): + """Setup common state""" + self.symbol_set = [] + + self.device_mock = Mock() + self.device_mock.content_type = 'MARKERS' + self.device_mock.channels = ['Marker'] + self.device_mock.sample_rate = 1 + + self.transform_mock = Mock() + self.signal_model_mock = Mock() + meta_mock = Mock() + meta_mock.device_spec = self.device_mock + meta_mock.transform = self.transform_mock + self.signal_model_mock.metadata = meta_mock + self.signal_model_mock.compute_likelihood_ratio = Mock() + + def test_evaluate(self): + """Test evaluation of evidence""" + parameters = Parameters.from_cast_values( + preview_inquiry_progress_method=1, stim_length=10) + raw_data = np.array([1.0]) + + # Parameters + symbols = ["+", "H", "D", "J", "B", "C", "A", "F", "G", "I", "E"] + times = [ + 0.0, 0.56, 0.81, 1.08, 1.33, 1.58, 1.83, 2.08, 2.33, 2.58, 2.83 + ] + target_info = ['nontarget'] * len(symbols) + window_length = 0.5 + + # Run the code + evaluator = SwitchEvaluator(self.symbol_set, self.signal_model_mock, + parameters) + evaluator.evaluate(raw_data, symbols, times, target_info, + window_length) + + expected_reshaped_data = np.ones((1, 10, 1)) + reshaped = evaluator.preprocess(raw_data, times, target_info, + window_length) + # Assertions + self.assertTrue(np.allclose(expected_reshaped_data, reshaped)) + self.signal_model_mock.compute_likelihood_ratio.assert_called_once() + + +if __name__ == '__main__': + unittest.main() From 56a966511d3c19f00befbd5356fc6c71057fdb74 Mon Sep 17 00:00:00 2001 From: lawhead Date: Thu, 3 Apr 2025 14:52:33 -0700 Subject: [PATCH 69/76] Moved README for multimodal simulation into the main simulator README file. Added script for generating marker data and documented its usage. --- bcipy/simulator/README.md | 91 +++++++++++++++++- bcipy/simulator/demo/README.md | 73 -------------- .../resources/switch_model.pkl | Bin bcipy/simulator/util/generate_marker_data.py | 42 ++++++++ bcipy/simulator/util/switch_utils.py | 22 +---- 5 files changed, 134 insertions(+), 94 deletions(-) delete mode 100644 bcipy/simulator/demo/README.md rename bcipy/simulator/{demo => tests}/resources/switch_model.pkl (100%) create mode 100644 bcipy/simulator/util/generate_marker_data.py diff --git a/bcipy/simulator/README.md b/bcipy/simulator/README.md index c82b3d504..04beb6395 100644 --- a/bcipy/simulator/README.md +++ b/bcipy/simulator/README.md @@ -133,4 +133,93 @@ The simulator demo module contains code with explorations for future directions ## Current Limitations * Only one sampler maybe provided for all devices. Ideally we should support a different sampling strategy for each device. -* Only Copy Phrase is currently supported. \ No newline at end of file +* Only Copy Phrase is currently supported. + +## Multimodal + +The `switch_data_processor` and `switch_model` are used to demonstrate a multimodal simulations using a button/switch as an example. To run a simulation with these inputs, you will need to perform the following steps: + +1. Ensure that the devices.json file has an entry for a switch + + ``` + { + "name": "Switch", + "content_type": "MARKERS", + "channels": [ + { "name": "Marker", "label": "Marker" } + ], + "sample_rate": 0.0, + "description": "Switch used for button press inputs", + "excluded_from_analysis": [], + "status": "active", + "static_offset": 0.0 + } + ``` + +2. Ensure that the switch signal model can be loaded or create a switch signal model. To create a new one: + + ``` + from pathlib import Path + from bcipy.acquisition.devices import preconfigured_device + from bcipy.io.save import save_model + from bcipy.signal.model.base_model import SignalModelMetadata + from bcipy.signal.model.switch_model import SwitchModel + + dirname = "" # TODO: enter the directory + model = SwitchModel() + # name should match devices.json spec. Alternatively, use bcipy.acquisition.datastream.mock.switch.switch_device() + device = preconfigured_device("Switch") + model.metadata = SignalModelMetadata(device_spec=device, evidence_type="BTN", transform=None) + save_model(model, Path(dirname, "switch_model.pkl")) + ``` + +3. Set the appropriate simulation parameters in the parameters.json file. + + - set the `acq_mode` parameter to 'EEG+MARKERS'. + - ensure that `preview_inquiry_progress_method` parameter is set to '1' or '2'. + - You may also want to set the `summarize_session` parameter to `true` to see how the evidences get combined during decision-making. + +4. Ensure that the data directories have a raw data file (csv) for markers in addition to the EEG data. If your data does not have marker data, you can extract this from the triggers.txt file using the script `bcipy.simulator.util.generate_marker_data`. If the task was run with Inquiry Preview, the script can use the button press events recoreded in the trigger file. Otherwise you can use the `--mock` flag along with a parameters file to mock what a raw data file would look like if the user pressed the button according to the configured button press mode. + + ``` + $ python -m bcipy.simulator.util.generate_marker_data -h + usage: generate_marker_data.py [-h] [-m] [-p PARAMETERS] data_folder + + Create raw marker data for a given session. + + positional arguments: + data_folder Data directory (must contain triggers.txt file) + + optional arguments: + -h, --help show this help message and exit + -m, --mock Mock data; use when button presses are not recorded in trigger file. + -p PARAMETERS, --parameters PARAMETERS + Optional parameters file to use when mocking data. + ``` + +5. Run a simulation. + + - Set the simulation parameters for both the EEG and the Button models (.pkl files). + - Use the InquirySampler + +Run with verbose mode and inspect the detailed run logs to ensure that the evidence is being sampled correctly. + +### Expected Behavior + +Along with the 'eeg' evidence, the output session.json (and session.xlsx) should record 'btn' evidence for each inquiry. These evidences should be fused +to provide the 'likelihood' values. + +For inquiries in which the target is shown: + +- evidence values for symbols in the inquiry should be boosted relative to non-inquiry symbols (default values are 0.95 for boosted and 0.05 for degraded). + +For inquiries in which the target not shown: + +- evidence values for symbols in inquiry should be degraded + +Note that the progress method (`preview_inquiry_progress_method` parameter) doesn't matter if it is set to "press to accept" or "press to skip", since the SwitchDataProcessor interprets this and outputs a 1.0 for inquiries that should be supported and 0.0 for those that shouldn't. + +### Limitations + +- A `preview_inquiry_progress_method` of 0 is currently not supported and an exception will be thrown. Ideally, all inquiries should get an evidence value of 1.0 (no change) with this mode. +- Button evidence only works correctly with the InquirySampler. This is due to all trials in the same inquiry receiving the same value. diff --git a/bcipy/simulator/demo/README.md b/bcipy/simulator/demo/README.md deleted file mode 100644 index e9a2837a6..000000000 --- a/bcipy/simulator/demo/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# Simulator Demo - -This module demonstrates how the simulator can be extended for various use cases. - - -## Multimodal - -The `switch_data_processor` and `switch_model` are used to demonstrate a multimodal simulations using a button/switch as an example. To run a simulation with these inputs, you will need to perform the following steps: - -1. Ensure that the switch signal model can be loaded or create a switch signal model. To create a new one: - - ``` - from pathlib import Path - from bcipy.acquisition.datastream.mock.switch import switch_device - from bcipy.io.save import save_model - from bcipy.simulator.demo.switch_model import SwitchModel - from bcipy.signal.model.base_model import SignalModel, SignalModelMetadata - - dirname = "" # TODO: enter the directory - model = SwitchModel() - model.metadata = SignalModelMetadata(device_spec=switch_device(), evidence_type="BTN", transform=None) - save_model(model, Path(dirname, "switch_model.pkl")) - ``` - -2. Ensure that the devices.json file has an entry for a switch - - ``` - { - "name": "Switch", - "content_type": "MARKERS", - "channels": [ - { "name": "Marker", "label": "Marker" } - ], - "sample_rate": 0.0, - "description": "Switch used for button press inputs", - "excluded_from_analysis": [], - "status": "active", - "static_offset": 0.0 - } - ``` - -3. Set the appropriate simulation parameters in the parameters.json file. - - - set the `acq_mode` parameter to 'EEG+MARKERS'. - - ensure that `preview_inquiry_progress_method` parameter is set to "1" or "2". - - You may also want to set the `summarize_session` parameter to `true` to see how the evidences get combined during decision-making. - -4. Run a simulation. - - - Select both the EEG and the Button models - - Use the InquirySampler - -Run with verbose mode and inspect the detailed run logs to ensure that the evidence is being sampled correctly. - -### Expected Behavior - -Along with the 'eeg' evidence, the output session.json (and session.xlsx) should record 'btn' evidence for each inquiry. These evidences should be fused -to provide the 'likelihood' values. - -For inquiries in which the target is shown: - -- evidence values for symbols in the inquiry should be boosted relative to non-inquiry symbols (default values are 0.95 for boosted and 0.05 for degraded). - -For inquiries in which the target not shown: - -- evidence values for symbols in inquiry should be degraded - -Note that the progress method (`preview_inquiry_progress_method` parameter) doesn't matter if it is set to "press to accept" or "press to skip", since the SwitchDataProcessor interprets this and outputs a 1.0 for inquiries that should be supported and 0.0 for those that shouldn't. - -### Limitations - -- A `preview_inquiry_progress_method` of 0 is currently not supported and an exception will be thrown. Ideally, all inquiries should get an evidence value of 1.0 (no change) with this mode. -- Button evidence only works correctly with the InquirySampler. This is due to all trials in the same inquiry receiving the same value. diff --git a/bcipy/simulator/demo/resources/switch_model.pkl b/bcipy/simulator/tests/resources/switch_model.pkl similarity index 100% rename from bcipy/simulator/demo/resources/switch_model.pkl rename to bcipy/simulator/tests/resources/switch_model.pkl diff --git a/bcipy/simulator/util/generate_marker_data.py b/bcipy/simulator/util/generate_marker_data.py new file mode 100644 index 000000000..71e31a401 --- /dev/null +++ b/bcipy/simulator/util/generate_marker_data.py @@ -0,0 +1,42 @@ +import argparse +from pathlib import Path + +from bcipy.config import DEFAULT_PARAMETERS_FILENAME +from bcipy.io.load import load_json_parameters +from bcipy.simulator.util.switch_utils import (generate_raw_data, + simulate_raw_data) + + +def main() -> str: + """"Main method used to generate a raw data file for a switch device.""" + parser = argparse.ArgumentParser( + description="Create raw marker data for a given session.") + parser.add_argument("data_folder", + type=Path, + help="Data directory (must contain triggers.txt file)") + parser.add_argument( + "-m", + "--mock", + action='store_true', + help= + "Mock data; use when button presses are not recorded in trigger file." + ) + parser.add_argument( + "-p", + "--parameters", + type=Path, + required=False, + help="Optional parameters file to use when mocking data.") + args = parser.parse_args() + data_dir = args.data_folder + + if args.mock: + params_file = args.parameters or Path(data_dir, DEFAULT_PARAMETERS_FILENAME) + params = load_json_parameters(params_file, value_cast=True) + return simulate_raw_data(data_dir, params) + else: + return generate_raw_data(data_dir) + + +if __name__ == '__main__': + print(main()) diff --git a/bcipy/simulator/util/switch_utils.py b/bcipy/simulator/util/switch_utils.py index 2adfd5c8e..b60e75725 100644 --- a/bcipy/simulator/util/switch_utils.py +++ b/bcipy/simulator/util/switch_utils.py @@ -1,6 +1,5 @@ """Utilities for working with switch data.""" -import argparse import random from pathlib import Path from typing import List, Tuple @@ -83,7 +82,7 @@ def timestamp_within_inquiry(inquiry_triggers: List[Trigger], return random.uniform(inq_start, inq_end) -def simulate_raw_data(data_dir: Path, parameters: Parameters): +def simulate_raw_data(data_dir: Path, parameters: Parameters) -> Path: """Simulate what the raw_data file for a switch would generate. Reads through trigger data. For inquiries with target, outputs one or @@ -117,7 +116,7 @@ def simulate_raw_data(data_dir: Path, parameters: Parameters): def generate_raw_data(data_dir: Path, - event_label_prefix: str = 'bcipy_key_press'): + event_label_prefix: str = 'bcipy_key_press') -> Path: """Given a data directory for a task where the Inquiry Preview feature was used, output a raw_data file for the switch data. @@ -150,20 +149,3 @@ def generate_raw_data(data_dir: Path, writer.writerow([i + 1, 1.0, trg.time]) return raw_data_path - - -def main(): - """"Main method used to generate a raw data file for a switch device.""" - parser = argparse.ArgumentParser() - parser.add_argument("-d", - "--data_folder", - type=Path, - required=True, - action='append', - help="Data directory (must contain triggers.txt file)") - args = parser.parse_args() - print(generate_raw_data(args.data_dir)) - - -if __name__ == '__main__': - main() From 013f39f6e3f6df9c64204a7b414a084be258942a Mon Sep 17 00:00:00 2001 From: lawhead Date: Fri, 4 Apr 2025 15:42:05 -0700 Subject: [PATCH 70/76] Fixes to allow switch devices to work correctly in multimodal acquisition --- bcipy/acquisition/datastream/mock/switch.py | 8 ++- .../acquisition/demo/demo_eeg_with_switch.py | 4 +- bcipy/acquisition/demo/demo_switch.py | 61 ++++++++++++++++++ bcipy/acquisition/multimodal.py | 4 +- bcipy/acquisition/protocols/lsl/lsl_client.py | 34 ++++++---- bcipy/display/paradigm/rsvp/display.py | 6 +- bcipy/helpers/acquisition.py | 12 ++-- .../tests/resources/switch_model.pkl | Bin 464 -> 0 bytes bcipy/simulator/util/generate_marker_data.py | 18 ++---- bcipy/task/control/evidence.py | 19 ++++-- bcipy/task/paradigm/rsvp/copy_phrase.py | 23 +++---- bcipy/task/tests/core/test_evidence.py | 6 +- 12 files changed, 140 insertions(+), 55 deletions(-) create mode 100644 bcipy/acquisition/demo/demo_switch.py delete mode 100644 bcipy/simulator/tests/resources/switch_model.pkl diff --git a/bcipy/acquisition/datastream/mock/switch.py b/bcipy/acquisition/datastream/mock/switch.py index 824195171..d01d9d11f 100644 --- a/bcipy/acquisition/datastream/mock/switch.py +++ b/bcipy/acquisition/datastream/mock/switch.py @@ -4,15 +4,19 @@ from pylsl import StreamInfo, StreamOutlet -from bcipy.acquisition.devices import DeviceSpec, IRREGULAR_RATE -from bcipy.gui.main import BCIGui, app +from bcipy.acquisition.devices import (IRREGULAR_RATE, DeviceSpec, + preconfigured_device) from bcipy.config import SESSION_LOG_FILENAME +from bcipy.gui.main import BCIGui, app log = logging.getLogger(SESSION_LOG_FILENAME) def switch_device() -> DeviceSpec: """Mock DeviceSpec for a switch""" + device = preconfigured_device('Switch', strict=False) + if device: + return device return DeviceSpec(name='Switch', channels=['Marker'], sample_rate=IRREGULAR_RATE, diff --git a/bcipy/acquisition/demo/demo_eeg_with_switch.py b/bcipy/acquisition/demo/demo_eeg_with_switch.py index 08e2b11ba..bb371666b 100644 --- a/bcipy/acquisition/demo/demo_eeg_with_switch.py +++ b/bcipy/acquisition/demo/demo_eeg_with_switch.py @@ -2,10 +2,10 @@ import subprocess import time -from bcipy.config import BCIPY_ROOT +from bcipy.acquisition import LslAcquisitionClient, LslDataServer, await_start from bcipy.acquisition.datastream.mock.switch import switch_device from bcipy.acquisition.devices import preconfigured_device -from bcipy.acquisition import LslAcquisitionClient, LslDataServer, await_start +from bcipy.config import BCIPY_ROOT from bcipy.helpers.utils import log_to_stdout diff --git a/bcipy/acquisition/demo/demo_switch.py b/bcipy/acquisition/demo/demo_switch.py new file mode 100644 index 000000000..ded73a7bc --- /dev/null +++ b/bcipy/acquisition/demo/demo_switch.py @@ -0,0 +1,61 @@ +"""Sample script to demonstrate usage of LSL client and server.""" +import subprocess +import time + +from bcipy.acquisition import LslAcquisitionClient +from bcipy.acquisition.datastream.mock.switch import switch_device +from bcipy.config import BCIPY_ROOT +from bcipy.helpers.utils import log_to_stdout + + +def start_switch(): + """Start the demo switch""" + return subprocess.Popen( + f'python {BCIPY_ROOT}/acquisition/datastream/mock/switch.py', + shell=True) + + +def main(debug: bool = False): + # pylint: disable=too-many-locals + """Creates a sample lsl client that reads data from a sample LSL server + (see demo/server.py). + + The client/server can be stopped with a Keyboard Interrupt (Ctl-C).""" + + if debug: + log_to_stdout() + + switch_client = LslAcquisitionClient(max_buffer_len=1024, + device_spec=switch_device(), + save_directory='.') + + # Open the Demo Switch GUI. + start_switch() + # Wait for switch start + print("Waiting for Switch") + time.sleep(0.5) + try: + seconds = 5 + switch_client.start_acquisition() + + print(f"\nCollecting data for {seconds}s...", + "Click in the demo switch GUI to register a switch hit.", + "Close the GUI when finished.\n") + + time.sleep(seconds) + switch_client.stop_acquisition() + print("\nThe collected data has been written to the local directory") + + except KeyboardInterrupt: + print("Keyboard Interrupt; stopping.") + switch_client.stop_acquisition() + print("\nThe collected data has been written to the local directory") + + +if __name__ == '__main__': + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument('--debug', action='store_true') + args = parser.parse_args() + main(args.debug) diff --git a/bcipy/acquisition/multimodal.py b/bcipy/acquisition/multimodal.py index 7d0df0fe5..dba7a7423 100644 --- a/bcipy/acquisition/multimodal.py +++ b/bcipy/acquisition/multimodal.py @@ -8,8 +8,8 @@ UnsupportedContentType) from bcipy.acquisition.protocols.lsl.lsl_client import LslAcquisitionClient from bcipy.acquisition.record import Record -from bcipy.helpers.utils import AutoNumberEnum from bcipy.config import SESSION_LOG_FILENAME +from bcipy.helpers.utils import AutoNumberEnum logger = logging.getLogger(SESSION_LOG_FILENAME) @@ -162,8 +162,10 @@ def get_data_by_device( raise InsufficientDataException(msg) else: # Markers have an IRREGULAR_RATE. + logger.info(f'Querying {name} data') output[content_type] = client.get_data(start=adjusted_start, end=adjusted_start + seconds) + logger.info(f"Received {len(output[content_type])} records.") return output def cleanup(self): diff --git a/bcipy/acquisition/protocols/lsl/lsl_client.py b/bcipy/acquisition/protocols/lsl/lsl_client.py index 754a4d119..4732b7a12 100644 --- a/bcipy/acquisition/protocols/lsl/lsl_client.py +++ b/bcipy/acquisition/protocols/lsl/lsl_client.py @@ -80,6 +80,13 @@ def __init__(self, self.raw_data_file_name = raw_data_file_name self._max_samples = None + @property + def has_irregular_rate(self) -> bool: + """Returns true for sampling devices with an irregular rate, + such as markers. + """ + return self.device_spec.sample_rate == IRREGULAR_RATE + @property def first_sample_time(self) -> float: """Timestamp returned by the first sample. If the data is being @@ -90,7 +97,7 @@ def first_sample_time(self) -> float: def max_samples(self) -> int: """Maximum number of samples available at any given time.""" if self._max_samples is None: - if self.device_spec.sample_rate == IRREGULAR_RATE: + if self.has_irregular_rate: self._max_samples = int(self.max_buffer_len) else: self._max_samples = int(self.max_buffer_len * @@ -128,16 +135,19 @@ def start_acquisition(self) -> bool: device_spec=self.device_spec, queue=msg_queue) self.recorder.start() - logger.info("Waiting for first sample from lsl_recorder") - self._first_sample_time = msg_queue.get(block=True, - timeout=LSL_TIMEOUT) - logger.info(f"First sample time: {self.first_sample_time}") + if not self.has_irregular_rate: + logger.info("Waiting for first sample from lsl_recorder") + self._first_sample_time = msg_queue.get(block=True, + timeout=LSL_TIMEOUT) + logger.info(f"First sample time: {self.first_sample_time}") self.inlet.open_stream(timeout=LSL_TIMEOUT) if self.max_buffer_len and self.max_buffer_len > 0: self.buffer = RingBuffer(size_max=self.max_samples) if not self._first_sample_time: - _, self._first_sample_time = self.inlet.pull_sample() + timeout = 0.0 if self.has_irregular_rate else LSL_TIMEOUT + _, self._first_sample_time = self.inlet.pull_sample( + timeout=timeout) return True def stop_acquisition(self) -> None: @@ -171,13 +181,14 @@ def _data_stats(self, data: List[Record]) -> Dict[str, float]: data_start = data[0].timestamp data_end = data[-1].timestamp precision = 3 + expected_diff = 0.0 if self.has_irregular_rate else round( + 1 / self.device_spec.sample_rate, precision) return { 'count': len(data), 'seconds': round(data_end - data_start, precision), 'from': round(data_start, precision), 'to': round(data_end, precision), - 'expected_diff': round(1 / self.device_spec.sample_rate, - precision), + 'expected_diff': expected_diff, 'mean_diff': round(diffs.mean(), precision), 'max_diff': round(diffs.max(), precision) } @@ -218,8 +229,9 @@ def get_data(self, if end is None: end = data_end - assert start >= data_start, 'Start time out of range' - assert end <= data_end, 'End time out of range' + if not self.has_irregular_rate: + assert start >= data_start, 'Start time out of range' + assert end <= data_end, 'End time out of range' data_slice = [ record for record in data if start <= record.timestamp <= end @@ -357,7 +369,7 @@ def offset(self, first_stim_time: float) -> float: event, or 0.0 . """ - if not first_stim_time: + if not first_stim_time or self.has_irregular_rate: return 0.0 assert self.first_sample_time, "Acquisition was not started." offset_from_stim = first_stim_time - self.first_sample_time diff --git a/bcipy/display/paradigm/rsvp/display.py b/bcipy/display/paradigm/rsvp/display.py index fdf5d40f7..e96a28d33 100644 --- a/bcipy/display/paradigm/rsvp/display.py +++ b/bcipy/display/paradigm/rsvp/display.py @@ -4,15 +4,15 @@ from psychopy import core, visual +from bcipy.core.stimuli import resize_image +from bcipy.core.symbols import SPACE_CHAR +from bcipy.core.triggers import TriggerCallback, _calibration_trigger from bcipy.display import (BCIPY_LOGO_PATH, Display, InformationProperties, StimuliProperties) from bcipy.display.components.task_bar import TaskBar from bcipy.display.main import PreviewParams, init_preview_button_handler from bcipy.helpers.clock import Clock -from bcipy.core.stimuli import resize_image -from bcipy.core.symbols import SPACE_CHAR from bcipy.helpers.utils import get_screen_info -from bcipy.core.triggers import TriggerCallback, _calibration_trigger class RSVPDisplay(Display): diff --git a/bcipy/helpers/acquisition.py b/bcipy/helpers/acquisition.py index 95ece417c..774bee5d7 100644 --- a/bcipy/helpers/acquisition.py +++ b/bcipy/helpers/acquisition.py @@ -190,11 +190,13 @@ def init_lsl_client(parameters: dict, raw_data_file_name: Optional[str] = None): """Initialize a client that acquires data from LabStreamingLayer.""" - data_buffer_seconds = round(max_inquiry_duration(parameters)) - logger.info( - f"Setting an acquisition buffer for {device_spec.name} of {data_buffer_seconds} seconds" - ) - return LslAcquisitionClient(max_buffer_len=data_buffer_seconds, + max_len = 100 + if device_spec.sample_rate > 0: + max_len = round(max_inquiry_duration(parameters)) + logger.info( + f"Setting an acquisition buffer for {device_spec.name} of {max_len} seconds" + ) + return LslAcquisitionClient(max_buffer_len=max_len, device_spec=device_spec, save_directory=save_folder, raw_data_file_name=raw_data_file_name) diff --git a/bcipy/simulator/tests/resources/switch_model.pkl b/bcipy/simulator/tests/resources/switch_model.pkl deleted file mode 100644 index 35b2e0fb13b6fca93a79d8978c1a7bdd75cc82de..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 464 zcmX|-Jx;?w5QP&F1tks=5(0^Wf-W>J-~dQ~L=nXWp+&3ldW=`tUbDLfQXr9nZn_Kw z+<+r+0Pcl^sUOu6r47Ggqim3vvM*KnT{}J@)k`y zZ@M*~toW6uG)7~z5rxqqU!Q+}*7Zdrk)eYK955Yq-uF4Aeq-i`V6pSvTd4Y{@tt?7 zb!C0n3PC=W$||RHUST~ciI!=6)w~u3O8#dVkqP8ErovWhmhU(jlXuz9 zHp(dt=U^VtaIzl+j#muiS;&eMg#m{=>(NB&++ps7TNai5b}OhLya=r{s;JR8*`-k( zxZ8?T4esoe=>T73T1FU&*yv0^m33)VjYC80z?HVcT(mh08FYt&7`$gO<7s{XhQlxrG1# diff --git a/bcipy/simulator/util/generate_marker_data.py b/bcipy/simulator/util/generate_marker_data.py index 71e31a401..6cb82a4ef 100644 --- a/bcipy/simulator/util/generate_marker_data.py +++ b/bcipy/simulator/util/generate_marker_data.py @@ -7,20 +7,15 @@ simulate_raw_data) -def main() -> str: +def main() -> Path: """"Main method used to generate a raw data file for a switch device.""" parser = argparse.ArgumentParser( description="Create raw marker data for a given session.") parser.add_argument("data_folder", type=Path, help="Data directory (must contain triggers.txt file)") - parser.add_argument( - "-m", - "--mock", - action='store_true', - help= - "Mock data; use when button presses are not recorded in trigger file." - ) + mock_help = "Mock data (use when button presses are not recorded in trigger file)" + parser.add_argument("-m", "--mock", action='store_true', help=mock_help) parser.add_argument( "-p", "--parameters", @@ -31,12 +26,13 @@ def main() -> str: data_dir = args.data_folder if args.mock: - params_file = args.parameters or Path(data_dir, DEFAULT_PARAMETERS_FILENAME) - params = load_json_parameters(params_file, value_cast=True) + params_file = args.parameters or Path(data_dir, + DEFAULT_PARAMETERS_FILENAME) + params = load_json_parameters(str(params_file), value_cast=True) return simulate_raw_data(data_dir, params) else: return generate_raw_data(data_dir) if __name__ == '__main__': - print(main()) + print(str(main())) diff --git a/bcipy/task/control/evidence.py b/bcipy/task/control/evidence.py index 6c804093c..824954dcf 100644 --- a/bcipy/task/control/evidence.py +++ b/bcipy/task/control/evidence.py @@ -214,6 +214,12 @@ def __init__(self, parameters.get('preview_inquiry_progress_method')) self.trial_count = parameters.get('stim_length') + if self.button_press_mode == ButtonPressMode.NOTHING: + raise AssertionError(( + "Button press mode not supported.", + "To run without button press evidence set the acq_mode to exclude MARKERS." + )) + def preprocess(self, raw_data: np.ndarray, times: List[float], target_info: List[str], window_length: float) -> np.ndarray: """Preprocess the inquiry data. @@ -230,8 +236,6 @@ def preprocess(self, raw_data: np.ndarray, times: List[float], # Inquiries with 1.0s will be upgraded/supported by the model probabilities. rules = { - ButtonPressMode.NOTHING: - lambda: ones, ButtonPressMode.ACCEPT: lambda: ones if switch_was_pressed else zeros, ButtonPressMode.REJECT: @@ -256,8 +260,9 @@ def evaluate(self, raw_data: np.ndarray, symbols: List[str], window_length - The length of the time between stimuli presentation """ data = self.preprocess(raw_data, times, target_info, window_length) - return self.signal_model.compute_likelihood_ratio( + probs = self.signal_model.compute_likelihood_ratio( data, symbols, self.symbol_set) + return probs def get_evaluator( @@ -303,9 +308,11 @@ def find_matching_evaluator( return get_evaluator(content_type, evidence_type) -def init_evidence_evaluator(symbol_set: List[str], - signal_model: SignalModel) -> EvidenceEvaluator: +def init_evidence_evaluator( + symbol_set: List[str], + signal_model: SignalModel, + parameters: Optional[Parameters] = None) -> EvidenceEvaluator: """Find an EvidenceEvaluator that matches the given signal_model and initialize it.""" evaluator_class = find_matching_evaluator(signal_model) - return evaluator_class(symbol_set, signal_model) + return evaluator_class(symbol_set, signal_model, parameters) diff --git a/bcipy/task/paradigm/rsvp/copy_phrase.py b/bcipy/task/paradigm/rsvp/copy_phrase.py index b69df658f..aa76a7842 100644 --- a/bcipy/task/paradigm/rsvp/copy_phrase.py +++ b/bcipy/task/paradigm/rsvp/copy_phrase.py @@ -9,6 +9,14 @@ from bcipy.config import (DEFAULT_EVIDENCE_PRECISION, SESSION_DATA_FILENAME, SESSION_LOG_FILENAME, SESSION_SUMMARY_FILENAME, TRIGGER_FILENAME, WAIT_SCREEN_MESSAGE) +from bcipy.core.list import destutter +from bcipy.core.parameters import Parameters +from bcipy.core.session import session_excel +from bcipy.core.stimuli import InquirySchedule, StimuliOrder +from bcipy.core.symbols import BACKSPACE_CHAR, alphabet +from bcipy.core.triggers import (FlushFrequency, Trigger, TriggerHandler, + TriggerType, convert_timing_triggers, + offset_label) from bcipy.display import (InformationProperties, StimuliProperties, init_display_window) from bcipy.display.components.task_bar import CopyPhraseTaskBar @@ -21,21 +29,13 @@ from bcipy.helpers.clock import Clock from bcipy.helpers.copy_phrase_wrapper import CopyPhraseWrapper from bcipy.helpers.language_model import init_language_model -from bcipy.core.list import destutter -from bcipy.io.load import choose_signal_models -from bcipy.core.parameters import Parameters -from bcipy.io.save import _save_session_related_data -from bcipy.core.session import session_excel -from bcipy.core.stimuli import InquirySchedule, StimuliOrder -from bcipy.core.symbols import BACKSPACE_CHAR, alphabet from bcipy.helpers.task import (consecutive_incorrect, construct_triggers, fake_copy_phrase_decision, get_device_data_for_decision, get_user_input, relative_triggers, target_info, trial_complete_message) -from bcipy.core.triggers import (FlushFrequency, Trigger, TriggerHandler, - TriggerType, convert_timing_triggers, - offset_label) +from bcipy.io.load import choose_signal_models +from bcipy.io.save import _save_session_related_data from bcipy.language.main import LanguageModel from bcipy.signal.model import SignalModel from bcipy.signal.model.inquiry_preview import compute_probs_after_preview @@ -282,7 +282,8 @@ def init_evidence_evaluators( evidence_types = [] evaluators = [] for model in signal_models: - evaluator = init_evidence_evaluator(self.alp, model) + evaluator = init_evidence_evaluator(self.alp, model, + self.parameters) content_type = evaluator.consumes evidence_type = evaluator.produces if content_type in self.daq.active_device_content_types: diff --git a/bcipy/task/tests/core/test_evidence.py b/bcipy/task/tests/core/test_evidence.py index 4f0a994e0..bf18619ca 100644 --- a/bcipy/task/tests/core/test_evidence.py +++ b/bcipy/task/tests/core/test_evidence.py @@ -4,7 +4,8 @@ from unittest.mock import Mock from bcipy.acquisition.multimodal import ContentType -from bcipy.task.control.evidence import (EEGEvaluator, find_matching_evaluator, +from bcipy.task.control.evidence import (EEGEvaluator, SwitchEvaluator, + find_matching_evaluator, get_evaluator) from bcipy.task.data import EvidenceType from bcipy.task.exceptions import MissingEvidenceEvaluator @@ -18,11 +19,10 @@ def test_get_evaluator(self): self.assertEqual(EEGEvaluator, get_evaluator(ContentType.EEG)) self.assertEqual(EEGEvaluator, get_evaluator(ContentType.EEG, EvidenceType.ERP)) + self.assertEqual(SwitchEvaluator, get_evaluator(ContentType.MARKERS)) def test_missing_evaluator(self): """Test error condition when evaluator is missing""" - with self.assertRaises(MissingEvidenceEvaluator): - get_evaluator(ContentType.MARKERS) with self.assertRaises(MissingEvidenceEvaluator): get_evaluator(ContentType.EEG, EvidenceType.EYE) From 07e7222caa9603d17ecf8f31deae15f5582c9058 Mon Sep 17 00:00:00 2001 From: lawhead Date: Mon, 7 Apr 2025 13:27:24 -0700 Subject: [PATCH 71/76] Cleanup --- bcipy/simulator/data/data_process.py | 2 +- bcipy/simulator/data/processor_registry.py | 4 ++-- bcipy/task/tests/core/test_switch_evaluator.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bcipy/simulator/data/data_process.py b/bcipy/simulator/data/data_process.py index f3dd26ef0..efee02c72 100644 --- a/bcipy/simulator/data/data_process.py +++ b/bcipy/simulator/data/data_process.py @@ -369,7 +369,7 @@ def parameters_compatible(self, sim_timing_params: TimingParams, return sim_timing_params.time_flash == data_timing_params.time_flash -class EegRawDataProcessor(RawDataProcessor): +class EEGRawDataProcessor(RawDataProcessor): """RawDataProcessor that processes EEG data.""" consumes = ContentType.EEG produces = EvidenceType.ERP diff --git a/bcipy/simulator/data/processor_registry.py b/bcipy/simulator/data/processor_registry.py index 838492a07..92e45b596 100644 --- a/bcipy/simulator/data/processor_registry.py +++ b/bcipy/simulator/data/processor_registry.py @@ -5,14 +5,14 @@ from bcipy.acquisition.multimodal import ContentType from bcipy.signal.model.base_model import SignalModel # flake8: noqa -from bcipy.simulator.data.data_process import (EegRawDataProcessor, +from bcipy.simulator.data.data_process import (EEGRawDataProcessor, RawDataProcessor) from bcipy.simulator.data.switch_data_processor import SwitchDataProcessor from bcipy.task.data import EvidenceType log = logger.getLogger(__name__) -PROCESSOR_TYPES = [EegRawDataProcessor, SwitchDataProcessor] +PROCESSOR_TYPES = [EEGRawDataProcessor, SwitchDataProcessor] def get_processors() -> List[Type[RawDataProcessor]]: diff --git a/bcipy/task/tests/core/test_switch_evaluator.py b/bcipy/task/tests/core/test_switch_evaluator.py index 65166014c..6518667a8 100644 --- a/bcipy/task/tests/core/test_switch_evaluator.py +++ b/bcipy/task/tests/core/test_switch_evaluator.py @@ -1,4 +1,4 @@ -"""Tests for EEG evidence evaluator""" +"""Tests for Switch evidence evaluator""" import unittest from unittest.mock import Mock From 3bab3e3498dade9a76cba1ee686c1df812539572 Mon Sep 17 00:00:00 2001 From: Dylan Gaines Date: Tue, 29 Apr 2025 14:43:49 -0500 Subject: [PATCH 72/76] LM Toolkit Refactor (#381) * add script for simulating lm change with different phrases, small updates * add processing script, add NullDAQ * Update to custom typing parameters TODO: finish processing script, integrate LLM * Add progress bar and WIP update to average phrases across LM * update the process script to the new output format * Add final phrases * add plotting and stats to the processing script * add missing params command * add more logging and custom metrics * Integration of causal model, add phrases, update processing scripts * matrix processing * update figure * move script to a group demo * reset devices default * remove retry logic from language model init * reset static defaults * update parameters * remove bad test * lint * remove integration tests (for now) and add some info to sim README + linting * drop support for 3.8 * Added textpredict dependency, removed LM dependencies that are included in that package now * Refactored main language model classes into adapters that use the new textpredict package * Renamed ngram model * Updated imports * More ngram renaming * More ngram renaming, adjusted mixture default params * Updated textpredict version * Converted mixture model to adapter * Conveted oracle model into adapter * Deprecated InvalidLanguageModelException from bcipy in favor of aactextpredict's exception * Upgraded transformers version to address pytorch deprecation warnings * Adjusted max bump to 1, all mass on target * Updated test cases to use adapters * Store bcipy symbol set alongside toolkit model symbol set * Updated demos to use new adapter classes * Updated test class names * Fixed LM class references * Updated LM documentation * Fixed misc LM class references * Updated LM class references * lint * Update textpredict dependency to fix 3.10 * Converted LanguageModelAdapter base class to BciPyLanguageModel protocol. Adjusted subclasses and references * Restored BciPy's own uniform model * Renamed base LM class back to LanguageModel * Removed language module exclusion from mypy * Deprecated response type, added separate protocols for char and word LMs * Updated dependency to the new toolkit name * Loosened textslinger dependency to allow minor changes without updating requirement every time * Refactored Language Model protocols; fixed linting issues and import sort order. --------- Co-authored-by: Tab Memmott Co-authored-by: lawhead --- .../resources/mock_session/parameters.json | 2 +- bcipy/exceptions.py | 26 +- bcipy/helpers/copy_phrase_wrapper.py | 7 +- bcipy/helpers/language_model.py | 41 +- .../helpers/tests/test_copy_phrase_wrapper.py | 13 +- bcipy/language/README.md | 14 +- bcipy/language/__init__.py | 5 +- bcipy/language/demo/demo_causal.py | 45 +- bcipy/language/demo/demo_mixture.py | 44 +- .../demo/{demo_kenlm.py => demo_ngram.py} | 54 +- bcipy/language/main.py | 99 ++-- bcipy/language/model/adapter.py | 67 +++ bcipy/language/model/causal.py | 511 +----------------- bcipy/language/model/kenlm.py | 137 ----- bcipy/language/model/mixture.py | 138 +---- bcipy/language/model/ngram.py | 29 + bcipy/language/model/oracle.py | 62 ++- bcipy/language/model/uniform.py | 45 +- bcipy/language/tests/test_causal.py | 108 ++-- bcipy/language/tests/test_mixture.py | 110 ++-- .../tests/{test_kenlm.py => test_ngram.py} | 76 +-- bcipy/language/tests/test_oracle.py | 51 +- bcipy/language/tests/test_uniform.py | 27 +- bcipy/parameters/lm_params.json | 8 +- bcipy/simulator/task/copy_phrase.py | 5 +- pyproject.toml | 4 +- 26 files changed, 594 insertions(+), 1134 deletions(-) rename bcipy/language/demo/{demo_kenlm.py => demo_ngram.py} (72%) create mode 100644 bcipy/language/model/adapter.py delete mode 100644 bcipy/language/model/kenlm.py create mode 100644 bcipy/language/model/ngram.py rename bcipy/language/tests/{test_kenlm.py => test_ngram.py} (64%) diff --git a/bcipy/core/tests/resources/mock_session/parameters.json b/bcipy/core/tests/resources/mock_session/parameters.json index 9331fe714..99b99a237 100644 --- a/bcipy/core/tests/resources/mock_session/parameters.json +++ b/bcipy/core/tests/resources/mock_session/parameters.json @@ -680,7 +680,7 @@ "recommended": [ "UNIFORM", "CAUSAL", - "KENLM", + "NGRAM", "MIXTURE", "ORACLE" ], diff --git a/bcipy/exceptions.py b/bcipy/exceptions.py index e58bb1615..23c525a03 100644 --- a/bcipy/exceptions.py +++ b/bcipy/exceptions.py @@ -1,4 +1,3 @@ - class BciPyCoreException(Exception): """BciPy Core Exception. @@ -75,30 +74,29 @@ class InvalidFieldException(FieldException): ... -class UnsupportedResponseType(BciPyCoreException): - """Unsupported ResponseType +class TaskConfigurationException(BciPyCoreException): + """Task Configuration Exception. - Thrown when attempting to set the response type of a language model to an - unsupported value.""" + Thrown when attempting to run a task with invalid configurations""" ... -class TaskConfigurationException(BciPyCoreException): - """Task Configuration Exception. +class KenLMInstallationException(BciPyCoreException): + """KenLM Installation Exception. - Thrown when attempting to run a task with invalid configurations""" + Thrown when attempting to import kenlm without installing the module""" ... -class InvalidLanguageModelException(BciPyCoreException): - """Invalid Language Model Exception. +class InvalidSymbolSetException(BciPyCoreException): + """Invalid Symbol Set Exception. - Thrown when attempting to load a language model from an invalid path""" + Thrown when querying a language model for predictions without setting the symbol set.""" ... -class KenLMInstallationException(BciPyCoreException): - """KenLM Installation Exception. +class LanguageModelNameInUseException(BciPyCoreException): + """Language Model Name In Use Exception. - Thrown when attempting to import kenlm without installing the module""" + Thrown when attempting to register a language model type with a duplicate name.""" ... diff --git a/bcipy/helpers/copy_phrase_wrapper.py b/bcipy/helpers/copy_phrase_wrapper.py index cfa40aa86..c41e4dafa 100644 --- a/bcipy/helpers/copy_phrase_wrapper.py +++ b/bcipy/helpers/copy_phrase_wrapper.py @@ -10,7 +10,7 @@ from bcipy.core.symbols import BACKSPACE_CHAR from bcipy.exceptions import BciPyCoreException from bcipy.helpers.language_model import histogram, with_min_prob -from bcipy.language.main import LanguageModel +from bcipy.language.main import CharacterLanguageModel, LanguageModel from bcipy.task.control.criteria import (CriteriaEvaluator, MaxIterationsCriteria, MinIterationsCriteria, @@ -180,6 +180,9 @@ def letter_info(self, triggers: List[Tuple[str, float]], def initialize_series(self) -> Tuple[bool, InquirySchedule]: """If a decision is made initializes the next series.""" assert self.lmodel, "Language model must be initialized." + if not isinstance(self.lmodel, CharacterLanguageModel): + raise BciPyCoreException( + "Only character language models are currently supported.") try: # First, reset the history for this new series @@ -190,7 +193,7 @@ def initialize_series(self) -> Tuple[bool, InquirySchedule]: log.info(f"Querying language model: '{update}'") # update the lmodel and get back the priors - lm_letter_prior = self.lmodel.predict(list(update)) + lm_letter_prior = self.lmodel.predict_character(list(update)) if BACKSPACE_CHAR in self.alp: # Apply configured backspace probability. diff --git a/bcipy/helpers/language_model.py b/bcipy/helpers/language_model.py index 2c0bcd4d0..810058176 100644 --- a/bcipy/helpers/language_model.py +++ b/bcipy/helpers/language_model.py @@ -1,29 +1,45 @@ """Helper functions for language model use.""" import inspect import math -from typing import Dict, List, Tuple +from typing import Callable, Dict, List, Tuple import numpy as np from bcipy.core.symbols import alphabet -from bcipy.language.main import LanguageModel, ResponseType +from bcipy.exceptions import LanguageModelNameInUseException +from bcipy.language.main import LanguageModel # pylint: disable=unused-import # flake8: noqa """Only imported models will be included in language_models_by_name""" # flake8: noqa -from bcipy.exceptions import InvalidLanguageModelException -from bcipy.language.model.causal import CausalLanguageModel -from bcipy.language.model.kenlm import KenLMLanguageModel -from bcipy.language.model.mixture import MixtureLanguageModel +from bcipy.language.model.causal import CausalLanguageModelAdapter +from bcipy.language.model.mixture import MixtureLanguageModelAdapter +from bcipy.language.model.ngram import NGramLanguageModelAdapter from bcipy.language.model.oracle import OracleLanguageModel from bcipy.language.model.uniform import UniformLanguageModel +VALID_LANGUAGE_MODELS: Dict[str, Callable[[], LanguageModel]] = { + "CAUSAL": CausalLanguageModelAdapter, + "NGRAM": NGramLanguageModelAdapter, + "MIXTURE": MixtureLanguageModelAdapter, + "ORACLE": OracleLanguageModel, + "UNIFORM": UniformLanguageModel +} -def language_models_by_name() -> Dict[str, LanguageModel]: + +def language_models_by_name() -> Dict[str, Callable[[], LanguageModel]]: """Returns available language models indexed by name.""" - return {lm.name(): lm for lm in LanguageModel.__subclasses__()} + return VALID_LANGUAGE_MODELS + + +def register_language_model(name: str, lm_type: Callable[[], LanguageModel]) -> None: + if name in VALID_LANGUAGE_MODELS: + raise LanguageModelNameInUseException( + f"{name} is already registered as {VALID_LANGUAGE_MODELS[name]}.") + else: + VALID_LANGUAGE_MODELS[name] = lm_type def init_language_model(parameters: dict) -> LanguageModel: @@ -50,10 +66,11 @@ def init_language_model(parameters: dict) -> LanguageModel: # select the relevant parameters into a dict. params = {key: parameters[key] for key in args & parameters.keys()} - return model( - response_type=ResponseType.SYMBOL, - symbol_set=alphabet(parameters), - **params) + lm = model(**params) + + lm.set_symbol_set(alphabet(parameters)) + + return lm def norm_domain(priors: List[Tuple[str, float]]) -> List[Tuple[str, float]]: diff --git a/bcipy/helpers/tests/test_copy_phrase_wrapper.py b/bcipy/helpers/tests/test_copy_phrase_wrapper.py index ee08a6a57..7eeccc89c 100644 --- a/bcipy/helpers/tests/test_copy_phrase_wrapper.py +++ b/bcipy/helpers/tests/test_copy_phrase_wrapper.py @@ -1,7 +1,7 @@ import unittest from bcipy.helpers.copy_phrase_wrapper import CopyPhraseWrapper -from bcipy.core.symbols import alphabet +from bcipy.core.symbols import DEFAULT_SYMBOL_SET from bcipy.language.model.uniform import UniformLanguageModel from bcipy.task.data import EvidenceType @@ -10,12 +10,11 @@ class TestCopyPhraseWrapper(unittest.TestCase): """Test CopyPhraseWrapper""" def test_valid_letters(self): - alp = alphabet() cp = CopyPhraseWrapper( min_num_inq=1, max_num_inq=50, lmodel=None, - alp=alp, + alp=DEFAULT_SYMBOL_SET, task_list=[("HELLO_WORLD", "HE")], is_txt_stim=True, evidence_names=[EvidenceType.LM, EvidenceType.ERP], @@ -104,13 +103,15 @@ def test_valid_letters(self): ["nontarget", "nontarget"]) def test_init_series(self): - alp = alphabet() + + lmodel = UniformLanguageModel() + lmodel.set_symbol_set(DEFAULT_SYMBOL_SET) copy_phrase_task = CopyPhraseWrapper( min_num_inq=1, max_num_inq=50, - lmodel=UniformLanguageModel(symbol_set=alp), - alp=alp, + lmodel=lmodel, + alp=DEFAULT_SYMBOL_SET, task_list=[("HELLO_WORLD", "HE")], is_txt_stim=True, evidence_names=[EvidenceType.LM, EvidenceType.ERP], diff --git a/bcipy/language/README.md b/bcipy/language/README.md index 1438a7455..c54ca8e35 100644 --- a/bcipy/language/README.md +++ b/bcipy/language/README.md @@ -1,6 +1,6 @@ # Language -BciPy Language module provides an interface for word and character level predictions. +BciPy Language module provides an interface for word and character level predictions. This module primarily relies upon the TextSlinger package (textslinger on PyPI) for its probability calculations. More information on this package can be found on our [GitHub repo](https://github.com/kdv123/textpredict) The core methods of any `LanguageModel` include: @@ -8,8 +8,6 @@ The core methods of any `LanguageModel` include: > `load` - load a pre-trained model given a path (currently BciPy does not support training language models!) -> `update` - update internal state of your model. - You may of course define other methods, however all integrated BciPy experiments using your model will require those to be defined! The language module has the following structure: @@ -30,20 +28,20 @@ The language module has the following structure: The UniformLanguageModel provides equal probabilities for all symbols in the symbol set. This model is useful for evaluating other aspects of the system, such as EEG signal quality, without any influence from a language model. -## KenLM Model -The KenLMLanguageModel utilizes a pretrained n-gram language model to generate probabilities for all symbols in the symbol set. N-gram models use frequencies of different character sequences to generate their predictions. Models trained on AAC-like data can be found [here](https://imagineville.org/software/lm/dec19_char/). For faster load times, it is recommended to use the binary models located at the bottom of the page. The default parameters file utilizes `lm_dec19_char_large_12gram.kenlm`. If you have issues accessing, please reach out to us on GitHub or via email at `cambi_support@googlegroups.com`. +## NGram Model +The NGramLanguageModelAdapter utilizes a pretrained n-gram language model to generate probabilities for all symbols in the symbol set. N-gram models use frequencies of different character sequences to generate their predictions. Models trained on AAC-like data can be found [here](https://imagineville.org/software/lm/dec19_char/). For faster load times, it is recommended to use the binary models located at the bottom of the page. The default parameters file utilizes `lm_dec19_char_large_12gram.kenlm`. If you have issues accessing, please reach out to us on GitHub or via email at `cambi_support@googlegroups.com`. For models that import the kenlm module, this must be manually installed using `pip install kenlm==0.1 --global-option="max_order=12"`. ## Causal Model -The CausalLanguageModel class can use any causal language model from Huggingface, though it has only been tested with gpt2, facebook/opt, and distilgpt2 families of models. Causal language models predict the next token in a sequence of tokens. For the many of these models, byte-pair encoding (BPE) is used for tokenization. The main idea of BPE is to create a fixed-size vocabulary that contains common English subword units. Then a less common word would be broken down into several subword units in the vocabulary. For example, the tokenization of character sequence `peanut_butter_and_jel` would be: +The CausalLanguageModelAdapter class can use any causal language model from Huggingface, though it has only been tested with gpt2, facebook/opt, and distilgpt2 families of models (including the domain-adapted figmtu/opt-350m-aac). Causal language models predict the next token in a sequence of tokens. For the many of these models, byte-pair encoding (BPE) is used for tokenization. The main idea of BPE is to create a fixed-size vocabulary that contains common English subword units. Then a less common word would be broken down into several subword units in the vocabulary. For example, the tokenization of character sequence `peanut_butter_and_jel` would be: > *['pe', 'anut', '_butter', '_and', '_j', 'el']* -Therefore, in order to generate a predictive distribution on the next character, we need to examine all the possibilities that could complete the final subword tokens in the input sequences. We must remove at least one token from the end of the context to allow the model the option of extending it, as opposed to only adding a new token. Removing more tokens allows the model more flexibility and may lead to better predictions, but at the cost of a higher prediction time. In this model we remove all of the subword tokens in the current (partially-typed) word to allow it the most flexibility. We then ask the model to estimate the likelihood of the next token and evaluate each token that matches our context. For efficiency, we only track a certain number of hypotheses at a time, known as the beam width, and each hypothesis until it surpasses the context. We can then store the likelihood for each final prediction in a list based on the character that directly follows the context. Once we have no more hypotheses to extend, we can sum the likelihoods stored for each character in our symbol set and normalize so they sum to 1, giving us our final distribution. +Therefore, in order to generate a predictive distribution on the next character, we need to examine all the possibilities that could complete the final subword tokens in the input sequences. We must remove at least one token from the end of the context to allow the model the option of extending it, as opposed to only adding a new token. Removing more tokens allows the model more flexibility and may lead to better predictions, but at the cost of a higher prediction time. In this model we remove all of the subword tokens in the current (partially-typed) word to allow it the most flexibility. We then ask the model to estimate the likelihood of the next token and evaluate each token that matches our context. For efficiency, we only track a certain number of hypotheses at a time, known as the beam width, and each hypothesis until it surpasses the context. We can then store the likelihood for each final prediction in a list based on the character that directly follows the context. Once we have no more hypotheses to extend, we can sum the likelihoods stored for each character in our symbol set and normalize so they sum to 1, giving us our final distribution. More details on this process can be found in our paper, [Adapting Large Language Models for Character-based Augmentative and Alternative Communication](https://arxiv.org/abs/2501.10582). ## Mixture Model -The MixtureLanguageModel class allows for the combination of two or more supported models. The selected models are mixed according to the provided weights, which can be tuned using the Bcipy/scripts/python/mixture_tuning.py script. It is not recommended to use more than one "heavy-weight" model with long prediction times (the CausalLanguageModel) since this model will query each component model and parallelization is not currently supported. +The MixtureLanguageModelAdapter class allows for the combination of two or more supported models. The selected models are mixed according to the provided weights, which can be tuned using the Bcipy/scripts/python/mixture_tuning.py script. It is not recommended to use more than one "heavy-weight" model with long prediction times (the CausalLanguageModel) since this model will query each component model and parallelization is not currently supported. # Contact Information diff --git a/bcipy/language/__init__.py b/bcipy/language/__init__.py index 73c549b39..c6f353134 100644 --- a/bcipy/language/__init__.py +++ b/bcipy/language/__init__.py @@ -1,6 +1,7 @@ -from .main import LanguageModel, ResponseType +from .main import LanguageModel, CharacterLanguageModel, WordLanguageModel __all__ = [ "LanguageModel", - "ResponseType", + "CharacterLanguageModel", + "WordLanguageModel" ] diff --git a/bcipy/language/demo/demo_causal.py b/bcipy/language/demo/demo_causal.py index 486b228e6..1d6fb3425 100644 --- a/bcipy/language/demo/demo_causal.py +++ b/bcipy/language/demo/demo_causal.py @@ -1,26 +1,35 @@ -from bcipy.language.model.causal import CausalLanguageModel -from bcipy.core.symbols import alphabet -from bcipy.language.main import ResponseType +from bcipy.language.model.causal import CausalLanguageModelAdapter +from bcipy.core.symbols import DEFAULT_SYMBOL_SET if __name__ == "__main__": - symbol_set = alphabet() - response_type = ResponseType.SYMBOL - lm = CausalLanguageModel(response_type, symbol_set, lang_model_name="gpt2") + lm = CausalLanguageModelAdapter(lang_model_name="figmtu/opt-350m-aac") + lm.set_symbol_set(DEFAULT_SYMBOL_SET) - next_char_pred = lm.state_update(list("does_it_make_sen")) - print(next_char_pred) + print("Target sentence: does_it_make_sense\n") + + next_char_pred = lm.predict_character(list("does_it_make_sen")) + print("Context: does_it_make_sen") + print(f"Predictions: {next_char_pred}") correct_char_rank = [c[0] for c in next_char_pred].index("S") + 1 - print(correct_char_rank) - next_char_pred = lm.state_update(list("does_it_make_sens")) - print(next_char_pred) + print(f"Correct character rank: {correct_char_rank}\n") + + next_char_pred = lm.predict_character(list("does_it_make_sens")) + print("Context: does_it_make_sens") + print(f"Predictions: {next_char_pred}") correct_char_rank = [c[0] for c in next_char_pred].index("E") + 1 - print(correct_char_rank) - next_char_pred = lm.state_update(list("does_it_make_sense")) - print(next_char_pred) + print(f"Correct character rank: {correct_char_rank}\n") + + next_char_pred = lm.predict_character(list("does_it_make_sense")) + print("Context: does_it_make_sense") + print(f"Predictions: {next_char_pred}") correct_char_rank = [c[0] for c in next_char_pred].index("_") + 1 - print(correct_char_rank) - next_char_pred = lm.state_update(list("i_like_zebra")) - print(next_char_pred) + print(f"Correct character rank: {correct_char_rank}\n") + + print("Target sentence: i_like_zebras\n") + + next_char_pred = lm.predict_character(list("i_like_zebra")) + print("Context: i_like_zebra") + print(f"Predictions: {next_char_pred}") correct_char_rank = [c[0] for c in next_char_pred].index("S") + 1 - print(correct_char_rank) + print(f"Correct character rank: {correct_char_rank}\n") diff --git a/bcipy/language/demo/demo_mixture.py b/bcipy/language/demo/demo_mixture.py index fee828e44..aeec37431 100644 --- a/bcipy/language/demo/demo_mixture.py +++ b/bcipy/language/demo/demo_mixture.py @@ -1,22 +1,36 @@ -from bcipy.language.model.mixture import MixtureLanguageModel -from bcipy.core.symbols import alphabet -from bcipy.language.main import ResponseType +from bcipy.language.model.mixture import MixtureLanguageModelAdapter +from bcipy.core.symbols import DEFAULT_SYMBOL_SET if __name__ == "__main__": - symbol_set = alphabet() - response_type = ResponseType.SYMBOL - lm = MixtureLanguageModel(response_type, symbol_set) + # Load the default mixture model from lm_params.json + lm = MixtureLanguageModelAdapter() + lm.set_symbol_set(DEFAULT_SYMBOL_SET) - next_char_pred = lm.state_update(list("does_it_make_sen")) - print(next_char_pred) + print("Target sentence: does_it_make_sense\n") + + next_char_pred = lm.predict_character(list("does_it_make_sen")) + print("Context: does_it_make_sen") + print(f"Predictions: {next_char_pred}") correct_char_rank = [c[0] for c in next_char_pred].index("S") + 1 - print(correct_char_rank) - next_char_pred = lm.state_update(list("does_it_make_sens")) - print(next_char_pred) + print(f"Correct character rank: {correct_char_rank}\n") + + next_char_pred = lm.predict_character(list("does_it_make_sens")) + print("Context: does_it_make_sens") + print(f"Predictions: {next_char_pred}") correct_char_rank = [c[0] for c in next_char_pred].index("E") + 1 - print(correct_char_rank) - next_char_pred = lm.state_update(list("does_it_make_sense")) - print(next_char_pred) + print(f"Correct character rank: {correct_char_rank}\n") + + next_char_pred = lm.predict_character(list("does_it_make_sense")) + print("Context: does_it_make_sense") + print(f"Predictions: {next_char_pred}") correct_char_rank = [c[0] for c in next_char_pred].index("_") + 1 - print(correct_char_rank) + print(f"Correct character rank: {correct_char_rank}\n") + + print("Target sentence: i_like_zebras\n") + + next_char_pred = lm.predict_character(list("i_like_zebra")) + print("Context: i_like_zebra") + print(f"Predictions: {next_char_pred}") + correct_char_rank = [c[0] for c in next_char_pred].index("S") + 1 + print(f"Correct character rank: {correct_char_rank}\n") diff --git a/bcipy/language/demo/demo_kenlm.py b/bcipy/language/demo/demo_ngram.py similarity index 72% rename from bcipy/language/demo/demo_kenlm.py rename to bcipy/language/demo/demo_ngram.py index cf42c503a..50e335a1b 100644 --- a/bcipy/language/demo/demo_kenlm.py +++ b/bcipy/language/demo/demo_ngram.py @@ -1,8 +1,7 @@ # Basic sanity test of using KenLM to predict a sentence using a 12-gram character model. -from bcipy.language.model.kenlm import KenLMLanguageModel -from bcipy.core.symbols import alphabet -from bcipy.language.main import ResponseType +from bcipy.language.model.ngram import NGramLanguageModelAdapter +from bcipy.core.symbols import DEFAULT_SYMBOL_SET from bcipy.config import LM_PATH from bcipy.exceptions import KenLMInstallationException @@ -16,6 +15,8 @@ if __name__ == "__main__": lm_path = f"{LM_PATH}/lm_dec19_char_12gram_1e-5_kenlm_probing.bin" + # Using KenLM directly + # Load a really pruned n-gram language model model = kenlm.LanguageModel(lm_path) @@ -80,27 +81,38 @@ prev = token print(f"sum logprob = {accum:.4f}") - symbol_set = alphabet() - response_type = ResponseType.SYMBOL - lm = KenLMLanguageModel(response_type, symbol_set, lm_path) + # Using the adapter and textslinger toolkit + lm = NGramLanguageModelAdapter(lm_path) + lm.set_symbol_set(DEFAULT_SYMBOL_SET) + + print("Target sentence: i_like_zebras\n") - next_char_pred = lm.state_update(list("i_like_z")) - print(next_char_pred) + next_char_pred = lm.predict_character(list("i_like_z")) + print("Context: i_like_z") + print(f"Predictions: {next_char_pred}") correct_char_rank = [c[0] for c in next_char_pred].index("E") + 1 - print(correct_char_rank) - next_char_pred = lm.state_update(list("i_lik")) - print(next_char_pred) + print(f"Correct character rank: {correct_char_rank}\n") + + next_char_pred = lm.predict_character(list("i_lik")) + print("Context: i_lik") + print(f"Predictions: {next_char_pred}") correct_char_rank = [c[0] for c in next_char_pred].index("E") + 1 - print(correct_char_rank) - next_char_pred = lm.state_update(list("i_like_zebras")) - print(next_char_pred) + print(f"Correct character rank: {correct_char_rank}\n") + + next_char_pred = lm.predict_character(list("i_like_zebras")) + print("Context: i_like_zebras") + print(f"Predictions: {next_char_pred}") correct_char_rank = [c[0] for c in next_char_pred].index("_") + 1 - print(correct_char_rank) - next_char_pred = lm.state_update(list("")) - print(next_char_pred) + print(f"Correct character rank: {correct_char_rank}\n") + + next_char_pred = lm.predict_character(list("")) + print("Context: ") + print(f"Predictions: {next_char_pred}") correct_char_rank = [c[0] for c in next_char_pred].index("I") + 1 - print(correct_char_rank) - next_char_pred = lm.state_update(list("i_like_zebra")) - print(next_char_pred) + print(f"Correct character rank: {correct_char_rank}\n") + + next_char_pred = lm.predict_character(list("i_like_zebra")) + print("Context: i_like_zebra") + print(f"Predictions: {next_char_pred}") correct_char_rank = [c[0] for c in next_char_pred].index("S") + 1 - print(correct_char_rank) + print(f"Correct character rank: {correct_char_rank}\n") diff --git a/bcipy/language/main.py b/bcipy/language/main.py index ed3bfdacb..37136a037 100644 --- a/bcipy/language/main.py +++ b/bcipy/language/main.py @@ -1,87 +1,50 @@ """Defines the language model base class.""" -from abc import ABC, abstractmethod -from enum import Enum -from typing import List, Optional, Tuple -import json +from abc import abstractmethod +from typing import List, Optional, Protocol, Tuple, Union, runtime_checkable -from bcipy.exceptions import UnsupportedResponseType -from bcipy.core.symbols import DEFAULT_SYMBOL_SET -from bcipy.config import DEFAULT_LM_PARAMETERS_PATH - -class ResponseType(Enum): - """Language model response type options.""" - SYMBOL = 'Symbol' - WORD = 'Word' - - def __str__(self): - return self.value - - -class LanguageModel(ABC): - """Parent class for Language Models.""" - - _response_type: ResponseType = None - symbol_set: List[str] = None - - def __init__(self, - response_type: Optional[ResponseType] = None, - symbol_set: Optional[List[str]] = None): - self.response_type = response_type or ResponseType.SYMBOL - self.symbol_set = symbol_set or DEFAULT_SYMBOL_SET - with open(DEFAULT_LM_PARAMETERS_PATH, 'r') as params_file: - self.parameters = json.load(params_file) - - @classmethod - def name(cls) -> str: - """Model name used for configuration""" - suffix = 'LanguageModel' - if cls.__name__.endswith(suffix): - return cls.__name__[0:-len(suffix)].upper() - return cls.__name__.upper() +class UsesSymbols(Protocol): + """A protocol for classes in which symbols can be set.""" + symbol_set: Optional[List[str]] = None @abstractmethod - def supported_response_types(self) -> List[ResponseType]: - """Returns a list of response types supported by this language model.""" + def set_symbol_set(self, symbol_set: List[str]) -> None: + """Updates the symbol set of the model. Must be called prior to prediction""" - @property - def response_type(self) -> ResponseType: - """Returns the current response type""" - return self._response_type - @response_type.setter - def response_type(self, value: ResponseType): - """Attempts to set the response type to the given value""" - if value not in self.supported_response_types(): - raise UnsupportedResponseType( - f"{value} responses are not supported by this model") - self._response_type = value +@runtime_checkable +class CharacterLanguageModel(UsesSymbols, Protocol): + """Protocol for BciPy Language models that predict characters.""" @abstractmethod - def predict(self, evidence: List[str]) -> List[Tuple]: + def predict_character(self, evidence: Union[str, + List[str]]) -> List[Tuple]: """ - Using the provided data, compute log likelihoods over the entire symbol set. + Using the provided data, compute the probability distribution over the entire symbol set. Args: - evidence - ['A', 'B'] + evidence - ['H', 'E'] Response: - probability - dependant on response type, a list of words or symbols with probability + probability - a list of symbols with probability """ - ... - @abstractmethod - def update(self) -> None: - """Update the model state""" - ... + +@runtime_checkable +class WordLanguageModel(UsesSymbols, Protocol): + """Protocol for BciPy Language models that predict words.""" @abstractmethod - def load(self) -> None: - """Restore model state from the provided checkpoint""" - ... + def predict_word(self, evidence: Union[str, List[str]], + num_predictions: int) -> List[Tuple]: + """ + Using the provided data, compute log likelihoods of word completions + in the symbol set. + Args: + evidence - ['H', 'E'] + + Response: + a list of words with associated log likelihoods + """ - def reset(self) -> None: - """Reset language model state""" - ... - def state_update(self, evidence: List[str]) -> List[Tuple]: - """Update state by predicting and updating""" +LanguageModel = Union[CharacterLanguageModel, WordLanguageModel] diff --git a/bcipy/language/model/adapter.py b/bcipy/language/model/adapter.py new file mode 100644 index 000000000..9fb6ff438 --- /dev/null +++ b/bcipy/language/model/adapter.py @@ -0,0 +1,67 @@ +"""Defines the language model adapter base class.""" +import json +from abc import ABC, abstractmethod +from typing import List, Optional, Tuple, Union + +from bcipy.config import DEFAULT_LM_PARAMETERS_PATH +from bcipy.core.symbols import BACKSPACE_CHAR, SPACE_CHAR +from bcipy.exceptions import InvalidSymbolSetException + + +class LanguageModelAdapter(ABC): + """Abstract base class for textslinger language model adapters.""" + + symbol_set: Optional[List[str]] = None + model = None + + def predict_character(self, evidence: Union[str, List[str]]) -> List[Tuple]: + """ + Using the provided data, compute the probability distribution over the entire symbol set. + Args: + evidence - ['H', 'E'] + + Response: + probability - a list of symbols with probability + """ + + if self.symbol_set is None: + raise InvalidSymbolSetException("symbol set must be set prior to requesting predictions.") + + assert self.model is not None, "language model does not exist!" + + context = "".join(evidence) + converted_context = context.replace(SPACE_CHAR, ' ') + + # TODO: If toolkit dependency is updated to >=1.0.0, this will need to change to predict_character() + next_char_pred = dict(self.model.predict(list(converted_context))) + + # Replace space with special space + if ' ' in next_char_pred: + next_char_pred[SPACE_CHAR] = next_char_pred[' '] + del next_char_pred[' '] + + # Add backspace, but return prob 0 from the lm + next_char_pred[BACKSPACE_CHAR] = 0.0 + + return list(sorted(next_char_pred.items(), + key=lambda item: item[1], reverse=True)) + + def _load_parameters(self) -> None: + with open(DEFAULT_LM_PARAMETERS_PATH, 'r', + encoding='utf8') as params_file: + self.parameters = json.load(params_file) + + @abstractmethod + def _load_model(self) -> None: + """Load the model itself using stored parameters""" + + def set_symbol_set(self, symbol_set: List[str]) -> None: + """Update the symbol set and call for the model to be loaded""" + + self.symbol_set = symbol_set + + # LM doesn't care about backspace, needs literal space + self.model_symbol_set = [' ' if ch is SPACE_CHAR else ch for ch in self.symbol_set] + self.model_symbol_set.remove(BACKSPACE_CHAR) + + self._load_model() diff --git a/bcipy/language/model/causal.py b/bcipy/language/model/causal.py index 4a2a475fd..ca493d24c 100644 --- a/bcipy/language/model/causal.py +++ b/bcipy/language/model/causal.py @@ -1,42 +1,28 @@ -import torch -from typing import Optional, List, Tuple -from transformers import AutoModelForCausalLM, AutoTokenizer -import itertools -import heapq +from typing import Optional -from bcipy.core.symbols import BACKSPACE_CHAR, SPACE_CHAR -from bcipy.language.main import LanguageModel, ResponseType -from bcipy.exceptions import InvalidLanguageModelException +from textslinger.causal import CausalLanguageModel -from scipy.special import logsumexp -from scipy.special import softmax -import time -from collections import defaultdict -from typing import Final from bcipy.config import LM_PATH +from bcipy.language.model.adapter import LanguageModelAdapter -class CausalLanguageModel(LanguageModel): - """Character language model based on a pre-trained causal model, GPT-2 by default.""" +class CausalLanguageModelAdapter(LanguageModelAdapter): + """Character language model based on a pre-trained causal model.""" def __init__(self, - response_type: ResponseType, - symbol_set: List[str], lang_model_name: Optional[str] = None, lm_path: Optional[str] = None, lm_device: str = "cpu", lm_left_context: str = "", - beam_width: int = None, + beam_width: Optional[int] = None, fp16: bool = True, mixed_case_context: bool = True, case_simple: bool = True, - max_completed: int = None, + max_completed: Optional[int] = None, ): """ - Initialize instance variables and load the language model with given path + Initialize instance variables and load model parameters Args: - response_type - SYMBOL only - symbol_set - list of symbol strings lang_model_name - name of the Hugging Face casual language model to load lm_path - load fine-tuned model from specified directory lm_device - device to use for making predictions (cpu, mps, or cuda) @@ -47,33 +33,14 @@ def __init__(self, case_simple - simple fixing of left context case max_completed - stop search once we reach this many completed hypotheses, None=don't prune """ - super().__init__(response_type=response_type, symbol_set=symbol_set) + + self._load_parameters() causal_params = self.parameters['causal'] - self.model = None - self.tokenizer = None - self.vocab_size = 0 - self.valid_vocab = [] - self.vocab = defaultdict(list) - # Since subword token ids are integers, use a list instead of a - # dictionary - self.index_to_word = [] - self.index_to_word_lower = [] - self.symbol_set_lower = None - self.device = lm_device - self.left_context = lm_left_context self.beam_width = beam_width or int(causal_params['beam_width']['value']) - self.fp16 = fp16 - self.mixed_case_context = mixed_case_context - self.case_simple = case_simple self.max_completed = max_completed or int(causal_params['max_completed']['value']) - if not self.max_completed and not self.beam_width: - print("WARNING: using causal language model without any pruning, this can be slow!") - else: - print(f"Causal language model, beam_width {self.beam_width}, max_completed {self.max_completed}") - # We optionally load the model from a local directory, but if this is not # specified, we load a Hugging Face model @@ -82,443 +49,23 @@ def __init__(self, local_model_path = lm_path or causal_params['model_path']['value'] self.model_dir = f"{LM_PATH}/{local_model_path}" if local_model_path != "" else self.model_name - # Parameters for the search - - # Simple heuristic to correct case in the LM context - self.simple_upper_words = {"i": "I", - "i'll": "I'll", - "i've": "I've", - "i'd": "I'd", - "i'm": "I'm"} - - # Track how much time spent in different parts of the predict function - self.predict_total_ns = 0 - self.predict_inference_ns = 0 - - # Are we a model that automatically inserts a start token that we need - # to get rid of - self.drop_first_token = False - - self.load() - - def supported_response_types(self) -> List[ResponseType]: - return [ResponseType.SYMBOL] - - def _build_vocab(self) -> None: - """ - Build a vocabulary table mapping token index to word strings - """ - - # Loop over all the subword tokens in the LLM - for i in range(self.vocab_size): - # Create a map from the subword token integer ID to the mixed and - # lowercase string versions - word = self.tokenizer.decode([i]) - word_lower = word.lower() - self.index_to_word += word, - self.index_to_word_lower += word_lower, - - # Check if all the characters in the subword token are in our valid - # symbol set - valid = True - for ch in word_lower: - # The space char is only valid once we convert spaces to the - # space char - if ch == SPACE_CHAR: - valid = False - break - if ch == ' ': - continue - elif ch not in self.symbol_set_lower: - valid = False - break - - # If the subword token symbols are all valid, then add it to the - # list of valid token IDs - if valid: - self.valid_vocab += i, - # Add this token ID to all lists for its valid text prefixes - for j in range(len(word)): - key = word_lower[0:j + 1].replace(' ', SPACE_CHAR) - self.vocab[key] += i, - - # When done, self.vocab can be used to map to possible following subword tokens given some text, e.g.: - # self.vocab["cyclo"] = [47495, 49484] - # self.index_to_word[self.vocab["cyclo"][0]] = cyclop - # self.index_to_word[self.vocab["cyclo"][1]] = cyclopedia - - (self.model_name.startswith("facebook/opt") - or self.model_name.startswith("figmtu/opt") - or "Llama-3.1" in self.model_name) - - # Get the index we use for the start or end pseudo-word - if self.left_context == "": - if "gpt2" in self.model_name: - self.left_context = "<|endoftext|>" - elif "Llama-3.1" in self.model_name: - self.left_context = "<|begin_of_text|>" - # Seems to have both sentence start and end tokens: - # https://docs.mistral.ai/guides/tokenization/ - elif "Mistral" in self.model_name: - self.left_context = "" - else: - self.left_context = "" - - # OPT, Llama and Mistral all insert start token - self.drop_first_token = (self.model_name.startswith("facebook/opt") or - self.model_name.startswith("figmtu/opt") or - "Llama-3.1" in self.model_name or - "Mistral" in self.model_name) - - # Get token id(s) for the left context we condition all sentences on - self.left_context_tokens = self._encode(self.left_context) - print(f"Causal: left_context = '{self.left_context}', left_context_tokens = {self.left_context_tokens}") - - def _encode(self, text: str) -> List[int]: - tokens = self.tokenizer.encode(text) - # Both OPT and Llama automatically insert a start token which we want - # to control ourselves - if len(tokens) > 1 and self.drop_first_token: - tokens = tokens[1:] - - return tokens - - def _sequence_string(self, sequence: List[int]) -> str: - """ - Convert a sequence of subword token IDs into a string with each token in ()'s - :param sequence: List of subword token IDs - :return: String - """ - return "".join([f"({self.index_to_word[x]})" for x in sequence]) - - def get_all_tokens_text(self): - """ - Return an array with the text of all subword tokens. - The array is in order by the integer index into the vocabulary. - This is mostly just for exploring the tokens in different LLMs. - :return: Array of subword token text strings. - """ - result = [] - for i in range(self.vocab_size): - result.append(self.tokenizer.decode([i])) - return result - - def predict(self, evidence: List[str]) -> List[Tuple]: - """ - Given an evidence of typed string, predict the probability distribution of - the next symbol - Args: - evidence - a list of characters (typed by the user) - Response: - A list of symbols with probability - """ - - assert self.model is not None, "language model does not exist!" - start_ns = time.time_ns() - - converted_context = "".join(evidence) - converted_context_lower = converted_context.lower() - context = converted_context.replace(SPACE_CHAR, ' ') - - # If using the simple case feature, we need to go through the actual - # left context and capitalize the first letter in the sentence as - # well as any word in our list of words that should be capitalized. - if self.case_simple and len(context) > 0: - cased_context = "" - words = context.split() - for i, word in enumerate(words): - if i == 0 and word[0] >= 'a' and word[0] <= 'z': - word = word[0].upper() + word[1:] - if i > 0: - if word in self.simple_upper_words: - word = self.simple_upper_words[word] - cased_context += " " - cased_context += word - # Handle ending space in the context - if context[-1] == ' ': - cased_context += " " - context = cased_context - - context_lower = context.lower() - - # Index in the hypothesis string that is the next character after our - # context - target_pos = len(context_lower) - - # For stats purposes track length of the prefix we are extending from space to match - # prefix_len = target_pos - - # Look for the last space in the context, or -1 if no begin_text in - # context yet - pos = context_lower.rfind(" ") - tokens = [] - tokens.extend(self.left_context_tokens) - if pos >= 0: - # Optionally, we condition on upper and lower case left context - if self.mixed_case_context: - truncated_context = context[0:pos] - else: - truncated_context = context_lower[0:pos] - tokens.extend(self._encode(truncated_context)) - # prefix_len -= pos - - # print(f"DEBUG, {context_lower} pos {pos}, prefix_len {prefix_len}") - - # Constant indexes for use with the hypotheses tuples - LOGP: Final[int] = 0 - SEQ: Final[int] = 1 - LEN: Final[int] = 2 - - # Our starting hypothesis that we'll be extending. - # Format is (log likelihood, token id sequence, text length). - # Note: we only include tokens after any in left context. - start_length = 0 - for x in tokens[len(self.left_context_tokens):]: - start_length += len(self.index_to_word_lower[x]) - current_hypos = [(0.0, tokens, start_length)] - - # We use a priority queue to track the top hypotheses during the beam search. - # For a beam of 8, empirical testing showed this was about the same amount - # of time as a simpler list that used a linear search to replace when - # full. - heapq.heapify(current_hypos) - - # Create a hash mapping each valid following character to a list of log - # probabilities - char_to_log_probs = defaultdict(list) - - # Add new extended hypotheses to this heap - next_hypos = [] - - # Tracks count of completed hypotheses - completed = 0 - - # Used to signal to while loop to stop the search - done = False - - # Start a beam search forward from the backed off token sequence. - # Each iteration of this while loop extends hypotheses by all valid tokens. - # We only keep at most self.beam_width hypotheses in the valid heap. - # Stop extending search once we reach our max completed target. - while len(current_hypos) > 0 and not done: - # We'll explore hypothesis in order from most probable to least. - # This has little impact on how long it takes since this is only sorting a small number of things. - # But it is important with max_completed pruning since we want to - # bias for completing high probability things. - current_hypos.sort(reverse=True) - - # Work on the hypotheses from the last round of extension. - # Create the torch tensor for the inference with a row for each - # hypothesis. - tokens_tensor = torch.tensor([x[SEQ] for x in current_hypos]).reshape( - len(current_hypos), -1).to(self.device) - - before_inference_ns = time.time_ns() - # Ask the LLM to predict tokens that come after our current set of - # hypotheses - with torch.no_grad(): - # Compute the probabilities from the logits - log_probs = torch.log_softmax(self.model( - tokens_tensor).logits[:, -1, :], dim=1) - - # Create a big 2D tensor where each row is that hypothesis' current likelihood. - # First create a list of just the hypotheses' likelihoods. - # Then reshape to be a column vector. - # Then duplicate the column based on the number of subword - # tokens in the LLM. - add_tensor = torch.tensor([x[LOGP] for x in current_hypos]).reshape( - (log_probs.size()[0], 1)).repeat(1, log_probs.size()[1]).to(self.device) - - # Add the current likelihoods with each subtoken's probability. - # Move it back to the CPU and convert to numpy since this makes - # it a lot faster to access for some reason. - new_log_probs = torch.add( - log_probs, add_tensor).detach().cpu().numpy() - self.predict_inference_ns += time.time_ns() - before_inference_ns - - for current_index, current in enumerate(current_hypos): - vocab = [] - extra_vocab = [] - # Extending this hypothesis must match the remaining text - remaining_context = converted_context_lower[current[LEN]:] - if len(remaining_context) == 0: - # There is no remaining context thus all subword tokens that are valid under our symbol set - # should be considered when computing the probability of - # the next character. - vocab = self.valid_vocab - else: - if remaining_context in self.vocab: - # We have a list of subword tokens that match the remaining text. - # They could be the same length as the remaining text - # or longer and have the remaining text as a prefix. - vocab = self.vocab[remaining_context] - - # We may need to use a subword token that doesn't completely consume the remaining text. - # Find these by tokenizing all possible lengths of text - # starting from the current position. - for i in range(1, len(remaining_context)): - tokenization = self._encode( - context_lower[current[LEN]:current[LEN] + i]) - # Ignore tokenizations involving multiple tokens since - # they involve an ID we would have already added. - if len(tokenization) == 1: - extra_vocab += tokenization[0], - - # The below code takes the most time, results from pprofile on 5 phrases on an 2080 GPU: - # 299| 22484582| 89.5763| 3.9839e-06| 14.24%| for token_id in itertools.chain(vocab, extra_vocab): - # 300| 0| 0| 0| 0.00%| # For a hypothesis to finish it must extend beyond the existing typed context - # 301| 22483271| 93.7939| 4.17172e-06| 14.91%| subword_len = len(self.index_to_word_lower[token_id]) - # 302| 22483271| 92.8608| 4.13022e-06| 14.76%| if (current[LEN] + subword_len) > len(context): - # 303| 0| 0| 0| 0.00%| # Add this likelihood to the list for the character at the prediction position. - # 304| 0| 0| 0| 0.00%| # Tracking the list and doing logsumpexp later was faster than doing it for each add. - # 305| 22480431| 106.353| 4.73094e-06| 16.90%| char_to_log_probs[self.index_to_word_lower[token_id][target_pos - current[LEN]]] += new_log_probs[current_index][token_id], - # 306| 22480431| 92.689| 4.1231e-06| 14.73%| completed += 1 - # 307| 2840| 0.0124488| 4.38338e-06| 0.00%| elif not self.beam_width or len(next_hypos) < - # - # Tuning notes: - # - With a beam of 8 and max completed of 32,000, getting around 5x speedup on written dev set. - # - This results in a PPL increase of 0.0025 versus old results using only beam of >= 8. - # - Pruning based on log probability difference and based on minimum number of hypotheses per symbol in alphabet did worse. - # - Code for these other pruning methods was removed. - # Possible ways to make it faster: - # - Stop part way through the below for loop over (vocab, extra_vocab). But this seems weird since the token IDs are in - # no particular order, we'd be just stopping early on the last hypothesis being explored by the enclosing loop. - # - Sort the rows in the log prob results on the GPU. Use these to limit which token IDs we explore in the below - # for loop. Is it possible to do this without introducing too - # much extra work to limit to the high probability ones? - - # Create a list of token indexes that are a prefix of the target text. - # We go over all the integer IDs in the vocab and extra_vocab - # lists. - for token_id in itertools.chain(vocab, extra_vocab): - # For a hypothesis to finish it must extend beyond the - # existing typed context - subword_len = len(self.index_to_word_lower[token_id]) - if (current[LEN] + subword_len) > len(context): - # Add this likelihood to the list for the character at the prediction position. - # Tracking the list and doing logsumpexp later was - # faster than doing it for each add. - char_to_log_probs[self.index_to_word_lower[token_id][target_pos - - current[LEN]]] += new_log_probs[current_index][token_id], - completed += 1 - elif not self.beam_width or len(next_hypos) < self.beam_width: - # If we are under the beam limit then just add it - heapq.heappush(next_hypos, - (new_log_probs[current_index][token_id], - current[SEQ] + [token_id], - current[LEN] + subword_len)) - elif new_log_probs[current_index][token_id] > next_hypos[0][LOGP]: - # Or replace the worst hypotheses with the new one - heapq.heappushpop(next_hypos, - (new_log_probs[current_index][token_id], - current[SEQ] + [token_id], - current[LEN] + subword_len)) - - # Break out of the for loop over hypotheses and while loop if - # we reach our max completed goal - if self.max_completed and completed >= self.max_completed: - done = True - break - - # Swap in the extended set as the new current working set - current_hypos = next_hypos - next_hypos = [] - - # Parallel array to symbol_set for storing the marginals - char_probs = [] - for ch in self.symbol_set_lower: - # Convert space to the underscore used in BciPy - if ch == SPACE_CHAR: - target_ch = ' ' - else: - target_ch = ch - - # Handle cases when symbols are never seen - if target_ch in char_to_log_probs: - char_probs += logsumexp(char_to_log_probs[target_ch]), - else: - char_probs += float("-inf"), - - # Normalize to a distribution that sums to 1 - char_probs = softmax(char_probs) - - next_char_pred = {} - for i, ch in enumerate(self.symbol_set_lower): - if ch is SPACE_CHAR: - next_char_pred[ch] = char_probs[i] - else: - next_char_pred[ch.upper()] = char_probs[i] - next_char_pred[BACKSPACE_CHAR] = 0.0 - - end_ns = time.time_ns() - self.predict_total_ns += end_ns - start_ns - - return list(sorted(next_char_pred.items(), - key=lambda item: item[1], reverse=True)) - - def dump_predict_times(self) -> None: - """Print some stats about the prediction timing""" - if self.predict_total_ns > 0: - print(f"Predict %: " - f"inference {self.predict_inference_ns / self.predict_total_ns * 100.0:.3f}") - - def update(self) -> None: - """Update the model state""" - ... - - def load(self) -> None: - """ - Load the language model and tokenizer, initialize class variables - """ - try: - self.tokenizer = AutoTokenizer.from_pretrained( - self.model_name, use_fast=False) - except BaseException: - raise InvalidLanguageModelException( - f"{self.model_name} is not a valid model identifier on HuggingFace.") - self.vocab_size = self.tokenizer.vocab_size - try: - self.model = AutoModelForCausalLM.from_pretrained(self.model_dir) - if self.fp16 and self.device == "cuda": - self.model = self.model.half() - except BaseException: - raise InvalidLanguageModelException( - f"{self.model_dir} is not a valid local folder or model identifier on HuggingFace.") - - self.model.eval() - - self.model.to(self.device) - - self.symbol_set_lower = [] - - for ch in self.symbol_set: - if ch is SPACE_CHAR: - self.symbol_set_lower.append(SPACE_CHAR) - elif ch is BACKSPACE_CHAR: - continue - else: - self.symbol_set_lower.append(ch.lower()) - - self._build_vocab() - - def get_num_parameters(self) -> int: - """ - Find out how many parameters the loaded model has - Args: - Response: - Integer number of parameters in the transformer model - """ - return sum(p.numel() for p in self.model.parameters()) - - def state_update(self, evidence: List[str]) -> List[Tuple]: - """ - Wrapper method that takes in evidence text, and output probability distribution - of next character - Args: - evidence - a list of characters (typed by the user) - Response: - A list of symbol with probability - """ - next_char_pred = self.predict(evidence) + self.lm_device = lm_device + self.lm_left_context = lm_left_context + self.fp16 = fp16 + self.mixed_case_context = mixed_case_context + self.case_simple = case_simple - return next_char_pred + def _load_model(self) -> None: + """Load the model itself using stored parameters""" + + self.model = CausalLanguageModel( + symbol_set=self.model_symbol_set, + lang_model_name=self.model_name, + lm_path=self.model_dir, + lm_device=self.lm_device, + lm_left_context=self.lm_left_context, + beam_width=self.beam_width, + fp16=self.fp16, + mixed_case_context=self.mixed_case_context, + case_simple=self.case_simple, + max_completed=self.max_completed) diff --git a/bcipy/language/model/kenlm.py b/bcipy/language/model/kenlm.py deleted file mode 100644 index 05b9bbf13..000000000 --- a/bcipy/language/model/kenlm.py +++ /dev/null @@ -1,137 +0,0 @@ -from collections import Counter -from typing import Optional, List, Tuple -from bcipy.core.symbols import BACKSPACE_CHAR, SPACE_CHAR -from bcipy.language.main import LanguageModel, ResponseType -from bcipy.exceptions import InvalidLanguageModelException, KenLMInstallationException -from bcipy.config import LM_PATH -try: - import kenlm -except BaseException: - raise KenLMInstallationException( - "Please install the requisite kenlm package:\n'pip install kenlm==0.1 --global-option=\"--max_order=12\"") -import numpy as np - - -class KenLMLanguageModel(LanguageModel): - """Character n-gram language model using the KenLM library for querying""" - - def __init__(self, response_type: ResponseType, symbol_set: List[str], lm_path: Optional[str] = None): - - super().__init__(response_type=response_type, symbol_set=symbol_set) - self.model = None - kenlm_params = self.parameters['kenlm'] - kenlm_model = kenlm_params['model_file']['value'] - self.lm_path = lm_path or f"{LM_PATH}/{kenlm_model}" - - self.load() - - def supported_response_types(self) -> List[ResponseType]: - return [ResponseType.SYMBOL] - - def predict(self, evidence: List[str]) -> List[Tuple]: - """ - Given an evidence of typed string, predict the probability distribution of - the next symbol - Args: - evidence - a list of characters (typed by the user) - Response: - A list of symbols with probability - """ - - # Do not modify the original parameter, could affect mixture model - context = evidence.copy() - - if len(context) > 11: - context = context[-11:] - - evidence_str = ''.join(context).lower() - - for i, ch in enumerate(context): - if ch == SPACE_CHAR: - context[i] = "" - - self.model.BeginSentenceWrite(self.state) - - # Update the state one token at a time based on evidence, alternate states - for i, token in enumerate(context): - if i % 2 == 0: - self.model.BaseScore(self.state, token.lower(), self.state2) - else: - self.model.BaseScore(self.state2, token.lower(), self.state) - - next_char_pred = None - - # Generate the probability distribution based on the final state - if len(context) % 2 == 0: - next_char_pred = self.prob_dist(self.state) - else: - next_char_pred = self.prob_dist(self.state2) - - return next_char_pred - - def update(self) -> None: - """Update the model state""" - ... - - def load(self) -> None: - """ - Load the language model, initialize state variables - Args: - path: language model file path - """ - - try: - self.model = kenlm.LanguageModel(self.lm_path) - except BaseException: - raise InvalidLanguageModelException( - f"A valid model path must be provided for the KenLMLanguageModel.\nPath{self.lm_path} is not valid.") - - self.state = kenlm.State() - self.state2 = kenlm.State() - - def state_update(self, evidence: List[str]) -> List[Tuple]: - """ - Wrapper method that takes in evidence text and outputs probability distribution - of next character - Args: - evidence - a list of characters (typed by the user) - Response: - A list of symbols with probabilities - """ - next_char_pred = self.predict(evidence) - - return next_char_pred - - def prob_dist(self, state: kenlm.State) -> List[Tuple]: - """ - Take in a state and generate the probability distribution of next character - Args: - state - the kenlm state updated with the evidence - Response: - A list of symbols with probability - """ - next_char_pred = Counter() - - temp_state = kenlm.State() - - for char in self.symbol_set: - # Backspace probability under the LM is 0 - if char == BACKSPACE_CHAR: - next - - score = 0.0 - - # Replace the space character with KenLM's token - if char == SPACE_CHAR: - score = self.model.BaseScore(state, '', temp_state) - else: - score = self.model.BaseScore(state, char.lower(), temp_state) - - # BaseScore returns log probs, convert by putting 10 to its power - next_char_pred[char] = pow(10, score) - - sum = np.sum(list(next_char_pred.values())) - for char in self.symbol_set: - next_char_pred[char] /= sum - - return list(sorted(next_char_pred.items(), key=lambda item: item[1], reverse=True)) diff --git a/bcipy/language/model/mixture.py b/bcipy/language/model/mixture.py index 36e8cdd43..f337ca677 100644 --- a/bcipy/language/model/mixture.py +++ b/bcipy/language/model/mixture.py @@ -1,61 +1,25 @@ -from collections import Counter -from typing import Optional, Dict, List, Tuple -from math import isclose +from typing import Dict, List, Optional -from bcipy.language.main import LanguageModel, ResponseType +from textslinger.mixture import MixtureLanguageModel -from bcipy.exceptions import InvalidLanguageModelException +from bcipy.config import LM_PATH +from bcipy.language.model.adapter import LanguageModelAdapter -# pylint: disable=unused-import -# flake8: noqa -"""All supported models must be imported""" -from bcipy.language.model.causal import CausalLanguageModel -from bcipy.language.model.kenlm import KenLMLanguageModel - -class MixtureLanguageModel(LanguageModel): +class MixtureLanguageModelAdapter(LanguageModelAdapter): """ Character language model that mixes any combination of other models """ - supported_lm_types = ["CAUSAL", "KENLM"] - - @staticmethod - def language_models_by_name() -> Dict[str, LanguageModel]: - """Returns available language models indexed by name.""" - return {lm.name(): lm for lm in LanguageModel.__subclasses__()} - - @staticmethod - def validate_parameters(types: List[str], weights: List[float], params: List[Dict[str, str]]): - if params is not None: - if (types is None) or (len(types) != len(params)): - raise InvalidLanguageModelException("Length of parameters does not match length of types") - - if weights is not None: - if (types is None) or (len(types) != len(weights)): - raise InvalidLanguageModelException("Length of weights does not match length of types") - if not isclose(sum(weights), 1.0, abs_tol=1e-05): - raise InvalidLanguageModelException("Weights do not sum to 1") - - if types is not None: - if weights is None: - raise InvalidLanguageModelException("Model weights not provided") - if params is None: - raise InvalidLanguageModelException("Model parameters not provided") - if not all(x in MixtureLanguageModel.supported_lm_types for x in types): - raise InvalidLanguageModelException(f"Supported model types: {MixtureLanguageModel.supported_lm_types}") + supported_lm_types = MixtureLanguageModel.supported_lm_types def __init__(self, - response_type: ResponseType, - symbol_set: List[str], lm_types: Optional[List[str]] = None, lm_weights: Optional[List[float]] = None, lm_params: Optional[List[Dict[str, str]]] = None): """ - Initialize instance variables and load the language model with given path + Initialize instance variables and load parameters Args: - response_type - SYMBOL only - symbol_set - list of symbol strings lm_types - list of types of models to mix lm_weights - list of weights to use when mixing the models lm_params - list of dictionaries to pass as parameters for each model's instantiation @@ -63,90 +27,20 @@ def __init__(self, MixtureLanguageModel.validate_parameters(lm_types, lm_weights, lm_params) - super().__init__(response_type=response_type, symbol_set=symbol_set) - self.models = list() - self.response_type = response_type - self.symbol_set = symbol_set + self._load_parameters() mixture_params = self.parameters['mixture'] self.lm_types = lm_types or mixture_params['model_types']['value'] self.lm_weights = lm_weights or mixture_params['model_weights']['value'] self.lm_params = lm_params or mixture_params['model_params']['value'] - MixtureLanguageModel.validate_parameters(self.lm_types, self.lm_weights, self.lm_params) - - self.load() - - def supported_response_types(self) -> List[ResponseType]: - return [ResponseType.SYMBOL] - - @staticmethod - def interpolate_language_models(lms: List[Dict[str, float]], coeffs: List[float]) -> List[Tuple]: - """ - interpolate two or more language models - Args: - lms - output from the language models (a list of dicts with char as keys and prob as values) - coeffs - list of rescale coefficients, lms[0] will be scaled by coeffs[0] and so on - Response: - a list of (char, prob) tuples representing an interpolated language model - """ - combined_lm = Counter() - - for i, lm in enumerate(lms): - for char in lm: - combined_lm[char] += lm[char] * coeffs[i] - - return list(sorted(combined_lm.items(), key=lambda item: item[1], reverse=True)) - - def predict(self, evidence: List[str]) -> List[Tuple]: - """ - Given an evidence of typed string, predict the probability distribution of - the next symbol - Args: - evidence - a list of characters (typed by the user) - Response: - A list of symbols with probability - """ - - pred_list = list() - - # Generate predictions from each component language model - pred_list = [dict(model.predict(evidence)) for model in self.models] - - # Mix the component models - next_char_pred = MixtureLanguageModel.interpolate_language_models(pred_list, self.lm_weights) - - return next_char_pred - - def update(self) -> None: - """Update the model state""" - ... - - def load(self) -> None: - """ - Load the language models to be mixed - """ - - language_models = MixtureLanguageModel.language_models_by_name() - for lm_type, params in zip(self.lm_types, self.lm_params): - model = language_models[lm_type] - lm = None - try: - lm = model(self.response_type, self.symbol_set, **params) - except InvalidLanguageModelException as e: - raise InvalidLanguageModelException(f"Error in creation of model type {lm_type}: {e.message}") - - self.models.append(lm) + for type, params in zip(self.lm_types, self.lm_params): + if type == "NGRAM": + params["lm_path"] = f"{LM_PATH}/{params['lm_path']}" - def state_update(self, evidence: List[str]) -> List[Tuple]: - """ - Wrapper method that takes in evidence text, and output probability distribution - of next character - Args: - evidence - a list of characters (typed by the user) - Response: - A list of symbol with probability - """ - next_char_pred = self.predict(evidence) + MixtureLanguageModel.validate_parameters(self.lm_types, self.lm_weights, self.lm_params) - return next_char_pred + def _load_model(self) -> None: + """Load the model itself using stored parameters""" + self.model = MixtureLanguageModel(self.model_symbol_set, self.lm_types, + self.lm_weights, self.lm_params) diff --git a/bcipy/language/model/ngram.py b/bcipy/language/model/ngram.py new file mode 100644 index 000000000..226860a4d --- /dev/null +++ b/bcipy/language/model/ngram.py @@ -0,0 +1,29 @@ +from typing import Optional + +from textslinger.ngram import NGramLanguageModel + +from bcipy.config import LM_PATH +from bcipy.language.model.adapter import LanguageModelAdapter + + +class NGramLanguageModelAdapter(LanguageModelAdapter): + """Character n-gram language model using the KenLM library for querying""" + + def __init__(self, + lm_path: Optional[str] = None): + """ + Initialize instance variables and load parameters + Args: + lm_path - location of local ngram model - loaded from parameters if None + """ + + self._load_parameters() + + ngram_params = self.parameters['ngram'] + ngram_model = ngram_params['model_file']['value'] + self.lm_path = lm_path or f"{LM_PATH}/{ngram_model}" + + def _load_model(self) -> None: + """Load the model itself using stored parameters""" + self.model = NGramLanguageModel(symbol_set=self.model_symbol_set, + lm_path=self.lm_path) diff --git a/bcipy/language/model/oracle.py b/bcipy/language/model/oracle.py index 73896fb5a..c1f1157ea 100644 --- a/bcipy/language/model/oracle.py +++ b/bcipy/language/model/oracle.py @@ -5,17 +5,18 @@ import numpy as np from bcipy.config import SESSION_LOG_FILENAME -from bcipy.core.symbols import BACKSPACE_CHAR -from bcipy.language.main import LanguageModel, ResponseType +from bcipy.core.symbols import BACKSPACE_CHAR, DEFAULT_SYMBOL_SET +from bcipy.exceptions import InvalidSymbolSetException +from bcipy.language.main import CharacterLanguageModel from bcipy.language.model.uniform import equally_probable logger = logging.getLogger(SESSION_LOG_FILENAME) TARGET_BUMP_MIN = 0.0 -TARGET_BUMP_MAX = 0.95 +TARGET_BUMP_MAX = 1.0 -class OracleLanguageModel(LanguageModel): +class OracleLanguageModel(CharacterLanguageModel): """Language model which knows the target phrase the user is attempting to spell. @@ -28,25 +29,28 @@ class OracleLanguageModel(LanguageModel): Parameters ---------- - response_type - SYMBOL only - symbol_set - optional specify the symbol set, otherwise uses DEFAULT_SYMBOL_SET task_text - the phrase the user is attempting to spell (ex. 'HELLO_WORLD') target_bump - the amount by which the probability of the target letter is increased. """ def __init__(self, - response_type: Optional[ResponseType] = None, - symbol_set: Optional[List[str]] = None, - task_text: str = None, + task_text: Optional[str] = None, target_bump: float = 0.1): - super().__init__(response_type=response_type, symbol_set=symbol_set) + self.task_text = task_text self.target_bump = target_bump + + self.symbol_set = DEFAULT_SYMBOL_SET + logger.debug( f"Initialized OracleLanguageModel(task_text='{task_text}', target_bump={target_bump})" ) + def set_symbol_set(self, symbol_set: List[str]) -> None: + """Updates the symbol set of the model. Must be called prior to prediction""" + self.symbol_set = symbol_set + @property def task_text(self): """Get the task_text property""" @@ -70,10 +74,7 @@ def target_bump(self, value: float): assert TARGET_BUMP_MIN <= value <= TARGET_BUMP_MAX, msg self._target_bump = value - def supported_response_types(self) -> List[ResponseType]: - return [ResponseType.SYMBOL] - - def predict(self, evidence: Union[str, List[str]]) -> List[Tuple]: + def predict_character(self, evidence: Union[str, List[str]]) -> List[Tuple]: """ Using the provided data, compute probabilities over the entire symbol. set. @@ -86,25 +87,32 @@ def predict(self, evidence: Union[str, List[str]]) -> List[Tuple]: ------- list of (symbol, probability) tuples """ + + if not self.symbol_set: + raise InvalidSymbolSetException( + "symbol set must be set prior to requesting predictions.") + spelled_text = ''.join(evidence) - probs = equally_probable(self.symbol_set) - symbol_probs = list(zip(self.symbol_set, probs)) target = self._next_target(spelled_text) + symbol_probs = {} + if target: - sym = (target, probs[0] + self.target_bump) - updated_symbol_probs = with_min_prob(symbol_probs, sym) + # non-target prob = x = (1-b)/n where n is len(symbol_set) and b is target_bump + # target prob = x + b + non_target_prob = (1 - self.target_bump) / len(self.symbol_set) + for ch in self.symbol_set: + if ch == target: + symbol_probs[ch] = non_target_prob + self.target_bump + else: + symbol_probs[ch] = non_target_prob else: - updated_symbol_probs = symbol_probs - - return sorted(updated_symbol_probs, - key=lambda pair: self.symbol_set.index(pair[0])) - - def update(self) -> None: - """Update the model state""" + symbol_probs = dict( + zip(self.symbol_set, equally_probable(self.symbol_set))) - def load(self) -> None: - """Restore model state from the provided checkpoint""" + return sorted(symbol_probs.items(), + key=lambda item: item[1], + reverse=True) def _next_target(self, spelled_text: str) -> Optional[str]: """Computes the next target letter based on the currently spelled_text. diff --git a/bcipy/language/model/uniform.py b/bcipy/language/model/uniform.py index 013735ba6..e2dc52e0c 100644 --- a/bcipy/language/model/uniform.py +++ b/bcipy/language/model/uniform.py @@ -1,30 +1,34 @@ """Uniform language model""" -from typing import Dict, List, Tuple, Union, Optional +from typing import Dict, List, Optional, Tuple, Union import numpy as np -from bcipy.language.main import LanguageModel, ResponseType +from bcipy.core.symbols import BACKSPACE_CHAR, DEFAULT_SYMBOL_SET +from bcipy.exceptions import InvalidSymbolSetException +from bcipy.language.main import CharacterLanguageModel -class UniformLanguageModel(LanguageModel): +class UniformLanguageModel(CharacterLanguageModel): """Language model in which probabilities for symbols are uniformly distributed. Parameters ---------- - response_type - SYMBOL only - symbol_set - optional specify the symbol set, otherwise uses DEFAULT_SYMBOL_SET + None """ - def __init__(self, - response_type: Optional[ResponseType] = None, - symbol_set: Optional[List[str]] = None): - super().__init__(response_type=response_type, symbol_set=symbol_set) + def __init__(self): + self.set_symbol_set(DEFAULT_SYMBOL_SET) - def supported_response_types(self) -> List[ResponseType]: - return [ResponseType.SYMBOL] + def set_symbol_set(self, symbol_set: List[str]) -> None: + """Updates the symbol set of the model. Must be called prior to prediction""" + self.symbol_set = symbol_set - def predict(self, evidence: Union[str, List[str]]) -> List[Tuple]: + self.model_symbol_set = [ch for ch in symbol_set] + if BACKSPACE_CHAR in symbol_set: + self.model_symbol_set.remove(BACKSPACE_CHAR) + + def predict_character(self, evidence: Union[str, List[str]]) -> List[Tuple]: """ Using the provided data, compute probabilities over the entire symbol. set. @@ -37,18 +41,19 @@ def predict(self, evidence: Union[str, List[str]]) -> List[Tuple]: ------- list of (symbol, probability) tuples """ - probs = equally_probable(self.symbol_set) - return list(zip(self.symbol_set, probs)) - def update(self) -> None: - """Update the model state""" + if not self.symbol_set: + raise InvalidSymbolSetException( + "symbol set must be set prior to requesting predictions.") - def load(self) -> None: - """Restore model state from the provided checkpoint""" + probs = equally_probable(self.model_symbol_set) + return list(zip(self.model_symbol_set, probs)) + [(BACKSPACE_CHAR, 0.0) + ] -def equally_probable(alphabet: List[str], - specified: Optional[Dict[str, float]] = None) -> List[float]: +def equally_probable( + alphabet: List[str], + specified: Optional[Dict[str, float]] = None) -> List[float]: """Returns a list of probabilities which correspond to the provided alphabet. Unless overridden by the specified values, all items will have the same probability. All probabilities sum to 1.0. diff --git a/bcipy/language/tests/test_causal.py b/bcipy/language/tests/test_causal.py index da685cb84..e1f13467f 100644 --- a/bcipy/language/tests/test_causal.py +++ b/bcipy/language/tests/test_causal.py @@ -4,114 +4,110 @@ import unittest from operator import itemgetter -from bcipy.exceptions import UnsupportedResponseType, InvalidLanguageModelException -from bcipy.core.symbols import alphabet, BACKSPACE_CHAR, SPACE_CHAR -from bcipy.language.model.causal import CausalLanguageModel -from bcipy.language.main import ResponseType +from bcipy.exceptions import InvalidSymbolSetException +from bcipy.core.symbols import DEFAULT_SYMBOL_SET, BACKSPACE_CHAR, SPACE_CHAR +from bcipy.language.model.causal import CausalLanguageModelAdapter +from bcipy.language.main import CharacterLanguageModel + +from textslinger.exceptions import InvalidLanguageModelException @pytest.mark.slow -class TestCausalLanguageModel(unittest.TestCase): +class TestCausalLanguageModelAdapter(unittest.TestCase): """Tests for language model""" @classmethod def setUpClass(cls): - cls.gpt2_model = CausalLanguageModel(response_type=ResponseType.SYMBOL, - symbol_set=alphabet(), lang_model_name="gpt2") - cls.opt_model = CausalLanguageModel(response_type=ResponseType.SYMBOL, - symbol_set=alphabet(), lang_model_name="facebook/opt-125m") + cls.gpt2_model = CausalLanguageModelAdapter(lang_model_name="gpt2") + cls.gpt2_model.set_symbol_set(DEFAULT_SYMBOL_SET) + + cls.opt_model = CausalLanguageModelAdapter(lang_model_name="facebook/opt-125m") + cls.opt_model.set_symbol_set(DEFAULT_SYMBOL_SET) @pytest.mark.slow def test_default_load(self): """Test loading model with parameters from json This test requires a valid lm_params.json file and all requisite models""" - lm = CausalLanguageModel(response_type=ResponseType.SYMBOL, symbol_set=alphabet()) + lm = CausalLanguageModelAdapter() + lm.set_symbol_set(DEFAULT_SYMBOL_SET) def test_gpt2_init(self): """Test default parameters for GPT-2 model""" - self.assertEqual(self.gpt2_model.response_type, ResponseType.SYMBOL) - self.assertEqual(self.gpt2_model.symbol_set, alphabet()) - self.assertTrue( - ResponseType.SYMBOL in self.gpt2_model.supported_response_types()) - self.assertEqual(self.gpt2_model.left_context, "<|endoftext|>") - self.assertEqual(self.gpt2_model.device, "cpu") + self.assertEqual(self.gpt2_model.symbol_set, DEFAULT_SYMBOL_SET) + self.assertTrue(isinstance(self.gpt2_model, CharacterLanguageModel)) + self.assertEqual(self.gpt2_model.model.left_context, "<|endoftext|>") + self.assertEqual(self.gpt2_model.model.device, "cpu") def test_opt_init(self): """Test default parameters for Facebook OPT model""" - self.assertEqual(self.opt_model.response_type, ResponseType.SYMBOL) - self.assertEqual(self.opt_model.symbol_set, alphabet()) - self.assertTrue( - ResponseType.SYMBOL in self.opt_model.supported_response_types()) - self.assertEqual(self.opt_model.left_context, "") - self.assertEqual(self.opt_model.device, "cpu") - - def test_name(self): - """Test model name.""" - self.assertEqual("CAUSAL", CausalLanguageModel.name()) + self.assertEqual(self.opt_model.symbol_set, DEFAULT_SYMBOL_SET) + self.assertTrue(isinstance(self.opt_model, CharacterLanguageModel)) + self.assertEqual(self.opt_model.model.left_context, "") + self.assertEqual(self.opt_model.model.device, "cpu") - def test_unsupported_response_type(self): + def test_invalid_symbol_set(self): """Unsupported responses should raise an exception""" - with self.assertRaises(UnsupportedResponseType): - CausalLanguageModel(response_type=ResponseType.WORD, - symbol_set=alphabet(), lang_model_name="gpt2") + with self.assertRaises(InvalidSymbolSetException): + lm = CausalLanguageModelAdapter(lang_model_name="gpt2") + lm.predict_character("this_should_fail") def test_invalid_model_name(self): """Test that the proper exception is thrown if given an invalid lang_model_name""" with self.assertRaises(InvalidLanguageModelException): - CausalLanguageModel(response_type=ResponseType.SYMBOL, symbol_set=alphabet(), - lang_model_name="phonymodel") + lm = CausalLanguageModelAdapter(lang_model_name="phonymodel") + lm.set_symbol_set(DEFAULT_SYMBOL_SET) def test_invalid_model_path(self): """Test that the proper exception is thrown if given an invalid lm_path""" with self.assertRaises(InvalidLanguageModelException): - CausalLanguageModel(response_type=ResponseType.SYMBOL, symbol_set=alphabet(), - lang_model_name="gpt2", lm_path="./phonypath/") + lm = CausalLanguageModelAdapter(lang_model_name="gpt2", lm_path="./phonypath/") + lm.set_symbol_set(DEFAULT_SYMBOL_SET) def test_non_mutable_evidence(self): """Test that the model does not change the evidence variable passed in. This could impact the mixture model if failed""" evidence = list("Test_test") evidence2 = list("Test_test") - self.gpt2_model.predict(evidence) + self.gpt2_model.predict_character(evidence) self.assertEqual(evidence, evidence2) - self.opt_model.predict(evidence) + self.opt_model.predict_character(evidence) self.assertEqual(evidence, evidence2) def test_gpt2_identical(self): """Ensure predictions are the same for subsequent queries with the same evidence.""" - query1 = self.gpt2_model.predict(list("evidenc")) - query2 = self.gpt2_model.predict(list("evidenc")) + query1 = self.gpt2_model.predict_character(list("evidenc")) + query2 = self.gpt2_model.predict_character(list("evidenc")) for ((sym1, prob1), (sym2, prob2)) in zip(query1, query2): self.assertAlmostEqual(prob1, prob2, places=5) self.assertEqual(sym1, sym2) def test_opt_identical(self): """Ensure predictions are the same for subsequent queries with the same evidence.""" - query1 = self.opt_model.predict(list("evidenc")) - query2 = self.opt_model.predict(list("evidenc")) + query1 = self.opt_model.predict_character(list("evidenc")) + query2 = self.opt_model.predict_character(list("evidenc")) for ((sym1, prob1), (sym2, prob2)) in zip(query1, query2): self.assertAlmostEqual(prob1, prob2, places=5) self.assertEqual(sym1, sym2) def test_gpt2_upper_lower_case(self): """Ensure predictions are the same for upper or lower case evidence.""" - lc = self.gpt2_model.predict(list("EVIDENC")) - uc = self.gpt2_model.predict(list("evidenc")) + lc = self.gpt2_model.predict_character(list("EVIDENC")) + uc = self.gpt2_model.predict_character(list("evidenc")) for ((l_sym, l_prob), (u_sym, u_prob)) in zip(lc, uc): self.assertAlmostEqual(l_prob, u_prob, places=5) self.assertEqual(l_sym, u_sym) def test_opt_upper_lower_case(self): """Ensure predictions are the same for upper or lower case evidence.""" - lc = self.opt_model.predict(list("EVIDENC")) - uc = self.opt_model.predict(list("evidenc")) + lc = self.opt_model.predict_character(list("EVIDENC")) + uc = self.opt_model.predict_character(list("evidenc")) for ((l_sym, l_prob), (u_sym, u_prob)) in zip(lc, uc): self.assertAlmostEqual(l_prob, u_prob, places=5) self.assertEqual(l_sym, u_sym) def test_gpt2_predict_start_of_word(self): """Test the gpt2 predict method with no prior evidence.""" - symbol_probs = self.gpt2_model.predict(evidence=[]) + symbol_probs = self.gpt2_model.predict_character(evidence=[]) probs = [prob for sym, prob in symbol_probs] self.assertTrue( @@ -126,7 +122,7 @@ def test_gpt2_predict_start_of_word(self): def test_opt_predict_start_of_word(self): """Test the Facebook opt predict method with no prior evidence.""" - symbol_probs = self.opt_model.predict(evidence=[]) + symbol_probs = self.opt_model.predict_character(evidence=[]) probs = [prob for sym, prob in symbol_probs] self.assertTrue( @@ -141,7 +137,7 @@ def test_opt_predict_start_of_word(self): def test_gpt2_predict_middle_of_word(self): """Test the predict method in the middle of a word with gpt2 model.""" - symbol_probs = self.gpt2_model.predict(evidence=list("TH")) + symbol_probs = self.gpt2_model.predict_character(evidence=list("TH")) probs = [prob for sym, prob in symbol_probs] self.assertTrue( @@ -159,7 +155,7 @@ def test_gpt2_predict_middle_of_word(self): def test_opt_predict_middle_of_word(self): """Test the predict method in the middle of a word with Facebook opt model.""" - symbol_probs = self.opt_model.predict(evidence=list("TH")) + symbol_probs = self.opt_model.predict_character(evidence=list("TH")) probs = [prob for sym, prob in symbol_probs] self.assertTrue( @@ -177,7 +173,7 @@ def test_opt_predict_middle_of_word(self): def test_gpt2_phrase(self): """Test that a phrase can be used for input with gpt2 model""" - symbol_probs = self.gpt2_model.predict(list("does_it_make_sen")) + symbol_probs = self.gpt2_model.predict_character(list("does_it_make_sen")) most_likely_sym, _prob = sorted(symbol_probs, key=itemgetter(1), reverse=True)[0] @@ -185,7 +181,7 @@ def test_gpt2_phrase(self): def test_opt_phrase(self): """Test that a phrase can be used for input with Facebook opt model""" - symbol_probs = self.opt_model.predict(list("does_it_make_sen")) + symbol_probs = self.opt_model.predict_character(list("does_it_make_sen")) most_likely_sym, _prob = sorted(symbol_probs, key=itemgetter(1), reverse=True)[0] @@ -193,30 +189,30 @@ def test_opt_phrase(self): def test_gpt2_multiple_spaces(self): """Test that the probability of space after a space is smaller than before the space""" - symbol_probs_before = self.gpt2_model.predict(list("the")) - symbol_probs_after = self.gpt2_model.predict(list("the_")) + symbol_probs_before = self.gpt2_model.predict_character(list("the")) + symbol_probs_after = self.gpt2_model.predict_character(list("the_")) space_prob_before = (dict(symbol_probs_before))[SPACE_CHAR] space_prob_after = (dict(symbol_probs_after))[SPACE_CHAR] self.assertTrue(space_prob_before > space_prob_after) def test_opt_multiple_spaces(self): """Test that the probability of space after a space is smaller than before the space""" - symbol_probs_before = self.opt_model.predict(list("the")) - symbol_probs_after = self.opt_model.predict(list("the_")) + symbol_probs_before = self.opt_model.predict_character(list("the")) + symbol_probs_after = self.opt_model.predict_character(list("the_")) space_prob_before = (dict(symbol_probs_before))[SPACE_CHAR] space_prob_after = (dict(symbol_probs_after))[SPACE_CHAR] self.assertTrue(space_prob_before > space_prob_after) def test_gpt2_nonzero_prob(self): """Test that all letters in the alphabet have nonzero probability except for backspace""" - symbol_probs = self.gpt2_model.predict(list("does_it_make_sens")) + symbol_probs = self.gpt2_model.predict_character(list("does_it_make_sens")) prob_values = [item[1] for item in symbol_probs if item[0] != BACKSPACE_CHAR] for value in prob_values: self.assertTrue(value > 0) def test_opt_nonzero_prob(self): """Test that all letters in the alphabet have nonzero probability except for backspace""" - symbol_probs = self.opt_model.predict(list("does_it_make_sens")) + symbol_probs = self.opt_model.predict_character(list("does_it_make_sens")) prob_values = [item[1] for item in symbol_probs if item[0] != BACKSPACE_CHAR] for value in prob_values: self.assertTrue(value > 0) diff --git a/bcipy/language/tests/test_mixture.py b/bcipy/language/tests/test_mixture.py index aefe5b3a8..107cfc2cc 100644 --- a/bcipy/language/tests/test_mixture.py +++ b/bcipy/language/tests/test_mixture.py @@ -1,108 +1,110 @@ """Tests for MIXTURE Language Model""" -import pytest -import unittest import os +import unittest from operator import itemgetter -from bcipy.exceptions import UnsupportedResponseType, InvalidLanguageModelException -from bcipy.core.symbols import alphabet, BACKSPACE_CHAR, SPACE_CHAR -from bcipy.language.model.mixture import MixtureLanguageModel -from bcipy.language.main import ResponseType +import pytest +from textslinger.exceptions import InvalidLanguageModelException + +from bcipy.core.symbols import BACKSPACE_CHAR, DEFAULT_SYMBOL_SET, SPACE_CHAR +from bcipy.exceptions import InvalidSymbolSetException +from bcipy.language.main import CharacterLanguageModel +from bcipy.language.model.mixture import MixtureLanguageModelAdapter @pytest.mark.slow -class TestMixtureLanguageModel(unittest.TestCase): +class TestMixtureLanguageModelAdapter(unittest.TestCase): """Tests for language model""" @classmethod def setUpClass(cls): dirname = os.path.dirname(__file__) or '.' - cls.kenlm_path = f"{dirname}/resources/lm_dec19_char_tiny_12gram.kenlm" + cls.kenlm_path = "lm_dec19_char_tiny_12gram.kenlm" + print(cls.kenlm_path) cls.lm_params = [{"lm_path": cls.kenlm_path}, {"lang_model_name": "gpt2"}] - cls.lmodel = MixtureLanguageModel(response_type=ResponseType.SYMBOL, symbol_set=alphabet(), - lm_types=["KENLM", "CAUSAL"], lm_weights=[0.5, 0.5], - lm_params=cls.lm_params) + cls.lmodel = MixtureLanguageModelAdapter(lm_types=["NGRAM", "CAUSAL"], lm_weights=[0.5, 0.5], + lm_params=cls.lm_params) + cls.lmodel.set_symbol_set(DEFAULT_SYMBOL_SET) @pytest.mark.slow def test_default_load(self): """Test loading model with parameters from json This test requires a valid lm_params.json file and all referenced models""" - lm = MixtureLanguageModel(response_type=ResponseType.SYMBOL, symbol_set=alphabet()) + lm = MixtureLanguageModelAdapter() + lm.set_symbol_set(DEFAULT_SYMBOL_SET) def test_init(self): """Test default parameters""" - self.assertEqual(self.lmodel.response_type, ResponseType.SYMBOL) - self.assertEqual(self.lmodel.symbol_set, alphabet()) - self.assertTrue( - ResponseType.SYMBOL in self.lmodel.supported_response_types()) + self.assertEqual(self.lmodel.symbol_set, DEFAULT_SYMBOL_SET) + self.assertTrue(isinstance(self.lmodel, CharacterLanguageModel)) - def test_name(self): - """Test model name.""" - self.assertEqual("MIXTURE", MixtureLanguageModel.name()) - - def test_unsupported_response_type(self): - """Unsupported responses should raise an exception""" - with self.assertRaises(UnsupportedResponseType): - MixtureLanguageModel(response_type=ResponseType.WORD, - symbol_set=alphabet()) + def test_invalid_symbol_set(self): + """Should raise an exception if predict is called without setting symbol set""" + with self.assertRaises(InvalidSymbolSetException): + lm = MixtureLanguageModelAdapter() + lm.predict_character("this_should_fail") def test_invalid_model_type(self): """Test that the proper exception is thrown if given an invalid lm_type""" with self.assertRaises(InvalidLanguageModelException): - MixtureLanguageModel(response_type=ResponseType.SYMBOL, symbol_set=alphabet(), - lm_types=["PHONY", "CAUSAL"], lm_weights=[0.5, 0.5], - lm_params=[{}, {"lang_model_name": "gpt2"}]) + lm = MixtureLanguageModelAdapter(lm_types=["PHONY", "CAUSAL"], lm_weights=[0.5, 0.5], + lm_params=[{}, {"lang_model_name": "gpt2"}]) + lm.set_symbol_set(DEFAULT_SYMBOL_SET) + with self.assertRaises(InvalidLanguageModelException): - MixtureLanguageModel(response_type=ResponseType.SYMBOL, symbol_set=alphabet(), - lm_types=["CAUSAL", "PHONY"], lm_weights=[0.5, 0.5], - lm_params=[{"lang_model_name": "gpt2"}, {}]) + lm = MixtureLanguageModelAdapter(lm_types=["CAUSAL", "PHONY"], lm_weights=[0.5, 0.5], + lm_params=[{"lang_model_name": "gpt2"}, {}]) + lm.set_symbol_set(DEFAULT_SYMBOL_SET) def test_invalid_model_weights(self): """Test that the proper exception is thrown if given an improper number of lm_weights""" with self.assertRaises(InvalidLanguageModelException, msg="Exception not thrown when too few weights given"): - MixtureLanguageModel(response_type=ResponseType.SYMBOL, symbol_set=alphabet(), - lm_types=["KENLM", "CAUSAL"], lm_weights=[0.5], - lm_params=self.lm_params) + lm = MixtureLanguageModelAdapter(lm_types=["NGRAM", "CAUSAL"], lm_weights=[0.5], + lm_params=[{"lm_path": self.kenlm_path}, {"lang_model_name": "gpt2"}]) + lm.set_symbol_set(DEFAULT_SYMBOL_SET) + with self.assertRaises(InvalidLanguageModelException, msg="Exception not thrown when no weights given"): - MixtureLanguageModel(response_type=ResponseType.SYMBOL, symbol_set=alphabet(), - lm_types=["KENLM", "CAUSAL"], lm_weights=None, - lm_params=self.lm_params) + lm = MixtureLanguageModelAdapter(lm_types=["NGRAM", "CAUSAL"], lm_weights=None, + lm_params=[{"lm_path": self.kenlm_path}, {"lang_model_name": "gpt2"}]) + lm.set_symbol_set(DEFAULT_SYMBOL_SET) + with self.assertRaises(InvalidLanguageModelException, msg="Exception not thrown when too many weights given"): - MixtureLanguageModel(response_type=ResponseType.SYMBOL, symbol_set=alphabet(), - lm_types=["KENLM", "CAUSAL"], lm_weights=[0.2, 0.3, 0.5], - lm_params=self.lm_params) + lm = MixtureLanguageModelAdapter(lm_types=["NGRAM", "CAUSAL"], lm_weights=[0.2, 0.3, 0.5], + lm_params=[{"lm_path": self.kenlm_path}, {"lang_model_name": "gpt2"}]) + lm.set_symbol_set(DEFAULT_SYMBOL_SET) + with self.assertRaises(InvalidLanguageModelException, msg="Exception not thrown when weights given do not \ sum to 1"): - MixtureLanguageModel(response_type=ResponseType.SYMBOL, symbol_set=alphabet(), - lm_types=["KENLM", "CAUSAL"], lm_weights=[0.5, 0.8], - lm_params=self.lm_params) + lm = MixtureLanguageModelAdapter(lm_types=["NGRAM", "CAUSAL"], lm_weights=[0.5, 0.8], + lm_params=[{"lm_path": self.kenlm_path}, {"lang_model_name": "gpt2"}]) + lm.set_symbol_set(DEFAULT_SYMBOL_SET) def test_non_mutable_evidence(self): """Test that the model does not change the evidence variable passed in.""" evidence = list("Test_test") evidence2 = list("Test_test") - self.lmodel.predict(evidence) + self.lmodel.predict_character(evidence) self.assertEqual(evidence, evidence2) def test_identical(self): """Ensure predictions are the same for subsequent queries with the same evidence.""" - query1 = self.lmodel.predict(list("evidenc")) - query2 = self.lmodel.predict(list("evidenc")) + query1 = self.lmodel.predict_character(list("evidenc")) + query2 = self.lmodel.predict_character(list("evidenc")) for ((sym1, prob1), (sym2, prob2)) in zip(query1, query2): self.assertAlmostEqual(prob1, prob2, places=5) self.assertEqual(sym1, sym2) def test_upper_lower_case(self): """Ensure predictions are the same for upper or lower case evidence.""" - lc = self.lmodel.predict(list("EVIDENC")) - uc = self.lmodel.predict(list("evidenc")) + lc = self.lmodel.predict_character(list("EVIDENC")) + uc = self.lmodel.predict_character(list("evidenc")) for ((l_sym, l_prob), (u_sym, u_prob)) in zip(lc, uc): self.assertAlmostEqual(l_prob, u_prob, places=5) self.assertEqual(l_sym, u_sym) def test_predict_start_of_word(self): """Test the predict method with no prior evidence.""" - symbol_probs = self.lmodel.predict(evidence=[]) + symbol_probs = self.lmodel.predict_character(evidence=[]) probs = [prob for sym, prob in symbol_probs] self.assertTrue( @@ -117,7 +119,7 @@ def test_predict_start_of_word(self): def test_predict_middle_of_word(self): """Test the predict method in the middle of a word.""" - symbol_probs = self.lmodel.predict(evidence=list("TH")) + symbol_probs = self.lmodel.predict_character(evidence=list("TH")) probs = [prob for sym, prob in symbol_probs] self.assertTrue( @@ -135,7 +137,7 @@ def test_predict_middle_of_word(self): def test_phrase(self): """Test that a phrase can be used for input""" - symbol_probs = self.lmodel.predict(list("does_it_make_sen")) + symbol_probs = self.lmodel.predict_character(list("does_it_make_sen")) most_likely_sym, _prob = sorted(symbol_probs, key=itemgetter(1), reverse=True)[0] @@ -143,15 +145,15 @@ def test_phrase(self): def test_multiple_spaces(self): """Test that the probability of space after a space is smaller than before the space""" - symbol_probs_before = self.lmodel.predict(list("the")) - symbol_probs_after = self.lmodel.predict(list("the_n")) + symbol_probs_before = self.lmodel.predict_character(list("the")) + symbol_probs_after = self.lmodel.predict_character(list("the_n")) space_prob_before = (dict(symbol_probs_before))[SPACE_CHAR] space_prob_after = (dict(symbol_probs_after))[SPACE_CHAR] self.assertTrue(space_prob_before > space_prob_after) def test_nonzero_prob(self): """Test that all letters in the alphabet have nonzero probability except for backspace""" - symbol_probs = self.lmodel.predict(list("does_it_make_sens")) + symbol_probs = self.lmodel.predict_character(list("does_it_make_sens")) prob_values = [item[1] for item in symbol_probs if item[0] != BACKSPACE_CHAR] for value in prob_values: self.assertTrue(value > 0) diff --git a/bcipy/language/tests/test_kenlm.py b/bcipy/language/tests/test_ngram.py similarity index 64% rename from bcipy/language/tests/test_kenlm.py rename to bcipy/language/tests/test_ngram.py index 6a460d38c..b55ac6050 100644 --- a/bcipy/language/tests/test_kenlm.py +++ b/bcipy/language/tests/test_ngram.py @@ -1,82 +1,80 @@ -"""Tests for KENLM Language Model""" +"""Tests for NGRAM Language Model""" -import pytest -import unittest import os +import unittest from operator import itemgetter -from bcipy.exceptions import UnsupportedResponseType, InvalidLanguageModelException -from bcipy.core.symbols import alphabet, BACKSPACE_CHAR, SPACE_CHAR -from bcipy.language.model.kenlm import KenLMLanguageModel -from bcipy.language.main import ResponseType +import pytest +from textslinger.exceptions import InvalidLanguageModelException + +from bcipy.core.symbols import BACKSPACE_CHAR, DEFAULT_SYMBOL_SET, SPACE_CHAR +from bcipy.exceptions import InvalidSymbolSetException +from bcipy.language.main import CharacterLanguageModel +from bcipy.language.model.ngram import NGramLanguageModelAdapter @pytest.mark.slow -class TestKenLMLanguageModel(unittest.TestCase): +class TestNGramLanguageModelAdapter(unittest.TestCase): """Tests for language model""" + @classmethod def setUpClass(cls): dirname = os.path.dirname(__file__) or '.' cls.lm_path = f"{dirname}/resources/lm_dec19_char_tiny_12gram.kenlm" - cls.lmodel = KenLMLanguageModel(response_type=ResponseType.SYMBOL, - symbol_set=alphabet(), lm_path=cls.lm_path) + cls.lmodel = NGramLanguageModelAdapter(lm_path=cls.lm_path) + cls.lmodel.set_symbol_set(DEFAULT_SYMBOL_SET) @pytest.mark.slow def test_default_load(self): """Test loading model with parameters from json This test requires a valid lm_params.json file and all requisite models""" - lm = KenLMLanguageModel(response_type=ResponseType.SYMBOL, symbol_set=alphabet()) + lm = NGramLanguageModelAdapter() + lm.set_symbol_set(DEFAULT_SYMBOL_SET) def test_init(self): """Test default parameters""" - self.assertEqual(self.lmodel.response_type, ResponseType.SYMBOL) - self.assertEqual(self.lmodel.symbol_set, alphabet()) - self.assertTrue( - ResponseType.SYMBOL in self.lmodel.supported_response_types()) - - def test_name(self): - """Test model name.""" - self.assertEqual("KENLM", KenLMLanguageModel.name()) + self.assertEqual(self.lmodel.symbol_set, DEFAULT_SYMBOL_SET) + self.assertTrue(isinstance(self.lmodel, CharacterLanguageModel)) - def test_unsupported_response_type(self): - """Unsupported responses should raise an exception""" - with self.assertRaises(UnsupportedResponseType): - KenLMLanguageModel(response_type=ResponseType.WORD, - symbol_set=alphabet(), lm_path=self.lm_path) + def test_invalid_symbol_set(self): + """Should raise an exception if predict is called without settting symbol set""" + with self.assertRaises(InvalidSymbolSetException): + lm = NGramLanguageModelAdapter(lm_path=self.lm_path) + lm.predict_character("this_should_fail") def test_invalid_model_path(self): """Test that the proper exception is thrown if given an invalid lm_path""" with self.assertRaises(InvalidLanguageModelException): - KenLMLanguageModel(response_type=ResponseType.SYMBOL, symbol_set=alphabet(), - lm_path="phonymodel.txt") + lm = NGramLanguageModelAdapter(lm_path="phonymodel.txt") + lm.set_symbol_set(DEFAULT_SYMBOL_SET) def test_non_mutable_evidence(self): """Test that the model does not change the evidence variable passed in. This could impact the mixture model if failed""" evidence = list("Test_test") evidence2 = list("Test_test") - self.lmodel.predict(evidence) + self.lmodel.predict_character(evidence) self.assertEqual(evidence, evidence2) def test_identical(self): """Ensure predictions are the same for subsequent queries with the same evidence.""" - query1 = self.lmodel.predict(list("evidenc")) - query2 = self.lmodel.predict(list("evidenc")) + query1 = self.lmodel.predict_character(list("evidenc")) + query2 = self.lmodel.predict_character(list("evidenc")) for ((sym1, prob1), (sym2, prob2)) in zip(query1, query2): self.assertAlmostEqual(prob1, prob2, places=5) self.assertEqual(sym1, sym2) def test_upper_lower_case(self): """Ensure predictions are the same for upper or lower case evidence.""" - lc = self.lmodel.predict(list("EVIDENC")) - uc = self.lmodel.predict(list("evidenc")) + lc = self.lmodel.predict_character(list("EVIDENC")) + uc = self.lmodel.predict_character(list("evidenc")) for ((l_sym, l_prob), (u_sym, u_prob)) in zip(lc, uc): self.assertAlmostEqual(l_prob, u_prob, places=5) self.assertEqual(l_sym, u_sym) def test_predict_start_of_word(self): """Test the predict method with no prior evidence.""" - symbol_probs = self.lmodel.predict(evidence=[]) + symbol_probs = self.lmodel.predict_character(evidence=[]) probs = [prob for sym, prob in symbol_probs] self.assertTrue( @@ -91,7 +89,7 @@ def test_predict_start_of_word(self): def test_predict_middle_of_word(self): """Test the predict method in the middle of a word.""" - symbol_probs = self.lmodel.predict(evidence=list("TH")) + symbol_probs = self.lmodel.predict_character(evidence=list("TH")) probs = [prob for sym, prob in symbol_probs] self.assertTrue( @@ -109,7 +107,7 @@ def test_predict_middle_of_word(self): def test_phrase(self): """Test that a phrase can be used for input""" - symbol_probs = self.lmodel.predict(list("does_it_make_sen")) + symbol_probs = self.lmodel.predict_character(list("does_it_make_sen")) most_likely_sym, _prob = sorted(symbol_probs, key=itemgetter(1), reverse=True)[0] @@ -117,16 +115,18 @@ def test_phrase(self): def test_multiple_spaces(self): """Test that the probability of space after a space is smaller than before the space""" - symbol_probs_before = self.lmodel.predict(list("the")) - symbol_probs_after = self.lmodel.predict(list("the_")) + symbol_probs_before = self.lmodel.predict_character(list("the")) + symbol_probs_after = self.lmodel.predict_character(list("the_")) space_prob_before = (dict(symbol_probs_before))[SPACE_CHAR] space_prob_after = (dict(symbol_probs_after))[SPACE_CHAR] self.assertTrue(space_prob_before > space_prob_after) def test_nonzero_prob(self): """Test that all letters in the alphabet have nonzero probability except for backspace""" - symbol_probs = self.lmodel.predict(list("does_it_make_sens")) - prob_values = [item[1] for item in symbol_probs if item[0] != BACKSPACE_CHAR] + symbol_probs = self.lmodel.predict_character(list("does_it_make_sens")) + prob_values = [ + item[1] for item in symbol_probs if item[0] != BACKSPACE_CHAR + ] for value in prob_values: self.assertTrue(value > 0) diff --git a/bcipy/language/tests/test_oracle.py b/bcipy/language/tests/test_oracle.py index 69a2f7a2b..c5cb5489d 100644 --- a/bcipy/language/tests/test_oracle.py +++ b/bcipy/language/tests/test_oracle.py @@ -2,8 +2,10 @@ import unittest -from bcipy.language.model.oracle import (BACKSPACE_CHAR, OracleLanguageModel, - ResponseType) +from bcipy.core.symbols import BACKSPACE_CHAR, DEFAULT_SYMBOL_SET +from bcipy.exceptions import InvalidSymbolSetException +from bcipy.language.main import CharacterLanguageModel +from bcipy.language.model.oracle import OracleLanguageModel class TestOracleLanguageModel(unittest.TestCase): @@ -17,15 +19,25 @@ def test_init(self): def test_init_with_text(self): """Test with task_text provided""" lmodel = OracleLanguageModel(task_text="HELLO_WORLD") - self.assertEqual(lmodel.response_type, ResponseType.SYMBOL) + lmodel.set_symbol_set(DEFAULT_SYMBOL_SET) self.assertEqual( len(lmodel.symbol_set), 28, "Should be the alphabet plus the backspace and space chars") + self.assertTrue(isinstance(lmodel, CharacterLanguageModel)) + + def test_invalid_symbol_set(self): + """Should raise an exception if predict is called before settting the symbol set""" + lm = OracleLanguageModel(task_text="HELLO_WORLD") + lm.set_symbol_set([]) + with self.assertRaises(InvalidSymbolSetException): + lm.predict_character("this_should_fail") + def test_predict(self): """Test the predict method""" lm = OracleLanguageModel(task_text="HELLO_WORLD") - symbol_probs = lm.predict(evidence=[]) + lm.set_symbol_set(DEFAULT_SYMBOL_SET) + symbol_probs = lm.predict_character(evidence=[]) probs = [prob for sym, prob in symbol_probs] self.assertEqual(len(set(probs)), 2, @@ -38,12 +50,13 @@ def test_predict(self): "Target should have a higher value") self.assertAlmostEqual(lm.target_bump, probs_dict['H'] - probs_dict['A'], - places=1) + places=4) def test_predict_with_spelled_text(self): """Test predictions with previously spelled symbols""" lm = OracleLanguageModel(task_text="HELLO_WORLD") - symbol_probs = lm.predict(evidence=list("HELLO_")) + lm.set_symbol_set(DEFAULT_SYMBOL_SET) + symbol_probs = lm.predict_character(evidence=list("HELLO_")) probs = [prob for sym, prob in symbol_probs] self.assertEqual(len(set(probs)), 2, @@ -55,7 +68,8 @@ def test_predict_with_spelled_text(self): def test_predict_with_incorrectly_spelled_text(self): """Test predictions with incorrectly spelled prior.""" lm = OracleLanguageModel(task_text="HELLO_WORLD") - symbol_probs = lm.predict(evidence=list("HELP")) + lm.set_symbol_set(DEFAULT_SYMBOL_SET) + symbol_probs = lm.predict_character(evidence=list("HELP")) probs = [prob for sym, prob in symbol_probs] self.assertEqual(len(set(probs)), 2) @@ -66,29 +80,32 @@ def test_predict_with_incorrectly_spelled_text(self): def test_target_bump_parameter(self): """Test setting the target_bump parameter.""" lm = OracleLanguageModel(task_text="HELLO_WORLD", target_bump=0.2) - symbol_probs = lm.predict(evidence=[]) + lm.set_symbol_set(DEFAULT_SYMBOL_SET) + symbol_probs = lm.predict_character(evidence=[]) probs_dict = dict(symbol_probs) self.assertTrue(probs_dict['H'] > probs_dict['A'], "Target should have a higher value") self.assertAlmostEqual(0.2, probs_dict['H'] - probs_dict['A'], - places=1) + places=4) def test_setting_task_text_to_none(self): """Test that task_text is required""" lmodel = OracleLanguageModel(task_text="HELLO_WORLD") + lmodel.set_symbol_set(DEFAULT_SYMBOL_SET) with self.assertRaises(AssertionError): lmodel.task_text = None def test_updating_task_text(self): """Test updating the task_text property.""" lm = OracleLanguageModel(task_text="HELLO_WORLD", target_bump=0.2) - probs = dict(lm.predict(evidence=list("HELLO_"))) + lm.set_symbol_set(DEFAULT_SYMBOL_SET) + probs = dict(lm.predict_character(evidence=list("HELLO_"))) self.assertTrue(probs['W'] > probs['T'], "Target should have a higher value") lm.task_text = "HELLO_THERE" - probs = dict(lm.predict(evidence=list("HELLO_"))) + probs = dict(lm.predict_character(evidence=list("HELLO_"))) self.assertTrue(probs['T'] > probs['W'], "Target should have a higher value") @@ -101,6 +118,7 @@ def test_target_bump_bounds(self): OracleLanguageModel(task_text="HI", target_bump=1.1) lm = OracleLanguageModel(task_text="HI", target_bump=0.0) + lm.set_symbol_set(DEFAULT_SYMBOL_SET) with self.assertRaises(AssertionError): lm.target_bump = -1.0 @@ -110,22 +128,23 @@ def test_target_bump_bounds(self): def test_evidence_exceeds_task(self): """Test probs when evidence exceeds task_text.""" lm = OracleLanguageModel(task_text="HELLO") + lm.set_symbol_set(DEFAULT_SYMBOL_SET) - probs = dict(lm.predict(evidence="HELL")) + probs = dict(lm.predict_character(evidence="HELL")) self.assertEqual(2, len(set(probs.values()))) self.assertEqual(max(probs.values()), probs['O']) - probs = dict(lm.predict(evidence="HELLO")) + probs = dict(lm.predict_character(evidence="HELLO")) self.assertEqual(1, len(set(probs.values()))) - probs = dict(lm.predict(evidence="HELLP")) + probs = dict(lm.predict_character(evidence="HELLP")) self.assertEqual(2, len(set(probs.values()))) self.assertEqual(max(probs.values()), probs[BACKSPACE_CHAR]) - probs = dict(lm.predict(evidence="HELLO_")) + probs = dict(lm.predict_character(evidence="HELLO_")) self.assertEqual(1, len(set(probs.values()))) - probs = dict(lm.predict(evidence="HELPED")) + probs = dict(lm.predict_character(evidence="HELPED")) self.assertEqual(2, len(set(probs.values()))) self.assertEqual(max(probs.values()), probs[BACKSPACE_CHAR]) diff --git a/bcipy/language/tests/test_uniform.py b/bcipy/language/tests/test_uniform.py index 8e8a4034a..7b4b6dca9 100644 --- a/bcipy/language/tests/test_uniform.py +++ b/bcipy/language/tests/test_uniform.py @@ -2,25 +2,42 @@ import unittest -from bcipy.language.model.uniform import (ResponseType, UniformLanguageModel, - equally_probable) +from bcipy.core.symbols import BACKSPACE_CHAR, DEFAULT_SYMBOL_SET +from bcipy.exceptions import InvalidSymbolSetException +from bcipy.language.main import CharacterLanguageModel +from bcipy.language.model.uniform import UniformLanguageModel, equally_probable class TestUniformLanguageModel(unittest.TestCase): """Tests for language model""" + @classmethod + def setUpClass(cls): + cls.lm = UniformLanguageModel() + cls.lm.set_symbol_set(DEFAULT_SYMBOL_SET) + def test_init(self): """Test default parameters""" lmodel = UniformLanguageModel() - self.assertEqual(lmodel.response_type, ResponseType.SYMBOL) + lmodel.set_symbol_set(DEFAULT_SYMBOL_SET) self.assertEqual( len(lmodel.symbol_set), 28, "Should be the alphabet plus the backspace and space chars") + self.assertTrue(isinstance(lmodel, CharacterLanguageModel)) + + def test_invalid_symbol_set(self): + """Should raise an exception if predict is called before setting a symbol set""" + lm = UniformLanguageModel() + lm.set_symbol_set([]) + with self.assertRaises(InvalidSymbolSetException): + lm.predict_character("this_should_fail") def test_predict(self): """Test the predict method""" - symbol_probs = UniformLanguageModel().predict(evidence=[]) - probs = [prob for sym, prob in symbol_probs] + symbol_probs = self.lm.predict_character(evidence=[]) + + # Backspace can be 0 + probs = [prob for sym, prob in symbol_probs if sym != BACKSPACE_CHAR] self.assertEqual(len(set(probs)), 1, "All values should be the same") self.assertTrue(0 < probs[0] < 1) diff --git a/bcipy/parameters/lm_params.json b/bcipy/parameters/lm_params.json index 02f934725..ddd81c5a7 100644 --- a/bcipy/parameters/lm_params.json +++ b/bcipy/parameters/lm_params.json @@ -1,5 +1,5 @@ { - "kenlm": { + "ngram": { "model_file": { "description": "Name of the pretrained model file", "value": "lm_dec19_char_large_12gram.kenlm", @@ -33,7 +33,7 @@ "description": "Defines the types of models to be used by the mixture model.", "value": [ "CAUSAL", - "KENLM" + "NGRAM" ], "type": "List[str]" }, @@ -48,8 +48,8 @@ "model_params": { "description": "Defines the extra parameters of models to be used by the mixture model.", "value": [ - {}, - {} + {"lang_model_name": "figmtu/opt-350m-aac"}, + {"lm_path": "lm_dec19_char_large_12gram.kenlm"} ], "type": "List[Dict[str, str]]" } diff --git a/bcipy/simulator/task/copy_phrase.py b/bcipy/simulator/task/copy_phrase.py index 164a8d13a..c9129e1d0 100644 --- a/bcipy/simulator/task/copy_phrase.py +++ b/bcipy/simulator/task/copy_phrase.py @@ -4,6 +4,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple +from bcipy.acquisition.multimodal import ClientManager from bcipy.core.parameters import Parameters from bcipy.core.stimuli import InquirySchedule from bcipy.display.main import Display @@ -11,10 +12,8 @@ from bcipy.language.main import LanguageModel from bcipy.signal.model.base_model import SignalModel from bcipy.simulator.data.sampler import Sampler -from bcipy.simulator.task.null_display import NullDisplay from bcipy.simulator.task.null_daq import NullDAQ -from bcipy.acquisition.multimodal import ClientManager - +from bcipy.simulator.task.null_display import NullDisplay from bcipy.simulator.util.state import SimState from bcipy.task import TaskMode from bcipy.task.control.evidence import EvidenceEvaluator diff --git a/pyproject.toml b/pyproject.toml index 16cfb2fc8..13d82afaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,9 +24,9 @@ classifiers = [ ] dependencies = [ + "textslinger~=0.1.1", "attrdict3==2.0.2", "EDFlib-Python==1.0.8", - "transformers==4.36.0", "torch==2.2.0", "construct==2.8.14", "mne==1.6.1", @@ -56,7 +56,6 @@ dependencies = [ "rich==13.9.4", "reportlab==4.2.0", "tables==3.7.0", - "kenlm==0.1", "pyWinhook==1.6.2;python_version=='3.9' and platform_system=='Windows'", "WxPython==4.2.1;platform_system!='Linux'", ] @@ -180,7 +179,6 @@ exclude = [ "scripts", "acquisition", "display.paradigm", - "language", "signal.model", "task.control", "core.parameters", From 2341d0e420ee453293ef37b557bd3da693a27ec8 Mon Sep 17 00:00:00 2001 From: tab-cmd Date: Fri, 2 May 2025 12:02:49 -0700 Subject: [PATCH 73/76] Support multi run experiments and additional 10-20 channels (#386) --- .github/workflows/main.yml | 7 +- CHANGELOG.md | 10 ++ bcipy/core/raw_data.py | 3 +- bcipy/core/tests/test_raw_data.py | 2 +- bcipy/gui/main.py | 8 +- bcipy/io/convert.py | 63 +++++++++- bcipy/io/demo/demo_convert.py | 31 +++-- bcipy/io/demo/demo_load_BIDS.py | 74 ++++++++++++ bcipy/io/tests/test_convert.py | 192 +++++++++++++++++++++++++++++- pyproject.toml | 1 + scripts/shell/m2chip_install.sh | 6 - 11 files changed, 373 insertions(+), 24 deletions(-) create mode 100644 bcipy/io/demo/demo_load_BIDS.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bab7d7316..aef8daf55 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -99,7 +99,7 @@ jobs: build-macos: - runs-on: macos-latest + runs-on: macos-14 strategy: fail-fast: false matrix: @@ -113,9 +113,12 @@ jobs: python-version: ${{ matrix.python-version }} - name: update pip & install custom dependencies run: | + python -m pip install --upgrade pip + brew update sh scripts/shell/m2chip_install.sh brew install labstreaminglayer/tap/lsl - python -m pip install --upgrade pip + pip install psychopy --no-deps + make install - name: install dependencies run: | make dev-install diff --git a/CHANGELOG.md b/CHANGELOG.md index 4746f2d14..48ca96c20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,21 @@ # 2.0.0 +The next major BciPy release is here! All features included from release canidates rc1-rc4 and contributions described below. + ## Contributions - BIDS - Bundling support and refactor of `convert` module. See `demo_convert.py` #362 Add support for 1020 channels and eye tracker data #369 - Library Refactor - Refactor `helpers` into `io` and `core` #362 +- Refactor to use `pyproject.toml` for installs #367 +- Language Model Refactor to use lm-toolkit #381 #390 +- Simulator + - Multimodal support #385 + - Replay feature #376 + - Verbose mode configuration #375 and logging fixes #373 + - Inquiry- Ranger Sampler #374 Trial Sampler #372 + - Metrics #371 - Dependencies - Upgrade - `seaborn` #362 diff --git a/bcipy/core/raw_data.py b/bcipy/core/raw_data.py index 297ac1e10..ea9a0d2c4 100644 --- a/bcipy/core/raw_data.py +++ b/bcipy/core/raw_data.py @@ -416,7 +416,8 @@ def get_1020_channels() -> List[str]: """ return [ 'Fp1', 'Fp2', 'F7', 'F3', 'Fz', 'F4', 'F8', 'T3', 'C3', 'Cz', 'C4', - 'T4', 'T5', 'P3', 'Pz', 'P4', 'T6', 'O1', 'O2' + 'T4', 'T5', 'P3', 'Pz', 'P4', 'T6', 'O1', 'O2', 'PO7', 'PO3', 'POz', + 'PO4', 'PO8', 'Oz', 'A1', 'A2', ] diff --git a/bcipy/core/tests/test_raw_data.py b/bcipy/core/tests/test_raw_data.py index 2ba537c71..31d623ad1 100644 --- a/bcipy/core/tests/test_raw_data.py +++ b/bcipy/core/tests/test_raw_data.py @@ -384,7 +384,7 @@ class Test1020(unittest.TestCase): def test_get_1020_channels(self): """Tests that the 10-20 channel map is correctly generated.""" channels = get_1020_channels() - self.assertEqual(19, len(channels)) + self.assertEqual(27, len(channels)) self.assertTrue(isinstance(channels[0], str)) def test_get_1020_channel_map(self): diff --git a/bcipy/gui/main.py b/bcipy/gui/main.py index 747c54d1f..31e771475 100644 --- a/bcipy/gui/main.py +++ b/bcipy/gui/main.py @@ -920,10 +920,10 @@ def add_image(self, path: str, position: list, size: int) -> QLabel: if width > height: new_width = size - new_height = size * height / width + new_height = int(size * height / width) else: new_height = size - new_width = size * width / height + new_width = int(size * width / height) labelImage.resize(new_width, new_height) labelImage.move(position[0], position[1]) @@ -1135,8 +1135,8 @@ def start_app() -> None: """Start BCIGui.""" bcipy_gui = app(sys.argv) ex = BCIGui(title='BCI GUI', - height=650, - width=650, + height=500, + width=500, background_color='white') ex.show_gui() sys.exit(bcipy_gui.exec()) diff --git a/bcipy/io/convert.py b/bcipy/io/convert.py index 03c03e0e8..369dd03a1 100644 --- a/bcipy/io/convert.py +++ b/bcipy/io/convert.py @@ -9,7 +9,7 @@ import mne from enum import Enum from mne.io import RawArray -from mne_bids import BIDSPath, write_raw_bids +from mne_bids import BIDSPath, write_raw_bids, find_matching_paths, read_raw_bids, get_entity_vals from tqdm import tqdm from bcipy.acquisition.devices import preconfigured_device @@ -476,3 +476,64 @@ def norm_to_tobii(norm_units: Tuple[float, float]) -> Tuple[float, float]: tobii_x = (norm_units[0] / 2) + 0.5 tobii_y = ((norm_units[1] * -1) / 2) + 0.5 return (tobii_x, tobii_y) + + +def BIDS_to_MNE( + bids_root_path: str, + task_name: Optional[str] = None) -> List[mne.io.Raw]: + """Convert BIDS data to MNE Raw object. + + Parameters + ---------- + bids_root_path : str + Path to the BIDS directory. + task_name : str, optional + Task name. Example: 'RSVPCalibration'. + + Returns + ------- + List[mne.io.Raw] + List of MNE Raw objects containing the BIDS data. + + Raises + ------ + FileNotFoundError + If the BIDS root path does not exist or no matching files are found. + ValueError + If the BIDS directory is invalid or contains unsupported data types. + """ + # Check if the BIDS root path exists + if not os.path.exists(bids_root_path): + raise FileNotFoundError(f"BIDS root path '{bids_root_path}' does not exist.") + logger.info(f"Searching for BIDS data in '{bids_root_path}'...") + + # Get the sessions from the BIDS root path using the session label (e.g., 'ses' or 'session') + sessions = get_entity_vals(bids_root_path, 'session') + data_type = 'eeg' + extensions = ['.vhdr', '.edf', '.bdf'] # Supported file extensions + + bid_paths = find_matching_paths( + bids_root_path, + extensions=extensions, + sessions=sessions, + datatypes=data_type, + tasks=task_name, + ) + if not bid_paths: + raise FileNotFoundError(f"No matching BIDS files found in '{bids_root_path}'.") + + raw_data = [] + for bid_path in bid_paths: + if task_name and bid_path.task != task_name: + logger.debug(f"Skipping file '{bid_path}' due to task name [{task_name}] mismatch.") + continue + + logger.info(f"Reading BIDS file: {bid_path}") + + # Read the raw data from the BIDS path + raw = read_raw_bids(bid_path) + raw_data.append(raw) + + logger.info(f"Successfully loaded {len(raw_data)} BIDS files.") + + return raw_data diff --git a/bcipy/io/demo/demo_convert.py b/bcipy/io/demo/demo_convert.py index fd08b71b7..5942a960b 100644 --- a/bcipy/io/demo/demo_convert.py +++ b/bcipy/io/demo/demo_convert.py @@ -13,8 +13,7 @@ EXCLUDED_TASKS = ['Report', 'Offline', 'Intertask', 'BAD'] -def load_historical_bcipy_data(directory: str, experiment_id: str, - task_name: str = 'MatrixCalibration') -> List[BciPySessionTaskData]: +def load_historical_bcipy_data(directory: str, experiment_id: str) -> List[BciPySessionTaskData]: """Load the data from the given directory. The expected directory structure is: @@ -39,16 +38,34 @@ def load_historical_bcipy_data(directory: str, experiment_id: str, # pull out the user id. This is the name of the folder user_id = participant.name + run_tasks = {} for task_run in participant.iterdir(): if not task_run.is_dir(): continue + extracted_task_paradigm = task_run.name.split('_')[1] + extracted_task_mode = task_run.name.split('_')[2] + if extracted_task_mode == 'Copy': + task_name = 'CopyPhrase' + extracted_task_time = task_run.name.split('_')[8] + else: + extracted_task_time = task_run.name.split('_')[7] + + # add the tasks to the list with the time as the key + run_tasks[extracted_task_time] = [task_run, f'{extracted_task_paradigm}{extracted_task_mode}'] + + # sort the tasks by time + sorted_tasks = sorted(run_tasks.items()) + # print(sorted_tasks) + for i, (_, task_info) in enumerate(sorted_tasks): + task_run, task_name = task_info + session_data = BciPySessionTaskData( path=task_run, user_id=user_id, experiment_id=experiment_id, session_id=1, - run=1, + run=i + 1, task_name=task_name ) experiment_data.append(session_data) @@ -64,9 +81,9 @@ def convert_experiment_to_bids( """Converts the data in the study folder to BIDS format.""" # Use for data pre-2.0rc4 - # experiment_data = load_historical_bcipy_data( - # directory, - # experiment_id=experiment_id) + experiment_data = load_historical_bcipy_data( + directory, + experiment_id=experiment_id) # Use for data post-2.0rc4 experiment_data = load_bcipy_data(directory, experiment_id, excluded_tasks=EXCLUDED_TASKS) @@ -129,7 +146,7 @@ def convert_experiment_to_bids( '-e', '--experiment', help='Experiment ID to convert', - default='Matrix Multimodal Experiment', + default='BciPyDefaultExperiment', ) parser.add_argument( '-et', diff --git a/bcipy/io/demo/demo_load_BIDS.py b/bcipy/io/demo/demo_load_BIDS.py new file mode 100644 index 000000000..899bec593 --- /dev/null +++ b/bcipy/io/demo/demo_load_BIDS.py @@ -0,0 +1,74 @@ +"""Demo script to load BIDS data into MNE format using BciPy. + +This script demonstrates how to load BIDS data using the BciPy library. This assumes that the data is +already in BIDS format (BV, EDF, or BDF, with the channels.tsv file) and that the BIDS directory is +structured correctly. + +The BIDS directory should contain a dataset_description.json file and a +participants.tsv file, along with the data files in the appropriate subdirectories. +""" + +from bcipy.io.convert import BIDS_to_MNE +from bcipy.io.load import load_experimental_data +import mne +from mne.viz import plot_compare_evokeds + +# This script demonstrates how to load BIDS data using the BciPy library. This assumes that the data is +# already in BIDS format (BV, EDF, or BDF, with the channels.tsv file) and that the BIDS directory is +# structured correctly. The BIDS directory should contain a dataset_description.json file and a +# participants.tsv file, along with the data files in the appropriate subdirectories. +try: + path_to_bids = load_experimental_data() + + # Load BIDS data using MNE-Python and MNE-BIDS. This returns a list of raw data objects. + # The data is loaded from the BIDS directory specified in the path_to_bids variable. + # task_name = 'Calibration' # Specify the task name to load data for. + # This will filter the data to only include the specified task. + task_name = None # Set to None to load all tasks. + raw_data_files = BIDS_to_MNE(path_to_bids, task_name=task_name) + + raw_data = raw_data_files[0] # Get the first raw data object from the list. + + # to see where the data is stored, you can use the following command: + # print(raw_data.filenames) + + # PLOT THE RAW DATA + raw_data.load_data() + raw_data.filter(1, 20) # Apply a bandpass filter to the data (1-20 Hz). + raw_data.plot() # + + # EPOCH THE DATA / CREATE ERPS + # epoch the data using the events from the raw data object. + # You can specify the event_id, tmin, tmax, and baseline parameters as needed. + events = mne.events_from_annotations(raw_data, event_id={'nontarget': 0, 'target': 1}) + tmin = -0.2 + tmax = 0.8 + baseline = (None, None) # No baseline correction. + epochs = mne.Epochs(raw_data, events[0], events[1], tmin, tmax, baseline=baseline, preload=True) + + # Grab the epochs for non-target and target events. + non_target_epochs = epochs['nontarget'] + target_epochs = epochs['target'] + + # Create evoked objects for non-target and target events. + non_target_evoked = non_target_epochs.average() + target_evoked = target_epochs.average() + + # PLOT THE EVOKED DATA + plot_compare_evokeds( + dict( + nontarget=non_target_evoked, + target=target_evoked), + title='Evoked Responses for Non-Target and Target Events', + show_sensors=True, + ci=0.95, + combine='mean', + invert_y=True) + target_evoked.plot_joint() + non_target_evoked.plot_joint() + +except Exception as e: + print(f"An error occurred: {e}") +finally: + print("Demo script completed.") + breakpoint() # This will pause the script execution and allow you to inspect the variables in the debugger. diff --git a/bcipy/io/tests/test_convert.py b/bcipy/io/tests/test_convert.py index d9e6d8d76..bb59f7ca6 100644 --- a/bcipy/io/tests/test_convert.py +++ b/bcipy/io/tests/test_convert.py @@ -5,11 +5,12 @@ import unittest from pathlib import Path from typing import Tuple, Union +from unittest.mock import patch, MagicMock import bcipy.acquisition.devices as devices +import mne from bcipy.config import (DEFAULT_ENCODING, DEFAULT_PARAMETERS_FILENAME, RAW_DATA_FILENAME, TRIGGER_FILENAME) -# from bcipy.io import convert from bcipy.io.convert import ( archive_list, compress, @@ -19,7 +20,8 @@ decompress, norm_to_tobii, tobii_to_norm, - convert_eyetracking_to_bids + convert_eyetracking_to_bids, + BIDS_to_MNE ) from bcipy.core.parameters import Parameters from bcipy.core.raw_data import RawData, sample_data, write @@ -349,6 +351,14 @@ def test_convert_to_mne_with_custom_montage(self): self.assertEqual(data.ch_names, self.channels[1:-2]) self.assertEqual(data.info['sfreq'], self.sample_rate) + def test_convert_to_mne_with_custom_channel_types_length_mismatch(self): + """Test the convert_to_mne function raises an error when channel types length doesn't match channels""" + # Not enough channel types + channel_types = ['eeg', 'eeg'] # Only 2 types for 3 channels + + with self.assertRaises(AssertionError): + convert_to_mne(self.raw_data, channel_types=channel_types) + class TestCompressionSupport(unittest.TestCase): @@ -612,5 +622,183 @@ def test_convert_et_raises_error_with_multiple_data_files(self): ) +class TestBIDSToMNE(unittest.TestCase): + """Tests for the BIDS_to_MNE function.""" + + @patch('bcipy.io.convert.os.path.exists') + @patch('bcipy.io.convert.get_entity_vals') + @patch('bcipy.io.convert.find_matching_paths') + @patch('bcipy.io.convert.read_raw_bids') + def test_successful_conversion(self, mock_read_raw_bids, mock_find_matching_paths, + mock_get_entity_vals, mock_path_exists): + """Test successful conversion from BIDS to MNE.""" + # Setup mocks + mock_path_exists.return_value = True + mock_get_entity_vals.return_value = ['01'] + + # Create mock BIDSPath objects + mock_bids_path1 = MagicMock() + mock_bids_path1.task = 'RSVPCalibration' + mock_bids_path2 = MagicMock() + mock_bids_path2.task = 'RSVPCalibration' + mock_find_matching_paths.return_value = [mock_bids_path1, mock_bids_path2] + + # Create mock Raw objects that read_raw_bids will return + mock_raw1 = MagicMock(spec=mne.io.Raw) + mock_raw2 = MagicMock(spec=mne.io.Raw) + mock_read_raw_bids.side_effect = [mock_raw1, mock_raw2] + + result = BIDS_to_MNE('/fake/bids/path', task_name='RSVPCalibration') + + self.assertEqual(len(result), 2) + self.assertIs(result[0], mock_raw1) + self.assertIs(result[1], mock_raw2) + mock_path_exists.assert_called_once_with('/fake/bids/path') + mock_get_entity_vals.assert_called_once_with('/fake/bids/path', 'session') + mock_find_matching_paths.assert_called_once() + self.assertEqual(mock_read_raw_bids.call_count, 2) + + @patch('bcipy.io.convert.os.path.exists') + def test_nonexistent_path(self, mock_path_exists): + """Test handling of non-existent BIDS path.""" + mock_path_exists.return_value = False + + with self.assertRaises(FileNotFoundError): + BIDS_to_MNE('/nonexistent/path') + + mock_path_exists.assert_called_once_with('/nonexistent/path') + + @patch('bcipy.io.convert.os.path.exists') + @patch('bcipy.io.convert.get_entity_vals') + @patch('bcipy.io.convert.find_matching_paths') + def test_no_matching_files(self, mock_find_matching_paths, mock_get_entity_vals, mock_path_exists): + """Test handling of no matching BIDS files.""" + mock_path_exists.return_value = True + mock_get_entity_vals.return_value = ['01'] + mock_find_matching_paths.return_value = [] + + with self.assertRaises(FileNotFoundError): + BIDS_to_MNE('/fake/bids/path') + + mock_path_exists.assert_called_once_with('/fake/bids/path') + mock_get_entity_vals.assert_called_once_with('/fake/bids/path', 'session') + mock_find_matching_paths.assert_called_once() + + @patch('bcipy.io.convert.os.path.exists') + @patch('bcipy.io.convert.get_entity_vals') + @patch('bcipy.io.convert.find_matching_paths') + @patch('bcipy.io.convert.read_raw_bids') + @patch('bcipy.io.convert.logger') # Mock the logger to prevent actual logging during tests + def test_task_filtering(self, mock_logger, mock_read_raw_bids, mock_find_matching_paths, + mock_get_entity_vals, mock_path_exists): + """Test task name filtering.""" + # Setup mocks + mock_path_exists.return_value = True + mock_get_entity_vals.return_value = ['01'] + + # Create mock BIDSPath objects with different tasks + mock_bids_path1 = MagicMock() + mock_bids_path1.task = 'RSVPCalibration' + mock_bids_path2 = MagicMock() + mock_bids_path2.task = 'OtherTask' + mock_find_matching_paths.return_value = [mock_bids_path1, mock_bids_path2] + + # Create mock Raw object that read_raw_bids will return + mock_raw = MagicMock(spec=mne.io.Raw) + mock_read_raw_bids.return_value = mock_raw + + # Call function with specific task_name + result = BIDS_to_MNE('/fake/bids/path', task_name='RSVPCalibration') + + # Assertions + self.assertEqual(len(result), 1) + self.assertIs(result[0], mock_raw) + mock_read_raw_bids.assert_called_once_with(mock_bids_path1) + + @patch('bcipy.io.convert.os.path.exists') + @patch('bcipy.io.convert.get_entity_vals') + @patch('bcipy.io.convert.find_matching_paths') + @patch('bcipy.io.convert.read_raw_bids') + def test_multiple_sessions(self, mock_read_raw_bids, mock_find_matching_paths, + mock_get_entity_vals, mock_path_exists): + """Test handling multiple sessions.""" + # Setup mocks + mock_path_exists.return_value = True + mock_get_entity_vals.return_value = ['01', '02'] # Multiple sessions + + # Create mock BIDSPath objects for different sessions + mock_bids_path1 = MagicMock() + mock_bids_path1.task = 'RSVPCalibration' + mock_bids_path1.session = '01' + mock_bids_path2 = MagicMock() + mock_bids_path2.task = 'RSVPCalibration' + mock_bids_path2.session = '02' + mock_find_matching_paths.return_value = [mock_bids_path1, mock_bids_path2] + + # Create mock Raw objects + mock_raw1 = MagicMock(spec=mne.io.Raw) + mock_raw2 = MagicMock(spec=mne.io.Raw) + mock_read_raw_bids.side_effect = [mock_raw1, mock_raw2] + + result = BIDS_to_MNE('/fake/bids/path') + + self.assertEqual(len(result), 2) + mock_get_entity_vals.assert_called_once_with('/fake/bids/path', 'session') + self.assertEqual(mock_read_raw_bids.call_count, 2) + + @patch('bcipy.io.convert.os.path.exists') + @patch('bcipy.io.convert.get_entity_vals') + @patch('bcipy.io.convert.find_matching_paths') + @patch('bcipy.io.convert.read_raw_bids') + def test_extension_filtering(self, mock_read_raw_bids, mock_find_matching_paths, + mock_get_entity_vals, mock_path_exists): + """Test file extension filtering.""" + mock_path_exists.return_value = True + mock_get_entity_vals.return_value = ['01'] + + # Check that the function properly searches for supported extensions + mock_find_matching_paths.return_value = [] + + with self.assertRaises(FileNotFoundError): + BIDS_to_MNE('/fake/bids/path') + + # Verify extensions were specified in the find_matching_paths call + call_args = mock_find_matching_paths.call_args[1] + self.assertIn('extensions', call_args) + self.assertTrue(len(call_args['extensions']) > 0) + self.assertIn('.vhdr', call_args['extensions']) # BrainVision format + self.assertIn('.edf', call_args['extensions']) # EDF format + + @patch('bcipy.io.convert.os.path.exists') + @patch('bcipy.io.convert.get_entity_vals') + @patch('bcipy.io.convert.find_matching_paths') + @patch('bcipy.io.convert.read_raw_bids') + @patch('bcipy.io.convert.logger') + def test_debug_logging(self, mock_logger, mock_read_raw_bids, mock_find_matching_paths, + mock_get_entity_vals, mock_path_exists): + """Test debug logging when skipping files due to task name mismatch.""" + mock_path_exists.return_value = True + mock_get_entity_vals.return_value = ['01'] + + # Create mock BIDSPath objects with different tasks + mock_bids_path1 = MagicMock() + mock_bids_path1.task = 'RSVPCalibration' + mock_bids_path2 = MagicMock() + mock_bids_path2.task = 'OtherTask' + mock_find_matching_paths.return_value = [mock_bids_path1, mock_bids_path2] + + # Mock the debug logging method specifically + mock_logger.debug = MagicMock() + + # Create mock Raw object + mock_raw = MagicMock(spec=mne.io.Raw) + mock_read_raw_bids.return_value = mock_raw + + BIDS_to_MNE('/fake/bids/path', task_name='RSVPCalibration') + + # Check if debug was called for skipping a file + self.assertTrue(any('Skipping' in str(args) for args, _ in mock_logger.debug.call_args_list)) + + if __name__ == '__main__': unittest.main() diff --git a/pyproject.toml b/pyproject.toml index 13d82afaf..9bc3d0ef4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "construct==2.8.14", "mne==1.6.1", "mne-bids==0.14.0", + "pyobjc==7.3;platform_system=='Darwin'", "pybv==0.7.5", "pyo==1.0.5", "pyglet<=1.5.27,>=1.4", diff --git a/scripts/shell/m2chip_install.sh b/scripts/shell/m2chip_install.sh index 2aa3e9cd4..8edbb5747 100644 --- a/scripts/shell/m2chip_install.sh +++ b/scripts/shell/m2chip_install.sh @@ -23,9 +23,3 @@ brew install hdf5 brew install portaudio # also make sure the path is correct/exported in .zprofile or .bash_profile export HDF5_DIR=/opt/homebrew/opt/hdf5 -# portaudio installation for soundfile and pyo -python -m pip install pyaudio -# your path may be different to the portaudio version -pip install --global-option='build_ext' --global-option='-I/usr/local/Cellar/portaudio/19.7.0/include' --global-option='-L/usr/local/Cellar/portaudio/19.7.0/lib' pyo -pip install kenlm==0.1 --global-option="--max_order=12" # install kenlm with max_order=12 for language model. Not required for BciPy to run in most places. -pip install -e . From b29116b12f0433c66de0b0c495ec2d9acbaa3103 Mon Sep 17 00:00:00 2001 From: Basak Celik Date: Mon, 12 May 2025 14:11:02 -0400 Subject: [PATCH 74/76] Online Multimodal Fusion (#384) --- bcipy/core/stimuli.py | 6 +- bcipy/signal/evaluate/fusion.py | 1 - bcipy/signal/model/README.md | 2 +- bcipy/signal/model/__init__.py | 3 +- .../signal/model/gaussian_mixture/__init__.py | 3 +- .../gaussian_mixture/gaussian_mixture.py | 204 +++++++----------- bcipy/signal/model/offline_analysis.py | 57 ++--- .../gaussian_mixture/test_gaussian_mixture.py | 9 - bcipy/task/control/evidence.py | 57 +++-- bcipy/task/control/handler.py | 2 +- bcipy/task/paradigm/rsvp/copy_phrase.py | 4 + 11 files changed, 141 insertions(+), 207 deletions(-) diff --git a/bcipy/core/stimuli.py b/bcipy/core/stimuli.py index 43ce5feb5..eace69c40 100644 --- a/bcipy/core/stimuli.py +++ b/bcipy/core/stimuli.py @@ -307,9 +307,11 @@ def __call__(self, for symbol in symbol_set: data_by_targets_dict[symbol] = [] - buffer = stimulus_duration / 5 # seconds, buffer for each inquiry + buffer = 0.5 # seconds, buffer for each inquiry # NOTE: This buffer is used to account for the screen downtime between each stimulus. - # There is a "duty cycle" of 80% for the stimuli, so we add a buffer of 20% of the stimulus length + # A better way of handling this buffer would be subtracting the flash time of the + # second symbol from the first symbol, which gives a more accurate representation of + # "stimulus duration". window_length = (stimulus_duration + buffer) * num_stimuli_per_inquiry # in seconds reshaped_data = [] diff --git a/bcipy/signal/evaluate/fusion.py b/bcipy/signal/evaluate/fusion.py index c23ba928f..824b4a09d 100644 --- a/bcipy/signal/evaluate/fusion.py +++ b/bcipy/signal/evaluate/fusion.py @@ -260,7 +260,6 @@ def calculate_eeg_gaze_fusion_acc( # generate a tuple that matches the index of the symbol with the symbol itself: symbol_to_index = {symbol: i for i, symbol in enumerate(symbol_set)} - # train and save the gaze model as a pkl file: reshaped_data = centralized_gaze_data_train.reshape( (len(centralized_gaze_data_train), inquiry_length * predefined_dimensions)) units = 1e4 diff --git a/bcipy/signal/model/README.md b/bcipy/signal/model/README.md index 9f0abf2d4..a4189030d 100644 --- a/bcipy/signal/model/README.md +++ b/bcipy/signal/model/README.md @@ -62,7 +62,7 @@ These models may be trained and evalulated, but are still being integrated into *Note*: The gaze model is currently under development and is not yet fully implemented. -These models are used to update the posterior probability of stimuli viewed by a user based on gaze data. The gaze model uses a generative model to estimate the likelihood of the gaze data given the stimuli. There are several models implemented in this module, including a Gaussian Mixture Model (GMIndividual and GMCentralized) and Gaussian Process Model (GaussianProcess). When training data via offline analysis, if the data folder contains gaze data, the gaze model will be trained and saved to the output directory. +These models are used to update the posterior probability of stimuli viewed by a user based on gaze data. The gaze model uses a generative model to estimate the likelihood of the gaze data given the stimuli. There are several models implemented in this module, including a Gaussian Mixture Model (GMIndividual) and a Gaussian Process Model (GaussianProcess). When training data via offline analysis, if the data folder contains gaze data, the gaze model will be trained and saved to the output directory. ## Fusion Analyis diff --git a/bcipy/signal/model/__init__.py b/bcipy/signal/model/__init__.py index e4559f1b2..d48a32f45 100644 --- a/bcipy/signal/model/__init__.py +++ b/bcipy/signal/model/__init__.py @@ -2,7 +2,7 @@ from bcipy.signal.model.pca_rda_kde.pca_rda_kde import PcaRdaKdeModel from bcipy.signal.model.rda_kde.rda_kde import RdaKdeModel from bcipy.signal.model.gaussian_mixture.gaussian_mixture import ( - GMIndividual, GMCentralized, GaussianProcess) + GMIndividual, GaussianProcess) __all__ = [ @@ -10,7 +10,6 @@ "PcaRdaKdeModel", "RdaKdeModel", 'GMIndividual', - 'GMCentralized', 'GaussianProcess', "ModelEvaluationReport", ] diff --git a/bcipy/signal/model/gaussian_mixture/__init__.py b/bcipy/signal/model/gaussian_mixture/__init__.py index 9be2725f6..f9b92f3fa 100644 --- a/bcipy/signal/model/gaussian_mixture/__init__.py +++ b/bcipy/signal/model/gaussian_mixture/__init__.py @@ -1,8 +1,7 @@ -from .gaussian_mixture import GMIndividual, GMCentralized, GaussianProcess, GazeModelResolver +from .gaussian_mixture import GMIndividual, GaussianProcess, GazeModelResolver __all__ = [ 'GMIndividual', - 'GMCentralized', 'GaussianProcess', 'GazeModelResolver' ] diff --git a/bcipy/signal/model/gaussian_mixture/gaussian_mixture.py b/bcipy/signal/model/gaussian_mixture/gaussian_mixture.py index 73e64b75a..5d2b591f3 100644 --- a/bcipy/signal/model/gaussian_mixture/gaussian_mixture.py +++ b/bcipy/signal/model/gaussian_mixture/gaussian_mixture.py @@ -1,7 +1,9 @@ +import pickle from pathlib import Path from typing import List from enum import Enum +from bcipy.exceptions import SignalException from bcipy.core.stimuli import GazeReshaper from bcipy.signal.model import SignalModel @@ -17,7 +19,6 @@ class GazeModelType(Enum): """Enum for gaze model types""" GAUSSIAN_PROCESS = "GaussianProcess" GM_INDIVIDUAL = "GMIndividual" - GM_CENTRALIZED = "GMCentralized" def __str__(self): return self.value @@ -31,8 +32,6 @@ def from_str(label: str): return GazeModelType.GAUSSIAN_PROCESS elif label == "GMIndividual": return GazeModelType.GM_INDIVIDUAL - elif label == "GMCentralized": - return GazeModelType.GM_CENTRALIZED else: raise ValueError(f"Model type {label} not recognized.") @@ -51,8 +50,6 @@ def resolve(model_type: str, *args, **kwargs) -> SignalModel: return GaussianProcess(*args, **kwargs) elif model_type == GazeModelType.GM_INDIVIDUAL: return GMIndividual(*args, **kwargs) - elif model_type == GazeModelType.GM_CENTRALIZED: - return GMCentralized(*args, **kwargs) else: raise ValueError( f"Model type {model_type} not able to resolve. Not registered in GazeModelResolver.") @@ -66,24 +63,74 @@ class GaussianProcess(SignalModel): def __init__(self, *args, **kwargs): self.ready_to_predict = False self.acc = None + self.time_average = None + self.centralized_data = None + self.model = None - def fit(self, training_data: np.ndarray): - ... + def fit(self, time_avg: np.ndarray, cent_data: np.ndarray): + """Fit the Gaussian Process model to the training data. + Args: + time_avg Dict[(np.ndarray)]: Time average for the symbols. + mean_data (np.ndarray): Sample average for the training data. + cov_data (np.ndarray): Covariance matrix for the training data. + """ + self.time_average = time_avg + self.centralized_data = cent_data + self.ready_to_predict = True + return self def evaluate(self, test_data: np.ndarray, test_labels: np.ndarray): ... + def evaluate_likelihood(self, data: np.ndarray, symbols: List[str], + symbol_set: List[str]) -> np.ndarray: + if not self.ready_to_predict: + raise SignalException("must use model.fit() before model.evaluate_likelihood()") + + gaze_log_likelihoods = np.zeros((len(symbol_set))) + # Clip the pre-saved centralized data to the length of our test data + cent_data = self.centralized_data[:, :, :data.shape[1]] + reshaped_data = cent_data.reshape((len(cent_data), data.shape[0] * data.shape[1])) + cov_matrix = np.cov(reshaped_data, rowvar=False) + reshaped_mean = np.mean(reshaped_data, axis=0) + eps = 10e-1 # add a small value to the diagonal to make the cov matrix invertible + inv_cov_matrix = np.linalg.inv(cov_matrix + np.eye(len(cov_matrix)) * eps) + + for idx, sym in enumerate(symbol_set): + if self.time_average[sym] == []: + gaze_log_likelihoods[idx] = -100000 # set a very small value + else: + # Compute the likelihood of the data for each symbol + central_data = self.subtract_mean(data, self.time_average[sym]) + # flatten this data + flattened_data = np.reshape(central_data, (-1, )) + diff = flattened_data - reshaped_mean + numerator = -np.dot(diff.T, np.dot(inv_cov_matrix, diff)) / 2 + denominator = 0 + unnormalized_log_likelihood_gaze = numerator - denominator + gaze_log_likelihoods[idx] = unnormalized_log_likelihood_gaze + # Find the gaze_likelihoods for the symbols in the inquiry + gaze_likelihood = np.exp(gaze_log_likelihoods) + + return gaze_likelihood # used in multimodal update + def predict(self, test_data: np.ndarray, inquiry, symbol_set) -> np.ndarray: ... def predict_proba(self, test_data: np.ndarray) -> np.ndarray: ... - def save(self, path: Path): - ... + def save(self, path: Path) -> None: + """Save model weights (e.g. after training) to `path`""" + with open(path, "wb") as f: + pickle.dump(self.model, f) - def load(self, path: Path): - ... + def load(self, path: Path) -> SignalModel: + """Load pretrained model from `path`""" + with open(path, "rb") as f: + model = pickle.load(f) + + return model def centralize(self, data: np.ndarray, symbol_pos: np.ndarray) -> np.ndarray: """ Using the symbol locations in matrix, centralize all data (in Tobii units). @@ -207,9 +254,12 @@ def predict_proba(self, test_data: np.ndarray) -> np.ndarray: return likelihoods - def evaluate_likelihood(self, data: np.ndarray) -> np.ndarray: - data_length, _ = data.shape + def evaluate_likelihood(self, data: np.ndarray, symbols: List[str], + symbol_set: List[str]) -> np.ndarray: + if not self.ready_to_predict: + raise SignalException("must use model.fit() before model.evaluate_likelihood()") + data_length, _ = data.shape likelihoods = np.zeros((data_length, self.num_components), dtype=object) # Find the likelihoods by insterting the test data into the pdf of each component @@ -222,124 +272,14 @@ def evaluate_likelihood(self, data: np.ndarray) -> np.ndarray: return likelihoods - def save(self, path: Path): - """Save model state to the provided checkpoint""" - ... - - def load(self, path: Path): - """Load model state from the provided checkpoint""" - ... - - -class GMCentralized(SignalModel): - '''Gaze model that uses all symbols to fit a single Gaussian ''' - reshaper = GazeReshaper() - name = "gaze_model_combined" - - def __init__(self, num_components=4, random_state=0, *args, **kwargs): - self.num_components = num_components # number of gaussians to fit - self.random_state = random_state - self.acc = None - self.means = None - self.covs = None - - self.ready_to_predict = False - - def fit(self, train_data: np.ndarray): - model = GaussianMixture(n_components=self.num_components, random_state=self.random_state, init_params='kmeans') - model.fit(train_data) - self.model = model - - self.means = model.means_ - self.covs = model.covariances_ - - self.ready_to_predict = True - return self - - def evaluate(self, predictions, true_labels) -> np.ndarray: - ''' - Compute performance characteristics on the provided test data and labels. - - Parameters: - ----------- - predictions: predicted labels for each test point per symbol - true_labels: true labels for each test point per symbol - Returns: - -------- - accuracy_per_symbol: accuracy per symbol - ''' - accuracy_per_symbol = np.sum(predictions == true_labels) / len(predictions) * 100 - self.acc = accuracy_per_symbol - return accuracy_per_symbol - - def predict(self, test_data: np.ndarray) -> np.ndarray: - ''' - Compute log-likelihood of each sample. - Predict the labels for the test data. - ''' - data_length, _ = test_data.shape - predictions = np.zeros(data_length, dtype=object) - likelihoods = self.model.predict_proba(test_data) - - for i in range(data_length): - # Find the argmax of the likelihoods to get the predictions - predictions[i] = np.argmax(likelihoods[i]) - - return predictions - - def predict_proba(self, test_data: np.ndarray) -> np.ndarray: - ''' - Compute log-likelihood of each sample. - Predict the labels for the test data. - - test_data: - ''' - data_length, _ = test_data.shape - - likelihoods = np.zeros((data_length, self.num_components), dtype=object) - - # Find the likelihoods by insterting the test data into the pdf of each component - for i in range(data_length): - for k in range(self.num_components): - mu = self.means[k] - sigma = self.covs[k] - - likelihoods[i, k] = stats.multivariate_normal.pdf(test_data[i], mu, sigma) - - return likelihoods - - def calculate_acc(self, predictions: int, counter: int): - ''' - Compute model performance characteristics on the provided test data and labels. - - predictions: predicted labels for each test point per symbol - counter: true labels for each test point per symbol - ''' - accuracy_per_symbol = np.sum(predictions == counter) / len(predictions) * 100 + def save(self, path: Path) -> None: + """Save model weights (e.g. after training) to `path`""" + with open(path, "wb") as f: + pickle.dump(self.model, f) - return accuracy_per_symbol + def load(self, path: Path) -> SignalModel: + """Load pretrained model from `path`""" + with open(path, "rb") as f: + model = pickle.load(f) - def save(self, path: Path): - """Save model state to the provided checkpoint""" - ... - - def load(self, path: Path): - """Load model state from the provided checkpoint""" - ... - - def centralize(self, data: np.ndarray, symbol_pos: np.ndarray) -> np.ndarray: - """ Using the symbol locations in matrix, centralize all data (in Tobii units). - This data will only be used in certain model types. - Args: - data (np.ndarray): Data in shape of num_samples x num_dimensions - symbol_pos (np.ndarray(float)): Array of the current symbol posiiton in Tobii units - Returns: - new_data (np.ndarray): Centralized data in shape of num_samples x num_dimensions - """ - new_data = np.copy(data) - for i in range(len(data)): - # new_data[i] = data[i] - symbol_pos - new_data[:2, i] = data[:2, i] - symbol_pos - new_data[2:, i] = data[2:, i] - symbol_pos - - return new_data + return model diff --git a/bcipy/signal/model/offline_analysis.py b/bcipy/signal/model/offline_analysis.py index aab4c49f5..5aa02bbdf 100644 --- a/bcipy/signal/model/offline_analysis.py +++ b/bcipy/signal/model/offline_analysis.py @@ -1,5 +1,4 @@ # mypy: disable-error-code="attr-defined" -import json import logging import subprocess from pathlib import Path @@ -14,8 +13,7 @@ from bcipy.acquisition.devices import DeviceSpec from bcipy.config import (DEFAULT_DEVICE_SPEC_FILENAME, DEFAULT_PARAMETERS_PATH, DEFAULT_DEVICES_PATH, - TRIGGER_FILENAME, SESSION_LOG_FILENAME, - STIMULI_POSITIONS_FILENAME) + TRIGGER_FILENAME, SESSION_LOG_FILENAME) from bcipy.helpers.acquisition import analysis_channels, raw_data_filename from bcipy.io.load import (load_experimental_data, load_json_parameters, load_raw_data) @@ -234,7 +232,8 @@ def analyze_gaze( device_spec: DeviceSpec, data_folder: str, model_type: str = "GaussianProcess", - symbol_set: List[str] = alphabet()) -> SignalModel: + symbol_set: List[str] = alphabet(), + testing_acc: float = 0.0) -> SignalModel: """Analyze gaze data and return/save the gaze model. Extract relevant information from gaze data object. Extract timing information from trigger file. @@ -250,15 +249,18 @@ def analyze_gaze( parameters (Parameters): Parameters object retireved from parameters.json. device_spec (DeviceSpec): DeviceSpec object containing information about the device used. data_folder (str): Path to the folder containing the data to be analyzed. - model_type (str): Type of gaze model to be used. Options are: "GMIndividual", "GMCentralized", - or "GaussianProcess". + model_type (str): Type of gaze model to be used. Options are: "GMIndividual" or + "GaussianProcess". + symbol_set (List[str]): List of symbols to be used in the analysis. + testing_acc (float): Testing accuracy of the model. This is calculated during fusion analysis. + Imported to add to the metadata of the model. """ channels = gaze_data.channels type_amp = gaze_data.daq_type sample_rate = gaze_data.sample_rate flash_time = parameters.get("time_flash") # duration of each stimulus - stim_size = parameters.get("stim_length") # number of stimuli per inquiry + stim_length = parameters.get("stim_length") # number of stimuli per inquiry log.info(f"Channels read from csv: {channels}") log.info(f"Device type: {type_amp}, fs={sample_rate}") @@ -286,10 +288,10 @@ def analyze_gaze( ) ''' Trigger_timing includes PROMPT and excludes FIXATION ''' - target_symbols = trigger_symbols[0::stim_size + 1] # target symbols are the PROMPT triggers + target_symbols = trigger_symbols[0::stim_length + 1] # target symbols are the PROMPT triggers # Use trigger_timing to generate time windows for each letter flashing # Take every 10th trigger as the start point of timing. - inq_start = trigger_timing[1::stim_size + 1] # start of each inquiry (here we jump over prompts) + inq_start = trigger_timing[1::stim_length + 1] # start of each inquiry (here we jump over prompts) # Extract the inquiries dictionary with keys as target symbols and values as inquiry windows: inquiries_dict, inquiries_list, _ = model.reshaper( @@ -298,7 +300,7 @@ def analyze_gaze( gaze_data=data, sample_rate=sample_rate, stimulus_duration=flash_time, - num_stimuli_per_inquiry=stim_size, + num_stimuli_per_inquiry=stim_length, symbol_set=symbol_set, ) @@ -345,16 +347,6 @@ def analyze_gaze( preprocessed_data[sym].shape[1])) model.fit(reshaped_data) - if model_type == "GMCentralized": - # Centralize the data using symbol positions and fit a single Gaussian. - # Load json file. - with open(f"{data_folder}/{STIMULI_POSITIONS_FILENAME}", 'r') as params_file: - symbol_positions = json.load(params_file) - - # Subtract the symbol positions from the data: - for j in range(len(preprocessed_data[sym])): - centralized_data[sym].append(model.centralize(preprocessed_data[sym][j], symbol_positions[sym])) - if model_type == "GaussianProcess": # Instead of centralizing, take the time average: for j in range(len(preprocessed_data[sym])): @@ -388,26 +380,17 @@ def analyze_gaze( cov_matrix[l_ind, m_ind] = 0 reshaped_mean = np.mean(reshaped_data, axis=0) - # Save model parameters which are mean and covariance matrix - model.fit(reshaped_mean) - - if model_type == "GMCentralized": - # Fit the model parameters using the centralized data: - # flatten the dict to a np array: - cent_data = np.concatenate([centralized_data[sym] for sym in symbol_set], axis=0) - # Merge the first and third dimensions: - cent_data = cent_data.reshape((cent_data.shape[0] * cent_data.shape[2], cent_data.shape[1])) - - # cent_data = np.concatenate(centralized_data, axis=0) - model.fit(cent_data) + # Save model parameters which are time averages per symbol (time_average): Dict(np.array), + # and centralized data points (centralized_gaze_data): (np.array) of shape N_inquiries x N_dims x N_timesamples + model.fit(time_average, centralized_gaze_data) model.metadata = SignalModelMetadata(device_spec=device_spec, transform=None, - acc=model.acc) + acc=testing_acc) log.info("Training complete for Eyetracker model. Saving data...") save_model( model, - Path(data_folder, f"model_{device_spec.content_type.lower()}_{model.acc}.pkl")) + Path(data_folder, f"model_{device_spec.content_type.lower()}_{model.metadata.acc}.pkl")) return model @@ -478,6 +461,7 @@ def offline_analysis( symbol_set = alphabet() fusion = False + avg_testing_acc_gaze = 0.0 if num_devices == 2: # Ensure there is an EEG and Eyetracker device fusion = True @@ -506,6 +490,8 @@ def offline_analysis( ) log.info(f"EEG Accuracy: {eeg_acc}, Gaze Accuracy: {gaze_acc}, Fusion Accuracy: {fusion_acc}") + # The average gaze model accuracy: + avg_testing_acc_gaze = round(np.mean(gaze_acc), 3) # Ask the user if they want to proceed with full dataset model training models = [] @@ -532,7 +518,8 @@ def offline_analysis( parameters, device_spec, data_folder, - symbol_set=symbol_set) + symbol_set=symbol_set, + testing_acc=avg_testing_acc_gaze) models.append(et_model) if alert: diff --git a/bcipy/signal/tests/model/gaussian_mixture/test_gaussian_mixture.py b/bcipy/signal/tests/model/gaussian_mixture/test_gaussian_mixture.py index df373f282..ff5fe3448 100644 --- a/bcipy/signal/tests/model/gaussian_mixture/test_gaussian_mixture.py +++ b/bcipy/signal/tests/model/gaussian_mixture/test_gaussian_mixture.py @@ -2,7 +2,6 @@ from bcipy.signal.model.gaussian_mixture import ( GaussianProcess, - GMCentralized, GMIndividual, GazeModelResolver ) @@ -14,10 +13,6 @@ def test_resolve(self): response = GazeModelResolver.resolve('GaussianProcess') self.assertIsInstance(response, GaussianProcess) - def test_resolve_centralized(self): - response = GazeModelResolver.resolve('GMCentralized') - self.assertIsInstance(response, GMCentralized) - def test_resolve_individual(self): response = GazeModelResolver.resolve('GMIndividual') self.assertIsInstance(response, GMIndividual) @@ -33,10 +28,6 @@ def test_gaussian_process(self): model = GaussianProcess() self.assertIsInstance(model, GaussianProcess) - def test_centrailized(self): - model = GMCentralized() - self.assertIsInstance(model, GMCentralized) - def test_individual(self): model = GMIndividual() self.assertIsInstance(model, GMIndividual) diff --git a/bcipy/task/control/evidence.py b/bcipy/task/control/evidence.py index 824954dcf..684d4c5a3 100644 --- a/bcipy/task/control/evidence.py +++ b/bcipy/task/control/evidence.py @@ -8,10 +8,11 @@ from bcipy.acquisition.multimodal import ContentType from bcipy.config import SESSION_LOG_FILENAME from bcipy.core.parameters import Parameters -from bcipy.core.stimuli import TrialReshaper from bcipy.display.main import ButtonPressMode from bcipy.helpers.acquisition import analysis_channels +from bcipy.core.stimuli import TrialReshaper, GazeReshaper from bcipy.signal.model import SignalModel +from bcipy.signal.process import extract_eye_info from bcipy.task.data import EvidenceType from bcipy.task.exceptions import MissingEvidenceEvaluator @@ -105,7 +106,7 @@ def preprocess(self, raw_data: np.ndarray, times: List[float], # pylint: disable=arguments-differ def evaluate(self, raw_data: np.ndarray, symbols: List[str], times: List[float], target_info: List[str], - window_length: float) -> np.ndarray: + window_length: float, *args) -> np.ndarray: """Evaluate the evidence. Parameters @@ -143,38 +144,50 @@ def __init__(self, self.channel_map = analysis_channels(self.device_spec.channels, self.device_spec) self.transform = signal_model.metadata.transform - self.reshape = TrialReshaper() + self.reshape = GazeReshaper() def preprocess(self, raw_data: np.ndarray, times: List[float], - target_info: List[str], window_length: float) -> np.ndarray: + flash_time: float) -> np.ndarray: """Preprocess the inquiry data. Parameters ---------- raw_data - C x L eeg data where C is number of channels and L is the - signal length + signal length. Includes all channels in devices.json symbols - symbols displayed in the inquiry times - timestamps associated with each symbol - target_info - target information about the stimuli; - ex. ['nontarget', 'nontarget', ...] - window_length - The length of the time between stimuli presentation + flash_time - duration (in seconds) of each stimulus + + Function + -------- + The preprocessing is functionally different than Gaze Reshaper, since + the raw data contains only one inquiry. start_idx is determined as the + start time of first symbol flashing multiplied by the sampling rate + of eye tracker. stop_idx is the index indicating the end of last + symbol flashing. """ - transformed_data, transform_sample_rate = self.transform( - raw_data, self.device_spec.sample_rate) + if self.transform: + transformed_data, transform_sample_rate = self.transform( + raw_data, self.device_spec.sample_rate) + else: + transformed_data = raw_data + transform_sample_rate = self.device_spec.sample_rate - # The data from DAQ is assumed to have offsets applied - reshaped_data, _lbls = self.reshape(trial_targetness_label=target_info, - timing_info=times, - eeg_data=transformed_data, - sample_rate=transform_sample_rate, - channel_map=self.channel_map, - poststimulus_length=window_length) - return reshaped_data + start_idx = int(self.device_spec.sample_rate * times[0]) + stop_idx = start_idx + int((times[-1] - times[0] + flash_time) * self.device_spec.sample_rate) + data_all_channels = transformed_data[:, start_idx:stop_idx] + + # Extract left and right eye from all channels. Remove/replace nan values + left_eye, right_eye, _, _, _, _ = extract_eye_info(data_all_channels) + reshaped_data = np.vstack((np.array(left_eye).T, np.array(right_eye).T)) + + return reshaped_data # (4, N_samples) # pylint: disable=arguments-differ def evaluate(self, raw_data: np.ndarray, symbols: List[str], times: List[float], target_info: List[str], - window_length: float) -> np.ndarray: + window_length: float, flash_time: float, + stim_length: float) -> np.ndarray: """Evaluate the evidence. Parameters @@ -187,11 +200,11 @@ def evaluate(self, raw_data: np.ndarray, symbols: List[str], ex. ['nontarget', 'nontarget', ...] window_length - The length of the time between stimuli presentation """ - data = self.preprocess(raw_data, times, target_info, window_length) + data = self.preprocess(raw_data, times, flash_time) # We need the likelihoods in the form of p(label | gaze). predict returns the argmax of the likelihoods. # Therefore we need predict_proba method to get the likelihoods. - return self.signal_model.evaluate_likelihood( - data) # multiplication over the inquiry + likelihood = self.signal_model.evaluate_likelihood(data, symbols, self.symbol_set) + return likelihood class SwitchEvaluator(EvidenceEvaluator): diff --git a/bcipy/task/control/handler.py b/bcipy/task/control/handler.py index 3e67cdfd7..cf1a039b9 100644 --- a/bcipy/task/control/handler.py +++ b/bcipy/task/control/handler.py @@ -33,7 +33,7 @@ def update_and_fuse(self, dict_evidence): dict_evidence(dict{name: ndarray[float]}): dictionary of evidences (EEG (likelihood ratios) and other likelihoods) """ - # {EEG: [], GAZE: ()} + # {ERP: [], EYE: ()} for key in dict_evidence.keys(): tmp = dict_evidence[key][:][:] diff --git a/bcipy/task/paradigm/rsvp/copy_phrase.py b/bcipy/task/paradigm/rsvp/copy_phrase.py index aa76a7842..877262571 100644 --- a/bcipy/task/paradigm/rsvp/copy_phrase.py +++ b/bcipy/task/paradigm/rsvp/copy_phrase.py @@ -725,6 +725,8 @@ def compute_device_evidence( post_stim_buffer = int(self.parameters.get("task_buffer_length") / 2) prestim_buffer: float = self.parameters["prestim_length"] trial_window: Tuple[float, float] = self.parameters["trial_window"] + flash_time = self.parameters["time_flash"] + stim_length = self.parameters["stim_length"] window_length = trial_window[1] - trial_window[0] inquiry_timing = self.stims_for_decision(stim_times) @@ -757,6 +759,8 @@ def compute_device_evidence( times=times, target_info=filtered_labels, window_length=window_length, + flash_time=flash_time, + stim_length=stim_length, ) evidences.append((evidence_evaluator.produces, probs)) From 040fd574c50ae23f02e15ba56b83bd3f9ac8f176 Mon Sep 17 00:00:00 2001 From: lawhead Date: Wed, 28 May 2025 13:51:24 -0700 Subject: [PATCH 75/76] import cleanup (#393) Organized imports across the code base --- bcipy/acquisition/datastream/generator.py | 3 +- bcipy/acquisition/datastream/lsl_server.py | 5 ++- bcipy/acquisition/datastream/producer.py | 4 +- .../demo_eye_tracker_client_and_server.py | 4 +- bcipy/acquisition/demo/demo_lsl_acq_client.py | 1 + bcipy/acquisition/demo/demo_lsl_recorder.py | 3 +- bcipy/acquisition/devices.py | 4 +- bcipy/acquisition/marker_writer.py | 1 + .../tests/datastream/test_generator.py | 10 +++-- .../tests/protocols/lsl/test_lsl_recorder.py | 2 +- bcipy/acquisition/util.py | 1 + bcipy/core/demo/demo_report.py | 19 ++++------ bcipy/core/demo/demo_session_tools.py | 2 +- bcipy/core/demo/demo_stimuli_generation.py | 4 +- bcipy/core/raw_data.py | 2 +- bcipy/core/report.py | 5 +-- bcipy/core/session.py | 11 +++--- bcipy/core/stimuli.py | 5 ++- bcipy/core/tests/test_raw_data.py | 9 ++--- bcipy/core/tests/test_report.py | 10 ++--- bcipy/core/tests/test_stimuli.py | 9 ++--- bcipy/core/tests/test_triggers.py | 5 +-- bcipy/demo/bci_main_demo.py | 2 +- bcipy/display/__init__.py | 10 ++--- bcipy/display/demo/components/demo_layouts.py | 2 +- bcipy/display/paradigm/matrix/__init__.py | 4 +- bcipy/display/paradigm/matrix/display.py | 6 +-- bcipy/display/paradigm/rsvp/__init__.py | 4 +- .../display/paradigm/rsvp/mode/calibration.py | 2 +- .../display/paradigm/rsvp/mode/copy_phrase.py | 2 +- bcipy/display/paradigm/vep/display.py | 8 ++-- .../display/tests/components/test_task_bar.py | 6 ++- bcipy/display/tests/test_display.py | 3 +- bcipy/feedback/feedback.py | 1 + bcipy/feedback/sound/auditory_feedback.py | 5 ++- .../tests/sound/test_sound_feedback.py | 5 +-- bcipy/feedback/visual/visual_feedback.py | 4 +- bcipy/gui/alert.py | 5 ++- bcipy/gui/bciui.py | 21 ++++------- bcipy/gui/experiments/ExperimentField.py | 34 +++++------------ bcipy/gui/experiments/ExperimentRegistry.py | 35 +++++++----------- bcipy/gui/experiments/FieldRegistry.py | 2 +- .../demo/demo_experiment_field_collection.py | 3 +- bcipy/gui/file_dialog.py | 2 +- bcipy/gui/intertask_gui.py | 14 +++---- bcipy/gui/parameters/params_form.py | 2 +- bcipy/gui/tests/viewer/test_gui_main.py | 3 +- bcipy/gui/tests/viewer/test_ring_buffer.py | 1 + bcipy/gui/viewer/data_source/data_source.py | 2 +- bcipy/gui/viewer/data_source/file_streamer.py | 1 + bcipy/gui/viewer/data_viewer.py | 11 +++--- bcipy/helpers/clock.py | 1 + bcipy/helpers/demo/demo_visualization.py | 4 +- bcipy/helpers/offset.py | 20 ++++------ bcipy/helpers/task.py | 5 ++- bcipy/helpers/tests/test_acquisition.py | 2 +- .../helpers/tests/test_copy_phrase_wrapper.py | 2 +- bcipy/helpers/tests/test_language_model.py | 4 +- bcipy/helpers/tests/test_offset.py | 26 ++++++------- bcipy/helpers/tests/test_system_utils.py | 16 ++++---- bcipy/helpers/tests/test_validate.py | 10 ++--- bcipy/helpers/tests/test_visualization.py | 12 +++--- bcipy/helpers/validate.py | 16 ++++---- bcipy/helpers/visualization.py | 13 +++---- bcipy/io/convert.py | 13 ++++--- bcipy/io/demo/demo_convert.py | 6 ++- bcipy/io/demo/demo_load_BIDS.py | 5 ++- bcipy/io/load.py | 7 ++-- bcipy/io/save.py | 6 +-- bcipy/io/tests/test_convert.py | 22 ++++------- bcipy/io/tests/test_load.py | 5 +-- bcipy/io/tests/test_save.py | 14 +++---- bcipy/language/__init__.py | 2 +- bcipy/language/demo/demo_causal.py | 3 +- bcipy/language/demo/demo_mixture.py | 3 +- bcipy/language/demo/demo_ngram.py | 4 +- bcipy/language/tests/test_causal.py | 10 ++--- bcipy/main.py | 2 +- bcipy/preferences.py | 5 ++- bcipy/signal/evaluate/artifact.py | 37 ++++++++----------- bcipy/signal/evaluate/fusion.py | 17 ++++----- bcipy/signal/generator/generator.py | 3 +- bcipy/signal/model/__init__.py | 7 ++-- bcipy/signal/model/cross_validation.py | 6 +-- .../signal/model/dimensionality_reduction.py | 2 +- .../signal/model/gaussian_mixture/__init__.py | 2 +- .../gaussian_mixture/gaussian_mixture.py | 14 +++---- bcipy/signal/model/offline_analysis.py | 25 ++++++------- bcipy/signal/model/pca_rda_kde/pca_rda_kde.py | 2 +- bcipy/signal/model/rda_kde/rda_kde.py | 8 ++-- bcipy/signal/process/__init__.py | 2 +- bcipy/signal/process/decomposition/cwt.py | 1 + bcipy/signal/process/decomposition/psd.py | 12 +++--- bcipy/signal/tests/evaluate/test_artifact.py | 7 +++- .../gaussian_mixture/test_gaussian_mixture.py | 8 ++-- .../model/pca_rda_kde/test_pca_rda_kde.py | 2 +- .../tests/model/rda_kde/test_rda_kde.py | 2 +- .../tests/model/test_offline_analysis.py | 17 +++++---- bcipy/signal/tests/process/test_downsample.py | 6 ++- bcipy/simulator/demo/demo_group_simulation.py | 10 +++-- bcipy/simulator/task/task_runner.py | 2 +- bcipy/task/__init__.py | 1 - bcipy/task/actions.py | 35 +++++++++--------- bcipy/task/calibration.py | 18 ++++----- bcipy/task/control/criteria.py | 6 ++- bcipy/task/control/evidence.py | 2 +- bcipy/task/control/handler.py | 8 ++-- .../demo/actions/demo_calibration_report.py | 3 +- bcipy/task/demo/control/demo_handler.py | 3 +- .../demo/orchestrator/demo_orchestrator.py | 6 ++- bcipy/task/main.py | 8 ++-- bcipy/task/orchestrator/orchestrator.py | 28 ++++++-------- bcipy/task/orchestrator/protocol.py | 3 +- bcipy/task/paradigm/matrix/calibration.py | 6 +-- bcipy/task/paradigm/matrix/copy_phrase.py | 4 +- .../paradigm/matrix/timing_verification.py | 5 +-- bcipy/task/paradigm/rsvp/__init__.py | 3 +- .../paradigm/rsvp/calibration/calibration.py | 2 +- .../rsvp/calibration/timing_verification.py | 5 +-- bcipy/task/paradigm/vep/calibration.py | 4 +- bcipy/task/registry.py | 3 +- bcipy/task/tests/core/test_actions.py | 11 ++++-- bcipy/task/tests/core/test_handler.py | 12 ++++-- bcipy/task/tests/core/test_task_main.py | 1 + .../tests/orchestrator/test_orchestrator.py | 12 +++--- .../task/tests/orchestrator/test_protocol.py | 8 +++- .../matrix/test_matrix_calibration.py | 2 +- .../tests/paradigm/rsvp/test_copy_phrase.py | 4 +- bcipy/tests/test_preferences.py | 3 +- pyproject.toml | 3 ++ 130 files changed, 464 insertions(+), 494 deletions(-) diff --git a/bcipy/acquisition/datastream/generator.py b/bcipy/acquisition/datastream/generator.py index 8c6798bb5..cb2dfe04c 100644 --- a/bcipy/acquisition/datastream/generator.py +++ b/bcipy/acquisition/datastream/generator.py @@ -1,6 +1,7 @@ """Functions for generating mock data to be used for testing/development.""" -from typing import Optional, Generator, Callable +from typing import Callable, Generator, Optional + from past.builtins import range from bcipy.config import DEFAULT_ENCODING diff --git a/bcipy/acquisition/datastream/lsl_server.py b/bcipy/acquisition/datastream/lsl_server.py index 0f8f82aa3..cee201f9f 100644 --- a/bcipy/acquisition/datastream/lsl_server.py +++ b/bcipy/acquisition/datastream/lsl_server.py @@ -5,7 +5,7 @@ import time import uuid from queue import Empty, Queue -from typing import Optional, Generator +from typing import Generator, Optional from pylsl import StreamInfo, StreamOutlet @@ -13,7 +13,8 @@ from bcipy.acquisition.datastream.producer import Producer from bcipy.acquisition.devices import DeviceSpec from bcipy.acquisition.util import StoppableThread -from bcipy.config import DEFAULT_ENCODING, MARKER_STREAM_NAME, SESSION_LOG_FILENAME +from bcipy.config import (DEFAULT_ENCODING, MARKER_STREAM_NAME, + SESSION_LOG_FILENAME) log = logging.getLogger(SESSION_LOG_FILENAME) # pylint: disable=too-many-arguments diff --git a/bcipy/acquisition/datastream/producer.py b/bcipy/acquisition/datastream/producer.py index d62ba60d3..ffdc310c9 100644 --- a/bcipy/acquisition/datastream/producer.py +++ b/bcipy/acquisition/datastream/producer.py @@ -1,11 +1,11 @@ """Code for mocking an EEG data stream. Code in this module produces data at a specified frequency.""" import logging -from builtins import next -from queue import Queue import random import threading import time +from builtins import next +from queue import Queue from bcipy.acquisition.datastream.generator import random_data_generator from bcipy.config import SESSION_LOG_FILENAME diff --git a/bcipy/acquisition/demo/demo_eye_tracker_client_and_server.py b/bcipy/acquisition/demo/demo_eye_tracker_client_and_server.py index 6d0003420..cf0b95887 100644 --- a/bcipy/acquisition/demo/demo_eye_tracker_client_and_server.py +++ b/bcipy/acquisition/demo/demo_eye_tracker_client_and_server.py @@ -2,8 +2,8 @@ import time from bcipy.acquisition import LslAcquisitionClient, await_start -from bcipy.acquisition.datastream.mock.eye_tracker_server import (eye_tracker_device, - eye_tracker_server) +from bcipy.acquisition.datastream.mock.eye_tracker_server import ( + eye_tracker_device, eye_tracker_server) def main(): diff --git a/bcipy/acquisition/demo/demo_lsl_acq_client.py b/bcipy/acquisition/demo/demo_lsl_acq_client.py index 3679e94c4..b1c0eacbf 100644 --- a/bcipy/acquisition/demo/demo_lsl_acq_client.py +++ b/bcipy/acquisition/demo/demo_lsl_acq_client.py @@ -1,6 +1,7 @@ """Demo for the LslAcquisitionClient""" import time + from bcipy.acquisition import LslAcquisitionClient diff --git a/bcipy/acquisition/demo/demo_lsl_recorder.py b/bcipy/acquisition/demo/demo_lsl_recorder.py index b2efe1af8..2b14feb28 100644 --- a/bcipy/acquisition/demo/demo_lsl_recorder.py +++ b/bcipy/acquisition/demo/demo_lsl_recorder.py @@ -1,8 +1,9 @@ """Sample script to demonstrate usage of the LSL Recorder for writing data from an LSL stream.""" -from bcipy.acquisition.protocols.lsl.lsl_recorder import LslRecorder import time +from bcipy.acquisition.protocols.lsl.lsl_recorder import LslRecorder + SLEEP = 10 PATH = '.' diff --git a/bcipy/acquisition/devices.py b/bcipy/acquisition/devices.py index 74b152e58..77308a5a1 100644 --- a/bcipy/acquisition/devices.py +++ b/bcipy/acquisition/devices.py @@ -6,8 +6,8 @@ from pathlib import Path from typing import Dict, List, NamedTuple, Optional, Union -from bcipy.config import DEFAULT_ENCODING, DEVICE_SPEC_PATH, SESSION_LOG_FILENAME - +from bcipy.config import (DEFAULT_ENCODING, DEVICE_SPEC_PATH, + SESSION_LOG_FILENAME) IRREGULAR_RATE: int = 0 DEFAULT_CONFIG = DEVICE_SPEC_PATH diff --git a/bcipy/acquisition/marker_writer.py b/bcipy/acquisition/marker_writer.py index 6d4172654..fd6216014 100644 --- a/bcipy/acquisition/marker_writer.py +++ b/bcipy/acquisition/marker_writer.py @@ -3,6 +3,7 @@ from typing import Any import pylsl + from bcipy.config import SESSION_LOG_FILENAME log = logging.getLogger(SESSION_LOG_FILENAME) diff --git a/bcipy/acquisition/tests/datastream/test_generator.py b/bcipy/acquisition/tests/datastream/test_generator.py index a4469553c..82db8cc14 100644 --- a/bcipy/acquisition/tests/datastream/test_generator.py +++ b/bcipy/acquisition/tests/datastream/test_generator.py @@ -1,10 +1,14 @@ # pylint: disable=too-few-public-methods,no-self-use """Tests for datastream generator module""" -from builtins import next import unittest -from past.builtins import map, range +from builtins import next + from mock import mock_open, patch -from bcipy.acquisition.datastream.generator import random_data_generator, file_data_generator, generator_with_args +from past.builtins import map, range + +from bcipy.acquisition.datastream.generator import (file_data_generator, + generator_with_args, + random_data_generator) from bcipy.acquisition.util import mock_data diff --git a/bcipy/acquisition/tests/protocols/lsl/test_lsl_recorder.py b/bcipy/acquisition/tests/protocols/lsl/test_lsl_recorder.py index 57bef2483..06f77dfcb 100644 --- a/bcipy/acquisition/tests/protocols/lsl/test_lsl_recorder.py +++ b/bcipy/acquisition/tests/protocols/lsl/test_lsl_recorder.py @@ -1,11 +1,11 @@ """Tests for LslRecorder""" +import logging import tempfile import time import unittest from pathlib import Path import pytest -import logging from bcipy.acquisition.datastream.lsl_server import LslDataServer from bcipy.acquisition.datastream.mock.eye_tracker_server import \ diff --git a/bcipy/acquisition/util.py b/bcipy/acquisition/util.py index 15472c23d..1e909dee2 100644 --- a/bcipy/acquisition/util.py +++ b/bcipy/acquisition/util.py @@ -1,6 +1,7 @@ """Defines utility classes and functions used by the acquisition module.""" import multiprocessing import threading + import numpy as np diff --git a/bcipy/core/demo/demo_report.py b/bcipy/core/demo/demo_report.py index 3a2771c75..c2d3bb4af 100644 --- a/bcipy/core/demo/demo_report.py +++ b/bcipy/core/demo/demo_report.py @@ -1,20 +1,17 @@ from pathlib import Path -from bcipy.io.load import load_json_parameters, load_raw_data, load_experimental_data -from bcipy.core.triggers import trigger_decoder, TriggerType -from bcipy.config import ( - BCIPY_ROOT, - DEFAULT_PARAMETERS_FILENAME, - RAW_DATA_FILENAME, - TRIGGER_FILENAME, - DEFAULT_DEVICE_SPEC_FILENAME) from bcipy.acquisition import devices +from bcipy.config import (BCIPY_ROOT, DEFAULT_DEVICE_SPEC_FILENAME, + DEFAULT_PARAMETERS_FILENAME, RAW_DATA_FILENAME, + TRIGGER_FILENAME) +from bcipy.core.report import Report, SessionReportSection, SignalReportSection +from bcipy.core.triggers import TriggerType, trigger_decoder from bcipy.helpers.acquisition import analysis_channels from bcipy.helpers.visualization import visualize_erp -from bcipy.signal.process import get_default_transform +from bcipy.io.load import (load_experimental_data, load_json_parameters, + load_raw_data) from bcipy.signal.evaluate.artifact import ArtifactDetection -from bcipy.core.report import Report, SignalReportSection, SessionReportSection - +from bcipy.signal.process import get_default_transform if __name__ == "__main__": import argparse diff --git a/bcipy/core/demo/demo_session_tools.py b/bcipy/core/demo/demo_session_tools.py index b3c06dd51..97b9bbcdd 100644 --- a/bcipy/core/demo/demo_session_tools.py +++ b/bcipy/core/demo/demo_session_tools.py @@ -4,9 +4,9 @@ from pathlib import Path from bcipy.config import SESSION_DATA_FILENAME, SESSION_SUMMARY_FILENAME -from bcipy.gui.file_dialog import ask_directory from bcipy.core.session import (read_session, session_csv, session_data, session_db, session_excel) +from bcipy.gui.file_dialog import ask_directory def main(data_dir: str): diff --git a/bcipy/core/demo/demo_stimuli_generation.py b/bcipy/core/demo/demo_stimuli_generation.py index d5449b598..1ed8c3648 100644 --- a/bcipy/core/demo/demo_stimuli_generation.py +++ b/bcipy/core/demo/demo_stimuli_generation.py @@ -1,6 +1,8 @@ -from bcipy.core.stimuli import StimuliOrder, generate_calibration_inquiries, best_case_rsvp_inq_gen import numpy as np +from bcipy.core.stimuli import (StimuliOrder, best_case_rsvp_inq_gen, + generate_calibration_inquiries) + def _demo_random_rsvp_inquiry_generator(): alp = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', diff --git a/bcipy/core/raw_data.py b/bcipy/core/raw_data.py index ea9a0d2c4..60f04d728 100644 --- a/bcipy/core/raw_data.py +++ b/bcipy/core/raw_data.py @@ -1,7 +1,7 @@ """Functionality for reading and writing raw signal data.""" import csv from io import TextIOWrapper -from typing import List, Optional, TextIO, Tuple, Union, Any +from typing import Any, List, Optional, TextIO, Tuple, Union import numpy as np import pandas as pd diff --git a/bcipy/core/report.py b/bcipy/core/report.py index 774e7ea8e..1ce5504be 100644 --- a/bcipy/core/report.py +++ b/bcipy/core/report.py @@ -4,13 +4,12 @@ from typing import List, Optional, Tuple from matplotlib import pyplot as plt - from matplotlib.figure import Figure -from reportlab.platypus import SimpleDocTemplate, Paragraph, Image from reportlab.lib.pagesizes import letter from reportlab.lib.styles import getSampleStyleSheet -from reportlab.platypus import Flowable, KeepTogether from reportlab.lib.units import inch +from reportlab.platypus import (Flowable, Image, KeepTogether, Paragraph, + SimpleDocTemplate) from bcipy.config import BCIPY_FULL_LOGO_PATH from bcipy.signal.evaluate.artifact import ArtifactDetection diff --git a/bcipy/core/session.py b/bcipy/core/session.py index 1c4cc5dab..5fcce9267 100644 --- a/bcipy/core/session.py +++ b/bcipy/core/session.py @@ -13,15 +13,16 @@ from openpyxl.styles import PatternFill from openpyxl.styles.borders import BORDER_THIN, Border, Side from openpyxl.styles.colors import COLOR_INDEX -BLACK = COLOR_INDEX[0] -WHITE = COLOR_INDEX[1] -YELLOW = COLOR_INDEX[5] -from bcipy.config import (DEFAULT_ENCODING, - DEFAULT_PARAMETERS_FILENAME, + +from bcipy.config import (DEFAULT_ENCODING, DEFAULT_PARAMETERS_FILENAME, SESSION_DATA_FILENAME, SESSION_SUMMARY_FILENAME) from bcipy.io.load import load_json_parameters from bcipy.task.data import Session +BLACK = COLOR_INDEX[0] +WHITE = COLOR_INDEX[1] +YELLOW = COLOR_INDEX[5] + def read_session(file_name: str = SESSION_DATA_FILENAME) -> Session: """Read the session data from the given file.""" diff --git a/bcipy/core/stimuli.py b/bcipy/core/stimuli.py index eace69c40..58de25f43 100644 --- a/bcipy/core/stimuli.py +++ b/bcipy/core/stimuli.py @@ -21,10 +21,11 @@ from PIL import Image from psychopy import core -from bcipy.config import DEFAULT_FIXATION_PATH, DEFAULT_TEXT_FIXATION, SESSION_LOG_FILENAME -from bcipy.exceptions import BciPyCoreException +from bcipy.config import (DEFAULT_FIXATION_PATH, DEFAULT_TEXT_FIXATION, + SESSION_LOG_FILENAME) from bcipy.core.list import grouper from bcipy.core.symbols import alphabet +from bcipy.exceptions import BciPyCoreException # Prevents pillow from filling the console with debug info logging.getLogger('PIL').setLevel(logging.WARNING) diff --git a/bcipy/core/tests/test_raw_data.py b/bcipy/core/tests/test_raw_data.py index 31d623ad1..6d8294be0 100644 --- a/bcipy/core/tests/test_raw_data.py +++ b/bcipy/core/tests/test_raw_data.py @@ -7,13 +7,12 @@ import numpy as np import pandas as pd +from mockito import any, mock, unstub, verify, when -from mockito import any, mock, when, verify, unstub - -from bcipy.exceptions import BciPyCoreException from bcipy.core.raw_data import (RawData, RawDataReader, RawDataWriter, - load, sample_data, settings, write, - get_1020_channel_map, get_1020_channels) + get_1020_channel_map, get_1020_channels, load, + sample_data, settings, write) +from bcipy.exceptions import BciPyCoreException class TestRawData(unittest.TestCase): diff --git a/bcipy/core/tests/test_report.py b/bcipy/core/tests/test_report.py index 79a25915c..4837bbdcc 100644 --- a/bcipy/core/tests/test_report.py +++ b/bcipy/core/tests/test_report.py @@ -1,15 +1,15 @@ import os +import shutil +import tempfile import unittest from unittest.mock import patch -import tempfile -import shutil import matplotlib.pyplot as plt import numpy as np -from reportlab.platypus import Paragraph, Image -from reportlab.platypus import Flowable, KeepTogether +from reportlab.platypus import Flowable, Image, KeepTogether, Paragraph -from bcipy.core.report import Report, SessionReportSection, ReportSection, SignalReportSection +from bcipy.core.report import (Report, ReportSection, SessionReportSection, + SignalReportSection) class TestReport(unittest.TestCase): diff --git a/bcipy/core/tests/test_stimuli.py b/bcipy/core/tests/test_stimuli.py index 4bef764d7..3c5861e28 100644 --- a/bcipy/core/tests/test_stimuli.py +++ b/bcipy/core/tests/test_stimuli.py @@ -9,12 +9,10 @@ from mockito import any, mock, unstub, verify, when from psychopy import core -from bcipy.exceptions import BciPyCoreException from bcipy.core.stimuli import (DEFAULT_FIXATION_PATH, InquiryReshaper, - StimuliOrder, TargetPositions, - TrialReshaper, alphabetize, - best_case_rsvp_inq_gen, best_selection, - distributed_target_positions, + StimuliOrder, TargetPositions, TrialReshaper, + alphabetize, best_case_rsvp_inq_gen, + best_selection, distributed_target_positions, generate_calibration_inquiries, generate_inquiry, generate_targets, get_fixation, inquiry_nontarget_counts, @@ -22,6 +20,7 @@ jittered_timing, play_sound, random_target_positions, soundfiles, target_index, update_inquiry_timing) +from bcipy.exceptions import BciPyCoreException MOCK_FS = 44100 diff --git a/bcipy/core/tests/test_triggers.py b/bcipy/core/tests/test_triggers.py index 2ac6500d6..00ddccf3d 100644 --- a/bcipy/core/tests/test_triggers.py +++ b/bcipy/core/tests/test_triggers.py @@ -5,14 +5,13 @@ import psychopy from mockito import any, mock, unstub, verify, when -from bcipy.exceptions import BciPyCoreException from bcipy.core.triggers import (FlushFrequency, Trigger, TriggerHandler, TriggerType, _calibration_trigger, apply_offsets, exclude_types, find_starting_offset, offset_device, offset_label, read, read_data, - starting_offsets_by_device, - trigger_decoder) + starting_offsets_by_device, trigger_decoder) +from bcipy.exceptions import BciPyCoreException class TestCalibrationTrigger(unittest.TestCase): diff --git a/bcipy/demo/bci_main_demo.py b/bcipy/demo/bci_main_demo.py index 4e3c0adb3..cc51c5385 100644 --- a/bcipy/demo/bci_main_demo.py +++ b/bcipy/demo/bci_main_demo.py @@ -1,5 +1,5 @@ -from bcipy.main import bci_main from bcipy.config import DEFAULT_PARAMETERS_PATH +from bcipy.main import bci_main parameter_location = DEFAULT_PARAMETERS_PATH # Path to a valid BciPy parameters file user = 'test_demo_user' # User ID diff --git a/bcipy/display/__init__.py b/bcipy/display/__init__.py index 58bbf82eb..99506545c 100644 --- a/bcipy/display/__init__.py +++ b/bcipy/display/__init__.py @@ -4,13 +4,9 @@ `from bcipy.display import init_display_window` vs. `from bcipy.display.main import init_display_window` """ from bcipy.config import BCIPY_LOGO_PATH -from .main import ( - Display, - InformationProperties, - init_display_window, - StimuliProperties, - VEPStimuliProperties -) + +from .main import (Display, InformationProperties, StimuliProperties, + VEPStimuliProperties, init_display_window) __all__ = [ 'Display', diff --git a/bcipy/display/demo/components/demo_layouts.py b/bcipy/display/demo/components/demo_layouts.py index 6fbcd78c9..f0e2179f3 100644 --- a/bcipy/display/demo/components/demo_layouts.py +++ b/bcipy/display/demo/components/demo_layouts.py @@ -10,12 +10,12 @@ from psychopy.visual.rect import Rect from psychopy.visual.shape import ShapeStim +from bcipy.core.symbols import alphabet from bcipy.display.components.layout import (Layout, at_top, centered, envelope, height_units, scaled_size) from bcipy.display.paradigm.matrix.layout import symbol_positions from bcipy.display.paradigm.vep.layout import BoxConfiguration, checkerboard -from bcipy.core.symbols import alphabet def make_window(): diff --git a/bcipy/display/paradigm/matrix/__init__.py b/bcipy/display/paradigm/matrix/__init__.py index 98a0ee95e..88307e03c 100644 --- a/bcipy/display/paradigm/matrix/__init__.py +++ b/bcipy/display/paradigm/matrix/__init__.py @@ -1,6 +1,4 @@ -from .display import ( - MatrixDisplay -) +from .display import MatrixDisplay __all__ = [ 'MatrixDisplay' diff --git a/bcipy/display/paradigm/matrix/display.py b/bcipy/display/paradigm/matrix/display.py index a3ab6f284..4e70ebeb3 100644 --- a/bcipy/display/paradigm/matrix/display.py +++ b/bcipy/display/paradigm/matrix/display.py @@ -6,14 +6,14 @@ import bcipy.display.components.layout as layout from bcipy.config import MATRIX_IMAGE_FILENAME, SESSION_LOG_FILENAME +from bcipy.core.stimuli import resize_image +from bcipy.core.symbols import alphabet, frequency_order, qwerty_order +from bcipy.core.triggers import _calibration_trigger from bcipy.display import (BCIPY_LOGO_PATH, Display, InformationProperties, StimuliProperties) from bcipy.display.components.task_bar import TaskBar from bcipy.display.main import PreviewParams, init_preview_button_handler from bcipy.display.paradigm.matrix.layout import symbol_positions -from bcipy.core.stimuli import resize_image -from bcipy.core.symbols import alphabet, frequency_order, qwerty_order -from bcipy.core.triggers import _calibration_trigger logger = logging.getLogger(SESSION_LOG_FILENAME) diff --git a/bcipy/display/paradigm/rsvp/__init__.py b/bcipy/display/paradigm/rsvp/__init__.py index 82283e37c..9423f249d 100644 --- a/bcipy/display/paradigm/rsvp/__init__.py +++ b/bcipy/display/paradigm/rsvp/__init__.py @@ -1,6 +1,4 @@ -from .display import ( - RSVPDisplay -) +from .display import RSVPDisplay __all__ = [ 'RSVPDisplay' diff --git a/bcipy/display/paradigm/rsvp/mode/calibration.py b/bcipy/display/paradigm/rsvp/mode/calibration.py index 8ab5caab8..9b76a0563 100644 --- a/bcipy/display/paradigm/rsvp/mode/calibration.py +++ b/bcipy/display/paradigm/rsvp/mode/calibration.py @@ -1,5 +1,5 @@ -from bcipy.display.paradigm.rsvp.display import RSVPDisplay from bcipy.core.symbols import SPACE_CHAR +from bcipy.display.paradigm.rsvp.display import RSVPDisplay class CalibrationDisplay(RSVPDisplay): diff --git a/bcipy/display/paradigm/rsvp/mode/copy_phrase.py b/bcipy/display/paradigm/rsvp/mode/copy_phrase.py index 1dc8ec287..8ce877c21 100644 --- a/bcipy/display/paradigm/rsvp/mode/copy_phrase.py +++ b/bcipy/display/paradigm/rsvp/mode/copy_phrase.py @@ -1,8 +1,8 @@ from psychopy import visual -from bcipy.display.paradigm.rsvp.display import BCIPY_LOGO_PATH, RSVPDisplay from bcipy.core.stimuli import resize_image from bcipy.core.symbols import SPACE_CHAR +from bcipy.display.paradigm.rsvp.display import BCIPY_LOGO_PATH, RSVPDisplay """Note: diff --git a/bcipy/display/paradigm/vep/display.py b/bcipy/display/paradigm/vep/display.py index 4ca6a062c..270f077ca 100644 --- a/bcipy/display/paradigm/vep/display.py +++ b/bcipy/display/paradigm/vep/display.py @@ -6,6 +6,10 @@ from psychopy import core, visual # type: ignore import bcipy.display.components.layout as layout +from bcipy.core.list import expanded +from bcipy.core.stimuli import resize_image +from bcipy.core.symbols import alphabet +from bcipy.core.triggers import _calibration_trigger from bcipy.display import (BCIPY_LOGO_PATH, Display, InformationProperties, VEPStimuliProperties) from bcipy.display.components.layout import scaled_size @@ -17,10 +21,6 @@ from bcipy.display.paradigm.vep.layout import BoxConfiguration, animation_path from bcipy.display.paradigm.vep.vep_stim import VEPStim from bcipy.helpers.clock import Clock -from bcipy.core.list import expanded -from bcipy.core.stimuli import resize_image -from bcipy.core.symbols import alphabet -from bcipy.core.triggers import _calibration_trigger class StimTime(NamedTuple): diff --git a/bcipy/display/tests/components/test_task_bar.py b/bcipy/display/tests/components/test_task_bar.py index 94f5183d7..6ec210b77 100644 --- a/bcipy/display/tests/components/test_task_bar.py +++ b/bcipy/display/tests/components/test_task_bar.py @@ -1,7 +1,9 @@ """Task bar component tests""" import unittest -from unittest.mock import patch, Mock -from bcipy.display.components.task_bar import TaskBar, CalibrationTaskBar, CopyPhraseTaskBar +from unittest.mock import Mock, patch + +from bcipy.display.components.task_bar import (CalibrationTaskBar, + CopyPhraseTaskBar, TaskBar) class TestTaskBar(unittest.TestCase): diff --git a/bcipy/display/tests/test_display.py b/bcipy/display/tests/test_display.py index 4fd9eec49..ab9d6337c 100644 --- a/bcipy/display/tests/test_display.py +++ b/bcipy/display/tests/test_display.py @@ -1,7 +1,8 @@ import unittest -from mockito import any, mock, when, unstub import psychopy +from mockito import any, mock, unstub, when + from bcipy.display.main import init_display_window diff --git a/bcipy/feedback/feedback.py b/bcipy/feedback/feedback.py index 8496371b8..e7976a1d3 100644 --- a/bcipy/feedback/feedback.py +++ b/bcipy/feedback/feedback.py @@ -1,4 +1,5 @@ import logging + from bcipy.config import SESSION_LOG_FILENAME REGISTERED_FEEDBACK_TYPES = ['sound', 'visual'] diff --git a/bcipy/feedback/sound/auditory_feedback.py b/bcipy/feedback/sound/auditory_feedback.py index 4d3aa06d6..2b2a7b609 100644 --- a/bcipy/feedback/sound/auditory_feedback.py +++ b/bcipy/feedback/sound/auditory_feedback.py @@ -1,6 +1,7 @@ -from bcipy.feedback.feedback import Feedback -from psychopy import core import sounddevice as sd +from psychopy import core + +from bcipy.feedback.feedback import Feedback class AuditoryFeedback(Feedback): diff --git a/bcipy/feedback/tests/sound/test_sound_feedback.py b/bcipy/feedback/tests/sound/test_sound_feedback.py index 08c2ca3a5..5cc418c1d 100644 --- a/bcipy/feedback/tests/sound/test_sound_feedback.py +++ b/bcipy/feedback/tests/sound/test_sound_feedback.py @@ -1,11 +1,10 @@ import unittest -from mockito import mock, when, unstub +import sounddevice as sd +from mockito import mock, unstub, when from bcipy.feedback.sound.auditory_feedback import AuditoryFeedback -import sounddevice as sd - class TestSoundFeedback(unittest.TestCase): diff --git a/bcipy/feedback/visual/visual_feedback.py b/bcipy/feedback/visual/visual_feedback.py index 8db136d01..0986e8396 100644 --- a/bcipy/feedback/visual/visual_feedback.py +++ b/bcipy/feedback/visual/visual_feedback.py @@ -1,12 +1,12 @@ # mypy: disable-error-code="return-value" from enum import Enum +from typing import List, Tuple, Union from psychopy import core, visual -from typing import Tuple, List, Union +from bcipy.core.stimuli import resize_image from bcipy.feedback.feedback import Feedback from bcipy.helpers.clock import Clock -from bcipy.core.stimuli import resize_image class FeedbackType(Enum): diff --git a/bcipy/gui/alert.py b/bcipy/gui/alert.py index 91fec4c9e..bc79a167b 100644 --- a/bcipy/gui/alert.py +++ b/bcipy/gui/alert.py @@ -1,8 +1,11 @@ """GUI alert messages""" # pylint: disable=no-name-in-module import sys + from PyQt6.QtWidgets import QApplication -from bcipy.gui.main import alert_message, AlertMessageType, AlertResponse, AlertMessageResponse + +from bcipy.gui.main import (AlertMessageResponse, AlertMessageType, + AlertResponse, alert_message) def confirm(message: str) -> bool: diff --git a/bcipy/gui/bciui.py b/bcipy/gui/bciui.py index b3c301832..fca09e1d3 100644 --- a/bcipy/gui/bciui.py +++ b/bcipy/gui/bciui.py @@ -1,19 +1,12 @@ -from typing import Callable, Type +import sys +from typing import Callable, List, Optional, Type + from PyQt6.QtCore import pyqtSignal -from PyQt6.QtWidgets import ( - QWidget, - QVBoxLayout, - QHBoxLayout, - QPushButton, - QScrollArea, - QLayout, - QSizePolicy, - QMessageBox, - QApplication, -) -from typing import Optional, List +from PyQt6.QtWidgets import (QApplication, QHBoxLayout, QLayout, QMessageBox, + QPushButton, QScrollArea, QSizePolicy, + QVBoxLayout, QWidget) + from bcipy.config import BCIPY_ROOT -import sys class BCIUI(QWidget): diff --git a/bcipy/gui/experiments/ExperimentField.py b/bcipy/gui/experiments/ExperimentField.py index 13201957e..fbf8772b5 100644 --- a/bcipy/gui/experiments/ExperimentField.py +++ b/bcipy/gui/experiments/ExperimentField.py @@ -3,34 +3,19 @@ # pylint: disable=E0611 import sys - from typing import List from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import ( - QHBoxLayout, - QPushButton, - QScrollArea, - QVBoxLayout, - QWidget, -) - -from bcipy.gui.main import ( - AlertMessageType, - AlertMessageResponse, - AlertResponse, - MessageBox, - app, - BoolInput, - DirectoryInput, - FileInput, - FloatInput, - FormInput, - IntegerInput, - TextInput, -) +from PyQt6.QtWidgets import (QHBoxLayout, QPushButton, QScrollArea, + QVBoxLayout, QWidget) + from bcipy.config import EXPERIMENT_DATA_FILENAME -from bcipy.helpers.validate import validate_experiment, validate_field_data_written +from bcipy.gui.main import (AlertMessageResponse, AlertMessageType, + AlertResponse, BoolInput, DirectoryInput, + FileInput, FloatInput, FormInput, IntegerInput, + MessageBox, TextInput, app) +from bcipy.helpers.validate import (validate_experiment, + validate_field_data_written) from bcipy.io.load import load_experiments, load_fields from bcipy.io.save import save_experiment_field_data @@ -332,6 +317,7 @@ def start_experiment_field_collection_gui( def start_app() -> None: """Start Experiment Field Collection.""" import argparse + from bcipy.config import DEFAULT_EXPERIMENT_ID, EXPERIMENT_DATA_FILENAME parser = argparse.ArgumentParser() diff --git a/bcipy/gui/experiments/ExperimentRegistry.py b/bcipy/gui/experiments/ExperimentRegistry.py index 250d03efc..90c931510 100644 --- a/bcipy/gui/experiments/ExperimentRegistry.py +++ b/bcipy/gui/experiments/ExperimentRegistry.py @@ -1,28 +1,19 @@ +import json +import subprocess from typing import List, Optional -from PyQt6.QtWidgets import ( - QComboBox, - QVBoxLayout, - QHBoxLayout, - QLabel, - QLineEdit, - QPushButton, - QScrollArea, -) -from bcipy.gui.bciui import BCIUI, DynamicItem, DynamicList, SmallButton, run_bciui -from bcipy.io.load import load_fields, load_experiments + +from PyQt6.QtWidgets import (QComboBox, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QScrollArea, QVBoxLayout) + +from bcipy.config import (BCIPY_ROOT, DEFAULT_ENCODING, + DEFAULT_EXPERIMENT_PATH, DEFAULT_FIELD_PATH, + EXPERIMENT_FILENAME, FIELD_FILENAME) +from bcipy.gui.bciui import (BCIUI, DynamicItem, DynamicList, SmallButton, + run_bciui) +from bcipy.io.load import load_experiments, load_fields from bcipy.io.save import save_experiment_data -from bcipy.config import ( - DEFAULT_ENCODING, - DEFAULT_EXPERIMENT_PATH, - DEFAULT_FIELD_PATH, - EXPERIMENT_FILENAME, - FIELD_FILENAME, - BCIPY_ROOT, -) -from bcipy.task.registry import TaskRegistry -import subprocess from bcipy.task.orchestrator.protocol import serialize_protocol -import json +from bcipy.task.registry import TaskRegistry class ExperimentRegistry(BCIUI): diff --git a/bcipy/gui/experiments/FieldRegistry.py b/bcipy/gui/experiments/FieldRegistry.py index d39da51e2..3c23a9c24 100644 --- a/bcipy/gui/experiments/FieldRegistry.py +++ b/bcipy/gui/experiments/FieldRegistry.py @@ -1,7 +1,7 @@ import sys -from bcipy.gui.main import BCIGui, app, AlertMessageType, AlertMessageResponse from bcipy.config import DEFAULT_FIELD_PATH, FIELD_FILENAME +from bcipy.gui.main import AlertMessageResponse, AlertMessageType, BCIGui, app from bcipy.io.load import load_fields from bcipy.io.save import save_field_data diff --git a/bcipy/gui/experiments/demo/demo_experiment_field_collection.py b/bcipy/gui/experiments/demo/demo_experiment_field_collection.py index 5f93b9d95..2f29acf25 100644 --- a/bcipy/gui/experiments/demo/demo_experiment_field_collection.py +++ b/bcipy/gui/experiments/demo/demo_experiment_field_collection.py @@ -1,6 +1,5 @@ -from bcipy.core.session import collect_experiment_field_data from bcipy.config import DEFAULT_EXPERIMENT_ID - +from bcipy.core.session import collect_experiment_field_data experiment_name = DEFAULT_EXPERIMENT_ID # this will save the data at the location you've run the demo from! diff --git a/bcipy/gui/file_dialog.py b/bcipy/gui/file_dialog.py index f8271fc6e..dd39055a4 100644 --- a/bcipy/gui/file_dialog.py +++ b/bcipy/gui/file_dialog.py @@ -6,8 +6,8 @@ from PyQt6 import QtGui from PyQt6.QtWidgets import QApplication, QFileDialog, QWidget -from bcipy.preferences import preferences from bcipy.exceptions import BciPyCoreException +from bcipy.preferences import preferences DEFAULT_FILE_TYPES = "All Files (*)" diff --git a/bcipy/gui/intertask_gui.py b/bcipy/gui/intertask_gui.py index 87e1a8e47..463ca551a 100644 --- a/bcipy/gui/intertask_gui.py +++ b/bcipy/gui/intertask_gui.py @@ -1,15 +1,11 @@ +import logging from typing import Callable, List -from PyQt6.QtWidgets import ( - QLabel, - QHBoxLayout, - QPushButton, - QProgressBar, - QApplication -) -from bcipy.gui.bciui import BCIUI, run_bciui +from PyQt6.QtWidgets import (QApplication, QHBoxLayout, QLabel, QProgressBar, + QPushButton) + from bcipy.config import SESSION_LOG_FILENAME -import logging +from bcipy.gui.bciui import BCIUI, run_bciui logger = logging.getLogger(SESSION_LOG_FILENAME) diff --git a/bcipy/gui/parameters/params_form.py b/bcipy/gui/parameters/params_form.py index 188d849f4..3be18d35e 100644 --- a/bcipy/gui/parameters/params_form.py +++ b/bcipy/gui/parameters/params_form.py @@ -11,10 +11,10 @@ QPushButton, QScrollArea, QVBoxLayout, QWidget) from bcipy.config import BCIPY_ROOT, DEFAULT_PARAMETERS_PATH +from bcipy.core.parameters import Parameters, changes_from_default from bcipy.gui.main import (BoolInput, DirectoryInput, FileInput, FloatInput, FormInput, IntegerInput, RangeInput, SearchInput, SelectionInput, TextInput, static_text_control) -from bcipy.core.parameters import Parameters, changes_from_default class ParamsForm(QWidget): diff --git a/bcipy/gui/tests/viewer/test_gui_main.py b/bcipy/gui/tests/viewer/test_gui_main.py index c4d0ff5d1..163e02591 100644 --- a/bcipy/gui/tests/viewer/test_gui_main.py +++ b/bcipy/gui/tests/viewer/test_gui_main.py @@ -1,5 +1,6 @@ import unittest -from bcipy.gui.main import float_input_properties, FloatInputProperties + +from bcipy.gui.main import FloatInputProperties, float_input_properties class TestFloatInputProperties(unittest.TestCase): diff --git a/bcipy/gui/tests/viewer/test_ring_buffer.py b/bcipy/gui/tests/viewer/test_ring_buffer.py index 1e7364cb4..0c30f2a80 100644 --- a/bcipy/gui/tests/viewer/test_ring_buffer.py +++ b/bcipy/gui/tests/viewer/test_ring_buffer.py @@ -1,5 +1,6 @@ """Tests for the RingBuffer data structure""" import unittest + from bcipy.gui.viewer.ring_buffer import RingBuffer diff --git a/bcipy/gui/viewer/data_source/data_source.py b/bcipy/gui/viewer/data_source/data_source.py index b89ec6c2c..03eba3e3f 100644 --- a/bcipy/gui/viewer/data_source/data_source.py +++ b/bcipy/gui/viewer/data_source/data_source.py @@ -1,5 +1,5 @@ -from queue import Queue, Empty import itertools as it +from queue import Empty, Queue class DataSource: diff --git a/bcipy/gui/viewer/data_source/file_streamer.py b/bcipy/gui/viewer/data_source/file_streamer.py index af5e11bca..b90c177d5 100644 --- a/bcipy/gui/viewer/data_source/file_streamer.py +++ b/bcipy/gui/viewer/data_source/file_streamer.py @@ -1,6 +1,7 @@ """Streams file data for the viewer""" import logging import time + from bcipy.acquisition.util import StoppableThread from bcipy.config import SESSION_LOG_FILENAME from bcipy.core.raw_data import RawDataReader diff --git a/bcipy/gui/viewer/data_viewer.py b/bcipy/gui/viewer/data_viewer.py index d36c81d36..83d0e0a21 100644 --- a/bcipy/gui/viewer/data_viewer.py +++ b/bcipy/gui/viewer/data_viewer.py @@ -4,28 +4,27 @@ from queue import Queue from typing import Callable, Dict, List, Optional, Tuple +import matplotlib +import matplotlib.ticker as ticker +import numpy as np from PyQt6.QtCore import Qt, QTimer # pylint: disable=no-name-in-module from PyQt6.QtWidgets import (QApplication, QCheckBox, QComboBox, QHBoxLayout, QLabel, QPushButton, QSpinBox, QVBoxLayout, QWidget) -import matplotlib -import matplotlib.ticker as ticker -import numpy as np matplotlib.use('Qt5Agg') from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas from matplotlib.figure import Figure - from bcipy.acquisition.devices import DeviceSpec from bcipy.acquisition.util import StoppableProcess +from bcipy.core.parameters import DEFAULT_PARAMETERS_PATH, Parameters +from bcipy.core.raw_data import settings from bcipy.gui.main import static_text_control from bcipy.gui.viewer.data_source.data_source import QueueDataSource from bcipy.gui.viewer.data_source.file_streamer import FileStreamer from bcipy.gui.viewer.data_source.lsl_data_source import LslDataSource from bcipy.gui.viewer.ring_buffer import RingBuffer -from bcipy.core.parameters import DEFAULT_PARAMETERS_PATH, Parameters -from bcipy.core.raw_data import settings from bcipy.signal.process.transform import Downsample, get_default_transform diff --git a/bcipy/helpers/clock.py b/bcipy/helpers/clock.py index 251bb0f0e..7088c724b 100644 --- a/bcipy/helpers/clock.py +++ b/bcipy/helpers/clock.py @@ -1,6 +1,7 @@ """Functionality related to clocks to keep track of time in experiments.""" from typing import Callable + from pylsl import local_clock diff --git a/bcipy/helpers/demo/demo_visualization.py b/bcipy/helpers/demo/demo_visualization.py index 192a0b01f..149e0498d 100644 --- a/bcipy/helpers/demo/demo_visualization.py +++ b/bcipy/helpers/demo/demo_visualization.py @@ -17,11 +17,11 @@ from bcipy.config import (DEFAULT_DEVICE_SPEC_FILENAME, DEFAULT_PARAMETERS_FILENAME, RAW_DATA_FILENAME, TRIGGER_FILENAME) +from bcipy.core.triggers import TriggerType, trigger_decoder from bcipy.helpers.acquisition import analysis_channels +from bcipy.helpers.visualization import visualize_erp from bcipy.io.load import (load_experimental_data, load_json_parameters, load_raw_data) -from bcipy.core.triggers import TriggerType, trigger_decoder -from bcipy.helpers.visualization import visualize_erp from bcipy.signal.process import get_default_transform if __name__ == '__main__': diff --git a/bcipy/helpers/offset.py b/bcipy/helpers/offset.py index f0309d34f..e36a68783 100644 --- a/bcipy/helpers/offset.py +++ b/bcipy/helpers/offset.py @@ -1,23 +1,17 @@ -from typing import Any, List, Tuple -from textwrap import wrap from pathlib import Path +from textwrap import wrap +from typing import Any, List, Tuple import matplotlib.pyplot as plt import numpy as np - from scipy.stats import normaltest -from bcipy.io.load import load_raw_data, ask_directory, load_json_parameters +from bcipy.config import (DEFAULT_PARAMETERS_FILENAME, + DEFAULT_TRIGGER_CHANNEL_NAME, DIODE_TRIGGER, + RAW_DATA_FILENAME, TRIGGER_FILENAME) from bcipy.core.raw_data import RawData -from bcipy.core.triggers import trigger_decoder, TriggerType - -from bcipy.config import ( - TRIGGER_FILENAME, - DIODE_TRIGGER, - RAW_DATA_FILENAME, - DEFAULT_TRIGGER_CHANNEL_NAME, - DEFAULT_PARAMETERS_FILENAME -) +from bcipy.core.triggers import TriggerType, trigger_decoder +from bcipy.io.load import ask_directory, load_json_parameters, load_raw_data def sample_to_seconds(sample_rate: float, sample: int) -> float: diff --git a/bcipy/helpers/task.py b/bcipy/helpers/task.py index b0aaa3ea5..8e6dbd8f4 100644 --- a/bcipy/helpers/task.py +++ b/bcipy/helpers/task.py @@ -8,9 +8,10 @@ from bcipy.acquisition.multimodal import ClientManager, ContentType from bcipy.acquisition.record import Record -from bcipy.config import MAX_PAUSE_SECONDS, SESSION_COMPLETE_MESSAGE, SESSION_LOG_FILENAME -from bcipy.helpers.clock import Clock +from bcipy.config import (MAX_PAUSE_SECONDS, SESSION_COMPLETE_MESSAGE, + SESSION_LOG_FILENAME) from bcipy.core.stimuli import get_fixation +from bcipy.helpers.clock import Clock from bcipy.task.exceptions import InsufficientDataException log = logging.getLogger(SESSION_LOG_FILENAME) diff --git a/bcipy/helpers/tests/test_acquisition.py b/bcipy/helpers/tests/test_acquisition.py index bfa6a1f71..3a9c2dddd 100644 --- a/bcipy/helpers/tests/test_acquisition.py +++ b/bcipy/helpers/tests/test_acquisition.py @@ -4,13 +4,13 @@ from bcipy.acquisition.devices import DeviceSpec, DeviceStatus from bcipy.config import DEFAULT_DEVICE_SPEC_FILENAME as spec_name +from bcipy.core.parameters import Parameters from bcipy.helpers.acquisition import (RAW_DATA_FILENAME, StreamType, active_content_types, init_acquisition, init_device, is_stream_type_active, max_inquiry_duration, parse_stream_type, raw_data_filename, server_spec, stream_types) -from bcipy.core.parameters import Parameters class TestAcquisition(unittest.TestCase): diff --git a/bcipy/helpers/tests/test_copy_phrase_wrapper.py b/bcipy/helpers/tests/test_copy_phrase_wrapper.py index 7eeccc89c..9bfc39af9 100644 --- a/bcipy/helpers/tests/test_copy_phrase_wrapper.py +++ b/bcipy/helpers/tests/test_copy_phrase_wrapper.py @@ -1,7 +1,7 @@ import unittest -from bcipy.helpers.copy_phrase_wrapper import CopyPhraseWrapper from bcipy.core.symbols import DEFAULT_SYMBOL_SET +from bcipy.helpers.copy_phrase_wrapper import CopyPhraseWrapper from bcipy.language.model.uniform import UniformLanguageModel from bcipy.task.data import EvidenceType diff --git a/bcipy/helpers/tests/test_language_model.py b/bcipy/helpers/tests/test_language_model.py index c3e367d6f..65c315c32 100644 --- a/bcipy/helpers/tests/test_language_model.py +++ b/bcipy/helpers/tests/test_language_model.py @@ -1,8 +1,8 @@ """Unit test for language model helper""" import unittest - from collections import Counter -from bcipy.helpers.language_model import norm_domain, with_min_prob, histogram + +from bcipy.helpers.language_model import histogram, norm_domain, with_min_prob class TestLanguageModelRelated(unittest.TestCase): diff --git a/bcipy/helpers/tests/test_offset.py b/bcipy/helpers/tests/test_offset.py index 7f0928af1..f9a3d5f6e 100644 --- a/bcipy/helpers/tests/test_offset.py +++ b/bcipy/helpers/tests/test_offset.py @@ -1,25 +1,21 @@ -import unittest import shutil +import tempfile +import unittest import zipfile from pathlib import Path -import tempfile -import pytest +import pytest from matplotlib import pyplot as plt +from mockito import unstub, verify, when -from mockito import when, unstub, verify - -from bcipy.helpers.offset import ( - calculate_latency, - sample_to_seconds, - extract_data_latency_calculation, - sample_rate_diffs, - lsl_timestamp_diffs -) -from bcipy.io.load import load_raw_data -from bcipy.core.raw_data import RawData -from bcipy.core.triggers import trigger_decoder, TriggerType from bcipy.config import RAW_DATA_FILENAME, TRIGGER_FILENAME +from bcipy.core.raw_data import RawData +from bcipy.core.triggers import TriggerType, trigger_decoder +from bcipy.helpers.offset import (calculate_latency, + extract_data_latency_calculation, + lsl_timestamp_diffs, sample_rate_diffs, + sample_to_seconds) +from bcipy.io.load import load_raw_data pwd = Path(__file__).absolute().parent input_folder = pwd / "resources/mock_offset/time_test_data/" diff --git a/bcipy/helpers/tests/test_system_utils.py b/bcipy/helpers/tests/test_system_utils.py index ead20a28b..445e47afb 100644 --- a/bcipy/helpers/tests/test_system_utils.py +++ b/bcipy/helpers/tests/test_system_utils.py @@ -1,15 +1,13 @@ -import unittest -from bcipy.helpers.utils import ( - is_connected, - is_battery_powered, - is_screen_refresh_rate_low, - get_screen_info, - ScreenInfo, -) import socket +import unittest + import psutil +from mockito import any, mock, verify, when from pyglet import canvas -from mockito import any, when, mock, verify + +from bcipy.helpers.utils import (ScreenInfo, get_screen_info, + is_battery_powered, is_connected, + is_screen_refresh_rate_low) class TestSystemUtilsAlerts(unittest.TestCase): diff --git a/bcipy/helpers/tests/test_validate.py b/bcipy/helpers/tests/test_validate.py index 0c7c6a8f6..47164370e 100644 --- a/bcipy/helpers/tests/test_validate.py +++ b/bcipy/helpers/tests/test_validate.py @@ -1,13 +1,11 @@ import unittest from bcipy.config import DEFAULT_EXPERIMENT_ID +from bcipy.exceptions import (InvalidExperimentException, + InvalidFieldException, + UnregisteredExperimentException, + UnregisteredFieldException) from bcipy.helpers.validate import validate_experiment, validate_experiments -from bcipy.exceptions import ( - InvalidExperimentException, - InvalidFieldException, - UnregisteredExperimentException, - UnregisteredFieldException, -) class TestValidateExperiment(unittest.TestCase): diff --git a/bcipy/helpers/tests/test_visualization.py b/bcipy/helpers/tests/test_visualization.py index 49649bb0f..aa7ff7a86 100644 --- a/bcipy/helpers/tests/test_visualization.py +++ b/bcipy/helpers/tests/test_visualization.py @@ -1,15 +1,15 @@ -import unittest -import tempfile import shutil +import tempfile +import unittest from pathlib import Path -from mockito import when, any, verify, unstub, mock +from mockito import any, mock, unstub, verify, when -from bcipy.helpers.visualization import visualize_session_data -from bcipy.helpers import visualization +from bcipy.config import DEFAULT_PARAMETERS_PATH from bcipy.core.raw_data import RawData +from bcipy.helpers import visualization +from bcipy.helpers.visualization import visualize_session_data from bcipy.io.load import load_json_parameters -from bcipy.config import DEFAULT_PARAMETERS_PATH class TestVisualizeSessionData(unittest.TestCase): diff --git a/bcipy/helpers/validate.py b/bcipy/helpers/validate.py index abfc50564..7c8184a89 100644 --- a/bcipy/helpers/validate.py +++ b/bcipy/helpers/validate.py @@ -1,17 +1,15 @@ import os -from bcipy.config import ( - DEFAULT_EXPERIMENT_PATH, - DEFAULT_FIELD_PATH, - EXPERIMENT_FILENAME, - FIELD_FILENAME) -from bcipy.io.load import load_experiments, load_fields -from bcipy.helpers.utils import is_battery_powered, is_connected, is_screen_refresh_rate_low -from bcipy.exceptions import (InvalidFieldException, - InvalidExperimentException, +from bcipy.config import (DEFAULT_EXPERIMENT_PATH, DEFAULT_FIELD_PATH, + EXPERIMENT_FILENAME, FIELD_FILENAME) +from bcipy.exceptions import (InvalidExperimentException, + InvalidFieldException, UnregisteredExperimentException, UnregisteredFieldException) from bcipy.gui.alert import confirm +from bcipy.helpers.utils import (is_battery_powered, is_connected, + is_screen_refresh_rate_low) +from bcipy.io.load import load_experiments, load_fields from bcipy.task.orchestrator.protocol import validate_protocol_string diff --git a/bcipy/helpers/visualization.py b/bcipy/helpers/visualization.py index 7eee8a30a..b4bd72294 100644 --- a/bcipy/helpers/visualization.py +++ b/bcipy/helpers/visualization.py @@ -9,7 +9,6 @@ import numpy as np import pandas as pd import seaborn as sns - from matplotlib.figure import Figure from matplotlib.patches import Ellipse from mne import Epochs @@ -17,16 +16,16 @@ import bcipy.acquisition.devices as devices from bcipy.config import (DEFAULT_DEVICE_SPEC_FILENAME, - DEFAULT_GAZE_IMAGE_PATH, RAW_DATA_FILENAME, - TRIGGER_FILENAME, SESSION_LOG_FILENAME, - DEFAULT_PARAMETERS_PATH) -from bcipy.helpers.acquisition import analysis_channels -from bcipy.io.convert import convert_to_mne -from bcipy.io.load import choose_csv_file, load_raw_data, load_json_parameters + DEFAULT_GAZE_IMAGE_PATH, DEFAULT_PARAMETERS_PATH, + RAW_DATA_FILENAME, SESSION_LOG_FILENAME, + TRIGGER_FILENAME) from bcipy.core.parameters import Parameters from bcipy.core.raw_data import RawData from bcipy.core.stimuli import mne_epochs from bcipy.core.triggers import TriggerType, trigger_decoder +from bcipy.helpers.acquisition import analysis_channels +from bcipy.io.convert import convert_to_mne +from bcipy.io.load import choose_csv_file, load_json_parameters, load_raw_data from bcipy.signal.process import (Composition, ERPTransformParams, get_default_transform) diff --git a/bcipy/io/convert.py b/bcipy/io/convert.py index 369dd03a1..9f308fa63 100644 --- a/bcipy/io/convert.py +++ b/bcipy/io/convert.py @@ -1,23 +1,24 @@ # mypy: disable-error-code="no-redef" """Functionality for converting the bcipy raw data output to other formats""" +import glob import logging import os import tarfile +from enum import Enum from typing import List, Optional, Tuple -import glob import mne -from enum import Enum from mne.io import RawArray -from mne_bids import BIDSPath, write_raw_bids, find_matching_paths, read_raw_bids, get_entity_vals +from mne_bids import (BIDSPath, find_matching_paths, get_entity_vals, + read_raw_bids, write_raw_bids) from tqdm import tqdm from bcipy.acquisition.devices import preconfigured_device -from bcipy.config import (RAW_DATA_FILENAME, - TRIGGER_FILENAME, SESSION_LOG_FILENAME) -from bcipy.io.load import load_raw_data +from bcipy.config import (RAW_DATA_FILENAME, SESSION_LOG_FILENAME, + TRIGGER_FILENAME) from bcipy.core.raw_data import RawData, get_1020_channel_map from bcipy.core.triggers import trigger_decoder +from bcipy.io.load import load_raw_data from bcipy.signal.process import Composition logger = logging.getLogger(SESSION_LOG_FILENAME) diff --git a/bcipy/io/demo/demo_convert.py b/bcipy/io/demo/demo_convert.py index 5942a960b..bce775562 100644 --- a/bcipy/io/demo/demo_convert.py +++ b/bcipy/io/demo/demo_convert.py @@ -4,10 +4,12 @@ `python bcipy/io/demo/demo_convert.py -d "path://to/bcipy/data/folder"` """ -from typing import Optional, List from pathlib import Path -from bcipy.io.convert import convert_to_bids, ConvertFormat, convert_eyetracking_to_bids +from typing import List, Optional + from bcipy.gui.file_dialog import ask_directory +from bcipy.io.convert import (ConvertFormat, convert_eyetracking_to_bids, + convert_to_bids) from bcipy.io.load import BciPySessionTaskData, load_bcipy_data EXCLUDED_TASKS = ['Report', 'Offline', 'Intertask', 'BAD'] diff --git a/bcipy/io/demo/demo_load_BIDS.py b/bcipy/io/demo/demo_load_BIDS.py index 899bec593..5f4ac5bd1 100644 --- a/bcipy/io/demo/demo_load_BIDS.py +++ b/bcipy/io/demo/demo_load_BIDS.py @@ -8,11 +8,12 @@ participants.tsv file, along with the data files in the appropriate subdirectories. """ -from bcipy.io.convert import BIDS_to_MNE -from bcipy.io.load import load_experimental_data import mne from mne.viz import plot_compare_evokeds +from bcipy.io.convert import BIDS_to_MNE +from bcipy.io.load import load_experimental_data + # This script demonstrates how to load BIDS data using the BciPy library. This assumes that the data is # already in BIDS format (BV, EDF, or BDF, with the channels.tsv file) and that the BIDS directory is # structured correctly. The BIDS directory should contain a dataset_description.json file and a diff --git a/bcipy/io/load.py b/bcipy/io/load.py index fe3fe268f..2c0157c82 100644 --- a/bcipy/io/load.py +++ b/bcipy/io/load.py @@ -11,12 +11,11 @@ from bcipy.config import (DEFAULT_ENCODING, DEFAULT_EXPERIMENT_PATH, DEFAULT_FIELD_PATH, DEFAULT_PARAMETERS_PATH, EXPERIMENT_FILENAME, FIELD_FILENAME, - SIGNAL_MODEL_FILE_SUFFIX, SESSION_LOG_FILENAME) -from bcipy.gui.file_dialog import ask_directory, ask_filename -from bcipy.exceptions import (BciPyCoreException, - InvalidExperimentException) + SESSION_LOG_FILENAME, SIGNAL_MODEL_FILE_SUFFIX) from bcipy.core.parameters import Parameters from bcipy.core.raw_data import RawData +from bcipy.exceptions import BciPyCoreException, InvalidExperimentException +from bcipy.gui.file_dialog import ask_directory, ask_filename from bcipy.preferences import preferences from bcipy.signal.model import SignalModel diff --git a/bcipy/io/save.py b/bcipy/io/save.py index 98a232645..197adbceb 100644 --- a/bcipy/io/save.py +++ b/bcipy/io/save.py @@ -9,13 +9,11 @@ from typing import Any, Dict, List, Tuple, Union from bcipy.acquisition.devices import DeviceSpec -from bcipy.config import (DEFAULT_ENCODING, - DEFAULT_EXPERIMENT_ID, +from bcipy.config import (DEFAULT_ENCODING, DEFAULT_EXPERIMENT_ID, DEFAULT_LM_PARAMETERS_FILENAME, DEFAULT_LM_PARAMETERS_PATH, DEFAULT_PARAMETERS_FILENAME, - SIGNAL_MODEL_FILE_SUFFIX, - STIMULI_POSITIONS_FILENAME) + SIGNAL_MODEL_FILE_SUFFIX, STIMULI_POSITIONS_FILENAME) from bcipy.signal.model.base_model import SignalModel diff --git a/bcipy/io/tests/test_convert.py b/bcipy/io/tests/test_convert.py index bb59f7ca6..882779b45 100644 --- a/bcipy/io/tests/test_convert.py +++ b/bcipy/io/tests/test_convert.py @@ -5,30 +5,22 @@ import unittest from pathlib import Path from typing import Tuple, Union -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch -import bcipy.acquisition.devices as devices import mne + +import bcipy.acquisition.devices as devices from bcipy.config import (DEFAULT_ENCODING, DEFAULT_PARAMETERS_FILENAME, RAW_DATA_FILENAME, TRIGGER_FILENAME) -from bcipy.io.convert import ( - archive_list, - compress, - ConvertFormat, - convert_to_bids, - convert_to_mne, - decompress, - norm_to_tobii, - tobii_to_norm, - convert_eyetracking_to_bids, - BIDS_to_MNE -) from bcipy.core.parameters import Parameters from bcipy.core.raw_data import RawData, sample_data, write from bcipy.core.triggers import MOCK_TRIGGER_DATA +from bcipy.io.convert import (BIDS_to_MNE, ConvertFormat, archive_list, + compress, convert_eyetracking_to_bids, + convert_to_bids, convert_to_mne, decompress, + norm_to_tobii, tobii_to_norm) from bcipy.signal.generator.generator import gen_random_data - CHANNEL_NAMES = [ 'Fp1', 'Fp2', 'F3', 'F4', 'C3', 'C4', 'P3', 'P4', 'O1', 'O2', 'F7', 'F8', 'T3', 'T4', 'T5', 'T6', 'Fz', 'Cz', 'Pz'] diff --git a/bcipy/io/tests/test_load.py b/bcipy/io/tests/test_load.py index cb479eee5..d170efbc3 100644 --- a/bcipy/io/tests/test_load.py +++ b/bcipy/io/tests/test_load.py @@ -11,15 +11,14 @@ from bcipy.config import (DEFAULT_ENCODING, DEFAULT_EXPERIMENT_PATH, DEFAULT_FIELD_PATH, DEFAULT_PARAMETERS_PATH, EXPERIMENT_FILENAME, FIELD_FILENAME) +from bcipy.core.parameters import Parameters from bcipy.exceptions import BciPyCoreException, InvalidExperimentException from bcipy.io.load import (choose_signal_model, choose_signal_models, - copy_parameters, extract_mode, + copy_parameters, extract_mode, load_bcipy_data, load_experiment_fields, load_experiments, - load_bcipy_data, load_fields, load_json_parameters, load_signal_model, load_users) from bcipy.io.tests.test_convert import create_bcipy_session_artifacts -from bcipy.core.parameters import Parameters MOCK_EXPERIMENT = { "test": { diff --git a/bcipy/io/tests/test_save.py b/bcipy/io/tests/test_save.py index f4ca79481..b272497ee 100644 --- a/bcipy/io/tests/test_save.py +++ b/bcipy/io/tests/test_save.py @@ -1,17 +1,15 @@ +import json import os import shutil -import unittest import tempfile -import json +import unittest -from bcipy.config import ( - DEFAULT_PARAMETERS_PATH, - DEFAULT_PARAMETERS_FILENAME, - DEFAULT_EXPERIMENT_ID, - STIMULI_POSITIONS_FILENAME) +from mockito import any, unstub, when + +from bcipy.config import (DEFAULT_EXPERIMENT_ID, DEFAULT_PARAMETERS_FILENAME, + DEFAULT_PARAMETERS_PATH, STIMULI_POSITIONS_FILENAME) from bcipy.io import save from bcipy.io.save import init_save_data_structure, save_stimuli_position_info -from mockito import any, unstub, when class TestSave(unittest.TestCase): diff --git a/bcipy/language/__init__.py b/bcipy/language/__init__.py index c6f353134..6e3a3a19c 100644 --- a/bcipy/language/__init__.py +++ b/bcipy/language/__init__.py @@ -1,4 +1,4 @@ -from .main import LanguageModel, CharacterLanguageModel, WordLanguageModel +from .main import CharacterLanguageModel, LanguageModel, WordLanguageModel __all__ = [ "LanguageModel", diff --git a/bcipy/language/demo/demo_causal.py b/bcipy/language/demo/demo_causal.py index 1d6fb3425..8d84bce98 100644 --- a/bcipy/language/demo/demo_causal.py +++ b/bcipy/language/demo/demo_causal.py @@ -1,6 +1,5 @@ -from bcipy.language.model.causal import CausalLanguageModelAdapter from bcipy.core.symbols import DEFAULT_SYMBOL_SET - +from bcipy.language.model.causal import CausalLanguageModelAdapter if __name__ == "__main__": lm = CausalLanguageModelAdapter(lang_model_name="figmtu/opt-350m-aac") diff --git a/bcipy/language/demo/demo_mixture.py b/bcipy/language/demo/demo_mixture.py index aeec37431..960e3f2cc 100644 --- a/bcipy/language/demo/demo_mixture.py +++ b/bcipy/language/demo/demo_mixture.py @@ -1,6 +1,5 @@ -from bcipy.language.model.mixture import MixtureLanguageModelAdapter from bcipy.core.symbols import DEFAULT_SYMBOL_SET - +from bcipy.language.model.mixture import MixtureLanguageModelAdapter if __name__ == "__main__": # Load the default mixture model from lm_params.json diff --git a/bcipy/language/demo/demo_ngram.py b/bcipy/language/demo/demo_ngram.py index 50e335a1b..54f146460 100644 --- a/bcipy/language/demo/demo_ngram.py +++ b/bcipy/language/demo/demo_ngram.py @@ -1,9 +1,9 @@ # Basic sanity test of using KenLM to predict a sentence using a 12-gram character model. -from bcipy.language.model.ngram import NGramLanguageModelAdapter -from bcipy.core.symbols import DEFAULT_SYMBOL_SET from bcipy.config import LM_PATH +from bcipy.core.symbols import DEFAULT_SYMBOL_SET from bcipy.exceptions import KenLMInstallationException +from bcipy.language.model.ngram import NGramLanguageModelAdapter try: import kenlm diff --git a/bcipy/language/tests/test_causal.py b/bcipy/language/tests/test_causal.py index e1f13467f..c91beacc8 100644 --- a/bcipy/language/tests/test_causal.py +++ b/bcipy/language/tests/test_causal.py @@ -1,15 +1,15 @@ """Tests for CAUSAL Language Model""" -import pytest import unittest from operator import itemgetter +import pytest +from textslinger.exceptions import InvalidLanguageModelException + +from bcipy.core.symbols import BACKSPACE_CHAR, DEFAULT_SYMBOL_SET, SPACE_CHAR from bcipy.exceptions import InvalidSymbolSetException -from bcipy.core.symbols import DEFAULT_SYMBOL_SET, BACKSPACE_CHAR, SPACE_CHAR -from bcipy.language.model.causal import CausalLanguageModelAdapter from bcipy.language.main import CharacterLanguageModel - -from textslinger.exceptions import InvalidLanguageModelException +from bcipy.language.model.causal import CausalLanguageModelAdapter @pytest.mark.slow diff --git a/bcipy/main.py b/bcipy/main.py index fe8d8fa37..ada3a8f6b 100644 --- a/bcipy/main.py +++ b/bcipy/main.py @@ -5,8 +5,8 @@ from bcipy.config import CUSTOM_TASK_EXPERIMENT_ID, DEFAULT_PARAMETERS_PATH from bcipy.exceptions import BciPyCoreException -from bcipy.io.load import load_experiments, load_json_parameters from bcipy.helpers.validate import validate_bcipy_session, validate_experiment +from bcipy.io.load import load_experiments, load_json_parameters from bcipy.task import Task, TaskRegistry from bcipy.task.orchestrator import SessionOrchestrator from bcipy.task.orchestrator.protocol import parse_protocol diff --git a/bcipy/preferences.py b/bcipy/preferences.py index 15963e3fb..84b9ade5c 100644 --- a/bcipy/preferences.py +++ b/bcipy/preferences.py @@ -1,8 +1,9 @@ """Module for recording and loading application state and user preferences.""" import json from pathlib import Path -from typing import Optional, Any, Dict -from bcipy.config import DEFAULT_ENCODING, PREFERENCES_PATH, BCIPY_ROOT +from typing import Any, Dict, Optional + +from bcipy.config import BCIPY_ROOT, DEFAULT_ENCODING, PREFERENCES_PATH class Pref: diff --git a/bcipy/signal/evaluate/artifact.py b/bcipy/signal/evaluate/artifact.py index 3c09e1d04..c88d56a41 100644 --- a/bcipy/signal/evaluate/artifact.py +++ b/bcipy/signal/evaluate/artifact.py @@ -1,34 +1,27 @@ # mypy: disable-error-code="assignment,arg-type" +import logging import os from enum import Enum -from pathlib import Path -from typing import Union, List, Tuple, Optional from logging import getLogger -import logging +from pathlib import Path +from typing import List, Optional, Tuple, Union -from bcipy.config import ( - DEFAULT_PARAMETERS_FILENAME, - RAW_DATA_FILENAME, - TRIGGER_FILENAME, - DEFAULT_DEVICE_SPEC_FILENAME, - BCIPY_ROOT, - SESSION_LOG_FILENAME -) -from bcipy.helpers.acquisition import analysis_channels -from bcipy.io.load import ( - load_experimental_data, - load_json_parameters, - load_raw_data, -) +import mne + +import bcipy.acquisition.devices as devices +from bcipy.acquisition.devices import DeviceSpec +from bcipy.config import (BCIPY_ROOT, DEFAULT_DEVICE_SPEC_FILENAME, + DEFAULT_PARAMETERS_FILENAME, RAW_DATA_FILENAME, + SESSION_LOG_FILENAME, TRIGGER_FILENAME) +from bcipy.core.raw_data import RawData from bcipy.core.stimuli import mne_epochs +from bcipy.core.triggers import TriggerType, trigger_decoder +from bcipy.helpers.acquisition import analysis_channels from bcipy.io.convert import convert_to_mne -from bcipy.core.raw_data import RawData +from bcipy.io.load import (load_experimental_data, load_json_parameters, + load_raw_data) from bcipy.signal.process import get_default_transform -from bcipy.core.triggers import TriggerType, trigger_decoder -import bcipy.acquisition.devices as devices -from bcipy.acquisition.devices import DeviceSpec -import mne mne.set_log_level('WARNING') log = getLogger(SESSION_LOG_FILENAME) diff --git a/bcipy/signal/evaluate/fusion.py b/bcipy/signal/evaluate/fusion.py index 824b4a09d..ab06c01b7 100644 --- a/bcipy/signal/evaluate/fusion.py +++ b/bcipy/signal/evaluate/fusion.py @@ -1,25 +1,24 @@ # mypy: disable-error-code="assignment,var-annotated" +import logging +from typing import List, Tuple + import numpy as np from sklearn.utils import resample -from typing import List, Tuple -import logging from tqdm import tqdm -from bcipy.config import (TRIGGER_FILENAME, SESSION_LOG_FILENAME) -from bcipy.helpers.acquisition import analysis_channels +from bcipy.acquisition.devices import DeviceSpec +from bcipy.config import SESSION_LOG_FILENAME, TRIGGER_FILENAME +from bcipy.core.parameters import Parameters from bcipy.core.raw_data import RawData from bcipy.core.stimuli import update_inquiry_timing from bcipy.core.triggers import TriggerType, trigger_decoder +from bcipy.helpers.acquisition import analysis_channels from bcipy.preferences import preferences -from bcipy.signal.model.base_model import SignalModelMetadata -from bcipy.signal.model.base_model import SignalModel +from bcipy.signal.model.base_model import SignalModel, SignalModelMetadata from bcipy.signal.model.gaussian_mixture import GaussianProcess from bcipy.signal.model.pca_rda_kde import PcaRdaKdeModel from bcipy.signal.process import (ERPTransformParams, extract_eye_info, filter_inquiries, get_default_transform) -from bcipy.acquisition.devices import DeviceSpec -from bcipy.core.parameters import Parameters - logger = logging.getLogger(SESSION_LOG_FILENAME) diff --git a/bcipy/signal/generator/generator.py b/bcipy/signal/generator/generator.py index b9f411cc0..efc76d1f0 100644 --- a/bcipy/signal/generator/generator.py +++ b/bcipy/signal/generator/generator.py @@ -1,6 +1,7 @@ -import numpy as np from typing import List +import numpy as np + def truncate_float(num: float, precision: int) -> float: """Truncate a float to a given precision.""" diff --git a/bcipy/signal/model/__init__.py b/bcipy/signal/model/__init__.py index d48a32f45..f65fe7c0d 100644 --- a/bcipy/signal/model/__init__.py +++ b/bcipy/signal/model/__init__.py @@ -1,9 +1,8 @@ -from bcipy.signal.model.base_model import SignalModel, ModelEvaluationReport +from bcipy.signal.model.base_model import ModelEvaluationReport, SignalModel +from bcipy.signal.model.gaussian_mixture.gaussian_mixture import ( + GaussianProcess, GMIndividual) from bcipy.signal.model.pca_rda_kde.pca_rda_kde import PcaRdaKdeModel from bcipy.signal.model.rda_kde.rda_kde import RdaKdeModel -from bcipy.signal.model.gaussian_mixture.gaussian_mixture import ( - GMIndividual, GaussianProcess) - __all__ = [ "SignalModel", diff --git a/bcipy/signal/model/cross_validation.py b/bcipy/signal/model/cross_validation.py index 238229b0a..65587a40d 100644 --- a/bcipy/signal/model/cross_validation.py +++ b/bcipy/signal/model/cross_validation.py @@ -1,10 +1,10 @@ -import numpy as np +import logging +import numpy as np import scipy.optimize from sklearn import metrics -from bcipy.config import SESSION_LOG_FILENAME -import logging +from bcipy.config import SESSION_LOG_FILENAME log = logging.getLogger(SESSION_LOG_FILENAME) diff --git a/bcipy/signal/model/dimensionality_reduction.py b/bcipy/signal/model/dimensionality_reduction.py index 288cc3da3..c2bff96e3 100644 --- a/bcipy/signal/model/dimensionality_reduction.py +++ b/bcipy/signal/model/dimensionality_reduction.py @@ -1,6 +1,6 @@ import logging - from typing import Optional + import numpy as np from sklearn.decomposition import PCA diff --git a/bcipy/signal/model/gaussian_mixture/__init__.py b/bcipy/signal/model/gaussian_mixture/__init__.py index f9b92f3fa..8999f632e 100644 --- a/bcipy/signal/model/gaussian_mixture/__init__.py +++ b/bcipy/signal/model/gaussian_mixture/__init__.py @@ -1,4 +1,4 @@ -from .gaussian_mixture import GMIndividual, GaussianProcess, GazeModelResolver +from .gaussian_mixture import GaussianProcess, GazeModelResolver, GMIndividual __all__ = [ 'GMIndividual', diff --git a/bcipy/signal/model/gaussian_mixture/gaussian_mixture.py b/bcipy/signal/model/gaussian_mixture/gaussian_mixture.py index 5d2b591f3..f438e6df7 100644 --- a/bcipy/signal/model/gaussian_mixture/gaussian_mixture.py +++ b/bcipy/signal/model/gaussian_mixture/gaussian_mixture.py @@ -1,17 +1,17 @@ import pickle +import warnings +from enum import Enum from pathlib import Path from typing import List -from enum import Enum - -from bcipy.exceptions import SignalException -from bcipy.core.stimuli import GazeReshaper -from bcipy.signal.model import SignalModel -from sklearn.mixture import GaussianMixture import numpy as np import scipy.stats as stats +from sklearn.mixture import GaussianMixture + +from bcipy.core.stimuli import GazeReshaper +from bcipy.exceptions import SignalException +from bcipy.signal.model import SignalModel -import warnings warnings.filterwarnings("ignore") # ignore DeprecationWarnings from tensorflow diff --git a/bcipy/signal/model/offline_analysis.py b/bcipy/signal/model/offline_analysis.py index 5aa02bbdf..e82b706a3 100644 --- a/bcipy/signal/model/offline_analysis.py +++ b/bcipy/signal/model/offline_analysis.py @@ -5,33 +5,32 @@ from typing import List import numpy as np - from sklearn.metrics import balanced_accuracy_score from sklearn.model_selection import train_test_split import bcipy.acquisition.devices as devices from bcipy.acquisition.devices import DeviceSpec -from bcipy.config import (DEFAULT_DEVICE_SPEC_FILENAME, - DEFAULT_PARAMETERS_PATH, DEFAULT_DEVICES_PATH, - TRIGGER_FILENAME, SESSION_LOG_FILENAME) -from bcipy.helpers.acquisition import analysis_channels, raw_data_filename -from bcipy.io.load import (load_experimental_data, load_json_parameters, - load_raw_data) -from bcipy.gui.alert import confirm +from bcipy.config import (DEFAULT_DEVICE_SPEC_FILENAME, DEFAULT_DEVICES_PATH, + DEFAULT_PARAMETERS_PATH, SESSION_LOG_FILENAME, + TRIGGER_FILENAME) from bcipy.core.parameters import Parameters -from bcipy.io.save import save_model +from bcipy.core.raw_data import RawData from bcipy.core.stimuli import update_inquiry_timing from bcipy.core.symbols import alphabet -from bcipy.core.raw_data import RawData -from bcipy.helpers.utils import report_execution_time from bcipy.core.triggers import TriggerType, trigger_decoder +from bcipy.gui.alert import confirm +from bcipy.helpers.acquisition import analysis_channels, raw_data_filename +from bcipy.helpers.utils import report_execution_time +from bcipy.io.load import (load_experimental_data, load_json_parameters, + load_raw_data) +from bcipy.io.save import save_model from bcipy.preferences import preferences +from bcipy.signal.evaluate.fusion import calculate_eeg_gaze_fusion_acc from bcipy.signal.model.base_model import SignalModel, SignalModelMetadata -from bcipy.signal.model.gaussian_mixture import (GazeModelResolver) +from bcipy.signal.model.gaussian_mixture import GazeModelResolver from bcipy.signal.model.pca_rda_kde import PcaRdaKdeModel from bcipy.signal.process import (ERPTransformParams, extract_eye_info, filter_inquiries, get_default_transform) -from bcipy.signal.evaluate.fusion import calculate_eeg_gaze_fusion_acc log = logging.getLogger(SESSION_LOG_FILENAME) logging.basicConfig(level=logging.INFO, format="[%(threadName)-9s][%(asctime)s][%(name)s][%(levelname)s]: %(message)s") diff --git a/bcipy/signal/model/pca_rda_kde/pca_rda_kde.py b/bcipy/signal/model/pca_rda_kde/pca_rda_kde.py index 509ec03dc..66439a427 100644 --- a/bcipy/signal/model/pca_rda_kde/pca_rda_kde.py +++ b/bcipy/signal/model/pca_rda_kde/pca_rda_kde.py @@ -4,8 +4,8 @@ import numpy as np -from bcipy.exceptions import SignalException from bcipy.core.stimuli import InquiryReshaper +from bcipy.exceptions import SignalException from bcipy.signal.model import ModelEvaluationReport, SignalModel from bcipy.signal.model.classifier import RegularizedDiscriminantAnalysis from bcipy.signal.model.cross_validation import (cost_cross_validation_auc, diff --git a/bcipy/signal/model/rda_kde/rda_kde.py b/bcipy/signal/model/rda_kde/rda_kde.py index eb6f4b565..785f7c9dc 100644 --- a/bcipy/signal/model/rda_kde/rda_kde.py +++ b/bcipy/signal/model/rda_kde/rda_kde.py @@ -3,14 +3,16 @@ from typing import List import numpy as np + +from bcipy.core.stimuli import InquiryReshaper +from bcipy.exceptions import SignalException from bcipy.signal.model import ModelEvaluationReport, SignalModel from bcipy.signal.model.classifier import RegularizedDiscriminantAnalysis -from bcipy.signal.model.cross_validation import cost_cross_validation_auc, cross_validation +from bcipy.signal.model.cross_validation import (cost_cross_validation_auc, + cross_validation) from bcipy.signal.model.density_estimation import KernelDensityEstimate from bcipy.signal.model.dimensionality_reduction import MockPCA from bcipy.signal.model.pipeline import Pipeline -from bcipy.exceptions import SignalException -from bcipy.core.stimuli import InquiryReshaper class RdaKdeModel(SignalModel): diff --git a/bcipy/signal/process/__init__.py b/bcipy/signal/process/__init__.py index e2239aab0..0b3c62b41 100644 --- a/bcipy/signal/process/__init__.py +++ b/bcipy/signal/process/__init__.py @@ -1,8 +1,8 @@ +from bcipy.signal.process.extract_gaze import extract_eye_info from bcipy.signal.process.filter import filter_inquiries from bcipy.signal.process.transform import (Composition, Downsample, ERPTransformParams, get_default_transform) -from bcipy.signal.process.extract_gaze import extract_eye_info __all__ = [ "filter_inquiries", diff --git a/bcipy/signal/process/decomposition/cwt.py b/bcipy/signal/process/decomposition/cwt.py index 056c27333..920ae5fcf 100644 --- a/bcipy/signal/process/decomposition/cwt.py +++ b/bcipy/signal/process/decomposition/cwt.py @@ -1,4 +1,5 @@ from typing import Tuple + import numpy as np import pywt diff --git a/bcipy/signal/process/decomposition/psd.py b/bcipy/signal/process/decomposition/psd.py index ddeda93da..bc326a218 100644 --- a/bcipy/signal/process/decomposition/psd.py +++ b/bcipy/signal/process/decomposition/psd.py @@ -1,12 +1,12 @@ +from enum import Enum +from typing import Tuple + import matplotlib.pyplot as plt -import seaborn as sns import numpy as np -from enum import Enum -from scipy.signal import welch -from scipy.integrate import simps +import seaborn as sns from mne.time_frequency import psd_array_multitaper - -from typing import Tuple +from scipy.integrate import simps +from scipy.signal import welch from bcipy.exceptions import SignalException diff --git a/bcipy/signal/tests/evaluate/test_artifact.py b/bcipy/signal/tests/evaluate/test_artifact.py index 758dc535f..ec201561e 100644 --- a/bcipy/signal/tests/evaluate/test_artifact.py +++ b/bcipy/signal/tests/evaluate/test_artifact.py @@ -1,7 +1,10 @@ import unittest -from mockito import when, mock, unstub -from bcipy.signal.evaluate.artifact import ArtifactDetection, DefaultArtifactParameters, ArtifactType + +from mockito import mock, unstub, when + from bcipy.signal.evaluate import artifact +from bcipy.signal.evaluate.artifact import (ArtifactDetection, ArtifactType, + DefaultArtifactParameters) class TestArtifactDetection(unittest.TestCase): diff --git a/bcipy/signal/tests/model/gaussian_mixture/test_gaussian_mixture.py b/bcipy/signal/tests/model/gaussian_mixture/test_gaussian_mixture.py index ff5fe3448..41487b888 100644 --- a/bcipy/signal/tests/model/gaussian_mixture/test_gaussian_mixture.py +++ b/bcipy/signal/tests/model/gaussian_mixture/test_gaussian_mixture.py @@ -1,10 +1,8 @@ import unittest -from bcipy.signal.model.gaussian_mixture import ( - GaussianProcess, - GMIndividual, - GazeModelResolver -) +from bcipy.signal.model.gaussian_mixture import (GaussianProcess, + GazeModelResolver, + GMIndividual) class TestGazeModelResolver(unittest.TestCase): diff --git a/bcipy/signal/tests/model/pca_rda_kde/test_pca_rda_kde.py b/bcipy/signal/tests/model/pca_rda_kde/test_pca_rda_kde.py index 7aed8d9fb..dabca5f2a 100644 --- a/bcipy/signal/tests/model/pca_rda_kde/test_pca_rda_kde.py +++ b/bcipy/signal/tests/model/pca_rda_kde/test_pca_rda_kde.py @@ -8,10 +8,10 @@ import pytest from scipy.stats import norm +from bcipy.core.symbols import alphabet from bcipy.exceptions import SignalException from bcipy.io.load import load_signal_models from bcipy.io.save import save_model -from bcipy.core.symbols import alphabet from bcipy.signal.model import ModelEvaluationReport, PcaRdaKdeModel from bcipy.signal.model.classifier import RegularizedDiscriminantAnalysis from bcipy.signal.model.cross_validation import cross_validation diff --git a/bcipy/signal/tests/model/rda_kde/test_rda_kde.py b/bcipy/signal/tests/model/rda_kde/test_rda_kde.py index 4dfcea21c..b06cf974e 100644 --- a/bcipy/signal/tests/model/rda_kde/test_rda_kde.py +++ b/bcipy/signal/tests/model/rda_kde/test_rda_kde.py @@ -9,13 +9,13 @@ from scipy.stats import norm from bcipy.core.symbols import alphabet +from bcipy.exceptions import SignalException from bcipy.signal.model import ModelEvaluationReport, RdaKdeModel from bcipy.signal.model.classifier import RegularizedDiscriminantAnalysis from bcipy.signal.model.cross_validation import cross_validation from bcipy.signal.model.density_estimation import KernelDensityEstimate from bcipy.signal.model.dimensionality_reduction import MockPCA from bcipy.signal.model.pipeline import Pipeline -from bcipy.exceptions import SignalException expected_output_folder = Path(__file__).absolute().parent.parent / "unit_test_expected_output" diff --git a/bcipy/signal/tests/model/test_offline_analysis.py b/bcipy/signal/tests/model/test_offline_analysis.py index 999807c55..3cc21d78f 100644 --- a/bcipy/signal/tests/model/test_offline_analysis.py +++ b/bcipy/signal/tests/model/test_offline_analysis.py @@ -1,15 +1,18 @@ """Integration test of offline_analysis.py (slow)""" -import unittest -from pathlib import Path -import pytest +import gzip +import random +import re import shutil import tempfile -import re +import unittest +from pathlib import Path + import numpy as np -import random -import gzip +import pytest -from bcipy.config import RAW_DATA_FILENAME, DEFAULT_PARAMETERS_FILENAME, TRIGGER_FILENAME, DEFAULT_DEVICE_SPEC_FILENAME +from bcipy.config import (DEFAULT_DEVICE_SPEC_FILENAME, + DEFAULT_PARAMETERS_FILENAME, RAW_DATA_FILENAME, + TRIGGER_FILENAME) from bcipy.io.load import load_json_parameters from bcipy.signal.model.offline_analysis import offline_analysis diff --git a/bcipy/signal/tests/process/test_downsample.py b/bcipy/signal/tests/process/test_downsample.py index 39c008528..ce6529cdf 100644 --- a/bcipy/signal/tests/process/test_downsample.py +++ b/bcipy/signal/tests/process/test_downsample.py @@ -1,7 +1,9 @@ -from bcipy.signal.process import Downsample -import numpy as np import unittest +import numpy as np + +from bcipy.signal.process import Downsample + class TestDownsample(unittest.TestCase): """Test downsample functionality""" diff --git a/bcipy/simulator/demo/demo_group_simulation.py b/bcipy/simulator/demo/demo_group_simulation.py index ef5f215a6..592798e4f 100644 --- a/bcipy/simulator/demo/demo_group_simulation.py +++ b/bcipy/simulator/demo/demo_group_simulation.py @@ -32,12 +32,13 @@ The summary data at the simulation run level (summary_data.json) will be the the data used to compare across users, phrases, language models. """ -from typing import Optional from pathlib import Path +from typing import Optional + +import bcipy.simulator.util.metrics as metrics from bcipy.io.load import load_json_parameters from bcipy.simulator.task.task_factory import TaskFactory from bcipy.simulator.task.task_runner import TaskRunner, init_simulation_dir -import bcipy.simulator.util.metrics as metrics # Define the phrases, starting indeces, and language models to use for the simulation @@ -175,9 +176,12 @@ def run_simulation( if __name__ == "__main__": - from bcipy.io.load import load_experimental_data from pathlib import Path + from tqdm import tqdm + + from bcipy.io.load import load_experimental_data + # load experimental directory. This will have users/run/data data_dir = Path(load_experimental_data()) diff --git a/bcipy/simulator/task/task_runner.py b/bcipy/simulator/task/task_runner.py index 0326af450..69875d74a 100644 --- a/bcipy/simulator/task/task_runner.py +++ b/bcipy/simulator/task/task_runner.py @@ -8,8 +8,8 @@ from rich.progress import track -from bcipy.io.load import load_json_parameters import bcipy.simulator.util.metrics as metrics +from bcipy.io.load import load_json_parameters # pylint: disable=wildcard-import,unused-wildcard-import # flake8: noqa from bcipy.simulator.data.sampler import * diff --git a/bcipy/task/__init__.py b/bcipy/task/__init__.py index 2a60353aa..0ad844a15 100644 --- a/bcipy/task/__init__.py +++ b/bcipy/task/__init__.py @@ -2,7 +2,6 @@ This import statement allows users to import submodules from Task """ from .main import Task, TaskData, TaskMode - # Makes the following classes available to the task registry from .registry import TaskRegistry diff --git a/bcipy/task/actions.py b/bcipy/task/actions.py index 2937a35ff..5472c7987 100644 --- a/bcipy/task/actions.py +++ b/bcipy/task/actions.py @@ -1,31 +1,32 @@ # mypy: disable-error-code="assignment,arg-type" -import subprocess -from typing import Any, Optional, List, Callable, Tuple +import glob import logging +import subprocess from pathlib import Path -import glob +from typing import Any, Callable, List, Optional, Tuple -from bcipy.gui.bciui import run_bciui from matplotlib.figure import Figure -from bcipy.gui.file_dialog import ask_directory -from bcipy.gui.intertask_gui import IntertaskGUI -from bcipy.gui.experiments.ExperimentField import start_experiment_field_collection_gui -from bcipy.task import Task, TaskMode, TaskData -from bcipy.core.triggers import trigger_decoder, TriggerType - from bcipy.acquisition import devices -from bcipy.helpers.acquisition import analysis_channels -from bcipy.core.parameters import Parameters from bcipy.acquisition.devices import DeviceSpec -from bcipy.io.load import load_raw_data +from bcipy.config import (RAW_DATA_FILENAME, SESSION_LOG_FILENAME, + TRIGGER_FILENAME) +from bcipy.core.parameters import Parameters from bcipy.core.raw_data import RawData -from bcipy.signal.process import get_default_transform -from bcipy.core.report import SignalReportSection, SessionReportSection, Report, ReportSection -from bcipy.config import SESSION_LOG_FILENAME, RAW_DATA_FILENAME, TRIGGER_FILENAME +from bcipy.core.report import (Report, ReportSection, SessionReportSection, + SignalReportSection) +from bcipy.core.triggers import TriggerType, trigger_decoder +from bcipy.gui.bciui import run_bciui +from bcipy.gui.experiments.ExperimentField import \ + start_experiment_field_collection_gui +from bcipy.gui.file_dialog import ask_directory +from bcipy.gui.intertask_gui import IntertaskGUI +from bcipy.helpers.acquisition import analysis_channels from bcipy.helpers.visualization import visualize_erp +from bcipy.io.load import load_raw_data from bcipy.signal.evaluate.artifact import ArtifactDetection - +from bcipy.signal.process import get_default_transform +from bcipy.task import Task, TaskData, TaskMode logger = logging.getLogger(SESSION_LOG_FILENAME) diff --git a/bcipy/task/calibration.py b/bcipy/task/calibration.py index d0829cfab..22fdbae46 100644 --- a/bcipy/task/calibration.py +++ b/bcipy/task/calibration.py @@ -1,4 +1,5 @@ """Base calibration task.""" +import logging from abc import abstractmethod from typing import Any, Dict, Iterator, List, NamedTuple, Optional, Tuple @@ -7,25 +8,24 @@ import bcipy.task.data as session_data from bcipy.acquisition import ClientManager -from bcipy.config import (SESSION_DATA_FILENAME, TRIGGER_FILENAME, - WAIT_SCREEN_MESSAGE, SESSION_LOG_FILENAME) -from bcipy.helpers.acquisition import init_acquisition, LslDataServer -from bcipy.display import init_display_window, Display -from bcipy.helpers.clock import Clock +from bcipy.config import (SESSION_DATA_FILENAME, SESSION_LOG_FILENAME, + TRIGGER_FILENAME, WAIT_SCREEN_MESSAGE) from bcipy.core.parameters import Parameters -from bcipy.io.save import _save_session_related_data from bcipy.core.stimuli import (DEFAULT_TEXT_FIXATION, StimuliOrder, TargetPositions, generate_calibration_inquiries) from bcipy.core.symbols import alphabet -from bcipy.helpers.task import (get_user_input, pause_calibration, - trial_complete_message) from bcipy.core.triggers import (FlushFrequency, Trigger, TriggerHandler, TriggerType, convert_timing_triggers, offset_label) +from bcipy.display import Display, init_display_window +from bcipy.helpers.acquisition import LslDataServer, init_acquisition +from bcipy.helpers.clock import Clock +from bcipy.helpers.task import (get_user_input, pause_calibration, + trial_complete_message) +from bcipy.io.save import _save_session_related_data from bcipy.task import Task, TaskData, TaskMode -import logging logger = logging.getLogger(SESSION_LOG_FILENAME) diff --git a/bcipy/task/control/criteria.py b/bcipy/task/control/criteria.py index 701fb544d..e5da2512a 100644 --- a/bcipy/task/control/criteria.py +++ b/bcipy/task/control/criteria.py @@ -1,9 +1,11 @@ -import numpy as np import logging -from typing import Dict, List from copy import copy +from typing import Dict, List + +import numpy as np from bcipy.config import SESSION_LOG_FILENAME + log = logging.getLogger(SESSION_LOG_FILENAME) diff --git a/bcipy/task/control/evidence.py b/bcipy/task/control/evidence.py index 684d4c5a3..87f6b658b 100644 --- a/bcipy/task/control/evidence.py +++ b/bcipy/task/control/evidence.py @@ -8,9 +8,9 @@ from bcipy.acquisition.multimodal import ContentType from bcipy.config import SESSION_LOG_FILENAME from bcipy.core.parameters import Parameters +from bcipy.core.stimuli import GazeReshaper, TrialReshaper from bcipy.display.main import ButtonPressMode from bcipy.helpers.acquisition import analysis_channels -from bcipy.core.stimuli import TrialReshaper, GazeReshaper from bcipy.signal.model import SignalModel from bcipy.signal.process import extract_eye_info from bcipy.task.data import EvidenceType diff --git a/bcipy/task/control/handler.py b/bcipy/task/control/handler.py index cf1a039b9..6dd534a80 100644 --- a/bcipy/task/control/handler.py +++ b/bcipy/task/control/handler.py @@ -1,14 +1,14 @@ import logging import string -from typing import Dict, List, Tuple, Optional +from typing import Dict, List, Optional, Tuple import numpy as np from bcipy.config import SESSION_LOG_FILENAME -from bcipy.core.stimuli import InquirySchedule, inq_generator, StimuliOrder -from bcipy.core.symbols import SPACE_CHAR, BACKSPACE_CHAR -from bcipy.task.control.query import RandomStimuliAgent, StimuliAgent +from bcipy.core.stimuli import InquirySchedule, StimuliOrder, inq_generator +from bcipy.core.symbols import BACKSPACE_CHAR, SPACE_CHAR from bcipy.task.control.criteria import CriteriaEvaluator +from bcipy.task.control.query import RandomStimuliAgent, StimuliAgent from bcipy.task.data import EvidenceType log = logging.getLogger(SESSION_LOG_FILENAME) diff --git a/bcipy/task/demo/actions/demo_calibration_report.py b/bcipy/task/demo/actions/demo_calibration_report.py index 560461028..776b91aaa 100644 --- a/bcipy/task/demo/actions/demo_calibration_report.py +++ b/bcipy/task/demo/actions/demo_calibration_report.py @@ -1,7 +1,8 @@ import logging -from bcipy.task.actions import BciPyCalibrationReportAction + from bcipy.config import DEFAULT_PARAMETERS_PATH from bcipy.io.load import load_json_parameters +from bcipy.task.actions import BciPyCalibrationReportAction logging.basicConfig(level=logging.INFO) diff --git a/bcipy/task/demo/control/demo_handler.py b/bcipy/task/demo/control/demo_handler.py index 987d2bf36..318f24960 100644 --- a/bcipy/task/demo/control/demo_handler.py +++ b/bcipy/task/demo/control/demo_handler.py @@ -1,6 +1,7 @@ -from bcipy.task.control.handler import EvidenceFusion, DecisionMaker import numpy as np +from bcipy.task.control.handler import DecisionMaker, EvidenceFusion + def _demo_fusion(): len_alp = 4 diff --git a/bcipy/task/demo/orchestrator/demo_orchestrator.py b/bcipy/task/demo/orchestrator/demo_orchestrator.py index d57bd0013..afbdd5108 100644 --- a/bcipy/task/demo/orchestrator/demo_orchestrator.py +++ b/bcipy/task/demo/orchestrator/demo_orchestrator.py @@ -1,9 +1,11 @@ from bcipy.config import DEFAULT_PARAMETERS_PATH +from bcipy.task.actions import (BciPyCalibrationReportAction, IntertaskAction, + OfflineAnalysisAction) from bcipy.task.orchestrator import SessionOrchestrator -from bcipy.task.actions import (OfflineAnalysisAction, IntertaskAction, BciPyCalibrationReportAction) -from bcipy.task.paradigm.rsvp import RSVPCalibrationTask # from bcipy.task.paradigm.rsvp import RSVPCopyPhraseTask, RSVPTimingVerificationCalibration from bcipy.task.paradigm.matrix import MatrixCalibrationTask +from bcipy.task.paradigm.rsvp import RSVPCalibrationTask + # from bcipy.task.paradigm.matrix.timing_verification import MatrixTimingVerificationCalibration diff --git a/bcipy/task/main.py b/bcipy/task/main.py index bd6d0f4c0..daadb089d 100644 --- a/bcipy/task/main.py +++ b/bcipy/task/main.py @@ -1,11 +1,11 @@ -from dataclasses import dataclass -from typing import Optional -from bcipy.core.parameters import Parameters from abc import ABC, abstractmethod +from dataclasses import dataclass from enum import Enum +from typing import Optional -from bcipy.core.stimuli import play_sound from bcipy.config import STATIC_AUDIO_PATH +from bcipy.core.parameters import Parameters +from bcipy.core.stimuli import play_sound @dataclass diff --git a/bcipy/task/orchestrator/orchestrator.py b/bcipy/task/orchestrator/orchestrator.py index e5c2c6756..9fcf2e92a 100644 --- a/bcipy/task/orchestrator/orchestrator.py +++ b/bcipy/task/orchestrator/orchestrator.py @@ -1,29 +1,23 @@ # mypy: disable-error-code="arg-type, assignment" import errno -import os import json -import subprocess -from datetime import datetime -import random import logging +import os +import random +import subprocess import time +from datetime import datetime from logging import Logger -from typing import List, Type, Optional +from typing import List, Optional, Type +from bcipy.config import (DEFAULT_EXPERIMENT_ID, DEFAULT_PARAMETERS_FILENAME, + DEFAULT_PARAMETERS_PATH, DEFAULT_USER_ID, + MULTIPHRASE_FILENAME, PROTOCOL_FILENAME, + PROTOCOL_LOG_FILENAME, SESSION_LOG_FILENAME) from bcipy.core.parameters import Parameters -from bcipy.helpers.utils import get_system_info, configure_logger -from bcipy.task import Task, TaskData, TaskMode -from bcipy.config import ( - DEFAULT_EXPERIMENT_ID, - DEFAULT_PARAMETERS_FILENAME, - DEFAULT_PARAMETERS_PATH, - DEFAULT_USER_ID, - MULTIPHRASE_FILENAME, - PROTOCOL_FILENAME, - PROTOCOL_LOG_FILENAME, - SESSION_LOG_FILENAME, -) +from bcipy.helpers.utils import configure_logger, get_system_info from bcipy.io.load import load_json_parameters +from bcipy.task import Task, TaskData, TaskMode class SessionOrchestrator: diff --git a/bcipy/task/orchestrator/protocol.py b/bcipy/task/orchestrator/protocol.py index af950162c..20396c07d 100644 --- a/bcipy/task/orchestrator/protocol.py +++ b/bcipy/task/orchestrator/protocol.py @@ -2,8 +2,9 @@ To start these will be 1:1 with tasks, but later this can be extended to represent training sequences, GUI popups etc""" from typing import List, Type -from bcipy.task import Task + from bcipy.config import TASK_SEPERATOR +from bcipy.task import Task from bcipy.task.registry import TaskRegistry diff --git a/bcipy/task/paradigm/matrix/calibration.py b/bcipy/task/paradigm/matrix/calibration.py index 21d005c32..1ecc355be 100644 --- a/bcipy/task/paradigm/matrix/calibration.py +++ b/bcipy/task/paradigm/matrix/calibration.py @@ -1,15 +1,15 @@ -from typing import Any, Dict, Optional, List +from typing import Any, Dict, List, Optional from psychopy import visual +from bcipy.core.parameters import Parameters from bcipy.display import InformationProperties, StimuliProperties from bcipy.display.components.task_bar import CalibrationTaskBar from bcipy.display.main import PreviewParams from bcipy.display.paradigm.matrix.display import MatrixDisplay from bcipy.helpers.clock import Clock -from bcipy.core.parameters import Parameters -from bcipy.io.save import save_stimuli_position_info from bcipy.helpers.utils import get_screen_info +from bcipy.io.save import save_stimuli_position_info from bcipy.task.calibration import BaseCalibrationTask diff --git a/bcipy/task/paradigm/matrix/copy_phrase.py b/bcipy/task/paradigm/matrix/copy_phrase.py index b9856e9e8..fdde27325 100644 --- a/bcipy/task/paradigm/matrix/copy_phrase.py +++ b/bcipy/task/paradigm/matrix/copy_phrase.py @@ -1,14 +1,14 @@ """Defines the Copy Phrase Task which uses a Matrix display""" from psychopy import visual +from bcipy.core.parameters import Parameters from bcipy.display import InformationProperties, StimuliProperties from bcipy.display.components.task_bar import CopyPhraseTaskBar from bcipy.display.main import PreviewParams from bcipy.display.paradigm.matrix.display import MatrixDisplay +from bcipy.helpers.clock import Clock from bcipy.task import TaskMode from bcipy.task.paradigm.rsvp.copy_phrase import RSVPCopyPhraseTask -from bcipy.core.parameters import Parameters -from bcipy.helpers.clock import Clock class MatrixCopyPhraseTask(RSVPCopyPhraseTask): diff --git a/bcipy/task/paradigm/matrix/timing_verification.py b/bcipy/task/paradigm/matrix/timing_verification.py index 33cfc6901..05f8ac0e9 100644 --- a/bcipy/task/paradigm/matrix/timing_verification.py +++ b/bcipy/task/paradigm/matrix/timing_verification.py @@ -1,10 +1,9 @@ from itertools import cycle, islice, repeat from typing import Iterator, List -from bcipy.core.stimuli import (PhotoDiodeStimuli, get_fixation, - jittered_timing) -from bcipy.task.calibration import Inquiry +from bcipy.core.stimuli import PhotoDiodeStimuli, get_fixation, jittered_timing from bcipy.task import TaskMode +from bcipy.task.calibration import Inquiry from bcipy.task.paradigm.matrix.calibration import (MatrixCalibrationTask, MatrixDisplay) diff --git a/bcipy/task/paradigm/rsvp/__init__.py b/bcipy/task/paradigm/rsvp/__init__.py index 111883a46..82e79d99e 100644 --- a/bcipy/task/paradigm/rsvp/__init__.py +++ b/bcipy/task/paradigm/rsvp/__init__.py @@ -1,4 +1,5 @@ # Import all RSVP tasks to make them available to the task registry from .calibration.calibration import RSVPCalibrationTask # noqa -from .calibration.timing_verification import RSVPTimingVerificationCalibration # noqa +from .calibration.timing_verification import \ + RSVPTimingVerificationCalibration # noqa from .copy_phrase import RSVPCopyPhraseTask # noqa diff --git a/bcipy/task/paradigm/rsvp/calibration/calibration.py b/bcipy/task/paradigm/rsvp/calibration/calibration.py index db188908c..efd5ecb54 100644 --- a/bcipy/task/paradigm/rsvp/calibration/calibration.py +++ b/bcipy/task/paradigm/rsvp/calibration/calibration.py @@ -1,11 +1,11 @@ from psychopy import core, visual +from bcipy.core.parameters import Parameters from bcipy.display import Display, InformationProperties, StimuliProperties from bcipy.display.components.task_bar import CalibrationTaskBar from bcipy.display.main import PreviewParams from bcipy.display.paradigm.rsvp.mode.calibration import CalibrationDisplay from bcipy.helpers.clock import Clock -from bcipy.core.parameters import Parameters from bcipy.task.calibration import BaseCalibrationTask diff --git a/bcipy/task/paradigm/rsvp/calibration/timing_verification.py b/bcipy/task/paradigm/rsvp/calibration/timing_verification.py index 9d4f502bf..f7411189b 100644 --- a/bcipy/task/paradigm/rsvp/calibration/timing_verification.py +++ b/bcipy/task/paradigm/rsvp/calibration/timing_verification.py @@ -3,10 +3,9 @@ from typing import Any, Iterator, List from bcipy.core.parameters import Parameters -from bcipy.core.stimuli import (PhotoDiodeStimuli, get_fixation, - jittered_timing) -from bcipy.task.calibration import Inquiry +from bcipy.core.stimuli import PhotoDiodeStimuli, get_fixation, jittered_timing from bcipy.task import TaskMode +from bcipy.task.calibration import Inquiry from bcipy.task.paradigm.rsvp.calibration.calibration import \ RSVPCalibrationTask diff --git a/bcipy/task/paradigm/vep/calibration.py b/bcipy/task/paradigm/vep/calibration.py index 8dca8b6bc..a7c54a18b 100644 --- a/bcipy/task/paradigm/vep/calibration.py +++ b/bcipy/task/paradigm/vep/calibration.py @@ -5,6 +5,8 @@ from psychopy import visual # type: ignore from bcipy.config import DEFAULT_FRAME_RATE, SESSION_LOG_FILENAME +from bcipy.core.parameters import Parameters +from bcipy.core.triggers import TriggerType from bcipy.display import InformationProperties, VEPStimuliProperties from bcipy.display.components.layout import centered from bcipy.display.components.task_bar import CalibrationTaskBar @@ -12,8 +14,6 @@ from bcipy.display.paradigm.vep.display import VEPDisplay from bcipy.display.paradigm.vep.layout import BoxConfiguration from bcipy.helpers.clock import Clock -from bcipy.core.parameters import Parameters -from bcipy.core.triggers import TriggerType from bcipy.task.calibration import BaseCalibrationTask, Inquiry from bcipy.task.paradigm.vep.stim_generation import \ generate_vep_calibration_inquiries diff --git a/bcipy/task/registry.py b/bcipy/task/registry.py index 36b1bacf2..58ad32cff 100644 --- a/bcipy/task/registry.py +++ b/bcipy/task/registry.py @@ -1,6 +1,7 @@ """Task Registry ; used to provide task options to the GUI and command line tools. User defined tasks can be added to the Registry.""" from typing import Dict, List, Type + from bcipy.task import Task @@ -10,8 +11,8 @@ class TaskRegistry: def __init__(self): # Collects all non-abstract subclasses of Task. type ignore is used to work around a mypy bug # https://github.com/python/mypy/issues/3115 - from bcipy.task.paradigm import vep, rsvp, matrix # noqa from bcipy.task import actions # noqa + from bcipy.task.paradigm import matrix, rsvp, vep # noqa self.registry_dict = {} self.collect_subclasses(Task) # type: ignore[type-abstract] diff --git a/bcipy/task/tests/core/test_actions.py b/bcipy/task/tests/core/test_actions.py index 185ba8cf3..8f0233338 100644 --- a/bcipy/task/tests/core/test_actions.py +++ b/bcipy/task/tests/core/test_actions.py @@ -1,9 +1,12 @@ -import unittest import subprocess +import unittest + +from mockito import mock, unstub, verify, when -from mockito import mock, when, verify, unstub -from bcipy.task import actions, TaskData -from bcipy.task.actions import CodeHookAction, OfflineAnalysisAction, ExperimentFieldCollectionAction +from bcipy.task import TaskData, actions +from bcipy.task.actions import (CodeHookAction, + ExperimentFieldCollectionAction, + OfflineAnalysisAction) class TestActions(unittest.TestCase): diff --git a/bcipy/task/tests/core/test_handler.py b/bcipy/task/tests/core/test_handler.py index 237f7cc51..c87e76551 100644 --- a/bcipy/task/tests/core/test_handler.py +++ b/bcipy/task/tests/core/test_handler.py @@ -1,11 +1,15 @@ +import math import unittest + import numpy as np -import math + +from bcipy.core.symbols import alphabet +from bcipy.task.control.criteria import (CriteriaEvaluator, + MaxIterationsCriteria, + MinIterationsCriteria, + ProbThresholdCriteria) from bcipy.task.control.handler import DecisionMaker, EvidenceFusion -from bcipy.task.control.criteria import CriteriaEvaluator, \ - MaxIterationsCriteria, MinIterationsCriteria, ProbThresholdCriteria from bcipy.task.control.query import NBestStimuliAgent -from bcipy.core.symbols import alphabet class TestDecisionMaker(unittest.TestCase): diff --git a/bcipy/task/tests/core/test_task_main.py b/bcipy/task/tests/core/test_task_main.py index dd3ce22f1..3b29b666a 100644 --- a/bcipy/task/tests/core/test_task_main.py +++ b/bcipy/task/tests/core/test_task_main.py @@ -1,4 +1,5 @@ import unittest + from bcipy.task import Task, TaskData, TaskMode diff --git a/bcipy/task/tests/orchestrator/test_orchestrator.py b/bcipy/task/tests/orchestrator/test_orchestrator.py index adcc6102a..2e220acc6 100644 --- a/bcipy/task/tests/orchestrator/test_orchestrator.py +++ b/bcipy/task/tests/orchestrator/test_orchestrator.py @@ -1,12 +1,14 @@ -import unittest -import logging import json +import logging +import unittest + from mock import mock_open -from mockito import any, mock, when, unstub, verify -from bcipy.task.orchestrator import SessionOrchestrator -from bcipy.task import Task, TaskData +from mockito import any, mock, unstub, verify, when + from bcipy.config import DEFAULT_PARAMETERS_PATH from bcipy.io.load import load_json_parameters +from bcipy.task import Task, TaskData +from bcipy.task.orchestrator import SessionOrchestrator class TestSessionOrchestrator(unittest.TestCase): diff --git a/bcipy/task/tests/orchestrator/test_protocol.py b/bcipy/task/tests/orchestrator/test_protocol.py index 1ea8750fe..ec258d71d 100644 --- a/bcipy/task/tests/orchestrator/test_protocol.py +++ b/bcipy/task/tests/orchestrator/test_protocol.py @@ -1,7 +1,11 @@ import unittest -from bcipy.task.orchestrator.protocol import parse_protocol, serialize_protocol, validate_protocol_string + from bcipy.task.actions import OfflineAnalysisAction -from bcipy.task.paradigm.rsvp.calibration.calibration import RSVPCalibrationTask +from bcipy.task.orchestrator.protocol import (parse_protocol, + serialize_protocol, + validate_protocol_string) +from bcipy.task.paradigm.rsvp.calibration.calibration import \ + RSVPCalibrationTask from bcipy.task.paradigm.rsvp.copy_phrase import RSVPCopyPhraseTask diff --git a/bcipy/task/tests/paradigm/matrix/test_matrix_calibration.py b/bcipy/task/tests/paradigm/matrix/test_matrix_calibration.py index ef7878de7..49027271e 100644 --- a/bcipy/task/tests/paradigm/matrix/test_matrix_calibration.py +++ b/bcipy/task/tests/paradigm/matrix/test_matrix_calibration.py @@ -9,9 +9,9 @@ from bcipy.acquisition import LslAcquisitionClient from bcipy.acquisition.devices import DeviceSpec from bcipy.acquisition.multimodal import ContentType -from bcipy.display.paradigm.matrix import MatrixDisplay from bcipy.core.parameters import Parameters from bcipy.core.triggers import TriggerHandler, TriggerType +from bcipy.display.paradigm.matrix import MatrixDisplay from bcipy.task.paradigm.matrix.calibration import MatrixCalibrationTask diff --git a/bcipy/task/tests/paradigm/rsvp/test_copy_phrase.py b/bcipy/task/tests/paradigm/rsvp/test_copy_phrase.py index cba6e320b..5c75a8565 100644 --- a/bcipy/task/tests/paradigm/rsvp/test_copy_phrase.py +++ b/bcipy/task/tests/paradigm/rsvp/test_copy_phrase.py @@ -13,11 +13,11 @@ from bcipy.acquisition.devices import DeviceSpec from bcipy.acquisition.multimodal import ContentType from bcipy.config import DEFAULT_ENCODING -from bcipy.exceptions import TaskConfigurationException -from bcipy.helpers.copy_phrase_wrapper import CopyPhraseWrapper from bcipy.core.parameters import Parameters from bcipy.core.stimuli import InquirySchedule from bcipy.core.triggers import TriggerHandler +from bcipy.exceptions import TaskConfigurationException +from bcipy.helpers.copy_phrase_wrapper import CopyPhraseWrapper from bcipy.task.data import EvidenceType, Session from bcipy.task.paradigm.rsvp.copy_phrase import RSVPCopyPhraseTask diff --git a/bcipy/tests/test_preferences.py b/bcipy/tests/test_preferences.py index f526d570c..967ac0000 100644 --- a/bcipy/tests/test_preferences.py +++ b/bcipy/tests/test_preferences.py @@ -1,8 +1,9 @@ """Tests for the Preferences""" -import unittest import shutil import tempfile +import unittest from pathlib import Path + from bcipy.preferences import Preferences diff --git a/pyproject.toml b/pyproject.toml index 9bc3d0ef4..a57c6768b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -160,6 +160,9 @@ ignore = [ "W504", ] +[tool.isort] +skip = [".gitignore"] + [tool.pytest.ini] markers = "slow" From e40b9d399c1aa6d5894ef51329c908ebcad666c4 Mon Sep 17 00:00:00 2001 From: tab-cmd Date: Sun, 24 Aug 2025 12:10:54 -0700 Subject: [PATCH 76/76] Cleanup for 2.0 release (#391) --- .gitignore | 2 + CHANGELOG.md | 5 + LICENSE.md | 34 +- README.md | 535 ++++++++----- bcipy/acquisition/datastream/generator.py | 111 ++- bcipy/acquisition/datastream/lsl_server.py | 107 ++- .../datastream/mock/eye_tracker_server.py | 33 +- bcipy/acquisition/datastream/mock/switch.py | 52 +- bcipy/acquisition/datastream/producer.py | 145 ++-- bcipy/acquisition/devices.py | 259 +++--- bcipy/acquisition/exceptions.py | 15 +- bcipy/acquisition/marker_writer.py | 82 +- bcipy/acquisition/multimodal.py | 201 +++-- bcipy/acquisition/protocols/lsl/connect.py | 28 +- bcipy/acquisition/protocols/lsl/lsl_client.py | 365 ++++++--- .../protocols/lsl/lsl_connector.py | 114 ++- .../acquisition/protocols/lsl/lsl_recorder.py | 176 +++-- bcipy/acquisition/record.py | 14 +- .../tests/datastream/test_generator.py | 6 +- bcipy/acquisition/tests/test_devices.py | 16 +- bcipy/config.py | 5 +- bcipy/core/demo/demo_report.py | 9 +- bcipy/core/demo/demo_session_tools.py | 3 +- bcipy/core/list.py | 106 ++- bcipy/core/parameters.py | 340 +++++--- bcipy/core/raw_data.py | 407 ++++++---- bcipy/core/report.py | 186 +++-- bcipy/core/session.py | 190 +++-- bcipy/core/stimuli.py | 741 +++++++++--------- bcipy/core/symbols.py | 62 +- bcipy/core/tests/test_list.py | 3 +- bcipy/core/tests/test_raw_data.py | 12 +- bcipy/core/tests/test_report.py | 9 +- bcipy/core/tests/test_stimuli.py | 30 +- bcipy/core/tests/test_triggers.py | 12 +- bcipy/core/triggers.py | 486 +++++++----- bcipy/demo/bci_main_demo.py | 6 +- bcipy/display/README.md | 178 ++++- .../components/button_press_handler.py | 149 +++- bcipy/display/components/layout.py | 355 +++++++-- bcipy/display/components/task_bar.py | 274 ++++--- bcipy/display/demo/components/demo_layouts.py | 3 +- .../demo/matrix/demo_calibration_matrix.py | 3 +- bcipy/display/main.py | 312 +++++--- bcipy/display/paradigm/matrix/README.md | 11 +- bcipy/display/paradigm/matrix/display.py | 476 ++++++----- bcipy/display/paradigm/matrix/layout.py | 53 +- bcipy/display/paradigm/rsvp/display.py | 276 ++++--- .../display/paradigm/rsvp/mode/calibration.py | 62 +- .../display/paradigm/rsvp/mode/copy_phrase.py | 100 ++- bcipy/display/paradigm/vep/display.py | 18 +- bcipy/display/tests/components/test_layout.py | 3 +- bcipy/exceptions.py | 131 +++- bcipy/feedback/README.md | 134 ++++ bcipy/feedback/feedback.py | 94 ++- bcipy/feedback/sound/auditory_feedback.py | 50 +- .../tests/sound/test_sound_feedback.py | 5 +- .../tests/visual/test_visual_feedback.py | 22 +- bcipy/feedback/visual/visual_feedback.py | 114 ++- bcipy/gui/BCInterface.py | 692 +++++++++------- bcipy/gui/README.md | 184 ++++- bcipy/gui/alert.py | 28 +- bcipy/gui/bciui.py | 244 ++++-- bcipy/gui/experiments/ExperimentField.py | 12 +- bcipy/gui/experiments/ExperimentRegistry.py | 9 +- bcipy/gui/file_dialog.py | 135 ++-- bcipy/gui/intertask_gui.py | 68 +- bcipy/gui/main.py | 225 +++--- bcipy/gui/parameters/params_form.py | 12 +- bcipy/gui/viewer/data_viewer.py | 18 +- bcipy/gui/viewer/ring_buffer.py | 81 +- bcipy/helpers/acquisition.py | 9 +- bcipy/helpers/copy_phrase_wrapper.py | 3 +- bcipy/helpers/demo/demo_visualization.py | 3 +- bcipy/helpers/language_model.py | 10 +- bcipy/helpers/offset.py | 15 +- bcipy/helpers/task.py | 9 +- bcipy/helpers/tests/test_offset.py | 9 +- bcipy/helpers/tests/test_system_utils.py | 6 +- bcipy/helpers/tests/test_visualization.py | 15 +- bcipy/helpers/utils.py | 16 +- bcipy/helpers/validate.py | 6 +- bcipy/helpers/visualization.py | 63 +- bcipy/io/README.md | 2 +- bcipy/io/convert.py | 40 +- bcipy/io/demo/demo_convert.py | 18 +- bcipy/io/demo/demo_load_BIDS.py | 12 +- bcipy/io/load.py | 464 +++++++---- bcipy/io/save.py | 38 +- bcipy/io/tests/test_convert.py | 72 +- bcipy/io/tests/test_load.py | 9 +- bcipy/io/tests/test_save.py | 9 +- bcipy/language/README.md | 10 +- bcipy/language/demo/demo_ngram.py | 6 +- bcipy/language/model/adapter.py | 6 +- bcipy/language/model/causal.py | 6 +- bcipy/language/model/mixture.py | 10 +- bcipy/language/model/oracle.py | 3 +- bcipy/language/tests/test_causal.py | 24 +- bcipy/language/tests/test_mixture.py | 6 +- bcipy/main.py | 64 +- bcipy/parameters/devices.json | 19 + bcipy/preferences.py | 113 ++- bcipy/signal/evaluate/artifact.py | 39 +- bcipy/signal/evaluate/fusion.py | 105 ++- bcipy/signal/model/classifier.py | 24 +- bcipy/signal/model/cross_validation.py | 5 +- bcipy/signal/model/density_estimation.py | 12 +- .../signal/model/dimensionality_reduction.py | 6 +- .../gaussian_mixture/gaussian_mixture.py | 30 +- bcipy/signal/model/offline_analysis.py | 63 +- bcipy/signal/model/pca_rda_kde/pca_rda_kde.py | 33 +- bcipy/signal/model/pipeline.py | 11 +- bcipy/signal/model/rda_kde/rda_kde.py | 24 +- bcipy/signal/model/switch_model.py | 7 +- bcipy/signal/process/decomposition/cwt.py | 3 +- bcipy/signal/process/extract_gaze.py | 3 +- bcipy/signal/process/filter.py | 9 +- bcipy/signal/tests/evaluate/test_artifact.py | 15 +- .../model/pca_rda_kde/test_pca_rda_kde.py | 60 +- .../tests/model/rda_kde/test_rda_kde.py | 39 +- .../tests/model/test_offline_analysis.py | 51 +- bcipy/simulator/README.md | 85 +- bcipy/simulator/data/data_engine.py | 6 +- bcipy/simulator/data/data_process.py | 36 +- bcipy/simulator/data/sampler/base_sampler.py | 3 +- .../simulator/data/sampler/inquiry_sampler.py | 6 +- .../data/sampler/target_nontarget_sampler.py | 1 + bcipy/simulator/data/trial.py | 6 +- bcipy/simulator/demo/demo_group_simulation.py | 27 +- bcipy/simulator/exceptions.py | 6 +- bcipy/simulator/task/copy_phrase.py | 22 +- bcipy/simulator/task/null_display.py | 4 +- bcipy/simulator/task/replay_session.py | 4 +- bcipy/simulator/task/task_factory.py | 4 +- bcipy/simulator/task/task_runner.py | 3 +- bcipy/simulator/ui/cli.py | 12 +- bcipy/simulator/ui/gui.py | 16 +- bcipy/simulator/ui/obj_args_widget.py | 6 +- bcipy/simulator/util/artifact.py | 3 +- bcipy/simulator/util/generate_marker_data.py | 2 +- bcipy/simulator/util/metrics.py | 3 +- bcipy/simulator/util/state.py | 6 +- bcipy/simulator/util/switch_utils.py | 10 +- bcipy/static/images/gui/cambi_fav.ico | Bin 0 -> 432254 bytes bcipy/task/actions.py | 248 +++++- bcipy/task/calibration.py | 7 +- bcipy/task/control/criteria.py | 368 ++++++--- bcipy/task/control/evidence.py | 360 ++++++--- bcipy/task/control/handler.py | 312 ++++---- bcipy/task/control/query.py | 164 ++-- bcipy/task/data.py | 238 ++++-- .../demo/actions/demo_calibration_report.py | 6 +- .../demo/orchestrator/demo_orchestrator.py | 3 +- bcipy/task/exceptions.py | 62 +- bcipy/task/main.py | 113 ++- bcipy/task/orchestrator/__init__.py | 7 + bcipy/task/orchestrator/orchestrator.py | 243 ++++-- bcipy/task/orchestrator/protocol.py | 97 ++- bcipy/task/paradigm/matrix/calibration.py | 93 ++- bcipy/task/paradigm/matrix/copy_phrase.py | 55 +- .../paradigm/matrix/timing_verification.py | 66 +- .../paradigm/rsvp/calibration/calibration.py | 69 +- .../rsvp/calibration/timing_verification.py | 50 +- bcipy/task/paradigm/rsvp/copy_phrase.py | 51 +- bcipy/task/paradigm/vep/calibration.py | 121 ++- bcipy/task/paradigm/vep/stim_generation.py | 152 ++-- bcipy/task/registry.py | 94 ++- bcipy/task/tests/core/test_actions.py | 9 +- bcipy/task/tests/core/test_handler.py | 3 +- bcipy/task/tests/core/test_task_main.py | 58 -- .../tests/orchestrator/test_orchestrator.py | 48 +- .../task/tests/orchestrator/test_protocol.py | 3 +- .../rsvp/calibration/test_rsvp_calibration.py | 9 +- .../tests/paradigm/rsvp/test_copy_phrase.py | 30 +- bcipy/tests/test_bci_main.py | 30 +- pyproject.toml | 20 +- 177 files changed, 9737 insertions(+), 4795 deletions(-) create mode 100644 bcipy/feedback/README.md create mode 100644 bcipy/static/images/gui/cambi_fav.ico delete mode 100644 bcipy/task/tests/core/test_task_main.py diff --git a/.gitignore b/.gitignore index 90ff2ae34..30c3caabc 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ html/ venv/ venv*/ __pycache__/ +.pytest_cache/ .cache/ # BciPy files and directories @@ -39,3 +40,4 @@ bcipy/simulator/tests/resource/ data/ bids/ !bcipy/simulator/data + diff --git a/CHANGELOG.md b/CHANGELOG.md index 48ca96c20..3d2f47480 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The next major BciPy release is here! All features included from release canidat - Refactor `helpers` into `io` and `core` #362 - Refactor to use `pyproject.toml` for installs #367 - Language Model Refactor to use lm-toolkit #381 #390 +- Gaze Model integration #384 - Simulator - Multimodal support #385 - Replay feature #376 @@ -25,6 +26,10 @@ The next major BciPy release is here! All features included from release canidat - `EDFlib-Python` #362 - Remove - `pyedflib` #362 + - Drop support for python 3.8 #391 +- General documentation improvements + - README updates #391 + - Drop Twitter links #391 # 2.0.1-rc.4 diff --git a/LICENSE.md b/LICENSE.md index 907d40b62..341ee96fe 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,33 +1,11 @@ -BciPy Copyright 2021 (CAMBI)(“Licensor”) +Copyright 2025 (CAMBI)("Licensor") -Hippocratic License Version Number: 2.1. +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: -Purpose. The purpose of this License is for the Licensor named above to permit the Licensee (as defined below) broad permission, if consistent with Human Rights Laws and Human Rights Principles (as each is defined below), to use and work with the Software (as defined below) within the full scope of Licensor’s copyright and patent rights, if any, in the Software, while ensuring attribution and protecting the Licensor from liability. +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. -Permission and Conditions. The Licensor grants permission by this license (“License”), free of charge, to the extent of Licensor’s rights under applicable copyright and patent law, to any person or entity (the “Licensee”) obtaining a copy of this software and associated documentation files (the “Software”), to do everything with the Software that would otherwise infringe (i) the Licensor’s copyright in the Software or (ii) any patent claims to the Software that the Licensor can license or becomes able to license, subject to all of the following terms and conditions: +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. -* Acceptance. This License is automatically offered to every person and entity subject to its terms and conditions. Licensee accepts this License and agrees to its terms and conditions by taking any action with the Software that, absent this License, would infringe any intellectual property right held by Licensor. +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. -* Notice. Licensee must ensure that everyone who gets a copy of any part of this Software from Licensee, with or without changes, also receives the License and the above copyright notice (and if included by the Licensor, patent, trademark and attribution notice). Licensee must cause any modified versions of the Software to carry prominent notices stating that Licensee changed the Software. For clarity, although Licensee is free to create modifications of the Software and distribute only the modified portion created by Licensee with additional or different terms, the portion of the Software not modified must be distributed pursuant to this License. If anyone notifies Licensee in writing that Licensee has not complied with this Notice section, Licensee can keep this License by taking all practical steps to comply within 30 days after the notice. If Licensee does not do so, Licensee’s License (and all rights licensed hereunder) shall end immediately. - -* Compliance with Human Rights Principles and Human Rights Laws. - - 1. Human Rights Principles. - - (a) Licensee is advised to consult the articles of the United Nations Universal Declaration of Human Rights and the United Nations Global Compact that define recognized principles of international human rights (the “Human Rights Principles”). Licensee shall use the Software in a manner consistent with Human Rights Principles. - - (b) Unless the Licensor and Licensee agree otherwise, any dispute, controversy, or claim arising out of or relating to (i) Section 1(a) regarding Human Rights Principles, including the breach of Section 1(a), termination of this License for breach of the Human Rights Principles, or invalidity of Section 1(a) or (ii) a determination of whether any Law is consistent or in conflict with Human Rights Principles pursuant to Section 2, below, shall be settled by arbitration in accordance with the Hague Rules on Business and Human Rights Arbitration (the “Rules”); provided, however, that Licensee may elect not to participate in such arbitration, in which event this License (and all rights licensed hereunder) shall end immediately. The number of arbitrators shall be one unless the Rules require otherwise. - - Unless both the Licensor and Licensee agree to the contrary: (1) All documents and information concerning the arbitration shall be public and may be disclosed by any party; (2) The repository referred to under Article 43 of the Rules shall make available to the public in a timely manner all documents concerning the arbitration which are communicated to it, including all submissions of the parties, all evidence admitted into the record of the proceedings, all transcripts or other recordings of hearings and all orders, decisions and awards of the arbitral tribunal, subject only to the arbitral tribunal's powers to take such measures as may be necessary to safeguard the integrity of the arbitral process pursuant to Articles 18, 33, 41 and 42 of the Rules; and (3) Article 26(6) of the Rules shall not apply. - - 2. Human Rights Laws. The Software shall not be used by any person or entity for any systems, activities, or other uses that violate any Human Rights Laws. “Human Rights Laws” means any applicable laws, regulations, or rules (collectively, “Laws”) that protect human, civil, labor, privacy, political, environmental, security, economic, due process, or similar rights; provided, however, that such Laws are consistent and not in conflict with Human Rights Principles (a dispute over the consistency or a conflict between Laws and Human Rights Principles shall be determined by arbitration as stated above). Where the Human Rights Laws of more than one jurisdiction are applicable or in conflict with respect to the use of the Software, the Human Rights Laws that are most protective of the individuals or groups harmed shall apply. - - 3. Indemnity. Licensee shall hold harmless and indemnify Licensor (and any other contributor) against all losses, damages, liabilities, deficiencies, claims, actions, judgments, settlements, interest, awards, penalties, fines, costs, or expenses of whatever kind, including Licensor’s reasonable attorneys’ fees, arising out of or relating to Licensee’s use of the Software in violation of Human Rights Laws or Human Rights Principles. - -* Failure to Comply. Any failure of Licensee to act according to the terms and conditions of this License is both a breach of the License and an infringement of the intellectual property rights of the Licensor (subject to exceptions under Laws, e.g., fair use). In the event of a breach or infringement, the terms and conditions of this License may be enforced by Licensor under the Laws of any jurisdiction to which Licensee is subject. Licensee also agrees that the Licensor may enforce the terms and conditions of this License against Licensee through specific performance (or similar remedy under Laws) to the extent permitted by Laws. For clarity, except in the event of a breach of this License, infringement, or as otherwise stated in this License, Licensor may not terminate this License with Licensee. - -* Enforceability and Interpretation. If any term or provision of this License is determined to be invalid, illegal, or unenforceable by a court of competent jurisdiction, then such invalidity, illegality, or unenforceability shall not affect any other term or provision of this License or invalidate or render unenforceable such term or provision in any other jurisdiction; provided, however, subject to a court modification pursuant to the immediately following sentence, if any term or provision of this License pertaining to Human Rights Laws or Human Rights Principles is deemed invalid, illegal, or unenforceable against Licensee by a court of competent jurisdiction, all rights in the Software granted to Licensee shall be deemed null and void as between Licensor and Licensee. Upon a determination that any term or provision is invalid, illegal, or unenforceable, to the extent permitted by Laws, the court may modify this License to affect the original purpose that the Software be used in compliance with Human Rights Principles and Human Rights Laws as closely as possible. The language in this License shall be interpreted as to its fair meaning and not strictly for or against any party. - -* Disclaimer. TO THE FULL EXTENT ALLOWED BY LAW, THIS SOFTWARE COMES “AS IS,” WITHOUT ANY WARRANTY, EXPRESS OR IMPLIED, AND LICENSOR AND ANY OTHER CONTRIBUTOR SHALL NOT BE LIABLE TO ANYONE FOR ANY DAMAGES OR OTHER LIABILITY ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THIS LICENSE, UNDER ANY KIND OF LEGAL CLAIM. - -This Hippocratic License is an Ethical Source license (https://ethicalsource.dev) and is offered for use by licensors and licensees at their own risk, on an “AS IS” basis, and with no warranties express or implied, to the maximum extent permitted by Laws. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index 4b908fd6f..f100d5b45 100644 --- a/README.md +++ b/README.md @@ -3,21 +3,93 @@ [![BciPy](https://github.com/CAMBI-tech/BciPy/actions/workflows/main.yml/badge.svg)](https://github.com/CAMBI-tech/BciPy/actions/workflows/main.yml) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/96e31da4b0554dae9db7a1356556b0d5)](https://app.codacy.com/gh/CAMBI-tech/BciPy/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg)](https://github.com/CAMBI-tech/BciPy/fork) -[![Follow on Twitter](https://img.shields.io/twitter/follow/cambi_tech?label=Follow&style=social)](https://twitter.com/cambi_tech) +[![BSD-3-Clause License](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) -BciPy is a library for conducting Brain-Computer Interface experiments in Python. It functions as a standalone application for experimental data collection or you can take the tools you need and start coding your own system. See our official BciPy documentation including affiliations and more context information [here](https://bcipy.github.io/). +[![CAMBI_logo](./bcipy/static/images/gui/CAMBI_full_logo.png)](https://cambi.tech) -It will run on the latest windows (10, 11), linux (ubuntu 22.04) and macos (Sonoma). Other versions may work as well, but are not guaranteed. To see supported versions and operating systems as of this release see here: [BciPy Builds](https://github.com/CAMBI-tech/BciPy/actions/workflows/main.yml). +BciPy is a library for conducting Brain-Computer Interface experiments in Python. It is designed to be modular and extensible, allowing researchers to easily add new paradigms, models, and processing methods. The focus of BciPy is on paradigms for communication and control, including Rapid Serial Visual Presentation (RSVP) and Matrix Speller. See our official documentation including affiliations and more context information [here](https://bcipy.github.io/). -*Please cite us when using!* +BciPy is released open-source under the BSD-3 clause. Please refer to [LICENSE.md](LICENSE.md). + +**If you use BciPy in your research, please cite the following manuscript:** ```text Memmott, T., Koçanaoğulları, A., Lawhead, M., Klee, D., Dudy, S., Fried-Oken, M., & Oken, B. (2021). BciPy: brain–computer interface software in Python. Brain-Computer Interfaces, 1-18. ``` +## Table of Contents + +- [BciPy: Brain-Computer Interface Software in Python](#bcipy-brain-computer-interface-software-in-python) + - [Table of Contents](#table-of-contents) + - [Dependencies](#dependencies) + - [Linux](#linux) + - [Windows](#windows) + - [Mac](#mac) + - [Installation](#installation) + - [BciPy Setup](#bcipy-setup) + - [Editable Install and GUI usage](#editable-install-and-gui-usage) + - [PyPi Install](#pypi-install) + - [Make install](#make-install) + - [Usage](#usage) + - [Package Usage](#package-usage) + - [GUI Usage](#gui-usage) + - [Client Usage](#client-usage) + - [General Usage](#general-usage) + - [Running Experiments or Tasks via Command Line](#running-experiments-or-tasks-via-command-line) + - [Options](#options) + - [Train a Signal Model via Command Line](#train-a-signal-model-via-command-line) + - [Basic Commands Signal Model Training](#basic-commands-signal-model-training) + - [Visualize ERP data from a session with Target / Non-Target labels via Command Line](#visualize-erp-data-from-a-session-with-target--non-target-labels-via-command-line) + - [Basic Commands ERP Viz](#basic-commands-erp-viz) + - [BciPy Simulator](#bcipy-simulator) + - [Running the Simulator](#running-the-simulator) + - [Basic Commands Simulator](#basic-commands-simulator) + - [Other Options](#other-options) + - [Core Modules](#core-modules) + - [Top-Level Modules Overview](#top-level-modules-overview) + - [`Acquisition`](#acquisition) + - [`Core`](#core) + - [`Display`](#display) + - [`Feedback`](#feedback) + - [`GUI`](#gui) + - [`Helpers`](#helpers) + - [`IO`](#io) + - [`Language`](#language) + - [`Signal`](#signal) + - [`Simulator`](#simulator) + - [`Task`](#task) + - [Entry Point and Configuration Modules](#entry-point-and-configuration-modules) + - [`main.py`](#mainpy) + - [`parameters/`](#parameters) + - [`config.py`](#configpy) + - [`static/`](#static) + - [Paradigms](#paradigms) + - [RSVPKeyboard](#rsvpkeyboard) + - [Matrix Speller](#matrix-speller) + - [Offset Determination and Correction](#offset-determination-and-correction) + - [What is a Static Offset?](#what-is-a-static-offset) + - [How to Determine the Offset](#how-to-determine-the-offset) + - [Running Offset Determination](#running-offset-determination) + - [Applying the Offset Correction](#applying-the-offset-correction) + - [Using Make for Offset Determination](#using-make-for-offset-determination) + - [Additional Resources](#additional-resources) + - [Glossary](#glossary) + - [Scientific Publications using BciPy](#scientific-publications-using-bcipy) + - [2025](#2025) + - [2024](#2024) + - [2023](#2023) + - [2022](#2022) + - [2021](#2021) + - [2020](#2020) + - [Contributions Welcome](#contributions-welcome) + - [Contribution Guidelines](#contribution-guidelines) + - [Contributors](#contributors) + ## Dependencies -This project requires Python 3.8 or 3.9. Please see notes below for additional OS specific dependencies before installation can be completed and reference our documentation/FAQs for more information: +This project requires Python 3.9, 3.10 or 3.11. + +It will run on the latest windows (10, 11), linux (ubuntu 22.04) and macos (Sonoma). Other versions may work as well, but are not guaranteed. To see supported versions and operating systems as of this release see our GitHub builds: [BciPy Builds](https://github.com/CAMBI-tech/BciPy/actions/workflows/main.yml). Please see notes below for additional OS specific dependencies before installation can be completed and reference our documentation here: ### Linux @@ -27,183 +99,285 @@ You will need to install the prerequisites defined in `scripts\shell\linux_requi If you are using a Windows machine, you will need to install the [Microsoft Visual C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/). - ### Mac -If you are using a Mac, you will need to install XCode and enable command line tools. `xcode-select --install`. If using an m1/2 chip, you will need to use the install script in `scripts/shell/m2chip_install.sh` to install the prerequisites. You may also need to use the Rosetta terminal to run the install script, but this has not been necessary in our testing using m2 chips. +If you are using a Mac, you will need to install XCode and enable command line tools. `xcode-select --install`. If using an m1/2 chip, you may need to use the install script in `scripts/shell/m2chip_install.sh` to install the prerequisites. You may also need to use the Rosetta terminal to run the install script, but this has not been necessary in our testing using m2 chips. -If using zsh, instead of bash, you may encounter a segementation fault when running BciPy. This is due to an issue in a dependeancy of psychopy with no known fix as of yet. Please use bash instead of zsh for now. +If using zsh, instead of bash, you may encounter a segmentation fault when running BciPy. This is due to an issue in a dependency of psychopy with no known fix as of yet. Please use bash instead of zsh for now. ## Installation ### BciPy Setup -In order to run BciPy on your computer, after following the dependencies above, you will need to install the BciPy package. +In order to run BciPy on your computer, after ensuring the OS dependencies above are met, you can proceed to install the BciPy package. + +#### Editable Install and GUI usage -To install for use locally and use of the GUI: +If wanting to run the GUI or make changes to the code, you will need to install BciPy in editable mode. This will ensure that all dependencies are installed and the package is linked to your local directory. This will allow you to make changes to the code and see them reflected in your local installation without needing to reinstall the package. 1. Git clone . 2. Change directory in your terminal to the repo directory. -3. [Optional] Install the kenlm language model package. `pip install kenlm==0.1 --global-option="--max_order=12"`. -4. Install BciPy in development mode. `pip install -e .` +3. Install BciPy in development mode. -If wanting the latest version from PyPi and to build using modules: + ```sh + pip install -e . + ``` + +#### PyPi Install + +If you do not want to run the GUI or make changes to the code, you can install BciPy from PyPi. This will install the package and all dependencies, but will not link it to your local directory. This means that any changes you make to the code will not be reflected in your local installation. This is the recommended installation method if wanting to use the modules without making changes to the BciPy code. + +```sh +pip install bcipy +``` -1. `pip install bcipy` +#### Make install Alternately, if [Make](http://www.mingw.org/) is installed, you may run the follow command to install: ```sh -# install in development mode +# install in development mode with all testing and demo dependencies make dev-install ``` +## Usage + +The BciPy package may be used in two ways: via the command line interface (CLI) or via the graphical user interface (GUI). The CLI is useful for running experiments, training models, and visualizing data without needing to run the GUI. The GUI is useful for running experiments, editing parameters and training models with a more user-friendly interface. + +### Package Usage + +To run the package, you will need to import the modules you want to use. For example, to run the the system info module, you can run the following: + +```python +from bcipy.helpers import system_utils +system_utils.get_system_info() +``` + +### GUI Usage + +Run the following command in your terminal to start the BciPy GUI: + +```sh +python bcipy/gui/BCInterface.py +``` + +Alternately, if Make is installed, you may run the follow command to start the GUI from the BciPy root directory: + +```sh +make bci-gui +``` + ### Client Usage -#### Run an experiment protocol or task +Once BciPy is installed, it can be used via the command line interface. This is useful for running experiments, training models, and visualizing data without needing to run the GUI. -Invoke an experiment protocol or task directly using command line utility `bcipy`. +#### General Usage -Use the help flag to see other available input options: `bcipy --help` +Use the help flag to explore all available options: -You can pass it attributes with flags, if desired. +```sh +bcipy --help +``` -- Run with a User ID and Task: - - `bcipy --user "bci_user" --task "RSVP Calibration"` -- Run with a User ID and Tasks with a registered Protocol: - - `bcipy --user "bci_user" --experiment "default"` -- Run with fake data: - - `bcipy --fake` -- Run without visualizations: - - `bcipy --noviz` -- Run with alerts after each Task execution: - - `bcipy --alert` -- Run with custom parameters: - - `bcipy --parameters "path/to/valid/parameters.json"` - -#### Train a signal model with registered BciPy models +#### Running Experiments or Tasks via Command Line -To train a signal model (currently `PCARDAKDE` and `GazeModels`), run the following command after installing BciPy: +You can invoke an experiment protocol or task directly using the `bcipy` command-line utility. This allows for flexible execution of tasks with various configurations. -`bcipy-train` +##### Options -Use the help flag to see other available input options: `bcipy-train --help` - -You can pass it attributes with flags, if desired. - -- Run without a window prompting for data session folder: - - `bcipy-train -d path/to/data` -- Run with data visualizations (ERPs, etc.): - - `bcipy-train -v` -- Run with data visualizations that do not show, but save to file: - - `bcipy-train -s` -- Run with balanced accuracy: - - `bcipy-train --balanced-acc` -- Run with alerts after each Task execution: - - `bcipy-train --alert` -- Run with custom parameters: - - `bcipy-train -p "path/to/valid/parameters.json"` +```sh +# Run with a User ID and Task +bcipy --user "bci_user" --task "RSVP Calibration" + +# Run with a User ID and Experiment Protocol +bcipy --user "bci_user" --experiment "default" + +# Run with Simulated Data +bcipy --fake + +# Run without Visualizations +bcipy --noviz + +# Run with Alerts after Task Execution +bcipy --alert + +# Run with Custom Parameters +bcipy --parameters "path/to/valid/parameters.json" +``` + +These options provide flexibility for running experiments tailored to your specific needs. -#### Visualize ERP data from a session with Target / Non-Target labels +#### Train a Signal Model via Command Line -To generate plots that can be shown or saved after collection of data, run the following command after installing BciPy: +To train a signal model (e.g., `PCARDAKDE` or `GazeModels`), use the `bcipy-train` command. -`bcipy-erp-viz` +##### Basic Commands Signal Model Training -Use the help flag to see other available input options: `bcipy-erp-viz --help` +```sh +# Display help information +bcipy-train --help -You can pass it attributes with flags, if desired. +# Train using data from a specific folder +bcipy-train -d path/to/data -- Run without a window prompt for a data session folder: - - `bcipy-erp-viz -s path/to/data` -- Run with data visualizations (ERPs, etc.): - - `bcipy-erp-viz --show` -- Run with data visualizations that do not show, but save to file: - - `bcipy-erp-viz --save` -- Run with custom parameters (default is in bcipy/parameters/parameters.json): - - `bcipy-erp-viz -p "path/to/valid/parameters.json"` +# Display data visualizations (e.g., ERPs) +bcipy-train -v -#### BciPy Simulator Usage +# Save visualizations to a file without displaying them +bcipy-train -s -The simulator can be run using the command line utility `bcipy-sim`. +# Train with balanced accuracy metrics +bcipy-train --balanced-acc -To run the simulator with a single data folder, a custom parameters file, a trained model, and 5 iterations, use the following command: +# Receive alerts after each task execution +bcipy-train --alert -`bcipy-sim -d my_data_folder/ -p my_parameters.json -m my_models/ -n 5` +# Use a custom parameters file +bcipy-train -p path/to/parameters.json +``` + +#### Visualize ERP data from a session with Target / Non-Target labels via Command Line -For more information or to see other available input options, use the help flag: `bcipy-sim --help`. In addition, more information can be found in the simulator module README. +To visualize ERP data from a session with Target / Non-Target labels, use the `bcipy-erp-viz` command. This command allows you to visualize the data collected during a session and provides options for saving or displaying the visualizations. -### Package Usage +##### Basic Commands ERP Viz -```python -from bcipy.helpers import system_utils -system_utils.get_system_info() +```sh +# Display help information +bcipy-erp-viz --help + +# Run without a window prompt for a data session folder +bcipy-erp-viz -s path/to/data + +# Run with data visualizations (ERPs, etc.) +bcipy-erp-viz --show + +# Run with data visualizations that do not show, but save to file +bcipy-erp-viz --save + +# Run with custom parameters (default is in bcipy/parameters/parameters.json) +bcipy-erp-viz -p "path/to/valid/parameters.json" ``` -### GUI Usage +### BciPy Simulator -Run the following command in your terminal to start the BciPy GUI: +The BciPy simulator allows you to run simulations based on previously collected data. This is useful for testing and validating models and algorithms without needing to collect new data. + +#### Running the Simulator + +The simulator can be executed using the `bcipy-sim` command-line utility. + +##### Basic Commands Simulator ```sh -python bcipy/gui/BCInterface.py +bcipy-sim --help ``` -Alternately, if Make is installed, you may run the follow command to start the GUI from the BciPy root directory: +##### Other Options + +- `-d`: Path to the data folder. +- `-p`: Path to the custom parameters file. [optional] +- `-m`: Path to the directory of trained model pickle files. +- `-n`: Number of iterations to run. ```sh -make bci-gui +bcipy-sim -d path/to/data -p path/to/parameters.json -m path/to/model.pkl/ -n 5 ``` -## Glossary +More comprehensive information can be found in the [Simulator Module README](./bcipy/simulator/README.md). -***Stimuli***: A single letter, tone or image shown (generally in an inquiry). Singular = stimulus, plural = stimuli. +## Core Modules -***Trial***: A collection of data after a stimuli is shown. A---- +### Top-Level Modules Overview -***Inquiry***: The set of stimuli after a fixation cross in a spelling task to gather user intent. A ---- B --- C ---- +Each module includes its own README, demo, and tests. Click on the module name to view its README for more information. -***Series***: Each series contains at least one inquiry. A letter/icon decision is made after a series in a spelling task. +#### [`Acquisition`](./bcipy/acquisition/README.md) -***Session***: Data collected for a task. Comprised of metadata about the task and a list of Series. +Captures data, returns desired time series, and saves to file at the end of a session. -***Protocol***: A collection of tasks and actions to be executed in a session. This is defined as within experiments and can be registered using the BciPy GUI. +#### [`Core`](./bcipy/core/README.md) -***Task***: An experimental design with stimuli, trials, inquiries and series for use in BCI. For instance, "RSVP Calibration" is a task. +Core data structures and methods essential for BciPy operation. -***Mode***: Common design elements between task types. For instance, Calibration and Free Spelling are modes. +- Includes triggers, parameters, and raw data handling. -***Paradigm***: Display paradigm with unique properties and modes. Ex. Rapid-Serial Visual Presentation (RSVP), Matrix Speller, Steady-State Visual Evoked Potential (SSVEP). +#### [`Display`](./bcipy/display/README.md) -## Core Modules +Manages the display of stimuli on the screen and records stimuli timing. + +#### [`Feedback`](./bcipy/feedback/README.md) + +Provides feedback mechanisms for sound and visual stimuli. + +#### [`GUI`](./bcipy/gui/README.md) + +End-user interface for registered BCI tasks and parameter editing. + +- Key files: [BCInterface.py](./bcipy/gui/BCInterface.py) and [ParamsForm](./bcipy/gui/parameters/params_form.py). + +#### [`Helpers`](./bcipy/helpers/README.md) + +Utility functions for interactions between modules and general-purpose tasks. + +#### [`IO`](./bcipy/io/README.md) + +Handles data file operations such as loading, saving, and format conversion. + +- Supported formats: BIDS, BrainVision, EDF, MNE, CSV, JSON, etc. + +#### [`Language`](./bcipy/language/README.md) + +Provides symbol probability predictions during typing tasks. + +#### [`Signal`](./bcipy/signal/README.md) + +Includes EEG signal models, gaze signal models, filters, processing tools, evaluators, and viewers. + +#### [`Simulator`](./bcipy/simulator/README.md) -This a list of the major modules and their functionality. Each module will contain its own README, demo and tests. Please check them out for more information! - -- `acquisition`: acquires data, gives back desired time series, saves to file at end of session. -- `config`: configuration parameters for the application, including paths and data filenames. -- `core`: core data structures and methods needed for BciPy operation. These include triggers, parameters, and raw data. -- `display`: handles display of stimuli on screen and passes back stimuli timing. -- `feedback`: feedback mechanisms for sound and visual stimuli. -- `gui`: end-user interface into registered bci tasks and parameter editing. See BCInterface.py. -- `helpers`: helpful functions needed for interactions between modules and general utility. -- `io`: load, save, and convert data files. Ex. BIDS, BrainVision, EDF, MNE, CSV, JSON, etc. -- `language`: gives probabilities of next symbols during typing. -- `main`: executor of experiments. Main entry point into the application -- `parameters`: location of json parameters. This includes parameters.json (main experiment / app configuration) and device.json (device registry and configuration). -- `signal`: eeg signal models, gaze signal models, filters, processing, evaluators and viewers. -- `simulator`: provides support for running simulations based off of previously collected data. -- `static`: image and sound stimuli, misc manuals, and readable texts for gui. -- `task`: bcipy implemented user tasks. Main collection of bci modules for use during various experimentation. Ex. RSVP Calibration. +Supports running simulations based on previously collected data. + +#### [`Task`](./bcipy/task/README.md) + +Implements user tasks and actions for BCI experiments. + +- Examples: RSVP Calibration, InterTaskAction. + +### Entry Point and Configuration Modules + +#### [`main.py`](./bcipy/main.py) + +The main executor of experiments and the primary entry point into the application. See the [Running Experiments](#running-experiments-or-tasks-via-command-line) section for more information. + +#### [`parameters/`](./bcipy/parameters/) + +Contains JSON configuration files: + +- [`parameters.json`](./bcipy/parameters/parameters.json): Main experiment and application configuration. +- [`device.json`](./bcipy/parameters/device.json): Device registry and configuration. +- [`experiments.json`](./bcipy/parameters/experiment/experiments.json): Experiment / protocol registry and configuration. +- [`phrases.json`](./bcipy/parameters/experiment/phrases.json): Phrase registry and configuration. This can be used to define a list of phrases used in the RSVP and Matrix Speller Copy phrase tasks. If not defined in parameters.json, the `task_text` parameter will be used. + +#### [`config.py`](./bcipy/config.py) + +Holds configuration parameters for BciPy, including paths and default data filenames. + +#### [`static/`](./bcipy/static/) + +Includes resources such as: + +- Image and sound stimuli. +- Miscellaneous manuals and readable texts for the GUI. ## Paradigms -See `bcipy/task/README.md` for more information on all supported paradigms, tasks, actions and modes. The following are the supported and validated paradigms: +See the [Task README](./bcipy/task/README.md) for more information on all supported paradigms, tasks, actions and modes. The major paradigms are listed below. ### RSVPKeyboard *RSVP KeyboardTM* is an EEG (electroencephalography) based BCI (brain computer interface) typing system. It utilizes a visual presentation technique called rapid serial visual presentation (RSVP). In RSVP, the options are presented rapidly at a single location with a temporal separation. Similarly in RSVP KeyboardTM, the symbols (the letters and additional symbols) are shown at the center of screen. When the subject wants to select a symbol, they await the intended symbol during the presentation and elicit a p300 response to a target symbol. -References: - ```text Orhan, U., Hild, K. E., 2nd, Erdogmus, D., Roark, B., Oken, B., & Fried-Oken, M. (2012). RSVP Keyboard: An EEG Based Typing Interface. Proceedings of the ... IEEE International Conference on Acoustics, Speech, and Signal Processing. ICASSP (Conference), 10.1109/ICASSP.2012.6287966. https://doi.org/10.1109/ICASSP.2012.6287966 ``` @@ -212,152 +386,121 @@ Orhan, U., Hild, K. E., 2nd, Erdogmus, D., Roark, B., Oken, B., & Fried-Oken, M. Matrix Speller is an EEG (electroencephalography) based BCI (brain computer interface) typing system. It utilizes a visual presentation technique called Single Character Presentation (SCP). In matrix speller, the symbols are arranged in a matrix with fixed number of rows and columns. Using SCP, subsets of these symbols are intensified (i.e. highlighted) usually in pseudorandom order to produce an odd ball paradigm to induce p300 responses. -References: - ```text Farwell, L. A., & Donchin, E. (1988). Talking off the top of your head: toward a mental prosthesis utilizing event-related brain potentials. Electroencephalography and clinical Neurophysiology, 70(6), 510-523. Ahani A, Moghadamfalahi M, Erdogmus D. Language-Model Assisted And Icon-based Communication Through a Brain Computer Interface With Different Presentation Paradigms. IEEE Trans Neural Syst Rehabil Eng. 2018 Jul 25. doi: 10.1109/TNSRE.2018.2859432. ``` -## Demo - -All major functions and modules have demo and test files associated with them which may be run locally. This should help orient you to the functionality as well as serve as documentation. *If you add to the repo, you should be adding tests and fixing any test that fail when you change the code.* - -For example, you may run the main BciPy demo by: - -`python demo/bci_main_demo.py` +## Offset Determination and Correction -This demo will load in parameters and execute a demo task defined in the file. There are demo files contained in most modules, excepting gui, signal and parameters. Run them as a python script! +> [!CAUTION] +> Static offset determination and correction are critical steps before starting an experiment. BciPy uses LSL to acquire EEG data and Psychopy to present stimuli. The synchronization between the two systems is crucial for accurate data collection and analysis. -## Offset Determination and Correction +### What is a Static Offset? -Static offset determination and correction are critical steps before starting an experiment. BciPy uses LSL to acquire EEG data and Psychopy to present stimuli. The synchronization between the two systems is crucial for accurate data collection and analysis. +A static offset is the regular time difference between signals and stimuli presentation. This offset is determined through testing using a photodiode or another triggering mechanism. Once determined, the offset is corrected by shifting the EEG signal using the `static_offset` parameter in devices.json. -[LSL synchronization documentation](https://labstreaminglayer.readthedocs.io/info/time_synchronization.html) -[PsychoPy timing documentation](https://www.psychopy.org/general/timing/index.html) +#### How to Determine the Offset -A static offset is the regular time difference between our signals and stimuli. This offset is determined through testing via a photodiode or other triggering mechanism. The offset correction is done by shifting the EEG signal by the determined offset using the `static_offset` parameter. +To determine the static offset, you can run a timing verification task (e.g., `RSVPTimingVerification`) with a photodiode attached to the display and connected to your device. After collecting the data, use the `offset` module to analyze the results and recommend an offset correction value. -After running a timing verification task (such as, RSVPTimingVerification) with a photodiode attached to the display and connected to a device, the offset can be determined by analyzing the data. Use the `offset` module to recommend an offset correction value and display the results. +#### Running Offset Determination -To run the offset determination and print the results, use the following command: +To calculate the offset and display the results, use the following command: ```bash python bcipy/helpers/offset.py -r ``` -After running the above command, the recommended offset correction value will be displayed in the terminal and can be passed to determine system stability and display the results. +This will analyze the data and recommend an offset correction value, which will be displayed in the terminal. + +#### Applying the Offset Correction + +Once you have the recommended offset value, you can apply it to verify system stability and display the results. For example, if the recommended offset value is `0.1`, run the following command: ```bash -# Let's say the recommneded offset value is 0.1 python bcipy/helpers/offset.py --offset "0.1" -p ``` -Alternately, if Make is installed, you may run the follow command to run offset determination and display the results: +#### Using Make for Offset Determination + +If `Make` is installed, you can simplify the process by running the following command to determine the offset and display the results: ```sh make offset-recommend ``` -## Testing - -When writing tests, put them in the correct module, in a tests folder, and prefix the file and test itself with `test_` in order for pytest to discover it. See other module tests for examples! - -Development requirements must be installed before running: `pip install -r dev_requirements.txt` - -To run all tests, in the command line: +#### Additional Resources -```python -py.test -``` +For more information on synchronization and timing, refer to the following documentation: -To run a single modules tests (ex. acquisition), in the command line: +- [LSL Synchronization Documentation](https://labstreaminglayer.readthedocs.io/info/time_synchronization.html) +- [PsychoPy Timing Documentation](https://www.psychopy.org/general/timing/index.html) -```python -py.test acquisition -``` +## Glossary -To generate test coverage metrics, in the command line: +***Stimuli***: A single letter, tone or image shown (generally in an inquiry). Singular = stimulus, plural = stimuli. -```bash -coverage run --branch --source=bcipy -m pytest --mpl -k "not slow" +***Trial***: A collection of data after a stimuli is shown. A---- -#Generate a command line report -coverage report +***Inquiry***: The set of stimuli after a fixation cross in a spelling task to gather user intent. A ---- B --- C ---- -# Generate html doc in the bci folder. Navigate to index.html and click. -coverage html -``` +***Series***: Each series contains at least one inquiry. A letter/icon decision is made after a series in a spelling task. -Alternately, if Make is installed, you may run the follow command to run coverage/pytest and generate the html: +***Session***: Data collected for a task. Comprised of metadata about the task and a list of Series. -```sh -make coverage-html -``` +***Protocol***: A collection of tasks and actions to be executed in a session. This is defined for each experiment and can be registered in experiments.json via the BCI GUI. -## Linting +***Experiment***: A protocol with a set of parameters. This is defined within experiments and can be registered in experiments.json via the BCI GUI. -This project enforces `PEP` style guidelines using [flake8](http://flake8.pycqa.org/en/latest/). +***Task***: An experimental design with stimuli, trials, inquiries and series for use in BCI. For instance, "RSVP Calibration" is a task. -To avoid spending unnecessary time on formatting, we recommend using `autopep8`. You can specify a file or directory to auto format. When ready to push your code, you may run the following commands to format your code: +***Action***: A task without a paradigm. For instance, "RSVP Calibration" is a task, but "InterTaskAction" is an action. These are most often used to define the actions that take place in between tasks. -```sh -# autoformat all files in bcipy -autopep8 --in-place --aggressive -r bcipy +***Mode***: Common design elements between task types. For instance, Calibration and Free Spelling are modes. -# autoformat only the processor file -autopep8 --in-place --aggressive bcipy/acquisition/processor.py -``` +***Paradigm***: Display paradigm with unique properties and modes. Ex. Rapid-Serial Visual Presentation (RSVP), Matrix Speller, Steady-State Visual Evoked Potential (SSVEP). -Finally, run the lint check: `flake8 bcipy`. +## Scientific Publications using BciPy -Alternately, if Make is installed, you may run the follow command to run autopep8 and flake8: +### 2025 -```sh -make lint -``` +- Memmott, T., Klee, D., Smedemark-Margulies, N., & Oken, B. (2025). Artifact filtering application to increase online parity in a communication BCI: progress toward use in daily-life. Frontiers in Human Neuroscience, 19, 1551214. +- Peters, B., Celik, B., Gaines, D., Galvin-McLaughlin, D., Imbiriba, T., Kinsella, M., ... & Fried-Oken, M. (2025). RSVP keyboard with inquiry preview: mixed performance and user experience with an adaptive, multimodal typing interface combining EEG and switch input. Journal of neural engineering, 22(1), 016022. -## Type Checking +### 2024 -This project enforces `mypy` type checking. The typing project configuration is found in the mypy.ini file. To run type checking, run the following command: +- Klee, D., Memmott, T., & Oken, B. (2024). The Effect of Jittered Stimulus Onset Interval on Electrophysiological Markers of Attention in a Brain–Computer Interface Rapid Serial Visual Presentation Paradigm. Signals, 5(1), 18-39. +- Kocanaogullari, D. (2024). Detection and Assessment of Spatial Neglect Using a Novel Augmented Reality-Guided Eeg-Based Brain-Computer Interface (Doctoral dissertation, University of Pittsburgh). +- Smedemark-Margulies, N. (2024). Reducing Calibration Effort for Brain-Computer Interfaces (Doctoral dissertation, Northeastern University). -```sh -mypy bcipy -``` +### 2023 -To generate a report, run the following command: +- Smedemark-Margulies, N., Celik, B., Imbiriba, T., Kocanaogullari, A., & Erdoğmuş, D. (2023, June). Recursive estimation of user intent from noninvasive electroencephalography using discriminative models. In ICASSP 2023-2023 IEEE International Conference on Acoustics, Speech and Signal Processing (ICASSP) (pp. 1-5). IEEE. -```sh -mypy --html-report bcipy -``` +### 2022 -Alternately, if Make is installed, you may run the follow command to run mypy: +- Mak, J., Kocanaogullari, D., Huang, X., Kersey, J., Shih, M., Grattan, E. S., ... & Akcakaya, M. (2022). Detection of stroke-induced visual neglect and target response prediction using augmented reality and electroencephalography. IEEE Transactions on Neural Systems and Rehabilitation Engineering, 30, 1840-1850. +- Galvin-McLaughlin, D., Klee, D., Memmott, T., Peters, B., Wiedrick, J., Fried-Oken, M., ... & Dudy, S. (2022). Methodology and preliminary data on feasibility of a neurofeedback protocol to improve visual attention to letters in mild Alzheimer's disease. Contemporary Clinical Trials Communications, 28, 100950. +- Klee, D., Memmott, T., Smedemark-Margulies, N., Celik, B., Erdogmus, D., & Oken, B. S. (2022). Target-related alpha attenuation in a brain-computer interface rapid serial visual presentation calibration. Frontiers in Human Neuroscience, 16, 882557. -```sh -make type -``` +### 2021 -### Contributions Welcome +- Koçanaoğulları, A., Akcakaya, M., & Erdoğmuş, D. (2021). Stopping criterion design for recursive Bayesian classification: analysis and decision geometry. IEEE Transactions on Pattern Analysis and Machine Intelligence, 44(9), 5590-5601. -If you want to be added to the development team slack or have additional questions, please reach out to us at ! +### 2020 -#### Contribution Guidelines +- Koçanaogullari, A. (2020). Active Recursive Bayesian Classification (Querying and Stopping) for Event Related Potential Driven Brain Computer Interface Systems (Doctoral dissertation, Northeastern University). +- Koçanaoğulları, A., Akçakaya, M., Oken, B., & Erdoğmuş, D. (2020, June). Optimal modality selection using information transfer rate for event related potential driven brain computer interfaces. In Proceedings of the 13th ACM International Conference on PErvasive Technologies Related to Assistive Environments (pp. 1-7). -We follow and will enforce the contributor's covenant to foster a safe and inclusive environment for this open source software, please reference this link for more information: +## Contributions Welcome -We welcome all contributions to BciPy! Please follow the guidelines below: +If you want to be added to the development team Discord or have additional questions, please reach out to us at ! -- All modules require tests and a demo. -- All tests must pass to merge, even if they are seemingly unrelated to your work. -- Use Spaces, not Tabs. -- Use informative names for functions and classes. -- Document the input and output of your functions / classes in the code. eg in-line commenting and typing. -- Do not push IDE or other local configuration files. -- All new modules or major functionality should be documented outside of the code with a README.md. --- See README.md in repo or go to this site for inspiration: . Always use a Markdown interpreter before pushing. +### Contribution Guidelines -See this resource for examples: +We follow and will enforce the code of conduct outlined [here](CODE_OF_CONDUCT.md). Please read it before contributing. ### Contributors diff --git a/bcipy/acquisition/datastream/generator.py b/bcipy/acquisition/datastream/generator.py index cb2dfe04c..79c31ebd3 100644 --- a/bcipy/acquisition/datastream/generator.py +++ b/bcipy/acquisition/datastream/generator.py @@ -1,6 +1,6 @@ """Functions for generating mock data to be used for testing/development.""" -from typing import Callable, Generator, Optional +from typing import Any, Callable, Generator, List, Optional, TextIO from past.builtins import range @@ -8,8 +8,13 @@ from bcipy.signal.generator.generator import gen_random_data -def advance_to_row(filehandle, rownum): - """Utility function to advance a file cursor to the given row.""" +def advance_to_row(filehandle: TextIO, rownum: int) -> None: + """Utility function to advance a file cursor to the given row. + + Args: + filehandle (TextIO): The file handle to advance. + rownum (int): The target row number to advance to (1-indexed). + """ for _ in range(rownum - 1): filehandle.readline() @@ -21,39 +26,52 @@ class _DefaultEncoder: """Encodes data by returning the raw data.""" # pylint: disable=no-self-use - def encode(self, data): - """Encode the data that will be output by the file_data generator.""" + def encode(self, data: Any) -> Any: + """Encodes the input data. + + Args: + data (Any): The data to be encoded. + + Returns: + Any: The raw input data, as no actual encoding is performed. + """ return data -def generator_with_args(generator_fn, **generator_args) -> Callable[[], Generator]: +def generator_with_args(generator_fn: Callable[..., Generator], **generator_args: Any) -> Callable[..., Generator]: """Constructs a generator with the given arguments. - Parameters - ---------- - generator_fn : Function - a generator function - - Returns - ------- - Function which creates a generator using the given args. + + Args: + generator_fn (Callable[..., Generator]): A generator function. + **generator_args (Any): Keyword arguments to be passed to the generator_fn. + + Returns: + Callable[..., Generator]: A function that creates a generator using the given args. """ - def factory(**args): + def factory(**args: Any) -> Generator: return generator_fn(**{**generator_args, **args}) return factory -def random_data_generator(encoder=_DefaultEncoder(), - low=-1000, - high=1000, - channel_count=25): +def random_data_generator(encoder: _DefaultEncoder = _DefaultEncoder(), + low: int = -1000, + high: int = 1000, + channel_count: int = 25) -> Generator[Any, None, None]: """Generator that outputs random EEG-like data encoded according to the provided encoder. - Returns - ------- - A generator that produces packet of data, which decodes into a list of - floats in the range low to high with channel_count number of items. + Args: + encoder (_DefaultEncoder, optional): An encoder object to encode the output data. + Defaults to _DefaultEncoder(). + low (int, optional): The lower bound for random data generation. Defaults to -1000. + high (int, optional): The upper bound for random data generation. Defaults to 1000. + channel_count (int, optional): The number of channels (items) in the generated data. + Defaults to 25. + + Yields: + Any: A packet of data, which decodes into a list of floats in the range + low to high with `channel_count` number of items, encoded by the encoder. """ while True: @@ -61,23 +79,29 @@ def random_data_generator(encoder=_DefaultEncoder(), yield encoder.encode(sensor_data) -def file_data_generator(filename, header_row=3, encoder=_DefaultEncoder(), channel_count: Optional[int] = None): +def file_data_generator(filename: str, + header_row: int = 3, + encoder: _DefaultEncoder = _DefaultEncoder(), + channel_count: Optional[int] = None) -> Generator[List[float], None, None]: """Generates data from a source file and encodes it according to the provided encoder. - Parameters - ---------- - filename: str - Name of file containing a sample EEG session output. This file will - be the source of the generated data. File should be a csv file. - header_row: int, optional - Row with the header data (channel names); the default is 3, - assuming the first 2 rows are for metadata. - encoder : Encoder, optional - Used to encode the output data. - channel_count : int, optional - If provided this is used to truncate the data to the given number - of channels. + Args: + filename (str): Name of file containing a sample EEG session output. + This file will be the source of the generated data. + File should be a csv file. + header_row (int, optional): Row with the header data (channel names). + The default is 3, assuming the first 2 rows + are for metadata. + encoder (_DefaultEncoder, optional): Used to encode the output data. + Defaults to _DefaultEncoder(). + channel_count (Optional[int], optional): If provided this is used to truncate + the data to the given number of channels. + Defaults to None. + + Yields: + List[float]: A list of float values representing sensor data from the file, + encoded by the encoder. """ with open(filename, 'r', encoding=DEFAULT_ENCODING) as infile: @@ -95,8 +119,17 @@ def file_data_generator(filename, header_row=3, encoder=_DefaultEncoder(), chann def data_value(value: str) -> float: - """Convert to a float; some trigger values are strings, rather than - numbers (ex. indicating the letter); convert these to 1.0.""" + """Converts a string value to a float. + + Some trigger values might be strings (e.g., indicating a letter), + which are converted to 1.0. Empty strings are converted to 0.0. + + Args: + value (str): The string value to convert. + + Returns: + float: The converted float value. + """ if value: try: return float(value) diff --git a/bcipy/acquisition/datastream/lsl_server.py b/bcipy/acquisition/datastream/lsl_server.py index cee201f9f..71f6f5bee 100644 --- a/bcipy/acquisition/datastream/lsl_server.py +++ b/bcipy/acquisition/datastream/lsl_server.py @@ -1,11 +1,11 @@ # mypy: disable-error-code="misc" -"""Data server that streams EEG data over a LabStreamingLayer StreamOutlet -using pylsl.""" +"""Data server that streams EEG data over a LabStreamingLayer StreamOutlet using pylsl.""" + import logging import time import uuid from queue import Empty, Queue -from typing import Generator, Optional +from typing import Generator, List, Optional, Tuple from pylsl import StreamInfo, StreamOutlet @@ -29,22 +29,21 @@ class LslDataServer(StoppableThread): fake_data can be set to false and this module can be run standalone in its own python instance. - Parameters - ---------- - device_spec : DeviceSpec - parameters used to configure the server. Should have at least - a list of channels and the sample frequency. - generator : optional Generator (see generator.py for options) - used to generate the data to be served. Uses random_data_generator - by default. - include_meta: bool, optional - if True, writes metadata to the outlet stream. - add_markers: bool, optional - if True, creates a the marker channel and streams data to this - channel at a fixed frequency. - marker_stream_name: str, optional - name of the sample marker stream - chunk_size: int, optional chunk size. + Args: + device_spec (DeviceSpec): Parameters used to configure the server. + Should have at least a list of channels and the + sample frequency. + generator (Optional[Generator], optional): Used to generate the data to be + served. Uses `random_data_generator` + by default. Defaults to None. + include_meta (bool, optional): If True, writes metadata to the outlet stream. + Defaults to True. + add_markers (bool, optional): If True, creates a the marker channel and + streams data to this channel at a fixed + frequency. Defaults to False. + marker_stream_name (str, optional): Name of the sample marker stream. + Defaults to `MARKER_STREAM_NAME`. + chunk_size (int, optional): Chunk size for the LSL stream. Defaults to 0. """ def __init__(self, @@ -57,7 +56,8 @@ def __init__(self, super(LslDataServer, self).__init__() self.device_spec = device_spec - self.generator = generator or random_data_generator(channel_count=device_spec.channel_count) + self.generator = generator or random_data_generator( + channel_count=device_spec.channel_count) log.debug("Starting LSL server for device: %s", device_spec.name) print(f"Serving: {device_spec}") @@ -96,8 +96,12 @@ def __init__(self, self.markers_outlet = StreamOutlet(markers_info) self.started = False - def stop(self): - """Stop the thread and cleanup resources.""" + def stop(self) -> None: + """Stops the thread and cleans up resources. + + This method sets the internal flag to stop the thread's execution + and closes the LSL `StreamOutlet`s, making them no longer discoverable. + """ log.debug("[*] Stopping data server") super(LslDataServer, self).stop() @@ -111,10 +115,12 @@ def stop(self): del self.markers_outlet self.markers_outlet = None - def run(self): - """Main loop of the thread. Continuously streams data to the stream - outlet at a rate consistent with the sample frequency. May also - output markers at a different interval.""" + def run(self) -> None: + """Main loop of the thread. + + Continuously streams data to the stream outlet at a rate consistent + with the sample frequency. May also output markers at a different interval. + """ sample_counter = 0 self.started = True @@ -138,8 +144,16 @@ def run(self): log.debug("[*] No longer pushing data") -def _settings(filename): - """Read the daq settings from the given data file""" +def _settings(filename: str) -> Tuple[str, int, List[str]]: + """Reads the DAQ settings from the given data file. + + Args: + filename (str): The path to the data file containing DAQ settings. + + Returns: + Tuple[str, int, List[str]]: A tuple containing the DAQ type (str), + sample rate (int), and a list of channel names (List[str]). + """ with open(filename, 'r', encoding=DEFAULT_ENCODING) as datafile: daq_type = datafile.readline().strip().split(',')[1] @@ -148,15 +162,20 @@ def _settings(filename): return (daq_type, sample_hz, channels) -def await_start(dataserver: LslDataServer, max_wait: float = 2.0): - """Blocks until server is started. Raises if max_wait is exceeded before - server is started. +def await_start(dataserver: LslDataServer, max_wait: float = 2.0) -> None: + """Blocks until the LSL data server is started. + + Raises an exception if `max_wait` is exceeded before the server starts. + + Args: + dataserver (LslDataServer): An instantiated (unstarted) server on which to wait. + max_wait (float, optional): The maximum number of seconds to wait. + Defaults to 2.0. After this period, if the + server has not successfully started, an + exception is thrown. - Parameters - ---------- - dataserver - instantiated (unstarted) server on which to wait. - max_wait - the max number of seconds to wait. After this period if the - server has not succesfully started an exception is thrown. + Raises: + Exception: If the server cannot start up within the `max_wait` period. """ dataserver.start() @@ -170,8 +189,14 @@ def await_start(dataserver: LslDataServer, max_wait: float = 2.0): raise Exception("Server couldn't start up in time.") -def main(): - """Initialize and start the server.""" +def main() -> None: + """Initializes and starts the LSL data server. + + This function parses command-line arguments to configure the server + (e.g., source filename, marker inclusion, device name, chunk size). + It then creates and starts an `LslDataServer` instance, running until + a `KeyboardInterrupt` is received. + """ import argparse from bcipy.acquisition.datastream.generator import file_data_generator @@ -182,8 +207,10 @@ def main(): help="file containing data to be streamed; " "if missing, random data will be served.") parser.add_argument('-m', '--markers', action="store_true", default=False) - parser.add_argument('-n', '--name', default='DSI-24', help='Name of the device spec to mock.') - parser.add_argument('-c', '--chunk_size', default=0, type=int, help='Chunk size') + parser.add_argument('-n', '--name', default='DSI-24', + help='Name of the device spec to mock.') + parser.add_argument('-c', '--chunk_size', default=0, + type=int, help='Chunk size') args = parser.parse_args() if args.filename: diff --git a/bcipy/acquisition/datastream/mock/eye_tracker_server.py b/bcipy/acquisition/datastream/mock/eye_tracker_server.py index 9cc3c3e73..948d5f6c5 100644 --- a/bcipy/acquisition/datastream/mock/eye_tracker_server.py +++ b/bcipy/acquisition/datastream/mock/eye_tracker_server.py @@ -3,6 +3,7 @@ import logging import math import time +from typing import Generator, List from numpy.random import uniform @@ -14,7 +15,11 @@ def eye_tracker_device() -> DeviceSpec: - """Mock DeviceSpec for an eye tracker.""" + """Mock DeviceSpec for an eye tracker. + + Returns: + DeviceSpec: A DeviceSpec object configured for an eye tracker. + """ return DeviceSpec(name='EyeTracker', channels=[ 'leftEyeX', 'leftEyeY', 'rightEyeX', 'rightEyeY', @@ -25,15 +30,24 @@ def eye_tracker_device() -> DeviceSpec: content_type='Gaze') -def eye_tracker_data_generator(display_x=1920, display_y=1080): +def eye_tracker_data_generator(display_x: int = 1920, display_y: int = 1080) -> Generator[List[float], None, None]: """Generates sample eye tracker data. TODO: determine appropriate values for pixelsPerDegree fields. TODO: look info alternatives; maybe PyGaze. http://www.pygaze.org/about/ + + Args: + display_x (int): The width of the display in pixels. Defaults to 1920. + display_y (int): The height of the display in pixels. Defaults to 1080. + + Yields: + List[float]: A list of float values representing eye tracker data, including + left eye X/Y, right eye X/Y, left pupil area, right pupil area, + pixels per degree X, and pixels per degree Y. """ - def area(diameter): + def area(diameter: float) -> float: return math.pi * (diameter / 2.0)**2 while True: @@ -50,14 +64,23 @@ def area(diameter): def eye_tracker_server() -> LslDataServer: - """Create a demo lsl_server that serves eye tracking data.""" + """Create a demo lsl_server that serves eye tracking data. + + Returns: + LslDataServer: An LslDataServer instance configured for eye tracking data. + """ return LslDataServer(device_spec=eye_tracker_device(), generator=eye_tracker_data_generator()) def main(): - """Create an run an lsl_server""" + """Create and run an lsl_server. + + This function initializes and starts an LSL data server for eye tracking. + It runs indefinitely until a KeyboardInterrupt is received, at which point + the server is stopped. + """ try: server = eye_tracker_server() log.info("New server created") diff --git a/bcipy/acquisition/datastream/mock/switch.py b/bcipy/acquisition/datastream/mock/switch.py index d01d9d11f..94b4ffda3 100644 --- a/bcipy/acquisition/datastream/mock/switch.py +++ b/bcipy/acquisition/datastream/mock/switch.py @@ -13,7 +13,11 @@ def switch_device() -> DeviceSpec: - """Mock DeviceSpec for a switch""" + """Mock DeviceSpec for a switch. + + Returns: + DeviceSpec: A DeviceSpec object configured for a switch. + """ device = preconfigured_device('Switch', strict=False) if device: return device @@ -27,21 +31,27 @@ class Switch: """Mock switch which streams data over LSL at an irregular interval.""" def __init__(self): + """Initializes the Switch with a DeviceSpec and LSL StreamOutlet.""" super().__init__() - self.device = switch_device() - self.lsl_id = 'bci_demo_switch' + self.device: DeviceSpec = switch_device() + self.lsl_id: str = 'bci_demo_switch' info = StreamInfo(self.device.name, self.device.content_type, self.device.channel_count, self.device.sample_rate, self.device.data_type, self.lsl_id) - self.outlet = StreamOutlet(info) + self.outlet: StreamOutlet = StreamOutlet(info) + + def click(self, _position: list) -> None: + """Pushes a sample to the LSL stream when a click event occurs. - def click(self, _position): - """Click event that pushes a sample""" + Args: + _position (list): The position of the click event. This parameter is + currently unused. + """ log.debug("Click!") self.outlet.push_sample([1.0]) - def quit(self): - """Quit and cleanup""" + def quit(self) -> None: + """Cleans up and releases the LSL StreamOutlet.""" del self.outlet self.outlet = None @@ -49,13 +59,19 @@ def quit(self): class SwitchGui(BCIGui): # pragma: no cover """GUI to emulate a switch.""" - def __init__(self, switch: Switch, *args, **kwargs): + def __init__(self, switch: 'Switch', *args, **kwargs): + """Initializes the SwitchGui with a Switch object. + + Args: + switch (Switch): The Switch object to control. + *args: Variable length argument list for the base class. + **kwargs: Arbitrary keyword arguments for the base class. + """ super().__init__(*args, **kwargs) - self.switch = switch + self.switch: Switch = switch def build_buttons(self) -> None: - """Build all buttons necessary for the UI. - """ + """Build all buttons necessary for the UI.""" self.add_button(message='Click!', position=[100, 75], size=[50, 50], @@ -75,9 +91,15 @@ def build_text(self) -> None: font_size=16) -def main(switch: Switch): # pragma: no cover - """Creates a PyQt5 GUI with a single button in the middle. Performs the - switch action when clicked.""" +def main(switch: Switch) -> None: # pragma: no cover + """Creates a PyQt6 GUI with a single button in the middle and runs it. + + Performs the switch action when the button is clicked and cleans up + the switch resources upon exit. + + Args: + switch (Switch): The Switch object to associate with the GUI. + """ gui = app(sys.argv) ex = SwitchGui(switch=switch, title='Demo Switch!', diff --git a/bcipy/acquisition/datastream/producer.py b/bcipy/acquisition/datastream/producer.py index ffdc310c9..82e698f66 100644 --- a/bcipy/acquisition/datastream/producer.py +++ b/bcipy/acquisition/datastream/producer.py @@ -1,11 +1,12 @@ -"""Code for mocking an EEG data stream. Code in this module produces data -at a specified frequency.""" +"""Code for mocking an EEG data stream. Code in this module produces data at a specified frequency.""" + import logging import random import threading import time from builtins import next from queue import Queue +from typing import Any, Iterator, Optional from bcipy.acquisition.datastream.generator import random_data_generator from bcipy.config import SESSION_LOG_FILENAME @@ -16,76 +17,106 @@ class Producer(threading.Thread): """Produces generated data at a specified frequency. - Parameters - ---------- - queue : Queue - Generated data will be written to the queue. - freq : float, optional - Data will be generated at the given frequency. - generator : object, optional - python generator for creating data. - maxiters : int, optional - if provided, stops generating data after the given number of iters. + This class extends `threading.Thread` to run data generation in a separate + thread, pushing generated samples into a queue at a specified frequency. + It can also act as a context manager to automatically start and stop the + thread. + + Args: + queue (Queue): The queue where generated data will be written. + freq (float, optional): The frequency (in Hz) at which data will be generated. + Defaults to 1/100 (0.01 Hz). + generator (Optional[Any], optional): A Python generator for creating data. + Defaults to `random_data_generator()`. + maxiters (Optional[int], optional): If provided, stops generating data + after this many iterations. Defaults to None. """ def __init__(self, - queue, - freq=1 / 100, - generator=None, - maxiters=None): + queue: Queue, + freq: float = 1 / 100, + generator: Optional[Any] = None, + maxiters: Optional[int] = None): super(Producer, self).__init__() - self.daemon = True - self._running = True + self.daemon: bool = True + self._running: bool = True + + self.freq: float = freq + self.generator: Any = generator or random_data_generator() + self.maxiters: Optional[int] = maxiters + self.queue: Queue = queue + + def __enter__(self) -> 'Producer': + """Enters the runtime context related to this object. - self.freq = freq - self.generator = generator or random_data_generator() - self.maxiters = maxiters - self.queue = queue + Starts the producer thread when entering the context. - # @override to make this class a context manager - def __enter__(self): + Returns: + Producer: The Producer instance. + """ self.start() return self - # @override to make this class a context manager - def __exit__(self, _exc_type, _exc_value, _traceback): + def __exit__(self, _exc_type: Any, _exc_value: Any, _traceback: Any) -> None: + """Exits the runtime context related to this object. + + Stops the producer thread when exiting the context. + + Args: + _exc_type (Any): The exception type, if an exception was raised. + _exc_value (Any): The exception value, if an exception was raised. + _traceback (Any): The traceback, if an exception was raised. + """ self.stop() - def _genitem(self): - """Generates the data item to be added to the queue.""" + def _genitem(self) -> Any: + """Generates the data item to be added to the queue. + Returns: + Any: The next data item from the configured generator. + """ return next(self.generator) - def _additem(self): - """Adds the data item to the queue.""" + def _additem(self) -> None: + """Adds the data item to the queue. + This method fetches an item using `_genitem` and places it into the internal queue. + """ self.queue.put(self._genitem()) - def stop(self): - """Stop the thread; stopped threads cannot be restarted.""" + def stop(self) -> None: + """Stops the thread. + + Sets an internal flag to stop the thread's execution and waits for the thread to finish. + Stopped threads cannot be restarted. + """ self._running = False self.join() - def run(self): + def run(self) -> None: """Provides a control loop, adding a data item to the queue at the configured frequency. - @overrides the Thread run method + This method overrides the `threading.Thread.run` method. """ - def tick(): + def tick() -> Iterator[float]: """Corrects the time interval if the time of the work to add the - item causes drift.""" - current_time = time.time() - count = 0 + item causes drift. + + Yields: + float: The calculated sleep duration to maintain the target frequency. + """ + current_time: float = time.time() + count: int = 0 while True: count += 1 yield max(current_time + count * self.freq - time.time(), 0) - sleep_len = tick() - times = 0 + sleep_len: Iterator[float] = tick() + times: int = 0 while self._running and (self.maxiters is None or times < self.maxiters): times += 1 @@ -94,25 +125,39 @@ def tick(): class _ConsumerThread(threading.Thread): - """Consumer used to test the Producer by consuming generated items.""" + """Consumer used to test the Producer by consuming generated items. + + Args: + queue (Queue): The queue from which to consume items. + name (Optional[str], optional): The name of the consumer thread. + Defaults to None. + """ - def __init__(self, queue, name=None): - super(_ConsumerThread, self).__init__() - self.daemon = True - self.name = name - self._q = queue + def __init__(self, queue: Queue, name: Optional[str] = None): + super(_ConsumerThread, self).__init__(name=name) + self.daemon: bool = True + self._q: Queue = queue - def run(self): + def run(self) -> None: + """Main loop for the consumer thread. + + Continuously checks the queue for items and processes them, logging + the item and the queue size. + """ while True: if not self._q.empty(): - item = self._q.get() + item: Any = self._q.get() log.info('Getting %s: %s items in queue', str(item), str(self._q.qsize())) time.sleep(random.random()) -def main(): - """Main method""" +def main() -> None: + """Main method to demonstrate the Producer and Consumer threads. + + Initializes a Producer and a Consumer thread, starts them, and lets them run + for a short period (5 seconds) before the program exits. + """ data_queue: Queue = Queue() producer: Producer = Producer(data_queue) consumer: _ConsumerThread = _ConsumerThread(data_queue) diff --git a/bcipy/acquisition/devices.py b/bcipy/acquisition/devices.py index 77308a5a1..bf39917bf 100644 --- a/bcipy/acquisition/devices.py +++ b/bcipy/acquisition/devices.py @@ -1,10 +1,10 @@ -"""Functionality for loading and querying configuration for supported hardware -devices.""" +"""Functionality for loading and querying configuration for supported hardware devices.""" + import json import logging from enum import Enum, auto from pathlib import Path -from typing import Dict, List, NamedTuple, Optional, Union +from typing import Any, Dict, List, NamedTuple, Optional, Union from bcipy.config import (DEFAULT_ENCODING, DEVICE_SPEC_PATH, SESSION_LOG_FILENAME) @@ -23,26 +23,41 @@ class ChannelSpec(NamedTuple): - """Represents metadata about a channel.""" - name: str # Label in the LSL metadata - label: str # Label used within BciPy (raw_data, etc.) - type: str = None - units: str = None + """Represents metadata about a channel. + + Attributes: + name (str): Label in the LSL metadata. + label (str): Label used within BciPy (raw_data, etc.). + type (Optional[str]): Type of the channel (e.g., 'EEG', 'Pupil'). Defaults to None. + units (Optional[str]): Units of measurement for the channel data (e.g., 'microvolts'). + Defaults to None. + """ + name: str + label: str + type: Optional[str] = None + units: Optional[str] = None - def __repr__(self): + def __repr__(self) -> str: + """Returns a string representation of the ChannelSpec object.""" fields = ['name', 'label', 'type', 'units'] items = [(field, self.__getattribute__(field)) for field in fields] props = [f"{key}='{val}'" for key, val in items if val] - return f"ChannelSpec({', '.join(props)})" + return f"ChannelSpec({ ', '.join(props) })" + +def channel_spec(channel: Union[str, Dict[str, Any], ChannelSpec]) -> ChannelSpec: + """Creates a ChannelSpec from the given channel information. -def channel_spec(channel: Union[str, dict, ChannelSpec]) -> ChannelSpec: - """Creates a ChannelSpec from the given channel. + Args: + channel (Union[str, Dict[str, Any], ChannelSpec]): Acquisition channel + information, specified as either just the label (str), a dictionary + with channel properties, or an existing ChannelSpec object. - Parameters - ---------- - channel - acquisition channel information specified as either just the - label or with additional data represented by a dict or ChannelSpec. + Returns: + ChannelSpec: A `ChannelSpec` object. + + Raises: + Exception: If an unexpected channel type is provided. """ if isinstance(channel, str): return ChannelSpec(name=channel, label=channel) @@ -59,40 +74,49 @@ class DeviceStatus(Enum): PASSIVE = auto() def __str__(self) -> str: - """String representation""" + """Returns the lowercase string representation of the DeviceStatus enum member.""" return self.name.lower() @classmethod def from_str(cls, name: str) -> 'DeviceStatus': - """Returns the DeviceStatus associated with the given string - representation.""" + """Returns the `DeviceStatus` enum member associated with the given string + representation. + + Args: + name (str): The string representation of the device status (e.g., 'active', 'passive'). + + Returns: + DeviceStatus: The corresponding `DeviceStatus` enum member. + """ return cls[name.upper()] class DeviceSpec: """Specification for a hardware device used in data acquisition. - Parameters - ---------- - name - device short name; ex. DSI-24 - channels - list of data collection channels; devices must have at least - one channel. Channels may be provided as a list of names or list of - ChannelSpecs. - sample_rate - sample frequency in Hz. - content_type - type of device; likely one of ['EEG', 'MoCap', 'Gaze', - 'Audio', 'Markers']; see https://github.com/sccn/xdf/wiki/Meta-Data. - description - device description - ex. 'Wearable Sensing DSI-24 dry electrode EEG headset' - data_type - data format of a channel; all channels must have the same type; - see https://labstreaminglayer.readthedocs.io/projects/liblsl/ref/enums.html - excluded_from_analysis - list of channels (label) to exclude from analysis. - status - recording status - static_offset - Specifies the static trigger offset (in seconds) used to align - triggers properly with EEG data from LSL. The system includes built-in - offset correction, but there is still a hardware-limited offset between EEG - and trigger timing values for which the system does not account. The correct - value may be different for each computer, and must be determined on a - case-by-case basis. Default: 0.1", + Args: + name (str): Device short name (e.g., 'DSI-24'). + channels (Union[List[str], List[ChannelSpec], List[dict]]): A list of + data collection channels. Devices must have at least one channel. + Channels may be provided as a list of names (str), a list of + `ChannelSpec` objects, or a list of dictionaries representing channel properties. + sample_rate (int): Sample frequency in Hz. + content_type (str, optional): Type of device (e.g., 'EEG', 'MoCap', 'Gaze', + 'Audio', 'Markers'). Defaults to `DEFAULT_DEVICE_TYPE` ('EEG'). + See https://github.com/sccn/xdf/wiki/Meta-Data. + description (Optional[str], optional): Device description (e.g., + 'Wearable Sensing DSI-24 dry electrode EEG headset'). + Defaults to `name` if not provided. + excluded_from_analysis (Optional[List[str]], optional): A list of channel labels + to exclude from analysis. + Defaults to an empty list. + data_type (str, optional): Data format of a channel. All channels must have the same type. + Defaults to 'float32'. See https://labstreaminglayer.readthedocs.io/projects/liblsl/ref/enums.html. + status (DeviceStatus, optional): Recording status of the device. + Defaults to `DeviceStatus.ACTIVE`. + static_offset (float, optional): Specifies the static trigger offset (in seconds) + used to align triggers properly with EEG data from LSL. + Defaults to `DEFAULT_STATIC_OFFSET` (0.1). """ def __init__(self, @@ -109,38 +133,40 @@ def __init__(self, assert sample_rate >= 0, "Sample rate can't be negative." assert data_type in SUPPORTED_DATA_TYPES - self.name = name - self.channel_specs = [channel_spec(ch) for ch in channels] - self.sample_rate = int(sample_rate) - self.content_type = content_type - self.description = description or name - self.data_type = data_type - self.excluded_from_analysis = excluded_from_analysis or [] + self.name: str = name + self.channel_specs: List[ChannelSpec] = [ + channel_spec(ch) for ch in channels] + self.sample_rate: int = int(sample_rate) + self.content_type: str = content_type + self.description: str = description or name + self.data_type: str = data_type + self.excluded_from_analysis: List[str] = excluded_from_analysis or [] self._validate_excluded_channels() - self.status = status - self.static_offset = static_offset + self.status: DeviceStatus = status + self.static_offset: float = static_offset @property def channel_count(self) -> int: - """Number of channels""" + """Returns the number of channels for the device.""" return len(self.channel_specs) @property def channels(self) -> List[str]: - """List of channel labels. These may be customized for BciPy.""" + """Returns a list of channel labels, which may be customized for BciPy.""" return [ch.label for ch in self.channel_specs] @property def channel_names(self) -> List[str]: - """List of channel names from the device.""" + """Returns a list of channel names as reported by the device.""" return [ch.name for ch in self.channel_specs] @property def analysis_channels(self) -> List[str]: - """List of channels used for analysis by the signal module. - Parameters: - ----------- - exclude_trg - indicates whether or not to exclude a TRG channel if present. + """Returns a list of channels used for analysis by the signal module. + + Returns: + List[str]: A list of channel labels to be used for analysis, excluding + any channels specified in `excluded_from_analysis`. """ return list( @@ -149,12 +175,19 @@ def analysis_channels(self) -> List[str]: @property def is_active(self) -> bool: - """Returns a boolean indicating if the device is currently active - (recording status set to DeviceStatus.ACTIVE).""" + """Checks if the device is currently active (recording status set to `DeviceStatus.ACTIVE`). + + Returns: + bool: True if the device is active, False otherwise. + """ return self.status == DeviceStatus.ACTIVE - def to_dict(self) -> dict: - """Converts the DeviceSpec to a dict.""" + def to_dict(self) -> Dict[str, Any]: + """Converts the `DeviceSpec` object to a dictionary representation. + + Returns: + Dict[str, Any]: A dictionary containing the device's properties. + """ return { 'name': self.name, 'content_type': self.content_type, @@ -166,21 +199,25 @@ def to_dict(self) -> dict: 'static_offset': self.static_offset } - def __str__(self): - """Custom str representation.""" + def __str__(self) -> str: + """Returns a custom string representation of the `DeviceSpec` object.""" names = [ 'name', 'content_type', 'channels', 'sample_rate', 'description' ] - def quoted_value(name): + def quoted_value(name: str) -> Union[str, Any]: value = self.__getattribute__(name) return f"'{value}'" if isinstance(value, str) else value props = [f"{name}={quoted_value(name)}" for name in names] - return f"DeviceSpec({', '.join(props)})" + return f"DeviceSpec({ ', '.join(props) })" - def _validate_excluded_channels(self): - """Warn if excluded channels are not in the list of channels""" + def _validate_excluded_channels(self) -> None: + """Warns if any excluded channels are not found in the device's channel list. + + This method logs a warning for each channel in `excluded_from_analysis` + that does not exist in `self.channels`. + """ for channel in self.excluded_from_analysis: if channel not in self.channels: logger.warning( @@ -188,9 +225,18 @@ def _validate_excluded_channels(self): ) -def make_device_spec(config: dict) -> DeviceSpec: - """Constructs a DeviceSpec from a dict. Throws a KeyError if any fields - are missing.""" +def make_device_spec(config: Dict[str, Any]) -> DeviceSpec: + """Constructs a `DeviceSpec` object from a dictionary configuration. + + Args: + config (Dict[str, Any]): A dictionary containing device configuration parameters. + + Returns: + DeviceSpec: A `DeviceSpec` object initialized with the provided configuration. + + Raises: + KeyError: If any required fields are missing in the `config` dictionary. + """ default_status = str(DeviceStatus.ACTIVE) return DeviceSpec(name=config['name'], content_type=config['content_type'], @@ -199,19 +245,23 @@ def make_device_spec(config: dict) -> DeviceSpec: description=config['description'], excluded_from_analysis=config.get( 'excluded_from_analysis', []), - status=DeviceStatus.from_str(config.get('status', default_status)), + status=DeviceStatus.from_str( + config.get('status', default_status)), static_offset=config.get('static_offset', DEFAULT_STATIC_OFFSET)) -def load(config_path: Path = Path(DEFAULT_CONFIG), replace: bool = False) -> Dict[str, DeviceSpec]: - """Load the list of supported hardware for data acquisition from the given +def load(config_path: Path = Path(DEFAULT_CONFIG), replace: bool = False) -> Dict[str, 'DeviceSpec']: + """Loads the list of supported hardware devices for data acquisition from a configuration file. - Parameters - ---------- - config_path - path to the devices json file - replace - optional; if true, existing devices are replaced; if false, - values will be overwritten or appended. + Args: + config_path (Path, optional): Path to the devices JSON file. + Defaults to `Path(DEFAULT_CONFIG)`. + replace (bool, optional): If True, existing devices are replaced; if False, + values will be overwritten or appended. Defaults to False. + + Returns: + Dict[str, DeviceSpec]: A dictionary of loaded `DeviceSpec` objects, keyed by device name. """ global _SUPPORTED_DEVICES @@ -225,9 +275,14 @@ def load(config_path: Path = Path(DEFAULT_CONFIG), replace: bool = False) -> Dic return _SUPPORTED_DEVICES -def preconfigured_devices() -> Dict[str, DeviceSpec]: - """Returns the preconfigured devices keyed by name. If no devices have yet - been configured, loads and returns the DEFAULT_CONFIG.""" +def preconfigured_devices() -> Dict[str, 'DeviceSpec']: + """Returns the preconfigured devices, keyed by name. + + If no devices have yet been configured, it loads and returns the `DEFAULT_CONFIG`. + + Returns: + Dict[str, DeviceSpec]: A dictionary of preconfigured `DeviceSpec` objects. + """ global _SUPPORTED_DEVICES if not _SUPPORTED_DEVICES: load() @@ -235,33 +290,55 @@ def preconfigured_devices() -> Dict[str, DeviceSpec]: def preconfigured_device(name: str, strict: bool = True) -> DeviceSpec: - """Retrieve the DeviceSpec with the given name. An exception is raised - if the device is not found.""" + """Retrieves the `DeviceSpec` with the given name. + + Args: + name (str): The name of the device to retrieve. + strict (bool, optional): If True, raises an exception if the device is not found. + Defaults to True. + + Returns: + DeviceSpec: The `DeviceSpec` object for the specified device. + + Raises: + ValueError: If `strict` is True and the device is not found. + """ device = preconfigured_devices().get(name, None) if strict and not device: current = ', '.join( - [f"'{key}'" for key, _ in preconfigured_devices().items()]) + [f"' {key}'" for key, _ in preconfigured_devices().items()]) msg = ( - f"Device not found: {name}." - "\n\n" - f"The current list of devices includes the following: {current}." - "\n" + f"Device not found: {name}.\n\n" + f"The current list of devices includes the following: {current}.\n" "You may register new devices using the device module `register` function or in bulk" " using `load`.") logger.error(msg) raise ValueError(msg) - return device + return device # type: ignore def with_content_type(content_type: str) -> List[DeviceSpec]: - """Retrieve the list of DeviceSpecs with the given content_type.""" + """Retrieves a list of `DeviceSpec` objects with the given content type. + + Args: + content_type (str): The content type to filter devices by. + + Returns: + List[DeviceSpec]: A list of `DeviceSpec` objects matching the specified content type. + """ return [ spec for spec in preconfigured_devices().values() if spec.content_type == content_type ] -def register(device_spec: DeviceSpec): - """Register the given DeviceSpec.""" +def register(device_spec: DeviceSpec) -> None: + """Registers the given `DeviceSpec`. + + Adds the provided `DeviceSpec` to the collection of preconfigured devices. + + Args: + device_spec (DeviceSpec): The `DeviceSpec` object to register. + """ config = preconfigured_devices() config[device_spec.name] = device_spec diff --git a/bcipy/acquisition/exceptions.py b/bcipy/acquisition/exceptions.py index 7caaf1f54..de13064e4 100644 --- a/bcipy/acquisition/exceptions.py +++ b/bcipy/acquisition/exceptions.py @@ -1,12 +1,19 @@ - class InvalidClockError(Exception): - def __init__(self, msg): - Exception.__init__(self, msg) + """Exception raised for invalid clock operations in acquisition.""" + + def __init__(self, msg: str): + """Initializes the InvalidClockError with a message. + + Args: + msg (str): The error message. + """ + super().__init__(msg) class UnsupportedContentType(Exception): """Error that occurs when attempting to collect data from a device with a - content type that is not yet supported by BciPy.""" + content type that is not yet supported by BciPy. + """ class InsufficientDataException(Exception): diff --git a/bcipy/acquisition/marker_writer.py b/bcipy/acquisition/marker_writer.py index fd6216014..d45616de4 100644 --- a/bcipy/acquisition/marker_writer.py +++ b/bcipy/acquisition/marker_writer.py @@ -1,6 +1,6 @@ """Defines classes that can write markers to LabStreamingLayer StreamOutlet.""" import logging -from typing import Any +from typing import Any, Optional import pylsl @@ -9,47 +9,68 @@ log = logging.getLogger(SESSION_LOG_FILENAME) -class MarkerWriter(): +class MarkerWriter: """Abstract base class for an object that can be used to handle stimulus markers. """ - def push_marker(self, marker: Any): - """Push the given stimulus marker for processing. + def push_marker(self, marker: Any) -> None: + """Pushes the given stimulus marker for processing. - Parameters - ---------- - - marker : any object that can be converted to a str + This method must be implemented by subclasses. + + Args: + marker (Any): Any object that can be converted to a string, representing + the stimulus marker. + + Raises: + NotImplementedError: If the method is not implemented by a subclass. """ raise NotImplementedError() - def cleanup(self): - """Performs any necessary cleanup""" + def cleanup(self) -> None: + """Performs any necessary cleanup. + + This method must be implemented by subclasses. + + Raises: + NotImplementedError: If the method is not implemented by a subclass. + """ raise NotImplementedError() class LslMarkerWriter(MarkerWriter): - """Writes stimulus markers to a LabStreamingLayer StreamOutlet - using pylsl. To consume this data, the client code would need to create a - pylsl.StreamInlet. See https://github.com/sccn/labstreaminglayer/wiki. + """Writes stimulus markers to a LabStreamingLayer StreamOutlet using pylsl. + + To consume this data, client code would typically create a `pylsl.StreamInlet`. + See https://github.com/sccn/labstreaminglayer/wiki for more information. + + Args: + stream_name (str, optional): The name of the LSL stream. Defaults to + "BCI_Stimulus_Markers". + stream_id (str, optional): The unique ID of the LSL stream. Defaults to + "bci_stim_markers". """ def __init__(self, stream_name: str = "BCI_Stimulus_Markers", stream_id: str = "bci_stim_markers"): - super(LslMarkerWriter, self).__init__() - self.stream_name = stream_name + super().__init__() + self.stream_name: str = stream_name markers_info = pylsl.StreamInfo(stream_name, "Markers", 1, 0, 'string', stream_id) - self.markers_outlet = pylsl.StreamOutlet(markers_info) - self.first_marker_stamp: float = None + self.markers_outlet: pylsl.StreamOutlet = pylsl.StreamOutlet(markers_info) + self.first_marker_stamp: Optional[float] = None - def push_marker(self, marker: Any): - """Push the given stimulus marker for processing. + def push_marker(self, marker: Any) -> None: + """Pushes the given stimulus marker to the LSL stream. - Parameters - ---------- - - marker : any object that can be converted to a str + The marker is converted to a string and sent along with a local timestamp. + The `first_marker_stamp` is set upon the first marker push. + + Args: + marker (Any): Any object that can be converted to a string, representing + the stimulus marker. """ stamp = pylsl.local_clock() log.info(f'Pushing marker {str(marker)} at {stamp}') @@ -57,27 +78,28 @@ def push_marker(self, marker: Any): if not self.first_marker_stamp: self.first_marker_stamp = stamp - def cleanup(self): - """Cleans up the StreamOutlet.""" + def cleanup(self) -> None: + """Cleans up and releases the LSL StreamOutlet.""" del self.markers_outlet class NullMarkerWriter(MarkerWriter): """MarkerWriter which doesn't write anything. - A NullMarkerWriter can be passed in to the calling object in scenarios - where marker handling occurs indirectly (ex. through a trigger box). By - using a NullMarkerWriter rather than a None value, the calling - object does not have to do additional null checks and a separation - of concerns is maintained regarding how triggers are written for different - devices. + A `NullMarkerWriter` can be passed to calling objects in scenarios where + marker handling occurs indirectly (e.g., through a trigger box). By using + a `NullMarkerWriter` instead of `None`, the calling object avoids additional + null checks, maintaining a separation of concerns regarding how triggers + are written for different devices. See the Null Object Design Pattern: https://en.wikipedia.org/wiki/Null_object_pattern """ - def push_marker(self, marker: Any): + def push_marker(self, marker: Any) -> None: + """Overrides the abstract method to do nothing.""" pass def cleanup(self): + """Overrides the abstract method to do nothing.""" pass diff --git a/bcipy/acquisition/multimodal.py b/bcipy/acquisition/multimodal.py index dba7a7423..ad4ad2a5e 100644 --- a/bcipy/acquisition/multimodal.py +++ b/bcipy/acquisition/multimodal.py @@ -15,13 +15,15 @@ class ContentType(AutoNumberEnum): - """Enum of supported acquisition device (LSL) content types. Allows for - case-insensitive matching, as well as synonyms for some types. + """Enum of supported acquisition device (LSL) content types. - >>> ContentType(1) == ContentType.EEG - True - >>> ContentType('Eeg') == ContentType.EEG - True + Allows for case-insensitive matching, as well as synonyms for some types. + + Examples: + >>> ContentType(1) == ContentType.EEG + True + >>> ContentType('Eeg') == ContentType.EEG + True """ def __init__(self, synonyms: List[str]): @@ -33,34 +35,40 @@ def __init__(self, synonyms: List[str]): @classmethod def _missing_(cls, value: Any) -> 'ContentType': - """Lookup function used when a value is not found.""" + """Lookup function used when a value is not found. + + This method enables case-insensitive matching and allows for synonyms. + + Args: + value (Any): The value to lookup, which will be converted to a string and lowercased. + + Returns: + ContentType: The matching ContentType enum member. + + Raises: + UnsupportedContentType: If no matching content type is found. + """ value = str(value).lower() - for member in cls: - if member.name.lower() == value or value in member.synonyms: + for member in cls: # type: ignore + if member.name.lower() == value or value in member.synonyms: # type: ignore return member raise UnsupportedContentType(f"ContentType not supported: {value}") class ClientManager(): - """Manages multiple acquisition clients. This class can also act as an - acquisition client. If used in this way, it dispatches to the managed - client with the default_client_type. - - >>> from bcipy.acquisition import LslAcquisitionClient - >>> from bcipy.acquisition.devices import DeviceSpec - >>> spec = DeviceSpec('Testing', ['ch1', 'ch2', 'ch3'], 60.0, 'EEG') - >>> manager = ClientManager() - >>> eeg_client = LslAcquisitionClient(device_spec=spec) - >>> manager.add_client(eeg_client) - >>> manager.device_spec == spec - True - - Parameters - ---------- - default_content_type - used for dispatching calls to an LslClient. + """Manages multiple acquisition clients. + + This class can also act as an acquisition client. If used in this way, + it dispatches to the managed client with the `default_client_type`. + + Args: + default_content_type (ContentType, optional): The default content type + to use for dispatching calls to an `LslClient`. Defaults to `ContentType.EEG`. """ - def __init__(self, default_content_type: ContentType = ContentType.EEG) -> None: + inlet: Any = None # To satisfy mypy, as ClientManager can act as an LslAcquisitionClient + + def __init__(self, default_content_type: ContentType = ContentType.EEG) -> None: # type: ignore self._clients: Dict[ContentType, LslAcquisitionClient] = {} self.default_content_type = default_content_type @@ -71,27 +79,25 @@ def clients(self) -> List[LslAcquisitionClient]: @property def clients_by_type(self) -> Dict[ContentType, LslAcquisitionClient]: - """Returns a dict of clients keyed by their content type""" + """Returns a dictionary of clients keyed by their content type.""" return self._clients @property def device_specs(self) -> List[DeviceSpec]: - """Returns a list of DeviceSpecs for all the clients.""" - return [client.device_spec for client in self.clients] + """Returns a list of `DeviceSpec` objects for all the clients.""" + return [client.device_spec for client in self.clients if client.device_spec] # type: ignore @property def device_content_types(self) -> List[ContentType]: - """Returns a list of ContentTypes provided by the configured devices. - """ + """Returns a list of `ContentType` enums provided by the configured devices.""" return list(self._clients.keys()) @property def active_device_content_types(self) -> List[ContentType]: - """Returns a list of ContentTypes provided by the active configured - devices.""" + """Returns a list of `ContentType` enums provided by the active configured devices.""" return [ content_type for content_type, client in self._clients.items() - if client.device_spec.is_active + if client.device_spec and client.device_spec.is_active # type: ignore ] @property @@ -99,24 +105,38 @@ def default_client(self) -> Optional[LslAcquisitionClient]: """Returns the default client.""" return self.get_client(self.default_content_type) - def add_client(self, client: LslAcquisitionClient): - """Add the given client to the manager.""" - content_type = ContentType(client.device_spec.content_type) + def add_client(self, client: LslAcquisitionClient) -> None: # type: ignore + """Adds the given client to the manager. + + Args: + client (LslAcquisitionClient): The client instance to add. + """ + content_type = ContentType( + client.device_spec.content_type) # type: ignore self._clients[content_type] = client def get_client( self, content_type: ContentType) -> Optional[LslAcquisitionClient]: - """Get client by content type""" + """Retrieves a client by its content type. + + Args: + content_type (ContentType): The content type of the client to retrieve. + + Returns: + Optional[LslAcquisitionClient]: The `LslAcquisitionClient` instance if found, + otherwise None. + """ return self._clients.get(content_type, None) - def start_acquisition(self): - """Start acquiring data for all clients""" + def start_acquisition(self) -> None: # type: ignore + """Starts data acquisition for all clients.""" for client in self.clients: + # type: ignore logger.info(f"Connecting to {client.device_spec.name}...") client.start_acquisition() - def stop_acquisition(self): - """Stop acquiring data for all clients""" + def stop_acquisition(self) -> None: # type: ignore + """Stops data acquisition for all clients.""" logger.info("Stopping acquisition...") for client in self.clients: client.stop_acquisition() @@ -128,54 +148,83 @@ def get_data_by_device( content_types: Optional[List[ContentType]] = None, strict: bool = True ) -> Dict[ContentType, List[Record]]: - """Get data for one or more devices. The number of samples for each - device depends on the sample rate and may be different for item. - - Parameters - ---------- - start - start time (acquisition clock) of data window; NOTE: the - actual start time will be adjusted to by the static_offset - configured for each device. - seconds - duration of data to return for each device - content_types - specifies which devices to include; if not - unspecified, data for all types is returned. - strict - if True, raises an exception if the returned rows is - less than the requested number of records. + """Retrieves data for one or more devices within a specified time window. + + The number of samples for each device depends on the sample rate and may + differ. The actual start time will be adjusted by the `static_offset` + configured for each device. + + Args: + start (Optional[float]): Start time (acquisition clock) of the data window. + seconds (Optional[float]): Duration of data to return for each device. + content_types (Optional[List[ContentType]], optional): Specifies which + devices to include. If None, data for all types is returned. + Defaults to None. + strict (bool, optional): If True, raises an `InsufficientDataException` + if the number of returned records is less than the requested number. + Defaults to True. + + Returns: + Dict[ContentType, List[Record]]: A dictionary where keys are `ContentType` + and values are lists of `Record` objects. + + Raises: + InsufficientDataException: If `strict` is True and the returned data count + is less than the requested count for a device. """ - output = {} + output: Dict[ContentType, List[Record]] = {} if not content_types: content_types = self.device_content_types for content_type in content_types: name = content_type.name client = self.get_client(content_type) - adjusted_start = start + client.device_spec.static_offset - if client.device_spec.sample_rate > 0: - count = round(seconds * client.device_spec.sample_rate) - logger.info(f'Need {count} records for processing {name} data') - output[content_type] = client.get_data(start=adjusted_start, - limit=count) - data_count = len(output[content_type]) - if strict and data_count < count: - msg = f'Needed {count} {name} records but received {data_count}' - logger.error(msg) - raise InsufficientDataException(msg) + if client and client.device_spec: + adjusted_start = start + client.device_spec.static_offset + if client.device_spec.sample_rate > 0: + count = round(seconds * client.device_spec.sample_rate) + logger.info( + f'Need {count} records for processing {name} data') + output[content_type] = client.get_data(start=adjusted_start, + limit=count) + data_count = len(output[content_type]) + if strict and data_count < count: + msg = f'Needed {count} {name} records but received {data_count}' + logger.error(msg) + raise InsufficientDataException(msg) + else: + # Markers have an IRREGULAR_RATE. + logger.info(f'Querying {name} data') + output[content_type] = client.get_data(start=adjusted_start, + end=adjusted_start + seconds) + logger.info( + f"Received {len(output[content_type])} records.") else: - # Markers have an IRREGULAR_RATE. - logger.info(f'Querying {name} data') - output[content_type] = client.get_data(start=adjusted_start, - end=adjusted_start + seconds) - logger.info(f"Received {len(output[content_type])} records.") + logger.error( + f"No client and device spec found for content type: {content_type.name}") return output - def cleanup(self): - """Perform any cleanup tasks""" + def cleanup(self) -> None: # type: ignore + """Performs any necessary cleanup tasks for all managed clients.""" for client in self.clients: client.cleanup() def __getattr__(self, name: str) -> Any: - """Dispatch unknown properties and methods to the client with the - default content type.""" + """Dispatches unknown properties and methods to the client with the + default content type. + + This allows `ClientManager` to act as a proxy for the default client. + + Args: + name (str): The name of the attribute being accessed. + + Returns: + Any: The attribute from the default client. + + Raises: + AttributeError: If the default client is not set or the attribute + does not exist on the default client. + """ client = self.default_client if client: return client.__getattribute__(name) diff --git a/bcipy/acquisition/protocols/lsl/connect.py b/bcipy/acquisition/protocols/lsl/connect.py index 96e49ba35..097a76fc0 100644 --- a/bcipy/acquisition/protocols/lsl/connect.py +++ b/bcipy/acquisition/protocols/lsl/connect.py @@ -9,7 +9,24 @@ def resolve_device_stream( device_spec: Optional[DeviceSpec] = None) -> StreamInfo: - """Get the LSL stream for the given device.""" + """Resolves and returns the LSL stream for the given device. + + This function searches for an LSL stream based on the `content_type` of the + provided `DeviceSpec`. If no `DeviceSpec` is provided, it defaults to + `DEFAULT_DEVICE_TYPE`. + + Args: + device_spec (Optional[DeviceSpec], optional): The DeviceSpec object + containing the content type + of the stream to resolve. + Defaults to None. + + Returns: + StreamInfo: The resolved LSL `StreamInfo` object. + + Raises: + Exception: If an LSL stream with the specified content type is not found. + """ content_type = device_spec.content_type if device_spec else DEFAULT_DEVICE_TYPE streams = resolve_stream('type', content_type) if not streams: @@ -19,7 +36,14 @@ def resolve_device_stream( def device_from_metadata(metadata: StreamInfo) -> DeviceSpec: - """Create a device_spec from the data stream metadata.""" + """Creates a `DeviceSpec` object from LSL stream metadata. + + Args: + metadata (StreamInfo): The LSL `StreamInfo` object containing device metadata. + + Returns: + DeviceSpec: A `DeviceSpec` object populated with information from the metadata. + """ return DeviceSpec(name=metadata.name(), channels=channel_names(metadata), sample_rate=metadata.nominal_srate(), diff --git a/bcipy/acquisition/protocols/lsl/lsl_client.py b/bcipy/acquisition/protocols/lsl/lsl_client.py index 4732b7a12..d0a14de81 100644 --- a/bcipy/acquisition/protocols/lsl/lsl_client.py +++ b/bcipy/acquisition/protocols/lsl/lsl_client.py @@ -1,7 +1,7 @@ """DataAcquisitionClient for LabStreamingLayer data sources.""" import logging from multiprocessing import Queue -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional import pandas as pd from pylsl import StreamInlet, local_clock, resolve_byprop @@ -25,7 +25,19 @@ def time_range(stamps: List[float], precision: int = 3, sep: str = " to ") -> str: - """Utility for printing a range of timestamps""" + """Utility for formatting a range of timestamps into a string. + + Args: + stamps (List[float]): A list of timestamps. + precision (int, optional): The number of decimal places for rounding timestamps. + Defaults to 3. + sep (str, optional): The separator string between the start and end timestamps. + Defaults to " to ". + + Returns: + str: A string representing the range of timestamps (e.g., "1.234 to 5.678"), + or an empty string if `stamps` is empty. + """ if stamps: return "".join([ str(round(stamps[0], precision)), sep, @@ -35,8 +47,17 @@ def time_range(stamps: List[float], def request_desc(start: Optional[float], end: Optional[float], - limit: Optional[int]): - """Returns a description of the request which can be logged.""" + limit: Optional[int]) -> str: + """Returns a descriptive string of a data request for logging purposes. + + Args: + start (Optional[float]): The starting timestamp of the request. + end (Optional[float]): The ending timestamp of the request. + limit (Optional[int]): The maximum number of records requested. + + Returns: + str: A formatted string describing the data request. + """ start_str = round(start, 3) if start else "None" end_str = round(end, 3) if end else "None" return f"Requesting data from: {start_str} to: {end_str} limit: {limit}" @@ -44,28 +65,35 @@ def request_desc(start: Optional[float], end: Optional[float], class LslAcquisitionClient: """Data Acquisition Client for devices streaming data using Lab Streaming - Layer. Its primary use is dynamically querying streaming data in realtime, - however, if the save_directory and filename parameters are provided it uses - a LslRecordingThread to persist the data. - - Parameters - ---------- - max_buffer_len: the maximum length of data to be queried. For continuously - streaming data this is the number of seconds of data to retain. For - irregular data, specify the number of samples. When using the RSVP - paradigm, the max_buffer_len should be large enough to store data for - the entire inquiry. - device_spec: spec for the device from which to query data; if missing, - this class will attempt to find the first EEG stream. - save_directory: if present, persists the data to the given location. - raw_data_file_name: if present, uses this name for the data file. + Layer. + + Its primary use is dynamically querying streaming data in realtime. + If `save_directory` and `filename` parameters are provided, it uses a + `LslRecordingThread` to persist the data. + + Args: + max_buffer_len (float, optional): The maximum length of data to be queried. + For continuously streaming data, this is the + number of seconds of data to retain. For + irregular data, it specifies the number of samples. + When using the RSVP paradigm, `max_buffer_len` + should be large enough to store data for the + entire inquiry. Defaults to 1. + device_spec (Optional[DeviceSpec], optional): The `DeviceSpec` for the device + from which to query data. + If missing, this class will attempt + to find the first EEG stream. Defaults to None. + save_directory (Optional[str], optional): If present, persists the data to + the given location. Defaults to None. + raw_data_file_name (Optional[str], optional): If present, uses this name + for the data file. Defaults to None. """ - inlet: StreamInlet = None - recorder: LslRecordingThread = None - buffer: RingBuffer = None - _first_sample_time: float = None - experiment_clock: Clock = None + inlet: Optional[StreamInlet] = None + recorder: Optional[LslRecordingThread] = None + buffer: Optional[RingBuffer] = None + _first_sample_time: Optional[float] = None + experiment_clock: Optional[Clock] = None def __init__(self, max_buffer_len: float = 1, @@ -74,42 +102,59 @@ def __init__(self, raw_data_file_name: Optional[str] = None): super().__init__() - self.device_spec = device_spec - self.max_buffer_len = max_buffer_len - self.save_directory = save_directory - self.raw_data_file_name = raw_data_file_name - self._max_samples = None + self.device_spec: Optional[DeviceSpec] = device_spec + self.max_buffer_len: float = max_buffer_len + self.save_directory: Optional[str] = save_directory + self.raw_data_file_name: Optional[str] = raw_data_file_name + self._max_samples: Optional[int] = None @property def has_irregular_rate(self) -> bool: - """Returns true for sampling devices with an irregular rate, - such as markers. + """Checks if the device has an irregular sampling rate. + + Returns: + bool: True if the device's sample rate is `IRREGULAR_RATE`, + False otherwise. """ - return self.device_spec.sample_rate == IRREGULAR_RATE + return self.device_spec.sample_rate == IRREGULAR_RATE # type: ignore @property - def first_sample_time(self) -> float: - """Timestamp returned by the first sample. If the data is being - recorded this value reflects the timestamp of the first recorded sample""" + def first_sample_time(self) -> Optional[float]: + """Returns the timestamp of the first sample. + + If data is being recorded, this value reflects the timestamp of the first + recorded sample. + + Returns: + Optional[float]: The timestamp of the first sample, or None if not set. + """ return self._first_sample_time @property def max_samples(self) -> int: - """Maximum number of samples available at any given time.""" + """Calculates the maximum number of samples available at any given time. + + This depends on `max_buffer_len` and the device's sample rate. + + Returns: + int: The maximum number of samples. + """ if self._max_samples is None: if self.has_irregular_rate: self._max_samples = int(self.max_buffer_len) else: self._max_samples = int(self.max_buffer_len * - self.device_spec.sample_rate) + self.device_spec.sample_rate) # type: ignore return self._max_samples def start_acquisition(self) -> bool: - """Connect to the datasource and start acquiring data. + """Connects to the data source and begins acquiring data. + + Initializes the LSL `StreamInlet` and optionally an `LslRecordingThread` + if a `save_directory` is provided. - Returns - ------- - bool : False if acquisition is already in progress, otherwise True. + Returns: + bool: False if acquisition is already in progress, otherwise True. """ if self.inlet: return False @@ -128,7 +173,7 @@ def start_acquisition(self) -> bool: self.device_spec = device_from_metadata(self.inlet.info()) if self.save_directory: - msg_queue = Queue() + msg_queue: Queue[float] = Queue() self.recorder = LslRecordingThread( directory=self.save_directory, filename=self.raw_data_file_name, @@ -151,9 +196,15 @@ def start_acquisition(self) -> bool: return True def stop_acquisition(self) -> None: - """Disconnect from the data source.""" - logger.info(f"Stopping Acquisition from {self.device_spec.name} ...") + """Disconnects from the data source and cleans up resources. + + Stops the `LslRecordingThread` if active, closes the LSL `StreamInlet`, + and clears the internal buffer. + """ + logger.info( + f"Stopping Acquisition from {self.device_spec.name} ...") # type: ignore if self.recorder: + # type: ignore logger.info(f"Closing {self.device_spec.name} data recorder") self.recorder.stop() self.recorder.join() @@ -165,24 +216,44 @@ def stop_acquisition(self) -> None: self.buffer = None - def __enter__(self): - """Context manager enter method that starts data acquisition.""" + def __enter__(self) -> 'LslAcquisitionClient': + """Context manager enter method that starts data acquisition. + + Returns: + LslAcquisitionClient: The instance of the acquisition client. + """ self.start_acquisition() return self - def __exit__(self, _exc_type, _exc_value, _traceback): - """Context manager exit method to clean up resources.""" + def __exit__(self, _exc_type: Any, _exc_value: Any, _traceback: Any) -> None: + """Context manager exit method to clean up resources. + + Args: + _exc_type (Any): The exception type, if an exception was raised. + _exc_value (Any): The exception value, if an exception was raised. + _traceback (Any): The traceback, if an exception was raised. + """ self.stop_acquisition() def _data_stats(self, data: List[Record]) -> Dict[str, float]: - """Summarize a list of records for logging and inspection.""" + """Summarizes a list of records for logging and inspection. + + Args: + data (List[Record]): A list of `Record` objects. + + Returns: + Dict[str, float]: A dictionary containing statistics such as count, + total seconds, start/end timestamps, expected difference, + mean difference, and max difference between samples. + Returns an empty dict if `data` is empty. + """ if data: diffs = pd.DataFrame(data)['timestamp'].diff() data_start = data[0].timestamp data_end = data[-1].timestamp precision = 3 expected_diff = 0.0 if self.has_irregular_rate else round( - 1 / self.device_spec.sample_rate, precision) + 1 / self.device_spec.sample_rate, precision) # type: ignore return { 'count': len(data), 'seconds': round(data_end - data_start, precision), @@ -198,20 +269,28 @@ def get_data(self, start: Optional[float] = None, end: Optional[float] = None, limit: Optional[int] = None) -> List[Record]: - """Get data in time range. + """Retrieves data within a specified time range from the current buffer. - Only data in the current buffer is available to query; - requests for data outside of this will fail. + Only data currently in the buffer is available for querying. + Requests for data outside of this range will fail. - Parameters - ---------- - start : starting timestamp (acquisition clock). - end : end timestamp (in acquisition clock). - limit: the max number of records that should be returned. + Args: + start (Optional[float]): The starting timestamp (in acquisition clock). + Defaults to None, which means the beginning + of available data. + end (Optional[float]): The end timestamp (in acquisition clock). + Defaults to None, which means the end of + available data. + limit (Optional[int]): The maximum number of records to return. + Defaults to None, which means no limit. - Returns - ------- - List of Records + Returns: + List[Record]: A list of `Record` objects within the specified range. + Returns an empty list if no records are available. + + Raises: + AssertionError: If `start` or `end` times are out of the available data range + for regular rate devices. """ logger.info(request_desc(start, end, limit)) @@ -230,21 +309,26 @@ def get_data(self, end = data_end if not self.has_irregular_rate: - assert start >= data_start, 'Start time out of range' - assert end <= data_end, 'End time out of range' + assert start is not None and start >= data_start, 'Start time out of range' + assert end is not None and end <= data_end, 'End time out of range' data_slice = [ - record for record in data if start <= record.timestamp <= end + record for record in data if start <= record.timestamp <= end # type: ignore ][0:limit] logger.info(f"Filtered records: {self._data_stats(data_slice)}") return data_slice def get_latest_data(self) -> List[Record]: - """Add all available samples in the inlet to the buffer. + """Adds all currently available samples from the inlet to the buffer. The number of items returned depends on the size of the configured - max_buffer_len and the amount of data available in the inlet.""" + `max_buffer_len` and the amount of data available in the inlet. + + Returns: + List[Record]: A list of the latest `Record` objects from the buffer. + Returns an empty list if no buffer is initialized. + """ if not self.buffer: return [] @@ -256,76 +340,101 @@ def get_latest_data(self) -> List[Record]: return self.buffer.get() def _pull_chunk(self) -> int: - """Pull a chunk of samples from LSL and record in the buffer. - Returns the count of samples pulled. + """Pulls a chunk of samples from LSL and records them in the buffer. + + Returns: + int: The count of samples pulled in this operation. """ logger.debug(f"\tPulling chunk (max_samples: {self.max_samples})") # A timeout of 0.0 gets currently available samples without blocking. - samples, timestamps = self.inlet.pull_chunk( + samples, timestamps = self.inlet.pull_chunk( # type: ignore timeout=0.0, max_samples=self.max_samples) count = len(samples) - logger.debug(f"\t-> received {count} samples: {time_range(timestamps)}") + logger.debug( + f"\t-> received {count} samples: {time_range(timestamps)}") for sample, stamp in zip(samples, timestamps): - self.buffer.append(Record(sample, stamp)) + self.buffer.append(Record(sample, stamp)) # type: ignore return count def convert_time(self, experiment_clock: Clock, timestamp: float) -> float: - """ - Convert a timestamp from the experiment clock to the acquisition clock. + """Converts a timestamp from the experiment clock to the acquisition clock. + Used for querying the acquisition data for a time slice. - Parameters: - ---------- - - experiment_clock : clock used to generate the timestamp - - timestamp : timestamp from the experiment clock + Args: + experiment_clock (Clock): The clock used to generate the timestamp. + timestamp (float): The timestamp from the experiment clock. Returns: - -------- - corresponding timestamp for the acquistion clock + float: The corresponding timestamp for the acquisition clock. """ # experiment_time = pylsl.local_clock() - offset return timestamp + self.clock_offset(experiment_clock) def get_data_seconds(self, seconds: int) -> List[Record]: - """Returns the last n second of data""" + """Returns the last 'n' seconds of data available in the buffer. + + Args: + seconds (int): The number of seconds of data to retrieve. + + Returns: + List[Record]: A list of `Record` objects covering the last `seconds`. + + Raises: + AssertionError: If `seconds` exceeds `max_buffer_len`. + """ assert seconds <= self.max_buffer_len, f"Seconds can't exceed {self.max_buffer_len}" - sample_count = seconds * self.device_spec.sample_rate + sample_count = seconds * self.device_spec.sample_rate # type: ignore records = self.get_latest_data() start_index = 0 if len( records) > sample_count else len(records) - sample_count - return records[start_index:] + return records[int(start_index):] @property - def is_calibrated(self): - """Returns boolean indicating whether or not acquisition has been - calibrated (an offset calculated based on a trigger).""" + def is_calibrated(self) -> bool: + """Checks whether acquisition has been calibrated (an offset calculated based on a trigger). + + Returns: + bool: True, as this property is currently hardcoded to return True. + """ return True @is_calibrated.setter - def is_calibrated(self, bool_val): - """Setter for the is_calibrated property that allows the user to - override the calculated value and use a 0 offset. - - Parameters - ---------- - - bool_val : boolean - if True, uses a 0 offset; if False forces the calculation. + def is_calibrated(self, bool_val: bool) -> None: + """Setter for the `is_calibrated` property. + + Allows the user to override the calculated value and use a 0 offset. + + Args: + bool_val (bool): If True, forces a 0 offset; if False, forces the calculation. + Note: Current implementation always returns True for getter. """ + # This setter currently has no effect on the getter's return value. + pass def clock_offset(self, experiment_clock: Optional[Clock] = None) -> float: - """ - Offset in seconds from the experiment clock to the acquisition local clock. + """Calculates the offset in seconds from the experiment clock to the acquisition local clock. + + The experiment clock should be monotonic from experiment start time. The + acquisition clock (`pylsl.local_clock()`) is monotonic from local machine + start time (or since 1970-01-01 00:00). Therefore the acquisition clock + should always be greater than experiment clock. + + Args: + experiment_clock (Optional[Clock], optional): The experiment clock object. + Defaults to None, in which + case `self.experiment_clock` is used. - The experiment clock should be monotonic from experiment start time. - The acquisition clock (pylsl.local_clock()) is monotonic from local - machine start time (or since 1970-01-01 00:00). Therefore the acquisition - clock should always be greater than experiment clock. An exception is - raised if this doesn't hold. + Returns: + float: The offset in seconds. - See https://labstreaminglayer.readthedocs.io/info/faqs.html#lsl-local-clock + Raises: + AssertionError: If an experiment clock is not provided or available. + InvalidClockError: If the acquisition clock is not greater than the + experiment clock. """ clock = experiment_clock or self.experiment_clock assert clock, "An experiment clock must be provided" @@ -338,17 +447,14 @@ def clock_offset(self, experiment_clock: Optional[Clock] = None) -> float: return diff def event_offset(self, event_clock: Clock, event_time: float) -> float: - """Compute number of seconds that recording started prior to the given - event. + """Computes the number of seconds that recording started prior to the given event. - Parameters - ---------- - - event_clock : monotonic clock used to record the event time. - - event_time : timestamp of the event of interest. + Args: + event_clock (Clock): Monotonic clock used to record the event time. + event_time (float): Timestamp of the event of interest. - Returns - ------- - Seconds between acquisition start and the event. + Returns: + float: Seconds between acquisition start and the event, or 0.0 if `first_sample_time` is not set. """ if self.first_sample_time: lsl_event_time = self.convert_time(event_clock, event_time) @@ -356,19 +462,18 @@ def event_offset(self, event_clock: Clock, event_time: float) -> float: return 0.0 def offset(self, first_stim_time: float) -> float: - """Offset in seconds from the start of acquisition to the given stim - time. + """Calculates the offset in seconds from the start of acquisition to the given stimulus time. - Parameters - ---------- - - first_stim_time : LSL local clock timestamp of the first stimulus. + Args: + first_stim_time (float): LSL local clock timestamp of the first stimulus. - Returns - ------- - The number of seconds between acquisition start and the calibration - event, or 0.0 . - """ + Returns: + float: The number of seconds between acquisition start and the calibration + event, or 0.0 if `first_stim_time` is zero or `has_irregular_rate` is True. + Raises: + AssertionError: If `first_sample_time` is not set and `has_irregular_rate` is False. + """ if not first_stim_time or self.has_irregular_rate: return 0.0 assert self.first_sample_time, "Acquisition was not started." @@ -376,13 +481,27 @@ def offset(self, first_stim_time: float) -> float: logger.info(f"Acquisition offset: {offset_from_stim}") return offset_from_stim - def cleanup(self): - """Perform any necessary cleanup.""" + def cleanup(self) -> None: + """Performs any necessary cleanup tasks. + + Currently, this method is a placeholder and does not perform any specific actions. + """ + pass def discover_device_spec(content_type: str) -> DeviceSpec: """Finds the first LSL stream with the given content type and creates a - device spec from the stream's metadata.""" + device spec from the stream's metadata. + + Args: + content_type (str): The content type of the LSL stream to discover (e.g., "EEG", "Markers"). + + Returns: + DeviceSpec: A `DeviceSpec` object created from the metadata of the discovered stream. + + Raises: + Exception: If an LSL stream with the specified content type is not found within `LSL_TIMEOUT`. + """ logger.info(f"Waiting for {content_type} data to be streamed over LSL.") streams = resolve_byprop('type', content_type, timeout=LSL_TIMEOUT) if not streams: diff --git a/bcipy/acquisition/protocols/lsl/lsl_connector.py b/bcipy/acquisition/protocols/lsl/lsl_connector.py index 31363db38..4c2002e8a 100644 --- a/bcipy/acquisition/protocols/lsl/lsl_connector.py +++ b/bcipy/acquisition/protocols/lsl/lsl_connector.py @@ -1,8 +1,7 @@ -# pylint: disable=fixme -"""Defines the driver for the Device for communicating with -LabStreamingLayer (LSL).""" +"""Defines the driver for the Device for communicating with LabStreamingLayer (LSL).""" + import logging -from typing import Dict, List +from typing import Any, Dict, List, Optional, Tuple import pylsl @@ -15,43 +14,84 @@ LSL_TIMEOUT_SECONDS = 5.0 -class Marker(): - """Data class which wraps a LSL marker; data pulled from a marker stream is - a tuple where the first item is a list of channels and second item is the - timestamp. Assumes that marker inlet only has a single channel.""" +class Marker: + """Data class which wraps an LSL marker. + + Data pulled from a marker stream is a tuple where the first item is a list + of channels (typically one) and the second item is the timestamp. + Assumes that the marker inlet only has a single channel. - def __init__(self, data=(None, None)): - super(Marker, self).__init__() - self.channels, self.timestamp = data + Args: + data (Tuple[Optional[List[Any]], Optional[float]], optional): A tuple + containing the channel data (list) and its timestamp (float). + Defaults to (None, None). + """ + + def __init__(self, data: Tuple[Optional[List[Any]], Optional[float]] = (None, None)): + super().__init__() + self.channels: Optional[List[Any]] = data[0] + self.timestamp: Optional[float] = data[1] @classmethod - def empty(cls): - """Creates an empty Marker.""" + def empty(cls) -> 'Marker': + """Creates an empty Marker instance. + + Returns: + Marker: An empty Marker object. + """ return Marker() - def __repr__(self): + def __repr__(self) -> str: + """Returns a string representation of the Marker object.""" return f"" @property - def is_empty(self): - """Test to see if the current marker is empty.""" + def is_empty(self) -> bool: + """Checks if the current marker is empty. + + Returns: + bool: True if both channels and timestamp are None, False otherwise. + """ return self.channels is None or self.timestamp is None @property - def trg(self): - """Get the trigger.""" + def trg(self) -> Optional[Any]: + """Gets the trigger value from the marker's channels. + + Assumes the trigger is the first element in the channels list. + + Returns: + Optional[Any]: The trigger value, or None if channels is empty or None. + """ # pylint: disable=unsubscriptable-object return self.channels[0] if self.channels else None -def inlet_name(inlet) -> str: - """Returns the name of a pylsl streamInlet.""" +def inlet_name(inlet: pylsl.StreamInlet) -> str: + """Returns a sanitized name of a pylsl `StreamInlet`. + + Converts the stream info name by replacing spaces and hyphens with underscores. + + Args: + inlet (pylsl.StreamInlet): The LSL StreamInlet object. + + Returns: + str: The sanitized name of the inlet. + """ name = '_'.join(inlet.info().name().split()) return name.replace('-', '_') def channel_names(stream_info: pylsl.StreamInfo) -> List[str]: - """Extracts the channel names from the LSL Stream metadata.""" + """Extracts the channel names from the LSL Stream metadata. + + Args: + stream_info (pylsl.StreamInfo): The LSL `StreamInfo` object. + + Returns: + List[str]: A list of channel names. If the stream type is 'Markers', + it returns `['Marker']`. + """ channels: List[str] = [] if stream_info.type() == 'Markers': return ['Marker'] @@ -67,9 +107,19 @@ def channel_names(stream_info: pylsl.StreamInfo) -> List[str]: return channels -def check_device(device_spec: DeviceSpec, metadata: pylsl.StreamInfo): - """Confirm that the properties of the given device_spec match the metadata - acquired from the device.""" +def check_device(device_spec: DeviceSpec, metadata: pylsl.StreamInfo) -> None: + """Confirms that the properties of the given `DeviceSpec` match the metadata + acquired from the LSL stream. + + Args: + device_spec (DeviceSpec): The expected `DeviceSpec` for the device. + metadata (pylsl.StreamInfo): The LSL `StreamInfo` object containing the + actual device metadata. + + Raises: + Exception: If channel names, channel count, or sample rate do not match + between `device_spec` and `metadata`. + """ channels = channel_names(metadata) # Confirm that provided channels match metadata, or meta is empty. if channels and device_spec.channel_names != channels: @@ -77,8 +127,7 @@ def check_device(device_spec: DeviceSpec, metadata: pylsl.StreamInfo): print(device_spec.channel_names) raise Exception("Channels read from the device do not match " "the provided parameters.") - assert device_spec.channel_count == metadata.channel_count( - ), "Channel count error" + assert device_spec.channel_count == metadata.channel_count(), "Channel count error" if device_spec.sample_rate != metadata.nominal_srate(): raise Exception("Sample frequency read from device does not match " @@ -86,11 +135,14 @@ def check_device(device_spec: DeviceSpec, metadata: pylsl.StreamInfo): def rename_items(items: List[str], rules: Dict[str, str]) -> None: - """Renames items based on the provided rules. - Parameters - ---------- - items - list of items ; values will be mutated - rules - change key -> value + """Renames items in a list based on a provided mapping of rules. + + The list of items is modified in place. + + Args: + items (List[str]): A list of strings whose values may be mutated. + rules (Dict[str, str]): A dictionary where keys are original item names + and values are their new names. """ for key, val in rules.items(): if key in items: diff --git a/bcipy/acquisition/protocols/lsl/lsl_recorder.py b/bcipy/acquisition/protocols/lsl/lsl_recorder.py index d80d2bade..f4e633f77 100644 --- a/bcipy/acquisition/protocols/lsl/lsl_recorder.py +++ b/bcipy/acquisition/protocols/lsl/lsl_recorder.py @@ -3,7 +3,7 @@ import time from multiprocessing import Queue from pathlib import Path -from typing import List, Optional +from typing import Any, List, Optional from pylsl import StreamInfo, StreamInlet, resolve_streams @@ -23,22 +23,30 @@ class LslRecorder: """Records LSL data to a datastore. Resolves streams when started. - Parameters: - ----------- - - path : location to store the recordings - - filenames : optional dict mapping device type to its raw data filename. - Devices without an entry will use a naming convention. + Args: + path (str): Location to store the recordings. + filenames (Optional[dict], optional): Optional dictionary mapping device + type to its raw data filename. + Devices without an entry will use a + naming convention. Defaults to None. """ - streams: List['LslRecordingThread'] = None + streams: Optional[List['LslRecordingThread']] = None def __init__(self, path: str, filenames: Optional[dict] = None) -> None: super().__init__() - self.path = path - self.filenames = filenames or {} + self.path: str = path + self.filenames: dict = filenames or {} def start(self) -> None: - """Start recording all streams currently on the network.""" + """Starts recording all LSL streams currently on the network. + + This method creates an `LslRecordingThread` for each discovered stream + and starts them. It also validates that stream names are unique. + + Raises: + Exception: If data stream names are not unique. + """ if not self.streams: log.info("Recording data") @@ -58,32 +66,40 @@ def start(self) -> None: stream.start() def stop(self, wait: bool = False) -> None: - """Stop recording. + """Stops recording for all active streams. - Parameters - ---------- - - wait : if True waits for all threads to stop before returning. + Args: + wait (bool, optional): If True, waits for all recording threads + to stop before returning. Defaults to False. """ - for stream in self.streams: - stream.stop() - if wait: - stream.join() - self.streams = None + if self.streams: + for stream in self.streams: + stream.stop() + if wait: + stream.join() + self.streams = None class LslRecordingThread(StoppableProcess): """Records data for the given LabStreamingLayer (LSL) data stream. - Parameters: - ---------- - - device_spec : DeviceSpec ; specifies the device from which to record. - - directory : location to store the recording - - filename : optional, name of the data file. - - queue : optional multiprocessing queue; if provided the first_sample_time - will be written here when available. + This class extends `StoppableProcess` to run recording in a separate process. + + Args: + device_spec (DeviceSpec): Specifies the device from which to record. + directory (Optional[str], optional): Location to store the recording. + Defaults to '.'. + filename (Optional[str], optional): Optional name of the data file. + If None, a default filename based + on device properties will be used. + Defaults to None. + queue (Optional[Queue], optional): Optional multiprocessing queue. + If provided, the `first_sample_time` + will be written to this queue when available. + Defaults to None. """ - writer: RawDataWriter = None + writer: Optional[RawDataWriter] = None def __init__(self, device_spec: DeviceSpec, @@ -92,30 +108,41 @@ def __init__(self, queue: Optional[Queue] = None) -> None: super().__init__() - self.directory = directory - self.device_spec = device_spec - self.queue = queue + self.directory: Optional[str] = directory + self.device_spec: DeviceSpec = device_spec + self.queue: Optional[Queue] = queue - self.sample_count = 0 + self.sample_count: int = 0 # see: https://labstreaminglayer.readthedocs.io/info/faqs.html#chunk-sizes - self.max_chunk_size = 1024 + self.max_chunk_size: int = 1024 # seconds to sleep between data pulls from LSL - self.sleep_seconds = 0.2 + self.sleep_seconds: float = 0.2 - self.filename = filename if filename else self.default_filename() - self.first_sample_time = None - self.last_sample_time = None + self.filename: str = filename if filename else self.default_filename() + self.first_sample_time: Optional[float] = None + self.last_sample_time: Optional[float] = None + + def default_filename(self) -> str: + """Generates a default filename to use if a name is not provided. + + The filename is based on the device's content type and name. - def default_filename(self): - """Default filename to use if a name is not provided.""" + Returns: + str: The generated default filename (e.g., "eeg_data_dsi_24.csv"). + """ content_type = '_'.join(self.device_spec.content_type.split()).lower() name = '_'.join(self.device_spec.name.split()).lower() return f"{content_type}_data_{name}.csv" @property def recorded_seconds(self) -> float: - """Total seconds of data recorded.""" + """Calculates the total seconds of data recorded. + + Returns: + float: The duration of recorded data in seconds, or 0.0 if recording + hasn't started or completed. + """ if self.first_sample_time and self.last_sample_time: return self.last_sample_time - self.first_sample_time return 0.0 @@ -123,9 +150,11 @@ def recorded_seconds(self) -> float: def _init_data_writer(self, stream_info: StreamInfo) -> None: """Initializes the raw data writer. - Parameters: - ---------- - - metadata : metadata about the data stream. + Args: + stream_info (StreamInfo): Metadata about the data stream. + + Raises: + AssertionError: If the data writer has already been initialized. """ assert self.writer is None, "Data store has already been initialized." @@ -135,7 +164,7 @@ def _init_data_writer(self, stream_info: StreamInfo) -> None: check_device(self.device_spec, stream_info) channels = self.device_spec.channels - path = str(Path(self.directory, self.filename)) + path = str(Path(self.directory, self.filename)) # type: ignore log.info(f"Writing data to {path}") self.writer = RawDataWriter( path, @@ -145,37 +174,38 @@ def _init_data_writer(self, stream_info: StreamInfo) -> None: self.writer.__enter__() def _cleanup(self) -> None: - """Performs cleanup tasks.""" + """Performs cleanup tasks for the data writer. + + Closes the `RawDataWriter` if it was initialized. + """ if self.writer: self.writer.__exit__() self.writer = None - def _write_chunk(self, data: List, timestamps: List) -> None: - """Persists the data resulting from pulling a chunk from the inlet. + def _write_chunk(self, data: List[List[Any]], timestamps: List[float]) -> None: + """Persists a chunk of data pulled from the LSL inlet. - Parameters - ---------- - data : list of samples - timestamps : list of timestamps + Args: + data (List[List[Any]]): A list of samples, where each sample is a list of channel values. + timestamps (List[float]): A list of timestamps corresponding to each sample. """ assert self.writer, "Writer not initialized" - chunk = [] + chunk: List[List[Any]] = [] for i, sample in enumerate(data): self.sample_count += 1 chunk.append([self.sample_count] + sample + [timestamps[i]]) self.writer.writerows(chunk) def _pull_chunk(self, inlet: StreamInlet) -> int: - """Pull a chunk of data and persist. Updates first_sample_time, - last_sample_time, and sample_count. + """Pulls a chunk of data from the `StreamInlet` and persists it. + + Updates `first_sample_time`, `last_sample_time`, and `sample_count`. - Parameters - ---------- - inlet : stream inlet from which to pull + Args: + inlet (StreamInlet): The LSL `StreamInlet` from which to pull data. - Returns - ------- - number of samples pulled + Returns: + int: The number of samples pulled in this operation. """ # A timeout of 0.0 does not block and only gets samples immediately # available. @@ -191,14 +221,20 @@ def _pull_chunk(self, inlet: StreamInlet) -> int: return len(timestamps) def _reset(self) -> None: - """Reset state""" + """Resets the internal state of the recorder. + + This includes resetting the sample count and clearing the first and last + sample timestamps. + """ self.sample_count = 0 self.first_sample_time = None self.last_sample_time = None # @override - def run(self): - """Process startup. Connects to the device, reads chunks of data at the + def run(self) -> None: + """Process startup and main recording loop. + + Connects to the device, continuously reads chunks of data at the given interval, and persists the results. This happens continuously until the `stop()` method is called. """ @@ -236,9 +272,19 @@ def run(self): self._cleanup() -def main(path: str, seconds: int = 5, debug: bool = False): - """Function to demo the LslRecorder. Expects LSL data streams to be already - running.""" +def main(path: str, seconds: int = 5, debug: bool = False) -> None: + """Demonstrates the `LslRecorder` functionality. + + This function initializes an `LslRecorder` and records data for a specified + duration. It expects LSL data streams to be already running. + + Args: + path (str): The directory path to save the recorded data. + seconds (int, optional): The duration in seconds to record data. + Defaults to 5. + debug (bool, optional): If True, enables logging to stdout for debugging. + Defaults to False. + """ if debug: log_to_stdout() recorder = LslRecorder(path) diff --git a/bcipy/acquisition/record.py b/bcipy/acquisition/record.py index 8d93a5a9c..14b331264 100644 --- a/bcipy/acquisition/record.py +++ b/bcipy/acquisition/record.py @@ -4,9 +4,17 @@ class Record(NamedTuple): - """Domain object used for storing data and timestamp - information, where data is a single reading from a device and is a list - of channel information (float).""" + """Domain object used for storing data and timestamp information. + + The `data` attribute represents a single reading from a device and is a list + of channel information (typically float values). + + Attributes: + data (List[Any]): A list of values representing channel information from a device. + timestamp (float): The timestamp associated with the data recording. + rownum (Optional[int], optional): The row number of the record, if applicable. + Defaults to None. + """ data: List[Any] timestamp: float rownum: Optional[int] = None diff --git a/bcipy/acquisition/tests/datastream/test_generator.py b/bcipy/acquisition/tests/datastream/test_generator.py index 82db8cc14..035e0a9f2 100644 --- a/bcipy/acquisition/tests/datastream/test_generator.py +++ b/bcipy/acquisition/tests/datastream/test_generator.py @@ -94,7 +94,8 @@ def test_file_generator_channel_count(self): with patch('bcipy.acquisition.datastream.generator.open', mock_open(read_data=test_data), create=True): - gen = file_data_generator(filename='foo', header_row=1, channel_count=2) + gen = file_data_generator( + filename='foo', header_row=1, channel_count=2) generated_data = [next(gen) for _ in range(row_count)] for i, row in enumerate(generated_data): @@ -163,7 +164,8 @@ def count_generator(low=0, high=10, step=1): self.assertEqual(1, next(gen2)) self.assertEqual(3, next(gen1)) - new_rand_gen = generator_with_args(random_data_generator, channel_count=10) + new_rand_gen = generator_with_args( + random_data_generator, channel_count=10) gen3 = new_rand_gen() data = next(gen3) self.assertEqual(10, len(data)) diff --git a/bcipy/acquisition/tests/test_devices.py b/bcipy/acquisition/tests/test_devices.py index dc86980cd..0b63a0976 100644 --- a/bcipy/acquisition/tests/test_devices.py +++ b/bcipy/acquisition/tests/test_devices.py @@ -26,7 +26,7 @@ def test_default_supported_devices(self): dsi = supported['DSI-24'] self.assertEqual('EEG', dsi.content_type) - self.assertEqual(len(devices.with_content_type('EEG')), 4) + self.assertEqual(len(devices.with_content_type('EEG')), 5) def test_load_from_config(self): """Should be able to load a list of supported devices from a @@ -142,7 +142,7 @@ def test_device_spec_defaults(self): """DeviceSpec should require minimal information with default values.""" spec = devices.DeviceSpec(name='TestDevice', channels=['C1', 'C2', 'C3'], - sample_rate=256.0) + sample_rate=256) self.assertEqual(3, spec.channel_count) self.assertEqual('EEG', spec.content_type) self.assertEqual(devices.DeviceStatus.ACTIVE, spec.status) @@ -151,7 +151,7 @@ def test_device_spec_analysis_channels(self): """DeviceSpec should have a list of channels used for analysis.""" spec = devices.DeviceSpec(name='TestDevice', channels=['C1', 'C2', 'C3', 'TRG'], - sample_rate=256.0, + sample_rate=256, excluded_from_analysis=['TRG']) self.assertEqual(['C1', 'C2', 'C3'], spec.analysis_channels) @@ -160,7 +160,7 @@ def test_device_spec_analysis_channels(self): spec2 = devices.DeviceSpec(name='Device2', channels=['C1', 'C2', 'C3', 'TRG'], - sample_rate=256.0, + sample_rate=256, excluded_from_analysis=['C1', 'TRG']) self.assertEqual(['C2', 'C3'], spec2.analysis_channels) @@ -178,7 +178,7 @@ def test_device_spec_analysis_channels(self): 'name': 'C4', 'label': 'TRG' }], - sample_rate=256.0, + sample_rate=256, excluded_from_analysis=['ch1', 'TRG']) self.assertEqual(['ch2', 'ch3'], spec3.analysis_channels) @@ -192,7 +192,7 @@ def test_irregular_sample_rate(self): with self.assertRaises(AssertionError): devices.DeviceSpec(name='Mouse', channels=['Btn1', 'Btn2'], - sample_rate=-100.0, + sample_rate=-100, content_type='Markers') def test_data_type(self): @@ -239,7 +239,7 @@ def test_device_spec_to_dict(self): 'type': None, 'units': None }] - sample_rate = 256.0 + sample_rate = 256 content_type = 'EEG' spec = devices.DeviceSpec(name=device_name, channels=channels, @@ -290,7 +290,7 @@ def test_load_static_offset(self): content_type="EEG", description="My Device", channels=["a", "b", "c"], - sample_rate=100.0, + sample_rate=100, status=str(devices.DeviceStatus.PASSIVE), static_offset=offset) ] diff --git a/bcipy/config.py b/bcipy/config.py index 9325e2447..ec31fd991 100644 --- a/bcipy/config.py +++ b/bcipy/config.py @@ -6,7 +6,8 @@ from pathlib import Path DEFAULT_ENCODING = 'utf-8' -DEFAULT_EVIDENCE_PRECISION = 5 # number of decimal places to round evidence to by default +# number of decimal places to round evidence to by default +DEFAULT_EVIDENCE_PRECISION = 5 MARKER_STREAM_NAME = 'TRG_device_stream' DEFAULT_TRIGGER_CHANNEL_NAME = 'TRG' DIODE_TRIGGER = '\u25A0' @@ -25,7 +26,7 @@ DEFAULT_EXPERIMENT_PATH = f'{BCIPY_ROOT}/parameters/experiment' DEFAULT_FIELD_PATH = f'{BCIPY_ROOT}/parameters/field' DEFAULT_USER_ID = 'test_user' -TASK_SEPERATOR = '->' +TASK_SEPARATOR = '->' DEFAULT_PARAMETERS_FILENAME = 'parameters.json' DEFAULT_DEVICES_PATH = f"{BCIPY_ROOT}/parameters" diff --git a/bcipy/core/demo/demo_report.py b/bcipy/core/demo/demo_report.py index c2d3bb4af..b4dfe46dc 100644 --- a/bcipy/core/demo/demo_report.py +++ b/bcipy/core/demo/demo_report.py @@ -40,14 +40,16 @@ # loop through the sessions, pausing after each one to allow for manual stopping if session.is_dir(): print(f'Processing {session}') - prompt = input('Hit enter to continue or type "skip" to skip processing: ') + prompt = input( + 'Hit enter to continue or type "skip" to skip processing: ') if prompt != 'skip': # load the parameters from the data directory parameters = load_json_parameters( f'{session}/{DEFAULT_PARAMETERS_FILENAME}', value_cast=True) # load the raw data from the data directory - raw_data = load_raw_data(Path(session, f'{RAW_DATA_FILENAME}.csv')) + raw_data = load_raw_data( + Path(session, f'{RAW_DATA_FILENAME}.csv')) type_amp = raw_data.daq_type channels = raw_data.channels sample_rate = raw_data.sample_rate @@ -72,7 +74,8 @@ trigger_type, trigger_timing, trigger_label = trigger_decoder( offset=parameters.get('static_trigger_offset'), trigger_path=f"{session}/{TRIGGER_FILENAME}", - exclusion=[TriggerType.PREVIEW, TriggerType.EVENT, TriggerType.FIXATION], + exclusion=[TriggerType.PREVIEW, + TriggerType.EVENT, TriggerType.FIXATION], ) triggers = (trigger_type, trigger_timing, trigger_label) else: diff --git a/bcipy/core/demo/demo_session_tools.py b/bcipy/core/demo/demo_session_tools.py index 97b9bbcdd..daa09f337 100644 --- a/bcipy/core/demo/demo_session_tools.py +++ b/bcipy/core/demo/demo_session_tools.py @@ -48,6 +48,7 @@ def main(data_dir: str): if args.csv: session_csv(session, csv_file=str(Path(path, "session.csv"))) if args.charts: - session_excel(session, excel_file=str(Path(path, SESSION_SUMMARY_FILENAME))) + session_excel(session, excel_file=str( + Path(path, SESSION_SUMMARY_FILENAME))) else: main(path) diff --git a/bcipy/core/list.py b/bcipy/core/list.py index 64c643617..8bfeae51a 100644 --- a/bcipy/core/list.py +++ b/bcipy/core/list.py @@ -1,16 +1,20 @@ """Utility functions for list processing.""" from itertools import zip_longest -from typing import Any, Callable, List, Optional, Union +from typing import Any, Callable, Iterator, List, Optional, Tuple, Union def destutter(items: List[Any], key: Callable = lambda x: x) -> List: """Removes sequential duplicates from a list. Retains the last item in the - sequence. Equality is determined using the provided key function. + sequence. - Parameters - ---------- - items - list of items with sequential duplicates - key - equality function + Equality is determined using the provided key function. + + Args: + items (List[Any]): List of items with sequential duplicates. + key (Callable, optional): Equality function. Defaults to `lambda x: x`. + + Returns: + List: A new list with sequential duplicates removed. """ deduped: List[Any] = [] for item in items: @@ -21,10 +25,29 @@ def destutter(items: List[Any], key: Callable = lambda x: x) -> List: return deduped -def grouper(iterable, chunk_size, incomplete="fill", fillvalue=None): - "Collect data into non-overlapping fixed-length chunks or blocks" - # grouper('ABCDEFG', 3, fillvalue='x') --> ABC DEF Gxx - # grouper('ABCDEFG', 3, incomplete='ignore') --> ABC DEF +def grouper(iterable: Any, chunk_size: int, incomplete: str = "fill", + fillvalue: Optional[Any] = None) -> Union[Iterator[Tuple], Iterator[Any]]: + """Collect data into non-overlapping fixed-length chunks or blocks. + + Args: + iterable (Any): The iterable to group. + chunk_size (int): The size of each chunk. + incomplete (str, optional): Strategy for incomplete chunks. Can be "fill" or "ignore". + Defaults to "fill". + fillvalue (Optional[Any], optional): Value to fill incomplete chunks with if `incomplete` is "fill". + Defaults to None. + + Returns: + Union[Iterator[Tuple], Iterator[Any]]: An iterator yielding chunks. + + Raises: + ValueError: If `fillvalue` is not defined when `incomplete` is "fill", or if `incomplete` + is neither "fill" nor "ignore". + + Examples: + grouper('ABCDEFG', 3, fillvalue='x') --> ABC DEF Gxx + grouper('ABCDEFG', 3, incomplete='ignore') --> ABC DEF + """ chunks = [iter(iterable)] * chunk_size if incomplete == "fill": if fillvalue: @@ -39,7 +62,16 @@ def grouper(iterable, chunk_size, incomplete="fill", fillvalue=None): def find_index(iterable: List, match_item: Union[Any, Callable], key: Callable = lambda x: x) -> Optional[int]: - """Find the index of the first item in the iterable which matches.""" + """Find the index of the first item in the iterable which matches. + + Args: + iterable (List): The list to search through. + match_item (Union[Any, Callable]): The item to match or a callable to apply to each item. + key (Callable, optional): A function to apply to each item before comparison. Defaults to `lambda x: x`. + + Returns: + Optional[int]: The index of the first matching item, or None if no match is found. + """ for i, value in enumerate(iterable): if callable(match_item): result = match_item(value) @@ -51,8 +83,16 @@ def find_index(iterable: List, def swapped(lst: List[Any], index1: int, index2: int) -> List[Any]: - """Creates a copy of the provided list with elements at the given indices - swapped.""" + """Creates a copy of the provided list with elements at the given indices swapped. + + Args: + lst (List[Any]): The original list. + index1 (int): The index of the first element to swap. + index2 (int): The index of the second element to swap. + + Returns: + List[Any]: A new list with the elements at `index1` and `index2` swapped. + """ replacements = {index1: lst[index2], index2: lst[index1]} return [replacements.get(i, val) for i, val in enumerate(lst)] @@ -60,18 +100,22 @@ def swapped(lst: List[Any], index1: int, index2: int) -> List[Any]: def expanded(lst: List[Any], length: int, fill: Union[Any, Callable] = lambda x: x[-1]) -> List[Any]: - """Creates a copy of the provided list expanded to the given length. By - default the last item is used as the fill item. - - Parameters - ---------- - lst - list of items to copy - length - expands list to this length - fill - optional; used to determine which element to use for + """Creates a copy of the provided list expanded to the given length. + + By default, the last item is used as the fill item. + + Args: + lst (List[Any]): List of items to copy. + length (int): The target length to expand the list to. + fill (Union[Any, Callable], optional): Used to determine which element to use for the fill, given the list. Defaults to the last element. - >>> expand([1,2,3], length=5) - [1,2,3,3,3] + Returns: + List[Any]: The expanded list. + + Examples: + >>> expanded([1,2,3], length=5) + [1,2,3,3,3] """ times = length - len(lst) if lst and times > 0: @@ -80,10 +124,18 @@ def expanded(lst: List[Any], return lst -def pairwise(iterable): - """ - pairwise('ABCDEFG') → AB BC CD DE EF FG - https://docs.python.org/3/library/itertools.html#itertools.pairwise +def pairwise(iterable: Any) -> Iterator[Tuple]: + """Returns an iterator over overlapping pairs from the input iterable. + + Args: + iterable (Any): The iterable to process. + + Yields: + Tuple: A tuple containing two consecutive elements from the iterable. + + Examples: + pairwise('ABCDEFG') → AB BC CD DE EF FG + https://docs.python.org/3/library/itertools.html#itertools.pairwise """ iterator = iter(iterable) a = next(iterator, None) diff --git a/bcipy/core/parameters.py b/bcipy/core/parameters.py index dbcf03009..230b66960 100644 --- a/bcipy/core/parameters.py +++ b/bcipy/core/parameters.py @@ -4,13 +4,23 @@ from json import dump, load from pathlib import Path from re import fullmatch -from typing import Any, Dict, NamedTuple, Optional, Tuple +from typing import Any, Dict, List, NamedTuple, Optional, Tuple, Union from bcipy.config import DEFAULT_ENCODING, DEFAULT_PARAMETERS_PATH class Parameter(NamedTuple): - """Represents a single parameter""" + """Represents a single parameter." + + Attributes: + value (Any): The value of the parameter. + section (str): The section the parameter belongs to. + name (str): The display name of the parameter. + helpTip (str): A helpful tip or description for the parameter. + recommended (list): Recommended values for the parameter. + editable (bool): Whether the parameter is editable. + type (str): The data type of the parameter (e.g., 'int', 'float', 'bool', 'str', 'range'). + """ value: Any section: str name: str @@ -21,41 +31,62 @@ class Parameter(NamedTuple): class ParameterChange(NamedTuple): - """Represents a Parameter that has been modified from a different value.""" - parameter: Parameter + """Represents a Parameter that has been modified from a different value." + + Attributes: + parameter (Parameter): The modified parameter. + original_value (Any): The original value of the parameter before modification. + """ + parameter: Union[Parameter, dict] original_value: Any -def parse_range(range_str: str) -> Tuple: - """Parse the range description into a tuple of (low, high). +def parse_range(range_str: str) -> Tuple[Union[int, float], Union[int, float]]: + """Parses the range description into a tuple of (low, high). + + If either value can be parsed as a float, the resulting tuple will have + float values; otherwise, they will be integers. - If either value can be parsed as a float the resulting tuple will have - float values, otherwise they will be ints. + Args: + range_str (str): Range description formatted as 'low:high'. - Parameters - ---------- - range_str - range description formatted 'low:high' + Returns: + Tuple[Union[int, float], Union[int, float]]: A tuple containing the low and high values of the range. - >>> parse_range("1:10") - (1, 10) + Raises: + AssertionError: If the `range_str` is not in the format 'low:high' or if the low value is not less than the high value. + + Examples: + >>> parse_range("1:10") + (1, 10) """ assert ':' in range_str, "Invalid range format; values must be separated by ':'" - low, high = range_str.split(':') + low_str, high_str = range_str.split(':') int_pattern = "-?\\d+" - if fullmatch(int_pattern, low) and fullmatch(int_pattern, high): - low = int(low) - high = int(high) + if fullmatch(int_pattern, low_str) and fullmatch(int_pattern, high_str): + low: Union[int, float] = int(low_str) + high: Union[int, float] = int(high_str) else: - low = float(low) - high = float(high) + low = float(low_str) + high = float(high_str) assert low < high, "Low value must be less that the high value" return (low, high) def serialize_value(value_type: str, value: Any) -> str: - """Serialize the given value to a string. Serialized values should be able - to be cast using the conversions.""" + """Serializes the given value to a string. + + Serialized values should be able to be cast using the `conversions` dictionary + defined in the `Parameters` class. + + Args: + value_type (str): The declared type of the value (e.g., 'bool', 'range'). + value (Any): The value to serialize. + + Returns: + str: The serialized string representation of the value. + """ if value_type == 'bool': return str(value).lower() if value_type == 'range': @@ -67,12 +98,16 @@ def serialize_value(value_type: str, value: Any) -> str: class Parameters(dict): """Configuration parameters for BciPy. - source: str - optional path to a JSON file. If file exists, data will be - loaded from here. Raises an exception unless the entries are dicts with - the required_keys. + This class extends `dict` to provide type-casting and validation for + configuration parameters, typically loaded from a JSON file. - cast_values: bool - if True cast values to specified type; default is False. - """ + Args: + source (Optional[str], optional): Optional path to a JSON file. If the file exists, + data will be loaded from here. Raises an exception unless the entries are + dictionaries with the required keys. Defaults to None. + cast_values (bool, optional): If True, values will be cast to their specified type + when accessed. Defaults to False. + """ def __init__(self, source: Optional[str] = None, cast_values: bool = False): super().__init__() @@ -92,11 +127,19 @@ def __init__(self, source: Optional[str] = None, cast_values: bool = False): self.load_from_source() @classmethod - def from_cast_values(cls, **kwargs): - """Create a new Parameters object from cast values. This is useful - primarily for testing + def from_cast_values(cls, **kwargs: Any) -> 'Parameters': + """Creates a new `Parameters` object from cast values. + + This is useful primarily for testing. + + Args: + **kwargs (Any): Keyword arguments representing parameter names and their values. + + Returns: + Parameters: A new `Parameters` instance with values cast. - >>> Parameters.from_cast_values(time_prompt=1.0, fake_data=True) + Examples: + >>> Parameters.from_cast_values(time_prompt=1.0, fake_data=True) """ params = Parameters(source=None, cast_values=True) for key, val in kwargs.items(): @@ -118,29 +161,59 @@ def from_cast_values(cls, **kwargs): return params @property - def supported_types(self) -> set: - """Supported types for casting values""" - return self.conversions.keys() + def supported_types(self) -> list: + """Returns the set of supported types for casting values.""" + return list(self.conversions.keys()) - def cast_value(self, entry: dict) -> Any: - """Takes an entry with a desired type and attempts to cast it to that type.""" + def cast_value(self, entry: Dict[str, Any]) -> Any: + """Takes an entry with a desired type and attempts to cast it to that type. + + Args: + entry (Dict[str, Any]): A dictionary representing a parameter entry with a 'type' key. + + Returns: + Any: The value cast to the specified type. + """ cast = self.conversions[entry['type']] - return cast(entry['value']) + return cast(entry['value']) # type: ignore + + def serialized_value(self, value: Any, entry_type: str) -> str: + """Converts a value back into its serialized string form." - def serialized_value(self, value, entry_type) -> str: - """Convert a value back into its serialized form""" + Args: + value (Any): The value to serialize. + entry_type (str): The declared type of the value. + + Returns: + str: The serialized string representation of the value. + """ serialized = str(value) return serialized.lower() if entry_type == 'bool' else serialized - def __getitem__(self, key) -> Any: - """Override to handle cast values""" + def __getitem__(self, key: str) -> Any: + """Overrides dictionary item access to handle cast values." + + Args: + key (str): The key of the parameter to retrieve. + + Returns: + Any: The cast value of the parameter if `cast_values` is True, otherwise the raw entry dictionary. + """ entry = self.get_entry(key) if self.cast_values: return self.cast_value(entry) return entry - def __setitem__(self, key, value) -> None: - """Override to handle cast values""" + def __setitem__(self, key: str, value: Any) -> None: + """Overrides dictionary item assignment to handle cast values and validate entries." + + If `cast_values` is True, it attempts to set the serialized value for an existing entry. + Otherwise, it adds a new entry after validation. + + Args: + key (str): The key of the parameter to set. + value (Any): The value to set for the parameter. + """ if self.cast_values: # Can only set values for existing entries when cast. entry = self.get_entry(key) @@ -148,72 +221,119 @@ def __setitem__(self, key, value) -> None: else: self.add_entry(key, value) - def add_entry(self, key, value) -> None: - """Adds a configuration parameter.""" + def add_entry(self, key: str, value: Dict[str, Any]) -> None: + """Adds a configuration parameter after validating its format." + + Args: + key (str): The name of the configuration parameter. + value (Dict[str, Any]): A dictionary containing the parameter properties. + """ self.check_valid_entry(key, value) super().__setitem__(key, value) - def get_entry(self, key) -> dict: - """Get the non-cast entry associated with the given key.""" + def get_entry(self, key: str) -> Dict[str, Any]: + """Gets the non-cast entry associated with the given key." + + Args: + key (str): The key of the parameter entry to retrieve. + + Returns: + Dict[str, Any]: The raw dictionary entry for the parameter. + """ return super().__getitem__(key) - def get(self, key, d=None) -> Any: - """Override to handle cast values""" + def get(self, key: str, d: Optional[Any] = None) -> Any: + """Overrides dictionary `get` method to handle cast values." + + Args: + key (str): The key of the parameter to retrieve. + d (Optional[Any], optional): Default value to return if the key is not found. + Defaults to None. + + Returns: + Any: The cast value of the parameter if `cast_values` is True and the key is found, + otherwise the raw entry or the default value. + """ entry = super().get(key, d) if self.cast_values and entry != d: return self.cast_value(entry) return entry - def entries(self) -> list: - """Uncast items""" - return super().items() + def entries(self) -> List[Tuple[str, Dict[str, Any]]]: + """Returns the uncast items (key-value pairs) of the parameters. - def items(self) -> list: - """Override to handle cast values""" + Returns: + List[Tuple[str, Dict[str, Any]]]: A list of key-value tuples, where values are raw entry dictionaries. + """ + return list(super().items()) # type: ignore + + def items(self) -> List[Tuple[str, Any]]: # type: ignore + """Overrides dictionary `items` method to handle cast values. + + Returns: + List[Tuple[str, Any]]: A list of key-value tuples, where values are cast if `cast_values` is True. + """ if self.cast_values: return [(key, self.cast_value(entry)) for key, entry in self.entries()] return self.entries() - def values(self) -> list: - """Override to handle cast values""" + def values(self) -> List[Any]: # type: ignore + """Override to handle cast values. + + Returns: + List[Any]: A list of parameter values, cast if `cast_values` is True. + """ vals = super().values() if self.cast_values: return [self.cast_value(entry) for entry in vals] - return vals + return list(vals) # type: ignore - def update(self, *args, **kwargs) -> None: - """Override to ensure update uses __setitem___""" + def update(self, *args: Any, **kwargs: Any) -> None: + """Overrides dictionary `update` method to ensure `__setitem__` is used for each item." + + Args: + *args (Any): Positional arguments for dictionary update. + **kwargs (Any): Keyword arguments for dictionary update. + """ for key, value in dict(*args, **kwargs).items(): self[key] = value def copy(self) -> 'Parameters': - """Override + """Creates a shallow copy of the `Parameters` object." + + Returns: + Parameters: A new `Parameters` instance with the same parameters. """ params = Parameters(source=None, cast_values=self.cast_values) - params.load(super().copy()) + params.load(super().copy()) # type: ignore return params - def load(self, data: dict) -> None: - """Load values from a dict, validating entries (see check_valid_entry) and raising - an exception for invalid values. + def load(self, data: Dict[str, Dict[str, Any]]) -> None: + """Loads values from a dictionary, validating entries and raising an exception for invalid values." - data: dict of configuration parameters. + Args: + data (Dict[str, Dict[str, Any]]): A dictionary of configuration parameters. """ for name, entry in data.items(): self.add_entry(name, entry) def load_from_source(self) -> None: - """Load data from the configured JSON file.""" + """Loads data from the configured JSON file." + + If `self.source` is set, it attempts to open and load the JSON data from that path. + """ if self.source: with codecsopen(self.source, 'r', encoding=DEFAULT_ENCODING) as json_file: data = load(json_file) - self.load(data) + self.load(data) # type: ignore - def check_valid_entry(self, entry_name: str, entry: dict) -> None: - """Checks if the given entry is valid. Raises an exception unless the entry is formatted: + def check_valid_entry(self, entry_name: str, entry: Dict[str, Any]) -> None: + """Checks if the given entry is valid. Raises an exception unless the entry is formatted as expected." + Expected format: + ```json "fake_data": { "value": "true", "section": "bci_config", @@ -223,9 +343,16 @@ def check_valid_entry(self, entry_name: str, entry: dict) -> None: "editable": true, "type": "bool" } + ``` + + Args: + entry_name (str): Name of the configuration parameter. + entry (Dict[str, Any]): Parameter properties. - entry_name : str - name of the configuration parameter - entry : dict - parameter properties + Raises: + AttributeError: If `entry` is not a dictionary. + Exception: If `entry` does not contain required keys, if the 'type' is not supported, + or if the 'value' for a 'bool' type is invalid. """ if not isinstance(entry, abc.Mapping): raise AttributeError(f"'{entry_name}' value must be a dict") @@ -242,10 +369,11 @@ def check_valid_entry(self, entry_name: str, entry: dict) -> None: f"Invalid value for key: {entry_name}. Must be either 'true' or 'false'" ) - def source_location(self) -> Tuple[Path, str]: - """Location of the source json data if source was provided. + def source_location(self) -> Tuple[Optional[Path], Optional[str]]: + """Returns the location of the source JSON data if a source was provided." - Returns Tuple(Path, filename: str) + Returns: + Tuple[Optional[Path], Optional[str]]: A tuple containing the parent directory path and the filename. """ if self.source: path = Path(self.source) @@ -253,12 +381,17 @@ def source_location(self) -> Tuple[Path, str]: return (None, None) def save(self, directory: Optional[str] = None, name: Optional[str] = None) -> str: - """Save parameters to the given location + """Saves parameters to the given location." + + Args: + directory (Optional[str], optional): Optional location to save the file. Defaults to the source directory. + name (Optional[str], optional): Optional name of the new parameters file. Defaults to the source filename. - directory: str - optional location to save; default is the source_directory. - name: str - optional name of new parameters file; default is the source filename. + Returns: + str: The path of the saved file. - Returns the path of the saved file. + Raises: + AttributeError: If neither `directory` and `name` are provided nor a `source` path is set. """ if (not directory or not name) and not self.source: raise AttributeError('name and directory parameters are required') @@ -266,18 +399,22 @@ def save(self, directory: Optional[str] = None, name: Optional[str] = None) -> s source_directory, source_name = self.source_location() location = directory if directory else source_directory filename = name if name else source_name - path = Path(location, filename) + path = Path(location, filename) # type: ignore with open(path, 'w', encoding=DEFAULT_ENCODING) as json_file: - dump(dict(self.entries()), json_file, ensure_ascii=False, indent=2) + dump(dict(self.entries()), json_file, + ensure_ascii=False, indent=2) # type: ignore return str(path) - def add_missing_items(self, parameters) -> bool: - """Given another Parameters instance, add any items that are not already - present. Existing items will not be updated. + def add_missing_items(self, parameters: 'Parameters') -> bool: + """Given another `Parameters` instance, adds any items that are not already present. - parameters: Parameters - object from which to add parameters. + Existing items will not be updated. - Returns bool indicating whether or not any new items were added. + Args: + parameters (Parameters): Object from which to add parameters. + + Returns: + bool: True if any new items were added, False otherwise. """ updated = False existing_keys = self.keys() @@ -287,17 +424,20 @@ def add_missing_items(self, parameters) -> bool: updated = True return updated - def diff(self, parameters) -> Dict[str, ParameterChange]: + def diff(self, parameters: 'Parameters') -> Dict[str, ParameterChange]: """Lists the differences between this and another set of parameters. A None original_value indicates a new parameter. - Parameters - ---------- - parameters : Parameters - set of parameters for comparison; these + Args: + parameters (Parameters): Set of parameters for comparison; these are considered the original values and the current set the changed values. + + Returns: + Dict[str, ParameterChange]: A dictionary where keys are parameter names + and values are ParameterChange objects. """ - diffs = {} + diffs: Dict[str, ParameterChange] = {} for key, param in self.entries(): if key in parameters.keys(): @@ -310,9 +450,15 @@ def diff(self, parameters) -> Dict[str, ParameterChange]: original_value=None) return diffs - def instantiate(self, named_tuple_class: NamedTuple) -> NamedTuple: - """Instantiate a namedtuple whose fields represent a subset of the - parameters.""" + def instantiate(self, named_tuple_class: type[NamedTuple]) -> NamedTuple: + """Instantiates a `NamedTuple` whose fields represent a subset of the parameters." + + Args: + named_tuple_class (type[NamedTuple]): The `NamedTuple` class to instantiate. + + Returns: + NamedTuple: An instance of the provided `NamedTuple` class with values populated from parameters. + """ vals = [ self.cast_value(self.get_entry(key)) for key in named_tuple_class._fields @@ -321,12 +467,14 @@ def instantiate(self, named_tuple_class: NamedTuple) -> NamedTuple: def changes_from_default(source: str) -> Dict[str, ParameterChange]: - """Determines which parameters have changed from the default params. + """Determines which parameters have changed from the default parameters. - Parameters - ---------- - source - path to the parameters json file that will be compared with + Args: + source (str): Path to the parameters JSON file that will be compared with the default parameters. + + Returns: + Dict[str, ParameterChange]: A dictionary of `ParameterChange` objects representing the differences. """ default = Parameters(source=DEFAULT_PARAMETERS_PATH, cast_values=True) params = Parameters(source=source, cast_values=True) diff --git a/bcipy/core/raw_data.py b/bcipy/core/raw_data.py index 60f04d728..6a05e2da6 100644 --- a/bcipy/core/raw_data.py +++ b/bcipy/core/raw_data.py @@ -15,10 +15,25 @@ class RawData: - """Represents the raw data format used by BciPy. Used primarily for loading - a raw data file into memory.""" + """Represents the raw data format used by BciPy. + + This class is used primarily for loading a raw data file into memory. It provides + methods to access and manipulate the data in various formats. + + Attributes: + daq_type (str): Type of data acquisition device. + sample_rate (int): Sample rate in Hz. + columns (List[str]): List of column names in the data. + """ def __init__(self, daq_type: str, sample_rate: int, columns: List[str]) -> None: + """Initialize RawData. + + Args: + daq_type (str): Type of data acquisition device. + sample_rate (int): Sample rate in Hz. + columns (List[str]): List of column names in the data. + """ self.daq_type = daq_type self.sample_rate = sample_rate self.columns = columns @@ -27,44 +42,70 @@ def __init__(self, daq_type: str, sample_rate: int, columns: List[str]) -> None: self._rows: List[Any] = [] @classmethod - def load(cls, filename: str): + def load(cls, filename: str) -> 'RawData': """Constructs a RawData object by deserializing the given file. + All data will be read into memory. If you want to lazily read data one record at a time, use a RawDataReader. - Parameters - ---------- - - filename : path to the csv file to read + Args: + filename (str): Path to the csv file to read. + + Returns: + RawData: A new RawData instance containing the loaded data. """ return load(filename) @property def rows(self) -> List[List]: - """Returns the data rows""" + """Returns the data rows. + + Returns: + List[List]: List of data rows. + """ return self._rows @rows.setter def rows(self, value: Any) -> None: + """Sets the data rows and invalidates the cached dataframe. + + Args: + value (Any): New data rows to set. + """ self._rows = value self._dataframe = None @property def channels(self) -> List[str]: - """Compute the list of channels. Channels are the numeric columns - excluding the timestamp column.""" + """Compute the list of channels. + + Channels are the numeric columns excluding the timestamp column. + Returns: + List[str]: List of channel names. + """ # Start data slice at 1 to remove the timestamp column. return list(self.numeric_data.columns[1:]) @property def numeric_data(self) -> pd.DataFrame: - """Data for columns with numeric data. This is usually comprised of the - timestamp column and device channels, excluding string triggers.""" + """Data for columns with numeric data. + + This is usually comprised of the timestamp column and device channels, + excluding string triggers. + + Returns: + pd.DataFrame: DataFrame containing only numeric columns. + """ return self.dataframe.select_dtypes(exclude=['object']) @property def channel_data(self) -> np.ndarray: - """Data for columns with numeric data, excluding the timestamp column.""" + """Data for columns with numeric data, excluding the timestamp column. + + Returns: + np.ndarray: Array of channel data with shape (channels, samples). + """ numeric_data = self.numeric_data numeric_vals = numeric_data.values @@ -79,12 +120,15 @@ def by_channel(self, transform: Optional[Composition] = None) -> Tuple[np.ndarra This will apply n tranformations to the data before returning. For an example Composition with EEG preprocessing, see bcipy.signal.get_default_transform(). - Returns - ---------- - data: C x N numpy array with samples where C is the number of channels and N - is number of time samples - fs: resulting sample rate if any transformations applied""" + Args: + transform (Optional[Composition]): Optional transformation to apply to the data. + Returns: + Tuple[np.ndarray, int]: A tuple containing: + - data: C x N numpy array with samples where C is the number of channels and N + is number of time samples + - fs: resulting sample rate if any transformations applied + """ data = self.channel_data fs = self.sample_rate @@ -97,21 +141,29 @@ def by_channel_map( self, channel_map: List[int], transform: Optional[Composition] = None) -> Tuple[np.ndarray, List[str], int]: - """By Channel Map. + """Returns channels with columns removed if index in list (channel_map) is zero. - Returns channels with columns removed if index in list (channel_map) is zero. The channel map must align - with the numeric channels read in as self.channels. We assume most trigger or other string columns are - removed, however some are numeric trigger columns from devices that will require filtering before returning + The channel map must align with the numeric channels read in as self.channels. + We assume most trigger or other string columns are removed, however some are + numeric trigger columns from devices that will require filtering before returning data. Other cases could be dropping bad channels before running further analyses. - Optionally, it can apply a BciPy Composition to the data before returning using the transform arg. - This will apply n tranformations to the data before returning. For an example Composition with - EEG preprocessing, see bcipy.signal.get_default_transform(). + Args: + channel_map (List[int]): List of 1s and 0s indicating which channels to keep. + transform (Optional[Composition]): Optional transformation to apply to the data. + + Returns: + Tuple[np.ndarray, List[str], int]: A tuple containing: + - data: Array of channel data with shape (channels, samples) + - channels: List of channel names + - fs: Sample rate """ data, fs = self.by_channel(transform) - channels_to_remove = [idx for idx, value in enumerate(channel_map) if value == 0] + channels_to_remove = [idx for idx, + value in enumerate(channel_map) if value == 0] data = np.delete(data, channels_to_remove, axis=0) - channels: List[str] = np.delete(self.channels, channels_to_remove, axis=0).tolist() + channels: List[str] = np.delete( + self.channels, channels_to_remove, axis=0).tolist() return data, channels, fs @@ -119,13 +171,26 @@ def apply_transform(self, data: np.ndarray, transform: Composition) -> Tuple[np. """Apply Transform. Using data provided as an np.ndarray, call the Composition with self.sample_rate to apply - transformations to the data. This will return the transformed data and resulting sample rate. + transformations to the data. This will return the transformed data and resulting sample rate. + + Args: + data (np.ndarray): Input data to transform. + transform (Composition): Transformation to apply. + + Returns: + Tuple[np.ndarray, int]: A tuple containing: + - data: Transformed data + - fs: Resulting sample rate """ return transform(data, self.sample_rate) @property def dataframe(self) -> pd.DataFrame: - """Returns a dataframe of the row data.""" + """Returns a dataframe of the row data. + + Returns: + pd.DataFrame: DataFrame containing all data. + """ if self._dataframe is None: self._dataframe = pd.DataFrame(data=self.rows, columns=self.columns) @@ -133,15 +198,24 @@ def dataframe(self) -> pd.DataFrame: @property def total_seconds(self) -> float: - """Total recorded seconds, defined as the diff between the first and - last timestamp.""" + """Total recorded seconds. + + Defined as the diff between the first and last timestamp. + + Returns: + float: Total duration in seconds. + """ frame = self.dataframe col = 'lsl_timestamp' return frame.iloc[-1][col] - frame.iloc[0][col] @property def total_samples(self) -> int: - """Number of samples recorded.""" + """Number of samples recorded. + + Returns: + int: Total number of samples. + """ return int(self.dataframe.iloc[-1]['timestamp']) def query(self, @@ -150,15 +224,13 @@ def query(self, column: str = 'lsl_timestamp') -> pd.DataFrame: """Query for a subset of data. - Pararameters - ------------ - start - find records greater than or equal to this value - stop - find records less than or equal to this value - column - column to compare to the given start and stop values + Args: + start (Optional[float]): Find records greater than or equal to this value. + stop (Optional[float]): Find records less than or equal to this value. + column (str): Column to compare to the given start and stop values. - Returns - ------- - Dataframe for the given slice of data + Returns: + pd.DataFrame: DataFrame for the given slice of data. """ dataframe = self.dataframe start = start or dataframe.iloc[0][column] @@ -169,24 +241,41 @@ def query(self, def append(self, row: list) -> None: """Append the given row of data. - Parameters - ---------- - - row : row of data + Args: + row (list): Row of data to append. """ assert len(row) == len(self.columns), "Wrong number of columns" self._rows.append(row) self._dataframe = None def __str__(self) -> str: + """String representation of RawData. + + Returns: + str: String representation. + """ return f"RawData({self.daq_type})" def __repr__(self) -> str: + """String representation of RawData. + + Returns: + str: String representation. + """ return f"RawData({self.daq_type})" def maybe_float(val: Any) -> Union[float, Any]: - """Attempt to convert the given value to float. If conversion fails return - as is.""" + """Attempt to convert the given value to float. + + If conversion fails return the value as is. + + Args: + val (Any): Value to convert to float. + + Returns: + Union[float, Any]: Float if conversion succeeds, original value otherwise. + """ try: return float(val) except ValueError: @@ -194,47 +283,74 @@ def maybe_float(val: Any) -> Union[float, Any]: class RawDataReader: - """Lazily reads raw data from a file. Intended to be used as a ContextManager - using Python's `with` keyword. + """Lazily reads raw data from a file. - Example usage: + Intended to be used as a ContextManager using Python's `with` keyword. - ``` - with RawDataReader(path) as reader: - print(f"Data from ${reader.daq_type}") - print(reader.columns) - for row in reader: - print(row) - ``` - - Parameters - ---------- - - file_path : path to the csv file - - convert_data : if True attempts to convert data values to floats; - default is False + Example usage: + ``` + with RawDataReader(path) as reader: + print(f"Data from ${reader.daq_type}") + print(reader.columns) + for row in reader: + print(row) + ``` + + Attributes: + file_path (str): Path to the csv file. + convert_data (bool): If True attempts to convert data values to floats. + daq_type (str): Type of data acquisition device. + sample_rate (int): Sample rate in Hz. + columns (List[str]): List of column names. """ _file_obj: TextIOWrapper def __init__(self, file_path: str, convert_data: bool = False): + """Initialize RawDataReader. + + Args: + file_path (str): Path to the csv file. + convert_data (bool, optional): If True attempts to convert data values to floats. + Defaults to False. + """ self.file_path = file_path self.convert_data = convert_data - def __enter__(self): - self._file_obj = open(self.file_path, mode="r", encoding=DEFAULT_ENCODING) + def __enter__(self) -> 'RawDataReader': + """Enter the context manager. + + Returns: + RawDataReader: Self. + """ + self._file_obj = open(self.file_path, mode="r", + encoding=DEFAULT_ENCODING) self.daq_type, self.sample_rate = read_metadata(self._file_obj) self._reader = csv.reader(self._file_obj) self.columns = next(self._reader) return self - def __exit__(self, *args, **kwargs): - """Exit the context manager. Close resources""" + def __exit__(self, *args, **kwargs) -> None: + """Exit the context manager. Close resources.""" if self._file_obj: self._file_obj.close() - def __iter__(self): + def __iter__(self) -> 'RawDataReader': + """Return self as iterator. + + Returns: + RawDataReader: Self. + """ return self - def __next__(self): + def __next__(self) -> List[Any]: + """Get next row of data. + + Returns: + List[Any]: Next row of data. + + Raises: + AssertionError: If reader is not initialized. + """ assert self._reader, "Reader must be initialized" row = next(self._reader) if self.convert_data: @@ -243,36 +359,46 @@ def __next__(self): class RawDataWriter: - """Writes a raw data file one row at a time without storing the records - in memory. Intended to be used as a ContextManager using Python's `with` - keyword. + """Writes a raw data file one row at a time without storing the records in memory. - Example usage: + Intended to be used as a ContextManager using Python's `with` keyword. - ``` - with RawDataWriter(path, daq_type, sample_rate, columns) as writer: - for row in data: - writer.writerow(row) - ``` - - Parameters - ---------- - - file_path : path to the csv file - - daq_type : name of device - - sample_rate : sample frequency in Hz - - columns : list of column names + Example usage: + ``` + with RawDataWriter(path, daq_type, sample_rate, columns) as writer: + for row in data: + writer.writerow(row) + ``` + + Attributes: + file_path (str): Path to the csv file. + daq_type (str): Name of device. + sample_rate (float): Sample frequency in Hz. + columns (List[str]): List of column names. """ _file_obj: TextIOWrapper def __init__(self, file_path: str, daq_type: str, sample_rate: float, columns: List[str]) -> None: + """Initialize RawDataWriter. + + Args: + file_path (str): Path to the csv file. + daq_type (str): Name of device. + sample_rate (float): Sample frequency in Hz. + columns (List[str]): List of column names. + """ self.file_path = file_path self.daq_type = daq_type self.sample_rate = sample_rate self.columns = columns - def __enter__(self): - """Enter the context manager. Initializes the underlying data file.""" + def __enter__(self) -> 'RawDataWriter': + """Enter the context manager. Initializes the underlying data file. + + Returns: + RawDataWriter: Self. + """ self._file_obj = open(self.file_path, mode='w', encoding=DEFAULT_ENCODING, @@ -289,28 +415,47 @@ def __enter__(self): return self - def __exit__(self, *args, **kwargs): - """Exit the context manager. Close resources""" + def __exit__(self, *args, **kwargs) -> None: + """Exit the context manager. Close resources.""" self._file_obj.close() def writerow(self, row: List) -> None: + """Write a single row of data. + + Args: + row (List): Row of data to write. + + Raises: + AssertionError: If writer is not initialized or row has wrong number of columns. + """ assert self._csv_writer, "Writer must be initialized" assert len(row) == len(self.columns), "Wrong number of columns" self._csv_writer.writerow(row) def writerows(self, rows: List[List]) -> None: + """Write multiple rows of data. + + Args: + rows (List[List]): Rows of data to write. + """ for row in rows: self.writerow(row) def load(filename: str) -> RawData: """Reads the file at the given path and initializes a RawData object. + All data will be read into memory. If you want to lazily read data one record at a time, use a RawDataReader. - Parameters - ---------- - - filename : path to the csv file to read + Args: + filename (str): Path to the csv file to read. + + Returns: + RawData: A new RawData instance containing the loaded data. + + Raises: + BciPyCoreException: If the file is not found. """ # Loading all data from a csv is faster using pandas than using the # RawDataReader. @@ -322,33 +467,32 @@ def load(filename: str) -> RawData: data.rows = dataframe.values.tolist() return data except FileNotFoundError: - raise BciPyCoreException(f"\nError loading BciPy RawData. Valid data not found at: {filename}") + raise BciPyCoreException( + f"\nError loading BciPy RawData. Valid data not found at: {filename}") def read_metadata(file_obj: TextIO) -> Tuple[str, int]: - """Reads the metadata from an open raw data file and retuns the result as - a tuple. Increments the reader. + """Reads the metadata from an open raw data file and returns the result as a tuple. + + Increments the reader. - Parameters - ---------- - - file_obj : open TextIO object + Args: + file_obj (TextIO): Open TextIO object. - Returns - ------- - tuple of daq_type, sample_rate + Returns: + Tuple[str, int]: Tuple of (daq_type, sample_rate). """ daq_type = next(file_obj).strip().split(',')[1] sample_rate = int(float(next(file_obj).strip().split(",")[1])) return daq_type, sample_rate -def write(data: RawData, filename: str): +def write(data: RawData, filename: str) -> None: """Write the given raw data file. - Parameters - ---------- - - data : raw data object to write - - filename : path to destination file. + Args: + data (RawData): Raw data object to write. + filename (str): Path to destination file. """ with RawDataWriter(filename, data.daq_type, data.sample_rate, data.columns) as writer: @@ -357,17 +501,14 @@ def write(data: RawData, filename: str): def settings(filename: str) -> Tuple[str, float, List[str]]: - """Read the daq settings from the given data file + """Read the daq settings from the given data file. - Parameters - ---------- - - filename : path to the raw data file (csv) + Args: + filename (str): Path to the raw data file (csv). - Returns - ------- - tuple of (acquisition type, sample_rate, columns) + Returns: + Tuple[str, float, List[str]]: Tuple of (acquisition type, sample_rate, columns). """ - with RawDataReader(filename) as reader: return reader.daq_type, reader.sample_rate, reader.columns @@ -377,15 +518,21 @@ def sample_data(rows: int = 1000, daq_type: str = 'SampleDevice', sample_rate: int = 256, triggers: List[Tuple[float, str]] = []) -> RawData: - """Creates sample data to be written as a raw_data.csv file. The resulting data has - a column for the timestamp, one for each channel, and a TRG column. - - - rows : number of sample rows to generate - - ch_names : list of channel names - - daq_type : metadata for the device name - - sample_rate : device sample rate in hz - - triggers : List of (timestamp, trigger_value) tuples to be inserted - in the data. + """Creates sample data to be written as a raw_data.csv file. + + The resulting data has a column for the timestamp, one for each channel, + and a TRG column. + + Args: + rows (int, optional): Number of sample rows to generate. Defaults to 1000. + ch_names (List[str], optional): List of channel names. Defaults to ['ch1', 'ch2', 'ch3']. + daq_type (str, optional): Metadata for the device name. Defaults to 'SampleDevice'. + sample_rate (int, optional): Device sample rate in hz. Defaults to 256. + triggers (List[Tuple[float, str]], optional): List of (timestamp, trigger_value) tuples + to be inserted in the data. Defaults to []. + + Returns: + RawData: A new RawData instance containing the sample data. """ channels = [name for name in ch_names if name != 'TRG'] columns = [TIMESTAMP_COLUMN] + channels + ['TRG'] @@ -407,12 +554,12 @@ def sample_data(rows: int = 1000, def get_1020_channels() -> List[str]: """Returns the standard 10-20 channel names. - Note: The 10-20 system is a standard for EEG electrode placement. The following is not a complete list of all - possible channels, but the most common ones used in BCI research. This excludes the reference and ground channels. + Note: The 10-20 system is a standard for EEG electrode placement. The following + is not a complete list of all possible channels, but the most common ones used + in BCI research. This excludes the reference and ground channels. - Returns - ------- - list of channel names + Returns: + List[str]: List of channel names. """ return [ 'Fp1', 'Fp2', 'F7', 'F3', 'Fz', 'F4', 'F8', 'T3', 'C3', 'Cz', 'C4', @@ -424,13 +571,11 @@ def get_1020_channels() -> List[str]: def get_1020_channel_map(channels_name: List[str]) -> List[int]: """Returns a list of 1s and 0s indicating if the channel name is in the 10-20 system. - Parameters - ---------- - channels_name : list of channel names + Args: + channels_name (List[str]): List of channel names. - Returns - ------- - list of 1s and 0s indicating if the channel name is in the 10-20 system + Returns: + List[int]: List of 1s and 0s indicating if the channel name is in the 10-20 system. """ valid_channels = get_1020_channels() return [1 if name in valid_channels else 0 for name in channels_name] diff --git a/bcipy/core/report.py b/bcipy/core/report.py index 1ce5504be..25e9e7c4f 100644 --- a/bcipy/core/report.py +++ b/bcipy/core/report.py @@ -1,7 +1,7 @@ # mypy: disable-error-code="union-attr" import io from abc import ABC -from typing import List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple from matplotlib import pyplot as plt from matplotlib.figure import Figure @@ -16,35 +16,58 @@ class ReportSection(ABC): - """Report Section. + """Abstract base class for report sections in BciPy. - An abstract class to handle the creation of a section in a BciPy Report. + This class defines the interface for creating sections in a BciPy Report. + All report sections must implement the `compile` method to generate their content. """ def compile(self) -> Flowable: - """Compile. + """Compile the section into a flowable for the report. - This method must be implemented by the child class. - It is intented to be called on final Report build, - as opposed to immediatley after class initiatlization, - to compile the section into a usuable flowable for a Report. + This method must be implemented by child classes. It is intended to be called + during final report build, not immediately after class initialization. + + Returns: + Flowable: A reportlab flowable object containing the section's content. """ ... def _create_header(self) -> Flowable: + """Create the header for the section. + + Returns: + Flowable: A reportlab flowable object containing the section header. + """ ... class SignalReportSection(ReportSection): - """Signal Report Section. + """Section for displaying signal analysis results in a BciPy Report. - A class to handle the creation of a Signal Report section in a BciPy Report. + This section can include signal figures and artifact detection results. + + Attributes: + figures (List[Figure]): List of matplotlib figures to include in the report. + report_flowables (List[Flowable]): List of reportlab flowables for the section. + artifact (Optional[ArtifactDetection]): Optional artifact detection results. + style: Reportlab style sheet for formatting. """ def __init__( self, figures: List[Figure], artifact: Optional[ArtifactDetection] = None) -> None: + """Initialize SignalReportSection. + + Args: + figures (List[Figure]): List of matplotlib figures to include. + artifact (Optional[ArtifactDetection], optional): Artifact detection results. + Defaults to None. + + Raises: + AssertionError: If artifact is provided but analysis is not complete. + """ self.figures = figures self.report_flowables: List[Flowable] = [] self.artifact = artifact @@ -55,9 +78,10 @@ def __init__( self.style = getSampleStyleSheet() def compile(self) -> Flowable: - """Compile. + """Compile the signal report section into a flowable. - Compiles the Signal Report sections into a flowable that can be used to generate a Report. + Returns: + Flowable: A reportlab flowable containing the compiled section. """ self.report_flowables.append(self._create_header()) if self.artifact: @@ -67,9 +91,10 @@ def compile(self) -> Flowable: return KeepTogether(self.report_flowables) def _create_artifact_section(self) -> Flowable: - """Create Artifact Section. + """Create a section displaying artifact detection results. - Creates a paragraph with the artifact information. This is only included if an artifact detection is provided. + Returns: + Flowable: A reportlab flowable containing artifact information and visualizations. """ artifact_report = [] artifacts_detected = self.artifact.dropped @@ -91,7 +116,8 @@ def _create_artifact_section(self) -> Flowable: if self.artifact.voltage_annotations: voltage_artifacts = f'Voltage Artifacts: {len(self.artifact.voltage_annotations)}' - voltage_section = Paragraph(voltage_artifacts, self.style['BodyText']) + voltage_section = Paragraph( + voltage_artifacts, self.style['BodyText']) artifact_report.append(voltage_section) # create a heatmap with the onset values of the voltage artifacts @@ -104,9 +130,15 @@ def _create_artifact_section(self) -> Flowable: return KeepTogether(artifact_report) def _create_heatmap(self, onsets: List[float], range: Tuple[float, float], type: str) -> Image: - """Create Heatmap. + """Create a heatmap visualization of artifact onsets. - Creates a heatmap image with the onset values of the voltage artifacts. + Args: + onsets (List[float]): List of artifact onset times. + range (Tuple[float, float]): Time range for the heatmap. + type (str): Type of artifact being visualized. + + Returns: + Image: A reportlab Image containing the heatmap. """ # create a heatmap with the onset values fig, ax = plt.subplots() @@ -120,19 +152,23 @@ def _create_heatmap(self, onsets: List[float], range: Tuple[float, float], type: return heatmap def _create_epochs_section(self) -> List[Image]: - """Create Epochs Section. + """Create a section containing all signal figures. - Creates a flowable image for each figure in the Signal Report. + Returns: + List[Image]: List of reportlab Images containing the signal figures. """ # create a flowable for each figure flowables = [self.convert_figure_to_image(fig) for fig in self.figures] return flowables def convert_figure_to_image(self, fig: Figure) -> Image: - """Convert Figure to Image. + """Convert a matplotlib figure to a reportlab Image. + + Args: + fig (Figure): Matplotlib figure to convert. - Converts a matplotlib figure to a reportlab Image. - retrieved from: https://nicd.org.uk/knowledge-hub/creating-pdf-reports-with-reportlab-and-pandas + Returns: + Image: A reportlab Image containing the figure. """ buf = io.BytesIO() fig.savefig(buf, format='png', dpi=300) @@ -141,42 +177,54 @@ def convert_figure_to_image(self, fig: Figure) -> Image: return Image(buf, x * inch, y * inch) def _create_header(self) -> Paragraph: - """Create Header. + """Create the header for the signal report section. - Creates a header for the Signal Report section. + Returns: + Paragraph: A reportlab Paragraph containing the section header. """ header = Paragraph('Signal Report', self.style['Heading3']) return header class SessionReportSection(ReportSection): - """Session Report Section. + """Section for displaying session summary information in a BciPy Report. - A class to handle the creation of a Session Report section in a BciPy Report using a summary dictionary. + Attributes: + summary (dict): Dictionary containing session summary information. + session_name (str): Name of the session or default name if not specified. + style: Reportlab style sheet for formatting. + summary_table (Optional[Flowable]): The compiled summary table. """ - def __init__(self, summary: dict) -> None: + def __init__(self, summary: Dict[str, Any]) -> None: + """Initialize SessionReportSection. + + Args: + summary (Dict[str, Any]): Dictionary containing session summary information. + """ self.summary = summary if 'task' in self.summary: self.session_name = self.summary['task'] else: self.session_name = 'Session Summary' self.style = getSampleStyleSheet() - self.summary_table = None + self.summary_table: Optional[Flowable] = None def compile(self) -> Flowable: - """Compile. + """Compile the session report section into a flowable. - Compiles the Session Report sections into a flowable that can be used to generate a Report. + Returns: + Flowable: A reportlab flowable containing the compiled section. """ summary_table = self._create_summary_flowable() self.summary_table = summary_table return summary_table def _create_summary_flowable(self) -> Flowable: - """Create Summary Flowable. + """Create a flowable containing the session summary. - Creates a flowable table with the summary dictionary. + Returns: + Flowable: A reportlab flowable containing the formatted summary. """ if self.summary: # split the summary keys and values into a list @@ -189,10 +237,15 @@ def _create_summary_flowable(self) -> Flowable: summary_list = self._create_summary_text(keys, values) return KeepTogether(summary_list) - def _create_summary_text(self, keys: list, values: list) -> List[Paragraph]: - """Create Summary Text. + def _create_summary_text(self, keys: List[str], values: List[Any]) -> List[Paragraph]: + """Create formatted text for the summary. + + Args: + keys (List[str]): List of summary keys. + values (List[Any]): List of summary values. - Creates a list of paragraphs with the keys and values from the provided summary. + Returns: + List[Paragraph]: List of reportlab Paragraphs containing the formatted summary. """ # create a table with the keys and values, adding a header table = [self._create_header()] @@ -202,18 +255,31 @@ def _create_summary_text(self, keys: list, values: list) -> List[Paragraph]: return table def _create_header(self) -> Paragraph: - """Create Header. + """Create the header for the session report section. - Creates a header for the Session Report section. + Returns: + Paragraph: A reportlab Paragraph containing the section header. """ - header = Paragraph(f'{self.session_name}', self.style['Heading3']) + header = Paragraph( + f'{self.session_name}', self.style['Heading3']) return header class Report: - """Report. - - A class to handle compiling and saving a BciPy Report after at least one session. + """Class for compiling and saving BciPy Reports. + + This class handles the creation of PDF reports containing multiple sections + of signal analysis and session information. + + Attributes: + DEFAULT_NAME (str): Default name for the report file. + sections (List[ReportSection]): List of report sections to include. + elements (List[Flowable]): List of reportlab flowables for the report. + name (str): Name of the report file. + path (str): Full path where the report will be saved. + document (SimpleDocTemplate): Reportlab document template. + styles: Reportlab style sheet for formatting. + header (Optional[Flowable]): Report header containing logo and title. """ DEFAULT_NAME: str = 'BciPyReport.pdf' @@ -223,6 +289,20 @@ def __init__(self, name: Optional[str] = None, sections: Optional[List[ReportSection]] = None, autocompile: bool = False): + """Initialize Report. + + Args: + save_path (str): Directory where the report will be saved. + name (Optional[str], optional): Name of the report file. Defaults to None. + sections (Optional[List[ReportSection]], optional): List of report sections. + Defaults to None. + autocompile (bool, optional): Whether to compile the report immediately. + Defaults to False. + + Raises: + AssertionError: If sections is not a list or contains invalid section types. + AssertionError: If name does not end with .pdf. + """ if sections: assert isinstance(sections, list), "Sections should be a list." assert all(isinstance(section, ReportSection) @@ -244,17 +324,15 @@ def __init__(self, self.compile() def add(self, section: ReportSection) -> None: - """Add. + """Add a section to the report. - Adds a ReportSection to the Report. + Args: + section (ReportSection): The section to add to the report. """ self.sections.append(section) def compile(self) -> None: - """Compile. - - Compiles the Report by adding the header and all sections to the elements list. - """ + """Compile the report by adding the header and all sections.""" if self.header is None: self._construct_report_header() header_group = KeepTogether(self.header) @@ -263,19 +341,17 @@ def compile(self) -> None: self.elements.append(section.compile()) def save(self) -> None: - """Save. - - Exports the Report to a PDF file. - """ + """Save the report as a PDF file.""" self.document.build(self.elements) def _construct_report_header(self) -> None: - """Construct Report Header. + """Construct the report header with logo and title. - Constructs the header for the Report. This should be called before adding any other elements. - The header should consist of the CAMBI logo and a report title. + Raises: + AssertionError: If called after other elements have been added. """ - assert len(self.elements) < 1, "The report header should be constructed before other elements" + assert len( + self.elements) < 1, "The report header should be constructed before other elements" report_title = Paragraph('BciPy Report', self.styles['Title']) logo = Image(BCIPY_FULL_LOGO_PATH, hAlign='LEFT', width=170, height=50) report_title.hAlign = 'CENTER' diff --git a/bcipy/core/session.py b/bcipy/core/session.py index 5fcce9267..7fa121251 100644 --- a/bcipy/core/session.py +++ b/bcipy/core/session.py @@ -3,38 +3,66 @@ import csv import itertools import json +import logging import os import sqlite3 from dataclasses import dataclass, fields -from typing import Any, Dict, List, Optional +from typing import Any, Dict, Iterator, List, Optional, Union import openpyxl from openpyxl.chart import BarChart, Reference from openpyxl.styles import PatternFill from openpyxl.styles.borders import BORDER_THIN, Border, Side from openpyxl.styles.colors import COLOR_INDEX +from openpyxl.worksheet.worksheet import Worksheet from bcipy.config import (DEFAULT_ENCODING, DEFAULT_PARAMETERS_FILENAME, - SESSION_DATA_FILENAME, SESSION_SUMMARY_FILENAME) + SESSION_DATA_FILENAME, SESSION_LOG_FILENAME, + SESSION_SUMMARY_FILENAME) from bcipy.io.load import load_json_parameters from bcipy.task.data import Session +# Configure logging +logger = logging.getLogger(SESSION_LOG_FILENAME) + BLACK = COLOR_INDEX[0] WHITE = COLOR_INDEX[1] YELLOW = COLOR_INDEX[5] def read_session(file_name: str = SESSION_DATA_FILENAME) -> Session: - """Read the session data from the given file.""" + """Read session data from a JSON file. + + Args: + file_name (str, optional): Path to the session data file. + Defaults to SESSION_DATA_FILENAME. + + Returns: + Session: A Session object containing the parsed data. + + Raises: + FileNotFoundError: If the specified file does not exist. + json.JSONDecodeError: If the file contains invalid JSON. + """ with open(file_name, 'r', encoding=DEFAULT_ENCODING) as json_file: return Session.from_dict(json.load(json_file)) -def session_data(data_dir: str) -> Dict: - """Returns a dict of session data transformed to map the alphabet letter - to the likelihood when presenting the evidence. Also removes attributes - not useful for debugging.""" +def session_data(data_dir: str) -> Dict[str, Any]: + """Transform session data to map alphabet letters to likelihood values. + + Args: + data_dir (str): Directory containing the session data and parameters. + + Returns: + Dict[str, Any]: Dictionary containing transformed session data with: + - Mapped alphabet letters to likelihood values + - Target text from parameters + - Removed debugging attributes + Raises: + FileNotFoundError: If required files are not found in data_dir. + """ parameters = load_json_parameters(os.path.join(data_dir, DEFAULT_PARAMETERS_FILENAME), value_cast=True) @@ -46,7 +74,22 @@ def session_data(data_dir: str) -> Dict: @dataclass(frozen=True) class EvidenceRecord: - """Record summarizing Inquiry evidence.""" + """Record summarizing Inquiry evidence. + + Attributes: + series (int): Series number. + inquiry (int): Inquiry number within the series. + stim (str): Stimulus (letter or icon). + lm (float): Language model probability. + eeg (float): EEG evidence value. + eye (float): Eye tracking evidence value. + btn (float): Button press evidence value. + cumulative (float): Cumulative likelihood value. + inq_position (Optional[int]): Position in inquiry sequence. + is_target (int): Whether this is the target (1) or not (0). + presented (int): Whether this was presented (1) or not (0). + above_threshold (int): Whether above decision threshold (1) or not (0). + """ series: int inquiry: int stim: str @@ -60,12 +103,25 @@ class EvidenceRecord: presented: int above_threshold: int - def __iter__(self): + def __iter__(self) -> Iterator[Any]: + """Iterate over the record's field values. + + Returns: + Iterator[Any]: Iterator over the record's field values. + """ return iter([getattr(self, field.name) for field in fields(self)]) def sqlite_ddl(cls: Any, table_name: str) -> str: - """Sqlite create table statement for the given dataclass""" + """Generate SQLite CREATE TABLE statement for a dataclass. + + Args: + cls (Any): Dataclass to generate DDL for. + table_name (str): Name of the table to create. + + Returns: + str: SQLite CREATE TABLE statement. + """ conversions = {int: 'integer', str: 'text', float: 'real'} column_defs = [ @@ -76,13 +132,31 @@ def sqlite_ddl(cls: Any, table_name: str) -> str: def sqlite_insert(cls: Any, table_name: str) -> str: - """sqlite INSERT statement for the given dataclass.""" + """Generate SQLite INSERT statement for a dataclass. + + Args: + cls (Any): Dataclass to generate INSERT for. + table_name (str): Name of the table to insert into. + + Returns: + str: SQLite INSERT statement with placeholders. + """ placeholders = ['?' for _ in fields(cls)] return f"INSERT INTO {table_name} VALUES ({','.join(placeholders)})" def evidence_records(session: Session) -> List[EvidenceRecord]: - """Summarize the session evidence data.""" + """Generate evidence records from session data. + + Args: + session (Session): Session data to process. + + Returns: + List[EvidenceRecord]: List of evidence records. + + Raises: + AssertionError: If session has no evidence or no symbol set. + """ assert session.has_evidence( ), "There is no evidence in the provided session" assert session.symbol_set, "Session must define a symbol_set" @@ -106,7 +180,8 @@ def evidence_records(session: Session) -> List[EvidenceRecord]: eye=evidence.get('eye_evidence', {}).get(stim, ''), btn=evidence.get('btn_evidence', {}).get(stim, ''), cumulative=evidence['likelihood'][stim], - inq_position=stimuli.index(stim) if stim in stimuli else None, + inq_position=stimuli.index( + stim) if stim in stimuli else None, is_target=int(inquiry.target_letter == stim), presented=int(stim in stimuli), above_threshold=int(evidence['likelihood'][stim] > @@ -114,34 +189,27 @@ def evidence_records(session: Session) -> List[EvidenceRecord]: return records -def session_db(session: Session, db_file: str = 'session.db'): - """Creates a sqlite database from the given session data. - - Parameters - ---------- - session - task data (evidence values, stim times, etc.) - db_file - path of database to write; defaults to session.db - - Database Schema - --------------- - evidence: - - trial integer (0-based) - - inquiry integer (0-based) - - letter text (letter or icon) - - lm real (language model probability for the trial; same for every - inquiry and only considered in the cumulative value during the - first inquiry) - - eeg real (likelihood for the given inquiry; a value of 1.0 indicates - that the letter was not presented) - - btn real (button press evidence) - - eye real (eyetracker evidence) - - cumulative real (cumulative likelihood for the trial thus far) - - inq_position integer (inquiry position; null if not presented) - - is_target integer (boolean; true(1) if this letter is the target) - - presented integer (boolean; true if the letter was presented in - this inquiry) - - above_threshold (boolean; true if cumulative likelihood was above - the configured threshold) +def session_db(session: Session, db_file: str = 'session.db') -> None: + """Create a SQLite database from session data. + + Args: + session (Session): Session data to store in database. + db_file (str, optional): Path to database file. Defaults to 'session.db'. + + Database Schema: + evidence: + - trial integer (0-based) + - inquiry integer (0-based) + - letter text (letter or icon) + - lm real (language model probability) + - eeg real (likelihood for the inquiry) + - btn real (button press evidence) + - eye real (eyetracker evidence) + - cumulative real (cumulative likelihood) + - inq_position integer (inquiry position) + - is_target integer (boolean) + - presented integer (boolean) + - above_threshold integer (boolean) """ # Create database conn = sqlite3.connect(db_file) @@ -155,8 +223,13 @@ def session_db(session: Session, db_file: str = 'session.db'): conn.commit() -def session_csv(session: Session, csv_file='session.csv'): - """Create a csv file summarizing the evidence data for the given session.""" +def session_csv(session: Session, csv_file: str = 'session.csv') -> None: + """Create a CSV file summarizing session evidence data. + + Args: + session (Session): Session data to summarize. + csv_file (str, optional): Path to CSV file. Defaults to 'session.csv'. + """ with open(csv_file, "w", encoding=DEFAULT_ENCODING, newline='') as output: csv_writer = csv.writer(output, delimiter=',') @@ -166,8 +239,20 @@ def session_csv(session: Session, csv_file='session.csv'): csv_writer.writerow(record) -def write_row(excel_sheet, rownum, data, background=None, border=None): - """Helper method to write a row to an Excel spreadsheet""" +def write_row(excel_sheet: Worksheet, + rownum: int, + data: Union[EvidenceRecord, List[Any]], + background: Optional[PatternFill] = None, + border: Optional[Border] = None) -> None: + """Write a row to an Excel spreadsheet. + + Args: + excel_sheet (Worksheet): Worksheet to write to. + rownum (int): Row number to write to. + data (Union[EvidenceRecord, List[Any]]): Data to write. + background (Optional[PatternFill], optional): Background fill. Defaults to None. + border (Optional[Border], optional): Cell border. Defaults to None. + """ for col, val in enumerate(data, start=1): cell = excel_sheet.cell(row=rownum, column=col) cell.value = val @@ -178,10 +263,15 @@ def write_row(excel_sheet, rownum, data, background=None, border=None): def session_excel(session: Session, - excel_file=SESSION_SUMMARY_FILENAME, - include_charts=True): - """Create an Excel spreadsheet summarizing the evidence data for the given session.""" - + excel_file: str = SESSION_SUMMARY_FILENAME, + include_charts: bool = True) -> None: + """Create an Excel spreadsheet summarizing session evidence data. + + Args: + session (Session): Session data to summarize. + excel_file (str, optional): Path to Excel file. Defaults to SESSION_SUMMARY_FILENAME. + include_charts (bool, optional): Whether to include charts. Defaults to True. + """ # Define styles and borders to use within the spreadsheet. gray_background = PatternFill(start_color='ededed', fill_type='solid') white_background = PatternFill(start_color=WHITE, fill_type=None) @@ -280,7 +370,7 @@ def session_excel(session: Session, # Freeze header row sheet.freeze_panes = 'A2' workbook.save(excel_file) - print("Wrote output to " + excel_file) + logger.info("Wrote output to %s", excel_file) if __name__ == "__main__": diff --git a/bcipy/core/stimuli.py b/bcipy/core/stimuli.py index 58de25f43..b466cd1d4 100644 --- a/bcipy/core/stimuli.py +++ b/bcipy/core/stimuli.py @@ -35,45 +35,62 @@ class StimuliOrder(Enum): - """Stimuli Order. + """Defines the ordering of stimuli for inquiry. - Enum to define the ordering of stimuli for inquiry. + Attributes: + RANDOM (str): Random ordering of stimuli. + ALPHABETICAL (str): Alphabetical ordering of stimuli. """ RANDOM = 'random' ALPHABETICAL = 'alphabetical' @classmethod - def list(cls): - """Returns all enum values as a list""" + def list(cls) -> list: + """Returns all enum values as a list. + + Returns: + list: List of all enum values. + """ return list(map(lambda c: c.value, cls)) class TargetPositions(Enum): - """Target Positions. + """Defines the positions of targets within the inquiry. - Enum to define the positions of targets within the inquiry. + Attributes: + RANDOM (str): Random positioning of targets. + DISTRIBUTED (str): Evenly distributed positioning of targets. """ RANDOM = 'random' DISTRIBUTED = 'distributed' @classmethod - def list(cls): - """Returns all enum values as a list""" + def list(cls) -> list: + """Returns all enum values as a list. + + Returns: + list: List of all enum values. + """ return list(map(lambda c: c.value, cls)) class PhotoDiodeStimuli(Enum): - """Photodiode Stimuli. + """Defines unicode stimuli needed for testing system timing. - Enum to define unicode stimuli needed for testing system timing. + Attributes: + EMPTY (str): Box with a white border, no fill (□). + SOLID (str): Solid white box (■). """ - EMPTY = '\u25A1' # box with a white border, no fill SOLID = '\u25A0' # solid white box @classmethod - def list(cls): - """Returns all enum values as a list""" + def list(cls) -> list: + """Returns all enum values as a list. + + Returns: + list: List of all enum values. + """ return list(map(lambda c: c.value, cls)) @@ -81,19 +98,21 @@ class InquirySchedule(NamedTuple): """Schedule for the next inquiries to present, where each inquiry specifies the stimulus, duration, and color information. - Attributes - ---------- - - stimuli: `List[List[str]]` - - durations: `List[List[float]]` - - colors: `List[List[str]]` + Attributes: + stimuli (List[Any]): List of stimuli for each inquiry. + durations (Union[List[List[float]], List[float]]): Duration for each stimulus. + colors (Union[List[List[str]], List[str]]): Color for each stimulus. """ stimuli: List[Any] durations: Union[List[List[float]], List[float]] colors: Union[List[List[str]], List[str]] def inquiries(self) -> Iterator[Tuple]: - """Generator that iterates through each Inquiry. Yields tuples of - (stim, duration, color).""" + """Generator that iterates through each Inquiry. + + Yields: + Tuple: Tuple of (stim, duration, color) for each inquiry. + """ count = len(self.stimuli) index = 0 while index < count: @@ -103,13 +122,25 @@ def inquiries(self) -> Iterator[Tuple]: class Reshaper(ABC): + """Abstract base class for reshaping data in BCI experiments.""" @abstractmethod def __call__(self, *args, **kwargs) -> Any: + """Reshape data for a specific paradigm. + + Args: + *args: Variable length argument list. + **kwargs: Arbitrary keyword arguments. + + Returns: + Any: Reshaped data. + """ ... class InquiryReshaper: + """Reshapes EEG data, timing, and labels for inquiries in BCI experiments.""" + def __call__(self, trial_targetness_label: List[str], timing_info: List[float], @@ -125,30 +156,28 @@ def __call__(self, """Extract inquiry data and labels. Args: - trial_targetness_label (List[str]): labels each trial as "target", "non-target", "first_pres_target", etc - timing_info (List[float]): Timestamp of each event in seconds - eeg_data (np.ndarray): shape (channels, samples) preprocessed EEG data - sample_rate (int): sample rate of data provided in eeg_data - trials_per_inquiry (int): number of trials in each inquiry + trial_targetness_label (List[str]): Labels each trial as "target", "non-target", etc. + timing_info (List[float]): Timestamp of each event in seconds. + eeg_data (np.ndarray): Shape (channels, samples) preprocessed EEG data. + sample_rate (int): Sample rate of data provided in eeg_data. + trials_per_inquiry (int): Number of trials in each inquiry. offset (float, optional): Any calculated or hypothesized offsets in timings. Defaults to 0. - channel_map (List[int], optional): Describes which channels to include or discard. - Defaults to None; all channels will be used. - poststimulus_length (float, optional): time in seconds needed after the last trial in an inquiry. - prestimulus_length (float, optional): time in seconds needed before the first trial in an inquiry. - transformation_buffer (float, optional): time in seconds to buffer the end of the inquiry. Defaults to 0.0. - target_label (str): label of target symbol. Defaults to "target" + channel_map (List[int], optional): Describes which channels to include or discard. Defaults to None. + poststimulus_length (float, optional): Time in seconds needed after the last trial. Defaults to 0.5. + prestimulus_length (float, optional): Time in seconds needed before the first trial. Defaults to 0.0. + transformation_buffer (float, optional): Time in seconds to buffer the end of the inquiry. Defaults to 0.0. + target_label (str, optional): Label of target symbol. Defaults to "target". Returns: - reshaped_data (np.ndarray): inquiry data of shape (Channels, Inquiries, Samples) - labels (np.ndarray): integer label for each inquiry. With `trials_per_inquiry=K`, - a label of [0, K-1] indicates the position of `target_label`, or label of [0 ... 0] indicates - `target_label` was not present. - reshaped_trigger_timing (List[List[int]]): For each inquiry, a list of the sample index where each trial - begins, accounting for the prestim buffer that may have been added to the front of each inquiry. + Tuple[np.ndarray, np.ndarray, List[List[float]]]: + - reshaped_data: Inquiry data of shape (Channels, Inquiries, Samples) + - labels: Integer label for each inquiry + - reshaped_trigger_timing: For each inquiry, a list of the sample index where each trial begins """ if channel_map: # Remove the channels that we are not interested in - channels_to_remove = [idx for idx, value in enumerate(channel_map) if value == 0] + channels_to_remove = [idx for idx, + value in enumerate(channel_map) if value == 0] eeg_data = np.delete(eeg_data, channels_to_remove, axis=0) n_inquiry = len(timing_info) // trials_per_inquiry @@ -156,15 +185,18 @@ def __call__(self, prestimulus_samples = int(prestimulus_length * sample_rate) # triggers in seconds are mapped to triggers in number of samples. - triggers = list(map(lambda x: int((x + offset) * sample_rate), timing_info)) + triggers = list( + map(lambda x: int((x + offset) * sample_rate), timing_info)) # First, find the longest inquiry in this experiment # We'll add or remove a few samples from all other inquiries, to match this length def get_inquiry_len(inq_trigs): return inq_trigs[-1] - inq_trigs[0] - longest_inquiry = max(grouper(triggers, trials_per_inquiry, fillvalue='x'), key=lambda xy: get_inquiry_len(xy)) - num_samples_per_inq = get_inquiry_len(longest_inquiry) + trial_duration_samples + longest_inquiry = max(grouper( + triggers, trials_per_inquiry, fillvalue='x'), key=lambda xy: get_inquiry_len(xy)) + num_samples_per_inq = get_inquiry_len( + longest_inquiry) + trial_duration_samples buffer_samples = int(transformation_buffer * sample_rate) # Label for every inquiry @@ -173,7 +205,8 @@ def get_inquiry_len(inq_trigs): ) # maybe this can be configurable? return either class indexes or labels ('nontarget' etc) reshaped_data, reshaped_trigger_timing = [], [] for inquiry_idx, trials_within_inquiry in enumerate( - grouper(zip(trial_targetness_label, triggers), trials_per_inquiry, fillvalue='x') + grouper(zip(trial_targetness_label, triggers), + trials_per_inquiry, fillvalue='x') ): first_trigger = trials_within_inquiry[0][1] @@ -184,7 +217,8 @@ def get_inquiry_len(inq_trigs): # If prestimulus buffer is used, we add it here so that trigger timings will # still line up with trial onset - trial_triggers.append((trigger - first_trigger) + prestimulus_samples) + trial_triggers.append( + (trigger - first_trigger) + prestimulus_samples) reshaped_trigger_timing.append(trial_triggers) start = first_trigger - prestimulus_samples stop = first_trigger + num_samples_per_inq + buffer_samples @@ -198,29 +232,19 @@ def extract_trials( samples_per_trial: int, inquiry_timing: List[List[int]], prestimulus_samples: int = 0) -> np.ndarray: - """Extract Trials. - - After using the InquiryReshaper, it may be necessary to further extract the trials for processing. - Using the number of samples and inquiry timing, the data is reshaped from Channels, Inquiry, Samples to - Channels, Trials, Samples. These should match with the trials extracted from the TrialReshaper given the same - slicing parameters. - - Parameters - ---------- - inquiries : np.ndarray - shape (Channels, Inquiries, Samples) - samples_per_trial : int - number of samples per trial - inquiry_timing : List[List[int]] - For each inquiry, a list of the sample index where each trial begins - prestimulus_samples : int, optional - Number of samples to move the start of each trial in each inquiry, by default 0. - This is useful if wanting to use baseline intervals before the trial onset, along with the trial data. - - Returns - ------- - np.ndarray - shape (Channels, Trials, Samples) + """Extract trials from inquiry data. + + Args: + inquiries (np.ndarray): Shape (Channels, Inquiries, Samples). + samples_per_trial (int): Number of samples per trial. + inquiry_timing (List[List[int]]): For each inquiry, a list of the sample index where each trial begins. + prestimulus_samples (int, optional): Number of samples to move the start of each trial. Defaults to 0. + + Returns: + np.ndarray: Shape (Channels, Trials, Samples). + + Raises: + BciPyCoreException: If index is out of bounds when extracting trials. """ new_trials = [] num_inquiries = inquiries.shape[1] @@ -242,6 +266,8 @@ def extract_trials( class GazeReshaper: + """Reshapes gaze trajectory data and labels for inquiries in BCI experiments.""" + def __call__(self, inq_start_times: List[float], target_symbols: List[str], @@ -254,36 +280,21 @@ def __call__(self, ) -> Tuple[dict, list, List[str]]: """Extract gaze trajectory data and labels. - Different from the EEG, gaze inquiry windows start with the first highlighted symbol and end with the - last highlighted symbol in the inquiry. Each inquiry has a length of (trial duration x num of trials) - seconds. Labels are provided in 'target_symbols'. It returns a Dict, where keys are the target symbols - and the values are inquiries (appended in order of appearance) where the corresponding target symbol is - prompted. - - Optional outputs: - reshape_data is the list of data reshaped into (Inquiries, Channels, Samples), where inquirires are - appended in chronological order. - labels returns the list of target symbols in each inquiry. - - Parameters - ---------- - inq_start_times (List[float]): Timestamp of each event in seconds - target_symbols (List[str]): Prompted symbol in each inquiry - gaze_data (np.ndarray): shape (channels, samples) eye tracking data - sample_rate (int): sample rate of eye tracker data - stimulus_duration (float): duration of flash time (in seconds) for each trial - num_stimuli_per_inquiry (int): number of stimuli in each inquiry (default: 10) - symbol_set (List[str]): list of all symbols for the task - channel_map (List[int], optional): Describes which channels to include or discard. - Defaults to None; all channels will be used. - - Returns - ------- - data_by_targets (dict): Dictionary where keys consist of the symbol set, and values - the appended inquiries for each symbol. dict[Key] = (np.ndarray) of shape (Channels, Samples) - - reshaped_data (List[float]) [optional]: inquiry data of shape (Inquiries, Channels, Samples) - labels (List[str]) [optional] : Target symbol in each inquiry. + Args: + inq_start_times (List[float]): Timestamp of each event in seconds. + target_symbols (List[str]): Prompted symbol in each inquiry. + gaze_data (np.ndarray): Shape (channels, samples) eye tracking data. + sample_rate (int): Sample rate of eye tracker data. + stimulus_duration (float): Duration of flash time (in seconds) for each trial. + num_stimuli_per_inquiry (int): Number of stimuli in each inquiry. + symbol_set (List[str], optional): List of all symbols for the task. Defaults to alphabet(). + channel_map (List[int], optional): Describes which channels to include or discard. Defaults to None. + + Returns: + Tuple[dict, list, List[str]]: + - data_by_targets: Dictionary where keys are the symbol set, and values are the appended inquiries for each symbol. + - reshaped_data: Inquiry data of shape (Inquiries, Channels, Samples). + - labels: Target symbol in each inquiry. """ # Find the timestamp value closest to (& greater than) inq_start_times. # Lsl timestamps are the last row in the gaze_data @@ -313,7 +324,8 @@ def __call__(self, # A better way of handling this buffer would be subtracting the flash time of the # second symbol from the first symbol, which gives a more accurate representation of # "stimulus duration". - window_length = (stimulus_duration + buffer) * num_stimuli_per_inquiry # in seconds + window_length = (stimulus_duration + buffer) * \ + num_stimuli_per_inquiry # in seconds reshaped_data = [] # Merge the inquiries if they have the same target letter: @@ -332,13 +344,14 @@ def __call__(self, return data_by_targets_dict, reshaped_data, labels def centralize_all_data(self, data: np.ndarray, symbol_pos: np.ndarray) -> np.ndarray: - """ Using the symbol locations in matrix, centralize all data (in Tobii units). - This data will only be used in certain model types. + """Centralize all data using symbol locations in matrix (Tobii units). + Args: - data (np.ndarray): Data in shape of num_samples x num_dimensions - symbol_pos (np.ndarray(float)): Array of the current symbol posiiton in Tobii units + data (np.ndarray): Data in shape of num_samples x num_dimensions. + symbol_pos (np.ndarray): Array of the current symbol position in Tobii units. + Returns: - new_data (np.ndarray): Centralized data in shape of num_samples x num_dimensions + np.ndarray: Centralized data in shape of num_samples x num_dimensions. """ new_data = np.copy(data) for i in range(len(data)): @@ -348,6 +361,8 @@ def centralize_all_data(self, data: np.ndarray, symbol_pos: np.ndarray) -> np.nd class TrialReshaper(Reshaper): + """Reshapes EEG data, timing, and labels for individual trials in BCI experiments.""" + def __call__(self, trial_targetness_label: list, timing_info: list, @@ -360,28 +375,26 @@ def __call__(self, target_label: str = "target") -> Tuple[np.ndarray, np.ndarray]: """Extract trial data and labels. - Parameters - ---------- - trial_targetness_label (list): labels each trial as "target", "non-target", "first_pres_target", etc - timing_info (list): Timestamp of each event in seconds - eeg_data (np.ndarray): shape (channels, samples) preprocessed EEG data - sample_rate (int): sample rate of preprocessed EEG data - trials_per_inquiry (int, optional): unused, kept here for consistent interface with `inquiry_reshaper` - offset (float, optional): Any calculated or hypothesized offsets in timings. - Defaults to 0. - channel_map (List, optional): Describes which channels to include or discard. - Defaults to None; all channels will be used. - poststimulus_length (float, optional): [description]. Defaults to 0.5. - target_label (str): label of target symbol. Defaults to "target" - - Returns - ------- - trial_data (np.ndarray): shape (channels, trials, samples) reshaped data - labels (np.ndarray): integer label for each trial + Args: + trial_targetness_label (list): Labels each trial as "target", "non-target", etc. + timing_info (list): Timestamp of each event in seconds. + eeg_data (np.ndarray): Shape (channels, samples) preprocessed EEG data. + sample_rate (int): Sample rate of preprocessed EEG data. + offset (float, optional): Any calculated or hypothesized offsets in timings. Defaults to 0. + channel_map (List, optional): Describes which channels to include or discard. Defaults to None. + poststimulus_length (float, optional): Time in seconds needed after the last trial. Defaults to 0.5. + prestimulus_length (float, optional): Time in seconds needed before the first trial. Defaults to 0.0. + target_label (str, optional): Label of target symbol. Defaults to "target". + + Returns: + Tuple[np.ndarray, np.ndarray]: + - trial_data: Reshaped data of shape (channels, trials, samples) + - labels: Integer label for each trial """ # Remove the channels that we are not interested in if channel_map: - channels_to_remove = [idx for idx, value in enumerate(channel_map) if value == 0] + channels_to_remove = [idx for idx, + value in enumerate(channel_map) if value == 0] eeg_data = np.delete(eeg_data, channels_to_remove, axis=0) # Number of samples we are interested per trial @@ -389,7 +402,8 @@ def __call__(self, prestim_samples = int(prestimulus_length * sample_rate) # triggers in seconds are mapped to triggers in number of samples. - triggers = list(map(lambda x: int((x + offset) * sample_rate), timing_info)) + triggers = list( + map(lambda x: int((x + offset) * sample_rate), timing_info)) # Label for every trial in 0 or 1 targetness_labels = np.zeros(len(triggers), dtype=np.longlong) @@ -399,13 +413,22 @@ def __call__(self, targetness_labels[trial_idx] = 1 # For every channel append filtered channel data to trials - reshaped_trials.append(eeg_data[:, trigger - prestim_samples: trigger + poststim_samples]) + reshaped_trials.append( + eeg_data[:, trigger - prestim_samples: trigger + poststim_samples]) return np.stack(reshaped_trials, 1), targetness_labels def update_inquiry_timing(timing: List[List[int]], downsample: int) -> List[List[int]]: - """Update inquiry timing to reflect downsampling.""" + """Update inquiry timing to reflect downsampling. + + Args: + timing (List[List[int]]): Original timing values for each inquiry. + downsample (int): Downsampling factor. + + Returns: + List[List[int]]: Updated timing values for each inquiry. + """ for i, inquiry in enumerate(timing): for j, time in enumerate(inquiry): timing[i][j] = int(time // downsample) @@ -420,14 +443,24 @@ def mne_epochs(mne_data: RawArray, baseline: Optional[Tuple[Any, float]] = None, reject_by_annotation: bool = False, preload: bool = False) -> Epochs: - """MNE Epochs. + """Create MNE Epochs from a RawArray and trigger information. - Using an MNE RawArray, reshape the data given trigger information. If two labels present [0, 1], - each may be accessed by numbered order. Ex. first_class = epochs['1'], second_class = epochs['2'] + Args: + mne_data (RawArray): MNE RawArray object. + trial_length (float): Length of each trial in seconds. + trigger_timing (Optional[List[float]], optional): List of trigger times. Defaults to None. + trigger_labels (Optional[List[int]], optional): List of trigger labels. Defaults to None. + baseline (Optional[Tuple[Any, float]], optional): Baseline interval. Defaults to None. + reject_by_annotation (bool, optional): Whether to reject epochs by annotation. Defaults to False. + preload (bool, optional): Whether to preload the data. Defaults to False. + + Returns: + Epochs: MNE Epochs object. """ old_annotations = mne_data.annotations if trigger_timing and trigger_labels: - new_annotations = Annotations(trigger_timing, [trial_length] * len(trigger_timing), trigger_labels) + new_annotations = Annotations( + trigger_timing, [trial_length] * len(trigger_timing), trigger_labels) all_annotations = new_annotations + old_annotations else: all_annotations = old_annotations @@ -449,15 +482,20 @@ def mne_epochs(mne_data: RawArray, baseline=baseline, tmax=trial_length, tmin=tmin, - proj=False, # apply SSP projection to data. Defaults to True in Epochs. + # apply SSP projection to data. Defaults to True in Epochs. + proj=False, reject_by_annotation=reject_by_annotation, preload=preload) def alphabetize(stimuli: List[str]) -> List[str]: - """Alphabetize. + """Return a list of sorted stimuli by alphabet. - Given a list of string stimuli, return a list of sorted stimuli by alphabet. + Args: + stimuli (List[str]): List of string stimuli. + + Returns: + List[str]: Alphabetically sorted list of stimuli. """ return sorted(stimuli, key=lambda x: re.sub(r'[^a-zA-Z0-9 \n\.]', 'ZZ', x).lower()) @@ -469,23 +507,20 @@ def inq_generator(query: List[str], stim_jitter: float = 0, stim_order: StimuliOrder = StimuliOrder.RANDOM, is_txt: bool = True) -> InquirySchedule: - """Given the query set, prepares the stimuli, color and timing - - Parameters - ---------- - query(list[str]): list of queries to be shown - timing(list[float]): Task specific timing for generator - color(list[str]): Task specific color for generator - First element is the target, second element is the fixation - Observe that [-1] element represents the trial information - Return - ------ - schedule_inq(tuple( - samples[list[list[str]]]: list of inquiries - timing(list[list[float]]): list of timings - color(list(list[str])): list of colors)): scheduled inquiries - """ + """Prepare the stimuli, color, and timing for a set of inquiries. + Args: + query (List[str]): List of queries to be shown. + timing (List[float], optional): Task specific timing for generator. Defaults to [1, 0.2]. + color (List[str], optional): Task specific color for generator. Defaults to ['red', 'white']. + inquiry_count (int, optional): Number of inquiries to generate. Defaults to 1. + stim_jitter (float, optional): Jitter to apply to stimulus timing. Defaults to 0. + stim_order (StimuliOrder, optional): Ordering of stimuli. Defaults to StimuliOrder.RANDOM. + is_txt (bool, optional): Whether the stimuli are text. Defaults to True. + + Returns: + InquirySchedule: Scheduled inquiries with samples, timing, and color. + """ if stim_order == StimuliOrder.ALPHABETICAL: query = alphabetize(query) else: @@ -496,18 +531,14 @@ def inq_generator(query: List[str], # Init some lists to construct our stimuli with samples, times, colors = [], [], [] for _ in range(inquiry_count): - # append a fixation cross. if not text, append path to image fixation sample = [get_fixation(is_txt)] - # construct the sample from the query sample += [i for i in query] samples.append(sample) - times.append([timing[i] for i in range(len(timing) - 1)]) base_timing = timing[-1] times[-1] += jittered_timing(base_timing, stim_jitter, stim_length) - # append colors colors.append([color[i] for i in range(len(color) - 1)] + [color[-1]] * stim_length) @@ -518,33 +549,26 @@ def best_selection(selection_elements: list, val: list, len_query: int, always_included: Optional[List[str]] = None) -> list: - """Best Selection. - - Given set of elements and a value function over the set, picks the len_query - number of elements with the best value. - - Parameters - ---------- - selection_elements(list[str]): the set of elements - val(list[float]): values for the corresponding elements - len_query(int): number of elements to be picked from the set - always_included(list[str]): subset of elements that should always be - included in the result. Defaults to None. - Return - ------ - best_selection(list[str]): elements from selection_elements with the best values - """ + """Pick the len_query number of elements with the best value. + Args: + selection_elements (list): The set of elements. + val (list): Values for the corresponding elements. + len_query (int): Number of elements to be picked from the set. + always_included (Optional[List[str]], optional): Subset of elements that should always be included. Defaults to None. + + Returns: + list: Elements from selection_elements with the best values. + """ always_included = always_included or [] # pick the top n items sorted by value in decreasing order elem_val = dict(zip(selection_elements, val)) - best = sorted(selection_elements, key=elem_val.get, reverse=True)[0:len_query] - + best = sorted(selection_elements, key=elem_val.get, + reverse=True)[0:len_query] replacements = [ item for item in always_included if item not in best and item in selection_elements ][0:len_query] - if replacements: best[-len(replacements):] = replacements return best @@ -559,67 +583,49 @@ def best_case_rsvp_inq_gen(alp: list, stim_order: StimuliOrder = StimuliOrder.RANDOM, is_txt: bool = True, inq_constants: Optional[List[str]] = None) -> InquirySchedule: - """Best Case RSVP Inquiry Generation. - - Generates RSVPKeyboard inquiry by picking n-most likely letters. - - Parameters - ---------- - alp(list[str]): alphabet (can be arbitrary) - session_stimuli(ndarray[float]): quantifier metric for query selection - dim(session_stimuli) = card(alp)! - timing(list[float]): Task specific timing for generator - color(list[str]): Task specific color for generator - First element is the target, second element is the fixation - Observe that [-1] element represents the trial information - inquiry_count(int): number of random stimuli to be created - stim_per_inquiry(int): number of trials in a inquiry - stim_order(StimuliOrder): ordering of stimuli in the inquiry - inq_constants(list[str]): list of letters that should always be - included in every inquiry. If provided, must be alp items. - Return - ------ - schedule_inq(tuple( - samples[list[list[str]]]: list of inquiries - timing(list[list[float]]): list of timings - color(list(list[str])): list of colors)): scheduled inquiries - """ + """Generate RSVPKeyboard inquiry by picking n-most likely letters. + + Args: + alp (list): Alphabet (can be arbitrary). + session_stimuli (np.ndarray): Quantifier metric for query selection. + timing (List[float], optional): Task specific timing for generator. Defaults to [1, 0.2]. + color (List[str], optional): Task specific color for generator. Defaults to ['red', 'white']. + stim_number (int, optional): Number of random stimuli to be created. Defaults to 1. + stim_length (int, optional): Number of trials in an inquiry. Defaults to 10. + stim_order (StimuliOrder, optional): Ordering of stimuli. Defaults to StimuliOrder.RANDOM. + is_txt (bool, optional): Whether the stimuli are text. Defaults to True. + inq_constants (Optional[List[str]], optional): Letters that should always be included. Defaults to None. + Returns: + InquirySchedule: Scheduled inquiries with samples, timing, and color. + """ if len(alp) != len(session_stimuli): raise BciPyCoreException(( f'Missing information about alphabet.' f'len(alp):{len(alp)} and len(session_stimuli):{len(session_stimuli)} should be same!')) - if inq_constants and not set(inq_constants).issubset(alp): raise BciPyCoreException('Inquiry constants must be alphabet items.') - # query for the best selection query = best_selection( alp, session_stimuli, stim_length, inq_constants) - if stim_order == StimuliOrder.ALPHABETICAL: query = alphabetize(query) else: random.shuffle(query) - # Init some lists to construct our stimuli with samples, times, colors = [], [], [] for _ in range(stim_number): - # append a fixation cross. if not text, append path to image fixation sample = [get_fixation(is_txt)] - # construct the sample from the query sample += [i for i in query] samples.append(sample) - # append timing times.append([timing[i] for i in range(len(timing) - 1)] + [timing[-1]] * stim_length) - # append colors colors.append([color[i] for i in range(len(color) - 1)] + [color[-1]] * stim_length) @@ -637,37 +643,22 @@ def generate_calibration_inquiries( target_positions: TargetPositions = TargetPositions.RANDOM, percentage_without_target: int = 0, is_txt: bool = True) -> InquirySchedule: - """ - Generates inquiries with target letters in all possible positions. - - This function attempts to display all symbols as targets an equal number of - times when stim_order is RANDOM. When the stim_order is ALPHABETICAL there - is much more variation in the counts (target distribution takes priority) - and some symbols may not appear as targets depending on the inquiry_count. - The frequency that each symbol is displayed as a nontarget should follow a - uniform distribution. - - Parameters - ---------- - alp(list[str]): stimuli - timing(list[float]): Task specific timing for generator. - [target, fixation, stimuli] - jitter(int): jitter for stimuli timing. If None, no jitter is applied. - color(list[str]): Task specific color for generator - [target, fixation, stimuli] - inquiry_count(int): number of inquiries in a calibration - stim_per_inquiry(int): number of stimuli in each inquiry - stim_order(StimuliOrder): ordering of stimuli in the inquiry - target_positions(TargetPositions): positioning of targets to select for the inquiries - percentage_without_target(int): percentage of inquiries for which target letter flashed is not in inquiry - is_txt(bool): whether the stimuli type is text. False would be an image stimuli. - - Return - ------ - schedule_inq(tuple( - samples[list[list[str]]]: list of inquiries - timing(list[list[float]]): list of timings - color(list(list[str])): list of colors)): scheduled inquiries + """Generate inquiries with target letters in all possible positions. + + Args: + alp (List[str]): Stimuli. + timing (Optional[List[float]], optional): Task specific timing for generator. Defaults to None. + jitter (Optional[int], optional): Jitter for stimuli timing. Defaults to None. + color (Optional[List[str]], optional): Task specific color for generator. Defaults to None. + inquiry_count (int, optional): Number of inquiries in a calibration. Defaults to 100. + stim_per_inquiry (int, optional): Number of stimuli in each inquiry. Defaults to 10. + stim_order (StimuliOrder, optional): Ordering of stimuli. Defaults to StimuliOrder.RANDOM. + target_positions (TargetPositions, optional): Positioning of targets. Defaults to TargetPositions.RANDOM. + percentage_without_target (int, optional): Percentage of inquiries without a target. Defaults to 0. + is_txt (bool, optional): Whether the stimuli type is text. Defaults to True. + + Returns: + InquirySchedule: Scheduled inquiries with samples, timing, and color. """ if timing is None: timing = [0.5, 1, 0.2] @@ -678,7 +669,6 @@ def generate_calibration_inquiries( ) == 3, "timing must include values for [target, fixation, stimuli]" time_target, time_fixation, time_stim = timing fixation = get_fixation(is_txt) - target_indexes = generate_target_positions(inquiry_count, stim_per_inquiry, percentage_without_target, target_positions) @@ -700,15 +690,12 @@ def generate_calibration_inquiries( next_targets=targets, last_target=target) samples.append([target, fixation, *inquiry]) - times = [[ time_target, time_fixation, *generate_inquiry_stim_timing(time_stim, stim_per_inquiry, jitter) ] for _ in range(inquiry_count)] - inquiry_colors = color[0:2] + [color[-1]] * stim_per_inquiry colors = [inquiry_colors for _ in range(inquiry_count)] - return InquirySchedule(samples, times, colors) @@ -717,9 +704,11 @@ def inquiry_target_counts(inquiries: List[List[str]], """Count the number of times each symbol was presented as a target. Args: - inquiries - list of inquiries where each inquiry is structured as - [target, fixation, *stim] - symbols - list of all possible symbols + inquiries (List[List[str]]): List of inquiries where each inquiry is structured as [target, fixation, *stim]. + symbols (List[str]): List of all possible symbols. + + Returns: + dict: Dictionary mapping each symbol to its target count. """ target_presentations = [inq[0] for inq in inquiries if inq[0] in inq[2:]] counter = dict.fromkeys(symbols, 0) @@ -730,7 +719,15 @@ def inquiry_target_counts(inquiries: List[List[str]], def inquiry_nontarget_counts(inquiries: List[List[str]], symbols: List[str]) -> dict: - """Count the number of times each symbol was presented as a nontarget.""" + """Count the number of times each symbol was presented as a nontarget. + + Args: + inquiries (List[List[str]]): List of inquiries. + symbols (List[str]): List of all possible symbols. + + Returns: + dict: Dictionary mapping each symbol to its nontarget count. + """ counter = dict.fromkeys(symbols, 0) for inq in inquiries: target, _fixation, *stimuli = inq @@ -742,16 +739,20 @@ def inquiry_nontarget_counts(inquiries: List[List[str]], def inquiry_stats(inquiries: List[List[str]], symbols: List[str]) -> Dict[str, Dict[str, float]]: - """Descriptive stats for the number of times each target and nontarget - symbol is shown in the inquiries""" + """Descriptive stats for the number of times each target and nontarget symbol is shown in the inquiries. + + Args: + inquiries (List[List[str]]): List of inquiries. + symbols (List[str]): List of all possible symbols. + Returns: + Dict[str, Dict[str, float]]: Dictionary with stats for target and nontarget symbols. + """ target_stats = dict( Series(Counter(inquiry_target_counts(inquiries, symbols))).describe()) - nontarget_stats = dict( Series(Counter(inquiry_nontarget_counts(inquiries, symbols))).describe()) - return { 'target_symbols': target_stats, 'nontarget_symbols': nontarget_stats @@ -762,15 +763,15 @@ def generate_inquiries(symbols: List[str], inquiry_count: int, stim_per_inquiry: int, stim_order: StimuliOrder) -> List[List[str]]: """Generate a list of inquiries. For each inquiry no symbols are repeated. - Inquiries do not include the target or fixation. Symbols should be - distributed uniformly across inquiries. Args: + symbols (List[str]): Values from which to select. + inquiry_count (int): Total number of inquiries to generate. + stim_per_inquiry (int): Length of each inquiry. + stim_order (StimuliOrder): Ordering of results. - symbols - values from which to select - inquiry_count - total number of inquiries to generate - stim_per_inquiry - length of each inquiry - stim_order - ordering of results + Returns: + List[List[str]]: List of generated inquiries. """ return [ generate_inquiry(symbols=symbols, @@ -781,13 +782,15 @@ def generate_inquiries(symbols: List[str], inquiry_count: int, def generate_inquiry(symbols: List[str], length: int, stim_order: StimuliOrder) -> List[str]: - """Generate an inquiry from the list of symbols. No symbols are repeated - in the output list. Output does not include the target or fixation. + """Generate an inquiry from the list of symbols. No symbols are repeated in the output list. Args: - symbols - values from which to select - length - number of items in the return list - stim_order - ordering of results + symbols (List[str]): Values from which to select. + length (int): Number of items in the return list. + stim_order (StimuliOrder): Ordering of results. + + Returns: + List[str]: Generated inquiry. """ inquiry = random.sample(symbols, k=length) if stim_order == StimuliOrder.ALPHABETICAL: @@ -800,20 +803,17 @@ def inquiry_target(inquiry: List[str], symbols: List[str], next_targets: Optional[List[str]] = None, last_target: Optional[str] = None) -> str: - """Returns the target for the given inquiry. If the optional - target position is not provided a target will randomly be selected from - the list of symbols and will not be in the inquiry. + """Returns the target for the given inquiry. Args: - inquiry - list of symbols to be presented - target_position - optional position within the list of the target sym - symbols - used if position is not provided to select a random symbol - as the target. - next_targets - list of targets from which to select - last_target - target from the previous inquiry; used to avoid selecting - the same target consecutively. - - Returns target symbol + inquiry (List[str]): List of symbols to be presented. + target_position (Optional[int]): Optional position within the list of the target symbol. + symbols (List[str]): Used if position is not provided to select a random symbol as the target. + next_targets (Optional[List[str]], optional): List of targets from which to select. Defaults to None. + last_target (Optional[str], optional): Target from the previous inquiry. Defaults to None. + + Returns: + str: Target symbol. """ if target_position is None: return random.choice(list(set(symbols) - set(inquiry))) @@ -854,11 +854,12 @@ def generate_inquiry_stim_timing(time_stim: float, length: int, """Generate stimuli timing values for a given inquiry. Args: - time_stim: seconds to display each stimuli - length: Number of timings to generate - jitter: whether the timing should be jittered. + time_stim (float): Seconds to display each stimulus. + length (int): Number of timings to generate. + jitter (bool): Whether the timing should be jittered. - Returns list of times (in seconds) + Returns: + List[float]: List of times (in seconds). """ if jitter: return jittered_timing(time_stim, jitter, length) @@ -868,10 +869,15 @@ def generate_inquiry_stim_timing(time_stim: float, length: int, def jittered_timing(time: float, jitter: float, stim_count: int) -> List[float]: - """Jittered timing. + """Generate a list of jittered timing values for stimuli. + + Args: + time (float): Base time for each stimulus. + jitter (float): Jitter to apply. + stim_count (int): Number of stimuli. - Using a base time and a jitter, generate a list (with length stim_count) of - timing that is uniformly distributed. + Returns: + List[float]: List of jittered timing values. """ assert time > jitter, ( f'Jitter timing [{jitter}] must be less than stimuli timing =[{time}] in the inquiry.' @@ -883,15 +889,14 @@ def jittered_timing(time: float, jitter: float, def compute_counts(inquiry_count: int, percentage_without_target: int) -> Tuple[int, int]: - """Determine the number of inquiries that should display targets and the - number that should not. + """Determine the number of inquiries that should display targets and the number that should not. Args: - inquiry_count: Number of inquiries in calibration - percentage_without_target: percentage of inquiries for which - target letter flashed is not in inquiry + inquiry_count (int): Number of inquiries in calibration. + percentage_without_target (int): Percentage of inquiries without a target. - Returns tuple of (target_count, no_target_count) + Returns: + Tuple[int, int]: Tuple of (target_count, no_target_count). """ no_target_count = int(inquiry_count * (percentage_without_target / 100)) target_count = inquiry_count - no_target_count @@ -901,17 +906,16 @@ def compute_counts(inquiry_count: int, def generate_target_positions(inquiry_count: int, stim_per_inquiry: int, percentage_without_target: int, distribution: TargetPositions) -> List[int]: - """ - Generates target positions distributed according to the provided parameter. + """Generate target positions distributed according to the provided parameter. Args: - inquiry_count: Number of inquiries in calibration - stim_per_inquiry: Number of stimuli in each inquiry - percentage_without_target: percentage of inquiries for which - target letter flashed is not in inquiry - distribution: specifies how targets should be distributed + inquiry_count (int): Number of inquiries in calibration. + stim_per_inquiry (int): Number of stimuli in each inquiry. + percentage_without_target (int): Percentage of inquiries without a target. + distribution (TargetPositions): Specifies how targets should be distributed. - Returns list of indexes + Returns: + List[int]: List of indexes for target positions. """ if distribution is TargetPositions.DISTRIBUTED: return distributed_target_positions(inquiry_count, stim_per_inquiry, @@ -922,65 +926,51 @@ def generate_target_positions(inquiry_count: int, stim_per_inquiry: int, def distributed_target_positions(inquiry_count: int, stim_per_inquiry: int, percentage_without_target: int) -> list: - """Distributed Target Positions. - - Generates evenly distributed target positions, including target letter - not flashed at all, and shuffles them. + """Generate evenly distributed target positions, including target letter not flashed at all, and shuffle them. Args: - inquiry_count(int): Number of inquiries in calibration - stim_per_inquiry(int): Number of stimuli in each inquiry - percentage_without_target(int): percentage of inquiries for which - target letter flashed is not in inquiry + inquiry_count (int): Number of inquiries in calibration. + stim_per_inquiry (int): Number of stimuli in each inquiry. + percentage_without_target (int): Percentage of inquiries without a target. - Return distributed_target_positions(list): targets: array of target - indexes to be chosen + Returns: + list: Targets array of target indexes to be chosen. """ - targets = [] - # find number of target and no_target inquiries target_count, no_target_count = compute_counts(inquiry_count, percentage_without_target) - # find number each target position is repeated, and remaining number num_pos = (int)(target_count / stim_per_inquiry) num_rem_pos = (target_count % stim_per_inquiry) - # add correct number of None's for nontarget inquiries targets = [NO_TARGET_INDEX] * no_target_count - # add distributed list of target positions targets.extend(list(range(stim_per_inquiry)) * num_pos) - # pick leftover positions randomly rem_pos = list(range(stim_per_inquiry)) random.shuffle(rem_pos) rem_pos = rem_pos[0:num_rem_pos] targets.extend(rem_pos) - # shuffle targets random.shuffle(targets) - return targets def random_target_positions(inquiry_count: int, stim_per_inquiry: int, percentage_without_target: int) -> list: - """Generates randomly distributed target positions, including target letter - not flashed at all, and shuffles them. + """Generate randomly distributed target positions, including target letter not flashed at all, and shuffle them. Args: - inquiry_count(int): Number of inquiries in calibration - stim_per_inquiry(int): Number of stimuli in each inquiry - percentage_without_target(int): percentage of inquiries for which - target letter flashed is not in inquiry + inquiry_count (int): Number of inquiries in calibration. + stim_per_inquiry (int): Number of stimuli in each inquiry. + percentage_without_target (int): Percentage of inquiries without a target. - Return list of target indexes to be chosen + Returns: + list: List of target indexes to be chosen. """ target_count, no_target_count = compute_counts(inquiry_count, percentage_without_target) - target_indexes = [NO_TARGET_INDEX] * no_target_count target_indexes.extend( random.choices(range(stim_per_inquiry), k=target_count)) @@ -990,20 +980,18 @@ def random_target_positions(inquiry_count: int, stim_per_inquiry: int, def generate_targets(symbols: List[str], inquiry_count: int, percentage_without_target: int) -> List[str]: - """Generates list of target symbols. Generates an equal number of each - target. The resulting list may be less than the inquiry_count. Used for - sampling without replacement to get approximately equal numbers of each - target. + """Generate a list of target symbols for calibration inquiries. Args: - symbols: - inquiry_count: number of inquiries in calibration - percentage_without_target: percentage of inquiries for which - target letter flashed is not in inquiry + symbols (List[str]): List of possible symbols. + inquiry_count (int): Number of inquiries in calibration. + percentage_without_target (int): Percentage of inquiries without a target. + + Returns: + List[str]: List of target symbols. """ target_count, no_target_count = compute_counts(inquiry_count, percentage_without_target) - # each symbol should appear at least once symbol_count = int(target_count / len(symbols)) or 1 targets = symbols * symbol_count @@ -1012,19 +1000,13 @@ def generate_targets(symbols: List[str], inquiry_count: int, def target_index(inquiry: List[str]) -> Optional[int]: - """Given an inquiry, return the index of the target within the choices and - None if the target is not included as a choice. - - Parameters - ---------- - inquiry - list of [target, fixation, *choices] - - >>> inquiry = ['T', '+', 'G', 'J', 'K', 'L', 'M', 'Q', 'T', 'V', 'X', '<'] - >>> target_index(inquiry) - 6 - >>> inquiry = ['A', '+', 'G', 'J', 'K', 'L', 'M', 'Q', 'T', 'V', 'X', '<'] - >>> target_index(inquiry) - None + """Return the index of the target within the choices, or None if not present. + + Args: + inquiry (List[str]): List of [target, fixation, *choices]. + + Returns: + Optional[int]: Index of the target in choices, or None if not present. """ assert len(inquiry) > 3, "Not enough choices" target, _fixation, *choices = inquiry @@ -1035,19 +1017,15 @@ def target_index(inquiry: List[str]) -> Optional[int]: def get_task_info(experiment_length: int, task_color: str) -> Tuple[List[str], List[str]]: - """Get Task Info. + """Generate fixed RSVPKeyboard task text and color information for display. - Generates fixed RSVPKeyboard task text and color information for - display. Args: - experiment_length(int): Number of inquiries for the experiment - task_color(str): Task information display color + experiment_length (int): Number of inquiries for the experiment. + task_color (str): Task information display color. - Return get_task_info((tuple): task_text: array of task text to display - task_color: array of colors for the task text - ) + Returns: + Tuple[List[str], List[str]]: Tuple of task text and color arrays. """ - # Do list comprehensions to get the arrays for the task we need. task_text_list = ['%s/%s' % (stim + 1, experiment_length) for stim in range(experiment_length)] @@ -1057,11 +1035,16 @@ def get_task_info(experiment_length: int, task_color: str) -> Tuple[List[str], L def resize_image(image_path: str, screen_size: tuple, sti_height: float) -> Tuple[float, float]: - """Resize Image. + """Return the width and height that a given image should be displayed at given the screen size and stimuli height. + + Args: + image_path (str): Path to the image file. + screen_size (tuple): Screen size as (width, height). + sti_height (float): Desired stimuli height. - Returns the width and height that a given image should be displayed at - given the screen size, size of the original image, and stimuli height - parameter""" + Returns: + Tuple[float, float]: Width and height for displaying the image. + """ # Retrieve image width and height with Image.open(image_path) as pillow_image: image_width, image_height = pillow_image.size @@ -1094,43 +1077,35 @@ def play_sound(sound_file_path: str, experiment_clock=None, trigger_name: Optional[str] = None, timing: list = []) -> list: - """Play Sound. - - Using soundevice and soundfile, play a sound giving options to buffer times between - loading sound into memory and after playing. If desired, marker writers or list based - timing with psychopy clocks may be passed and sound timing returned. - - - PARAMETERS - ---------- - :param: sound_file_path - :param: dtype: type of sound ex. float32. - :param: track_timing: whether or not to track timing of sound playin - :param: sound_callback: trigger based callback (see MarkerWriter and NullMarkerWriter) - :param: sound_load_buffer_time: time to wait after loading file before playing - :param: experiment_clock: psychopy clock to get time of sound stimuli - :param: trigger_name: name of the sound trigger - :param: timing: list of triggers in the form of trigger name, trigger timing - :resp: timing - """ + """Play a sound file and optionally track timing and triggers. + + Args: + sound_file_path (str): Path to the sound file. + dtype (str, optional): Type of sound (e.g., 'float32'). Defaults to 'float32'. + track_timing (bool, optional): Whether to track timing of sound playing. Defaults to False. + sound_callback (optional): Callback for sound triggers. Defaults to None. + sound_load_buffer_time (float, optional): Time to wait after loading file before playing. Defaults to 0.5. + experiment_clock (optional): Clock to get time of sound stimuli. Defaults to None. + trigger_name (Optional[str], optional): Name of the sound trigger. Defaults to None. + timing (list, optional): List of triggers in the form of trigger name, trigger timing. Defaults to []. + Returns: + list: Timing information for sound triggers. + """ try: # load in the sound file and wait some time before playing data, fs = sf.read(sound_file_path, dtype=dtype) core.wait(sound_load_buffer_time) - except Exception as e: error_message = f'Sound file could not be found or initialized. \n Exception={e}' log.exception(error_message) raise BciPyCoreException(error_message) - # if timing is wanted, get trigger timing for this sound stimuli if track_timing: # if there is a timing callback for sound, evoke it if sound_callback is not None: sound_callback(experiment_clock, trigger_name) timing.append([trigger_name, experiment_clock.getTime()]) - # play our loaded sound and wait for some time before it's finished # NOTE: there is a measurable delay for calling sd.play. (~ 0.1 seconds; # which I believe happens prior to the sound playing). @@ -1142,15 +1117,13 @@ def play_sound(sound_file_path: str, def soundfiles(directory: str) -> Iterator[str]: - """Creates a generator that cycles through sound files (.wav) in a - directory and returns the path to next sound file on each iteration. + """Return an iterator cycling through .wav files in a directory. + + Args: + directory (str): Path to the directory containing .wav files. - Parameters: - ----------- - directory - path to the directory which contains .wav files Returns: - -------- - iterator that infinitely cycles through the filenames. + Iterator[str]: Iterator that infinitely cycles through the filenames. """ if not path.isdir(directory): error_message = f'Invalid directory=[{directory}] for sound files.' @@ -1162,9 +1135,13 @@ def soundfiles(directory: str) -> Iterator[str]: def get_fixation(is_txt: bool) -> str: - """Get Fixation. + """Return the correct stimulus fixation given the type (text or image). + + Args: + is_txt (bool): Whether the fixation is text or image. - Return the correct stimulus fixation given the type (text or image). + Returns: + str: Fixation stimulus (text or image path). """ if is_txt: return DEFAULT_TEXT_FIXATION diff --git a/bcipy/core/symbols.py b/bcipy/core/symbols.py index dca9feb4c..1b642e4fe 100644 --- a/bcipy/core/symbols.py +++ b/bcipy/core/symbols.py @@ -1,25 +1,35 @@ """Defines helper methods and variables related to input symbols""" import os from string import ascii_uppercase -from typing import Callable +from typing import Any, Callable, List, Optional SPACE_CHAR = '_' BACKSPACE_CHAR = '<' -def alphabet(parameters=None, include_path=True, backspace=BACKSPACE_CHAR, space=SPACE_CHAR): - """Alphabet. +def alphabet(parameters: Optional[Any] = None, include_path: bool = True, + backspace: str = BACKSPACE_CHAR, space: str = SPACE_CHAR) -> List[str]: + """Standardizes and returns the alphabet symbols used in BciPy. - Function used to standardize the symbols we use as alphabet. + The symbols can either be text (uppercase ASCII letters, backspace, and space) + or paths to image files, depending on the `parameters` and `is_txt_stim` setting. - Returns - ------- - array of letters. + Args: + parameters (Optional[Any], optional): A dictionary-like object containing configuration + parameters, specifically 'is_txt_stim' and 'path_to_presentation_images'. + Defaults to None. + include_path (bool, optional): If True and image stimuli are used, returns full paths to images. + If False, returns just the image filenames without extensions. Defaults to True. + backspace (str, optional): The character representing backspace. Defaults to `BACKSPACE_CHAR`. + space (str, optional): The character representing space. Defaults to `SPACE_CHAR`. + + Returns: + List[str]: A list of alphabet symbols (either letters or image paths). """ if parameters and not parameters['is_txt_stim']: # construct an array of paths to images path = parameters['path_to_presentation_images'] - stimulus_array = [] + stimulus_array: List[str] = [] for stimulus_filename in sorted(os.listdir(path)): # PLUS.png is reserved for the fixation symbol if stimulus_filename.endswith( @@ -36,9 +46,21 @@ def alphabet(parameters=None, include_path=True, backspace=BACKSPACE_CHAR, space def qwerty_order(is_txt_stim: bool = True, space: str = SPACE_CHAR, - backspace: str = BACKSPACE_CHAR) -> Callable: - """Returns a function that can be used to sort the alphabet symbols - in QWERTY order. Note that sorting only works for text stim. + backspace: str = BACKSPACE_CHAR) -> Callable[[str], int]: + """Returns a function that can be used to sort alphabet symbols in QWERTY order. + + Note that sorting only works for text stimuli. + + Args: + is_txt_stim (bool, optional): If True, indicates text stimuli. Defaults to True. + space (str, optional): The character representing space. Defaults to `SPACE_CHAR`. + backspace (str, optional): The character representing backspace. Defaults to `BACKSPACE_CHAR`. + + Returns: + Callable[[str], int]: A function that takes a symbol string and returns its QWERTY index. + + Raises: + NotImplementedError: If `is_txt_stim` is False, as QWERTY ordering is not implemented for images. """ if not is_txt_stim: raise NotImplementedError('QWERTY ordering not implemented for images') @@ -52,9 +74,21 @@ def qwerty_order(is_txt_stim: bool = True, def frequency_order( is_txt_stim: bool = True, space: str = SPACE_CHAR, - backspace: str = BACKSPACE_CHAR) -> Callable: - """Returns a function that can be used to sort the alphabet symbols - in most frequently used order in the English language. + backspace: str = BACKSPACE_CHAR) -> Callable[[str], int]: + """Returns a function that can be used to sort alphabet symbols by frequency of use in English." + + Note that sorting only works for text stimuli. + + Args: + is_txt_stim (bool, optional): If True, indicates text stimuli. Defaults to True. + space (str, optional): The character representing space. Defaults to `SPACE_CHAR`. + backspace (str, optional): The character representing backspace. Defaults to `BACKSPACE_CHAR`. + + Returns: + Callable[[str], int]: A function that takes a symbol string and returns its frequency order index. + + Raises: + NotImplementedError: If `is_txt_stim` is False, as frequency ordering is not implemented for images. """ if not is_txt_stim: raise NotImplementedError( diff --git a/bcipy/core/tests/test_list.py b/bcipy/core/tests/test_list.py index 88dcbd3e7..8759b2795 100644 --- a/bcipy/core/tests/test_list.py +++ b/bcipy/core/tests/test_list.py @@ -95,7 +95,8 @@ def test_pairwise(self): """Test pairwise iterator""" iterable = 'ABCDEFG' response = pairwise(iterable) - expected = [('A', 'B'), ('B', 'C'), ('C', 'D'), ('D', 'E'), ('E', 'F'), ('F', 'G')] + expected = [('A', 'B'), ('B', 'C'), ('C', 'D'), + ('D', 'E'), ('E', 'F'), ('F', 'G')] self.assertListEqual(expected, list(response)) diff --git a/bcipy/core/tests/test_raw_data.py b/bcipy/core/tests/test_raw_data.py index 6d8294be0..2de57891e 100644 --- a/bcipy/core/tests/test_raw_data.py +++ b/bcipy/core/tests/test_raw_data.py @@ -312,7 +312,8 @@ def test_data_by_channel_applies_transformation(self): transform = mock() # note data here should be returned as a nd.array. for mocking we don't care as much - when(RawData).apply_transform(any(), transform).thenReturn((data, self.sample_rate)) + when(RawData).apply_transform( + any(), transform).thenReturn((data, self.sample_rate)) resp, fs = data.by_channel(transform=transform) self.assertEqual(self.sample_rate, fs) @@ -369,8 +370,10 @@ def test_data_by_channel_map_applies_transformation(self): transform = mock() expected_output, expected_fs = data.by_channel() # note data here should be returned as a nd.array. for mocking we don't care as much - when(RawData).by_channel(transform).thenReturn((expected_output, expected_fs)) - _, channels, fs = data.by_channel_map(channel_map=channel_map, transform=transform) + when(RawData).by_channel(transform).thenReturn( + (expected_output, expected_fs)) + _, channels, fs = data.by_channel_map( + channel_map=channel_map, transform=transform) self.assertEqual(expected_fs, fs) self.assertEqual(expected_channels, channels) @@ -389,7 +392,8 @@ def test_get_1020_channels(self): def test_get_1020_channel_map(self): """Tests that the 10-20 channel map is correctly generated.""" # all but the last channel are valid 10-20 channels - channels = ['Fp1', 'Fp2', 'F3', 'F4', 'C3', 'C4', 'P3', 'P4', 'O1', 'invalid'] + channels = ['Fp1', 'Fp2', 'F3', 'F4', 'C3', + 'C4', 'P3', 'P4', 'O1', 'invalid'] channel_map = get_1020_channel_map(channels) self.assertEqual(10, len(channel_map)) self.assertEqual(0, channel_map[-1]) diff --git a/bcipy/core/tests/test_report.py b/bcipy/core/tests/test_report.py index 4837bbdcc..a181d8c7f 100644 --- a/bcipy/core/tests/test_report.py +++ b/bcipy/core/tests/test_report.py @@ -61,7 +61,8 @@ def test_add_section(self): self.assertEqual(report.sections, [report_section]) another_report_section = SessionReportSection(summary=summary) report.add(another_report_section) - self.assertEqual(report.sections, [report_section, another_report_section]) + self.assertEqual(report.sections, [ + report_section, another_report_section]) def test_save(self): report = Report(self.temp_dir) @@ -69,12 +70,14 @@ def test_save(self): report_section = SessionReportSection(summary) report.add(report_section) report.save() - self.assertTrue(os.path.exists(os.path.join(self.temp_dir, report.name))) + self.assertTrue(os.path.exists( + os.path.join(self.temp_dir, report.name))) def test_save_no_sections(self): report = Report(self.temp_dir) report.save() - self.assertTrue(os.path.exists(os.path.join(self.temp_dir, report.name))) + self.assertTrue(os.path.exists( + os.path.join(self.temp_dir, report.name))) def test_complile_adds_section_and_header(self): report = Report(self.temp_dir) diff --git a/bcipy/core/tests/test_stimuli.py b/bcipy/core/tests/test_stimuli.py index 3c5861e28..5f53a289d 100644 --- a/bcipy/core/tests/test_stimuli.py +++ b/bcipy/core/tests/test_stimuli.py @@ -500,11 +500,13 @@ def test_random_target_positions_all_nontargets(self): def test_generate_targets(self): """Test target generation""" symbols = ['A', 'B', 'C', 'D'] - targets = generate_targets(symbols, inquiry_count=9, percentage_without_target=0) + targets = generate_targets( + symbols, inquiry_count=9, percentage_without_target=0) self.assertEqual(len(targets), 8) self.assertTrue(all(val == 2 for val in Counter(targets).values())) - targets = generate_targets(symbols, inquiry_count=9, percentage_without_target=50) + targets = generate_targets( + symbols, inquiry_count=9, percentage_without_target=50) self.assertEqual(len(targets), 4) self.assertTrue(all(val == 1 for val in Counter(targets).values())) @@ -536,7 +538,8 @@ def test_inquiry_target_with_none_position(self): target = inquiry_target(inquiry, None, symbols, next_targets) self.assertTrue(target not in inquiry) self.assertTrue(target in symbols) - self.assertSequenceEqual(inquiry, ['C', 'A', 'F', 'E'], 'inquiry should not have changed') + self.assertSequenceEqual( + inquiry, ['C', 'A', 'F', 'E'], 'inquiry should not have changed') def test_inquiry_target_missing(self): """Test inquiry target where none of the next_targets are present in @@ -550,8 +553,10 @@ def test_inquiry_target_missing(self): next_targets=next_targets) self.assertTrue(target in inquiry) self.assertTrue(target not in next_targets) - self.assertSequenceEqual(inquiry, ['C', 'A', 'F', 'E'], 'inquiry should not have changed') - self.assertSequenceEqual(next_targets, ['Q', 'D'], 'next_targets should not have changed') + self.assertSequenceEqual( + inquiry, ['C', 'A', 'F', 'E'], 'inquiry should not have changed') + self.assertSequenceEqual( + next_targets, ['Q', 'D'], 'next_targets should not have changed') def test_inquiry_target_no_targets(self): """Test inquiry_target when no next_targets are provided""" @@ -562,8 +567,10 @@ def test_inquiry_target_no_targets(self): target_position=0, symbols=symbols, next_targets=next_targets) - self.assertEqual(target, 'C', 'should have used the target_position to get the target') - self.assertSequenceEqual(inquiry, ['C', 'A', 'F', 'E'], 'inquiry should not have changed') + self.assertEqual( + target, 'C', 'should have used the target_position to get the target') + self.assertSequenceEqual( + inquiry, ['C', 'A', 'F', 'E'], 'inquiry should not have changed') def test_inquiry_target_last_target(self): """Test inquiry target behavior.""" @@ -776,7 +783,8 @@ def test_best_case_inq_gen_is_random(self): is_txt=True, inq_constants=['<']) samps.add(tuple(samples[0])) - self.assertTrue(len(samps) > 1, '`best_case_rsvp_inq_gen` Should produce random results') + self.assertTrue( + len(samps) > 1, '`best_case_rsvp_inq_gen` Should produce random results') class TestJitteredTiming(unittest.TestCase): @@ -894,7 +902,8 @@ def test_trial_reshaper_with_no_channel_map(self): poststimulus_length=trial_length_s ) trial_length_samples = int(sample_rate * trial_length_s) - expected_shape = (self.channel_number, len(self.target_info), trial_length_samples) + expected_shape = (self.channel_number, len( + self.target_info), trial_length_samples) self.assertTrue(np.all(labels == [1, 0, 0])) self.assertTrue(reshaped_trials.shape == expected_shape) @@ -972,7 +981,8 @@ def test_inquiry_reshaper_with_no_channel_map(self): channel_map=None, poststimulus_length=self.trial_length ) - expected_shape = (self.n_channel, self.n_inquiry, self.samples_per_inquiry) + expected_shape = (self.n_channel, self.n_inquiry, + self.samples_per_inquiry) self.assertTrue(reshaped_data.shape == expected_shape) self.assertTrue(np.all(labels == self.true_labels)) diff --git a/bcipy/core/tests/test_triggers.py b/bcipy/core/tests/test_triggers.py index 00ddccf3d..9f33fd0b1 100644 --- a/bcipy/core/tests/test_triggers.py +++ b/bcipy/core/tests/test_triggers.py @@ -170,8 +170,10 @@ def setUp(self, mock_file): self.flush = FlushFrequency.END self.file = f'{self.path_name}/{self.file_name}.txt' # with patch('builtins.open', mock_open(read_data='data')) as _: - self.handler = TriggerHandler(self.path_name, self.file_name, self.flush) - self.mock_file.assert_called_once_with(self.file, 'w+', encoding=self.handler.encoding) + self.handler = TriggerHandler( + self.path_name, self.file_name, self.flush) + self.mock_file.assert_called_once_with( + self.file, 'w+', encoding=self.handler.encoding) def tearDown(self): unstub() @@ -179,7 +181,8 @@ def tearDown(self): def test_file_exist_exception(self): with open(self.file, 'w+', encoding=self.handler.encoding) as _: with self.assertRaises(Exception): - TriggerHandler(self.path_name, self.file_name, FlushFrequency.END) + TriggerHandler(self.path_name, self.file_name, + FlushFrequency.END) os.remove(self.file) def test_add_triggers_returns_list_of_triggers(self): @@ -478,7 +481,8 @@ def test_starting_offsets_by_device_defaults(self): """Test default values""" triggers = read_data('''J prompt 6.15 + fixation 8.11'''.split('\n')) - offsets = starting_offsets_by_device(triggers, device_types=['EEG', 'EYETRACKER']) + offsets = starting_offsets_by_device( + triggers, device_types=['EEG', 'EYETRACKER']) self.assertEqual(len(offsets), 2) self.assertEqual(offsets['EEG'].time, 0.0) self.assertEqual(offsets['EYETRACKER'].time, 0.0) diff --git a/bcipy/core/triggers.py b/bcipy/core/triggers.py index 906982ee1..95363f820 100644 --- a/bcipy/core/triggers.py +++ b/bcipy/core/triggers.py @@ -1,3 +1,8 @@ +"""Trigger utilities for BciPy core. + +This module provides classes and functions for managing triggers and calibration events in BciPy experiments. +""" + import logging import os from enum import Enum @@ -42,31 +47,50 @@ class CalibrationType(Enum): IMAGE = 'image' @classmethod - def list(cls): - """Returns all enum values as a list""" + def list(cls) -> List[str]: + """Returns all enum values as a list. + + Returns: + List[str]: List of all enum values. + """ return list(map(lambda c: c.value, cls)) class TriggerCallback: + """Callback handler for trigger events. + + Attributes: + timing (Optional[Tuple[str, float]]): Timing information for the trigger. + first_time (bool): Flag indicating if this is the first trigger. + """ timing: Optional[Tuple[str, float]] = None first_time: bool = True def callback(self, clock: Clock, stimuli: str) -> None: + """Callback function for trigger events. + + Args: + clock (Clock): Clock instance for timing. + stimuli (str): Stimulus identifier. + """ if self.first_time: self.timing = (stimuli, clock.getTime()) self.first_time = False - def reset(self): + def reset(self) -> None: + """Reset the callback state.""" self.timing = None self.first_time = True -def _calibration_trigger(experiment_clock: Clock, - trigger_type: str = CalibrationType.TEXT.value, - trigger_name: str = 'calibration', - trigger_time: float = 1, - display=None, - on_trigger=None) -> Tuple[str, float]: +def _calibration_trigger( + experiment_clock: Clock, + trigger_type: str = CalibrationType.TEXT.value, + trigger_name: str = 'calibration', + trigger_time: float = 1, + display: Optional[visual.Window] = None, + on_trigger: Optional[Callable[[str], None]] = None +) -> Tuple[str, float]: """Calibration Trigger. Outputs triggers for the purpose of calibrating data and stimuli. @@ -74,20 +98,22 @@ def _calibration_trigger(experiment_clock: Clock, code aims to operationalize the approach to finding the correct DAQ samples in relation to our trigger code. - PARAMETERS - --------- - experiment_clock(clock): clock with getTime() method, which is used in the code - to report timing of stimuli - trigger_type(string): type of trigger that is desired (text, image, etc) - trigger_name(string): name of the trigger used for callbacks / labeling - trigger_time(float): time to display the trigger. Can also be used as a buffer. - display(DisplayWindow): a window that can display stimuli. Currently, a Psychopy window. - on_trigger(function): optional callback; if present gets called - when the calibration trigger is fired; accepts a single - parameter for the timing information. - Return: - timing(array): timing values for the calibration triggers to be written to trigger file or - used to calculate offsets. + Args: + experiment_clock (Clock): Clock with getTime() method, which is used in the code + to report timing of stimuli. + trigger_type (str): Type of trigger that is desired (text, image, etc). + trigger_name (str): Name of the trigger used for callbacks / labeling. + trigger_time (float): Time to display the trigger. Can also be used as a buffer. + display (Optional[visual.Window]): A window that can display stimuli. Currently, a Psychopy window. + on_trigger (Optional[Callable[[str], None]]): Optional callback; if present gets called + when the calibration trigger is fired; accepts a single parameter for the timing information. + + Returns: + Tuple[str, float]: Timing values for the calibration triggers to be written to trigger file or + used to calculate offsets. + + Raises: + BciPyCoreException: If trigger type is invalid or display is required but not provided. """ trigger_callback = TriggerCallback() @@ -109,9 +135,11 @@ def _calibration_trigger(experiment_clock: Clock, pos=(-.5, -.5), mask=None, ori=0.0) - calibration_box.size = resize_image(CALIBRATION_IMAGE_PATH, display.size, 0.75) + calibration_box.size = resize_image( + CALIBRATION_IMAGE_PATH, display.size, 0.75) - display.callOnFlip(trigger_callback.callback, experiment_clock, trigger_name) + display.callOnFlip(trigger_callback.callback, + experiment_clock, trigger_name) if on_trigger is not None: display.callOnFlip(on_trigger, trigger_name) @@ -137,7 +165,14 @@ def _calibration_trigger(experiment_clock: Clock, def trigger_durations(params: Parameters) -> Dict[str, float]: - """Duration for each type of trigger given in seconds.""" + """Get duration for each type of trigger given in seconds. + + Args: + params (Parameters): Parameters containing timing information. + + Returns: + Dict[str, float]: Dictionary mapping trigger types to their durations in seconds. + """ return { 'offset': 0.0, 'preview': params['preview_inquiry_length'], @@ -149,9 +184,7 @@ def trigger_durations(params: Parameters) -> Dict[str, float]: class TriggerType(Enum): - """ - Enum for the primary types of Triggers. - """ + """Enum for the primary types of Triggers.""" NONTARGET = "nontarget" TARGET = "target" @@ -165,54 +198,86 @@ class TriggerType(Enum): @classmethod def list(cls) -> List[str]: - """Returns all enum values as a list""" + """Returns all enum values as a list. + + Returns: + List[str]: List of all enum values. + """ return list(map(lambda c: c.value, cls)) @classmethod def pre_fixation(cls) -> List['TriggerType']: """Returns the subset of TriggerTypes that occur before and including - the FIXATION trigger.""" + the FIXATION trigger. + + Returns: + List[TriggerType]: List of trigger types that occur before fixation. + """ return [ TriggerType.FIXATION, TriggerType.PROMPT, TriggerType.SYSTEM, TriggerType.OFFSET ] def __str__(self) -> str: + """String representation of the trigger type. + + Returns: + str: String representation of the trigger type. + """ return f'{self.value}' class Trigger(NamedTuple): - """ - Object that encompasses data for a single trigger instance. + """Object that encompasses data for a single trigger instance. + + Attributes: + label (str): Label for the trigger. + type (TriggerType): Type of the trigger. + time (float): Timestamp of the trigger. """ label: str type: TriggerType time: float - def __repr__(self): + def __repr__(self) -> str: + """String representation of the trigger. + + Returns: + str: String representation of the trigger. + """ return f'Trigger: label=[{self.label}] type=[{self.type}] time=[{self.time}]' - def with_offset(self, offset: float): - """Construct a copy of this Trigger with the offset adjusted.""" + def with_offset(self, offset: float) -> 'Trigger': + """Construct a copy of this Trigger with the offset adjusted. + + Args: + offset (float): Offset to apply to the trigger time. + + Returns: + Trigger: New trigger instance with adjusted time. + """ return Trigger(self.label, self.type, self.time + offset) @classmethod - def from_list(cls, lst: List[str]): + def from_list(cls, lst: List[str]) -> 'Trigger': """Constructs a Trigger from a serialized representation. - Parameters - ---------- - lst - serialized representation [label, type, stamp] + Args: + lst (List[str]): Serialized representation [label, type, stamp]. + + Returns: + Trigger: New trigger instance. + + Raises: + AssertionError: If input list does not have exactly 3 elements. """ assert len(lst) == 3, "Input must have a label, type, and stamp" return cls(lst[0], TriggerType(lst[1]), float(lst[2])) class FlushFrequency(Enum): - """ - Enum that defines how often list of Triggers will be written and dumped. - """ + """Enum that defines how often list of Triggers will be written and dumped.""" EVERY = "flush after every trigger addition" END = "flush at end of session" @@ -221,14 +286,15 @@ class FlushFrequency(Enum): def read_data(lines: Iterable[str]) -> List[Trigger]: """Read raw trigger data from the given source. - Parameters - ---------- - data - iterable object where each item is a str with data for a single + Args: + lines (Iterable[str]): Iterable object where each item is a str with data for a single trigger. - Returns - ------- - list of all Triggers in the data. + Returns: + List[Trigger]: List of all Triggers in the data. + + Raises: + BciPyCoreException: If there is an error reading a trigger from any line. """ triggers = [] for i, line in enumerate(lines): @@ -244,6 +310,13 @@ def read_data(lines: Iterable[str]) -> List[Trigger]: def offset_label(device_type: Optional[str] = None, prefix: str = 'starting_offset') -> str: """Compute the offset label for the given device. + + Args: + device_type (Optional[str]): Type of device. If None or 'EEG', returns default prefix. + prefix (str): Prefix for the offset label. Defaults to 'starting_offset'. + + Returns: + str: Offset label for the device. """ if not device_type or device_type == 'EEG': return prefix @@ -251,7 +324,18 @@ def offset_label(device_type: Optional[str] = None, prefix: str = 'starting_offs def offset_device(label: str, prefix: str = 'starting_offset') -> str: - """Given an label, determine the device type""" + """Given a label, determine the device type. + + Args: + label (str): Label to parse. + prefix (str): Expected prefix of the label. Defaults to 'starting_offset'. + + Returns: + str: Device type extracted from the label. + + Raises: + AssertionError: If label does not start with the given prefix. + """ assert label.startswith( prefix), "Label must start with the given prefix" try: @@ -262,12 +346,19 @@ def offset_device(label: str, prefix: str = 'starting_offset') -> str: def starting_offsets_by_device( - triggers: List[Trigger], - device_types: Optional[List[str]] = None) -> Dict[str, Trigger]: + triggers: List[Trigger], + device_types: Optional[List[str]] = None +) -> Dict[str, Trigger]: """Returns a dict of starting_offset triggers keyed by device type. - If device_types are provided, an entry is created for each one, using a - default offset of 0.0 if a match is not found. + Args: + triggers (List[Trigger]): List of triggers to search through. + device_types (Optional[List[str]]): List of device types to include in the result. + If provided, an entry is created for each one, using a default offset of 0.0 + if a match is not found. + + Returns: + Dict[str, Trigger]: Dictionary mapping device types to their offset triggers. """ offset_triggers = {} for trg in triggers: @@ -285,26 +376,23 @@ def starting_offsets_by_device( return offset_triggers -def find_starting_offset(triggers: List[Trigger], - device_type: Optional[str] = None) -> Trigger: +def find_starting_offset( + triggers: List[Trigger], + device_type: Optional[str] = None +) -> Trigger: """Given a list of raw trigger data, determine the starting offset for the given device. The returned trigger has the timestamp of the first sample recorded for the device. - If no device is provided the EEG offset will be used. If there are - no offset triggers in the given data a Trigger with offset of 0.0 will be - returned. - - Parameters - ---------- - triggers - list of trigger data; should include Triggers of - TriggerType.OFFSET - device_type - each device will generally have a different offset. This + Args: + triggers (List[Trigger]): List of trigger data; should include Triggers of + TriggerType.OFFSET. + device_type (Optional[str]): Each device will generally have a different offset. This parameter is used to determine which trigger to use. If not given - the EEG offset will be used by default. Ex. 'EYETRACKER' - Returns - ------- - The Trigger for the first matching offset for the given device, or a + the EEG offset will be used by default. Ex. 'EYETRACKER'. + + Returns: + Trigger: The Trigger for the first matching offset for the given device, or a Trigger with offset of 0.0 if a matching offset was not found. """ label = offset_label(device_type) @@ -318,12 +406,14 @@ def find_starting_offset(triggers: List[Trigger], def read(path: str) -> List[Trigger]: """Read all Triggers from the given text file. - Parameters - ---------- - path - trigger (.txt) file to read - Returns - ------- - triggers + Args: + path (str): Trigger (.txt) file to read. + + Returns: + List[Trigger]: List of triggers read from the file. + + Raises: + FileNotFoundError: If the file does not exist or is not a .txt file. """ if not path.endswith('.txt') or not os.path.exists(path): raise FileNotFoundError( @@ -333,21 +423,21 @@ def read(path: str) -> List[Trigger]: return triggers -def apply_offsets(triggers: List[Trigger], - starting_offset: Trigger, - static_offset: float = 0.0) -> List[Trigger]: +def apply_offsets( + triggers: List[Trigger], + starting_offset: Trigger, + static_offset: float = 0.0 +) -> List[Trigger]: """Returns a list of triggers with timestamps adjusted relative to the device start time. Offset triggers are filtered out if present. - Parameters - ---------- - triggers - list of triggers - starting_offset - offset from the device start time. - static_offset - the measured static system offset + Args: + triggers (List[Trigger]): List of triggers to adjust. + starting_offset (Trigger): Offset from the device start time. + static_offset (float): The measured static system offset. - Returns - ------- - a list of triggers with timestamps relative to the starting_offset + Returns: + List[Trigger]: List of triggers with timestamps relative to the starting_offset. """ total_offset = starting_offset.time + static_offset return [ @@ -356,28 +446,59 @@ def apply_offsets(triggers: List[Trigger], ] -def exclude_types(triggers: List[Trigger], - types: Optional[List[TriggerType]] = None) -> List[Trigger]: - """Filter the list of triggers to exclude the provided types""" +def exclude_types( + triggers: List[Trigger], + types: Optional[List[TriggerType]] = None +) -> List[Trigger]: + """Filter the list of triggers to exclude the provided types. + + Args: + triggers (List[Trigger]): List of triggers to filter. + types (Optional[List[TriggerType]]): List of trigger types to exclude. + + Returns: + List[Trigger]: Filtered list of triggers. + """ if not types: return triggers return [trg for trg in triggers if trg.type not in types] class TriggerHandler: - """ - Class that contains methods to work with Triggers, including adding and + """Class that contains methods to work with Triggers, including adding and writing triggers and loading triggers from a txt file. + + Attributes: + encoding (str): File encoding to use. + path (str): Path to the trigger file. + file_name (str): Name of the trigger file. + flush (FlushFrequency): Frequency at which to flush triggers to file. + triggers (List[Trigger]): List of triggers being handled. + file_path (str): Full path to the trigger file. + file (TextIO): File handle for the trigger file. """ encoding = DEFAULT_ENCODING - def __init__(self, - path: str, - file_name: str, - flush: FlushFrequency): + def __init__( + self, + path: str, + file_name: str, + flush: FlushFrequency + ) -> None: + """Initialize the TriggerHandler. + + Args: + path (str): Path to the trigger file. + file_name (str): Name of the trigger file. + flush (FlushFrequency): Frequency at which to flush triggers to file. + + Raises: + Exception: If the file already exists. + """ self.path = path - self.file_name = f'{file_name}.txt' if not file_name.endswith('.txt') else file_name + self.file_name = f'{file_name}.txt' if not file_name.endswith( + '.txt') else file_name self.flush = flush self.triggers: List[Trigger] = [] self.file_path = f'{self.path}/{self.file_name}' @@ -390,33 +511,29 @@ def __init__(self, self.file = open(self.file_path, 'w+', encoding=self.encoding) def close(self) -> None: - """Close. - - Ensures all data is written and file is closed properly. - """ + """Close the trigger file and ensure all data is written.""" self.write() self.file.close() def write(self) -> None: - """ - Writes current Triggers in self.triggers[] to .txt file in self.file_name. + """Writes current Triggers in self.triggers[] to .txt file in self.file_name. File writes in the format "label, targetness, time". """ - for trigger in self.triggers: - self.file.write(f'{trigger.label} {trigger.type.value} {trigger.time}\n') + self.file.write( + f'{trigger.label} {trigger.type.value} {trigger.time}\n') self.triggers = [] @staticmethod def read_text_file(path: str) -> Tuple[List[Trigger], float]: """Read Triggers from the given text file. - Parameters - ---------- - path - trigger (.txt) file to read - Returns - ------- - triggers, offset + + Args: + path (str): Trigger (.txt) file to read. + + Returns: + Tuple[List[Trigger], float]: List of triggers and offset time. """ triggers = read(path) offset = find_starting_offset(triggers) @@ -424,36 +541,26 @@ def read_text_file(path: str) -> Tuple[List[Trigger], float]: return triggers, offset.time @staticmethod - def load(path: str, - offset: float = 0.0, - exclusion: Optional[List[TriggerType]] = None, - device_type: Optional[str] = None) -> List[Trigger]: - """ - Loads a list of triggers from a .txt of triggers. - - Exclusion based on type only (ex. exclusion=[TriggerType.Fixation]) - - 1. Checks if .txt file exists at path - 2. Loads the triggers data as a list of lists - 3. If offset provided, adds it to the time as float - 4. If exclusion provided, filters those triggers - 5. Casts all loaded and modified triggers to Trigger - 6. Returns as a List[Triggers] - - Parameters - ---------- - path (str): name or file path of .txt trigger file to be loaded. - Input string must include file extension (.txt). - offset (Optional float): if desired, time offset for all loaded triggers, - positive number for adding time, negative number for subtracting time. - exclusion (Optional List[TriggerType]): if desired, list of TriggerType's - to be removed from the loaded trigger list. - device_type : optional; if specified looks for the starting_offset for - a given device; default is to use the EEG offset. - - Returns - ------- - List of Triggers from loaded .txt file with desired modifications + def load( + path: str, + offset: float = 0.0, + exclusion: Optional[List[TriggerType]] = None, + device_type: Optional[str] = None + ) -> List[Trigger]: + """Loads a list of triggers from a .txt of triggers. + + Args: + path (str): Name or file path of .txt trigger file to be loaded. + Input string must include file extension (.txt). + offset (float): If desired, time offset for all loaded triggers, + positive number for adding time, negative number for subtracting time. + exclusion (Optional[List[TriggerType]]): If desired, list of TriggerType's + to be removed from the loaded trigger list. + device_type (Optional[str]): If specified looks for the starting_offset for + a given device; default is to use the EEG offset. + + Returns: + List[Trigger]: List of Triggers from loaded .txt file with desired modifications. """ excluded_types = exclusion or [] triggers = read(path) @@ -463,17 +570,14 @@ def load(path: str, static_offset=offset) def add_triggers(self, triggers: List[Trigger]) -> List[Trigger]: - """ - Adds provided list of Triggers to self.triggers. + """Adds provided list of Triggers to self.triggers. - Parameters - ---------- - triggers (List[Triggers]): list of Trigger objects to be added to the - handler's list of Triggers (self.triggers). + Args: + triggers (List[Trigger]): List of Trigger objects to be added to the + handler's list of Triggers (self.triggers). - Returns - ------- - Returns list of Triggers currently part of Handler + Returns: + List[Trigger]: Returns list of Triggers currently part of Handler. """ self.triggers.extend(triggers) @@ -483,11 +587,22 @@ def add_triggers(self, triggers: List[Trigger]) -> List[Trigger]: return self.triggers -def convert_timing_triggers(timing: List[Tuple[str, float]], target_stimuli: str, - trigger_type: Callable) -> List[Trigger]: +def convert_timing_triggers( + timing: List[Tuple[str, float]], + target_stimuli: str, + trigger_type: Callable +) -> List[Trigger]: """Convert Stimuli Times to Triggers. Using the stimuli presentation times provided by the display, convert them into BciPy Triggers. + + Args: + timing (List[Tuple[str, float]]): List of (symbol, time) tuples. + target_stimuli (str): Target stimulus identifier. + trigger_type (Callable): Function to determine trigger type. + + Returns: + List[Trigger]: List of converted triggers. """ return [ Trigger(symbol, trigger_type(symbol, target_stimuli, i), time) @@ -495,29 +610,30 @@ def convert_timing_triggers(timing: List[Tuple[str, float]], target_stimuli: str ] -def load_triggers(trigger_path: str, - remove_pre_fixation: bool = True, - offset: float = 0.0, - exclusion: Optional[List[TriggerType]] = None, - device_type: Optional[str] = None, - apply_starting_offset: bool = True) -> List[Trigger]: +def load_triggers( + trigger_path: str, + remove_pre_fixation: bool = True, + offset: float = 0.0, + exclusion: Optional[List[TriggerType]] = None, + device_type: Optional[str] = None, + apply_starting_offset: bool = True +) -> List[Trigger]: """Trigger Decoder. Given a path to trigger data, this method loads valid Triggers. - Parameters - ---------- - trigger_path: path to triggers file - remove_pre_fixation: boolean to determine whether any stimuli before a fixation + system should be removed - offset: static offset value to apply to triggers. - exclusion: any TriggerTypes to be filtered from data returned - device_type: used to determine which starting_offset value to use; if + Args: + trigger_path (str): Path to triggers file. + remove_pre_fixation (bool): Boolean to determine whether any stimuli before a fixation + system should be removed. + offset (float): Static offset value to apply to triggers. + exclusion (Optional[List[TriggerType]]): Any TriggerTypes to be filtered from data returned. + device_type (Optional[str]): Used to determine which starting_offset value to use; if a 'starting_offset' trigger is found it will be applied. - apply_starting_offset: if False, does not apply the starting offset for + apply_starting_offset (bool): If False, does not apply the starting offset for the given device_type. - Returns - ------- - list of Triggers + + Returns: + List[Trigger]: List of processed triggers. """ excluded_types = exclusion or [] excluded_types += TriggerType.pre_fixation() if remove_pre_fixation else [ @@ -535,29 +651,29 @@ def load_triggers(trigger_path: str, def trigger_decoder( - trigger_path: str, - remove_pre_fixation: bool = True, - offset: float = 0.0, - exclusion: Optional[List[TriggerType]] = None, - device_type: Optional[str] = None, - apply_starting_offset: bool = True) -> Tuple[list, list, list]: + trigger_path: str, + remove_pre_fixation: bool = True, + offset: float = 0.0, + exclusion: Optional[List[TriggerType]] = None, + device_type: Optional[str] = None, + apply_starting_offset: bool = True +) -> Tuple[List[str], List[float], List[str]]: """Trigger Decoder. Given a path to trigger data, this method loads valid Triggers and returns their type, timing and label. - Parameters - ---------- - trigger_path: path to triggers file - remove_pre_fixation: boolean to determine whether any stimuli before a fixation + system should be removed - offset: static offset value to apply to triggers. - exclusion: any TriggerTypes to be filtered from data returned - device_type: used to determine which starting_offset value to use; if + Args: + trigger_path (str): Path to triggers file. + remove_pre_fixation (bool): Boolean to determine whether any stimuli before a fixation + system should be removed. + offset (float): Static offset value to apply to triggers. + exclusion (Optional[List[TriggerType]]): Any TriggerTypes to be filtered from data returned. + device_type (Optional[str]): Used to determine which starting_offset value to use; if a 'starting_offset' trigger is found it will be applied. - apply_starting_offset: if False, does not apply the starting offset for + apply_starting_offset (bool): If False, does not apply the starting offset for the given device_type. - Returns - ------- - tuple: trigger_type, trigger_timing, trigger_label + + Returns: + Tuple[List[str], List[float], List[str]]: Tuple containing trigger types, timings, and labels. """ triggers = load_triggers(trigger_path, remove_pre_fixation, offset, exclusion, device_type, apply_starting_offset) diff --git a/bcipy/demo/bci_main_demo.py b/bcipy/demo/bci_main_demo.py index cc51c5385..f776b7b30 100644 --- a/bcipy/demo/bci_main_demo.py +++ b/bcipy/demo/bci_main_demo.py @@ -1,9 +1,11 @@ from bcipy.config import DEFAULT_PARAMETERS_PATH from bcipy.main import bci_main -parameter_location = DEFAULT_PARAMETERS_PATH # Path to a valid BciPy parameters file +# Path to a valid BciPy parameters file +parameter_location = DEFAULT_PARAMETERS_PATH user = 'test_demo_user' # User ID -experiment_id = 'default' # This will run two tasks: RSVP Calibration and Matrix Calibration +# This will run two tasks: RSVP Calibration and Matrix Calibration +experiment_id = 'default' alert = False # Set to True to alert user when tasks are complete visualize = False # Set to True to visualize data at the end of a task fake_data = True # Set to True to use fake acquisition data during the session diff --git a/bcipy/display/README.md b/bcipy/display/README.md index f09c5fea0..5df034936 100644 --- a/bcipy/display/README.md +++ b/bcipy/display/README.md @@ -1,22 +1,160 @@ # Display Module -The Display module defines the visual presentation logic needed for any tasks in BciPy. Most displays take in a large number of user-configured parameters (including text size and font) and handle - passing back timestamps from their stimuli presentations for classification. - -### Structure -`display` - `main`: Initializes a display window and contains useful display objects - `paradigm`: top level module holding all bcipy related display objects - `rsvp`: RSVP related display objects and functions. - `mode`: defines task specific displays - `matrix`: matrix related display objects and functions. Currently, only single character presentation is available. - `mode`: defines task specific displays - `tests`: tests for display module - `demo`: demo code for the display module - -### Guidelines - -- Add new modes in their own submodule -- Inherit base classes defined in display.py where possible -- Test timing between your code and the devices you're using - - consult psychopy (or other display codebase) for best practices for your OS +The Display module is a core component of BciPy that handles all visual presentation logic for BCI tasks. It provides a flexible and configurable framework for creating and managing visual stimuli, with precise timing control and synchronization capabilities. Please note that the VEPDisplay is still in progress. Please use with discretion! + +## Structure + +The module is organized into several key components: + +- `main/`: Core display initialization and window management +- `paradigm/`: BCI paradigm-specific display implementations + - `rsvp/`: RSVP Keyboard display components + - `matrix/`: Matrix Speller display components + - `vep/`: *WIP* Visual Evoked Potetinal display components. +- `components/`: Reusable display components +- `tests/`: Unit and integration tests +- `demo/`: Example implementations and usage + +## Core Concepts + +### Display System + +The display system is built on several key abstractions: + +#### 1. Display Base Class + +The base `Display` class provides core functionality for all displays. It is defined in `main.py`. + +- Window management and initialization +- Stimulus presentation timing +- Task bar and information display +- Trigger handling and calibration + +#### 2. Stimuli Properties + +Configure visual properties of stimuli: + +```python +from bcipy.display import StimuliProperties + +properties = StimuliProperties( + stim_font='Arial', + stim_height=32, + stim_pos=(0, 0) +) +``` + +#### 3. Information Properties + +Manage task information display: + +```python +from bcipy.display import InformationProperties + +info = InformationProperties( + info_color='white', + info_text="Task Progress", + info_font='Consolas', + info_height=24, + info_pos=(0, 0) +) +``` + +### Key Features + +#### Window Management + +- PsychoPy window initialization +- Screen resolution handling +- Fullscreen/windowed modes +- Refresh rate synchronization + +#### Stimulus Presentation + +- Precise timing control +- Animation and transitions +- Trigger synchronization +- Event logging + +#### Task Display + +- Progress tracking +- User feedback +- Custom UI elements +- Dynamic updates + +#### Layout System + +- Flexible positioning +- Responsive design +- Component alignment +- Screen space optimization + +## Supported Paradigms + +### RSVP Keyboard + +The RSVP Keyboard is an EEG-based typing system that presents symbols sequentially at a single location. Users select symbols by attending to their target and eliciting a P300 response. + +Key features: + +- Single-location presentation +- Temporal separation of stimuli +- P300-based selection +- Configurable timing + +### Matrix Speller + +The Matrix Speller presents symbols in a grid layout, highlighting subsets of symbols to elicit P300 responses for selection. + +Key features: + +- Grid-based layout +- P300-based selection +- Configurable matrix size + +## Development Guidelines + +1. **Adding New Paradigms** + - Create a new submodule in `paradigm/` + - Inherit from base `Display` class + - Implement required interface methods + - Add comprehensive tests + +2. **Timing Considerations** + - Test timing with actual hardware + - Account for refresh rate variations + - Log timing events for analysis + - Use PsychoPy's timing functions + +3. **Best Practices** + - Follow PsychoPy guidelines + - Document timing parameters + - Include example configurations + - Add unit tests for timing + +## References + +1. RSVP Keyboard: + +```text +Orhan, U., et al. (2012). RSVP Keyboard: An EEG Based Typing Interface. +IEEE International Conference on Acoustics, Speech, and Signal Processing. +``` + +1. Matrix Speller: + +```text +Farwell, L. A., & Donchin, E. (1988). Talking off the top of your head: +toward a mental prosthesis utilizing event-related brain potentials. +Electroencephalography and clinical Neurophysiology, 70(6), 510-523. +``` + +## Support + +For issues, questions, or contributions: + +- Open an issue on GitHub +- Check existing documentation +- Review test examples +- Consult the demo implementations diff --git a/bcipy/display/components/button_press_handler.py b/bcipy/display/components/button_press_handler.py index 3a087f74d..42563a362 100644 --- a/bcipy/display/components/button_press_handler.py +++ b/bcipy/display/components/button_press_handler.py @@ -1,6 +1,11 @@ -"""Handles button press interactions""" +"""Handles button press interactions. + +This module provides classes for handling button press events in the BciPy system. +It includes abstract base classes and concrete implementations for different types +of button press handling strategies. +""" from abc import ABC, abstractmethod -from typing import List, Optional, Type +from typing import Any, List, Optional, Type from psychopy import event from psychopy.core import CountdownTimer @@ -10,100 +15,178 @@ class ButtonPressHandler(ABC): - """Handles button press events.""" - - def __init__(self, - max_wait: float, - key_input: str, - clock: Optional[Clock] = None, - timer: Type[CountdownTimer] = CountdownTimer): - """ - Parameters - ---------- - wait_length - maximum number of seconds to wait for a key press - key_input - key that we are listening for. - clock - clock used to associate the key event with a timestamp + """Handles button press events. + + This is an abstract base class that defines the interface for handling button press + events. It provides functionality for waiting for and processing button presses + within a specified time window. + + Attributes: + max_wait (float): Maximum number of seconds to wait for a key press. + key_input (str): Key that we are listening for. + clock (Clock): Clock used to associate the key event with a timestamp. + response (Optional[List]): List containing the latest response information. + _timer (Optional[CountdownTimer]): Timer for tracking wait period. + make_timer (Type[CountdownTimer]): Factory for creating timer instances. + """ + + def __init__( + self, + max_wait: float, + key_input: str, + clock: Optional[Clock] = None, + timer: Type[CountdownTimer] = CountdownTimer + ) -> None: + """Initialize the ButtonPressHandler. + + Args: + max_wait (float): Maximum number of seconds to wait for a key press. + key_input (str): Key that we are listening for. + clock (Optional[Clock]): Clock used to associate the key event with a timestamp. + If None, a new Clock instance will be created. + timer (Type[CountdownTimer]): Factory for creating timer instances. + Defaults to CountdownTimer. """ self.max_wait = max_wait self.key_input = key_input self.clock = clock or Clock() - self.response: Optional[List] = None + self.response: Optional[List[Any]] = None self._timer: Optional[CountdownTimer] = None self.make_timer = timer @property def response_label(self) -> Optional[str]: - """Label for the latest button press""" + """Get the label for the latest button press. + + Returns: + Optional[str]: The label of the latest button press, or None if no response. + """ return self.response[0] if self.response else None @property def response_timestamp(self) -> Optional[float]: - """Timestamp for the latest button response""" + """Get the timestamp for the latest button response. + + Returns: + Optional[float]: The timestamp of the latest button press, or None if no response. + """ return self.response[1] if self.response else None def _reset(self) -> None: - """Reset any existing events and timers.""" + """Reset any existing events and timers. + + This method clears any existing keyboard events, resets the timer, + and clears the current response. + """ self._timer = self.make_timer(self.max_wait) self.response = None event.clearEvents(eventType='keyboard') self._timer.reset() def await_response(self) -> None: - """Wait for a button response for a maximum number of seconds. Wait - period could end early if the class determines that some other - criteria have been met (such as an acceptable response).""" + """Wait for a button response for a maximum number of seconds. + Wait period could end early if the class determines that some other + criteria have been met (such as an acceptable response). + """ self._reset() while self._should_keep_waiting() and self._within_wait_period(): self._check_key_press() def has_response(self) -> bool: - """Whether a response has been provided""" + """Check whether a response has been provided. + + Returns: + bool: True if a response has been provided, False otherwise. + """ return self.response is not None def _check_key_press(self) -> None: - """Check for any key press events and set the latest as the response.""" + """Check for any key press events and set the latest as the response. + + This method updates the response attribute with the latest key press + information if a valid key press is detected. + """ self.response = get_key_press( key_list=[self.key_input], clock=self.clock, ) def _within_wait_period(self) -> bool: - """Check that we are within the allotted time for a response.""" + """Check that we are within the allotted time for a response. + + Returns: + bool: True if we are still within the wait period, False otherwise. + """ return (self._timer is not None) and (self._timer.getTime() > 0) def _should_keep_waiting(self) -> bool: - """Check that we should keep waiting for responses.""" + """Check that we should keep waiting for responses. + + Returns: + bool: True if we should continue waiting, False otherwise. + """ return not self.has_response() @abstractmethod def accept_result(self) -> bool: - """Should the result of a button press be affirmative""" + """Determine if the result of a button press should be affirmative. + + Returns: + bool: True if the result should be considered affirmative, False otherwise. + """ + pass class AcceptButtonPressHandler(ButtonPressHandler): - """ButtonPressHandler where a matching button press indicates an affirmative result.""" + """ButtonPressHandler where a matching button press indicates an affirmative result. + + This handler considers a button press as an affirmative response. + """ def accept_result(self) -> bool: - """Should the result of a button press be affirmative""" + """Determine if the result of a button press should be affirmative. + + Returns: + bool: True if a response has been provided, False otherwise. + """ return self.has_response() class RejectButtonPressHandler(ButtonPressHandler): - """ButtonPressHandler where a matching button press indicates a rejection.""" + """ButtonPressHandler where a matching button press indicates a rejection. + + This handler considers a button press as a rejection response. + """ def accept_result(self) -> bool: - """Should the result of a button press be affirmative""" + """Determine if the result of a button press should be affirmative. + + Returns: + bool: True if no response has been provided, False otherwise. + """ return not self.has_response() class PreviewOnlyButtonPressHandler(ButtonPressHandler): - """ButtonPressHandler that waits for the entire span of the configured max_wait.""" + """ButtonPressHandler that waits for the entire span of the configured max_wait. + + This handler always waits for the full duration regardless of button presses. + """ def _should_keep_waiting(self) -> bool: + """Check that we should keep waiting for responses. + + Returns: + bool: Always returns True to ensure full wait duration. + """ return True def accept_result(self) -> bool: - """Should the result of a button press be affirmative""" + """Determine if the result of a button press should be affirmative. + + Returns: + bool: Always returns True. + """ return True diff --git a/bcipy/display/components/layout.py b/bcipy/display/components/layout.py index e0887a79f..0c6fa86cb 100644 --- a/bcipy/display/components/layout.py +++ b/bcipy/display/components/layout.py @@ -1,11 +1,21 @@ # mypy: disable-error-code="override" -"""Defines common functionality for GUI layouts.""" +"""Defines common functionality for GUI layouts. + +This module provides classes and functions for managing layout and positioning +of GUI elements in the BciPy system. It includes utilities for alignment, +scaling, and positioning of components within containers. +""" from enum import Enum from typing import List, Optional, Protocol, Tuple class Container(Protocol): - """Protocol for an enclosing container with units and size.""" + """Protocol for an enclosing container with units and size. + + Attributes: + size (Tuple[float, float]): Size of the container as (width, height). + units (str): Units used for measurements (e.g., 'norm', 'height'). + """ size: Tuple[float, float] units: str @@ -18,7 +28,11 @@ class Container(Protocol): class Alignment(Enum): - """Specifies how elements should be aligned spatially""" + """Specifies how elements should be aligned spatially. + + This enum defines the possible alignment options for positioning elements + within a container. + """ CENTERED = 1 LEFT = 2 RIGHT = 3 @@ -26,49 +40,104 @@ class Alignment(Enum): BOTTOM = 5 @classmethod - def horizontal(cls): - """Subset used for horizontal alignment""" + def horizontal(cls) -> List['Alignment']: + """Get subset used for horizontal alignment. + + Returns: + List[Alignment]: List of horizontal alignment options. + """ return [Alignment.CENTERED, Alignment.LEFT, Alignment.RIGHT] @classmethod - def vertical(cls): - """Subset used for vertical alignment""" + def vertical(cls) -> List['Alignment']: + """Get subset used for vertical alignment. + + Returns: + List[Alignment]: List of vertical alignment options. + """ return [Alignment.CENTERED, Alignment.TOP, Alignment.BOTTOM] # Positioning functions def above(y_coordinate: float, amount: float) -> float: - """Returns a new y_coordinate value that is above the provided value - by the given amount.""" + """Returns a new y_coordinate value that is above the provided value. + + Args: + y_coordinate (float): Base y-coordinate. + amount (float): Distance to move upward. + + Returns: + float: New y-coordinate value. + + Raises: + AssertionError: If amount is negative. + """ assert amount >= 0, 'Amount must be positive' return y_coordinate + amount def below(y_coordinate: float, amount: float) -> float: - """Returns a new y_coordinate value that is below the provided value - by the given amount.""" + """Returns a new y_coordinate value that is below the provided value. + + Args: + y_coordinate (float): Base y-coordinate. + amount (float): Distance to move downward. + + Returns: + float: New y-coordinate value. + + Raises: + AssertionError: If amount is negative. + """ assert amount >= 0, 'Amount must be positive' return y_coordinate - amount def right_of(x_coordinate: float, amount: float) -> float: - """Returns a new x_coordinate value that is to the right of the - provided value by the given amount.""" + """Returns a new x_coordinate value that is to the right of the provided value. + + Args: + x_coordinate (float): Base x-coordinate. + amount (float): Distance to move right. + + Returns: + float: New x-coordinate value. + + Raises: + AssertionError: If amount is negative. + """ assert amount >= 0, 'Amount must be positive' return x_coordinate + amount def left_of(x_coordinate: float, amount: float) -> float: - """Returns a new x_coordinate value that is to the left of the - provided value by the given amount.""" + """Returns a new x_coordinate value that is to the left of the provided value. + + Args: + x_coordinate (float): Base x-coordinate. + amount (float): Distance to move left. + + Returns: + float: New x-coordinate value. + + Raises: + AssertionError: If amount is negative. + """ assert amount >= 0, 'Amount must be positive' return x_coordinate - amount def envelope(pos: Tuple[float, float], size: Tuple[float, float]) -> List[Tuple[float, float]]: - """Compute the vertices for the envelope of a shape centered at pos with - the given size.""" + """Compute the vertices for the envelope of a shape. + + Args: + pos (Tuple[float, float]): Center position of the shape. + size (Tuple[float, float]): Size of the shape as (width, height). + + Returns: + List[Tuple[float, float]]: List of vertices defining the shape's envelope. + """ width, height = size half_w = width / 2 half_h = height / 2 @@ -81,8 +150,16 @@ def envelope(pos: Tuple[float, float], def scaled_size(height: float, window_size: Tuple[float, float], units: str = 'norm') -> Tuple[float, float]: - """Scales the provided height value to reflect the aspect ratio of a - visual.Window. Used for creating squared stimulus. Returns (w,h) tuple""" + """Scales the provided height value to reflect the aspect ratio of a window. + + Args: + height (float): Height value to scale. + window_size (Tuple[float, float]): Window dimensions as (width, height). + units (str): Units to use for scaling. Defaults to 'norm'. + + Returns: + Tuple[float, float]: Scaled size as (width, height). + """ if units == 'height': width = height return (width, height) @@ -95,8 +172,16 @@ def scaled_size(height: float, def scaled_height(width: float, window_size: Tuple[float, float], units: str = 'norm') -> float: - """Given a width, find the equivalent height scaled to the aspect ratio of - a window with the given size""" + """Given a width, find the equivalent height scaled to the aspect ratio. + + Args: + width (float): Width value to scale. + window_size (Tuple[float, float]): Window dimensions as (width, height). + units (str): Units to use for scaling. Defaults to 'norm'. + + Returns: + float: Scaled height value. + """ if units == 'height': return width win_width, win_height = window_size @@ -105,24 +190,56 @@ def scaled_height(width: float, def scaled_width(height: float, window_size: Tuple[float, float], - units: str = 'norm'): - """Given a height, find the equivalent width scaled to the aspect ratio of - a window with the given size""" + units: str = 'norm') -> float: + """Given a height, find the equivalent width scaled to the aspect ratio. + + Args: + height (float): Height value to scale. + window_size (Tuple[float, float]): Window dimensions as (width, height). + units (str): Units to use for scaling. Defaults to 'norm'. + + Returns: + float: Scaled width value. + """ width, _height = scaled_size(height, window_size, units) return width class Layout(Container): """Class with methods for positioning elements within a parent container. + + This class provides functionality for managing the layout and positioning + of GUI elements within a container, including methods for resizing and + alignment. + + Attributes: + units (str): Units used for measurements (e.g., 'norm', 'height'). + parent (Optional[Container]): Parent container if any. + top (float): Top boundary position. + left (float): Left boundary position. + bottom (float): Bottom boundary position. + right (float): Right boundary position. """ - def __init__(self, - parent: Optional[Container] = None, - left: float = DEFAULT_LEFT, - top: float = DEFAULT_TOP, - right: float = DEFAULT_RIGHT, - bottom: float = DEFAULT_BOTTOM, - units: str = "norm"): + def __init__( + self, + parent: Optional[Container] = None, + left: float = DEFAULT_LEFT, + top: float = DEFAULT_TOP, + right: float = DEFAULT_RIGHT, + bottom: float = DEFAULT_BOTTOM, + units: str = "norm" + ) -> None: + """Initialize the Layout. + + Args: + parent (Optional[Container]): Parent container. Defaults to None. + left (float): Left boundary position. Defaults to DEFAULT_LEFT. + top (float): Top boundary position. Defaults to DEFAULT_TOP. + right (float): Right boundary position. Defaults to DEFAULT_RIGHT. + bottom (float): Bottom boundary position. Defaults to DEFAULT_BOTTOM. + units (str): Units to use. Defaults to "norm". + """ self.units: str = units self.parent = parent self.top = top @@ -131,8 +248,12 @@ def __init__(self, self.right = right self.check_invariants() - def check_invariants(self): - """Check that all invariants hold true.""" + def check_invariants(self) -> None: + """Check that all invariants hold true. + + Raises: + AssertionError: If any invariant is violated. + """ # https://psychopy.org/general/units.html#units assert self.units in ['height', 'norm'], "Units must be 'height' or 'norm'" @@ -162,8 +283,17 @@ def check_invariants(self): 1], "Height must be greater than 0 and fit within the parent height." def scaled_size(self, height: float) -> Tuple[float, float]: - """Returns the (w,h) value scaled to reflect the aspect ratio of a - visual.Window. Used for creating squared stimulus""" + """Returns the (w,h) value scaled to reflect the aspect ratio. + + Args: + height (float): Height value to scale. + + Returns: + Tuple[float, float]: Scaled size as (width, height). + + Raises: + AssertionError: If parent is not configured. + """ if self.units == 'height': width = height return (width, height) @@ -172,52 +302,92 @@ def scaled_size(self, height: float) -> Tuple[float, float]: @property def size(self) -> Tuple[float, float]: - """Layout size.""" + """Get the layout size. + + Returns: + Tuple[float, float]: Size as (width, height). + """ return (self.width, self.height) @property def width(self) -> float: - """Width in norm units of this component.""" + """Get the width in norm units of this component. + + Returns: + float: Width value. + """ return self.right - self.left @property def height(self) -> float: - """Height in norm units of this component.""" + """Get the height in norm units of this component. + + Returns: + float: Height value. + """ return self.top - self.bottom @property def left_top(self) -> Tuple[float, float]: - """Top left position""" + """Get the top left position. + + Returns: + Tuple[float, float]: Position as (x, y). + """ return (self.left, self.top) @property def right_bottom(self) -> Tuple[float, float]: - """Bottom right position""" + """Get the bottom right position. + + Returns: + Tuple[float, float]: Position as (x, y). + """ return (self.right, self.bottom) @property def horizontal_middle(self) -> float: - """x-axis value in norm units for the midpoint of this component""" + """Get the x-axis value for the midpoint of this component. + + Returns: + float: X-coordinate of the midpoint. + """ return (self.left + self.right) / 2 @property def vertical_middle(self) -> float: - """x-axis value in norm units for the midpoint of this component.""" + """Get the y-axis value for the midpoint of this component. + + Returns: + float: Y-coordinate of the midpoint. + """ return (self.top + self.bottom) / 2 @property def center(self) -> Tuple[float, float]: - """Center point of the component in norm units. Returns a (x,y) tuple.""" + """Get the center point of the component. + + Returns: + Tuple[float, float]: Center position as (x, y). + """ return (self.horizontal_middle, self.vertical_middle) @property def left_middle(self) -> Tuple[float, float]: - """Point centered on the left-most edge.""" + """Get the point centered on the left-most edge. + + Returns: + Tuple[float, float]: Position as (x, y). + """ return (self.left, self.vertical_middle) @property def right_middle(self) -> Tuple[float, float]: - """Point centered on the right-most edge.""" + """Get the point centered on the right-most edge. + + Returns: + Tuple[float, float]: Position as (x, y). + """ return (self.right, self.vertical_middle) def resize_width(self, @@ -225,10 +395,13 @@ def resize_width(self, alignment: Alignment = Alignment.CENTERED) -> None: """Adjust the width of the current layout. - Parameters - ---------- - width_pct - percentage of the current width - alignment - specifies how the remaining width should be aligned. + Args: + width_pct (float): Percentage of the current width. + alignment (Alignment): Specifies how the remaining width should be aligned. + Defaults to Alignment.CENTERED. + + Raises: + AssertionError: If width_pct is not positive or alignment is invalid. """ assert 0 < width_pct, 'width_pct must be greater than 0' assert alignment in Alignment.horizontal() @@ -256,10 +429,13 @@ def resize_height(self, alignment: Alignment = Alignment.CENTERED) -> None: """Adjust the height of the current layout. - Parameters - ---------- - height_pct - percentage of the current width - alignment - specifies how the remaining width should be aligned. + Args: + height_pct (float): Percentage of the current height. + alignment (Alignment): Specifies how the remaining height should be aligned. + Defaults to Alignment.CENTERED. + + Raises: + AssertionError: If height_pct is not positive or alignment is invalid. """ assert 0 < height_pct, 'height_pct must be greater than 0' assert alignment in Alignment.vertical() @@ -286,12 +462,14 @@ def resize_height(self, # Factory functions def at_top(parent: Container, height: float) -> Layout: - """Constructs a layout of a given height that spans the full width of the - window and is positioned at the top. + """Constructs a layout of a given height that spans the full width of the window. - Parameters - ---------- - height - value in 'norm' units + Args: + parent (Container): Parent container. + height (float): Height value in 'norm' units. + + Returns: + Layout: New layout instance positioned at the top. """ top = DEFAULT_TOP return Layout(parent=parent, @@ -302,8 +480,15 @@ def at_top(parent: Container, height: float) -> Layout: def at_bottom(parent: Container, height: float) -> Layout: - """Constructs a layout of a given height that spans the full width of the - window and is positioned at the bottom""" + """Constructs a layout of a given height that spans the full width of the window. + + Args: + parent (Container): Parent container. + height (float): Height value in 'norm' units. + + Returns: + Layout: New layout instance positioned at the bottom. + """ bottom = DEFAULT_BOTTOM return Layout(parent=parent, left=DEFAULT_LEFT, @@ -315,17 +500,17 @@ def at_bottom(parent: Container, height: float) -> Layout: def centered(parent: Optional[Container] = None, width_pct: float = 1.0, height_pct: float = 1.0) -> Layout: - """Constructs a layout that is centered on the screen. Default size is - fullscreen but optional parameters can be used to adjust the width and - height. - - Parameters - ---------- - parent - optional parent - width_pct - optional; sets the width to a given percentage of - fullscreen. - height_pct - optional; sets the height to a given percentage of - fullscreen. + """Constructs a layout that is centered on the screen. + + Args: + parent (Optional[Container]): Optional parent container. + width_pct (float): Optional; sets the width to a given percentage of fullscreen. + Defaults to 1.0. + height_pct (float): Optional; sets the height to a given percentage of fullscreen. + Defaults to 1.0. + + Returns: + Layout: New centered layout instance. """ container = Layout(parent=parent) container.resize_width(width_pct, alignment=Alignment.CENTERED) @@ -334,8 +519,14 @@ def centered(parent: Optional[Container] = None, def from_envelope(verts: List[Tuple[float, float]]) -> Layout: - """Constructs a layout from a list of vertices which comprise a shape's - envelope.""" + """Constructs a layout from a list of vertices which comprise a shape's envelope. + + Args: + verts (List[Tuple[float, float]]): List of vertices defining the shape's envelope. + + Returns: + Layout: New layout instance based on the envelope. + """ x_coords, y_coords = zip(*verts) return Layout(left=min(x_coords), top=max(y_coords), @@ -344,13 +535,19 @@ def from_envelope(verts: List[Tuple[float, float]]) -> Layout: def height_units(window_size: Tuple[float, float]) -> Layout: - """Constructs a layout with height units using the given Window - dimensions + """Constructs a layout with height units using the given Window dimensions. + + Args: + window_size (Tuple[float, float]): Window dimensions as (width, height). + + Returns: + Layout: New layout instance using height units. - for an aspect ratio of 4:3 - 4 widths / 3 height = 1.333 - 1.333 / 2 = 0.667 - so, left is -0.667 and right is 0.667 + Note: + For an aspect ratio of 4:3: + 4 widths / 3 height = 1.333 + 1.333 / 2 = 0.667 + so, left is -0.667 and right is 0.667 """ win_width, win_height = window_size right = (win_width / win_height) / 2 diff --git a/bcipy/display/components/task_bar.py b/bcipy/display/components/task_bar.py index 654acd859..4ade5574f 100644 --- a/bcipy/display/components/task_bar.py +++ b/bcipy/display/components/task_bar.py @@ -1,6 +1,11 @@ -"""Task bar component""" +"""Task bar component. -from typing import Dict, List, Optional +This module provides components for displaying task-related information in a task window. +It includes base task bar functionality and specialized implementations for different +types of tasks like calibration and copy phrase tasks. +""" + +from typing import Any, Dict, List, Optional from psychopy import visual from psychopy.visual.basevisual import BaseVisualStim @@ -9,27 +14,43 @@ class TaskBar: - """Component for displaying task-related information in a task window. The - component elements are positioned at the top of the window. - - Parameters - ---------- - win - visual.Window on which to render elements - colors - Ordered list of colors to apply to task stimuli - font - Font to apply to all task stimuli - height - Height of all task text stimuli - text - Task text to apply to stimuli - padding - used in conjunction with the text height to compute the - overall height of the task bar. + """Component for displaying task-related information in a task window. + + The component elements are positioned at the top of the window. + + Attributes: + win (visual.Window): Window on which to render elements. + colors (List[str]): Ordered list of colors to apply to task stimuli. + font (str): Font to apply to all task stimuli. + height (float): Height of all task text stimuli. + padding (float): Padding used in conjunction with text height to compute + the overall height of the task bar. + text (str): Task text to apply to stimuli. + layout (layout.Layout): Layout manager for positioning elements. + stim (Dict[str, BaseVisualStim]): Dictionary of visual stimuli. """ - def __init__(self, - win: visual.Window, - colors: Optional[List[str]] = None, - font: str = 'Courier New', - height: float = 0.1, - text: str = '', - padding: Optional[float] = None): + def __init__( + self, + win: visual.Window, + colors: Optional[List[str]] = None, + font: str = 'Courier New', + height: float = 0.1, + text: str = '', + padding: Optional[float] = None + ) -> None: + """Initialize the TaskBar. + + Args: + win (visual.Window): Window on which to render elements. + colors (Optional[List[str]]): Ordered list of colors to apply to task stimuli. + Defaults to ['white']. + font (str): Font to apply to all task stimuli. Defaults to 'Courier New'. + height (float): Height of all task text stimuli. Defaults to 0.1. + text (str): Task text to apply to stimuli. Defaults to ''. + padding (Optional[float]): Padding used in conjunction with text height. + If None, defaults to height/2. + """ self.win = win self.colors = colors or ['white'] self.font = font @@ -41,35 +62,50 @@ def __init__(self, @property def height_pct(self) -> float: - """Percentage of the total window that the task bar occupies. + """Get the percentage of the total window that the task bar occupies. - Returns - ------- - percentage ; value will be between 0 and 1. + Returns: + float: Percentage value between 0 and 1. """ win_layout = layout.Layout(self.win) return self.compute_height() / win_layout.height - def compute_height(self): - """Computes the component height using the provided config.""" + def compute_height(self) -> float: + """Compute the component height using the provided config. + + Returns: + float: Total height of the task bar. + """ return self.height + self.padding def init_stim(self) -> Dict[str, BaseVisualStim]: - """Initialize the stimuli elements.""" + """Initialize the stimuli elements. + + Returns: + Dict[str, BaseVisualStim]: Dictionary of initialized visual stimuli. + """ task = self.text_stim() return {'task_text': task, 'border': self.border_stim()} - def draw(self): + def draw(self) -> None: """Draw the task bar elements.""" for _key, stim in self.stim.items(): stim.draw() - def update(self, text: str = ''): - """Update the task bar to display the given text.""" + def update(self, text: str = '') -> None: + """Update the task bar to display the given text. + + Args: + text (str): New text to display. Defaults to ''. + """ self.stim['task_text'].text = text def border_stim(self) -> visual.Line: - """Create the task bar outline""" + """Create the task bar outline. + + Returns: + visual.Line: Line stimulus representing the task bar border. + """ # pylint: disable=not-callable return visual.Line( win=self.win, @@ -79,15 +115,26 @@ def border_stim(self) -> visual.Line: lineColor=self.colors[0]) def text_stim(self, **kwargs) -> visual.TextStim: - """Constructs a TextStim. Uses the config to set default properties - which may be overridden by providing keyword args. - """ + """Construct a TextStim with default properties. + + Uses the config to set default properties which may be overridden by + providing keyword args. + + Args: + **kwargs: Additional properties to override defaults. + Returns: + visual.TextStim: Configured text stimulus. + """ props = {**self.default_text_props(), **kwargs} return visual.TextStim(**props) - def default_text_props(self) -> dict: - """Default properties for constructing a TextStim.""" + def default_text_props(self) -> Dict[str, Any]: + """Get default properties for constructing a TextStim. + + Returns: + Dict[str, Any]: Dictionary of default text stimulus properties. + """ return { 'win': self.win, 'text': self.text, @@ -100,82 +147,125 @@ def default_text_props(self) -> dict: class CalibrationTaskBar(TaskBar): - """Task bar for Calibration tasks. Displays the count of inquiries. - - Parameters - ---------- - win - visual.Window on which to render elements - inquiry_count - total number of inquiries to display - current_index - index of the current inquiry - **config - display config (colors, font, height) + """Task bar for Calibration tasks. + + Displays the count of inquiries in the format "current/total". + + Attributes: + inquiry_count (int): Total number of inquiries to display. + current_index (int): Index of the current inquiry. """ - def __init__(self, - win: visual.Window, - inquiry_count: int, - current_index: int = 1, - **config): + def __init__( + self, + win: visual.Window, + inquiry_count: int, + current_index: int = 1, + **config: Any + ) -> None: + """Initialize the CalibrationTaskBar. + + Args: + win (visual.Window): Window on which to render elements. + inquiry_count (int): Total number of inquiries to display. + current_index (int): Index of the current inquiry. Defaults to 1. + **config: Additional display configuration (colors, font, height). + """ self.inquiry_count = inquiry_count self.current_index = current_index super().__init__(win, **config) def init_stim(self) -> Dict[str, BaseVisualStim]: - """Initialize the stimuli elements.""" + """Initialize the stimuli elements. - task = self.text_stim(text=self.displayed_text(), - pos=self.layout.left_middle, - anchorHoriz='left', - alignText='left') + Returns: + Dict[str, BaseVisualStim]: Dictionary of initialized visual stimuli. + """ + task = self.text_stim( + text=self.displayed_text(), + pos=self.layout.left_middle, + anchorHoriz='left', + alignText='left' + ) return {'task_text': task, 'border': self.border_stim()} - def update(self, text: str = ''): - """Update the displayed text""" + def update(self, text: str = '') -> None: + """Update the displayed text. + + Args: + text (str): Unused parameter. Defaults to ''. + """ self.current_index += 1 self.stim['task_text'].text = self.displayed_text() def displayed_text(self) -> str: - """Text to display. Computed from the current_index and inquiry_count. Ex. '2/100'""" + """Get the text to display. + + Returns: + str: Text in the format "current/total" (e.g., "2/100"). + """ return f" {self.current_index}/{self.inquiry_count}" class CopyPhraseTaskBar(TaskBar): - """Task bar for the Copy Phrase Task - - Parameters - ---------- - win - visual.Window on which to render elements - task_text - text for the participant to spell - spelled_text - text that has already been spelled - **config - display config (colors, font, height) + """Task bar for the Copy Phrase Task. + + Displays both the target text and the currently spelled text. + + Attributes: + task_text (str): Text for the participant to spell. + spelled_text (str): Text that has already been spelled. """ - def __init__(self, - win: visual.Window, - task_text: str = '', - spelled_text: str = '', - **config): + def __init__( + self, + win: visual.Window, + task_text: str = '', + spelled_text: str = '', + **config: Any + ) -> None: + """Initialize the CopyPhraseTaskBar. + + Args: + win (visual.Window): Window on which to render elements. + task_text (str): Text for the participant to spell. Defaults to ''. + spelled_text (str): Text that has already been spelled. Defaults to ''. + **config: Additional display configuration (colors, font, height). + """ self.task_text = task_text self.spelled_text = spelled_text super().__init__(win, **config) - def compute_height(self): - """Computes the component height using the provided config.""" - # height is doubled to account for task_text and spelled_text being on - # separate lines. + def compute_height(self) -> float: + """Compute the component height using the provided config. + + Height is doubled to account for task_text and spelled_text being on + separate lines. + + Returns: + float: Total height of the task bar. + """ return (self.height * 2) + self.padding def init_stim(self) -> Dict[str, BaseVisualStim]: - """Initialize the stimuli elements.""" - - task = self.text_stim(text=self.task_text, - pos=self.layout.center, - anchorVert='bottom') + """Initialize the stimuli elements. - spelled = self.text_stim(text=self.displayed_text(), - pos=self.layout.center, - color=self.colors[-1], - anchorVert='top') + Returns: + Dict[str, BaseVisualStim]: Dictionary of initialized visual stimuli. + """ + task = self.text_stim( + text=self.task_text, + pos=self.layout.center, + anchorVert='bottom' + ) + + spelled = self.text_stim( + text=self.displayed_text(), + pos=self.layout.center, + color=self.colors[-1], + anchorVert='top' + ) return { 'task_text': task, @@ -183,13 +273,21 @@ def init_stim(self) -> Dict[str, BaseVisualStim]: 'border': self.border_stim() } - def update(self, text: str = ''): - """Update the task bar to display the given text.""" + def update(self, text: str = '') -> None: + """Update the task bar to display the given text. + + Args: + text (str): New spelled text to display. Defaults to ''. + """ self.spelled_text = text self.stim['spelled_text'].text = self.displayed_text() - def displayed_text(self): - """Spelled text padded for alignment.""" + def displayed_text(self) -> str: + """Get the spelled text padded for alignment. + + Returns: + str: Spelled text padded with spaces to match task_text length. + """ diff = len(self.task_text) - len(self.spelled_text) if (diff > 0): return self.spelled_text + (' ' * diff) diff --git a/bcipy/display/demo/components/demo_layouts.py b/bcipy/display/demo/components/demo_layouts.py index f0e2179f3..5c944f2e0 100644 --- a/bcipy/display/demo/components/demo_layouts.py +++ b/bcipy/display/demo/components/demo_layouts.py @@ -267,7 +267,8 @@ def demo_matrix_positions(win: visual.Window): symbols = alphabet() norm_layout = centered(parent=win, width_pct=0.7, height_pct=0.75) - positions = symbol_positions(norm_layout, symbol_set=symbols, rows=5, columns=6) + positions = symbol_positions( + norm_layout, symbol_set=symbols, rows=5, columns=6) for sym, pos in zip(symbols, positions): stim = visual.TextStim(win, diff --git a/bcipy/display/demo/matrix/demo_calibration_matrix.py b/bcipy/display/demo/matrix/demo_calibration_matrix.py index 3e737644d..839a69554 100644 --- a/bcipy/display/demo/matrix/demo_calibration_matrix.py +++ b/bcipy/display/demo/matrix/demo_calibration_matrix.py @@ -35,7 +35,8 @@ win = init_display_window(window_parameters) win.recordFrameIntervals = False -task_bar = CalibrationTaskBar(win, inquiry_count=4, current_index=0, font='Arial') +task_bar = CalibrationTaskBar( + win, inquiry_count=4, current_index=0, font='Arial') preview_config = PreviewParams(show_preview_inquiry=True, preview_inquiry_length=2, preview_inquiry_key_input='return', diff --git a/bcipy/display/main.py b/bcipy/display/main.py index 49f5fa074..859093aa6 100644 --- a/bcipy/display/main.py +++ b/bcipy/display/main.py @@ -1,7 +1,13 @@ +"""Main display module. + +This module provides the core display functionality for BciPy, including base classes +and utilities for creating and managing visual stimuli in BCI paradigms. +""" + # mypy: disable-error-code="assignment,empty-body" from abc import ABC, abstractmethod from enum import Enum -from typing import Any, List, NamedTuple, Optional, Tuple, Type, Union +from typing import Any, Dict, List, NamedTuple, Optional, Tuple, Type, Union from psychopy import visual @@ -13,9 +19,22 @@ class Display(ABC): - """Display. - - Base class for BciPy displays. This defines the logic necessary for task executions that require a display. + """Base class for BciPy displays. + + This abstract class defines the core interface and functionality necessary for + task executions that require a display. It provides methods for stimulus + presentation, timing control, and task management. + + Attributes: + window (visual.Window): PsychoPy window for display. + timing_clock (Clock): Clock for timing control. + experiment_clock (Clock): Clock for experiment timing. + stimuli_inquiry (List[str]): List of stimuli to present. + stimuli_colors (List[str]): List of colors for each stimulus. + stimuli_timing (List[float]): List of presentation durations. + task (Any): Task-related information. + info_text (List[Any]): Information text to display. + first_stim_time (float): Time of first stimulus presentation. """ window: visual.Window = None @@ -30,107 +49,142 @@ class Display(ABC): @abstractmethod def do_inquiry(self) -> List[Tuple[str, float]]: - """Do inquiry. + """Perform an inquiry of stimuli. Animates an inquiry of stimuli and returns a list of stimuli trigger timing. + + Returns: + List[Tuple[str, float]]: List of (stimulus, timing) pairs. """ ... @abstractmethod - def wait_screen(self, *args, **kwargs) -> None: - """Wait Screen. + def wait_screen(self, *args: Any, **kwargs: Any) -> None: + """Display a wait screen. Define what happens on the screen when a user pauses a session. + + Args: + *args: Variable length argument list. + **kwargs: Arbitrary keyword arguments. """ ... @abstractmethod - def update_task_bar(self, *args, **kwargs) -> None: - """Update Task. + def update_task_bar(self, *args: Any, **kwargs: Any) -> None: + """Update task bar display. + + Update any taskbar-related display items not related to the inquiry. + Example: stimuli count 1/200. - Update any taskbar-related display items not related to the inquiry. Ex. stimuli count 1/200. + Args: + *args: Variable length argument list. + **kwargs: Arbitrary keyword arguments. """ ... - def schedule_to(self, stimuli: list, timing: list, colors: list) -> None: - """Schedule To. + def schedule_to(self, stimuli: List[str], timing: List[float], colors: List[str]) -> None: + """Schedule stimuli elements. Schedule stimuli elements (works as a buffer) before calling do_inquiry. + + Args: + stimuli (List[str]): List of stimuli to present. + timing (List[float]): List of presentation durations. + colors (List[str]): List of colors for each stimulus. """ ... def draw_static(self) -> None: - """Draw Static. + """Draw static elements. Displays task information not related to the inquiry. """ ... - def preview_inquiry(self, *args, **kwargs) -> List[float]: - """Preview Inquiry. + def preview_inquiry(self, *args: Any, **kwargs: Any) -> List[float]: + """Preview an inquiry before presentation. + + Display an inquiry or instruction beforehand to the user. This should be called + before do_inquiry. This can be used to determine if the desired stimuli is present + before displaying them more laboriously or prompting users before the inquiry. + + Note: + All stimuli elements (stimuli, timing, colors) must be set on the display + before calling this method. This implies something like schedule_to is called. - Display an inquiry or instruction beforehand to the user. This should be called before do_inquiry. - This can be used to determine if the desired stimuli is present before displaying them more laboriusly - or prompting users before the inquiry. - All stimuli elements (stimuli, timing, colors) must be set on the display before calling this method. - This implies, something like schedule_to is called. + Args: + *args: Variable length argument list. + **kwargs: Arbitrary keyword arguments. + + Returns: + List[float]: List of timing information for the preview. """ ... -def init_display_window(parameters): - """ - Init Display Window. +def init_display_window(parameters: Dict[str, Any]) -> visual.Window: + """Initialize the main display window. - Function to Initialize main display window - needed for all later stimuli presentation. + Function to initialize main display window needed for all later stimuli presentation. See Psychopy official documentation for more information and working demos: http://www.psychopy.org/api/visual/window.html - """ - # Check is full_screen mode is set and get necessary values - if parameters['full_screen']: + Args: + parameters (Dict[str, Any]): Dictionary containing window configuration parameters. + Returns: + visual.Window: Initialized PsychoPy window for display. + """ + # Check if full_screen mode is set and get necessary values + if parameters['full_screen']: # set window attributes based on resolution screen_info = get_screen_info() window_height = screen_info.height window_width = screen_info.width - - # set full screen mode to true (removes os dock, explorer etc.) full_screen = True - - # otherwise, get user defined window attributes else: - # set window attributes directly from parameters file window_height = parameters['window_height'] window_width = parameters['window_width'] - - # make sure full screen is set to false full_screen = False # Initialize PsychoPy Window for Main Display of Stimuli display_window = visual.Window( - size=[window_width, - window_height], + size=[window_width, window_height], screen=parameters['stim_screen'], allowGUI=False, useFBO=False, fullscr=full_screen, allowStencil=False, monitor='mainMonitor', - winType='pyglet', units='norm', waitBlanking=False, + winType='pyglet', + units='norm', + waitBlanking=False, color=parameters['background_color']) - # Return display window to caller return display_window class StimuliProperties: - """"Stimuli Properties. - - An encapsulation of properties relevant to core stimuli presentation in a paradigm. + """Encapsulation of properties for core stimuli presentation. + + This class manages the properties and configuration for presenting stimuli + in a paradigm, including text and image-based stimuli. + + Attributes: + stim_font (str): Font to use for text stimuli. + stim_pos (Union[Tuple[float, float], List[Tuple[float, float]]]): Position(s) for stimuli. + stim_height (float): Height of stimuli. + stim_inquiry (List[str]): List of stimuli to present. + stim_colors (List[str]): List of colors for each stimulus. + stim_timing (List[float]): List of presentation durations. + is_txt_stim (bool): Whether stimuli are text-based. + stim_length (int): Number of stimuli. + sti (Optional[Union[visual.TextStim, visual.ImageStim]]): Stimulus object. + prompt_time (Optional[float]): Time to display target prompt. + layout (Optional[str]): Layout of stimuli (e.g., 'ALPHABET' or 'QWERTY'). """ def __init__( @@ -143,20 +197,19 @@ def __init__( stim_timing: Optional[List[float]] = None, is_txt_stim: bool = True, prompt_time: Optional[float] = None, - layout: Optional[str] = None): - """Initialize Stimuli Parameters. - - stim_font(List[str]): Ordered list of colors to apply to information stimuli - stim_pos(Tuple[float, float]): Position on window where the stimuli will be presented - or a list of positions (ex. for matrix displays) - stim_height(float): Height of all stimuli - stim_inquiry(List[str]): Ordered list of text to build stimuli with - stim_colors(List[str]): Ordered list of colors to apply to stimuli - stim_timing(List[float]): Ordered list of timing to apply to an inquiry using the stimuli - is_txt_stim(bool): Whether or not this is a text based stimuli (False implies image based) - prompt_time(float): Time to display target prompt for at the beginning of inquiry - layout(str): Layout of stimuli on the screen (ex. 'ALPHABET' or 'QWERTY'). - This is only used for matrix displays. + layout: Optional[str] = None) -> None: + """Initialize Stimuli Properties. + + Args: + stim_font (str): Font to use for text stimuli. + stim_pos (Union[Tuple[float, float], List[Tuple[float, float]]]): Position(s) for stimuli. + stim_height (float): Height of stimuli. + stim_inquiry (Optional[List[str]]): List of stimuli to present. Defaults to None. + stim_colors (Optional[List[str]]): List of colors for each stimulus. Defaults to None. + stim_timing (Optional[List[float]]): List of presentation durations. Defaults to None. + is_txt_stim (bool): Whether stimuli are text-based. Defaults to True. + prompt_time (Optional[float]): Time to display target prompt. Defaults to None. + layout (Optional[str]): Layout of stimuli. Defaults to None. """ self.stim_font = stim_font self.stim_pos = stim_pos @@ -171,11 +224,17 @@ def __init__( self.layout = layout def build_init_stimuli(self, window: visual.Window) -> Union[visual.TextStim, visual.ImageStim]: - """"Build Initial Stimuli. + """Build initial stimulus object. This method constructs the stimuli object which can be updated later. This is more - performant than creating a new stimuli each call. It can create either an image or text stimuli - based on the boolean self.is_txt_stim. + performant than creating a new stimuli each call. It can create either an image or + text stimuli based on the boolean self.is_txt_stim. + + Args: + window (visual.Window): PsychoPy window for display. + + Returns: + Union[visual.TextStim, visual.ImageStim]: The created stimulus object. """ if self.is_txt_stim: self.sti = visual.TextStim( @@ -185,8 +244,10 @@ def build_init_stimuli(self, window: visual.Window) -> Union[visual.TextStim, vi text='', font=self.stim_font, pos=self.stim_pos, - wrapWidth=None, colorSpace='rgb', - opacity=1, depth=-6.0) + wrapWidth=None, + colorSpace='rgb', + opacity=1, + depth=-6.0) else: self.sti = visual.ImageStim( win=window, @@ -198,10 +259,18 @@ def build_init_stimuli(self, window: visual.Window) -> Union[visual.TextStim, vi class InformationProperties: - """"Information Properties. - - An encapsulation of properties relevant to task information presentation in an RSVP paradigm. This could be - messaging relevant to feedback or static text to remain on screen not related to task tracking. + """Encapsulation of properties for task information presentation. + + This class manages the properties and configuration for displaying task-related + information, feedback, and static text in an RSVP paradigm. + + Attributes: + info_color (List[str]): List of colors for information text. + info_text (List[str]): List of information text to display. + info_font (List[str]): List of fonts for information text. + info_pos (List[Tuple[float, float]]): List of positions for information text. + info_height (List[float]): List of heights for information text. + text_stim (List[visual.TextStim]): List of text stimulus objects. """ def __init__( @@ -210,14 +279,15 @@ def __init__( info_text: List[str], info_font: List[str], info_pos: List[Tuple[float, float]], - info_height: List[float]): - """Initialize Information Parameters. - - info_color(List[str]): Ordered list of colors to apply to information stimuli - info_text(List[str]): Ordered list of text to apply to information stimuli - info_font(List[str]): Ordered list of font to apply to information stimuli - info_pos(Tuple[float, float]): Position on window where the Information stimuli will be presented - info_height(List[float]): Ordered list of height of Information stimuli + info_height: List[float]) -> None: + """Initialize Information Properties. + + Args: + info_color (List[str]): List of colors for information text. + info_text (List[str]): List of information text to display. + info_font (List[str]): List of fonts for information text. + info_pos (List[Tuple[float, float]]): List of positions for information text. + info_height (List[float]): List of heights for information text. """ self.info_color = info_color self.info_text = info_text @@ -226,9 +296,15 @@ def __init__( self.info_height = info_height def build_info_text(self, window: visual.Window) -> List[visual.TextStim]: - """"Build Information Text. + """Build information text stimuli. Constructs a list of Information stimuli to display. + + Args: + window (visual.Window): PsychoPy window for display. + + Returns: + List[visual.TextStim]: List of text stimulus objects. """ self.text_stim = [] for idx in range(len(self.info_text)): @@ -239,24 +315,39 @@ def build_info_text(self, window: visual.Window) -> List[visual.TextStim]: text=self.info_text[idx], font=self.info_font[idx], pos=self.info_pos[idx], - wrapWidth=None, colorSpace='rgb', - opacity=1, depth=-6.0)) + wrapWidth=None, + colorSpace='rgb', + opacity=1, + depth=-6.0)) return self.text_stim class ButtonPressMode(Enum): - """Represents the possible meanings for a button press (when using an Inquiry Preview.)""" + """Represents the possible meanings for a button press. + + Used when implementing Inquiry Preview functionality to determine the + action to take based on user input. + """ NOTHING = 0 ACCEPT = 1 REJECT = 2 class PreviewParams(NamedTuple): - """Parameters relevant for the Inquiry Preview functionality. - - Create from an existing Parameters instance using: - >>> parameters.instantiate(PreviewParams) + """Parameters for Inquiry Preview functionality. + + This class defines the configuration parameters needed for the Inquiry Preview + feature, which allows users to preview stimuli before presentation. + + Attributes: + show_preview_inquiry (bool): Whether to show preview. + preview_inquiry_length (float): Duration of preview. + preview_inquiry_key_input (str): Key to use for preview input. + preview_inquiry_progress_method (int): Method for handling preview progress. + preview_inquiry_isi (float): Inter-stimulus interval for preview. + preview_box_text_size (float): Text size for preview box. """ + show_preview_inquiry: bool preview_inquiry_length: float preview_inquiry_key_input: str @@ -266,13 +357,24 @@ class PreviewParams(NamedTuple): @property def button_press_mode(self) -> ButtonPressMode: - """Mode indicated by the inquiry progress method.""" + """Get the button press mode from the progress method. + + Returns: + ButtonPressMode: The mode indicated by the progress method. + """ return ButtonPressMode(self.preview_inquiry_progress_method) def get_button_handler_class( mode: ButtonPressMode) -> Type[ButtonPressHandler]: - """Get the appropriate handler constructor for the given button press mode.""" + """Get the appropriate button handler class for the given mode. + + Args: + mode (ButtonPressMode): The button press mode to handle. + + Returns: + Type[ButtonPressHandler]: The appropriate handler class. + """ mapping = { ButtonPressMode.NOTHING: PreviewOnlyButtonPressHandler, ButtonPressMode.ACCEPT: AcceptButtonPressHandler, @@ -283,7 +385,15 @@ def get_button_handler_class( def init_preview_button_handler(params: PreviewParams, experiment_clock: Clock) -> ButtonPressHandler: - """"Returns a button press handler for inquiry preview.""" + """Initialize a button press handler for inquiry preview. + + Args: + params (PreviewParams): Preview configuration parameters. + experiment_clock (Clock): Clock for experiment timing. + + Returns: + ButtonPressHandler: Configured button press handler. + """ make_handler = get_button_handler_class(params.button_press_mode) return make_handler(max_wait=params.preview_inquiry_length, key_input=params.preview_inquiry_key_input, @@ -291,6 +401,14 @@ def init_preview_button_handler(params: PreviewParams, class VEPStimuliProperties(StimuliProperties): + """Properties for VEP (Visual Evoked Potential) stimuli. + + This class extends StimuliProperties to provide specific functionality + for VEP-based paradigms. + + Attributes: + animation_seconds (float): Duration of animation. + """ def __init__(self, stim_font: str, @@ -300,12 +418,18 @@ def __init__(self, stim_color: List[str], inquiry: List[List[Any]], stim_length: int = 1, - animation_seconds: float = 1.0): - """Initialize VEP Stimuli Parameters. - stim_color(List[str]): Ordered list of colors to apply to VEP stimuli - stim_font(str): Font to apply to all VEP stimuli - stim_pos(List[Tuple[float, float]]): Position on the screen where to present to VEP text - stim_height(float): Height of all VEP text stimuli + animation_seconds: float = 1.0) -> None: + """Initialize VEP Stimuli Properties. + + Args: + stim_font (str): Font to use for text stimuli. + stim_pos (List[Tuple[float, float]]): Positions for stimuli. + stim_height (float): Height of stimuli. + timing (List[float]): List of presentation durations. + stim_color (List[str]): List of colors for each stimulus. + inquiry (List[List[Any]]): List of inquiry stimuli. + stim_length (int, optional): Number of stimuli. Defaults to 1. + animation_seconds (float, optional): Duration of animation. Defaults to 1.0. """ # static properties self.stim_font = stim_font @@ -317,11 +441,15 @@ def __init__(self, # dynamic property. List of length 3. 1. prompt; 2. fixation; 3. inquiry self.stim_timing = timing - # dynamic properties, must be a a list of lists where each list is a different box + # dynamic properties, must be a list of lists where each list is a different box self.stim_colors = stim_color self.stim_inquiry = inquiry self.animation_seconds = animation_seconds def build_init_stimuli(self, window: visual.Window) -> None: - """"Build Initial Stimuli.""" + """Build initial VEP stimuli. + + Args: + window (visual.Window): PsychoPy window for display. + """ ... diff --git a/bcipy/display/paradigm/matrix/README.md b/bcipy/display/paradigm/matrix/README.md index 0fbdf4a89..efbb7d6de 100644 --- a/bcipy/display/paradigm/matrix/README.md +++ b/bcipy/display/paradigm/matrix/README.md @@ -6,7 +6,7 @@ The matrix display presents a list of symbols in a grid format. Grid items are e A matrix display needs a psychopy Window, core.Clock, and configuration for the stimuli (StimuliProperties), task_bar (TaskBar), and information elements (InformationProperties). -``` +```python from psychopy import core import bcipy.display.components.layout as layout @@ -45,7 +45,7 @@ The number of rows and columns can be specified by using the provided parameters When using a task, the `matrix_rows` and `matrix_columns` parameters are used for customization. -``` +```python matrix_display = MatrixDisplay(win, experiment_clock, stim_properties, @@ -55,14 +55,13 @@ matrix_display = MatrixDisplay(win, columns=7) ``` - ## Layout Symbol positions are calculated when the display is initialized. The grid will be centered within the window. By default the grid will take up 75% of the width, and 80% of the height, or whichever is smaller depending on the aspect ratio of your monitor. These values can be adjusted by provided a width_pct and height_pct parameter. When using a task, the `matrix_width` parameter is used for customization. -``` +```python # determine matrix height based on the size of the task_bar matrix_height_pct = 1 - (2 * task_bar.height_pct) matrix_display = MatrixDisplay(win, @@ -93,9 +92,9 @@ You may need to do some trial and error to determine the best matrix configurati For tasks which use the matrix display, the following parameters are recommended: -``` +```python time_fixation: 2 stim_height: 0.17 task_height: 0.1 task_padding: 0.05 -``` \ No newline at end of file +``` diff --git a/bcipy/display/paradigm/matrix/display.py b/bcipy/display/paradigm/matrix/display.py index 4e70ebeb3..52432c969 100644 --- a/bcipy/display/paradigm/matrix/display.py +++ b/bcipy/display/paradigm/matrix/display.py @@ -1,6 +1,12 @@ -"""Display for presenting stimuli in a grid.""" +"""Display for presenting stimuli in a grid. + +This module provides functionality for displaying and managing matrix-style stimuli +presentations, commonly used in BCI paradigms. It handles the layout, timing, and +animation of stimuli in a grid format. +""" + import logging -from typing import Dict, List, NamedTuple, Optional, Tuple +from typing import Callable, Dict, List, NamedTuple, Optional, Tuple from psychopy import core, visual @@ -19,7 +25,13 @@ class SymbolDuration(NamedTuple): - """Represents a symbol and its associated duration to display""" + """Represents a symbol and its associated duration to display. + + Attributes: + symbol (str): The symbol to display. + duration (float): Duration in seconds to display the symbol. + color (str): Color to display the symbol in. Defaults to 'white'. + """ symbol: str duration: float color: str = 'white' @@ -30,59 +42,82 @@ class MatrixDisplay(Display): Animates display objects in matrix grid common to any Matrix task. - NOTE: The following are recommended parameter values for matrix experiments: - - time_fixation: 2 - stim_pos_x: -0.6 - stim_pos_y: 0.4 - stim_height: 0.17 + Attributes: + window (visual.Window): PsychoPy window for display. + stimuli_inquiry (List[str]): List of stimuli to present. + stimuli_timing (List[float]): List of timing values for each stimulus. + stimuli_colors (List[str]): List of colors for each stimulus. + stimuli_font (str): Font to use for text stimuli. + symbol_set (Optional[List[str]]): Set of symbols to display. + sort_order (Callable[[str], int]): Function to determine symbol order. + grid_stimuli_height (float): Height of grid stimuli. + positions (Dict[int, Tuple[float, float]]): Positions for each symbol. + grid_color (str): Default color for grid elements. + start_opacity (float): Initial opacity for grid elements. + highlight_opacity (float): Opacity for highlighted elements. + full_grid_opacity (float): Opacity for full grid display. + first_run (bool): Whether this is the first run. + first_stim_time (Optional[float]): Time of first stimulus. + trigger_type (str): Type of trigger to use. + _timing (List[Tuple[str, float]]): List of timing information. + experiment_clock (core.Clock): Clock for timing. + task_bar (TaskBar): Task bar component. + info_text (List[visual.TextStim]): Information text components. + stim_registry (Dict[str, visual.TextStim]): Registry of stimuli. + should_prompt_target (bool): Whether to prompt for target. + preview_params (Optional[PreviewParams]): Preview configuration. + preview_button_handler (Optional[Any]): Handler for preview buttons. + preview_accepted (bool): Whether preview was accepted. + + Note: + The following are recommended parameter values for matrix experiments: + - time_fixation: 2 + - stim_pos_x: -0.6 + - stim_pos_y: 0.4 + - stim_height: 0.17 """ - def __init__(self, - window: visual.Window, - experiment_clock: core.Clock, - stimuli: StimuliProperties, - task_bar: TaskBar, - info: InformationProperties, - rows: int = 5, - columns: int = 6, - width_pct: float = 0.75, - height_pct: float = 0.8, - trigger_type: str = 'text', - symbol_set: Optional[List[str]] = alphabet(), - should_prompt_target: bool = True, - preview_config: Optional[PreviewParams] = None): + def __init__( + self, + window: visual.Window, + experiment_clock: core.Clock, + stimuli: StimuliProperties, + task_bar: TaskBar, + info: InformationProperties, + rows: int = 5, + columns: int = 6, + width_pct: float = 0.75, + height_pct: float = 0.8, + trigger_type: str = 'text', + symbol_set: Optional[List[str]] = alphabet(), + should_prompt_target: bool = True, + preview_config: Optional[PreviewParams] = None + ) -> None: """Initialize Matrix display parameters and objects. - PARAMETERS: - ---------- - # Experiment - window(visual.Window): PsychoPy Window - experiment_clock(core.Clock): Clock used to timestamp display onsets - - # Stimuli - stimuli(StimuliProperties): attributes used for inquiries - - # Task - task_bar(TaskBar): used for task tracking. Ex. 1/100 - - # Info - info(InformationProperties): attributes to display informational stimuli alongside task and inquiry stimuli. - - trigger_type(str) default 'image': defines the calibration trigger type for the display at the beginning of any - task. This will be used to reconcile timing differences between acquisition and the display. - symbol_set default = none : subset of stimuli to be highlighted during an inquiry - should_prompt_target(bool): when True prompts for the target symbol. Assumes that this is - the first symbol of each inquiry. For example: [target, fixation, *stim]. - sort_order - optional function to define the position index for each - symbol. Using a custom function it is possible to skip a position. - preview_config - optional configuration for previewing inquiries + Args: + window (visual.Window): PsychoPy Window. + experiment_clock (core.Clock): Clock used to timestamp display onsets. + stimuli (StimuliProperties): Attributes used for inquiries. + task_bar (TaskBar): Used for task tracking. Ex. 1/100. + info (InformationProperties): Attributes to display informational stimuli. + rows (int): Number of rows in the matrix. Defaults to 5. + columns (int): Number of columns in the matrix. Defaults to 6. + width_pct (float): Width percentage of the display. Defaults to 0.75. + height_pct (float): Height percentage of the display. Defaults to 0.8. + trigger_type (str): Defines the calibration trigger type. Defaults to 'text'. + symbol_set (Optional[List[str]]): Subset of stimuli to be highlighted. + Defaults to alphabet(). + should_prompt_target (bool): When True prompts for the target symbol. + Defaults to True. + preview_config (Optional[PreviewParams]): Configuration for previewing inquiries. + Defaults to None. """ self.window = window - self.stimuli_inquiry = [] - self.stimuli_timing = [] - self.stimuli_colors = [] + self.stimuli_inquiry: List[str] = [] + self.stimuli_timing: List[float] = [] + self.stimuli_colors: List[str] = [] self.stimuli_font = stimuli.stim_font assert stimuli.is_txt_stim, "Matrix display is a text only display" @@ -92,9 +127,11 @@ def __init__(self, # Set position and parameters for grid of alphabet self.grid_stimuli_height = stimuli.stim_height - display_container = layout.centered(parent=window, - width_pct=width_pct, - height_pct=height_pct) + display_container = layout.centered( + parent=window, + width_pct=width_pct, + height_pct=height_pct + ) self.positions = symbol_positions( display_container, rows, columns, symbol_set) @@ -107,7 +144,7 @@ def __init__(self, self.first_run = True self.first_stim_time = None self.trigger_type = trigger_type - self._timing = [] + self._timing: List[Tuple[str, float]] = [] self.experiment_clock = experiment_clock @@ -127,10 +164,23 @@ def __init__(self, ) logger.info(f"Matrix center position: {display_container.center}") - def build_sort_order(self, stimuli: StimuliProperties) -> List[str]: - """Build the symbol set for the display.""" + def build_sort_order(self, stimuli: StimuliProperties) -> Callable[[str], int]: + """Build the symbol set for the display. + + Args: + stimuli (StimuliProperties): Properties containing layout information. + + Returns: + Callable[[str], int]: Function that returns the index for a given symbol. + + Raises: + ValueError: If layout or symbol set is not recognized. + """ if stimuli.layout == 'ALP': - return self.symbol_set.index + if self.symbol_set: + return self.symbol_set.index + else: + raise ValueError('Symbol set not defined') elif stimuli.layout == 'QWERTY': logger.info('Using QWERTY layout') return qwerty_order() @@ -142,7 +192,14 @@ def build_sort_order(self, stimuli: StimuliProperties) -> List[str]: @property def stim_positions(self) -> Dict[str, Tuple[float, float]]: - """Returns a dict with the position for each stim""" + """Get positions for each stimulus. + + Returns: + Dict[str, Tuple[float, float]]: Dictionary mapping symbols to positions. + + Raises: + AssertionError: If stim_registry is not initialized. + """ assert self.stim_registry, "stim_registry not yet initialized" return { sym: tuple(stim.pos) @@ -151,13 +208,18 @@ def stim_positions(self) -> Dict[str, Tuple[float, float]]: @property def preview_enabled(self) -> bool: - """Should the inquiry preview be enabled.""" - return self.preview_params and self.preview_params.show_preview_inquiry + """Check if inquiry preview should be enabled. + + Returns: + bool: True if preview is enabled, False otherwise. + """ + return bool(self.preview_params and self.preview_params.show_preview_inquiry) def capture_grid_screenshot(self, file_path: str) -> None: - """Capture Grid Screenshot. + """Capture a screenshot of the current display. - Capture a screenshot of the current display and save it to the specified filename. + Args: + file_path (str): Path where to save the screenshot. """ # draw the grid and flip the window self.draw_grid(opacity=self.full_grid_opacity) @@ -171,13 +233,17 @@ def capture_grid_screenshot(self, file_path: str) -> None: capture.save(f'{file_path}/{MATRIX_IMAGE_FILENAME}') self.task_bar.current_index = tmp_task_bar - def schedule_to(self, stimuli: list, timing: list, colors: list) -> None: + def schedule_to(self, stimuli: List[str], timing: List[float], colors: Optional[List[str]] = None) -> None: """Schedule stimuli elements (works as a buffer). Args: - stimuli(list[string]): list of stimuli text / name - timing(list[float]): list of timings of stimuli - colors(list[string]): list of colors + stimuli (List[str]): List of stimuli text / name. + timing (List[float]): List of timings of stimuli. + colors (Optional[List[str]]): List of colors. Defaults to None. + + Raises: + AssertionError: If lengths of stimuli and timing don't match, + or if colors are provided but lengths don't match. """ assert len(stimuli) == len( timing), "each stimuli must have a timing value" @@ -191,27 +257,36 @@ def schedule_to(self, stimuli: list, timing: list, colors: list) -> None: self.stimuli_colors = [self.grid_color] * len(stimuli) def symbol_durations(self) -> List[SymbolDuration]: - """Symbols associated with their duration for the currently configured - stimuli_inquiry.""" + """Get symbols associated with their duration for the currently configured stimuli_inquiry. + + Returns: + List[SymbolDuration]: List of symbol durations. + """ return [ SymbolDuration(*sti) for sti in zip( self.stimuli_inquiry, self.stimuli_timing, self.stimuli_colors) ] - def add_timing(self, stimuli: str, stamp: Optional[float] = None): + def add_timing(self, stimuli: str, stamp: Optional[float] = None) -> None: """Add a new timing entry using the stimuli as a label. - Useful as a callback function to register a marker at the time it is - first displayed.""" + Args: + stimuli (str): Label for the timing entry. + stamp (Optional[float]): Timestamp. If None, uses current time. + """ stamp = stamp or self.experiment_clock.getTime() self._timing.append((stimuli, stamp)) - def reset_timing(self): + def reset_timing(self) -> None: """Reset the trigger timing.""" self._timing = [] def do_inquiry(self) -> List[Tuple[str, float]]: - """Animates an inquiry of stimuli and returns a list of stimuli trigger timing.""" + """Animate an inquiry of stimuli and return timing information. + + Returns: + List[Tuple[str, float]]: List of stimuli trigger timing. + """ self.preview_accepted = True self.reset_timing() symbol_durations = self.symbol_durations() @@ -234,36 +309,44 @@ def do_inquiry(self) -> List[Tuple[str, float]]: return self._timing def build_grid(self) -> Dict[str, visual.TextStim]: - """Build the text stimuli to populate the grid.""" + """Build the text stimuli to populate the grid. + Returns: + Dict[str, visual.TextStim]: Dictionary mapping symbols to text stimuli. + """ grid = {} - for sym in self.symbol_set: - pos_index = self.sort_order(sym) - pos = self.positions[pos_index] - grid[sym] = visual.TextStim(win=self.window, - font=self.stimuli_font, - text=sym, - color=self.grid_color, - opacity=self.start_opacity, - pos=pos, - height=self.grid_stimuli_height) + if self.symbol_set: + for sym in self.symbol_set: + pos_index = self.sort_order(sym) + pos = self.positions[pos_index] + grid[sym] = visual.TextStim( + win=self.window, + font=self.stimuli_font, + text=sym, + color=self.grid_color, + opacity=self.start_opacity, + pos=pos, + height=self.grid_stimuli_height + ) return grid - def draw_grid(self, - opacity: float = 1, - color: Optional[str] = 'white', - highlight: Optional[List[str]] = None, - highlight_color: Optional[str] = None): + def draw_grid( + self, + opacity: float = 1, + color: Optional[str] = 'white', + highlight: Optional[List[str]] = None, + highlight_color: Optional[str] = None + ) -> None: """Draw the grid. - Parameters - ---------- - opacity - opacity for each item in the matrix - color - optional color for each item in the matrix - highlight - optional list of stim labels to be highlighted - (rendered using the highlight_opacity). - highlight_color - optional color to use for rendering the - highlighted stim. + Args: + opacity (float): Opacity for each item in the matrix. Defaults to 1. + color (Optional[str]): Optional color for each item in the matrix. + Defaults to 'white'. + highlight (Optional[List[str]]): Optional list of stim labels to be highlighted. + Defaults to None. + highlight_color (Optional[str]): Optional color to use for rendering the + highlighted stim. Defaults to None. """ for symbol, stim in self.stim_registry.items(): should_highlight = highlight and (symbol in highlight) @@ -273,32 +356,32 @@ def draw_grid(self, if highlight_color and should_highlight else color) stim.draw() - def prompt_target(self, target: SymbolDuration) -> float: - """Present the target for the configured length of time. Records the - stimuli timing information. + def prompt_target(self, target: SymbolDuration) -> None: + """Present the target for the configured length of time. - Parameters - ---------- - target - (symbol, duration) tuple + Args: + target (SymbolDuration): Target symbol and its duration. """ # register any timing and marker callbacks self.window.callOnFlip(self.add_timing, target.symbol) - self.draw(grid_opacity=self.start_opacity, - duration=target.duration, - highlight=[target.symbol], - highlight_color=target.color) + self.draw( + grid_opacity=self.start_opacity, + duration=target.duration, + highlight=[target.symbol], + highlight_color=target.color + ) def preview_inquiry(self, stimuli: List[SymbolDuration]) -> bool: - """"Preview the inquiry and handle any button presses. - Parameters - ---------- - stimuli - list of stimuli to highlight (will be flashed in next inquiry) - - Returns - ------- - boolean indicating whether the participant would like to proceed - with the inquiry (True) or reject the inquiry (False) and - go on to the next one. + """Preview the inquiry and handle any button presses. + + Args: + stimuli (List[SymbolDuration]): List of stimuli to highlight. + + Returns: + bool: True if participant wants to proceed, False to reject. + + Raises: + AssertionError: If preview is not enabled or button handler not initialized. """ assert self.preview_enabled, "Preview feature not enabled." assert self.preview_button_handler, "Button handler must be initialized" @@ -312,90 +395,115 @@ def preview_inquiry(self, stimuli: List[SymbolDuration]) -> bool: if handler.has_response(): self.add_timing(handler.response_label, handler.response_timestamp) - self.draw(grid_opacity=self.start_opacity, - duration=self.preview_params.preview_inquiry_isi) + if self.preview_params: + self.draw( + grid_opacity=self.start_opacity, + duration=self.preview_params.preview_inquiry_isi + ) return handler.accept_result() def draw_preview(self, stimuli: List[SymbolDuration]) -> None: - """Draw the inquiry preview by highlighting all of the symbols in the - list.""" - self.draw(grid_opacity=self.start_opacity, - highlight=[stim.symbol for stim in stimuli]) - - def draw(self, - grid_opacity: float, - grid_color: Optional[str] = None, - duration: Optional[float] = None, - highlight: Optional[List[str]] = None, - highlight_color: Optional[str] = None): + """Draw the inquiry preview by highlighting all of the symbols in the list. + + Args: + stimuli (List[SymbolDuration]): List of stimuli to highlight. + """ + self.draw( + grid_opacity=self.start_opacity, + highlight=[stim.symbol for stim in stimuli] + ) + + def draw( + self, + grid_opacity: float, + grid_color: Optional[str] = None, + duration: Optional[float] = None, + highlight: Optional[List[str]] = None, + highlight_color: Optional[str] = None + ) -> None: """Draw all screen elements and flip the window. - Parameters - ---------- - grid_opacity - opacity value to use on all grid symbols - grid_color - optional color to use for all grid symbols - duration - optional seconds to wait after flipping the window. - highlight - optional list of symbols to highlight in the grid. - highlight_color - optional color to use for rendering the - highlighted stim. + Args: + grid_opacity (float): Opacity value to use on all grid symbols. + grid_color (Optional[str]): Optional color to use for all grid symbols. + Defaults to None. + duration (Optional[float]): Optional seconds to wait after flipping the window. + Defaults to None. + highlight (Optional[List[str]]): Optional list of symbols to highlight in the grid. + Defaults to None. + highlight_color (Optional[str]): Optional color to use for rendering the + highlighted stim. Defaults to None. """ - self.draw_grid(opacity=grid_opacity, - color=grid_color or self.grid_color, - highlight=highlight, - highlight_color=highlight_color) + self.draw_grid( + opacity=grid_opacity, + color=grid_color or self.grid_color, + highlight=highlight, + highlight_color=highlight_color + ) self.draw_components() self.window.flip() if duration: core.wait(duration) - def animate_scp(self, fixation: SymbolDuration, - stimuli: List[SymbolDuration]) -> None: + def animate_scp(self, fixation: SymbolDuration, stimuli: List[SymbolDuration]) -> None: """Animate the given stimuli using single character presentation. - Flashes each stimuli in stimuli_inquiry for their respective flash - times and records the timing information. + Args: + fixation (SymbolDuration): Fixation symbol and duration. + stimuli (List[SymbolDuration]): List of stimuli to animate. """ - # Flashing the grid at full opacity is considered fixation. self.window.callOnFlip(self.add_timing, fixation.symbol) - self.draw(grid_opacity=self.full_grid_opacity, - grid_color=(fixation.color if self.should_prompt_target else - self.grid_color), - duration=fixation.duration / 2) - self.draw(grid_opacity=self.start_opacity, - duration=fixation.duration / 2) + self.draw( + grid_opacity=self.full_grid_opacity, + grid_color=(fixation.color if self.should_prompt_target else + self.grid_color), + duration=fixation.duration / 2 + ) + self.draw( + grid_opacity=self.start_opacity, + duration=fixation.duration / 2 + ) for stim in stimuli: self.window.callOnFlip(self.add_timing, stim.symbol) - self.draw(grid_opacity=self.start_opacity, - duration=stim.duration, - highlight=[stim.symbol], - highlight_color=stim.color) + self.draw( + grid_opacity=self.start_opacity, + duration=stim.duration, + highlight=[stim.symbol], + highlight_color=stim.color + ) self.draw(self.start_opacity) def wait_screen(self, message: str, message_color: str) -> None: - """Wait Screen. + """Display a wait screen with a message. - Define what happens on the screen when a user pauses a session. + Args: + message (str): Message to display. + message_color (str): Color of the message. """ self.draw_components() # Construct the wait message - wait_message = visual.TextStim(win=self.window, - font=self.stimuli_font, - text=message, - height=.1, - color=message_color, - pos=(0, -.5), - wrapWidth=2) + wait_message = visual.TextStim( + win=self.window, + font=self.stimuli_font, + text=message, + height=.1, + color=message_color, + pos=(0, -.5), + wrapWidth=2 + ) # try adding the BciPy logo to the wait screen try: - wait_logo = visual.ImageStim(self.window, - image=BCIPY_LOGO_PATH, - pos=(0, .25), - mask=None, - ori=0.0) + wait_logo = visual.ImageStim( + self.window, + image=BCIPY_LOGO_PATH, + pos=(0, .25), + mask=None, + ori=0.0 + ) wait_logo.size = resize_image(BCIPY_LOGO_PATH, self.window.size, 1) wait_logo.draw() @@ -422,29 +530,29 @@ def draw_components(self) -> None: info.draw() def update_task_bar(self, text: str = '') -> None: - """Update Task. - - Update any task related display items not related to the inquiry. Ex. stimuli count 1/200. + """Update task related display items. - PARAMETERS: - - text: text for task + Args: + text (str): Text for task. Defaults to ''. """ if self.task_bar: self.task_bar.update(text) def _trigger_pulse(self) -> None: - """Trigger Pulse. + """Send a calibration trigger pulse. This method uses a calibration trigger to determine any functional - offsets needed for operation with this display. By setting the first_stim_time and searching for the - same stimuli output to the marker stream, the offsets between these proceses can be reconciled at the - beginning of an experiment. If drift is detected in your experiment, more frequent pulses and offset - correction may be required. + offsets needed for operation with this display. By setting the first_stim_time + and searching for the same stimuli output to the marker stream, the offsets + between these processes can be reconciled at the beginning of an experiment. + If drift is detected in your experiment, more frequent pulses and offset + correction may be required. """ - calibration_time = _calibration_trigger(self.experiment_clock, - trigger_type=self.trigger_type, - display=self.window) + calibration_time = _calibration_trigger( + self.experiment_clock, + trigger_type=self.trigger_type, + display=self.window + ) # set the first stim time if not present and first_run to False if not self.first_stim_time: diff --git a/bcipy/display/paradigm/matrix/layout.py b/bcipy/display/paradigm/matrix/layout.py index 994642bf4..8267a3354 100644 --- a/bcipy/display/paradigm/matrix/layout.py +++ b/bcipy/display/paradigm/matrix/layout.py @@ -1,4 +1,10 @@ -"""Functions for calculating matrix layouts""" +"""Functions for calculating matrix layouts. + +This module provides functionality for calculating and managing the layout of symbols +in a matrix-style grid format, commonly used in BCI paradigms. It handles the +positioning and spacing of elements based on window dimensions and layout requirements. +""" + from typing import List, Optional, Tuple from bcipy.display.components.layout import (Layout, above, below, left_of, @@ -6,26 +12,35 @@ scaled_width) -def symbol_positions(container: Layout, - rows: int, - columns: int, - symbol_set: List[str], - max_spacing: Optional[float] = None) -> List[Tuple[float, float]]: - """Compute the positions for arranging a number of symbols in a grid - layout. +def symbol_positions( + container: Layout, + rows: int, + columns: int, + symbol_set: List[str], + max_spacing: Optional[float] = None +) -> List[Tuple[float, float]]: + """Compute the positions for arranging a number of symbols in a grid layout. + + This function calculates the positions for placing symbols in a grid format, + taking into account window dimensions, aspect ratio, and spacing requirements. + The grid is centered in the container and positions are returned in row-major order. - Parameters - ---------- - container - container in which the grid should be placed; must have a + Args: + container (Layout): Container in which the grid should be placed; must have a visual.Window parent, which is used to determine the aspect ratio. - rows - number of rows in the grid - columns - number of columns in the grid - symbol_set - list of symbols to place in the grid - max_spacing - optional max spacing (in layout units) in the height - direction; width will be normalized to this value if provided - Returns - ------- - list of (x,y) tuples with (rows * columns) positions in row,col order + rows (int): Number of rows in the grid. + columns (int): Number of columns in the grid. + symbol_set (List[str]): List of symbols to place in the grid. + max_spacing (Optional[float]): Optional max spacing (in layout units) in the height + direction; width will be normalized to this value if provided. Defaults to None. + + Returns: + List[Tuple[float, float]]: List of (x,y) tuples with (rows * columns) positions + in row-major order. + + Raises: + AssertionError: If container has no parent, if rows or columns are less than 1, + or if there are not enough positions for all symbols. """ assert container.parent, "Container must have a parent" assert rows >= 1 and columns >= 1, "There must be at least one row and one column" diff --git a/bcipy/display/paradigm/rsvp/display.py b/bcipy/display/paradigm/rsvp/display.py index e96a28d33..2c075edad 100644 --- a/bcipy/display/paradigm/rsvp/display.py +++ b/bcipy/display/paradigm/rsvp/display.py @@ -1,6 +1,13 @@ +"""RSVP display module. + +This module provides the base RSVP (Rapid Serial Visual Presentation) display implementation +which handles the visual presentation of stimuli during RSVP tasks. It provides core functionality +for stimulus presentation, timing control, and inquiry management. +""" + import logging import os.path as path -from typing import List, Optional, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple, Union from psychopy import core, visual @@ -18,13 +25,45 @@ class RSVPDisplay(Display): """RSVP Display Object for inquiry Presentation. - Animates display objects common to any RSVP task. + Animates display objects common to any RSVP task. Handles stimulus presentation, + timing control, and inquiry management for RSVP-based BCI paradigms. + + Attributes: + window (visual.Window): PsychoPy window for display. + window_size (Tuple[float, float]): Size of the display window. + refresh_rate (float): Display refresh rate. + stimuli_inquiry (List[str]): List of stimuli to present. + stimuli_colors (List[str]): List of colors for each stimulus. + stimuli_timing (List[float]): List of presentation durations. + stimuli_font (str): Font to use for text stimuli. + textbox_font (str): Font to use for text boxes. + stimuli_height (float): Height of stimuli. + stimuli_pos (Tuple[float, float]): Position of stimuli. + is_txt_stim (bool): Whether stimuli are text-based. + stim_length (int): Length of stimulus list. + full_screen (bool): Whether display is fullscreen. + preview_params (Optional[PreviewParams]): Preview configuration. + preview_button_handler (Optional[Any]): Handler for preview buttons. + preview_accepted (bool): Whether preview was accepted. + staticPeriod (core.StaticPeriod): Clock for static timing. + first_run (bool): Whether this is the first run. + first_stim_time (Optional[float]): Time of first stimulus. + trigger_type (str): Type of trigger to use. + trigger_callback (TriggerCallback): Callback for triggers. + experiment_clock (Clock): Clock for experiment timing. + first_stim_callback (Callable): Callback for first stimulus. + size_list_sti (List[float]): List of stimulus sizes. + space_char (str): Character to use for spaces. + task_bar (TaskBar): Task bar component. + info (InformationProperties): Information display properties. + info_text (List[visual.TextStim]): Text stimuli for information. + sti (List[visual.TextStim]): Initial stimuli objects. """ def __init__( self, window: visual.Window, - static_clock, + static_clock: core.StaticPeriod, experiment_clock: Clock, stimuli: StimuliProperties, task_bar: TaskBar, @@ -32,34 +71,21 @@ def __init__( preview_config: Optional[PreviewParams] = None, trigger_type: str = 'image', space_char: str = SPACE_CHAR, - full_screen: bool = False): + full_screen: bool = False) -> None: """Initialize RSVP display parameters and objects. - PARAMETERS: - ---------- - # Experiment - window(visual.Window): PsychoPy Window - static_clock(core.MonotonicClock): Used to schedule static periods of display time - experiment_clock(Clock): Clock used to timestamp display onsets - - # Stimuli - stimuli(StimuliProperties): attributes used for inquiries - - # Task - task_bar(TaskBar): used for task tracking. Ex. 1/100 - - # Info - info(InformationProperties): attributes to display informational stimuli alongside task and inquiry stimuli. - - # Preview Inquiry - preview_config(PreviewParams) Optional: parameters used to specify the behavior for displaying a preview - of upcoming stimuli defined via self.stimuli(StimuliProperties). If None a preview is not displayed. - - trigger_type(str) default 'image': defines the calibration trigger type for the display at the beginning of any - task. This will be used to reconcile timing differences between acquisition and the display. - space_char(str) default SPACE_CHAR: defines the space character to use in the RSVP inquiry. - full_screen(bool) default False: Whether or not the window is set to a full screen dimension. Used for - scaling display items as needed. + Args: + window (visual.Window): PsychoPy Window for display. + static_clock (core.StaticPeriod): Used to schedule static periods of display time. + experiment_clock (Clock): Clock used to timestamp display onsets. + stimuli (StimuliProperties): Attributes used for inquiries. + task_bar (TaskBar): Used for task tracking. Ex. 1/100. + info (InformationProperties): Attributes to display informational stimuli. + preview_config (Optional[PreviewParams]): Parameters for preview functionality. + If None, preview is not displayed. + trigger_type (str, optional): Defines the calibration trigger type. Defaults to 'image'. + space_char (str, optional): Character to use for spaces. Defaults to SPACE_CHAR. + full_screen (bool, optional): Whether window is fullscreen. Defaults to False. """ self.window = window self.window_size = self.window.size # [w, h] @@ -67,14 +93,12 @@ def __init__( self.logger = logging.getLogger(__name__) - # Stimuli parameters, these are set on display in order to allow - # easy updating after definition + # Stimuli parameters self.stimuli_inquiry = stimuli.stim_inquiry self.stimuli_colors = stimuli.stim_colors self.stimuli_timing = stimuli.stim_timing self.stimuli_font = stimuli.stim_font - # Note: there is a bug in TextBox2 that prevents certain custom fonts from being used. This is to avoid that. - self.textbox_font = 'Consolas' + self.textbox_font = 'Consolas' # Avoid TextBox2 font bug self.stimuli_height = stimuli.stim_height self.stimuli_pos = stimuli.stim_pos self.is_txt_stim = stimuli.is_txt_stim @@ -97,9 +121,9 @@ def __init__( self.experiment_clock = experiment_clock # Callback used on presentation of first stimulus. - self.first_stim_callback = lambda _sti: None - self.size_list_sti = [] # TODO force initial size definition - self.space_char = space_char # TODO remove and force task to define + self.first_stim_callback: Callable = lambda _sti: None + self.size_list_sti: List[float] = [] + self.space_char = space_char self.task_bar = task_bar @@ -112,10 +136,14 @@ def __init__( @property def preview_enabled(self) -> bool: - """Should the inquiry preview be enabled.""" - return self.preview_params and self.preview_params.show_preview_inquiry + """Check if inquiry preview should be enabled. + + Returns: + bool: True if preview is enabled and configured, False otherwise. + """ + return bool(self.preview_params and self.preview_params.show_preview_inquiry) - def draw_static(self): + def draw_static(self) -> None: """Draw static elements in a stimulus.""" if self.task_bar: self.task_bar.draw() @@ -125,13 +153,13 @@ def draw_static(self): def schedule_to(self, stimuli: Optional[List[str]] = None, timing: Optional[List[float]] = None, - colors: Optional[List[str]] = None): + colors: Optional[List[str]] = None) -> None: """Schedule stimuli elements (works as a buffer). Args: - stimuli(list[string]): list of stimuli text / name - timing(list[float]): list of timings of stimuli - colors(list[string]): list of colors + stimuli (Optional[List[str]]): List of stimuli text/name. + timing (Optional[List[float]]): List of timings of stimuli. + colors (Optional[List[str]]): List of colors. """ self.stimuli_inquiry = stimuli or [] self.stimuli_timing = timing or [] @@ -139,104 +167,93 @@ def schedule_to(self, @property def preview_index(self) -> int: - """Index within an inquiry at which the inquiry preview should be displayed. + """Get index within an inquiry at which the preview should be displayed. For calibration, we should display it after the target prompt (index = 1). For copy phrase there is no target prompt so it should display before the - rest of the inquiry.""" + rest of the inquiry. + + Returns: + int: The index at which to display the preview. + """ return 1 def do_inquiry(self) -> List[Tuple[str, float]]: - """Do inquiry. + """Perform an inquiry of flashing letters to achieve RSVP. - Animates an inquiry of flashing letters to achieve RSVP. - - - RETURNS: - -------- - timing(list[float]): list of timings of stimuli presented in the inquiry + Returns: + List[Tuple[str, float]]: List of timings of stimuli presented in the inquiry. """ - - # init an array for timing information timing: List[Tuple[str, float]] = [] self.preview_accepted = True if self.first_run: self._trigger_pulse() - # generate a inquiry (list of stimuli with meta information) inquiry = self._generate_inquiry() - # do the inquiry for idx, stim_props in enumerate(inquiry): - - # If this is the start of an inquiry and a callback registered for first_stim_callback evoke it if idx == 0 and callable(self.first_stim_callback): self.first_stim_callback(stim_props['sti']) - # If previewing the inquiry during calibration, do so after the first stimulus if self.preview_enabled and idx == self.preview_index: self.preview_accepted = self.preview_inquiry(timing) if not self.preview_accepted: break - # Reset the timing clock to start presenting self.window.callOnFlip( self.trigger_callback.callback, self.experiment_clock, stim_props['sti_label']) - # Draw stimulus for n frames stim_props['sti'].draw() self.draw_static() self.window.flip() core.wait(stim_props['time_to_present']) - # append timing information timing.append(self.trigger_callback.timing) - self.trigger_callback.reset() - # draw in static and flip once more self.draw_static() self.window.flip() return timing def _trigger_pulse(self) -> None: - """Trigger Pulse. + """Send a calibration trigger pulse. - This method uses a calibration trigger to determine any functional - offsets needed for operation with this display. By setting the first_stim_time and searching for the - same stimuli output to the marker stream, the offsets between these proceses can be reconciled at the - beginning of an experiment. If drift is detected in your experiment, more frequent pulses and offset - correction may be required. + Uses a calibration trigger to determine any functional offsets needed for + operation with this display. Sets first_stim_time and searches for the same + stimuli output to the marker stream to reconcile timing offsets. """ calibration_time = _calibration_trigger( self.experiment_clock, trigger_type=self.trigger_type, display=self.window) - # set the first stim time if not present and first_run to False if not self.first_stim_time: self.first_stim_time = calibration_time[-1] self.first_run = False def preview_inquiry(self, timing: List[Tuple[str, float]]) -> bool: - """Preview Inquiry. + """Preview the inquiry before presentation. Given an inquiry defined to be presented via do_inquiry(), present the full inquiry - to the user and allow input on whether the intended letter is present or not before - going through the rapid serial visual presention. + to the user and allow input on whether the intended letter is present or not before + going through the rapid serial visual presentation. + + Args: + timing (List[Tuple[str, float]]): List to which timing information should be appended. - Parameters: - timing - list to which all timing information should be appended. Returns: - - A boolean describing whether to present the inquiry (True) or - generate another (False). + bool: True if inquiry should be presented, False if a new one should be generated. + + Raises: + AssertionError: If preview is not enabled or button handler is not initialized. """ assert self.preview_enabled, "Preview feature not enabled." assert self.preview_button_handler, "Button handler must be initialized" + assert self.preview_params is not None, "Preview parameters must be set" handler = self.preview_button_handler self.window.callOnFlip( @@ -257,19 +274,26 @@ def preview_inquiry(self, timing: List[Tuple[str, float]]) -> bool: return handler.accept_result() - def draw_preview(self): - """Generate and draw the inquiry preview""" + def draw_preview(self) -> None: + """Generate and draw the inquiry preview.""" content = self._generate_inquiry_preview() content.draw() self.draw_static() self.window.flip() def _generate_inquiry_preview(self) -> visual.TextBox2: - """Generate Inquiry Preview. + """Generate the inquiry preview box. - Using the self.stimuli_inquiry list, construct a preview box to display to the user. This method - assumes the presence of a fixation (+). + Using the self.stimuli_inquiry list, construct a preview box to display to the user. + Assumes the presence of a fixation (+). + + Returns: + visual.TextBox2: The preview text box. + + Raises: + AssertionError: If preview parameters are not set. """ + assert self.preview_params is not None, "Preview parameters must be set" text = ' '.join(self.stimuli_inquiry).split('+ ')[1] return self._create_stimulus( @@ -280,10 +304,11 @@ def _generate_inquiry_preview(self) -> visual.TextBox2: mode='textbox', align_text='left') - def _generate_inquiry(self) -> list: - """Generate inquiry. + def _generate_inquiry(self) -> List[Dict[str, Any]]: + """Generate stimuli for next RSVP inquiry. - Generate stimuli for next RSVP inquiry. [A + A, C, Q, D] + Returns: + List[Dict[str, Any]]: List of stimulus properties for the inquiry. """ stim_info = [] for idx, stim in enumerate(self.stimuli_inquiry): @@ -291,13 +316,9 @@ def _generate_inquiry(self) -> list: current_stim['time_to_present'] = self.stimuli_timing[idx] - # check if stimulus needs to use a non-default size - if self.size_list_sti: - this_stimuli_size = self.size_list_sti[idx] - else: - this_stimuli_size = self.stimuli_height + this_stimuli_size = (self.size_list_sti[idx] if self.size_list_sti + else self.stimuli_height) - # Set the Stimuli attrs if stim.endswith('.png'): current_stim['sti'] = self._create_stimulus( mode='image', @@ -309,32 +330,27 @@ def _generate_inquiry(self) -> list: current_stim['sti_label'] = path.splitext( path.basename(stim))[0] else: - # text stimulus - current_stim['sti'] = self._create_stimulus(mode='text', height=this_stimuli_size) + current_stim['sti'] = self._create_stimulus( + mode='text', height=this_stimuli_size) txt = stim - # customize presentation of space char. current_stim['sti'].text = txt if txt != SPACE_CHAR else self.space_char current_stim['sti'].color = self.stimuli_colors[idx] current_stim['sti_label'] = txt - # test whether the word will be too big for the screen text_width = current_stim['sti'].boundingBox[0] if text_width > self.window.size[0]: screen_info = get_screen_info() monitor_width = screen_info.width monitor_height = screen_info.height text_height = current_stim['sti'].boundingBox[1] - # If we are in full-screen, text size in Psychopy norm units - # is monitor width/monitor height if self.window.size[0] == monitor_width: new_text_width = monitor_width / monitor_height else: - # If not, text width is calculated relative to both - # monitor size and window size new_text_width = ( self.window.size[1] / monitor_height) * ( monitor_width / monitor_height) - new_text_height = (text_height * new_text_width) / text_width + new_text_height = ( + text_height * new_text_width) / text_width current_stim['sti'].height = new_text_height stim_info.append(current_stim) return stim_info @@ -342,22 +358,22 @@ def _generate_inquiry(self) -> list: def update_task_bar(self, text: Optional[str] = None) -> None: """Update task state. - Removes letters or appends to the right. Args: - text(string): new text for task state + text (Optional[str]): New text for task state. """ if self.task_bar: self.task_bar.update(text) def wait_screen(self, message: str, message_color: str) -> None: - """Wait Screen. + """Display a wait screen with message and optional logo. Args: - message(string): message to be displayed while waiting - message_color(string): color of the message to be displayed - """ + message (str): Message to be displayed while waiting. + message_color (str): Color of the message to be displayed. - # Construct the wait message + Raises: + Exception: If the logo image cannot be loaded. + """ wait_message = visual.TextStim(win=self.window, font=self.stimuli_font, text=message, @@ -369,7 +385,6 @@ def wait_screen(self, message: str, message_color: str) -> None: opacity=1, depth=-6.0) - # try adding the BciPy logo to the wait screen try: wait_logo = visual.ImageStim( self.window, @@ -384,27 +399,41 @@ def wait_screen(self, message: str, message_color: str) -> None: wait_logo.draw() except Exception as e: - self.logger.exception(f'Cannot load logo image from path=[{BCIPY_LOGO_PATH}]') + self.logger.exception( + f'Cannot load logo image from path=[{BCIPY_LOGO_PATH}]') raise e - # Draw and flip the screen. wait_message.draw() self.window.flip() def _create_stimulus( self, - height: int, + height: float, mode: str = 'text', - stimulus='+', - color='white', - stimuli_position=None, - align_text='center', - units=None, - wrap_width=None, - border=False): - """Create Stimulus. - - Returns a TextStim, ImageStim or TextBox object. + stimulus: str = '+', + color: str = 'white', + stimuli_position: Optional[Tuple[float, float]] = None, + align_text: str = 'center', + units: Optional[str] = None, + wrap_width: Optional[float] = None, + border: bool = False) -> Union[visual.TextStim, visual.ImageStim, visual.TextBox2]: + """Create a stimulus object. + + Args: + height (float): Height of the stimulus. + mode (str, optional): Type of stimulus ('text', 'image', or 'textbox'). + Defaults to 'text'. + stimulus (str, optional): Content of the stimulus. Defaults to '+'. + color (str, optional): Color of the stimulus. Defaults to 'white'. + stimuli_position (Optional[Tuple[float, float]], optional): Position of the stimulus. + Defaults to None. + align_text (str, optional): Text alignment. Defaults to 'center'. + units (Optional[str], optional): Units for size/position. Defaults to None. + wrap_width (Optional[float], optional): Width for text wrapping. Defaults to None. + border (bool, optional): Whether to show border. Defaults to False. + + Returns: + Union[visual.TextStim, visual.ImageStim, visual.TextBox2]: The created stimulus object. """ if not stimuli_position: stimuli_position = self.stimuli_pos @@ -448,3 +477,6 @@ def _create_stimulus( alignment=align_text, editable=False, ) + else: + raise ValueError( + f'RSVPDisplay asked to create a stimulus type=[{mode}] that is not supported.') diff --git a/bcipy/display/paradigm/rsvp/mode/calibration.py b/bcipy/display/paradigm/rsvp/mode/calibration.py index 9b76a0563..c1efd1c95 100644 --- a/bcipy/display/paradigm/rsvp/mode/calibration.py +++ b/bcipy/display/paradigm/rsvp/mode/calibration.py @@ -1,22 +1,59 @@ +"""RSVP calibration display module. + +This module provides the RSVP calibration display implementation which handles the visual +presentation of stimuli during calibration tasks. It extends the base RSVP display with +calibration-specific functionality. +""" +from typing import Optional + +from psychopy import core, visual + from bcipy.core.symbols import SPACE_CHAR +from bcipy.display import InformationProperties, StimuliProperties +from bcipy.display.components.task_bar import TaskBar +from bcipy.display.main import PreviewParams from bcipy.display.paradigm.rsvp.display import RSVPDisplay class CalibrationDisplay(RSVPDisplay): - """Calibration Display.""" + """Calibration Display for RSVP paradigm. + + This class extends the RSVPDisplay to provide calibration-specific functionality. + It handles the visual presentation of stimuli during calibration tasks, including + preview functionality and timing control. + + Attributes: + preview_index (int): Index within an inquiry at which the inquiry preview + should be displayed. For calibration, this is set to 1 (after target prompt). + """ def __init__(self, - window, - static_clock, - experiment_clock, - stimuli, - task_bar, - info, - trigger_type='image', - preview_config=None, - space_char=SPACE_CHAR, - full_screen=False): + window: visual.Window, + static_clock: core.StaticPeriod, + experiment_clock: core.Clock, + stimuli: StimuliProperties, + task_bar: TaskBar, + info: InformationProperties, + trigger_type: str = 'image', + preview_config: Optional[PreviewParams] = None, + space_char: str = SPACE_CHAR, + full_screen: bool = False) -> None: + """Initialize the RSVP calibration display. + Args: + window (visual.Window): PsychoPy window for display. + static_clock (core.StaticPeriod): Clock for static timing. + experiment_clock (core.Clock): Clock for experiment timing. + stimuli (StimuliProperties): Properties for stimulus presentation. + task_bar (TaskBar): Task bar component for progress display. + info (InformationProperties): Properties for information display. + trigger_type (str, optional): Type of trigger to use. Defaults to 'image'. + preview_config (Optional[PreviewParams], optional): Configuration for preview + functionality. Defaults to None. + space_char (str, optional): Character to use for spaces. Defaults to SPACE_CHAR. + full_screen (bool, optional): Whether to display in fullscreen mode. + Defaults to False. + """ super().__init__(window, static_clock, experiment_clock, @@ -33,5 +70,8 @@ def preview_index(self) -> int: """Index within an inquiry at which the inquiry preview should be displayed. For calibration, we should display it after the target prompt (index = 1). + + Returns: + int: The index at which to display the preview (1 for calibration). """ return 1 diff --git a/bcipy/display/paradigm/rsvp/mode/copy_phrase.py b/bcipy/display/paradigm/rsvp/mode/copy_phrase.py index 8ce877c21..a516484fc 100644 --- a/bcipy/display/paradigm/rsvp/mode/copy_phrase.py +++ b/bcipy/display/paradigm/rsvp/mode/copy_phrase.py @@ -1,42 +1,72 @@ -from psychopy import visual +"""RSVP copy phrase display module. + +This module provides the RSVP copy phrase display implementation which handles the visual +presentation of stimuli during copy phrase tasks. It extends the base RSVP display with +copy phrase-specific functionality. + +Note: + RSVP Tasks are RSVPDisplay objects with different structure. They share + the tasks and the essential elements and stimuli. However, layout, length of + stimuli list, update procedures and colors are different. Therefore each + mode should be separated from each other carefully. +""" + +from typing import Optional + +from psychopy import core, visual from bcipy.core.stimuli import resize_image from bcipy.core.symbols import SPACE_CHAR +from bcipy.display import InformationProperties, StimuliProperties +from bcipy.display.components.task_bar import TaskBar +from bcipy.display.main import PreviewParams from bcipy.display.paradigm.rsvp.display import BCIPY_LOGO_PATH, RSVPDisplay -"""Note: - -RSVP Tasks are RSVPDisplay objects with different structure. They share -the tasks and the essential elements and stimuli. However, layout, length of -stimuli list, update procedures and colors are different. Therefore each -mode should be separated from each other carefully. -Functions: - update_task_state: update task information of the module -""" - class CopyPhraseDisplay(RSVPDisplay): - """ Copy Phrase display object of RSVP + """Copy Phrase display object of RSVP. - Custom attributes: - static_task_text(str): target text for the user to attempt to spell - static_task_color(str): target text color for the user to attempt to spell + This class extends the RSVPDisplay to provide copy phrase-specific functionality. + It handles the visual presentation of stimuli during copy phrase tasks, including + preview functionality and wait screen display. + + Attributes: + starting_spelled_text (str): Initial text that has been spelled. + static_task_text (str): Target text for the user to attempt to spell. + static_task_color (str): Target text color for the user to attempt to spell. """ def __init__( self, - window, - clock, - experiment_clock, - stimuli, - task_bar, - info, - starting_spelled_text='', - trigger_type='image', - space_char=SPACE_CHAR, - preview_config=None, - full_screen=False): - """ Initializes Copy Phrase Task Objects """ + window: visual.Window, + clock: core.Clock, + experiment_clock: core.Clock, + stimuli: StimuliProperties, + task_bar: TaskBar, + info: InformationProperties, + starting_spelled_text: str = '', + trigger_type: str = 'image', + space_char: str = SPACE_CHAR, + preview_config: Optional[PreviewParams] = None, + full_screen: bool = False) -> None: + """Initialize Copy Phrase Task Objects. + + Args: + window (visual.Window): PsychoPy window for display. + clock (core.Clock): Clock for timing. + experiment_clock (core.Clock): Clock for experiment timing. + stimuli (StimuliProperties): Properties for stimulus presentation. + task_bar (TaskBar): Task bar component for progress display. + info (InformationProperties): Properties for information display. + starting_spelled_text (str, optional): Initial text that has been spelled. + Defaults to ''. + trigger_type (str, optional): Type of trigger to use. Defaults to 'image'. + space_char (str, optional): Character to use for spaces. Defaults to SPACE_CHAR. + preview_config (Optional[PreviewParams], optional): Configuration for preview + functionality. Defaults to None. + full_screen (bool, optional): Whether to display in fullscreen mode. + Defaults to False. + """ self.starting_spelled_text = starting_spelled_text super().__init__(window, @@ -56,17 +86,22 @@ def preview_index(self) -> int: For copy phrase there is no target prompt so it should display before the fixation. + + Returns: + int: The index at which to display the preview (0 for copy phrase). """ return 0 def wait_screen(self, message: str, message_color: str) -> None: - """Wait Screen. + """Display a wait screen with a message and optional logo. Args: - message(string): message to be displayed while waiting - message_color(string): color of the message to be displayed - """ + message (str): Message to be displayed while waiting. + message_color (str): Color of the message to be displayed. + Raises: + Exception: If the logo image cannot be loaded. + """ self.draw_static() # Construct the wait message @@ -96,7 +131,8 @@ def wait_screen(self, message: str, message_color: str) -> None: wait_logo.draw() except Exception as e: - self.logger.exception(f'Cannot load logo image from path=[{BCIPY_LOGO_PATH}]') + self.logger.exception( + f'Cannot load logo image from path=[{BCIPY_LOGO_PATH}]') raise e # Draw and flip the screen. diff --git a/bcipy/display/paradigm/vep/display.py b/bcipy/display/paradigm/vep/display.py index 270f077ca..6f80dfcc3 100644 --- a/bcipy/display/paradigm/vep/display.py +++ b/bcipy/display/paradigm/vep/display.py @@ -152,7 +152,8 @@ def check_configuration(self): def stim_properties(self) -> List[StimProps]: """Returns a tuple of (symbol, duration, and color) for each stimuli, including the target and fixation stim. Stimuli that represent VEP - boxes will have a list of symbols.""" + boxes will have a list of symbols. + """ stim_num = len(self.stimuli_inquiry) assert len(self.stimuli_colors ) == stim_num, "Each box should have its own color" @@ -221,7 +222,8 @@ def prompt_target(self, target - (symbol, duration, color) tuple """ assert isinstance(target.symbol, str), "Target must be a str" - self.logger.info(f"Target: {target.symbol} at index {target_box_index}") + self.logger.info( + f"Target: {target.symbol} at index {target_box_index}") # Show all symbols in the matrix at reduced opacity for sym in self.symbol_set: @@ -313,7 +315,8 @@ def animate_inquiry(self, stimuli: List[StimProps]) -> None: def set_stimuli_colors(self, stim_groups: List[StimProps]) -> None: """Update the colors of the stimuli associated with each symbol to - reflect which box it will be placed in.""" + reflect which box it will be placed in. + """ for group in stim_groups: for sym in group.symbol: self.sti[sym].color = group.color @@ -399,7 +402,8 @@ def add_timing(self, stimuli: str): """Add a new timing entry using the stimuli as a label. Useful as a callback function to register a marker at the time it is - first displayed.""" + first displayed. + """ self._timing.append(StimTime(stimuli, self.experiment_clock.getTime())) def reset_timing(self): @@ -461,8 +465,7 @@ def _trigger_pulse(self) -> None: def schedule_to(self, stimuli: List[List[Any]], timing: Optional[List[List[float]]] = None, colors: Optional[List[List[str]]] = None) -> None: - """Schedule stimuli elements (works as a buffer). - """ + """Schedule stimuli elements (works as a buffer).""" self.stimuli_inquiry = stimuli # type: ignore assert timing is None or timing == self.stimuli_timing, "Timing values must match pre-configured values" assert colors is None or colors == self.stimuli_colors, "Colors must match the pre-configured values" @@ -503,8 +506,7 @@ def _reset_text_boxes(self) -> None: text_box.borderWidth = self.box_border_width def _set_inquiry(self, stimuli: List[StimProps]) -> List[visual.TextBox2]: - """Set the correct inquiry text for each text boxes. - """ + """Set the correct inquiry text for each text boxes.""" for i, sti in enumerate(stimuli): box = self.text_boxes[i] text = ' '.join(sti.symbol) diff --git a/bcipy/display/tests/components/test_layout.py b/bcipy/display/tests/components/test_layout.py index 846c589c1..d0432d90f 100644 --- a/bcipy/display/tests/components/test_layout.py +++ b/bcipy/display/tests/components/test_layout.py @@ -189,7 +189,8 @@ def test_scaled_size(self): msg="Width should be proportional to the window aspect") self.assertEqual( - scaled_size(height=0.2, window_size=(800, 500), units='height'), (0.2, 0.2), + scaled_size(height=0.2, window_size=( + 800, 500), units='height'), (0.2, 0.2), msg="Width should be the same in 'height' units") def test_scaled_height(self): diff --git a/bcipy/exceptions.py b/bcipy/exceptions.py index 23c525a03..7b6161389 100644 --- a/bcipy/exceptions.py +++ b/bcipy/exceptions.py @@ -1,102 +1,171 @@ +"""Custom exceptions for the BciPy package. + +This module contains all custom exceptions used throughout the BciPy application. +Each exception is designed to provide specific error information for different +components of the system. +""" +from typing import Any + + class BciPyCoreException(Exception): - """BciPy Core Exception. + """Base exception class for BciPy-specific errors. - Thrown when an error occurs specific to BciPy core concepts. + Args: + message: A descriptive message explaining the error. + errors: Optional additional error information. + + Attributes: + message: The error message. + errors: Additional error details, if any. """ - def __init__(self, message, errors=None): + def __init__(self, message: str, errors: Any = None) -> None: super().__init__(message) self.message = message self.errors = errors class SignalException(BciPyCoreException): - """ - Signal Exception. + """Exception raised for errors in the signal processing module. + + This exception is raised when the signal module encounters errors during + processing or analysis of signal data. - Thrown when signal module encounters an error. + Args: + message: A descriptive message explaining the signal processing error. + errors: Optional additional error information. """ - def __init__(self, message, errors=None): + def __init__(self, message: str, errors: Any = None) -> None: super().__init__(message) self.errors = errors class FieldException(BciPyCoreException): - """Field Exception. + """Exception raised for errors related to experimental fields. - Thrown when there is an exception relating to experimental fields. + This exception is raised when there are issues with field definitions, + validation, or processing in experiments. + + Args: + message: A descriptive message explaining the field-related error. + errors: Optional additional error information. """ ... class ExperimentException(BciPyCoreException): - """Experiment Exception. + """Exception raised for errors related to experiment execution. + + This exception is raised when there are issues with experiment setup, + execution, or validation. - Thrown when there is an exception relating to experiments. + Args: + message: A descriptive message explaining the experiment-related error. + errors: Optional additional error information. """ ... class UnregisteredExperimentException(ExperimentException): - """Unregistered Experiment. + """Exception raised when attempting to use an unregistered experiment. - Thrown when experiment is not registered in the provided experiment path. - """ + This exception is raised when trying to access or execute an experiment + that has not been registered in the provided experiment path. + Args: + message: A descriptive message explaining which experiment was not found. + errors: Optional additional error information. + """ ... class UnregisteredFieldException(FieldException): - """Unregistered Field. + """Exception raised when attempting to use an unregistered field. - Thrown when field is not registered in the provided field path. - """ + This exception is raised when trying to access a field that has not been + registered in the provided field path. + Args: + message: A descriptive message explaining which field was not found. + errors: Optional additional error information. + """ ... class InvalidExperimentException(ExperimentException): - """Invalid Experiment Exception. + """Exception raised when experiment data is in an invalid format. - Thrown when providing experiment data in the incorrect format. - """ + This exception is raised when experiment configuration or data does not + meet the required format specifications. + Args: + message: A descriptive message explaining the format error. + errors: Optional additional error information. + """ ... class InvalidFieldException(FieldException): - """Invalid Field Exception. + """Exception raised when field data is in an invalid format. - Thrown when providing field data in the incorrect format. - """ + This exception is raised when field configuration or data does not + meet the required format specifications. + Args: + message: A descriptive message explaining the format error. + errors: Optional additional error information. + """ ... class TaskConfigurationException(BciPyCoreException): - """Task Configuration Exception. + """Exception raised when task configuration is invalid. + + This exception is raised when attempting to run a task with invalid + or incompatible configuration settings. - Thrown when attempting to run a task with invalid configurations""" + Args: + message: A descriptive message explaining the configuration error. + errors: Optional additional error information. + """ ... class KenLMInstallationException(BciPyCoreException): - """KenLM Installation Exception. + """Exception raised when KenLM module is not properly installed. - Thrown when attempting to import kenlm without installing the module""" + This exception is raised when attempting to use KenLM functionality + without having the required module installed. + + Args: + message: A descriptive message explaining the installation issue. + errors: Optional additional error information. + """ ... class InvalidSymbolSetException(BciPyCoreException): - """Invalid Symbol Set Exception. + """Exception raised when symbol set is not properly configured. + + This exception is raised when attempting to query a language model for + predictions without properly configuring the symbol set. - Thrown when querying a language model for predictions without setting the symbol set.""" + Args: + message: A descriptive message explaining the symbol set error. + errors: Optional additional error information. + """ ... class LanguageModelNameInUseException(BciPyCoreException): - """Language Model Name In Use Exception. + """Exception raised when attempting to register a duplicate language model. + + This exception is raised when trying to register a language model type + with a name that is already in use. - Thrown when attempting to register a language model type with a duplicate name.""" + Args: + message: A descriptive message explaining the naming conflict. + errors: Optional additional error information. + """ ... diff --git a/bcipy/feedback/README.md b/bcipy/feedback/README.md new file mode 100644 index 000000000..59b2761a8 --- /dev/null +++ b/bcipy/feedback/README.md @@ -0,0 +1,134 @@ +# BciPy Feedback Module + +The feedback module provides a flexible framework for implementing real-time feedback mechanisms in BCI experiments. It supports both visual and auditory feedback, allowing researchers to create customized feedback paradigms for their specific needs. + +## Overview + +Feedback in BCI systems is crucial for providing users with information about their brain activity in real-time. This module implements a robust feedback system that can be easily extended and customized for different experimental paradigms. + +## Core Components + +### Base Classes + +- `Feedback`: Abstract base class that defines the interface for all feedback mechanisms + - Provides common functionality for feedback administration + - Defines abstract methods that must be implemented by subclasses + - Manages feedback type registration and logging + +### Feedback Types + +The module supports two main types of feedback: + +1. **Visual Feedback** (`VisualFeedback`) + - Displays text or image stimuli on screen + - Supports customizable positioning, timing, and appearance + - Provides precise timing control for stimulus presentation + - Features: + - Text and image stimulus support + - Configurable font, size, and color + - Position control + - Timing synchronization + +2. **Auditory Feedback** (`AuditoryFeedback`) + - Plays sound stimuli through the system's audio output + - Supports various audio formats and sampling rates + - Provides timing control for audio presentation + - Features: + - Sound playback + - Configurable audio parameters + - Timing synchronization + +## Usage Examples + +### Visual Feedback + +```python +from bcipy.feedback.visual.visual_feedback import VisualFeedback +from psychopy import visual +from bcipy.helpers.clock import Clock + +# Initialize display window +window = visual.Window(size=[800, 600]) + +# Configure parameters +parameters = { + 'feedback_font': 'Arial', + 'feedback_stim_height': 0.1, + 'feedback_pos_x': 0, + 'feedback_pos_y': 0, + 'feedback_duration': 1.0, + 'feedback_color': 'white' +} + +# Create feedback instance +clock = Clock() +feedback = VisualFeedback(window, parameters, clock) + +# Administer feedback +timing = feedback.administer("Hello World", StimuliType.TEXT) +``` + +### Auditory Feedback + +```python +from bcipy.feedback.sound.auditory_feedback import AuditoryFeedback +from psychopy import core +import numpy as np + +# Configure parameters +parameters = { + 'sound_buffer_time': 1.0 +} + +# Create feedback instance +clock = core.Clock() +feedback = AuditoryFeedback(parameters, clock) + +# Generate a simple tone +fs = 44100 # sampling frequency +t = np.linspace(0, 1, fs) # 1 second duration +sound = np.sin(2 * np.pi * 440 * t) # 440 Hz sine wave + +# Administer feedback +timing = feedback.administer(sound, fs) +``` + +## Configuration + +Both feedback types can be configured through a parameters dictionary. Common parameters include: + +- Timing parameters (duration, intervals) +- Display parameters (position, size, color) +- Stimulus-specific parameters (font, audio format) + +## Timing Control + +The feedback module provides precise timing control through: + +- Clock synchronization +- Timestamp recording +- Buffer time management + +## Extending the Module + +To create a new feedback type: + +1. Create a new class inheriting from `Feedback` +2. Implement the required abstract methods: + - `configure()` + - `administer()` +3. Register the new feedback type in `FeedbackType` enum + +## Best Practices + +1. Always use the provided timing mechanisms for synchronization +2. Handle exceptions appropriately in feedback administration +3. Clean up resources after feedback presentation +4. Use appropriate buffer times for smooth presentation +5. Test feedback timing in your specific experimental setup + +## References + +- PsychoPy documentation for visual stimulus presentation +- SoundDevice documentation for audio playback +- BciPy documentation for integration with other modules diff --git a/bcipy/feedback/feedback.py b/bcipy/feedback/feedback.py index e7976a1d3..e21795fb6 100644 --- a/bcipy/feedback/feedback.py +++ b/bcipy/feedback/feedback.py @@ -1,26 +1,98 @@ +# mypy: disable-error-code=override +"""Feedback module. + +This module provides the base feedback functionality for BciPy, including abstract +classes and utilities for creating and managing different types of feedback +mechanisms (sound, visual, etc.). +""" + import logging +from abc import ABC, abstractmethod +from enum import Enum +from typing import Any, List from bcipy.config import SESSION_LOG_FILENAME -REGISTERED_FEEDBACK_TYPES = ['sound', 'visual'] +class FeedbackType(Enum): + """Enumeration of feedback types supported by BciPy (Visual, Audio).""" + + VIS = 'Visual' + AUD = 'Audio' + + @classmethod + def list(cls) -> List[str]: + """Return a list of all available feedback types. + + Returns: + List[str]: List of feedback type values + """ + return [feedback_type.value for feedback_type in cls] + + +class StimuliType(Enum): + """Enumeration of stimuli types supported by BciPy (Text, Image).""" + + TEXT = 'Text' + IMAGE = 'Image' + + @classmethod + def list(cls) -> List[str]: + """Return a list of all available stimuli types. -class Feedback: - """Feedback.""" + Returns: + List[str]: List of stimuli type values + """ + return [stimuli_type.value for stimuli_type in cls] - def __init__(self, feedback_type): + +class Feedback(ABC): + """Abstract base class for feedback mechanisms. + + This class defines the interface for different types of feedback in BciPy, + such as sound and visual feedback. It provides methods for configuration + and administration of feedback. + + Attributes: + feedback_type (str): Type of feedback (e.g., 'sound', 'visual'). + logger (logging.Logger): Logger instance for feedback-related events. + """ + + def __init__(self, feedback_type: FeedbackType) -> None: + """Initialize Feedback. + + Args: + feedback_type (str): Type of feedback to be administered. + """ super(Feedback, self).__init__() self.feedback_type = feedback_type self.logger = logging.getLogger(SESSION_LOG_FILENAME) - def configure(self): - raise NotImplementedError() + @abstractmethod + def administer(self, *args: Any, **kwargs: Any) -> None: + """Administer feedback. + + This method should be implemented by subclasses to deliver the actual + feedback to the user. - def administer(self, *args, **kwargs): - raise NotImplementedError() + Args: + *args: Variable length argument list. + **kwargs: Arbitrary keyword arguments. + """ + ... - def _type(self): + def _type(self) -> FeedbackType: + """Get the feedback type. + + Returns: + str: The type of feedback being administered. + """ return self.feedback_type - def _available_modes(self): - return REGISTERED_FEEDBACK_TYPES + def _available_modes(self) -> List[str]: + """Get available feedback modes. + + Returns: + List[str]: List of registered feedback types. + """ + return FeedbackType.list() diff --git a/bcipy/feedback/sound/auditory_feedback.py b/bcipy/feedback/sound/auditory_feedback.py index 2b2a7b609..1d74fd404 100644 --- a/bcipy/feedback/sound/auditory_feedback.py +++ b/bcipy/feedback/sound/auditory_feedback.py @@ -1,16 +1,41 @@ +# mypy: disable-error-code=override +"""Auditory feedback module. + +This module provides auditory feedback functionality for BciPy, implementing +sound-based feedback mechanisms using sounddevice for audio playback. +""" + +from typing import Any, Dict, List, Optional, Union + import sounddevice as sd from psychopy import core -from bcipy.feedback.feedback import Feedback +from bcipy.feedback.feedback import Feedback, FeedbackType class AuditoryFeedback(Feedback): - """Auditory Feedback.""" + """Auditory feedback implementation. + + This class provides sound-based feedback functionality, allowing for + the playback of audio stimuli with precise timing control. - def __init__(self, parameters, clock): + Attributes: + feedback_type (FeedbackType): Type of feedback (AUD). + parameters (Dict[str, Any]): Configuration parameters for feedback. + sound_buffer_time (float): Buffer time for sound playback. + feedback_timestamp_label (str): Label for feedback timing. + clock (core.Clock): Clock for timing control. + """ + def __init__(self, parameters: Dict[str, Any], clock: core.Clock) -> None: + """Initialize Auditory Feedback. + + Args: + parameters (Dict[str, Any]): Configuration parameters for feedback. + clock (core.Clock): Clock instance for timing control. + """ # Register Feedback Type - self.feedback_type = 'Auditory Feedback' + self.feedback_type = FeedbackType.AUD super(AuditoryFeedback, self).__init__(self.feedback_type) @@ -23,7 +48,22 @@ def __init__(self, parameters, clock): # Clock self.clock = clock - def administer(self, sound, fs, assertion=None): + def administer(self, sound: Union[List[float], List[List[float]]], fs: int, + assertion: Optional[Any] = None) -> List[List[Union[str, float]]]: + """Administer auditory feedback. + + Plays the provided sound and records the timing of the feedback. + + Args: + sound (Union[List[float], List[List[float]]]): Sound data to play. + fs (int): Sampling frequency of the sound. + assertion (Optional[Any]): Optional assertion to check before playing. + Currently not used. + + Returns: + List[List[Union[str, float]]]: List containing timing information + in the format [[label, timestamp]]. + """ timing = [] if assertion: diff --git a/bcipy/feedback/tests/sound/test_sound_feedback.py b/bcipy/feedback/tests/sound/test_sound_feedback.py index 5cc418c1d..f4a6bd2b0 100644 --- a/bcipy/feedback/tests/sound/test_sound_feedback.py +++ b/bcipy/feedback/tests/sound/test_sound_feedback.py @@ -30,7 +30,7 @@ def tearDown(self): def test_feedback_type(self): feedback_type = self.auditory_feedback._type() - self.assertEqual(feedback_type, 'Auditory Feedback') + self.assertEqual(feedback_type.value, 'Audio') def test_feedback_administer_sound(self): timestamp = 100 @@ -39,7 +39,8 @@ def test_feedback_administer_sound(self): self.sound, self.fs) self.assertTrue(isinstance(resp, list)) - self.assertEqual(resp[0], [self.auditory_feedback.feedback_timestamp_label, timestamp]) + self.assertEqual( + resp[0], [self.auditory_feedback.feedback_timestamp_label, timestamp]) if __name__ == '__main__': diff --git a/bcipy/feedback/tests/visual/test_visual_feedback.py b/bcipy/feedback/tests/visual/test_visual_feedback.py index 390896ee4..bd7b1de1f 100644 --- a/bcipy/feedback/tests/visual/test_visual_feedback.py +++ b/bcipy/feedback/tests/visual/test_visual_feedback.py @@ -4,7 +4,7 @@ from mockito import (any, mock, unstub, verify, verifyNoUnwantedInteractions, verifyStubbedInvocationsAreUsed, when) -from bcipy.feedback.visual.visual_feedback import FeedbackType, VisualFeedback +from bcipy.feedback.visual.visual_feedback import StimuliType, VisualFeedback from bcipy.helpers.clock import Clock @@ -39,7 +39,7 @@ def tearDown(self): def test_feedback_type(self): feedback_type = self.visual_feedback._type() - self.assertEqual(feedback_type, 'Visual Feedback') + self.assertEqual(feedback_type.value, 'Visual') def test_construct_stimulus_image(self): image_mock = mock() @@ -52,13 +52,14 @@ def test_construct_stimulus_image(self): ori=any() ).thenReturn(image_mock) # mock the resize behavior for the image - when(self.visual_feedback)._resize_image(any(), any(), any()).thenReturn() + when(self.visual_feedback)._resize_image( + any(), any(), any()).thenReturn() response = self.visual_feedback._construct_stimulus( 'test_stim.png', (0, 0), None, - FeedbackType.IMAGE, + StimuliType.IMAGE, ) self.assertEqual(response, image_mock) @@ -78,7 +79,7 @@ def test_construct_stimulus_text(self): stimulus, (0, 0), None, - FeedbackType.TEXT, + StimuliType.TEXT, ) self.assertEqual(response, text_mock) @@ -88,7 +89,8 @@ def test_show_stimuli(self): when(stimuli_mock).draw().thenReturn(None) when(self.display).flip().thenReturn(None) - response = self.visual_feedback._show_stimuli(stimuli_mock) # TODO assertion + response = self.visual_feedback._show_stimuli( + stimuli_mock) # TODO assertion verify(stimuli_mock, times=1).draw() verify(self.display, times=1).flip() @@ -100,10 +102,12 @@ def test_administer_default(self): stimulus, self.visual_feedback.pos_stim, self.visual_feedback.color, - FeedbackType.TEXT + StimuliType.TEXT ).thenReturn(stimulus) - when(self.visual_feedback)._show_stimuli(stimulus).thenReturn(timestamp) - when(psychopy.core).wait(self.visual_feedback.feedback_length).thenReturn() + when(self.visual_feedback)._show_stimuli( + stimulus).thenReturn(timestamp) + when(psychopy.core).wait( + self.visual_feedback.feedback_length).thenReturn() response = self.visual_feedback.administer(stimulus) expected = [timestamp] self.assertEqual(response, expected) diff --git a/bcipy/feedback/visual/visual_feedback.py b/bcipy/feedback/visual/visual_feedback.py index 0986e8396..ad27fae0c 100644 --- a/bcipy/feedback/visual/visual_feedback.py +++ b/bcipy/feedback/visual/visual_feedback.py @@ -1,26 +1,47 @@ -# mypy: disable-error-code="return-value" -from enum import Enum -from typing import List, Tuple, Union +# mypy: disable-error-code="override,return-value" +"""Visual feedback module. + +This module provides visual feedback functionality for BciPy, implementing +visual-based feedback mechanisms using PsychoPy for stimulus presentation. +""" +from typing import Any, Dict, List, Tuple, Union from psychopy import core, visual from bcipy.core.stimuli import resize_image -from bcipy.feedback.feedback import Feedback +from bcipy.feedback.feedback import Feedback, FeedbackType, StimuliType from bcipy.helpers.clock import Clock -class FeedbackType(Enum): - TEXT = 'TEXT' - IMAGE = 'IMAGE' - - class VisualFeedback(Feedback): - """Visual Feedback.""" - - def __init__(self, display: visual.Window, parameters: dict, clock: Clock) -> None: - + """Visual feedback implementation. + + This class provides visual feedback functionality, allowing for the presentation + of text and image stimuli with precise timing control. + + Attributes: + feedback_type (FeedbackType): Type of feedback (VIS). + display (visual.Window): PsychoPy window for display. + parameters (Dict[str, Any]): Configuration parameters for feedback. + font_stim (str): Font to use for text stimuli. + height_stim (int): Height of stimuli. + pos_stim (Tuple[float, float]): Position for stimuli presentation. + feedback_length (float): Duration of feedback presentation. + color (str): Color of the feedback stimulus. + clock (Clock): Clock for timing control. + feedback_timestamp_label (str): Label for feedback timing. + """ + + def __init__(self, display: visual.Window, parameters: Dict[str, Any], clock: Clock) -> None: + """Initialize Visual Feedback. + + Args: + display (visual.Window): PsychoPy window for display. + parameters (Dict[str, Any]): Configuration parameters for feedback. + clock (Clock): Clock instance for timing control. + """ # Register Feedback Type - self.feedback_type = 'Visual Feedback' + self.feedback_type = FeedbackType.VIS super(VisualFeedback, self).__init__(self.feedback_type) @@ -47,13 +68,23 @@ def __init__(self, display: visual.Window, parameters: dict, clock: Clock) -> No def administer( self, stimulus: str, - stimuli_type=FeedbackType.TEXT) -> List[Tuple[str, float]]: - """Administer. + stimuli_type: StimuliType = StimuliType.TEXT) -> List[Tuple[str, float]]: + """Administer visual feedback. - Administer visual feedback. Timing information from parameters, - current feedback given by stimulus. - """ + Presents visual feedback stimulus and records timing information. + + Args: + stimulus (str): The stimulus to present (text or image path). + stimuli_type (StimuliType, optional): Type of stimulus to present. + Defaults to StimuliType.TEXT. + + Returns: + List[Tuple[str, float]]: List containing timing information + in the format [(label, timestamp)]. + Raises: + ValueError: If an unsupported stimulus type is provided. + """ stim = self._construct_stimulus( stimulus, self.pos_stim, @@ -61,14 +92,21 @@ def administer( stimuli_type) time = self._show_stimuli(stim) - core.wait(self.feedback_length) return [time] def _show_stimuli(self, stimulus: Union[visual.TargetStim, visual.ImageStim]) -> Tuple[str, float]: + """Show the stimulus and record timing. + + Args: + stimulus (Union[visual.TargetStim, visual.ImageStim]): The stimulus to present. + + Returns: + Tuple[str, float]: Timing information in the format (label, timestamp). + """ stimulus.draw() - time = [self.feedback_timestamp_label, self.clock.getTime()] # TODO: use callback for better precision + time = [self.feedback_timestamp_label, self.clock.getTime()] self.display.flip() return time @@ -77,6 +115,16 @@ def _resize_image( stimulus: str, display_size: Tuple[float, float], stimuli_height: int) -> Tuple[float, float]: + """Resize an image stimulus to fit the display. + + Args: + stimulus (str): Path to the image file. + display_size (Tuple[float, float]): Size of the display window. + stimuli_height (int): Desired height of the stimulus. + + Returns: + Tuple[float, float]: New size of the image (width, height). + """ return resize_image( stimulus, display_size, stimuli_height) @@ -85,8 +133,24 @@ def _construct_stimulus( stimulus: str, pos: Tuple[float, float], fill_color: str, - stimuli_type: FeedbackType) -> Union[visual.TargetStim, visual.ImageStim]: - if stimuli_type == FeedbackType.IMAGE: + stimuli_type: StimuliType) -> Union[visual.TargetStim, visual.ImageStim]: + """Construct a visual stimulus. + + Creates either a text or image stimulus based on the provided type. + + Args: + stimulus (str): The stimulus content (text or image path). + pos (Tuple[float, float]): Position for the stimulus. + fill_color (str): Color for text stimuli. + stimuli_type (StimuliType): Type of stimulus to create. + + Returns: + Union[visual.TargetStim, visual.ImageStim]: The created stimulus object. + + Raises: + ValueError: If an unsupported stimulus type is provided. + """ + if stimuli_type == StimuliType.IMAGE: image_stim = visual.ImageStim( win=self.display, image=stimulus, @@ -96,7 +160,7 @@ def _construct_stimulus( image_stim.size = self._resize_image( stimulus, self.display.size, self.height_stim) return image_stim - if stimuli_type == FeedbackType.TEXT: + if stimuli_type == StimuliType.TEXT: return visual.TextStim( win=self.display, font=self.font_stim, @@ -104,3 +168,5 @@ def _construct_stimulus( height=self.height_stim, pos=pos, color=fill_color) + raise ValueError( + f'VisualFeedback asked to create a stimulus type=[{stimuli_type}] that is not supported.') diff --git a/bcipy/gui/BCInterface.py b/bcipy/gui/BCInterface.py index 509157a43..ca097fcd9 100644 --- a/bcipy/gui/BCInterface.py +++ b/bcipy/gui/BCInterface.py @@ -1,7 +1,14 @@ +"""BCInterface module. + +This module provides the main graphical user interface for BciPy experiments, +including task execution, parameter management, and offline analysis capabilities. +""" + import logging import subprocess import sys -from typing import List +from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Optional, Tuple from bcipy.config import (BCIPY_ROOT, DEFAULT_PARAMETERS_PATH, PROTOCOL_LOG_FILENAME, STATIC_IMAGES_PATH) @@ -16,113 +23,191 @@ logger = logging.getLogger(PROTOCOL_LOG_FILENAME) +@dataclass +class UIConfig: + """Configuration for UI elements. + + Attributes: + padding (int): Padding value for UI elements. + btn_height (int): Height of buttons. + btn_width (int): Width of buttons. + font (str): Font family for UI elements. + static_font_size (int): Font size for static text. + """ + padding: int = 20 + btn_height: int = 40 + btn_width: int = 100 + font: str = 'Courier New' + static_font_size: int = 16 + + +@dataclass +class UserConfig: + """Configuration for user-related settings. + + Attributes: + max_length (int): Maximum length for user IDs. + min_length (int): Minimum length for user IDs. + default_text (str): Default text for input fields. + """ + max_length: int = 25 + min_length: int = 1 + default_text: str = '...' + + class BCInterface(BCIGui): """BCI Interface. - Main interface for execution of BciPy experiments and tasks. Additionally, quick access to parameter - editing and loading, and offline analysis execution. + Main interface for execution of BciPy experiments and tasks. Provides quick access to parameter + editing and loading, and offline analysis execution. + + Attributes: + tasks (List[str]): List of available tasks from TaskRegistry. + ui_config (UIConfig): UI configuration settings. + user_config (UserConfig): User-related configuration settings. + parameter_location (str): Path to parameters file. + parameters (Dict[str, Any]): Loaded parameters. + user_input (Optional[Any]): User input field. + experiment_input (Optional[Any]): Experiment input field. + task_input (Optional[Any]): Task input field. + user (Optional[str]): Selected user. + experiment (Optional[str]): Selected experiment. + task (Optional[str]): Selected task. + users (List[str]): List of available users. + disable (bool): Flag to prevent double-clicking. + task_start_timeout (int): Timeout for task start. + button_timeout (int): Timeout for button actions. + autoclose (bool): Whether to auto-close after task completion. + alert (bool): Whether to show alerts. + user_id_validations (List[Tuple[Callable[[str], bool], str]]): User ID validation rules. """ tasks = TaskRegistry().list() + ui_config = UIConfig() + user_config = UserConfig() - default_text = '...' - padding = 20 - btn_height = 40 - btn_width = 100 - max_length = 25 - min_length = 1 - timeout = 3 - font = 'Courier New' + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize the BCInterface. - def __init__(self, *args, **kwargs): + Args: + *args: Positional arguments passed to BCIGui. + **kwargs: Keyword arguments passed to BCIGui. + """ super(BCInterface, self).__init__(*args, **kwargs) self.parameter_location = DEFAULT_PARAMETERS_PATH + self.parameters = load_json_parameters( + self.parameter_location, value_cast=True) - self.parameters = load_json_parameters(self.parameter_location, - value_cast=True) - - # These are set in the build_inputs and represent text inputs from the user + # Input fields self.user_input = None self.experiment_input = None self.task_input = None - # These represent the current user, experiment, and task selected in the gui + # Selected values self.user = None self.experiment = None self.task = None + self.users: List[str] = [] - # user names available in the dropdown menu - self.users = [] - - # setup a timer to prevent double clicking in gui + # UI state self.disable = False self.timer.timeout.connect(self._disable_action) - - self.task_start_timeout = self.timeout - self.button_timeout = self.timeout - + self.task_start_timeout = 3 + self.button_timeout = 3 self.autoclose = False self.alert = True - self.static_font_size = 16 - self.user_id_validations = [ - (invalid_length(min=self.min_length, max=self.max_length), - f'User ID must contain between {self.min_length} and {self.max_length} alphanumeric characters.'), + # Initialize user ID validations + self.user_id_validations = self._init_user_validations() + + # Load tasks + self.tasks = TaskRegistry().list() + if not self.tasks: + logger.warning("No tasks found in TaskRegistry") + + def _init_user_validations(self) -> List[Tuple[Callable[[str], bool], str]]: + """Initialize user ID validation rules. + + Returns: + List[Tuple[Callable[[str], bool], str]]: List of validation rules and error messages. + """ + return [ + (invalid_length(min=self.user_config.min_length, max=self.user_config.max_length), + f'User ID must contain between {self.user_config.min_length} and {self.user_config.max_length} alphanumeric characters.'), (contains_whitespaces, 'User ID cannot contain white spaces'), (contains_special_characters, 'User ID cannot contain special characters') ] def build_buttons(self) -> None: - """Build Buttons. - - Build all buttons necessary for the UI. Define their action on click using the named argument action. - """ - + """Build all buttons for the UI.""" + self._build_action_buttons() + self._build_start_button() + self._build_create_experiment_button() + + def _build_action_buttons(self) -> None: + """Build the action buttons (Load, Edit, Train).""" + # Load button self.add_button( - message='Load', position=[self.padding, 450], - size=[self.btn_width, self.btn_height], background_color='Plum', + message='Load', + position=[self.ui_config.padding, 450], + size=[self.ui_config.btn_width, self.ui_config.btn_height], + background_color='Plum', text_color='black', - font_family=self.font, + font_family=self.ui_config.font, action=self.select_parameters) + # Edit button self.add_button( - message='Edit', position=[self.padding + self.btn_width + 10, 450], - size=[self.btn_width, self.btn_height], background_color='LightCoral', + message='Edit', + position=[self.ui_config.padding + + self.ui_config.btn_width + 10, 450], + size=[self.ui_config.btn_width, self.ui_config.btn_height], + background_color='LightCoral', text_color='black', - font_family=self.font, + font_family=self.ui_config.font, action=self.edit_parameters) - btn_auc_x = self.padding + (self.btn_width * 2) + 20 + # Train button + btn_auc_x = self.ui_config.padding + \ + (self.ui_config.btn_width * 2) + 20 self.add_button( - message='Train', position=(btn_auc_x, 450), - size=(self.btn_width, self.btn_height), background_color='LightSeaGreen', + message='Train', + position=(btn_auc_x, 450), + size=(self.ui_config.btn_width, self.ui_config.btn_height), + background_color='LightSeaGreen', text_color='black', - font_family=self.font, + font_family=self.ui_config.font, action=self.offline_analysis) - btn_start_width = self.btn_width * 2 + 10 - btn_start_x = self.width - (self.padding + btn_start_width) + def _build_start_button(self) -> None: + """Build the Start Session button.""" + btn_start_width = self.ui_config.btn_width * 2 + 10 + btn_start_x = self.width - (self.ui_config.padding + btn_start_width) self.add_button( - message='Start Session', position=[btn_start_x, 440], - size=[btn_start_width, self.btn_height + 10], + message='Start Session', + position=[btn_start_x, 440], + size=[btn_start_width, self.ui_config.btn_height + 10], background_color='green', action=self.start_experiment, text_color='white', - font_family=self.font) + font_family=self.ui_config.font) + def _build_create_experiment_button(self) -> None: + """Build the Create Experiment button.""" self.add_button( message='+', - position=[self.width - self.padding - 200, 260], - size=[35, self.btn_height - 10], + position=[self.width - self.ui_config.padding - 200, 260], + size=[35, self.ui_config.btn_height - 10], background_color='green', action=self.create_experiment, text_color='white' ) def create_experiment(self) -> None: - """Create Experiment. + """Launch the experiment registry. - Launch the experiment registry which will be used to add new experiments for selection in the GUI. + Opens the experiment registry interface for adding new experiments + to the GUI selection. """ if not self.action_disabled(): subprocess.call( @@ -131,339 +216,384 @@ def create_experiment(self) -> None: self.update_experiment_list() - def update_user_list(self, refresh=True) -> None: - """Updates the user_input combo box with a list of user ids based on the - data directory configured in the current parameters.""" - # if refresh is True, then we need to clear the list and add the default text - if refresh: - self.user_input.clear() - self.user_input.addItem(BCInterface.default_text) - - # load the users from the data directory and check if they have already been added to the dropdown - users = load_users(self.parameters['data_save_loc']) - for user in users: - if user not in self.users: - self.user_input.addItem(user) - self.users.append(user) - - def update_experiment_list(self) -> None: - """Updates the experiment_input combo box with a list of experiments based on the - data directory configured in the current parameters.""" - - self.experiment_input.clear() - self.experiment_input.addItem(BCInterface.default_text) - self.experiment_input.addItems(self.load_experiments()) - - def update_task_list(self) -> None: - """Updates the task_input combo box with a list of tasks ids based on what - is available in the Task Registry""" - - self.task_input.clear() - self.task_input.addItem(BCInterface.default_text) - self.task_input.addItems(self.tasks) - def build_inputs(self) -> None: - """Build Inputs. - - Build all inputs needed for BCInterface. - """ + """Build all input fields for the interface.""" input_x = 170 input_y = 160 - self.user_input = self.add_combobox( - position=[input_x, input_y], - size=[280, 25], - items=[], - editable=True, - background_color='white', - text_color='black') + # User input + self.user_input = self._build_combobox( + position=[input_x, input_y], + editable=True) self.update_user_list() + # Experiment input input_y += 100 - self.experiment_input = self.add_combobox( + self.experiment_input = self._build_combobox( position=[input_x, input_y], - size=[280, 25], - items=[], - editable=False, - background_color='white', - text_color='black') - + editable=False) self.update_experiment_list() + # Task input input_y += 100 - self.task_input = self.add_combobox( + self.task_input = self._build_combobox( position=[input_x, input_y], + editable=False) + self.update_task_list() + + def _build_combobox(self, position: List[int], editable: bool) -> Any: + """Build a combo box with standard styling. + + Args: + position (List[int]): Position coordinates [x, y]. + editable (bool): Whether the combo box is editable. + + Returns: + Any: The created combo box. + """ + combo = self.add_combobox( + position=position, size=[280, 25], - items=[], - editable=False, + items=[self.user_config.default_text], + editable=editable, background_color='white', text_color='black') - - self.update_task_list() + return combo def build_text(self) -> None: - """Build Text. - - Build all static text needed for the UI. - Positions are relative to the height / width of the UI defined in start_app. - """ + """Build all static text elements for the interface.""" + # Title self.add_static_textbox( text='BCInterface', position=[210, 0], size=[250, 50], background_color='black', text_color='white', - font_family=self.font, + font_family=self.ui_config.font, font_size=30) + # Labels text_x = 145 - self.add_static_textbox( - text='User', - position=[text_x, 105], - size=[200, 50], - background_color='black', - text_color='white', - font_family=self.font, - font_size=self.static_font_size) - self.add_static_textbox( - text='Experiment', - position=[text_x, 205], - size=[300, 50], - background_color='black', - font_family=self.font, - text_color='white', - font_size=self.static_font_size) - self.add_static_textbox( - text='Task', - position=[text_x, 305], - size=[300, 50], - background_color='black', - text_color='white', - font_family=self.font, - font_size=self.static_font_size) + labels = [ + ('User', 105), + ('Experiment', 205), + ('Task', 305) + ] - def build_images(self) -> None: - """Build Images. + for text, y_pos in labels: + self.add_static_textbox( + text=text, + position=[text_x, y_pos], + size=[300, 50], + background_color='black', + text_color='white', + font_family=self.ui_config.font, + font_size=self.ui_config.static_font_size) - Build add images needed for the UI. In this case, the OHSU and NEU logos. - """ + def build_images(self) -> None: + """Build all image elements for the interface.""" + # OHSU logo self.add_image( - path=f'{STATIC_IMAGES_PATH}/gui/ohsu.png', position=[self.padding, 0], size=100) + path=f'{STATIC_IMAGES_PATH}/gui/ohsu.png', + position=[self.ui_config.padding, 0], + size=100) + + # NEU logo self.add_image( - path=f'{STATIC_IMAGES_PATH}/gui/neu.png', position=[self.width - self.padding - 110, 0], size=100) + path=f'{STATIC_IMAGES_PATH}/gui/neu.png', + position=[self.width - self.ui_config.padding - 110, 0], + size=100) def build_assets(self) -> None: - """Build Assets. - - Define the assets to build in the UI. - """ + """Build all UI assets.""" self.build_buttons() self.build_inputs() self.build_text() self.build_images() + def update_user_list(self, refresh: bool = True) -> None: + """Update the user input combo box with available users. + + Args: + refresh (bool): Whether to clear the list before updating. + """ + if refresh and self.user_input: + self.user_input.clear() + self.user_input.addItem(self.user_config.default_text) + + users = load_users(self.parameters['data_save_loc']) + for user in users: + if user not in self.users and self.user_input: + self.user_input.addItem(user) + self.users.append(user) + + def update_experiment_list(self) -> None: + """Update the experiment input combo box with available experiments.""" + if self.experiment_input: + self.experiment_input.clear() + self.experiment_input.addItem(self.user_config.default_text) + experiments = self.load_experiments() + if experiments: + self.experiment_input.addItems(experiments) + + def update_task_list(self) -> None: + """Update the task input combo box with available tasks.""" + if self.task_input: + self.task_input.clear() + self.task_input.addItem(self.user_config.default_text) + if self.tasks: + self.task_input.addItems(self.tasks) + def set_parameter_location(self, path: str) -> None: - """Sets the parameter_location to the given path. Reloads the parameters - and updates any GUI widgets that are populated based on these params.""" + """Set the parameter file location and update the interface. + + Args: + path (str): Path to the parameters file. + """ self.parameter_location = path - self.parameters = load_json_parameters(self.parameter_location, - value_cast=True) + self.parameters = load_json_parameters( + self.parameter_location, value_cast=True) self.update_user_list(refresh=False) def select_parameters(self) -> None: - """Select Parameters. + """Open a dialog to select the parameters configuration file.""" + response = self.get_filename_dialog( + message='Select parameters file', + file_type='JSON files (*.json)') + + if not response: + return + + self.set_parameter_location(response) + self._handle_outdated_parameters() + + def _handle_outdated_parameters(self) -> None: + """Handle outdated parameter files by prompting for updates.""" + default_parameters = load_json_parameters( + DEFAULT_PARAMETERS_PATH, value_cast=True) + if not self.parameters.add_missing_items(default_parameters): + return + + save_response = self.throw_alert_message( + title='BciPy Alert', + message='The selected parameters file is out of date. ' + 'Would you like to update it with the latest options?', + message_type=AlertMessageType.INFO, + message_response=AlertMessageResponse.OCE) + + if save_response == AlertResponse.OK.value: + self.parameters.save() - Opens a dialog to select the parameters.json configuration to use. - """ + def edit_parameters(self) -> None: + """Open the parameter editor.""" + if self.action_disabled(): + return - response = self.get_filename_dialog(message='Select parameters file', - file_type='JSON files (*.json)') - if response: - self.set_parameter_location(response) - # If outdated, prompt to merge with the current defaults - default_parameters = load_json_parameters(DEFAULT_PARAMETERS_PATH, - value_cast=True) - if self.parameters.add_missing_items(default_parameters): - save_response = self.throw_alert_message( - title='BciPy Alert', - message='The selected parameters file is out of date.' - 'Would you like to update it with the latest options?', - message_type=AlertMessageType.INFO, - message_response=AlertMessageResponse.OCE) + if self.parameter_location == DEFAULT_PARAMETERS_PATH: + if not self._handle_default_parameters(): + return - if save_response == AlertResponse.OK.value: - self.parameters.save() + self._launch_parameter_editor() - def edit_parameters(self) -> None: - """Edit Parameters. + def _handle_default_parameters(self) -> bool: + """Handle editing of default parameters. - Prompts for a parameters.json file to use. If the default parameters are selected, a copy is used. - Note that any edits to the parameters file will not be applied to this GUI until the parameters - are reloaded. + Returns: + bool: True if should continue with editing, False otherwise. """ - if not self.action_disabled(): - if self.parameter_location == DEFAULT_PARAMETERS_PATH: - # Don't allow the user to overwrite the defaults - response = self.throw_alert_message( - title='BciPy Alert', - message='The default parameters.json cannot be overridden. A copy will be used.', - message_type=AlertMessageType.INFO, - message_response=AlertMessageResponse.OCE) - - if response == AlertResponse.OK.value: - self.parameter_location = copy_parameters() - else: - return None + response = self.throw_alert_message( + title='BciPy Alert', + message='The default parameters.json cannot be overridden. A copy will be used.', + message_type=AlertMessageType.INFO, + message_response=AlertMessageResponse.OCE) + + if response == AlertResponse.OK.value: + self.parameter_location = copy_parameters() + return True + return False - output = subprocess.check_output( - f'bcipy-params -p "{self.parameter_location}"', - shell=True) - if output: - self.parameter_location = output.decode().strip() + def _launch_parameter_editor(self) -> None: + """Launch the parameter editor process.""" + output = subprocess.check_output( + f'bcipy-params -p "{self.parameter_location}"', + shell=True) + if output: + self.parameter_location = output.decode().strip() def check_input(self) -> bool: - """Check Input. + """Check if all required input fields are valid. - Checks to make sure user has input all required fields. + Returns: + bool: True if all inputs are valid, False otherwise. """ + self._update_current_selections() - # Update based on current inputs - self.user = self.user_input.currentText() - self.experiment = self.experiment_input.currentText() - self.task = self.task_input.currentText() - - # Check the set values are different than defaults try: if not self.check_user_id(): return False - if self.experiment == BCInterface.default_text and self.task == BCInterface.default_text: - self.throw_alert_message( - title='BciPy Alert', - message='Please select an Experiment or Task for execution', - message_type=AlertMessageType.INFO, - message_response=AlertMessageResponse.OTE) - return False - if self.experiment != BCInterface.default_text and self.task != BCInterface.default_text: - self.throw_alert_message( - title='BciPy Alert', - message='Please select only an Experiment or Task', - message_type=AlertMessageType.INFO, - message_response=AlertMessageResponse.OTE) + if not self._validate_experiment_task_selection(): return False + except Exception as e: + self._show_error_alert(str(e)) + return False + + return True + + def _update_current_selections(self) -> None: + """Update current selections from input fields.""" + if self.user_input: + self.user = self.user_input.currentText() + if self.experiment_input: + self.experiment = self.experiment_input.currentText() + if self.task_input: + self.task = self.task_input.currentText() + + def _validate_experiment_task_selection(self) -> bool: + """Validate experiment and task selections. + + Returns: + bool: True if selections are valid, False otherwise. + """ + if self.experiment == self.user_config.default_text and self.task == self.user_config.default_text: self.throw_alert_message( title='BciPy Alert', - message=f'Error, {e}', - message_type=AlertMessageType.CRIT, + message='Please select an Experiment or Task for execution', + message_type=AlertMessageType.INFO, message_response=AlertMessageResponse.OTE) return False + + if self.experiment != self.user_config.default_text and self.task != self.user_config.default_text: + self.throw_alert_message( + title='BciPy Alert', + message='Please select only an Experiment or Task', + message_type=AlertMessageType.INFO, + message_response=AlertMessageResponse.OTE) + return False + return True - def check_user_id(self) -> bool: - """Check User ID + def _show_error_alert(self, error_message: str) -> None: + """Show an error alert message. - User ID must meet the following requirements: + Args: + error_message (str): The error message to display. + """ + self.throw_alert_message( + title='BciPy Alert', + message=f'Error, {error_message}', + message_type=AlertMessageType.CRIT, + message_response=AlertMessageResponse.OTE) - 1. Maximum length of self.max_length alphanumeric characters - 2. Minimum length of at least self.min_length alphanumeric character - 3. No special characters - 4. No spaces + def check_user_id(self) -> bool: + """Validate the user ID against defined requirements. + + Returns: + bool: True if user ID is valid, False otherwise. """ - # Check the user id set is different than the default text - if self.user == BCInterface.default_text: + if not self.user or self.user == self.user_config.default_text: self.throw_alert_message( title='BciPy Alert', message='Please input a User ID', message_type=AlertMessageType.INFO, message_response=AlertMessageResponse.OTE) return False - # Loop over defined user validations and check for error conditions - for validator in self.user_id_validations: - (invalid, error_message) = validator - if invalid(self.user): + + for validator, error_message in self.user_id_validations: + if validator(str(self.user)): # Ensure user is treated as string self.throw_alert_message( title='BciPy Alert', message=error_message, message_type=AlertMessageType.INFO, - message_response=AlertMessageResponse.OTE - ) + message_response=AlertMessageResponse.OTE) return False + return True def load_experiments(self) -> List[str]: - """Load experiments + """Load available experiments from the default experiment path. - Loads experiments registered in the DEFAULT_EXPERIMENT_PATH. + Returns: + List[str]: List of experiment names. """ return load_experiments().keys() def start_experiment(self) -> None: - """Start Experiment Session. + """Start an experiment session.""" + if not (self.check_input() and not self.action_disabled()): + return + + self._show_starting_alert() + cmd = self._build_experiment_command() + self._execute_experiment(cmd) + + def _show_starting_alert(self) -> None: + """Show the task starting alert.""" + self.throw_alert_message( + title='BciPy Alert', + message='Task Starting ...', + message_type=AlertMessageType.INFO, + message_response=AlertMessageResponse.OTE, + message_timeout=self.task_start_timeout) + + def _build_experiment_command(self) -> str: + """Build the experiment command. + + Returns: + str: The command to execute. + """ + if self.task != self.user_config.default_text: + cmd = f'bcipy -u "{self.user}" -t "{self.task}" -p "{self.parameter_location}"' + else: + cmd = f'bcipy -u "{self.user}" -e "{self.experiment}" -p "{self.parameter_location}"' + + if self.alert: + cmd += ' -a' + + return cmd + + def _execute_experiment(self, cmd: str) -> None: + """Execute the experiment command. - Using the inputs gathers, check for validity using the check_input method, then launch the experiment using a - command to bcipy main and subprocess. + Args: + cmd (str): The command to execute. """ - if self.check_input() and not self.action_disabled(): + output = subprocess.run(cmd, shell=True) + if output.returncode != 0: self.throw_alert_message( title='BciPy Alert', - message='Task Starting ...', - message_type=AlertMessageType.INFO, - message_response=AlertMessageResponse.OTE, - message_timeout=self.task_start_timeout) - if self.task != BCInterface.default_text: - cmd = ( - f'bcipy ' - f'-u "{self.user}" -t "{self.task}" -p "{self.parameter_location}"' - ) - else: - cmd = ( - f'bcipy ' - f'-u "{self.user}" -e "{self.experiment}" -p "{self.parameter_location}"' - ) - if self.alert: - cmd += ' -a' - output = subprocess.run(cmd, shell=True) - if output.returncode != 0: - self.throw_alert_message( - title='BciPy Alert', - message=f'Error: {output.stderr.decode()}', - message_type=AlertMessageType.CRIT, - message_response=AlertMessageResponse.OTE) + message=f'Error: {output.stderr.decode()}', + message_type=AlertMessageType.CRIT, + message_response=AlertMessageResponse.OTE) - if self.autoclose: - self.close() + if self.autoclose: + self.close() def offline_analysis(self) -> None: - """Offline Analysis. - - Run offline analysis as a script in a new process. - """ + """Run offline analysis in a new process.""" if not self.action_disabled(): cmd = f'bcipy-train --alert --p "{self.parameter_location}" -v -s' subprocess.Popen(cmd, shell=True) def action_disabled(self) -> bool: - """Action Disabled. - - Method to check whether another action can take place. If not disabled, it will allow the action and - start a timer that will disable actions until self.timeout (seconds) has occured. + """Check if actions are currently disabled. - Note: the timer is registed with the private method self._disable_action, which when self.timeout has - been reached, resets self.disable and corresponding timeouts. + Returns: + bool: True if actions are disabled, False otherwise. """ if self.disable: return True - else: - self.disable = True - # set the update time to every 500ms - self.timer.start(500) - return False + + self.disable = True + self.timer.start(500) + return False def _disable_action(self) -> bool: - """Disable Action. + """Handle action disabling timer. - A private method to register with a BCIGui.timer after setting self.button_timeout. + Returns: + bool: Current disabled state. """ if self.button_timeout > 0: self.disable = True @@ -472,12 +602,12 @@ def _disable_action(self) -> bool: self.timer.stop() self.disable = False - self.button_timeout = self.timeout + self.button_timeout = 3 return self.disable def start_app() -> None: - """Start BCIGui.""" + """Start the BCI interface application.""" bcipy_gui = app(sys.argv) ex = BCInterface( title='Brain Computer Interface', diff --git a/bcipy/gui/README.md b/bcipy/gui/README.md index bb05a1b32..063e79aff 100644 --- a/bcipy/gui/README.md +++ b/bcipy/gui/README.md @@ -1,33 +1,169 @@ -# RSVP Keyboard GUI -====================================== +# BciPy GUI Module -This module contains all GUI code used in BciPy. The base window class, BCIGui, is contained in gui_main.py, and contains methods for easily adding widgets to a given window. BCInterface.py launches the main GUI window. There are also interfaces for collecting and editing data (parameters and field data for experiments.) +The GUI module provides the graphical user interface components for BciPy, enabling users to interact with the BCI system through a user-friendly interface. This module is essential for running experiments, managing parameters, and controlling the BCI workflow. -## Dependencies -------------- -This project was written in wxPython version 4.0.4 and PyQt5 5.15.1. We are deprecating the wxPython UIs in future releases. +## Overview -## Project structure ---------------- -Name | Description -------------- | ------------- -BCInterface.py | Defines main GUI window. Selection of user, experiment and task. -gui_main.py | BCIGui containing methods for adding buttons, images, etc. to GUI window -parameters/params_form.py | Defines window for setting BCInterface parameters -experiments/ExperimentRegistry.py | GUI for creating new experiments to select in BCInterface. -experiments/FieldRegistry.py | GUI for creating new fields for experiment data collection. -experiments/ExperimentField.py | GUI for collecting a registered experiment's field data. +The GUI module consists of several key components: +1. **Main Interface (`BCInterface.py`)** + - Primary interface for running BCI experiments + - User management and experiment selection + - Parameter configuration and task execution + - Offline analysis capabilities -The folder 'bcipy/static/images/gui' contains images for the GUI. -Parameters loaded by BCInterface parameter definition form can be found in 'bcipy/parameters/parameters.json'. +2. **Base UI Components (`bciui.py`)** + - Core UI building blocks and utilities + - Common functionality for all BciPy interfaces + - Layout management and styling + - Dynamic list and item management -To run the GUI, do so from the root, as follows: +3. **Experiment Management** + - `ExperimentRegistry.py`: Interface for registering and managing experiments + - `ExperimentField.py`: Form for collecting experiment-specific data + - Field management and validation -`python bcipy/gui/BCInterface.py` +4. **Alert System (`alert.py`)** + - User notifications and confirmations + - Error handling and system messages -Contributors: +5. **Task Transitions (`intertask_gui.py`)** + - Progress tracking between tasks + - Experiment flow control + - User feedback during transitions -- Tab Memmott -- Matthew Lawhead -- Dani Smektala +## Getting Started + +### Running the GUI + +To start the BciPy GUI: + +```bash +python bcipy/gui/BCInterface.py +``` + +Or using Make (if installed): + +```bash +make bci-gui +``` + +### Basic Usage + +1. **User Management** + - Enter or select a user ID + - User IDs must be alphanumeric and meet length requirements + +2. **Experiment Selection** + - Choose between running a specific task or a complete experiment + - Tasks are individual BCI operations (e.g., calibration) + - Experiments are predefined sequences of tasks + +3. **Parameter Configuration** + - Load existing parameter files + - Edit parameters through the parameter editor + - Save custom configurations + +4. **Task Execution** + - Start sessions with selected configurations + - Monitor progress through the intertask interface + - View results and system feedback + +5. **Signal Viewer** + - Monitor real-time EEG signals during experiments + - View and analyze recorded data from previous sessions + - Toggle channel visibility and apply montages + - Control display duration and filtering options + - Pause/resume signal visualization + - Support for multiple monitor configurations + - See [Signal Viewer Documentation](gui/viewer/README.md) for more details + +## Key Features + +### Experiment Registry + +- Create and manage experiment protocols +- Define task sequences and parameters +- Configure experiment-specific fields +- Save and load experiment configurations + +### Parameter Management + +- Load default or custom parameter files +- Edit parameters through a user-friendly interface +- Save parameter configurations +- Validate parameter settings + +### Task Control + +- Start and stop BCI tasks +- Monitor task progress +- Handle transitions between tasks +- Manage experiment flow + +### User Interface + +- Clean, intuitive design +- Consistent styling across components +- Responsive feedback +- Error handling and notifications + +## Development + +### Adding New Components + +When extending the GUI: + +1. Inherit from `BCIUI` for new interfaces +2. Use the provided UI utilities and components +3. Follow the established styling guidelines +4. Implement proper error handling +5. Add appropriate documentation + +### Styling + +The GUI uses a consistent styling system: + +- CSS-based styling through `bcipy_stylesheet.css` +- Common UI elements and layouts +- Responsive design principles +- Accessibility considerations + +## Best Practices + +1. **Error Handling** + - Use the alert system for user notifications + - Validate inputs before processing + - Provide clear error messages + - Handle edge cases gracefully + +2. **User Experience** + - Maintain consistent interface behavior + - Provide clear feedback for actions + - Use appropriate timeouts and delays + - Implement proper state management + +3. **Performance** + - Minimize UI blocking operations + - Use appropriate threading for long operations + - Optimize resource usage + - Handle cleanup properly + +## Troubleshooting + +Common issues and solutions: + +1. **GUI Not Starting** + - Check Python and dependency versions + - Verify file permissions + - Check for conflicting processes + +2. **Parameter Issues** + - Validate parameter file format + - Check file paths and permissions + - Verify parameter values + +3. **Task Execution Problems** + - Check system requirements + - Verify device connections + - Review error logs diff --git a/bcipy/gui/alert.py b/bcipy/gui/alert.py index bc79a167b..23e1b8837 100644 --- a/bcipy/gui/alert.py +++ b/bcipy/gui/alert.py @@ -1,6 +1,13 @@ -"""GUI alert messages""" +"""GUI alert messages module. + +This module provides functionality for displaying alert messages and confirmation dialogs +in the BciPy GUI interface. It includes functions for user interaction through +standard dialog boxes with customizable options. +""" + # pylint: disable=no-name-in-module import sys +from typing import Optional from PyQt6.QtWidgets import QApplication @@ -11,12 +18,19 @@ def confirm(message: str) -> bool: """Confirmation dialog which allows the user to select between a true and false. - Parameters - ---------- - message - alert to display - Returns - ------- - users selection : True for selecting Ok, False for Cancel. + This function displays a dialog box with OK and Cancel buttons, allowing the user + to confirm or cancel an action. The dialog is displayed using the system's native + dialog style. + + Args: + message (str): The alert message to display in the dialog box. + + Returns: + bool: True if the user clicked OK, False if the user clicked Cancel. + + Note: + This function creates a new QApplication instance if one doesn't exist, + and quits it after the dialog is closed. """ app = QApplication(sys.argv).instance() if not app: diff --git a/bcipy/gui/bciui.py b/bcipy/gui/bciui.py index fca09e1d3..c13fca87d 100644 --- a/bcipy/gui/bciui.py +++ b/bcipy/gui/bciui.py @@ -1,5 +1,12 @@ +"""BCIUI module. + +This module provides the base UI components and utilities for building BciPy's +graphical user interfaces. It includes base classes for UI elements, dynamic +lists, and utility functions for common UI operations. +""" + import sys -from typing import Callable, List, Optional, Type +from typing import Any, Callable, Dict, List, Optional, Type from PyQt6.QtCore import pyqtSignal from PyQt6.QtWidgets import (QApplication, QHBoxLayout, QLayout, QMessageBox, @@ -10,29 +17,55 @@ class BCIUI(QWidget): + """Base class for BciPy user interfaces. + + This class provides common functionality for all BciPy UI components, + including layout management, styling, and utility methods. + + Attributes: + contents (QVBoxLayout): Main vertical layout container. + center_content_vertically (bool): Whether to center content vertically. + """ + contents: QVBoxLayout center_content_vertically: bool = False def __init__(self, title: str = "BCIUI", default_width: int = 500, default_height: int = 600) -> None: + """Initialize the BCIUI base class. + + Args: + title (str): Window title. Defaults to "BCIUI". + default_width (int): Default window width. Defaults to 500. + default_height (int): Default window height. Defaults to 600. + """ super().__init__() self.resize(default_width, default_height) self.setWindowTitle(title) self.contents = QVBoxLayout() self.setLayout(self.contents) - def app(self): + def app(self) -> None: + """Initialize the application UI. + + This method should be overridden by subclasses to set up their specific UI elements. + """ ... def apply_stylesheet(self) -> None: + """Apply the BciPy stylesheet to the UI. + + Loads and applies the CSS stylesheet from the BciPy configuration. + """ stylesheet_path = f'{BCIPY_ROOT}/gui/bcipy_stylesheet.css' # TODO: move to config with open(stylesheet_path, "r") as f: stylesheet = f.read() self.setStyleSheet(stylesheet) def display(self) -> None: - # Push contents to the top of the window - """ - Display the UI window and apply the stylesheet. + """Display the UI window and apply the stylesheet. + + Initializes the UI, applies vertical centering if configured, + and shows the window. """ self.app() if not self.center_content_vertically: @@ -41,12 +74,13 @@ def display(self) -> None: self.show() def show_alert(self, alert_text: str) -> int: - """ - Shows an alert dialog with the specified text. + """Show an alert dialog with the specified text. + + Args: + alert_text (str): Text to display in the alert dialog. - PARAMETERS - ---------- - :param: alert_text: string text to display in the alert dialog. + Returns: + int: The result code from the message box. """ msg = QMessageBox() msg.setText(alert_text) @@ -55,6 +89,14 @@ def show_alert(self, alert_text: str) -> int: @staticmethod def centered(widget: QWidget) -> QHBoxLayout: + """Create a centered horizontal layout for a widget. + + Args: + widget (QWidget): Widget to center. + + Returns: + QHBoxLayout: Layout with the widget centered horizontally. + """ layout = QHBoxLayout() layout.addStretch() layout.addWidget(widget) @@ -63,6 +105,14 @@ def centered(widget: QWidget) -> QHBoxLayout: @staticmethod def make_list_scroll_area(widget: QWidget) -> QScrollArea: + """Create a scrollable area for a widget. + + Args: + widget (QWidget): Widget to make scrollable. + + Returns: + QScrollArea: Scrollable area containing the widget. + """ scroll_area = QScrollArea() scroll_area.setWidget(widget) scroll_area.setWidgetResizable(True) @@ -72,28 +122,25 @@ def make_list_scroll_area(widget: QWidget) -> QScrollArea: def make_toggle( on_button: QPushButton, off_button: QPushButton, - on_action: Optional[Callable] = lambda: None, - off_action: Optional[Callable] = lambda: None, + on_action: Callable = lambda: None, + off_action: Callable = lambda: None, ) -> None: - """ - Connects two buttons to toggle between eachother and call passed methods - - PARAMETERS - ---------- - :param: on_button: QPushButton to toggle on - :param: off_button: QPushButton to toggle off - :param: on_action: function to call when on_button is clicked - :param: off_action: function to call when off_button is clicked + """Connect two buttons to toggle between each other and call passed methods. + Args: + on_button (QPushButton): Button to toggle on. + off_button (QPushButton): Button to toggle off. + on_action Callable: Function to call when on_button is clicked. + off_action Callable: Function to call when off_button is clicked. """ off_button.hide() - def toggle_off(): + def toggle_off() -> None: on_button.hide() off_button.show() off_action() - def toggle_on(): + def toggle_on() -> None: on_button.show() off_button.hide() on_action() @@ -102,127 +149,176 @@ def toggle_on(): off_button.clicked.connect(toggle_on) def hide(self) -> None: - """Close the UI window""" + """Hide the UI window.""" self.hide() class SmallButton(QPushButton): - """A small button with a fixed size""" + """A small button with a fixed size. - def __init__(self, *args, **kwargs): + This button is styled with a specific CSS class and fixed size policy. + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize the small button. + + Args: + *args: Positional arguments passed to QPushButton. + **kwargs: Keyword arguments passed to QPushButton. + """ super().__init__(*args, **kwargs) self.setProperty("class", "small-button") self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) class DynamicItem(QWidget): - """A widget that can be dynamically added and removed from the ui""" + """A widget that can be dynamically added and removed from the UI. + + This widget emits a signal when removed and can store arbitrary data. + + Attributes: + on_remove (pyqtSignal): Signal emitted when the item is removed. + data (Dict[str, Any]): Dictionary for storing arbitrary data. + """ on_remove: pyqtSignal = pyqtSignal() - data: dict = {} + data: Dict[str, Any] = {} def remove(self) -> None: - """Remove the widget from it's parent DynamicList, removing it from the UI and deleting it""" + """Remove the widget from its parent DynamicList. + + Emits the on_remove signal and triggers widget deletion. + """ self.on_remove.emit() class DynamicList(QWidget): - """A list of QWidgets that can be dynamically updated""" + """A list of QWidgets that can be dynamically updated. + + This widget manages a list of DynamicItems that can be added, removed, + and reordered. + + Attributes: + widgets (List[QWidget]): List of managed widgets. + """ widgets: List[QWidget] - def __init__(self, layout: Optional[QLayout] = None): + def __init__(self, layout: Optional[QLayout] = None) -> None: + """Initialize the dynamic list. + + Args: + layout (Optional[QLayout]): Layout to use. Defaults to QVBoxLayout. + """ super().__init__() if layout is None: layout = QVBoxLayout() self.setLayout(layout) self.widgets = [] - def __len__(self): + def __len__(self) -> int: + """Get the number of widgets in the list. + + Returns: + int: Number of widgets. + """ return len(self.widgets) def add_item(self, item: DynamicItem) -> None: - """ - Add a DynamicItem to the list. + """Add a DynamicItem to the list. - PARAMETERS - ---------- - :param: item: DynamicItem to add to the list. + Args: + item (DynamicItem): Item to add to the list. """ self.widgets.append(item) item.on_remove.connect(lambda: self.remove_item(item)) - self.layout().addWidget(item) + layout = self.layout() + if layout: + layout.addWidget(item) def move_item(self, item: DynamicItem, new_index: int) -> None: - """ - Move a DynamicItem to a new index in the list. + """Move a DynamicItem to a new index in the list. - PARAMETERS - ---------- - :param: item: A reference to the DynamicItem in the list to be moved. - :param: new_index: int new index to move the item to. + Args: + item (DynamicItem): Item to move. + new_index (int): New index for the item. + + Raises: + IndexError: If new_index is out of range. """ if new_index < 0 or new_index >= len(self): raise IndexError(f"Index out of range for length {len(self)}") self.widgets.pop(self.widgets.index(item)) self.widgets.insert(new_index, item) - self.layout().removeWidget(item) - self.layout().insertWidget(new_index, item) + layout = self.layout() + if layout: + layout.removeWidget(item) + layout.insertWidget(new_index, item) def index(self, item: DynamicItem) -> int: - """ - Get the index of a DynamicItem in the list. + """Get the index of a DynamicItem in the list. - PARAMETERS - ---------- - :param: item: A reference to the DynamicItem in the list to get the index of. + Args: + item (DynamicItem): Item to find the index of. - Returns - ------- - The index of the item in the list. + Returns: + int: Index of the item in the list. """ return self.widgets.index(item) def remove_item(self, item: DynamicItem) -> None: - """ - Remove a DynamicItem from the list. + """Remove a DynamicItem from the list. - PARAMETERS - ---------- - :param: item: A reference to the DynamicItem to remove from the list + Args: + item (DynamicItem): Item to remove. """ self.widgets.remove(item) - self.layout().removeWidget(item) + layout = self.layout() + if layout: + layout.removeWidget(item) item.deleteLater() def clear(self) -> None: - """Remove all items from the list""" + """Remove all items from the list.""" for widget in self.widgets: - self.layout().removeWidget(widget) + layout = self.layout() + if layout: + layout.removeWidget(widget) widget.deleteLater() self.widgets = [] - def list(self): - return [widget.data for widget in self.widgets] + def list(self) -> List[Dict[str, Any]]: + """Get a list of data dictionaries from all items. - def list_property(self, prop: str): + Returns: + List[Dict[str, Any]]: List of data dictionaries. """ - Get a list of values for a given property of each DynamicItem's data dictionary. + return [widget.data for widget in self.widgets] + + def list_property(self, prop: str) -> List[Any]: + """Get a list of values for a given property of each DynamicItem's data dictionary. - PARAMETERS - ---------- - :param: prop: string property name to get the values of. + Args: + prop (str): Property name to get values for. - Returns - ------- - A list of values for the given property. + Returns: + List[Any]: List of values for the given property. """ return [widget.data[prop] for widget in self.widgets] -def run_bciui(ui: Type[BCIUI], *args, **kwargs): - # add app to kwargs +def run_bciui(ui: Type[BCIUI], *args: Any, **kwargs: Any) -> int: + """Run a BCIUI instance. + + Args: + ui (Type[BCIUI]): BCIUI class to instantiate. + *args: Positional arguments for the UI class. + **kwargs: Keyword arguments for the UI class. + + Returns: + int: Application exit code. + """ app = QApplication(sys.argv).instance() if not app: app = QApplication(sys.argv) diff --git a/bcipy/gui/experiments/ExperimentField.py b/bcipy/gui/experiments/ExperimentField.py index fbf8772b5..7cde236b0 100644 --- a/bcipy/gui/experiments/ExperimentField.py +++ b/bcipy/gui/experiments/ExperimentField.py @@ -188,7 +188,8 @@ def build_save_data(self) -> None: ) def write_save_data(self) -> None: - save_experiment_field_data(self.save_data, self.save_path, self.file_name) + save_experiment_field_data( + self.save_data, self.save_path, self.file_name) self.throw_alert_message( title="Success", message=( @@ -219,7 +220,8 @@ def throw_alert_message( if message_response is AlertMessageResponse.OTE: msg.setStandardButtons(AlertResponse.OK.value) elif message_response is AlertMessageResponse.OCE: - msg.setStandardButtons(AlertResponse.OK.value | AlertResponse.CANCEL.value) + msg.setStandardButtons( + AlertResponse.OK.value | AlertResponse.CANCEL.value) return msg.exec() @@ -261,7 +263,8 @@ def initUI(self): vbox = QVBoxLayout() self.form_panel = QScrollArea() - self.form_panel.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn) + self.form_panel.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOn) self.form_panel.setHorizontalScrollBarPolicy( Qt.ScrollBarPolicy.ScrollBarAlwaysOff ) @@ -349,7 +352,8 @@ def start_app() -> None: ) args = parser.parse_args() - start_experiment_field_collection_gui(args.experiment, args.path, args.filename, args.validate) + start_experiment_field_collection_gui( + args.experiment, args.path, args.filename, args.validate) sys.exit() diff --git a/bcipy/gui/experiments/ExperimentRegistry.py b/bcipy/gui/experiments/ExperimentRegistry.py index 90c931510..99c122f1f 100644 --- a/bcipy/gui/experiments/ExperimentRegistry.py +++ b/bcipy/gui/experiments/ExperimentRegistry.py @@ -201,7 +201,8 @@ def create_experiment(self): for field in fields ] task_names = self.protocol_contents.list_property("task_name") - task_objects = [self.task_registry.get(task_name) for task_name in task_names] + task_objects = [self.task_registry.get( + task_name) for task_name in task_names] protocol = serialize_protocol(task_objects) existing_experiments[experiment_name] = { @@ -264,7 +265,8 @@ def add_field(): def add_task(): self.protocol_contents.add_item( - self.make_task_entry(self.experiment_protocol_input.currentText()) + self.make_task_entry( + self.experiment_protocol_input.currentText()) ) self.experiment_protocol_input = QComboBox() @@ -305,7 +307,8 @@ def add_task(): protocol_scroll_area = QScrollArea() self.protocol_contents = DynamicList() - protocol_scroll_area = BCIUI.make_list_scroll_area(self.protocol_contents) + protocol_scroll_area = BCIUI.make_list_scroll_area( + self.protocol_contents) label = QLabel("Protocol") label.setStyleSheet("color: black;") scroll_area_layout.addWidget(protocol_scroll_area) diff --git a/bcipy/gui/file_dialog.py b/bcipy/gui/file_dialog.py index dd39055a4..6e71433db 100644 --- a/bcipy/gui/file_dialog.py +++ b/bcipy/gui/file_dialog.py @@ -1,44 +1,83 @@ +"""File dialog module. + +This module provides functionality for displaying file and directory selection +dialogs in the BciPy GUI interface. It includes classes and functions for handling +file and directory selection with customizable options and filters. +""" + # pylint: disable=no-name-in-module,missing-docstring,too-few-public-methods import sys from pathlib import Path -from typing import Union +from typing import Optional, Tuple, Union from PyQt6 import QtGui +from PyQt6.QtCore import QRect from PyQt6.QtWidgets import QApplication, QFileDialog, QWidget from bcipy.exceptions import BciPyCoreException from bcipy.preferences import preferences -DEFAULT_FILE_TYPES = "All Files (*)" +DEFAULT_FILE_TYPES: str = "All Files (*)" class FileDialog(QWidget): - """GUI window that prompts the user to select a file.""" + """GUI window that prompts the user to select a file or directory. - def __init__(self): + This class provides a file dialog interface for selecting files and directories + in the BciPy GUI. It supports both file and directory selection with customizable + options and filters. + + Attributes: + title (str): Window title. + window_width (int): Window width in pixels. + window_height (int): Window height in pixels. + options (QFileDialog.Option): Dialog options. + """ + + def __init__(self) -> None: + """Initialize the file dialog window. + + Sets up the window properties and centers it on the screen. + """ super().__init__() self.title = 'File Dialog' - self.width = 640 - self.height = 480 + self.window_width = 640 + self.window_height = 480 # Center on screen - self.resize(self.width, self.height) - frame_geom = self.frameGeometry() - frame_geom.moveCenter(QtGui.QGuiApplication.primaryScreen().availableGeometry().center()) - self.move(frame_geom.topLeft()) + self.resize(self.window_width, self.window_height) + self._center_window() # The native dialog may prevent the selection from closing after a # directory is selected. self.options = QFileDialog.Option.DontUseNativeDialog + def _center_window(self) -> None: + """Center the window on the primary screen. + + This method calculates the center position of the primary screen and + moves the window to that position. + """ + frame_geom = self.frameGeometry() + screen = QtGui.QGuiApplication.primaryScreen() + if screen: + center_point = screen.availableGeometry().center() + frame_geom.moveCenter(center_point) + self.move(frame_geom.topLeft()) + def ask_file(self, file_types: str = DEFAULT_FILE_TYPES, directory: str = "", prompt: str = "Select File") -> str: - """Opens a file dialog window. - Returns - ------- - path or None + """Open a file selection dialog window. + + Args: + file_types (str, optional): File type filters. Defaults to DEFAULT_FILE_TYPES. + directory (str, optional): Initial directory. Defaults to "". + prompt (str, optional): Dialog prompt message. Defaults to "Select File". + + Returns: + str: Selected file path or empty string if cancelled. """ filename, _ = QFileDialog.getOpenFileName(self, caption=prompt, @@ -48,11 +87,14 @@ def ask_file(self, return filename def ask_directory(self, directory: str = "", prompt: str = "Select Directory") -> str: - """Opens a dialog window to select a directory. + """Open a directory selection dialog window. + + Args: + directory (str, optional): Initial directory. Defaults to "". + prompt (str, optional): Dialog prompt message. Defaults to "Select Directory". - Returns - ------- - path or None + Returns: + str: Selected directory path or empty string if cancelled. """ return QFileDialog.getExistingDirectory(self, prompt, @@ -67,18 +109,23 @@ def ask_filename( strict: bool = False) -> Union[str, BciPyCoreException]: """Prompt for a file using a GUI. - Parameters - ---------- - - file_types : optional file type filters; Examples: 'Text files (*.txt)' - or 'Image files (*.jpg *.gif)' or '*.csv;;*.pkl' - - directory : optional directory - - prompt : optional prompt message to display to users - - strict : optional flag to raise an exception if the user cancels the dialog. Default is False. - If False, an empty string is returned. - - Returns - ------- - path to file or raises an exception if the user cancels the dialog. + This function creates a file selection dialog and handles the user's selection. + It can optionally raise an exception if no file is selected. + + Args: + file_types (str, optional): File type filters. Examples: 'Text files (*.txt)' + or 'Image files (*.jpg *.gif)' or '*.csv;;*.pkl'. Defaults to DEFAULT_FILE_TYPES. + directory (str, optional): Initial directory. Defaults to "". + prompt (str, optional): Dialog prompt message. Defaults to "Select File". + strict (bool, optional): If True, raises an exception when no file is selected. + If False, returns an empty string. Defaults to False. + + Returns: + Union[str, BciPyCoreException]: Selected file path or empty string if cancelled. + Raises BciPyCoreException if strict=True and no file is selected. + + Note: + Updates the last_directory preference if a file is selected. """ app = QApplication(sys.argv) dialog = FileDialog() @@ -89,10 +136,7 @@ def ask_filename( path = Path(filename) if filename and path.is_file(): preferences.last_directory = str(path.parent) - - # Alternatively, we could use `app.closeAllWindows()` app.quit() - return filename if strict: @@ -104,18 +148,22 @@ def ask_filename( def ask_directory(prompt: str = "Select Directory", strict: bool = False) -> Union[str, BciPyCoreException]: """Prompt for a directory using a GUI. - Parameters - ---------- - prompt : optional prompt message to display to users - strict : optional flag to raise an exception if the user cancels the dialog. Default is False. - If False, an empty string is returned. + This function creates a directory selection dialog and handles the user's selection. + It can optionally raise an exception if no directory is selected. + + Args: + prompt (str, optional): Dialog prompt message. Defaults to "Select Directory". + strict (bool, optional): If True, raises an exception when no directory is selected. + If False, returns an empty string. Defaults to False. + + Returns: + Union[str, BciPyCoreException]: Selected directory path or empty string if cancelled. + Raises BciPyCoreException if strict=True and no directory is selected. - Returns - ------- - path to directory or raises an exception if the user cancels the dialog. + Note: + Updates the last_directory preference if a directory is selected. """ app = QApplication(sys.argv) - dialog = FileDialog() directory = '' if preferences.last_directory: @@ -123,10 +171,7 @@ def ask_directory(prompt: str = "Select Directory", strict: bool = False) -> Uni name = dialog.ask_directory(directory, prompt=prompt) if name and Path(name).is_dir(): preferences.last_directory = name - - # Alternatively, we could use `app.closeAllWindows()` app.quit() - return name if strict: diff --git a/bcipy/gui/intertask_gui.py b/bcipy/gui/intertask_gui.py index 463ca551a..845338b09 100644 --- a/bcipy/gui/intertask_gui.py +++ b/bcipy/gui/intertask_gui.py @@ -1,3 +1,9 @@ +"""Intertask GUI module. + +This module provides a graphical user interface for managing task transitions +in BciPy experiments, showing progress and allowing users to control task flow. +""" + import logging from typing import Callable, List @@ -11,6 +17,20 @@ class IntertaskGUI(BCIUI): + """GUI for managing transitions between tasks in an experiment. + + This class provides a progress interface that shows the current task progress + and allows users to proceed to the next task or stop the experiment. + + Attributes: + action_name (str): Name of the action type. + tasks (List[str]): List of task names in the experiment. + current_task_index (int): Index of the current task. + next_task_name (str): Name of the next task to be executed. + total_tasks (int): Total number of tasks in the experiment. + task_progress (int): Current progress through the tasks. + callback (Callable): Function to call when stopping tasks. + """ action_name = "IntertaskAction" @@ -18,8 +38,15 @@ def __init__( self, next_task_index: int, tasks: List[str], - exit_callback: Callable, - ): + exit_callback: Callable[[], None], + ) -> None: + """Initialize the intertask GUI. + + Args: + next_task_index (int): Index of the next task to be executed. + tasks (List[str]): List of task names in the experiment. + exit_callback (Callable[[], None]): Function to call when stopping tasks. + """ self.tasks = tasks self.current_task_index = next_task_index self.next_task_name = tasks[self.current_task_index] @@ -29,7 +56,11 @@ def __init__( super().__init__("Progress", 800, 150) self.setProperty("class", "inter-task") - def app(self): + def app(self) -> None: + """Initialize and configure the GUI application. + + Sets up the progress display, next task information, and control buttons. + """ self.contents.addLayout(BCIUI.centered(QLabel("Experiment Progress"))) progress_container = QHBoxLayout() @@ -37,7 +68,8 @@ def app(self): QLabel(f"({self.task_progress}/{self.total_tasks})") ) self.progress = QProgressBar() - self.progress.setValue(int(self.task_progress / self.total_tasks * 100)) + self.progress.setValue( + int(self.task_progress / self.total_tasks * 100)) self.progress.setTextVisible(False) progress_container.addWidget(self.progress) self.contents.addLayout(progress_container) @@ -61,22 +93,36 @@ def app(self): self.next_button.clicked.connect(self.next) self.stop_button.clicked.connect(self.stop_tasks) - def stop_tasks(self): + def stop_tasks(self) -> None: + """Stop the current task execution. + + Calls the exit callback and quits the application. + """ # This should exit Task executions - logger.info(f"Stopping Tasks... user requested. Using callback: {self.callback}") + logger.info( + f"Stopping Tasks... user requested. Using callback: {self.callback}") self.callback() self.quit() logger.info("Tasks Stopped") - def next(self): + def next(self) -> None: + """Proceed to the next task. + + Logs the next task request and quits the application. + """ logger.info(f"Next Task=[{self.next_task_name}] requested") self.quit() - def quit(self): - QApplication.instance().quit() + def quit(self) -> None: + """Quit the application.""" + instance = QApplication.instance() + if instance: + instance.quit() if __name__ == "__main__": - tasks = ["RSVP Calibration", "IntertaskAction", "Matrix Calibration", "IntertaskAction"] + tasks = ["RSVP Calibration", "IntertaskAction", + "Matrix Calibration", "IntertaskAction"] - run_bciui(IntertaskGUI, tasks=tasks, next_task_index=1, exit_callback=lambda: print("Stopping orchestrator")) + run_bciui(IntertaskGUI, tasks=tasks, next_task_index=1, + exit_callback=lambda: print("Stopping orchestrator")) diff --git a/bcipy/gui/main.py b/bcipy/gui/main.py index 31e771475..3cdcfe7b1 100644 --- a/bcipy/gui/main.py +++ b/bcipy/gui/main.py @@ -1,3 +1,9 @@ +"""Main GUI module for BciPy. + +This module provides the core GUI components and utilities for the BciPy interface, +including form inputs, alerts, and window management. +""" + # pylint: disable=E0611 import logging import os @@ -5,9 +11,10 @@ import sys from decimal import Decimal from enum import Enum -from typing import Any, Callable, List, NamedTuple, Optional, Tuple, Union +from typing import (Any, Callable, List, NamedTuple, Optional, Tuple, Union, + cast) -from PyQt6.QtCore import Qt, QTimer, pyqtSlot +from PyQt6.QtCore import QObject, Qt, QTimer, pyqtSlot from PyQt6.QtGui import QFont, QPixmap, QShowEvent, QWheelEvent from PyQt6.QtWidgets import (QApplication, QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QHBoxLayout, QLabel, @@ -22,7 +29,7 @@ def font(size: int = 16, font_family: str = 'Helvetica') -> QFont: return QFont(font_family, size, weight=0) -def invalid_length(min=1, max=25) -> bool: +def invalid_length(min=1, max=25) -> Callable[[str], bool]: """Invalid Length. Returns a function, which when passed a string will assert whether a string meets min/max conditions. @@ -35,7 +42,7 @@ def contains_whitespaces(string: str) -> bool: Checks for the presence of whitespace in a string. """ - return re.match(r'^(?=.*[\s])', string) + return bool(re.match(r'^(?=.*[\s])', string)) def contains_special_characters(string: str, @@ -48,7 +55,7 @@ def contains_special_characters(string: str, return bool(disallowed_chars.search(string)) -def static_text_control(parent, +def static_text_control(parent: Optional[QWidget], label: str, color: str = 'black', size: int = 16, @@ -57,7 +64,7 @@ def static_text_control(parent, creating labels and help components.""" static_text = QLabel(parent) static_text.setWordWrap(True) - static_text.setText(label) + static_text.setText(str(label)) static_text.setStyleSheet(f'color: {color};') static_text.setFont(font(size, font_family)) return static_text @@ -90,12 +97,11 @@ class PushButton(QPushButton): Custom Button to store unique identifiers which are required for coordinating events across multiple buttons.""" - id = None + id: Optional[int] = None - def get_id(self): + def get_id(self) -> int: if not self.id: raise Exception('No ID set on PushButton') - return self.id @@ -116,7 +122,7 @@ def __init__(self, options: List[str], selected_value: str, **kwargs): self.setText(selected_value) self.setEditable(True) - def setText(self, value: str): + def setText(self, value: str) -> None: """Sets the current index to the given value. If the value is not in the list of options it will be added.""" if value not in self.options: @@ -125,7 +131,7 @@ def setText(self, value: str): self.addItems(self.options) self.setCurrentIndex(self.options.index(value)) - def text(self): + def text(self) -> str: """Gets the currentText.""" return self.currentText() @@ -136,21 +142,20 @@ class MessageBox(QMessageBox): A custom QMessageBox implementation to provide timeout functionality to QMessageBoxes. """ - def __init__(self, *args, **kwargs): - QMessageBox.__init__(self, *args, **kwargs) + def __init__(self, *args: Any, **kwargs: Any): + super().__init__(*args, **kwargs) + self.timeout = 0.0 + self.current = 0.0 - self.timeout = 0 - self.current = 0 - - def showEvent(self, event: QShowEvent) -> None: + def showEvent(self, event: Optional[QShowEvent]) -> None: """showEvent. If a timeout greater than zero is defined, set a QTimer to call self.close after the defined timeout. """ if self.timeout > 0: # timeout is in seconds (multiply by 1000 to get ms) - QTimer().singleShot(self.timeout * 1000, self.close) - super(MessageBox, self).showEvent(event) + QTimer().singleShot(int(self.timeout * 1000.0), self.close) + super().showEvent(event) def setTimeout(self, timeout: float) -> None: """setTimeout. @@ -228,7 +233,7 @@ def __init__(self, help_size: int = 12, help_color: str = 'darkgray', should_display: bool = True): - super(FormInput, self).__init__() + super().__init__() self.label = label self.help_tip = help_tip @@ -245,9 +250,9 @@ def __init__(self, if not should_display: self.hide() - def eventFilter(self, source, event): + def eventFilter(self, source: Optional[QObject], event: Any) -> bool: """Event filter that suppresses the scroll wheel event.""" - if (event.type() == QWheelEvent and source is self.control): + if (isinstance(event, QWheelEvent) and source is self.control): return True return False @@ -255,7 +260,7 @@ def init_label(self) -> QWidget: """Initialize the label widget.""" return static_text_control(None, label=self.label, size=16) - def init_help(self, font_size: int, color: str) -> QWidget: + def init_help(self, font_size: int, color: str) -> Optional[QWidget]: """Initialize the help text widget.""" if self.help_tip and self.label != self.help_tip: return static_text_control(None, @@ -264,14 +269,14 @@ def init_help(self, font_size: int, color: str) -> QWidget: color=color) return None - def init_control(self, value) -> QWidget: + def init_control(self, value: Any) -> QWidget: """Initialize the form control widget. Parameter: --------- value - initial value """ # Default is a text input - return QLineEdit(value) + return QLineEdit(str(value)) def init_editable(self, value: Optional[bool]) -> Optional[QWidget]: "Override. Another checkbox is needed for editable" @@ -282,7 +287,7 @@ def init_editable(self, value: Optional[bool]) -> Optional[QWidget]: editable_checkbox.setFont(font(size=12)) return editable_checkbox - def init_layout(self): + def init_layout(self) -> None: """Initialize the layout by adding the label, help, and control widgets.""" self.vbox = QVBoxLayout() if self.label_widget: @@ -296,7 +301,7 @@ def init_layout(self): self.vbox.addWidget(self.separator()) self.setLayout(self.vbox) - def separator(self): + def separator(self) -> QWidget: """Creates a separator line.""" line = QLabel() line.setFixedHeight(1) @@ -307,16 +312,20 @@ def value(self) -> str: """Returns the value associated with the form input.""" if self.control: return self.control.text() - return None + return "" def is_editable(self) -> bool: """Returns whether the input is editable.""" - return self.editable_widget.isChecked() + if self.editable_widget: + return self.editable_widget.isChecked() + return False @property def editable(self) -> bool: """Returns whether the input is editable.""" - return True if self.editable_widget.isChecked() else False + if self.editable_widget: + return self.editable_widget.isChecked() + return False def cast_value(self) -> Any: """Returns the value associated with the form input, cast to the correct type. @@ -332,20 +341,20 @@ def matches(self, term: str) -> bool: self.help_tip and text in self.help_tip.lower()) or text in self.value().lower() - def show(self): + def show(self) -> None: """Show this widget, and all child widgets.""" if self.should_display: for widget in self.widgets(): if widget: widget.setVisible(True) - def hide(self): + def hide(self) -> None: """Hide this widget, and all child widgets.""" for widget in self.widgets(): if widget: widget.setVisible(False) - def widgets(self) -> List[QWidget]: + def widgets(self) -> List[Optional[QWidget]]: """Returns a list of self and child widgets. List may contain None values.""" return [self.label_widget, self.help_tip_widget, self.control, self] @@ -360,20 +369,21 @@ class IntegerInput(FormInput): value - initial value. """ - def __init__(self, **kwargs): - super(IntegerInput, self).__init__(**kwargs) + def __init__(self, **kwargs: Any): + super().__init__(**kwargs) - def init_control(self, value): + def init_control(self, value: Any) -> QWidget: """Override FormInput to create a spinbox.""" spin_box = QSpinBox() spin_box.setMinimum(-100000) spin_box.setMaximum(100000) - spin_box.wheelEvent = lambda event: None # disable scroll wheel + # Disable scroll wheel by overriding the event handler + spin_box.wheelEvent = lambda e: None # type: ignore if value: spin_box.setValue(int(value)) return spin_box - def cast_value(self) -> str: + def cast_value(self) -> Optional[int]: """Override FormInput to return an integer value.""" if self.control: return int(self.control.text()) @@ -390,13 +400,14 @@ class FloatInputProperties(NamedTuple): step: float = 0.1 -def float_input_properties(value: str) -> FloatInputProperties: +def float_input_properties(value: float) -> FloatInputProperties: """Given a string representation of a float value, determine suitable properties for the float component used to input or update this value. """ # Determine from the component if there is a reasonable min or max constraint dec = Decimal(str(value)) _sign, _digits, exponent = dec.as_tuple() + exponent = int(exponent) if exponent > 0: return FloatInputProperties() return FloatInputProperties(decimals=abs(exponent), step=10**exponent) @@ -412,29 +423,30 @@ class FloatInput(FormInput): value - initial value. """ - def __init__(self, **kwargs): - super(FloatInput, self).__init__(**kwargs) + def __init__(self, **kwargs: Any): + super().__init__(**kwargs) - def init_control(self, value): + def init_control(self, value: Any) -> QWidget: """Override FormInput to create a spinbox.""" spin_box = QDoubleSpinBox() # Make a reasonable guess about precision and step size based on the initial value. - props = float_input_properties(value) + props = float_input_properties(float(value)) spin_box.setMinimum(props.min) spin_box.setMaximum(props.max) spin_box.setDecimals(props.decimals) spin_box.setSingleStep(props.step) spin_box.setValue(float(value)) - spin_box.wheelEvent = lambda event: None # disable scroll wheel + # Disable scroll wheel by overriding the event handler + spin_box.wheelEvent = lambda e: None # type: ignore return spin_box def cast_value(self) -> float: """Override FormInput to return as a float.""" if self.control: return float(self.control.text()) - return None + return 0.0 class BoolInput(FormInput): @@ -447,10 +459,10 @@ class BoolInput(FormInput): value - initial value. """ - def __init__(self, **kwargs): - super(BoolInput, self).__init__(**kwargs) + def __init__(self, **kwargs: Any): + super().__init__(**kwargs) - def init_control(self, value): + def init_control(self, value: Any) -> QWidget: """Override to create a checkbox.""" ctl = QCheckBox(f'Enable {self.label}') ctl.setChecked(value == 'true') @@ -468,7 +480,7 @@ class RangeInput(FormInput): from the starting value and list of recommended if provided. """ - def init_control(self, value) -> QWidget: + def init_control(self, value: Any) -> QWidget: """Initialize the form control widget. Parameter: @@ -491,14 +503,16 @@ class SelectionInput(FormInput): help_font_size - font size for the help text. help_color - color of the help text.""" - def __init__(self, **kwargs): + def __init__(self, **kwargs: Any): assert isinstance(kwargs['options'], list), f"options are required for {kwargs['label']}" - super(SelectionInput, self).__init__(**kwargs) + super().__init__(**kwargs) - def init_control(self, value) -> QWidget: + def init_control(self, value: Any) -> QWidget: """Override to create a Combobox.""" - return ComboBox(self.options, value) + if not self.options: + raise ValueError(f"options are required for {self.label}") + return ComboBox(self.options, str(value)) class TextInput(FormInput): @@ -512,8 +526,8 @@ class TextInput(FormInput): help_font_size - font size for the help text. help_color - color of the help text.""" - def __init__(self, **kwargs): - super(TextInput, self).__init__(**kwargs) + def __init__(self, **kwargs: Any): + super().__init__(**kwargs) class FileInput(FormInput): @@ -529,15 +543,15 @@ class FileInput(FormInput): help_color - color of the help text. """ - def __init__(self, **kwargs): - super(FileInput, self).__init__(**kwargs) + def __init__(self, **kwargs: Any): + super().__init__(**kwargs) - def init_control(self, value) -> QWidget: + def init_control(self, value: Any) -> QWidget: """Override to create either a selection list or text field depending on whether there are recommended values.""" if isinstance(self.options, list): - return ComboBox(self.options, value) - return QLineEdit(value) + return ComboBox(self.options, str(value)) + return QLineEdit(str(value)) def init_button(self) -> QWidget: """Creates a Button to initiate the file/directory dialog.""" @@ -577,7 +591,7 @@ def init_layout(self) -> None: self.vbox.addWidget(self.separator()) self.setLayout(self.vbox) - def widgets(self) -> List[QWidget]: + def widgets(self) -> List[Optional[QWidget]]: """Override to include button.""" return super().widgets() + [self.button] @@ -595,7 +609,7 @@ class DirectoryInput(FileInput): help_color - color of the help text. """ - def prompt_path(self): + def prompt_path(self) -> str: """Override to prompt for a directory.""" return QFileDialog.getExistingDirectory(caption='Select a path') @@ -614,7 +628,7 @@ def __init__(self, label_low: str = "Low:", label_high="High:", size=14): - super(RangeWidget, self).__init__() + super().__init__() self.low, self.high = parse_range(value) self.input_min = None @@ -660,7 +674,8 @@ def float_input(self, value: float) -> QWidget: spin_box.setDecimals(props.decimals) spin_box.setSingleStep(props.step) spin_box.setValue(value) - spin_box.wheelEvent = lambda event: None # disable scroll wheel + # Disable scroll wheel by overriding the event handler + spin_box.wheelEvent = lambda e: None # type: ignore return spin_box def int_input(self, value: int) -> QWidget: @@ -671,12 +686,13 @@ def int_input(self, value: int) -> QWidget: -100000 if self.input_min is None else self.input_min) spin_box.setMaximum( 100000 if self.input_max is None else self.input_max) - spin_box.wheelEvent = lambda event: None # disable scroll wheel + # Disable scroll wheel by overriding the event handler + spin_box.wheelEvent = lambda e: None # type: ignore if value: spin_box.setValue(value) return spin_box - def text(self): + def text(self) -> str: """Text value""" low = self.low_input.text() or self.low high = self.high_input.text() or self.high @@ -700,8 +716,8 @@ class SearchInput(QWidget): contents of the text box. """ - def __init__(self, on_search, font_size: int = 12): - super(SearchInput, self).__init__() + def __init__(self, on_search: Callable[[str], None], font_size: int = 12): + super().__init__() self.on_search = on_search @@ -737,21 +753,21 @@ class BCIGui(QWidget): def __init__(self, title: str, width: int, height: int, background_color: str): - super(BCIGui, self).__init__() + super().__init__() logging.basicConfig(level=logging.INFO, format='%(name)s - %(levelname)s - %(message)s') self.logger = logging - self.buttons = [] - self.input_text = [] - self.static_text = [] - self.images = [] - self.comboboxes = [] - self.widgets = [] + self.buttons: List[PushButton] = [] + self.input_text: List[QLineEdit] = [] + self.static_text: List[QLabel] = [] + self.images: List[QLabel] = [] + self.comboboxes: List[QComboBox] = [] + self.widgets: List[QWidget] = [] # set main window properties self.background_color = background_color - self.window = QWidget() + self._window = QWidget() self.vbox = QVBoxLayout() self.setStyleSheet(f'background-color: {self.background_color};') @@ -760,12 +776,12 @@ def __init__(self, title: str, width: int, height: int, self.title = title # determines height/width of window - self.width = width - self.height = height + self._width = width + self._height = height self.setWindowTitle(self.title) - self.setFixedWidth(self.width) - self.setFixedHeight(self.height) + self.setFixedWidth(self._width) + self.setFixedHeight(self._height) self.setLayout(self.vbox) self.create_main_window() @@ -776,9 +792,9 @@ def create_main_window(self) -> None: Construct the main window for display of assets. """ self.window_layout = QHBoxLayout() - self.window.setStyleSheet( + self._window.setStyleSheet( f'background-color: {self.background_color};') - self.window_layout.addWidget(self.window) + self.window_layout.addWidget(self._window) self.vbox.addLayout(self.window_layout) def add_widget(self, widget: QWidget) -> None: @@ -848,9 +864,11 @@ def default_button_clicked(self) -> None: The default action for buttons if none are registed. """ - sender = self.sender() - self.logger.debug(sender.text() + ' was pressed') - self.logger.debug(sender.get_id()) + sender = cast(QObject, self.sender()) + if sender: + self.logger.debug(sender.text() + ' was pressed') + if isinstance(sender, PushButton): + self.logger.debug(sender.get_id()) def add_button(self, message: str, @@ -862,7 +880,7 @@ def add_button(self, font_family: str = 'Times', action: Optional[Callable] = None) -> PushButton: """Add Button.""" - btn = PushButton(message, self.window) + btn = PushButton(message, self._window) btn.id = id btn.move(position[0], position[1]) btn.resize(size[0], size[1]) @@ -888,7 +906,7 @@ def add_combobox(self, editable=False) -> QComboBox: """Add combobox.""" - combobox = QComboBox(self.window) + combobox = QComboBox(self._window) combobox.move(position[0], position[1]) combobox.resize(size[0], size[1]) @@ -910,7 +928,7 @@ def add_combobox(self, def add_image(self, path: str, position: list, size: int) -> QLabel: """Add Image.""" if os.path.isfile(path): - labelImage = QLabel(self.window) + labelImage = QLabel(self._window) pixmap = QPixmap(path) # ensures the new label size will scale the image itself labelImage.setScaledContents(True) @@ -943,7 +961,7 @@ def add_static_textbox(self, wrap_text=False) -> QLabel: """Add Static Text.""" - static_text = QLabel(self.window) + static_text = QLabel(self._window) static_text.setText(text) if wrap_text: static_text.setWordWrap(True) @@ -961,7 +979,7 @@ def add_static_textbox(self, def add_text_input(self, position: list, size: list) -> QLineEdit: """Add Text Input.""" - textbox = QLineEdit(self.window) + textbox = QLineEdit(self._window) textbox.move(position[0], position[1]) textbox.resize(size[0], size[1]) @@ -974,7 +992,7 @@ def throw_alert_message( message: str, message_type: AlertMessageType = AlertMessageType.INFO, message_response: AlertMessageResponse = AlertMessageResponse.OTE, - message_timeout: float = 0) -> MessageBox: + message_timeout: float = 0) -> int: """Throw Alert Message.""" msg = alert_message(message, title=title, @@ -988,7 +1006,7 @@ def get_filename_dialog(self, file_type: str = 'All Files (*)', location: str = "") -> str: """Get Filename Dialog.""" - file_name, _ = QFileDialog.getOpenFileName(self.window, message, + file_name, _ = QFileDialog.getOpenFileName(self._window, message, location, file_type) return file_name @@ -1007,8 +1025,8 @@ def __init__(self, title: Optional[str] = None): super().__init__() - self.height = height - self.width = width + self._height = height + self._width = width self.background_color = background_color self.setStyleSheet(f'background-color: {self.background_color}') @@ -1017,11 +1035,13 @@ def __init__(self, # create the scrollable are self.frame = QScrollArea() - self.frame.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn) - self.frame.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.frame.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOn) + self.frame.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.frame.setWidgetResizable(True) - self.frame.setFixedWidth(self.width) - self.setFixedHeight(self.height) + self.frame.setFixedWidth(self._width) + self.setFixedHeight(self._height) # if a widget is provided, add it to the scrollable frame if widget: @@ -1076,11 +1096,11 @@ class LineItems(QWidget): def __init__(self, items: List[dict], width: str): super().__init__() - self.width = width + self._width = int(width) self.items = items self.vbox = QVBoxLayout() - self.setFixedWidth(self.width) + self.setFixedWidth(self._width) # construct the line items as widgets added to the layout self.construct_line_items() @@ -1119,13 +1139,12 @@ def construct_line_items(self) -> None: self.vbox.addLayout(layout) -def app(args) -> QApplication: +def app(args: List[str]) -> QApplication: """Main app registry. Passes args from main and initializes the app """ - - bci_app = QApplication(args).instance() + bci_app = QApplication.instance() if not bci_app: return QApplication(args) return bci_app diff --git a/bcipy/gui/parameters/params_form.py b/bcipy/gui/parameters/params_form.py index 3be18d35e..2b227a5c4 100644 --- a/bcipy/gui/parameters/params_form.py +++ b/bcipy/gui/parameters/params_form.py @@ -250,8 +250,10 @@ def __init__(self, json_file: str): self.layout = QVBoxLayout() self.changes_area = QScrollArea() - self.changes_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn) - self.changes_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.changes_area.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOn) + self.changes_area.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.changes_area.setWidgetResizable(True) self.changes_area.setWidget(self.change_items) self.changes_area.setVisible(not self.collapsed) @@ -334,8 +336,10 @@ def initUI(self): vbox.addLayout(self.changes_panel) self.form_panel = QScrollArea() - self.form_panel.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn) - self.form_panel.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.form_panel.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOn) + self.form_panel.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.form_panel.setWidgetResizable(True) self.form_panel.setFixedWidth(self.size[0]) self.form_panel.setWidget(self.form) diff --git a/bcipy/gui/viewer/data_viewer.py b/bcipy/gui/viewer/data_viewer.py index 83d0e0a21..1c96116d7 100644 --- a/bcipy/gui/viewer/data_viewer.py +++ b/bcipy/gui/viewer/data_viewer.py @@ -7,15 +7,13 @@ import matplotlib import matplotlib.ticker as ticker import numpy as np +from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.figure import Figure from PyQt6.QtCore import Qt, QTimer # pylint: disable=no-name-in-module from PyQt6.QtWidgets import (QApplication, QCheckBox, QComboBox, QHBoxLayout, QLabel, QPushButton, QSpinBox, QVBoxLayout, QWidget) -matplotlib.use('Qt5Agg') -from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas -from matplotlib.figure import Figure - from bcipy.acquisition.devices import DeviceSpec from bcipy.acquisition.util import StoppableProcess from bcipy.core.parameters import DEFAULT_PARAMETERS_PATH, Parameters @@ -27,6 +25,8 @@ from bcipy.gui.viewer.ring_buffer import RingBuffer from bcipy.signal.process.transform import Downsample, get_default_transform +matplotlib.use('Qt5Agg') + def filters( sample_rate_hz: float, parameters: Parameters @@ -92,14 +92,14 @@ class FixedHeightHBox(QWidget): def __init__(self, height: int = 30): super().__init__() - self.layout = QHBoxLayout() + self.layout = QHBoxLayout() # type: ignore self.layout.setContentsMargins(0, 0, 0, 0) self.setLayout(self.layout) self.setFixedHeight(height) def addWidget(self, widget: QWidget): """Add the given widget to the layout""" - self.layout.addWidget(widget) + self.layout.addWidget(widget) # type: ignore class ChannelControls(QWidget): @@ -630,7 +630,7 @@ def file_data(path: str """ # read metadata name, freq, channels = settings(path) - queue = Queue() + queue: Queue = Queue() streamer = FileStreamer(path, queue) data_source = QueueDataSource(queue) device_spec = DeviceSpec(name=name, channels=channels, sample_rate=freq) @@ -639,7 +639,7 @@ def file_data(path: str return (data_source, device_spec, streamer) -def main(data_file: str, +def main(data_file: Optional[str], seconds: int, refresh: int, yscale: int, @@ -676,7 +676,7 @@ def main(data_file: str, display_monitor = non_primary_screens[0] monitor = display_monitor.geometry() else: - monitor = app.primaryScreen().geometry() + monitor = app.primaryScreen().geometry() # type: ignore # increase height to 90% of monitor height and preserve aspect ratio. new_height = int(monitor.height() * 0.9) diff --git a/bcipy/gui/viewer/ring_buffer.py b/bcipy/gui/viewer/ring_buffer.py index 3bb5433d4..bac5cea37 100644 --- a/bcipy/gui/viewer/ring_buffer.py +++ b/bcipy/gui/viewer/ring_buffer.py @@ -1,35 +1,71 @@ -"""Defines a RingBuffer with a fixed size; when full additional elements -overwrite the oldest items in the data structure. +"""Ring buffer implementation for efficient data storage and retrieval. + +This module defines a RingBuffer class that implements a fixed-size circular buffer. +When the buffer is full, new elements overwrite the oldest items in the data structure. Adapted from Python Cookbook by David Ascher, Alex Martelli https://www.oreilly.com/library/view/python-cookbook/0596001673/ch05s19.html """ +from typing import Any, List, Optional, TypeVar + +T = TypeVar('T') + class RingBuffer: - """Data structure with a fixed size; when full additional elements - overwrite the oldest items in the data structure. - - Parameters - ---------- - size_max - max size of the buffer - pre_allocated - whether to create all values on initialization - empty_value - if pre_allocated, empty_value is used to set the - values with no data. + """A fixed-size circular buffer implementation. + + This class implements a ring buffer (circular buffer) with a fixed maximum size. + When the buffer is full, new elements overwrite the oldest items in the data structure. + + Attributes: + empty_value (Any): Value used to represent empty slots in the buffer. + max (int): Maximum size of the buffer. + data (List[Any]): Internal storage for buffer elements. + cur (int): Current position in the buffer. + full (bool): Whether the buffer is full. + pre_allocated (bool): Whether the buffer was pre-allocated with empty values. + + Args: + size_max (int): Maximum size of the buffer. + pre_allocated (bool, optional): Whether to create all values on initialization. + Defaults to False. + empty_value (Any, optional): If pre_allocated, this value is used to set the + values with no data. Defaults to None. + + Raises: + AssertionError: If size_max is not greater than 0. """ def __init__(self, size_max: int, pre_allocated: bool = False, - empty_value=None): + empty_value: Optional[Any] = None) -> None: + """Initialize the ring buffer. + + Args: + size_max (int): Maximum size of the buffer. + pre_allocated (bool, optional): Whether to create all values on initialization. + Defaults to False. + empty_value (Any, optional): If pre_allocated, this value is used to set the + values with no data. Defaults to None. + + Raises: + AssertionError: If size_max is not greater than 0. + """ assert size_max > 0 self.empty_value = empty_value self.max = size_max - self.data = [empty_value] * size_max if pre_allocated else [] + self.data: List[Any] = [empty_value] * \ + size_max if pre_allocated else [] self.cur = 0 self.full = False self.pre_allocated = pre_allocated - def append(self, item): - """Add an element to the buffer, overwriting if full.""" + def append(self, item: Any) -> None: + """Add an element to the buffer, overwriting if full. + + Args: + item (Any): The item to add to the buffer. + """ if self.full or self.pre_allocated: # overwrite self.data[self.cur] = item @@ -39,11 +75,20 @@ def append(self, item): self.full = self.cur == self.max - 1 self.cur = (self.cur + 1) % self.max - def get(self): - """Return a list of elements from the oldest to the newest.""" + def get(self) -> List[Any]: + """Return a list of elements from the oldest to the newest. + + Returns: + List[Any]: List of elements in chronological order. + """ if self.full: return self.data[self.cur:] + self.data[:self.cur] return self.data - def is_empty(self): + def is_empty(self) -> bool: + """Check if the buffer is empty. + + Returns: + bool: True if the buffer is empty or contains only empty values. + """ return len(self.data) == 0 or self.data[0] == self.empty_value diff --git a/bcipy/helpers/acquisition.py b/bcipy/helpers/acquisition.py index 774bee5d7..5851d3faf 100644 --- a/bcipy/helpers/acquisition.py +++ b/bcipy/helpers/acquisition.py @@ -66,7 +66,8 @@ def init_acquisition( device_spec = init_device(content_type, device_name, status) raw_data_name = raw_data_filename(device_spec) - client = init_lsl_client(parameters, device_spec, save_folder, raw_data_name) + client = init_lsl_client( + parameters, device_spec, save_folder, raw_data_name) manager.add_client(client) manager.start_acquisition() @@ -115,7 +116,8 @@ def init_device(content_type: str, spec = preconfigured_device(device_name, strict=True) else: discovered_spec = discover_device_spec(content_type) - configured_spec = preconfigured_device(discovered_spec.name, strict=False) + configured_spec = preconfigured_device( + discovered_spec.name, strict=False) spec = configured_spec or discovered_spec if status_override is not None: spec.status = status_override @@ -335,7 +337,8 @@ def is_stream_type_active(stream_type: StreamType) -> bool: """Check if the provided stream type is active. A stream type's status, if provided, will be used to make the determinition. - If missing, the status of a matching pre-configured device will be used.""" + If missing, the status of a matching pre-configured device will be used. + """ content_type, device_name, status = stream_type if status: return status == DeviceStatus.ACTIVE diff --git a/bcipy/helpers/copy_phrase_wrapper.py b/bcipy/helpers/copy_phrase_wrapper.py index c41e4dafa..d30bf4ae6 100644 --- a/bcipy/helpers/copy_phrase_wrapper.py +++ b/bcipy/helpers/copy_phrase_wrapper.py @@ -216,7 +216,8 @@ def initialize_series(self) -> Tuple[bool, InquirySchedule]: prob_dist = self.conjugator.update_and_fuse( {EvidenceType.LM: np.array(prior)}) except Exception as fusion_error: - log.exception(f'Error fusing language model evidence!: {fusion_error}') + log.exception( + f'Error fusing language model evidence!: {fusion_error}') raise BciPyCoreException(fusion_error) from fusion_error # Get decision maker to give us back some decisions and stimuli diff --git a/bcipy/helpers/demo/demo_visualization.py b/bcipy/helpers/demo/demo_visualization.py index 149e0498d..e4b264479 100644 --- a/bcipy/helpers/demo/demo_visualization.py +++ b/bcipy/helpers/demo/demo_visualization.py @@ -86,7 +86,8 @@ trigger_targetness, trigger_timing, trigger_symbols = trigger_decoder( offset=static_offset, trigger_path=f"{path}/{TRIGGER_FILENAME}", - exclusion=[TriggerType.PREVIEW, TriggerType.EVENT, TriggerType.FIXATION], + exclusion=[TriggerType.PREVIEW, + TriggerType.EVENT, TriggerType.FIXATION], ) labels = [0 if label == 'nontarget' else 1 for label in trigger_targetness] diff --git a/bcipy/helpers/language_model.py b/bcipy/helpers/language_model.py index 810058176..d91154817 100644 --- a/bcipy/helpers/language_model.py +++ b/bcipy/helpers/language_model.py @@ -8,17 +8,17 @@ from bcipy.core.symbols import alphabet from bcipy.exceptions import LanguageModelNameInUseException from bcipy.language.main import LanguageModel +from bcipy.language.model.causal import CausalLanguageModelAdapter +from bcipy.language.model.mixture import MixtureLanguageModelAdapter +from bcipy.language.model.ngram import NGramLanguageModelAdapter +from bcipy.language.model.oracle import OracleLanguageModel +from bcipy.language.model.uniform import UniformLanguageModel # pylint: disable=unused-import # flake8: noqa """Only imported models will be included in language_models_by_name""" # flake8: noqa -from bcipy.language.model.causal import CausalLanguageModelAdapter -from bcipy.language.model.mixture import MixtureLanguageModelAdapter -from bcipy.language.model.ngram import NGramLanguageModelAdapter -from bcipy.language.model.oracle import OracleLanguageModel -from bcipy.language.model.uniform import UniformLanguageModel VALID_LANGUAGE_MODELS: Dict[str, Callable[[], LanguageModel]] = { "CAUSAL": CausalLanguageModelAdapter, diff --git a/bcipy/helpers/offset.py b/bcipy/helpers/offset.py index e36a68783..c45ab8404 100644 --- a/bcipy/helpers/offset.py +++ b/bcipy/helpers/offset.py @@ -154,7 +154,8 @@ def calculate_latency(raw_data: RawData, # if it's not normal, take the median if p_value < 0.05: - print(f'Non-normal distribution of diffs. p-value=[{p_value}] Consider using median for static offset.') + print( + f'Non-normal distribution of diffs. p-value=[{p_value}] Consider using median for static offset.') recommended_static = abs(np.median(diffs)) print( f'System recommended static offset median=[{recommended_static}]') @@ -188,7 +189,8 @@ def calculate_latency(raw_data: RawData, linewidth=0.5, color='cyan') - ax.plot(trg_box_x, trg_box_y, label=f'{diode_channel} (photodiode triggers)') + ax.plot(trg_box_x, trg_box_y, + label=f'{diode_channel} (photodiode triggers)') # Add labels for TRGs first_trg = trigger_diodes_timestamps[0] @@ -260,7 +262,8 @@ def sample_rate_diffs(raw_data: RawData) -> Tuple[int, float]: # get the count of all the samples and calculate the time recorded from the raw_data sample_time = raw_data.dataframe.shape[0] / raw_data.sample_rate - print(f'LSL Timestamp Sample Count: {lsl_sample_diff} EEG Sample Count: {sample_time}') + print( + f'LSL Timestamp Sample Count: {lsl_sample_diff} EEG Sample Count: {sample_time}') return lsl_sample_diff, sample_time @@ -335,10 +338,12 @@ def extract_data_latency_calculation( args = parser.parse_args() data_path = args.data_path if not data_path: - data_path = ask_directory(prompt="Please select a BciPy time test directory..", strict=True) + data_path = ask_directory( + prompt="Please select a BciPy time test directory..", strict=True) # grab the stim length from the data directory parameters - stim_length = load_json_parameters(f'{data_path}/{DEFAULT_PARAMETERS_FILENAME}', value_cast=True)['stim_length'] + stim_length = load_json_parameters( + f'{data_path}/{DEFAULT_PARAMETERS_FILENAME}', value_cast=True)['stim_length'] raw_data, triggers, static_offset = extract_data_latency_calculation( data_path, diff --git a/bcipy/helpers/task.py b/bcipy/helpers/task.py index 8e6dbd8f4..e7ae64c84 100644 --- a/bcipy/helpers/task.py +++ b/bcipy/helpers/task.py @@ -156,7 +156,8 @@ def get_data_for_decision(inquiry_timing: List[Tuple[str, float]], for text, timing in inquiry_timing] # Define the amount of data required for any processing to occur. - data_limit = round((time2 - time1 + poststim) * daq.device_spec.sample_rate) + data_limit = round((time2 - time1 + poststim) * + daq.device_spec.sample_rate) log.info(f'Need {data_limit} records for processing') # Query for raw data @@ -252,7 +253,8 @@ def relative_triggers(inquiry_timing: List[Tuple[str, float]], def _float_val(col: Any) -> float: """Convert marker data to float values so we can put them in a typed np.array. The marker column has type float if it has a 0.0 - value, and would only have type str for a marker value.""" + value, and would only have type str for a marker value. + """ if isinstance(col, str): return 1.0 return float(col) @@ -350,7 +352,8 @@ def pause_on_wait_screen(window, message, color) -> bool: elapsed_seconds = time.time() - pause_start if elapsed_seconds >= MAX_PAUSE_SECONDS: - log.info(f"Pause exceeded the allowed time ({MAX_PAUSE_SECONDS} seconds). Ending task.") + log.info( + f"Pause exceeded the allowed time ({MAX_PAUSE_SECONDS} seconds). Ending task.") return False if keys[0] == 'escape': return False diff --git a/bcipy/helpers/tests/test_offset.py b/bcipy/helpers/tests/test_offset.py index f9a3d5f6e..4ed0944ae 100644 --- a/bcipy/helpers/tests/test_offset.py +++ b/bcipy/helpers/tests/test_offset.py @@ -38,7 +38,8 @@ def setUp(self) -> None: remove_pre_fixation=False, exclusion=[TriggerType.FIXATION], device_type='EEG') - self.triggers = list(zip(trigger_label, trigger_targetness, trigger_time)) + self.triggers = list( + zip(trigger_label, trigger_targetness, trigger_time)) self.diode_channel = 'TRG' self.stim_number = 10 @@ -62,7 +63,8 @@ def test_sample_to_seconds_throws_error_on_zero_args(self): def test_extract_data_latency_calculation(self): static_offset = 0.0 recommend = False - resp = extract_data_latency_calculation(self.tmp_dir, recommend, static_offset) + resp = extract_data_latency_calculation( + self.tmp_dir, recommend, static_offset) self.assertIsInstance(resp[0], RawData) self.assertEqual(resp[1], self.triggers) self.assertEqual(resp[2], static_offset) @@ -70,7 +72,8 @@ def test_extract_data_latency_calculation(self): def test_extract_data_latency_calculation_resets_static_offset_on_recommend(self): static_offset = 1.0 recommend = True - resp = extract_data_latency_calculation(self.tmp_dir, recommend, static_offset) + resp = extract_data_latency_calculation( + self.tmp_dir, recommend, static_offset) self.assertIsInstance(resp[0], RawData) self.assertEqual(resp[1], self.triggers) self.assertNotEqual(resp[2], static_offset) diff --git a/bcipy/helpers/tests/test_system_utils.py b/bcipy/helpers/tests/test_system_utils.py index 445e47afb..322d086e2 100644 --- a/bcipy/helpers/tests/test_system_utils.py +++ b/bcipy/helpers/tests/test_system_utils.py @@ -15,12 +15,14 @@ class TestSystemUtilsAlerts(unittest.TestCase): def test_is_connected_true(self): """Test that a computer connected to the internet returns True.""" mock_conn = mock() - when(socket).create_connection(address=any, timeout=any).thenReturn(mock_conn) + when(socket).create_connection( + address=any, timeout=any).thenReturn(mock_conn) self.assertTrue(is_connected()) def test_is_connected_false(self): """Test that a computer not connected to the internet returns False.""" - when(socket).create_connection(address=any, timeout=any).thenRaise(OSError) + when(socket).create_connection( + address=any, timeout=any).thenRaise(OSError) self.assertFalse(is_connected()) def test_is_battery_powered_true(self): diff --git a/bcipy/helpers/tests/test_visualization.py b/bcipy/helpers/tests/test_visualization.py index aa7ff7a86..5cb137e39 100644 --- a/bcipy/helpers/tests/test_visualization.py +++ b/bcipy/helpers/tests/test_visualization.py @@ -17,7 +17,8 @@ class TestVisualizeSessionData(unittest.TestCase): def setUp(self): self.tmp_dir = str(Path(tempfile.mkdtemp())) - self.parameters = load_json_parameters(DEFAULT_PARAMETERS_PATH, value_cast=True) + self.parameters = load_json_parameters( + DEFAULT_PARAMETERS_PATH, value_cast=True) self.raw_data_mock = mock() self.raw_data_mock.daq_type = 'DSI-24' self.raw_data_mock.sample_rate = 300 @@ -33,7 +34,8 @@ def test_visualize_session_data(self): trigger_label_mock = ['target', 'nontarget'] show = True when(RawData).load(any()).thenReturn(self.raw_data_mock) - when(visualization).analysis_channels(any(), any()).thenReturn(self.channel_map_mock) + when(visualization).analysis_channels( + any(), any()).thenReturn(self.channel_map_mock) when(visualization).trigger_decoder( offset=any(), trigger_path=any(), @@ -82,7 +84,8 @@ def test_visualize_session_data_with_no_valid_targets(self): trigger_label_mock = ['nontarget', 'nontarget'] show = False when(RawData).load(any()).thenReturn(self.raw_data_mock) - when(visualization).analysis_channels(any(), any()).thenReturn(self.channel_map_mock) + when(visualization).analysis_channels( + any(), any()).thenReturn(self.channel_map_mock) when(visualization).trigger_decoder( offset=any(), trigger_path=any(), @@ -100,7 +103,8 @@ def test_visualize_session_data_with_no_valid_nontargets(self): trigger_label_mock = ['target', 'target'] show = False when(RawData).load(any()).thenReturn(self.raw_data_mock) - when(visualization).analysis_channels(any(), any()).thenReturn(self.channel_map_mock) + when(visualization).analysis_channels( + any(), any()).thenReturn(self.channel_map_mock) when(visualization).trigger_decoder( offset=any(), trigger_path=any(), @@ -118,7 +122,8 @@ def test_visualize_session_data_with_invalid_timing(self): trigger_label_mock = ['target', 'nontarget'] show = False when(RawData).load(any()).thenReturn(self.raw_data_mock) - when(visualization).analysis_channels(any(), any()).thenReturn(self.channel_map_mock) + when(visualization).analysis_channels( + any(), any()).thenReturn(self.channel_map_mock) when(visualization).trigger_decoder( offset=any(), trigger_path=any(), diff --git a/bcipy/helpers/utils.py b/bcipy/helpers/utils.py index c423dde4a..98cbd8254 100644 --- a/bcipy/helpers/utils.py +++ b/bcipy/helpers/utils.py @@ -1,6 +1,7 @@ # mypy: disable-error-code="import-untyped" """Utilities for system information and general functionality that may be -shared across modules.""" +shared across modules. +""" import importlib import logging import os @@ -23,6 +24,7 @@ class ScreenInfo(NamedTuple): + """Screen information including width, height, and refresh rate.""" width: int height: int rate: float @@ -52,7 +54,8 @@ def is_battery_powered() -> bool: ------- True if the computer is currently running on battery power. This can impact the performance of hardware (ex. GPU) needed for BciPy operation by entering - power saving operations.""" + power saving operations. + """ return psutil.sensors_battery( ) and not psutil.sensors_battery().power_plugged @@ -79,7 +82,8 @@ def git_dir() -> Optional[str]: """Git Directory. Returns the root directory with the .git folder. If this source code - was not checked out from scm, answers None.""" + was not checked out from scm, answers None. + """ # Relative to current file; may need to be modified if method is moved. git_root = Path(os.path.abspath(__file__)).parent.parent.parent @@ -143,7 +147,7 @@ def get_screen_info(stim_screen: Optional[int] = None) -> ScreenInfo: Returns ------- ScreenInfo(width, height, rate) - """ + """ if stim_screen: screen = pyglet.canvas.get_display().get_screens()[stim_screen] else: @@ -285,7 +289,6 @@ def log_to_stdout(): """Set logging to stdout. Useful for demo scripts. https://stackoverflow.com/questions/14058453/making-python-loggers-output-all-messages-to-stdout-in-addition-to-log-file """ - root = logging.getLogger() root.setLevel(logging.DEBUG) @@ -309,7 +312,8 @@ def wrap(*args, **kwargs): time1 = time.perf_counter() response = func(*args, **kwargs) time2 = time.perf_counter() - log.info('{:s} method took {:0.4f}s to execute'.format(func.__name__, (time2 - time1))) + log.info('{:s} method took {:0.4f}s to execute'.format( + func.__name__, (time2 - time1))) return response return wrap diff --git a/bcipy/helpers/validate.py b/bcipy/helpers/validate.py index 7c8184a89..be71f37e7 100644 --- a/bcipy/helpers/validate.py +++ b/bcipy/helpers/validate.py @@ -76,7 +76,8 @@ def _validate_experiment_fields(experiment_fields, fields): try: fields[field_name] except KeyError: - raise UnregisteredFieldException(f'Field [{field}] is not registered in [{fields}]') + raise UnregisteredFieldException( + f'Field [{field}] is not registered in [{fields}]') try: field[field_name]['required'] @@ -94,7 +95,8 @@ def validate_field_data_written(path: str, file_name: str) -> bool: experiment_data_path = f'{path}/{file_name}' if os.path.isfile(experiment_data_path): return True - raise InvalidFieldException(f'Experimental field data expected at path=[{experiment_data_path}] but not found.') + raise InvalidFieldException( + f'Experimental field data expected at path=[{experiment_data_path}] but not found.') def validate_experiments(experiments, fields) -> bool: diff --git a/bcipy/helpers/visualization.py b/bcipy/helpers/visualization.py index b4bd72294..96ff982f4 100644 --- a/bcipy/helpers/visualization.py +++ b/bcipy/helpers/visualization.py @@ -60,7 +60,7 @@ def visualize_erp( plot_topomaps: Optional[bool] = True, show: Optional[bool] = False, save_path: Optional[str] = None) -> List[Figure]: - """ Visualize ERP. + """Visualize ERP. Generates a comparative ERP figure following a task execution. Given a set of trailed data, and labels describing two classes (Nontarget=0 and Target=1), they are plotted and may be saved @@ -90,8 +90,10 @@ def visualize_erp( else: baseline = None - mne_data = convert_to_mne(raw_data, channel_map=channel_map, transform=transform) - epochs = mne_epochs(mne_data, trial_length, trigger_timing, trigger_labels, baseline=baseline) + mne_data = convert_to_mne( + raw_data, channel_map=channel_map, transform=transform) + epochs = mne_epochs(mne_data, trial_length, trigger_timing, + trigger_labels, baseline=baseline) # *Note* We assume, as described above, two trigger classes are defined for use in trigger_labels # (Nontarget=0 and Target=1). This will map into two corresponding MNE epochs whose indexing starts at 1. # Therefore, epochs['1'] == Nontarget and epochs['2'] == Target. @@ -102,10 +104,12 @@ def visualize_erp( if plot_topomaps: # make a list of equally spaced times to plot topomaps using the time window # defined in the task parameters - times = [round(trial_window[0] + i * (trial_window[1] - trial_window[0]) / 5, 1) for i in range(7)] + times = [round(trial_window[0] + i * (trial_window[1] - + trial_window[0]) / 5, 1) for i in range(7)] # clip any times that are out of bounds of the time window or zero - times = [time for time in times if trial_window[0] <= time <= trial_window[1] and time != 0] + times = [time for time in times if trial_window[0] + <= time <= trial_window[1] and time != 0] figs.extend(visualize_joint_average( epochs, ['Non-Target', 'Target'], @@ -150,7 +154,6 @@ def visualize_gaze( heatmap: Optional[bool]: Whether or not to plot the heatmap. Default: False raw_plot: Optional[bool]: Whether or not to plot the raw gaze data. Default: False """ - title = f'{data.daq_type} ' if heatmap: title += 'Heatmap ' @@ -162,12 +165,14 @@ def visualize_gaze( img = plt.imread(img_path) channels = data.channels - left_eye_channel_map = [1 if channel in left_keys else 0 for channel in channels] + left_eye_channel_map = [ + 1 if channel in left_keys else 0 for channel in channels] left_eye_data, _, _ = data.by_channel_map(left_eye_channel_map) left_eye_x = left_eye_data[0] left_eye_y = left_eye_data[1] - right_eye_channel_map = [1 if channel in right_keys else 0 for channel in channels] + right_eye_channel_map = [ + 1 if channel in right_keys else 0 for channel in channels] right_eye_data, _, _ = data.by_channel_map(right_eye_channel_map) right_eye_x = right_eye_data[0] right_eye_y = right_eye_data[1] @@ -220,7 +225,8 @@ def visualize_gaze( plt.title(f'{title}Plot') if save_path is not None: - plt.savefig(f"{save_path}/{title.lower().replace(' ', '_')}plot.png", dpi=fig.dpi) + plt.savefig( + f"{save_path}/{title.lower().replace(' ', '_')}plot.png", dpi=fig.dpi) if show: plt.show() @@ -337,7 +343,8 @@ def visualize_gaze_inquiries( plt.title(f'{title}Plot') if save_path is not None: - plt.savefig(f"{save_path}/{title.lower().replace(' ', '_')}plot.png", dpi=fig.dpi) + plt.savefig( + f"{save_path}/{title.lower().replace(' ', '_')}plot.png", dpi=fig.dpi) if show: plt.show() @@ -419,7 +426,8 @@ def visualize_pupil_size( plt.title(f'{title}Plot') if save_path is not None: - plt.savefig(f"{save_path}/{title.lower().replace(' ', '_')}plot.png", dpi=fig.dpi) + plt.savefig( + f"{save_path}/{title.lower().replace(' ', '_')}plot.png", dpi=fig.dpi) if show: plt.show() @@ -487,7 +495,8 @@ def visualize_centralized_data( plt.title(f'{title}Plot') if save_path is not None: - plt.savefig(f"{save_path}/{title.lower().replace(' ', '_')}plot.png", dpi=fig.dpi) + plt.savefig( + f"{save_path}/{title.lower().replace(' ', '_')}plot.png", dpi=fig.dpi) if show: plt.show() @@ -593,7 +602,8 @@ def visualize_results_all_symbols( # Plot an ellipse to show the Gaussian component angle = np.arctan(u[1] / u[0]) angle = 180.0 * angle / np.pi # convert to degrees - ell = Ellipse(mean, v[0], v[1], angle=180.0 + angle, color='navy') + ell = Ellipse(mean, v[0], v[1], + angle=180.0 + angle, color='navy') ell.set_clip_box(ax) ell.set_alpha(0.5) ax.add_artist(ell) @@ -606,7 +616,8 @@ def visualize_results_all_symbols( plt.title(f'{title}Plot') if save_path is not None: - plt.savefig(f"{save_path}/{title.lower().replace(' ', '_')}plot.png", dpi=fig.dpi) + plt.savefig( + f"{save_path}/{title.lower().replace(' ', '_')}plot.png", dpi=fig.dpi) if show: plt.show() @@ -655,7 +666,8 @@ def visualize_csv_eeg_triggers(trigger_col: Optional[int] = None): def visualize_joint_average( epochs: Tuple[Epochs], labels: List[str], - plot_joint_times: Optional[List[float]] = [-0.1, 0, 0.2, 0.3, 0.35, 0.4, 0.5], + plot_joint_times: Optional[List[float] + ] = [-0.1, 0, 0.2, 0.3, 0.35, 0.4, 0.5], save_path: Optional[str] = None, show: Optional[bool] = False) -> List[Figure]: """Visualize Joint Average. @@ -676,7 +688,8 @@ def visualize_joint_average( Returns: List of figures generated """ - assert len(epochs) == len(labels), "The number of epochs must match labels in Visualize Joint Average" + assert len(epochs) == len( + labels), "The number of epochs must match labels in Visualize Joint Average" figs = [] for i, label in enumerate(labels): @@ -740,12 +753,14 @@ def visualize_session_data( # extract all relevant parameters trial_window = parameters.get("trial_window") - raw_data = load_raw_data(str(Path(session_path, f'{RAW_DATA_FILENAME}.csv'))) + raw_data = load_raw_data( + str(Path(session_path, f'{RAW_DATA_FILENAME}.csv'))) channels = raw_data.channels sample_rate = raw_data.sample_rate daq_type = raw_data.daq_type - transform_params: ERPTransformParams = parameters.instantiate(ERPTransformParams) + transform_params: ERPTransformParams = parameters.instantiate( + ERPTransformParams) devices.load(Path(session_path, DEFAULT_DEVICE_SPEC_FILENAME)) device_spec = devices.preconfigured_device(daq_type) @@ -763,12 +778,14 @@ def visualize_session_data( trigger_targetness, trigger_timing, _ = trigger_decoder( offset=device_spec.static_offset, trigger_path=f"{session_path}/{TRIGGER_FILENAME}", - exclusion=[TriggerType.PREVIEW, TriggerType.EVENT, TriggerType.FIXATION], + exclusion=[TriggerType.PREVIEW, + TriggerType.EVENT, TriggerType.FIXATION], device_type='EEG', ) assert "nontarget" in trigger_targetness, "No nontarget triggers found." assert "target" in trigger_targetness, "No target triggers found." - assert len(trigger_targetness) == len(trigger_timing), "Trigger targetness and timing must be the same length." + assert len(trigger_targetness) == len( + trigger_timing), "Trigger targetness and timing must be the same length." labels = [0 if label == 'nontarget' else 1 for label in trigger_targetness] channel_map = analysis_channels(channels, device_spec) @@ -807,7 +824,8 @@ def visualize_gaze_accuracies(accuracy_dict: Dict[str, np.ndarray], ax.set_title(title + str(round(accuracy, 2))) if save_path is not None: - plt.savefig(f"{save_path}/{title.lower().replace(' ', '_').replace(':', '')}plot.png", dpi=fig.dpi) + plt.savefig( + f"{save_path}/{title.lower().replace(' ', '_').replace(':', '')}plot.png", dpi=fig.dpi) if show: plt.show() @@ -818,6 +836,9 @@ def visualize_gaze_accuracies(accuracy_dict: Dict[str, np.ndarray], def erp(): + """ERP Visualization CLI. + This function is used to visualize ERP data after a session. + """ import argparse parser = argparse.ArgumentParser(description='Visualize ERP data') diff --git a/bcipy/io/README.md b/bcipy/io/README.md index 8b04bd539..0381ec7fa 100644 --- a/bcipy/io/README.md +++ b/bcipy/io/README.md @@ -2,6 +2,6 @@ The BciPy IO module contains functionality for loading and saving data in various formats. This includes the ability to convert data to BIDS format, load and save raw data, and load and save triggers. -- `convert`: functionality for converting the bcipy raw data output to other formats (currently, BrainVision and EDF), and for converting the raw data to BIDS format. +- `convert`: functionality for converting the BciPy raw data output to other formats (currently, BrainVision and EDF), and for converting the raw data to BIDS format. - `load`: methods for loading most BciPy data formats, including raw data and triggers. - `save`: methods for saving BciPy data in supported formats. diff --git a/bcipy/io/convert.py b/bcipy/io/convert.py index 9f308fa63..baa88b583 100644 --- a/bcipy/io/convert.py +++ b/bcipy/io/convert.py @@ -27,6 +27,7 @@ class ConvertFormat(Enum): + """Enumeration of supported data conversion formats for BciPy raw data output.""" BV = 'BrainVision' EDF = 'EDF' @@ -34,14 +35,17 @@ class ConvertFormat(Enum): EEGLAB = 'EEGLAB' def __str__(self): + """Return the string representation of the format.""" return self.value @staticmethod def all(): + """Return a list of all ConvertFormat enum members.""" return [format for format in ConvertFormat] @staticmethod def values(): + """Return a list of all ConvertFormat values as strings.""" return [format.value for format in ConvertFormat] @@ -89,7 +93,8 @@ def convert_to_bids( try: os.mkdir(output_dir) except OSError as e: - raise OSError(f"Failed to create output directory={output_dir}") from e + raise OSError( + f"Failed to create output directory={output_dir}") from e if format not in ConvertFormat.all(): raise ValueError(f"Unsupported format={format}") if line_frequency not in [50, 60]: @@ -190,16 +195,20 @@ def convert_eyetracking_to_bids( """ # check that the raw data path exists if not os.path.exists(raw_data_path): - raise FileNotFoundError(f"Raw eye tracking data path={raw_data_path} does not exist") + raise FileNotFoundError( + f"Raw eye tracking data path={raw_data_path} does not exist") if not os.path.exists(output_dir): - raise FileNotFoundError(f"Output directory={output_dir} does not exist") + raise FileNotFoundError( + f"Output directory={output_dir} does not exist") found_files = glob.glob(f"{raw_data_path}/eyetracker*.csv") if len(found_files) == 0: - raise FileNotFoundError(f"No raw eye tracking data found in directory={raw_data_path}") + raise FileNotFoundError( + f"No raw eye tracking data found in directory={raw_data_path}") if len(found_files) > 1: - raise ValueError(f"Multiple raw eye tracking data files found in directory={raw_data_path}") + raise ValueError( + f"Multiple raw eye tracking data files found in directory={raw_data_path}") eye_tracking_file = found_files[0] logger.info(f"Found raw eye tracking data file={eye_tracking_file}") @@ -405,7 +414,8 @@ def convert_to_mne( # if remove_system_channels is True, exclude the system and trigger channels (last two channels) if remove_system_channels: channel_map = [1] * (len(raw_data.channels) - 2) - channel_map.extend([0, 0]) # exclude the system and trigger channels + # exclude the system and trigger channels + channel_map.extend([0, 0]) else: channel_map = [1] * len(raw_data.channels) @@ -413,7 +423,8 @@ def convert_to_mne( # if no channel types provided, assume all channels are eeg if not channel_types: - logger.warning("No channel types provided. Assuming all channels are EEG.") + logger.warning( + "No channel types provided. Assuming all channels are EEG.") channel_types = ['eeg'] * len(channels) # check that number of channel types matches number of channels in the case custom channel types are provided @@ -470,8 +481,10 @@ def norm_to_tobii(norm_units: Tuple[float, float]) -> Tuple[float, float]: and (1, 1) the lower right corner. """ # check that the coordinates are within the bounds of the screen - assert norm_units[0] >= -1 and norm_units[0] <= 1, "X coordinate must be between -1 and 1" - assert norm_units[1] >= -1 and norm_units[1] <= 1, "Y coordinate must be between -1 and 1" + assert norm_units[0] >= - \ + 1 and norm_units[0] <= 1, "X coordinate must be between -1 and 1" + assert norm_units[1] >= - \ + 1 and norm_units[1] <= 1, "Y coordinate must be between -1 and 1" # convert PsychoPy norm units to Tobii units tobii_x = (norm_units[0] / 2) + 0.5 @@ -505,7 +518,8 @@ def BIDS_to_MNE( """ # Check if the BIDS root path exists if not os.path.exists(bids_root_path): - raise FileNotFoundError(f"BIDS root path '{bids_root_path}' does not exist.") + raise FileNotFoundError( + f"BIDS root path '{bids_root_path}' does not exist.") logger.info(f"Searching for BIDS data in '{bids_root_path}'...") # Get the sessions from the BIDS root path using the session label (e.g., 'ses' or 'session') @@ -521,12 +535,14 @@ def BIDS_to_MNE( tasks=task_name, ) if not bid_paths: - raise FileNotFoundError(f"No matching BIDS files found in '{bids_root_path}'.") + raise FileNotFoundError( + f"No matching BIDS files found in '{bids_root_path}'.") raw_data = [] for bid_path in bid_paths: if task_name and bid_path.task != task_name: - logger.debug(f"Skipping file '{bid_path}' due to task name [{task_name}] mismatch.") + logger.debug( + f"Skipping file '{bid_path}' due to task name [{task_name}] mismatch.") continue logger.info(f"Reading BIDS file: {bid_path}") diff --git a/bcipy/io/demo/demo_convert.py b/bcipy/io/demo/demo_convert.py index bce775562..5195b44d6 100644 --- a/bcipy/io/demo/demo_convert.py +++ b/bcipy/io/demo/demo_convert.py @@ -54,7 +54,8 @@ def load_historical_bcipy_data(directory: str, experiment_id: str) -> List[BciPy extracted_task_time = task_run.name.split('_')[7] # add the tasks to the list with the time as the key - run_tasks[extracted_task_time] = [task_run, f'{extracted_task_paradigm}{extracted_task_mode}'] + run_tasks[extracted_task_time] = [ + task_run, f'{extracted_task_paradigm}{extracted_task_mode}'] # sort the tasks by time sorted_tasks = sorted(run_tasks.items()) @@ -88,7 +89,8 @@ def convert_experiment_to_bids( experiment_id=experiment_id) # Use for data post-2.0rc4 - experiment_data = load_bcipy_data(directory, experiment_id, excluded_tasks=EXCLUDED_TASKS) + experiment_data = load_bcipy_data( + directory, experiment_id, excluded_tasks=EXCLUDED_TASKS) if not output_dir: output_dir = directory @@ -120,8 +122,10 @@ def convert_experiment_to_bids( task_name=data.task_name ) except Exception as e: - print(f"Error converting eye tracker data for {data.path} - {e}") - errors.append(f"Error converting eye tracker data for {data.path}") + print( + f"Error converting eye tracker data for {data.path} - {e}") + errors.append( + f"Error converting eye tracker data for {data.path}") except Exception as e: print(f"Error converting {data.path} - {e}") @@ -131,7 +135,8 @@ def convert_experiment_to_bids( if errors: print(f"Errors converting the following data: {errors}") - print(f"\nData converted to BIDS format in {output_dir}/bids_{experiment_id}/") + print( + f"\nData converted to BIDS format in {output_dir}/bids_{experiment_id}/") print("--------------------") @@ -162,7 +167,8 @@ def convert_experiment_to_bids( path = args.directory if not path: - path = ask_directory("Select the directory with data to be converted", strict=True) + path = ask_directory( + "Select the directory with data to be converted", strict=True) # convert a study to BIDS format convert_experiment_to_bids( diff --git a/bcipy/io/demo/demo_load_BIDS.py b/bcipy/io/demo/demo_load_BIDS.py index 5f4ac5bd1..942e4a747 100644 --- a/bcipy/io/demo/demo_load_BIDS.py +++ b/bcipy/io/demo/demo_load_BIDS.py @@ -28,7 +28,8 @@ task_name = None # Set to None to load all tasks. raw_data_files = BIDS_to_MNE(path_to_bids, task_name=task_name) - raw_data = raw_data_files[0] # Get the first raw data object from the list. + # Get the first raw data object from the list. + raw_data = raw_data_files[0] # to see where the data is stored, you can use the following command: # print(raw_data.filenames) @@ -41,11 +42,13 @@ # EPOCH THE DATA / CREATE ERPS # epoch the data using the events from the raw data object. # You can specify the event_id, tmin, tmax, and baseline parameters as needed. - events = mne.events_from_annotations(raw_data, event_id={'nontarget': 0, 'target': 1}) + events = mne.events_from_annotations( + raw_data, event_id={'nontarget': 0, 'target': 1}) tmin = -0.2 tmax = 0.8 baseline = (None, None) # No baseline correction. - epochs = mne.Epochs(raw_data, events[0], events[1], tmin, tmax, baseline=baseline, preload=True) + epochs = mne.Epochs( + raw_data, events[0], events[1], tmin, tmax, baseline=baseline, preload=True) # Grab the epochs for non-target and target events. non_target_epochs = epochs['nontarget'] @@ -72,4 +75,5 @@ print(f"An error occurred: {e}") finally: print("Demo script completed.") - breakpoint() # This will pause the script execution and allow you to inspect the variables in the debugger. + # This will pause the script execution and allow you to inspect the variables in the debugger. + breakpoint() diff --git a/bcipy/io/load.py b/bcipy/io/load.py index 2c0157c82..8b4069426 100644 --- a/bcipy/io/load.py +++ b/bcipy/io/load.py @@ -1,3 +1,9 @@ +"""Module for loading BciPy data and configuration files. + +This module provides functions for loading various types of data used in BciPy, +including parameters, experiments, signal models, and session data. +""" + # mypy: disable-error-code="arg-type, union-attr" import json import logging @@ -24,17 +30,15 @@ def copy_parameters(path: str = DEFAULT_PARAMETERS_PATH, destination: Optional[str] = None) -> str: - """Creates a copy of the given configuration (parameters.json) to the - given directory and returns the path. - - Parameters: - ----------- - path: str - optional path of parameters file to copy; used default if not provided. - destination: str - optional destination directory; default is the same - directory as the default parameters. + """Creates a copy of the given configuration (parameters.json) to the given directory. + + Args: + path: Optional path of parameters file to copy; uses default if not provided. + destination: Optional destination directory; default is the same directory + as the default parameters. + Returns: - -------- - path to the new file. + str: Path to the new file. """ default_dir = str(Path(DEFAULT_PARAMETERS_PATH).parent) @@ -47,73 +51,91 @@ def copy_parameters(path: str = DEFAULT_PARAMETERS_PATH, def load_experiments(path: str = f'{DEFAULT_EXPERIMENT_PATH}/{EXPERIMENT_FILENAME}') -> dict: - """Load Experiments. + """Load experiment configurations from a JSON file. - PARAMETERS - ---------- - :param: path: string path to the experiments file. - - Returns - ------- - A dictionary of experiments, with the following format: - { name: { fields : {name: '', required: bool, anonymize: bool}, summary: '' } } + Args: + path: Path to the experiments file. + Returns: + dict: Dictionary of experiments with format: + { + name: { + fields: { + name: str, + required: bool, + anonymize: bool + }, + summary: str + } + } """ with open(path, 'r', encoding=DEFAULT_ENCODING) as json_file: return json.load(json_file) def extract_mode(bcipy_data_directory: str) -> str: - """Extract Mode. + """Extract the task mode from a BciPy data save directory. This method extracts the task mode from a BciPy data save directory. This is important for - trigger conversions and extracting targeteness. + trigger conversions and extracting targetness. - *note*: this is not compatible with older versions of BciPy (pre 1.5.0) where + Note: + Not compatible with older versions of BciPy (pre 1.5.0) where the tasks and modes were considered together using integers (1, 2, 3). - PARAMETERS - ---------- - :param: bcipy_data_directory: string path to the data directory + Args: + bcipy_data_directory: Path to the data directory. + + Returns: + str: The extracted mode ('calibration' or 'copy_phrase'). + + Raises: + BciPyCoreException: If no valid mode could be extracted. """ directory = bcipy_data_directory.lower() if 'calibration' in directory: return 'calibration' elif 'copy' in directory: return 'copy_phrase' - raise BciPyCoreException(f'No valid mode could be extracted from [{directory}]') + raise BciPyCoreException( + f'No valid mode could be extracted from [{directory}]') def load_fields(path: str = f'{DEFAULT_FIELD_PATH}/{FIELD_FILENAME}') -> dict: - """Load Fields. + """Load field definitions from a JSON file. - PARAMETERS - ---------- - :param: path: string path to the fields file. + Args: + path: Path to the fields file. - Returns - ------- - A dictionary of fields, with the following format: + Returns: + dict: Dictionary of fields with format: { "field_name": { - "help_text": "", - "type": "" + "help_text": str, + "type": str + } } - """ with open(path, 'r', encoding=DEFAULT_ENCODING) as json_file: return json.load(json_file) def load_experiment_fields(experiment: dict) -> list: - """Load Experiment Fields. + """Extract field names from an experiment configuration. - { - 'fields': [{}, {}], - 'summary': '' - } + Args: + experiment: Dictionary containing experiment configuration with format: + { + 'fields': [{field_dict}, {field_dict}], + 'summary': str + } - Using the experiment dictionary, loop over the field keys and put them in a list. + Returns: + list: List of field names from the experiment configuration. + + Raises: + InvalidExperimentException: If experiment format is incorrect. + TypeError: If experiment is not a dictionary. """ if isinstance(experiment, dict): try: @@ -122,55 +144,66 @@ def load_experiment_fields(experiment: dict) -> list: raise InvalidExperimentException( 'Experiment is not formatted correctly. It should be passed as a dictionary with the fields and' f' summary keys. Fields is a list of dictionaries. Summary is a string. \n experiment=[{experiment}]') - raise TypeError('Unsupported experiment type. It should be passed as a dictionary with the fields and summary keys') + raise TypeError( + 'Unsupported experiment type. It should be passed as a dictionary with the fields and summary keys') def load_json_parameters(path: str, value_cast: bool = False) -> Parameters: - """Load JSON Parameters. - - Given a path to a json of parameters, convert to a dictionary and optionally - cast the type. - - Expects the following format: - "fake_data": { - "value": "true", - "section": "bci_config", - "name": "Fake Data Sessions", - "helpTip": "If true, fake data server used", - "recommended": "", - "editable": "true", - "type": "bool" - } + """Load and parse parameters from a JSON file. - PARAMETERS - ---------- - :param: path: string path to the parameters file. - :param: value_case: True/False cast values to specified type. + Args: + path: Path to the parameters file. + value_cast: Whether to cast values to their specified types. - Returns - ------- - a Parameters object that behaves like a dict. + Returns: + Parameters: A Parameters object containing the loaded configuration. + + Note: + Expected JSON format: + { + "parameter_name": { + "value": str, + "section": str, + "name": str, + "helpTip": str, + "recommended": str, + "editable": str, + "type": str + } + } """ return Parameters(source=path, cast_values=value_cast) def load_experimental_data(message='', strict=False) -> str: - filename = ask_directory(prompt=message, strict=strict) # show dialog box and return the path + """Show a dialog to select an experimental data directory. + + Args: + message: Optional prompt message for the dialog. + strict: Whether to enforce strict directory selection. + + Returns: + str: Path to the selected directory. + """ + filename = ask_directory(prompt=message, strict=strict) log.info("Loaded Experimental Data From: %s" % filename) return filename def load_signal_models(directory: Optional[str] = None) -> List[SignalModel]: - """Load all signal models in a given directory. + """Load all signal models from a directory. Models are assumed to have been written using bcipy.helpers.save.save_model - function and should be serialized as pickled files. Note that reading - pickled files is a potential security concern so only load from trusted - directories. + function and should be serialized as pickled files. Args: - dirname (str, optional): Location of pretrained models. If not - provided the user will be prompted for a location. + directory: Location of pretrained models. User will be prompted if not provided. + + Returns: + list: List of loaded SignalModel instances. + + Warning: + Reading pickled files is a potential security risk. Only load from trusted directories. """ if not directory or Path(directory).is_file(): directory = ask_directory() @@ -191,9 +224,11 @@ def load_signal_models(directory: Optional[str] = None) -> List[SignalModel]: def choose_signal_models(device_types: List[str]) -> List[SignalModel]: """Prompt the user to load a signal model for each provided device. - Parameters - ---------- - device_types - list of device content types (ex. 'EEG') + Args: + device_types: List of device content types (e.g., 'EEG'). + + Returns: + list: List of selected SignalModel instances. """ return [ model for model in map(choose_signal_model, set(device_types)) if model @@ -203,11 +238,15 @@ def choose_signal_models(device_types: List[str]) -> List[SignalModel]: def load_signal_model(file_path: str) -> SignalModel: """Load signal model from persisted file. - Models are assumed to have been written using bcipy.io.save.save_model - function and should be serialized as pickled files. Note that reading - pickled files is a potential security concern so only load from trusted - directories.""" + Args: + file_path: Path to the model file. + + Returns: + SignalModel: The loaded signal model. + Warning: + Reading pickled files is a potential security risk. Only load from trusted sources. + """ with open(file_path, "rb") as signal_file: model = pickle.load(signal_file) log.info(f"Loading model {model}") @@ -215,15 +254,15 @@ def load_signal_model(file_path: str) -> SignalModel: def choose_signal_model(device_type: str) -> Optional[SignalModel]: - """Present a file dialog prompting the user to select a signal model for - the given device. + """Present a file dialog prompting the user to select a signal model. - Parameters - ---------- - device_type - ex. 'EEG' or 'Eyetracker'; this should correspond with - the content_type of the DeviceSpec of the model. - """ + Args: + device_type: Device type (e.g., 'EEG' or 'Eyetracker') that should correspond + with the content_type of the DeviceSpec of the model. + Returns: + Optional[SignalModel]: The selected signal model, or None if no selection made. + """ file_path = ask_filename(file_types=f"*{SIGNAL_MODEL_FILE_SUFFIX}", directory=preferences.signal_model_directory, prompt=f"Select the {device_type} signal model") @@ -237,7 +276,14 @@ def choose_signal_model(device_type: str) -> Optional[SignalModel]: def choose_model_paths(device_types: List[str]) -> List[Path]: - """Select a model for each device and return a list of paths.""" + """Select a model for each device and return a list of paths. + + Args: + device_types: List of device types to load models for. + + Returns: + list: List of paths to selected model files. + """ return [ ask_filename(file_types=f"*{SIGNAL_MODEL_FILE_SUFFIX}", directory=preferences.signal_model_directory, @@ -247,15 +293,16 @@ def choose_model_paths(device_types: List[str]) -> List[Path]: def choose_csv_file(filename: Optional[str] = None) -> Optional[str]: - """GUI prompt to select a csv file from the file system. + """GUI prompt to select a CSV file from the file system. - Parameters - ---------- - - filename : optional filename to use; if provided the GUI is not shown. + Args: + filename: Optional filename to use; if provided the GUI is not shown. + + Returns: + Optional[str]: Path to selected file. - Returns - ------- - file name of selected file; throws an exception if the file is not a csv. + Raises: + Exception: If the selected file is not a CSV file. """ if not filename: filename = ask_filename('*.csv') @@ -271,25 +318,26 @@ def choose_csv_file(filename: Optional[str] = None) -> Optional[str]: def load_raw_data(filename: Union[Path, str]) -> RawData: - """Reads the data (.csv) file written by data acquisition. + """Read data from a CSV file written by data acquisition. - Parameters - ---------- - - filename : path to the serialized data (csv file) + Args: + filename: Path to the serialized data (CSV file). - Returns - ------- - RawData object with data held in memory + Returns: + RawData: Object containing the loaded data in memory. """ return RawData.load(filename) def load_users(data_save_loc: str) -> List[str]: - """Load Users. + """Load user directory names from the data path. - Loads user directory names below experiments from the data path defined and returns them as a list. - If the save data directory is not found, this method returns an empty list assuming no experiments - have been run yet. + Args: + data_save_loc: Path to the data directory. + + Returns: + list: List of user IDs found in the directory. Returns empty list if + directory not found (assuming no experiments have been run). """ try: bcipy_data = BciPyCollection(data_directory=data_save_loc) @@ -300,11 +348,14 @@ def load_users(data_save_loc: str) -> List[str]: def fast_scandir(directory_name: str, return_path: bool = True) -> List[str]: - """Fast Scan Directory. + """Quickly scan a directory for subdirectories. - directory_name: name of the directory to be scanned - return_path: whether or not to return the scanned directories as a relative path or name. - False will return the directory name only. + Args: + directory_name: Name of the directory to scan. + return_path: Whether to return full paths (True) or just names (False). + + Returns: + list: List of subdirectory paths or names. """ if return_path: return [f.path for f in os.scandir(directory_name) if f.is_dir()] @@ -313,17 +364,38 @@ def fast_scandir(directory_name: str, return_path: bool = True) -> List[str]: class BciPySessionTaskData: - """Session Task Data. + """Class representing data from a single BciPy task session. - This class is used to represent a single task session. It is used to store the - path to the task data, as well as the parameters and other information about the task. + This class is used to store the path to the task data, as well as parameters + and other information about the task. - // - protocol.json - / - parameters.json - **task_data** + Directory structure: + // + protocol.json + / + parameters.json + **task_data** + Args: + path: Path to the session data. + user_id: ID of the user who performed the task. + experiment_id: ID of the experiment the task belongs to. + date_time: Optional timestamp of task execution. + date: Optional date of task execution. + task_name: Optional name of the executed task. + session_id: Session identifier number, defaults to 1. + run: Run number within the session, defaults to 1. + + Attributes: + user_id: ID of the user who performed the task. + experiment_id: ID of the experiment (with underscores removed). + session_id: Formatted session ID (zero-padded if < 10). + date_time: Timestamp of task execution. + date: Date of task execution. + run: Formatted run number (zero-padded if < 10). + path: Path to the session data. + task_name: Name of the executed task. + info: Dictionary containing all session information. """ def __init__( @@ -339,7 +411,8 @@ def __init__( self.user_id = user_id self.experiment_id = experiment_id.replace('_', '') - self.session_id = f'0{str(session_id)}' if session_id < 10 else str(session_id) + self.session_id = f'0{str(session_id)}' if session_id < 10 else str( + session_id) self.date_time = date_time self.date = date self.run = f'0{str(run)}' if run < 10 else str(run) @@ -364,10 +437,34 @@ def __repr__(self): class BciPyCollection: - """BciPy Data. + """Class for managing collections of BciPy session task data. + + This class is used to collect data from the data directory and filter based + on the provided filters. - This class is used to represent a full BciPy data collection. It is used to collect data from the - data directory and filter based on the provided filters. + Args: + data_directory: Root directory containing BciPy data. + experiment_id_filter: Optional filter for specific experiments. + user_id_filter: Optional filter for specific users. + date_filter: Optional filter for specific dates. + date_time_filter: Optional filter for specific timestamps. + excluded_tasks: Optional list of task names to exclude. + anonymize: Whether to anonymize user data. + + Attributes: + data_directory: Root directory containing BciPy data. + experiment_id_filter: Filter for specific experiments. + user_id_filter: Filter for specific users. + date_filter: Filter for specific dates. + date_time_filter: Filter for specific timestamps. + excluded_tasks: List of task names to exclude. + anonymize: Whether to anonymize user data. + session_task_data: List of collected BciPySessionTaskData objects. + user_paths: List of paths to user directories. + date_paths: List of paths to date directories. + experiment_paths: List of paths to experiment directories. + date_time_paths: List of paths to datetime directories. + task_paths: List of paths to task directories. """ def __init__( @@ -407,36 +504,64 @@ def __str__(self): @property def users(self) -> List[str]: + """Get list of users in the collection. + + Returns: + list: List of user IDs. + """ return [user.split('/')[-1] for user in self.user_paths] @property def experiments(self) -> List[str]: - experiments = [experiment.split('/')[-1] for experiment in self.experiment_paths] + """Get list of unique experiments in the collection. + + Returns: + list: List of experiment IDs. + """ + experiments = [experiment.split('/')[-1] + for experiment in self.experiment_paths] # remove duplicates from the list return list(set(experiments)) @property def dates(self) -> List[str]: + """Get list of unique dates in the collection. + + Returns: + list: List of dates. + """ dates = [date.split('/')[-1] for date in self.date_paths] # remove duplicates from the list return list(set(dates)) @property def date_times(self) -> List[str]: - date_times = [date_time.split('/')[-1] for date_time in self.date_time_paths] + """Get list of unique timestamps in the collection. + + Returns: + list: List of timestamps. + """ + date_times = [date_time.split('/')[-1] + for date_time in self.date_time_paths] # remove duplicates from the list return list(set(date_times)) @property def tasks(self) -> List[str]: + """Get list of unique tasks in the collection. + + Returns: + list: List of task names. + """ tasks = [task.task_name for task in self.session_task_data] # remove duplicates from the list return list(set(tasks)) def collect(self) -> List[BciPySessionTaskData]: - """Collect. + """Collect BciPy data from the data directory. - Collects the BciPy data from the data directory and returns a list of BciPySessionTaskData objects. + Returns: + list: List of BciPySessionTaskData objects representing the experiment data. """ if not self.session_task_data: self.load_tasks() @@ -455,20 +580,21 @@ def collect(self) -> List[BciPySessionTaskData]: return self.session_task_data def load_users(self) -> None: - """Load Users. + """Load user paths from the data directory. - Walks the data directory and sets the user paths. It will filter by the user id if provided. + Walks the data directory and sets the user paths. Filters by user ID if provided. """ user_paths = fast_scandir(self.data_directory, return_path=True) if self.user_id_filter: - self.user_paths = [user for user in user_paths if self.user_id_filter in user] + self.user_paths = [ + user for user in user_paths if self.user_id_filter in user] else: self.user_paths = user_paths def load_dates(self) -> None: - """Load Dates. + """Load date paths from the data directory. - Walks the data directory and sets the date paths. It will filter by the date if provided. + Walks the data directory and sets the date paths. Filters by date if provided. """ if not self.user_paths: self.load_users() @@ -476,14 +602,15 @@ def load_dates(self) -> None: for user in self.user_paths: data_paths = fast_scandir(user, return_path=True) if self.date_filter: - self.date_paths.extend([data for data in data_paths if self.date_filter in data]) + self.date_paths.extend( + [data for data in data_paths if self.date_filter in data]) else: self.date_paths.extend(data_paths) def load_experiments(self) -> None: - """Load Experiments. + """Load experiment paths from the data directory. - Walks the data directory and sets the experiment paths. It will filter by the experiment id if provided. + Walks the data directory and sets the experiment paths. Filters by experiment ID if provided. """ if not self.date_paths: self.load_dates() @@ -491,14 +618,15 @@ def load_experiments(self) -> None: for date in self.date_paths: experiment_paths = fast_scandir(date, return_path=True) if self.experiment_id_filter: - self.experiment_paths.extend([data for data in experiment_paths if self.experiment_id_filter in data]) + self.experiment_paths.extend( + [data for data in experiment_paths if self.experiment_id_filter in data]) else: self.experiment_paths.extend(experiment_paths) def load_date_times(self) -> None: - """Load Date Times. + """Load datetime paths from the data directory. - Walks the data directory and sets the date time paths. It will filter by the date time if provided. + Walks the data directory and sets the datetime paths. Filters by datetime if provided. """ if not self.experiment_paths: self.load_experiments() @@ -506,22 +634,27 @@ def load_date_times(self) -> None: for experiment in self.experiment_paths: data_paths = fast_scandir(experiment, return_path=True) if self.date_time_filter: - self.date_time_paths.extend([data for data in data_paths if self.date_time_filter in data]) + self.date_time_paths.extend( + [data for data in data_paths if self.date_time_filter in data]) else: self.date_time_paths.extend(data_paths) def sort_tasks(self, tasks: List[str]) -> List[str]: - """Sort Tasks. + """Sort tasks by their timestamp. + + Args: + tasks: List of task paths to sort. - Sorts the tasks in the order they were run using the timestamp at the end of the task path. + Returns: + list: Sorted list of task paths. """ return sorted(tasks, key=lambda x: x.split('_')[-1]) def load_tasks(self) -> None: - """Load Tasks. + """Load task data from the data directory. Walks the data directory and sets the session_task_data representing the experiment data. - It will exclude tasks that are in the excluded_tasks list. + Excludes tasks that are in the excluded_tasks list. """ if not self.date_time_paths: self.load_date_times() @@ -575,33 +708,32 @@ def load_bcipy_data( date_time: Optional[str] = None, excluded_tasks: Optional[List[str]] = None, anonymize: bool = False) -> List[BciPySessionTaskData]: - """Load BciPy Data. - - Walks a data directory and returns a list of data paths for the given experiment id, user id, and date. - - The BciPy data directory is structured as follows: - data/ - user_ids/ - dates/ - experiment_ids/ - datetimes/ - protocol.json - logs/ - tasks/ - raw_data.csv - triggers.txt - - data_directory: the bcipy data directory to walk - experiment_id: the experiment id to filter by - user_id: the user id to filter by - date: the date to filter by - date_time: the date time to filter by - excluded_tasks: a list of tasks to exclude from the returned list of experiment data - anonymize: whether or not to anonymize the user ids + """Load BciPy data from a directory. + + Args: + data_directory: The BciPy data directory to walk. + experiment_id: Optional experiment ID to filter by. + user_id: Optional user ID to filter by. + date: Optional date to filter by. + date_time: Optional datetime to filter by. + excluded_tasks: Optional list of tasks to exclude. + anonymize: Whether to anonymize user IDs. Returns: - -------- - a list of BciPySessionTaskData objects representing the experiment data + list: List of BciPySessionTaskData objects representing the experiment data. + + Note: + The BciPy data directory is structured as follows: + data/ + user_ids/ + dates/ + experiment_ids/ + datetimes/ + protocol.json + logs/ + tasks/ + raw_data.csv + triggers.txt """ if not excluded_tasks: excluded_tasks = [] diff --git a/bcipy/io/save.py b/bcipy/io/save.py index 197adbceb..7578e6c01 100644 --- a/bcipy/io/save.py +++ b/bcipy/io/save.py @@ -38,6 +38,17 @@ def save_experiment_data( fields: dict, location: str, name: str) -> str: + """Save experiment data to a JSON file. + + Args: + experiments (dict): Experiment data to save. + fields (dict): Additional fields to save. + location (str): Directory to save the file. + name (str): Name of the file. + + Returns: + str: Path to the saved file. + """ return save_json_data(experiments, location, name) @@ -45,6 +56,16 @@ def save_field_data( fields: dict, location: str, name: str) -> str: + """Save field data to a JSON file. + + Args: + fields (dict): Field data to save. + location (str): Directory to save the file. + name (str): Name of the file. + + Returns: + str: Path to the saved file. + """ return save_json_data(fields, location, name) @@ -52,6 +73,16 @@ def save_experiment_field_data( data: dict, location: str, name: str) -> str: + """Save experiment field data to a JSON file. + + Args: + data (dict): Data to save. + location (str): Directory to save the file. + name (str): Name of the file. + + Returns: + str: Path to the saved file. + """ return save_json_data(data, location, name) @@ -95,7 +126,8 @@ def init_save_data_structure(data_save_path: str, copyfile(parameters, Path(save_directory, DEFAULT_PARAMETERS_FILENAME)) - copyfile(DEFAULT_LM_PARAMETERS_PATH, Path(save_directory, DEFAULT_LM_PARAMETERS_FILENAME)) + copyfile(DEFAULT_LM_PARAMETERS_PATH, Path( + save_directory, DEFAULT_LM_PARAMETERS_FILENAME)) return save_directory @@ -186,8 +218,8 @@ def save_stimuli_position_info( screen_info: Dict[str, Any]) -> str: """Save stimuli positions and screen info to `path` - stimuli_position_info: {'A': (0, 0)} - screen_info: {'screen_size_pixels': [1920, 1080], 'screen_refresh': 160} + stimuli_position_info: {'A': (0, 0)} + screen_info: {'screen_size_pixels': [1920, 1080], 'screen_refresh': 160} Parameters ---------- diff --git a/bcipy/io/tests/test_convert.py b/bcipy/io/tests/test_convert.py index 882779b45..ec26682e1 100644 --- a/bcipy/io/tests/test_convert.py +++ b/bcipy/io/tests/test_convert.py @@ -47,8 +47,10 @@ def create_bcipy_session_artifacts( if isinstance(channels, int): channels = [CHANNEL_NAMES[i] for i in range(channels)] - data = sample_data(ch_names=channels, daq_type='SampleDevice', sample_rate=sample_rate, rows=samples) - devices.register(devices.DeviceSpec('SampleDevice', channels=channels, sample_rate=sample_rate)) + data = sample_data(ch_names=channels, daq_type='SampleDevice', + sample_rate=sample_rate, rows=samples) + devices.register(devices.DeviceSpec( + 'SampleDevice', channels=channels, sample_rate=sample_rate)) with open(Path(write_dir, TRIGGER_FILENAME), 'w', encoding=DEFAULT_ENCODING) as trg_file: trg_file.write(trg_data) @@ -75,7 +77,8 @@ class TestBIDSConversion(unittest.TestCase): def setUp(self): self.temp_dir = tempfile.mkdtemp() - self.trg_data, self.data, self.params = create_bcipy_session_artifacts(self.temp_dir) + self.trg_data, self.data, self.params = create_bcipy_session_artifacts( + self.temp_dir) def tearDown(self): shutil.rmtree(self.temp_dir) @@ -240,8 +243,10 @@ def setUp(self): 'down_sampling_rate': 3 } self.channels = ['timestamp', 'O1', 'O2', 'Pz', 'TRG', 'lsl_timestamp'] - self.raw_data = RawData('SampleDevice', self.sample_rate, self.channels) - devices.register(devices.DeviceSpec('SampleDevice', channels=self.channels, sample_rate=self.sample_rate)) + self.raw_data = RawData( + 'SampleDevice', self.sample_rate, self.channels) + devices.register(devices.DeviceSpec( + 'SampleDevice', channels=self.channels, sample_rate=self.sample_rate)) # generate 100 random samples of data for _ in range(0, 100): @@ -425,7 +430,8 @@ def test_tobii_to_norm(self): self.assertEqual(norm_data, excepted_norm_data) tobii_data = (1, 1) # bottom right of screen in tobii coordinates - excepted_norm_data = (1, -1) # bottom right of screen in norm coordinates + # bottom right of screen in norm coordinates + excepted_norm_data = (1, -1) norm_data = tobii_to_norm(tobii_data) self.assertEqual(norm_data, excepted_norm_data) @@ -443,7 +449,8 @@ def test_tobii_to_norm_raises_error_with_invalid_units(self): def test_norm_to_tobii(self): """Test the norm_to_tobii function""" norm_data = (0, 0) # center of screen in norm coordinates - excepted_tobii_data = (0.5, 0.5) # center of screen in tobii coordinates + # center of screen in tobii coordinates + excepted_tobii_data = (0.5, 0.5) tobii_data = norm_to_tobii(norm_data) self.assertEqual(tobii_data, excepted_tobii_data) @@ -453,7 +460,8 @@ def test_norm_to_tobii(self): self.assertEqual(tobii_data, excepted_tobii_data) norm_data = (1, -1) # bottom right of screen in norm coordinates - excepted_tobii_data = (1, 1) # bottom right of screen in tobii coordinates + # bottom right of screen in tobii coordinates + excepted_tobii_data = (1, 1) tobii_data = norm_to_tobii(norm_data) self.assertEqual(tobii_data, excepted_tobii_data) @@ -472,7 +480,8 @@ class TestConvertETBIDS(unittest.TestCase): def setUp(self): self.temp_dir = tempfile.mkdtemp() - self.trg_data, self.data, self.params = create_bcipy_session_artifacts(self.temp_dir, channels=3) + self.trg_data, self.data, self.params = create_bcipy_session_artifacts( + self.temp_dir, channels=3) self.eyetracking_data = sample_data( ch_names=[ 'timestamp', @@ -482,7 +491,8 @@ def setUp(self): daq_type='Gaze', sample_rate=60, rows=5000) - devices.register(devices.DeviceSpec('Gaze', channels=['timestamp', 'x', 'y', 'pupil'], sample_rate=60)) + devices.register(devices.DeviceSpec('Gaze', channels=[ + 'timestamp', 'x', 'y', 'pupil'], sample_rate=60)) write(self.eyetracking_data, Path(self.temp_dir, 'eyetracker.csv')) @@ -503,7 +513,8 @@ def test_convert_eyetracking_to_bids_generates_bids_strucutre(self): # Assert the session directory was created with et self.assertTrue(os.path.exists(f"{self.temp_dir}/et/")) # Assert the et tsv file was created with the correct name - self.assertTrue(os.path.exists(f"{self.temp_dir}/et/sub-01_ses-01_task-TestTask_run-01_eyetracking.tsv")) + self.assertTrue(os.path.exists( + f"{self.temp_dir}/et/sub-01_ses-01_task-TestTask_run-01_eyetracking.tsv")) def test_convert_eyetracking_to_bids_reflects_participant_id(self): """Test the convert_eyetracking_to_bids function with a participant id""" @@ -517,7 +528,8 @@ def test_convert_eyetracking_to_bids_reflects_participant_id(self): ) self.assertTrue(os.path.exists(response)) # Assert the et tsv file was created with the correct name - self.assertTrue(os.path.exists(f"{self.temp_dir}/et/sub-100_ses-01_task-TestTask_run-01_eyetracking.tsv")) + self.assertTrue(os.path.exists( + f"{self.temp_dir}/et/sub-100_ses-01_task-TestTask_run-01_eyetracking.tsv")) def test_convert_eyetracking_to_bids_reflects_session_id(self): """Test the convert_eyetracking_to_bids function with a session id""" @@ -531,7 +543,8 @@ def test_convert_eyetracking_to_bids_reflects_session_id(self): ) self.assertTrue(os.path.exists(response)) # Assert the et tsv file was created with the correct name - self.assertTrue(os.path.exists(f"{self.temp_dir}/et/sub-01_ses-100_task-TestTask_run-01_eyetracking.tsv")) + self.assertTrue(os.path.exists( + f"{self.temp_dir}/et/sub-01_ses-100_task-TestTask_run-01_eyetracking.tsv")) def test_convert_eyetracking_to_bids_reflects_run_id(self): """Test the convert_eyetracking_to_bids function with a run id""" @@ -545,7 +558,8 @@ def test_convert_eyetracking_to_bids_reflects_run_id(self): ) self.assertTrue(os.path.exists(response)) # Assert the et tsv file was created with the correct name - self.assertTrue(os.path.exists(f"{self.temp_dir}/et/sub-01_ses-01_task-TestTask_run-100_eyetracking.tsv")) + self.assertTrue(os.path.exists( + f"{self.temp_dir}/et/sub-01_ses-01_task-TestTask_run-100_eyetracking.tsv")) def test_convert_eyetracking_to_bids_reflects_task_name(self): """Test the convert_eyetracking_to_bids function with a task name""" @@ -559,7 +573,8 @@ def test_convert_eyetracking_to_bids_reflects_task_name(self): ) self.assertTrue(os.path.exists(response)) # Assert the et tsv file was created with the correct name - self.assertTrue(os.path.exists(f"{self.temp_dir}/et/sub-01_ses-01_task-TestTaskEtc_run-01_eyetracking.tsv")) + self.assertTrue(os.path.exists( + f"{self.temp_dir}/et/sub-01_ses-01_task-TestTaskEtc_run-01_eyetracking.tsv")) def test_convert_et_raises_error_with_invalid_data_dir(self): """Test the convert_eyetracking_to_bids function raises an error with invalid output directory""" @@ -633,7 +648,8 @@ def test_successful_conversion(self, mock_read_raw_bids, mock_find_matching_path mock_bids_path1.task = 'RSVPCalibration' mock_bids_path2 = MagicMock() mock_bids_path2.task = 'RSVPCalibration' - mock_find_matching_paths.return_value = [mock_bids_path1, mock_bids_path2] + mock_find_matching_paths.return_value = [ + mock_bids_path1, mock_bids_path2] # Create mock Raw objects that read_raw_bids will return mock_raw1 = MagicMock(spec=mne.io.Raw) @@ -646,7 +662,8 @@ def test_successful_conversion(self, mock_read_raw_bids, mock_find_matching_path self.assertIs(result[0], mock_raw1) self.assertIs(result[1], mock_raw2) mock_path_exists.assert_called_once_with('/fake/bids/path') - mock_get_entity_vals.assert_called_once_with('/fake/bids/path', 'session') + mock_get_entity_vals.assert_called_once_with( + '/fake/bids/path', 'session') mock_find_matching_paths.assert_called_once() self.assertEqual(mock_read_raw_bids.call_count, 2) @@ -673,14 +690,16 @@ def test_no_matching_files(self, mock_find_matching_paths, mock_get_entity_vals, BIDS_to_MNE('/fake/bids/path') mock_path_exists.assert_called_once_with('/fake/bids/path') - mock_get_entity_vals.assert_called_once_with('/fake/bids/path', 'session') + mock_get_entity_vals.assert_called_once_with( + '/fake/bids/path', 'session') mock_find_matching_paths.assert_called_once() @patch('bcipy.io.convert.os.path.exists') @patch('bcipy.io.convert.get_entity_vals') @patch('bcipy.io.convert.find_matching_paths') @patch('bcipy.io.convert.read_raw_bids') - @patch('bcipy.io.convert.logger') # Mock the logger to prevent actual logging during tests + # Mock the logger to prevent actual logging during tests + @patch('bcipy.io.convert.logger') def test_task_filtering(self, mock_logger, mock_read_raw_bids, mock_find_matching_paths, mock_get_entity_vals, mock_path_exists): """Test task name filtering.""" @@ -693,7 +712,8 @@ def test_task_filtering(self, mock_logger, mock_read_raw_bids, mock_find_matchin mock_bids_path1.task = 'RSVPCalibration' mock_bids_path2 = MagicMock() mock_bids_path2.task = 'OtherTask' - mock_find_matching_paths.return_value = [mock_bids_path1, mock_bids_path2] + mock_find_matching_paths.return_value = [ + mock_bids_path1, mock_bids_path2] # Create mock Raw object that read_raw_bids will return mock_raw = MagicMock(spec=mne.io.Raw) @@ -725,7 +745,8 @@ def test_multiple_sessions(self, mock_read_raw_bids, mock_find_matching_paths, mock_bids_path2 = MagicMock() mock_bids_path2.task = 'RSVPCalibration' mock_bids_path2.session = '02' - mock_find_matching_paths.return_value = [mock_bids_path1, mock_bids_path2] + mock_find_matching_paths.return_value = [ + mock_bids_path1, mock_bids_path2] # Create mock Raw objects mock_raw1 = MagicMock(spec=mne.io.Raw) @@ -735,7 +756,8 @@ def test_multiple_sessions(self, mock_read_raw_bids, mock_find_matching_paths, result = BIDS_to_MNE('/fake/bids/path') self.assertEqual(len(result), 2) - mock_get_entity_vals.assert_called_once_with('/fake/bids/path', 'session') + mock_get_entity_vals.assert_called_once_with( + '/fake/bids/path', 'session') self.assertEqual(mock_read_raw_bids.call_count, 2) @patch('bcipy.io.convert.os.path.exists') @@ -777,7 +799,8 @@ def test_debug_logging(self, mock_logger, mock_read_raw_bids, mock_find_matching mock_bids_path1.task = 'RSVPCalibration' mock_bids_path2 = MagicMock() mock_bids_path2.task = 'OtherTask' - mock_find_matching_paths.return_value = [mock_bids_path1, mock_bids_path2] + mock_find_matching_paths.return_value = [ + mock_bids_path1, mock_bids_path2] # Mock the debug logging method specifically mock_logger.debug = MagicMock() @@ -789,7 +812,8 @@ def test_debug_logging(self, mock_logger, mock_read_raw_bids, mock_find_matching BIDS_to_MNE('/fake/bids/path', task_name='RSVPCalibration') # Check if debug was called for skipping a file - self.assertTrue(any('Skipping' in str(args) for args, _ in mock_logger.debug.call_args_list)) + self.assertTrue(any('Skipping' in str(args) + for args, _ in mock_logger.debug.call_args_list)) if __name__ == '__main__': diff --git a/bcipy/io/tests/test_load.py b/bcipy/io/tests/test_load.py index d170efbc3..cdc4cd379 100644 --- a/bcipy/io/tests/test_load.py +++ b/bcipy/io/tests/test_load.py @@ -89,7 +89,8 @@ def tearDown(self): def test_load_experiments_calls_open_with_expected_default(self): with patch('builtins.open', mock_open(read_data='data')) as mock_file: load_experiments() - mock_file.assert_called_with(self.experiments_path, 'r', encoding=DEFAULT_ENCODING) + mock_file.assert_called_with( + self.experiments_path, 'r', encoding=DEFAULT_ENCODING) def test_load_experiments_throws_file_not_found_exception_with_invalid_path(self): with self.assertRaises(FileNotFoundError): @@ -112,7 +113,8 @@ def tearDown(self): def test_load_fields_calls_open_with_expected_default(self): with patch('builtins.open', mock_open(read_data='data')) as mock_file: load_fields() - mock_file.assert_called_with(self.fields_path, 'r', encoding=DEFAULT_ENCODING) + mock_file.assert_called_with( + self.fields_path, 'r', encoding=DEFAULT_ENCODING) def test_load_fields_throws_file_not_found_exception_with_invalid_path(self): with self.assertRaises(FileNotFoundError): @@ -375,7 +377,8 @@ def test_load_bcipy_data_with_experiment_id_filter(self): total_expected_files = len(self.user_ids) * len(self.dates) * (len(self.experiment_ids) - 1) * \ len(self.datetimes) * len(self.tasks) - response = load_bcipy_data(self.data_dir, experiment_id=desired_experiment_id) + response = load_bcipy_data( + self.data_dir, experiment_id=desired_experiment_id) self.assertEqual(len(response), total_expected_files) experiments = [file.experiment_id for file in response] diff --git a/bcipy/io/tests/test_save.py b/bcipy/io/tests/test_save.py index b272497ee..78b63fff5 100644 --- a/bcipy/io/tests/test_save.py +++ b/bcipy/io/tests/test_save.py @@ -99,11 +99,14 @@ def tearDown(self): shutil.rmtree(self.save_directory) def test_save_stimuli_position_info_writes_json(self): - save_stimuli_position_info(self.stimuli_positions, self.save_directory, self.screen_info) - self.assertTrue(os.path.isfile(os.path.join(self.save_directory, self.filename))) + save_stimuli_position_info( + self.stimuli_positions, self.save_directory, self.screen_info) + self.assertTrue(os.path.isfile( + os.path.join(self.save_directory, self.filename))) def test_save_stimuli_position_info_writes_correct_json(self): - save_stimuli_position_info(self.stimuli_positions, self.save_directory, self.screen_info) + save_stimuli_position_info( + self.stimuli_positions, self.save_directory, self.screen_info) # load the json file with open(os.path.join(self.save_directory, self.filename)) as f: data = json.load(f) diff --git a/bcipy/language/README.md b/bcipy/language/README.md index c54ca8e35..49d5c4703 100644 --- a/bcipy/language/README.md +++ b/bcipy/language/README.md @@ -29,22 +29,22 @@ The language module has the following structure: The UniformLanguageModel provides equal probabilities for all symbols in the symbol set. This model is useful for evaluating other aspects of the system, such as EEG signal quality, without any influence from a language model. ## NGram Model + The NGramLanguageModelAdapter utilizes a pretrained n-gram language model to generate probabilities for all symbols in the symbol set. N-gram models use frequencies of different character sequences to generate their predictions. Models trained on AAC-like data can be found [here](https://imagineville.org/software/lm/dec19_char/). For faster load times, it is recommended to use the binary models located at the bottom of the page. The default parameters file utilizes `lm_dec19_char_large_12gram.kenlm`. If you have issues accessing, please reach out to us on GitHub or via email at `cambi_support@googlegroups.com`. For models that import the kenlm module, this must be manually installed using `pip install kenlm==0.1 --global-option="max_order=12"`. ## Causal Model + The CausalLanguageModelAdapter class can use any causal language model from Huggingface, though it has only been tested with gpt2, facebook/opt, and distilgpt2 families of models (including the domain-adapted figmtu/opt-350m-aac). Causal language models predict the next token in a sequence of tokens. For the many of these models, byte-pair encoding (BPE) is used for tokenization. The main idea of BPE is to create a fixed-size vocabulary that contains common English subword units. Then a less common word would be broken down into several subword units in the vocabulary. For example, the tokenization of character sequence `peanut_butter_and_jel` would be: > *['pe', 'anut', '_butter', '_and', '_j', 'el']* Therefore, in order to generate a predictive distribution on the next character, we need to examine all the possibilities that could complete the final subword tokens in the input sequences. We must remove at least one token from the end of the context to allow the model the option of extending it, as opposed to only adding a new token. Removing more tokens allows the model more flexibility and may lead to better predictions, but at the cost of a higher prediction time. In this model we remove all of the subword tokens in the current (partially-typed) word to allow it the most flexibility. We then ask the model to estimate the likelihood of the next token and evaluate each token that matches our context. For efficiency, we only track a certain number of hypotheses at a time, known as the beam width, and each hypothesis until it surpasses the context. We can then store the likelihood for each final prediction in a list based on the character that directly follows the context. Once we have no more hypotheses to extend, we can sum the likelihoods stored for each character in our symbol set and normalize so they sum to 1, giving us our final distribution. More details on this process can be found in our paper, [Adapting Large Language Models for Character-based Augmentative and Alternative Communication](https://arxiv.org/abs/2501.10582). - ## Mixture Model -The MixtureLanguageModelAdapter class allows for the combination of two or more supported models. The selected models are mixed according to the provided weights, which can be tuned using the Bcipy/scripts/python/mixture_tuning.py script. It is not recommended to use more than one "heavy-weight" model with long prediction times (the CausalLanguageModel) since this model will query each component model and parallelization is not currently supported. - -# Contact Information -For language model related questions, please contact Dylan Gaines (dcgaines [[at](https://en.wikipedia.org/wiki/At_sign)] mtu.edu) or create an issue. +The MixtureLanguageModelAdapter class allows for the combination of two or more supported models. The selected models are mixed according to the provided weights. It is not recommended to use more than one "heavy-weight" model with long prediction times (the CausalLanguageModel) since this model will query each component model and parallelization is not currently supported. +## Contact Information +For language model related questions, please contact Dylan Gaines (dcgaines [[at](https://en.wikipedia.org/wiki/At_sign)] mtu.edu) or create a GitHub issue. diff --git a/bcipy/language/demo/demo_ngram.py b/bcipy/language/demo/demo_ngram.py index 54f146460..e02867e3f 100644 --- a/bcipy/language/demo/demo_ngram.py +++ b/bcipy/language/demo/demo_ngram.py @@ -57,7 +57,8 @@ # file zebras.txt: 1 sentences, 14 words, 0 OOVs # 0 zeroprobs, logprob= -15.2391 ppl= 10.374 ppl1= 12.260 sentence = "i l i k e z e b r a s ." - print(f"Sentence '{sentence}', logprob = {model.score(sentence, bos=True, eos=True):.4f}\n") + print( + f"Sentence '{sentence}', logprob = {model.score(sentence, bos=True, eos=True):.4f}\n") # Stateful query going one token at-a-time # We'll flip flop between two state objects, one is the input and the other is the output @@ -76,7 +77,8 @@ score = model.BaseScore(state, token, state2) else: score = model.BaseScore(state2, token, state) - print(f"p( {token} | {prev} ...) = {pow(10, score):.6f} [ {score:.6f} ]") + print( + f"p( {token} | {prev} ...) = {pow(10, score):.6f} [ {score:.6f} ]") accum += score prev = token print(f"sum logprob = {accum:.4f}") diff --git a/bcipy/language/model/adapter.py b/bcipy/language/model/adapter.py index 9fb6ff438..20814e6ba 100644 --- a/bcipy/language/model/adapter.py +++ b/bcipy/language/model/adapter.py @@ -25,7 +25,8 @@ def predict_character(self, evidence: Union[str, List[str]]) -> List[Tuple]: """ if self.symbol_set is None: - raise InvalidSymbolSetException("symbol set must be set prior to requesting predictions.") + raise InvalidSymbolSetException( + "symbol set must be set prior to requesting predictions.") assert self.model is not None, "language model does not exist!" @@ -61,7 +62,8 @@ def set_symbol_set(self, symbol_set: List[str]) -> None: self.symbol_set = symbol_set # LM doesn't care about backspace, needs literal space - self.model_symbol_set = [' ' if ch is SPACE_CHAR else ch for ch in self.symbol_set] + self.model_symbol_set = [ + ' ' if ch is SPACE_CHAR else ch for ch in self.symbol_set] self.model_symbol_set.remove(BACKSPACE_CHAR) self._load_model() diff --git a/bcipy/language/model/causal.py b/bcipy/language/model/causal.py index ca493d24c..e76ca7b90 100644 --- a/bcipy/language/model/causal.py +++ b/bcipy/language/model/causal.py @@ -38,8 +38,10 @@ def __init__(self, causal_params = self.parameters['causal'] - self.beam_width = beam_width or int(causal_params['beam_width']['value']) - self.max_completed = max_completed or int(causal_params['max_completed']['value']) + self.beam_width = beam_width or int( + causal_params['beam_width']['value']) + self.max_completed = max_completed or int( + causal_params['max_completed']['value']) # We optionally load the model from a local directory, but if this is not # specified, we load a Hugging Face model diff --git a/bcipy/language/model/mixture.py b/bcipy/language/model/mixture.py index f337ca677..29748c2a7 100644 --- a/bcipy/language/model/mixture.py +++ b/bcipy/language/model/mixture.py @@ -7,9 +7,7 @@ class MixtureLanguageModelAdapter(LanguageModelAdapter): - """ - Character language model that mixes any combination of other models - """ + """Character language model that mixes any combination of other models.""" supported_lm_types = MixtureLanguageModel.supported_lm_types @@ -25,7 +23,8 @@ def __init__(self, lm_params - list of dictionaries to pass as parameters for each model's instantiation """ - MixtureLanguageModel.validate_parameters(lm_types, lm_weights, lm_params) + MixtureLanguageModel.validate_parameters( + lm_types, lm_weights, lm_params) self._load_parameters() @@ -38,7 +37,8 @@ def __init__(self, if type == "NGRAM": params["lm_path"] = f"{LM_PATH}/{params['lm_path']}" - MixtureLanguageModel.validate_parameters(self.lm_types, self.lm_weights, self.lm_params) + MixtureLanguageModel.validate_parameters( + self.lm_types, self.lm_weights, self.lm_params) def _load_model(self) -> None: """Load the model itself using stored parameters""" diff --git a/bcipy/language/model/oracle.py b/bcipy/language/model/oracle.py index c1f1157ea..e581f460c 100644 --- a/bcipy/language/model/oracle.py +++ b/bcipy/language/model/oracle.py @@ -115,8 +115,7 @@ def predict_character(self, evidence: Union[str, List[str]]) -> List[Tuple]: reverse=True) def _next_target(self, spelled_text: str) -> Optional[str]: - """Computes the next target letter based on the currently spelled_text. - """ + """Computes the next target letter based on the currently spelled_text.""" len_spelled = len(spelled_text) len_task = len(self.task_text) diff --git a/bcipy/language/tests/test_causal.py b/bcipy/language/tests/test_causal.py index c91beacc8..c645ff6ae 100644 --- a/bcipy/language/tests/test_causal.py +++ b/bcipy/language/tests/test_causal.py @@ -20,7 +20,8 @@ def setUpClass(cls): cls.gpt2_model = CausalLanguageModelAdapter(lang_model_name="gpt2") cls.gpt2_model.set_symbol_set(DEFAULT_SYMBOL_SET) - cls.opt_model = CausalLanguageModelAdapter(lang_model_name="facebook/opt-125m") + cls.opt_model = CausalLanguageModelAdapter( + lang_model_name="facebook/opt-125m") cls.opt_model.set_symbol_set(DEFAULT_SYMBOL_SET) @pytest.mark.slow @@ -59,7 +60,8 @@ def test_invalid_model_name(self): def test_invalid_model_path(self): """Test that the proper exception is thrown if given an invalid lm_path""" with self.assertRaises(InvalidLanguageModelException): - lm = CausalLanguageModelAdapter(lang_model_name="gpt2", lm_path="./phonypath/") + lm = CausalLanguageModelAdapter( + lang_model_name="gpt2", lm_path="./phonypath/") lm.set_symbol_set(DEFAULT_SYMBOL_SET) def test_non_mutable_evidence(self): @@ -173,7 +175,8 @@ def test_opt_predict_middle_of_word(self): def test_gpt2_phrase(self): """Test that a phrase can be used for input with gpt2 model""" - symbol_probs = self.gpt2_model.predict_character(list("does_it_make_sen")) + symbol_probs = self.gpt2_model.predict_character( + list("does_it_make_sen")) most_likely_sym, _prob = sorted(symbol_probs, key=itemgetter(1), reverse=True)[0] @@ -181,7 +184,8 @@ def test_gpt2_phrase(self): def test_opt_phrase(self): """Test that a phrase can be used for input with Facebook opt model""" - symbol_probs = self.opt_model.predict_character(list("does_it_make_sen")) + symbol_probs = self.opt_model.predict_character( + list("does_it_make_sen")) most_likely_sym, _prob = sorted(symbol_probs, key=itemgetter(1), reverse=True)[0] @@ -205,14 +209,18 @@ def test_opt_multiple_spaces(self): def test_gpt2_nonzero_prob(self): """Test that all letters in the alphabet have nonzero probability except for backspace""" - symbol_probs = self.gpt2_model.predict_character(list("does_it_make_sens")) - prob_values = [item[1] for item in symbol_probs if item[0] != BACKSPACE_CHAR] + symbol_probs = self.gpt2_model.predict_character( + list("does_it_make_sens")) + prob_values = [item[1] + for item in symbol_probs if item[0] != BACKSPACE_CHAR] for value in prob_values: self.assertTrue(value > 0) def test_opt_nonzero_prob(self): """Test that all letters in the alphabet have nonzero probability except for backspace""" - symbol_probs = self.opt_model.predict_character(list("does_it_make_sens")) - prob_values = [item[1] for item in symbol_probs if item[0] != BACKSPACE_CHAR] + symbol_probs = self.opt_model.predict_character( + list("does_it_make_sens")) + prob_values = [item[1] + for item in symbol_probs if item[0] != BACKSPACE_CHAR] for value in prob_values: self.assertTrue(value > 0) diff --git a/bcipy/language/tests/test_mixture.py b/bcipy/language/tests/test_mixture.py index 107cfc2cc..4fe0ecc7c 100644 --- a/bcipy/language/tests/test_mixture.py +++ b/bcipy/language/tests/test_mixture.py @@ -21,7 +21,8 @@ def setUpClass(cls): dirname = os.path.dirname(__file__) or '.' cls.kenlm_path = "lm_dec19_char_tiny_12gram.kenlm" print(cls.kenlm_path) - cls.lm_params = [{"lm_path": cls.kenlm_path}, {"lang_model_name": "gpt2"}] + cls.lm_params = [{"lm_path": cls.kenlm_path}, + {"lang_model_name": "gpt2"}] cls.lmodel = MixtureLanguageModelAdapter(lm_types=["NGRAM", "CAUSAL"], lm_weights=[0.5, 0.5], lm_params=cls.lm_params) cls.lmodel.set_symbol_set(DEFAULT_SYMBOL_SET) @@ -154,6 +155,7 @@ def test_multiple_spaces(self): def test_nonzero_prob(self): """Test that all letters in the alphabet have nonzero probability except for backspace""" symbol_probs = self.lmodel.predict_character(list("does_it_make_sens")) - prob_values = [item[1] for item in symbol_probs if item[0] != BACKSPACE_CHAR] + prob_values = [item[1] + for item in symbol_probs if item[0] != BACKSPACE_CHAR] for value in prob_values: self.assertTrue(value > 0) diff --git a/bcipy/main.py b/bcipy/main.py index ada3a8f6b..310218495 100644 --- a/bcipy/main.py +++ b/bcipy/main.py @@ -1,3 +1,8 @@ +"""Main entry point for BciPy application. + +This module provides the main function to initialize and run a BCI task or experiment. +""" + import argparse import logging import multiprocessing @@ -22,32 +27,33 @@ def bci_main( visualize: bool = True, fake: bool = False, task: Optional[Type[Task]] = None) -> bool: - """BCI Main. + """Initialize and run a BCI task or experiment. - The BCI main function will initialize a save folder, construct needed information - and execute the task. This is the main connection between any UI and + The BCI main function initializes a save folder, constructs needed information + and executes the task. This is the main connection between any UI and running the app. - A Task or Experiment ID must be provided to run the task. If a task is provided, the experiment - ID will be ignored. - - It may also be invoked via tha command line. - Ex. `bcipy` this will default parameters, mode, user, and type. + Args: + parameter_location: Location of parameters file to use. + user: Name of the user. + experiment_id: Name of the experiment. If task is provided, this will be ignored. + alert: Whether to alert the user when the task is complete. + visualize: Whether to visualize data at the end of a task. + fake: Whether to use fake acquisition data during the session. If None, the + fake data will be determined by the parameters file. + task: Registered bcipy Task to execute. If None, the task will be determined by the + experiment protocol. - You can pass it those attributes with flags, if desired. - Ex. `bcipy --user "bci_user" --task "RSVP Calibration" + Returns: + bool: True if the task executed successfully, False otherwise. + Raises: + BciPyCoreException: If no experiment or task is provided. - Input: - parameter_location (str): location of parameters file to use - user (str): name of the user - experiment_id (str): Name of the experiment. If task is provided, this will be ignored. - alert (bool): whether to alert the user when the task is complete - visualize (bool): whether to visualize data at the end of a task - fake (bool): whether to use fake acquisition data during the session. If None, the - fake data will be determined by the parameters file. - task (Task): registered bcipy Task to execute. If None, the task will be determined by the - experiment protocol. + Examples: + Command line usage: + `bcipy` - uses default parameters, mode, user, and type + `bcipy --user "bci_user" --task "RSVP Calibration"` """ logger.info('Starting BciPy...') logger.info( @@ -104,11 +110,21 @@ def bci_main( return True -def bcipy_main() -> None: # pragma: no cover - """BciPy Main. +def bcipy_main() -> None: + """Command line interface for running BciPy experiments and tasks. + + This function provides a command line interface for running registered experiment + tasks in BciPy. It handles argument parsing and delegates execution to bci_main. + + Args: + None + + Returns: + None - Command line interface used for running a registered experiment task in BciPy. To see what - is available use the --help flag. + Note: + Use the --help flag to see available options. + Windows machines require multiprocessing support which is initialized here. """ # Needed for windows machines multiprocessing.freeze_support() diff --git a/bcipy/parameters/devices.json b/bcipy/parameters/devices.json index 66caab5fb..9da3281e0 100644 --- a/bcipy/parameters/devices.json +++ b/bcipy/parameters/devices.json @@ -57,6 +57,25 @@ "status": "active", "static_offset": 0.1 }, + { + "name": "DSI-7", + "content_type": "EEG", + "channels": [ + { "name": "Pz", "label": "Pz", "units": "microvolts", "type": "EEG" }, + { "name": "F4", "label": "F4", "units": "microvolts", "type": "EEG" }, + { "name": "C4", "label": "C4", "units": "microvolts", "type": "EEG" }, + { "name": "P4", "label": "P4", "units": "microvolts", "type": "EEG" }, + { "name": "P3", "label": "P3", "units": "microvolts", "type": "EEG" }, + { "name": "C3", "label": "C3", "units": "microvolts", "type": "EEG" }, + { "name": "F3", "label": "F3", "units": "microvolts", "type": "EEG" }, + { "name": "TRG", "label": "TRG", "units": "microvolts", "type": "EEG" } + ], + "sample_rate": 300, + "description": "Wearable Sensing DSI-7", + "excluded_from_analysis": ["TRG"], + "status": "active", + "static_offset": 0.1 + }, { "name": "DSI-Flex", "content_type": "EEG", diff --git a/bcipy/preferences.py b/bcipy/preferences.py index 84b9ade5c..cb80c8840 100644 --- a/bcipy/preferences.py +++ b/bcipy/preferences.py @@ -1,83 +1,126 @@ -"""Module for recording and loading application state and user preferences.""" +"""Module for recording and loading application state and user preferences. + +This module provides functionality for storing and retrieving user preferences +and application state between sessions using a JSON-based storage system. +""" import json from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Type from bcipy.config import BCIPY_ROOT, DEFAULT_ENCODING, PREFERENCES_PATH class Pref: - """Preference descriptor. When a class attribute is initialized as a Pref, - values will be stored and retrieved from an 'entries' dict initialized in - the instance. + """A descriptor class for managing preferences. + + When a class attribute is initialized as a Pref, values will be stored and + retrieved from an 'entries' dict initialized in the instance. + For more information on descriptors, see: https://docs.python.org/3/howto/descriptor.html - Parameters - ---------- - default - default value assigned to the attribute. + Args: + default: Default value assigned to the attribute if not found in entries. """ - def __init__(self, default: Optional[Any] = None): + def __init__(self, default: Optional[Any] = None) -> None: self.default = default - self.name = None + self.name: Optional[str] = None + + def __set_name__(self, owner: Type[Any], name: str) -> None: + """Assign the Pref descriptor to a class attribute. - def __set_name__(self, owner, name): - """Called when the class assigns a Pref to a class attribute.""" + Args: + owner: The class that owns this descriptor. + name: The name of the attribute this descriptor is assigned to. + """ self.name = name - def __get__(self, instance, owner=None): - """Retrieve the value from the dict of entries.""" + def __get__(self, instance: Any, owner: Optional[Type[Any]] = None) -> Any: + """Return the value from the dict of entries. + + Args: + instance: The instance that this descriptor is accessed from. + owner: The class that owns this descriptor. + + Returns: + The value stored in entries or the default value. + """ + if instance is None: + return self return instance.entries.get(self.name, self.default) - def __set__(self, instance, value): - """Stores the given value in the entries dict keyed on the attribute - name.""" + def __set__(self, instance: Any, value: Any) -> None: + """Store the given value in the entries dict keyed on the attribute name. + + Args: + instance: The instance that this descriptor is accessed from. + value: The value to store in the entries dict. + """ instance.entries[self.name] = value instance.save() class Preferences: - """User preferences persisted to disk to retain application state between - work sessions. + """User preferences persisted to disk to retain application state between work sessions. - Parameters - ---------- - filename - optional file used for persisting entries. + This class manages user preferences by storing them in a JSON file on disk. It provides + methods for loading, saving, and accessing preference values. + + Attributes: + signal_model_directory: Directory containing signal models. + last_directory: Last accessed directory, defaults to BCIPY_ROOT. + + Args: + filename: Optional file used for persisting entries. """ signal_model_directory = Pref() last_directory = Pref(default=str(BCIPY_ROOT)) def __init__(self, filename: str = PREFERENCES_PATH) -> None: self.filename = filename - self.entries: Dict[Any, Any] = {} + self.entries: Dict[str, Any] = {} self.load() - def load(self): - """Load preference data from the persisted file.""" + def load(self) -> None: + """Load preference data from the persisted file. + + Reads the JSON file specified by self.filename and populates the entries + dictionary with the stored preferences. + """ if Path(self.filename).is_file(): with open(self.filename, 'r', encoding=DEFAULT_ENCODING) as json_file: for key, val in json.load(json_file).items(): self.entries[key] = val - def save(self): - """Write preferences to disk.""" + def save(self) -> None: + """Write preferences to disk. + + Saves the current entries dictionary to the JSON file specified by + self.filename. + """ with open(self.filename, 'w', encoding=DEFAULT_ENCODING) as json_file: json.dump(self.entries, json_file, ensure_ascii=False, indent=2) - def get(self, name: str): - """Get preference by name""" + def get(self, name: str) -> Optional[Any]: + """Get preference by name. + + Args: + name: Name of the preference to retrieve. + + Returns: + The preference value if found, None otherwise. + """ return self.entries.get(name, None) - def set(self, name: str, value: Any, persist: bool = True): + def set(self, name: str, value: Any, persist: bool = True) -> None: """Set a preference and save the result. - Parameters - ---------- - name - name of the preference - value - value associated with the given name - persist - flag indicating whether to immediately save the result. + Args: + name: Name of the preference. + value: Value associated with the given name. + persist: Flag indicating whether to immediately save the result. Default is True. """ self.entries[name] = value diff --git a/bcipy/signal/evaluate/artifact.py b/bcipy/signal/evaluate/artifact.py index c88d56a41..1d8f69a21 100644 --- a/bcipy/signal/evaluate/artifact.py +++ b/bcipy/signal/evaluate/artifact.py @@ -7,6 +7,7 @@ from typing import List, Optional, Tuple, Union import mne +from mne import Annotations import bcipy.acquisition.devices as devices from bcipy.acquisition.devices import DeviceSpec @@ -25,8 +26,6 @@ mne.set_log_level('WARNING') log = getLogger(SESSION_LOG_FILENAME) -from mne import Annotations - class DefaultArtifactParameters(Enum): """Default Artifact Parameters. @@ -182,7 +181,8 @@ def __init__( self.session_triggers = mne.Annotations( self.trigger_time, [self.trial_duration] * len(self.trigger_time), self.trigger_description) - assert len(device_spec.channel_specs) > 0, 'DeviceSpec used must have channels. None found.' + assert len( + device_spec.channel_specs) > 0, 'DeviceSpec used must have channels. None found.' self.units = device_spec.channel_specs[0].units log.info(f'Artifact detection using {self.units} units.') assert self.units in self.supported_units, \ @@ -195,7 +195,8 @@ def __init__( self.detect_eog = detect_eog self.semi_automatic = semi_automatic - log.info(f'Artifact detection with {self.detect_voltage=}, {self.detect_eog=}, {self.semi_automatic=}') + log.info( + f'Artifact detection with {self.detect_voltage=}, {self.detect_eog=}, {self.semi_automatic=}') self.save_path = save_path @@ -252,7 +253,8 @@ def label_artifacts( log.info(f'Bad channels detected: {bad_channels}') if voltage_annotations: - log.info(f'Voltage violation events found: {len(voltage_annotations)}') + log.info( + f'Voltage violation events found: {len(voltage_annotations)}') annotations += voltage_annotations self.voltage_annotations = voltage_annotations @@ -290,7 +292,8 @@ def save_artifacts(self, overwrite: bool = False) -> None: f'{self.save_path}/{DefaultArtifactParameters.ARTIFACT_LABELLED_FILENAME.value}', overwrite=overwrite) else: - log.info('Artifact cannot be saved, artifact analysis has been done yet.') + log.info( + 'Artifact cannot be saved, artifact analysis has been done yet.') def raw_data_to_mne(self, raw_data: RawData, volts: bool = False) -> mne.io.RawArray: """Convert the raw data to an MNE RawArray.""" @@ -345,8 +348,10 @@ def label_eog_events( log.info('No eye channels provided. Cannot detect EOG artifacts.') return None - log.info(f'Using blink threshold of {threshold} for channels {self.eye_channels}.') - eog_events = mne.preprocessing.find_eog_events(self.mne_data, ch_name=self.eye_channels, thresh=threshold) + log.info( + f'Using blink threshold of {threshold} for channels {self.eye_channels}.') + eog_events = mne.preprocessing.find_eog_events( + self.mne_data, ch_name=self.eye_channels, thresh=threshold) # eog_events = mne.preprocessing.ica_find_eog_events(raw) TODO compare to ICA if len(eog_events) > 0: @@ -354,7 +359,8 @@ def label_eog_events( onsets = eog_events[:, 0] / self.mne_data.info['sfreq'] - preblink durations = [postblink + preblink] * len(eog_events) descriptions = [label] * len(eog_events) - blink_annotations = mne.Annotations(onsets, durations, descriptions) + blink_annotations = mne.Annotations( + onsets, durations, descriptions) return blink_annotations, eog_events return None @@ -416,7 +422,8 @@ def label_voltage_events( bad_percent=self.percent_bad, peak=peak[0]) if len(peak_voltage_annotations) > 0: - log.info(f'Peak voltage events found: {len(peak_voltage_annotations)}') + log.info( + f'Peak voltage events found: {len(peak_voltage_annotations)}') onsets, durations, descriptions = self.concat_annotations( peak_voltage_annotations, pre_event, @@ -430,7 +437,8 @@ def label_voltage_events( flat_voltage_annotations, bad_channels2 = mne.preprocessing.annotate_amplitude( self.mne_data, min_duration=flat[1], bad_percent=self.percent_bad, flat=flat[0]) if len(flat_voltage_annotations) > 0: - log.info(f'Flat voltage events found: {len(flat_voltage_annotations)}') + log.info( + f'Flat voltage events found: {len(flat_voltage_annotations)}') onsets, durations, descriptions = self.concat_annotations( flat_voltage_annotations, pre_event, @@ -567,14 +575,16 @@ def write_mne_annotations( # loop through the sessions, pausing after each one to allow for manual stopping if session.is_dir(): print(f'Processing {session}') - prompt = input('Hit enter to continue or type "skip" to skip processing: ') + prompt = input( + 'Hit enter to continue or type "skip" to skip processing: ') if prompt != 'skip': # load the parameters from the data directory parameters = load_json_parameters( f'{session}/{DEFAULT_PARAMETERS_FILENAME}', value_cast=True) # load the raw data from the data directory - raw_data = load_raw_data(str(Path(session, f'{RAW_DATA_FILENAME}.csv'))) + raw_data = load_raw_data( + str(Path(session, f'{RAW_DATA_FILENAME}.csv'))) type_amp = raw_data.daq_type # load the triggers @@ -582,7 +592,8 @@ def write_mne_annotations( trigger_type, trigger_timing, trigger_label = trigger_decoder( offset=0.1, trigger_path=f"{session}/{TRIGGER_FILENAME}", - exclusion=[TriggerType.PREVIEW, TriggerType.EVENT, TriggerType.FIXATION], + exclusion=[TriggerType.PREVIEW, + TriggerType.EVENT, TriggerType.FIXATION], ) triggers = (trigger_type, trigger_timing, trigger_label) else: diff --git a/bcipy/signal/evaluate/fusion.py b/bcipy/signal/evaluate/fusion.py index ab06c01b7..ffeb71688 100644 --- a/bcipy/signal/evaluate/fusion.py +++ b/bcipy/signal/evaluate/fusion.py @@ -52,10 +52,12 @@ def calculate_eeg_gaze_fusion_acc( gaze_acc: accuracy of the gaze model only fusion_acc: accuracy of the fusion """ - logger.info(f"Calculating EEG [{eeg_model.name}] and Gaze [{gaze_model.name}] model fusion accuracy.") + logger.info( + f"Calculating EEG [{eeg_model.name}] and Gaze [{gaze_model.name}] model fusion accuracy.") # Extract relevant session information from parameters file trial_window = parameters.get("trial_window", (0.0, 0.5)) - window_length = trial_window[1] - trial_window[0] # eeg window length, in seconds + # eeg window length, in seconds + window_length = trial_window[1] - trial_window[0] prestim_length = parameters.get("prestim_length") trials_per_inquiry = parameters.get("stim_length") @@ -64,7 +66,8 @@ def calculate_eeg_gaze_fusion_acc( buffer = int(parameters.get("task_buffer_length") / 2) # Get signal filtering information - transform_params: ERPTransformParams = parameters.instantiate(ERPTransformParams) + transform_params: ERPTransformParams = parameters.instantiate( + ERPTransformParams) downsample_rate = transform_params.down_sampling_rate static_offset = device_spec_eeg.static_offset @@ -119,10 +122,12 @@ def calculate_eeg_gaze_fusion_acc( target_symbols = [trigger_symbols[idx] for idx, targetness in enumerate(trigger_targetness_gaze) if targetness == 'prompt'] total_len = trials_per_inquiry + 1 # inquiry length + the prompt symbol - inq_start = trigger_timing_gaze[1::total_len] # inquiry start times, exluding prompt and fixation + # inquiry start times, exluding prompt and fixation + inq_start = trigger_timing_gaze[1::total_len] # update the trigger timing list to account for the initial trial window - corrected_trigger_timing = [timing + trial_window[0] for timing in trigger_timing] + corrected_trigger_timing = [timing + trial_window[0] + for timing in trigger_timing] erp_data, _fs_eeg = eeg_data.by_channel() trajectory_data, _fs_eye = gaze_data.by_channel() @@ -153,18 +158,24 @@ def calculate_eeg_gaze_fusion_acc( ) # More EEG preprocessing: - eeg_inquiries, fs = filter_inquiries(eeg_inquiries, default_transform, eeg_sample_rate) - eeg_inquiry_timing = update_inquiry_timing(eeg_inquiry_timing, downsample_rate) + eeg_inquiries, fs = filter_inquiries( + eeg_inquiries, default_transform, eeg_sample_rate) + eeg_inquiry_timing = update_inquiry_timing( + eeg_inquiry_timing, downsample_rate) trial_duration_samples = int(window_length * fs) # More gaze preprocessing: - inquiry_length = gaze_inquiries_list[0].shape[1] # number of time samples in each inquiry + # number of time samples in each inquiry + inquiry_length = gaze_inquiries_list[0].shape[1] predefined_dimensions = 4 # left_x, left_y, right_x, right_y - preprocessed_gaze_data = np.zeros((len(gaze_inquiries_list), predefined_dimensions, inquiry_length)) + preprocessed_gaze_data = np.zeros( + (len(gaze_inquiries_list), predefined_dimensions, inquiry_length)) # Extract left_x, left_y, right_x, right_y for each inquiry for j in range(len(gaze_inquiries_list)): - left_eye, right_eye, _, _, _, _ = extract_eye_info(gaze_inquiries_list[j]) - preprocessed_gaze_data[j] = np.concatenate((left_eye.T, right_eye.T,), axis=0) + left_eye, right_eye, _, _, _, _ = extract_eye_info( + gaze_inquiries_list[j]) + preprocessed_gaze_data[j] = np.concatenate( + (left_eye.T, right_eye.T,), axis=0) preprocessed_gaze_dict = {i: [] for i in symbol_set} for i in symbol_set: @@ -172,8 +183,10 @@ def calculate_eeg_gaze_fusion_acc( if len(gaze_inquiries_dict[i]) == 0: continue for j in range(len(gaze_inquiries_dict[i])): - left_eye, right_eye, _, _, _, _ = extract_eye_info(gaze_inquiries_dict[i][j]) - preprocessed_gaze_dict[i].append((np.concatenate((left_eye.T, right_eye.T), axis=0))) + left_eye, right_eye, _, _, _, _ = extract_eye_info( + gaze_inquiries_dict[i][j]) + preprocessed_gaze_dict[i].append( + (np.concatenate((left_eye.T, right_eye.T), axis=0))) preprocessed_gaze_dict[i] = np.array(preprocessed_gaze_dict[i]) # Find the time averages for each symbol: @@ -195,12 +208,14 @@ def calculate_eeg_gaze_fusion_acc( preprocessed_gaze_dict[sym][j], temp)) # Delta_t = X_t - mu centralized_data_dict[sym] = np.array(centralized_data_dict[sym]) - time_average_per_symbol[sym] = np.mean(np.array(time_average_per_symbol[sym]), axis=0) + time_average_per_symbol[sym] = np.mean( + np.array(time_average_per_symbol[sym]), axis=0) # Take the time average of the gaze data: centralized_gaze_data = np.zeros_like(preprocessed_gaze_data) for i, (_, sym) in enumerate(zip(preprocessed_gaze_data, target_symbols)): - centralized_gaze_data[i] = gaze_model.subtract_mean(preprocessed_gaze_data[i], time_average_per_symbol[sym]) + centralized_gaze_data[i] = gaze_model.subtract_mean( + preprocessed_gaze_data[i], time_average_per_symbol[sym]) """ Calculate the accuracy of the fusion of EEG and Gaze models. Use the number of iterations to change bootstraping. @@ -217,10 +232,13 @@ def calculate_eeg_gaze_fusion_acc( bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt} [est. {remaining}][ela. {elapsed}]\n", colour='MAGENTA') for _progress in progress_bar: - progress_bar.set_description(f"Running iteration {_progress + 1}/{n_iterations}") + progress_bar.set_description( + f"Running iteration {_progress + 1}/{n_iterations}") # Pick a train and test dataset (that consists of non-train elements) until test dataset is not empty: - train_indices = resample(list(range(selection_length)), replace=True, n_samples=100) - test_indices = np.array([x for x in list(range(selection_length)) if x not in train_indices]) + train_indices = resample( + list(range(selection_length)), replace=True, n_samples=100) + test_indices = np.array( + [x for x in list(range(selection_length)) if x not in train_indices]) if len(test_indices) == 0: break @@ -254,7 +272,8 @@ def calculate_eeg_gaze_fusion_acc( # extract train and test indices for gaze data: centralized_gaze_data_train = centralized_gaze_data[train_indices] # gaze_train_labels = np.array([target_symbols[i] for i in train_indices]) - gaze_data_test = preprocessed_gaze_data[test_indices] # test set is NOT centralized + # test set is NOT centralized + gaze_data_test = preprocessed_gaze_data[test_indices] gaze_test_labels = np.array([target_symbols[i] for i in test_indices]) # generate a tuple that matches the index of the symbol with the symbol itself: symbol_to_index = {symbol: i for i, symbol in enumerate(symbol_set)} @@ -283,14 +302,17 @@ def calculate_eeg_gaze_fusion_acc( except BaseException: # Singular matrix, using pseudo-inverse instead eps = 10e-3 # add a small value to the diagonal to make the matrix invertible - inv_cov_matrix = np.linalg.inv(cov_matrix + np.eye(len(cov_matrix)) * eps) + inv_cov_matrix = np.linalg.inv( + cov_matrix + np.eye(len(cov_matrix)) * eps) # inv_cov_matrix = np.linalg.pinv(cov_matrix + np.eye(len(cov_matrix))*eps) denominator_gaze = 0 # Given the test data, compute the log likelihood ratios for each symbol, # from eeg and gaze models: - eeg_log_likelihoods = np.zeros((len(gaze_data_test), (len(symbol_set)))) - gaze_log_likelihoods = np.zeros((len(gaze_data_test), (len(symbol_set)))) + eeg_log_likelihoods = np.zeros( + (len(gaze_data_test), (len(symbol_set)))) + gaze_log_likelihoods = np.zeros( + (len(gaze_data_test), (len(symbol_set)))) # Save the max posterior and the second max posterior for each test point: target_posteriors_gaze = np.zeros((len(gaze_data_test), 2)) @@ -306,23 +328,29 @@ def calculate_eeg_gaze_fusion_acc( for idx, sym in enumerate(symbol_set): # skip if there is no training example from the symbol if time_average_per_symbol[sym] == []: - gaze_log_likelihoods[test_idx, idx] = -100000 # set a very small value + gaze_log_likelihoods[test_idx, idx] = - \ + 100000 # set a very small value else: - central_data = gaze_model.subtract_mean(test_data, time_average_per_symbol[sym]) - flattened_data = central_data.reshape((inquiry_length * predefined_dimensions,)) + central_data = gaze_model.subtract_mean( + test_data, time_average_per_symbol[sym]) + flattened_data = central_data.reshape( + (inquiry_length * predefined_dimensions,)) flattened_data *= units diff = flattened_data - reshaped_mean diff_list.append(diff) - numerator = -np.dot(diff.T, np.dot(inv_cov_matrix, diff)) / 2 + numerator = - \ + np.dot(diff.T, np.dot(inv_cov_matrix, diff)) / 2 numerator_gaze_list.append(numerator) unnormalized_log_likelihood_gaze = numerator - denominator_gaze - gaze_log_likelihoods[test_idx, idx] = unnormalized_log_likelihood_gaze + gaze_log_likelihoods[test_idx, + idx] = unnormalized_log_likelihood_gaze normalized_posterior_gaze_only = np.exp( gaze_log_likelihoods[test_idx, :]) / np.sum(np.exp(gaze_log_likelihoods[test_idx, :])) # Find the max likelihood: max_like_gaze = np.argmax(normalized_posterior_gaze_only) - posterior_of_true_label_gaze = normalized_posterior_gaze_only[symbol_to_index[gaze_test_labels[test_idx]]] + posterior_of_true_label_gaze = normalized_posterior_gaze_only[ + symbol_to_index[gaze_test_labels[test_idx]]] top_competitor_gaze = np.sort(normalized_posterior_gaze_only)[-2] target_posteriors_gaze[test_idx, 0] = posterior_of_true_label_gaze target_posteriors_gaze[test_idx, 1] = top_competitor_gaze @@ -335,7 +363,8 @@ def calculate_eeg_gaze_fusion_acc( end = (test_idx + 1) * trials_per_inquiry eeg_tst_data = preprocessed_test_eeg[:, start:end, :] inq_sym = inquiry_symbols_test[start: end] - eeg_likelihood_ratios = eeg_model.compute_likelihood_ratio(eeg_tst_data, inq_sym, symbol_set) + eeg_likelihood_ratios = eeg_model.compute_likelihood_ratio( + eeg_tst_data, inq_sym, symbol_set) unnormalized_log_likelihood_eeg = np.log(eeg_likelihood_ratios) eeg_log_likelihoods[test_idx, :] = unnormalized_log_likelihood_eeg normalized_posterior_eeg_only = np.exp( @@ -343,7 +372,8 @@ def calculate_eeg_gaze_fusion_acc( max_like_eeg = np.argmax(normalized_posterior_eeg_only) top_competitor_eeg = np.sort(normalized_posterior_eeg_only)[-2] - posterior_of_true_label_eeg = normalized_posterior_eeg_only[symbol_to_index[gaze_test_labels[test_idx]]] + posterior_of_true_label_eeg = normalized_posterior_eeg_only[ + symbol_to_index[gaze_test_labels[test_idx]]] target_posteriors_eeg[test_idx, 0] = posterior_of_true_label_eeg target_posteriors_eeg[test_idx, 1] = top_competitor_eeg @@ -351,7 +381,8 @@ def calculate_eeg_gaze_fusion_acc( counter_eeg += 1 # Bayesian fusion update and decision making: - log_unnormalized_posterior = np.log(eeg_likelihood_ratios) + gaze_log_likelihoods[test_idx, :] + log_unnormalized_posterior = np.log( + eeg_likelihood_ratios) + gaze_log_likelihoods[test_idx, :] unnormalized_posterior = np.exp(log_unnormalized_posterior) denominator = np.sum(unnormalized_posterior) posterior = unnormalized_posterior / denominator # normalized posterior @@ -360,7 +391,8 @@ def calculate_eeg_gaze_fusion_acc( top_competitor_fusion = np.sort(log_posterior)[-2] posterior_of_true_label_fusion = posterior[symbol_to_index[gaze_test_labels[test_idx]]] - target_posteriors_fusion[test_idx, 0] = posterior_of_true_label_fusion + target_posteriors_fusion[test_idx, + 0] = posterior_of_true_label_fusion target_posteriors_fusion[test_idx, 1] = top_competitor_fusion if symbol_set[max_posterior] == gaze_test_labels[test_idx]: counter_fusion += 1 @@ -369,9 +401,12 @@ def calculate_eeg_gaze_fusion_acc( if posterior.any() == np.nan: break - eeg_acc_in_iteration = float("{:.3f}".format(counter_eeg / len(test_indices))) - gaze_acc_in_iteration = float("{:.3f}".format(counter_gaze / len(test_indices))) - fusion_acc_in_iteration = float("{:.3f}".format(counter_fusion / len(test_indices))) + eeg_acc_in_iteration = float( + "{:.3f}".format(counter_eeg / len(test_indices))) + gaze_acc_in_iteration = float( + "{:.3f}".format(counter_gaze / len(test_indices))) + fusion_acc_in_iteration = float( + "{:.3f}".format(counter_fusion / len(test_indices))) eeg_acc.append(eeg_acc_in_iteration) gaze_acc.append(gaze_acc_in_iteration) fusion_acc.append(fusion_acc_in_iteration) diff --git a/bcipy/signal/model/classifier.py b/bcipy/signal/model/classifier.py index 61b629b10..c9df576d8 100644 --- a/bcipy/signal/model/classifier.py +++ b/bcipy/signal/model/classifier.py @@ -71,16 +71,20 @@ def fit(self, x, y, p=[]): # in order to make the ndarray readable from MATLAB side. There are # two arrays, [0] for the correctness, choose it # Class means - self.mean_i = [np.mean(x[np.where(y == i)[0]], axis=0) for i in self.class_i] + self.mean_i = [np.mean(x[np.where(y == i)[0]], axis=0) + for i in self.class_i] # Normalized x - norm_vec = [x[np.where(y == self.class_i[i])[0]] - self.mean_i[i] for i in range(len(self.class_i))] + norm_vec = [x[np.where(y == self.class_i[i])[0]] - self.mean_i[i] + for i in range(len(self.class_i))] # Outer product of data matrix, Xi'Xi for each class - self.S_i = [np.dot(np.transpose(norm_vec[i]), norm_vec[i]) for i in range(len(self.class_i))] + self.S_i = [np.dot(np.transpose(norm_vec[i]), norm_vec[i]) + for i in range(len(self.class_i))] # Sample covariances are calculated Si/Ni for each class - self.cov_i = [self.S_i[i] / self.N_i[i] for i in range(len(self.class_i))] + self.cov_i = [self.S_i[i] / self.N_i[i] + for i in range(len(self.class_i))] # Sample covariance of total data self.S = np.zeros((self.k, self.k)) @@ -90,7 +94,8 @@ def fit(self, x, y, p=[]): # Set priors if len(p) == 0: - prior = np.asarray([np.sum(y == self.class_i[i]) for i in range(len(self.class_i))], dtype=float) + prior = np.asarray([np.sum(y == self.class_i[i]) + for i in range(len(self.class_i))], dtype=float) self.prior_i = np.divide(prior, np.sum(prior)) else: self.prior_i = p @@ -110,13 +115,15 @@ def regularize(self, param): # TODO: what if no param passed? # Shrinked class covariances shr_cov_i = [ - ((1 - self.lam) * self.S_i[i] + self.lam * self.S) / ((1 - self.lam) * self.N_i[i] + self.lam * self.N) + ((1 - self.lam) * self.S_i[i] + self.lam * self.S) / + ((1 - self.lam) * self.N_i[i] + self.lam * self.N) for i in range(len(self.class_i)) ] # Regularized class covariances reg_cov_i = [ - ((1 - self.gam) * shr_cov_i[i] + self.gam / self.k * np.trace(shr_cov_i[i]) * np.eye(self.k)) + ((1 - self.gam) * shr_cov_i[i] + self.gam / + self.k * np.trace(shr_cov_i[i]) * np.eye(self.k)) for i in range(len(self.class_i)) ] @@ -156,7 +163,8 @@ def get_prob(self, x): # Every constant at the end of score calculation is omitted. # This is why we omit log det of class regularized covariances. - evidence = np.dot(zero_mean, np.dot(self.inv_reg_cov_i[i], zero_mean)) + evidence = np.dot(zero_mean, np.dot( + self.inv_reg_cov_i[i], zero_mean)) neg_log_l[s][i] = -0.5 * evidence + np.log(self.prior_i[i]) diff --git a/bcipy/signal/model/cross_validation.py b/bcipy/signal/model/cross_validation.py index 65587a40d..d29145113 100644 --- a/bcipy/signal/model/cross_validation.py +++ b/bcipy/signal/model/cross_validation.py @@ -11,7 +11,7 @@ def cost_cross_validation_auc(model, opt_el, x, y, param, k_folds=10, split='uniform'): - """ Minimize cost of the overall -AUC. + """Minimize cost of the overall -AUC. Cost function: given a particular architecture (model). Fits the parameters to the folds with leave one fold out procedure. Calculates scores for the validation fold. Concatenates all calculated scores @@ -31,7 +31,8 @@ def cost_cross_validation_auc(model, opt_el, x, y, param, k_folds=10, -auc(float): negative AUC value for current setup sc_h(ndarray[float]): scores computed for each validation fold y_valid_h(ndarray[int]): labels of the scores for each validation fold - y_valid_h[i] is basically the label for sc_h[i] """ + y_valid_h[i] is basically the label for sc_h[i] + """ num_samples = x.shape[1] fold_len = np.floor(float(num_samples) / k_folds) diff --git a/bcipy/signal/model/density_estimation.py b/bcipy/signal/model/density_estimation.py index 7e88442b9..28aa119be 100644 --- a/bcipy/signal/model/density_estimation.py +++ b/bcipy/signal/model/density_estimation.py @@ -17,11 +17,13 @@ class KernelDensityEstimate: """ def __init__(self, scores: Optional[np.array] = None, kernel="gaussian", num_cls=2): - bandwidth = 1.0 if scores is None else self._compute_bandwidth(scores, scores.shape[0]) + bandwidth = 1.0 if scores is None else self._compute_bandwidth( + scores, scores.shape[0]) self.logger = logging.getLogger(SESSION_LOG_FILENAME) self.logger.info(f"KDE. bandwidth={bandwidth}, kernel={kernel}") self.num_cls = num_cls - self.list_den_est = [KernelDensity(bandwidth=bandwidth, kernel=kernel) for _ in range(self.num_cls)] + self.list_den_est = [KernelDensity( + bandwidth=bandwidth, kernel=kernel) for _ in range(self.num_cls)] def _compute_bandwidth(self, scores: np.array, num_items: int): """Estimate bandwidth parameter using Silverman's rule of thumb. @@ -34,7 +36,8 @@ def _compute_bandwidth(self, scores: np.array, num_items: int): Returns: float: rule-of-thumb bandwidth parameter for KDE """ - bandwidth = 0.9 * min(np.std(scores), iqr(scores) / 1.34) * np.power(num_items, -0.2) + bandwidth = 0.9 * min(np.std(scores), iqr(scores) / + 1.34) * np.power(num_items, -0.2) return bandwidth def fit(self, x, y): @@ -61,7 +64,8 @@ def transform(self, x): Where N and c denotes number of samples and classes Returns: val(ndarray[float]): N x c log-likelihood array - respectively.""" + respectively. + """ # Calculate likelihoods for each density estimate val = [] diff --git a/bcipy/signal/model/dimensionality_reduction.py b/bcipy/signal/model/dimensionality_reduction.py index c2bff96e3..8ec52b868 100644 --- a/bcipy/signal/model/dimensionality_reduction.py +++ b/bcipy/signal/model/dimensionality_reduction.py @@ -28,9 +28,11 @@ class ChannelWisePrincipalComponentAnalysis: def __init__(self, n_components: Optional[float] = None, random_state: Optional[int] = None, num_ch: int = 1): self.num_ch = num_ch - self.list_pca = [PCA(n_components=n_components, random_state=random_state) for _ in range(self.num_ch)] + self.list_pca = [PCA(n_components=n_components, + random_state=random_state) for _ in range(self.num_ch)] self.logger = logging.getLogger(SESSION_LOG_FILENAME) - self.logger.info(f"PCA. n_components={n_components}, random_state={random_state}, num_ch={num_ch}") + self.logger.info( + f"PCA. n_components={n_components}, random_state={random_state}, num_ch={num_ch}") def fit(self, x: np.ndarray, y: Optional[np.ndarray] = None) -> None: """Fit PCA to each channel of data. diff --git a/bcipy/signal/model/gaussian_mixture/gaussian_mixture.py b/bcipy/signal/model/gaussian_mixture/gaussian_mixture.py index f438e6df7..a585309c7 100644 --- a/bcipy/signal/model/gaussian_mixture/gaussian_mixture.py +++ b/bcipy/signal/model/gaussian_mixture/gaussian_mixture.py @@ -85,16 +85,19 @@ def evaluate(self, test_data: np.ndarray, test_labels: np.ndarray): def evaluate_likelihood(self, data: np.ndarray, symbols: List[str], symbol_set: List[str]) -> np.ndarray: if not self.ready_to_predict: - raise SignalException("must use model.fit() before model.evaluate_likelihood()") + raise SignalException( + "must use model.fit() before model.evaluate_likelihood()") gaze_log_likelihoods = np.zeros((len(symbol_set))) # Clip the pre-saved centralized data to the length of our test data cent_data = self.centralized_data[:, :, :data.shape[1]] - reshaped_data = cent_data.reshape((len(cent_data), data.shape[0] * data.shape[1])) + reshaped_data = cent_data.reshape( + (len(cent_data), data.shape[0] * data.shape[1])) cov_matrix = np.cov(reshaped_data, rowvar=False) reshaped_mean = np.mean(reshaped_data, axis=0) eps = 10e-1 # add a small value to the diagonal to make the cov matrix invertible - inv_cov_matrix = np.linalg.inv(cov_matrix + np.eye(len(cov_matrix)) * eps) + inv_cov_matrix = np.linalg.inv( + cov_matrix + np.eye(len(cov_matrix)) * eps) for idx, sym in enumerate(symbol_set): if self.time_average[sym] == []: @@ -179,7 +182,8 @@ def __init__(self, num_components=4, random_state=0, *args, **kwargs): self.ready_to_predict = False def fit(self, train_data: np.ndarray): - model = GaussianMixture(n_components=self.num_components, random_state=self.random_state, init_params='kmeans') + model = GaussianMixture(n_components=self.num_components, + random_state=self.random_state, init_params='kmeans') model.fit(train_data) self.model = model @@ -202,7 +206,8 @@ def evaluate(self, predictions, true_labels) -> np.ndarray: -------- accuracy_per_symbol: accuracy per symbol ''' - accuracy_per_symbol = np.sum(predictions == true_labels) / len(predictions) * 100 + accuracy_per_symbol = np.sum( + predictions == true_labels) / len(predictions) * 100 self.acc = accuracy_per_symbol return accuracy_per_symbol @@ -242,7 +247,8 @@ def predict_proba(self, test_data: np.ndarray) -> np.ndarray: ''' data_length, _ = test_data.shape - likelihoods = np.zeros((data_length, self.num_components), dtype=object) + likelihoods = np.zeros( + (data_length, self.num_components), dtype=object) # Find the likelihoods by insterting the test data into the pdf of each component for i in range(data_length): @@ -250,17 +256,20 @@ def predict_proba(self, test_data: np.ndarray) -> np.ndarray: mu = self.means[k] sigma = self.covs[k] - likelihoods[i, k] = stats.multivariate_normal.pdf(test_data[i], mu, sigma) + likelihoods[i, k] = stats.multivariate_normal.pdf( + test_data[i], mu, sigma) return likelihoods def evaluate_likelihood(self, data: np.ndarray, symbols: List[str], symbol_set: List[str]) -> np.ndarray: if not self.ready_to_predict: - raise SignalException("must use model.fit() before model.evaluate_likelihood()") + raise SignalException( + "must use model.fit() before model.evaluate_likelihood()") data_length, _ = data.shape - likelihoods = np.zeros((data_length, self.num_components), dtype=object) + likelihoods = np.zeros( + (data_length, self.num_components), dtype=object) # Find the likelihoods by insterting the test data into the pdf of each component for i in range(data_length): @@ -268,7 +277,8 @@ def evaluate_likelihood(self, data: np.ndarray, symbols: List[str], mu = self.means[k] sigma = self.covs[k] - likelihoods[i, k] = stats.multivariate_normal.pdf(data[i], mu, sigma) + likelihoods[i, k] = stats.multivariate_normal.pdf( + data[i], mu, sigma) return likelihoods diff --git a/bcipy/signal/model/offline_analysis.py b/bcipy/signal/model/offline_analysis.py index e82b706a3..2f6474547 100644 --- a/bcipy/signal/model/offline_analysis.py +++ b/bcipy/signal/model/offline_analysis.py @@ -33,7 +33,8 @@ filter_inquiries, get_default_transform) log = logging.getLogger(SESSION_LOG_FILENAME) -logging.basicConfig(level=logging.INFO, format="[%(threadName)-9s][%(asctime)s][%(name)s][%(levelname)s]: %(message)s") +logging.basicConfig(level=logging.INFO, + format="[%(threadName)-9s][%(asctime)s][%(name)s][%(levelname)s]: %(message)s") def subset_data(data: np.ndarray, labels: np.ndarray, test_size: float, random_state: int = 0, swap_axes: bool = True): @@ -150,12 +151,14 @@ def analyze_erp( ) # update the trigger timing list to account for the initial trial window - corrected_trigger_timing = [timing + trial_window[0] for timing in trigger_timing] + corrected_trigger_timing = [timing + trial_window[0] + for timing in trigger_timing] # Channel map can be checked from raw_data.csv file or the devices.json located in the acquisition module # The timestamp column [0] is already excluded. channel_map = analysis_channels(channels, device_spec) - channels_used = [channels[i] for i, keep in enumerate(channel_map) if keep == 1] + channels_used = [channels[i] + for i, keep in enumerate(channel_map) if keep == 1] log.info(f'Channels used in analysis: {channels_used}') data, fs = erp_data.by_channel() @@ -175,7 +178,8 @@ def analyze_erp( inquiries, fs = filter_inquiries(inquiries, default_transform, sample_rate) inquiry_timing = update_inquiry_timing(inquiry_timing, downsample_rate) trial_duration_samples = int(window_length * fs) - data = model.reshaper.extract_trials(inquiries, trial_duration_samples, inquiry_timing) + data = model.reshaper.extract_trials( + inquiries, trial_duration_samples, inquiry_timing) # define the training classes using integers, where 0=nontargets/1=targets labels = inquiry_labels.flatten().tolist() @@ -196,7 +200,8 @@ def analyze_erp( try: # Using an 80/20 split, report on balanced accuracy if estimate_balanced_acc: - train_data, test_data, train_labels, test_labels = subset_data(data, labels, test_size=0.2) + train_data, test_data, train_labels, test_labels = subset_data( + data, labels, test_size=0.2) dummy_model = PcaRdaKdeModel(k_folds=k_folds) dummy_model.fit(train_data, train_labels) probs = dummy_model.predict_proba(test_data) @@ -209,7 +214,8 @@ def analyze_erp( except Exception as e: log.error(f"Error calculating balanced accuracy: {e}") - save_model(model, Path(data_folder, f"model_{device_spec.content_type.lower()}_{model.auc:0.4f}.pkl")) + save_model(model, Path( + data_folder, f"model_{device_spec.content_type.lower()}_{model.auc:0.4f}.pkl")) preferences.signal_model_directory = data_folder if save_figures or show_figures: @@ -259,13 +265,15 @@ def analyze_gaze( sample_rate = gaze_data.sample_rate flash_time = parameters.get("time_flash") # duration of each stimulus - stim_length = parameters.get("stim_length") # number of stimuli per inquiry + # number of stimuli per inquiry + stim_length = parameters.get("stim_length") log.info(f"Channels read from csv: {channels}") log.info(f"Device type: {type_amp}, fs={sample_rate}") channel_map = analysis_channels(channels, device_spec) - channels_used = [channels[i] for i, keep in enumerate(channel_map) if keep == 1] + channels_used = [channels[i] + for i, keep in enumerate(channel_map) if keep == 1] log.info(f'Channels used in analysis: {channels_used}') data, _fs = gaze_data.by_channel() @@ -287,10 +295,12 @@ def analyze_gaze( ) ''' Trigger_timing includes PROMPT and excludes FIXATION ''' - target_symbols = trigger_symbols[0::stim_length + 1] # target symbols are the PROMPT triggers + # target symbols are the PROMPT triggers + target_symbols = trigger_symbols[0::stim_length + 1] # Use trigger_timing to generate time windows for each letter flashing # Take every 10th trigger as the start point of timing. - inq_start = trigger_timing[1::stim_length + 1] # start of each inquiry (here we jump over prompts) + # start of each inquiry (here we jump over prompts) + inq_start = trigger_timing[1::stim_length + 1] # Extract the inquiries dictionary with keys as target symbols and values as inquiry windows: inquiries_dict, inquiries_list, _ = model.reshaper( @@ -304,13 +314,16 @@ def analyze_gaze( ) # Apply preprocessing: - inquiry_length = inquiries_list[0].shape[1] # number of time samples in each inquiry + # number of time samples in each inquiry + inquiry_length = inquiries_list[0].shape[1] predefined_dimensions = 4 # left_x, left_y, right_x, right_y - preprocessed_array = np.zeros((len(inquiries_list), predefined_dimensions, inquiry_length)) + preprocessed_array = np.zeros( + (len(inquiries_list), predefined_dimensions, inquiry_length)) # Extract left_x, left_y, right_x, right_y for each inquiry for j in range(len(inquiries_list)): left_eye, right_eye, _, _, _, _ = extract_eye_info(inquiries_list[j]) - preprocessed_array[j] = np.concatenate((left_eye.T, right_eye.T,), axis=0) + preprocessed_array[j] = np.concatenate( + (left_eye.T, right_eye.T,), axis=0) preprocessed_data = {i: [] for i in symbol_set} for i in symbol_set: @@ -319,8 +332,10 @@ def analyze_gaze( continue for j in range(len(inquiries_dict[i])): - left_eye, right_eye, _, _, _, _ = extract_eye_info(inquiries_dict[i][j]) - preprocessed_data[i].append((np.concatenate((left_eye.T, right_eye.T), axis=0))) + left_eye, right_eye, _, _, _, _ = extract_eye_info( + inquiries_dict[i][j]) + preprocessed_data[i].append( + (np.concatenate((left_eye.T, right_eye.T), axis=0))) # Inquiries x All Dimensions (left_x, left_y, right_x, right_y) x Time preprocessed_data[i] = np.array(preprocessed_data[i]) @@ -362,7 +377,8 @@ def analyze_gaze( # Split the data into train and test sets & fit the model: centralized_gaze_data = np.zeros_like(preprocessed_array) for i, (_, sym) in enumerate(zip(preprocessed_array, target_symbols)): - centralized_gaze_data[i] = model.subtract_mean(preprocessed_array[i], time_average[sym]) + centralized_gaze_data[i] = model.subtract_mean( + preprocessed_array[i], time_average[sym]) reshaped_data = centralized_gaze_data.reshape( (len(centralized_gaze_data), inquiry_length * predefined_dimensions)) @@ -451,7 +467,8 @@ def offline_analysis( if spec.is_active) active_raw_data_paths = (Path(data_folder, raw_data_filename(device_spec)) for device_spec in active_devices) - data_file_paths = [str(path) for path in active_raw_data_paths if path.exists()] + data_file_paths = [str(path) + for path in active_raw_data_paths if path.exists()] num_devices = len(data_file_paths) assert num_devices >= 1 and num_devices < 3, ( @@ -488,7 +505,8 @@ def offline_analysis( n_iterations=n_iterations, ) - log.info(f"EEG Accuracy: {eeg_acc}, Gaze Accuracy: {gaze_acc}, Fusion Accuracy: {fusion_acc}") + log.info( + f"EEG Accuracy: {eeg_acc}, Gaze Accuracy: {gaze_acc}, Fusion Accuracy: {fusion_acc}") # The average gaze model accuracy: avg_testing_acc_gaze = round(np.mean(gaze_acc), 3) @@ -556,9 +574,12 @@ def main(): "--parameters_file", default=DEFAULT_PARAMETERS_PATH, help="Path to the BciPy parameters file.") - parser.add_argument("-s", "--save_figures", action="store_true", help="Save figures after training.") - parser.add_argument("-v", "--show_figures", action="store_true", help="Show figures after training.") - parser.add_argument("-i", "--iterations", type=int, default=10, help="Number of iterations for fusion analysis.") + parser.add_argument("-s", "--save_figures", + action="store_true", help="Save figures after training.") + parser.add_argument("-v", "--show_figures", + action="store_true", help="Show figures after training.") + parser.add_argument("-i", "--iterations", type=int, default=10, + help="Number of iterations for fusion analysis.") parser.add_argument( "--alert", dest="alert", diff --git a/bcipy/signal/model/pca_rda_kde/pca_rda_kde.py b/bcipy/signal/model/pca_rda_kde/pca_rda_kde.py index 66439a427..53815126a 100644 --- a/bcipy/signal/model/pca_rda_kde/pca_rda_kde.py +++ b/bcipy/signal/model/pca_rda_kde/pca_rda_kde.py @@ -49,13 +49,15 @@ def fit(self, train_data: np.array, train_labels: np.array) -> SignalModel: """ model = Pipeline( [ - ChannelWisePrincipalComponentAnalysis(n_components=self.pca_n_components, num_ch=train_data.shape[0]), + ChannelWisePrincipalComponentAnalysis( + n_components=self.pca_n_components, num_ch=train_data.shape[0]), RegularizedDiscriminantAnalysis(), ] ) # Find the optimal gamma + lambda values - arg_cv = cross_validation(train_data, train_labels, model=model, k_folds=self.k_folds) + arg_cv = cross_validation( + train_data, train_labels, model=model, k_folds=self.k_folds) # Get the AUC using those optimized gamma + lambda rda_index = 1 # the index in the pipeline @@ -102,7 +104,8 @@ def evaluate(self, test_data: np.array, test_labels: np.array) -> ModelEvaluatio ModelEvaluationReport: stores AUC """ if not self.ready_to_predict: - raise SignalException("must use model.fit() before model.evaluate()") + raise SignalException( + "must use model.fit() before model.evaluate()") tmp_model = Pipeline([self.model.pipeline[0], self.model.pipeline[1]]) @@ -134,18 +137,22 @@ def compute_likelihood_ratio(self, data: np.array, inquiry: List[str], symbol_se """ if not self.ready_to_predict: - raise SignalException("must use model.fit() before model.predict()") + raise SignalException( + "must use model.fit() before model.predict()") # Evaluate likelihood probabilities for p(e|l=1) and p(e|l=0) log_likelihoods = self.model.transform(data) - subset_likelihood_ratios = np.exp(log_likelihoods[:, 1] - log_likelihoods[:, 0]) + subset_likelihood_ratios = np.exp( + log_likelihoods[:, 1] - log_likelihoods[:, 0]) # Restrict multiplicative updates to a reasonable range - subset_likelihood_ratios = np.clip(subset_likelihood_ratios, self.min, self.max) + subset_likelihood_ratios = np.clip( + subset_likelihood_ratios, self.min, self.max) # Apply likelihood ratios to entire symbol set. likelihood_ratios = np.ones(len(symbol_set)) for idx in range(len(subset_likelihood_ratios)): - likelihood_ratios[symbol_set.index(inquiry[idx])] *= subset_likelihood_ratios[idx] + likelihood_ratios[symbol_set.index( + inquiry[idx])] *= subset_likelihood_ratios[idx] return likelihood_ratios # used in multimodal update def compute_class_probabilities(self, data: np.ndarray) -> np.ndarray: @@ -156,7 +163,8 @@ def compute_class_probabilities(self, data: np.ndarray) -> np.ndarray: probability for the two labels. """ if not self.ready_to_predict: - raise SignalException("must use model.fit() before model.predict_proba()") + raise SignalException( + "must use model.fit() before model.predict_proba()") # Model originally produces p(eeg | label). We want p(label | eeg): # @@ -178,7 +186,8 @@ def evaluate_likelihood(self, data: np.ndarray) -> np.ndarray: p(e | l=1), p(e | l=0) """ if not self.ready_to_predict: - raise SignalException("must use model.fit() before model.predict_proba()") + raise SignalException( + "must use model.fit() before model.predict_proba()") log_scores_class_0 = self.model.transform(data)[:, 0] log_scores_class_1 = self.model.transform(data)[:, 1] @@ -191,7 +200,8 @@ def predict(self, data: np.ndarray) -> np.ndarray: predictions (np.ndarray): shape (num_items,) - the predicted label for each item. """ if not self.ready_to_predict: - raise SignalException("must use model.fit() before model.predict()") + raise SignalException( + "must use model.fit() before model.predict()") posterior = self.compute_class_probabilities(data) predictions = np.argmax(posterior, axis=1) @@ -205,7 +215,8 @@ def predict_proba(self, data: np.ndarray) -> np.ndarray: probability for the two labels. """ if not self.ready_to_predict: - raise SignalException("must use model.fit() before model.predict_proba()") + raise SignalException( + "must use model.fit() before model.predict_proba()") return self.compute_class_probabilities(data) diff --git a/bcipy/signal/model/pipeline.py b/bcipy/signal/model/pipeline.py index f89243f66..3ac6c79e1 100644 --- a/bcipy/signal/model/pipeline.py +++ b/bcipy/signal/model/pipeline.py @@ -36,7 +36,8 @@ def fit(self, x, y): y(ndarray[int]): of desired shape """ self.line_el = [x] for i in range(len(self.pipeline) - 1): - self.line_el.append(self.pipeline[i].fit_transform(self.line_el[i], y)) + self.line_el.append( + self.pipeline[i].fit_transform(self.line_el[i], y)) self.pipeline[-1].fit(self.line_el[-1], y) @@ -48,16 +49,18 @@ def fit_transform(self, x, y): self.line_el = [x] for i in range(len(self.pipeline) - 1): - self.line_el.append(self.pipeline[i].fit_transform(self.line_el[i], y)) + self.line_el.append( + self.pipeline[i].fit_transform(self.line_el[i], y)) arg = self.pipeline[-1].fit_transform(self.line_el[-1], y) return arg def transform(self, x): - """ Applies transform on all functions. Prior to using transform on + """Applies transform on all functions. Prior to using transform on pipeline, it should be trained. Args: - x(ndarray[float]): of desired shape """ + x(ndarray[float]): of desired shape + """ self.line_el = [x] for i in range(len(self.pipeline)): self.line_el.append(self.pipeline[i].transform(self.line_el[i])) diff --git a/bcipy/signal/model/rda_kde/rda_kde.py b/bcipy/signal/model/rda_kde/rda_kde.py index 785f7c9dc..ab0ff82a6 100644 --- a/bcipy/signal/model/rda_kde/rda_kde.py +++ b/bcipy/signal/model/rda_kde/rda_kde.py @@ -43,7 +43,8 @@ def fit(self, train_data: np.array, train_labels: np.array) -> SignalModel: model = Pipeline([MockPCA(), RegularizedDiscriminantAnalysis()]) # Find the optimal gamma + lambda values - arg_cv = cross_validation(train_data, train_labels, model=model, k_folds=self.k_folds) + arg_cv = cross_validation( + train_data, train_labels, model=model, k_folds=self.k_folds) # Get the AUC using those optimized gamma + lambda rda_index = 1 # the index in the pipeline @@ -92,7 +93,8 @@ def evaluate(self, test_data: np.array, test_labels: np.array) -> ModelEvaluatio ModelEvaluationReport: stores AUC """ if not self._ready_to_predict: - raise SignalException("must use model.fit() before model.evaluate()") + raise SignalException( + "must use model.fit() before model.evaluate()") tmp_model = Pipeline([self.model.pipeline[0], self.model.pipeline[1]]) @@ -105,8 +107,7 @@ def evaluate(self, test_data: np.array, test_labels: np.array) -> ModelEvaluatio return ModelEvaluationReport(auc) def predict(self, data: np.array, inquiry: List[str], symbol_set: List[str]) -> np.array: - """ - For each trial in `data`, compute a likelihood ratio to update that symbol's probability. + """For each trial in `data`, compute a likelihood ratio to update that symbol's probability. Rather than just computing an update p(e|l=+) for the seen symbol and p(e|l=-) for all unseen symbols, we compute a likelihood ratio p(e | l=+) / p(e | l=-) to update the seen symbol, and all other symbols can receive a multiplicative update of 1. @@ -124,18 +125,22 @@ def predict(self, data: np.array, inquiry: List[str], symbol_set: List[str]) -> """ if not self._ready_to_predict: - raise SignalException("must use model.fit() before model.predict()") + raise SignalException( + "must use model.fit() before model.predict()") # Evaluate likelihood probabilities for p(e|l=1) and p(e|l=0) log_likelihoods = self.model.transform(data) - subset_likelihood_ratios = np.exp(log_likelihoods[:, 1] - log_likelihoods[:, 0]) + subset_likelihood_ratios = np.exp( + log_likelihoods[:, 1] - log_likelihoods[:, 0]) # Restrict multiplicative updates to a reasonable range - subset_likelihood_ratios = np.clip(subset_likelihood_ratios, self.min, self.max) + subset_likelihood_ratios = np.clip( + subset_likelihood_ratios, self.min, self.max) # Apply likelihood ratios to entire symbol set. likelihood_ratios = np.ones(len(symbol_set)) for idx in range(len(subset_likelihood_ratios)): - likelihood_ratios[symbol_set.index(inquiry[idx])] *= subset_likelihood_ratios[idx] + likelihood_ratios[symbol_set.index( + inquiry[idx])] *= subset_likelihood_ratios[idx] return likelihood_ratios def predict_proba(self, data: np.array) -> np.array: @@ -146,7 +151,8 @@ def predict_proba(self, data: np.array) -> np.array: probability for the two labels. """ if not self._ready_to_predict: - raise SignalException("must use model.fit() before model.predict_proba()") + raise SignalException( + "must use model.fit() before model.predict_proba()") # Model originally produces p(eeg | label). We want p(label | eeg): # diff --git a/bcipy/signal/model/switch_model.py b/bcipy/signal/model/switch_model.py index d05b58ced..4a2b64bff 100644 --- a/bcipy/signal/model/switch_model.py +++ b/bcipy/signal/model/switch_model.py @@ -30,9 +30,7 @@ def __init__(self, error_prob: float = 0.05): self.error_prob = error_prob def fit(self, training_data: np.ndarray, training_labels: np.ndarray): - """ - @override - """ + """@override""" return self def evaluate(self, test_data: np.ndarray, test_labels: np.ndarray): @@ -45,8 +43,7 @@ def predict(self, data: np.ndarray, inquiry: List[str], def compute_likelihood_ratio(self, data: np.array, inquiry: List[str], symbol_set: List[str]) -> np.array: - """ - For each trial in `data`, compute a likelihood ratio to update that symbol's probability. + """For each trial in `data`, compute a likelihood ratio to update that symbol's probability. Args: data (np.array): button press data data a single element of 0 or 1; shape (1,) diff --git a/bcipy/signal/process/decomposition/cwt.py b/bcipy/signal/process/decomposition/cwt.py index 920ae5fcf..72279c036 100644 --- a/bcipy/signal/process/decomposition/cwt.py +++ b/bcipy/signal/process/decomposition/cwt.py @@ -26,7 +26,8 @@ def continuous_wavelet_transform( scales = pywt.central_frequency(wavelet) * fs / np.array(freq) all_coeffs = [] for trial in data: - coeffs, _ = pywt.cwt(trial, scales, wavelet) # shape == (scales, channels, time) + # shape == (scales, channels, time) + coeffs, _ = pywt.cwt(trial, scales, wavelet) all_coeffs.append(coeffs) final_data = np.stack(all_coeffs) diff --git a/bcipy/signal/process/extract_gaze.py b/bcipy/signal/process/extract_gaze.py index 77183565c..8516ce7ad 100644 --- a/bcipy/signal/process/extract_gaze.py +++ b/bcipy/signal/process/extract_gaze.py @@ -4,7 +4,7 @@ def extract_eye_info(data): - """"Rearrange the dimensions of gaze inquiry data and reshape it to num_channels x num_samples + """Rearrange the dimensions of gaze inquiry data and reshape it to num_channels x num_samples Extract Left and Right Eye info from data. Remove all blinks, do necessary preprocessing. The data is extracted according to the channel map: ['device_ts, 'system_ts', 'left_x', 'left_y', 'left_pupil', 'right_x', 'right_y', 'right_pupil'] @@ -16,7 +16,6 @@ def extract_eye_info(data): left_eye (np.ndarray), left_pupil (List(float)) right_eye (np.ndarray), right_pupil (List(float)) """ - # Extract samples from channels lx = data[2, :] ly = data[3, :] diff --git a/bcipy/signal/process/filter.py b/bcipy/signal/process/filter.py index daea1c95d..b58ad2100 100644 --- a/bcipy/signal/process/filter.py +++ b/bcipy/signal/process/filter.py @@ -21,7 +21,8 @@ class Bandpass: def __init__(self, lo, hi, sample_rate_hz, order=5): nyq = 0.5 * sample_rate_hz lo, hi = lo / nyq, hi / nyq - self.sos = butter(order, [lo, hi], analog=False, btype="band", output="sos") + self.sos = butter(order, [lo, hi], analog=False, + btype="band", output="sos") def __call__(self, data: np.ndarray, fs: int) -> Tuple[np.ndarray, int]: return sosfiltfilt(self.sos, data), fs @@ -38,7 +39,9 @@ def filter_inquiries(inquiries: np.ndarray, transform, sample_rate: int) -> Tupl old_shape = inquiries.shape # (Channels*Inquiry, Samples) inq_flatten = inquiries.reshape(-1, old_shape[-1]) - inq_flatten_filtered, transformed_sample_rate = transform(inq_flatten, sample_rate) + inq_flatten_filtered, transformed_sample_rate = transform( + inq_flatten, sample_rate) # (Channels, Inquiries, Samples) - inquiries = inq_flatten_filtered.reshape(*old_shape[:2], inq_flatten_filtered.shape[-1]) + inquiries = inq_flatten_filtered.reshape( + *old_shape[:2], inq_flatten_filtered.shape[-1]) return inquiries, transformed_sample_rate diff --git a/bcipy/signal/tests/evaluate/test_artifact.py b/bcipy/signal/tests/evaluate/test_artifact.py index ec201561e..f89236a70 100644 --- a/bcipy/signal/tests/evaluate/test_artifact.py +++ b/bcipy/signal/tests/evaluate/test_artifact.py @@ -43,7 +43,8 @@ def tearDown(self) -> None: def test_artifact_detection_init(self): """Test the ArtifactDetection class.""" - ar = ArtifactDetection(raw_data=self.raw_data, parameters=self.parameters, device_spec=self.device_spec) + ar = ArtifactDetection( + raw_data=self.raw_data, parameters=self.parameters, device_spec=self.device_spec) self.assertIsInstance(ar, ArtifactDetection) self.assertFalse(ar.analysis_done) self.assertIsNone(ar.dropped) @@ -101,7 +102,8 @@ def test_artifact_detection_detect_artifacts(self): device_spec=self.device_spec) labels = [mock()] expected_label_response = f'{len(labels)} artifacts found in the data.' - when(ar).label_artifacts(extra_labels=ar.session_triggers).thenReturn(labels) + when(ar).label_artifacts( + extra_labels=ar.session_triggers).thenReturn(labels) response_labels, response_dropped = ar.detect_artifacts() self.assertEqual(response_labels, expected_label_response) self.assertEqual(response_dropped, 0) @@ -122,9 +124,12 @@ def test_artifact_type(self): def test_default_artifact_parameters(self): """Test the DefaultArtifactParameters class.""" - self.assertEqual(DefaultArtifactParameters.EOG_THRESHOLD.value, 5.5e-05) - self.assertEqual(DefaultArtifactParameters.VOlTAGE_LABEL_DURATION.value, 0.25) - self.assertEqual(DefaultArtifactParameters.ARTIFACT_LABELLED_FILENAME.value, 'artifacts.fif') + self.assertEqual( + DefaultArtifactParameters.EOG_THRESHOLD.value, 5.5e-05) + self.assertEqual( + DefaultArtifactParameters.VOlTAGE_LABEL_DURATION.value, 0.25) + self.assertEqual( + DefaultArtifactParameters.ARTIFACT_LABELLED_FILENAME.value, 'artifacts.fif') if __name__ == '__main__': diff --git a/bcipy/signal/tests/model/pca_rda_kde/test_pca_rda_kde.py b/bcipy/signal/tests/model/pca_rda_kde/test_pca_rda_kde.py index dabca5f2a..0def715e4 100644 --- a/bcipy/signal/tests/model/pca_rda_kde/test_pca_rda_kde.py +++ b/bcipy/signal/tests/model/pca_rda_kde/test_pca_rda_kde.py @@ -20,7 +20,8 @@ ChannelWisePrincipalComponentAnalysis from bcipy.signal.model.pipeline import Pipeline -expected_output_folder = Path(__file__).absolute().parent.parent / "unit_test_expected_output" +expected_output_folder = Path(__file__).absolute( +).parent.parent / "unit_test_expected_output" class ModelSetup(unittest.TestCase): @@ -37,8 +38,10 @@ def setUpClass(cls): # Generate Gaussian random data cls.pos_mean, cls.pos_std = 0, 0.5 cls.neg_mean, cls.neg_std = 1, 0.5 - x_pos = cls.pos_mean + cls.pos_std * np.random.randn(cls.num_channel, cls.num_x_pos, cls.dim_x) - x_neg = cls.neg_mean + cls.neg_std * np.random.randn(cls.num_channel, cls.num_x_neg, cls.dim_x) + x_pos = cls.pos_mean + cls.pos_std * \ + np.random.randn(cls.num_channel, cls.num_x_pos, cls.dim_x) + x_neg = cls.neg_mean + cls.neg_std * \ + np.random.randn(cls.num_channel, cls.num_x_neg, cls.dim_x) y_pos = np.ones(cls.num_x_pos) y_neg = np.zeros(cls.num_x_neg) @@ -76,7 +79,8 @@ def setUp(self): def test_pca(self): # .fit() then .transform() should match .fit_transform() - pca = ChannelWisePrincipalComponentAnalysis(n_components=0.9, num_ch=self.num_channel) + pca = ChannelWisePrincipalComponentAnalysis( + n_components=0.9, num_ch=self.num_channel) pca.fit(self.x) x_reduced = pca.transform(self.x) x_reduced_2 = pca.fit_transform(self.x) @@ -98,7 +102,8 @@ def test_kde_plot(self): """ # generate some dummy data n = 100 - x = np.concatenate((np.random.normal(0, 1, int(0.3 * n)), np.random.normal(5, 1, int(0.7 * n))))[:, np.newaxis] + x = np.concatenate((np.random.normal(0, 1, int(0.3 * n)), + np.random.normal(5, 1, int(0.7 * n))))[:, np.newaxis] # append 0 label to all data as we are interested in a single class case y = np.zeros(x.shape) @@ -107,17 +112,20 @@ def test_kde_plot(self): x_plot = np.linspace(-5, 10, 1000)[:, np.newaxis] # generate a dummy density function to sample data from - true_dens = 0.3 * norm(0, 1).pdf(x_plot[:, 0]) + 0.7 * norm(5, 1).pdf(x_plot[:, 0]) + true_dens = 0.3 * \ + norm(0, 1).pdf(x_plot[:, 0]) + 0.7 * norm(5, 1).pdf(x_plot[:, 0]) fig, ax = plt.subplots() - ax.fill(x_plot[:, 0], true_dens, fc="black", alpha=0.2, label="input distribution") + ax.fill(x_plot[:, 0], true_dens, fc="black", + alpha=0.2, label="input distribution") # try different kernels and show how the look like for kernel in ["gaussian", "tophat", "epanechnikov"]: kde = KernelDensityEstimate(kernel=kernel, scores=x, num_cls=1) kde.fit(x, y) log_dens = kde.list_den_est[0].score_samples(x_plot) - ax.plot(x_plot[:, 0], np.exp(log_dens), "-", label=f"kernel = '{kernel}'") + ax.plot(x_plot[:, 0], np.exp(log_dens), + "-", label=f"kernel = '{kernel}'") ax.plot(x[:, 0], -0.005 - 0.01 * np.random.random(x.shape[0]), "+k") @@ -126,7 +134,8 @@ def test_kde_plot(self): return fig def test_kde_values(self): - pca = ChannelWisePrincipalComponentAnalysis(n_components=0.9, num_ch=self.num_channel) + pca = ChannelWisePrincipalComponentAnalysis( + n_components=0.9, num_ch=self.num_channel) rda = RegularizedDiscriminantAnalysis() kde = KernelDensityEstimate() @@ -139,7 +148,8 @@ def test_kde_values(self): self.assertTrue(np.allclose(z, z_2)) # output values should be correct - expected = np.load(expected_output_folder / "test_kde_values.expected.npy") + expected = np.load(expected_output_folder / + "test_kde_values.expected.npy") self.assertTrue(np.allclose(z, expected)) def test_cv(self): @@ -150,7 +160,8 @@ def test_cv(self): before fitting it - it is not clear how sensitive this test is to changes in the code or input data, so this may be a weak test of cross_validation(). """ - pca = ChannelWisePrincipalComponentAnalysis(n_components=0.9, num_ch=self.num_channel) + pca = ChannelWisePrincipalComponentAnalysis( + n_components=0.9, num_ch=self.num_channel) rda = RegularizedDiscriminantAnalysis() pipeline = Pipeline([pca, rda]) @@ -160,7 +171,8 @@ def test_cv(self): self.assertAlmostEqual(gam, 0.1) def test_rda(self): - pca = ChannelWisePrincipalComponentAnalysis(n_components=0.9, num_ch=self.num_channel) + pca = ChannelWisePrincipalComponentAnalysis( + n_components=0.9, num_ch=self.num_channel) rda = RegularizedDiscriminantAnalysis() pipeline = Pipeline([pca, rda]) @@ -205,13 +217,17 @@ def test_fit_compute_likelihood_ratio(self): num_x_p = 1 num_x_n = 9 - x_test_pos = self.pos_mean + self.pos_std * np.random.randn(self.num_channel, num_x_p, self.dim_x) - x_test_neg = self.neg_mean + self.neg_std * np.random.randn(self.num_channel, num_x_n, self.dim_x) - x_test = np.concatenate((x_test_pos, x_test_neg), 1) # Target letter is first + x_test_pos = self.pos_mean + self.pos_std * \ + np.random.randn(self.num_channel, num_x_p, self.dim_x) + x_test_neg = self.neg_mean + self.neg_std * \ + np.random.randn(self.num_channel, num_x_n, self.dim_x) + # Target letter is first + x_test = np.concatenate((x_test_pos, x_test_neg), 1) letters = alp[10: 10 + num_x_p + num_x_n] # Target letter is K - lik_r = self.model.compute_likelihood_ratio(data=x_test, inquiry=letters, symbol_set=alp) + lik_r = self.model.compute_likelihood_ratio( + data=x_test, inquiry=letters, symbol_set=alp) fig, ax = plt.subplots() ax.plot(np.arange(len(alp)), lik_r, "ro") ax.set_xticks(np.arange(len(alp))) @@ -228,7 +244,8 @@ def test_save_load(self): symbol_set = alphabet() inquiry = symbol_set[:n_trial] data = np.random.randn(self.num_channel, n_trial, self.dim_x) - output_before = self.model.compute_likelihood_ratio(data=data, inquiry=inquiry, symbol_set=symbol_set) + output_before = self.model.compute_likelihood_ratio( + data=data, inquiry=inquiry, symbol_set=symbol_set) checkpoint_path = self.tmp_dir / "model.pkl" save_model(self.model, checkpoint_path) @@ -237,19 +254,22 @@ def test_save_load(self): self.assertEqual(1, len(loaded_models)) other_model = loaded_models[0] self.assertEqual(self.model.k_folds, other_model.k_folds) - output_after = other_model.compute_likelihood_ratio(data=data, inquiry=inquiry, symbol_set=symbol_set) + output_after = other_model.compute_likelihood_ratio( + data=data, inquiry=inquiry, symbol_set=symbol_set) self.assertTrue(np.allclose(output_before, output_after)) try: other_model.predict_proba(self.x) except Exception: - pytest.fail("Should be able to compute predict_proba after loading a model") + pytest.fail( + "Should be able to compute predict_proba after loading a model") def test_predict_before_fit(self): model = PcaRdaKdeModel(k_folds=10) with self.assertRaises(SignalException): - model.compute_likelihood_ratio(self.x, inquiry=["A"], symbol_set=alphabet()) + model.compute_likelihood_ratio( + self.x, inquiry=["A"], symbol_set=alphabet()) def test_evaluate_before_fit(self): model = PcaRdaKdeModel(k_folds=10) diff --git a/bcipy/signal/tests/model/rda_kde/test_rda_kde.py b/bcipy/signal/tests/model/rda_kde/test_rda_kde.py index b06cf974e..c2b7b6177 100644 --- a/bcipy/signal/tests/model/rda_kde/test_rda_kde.py +++ b/bcipy/signal/tests/model/rda_kde/test_rda_kde.py @@ -17,7 +17,8 @@ from bcipy.signal.model.dimensionality_reduction import MockPCA from bcipy.signal.model.pipeline import Pipeline -expected_output_folder = Path(__file__).absolute().parent.parent / "unit_test_expected_output" +expected_output_folder = Path(__file__).absolute( +).parent.parent / "unit_test_expected_output" class ModelSetup(unittest.TestCase): @@ -34,8 +35,10 @@ def setUpClass(cls): # Generate Gaussian random data cls.pos_mean, cls.pos_std = 0, 0.5 cls.neg_mean, cls.neg_std = 1, 0.5 - x_pos = cls.pos_mean + cls.pos_std * np.random.randn(cls.num_channel, cls.num_x_pos, cls.dim_x) - x_neg = cls.neg_mean + cls.neg_std * np.random.randn(cls.num_channel, cls.num_x_neg, cls.dim_x) + x_pos = cls.pos_mean + cls.pos_std * \ + np.random.randn(cls.num_channel, cls.num_x_pos, cls.dim_x) + x_neg = cls.neg_mean + cls.neg_std * \ + np.random.randn(cls.num_channel, cls.num_x_neg, cls.dim_x) y_pos = np.ones(cls.num_x_pos) y_neg = np.zeros(cls.num_x_neg) @@ -78,7 +81,8 @@ def setUp(self): def test_kde_plot(self): # generate some dummy data n = 100 - x = np.concatenate((np.random.normal(0, 1, int(0.3 * n)), np.random.normal(5, 1, int(0.7 * n))))[:, np.newaxis] + x = np.concatenate((np.random.normal(0, 1, int(0.3 * n)), + np.random.normal(5, 1, int(0.7 * n))))[:, np.newaxis] # append 0 label to all data as we are interested in a single class case y = np.zeros(x.shape) @@ -87,17 +91,20 @@ def test_kde_plot(self): x_plot = np.linspace(-5, 10, 1000)[:, np.newaxis] # generate a dummy density function to sample data from - true_dens = 0.3 * norm(0, 1).pdf(x_plot[:, 0]) + 0.7 * norm(5, 1).pdf(x_plot[:, 0]) + true_dens = 0.3 * \ + norm(0, 1).pdf(x_plot[:, 0]) + 0.7 * norm(5, 1).pdf(x_plot[:, 0]) fig, ax = plt.subplots() - ax.fill(x_plot[:, 0], true_dens, fc="black", alpha=0.2, label="input distribution") + ax.fill(x_plot[:, 0], true_dens, fc="black", + alpha=0.2, label="input distribution") # try different kernels and show how the look like for kernel in ["gaussian", "tophat", "epanechnikov"]: kde = KernelDensityEstimate(kernel=kernel, scores=x, num_cls=1) kde.fit(x, y) log_dens = kde.list_den_est[0].score_samples(x_plot) - ax.plot(x_plot[:, 0], np.exp(log_dens), "-", label=f"kernel = '{kernel}'") + ax.plot(x_plot[:, 0], np.exp(log_dens), + "-", label=f"kernel = '{kernel}'") ax.plot(x[:, 0], -0.005 - 0.01 * np.random.random(x.shape[0]), "+k") @@ -177,13 +184,17 @@ def test_fit_predict(self): num_x_p = 1 num_x_n = 9 - x_test_pos = self.pos_mean + self.pos_std * np.random.randn(self.num_channel, num_x_p, self.dim_x) - x_test_neg = self.neg_mean + self.neg_std * np.random.randn(self.num_channel, num_x_n, self.dim_x) - x_test = np.concatenate((x_test_pos, x_test_neg), 1) # Target letter is first + x_test_pos = self.pos_mean + self.pos_std * \ + np.random.randn(self.num_channel, num_x_p, self.dim_x) + x_test_neg = self.neg_mean + self.neg_std * \ + np.random.randn(self.num_channel, num_x_n, self.dim_x) + # Target letter is first + x_test = np.concatenate((x_test_pos, x_test_neg), 1) letters = alp[10: 10 + num_x_p + num_x_n] # Target letter is K - lik_r = self.model.predict(data=x_test, inquiry=letters, symbol_set=alp) + lik_r = self.model.predict( + data=x_test, inquiry=letters, symbol_set=alp) fig, ax = plt.subplots() ax.plot(np.arange(len(alp)), lik_r, "ro") ax.set_xticks(np.arange(len(alp))) @@ -200,13 +211,15 @@ def test_save_load(self): symbol_set = alphabet() inquiry = symbol_set[:n_trial] data = np.random.randn(self.num_channel, n_trial, self.dim_x) - output_before = self.model.predict(data=data, inquiry=inquiry, symbol_set=symbol_set) + output_before = self.model.predict( + data=data, inquiry=inquiry, symbol_set=symbol_set) checkpoint_path = self.tmp_dir / "model.pkl" self.model.save(checkpoint_path) other_model = RdaKdeModel(k_folds=self.model.k_folds) other_model.load(checkpoint_path) - output_after = other_model.predict(data=data, inquiry=inquiry, symbol_set=symbol_set) + output_after = other_model.predict( + data=data, inquiry=inquiry, symbol_set=symbol_set) self.assertTrue(np.allclose(output_before, output_after)) diff --git a/bcipy/signal/tests/model/test_offline_analysis.py b/bcipy/signal/tests/model/test_offline_analysis.py index 3cc21d78f..bad8ad5cf 100644 --- a/bcipy/signal/tests/model/test_offline_analysis.py +++ b/bcipy/signal/tests/model/test_offline_analysis.py @@ -18,7 +18,8 @@ pwd = Path(__file__).absolute().parent input_folder = pwd / "integration_test_input" -expected_output_folder = pwd / "integration_test_expected_output" # global for the purpose of pytest-mpl decorator +# global for the purpose of pytest-mpl decorator +expected_output_folder = pwd / "integration_test_expected_output" @pytest.mark.slow @@ -48,10 +49,13 @@ def setUpClass(cls): shutil.copyfileobj(f_source, f_dest) # copy the other required inputs into tmp_dir - shutil.copyfile(eeg_input_folder / TRIGGER_FILENAME, cls.tmp_dir / TRIGGER_FILENAME) - shutil.copyfile(eeg_input_folder / DEFAULT_DEVICE_SPEC_FILENAME, cls.tmp_dir / DEFAULT_DEVICE_SPEC_FILENAME) + shutil.copyfile(eeg_input_folder / TRIGGER_FILENAME, + cls.tmp_dir / TRIGGER_FILENAME) + shutil.copyfile(eeg_input_folder / DEFAULT_DEVICE_SPEC_FILENAME, + cls.tmp_dir / DEFAULT_DEVICE_SPEC_FILENAME) - params_path = pwd.parent.parent.parent / "parameters" / DEFAULT_PARAMETERS_FILENAME + params_path = pwd.parent.parent.parent / \ + "parameters" / DEFAULT_PARAMETERS_FILENAME cls.parameters = load_json_parameters(params_path, value_cast=True) models = offline_analysis( str(cls.tmp_dir), @@ -74,8 +78,10 @@ def get_auc(model_filename): return float(match[1]) def test_model_auc(self): - expected_auc = self.get_auc(list(expected_output_folder.glob("model_eeg_*.pkl"))[0].name) - found_auc = self.get_auc(list(self.tmp_dir.glob("model_eeg_*.pkl"))[0].name) + expected_auc = self.get_auc( + list(expected_output_folder.glob("model_eeg_*.pkl"))[0].name) + found_auc = self.get_auc( + list(self.tmp_dir.glob("model_eeg_*.pkl"))[0].name) self.assertAlmostEqual(expected_auc, found_auc, delta=0.005) def test_model_metadata_loads(self): @@ -111,14 +117,16 @@ def setUpClass(cls): shutil.copyfileobj(f_source, f_dest) # copy the other required inputs into tmp_dir - shutil.copyfile(eye_tracking_input_folder / TRIGGER_FILENAME, cls.tmp_dir / TRIGGER_FILENAME) + shutil.copyfile(eye_tracking_input_folder / + TRIGGER_FILENAME, cls.tmp_dir / TRIGGER_FILENAME) shutil.copyfile( eye_tracking_input_folder / DEFAULT_DEVICE_SPEC_FILENAME, cls.tmp_dir / DEFAULT_DEVICE_SPEC_FILENAME) - params_path = pwd.parent.parent.parent / "parameters" / DEFAULT_PARAMETERS_FILENAME + params_path = pwd.parent.parent.parent / \ + "parameters" / DEFAULT_PARAMETERS_FILENAME cls.parameters = load_json_parameters(params_path, value_cast=True) models = offline_analysis( str(cls.tmp_dir), @@ -144,8 +152,10 @@ def get_acc(model_filename): return float(match[1]) def test_model_acc(self): - expected_auc = self.get_acc(list(expected_output_folder.glob("model_eyetracker_*.pkl"))[0].name) - found_auc = self.get_acc(list(self.tmp_dir.glob("model_eyetracker_*.pkl"))[0].name) + expected_auc = self.get_acc( + list(expected_output_folder.glob("model_eyetracker_*.pkl"))[0].name) + found_auc = self.get_acc( + list(self.tmp_dir.glob("model_eyetracker_*.pkl"))[0].name) self.assertAlmostEqual(expected_auc, found_auc, delta=0.005) @@ -184,10 +194,13 @@ def setUpClass(cls): shutil.copyfileobj(f_source, f_dest) # copy the other required inputs into tmp_dir - shutil.copyfile(et_input_folder / TRIGGER_FILENAME, cls.tmp_dir / TRIGGER_FILENAME) - shutil.copyfile(fusion_input_folder / DEFAULT_DEVICE_SPEC_FILENAME, cls.tmp_dir / DEFAULT_DEVICE_SPEC_FILENAME) + shutil.copyfile(et_input_folder / TRIGGER_FILENAME, + cls.tmp_dir / TRIGGER_FILENAME) + shutil.copyfile(fusion_input_folder / DEFAULT_DEVICE_SPEC_FILENAME, + cls.tmp_dir / DEFAULT_DEVICE_SPEC_FILENAME) - params_path = pwd.parent.parent.parent / "parameters" / DEFAULT_PARAMETERS_FILENAME + params_path = pwd.parent.parent.parent / \ + "parameters" / DEFAULT_PARAMETERS_FILENAME cls.parameters = load_json_parameters(params_path, value_cast=True) models = offline_analysis( str(cls.tmp_dir), @@ -222,13 +235,17 @@ def get_auc(model_filename): return float(match[1]) def test_model_acc(self): - expected_auc = self.get_acc(list(self.output_folder.glob("model_eyetracker_*.pkl"))[0].name) - found_auc = self.get_acc(list(self.tmp_dir.glob("model_eyetracker_*.pkl"))[0].name) + expected_auc = self.get_acc( + list(self.output_folder.glob("model_eyetracker_*.pkl"))[0].name) + found_auc = self.get_acc( + list(self.tmp_dir.glob("model_eyetracker_*.pkl"))[0].name) self.assertAlmostEqual(expected_auc, found_auc, delta=0.005) def test_model_auc(self): - expected_auc = self.get_auc(list(self.output_folder.glob("model_eeg_*.pkl"))[0].name) - found_auc = self.get_auc(list(self.tmp_dir.glob("model_eeg_*.pkl"))[0].name) + expected_auc = self.get_auc( + list(self.output_folder.glob("model_eeg_*.pkl"))[0].name) + found_auc = self.get_auc( + list(self.tmp_dir.glob("model_eeg_*.pkl"))[0].name) self.assertAlmostEqual(expected_auc, found_auc, delta=0.005) diff --git a/bcipy/simulator/README.md b/bcipy/simulator/README.md index 452ef1f9d..fb5c5ed24 100644 --- a/bcipy/simulator/README.md +++ b/bcipy/simulator/README.md @@ -1,4 +1,4 @@ -# RSVP Simulator +# BciPy Simulator ## Overview @@ -33,14 +33,15 @@ optional arguments: ``` For example, -`$ python bcipy/simulator -d my_data_folder/ -p my_parameters.json -m my_models/ -n 5` + +`$ bcipy-sim -d my_data_folder/ -p my_parameters.json -m my_models/ -n 5` #### Program Args - `i` : Interactive command line interface. Provide this flag by itself to be prompted for each parameter. - `gui`: A graphical user interface for configuring a simulation. This mode will output the command line arguments which can be used to repeat the simulation. -- `d` : Raw data folders to be processed. One ore more values can be provided. Each session data folder should contain - _raw_data.csv_, _triggers.txt_, _parameters.json_. These files will be used to construct a data pool from which simulator will sample EEG and other device responses. The parameters file in each data folder will be used to check compatibility with the simulation/model parameters. +- `d` : Raw data folders to be processed. Data folders should contain EEG responses to Copy Phrase tasks. Each session data folder should contain + _raw_data.csv_, _triggers.txt_, _parameters.json_.These files will be used to construct a data pool from which simulator will sample EEG. The parameters file in each data folder will be used to check compatibility with the simulation/model parameters. - `p` : path to the parameters.json file used to run the simulation. These parameters will be applied to all raw_data files when loading. This file can specify various aspects of the simulation, including the language model to be used, the text to be spelled, etc. Timing-related parameters should generally match the parameters file used for training the signal model(s). - `m`: Path to a pickled (.pkl) signal model. One or more models can be provided. @@ -52,10 +53,12 @@ For example, #### Sim Output Details -Output folders are generally located in the `data/simulator` directory, but can be configured per simulation. Each simulation will create a new directory. The directory name will be prefixed with `SIM` and will include the current date and time. +Output folders are generally located in the `data/simulator` directory, but can be configured per simulation. Each simulation will create a new directory. The directory name will be prefixed with `SIM` and will include the current date and time (E.G -- "SIM_%m-%d-%Y_%H_%M_%S") + +At the top level of the output directory, the following files are created: - `parameters.json` captures params used for the simulation. -- `sim.log` is a log file for the simulation; metrics will be output here. +- `sim.log` is a log file for the overall simulation; metrics will be output here. - `summary_data.json` summarizes session data from each of the runs into a single data structure. - `metrics.png` boxplots for several metrics summarizing all simulation runs. @@ -126,7 +129,6 @@ optional arguments: Sim output path ``` - ## Current Limitations - Only one sampler maybe provided for all devices. Ideally we should support a different sampling strategy for each device. @@ -148,47 +150,49 @@ The `switch_data_processor` and `switch_model` are used to demonstrate a multimo 1. Ensure that the devices.json file has an entry for a switch - ``` - { - "name": "Switch", - "content_type": "MARKERS", - "channels": [ - { "name": "Marker", "label": "Marker" } - ], - "sample_rate": 0.0, - "description": "Switch used for button press inputs", - "excluded_from_analysis": [], - "status": "active", - "static_offset": 0.0 - } - ``` +```json +{ + "name": "Switch", + "content_type": "MARKERS", + "channels": [ + { "name": "Marker", "label": "Marker" } + ], + "sample_rate": 0.0, + "description": "Switch used for button press inputs", + "excluded_from_analysis": [], + "status": "active", + "static_offset": 0.0 +} +``` -2. Ensure that the switch signal model can be loaded or create a switch signal model. To create a new one: +1. Ensure that the switch signal model can be loaded or create a switch signal model. To create a new one: - ``` - from pathlib import Path - from bcipy.acquisition.devices import preconfigured_device - from bcipy.io.save import save_model - from bcipy.signal.model.base_model import SignalModelMetadata - from bcipy.signal.model.switch_model import SwitchModel - - dirname = "" # TODO: enter the directory - model = SwitchModel() - # name should match devices.json spec. Alternatively, use bcipy.acquisition.datastream.mock.switch.switch_device() - device = preconfigured_device("Switch") - model.metadata = SignalModelMetadata(device_spec=device, evidence_type="BTN", transform=None) - save_model(model, Path(dirname, "switch_model.pkl")) - ``` +```python +from pathlib import Path +from bcipy.acquisition.devices import preconfigured_device +from bcipy.io.save import save_model +from bcipy.signal.model.base_model import SignalModelMetadata +from bcipy.signal.model.switch_model import SwitchModel + +dirname = "" # TODO: enter the directory +model = SwitchModel() -3. Set the appropriate simulation parameters in the parameters.json file. +# name should match devices.json spec. Alternatively, use bcipy.acquisition.datastream.mock.switch.switch_device() + +device = preconfigured_device("Switch") +model.metadata = SignalModelMetadata(device_spec=device, evidence_type="BTN", transform=None) +save_model(model, Path(dirname, "switch_model.pkl")) +``` + +1. Set the appropriate simulation parameters in the parameters.json file. - set the `acq_mode` parameter to 'EEG+MARKERS'. - ensure that `preview_inquiry_progress_method` parameter is set to '1' or '2'. - You may also want to set the `summarize_session` parameter to `true` to see how the evidences get combined during decision-making. -4. Ensure that the data directories have a raw data file (csv) for markers in addition to the EEG data. If your data does not have marker data, you can extract this from the triggers.txt file using the script `bcipy.simulator.util.generate_marker_data`. If the task was run with Inquiry Preview, the script can use the button press events recoreded in the trigger file. Otherwise you can use the `--mock` flag along with a parameters file to mock what a raw data file would look like if the user pressed the button according to the configured button press mode. +2. Ensure that the data directories have a raw data file (csv) for markers in addition to the EEG data. If your data does not have marker data, you can extract this from the triggers.txt file using the script `bcipy.simulator.util.generate_marker_data`. If the task was run with Inquiry Preview, the script can use the button press events recoreded in the trigger file. Otherwise you can use the `--mock` flag along with a parameters file to mock what a raw data file would look like if the user pressed the button according to the configured button press mode. - ``` + ```bash $ python -m bcipy.simulator.util.generate_marker_data -h usage: generate_marker_data.py [-h] [-m] [-p PARAMETERS] data_folder @@ -204,7 +208,7 @@ The `switch_data_processor` and `switch_model` are used to demonstrate a multimo Optional parameters file to use when mocking data. ``` -5. Run a simulation. +3. Run a simulation. - Set the simulation parameters for both the EEG and the Button models (.pkl files). - Use the InquirySampler @@ -230,4 +234,3 @@ Note that the progress method (`preview_inquiry_progress_method` parameter) does - A `preview_inquiry_progress_method` of 0 is currently not supported and an exception will be thrown. Ideally, all inquiries should get an evidence value of 1.0 (no change) with this mode. - Button evidence only works correctly with the InquirySampler. This is due to all trials in the same inquiry receiving the same value. - diff --git a/bcipy/simulator/data/data_engine.py b/bcipy/simulator/data/data_engine.py index a9635f243..23ab816cc 100644 --- a/bcipy/simulator/data/data_engine.py +++ b/bcipy/simulator/data/data_engine.py @@ -31,7 +31,8 @@ def is_valid(self) -> bool: origin = get_origin(field_type) if origin: options = get_args(field_type) - is_correct_type = any(isinstance(self.value, ftype) for ftype in options) + is_correct_type = any(isinstance(self.value, ftype) + for ftype in options) else: is_correct_type = isinstance(self.value, field_type) @@ -46,7 +47,8 @@ def valid_operators(self) -> List[str]: class DataEngine(ABC): """Abstract class for an object that loads data from one or more sources, processes the data using a provided processor, and provides an interface - for querying the processed data.""" + for querying the processed data. + """ def load(self): """Load data from sources.""" diff --git a/bcipy/simulator/data/data_process.py b/bcipy/simulator/data/data_process.py index efee02c72..85bbe2a46 100644 --- a/bcipy/simulator/data/data_process.py +++ b/bcipy/simulator/data/data_process.py @@ -1,6 +1,7 @@ """This module defines functionality related to pre-processing simulation data. Processed data can be subsequently sampled and provided to a SignalModel -for classification.""" +for classification. +""" import logging as logger from abc import abstractmethod @@ -73,7 +74,8 @@ def load_device_data(data_folder: str, class DecodedTriggers(NamedTuple): """Extracted properties after decoding the triggers.txt file and applying - the necessary offsets and corrections.""" + the necessary offsets and corrections. + """ targetness: List[str] # TriggerType times: List[float] symbols: List[str] # symbol @@ -93,6 +95,7 @@ def triggers(self) -> List[Trigger]: @dataclass() class ExtractedExperimentData: """Data from an acquisition device after reshaping and filtering.""" + source_dir: str inquiries: np.ndarray trials: np.ndarray @@ -138,18 +141,17 @@ class TimingParams(NamedTuple): @property def trials_per_inquiry(self) -> int: - """Alias for stim_length""" + """Alias for stim_length.""" return self.stim_length @property def buffer(self) -> float: - """The task buffer length defines the min time between two inquiries - We use half of that time here to buffer during transforms""" + """The task buffer length defines the min time between two inquiries. We use half of that time here to buffer during transforms.""" return self.task_buffer_length / 2 @property def window_length(self) -> float: - """window (in seconds) of data collection after each stimulus presentation""" + """window (in seconds) of data collection after each stimulus presentation.""" start, end = self.trial_window return end - start @@ -222,7 +224,7 @@ def consumes(self) -> ContentType: @property def produces(self) -> EvidenceType: - """Type of evidence that is output""" + """Type of evidence that is output.""" raise NotImplementedError @property @@ -237,12 +239,13 @@ def model_device(self) -> DeviceSpec: @property def reshaper(self): - """data reshaper""" + """data reshaper.""" return self.model.reshaper def check_model_compatibility(self, model: SignalModel) -> None: """Check that the given model is compatible with this processor. - Checked on initialization.""" + Checked on initialization. + """ assert model.metadata, "Metadata missing from signal model." assert ContentType( model.metadata.device_spec.content_type @@ -258,7 +261,6 @@ def check_data_compatibility(self, data_device: DeviceSpec, data_timing_params): raise IncompatibleParameters( "Timing parameters are not compatible") - if data_device.static_offset == devices.DEFAULT_STATIC_OFFSET: log.warning(' '.join([ f"Using the default static offset [{devices.DEFAULT_STATIC_OFFSET}] for {data_device.name}.", @@ -309,7 +311,7 @@ def process(self, data_folder: str, def load_device_data(self, data_folder: str, parameters: Parameters) -> Tuple[RawData, DeviceSpec]: - """Load the device data""" + """Load the device data.""" return load_device_data(data_folder, self.content_type.name) def decode_triggers(self, @@ -351,26 +353,26 @@ def extract_trials(self, filtered_reshaped_data: ReshapedData, raise NotImplementedError def excluded_triggers(self): - """Trigger types to exclude when decoding""" + """Trigger types to exclude when decoding.""" return [TriggerType.PREVIEW, TriggerType.EVENT, TriggerType.FIXATION] def devices_compatible(self, model_device: DeviceSpec, data_device: DeviceSpec) -> bool: """Check compatibility between the device on which the model was trained - and the device used for data collection.""" - + and the device used for data collection. + """ # TODO: check analysis channels? return model_device.sample_rate == data_device.sample_rate def parameters_compatible(self, sim_timing_params: TimingParams, data_timing_params: TimingParams) -> bool: - """Check compatibility between the parameters used for simulation and - those used for data collection.""" + """Check compatibility between the parameters used for simulation and those used for data collection.""" return sim_timing_params.time_flash == data_timing_params.time_flash class EEGRawDataProcessor(RawDataProcessor): """RawDataProcessor that processes EEG data.""" + consumes = ContentType.EEG produces = EvidenceType.ERP @@ -438,7 +440,7 @@ def extract_trials(self, filtered_reshaped_data: ReshapedData, def get_transform(self, transform_params: ERPTransformParams, data_sample_rate: int) -> Composition: - """"Get the transform used for filtering the data.""" + """Get the transform used for filtering the data.""" return get_default_transform( sample_rate_hz=data_sample_rate, notch_freq_hz=transform_params.notch_filter_frequency, diff --git a/bcipy/simulator/data/sampler/base_sampler.py b/bcipy/simulator/data/sampler/base_sampler.py index 18f35b1a1..87af04e86 100644 --- a/bcipy/simulator/data/sampler/base_sampler.py +++ b/bcipy/simulator/data/sampler/base_sampler.py @@ -34,7 +34,8 @@ def format_samples(sample_rows: List[Trial]) -> str: class Sampler(ABC): """Represents a strategy for sampling signal model data from a DataEngine - comprised of signal data from one or more data collection sessions.""" + comprised of signal data from one or more data collection sessions. + """ def __init__(self, data_engine: RawDataEngine): self.data_engine: RawDataEngine = data_engine diff --git a/bcipy/simulator/data/sampler/inquiry_sampler.py b/bcipy/simulator/data/sampler/inquiry_sampler.py index 72b1a1133..d4a7cc214 100644 --- a/bcipy/simulator/data/sampler/inquiry_sampler.py +++ b/bcipy/simulator/data/sampler/inquiry_sampler.py @@ -28,9 +28,9 @@ def __init__(self, data_engine: RawDataEngine): def prepare_data( self, data: pd.DataFrame ) -> Tuple[Dict[Path, List[int]], Dict[Path, List[int]]]: - """Partition the data into those inquiries that displayed the target - and those that did not. The resulting data structures map the data - source with a list of inquiry_n numbers.""" + """Partition the data into those inquiries that displayed the target and those that did not. + The resulting data structures map the data source with a list of inquiry_n numbers. + """ target_inquiries = defaultdict(list) no_target_inquiries = defaultdict(list) diff --git a/bcipy/simulator/data/sampler/target_nontarget_sampler.py b/bcipy/simulator/data/sampler/target_nontarget_sampler.py index 59cce32bd..71e4e69f0 100644 --- a/bcipy/simulator/data/sampler/target_nontarget_sampler.py +++ b/bcipy/simulator/data/sampler/target_nontarget_sampler.py @@ -13,6 +13,7 @@ class TargetNontargetSampler(Sampler): """Sampler that that queries based on target/non-target label.""" def sample(self, state: SimState) -> List[Trial]: + """Sample trials for each symbol in the display alphabet, labeling as target or non-target.""" sample_rows = [] for symbol in state.display_alphabet: filters = self.query_filters( diff --git a/bcipy/simulator/data/trial.py b/bcipy/simulator/data/trial.py index e7480fdd5..58ff9e20d 100644 --- a/bcipy/simulator/data/trial.py +++ b/bcipy/simulator/data/trial.py @@ -34,7 +34,8 @@ class Trial(NamedTuple): inquiry_pos: int symbol: str target: int - eeg: np.ndarray # Channels by Samples ; ndarray.shape = (channel_n, sample_n) + # Channels by Samples ; ndarray.shape = (channel_n, sample_n) + eeg: np.ndarray def __str__(self): fields = [ @@ -51,8 +52,7 @@ def __repr__(self): def session_series_counts(source_dir: str) -> List[int]: - """Read the session.json file in the provided directory - and compute the number of inquiries per series.""" + """Read the session.json file in the provided directory and compute the number of inquiries per series.""" session_path = Path(source_dir, SESSION_DATA_FILENAME) if session_path.exists(): session = read_session(str(session_path)) diff --git a/bcipy/simulator/demo/demo_group_simulation.py b/bcipy/simulator/demo/demo_group_simulation.py index 592798e4f..5b6e84b64 100644 --- a/bcipy/simulator/demo/demo_group_simulation.py +++ b/bcipy/simulator/demo/demo_group_simulation.py @@ -102,7 +102,8 @@ def run_simulation( ------- The simulation results will be saved in the output directory defined by OUTPUT_DIR. """ - print(f"Running simulation for {user} with phrase {phrase} and language model {language_model}") + print( + f"Running simulation for {user} with phrase {phrase} and language model {language_model}") model_path = None params_path = None @@ -120,7 +121,8 @@ def run_simulation( model_path = pkl_file break if not model_path: - raise FileNotFoundError(f"Could not find a model file in {mode_calibration_dir}") + raise FileNotFoundError( + f"Could not find a model file in {mode_calibration_dir}") else: model_path = signal_model_path @@ -132,7 +134,8 @@ def run_simulation( # update the parameters with the new phrase, starting index, and language model phrase_length = len(phrase) - starting_index - print(f"Processing {user}:{phrase}:{language_model} of phrase length: {phrase_length}") + print( + f"Processing {user}:{phrase}:{language_model} of phrase length: {phrase_length}") parameters["task_text"] = phrase parameters["spelled_letters_count"] = starting_index parameters["lang_model_type"] = language_model @@ -140,7 +143,8 @@ def run_simulation( # Below are task constraints that impact typing speed and letter selection. Here we use criteria based on phrase length # and some sensible defaults. parameters["max_inq_len"] = phrase_length * 8 - parameters["max_selections"] = phrase_length * 2 # This should be 2 * the length of the phrase to type + # This should be 2 * the length of the phrase to type + parameters["max_selections"] = phrase_length * 2 parameters["min_inq_per_series"] = 1 parameters["max_inq_per_series"] = 8 parameters["backspace_always_shown"] = True @@ -148,11 +152,14 @@ def run_simulation( parameters["lm_backspace_prob"] = 0.03571 # signal model decision threshold for letter selection parameters["decision_threshold"] = 0.8 - parameters["max_minutes"] = 120 # This is not used in the simulation. But we set it high to avoid any issues. - parameters["max_incorrect"] = int(phrase_length / 2) # This should be half the length of the phrase to type + # This is not used in the simulation. But we set it high to avoid any issues. + parameters["max_minutes"] = 120 + # This should be half the length of the phrase to type + parameters["max_incorrect"] = int(phrase_length / 2) # get the correct list of source directories from the data directory - source_dirs = [str(file) for file in data_dir.iterdir() if file.is_dir() and DATA_PATTERN in file.name] + source_dirs = [str(file) for file in data_dir.iterdir() + if file.is_dir() and DATA_PATTERN in file.name] if not source_dirs: raise FileNotFoundError(f"Could not find a data directory for {user}") @@ -171,7 +178,8 @@ def run_simulation( runner.run() metrics.report(sim_dir) except Exception as e: - print(f"Error running simulation for {user} with phrase {phrase} and language model {language_model}") + print( + f"Error running simulation for {user} with phrase {phrase} and language model {language_model}") print(e) @@ -201,6 +209,7 @@ def run_simulation( progress_bar.set_description(f"Processing {user.name}") for phrase, starting_index in PHRASES: for language_model in LANGUAGE_MODELS: - run_simulation(user, user.name, phrase, starting_index, language_model) + run_simulation(user, user.name, phrase, + starting_index, language_model) progress_bar.close() diff --git a/bcipy/simulator/exceptions.py b/bcipy/simulator/exceptions.py index f95f5f1a6..80e69a27d 100644 --- a/bcipy/simulator/exceptions.py +++ b/bcipy/simulator/exceptions.py @@ -1,6 +1,5 @@ class DeviceSpecNotFoundError(Exception): - """Thrown when a suitable DeviceSpec was not found in the devices.json file - """ + """Thrown when a suitable DeviceSpec was not found in the devices.json file.""" class IncompatibleData(Exception): @@ -15,7 +14,8 @@ class IncompatibleDeviceSpec(IncompatibleData): class IncompatibleParameters(IncompatibleData): """Thrown when the timing parameters used for data collection are - incompatible with the timing parameters of the simulation.""" + incompatible with the timing parameters of the simulation. + """ class IncompatibleSampler(Exception): diff --git a/bcipy/simulator/task/copy_phrase.py b/bcipy/simulator/task/copy_phrase.py index c9129e1d0..fa8a1fb76 100644 --- a/bcipy/simulator/task/copy_phrase.py +++ b/bcipy/simulator/task/copy_phrase.py @@ -1,4 +1,4 @@ -# mypy: disable-error-code="union-attr" +# mypy: disable-error-code="union-attr, override" """Simulates the Copy Phrase task""" import logging from pathlib import Path @@ -37,12 +37,14 @@ def get_evidence_type(model: SignalModel) -> EvidenceType: try: return EvidenceType(evidence_type) except ValueError: - raise ValueError(f"Unsupported evidence type: {evidence_type}. Supported types: {EvidenceType.list()}") + raise ValueError( + f"Unsupported evidence type: {evidence_type}. Supported types: {EvidenceType.list()}") class SimulatorCopyPhraseTask(RSVPCopyPhraseTask): """CopyPhraseTask that simulates user interactions by sampling data - from a DataSampler.""" + from a DataSampler. + """ name = "Simulator Copy Phrase" paradigm = "RSVP" @@ -83,6 +85,7 @@ def get_language_model(self): def init_evidence_evaluators( self, signal_models: List[SignalModel]) -> List[EvidenceEvaluator]: + """Initialize evidence evaluators for the simulation (returns empty list).""" # Evidence will be sampled so we don't need to evaluate raw data. return [] @@ -90,21 +93,25 @@ def init_evidence_types( self, signal_models: List[SignalModel], evidence_evaluators: List[EvidenceEvaluator] ) -> List[EvidenceType]: + """Initialize evidence types for the simulation.""" evidence_types = set( [get_evidence_type(model) for model in self.signal_models]) return [EvidenceType.LM, *evidence_types] def init_display(self) -> Display: + """Initialize the display for the simulation (returns NullDisplay).""" return NullDisplay() def init_acquisition(self) -> ClientManager: - """Override to do nothing""" + """Override to do nothing.""" return NullDAQ() def init_feedback(self) -> Optional[VisualFeedback]: + """Initialize feedback for the simulation (returns None).""" return None def user_wants_to_continue(self) -> bool: + """Always continue for simulation purposes.""" return True def wait(self, seconds: Optional[float] = None) -> None: @@ -113,8 +120,7 @@ def wait(self, seconds: Optional[float] = None) -> None: def present_inquiry( self, inquiry_schedule: InquirySchedule ) -> Tuple[List[Tuple[str, float]], bool]: - """Override ; returns empty timing info; always proceed for inquiry - preview""" + """Override; returns empty timing info; always proceed for inquiry preview.""" return [], True def show_feedback(self, selection: str, correct: bool = True) -> None: @@ -122,12 +128,14 @@ def show_feedback(self, selection: str, correct: bool = True) -> None: def compute_button_press_evidence( self, proceed: bool) -> Optional[Tuple[EvidenceType, List[float]]]: + """Compute button press evidence for simulation (returns None).""" return None def compute_device_evidence( self, stim_times: List[List], proceed: bool = True) -> List[Tuple[EvidenceType, List[float]]]: + """Compute device evidence for simulation.""" current_state = self.get_sim_state() self.logger.debug("Computing evidence with sim_state:") @@ -149,6 +157,7 @@ def compute_device_evidence( return evidences def cleanup(self): + """Cleanup after simulation, including saving session data and removing empty trigger files.""" self.save_session_data() trigger_path = Path(self.trigger_handler.file_path) self.trigger_handler.close() @@ -161,6 +170,7 @@ def exit_display(self) -> None: """Close the UI and cleanup.""" def elapsed_seconds(self) -> float: + """Return elapsed seconds for simulation (always 0.0).""" return 0.0 def write_offset_trigger(self) -> None: diff --git a/bcipy/simulator/task/null_display.py b/bcipy/simulator/task/null_display.py index 4eef7b50a..3db159c7b 100644 --- a/bcipy/simulator/task/null_display.py +++ b/bcipy/simulator/task/null_display.py @@ -6,9 +6,11 @@ class NullDisplay(Display): """Display that doesn't show anything to the user. Useful in simulated tasks - that do not have a display component.""" + that do not have a display component. + """ def do_inquiry(self) -> List[Tuple[str, float]]: + """Perform an inquiry and return an empty list (no display).""" return [] def wait_screen(self, *args, **kwargs) -> None: diff --git a/bcipy/simulator/task/replay_session.py b/bcipy/simulator/task/replay_session.py index 995f322e9..df60e7fb4 100644 --- a/bcipy/simulator/task/replay_session.py +++ b/bcipy/simulator/task/replay_session.py @@ -1,5 +1,7 @@ """Task that will replay sessions to compare model predictions on that data. -Used for testing if changes to a model result in more easily differentiated signals.""" +Used for testing if changes to a model result in more easily differentiated signals. +""" + import argparse import logging from pathlib import Path diff --git a/bcipy/simulator/task/task_factory.py b/bcipy/simulator/task/task_factory.py index 9d661ea5e..aacdc8784 100644 --- a/bcipy/simulator/task/task_factory.py +++ b/bcipy/simulator/task/task_factory.py @@ -32,7 +32,6 @@ def update_latest_params(parameters: Parameters) -> None: class TaskFactory: """Constructs the hierarchy of objects necessary for initializing a task. - Parameters ---------- parameters : Parameters @@ -82,7 +81,8 @@ def __init__( def log_state(self): """Log configured objects of interest. This should be done after the sim directory has been created and TOP_LEVEL_LOGGER has been configured, - which may happen some time after object construction.""" + which may happen some time after object construction. + """ logger.debug("Language model:") logger.debug(f"\t{repr(self.language_model)}") logger.debug("Models -> Samplers:") diff --git a/bcipy/simulator/task/task_runner.py b/bcipy/simulator/task/task_runner.py index 69875d74a..b5120e681 100644 --- a/bcipy/simulator/task/task_runner.py +++ b/bcipy/simulator/task/task_runner.py @@ -149,7 +149,8 @@ def main(): elif args.interactive: task_factory = cli.main(sim_args) else: - parameters = load_json_parameters(sim_args['parameters'], value_cast=True) + parameters = load_json_parameters( + sim_args['parameters'], value_cast=True) task_factory = TaskFactory( parameters=parameters, source_dirs=sim_args['data_folder'], diff --git a/bcipy/simulator/ui/cli.py b/bcipy/simulator/ui/cli.py index a99cb5ce8..973d0810e 100644 --- a/bcipy/simulator/ui/cli.py +++ b/bcipy/simulator/ui/cli.py @@ -31,7 +31,8 @@ def do_directories(parent: Path, max_depth: int = 3, current_depth: int = 1) -> None: """Recursively walk a tree of directories, calling the provided - function on each path.""" + function on each path. + """ paths = sorted( Path(parent).iterdir(), @@ -108,7 +109,8 @@ def excluded(path: Path) -> bool: def select_directories(parent: Path) -> List[Path]: """Select all directories of interest within the parent path. - Traverses all directories and prompts for each.""" + Traverses all directories and prompts for each. + """ accum = [] def do_prompt(path: Path): @@ -155,6 +157,7 @@ class PromptPath(PromptBase[Path]): response_type = Path def process_response(self, value: str) -> Path: + """Process the response value and return a Path object.""" value = value.strip("'\"") return super().process_response(value) @@ -229,8 +232,9 @@ def get_acq_mode(params_path: str): def command(params: str, models: List[str], source_dirs: List[str], sampler: Type[Sampler]) -> str: - """Command equivalent to to the result of the interactive selection of - simulator inputs.""" + """Command equivalent to the result of the interactive selection of + simulator inputs. + """ model_args = ' '.join([f"-m {path}" for path in models]) dir_args = ' '.join(f"-d {source}" for source in source_dirs) sampler_args = f"-s {sampler.__name__}" diff --git a/bcipy/simulator/ui/gui.py b/bcipy/simulator/ui/gui.py index edfb42bbb..0d61303f4 100644 --- a/bcipy/simulator/ui/gui.py +++ b/bcipy/simulator/ui/gui.py @@ -186,6 +186,7 @@ def __init__(self, change_event=change_event) def prompt_path(self): + """Prompt the user to select a directory path.""" dialog = FileDialog() directory = '' if preferences.last_directory: @@ -493,13 +494,13 @@ def sim_runs_control(value: int = 1) -> QWidget: class SimConfigForm(QWidget): - """The InputForm class is a QWidget that creates controls/inputs for each simulation parameter + """The InputForm class is a QWidget that creates controls/inputs for each simulation parameter. - Parameters: - ----------- - json_file - path of parameters file to be edited. - width - optional; used to set the width of the form controls. - """ + Parameters + ---------- + json_file - path of parameters file to be edited. + width - optional; used to set the width of the form controls. + """ def __init__(self, width: int = 400, @@ -591,8 +592,7 @@ def command_valid(self) -> bool: and self.data_paths and self.sampler and self.sampler_args) def command(self) -> str: - """Command equivalent to to the result of the interactive selection of - simulator inputs.""" + """Command equivalent to the result of the interactive selection of simulator inputs.""" if not self.command_valid(): return '' diff --git a/bcipy/simulator/ui/obj_args_widget.py b/bcipy/simulator/ui/obj_args_widget.py index d59113a53..6aa35a9d4 100644 --- a/bcipy/simulator/ui/obj_args_widget.py +++ b/bcipy/simulator/ui/obj_args_widget.py @@ -11,7 +11,8 @@ class ObjectArgInputs(QWidget): """Widget with inputs for parameters needed to instantiate an object for a - given class.""" + given class. + """ def __init__(self, parent: Optional[QWidget] = None, @@ -69,7 +70,8 @@ def _field_definition(self, name: str) -> InputField: def _json_name_value(self, name: str, control: QWidget) -> str: """Returns a json partial for a name: value, quoting the value according - to the input_type""" + to the input_type + """ field = self._field_definition(name) value = self._input_value(field, control) diff --git a/bcipy/simulator/util/artifact.py b/bcipy/simulator/util/artifact.py index 03329863a..2859ff7af 100644 --- a/bcipy/simulator/util/artifact.py +++ b/bcipy/simulator/util/artifact.py @@ -1,4 +1,5 @@ -""" Handles artifacts related logic ie. logs, save dir creation, result.json, ...""" +"""Handles artifacts related logic ie. logs, save dir creation, result.json, ...""" + import datetime import logging import os diff --git a/bcipy/simulator/util/generate_marker_data.py b/bcipy/simulator/util/generate_marker_data.py index 6cb82a4ef..634343253 100644 --- a/bcipy/simulator/util/generate_marker_data.py +++ b/bcipy/simulator/util/generate_marker_data.py @@ -8,7 +8,7 @@ def main() -> Path: - """"Main method used to generate a raw data file for a switch device.""" + """Main method used to generate a raw data file for a switch device.""" parser = argparse.ArgumentParser( description="Create raw marker data for a given session.") parser.add_argument("data_folder", diff --git a/bcipy/simulator/util/metrics.py b/bcipy/simulator/util/metrics.py index b4b7a91b0..edecb742e 100644 --- a/bcipy/simulator/util/metrics.py +++ b/bcipy/simulator/util/metrics.py @@ -168,8 +168,7 @@ def plot_results(df: pd.DataFrame, def report(sim_dir: str, show_plots: bool = False) -> None: - """Summarize the data, write as a JSON file, and output a summary to - the top level log file.""" + """Summarize the data, write as a JSON file, and output a summary to the top level log file.""" summary = summarize(sim_dir) save_json_data(summary, sim_dir, SUMMARY_DATA_FILE_NAME) diff --git a/bcipy/simulator/util/state.py b/bcipy/simulator/util/state.py index 5ae25ba43..abb6ca4ce 100644 --- a/bcipy/simulator/util/state.py +++ b/bcipy/simulator/util/state.py @@ -10,7 +10,8 @@ @dataclass class SimState: - """ Represents the state of a current session during simulation """ + """Represents the state of a current session during simulation.""" + target_symbol: str current_sentence: str target_sentence: str @@ -20,8 +21,7 @@ class SimState: def get_inquiry(session_dir: str, n: int) -> Dict[str, Any]: - """Extracts an inquiry from a session.json file. Useful for debugging - simulator output.""" + """Extracts an inquiry from a session.json file. Useful for debugging simulator output.""" session = read_session(f"{session_dir}/{SESSION_DATA_FILENAME}") inq = session.all_inquiries[n] return inq.stim_evidence(session.symbol_set) diff --git a/bcipy/simulator/util/switch_utils.py b/bcipy/simulator/util/switch_utils.py index b60e75725..680412586 100644 --- a/bcipy/simulator/util/switch_utils.py +++ b/bcipy/simulator/util/switch_utils.py @@ -49,16 +49,13 @@ def has_target(triggers: List[Trigger]) -> bool: def time_range(inquiry_triggers: List[Trigger], time_flash: float) -> Tuple[float, float]: - """Given a list of triggers for a given inquiry, determine the start and - end timestamps of that inquiry.""" + """Given a list of triggers for a given inquiry, determine the start and end timestamps of that inquiry.""" return (inquiry_triggers[0].time, inquiry_triggers[-1].time + time_flash) def inquiry_windows(trigger_path: Path, time_flash: float) -> List[Tuple[float, float]]: - """Returns a list of (inquiry_start, inquiry_stop) timestamp pairs for - all inquiries in the trigger file.""" - + """Returns a list of (inquiry_start, inquiry_stop) timestamp pairs for all inquiries in the trigger file.""" return [ time_range(inq_triggers, time_flash) for inq_triggers in partition_triggers(trigger_path) @@ -67,8 +64,7 @@ def inquiry_windows(trigger_path: Path, def should_press_switch(inquiry_triggers: List[Trigger], button_press_mode: ButtonPressMode) -> bool: - """Determine if a marker should be written for the given inquiry - depending on the presence of a target and the button press mode.""" + """Determine if a marker should be written for the given inquiry depending on the presence of a target and the button press mode.""" return (button_press_mode == ButtonPressMode.ACCEPT and has_target(inquiry_triggers)) or ( button_press_mode == ButtonPressMode.REJECT diff --git a/bcipy/static/images/gui/cambi_fav.ico b/bcipy/static/images/gui/cambi_fav.ico new file mode 100644 index 0000000000000000000000000000000000000000..897130825302081aa00c67e34bd28f43535db561 GIT binary patch literal 432254 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

X zBD~IJ$e?9Qb13ug@xcE^U$jrlUS_p#Kf|is_-kU>oPho$`b@;M zsYjgG)|DT$(d_+Yz<+E**B}$!w7hsR=6L(jz+Y>C8;2Ak4`ok={Q$w=>u4Ek0Qf)V z0I#DJ*Xcm_e|;$&o^Ha|FbS=tZ+A!D@Fa5Hvys0Y`V?aT_-j7aJWz|Z@?dDt5@av3 z14c{|9ii@=)$jKK`{Z}Y7+!WUs&@hV)x=?*HL9=mN3Pi9p zUm+f|bU!{}|Ec>2FWaO(t=Rh>XWKTcVGPibqa&^V52pWjFSH{Bf7W8f`+ss@_`vr6 z1f)V$Q)lgo48b<55_S=ib)BMkbI>2fbH0duv2yLQiItY??5x*f>z>97MFl5mPO4xo z_V?Y~oE-K#mf37#p(sXp`|3~FhmT(5bXgTY^tFq85)PE!y8KtjfdAld)a#QsY%=-> zYnRNy1_ELp9ir)qp?|$xO>@ytko+A+Y0n#*{jz+TI%3Z-ClQ!malsy z-4E~P0JqhPzPItcT3fdU73@o5l-5?v8sdDs2K*JbreYKiSTVQXB1VhiFMU8uRUe`({Z5S zud*GC_)JM8Li4KRGMy(Bpr4lKV&9#?9<26%f6X4`KiMaHg8j132j>z`FW>DWs7!Z6 zWd=B>aG?8SNT%qG)Jbs*R?MA({#YaHP_-=Xr zdg%u1*SI?Su%pp6i`Svr59`HV%v0mpe-!_(y)gJs=|#xCul15b_1G{h z_(wo+`y?p8p^Nj9ftf7+!$QM6O+kp6rpp|1jc#{}y_56?QCdqoX+#8N7J* z)WN00EjLuOD?Z%QIr^8(AFgFLwE})}Wc!b;edUMHhrh(C-~1cm<{WAv*1U8+LOk`i zE<42Sh16vq@GHiO;*SabS&UUo(8n@ZZ?OIg zJtNdzbvj`EU!M!W31{7l4#~7+!?l2M+YkA<;$vRT-au*ef1JHFu}2jvx=iC->x3PE z_HebAEVjeplyXv^;KH?u@8}#4I6a6mM&FhF*{ZpdZA|Be*11|)VlG`uOr2Amu30ho zLyIagE~?#dqP_pXpNOgR9~<1N9`t9N?FEmTfw%L`S^W75+2ZX((T^7GY3<>!J;{eg zV_S=CV!IZ_Zh<(n49&W-8Zu;^<^qtX-KtJJhY>;Gsr+wHH z@hxy2x(Kzk4@JD!#j=-R95@(9i>ta{yMi8Hm>Z!NIo6==u6egwcvokU!}WG30@K1iU%q^S|mQR<^caJ z+d%)B$hx*QdmH7o|AIZhvB(~@_Bsvw`hO2Bw0EW#;$JH%e-6}9H9LU)-<9rLuP+9V z&rUWL!O261K8+FR80=8%_Rc}?Alv?kOyYR@{!zgH6m--7QG@uI$mTx5C(5`*?8UMk z@U{0PBtDgbe>CjE@N-%8M>e#(wr;?#YPwBFXTHzpm95b;#2`oC>Gd%GM)CXfyE1W0 zKO{a~=h{!$$S$7}S8t$gUzdS?Vva3jJtf;bt-BPjUGdum|6jxR{2g38hQ0A;a}~o~ z`!D)>fQOe>>COS||M~#m)IWVOczl*vS^YHBCkOe-{I&VkWn!+2KYjZr?2)i06a0@v zUqNw}uRuQi0&{e$k@@&z@_T}SV4sJIPvXFRPWNO>i%f0nhLyJL`|&oW+gI3SK4$Mf z_!q0ndRMx}6`6D0TmT-b-FOo5`L45pO{)=yvKO%}zqg&+H`#{0dHAz2`v}?2BhdJZ z;X6g|O|e|*nXUV$$3M0BBnz1F&tJIha~{8mawy7?mXPmuepg3r(%MCx!v(%1dxfxo`2?|G;Q z_HGPB6%swy`bv8)vsM>ab95yh{3_SZteDLnvFs(hjQT9>hO~e9vTV18d4E@WM|;fL zv(y>!4r43+madZHK===bZ1>kLoJs7twl0Qo(^qb>I(M-aBOc=$@cossX?ve_SGV`d z(f9iym)`39mn~!IH5_?QgSqTEvc6TncY7}AzsX0?wcM+PjcoV%rCUE;|fjw%be~Z>n-*DbJ;(8bgcIqJ#!oU9byXEF302D zfgz&5+()n_+n}WWTiAauy8rvr_a75bh;W{QKaV{81Eug8Wb-0_D6Plj*{hv$zyUVY z*pF&WAl(q@O=zFs3S!1Tgxo^DH@*Z9eP`ss$L5=4MQO5%L=}o%7UtQWZR>5-{K+;I zzNY8L73>pY3|F}tJ96d$#pnF&xofS%r&Vp>$k8@rd8V~vzO78(zKgZih2P0XD}J1i z^#rUh?Vl6(c zY3&D&v1YS(Tjg11H;*(sv$waqIJ}eh3v_nRLgz#4rHS%O&wf9A>2AmK2nGQF9`bGu zjObttfAnwD^bfuMPd(%w%_>CtC&<6E6Yy85B^|}?$DjA`=Oy+1lHf1;Em?zf{zjm; zslCc_=*?Y+P4GEyNjH=E06iViBgv{yLw`}SBH32F$$0qyJ>OQNq(hpA{z$&lAs@rq zbo^rPyJqsT0_YX%?sf7hnrAE4ZnG83X#1o==-zwXSLt59i|%EEyU(&liXp$O1o#()b^Ik)d=mBUIy_Ip-wPD{6O9e6$%F&xevezs zJ~_SuYWBn@ANmRxpf@6YiQpGbbA$9rWy2yLC(>`c9eu_Jz{j)fCBNAPJx<=0{^9%I z<=H3bv^5)90H1}A`C0f@nYYd6&0lPD@#Qk4Wlj6?Ij{R#6~6z2d(N_k?vdc&55WF<2YYmDmarZ}&b*TOc^7>jwys1V1Q_UH4F4s> z@*eE`n#j+9`hbH!W3U-~!Jy`~BK<=U8y$jSvaE2Ox*u-n93A17&qFT|z$MUeXI+z# zq&Zabo(($+Y&QE~@&zjW+`G{iITyZ2dW6R^7UUyKbApoW$n;w>>=VlG#opK3wA_rp zf&bvgQvS6bVIB8En>_UG3hdMV+173JRvR~E5&OUN|K`A7eGmFmP3tZP|68rjd$p~6 zr!jW#pgndO@c(@u)>!N@p3Ph<*uTWuxA8)B{D8e|CB-{}HGH$cmj)Hxi!=@1)?bOhw`E z^81{!pO8OQ=>f`ys9x_pd!lKs)6cw|Irz`m6UeWL@NqaY3(e7`9zma637zs2kY#AT z*7%UF>0jArxSX}cEg$FE<4v-xcGoTT&EQ2ga?oJ=ws9@i!sz}1f7ky-f6CozAAeBW zYWEmpZ;snzPe9*qAH}}vV<`Uvf_*sD#$-EV+T$^zeDZ6Rg-y}6 z4aD}IG1>;VdC$Ih;onZ?E&Ef&+y3hLYpwrRRc$HuN3zilN0Ba@0=FPn^5LnTo`=2> z-zoZ*udVhfGNZM@BHAch^)Be`NH?|;cJ1<)cq9G$Lj0UahehAi zKTH}I@~v`wxxIE0_7$hTj=cLFeDr;`**@*E$a?h{Y+rv?%RYHBhQH|lm(c&#pW$<7 z$XNS&;T~(50q+TouRq7^E9P10{Y=~9?ZVP@45K!B9Qd|`c~@gQ@W_IH4l===$V8SS zmmS{mV`pbAKN@ec1`!Y3@X5=q4}LfnO&a3-iltx+$Q%yR;F)?_5$J5$)vLO z?zDCAVPn5(%wFfs&X^;BXl*nl-jS7N5m!GGB9Z7iruIQ&=jRro5NlZP*#T<3GCO4nR_sZEZRX_AeOX|{>(OJ;Eu zzHzUFR{g6EI$rhgm0K^*uB=a-1$6LU_-eg<)_$6O-S%s1TlWon|IvRq_*X^$;r;tB zV9)Vh8_?=~n>T#|vhuyQiFtJeYpSKJ!`2~t_HrH92rB2#0Sigk>z=~_a1U)$YXtvr z-o9~-i^<;N^*fRK9b@JH;(hj1hX%Dl2Sa<$ThNo14BhiA?p6Oje^cLZ-wK{EJ?35A z64ey?lPRjY%cg7JhQtdlga4k^Gty&|eXjHr=b%p{-`mnFlx?x>m)c`LD*xQ_(=C79 z()p}`-eWEH66$;l{pe@l4Bvl!S6TNSBdt&C#@4R-GxW6!Y5TEO4O!gBk6w&#wKC9z z&ur=JDYk2GrfJum&pMxbl4-^~j<1{k`dUZuPPHUz5seP*4mrb{vcr&nxAyNmV)7{_ z{fqL*#N|`$%a^XVp=}$uIba*|SH+Oe%gIXNFMQWSTY7+kzyIrM;y#+HTZLud z8zjLUwL%%)xOVl9>#lWzY+S^vYP~C7N%oT4=^7i6xyaso?i&0tEcSX91MrD8U?%bk zcuv_?j=-*K)b~4W$X}hWpV!%oc#E}o zgNOcPooXTj@BFFF9sRAXSu_)Wi0hngq?dOUt;H|4?6ec_P<+CT)ur>{>HQ)Z=feTE&AKr!!s)kP3hmT%}kHfpI z?^o|2FX@cE{50FKX+89aiSfVmP|bvg9nk8{J92I9x^1>}@iLn=Wg@Wu#(D$$R&PCE zA0vCNK^xyf=KuDUhtvNg_k00=3@xyG9%I`#tzw=;h*84f=iO8f2lhqaUljHwl%ZQW z(35q@xTp2+V14oV){;H92KSup*5Z{eJD72KB=XratO@dnzVM?{2X&=x=%%jDu)W*y z|092_`2TT!e}eBH))rb{$i_aIiu!1kUZwO~L&{9}an*Z!Q;B@3GQCgNl#f^L2Vbu$ z&zHFiKP}sB`{qr^7+2b=1+#7LgkkufYioU))U;Mr9(FQj@%$Ad-JcrhAT}v?18bq$ z=zaF5@8f4@*JjqL#rs*3HhQ=Rz9*tTj&3@H#0c64{PWPM(>h=oeEy_f&8*i4iw^JKUngG6({*+4awaf`VH^t{q*}Y$QM$0KfG7;{5^R^GiKwZP|%8z5kOWA9``M1{SsVl5|gK{>de|uZAcqYCv)}!yX zhdGJ~H+e(^C*UB4e}eYAzE8$LoD%Pc_rfi$h2^hG^Xsx1qii&^zHO!ZtUkIgm9-~9 z|9FFaA^G{N!#wrz6aTa?*qdwi+U;)ts5N<8DdBImHU8k_6Ryo$_eaV9ZY^J|q?S=? z1q@n~+9+|aZ7PNQD(^n>lgOj|;T?TX-%(PT{=1aH{Zx9Ja(Jipn|E8Q*Y33D{uXvJ$K~c^x_!$uo-e>N zg@c&pi1xdGDcB4C%9wtn;2%A5zG&DFkdDl@b<5%Rd)v3HyS{qiI{1QfS&N_4?!S5qF|%i`wH7W2Wl$CoylF#GK}nuC>SdEQPMT}^{#Ym zs*^OwsO2%-&bah zRS6kO#Vdbh@3PPPDPz5xVBe#KEuK1x{mB*1*L6D7_!YrCd3K=YMv1UbW=s?o!#~1L zU;;UdKFhb;l<(TxcVARNFa9z6>ct!E!-p@j+IOC5RiTlU|9z}gy6Q;R2DL*;dX$y^ zb-25Jo9nk3HyS%3XqGhe_PTcQ8CuX*m>;Uwu$}z zxyT)cqI=!i`Zum&oohaYZ@638C;ErgzxO<=11{v3Pdco1IBR~cOVZgV?s2~^`Rd;J z2bbb^l%IPf@q6|0HBtXRXS-C7Yx$ng{tU~L4WKP zr<>h`J%Xn2Ma?SQg09ldE;Z%s68D>nZ zR!C`YJ2c6^)5G8AcLk}iD1kaR28#E8 zf!JsAnDoCK;G;j%+uM!ak@gAJEuCvC=T5bZ8ROwq$GCj~`9usU13wY|dNk+JjOj7X zcgIp}tz2RsO}5N2sQ$x`j^(9vdc?90`NTS91O|edw%o`*y*3gOC*IgO0@QFE;u> z$PxYY8Oc%NeU~ZmBykyD(6`-qq70oqmP5w))8e_pyw4DJ%fwW@eYW(!p$MI$kNL6Y zshzt{%ai_kiNEy0RPx0-UV}?X4MdwI5%(<#JvDrU+o>bEM_H~owa4A^_kBHmKTq8D z%}A9IUqq$3FBi`ro^noHhHHISaQF3BmSiUX4c!UwcR2VjgTLyI_|x%>G5pmk;UKh! z%hVCw`x)S0>VJ>_g~0zp;C~_T3qjxlzFS-0K1l!npev4pNhMVF1KV7j7mL&b!AC7naX{}=y11jQ1cmjqJJ z5R<*6hb5Ns6K@tR_lI6Dnm_qF;NPEr?D!AkKAh9rnrOPKTJn3k=1AW1clPr)nK^th z$Q@MgU+|!6LU%~1cBL-T>;7XKzOT39mWCYho&8+}sD*QSJ>otPo`*ZdzY=o9=k&5l zszoRzoTEJY$r0{GKmE<*-vC_BCH+fg{kgAQ-T9NKVxkJ6Wy!uO$cFnm7eD;d(9$S3 zH^Vo6;Pqk!CchR+ExxzuKj5Dy#>X+Q*+{K3`hMSoBH!G7smv8D{fNCh~mpGSH|DtX1H4VEG`&-)wXr{L>>_-Irr-42GSc%O~)-TobC?JKu zRG}Wm?kAV&<3Cvn*Qs}+>*SY$t~$0ONNO6PYw+tRXYU5`%RI{exwDc+U*5XrxsVss?;H$&+$RKgw3CDl*2 z6Lm~$@X!1sQPVV(#P#6gjlR2Dp*s6G7yW)o@Yk!DHaH*msBPO?aQ{8y{yT90Iry%OU!ii7 z@b84}rOuVGx&Huv6kYMPHGQj(la&q)UzyOJA44TW>-W)&pYe8#K#vJuGY5NW5kHNu z)1IYepoC)hO3RSeF8^fT_;=5Xm6ujT+K;07Juygcgm@9f8j}6~M&|h?*u2TstuHXlxzaQ6q#j>eKJI;#)3>k@lUdiy-~_whG@`%rfJ+x}NvzqtST@2BThwerBd z_&nLy&1TFG#-BjjrTB54BOjlIcK66ayAt1Gf5rcX{Ga?8dosm8dJwzdy2Lz^FZ4Co z0t?46wBjp&H*QbSC&hoK*YOwmm23*v0ew1$^_x2!v|jK5ic`6N{Hwa5!a9p$CaC5<>X@kR&v%)uX5!jak>5e* zHk?BiU%@2w1(8G>Q|&L>lu$xAC!cdKd^qh)o>ca5k^FYzI)0ogSbbNq3FNmzG=C8` zj3cmdmM@oACSmVC#OzOu^V7O}|H_IpbR_@Z{C+9!*5B~^_2L9`KG!l>2gLaI zpGmen^?Z;@okpiYiU{(YZ+e~itQ+0R|iK)F^)>L z^}bfs2ju@y@qarkE3{>-0R#_UDfh)ht^Du!ZUXn}2mfvOS4Bfpikt&4m&CoYq#rTt zlUGUD6wROh!Stu@S+PDqo>bjZ7u<^f75$!y$cEPMK%S$R@mkj_PD9t_-e>xoQx(V1 zk8AmuJ6QO}&sp#_=gw{T-I|2&In4o&k2P!0T5#!p@E^A(Sx-+JQ?;dXp^0(+U-Bxh zYXZs>VmCRXS8k z`W3(POI^ivjj^1(_v7mo-bztCZa5T*>?yh5Ay+u>uY4fJOUdLw{sXT{Pj$D(eT4gg z@Odr3z3BbDBMXTkTWF`k-^st2aDP}QPxs~HGbGm^+Isx!-r3OqCq(}l|Dyjz@t>+! z>LQ8lzyWx^Ay5O)L+r2pKgY8G%zd4IdL2}f`heD2iV-efy0}#(d0!IpDIwoa^4TvR zMB>lV$4~N`KMwyYL+g6^qudIQx14qTw9Ux#R>@bZSvAJ}!>r>k0rw|$D|9h56vsih zKMMZtXp(q7#hMr2C!D+S-`4jH`Qv(GT;cx#{sZpAIlT`gTlz~}d#uNO6--c#kpAy# zBtQI$bGDsvxCMM{#)qlW7Ge}_C$7M5_Ei)|RO_-IKB7A9qiT}pNrtbr`5a{R@(V8i z$cm3uk-1*Dzr4Ta@fANsaV&-7W8wRL3*Ub_={KzP6xUC&?o;ExE&0fQHa;O8{v-a+ zkN-ri)9U4a=&!~>(G){lIexh#R5^Jru{I$?i4XUEQ*k+k_f^Q(mce^11}F0KzZ4&y zD>vsm|9gr_t3L4KArUH*{zevkO}JM)mK$;E6(iTj$(?}TOU0d;yC&cH!Bk9c z`R2`}zr=o~_w}LT6$16Pg*j$6KEs9k&+%hdX?lS@GKRPfg9`0@d@5>e%a5+sa!SIz zcsj}U6k}fLO0Li5o@ju>Ke#W|rXa@u_=0Htx1aecQ9X};&2MoEkN4HOpYwjGi_Yqs z;dfdc!cCnMhkJ)VZjB8Bgm1+G-2{DKg^%9__#>LUw17BV#3-3UoEX7wtWrMLdG^hu zJo^s6Q6uK(oBYOpj~`CO5|{tGHPCPM3B^U%{2?B~*&ew%=#Ph|{L*PJR=8KJ=)w3f zY`my|SXl+g>z{HY4hUk%B3u|?#ct#TK9|9AFD*HwvET?c<{AN9+#FNsyydX!=` z}m8S{b)x*~?hi^+CLw-4DG42O}`=-!*jr;q-{pH~POmKf} z7kmjb-Zj^2{GSHjFaH1Lp?+?t#T?OaKKpyf|J4s9=RJirq2vD?{?qCI58_`y6P^<( z^Z|E|Mw9@~6sUiR3lC8bUVp7R`g)dJSMh9~f8T{*bv^MN<`QFLApYu`4hOg2`uD?_v4hw!X<=y82oE(Tl@$R3{dot z3?s|I^#1|;$GF#3@k8`Xu_#3I=P~YwFJQcmCB9BK#W+&@_iQ^}KAWG(vR@JJOR;~I zj^OuE&t%)t&k`5-nH)R(sT@1>3F4zZiH~jKz?||5vC4^$bTz(iANrE`c=$JOIyBb? zDE!3A~>>$K8c{kmT3c&+Kh(<^R^;?=32 zXrAcL+%kvyZ)Sr)`aFv%w^N4VjGLVYuK!Q)AHyw{@C4k4b8=qzZyW!@ble7iRUG@C z_9+I#{B`-nvCXsQqjK%t?m5KF$hJ$WXWOq8&jFk|KlYDj68|=nen6}reA^#Q%%x+9 zy>!&G+2kQMaT)SGj9=+Tef$T-C_bB51d0oBFMf6_5F_FP;y-j?J{SpKu#oY-cDq>y z`?sT6TQpl#$o_2svit(Oq+fxZ0*ybsGjTII(GQ>j;`6S8&wH5t`?t~UkU#GM;C?3K zegkt?4t+V7Bwxp0FE6xb1OA~IrCLQaV4vgPkN4tG3$TlymxOb*C|q$ zs;~I*wam$57Uf$Da9{E39J`;G1eezGv79uP4<^Q+l4!tjFJ{|G<$b)f)0qo?$DE)T zm?7STa9`>X(qoEEr#RJ_b_Rak|N5@tLS@-Q#14G3BeVcMaTsxZ=b@)HXCt~BtIQf9 z-;s~}+lDDVeIdT{k?At;OSXG1wBV+pdX7)v+1k6&-d~|xC$5F3%yRsMpYK*6qw1Vt z_B1j+#r8X>J-mObC?O}5{O56=wOi(O0JZi=;gnI6K4-?yTplOoE@h6tjIpi!=MoF}4p?nM^zV)lBw|?p;LD}Z0JtLf z54ivD#D9QF`dR$0$_(s6xzr1mAmP?-H=>3Am3D(r z8nLeHfPeO$$O7=!{$c-Id%s_<)#(F|55K7RLyr+C8{pKLpXd%-w)q~l*aLxlf`{vRaz&pnNQt^c)NkVQbU3j^yiGfZ@_jg*E}%Oc&yDy~|qtwokH&g+pu*aOL8tJxOc| z#;a)i9gNp1-Eyt<=zM(l7uaxo_)Fe51U{}m>$;v(@#Rna^Nm&BjX z<)5bdlgdq`Wd8V?H={n{@-m@GR#H4sDsxfsl>UwIz0ga=r|x3Cw~95m;&}GO*Z&u+ z%gVuG{L^=|$FH@1DR_Rr{+A31o&bKr zkAJT-UaTLf(4qw=KjN-KrsO%!!+ zYP+{?w4IyQ+4c>q@v*$p#X#M%CWE*h%Us&LdMWvnKo6kg&bv_H5r6J2c9bLs~Jzeg^E{ID`~r7SX_>xj>&m^Z&cE^svbfarkM z2c=)kax#YhAZvIBexSp6V#Xo^TFAVlIGKv2Bb@pp3G39&@X(!qD*h$^FWMf^@+0uC z&xMTP?5Ys`@wkuw7SFA>ed2{6??Z%3YX?%pi?x-XtQ$kr~H?8;xh zg7`5Dwp!0gyX><8nf6+zY$xma6LY`TYvS1zTl^eiafv28-7?1-4$iaD?8j_oZN0BU z<57D&>*)it=>tRN6d+5=v%0MLpKG0CHxWlC^Z~`1Iz%*vwS{CwmlL=7$!0!&c~|xh zCM`iW0A0yLo|As)chP%sj)4F6C60gg0y_Hf|0H~-)(9E!Kt+$ZzZHE_{CAAsP_OiJ z`Y&Je0*cXno%8S)nn_Hhts7U`YJBJ~BDUL1@HL@Va~t0NL+jt98Zmd@uvYjREMMCwfH;?fh=sd`!KSnV-)zvo94Y(%^FY}TL-_^=&r zb7m3CW%3GZJ!G?0@3tEmEi@QBYwkae`TkhqQT_gnOuK@)Tl`#A_Ixxhr!FU6C-d+A z4$%UwDK{X`UCf+45&ohlYs-(|6`yOxo&oVGFF-D+^?_taT3ePTKHoXS>bp*TtwoO2 z>yvBUi7!286?!GC?e~Z8qID5nl}=~C|5<7D-_Hf-aPKMb-zmeK9&vgZ0i)@6Q@;|` z376sQ>luFO93SDXaGuYY7v6J-6S8;j4%XBArSuzMQ9ixIp2ROWugB0G_oWPgJ0izdIx zKHnGcbmIButSJQl)IV+(SD|IVjY~o^SS~W99OeVj0@3isuz`^~cYombgYjknv4 z$YU<3l4B<^CXar8FJp7B{i8;f!_|AlBkeMw0Qn$mCDseu8QY*YIzl_6bMj}DLvw=m z|D_kup1DAKf@Pp7SF=7m7kYCdGJ>O^14<{U56~}eA|BgY$RQPDem?s;+LI1_KyY_} z4^>DzNBD;??U>=^|I!yZ6g`1bZG6)H&snSq9vy=&A$vr#w?VJys{wZ@UQK-|y6Zj> zenjVh|A71A_;+s=`y*fVS$lWcw)HD)<@~8OcieY2sc&mP=0ACrIA)JnlkzvZxF3oK zpqLqNU4EFo20mXQ&Rh8lO4&>2m$DbnD`hWme<ky~z0`QaL5vKc|#EcQ*IP|2DB7 zK7Z;T*8BbEZOqqS+K7Jrtz+*QR-GW#4}Z4J#k)HTxn5~-apZG*?UV|8S-)r5ORVP= zySzVRe+GN1tG8+GkR1QXks`p9bclyzOtRlIob{sQOx1|*rg`Nu_|4Oi9UlX~Az71n zf>SCW8>pG(c#f}z<=NQ9p68T4N;Y^Bec3nuc?4o))6*IJKaV`&FMa*KQK_~O{@DvS z1HGX8Mw)$syjeD&+I!xW$37^XRqG0mdEXX?XPQ>*991LPEm9pOc{5!5;;eu;1$*G_ zSI?hD?4E8myxj-Z>*H66+wlnTB5tzJ9{(pX7tXVqj9JCL5bhOEsKQ@M*~@?O_zn1f zQT+gXhot)uOMp|0K+OQ$xeyZxJ|dMo&~_jD)vuhdzm93IAd3 z|3_&43CNp-f2|25D;5oq?4VZfT{zl-IjZK@!ydnAJ6_l zumRCNvh+i5WiO%H%tGrSnG*e4eLy-W+4KR`JRG85$)x_}|M_Z!R=RhRKimyU(OCtk z1JQYNp!-`_F17_@`@xfaU|-iFP6%;(KPhvOi>X%&yjQ*Mcw*We?c$@nb;aRU@v_5+ zxpWBh{a|~OxJGX(zLAS@1f3VHPb9@>dYxyY{jZP|GpNR`zqMxNu7&=;X5+iJuo3-- zSeqWRtzz@lc3Xq(_80j6li9~V{5ketQM}_a_IXFq2Sit%WnFPO`%KywIEt|!_6zj=f5QvD+&CKdbSdtz88B0vhkG8;OW{&gzr}$I20X%Q_vr{3fZ#!0er>0 z)}J|MCUT55&=}bhCyP(wUUN{<`5~tVT@0Qm4 z!wTU32I7Y@$KQMk@rFth^M-gy#7%mK*j0*ARD(EMwQf7z>fAxR7~-Gn_Z<7|Y52byWo%TNx;A}4R~s{QthMg7&|Yn}&aSJo)9H2` z_foDM@^p^GJoA;^ig z?f`%D*|+SOGg!->BlRntpBe4Jew$M)Q%3dOK|KW`P6WL4t8~ck7j4rfy>02#WqYOdbumE0F zdkRT;KuP$I`+SjVg!U(I)8;(lRqWli*|w}&Y%6Asvsr_`w!zJ6TD!L&Ab#NA!0{;# z?=Bt^vFxi}cLMnSJu#gA>SAj)BTh`~iuYK%Di66eU1!#PTI*>Y*hy=^L~_5Yvo>Vy z*S=aA>sadv>+x{~8`b_jn=`m0a)6OGdEy*v^W91-*K~_rUVS(5ptJ2TbT~>qj{XMg zu`}MxvisPl{d8cija`(-xXVXJM06b4Pa5oFNcby1HeEk(qbTkJVN~VNe^xP$hrtUp z9Gq(}AXmKU1NcwYgxV*N?BQ5sLRyPTZukW2!J5niohIbllnmw|aFkw$B(OcHU56ZM z)CO-4{^CUTjoCZAfOWvH!TXW)fnz(v1N11ge?bEtL)WAleV}~?v6`7KV_+v6M(psf>pV^T_G?_srz(tl7cYykUzIr4 z^%&0$iLKX$_;6bH4`@=|Mj#6q*X=9fOSMA2)85H}6klh`cajAaP3>)R-?s3TZJcaq z=CE$IZ0aD}vjbhll^*gNh8aQ_(T5$SPc+8@CG!{~oVXKMyB z;_Z9mHJHO~3O)f6;q0*-Lm^y+Gh>VXqxFDf3EFqi-qF)7v+ddjURUHeaDOE0!4p}F zoR7TldSph=AUpbWP@ba!tH4oCyZ}i-K3r(*%077NR`w5;o7G`YS$aU%4ubEOJ~+5P zoIX&x3%&t*(gy|=SXuhO`*XcN2a6Mv$HG1Y>){fZ1isv@2dt>B(J#5L; z5%7#tZ3D5lCEMNVWWgD>dDXJwNwVKnONgVn#5S*4!dfH4GPkX^{5_k9k+s7XuH0ko zM`qg7O_9Y^g62c_OFe}C&V$JPpUAYcDrQ<)_H|p2&P5M6-{}PxYp-i!+;|TkPx0fs z2{=TTqTS*n#0zSD-)c;*RqKhqQDf;Hpj!go#e*D9A2^Bq0_{iM${bM{ezDu+e4D>k zy6L_j3EZwfCO)ICu@xS10eXFkecoz`S@oI0UJM$*+%KD;L)Zt9PU)FFy{_5o@Cu!m z6APLBhMl}0<^aF$OtvU-ow#(UF9H9W`*v;FU>U?d8V@dee)zJrK&JE2qr{Pg*Aw0q zC-Ggzef|5+x6hxy#@ewK==()w8{ea;%^yF&)-0Ir;w3-YwLG9TKs?7=TNe&)bj5wZR+6vG)wI&uZF4_(O+#;#Y6qu*$`GEFiAb z|HP588T|Cv9KU}BhNP8o#&J-ot zpzJ}krqCW_kEvcJa4TyL>6AHr!!qEdJx{=Kj;ru?Vf9$;RD9853vN<@@D3d zOxA3^e*P=!>Cy+wEN| zR(Zb?>H}I2st=^KJ=wA%j}o9rHCb7wUicKeV149~o!lN_K7GKiNt0t# zb(J2de0%O-y|M~f&;(>WEf<-+JjvUHNd70CLg|%Czw}gi!D|MiYl3Z46Y>pQ>wU<{ zU+7-S@r^6tT;x^&OMr#iTmT(bEWT;T-MiI)0e)Zc>W{Y9{)S8kd0s`<^tD;vH7Dld zK=?exVqUXgnxprM<0mO`I1~|Dv<)!2UM^Uw_HkH@YDC!P-8uD2N$lm z=!g2iZ1#DYkI1t}(G@t0wdX;Md#6W?9_hizgid5#pnb!ekqbW$FR1;YP80HM{1Rk? zTO=Q5Z4%LH^#vvU)4a8tKCqa*s6NOqYt1rjO3Y+GdOGvKG4z2W;Q>xY&-9Z1h4%0m zcm?vbUTQWR-a&fgq5-}oaobW2E^g zz2XRKz@ARWcOSK}Uw`h_hdbE^R!qLUyd1^~5j2i@ z!QRl>tot1PABy?_eL?yHninpqo`r5%HfzQld#`VlDJBbave9j~r)uj>Zx%;io?Z0P#<{*0&+)IvE9loz2GT-j+ zz2w$)>!JBtFKXSPqX@X%y|fRze4$?c1>t@xbZ-T+w%N!x$73Tg1RcL#%(JcF>FcuQ zl3mWF?72(UqIuSheT{!~{?26ne+>T5jsFz>^$m^YMAEli(sx50;&VDC5?RQeJXX># zWk>on`+nzOFQ7F@seu2o%mK)RPGE21OxC691L6~&L3UWHSFW`jrTvL~_=p1bCs=DS z-qYX%1h<33nc%V;K0oWtHhX3Q>%k$c2hjsM4*VYl4Uq4c3+WTL566!fyrOu<(Z~?i z(Rbw6B9!Ud6!)+c?v+KkfP-QYD@Lhem2PAWwqVj=7e}@Qaoua*_B*R|De~OEA7t;q z`@e?_uW9+4?7Pn^J3SBS^yKESfQ^o**zhxP_H~MD68+};qN&2YY<9n$j&ASBeER@> z%}VU4J>5dO9$EHJ_E>)Zy4S@k1&m8Q4DKJ=LtofqXTbkI#@bN28yZ`Fe$}V_H`2}y z|0UcfZ~DK)>xowAawT;Q_&=BZfP>*dNP0hb)`*_yaa1Q3^sNs6m9HSi zIY~0#Ps&{E~^JN z`#8GVH?Yt9*LU{~(v6lDRuw&oaRK(Y;Pb_Z;Z|lj8rG|NZ(e@mY5nD)ST1 zA$y`Y&6mr-i|GH88vlG-dcvxUvn7EyISAVmrz?am^3mug{F?pgGv3U!zgEqrO*znl zTx6AbZhvtd`U0MIMjrzn(is#l_dRpqpmnk>F0@zR_2eV+RB$ePa@m&3p8R+GzIq^f z=YEMKh8z#gC5=?^aJT;A5Xuyq(-LG2kpxIDjR;-LswPd zS288)maM10_E{wZR+8VSjXMg^Db7d7D9`QpNcLPZ{;6--&~Nw95B9pfs*_*NX8$_J zo=1PUp|8(ccTqWAI(D}NH|1DXe`~wq1|vUOh)i%3ytI6dveaZz80Dk_i&vM{NvY}7#q>qx^_-D*B?!}k5^aN+6$9?~m{plYo zBR|F->7xO8&K_YAG(fuPd-#U)OGX8m6`WjP3-@M013F|B+H1@K*D(hO-|`cqK5&HQ z0M>>#vlgf_ojHJf6E=CBLdA=aJRp{b6l=8W;KIM7|E&8n9R2TXeLsI2`p@{k?KJjx zk3b0d(ge}+s&JhkjD zWtSx#yCaaTAM#Wdy4UD>V%K%rE7}9dwkuf+h)%q~II7!QI$vJzLVEuCbU1V^ilfh` z5;sb&j;pkCN6EjN#U#3&`FIukiZfQ`p&y!yO%e7dALp>n%K_i4NhACp1fCB9-%1Ye zn!Cgw3jc0xfZi%HtUEu=wVK$SIDHY;0xOX#ZQ~nRj9Jw;)FBhXvy6V%ceUgJg^t&| z1U^uFK=2zUKJZ`g0J25>Vu5VVBD*8z59tZ1FNN~b&${i$f55+=|M!pocFg}n0{)B4 z|EhWFNC7+oep)?zE@jb}9jX0Q_HUns-@pDnzrX5qZrN86?xlPCYwWYnLH=`T4RDVv z;HB2NRu9~_VZVJC`|Yv~le|c+4TrA(Z}G4Bc?&+{GB%^PtJ9^05{`X@W;a@f1f-b`p z_L18yEwp#0BljC^c3FQ<_vJ7A1p0ycgW^Rfj=)Frv@hU&6KVb^hW`KvHIeHi{a-#i z$^RFj|97O*fARma0r7HP4=?}MLp=@l`cyJYbgim5eOuQ4?eMek4tAN3;D1B@Sk8DO z8~r-JzaD%uT#3D}{Byhu?Bu6Mwl^C0;vpqRC?-$itNvf~{i2mBp-#zLjvr?XX`L<_ zJq=pj9ll-srpE9o=%P4Z6Au591^WE}?Fl%!kYq!m{o)NG`GD+4ZldqVhVdKrh^1Q~ zeZg&v|18FT3@Be&tp(=Lw`EuO3AlX)9N&ga@Pdd2$k&AY2Fd@7;)hh8;pGMsSqq5= z@FOB>c(VPjq2NC!p8o^>zh(W`nEAg}a{f=?e;D*Xh5u-OH-=j}%!_?8&ghyde;%`t z@%IF-AAW~zUz;2!JC`pP;r%FRz=@Hrwe&8u)_#V_ZA@s}DqSLE&Es{}Sp0vQyMM zdROwmZ>M`XkbI5pq#fF8bk%iL^^lW~7|DZVQz{$a+VBBo!1F(#`SLF#UQly@Vur}} z{5kl5hVTK3A+}!gp$1tfRF@)ZDU$uu@Gm_d@qho5_}82*|9iu*&DFZP8e{80WM$H+ zle|~@HPZDyi?#Mu_0aQ2)+<|X*@4TpQ@9U@@E-7Aybpw2Klbclmka)-10WtqplDz3e6 zOtHt9m>4zDRAZ{ir?JFDqsBZFqcJA2#omn-0TC<|#exNFSZGqk1_~lbRjPEU(tAfh zK)C&^@AseCb9OoBTrQgT^gTE*cV^F?wr9;+vu4ejH98X{TT%}0q6OyxcRBHW;t7xu ziH;q_{?)O~E8O=zJCwPfbA!u(tDC!kec^clFArMB_?yr88^hX8YrIDA0e=|Cz978+ zZma=iGiopP1$2LmXn^K`*WrnzC+=}S`u@zL-lgb&3jU`!`Y-t3ruAO}{)6)Hm;5hJ zObR-R{)VEH*0$5IDE|gqMcKkF)%2to;vuN$4^S3&OFIx?gqf5tf2KrSx^c{v+SmUIAQvqe1E>&yXDvCFoCHKfkniT{tsdnTNDENo9QhQuk@ zE3%C4YxZ9yUC$}(@jN#thaNEY!r=L&7v$jI!S4%6KB)U*Ix$bp zW?dv*6Q8?y`GxfVe+vF;Pr7Yd|A@!xN#EYwt{{59o`I zL^E`Lbgtu8-eBYqGJ(1;gKu@_Xyy4@6u?0UEUVSKV02h0J?{7>uXFF~h zx>`$_$2T$7Rd3yXzJO{`l zT!y^31^RW``xWo#zLc#X9cy^5g?O$l$c^2!qsY}_1 zScKi7`IXosuR!;=0v^ADaZ<_s6P!&#hV%itUz?EWmh-XFZx4EsaT}tVLSv&ADpoQM z&FQiktup|}5Ab^dvN5gmf?tFO6#UN~X3n2u|&l3DKPFM4N zl8L>*d7Bp4`Vov}YeO)X{T+<~+2zrg(B7}c$P)uA-I$SWP}%gz{z6w~{g5v1dr91r zV>tg?PSOJOt(Vxwb*r3B9qC_IRaG)Bs@2>3M5?X}H;mW!r8kN)4?uSh0b zVZVgte-+xVGoO+PX>F*v;cwUgb+R<%a2fSiq`}M#cQa77CpsTZMGpTO+n_NHM(ek*3wQi9RNukz6TSq z*8qtINqFad-|dN%E_WQ8Y`n^VmZSf?1zp%R%NN@0iNkG7pU&3*xqI!^hw7nAe~tBQ zak;(N;!^9`9J@!&F5>LWCHCTd*I1t?8{6=g+uF?0{TTx@Y!kGgJTV4b^Ai0dS)lHM zZazi(!Q3eU59sa=0``02z770O8)SAD=g7kOkY&sPq5-l+r+@xCe4(E;_)8Y>5p(Mr z#=!f?+usDQ?`5xC_7|jY;cR!GhnxkOy5<7u+FyV!*5m9S=H&s5cga#wNB2Y}|H&aB zn8^vhy>?Tfo01GTwi_owK8m`5yEVy5YzH_rwYQr!p@zW}P9u@NpmbvqkH{E3F?syYTn@ zD_NWFS}0?kwF({Sv2(D`h)nojPgQd68vFg&ad)=ckqJrGqWzT9ANA+|`*ClF&g*MG zP_`GGJUx&9N%#@Y!#@f;ya<3T<|p&}_Zm75_M5Shvu33&oj1d#jT&G>x;~06vrDb@ zMaNq6)4pwufA?i?9|QX+^^X3$vvYLI(O+EDfyWrL|}bk0V4z|t!%lRQB1&;Js&Qjh8i_2rW!0wHxArI`4q zrj~Erf*vfepF7=Vj2UDjd$qUTk2bJpZum3y&3@=~=jvhS%8s_XG@ z=4{O!fA|J^RmWf>;Sw9v@jhqsZ2sht=wmKGFMCtezPL`oKhXbhr|37p|J{)TNgq&q z!8$YI=>KAWK2Y}%J9{yo5&RGRn)Kr@j6>47oDWae8@oE%6Oi2R@J4=LSh|7EJ~R5& zvI8!gud>nb+N3IM@JJW5#?2#iU|gU=u2NCKYR@(CSBLCk6lH7|=M7?VH=70WJUbBkX9MYCZ3{$_6q9 zCJ%bqmd=`NA8l9{%}sFw1WTP0*4Zze|M(H|-mmnG&j;!bn$wBT^M4=uyXQAEugYGK z+Uz^Qm-%VT|Dgq0KmJM|5+A4iu|e48)PCtj&mt2|OGGNTjeo|5 z#`QMhrKfD(q!I2cescV(4YDu%+7j**VSYaeKJPodp!>)dcVQpki{1S_1ZNJ**@MWN zh9Yay-SZ!^Cej(xa7J7sKm0z+@E4pkmyc$jM|%Rd_K-|awtuyks`J)cY+ud*=nU}h zIWKZ~`*LTu@kPmlIIF!DS&JX<(bwv;aRL6C8swtrC{g?z;)9=05Cv4W&de>CGR8*t z?g-s)XdN2-6Wl-6n*QZbYk0yw&ZgB(z}vx}c|i6K>k0li%?q-lbH_)k=TbDFt92&K z?;lEb7|xx3Cg9JvI5$!@}je(4A`V z+dY}%q5Fb=1n&TUjRA!lGCwpv@hj}FA7T&x>qyoN|FE~(-)rxW9>96}WsG07&i@b_ zI=fG@7kLe5esnfCFaPa?3e{#ZTPEW9qjoY z_j!2$gfET#P4MxO>%7DFbiwvbv)BK?Ef(LyYoPup#L3k$be)o@?7-8o}G=}Ao9>Hx!SF<+`+XYp-!&q1 zeA3T0juuLuCp+y^mvX*^HSaS+YmoO;IUCU5WDfWO=c{&M9UwdK2eUVPd<)K5A}^BN zg&FJ%ZgM(s{#ylOS9j#)t5&~coQuf!mNG_}e8@inn@zxf{=|1|6!xxOz3)0}cf)D+ z@OekLvlESew-+{Wv3DE6U$D=^pSW8Y8@K%i`hT+MKX$Y@U*q)aoPG`GE6}f~qz>31 zLxf|?kc*5&SGW!O2j@WJe*ml{^L4NX{<0V8_kYm?pUeI)^iO&bg;00RcYi+X@Ynfp z+0R*w9Am;lL-&Sz_rUMdIe#NQa1UfaPRA0R%P(J5Za-1r6nV-3oYi&Irx8+Te8vmTZI%Q()BqW`rR{jXTQ;l6<82j73m zrviUf9XgZ_R~yBbIOlrYw@qm@{mDr#tTT9%7h?~MJ3nve!W}U8%Kjtk0PgLQ9j@Ij z)14r?3$nu9?Ry6@gAV8v51mot#?BVx!r5P<8Lmxf_{Y5!dQE=*2mH%6t;bH``!;LL zTQ=&|XRw9Vh&903jt9`%UU1i1Ut=H-e_-#%1Mt87#C_~u?4CYx&572%*~K=XT{C-c zU=Ld{djhuj*820{jQ>f@fzs)0&G;97AJ-4LKYRbO52$^g{a=6|Kp*Ig!JGqPZ>S$> zb^nm|Gm~Etk7)lDpBnrV?NXc6PMuYh1Kj+@g{J!_&PNE2y63x+J--U4A0itYvbiKX zoO>V}-uD{h1Gnl>9V_Tao>) zT5OAEjI;NK^|GPe91>oU4?tG^_zE2n z{0P9l#x(51IEa%nr<8ATbb8g|SvGglaBP@%wLVWYwr6iX1H3+rIbdJxVSmBjckpX$ zVuSOV6T}n9Ub<}SwYmH_?n}AMhIF|f*Enq9+^>? zS6TS$Yd&QEdpdeHuOsJ`&EM;p2P7B%F}j!gU02RJpxn^`%>##{D||BN$#qs#{9wPy zHJmj9FIX3CR^LRhR!fTgN{xY#O}$xFRnDG0_hZ1Tt(ZT<-e(?o8{1ftpgec;pRM1MjoAO`!v4=Y*h8J=!dWKcutc(i4(S?w`=n)|yab zpclIPW6u@xUx z+QfCb9|ZgG6a2p3VZdJc9yFYm)Z7GcbojKH8pgM~G((TbxfPd^OKa`99l&+5K;OYLmdB_7~6IFH;p1=JZ zx2|vgm+#nZtOx3`?|TdTezKu`_kSIPO^x5!s}EeyU1(jgp*h_)Zrq3ssA_a}xaS<( zAhH4U&dV_6TiM+f*?@cVe?`)_k@crVV2f1dS#>;*|j zMCWsKf2ZsQ{F^cGDCbN1L}xEW2Xwwyy2H}#mMs>QFFk~8XC-5NSbq~g#=utggI3I$ zV$-mpI;`u%_R53Tv#)a+cO(7SZpT)By<@)Q#(>5`^E18+-+zkrZhf0g9P*kiU9#BL zY=yqT?@vR{KL|W-H_z*O{vH?~_>ycv`}Lm2fX@B>5dE*S-bTNiGeOe#nY7OD5pAH1 zatV%-O-z13)a=g={MA+(h?5s^-}%vJXk0kQM@bh}cY{h-P&Oh(19Zo;?r_ze0DChA zq#vMj*n458U*~h&9j?p;*K&66e&~SCkm*iX+3KH(Tt)I?oyF9dMCo;=J1^?6ZFp5% zH`{t_Db1fa3|se|?9FHHayGJCaVMJW<==YDZtwxfe6ge3@@!;2H=J&-JaM;;8uF&i zShT`suwOeG$8~4+*le@<@cY`&`^oFvEf2gM?gM+B`#XrepJV%bJCIM2_8swo*Z&bQ71Uh>v+fH-XH`Y2)bl~IsYARR{tX+x? z{qg8_LIZj{%6jk$=73}EPINsQU_-Sbv|smVJb+%}`VSnyT{A1t?ZVzQ zc-#Vh@A7xi7qI^5OtPoCV$kn zybB$Puege`txkt?<-!>@d;DM<{aQP8KyS1su$8ZSvb5i;dvjz*`62duo~(b4b$X(u z_3ZbSy*PO}wx5yTOy^$tF=ofTh0Ql)I$s0!I_D$!?}M*9f{x%k(VqvKUB?)C{C#sa zfVnEc;k|E?Ypq)4T|0m9Wexmb#p#}LShNkBG z*26vt{}2;>6P{?b&SmTFk2l_{vZseuq9;&+eJ;+8uqP~g8-lye56ek!Sn|Sez!z!{ z`B-#^bk69euAIq-7U&)U*=QX-x7u{BY(DE~ozv8PPU0102SQGEi{w^s1m{F^*`v@| z-t}Dguy*ZQTd{bq&6_e3dwN~%^~dTvnQ&9~1aAZP8*`re9?nxsN9gee=iAedKVZ+k zHqf3Jv)o#|S7VpM%bfsh5ABV82XuSBgkFbWFZ-`r>;DRRARX^(86&d$_A>ealbQF0 z`|270^L?b4_2k;&%yuXl>HCg@go{0yU^bLBl-zN;2+l?+7bJ4i{iLw zc;;cRXNOxP3DA!5Kg5KRv>wpj0B4<;fBL7uELz;Cm+ zFcSULaSP1egJ$c#k2%Y>+JdDY*un*z`@o*gL}a;dJb4>-b7fBvduqVH3Hw4VPxtn8 z+BCSp9(nWuyYIz;)@0~%yLxnuod!R5IBU8uv$oq+G#{t?Kfd*fhrRZGZXAnDfOUa% zL3AcqdOo@jEdGT)@_up*C>(yG|FZr2!SV%G3jVVFEBLno{<^1k2>L0y?^*B{KNfz; z8be(0lkY;f80;PV$)q$fw4>Iu*7C9W65O7i^gM-o()rUFtU>T}x(iTuxL$)jnbTS! zA3_!+`+>62k+&(x7|it>U{d6 z1FGyX^pc;!jzi~>*aScyNB4-oGkvQ~nDv3pPvw#N*jaz*0c+TGpk4Xqa?bA5*sr1c2Z8&$!SC$~pC`NT2Z8s$ba*e=%hsD& z-=+SHpYDzj&!>4m{)Ik@{Wuh-DmMT3c|5lN*9e(K9{%X5cE$di?tz?*ev;%JrQol+ z!Y^5GKAaDbDukm9;W36b@OMOk7>xlJCYAp4$g+#QJT6=f$Jex9pw2g2RKJQ(9Y|#9Nq02`vp9`;J;5#@5JXH0qp-g*xP=2WCpfifW6iVYoPh= zd>?&57r3tU-N-|}V-+s`s{;SGu!r@*iiNgl`dH}I3&^h@Hg~j0^FKvfJ^wOFnYCMY7@i zea3=#O4+5+z0W7Ih7kWKJ%n?fsoU+T?Far3t+C%SxBs*sx*V_h_5H8kH0=L!<`?~=a>dx3-H!TCdy{&%XzUaUgkQ+LCU9#zZ4)z<*^R_&7 z*Z2XPK|TbVBw9awAD-ec<_y^+IRd?PrgKzaPr_4qia_zc-rX z?R&lq>}BIsd;2=mnf$5WvVG=9LHHZ@ZOG^Czi&j>Xc_k3Cp-J^Z?wIGJKz3jO~HBH z-E2O=KSmQpUHE%bekkT3f!g(W6S@VNwk={w49 zqsD-E_bJGFWh3+j>@Dgp*XGRQH*;U`MVu|xT}`qlAzLGo5$TMvvokN-P3Ho8Ic!kN zuCMG*@6B35JcIPl_k(}jpD}R&wtc=;uhQA3IidL`yZp&D)(V5{J$sL_*L%ER-5o5m<&vVh=XZ?T26m0v$-^<>M z&ioH%Pgi$CEZWSs!SndLPW0XWr%>p#m}dqjgcox3O>iNP&MlTBv(g#l<@2Z8wBdd1 z&1aiAnPt;IeT(x8l2?7n=_o(&&!1YmTh4HIe$C`g;15?X1tnEu;zuLG7zl0gkV&K| z8Vsr8v6{{wzxU%HF2uj_-{$N90)J<>i~SGDbtTi2jl~(P>m@7dGuhuQ((;W8yM?u) zY|7~@`O!_wo$X=S7XLE3Y^~z4j}l%Ostqr#Yw6_pwGc zb!acnvCp?^_P$a-HH2Sinc5P<(JS{p{U@0shQDIt{OL5w{4QN$Z2J;?y>ye46KT(X zBV$4KC8p|Z@l5RRj>V1?b|&sZmqd01&gD)g*_)7!u_MqeJM1=W_;A07?(xvQ&j-O9 zdZ#r54*gTP18(A6+D(=4jM#!|ywU#g;0n9r*%|g+m!a1ExsKMp>CM*ak`p-V#rmIp z;3g;TYxg4i*V)kL@3_SJb$rCejTmBcmaK5NJ%_!U_cmeApE-U4ZWiHHv|VR7^e^3` zaer+;{23!0lCI{izKSxPV_VKy^bzQ=JZ$aj|JA|&7IYSLKX*O$EAN5_Xw7|}uW(*< z0%sLhEu6`o7Yol4zjRAOI}82n_o%p3ULG7x@qhfwM4c{e1piQaK_b$NR|@+^Ggfdn z-(2qNMqZ%%xpZdWe)bTWu+Eb$AKlv|x*%ENnaGm<#2WHc?(aCcWrbTKN``zg=MGP1 zU2)Qb)pk;=9Cv?K+c|AE+bz#7vqw5lwys_JS3V7pXdyDC#kj!ZXL=>yOa7aa`0sH)^hK8T zd%B?xBt=BObqA;Tz@@V$x${SzZu^J3ms|I>U&ndH8yEvRuX;asKzD0;DY|aY+7kH1 zkGS`(273i=A*LcTKM%(YEds0cFOK!8@)Jk$k#HXDQ8+y~s8I2Ve=Z`QDBU3K?P*R} z#X4~TYlIogWrJ5bMeGG3LzaD4+2xfz0o~Umdz4!9>r6qD7ptrxJbnYtA;~rDS!s=k zyPfq}6V72ZWz8o3zisbD*8P>y*1KCz_IB>G4!2)ut^WCVp|+dS;U-~1cbF!v#G)BK0!+pMALToj)Nv7S>8(YL#A4*ft1(rN!#Is~#6 zrg5PAqGdNo=ghR1BtAiU#O8*v;nX8w>LYtMW45!wXgSA6YtcRJwM6P@BZU~+^hO4>sbFR zd+pI%IXl)4S@CGwux^b%qsP|(b|=q~Ob4|`N4wl->Qgg6-yQj$q$lEk`F`<<@{fr? zzBJK-b;}ppBy^cyzVBLl2!8+e6TWOWqO16CbXad@F1Q1IwI{CmJ!jqOac_4=?s^_# zE9Z0fCw6RfkAUuzslo6Dq{=t9$d~ZYhL+9>&FRD~~Vf zx;DVmPk*P6z465D?yfi80qM^%pt}hCZ~y(i>|6c}9p|&qb#BO9@dWq9_OgXjM?1R( zvR$D)FwH0b7k>H*$=BAwlPzbjXA%6^0_5%UR$-TWd8NCbeCC4nHf{C_oBZA^8#8=_ z4SBVj^=aGOdSCim+9Ph zj?$jPlG=MNhIt;sMe!;ooRpW%RHO{Ns>=)>`o@PB;UdfsDd)epe?#@B3UL^Y^y8An`E+|sp|CT8A3mlGp#5nkn z&ywBFwdf(QT(iX%FI|IuuLU-B(j*)E_8=S5tBbt`>^t9iu|0X^3HAUsU+#kLH$?BN zKKRq*_g~?h->sHK1hY*2ot?-AgYfd9o(rJtq%>Bi1UYTn7 zi7(s)KevcGK*vGb-@p!Rmq!1z$C3Nrd-@^lTkT^F(OJIjH@k6WZ9lu`tnVS$;7+i+ zFXHSwV_-l}n>%p?V|g~`;#RXZ*uq`!v@U9FPU{z*-F@%S2hBHf(xJ<`@GkB9@0YHf z&P4{NXRn7>SY|b42dD1~&htYw%=J^DulLRtO(aXuK4TdM)V6G`v`w3~+WHSR*y?2~ zZ1J2~_Wr~%Hf~5C8}f2H>)p1QJ%86F)<(L&XME3^z%$op{jBqz4HyH>*aLowvmU)3 zzKMHwpR?KH2GU>CkiRTv9j>)hMyMCyMSDVz_alZc&yNA0j*L;i=lxJt315Aq zy?_oPu0FxKbLQc# zj`!QUeY@E7k^ODqly_|fHc{3to@eWpEwm4oE`V3W{@s#!_JN%2;5j!BomcPH;0Gr? z*bp!OgUE%rkS_SyG$Fo}AIb^k$}bk@+K8M(vJSmpy=b24@cwVu0xn@Ax;Kw+V9nI(tW0ba8C&=WO&#ZEbkBM{O{4Pxs;WY;QmMO&6;Hd*~~A(x72HmDu*8S}tHto`pohf!l$XPESDk_+9= z{MMXv3=ds&6yJZ6Jp*skq2bxqx$(K~zR0fd5nWkNcDeIk&UL-xLhF`wyw{pj_e;vj z4o2t5?Hm+%E6=KySq=I8M~BuMz7yo8?| zOZjNB)Kqbvfj!|eWI-FC1B9aKx@k0Mswzc7H57JTX)}wPx!vIkRvRoxO+Jl(=zLjFY7$aJ=`qo(kHxoa`cr4>y=(USyoA!F7^YcQp z%bfiG;q(6=@cSX>7Y=r3SQ^Q`9dZrXJ8AISJ*)v|+T|KXE<6X{#p_-P=@w?3;-qt^ zF;zd|LU}qb-w1cR9O)Y4=%+5r@pa_fvoKc%FcJ0B0%qd%{o*_5r|g6>0cKd0fF^{y_&Gd7$hYEPa2X zyYGD%XIo!y-2i%cu|0RwU!ZxvwFl0D=7A5Qahmh=?Xr84?CSt`jg2rS9;RwzRCn^5 zB*zsE)ZMtZGyY}6NBaGDqcb7e@*wBB9!Kuek$Lkq?o%EC%@<$11lX@zv4pk$hsaAM z}r>>uCl+`;G)9K?Q75r>X}^bDjA z?Hn|vDK1}z%HqA8_M>Ghq4}SfUvQ$mai4*wD0h$XmMkUiN+x(%^_gRIF;yL4o+W3Cm*aK*V{2F?IoU8pUf3>Hs zKFQi#a-2PS(a}z~S@#8dcMQ72N2lDQmmO`+5&!5Vyh9f{=^o`-b}_x99CS~me=Mha zs2^kRJh(Q5Rc~w6ov$<0O`eN+cQI)qr|_f1J(@@_UCh?hr#tTDt1WRGeEn)ez8x;@ zvD}GHTP{1^o%MZ+z3~nW{%$X}yvF*YL!mvgshk6w|K3P!L~^DVT3W__scgJ!-__x4 z1PTWQ{s=aLJreZD0RzDsr+)%!arh@;>XN14A7HONP`UKal}NcU{9}3&r_AN5Jx{>e zTsF@p4SWgt6l^XJ1~pXXecJI{qL zmm`fF@tx5t?b0}QdiGBFFK~M2(mmI@!1L+w`%S#-ir+=`I!Aid89AN7)w`~Z;q@ce z1>dJt9-p=g^+~r?PGPxh8#=@Lcad}L(Dz)xI!0>(+Hvc% zqPbIRyv>~Ht*DSa--6C}9URDaarncJMQ~5TKj?CJ{Ey+}3*@gKE^b=b7W^4`Bu}~$ z?c}_(?u%ayU$}rhs~IET2|0Pn1yEnDf+zc{m} zF8b?>OQ+Kl=bVvMY+|411NI76%%6!Kz(gktn>%h8dZB~R4IIeW$1&&0X`eXd#u7jF zt%URGbf;V?CwrLAQU2_A2N$?m#3l2F^wLxJF3zL0dhgSRcD_Zr*oC%d{nU53vMG1$ zVD?OgVzcWV_QA&3GS2@>?)c%_mClAs1vWyet3}a^{i0aB04MnxDFGV;OazhePwylt z4)i?ab(N?{=kkGkzL39IOF~?nD=NmlP%O1F2S{g7<9RE*p={J_=FSVr`!`|-d*kXA zxaHU`^=>16QHSoh^v6F;IO!j4z~-rQ=oW8~3(xo;Qnqu1HwH%~2O@w)<0g!sZG*-`MSuPL5c?`BpI>437@(DXnSJ)@Q~5~}nwOl5 z{5bGcMY(o*+tmYc$ANS%49eXKN2V76?VU5WHv?eT_UVDioR$J zR2>rpd;=<7% zS5{H14OIlcma0OMl%IM~=1fH9F}x1&58obqPY05j;sz;J^J{W_%CyPc8M%-x&Qcf( zX@VabgdhH?4WS)sXCg^qdE1J~1B8NH*q5iE#=pKB6%gnAnoKpvz2QfDvJu$|6YnV0 zRgvnC_8N zFKNA;R&rh<6W}jjy`Ah69#g->yUzgpciH*d*#kR!U}q2P?17y;q9&M|4jO1w@?G5?gR*T*M&FYeQr4t&f?|MzFBz1uRwVY?F3H_7y$g|14r zCmH@vwJlj@UBkukK47>Xt1+f#Q)g=__9b!d+Cyo0B}*<=YBnyJzF0U)U+k6QKJl+q z!NtBg#`{1EK5_N`n+r&Eou>GtD@5T$;?&E!#((mTs%uS3m;RN?nNIb|o{EjPQuT)T zELO+QpB(>-w$#7pi7QUsFpXk+WlDfT0q$(DS3QZ2)h+oZqYq95ICGIeBbX~UF zlFxb2<~t+{z~pm0b_;dIlIik=)j5#Ny}jXdB_Ve@_LDw^Qj3kRWL$Av$@ZI`lev62 zn<}o;mzw^D_-$cHaZK^{DDU?C;<}2LSEQ_{if!erQ%jNqHWPMdWQz2qdLVEDMOwGR zM7jRwPo~>a&xM_lg<3o^L$3xNwlp0mEVWcjwzgWIOMFY7j)lynD*G7mp~$o!<5((0 zM<>r~r8+tZgn}`f?om%6(%9yko^FLwr2eV?=wCW_$oDZifbxo$72->m8B%P^PnVEL z?fR@(^?YU2*I616@ILtIvUjYfL{AnD#YDHJdnJ{NS04X8?(5`$KRux5@UHmyNS9jd zDP2<-xxvqrPU0jACBsFBXt4^nGcMh}>{o_%Mt*WUCL32G>~qCsCVR6u{@2o)MBVvL zD(IB${P=wGTIzLm+sc)E?=vQ2qOj{b-j;eryiekLGvIvEPc>=>dMMIm#k#Qs z1Vip(xF2YM_Op_sHslHZwmUndOkz?JcKOmt=+5xr7elA*_^@p^Dv=^?UpxduoBX@t zxF6=GqK%ElbarTma)Y0)JN*>5CWQ?OyZ-qJLtdK>D~w_d=5Y7$9SK7j(a`iN&4whlTUG~ zcr>MPKU!^&FyMah3t%4?77uyFeS%-HB18WL-?f~^1bol>Y5b=#i9++=5JiVFgC8gL z{gcXEv>9u!uoYKQTE-6C;0B#t+!Oy}zKfvNb z3kPED&B?#b77?D4V=9E*I~o(?S~^^^U1ZFE12q?`BgFaMiJ#)snYes`#7E%}6>wj8 zsySVFzX_ZduCL{esFhsXuuOOLVa|CGS9mONE@$((S4?gJ_jWGCEz*6oTe)Y9d&gFD zkEdvZXo2W}_yy4ywJQ6|v^4*{K0bV5CRJ#BCOTx!zvjIQQ>l^$*aSb@mL2K2MAwBb zti_LElE%g)ACSb@WO^UoRzJsWi}NN+%!dK4w{?~u)?s+({gNM&Xhd9}A1B-U9L7`9 zcV*DNV)$ROO;N@9@k<*+TSD34IqsP_CQiMrIREjgNBFOKUpT6}z&3*Wx_f;&IKL2F zpUu5s)44lPcQa4q{=o6LaS1nG@#}N;9%-h1$lZe6M?4>TunbzT20HK|Jb~_Z)p|g1 zEA~@tahLqcIP6lpPAZu(5C7ujdRkDdEjvnFseY+jd<=^;4aj3A@Km_iKM(6|^|NnB zoIh2v#!}?c_(j=d46f*)%2Zsp@AYjil{Vm}{CpZ3k#3uwlL=}Mhi@pJCGHo661CPL zOp@rQoSK(4_iNq{xW5$KpToV(Q^4zY*XHaU?gJdeouU1g=d9ndoV|hT8~JY%_ZIJl zkY@NA(nA|W3uZ$HmcSFNg$8Vbz(`IYn8j@kt?+*GO&m^tXY$DdIZ<4kDPM)_!&;K0 zTd?CjrMoQjZod0!8CfB1><8|uG~O$1y6jYsXI}d8PaXo>bd((b^L3=kF7c4AGL(>R zV>(SO?@G2a&QqcpMWg-qla~f~SG*tmONs~$Em~S(q>^jy*WJry;QuDp`X9ja3-@P( z`;(a0hjUl3aQ@ZBIqR_?XPxKgti!yVwV&&q!p{@ejdw3D%31HFIU4{i5FHRtAQ~V( zU_EOA@c^=!7dKSl_?La-p94%v_{B5DRZx67NxGc4_8mEt?i2r%?YCmjalY)^_)RjD zP35060Lmw-ZOrHCK?3X3HEkQ7@wiCl^x>lOdBIr9H?#=6!tO{$_B09k{=owf)tdOHr?e}CO*$sZo6Zy$ zm=C3Mg{S=M9XgQiywEr4*eqO<&xI+sr;H51lLgm;SJc2*O8!e_bcNOq)VS5Yl+^ly z{7D*6ES%4;c9e27LFqou&y~-gl4ZL(DLLPeM7TtSdA#R?^0K~Y-WTrAW4-_02D5h} z-0!j=XHU$^SxfNzj>$P|FfnJhjL+Fk<8b&2E3V08v*uIH9-3kH^c=Gu3q9YUwZp89 zIa`TLPGCDeM>d(gZop!{Sj--&EH+Y{l~!l=2LT4e?rc#eYeIg8(m`;j?CEwBfPta z@MWWOcFns6Eigw19++eKbw9>doJEDaUaLfDv7^znMw)2;w1*@KwMrvT|PhwsW7eBCVr(dWD5vQ zD)8f6@t1Bl{!_@d+gDI^og-WuYx#aE&-52X#~Ju`*4sHdcevT5qs(p? zYw!tXkIwWm0m%g=M_Bw(4f_D#zYL{2&MMQ;M6YDu1PBHAgy+~#WKIPOODgD~RQetD zp|GEP-xl^(De7HV!gk~>$V@~uaBIV_!Ws$+DhbdyWju<)_$uzl^vm}F`DkNp&V574 z7S#SC-@YPsgucn*diFW$!ZhwP-)o((J^e-C{Vec)GI&2~t=YhpX1$jf@;tN0SkE_` z;`w{c?|*+MXMY0cPac%B6W+?%@dLd39sWrJ%}yI^&<3-MN1EL{&g@=zhIYsZ`yxLW z%YMLu%{9mXxm)iO;C~FM!cg7DLcUk&{1C1k4m-x%&=+Yx{S^1ue>|Q7g-7wYPKUEN z=JBeottbsaUqaznQcRWx6r?JGm&tbKzom5bsW-*HhFUZAZYK z7w)eB_vgdwPlMNg7rY+=-oL(7bH3R#tle9&hQH%I=5p5f7mxDu`yar2;ry@P%-OHr z$k{LY=IrOVBXCFdGy6?{vp+xw{x;0)iqWhCCYe0}4S9t%z$o?t=CTj4E|32-Dvy65 z>^~+v75W7bpalP~u&lz|rPI{bi96c6&~Nc~hnW9fKZ2wNY+YQz*r}~`@wa3{K35n; zb@XXp7AKR>>Go%9EPO8A^0-ez`El8W9j}c;9r2*{u`It{P)@-te5AJ~U^)ADFYF!F%EQk6+K(4`0jK_j>2-yS;Mu{a$8= zy=M0FK4!x zojJ_x6ma>t{(im}-hUsQKlIg{9sEkpzWH*_4tUAzo4A8tHfVv_PhW=*fCij7)a+Wy z(HcPd0z+2j?0xhCwFg)n|9#`*o=HPA9iQ(RG9}+=#3fzwT@-c$Ig`y-y<`6^(p9Oh z<$Wm{5aT^OGl^nX7a9=5vQ#JyV@kAuV~!iAkeS?Pj`mL3vi zY6%#h_Fa9x|5Ig0C1*WVFPN~^=Ja{_A?{ju-AqS%|_aoN};-Y3IRI{MJVs$|u^0Xyp7 zY(ja--rSPa{9zz2y8K!Sn0Pp630f;J)}xNY?HA$vUOmiq?{4;`Zn&<5@xM%*c!3{5 zD^3_-c0Ox>+tCYnX13P@&>rCGPl*5V*Csn8iSNb2Q3pDhQ9?3niGGah*#Sb?{;0!q za`?ydeFE=E;Nmmgo$gIvQF_EDao>jv0hVoGy41p_Z2IhTVGbXYNSFU$##gw|Iv+C-?Up-dCc_%f5b6&OZ^{AGXr0*J8ik zzZahWM&|u5|F@4p@4e8Bg;!ENFE9^kz2{)^!Iu3gM_>FnL-@I?o{0w3@l zXvQ(f1OLHZK!XWpPtEe@1*W10=86uG1N z#X5T%amg_V+r@MaDnf2>Ds&dH8}f2=I#So%YTJ=UiiKn zcrIL*`vUka_j%kd!u{uQoe~ZjuqU+PAoBk|_=mHGq7O9A>~Z!0vI z&HNBuzIEvFt~ML6+|a{Cr)Qq_xk0`nIVlGZ;Xnt9Wh>sR>6A$vs4ZN)tWvS*ItmfARK5_O$X^ogNt1s^ zzC;!uPP|XNEcK0{=Au4D3IAn-ZwqsNUcMLc`cs+ngHDfl{jT%P+9KzBV4By-k$$dt ze#!YIr#k{(U3+)?gSX<}9exv@njN{7hE&993J(tI`c4Qx&+s6`o%<`GRbJ4zm3z==6+UWA+wv{>zKt z^;zdXGQ-RGZ$_8zQgr$Lj;{V62l@Ry&C|kt?cMDI-!FN-6G=lepx z6m~**6Z_c_vc1oRb?m4yai7Kz*mj)n@6tKy-kWhwR!vw)wQLC)|Gzet*zno8j$4sLpVAAcO;J-agq~$13uyx z(3QUo*?#_C9TB>J}>j* zrC35&eX$qGxR2Y$xbiIChv!=HUrpGapQ;nz5AJUU_dfvlmkIY->kIcs3ip>G<68*s z&-VO&L$}V)p_7jeFK2fCAA0*9@4X(6Gx$g`Dl#BWJsH&v_jl z@HV;6CmBF++UHNYlx(mQcpn@zL9)Yu|JqlOjgak;{}=5m*GKA=v`gcDG%iR*Hzi!5 zL@-MiT=6^fpLD-w`+3{X|Gk};ZJmQJk-`Vbz_k{x1TQo%Rv-^cJM_&lZdbu3d2p5N zKg(6gK^Ll>W6V&doTCNu>-<^8@jlR&%&?8Svqbr-KeR>PnBU`*tPg#j)tud1yqR;m z$oVFL``X)kb17$d7WjGpA@=ok_D=fu(!n{2d0*#tq__Vy^mg|I=fBu3XS;UwPIxZd z-whr>x;?=Omo=YDrXTkG108UC0bvc$DWUSf;_u)C{{D~Hb4tzf1 z1ycCmF^T`23F3@^=*<~Jy^XNi=l@Jc@ZAN&&?PB+5zqg{a6TEg6aO>4T;wtC%@8UC zL>2}`iuoUm5^t}&sLH?(;r(Xj#f|KNZD38jf%$R0bJ(5Tj00~r$o|Zh8r!(F#x`N_ z@}r0rl*1c{28a(3{(t;mrhig&fKrpMYpFzSNS@i#nc3yo&RW3v`I+d@%l`fdY-;si zma~@^=A16yV>3CwH^ofr{cD)_&ja`W3*0{%oxa1+>62dmUhw;Q+<#v9@A-Po*^>Q# z6idfhXkReM2l9HqVgBz(S)DvR(R!i)|Iz=G4bejVpCX-*?4uCQCkqkY z_v6%eYvG)q#D9fj8W6^J@bks*pn+n&8u~_ldf##+$0a~yL-e3#-fM|f$0-p^3L!X2`O86ld zlGNg>4YJQCeLkI+T?*}*!<;Yu+0oe1kPZIV(5LA(KgXP(W6sanoy_^twYzMT=lQk2 zCmsABqtmCexsvZ|{?5Qa;k0mHd-eyv;?Mg?e_!W*^ekRpbU`#gXM9vf9{)RO z-UsiC;XiW5{QO@O|7+=h&{tVMpT)ljueXi+o)#p>bTXVTb_WcEKHQdH=dK9_)j7k* zPbfKG8GP|3=KXb>JkBpi4!&e9XWZAAEd*C$S7VFT*4UB{YHZoM8e0K95G_~-Pp}c4 zlq3z%C;aDrp+mBM=^DiQ>kb~B-I2{q+0LDa?ws!5=(ntfb9*(;_P+FLT1>(A?gVUa zvcGr1aIbTF(p%{A!}Cj@_gmnw@K?C&^ z4<)~N2c7TTOFTdd|0CQ_;eQSDe~lgSMol69=k@Wam&;YFm+**LLHJ<)+zhs9b!^LZm zt)370wiUE#4m`hbf7q%T>~qyv*9A4!c6NFay=IoM@IXin;&aL%jgG+mF z!e!0-!hP}XZeJgqck@5Kp0(cBUcKgf;r*|m8CoyMhR;!iwTG{>yoW#o#2<)ONWWh) z07pZ@-0$$ehO%pr|0VE$SdFs}E<1vb|9AKg+@NiB{w2FB5e~Q?pb+|W^-=^eDP51&JCHKv%%A9oEtK|nz(9vduFwbm{o0~=T_VJ1=ThMS}+s7U>8Yrl)0KYcOA4~1GGT$0WT-?az@D;By-fBqxK8Jo`Kq%9Fq#iKRuLkaVp`yZ1t>X zO`|*WreiN_B;VH;-lYrk{$t>Nb8!FG2{m@*m>Tr?YS8DavE%!DdHWA~W0#9LUwZtq z#o=^%z;(BG7va6|SMzwled+%S&xP}63^n)&@6H(F<75j&HodeC_?mc$JpM;Azm8f1 zF!y&VHUIy2a7no3_w)Q7 zo%ZX5?;nB>>%jTd;KXwH;RVcz?}PX6anIDdbD1N-`GHeeA5UVgWIfz#T#dalHn?iS z)x=fXYZI!h@1!bw^Svq?G!1(2zNZBfz^^GwSSLUW=D=&u2k#amXJ5wryMj7bvj$&F z{h|wMi>C|VE4Zrthd>t`J>WY6J#d3BF#?0!DJhTEw~Oc3yf56>U3psb%VxIj(d)6W z#-4=dZxP}C72y6q!2SOM_mAse{T%V#ns3F23)h5q%h!9^-JF#* zHiNlv@}e4WzQ*2pzsBAIC;Gq(zYH(jePoS24_>q%R%6c&sj+7USKHH(duDJIaaGoC zXq9z*yUIF`sInfTtL$ay!fO+%t>1gqHV7VJ7-f!}L;3S+Yyx;Ud2tPEg@`U-!!po? z<X58T$AFMO9yk8oe|eXRlX zPILaZUcq)Re1i1S*( zYJ~eg?NejlVcwTL&VA75)f}(2d2*dE+!s$EyPe_@ehR(#bwBR#80gOg=sciwbel|K zFJY>2ucx;I+<21Nzo7xL~Dbc!prBu(WjZqTlcTB2l{xNzT>4TyX}Q4tJkf{ZtPrT|L#;}*LJM3 zYdTcf)$P4g_*&e*iNE3bD!YkvO5df&F@VTirVIqdPlvSI=ofI14yD=gtH+oX9=CQ~lk&PtVS|JNw$s_V$59 z19W~saskN)zsDLtv_y5eeS{SLLkG|)jOG9A{}1!DMf3kB$Ny+l7Qy{M3zBnxh|Bx* z`%Vf_98;s7rGLUG-G0A#$;DzlzgCF*?8T#xvywe|;r_H`tjp(B+W>IwC3y69@a&I* z^TN^F!Pi^5R@!x)D(#APm3Hwnm3ID9m3GeK754Ww6?RtZ3OnP+8Y>th5_CSK6)JE3Hw_O6WqR-TS(y2~P}U z{XjdqjjFO;z#P7XwLm4forAU|76Gm^g#ViRH^J|S-(Ljo z&lJCpOk*fzNY394y7Cn3eewKx+~>}{QwI6FcVve@;Qrp=zI6Aqd;8)A_Jt=nhFI@Dk@${wbItCD7tnp4vJ0%Wg7gSw z16XBy{KxMQ=Kp~Ik^UdFM)v~V2yfx=e+m0C%>TM~NOlXh?-!3ukHy>Zw+Ov(Fd;tg zZ(n2n&!&!hxfK3q`#nxqw@&djh)&1zevNHL*=>D<`wKs)X5O#1G3?d#p9+sZrplgV z9=->jy?&2MyQ)K_{R@0O8$3V#;R^fXgB5o2eHC`%z2)}X7UlMv=H+&5({elJ?s7Z& z?g~5lF7J-HyOOv{JFaP^{RVe@vr7AI^GZ9RMWy}z-by>=zDoP!0~Pk?hbru^k5T%G|#m`rzbz%=_cP{UPM-4ep0MzI%{s)Q{HtXGgey)SEdw9Na&I zd0)Ex!u|ZbFMeDuvA6$Ccmu8VkAmO-1AK$z2I3>?QEoHJ*M5TP>kV%%`~2Ex)Scf7 zzs#LoPtEf8cwREn+XmJ>0FohS4)}{`)bbk@m6w6`=un zzCd(f=dYj#;$9CpUaI%Q`+_2hL{J9(t-0Ux{OI3CxW9^df6+Sj;?U0-#~%H_8CBME ztjGOky_u7{RDzF{4(EU0vcisOT4BGsquhRWd%6AiwsQMX!!kRpL7Dxqewls$)-wBE zJ-PD8RoM6IRoD;eRoV}3t+XHF4y*6okLp+0;SDP6#|+Fx>+W66{R>F*9`xcJ@`U~U$B;it&M(}*0$JbR9nbIQec`_D z;PANb`S83hZiM^d`?cm5o@%|X^Zw!+g!@;&TVuCP@_dE%6S^bc?8h2w#F`rJ{YCab zTc+Y9d(@r1y0=I6!6h5i9)b1?w0EGhLTUWxxucKE>;I#R@Ry<3|3IerpTPh0=u7UN zq~8>M+KK-~aB$ckdxUXF+ zS7u+kMy}lUzgDiozV2M5edAj1{Bsn4UAZ0b?{YinhH^XP=5pvnxvS^@-3dA{uO6B_UqG~iv<0yCfi3z^GTv1hS?xqS=rfC}UR z(p}KrfOub><&(TmYkt4SSL62hl05%a%+vn_XC>!9hIwD>{V?xKU*F04B!_3dmy`W{ z$>oLn+Nan0;1}ePUXb_)=?gRh_wS#9{vdLN9>_5Juc)!nAF$R!-ZCFrq`ikl_}YJv z-hp@k-3KUpfx>0!6@C-_0?8Fh<3BWlG)I)c|K-~?|A1Bp9)CjB^FJRA8qmvbln z*N&Pc-p0Ke_kZa1I7Xqm;J+&IbH8N$S)O0>{`iF+_j|KH-(h&AJ=m|(>Vsz&JzHU? z!I%FUzW%TV;OLFz_EqqEpR3AjuPe%I&&#)ZcLly&c)pzX&XwEVSCrd6S5)NP-j`SK zuEO@btitxVwA^;b?QvPTOTRDW?oas#-B<>lDT5A_*^%%BC$Pr&)5GO<&XeV=Lp%+* z4;t_cG~h*OzyR?9$OWgszsv=boj2tti2?MTnC+UG6~u{fi;O_c8|aBWdMI2 z=JkVRAN=FuzwRCp{>xT*-F^ui&U61^1jc?A=d;gAUcXqlZf)7|QU$zD<9e~Dy7i56 zYTd8%_d0tgo`1nw_-5AoV;5A};F-wPCRSRf5ta7vfJ(dlsPTc@rtz-PJ!Y$Zj z&K~wq_V{1Y9zXoJ*8KIx*EoFr8*}!_18cZfyT;k;I*5JyuOQ3cgEhb8ePMrJcrTqC z>G|v44e9AfXIJt%;lB3wZk>>Gdx&A)cRWA1Kb3iZ5pY<`qD1mYw;zeGa|ctPP5n4O zAsK+~0zPXPcX}|VtBw@@w}%&y>*#p`XaA$b{4aatb;18)SRaqN81IoJGw{MjYdl(a z;(y)nu`a1*h|>cm!7~X@O~LiHv7h}m7QK> z?+cKT&*TiR=6=@bn*0Ae1ifD7{-5=&u|t{r_h;_k8+rbhy65Z*-8>BtA0U1}=l7(? zFFjqI`PDsM(&N1x+^_F&-_sY(`@;R1UZ_+Ph?7Dk->ES?v8cIo-HV)K58tJ~<^F;mqADs}A{;lFr) z>GtW|-Rzax-$&L5?!Pz=KAQFZ{rxJe(M#a|^USgE*?)Mj+>Sz){{v)u2V7rfdtX_` z91orb+}FHKcvt9w_<=8h@4GpiXI;SBK@c$g} zKDhG;{|mkXdLkOIA2Njlp#k4xJ@C`p%k1c;Wp)yD>5NCp?SiMu(KRTy2Hh*H`KuMy z`prt~h~B`f$S;SmhcO=B{(a_Bokd=<-fZCp&L%P!j#9j-l2;iCADZV|kV?3GMn9i)1-{~7Ab`bqA-)VRy$iJuu@+Guf( zIz-o!j_>s4`&9!e-RdEX&z*j$9`Nu=!Xxf;y^DTXu!i`^mtOow$iJl1uQN7dm=~o_ z*I{^--TwwUH^}-fZC7q*!B?Mjf0-S{9D6vjw?l66>u}+#_<60*KcB(-eVEtxldK;Z z{x_~KXRc;{06G34tQo$8EMIcG@4>&zKNS2u7%~s0aO4*(l_;ExBj%!wi4nY~Z17+wFm7`x+VJ%)|e*m4KSH@J^ z5OjIQGtWVtfZgUpUzQkmezMO1?oS8zrDH4{l&nv9D13-v z34aDstVTYmJB0dy|4-7kdgFYXg#Xeh*u5*b-YLR+;lG^2e{WAX*#8RpKdsUKdktBl z@L#fpy5hgumi-sEHQ8>$;CYPuq5*}tud)KpC;iMY-T7G11DU?2AJcG%GDOh=g-ens zhiS#_e{F>S)0sNy^bce1f8kwpdEY|U4xazA_Uy5>LCy{y{pv2s+=ORanZLI>Je6F3 zH{^Qa|G$J2E!Z1gUU)B@KbZZ%L)qv59((#fWIz9K_6dL5xSTaR@&o4TBXIJ6*0{`m zblX;F!B#uyCiV*1Gu#{8mdsFc1g-rYEs!6{6~4&3-Kk&c_Jsy0kLbWbH*Q5Pv6X#^ zt@ew?TkTlrz#ktdv-99zZhXGN?(Kz406l@;=-UmO0Upjr--kJW6z;8sW-rd=ZZ78O z7U1p8;IHssbFK9ArL*VseI)w_-@ghCkW=`(;QkSB_;kA0L%RBRPnNBHvrd%x$|8Rc zi0%ZC-p#Cy*hgW{VI}(z>%o1=`rI^}fRTbao3jf1*F8dgp-)c!5B{Gygfm0zCw`Oq zcdHHm;SEaSKX9)d|7*a{@Gs2OX$p`YcVVF67vMkCBhY||iX?G98P0UU&c}itNc3|7 z?(1Fji;^YSF9$B+zUF@Ie|@moY$0=z&erK{O^0{T?PCw^I_Ar>k*N#!kGK|1&sBa;Z+GTk;lJFT@cLTU@6W!y_5{8I-XC@wIy{Xl?3Z^|*pcY|9n+kBgL}{m zWZ&+0=<=M@ZeW^dZ` zWt{kjud*%>E%-L=6&*N|_MZy>qV+(1bQd1tOu_SSS9318#)d!x-iF^F4374}@5=o8 z#8k896TyGh?3c23|NAi6?&O}H{vOAryDu5ww_dG5|EI>j*PA^-aQ}q2w6~A#uXiOo zl%0Kl*PrYG>z;4j_pN)s=Sk+so`Yl}d21Hf0vozc3? z{`?5|*{aM=gw`Cx`dxg%kKp6Au9uu&@&U>Gwa+M7!rnNwLvGLDpaY@>YRev1Y=JM> z0w1!)4up>Uu>MwbkIU?IzUK;b7n*R6pv{}r)@68&^%{7!4rA+*I_wy1ao%#q#SqrNng*; z+3&34?qBxW-)E0uA@o*f?$&|(vL`D1E`|FVFmC=A{=+kfjtKv6itzsk>OY_^^Z%!Y z|Dpp7xM%{(&i@*AZpa;``SichcN1x!>R!oO2mnZLK(K zcXPK&Wc?Lxj{Y&S^@HL0C2N!1U32f}FWKVu_qDe#9zZ;}^!p?eJgi~4{fhbixA#Cl zT2-Pez}cQBs_guyE72ROM4nLT^me63Ai1K}7#Fi=cfr%;Zg24~$nCVpARa;bMcQZ7 zx497;P23$>o~J; z_sg6qga)){U%v}7{;u%U9Vc+c5BCVT-vS-GM&SQV$nUNm$vOZ&;GfLxvZ4E5gFT=B zdvN|__<}z%7ykpE|H?5r^m%jE3f%8F51InL3ilnq5AI9HNOSoH`d@d13%85${7J|c z$A8%sWlrBt{15hjQ}aLTkJ9V^F#8wd{r^9CZviGpm9~Ee7TCqzmjxDgUEJN>-6bJ` zga~mb3Q^)lLfqXa?(RO3iMz~9#xl?MyH8bhSNHTt*nRi?U*FqvovG^T>gtknKQ7_3 z46h5-!hPQ1@Xu$_Key+f#=rkd`n8x5KAZpR>$*3ZW8(ShRh8yre-|?7ChR{e;Qgwt zA-kV)>1q-?^A&#n7m2O?R}JFz@$;RFEPXPv_3>c+n2*xX!PDIGIOu@*tz_;C!Q@r! zdwW&I2Pj5}yr9dRoZ*XtT3>WivBlZKu{#=IL(Af;}BRHKzc0Se({Lb#YbAp##$U3!}g!GF*r+p1IdqyDfQ`*oCbiyW83-)cNHJ{Z3d zr`uvIvN-m}#^mrRhex?ZmHO(tSytkAa)3F``^XNjf!XKCFVykxh8EmHu90+vOQ8c7 zB6pmPP4ZM|fNTQCabCi|hR#v)$$5N!B{GC^iJq(nec^ata_rLGp-KIyPXZmNJDNJa zNk^;!bf6(PZwRIv5NlH(JKoQX+KlRqD)9J<{-}(eUlaYIA^O8_#O8EM_TqFWa&J{9 zcon{tMA(-+COb@FuY&Qf{2$dB@$LT|ecONgko{lwKlTat|0z2E_wZD7><3p9LTEq- zLTHUeX#*b5 z!mN0G1M2^@Ny@Qq(~eLx09_k<+hA(=1Y;mEdHskr=tEqN@`}514jr&RiZ(Q%-ba0E z0@a{Kkd9T7d_&O`$px|@Jk0q?Kez$k!4=3I(oxQdqXCiw6niL|a0awNwglxQ-U(fM zjGRT~6^ph=M)@9ERY5h1i1Dch4fqu~pv4?wb>Qjkmzs4z?{1H6u-!_tHVpOoXv1g+ z<|XU*0Qchg&ab~ywY9t+A)8XEDT0hGzOOt(>HAq=Uou%yW@bYClf(C7l9&IZ{6B~P zEr;PjD#4*dVq z&tzXAoZR)ORjm6~^w4$a{7a~pHDlcoV*SMXv#oU!ScCU|i;qnF`+n^7*OVs*54m3U zHp%`D|MJI4wuY~sgpKX&;%V^Ubh{P*;FHJMX?f63KV-Oq8<16GXz)%j9wpYGTrI$)=7F@`!~(1Ob74$3{0e4=xdo!}mH z`kS%uUxh62_aC4E=qjiAxCfa3Laq16= z2GkjyV@-zEFoS8oDW=!{cSz=Jp6b=@f8k3w;!J3_54!*AN2xJov;P){N?*| zO|`<+BWxGg-;aMU6(LO6*Rzm3Cf={PT-d8<_z&X$eC!t)|I7z?LOSP__vIliz>nDI zpJS!oal+USuO~uq-gEJ`#2zX7&A>Mi*%F8S=r!mF|F>S^Gl$_j`+3f)qrE}%lU%bo z>%CaNLFD#{_y3~&9%TPd+k5==E_DAZ%4VSZV`~Th^1CVSMl`_jer)WgeFN_=m1fse zNOwNGSBVoUj^D8E5aMl#AyN%b#S|^wOpi?D4b|pL;rC^C*uZ)+d{Q<06nCsTzluRv zNRG~&WX~TlVYz&g)C|T~FIv(ZI^gP#Kwo4(d;?kFX>9uUV=EN>xf&X92{b_V3fTqa z8<0&f@D)l|IRhG?xWh|{VN(8*>=h3o3%rQELNuThc8VJ40?n`obm4agAlHvtlWP-* z*PTlI%}n|6b`UQ_yx@Gr4Wi#KL!Oe3uUH=Od->E9v%3%6A4K>{bJ!=Y7tl-Y*E9K# z;lHmJ;~S0tP0asQUo=+#=lt<0=8u0LC!DLpy`ZDmnvSh}qR z;6I=RVH%_h47TUyawME(xkqyFnK5!Svb~X=Z8_OlmXj5XOx78hnU)SYV1#01wI!ccN6c^ zDv6p}VD@|B<=<;6gUGvx(O|i8#OG;Gxq0HI4^UCu3JXr$m}vhHvebD(Uuk z1LEx3X1KT^`4Cl)YdSHutB7?|z3zR;9*Q-T4MK4Ts@JVLy^1~F1zl1MqI{8}5AsRK zHn9}`IyX6&+J5A|EhJBtxV={6(bJIy%HhM4t?(^$m1l|hS3H4ymDeaf5nF`(|7So0 zgn7yT@>y!gUa6R5`4g2taK(?FEkQKk)#e-lZ=0W+kXhW2bj+Q>>lnF-{Xw{`$d6M4F8w^A00$> zft~z6%**XpjDIBmU%X$VBLkg4^*1>Jv%1{8+>2&cz) z5No5<`=ZhS5Az=0g_Rs|E*uF@S>Q7(Bh50?Q!OLykY%JEw8MuESo*b_l;x_6fy+OyLiJM7?YjqM4>Hn!z6_OLx>@cli79mCt_(B2*HJkk&B<~;XV zR{8*=-yc_uV1`hws@D{hh6MpuyiBx z@!#Q*XMmFv$=i|q?_zIQo`f&pOl*D^!)veoi5NTd{ui4bb~!?|h#l%o-mUbC#qe`s z`ViPq+(GL0cky?WYeWNl=Dp41$bec)%&fZm?3 z;$3K#{D!g#+yM=6G0KhxaQ;3G@O*>v4|=f+%1ux_f%F^E0M!$C3SYwq)E$)FvOczp zj?^|Ci2jm94WQZUp+{SY*M&B1=9+e5L*2)@9^$+uUj;h7upQ*FgqLov1#>h0MilV= z=%k|%uX~>zf!{mq z!`FrR%)@Em{h*~E+GlA8_Sm7lI~`{C@7!Yhwr{e%TQ}J5&B?ZF(;C~kVU_JzzrwaB zFSl)Lm)W*8OKt0#CAM|-V%xH6kv|sN=9R%%V4GJJKDMk{$afPB|GO($YH+MAs~6eU zwM%UK`sKEJ(<t$p9r@KHLkmNasm}oW7ZO`{6*0Sa5Vt4Z|8DCH zcMQ>z-q`b|tfuA$b+lAFTrzKAFCLYn5n1S>q6d&HM+dgSXOrQ%OOOSoAx{J}K=z9I zg7@p7J^}_B%?B_+n z{|PbtU-)O_i+@jsP%NRV|IdAvT_WFC_|&!jAA7|gIQ%~U9|=%g_%gtGK}%={_|M4! z`&oxA<8Z3O{h|H4?7;4=wimqb-n`CsZd_&Cl9xHGrL34|8<)bny)26=<`OQ#HVzqxw$I9tD9vZXAaY1`K>uw5IW2V2(Ju5Fua>(*_SoU+T7B=5Bu zD-PJ`1&6H9j5KSPluo=)2E5=(YIH+VFFT6jJU7==tOe`R}o1;5}Y zsE*z82jT}lrT&LvmLI1UfP4dv1|Y+T1_XIUE`A9chG@X?vl&Q(yjn zA<3)hrP^MJ@h_bqB>xXn?IB`^iEWY&p;9-T#M1T2z$vcrm z-wB^$QP(9m0WpqbbK>bZ=r4yZ_2);@;~w^qaGXcCv+IdfJL9gKgcMv9@W+G-QbR zwt3xh+pun(tz460^OkS3i3@kz;F@19&-0 z(gQA*41hiGeqsw=#J~6{w!pGn)6dWV^*0a=7!3`W&2O*9e!87&Q!PPf$jkQv!!t48 zoeKP*ApGBq{(oM9`1fpss!4Dw`@S^6s~x5sqG8xFl~c3>{7ZK!=q1_g-@lUBUx*$r zFPdY2ExKoTyZ5c|mi*UsdGSZlHbpmP!}~Kc(=9!9za8AS!}jjjXgiRjH?LV}8^>6gO^>0|p`URsA>%nNuI-`GM_F)Y0$3T`bgZ&3IENT54d}sX{ zlyIXz@AVr)TU6j2YCszr*{nfrY|+^6wsP7KTgSPtTQb{LFI{Ylm#ntw3pU#5SzE35 zVIi} z)#NTtoK98A%h>&T(;Gng@)B(RTd@13BFq1om(vH3OEY9oV~*}o+&q|*ZBQ|bid&ij zKOTj@(8&VCE-HSZ0yY8B0M!C|3|-((WE9l~_y_sH$|F#og5-YHCs5v^au$`xe4Z}@ z$S5j2KNJP-l*<2V{ivB-@?XsZD6Co`>>Wj z*}z{*Ti^P{tyi5dtb2`*ty`7%?e~iBSmz3FTBmZaTgM+?wGO3Uw)Q`~WbJ-<(b|6h zg0=lYgLRG4(b10ecBNmk_KXhx=*Th{9r#}R@1M7JrJl34C7-pnrJm)OFYp_$*zc9z zvEH>mw;@eR+qjN3Yg`MRzl!);$@pjD-xK~dw66R=#oS4@mXAQOIA5bHtN%ws*~{qt zC4c`^^z`LmbSpM}*~fI>kM`y8&ljZ!$Tlb%un##vISbOiroN3ObosvoF&qRLkk_BEsfxYz*2Fk=gp9-vy|sx$sEvW;|s z+Ry;$0{w|sQorIA{Pqs&^{Kzu{|x@U<3{oC_3R7F{|WKm5xHNZljQ#b_&@T0;rP$X zn~&;hgPlXm&}tr@tk1l;QrKR3XH$OP(D7u4^?Wbr`jYp-eHOm54Do$o|G+NWi_L8N zrj@p7)jV6faI!6%KEf7^>ti#KtEcp7>2N=?Z8aO*tgQ8IP~3Xd{KVm|W7${0^|RKh z_!HLRtB0-G=l5HaPwuf_KfKEtzki1{dhd2?`0lON;GJ9CsL#@kcUii@XYYBVAxn)$ zjK<-?_Z#!QhHu|w4c@xJ>c4qC;|7j*vo-$U4u10iYsWcsukx-9Zd}5~cc^AF`Zlxq zBfHrmY!36MPO#}yXWN)bORe9ywbq_O5)JzAw4eUiXWw)Vd7W(Q}g#?v+yq+0QY4=AA$OO%D15YpXw7ld8J1Kl*8~dzRc42GCv{~ zN%g?Q&u=G2$k71e83P({3b+>?P)%Z&zlg6xIR(mNxD6Vh`od1X;W$5VJjEv~u0eUy zbEzq;no0ZN&FY^}C=JjqNA>cK8IOPI?xoP-U!0JS{x9rnbc*6%HUi22`c5AGANzl1 zLi?Zh`*|rqlsgxAIo{W!JM&)8G{I(vd52RDsAb~4!ryc|<@p!hL+kFC!afs8n3t@d zm7U??9{*VCp}pA0ci0|e{s8-H7EH3G(}vkRbm{4X+S!C2zuM>ywVaIIuVD#?`z{sV zf>*y_zkT}{*t^eu`S1>~c$5A7#SyqWD`m=>gnHAgBl(5@3;in-;1YL?0hP=vsCo{G;02(+rRLysot*S{*qm>y%C?= zbQJN!Q>2fJ7bBa?P7W?rTk{|`JlU7@JYrw*PmA|%f-vK>%6Ei+!Y>>R5FOBZ@ZRYK z*xy746j!tu8ld_meX$Fw4@gaHg32d)pYwd48sWmS>Vql%_mVgoApDCKxH{v=2ciM0 zZ>W5Rz{c?j@`U1}nvKbF@sgAAZ>%J4Y3IQsu2-UPlISWUoAdcN_MQaqf}N=cCi$OO ziG|2K>e<_j`zs7fmlvil=tup(9v;>m<~t_Ff1>*T`PcuB{(j(Fi=_hzuQWJ$@5AUJ zBJcgj8*@u|#7Bw$=+ET07W}m1`@%kc{VaU?85!dD2YuWl@9)}dJF$^(UBA>eEJshC zmt;$(46(V$`%?$BvGLs++6Z|5peALk_s?Hjx2o@(_T zAN^65WjTM8XIY;8%JH2a?>f%P{OedNedjTZV>#aOR{nvLp)Kdx&#zr=Ek3{3elPcu z4XppAjcr%OruJ@TGl%_Q(?<`rq%jk1$f&v2b;xpS(s#X8?Y7m5x8Gy$wK!-mG)^Z5 zPBB09>K;9U z9pR0Z8TMVbOyUl*T)gCH>;TG}PN8iiMned^H^Ir`vI2Qjy!GH6W zxvmG`%M+;oJDmF8;QvJ7zbm7&kNu7;J28CuU;O{(p+*g3dI*!p*l3wok7(8|Y7p^^ z)c+)E1C0K*uIH%nAN_g+_rdi?o+lR`?524UKcb%odxe*Lp@=W}{&R@y$tFfG1HXQn zv+?b;eLFYXE{FSNwsGZL2{h{xb;`8tQQHt+=cO(1VP|S*7&tO^NCO*HTnETxt&s}W4zJH5#{QfEH zTk9hm^;>zH{6}MCm@YPT#2_0tGRX!En{Ay2F0)3xlI^F?TkOl$yY0;;2khwv>CWeW zW2H>u`7)9Hsmtr)W@P&l{?n-Ym4@9f9ohe|>%~{ROO{o~=GUB9`L0vR>+yO2-1XEG zA?7ay+tP0Ib?N^F;orkd`1!IP_qL$NEgGMS9Iy>r;|lEZ)8Na>ACR2h2wQ;i8I{u@ z8t|NAg_SQrUX%KfU4UQkOl<$+{U`di0M#^8Jpt7dQ%ynHJMN-}*mLL+@{v>~Z@Cpd z&O!W!dLk;uL2=dcbE=lIVw$85xVg@{xV5#F3=XXj&Z>l_bjx~JiI%`+_5$j#;T^rG?3_3squBoF< z9y-9r3?FCxhRm||{gzt&?(3{-EQcY;+9o!SbYbw{UN(qfdBYYbZ5xF z7-oF*@d-<&AkN@~zFT#6HO!4}P;Z5DYn|L0uPpsa?WwUG*@iMaQd$@0nFRzh} zY**d0@74Rpx>x(qI+uUl+1XoS*K6|eUDgmCzwWD7Sxsbp$=nqmIo-Q}UkBg+XEFQy@?!ScWek>IaI9~T z1tbGhdhATA|HjqU=9`DCXO*{Yc#|J&LYI0rrC$e|G^D?c8amec44P?e`z*1#-PYL; z?Y7v5&34%f4G-8uwbNX@|5fEP?V|59iS4Bxzx-tQ{Y3-L`Zmq^{cgjCCf#54Im%;W zZyeA9rd1pSycrNhzqb~p`-MH**cqfAMhB^#zYTl5 z>KMy5H=ddXq5-O9{L9E}t3u6@Z;@HvYC+y0SiS@M-j&2SNCr5=`2&a#Brd?~RpR9d zC~i=G5aksqCQ|W*-#|wc)gpCtfcq*sAp9qK6&d$@J@$ze{JzkggOdp#G}sniLVR<`#mP8? zZ-3WT;&rj_FPm+vW{$CiWBb^Qf!O!(;g4zy?wkH-eM7i!hkxz2uOG3d*zy};f3NrY zwN@LuUiD`#GU@m~J$xG2KQR{jlJ9-_{M&%%3&%cw9q;z>FB;$mp9k32(&1nGGxWV+ z=(}GO5&yj+_^)qM`hx$#{cYrsan^g_OlwV%yIK^cE7@j?z1L)yJyY)hF)y;~tJY7Z zonJE3PNy&DiSR_l{3+g7aR8T*5i{*eAM_eK!pyV-Njvq>sxk7vwj}FR0u>#R*CmP``5d z_Eih)24aL|3p^W~pTf1Orr~kK1&RhJR#L;jv1L{8<=>go? z=l@y?|HuXEKYG=u0y+55ioxNMu+ZJNx*UTPId`usk z-mkTd>r~fMp*tHSeT zd;jU7Q>~n{>q+MSOH|L7jt>794JaWUU2;5hAQ;jCoG!q&uYuUPo9f^@OtF`N0I;EL;n8}`M;q}?biwZKM?$nw_XEh+i$&= zT8%F2tVF9V_SP@E?a8_a=-r)e*JJm;v<$s>zss^yzosAG=k()|T?`CJ7gnu)>A|WC zraU3l3TT5KEPwcTFrdCc^2e`72A3|bxPSE(%McIb8R;=aU*1ED=l{Wbw_n2N8s4aN z%}k=WP_i?!>6wQNpgv}z0jrP$=95c2aTT@+;*mQ;0~%1<@JC|$J|JJ{Y2p`D7g)W? zF974Gqw}klp|I~_gIP-cQO(nfu>&aPNwJZNc_<~_hy0@s#5)Yc4xsphxx^zWE-6d{ z6u&GPz}Wzz7k^Z1!oOm4)c<4j7V3|X2UU5#SAXo0(YbcrV0wV`r58buTvM-cVZK;r zfB5)63;Vzo=n40rFQ}JE@maa_Hl(h}nq1chU?MbIwSYwf)N?qX0g?lp?MAmc{>8R} z@bA%usO}Ii;l)46@cX03zkf11h+aA|1g|6T{!DoPA#!(iCKCs{aH=gOj&J6`_73}l ze=TD@YkXo|%DrZ7OFV`B`~my*gFBs{y&gWbI`I3N&t7cRzaK|zB1G@kBw=sO%o$PnA+i8>x4cP*}wr_shYOgoiZI9MINQ|7~dX(>%WoH%7LiW$J6F$pe2>W4_QDw%qrQ!v4w#-VNdzIl7rJSWf%AOgZE&<{{yrqScWGr@(=hjz(0s5z9Z=ic{5dB9ofU^OF zFa0Ri1N^JTz*z9F{z26j(nFZ~L{E+N>iVd+_*uQYK0wE!^9MBG7$5&<;vcvSKY{uZ zJv+|(Zp8)kIK-FOZ9R5H@){>|uNLeh?;5!+g=e>mXLk_VrT!zL-+5kaD;WPC_N6;$ zMF0362Mdox$Bx+{zn$H7j?4|#2pq<_+2p=wlD|vNZW=bfz2xw0A#Z2(jL|l4csHAf z%s&ji-wW(_BF3)OSJ?DF{FgN(rsik)@1OsN)xfsr{OvyOe**Uv@aGBp;{7q>-V+@? z5KSnHudgh=J&khyGI$>>e`Nd74*B{dYdF8cozR$n;R9rwuwVHp{C}@|{=cr}U$#N@ zzp`;{tJ*Yj17;5CVN*s7w^73!y5HAQhj0-+ z24+yte|)apH9Xg@9_aM}I}I5?^1f(*u>Tj?|BwmJ0RNZB2ROv5TP)q7)Es1hCDc3S zoO>cCssH#??$tuhdo46zE6;Bqv`e)C3dFzYgHIzop0DT4Q_ZXOgR*M(*x@@^yERAG@Ahy(Q!O+w{JzY;>Dy*01i@ z)}`!g4*S2pk01ZFYpnM3mlAIS?jJkHDnD{2{Qfix&3mo3N}V>@XU(_U z3-$Keebo-32g}Z#Nj;A&JLRh^_qiTezhlpx~xc&y<-{}D4KuZUZj--3=GO>@60V-fm zXn+m09W~07_n_Fvpx%-60LcKd1Lz*ONgqFdi|}6f-_0>rk_SDJ+C$xw)lUrkBE!Ey zJ@SXK0jLh}<;Vc%^`;jYxxjye1{@FeHU0+vPiNaj_yDgPy(D`?)TWXsYPq&ri08%)m3AC=#;w#A>aCdm83ey!(--x0>Z zwD2unUje*}-)rdoPtXH>UWGV3#r4&A?jJ7Jug=R?;upA@7@TXd6JF=V=Du|!{f?`SMC@TR{E~|Q z?NRkz8}iF{Hlbs6o7K0uEg12KEf_z{W=)u6h80D zRXJqW5a)7HsZ4ZTc>m{FU_X;uUm4`e%LkzP-|~YKM~nU|-lyJREyrb1<0qT?p&sww zxI4$i!tGCo|DyLh-VX*fH#AT3zG68->^tu}?tA)fFd{43ph@YCfXjIAy(5Wd$@aJx z8GIAE?LutbW62rpHcdSasPREO@B7p&c%~t8J$4O81MzXn=NHfb$^WVyBwy%x=m5%j zR(`~j)CqYTUtn=+l2#!GqA@asYyt8&j0aEi)?)`H{&_n(h;#ti0RAofgTvJa%+x!q z?*{UBm(!bI9=!>t=Gt@P@B@sX7YTNNOZ(;8x!C$n1Meq71H}793r@uca30&QfJWRt z%9zED{waKRhU4wkpSc)6z(1Wo zK-l~R`qA{0d#yRPgO=zKt-g8OS{HxP*%8~3r_;XF^W^QkU>(T!aRYrr`9SS?uc5r4 zA`RsZk^9qzW412&jJ3f((Uy3m4#XpNQT|Vz&uu7te?q64V859y9@*8FQy*a2^a(b9 z`fQspX^Hh2xyG9H-DDLyZMP4a?y)CoAFx|1rn*>|bBkv>z5h6{U+hDT3}`?U|EdQf zAGmyfRj3uzmfqmQ7G_hcFB_YGj`O|kOB437#W}g(NNEo8)t0RQbZyh~tqR;7#1AQxXr@e z9`L`8Sn8R?>#3(m+g0-6tH)rjsn?+F0C%GYT!)YEAISOVfOW|Mq5;DH$>3i$z`ys^ zKDpQd)wAS?y-Un?acUfj1~i5SbdrpOk6{Y70QDPB;oSFwf7t*WEsi_4y!g+H{aCsl zyo)*81DW$+5W|+x`_+?Wqf#EU)-I;Xa7J74{_yR6^dC++GtpAs*l8z1ytW zr}sImw*%ju$n*RCr?>16^8b2N|JZuf`rP`|{mS~)`<8s&5-x9d0QtKE$pan`8vWU( zyx^jYe&qW0Blov&{Sq$cP&r8b8hvL2e*M9Q`1!w+do;4SgWK4$aouh0^r5zX-UMPB zXWNqbOKkFtHP(0BMr$@;o0aRl+um)m-yW%n>`zRudUlK)47CNs z@=x#A%Eoo7W3u=6sQ!`5qiy=>y;kpy>+tJc;&N#!fb(*`U0?WD?7oxt(d`xIr<@+; z-2M8|UBm@E=5lnqR(jWAy&sqy#Ic99_{m1Lt_q#0#qa%WN%-F;VuM%B4zHF+bC%Qt z@oR!a>w*OJL1LHs+}np*A>EtW1dcO-+8;?Ab3%7U4{Ct)ZcdC}Yghkc@wgtgdfFh{ zIB$$?T{hLWk;j~}dbzD!zRsr2OR@eFw_CG8yRB^JefD;fgZ4lTYWL#TJHI&bKA&gO zF9ZC)$9Na)zk8Tk|55zk0RErCXQtfxI>f)o_BUZgHhj+G{j$3Y`@+5AE;Tp8BkvO| zXqH6L9X}KFt%5%FKJ$J}d&>W)8bXTCQG9}8_Ehh@)p&Ax2UE8MJ>b*!o?qjUdS1N0 z;{GonKR~sD#rs7AL<>X{E`m0w*7)t{0gpfrUv5T@Ff_3YHpTk*7ZrD`ehR8}uA0Z{ zvm_g!=-{7u3IDRgZQ>dhVFR479UWjJ{RfZ%YA>cQ(Hz4UVD{<+FTUrtp=MVS6LbMF zKc{2&I|&*f89>X^u@A~dAX~vrLyp*eqmD%6fRfOFTEuX+UE}!#r(+9T&AIPH2T0={ zN}q~-Yx5?!MBJySj6*#{rMyR zuxaG(3j0II+wE2BGkpB7Ag4dYLw6Kov^c51tJS6YUCSm9 zbz><@)dh3)z*G~gNW-i9r8*>vTXci`e&s7IpFW&AWMj~ECfc@@Gi*=teA}P04Ex7g z+mW)#)+KMVSxa`=z^Qx5`#)f1x*W1MeoeD`tKrwD?$z1w{u3nkzt4CZ?7zi$8~nfT z`NUOAS3SC)A^t_a|N7*>^dkOsDzWx!k^N=!lfEyTZy0}ijPaYiM;Gj;8IkC(FoZ|H zpLfsD@MrZn3+3LNzK~2f!tBx$Ay2t84sMrSC_iiM|S#iIzdj>Lq zqXF0fY6|1=T)$6dLdeXU7v%=DKn73_;^bAt1E2$_cG;o81{iy4g}&2O z3jdP9H**cku>nj+2Oxc$K0{s|uj-4)>77k~!l~rNfMkF91T;?k zBiH035DmDj{}JQ>Xn^E^@ki{VX-8aK;jhbct>^k&*Sl;f=f0KSl?@;(0sV4MjtA`M zeZuD$`)s4n%D?pd9wWz!?i;KpzFo5YWm2zGb#-?k_iv(B*UA~AY{BRrHmz?<8{4Ti zF+OF{kH4@k6<#M#|8eql@3gwu{FI+t5&m8t-d`4eUlw`4ocw&ygUZy2Hb$%QSpYGkw;uTPil3l zUZ-k1_EPMAr+o?Um(KqV<4v&t2H1a#@t)@c zKOg+dMy}XD#nCITrY}5n7Fbfuy=;Dx`J>M)^sEwY4sZ0<^P4Go_w;@x`U}Ax(JkRM z`5zUBxE&k7T5@$ScdL2~<4!&E(wRo_H4P|imNI1S7T`-+A7 zo8$nW2B<#LHONH&!glxsG12e%K0(O>KO}wHD{P7=w|}F7+-{&r<0h(g9*+0RM>z z{|WILJeNdEE-caJU|YfuLpLV0dW86X+5Pro^V^1>Zyj>~k_r9c!|iN5KCq$9%c6^a zfqwob_{HA;$=%q{ueTa6Tnfg|b9`Qy7ybk6R}%K0yTs}c>)(JFz^0$wN6v46{jXgu zfN}T{ruA!W^RO$bF6Zi5V{JY0MJX%hy7N%Y&^=p{UCrPFySBJm-iP+=pe`@9!8NE6 zeqh)3e8)jPiy6?j18!_}wMkW<)D6`P-@Bc9S6O{(a>2ZNcWZYStJq`3HJ)6C2U05;6T> zTh~f&V>^GwntgUJy!=LT_b#_8*!+GXXV+n0dO!Ftk59i6K78j>$DgNKJWa9d%ibR0 z`_0SSxX$%#TK_h#{^#&Rb zVd?sXWYA|IZe;U5;ZR&drq?sX^(J6FSY~G(#s|tnI>IC|4G6Z0;bd7FPOz}$Lci~n1|{|n^6mZYXeQ+VcJ z_~-l$In?0s>R&`3C_f#3#XM&>ariW5W90pR9RC~{^N`cO6_c-+9LWHSQgWzKMqTg4 z<^MMR z)ssWKRm{m0PrT+Xndic8B{+ zYKiAib0jyH8}`5Wk}pp2_zQ?l9Ww8THKSHfsjfNH#mc4!UKYKuvdDV{`;z^|`(Fk7 zuY&z}{F4LwB6UAXAtyH*P24Yi!xqvz* zE16q(Q@{A8=n7 zq5+ZvP9tXcLi~l&10(~e4!ZIKoRRvRs=umg7OP&Np2HrnBz+_eJj*sI8c-1a zd3@yKc{af9U{EpuCET4aVA2k+j_)7IX3bZi2P`n+0zDZ(xjxqqGP@WV;0$DdzaayN z_Y40TTK`-3Bg73Kae9E_0-hV6YhMr-R0H3lasyN&c-C%WkNCaK{H|z#Yys&A0YQGC zXuy9Q|M7FBfQKyppN8#!H~xO<+tR^hhZFDbRr@pRSmq^b@#TZ~>~66-U|)5$RI5XI zyNbb8O`fvgzXH6!%2O9QeP7sb`SqjLf!dng@fY;RXE?l7WoiI6bhUp~2RMR#WDd#t z>J1{Ef0!C0;`c|WHRkcT0#5$F@R8=%4tT~Q^u-~_cFjf~iN!zh{?7LIjxYNs!asaY zwXhuiiTza#EXDmR=3nu?1>#>Qjenl`y>~O|sAp4wcae}l7k`t6j-q~J%F*4l$IB0z z2mPA3Du-Ggo*qzp2z3I`Z`4~#$pJ_G#0nwmPM3_CBYE#luq6lkpZfId z#P7EbUc3NoPN441AZqh=B`&5FHTr(};5Ms^9nWDOyMDR*sJErO9pdTB+;u!Q|C8|X zpY8nns;SumzTc5tz@GRE1~vK7)%TV@F>`Q7TR5(->+_L}uCLwzd9j~{%=!NY_H~aW zs|ENUx*#w9&m->VgpbAleZ4;h|Md7K22M5q9sbdAOC1&dHG>%fp&NzK0(RFf-r}g1 zd?n#a(?~kE}_(AsPTZkPL9T%MT=f5<7r;5WYt5fp4fYP>q-%)kE(=evo<>PQf1_8EiGab@e#( z@}s>NY4Om!SMBbc0ecsJBN;$;z<>s95DmZI85Sx}_Sg>O?=^ELxPBaC z$?H`vzj(j$_v^imt^b(|P4Ruo!EyO|M+kyQ59{6XDJN%O;BmbE2FIjmo{C@%be;fQ?vG%dg-^abP-1+Mf z8X#K0{xKu&>-yMj@_0-9(erzs=$`9mWCKtPu4sVj1v*^-pQUtx6PsKsHktM)@jdDftlmJ5|BLs(!+4t^IsiTR3-tm{K?YE*WaUT6Zy+D3dZ4?S;e);2 zB6l-vX52+IGU8YNr!ZRJTRPeS!ef-4R$l{=Ho$mAlFi!YvgkbAAZ!m0_RUc)QU zDP~~{7zs|58zdRv2mFEZ1t=y+GJxy=7xeM!1pTc$xQ7P(g(3cb68Jv{{9iGUTq5EE zUr6$FfXWNeDWE4k)_Zn_sr-g|5Ui0L01e&EIjCNcdYkGz!{=S-dLg?WZ%bZ|?Qb0UZk=po+v?!=6MXeg5_f;ItJhNrnZGPGx`H}AuC^Yzy2AfY z)aq1zzhd-TfBS@s;~PxP&m?U8GY7S|Meuz26qBhFsQzEu=>?{~VEcBaIP9x$PzLpc zv#`U7_vh*Y+&#(ftp9u4Rex&>Hk^4I=wUzi2)6qiZ1~yO^0TOeN3B2cf8qaK+58x< zF}F3|OheZ7@lOq$Tfo0;+$BT!pAY`Gg8%dw{QG!!*cTo2N93Uv4gb1-dH)KYS$XcN zKYwHcP%NTiljNtAz9LybaSO^>lpZr?iB~gF`N8TPpjgN2E71R3_y^}s?icpoVrYpB zAbX%pkc!2w`1Kcpg z(DBg&paG}$l-}=Q{{)u8|5@?{4xmRcHh?F_NN!|xz_ zp!%GvCb8;-9OitYaUX>Mn`?1Tu#nwiriwpZ@;qCALl(C5gXH26}*EC6#Ne z-XSfIn%5nxUmnSKcQRJOGxV)kNz>#lH@d znQ_?m;}=g^wb1p#oJ`+6>Fk~9ztiNSyR6m=mm>F{M&0bcc-Z%A^OS@J`~d#Tk*}}3 zUF86(?oT&t{X^;BHJM&}3$Y~#`>GwF*rNTi@nPSWy+QrL4ySuPwpCkPy#-WjSb3-( z{sS5iTx@h&M%IFlim((MGz4xA6DW8{m@Gad;+$+8)#ifWJ zebFJy^|`!(`F*a#KYU;OUp{|f|8=ncmXH6>6XSoN@Q?j$3p^}}{{Z`1ax(ElzL3bn z4K9e?v^#HON1hFQ9Q;Lh4Sq4aH%E%@;Qx%<7W2=e0kQ`uhDotZ{fKE&Y?ES~R4e#q zbo%qd_!sZjJX=28mUxDun**!8vc(}oO%%k()sP5b0v_t6NDYg;+ zyXyEVK3;wC+M~m(hfejUFLe34s?Fo-XH#!WwYkLqOH;E)dA-W{Z$ylr@_~Dy_p3gD z;&@l!OH5fh+hPA8xdrLqUcJGyGSWPLFWjSl1h@}qciveVf5At2ySNV#ybJ5d^zt7m zu2gX)!nt(GJq+c#$i}VuyX%=-ig(dmQ?Ja{5&bp`{gz%IzJ9A-cuuD+0RK0Kh9@Ges3=ruXLXP0G&=A=YzjSgyrqxBZP~L%Jr`5M` z1~g~s<{ad+BRns!ZjtDK>X*o-BHw_M2@>23clB)7e!IcpT6pqIe2haji@_ikKj50heJ5IG;Q~Vg6*WFZ`d%`nlKuF2e?J^RQfd2$|uPiO>)1h-J_N zlxNTqTR`{q*cpg9P#)q+es3$Z^B^*a=#}?0in1*Ft{(Pz+8p2Ag3tq$&XGw>Oe(&= zo#^7p%X<&V*=hv2)9mVV`uUB<=RFmsFpZ9C2y830Y{Z}H^S9L#B_e;I@ z`qwLNviZ#;cYf`hBzkEtaeQCc&vHI}WPEZCWaD##lMHkE&?)+>`P>s-Q2a;nU)C-R z>94Y1DTY+BY|1HBKTOq{RF0|g%+wcC`6jY0Db8JWsa2CtwXo%fR&PA@#VSe8`s?_& z?(_Xy>U*a?m`=ZS_=oR@`2PvW|K}n9-&}F%vb_9X7Ze~;%QzVz zAm(oAU!1;@XZWAReUJ$MdA_Q{CHgws6TPy)kM83lbaLtE&B^2ao*0Lhhyl12eO~o} zBl>?B|Go|2SZn~UP8j(?$`??NLFEvtRMQAi^mtFUrUcUfF836e!PHrFlI}x{jo0Bew7S>9Y8UGZ%)D= zIL@mVe%)Z=0+ID)^AqkR14ti`OmLBt0f-+SL9KB1e-j&`zOOvPdP}`nL&Y6W!Y8-{ zxiSS_vmaU|I+frdx^3ZS=I!G@h5Ww~Ih1>jVE+gJ@P>CU#P_B5r^AaS`)^yn+*Z$> zV6&<1HM&D>{C1z?_j`ico!40v>heg(7yc#l3;VJGl<|4LYV*{4^%`sO*#q`FeLIyS zIGH><+59#vnr6GO@uyn8SE2#2z?|uU)UGF z-z(b-yj^kS@+T|Le9=bqzxBkmqT|cYltk@o@s~lw=ygZlY?GvVmGW(ovyN?Ad3@?` zr=GZqZx;`9{9p6?jS&8^{~h-!eShiwbz|jpdybre5?~{Wf7RIw%m2YOB%%TSwixWQ zzq<$F8|rV%bBDcShv!r9YcWgz>oK=fJLL1qVealmURnaZ9FA_@5*y^)tY~{C}bWvJ0MreefU12eKF5i{0><@kX6tBQF4( zBDR1g(3I}U^bJ4;n7>=SgOEEpN9io?IVN~!iJn*FQ6#>k$j3UItM7vYC&vxy`GUbL z_`Y=SgXH_~roLASe)#3s=Vy?IGonp3YWIIkj_#vQr>~4(U-*}u73*WBi ztGrs-s)T*Tu)5y2o5{~uPmDJ4Wl7|lk6ff4JnF@fgRPl(7J6SaXO7h$=GE&^-0lbD zwVk+%Ja?6FEepN8)djY_$jcF9rW2$lHzJA6ZBJ0x!U~ z7s3DA+>@~W@5cd%e#%Fvdg1Durthdvy5xXckpu3lb(p$=UXNwf57)C${qzRIqz}k9 z;Kd3`RudhUJ#sTTiS#Gj|#~2Qh*0r2NiN&q}hfo__3E`!`>U_z(0xc(JbfK9aW;=cgF@%#3uW^QR(@ ztB==q{Qm2g%(TVi;7kFxLt9j|Zk6B1_Wv-oJg>GY;9oYr(zjEe7a2gEC(RD9fV_-F1aPR5h}C9g97n4^iWA%uH3 z3;dZ7kM;QXh1`?8G$3>rITal)&mm$TM&zABHS)zj*cFy_kK_v}Cj3hV(2)Kw{C^<& zi4KJgMRfyZ3lt4d%!GO-sb}g<&`#A(f8=L!sL(4^Ls53Y($o!6Ekaj640_#fj&cTa zTpi(cJ3YIg;sXkj|Aqg+1|WW{em;}X1J&E3%eq`_cez$=QLcSAJC}MtW=|p4%O7wB zwm|g-JEbSF!QlU|q5+K4d4EA)FJ|BtXn=BwUP?lSn1(+9J)k))k0E{rgda`({)yhZ`d%^Er++7THIn<)!*4TjIBVujbiKMKVzVFIv@E%P z?-1{g@6X}?9QtPx<9{c;Gw(RoVP7`Cs^|r^U$_jr-_6$Yi-(-Qpg;9>RJUW!u&yqa zKt9C-^bN_NzCeC4J#OkII`KT)!dISm2Irly-v07gD`rK!UvXo~DWARWh>a(|bkJPt z*}#Hb|Mxunp9BBDkvaUF_&@mn75pn+M)mcDf6c$xSM0UscWrKafc-pxE&LdX z?qa@&i9Nx{J6MbDf^RNC_LlrFKb@2R#s7tWY#$N)zatu;`^1nvP%%M1{*S|kqMD)F zSNByuLG=!~h}z=nopNo3bSDeki!31j;7imIS1rY_J7>9GO7-X`&~5^G$=D!OJ3w|@ z*(Id|Xb$9e4+H#54&MxqkX>#zx}o}d^v4$15kAp)d9GDjAfH~Yv+F$orez0E@6gkH z{3}1`_&>ZDLFEfv2n~?G;Pzo=k0CR>g$?0*e2R)M>bBNw{1$8gd#DdU9HMHhNk?_} zK5}P0>}#_|KnKEC652K2)d2tUuV*0hE7r#K@l!9a4J&K|IX^3AjO+hh5N|uzl%73hG>Coel@7yDZO8HzuQo+Pkuku@JXUJ_&jp? z6-%g?BIW94Qcu{U!Ff+Jn7+X>{vO1BBYO3*bJypK2_~0!(hAA? z`19cTEk@_q&-nBz_Ra?PS@tpU=dY88qPmt3)=|%%bi3iFG-BDk9-ildW%adEU2Dm; zPWKo8*IY*4_jsI^;Q#$pVqCHRq5t0u?RWuNQWE{YDe}K;iVMKMa$54@Ke+1X_2xH! z|MT1V?Lm z6Y-m#`jyu^QTOj+Xzo9$GcMUnbmcyJ52%*-YuI8xZJ&j#re4a~u2+%#gQ_8_*d*mF z$i|oWqCD9^_g{9n?dSmV%gHvUzFwoD0e!Fqv|E#l{Vvx^&&{>>iS2m;JAitJO9xO~ zu=xLR;Q!dJ-ctF(q5&5{1FnGv+>N~O!Z>V!$N)7K5D&15m_T%Z`S<}g9VD+29YFqP zCj;nI{j(MBB{ly$G$0oNEr+-o`P>et9l+kd8{WUg@%}Aq7uz~)bW6#>oi((Jjm2)? zzd=cA{Jdt(KDjS~|DW!sX9qe!`Fl>Jp7*Kn{&Stpzu}uVTJuluvv$PayEucXioz1Mwc1!PsZRscwBCe@W0@LF!}=xDG`PA{6DItFWG-J zdf{wre529zdlFOHYAo0vlx-E5JKuE5viFcH<+pmc4z+2j!gI=}k;97rzC~k1@%2s6N1>jBg6X{RNaa68T1pmbN6%+n- zUxfVx_?Hf)`{>aD?k)FMwDUynpQ8ixP&^k~qUgZoCBc!lcttDgUet@QaK_Or#RTx$Fw z1I);^7n8s=Hh$><>J2ElU-&=fcY22~)JI4*fwQ0usv~q0vVvrQ4<=K07+XS1bbvv` z8qOjwU>!LKyV5;>w992=8cSRe$&y&;2KWBY$^ZfW#e2Jy!RnD;@v$@K284y(gjfpYCe%*CF0d@xCpP{X5|IQ*E$e z)CQj1yM-;JPe3wsWyhv9c8J^p`5L&CgfEACuK8&2A4R{1ea}LY5B~LR!N2T&%ZcNe zhHN;5dD@wJ(~|is!}m)dL%vTvYGMB&?9H-UUkR_du%vi>Dwt0N@2SkqROV(aH2~cGO5Wag=s2o%pqwYgKB^C*qmSbG0sf^gIr-oJ zO!PB!|3o{F{Wz8moPs={eqySpt{S6PkzaIMRdNmLW#S{ug6?Evuk~Vu)l*GzTk2^l z+pp|EiSAi|f5qXrya4u94=?rdQVq{Z*o#!tx8pi;dXNFs_e1_b#RN$Qkeq)iwm(-N zz{kI6fZm_NcGVEN8aijRclr8XUaK)97RST z189cFUq$F~BwTg)m)#HFyK-!lW4m|9Cfl)*UY_*uUOjJuEutRYbn5+#B4$^<{|=>J zCid@cSKC+ezr(+Be9;3cJ#>aufAT_PgDZ&-y3N(>?ns`mY=6UBRk103ej|=(pd~M! zVLMaSx%h%C><{_90^A47mgQtXv`G@xksSFY%0bbtA%CKAWj4||_<{yGEERl8-` zr){(BRrJZnh&@+7o2#)oT?F<|XTJUQQ!xLbC(BEpcRU_mCK?cyS0%Hm-;IWFFTSt7 z-{My{R7|(W>K!JxHN(~ZmTzG+J}b>n=_Rhdb=MXW^C3ISH zLu4Q`?}5LrgU?PUCb!Q_;-j(2f2;UEXsP<0%SWOZ-y-2(G(d7fFm&IcxyOE->d6Dh zU5XVt6&s=Q3NJ2&ABGsLyQ*hm7tC^Tnbik)c7WmdA_5&iy%c2wj<*9!_ZR+^7oa#? z<>TySzwOWf_3v20vzf!Z8H-P_Co+KgeW|{W`UX5X)~NgA@ptiguRegs|Al|)0@|j} zE<;aHJ&_m3;R~EjUce%9jn?1?+)Q5JZnO0VJUgIhKpJ3It*{L4Z>9#%AV2(v9!I{b z@%WeS@A`P7`){Py*6O)QF79vIfVSYjCiwr_@$)GD>D4RwAKvfq|L~d4?l0TGFVz43 z?W;$fzR&Wanf1*GUKSOUoGS2k#?S7HsgAVf-hy zj(Gbi{zEaUo6rT7H!^N%j+6bHA*WVBUi=oh{~cn?o+aM&Ui^Bh)B6wX&S%57PeKP3 z&zBxATYf~Y$Ce%Vt!1w|nK>z&mFDH?)U}cQTC%F_gx8c$H~9=C53Ba4YXAN|)$94W z0vnZTY7~b5*ej3R5buVCZ(y*V@J7ZTrJ(oG-_RkYm%8Dv%jYjV9pt`l!^S4LY9e;` zKk&oV<@&y&4w!oV-dK_N|3vtg9FU0r2e_B7-~;ZrhED?|Pe~q7&Y-IyM!eA3->Sw? z2L1tPcRlpCR$0hb*~A8Tz5vw@Q7>c300)o(U7iqF7ah7r+OUmG+;e6Kz015 zp$Etx&~}Yi+vBT{4j>z!e17pVz%k(e1Y`i!5dJ$d!Y#xEJT-=xz^P_c=aUz-lHA~p z)FML%P;atLhp1789w}(Jk{vaDSWM9{Lisf;C~_Y{v7_>h4Fuv z&;JAb`}{wIf7Sn2oPg{F9{#K0Lk#0T*~7n##QD9LQ<`Iui%Ya#z=u3Zz8tS*G6WcQQoU!L5O&+#X}M!wl&eu3^EgZ~iEcQnAqewYUMdQ~ym2++4qAYS0q zuQIsbsxv@5W-ZwQiO)m_kR4F@g5%HuWMc{PghT`6>sL&W=7nN|h5cYi4p5%3dUs0> zP!B)#@|%Yqpql<&lXI;BI>7gV4ls&X0Ca#$(DB9Rz4`#E6Xp%+0HO=Z9loCZ9z|z( z9~(e@pZebT`C{aMZ2$86i~kG%E#dzi!GACK|8V%f!~cXqmRv;sU-5Zo&sP`Fp@nvSJ7A?3lo-W=W!PgKtw#j7YzUa^Mj@JA@` zKz5(}UfxxNkm}!#c&Hb8dkWnl{7c^1XbW%vC!Ss^{waUDaHQPd6zTve|6g(VgYjo7 zKSFUiF8?2${90^u=a3)e^8X$Fp#gFDKL)(JdmQlo0Q2FcWB@$}w~uEh2zskY2DqHQ z>vvX@%|WsUy+qLK+E4U)38_}8VuMw$L^VTn?z#rq{2d+e@$d44xJO!MFhm2CpQm2F z%dpW*=2`W{7ofU6o(=#%AM5o5mJA?_%l9vtU$#Km00SGqdDsBt2T)D1H;5xFHOs6% zHh?Z`s6UpXzGPn9f$WaT3snE`ebApnoP+3*&PkeXXciQ*?ma*KkKjK&&FTLh{x{in z>HlH*e_$a0qyJO)L%Dx>@lTx}@Lvu5t7o73__UyBR|op{^m6)tRh!(mm9PH?^8a39 z@AKFH-I?ZlX?$?h6FoSXcMk&mC&Q=Y_gDUChY8tMhZxdQ*r{amm+XIE&7hB^Vo*If zUb20}zvpyQu#b&Oe(ICqX=fmV3i}s>{mYRr4mwEmrFX)=&QExWV4wJU)h%7SQ~Z@0U@KI!UvjtS zi+>MW>ce$2(S0(VjDEtW)JJ$<{67x=KK6xwM*|$rLvnxcJ|6!(3)K^p3~)L!z$M>j zVrwKWyCMGDcAg(Vy@dK=kDN$e@&aUlH9NeRz+KQ=(SVp7A+AyQ3jVaNnjx|W?0^BU zh1Si&#yJ!_V7t{(9pL38v-`pHjmY+{J^(nE&F`$p=XHVmV^C0)8QZc zKe_&@xwB&~0RLyh|EcXuPoE0Z`Km~rziLljKu?b==m7%$ zzXbpI3wnb8Vc7pBWB*&2RCN1a^i1O~k86p?evu}*?m_gWxho#4m|x*vIV$al^Q%Rj ziW1nV-X^a6Y2W@6@xAx|4Hce@<)r`JHJ#UPK~&K<*Oj`UxfTA>|cv5 z`qoMuhZ;7IGWVZvtYc?7zf>9G)N12rY^^zo?QsG&NZI}skEHy=>_Yf|M8nDFmbj2$ zb}G{CDRdr1@15=>EXdiUB{v_0FYTpAp=uUxpl8`~c>nBV;_s;i-hDc8c<45Y$$JZX z$^*4C?W(fi{~NzPg8HF3{QG)<^AZPU;}9F#RB|-eWyD$ghylVnMEGgGCw~^y$tt2b9F6gTvG=39+^ zhcSK=*SrwrHVN_ne;fb!|5R%u9{-B{ z3+(^Gf28gg_^<2afACK~AK_npe1-q2KK>ihhgUUz+k^iewZ6dr7vO(@t#kJOwRVX7 z!b16fbjtaUAis(3Z!d5ThY#lQt^ohDkhzEW_*bl7fPdxlgz+yOUU_|r?U(GT*i_9m zE&nFHlkI2m`2{61?9%VCLBscN!Pa#zcIe0PN5A+Bb+CU^9gHmd5j@nyCf$s@IK>*O zmfZ~cTdrg-ZG+w`CrR>kelM=r>3)s|B)ToaeP|STYjm)}cZi&db{GDI5n)CBz<0s> z6;~(Tzj$*tc{k+YEh6q_s&XadgUfO;G@;KdD}^f@+0p2M}s8V}a-{D9x1*Qt+47woLV@UyB``9kRJ8f3r}?)gr5tgb~e zK!AVmqIJd5L53>%`PdIJ#4X2$sJP(Xfgb?;enLFY3rWcJ*a2?F7w}IoE?b~te}sG4 z1GE%PP^^&lc?^2-_GJ8s(2v@SbFI}X;tSU2(v!&3F;p8sdVpjA=^v_D_CJUJ`7ZW1 zKm2?0e;EHK7Ks0$!hdh^fAGIZ{=c<_`u`%6ExeM5?}xXAo>BODdF$Z=#o-_RAK*X2 z|MAI-|KBVAU(wUw9sc3%!oOf$v;NjcdvLcN2>uzW*Y8|2?q( zHP|oPLvlv8HGvl?-a!6w@qX2?lkUG0UX+T@=f8`8-Jj?j&$}MIf7;8#zlW85viV8x zhZnBiA-S6T+tu0BGS0G&^fOhCZzxcni!@&QY{{T9IP2sQT7P0|I))38ky}5S|b%_V=Krg|;=zEitBaH876@E(f zGT#BdbSIr!G--G{;8+}wSt}TrB@OlyFZt!!G z|7HIRM41YyYmI_V4_S*y&%;j&nC|bnu`r!9{86pP<>3dgMY;Y`G5kx1R9iQuZ1<8+~eqqWp zly6~(F9Rs{Fc}?S2Re#u5dZV|A4L35dvgCh`QO?9Li&G&i2PqL|IZiy*dLDW#l2^O zD?TGU98HLOKGElu_!k2FtG-9r{;wXKWr)T7s13O!_~sw3OHW$G{*c!z|DEDXP6hw2 zcOBT556kg>?ER`~CtJVd{@W@;J8F_IfWPZ4Y<*u6hgyz$Rdv9A%Q2p>ap0UIHkN!N z$^GG87K%%dFHIOr^opH{{*`*S#@&h-8jx^jjvC&c=SRXH2J6wCw8i0{IVf3KJTn&m z;;qWPjpAR}dj)^$-8C|uUH%O0bH|18ufC`O{^d)F@c%##z!r$FKNyMwjOhN(59{OK z9mDG@BpaZ5joig=s&?qN)DIW_+d=0C66@*kkIWUuzj%UVuUP)CYY*;&x6DDgI)Duz z1si~J1INRo)!$QofGY5B$pESq{49F?z1RZf4-9O9!hL{$b2P;Nn+)#w)Z7bF8H=4drEY8yI=>J=A=|M*Fu_n}wO_!Lj2Jl>hzxZbqy4*ag!oO_ns_`SaTYldm`5$aK`QOLCVlQCv zp;!2Rcrila!K&4@4BMRifa>ihKfupRa;+>fz~|G=UdOK|-Cj1ntB?Vd`y;*oH2A-C z0qF#?0bGx-;J%Uc3QF>10QCx}x{!K9(39VjJs*SOjZ`C4HURYs`LE$$@xRXgPyBCw z_0s=As$T{g8n~+I6%b#IUgWATrrormNfW(Vfa^^pmGCb zk6#5Q<)<3~9q)!8pedNG4t8bB`w%_gh4H>WP%;4ezIq2r1`rJpEf7r*ZMc27*DL5X zWPs0R;$0_k7;(_`m-i{ryj~e-i(}ERVme{N9HyGv8I&)(_%ilgzDnd)50AU+#juDjlE-@oFW0&vf~J z;^V68sk+{ZD;KYq4d5iOAK+i{?5aD{-9={^o%*g z8w&gSZD()t_01seOxPEH4ac4YxX*u?@1_0+KZxG(0RNIzlt-=WP|mXSDaF;z<{HOf z+fa|5#v{n5!r%EOHqiUAhhB+&L^M=3Iv4*F@&Ebw$9^Bhesn#+zLs82UIUCWO@KFvxlr$*M)>~}1FSr&S?FHNdG?aOlm{+<@FC(Z4r7*z;9r*< zd4;Z@=Og^9XP5l)i+Prc*Bgcm@JDhkb$#L8i{asOyu2XQ`??1k-?c$JfRBICfU|uW z;L3rycH6LAdw5K)y)=REP2>CM5wZhxMy?ovjZwD7_57Z45)r|6}hgz~m^iwvD^PE)Kh}z``!N=;H2f zi^G!O5EtSh9*7emAs*uHK5=(X#^TAuZCuWOKW}w)SNHTx2A2K)@A{I?RjKOk>YA#z z>N)Sx=fvROmw}O+8@ytFyU5G^oxbq@4(G@TD~D40iu4v|127Njy4E4v7NXy%Pq%6s zT8_(gy*u9u<^PEvT{RK%pB%#+?{on90i+8s_a7U=|MB7Z-;)921My`*6#kX>BOO38 zKx^cKay0cf^tT@5-sx|vXNY31Iw#qCvcJR$sC+BMT;&fs#50iv{#BZ${0Vnc!lM_t ze#vL$;J931ez%(6hxvif0lFlKE13Cvv8MyPl0a?nSaJff0aOso0wQypI0|{ZE`b!lrWYyP^ZMpk7^h`QrXj-s+Q0 zv+WLSfNPk$R9hnbRoIsf;M@O~OaI6BCr-M@nq->hf8oE@;B0FPKfO3<)uUz&&z$B; z{e8)y=$D6dUk>=V_t%lD{H<3W{nvioyU5@4@vnM6`2gg%knd79fb{95*sH2#8bpkv z?ZiCC!J!;nar4|nKIEmuQj{wx9YDBtdH~pWykERO{=KZQ4&dRQ@3dcV?)SH414ssF z;WN$s($6m8Z`{b=(%*a^{-$sHd%Z%$2dJKb^7|;?!uegnlxmlx`xlLWjg~hCvj5ei zOZ-6F_?_y36bG2WwQHX4o9g8QNC!|(;9Fo>If1v3>!Z5g3qtr8M~I&bp#A}W9!#%* z5%dflN6)~70(u2tM<7EK3mk}CnaS~$2PC;uc>WLFO`W0-kl;G>wN@p&ehujf{=c5!~MWKLG?jdTFjt1A~+^bhhDD3@V2bKr=@`L5Oo%LT~>ALF5#i&$A8o}d#rmSnPQH2E3yx1J;KQh&P+=O8XX z<$B5X7Z;$;Ra`6n-0yUM?u$xO3(x<$QxW|8{6MP9QO{6u1WG@jg8rkqx;r|1^OeN% z7AhyOz{LVp?|)SY|7U=GB3aF^2lvce>BO`GWY;xhd)674*62l(?j*qr!$TxgWD63 z0kZq0|2y2v9?&A&K)SuZNXM5fkQ|T>pgIh31adEgecAoGN2)Vc?(geuA{;ufqj65p74iI<(3JQGvKZJkyzxKe%?bZMI`G0}=KMMa>gMavcL;n9tel>Y)G9SHh{Q28<7Lw!_}U9{&B_o zlxGHnVqV@A{W{$HqY!hb-~Daq@A&u+=>YNrC>EH(^~k5X^PstTbveGUDabqZPH0We zQ&l+CK4cu9CLi)nY^m!MPa&sLGC(!ck^#p{5Ag9X+{-uU$Mv-#@(5Bb~5xzxER{-S&<&HcLf()%6u!M%@t#r})NKk79@A^gb) zpje#nsJOgh0n4xhq^HXl*fk04E-x^}^OXzm&oN%CPxx0I!0}-J7;1vW4WPc^>KXJ$ z>;Ux*za1Sw`2gY;R6d}1#}#8xZ}MM-|2U6F0T&mQ|63gX>HX6`g#YFr+~sQgel7l0 z|L5ZW%L4hohmHS7KfwNF6t#}tyZCobe)G(__%%9!{6De-)*`p%7ZM-X5PSjB7o`Jy z1{a`g0O^y;DNzo9=6*N-gytdD1d8iNbpqF52WVf#4&*}=AHDd!#P6q^6XhK!Z%%b4 zx}M0(Lw->AFIw8gjzr`r|DCPS-wWYiHh|&)S`@R$=2~Ra?8ApHKfqe%syRIWz73$f z=P${@Q|+^AB5uQHKy-*S!N-6qNi6V-$-$X63_qsV-mQ6LivBi;$Qhcp8g+= z|1*gHpK2wU|KsBSSLgqG7vNuxzYbhh2{%3cRrag=S3%p==>f<96k8lCG1Nd)xx%9fa1=|6_$?s5ITT-&y~<2T9f-X2$?dA=TG{v^34uP z=hSn@#rSi+WRn*CD>?SHZxOqD-~0~$ht4O2f3JU}HJEnVu)hxT`UIZ*3%D2lyZMW! z3!I8CKsf=b3wU50J%jN9D3{=;bzTpm71*oFJCOYm{d~mS;$pszkAJ`ZKSuv|_5bnl zU$IF1KM4PaXa7g)|H=Og&i}s=|1nqjE8lv9;+|K=Pr9pbze*2O4v1uc`hh4fU;O~u z;lKX2e=dE#a-0u9+>)vT5T~cJL;Yt|Yh-}z0(~$4(_Nw5Gv&W24@NkcEd3MRP&H?& zIrr*Ky*bp)r#yhaEB)5$4YUv)Kz)6GPBg2v#GDWCQRed-hI@U1!}#wC{=2Yr^>{*0KnGB5 zpy~qd#$HhVP^E=lO|Wtdm1~ez68r~GIA*(K_!s{#@&6G1JCXkrJO9VW|1I!;6ovo) zp#C@J8h-s--8FB~+$xNP^;hm)7WeKTI^3?1{i=ClJiel?(>xnMT=Gxg1CS3;{I%ko zbv`KJ-q%s_QOTDr{y)VD6zh`TK))3St@x)#(Pv;DHpV)10AWPAGs^i7b59|YbY2l` zMb6tl|G4YJ?e^;jEan&d0RbblM4w+|SIvv+U8S60#RBDP8@r787k`T4s@0LD;#Yke zZtmxh0n!6*qgGP>0M$<^E+8FB%N2T zARkog2_FB{RN@TElTXH1EE(W)&o~}RCKcJ!(fEp-d-OdId>+^giMi-qiyxfBe9OTD z*bi@q|4`)TIt}Rn@^8q`Azzz(s^U55gg>PTIk;8f@70uZfvpA4&vVUlvA6Qz zRL;ld$|v6!y*YuNUGq7IHT2fn1fS*}&qtxT|37)T;RqZ+@0HJSh-XMTz*_tO)6$H( zKzRM|-+wj-f8HeG`y;(R!Ke0S&X@k*m8G*5u;0bg0puIJn7RPv3p_vB>l4~?mB%x% zh%uF~QNG9DdV`C^|3;7hE6D%z-hF$5M`~dM$=E-AYta(=P*Io|uwHN^W$pZ&^L^kG8pN{D~`Dd9jmjd-=>Yg3)$cyxe%$kGnj z0g?eV&;!a~7b(9&oM_@ldK7+8xixDvzuzriwSvg z54yu6O|T`v{Tt}}AN`zfU*kuw#@yMMxwHLbd~kE&)L&sXKh^NNfK7U9Nk8Ef|D#vD zVbu^x_U%TGlnpR@Cw)E9BgN^ZUZKw;i&W=x=^%Op_X*-3-23?N=5d6{2XGU0fyy2J zl51$T(p(R6@qvjCRP~3_VLcfTeJTE@@y=0u9+~)eSFWpfi-#|L7X8w^ARo5ou7$*K z<-Zn(ZM6`Gt@=#f$ebe`Kzx9%k9E)wh|MbdRXXgI#0sS^Jj=6P3jU9poKc=46v92VMS1#nVcT|G@ckX|XVMmmAonsLjYT!{09I9|zZY6+XA@&l^Jih6I2f#*yz;AixJme>LfhzV3f4wOYUiWf~B!IH76*MEjFdjk95QS7Hj z$l=qX?Z2M_DU!%q7_#lZhc$&Ko zAiuZZXIYBws(g{o_yFp{4Xk>e=imiV?+*2$S5E--_K*xv&6)fG%25~omEWp3fP4Ut zHirL;IDqP4TrD86K;@y1M+VGcZdA?Odh`J0qNxT=KFJ8if+tz@osIg&zeMk94=Vz^ z-xi7bqNG9S=)v>zwxvhu8dMh`xt4^lL;1F!=Urem>@C!2Mi$(#@F zhcCna#`iuK%&go_P6GD$F64ST^8LR9e{U338*mV8YK~T2pmG6ayH{AKT0pPH@2VjM zb{;W6^#)d7phEcX>EU0zAx|R9KAr7xgZE7}atqKc&|!AtXOOO;zZ!h$y~baQw*4h) zABd;yoKdJboY?XuXgHo#8{1o~1rLKcE|ktFysKVg@7dzYa(K4$rsmu;=TIEU*UD z(lr6^&6yk9PGPPG^S$Po4O(nAig|MKI^u7s^wGlqz6QK(VBS|B9re#Z_5Xcd8o2N) zGV+q!$_tc@u6kYN0g2C3oIa}Sy?>l?0K6EV?0n__IKN+)kQ_iCI9d272JkobQ$K;y zv&iF0N(2}Ey353tN$|!{(!^|N@#!(sm?wW zAKYqeSM`nW&oip$QuEVCov>BW0q(Ew<@qU>-IoFKSxXm?ED%qiY6Zm|sGhXq6IL#O z`e3TINHxK#H5$4QzC3D=lz*j zZnN^O8}a*YGs%OS1uq`+;V;<4{m82svc$vu#5HEqlg;MQKWph$5BDkf`nE7vs+aBo zfS<{j>i(AWWazT;-!o0uu?}Iu(@EVR- z{6}t6VWAirAi+*KUE+gQjBYm1=rHnuJHp>l4;$cX@cVW?IZV zKHlMib9zyDkN%vwg#!iuPUN`Wr#hze;HVm5^kMk}RC6tTK(atOf$AY-|A>o6u|oCc znF)W;v~_tVj=o9A#tEzFC$JL!gcV+2fe9=5oZf;{)@onHQ;Ca_YqJX6Cu+VY_Pzy- z?7#=NC*8B>l$$8KUl`F@7WWEXTX5U`rq~a>FXfvTH;`iWV~HbmO7wF0l*^|cq2l}& z@0a*M)epeg0KN>s9yk^J|B>&6f7K2C8~oQ@V&oGWwFSm;@lSDGvH_$6WN}^cI|%pw zqU-$6@&Dh6|NsB-|8w_(_fFe!UV64O7}H%muZqvjXPz1ZUqlaZD!u*N{;DT~*RxHe zM~L_Vh4ViUuTos_bozd(_owm%FDA!bF)j5G6fe*#a0>mqQAkK&OYC=iROl5S|M~nL8<8VKV_)y%A3yll+%dtws}tlQbag`L z0;&^|O`z*j?Um$#d>)E_h(CNi{aBOfAs`;{HQREj(aWJHN;bMt7PT3fwlF2b79^*$ z9I{0$OHvQniVf+uW>dN)Ga+tB%doAxGHu76OxwLL%l6^OIY6QHAxOP4GO{cylb+XE zS(cNX1^--@vw>v?=jG+Fi1(1(VHNff%=KO%jfaAdbX~+a0|BsF%cW4H^fv|0qOEh+i zmtUNMY}E zb^Ks(dVLIr#Sf+)-omypE{ zC9mIV8#ZpWEt|L5_O07&=l1Qkd&hR$%d&4L%dTy<-!0qiz-|`$T^}UpX2VP5Wz|Gh=yQnk3u8|F} z2OWSRDQc(W>9Yd*$}cNWK+nt z7>3=eoMZ9ls6JRd0CfN2zWi$led)hF+&cPL-rf$QJSF;9c&?BA%#$ne<%@TW$P*Vl zo9MsnzY~^}>*(r-2)tw<7!Hw$xu5xZ&w&ivx#xgw+p*g=ZrN@r>o-|qVv4OH$iLGC~g#L9)Y|FYOwpB}Fyk)6vOT=7x;5> zfL=@}aCLy{3!)z2=i&np2Y_?{&HX2^oF1D0h5v`~bG|!`*ycj@2J~tQR9m1L1K9wo zJCY6{J0PeB1o0m}&CtdXiGN*tSM!dgK=o;Jy(MQQg=hmE?#oU{b znPCSG9z2~{cCGE+w!!wJ z9~?p_$V@+ot&rjA0=^7DuMf)soqp6Say{ALU;TmBfPdBO|B~Wyf_inp1&#~sMtp!i z7m&CBP6Gd$`%h%;^aEdF)B{M!_D@1oymK2lxIF7$BOrQ)=lpMuW`zta%xfUZ;V-;ahT6#e~EVtV2T zln!u171f`6v43Gd+#e8GpnM?30~Iq=?C=q417!zD2T)ExcX(;VOQ|@3s}DxD%LXWP z4>^CJ61k4R_4u2SwU5fkCxw3)*~j0CF9+iNGWc0>ew%FDUiI&Q|zGmSJTRMH5EtoLGW{>P`Glq1tX#+daTegGiFE_p4k2a%!8=Eyi zOY6|m%4YU&#pf*jTERon#%2s`M_o%tn=_)DEgaw1mQ5ROYv)h0ROGI_FT%XIu4+Peg1MIl%sApGAkm7>+T=L;M_yO*OC;ZuF9zTHc0z1GtCSH1R=PLJD z^)t#tkgY5k;8aenfr`I`Wm(`xYtzTEzt$&Fi+`}t=l*_0ucLk)do=gH=sjWvH~iK= zCt)9nQ-GeWoRq9|OQ$F8KIY-HP06+aeSYnNskUUwa4_4;ru1!Z$V%G-#Ru=0s${_Kt*OpWAm2ci(*r_20bd3%zsJwd@UBVt7e|-4x~7BweyJW8 zn0kY~JxwtH@&L(WxQ_Y#(*B+eAfKP&{#tzeUkm>48%+)16tlAP3>iY6LkjuK!~o<2 zSWO#+k?ZGyjB zGJtt{&|Lh16LPTwyuO~wU%vy+8TJ1==Tr5!riT4p)v>cs6QI06$pG~bR_?H3f)9uI z0mMx!8$iA3RWG{+pOI_;4=oUw#IC=+m_OTyayC)_3 zcae96ju8Hn(E}Mgk>GapUfw(ZJit8{l+V1Mo15co?)3Bnwtw$V+lel|DQShRTg<#a zWrWQh*4-xcY(YQi+BWF>a@M!rr`D_Hht~bux2;>1H>_)=S6Nss+e>r(Lr z>s;Y^xa(OeuwC(G>r(Y~>rwMv>r=n94Q^iE$$_cp4hzQib~0f7iaEADb(QVm?;qHm zhJSD`G5~)-RVi>p(-;Nk*P{Z3`@{~kF&PfaX<51c%Jp#^q1K7jM_ z0XRNDurECzi2r-R|7!`<0Fm#|gxsd?$wp2A`As{iXTk@N0{*vR11J`#90$c7ef&q? zIdA0M8S)?P_(yy(xTt0Oc7f7iJkb zqv}bi_}D>oJJlxVU<)w)aNk1sM-CKn@`sTb#9v_BVIHC3D-ga6i1}UM7vXP;8MM$> z@qVSx;=K5~(n0g_tL1S~^0#M!uXN_x{rh&?t~B)i^=mD8`8-=bW30^?*~?*nc zYpUg)TkU(kYyRQg*7}o&tV5Y+ty}fCZ9tQ+&?V|%N3^v?6Z$#7z^2s;YzO{;z1uh1 z!96>i49E=2fE?tpY?UIeEe8MM0viba)f-S;pdZXAuxG*lKf(XshZWEh2tEK}{b%6= zI29d0vHvsKR{ZZ8=Kp_!|5p;sz5@RZmwPz?iUG{P2e=d;KoUNHEzA?UoF5RsaR~pO zd;{!niv&+fOKTQK4icS;yIE5tH@QEMQ+Ma@&enD7g&wj z-uK(*xH{Gws$}B>%%V0hfd4lo1E>!`2B>COaY6M9yM?;3z^ZS~ z{8W!4NIHP>R}LZH9Y>vX0T#)Cg5QMyAojJ*_4>=NKzj6;`R{_yT5#L{KD5@Klzxg* zlZ#NxhHyJG6YL)n_V-}(Z?kP1lWqN)rM7zB6k9NUAUc0L8`-W7*#Ck#{SAlv=I`BY zP2RZK8ocyZtM~j>R`=N}tox(lP@OO} zLec@08?1f-50ex0uK4Mx3;1yoJ;I0ssyCgh3EJi5$0%3E$pB=0HhP})fI|3p*BQiJ zobB+u@5=#!F|_#*F3PF1NO5rGO+jY_ksOAJGWWd=2Y91xWW>b%(kV| zM%k=k-LUtY*x(<^TaRk*TKmtRv}W(zX$@cgtJQt_N~`(Ug;wpM^Q`IvXIqu~&v3l7 zmG3*vD&2dkRs82ER^gtLy@lD;TWQL0)?x0_tWWa$vJ2}t&j9 znD^5*f&HYFmb`4Pt(-mH=8x@bQ~I>QUsuEWd{^2!m3`4#eR!`me)R^c`_$!D{o(Vi z>V5D~-VMg@Ji*G}ah#RA{a7n|`!QDbAIDgk+m5ksZav1n4lQN)uH4-xS>*@LLIzxd z9&m%T{P=$BTkcegJVn@&}8=%>T**a{9kN{}UJZb^8C&`9IF%R_LSP6I$p?_kp*EbO0CQ31LJ! zt8@X`0z1*mlav?4JUtj2pf!D^D)okQ4o<5F;heu78$kSD(g73$^!RKe^S^r3h&w=9zyuV{B*iUiue)6(8V1I%woG{R) z59$d2TtobQWu2d|-Di*4_w<*n_uQZ9LHh@*a_<>d@y-*i+&{oP7%y`Rc-L|>m}mLw zreo~O8(IGDt-s>4Z;%V+?>@z#a6(_qXW#6`A*i z{W4(RVII7H9a_HfasRht?2FJMS@1RTq1+uOSmpcAvYJm_Y7M0W{QEwR{{nyKBOBJT zqD|=1&}Iy1hYaX#E0F;yOJ{B12WVO1|Apdz`2QpEKYYc^PeJ@g?yW{W z@;cJ+??2&1ts^6Im@N(<9Y9>cThPnJX(2v~uK4Zh!)5b1d=)RXgnybifa`Gs|Ic~k z1Iyk&o;hE9_2RNT3GQ0)S6)^o!{r02U*J0(s0r$mZB5B5>Vgh1m|R2ExXnw+bA4(z zkb@vQKz~~@NOnn)xOHFgpRc;Q+D)0eEqw( zZL+N?t1Sgz|Ef6?ZIQ4)w2Mt7);FSUP3za_OY2(YO>6u4<8H35_rldywgr5}{L{doKXr(5;M{^beDa0YksGw#t`R~4Zn{!UhzH)|Dc!+{Z9CoFMa>+9kv6#Upf10=ab_z zd6@I}P3qOsMz*hG1DcjWFMpp{-E-FBqx-DU>wmY}&s<^OqPti8=c&x&$A@ivr}u+x z$pU`~>jawjwICCuD>(eqx2hWcgE}0q5jsGN5AL@1Up#@Vc^w&0+R1c#HAMu&fux%~cdUVo1(iSM1?AHP3%7xs_u zSzsry*84y7CqD@N;6e6%YdZPAvtdeE%;07v10G|DqGBCwUP6%Hxc8caNm` zKR5!1+b-s@4SyvpW#EI;T&#XUGuC<>HsZ0Wh7BOD?-yIZYYp$O;(J$hm~9avv47f9&T){XY1)exp493}XHJ@b_;c z&Yyz6Zv}Dwd82#d@B7i^_6=%Q!Fts}@2~KxwfXc>;`g^(y_c?azWXZlo0ea{EdIVS z^7F~|4=vIM!izrhRh( z)t6NBzqkSA!;a_xk&`{r@z3oklscp5vZOENYNrC?8pQiRxbzPqw>j2z(U}|3zJYyfFwIDl990&F^E!RL67pKSX^G`Tbjx zS6bqt8O;4d$;ItVtgopJ#pdr*|1;}Sle1m@m@=Lt@U1?BM1CN?>+qE1H=bVk)kBC!P$@T3q&5mQROIIK$NhzJLz! zVaF`&h-~;qvps$w?10fra~(gBI09t@h*L}XiN6K^K6d?e@F?-FKlXdQFY34PejgdN z$d+np6ieT|eG|2J%h3H3$iW{#9Zx&v{_kvH)3Vl++}@7mULsfT5$yikt^O<5x%{1~ zkD&YCdzzKI>jW!9obMa(tQeo>e;@zCzFR{0FBgmd+mEw~cb!E1;B;~a&b6A4Uu54A zQ;;3d?7cg!Epdh}m0m*!_{>Iks7=jK2V0gf%+?c|*hNi(YJ-Zyzv^(eGcPa4Kd)T< z&PnihEQQBsmf2I2;Qts!e;;iB3()=5@9Q{x{;n4Q_?He4#{cjFQ_mpv4_6PNpA)^F zLaO~q!MEV}KhQ0==s;0*AueioN}lWx~k&am5S zXTTGj>3Rm2?(EqCstfFx;OPMJ11J`-iQEI#CH-Fb7na?a1lAFx7XB`F-{AY;?~2=2 zuD@!0c5mBgo3Q;?8%vaYk=SPXR4=Xfd32K|0K=*)ciRPueFC~o&qHs|v+4uAenD@O6Z{l*fb#tRO0M6f=m6(|d13!F zWPq;&C=Pf-)eQE_q-S-Oz1cR4p2OKzot!}Ng#LmKFrIkeVr&5U0Hk|}KgUC7oYVBb z3SMVuZ{a<53G5yoBaMX{uh8$J_9@~sJ}%MA;h(x1#nU#fUPRr^IGZ=Jr%mqBj9R`b z)~9YM@_b)o9)8@KzkfG=zrR^sbpPs)|IsQxcoz8gY=6b_3*le-eVqAUI)GdBJ$XRN z8>oyApenXR^+(RPTEr9;Tk!n=KYjCTC@-+8P3_a#mP{Pz;(*EpI#dk)nfh%%_huva zNc`U;HhcWPRk7dS!H$0jf8GtlV)#G1gMYYx+=33ESil9+|3ml}Z|S@c~eOAbzmlg8x|D`|rcp_AwuC%_xNTi?dy5kc#;rmpWh7 z)$QJrY8zKAAkSuu%^lv&CXwemta(N2UF#$3jPCy1@SuKeh-`( z<@@{X_$LS89{B*yBo1(%^85aIFu>Kd53;dbhI069!l^Il(^u zRkIZD1te_`zW7w`k@|lO+34l|S6onFZ{qvE58t29|1Iu6H}?zsT|*19Kstf&uU?^# zPb@I?4r~Da#UnBeK9RM|J89^Ehxj|felVv)I=_#7z6#&7;JF@2_P;*>KE~ae2S1z! ztL=CXc5uLAwl%Jb0jQ6p*WX5cZVPPGk^*`KuBti#tG`q;;O>^i((@HesB;X})!^sGw#sY0)~A4c|4?Wt<(9scpDt(rdC<__y> z6T39Fq2&4hQuBTLx$Fz}W9f&;_5X)8#P(NhPxZ$xuqq+^mqY&#`~H~Q4`=@un*Z;T z{_o*mdBnA_EmTL;7(ZhW|HFO!PmJJSc?-gS>}6_9)A?@){@Fz0G_&vzssER9f6C4) zAdgRVzaH<$`F%s&zruc(zyki&7eu}R;a@#N#T(dYd4cs!rJjiSCYk%M{9@_-E|1fl ziO$A*ujey}{n+z767lbbhm(%7mlU$uE#u*KHm0mj~_t2>n%t) z|Al`)$KS_(#Q%3JHh`8$48Yg@6&t7^{omoA7$CI()gQUQYLg3C@5PY*Pd=bx0Kcd% zs7+!0U--}F8Z>_XOP4K;U_S=HR=9sx5vQ3-yrxfTK6U%_|CRs$osi+AaOtZ zekaK8@5<6Sv~*#OJn-@VH)4d!6;|)irW`|g#B+AwcP93<4@E6Oe z@9sjrZ#(Mro4K@6i8iKK=)*iv3@SdAt0b z$GbWo#r?vuKl%He?SB~jlQ&T5?i2C<jbz3-uV2t{)fwflt{i z^g3ej9~_?W!_IINwH@4F+eN$_{<+}?iSft=kPeVYp2yOS^k>7@I|g5EAMSS>?0_2h z0u>K@4t-uWz*Xb}ollHWh0G4E-ie;_hIoS_~+P?r6@YPLO04$<3fbF5My@eb23 z3>!c_^kf61p#zAUM1BC}B*yAB;YTBM4Z%x`x_rLS^+aLcKVIPT@DA>Y4<1e1asEi) zWB+S^?c;wB_Wx$=|1~qm*aG83mVXCw#-Tm*msK16_W+SMz@{ zet&U%I)7j15cYliBNP1o-^vkwVR8YrKOTS3FnkG%ko{YbFB!SINwN2#m_g_K{MS+Z zGZGA6p(S=W{Q-+v3oqH+cyV;^L+I~72iOQ7o9uu^>&f|CP2WfCfX-9%tpUC3loRwC zy8M0E0LlgMd;s(dqz>5i4_9pvGC;M0ruJs_k zpuTxa&;e4>1C@iQUK;AFklyp*SJ$Vr4QSdJ>>eBfz3jcbnf(ze!k6Czw(eRJt zuR{amzxCHK-L#e)*)e{_r0A z{>>Y${tH)OqhDm-f>+h{yZN8F-p&7t?+5Vh>Hfv&|HA)0C*k`$1Ks~T>WP#Ccolpi z^8MZI>H)h}eZvMc{=&v~tV<5?PqqS{P;mwC+?)*l;RfXzr0;7H_7AY^VEor}Kh*bQ zI)2*0@WixTlTYqmzT^FRWGp_tAq93Jw!e6O!uapZwwA8w1lR;;krSl;KzEEPuvhRI zl%LOB!ZS5;6Mc?|DQu@#LZTloOluE?+=C6ZZ|ffuA2|z;Xf<`!h;z+QfQ!pA8EUYS3dr~%!7Mv z9~^t)=}&}VE5p?cbBXKlq0-t`>0au+GFN zMiQ$a|8HxG9Yl5s|4j8^?C;`v*i1}lE%!omzjXiJsrlBNxKM@p`EdBc`#px7{=ww- zf$uQ>yM(Z>HMYO%e=k5j{Du4=aRlji;tgxRw!p>^OI%LQNgDW-`pV=F!6)!~n;d!#W z&XIa(Ci0wZW=`0JPxv5nkNgr2|3#d;KGt=G7d8rAw=V+AfotbG4nLyP==%>8HM7rb}A&n_1KoLJ-zhqhUTUPmnc{b4Bv`_hH|#oyf@H-D{XTp06Z zfaYCK29TG*e4I#(R=q6M(^kCx%Hvbt-&b0z7dSn^u>n;3cQ!tM`Tb=39|KSLvCRJ` zz#DvesVuty8{p~+9;fg#-+MYhmA>>KB*(bhG`I&BojK@m z_}6v#*XHB3#B29kAOC^l#2!qOoDXmTHo&w#Ep6;i z=m5;UztkcQSnhdp^6$5%*Z|`AQ_Wr_;`U|n?+g2Wy}xSxG{*<=FI^xO|I!)4_1xLj+Pakd{?Cy6FJ90u$o;JeA5be>GQN)`&7Wx7=qI!miNY#S&~sZpIOXe_eK=q2hzk z10S0^A)l&S_z|}@{L{$jc|VoUO*oUiEjf^dKUOh0VNf}`;_p+=Za=s@l*3o4Z??VD zKFj6!Dj!%nz#m*LkY@)RkL`aPi|~Ky$63@2tCv7FJ;So?LHvNPwt{Dze4^_8bFDct zpbNZ%vIQoiUrGk3ueNLf@dN054)+QVNn|MeAt`#zLBqc*!FEySke>(Fcg6ub2*>{} z{ORJ`U55>@bkab_!8y4{Q&)R8py6la0l#7GK7R}@-+#FLyz21&sCLicAI!VDUTS+) z`!5}!DEzxRK;(nNKe59~_nur(j@>r?OFHne4Vo6uF9 zpdD;Q!XQgsJk@rjEVunTHp2n7pI&7-c93!24fdVhj}5p0Kk#Jyz_R-__cvXUZ_4`- z=eK-+w+@HzcR&RH!o2!@oy__S>i+)F*W&{fFQ{yVR}%{CYkdDrmXrIFT;O^Mt-l<2&n!Zxx^*`5Kyx(wtq61tWstHoRz?1R)pZF1dL*NY;{?En- za1k70*HrX$fG3({+nex~BZ+ zOThuS6d&;P9Y(${^}b-W4fa``B?a~+y8r9=@E*eVcjGW~eE;V%|NH$uWCxrH_Alt8 zUI3nd@NVY*r;r!#O)IcU`2JcD`|F?T^%7Zk6#wxasS zFeKcDuPeKU-~HzuS9(Jh1TH0f!Tz<$k=EA;PL)*l~ym&&hO8*%}Zn^)_p%ZS^Z1&7B85&b_N{;3U={$B|H zvj6Wo9%%LW4+*YqYR|kA`jfnYIV($NFMu9y)$>aN1Kfgnrz1@juA`qh=4`Irx`qwvqqd^~vA*Fl5Fq^}CVOV7%qhjkvc zLAm(qbFKf}9BVaBy*{()`I$xUPp=2)E!Y9-2Plpp$pB&gG;IGuaJz*WQo zZ>pBFn5X!ZlKpX%WDi`mNgCx@T8{2%1<{ADmT|HS^~;}gfHuzzmv0=pQS|LQ>n zc8m1>(a4L5#0#h8TUm7fhD(Vfuk&Jm^U&|a`6Ip`ag2Dc;GOZdkK*4So7i>qSd|3- zk_GO0=BHsS_=j8B#4!CpY4E<|}9hva^;N^t2!@t$pPm-y?<2QY^?h1`_!st-D+e5`ixYy z$-SD=Z>*y&nbaS@#5miua*pj-ztZ{sl>2)iJ>7QGXj8p6S394dk?Z5}{Ipy}t^eWz zD+6}l$L`nMe;>B|-_iY*_p8{RdVL80k^{2&uSD{x_%O+h^2~R9ooj z{<8h$`(K2QcRhZFy+`pM`$(1Wz5hgd>sb72t`E-r{Q?{$bo-_1|diAtD#Y z;C@ITHemxS#RizTGM9WnPY0+mFx$T9#vBgUPecZIbG+vIbC~l_XYQ8{AQ^C0$PT!S zd;sMI-$reKIL6g)@B?xRD!@6|nEC+u1I0rmJ76VoV&w>{PDnY*k^{EQwuIKh{;%eL1ueJ@c0~Suf4j5*$hf@<32dIh+kT0;pohRYb7ytk9&bKfBemwlEUO;(6mB{;5 zEm6G}uX6puI(+>kwSMp5Zzzk;5B_iT{zc<^+v-`PsQI66+nD=zZ%Kmtb1Qv`$y-Rz zBnF@7dhacU<6{zYfBy~ebFDS2x74iMJWua`c{2Px=<>IP=KhPN`xE;U_O+bL_QmM^ z*P!>`7SQ`Y#%8F%F&Z%UcR=@7|F9|B;F`d{xCwnjy?!I}zedy_X>aX4*S=QkYp~kS zdmXv@zpg(rRzCi<4vvSX)@?*m)Ad>D9KLDIO zZKw%s`sOXv>RpWJb@WCI#~Lfo;2i}x)W+Y4XhNb3Ek(gSD_oFDW8B<`oar2FCi+)a>m z3;p24?>C#g&k@Z1U6MSG&x#A+`I)JneQ@`TGgt59`uxi8&)hF=Z`J*3t@oFs6KL+g z7rkFGLizl@L3fZ2(GtI358{5K$S0YFUAG!LL%bs5`@_u?c=@C3t%J|C|6~VtKC<%v zf)^g%j*M4utb#J2&^A92{(~|ge7Jy2kqju{`OZKotrr~H8U=vU|3kcG_C)4(e6 zK=u4qPQY8pfG4r(@2ErXQ22tS1Du8LU-&)Hvh1-& z)Cf{rD1Ts0@&jAahomPwWFyHpResQ-RB8o?8E-;wR4-Ba1(mn>pS&U?=S#cdWkBry zdhhU03@|4L9RNRICbTrA4`8Pd)UT}6m z(|_G;^*pr= zi9Kq)i1!btH+VviCbnQ?SL%_6TMF^MAodUJNwWj+e(pV#ZfP`TPvzb&VeVJFukU)e zdDoa#Uu?wd;rLB3hy6Rq-xu~T3t|6sFt7Z+lUaoS3-AT}8K1zd%>9p&>+=S2g3s_7 zNbmoCxtH^!xnFVrg~*vCY>n;UUwnVj_}3kc9_iq7?R}?xyw{OE|A$9`jo>JG+l6q? zKJGJy*T=sv1N)rKe`f|*9S3H~3;Y}# z{&o6)Jp`6-B;WUPbbxcQ`KAA#0QQgnfHgS*stuA1P~X5S%VydQRa759|4_vQ@CSZH zjX*7GjpPsPuKb`-ZovFx<|%AP#>?zjcet+x55sR2zsr zfNrljzMOgk`1l9=Wyu#T59eQHa{g_JSY%MX~Ibd=mbHDig8Z0+r^>Fo1^{}rxUw_`0 zp07Dyb-gDL?^AuB@P8rpfVe=!3GnPBuQzB#;(ZN?9kfU9?~~%?{LaF@Sb?lrkMB=> zzv35@Em!yz8fo3&*d?&@QTzu6BaRFRN`zv7BsE`a7;kF#$r zdAbYX?@~_CaCrTD%p@;pT(0Z;`ES2IkQm=zDrJ)wZyoQDpfY?u703@P2fvpvUxqwE$pQ8JsmSM5 z#rX}_S3_!wTYr3?b*ucU4f+0So7}AtHAvlU-JEeQ&vz$zN$U5dTAzb(l^>u_=^h*@ z+tPF3-o&p%(yzF^`(gLDT5VQkkG30lO5!fAp}152`+4Ik{e4p6c^<9#9WI_4Gz(+^GJ(c{W15gV6(; z&^M$ky+7ZiZ-{h&zY*uV6gyw}KeGRo7pS~o$$%5l15P7H;G9qCDex7XqVS5}R4vQy z!+-D`Tmeo7pa&>NP`Lp=Q+ps8Fqu3w*#fE;l21_nz?erOeD1|>6g5sIlL4Mw@bxKV z0D6F1@R#J1NGVRuY{>w8?0ew`-VQgodT*_mI^6LCP44l%ji5fTe}hlx2l%G_^!3x& z0{7y>zX^WOtBB`cugX^!(vsc3Nccbh1)3f^9 zHlkG}n@+yLGUAS@aD1h$Uqeh{o749XWn|g`94C7f@5AT6p196Rl743si}UCH`po_6 z&;3e*xqf|rWlk6N<=>aCFU))Vo}LU4zt`D)y&6B&_&$Nmcn{ej-$BzA_!5XA4o0p_ z+UjwAuL{lmX|nyfcbrDi_>bHo@22?g12^_4{sV*IHvK`0tYf7>EcX5P`bE@>4|uG} z!9@qi@nnGby2aVImFGtMeM_kgQGZYM`ffit7dwEyA?N_lz!7kJZF&b1>yr+k9DtL^ z2^97v10)OdUNyqv3Ah-pkiS&Ov|Eq?4-iXqG622+_rdQI6U=VG*M9}`x^(`NHRpF{A-~@(Y#+;K zryv*9_vdQl#9d>k@lWu21Bm0hEywGJKVckp#jG9l`2qXM*!`OOcQg0P_D2+X`CWw` zK1NcX#asMA*4M~e`Cqd$)9JswkDi7*Y!Cj;H2j07n4zf+xcpa60|O z&ijn~L1e%+6j<2e?G&>`uQnp3zP z!6geFQ?MEPzztcH^l7TgQxp>a(C~y#&6!_ID)Iu&!ZC0W_j=~+)DBP_T$`o(zm)@`5oyzTV^S-d(HG=;W_*}aG#mI;or2CKe z`unLjp!)lEO(LGK$?F@qn7<{xUok&%{qN&GrR%;3|Nd==9YX)}*mYpPqxcUDM$~5X zpgH`zXFh`WSp4g~|0{PWy=OthI@}05;%SpVAf3ET$$-uH1Xtq^n2`ua1Uf+5NxAg= z@pJ(31l>*l(Ce}7FUQw^K0bfHK0w%4t)TeAP72BZ@&~U)X9(v8I2k~m0eV1gnsx{nGnSWS)0(f7cNHnfs69 zGxhrrx2O7l{9}~a(~}H-f3s?fy%>V#e)SAmu*>To=J)qi&JS)!r~6B93*U;^A@qkf zb{*L7DEyBb1%?CB2Rhi%nt9|BW5!C4CZeAPQ>EWG~R ziP<#{`2o~3K=Hu4>JsNe23&!k?}CsF5axw_afTenaw78JbhtyM2VCaM0Q3Oa0?!8W zgsZ7uh~synUZ_9*fH6zuhs>w9Ft!M_YLW#>_$H(mIC-$oix+zOfhPl`2Sm^Pu>`N4__DlaH$89Kmp;&LPC#n*#=9Ib5}dg+j6Wt|QnU!Zi;2I>(+PCyknJ$)Zw zd1OEZWI+}2{G%JFk3ge0Zn731KVTi{G0?m5H#V|!Q=2-Xk1d?T-&mJuN!#{X61f{| z57EPiWghx>0{Q*J!DTP-*>)ZM{8kiLMRI)Ao8$S(t^b7R&1e0 z7@d3|_PKO`+C#A2@!`KoJnsqctG*#OxV#{72FmXD^#2H7h&aOv%K+H|@(HS5=uLRX zO5g;0QXnbF=NF?rzhndF0|wARait<_zmZP5{0@bb!9~OF3J!6~27+@2Ull zuk2Cj`h}f~kl#oD4(a}~ z`$LOlfnoq>OYbKaKrz4Dnfsrd==uHB+qd~DgD=RN`=$3Mp>J%%-{7zh?&b4S&Y$Lf z*_K7#3T~1A%m&xN+jbQH!O>tl0^ma1!7+@I0sM^H5MG4u@IQX!@E>zveXqV<2fMN8>Z!vhqXHi28mx#DZrV*P| zj}g@nsNeWTFyQ+M#4#uv;D6@D-^t6eob-K`zGs{5A@_D`;$mU})5r-z2OvLhdjB@m z>C~ZD;Fs35>g#ZJKIDA*@&U@uSATF}zoPtp_yg7FPaNO%sUdDkPmxyi7W%2mJJz#l zMH|!wpTe*KmM~+c%~+9Sv)1pm>EvuoOfwsXzTayzbAAdj{?+v1#z*%l^YvTk{Ldzk z+Yk0{8c{$V5B2-~3W)2|%eyCiJbP&I;{B(BedYZs|5v3|}FqDB!t$2WXhb#kY$_W^PZT|DrTx&WSjMDe(vycpU0vRCQ;A`m} zauL41GvN%AAHZ=224sM<1&9mC7Py4{{!DD}1~>+kEBrux@`O0fYvc-k&=J2t_iV>W zSQmXj`N%&Z2h?M17`aIk(FbNFdO3^Y6_O0_c}B%?@;@U2xEaDf93km@(})G8+V<2H z^xvIhYpKOuf(|en9bjVjM$`tBb9_CD12lc>X8HtNL4BY&!cKG8uO!_cf1rANDNjiC zL*LU=tkoA!S%+`mw{A@3eiy0ls_ccEJ9h!2X5g@t@7yf9fwD{v`uWW;tCte+c_m4>r4bgxURL z&7Pa&(9d6C=kV(& z{=-8NJA{Sa7s5Y3jm7@2!M|j8R>%g}f(@X&An5?o!~4(6wV&wq)npVo{R6U{9`O2) z;tayK2Va2t1IZU~8aBV<3=?mtWB_aV1AL#LVuQ*Nx*VNB^~Hau?}+*ks~720)Em4C z51I4<$$=`=BsUnI<9ZEunS(H7n0bhmF1F&1<57a#LUwz5`S8lNI zPko>`w{~n?W1H44w4}vTYz6tb^G0{K1o%0Ix2Xb0$OqQpt0$=qx{dl^IKmz}&-M0n zb3Zvh-(vgM#P-*Bjb8t|HN*GU>dR-XeYFp*bJMEU>*vuLZV#E%5m@*nbD?Ka)TYFXsGPMq%#{L+2ml_3A#GI=<7u{>i<(6{ zoGUw^FMMGg66pDZ45-o{+r3M+y$oI-Y$y%@^8fJ%iYwq$WPoh{W6}R*1IQ<+_lgZl zJ}6iCeD+hG&=uqhi)-XYet$=uOn0oOkpto)`50c(^7I?8gYQ8+Wu1{f{pZkgd`T`j z1Na572h?*Y8Czuwu>#c*D4#(wLswJCwa6xQ3){N3k}m%5Je}5i)+JkV`64?j3!WYF z?)L4nJv-nD+>~USla>-go@uLQkD*^UIf4CK+o<-{9bZ>Rc>9{YOAO$7`h?QYPrZFL z_gBUasD?kV7CfKzpZ_y9!cEqUI^tGeKX2`-Q%}(JTkG1XjrANj$bOkH(|(zgXx*3Y zwvMZ^olRaFzuY$=>_3gZeh+%PoAZYh*hSd;=St_-ypP_mzMaCo*ZxI|uh6Mu<*_98J>|Jl?!`1`#=@Cp>asgJ(wKezw7h?m=7}U;o^$#9 z^}x9L`BlN@SAWkc;J-Tlz&cM{Xbs2_Z2GTTtog?eSgUVdv>&T~Y#o|bw@w{@w4eJA zvd&{?S*IC^)^6c$YrZtwzQfN~9*lm-JpBUm^}Q3oKDPT6VE+%``YiD6HNeEh5ST5>k*TP6M)i~mvWy!;>4arpT^!oO-i{#U#r!x(pn{~P$1 z4Xykd&7+FDZ-g&&O`twtBy(@?*}2wk623Zg^YXntyZ!m*@PA;--z?4$Yyt5GX^ubB zaff-hkCg@T7oH|L0N3yZ^c}hsn?OB?BuDN<7m#i7QcKSs_zHVKvBHM@%~t3FiWf=_ zh+|N_$JBpt#yWh2*ejA(@)NEihN1YO>=@M;tlRGKAFD62>W`DDTNNjfzbNNGi+mp& ziK%VcgTBjizYU+)c9t~eKk+B-AmE+0`;evW+-uvnZMUtP*4w7!)s{-XptTDo^L$bl zH1sFpfOV_f5GvDt4~M}|5bTDYk>c{Ph4aTL(gZ6QV)Xvm#j_oQubq$8rHT$ zTWi~Au(cUA%UVuJw5D@+Tiu1(RuR8^DX{glhZ5<@qeXm(Kq=*sscQ8-ac4{ryr6ZVzwnPvqJa?~~6j zdhQoe;yP5}} zI;M1F<=1FFRQ}C+{A|LJs}b_`fPf6BfDBMS;Fnu@{(!si3CgZl4S?o$`Tfqo|97&> z74+-^*#=>|K>7gs!nyPvxriRbSMob?kltP^(;mXMc)nSdt0OFpJs@2`azOFIAFxAy zr2nvFK@aK``l6c*LLLk!b~u_mg7N4FlaLDu*b3@Pq8vl<56n)aJ}${y<|gOEgM^=h zMR7)Pqbv7eF`RbG$aP!E^S^o}N!Y+CX?d0kvF7^ihit>vy|!_4nr+;$!8RnX zu@v%i*E)WXAWsrNRr=68?VSzXrZOfBvt^^H~F)@9&E(l|Ptj7WL?sU)IuZ}+P{R;g3V83rFz2dlb z#rszx12+=ym(L$+5%-)$Kf7@r&X7#-F<;y|c&y)sf8;Cpck(rif9$Ie{%ZyB|0DSC z?C@VY3je9(|L(+&JKX#qd!@m1FKRpXH?i*v{VsNgBHsU2{QJ_Ou-xWM_*k^$liSwx+nV)CQ01^R%u4%h;Xu?55*_*qwYL)vBAbKvzs{CV>4ixWh? zKE?kO2Ru>r0`wDbzQK@xP;x-Jfv*ev5$^HJu_I&;+=@MLFZlvbG(i_=;nfrWn>fMe z{H?OR@I%nQs3vhq$%Doty_iAk@j2F>oP|#K3%cSr?1`Sxd$#z_^IWfT#T17U(-;OP zk$8zDFO+XE5aLe--)ucs^@}@ZUIq{}$lC zIr#sfQ4MSIV_U1=W3bg8Jj<$%O|r5i`+PE;aR9T=VDme@e-wE;#O}@^hIcYKe8T;) ziu19SEI3{Eei7Ig|MxfO5z6mxK^&n=qQU7C$?=!oFJ3>*{o?kJ-%pQY^s9&mls!xG zFLOhE*)#Uw-hq@Kto-^+bO2ulY)9{vEwGN*yXpkx6CAfZ*9PE| z?@Vm4IkCDra0Zm4R^a2G=?g?WPnf+6-@Z6OE`ujfzW-Cu0gj_y=$Lm{-j2iwd>J6$ z;aT_v%tfNY7skz;T>vP5}9k2dmZieE$)=x<2gC|;ny`6)6%yd~xE8CLF_?YNBO zJ5&szDY}Ak7+Rw*w1tyU@}UF11jQFSQjgpTj|8s@^uJvs2VenrI z{MTdtZvy_+OHA{BbMXJex23IdgBn(^bz7_1b+A?HKg+%znP{aZ?6$WP@VO)qr=Li? zek`>*Bk0LHNOM0i{a?uM3E^LJ|7l?VTy%m<(D|HPPj^FKF<++c_O0;|LIsZO{@ zGC2k4Jd@FP=Hc&KxzEGCpX(#PpJM%bYNB7U&r#v`k>}9+eQ=N9v3^hdtA}zm>?_Ux z4bcCC`0qy#C*gkv`u~55|H9)DxuW287PsvNwYVMPd>8u>wXZ9DfCA< z00$NRr2{Ch&aV$rzO7#?EPwm*&0Y_INvkxElB>fUD@-+E4wioY1+~87{tyRQ=;fEc0d0l#*@mjQ|;_%Q<25?&9l$j#IfO0Uo{r57lF;9+cp z$B;EoeV^%i7|0G${NUXVS@v(_g7Ou<=?Nbpd4|d{s*b)Of1>17-66U5-OyaCH!K%9 zp?*l@IgpR6-iSX=%(ss4-gE;ey=SX;dcMm;7_&IbCM`WkKF(g7zcS61tlD5JR<5yC zOBdRz`S_>c30EGV=KtdGUybKe_^%&}|F2)Nrd3N>gZeeBR?D_l`RBp*O`lm-YFMJZ zGj_MVJR#d22m5!A$LELLf5k9r_R#%L1OF$2|KrK&(OUk$bHM(^VE->*{}$|hVPCzz z-=0=Lj1Mj^YWju!Udix;ZZ?~Njk5&5$2#o&jrc*+nD_TG@5}DT^=Uj3zX|`rU&TJR z5wz&>r2emj_P_G1`!|yRPx^mk{;wLE|Eu6%iN^me%>NHM{l6dfe;EJx|9t&l^M773 z{=e9B_umJvA-L^-f7tb5cL*NU+m8JSx2I_Qqb!K~D+>QoAc8U=^j07HGC;ZYk^_nf zc(uaf4pctA=M#`UFavu)IeG)p1-hUMi0i91_!0;3+sYSiLhW!(_(R|dzmU4YvycJO z15Q8>kS?G_*bl}B6epDLP<#UNBPwS=xkIug#4jS>f@FeXhq4o{ubP3+A%plq27ZGK z7ejckVWvING}E3ZKJi)`xQ*d6`w-hf{SKuoe1?otPP1|kzv@9BL+lCZFY1X}4X%>f zL-L4o@B8_^viYU=lV0KF_=TPt?~oT>7!MDS01Rr5b} zSF*i+ApZAbY4Fef9{#I)^S{IYENA~0hkySng)@IUxQ@N9;CKGEep~4Gd>ISHLVqCk z;Bn5uKeoRP&i=~HipD;Bxz9p>+--XY4SuY6fa+^~IUv8_K64x;;7@SN=fn_M6hHXmm+ANq)9GQHPCilwy$n5ha2K(J2O1Dl zL`QfQ-^7b>lfHuOpuXpCz;C8}=6_*FNItxWE%6b&rJqut_;t@bbOyMt2j>63_Ra#_ zva8DT&GdB7bVJiLO%L5j1B2T%1VXR`O9DxnIK)UoJQV`v?o}ywcXxM7xx2f&yQf}N zWzFxu&)N6LJ@>t;3N*}o@4j!neRSK|YwfkyUILGB9leKZZttPnjLSA)&UqV!(cP4# zM{Mr$J+@@UW?QvUkjcr>A&VEZ~uRpSZ3$(ZwbFFr9IDo_@vW?bBPu|6DzChyyof*2i}rz6F5;)~qJ0!v@Tu~b&`-YP^pkF{ zd)fIjmTdB;$Pr|JAewM5x(3ogzP}Z|%aJQQ(hj=NNpaDy+Kaue;Ro@$l^=ZFst>MgEtC|1Ji@0d%a0#YjB%9joWr`g!BwuX@?r z_*)r43?|9*+ah1p9$ziurfL7L3cgmWd>8*5G@oy7+pBM#jlR8&(Yk;s&cikVyK5AaML22S6 zXgryJpXIzh__b`wropJ|6})a*Srb>0@6JAb7unlr~1O&e=- zM)W}chxIA(K^%YjT)00K>i@*?U+aHA{|o=yfd4I6|2Jm+U$0#!tKMt0l^?dq%1qc{ zFTNef{~kqG=NHKTe>4+6J*)-3fgIp#;JW;Fd<}WvH=zmNn__l1G~(ge`0id1_yVZ` z&)*c>?*i@*-5td4ScblX*84}``8Dqg_Y3{<$Cz*+`In6q*;ms=dHi?&*TMe_=vban zEan5~SZ>GfDz?_^v9(?@zPF3RII`LAtQS7g+y3^u5&pwpRTlokV^s_Fs~cil-JBS0 z?O*(%^(=>P6?`jA>R8?85AA?&wFv(&-QxN`z>dOgEa^U7nEi6050UYB{1+79ehs%q>rb#pNa#n7=ZHmS&Q|w9v7`bzjIc9=t*lk_OSJxyvIgQ-(pi|uC&?H=h&P{ zV{OK;9yXzCLt^%oLH`f`RN#NZUq<>r9{&^aKh~$h|F+*_CC3*`SzI&`uIeMjSy zeUVjV|F8Upy}P{Dk!08DCOR=4Wg6FnVlz7u}wB(d!N3_HD$c!hXIXYE6#k&;0!1MU8@z##wUI#m}N47LBH4 zX$ky~#a}pn5Wma#TG+W3pNk77+L8%PA1_aEyxS5!R1 z+{MBN@yFBOMCb9I*Kw}*^YET~vD?`tMh_@2`3#Z0u+{?N10ovmE_wo53j{PketqQk zzvJ7;(vY!>7brWR$l3ir+m$^$*5KO96aW2F`0*dS71#ktkMK+2rEt~J0px#L`)f@g z8ld^#*%^BL|1~%-_*IxbN%}PK>_y~?7oBdwhoonStU$6v=_QHRP~M*?D}4iK13ZFa zpUW=k3+y#~xw`zG7uh%K6yfvaDl!D|FW4-%zG%;PI&0;6ql-8AuysI5cEH3fHfG97 zn>uB#y*+N6O&Q$N#&&LKgBq40e&0jRr&j~;zlNXx;Z42#U;BSe;aywF_Wz|HqyPV~ zwX5;6wPXL^>;E(#zsQ=*+F=bBpR~Fwh}XE9SUx}*^z~j`7SMtxpas81kMBuv{<(#L z-@fuI%xZ)8%~+!QE6o--cD?2yXuJy!TSTxziVV&N$De;hhPwbUPiIey}d+XuP zxp)iO_gB0X*;q-|qBU22>?@n%gQ4ATzh~X?%{sWwbNE)Ritpxj#8Djy|4$5M*8c(i z!vq!h<*T!W$in;7dN|u@bP5k}nTlg#4X)#5*TfW>NKgR>G4v^oVG!2jr zzI1L^qemy5yD8|@j93<|2Rco^=KTF=->n+^gwo-WEMB(1Pjr%O?j7e_K(hNg>Ie1! znxkbiEcyRO-~}WL{5y08wP)~GzhUloG$6!#PY1*c#Av{OfBvF-=L6C=Mz$bYAbrCR z1Ck&9GxEcapm+4Y-VAgQKPI|R4j)GquGnWP6(K_?a=s6xllY^C7wp02XYKh8C#+(x z!`6849_u=4iwz#P%EpeLYZFI}v+@0V+Q<$Kt$+P8)){^N7QeXz|2|3lkL&+5(f)LZ z{~g%>?^N>@>(acIb?w&4x(yp`-KH$IZVPr;7mRQ_Zn$D?Hlw2t)Zd7$4!Q}Ipatci z1!ci^;e3@LFl@P6)*cd_Sr7rnj!_m5yZc?LRunQy7@8jO$Z z@u0i;V=b;{8jZL3QK)_5@>dygh?kVZ|10vj4*s7zZHjMl^6*|eynB=F-mnxKtEt#n z4zL;ho7gYn6-WJ`w9G)e+7YT0OVhx9{v@YJo+W{uo+#OZDpG~ zsEzaAx(naR$MFBZvIzY)kQhb`7MC*xw&6{DM9}R<>!ABU$5vZ_$%3INwo>e z_qpl&$zOnY0JlFF;{#-SqqsiOv){0vv9=5QA@pw*2Y3u?0_oyOXQ;#Uz@At-I#tjg ze3L!;mwIWRjyaY+eEArWUGR5Ur+=M&fAQ>+5orDvFCZHt?HRuBIj#Kx@%_Si4>}iZ zfJXc`p7|T-g=B`J9ok=%e=^a74?qw80hyuJ2_JmzB5@9(4bX&tk__UFEB28$uh_r- zp$Hymi%1_sEqteAFBpGiXxyNZ$4zrpSi>8DL!y){;&SSK>x2k^M4cK1quJ#G5>ce`-pX`^@{asSTH8Y zjj>@f7u$&CJ8ZE6vO6hA-sj7RP_xO@Eu<4Ue(;?swvupZ% z#q)N1%`^6kw~pI$Z4Oz*Zo92XzfIP4$O;=cbhZs2G{%Pa=xzgBHL#wwU$^$Zd(fIb z{T-|OKtBH0`tgUX{?Gpn`yb)|ZTJiRiFGOWsP(Gzsts&i+lKe;Y~#j{u_^Nw+w`?N zY}SrbHtRqUKEBN+A2CDxV8hYd8Hk-;-(BeMg6~QP?Kb=X^UOiBIrP6|d>g>~9iq*W z*MRq@!25I1?MtHJd`I#AN&MHCiGDgukFnsq1iTkrP~I7!aTNbazI^=}_+NyN1#AFa z3`TJBv}Azn0qjEuKsHt@!P(jPTONbI^*+^~fNuWK`A$~6HQ~Q}D^?)Rl3N2v26(sj z00SPY)2lzRepR0!wn|x>MGV)KQw9^yX%4X!HoF+=SK&Ph{#4x%RQ1@YJC65H`ku~j z?U$94$I7S<@v?Osy_KdE(VgI_>{>-nCBL1+eHx!jHFhXac0*TKI$cJ5B3oJQ5lSu~ zJHkWIg1wAE*%QmB!1CSlFNnVVI&_Gk1AP`?%Y#i}v&q={j={bS{oOLy{5{|O9b^Q- zzP2*^Xk|O?w1&Mm zSjPd&tnZ+iHn`s?8{D<4^>0zny4QHk+LigGHF+w;{~rhYQ?mW7&OTKw#ryj?F@GNZ zJo>5MviA7?>sJ0T>s#+N8`iFljUPZoHb`{`Q>KmA`cdZMHB z_=?VXmp2PSn78uJjhWbSeDp>5uNbV+8sO~7BX;5-cGlb1p#w0_Rud0p4zjy(#7F5@ z<0<;>E_|$h!TC`Y{wv0s;;f1e$j6%O0_#8UX=|oEfS2wfuIgjhX1!@MdN;AAfKVXfY{I=D( z|1(w%{I3GuS4H=y`kli64_ke$P4WHL>Y3ZoPrciERD9e9G$>=EI@YtPgS*MFBHMNzyF~Wu*O9I`ORQh$!F=ezykqF{0K)rKT-(IG-SGK`Q+&Sg zP=DE@G`Jo-5j_$U))Y1J;2C!@0B5Z)i+Po2+ z@Uc`4A8XH8d-QtcV_9+5plM#J*qr zTl(=4v@m+xy~jUZb2Q-^gGIc7mlMh#$H6e(kh3G$h3+8nM<=YkYQvTlIbNU>yg&=q z1P!q3lbo7^fH}o8P22Y@~CtJY#kPpa5z@6+Fd>2`vWOLs@|MzP(F0r47{vUKf zd;XFeNLToC$PhmZ{BP+Gd(e3W=!XCr;-HfD0oa4Ac*QkS*%xjy@d-EHxV z@wRT|Qrot3m+d)r$_|_>vICa_|NlE-Rktu*ZD9X?4f=bl;RzJ`SGs)K)8ETI?dyB_ zp76f#h)mw|-`8JF;=G^hO2GXT{>R6U=LMpXqcKB%*RQ!XK#m54zCg}0H_O+OWB}R& zTsXQLF??#;fI81QyMB-V71{SwY~??E1NbJfm5H;W_-ZxpCbp{90K{YH`sOceFgie! zyAWTAd^RnZWCw^Zbo$6%yNqvV=^&=Zb-bSGW8X)`r%{jI$Wh7PspKbIefX!(uD%Tq z!Zh8ALU(Z9wyQREL%<6R zSz2Vh=NCCYBk}_(8$Q|WR6#didj@6DE0)cl=)sfh8%V!jdk50*{dtSa*aKa5e8P{= z=aG)V58xN%bNIW+67L{B`FG-gUf_2G=m|UE+3%pEbbI|G`*!_nz;(O5!FBs?!)taY zdw};exoD5HJY&zdJ8EUS?6G=1H(8s$%dJQMSvIiWSR2x_uMKYB()zwt*}7JK*4n&s zuQmGBH>~!3pGx9Cx@onUuNpk??{06Z&2!(gF8KcJQ}sz3*7!}Ei2i`|h*r#+V4Kz~ zvpsuu+2IqX?byX4JI?St$`qyeKl@l~?SdX`haV8mZ-*z?#qoZw9p&B`o{-hRltr2n76hl!lmWB6k{+|H;aNA!wf9uucXZe2m`iZZ*=lg39pglSO!#mn~ z_=&yjEu1{C)5!p@!gr`1H}muTl%GaDd9z0mex-AbYL>o}hvqyGzm`MU^ovoJ?#SJC z?^0`h7a)%NYD=i92L;#(N!}>?fQQggda&I!d$9ex_E39t=sMtQrz1W$I$g78yIi){x}9O~ z?}*hy*w$*mdh0fDvGpG~&4%_H?qq*M+BUX+4a!;9icb;G<7d|B@&9ABfALB9(0>Z& z|Mw%8{vdKG<|K5}v@UJ;!VicWdOz_)pRp0m%G#73jcop?UbcG9MBBD*x$WP(+m4?) zZKp34*%@#j@@bf4+cD7r=)nQzdEvZxgF_r21NWWnp5!t-r>__#JdWu~sXI2e=1cK&%tmA}{P+^;b5!b$OdTsI{#ohT|@D z3y>e-xCB(4+cG_NG4%-c8_`L*%eZvBKPC z&Tll$L)}h`m zu_lm@0r~EcAD>D1=8(^h5%};JOkBeL=oE=2bi;;U_WX(+(h7S6`6d;wP@g?T`2~@^ zZ>7Q5^}#dzLAHL_^_1z24|70v2@Z6O4#Ml)lTBb5o-5Pun!SOa{5J*=KX(v5c7d{k z@wtaRP}L#tSlyv#tm&{r)^7NA>oI(_4H!1ph7F!zqxuc7QQbS(u-5ggf4w)XYx&2m z%?qqeANwlt{p9~Y#DCTRHMAzZH?U1Q(HK;kFA|I z*>-GLfuFJ6cIq_y;W%fx05USg8`FJ22cQAs354^07ie2i#KAU`9U&?A&>q4o`>Lm;1B z3(zN0oWPkIiBY;f*hiG_9>pjaiH_0GCD-5=&^w-Y&H44{JVQ2n*YFia9JBG)tdVT} z$6)h6`YN_O;vs@G@B<)uL*NVGt?}2b(ZqMH@g(-tC$pzM1>aax@rgV2y0x2f**Z@? zWj!Yzu>KP^+t9JgY}Clt9qhB(hab7Pt}miTs_K0Pa*0GyYfo^u>Kn|-D)7oS7l_$GhRun0y#8t-*} zLw`B^XT*?c8lq}NB}UhxBdrB46BGF4p*^+-UVROEdy2a*8z9A5lmFye_k5K2O8+yM z|Aqfw4rss|;sGQF&|V<&mZrpG=qwokyo1&dve(==f1+Ch97E6G?6Cvr0-bQOK&=Ht z2c#DitO<(m%y=u*KgoW}$N8))$+zO2Mt>v=D0rUkXOXW2++aTO0^;90e!cK<^!-$v z_6UUkSH+VuDTxNihTyE*E4*$enAk)MWK(pASfq;eCA$Lo<&++Qe0Xj-gzg&p1nbc) zS_5s6zdrf>lx~5i5AxqNc?~uQE73h%dff&uyl(yHUANwI@bxnb+A#B)bpyJ(G~go? zb4b1f>X?pMF7|8M4}=K~17dU6~GBQ!o9{IP#4m%4$-nd*=Mx#toCJHMvN{|a(| zGvMa_&G=ei%|3TY|J@i%aW{e}zZ9!Vakcn?MITny;|{D2Utm>!My zU9#T-Y)8V8@fTf9bkFV&1bdA>Sdlz4x=(ul}&#rfE1~zX~pa&CH$u0>!#O1^{hF=&J!f50SqtQbc z1IV|y>=wqazK&18ckz3UFU)n=%i!Z=+M3Ju_R2H%_L4(3eg0OPK4Y0ppETXx9yQ#i z4(w)=x;L}2ZL5RBuUX$Jk6Y*0e(L;A$@ZrfYkkH4RD62T0i8Dp*Z%F%U+Yo!0rXOz z!Drx`E{^y-Y|~aw8;Vcynb^LrwG)T;+4=KlvAaS)hGC!)C;-*nIo;>xdd>F&;l1Q7 zzRx+lsh^$?Ao$A3ah#3+{*6Q$Wt6y_8&s)+GS_aJrW(bgf6mll2r%o4_v=?4V$B4w zSerhyk4@>_nz)~}!QVgF;M&hxFYHV^fY;4`{Z(sl|0msiuUNn0{j0mRzx-08_b1+; zxu|!gNAXMXqK!kRZF;Xpws2HeTZ3)t_LXz7eOu4|_W`?zO>EKCs}AqepGRLu^uYIL z`fH`1FYY|~ZgNEWv7Gzn3?ltyesMR@vTx;H$sPDS3fD|>!1Je$*zb9zLI+qx9H zV)+Bux)h!ZJ%fYz9FR=#)S=zbfkRIIAio6CO;lZ^tC*t!g{$rE_|_GdmXJm1?Nkq_ zFE!>h*Ij`&AR32vK<*7a5G^2FE^CLtfA^-47OX~he&z0fCM@5HeZo%U3p?3^+;J7S zV#~L`16;Nh+b=m-$;R4BHr7^bK55H09JZyacG}{_Yi+^o`PlkSuo)xz+tj}8Y(l4c zHo8SQ8;WgtU*?srum8l_Fvc1)*VMiDU%`2Je`L^Z?XSHkcvZ*yKYs^u|6kaEnorrt zW^dq^t|q*HOIwDG>jr#X>{`2!7=xRf9lT`VS_l6rfBEC{7V-Z^a9@Sp1pZ4WKr+B1 zyEof5c=cry``Oz)8p3bAV%-&M9sPd!0jYftycTq}!X5uT&;?-b*P5WbYy!}`sD;f% z%{davR!PP?>KP5w(mV-TX&zZ%{z|R#;yBp z{l=ZPX3ZvBv23L+nK#$wgZr~agZurF_ksH(nw7JGb)F~I$HUgOOo02%nO_>-{|R*b zKJ0u_R%ZXHl5A3uK}q(fF)H4_3-N*b*LV{8@jIK+tqw9R=S`Y$-&KJA&)o1^2Sc#DdyC?A0G1y+fY5#51O32}i?;XR8QZ=0xb4_=(6(;f zZ5uaiwY96)+R7!^;LM!~?vJyXqX*a&aDObgKMdUO&z^mcat~UkSAJ}5i1n#?KZpC^ zdu5G1_Ma-TCY660%}tH5SC!wo9`Z-U+%vv?Wt-W*DSJ`fZ1s#`%>7f@=UVLIi=RHS zhk5TfW9zGGw_2=*(dA$6A?A^HB z?b%6gHy!!iXncVTz!s=GYpM3g_FBMOy%o{``2m*Ag=ny#itI1AR!218LG-Vl_$od` z?m&;=Zu|*6%D&(WE`Iw|^o!=9AGmyq)&tOhRSRq%G~mb{@d2y_PMyF{;d#6I&K2|o z)Y#JBUs4Zf{E7y+Ju*(@+fwTS#r2fT@H8~w=(?_A z-MJOn6Z_HUPa=E17~=n}jJ05%7On_Q8*@c5=FvQwBPoX;P=^HUeFs{?&eM-QT>|+T#<^&< zo1p=_x1o>BTHs{J2VA~v*R`>IE1%{)_RAmZb^odUSo!_A@=-f9hWQ#!HmLoucR0BW zF_1mt1@;!tLk!NIzhb8$1Se0Pv*Slk*^z@s?7*JAwrATm+rD9)ZNlzm%_4M7XHB#P zlZM&Mk-cnkf9%dX*Rvt4_xsj*-ny10z8`*Nn?La%%s2nr$@&!6S3dqK$p%&XPUxQr z|26k$E|SiFD`bOR8K;BlKFi!+$z}~~Zc8U1f1WcM{ni=Sxh!RjZDg$PW}Z3BJaZbq z7{Y&1w3{4cIh^;n@6#m8Ch@)m{7>W=R>=>btdvLI>(Ajo^7qr|0LUNM4*3IT4X_v+ zo|*UqoY1zS4M&#Ouj*sSVD5G@7{3my!R$1%C36=x4GY z3op=AyueG}$8XyMHh{f>(fA3O%6`ne5nb^+G8F%#lleaL;6GMUhs}24z;1LB4zovi zGFT5>y5#tQYv^fRLs!G4SFUjFoov9@iTs_Up@?e%Ty}eo+IK7t z&K2Ixf_QoPdFR(BD~}+5l@Wn2+o!%xi4>hH*?{IjMqlYYpO@BSR~%qB0$(P$eEEu9 zy8Mn^ym;9zT)1TC&t0%{XU^K0lc(*}vE%HmAGV|W_uHXeJMl5G$#$++EU$&Ob^*R- zW{kB369?I>5#4M`zm_((OI;g^oWF0K-=g1(@A;R1WX*`(E&qPki_x6#@jjMj9)e`wv=8yJLKc1+tUHmy%nM+25k9*q6*c)rt23SZZ&mf(KEKoad2F~$(VTj5& zOL_5p&+uLr#GlE^H-10s{p7VHlo3Ht2h}5htoFb6Ye&i717#mS%pT4`I|bi%;^1C* z>)ni_?R>w@=zXnYuV*E5$|An)EaFT|v=!5a+rn{uZ5Dpwr}F*Bb*_bOZ&`HvU$E}% z*SAN%r#bed4cW)9?ezOT$ee<_`P;~wWAnaW_bWb4L-^5_#QKrkPx?ON+E%bx1DZLR z--ZR_@o^pS{m1rgp{{$8`yH`M7fwOb&_};|8C<>z{15T4IQ;i0TR6?ff2c=_rhuF( z%V+W@!vAaN+`MxM+KmptiG#b`8bH2#HlVk&oc+>y$nD?mUf0I8{DTdFf0qtMM|3b+ zY8?R2<#+%mk6}&rZTK?2Q911ka$FH!pb9=2YTS*l5Xl6P3$}-T^khG0F!91f1E%zB zgx{g|ws1@@w;otKYpiWTKYQD<*|ux-LfgB3ne8VI^g(0<2e+<`V9Q#@*BUzjt#Gh~ zGy&YxeF6InTHC~7Kj-~}yuTk;`M&s4pXSG->P zI4=(3&&H4Ab-LN(gOr&M^gp^A2?+aDx}*ECA=kd{p0$hlZVPP3@;SC`$qd^BZ?}$b zx(YsT$+$k)hIY0Y16tbT?)7a<`zpx#-T?QXw;q)ru@1=jTe8O|d$KyL_pANrADzFc z3Vz;aZ0T4sf35jz|Lk8J?l=GS*N8)Nr}bf6DSnlo``64I&Yt!R=96W}mp0i+`2KSz z58(szgq!=d_P=%wdzAlD{4ap}Dg1Z1pM~@O8l>U9{*;yQUwdw{1wM^_?ospr_OU-I z+nx2S1D2t`Gmrg~XRzuSUJ{ZWw8}1 zOIi*ZP{Gjv@d2y_h{@LcH@8^_Y>RvFtp;M3F#?}q<2%-X4!mVE2eh(z!#W|a?FsGf zkIujle2I=^{ER`5aGY(JJ06(e()I8Qy0(6H3Un=m4SFXA{(6*-Wo>wKAbmy$#7tX1 zm$GsYUJvhQ<(qrg=RvCaMfK5fvOe<5k^st8Jj?*bx!6tXs6!f(d8W%KeD3;-{5Hg3 zBgYQrk)l5nfR6Q?+Q9c=tgOR_)LL}DSM%LgO&Vy+$M>}*qkGswzUf?a@8uh6O4mA0 zH*5rc<_E(E_O1COc4NN;_kZGWzY%+Ub=c#pjxCSuPRfJt+xG!EH9)l{d^HU6( z2W<%cC!}*S8y}R*C-=8aizdPMFJcX{9{Ki8JA3k=_NuA~~m zh7x`r_rkOg58$Wl{Pf0fU-?q0ph~GSb9AAEs@}+LWqloeTdhg8_kIqaU?=ejCSAb2 z81FpJ|npv+l-{557lJg=KQⅇ&#rq{-2EV$B}%@WQJ+NaDWZ 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 I|9lVpU-8ZPDF6Tf literal 0 HcmV?d00001 diff --git a/bcipy/task/actions.py b/bcipy/task/actions.py index 5472c7987..ac943ce9f 100644 --- a/bcipy/task/actions.py +++ b/bcipy/task/actions.py @@ -1,9 +1,16 @@ # mypy: disable-error-code="assignment,arg-type" +"""Task actions module for BCI tasks. + +This module provides various task actions that can be executed as part of a BCI +experiment, including code hooks, offline analysis, intertask management, and +report generation. +""" + import glob import logging import subprocess from pathlib import Path -from typing import Any, Callable, List, Optional, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple from matplotlib.figure import Figure @@ -32,8 +39,13 @@ class CodeHookAction(Task): - """ - Action for running generic code hooks. + """Action for running generic code hooks. + + Attributes: + name: Name of the task. + mode: Task execution mode. + code_hook: Code to execute. + subprocess: Whether to run in a subprocess. """ name = "CodeHookAction" @@ -46,23 +58,43 @@ def __init__( code_hook: Optional[str] = None, subprocess: bool = True, **kwargs) -> None: + """Initialize the code hook action. + + Args: + parameters: Task parameters. + data_directory: Directory for data storage. + code_hook: Code to execute. + subprocess: Whether to run in a subprocess. + **kwargs: Additional keyword arguments. + """ super().__init__() self.code_hook = code_hook self.subprocess = subprocess def execute(self) -> TaskData: + """Execute the code hook. + + Returns: + TaskData: Empty task data. + """ if self.code_hook: if self.subprocess: subprocess.Popen(self.code_hook, shell=True) - else: subprocess.run(self.code_hook, shell=True) return TaskData() class OfflineAnalysisAction(Task): - """ - Action for running offline analysis. + """Action for running offline analysis. + + Attributes: + name: Name of the task. + mode: Task execution mode. + parameters: Task parameters. + parameters_path: Path to parameters file. + data_directory: Directory containing data to analyze. + alert_finished: Whether to alert when analysis completes. """ name = "OfflineAnalysisAction" @@ -76,6 +108,16 @@ def __init__( last_task_dir: Optional[str] = None, alert_finished: bool = False, **kwargs: Any) -> None: + """Initialize the offline analysis action. + + Args: + parameters: Task parameters. + data_directory: Directory containing data to analyze. + parameters_path: Path to parameters file. + last_task_dir: Directory of last executed task. + alert_finished: Whether to alert when analysis completes. + **kwargs: Additional keyword arguments. + """ super().__init__() self.parameters = parameters self.parameters_path = parameters_path @@ -94,6 +136,11 @@ def execute(self) -> TaskData: to stop execution. For example, if Exception is thrown in cross_validation due to the # of folds being inconsistent. + Returns: + TaskData: Contains analysis results and parameters. + + Raises: + Exception: If offline analysis fails. """ logger.info("Running offline analysis action") try: @@ -118,6 +165,21 @@ def execute(self) -> TaskData: class IntertaskAction(Task): + """Action for managing transitions between tasks. + + Attributes: + name: Name of the task. + mode: Task execution mode. + tasks: List of tasks to manage. + current_task_index: Index of current task. + save_folder: Directory for saving task data. + parameters: Task parameters. + next_task_index: Index of next task to execute. + task_name: Name of current task. + task_names: List of task names. + exit_callback: Function to call on exit. + """ + name = "IntertaskAction" mode = TaskMode.ACTION tasks: List[Task] @@ -131,11 +193,25 @@ def __init__( tasks: Optional[List[Task]] = None, exit_callback: Optional[Callable] = None, **kwargs: Any) -> None: + """Initialize the intertask action. + + Args: + parameters: Task parameters. + save_path: Directory for saving task data. + progress: Current progress (1-indexed). + tasks: List of tasks to manage. + exit_callback: Function to call on exit. + **kwargs: Additional keyword arguments. + + Raises: + AssertionError: If progress or tasks is None, or if progress < 0. + """ super().__init__() self.save_folder = save_path self.parameters = parameters assert progress is not None and tasks is not None, "Either progress or tasks must be provided" - self.next_task_index = progress # progress is 1-indexed, tasks is 0-indexed so we can use the same index + # progress is 1-indexed, tasks is 0-indexed so we can use the same index + self.next_task_index = progress assert self.next_task_index >= 0, "Progress must be greater than 1 " self.tasks = tasks self.task_name = self.tasks[self.next_task_index].name @@ -143,7 +219,11 @@ def __init__( self.exit_callback = exit_callback def execute(self) -> TaskData: + """Execute the intertask action. + Returns: + TaskData: Contains task state information. + """ run_bciui( IntertaskGUI, tasks=self.task_names, @@ -160,12 +240,19 @@ def execute(self) -> TaskData: ) def alert(self): + """Handle alerts (not implemented).""" pass class ExperimentFieldCollectionAction(Task): - """ - Action for collecting experiment field data. + """Action for collecting experiment field data. + + Attributes: + name: Name of the task. + mode: Task execution mode. + experiment_id: Identifier for the experiment. + save_folder: Directory for saving collected data. + parameters: Task parameters. """ name = "ExperimentFieldCollectionAction" @@ -177,16 +264,30 @@ def __init__( data_directory: str, experiment_id: str = 'default', **kwargs: Any) -> None: + """Initialize the experiment field collection action. + + Args: + parameters: Task parameters. + data_directory: Directory for saving collected data. + experiment_id: Identifier for the experiment. + **kwargs: Additional keyword arguments. + """ super().__init__() self.experiment_id = experiment_id self.save_folder = data_directory self.parameters = parameters def execute(self) -> TaskData: + """Execute the experiment field collection. + + Returns: + TaskData: Contains experiment metadata. + """ logger.info( f"Collecting experiment field data for experiment {self.experiment_id} in save folder {self.save_folder}" ) - start_experiment_field_collection_gui(self.experiment_id, self.save_folder) + start_experiment_field_collection_gui( + self.experiment_id, self.save_folder) return TaskData( save_path=self.save_folder, task_dict={ @@ -196,8 +297,22 @@ def execute(self) -> TaskData: class BciPyCalibrationReportAction(Task): - """ - Action for generating a report after calibration Tasks. + """Action for generating a report after calibration Tasks. + + Attributes: + name: Name of the task. + mode: Task execution mode. + parameters: Task parameters. + save_folder: Directory for saving reports. + protocol_path: Path to protocol file. + last_task_dir: Directory of last executed task. + trial_window: Time window for trial analysis. + report: Report instance. + report_sections: List of report sections. + all_raw_data: List of raw data. + default_transform: Signal transformation function. + type_amp: Amplifier type. + static_offset: Static offset value. """ name = "BciPyReportAction" @@ -211,6 +326,16 @@ def __init__( last_task_dir: Optional[str] = None, trial_window: Optional[Tuple[float, float]] = None, **kwargs: Any) -> None: + """Initialize the calibration report action. + + Args: + parameters: Task parameters. + save_path: Directory for saving reports. + protocol_path: Path to protocol file. + last_task_dir: Directory of last executed task. + trial_window: Time window for trial analysis. + **kwargs: Additional keyword arguments. + """ super().__init__() self.save_folder = save_path # Currently we assume all Tasks have the same parameters, this may change in the future. @@ -233,9 +358,12 @@ def __init__( self.static_offset = None def execute(self) -> TaskData: - """Excute the report generation action. + """Execute the report generation action. This assumes all data were collected using the same protocol, device, and parameters. + + Returns: + TaskData: Contains report data and metadata. """ logger.info(f"Generating report in save folder {self.save_folder}") # loop through all the files in the last_task_dir @@ -254,14 +382,17 @@ def execute(self) -> TaskData: task_name = path_data_dir.parts[-1].split('_')[0] data_directories.append(path_data_dir) # For each calibration directory, attempt to load the raw data - signal_report_section = self.create_signal_report(path_data_dir) - session_report = self.create_session_report(path_data_dir, task_name) + signal_report_section = self.create_signal_report( + path_data_dir) + session_report = self.create_session_report( + path_data_dir, task_name) self.report_sections.append(session_report) self.report.add(session_report) self.report_sections.append(signal_report_section) self.report.add(signal_report_section) if data_directories: - logger.info(f"Saving report generated from: {self.protocol_path}") + logger.info( + f"Saving report generated from: {self.protocol_path}") else: logger.info(f"No data found in {self.protocol_path}") @@ -270,6 +401,7 @@ def execute(self) -> TaskData: self.report.compile() self.report.save() + return TaskData( save_path=self.save_folder, task_dict={ @@ -278,6 +410,14 @@ def execute(self) -> TaskData: ) def create_signal_report(self, data_dir: Path) -> SignalReportSection: + """Create a report section for signal quality metrics. + + Args: + data_dir: Directory containing signal data. + + Returns: + SignalReportSection: Report section containing signal metrics. + """ raw_data = load_raw_data(Path(data_dir, f'{RAW_DATA_FILENAME}.csv')) if not self.type_amp: self.type_amp = raw_data.daq_type @@ -298,11 +438,22 @@ def create_signal_report(self, data_dir: Path) -> SignalReportSection: triggers = self.get_triggers(data_dir) # get figure handles - figure_handles = self.get_figure_handles(raw_data, channel_map, triggers) - artifact_detector = self.get_artifact_detector(raw_data, device_spec, triggers) + figure_handles = self.get_figure_handles( + raw_data, channel_map, triggers) + artifact_detector = self.get_artifact_detector( + raw_data, device_spec, triggers) return SignalReportSection(figure_handles, artifact_detector) - def create_session_report(self, data_dir, task_name) -> SessionReportSection: + def create_session_report(self, data_dir: Path, task_name: str) -> SessionReportSection: + """Create a report section for session information. + + Args: + data_dir: Directory containing session data. + task_name: Name of the task. + + Returns: + SessionReportSection: Report section containing session info. + """ # get task name summary_dict = { "task": task_name, @@ -314,11 +465,17 @@ def create_session_report(self, data_dir, task_name) -> SessionReportSection: return SessionReportSection(summary_dict) - def get_signal_model_metrics(self, data_directory: Path) -> dict: + def get_signal_model_metrics(self, data_directory: Path) -> Dict[str, Any]: """Get the signal model metrics from the session folder. In the future, the model will save a ModelMetrics with the pkl file. For now, we just look for the pkl file and extract the AUC from the filename. + + Args: + data_directory: Directory containing model data. + + Returns: + Dict[str, Any]: Dictionary of model metrics. """ pkl_file = None for file in data_directory.iterdir(): @@ -334,6 +491,11 @@ def get_signal_model_metrics(self, data_directory: Path) -> dict: return {'AUC': auc} def set_default_transform(self, sample_rate: int) -> None: + """Set the default signal transformation function. + + Args: + sample_rate: Sampling rate of the signal. + """ downsample_rate = self.parameters.get("down_sampling_rate") notch_filter = self.parameters.get("notch_filter_frequency") filter_high = self.parameters.get("filter_high") @@ -348,7 +510,15 @@ def set_default_transform(self, sample_rate: int) -> None: downsample_factor=downsample_rate, ) - def find_eye_channels(self, device_spec: DeviceSpec) -> Optional[list]: + def find_eye_channels(self, device_spec: DeviceSpec) -> Optional[List[str]]: + """Find eye-tracking channels in the device specification. + + Args: + device_spec: Device specification. + + Returns: + Optional[List[str]]: List of eye channel names if found. + """ eye_channels = [] for channel in device_spec.channels: if 'F' in channel: @@ -357,7 +527,15 @@ def find_eye_channels(self, device_spec: DeviceSpec) -> Optional[list]: eye_channels = None return eye_channels - def get_triggers(self, session: str) -> tuple: + def get_triggers(self, session: str) -> Tuple[List[Any], List[float], List[str]]: + """Get triggers from the session data. + + Args: + session: Path to session directory. + + Returns: + Tuple[List[Any], List[float], List[str]]: Trigger type, timing, and labels. + """ trigger_type, trigger_timing, trigger_label = trigger_decoder( offset=self.static_offset, trigger_path=f"{session}/{TRIGGER_FILENAME}", @@ -369,7 +547,18 @@ def get_triggers(self, session: str) -> tuple: ) return trigger_type, trigger_timing, trigger_label - def get_figure_handles(self, raw_data, channel_map, triggers) -> List[Figure]: + def get_figure_handles(self, raw_data: RawData, channel_map: List[str], + triggers: Tuple[TriggerType, List[float], List[str]]) -> List[Figure]: + """Generate figures for the report. + + Args: + raw_data: Raw signal data. + channel_map: List of channel names. + triggers: Tuple of trigger type, timing, and labels. + + Returns: + List[Figure]: List of generated figures. + """ trigger_type, trigger_timing, _ = triggers figure_handles = visualize_erp( raw_data, @@ -384,7 +573,18 @@ def get_figure_handles(self, raw_data, channel_map, triggers) -> List[Figure]: ) return figure_handles - def get_artifact_detector(self, raw_data, device_spec, triggers) -> ArtifactDetection: + def get_artifact_detector(self, raw_data: RawData, device_spec: DeviceSpec, + triggers: Tuple[TriggerType, List[float], List[str]]) -> ArtifactDetection: + """Create an artifact detector for the signal data. + + Args: + raw_data: Raw signal data. + device_spec: Device specification. + triggers: Tuple of trigger type, timing, and labels. + + Returns: + ArtifactDetection: Configured artifact detector. + """ eye_channels = self.find_eye_channels(device_spec) artifact_detector = ArtifactDetection( raw_data, diff --git a/bcipy/task/calibration.py b/bcipy/task/calibration.py index 22fdbae46..9f416534d 100644 --- a/bcipy/task/calibration.py +++ b/bcipy/task/calibration.py @@ -1,4 +1,5 @@ """Base calibration task.""" +# mypy: disable-error-code="override" import logging from abc import abstractmethod from typing import Any, Dict, Iterator, List, NamedTuple, Optional, Tuple @@ -126,6 +127,7 @@ def setup( parameters: Parameters, data_save_location: str, fake: bool = False) -> Tuple[ClientManager, List[LslDataServer], Window]: + """Set up acquisition and return client manager, data servers, and window.""" # Initialize Acquisition daq, servers = init_acquisition( parameters, data_save_location, server=fake) @@ -213,7 +215,7 @@ def init_session(self) -> session_data.Session: task_data=self.session_task_data()) def session_task_data(self) -> Optional[Dict[str, Any]]: - """"Task-specific session data""" + """Task-specific session data.""" return None def trigger_type(self, @@ -346,8 +348,7 @@ def write_trigger_data(self, timing: List[Tuple[str, float]], convert_timing_triggers(timing, timing[0][0], self.trigger_type)) def write_offset_trigger(self) -> None: - """Append an offset value to the end of the trigger file. - """ + """Append an offset value to the end of the trigger file.""" # To help support future refactoring or use of lsl timestamps only # we write only the sample offset here. triggers = [] diff --git a/bcipy/task/control/criteria.py b/bcipy/task/control/criteria.py index e5da2512a..b51eb1d82 100644 --- a/bcipy/task/control/criteria.py +++ b/bcipy/task/control/criteria.py @@ -1,6 +1,13 @@ +"""Decision criteria module for BCI task control. + +This module provides classes for evaluating decision criteria in BCI tasks. +These criteria are used to determine when to stop collecting evidence and +make a decision based on the accumulated data. +""" + import logging from copy import copy -from typing import Dict, List +from typing import Any, Dict, List, Optional import numpy as np @@ -10,48 +17,79 @@ class DecisionCriteria: - """Abstract class for Criteria which can be applied to evaluate a inquiry + """Abstract base class for decision criteria evaluation. + + This class defines the interface for criteria that can be applied to + evaluate whether a decision should be made based on accumulated evidence. + + Attributes: + None """ - def __init__(self, **kwargs): + def __init__(self, **kwargs: Any) -> None: + """Initialize the decision criteria. + + Args: + **kwargs: Arbitrary keyword arguments. + """ pass - def reset(self): + def reset(self) -> None: + """Reset the criteria state.""" pass - def decide(self, series: Dict): - """ - Apply the given criteria. - Parameters: - ----------- - series - series data - - target(str): target of the series - - time_spent(ndarray[float]): |num_trials|x1 - time spent on the inquiry - - list_sti(list[list[str]]): presented symbols in each - inquiry - - list_distribution(list[ndarray[float]]): list of |alp|x1 - arrays with prob. dist. over alp + def decide(self, series: Dict[str, Any]) -> bool: + """Apply the decision criteria to the given series data. + + Args: + series: Dictionary containing series data with the following keys: + target (str): Target of the series. + time_spent (np.ndarray): Time spent on each inquiry. + list_sti (List[List[str]]): Presented symbols in each inquiry. + list_distribution (List[np.ndarray]): Probability distributions + over the alphabet for each inquiry. + + Returns: + bool: True if the criteria is met, False otherwise. + Raises: + NotImplementedError: This is an abstract method. """ raise NotImplementedError() class MinIterationsCriteria(DecisionCriteria): - """ Returns true if the minimum number of iterations have not yet - been reached. """ + """Criteria for ensuring a minimum number of iterations. - def __init__(self, min_num_inq: int): - """ Args: - min_num_inq(int): minimum number of inquiry number before any - termination objective is allowed to be triggered """ + Returns true if the minimum number of iterations have not yet been reached. + + Attributes: + min_num_inq: Minimum number of inquiries required. + """ + + def __init__(self, min_num_inq: int) -> None: + """Initialize the minimum iterations criteria. + + Args: + min_num_inq: Minimum number of inquiries required before any + termination objective is allowed to be triggered. + """ self.min_num_inq = min_num_inq - def decide(self, series: Dict): - # Note: we use 'list_sti' parameter since this is the number of - # inquiries displayed. The length of 'list_distribution' is 1 greater - # than this, since the language model distribution is added before - # the first inquiry is displayed. + def decide(self, series: Dict[str, Any]) -> bool: + """Check if minimum number of iterations has been reached. + + Note: Uses 'list_sti' parameter since this is the number of inquiries + displayed. The length of 'list_distribution' is 1 greater than this, + since the language model distribution is added before the first + inquiry is displayed. + + Args: + series: Dictionary containing series data. + + Returns: + bool: True if current iterations < minimum required, False otherwise. + """ current_inq = len(series['list_sti']) log.info( f"Checking min iterations; current iteration is {current_inq}") @@ -59,51 +97,99 @@ def decide(self, series: Dict): class DecreasedProbabilityCriteria(DecisionCriteria): - """Returns true if the letter with the max probability decreased from the - last inquiry.""" + """Criteria for detecting decreased probability of the most likely symbol. - def decide(self, series: Dict): + Returns true if the letter with the max probability decreased from the + last inquiry. + + Attributes: + None + """ + + def decide(self, series: Dict[str, Any]) -> bool: + """Check if probability of most likely symbol has decreased. + + Args: + series: Dictionary containing series data. + + Returns: + bool: True if probability decreased for same symbol, False otherwise. + """ if len(series['list_distribution']) < 2: return False prev_dist = series['list_distribution'][-2] cur_dist = series['list_distribution'][-1] - return np.argmax(cur_dist) == np.argmax( - prev_dist) and np.max(cur_dist) < np.max(prev_dist) + return np.argmax(cur_dist) == np.argmax(prev_dist) and np.max(cur_dist) < np.max(prev_dist) class MaxIterationsCriteria(DecisionCriteria): - """Returns true if the max iterations have been reached.""" + """Criteria for enforcing maximum number of iterations. + + Returns true if the maximum allowed iterations have been reached. + + Attributes: + max_num_inq: Maximum number of inquiries allowed. + """ - def __init__(self, max_num_inq: int): - """ Args: - max_num_inq(int): maximum number of inquiries allowed before - mandatory termination """ + def __init__(self, max_num_inq: int) -> None: + """Initialize the maximum iterations criteria. + + Args: + max_num_inq: Maximum number of inquiries allowed before + mandatory termination. + """ self.max_num_inq = max_num_inq - def decide(self, series: Dict): - # Note: len(series['list_sti']) != len(series['list_distribution']) - # see MinIterationsCriteria comment + def decide(self, series: Dict[str, Any]) -> bool: + """Check if maximum iterations have been reached. + + Note: len(series['list_sti']) != len(series['list_distribution']) + See MinIterationsCriteria comment. + + Args: + series: Dictionary containing series data. + + Returns: + bool: True if max iterations reached, False otherwise. + """ current_inq = len(series['list_sti']) if current_inq >= self.max_num_inq: - log.info( - "Committing to decision: max iterations have been reached.") + log.info("Committing to decision: max iterations have been reached.") return True return False class ProbThresholdCriteria(DecisionCriteria): - """Returns true if the commit threshold has been met.""" - - def __init__(self, threshold: float): - """ Args: - threshold(float in [0,1]): A threshold on most likely - candidate posterior. If a candidate exceeds a posterior - the system terminates. - """ + """Criteria for probability threshold-based decisions. + + Returns true if the commit threshold has been met. + + Attributes: + tau: Probability threshold value. + """ + + def __init__(self, threshold: float) -> None: + """Initialize the probability threshold criteria. + + Args: + threshold: A threshold value in [0,1]. If a candidate exceeds this + posterior probability, the system terminates. + + Raises: + AssertionError: If threshold is not in [0,1]. + """ assert 1 >= threshold >= 0, "stopping threshold should be in [0,1]" self.tau = threshold - def decide(self, series: Dict): + def decide(self, series: Dict[str, Any]) -> bool: + """Check if probability threshold has been exceeded. + + Args: + series: Dictionary containing series data. + + Returns: + bool: True if threshold exceeded, False otherwise. + """ current_distribution = series['list_distribution'][-1] if np.max(current_distribution) > self.tau: log.info("Committing to decision: posterior exceeded threshold.") @@ -112,52 +198,79 @@ def decide(self, series: Dict): class MarginCriteria(DecisionCriteria): - """ Stopping criteria based on the difference of two most likely candidates. - This condition enforces the likelihood difference between two top - candidates to be at least a value. E.g. in 4 category case with - a margin 0.2, the edge cases [0.6,0.4,0.,0.] and [0.4,0.2,0.2,0.2] - satisfy the condition. + """Criteria based on margin between top two candidates. + + This condition enforces the likelihood difference between two top + candidates to be at least a specified value. E.g. in 4 category case with + a margin 0.2, the edge cases [0.6,0.4,0.,0.] and [0.4,0.2,0.2,0.2] + satisfy the condition. + + Attributes: + margin: Required margin between top candidates. """ - def __init__(self, margin: float): - """ Args: - margin(float in [0,1]): Minimum distance required between - two most likely competing candidates to trigger termination. - """ + def __init__(self, margin: float) -> None: + """Initialize the margin criteria. + + Args: + margin: Minimum distance required between two most likely competing + candidates to trigger termination. + + Raises: + AssertionError: If margin is not in [0,1]. + """ assert 1 >= margin >= 0, "difference margin should be in [0,1]" self.margin = margin - def decide(self, series: Dict): - # Get the current posterior probability values + def decide(self, series: Dict[str, Any]) -> bool: + """Check if margin between top candidates is sufficient. + + Args: + series: Dictionary containing series data. + + Returns: + bool: True if margin is sufficient, False otherwise. + """ p = copy(series['list_distribution'][-1]) - # This criteria compares most likely candidates (best competitors) candidates = [p[idx] for idx in list(np.argsort(p)[-2:])] stopping_rule = np.abs(candidates[0] - candidates[1]) d = stopping_rule > self.margin if d: log.info("Committing to decision: margin is high enough.") - return d class MomentumCommitCriteria(DecisionCriteria): - """ Stopping criteria based on Shannon entropy on the simplex - Attr: - lam(float): linear combination parameter between entropy and the - speed term - tau(float): decision threshold - """ - - def __init__(self, tau: float, lam: float): + """Criteria based on Shannon entropy and momentum. + + This stopping criteria combines Shannon entropy on the simplex with + a momentum term. + + Attributes: + lam: Linear combination parameter between entropy and speed term. + tau: Decision threshold. + """ + + def __init__(self, tau: float, lam: float) -> None: + """Initialize the momentum commit criteria. + + Args: + tau: Decision threshold value. + lam: Linear combination parameter. + """ self.lam = lam self.tau = tau - def reset(self): - pass + def decide(self, series: Dict[str, Any]) -> bool: + """Evaluate momentum-based stopping criteria. - def decide(self, series): - eps = np.power(.1, 6) + Args: + series: Dictionary containing series data. + Returns: + bool: True if stopping criteria met, False otherwise. + """ + eps = np.power(.1, 6) prob_history = copy(series['list_distribution']) p = prob_history[-1] @@ -176,60 +289,77 @@ def decide(self, series): else: stopping_rule = tmp - d = stopping_rule < tau_ + return stopping_rule < tau_ - return d +class CriteriaEvaluator: + """Evaluates decision criteria for BCI task control. -class CriteriaEvaluator(): - """Evaluates whether an series should commit to a decision based on the - provided criteria. + This class manages multiple decision criteria to determine when to commit + to a decision based on the accumulated evidence. - Parameters: - ----------- - continue_criteria: list of criteria; if any of these evaluate to true the - decision maker continues. - commit_criteria: list of criteria; if any of these return true and - continue_criteria are all false, decision maker commits to a decision. + Attributes: + continue_criteria: List of criteria that must all be false to allow + commitment. + commit_criteria: List of criteria where any true value triggers + commitment. """ - def __init__(self, continue_criteria: List[DecisionCriteria], - commit_criteria: List[DecisionCriteria]): + def __init__(self, + continue_criteria: Optional[List[DecisionCriteria]] = None, + commit_criteria: Optional[List[DecisionCriteria]] = None) -> None: + """Initialize the criteria evaluator. + + Args: + continue_criteria: List of criteria that must all be false to allow + commitment. + commit_criteria: List of criteria where any true value triggers + commitment. + """ self.continue_criteria = continue_criteria or [] self.commit_criteria = commit_criteria or [] @classmethod - def default(cls, min_num_inq: int, max_num_inq: int, threshold: float): - return cls(continue_criteria=[MinIterationsCriteria(min_num_inq)], - commit_criteria=[ - MaxIterationsCriteria(max_num_inq), - ProbThresholdCriteria(threshold) - ]) - - def do_series(self): + def default(cls, min_num_inq: int, max_num_inq: int, + threshold: float) -> 'CriteriaEvaluator': + """Create a default CriteriaEvaluator instance. + + Args: + min_num_inq: Minimum number of inquiries required. + max_num_inq: Maximum number of inquiries allowed. + threshold: Probability threshold for commitment. + + Returns: + CriteriaEvaluator: Configured with default criteria. + """ + return cls( + continue_criteria=[MinIterationsCriteria(min_num_inq)], + commit_criteria=[ + MaxIterationsCriteria(max_num_inq), + ProbThresholdCriteria(threshold) + ]) + + def do_series(self) -> None: + """Reset all criteria for a new series.""" for el_ in self.continue_criteria: el_.reset() for el in self.commit_criteria: el.reset() - def should_commit(self, series: Dict): - """Evaluates the given series; returns true if stoppage criteria has - been met, otherwise false. - - Parameters: - ----------- - series - series data - - target(str): target of the series - - time_spent(ndarray[float]): |num_trials|x1 - time spent on the inquiry - - list_sti(list[list[str]]): presented symbols in each - inquiry - - list_distribution(list[ndarray[float]]): list of |alp|x1 - arrays with prob. dist. over alp - """ - if any( - criteria.decide(series) - for criteria in self.continue_criteria): + def should_commit(self, series: Dict[str, Any]) -> bool: + """Evaluate whether to commit to a decision. + + Args: + series: Dictionary containing series data with: + target (str): Target of the series. + time_spent (np.ndarray): Time spent on each inquiry. + list_sti (List[List[str]]): Presented symbols in each inquiry. + list_distribution (List[np.ndarray]): Probability distributions + over the alphabet for each inquiry. + + Returns: + bool: True if commitment criteria met, False otherwise. + """ + if any(criteria.decide(series) for criteria in self.continue_criteria): return False - return any( - criteria.decide(series) for criteria in self.commit_criteria) + return any(criteria.decide(series) for criteria in self.commit_criteria) diff --git a/bcipy/task/control/evidence.py b/bcipy/task/control/evidence.py index 87f6b658b..83c6b68c6 100644 --- a/bcipy/task/control/evidence.py +++ b/bcipy/task/control/evidence.py @@ -1,7 +1,13 @@ +"""Evidence evaluation module for BCI task control. + +This module provides classes and functions for extracting evidence from raw device data, +including EEG, gaze tracking, and switch input data. The module supports different types +of evidence evaluation based on the input data type and desired output evidence type. +""" + # mypy: disable-error-code="override" -"""Classes and functions for extracting evidence from raw device data.""" import logging -from typing import List, Optional, Type +from typing import Any, List, Optional, Type import numpy as np @@ -20,20 +26,33 @@ class EvidenceEvaluator: - """Base class for a class that can evaluate raw device data using a - signal_model. EvidenceEvaluators are responsible for performing necessary - preprocessing steps such as filtering and reshaping. + """Base class for evaluating raw device data using a signal model. - Parameters - ---------- - symbol_set: set of possible symbols presented - signal_model: model trained using a calibration session of the same user. + This class defines the interface for evidence evaluators, which are responsible + for performing necessary preprocessing steps such as filtering and reshaping + before evaluating the evidence. + + Attributes: + symbol_set: List of possible symbols that can be presented. + signal_model: Model trained using calibration session data. + device_spec: Specification of the input device. """ - def __init__(self, - symbol_set: List[str], - signal_model: SignalModel, - parameters: Optional[Parameters] = None): + def __init__( + self, + symbol_set: List[str], + signal_model: SignalModel, + parameters: Optional[Parameters] = None) -> None: + """Initialize the evidence evaluator. + + Args: + symbol_set: List of possible symbols that can be presented. + signal_model: Model trained using calibration session data. + parameters: Optional configuration parameters. + + Raises: + AssertionError: If signal model metadata is missing or incompatible. + """ assert signal_model.metadata, "Metadata missing from signal model." device_spec = signal_model.metadata.device_spec assert ContentType( @@ -45,31 +64,63 @@ def __init__(self, @property def consumes(self) -> ContentType: - """ContentType of the data that should be input""" + """Get the type of data this evaluator consumes. + + Returns: + ContentType: Type of input data required. + """ + raise NotImplementedError() @property def produces(self) -> EvidenceType: - """Type of evidence that is output""" + """Get the type of evidence this evaluator produces. - def evaluate(self, **kwargs): - """Evaluate the evidence""" + Returns: + EvidenceType: Type of evidence output. + """ + raise NotImplementedError() + + def evaluate(self, **kwargs: Any) -> np.ndarray: + """Evaluate the evidence from raw data. + + Args: + **kwargs: Arbitrary keyword arguments for evaluation. + + Returns: + np.ndarray: Evaluated evidence data. + """ + raise NotImplementedError() class EEGEvaluator(EvidenceEvaluator): - """EvidenceEvaluator that extracts symbol likelihoods from raw EEG data. + """Evidence evaluator for extracting symbol likelihoods from EEG data. - Parameters - ---------- - symbol_set: set of possible symbols presented - signal_model: trained signal model + This evaluator processes raw EEG data to compute likelihood ratios for + different symbols based on the ERP response. + + Attributes: + consumes: Type of input data (EEG). + produces: Type of evidence output (ERP). + channel_map: Mapping of EEG channels. + transform: Signal transformation function. + reshape: Trial reshaping function. """ + consumes = ContentType.EEG produces = EvidenceType.ERP - def __init__(self, - symbol_set: List[str], - signal_model: SignalModel, - parameters: Optional[Parameters] = None): + def __init__( + self, + symbol_set: List[str], + signal_model: SignalModel, + parameters: Optional[Parameters] = None) -> None: + """Initialize the EEG evaluator. + + Args: + symbol_set: List of possible symbols that can be presented. + signal_model: Model trained using calibration session data. + parameters: Optional configuration parameters. + """ super().__init__(symbol_set, signal_model, parameters) self.channel_map = analysis_channels(self.device_spec.channels, @@ -77,47 +128,60 @@ def __init__(self, self.transform = signal_model.metadata.transform self.reshape = TrialReshaper() - def preprocess(self, raw_data: np.ndarray, times: List[float], - target_info: List[str], window_length: float) -> np.ndarray: - """Preprocess the inquiry data. - - Parameters - ---------- - raw_data - C x L eeg data where C is number of channels and L is the - signal length - symbols - symbols displayed in the inquiry - times - timestamps associated with each symbol - target_info - target information about the stimuli; - ex. ['nontarget', 'nontarget', ...] - window_length - The length of the time between stimuli presentation + def preprocess( + self, + raw_data: np.ndarray, + times: List[float], + target_info: List[str], + window_length: float) -> np.ndarray: + """Preprocess the inquiry EEG data. + + Args: + raw_data: C x L EEG data where C is number of channels and L is + signal length. + times: Timestamps associated with each symbol. + target_info: Target information about the stimuli + (e.g. ['nontarget', 'nontarget', ...]). + window_length: Length of time between stimuli presentation. + + Returns: + np.ndarray: Preprocessed EEG data. """ transformed_data, transform_sample_rate = self.transform( raw_data, self.device_spec.sample_rate) # The data from DAQ is assumed to have offsets applied - reshaped_data, _lbls = self.reshape(trial_targetness_label=target_info, - timing_info=times, - eeg_data=transformed_data, - sample_rate=transform_sample_rate, - channel_map=self.channel_map, - poststimulus_length=window_length) + reshaped_data, _lbls = self.reshape( + trial_targetness_label=target_info, + timing_info=times, + eeg_data=transformed_data, + sample_rate=transform_sample_rate, + channel_map=self.channel_map, + poststimulus_length=window_length) return reshaped_data - # pylint: disable=arguments-differ - def evaluate(self, raw_data: np.ndarray, symbols: List[str], - times: List[float], target_info: List[str], - window_length: float, *args) -> np.ndarray: - """Evaluate the evidence. - - Parameters - ---------- - raw_data - C x L eeg data where C is number of channels and L is the - signal length - symbols - symbols displayed in the inquiry - times - timestamps associated with each symbol - target_info - target information about the stimuli; - ex. ['nontarget', 'nontarget', ...] - window_length - The length of the time between stimuli presentation + def evaluate( + self, + raw_data: np.ndarray, + symbols: List[str], + times: List[float], + target_info: List[str], + window_length: float, + *args: Any) -> np.ndarray: + """Evaluate EEG evidence. + + Args: + raw_data: C x L EEG data where C is number of channels and L is + signal length. + symbols: Symbols displayed in the inquiry. + times: Timestamps associated with each symbol. + target_info: Target information about the stimuli + (e.g. ['nontarget', 'nontarget', ...]). + window_length: Length of time between stimuli presentation. + *args: Additional arguments. + + Returns: + np.ndarray: Likelihood ratios for each symbol. """ data = self.preprocess(raw_data, times, target_info, window_length) return self.signal_model.compute_likelihood_ratio( @@ -125,20 +189,34 @@ def evaluate(self, raw_data: np.ndarray, symbols: List[str], class GazeEvaluator(EvidenceEvaluator): - """EvidenceEvaluator that extracts symbol likelihoods from raw gaze data. + """Evidence evaluator for extracting symbol likelihoods from gaze data. - Parameters - ---------- - symbol_set: set of possible symbols presented - gaze_model: trained gaze model + This evaluator processes raw eye tracking data to compute likelihoods + for different symbols based on gaze patterns. + + Attributes: + consumes: Type of input data (EYETRACKER). + produces: Type of evidence output (EYE). + channel_map: Mapping of eye tracking channels. + transform: Signal transformation function. + reshape: Gaze data reshaping function. """ + consumes = ContentType.EYETRACKER produces = EvidenceType.EYE - def __init__(self, - symbol_set: List[str], - signal_model: SignalModel, - parameters: Optional[Parameters] = None): + def __init__( + self, + symbol_set: List[str], + signal_model: SignalModel, + parameters: Optional[Parameters] = None) -> None: + """Initialize the gaze evaluator. + + Args: + symbol_set: List of possible symbols that can be presented. + signal_model: Model trained using calibration session data. + parameters: Optional configuration parameters. + """ super().__init__(symbol_set, signal_model, parameters) self.channel_map = analysis_channels(self.device_spec.channels, @@ -146,25 +224,27 @@ def __init__(self, self.transform = signal_model.metadata.transform self.reshape = GazeReshaper() - def preprocess(self, raw_data: np.ndarray, times: List[float], - flash_time: float) -> np.ndarray: - """Preprocess the inquiry data. + def preprocess( + self, + raw_data: np.ndarray, + times: List[float], + flash_time: float) -> np.ndarray: + """Preprocess the inquiry gaze data. - Parameters - ---------- - raw_data - C x L eeg data where C is number of channels and L is the - signal length. Includes all channels in devices.json - symbols - symbols displayed in the inquiry - times - timestamps associated with each symbol - flash_time - duration (in seconds) of each stimulus - - Function - -------- The preprocessing is functionally different than Gaze Reshaper, since the raw data contains only one inquiry. start_idx is determined as the start time of first symbol flashing multiplied by the sampling rate of eye tracker. stop_idx is the index indicating the end of last symbol flashing. + + Args: + raw_data: C x L data where C is number of channels and L is signal + length. Includes all channels in devices.json. + times: Timestamps associated with each symbol. + flash_time: Duration (in seconds) of each stimulus. + + Returns: + np.ndarray: Preprocessed gaze data (4, N_samples). """ if self.transform: transformed_data, transform_sample_rate = self.transform( @@ -174,55 +254,85 @@ def preprocess(self, raw_data: np.ndarray, times: List[float], transform_sample_rate = self.device_spec.sample_rate start_idx = int(self.device_spec.sample_rate * times[0]) - stop_idx = start_idx + int((times[-1] - times[0] + flash_time) * self.device_spec.sample_rate) + stop_idx = start_idx + int( + (times[-1] - times[0] + flash_time) * self.device_spec.sample_rate) data_all_channels = transformed_data[:, start_idx:stop_idx] # Extract left and right eye from all channels. Remove/replace nan values left_eye, right_eye, _, _, _, _ = extract_eye_info(data_all_channels) - reshaped_data = np.vstack((np.array(left_eye).T, np.array(right_eye).T)) - - return reshaped_data # (4, N_samples) + reshaped_data = np.vstack( + (np.array(left_eye).T, np.array(right_eye).T)) - # pylint: disable=arguments-differ - def evaluate(self, raw_data: np.ndarray, symbols: List[str], - times: List[float], target_info: List[str], - window_length: float, flash_time: float, - stim_length: float) -> np.ndarray: - """Evaluate the evidence. + return reshaped_data - Parameters - ---------- - raw_data - C x L eeg data where C is number of channels and L is the - signal length - symbols - symbols displayed in the inquiry - times - timestamps associated with each symbol - target_info - target information about the stimuli; - ex. ['nontarget', 'nontarget', ...] - window_length - The length of the time between stimuli presentation + def evaluate( + self, + raw_data: np.ndarray, + symbols: List[str], + times: List[float], + target_info: List[str], + window_length: float, + flash_time: float, + stim_length: float) -> np.ndarray: + """Evaluate gaze evidence. + + Args: + raw_data: C x L data where C is number of channels and L is signal + length. + symbols: Symbols displayed in the inquiry. + times: Timestamps associated with each symbol. + target_info: Target information about the stimuli. + window_length: Length of time between stimuli presentation. + flash_time: Duration of each stimulus. + stim_length: Length of stimulus sequence. + + Returns: + np.ndarray: Likelihood values for each symbol. """ data = self.preprocess(raw_data, times, flash_time) - # We need the likelihoods in the form of p(label | gaze). predict returns the argmax of the likelihoods. + # We need the likelihoods in the form of p(label | gaze). + # predict returns the argmax of the likelihoods. # Therefore we need predict_proba method to get the likelihoods. - likelihood = self.signal_model.evaluate_likelihood(data, symbols, self.symbol_set) + likelihood = self.signal_model.evaluate_likelihood( + data, symbols, self.symbol_set) return likelihood class SwitchEvaluator(EvidenceEvaluator): - """EvidenceEvaluator that extracts symbol likelihoods from raw Switch data. + """Evidence evaluator for extracting symbol likelihoods from switch data. - Parameters - ---------- - symbol_set: set of possible symbols presented - signal_model: trained signal model + This evaluator processes raw switch input data to compute likelihoods + for different symbols based on button press patterns. + + Attributes: + consumes: Type of input data (MARKERS). + produces: Type of evidence output (BTN). + button_press_mode: Mode of button press interpretation. + trial_count: Number of trials in stimulus sequence. """ + consumes = ContentType.MARKERS produces = EvidenceType.BTN - def __init__(self, - symbol_set: List[str], - signal_model: SignalModel, - parameters: Optional[Parameters] = None): + def __init__( + self, + symbol_set: List[str], + signal_model: SignalModel, + parameters: Optional[Parameters] = None) -> None: + """Initialize the switch evaluator. + + Args: + symbol_set: List of possible symbols that can be presented. + signal_model: Model trained using calibration session data. + parameters: Optional configuration parameters. + + Raises: + AssertionError: If button press mode is not supported. + """ super().__init__(symbol_set, signal_model, parameters) + if not parameters: + raise ValueError("Parameters required for SwitchEvaluator") + self.button_press_mode = ButtonPressMode( parameters.get('preview_inquiry_progress_method')) self.trial_count = parameters.get('stim_length') @@ -233,12 +343,25 @@ def __init__(self, "To run without button press evidence set the acq_mode to exclude MARKERS." )) - def preprocess(self, raw_data: np.ndarray, times: List[float], - target_info: List[str], window_length: float) -> np.ndarray: - """Preprocess the inquiry data. + def preprocess( + self, + raw_data: np.ndarray, + times: List[float], + target_info: List[str], + window_length: float) -> np.ndarray: + """Preprocess the inquiry switch data. Determines the return data based on whether the switch was pressed during the inquiry and the configured ButtonPressMode. + + Args: + raw_data: Switch input data. + times: Timestamps associated with each symbol. + target_info: Target information about the stimuli. + window_length: Length of time between stimuli presentation. + + Returns: + np.ndarray: Preprocessed switch data. """ switch_was_pressed = np.any(raw_data) @@ -305,8 +428,7 @@ def get_evaluator( def find_matching_evaluator( signal_model: SignalModel) -> Type[EvidenceEvaluator]: - """Find the first EvidenceEvaluator compatible with the given signal - model.""" + """Find the first EvidenceEvaluator compatible with the given signal model.""" content_type = ContentType(signal_model.metadata.device_spec.content_type) # Metadata may provide an EvidenceType with a model so the same data source can # be used to produce multiple types of evidence (ex. alpha) @@ -317,7 +439,6 @@ def find_matching_evaluator( evidence_type = EvidenceType(model_output.upper()) except ValueError: log.error(f"Unsupported evidence type: {model_output}") - return get_evaluator(content_type, evidence_type) @@ -325,7 +446,6 @@ def init_evidence_evaluator( symbol_set: List[str], signal_model: SignalModel, parameters: Optional[Parameters] = None) -> EvidenceEvaluator: - """Find an EvidenceEvaluator that matches the given signal_model and - initialize it.""" + """Find an EvidenceEvaluator that matches the given signal_model and initialize it.""" evaluator_class = find_matching_evaluator(signal_model) return evaluator_class(symbol_set, signal_model, parameters) diff --git a/bcipy/task/control/handler.py b/bcipy/task/control/handler.py index 6dd534a80..dd87987ab 100644 --- a/bcipy/task/control/handler.py +++ b/bcipy/task/control/handler.py @@ -1,3 +1,10 @@ +"""Task control handler module for BCI tasks. + +This module provides classes for managing decision making and evidence fusion +in BCI tasks. It includes functionality for scheduling inquiries, managing +task state, and making decisions based on accumulated evidence. +""" + import logging import string from typing import Dict, List, Optional, Tuple @@ -14,39 +21,55 @@ log = logging.getLogger(SESSION_LOG_FILENAME) -class EvidenceFusion(): - """ Fuses likelihood evidences provided by the inference - Attr: - evidence_history(dict{list[ndarray]}): Dictionary of difference - evidence types in list. Lists are ordered using the arrival - time. - likelihood(ndarray[]): current probability distribution over the - set. Gets updated once new evidence arrives. """ +class EvidenceFusion: + """Class for fusing likelihood evidence from multiple sources. - def __init__(self, list_name_evidence, len_dist): - self.evidence_history = {name: [] for name in list_name_evidence} - self.likelihood = np.ones(len_dist) / len_dist + This class manages the combination of evidence from different sources + (e.g., EEG, eye tracking) to compute a final probability distribution + over possible decisions. + + Attributes: + evidence_history: Dictionary mapping evidence types to their history. + likelihood: Current probability distribution over the decision space. + """ + + def __init__(self, list_name_evidence: List[EvidenceType], + len_dist: int) -> None: + """Initialize the evidence fusion system. - def update_and_fuse(self, dict_evidence): - """ Updates the probability distribution - Args: - dict_evidence(dict{name: ndarray[float]}): dictionary of - evidences (EEG (likelihood ratios) and other likelihoods) + Args: + list_name_evidence: List of evidence types to track. + len_dist: Length of the probability distribution (number of + possible decisions). """ - # {ERP: [], EYE: ()} + self.evidence_history: Dict[EvidenceType, List[np.ndarray]] = { + name: [] for name in list_name_evidence + } + self.likelihood = np.ones(len_dist) / len_dist + + def update_and_fuse(self, + dict_evidence: Dict[EvidenceType, + np.ndarray]) -> np.ndarray: + """Update and fuse probability distributions with new evidence. + + Args: + dict_evidence: Dictionary mapping evidence types to their + likelihood arrays. - for key in dict_evidence.keys(): + Returns: + np.ndarray: Updated probability distribution after fusion. + """ + for key in dict_evidence: tmp = dict_evidence[key][:][:] self.evidence_history[key].append(tmp) - # Current rule is to multiply + # Current fusion rule is multiplication for value in dict_evidence.values(): self.likelihood *= value[:] if np.isinf(np.sum(self.likelihood)): tmp = np.zeros(len(self.likelihood)) tmp[np.where(self.likelihood == np.inf)[0][0]] = 1 - self.likelihood = tmp if not np.isnan(np.sum(self.likelihood)): @@ -56,24 +79,26 @@ def update_and_fuse(self, dict_evidence): return likelihood - def reset_history(self): - """ Clears evidence history """ + def reset_history(self) -> None: + """Clears evidence history.""" for value in self.evidence_history.values(): del value[:] self.likelihood = np.ones(len(self.likelihood)) / len(self.likelihood) def save_history(self) -> None: - """ Saves the current likelihood history """ + """Save the current likelihood history. + + Note: + Not currently implemented. + """ log.warning('save_history not implemented') - return @property def latest_evidence(self) -> Dict[EvidenceType, List[float]]: - """Latest evidence of each type in the evidence history. + """Get the latest evidence of each type. - Returns - ------- - a dictionary with an entry for all configured evidence types. + Returns: + Dict mapping evidence types to their most recent values. """ return { name: list(evidence[-1]) if evidence else [] @@ -82,57 +107,54 @@ def latest_evidence(self) -> Dict[EvidenceType, List[float]]: class DecisionMaker: - """ Scheduler of the entire framework - Attr: - state(str): state of the framework, which increases in size - by 1 after each inquiry. Elements are alphabet, ".,_,<" - where ".": null_inquiry(no decision made) - "_": space bar - "<": back space - alphabet(list[str]): list of symbols used by the framework. Can - be switched with location of images or one hot encoded images. - is_txt_stim(bool): whether the stimuli are text or images - inq_constants(list[str]): optional list of letters which should appear in - every inquiry. - stopping_evaluator: CriteriaEvaluator - optional parameter to - provide alternative rules for committing to a decision. - stimuli_agent(StimuliAgent): the query selection mechanism of the - system - stimuli_timing(list[float]): list of timings for the stimuli ([fixation_time, stimuli_flash_time]) - stimuli_order(StimuliOrder): ordering of the stimuli (random, distributed) - stimuli_jitter(float): jitter of the inquiry stimuli in seconds - - Functions: - decide(): - Checks the criteria for making and series, using all - evidences and decides to do an series or to collect more - evidence - do_series(): - Once committed an series perform updates to condition the - distribution on the previous letter. - schedule_inquiry(): - schedule the next inquiry using the current information - decide_state_update(): - If committed to an series update the state using a decision - metric. - (e.g. pick the letter with highest likelihood) - prepare_stimuli(): - prepares the query set for the next inquiry - (e.g pick n-highest likely letters and randomly shuffle) + """Scheduler and decision maker for BCI task control. + + This class manages the scheduling of inquiries and decision making based + on accumulated evidence. It maintains the task state and coordinates + the interaction between evidence collection and decision making. + + Attributes: + state: Current state string, growing by 1 after each inquiry. + displayed_state: State formatted for display. + alphabet: List of possible symbols. + is_txt_stim: Whether stimuli are text or images. + stimuli_timing: Timing parameters for stimuli presentation. + stimuli_order: Order of stimuli presentation. + stimuli_jitter: Jitter in stimulus timing. + inq_constants: Symbols to include in every inquiry. + stopping_evaluator: Evaluator for stopping criteria. + stimuli_agent: Agent for selecting stimuli. + list_series: List of series data. + time: Current time. + inquiry_counter: Number of inquiries made. + last_selection: Last selected symbol. + """ + + def __init__( + self, + state: str = '', + alphabet: List[str] = list(string.ascii_uppercase) + [BACKSPACE_CHAR] + + [SPACE_CHAR], + is_txt_stim: bool = True, + stimuli_timing: List[float] = [1, .2], + stimuli_jitter: float = 0, + stimuli_order: StimuliOrder = StimuliOrder.RANDOM, + inq_constants: Optional[List[str]] = None, + stopping_evaluator: Optional[CriteriaEvaluator] = None, + stimuli_agent: Optional[StimuliAgent] = None) -> None: + """Initialize the decision maker. + + Args: + state: Initial state string. + alphabet: List of possible symbols. + is_txt_stim: Whether stimuli are text or images. + stimuli_timing: [fixation_time, stimuli_flash_time]. + stimuli_jitter: Jitter in stimulus timing (seconds). + stimuli_order: Order of stimuli presentation. + inq_constants: Symbols to include in every inquiry. + stopping_evaluator: Evaluator for stopping criteria. + stimuli_agent: Agent for selecting stimuli. """ - - def __init__(self, - state: str = '', - alphabet: List[str] = list(string.ascii_uppercase) + [BACKSPACE_CHAR] + [SPACE_CHAR], - is_txt_stim: bool = True, - stimuli_timing: List[float] = [1, .2], - stimuli_jitter: float = 0, - stimuli_order: StimuliOrder = StimuliOrder.RANDOM, - inq_constants: Optional[List[str]] = None, - stopping_evaluator: CriteriaEvaluator = CriteriaEvaluator.default(min_num_inq=2, - max_num_inq=10, - threshold=0.8), - stimuli_agent: Optional[StimuliAgent] = None): self.state = state self.displayed_state = self.form_display_state(state) self.stimuli_timing = stimuli_timing @@ -142,75 +164,90 @@ def __init__(self, self.alphabet = alphabet self.is_txt_stim = is_txt_stim - self.list_series = [{'target': None, 'time_spent': 0, - 'list_sti': [], 'list_distribution': [], - 'decision': None}] + self.list_series = [{ + 'target': None, + 'time_spent': 0, + 'list_sti': [], + 'list_distribution': [], + 'decision': None + }] self.time = 0 self.inquiry_counter = 0 self.stopping_evaluator = stopping_evaluator - self.stimuli_agent = stimuli_agent or RandomStimuliAgent(alphabet=self.alphabet) + self.stimuli_agent = stimuli_agent or RandomStimuliAgent( + alphabet=self.alphabet) self.last_selection = '' # Items shown in every inquiry self.inq_constants = inq_constants - def reset(self, state=''): - """ Resets the decision maker with the initial state - Args: - state(str): current state of the system """ + def reset(self, state: str = '') -> None: + """Reset the decision maker to initial state. + + Args: + state: New initial state string. + """ self.state = state self.displayed_state = self.form_display_state(self.state) - self.list_series = [{'target': None, 'time_spent': 0, - 'list_sti': [], 'list_distribution': []}] + self.list_series = [{ + 'target': None, + 'time_spent': 0, + 'list_sti': [], + 'list_distribution': [] + }] self.time = 0 self.inquiry_counter = 0 self.stimuli_agent.reset() - def form_display_state(self, state): - """ Forms the state information or the user that fits to the - display. Basically takes '.' and BACKSPACE_CHAR into consideration and rewrites - the state - Args: - state(str): state string - Return: - displayed_state(str): state without '<,.' and removes - backspaced letters """ + def form_display_state(self, state: str) -> str: + """Format state string for display. + + Processes special characters (backspace, dots) and formats the + state appropriately for display. + + Args: + state: Raw state string. + + Returns: + str: Formatted state string for display. + """ tmp = '' for i in state: if i == BACKSPACE_CHAR: tmp = tmp[0:-1] elif i != '.': tmp += i - return tmp - def update(self, state=''): + def update(self, state: str = '') -> None: + """Update the current state. + + Args: + state: New state string. + """ self.state = state self.displayed_state = self.form_display_state(state) - def decide(self, p) -> Tuple[bool, InquirySchedule]: - """ Once evidence is collected, decision_maker makes a decision to - stop or not by leveraging the information of the stopping - criteria. Can decide to do an series or schedule another inquiry. - - Args - ---- - p(ndarray[float]): |A| x 1 distribution array - |A|: cardinality of the alphabet - - Return - ------ - - commitment: True if a letter is a commitment is made - False if requires more evidence - - inquiry schedule: Extra arguments depending on the decision - """ + def decide(self, p: np.ndarray) -> Tuple[bool, Optional[InquirySchedule]]: + """Make a decision based on current evidence. + + Evaluates whether to commit to a decision or schedule another + inquiry based on the current probability distribution and + stopping criteria. + + Args: + p: Probability distribution over possible decisions. + Returns: + Tuple containing: + - bool: True if committing to a decision. + - Optional[InquirySchedule]: Schedule for next inquiry if needed. + """ self.list_series[-1]['list_distribution'].append(p[:]) - # Check stopping criteria if self.stopping_evaluator.should_commit(self.list_series[-1]): self.do_series() return True, None @@ -218,34 +255,46 @@ def decide(self, p) -> Tuple[bool, InquirySchedule]: stimuli = self.schedule_inquiry() return False, stimuli - def do_series(self): - """ series refers to a commitment to a decision. - If made, state is updated, displayed state is updated - a new series is appended. """ + def do_series(self) -> None: + """Handle commitment to a decision. + + Updates state and prepares for the next series when a decision + is made. + """ self.inquiry_counter = 0 decision = self.decide_state_update() self.last_selection = decision self.state += decision self.displayed_state = self.form_display_state(self.state) - # Initialize next series - self.list_series.append({'target': None, 'time_spent': 0, - 'list_sti': [], 'list_distribution': []}) + self.list_series.append({ + 'target': None, + 'time_spent': 0, + 'list_sti': [], + 'list_distribution': [] + }) self.stimuli_agent.do_series() self.stopping_evaluator.do_series() def schedule_inquiry(self) -> InquirySchedule: - """ Schedules next inquiry """ + """Schedule the next inquiry. + + Returns: + InquirySchedule: Schedule for the next inquiry. + """ self.state += '.' stimuli = self.prepare_stimuli() self.list_series[-1]['list_sti'].append(stimuli[0]) self.inquiry_counter += 1 - return stimuli - def decide_state_update(self): - """ Checks stopping criteria to commit to an series """ + def decide_state_update(self) -> str: + """Determine the next state update. + + Returns: + str: Selected symbol for state update. + """ idx = np.where( self.list_series[-1]['list_distribution'][-1] == np.max(self.list_series[-1]['list_distribution'][-1]))[0][0] @@ -254,13 +303,10 @@ def decide_state_update(self): return decision def prepare_stimuli(self) -> InquirySchedule: - """ Given the alphabet, under a rule, prepares a stimuli for - the next inquiry. + """Prepare stimuli for the next inquiry. - Return - ------ - stimuli(tuple[list[str],list[float],list[str]]): tuple of - stimuli information. [0]: letter, [1]: timing, [2]: color + Returns: + InquirySchedule: Schedule containing stimuli and timing information. """ # querying agent decides on possible letters to be shown on the screen diff --git a/bcipy/task/control/query.py b/bcipy/task/control/query.py index 3e8b3c867..af7d478be 100644 --- a/bcipy/task/control/query.py +++ b/bcipy/task/control/query.py @@ -1,6 +1,14 @@ +"""Query module for BCI task control. + +This module provides classes for managing stimulus presentation in BCI tasks. +It includes agents that determine which stimuli to present based on different +selection strategies, such as random selection or N-best selection based on +probability distributions. +""" + import random from abc import ABC, abstractmethod -from typing import List, Optional +from typing import Any, List, Optional import numpy as np @@ -8,48 +16,81 @@ class StimuliAgent(ABC): + """Abstract base class for stimulus selection agents. + + This class defines the interface for agents that select stimuli to present + during BCI tasks. Subclasses implement different selection strategies. + """ + @abstractmethod - def reset(self): + def reset(self) -> None: + """Reset the agent's state.""" ... @abstractmethod - def return_stimuli(self, list_distribution: np.ndarray, **kwargs): - """ updates the agent with most likely posterior and selects queries - Args: - list_distribution(list[ndarray]): posterior distributions as - stored in the decision maker - Return: - query(list[str]): queries """ + def return_stimuli(self, list_distribution: np.ndarray, + **kwargs: Any) -> List[str]: + """Update agent with posterior probabilities and select queries. + + Args: + list_distribution: List of posterior probability distributions. + **kwargs: Additional keyword arguments. + + Returns: + List[str]: Selected stimuli for the next query. + """ ... @abstractmethod - def do_series(self): - """ If the system decides on a class let the agent know about it """ + def do_series(self) -> None: + """Handle series completion. + + Called when the system decides on a class to let the agent update + its state accordingly. + """ ... class RandomStimuliAgent(StimuliAgent): - """ An inherited class of StimuliAgent. Chooses random set of letters for - queries instead of most likely letters. - Attr: - alphabet(list[str]): Query space(possible queries). - len_query(int): number of elements in a query - Functions: - reset(): reset the agent - return_stimuli(): update the agent and return a stimuli set - do_series(): one a commitment is made update agent - """ - - def __init__(self, alphabet: List[str], len_query: int = 4): + """Stimuli agent that randomly selects queries. + + This agent chooses random sets of letters for queries instead of using + probability-based selection. + + Attributes: + alphabet: List of possible symbols to query from. + len_query: Number of symbols to include in each query. + """ + + def __init__(self, alphabet: List[str], len_query: int = 4) -> None: + """Initialize the random stimuli agent. + + Args: + alphabet: List of possible symbols to query from. + len_query: Number of symbols to include in each query. + """ self.alphabet = alphabet self.len_query = len_query - def reset(self): - """ This querying method is memoryless no reset needed """ + def reset(self) -> None: + """Reset the agent's state. + + This querying method is memoryless, so no reset is needed. + """ pass - def return_stimuli(self, list_distribution: np.ndarray, constants: Optional[List[str]] = None): - """ return random elements from the alphabet """ + def return_stimuli(self, + list_distribution: np.ndarray, + constants: Optional[List[str]] = None) -> List[str]: + """Return random elements from the alphabet. + + Args: + list_distribution: List of probability distributions (unused). + constants: Optional list of symbols to always include in the result. + + Returns: + List[str]: Randomly selected symbols, with constants if provided. + """ tmp = [i for i in self.alphabet] query = random.sample(tmp, self.len_query) @@ -58,41 +99,58 @@ def return_stimuli(self, list_distribution: np.ndarray, constants: Optional[List return query - def do_series(self): + def do_series(self) -> None: + """Handle series completion. + + This agent is stateless, so no action is needed. + """ pass class NBestStimuliAgent(StimuliAgent): - """ An inherited class of StimuliAgent. Updates the agent with N most likely - letters based on posteriors and selects queries. - Attr: - alphabet(list[str]): Query space(possible queries). - len_query(int): number of elements in a query - Functions: - reset(): reset the agent - return_stimuli(): update the agent and return a stimuli set - do_series(): one a commitment is made update agent - """ - - def __init__(self, alphabet: List[str], len_query: int = 4): + """Stimuli agent that selects the N most likely symbols. + + This agent updates its selection based on posterior probabilities, + choosing the N symbols with highest probability for each query. + + Attributes: + alphabet: List of possible symbols to query from. + len_query: Number of symbols to include in each query. + """ + + def __init__(self, alphabet: List[str], len_query: int = 4) -> None: + """Initialize the N-best stimuli agent. + + Args: + alphabet: List of possible symbols to query from. + len_query: Number of symbols to include in each query. + """ self.alphabet = alphabet self.len_query = len_query - def reset(self): + def reset(self) -> None: + """Reset the agent's state. + + This agent is stateless, so no reset is needed. + """ pass def return_stimuli(self, list_distribution: np.ndarray, constants: Optional[List[str]] = None) -> List[str]: - """Returns a list of the n most likely symbols based on the provided - probabilities, where n is self.len_query. Symbols of the same - probability will be ordered randomly. - - Parameters - ---------- - list_distribution - list of lists of probabilities. Only the last list will - be used. - constants - optional list of symbols which should appear every result + """Return the N most likely symbols based on probabilities. + + Selects symbols based on their probabilities in the distribution, + where N is self.len_query. Symbols with equal probabilities are + ordered randomly. + + Args: + list_distribution: List of probability distributions. Only the + last distribution is used. + constants: Optional list of symbols to always include in the result. + + Returns: + List[str]: Selected symbols, with constants if provided. """ symbol_probs = list(zip(self.alphabet, list_distribution[-1])) randomized = random.sample(symbol_probs, len(symbol_probs)) @@ -102,5 +160,9 @@ def return_stimuli(self, len_query=self.len_query, always_included=constants) - def do_series(self): + def do_series(self) -> None: + """Handle series completion. + + This agent is stateless, so no action is needed. + """ pass diff --git a/bcipy/task/data.py b/bcipy/task/data.py index 51a1df069..aed8a44e7 100644 --- a/bcipy/task/data.py +++ b/bcipy/task/data.py @@ -1,4 +1,8 @@ -"""Module for functionality related to session-related data.""" +"""Module for functionality related to session-related data. + +This module provides classes and functions for managing BCI session data, +including evidence types, inquiries, and session management. +""" from collections import Counter from enum import Enum from typing import Any, Dict, List, Optional, Tuple @@ -9,15 +13,25 @@ def rounded(values: List[float], precision: int) -> List[float]: """Round the list of values to the given precision. - Parameters - ---------- - values - values to round + Args: + values: Values to round. + precision: Number of decimal places to round to. + + Returns: + List[float]: Rounded values. """ return [round(value, precision) for value in values] class EvidenceType(Enum): - """Enum of the supported evidence types used in the various spelling tasks.""" + """Enum of the supported evidence types used in the various spelling tasks. + + Attributes: + LM: Language Model evidence. + ERP: Event-Related Potential using EEG signals. + BTN: Button press evidence. + EYE: Eye tracker evidence. + """ LM = 'LM' # Language Model ERP = 'ERP' # Event-Related Potential using EEG signals BTN = 'BTN' # Button @@ -25,16 +39,22 @@ class EvidenceType(Enum): @classmethod def list(cls) -> List[str]: - """List of evidence types""" + """List of evidence types. + + Returns: + List[str]: List of evidence type names. + """ return [ev_type.name for ev_type in cls] @classmethod def deserialized(cls, serialized_name: str) -> 'EvidenceType': """Deserialized name of the given evidence type. - Parameters: - evidence_name - ex. 'lm_evidence' + + Args: + serialized_name: Evidence name (ex. 'lm_evidence'). + Returns: - deserialized value: ex. EvidenceType.LM + EvidenceType: Deserialized value (ex. EvidenceType.LM). """ if serialized_name == 'eeg_evidence': return EvidenceType.ERP @@ -45,7 +65,11 @@ def __str__(self) -> str: @property def serialized(self) -> str: - """Name used when serialized to a json file.""" + """Name used when serialized to a json file. + + Returns: + str: Serialized name of the evidence type. + """ if self == EvidenceType.ERP: return 'eeg_evidence' return f'{self.name.lower()}{EVIDENCE_SUFFIX}' @@ -54,19 +78,18 @@ def serialized(self) -> str: class Inquiry: """Represents a sequence of stimuli. - Parameters: - ---------- - stimuli - list of stimuli presented (letters, icons, etc) - timing - duration in seconds for each stimulus - target_info - targetness ('nontarget', 'target', etc) for each stimulus - target_letter - current letter that the user is attempting to spell - current_text - letters spelled so far - target_text - word or words the user is attempting to spell - next_display_state - text to be displayed after evaluating the current evidence - lm_evidence - language model evidence for each stimulus - eeg_evidence - eeg evidence for each stimulus - likelihood - combined likelihood for each stimulus - task_data - task-specific information about the inquiry that may be useful in training a model + Args: + stimuli: List of stimuli presented (letters, icons, etc). + timing: Duration in seconds for each stimulus. + triggers: List of (trigger_name, timestamp) tuples. + target_info: Targetness ('nontarget', 'target', etc) for each stimulus. + target_letter: Current letter that the user is attempting to spell. + current_text: Letters spelled so far. + target_text: Word or words the user is attempting to spell. + selection: Currently selected symbol. + next_display_state: Text to be displayed after evaluating evidence. + likelihood: Combined likelihood for each stimulus. + task_data: Task-specific information about the inquiry. """ def __init__(self, @@ -80,7 +103,7 @@ def __init__(self, selection: Optional[str] = None, next_display_state: Optional[str] = None, likelihood: Optional[List[float]] = None, - task_data: Optional[Dict] = None) -> None: + task_data: Optional[Dict[str, Any]] = None) -> None: super().__init__() self.stimuli = stimuli self.timing = timing @@ -100,37 +123,53 @@ def __init__(self, @property def lm_evidence(self) -> List[float]: - """Language model evidence""" + """Language model evidence. + + Returns: + List[float]: Language model evidence values. + """ return self.evidences.get(EvidenceType.LM, []) @property def eeg_evidence(self) -> List[float]: - """EEG evidence""" + """EEG evidence. + + Returns: + List[float]: EEG evidence values. + """ return self.evidences.get(EvidenceType.ERP, []) @property def decision_made(self) -> bool: - """Returns true if the result of the inquiry was a decision.""" + """Returns true if the result of the inquiry was a decision. + + Returns: + bool: True if a decision was made. + """ return self.current_text != self.next_display_state @property def is_correct_decision(self) -> bool: - """Indicates whether the current selection was the target""" + """Indicates whether the current selection was the target. + + Returns: + bool: True if selection matches target_letter. + """ if self.selection and self.target_letter: return self.selection == self.target_letter return False @classmethod - def from_dict(cls, data: dict) -> 'Inquiry': - """Deserializes from a dict + def from_dict(cls, data: Dict[str, Any]) -> 'Inquiry': + """Deserializes from a dict. - Parameters: - ---------- - data - a dict in the format of the data output by the as_dict - method. + Args: + data: A dict in the format of the data output by the as_dict method. + + Returns: + Inquiry: New instance created from dict data. """ # partition into evidence data and other data. - evidences = { EvidenceType.deserialized(name): value for name, value in data.items() if name.endswith(EVIDENCE_SUFFIX) @@ -148,9 +187,13 @@ def from_dict(cls, data: dict) -> 'Inquiry': inquiry.evidences = evidences return inquiry - def as_dict(self) -> Dict: - """Dict representation""" - data: Dict = { + def as_dict(self) -> Dict[str, Any]: + """Dict representation. + + Returns: + Dict[str, Any]: Dictionary containing inquiry data. + """ + data: Dict[str, Any] = { 'stimuli': self.stimuli, 'timing': self.timing, 'triggers': self.triggers, @@ -174,15 +217,18 @@ def as_dict(self) -> Dict: def stim_evidence(self, symbol_set: List[str], n_most_likely: int = 5) -> Dict[str, Any]: - """Returns a dict of stim sequence data useful for debugging. Evidences - are paired with the appropriate symbol for easier visual + """Returns a dict of stim sequence data useful for debugging. + + Evidences are paired with the appropriate symbol for easier visual scanning. Also, an additional attribute is provided to display the top n most likely symbols based on the current evidence. - Parameters: - ----------- - symbol_set - list of stim in the same order as the evidences. - n_most_likely - number of most likely elements to include + Args: + symbol_set: List of stim in the same order as the evidences. + n_most_likely: Number of most likely elements to include. + + Returns: + Dict[str, Any]: Dictionary containing stimulus evidence data. """ likelihood = dict(zip(symbol_set, self.format(self.likelihood))) data: Dict[str, Any] = { @@ -199,9 +245,11 @@ def stim_evidence(self, def format(self, evidence: List[float]) -> List[float]: """Format the evidence for output. - Parameters - ---------- - evidence - list of evidence values + Args: + evidence: List of evidence values. + + Returns: + List[float]: Formatted evidence values. """ if self.precision: return rounded(evidence, self.precision) @@ -209,7 +257,16 @@ def format(self, evidence: List[float]) -> List[float]: class Session: - """Represents a data collection session. Not all tasks record session data.""" + """Represents a data collection session. Not all tasks record session data. + + Args: + save_location: Location where session data will be saved. + symbol_set: List of possible symbols that can be presented. + task: Name of the task being performed. + mode: Mode of operation (e.g., 'RSVP'). + decision_threshold: Threshold for making decisions. + task_data: Additional task-specific data. + """ def __init__(self, save_location: str, @@ -232,24 +289,40 @@ def __init__(self, @property def total_number_series(self) -> int: - """Total number of series that contain sequences.""" + """Total number of series that contain sequences. + + Returns: + int: Number of non-empty series. + """ return len([lst for lst in self.series if lst]) @property def total_number_decisions(self) -> int: - """Total number of series that ended in a decision.""" + """Total number of series that ended in a decision. + + Returns: + int: Number of completed series. + """ # An alternate implementation would be to count the inquiries with # decision_made property of true. return len(self.series) - 1 @property def total_inquiries(self) -> int: - """Total number of inquiries presented.""" + """Total number of inquiries presented. + + Returns: + int: Total number of inquiries. + """ return sum([len(lst) for lst in self.series]) @property def inquiries_per_selection(self) -> Optional[float]: - """Inquiries per selection""" + """Inquiries per selection. + + Returns: + Optional[float]: Average inquiries per selection, or None if no selections. + """ selections = self.total_number_decisions if selections == 0: return None @@ -257,25 +330,32 @@ def inquiries_per_selection(self) -> Optional[float]: @property def all_inquiries(self) -> List[Inquiry]: - """List of all Inquiries for the whole session""" + """List of all Inquiries for the whole session. + + Returns: + List[Inquiry]: All inquiries from non-empty series. + """ return [inq for inquiries in self.series for inq in inquiries if inquiries] def has_evidence(self) -> bool: - """Tests whether any inquiries have evidence.""" + """Tests whether any inquiries have evidence. + + Returns: + bool: True if any inquiries have evidence. + """ return any(inq.evidences for inq in self.all_inquiries) def add_series(self) -> None: - """Add another series unless the last one is empty""" + """Add another series unless the last one is empty.""" if self.last_series(): self.series.append([]) def add_sequence(self, inquiry: Inquiry, new_series: bool = False) -> None: - """Append sequence information + """Append sequence information. - Parameters: - ----------- - inquiry - data to append - new_series - a True value indicates that this is the first stim of + Args: + inquiry: Data to append. + new_series: A True value indicates that this is the first stim of a new series. """ if new_series: @@ -283,23 +363,42 @@ def add_sequence(self, inquiry: Inquiry, new_series: bool = False) -> None: self.last_series().append(inquiry) def last_series(self) -> List[Inquiry]: - """Returns the last series""" + """Returns the last series. + + Returns: + List[Inquiry]: Last series of inquiries. + """ return self.series[-1] def last_inquiry(self) -> Optional[Inquiry]: - """Returns the last inquiry of the last series.""" + """Returns the last inquiry of the last series. + + Returns: + Optional[Inquiry]: Last inquiry if it exists. + """ series = self.last_series() if series: return series[-1] return None def latest_series_is_empty(self) -> bool: - """Whether the latest series has had any inquiries added to it.""" + """Whether the latest series has had any inquiries added to it. + + Returns: + bool: True if latest series is empty. + """ return len(self.last_series()) == 0 def as_dict(self, evidence_only: bool = False) -> Dict[str, Any]: - """Dict representation""" + """Dict representation. + + Args: + evidence_only: Whether to include only evidence-related data. + + Returns: + Dict[str, Any]: Dictionary containing session data. + """ series_dict: Dict[str, Any] = {} for i, series in enumerate(self.series): if series: @@ -339,13 +438,14 @@ def as_dict(self, return info @classmethod - def from_dict(cls, data: dict) -> 'Session': + def from_dict(cls, data: Dict[str, Any]) -> 'Session': """Deserialize from a dict. - Parameters: - ---------- - data - a dict in the format of the data output by the as_dict - method. + Args: + data: A dict in the format of the data output by the as_dict method. + + Returns: + Session: New session instance created from dict data. """ session = cls(save_location=data['session'], task=data['task'], diff --git a/bcipy/task/demo/actions/demo_calibration_report.py b/bcipy/task/demo/actions/demo_calibration_report.py index 776b91aaa..9ca7b79f8 100644 --- a/bcipy/task/demo/actions/demo_calibration_report.py +++ b/bcipy/task/demo/actions/demo_calibration_report.py @@ -9,7 +9,8 @@ if __name__ == '__main__': import argparse - parser = argparse.ArgumentParser(description='Generate a calibration report from a session data file.') + parser = argparse.ArgumentParser( + description='Generate a calibration report from a session data file.') # Add the arguments: parameters and protocol parser.add_argument( '--parameters', @@ -31,6 +32,7 @@ The protocol path is the path to the directory containing the calibration sessions. """ - action = BciPyCalibrationReportAction(parameters=parameters, save_path='.', protocol_path=args.protocol) + action = BciPyCalibrationReportAction( + parameters=parameters, save_path='.', protocol_path=args.protocol) print('Generating Report.') task_data = action.execute() diff --git a/bcipy/task/demo/orchestrator/demo_orchestrator.py b/bcipy/task/demo/orchestrator/demo_orchestrator.py index afbdd5108..fdc6f777e 100644 --- a/bcipy/task/demo/orchestrator/demo_orchestrator.py +++ b/bcipy/task/demo/orchestrator/demo_orchestrator.py @@ -43,7 +43,8 @@ def demo_orchestrator(parameters_path: str) -> None: import argparse - parser = argparse.ArgumentParser(description="Demo the SessionOrchestrator") + parser = argparse.ArgumentParser( + description="Demo the SessionOrchestrator") parser.add_argument( '-p', '--parameters_path', diff --git a/bcipy/task/exceptions.py b/bcipy/task/exceptions.py index 7de570c24..5e29cb573 100644 --- a/bcipy/task/exceptions.py +++ b/bcipy/task/exceptions.py @@ -1,33 +1,79 @@ +"""Task-specific exceptions for the BciPy task module. + +This module defines custom exceptions that can be raised during task execution, +registration, and evidence evaluation. +""" + from typing import Any, Optional class InsufficientDataException(Exception): - """Insufficient Data Exception. + """Exception raised when task data requirements are not met. + + This exception is raised when a task does not have sufficient data to + execute properly, such as missing calibration data or required parameters. + + Args: + message: Description of what data was insufficient. + errors: Optional additional error information. - Thrown when data requirements to execute task are violated. + Attributes: + message: The error message. + errors: Additional error details, if any. """ def __init__(self, message: str, errors: Optional[Any] = None) -> None: super().__init__(message) + self.message = message self.errors = errors class TaskRegistryException(Exception): - """Task Registry Exception. + """Exception raised when there are issues with task registration. - Thrown when task type is unregistered. + This exception is raised when attempting to use an unregistered task type + or when there are problems with the task registry. + + Args: + message: Description of the registration issue. + errors: Optional additional error information. + + Attributes: + message: The error message. + errors: Additional error details, if any. """ def __init__(self, message: str, errors: Optional[Any] = None) -> None: super().__init__(message) + self.message = message self.errors = errors class MissingEvidenceEvaluator(Exception): - """Thrown when an evidence evaluator can't be found that matches the - provided data content type input and evidence_type output""" + """Exception raised when a required evidence evaluator is not found. + + This exception is raised when no evidence evaluator can be found that matches + the provided data content type input and evidence_type output requirements. + + Args: + message: Description of the missing evaluator. + """ + + def __init__(self, message: str) -> None: + super().__init__(message) + self.message = message class DuplicateModelEvidence(Exception): - """Thrown from a task when more than one of the provided models produces - the same type of evidence""" + """Exception raised when multiple models produce the same evidence type. + + This exception is raised when more than one of the provided models produces + the same type of evidence, making it ambiguous which evidence should be used. + + Args: + message: Description of the duplicate evidence. + """ + + def __init__(self, message: str) -> None: + super().__init__(message) + self.message = message diff --git a/bcipy/task/main.py b/bcipy/task/main.py index daadb089d..0e3c51809 100644 --- a/bcipy/task/main.py +++ b/bcipy/task/main.py @@ -1,7 +1,13 @@ +"""Core task module defining base classes for BciPy tasks. + +This module provides the foundational classes for implementing BCI tasks, +including the abstract base Task class and supporting data structures. +""" + from abc import ABC, abstractmethod from dataclasses import dataclass from enum import Enum -from typing import Optional +from typing import Any, Dict, Optional from bcipy.config import STATIC_AUDIO_PATH from bcipy.core.parameters import Parameters @@ -9,16 +15,33 @@ @dataclass -class TaskData(): - """TaskData. +class TaskData: + """Data structure for storing task execution results. + + This class encapsulates the data returned from a task execution, including + the save path for any generated data and a dictionary of task-specific data. - Data structure for storing task return data. + Attributes: + save_path: Path where task data was saved. + task_dict: Dictionary containing task-specific data and results. """ save_path: Optional[str] = None - task_dict: Optional[dict] = None + task_dict: Optional[Dict[str, Any]] = None class TaskMode(Enum): + """Enumeration of supported BCI task modes. + + This enum defines the different types of tasks that can be executed in the BCI system. + Each mode represents a specific type of interaction or experiment. + + Attributes: + CALIBRATION: Mode for system calibration tasks. + COPYPHRASE: Mode for copy-spelling tasks. + TIMING_VERIFICATION: Mode for timing verification tasks. + ACTION: Mode for action-based tasks. + TRAINING: Mode for training tasks. + """ CALIBRATION = "calibration" COPYPHRASE = "copy phrase" TIMING_VERIFICATION = "timing verification" @@ -26,16 +49,37 @@ class TaskMode(Enum): TRAINING = "training" def __str__(self) -> str: + """Return the string value of the task mode. + + Returns: + str: The string representation of the task mode. + """ return self.value def __repr__(self) -> str: + """Return the string representation of the task mode. + + Returns: + str: The string representation of the task mode. + """ return self.value class Task(ABC): - """Task. + """Abstract base class for BciPy tasks. + + This class defines the interface that all BCI tasks must implement. It provides + the basic structure for task execution, setup, and cleanup. + + Attributes: + name: Name of the task. + mode: Mode of operation for the task. + parameters: Task configuration parameters. + data_save_location: Location where task data should be saved. - Base class for BciPy tasks. + Note: + Subclasses must define the 'name' and 'mode' class attributes and + implement the execute() method. """ name: str mode: TaskMode @@ -43,19 +87,62 @@ class Task(ABC): data_save_location: str def __init__(self, *args, **kwargs) -> None: + """Initialize the task. + + Args: + *args: Variable length argument list. + **kwargs: Arbitrary keyword arguments. + + Raises: + AssertionError: If name or mode attributes are not defined. + """ super(Task, self).__init__() - assert getattr(self, 'name', None) is not None, "Task must have a `name` attribute defined" - assert getattr(self, 'mode', None) is not None, "Task must have a `mode` attribute defined" + assert getattr( + self, 'name', None) is not None, "Task must have a `name` attribute defined" + assert getattr( + self, 'mode', None) is not None, "Task must have a `mode` attribute defined" @abstractmethod def execute(self) -> TaskData: + """Execute the task. + + This method must be implemented by all task subclasses to define the + task's execution logic. + + Returns: + TaskData: Object containing the results of the task execution. + """ ... - def setup(self, *args, **kwargs): + def setup(self, *args, **kwargs) -> None: + """Set up the task before execution. + + This method can be overridden by subclasses to perform any necessary + setup before task execution. + + Args: + *args: Variable length argument list. + **kwargs: Arbitrary keyword arguments. + """ ... - def cleanup(self, *args, **kwargs): + def cleanup(self, *args, **kwargs) -> None: + """Clean up after task execution. + + This method can be overridden by subclasses to perform any necessary + cleanup after task execution. + + Args: + *args: Variable length argument list. + **kwargs: Arbitrary keyword arguments. + """ ... - def alert(self): - play_sound(f"{STATIC_AUDIO_PATH}/{self.parameters['alert_sound_file']}") + def alert(self) -> None: + """Play an alert sound. + + Plays the configured alert sound file to notify the user. + The sound file is specified in the task parameters. + """ + play_sound( + f"{STATIC_AUDIO_PATH}/{self.parameters['alert_sound_file']}") diff --git a/bcipy/task/orchestrator/__init__.py b/bcipy/task/orchestrator/__init__.py index df0973e4c..b594a7eb2 100644 --- a/bcipy/task/orchestrator/__init__.py +++ b/bcipy/task/orchestrator/__init__.py @@ -1,3 +1,10 @@ +"""Task orchestration module for managing BCI experiment sessions. + +This module provides functionality for managing and executing sequences of BCI tasks, +including task initialization, execution order, data saving, and logging. The main +component is the SessionOrchestrator, which handles the lifecycle of task execution. +""" + from bcipy.task.orchestrator.orchestrator import SessionOrchestrator __all__ = ['SessionOrchestrator'] diff --git a/bcipy/task/orchestrator/orchestrator.py b/bcipy/task/orchestrator/orchestrator.py index 9fcf2e92a..3090c0e9f 100644 --- a/bcipy/task/orchestrator/orchestrator.py +++ b/bcipy/task/orchestrator/orchestrator.py @@ -1,3 +1,9 @@ +"""Task orchestration module for managing BCI experiment sessions. + +This module provides functionality for managing and executing sequences of BCI tasks, +handling task initialization, execution, logging, and data management. +""" + # mypy: disable-error-code="arg-type, assignment" import errno import json @@ -8,7 +14,7 @@ import time from datetime import datetime from logging import Logger -from typing import List, Optional, Type +from typing import Any, Dict, List, Optional, Tuple, Type from bcipy.config import (DEFAULT_EXPERIMENT_ID, DEFAULT_PARAMETERS_FILENAME, DEFAULT_PARAMETERS_PATH, DEFAULT_USER_ID, @@ -21,43 +27,66 @@ class SessionOrchestrator: + """Manages the execution of a protocol of BCI tasks. + + The Session Orchestrator is responsible for managing the execution of a sequence + of tasks within an experiment session. It handles task initialization, execution + order, data saving, and logging. + + Attributes: + tasks: List of task classes to execute. + task_names: List of task names in execution order. + parameters: Configuration parameters for the session. + sys_info: System information dictionary. + log: Session logger instance. + save_folder: Path where session data is saved. + session_data: List of data from executed tasks. + ready_to_execute: Whether tasks are ready to execute. + last_task_dir: Path to the last executed task's directory. + copyphrases: List of phrases for copy tasks. + next_phrase: Next phrase to be used in copy tasks. + starting_index: Starting index for copy tasks. + user: User identifier. + fake: Whether to use fake data. + experiment_id: Experiment identifier. + alert: Whether to alert when tasks complete. + visualize: Whether to visualize task results. + progress: Current task execution progress. + user_exit: Whether user has requested to exit. """ - Session Orchestrator - -------------------- - - The Session Orchestrator is responsible for managing the execution of a protocol of tasks. It is initialized with an - experiment ID, user ID, and parameters file. Tasks are added to the orchestrator, which are then executed in order. - """ - tasks: List[Type[Task]] - task_names: List[str] - parameters: Parameters - sys_info: dict - log: Logger - save_folder: str - session_data: List[TaskData] - ready_to_execute: bool = False - last_task_dir: Optional[str] = None def __init__( self, experiment_id: str = DEFAULT_EXPERIMENT_ID, user: str = DEFAULT_USER_ID, parameters_path: str = DEFAULT_PARAMETERS_PATH, - parameters: Parameters = None, + parameters: Optional[Parameters] = None, fake: bool = False, alert: bool = False, visualize: bool = False ) -> None: + """Initialize the session orchestrator. + + Args: + experiment_id: Identifier for the experiment session. + user: User identifier. + parameters_path: Path to parameters file. + parameters: Optional pre-loaded parameters object. + fake: Whether to use fake data for testing. + alert: Whether to alert when tasks complete. + visualize: Whether to visualize task results. + """ self.parameters_path = parameters_path if not parameters: - self.parameters = load_json_parameters(parameters_path, value_cast=True) + self.parameters = load_json_parameters( + parameters_path, value_cast=True) else: # This allows for the parameters to be passed in directly and modified before executions self.parameters = parameters - self.copyphrases = None - self.next_phrase = None - self.starting_index = 0 + self.copyphrases: Optional[List[Tuple[str, int]]] = None + self.next_phrase: Optional[str] = None + self.starting_index: int = 0 self.initialize_copy_phrases() @@ -65,37 +94,49 @@ def __init__( self.fake = fake self.experiment_id = experiment_id self.sys_info = self.get_system_info() - self.tasks = [] - self.task_names = [] - self.session_data = [] - self.save_folder = self._init_orchestrator_save_folder(self.parameters["data_save_loc"]) + self.tasks: List[Type[Task]] = [] + self.task_names: List[str] = [] + self.session_data: List[TaskData] = [] + self.save_folder = self._init_orchestrator_save_folder( + self.parameters["data_save_loc"]) self.logger = self._init_orchestrator_logger(self.save_folder) self.alert = alert - self.logger.info("Alerts are on") if self.alert else self.logger.info("Alerts are off") + self.logger.info("Alerts are on") if self.alert else self.logger.info( + "Alerts are off") self.visualize = visualize - self.progress = 0 + self.progress: int = 0 self.ready_to_execute = False self.user_exit = False + self.last_task_dir = None self.logger.info("Session Orchestrator initialized successfully") def add_task(self, task: Type[Task]) -> None: - """Add a task to the orchestrator""" + """Add a single task to the execution queue. + + Args: + task: Task class to add to the queue. + """ self.tasks.append(task) self.task_names.append(task.name) self.ready_to_execute = True def add_tasks(self, tasks: List[Type[Task]]) -> None: - """Add a list of tasks to the orchestrator""" + """Add multiple tasks to the execution queue. + + Args: + tasks: List of task classes to add to the queue. + """ for task in tasks: self.add_task(task) self.ready_to_execute = True def set_next_phrase(self) -> None: - """Set the next phrase to be copied from the list of copy phrases loaded or the parameters directly. + """Set the next phrase for copy phrase tasks. - If there are no more phrases to copy, the task text and spelled letters from parameters will be used. + If there are phrases in the copyphrases list, uses the next one. + Otherwise, uses the task_text from parameters. """ if self.copyphrases: if len(self.copyphrases) > 0: @@ -108,9 +149,9 @@ def set_next_phrase(self) -> None: self.parameters['spelled_letters_count'] = self.starting_index def initialize_copy_phrases(self) -> None: - """Load copy phrases from a json file or take the task text if no file is provided. + """Load copy phrases from a JSON file. - Expects a json file structured as follows: + The JSON file should be structured as: { "Phrases": [ [string, int], @@ -118,8 +159,9 @@ def initialize_copy_phrases(self) -> None: ... ] } + + If no file is provided, uses task_text from parameters. """ - # load copy phrases from json file or take the task text if no file is provided if self.parameters.get('copy_phrases_location'): with open(self.parameters['copy_phrases_location'], 'r') as f: copy_phrases = json.load(f) @@ -132,21 +174,26 @@ def initialize_copy_phrases(self) -> None: self.starting_index = self.parameters['spelled_letters_count'] def execute(self) -> None: - """Executes queued tasks in order""" + """Execute all queued tasks in order. + Raises: + Exception: If no tasks have been added to the queue. + """ if not self.ready_to_execute: msg = "Orchestrator not ready to execute. No tasks have been added." - self.log.error(msg) + self.logger.error(msg) raise Exception(msg) - self.logger.info(f"Session Orchestrator executing tasks in order: {self.task_names}") + self.logger.info( + f"Session Orchestrator executing tasks in order: {self.task_names}") for task in self.tasks: self.progress += 1 if task.mode == TaskMode.COPYPHRASE: self.set_next_phrase() try: # initialize the task save folder and logger - self.logger.info(f"Initializing task {self.progress}/{len(self.tasks)} {task.name}") + self.logger.info( + f"Initializing task {self.progress}/{len(self.tasks)} {task.name}") data_save_location = self._init_task_save_folder(task) self._init_task_logger(data_save_location) @@ -179,13 +226,15 @@ def execute(self) -> None: if self.visualize: # Visualize session data and fail silently if it errors try: - self.logger.info(f"Visualizing session data. Saving to {data_save_location}") + self.logger.info( + f"Visualizing session data. Saving to {data_save_location}") subprocess.run( f'bcipy-erp-viz -s "{data_save_location}" ' f'--parameters "{self.parameters_path}" --show --save', shell=True) except Exception as e: - self.logger.info(f'Error visualizing session data: {e}') + self.logger.info( + f'Error visualizing session data: {e}') initialized_task = None @@ -193,7 +242,7 @@ def execute(self) -> None: self.logger.error(f"Task {task.name} failed to execute") self.logger.exception(e) try: - initialized_task.cleanup() + initialized_task.cleanup() # type: ignore except BaseException: pass @@ -208,25 +257,52 @@ def execute(self) -> None: self.progress = 0 def _init_orchestrator_logger(self, save_folder: str) -> Logger: + """Initialize the session logger. + + Args: + save_folder: Directory to save log files. + + Returns: + Logger: Configured logger instance. + """ return configure_logger( save_folder, PROTOCOL_LOG_FILENAME, logging.DEBUG) def _init_orchestrator_save_folder(self, save_path: str) -> str: + """Initialize the session save directory. + + Args: + save_path: Base path for saving session data. + + Returns: + str: Path to the created save directory. + """ date_time = datetime.now() date = date_time.strftime("%Y-%m-%d") timestamp = date_time.strftime("%Y-%m-%d_%H-%M-%S") - # * No '/' after `save_folder` since it is included in - # * `data_save_location` in parameters path = f'{save_path}{self.user}/{date}/{self.experiment_id}/{timestamp}/' os.makedirs(path) os.makedirs(os.path.join(path, 'logs'), exist_ok=True) return path def _init_task_save_folder(self, task: Type[Task]) -> str: + """Initialize a save directory for a task. + + Args: + task: Task class to create directory for. + + Returns: + str: Path to the created task directory. + + Raises: + OSError: If directory creation fails for reasons other than + the directory already existing. + """ timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - save_directory = self.save_folder + f'{task.name.replace(" ", "_")}_{timestamp}/' + save_directory = self.save_folder + \ + f'{task.name.replace(" ", "_")}_{timestamp}/' try: # make a directory to save task data to os.makedirs(save_directory) @@ -244,49 +320,68 @@ def _init_task_save_folder(self, task: Type[Task]) -> str: "type": "str", } ) - self.parameters.save(save_directory, name=DEFAULT_PARAMETERS_FILENAME) + self.parameters.save( + save_directory, name=DEFAULT_PARAMETERS_FILENAME) except OSError as error: # If the error is anything other than file existing, raise an error if error.errno != errno.EEXIST: raise error + return save_directory def _init_task_logger(self, save_folder: str) -> None: - configure_logger( - save_folder, - SESSION_LOG_FILENAME, - logging.DEBUG) + """Initialize a logger for a task. + + Args: + save_folder: Directory to save task logs. + """ + configure_logger(save_folder, SESSION_LOG_FILENAME, logging.DEBUG) def _save_data(self) -> None: + """Save all session data. - self._save_procotol_data() - # Save the remaining phrase data to a json file to be used in the next session - if self.copyphrases and len(self.copyphrases) > 0: - self._save_copy_phrases() - - def _save_procotol_data(self) -> None: - # Save the protocol data to a json file - with open(f'{self.save_folder}/{PROTOCOL_FILENAME}', 'w') as f: - f.write(json.dumps({ - 'tasks': self.task_names, - 'parameters': self.parameters_path, - 'system_info': self.sys_info, - })) - self.logger.info("Protocol data successfully saved") + Saves protocol data and copy phrases data to their respective files. + """ + self._save_protocol_data() + self._save_copy_phrases() + + def _save_protocol_data(self) -> None: + """Save protocol data to a JSON file. + + Saves task names, system info, and other session metadata. + """ + data = { + 'tasks': self.task_names, + 'sys_info': self.sys_info, + 'parameters': self.parameters_path, + 'user': self.user, + 'experiment_id': self.experiment_id, + 'fake': self.fake + } + with open(os.path.join(self.save_folder, PROTOCOL_FILENAME), 'w') as f: + json.dump(data, f) def _save_copy_phrases(self) -> None: - # Save the copy phrases data to a json file - with open(f'{self.save_folder}/{MULTIPHRASE_FILENAME}', 'w') as f: - f.write(json.dumps({ - 'Phrases': self.copyphrases - })) - self.logger.info("Copy phrases data successfully saved") - - def get_system_info(self) -> dict: + """Save copy phrases data to a JSON file. + + Only saves if copy phrases were used in the session. + """ + if self.copyphrases: + with open(os.path.join(self.save_folder, MULTIPHRASE_FILENAME), 'w') as f: + json.dump({'Phrases': self.copyphrases}, f) + + def get_system_info(self) -> Dict[str, Any]: + """Get system information. + + Returns: + Dict[str, Any]: Dictionary containing system information. + """ return get_system_info() - def close_experiment_callback(self): - """Callback to close the experiment.""" - self.logger.info("User has exited the experiment.") + def close_experiment_callback(self) -> None: + """Callback for handling user-initiated experiment closure. + + Sets the user_exit flag to true to stop task execution. + """ self.user_exit = True diff --git a/bcipy/task/orchestrator/protocol.py b/bcipy/task/orchestrator/protocol.py index 20396c07d..30f680492 100644 --- a/bcipy/task/orchestrator/protocol.py +++ b/bcipy/task/orchestrator/protocol.py @@ -1,77 +1,68 @@ -"""This file can define actions that can happen in a session orchestrator visit. -To start these will be 1:1 with tasks, but later this can be extended to represent training sequences, GUI popups etc""" +"""Protocol handling module for BciPy task orchestration. + +This module provides functionality for parsing and managing task protocols, +which define sequences of actions to be executed in a session. While currently +focused on task sequences, this can be extended to support training sequences, +GUI interactions, and other orchestrated behaviors. +""" from typing import List, Type -from bcipy.config import TASK_SEPERATOR +from bcipy.config import TASK_SEPARATOR from bcipy.task import Task from bcipy.task.registry import TaskRegistry def parse_protocol(protocol: str) -> List[Type[Task]]: - """ - Parses a string of actions into a list of Task objects. - - Converts a string of actions into a list of Task objects. The string is expected - to be in the format of 'Action1 -> Action2 -> ... -> ActionN'. - Parameters - ---------- - protocol : str - A string of actions in the format of 'Action1 -> Action2 -> ... -> ActionN'. - - Returns - ------- - List[TaskType] - A list of TaskType objects that represent the actions in the input string. + """Parse a protocol string into a list of Task classes. + + Converts a string of task names into a list of Task classes. The string + should be in the format 'Task1 -> Task2 -> ... -> TaskN', where each + task name corresponds to a registered task in the TaskRegistry. + + Args: + protocol: String of task names separated by the task separator. + Format: 'Task1 -> Task2 -> ... -> TaskN' + + Returns: + List[Type[Task]]: List of Task classes corresponding to the protocol. + + Raises: + ValueError: If any task name in the protocol is not registered. """ task_registry = TaskRegistry() - return [task_registry.get(item.strip()) for item in protocol.split(TASK_SEPERATOR)] + return [task_registry.get(item.strip()) for item in protocol.split(TASK_SEPARATOR)] def validate_protocol_string(protocol: str) -> None: - """ - Validates a string of actions. + """Validate a protocol string against registered tasks. - Validates a string of actions. The string is expected to be in the format of 'Action1 -> Action2 -> ... -> ActionN'. + Checks that all task names in the protocol string correspond to + registered tasks in the TaskRegistry. - Parameters - ---------- - protocol : str - A string of actions in the format of 'Action1 -> Action2 -> ... -> ActionN'. + Args: + protocol: String of task names separated by the task separator. + Format: 'Task1 -> Task2 -> ... -> TaskN' - Raises - ------ - ValueError - If the string of actions is invalid. + Raises: + ValueError: If any task name in the protocol is not registered. """ - for protocol_item in protocol.split(TASK_SEPERATOR): + for protocol_item in protocol.split(TASK_SEPARATOR): if protocol_item.strip() not in TaskRegistry().list(): - raise ValueError(f"Invalid task '{protocol_item}' name in protocol string.") + raise ValueError( + f"Invalid task '{protocol_item}' name in protocol string.") -def serialize_protocol(protocol: List[Type[Task]]) -> str: - """ - Converts a list of TaskType objects into a string of actions. +def serialize_protocol(tasks: List[Type[Task]]) -> str: + """Convert a list of Task classes into a protocol string. - Converts a list of TaskType objects into a string of actions. The string is in the format of - 'Action1 -> Action2 -> ... -> ActionN'. + Creates a protocol string from a list of Task classes, using the task + separator to join task names. - Parameters - ---------- - protocol : str - A string of actions in the format of 'Action1 -> Action2 -> ... -> ActionN'. + Args: + tasks: List of Task classes to serialize. - Returns - ------- - List[TaskType] - A list of TaskType objects that represent the actions in the input string. + Returns: + str: Protocol string in format 'Task1 -> Task2 -> ... -> TaskN'. """ - - return f" {TASK_SEPERATOR} ".join([item.name for item in protocol]) - - -if __name__ == '__main__': - actions = parse_protocol("Matrix Calibration -> Matrix Copy Phrase") - string = serialize_protocol(actions) - print(actions) - print(string) + return f" {TASK_SEPARATOR} ".join([item.name for item in tasks]) diff --git a/bcipy/task/paradigm/matrix/calibration.py b/bcipy/task/paradigm/matrix/calibration.py index 1ecc355be..851bc7942 100644 --- a/bcipy/task/paradigm/matrix/calibration.py +++ b/bcipy/task/paradigm/matrix/calibration.py @@ -1,3 +1,10 @@ +"""Matrix calibration task module. + +This module provides the Matrix calibration task implementation which performs +Matrix stimulus inquiries to elicit ERPs. The task presents a matrix of stimuli +and highlights them according to configured parameters. +""" + from typing import Any, Dict, List, Optional from psychopy import visual @@ -16,29 +23,41 @@ class MatrixCalibrationTask(BaseCalibrationTask): """Matrix Calibration Task. - Calibration task performs an Matrix stimulus inquiry - to elicit an ERP. Parameters change the number of stimuli - (i.e. the subset of matrix) and for how long they will highlight. - Parameters also change color and text / image inputs. - - A task begins setting up variables --> initializing eeg --> - awaiting user input to start --> - setting up stimuli --> highlighting inquiries --> - saving data - - PARAMETERS: - ---------- - parameters (dict) - file_save (str) - fake (bool) - + This task performs Matrix stimulus inquiries to elicit ERPs by highlighting + elements in a matrix display. Parameters control the number of stimuli, + highlight duration, colors, and text/image inputs. + + Task flow: + 1. Setup variables + 2. Initialize EEG + 3. Await user input + 4. Setup stimuli + 5. Perform highlighting inquiries + 6. Save data + + Attributes: + name: Name of the task. + paradigm: Name of the paradigm. + parameters: Task configuration parameters. + file_save: Path for saving task data. + fake: Whether to run in fake (testing) mode. + window: PsychoPy window for display. + experiment_clock: Task timing clock. + display: Matrix display instance. + symbol_set: Set of symbols to display. """ + name = 'Matrix Calibration' paradigm = 'Matrix' @property def screen_info(self) -> Dict[str, Any]: - """Screen properties""" + """Get screen properties. + + Returns: + Dict[str, Any]: Dictionary containing screen size, refresh rate, + and units information. + """ return { 'screen_size_pixels': self.window.size.tolist(), 'screen_hz': get_screen_info().rate, @@ -46,22 +65,45 @@ def screen_info(self) -> Dict[str, Any]: } def init_display(self) -> MatrixDisplay: - """Initialize the display""" + """Initialize the matrix display. + + Returns: + MatrixDisplay: Configured matrix display instance. + """ return init_matrix_display(self.parameters, self.window, self.experiment_clock, self.symbol_set) def exit_display(self) -> None: + """Clean up display resources and save screenshot. + + Raises: + AssertionError: If display is not a MatrixDisplay instance. + """ assert isinstance(self.display, MatrixDisplay) self.display.capture_grid_screenshot(self.file_save) return super().exit_display() def cleanup(self) -> None: + """Perform cleanup operations and save stimuli position data. + + Raises: + AssertionError: If display is not a MatrixDisplay instance. + """ assert isinstance(self.display, MatrixDisplay) save_stimuli_position_info(self.display.stim_positions, self.file_save, self.screen_info) return super().cleanup() def session_task_data(self) -> Optional[Dict[str, Any]]: + """Get session task data. + + Returns: + Optional[Dict[str, Any]]: Dictionary containing stimuli positions + and screen information. + + Raises: + AssertionError: If display is not a MatrixDisplay instance. + """ assert isinstance(self.display, MatrixDisplay) return {**self.display.stim_positions, **self.screen_info} @@ -69,7 +111,17 @@ def session_task_data(self) -> Optional[Dict[str, Any]]: def init_matrix_display(parameters: Parameters, window: visual.Window, experiment_clock: Clock, symbol_set: List[str]) -> MatrixDisplay: - """Initialize the matrix display""" + """Initialize the matrix display with given parameters. + + Args: + parameters: Task configuration parameters. + window: PsychoPy window for display. + experiment_clock: Task timing clock. + symbol_set: Set of symbols to display. + + Returns: + MatrixDisplay: Configured matrix display instance. + """ info = InformationProperties( info_color=[parameters['info_color']], info_pos=[(parameters['info_pos_x'], parameters['info_pos_y'])], @@ -78,7 +130,8 @@ def init_matrix_display(parameters: Parameters, window: visual.Window, info_text=[parameters['info_text']], ) stimuli = StimuliProperties(stim_font=parameters['font'], - stim_pos=(parameters['matrix_stim_pos_x'], parameters['matrix_stim_pos_y']), + stim_pos=( + parameters['matrix_stim_pos_x'], parameters['matrix_stim_pos_y']), stim_height=parameters['matrix_stim_height'], stim_inquiry=[''] * parameters['stim_length'], stim_colors=[parameters['stim_color']] * diff --git a/bcipy/task/paradigm/matrix/copy_phrase.py b/bcipy/task/paradigm/matrix/copy_phrase.py index fdde27325..fe993e813 100644 --- a/bcipy/task/paradigm/matrix/copy_phrase.py +++ b/bcipy/task/paradigm/matrix/copy_phrase.py @@ -1,4 +1,9 @@ -"""Defines the Copy Phrase Task which uses a Matrix display""" +"""Matrix copy phrase task module. + +This module defines the Copy Phrase Task implementation using a Matrix display. +The task allows users to copy a predefined phrase using a matrix-based interface. +""" + from psychopy import visual from bcipy.core.parameters import Parameters @@ -14,22 +19,23 @@ class MatrixCopyPhraseTask(RSVPCopyPhraseTask): """Matrix Copy Phrase Task. - Initializes and runs all needed code for executing a copy phrase task. A - phrase is set in parameters and necessary objects (daq, display) are - passed to this function. - - Parameters - ---------- - parameters : dict, - configuration details regarding the experiment. See parameters.json - file_save : str, - path location of where to save data from the session - fake : boolean, optional - boolean to indicate whether this is a fake session or not. - Returns - ------- - TaskData + This task allows users to copy a predefined phrase using a matrix-based + interface. The task initializes and runs all necessary components for + executing a copy phrase task. + + Attributes: + name: Name of the task. + paradigm: Name of the paradigm. + mode: Task execution mode. + parameters: Task configuration parameters. + file_save: Path for saving task data. + fake: Whether to run in fake (testing) mode. + window: PsychoPy window for display. + experiment_clock: Task timing clock. + spelled_text: Currently spelled text. + PARAMETERS_USED: List of parameter names used by this task. """ + name = 'Matrix Copy Phrase' paradigm = 'Matrix' mode = TaskMode.COPYPHRASE @@ -100,7 +106,11 @@ class MatrixCopyPhraseTask(RSVPCopyPhraseTask): ] def init_display(self) -> MatrixDisplay: - """Initialize the Matrix display""" + """Initialize the Matrix display. + + Returns: + MatrixDisplay: Configured matrix display instance. + """ return init_display(self.parameters, self.window, self.experiment_clock, self.spelled_text) @@ -110,8 +120,17 @@ def init_display( win: visual.Window, experiment_clock: Clock, starting_spelled_text: str) -> MatrixDisplay: - """Constructs a new Matrix display""" + """Initialize a new Matrix display with given parameters. + + Args: + parameters: Task configuration parameters. + win: PsychoPy window for display. + experiment_clock: Task timing clock. + starting_spelled_text: Initial text to display. + Returns: + MatrixDisplay: Configured matrix display instance. + """ info = InformationProperties( info_color=[parameters['info_color']], info_pos=[(parameters['info_pos_x'], parameters['info_pos_y'])], diff --git a/bcipy/task/paradigm/matrix/timing_verification.py b/bcipy/task/paradigm/matrix/timing_verification.py index 05f8ac0e9..bd225ece6 100644 --- a/bcipy/task/paradigm/matrix/timing_verification.py +++ b/bcipy/task/paradigm/matrix/timing_verification.py @@ -1,3 +1,10 @@ +"""Matrix timing verification module. + +This module provides functionality for verifying display timing in Matrix tasks +using photodiode stimuli. It alternates between solid and empty boxes that can +be measured with a photodiode to ensure accurate stimulus presentation timing. +""" + from itertools import cycle, islice, repeat from typing import Iterator, List @@ -11,32 +18,49 @@ class MatrixTimingVerificationCalibration(MatrixCalibrationTask): """Matrix Timing Verification Task. - This task is used for verifying display timing by alternating solid and empty boxes. These - stimuli can be used with a photodiode to ensure accurate presentations. - - Input: - parameters (Dictionary) - file_save (String) - fake (Boolean) + This task verifies display timing by alternating solid and empty boxes. + The stimuli can be measured with a photodiode to ensure accurate + presentation timing. - Output: - TaskData + Attributes: + name: Name of the task. + mode: Task execution mode. + parameters: Task configuration parameters. + file_save: Path for saving task data. + fake: Whether to run in fake (testing) mode. """ + name = 'Matrix Timing Verification' mode = TaskMode.TIMING_VERIFICATION def init_display(self) -> MatrixDisplay: - """Initialize the display""" + """Initialize the display with transparent background. + + Returns: + MatrixDisplay: Configured matrix display instance. + """ display = super().init_display() display.start_opacity = 0.0 return display @property def symbol_set(self) -> List[str]: - """Symbols used in the calibration""" + """Get symbols used in the calibration. + + Returns: + List[str]: List of symbols with photodiode stimuli inserted. + """ return symbols_with_photodiode_stim(super().symbol_set) def init_inquiry_generator(self) -> Iterator[Inquiry]: + """Initialize the inquiry generator for timing verification. + + The generator alternates between solid and empty boxes with specified + timing parameters. A fixation point is shown between stimuli. + + Returns: + Iterator[Inquiry]: Generator yielding timing verification inquiries. + """ params = self.parameters # alternate between solid and empty boxes @@ -63,11 +87,27 @@ def init_inquiry_generator(self) -> Iterator[Inquiry]: def symbols_with_photodiode_stim(symbols: List[str]) -> List[str]: - """Stim symbols with the central letters swapped out for Photodiode stim. + """Replace central symbols with photodiode stimuli. + + Args: + symbols: List of symbols to modify. + + Returns: + List[str]: Modified list with central symbols replaced by photodiode + stimuli. """ mid = int(len(symbols) / 2) - def sym_at_index(sym, index) -> str: + def sym_at_index(sym: str, index: int) -> str: + """Get symbol at given index, replacing central indices with stimuli. + + Args: + sym: Original symbol. + index: Position in symbol list. + + Returns: + str: Original symbol or photodiode stimulus. + """ if index == mid: return PhotoDiodeStimuli.SOLID.value if index == mid + 1: diff --git a/bcipy/task/paradigm/rsvp/calibration/calibration.py b/bcipy/task/paradigm/rsvp/calibration/calibration.py index efd5ecb54..b00024c41 100644 --- a/bcipy/task/paradigm/rsvp/calibration/calibration.py +++ b/bcipy/task/paradigm/rsvp/calibration/calibration.py @@ -1,3 +1,9 @@ +"""RSVP calibration task module. + +This module provides the RSVP (Rapid Serial Visual Presentation) calibration task +implementation which performs stimulus inquiries to elicit ERPs. The task presents +stimuli in rapid succession with configurable timing and appearance parameters. +""" from psychopy import core, visual from bcipy.core.parameters import Parameters @@ -12,36 +18,59 @@ class RSVPCalibrationTask(BaseCalibrationTask): """RSVP Calibration Task. - Calibration task performs an RSVP stimulus inquiry - to elicit an ERP. Parameters will change how many stimuli - and for how long they present. Parameters also change - color and text / image inputs. - - This task progresses as follows: + This task performs RSVP stimulus inquiries to elicit ERPs by presenting + stimuli in rapid succession. Parameters control the number of stimuli, + presentation duration, colors, and text/image inputs. - setting up variables --> initializing eeg --> awaiting user input to start --> setting up stimuli --> - presenting inquiries --> saving data + Task flow: + 1. Setup variables + 2. Initialize EEG + 3. Await user input + 4. Setup stimuli + 5. Present inquiries + 6. Save data - PARAMETERS: - ---------- - parameters (dict) - file_save (str) - fake (bool) + Attributes: + name (str): Name of the task. + paradigm (str): Name of the paradigm. + parameters (Parameters): Task configuration parameters. + file_save (str): Path for saving task data. + fake (bool): Whether to run in fake (testing) mode. + window (visual.Window): PsychoPy window for display. + static_clock (core.StaticPeriod): Clock for static timing. + experiment_clock (Clock): Clock for experiment timing. """ - name = 'RSVP Calibration' - paradigm = 'RSVP' + + name: str = 'RSVP Calibration' + paradigm: str = 'RSVP' def init_display(self) -> Display: + """Initialize the RSVP display. + + Returns: + Display: Configured RSVP calibration display instance. + """ return init_calibration_display_task(self.parameters, self.window, self.static_clock, self.experiment_clock) def init_calibration_display_task( - parameters: Parameters, window: visual.Window, + parameters: Parameters, + window: visual.Window, static_clock: core.StaticPeriod, experiment_clock: Clock) -> CalibrationDisplay: - """Initialize the display""" + """Initialize the RSVP calibration display. + + Args: + parameters (Parameters): Task configuration parameters. + window (visual.Window): PsychoPy window for display. + static_clock (core.StaticPeriod): Clock for static timing. + experiment_clock (Clock): Clock for experiment timing. + + Returns: + CalibrationDisplay: Configured RSVP calibration display instance. + """ info = InformationProperties( info_color=[parameters['info_color']], info_pos=[(parameters['info_pos_x'], parameters['info_pos_y'])], @@ -51,7 +80,8 @@ def init_calibration_display_task( ) stimuli = StimuliProperties( stim_font=parameters['font'], - stim_pos=(parameters['rsvp_stim_pos_x'], parameters['rsvp_stim_pos_y']), + stim_pos=(parameters['rsvp_stim_pos_x'], + parameters['rsvp_stim_pos_y']), stim_height=parameters['rsvp_stim_height'], stim_inquiry=[''] * parameters['stim_length'], stim_colors=[parameters['stim_color']] * parameters['stim_length'], @@ -72,7 +102,8 @@ def init_calibration_display_task( stimuli, task_bar, info, - preview_config=parameters.instantiate(PreviewParams), + preview_config=parameters.instantiate( + PreviewParams), trigger_type=parameters['trigger_type'], space_char=parameters['stim_space_char'], full_screen=parameters['full_screen']) diff --git a/bcipy/task/paradigm/rsvp/calibration/timing_verification.py b/bcipy/task/paradigm/rsvp/calibration/timing_verification.py index f7411189b..579369359 100644 --- a/bcipy/task/paradigm/rsvp/calibration/timing_verification.py +++ b/bcipy/task/paradigm/rsvp/calibration/timing_verification.py @@ -1,4 +1,11 @@ # mypy: disable-error-code="assignment" +"""RSVP timing verification module. + +This module provides functionality for verifying display timing in RSVP tasks +using photodiode stimuli. It alternates between solid and empty boxes that can +be measured with a photodiode to ensure accurate stimulus presentation timing. +""" + from itertools import cycle, islice, repeat from typing import Any, Iterator, List @@ -11,19 +18,20 @@ class RSVPTimingVerificationCalibration(RSVPCalibrationTask): - """RSVP Calibration Task. + """RSVP Timing Verification Task. - This task is used for verifying display timing by alternating solid and empty boxes. These - stimuli can be used with a photodiode to ensure accurate presentations. + This task verifies display timing by alternating solid and empty boxes. + The stimuli can be measured with a photodiode to ensure accurate + presentation timing. - Input: - parameters (Parameters) - file_save (str) - fake (bool) - - Output: - TaskData + Attributes: + name: Name of the task. + mode: Task execution mode. + parameters: Task configuration parameters. + file_save: Path for saving task data. + fake: Whether to run in fake (testing) mode. """ + name = 'RSVP Timing Verification' mode = TaskMode.TIMING_VERIFICATION @@ -32,6 +40,14 @@ def __init__(self, file_save: str, fake: bool = False, **kwargs: Any) -> None: + """Initialize the RSVP timing verification task. + + Args: + parameters: Task configuration parameters. + file_save: Path for saving task data. + fake: Whether to run in fake (testing) mode. + **kwargs: Additional keyword arguments. + """ parameters['rsvp_stim_height'] = 0.8 parameters['rsvp_stim_pos_y'] = 0.0 super(RSVPTimingVerificationCalibration, @@ -39,10 +55,22 @@ def __init__(self, @property def symbol_set(self) -> List[str]: - """Symbols used in the calibration""" + """Get symbols used in the calibration. + + Returns: + List[str]: List of photodiode stimuli symbols. + """ return PhotoDiodeStimuli.list() def init_inquiry_generator(self) -> Iterator[Inquiry]: + """Initialize the inquiry generator for timing verification. + + The generator alternates between solid and empty boxes with specified + timing parameters. A fixation point is shown between stimuli. + + Returns: + Iterator[Inquiry]: Generator yielding timing verification inquiries. + """ params = self.parameters # alternate between solid and empty boxes diff --git a/bcipy/task/paradigm/rsvp/copy_phrase.py b/bcipy/task/paradigm/rsvp/copy_phrase.py index 877262571..68f401f85 100644 --- a/bcipy/task/paradigm/rsvp/copy_phrase.py +++ b/bcipy/task/paradigm/rsvp/copy_phrase.py @@ -1,4 +1,4 @@ -# mypy: disable-error-code="arg-type" +# mypy: disable-error-code="arg-type, override" import logging from typing import Any, List, NamedTuple, Optional, Tuple @@ -178,8 +178,10 @@ def __init__( self.button_press_error_prob = parameters['preview_inquiry_error_prob'] self.signal_model = self.signal_models[0] if self.signal_models else None - self.evidence_evaluators = self.init_evidence_evaluators(self.signal_models) - self.evidence_types = self.init_evidence_types(self.signal_models, self.evidence_evaluators) + self.evidence_evaluators = self.init_evidence_evaluators( + self.signal_models) + self.evidence_types = self.init_evidence_types( + self.signal_models, self.evidence_evaluators) self.file_save = file_save self.save_session_every_inquiry = True @@ -201,6 +203,7 @@ def setup( parameters: Parameters, data_save_location: str, fake: bool = False) -> Tuple[ClientManager, List[LslDataServer], Window]: + """Set up acquisition and return client manager, data servers, and display.""" # Initialize Acquisition daq, servers = init_acquisition( parameters, data_save_location, server=fake) @@ -212,9 +215,11 @@ def setup( return daq, servers, display def get_language_model(self) -> LanguageModel: + """Return the initialized language model.""" return init_language_model(self.parameters) def get_signal_models(self) -> Optional[List[SignalModel]]: + """Return the list of signal models, or an empty list if fake.""" if not self.fake: try: signal_models = choose_signal_models( @@ -227,6 +232,7 @@ def get_signal_models(self) -> Optional[List[SignalModel]]: return [] def cleanup(self): + """Clean up resources and save session data.""" self.exit_display() self.write_offset_trigger() self.save_session_data() @@ -256,6 +262,7 @@ def cleanup(self): logger.exception(str(e)) def save_session_data(self) -> None: + """Save the session data and summary to disk.""" self.session.task_summary = TaskSummary( self.session, self.parameters["show_preview_inquiry"], @@ -303,6 +310,7 @@ def init_evidence_types( self, signal_models: List[SignalModel], evidence_evaluators: List[EvidenceEvaluator] ) -> List[EvidenceType]: + """Initialize evidence types for the simulation.""" evidence_types = [EvidenceType.LM] evidence_types.extend( [evaluator.produces for evaluator in evidence_evaluators]) @@ -318,7 +326,8 @@ def default_trigger_handler(self) -> TriggerHandler: def set(self) -> None: """Initialize/reset parameters used in the execute run loop.""" - self.spelled_text = str(self.copy_phrase[0: self.starting_spelled_letters()]) + self.spelled_text = str( + self.copy_phrase[0: self.starting_spelled_letters()]) self.last_selection = "" self.inq_counter = 0 self.session = Session( @@ -364,13 +373,15 @@ def validate_parameters(self) -> None: # ensure all required parameters are provided for param in RSVPCopyPhraseTask.PARAMETERS_USED: if param not in self.parameters: - raise TaskConfigurationException(f"parameter '{param}' is required") + raise TaskConfigurationException( + f"parameter '{param}' is required") # ensure data / query parameters are set correctly buffer_len = self.parameters["task_buffer_length"] prestim = self.parameters["prestim_length"] poststim = ( - self.parameters["trial_window"][1] - self.parameters["trial_window"][0] + self.parameters["trial_window"][1] - + self.parameters["trial_window"][0] ) if buffer_len < prestim: raise TaskConfigurationException( @@ -841,7 +852,8 @@ def exit_display(self) -> None: self.rsvp.update_task_bar(text=self.spelled_text) # Say Goodbye! - self.rsvp.info_text = trial_complete_message(self.window, self.parameters) + self.rsvp.info_text = trial_complete_message( + self.window, self.parameters) self.rsvp.draw_static() self.window.flip() @@ -911,9 +923,11 @@ def write_trigger_data( for content_type, client in self.daq.clients_by_type.items(): label = offset_label(content_type.name) time = ( - client.offset(self.rsvp.first_stim_time) - self.rsvp.first_stim_time + client.offset(self.rsvp.first_stim_time) - + self.rsvp.first_stim_time ) - offset_triggers.append(Trigger(label, TriggerType.OFFSET, time)) + offset_triggers.append( + Trigger(label, TriggerType.OFFSET, time)) self.trigger_handler.add_triggers(offset_triggers) triggers = convert_timing_triggers( @@ -975,16 +989,19 @@ def __init__( def as_dict(self) -> dict: """Computes the task summary data to append to the session.""" - selections = [inq for inq in self.session.all_inquiries if inq.selection] + selections = [ + inq for inq in self.session.all_inquiries if inq.selection] correct = [inq for inq in selections if inq.is_correct_decision] incorrect = [inq for inq in selections if not inq.is_correct_decision] # Note that SPACE is considered a symbol - correct_symbols = [inq for inq in correct if inq.selection != BACKSPACE_CHAR] + correct_symbols = [ + inq for inq in correct if inq.selection != BACKSPACE_CHAR] btn_presses = self.btn_press_count() sel_count = len(selections) - switch_per_selection = (btn_presses / sel_count) if sel_count > 0 else 0 + switch_per_selection = ( + btn_presses / sel_count) if sel_count > 0 else 0 accuracy = (len(correct) / sel_count) if sel_count > 0 else 0 # Note that minutes includes startup time and any breaks. @@ -1002,9 +1019,7 @@ def as_dict(self) -> dict: } def btn_press_count(self) -> int: - """Compute the number of times the switch was activated. Returns 0 if - inquiry preview mode was off or mode was preview-only.""" - + """Compute the number of times the switch was activated. Returns 0 if inquiry preview mode was off or mode was preview-only.""" if not self.show_preview or self.preview_mode == 0: return 0 @@ -1034,7 +1049,8 @@ def switch_response_time(self) -> Optional[float]: logger.info("Could not compute switch_response_time") return None - response_times = [keypress.time - preview.time for preview, keypress in pairs] + response_times = [keypress.time - + preview.time for preview, keypress in pairs] count = len(response_times) return sum(response_times) / count if count > 0 else None @@ -1066,7 +1082,8 @@ def _init_copy_phrase_display( ) stimuli = StimuliProperties( stim_font=parameters["font"], - stim_pos=(parameters["rsvp_stim_pos_x"], parameters["rsvp_stim_pos_y"]), + stim_pos=(parameters["rsvp_stim_pos_x"], + parameters["rsvp_stim_pos_y"]), stim_height=parameters["rsvp_stim_height"], stim_inquiry=["A"] * parameters["stim_length"], stim_colors=[parameters["stim_color"]] * parameters["stim_length"], diff --git a/bcipy/task/paradigm/vep/calibration.py b/bcipy/task/paradigm/vep/calibration.py index a7c54a18b..52ad1c6b8 100644 --- a/bcipy/task/paradigm/vep/calibration.py +++ b/bcipy/task/paradigm/vep/calibration.py @@ -1,4 +1,10 @@ -"""VEP Calibration task-related code""" +"""VEP calibration task module. + +This module provides the VEP (Visual Evoked Potential) calibration task +implementation. The task presents visual stimuli with different flicker rates +to calibrate the system's response to visual evoked potentials. +""" + import logging from typing import Any, Dict, Iterator, List, Optional @@ -24,17 +30,27 @@ class VEPCalibrationTask(BaseCalibrationTask): """VEP Calibration Task. - A task begins setting up variables --> initializing eeg --> - awaiting user input to start --> - setting up stimuli --> highlighting inquiries --> - saving data + This task calibrates the system's response to visual evoked potentials by + presenting visual stimuli with different flicker rates. + + Task flow: + 1. Setup variables + 2. Initialize EEG + 3. Await user input + 4. Setup stimuli + 5. Present flickering inquiries + 6. Save data - PARAMETERS: - ---------- - parameters (dict) - file_save (str) - fake (bool) + Attributes: + name: Name of the task. + paradigm: Name of the paradigm. + parameters: Task configuration parameters. + file_save: Path for saving task data. + fake: Whether to run in fake (testing) mode. + box_colors: List of colors for stimulus boxes. + num_boxes: Number of stimulus boxes. """ + name = 'VEP Calibration' paradigm = 'VEP' @@ -43,6 +59,14 @@ def __init__(self, file_save: str, fake: bool = False, **kwargs: Any) -> None: + """Initialize the VEP calibration task. + + Args: + parameters: Task configuration parameters. + file_save: Path for saving task data. + fake: Whether to run in fake (testing) mode. + **kwargs: Additional keyword arguments. + """ self.box_colors = [ '#00FF80', '#FFFFB3', '#CB99FF', '#FB8072', '#80B1D3', '#FF8232' ] @@ -50,14 +74,22 @@ def __init__(self, super().__init__(parameters, file_save, fake=fake, **kwargs) def init_display(self) -> VEPDisplay: - """Initialize the display""" + """Initialize the VEP display. + + Returns: + VEPDisplay: Configured VEP display instance. + """ return init_vep_display(self.parameters, self.window, self.experiment_clock, self.symbol_set, self.box_colors, fake=self.fake) def init_inquiry_generator(self) -> Iterator[Inquiry]: - """Initializes a generator that returns inquiries to be presented.""" + """Initialize the inquiry generator. + + Returns: + Iterator[Inquiry]: Generator yielding VEP calibration inquiries. + """ parameters = self.parameters schedule = generate_vep_calibration_inquiries( alp=self.symbol_set, @@ -75,12 +107,30 @@ def init_inquiry_generator(self) -> Iterator[Inquiry]: def trigger_type(self, symbol: str, target: str, index: int) -> TriggerType: + """Get trigger type for a symbol. + + Args: + symbol: Presented symbol. + target: Target symbol. + index: Position in sequence. + + Returns: + TriggerType: Type of trigger to use. + """ if target == symbol: return TriggerType.TARGET return TriggerType.EVENT def session_task_data(self) -> Dict[str, Any]: - """Task-specific session data""" + """Get task-specific session data. + + Returns: + Dict[str, Any]: Dictionary containing box configurations and + starting positions. + + Raises: + AssertionError: If display is not a VEPDisplay instance. + """ assert isinstance(self.display, VEPDisplay) boxes = [{ "colors": box.colors, @@ -94,7 +144,18 @@ def session_task_data(self) -> Dict[str, Any]: def session_inquiry_data(self, inquiry: Inquiry) -> Optional[Dict[str, Any]]: - """Defines task-specific session data for each inquiry.""" + """Get task-specific data for an inquiry. + + Args: + inquiry: Inquiry to get data for. + + Returns: + Optional[Dict[str, Any]]: Dictionary containing target box index + and frequency. + + Raises: + AssertionError: If display is not a VEPDisplay instance. + """ assert isinstance(self.display, VEPDisplay) target_box = target_box_index(inquiry) target_freq = self.display.flicker_rates[ @@ -105,7 +166,14 @@ def session_inquiry_data(self, } def stim_labels(self, inquiry: Inquiry) -> List[str]: - """labels for each stimuli in the session data.""" + """Get labels for each stimulus in the session data. + + Args: + inquiry: Inquiry to get labels for. + + Returns: + List[str]: List of stimulus labels. + """ target_box = target_box_index(inquiry) targetness = [TriggerType.NONTARGET for _ in range(self.num_boxes)] if target_box is not None: @@ -115,7 +183,14 @@ def stim_labels(self, inquiry: Inquiry) -> List[str]: def target_box_index(inquiry: Inquiry) -> Optional[int]: - """Index of the target box.""" + """Get the index of the target box. + + Args: + inquiry: Inquiry to find target box in. + + Returns: + Optional[int]: Index of target box if found, None otherwise. + """ target_letter, _fixation, *boxes = inquiry.stimuli for i, box in enumerate(boxes): if target_letter in box: @@ -126,7 +201,19 @@ def target_box_index(inquiry: Inquiry) -> Optional[int]: def init_vep_display(parameters: Parameters, window: visual.Window, experiment_clock: Clock, symbol_set: List[str], box_colors: List[str], fake: bool = False) -> VEPDisplay: - """Initialize the display""" + """Initialize the VEP display. + + Args: + parameters: Task configuration parameters. + window: PsychoPy window for display. + experiment_clock: Clock for experiment timing. + symbol_set: Set of symbols to display. + box_colors: List of colors for stimulus boxes. + fake: Whether to run in fake (testing) mode. + + Returns: + VEPDisplay: Configured VEP display instance. + """ info = InformationProperties( info_color=[parameters['info_color']], info_pos=[(parameters['info_pos_x'], parameters['info_pos_y'])], diff --git a/bcipy/task/paradigm/vep/stim_generation.py b/bcipy/task/paradigm/vep/stim_generation.py index b39a173c4..aed53e663 100644 --- a/bcipy/task/paradigm/vep/stim_generation.py +++ b/bcipy/task/paradigm/vep/stim_generation.py @@ -1,4 +1,10 @@ -"""Functions related to stimuli generation for VEP tasks""" +"""VEP stimulus generation module. + +This module provides functions for generating visual stimuli used in VEP +(Visual Evoked Potential) tasks. It handles the creation of calibration +inquiries, stimulus box configurations, and inquiry schedules. +""" + import itertools import math import random @@ -15,30 +21,25 @@ def generate_vep_calibration_inquiries(alp: List[str], inquiry_count: int = 100, num_boxes: int = 4, is_txt: bool = True) -> InquirySchedule: - """ - Generates VEP inquiries with target letters in all possible positions. + """Generate VEP inquiries with target letters in all possible positions. In the VEP paradigm, all stimuli in the alphabet are displayed in each inquiry. The symbols with the highest likelihoods are displayed alone while those with lower likelihoods occur together. - Parameters - ---------- - alp(list[str]): stimuli - timing(list[float]): Task specific timing for generator. - [target, fixation, stimuli] - color(list[str]): Task specific color for generator - [target, fixation, stimuli] - inquiry_count(int): number of inquiries in a calibration - num_boxes(int): number of display boxes - is_txt(bool): whether the stimuli type is text. False would be an image stimuli. - - Return - ------ - schedule_inq(tuple( - samples[list[list[str]]]: list of inquiries - timing(list[list[float]]): list of timings - color(list(list[str])): list of colors)): scheduled inquiries + Args: + alp: List of stimuli. + timing: Task specific timing for generator [target, fixation, stimuli]. + color: Task specific color for generator [target, fixation, stimuli]. + inquiry_count: Number of inquiries in a calibration. + num_boxes: Number of display boxes. + is_txt: Whether the stimuli type is text (False for image stimuli). + + Returns: + InquirySchedule: Schedule containing inquiries, timings, and colors. + + Raises: + AssertionError: If timing list does not contain exactly 3 values. """ if timing is None: timing = [0.5, 1, 2] @@ -64,7 +65,20 @@ def generate_vep_inquiries(symbols: List[str], num_boxes: int = 6, inquiry_count: int = 20, is_txt: bool = True) -> List[List[Any]]: - """Generates inquiries""" + """Generate a list of VEP inquiries. + + Args: + symbols: List of symbols to use in inquiries. + num_boxes: Number of display boxes. + inquiry_count: Number of inquiries to generate. + is_txt: Whether the stimuli type is text. + + Returns: + List[List[Any]]: List of inquiries, where each inquiry contains: + - Target symbol + - Fixation point + - List of symbols for each box + """ fixation = get_fixation(is_txt) target_indices = random_target_positions(inquiry_count, stim_per_inquiry=num_boxes, @@ -86,44 +100,29 @@ def stim_per_box(num_symbols: int, num_boxes: int = 6, max_empty_boxes: int = 0, max_single_sym_boxes: int = 4) -> List[int]: - """Determine the number of stimuli per vep box. - - Parameters - ---------- - num_symbols - number of symbols - num_boxes - number of boxes - max_empty_boxes - the maximum number of boxes which won't have any - symbols within them. - max_single_sym_boxes - maximum number of boxes with a single symbol - - Returns - ------- - A list of length num_boxes, where each number in the list represents - the number of symbols that should be in the box at that position. - - Post conditions: - The sum of the list should equal num_symbols. Further, there should - be at most max_empty_boxes with value of 0 and max_single_sym_boxes - with a value of 1. + """Determine the number of stimuli per VEP box. + + This function distributes symbols across boxes based on rules derived from + example sessions. It ensures a balanced distribution while allowing for + some empty boxes and boxes with single symbols. + + Args: + num_symbols: Number of symbols to distribute. + num_boxes: Number of boxes to distribute symbols across. + max_empty_boxes: Maximum number of boxes that can be empty. + max_single_sym_boxes: Maximum number of boxes that can have a single symbol. + + Returns: + List[int]: List where each number represents the number of symbols + that should be in the box at that position. + + Notes: + - The sum of the returned list equals num_symbols + - There will be at most max_empty_boxes with value 0 + - There will be at most max_single_sym_boxes with value 1 + - Distribution is based on example sessions from: + https://www.youtube.com/watch?v=JNFYSeIIOrw """ - # Logic based off of example sessions from: - # https://www.youtube.com/watch?v=JNFYSeIIOrw - # [[2, 3, 5, 5, 6, 7], - # [2, 1, 10, 1, 1, 13], - # [3, 4, 17, 1, 1, 2], - # [1, 1, 1, 0, 1, 24], - # [1, 2, 1, 22, 1, 1], - # [2, 1, 1, 21, 2, 1], - # [1, 1, 25, 0, 1, 0]] - # and - # [[7, 3, 4, 9, 2, 3], - # 1, 1, 6, 9, 7, 2], - # 1, 2, 18, 2, 3, 2], - # 1, 1, 4, 4, 17, 1], - # 1, 3, 1, 1, 20, 2], - # 1, 1, 1, 20, 3, 2], - # 1, 1, 1, 4, 21, 0]] - if max_empty_boxes + max_single_sym_boxes >= num_boxes: max_empty_boxes = 0 max_single_sym_boxes = num_boxes - 1 @@ -160,26 +159,24 @@ def generate_vep_inquiry(alphabet: List[str], num_boxes: int = 6, target: Optional[str] = None, target_pos: Optional[int] = None) -> List[List[str]]: - """Generates a single random inquiry. - - Parameters - ---------- - alphabet - list of symbols from which to select. - num_boxes - number of display areas; symbols will be partitioned into - these areas. - target - target symbol for the generated inquiry - target_pos - box index that should contain the target - - Returns - ------- - An inquiry represented by a list of lists, where each sublist - represents a display box and contains symbols that should appear in that box. - - Post-conditions: - Symbols will not be repeated and all symbols will be partitioned into - one of the boxes. + """Generate a single random VEP inquiry. + + Args: + alphabet: List of symbols to select from. + num_boxes: Number of display areas to partition symbols into. + target: Target symbol for the inquiry. + target_pos: Box index that should contain the target. + + Returns: + List[List[str]]: List of lists where each sublist represents a display + box and contains symbols that should appear in that box. + + Notes: + - Symbols will not be repeated + - All symbols will be partitioned into one of the boxes + - If target is specified, it will be placed in the lowest count box + greater than 0 """ - box_counts = stim_per_box(num_symbols=len(alphabet), num_boxes=num_boxes) assert len(box_counts) == num_boxes syms = [sym for sym in alphabet] @@ -189,6 +186,7 @@ def generate_vep_inquiry(alphabet: List[str], # Move the target to the front so it gets put in the lowest count box # greater than 0. syms = swapped(syms, index1=0, index2=syms.index(target)) + # Put syms in boxes boxes = [] sym_index = 0 diff --git a/bcipy/task/registry.py b/bcipy/task/registry.py index 58ad32cff..035b55bbb 100644 --- a/bcipy/task/registry.py +++ b/bcipy/task/registry.py @@ -1,50 +1,108 @@ -"""Task Registry ; used to provide task options to the GUI and command line -tools. User defined tasks can be added to the Registry.""" -from typing import Dict, List, Type +"""Task Registry module for managing BciPy tasks. + +This module provides a registry system for BCI tasks, allowing tasks to be +dynamically discovered and accessed by the GUI and command line tools. +User-defined tasks can be added to the Registry. +""" + +from typing import Dict, List, Type, TypeVar from bcipy.task import Task +# Type variable for Task subclasses +T = TypeVar('T', bound=Task) + class TaskRegistry: - registry_dict: Dict[str, Type[Task]] + """Registry for managing and accessing BCI tasks. + + This class maintains a registry of all available task types in the system. + It automatically discovers and registers all non-abstract Task subclasses + when initialized, and provides methods for accessing and managing tasks. - def __init__(self): - # Collects all non-abstract subclasses of Task. type ignore is used to work around a mypy bug - # https://github.com/python/mypy/issues/3115 + Attributes: + registry_dict: Dictionary mapping task names to task classes. + """ + + def __init__(self) -> None: + """Initialize the task registry. + + Collects all non-abstract subclasses of Task and registers them. + Imports task modules to ensure all tasks are discovered. + """ + # Import task modules to ensure all tasks are discovered from bcipy.task import actions # noqa from bcipy.task.paradigm import matrix, rsvp, vep # noqa - self.registry_dict = {} + self.registry_dict: Dict[str, Type[Task]] = {} self.collect_subclasses(Task) # type: ignore[type-abstract] - def collect_subclasses(self, cls: Type[Task]): - """Recursively collects all non-abstract subclasses of the given class and adds them to the registry.""" + def collect_subclasses(self, cls: Type[T]) -> None: + """Recursively collect and register non-abstract subclasses. + + Args: + cls: The base class to collect subclasses from. + + Note: + Subclasses are only registered if they have no abstract methods. + """ for sub_class in cls.__subclasses__(): + # Only register non-abstract subclasses if not getattr(sub_class, '__abstractmethods__', False): - self.registry_dict[sub_class.name] = sub_class + if hasattr(sub_class, 'name'): + self.registry_dict[sub_class.name] = sub_class + else: + raise ValueError(f'Task class {sub_class} missing name attribute') self.collect_subclasses(sub_class) def get(self, task_name: str) -> Type[Task]: - """Returns a task type based on its name property.""" + """Get a task class by its name. + + Args: + task_name: Name of the task to retrieve. + + Returns: + Type[Task]: The task class. + + Raises: + ValueError: If the task name is not registered. + """ if task_name in self.registry_dict: return self.registry_dict[task_name] raise ValueError(f'{task_name} not a registered task') def get_all_types(self) -> List[Type[Task]]: - """Returns a list of all registered tasks.""" + """Get all registered task classes. + + Returns: + List[Type[Task]]: List of all registered task classes. + """ return list(self.registry_dict.values()) def list(self) -> List[str]: - """Returns a list of all registered task names.""" + """Get names of all registered tasks. + + Returns: + List[str]: List of registered task names. + """ return list(self.registry_dict.keys()) def calibration_tasks(self) -> List[Type[Task]]: - """Returns a list of all registered calibration tasks.""" + """Get all registered calibration tasks. + + Returns: + List[Type[Task]]: List of registered calibration task classes. + """ from bcipy.task.calibration import BaseCalibrationTask return [task for task in self.get_all_types() if issubclass(task, BaseCalibrationTask)] def register_task(self, task: Type[Task]) -> None: - """Registers a task with the TaskRegistry.""" - # Note that all imported tasks are automatically registered when the TaskRegistry is initialized. This - # method allows for the registration of additional tasks after initialization. + """Register a new task with the registry. + + This method allows registration of additional tasks after initialization. + Tasks imported during initialization are automatically registered. + + Args: + task: The task class to register. + """ self.registry_dict[task.name] = task diff --git a/bcipy/task/tests/core/test_actions.py b/bcipy/task/tests/core/test_actions.py index 8f0233338..d4442f4b2 100644 --- a/bcipy/task/tests/core/test_actions.py +++ b/bcipy/task/tests/core/test_actions.py @@ -48,7 +48,8 @@ def test_code_hook_action_no_subprocess(self) -> None: def test_offline_analysis_action(self) -> None: cmd_expected = f'bcipy-train -p "{self.parameters_path}"' - when(subprocess).run(cmd_expected, shell=True, check=True).thenReturn(None) + when(subprocess).run(cmd_expected, + shell=True, check=True).thenReturn(None) action = OfflineAnalysisAction( parameters=self.parameters, data_directory=self.data_directory, @@ -60,7 +61,8 @@ def test_offline_analysis_action(self) -> None: def test_experiment_field_collection_action(self) -> None: experiment_id = 'experiment_id' - when(actions).start_experiment_field_collection_gui(experiment_id, self.data_directory).thenReturn(None) + when(actions).start_experiment_field_collection_gui( + experiment_id, self.data_directory).thenReturn(None) action = ExperimentFieldCollectionAction( parameters=self.parameters, data_directory=self.data_directory, @@ -69,7 +71,8 @@ def test_experiment_field_collection_action(self) -> None: task_data = action.execute() self.assertIsNotNone(task_data) self.assertIsInstance(task_data, TaskData) - verify(actions, times=1).start_experiment_field_collection_gui(experiment_id, self.data_directory) + verify(actions, times=1).start_experiment_field_collection_gui( + experiment_id, self.data_directory) if __name__ == '__main__': diff --git a/bcipy/task/tests/core/test_handler.py b/bcipy/task/tests/core/test_handler.py index c87e76551..020527198 100644 --- a/bcipy/task/tests/core/test_handler.py +++ b/bcipy/task/tests/core/test_handler.py @@ -157,7 +157,8 @@ def test_prepare_stimuli(self): len(stimuli[0][0])) for i in range(1, len(stimuli[0][0])): self.assertIn(stimuli[0][0][i], self.decision_maker.alphabet) - self.assertEqual(stimuli[1][0][0:2], self.decision_maker.stimuli_timing) + self.assertEqual(stimuli[1][0][0:2], + self.decision_maker.stimuli_timing) class TestDecisionMakerOld(unittest.TestCase): diff --git a/bcipy/task/tests/core/test_task_main.py b/bcipy/task/tests/core/test_task_main.py deleted file mode 100644 index 3b29b666a..000000000 --- a/bcipy/task/tests/core/test_task_main.py +++ /dev/null @@ -1,58 +0,0 @@ -import unittest - -from bcipy.task import Task, TaskData, TaskMode - - -class TestTask(unittest.TestCase): - - def test_task_fails_without_name(self): - mode = TaskMode.CALIBRATION - - class TestTask(Task): - - def execute(self) -> TaskData: - ... - - with self.assertRaises(AssertionError): - TestTask(mode=mode) - - def test_task_fails_without_mode(self): - name = "test task" - - class TestTask(Task): - - def execute(self) -> TaskData: - ... - - with self.assertRaises(AssertionError): - TestTask(name=name) - - def test_task_fails_without_execute(self): - name = "test task" - mode = TaskMode.CALIBRATION - - class TestTask(Task): - pass - - with self.assertRaises(TypeError): - TestTask(name=name, mode=mode) - - def test_task_initializes(self): - name = "test task" - mode = TaskMode.CALIBRATION - - class TestTask(Task): - - def __init__(self, name: str, mode: TaskMode): - self.name = name - self.mode = mode - - def execute(self) -> TaskData: - ... - task = TestTask(name=name, mode=mode) - self.assertEqual(task.name, name) - self.assertEqual(task.mode, mode) - - -if __name__ == '__main__': - unittest.main() diff --git a/bcipy/task/tests/orchestrator/test_orchestrator.py b/bcipy/task/tests/orchestrator/test_orchestrator.py index 2e220acc6..3e14699e7 100644 --- a/bcipy/task/tests/orchestrator/test_orchestrator.py +++ b/bcipy/task/tests/orchestrator/test_orchestrator.py @@ -27,8 +27,10 @@ def test_orchestrator_add_task(self) -> None: task = mock(spec=Task) task.name = "test task" task.mode = "test mode" - when(SessionOrchestrator)._init_orchestrator_save_folder(any()).thenReturn() - when(SessionOrchestrator)._init_orchestrator_logger(any()).thenReturn(self.logger) + when(SessionOrchestrator)._init_orchestrator_save_folder( + any()).thenReturn() + when(SessionOrchestrator)._init_orchestrator_logger( + any()).thenReturn(self.logger) orchestrator = SessionOrchestrator() self.assertTrue(orchestrator.tasks == []) orchestrator.add_task(task) @@ -45,8 +47,10 @@ def test_orchestrator_add_tasks(self) -> None: task2.name = "test task" task2.mode = "test mode" tasks = [task1, task2] - when(SessionOrchestrator)._init_orchestrator_save_folder(any()).thenReturn() - when(SessionOrchestrator)._init_orchestrator_logger(any()).thenReturn(self.logger) + when(SessionOrchestrator)._init_orchestrator_save_folder( + any()).thenReturn() + when(SessionOrchestrator)._init_orchestrator_logger( + any()).thenReturn(self.logger) orchestrator = SessionOrchestrator() self.assertTrue(orchestrator.tasks == []) orchestrator.add_tasks(tasks) @@ -63,8 +67,10 @@ def test_orchestrator_execute(self) -> None: task.name = "test task" task.mode = "test mode" task.execute = lambda: TaskData() - when(SessionOrchestrator)._init_orchestrator_save_folder(any()).thenReturn() - when(SessionOrchestrator)._init_orchestrator_logger(any()).thenReturn(self.logger) + when(SessionOrchestrator)._init_orchestrator_save_folder( + any()).thenReturn() + when(SessionOrchestrator)._init_orchestrator_logger( + any()).thenReturn(self.logger) when(SessionOrchestrator)._init_task_save_folder(any()).thenReturn() when(SessionOrchestrator)._init_task_logger(any()).thenReturn() when(SessionOrchestrator)._save_data().thenReturn() @@ -105,12 +111,15 @@ def test_orchestrator_execute(self) -> None: @mock_open(read_data='{"Phrases": []}') def test_orchestrator_multiple_copyphrases_loads_from_parameters_when_set(self, mock_file): - parameters = load_json_parameters(self.parameter_location, value_cast=True) + parameters = load_json_parameters( + self.parameter_location, value_cast=True) copy_phrase_location = "bcipy/parameters/experiments/phrases.json" parameters['copy_phrases_location'] = copy_phrase_location mock_copy_phrases = {"Phrases": [["test", 0], ["test2", 1]]} - when(SessionOrchestrator)._init_orchestrator_save_folder(any()).thenReturn() - when(SessionOrchestrator)._init_orchestrator_logger(any()).thenReturn(self.logger) + when(SessionOrchestrator)._init_orchestrator_save_folder( + any()).thenReturn() + when(SessionOrchestrator)._init_orchestrator_logger( + any()).thenReturn(self.logger) when(SessionOrchestrator)._init_task_save_folder(any()).thenReturn() when(SessionOrchestrator)._init_task_logger(any()).thenReturn() when(SessionOrchestrator)._save_data().thenReturn() @@ -118,30 +127,35 @@ def test_orchestrator_multiple_copyphrases_loads_from_parameters_when_set(self, orchestrator = SessionOrchestrator(parameters=parameters) - self.assertEqual(orchestrator.copyphrases, mock_copy_phrases['Phrases']) + self.assertEqual(orchestrator.copyphrases, + mock_copy_phrases['Phrases']) verify(json, times=1).load(mock_file) def test_orchestrator_save_data_multiple_copyphrases_saves_remaining_phrases(self): - when(SessionOrchestrator)._init_orchestrator_save_folder(any()).thenReturn() - when(SessionOrchestrator)._init_orchestrator_logger(any()).thenReturn(self.logger) + when(SessionOrchestrator)._init_orchestrator_save_folder( + any()).thenReturn() + when(SessionOrchestrator)._init_orchestrator_logger( + any()).thenReturn(self.logger) when(SessionOrchestrator)._init_task_save_folder(any()).thenReturn() when(SessionOrchestrator)._init_task_logger(any()).thenReturn() - when(SessionOrchestrator)._save_procotol_data().thenReturn() + when(SessionOrchestrator)._save_protocol_data().thenReturn() when(SessionOrchestrator)._save_copy_phrases().thenReturn() orchestrator = SessionOrchestrator() orchestrator.copyphrases = [["test", 0], ["test2", 1]] orchestrator._save_data() - verify(SessionOrchestrator, times=1)._save_procotol_data() + verify(SessionOrchestrator, times=1)._save_protocol_data() verify(SessionOrchestrator, times=1)._save_copy_phrases() def test_orchestrator_next_phrase(self): - when(SessionOrchestrator)._init_orchestrator_save_folder(any()).thenReturn() - when(SessionOrchestrator)._init_orchestrator_logger(any()).thenReturn(self.logger) + when(SessionOrchestrator)._init_orchestrator_save_folder( + any()).thenReturn() + when(SessionOrchestrator)._init_orchestrator_logger( + any()).thenReturn(self.logger) when(SessionOrchestrator)._init_task_save_folder(any()).thenReturn() when(SessionOrchestrator)._init_task_logger(any()).thenReturn() - when(SessionOrchestrator)._save_procotol_data().thenReturn() + when(SessionOrchestrator)._save_protocol_data().thenReturn() when(SessionOrchestrator).initialize_copy_phrases().thenReturn() orchestrator = SessionOrchestrator() diff --git a/bcipy/task/tests/orchestrator/test_protocol.py b/bcipy/task/tests/orchestrator/test_protocol.py index ec258d71d..b2f65f686 100644 --- a/bcipy/task/tests/orchestrator/test_protocol.py +++ b/bcipy/task/tests/orchestrator/test_protocol.py @@ -71,6 +71,7 @@ def test_serializes_one_task(self) -> None: assert serialized == RSVPCalibrationTask.name def test_serializes_multiple_tasks(self) -> None: - sequence = [RSVPCalibrationTask, OfflineAnalysisAction, RSVPCopyPhraseTask] + sequence = [RSVPCalibrationTask, + OfflineAnalysisAction, RSVPCopyPhraseTask] serialized = serialize_protocol(sequence) assert serialized == 'RSVP Calibration -> OfflineAnalysisAction -> RSVP Copy Phrase' diff --git a/bcipy/task/tests/paradigm/rsvp/calibration/test_rsvp_calibration.py b/bcipy/task/tests/paradigm/rsvp/calibration/test_rsvp_calibration.py index 9fcbbc339..87523e0ea 100644 --- a/bcipy/task/tests/paradigm/rsvp/calibration/test_rsvp_calibration.py +++ b/bcipy/task/tests/paradigm/rsvp/calibration/test_rsvp_calibration.py @@ -400,7 +400,8 @@ def test_cleanup(self, save_session_mock, trigger_handler_mock): (self.daq, self.servers, self.win)) # Mock the default cleanup - when(bcipy.task.calibration.BaseCalibrationTask).write_offset_trigger().thenReturn(None) + when(bcipy.task.calibration.BaseCalibrationTask).write_offset_trigger( + ).thenReturn(None) when(bcipy.task.calibration.BaseCalibrationTask).exit_display().thenReturn(None) when(bcipy.task.calibration.BaseCalibrationTask).wait().thenReturn(None) @@ -421,8 +422,10 @@ def test_cleanup(self, save_session_mock, trigger_handler_mock): verify(self.daq, times=1).cleanup() verify(self.servers[0], times=1).stop() verify(self.win, times=1).close() - verify(bcipy.task.calibration.BaseCalibrationTask, times=1).setup(any(), any(), any()) - verify(bcipy.task.calibration.BaseCalibrationTask, times=1).write_offset_trigger() + verify(bcipy.task.calibration.BaseCalibrationTask, + times=1).setup(any(), any(), any()) + verify(bcipy.task.calibration.BaseCalibrationTask, + times=1).write_offset_trigger() verify(bcipy.task.calibration.BaseCalibrationTask, times=1).exit_display() verify(bcipy.task.calibration.BaseCalibrationTask, times=1).wait() diff --git a/bcipy/task/tests/paradigm/rsvp/test_copy_phrase.py b/bcipy/task/tests/paradigm/rsvp/test_copy_phrase.py index 5c75a8565..2e2f86145 100644 --- a/bcipy/task/tests/paradigm/rsvp/test_copy_phrase.py +++ b/bcipy/task/tests/paradigm/rsvp/test_copy_phrase.py @@ -111,7 +111,8 @@ def setUp(self): } }) self.servers = [mock()] - when(self.daq).get_client(ContentType.EEG).thenReturn(self.eeg_client_mock) + when(self.daq).get_client( + ContentType.EEG).thenReturn(self.eeg_client_mock) self.temp_dir = tempfile.mkdtemp() self.model_metadata = mock({ 'device_spec': device_spec, @@ -464,7 +465,8 @@ def test_execute_fake_data_with_preview(self, process_data_mock, message_mock, # Assertions verify(self.copy_phrase_wrapper, times=2).initialize_series() verify(self.display, times=1).do_inquiry() - verify(self.copy_phrase_wrapper, times=1).add_evidence(EvidenceType.BTN, ...) + verify(self.copy_phrase_wrapper, times=1).add_evidence( + EvidenceType.BTN, ...) self.assertEqual(self.temp_dir, result.save_path) @patch('bcipy.task.paradigm.rsvp.copy_phrase.init_evidence_evaluator') @@ -655,7 +657,8 @@ def test_btn_evidence_with_preview_only(self): when(bcipy.task.paradigm.rsvp.copy_phrase.RSVPCopyPhraseTask).setup(any(), any(), any()).thenReturn( (self.daq, self.servers, self.win)) self.parameters['show_preview_inquiry'] = True - self.parameters['preview_inquiry_progress_method'] = 0 # ButtonPressMode.NOTHING.value + # ButtonPressMode.NOTHING.value + self.parameters['preview_inquiry_progress_method'] = 0 task = RSVPCopyPhraseTask( parameters=self.parameters, @@ -693,10 +696,14 @@ def test_cleanup(self): (self.daq, self.servers, self.win)) # Mock the default cleanup - when(bcipy.task.paradigm.rsvp.copy_phrase.RSVPCopyPhraseTask).write_offset_trigger().thenReturn(None) - when(bcipy.task.paradigm.rsvp.copy_phrase.RSVPCopyPhraseTask).exit_display().thenReturn(None) - when(bcipy.task.paradigm.rsvp.copy_phrase.RSVPCopyPhraseTask).save_session_data().thenReturn(None) - when(bcipy.task.paradigm.rsvp.copy_phrase.RSVPCopyPhraseTask).wait().thenReturn(None) + when(bcipy.task.paradigm.rsvp.copy_phrase.RSVPCopyPhraseTask).write_offset_trigger( + ).thenReturn(None) + when(bcipy.task.paradigm.rsvp.copy_phrase.RSVPCopyPhraseTask).exit_display( + ).thenReturn(None) + when(bcipy.task.paradigm.rsvp.copy_phrase.RSVPCopyPhraseTask).save_session_data( + ).thenReturn(None) + when(bcipy.task.paradigm.rsvp.copy_phrase.RSVPCopyPhraseTask).wait( + ).thenReturn(None) # Mock the initialized cleanup when(self.daq).stop_acquisition().thenReturn(None) @@ -716,9 +723,12 @@ def test_cleanup(self): verify(self.daq, times=1).cleanup() verify(self.servers[0], times=1).stop() verify(self.win, times=1).close() - verify(bcipy.task.paradigm.rsvp.copy_phrase.RSVPCopyPhraseTask, times=1).setup(any(), any(), any()) - verify(bcipy.task.paradigm.rsvp.copy_phrase.RSVPCopyPhraseTask, times=1).write_offset_trigger() - verify(bcipy.task.paradigm.rsvp.copy_phrase.RSVPCopyPhraseTask, times=1).exit_display() + verify(bcipy.task.paradigm.rsvp.copy_phrase.RSVPCopyPhraseTask, + times=1).setup(any(), any(), any()) + verify(bcipy.task.paradigm.rsvp.copy_phrase.RSVPCopyPhraseTask, + times=1).write_offset_trigger() + verify(bcipy.task.paradigm.rsvp.copy_phrase.RSVPCopyPhraseTask, + times=1).exit_display() verify(bcipy.task.paradigm.rsvp.copy_phrase.RSVPCopyPhraseTask, times=1).wait() diff --git a/bcipy/tests/test_bci_main.py b/bcipy/tests/test_bci_main.py index 76edfa019..348cd8332 100644 --- a/bcipy/tests/test_bci_main.py +++ b/bcipy/tests/test_bci_main.py @@ -42,7 +42,8 @@ def test_bci_main_fails_without_experiment_or_task(self) -> None: ) def test_bcipy_main_fails_with_invalid_experiment(self) -> None: - when(main).validate_bcipy_session(any(), any()).thenRaise(UnregisteredExperimentException) + when(main).validate_bcipy_session(any(), any()).thenRaise( + UnregisteredExperimentException) with self.assertRaises(UnregisteredExperimentException): bci_main( parameter_location=self.parameters_path, @@ -54,13 +55,16 @@ def test_bcipy_main_fails_with_invalid_experiment(self) -> None: ) def test_bci_main_runs_with_valid_experiment(self) -> None: - when(main).validate_bcipy_session(any(), any()).thenReturn(True) # Mock the validate_bcipy_session function + when(main).validate_bcipy_session(any(), any()).thenReturn( + True) # Mock the validate_bcipy_session function when(main).load_json_parameters( any(), value_cast=any()).thenReturn( self.parameters) # Mock the load_json_parameters function when(SessionOrchestrator).get_system_info().thenReturn(None) - when(SessionOrchestrator)._init_orchestrator_save_folder(any()).thenReturn(None) - when(SessionOrchestrator)._init_orchestrator_logger(any()).thenReturn(self.logger) + when(SessionOrchestrator)._init_orchestrator_save_folder( + any()).thenReturn(None) + when(SessionOrchestrator)._init_orchestrator_logger( + any()).thenReturn(self.logger) when(SessionOrchestrator).initialize_copy_phrases().thenReturn(None) when(SessionOrchestrator).add_tasks(any()).thenReturn(None) when(SessionOrchestrator).execute().thenReturn(None) @@ -83,10 +87,13 @@ def test_bci_main_runs_with_valid_experiment(self) -> None: def test_bci_main_runs_with_valid_task(self) -> None: when(main).validate_bcipy_session(any(), any()).thenReturn(True) - when(main).load_json_parameters(any(), value_cast=any()).thenReturn(self.parameters) + when(main).load_json_parameters( + any(), value_cast=any()).thenReturn(self.parameters) when(SessionOrchestrator).get_system_info().thenReturn(None) - when(SessionOrchestrator)._init_orchestrator_save_folder(any()).thenReturn(None) - when(SessionOrchestrator)._init_orchestrator_logger(any()).thenReturn(self.logger) + when(SessionOrchestrator)._init_orchestrator_save_folder( + any()).thenReturn(None) + when(SessionOrchestrator)._init_orchestrator_logger( + any()).thenReturn(self.logger) when(SessionOrchestrator).initialize_copy_phrases().thenReturn(None) when(SessionOrchestrator).add_tasks(any()).thenReturn(None) when(SessionOrchestrator).execute().thenReturn(None) @@ -110,10 +117,13 @@ def test_bci_main_runs_with_valid_task(self) -> None: def test_bci_main_returns_false_with_orchestrator_execute_exception(self): when(main).validate_bcipy_session(any(), any()).thenReturn(True) - when(main).load_json_parameters(any(), value_cast=any()).thenReturn(self.parameters) + when(main).load_json_parameters( + any(), value_cast=any()).thenReturn(self.parameters) when(SessionOrchestrator).get_system_info().thenReturn(None) - when(SessionOrchestrator)._init_orchestrator_save_folder(any()).thenReturn(None) - when(SessionOrchestrator)._init_orchestrator_logger(any()).thenReturn(self.logger) + when(SessionOrchestrator)._init_orchestrator_save_folder( + any()).thenReturn(None) + when(SessionOrchestrator)._init_orchestrator_logger( + any()).thenReturn(self.logger) when(SessionOrchestrator).initialize_copy_phrases().thenReturn(None) when(SessionOrchestrator).add_tasks(any()).thenReturn(None) when(SessionOrchestrator).execute().thenRaise(Exception) diff --git a/pyproject.toml b/pyproject.toml index a57c6768b..c7c9cd050 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ authors = [ ] description = "Python Software for Brain-Computer Interface Development." readme = "README.md" -requires-python = ">3.7,<3.11" +requires-python = ">3.8,<3.11" classifiers = [ 'License :: Other/Proprietary License', 'Topic :: Scientific/Engineering :: Human Machine Interfaces', @@ -18,7 +18,6 @@ classifiers = [ 'Intended Audience :: End Users/Desktop', 'Intended Audience :: Developers', 'Programming Language :: Python', - 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', ] @@ -70,6 +69,7 @@ dev = [ "coverage>=7.0", "flake8==5.0.4", "Flake8-pyproject==1.2.3", + "flake8-docstrings==1.7.0", "mypy==1.13", "lxml", "mock", @@ -81,6 +81,7 @@ dev = [ release = [ "twine==3.2.0", "build==1.2.2.post1", + "pyinstaller==6.13.0", "wheel==0.43.0", ] @@ -149,8 +150,16 @@ max_line_length = 120 application_import_names = "bcipy" ignore = [ + "D100", + "D105", + "D107", + "D202", + "D204", "D205", "D400", + "D401", + "D403", + "D412", "F841", "F821", "E402", @@ -159,6 +168,13 @@ ignore = [ "W503", "W504", ] +exclude = [ + "tests", + "__init__.py", + "demo", + "signal", # TODO: Remove when fixed + "gui", +] [tool.isort] skip = [".gitignore"]