Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d439ff4
Remove clear() after trial recycle
a-hurst Jan 29, 2026
2e60a42
Allow setting practice on TrialIterator init
a-hurst Jan 28, 2026
baeea35
Add unit tests for trial sequencing
a-hurst Jan 29, 2026
70cf47d
Remove unused practice argument to __trial__
a-hurst Jan 29, 2026
a24184a
Improve trial sequencing unit tests
a-hurst Jan 29, 2026
30b3b0a
Rewrite trial recycling behaviour to be in KLExperiment
a-hurst Jan 29, 2026
b73c4fb
Replace TrialIterator with simpler TrialSet class
a-hurst Jan 29, 2026
7d2b12f
Remove unused methods from TrialFactory
a-hurst Jan 29, 2026
66a3e2c
Add unit tests for inserting practice blocks
a-hurst Jan 29, 2026
4b29767
Deprecate BlockIterator interally, replace with shim
a-hurst Jan 29, 2026
764f885
Clean up insert_practice_block
a-hurst Jan 29, 2026
24f930e
Remove the BlockIterator class entirely
a-hurst Jan 29, 2026
0651ce3
Implement run_practice_blocks
a-hurst Jan 29, 2026
c6a291c
Add support for setting block labels with TrialSets
a-hurst Feb 1, 2026
6a1993c
Add TrialSet to public API
a-hurst Feb 1, 2026
c8c7145
Set blocks_per_experiment directly with block count
a-hurst Feb 1, 2026
a9a403f
Move exp factor import to Experiment class
a-hurst Feb 1, 2026
9e1f385
Further clean up and document TrialFactory
a-hurst Feb 1, 2026
6ce8e14
Replace dump() with generate_trials_txt()
a-hurst Feb 1, 2026
b318d6f
Minor clarity fix
a-hurst Feb 1, 2026
e11ce4e
Officially remove TrialFactory from the public API
a-hurst Feb 1, 2026
f643318
Fix GitHub Actions
a-hurst Feb 1, 2026
1a61ae5
Drop tests on 32-bit Windows
a-hurst Feb 1, 2026
e94c261
Change name of trials_txt method based on feedback
a-hurst Feb 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 3 additions & 7 deletions .github/workflows/run_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12']
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14']
name-prefix: ['Linux (Python ']

env:
Expand Down Expand Up @@ -59,7 +59,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ['3.10']
python-version: ['3.11']
name-prefix: ['macOS (Python ']

env:
Expand Down Expand Up @@ -95,13 +95,9 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ['3.10']
python-version: ['3.11']
architecture: ['x64']
name-prefix: ['Windows (Python ']
include:
- python-version: '3.10'
architecture: 'x86'
name-prefix: 'Windows 32-bit (Python '

env:
SDL_VIDEODRIVER: dummy
Expand Down
26 changes: 26 additions & 0 deletions docs/CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,36 @@ This is a log of the latest changes and improvements to KLibs.
Runtime Changes:

* KLibs now requires Python 3.7 or newer to run, dropping support for 2.7.
* Trial recycling behaviour has been changed, such that recycled trials are
now re-inserted at a random position in the list of remaining trials, avoiding
re-insertion as the next trial (to avoid an immediate repeat) unless it is the
only trial remaining. Previously recycling a trial would shuffle the order of
*all* remaining trials, which could unexpectedly affect the even distribution
of trial factors within blocks.
* Practice blocks are no longer added when `P.run_practice_blocks` is False.

API Changes:

* The :class:`~klibs.KLTrialFactory.TrialFactory` class has been removed from
the public API and should no longer be used directly by new projects.
* Added a new class :class:`~klibs.KLTrialFactory.TrialSet` to allow for
defining custom block types and block structures as well as setting labels
for blocks (accessible during blocks through the `self.block_label` attribute
in the experiment runtime).
* Removed the :class:`~klibs.KLTrialFactory.BlockIterator` class.
The `self.blocks` attribute of the Experiment class is now a list of
:class:`~klibs.KLTrialFactory.TrialSet` objects.
* Experiment factor names and levels are now accessible directly through the
:attr:`~klibs.KLExperiment.exp_factors` attribute during the Experiment
runtime (e.g. `self.exp_factors`).
* Added a new method :method:`~klibs.KLExperiment.write_trials_txt` for
exporting the full sequence of generated trials and blocks to a human
readable text file.

Fixed Bugs:

* KLibs no longer crashes on launch with Python 3.12.
* KLibs no longer briefly shows a blank screen when a trial is recycled.


0.7.7b1
Expand Down
2 changes: 1 addition & 1 deletion docs/source/api/trial_factory.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
KLTrialFactory
==============

.. automodule:: klibs.KLTrialFactory
.. automodule:: klibs.KLTrialSet
:undoc-members:
:members:
191 changes: 144 additions & 47 deletions klibs/KLExperiment.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@
__author__ = 'Jonathan Mulle & Austin Hurst'

