Source code for allensdk.brain_observatory.behavior.metadata.behavior_metadata
import abc
import uuid
import warnings
from datetime import datetime
from typing import Dict, List, Optional
import re
import numpy as np
import pytz
from allensdk.brain_observatory.behavior.session_apis.abcs.\
data_extractor_base.behavior_data_extractor_base import \
BehaviorDataExtractorBase
from allensdk.brain_observatory.session_api_utils import compare_session_fields
description_dict = {
# key is a regex and value is returned on match
r"\AOPHYS_0_images": "A behavior training session performed on the 2-photon calcium imaging setup but without recording neural activity, with the goal of habituating the mouse to the experimental setup before commencing imaging of neural activity. Habituation sessions are change detection with the same image set on which the mouse was trained. The session is 75 minutes long, with 5 minutes of gray screen before and after 60 minutes of behavior, followed by 10 repeats of a 30 second natural movie stimulus at the end of the session.", # noqa: E501
r"\AOPHYS_[1|3]_images": "2-photon calcium imaging in the visual cortex of the mouse brain as the mouse performs a visual change detection task with a set of natural images upon which it has been previously trained. Image stimuli are displayed for 250 ms with a 500 ms intervening gray period. 5% of non-change image presentations are randomly omitted. The session is 75 minutes long, with 5 minutes of gray screen before and after 60 minutes of behavior, followed by 10 repeats of a 30 second natural movie stimulus at the end of the session.", # noqa: E501
r"\AOPHYS_2_images": "2-photon calcium imaging in the visual cortex of the mouse brain as the mouse is passively shown changes in natural scene images upon which it was previously trained as the change detection task is played in open loop mode, with the lick-response sensory withdrawn and the mouse is unable to respond to changes or receive reward feedback. Image stimuli are displayed for 250 ms with a 500 ms intervening gray period. 5% of non-change image presentations are randomly omitted. The session is 75 minutes long, with 5 minutes of gray screen before and after 60 minutes of behavior, followed by 10 repeats of a 30 second natural movie stimulus at the end of the session.", # noqa: E501
r"\AOPHYS_[4|6]_images": "2-photon calcium imaging in the visual cortex of the mouse brain as the mouse performs a visual change detection task with natural scene images that are unique from those on which the mouse was trained prior to the imaging phase of the experiment. Image stimuli are displayed for 250 ms with a 500 ms intervening gray period. 5% of non-change image presentations are randomly omitted. The session is 75 minutes long, with 5 minutes of gray screen before and after 60 minutes of behavior, followed by 10 repeats of a 30 second natural movie stimulus at the end of the session.", # noqa: E501
r"\AOPHYS_5_images": "2-photon calcium imaging in the visual cortex of the mouse brain as the mouse is passively shown changes in natural scene images that are unique from those on which the mouse was trained prior to the imaging phase of the experiment. In this session, the change detection task is played in open loop mode, with the lick-response sensory withdrawn and the mouse is unable to respond to changes or receive reward feedback. Image stimuli are displayed for 250 ms with a 500 ms intervening gray period. 5% of non-change image presentations are randomly omitted. The session is 75 minutes long, with 5 minutes of gray screen before and after 60 minutes of behavior, followed by 10 repeats of a 30 second natural movie stimulus at the end of the session.", # noqa: E501
r"\ATRAINING_0_gratings": "An associative training session where a mouse is automatically rewarded when a grating stimulus changes orientation. Grating stimuli are full-field, square-wave static gratings with a spatial frequency of 0.04 cycles per degree, with orientation changes between 0 and 90 degrees, at two spatial phases. Delivered rewards are 5ul in volume, and the session lasts for 15 minutes.", # noqa: E501
r"\ATRAINING_1_gratings": "An operant behavior training session where a mouse must lick following a change in stimulus identity to earn rewards. Stimuli consist of full-field, square-wave static gratings with a spatial frequency of 0.04 cycles per degree. Orientation changes between 0 and 90 degrees occur with no intervening gray period. Delivered rewards are 10ul in volume, and the session lasts 60 minutes", # noqa: E501
r"\ATRAINING_2_gratings": "An operant behavior training session where a mouse must lick following a change in stimulus identity to earn rewards. Stimuli consist of full-field, square-wave static gratings with a spatial frequency of 0.04 cycles per degree. Gratings of 0 or 90 degrees are presented for 250 ms with a 500 ms intervening gray period. Delivered rewards are 10ul in volume, and the session lasts 60 minutes.", # noqa: E501
r"\ATRAINING_3_images": "An operant behavior training session where a mouse must lick following a change in stimulus identity to earn rewards. Stimuli consist of 8 natural scene images, for a total of 64 possible pairwise transitions. Images are shown for 250 ms with a 500 ms intervening gray period. Delivered rewards are 10ul in volume, and the session lasts for 60 minutes", # noqa: E501
r"\ATRAINING_4_images": "An operant behavior training session where a mouse must lick a spout following a change in stimulus identity to earn rewards. Stimuli consist of 8 natural scene images, for a total of 64 possible pairwise transitions. Images are shown for 250 ms with a 500 ms intervening gray period. Delivered rewards are 7ul in volume, and the session lasts for 60 minutes", # noqa: E501
r"\ATRAINING_5_images": "An operant behavior training session where a mouse must lick a spout following a change in stimulus identity to earn rewards. Stimuli consist of 8 natural scene images, for a total of 64 possible pairwise transitions. Images are shown for 250 ms with a 500 ms intervening gray period. Delivered rewards are 7ul in volume. The session is 75 minutes long, with 5 minutes of gray screen before and after 60 minutes of behavior, followed by 10 repeats of a 30 second natural movie stimulus at the end of the session." # noqa: E501
}
[docs]def get_expt_description(session_type: str) -> str:
"""Determine a behavior ophys session's experiment description based on
session type. Matches the regex patterns defined as the keys in
description_dict
Parameters
----------
session_type : str
A session description string (e.g. OPHYS_1_images_B )
Returns
-------
str
A description of the experiment based on the session_type.
Raises
------
RuntimeError
Behavior ophys sessions should only have 6 different session types.
Unknown session types (or malformed session_type strings) will raise
an error.
"""
match = dict()
for k, v in description_dict.items():
if re.match(k, session_type) is not None:
match.update({k: v})
if len(match) != 1:
emsg = (f"session type should match one and only one possible pattern "
f"template. '{session_type}' matched {len(match)} pattern "
"templates.")
if len(match) > 1:
emsg += f"{list(match.keys())}"
emsg += f"the regex pattern templates are {list(description_dict)}"
raise RuntimeError(emsg)
return match.popitem()[1]
[docs]def get_task_parameters(data: Dict) -> Dict:
"""
Read task_parameters metadata from the behavior stimulus pickle file.
Parameters
----------
data: dict
The nested dict read in from the behavior stimulus pickle file.
All of the data expected by this method lives under
data['items']['behavior']
Returns
-------
dict
A dict containing the task_parameters associated with this session.
"""
behavior = data["items"]["behavior"]
stimuli = behavior['stimuli']
config = behavior["config"]
doc = config["DoC"]
task_parameters = {}
task_parameters['blank_duration_sec'] = \
[float(x) for x in doc['blank_duration_range']]
if 'images' in stimuli:
stim_key = 'images'
elif 'grating' in stimuli:
stim_key = 'grating'
else:
msg = "Cannot get stimulus_duration_sec\n"
msg += "'images' and/or 'grating' not a valid "
msg += "key in pickle file under "
msg += "['items']['behavior']['stimuli']\n"
msg += f"keys: {list(stimuli.keys())}"
raise RuntimeError(msg)
stim_duration = stimuli[stim_key]['flash_interval_sec']
# from discussion in
# https://github.com/AllenInstitute/AllenSDK/issues/1572
#
# 'flash_interval' contains (stimulus_duration, gray_screen_duration)
# (as @matchings said above). That second value is redundant with
# 'blank_duration_range'. I'm not sure what would happen if they were
# set to be conflicting values in the params. But it looks like
# they're always consistent. It should always be (0.25, 0.5),
# except for TRAINING_0 and TRAINING_1, which have statically
# displayed stimuli (no flashes).
if stim_duration is None:
stim_duration = np.NaN
else:
stim_duration = stim_duration[0]
task_parameters['stimulus_duration_sec'] = stim_duration
task_parameters['omitted_flash_fraction'] = \
behavior['params'].get('flash_omit_probability', float('nan'))
task_parameters['response_window_sec'] = \
[float(x) for x in doc["response_window"]]
task_parameters['reward_volume'] = config["reward"]["reward_volume"]
task_parameters['auto_reward_volume'] = doc['auto_reward_volume']
task_parameters['session_type'] = behavior["params"]["stage"]
task_parameters['stimulus'] = next(iter(behavior["stimuli"]))
task_parameters['stimulus_distribution'] = doc["change_time_dist"]
task_id = config['behavior']['task_id']
if 'DoC' in task_id:
task_parameters['task'] = 'change detection'
else:
msg = "metadata.get_task_parameters does not "
msg += f"know how to parse 'task_id' = {task_id}"
raise RuntimeError(msg)
n_stimulus_frames = 0
for stim_type, stim_table in behavior["stimuli"].items():
n_stimulus_frames += sum(stim_table.get("draw_log", []))
task_parameters['n_stimulus_frames'] = n_stimulus_frames
return task_parameters
[docs]class BehaviorMetadata:
"""Container class for behavior metadata"""
def __init__(self, extractor: BehaviorDataExtractorBase,
stimulus_timestamps: np.ndarray,
behavior_stimulus_file: dict):
self._extractor = extractor
self._stimulus_timestamps = stimulus_timestamps
self._behavior_stimulus_file = behavior_stimulus_file
self._exclude_from_equals = set()
@property
def equipment_name(self) -> str:
return self._extractor.get_equipment_name()
@property
def sex(self) -> str:
return self._extractor.get_sex()
@property
def age_in_days(self) -> Optional[int]:
"""Converts the age cod into a numeric days representation"""
age = self._extractor.get_age()
return self.parse_age_in_days(age=age, warn=True)
@property
def stimulus_frame_rate(self) -> float:
return self._get_frame_rate(timestamps=self._stimulus_timestamps)
@property
def session_type(self) -> str:
return self._extractor.get_stimulus_name()
@property
def date_of_acquisition(self) -> datetime:
"""Return the timestamp for when experiment was started in UTC
NOTE: This method will only get acquisition datetime from
extractor (data from LIMS) methods. As a sanity check,
it will also read the acquisition datetime from the behavior stimulus
(*.pkl) file and raise a warning if the date differs too much from the
datetime obtained from the behavior stimulus (*.pkl) file.
:rtype: datetime
"""
extractor_acq_date = self._extractor.get_date_of_acquisition()
pkl_data = self._behavior_stimulus_file
pkl_raw_acq_date = pkl_data["start_time"]
if isinstance(pkl_raw_acq_date, datetime):
pkl_acq_date = pytz.utc.localize(pkl_raw_acq_date)
elif isinstance(pkl_raw_acq_date, (int, float)):
# We are dealing with an older pkl file where the acq time is
# stored as a Unix style timestamp string
parsed_pkl_acq_date = datetime.fromtimestamp(pkl_raw_acq_date)
pkl_acq_date = pytz.utc.localize(parsed_pkl_acq_date)
else:
pkl_acq_date = None
warnings.warn(
"Could not parse the acquisition datetime "
f"({pkl_raw_acq_date}) found in the following stimulus *.pkl: "
f"{self._extractor.get_behavior_stimulus_file()}"
)
if pkl_acq_date:
acq_start_diff = (
extractor_acq_date - pkl_acq_date).total_seconds()
# If acquisition dates differ by more than an hour
if abs(acq_start_diff) > 3600:
session_id = self._extractor.get_behavior_session_id()
warnings.warn(
"The `date_of_acquisition` field in LIMS "
f"({extractor_acq_date}) for behavior session "
f"({session_id}) deviates by more "
f"than an hour from the `start_time` ({pkl_acq_date}) "
"specified in the associated stimulus *.pkl file: "
f"{self._extractor.get_behavior_stimulus_file()}"
)
return extractor_acq_date
@property
def reporter_line(self) -> Optional[str]:
reporter_line = self._extractor.get_reporter_line()
return self.parse_reporter_line(reporter_line=reporter_line, warn=True)
@property
def indicator(self) -> Optional[str]:
"""Parses indicator from reporter"""
reporter_line = self.reporter_line
return self.parse_indicator(reporter_line=reporter_line, warn=True)
@property
def cre_line(self) -> Optional[str]:
"""Parses cre_line from full_genotype"""
cre_line = self.parse_cre_line(full_genotype=self.full_genotype,
warn=True)
return cre_line
@property
def behavior_session_uuid(self) -> Optional[uuid.UUID]:
"""Get the universally unique identifier (UUID)
"""
data = self._behavior_stimulus_file
behavior_pkl_uuid = data.get("session_uuid")
behavior_session_id = self._extractor.get_behavior_session_id()
foraging_id = self._extractor.get_foraging_id()
# Sanity check to ensure that pkl file data matches up with
# the behavior session that the pkl file has been associated with.
assert_err_msg = (
f"The behavior session UUID ({behavior_pkl_uuid}) in the "
f"behavior stimulus *.pkl file "
f"({self._extractor.get_behavior_stimulus_file()}) does "
f"does not match the foraging UUID ({foraging_id}) for "
f"behavior session: {behavior_session_id}")
assert behavior_pkl_uuid == foraging_id, assert_err_msg
if behavior_pkl_uuid is None:
bs_uuid = None
else:
bs_uuid = uuid.UUID(behavior_pkl_uuid)
return bs_uuid
@property
def driver_line(self) -> List[str]:
return sorted(self._extractor.get_driver_line())
@property
def mouse_id(self) -> int:
return self._extractor.get_mouse_id()
@property
def full_genotype(self) -> str:
return self._extractor.get_full_genotype()
@property
def behavior_session_id(self) -> int:
return self._extractor.get_behavior_session_id()
[docs] @abc.abstractmethod
def to_dict(self) -> dict:
"""Returns dict representation of all properties in class"""
vars_ = vars(BehaviorMetadata)
return self._get_properties(vars_=vars_)
@staticmethod
def _get_frame_rate(timestamps: np.ndarray):
return np.round(1 / np.mean(np.diff(timestamps)), 0)
[docs] @staticmethod
def parse_cre_line(full_genotype: str, warn=False) -> Optional[str]:
"""
Parameters
----------
full_genotype
formatted from LIMS, e.g.
Vip-IRES-Cre/wt;Ai148(TIT2L-GC6f-ICL-tTA2)/wt
warn
Whether to output warning if parsing fails
Returns
----------
cre_line
just the Cre line, e.g. Vip-IRES-Cre, or None if not possible to
parse
"""
if ';' not in full_genotype:
if warn:
warnings.warn('Unable to parse cre_line from full_genotype')
return None
return full_genotype.split(';')[0].replace('/wt', '')
[docs] @staticmethod
def parse_age_in_days(age: str, warn=False) -> Optional[int]:
"""Converts the age code into a numeric days representation
Parameters
----------
age
age code, ie P123
warn
Whether to output warning if parsing fails
"""
if not age.startswith('P'):
if warn:
warnings.warn('Could not parse numeric age from age code '
'(age code does not start with "P")')
return None
match = re.search(r'\d+', age)
if match is None:
if warn:
warnings.warn('Could not parse numeric age from age code '
'(no numeric values found in age code)')
return None
start, end = match.span()
return int(age[start:end])
[docs] @staticmethod
def parse_reporter_line(reporter_line: Optional[List[str]],
warn=False) -> Optional[str]:
"""There can be multiple reporter lines, so it is returned from LIMS
as a list. But there shouldn't be more than 1 for behavior. This
tries to convert to str
Parameters
----------
reporter_line
List of reporter line
warn
Whether to output warnings if parsing fails
Returns
---------
single reporter line, or None if not possible
"""
if reporter_line is None:
if warn:
warnings.warn('Error parsing reporter line. It is null.')
return None
if len(reporter_line) == 0:
if warn:
warnings.warn('Error parsing reporter line. '
'The array is empty')
return None
if isinstance(reporter_line, str):
return reporter_line
if len(reporter_line) > 1:
if warn:
warnings.warn('More than 1 reporter line. Returning the first '
'one')
return reporter_line[0]
def _get_properties(self, vars_: dict):
"""Returns all property names and values"""
return {name: getattr(self, name) for name, value in vars_.items()
if isinstance(value, property)}
def __eq__(self, other):
if not isinstance(other, (BehaviorMetadata, dict)):
msg = f'Do not know how to compare with type {type(other)}'
raise NotImplementedError(msg)
properties_self = self.to_dict()
if isinstance(other, dict):
properties_other = other
else:
properties_other = other.to_dict()
for p in properties_self:
if p in self._exclude_from_equals:
continue
x1 = properties_self[p]
x2 = properties_other[p]
try:
compare_session_fields(x1=x1, x2=x2)
except AssertionError:
return False
return True
[docs] @staticmethod
def parse_indicator(reporter_line: Optional[str], warn=False) -> Optional[
str]:
"""Parses indicator from reporter"""
reporter_substring_indicator_map = {
'GCaMP6f': 'GCaMP6f',
'GC6f': 'GCaMP6f',
'GCaMP6s': 'GCaMP6s'
}
if reporter_line is None:
if warn:
warnings.warn(
'Could not parse indicator from reporter because '
'there is no reporter')
return None
for substr, indicator in reporter_substring_indicator_map.items():
if substr in reporter_line:
return indicator
if warn:
warnings.warn(
'Could not parse indicator from reporter because none'
'of the expected substrings were found in the reporter')
return None