import os
import random
from abc import abstractmethod
from traceback import print_tb, print_stack

from klibs import P
from klibs.KLEnvironment import EnvAgent
from klibs.KLExceptions import TrialException
from klibs.KLInternal import full_trace
from klibs.KLInternal import full_trace, iterable
from klibs.KLInternal import colored_stdout as cso


Expand All @@ -28,44 +29,60 @@ def __init__(self):
self.incomplete = True # flag for keeping track of session completeness
self.blocks = None # blocks of trials for the experiment
self.tracker_dot = None # overlay of eye tracker gaze location in devmode
self.block_label = None # runtime attribute containing label of current block

self.audio = AudioManager() # initialize audio management for the experiment
self.rc = ResponseCollector() # add default response collector
self.database = self.db # use database from env
self._evm = EventManager()

self.trial_factory = TrialFactory()
self._exp_factors = self._get_exp_factors()
self.trial_factory = TrialFactory(self._exp_factors)
if P.manual_trial_generation is False:
self.trial_factory.generate()
self.event_code_generator = None


def _get_exp_factors(self):
# Reads in the trial factors for the study, including any local overrides
from klibs.KLTrialFactory import _load_factors

# Load experiment factors from the project's _independent_variables.py file(s)
factors = _load_factors(P.ind_vars_file_path)
if os.path.exists(P.ind_vars_file_local_path):
if not P.dm_ignore_local_overrides:
local_factors = _load_factors(P.ind_vars_file_local_path)
factors.update(local_factors)

return factors


def __execute_experiment__(self, *args, **kwargs):
"""For internal use, actually runs the blocks/trials of the experiment in sequence.

"""
from klibs.KLGraphics import clear

if self.blocks == None:
self.blocks = self.trial_factory.export_trials()

P.blocks_per_experiment = len(self.blocks)
P.block_number = 0
P.trial_id = 0
for block in self.blocks:
P.recycle_count = 0
P.block_number += 1
P.practicing = block.practice
self.block_label = block.label
self.block()
P.trial_number = 1
for trial in block: # ie. list of trials
remaining = list(block.trials)
while len(remaining):
trial = remaining.pop(0)
try:
P.trial_id += 1 # Increments regardless of recycling
self.__trial__(trial, block.practice)
self.__trial__(trial)
P.trial_number += 1
except TrialException:
block.recycle()
remaining = self._recycle_trial(remaining, trial)
P.recycle_count += 1
clear() # NOTE: is this actually wanted?
self.rc.reset()
self.clean_up()

Expand All @@ -75,7 +92,7 @@ def __execute_experiment__(self, *args, **kwargs):
self.database.update('session_info', {'complete': True}, where)


def __trial__(self, trial, practice):
def __trial__(self, trial):
"""
Private method; manages a trial.
"""
Expand Down Expand Up @@ -132,6 +149,34 @@ def __log_trial__(self, trial_data):
return self.database.insert(trial_template)


def _recycle_trial(self, remaining, trial):
"""Internal method for recycling a trial within the current block.

This method re-inserts a trial into the set of remaining trials at a
random position, avoiding an immediate repeat of the trial unless it is
the only trial remaining in the block.

Recycling behaviour can be customized by overriding this method.

Args:
remaining (list): The remaining trials for the current block.
trial (dict): The trial factors to recycle into the block.

Returns:
list: The new set of remaining trials.

"""
# NOTE: Should this be part of public API or stay unofficial/internal?
if len(remaining):
# Re-insert the trial in a random position after the first element
tmp = remaining.copy()
new_idx = random.randrange(1, len(tmp)) if len(tmp) > 1 else 1
tmp.insert(new_idx, trial)
return tmp
else:
return [trial]


## Define abstract methods to be overridden in experiment.py ##

@abstractmethod
Expand Down Expand Up @@ -197,52 +242,94 @@ def clean_up(self):


def insert_practice_block(self, block_nums, trial_counts=None, factor_mask=None):
"""
Adds one or more practice blocks to the experiment. This function must be called during setup(),
otherwise the trials will have already been exported and this function will no longer have
any effect. If you want to add a block to the experiment after setup() for whatever reason,
you can manually generate one using trial_factory.generate() and then insert it using
self.blocks.insert().

If multiple block indexes are given but only a single integer is given for trial counts,
then all practice blocks inserted will be trial_counts trials long. If not trial_counts
value is provided, the number of trials per practice block defaults to the global
experiment trials_per_block parameter.
"""Adds a practice block to the experiment.

If multiple block indexes are given but only a single factor mask is provided, the same
factor mask will be applied to all appended practice blocks. If no factor mask is provided,
the function will generate a full set of trials based on all possible combination of factors,
and will randomly select trial_counts trials from it for each practice block.
This method adds an extra block of trials at a given position in the block
sequence, optionally with a different trial count and/or different factor
levels than the rest of the task. For example, to add a practice block with
20 trials at the start of the task, you would add the following somewhere in
the `setup()` block of your `experiment.py` file::

Args:
block_nums (:obj:`list` of int): Index numbers at which to insert the blocks.
trial_counts (:obj:`list` of int, optional): The numbers of trials to insert for each
of the inserted blocks.
factor_mask (:obj:`dict` of :obj:`list`, optional): Override values for the variables
specified in independent_variables.py.
self.insert_practice_block(1, 20)

Raises:
TrialException: If called after the experiment's :meth:`setup` method has run.
During practice blocks the klibs parameter `P.practicing` will be set to True,
allowing easy conditional changes during practice blocks (e.g. showing
additional feedback if practicing).

You can also provide a factor mask to override one or more factor levels for
the practice block. For example, if the task has a factor 'difficulty' with
the levels 'easy' and 'hard' and you want to add separate practice blocks for
each trial type, you can specify overrides for the factor levels like so::

self.insert_practice_block(1, 32, factor_mask={'difficulty': ['easy']})
self.insert_practice_block(2, 32, factor_mask={'difficulty': ['hard']})

This function must be called during setup(), otherwise the block structure of
the study will already be set and can no longer be changed.

Args:
block_nums (int): Position at which to insert the block.
trial_counts (int, optional): The trial count for the practice block.
Defaults to `P.trials_per_block`.
factor_mask (:obj:`dict` of :obj:`list`, optional): Overrides for one or
more factors in the task's `independent_variables.py` file.

"""
# [Compat]: Messy API to allow multiple insertions at once, fix when possible.
# Only TOJ_Motion uses multiple insertions. Multiple projects use 'trial_counts'
# keyword, however.

if self.blocks:
# If setup has passed and trial execution has started, blocks have already been exported
# from trial_factory so this function will no longer work. If it is called after it is no
# longer useful, we throw a TrialException
raise TrialException("Practice blocks cannot be inserted after setup() is complete.")
try:
iter(block_nums)
except TypeError:
block_nums = [block_nums]
try:
iter(trial_counts)
except TypeError:
trial_counts = ([P.trials_per_block] if trial_counts is None else [trial_counts]) * len(block_nums)
while len(trial_counts) < len(block_nums):
trial_counts.append(P.trials_per_block)
for i in range(0, len(block_nums)):
self.trial_factory.insert_block(block_nums[i], True, trial_counts[i], factor_mask)
P.blocks_per_experiment += 1
raise RuntimeError("Cannot insert practice blocks after setup() is complete.")

if not trial_counts:
trial_counts = P.trials_per_block

if iterable(block_nums):
# [Compat]: Only TOJ_Motion uses this and it's a bad idea, remove when fixed.
for b in block_nums:
self.insert_practice_block(b, trial_counts, factor_mask)
else:
self.trial_factory.insert_block(block_nums, trial_counts, True, factor_mask)


def write_trials_txt(self, outpath=None):
"""Writes the current block/trial structure to a text file.

This method is intended for verifying your blocks and trials are being
generated and sequenced as expected during development. The factors for
each trial within each block are written in a human-readable format::

=========================
== Block 1 (3 trials) ==
=========================

trial cue_validity soa target_loc
----- ------------ --- ----------
1 valid 200 left
2 invalid 800 right
3 neutral 800 left

A summary of the block structure and a list of the experiment factors
and their base levels is also included at the top of the file.

Args:
outpath (str, optional): The path at which to save the text file.
Defaults to `ExpAssets/Local/[project_name]_trials.txt`.

"""
from klibs.KLTrialFactory import _structure_to_str

if not outpath:
fname = "{0}_trials.txt".format(P.project_name)
outpath = os.path.join(P.local_dir, fname)

with open(outpath, "w") as out:
blocks = self.blocks if self.blocks else self.trial_factory.blocks
out.write(_structure_to_str(blocks, self.exp_factors))


def before_flip(self):
Expand Down Expand Up @@ -340,6 +427,16 @@ def show_logo(self):
any_key()


@property
def exp_factors(self):
"""dict: The names and levels of all categorical factors in the study.

This attribute is read-only, meaning that any changes to this attribute's
keys or values will have no effect on the experiment runtime.

"""
return self._exp_factors.copy()

@property
def evm(self):
""":obj:`~klibs.KLEventInterface.EventManager`: The trial event sequencer for
Expand Down
2 changes: 1 addition & 1 deletion klibs/KLParams.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
conditions = []
default_condition = None
table_defaults = {} # default column values for db tables when using EntryTemplate
run_practice_blocks = True # (not implemented in klibs itself)
run_practice_blocks = True
color_output = False # whether cso() outputs colorized text or not

# Eye Tracking Settings
Expand Down
Loading