From 0846ff8b095864a90fdeffd3c3f15d1a5402833c Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 24 Jun 2022 12:05:54 -0500 Subject: [PATCH 001/489] rename `ingest` -> `populate` --- aeon/dj_pipeline/README.md | 4 ++-- aeon/dj_pipeline/acquisition.py | 4 ++-- aeon/dj_pipeline/analysis/visit.py | 4 ++-- aeon/dj_pipeline/{ingest => populate}/__init__.py | 0 aeon/dj_pipeline/{ingest => populate}/create_experiment_01.py | 2 +- aeon/dj_pipeline/{ingest => populate}/create_experiment_02.py | 2 +- .../{ingest => populate}/create_socialexperiment_0.py | 4 ++-- aeon/dj_pipeline/{ingest => populate}/load_metadata.py | 0 aeon/dj_pipeline/{ingest => populate}/process.py | 4 ++-- .../{ingest => populate}/setup_yml/Experiment0.1.yml | 0 .../{ingest => populate}/setup_yml/SocialExperiment0.yml | 0 11 files changed, 12 insertions(+), 12 deletions(-) rename aeon/dj_pipeline/{ingest => populate}/__init__.py (100%) rename aeon/dj_pipeline/{ingest => populate}/create_experiment_01.py (99%) rename aeon/dj_pipeline/{ingest => populate}/create_experiment_02.py (99%) rename aeon/dj_pipeline/{ingest => populate}/create_socialexperiment_0.py (98%) rename aeon/dj_pipeline/{ingest => populate}/load_metadata.py (100%) rename aeon/dj_pipeline/{ingest => populate}/process.py (96%) rename aeon/dj_pipeline/{ingest => populate}/setup_yml/Experiment0.1.yml (100%) rename aeon/dj_pipeline/{ingest => populate}/setup_yml/SocialExperiment0.yml (100%) diff --git a/aeon/dj_pipeline/README.md b/aeon/dj_pipeline/README.md index c75c552b..93646ce8 100644 --- a/aeon/dj_pipeline/README.md +++ b/aeon/dj_pipeline/README.md @@ -69,14 +69,14 @@ animals, cameras, food patches setup, etc. + These information are either entered by hand, or parsed and inserted from configuration yaml files. + For experiment 0.1 these info can be inserted by running -the [exp01_insert_meta script](./ingest/create_experiment_01.py) (just need to do this once) +the [exp01_insert_meta script](populate/create_experiment_01.py) (just need to do this once) Tables in DataJoint are written with a `make()` function - instruction to generate and insert new records to itself, based on data from upstream tables. Triggering the auto ingestion and processing/computation routine is essentially calling the `.populate()` method for all relevant tables. -These routines are prepared in this [auto-processing script](./ingest/process.py). +These routines are prepared in this [auto-processing script](populate/process.py). Essentially, turning on the auto-processing routine amounts to running the following 3 commands (in different processing threads) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 4c9df4af..e04bfe1c 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -11,7 +11,7 @@ from . import get_schema_name from .utils import paths -from .ingest.load_metadata import extract_epoch_metadata, ingest_epoch_metadata +from .populate.load_metadata import extract_epoch_metadata, ingest_epoch_metadata schema = dj.schema(get_schema_name("acquisition")) @@ -1042,7 +1042,7 @@ def _load_legacy_subjectdata(experiment_name, data_dir, start, end): return subject_data if experiment_name == "social0-r1": - from aeon.dj_pipeline.ingest.create_socialexperiment_0 import fixID + from aeon.dj_pipeline.populate.create_socialexperiment_0 import fixID sessdf = subject_data.copy() sessdf = sessdf[~sessdf.id.str.contains("test")] diff --git a/aeon/dj_pipeline/analysis/visit.py b/aeon/dj_pipeline/analysis/visit.py index a0b8f425..6e48fb84 100644 --- a/aeon/dj_pipeline/analysis/visit.py +++ b/aeon/dj_pipeline/analysis/visit.py @@ -118,11 +118,11 @@ def make(self, key): def ingest_environment_visits(experiment_names=["exp0.2-r0"]): """ - Function to ingest into `Visit` and `VisitEnd` for specified experiments (default: 'exp0.2-r0') + Function to populate into `Visit` and `VisitEnd` for specified experiments (default: 'exp0.2-r0') This ingestion routine handles only those "complete" visits, not ingesting any "on-going" visits Using "analyze" method: `aeon.analyze.utils.visits()` - :param list experiment_names: list of names of the experiment to ingest into the Visit table + :param list experiment_names: list of names of the experiment to populate into the Visit table """ place_key = {"place": "environment"} for experiment_name in experiment_names: diff --git a/aeon/dj_pipeline/ingest/__init__.py b/aeon/dj_pipeline/populate/__init__.py similarity index 100% rename from aeon/dj_pipeline/ingest/__init__.py rename to aeon/dj_pipeline/populate/__init__.py diff --git a/aeon/dj_pipeline/ingest/create_experiment_01.py b/aeon/dj_pipeline/populate/create_experiment_01.py similarity index 99% rename from aeon/dj_pipeline/ingest/create_experiment_01.py rename to aeon/dj_pipeline/populate/create_experiment_01.py index c09cfd68..e6b29317 100644 --- a/aeon/dj_pipeline/ingest/create_experiment_01.py +++ b/aeon/dj_pipeline/populate/create_experiment_01.py @@ -171,7 +171,7 @@ def ingest_exp01_metadata(metadata_yml_filepath, experiment_name): ) -# ============ Manual and automatic steps to for experiment 0.1 ingest ============ +# ============ Manual and automatic steps to for experiment 0.1 populate ============ experiment_name = "exp0.1-r0" diff --git a/aeon/dj_pipeline/ingest/create_experiment_02.py b/aeon/dj_pipeline/populate/create_experiment_02.py similarity index 99% rename from aeon/dj_pipeline/ingest/create_experiment_02.py rename to aeon/dj_pipeline/populate/create_experiment_02.py index 1518dac8..c5ff7a03 100644 --- a/aeon/dj_pipeline/ingest/create_experiment_02.py +++ b/aeon/dj_pipeline/populate/create_experiment_02.py @@ -1,7 +1,7 @@ from aeon.dj_pipeline import acquisition, lab, subject -# ============ Manual and automatic steps to for experiment 0.2 ingest ============ +# ============ Manual and automatic steps to for experiment 0.2 populate ============ experiment_name = "exp0.2-r0" _weight_scale_rate = 20 diff --git a/aeon/dj_pipeline/ingest/create_socialexperiment_0.py b/aeon/dj_pipeline/populate/create_socialexperiment_0.py similarity index 98% rename from aeon/dj_pipeline/ingest/create_socialexperiment_0.py rename to aeon/dj_pipeline/populate/create_socialexperiment_0.py index d103b02f..ed63b447 100644 --- a/aeon/dj_pipeline/ingest/create_socialexperiment_0.py +++ b/aeon/dj_pipeline/populate/create_socialexperiment_0.py @@ -1,9 +1,9 @@ import pathlib from aeon.dj_pipeline import acquisition, lab, subject -from aeon.dj_pipeline.ingest.create_experiment_01 import ingest_exp01_metadata +from aeon.dj_pipeline.populate.create_experiment_01 import ingest_exp01_metadata -# ============ Manual and automatic steps to for experiment 0.1 ingest ============ +# ============ Manual and automatic steps to for experiment 0.1 populate ============ experiment_name = "social0-r1" diff --git a/aeon/dj_pipeline/ingest/load_metadata.py b/aeon/dj_pipeline/populate/load_metadata.py similarity index 100% rename from aeon/dj_pipeline/ingest/load_metadata.py rename to aeon/dj_pipeline/populate/load_metadata.py diff --git a/aeon/dj_pipeline/ingest/process.py b/aeon/dj_pipeline/populate/process.py similarity index 96% rename from aeon/dj_pipeline/ingest/process.py rename to aeon/dj_pipeline/populate/process.py index 8af145e9..dcba112b 100644 --- a/aeon/dj_pipeline/ingest/process.py +++ b/aeon/dj_pipeline/populate/process.py @@ -22,12 +22,12 @@ Usage as a script: - python ./aeon/dj_pipeline/ingest/process.py --help + python ./aeon/dj_pipeline/populate/process.py --help Usage from python: - `from aeon.dj_pipeline.ingest.process import run; run(worker_name='high_priority', duration=20, sleep=5)` + `from aeon.dj_pipeline.populate.process import run; run(worker_name='high_priority', duration=20, sleep=5)` """ diff --git a/aeon/dj_pipeline/ingest/setup_yml/Experiment0.1.yml b/aeon/dj_pipeline/populate/setup_yml/Experiment0.1.yml similarity index 100% rename from aeon/dj_pipeline/ingest/setup_yml/Experiment0.1.yml rename to aeon/dj_pipeline/populate/setup_yml/Experiment0.1.yml diff --git a/aeon/dj_pipeline/ingest/setup_yml/SocialExperiment0.yml b/aeon/dj_pipeline/populate/setup_yml/SocialExperiment0.yml similarity index 100% rename from aeon/dj_pipeline/ingest/setup_yml/SocialExperiment0.yml rename to aeon/dj_pipeline/populate/setup_yml/SocialExperiment0.yml From 84edff3084ec451673d6fd19bb8495a0e094e69b Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 24 Jun 2022 12:06:03 -0500 Subject: [PATCH 002/489] Update docker-compose-remote.yaml --- .../dj_pipeline/webapps/sciviz/docker-compose-remote.yaml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml index 384136a0..83c5c4fd 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml @@ -4,7 +4,9 @@ version: '2.4' services: pharus: - image: datajoint/pharus:0.4.0 + cpus: 2.0 + mem_limit: 4g + image: jverswijver/pharus:0.4.2-Beta.0 environment: # - FLASK_ENV=development # enables logging to console from Flask - PHARUS_SPEC_PATH=/main/specs/specsheet.yaml # for dynamic utils spec @@ -22,7 +24,9 @@ services: networks: - main sci-viz: - image: datajoint/sci-viz:0.1.1 + cpus: 2.0 + mem_limit: 4g + image: jverswijver/sci-viz:0.1.3-beta.3 environment: - CHOKIDAR_USEPOLLING=true - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/aeon/utils From 7cde170074247ff02f3326b0b7618d3b5aaf7b2e Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 24 Jun 2022 12:13:41 -0500 Subject: [PATCH 003/489] bugfix in position column names mismatch --- aeon/dj_pipeline/analysis/in_arena.py | 14 ++++++++------ aeon/dj_pipeline/report.py | 3 +-- aeon/dj_pipeline/tracking.py | 8 ++++---- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/aeon/dj_pipeline/analysis/in_arena.py b/aeon/dj_pipeline/analysis/in_arena.py index a3f0bb90..d1158402 100644 --- a/aeon/dj_pipeline/analysis/in_arena.py +++ b/aeon/dj_pipeline/analysis/in_arena.py @@ -42,7 +42,7 @@ def key_source(self): acquisition.SubjectEnterExit.Time * acquisition.EventType & 'event_type = "SubjectEnteredArena"' ).proj(in_arena_start="enter_exit_time") - & {"experiment_name": "exp0.1-r0"} + & 'experiment_name in ("exp0.1-r0", "exp0.2-r0")' ) def make(self, key): @@ -205,9 +205,9 @@ class InArenaSubjectPosition(dj.Imported): def make(self, key): """ - The ingest logic here relies on the assumption that there is only one subject in the arena at a time + The populate logic here relies on the assumption that there is only one subject in the arena at a time The positiondata is associated with that one subject currently in the arena at any timepoints - For multi-animal experiments, a mapping of object_id-to-subject is needed to ingest the right position data + For multi-animal experiments, a mapping of object_id-to-subject is needed to populate the right position data associated with a particular animal """ time_slice_start, time_slice_end = (InArenaTimeSlice & key).fetch1( @@ -402,6 +402,7 @@ def make(self, key): # subject's position data in the time_slices position = InArenaSubjectPosition.get_position(key) + position.rename({"position_x": "x", "position_y": "y"}, inplace=True) # filter for objects of the correct size valid_position = (position.area > 0) & (position.area < 1000) @@ -409,7 +410,8 @@ def make(self, key): # in corridor distance_from_center = tracking.compute_distance( - position[["x", "y"]], (tracking.arena_center_x, tracking.arena_center_y) + position[["x", "y"]], + (tracking.arena_center_x, tracking.arena_center_y), ) in_corridor = (distance_from_center < tracking.arena_outer_radius) & ( distance_from_center > tracking.arena_inner_radius @@ -464,7 +466,7 @@ def make(self, key): in_patch = tracking.is_in_patch( position, patch_position, - wheel_data.distance_travelled.values, + wheel_data.distance_travelled, patch_radius=0.2, ) @@ -528,7 +530,6 @@ class FoodPatch(dj.Part): ) def make(self, key): - raw_data_dir = acquisition.Experiment.get_data_directory(key) in_arena_start, in_arena_end = (InArena * InArenaEnd & key).fetch1( "in_arena_start", "in_arena_end" ) @@ -543,6 +544,7 @@ def make(self, key): # subject's position data in this session position = InArenaSubjectPosition.get_position(key) + position.rename({"position_x": "x", "position_y": "y"}, inplace=True) valid_position = (position.area > 0) & ( position.area < 1000 diff --git a/aeon/dj_pipeline/report.py b/aeon/dj_pipeline/report.py index d2fab415..785d800d 100644 --- a/aeon/dj_pipeline/report.py +++ b/aeon/dj_pipeline/report.py @@ -45,14 +45,13 @@ class InArenaSummaryPlot(dj.Computed): } def make(self, key): - raw_data_dir = acquisition.Experiment.get_data_directory(key) - in_arena_start, in_arena_end = ( analysis.InArena * analysis.InArenaEnd & key ).fetch1("in_arena_start", "in_arena_end") # subject's position data in the time_slices position = analysis.InArenaSubjectPosition.get_position(key) + position.rename({"position_x": "x", "position_y": "y"}, inplace=True) position_minutes_elapsed = ( position.index - in_arena_start diff --git a/aeon/dj_pipeline/tracking.py b/aeon/dj_pipeline/tracking.py index ab9abec9..ee5909be 100644 --- a/aeon/dj_pipeline/tracking.py +++ b/aeon/dj_pipeline/tracking.py @@ -218,9 +218,9 @@ def get_object_position( # ---------- HELPER ------------------ -def compute_distance(position_df, target): +def compute_distance(position_df, target, xcol="x", ycol="y"): assert len(target) == 2 - return np.sqrt(np.square(position_df[["x", "y"]] - target).sum(axis=1)) + return np.sqrt(np.square(position_df[[xcol, ycol]] - target).sum(axis=1)) def is_in_patch( @@ -236,7 +236,7 @@ def is_in_patch( return in_wheel.groupby(time_slice).apply(lambda x: x.cumsum()) > 0 -def is_position_in_nest(position_df, nest_key): +def is_position_in_nest(position_df, nest_key, xcol="x", ycol="y"): """ Given the session key and the position data - arrays of x and y return an array of boolean indicating whether or not a position is inside the nest @@ -246,7 +246,7 @@ def is_position_in_nest(position_df, nest_key): ) nest_path = path.Path(nest_vertices) - return nest_path.contains_points(position_df[["x", "y"]]) + return nest_path.contains_points(position_df[[xcol, ycol]]) def _get_position( From 3fc918146c937a13a7b102a688be4a4a45ca10e7 Mon Sep 17 00:00:00 2001 From: JR Date: Mon, 27 Jun 2022 18:48:44 -0500 Subject: [PATCH 004/489] Update report.py --- aeon/dj_pipeline/report.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/report.py b/aeon/dj_pipeline/report.py index 785d800d..1a81dd77 100644 --- a/aeon/dj_pipeline/report.py +++ b/aeon/dj_pipeline/report.py @@ -51,8 +51,8 @@ def make(self, key): # subject's position data in the time_slices position = analysis.InArenaSubjectPosition.get_position(key) - position.rename({"position_x": "x", "position_y": "y"}, inplace=True) - + position.rename(columns={"position_x": "x", "position_y": "y"}, inplace=True) + position_minutes_elapsed = ( position.index - in_arena_start ).total_seconds() / 60 From b62d8c9ae4ca4fb6e191a71ae1c21f8224e01081 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 29 Jun 2022 16:26:12 +0000 Subject: [PATCH 005/489] =?UTF-8?q?=E2=9C=A8=20mark=20threshold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test.ipynb | 283 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 tests/test.ipynb diff --git a/tests/test.ipynb b/tests/test.ipynb new file mode 100644 index 00000000..c5f5db12 --- /dev/null +++ b/tests/test.ipynb @@ -0,0 +1,283 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'experiment_name': 'exp0.1-r0', 'subject': 'BAA-1099790', 'in_arena_start': datetime.datetime(2021, 6, 29, 13, 6, 34, 761660)}\n", + "2021-06-29 13:06:34.761660\n", + "2021-06-29 17:11:26.405819\n" + ] + } + ], + "source": [ + "import datajoint as dj\n", + "\n", + "# from aeon.dj_pipeline import acquisition, lab, tracking, analysis, report, qc\n", + "from aeon.dj_pipeline.report import *\n", + "# acquisition.SubjectEnterExit()\n", + "# InArenaSummaryPlot.progress()\n", + "# InArenaSummaryPlot.populate(limit=1)\n", + "# %%\n", + "key_id = 10\n", + "key = (analysis.InArena * analysis.InArenaEnd).fetch('KEY')[key_id]\n", + "in_arena_start, in_arena_end = (\n", + " analysis.InArena * analysis.InArenaEnd & key\n", + ").fetch1(\"in_arena_start\", \"in_arena_end\")\n", + "\n", + "print(key)\n", + "print(in_arena_start)\n", + "print(in_arena_end)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [], + "source": [ + "# subject's position data in the time_slices\n", + "position = analysis.InArenaSubjectPosition.get_position(key)\n", + "position.rename(columns={\"position_x\": \"x\", \"position_y\": \"y\"}, inplace=True)\n", + "position_minutes_elapsed = (\n", + " position.index - in_arena_start\n", + ").total_seconds() / 60" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "No artists with labels found to put in legend. Note that artists whose label start with an underscore are ignored when legend() is called with no argument.\n" + ] + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA84AAAHwCAYAAAB3+jasAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjUuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8qNh9FAAAACXBIWXMAAAsTAAALEwEAmpwYAADDiUlEQVR4nOzdd5xcdfX/8dfZ3XQS0jaFFJJAEkILJXRFioHEAhbgC4gi8jWCAir6VbAxqPzsKOhXFL+AKKCCooASINIUqaGXEAgkJJuEZDed9M2e3x+fO8nsZnZ3yp2676eP+7hz+5ndMeyZ8ynm7oiIiIiIiIhIejWlDkBERERERESknClxFhEREREREemAEmcRERERERGRDihxFhEREREREemAEmcRERERERGRDihxFhEREREREemAEmeRLszMJprZs2a2zswuKvKz321mc4v5zEIysx5m9oqZDYu2f2tm3+3g/HfMbFzMMcw0s7PjvGelMbPR0c+2NoNzh5rZHDPrUYzYREREpHIpcRbp2r4CPOTufd396mI+2N3/7e4Ti/nM9pjZMWbWkOdtZgD/cve3MznZ3Xdx9zfzfGbbe0539xvjvGeuzOw7ZvaimTWbWSLN8TPN7C0zW29mfzOzgXE8190XRj/bbRmcuwx4kPC7ExEREWmXEmeRrm134OVcLjSzuphjKQgLivFv3WeA3xfhOZViHuGLmX+0PWBm+wC/Bj4ODAU2AL8sanQ73Ez43YmIiIi0S4mzSBdlZg8AxwK/iJq2TjCzXc3sd2bWGFUDv5FMOs3sk2b2HzP7qZmtBBJmtoeZPWBmK8ysycxuNrP+Kc84KKUp+G1m9qdk8+W2VV4zW2BmXzazF8xsTXRuz5TjXzGzpWa2xMz+28zczPZs5709ZGZXmNl/CEnZODM7J2qWu87M3jSzz0Tn9gFmArtFP4d3zGw3M6sxs0vM7I3o/d3aXlXUzEYDewBPtDk02MxmRc982Mx2T7lme/xRs+7/NbN/ROc+YWZ7tPOsnmZ2UxTTajN7ysyGprzv/45eP5/yft6JnndMdOxwM3s0uv755P44ufuN7j4TWJfm8MeAu9z9X+7+DvBN4CNm1red97zAzP4n+mysN7PrombWM6Of1z/NbEB07pjovdZF2w9F1e//ROfeZ2aDU27/BOHzsXu6Z4uIiIiAEmeRLsvdjwP+DVwQNW19Dfg5sCswDngP8AngnJTLDgPeBIYAVwAGfA/YDZgEjAISAGbWHfgr8FtgIPAH4MOdhHUaMA0YC+wPfDK61zTgYuC9wJ5RbJ35OKEJbl/gLWA58AGgX/SefmpmB7n7emA6sCT6Oezi7kuAi4APRc/aDVgF/G87z9oPeNPdm9vs/xjwHWAw8ByhutmeM4DLgQGEau0V7Zx3NuF3NAoYBJwHbGx7krtPTr4fws9uLvCMmY0gVIG/S/i9fBn4i5nVp3uYmf09SrDTLX/v4P10ZB/g+ZRY3wC2ABM6uOajwNTonA8Svuz4GuFnW0P4fbXnTMLvfAjQnfCek89uJvy8J+fwPkRERKSLUOIsIgBYGEzpv4BL3X2duy8AfkJIQJOWuPvP3b3Z3Te6+zx3n+Xum929EbiSHUnt4UAdcLW7b3X324EnOwnjandf4u4rgbuAA6L9pwE3uPvL7r6BkGB25rfR+c3R8//h7m948DBwH/DuDq7/DPB1d29w982ELwROsfRN1PuTvrL6j6iquhn4OnCEmY1q53m3u/uTUSJ3Mzvee1tbCQnznu6+zd2fdve17b0JM3sXIUk+KTrvLOBud7/b3VvcfRYwG3hfuuvd/QPu3r+d5QPtPbcTuwBr2uxbQ/iSoz0/d/dl7r6Y8IXPE+7+bPSz/StwYAfX3uDur7n7RuBWdv7ZriP8DkVERETSUuIsIkmDCdW4t1L2vQWMSNlelHqBmQ0xsz+a2WIzWwvcFN0HQpV2sbt7e9enkTqw1gZCgpW8V+q1nd0nXazTzexxM1tpZqsJieLgtFcGuwN/TVZXgTnANkKf3LZWkT7p2x5D1CR5JeG9pNPee2/r98C9wB+jZus/NLNu6U6MkvRbgbOjFgXJ93VqauUYeBcwvJ3nFcI7hMp/qn6k//IhaVnK641pttv7eUHnP9u+wOoOrhcREZEuTomziCQ1EaqZqX09RwOLU7ad1r4X7dvf3fsRqpkWHVsKjDAzSzm/vWprZ5YCI7O8z/ZYLUw39Bfgx8BQd+8P3J0Sa9v3BSHpnd6mwtozqni29QKhn2zbavT2OM1sF0LT6CUZxN6uqHp+ubvvDRxJaH7+ibbnmVkv4G/Az6K+xqnv6/dt3lcfd/9+uudF/YjfaWeZme6aDLxMStNoC9Ny9QBea/eKAol+Z3uS0nRcREREpC0lziICQDR9z63AFWbWNxos6WJCFbk9fQnVw9VR39n/STn2GKFCe4GZ1ZnZycChOYZ3K3COmU0ys97At7K8vjshMWsEms1sOnBCyvFlwCAz2zVl368IP4vdAcysPnoPO3H3BuB1dn5/7zOzd0X9vb9DaF6cSbW8XWZ2rJntFzWtX0v4siPd1EvXA6+6+w/b7L8J+KCZnWhmtdFgY8eY2cg090hOcbVLO8v0DuLsZmFwtxqgLnpOcm7lm6MY3m1hcLZvE5qqd1RxLpRDgQXu/lanZ4qIiEiXpcRZRFJdCKwnDAD2CHALIQFrz+XAQYT+qf8Abk8ecPctwEeAcwnNYM8C/g5szjaoqGJ6NWHO3XmEpJxM7xUlZBcREvBVhMGi7kw5/iph8LI3o+bLuwFXRefcZ2brgMcJg6O1Jzm9UqpbgMsITbQPJgwWlq9hwJ8JSfMc4GHSf7lxOvDhNhXid0eJ+8mEgbUaCRXo/yH+/x78htCE+gxC/+6NRD8fd3+ZMKjZzYRB2/oCn435+Zn6GOFLEhEREZF2WevuhyIihWNmTwC/cvcb8rzPJOAloEeakaxLImoO/ixwvLsvLXU80jkzG0L44uFAd99U6nhERESkfClxFpGCMbP3EKZBamJHZW9cLomlmX2YUNXuA9wItLj7h+KLVkREREQkPTXVFpFCmkgYdGkN8CXglDyqsZ8hNC1+g9Cn9/xYIhQRERER6YQqziIiIiIiIiIdUMVZREREREREpANKnEVEREREpGTMbJqZzTWzeWZ2STvnHGNmz5nZy2b2cLFjFOlyTbWnTZvm99xzT6nDEBERESlXVugH6O+xLm/7Z8zMaoHXgKlAA/AUcIa7v5JyTn/gUWCauy80syHuvryjB+gz1uXF/u9Yl6s4NzU1lToEERERkS5Nf49JikOBee7+prtvAf4InNzmnDOB2919IUBnSTPoMybx63KJs4iIiIiIlI0RwKKU7YZoX6oJwAAze8jMnjazTxQtOpFIXakDEBERERGRLitdk9q2fUnrgIOB44FewGNm9ri7v9bqRmYzgBkAo0ePLkCo0pWp4iwiIiIiIqXSAIxK2R4JLElzzj3uvt7dm4B/AZPb3sjdr3X3Ke4+pb6+vmABS9ekirOIiIhIldq6dSsNDQ1s2rRpp2M9e/Zk5MiRdOvWrQSRiWz3FDDezMYCi4HTCX2aU90B/MLM6oDuwGHAT4sapXR5SpxFREREqlRDQwN9+/ZlzJgxmO1oEevurFixgoaGBsaOHVvCCKWrc/dmM7sAuBeoBa5395fN7Lzo+K/cfY6Z3QO8ALQA/+fuL5UuaumKlDiLiIiIVKlNmzbtlDQDmBmDBg2isbGxRJGJ7ODudwN3t9n3qzbbPwJ+VMy4RFIpcRaR3PztbzBvHnTr1nqpr4fp06FGQyiIiJSDtklzZ/tFRGRnSpxFJHuzZsGHP9z+8SuvhC9+sXjxiIiIiIgUkEpCIpK9K6+E4cOhsRFWrIC334ZFi+DNN+GYY+DnPwdvO5OEiIiIdMisuhaJnZnFukjmlDiLSHa2boWHH4ZTT4XBg2HgQBg6FEaOhLFj4b/+C+bPh9de6/xeIiJScN7OF5nt7RcRkZ0pcRaR7Dz/PGzcCEcdlf74u94V1k89VbyYREQkrZ49e7JixYqdkuTkqNo9e/YsUWQiIpVFfZxFJDtPPBHWRxyR/vhee0GvXvD003DWWcWLS0REdjJy5EgaGhrSjp6dnMdZREQ6p8RZRLLzyiuw666haXY6dXWw777w8svFjUtERHbSrVs3zdMsIhIDNdUWkezMnQsTJ3Y86Mf48fD668WLSURERESkgJQ4i0h2Xn01JM4dmTAB3noLNm0qTkwiIiIiIgVUEYmzmfU0syfN7Hkze9nMLo/2DzSzWWb2erQeUOpYRarahg2weHFIjDsyZkyYjqqhoShhiYiIiIgUUkUkzsBm4Dh3nwwcAEwzs8OBS4D73X08cH+0LSKFkkyEd9+94/OS/Z8XLy5sPCIiIiIiRVARibMH70Sb3aLFgZOBG6P9NwIfKn50Il1IMnHubBTW5HFVnEVERESkClRE4gxgZrVm9hywHJjl7k8AQ919KUC0HlLCEEWqX6aJ84gRrc8XEREREalgFZM4u/s2dz8AGAkcamb7Znqtmc0ws9lmNjvdPIYikqFME+dddoH+/ZU4i4iIiEhVqJjEOcndVwMPAdOAZWY2HCBaL2/nmmvdfYq7T6mvry9WqCIld/vtMd9w0SIYNAh69er83JEjlTiLiIiISFWoiMTZzOrNrH/0uhfwXuBV4E7g7Oi0s4E7ShKgSJlqaor5hg0NnVebk0aMUOIsIiIiIlWhrtQBZGg4cKOZ1RKS/Vvd/e9m9hhwq5mdCywETi1lkCJV7+23YfjwzM4dNgxeeaWw8YiIiIiIFEFFJM7u/gJwYJr9K4Djix+RSGXo3TvmGzY2wt57Z3ZufX043x3MYg5ERERERKR4KqKptojk5qyzYr7h8uUhIc7EkCGwaROsXx9zECIiIiIixaXEWaSKPf10jDdbvx42bsw8cU6etzztmH0iIiIiIhVDibNIFYs1cU5O5TYkw+nSk4mzpoATERERkQqnxFlEMpOsHGdbcVbiLCIiIiIVTomziGQmmQBn08cZ1FRbRERERCqeEmeRKvaRj8R4s2QCrKbaIiIiItLFKHEWkcxkW3Hu0wd69VLiLCJSRczsi2b2spm9ZGZ/MLOeZjbQzGaZ2evRekCp4xQRiZsSZ5EqdvvtMd6ssRF69gwJcaaGDFFTbRGRKmFmI4CLgCnuvi9QC5wOXALc7+7jgfujbZGMmdk0M5trZvPMbKfPj5kdY2ZrzOy5aPlWKeKUrq2u1AGISIVYvjwkwmaZX1Nfr4qziEh1qQN6mdlWoDewBLgUOCY6fiPwEPDVUgQnlcfMaoH/BaYCDcBTZnanu7/S5tR/u/sHih6gSEQVZxHJTGNj5s20kwYNgpUrCxOPiIgUlbsvBn4MLASWAmvc/T5gqLsvjc5ZCqQdDMPMZpjZbDOb3agvVavSYuBR4F/J5V//yuSyQ4F57v6mu28B/gicXLAgRXKkxFmkih18cIw3yyVxHjBAibOISJWI+i6fDIwFdgP6mNlZmV7v7te6+xR3n1Kf7X9PpOx9FTgK+C7wo2j58Y9/nMmlI4BFKdsN0b62jjCz581sppntk2e4IllTU22RKhZr4rxiBey1V3bXDBgAq1bFGISIiJTQe4H57t4IYGa3A0cCy8xsuLsvNbPhgAa36IL+BswFeqTuvPPOTC5N1wfM22w/A+zu7u+Y2fuix43f6UZmM4AZAKNHj87k2SIZU8VZpIrddFOMN1u1KiTC2RgwAFavBm/73z8REalAC4HDzay3mRlwPDAHuBM4OzrnbOCOEsUnJTQO2JrbpQ3AqJTtkYS+89u5+1p3fyd6fTfQzcwGt72RWjVIIaniLFLFNmyI6UYtLbBmDfTvn911AwbAtm2wbh306xdTMCIiUgru/oSZ/ZlQ/WsGngWuBXYBbjWzcwnJ9amli1JKpTdwAOHblO1V54su4uqrr+7s0qeA8WY2ltBN+nTgzNQTzGwYsMzd3cwOJRT/VsQWvEgGlDiLSOfWrg1V42wrzgMHhvWqVUqcRUSqgLtfBlzWZvdmQr4kXdhJ0dJKBn3G3L3ZzC4A7iVMcXa9u79sZudFx38FnAKcb2bNwEbgdHc1Z5PiUuIsUsUG79SIKUfJfsq5VJyT1+++e0zBiIiISLk5G9gCvBZtTwS6nX12+xekiJpf391m369SXv8C+EUsgYrkSH2cRarYRz4S041Wrw7rXPo4g0bWFhERqXIPEUbr+hzwWWACGU9HJVIRlDiLVLHY/nuVrDjnmjhrZG0REZGq9iXgPuBhwhzO9wJf/OIXSxqTSJyUOItUsVdfjelGcTTVFhERkaq1ldA8O2kCsHVrjuNsi5Qh9XEWkc7l2lQ7dXAwERERqVpTgHOBj0fbNwMHZzA4mEilqIiKs5mNMrMHzWyOmb1sZp+P9ifMbLGZPRct7yt1rCJVKdeKc58+UFenxFlERKTKXQPsA1wNXAXsDfzqV7/q8BqRSlIpFedm4Evu/oyZ9QWeNrNZ0bGfuvuPSxibSNk666yYbrR6NdTWQt++2V1nFqrUGhxMRESkqvUALo6WHTt7pD9ZpAJVRMXZ3Ze6+zPR63XAHGBEaaMSKX+NjTHdaNWqUG02y/7aAQNUcRYREalSp0Xr/YD92y7771+qsERiVykV5+3MbAxwIPAEcBRwgZl9AphNqErrL3SRyL33wowZMdwomTjnopwS53nz4KtfhXfeCU3Ia2vDknxdVxeWz3wGjjii1NGKiIiUvaui9d/THbzrriJGIlJYFVFxTjKzXYC/AF9w97WE7hR7AAcAS4GftHPdDDObbWazG2MrwYl0IatXZz8wWFI5Jc4XXhj+I75mDSxfDosWwRtvwMsvw7PPwuOPw403wimngEYCFRER6dTwaP1LYPc2yy9/+ctShSUSu4pJnM2sGyFpvtndbwdw92Xuvs3dW4DfAIemu9bdr3X3Ke4+pb6+vnhBi1SLaqg4v/463HMPJBIhQX7qqZAsv/ACvPIKzJ0bKtK33w5LlsC//13qiEVERCrGrDT7Zs6cWfQ4RAqlIhJnMzPgOmCOu1+Zsn94ymkfBl4qdmwi5ezd747pRvlWnJPTWZXSww+H9SmndHzeCSdA9+6hnbuIiIh06BpC/+a5tO7fPBb1cZbqUil9nI8iTAv3opk9F+37GnCGmR0AOLAA+EwpghMpV5MmxXSjVavyT5zdcxtcLC6PPQaDBsH48R2f16cP7L9/qEiLiIhIh84EpgOXAt9P2d8XGHjTTSWJSaQQKiJxdvdHgHR/cd9d7FhEKsm118YwOJh7/k21t22DdeugX788g8nDY4/B4YdnlrxPmQJ/+EPpk30REZEyt2u0/CHaXg5sAt4B3lm4kNGjR5cqNJFYVURTbREpoU2bYMuW/CrOUNp+zlu2wGuvwQEHZHb+PvvsGEBMREREOnUXMJ7QRPs9wBhg+vTppQxJJFZKnEWkY8mEN9eKc/K6UibO8+eHqveECZmdP3ZsWL/5ZuFiEhERqSLfAB4HJgDzgfuBo446qqQxicRJibNIFYuldVRyYK9Krji/9lpYT5yY2fnJxHn+/MLEIyIiUmW6AYOAlmg5FnjuuedKGZJIrCqij7OI5GbatBhukkx4Kzlxnjs3rDsbGCxpzJiwVuIsIiKSkf6Efs1HAx8DhgB1dUo1pHqo4ixSxe65J4ab5NtUuxwS5zfegIEDw5KJ3r1h2DAlziIiIhm6A+gN/BSYBuwB3HXXXSWNSSRO+hpIOrRmTcghunUrdSSSi4ULY7hJNTTVXrwYRo7M7pqxY5U4i4iIZGAbcDLwT0JV7uzkgUGDShWSSOxUcZZ2XXZZKDJ27w6f/3ypo5GSybepdt++UFu7IwEvhSVLYMSI7K7ZbTd4++3CxCMiIlJFagnV5jWlDkSkgJQ4S1qbNsGPfwzvfjeceSZcfTVcd12po5KSSCbOu+6a2/Vm4RuYUlecs02chw1T4iwiIpKhnsB+wLnARcnlootKGpNInNRUW9J66CHYsAEuvRROOAEaG+Fzn4ODDoIDDyx1dJKpGTNiuMnq1bDLLvm11x8woHSJ89atsGxZqCBnY9gwWLkSNm+GHj0KE5uISJE988wzHR4/6KCDihSJVJv3R0srBx9cgkhECkOJs6R1773Qqxcce2xoZXvzzSFp/uhH4emnc2+1K8U1Zw5MmpTnTVatyn1gsKRSJs5vvw3uuVWcAZYvh1Gj4o9LRKQEvvSlLwGwadMmZs+ezeTJk3F3XnjhBQ477DAeeeSREkcolerstDvT7hWpSGqqLWk9+ywccAD07Bm26+vhttugoQE+8QloaSlpeJKhf/87hpusXp3/NyWlbKq9ZElY55o4q7m2iFSRBx98kAcffJDdd9+dZ555htmzZ/P000/z7LPPsueee5Y6PKlgY4FxbZdx40oak0icVHGWnbjDCy/Aaae13n/44fDTn8IFF8All8DJJ4cRt/faK1SnpUqtWpV/4jxgALz1VjzxZGvx4rBW4iwist2rr77Kfvvtt31733335bnnnitdQFLxZqe83gTcBqw866wSRSMSP1WcZSeLF4dcKeW/p9t99rPwsY/Bj34E73pXaL59xBHQ3Fz8OKVIKr2pdjJxzqWPMyhxFpGqNGnSJP77v/+bhx56iIcffphPf/rTTMq7b490ZYNSlhHAF4AHHnggo2vNbJqZzTWzeWZ2SQfnHWJm28zslBhCFsmKKs6VrKUlDHndp0+sQ16//HJY77vvzsfM4MYbQ9V57VqYORN+9jO4/3448cTYQpCYxPI7iaOpdjJxdg8fomJasiQMbDZ4cHbXDRkS1kqcRaQK3XDDDVxzzTVcddVVABx99NGcf/75JY5KKlnqsHMthAr0unXrOr3OzGqB/wWmAg3AU2Z2p7u/kua8HwD3xhWzSDaUOFeyRx+FP/0pvL7oIpg8OZbbLlgQ1u11S6mtDc22IUxX9etfhwRaiXP5qa+P4SZxVZybm8NQ7X36xBBUFpYsgeHDoSbLBjbdu4e4ly8vTFwiIiXUs2dPzjvvPN73vvcxceLEUocjVeBLKa/rgDHArbfemsmlhwLz3P1NADP7I3Ay8Eqb8y4E/gIckm+sIrlQU+1K9uCDO17/85+x3fatt6CuLrOWrb16wSGHwBNPxPZ4idFNN+V5g+ZmWLcunoozlKa5dmNj7t8gDBoEK1bEG4+ISBm48847OeCAA5g2bRoAzz33HCeddFKJo5JK9mDKMgv4DWT6pcwIYFHKdkO0bzszGwF8GPhVRzcysxlmNtvMZjc2NmYevEgGlDhXshdegD33DKNzZdiHJBMLFoTZd2prMzt/yhR47rkwXa5UmTVrwrqSE+cVK0ICnIuBA8NcziIiVebyyy/nySefpH/UouiAAw5gQbLJmUgWruxoufLKTG6Rrg+Xt9n+GfBVd9/W0Y3c/Vp3n+LuU+pjaXYnsoOaaleyF16A/fcPgxj97nchc+3WLe/bvvUWjBmT+fkHHgibNsHrr8Pee+f9eCknyUQ3jqbaqfcrphUrwhdMuRg0KFSsRUSqTF1dHbvuumupw5AqkOzFPBd4Cki2W7gLOPqVtq2t02oARqVsjwSWtDlnCvBHC+OkDAbeZ2bN7v633KIWyZ4qzpVqw4aQqe6/Pxx3HLzzDsye3fl1GViwAHbfPfPzk4NwvvpqLI+XGO21V543WL06rOOYxxkqr+KsptoiUqX23XdfbrnlFrZt28brr7/OhRdeyJFHHlnqsKQCXRYtTYQBwn4SLU8DDQ0NmdziKWC8mY01s+7A6cCdqSe4+1h3H+PuY4A/A59V0izFVtTE2cw+Ymavm9kaM1trZuvMbG0xY6ga8+eHEYonToT3vCfsi6G59ubNsHRpdhXnZPcVJc7l5+ij87xBpVecm5tD8p9PU20lziJShX7+85/z8ssv06NHD84880x23XVXfvazn3V6nZn1N7M/m9mrZjbHzI4ws4FmNiv6G2+WmeX5batUooVA95Tt7pBR8393bwYuIIyWPQe41d1fNrPzzOy8AoQqkpNiN9X+IfBBd5+TzUVmNgr4HTCMMML9te5+lZkNBP5EGLhvAXCau5dostgie+utsB4zJkyzM3lySJy//vW8brtkScjHR43q/NykXXaBkSNhTla/VSmG22+Hj3wkjxvEVXEuVeKcfF4+Fee1a2PrBiEiUg62bdvGSSedxD//+U+uuOKKbC+/CrjH3U+JqoO9ga8B97v796M5eC8Bvhpv1FLuPk4YHvvDhE7LfwXOPvvsjK5197uBu9vsSzsQmLt/Mp84RXJV7Kbay7JNmiPNwJfcfRJwOPA5M9ub8A/z/e4+Hrg/2u4akt/gJdtUH3cc/Oc/obNxHpqawjo5hW2mJkyAefPyerQUQPL3mbNk4plv4rzrrmH+5mInzslqcT6JM5SmibmISIHU1tbSu3dv1iQHgMyQmfUDjgauA3D3Le6+mjB10I3RaTcCH4otWKkYXwduAAYA/aPXl156aSlDEolVsSvOs83sT8DfgM3Jne5+e0cXuftSYGn0ep2ZzSEMU38ycEx02o3AQ3SVbzjfeivMMzt0aNg+7jj46U/h8cfhmGNyvm0y0co2z9hjD/jb33J+rJSruJpq19SE5LnYCWiuH+ik5HUrVmT/bZKISBnr2bMn++23H1OnTqVPnz7b91999dUdXTYOaARuMLPJhG6snweGRn+r4e5LzUz/YHZRB0WLSDUqduLcD9gAnJCyz4EOE+dUZjYGOBB4gq78D3VyBK+aqNHA0UeH+aMeeCCvxDlZoBs8OLvrxo0Lgw+vWwd9++b8eIlZ79553mD16tBEOe8bEarWyabfxZJvxXngwNb3ERGpEu9///t5//vfn+1ldYS86EJ3f8LMriKL1n5mNgOYATB69Ohsny0iUlJFTZzd/Zx8rjezXYC/AF9w97XRkPSZXFd9/1C3Hfq6X78wofIDD8C3v53zbZMFumwT5z32COv588NA31IezjorzxusWhUS3gz/v9ahAQMqt6m2EmcRqTJnn302GzduZOHChUxMjvLZuQagwd2fiLb/TEicl5nZ8KiIMRxYnu5id78WuBZgypQpbefpFREpa0Xp42xmX4nWPzezq9suGd6jGyFpvjmlafey6B9oOvuHuuomQ3/rrZ3njDruOHjiiTA1VY6amna0qs3GuHFh/cYbOT9aCuDpp/O8werV+TfTTqrkxHnlynjiEREpE3fddRcHHHAA06ZNA+C5557jpJNO6vAad38bWGRmyUz7eOAVwtRByVGgzgbuKEjQIiIlVKzBwZIDgs0m9Idpu3TIQmn5OmCOu1+Zcqhr/kPd3AzLl4ehrFMdd1w49sgjOd86OeVtTZafjGTF+c03c360FEDeiXOy4hyH/v1Lkzh36xaGfs9F8kuDYjcxFxEpsEQiwZNPPkn/6N+5Aw44gPnz52dy6YXAzWb2AnAA8P+A7wNTzex1YGq0LV3M7cB4YFdC38y+QL9+/Uoak0icitJU293vitY3dnZuO44ijHL/opk9F+37GuEf5lvN7FzC9HGn5hlqZVixIswZ1bZ6fuSRIUl44AGIvkHOVlNT9s20IeQXAwYoca46q1bt6Oebr1JVnAcNyr2peb9+4dosR54VESl3dXV17NqmeVkmXeDc/TlgSppDx8cSmFSsrwB3AZNSd65dW5pgRAqgqH2czWwKYbT63VOf7e4d9op190cIU8Kl0/X+oV4etUhvO8pv795wxBHwz3/mfOtknpGLcePUVLvqrF69ozlBvko1OFiuH2gITS/69VPFWUSqzr777sstt9zCtm3beP3117n66qs58sgjSx2WVLChtEmaRapMsUfVvhn4H+BFoKXIz64e7SXOAB/+MHzxizBxYviDv64uVKGT60MOge98p90KXFNT7nnSHnvAc8/ldq0Uxkc+kucN4myqPWBAmGd80ybo2TOee3Ym38QZQod/Jc4iUmV+/vOfc8UVV9CjRw/OPPNMTjzxRL7xjW+UOiyp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", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "\n", + "# figure\n", + "fig = plt.figure(figsize=(16, 8))\n", + "gs = fig.add_gridspec(21, 6)\n", + "rate_ax = fig.add_subplot(gs[:10, :4])\n", + "distance_ax = fig.add_subplot(gs[10:20, :4])\n", + "ethogram_ax = fig.add_subplot(gs[20, :4])\n", + "position_ax = fig.add_subplot(gs[10:, 4:])\n", + "pellet_ax = fig.add_subplot(gs[:10, 4])\n", + "time_dist_ax = fig.add_subplot(gs[:10, 5:])\n", + "\n", + "# position plot\n", + "non_nan = np.logical_and(~np.isnan(position.x), ~np.isnan(position.y))\n", + "analysis_plotting.heatmap(\n", + " position[non_nan], 50, ax=position_ax, bins=500, alpha=0.5\n", + ")\n", + "\n", + "# event rate plots\n", + "in_arena_food_patches = (\n", + " analysis.InArena\n", + " * acquisition.ExperimentFoodPatch.join(\n", + " acquisition.ExperimentFoodPatch.RemovalTime, left=True\n", + " )\n", + " & key\n", + " & \"in_arena_start >= food_patch_install_time\"\n", + " & 'in_arena_start < IFNULL(food_patch_remove_time, \"2200-01-01\")'\n", + ").proj(\"food_patch_description\")\n", + "\n", + "for food_patch_key in in_arena_food_patches.fetch(as_dict=True):\n", + " pellet_times_df = (\n", + " (\n", + " acquisition.FoodPatchEvent * acquisition.EventType\n", + " & food_patch_key\n", + " & 'event_type = \"TriggerPellet\"'\n", + " & f'event_time BETWEEN \"{in_arena_start}\" AND \"{in_arena_end}\"'\n", + " )\n", + " .proj(\"event_time\")\n", + " .fetch(format=\"frame\", order_by=\"event_time\")\n", + " .reset_index()\n", + " )\n", + " pellet_times_df.set_index(\"event_time\", inplace=True)\n", + " analysis_plotting.rateplot(\n", + " pellet_times_df,\n", + " window=\"600s\",\n", + " frequency=500,\n", + " ax=rate_ax,\n", + " smooth=\"120s\",\n", + " start=in_arena_start,\n", + " end=in_arena_end,\n", + " color=InArenaSummaryPlot.color_code[food_patch_key[\"food_patch_description\"]],\n", + " )\n", + " \n", + " # mark threshold changes\n", + " wheel_time, wheel_threshold = (acquisition.WheelState.Time & \n", + " food_patch_key & \n", + " f'state_timestamp between \"{in_arena_start}\" and \"{in_arena_end}\"').fetch('state_timestamp', 'threshold')\n", + " wheel_time -= in_arena_start \n", + " wheel_time /= datetime.timedelta(minutes=1)\n", + " \n", + " \n", + " threshold_change_ind = np.where(wheel_threshold[:-1] != wheel_threshold[1:])[0]\n", + " # rate_ax.vlines(wheel_time[threshold_change_ind], 0, rate_ax.get_ylim()[1], linewidth=1, linestyle='--', \n", + " # color=InArenaSummaryPlot.color_code[food_patch_key[\"food_patch_description\"]], alpha=0.2)\n", + " rate_ax.vlines(wheel_time[threshold_change_ind], 0, 30, linewidth=1, linestyle='--', \n", + " color=InArenaSummaryPlot.color_code[food_patch_key[\"food_patch_description\"]], alpha=0.4)\n", + " \n", + " # wheel data\n", + " wheel_data = acquisition.FoodPatchWheel.get_wheel_data(\n", + " experiment_name=key[\"experiment_name\"],\n", + " start=pd.Timestamp(in_arena_start),\n", + " end=pd.Timestamp(in_arena_end),\n", + " patch_name=food_patch_key[\"food_patch_description\"],\n", + " using_aeon_io=True,\n", + " )\n", + "\n", + " minutes_elapsed = (wheel_data.index - in_arena_start).total_seconds() / 60\n", + " distance_ax.plot(\n", + " minutes_elapsed,\n", + " wheel_data.distance_travelled.values,\n", + " color=InArenaSummaryPlot.color_code[food_patch_key[\"food_patch_description\"]],\n", + " )\n", + "\n", + "# ethogram\n", + "in_arena, in_corridor, arena_time, corridor_time = (\n", + " analysis.InArenaTimeDistribution & key\n", + ").fetch1(\n", + " \"in_arena\",\n", + " \"in_corridor\",\n", + " \"time_fraction_in_arena\",\n", + " \"time_fraction_in_corridor\",\n", + ")\n", + "nest_keys, in_nests, nests_times = (\n", + " analysis.InArenaTimeDistribution.Nest & key\n", + ").fetch(\"KEY\", \"in_nest\", \"time_fraction_in_nest\")\n", + "patch_names, in_patches, patches_times = (\n", + " analysis.InArenaTimeDistribution.FoodPatch * acquisition.ExperimentFoodPatch\n", + " & key\n", + ").fetch(\"food_patch_description\", \"in_patch\", \"time_fraction_in_patch\")\n", + "\n", + "ethogram_ax.plot(\n", + " position_minutes_elapsed[in_arena],\n", + " np.full_like(position_minutes_elapsed[in_arena], 0),\n", + " \".\",\n", + " color=InArenaSummaryPlot.color_code[\"arena\"],\n", + " markersize=0.5,\n", + " alpha=0.6,\n", + " label=f\"Times in arena\",\n", + ")\n", + "ethogram_ax.plot(\n", + " position_minutes_elapsed[in_corridor],\n", + " np.full_like(position_minutes_elapsed[in_corridor], 1),\n", + " \".\",\n", + " color=InArenaSummaryPlot.color_code[\"corridor\"],\n", + " markersize=0.5,\n", + " alpha=0.6,\n", + " label=f\"Times in corridor\",\n", + ")\n", + "for in_nest in in_nests:\n", + " ethogram_ax.plot(\n", + " position_minutes_elapsed[in_nest],\n", + " np.full_like(position_minutes_elapsed[in_nest], 2),\n", + " \".\",\n", + " color=InArenaSummaryPlot.color_code[\"nest\"],\n", + " markersize=0.5,\n", + " alpha=0.6,\n", + " label=f\"Times in nest\",\n", + " )\n", + "for patch_idx, (patch_name, in_patch) in enumerate(\n", + " zip(patch_names, in_patches)\n", + "):\n", + " ethogram_ax.plot(\n", + " position_minutes_elapsed[in_patch],\n", + " np.full_like(position_minutes_elapsed[in_patch], (patch_idx + 3)),\n", + " \".\",\n", + " color=InArenaSummaryPlot.color_code[patch_name],\n", + " markersize=0.5,\n", + " alpha=0.6,\n", + " label=f\"Times in {patch_name}\",\n", + " )\n", + "\n", + "# pellet\n", + "patch_names, patches_pellet = (\n", + " analysis.InArenaSummary.FoodPatch * acquisition.ExperimentFoodPatch & key\n", + ").fetch(\"food_patch_description\", \"pellet_count\")\n", + "pellet_ax.bar(\n", + " range(len(patches_pellet)),\n", + " patches_pellet,\n", + " color=[InArenaSummaryPlot.color_code[n] for n in patch_names],\n", + ")\n", + "\n", + "# time distribution\n", + "time_fractions = [arena_time, corridor_time]\n", + "colors = [InArenaSummaryPlot.color_code[\"arena\"], InArenaSummaryPlot.color_code[\"corridor\"]]\n", + "time_fractions.extend(nests_times)\n", + "colors.extend([InArenaSummaryPlot.color_code[\"nest\"] for _ in nests_times])\n", + "time_fractions.extend(patches_times)\n", + "colors.extend([InArenaSummaryPlot.color_code[n] for n in patch_names])\n", + "time_dist_ax.bar(range(len(time_fractions)), time_fractions, color=colors)\n", + "\n", + "# cosmetic\n", + "rate_ax.legend()\n", + "rate_ax.sharex(distance_ax)\n", + "fig.subplots_adjust(hspace=0.1)\n", + "rate_ax.set_ylabel(\"pellets / min\")\n", + "rate_ax.set_title(\"foraging rate (bin size = 10 min)\")\n", + "distance_ax.set_ylabel(\"distance travelled (m)\")\n", + "ethogram_ax.set_xlabel(\"time (min)\")\n", + "analysis_plotting.set_ymargin(distance_ax, 0.2, 0.1)\n", + "for ax in (rate_ax, distance_ax, pellet_ax, time_dist_ax):\n", + " ax.spines[\"top\"].set_visible(False)\n", + " ax.spines[\"right\"].set_visible(False)\n", + " ax.spines[\"bottom\"].set_visible(False)\n", + " ax.tick_params(bottom=False, labelbottom=False)\n", + "\n", + "ethogram_ax.spines[\"top\"].set_visible(False)\n", + "ethogram_ax.spines[\"right\"].set_visible(False)\n", + "ethogram_ax.spines[\"left\"].set_visible(False)\n", + "ethogram_ax.tick_params(left=False, labelleft=False)\n", + "analysis_plotting.set_ymargin(ethogram_ax, 0.4, 0)\n", + "\n", + "position_ax.set_aspect(\"equal\")\n", + "position_ax.set_axis_off()\n", + "\n", + "pellet_ax.set_ylabel(\"pellets delivered\")\n", + "time_dist_ax.set_ylabel(\"Fraction of session duration\")\n", + "\n", + "fig.show()\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "language_info": { + "name": "python" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} From cd565c71a06fcccbf870528af7dce421c33d9ad3 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Thu, 30 Jun 2022 03:47:13 +0000 Subject: [PATCH 006/489] =?UTF-8?q?=E2=9C=A8=20plot=20threshold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test.ipynb | 186 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 146 insertions(+), 40 deletions(-) diff --git a/tests/test.ipynb b/tests/test.ipynb index c5f5db12..4b90b619 100644 --- a/tests/test.ipynb +++ b/tests/test.ipynb @@ -2,16 +2,24 @@ "cells": [ { "cell_type": "code", - "execution_count": 17, + "execution_count": 6, "metadata": {}, "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "[2022-06-29 22:32:34,663][INFO]: Connecting jaeronga@aeon-db2:3306\n", + "[2022-06-29 22:32:34,683][INFO]: Connected jaeronga@aeon-db2:3306\n" + ] + }, { "name": "stdout", "output_type": "stream", "text": [ - "{'experiment_name': 'exp0.1-r0', 'subject': 'BAA-1099790', 'in_arena_start': datetime.datetime(2021, 6, 29, 13, 6, 34, 761660)}\n", - "2021-06-29 13:06:34.761660\n", - "2021-06-29 17:11:26.405819\n" + "{'experiment_name': 'exp0.1-r0', 'subject': 'BAA-1099790', 'in_arena_start': datetime.datetime(2021, 10, 1, 8, 38, 14, 983300)}\n", + "2021-10-01 08:38:14.983300\n", + "2021-10-01 12:47:43.022240\n" ] } ], @@ -20,11 +28,12 @@ "\n", "# from aeon.dj_pipeline import acquisition, lab, tracking, analysis, report, qc\n", "from aeon.dj_pipeline.report import *\n", + "\n", "# acquisition.SubjectEnterExit()\n", "# InArenaSummaryPlot.progress()\n", "# InArenaSummaryPlot.populate(limit=1)\n", - "# %%\n", - "key_id = 10\n", + "\n", + "key_id = 30\n", "key = (analysis.InArena * analysis.InArenaEnd).fetch('KEY')[key_id]\n", "in_arena_start, in_arena_end = (\n", " analysis.InArena * analysis.InArenaEnd & key\n", @@ -32,12 +41,12 @@ "\n", "print(key)\n", "print(in_arena_start)\n", - "print(in_arena_end)\n" + "print(in_arena_end)" ] }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 7, "metadata": {}, "outputs": [], "source": [ @@ -51,7 +60,38 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 37, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "# figure\n", + "fig = plt.figure(figsize=(22, 9))\n", + "gs = fig.add_gridspec(21, 6)\n", + "threshold_ax = fig.add_subplot(gs[:4, :3])\n", + "rate_ax = fig.add_subplot(gs[6:13, :3])\n", + "distance_ax = fig.add_subplot(gs[14:20, :3])\n", + "ethogram_ax = fig.add_subplot(gs[20, :3])\n", + "position_ax = fig.add_subplot(gs[13:, 4:])\n", + "pellet_ax = fig.add_subplot(gs[2:12, 3])\n", + "time_dist_ax = fig.add_subplot(gs[2:12, 4:])\n" + ] + }, + { + "cell_type": "code", + "execution_count": 42, "metadata": {}, "outputs": [ { @@ -63,9 +103,9 @@ }, { "data": { - "image/png": "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++b8eIlZ79553mD16tBEOe8bEarWyabfxZJvxXngwNb3ERGpEu9///t5//vfn+1ldYS86EJ3f8LMriKL1n5mNgOYATB69Ohsny0iUlJFTZzd/Zx8rjezXYC/AF9w97XRkPSZXFd9/1C3Hfq6X78wofIDD8C3v53zbZMFumwT5z32COv588NA31IezjorzxusWhUS3gz/v9ahAQMqt6m2EmcRqTJnn302GzduZOHChUxMjvLZuQagwd2fiLb/TEicl5nZ8KiIMRxYnu5id78WuBZgypQpbefpFREpa0Xp42xmX4nWPzezq9suGd6jGyFpvjmlafey6B9oOvuHuuomQ3/rrZ3njDruOHjiiTA1VY6amna0qs3GuHFh/cYbOT9aCuDpp/O8werV+TfTTqrkxHnlynjiEREpE3fddRcHHHAA06ZNA+C5557jpJNO6vAad38bWGRmyUz7eOAVwtRByVGgzgbuKEjQIiIlVKzBwZIDgs0m9Idpu3TIQmn5OmCOu1+Zcqhr/kPd3AzLl4ehrFMdd1w49sgjOd86OeVtTZafjGTF+c03c360FEDeiXOy4hyH/v1Lkzh36xaGfs9F8kuDYjcxFxEpsEQiwZNPPkn/6N+5Aw44gPnz52dy6YXAzWb2AnAA8P+A7wNTzex1YGq0LV3M7cB4YFdC38y+QL9+/Uoak0icitJU293vitY3dnZuO44ijHL/opk9F+37GuEf5lvN7FzC9HGn5hlqZVixIswZ1bZ6fuSRIUl44AGIvkHOVlNT9s20IeQXAwYoca46q1bt6Oebr1JVnAcNyr2peb9+4dosR54VESl3dXV17NqmeVkmXeDc/TlgSppDx8cSmFSsrwB3AZNSd65dW5pgRAqgqH2czWwKYbT63VOf7e4d9op190cIU8Kl0/X+oV4etUhvO8pv795wxBHwz3/mfOtknpGLcePUVLvqrF69ozlBvko1OFiuH2gITS/69VPFWUSqzr777sstt9zCtm3beP3117n66qs58sgjSx2WVLChtEmaRapMsUfVvhn4H+BFoKXIz64e7SXOAB/+MHzxizBxYviDv64uVKGT60MOge98p90KXFNT7nnSHnvAc8/ldq0Uxkc+kucN4myqPWBAmGd80ybo2TOee3Ym38QZQod/Jc4iUmV+/vOfc8UVV9CjRw/OPPNMTjzxRL7xjW+UOiyp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", + "image/png": "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HjPbJrquO/BV3G7zSZ/sqD9yMP9oshEnuVxERERERERE6oO6miq1VsysDfAYcIG7rwLuBPoBexAqM26KbZpk9yq33WY2zsxmmdmsJUuW1E3QeaYpTEVERERERKSpqHfJCzNrRkhcPODujwO4+zfuXubu5cBfCENDIFRa9IzbvQewMPGY7n63uw9z92GdOnWq2wvIRg6qLkDVESIiIiIiItI01KvkhZkZ8FfgI3e/OW59t7jNjgbej/48GTjJzFqYWR+gP/B6vuItJCUuREREREREpKnIdcPO2toPOB14z8zeia77FXCyme1BGBLyOfATAHf/wMwmAR8SZio5p77NNJJ8eIcnH/BSDUpeiIiIiIiISFNRr5IX7v4yyW/rn06zz9XA1XUWVC0kS1w4hlVtyyEiIiIiIiIiKdSr5EVjEktcVKmQyE27CxEREREREZEmo171vGhskiYplLkQERERERERqRYlL+pI0hyF5jcVERERERERqTYlL+pA0hyFEhciIiIiIiIiNaLkRT6kbIAhIiIiIiIiIpkoeVEH1OtCREREREREJHeUvMixpKNDlLgQERERERERqTElL0RERERERESkXlPyIsdUZCEiIiIiIiKSW0pe1DXNMiIiIiIiIiJSKyWFDqDRUJJCREREREREpE6o8qKuaRyJiIiIiIiISK2o8iJXlKQQERERERERqROqvBARERERERGRek3JCxERERERERGp15rcsJE333zzWWBMoeMQERERERERkew0ucoLd1fiQkRERERERKQBaXLJCxERERERERFpWJrcsBERERFp2Mzsc2A1UAaUuvswM+sAPAL0Bj4HTnD3FYWKUURERHJLyQsRERFpiEa6+9K45xOA6e5+rZlNiD6/pDChSWOxYMECvvjiC0pLSyvWHXDAAQWMSESk6VLyQkRERBqDI4ER0Z/vBV5AyQuphUuAR/bbj4EDB1JcXAyAmSl5ISJSIEpeiIiISEPjwFQzc+DP7n430MXdFwG4+yIz65xsRzMbB4wD6NWrV77ilQbon8CcOXNo0aJFoUMRERGUvBAREZGGZz93XxhNUEwzs/9lu2M00XE3wLBhw7yuApSGry+wefNmJS9EROoJJS9ERESkQXH3hdHHxWb2BDAc+MbMukWrLroBiwsapDR4rYA99tiDgw8+uFIC47bbbku7n5mNAW4FioH/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", "text/plain": [ - "
" + "
" ] }, "metadata": { @@ -75,16 +115,16 @@ } ], "source": [ - "\n", "# figure\n", - "fig = plt.figure(figsize=(16, 8))\n", + "fig = plt.figure(figsize=(18, 9))\n", "gs = fig.add_gridspec(21, 6)\n", - "rate_ax = fig.add_subplot(gs[:10, :4])\n", - "distance_ax = fig.add_subplot(gs[10:20, :4])\n", - "ethogram_ax = fig.add_subplot(gs[20, :4])\n", - "position_ax = fig.add_subplot(gs[10:, 4:])\n", - "pellet_ax = fig.add_subplot(gs[:10, 4])\n", - "time_dist_ax = fig.add_subplot(gs[:10, 5:])\n", + "threshold_ax = fig.add_subplot(gs[:4, :3])\n", + "rate_ax = fig.add_subplot(gs[6:13, :3])\n", + "distance_ax = fig.add_subplot(gs[14:20, :3])\n", + "ethogram_ax = fig.add_subplot(gs[20, :3])\n", + "position_ax = fig.add_subplot(gs[13:, 2:])\n", + "pellet_ax = fig.add_subplot(gs[2:12, 3])\n", + "time_dist_ax = fig.add_subplot(gs[2:12, 4:])\n", "\n", "# position plot\n", "non_nan = np.logical_and(~np.isnan(position.x), ~np.isnan(position.y))\n", @@ -127,20 +167,6 @@ " color=InArenaSummaryPlot.color_code[food_patch_key[\"food_patch_description\"]],\n", " )\n", " \n", - " # mark threshold changes\n", - " wheel_time, wheel_threshold = (acquisition.WheelState.Time & \n", - " food_patch_key & \n", - " f'state_timestamp between \"{in_arena_start}\" and \"{in_arena_end}\"').fetch('state_timestamp', 'threshold')\n", - " wheel_time -= in_arena_start \n", - " wheel_time /= datetime.timedelta(minutes=1)\n", - " \n", - " \n", - " threshold_change_ind = np.where(wheel_threshold[:-1] != wheel_threshold[1:])[0]\n", - " # rate_ax.vlines(wheel_time[threshold_change_ind], 0, rate_ax.get_ylim()[1], linewidth=1, linestyle='--', \n", - " # color=InArenaSummaryPlot.color_code[food_patch_key[\"food_patch_description\"]], alpha=0.2)\n", - " rate_ax.vlines(wheel_time[threshold_change_ind], 0, 30, linewidth=1, linestyle='--', \n", - " color=InArenaSummaryPlot.color_code[food_patch_key[\"food_patch_description\"]], alpha=0.4)\n", - " \n", " # wheel data\n", " wheel_data = acquisition.FoodPatchWheel.get_wheel_data(\n", " experiment_name=key[\"experiment_name\"],\n", @@ -156,7 +182,22 @@ " wheel_data.distance_travelled.values,\n", " color=InArenaSummaryPlot.color_code[food_patch_key[\"food_patch_description\"]],\n", " )\n", + " \n", + " # mark threshold changes\n", + " wheel_time, wheel_threshold = (acquisition.WheelState.Time & \n", + " food_patch_key & \n", + " f'state_timestamp between \"{in_arena_start}\" and \"{in_arena_end}\"').fetch('state_timestamp', 'threshold')\n", + " wheel_time -= in_arena_start \n", + " wheel_time /= datetime.timedelta(minutes=1)\n", + " \n", + " wheel_time = np.append(wheel_time, position_minutes_elapsed[-1])\n", "\n", + " # plot threshold values\n", + " for i in range(0, len(wheel_time) - 1):\n", + " \n", + " threshold_ax.hlines(y=wheel_threshold[i], xmin=wheel_time[i], xmax=wheel_time[i + 1], linewidth=2, \n", + " color=InArenaSummaryPlot.color_code[food_patch_key[\"food_patch_description\"]])\n", + " \n", "# ethogram\n", "in_arena, in_corridor, arena_time, corridor_time = (\n", " analysis.InArenaTimeDistribution & key\n", @@ -241,9 +282,10 @@ "rate_ax.set_ylabel(\"pellets / min\")\n", "rate_ax.set_title(\"foraging rate (bin size = 10 min)\")\n", "distance_ax.set_ylabel(\"distance travelled (m)\")\n", + "threshold_ax.set_ylabel(\"threshold\")\n", "ethogram_ax.set_xlabel(\"time (min)\")\n", "analysis_plotting.set_ymargin(distance_ax, 0.2, 0.1)\n", - "for ax in (rate_ax, distance_ax, pellet_ax, time_dist_ax):\n", + "for ax in (rate_ax, distance_ax, pellet_ax, time_dist_ax, threshold_ax):\n", " ax.spines[\"top\"].set_visible(False)\n", " ax.spines[\"right\"].set_visible(False)\n", " ax.spines[\"bottom\"].set_visible(False)\n", @@ -261,22 +303,86 @@ "pellet_ax.set_ylabel(\"pellets delivered\")\n", "time_dist_ax.set_ylabel(\"Fraction of session duration\")\n", "\n", - "fig.show()\n" + "fig.show()" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 122, "metadata": {}, - "outputs": [], - "source": [] + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Float64Index([0.0003337833333333333, 0.0006665833333333334,\n", + " 0.0010004500000000002, 0.0013332500000000002,\n", + " 0.0016671166666666667, 0.0019999166666666668,\n", + " 0.0023338, 0.0026665833333333333,\n", + " 0.003000466666666667, 0.003333266666666667,\n", + " ...\n", + " 249.46424953333334, 249.46458233333337,\n", + " 249.4649162, 249.465249,\n", + " 249.4655828666667, 249.4659156666667,\n", + " 249.4662495166667, 249.46658233333335,\n", + " 249.46691618333335, 249.46724898333335],\n", + " dtype='float64', name='timestamps', length=748398)\n", + "[100. 112.5 125. 137.5 150. 162.5 175. 187.5 200. 212.5 225. 237.5\n", + " 250. 262.5 275. 287.5 300. 312.5 325. 337.5 350. 362.5 375. 387.5\n", + " 400. 412.5 425. 437.5 450. 462.5 475. 487.5 500. 512.5 525. 537.5\n", + " 550. 562.5 575. 587.5 600. 612.5 625. 637.5 650. 662.5 675. 687.5\n", + " 700. 712.5 725. 737.5 750. 762.5 775. 787.5 800. 812.5 825. 837.5\n", + " 850. 862.5]\n", + "[4.4666666666666664e-05 3.4698446666666665 3.6094446666666666\n", + " 3.786878333333333 4.013545 4.518578 4.741878316666667 5.205745\n", + " 5.491744983333334 6.003345 6.270478316666667 6.598678316666667 6.88901165\n", + " 7.2462116666666665 7.632078316666667 8.151078316666666 10.376911333333334\n", + " 10.759911333333333 11.130211666666666 11.972911316666666 12.37124465\n", + " 12.933678333333333 18.687411666666666 19.182478333333332 20.056745\n", + " 20.916911333333335 22.161978 24.016611666666666 24.672911316666667\n", + " 34.41367831666667 35.93901165 43.11471133333333 43.775111333333335\n", + " 56.29341165 57.577078316666665 58.254545 60.443545 61.94967833333333\n", + " 77.051345 79.54927833333333 80.24741166666666 81.82771131666667\n", + " 90.34191133333333 91.53231131666666 92.90271131666667 96.44551133333333\n", + " 113.52141166666667 114.30854498333333 115.77034498333333 117.48924465\n", + " 126.70811131666666 128.210545 132.39131131666667 133.28711133333334\n", + " 136.28071133333333 142.37397798333333 143.484145 149.71554498333333\n", + " 183.624145 202.69391133333335 218.04867831666667 243.245178]\n" + ] + } + ], + "source": [ + "# wheel_time, wheel_threshold, position_minutes_elapsed\n", + "print(position_minutes_elapsed)\n", + "print(wheel_threshold)\n", + "print(wheel_time)" + ] } ], "metadata": { + "kernelspec": { + "display_name": "Python 3.9.13 64-bit ('aeon')", + "language": "python", + "name": "python3" + }, "language_info": { - "name": "python" + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.13" }, - "orig_nbformat": 4 + "orig_nbformat": 4, + "vscode": { + "interpreter": { + "hash": "db60b8d28c97257fefe0af538ee53d7167e755182e7d18d3719c6d10bd23282f" + } + } }, "nbformat": 4, "nbformat_minor": 2 From f8f00b1b09d5319eea40591ab83bdf88f93edaed Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 1 Jul 2022 14:23:24 -0500 Subject: [PATCH 007/489] bugfix, update sciviz version --- aeon/dj_pipeline/analysis/in_arena.py | 4 +++- .../webapps/sciviz/docker-compose-local.yaml | 14 +++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/aeon/dj_pipeline/analysis/in_arena.py b/aeon/dj_pipeline/analysis/in_arena.py index 2d53dcfb..895f19ee 100644 --- a/aeon/dj_pipeline/analysis/in_arena.py +++ b/aeon/dj_pipeline/analysis/in_arena.py @@ -201,7 +201,9 @@ class InArenaSubjectPosition(dj.Imported): """ key_source = InArenaTimeSlice & ( - qc.CameraQC * acquisition.ExperimentCamera & 'camera_description = "FrameTop"' + tracking.CameraTracking * acquisition.ExperimentCamera + & f"camera_description in {tuple(set(acquisition._ref_device_mapping.values()))}" + & "tracking_paramset_id = 0" ) def make(self, key): diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml index 77e24637..55e3956e 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml @@ -4,10 +4,12 @@ version: '2.4' services: pharus: - image: jverswijver/pharus:0.2.3.beta.10 + cpus: 2.0 + mem_limit: 4g + image: jverswijver/pharus:0.4.2-Beta.0 environment: # - FLASK_ENV=development # enables logging to console from Flask - - API_SPEC_PATH=/main/specs/specsheet.yaml # for dynamic utils spec + - PHARUS_SPEC_PATH=/main/specs/specsheet.yaml # for dynamic utils spec user: ${HOST_UID}:anaconda volumes: - ./specsheet.yaml:/main/specs/specsheet.yaml #copy the spec over to /main/specs/YOUR_SPEC_NAME @@ -43,11 +45,13 @@ services: networks: - main sci-viz: - image: jverswijver/sciviz:0.1.2-beta.9 + cpus: 2.0 + mem_limit: 4g + image: jverswijver/sci-viz:0.1.3-beta.3 environment: - CHOKIDAR_USEPOLLING=true - - REACT_APP_DJLABBOOK_BACKEND_PREFIX=/utils - - FRONTEND_SPEC_PATH=specsheet.yaml + - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/utils + - DJSCIVIZ_SPEC_PATH=specsheet.yaml volumes: - ./specsheet.yaml:/main/specsheet.yaml # ports: From a9ba92c5673454ef500aa42d8ec5dfbc71870659 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 1 Jul 2022 19:54:07 +0000 Subject: [PATCH 008/489] =?UTF-8?q?=E2=9C=A8=20plot=20threshold=20over=20t?= =?UTF-8?q?ime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aeon/analysis/plotting.py | 2 +- tests/test.ipynb | 389 --------------------------------- tests/threshold.ipynb | 441 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 442 insertions(+), 390 deletions(-) delete mode 100644 tests/test.ipynb create mode 100644 tests/threshold.ipynb diff --git a/aeon/analysis/plotting.py b/aeon/analysis/plotting.py index cf05d075..7be66d3c 100644 --- a/aeon/analysis/plotting.py +++ b/aeon/analysis/plotting.py @@ -91,7 +91,7 @@ def rateplot( **kwargs, ) ax.vlines( - sessiontime(events.index, eventrate.index[0]), -0.2, -0.1, linewidth=1, **kwargs + sessiontime(events.index, eventrate.index[0]), -0.5, -0.1, linewidth=1, **kwargs ) diff --git a/tests/test.ipynb b/tests/test.ipynb deleted file mode 100644 index 4b90b619..00000000 --- a/tests/test.ipynb +++ /dev/null @@ -1,389 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "[2022-06-29 22:32:34,663][INFO]: Connecting jaeronga@aeon-db2:3306\n", - "[2022-06-29 22:32:34,683][INFO]: Connected jaeronga@aeon-db2:3306\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "{'experiment_name': 'exp0.1-r0', 'subject': 'BAA-1099790', 'in_arena_start': datetime.datetime(2021, 10, 1, 8, 38, 14, 983300)}\n", - "2021-10-01 08:38:14.983300\n", - "2021-10-01 12:47:43.022240\n" - ] - } - ], - "source": [ - "import datajoint as dj\n", - "\n", - "# from aeon.dj_pipeline import acquisition, lab, tracking, analysis, report, qc\n", - "from aeon.dj_pipeline.report import *\n", - "\n", - "# acquisition.SubjectEnterExit()\n", - "# InArenaSummaryPlot.progress()\n", - "# InArenaSummaryPlot.populate(limit=1)\n", - "\n", - "key_id = 30\n", - "key = (analysis.InArena * analysis.InArenaEnd).fetch('KEY')[key_id]\n", - "in_arena_start, in_arena_end = (\n", - " analysis.InArena * analysis.InArenaEnd & key\n", - ").fetch1(\"in_arena_start\", \"in_arena_end\")\n", - "\n", - "print(key)\n", - "print(in_arena_start)\n", - "print(in_arena_end)" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [], - "source": [ - "# subject's position data in the time_slices\n", - "position = analysis.InArenaSubjectPosition.get_position(key)\n", - "position.rename(columns={\"position_x\": \"x\", \"position_y\": \"y\"}, inplace=True)\n", - "position_minutes_elapsed = (\n", - " position.index - in_arena_start\n", - ").total_seconds() / 60" - ] - }, - { - "cell_type": "code", - "execution_count": 37, - "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], - "source": [ - "# figure\n", - "fig = plt.figure(figsize=(22, 9))\n", - "gs = fig.add_gridspec(21, 6)\n", - "threshold_ax = fig.add_subplot(gs[:4, :3])\n", - "rate_ax = fig.add_subplot(gs[6:13, :3])\n", - "distance_ax = fig.add_subplot(gs[14:20, :3])\n", - "ethogram_ax = fig.add_subplot(gs[20, :3])\n", - "position_ax = fig.add_subplot(gs[13:, 4:])\n", - "pellet_ax = fig.add_subplot(gs[2:12, 3])\n", - "time_dist_ax = fig.add_subplot(gs[2:12, 4:])\n" - ] - }, - { - "cell_type": "code", - "execution_count": 42, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "No artists with labels found to put in legend. Note that artists whose label start with an underscore are ignored when legend() is called with no argument.\n" - ] - }, - { - "data": { - "image/png": "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HjPbJrquO/BV3G7zSZ/sqD9yMP9oshEnuVxERERERERE6oO6miq1VsysDfAYcIG7rwLuBPoBexAqM26KbZpk9yq33WY2zsxmmdmsJUuW1E3QeaYpTEVERERERKSpqHfJCzNrRkhcPODujwO4+zfuXubu5cBfCENDIFRa9IzbvQewMPGY7n63uw9z92GdOnWq2wvIRg6qLkDVESIiIiIiItI01KvkhZkZ8FfgI3e/OW59t7jNjgbej/48GTjJzFqYWR+gP/B6vuItJCUuREREREREpKnIdcPO2toPOB14z8zeia77FXCyme1BGBLyOfATAHf/wMwmAR8SZio5p77NNJJ8eIcnH/BSDUpeiIiIiIiISFNRr5IX7v4yyW/rn06zz9XA1XUWVC0kS1w4hlVtyyEiIiIiIiIiKdSr5EVjEktcVKmQyE27CxEREREREZEmo171vGhskiYplLkQERERERERqRYlL+pI0hyF5jcVERERERERqTYlL+pA0hyFEhciIiIiIiIiNaLkRT6kbIAhIiIiIiIiIpkoeVEH1OtCREREREREJHeUvMixpKNDlLgQERERERERqTElL0RERERERESkXlPyIsdUZCEiIiIiIiKSW0pe1DXNMiIiIiIiIiJSKyWFDqDRUJJCREREREREpE6o8qKuaRyJiIiIiIiISK2o8iJXlKQQERERERERqROqvBARERERERGRek3JCxERERERERGp15rcsJE333zzWWBMoeMQERERERERkew0ucoLd1fiQkRERERERKQBaXLJCxERERERERFpWJrcsBERERFp2Mzsc2A1UAaUuvswM+sAPAL0Bj4HTnD3FYWKUURERHJLyQsRERFpiEa6+9K45xOA6e5+rZlNiD6/pDChSWOxYMECvvjiC0pLSyvWHXDAAQWMSESk6VLyQkRERBqDI4ER0Z/vBV5AyQuphUuAR/bbj4EDB1JcXAyAmSl5ISJSIEpeiIiISEPjwFQzc+DP7n430MXdFwG4+yIz65xsRzMbB4wD6NWrV77ilQbon8CcOXNo0aJFoUMRERGUvBAREZGGZz93XxhNUEwzs/9lu2M00XE3wLBhw7yuApSGry+wefNmJS9EROoJJS9ERESkQXH3hdHHxWb2BDAc+MbMukWrLroBiwsapDR4rYA99tiDgw8+uFIC47bbbku7n5mNAW4FioH/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", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], - "source": [ - "# figure\n", - "fig = plt.figure(figsize=(18, 9))\n", - "gs = fig.add_gridspec(21, 6)\n", - "threshold_ax = fig.add_subplot(gs[:4, :3])\n", - "rate_ax = fig.add_subplot(gs[6:13, :3])\n", - "distance_ax = fig.add_subplot(gs[14:20, :3])\n", - "ethogram_ax = fig.add_subplot(gs[20, :3])\n", - "position_ax = fig.add_subplot(gs[13:, 2:])\n", - "pellet_ax = fig.add_subplot(gs[2:12, 3])\n", - "time_dist_ax = fig.add_subplot(gs[2:12, 4:])\n", - "\n", - "# position plot\n", - "non_nan = np.logical_and(~np.isnan(position.x), ~np.isnan(position.y))\n", - "analysis_plotting.heatmap(\n", - " position[non_nan], 50, ax=position_ax, bins=500, alpha=0.5\n", - ")\n", - "\n", - "# event rate plots\n", - "in_arena_food_patches = (\n", - " analysis.InArena\n", - " * acquisition.ExperimentFoodPatch.join(\n", - " acquisition.ExperimentFoodPatch.RemovalTime, left=True\n", - " )\n", - " & key\n", - " & \"in_arena_start >= food_patch_install_time\"\n", - " & 'in_arena_start < IFNULL(food_patch_remove_time, \"2200-01-01\")'\n", - ").proj(\"food_patch_description\")\n", - "\n", - "for food_patch_key in in_arena_food_patches.fetch(as_dict=True):\n", - " pellet_times_df = (\n", - " (\n", - " acquisition.FoodPatchEvent * acquisition.EventType\n", - " & food_patch_key\n", - " & 'event_type = \"TriggerPellet\"'\n", - " & f'event_time BETWEEN \"{in_arena_start}\" AND \"{in_arena_end}\"'\n", - " )\n", - " .proj(\"event_time\")\n", - " .fetch(format=\"frame\", order_by=\"event_time\")\n", - " .reset_index()\n", - " )\n", - " pellet_times_df.set_index(\"event_time\", inplace=True)\n", - " analysis_plotting.rateplot(\n", - " pellet_times_df,\n", - " window=\"600s\",\n", - " frequency=500,\n", - " ax=rate_ax,\n", - " smooth=\"120s\",\n", - " start=in_arena_start,\n", - " end=in_arena_end,\n", - " color=InArenaSummaryPlot.color_code[food_patch_key[\"food_patch_description\"]],\n", - " )\n", - " \n", - " # wheel data\n", - " wheel_data = acquisition.FoodPatchWheel.get_wheel_data(\n", - " experiment_name=key[\"experiment_name\"],\n", - " start=pd.Timestamp(in_arena_start),\n", - " end=pd.Timestamp(in_arena_end),\n", - " patch_name=food_patch_key[\"food_patch_description\"],\n", - " using_aeon_io=True,\n", - " )\n", - "\n", - " minutes_elapsed = (wheel_data.index - in_arena_start).total_seconds() / 60\n", - " distance_ax.plot(\n", - " minutes_elapsed,\n", - " wheel_data.distance_travelled.values,\n", - " color=InArenaSummaryPlot.color_code[food_patch_key[\"food_patch_description\"]],\n", - " )\n", - " \n", - " # mark threshold changes\n", - " wheel_time, wheel_threshold = (acquisition.WheelState.Time & \n", - " food_patch_key & \n", - " f'state_timestamp between \"{in_arena_start}\" and \"{in_arena_end}\"').fetch('state_timestamp', 'threshold')\n", - " wheel_time -= in_arena_start \n", - " wheel_time /= datetime.timedelta(minutes=1)\n", - " \n", - " wheel_time = np.append(wheel_time, position_minutes_elapsed[-1])\n", - "\n", - " # plot threshold values\n", - " for i in range(0, len(wheel_time) - 1):\n", - " \n", - " threshold_ax.hlines(y=wheel_threshold[i], xmin=wheel_time[i], xmax=wheel_time[i + 1], linewidth=2, \n", - " color=InArenaSummaryPlot.color_code[food_patch_key[\"food_patch_description\"]])\n", - " \n", - "# ethogram\n", - "in_arena, in_corridor, arena_time, corridor_time = (\n", - " analysis.InArenaTimeDistribution & key\n", - ").fetch1(\n", - " \"in_arena\",\n", - " \"in_corridor\",\n", - " \"time_fraction_in_arena\",\n", - " \"time_fraction_in_corridor\",\n", - ")\n", - "nest_keys, in_nests, nests_times = (\n", - " analysis.InArenaTimeDistribution.Nest & key\n", - ").fetch(\"KEY\", \"in_nest\", \"time_fraction_in_nest\")\n", - "patch_names, in_patches, patches_times = (\n", - " analysis.InArenaTimeDistribution.FoodPatch * acquisition.ExperimentFoodPatch\n", - " & key\n", - ").fetch(\"food_patch_description\", \"in_patch\", \"time_fraction_in_patch\")\n", - "\n", - "ethogram_ax.plot(\n", - " position_minutes_elapsed[in_arena],\n", - " np.full_like(position_minutes_elapsed[in_arena], 0),\n", - " \".\",\n", - " color=InArenaSummaryPlot.color_code[\"arena\"],\n", - " markersize=0.5,\n", - " alpha=0.6,\n", - " label=f\"Times in arena\",\n", - ")\n", - "ethogram_ax.plot(\n", - " position_minutes_elapsed[in_corridor],\n", - " np.full_like(position_minutes_elapsed[in_corridor], 1),\n", - " \".\",\n", - " color=InArenaSummaryPlot.color_code[\"corridor\"],\n", - " markersize=0.5,\n", - " alpha=0.6,\n", - " label=f\"Times in corridor\",\n", - ")\n", - "for in_nest in in_nests:\n", - " ethogram_ax.plot(\n", - " position_minutes_elapsed[in_nest],\n", - " np.full_like(position_minutes_elapsed[in_nest], 2),\n", - " \".\",\n", - " color=InArenaSummaryPlot.color_code[\"nest\"],\n", - " markersize=0.5,\n", - " alpha=0.6,\n", - " label=f\"Times in nest\",\n", - " )\n", - "for patch_idx, (patch_name, in_patch) in enumerate(\n", - " zip(patch_names, in_patches)\n", - "):\n", - " ethogram_ax.plot(\n", - " position_minutes_elapsed[in_patch],\n", - " np.full_like(position_minutes_elapsed[in_patch], (patch_idx + 3)),\n", - " \".\",\n", - " color=InArenaSummaryPlot.color_code[patch_name],\n", - " markersize=0.5,\n", - " alpha=0.6,\n", - " label=f\"Times in {patch_name}\",\n", - " )\n", - "\n", - "# pellet\n", - "patch_names, patches_pellet = (\n", - " analysis.InArenaSummary.FoodPatch * acquisition.ExperimentFoodPatch & key\n", - ").fetch(\"food_patch_description\", \"pellet_count\")\n", - "pellet_ax.bar(\n", - " range(len(patches_pellet)),\n", - " patches_pellet,\n", - " color=[InArenaSummaryPlot.color_code[n] for n in patch_names],\n", - ")\n", - "\n", - "# time distribution\n", - "time_fractions = [arena_time, corridor_time]\n", - "colors = [InArenaSummaryPlot.color_code[\"arena\"], InArenaSummaryPlot.color_code[\"corridor\"]]\n", - "time_fractions.extend(nests_times)\n", - "colors.extend([InArenaSummaryPlot.color_code[\"nest\"] for _ in nests_times])\n", - "time_fractions.extend(patches_times)\n", - "colors.extend([InArenaSummaryPlot.color_code[n] for n in patch_names])\n", - "time_dist_ax.bar(range(len(time_fractions)), time_fractions, color=colors)\n", - "\n", - "# cosmetic\n", - "rate_ax.legend()\n", - "rate_ax.sharex(distance_ax)\n", - "fig.subplots_adjust(hspace=0.1)\n", - "rate_ax.set_ylabel(\"pellets / min\")\n", - "rate_ax.set_title(\"foraging rate (bin size = 10 min)\")\n", - "distance_ax.set_ylabel(\"distance travelled (m)\")\n", - "threshold_ax.set_ylabel(\"threshold\")\n", - "ethogram_ax.set_xlabel(\"time (min)\")\n", - "analysis_plotting.set_ymargin(distance_ax, 0.2, 0.1)\n", - "for ax in (rate_ax, distance_ax, pellet_ax, time_dist_ax, threshold_ax):\n", - " ax.spines[\"top\"].set_visible(False)\n", - " ax.spines[\"right\"].set_visible(False)\n", - " ax.spines[\"bottom\"].set_visible(False)\n", - " ax.tick_params(bottom=False, labelbottom=False)\n", - "\n", - "ethogram_ax.spines[\"top\"].set_visible(False)\n", - "ethogram_ax.spines[\"right\"].set_visible(False)\n", - "ethogram_ax.spines[\"left\"].set_visible(False)\n", - "ethogram_ax.tick_params(left=False, labelleft=False)\n", - "analysis_plotting.set_ymargin(ethogram_ax, 0.4, 0)\n", - "\n", - "position_ax.set_aspect(\"equal\")\n", - "position_ax.set_axis_off()\n", - "\n", - "pellet_ax.set_ylabel(\"pellets delivered\")\n", - "time_dist_ax.set_ylabel(\"Fraction of session duration\")\n", - "\n", - "fig.show()" - ] - }, - { - "cell_type": "code", - "execution_count": 122, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Float64Index([0.0003337833333333333, 0.0006665833333333334,\n", - " 0.0010004500000000002, 0.0013332500000000002,\n", - " 0.0016671166666666667, 0.0019999166666666668,\n", - " 0.0023338, 0.0026665833333333333,\n", - " 0.003000466666666667, 0.003333266666666667,\n", - " ...\n", - " 249.46424953333334, 249.46458233333337,\n", - " 249.4649162, 249.465249,\n", - " 249.4655828666667, 249.4659156666667,\n", - " 249.4662495166667, 249.46658233333335,\n", - " 249.46691618333335, 249.46724898333335],\n", - " dtype='float64', name='timestamps', length=748398)\n", - "[100. 112.5 125. 137.5 150. 162.5 175. 187.5 200. 212.5 225. 237.5\n", - " 250. 262.5 275. 287.5 300. 312.5 325. 337.5 350. 362.5 375. 387.5\n", - " 400. 412.5 425. 437.5 450. 462.5 475. 487.5 500. 512.5 525. 537.5\n", - " 550. 562.5 575. 587.5 600. 612.5 625. 637.5 650. 662.5 675. 687.5\n", - " 700. 712.5 725. 737.5 750. 762.5 775. 787.5 800. 812.5 825. 837.5\n", - " 850. 862.5]\n", - "[4.4666666666666664e-05 3.4698446666666665 3.6094446666666666\n", - " 3.786878333333333 4.013545 4.518578 4.741878316666667 5.205745\n", - " 5.491744983333334 6.003345 6.270478316666667 6.598678316666667 6.88901165\n", - " 7.2462116666666665 7.632078316666667 8.151078316666666 10.376911333333334\n", - " 10.759911333333333 11.130211666666666 11.972911316666666 12.37124465\n", - " 12.933678333333333 18.687411666666666 19.182478333333332 20.056745\n", - " 20.916911333333335 22.161978 24.016611666666666 24.672911316666667\n", - " 34.41367831666667 35.93901165 43.11471133333333 43.775111333333335\n", - " 56.29341165 57.577078316666665 58.254545 60.443545 61.94967833333333\n", - " 77.051345 79.54927833333333 80.24741166666666 81.82771131666667\n", - " 90.34191133333333 91.53231131666666 92.90271131666667 96.44551133333333\n", - " 113.52141166666667 114.30854498333333 115.77034498333333 117.48924465\n", - " 126.70811131666666 128.210545 132.39131131666667 133.28711133333334\n", - " 136.28071133333333 142.37397798333333 143.484145 149.71554498333333\n", - " 183.624145 202.69391133333335 218.04867831666667 243.245178]\n" - ] - } - ], - "source": [ - "# wheel_time, wheel_threshold, position_minutes_elapsed\n", - "print(position_minutes_elapsed)\n", - "print(wheel_threshold)\n", - "print(wheel_time)" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3.9.13 64-bit ('aeon')", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.13" - }, - "orig_nbformat": 4, - "vscode": { - "interpreter": { - "hash": "db60b8d28c97257fefe0af538ee53d7167e755182e7d18d3719c6d10bd23282f" - } - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/tests/threshold.ipynb b/tests/threshold.ipynb new file mode 100644 index 00000000..8bdfd126 --- /dev/null +++ b/tests/threshold.ipynb @@ -0,0 +1,441 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "[2022-06-30 15:38:17,222][INFO]: Connecting jaeronga@aeon-db2:3306\n", + "[2022-06-30 15:38:17,288][INFO]: Connected jaeronga@aeon-db2:3306\n" + ] + } + ], + "source": [ + "import datajoint as dj\n", + "from aeon.dj_pipeline.report import *" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'experiment_name': 'exp0.1-r0', 'subject': 'BAA-1099790', 'in_arena_start': datetime.datetime(2021, 6, 29, 13, 6, 34, 761660)}\n", + "2021-06-29 13:06:34.761660\n", + "2021-06-29 17:11:26.405819\n" + ] + } + ], + "source": [ + "key_id = 10\n", + "key = (analysis.InArena * analysis.InArenaEnd).fetch('KEY')[key_id]\n", + "in_arena_start, in_arena_end = (\n", + " analysis.InArena * analysis.InArenaEnd & key\n", + ").fetch1(\"in_arena_start\", \"in_arena_end\")\n", + "\n", + "print(key)\n", + "print(in_arena_start)\n", + "print(in_arena_end)\n", + "\n", + "# subject's position data in the time_slices\n", + "position = analysis.InArenaSubjectPosition.get_position(key)\n", + "position.rename(columns={\"position_x\": \"x\", \"position_y\": \"y\"}, inplace=True)\n", + "position_minutes_elapsed = (\n", + " position.index - in_arena_start\n", + ").total_seconds() / 60" + ] + }, + { + "cell_type": "code", + "execution_count": 39, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_773206/3446466821.py:201: UserWarning: Tight layout not applied. tight_layout cannot make axes height small enough to accommodate all axes decorations.\n", + " plt.tight_layout()\n" + ] + }, + { + "data": { + "image/png": "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lER06tSJ/v370759+5zOd/cjzKw3cCowycy6An91918UM04Rqc/dz046BhFpmpJPIlKafvhDuPtuaNcOKiuhffut+65d4f/9P9h776SjFBFpFWpqathhhx0YPHgwGSu4tXruzqpVq6ipqWHIkCHNuW4p8Dszexz4PvBTQMknkZiZWX/g98DBhIqnZ4BvuXtNooGJyDbUcFxESs8998AVV0DfvjBqFOy+OwwYAD17wvbbw8svwxe+AJs3Jx2piEir8Omnn7Lzzju3ucQTgJmx8847N6vqy8z2NLNqM3sTuJaw0l3/YsUoIo26BXgA6Av0Ax6MjolICVHlk4iUnuuug+HDYdq0UO2U6e674dRTYepUGD8+/vhERFqhtph4Ssnjvd8C3Akc4+5LCh+RiDRDT3dPTzbdambfTioYEclOlU8iUlqWLYOnnoLTTsueeAI48UTo0gX+/vdYQxMRkeJZunQpp512GkOHDmXEiBEcd9xxvPPOO8ycOZMjjzyS4cOHM2zYMH7+85/j7gDceuutmBnTpk3bMs59992HmXHPPfdsM/4FF1xAly5dChKrux/o7r9V4kmkJKw0szPMrF20nQGsSjooEdmWkk8iUloeewzq6uDkkxs+p2NHOProcK6IiJQ9d+fzn/88hx9+OO+99x6zZs3il7/8JcuWLePEE0/k4osv5p133uH111/n2Wef5frrr99y7d57782dd9655fldd93FqFGjthl/+vTpfPjhhy2O08wmR/sZZvZG2jbDzN5o8Q1EJB9fJTT/XwrUAl+IjolICVHySURKy3PPwQ47wD77NH7eIYfAvHmwdGk8cYmISNE8/vjjtG/fnq9//etbjo0ePZp33nmHgw8+mGOOCSuob7fddlx77bVceeWVW8479NBDefHFF9m4cSMfffQRc+bMYfTo0Vte37x5M9/73ve4+uqrCxHqt6L98cAJaVvquYjEzN0XuvuJ7t7T3Xdx95PdfUHScYnItsqi55OZdQKeAjoSYr7H3S81s+7AX4HBwHzgVHf/IKk4RaQAnn0WDjggrHLXmAMPDPuXXoIT9O99EZFC+fa34bXXCjvm6NHwm980/Pqbb77JmDFj6h2fOXNmveNDhw7lo48+Ys2aNUDo13T00Ufz6KOPsnr1ak488UTmzZu35fxrr72WE088kT59+rT4fbh7bbTXB1uRhJnZ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", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "# figure\n", + "fig = plt.figure(figsize=(20, 9))\n", + "gs = fig.add_gridspec(22, 5)\n", + "threshold_ax = fig.add_subplot(gs[:4, :3])\n", + "rate_ax = fig.add_subplot(gs[6:13, :3])\n", + "distance_ax = fig.add_subplot(gs[14:20, :3])\n", + "ethogram_ax = fig.add_subplot(gs[20, :3])\n", + "position_ax = fig.add_subplot(gs[13:, 3:])\n", + "pellet_ax = fig.add_subplot(gs[2:12, 3])\n", + "time_dist_ax = fig.add_subplot(gs[2:12, 4:])\n", + "\n", + "# position plot\n", + "non_nan = np.logical_and(~np.isnan(position.x), ~np.isnan(position.y))\n", + "analysis_plotting.heatmap(\n", + " position[non_nan], 50, ax=position_ax, bins=500, alpha=0.5\n", + ")\n", + "\n", + "# event rate plots\n", + "in_arena_food_patches = (\n", + " analysis.InArena\n", + " * acquisition.ExperimentFoodPatch.join(\n", + " acquisition.ExperimentFoodPatch.RemovalTime, left=True\n", + " )\n", + " & key\n", + " & \"in_arena_start >= food_patch_install_time\"\n", + " & 'in_arena_start < IFNULL(food_patch_remove_time, \"2200-01-01\")'\n", + ").proj(\"food_patch_description\")\n", + "\n", + "for food_patch_key in in_arena_food_patches.fetch(as_dict=True):\n", + " pellet_times_df = (\n", + " (\n", + " acquisition.FoodPatchEvent * acquisition.EventType\n", + " & food_patch_key\n", + " & 'event_type = \"TriggerPellet\"'\n", + " & f'event_time BETWEEN \"{in_arena_start}\" AND \"{in_arena_end}\"'\n", + " )\n", + " .proj(\"event_time\")\n", + " .fetch(format=\"frame\", order_by=\"event_time\")\n", + " .reset_index()\n", + " )\n", + " pellet_times_df.set_index(\"event_time\", inplace=True)\n", + " analysis_plotting.rateplot(\n", + " pellet_times_df,\n", + " window=\"600s\",\n", + " frequency=500,\n", + " ax=rate_ax,\n", + " smooth=\"120s\",\n", + " start=in_arena_start,\n", + " end=in_arena_end,\n", + " color=InArenaSummaryPlot.color_code[food_patch_key[\"food_patch_description\"]],\n", + " label=food_patch_key['food_patch_serial_number']\n", + " )\n", + " \n", + " # wheel data\n", + " wheel_data = acquisition.FoodPatchWheel.get_wheel_data(\n", + " experiment_name=key[\"experiment_name\"],\n", + " start=pd.Timestamp(in_arena_start),\n", + " end=pd.Timestamp(in_arena_end),\n", + " patch_name=food_patch_key[\"food_patch_description\"],\n", + " using_aeon_io=True,\n", + " )\n", + "\n", + " minutes_elapsed = (wheel_data.index - in_arena_start).total_seconds() / 60\n", + " distance_ax.plot(\n", + " minutes_elapsed,\n", + " wheel_data.distance_travelled.values,\n", + " color=InArenaSummaryPlot.color_code[food_patch_key[\"food_patch_description\"]],\n", + " )\n", + " \n", + " # plot wheel threshold\n", + " wheel_time, wheel_threshold = (acquisition.WheelState.Time & \n", + " food_patch_key & \n", + " f'state_timestamp between \"{in_arena_start}\" and \"{in_arena_end}\"').fetch('state_timestamp', 'threshold')\n", + " wheel_time -= in_arena_start \n", + " wheel_time /= datetime.timedelta(minutes=1)\n", + " \n", + " wheel_time = np.append(wheel_time, position_minutes_elapsed[-1])\n", + " \n", + " for i in range(0, len(wheel_time) - 1):\n", + " threshold_ax.hlines(y=wheel_threshold[i], xmin=wheel_time[i], xmax=wheel_time[i + 1], linewidth=2, \n", + " color=InArenaSummaryPlot.color_code[food_patch_key[\"food_patch_description\"]],\n", + " alpha=0.3\n", + " )\n", + " threshold_change_ind = np.where(wheel_threshold[:-1] != wheel_threshold[1:])[0]\n", + " threshold_ax.vlines(wheel_time[threshold_change_ind], \n", + " ymin=wheel_threshold[threshold_change_ind], \n", + " ymax=wheel_threshold[threshold_change_ind+1], \n", + " linewidth=2, linestyle='--',\n", + " color=InArenaSummaryPlot.color_code[food_patch_key[\"food_patch_description\"]], alpha=0.4)\n", + " \n", + "# ethogram\n", + "in_arena, in_corridor, arena_time, corridor_time = (\n", + " analysis.InArenaTimeDistribution & key\n", + ").fetch1(\n", + " \"in_arena\",\n", + " \"in_corridor\",\n", + " \"time_fraction_in_arena\",\n", + " \"time_fraction_in_corridor\",\n", + ")\n", + "nest_keys, in_nests, nests_times = (\n", + " analysis.InArenaTimeDistribution.Nest & key\n", + ").fetch(\"KEY\", \"in_nest\", \"time_fraction_in_nest\")\n", + "patch_names, in_patches, patches_times = (\n", + " analysis.InArenaTimeDistribution.FoodPatch * acquisition.ExperimentFoodPatch\n", + " & key\n", + ").fetch(\"food_patch_description\", \"in_patch\", \"time_fraction_in_patch\")\n", + "\n", + "ethogram_ax.plot(\n", + " position_minutes_elapsed[in_arena],\n", + " np.full_like(position_minutes_elapsed[in_arena], 0),\n", + " \".\",\n", + " color=InArenaSummaryPlot.color_code[\"arena\"],\n", + " markersize=0.2,\n", + " alpha=0.6,\n", + " label=f\"arena\",\n", + ")\n", + "ethogram_ax.plot(\n", + " position_minutes_elapsed[in_corridor],\n", + " np.full_like(position_minutes_elapsed[in_corridor], 1),\n", + " \".\",\n", + " color=InArenaSummaryPlot.color_code[\"corridor\"],\n", + " markersize=0.5,\n", + " alpha=0.6,\n", + " label=f\"corridor\",\n", + ")\n", + "for in_nest in in_nests:\n", + " ethogram_ax.plot(\n", + " position_minutes_elapsed[in_nest],\n", + " np.full_like(position_minutes_elapsed[in_nest], 2),\n", + " \".\",\n", + " color=InArenaSummaryPlot.color_code[\"nest\"],\n", + " markersize=0.5,\n", + " alpha=0.6,\n", + " label=f\"nest\",\n", + " )\n", + "for patch_idx, (patch_name, in_patch) in enumerate(\n", + " zip(patch_names, in_patches)\n", + "):\n", + " ethogram_ax.plot(\n", + " position_minutes_elapsed[in_patch],\n", + " np.full_like(position_minutes_elapsed[in_patch], (patch_idx + 3)),\n", + " \".\",\n", + " color=InArenaSummaryPlot.color_code[patch_name],\n", + " markersize=0.5,\n", + " alpha=0.6,\n", + " label=f\"{patch_name}\",\n", + " )\n", + "\n", + "# pellet\n", + "patch_names, patches_pellet = (\n", + " analysis.InArenaSummary.FoodPatch * acquisition.ExperimentFoodPatch & key\n", + ").fetch(\"food_patch_description\", \"pellet_count\")\n", + "pellet_ax.bar(\n", + " range(len(patches_pellet)),\n", + " patches_pellet,\n", + " color=[InArenaSummaryPlot.color_code[n] for n in patch_names],\n", + ")\n", + "\n", + "# time distribution\n", + "time_fractions = [arena_time, corridor_time]\n", + "colors = [InArenaSummaryPlot.color_code[\"arena\"], InArenaSummaryPlot.color_code[\"corridor\"]]\n", + "time_fractions.extend(nests_times)\n", + "colors.extend([InArenaSummaryPlot.color_code[\"nest\"] for _ in nests_times])\n", + "time_fractions.extend(patches_times)\n", + "colors.extend([InArenaSummaryPlot.color_code[n] for n in patch_names])\n", + "time_dist_ax.bar(range(len(time_fractions)), time_fractions, color=colors)\n", + "\n", + "# cosmetic\n", + "rate_ax.legend()\n", + "rate_ax.sharex(distance_ax)\n", + "fig.subplots_adjust(hspace=0.1)\n", + "rate_ax.set_ylabel(\"pellets / min\")\n", + "rate_ax.set_title(\"foraging rate (bin size = 10 min)\")\n", + "distance_ax.set_ylabel(\"distance travelled (m)\")\n", + "threshold_ax.set_ylabel(\"threshold\")\n", + "threshold_ax.set_ylim([threshold_ax.get_ylim()[0] - 100, threshold_ax.get_ylim()[1] + 100])\n", + "ethogram_ax.set_xlabel(\"time (min)\")\n", + "analysis_plotting.set_ymargin(distance_ax, 0.2, 0.1)\n", + "for ax in (rate_ax, distance_ax, pellet_ax, time_dist_ax, threshold_ax):\n", + " ax.spines[\"top\"].set_visible(False)\n", + " ax.spines[\"right\"].set_visible(False)\n", + " ax.spines[\"bottom\"].set_visible(False)\n", + " ax.tick_params(bottom=False, labelbottom=False)\n", + "\n", + "ethogram_ax.legend(loc='center left', \n", + " bbox_to_anchor=(1.01, 2.5), prop={'size': 12},\n", + " markerscale=40,\n", + " ) \n", + "ethogram_ax.spines[\"top\"].set_visible(False)\n", + "ethogram_ax.spines[\"right\"].set_visible(False)\n", + "ethogram_ax.spines[\"left\"].set_visible(False)\n", + "ethogram_ax.tick_params(left=False, labelleft=False)\n", + "analysis_plotting.set_ymargin(ethogram_ax, 0.4, 0)\n", + "\n", + "position_ax.set_aspect(\"equal\")\n", + "position_ax.set_axis_off()\n", + "\n", + "pellet_ax.set_ylabel(\"pellets delivered\")\n", + "time_dist_ax.set_ylabel(\"Fraction of session duration\")\n", + "\n", + "plt.tight_layout()\n", + "fig.show()" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "array([ 100., 100., 100., 100., 100., 100., 100., 100., 100.,\n", + " 100., 100., 100., 100., 100., 100., 100., 100., 100.,\n", + " 100., 100., 100., 100., 100., 100., 100., 100., 100.,\n", + " 100., 100., 100., 100., 100., 100., 100., 100., 100.,\n", + " 100., 100., 100., 100., 100., 100., 100., 100., 100.,\n", + " 100., 100., 1500.])" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "wheel_threshold" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "array([0.00013865, 19.434972333333334, 22.726538666666666,\n", + " 22.879405316666666, 23.034872, 23.205572333333333, 23.435872,\n", + " 23.668839, 23.887172333333332, 24.064272, 24.253272,\n", + " 24.443238983333334, 24.64470565, 24.80090565, 24.973972316666668,\n", + " 25.304138666666667, 25.47613865, 25.649271983333332,\n", + " 25.830705666666667, 26.017639, 26.193671983333335,\n", + " 26.40470566666667, 26.57310565, 26.738172333333335,\n", + " 33.79057233333333, 33.994005316666666, 34.197705666666664,\n", + " 34.44197233333333, 34.61690566666667, 34.858305666666666,\n", + " 35.056372333333336, 71.05550566666666, 71.19780533333333,\n", + " 71.50957231666666, 71.66637231666667, 71.94233866666667, 72.103839,\n", + " 72.29610565, 72.51560531666667, 72.81097233333334, 73.114272,\n", + " 73.28393865, 73.46630565, 73.62647198333333, 73.77723898333333,\n", + " 73.94443898333333, 74.12590565, 74.27870566666667,\n", + " 244.86060060000003], dtype=object)" + ] + }, + "execution_count": 23, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "wheel_time" + ] + }, + { + "cell_type": "code", + "execution_count": 41, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "array([], dtype=float64)" + ] + }, + "execution_count": 41, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [] + }, + { + "cell_type": "code", + "execution_count": 42, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "threshold_change_ind = np.where(wheel_threshold[:-1] != wheel_threshold[1:])[0]\n", + "plt.vlines(wheel_time[threshold_change_ind], \n", + " ymin=wheel_threshold[threshold_change_ind], \n", + " ymax=wheel_threshold[threshold_change_ind+1], \n", + " linewidth=2, linestyle='--',\n", + " color=InArenaSummaryPlot.color_code[food_patch_key[\"food_patch_description\"]], alpha=0.4)\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": 36, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "array([100.])" + ] + }, + "execution_count": 36, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "\n", + "wheel_threshold[threshold_change_ind-1]" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3.9.13 64-bit ('aeon')", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.13" + }, + "orig_nbformat": 4, + "vscode": { + "interpreter": { + "hash": "db60b8d28c97257fefe0af538ee53d7167e755182e7d18d3719c6d10bd23282f" + } + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} From 14fef329c1335e976f60fd1d79c911cf45efe7d7 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 1 Jul 2022 15:04:38 -0500 Subject: [PATCH 009/489] add first draft test suite --- tests/dj_pipeline/__init__.py | 119 ++++++++++++++++++ tests/dj_pipeline/test_ingestion.py | 47 +++++++ .../test_pipeline_instantiation.py | 25 ++++ 3 files changed, 191 insertions(+) create mode 100644 tests/dj_pipeline/__init__.py create mode 100644 tests/dj_pipeline/test_ingestion.py create mode 100644 tests/dj_pipeline/test_pipeline_instantiation.py diff --git a/tests/dj_pipeline/__init__.py b/tests/dj_pipeline/__init__.py new file mode 100644 index 00000000..f28c0d6c --- /dev/null +++ b/tests/dj_pipeline/__init__.py @@ -0,0 +1,119 @@ +import datajoint as dj +import pytest +import pathlib + + +_tear_down = False +_populate_settings = {"suppress_errors": True} + + +@pytest.fixture(autouse=True) +def dj_config(): + """If dj_local_config exists, load""" + dj_config_fp = pathlib.Path("../../dj_local_conf.json") + assert dj_config_fp.exists() + dj.config.load(dj_config_fp) + dj.config["safemode"] = False + assert "custom" in dj.config + dj.config["custom"]["database.prefix"] = "aeon_test_" + return + + +@pytest.fixture +def pipeline(): + from aeon.dj_pipeline import ( + lab, + subject, + acquisition, + qc, + tracking, + analysis, + report, + ) + + yield { + "subject": subject, + "lab": lab, + "acquisition": acquisition, + "qc": qc, + "tracking": tracking, + "analysis": analysis, + "report": report, + } + + if _tear_down: + subject.Subject.delete() + + +@pytest.fixture +def test_variables(): + return { + "experiment_name": "exp0.2-r0", + "raw_dir": "aeon/data/raw/TEST_SUITE/experiment0.2", + "qc_dir": "aeon/data/qc/TEST_SUITE/experiment0.2", + "subject_count": 5, + "epoch_count": 999, + "chunk_count": 999, + } + + +@pytest.fixture +def new_experiment(pipeline, test_variables): + from aeon.dj_pipeline.populate import create_experiment_02 + + acquisition = pipeline["acquisition"] + + create_experiment_02.main() + + experiment_name = acquisition.Experiment.fetch1("experiment_name") + + acquisition.Experiment.Directory.update1( + { + "experiment_name": experiment_name, + "repository_name": "ceph_aeon", + "directory_type": "raw", + "directory_path": test_variables["raw_dir"], + } + ) + acquisition.Experiment.Directory.update1( + { + "experiment_name": experiment_name, + "repository_name": "ceph_aeon", + "directory_type": "quality-control", + "directory_path": test_variables["qc_dir"], + } + ) + + yield + + if _tear_down: + acquisition.Experiment.delete() + + +@pytest.fixture +def epoch_chunk_ingestion(pipeline, test_variables): + acquisition = pipeline["acquisition"] + + acquisition.Epoch.ingest_epochs(experiment_name=test_variables["experiment_name"]) + acquisition.Chunk.ingest_chunks(experiment_name=test_variables["experiment_name"]) + + yield + + if _tear_down: + acquisition.Epoch.delete() + + +@pytest.fixture +def experimentlog_ingestion(pipeline, test_variables): + acquisition = pipeline["acquisition"] + + acquisition.ExperimentLog.populate(**_populate_settings) + acquisition.SubjectEnterExit.populate(**_populate_settings) + acquisition.SubjectWeight.populate(**_populate_settings) + + yield + + if _tear_down: + acquisition.ExperimentLog.delete() + acquisition.SubjectEnterExit.delete() + acquisition.SubjectWeight.delete() diff --git a/tests/dj_pipeline/test_ingestion.py b/tests/dj_pipeline/test_ingestion.py new file mode 100644 index 00000000..0c00b12f --- /dev/null +++ b/tests/dj_pipeline/test_ingestion.py @@ -0,0 +1,47 @@ +from . import ( + dj_config, + pipeline, + new_experiment, + test_variables, + epoch_chunk_ingestion, + experimentlog_ingestion, +) + + +def test_epoch_chunk_ingestion(epoch_chunk_ingestion, test_variables, pipeline): + acquisition = pipeline["acquisition"] + + assert ( + len(acquisition.Epoch & {"experiment_name": test_variables["experiment_name"]}) + == test_variables["epoch_count"] + ) + assert ( + len(acquisition.Chunk & {"experiment_name": test_variables["experiment_name"]}) + == test_variables["chunk_count"] + ) + + +def test_epoch_chunk_ingestion(experimentlog_ingestion, test_variables, pipeline): + acquisition = pipeline["acquisition"] + + assert ( + len( + acquisition.ExperimentLog.Message + & {"experiment_name": test_variables["experiment_name"]} + ) + == test_variables["experiment_log_message_count"] + ) + assert ( + len( + acquisition.SubjectEnterExit.Time + & {"experiment_name": test_variables["experiment_name"]} + ) + == test_variables["subject_enter_exit_count"] + ) + assert ( + len( + acquisition.SubjectWeight.WeightTime + & {"experiment_name": test_variables["experiment_name"]} + ) + == test_variables["subject_weight_time_count"] + ) diff --git a/tests/dj_pipeline/test_pipeline_instantiation.py b/tests/dj_pipeline/test_pipeline_instantiation.py new file mode 100644 index 00000000..4bcac219 --- /dev/null +++ b/tests/dj_pipeline/test_pipeline_instantiation.py @@ -0,0 +1,25 @@ +from . import dj_config, pipeline, new_experiment, test_variables + + +def test_pipeline_instantiation(pipeline): + acquisition = pipeline["acquisition"] + report = pipeline["report"] + + assert hasattr(acquisition, "FoodPatchEvent") + assert hasattr(report, "InArenaSummaryPlot") + + +def test_experiment_creation(pipeline, new_experiment, test_variables): + acquisition = pipeline["acquisition"] + experiment_name = test_variables["experiment_name"] + assert acquisition.Experiment.fetch1("experiment_name") == experiment_name + raw_dir = ( + acquisition.Experiment.Directory + & {"experiment_name": experiment_name, "directory_type": "raw"} + ).fetch1("directory_path") + assert raw_dir == test_variables["raw_dir"] + exp_subjects = ( + acquisition.Experiment.Subject & {"experiment_name": experiment_name} + ).fetch("subject") + assert len(exp_subjects) == test_variables["subject_count"] + assert "BAA-1100701" in exp_subjects From 7e567a9d3bfbfeabb050614586a9d4077b705b63 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 6 Jul 2022 16:57:58 -0500 Subject: [PATCH 010/489] bugfix plotting --- aeon/dj_pipeline/utils/plotting.py | 156 +++++++++++++++++++---------- 1 file changed, 105 insertions(+), 51 deletions(-) diff --git a/aeon/dj_pipeline/utils/plotting.py b/aeon/dj_pipeline/utils/plotting.py index bfb0fe02..1ba16701 100644 --- a/aeon/dj_pipeline/utils/plotting.py +++ b/aeon/dj_pipeline/utils/plotting.py @@ -25,34 +25,46 @@ def plot_reward_rate_differences(subject_keys): fig = plot_reward_rate_differences(subject_keys) ``` """ - subj_names, sess_starts, rate_timestamps, rate_diffs = (analysis.InArenaRewardRate - & subject_keys).fetch( - 'subject', 'session_start', 'pellet_rate_timestamps', 'patch2_patch1_rate_diff') + subj_names, sess_starts, rate_timestamps, rate_diffs = ( + analysis.InArenaRewardRate & subject_keys + ).fetch( + "subject", "in_arena_start", "pellet_rate_timestamps", "patch2_patch1_rate_diff" + ) nSessions = len(sess_starts) longest_rateDiff = np.max([len(t) for t in rate_timestamps]) max_session_idx = np.argmax([len(t) for t in rate_timestamps]) - max_session_elapsed_times = rate_timestamps[max_session_idx] - rate_timestamps[max_session_idx][0] + max_session_elapsed_times = ( + rate_timestamps[max_session_idx] - rate_timestamps[max_session_idx][0] + ) x_labels = [t.total_seconds() / 60 for t in max_session_elapsed_times] - y_labels = [f'{subj_name}_{sess_start.strftime("%m/%d/%Y")}' for subj_name, sess_start in - zip(subj_names, sess_starts)] + y_labels = [ + f'{subj_name}_{sess_start.strftime("%m/%d/%Y")}' + for subj_name, sess_start in zip(subj_names, sess_starts) + ] rateDiffs_matrix = np.full((nSessions, longest_rateDiff), np.nan) for row_index, rate_diff in enumerate(rate_diffs): - rateDiffs_matrix[row_index, :len(rate_diff)] = rate_diff + rateDiffs_matrix[row_index, : len(rate_diff)] = rate_diff absZmax = np.nanmax(np.absolute(rateDiffs_matrix)) - fig = px.imshow(img=rateDiffs_matrix, x=x_labels, y=y_labels, - zmin=-absZmax, zmax=absZmax, aspect="auto", - color_continuous_scale='RdBu_r', - labels=dict(color="Reward Rate
Patch2-Patch1")) + fig = px.imshow( + img=rateDiffs_matrix, + x=x_labels, + y=y_labels, + zmin=-absZmax, + zmax=absZmax, + aspect="auto", + color_continuous_scale="RdBu_r", + labels=dict(color="Reward Rate
Patch2-Patch1"), + ) fig.update_layout( xaxis_title="Time (min)", - paper_bgcolor='rgba(0,0,0,0)', - plot_bgcolor='rgba(0,0,0,0)' + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", ) return fig @@ -72,24 +84,42 @@ def plot_wheel_travelled_distance(session_keys): """ distance_travelled_query = ( - analysis.InArenaSummary.FoodPatch - * acquisition.ExperimentFoodPatch.proj('food_patch_description') - & session_keys) - - distance_travelled_df = distance_travelled_query.proj( - 'food_patch_description', 'wheel_distance_travelled').fetch(format='frame').reset_index() + analysis.InArenaSummary.FoodPatch + * acquisition.ExperimentFoodPatch.proj("food_patch_description") + & session_keys + ) - distance_travelled_df['session'] = [f'{subj_name}_{sess_start.strftime("%m/%d/%Y")}' - for subj_name, sess_start in zip(distance_travelled_df.subject, - distance_travelled_df.session_start)] + distance_travelled_df = ( + distance_travelled_query.proj( + "food_patch_description", "wheel_distance_travelled" + ) + .fetch(format="frame") + .reset_index() + ) - distance_travelled_df.rename(columns={'food_patch_description': 'Patch', - 'wheel_distance_travelled': 'Travelled Distance (m)'}, - inplace=True) + distance_travelled_df["in_arena"] = [ + f'{subj_name}_{sess_start.strftime("%m/%d/%Y")}' + for subj_name, sess_start in zip( + distance_travelled_df.subject, distance_travelled_df.in_arena_start + ) + ] + + distance_travelled_df.rename( + columns={ + "food_patch_description": "Patch", + "wheel_distance_travelled": "Travelled Distance (m)", + }, + inplace=True, + ) - title = '|'.join((acquisition.Experiment.Subject & session_keys).fetch('subject')) - fig = px.bar(distance_travelled_df, x="session", y="Travelled Distance (m)", - color="Patch", title=title) + title = "|".join((acquisition.Experiment.Subject & session_keys).fetch("subject")) + fig = px.bar( + distance_travelled_df, + x="in_arena", + y="Travelled Distance (m)", + color="Patch", + title=title, + ) fig.update_xaxes(tickangle=45) return fig @@ -99,47 +129,71 @@ def plot_average_time_distribution(session_keys): subject_list, arena_location_list, avg_time_spent_list = [], [], [] # Time spent in arena and corridor - subjects, avg_in_corridor, avg_in_arena = (acquisition.Experiment.Subject & session_keys).aggr( - analysis.InArenaTimeDistribution, - avg_in_corridor='AVG(time_fraction_in_corridor)', - avg_in_arena='AVG(time_fraction_in_arena)').fetch( - 'subject', 'avg_in_corridor', 'avg_in_arena') + subjects, avg_in_corridor, avg_in_arena = ( + (acquisition.Experiment.Subject & session_keys) + .aggr( + analysis.InArenaTimeDistribution, + avg_in_corridor="AVG(time_fraction_in_corridor)", + avg_in_arena="AVG(time_fraction_in_arena)", + ) + .fetch("subject", "avg_in_corridor", "avg_in_arena") + ) subject_list.extend(subjects) - arena_location_list.extend(['corridor'] * len(avg_in_corridor)) + arena_location_list.extend(["corridor"] * len(avg_in_corridor)) avg_time_spent_list.extend(avg_in_corridor) subject_list.extend(subjects) - arena_location_list.extend(['arena'] * len(avg_in_arena)) + arena_location_list.extend(["arena"] * len(avg_in_arena)) avg_time_spent_list.extend(avg_in_arena) # Time spent in food-patch subjects, patches, avg_in_patch = ( - dj.U('experiment_name', 'subject', 'food_patch_description') - & acquisition.Experiment.Subject * acquisition.ExperimentFoodPatch & session_keys).aggr( - analysis.InArenaTimeDistribution.FoodPatch * acquisition.ExperimentFoodPatch, - avg_in_patch='AVG(time_fraction_in_patch)').fetch( - 'subject', 'food_patch_description', 'avg_in_patch') + ( + dj.U("experiment_name", "subject", "food_patch_description") + & acquisition.Experiment.Subject * acquisition.ExperimentFoodPatch + & session_keys + ) + .aggr( + analysis.InArenaTimeDistribution.FoodPatch + * acquisition.ExperimentFoodPatch, + avg_in_patch="AVG(time_fraction_in_patch)", + ) + .fetch("subject", "food_patch_description", "avg_in_patch") + ) subject_list.extend(subjects) arena_location_list.extend(patches) avg_time_spent_list.extend(avg_in_patch) # Time spent in nest subjects, nests, avg_in_nest = ( - acquisition.Experiment.Subject * lab.ArenaNest & session_keys).aggr( - analysis.InArenaTimeDistribution.Nest, - avg_in_nest='AVG(time_fraction_in_nest)').fetch( - 'subject', 'nest', 'avg_in_nest') + (acquisition.Experiment.Subject * lab.ArenaNest & session_keys) + .aggr( + analysis.InArenaTimeDistribution.Nest, + avg_in_nest="AVG(time_fraction_in_nest)", + ) + .fetch("subject", "nest", "avg_in_nest") + ) subject_list.extend(subjects) - arena_location_list.extend([f'Nest{n}' for n in nests]) + arena_location_list.extend([f"Nest{n}" for n in nests]) avg_time_spent_list.extend(avg_in_nest) # Average time distribution - avg_time_df = pd.DataFrame({'Subject': subject_list, - 'Location': arena_location_list, - 'Time Fraction': avg_time_spent_list}) + avg_time_df = pd.DataFrame( + { + "Subject": subject_list, + "Location": arena_location_list, + "Time Fraction": avg_time_spent_list, + } + ) - title = '|'.join((acquisition.Experiment & session_keys).fetch('experiment_name')) - fig = px.bar(avg_time_df, x="Subject", y="Time Fraction", - color='Location', barmode='group', title=title) + title = "|".join((acquisition.Experiment & session_keys).fetch("experiment_name")) + fig = px.bar( + avg_time_df, + x="Subject", + y="Time Fraction", + color="Location", + barmode="group", + title=title, + ) fig.update_xaxes(tickangle=45) return fig From e45f18bc75c2b92226aec6d62402b5eb3f17eef0 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 6 Jul 2022 16:58:13 -0500 Subject: [PATCH 011/489] allow ingesting epochs from a specified time range --- aeon/dj_pipeline/acquisition.py | 58 ++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index e04bfe1c..4bf2519f 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -10,6 +10,7 @@ from aeon.analysis import utils as analysis_utils from . import get_schema_name +from . import lab, subject from .utils import paths from .populate.load_metadata import extract_epoch_metadata, ingest_epoch_metadata @@ -259,7 +260,13 @@ class Config(dj.Part): """ @classmethod - def ingest_epochs(cls, experiment_name): + def ingest_epochs(cls, experiment_name, start=None, end=None): + """ + Ingest epochs for the specified "experiment_name" + Ingest only epochs that start in between the specified (start, end) time + - if not specified, ingest all epochs + Note: "start" and "end" are datetime specified a string in the format: "%Y-%m-%d %H:%M:%S" + """ device_name = _ref_device_mapping.get(experiment_name, "CameraTop") all_chunks, raw_data_dirs = _get_all_chunks(experiment_name, device_name) @@ -275,8 +282,17 @@ def ingest_epochs(cls, experiment_name): # --- insert to Epoch --- epoch_key = {"experiment_name": experiment_name, "epoch_start": epoch_start} + # skip over epochs out of the (start, end) range + is_out_of_start_end_range = ( + start + and epoch_start < datetime.datetime.strptime(start, "%Y-%m-%d %H:%M:%S") + ) or ( + end + and epoch_start > datetime.datetime.strptime(end, "%Y-%m-%d %H:%M:%S") + ) + + # skip over those already ingested if cls & epoch_key or epoch_key in epoch_list: - # skip over those already ingested continue epoch_config, metadata_yml_filepath = None, None @@ -303,7 +319,7 @@ def ingest_epochs(cls, experiment_name): } # find previous epoch end-time - previous_epoch_end = None + previous_epoch_key = None if i > 0: previous_chunk = all_chunks.iloc[i - 1] previous_chunk_path = pathlib.Path(previous_chunk.path) @@ -317,21 +333,30 @@ def ingest_epochs(cls, experiment_name): hours=io_api.CHUNK_DURATION ) previous_epoch_end = min(previous_chunk_end, epoch_start) + previous_epoch_key = { + "experiment_name": experiment_name, + "epoch_start": previous_epoch_start, + } - with cls.connection.transaction: - cls.insert1(epoch_key) - if previous_epoch_end and not ( - EpochEnd - & { - "experiment_name": experiment_name, - "epoch_start": previous_epoch_start, - } - ): + # insert new epoch + if not is_out_of_start_end_range: + with cls.connection.transaction: + cls.insert1(epoch_key) + if epoch_config: + cls.Config.insert1(epoch_config) + ingest_epoch_metadata(experiment_name, metadata_yml_filepath) + epoch_list.append(epoch_key) + # update previous epoch + if ( + previous_epoch_key + and (cls & previous_epoch_key) + and not (EpochEnd & previous_epoch_key) + ): + with cls.connection.transaction: # insert end-time for previous epoch EpochEnd.insert1( { - "experiment_name": experiment_name, - "epoch_start": previous_epoch_start, + **previous_epoch_key, "epoch_end": previous_epoch_end, "epoch_duration": ( previous_epoch_end - previous_epoch_start @@ -351,11 +376,6 @@ def ingest_epochs(cls, experiment_name): "chunk_end": previous_epoch_end, } ) - if epoch_config: - cls.Config.insert1(epoch_config) - ingest_epoch_metadata(experiment_name, metadata_yml_filepath) - - epoch_list.append(epoch_key) print(f"Insert {len(epoch_list)} new Epoch(s)") From 25754b70e88980f243eaab7076d6dd385dc454e7 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 6 Jul 2022 16:58:19 -0500 Subject: [PATCH 012/489] update tests --- tests/dj_pipeline/__init__.py | 26 ++++++++++++++++++++++---- tests/dj_pipeline/test_ingestion.py | 2 +- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/tests/dj_pipeline/__init__.py b/tests/dj_pipeline/__init__.py index f28c0d6c..404f8ee2 100644 --- a/tests/dj_pipeline/__init__.py +++ b/tests/dj_pipeline/__init__.py @@ -1,3 +1,8 @@ +# run all tests: +# pytest -sv --cov-report term-missing --cov=aeon_mecha -p no:warnings tests/dj_pipeline +# run one test, debug: +# pytest [above options] --pdb tests/dj_pipeline/test_ingestion.py -k function_name + import datajoint as dj import pytest import pathlib @@ -15,7 +20,9 @@ def dj_config(): dj.config.load(dj_config_fp) dj.config["safemode"] = False assert "custom" in dj.config - dj.config["custom"]["database.prefix"] = "aeon_test_" + dj.config["custom"][ + "database.prefix" + ] = f"u_{dj.config['database.user']}_testsuite_" return @@ -49,8 +56,8 @@ def pipeline(): def test_variables(): return { "experiment_name": "exp0.2-r0", - "raw_dir": "aeon/data/raw/TEST_SUITE/experiment0.2", - "qc_dir": "aeon/data/qc/TEST_SUITE/experiment0.2", + "raw_dir": "aeon/data/raw/AEON2/experiment0.2", + "qc_dir": "aeon/data/qc/AEON2/experiment0.2", "subject_count": 5, "epoch_count": 999, "chunk_count": 999, @@ -94,7 +101,18 @@ def new_experiment(pipeline, test_variables): def epoch_chunk_ingestion(pipeline, test_variables): acquisition = pipeline["acquisition"] - acquisition.Epoch.ingest_epochs(experiment_name=test_variables["experiment_name"]) + start = "2022-02-23 17:26:31" + end = "2022-02-24 09:28:23" + acquisition.Epoch.ingest_epochs( + experiment_name=test_variables["experiment_name"], start=start, end=end + ) + + start = "2022-05-24 08:29:42" + end = "2022-05-24 15:59:00" + acquisition.Epoch.ingest_epochs( + experiment_name=test_variables["experiment_name"], start=start, end=end + ) + acquisition.Chunk.ingest_chunks(experiment_name=test_variables["experiment_name"]) yield diff --git a/tests/dj_pipeline/test_ingestion.py b/tests/dj_pipeline/test_ingestion.py index 0c00b12f..79358081 100644 --- a/tests/dj_pipeline/test_ingestion.py +++ b/tests/dj_pipeline/test_ingestion.py @@ -21,7 +21,7 @@ def test_epoch_chunk_ingestion(epoch_chunk_ingestion, test_variables, pipeline): ) -def test_epoch_chunk_ingestion(experimentlog_ingestion, test_variables, pipeline): +def test_experimentlog_ingestion(experimentlog_ingestion, test_variables, pipeline): acquisition = pipeline["acquisition"] assert ( From 0178623f605f3b9e3f220c053a4f408539dfe729 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 6 Jul 2022 20:52:23 -0500 Subject: [PATCH 013/489] truncate Video data from CameraQC Fix https://github.com/SainsburyWellcomeCentre/aeon_mecha/issues/130 --- aeon/dj_pipeline/tracking.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/tracking.py b/aeon/dj_pipeline/tracking.py index ee5909be..ed961e89 100644 --- a/aeon/dj_pipeline/tracking.py +++ b/aeon/dj_pipeline/tracking.py @@ -155,10 +155,16 @@ def make(self, key): # replace id=NaN with -1 positiondata.fillna({"id": -1}, inplace=True) - # Correct for frame offsets from Camera QC + # Retrieve frame offsets from Camera QC qc_timestamps, qc_frame_offsets, camera_fs = ( qc.CameraQC * acquisition.ExperimentCamera & key ).fetch1("timestamps", "frame_offset", "camera_sampling_rate") + + # For cases where position data is shorter than video data (from QC) - truncate video data + # - fix for issue: https://github.com/SainsburyWellcomeCentre/aeon_mecha/issues/130 + qc_frame_offsets = qc_frame_offsets[: len(positiondata.index)] + + # Correct for frame offsets from Camera QC qc_time_offsets = qc_frame_offsets / camera_fs qc_time_offsets = np.where( np.isnan(qc_time_offsets), 0, qc_time_offsets From 228991cca37fd8d7521eff8ac40e9d858392a17c Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 7 Jul 2022 09:42:21 -0500 Subject: [PATCH 014/489] truncate Video data from CameraQC --- aeon/dj_pipeline/tracking.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/tracking.py b/aeon/dj_pipeline/tracking.py index ed961e89..8f8066cb 100644 --- a/aeon/dj_pipeline/tracking.py +++ b/aeon/dj_pipeline/tracking.py @@ -162,7 +162,9 @@ def make(self, key): # For cases where position data is shorter than video data (from QC) - truncate video data # - fix for issue: https://github.com/SainsburyWellcomeCentre/aeon_mecha/issues/130 - qc_frame_offsets = qc_frame_offsets[: len(positiondata.index)] + max_frame_count = min(len(positiondata), len(qc_timestamps)) + qc_frame_offsets = qc_frame_offsets[:max_frame_count] + positiondata = positiondata[:max_frame_count] # Correct for frame offsets from Camera QC qc_time_offsets = qc_frame_offsets / camera_fs From 8f81e101ba39b57181f63a43b00af6deb32ef74b Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 8 Jul 2022 18:39:21 +0000 Subject: [PATCH 015/489] =?UTF-8?q?=F0=9F=8E=A8=20=20add=20modified=20test?= =?UTF-8?q?=20suits=20and=20conftest.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{dj_pipeline/__init__.py => conftest.py} | 164 ++++++++++-------- .../test_pipeline_instantiation.py | 17 +- 2 files changed, 104 insertions(+), 77 deletions(-) rename tests/{dj_pipeline/__init__.py => conftest.py} (50%) diff --git a/tests/dj_pipeline/__init__.py b/tests/conftest.py similarity index 50% rename from tests/dj_pipeline/__init__.py rename to tests/conftest.py index 404f8ee2..8b651fd9 100644 --- a/tests/dj_pipeline/__init__.py +++ b/tests/conftest.py @@ -1,44 +1,53 @@ +""" # run all tests: # pytest -sv --cov-report term-missing --cov=aeon_mecha -p no:warnings tests/dj_pipeline + # run one test, debug: -# pytest [above options] --pdb tests/dj_pipeline/test_ingestion.py -k function_name +# pytest [above options] --pdb tests/dj_pipeline/test_ingestion.py -k + +# run test on marker: +# pytest -m +""" -import datajoint as dj -import pytest import pathlib +import datajoint as dj +import pytest -_tear_down = False +_tear_down = True _populate_settings = {"suppress_errors": True} -@pytest.fixture(autouse=True) -def dj_config(): - """If dj_local_config exists, load""" - dj_config_fp = pathlib.Path("../../dj_local_conf.json") - assert dj_config_fp.exists() - dj.config.load(dj_config_fp) - dj.config["safemode"] = False - assert "custom" in dj.config - dj.config["custom"][ - "database.prefix" - ] = f"u_{dj.config['database.user']}_testsuite_" - return - - -@pytest.fixture -def pipeline(): +@pytest.fixture(autouse=True, scope="session") +def test_params(): + + return { + "start_ts": "2022-05-24 08:29:42", + "end_ts": "2022-05-24 15:59:00", + "experiment_name": "exp0.2-r0", + "raw_dir": "aeon/data/raw/AEON2/experiment0.2", + "qc_dir": "aeon/data/qc/AEON2/experiment0.2", + "subject_count": 5, + "epoch_count": 1, + "chunk_count": 9, + "experiment_log_message_count": 1, + "subject_enter_exit_count": 1, + "subject_weight_time_count": 1 + } + + +def load_pipeline(): from aeon.dj_pipeline import ( - lab, - subject, acquisition, - qc, - tracking, analysis, + lab, + qc, report, + subject, + tracking, ) - yield { + return { "subject": subject, "lab": lab, "acquisition": acquisition, @@ -48,29 +57,58 @@ def pipeline(): "report": report, } - if _tear_down: - subject.Subject.delete() +def drop_schema(): + _pipeline = load_pipeline() + + _pipeline['report'].schema.drop() + _pipeline['analysis'].schema.drop() + _pipeline['tracking'].schema.drop() + _pipeline['qc'].schema.drop() + _pipeline['acquisition'].schema.drop() + _pipeline['subject'].schema.drop() + _pipeline['lab'].schema.drop() -@pytest.fixture -def test_variables(): - return { - "experiment_name": "exp0.2-r0", - "raw_dir": "aeon/data/raw/AEON2/experiment0.2", - "qc_dir": "aeon/data/qc/AEON2/experiment0.2", - "subject_count": 5, - "epoch_count": 999, - "chunk_count": 999, - } + +@pytest.fixture(autouse=True, scope="session") +def dj_config(): + """If dj_local_config exists, load""" + dj_config_fp = pathlib.Path("dj_local_conf.json") + assert dj_config_fp.exists() + dj.config.load(dj_config_fp) + dj.config["safemode"] = False + assert "custom" in dj.config + dj.config["custom"][ + "database.prefix" + ] = f"u_{dj.config['database.user']}_testsuite_" + return -@pytest.fixture -def new_experiment(pipeline, test_variables): - from aeon.dj_pipeline.populate import create_experiment_02 - acquisition = pipeline["acquisition"] +@pytest.fixture(autouse=True, scope="session") +def pipeline(dj_config): + + _pipeline = load_pipeline() + + if len(_pipeline['acquisition'].Experiment()): + drop_schema() + _pipeline = load_pipeline() + + yield _pipeline + + if _tear_down: + + drop_schema() + + + +@pytest.fixture(autouse=True, scope="session") +def exp_creation(test_params, pipeline): + from aeon.dj_pipeline.populate import create_experiment_02 create_experiment_02.main() + + acquisition = pipeline["acquisition"] experiment_name = acquisition.Experiment.fetch1("experiment_name") @@ -79,7 +117,7 @@ def new_experiment(pipeline, test_variables): "experiment_name": experiment_name, "repository_name": "ceph_aeon", "directory_type": "raw", - "directory_path": test_variables["raw_dir"], + "directory_path": test_params["raw_dir"], } ) acquisition.Experiment.Directory.update1( @@ -87,51 +125,37 @@ def new_experiment(pipeline, test_variables): "experiment_name": experiment_name, "repository_name": "ceph_aeon", "directory_type": "quality-control", - "directory_path": test_variables["qc_dir"], + "directory_path": test_params["qc_dir"], } ) - - yield - - if _tear_down: - acquisition.Experiment.delete() + + return @pytest.fixture -def epoch_chunk_ingestion(pipeline, test_variables): +def epoch_chunk_ingestion(test_params, pipeline): acquisition = pipeline["acquisition"] - - start = "2022-02-23 17:26:31" - end = "2022-02-24 09:28:23" + + test_params["experiment_name"] + acquisition.Epoch.ingest_epochs( - experiment_name=test_variables["experiment_name"], start=start, end=end + experiment_name=test_params["experiment_name"], + start=test_params["start_ts"], end=test_params["end_ts"] ) - start = "2022-05-24 08:29:42" - end = "2022-05-24 15:59:00" - acquisition.Epoch.ingest_epochs( - experiment_name=test_variables["experiment_name"], start=start, end=end + acquisition.Chunk.ingest_chunks( + experiment_name=test_params["experiment_name"] ) - acquisition.Chunk.ingest_chunks(experiment_name=test_variables["experiment_name"]) - - yield - - if _tear_down: - acquisition.Epoch.delete() + return @pytest.fixture -def experimentlog_ingestion(pipeline, test_variables): +def experimentlog_ingestion(pipeline): acquisition = pipeline["acquisition"] acquisition.ExperimentLog.populate(**_populate_settings) acquisition.SubjectEnterExit.populate(**_populate_settings) acquisition.SubjectWeight.populate(**_populate_settings) - yield - - if _tear_down: - acquisition.ExperimentLog.delete() - acquisition.SubjectEnterExit.delete() - acquisition.SubjectWeight.delete() + return diff --git a/tests/dj_pipeline/test_pipeline_instantiation.py b/tests/dj_pipeline/test_pipeline_instantiation.py index 4bcac219..48652ab3 100644 --- a/tests/dj_pipeline/test_pipeline_instantiation.py +++ b/tests/dj_pipeline/test_pipeline_instantiation.py @@ -1,25 +1,28 @@ -from . import dj_config, pipeline, new_experiment, test_variables +from pytest import mark +@mark.instantiation def test_pipeline_instantiation(pipeline): acquisition = pipeline["acquisition"] report = pipeline["report"] - + assert hasattr(acquisition, "FoodPatchEvent") assert hasattr(report, "InArenaSummaryPlot") + - -def test_experiment_creation(pipeline, new_experiment, test_variables): +@mark.instantiation +def test_exp_creation(pipeline, test_params): acquisition = pipeline["acquisition"] - experiment_name = test_variables["experiment_name"] + + experiment_name = test_params["experiment_name"] assert acquisition.Experiment.fetch1("experiment_name") == experiment_name raw_dir = ( acquisition.Experiment.Directory & {"experiment_name": experiment_name, "directory_type": "raw"} ).fetch1("directory_path") - assert raw_dir == test_variables["raw_dir"] + assert raw_dir == test_params["raw_dir"] exp_subjects = ( acquisition.Experiment.Subject & {"experiment_name": experiment_name} ).fetch("subject") - assert len(exp_subjects) == test_variables["subject_count"] + assert len(exp_subjects) == test_params["subject_count"] assert "BAA-1100701" in exp_subjects From b6af3f5ccb96910e73e5ab8c551088ad7ffdffb3 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 11 Jul 2022 03:28:05 +0000 Subject: [PATCH 016/489] =?UTF-8?q?=E2=9C=85=20updated=20test=20suites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/conftest.py | 103 ++++++++++-------- tests/dj_pipeline/test_ingestion.py | 58 ++++++---- .../test_pipeline_instantiation.py | 2 +- tests/pytest.ini | 4 + 4 files changed, 103 insertions(+), 64 deletions(-) create mode 100644 tests/pytest.ini diff --git a/tests/conftest.py b/tests/conftest.py index 8b651fd9..0d3bd856 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,25 +18,28 @@ _populate_settings = {"suppress_errors": True} -@pytest.fixture(autouse=True, scope="session") +@pytest.fixture(autouse=True, scope="session") def test_params(): - - return { - "start_ts": "2022-05-24 08:29:42", - "end_ts": "2022-05-24 15:59:00", - "experiment_name": "exp0.2-r0", - "raw_dir": "aeon/data/raw/AEON2/experiment0.2", - "qc_dir": "aeon/data/qc/AEON2/experiment0.2", - "subject_count": 5, - "epoch_count": 1, - "chunk_count": 9, - "experiment_log_message_count": 1, - "subject_enter_exit_count": 1, - "subject_weight_time_count": 1 - } - + + return { + "start_ts": "2022-05-24 08:29:42", + "end_ts": "2022-05-24 15:59:00", + "experiment_name": "exp0.2-r0", + "raw_dir": "aeon/data/raw/AEON2/experiment0.2", + "qc_dir": "aeon/data/qc/AEON2/experiment0.2", + "subject_count": 5, + "epoch_count": 1, + "chunk_count": 9, + "experiment_log_message_count": 0, + "subject_enter_exit_count": 0, + "subject_weight_time_count": 0, + "camera_qc_count": 56, + "camera_tracking_count": 7, + } + def load_pipeline(): + from aeon.dj_pipeline import ( acquisition, analysis, @@ -57,17 +60,18 @@ def load_pipeline(): "report": report, } + def drop_schema(): + _pipeline = load_pipeline() - - _pipeline['report'].schema.drop() - _pipeline['analysis'].schema.drop() - _pipeline['tracking'].schema.drop() - _pipeline['qc'].schema.drop() - _pipeline['acquisition'].schema.drop() - _pipeline['subject'].schema.drop() - _pipeline['lab'].schema.drop() + _pipeline["report"].schema.drop() + _pipeline["analysis"].schema.drop() + _pipeline["tracking"].schema.drop() + _pipeline["qc"].schema.drop() + _pipeline["acquisition"].schema.drop() + _pipeline["subject"].schema.drop() + _pipeline["lab"].schema.drop() @pytest.fixture(autouse=True, scope="session") @@ -84,30 +88,28 @@ def dj_config(): return - @pytest.fixture(autouse=True, scope="session") def pipeline(dj_config): - + _pipeline = load_pipeline() - - if len(_pipeline['acquisition'].Experiment()): + + if len(_pipeline["acquisition"].Experiment()): drop_schema() _pipeline = load_pipeline() - + yield _pipeline if _tear_down: drop_schema() - -@pytest.fixture(autouse=True, scope="session") +@pytest.fixture(scope="session") def exp_creation(test_params, pipeline): from aeon.dj_pipeline.populate import create_experiment_02 create_experiment_02.main() - + acquisition = pipeline["acquisition"] experiment_name = acquisition.Experiment.fetch1("experiment_name") @@ -128,30 +130,29 @@ def exp_creation(test_params, pipeline): "directory_path": test_params["qc_dir"], } ) - + return -@pytest.fixture -def epoch_chunk_ingestion(test_params, pipeline): +@pytest.fixture(scope="session") +def epoch_chunk_ingestion(test_params, pipeline, exp_creation): acquisition = pipeline["acquisition"] - + test_params["experiment_name"] - + acquisition.Epoch.ingest_epochs( experiment_name=test_params["experiment_name"], - start=test_params["start_ts"], end=test_params["end_ts"] + start=test_params["start_ts"], + end=test_params["end_ts"], ) - acquisition.Chunk.ingest_chunks( - experiment_name=test_params["experiment_name"] - ) + acquisition.Chunk.ingest_chunks(experiment_name=test_params["experiment_name"]) return -@pytest.fixture -def experimentlog_ingestion(pipeline): +@pytest.fixture(scope="session") +def experimentlog_ingestion(pipeline, epoch_chunk_ingestion): acquisition = pipeline["acquisition"] acquisition.ExperimentLog.populate(**_populate_settings) @@ -159,3 +160,19 @@ def experimentlog_ingestion(pipeline): acquisition.SubjectWeight.populate(**_populate_settings) return + + +@pytest.fixture(scope="session") +def camera_qc_ingestion(pipeline, epoch_chunk_ingestion): + qc = pipeline["qc"] + qc.CameraQC.populate() + + return + + +@pytest.fixture(scope="session") +def camera_tracking_ingestion(pipeline, camera_qc_ingestion): + tracking = pipeline["tracking"] + tracking.CameraTracking.populate(display_progress=True) + + return diff --git a/tests/dj_pipeline/test_ingestion.py b/tests/dj_pipeline/test_ingestion.py index 79358081..66e41fee 100644 --- a/tests/dj_pipeline/test_ingestion.py +++ b/tests/dj_pipeline/test_ingestion.py @@ -1,47 +1,65 @@ -from . import ( - dj_config, - pipeline, - new_experiment, - test_variables, - epoch_chunk_ingestion, - experimentlog_ingestion, -) +from pytest import mark -def test_epoch_chunk_ingestion(epoch_chunk_ingestion, test_variables, pipeline): +@mark.ingestion +def test_epoch_chunk_ingestion(test_params, pipeline, epoch_chunk_ingestion): acquisition = pipeline["acquisition"] assert ( - len(acquisition.Epoch & {"experiment_name": test_variables["experiment_name"]}) - == test_variables["epoch_count"] + len(acquisition.Epoch & {"experiment_name": test_params["experiment_name"]}) + == test_params["epoch_count"] ) assert ( - len(acquisition.Chunk & {"experiment_name": test_variables["experiment_name"]}) - == test_variables["chunk_count"] + len(acquisition.Chunk & {"experiment_name": test_params["experiment_name"]}) + == test_params["chunk_count"] ) -def test_experimentlog_ingestion(experimentlog_ingestion, test_variables, pipeline): +@mark.experimentlog +def test_experimentlog_ingestion(test_params, pipeline, experimentlog_ingestion): acquisition = pipeline["acquisition"] assert ( len( acquisition.ExperimentLog.Message - & {"experiment_name": test_variables["experiment_name"]} + & {"experiment_name": test_params["experiment_name"]} ) - == test_variables["experiment_log_message_count"] + == test_params["experiment_log_message_count"] ) assert ( len( acquisition.SubjectEnterExit.Time - & {"experiment_name": test_variables["experiment_name"]} + & {"experiment_name": test_params["experiment_name"]} ) - == test_variables["subject_enter_exit_count"] + == test_params["subject_enter_exit_count"] ) assert ( len( acquisition.SubjectWeight.WeightTime - & {"experiment_name": test_variables["experiment_name"]} + & {"experiment_name": test_params["experiment_name"]} ) - == test_variables["subject_weight_time_count"] + == test_params["subject_weight_time_count"] ) + + +@mark.qc +def test_camera_qc_ingestion(test_params, pipeline, camera_qc_ingestion): + + qc = pipeline["qc"] + + assert len(qc.CameraQC()) == test_params["camera_qc_count"] + + +@mark.tracking +def test_camera_tracking_ingestion( + test_params, + pipeline, + camera_tracking_ingestion, +): + + tracking = pipeline["tracking"] + + import pdb + + pdb.set_trace() + assert len(tracking.CameraTracking()) == test_params["camera_tracking_count"] diff --git a/tests/dj_pipeline/test_pipeline_instantiation.py b/tests/dj_pipeline/test_pipeline_instantiation.py index 48652ab3..9b411c19 100644 --- a/tests/dj_pipeline/test_pipeline_instantiation.py +++ b/tests/dj_pipeline/test_pipeline_instantiation.py @@ -11,7 +11,7 @@ def test_pipeline_instantiation(pipeline): @mark.instantiation -def test_exp_creation(pipeline, test_params): +def test_exp_creation(test_params, pipeline, exp_creation): acquisition = pipeline["acquisition"] experiment_name = test_params["experiment_name"] diff --git a/tests/pytest.ini b/tests/pytest.ini new file mode 100644 index 00000000..3148a133 --- /dev/null +++ b/tests/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +filterwarnings = + error + ignore::UserWarning \ No newline at end of file From cd46735e9fa2348b471d69f4adfcd1dd3f8e23d8 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 11 Jul 2022 03:24:56 +0000 Subject: [PATCH 017/489] =?UTF-8?q?=E2=9C=A8=20add=20an=20axis=20to=20mark?= =?UTF-8?q?=20threshold=20change=20over=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aeon/dj_pipeline/report.py | 95 ++++++++++++++++++++++++++++---------- 1 file changed, 71 insertions(+), 24 deletions(-) diff --git a/aeon/dj_pipeline/report.py b/aeon/dj_pipeline/report.py index 1a81dd77..d3006f42 100644 --- a/aeon/dj_pipeline/report.py +++ b/aeon/dj_pipeline/report.py @@ -1,18 +1,17 @@ +import datetime +import json import os -import datajoint as dj -import pandas as pd -import numpy as np import pathlib -import matplotlib.pyplot as plt import re -import datetime -import json -from aeon.analysis import plotting as analysis_plotting +import datajoint as dj +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd -from . import acquisition, analysis -from . import get_schema_name +from aeon.analysis import plotting as analysis_plotting +from . import acquisition, analysis, get_schema_name schema = dj.schema(get_schema_name("report")) os.environ["DJ_SUPPORT_FILEPATH_MANAGEMENT"] = "TRUE" @@ -52,20 +51,21 @@ def make(self, key): # subject's position data in the time_slices position = analysis.InArenaSubjectPosition.get_position(key) position.rename(columns={"position_x": "x", "position_y": "y"}, inplace=True) - + position_minutes_elapsed = ( position.index - in_arena_start ).total_seconds() / 60 # figure - fig = plt.figure(figsize=(16, 8)) - gs = fig.add_gridspec(21, 6) - rate_ax = fig.add_subplot(gs[:10, :4]) - distance_ax = fig.add_subplot(gs[10:20, :4]) - ethogram_ax = fig.add_subplot(gs[20, :4]) - position_ax = fig.add_subplot(gs[10:, 4:]) - pellet_ax = fig.add_subplot(gs[:10, 4]) - time_dist_ax = fig.add_subplot(gs[:10, 5:]) + fig = plt.figure(figsize=(20, 9)) + gs = fig.add_gridspec(22, 5) + threshold_ax = fig.add_subplot(gs[:3, :3]) + rate_ax = fig.add_subplot(gs[5:13, :3]) + distance_ax = fig.add_subplot(gs[14:20, :3]) + ethogram_ax = fig.add_subplot(gs[20, :3]) + position_ax = fig.add_subplot(gs[13:, 3:]) + pellet_ax = fig.add_subplot(gs[2:12, 3]) + time_dist_ax = fig.add_subplot(gs[2:12, 4:]) # position plot non_nan = np.logical_and(~np.isnan(position.x), ~np.isnan(position.y)) @@ -106,6 +106,7 @@ def make(self, key): start=in_arena_start, end=in_arena_end, color=self.color_code[food_patch_key["food_patch_description"]], + label=food_patch_key["food_patch_serial_number"], ) # wheel data @@ -124,6 +125,39 @@ def make(self, key): color=self.color_code[food_patch_key["food_patch_description"]], ) + # plot wheel threshold + wheel_time, wheel_threshold = ( + acquisition.WheelState.Time + & food_patch_key + & f'state_timestamp between "{in_arena_start}" and "{in_arena_end}"' + ).fetch("state_timestamp", "threshold") + wheel_time -= in_arena_start + wheel_time /= datetime.timedelta(minutes=1) + + wheel_time = np.append(wheel_time, position_minutes_elapsed[-1]) + + for i in range(0, len(wheel_time) - 1): + threshold_ax.hlines( + y=wheel_threshold[i], + xmin=wheel_time[i], + xmax=wheel_time[i + 1], + linewidth=2, + color=self.color_code[food_patch_key["food_patch_description"]], + alpha=0.3, + ) + threshold_change_ind = np.where( + wheel_threshold[:-1] != wheel_threshold[1:] + )[0] + threshold_ax.vlines( + wheel_time[threshold_change_ind + 1], + ymin=wheel_threshold[threshold_change_ind], + ymax=wheel_threshold[threshold_change_ind + 1], + linewidth=1, + linestyle="dashed", + color=self.color_code[food_patch_key["food_patch_description"]], + alpha=0.4, + ) + # ethogram in_arena, in_corridor, arena_time, corridor_time = ( analysis.InArenaTimeDistribution & key @@ -148,7 +182,7 @@ def make(self, key): color=self.color_code["arena"], markersize=0.5, alpha=0.6, - label=f"Times in arena", + label=f"arena", ) ethogram_ax.plot( position_minutes_elapsed[in_corridor], @@ -157,7 +191,7 @@ def make(self, key): color=self.color_code["corridor"], markersize=0.5, alpha=0.6, - label=f"Times in corridor", + label=f"corridor", ) for in_nest in in_nests: ethogram_ax.plot( @@ -167,7 +201,7 @@ def make(self, key): color=self.color_code["nest"], markersize=0.5, alpha=0.6, - label=f"Times in nest", + label=f"nest", ) for patch_idx, (patch_name, in_patch) in enumerate( zip(patch_names, in_patches) @@ -179,7 +213,7 @@ def make(self, key): color=self.color_code[patch_name], markersize=0.5, alpha=0.6, - label=f"Times in {patch_name}", + label=f"{patch_name}", ) # pellet @@ -194,7 +228,10 @@ def make(self, key): # time distribution time_fractions = [arena_time, corridor_time] - colors = [self.color_code["arena"], self.color_code["corridor"]] + colors = [ + self.color_code["arena"], + self.color_code["corridor"], + ] time_fractions.extend(nests_times) colors.extend([self.color_code["nest"] for _ in nests_times]) time_fractions.extend(patches_times) @@ -208,14 +245,24 @@ def make(self, key): rate_ax.set_ylabel("pellets / min") rate_ax.set_title("foraging rate (bin size = 10 min)") distance_ax.set_ylabel("distance travelled (m)") + threshold_ax.set_ylabel("threshold") + threshold_ax.set_ylim( + [threshold_ax.get_ylim()[0] - 100, threshold_ax.get_ylim()[1] + 100] + ) ethogram_ax.set_xlabel("time (min)") analysis_plotting.set_ymargin(distance_ax, 0.2, 0.1) - for ax in (rate_ax, distance_ax, pellet_ax, time_dist_ax): + for ax in (rate_ax, distance_ax, pellet_ax, time_dist_ax, threshold_ax): ax.spines["top"].set_visible(False) ax.spines["right"].set_visible(False) ax.spines["bottom"].set_visible(False) ax.tick_params(bottom=False, labelbottom=False) + ethogram_ax.legend( + loc="center left", + bbox_to_anchor=(1.01, 2.5), + prop={"size": 12}, + markerscale=40, + ) ethogram_ax.spines["top"].set_visible(False) ethogram_ax.spines["right"].set_visible(False) ethogram_ax.spines["left"].set_visible(False) From 285ec0dc96a6c7d21fe2f615db8b8a1dbcd5e57c Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 24 Jun 2022 12:05:54 -0500 Subject: [PATCH 018/489] rename `ingest` -> `populate` --- aeon/dj_pipeline/README.md | 4 ++-- aeon/dj_pipeline/acquisition.py | 4 ++-- aeon/dj_pipeline/analysis/visit.py | 4 ++-- aeon/dj_pipeline/{ingest => populate}/__init__.py | 0 aeon/dj_pipeline/{ingest => populate}/create_experiment_01.py | 2 +- aeon/dj_pipeline/{ingest => populate}/create_experiment_02.py | 2 +- .../{ingest => populate}/create_socialexperiment_0.py | 4 ++-- aeon/dj_pipeline/{ingest => populate}/load_metadata.py | 0 aeon/dj_pipeline/{ingest => populate}/process.py | 4 ++-- .../{ingest => populate}/setup_yml/Experiment0.1.yml | 0 .../{ingest => populate}/setup_yml/SocialExperiment0.yml | 0 11 files changed, 12 insertions(+), 12 deletions(-) rename aeon/dj_pipeline/{ingest => populate}/__init__.py (100%) rename aeon/dj_pipeline/{ingest => populate}/create_experiment_01.py (99%) rename aeon/dj_pipeline/{ingest => populate}/create_experiment_02.py (99%) rename aeon/dj_pipeline/{ingest => populate}/create_socialexperiment_0.py (98%) rename aeon/dj_pipeline/{ingest => populate}/load_metadata.py (100%) rename aeon/dj_pipeline/{ingest => populate}/process.py (96%) rename aeon/dj_pipeline/{ingest => populate}/setup_yml/Experiment0.1.yml (100%) rename aeon/dj_pipeline/{ingest => populate}/setup_yml/SocialExperiment0.yml (100%) diff --git a/aeon/dj_pipeline/README.md b/aeon/dj_pipeline/README.md index c75c552b..93646ce8 100644 --- a/aeon/dj_pipeline/README.md +++ b/aeon/dj_pipeline/README.md @@ -69,14 +69,14 @@ animals, cameras, food patches setup, etc. + These information are either entered by hand, or parsed and inserted from configuration yaml files. + For experiment 0.1 these info can be inserted by running -the [exp01_insert_meta script](./ingest/create_experiment_01.py) (just need to do this once) +the [exp01_insert_meta script](populate/create_experiment_01.py) (just need to do this once) Tables in DataJoint are written with a `make()` function - instruction to generate and insert new records to itself, based on data from upstream tables. Triggering the auto ingestion and processing/computation routine is essentially calling the `.populate()` method for all relevant tables. -These routines are prepared in this [auto-processing script](./ingest/process.py). +These routines are prepared in this [auto-processing script](populate/process.py). Essentially, turning on the auto-processing routine amounts to running the following 3 commands (in different processing threads) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 4c9df4af..e04bfe1c 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -11,7 +11,7 @@ from . import get_schema_name from .utils import paths -from .ingest.load_metadata import extract_epoch_metadata, ingest_epoch_metadata +from .populate.load_metadata import extract_epoch_metadata, ingest_epoch_metadata schema = dj.schema(get_schema_name("acquisition")) @@ -1042,7 +1042,7 @@ def _load_legacy_subjectdata(experiment_name, data_dir, start, end): return subject_data if experiment_name == "social0-r1": - from aeon.dj_pipeline.ingest.create_socialexperiment_0 import fixID + from aeon.dj_pipeline.populate.create_socialexperiment_0 import fixID sessdf = subject_data.copy() sessdf = sessdf[~sessdf.id.str.contains("test")] diff --git a/aeon/dj_pipeline/analysis/visit.py b/aeon/dj_pipeline/analysis/visit.py index a0b8f425..6e48fb84 100644 --- a/aeon/dj_pipeline/analysis/visit.py +++ b/aeon/dj_pipeline/analysis/visit.py @@ -118,11 +118,11 @@ def make(self, key): def ingest_environment_visits(experiment_names=["exp0.2-r0"]): """ - Function to ingest into `Visit` and `VisitEnd` for specified experiments (default: 'exp0.2-r0') + Function to populate into `Visit` and `VisitEnd` for specified experiments (default: 'exp0.2-r0') This ingestion routine handles only those "complete" visits, not ingesting any "on-going" visits Using "analyze" method: `aeon.analyze.utils.visits()` - :param list experiment_names: list of names of the experiment to ingest into the Visit table + :param list experiment_names: list of names of the experiment to populate into the Visit table """ place_key = {"place": "environment"} for experiment_name in experiment_names: diff --git a/aeon/dj_pipeline/ingest/__init__.py b/aeon/dj_pipeline/populate/__init__.py similarity index 100% rename from aeon/dj_pipeline/ingest/__init__.py rename to aeon/dj_pipeline/populate/__init__.py diff --git a/aeon/dj_pipeline/ingest/create_experiment_01.py b/aeon/dj_pipeline/populate/create_experiment_01.py similarity index 99% rename from aeon/dj_pipeline/ingest/create_experiment_01.py rename to aeon/dj_pipeline/populate/create_experiment_01.py index c09cfd68..e6b29317 100644 --- a/aeon/dj_pipeline/ingest/create_experiment_01.py +++ b/aeon/dj_pipeline/populate/create_experiment_01.py @@ -171,7 +171,7 @@ def ingest_exp01_metadata(metadata_yml_filepath, experiment_name): ) -# ============ Manual and automatic steps to for experiment 0.1 ingest ============ +# ============ Manual and automatic steps to for experiment 0.1 populate ============ experiment_name = "exp0.1-r0" diff --git a/aeon/dj_pipeline/ingest/create_experiment_02.py b/aeon/dj_pipeline/populate/create_experiment_02.py similarity index 99% rename from aeon/dj_pipeline/ingest/create_experiment_02.py rename to aeon/dj_pipeline/populate/create_experiment_02.py index 1518dac8..c5ff7a03 100644 --- a/aeon/dj_pipeline/ingest/create_experiment_02.py +++ b/aeon/dj_pipeline/populate/create_experiment_02.py @@ -1,7 +1,7 @@ from aeon.dj_pipeline import acquisition, lab, subject -# ============ Manual and automatic steps to for experiment 0.2 ingest ============ +# ============ Manual and automatic steps to for experiment 0.2 populate ============ experiment_name = "exp0.2-r0" _weight_scale_rate = 20 diff --git a/aeon/dj_pipeline/ingest/create_socialexperiment_0.py b/aeon/dj_pipeline/populate/create_socialexperiment_0.py similarity index 98% rename from aeon/dj_pipeline/ingest/create_socialexperiment_0.py rename to aeon/dj_pipeline/populate/create_socialexperiment_0.py index d103b02f..ed63b447 100644 --- a/aeon/dj_pipeline/ingest/create_socialexperiment_0.py +++ b/aeon/dj_pipeline/populate/create_socialexperiment_0.py @@ -1,9 +1,9 @@ import pathlib from aeon.dj_pipeline import acquisition, lab, subject -from aeon.dj_pipeline.ingest.create_experiment_01 import ingest_exp01_metadata +from aeon.dj_pipeline.populate.create_experiment_01 import ingest_exp01_metadata -# ============ Manual and automatic steps to for experiment 0.1 ingest ============ +# ============ Manual and automatic steps to for experiment 0.1 populate ============ experiment_name = "social0-r1" diff --git a/aeon/dj_pipeline/ingest/load_metadata.py b/aeon/dj_pipeline/populate/load_metadata.py similarity index 100% rename from aeon/dj_pipeline/ingest/load_metadata.py rename to aeon/dj_pipeline/populate/load_metadata.py diff --git a/aeon/dj_pipeline/ingest/process.py b/aeon/dj_pipeline/populate/process.py similarity index 96% rename from aeon/dj_pipeline/ingest/process.py rename to aeon/dj_pipeline/populate/process.py index 8af145e9..dcba112b 100644 --- a/aeon/dj_pipeline/ingest/process.py +++ b/aeon/dj_pipeline/populate/process.py @@ -22,12 +22,12 @@ Usage as a script: - python ./aeon/dj_pipeline/ingest/process.py --help + python ./aeon/dj_pipeline/populate/process.py --help Usage from python: - `from aeon.dj_pipeline.ingest.process import run; run(worker_name='high_priority', duration=20, sleep=5)` + `from aeon.dj_pipeline.populate.process import run; run(worker_name='high_priority', duration=20, sleep=5)` """ diff --git a/aeon/dj_pipeline/ingest/setup_yml/Experiment0.1.yml b/aeon/dj_pipeline/populate/setup_yml/Experiment0.1.yml similarity index 100% rename from aeon/dj_pipeline/ingest/setup_yml/Experiment0.1.yml rename to aeon/dj_pipeline/populate/setup_yml/Experiment0.1.yml diff --git a/aeon/dj_pipeline/ingest/setup_yml/SocialExperiment0.yml b/aeon/dj_pipeline/populate/setup_yml/SocialExperiment0.yml similarity index 100% rename from aeon/dj_pipeline/ingest/setup_yml/SocialExperiment0.yml rename to aeon/dj_pipeline/populate/setup_yml/SocialExperiment0.yml From 76696965f7f0a10253cd3211e46b88cce93fb8cb Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 24 Jun 2022 12:06:03 -0500 Subject: [PATCH 019/489] Update docker-compose-remote.yaml --- .../dj_pipeline/webapps/sciviz/docker-compose-remote.yaml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml index 384136a0..83c5c4fd 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml @@ -4,7 +4,9 @@ version: '2.4' services: pharus: - image: datajoint/pharus:0.4.0 + cpus: 2.0 + mem_limit: 4g + image: jverswijver/pharus:0.4.2-Beta.0 environment: # - FLASK_ENV=development # enables logging to console from Flask - PHARUS_SPEC_PATH=/main/specs/specsheet.yaml # for dynamic utils spec @@ -22,7 +24,9 @@ services: networks: - main sci-viz: - image: datajoint/sci-viz:0.1.1 + cpus: 2.0 + mem_limit: 4g + image: jverswijver/sci-viz:0.1.3-beta.3 environment: - CHOKIDAR_USEPOLLING=true - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/aeon/utils From 01f4a590800c7ff535a7f0c91c37d1f0abefe15d Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 24 Jun 2022 12:13:41 -0500 Subject: [PATCH 020/489] bugfix in position column names mismatch --- aeon/dj_pipeline/analysis/in_arena.py | 14 ++++++++------ aeon/dj_pipeline/report.py | 3 +-- aeon/dj_pipeline/tracking.py | 8 ++++---- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/aeon/dj_pipeline/analysis/in_arena.py b/aeon/dj_pipeline/analysis/in_arena.py index ccfe8ac9..2d53dcfb 100644 --- a/aeon/dj_pipeline/analysis/in_arena.py +++ b/aeon/dj_pipeline/analysis/in_arena.py @@ -42,7 +42,7 @@ def key_source(self): acquisition.SubjectEnterExit.Time * acquisition.EventType & 'event_type = "SubjectEnteredArena"' ).proj(in_arena_start="enter_exit_time") - & {"experiment_name": "exp0.1-r0"} + & 'experiment_name in ("exp0.1-r0", "exp0.2-r0")' ) def make(self, key): @@ -206,9 +206,9 @@ class InArenaSubjectPosition(dj.Imported): def make(self, key): """ - The ingest logic here relies on the assumption that there is only one subject in the arena at a time + The populate logic here relies on the assumption that there is only one subject in the arena at a time The positiondata is associated with that one subject currently in the arena at any timepoints - For multi-animal experiments, a mapping of object_id-to-subject is needed to ingest the right position data + For multi-animal experiments, a mapping of object_id-to-subject is needed to populate the right position data associated with a particular animal """ time_slice_start, time_slice_end = (InArenaTimeSlice & key).fetch1( @@ -403,6 +403,7 @@ def make(self, key): # subject's position data in the time_slices position = InArenaSubjectPosition.get_position(key) + position.rename({"position_x": "x", "position_y": "y"}, inplace=True) # filter for objects of the correct size valid_position = (position.area > 0) & (position.area < 1000) @@ -410,7 +411,8 @@ def make(self, key): # in corridor distance_from_center = tracking.compute_distance( - position[["x", "y"]], (tracking.arena_center_x, tracking.arena_center_y) + position[["x", "y"]], + (tracking.arena_center_x, tracking.arena_center_y), ) in_corridor = (distance_from_center < tracking.arena_outer_radius) & ( distance_from_center > tracking.arena_inner_radius @@ -465,7 +467,7 @@ def make(self, key): in_patch = tracking.is_in_patch( position, patch_position, - wheel_data.distance_travelled.values, + wheel_data.distance_travelled, patch_radius=0.2, ) @@ -529,7 +531,6 @@ class FoodPatch(dj.Part): ) def make(self, key): - raw_data_dir = acquisition.Experiment.get_data_directory(key) in_arena_start, in_arena_end = (InArena * InArenaEnd & key).fetch1( "in_arena_start", "in_arena_end" ) @@ -544,6 +545,7 @@ def make(self, key): # subject's position data in this session position = InArenaSubjectPosition.get_position(key) + position.rename({"position_x": "x", "position_y": "y"}, inplace=True) valid_position = (position.area > 0) & ( position.area < 1000 diff --git a/aeon/dj_pipeline/report.py b/aeon/dj_pipeline/report.py index d2fab415..785d800d 100644 --- a/aeon/dj_pipeline/report.py +++ b/aeon/dj_pipeline/report.py @@ -45,14 +45,13 @@ class InArenaSummaryPlot(dj.Computed): } def make(self, key): - raw_data_dir = acquisition.Experiment.get_data_directory(key) - in_arena_start, in_arena_end = ( analysis.InArena * analysis.InArenaEnd & key ).fetch1("in_arena_start", "in_arena_end") # subject's position data in the time_slices position = analysis.InArenaSubjectPosition.get_position(key) + position.rename({"position_x": "x", "position_y": "y"}, inplace=True) position_minutes_elapsed = ( position.index - in_arena_start diff --git a/aeon/dj_pipeline/tracking.py b/aeon/dj_pipeline/tracking.py index ab9abec9..ee5909be 100644 --- a/aeon/dj_pipeline/tracking.py +++ b/aeon/dj_pipeline/tracking.py @@ -218,9 +218,9 @@ def get_object_position( # ---------- HELPER ------------------ -def compute_distance(position_df, target): +def compute_distance(position_df, target, xcol="x", ycol="y"): assert len(target) == 2 - return np.sqrt(np.square(position_df[["x", "y"]] - target).sum(axis=1)) + return np.sqrt(np.square(position_df[[xcol, ycol]] - target).sum(axis=1)) def is_in_patch( @@ -236,7 +236,7 @@ def is_in_patch( return in_wheel.groupby(time_slice).apply(lambda x: x.cumsum()) > 0 -def is_position_in_nest(position_df, nest_key): +def is_position_in_nest(position_df, nest_key, xcol="x", ycol="y"): """ Given the session key and the position data - arrays of x and y return an array of boolean indicating whether or not a position is inside the nest @@ -246,7 +246,7 @@ def is_position_in_nest(position_df, nest_key): ) nest_path = path.Path(nest_vertices) - return nest_path.contains_points(position_df[["x", "y"]]) + return nest_path.contains_points(position_df[[xcol, ycol]]) def _get_position( From df55c8a792970658f37dddda08e01edcdcb43e69 Mon Sep 17 00:00:00 2001 From: jai Date: Tue, 21 Jun 2022 09:13:49 +0100 Subject: [PATCH 021/489] updated docs and minor config updates --- docs/env_setup/remote/developing_on_hpc.md | 4 +- docs/examples/dj_example_notebook.ipynb | 933 --------------------- pyproject.toml | 26 +- 3 files changed, 16 insertions(+), 947 deletions(-) delete mode 100644 docs/examples/dj_example_notebook.ipynb diff --git a/docs/env_setup/remote/developing_on_hpc.md b/docs/env_setup/remote/developing_on_hpc.md index 668a48cf..9f2e7ade 100644 --- a/docs/env_setup/remote/developing_on_hpc.md +++ b/docs/env_setup/remote/developing_on_hpc.md @@ -2,9 +2,9 @@ In the examples below, replace `` with your SWC HPC username. # Developing while on the HPC -After you've finished creating the virtual `aeon` environment, activate the environment. Then, add this repository to your python path within the environment by opening a bash terminal and running `python setup.py develop` within this repo's root folder. +0) After you've finished creating the virtual `aeon` environment, activate the environment. Then, add this repository to your python path within the activated environment by opening a terminal and running `pip install -e `. -For using an IDE (e.g. PyCharm, VSCode, Jupyter, etc.) from your local machine, you will need to set up local port forwarding from a specified port on the HPC: +1) For using an IDE (e.g. PyCharm, VSCode, Jupyter, etc.) from your local machine, you will need to set up local port forwarding from a specified port on the HPC: * First, open a terminal and set up SSH local port forwarding to HPC-GW1: `ssh -L 9999:hpc-gw1:22 @ssh.swc.ucl.ac.uk` diff --git a/docs/examples/dj_example_notebook.ipynb b/docs/examples/dj_example_notebook.ipynb deleted file mode 100644 index 3dec38a8..00000000 --- a/docs/examples/dj_example_notebook.ipynb +++ /dev/null @@ -1,933 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "# Imports.\n", - "import matplotlib.pyplot as plt\n", - "import numpy as np\n", - "import datajoint as dj" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [ - { - "name": "stdin", - "output_type": "stream", - "text": [ - "Please enter DataJoint username: jbhagat\n", - "Please enter DataJoint password: ················\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Connecting jbhagat@aeon-db:3306\n" - ] - } - ], - "source": [ - "# Configure 'aeon-db' as database host and get schemas\n", - "dj.config['database.host'] = 'aeon-db2'\n", - "dj.config['display.limit'] = 5 # rows per displayed table\n", - "dj.conn()\n", - "db_prefix = 'aeon_'\n", - "acquisition = dj.create_virtual_module('acquisition', db_prefix + 'acquisition')\n", - "tracking = dj.create_virtual_module('tracking', db_prefix + 'tracking')\n", - "analysis = dj.create_virtual_module('analysis', db_prefix + 'analysis')" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [ - { - "data": { - "image/svg+xml": [ - "\n", - "\n", - "\n", - "\n", - "\n", - "tracking.SubjectPosition\n", - "\n", - "\n", - "tracking.SubjectPosition\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "analysis.Ethogram\n", - "\n", - "\n", - "analysis.Ethogram\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "analysis.Ethogram.FoodPatchStatistics\n", - "\n", - "\n", - "analysis.Ethogram.FoodPatchStatistics\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "analysis.Ethogram->analysis.Ethogram.FoodPatchStatistics\n", - "\n", - "\n", - "\n", - "\n", - "analysis.Ethogram.NestTime\n", - "\n", - "\n", - "analysis.Ethogram.NestTime\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "analysis.Ethogram->analysis.Ethogram.NestTime\n", - "\n", - "\n", - "\n", - "\n", - "analysis.Ethogram.FoodPatchTime\n", - "\n", - "\n", - "analysis.Ethogram.FoodPatchTime\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "analysis.Ethogram->analysis.Ethogram.FoodPatchTime\n", - "\n", - "\n", - "\n", - "\n", - "analysis.SessionSummary\n", - "\n", - "\n", - "analysis.SessionSummary\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "analysis.SessionSummary.FoodPatch\n", - "\n", - "\n", - "analysis.SessionSummary.FoodPatch\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "analysis.SessionSummary->analysis.SessionSummary.FoodPatch\n", - "\n", - "\n", - "\n", - "\n", - "experiment.Session\n", - "\n", - "\n", - "experiment.Session\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "experiment.Session->analysis.Ethogram\n", - "\n", - "\n", - "\n", - "\n", - "experiment.Session->analysis.SessionSummary\n", - "\n", - "\n", - "\n", - "\n", - "experiment.SessionEnd\n", - "\n", - "\n", - "experiment.SessionEnd\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "experiment.Session->experiment.SessionEnd\n", - "\n", - "\n", - "\n", - "\n", - "analysis.SessionStatistics\n", - "\n", - "\n", - "analysis.SessionStatistics\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "experiment.Session->analysis.SessionStatistics\n", - "\n", - "\n", - "\n", - "\n", - "analysis.SessionTimeDistribution\n", - "\n", - "\n", - "analysis.SessionTimeDistribution\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "experiment.Session->analysis.SessionTimeDistribution\n", - "\n", - "\n", - "\n", - "\n", - "experiment.SessionEpoch\n", - "\n", - "\n", - "experiment.SessionEpoch\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "experiment.Session->experiment.SessionEpoch\n", - "\n", - "\n", - "\n", - "\n", - "experiment.NeverExitedSession\n", - "\n", - "\n", - "experiment.NeverExitedSession\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "experiment.Session->experiment.NeverExitedSession\n", - "\n", - "\n", - "\n", - "\n", - "analysis.SessionTimeDistribution.Nest\n", - "\n", - "\n", - "analysis.SessionTimeDistribution.Nest\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "analysis.SessionStatistics.FoodPatchStatistics\n", - "\n", - "\n", - "analysis.SessionStatistics.FoodPatchStatistics\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "analysis.SessionStatistics->analysis.SessionStatistics.FoodPatchStatistics\n", - "\n", - "\n", - "\n", - "\n", - "analysis.SessionTimeDistribution.FoodPatch\n", - "\n", - "\n", - "analysis.SessionTimeDistribution.FoodPatch\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "analysis.SessionTimeDistribution->analysis.SessionTimeDistribution.Nest\n", - "\n", - "\n", - "\n", - "\n", - "analysis.SessionTimeDistribution->analysis.SessionTimeDistribution.FoodPatch\n", - "\n", - "\n", - "\n", - "\n", - "experiment.SessionEpoch->tracking.SubjectPosition\n", - "\n", - "\n", - "\n", - "" - ], - "text/plain": [ - "" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# View table diagram of `acquisition.Session` + levels up/down\n", - "dj.Diagram(acquisition.Session) + 2" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Example 1: Position Tracking" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": { - "tags": [] - }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " \n", - " \n", - " \n", - "
\n", - " \n", - " \n", - " \n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "
\n", - "

experiment_name

\n", - " e.g exp0-a\n", - "
\n", - "

subject

\n", - " \n", - "
\n", - "

session_start

\n", - " \n", - "
\n", - "

time_bin_start

\n", - " datetime of the start of this recorded TimeBin\n", - "
\n", - "

epoch_start

\n", - " datetime of the start of this Epoch\n", - "
\n", - "

timestamps

\n", - " (datetime) timestamps of the position data\n", - "
\n", - "

position_x

\n", - " (px) animal's x-position, in the arena's coordinate frame\n", - "
\n", - "

position_y

\n", - " (px) animal's y-position, in the arena's coordinate frame\n", - "
\n", - "

position_z

\n", - " (px) animal's z-position, in the arena's coordinate frame\n", - "
\n", - "

area

\n", - " (px^2) animal's size detected in the camera\n", - "
\n", - "

speed

\n", - " (px/s) speed\n", - "
exp0.1-r0BAA-10997902021-06-04 07:46:35.1780002021-06-04 07:00:002021-06-04 07:46:35.178000=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
exp0.1-r0BAA-10997902021-06-04 07:46:35.1780002021-06-04 07:00:002021-06-04 07:56:35.178000=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
exp0.1-r0BAA-10997902021-06-04 07:46:35.1780002021-06-04 08:00:002021-06-04 08:00:00=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
exp0.1-r0BAA-10997902021-06-04 07:46:35.1780002021-06-04 08:00:002021-06-04 08:10:00=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
exp0.1-r0BAA-10997902021-06-04 07:46:35.1780002021-06-04 08:00:002021-06-04 08:20:00=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
\n", - "

...

\n", - "

Total: 89

\n", - " " - ], - "text/plain": [ - "*experiment_na *subject *session_start *time_bin_star *epoch_start timestamps position_x position_y position_z area speed \n", - "+------------+ +------------+ +------------+ +------------+ +------------+ +--------+ +--------+ +--------+ +--------+ +--------+ +--------+\n", - "exp0.1-r0 BAA-1099790 2021-06-04 07: 2021-06-04 07: 2021-06-04 07: =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", - "exp0.1-r0 BAA-1099790 2021-06-04 07: 2021-06-04 07: 2021-06-04 07: =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", - "exp0.1-r0 BAA-1099790 2021-06-04 07: 2021-06-04 08: 2021-06-04 08: =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", - "exp0.1-r0 BAA-1099790 2021-06-04 07: 2021-06-04 08: 2021-06-04 08: =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", - "exp0.1-r0 BAA-1099790 2021-06-04 07: 2021-06-04 08: 2021-06-04 08: =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", - " ...\n", - " (Total: 89)" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# Example: position tracking table restricted by a subject and some particular sessions\n", - "tracking.SubjectPosition & 'subject = \"BAA-1099790\"' & 'session_start BETWEEN \"2021-06-04\" AND \"2021-06-10\"'" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], - "source": [ - "# Fetch positions from the table (array of arrays)\n", - "positionx, positiony = (\n", - " tracking.SubjectPosition\n", - " & 'subject = \"BAA-1099790\"'\n", - " & 'session_start BETWEEN \"2021-06-04\" AND \"2021-06-10\"').fetch(\n", - " 'position_x', 'position_y', order_by='time_slice_start')\n", - "# Stack arrays to get single x, y arrays and do pixel -> meter conversion\n", - "positionx = np.hstack(positionx)\n", - "positiony = np.hstack(positiony)\n", - "positionx = positionx * 0.00192 \n", - "positiony = positiony * 0.00192\n", - "# Get location of patches from 'FoodPatch' table (which ingests from metadata)\n", - "patchx, patchy = (acquisition.ExperimentFoodPatch.Position).fetch('food_patch_position_x', 'food_patch_position_y')\n", - "# Plot position data with food patch positions overlaid\n", - "fig, ax = plt.subplots(1,1)\n", - "ax.plot(positionx, positiony, '.', alpha=0.1, markersize=1)\n", - "for x, y in zip(patchx, patchy):\n", - " ax.plot(x, y, 'r*', markersize=5)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "tags": [] - }, - "source": [ - "# Example 2: Session Summary Analysis" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " \n", - " \n", - " \n", - "
\n", - " \n", - " \n", - " \n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "
\n", - "

experiment_name

\n", - " e.g exp0-a\n", - "
\n", - "

subject

\n", - " \n", - "
\n", - "

session_start

\n", - " \n", - "
\n", - "

total_distance_travelled

\n", - " (m) total distance the animal travelled during this session\n", - "
\n", - "

total_pellet_count

\n", - " total pellet delivered for all patches during this session\n", - "
\n", - "

total_wheel_distance_travelled

\n", - " total wheel distance for all patches\n", - "
\n", - "

change_in_weight

\n", - " weight change before/after the session\n", - "
exp0.1-r0BAA-10997902021-06-15 13:59:20.3690001669.3620120151.14.8
exp0.1-r0BAA-10997902021-06-16 13:21:37.7110001453.8710610628.72.6
exp0.1-r0BAA-10997902021-06-17 13:07:55.9170001472.215015082.93.7
exp0.1-r0BAA-10997902021-06-25 13:22:59.8120001267.9825028875.56.1
exp0.1-r0BAA-10997902021-06-28 12:43:44.0990002993.4322238537.85.4
\n", - "

...

\n", - "

Total: 41

\n", - " " - ], - "text/plain": [ - "*experiment_na *subject *session_start total_distance total_pellet_c total_wheel_di change_in_weig\n", - "+------------+ +------------+ +------------+ +------------+ +------------+ +------------+ +------------+\n", - "exp0.1-r0 BAA-1099790 2021-06-15 13: 1669.36 201 20151.1 4.8 \n", - "exp0.1-r0 BAA-1099790 2021-06-16 13: 1453.87 106 10628.7 2.6 \n", - "exp0.1-r0 BAA-1099790 2021-06-17 13: 1472.2 150 15082.9 3.7 \n", - "exp0.1-r0 BAA-1099790 2021-06-25 13: 1267.98 250 28875.5 6.1 \n", - "exp0.1-r0 BAA-1099790 2021-06-28 12: 2993.43 222 38537.8 5.4 \n", - " ...\n", - " (Total: 41)" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# Example: session summary table restricted by sessions\n", - "analysis.SessionSummary & 'session_start >= \"2021-06-14\"'" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " \n", - " \n", - " \n", - "
\n", - " \n", - " \n", - " \n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "
\n", - "

experiment_name

\n", - " e.g exp0-a\n", - "
\n", - "

subject

\n", - " \n", - "
\n", - "

mean_distance

\n", - " calculated attribute\n", - "
\n", - "

mean_pellet

\n", - " calculated attribute\n", - "
\n", - "

mean_wheel_distance

\n", - " calculated attribute\n", - "
\n", - "

mean_weight_change

\n", - " calculated attribute\n", - "
exp0.1-r0BAA-10997901874.1334979717549166.615419346.265024038465.503846237292657
exp0.1-r0BAA-10997911850.0412190755208150.000016310.2578667534732.9555555714501276
exp0.1-r0BAA-10997932361.7435607910156152.500016269.6792480468763.2600000143051147
exp0.1-r0BAA-10997941535.6339768629807170.076917836.7134164663484.569230758226835
exp0.1-r0BAA-10997952084.028638203939125.222213313.776472104922.522222214274936
\n", - " \n", - "

Total: 5

\n", - " " - ], - "text/plain": [ - "*experiment_na *subject mean_distance mean_pellet mean_wheel_dis mean_weight_ch\n", - "+------------+ +------------+ +------------+ +------------+ +------------+ +------------+\n", - "exp0.1-r0 BAA-1099790 1874.133497971 166.6154 19346.26502403 5.503846237292\n", - "exp0.1-r0 BAA-1099791 1850.041219075 150.0000 16310.25786675 2.955555571450\n", - "exp0.1-r0 BAA-1099793 2361.743560791 152.5000 16269.67924804 3.260000014305\n", - "exp0.1-r0 BAA-1099794 1535.633976862 170.0769 17836.71341646 4.569230758226\n", - "exp0.1-r0 BAA-1099795 2084.028638203 125.2222 13313.77647210 2.522222214274\n", - " (Total: 5)" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# Aggregate over subjects and get mean values\n", - "subject_summary_query = acquisition.Experiment.Subject.aggr(analysis.SessionSummary,\n", - " mean_distance='AVG(total_distance_travelled)', \n", - " mean_pellet='AVG(total_pellet_count)',\n", - " mean_wheel_distance='AVG(total_wheel_distance_travelled)',\n", - " mean_weight_change='AVG(change_in_weight)')\n", - "subject_summary_query" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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8sMf/pDhw4czcOBAjjrqqEWvf9SoUXz3u98tfS3f/va3GTRoEIMHD2bcuMptS+677z4++tGPsu222zJmzBheeOGFZXotS+KItCRJUgt54oknuOyyyxg/fjzDhw/n4osv5o477mDChAmcdtpp9O/fn1122YVzzz2X2bNnM2LECHbddVd69uzJDTfcQLdu3Zg2bRoHHXTQoikYkydP5uGHH6ZXr16MHDmSO++8kx133PE95x46dCiTJ08G4Pbbb2fgwIHce++9LFiwgO22225RvwULFjBx4kSuvfZa/uu//osbb7yRc845h3XXXZd7772Xt956i5EjR7Lbbrsxbdo0pk2bxsSJE8lM9t57b2677TZ23nnnJb4PV155JY899hgPPvggL774Iv379+cLX/jCYn2mTJnCc889x0MPPQTA7NmzWW+99TjzzDM544wzGDZsGADHHHMM3//+9wE4+OCDufrqq9lrr72afS3jx49n+vTpTJ48mdVWW41XXnmF+fPnc+yxx3LVVVfRo0cPLrnkEr73ve9x7rnn1vJjXsRCWpIkqYX07duXQYMqU3YGDBjA6NGjiQgGDRrEjBkzaGhoYMKECZxxxhlAZf3rZ555hl69enHMMccwZcoUOnfuzOOPP77omCNGjKB3794AbLPNNsyYMaO0kF5ttdXYfPPNmTp1KhMnTuSb3/wmt912GwsXLmSnnXZa1K9x1HrbbbdlxowZAFx//fU88MADi+Yxz5kzh2nTpnH99ddz/fXXM2TIEADmzp3LtGnTllpI33bbbRx00EF07tyZXr16scsuu7ynzwc/+EGeeuopjj32WD75yU+y2267lR7r5ptv5qc//Sn/+c9/eOWVVxgwYMCiQrrstdx44418+ctfZrXVKmXuBhtswEMPPcRDDz3Exz/+caAyar3xxhsv8TUsCwtpSZKkFtK1a9dFjzt16rToeadOnViwYAGdO3fmiiuuYIsttlhsv1NOOYWNNtqI+++/n3feeYdu3bqVHrNz584sWLCg2fPvtNNOXHfddXTp0oVdd92Vww47jIULFy4q3KuPV32szGTcuHGMGTNmseP9/e9/58QTT+RLX/rS8r4VS12Tef311+f+++/n73//O2eddRaXXnrpe0aI582bx9FHH82kSZPYdNNNOeWUUxa7DXxzr6XpuTOTAQMGcPfddy/361gSC2lJ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JUcccQQDBw5k7Nix3HjjjYwcOZJ+/foxceJE3njjDb7whS8wfPhwhgwZwlVXXbVo35122omhQ4cydOhQ7rrrLgBuueUWRo0axf7778+WW27J2LFjyczS80+cOJFPf/rTAFx11VWsscYavP3228ybN48PfvCDADz55JPsvvvubLvttuy00048+uijAJxyyimcccYZANx7770MHjyY7bffnuOPP56BAwcuOsfzzz/P7rvvTr9+/fjOd74DwAknnMCbb77JNttsw9ixY5t9fy644AIGDx7M1ltvzcEHH7yo/bbbbmOHHXbggx/8IJdffjkAc+fOZfTo0QwdOpRBgwYt9j5ttdVWHHnkkQwYMIDddtuNN998c4lxL1y4kOOPP57hw4czePBgfvvb3y7zz7Q5Tu2QJElqYU888QSXXXYZ48ePZ/jw4Vx88cXccccdTJgwgdNOO43+/fuzyy67cO655zJ79mxGjBjBrrvuSs+ePbnhhhvo1q0b06ZN46CDDmLSpEkATJ48mYcffphevXoxcuRI7rzzTnbcccf3nHvo0KFMnjwZgNtvv52BAwdy7733smDBArbbbjsAjjrqKM4++2z69evHPffcw9FHH81NN9202HEOP/xwxo8fzw477MAJJ5yw2LYpU6YwefJkunbtyhZbbMGxxx7L6aefzplnnsmUKVOafV8efvhhTj31VO688066d+/OK6+8smjbCy+8wB133MGjjz7K3nvvzf7770+3bt248sorWWeddXj55Zf5yEc+wt577w3AtGnT+NOf/sTvfvc7PvOZz3DFFVfw+c9/vtm4zznnHNZdd13uvfde3nrrLUaOHMluu+22zEvdlbGQliRJamF9+/Zl0KBBAAwYMIDRo0cTEQwaNIgZM2bQ0NDAhAkTFo3+zps3j2eeeYZevXpxzDHHMGXKFDp37szjjz++6JgjRoygd+/eAGyzzTbMmDGjtJBebbXV2HzzzZk6dSoTJ07km9/8JrfddhsLFy5kp512Yu7cudx1110ccMABi/Z56623FjvG7Nmzef3119lhhx0A+NznPsfVV1+9aPvo0aNZd911Aejfvz9PP/00m2666VLfl5tuuon999+f7t27A7DBBhss2vapT32KTp060b9/f1588UWgsrbzSSedxG233UanTp147rnnFm3r27cv22yzDQDbbrstM2bMWGLc119/PQ888MCi0e45c+Ywbdo0C2lJkqS2pGvXrosed+rUadHzTp06sWDBAjp37swVV1zBFltssdh+p5xyChtttBH3338/77zzDt26dSs9ZufOnVmwYEGz599pp5247rrr6NKlC7vuuiuHHXYYCxcu5IwzzuCdd95hvfXWW+LIcXPTRmqJpelxm1tirvqYjee/6KKLmDlzJvfddx9dunShT58+i9Z5bhrDm2++ucS4M5Nx48YxZsyYZYp1WThHWpIkqZWNGTOGcePGLSr8GqdizJkzh4033phOnTrxxz/+kYULF9Z0/J133plf/vKXbL/99vTo0YNZs2bx6KOPMmDAANZZZx369u3LZZddBlQKzPvvv3+x/ddff33WXntt/vnPfwLw5z//eZnO26VLF+bPn9/s9tGjR3PppZcya9YsgMWmdpSZM2cOPXv2pEuXLtx88808/fTTS+y/pLjHjBnDb37zm0XxPf7447zxxhvL9Lqa44i0JElqt9rqcnUnn3wyxx13HIMHDyYz6dOnD1dffTVHH300++23H5dddhkf+9jHWHPNNWs6/nbbbceLL77IzjvvDMDgwYPp2bPnotHgiy66iK985Sv86Ec/Yv78+Rx44IFsvfXWix3jnHPO4cgjj2TNNddk1KhRi6ZyLMlRRx3F4MGDGTp0KBdddNF7tg8YMIDvfe97fPSjH6Vz584MGTKE8847r9njjR07lr322othw4axzTbbsOWWWy41hubiPuKII5gxYwZDhw4lM+nRowd//etfl3q8JYmlDd23VcOGDcvGyff11NLrU67K2uovK0lSxzF16lS22sr/m1vC3LlzWWuttQA4/fTTeeGFF/jVr35V56iWbkXjLsuhiLgvM4c17euItCRJdeJgzLscjGl7rrnmGn784x+zYMECNttssyWOHLclrRm3hbQkSdIqat9992X69OmLtf3kJz9pkQvqPvvZz/LZz362pn1nzZrF6NGj39P+j3/8gw033HBFQ1uiFYl7eVlIS5KkdmVJK0O0N1deeWW9Qyi14YYbLnFVkLZqeac8u2qHJElqN7p168asWbOWuyCSMpNZs2YttuTg0jgiLUmS2o3evXvT0NDAzJkz6x2KVkHdunVbdNObZWEhLUmS2o0uXbqs0J3qpOXh1A5JkiSpBm2qkI6IzhExOSKuXnpvSZIkqX7aVCENfB1w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\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], - "source": [ - "# Fetch data back in the form of pandas DataFrame\n", - "subject_summary = subject_summary_query.fetch(format='frame')\n", - "# Ensure all data is in float representation\n", - "subject_summary = subject_summary.astype({'mean_distance': float, 'mean_pellet': float, \n", - " 'mean_wheel_distance': float, 'mean_weight_change': float,})\n", - "# Create bar plots\n", - "subject_summary.plot.bar(subplots=True, figsize=(12, 8), rot=45);" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.4" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index c6f43ef9..b2aec0e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,17 +1,19 @@ [build-system] -requires = ["setuptools>=62.0", "wheel>=0.37", "poetry-core>=1.0.0"] +requires = ["setuptools>=62.0", "wheel>=0.37"] build-backend = "setuptools.build_meta" [project] name = "aeon_mecha" -version = "0.0.0" +version = "0.1.0" requires-python = ">=3.9.4" -description = "Code for managing acquired data. Includes preprocessing, querying, and analysis modules." +description = "Code for managing acquired data from Project Aeon experiments. Includes general file IO, data QC, querying, and analysis modules." authors = [ { name = "Jai Bhagat", email = "jkbhagatio@gmail.com" }, { name = "Goncalo Lopes", email = "goncaloclopes@gmail.com" }, { name = "Thinh Nguyen", email = "thinh@datajoint.com" }, { name = "Joseph Burling", email = "joseph@datajoint.com" }, + { name = "Chang Huan Lo", email = "changhuan.lo@ucl.ac.uk" }, + { name = "Jaerong Ahn", email = "jaerong.ahn@datajoint.com" }, ] license = { file = "license.md" } readme = "readme.md" @@ -42,15 +44,6 @@ dependencies = [ "xarray>=0.12.3", ] -[project.urls] -DataJoint = "https://docs.datajoint.org/" -Homepage = "https://github.com/projectAeon/data-management" -Repository = "https://github.com/projectAeon/data-management" -Documentation = "https://github.com/projectAeon/data-management" - -[project.scripts] -aeon_ingest = "aeon.dj_pipeline.ingest.process:cli" - [project.optional-dependencies] dev = [ "bandit", @@ -67,6 +60,15 @@ dev = [ "tox", ] +[project.scripts] +aeon_ingest = "aeon.dj_pipeline.ingest.process:cli" + +[project.urls] +Homepage = "https://sainsburywellcomecentre.github.io/aeon_docs/" +Repository = "https://github.com/sainsburyWellcomeCentre/aeon_mecha" +Documentation = "https://sainsburywellcomecentre.github.io/aeon_docs/" +DataJoint = "https://docs.datajoint.org/" + [tool.setuptools] packages = ["aeon"] From 5f0737b6c33fc35bb9ba716c23c21f9dc831ebf0 Mon Sep 17 00:00:00 2001 From: JR Date: Mon, 27 Jun 2022 18:48:44 -0500 Subject: [PATCH 022/489] Update report.py --- aeon/dj_pipeline/report.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/report.py b/aeon/dj_pipeline/report.py index 785d800d..1a81dd77 100644 --- a/aeon/dj_pipeline/report.py +++ b/aeon/dj_pipeline/report.py @@ -51,8 +51,8 @@ def make(self, key): # subject's position data in the time_slices position = analysis.InArenaSubjectPosition.get_position(key) - position.rename({"position_x": "x", "position_y": "y"}, inplace=True) - + position.rename(columns={"position_x": "x", "position_y": "y"}, inplace=True) + position_minutes_elapsed = ( position.index - in_arena_start ).total_seconds() / 60 From 09bde1676c7cf34048b15dc024c5ef0830aa4e52 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 1 Jul 2022 14:23:24 -0500 Subject: [PATCH 023/489] bugfix, update sciviz version --- aeon/dj_pipeline/analysis/in_arena.py | 4 +++- .../webapps/sciviz/docker-compose-local.yaml | 14 +++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/aeon/dj_pipeline/analysis/in_arena.py b/aeon/dj_pipeline/analysis/in_arena.py index 2d53dcfb..895f19ee 100644 --- a/aeon/dj_pipeline/analysis/in_arena.py +++ b/aeon/dj_pipeline/analysis/in_arena.py @@ -201,7 +201,9 @@ class InArenaSubjectPosition(dj.Imported): """ key_source = InArenaTimeSlice & ( - qc.CameraQC * acquisition.ExperimentCamera & 'camera_description = "FrameTop"' + tracking.CameraTracking * acquisition.ExperimentCamera + & f"camera_description in {tuple(set(acquisition._ref_device_mapping.values()))}" + & "tracking_paramset_id = 0" ) def make(self, key): diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml index 77e24637..55e3956e 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml @@ -4,10 +4,12 @@ version: '2.4' services: pharus: - image: jverswijver/pharus:0.2.3.beta.10 + cpus: 2.0 + mem_limit: 4g + image: jverswijver/pharus:0.4.2-Beta.0 environment: # - FLASK_ENV=development # enables logging to console from Flask - - API_SPEC_PATH=/main/specs/specsheet.yaml # for dynamic utils spec + - PHARUS_SPEC_PATH=/main/specs/specsheet.yaml # for dynamic utils spec user: ${HOST_UID}:anaconda volumes: - ./specsheet.yaml:/main/specs/specsheet.yaml #copy the spec over to /main/specs/YOUR_SPEC_NAME @@ -43,11 +45,13 @@ services: networks: - main sci-viz: - image: jverswijver/sciviz:0.1.2-beta.9 + cpus: 2.0 + mem_limit: 4g + image: jverswijver/sci-viz:0.1.3-beta.3 environment: - CHOKIDAR_USEPOLLING=true - - REACT_APP_DJLABBOOK_BACKEND_PREFIX=/utils - - FRONTEND_SPEC_PATH=specsheet.yaml + - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/utils + - DJSCIVIZ_SPEC_PATH=specsheet.yaml volumes: - ./specsheet.yaml:/main/specsheet.yaml # ports: From 538fc38b32af458d5c235f7e9c8ec7aa36534e88 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 1 Jul 2022 15:04:38 -0500 Subject: [PATCH 024/489] add first draft test suite --- tests/dj_pipeline/__init__.py | 119 ++++++++++++++++++ tests/dj_pipeline/test_ingestion.py | 47 +++++++ .../test_pipeline_instantiation.py | 25 ++++ 3 files changed, 191 insertions(+) create mode 100644 tests/dj_pipeline/__init__.py create mode 100644 tests/dj_pipeline/test_ingestion.py create mode 100644 tests/dj_pipeline/test_pipeline_instantiation.py diff --git a/tests/dj_pipeline/__init__.py b/tests/dj_pipeline/__init__.py new file mode 100644 index 00000000..f28c0d6c --- /dev/null +++ b/tests/dj_pipeline/__init__.py @@ -0,0 +1,119 @@ +import datajoint as dj +import pytest +import pathlib + + +_tear_down = False +_populate_settings = {"suppress_errors": True} + + +@pytest.fixture(autouse=True) +def dj_config(): + """If dj_local_config exists, load""" + dj_config_fp = pathlib.Path("../../dj_local_conf.json") + assert dj_config_fp.exists() + dj.config.load(dj_config_fp) + dj.config["safemode"] = False + assert "custom" in dj.config + dj.config["custom"]["database.prefix"] = "aeon_test_" + return + + +@pytest.fixture +def pipeline(): + from aeon.dj_pipeline import ( + lab, + subject, + acquisition, + qc, + tracking, + analysis, + report, + ) + + yield { + "subject": subject, + "lab": lab, + "acquisition": acquisition, + "qc": qc, + "tracking": tracking, + "analysis": analysis, + "report": report, + } + + if _tear_down: + subject.Subject.delete() + + +@pytest.fixture +def test_variables(): + return { + "experiment_name": "exp0.2-r0", + "raw_dir": "aeon/data/raw/TEST_SUITE/experiment0.2", + "qc_dir": "aeon/data/qc/TEST_SUITE/experiment0.2", + "subject_count": 5, + "epoch_count": 999, + "chunk_count": 999, + } + + +@pytest.fixture +def new_experiment(pipeline, test_variables): + from aeon.dj_pipeline.populate import create_experiment_02 + + acquisition = pipeline["acquisition"] + + create_experiment_02.main() + + experiment_name = acquisition.Experiment.fetch1("experiment_name") + + acquisition.Experiment.Directory.update1( + { + "experiment_name": experiment_name, + "repository_name": "ceph_aeon", + "directory_type": "raw", + "directory_path": test_variables["raw_dir"], + } + ) + acquisition.Experiment.Directory.update1( + { + "experiment_name": experiment_name, + "repository_name": "ceph_aeon", + "directory_type": "quality-control", + "directory_path": test_variables["qc_dir"], + } + ) + + yield + + if _tear_down: + acquisition.Experiment.delete() + + +@pytest.fixture +def epoch_chunk_ingestion(pipeline, test_variables): + acquisition = pipeline["acquisition"] + + acquisition.Epoch.ingest_epochs(experiment_name=test_variables["experiment_name"]) + acquisition.Chunk.ingest_chunks(experiment_name=test_variables["experiment_name"]) + + yield + + if _tear_down: + acquisition.Epoch.delete() + + +@pytest.fixture +def experimentlog_ingestion(pipeline, test_variables): + acquisition = pipeline["acquisition"] + + acquisition.ExperimentLog.populate(**_populate_settings) + acquisition.SubjectEnterExit.populate(**_populate_settings) + acquisition.SubjectWeight.populate(**_populate_settings) + + yield + + if _tear_down: + acquisition.ExperimentLog.delete() + acquisition.SubjectEnterExit.delete() + acquisition.SubjectWeight.delete() diff --git a/tests/dj_pipeline/test_ingestion.py b/tests/dj_pipeline/test_ingestion.py new file mode 100644 index 00000000..0c00b12f --- /dev/null +++ b/tests/dj_pipeline/test_ingestion.py @@ -0,0 +1,47 @@ +from . import ( + dj_config, + pipeline, + new_experiment, + test_variables, + epoch_chunk_ingestion, + experimentlog_ingestion, +) + + +def test_epoch_chunk_ingestion(epoch_chunk_ingestion, test_variables, pipeline): + acquisition = pipeline["acquisition"] + + assert ( + len(acquisition.Epoch & {"experiment_name": test_variables["experiment_name"]}) + == test_variables["epoch_count"] + ) + assert ( + len(acquisition.Chunk & {"experiment_name": test_variables["experiment_name"]}) + == test_variables["chunk_count"] + ) + + +def test_epoch_chunk_ingestion(experimentlog_ingestion, test_variables, pipeline): + acquisition = pipeline["acquisition"] + + assert ( + len( + acquisition.ExperimentLog.Message + & {"experiment_name": test_variables["experiment_name"]} + ) + == test_variables["experiment_log_message_count"] + ) + assert ( + len( + acquisition.SubjectEnterExit.Time + & {"experiment_name": test_variables["experiment_name"]} + ) + == test_variables["subject_enter_exit_count"] + ) + assert ( + len( + acquisition.SubjectWeight.WeightTime + & {"experiment_name": test_variables["experiment_name"]} + ) + == test_variables["subject_weight_time_count"] + ) diff --git a/tests/dj_pipeline/test_pipeline_instantiation.py b/tests/dj_pipeline/test_pipeline_instantiation.py new file mode 100644 index 00000000..4bcac219 --- /dev/null +++ b/tests/dj_pipeline/test_pipeline_instantiation.py @@ -0,0 +1,25 @@ +from . import dj_config, pipeline, new_experiment, test_variables + + +def test_pipeline_instantiation(pipeline): + acquisition = pipeline["acquisition"] + report = pipeline["report"] + + assert hasattr(acquisition, "FoodPatchEvent") + assert hasattr(report, "InArenaSummaryPlot") + + +def test_experiment_creation(pipeline, new_experiment, test_variables): + acquisition = pipeline["acquisition"] + experiment_name = test_variables["experiment_name"] + assert acquisition.Experiment.fetch1("experiment_name") == experiment_name + raw_dir = ( + acquisition.Experiment.Directory + & {"experiment_name": experiment_name, "directory_type": "raw"} + ).fetch1("directory_path") + assert raw_dir == test_variables["raw_dir"] + exp_subjects = ( + acquisition.Experiment.Subject & {"experiment_name": experiment_name} + ).fetch("subject") + assert len(exp_subjects) == test_variables["subject_count"] + assert "BAA-1100701" in exp_subjects From 7499bc703da7e3669592ec6513549b6510b8ed18 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 6 Jul 2022 16:57:58 -0500 Subject: [PATCH 025/489] bugfix plotting --- aeon/dj_pipeline/utils/plotting.py | 156 +++++++++++++++++++---------- 1 file changed, 105 insertions(+), 51 deletions(-) diff --git a/aeon/dj_pipeline/utils/plotting.py b/aeon/dj_pipeline/utils/plotting.py index bfb0fe02..1ba16701 100644 --- a/aeon/dj_pipeline/utils/plotting.py +++ b/aeon/dj_pipeline/utils/plotting.py @@ -25,34 +25,46 @@ def plot_reward_rate_differences(subject_keys): fig = plot_reward_rate_differences(subject_keys) ``` """ - subj_names, sess_starts, rate_timestamps, rate_diffs = (analysis.InArenaRewardRate - & subject_keys).fetch( - 'subject', 'session_start', 'pellet_rate_timestamps', 'patch2_patch1_rate_diff') + subj_names, sess_starts, rate_timestamps, rate_diffs = ( + analysis.InArenaRewardRate & subject_keys + ).fetch( + "subject", "in_arena_start", "pellet_rate_timestamps", "patch2_patch1_rate_diff" + ) nSessions = len(sess_starts) longest_rateDiff = np.max([len(t) for t in rate_timestamps]) max_session_idx = np.argmax([len(t) for t in rate_timestamps]) - max_session_elapsed_times = rate_timestamps[max_session_idx] - rate_timestamps[max_session_idx][0] + max_session_elapsed_times = ( + rate_timestamps[max_session_idx] - rate_timestamps[max_session_idx][0] + ) x_labels = [t.total_seconds() / 60 for t in max_session_elapsed_times] - y_labels = [f'{subj_name}_{sess_start.strftime("%m/%d/%Y")}' for subj_name, sess_start in - zip(subj_names, sess_starts)] + y_labels = [ + f'{subj_name}_{sess_start.strftime("%m/%d/%Y")}' + for subj_name, sess_start in zip(subj_names, sess_starts) + ] rateDiffs_matrix = np.full((nSessions, longest_rateDiff), np.nan) for row_index, rate_diff in enumerate(rate_diffs): - rateDiffs_matrix[row_index, :len(rate_diff)] = rate_diff + rateDiffs_matrix[row_index, : len(rate_diff)] = rate_diff absZmax = np.nanmax(np.absolute(rateDiffs_matrix)) - fig = px.imshow(img=rateDiffs_matrix, x=x_labels, y=y_labels, - zmin=-absZmax, zmax=absZmax, aspect="auto", - color_continuous_scale='RdBu_r', - labels=dict(color="Reward Rate
Patch2-Patch1")) + fig = px.imshow( + img=rateDiffs_matrix, + x=x_labels, + y=y_labels, + zmin=-absZmax, + zmax=absZmax, + aspect="auto", + color_continuous_scale="RdBu_r", + labels=dict(color="Reward Rate
Patch2-Patch1"), + ) fig.update_layout( xaxis_title="Time (min)", - paper_bgcolor='rgba(0,0,0,0)', - plot_bgcolor='rgba(0,0,0,0)' + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", ) return fig @@ -72,24 +84,42 @@ def plot_wheel_travelled_distance(session_keys): """ distance_travelled_query = ( - analysis.InArenaSummary.FoodPatch - * acquisition.ExperimentFoodPatch.proj('food_patch_description') - & session_keys) - - distance_travelled_df = distance_travelled_query.proj( - 'food_patch_description', 'wheel_distance_travelled').fetch(format='frame').reset_index() + analysis.InArenaSummary.FoodPatch + * acquisition.ExperimentFoodPatch.proj("food_patch_description") + & session_keys + ) - distance_travelled_df['session'] = [f'{subj_name}_{sess_start.strftime("%m/%d/%Y")}' - for subj_name, sess_start in zip(distance_travelled_df.subject, - distance_travelled_df.session_start)] + distance_travelled_df = ( + distance_travelled_query.proj( + "food_patch_description", "wheel_distance_travelled" + ) + .fetch(format="frame") + .reset_index() + ) - distance_travelled_df.rename(columns={'food_patch_description': 'Patch', - 'wheel_distance_travelled': 'Travelled Distance (m)'}, - inplace=True) + distance_travelled_df["in_arena"] = [ + f'{subj_name}_{sess_start.strftime("%m/%d/%Y")}' + for subj_name, sess_start in zip( + distance_travelled_df.subject, distance_travelled_df.in_arena_start + ) + ] + + distance_travelled_df.rename( + columns={ + "food_patch_description": "Patch", + "wheel_distance_travelled": "Travelled Distance (m)", + }, + inplace=True, + ) - title = '|'.join((acquisition.Experiment.Subject & session_keys).fetch('subject')) - fig = px.bar(distance_travelled_df, x="session", y="Travelled Distance (m)", - color="Patch", title=title) + title = "|".join((acquisition.Experiment.Subject & session_keys).fetch("subject")) + fig = px.bar( + distance_travelled_df, + x="in_arena", + y="Travelled Distance (m)", + color="Patch", + title=title, + ) fig.update_xaxes(tickangle=45) return fig @@ -99,47 +129,71 @@ def plot_average_time_distribution(session_keys): subject_list, arena_location_list, avg_time_spent_list = [], [], [] # Time spent in arena and corridor - subjects, avg_in_corridor, avg_in_arena = (acquisition.Experiment.Subject & session_keys).aggr( - analysis.InArenaTimeDistribution, - avg_in_corridor='AVG(time_fraction_in_corridor)', - avg_in_arena='AVG(time_fraction_in_arena)').fetch( - 'subject', 'avg_in_corridor', 'avg_in_arena') + subjects, avg_in_corridor, avg_in_arena = ( + (acquisition.Experiment.Subject & session_keys) + .aggr( + analysis.InArenaTimeDistribution, + avg_in_corridor="AVG(time_fraction_in_corridor)", + avg_in_arena="AVG(time_fraction_in_arena)", + ) + .fetch("subject", "avg_in_corridor", "avg_in_arena") + ) subject_list.extend(subjects) - arena_location_list.extend(['corridor'] * len(avg_in_corridor)) + arena_location_list.extend(["corridor"] * len(avg_in_corridor)) avg_time_spent_list.extend(avg_in_corridor) subject_list.extend(subjects) - arena_location_list.extend(['arena'] * len(avg_in_arena)) + arena_location_list.extend(["arena"] * len(avg_in_arena)) avg_time_spent_list.extend(avg_in_arena) # Time spent in food-patch subjects, patches, avg_in_patch = ( - dj.U('experiment_name', 'subject', 'food_patch_description') - & acquisition.Experiment.Subject * acquisition.ExperimentFoodPatch & session_keys).aggr( - analysis.InArenaTimeDistribution.FoodPatch * acquisition.ExperimentFoodPatch, - avg_in_patch='AVG(time_fraction_in_patch)').fetch( - 'subject', 'food_patch_description', 'avg_in_patch') + ( + dj.U("experiment_name", "subject", "food_patch_description") + & acquisition.Experiment.Subject * acquisition.ExperimentFoodPatch + & session_keys + ) + .aggr( + analysis.InArenaTimeDistribution.FoodPatch + * acquisition.ExperimentFoodPatch, + avg_in_patch="AVG(time_fraction_in_patch)", + ) + .fetch("subject", "food_patch_description", "avg_in_patch") + ) subject_list.extend(subjects) arena_location_list.extend(patches) avg_time_spent_list.extend(avg_in_patch) # Time spent in nest subjects, nests, avg_in_nest = ( - acquisition.Experiment.Subject * lab.ArenaNest & session_keys).aggr( - analysis.InArenaTimeDistribution.Nest, - avg_in_nest='AVG(time_fraction_in_nest)').fetch( - 'subject', 'nest', 'avg_in_nest') + (acquisition.Experiment.Subject * lab.ArenaNest & session_keys) + .aggr( + analysis.InArenaTimeDistribution.Nest, + avg_in_nest="AVG(time_fraction_in_nest)", + ) + .fetch("subject", "nest", "avg_in_nest") + ) subject_list.extend(subjects) - arena_location_list.extend([f'Nest{n}' for n in nests]) + arena_location_list.extend([f"Nest{n}" for n in nests]) avg_time_spent_list.extend(avg_in_nest) # Average time distribution - avg_time_df = pd.DataFrame({'Subject': subject_list, - 'Location': arena_location_list, - 'Time Fraction': avg_time_spent_list}) + avg_time_df = pd.DataFrame( + { + "Subject": subject_list, + "Location": arena_location_list, + "Time Fraction": avg_time_spent_list, + } + ) - title = '|'.join((acquisition.Experiment & session_keys).fetch('experiment_name')) - fig = px.bar(avg_time_df, x="Subject", y="Time Fraction", - color='Location', barmode='group', title=title) + title = "|".join((acquisition.Experiment & session_keys).fetch("experiment_name")) + fig = px.bar( + avg_time_df, + x="Subject", + y="Time Fraction", + color="Location", + barmode="group", + title=title, + ) fig.update_xaxes(tickangle=45) return fig From 94b2bbc612e1a9b344132b7b61050906bc64b31e Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 6 Jul 2022 16:58:13 -0500 Subject: [PATCH 026/489] allow ingesting epochs from a specified time range --- aeon/dj_pipeline/acquisition.py | 58 ++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index e04bfe1c..4bf2519f 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -10,6 +10,7 @@ from aeon.analysis import utils as analysis_utils from . import get_schema_name +from . import lab, subject from .utils import paths from .populate.load_metadata import extract_epoch_metadata, ingest_epoch_metadata @@ -259,7 +260,13 @@ class Config(dj.Part): """ @classmethod - def ingest_epochs(cls, experiment_name): + def ingest_epochs(cls, experiment_name, start=None, end=None): + """ + Ingest epochs for the specified "experiment_name" + Ingest only epochs that start in between the specified (start, end) time + - if not specified, ingest all epochs + Note: "start" and "end" are datetime specified a string in the format: "%Y-%m-%d %H:%M:%S" + """ device_name = _ref_device_mapping.get(experiment_name, "CameraTop") all_chunks, raw_data_dirs = _get_all_chunks(experiment_name, device_name) @@ -275,8 +282,17 @@ def ingest_epochs(cls, experiment_name): # --- insert to Epoch --- epoch_key = {"experiment_name": experiment_name, "epoch_start": epoch_start} + # skip over epochs out of the (start, end) range + is_out_of_start_end_range = ( + start + and epoch_start < datetime.datetime.strptime(start, "%Y-%m-%d %H:%M:%S") + ) or ( + end + and epoch_start > datetime.datetime.strptime(end, "%Y-%m-%d %H:%M:%S") + ) + + # skip over those already ingested if cls & epoch_key or epoch_key in epoch_list: - # skip over those already ingested continue epoch_config, metadata_yml_filepath = None, None @@ -303,7 +319,7 @@ def ingest_epochs(cls, experiment_name): } # find previous epoch end-time - previous_epoch_end = None + previous_epoch_key = None if i > 0: previous_chunk = all_chunks.iloc[i - 1] previous_chunk_path = pathlib.Path(previous_chunk.path) @@ -317,21 +333,30 @@ def ingest_epochs(cls, experiment_name): hours=io_api.CHUNK_DURATION ) previous_epoch_end = min(previous_chunk_end, epoch_start) + previous_epoch_key = { + "experiment_name": experiment_name, + "epoch_start": previous_epoch_start, + } - with cls.connection.transaction: - cls.insert1(epoch_key) - if previous_epoch_end and not ( - EpochEnd - & { - "experiment_name": experiment_name, - "epoch_start": previous_epoch_start, - } - ): + # insert new epoch + if not is_out_of_start_end_range: + with cls.connection.transaction: + cls.insert1(epoch_key) + if epoch_config: + cls.Config.insert1(epoch_config) + ingest_epoch_metadata(experiment_name, metadata_yml_filepath) + epoch_list.append(epoch_key) + # update previous epoch + if ( + previous_epoch_key + and (cls & previous_epoch_key) + and not (EpochEnd & previous_epoch_key) + ): + with cls.connection.transaction: # insert end-time for previous epoch EpochEnd.insert1( { - "experiment_name": experiment_name, - "epoch_start": previous_epoch_start, + **previous_epoch_key, "epoch_end": previous_epoch_end, "epoch_duration": ( previous_epoch_end - previous_epoch_start @@ -351,11 +376,6 @@ def ingest_epochs(cls, experiment_name): "chunk_end": previous_epoch_end, } ) - if epoch_config: - cls.Config.insert1(epoch_config) - ingest_epoch_metadata(experiment_name, metadata_yml_filepath) - - epoch_list.append(epoch_key) print(f"Insert {len(epoch_list)} new Epoch(s)") From 6ac624df4b45ab8fcbc60fbe07ec563582a98c65 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 6 Jul 2022 16:58:19 -0500 Subject: [PATCH 027/489] update tests --- tests/dj_pipeline/__init__.py | 26 ++++++++++++++++++++++---- tests/dj_pipeline/test_ingestion.py | 2 +- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/tests/dj_pipeline/__init__.py b/tests/dj_pipeline/__init__.py index f28c0d6c..404f8ee2 100644 --- a/tests/dj_pipeline/__init__.py +++ b/tests/dj_pipeline/__init__.py @@ -1,3 +1,8 @@ +# run all tests: +# pytest -sv --cov-report term-missing --cov=aeon_mecha -p no:warnings tests/dj_pipeline +# run one test, debug: +# pytest [above options] --pdb tests/dj_pipeline/test_ingestion.py -k function_name + import datajoint as dj import pytest import pathlib @@ -15,7 +20,9 @@ def dj_config(): dj.config.load(dj_config_fp) dj.config["safemode"] = False assert "custom" in dj.config - dj.config["custom"]["database.prefix"] = "aeon_test_" + dj.config["custom"][ + "database.prefix" + ] = f"u_{dj.config['database.user']}_testsuite_" return @@ -49,8 +56,8 @@ def pipeline(): def test_variables(): return { "experiment_name": "exp0.2-r0", - "raw_dir": "aeon/data/raw/TEST_SUITE/experiment0.2", - "qc_dir": "aeon/data/qc/TEST_SUITE/experiment0.2", + "raw_dir": "aeon/data/raw/AEON2/experiment0.2", + "qc_dir": "aeon/data/qc/AEON2/experiment0.2", "subject_count": 5, "epoch_count": 999, "chunk_count": 999, @@ -94,7 +101,18 @@ def new_experiment(pipeline, test_variables): def epoch_chunk_ingestion(pipeline, test_variables): acquisition = pipeline["acquisition"] - acquisition.Epoch.ingest_epochs(experiment_name=test_variables["experiment_name"]) + start = "2022-02-23 17:26:31" + end = "2022-02-24 09:28:23" + acquisition.Epoch.ingest_epochs( + experiment_name=test_variables["experiment_name"], start=start, end=end + ) + + start = "2022-05-24 08:29:42" + end = "2022-05-24 15:59:00" + acquisition.Epoch.ingest_epochs( + experiment_name=test_variables["experiment_name"], start=start, end=end + ) + acquisition.Chunk.ingest_chunks(experiment_name=test_variables["experiment_name"]) yield diff --git a/tests/dj_pipeline/test_ingestion.py b/tests/dj_pipeline/test_ingestion.py index 0c00b12f..79358081 100644 --- a/tests/dj_pipeline/test_ingestion.py +++ b/tests/dj_pipeline/test_ingestion.py @@ -21,7 +21,7 @@ def test_epoch_chunk_ingestion(epoch_chunk_ingestion, test_variables, pipeline): ) -def test_epoch_chunk_ingestion(experimentlog_ingestion, test_variables, pipeline): +def test_experimentlog_ingestion(experimentlog_ingestion, test_variables, pipeline): acquisition = pipeline["acquisition"] assert ( From 13641d978f1e865b5a7117cf762433f615e4e4c3 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 8 Jul 2022 18:39:21 +0000 Subject: [PATCH 028/489] =?UTF-8?q?=F0=9F=8E=A8=20=20add=20modified=20test?= =?UTF-8?q?=20suits=20and=20conftest.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{dj_pipeline/__init__.py => conftest.py} | 164 ++++++++++-------- .../test_pipeline_instantiation.py | 17 +- 2 files changed, 104 insertions(+), 77 deletions(-) rename tests/{dj_pipeline/__init__.py => conftest.py} (50%) diff --git a/tests/dj_pipeline/__init__.py b/tests/conftest.py similarity index 50% rename from tests/dj_pipeline/__init__.py rename to tests/conftest.py index 404f8ee2..8b651fd9 100644 --- a/tests/dj_pipeline/__init__.py +++ b/tests/conftest.py @@ -1,44 +1,53 @@ +""" # run all tests: # pytest -sv --cov-report term-missing --cov=aeon_mecha -p no:warnings tests/dj_pipeline + # run one test, debug: -# pytest [above options] --pdb tests/dj_pipeline/test_ingestion.py -k function_name +# pytest [above options] --pdb tests/dj_pipeline/test_ingestion.py -k + +# run test on marker: +# pytest -m +""" -import datajoint as dj -import pytest import pathlib +import datajoint as dj +import pytest -_tear_down = False +_tear_down = True _populate_settings = {"suppress_errors": True} -@pytest.fixture(autouse=True) -def dj_config(): - """If dj_local_config exists, load""" - dj_config_fp = pathlib.Path("../../dj_local_conf.json") - assert dj_config_fp.exists() - dj.config.load(dj_config_fp) - dj.config["safemode"] = False - assert "custom" in dj.config - dj.config["custom"][ - "database.prefix" - ] = f"u_{dj.config['database.user']}_testsuite_" - return - - -@pytest.fixture -def pipeline(): +@pytest.fixture(autouse=True, scope="session") +def test_params(): + + return { + "start_ts": "2022-05-24 08:29:42", + "end_ts": "2022-05-24 15:59:00", + "experiment_name": "exp0.2-r0", + "raw_dir": "aeon/data/raw/AEON2/experiment0.2", + "qc_dir": "aeon/data/qc/AEON2/experiment0.2", + "subject_count": 5, + "epoch_count": 1, + "chunk_count": 9, + "experiment_log_message_count": 1, + "subject_enter_exit_count": 1, + "subject_weight_time_count": 1 + } + + +def load_pipeline(): from aeon.dj_pipeline import ( - lab, - subject, acquisition, - qc, - tracking, analysis, + lab, + qc, report, + subject, + tracking, ) - yield { + return { "subject": subject, "lab": lab, "acquisition": acquisition, @@ -48,29 +57,58 @@ def pipeline(): "report": report, } - if _tear_down: - subject.Subject.delete() +def drop_schema(): + _pipeline = load_pipeline() + + _pipeline['report'].schema.drop() + _pipeline['analysis'].schema.drop() + _pipeline['tracking'].schema.drop() + _pipeline['qc'].schema.drop() + _pipeline['acquisition'].schema.drop() + _pipeline['subject'].schema.drop() + _pipeline['lab'].schema.drop() -@pytest.fixture -def test_variables(): - return { - "experiment_name": "exp0.2-r0", - "raw_dir": "aeon/data/raw/AEON2/experiment0.2", - "qc_dir": "aeon/data/qc/AEON2/experiment0.2", - "subject_count": 5, - "epoch_count": 999, - "chunk_count": 999, - } + +@pytest.fixture(autouse=True, scope="session") +def dj_config(): + """If dj_local_config exists, load""" + dj_config_fp = pathlib.Path("dj_local_conf.json") + assert dj_config_fp.exists() + dj.config.load(dj_config_fp) + dj.config["safemode"] = False + assert "custom" in dj.config + dj.config["custom"][ + "database.prefix" + ] = f"u_{dj.config['database.user']}_testsuite_" + return -@pytest.fixture -def new_experiment(pipeline, test_variables): - from aeon.dj_pipeline.populate import create_experiment_02 - acquisition = pipeline["acquisition"] +@pytest.fixture(autouse=True, scope="session") +def pipeline(dj_config): + + _pipeline = load_pipeline() + + if len(_pipeline['acquisition'].Experiment()): + drop_schema() + _pipeline = load_pipeline() + + yield _pipeline + + if _tear_down: + + drop_schema() + + + +@pytest.fixture(autouse=True, scope="session") +def exp_creation(test_params, pipeline): + from aeon.dj_pipeline.populate import create_experiment_02 create_experiment_02.main() + + acquisition = pipeline["acquisition"] experiment_name = acquisition.Experiment.fetch1("experiment_name") @@ -79,7 +117,7 @@ def new_experiment(pipeline, test_variables): "experiment_name": experiment_name, "repository_name": "ceph_aeon", "directory_type": "raw", - "directory_path": test_variables["raw_dir"], + "directory_path": test_params["raw_dir"], } ) acquisition.Experiment.Directory.update1( @@ -87,51 +125,37 @@ def new_experiment(pipeline, test_variables): "experiment_name": experiment_name, "repository_name": "ceph_aeon", "directory_type": "quality-control", - "directory_path": test_variables["qc_dir"], + "directory_path": test_params["qc_dir"], } ) - - yield - - if _tear_down: - acquisition.Experiment.delete() + + return @pytest.fixture -def epoch_chunk_ingestion(pipeline, test_variables): +def epoch_chunk_ingestion(test_params, pipeline): acquisition = pipeline["acquisition"] - - start = "2022-02-23 17:26:31" - end = "2022-02-24 09:28:23" + + test_params["experiment_name"] + acquisition.Epoch.ingest_epochs( - experiment_name=test_variables["experiment_name"], start=start, end=end + experiment_name=test_params["experiment_name"], + start=test_params["start_ts"], end=test_params["end_ts"] ) - start = "2022-05-24 08:29:42" - end = "2022-05-24 15:59:00" - acquisition.Epoch.ingest_epochs( - experiment_name=test_variables["experiment_name"], start=start, end=end + acquisition.Chunk.ingest_chunks( + experiment_name=test_params["experiment_name"] ) - acquisition.Chunk.ingest_chunks(experiment_name=test_variables["experiment_name"]) - - yield - - if _tear_down: - acquisition.Epoch.delete() + return @pytest.fixture -def experimentlog_ingestion(pipeline, test_variables): +def experimentlog_ingestion(pipeline): acquisition = pipeline["acquisition"] acquisition.ExperimentLog.populate(**_populate_settings) acquisition.SubjectEnterExit.populate(**_populate_settings) acquisition.SubjectWeight.populate(**_populate_settings) - yield - - if _tear_down: - acquisition.ExperimentLog.delete() - acquisition.SubjectEnterExit.delete() - acquisition.SubjectWeight.delete() + return diff --git a/tests/dj_pipeline/test_pipeline_instantiation.py b/tests/dj_pipeline/test_pipeline_instantiation.py index 4bcac219..48652ab3 100644 --- a/tests/dj_pipeline/test_pipeline_instantiation.py +++ b/tests/dj_pipeline/test_pipeline_instantiation.py @@ -1,25 +1,28 @@ -from . import dj_config, pipeline, new_experiment, test_variables +from pytest import mark +@mark.instantiation def test_pipeline_instantiation(pipeline): acquisition = pipeline["acquisition"] report = pipeline["report"] - + assert hasattr(acquisition, "FoodPatchEvent") assert hasattr(report, "InArenaSummaryPlot") + - -def test_experiment_creation(pipeline, new_experiment, test_variables): +@mark.instantiation +def test_exp_creation(pipeline, test_params): acquisition = pipeline["acquisition"] - experiment_name = test_variables["experiment_name"] + + experiment_name = test_params["experiment_name"] assert acquisition.Experiment.fetch1("experiment_name") == experiment_name raw_dir = ( acquisition.Experiment.Directory & {"experiment_name": experiment_name, "directory_type": "raw"} ).fetch1("directory_path") - assert raw_dir == test_variables["raw_dir"] + assert raw_dir == test_params["raw_dir"] exp_subjects = ( acquisition.Experiment.Subject & {"experiment_name": experiment_name} ).fetch("subject") - assert len(exp_subjects) == test_variables["subject_count"] + assert len(exp_subjects) == test_params["subject_count"] assert "BAA-1100701" in exp_subjects From 8284ef8ece367cda6e8e5d5834ad87f391fa016e Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 11 Jul 2022 03:28:05 +0000 Subject: [PATCH 029/489] =?UTF-8?q?=E2=9C=85=20updated=20test=20suites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/conftest.py | 103 ++++++++++-------- tests/dj_pipeline/test_ingestion.py | 58 ++++++---- .../test_pipeline_instantiation.py | 2 +- tests/pytest.ini | 4 + 4 files changed, 103 insertions(+), 64 deletions(-) create mode 100644 tests/pytest.ini diff --git a/tests/conftest.py b/tests/conftest.py index 8b651fd9..0d3bd856 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,25 +18,28 @@ _populate_settings = {"suppress_errors": True} -@pytest.fixture(autouse=True, scope="session") +@pytest.fixture(autouse=True, scope="session") def test_params(): - - return { - "start_ts": "2022-05-24 08:29:42", - "end_ts": "2022-05-24 15:59:00", - "experiment_name": "exp0.2-r0", - "raw_dir": "aeon/data/raw/AEON2/experiment0.2", - "qc_dir": "aeon/data/qc/AEON2/experiment0.2", - "subject_count": 5, - "epoch_count": 1, - "chunk_count": 9, - "experiment_log_message_count": 1, - "subject_enter_exit_count": 1, - "subject_weight_time_count": 1 - } - + + return { + "start_ts": "2022-05-24 08:29:42", + "end_ts": "2022-05-24 15:59:00", + "experiment_name": "exp0.2-r0", + "raw_dir": "aeon/data/raw/AEON2/experiment0.2", + "qc_dir": "aeon/data/qc/AEON2/experiment0.2", + "subject_count": 5, + "epoch_count": 1, + "chunk_count": 9, + "experiment_log_message_count": 0, + "subject_enter_exit_count": 0, + "subject_weight_time_count": 0, + "camera_qc_count": 56, + "camera_tracking_count": 7, + } + def load_pipeline(): + from aeon.dj_pipeline import ( acquisition, analysis, @@ -57,17 +60,18 @@ def load_pipeline(): "report": report, } + def drop_schema(): + _pipeline = load_pipeline() - - _pipeline['report'].schema.drop() - _pipeline['analysis'].schema.drop() - _pipeline['tracking'].schema.drop() - _pipeline['qc'].schema.drop() - _pipeline['acquisition'].schema.drop() - _pipeline['subject'].schema.drop() - _pipeline['lab'].schema.drop() + _pipeline["report"].schema.drop() + _pipeline["analysis"].schema.drop() + _pipeline["tracking"].schema.drop() + _pipeline["qc"].schema.drop() + _pipeline["acquisition"].schema.drop() + _pipeline["subject"].schema.drop() + _pipeline["lab"].schema.drop() @pytest.fixture(autouse=True, scope="session") @@ -84,30 +88,28 @@ def dj_config(): return - @pytest.fixture(autouse=True, scope="session") def pipeline(dj_config): - + _pipeline = load_pipeline() - - if len(_pipeline['acquisition'].Experiment()): + + if len(_pipeline["acquisition"].Experiment()): drop_schema() _pipeline = load_pipeline() - + yield _pipeline if _tear_down: drop_schema() - -@pytest.fixture(autouse=True, scope="session") +@pytest.fixture(scope="session") def exp_creation(test_params, pipeline): from aeon.dj_pipeline.populate import create_experiment_02 create_experiment_02.main() - + acquisition = pipeline["acquisition"] experiment_name = acquisition.Experiment.fetch1("experiment_name") @@ -128,30 +130,29 @@ def exp_creation(test_params, pipeline): "directory_path": test_params["qc_dir"], } ) - + return -@pytest.fixture -def epoch_chunk_ingestion(test_params, pipeline): +@pytest.fixture(scope="session") +def epoch_chunk_ingestion(test_params, pipeline, exp_creation): acquisition = pipeline["acquisition"] - + test_params["experiment_name"] - + acquisition.Epoch.ingest_epochs( experiment_name=test_params["experiment_name"], - start=test_params["start_ts"], end=test_params["end_ts"] + start=test_params["start_ts"], + end=test_params["end_ts"], ) - acquisition.Chunk.ingest_chunks( - experiment_name=test_params["experiment_name"] - ) + acquisition.Chunk.ingest_chunks(experiment_name=test_params["experiment_name"]) return -@pytest.fixture -def experimentlog_ingestion(pipeline): +@pytest.fixture(scope="session") +def experimentlog_ingestion(pipeline, epoch_chunk_ingestion): acquisition = pipeline["acquisition"] acquisition.ExperimentLog.populate(**_populate_settings) @@ -159,3 +160,19 @@ def experimentlog_ingestion(pipeline): acquisition.SubjectWeight.populate(**_populate_settings) return + + +@pytest.fixture(scope="session") +def camera_qc_ingestion(pipeline, epoch_chunk_ingestion): + qc = pipeline["qc"] + qc.CameraQC.populate() + + return + + +@pytest.fixture(scope="session") +def camera_tracking_ingestion(pipeline, camera_qc_ingestion): + tracking = pipeline["tracking"] + tracking.CameraTracking.populate(display_progress=True) + + return diff --git a/tests/dj_pipeline/test_ingestion.py b/tests/dj_pipeline/test_ingestion.py index 79358081..66e41fee 100644 --- a/tests/dj_pipeline/test_ingestion.py +++ b/tests/dj_pipeline/test_ingestion.py @@ -1,47 +1,65 @@ -from . import ( - dj_config, - pipeline, - new_experiment, - test_variables, - epoch_chunk_ingestion, - experimentlog_ingestion, -) +from pytest import mark -def test_epoch_chunk_ingestion(epoch_chunk_ingestion, test_variables, pipeline): +@mark.ingestion +def test_epoch_chunk_ingestion(test_params, pipeline, epoch_chunk_ingestion): acquisition = pipeline["acquisition"] assert ( - len(acquisition.Epoch & {"experiment_name": test_variables["experiment_name"]}) - == test_variables["epoch_count"] + len(acquisition.Epoch & {"experiment_name": test_params["experiment_name"]}) + == test_params["epoch_count"] ) assert ( - len(acquisition.Chunk & {"experiment_name": test_variables["experiment_name"]}) - == test_variables["chunk_count"] + len(acquisition.Chunk & {"experiment_name": test_params["experiment_name"]}) + == test_params["chunk_count"] ) -def test_experimentlog_ingestion(experimentlog_ingestion, test_variables, pipeline): +@mark.experimentlog +def test_experimentlog_ingestion(test_params, pipeline, experimentlog_ingestion): acquisition = pipeline["acquisition"] assert ( len( acquisition.ExperimentLog.Message - & {"experiment_name": test_variables["experiment_name"]} + & {"experiment_name": test_params["experiment_name"]} ) - == test_variables["experiment_log_message_count"] + == test_params["experiment_log_message_count"] ) assert ( len( acquisition.SubjectEnterExit.Time - & {"experiment_name": test_variables["experiment_name"]} + & {"experiment_name": test_params["experiment_name"]} ) - == test_variables["subject_enter_exit_count"] + == test_params["subject_enter_exit_count"] ) assert ( len( acquisition.SubjectWeight.WeightTime - & {"experiment_name": test_variables["experiment_name"]} + & {"experiment_name": test_params["experiment_name"]} ) - == test_variables["subject_weight_time_count"] + == test_params["subject_weight_time_count"] ) + + +@mark.qc +def test_camera_qc_ingestion(test_params, pipeline, camera_qc_ingestion): + + qc = pipeline["qc"] + + assert len(qc.CameraQC()) == test_params["camera_qc_count"] + + +@mark.tracking +def test_camera_tracking_ingestion( + test_params, + pipeline, + camera_tracking_ingestion, +): + + tracking = pipeline["tracking"] + + import pdb + + pdb.set_trace() + assert len(tracking.CameraTracking()) == test_params["camera_tracking_count"] diff --git a/tests/dj_pipeline/test_pipeline_instantiation.py b/tests/dj_pipeline/test_pipeline_instantiation.py index 48652ab3..9b411c19 100644 --- a/tests/dj_pipeline/test_pipeline_instantiation.py +++ b/tests/dj_pipeline/test_pipeline_instantiation.py @@ -11,7 +11,7 @@ def test_pipeline_instantiation(pipeline): @mark.instantiation -def test_exp_creation(pipeline, test_params): +def test_exp_creation(test_params, pipeline, exp_creation): acquisition = pipeline["acquisition"] experiment_name = test_params["experiment_name"] diff --git a/tests/pytest.ini b/tests/pytest.ini new file mode 100644 index 00000000..3148a133 --- /dev/null +++ b/tests/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +filterwarnings = + error + ignore::UserWarning \ No newline at end of file From db0c9ac554d56dc28e7658d40ae437a056db0b33 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 29 Jun 2022 16:26:12 +0000 Subject: [PATCH 030/489] =?UTF-8?q?=E2=9C=A8=20mark=20threshold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aeon/analysis/plotting.py | 2 +- aeon/dj_pipeline/report.py | 95 ++++++++++++++++++++++++++++---------- 2 files changed, 72 insertions(+), 25 deletions(-) diff --git a/aeon/analysis/plotting.py b/aeon/analysis/plotting.py index cf05d075..7be66d3c 100644 --- a/aeon/analysis/plotting.py +++ b/aeon/analysis/plotting.py @@ -91,7 +91,7 @@ def rateplot( **kwargs, ) ax.vlines( - sessiontime(events.index, eventrate.index[0]), -0.2, -0.1, linewidth=1, **kwargs + sessiontime(events.index, eventrate.index[0]), -0.5, -0.1, linewidth=1, **kwargs ) diff --git a/aeon/dj_pipeline/report.py b/aeon/dj_pipeline/report.py index 1a81dd77..d3006f42 100644 --- a/aeon/dj_pipeline/report.py +++ b/aeon/dj_pipeline/report.py @@ -1,18 +1,17 @@ +import datetime +import json import os -import datajoint as dj -import pandas as pd -import numpy as np import pathlib -import matplotlib.pyplot as plt import re -import datetime -import json -from aeon.analysis import plotting as analysis_plotting +import datajoint as dj +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd -from . import acquisition, analysis -from . import get_schema_name +from aeon.analysis import plotting as analysis_plotting +from . import acquisition, analysis, get_schema_name schema = dj.schema(get_schema_name("report")) os.environ["DJ_SUPPORT_FILEPATH_MANAGEMENT"] = "TRUE" @@ -52,20 +51,21 @@ def make(self, key): # subject's position data in the time_slices position = analysis.InArenaSubjectPosition.get_position(key) position.rename(columns={"position_x": "x", "position_y": "y"}, inplace=True) - + position_minutes_elapsed = ( position.index - in_arena_start ).total_seconds() / 60 # figure - fig = plt.figure(figsize=(16, 8)) - gs = fig.add_gridspec(21, 6) - rate_ax = fig.add_subplot(gs[:10, :4]) - distance_ax = fig.add_subplot(gs[10:20, :4]) - ethogram_ax = fig.add_subplot(gs[20, :4]) - position_ax = fig.add_subplot(gs[10:, 4:]) - pellet_ax = fig.add_subplot(gs[:10, 4]) - time_dist_ax = fig.add_subplot(gs[:10, 5:]) + fig = plt.figure(figsize=(20, 9)) + gs = fig.add_gridspec(22, 5) + threshold_ax = fig.add_subplot(gs[:3, :3]) + rate_ax = fig.add_subplot(gs[5:13, :3]) + distance_ax = fig.add_subplot(gs[14:20, :3]) + ethogram_ax = fig.add_subplot(gs[20, :3]) + position_ax = fig.add_subplot(gs[13:, 3:]) + pellet_ax = fig.add_subplot(gs[2:12, 3]) + time_dist_ax = fig.add_subplot(gs[2:12, 4:]) # position plot non_nan = np.logical_and(~np.isnan(position.x), ~np.isnan(position.y)) @@ -106,6 +106,7 @@ def make(self, key): start=in_arena_start, end=in_arena_end, color=self.color_code[food_patch_key["food_patch_description"]], + label=food_patch_key["food_patch_serial_number"], ) # wheel data @@ -124,6 +125,39 @@ def make(self, key): color=self.color_code[food_patch_key["food_patch_description"]], ) + # plot wheel threshold + wheel_time, wheel_threshold = ( + acquisition.WheelState.Time + & food_patch_key + & f'state_timestamp between "{in_arena_start}" and "{in_arena_end}"' + ).fetch("state_timestamp", "threshold") + wheel_time -= in_arena_start + wheel_time /= datetime.timedelta(minutes=1) + + wheel_time = np.append(wheel_time, position_minutes_elapsed[-1]) + + for i in range(0, len(wheel_time) - 1): + threshold_ax.hlines( + y=wheel_threshold[i], + xmin=wheel_time[i], + xmax=wheel_time[i + 1], + linewidth=2, + color=self.color_code[food_patch_key["food_patch_description"]], + alpha=0.3, + ) + threshold_change_ind = np.where( + wheel_threshold[:-1] != wheel_threshold[1:] + )[0] + threshold_ax.vlines( + wheel_time[threshold_change_ind + 1], + ymin=wheel_threshold[threshold_change_ind], + ymax=wheel_threshold[threshold_change_ind + 1], + linewidth=1, + linestyle="dashed", + color=self.color_code[food_patch_key["food_patch_description"]], + alpha=0.4, + ) + # ethogram in_arena, in_corridor, arena_time, corridor_time = ( analysis.InArenaTimeDistribution & key @@ -148,7 +182,7 @@ def make(self, key): color=self.color_code["arena"], markersize=0.5, alpha=0.6, - label=f"Times in arena", + label=f"arena", ) ethogram_ax.plot( position_minutes_elapsed[in_corridor], @@ -157,7 +191,7 @@ def make(self, key): color=self.color_code["corridor"], markersize=0.5, alpha=0.6, - label=f"Times in corridor", + label=f"corridor", ) for in_nest in in_nests: ethogram_ax.plot( @@ -167,7 +201,7 @@ def make(self, key): color=self.color_code["nest"], markersize=0.5, alpha=0.6, - label=f"Times in nest", + label=f"nest", ) for patch_idx, (patch_name, in_patch) in enumerate( zip(patch_names, in_patches) @@ -179,7 +213,7 @@ def make(self, key): color=self.color_code[patch_name], markersize=0.5, alpha=0.6, - label=f"Times in {patch_name}", + label=f"{patch_name}", ) # pellet @@ -194,7 +228,10 @@ def make(self, key): # time distribution time_fractions = [arena_time, corridor_time] - colors = [self.color_code["arena"], self.color_code["corridor"]] + colors = [ + self.color_code["arena"], + self.color_code["corridor"], + ] time_fractions.extend(nests_times) colors.extend([self.color_code["nest"] for _ in nests_times]) time_fractions.extend(patches_times) @@ -208,14 +245,24 @@ def make(self, key): rate_ax.set_ylabel("pellets / min") rate_ax.set_title("foraging rate (bin size = 10 min)") distance_ax.set_ylabel("distance travelled (m)") + threshold_ax.set_ylabel("threshold") + threshold_ax.set_ylim( + [threshold_ax.get_ylim()[0] - 100, threshold_ax.get_ylim()[1] + 100] + ) ethogram_ax.set_xlabel("time (min)") analysis_plotting.set_ymargin(distance_ax, 0.2, 0.1) - for ax in (rate_ax, distance_ax, pellet_ax, time_dist_ax): + for ax in (rate_ax, distance_ax, pellet_ax, time_dist_ax, threshold_ax): ax.spines["top"].set_visible(False) ax.spines["right"].set_visible(False) ax.spines["bottom"].set_visible(False) ax.tick_params(bottom=False, labelbottom=False) + ethogram_ax.legend( + loc="center left", + bbox_to_anchor=(1.01, 2.5), + prop={"size": 12}, + markerscale=40, + ) ethogram_ax.spines["top"].set_visible(False) ethogram_ax.spines["right"].set_visible(False) ethogram_ax.spines["left"].set_visible(False) From 944f75fd27c4e5ed71360281d2b80f59586da5d4 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 11 Jul 2022 16:14:13 +0000 Subject: [PATCH 031/489] delete the wrong notebook file --- tests/threshold.ipynb | 441 ------------------------------------------ 1 file changed, 441 deletions(-) delete mode 100644 tests/threshold.ipynb diff --git a/tests/threshold.ipynb b/tests/threshold.ipynb deleted file mode 100644 index 8bdfd126..00000000 --- a/tests/threshold.ipynb +++ /dev/null @@ -1,441 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "[2022-06-30 15:38:17,222][INFO]: Connecting jaeronga@aeon-db2:3306\n", - "[2022-06-30 15:38:17,288][INFO]: Connected jaeronga@aeon-db2:3306\n" - ] - } - ], - "source": [ - "import datajoint as dj\n", - "from aeon.dj_pipeline.report import *" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "{'experiment_name': 'exp0.1-r0', 'subject': 'BAA-1099790', 'in_arena_start': datetime.datetime(2021, 6, 29, 13, 6, 34, 761660)}\n", - "2021-06-29 13:06:34.761660\n", - "2021-06-29 17:11:26.405819\n" - ] - } - ], - "source": [ - "key_id = 10\n", - "key = (analysis.InArena * analysis.InArenaEnd).fetch('KEY')[key_id]\n", - "in_arena_start, in_arena_end = (\n", - " analysis.InArena * analysis.InArenaEnd & key\n", - ").fetch1(\"in_arena_start\", \"in_arena_end\")\n", - "\n", - "print(key)\n", - "print(in_arena_start)\n", - "print(in_arena_end)\n", - "\n", - "# subject's position data in the time_slices\n", - "position = analysis.InArenaSubjectPosition.get_position(key)\n", - "position.rename(columns={\"position_x\": \"x\", \"position_y\": \"y\"}, inplace=True)\n", - "position_minutes_elapsed = (\n", - " position.index - in_arena_start\n", - ").total_seconds() / 60" - ] - }, - { - "cell_type": "code", - "execution_count": 39, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/tmp/ipykernel_773206/3446466821.py:201: UserWarning: Tight layout not applied. tight_layout cannot make axes height small enough to accommodate all axes decorations.\n", - " plt.tight_layout()\n" - ] - }, - { - "data": { - "image/png": "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lER06tSJ/v370759+5zOd/cjzKw3cCowycy6An91918UM04Rqc/dz046BhFpmpJPIlKafvhDuPtuaNcOKiuhffut+65d4f/9P9h776SjFBFpFWpqathhhx0YPHgwGSu4tXruzqpVq6ipqWHIkCHNuW4p8Dszexz4PvBTQMknkZiZWX/g98DBhIqnZ4BvuXtNooGJyDbUcFxESs8998AVV0DfvjBqFOy+OwwYAD17wvbbw8svwxe+AJs3Jx2piEir8Omnn7Lzzju3ucQTgJmx8847N6vqy8z2NLNqM3sTuJaw0l3/YsUoIo26BXgA6Av0Ax6MjolICVHlk4iUnuuug+HDYdq0UO2U6e674dRTYepUGD8+/vhERFqhtph4Ssnjvd8C3Akc4+5LCh+RiDRDT3dPTzbdambfTioYEclOlU8iUlqWLYOnnoLTTsueeAI48UTo0gX+/vdYQxMRkeJZunQpp512GkOHDmXEiBEcd9xxvPPOO8ycOZMjjzyS4cOHM2zYMH7+85/j7gDceuutmBnTpk3bMs59992HmXHPPfdsM/4FF1xAly5dChKrux/o7r9V4kmkJKw0szPMrF20nQGsSjooEdmWkk8iUloeewzq6uDkkxs+p2NHOProcK6IiJQ9d+fzn/88hx9+OO+99x6zZs3il7/8JcuWLePEE0/k4osv5p133uH111/n2Wef5frrr99y7d57782dd9655fldd93FqFGjthl/+vTpfPjhhy2O08wmR/sZZvZG2jbDzN5o8Q1EJB9fJTT/XwrUAl+IjolICVHySURKy3PPwQ47wD77NH7eIYfAvHmwdGk8cYmISNE8/vjjtG/fnq9//etbjo0ePZp33nmHgw8+mGOOCSuob7fddlx77bVceeWVW8479NBDefHFF9m4cSMfffQRc+bMYfTo0Vte37x5M9/73ve4+uqrCxHqt6L98cAJaVvquYjEzN0XuvuJ7t7T3Xdx95PdfUHScYnItsqi55OZdQKeAjoSYr7H3S81s+7AX4HBwHzgVHf/IKk4RaQAnn0WDjggrHLXmAMPDPuXXoIT9O99EZFC+fa34bXXCjvm6NHwm980/Pqbb77JmDFj6h2fOXNmveNDhw7lo48+Ys2aNUDo13T00Ufz6KOPsnr1ak488UTmzZu35fxrr72WE088kT59+rT4fbh7bbTXB1uRhJnZ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", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], - "source": [ - "# figure\n", - "fig = plt.figure(figsize=(20, 9))\n", - "gs = fig.add_gridspec(22, 5)\n", - "threshold_ax = fig.add_subplot(gs[:4, :3])\n", - "rate_ax = fig.add_subplot(gs[6:13, :3])\n", - "distance_ax = fig.add_subplot(gs[14:20, :3])\n", - "ethogram_ax = fig.add_subplot(gs[20, :3])\n", - "position_ax = fig.add_subplot(gs[13:, 3:])\n", - "pellet_ax = fig.add_subplot(gs[2:12, 3])\n", - "time_dist_ax = fig.add_subplot(gs[2:12, 4:])\n", - "\n", - "# position plot\n", - "non_nan = np.logical_and(~np.isnan(position.x), ~np.isnan(position.y))\n", - "analysis_plotting.heatmap(\n", - " position[non_nan], 50, ax=position_ax, bins=500, alpha=0.5\n", - ")\n", - "\n", - "# event rate plots\n", - "in_arena_food_patches = (\n", - " analysis.InArena\n", - " * acquisition.ExperimentFoodPatch.join(\n", - " acquisition.ExperimentFoodPatch.RemovalTime, left=True\n", - " )\n", - " & key\n", - " & \"in_arena_start >= food_patch_install_time\"\n", - " & 'in_arena_start < IFNULL(food_patch_remove_time, \"2200-01-01\")'\n", - ").proj(\"food_patch_description\")\n", - "\n", - "for food_patch_key in in_arena_food_patches.fetch(as_dict=True):\n", - " pellet_times_df = (\n", - " (\n", - " acquisition.FoodPatchEvent * acquisition.EventType\n", - " & food_patch_key\n", - " & 'event_type = \"TriggerPellet\"'\n", - " & f'event_time BETWEEN \"{in_arena_start}\" AND \"{in_arena_end}\"'\n", - " )\n", - " .proj(\"event_time\")\n", - " .fetch(format=\"frame\", order_by=\"event_time\")\n", - " .reset_index()\n", - " )\n", - " pellet_times_df.set_index(\"event_time\", inplace=True)\n", - " analysis_plotting.rateplot(\n", - " pellet_times_df,\n", - " window=\"600s\",\n", - " frequency=500,\n", - " ax=rate_ax,\n", - " smooth=\"120s\",\n", - " start=in_arena_start,\n", - " end=in_arena_end,\n", - " color=InArenaSummaryPlot.color_code[food_patch_key[\"food_patch_description\"]],\n", - " label=food_patch_key['food_patch_serial_number']\n", - " )\n", - " \n", - " # wheel data\n", - " wheel_data = acquisition.FoodPatchWheel.get_wheel_data(\n", - " experiment_name=key[\"experiment_name\"],\n", - " start=pd.Timestamp(in_arena_start),\n", - " end=pd.Timestamp(in_arena_end),\n", - " patch_name=food_patch_key[\"food_patch_description\"],\n", - " using_aeon_io=True,\n", - " )\n", - "\n", - " minutes_elapsed = (wheel_data.index - in_arena_start).total_seconds() / 60\n", - " distance_ax.plot(\n", - " minutes_elapsed,\n", - " wheel_data.distance_travelled.values,\n", - " color=InArenaSummaryPlot.color_code[food_patch_key[\"food_patch_description\"]],\n", - " )\n", - " \n", - " # plot wheel threshold\n", - " wheel_time, wheel_threshold = (acquisition.WheelState.Time & \n", - " food_patch_key & \n", - " f'state_timestamp between \"{in_arena_start}\" and \"{in_arena_end}\"').fetch('state_timestamp', 'threshold')\n", - " wheel_time -= in_arena_start \n", - " wheel_time /= datetime.timedelta(minutes=1)\n", - " \n", - " wheel_time = np.append(wheel_time, position_minutes_elapsed[-1])\n", - " \n", - " for i in range(0, len(wheel_time) - 1):\n", - " threshold_ax.hlines(y=wheel_threshold[i], xmin=wheel_time[i], xmax=wheel_time[i + 1], linewidth=2, \n", - " color=InArenaSummaryPlot.color_code[food_patch_key[\"food_patch_description\"]],\n", - " alpha=0.3\n", - " )\n", - " threshold_change_ind = np.where(wheel_threshold[:-1] != wheel_threshold[1:])[0]\n", - " threshold_ax.vlines(wheel_time[threshold_change_ind], \n", - " ymin=wheel_threshold[threshold_change_ind], \n", - " ymax=wheel_threshold[threshold_change_ind+1], \n", - " linewidth=2, linestyle='--',\n", - " color=InArenaSummaryPlot.color_code[food_patch_key[\"food_patch_description\"]], alpha=0.4)\n", - " \n", - "# ethogram\n", - "in_arena, in_corridor, arena_time, corridor_time = (\n", - " analysis.InArenaTimeDistribution & key\n", - ").fetch1(\n", - " \"in_arena\",\n", - " \"in_corridor\",\n", - " \"time_fraction_in_arena\",\n", - " \"time_fraction_in_corridor\",\n", - ")\n", - "nest_keys, in_nests, nests_times = (\n", - " analysis.InArenaTimeDistribution.Nest & key\n", - ").fetch(\"KEY\", \"in_nest\", \"time_fraction_in_nest\")\n", - "patch_names, in_patches, patches_times = (\n", - " analysis.InArenaTimeDistribution.FoodPatch * acquisition.ExperimentFoodPatch\n", - " & key\n", - ").fetch(\"food_patch_description\", \"in_patch\", \"time_fraction_in_patch\")\n", - "\n", - "ethogram_ax.plot(\n", - " position_minutes_elapsed[in_arena],\n", - " np.full_like(position_minutes_elapsed[in_arena], 0),\n", - " \".\",\n", - " color=InArenaSummaryPlot.color_code[\"arena\"],\n", - " markersize=0.2,\n", - " alpha=0.6,\n", - " label=f\"arena\",\n", - ")\n", - "ethogram_ax.plot(\n", - " position_minutes_elapsed[in_corridor],\n", - " np.full_like(position_minutes_elapsed[in_corridor], 1),\n", - " \".\",\n", - " color=InArenaSummaryPlot.color_code[\"corridor\"],\n", - " markersize=0.5,\n", - " alpha=0.6,\n", - " label=f\"corridor\",\n", - ")\n", - "for in_nest in in_nests:\n", - " ethogram_ax.plot(\n", - " position_minutes_elapsed[in_nest],\n", - " np.full_like(position_minutes_elapsed[in_nest], 2),\n", - " \".\",\n", - " color=InArenaSummaryPlot.color_code[\"nest\"],\n", - " markersize=0.5,\n", - " alpha=0.6,\n", - " label=f\"nest\",\n", - " )\n", - "for patch_idx, (patch_name, in_patch) in enumerate(\n", - " zip(patch_names, in_patches)\n", - "):\n", - " ethogram_ax.plot(\n", - " position_minutes_elapsed[in_patch],\n", - " np.full_like(position_minutes_elapsed[in_patch], (patch_idx + 3)),\n", - " \".\",\n", - " color=InArenaSummaryPlot.color_code[patch_name],\n", - " markersize=0.5,\n", - " alpha=0.6,\n", - " label=f\"{patch_name}\",\n", - " )\n", - "\n", - "# pellet\n", - "patch_names, patches_pellet = (\n", - " analysis.InArenaSummary.FoodPatch * acquisition.ExperimentFoodPatch & key\n", - ").fetch(\"food_patch_description\", \"pellet_count\")\n", - "pellet_ax.bar(\n", - " range(len(patches_pellet)),\n", - " patches_pellet,\n", - " color=[InArenaSummaryPlot.color_code[n] for n in patch_names],\n", - ")\n", - "\n", - "# time distribution\n", - "time_fractions = [arena_time, corridor_time]\n", - "colors = [InArenaSummaryPlot.color_code[\"arena\"], InArenaSummaryPlot.color_code[\"corridor\"]]\n", - "time_fractions.extend(nests_times)\n", - "colors.extend([InArenaSummaryPlot.color_code[\"nest\"] for _ in nests_times])\n", - "time_fractions.extend(patches_times)\n", - "colors.extend([InArenaSummaryPlot.color_code[n] for n in patch_names])\n", - "time_dist_ax.bar(range(len(time_fractions)), time_fractions, color=colors)\n", - "\n", - "# cosmetic\n", - "rate_ax.legend()\n", - "rate_ax.sharex(distance_ax)\n", - "fig.subplots_adjust(hspace=0.1)\n", - "rate_ax.set_ylabel(\"pellets / min\")\n", - "rate_ax.set_title(\"foraging rate (bin size = 10 min)\")\n", - "distance_ax.set_ylabel(\"distance travelled (m)\")\n", - "threshold_ax.set_ylabel(\"threshold\")\n", - "threshold_ax.set_ylim([threshold_ax.get_ylim()[0] - 100, threshold_ax.get_ylim()[1] + 100])\n", - "ethogram_ax.set_xlabel(\"time (min)\")\n", - "analysis_plotting.set_ymargin(distance_ax, 0.2, 0.1)\n", - "for ax in (rate_ax, distance_ax, pellet_ax, time_dist_ax, threshold_ax):\n", - " ax.spines[\"top\"].set_visible(False)\n", - " ax.spines[\"right\"].set_visible(False)\n", - " ax.spines[\"bottom\"].set_visible(False)\n", - " ax.tick_params(bottom=False, labelbottom=False)\n", - "\n", - "ethogram_ax.legend(loc='center left', \n", - " bbox_to_anchor=(1.01, 2.5), prop={'size': 12},\n", - " markerscale=40,\n", - " ) \n", - "ethogram_ax.spines[\"top\"].set_visible(False)\n", - "ethogram_ax.spines[\"right\"].set_visible(False)\n", - "ethogram_ax.spines[\"left\"].set_visible(False)\n", - "ethogram_ax.tick_params(left=False, labelleft=False)\n", - "analysis_plotting.set_ymargin(ethogram_ax, 0.4, 0)\n", - "\n", - "position_ax.set_aspect(\"equal\")\n", - "position_ax.set_axis_off()\n", - "\n", - "pellet_ax.set_ylabel(\"pellets delivered\")\n", - "time_dist_ax.set_ylabel(\"Fraction of session duration\")\n", - "\n", - "plt.tight_layout()\n", - "fig.show()" - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "array([ 100., 100., 100., 100., 100., 100., 100., 100., 100.,\n", - " 100., 100., 100., 100., 100., 100., 100., 100., 100.,\n", - " 100., 100., 100., 100., 100., 100., 100., 100., 100.,\n", - " 100., 100., 100., 100., 100., 100., 100., 100., 100.,\n", - " 100., 100., 100., 100., 100., 100., 100., 100., 100.,\n", - " 100., 100., 1500.])" - ] - }, - "execution_count": 16, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "wheel_threshold" - ] - }, - { - "cell_type": "code", - "execution_count": 23, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "array([0.00013865, 19.434972333333334, 22.726538666666666,\n", - " 22.879405316666666, 23.034872, 23.205572333333333, 23.435872,\n", - " 23.668839, 23.887172333333332, 24.064272, 24.253272,\n", - " 24.443238983333334, 24.64470565, 24.80090565, 24.973972316666668,\n", - " 25.304138666666667, 25.47613865, 25.649271983333332,\n", - " 25.830705666666667, 26.017639, 26.193671983333335,\n", - " 26.40470566666667, 26.57310565, 26.738172333333335,\n", - " 33.79057233333333, 33.994005316666666, 34.197705666666664,\n", - " 34.44197233333333, 34.61690566666667, 34.858305666666666,\n", - " 35.056372333333336, 71.05550566666666, 71.19780533333333,\n", - " 71.50957231666666, 71.66637231666667, 71.94233866666667, 72.103839,\n", - " 72.29610565, 72.51560531666667, 72.81097233333334, 73.114272,\n", - " 73.28393865, 73.46630565, 73.62647198333333, 73.77723898333333,\n", - " 73.94443898333333, 74.12590565, 74.27870566666667,\n", - " 244.86060060000003], dtype=object)" - ] - }, - "execution_count": 23, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "wheel_time" - ] - }, - { - "cell_type": "code", - "execution_count": 41, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "array([], dtype=float64)" - ] - }, - "execution_count": 41, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [] - }, - { - "cell_type": "code", - "execution_count": 42, - "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], - "source": [ - "threshold_change_ind = np.where(wheel_threshold[:-1] != wheel_threshold[1:])[0]\n", - "plt.vlines(wheel_time[threshold_change_ind], \n", - " ymin=wheel_threshold[threshold_change_ind], \n", - " ymax=wheel_threshold[threshold_change_ind+1], \n", - " linewidth=2, linestyle='--',\n", - " color=InArenaSummaryPlot.color_code[food_patch_key[\"food_patch_description\"]], alpha=0.4)\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": 36, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "array([100.])" - ] - }, - "execution_count": 36, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "\n", - "wheel_threshold[threshold_change_ind-1]" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3.9.13 64-bit ('aeon')", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.13" - }, - "orig_nbformat": 4, - "vscode": { - "interpreter": { - "hash": "db60b8d28c97257fefe0af538ee53d7167e755182e7d18d3719c6d10bd23282f" - } - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} From fd0c79f0ae01095f92f4f4670b73fc4b8424ca71 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 13 Jul 2022 00:59:32 +0000 Subject: [PATCH 032/489] remove test notebook --- tests/threshold.ipynb | 441 ------------------------------------------ 1 file changed, 441 deletions(-) delete mode 100644 tests/threshold.ipynb diff --git a/tests/threshold.ipynb b/tests/threshold.ipynb deleted file mode 100644 index 8bdfd126..00000000 --- a/tests/threshold.ipynb +++ /dev/null @@ -1,441 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "[2022-06-30 15:38:17,222][INFO]: Connecting jaeronga@aeon-db2:3306\n", - "[2022-06-30 15:38:17,288][INFO]: Connected jaeronga@aeon-db2:3306\n" - ] - } - ], - "source": [ - "import datajoint as dj\n", - "from aeon.dj_pipeline.report import *" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "{'experiment_name': 'exp0.1-r0', 'subject': 'BAA-1099790', 'in_arena_start': datetime.datetime(2021, 6, 29, 13, 6, 34, 761660)}\n", - "2021-06-29 13:06:34.761660\n", - "2021-06-29 17:11:26.405819\n" - ] - } - ], - "source": [ - "key_id = 10\n", - "key = (analysis.InArena * analysis.InArenaEnd).fetch('KEY')[key_id]\n", - "in_arena_start, in_arena_end = (\n", - " analysis.InArena * analysis.InArenaEnd & key\n", - ").fetch1(\"in_arena_start\", \"in_arena_end\")\n", - "\n", - "print(key)\n", - "print(in_arena_start)\n", - "print(in_arena_end)\n", - "\n", - "# subject's position data in the time_slices\n", - "position = analysis.InArenaSubjectPosition.get_position(key)\n", - "position.rename(columns={\"position_x\": \"x\", \"position_y\": \"y\"}, inplace=True)\n", - "position_minutes_elapsed = (\n", - " position.index - in_arena_start\n", - ").total_seconds() / 60" - ] - }, - { - "cell_type": "code", - "execution_count": 39, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/tmp/ipykernel_773206/3446466821.py:201: UserWarning: Tight layout not applied. tight_layout cannot make axes height small enough to accommodate all axes decorations.\n", - " plt.tight_layout()\n" - ] - }, - { - "data": { - "image/png": "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lER06tSJ/v370759+5zOd/cjzKw3cCowycy6An91918UM04Rqc/dz046BhFpmpJPIlKafvhDuPtuaNcOKiuhffut+65d4f/9P9h776SjFBFpFWpqathhhx0YPHgwGSu4tXruzqpVq6ipqWHIkCHNuW4p8Dszexz4PvBTQMknkZiZWX/g98DBhIqnZ4BvuXtNooGJyDbUcFxESs8998AVV0DfvjBqFOy+OwwYAD17wvbbw8svwxe+AJs3Jx2piEir8Omnn7Lzzju3ucQTgJmx8847N6vqy8z2NLNqM3sTuJaw0l3/YsUoIo26BXgA6Av0Ax6MjolICVHlk4iUnuuug+HDYdq0UO2U6e674dRTYepUGD8+/vhERFqhtph4Ssnjvd8C3Akc4+5LCh+RiDRDT3dPTzbdambfTioYEclOlU8iUlqWLYOnnoLTTsueeAI48UTo0gX+/vdYQxMRkeJZunQpp512GkOHDmXEiBEcd9xxvPPOO8ycOZMjjzyS4cOHM2zYMH7+85/j7gDceuutmBnTpk3bMs59992HmXHPPfdsM/4FF1xAly5dChKrux/o7r9V4kmkJKw0szPMrF20nQGsSjooEdmWkk8iUloeewzq6uDkkxs+p2NHOProcK6IiJQ9d+fzn/88hx9+OO+99x6zZs3il7/8JcuWLePEE0/k4osv5p133uH111/n2Wef5frrr99y7d57782dd9655fldd93FqFGjthl/+vTpfPjhhy2O08wmR/sZZvZG2jbDzN5o8Q1EJB9fJTT/XwrUAl+IjolICVHySURKy3PPwQ47wD77NH7eIYfAvHmwdGk8cYmISNE8/vjjtG/fnq9//etbjo0ePZp33nmHgw8+mGOOCSuob7fddlx77bVceeWVW8479NBDefHFF9m4cSMfffQRc+bMYfTo0Vte37x5M9/73ve4+uqrCxHqt6L98cAJaVvquYjEzN0XuvuJ7t7T3Xdx95PdfUHScYnItsqi55OZdQKeAjoSYr7H3S81s+7AX4HBwHzgVHf/IKk4RaQAnn0WDjggrHLXmAMPDPuXXoIT9O99EZFC+fa34bXXCjvm6NHwm980/Pqbb77JmDFj6h2fOXNmveNDhw7lo48+Ys2aNUDo13T00Ufz6KOPsnr1ak488UTmzZu35fxrr72WE088kT59+rT4fbh7bbTXB1uRhJnZ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", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], - "source": [ - "# figure\n", - "fig = plt.figure(figsize=(20, 9))\n", - "gs = fig.add_gridspec(22, 5)\n", - "threshold_ax = fig.add_subplot(gs[:4, :3])\n", - "rate_ax = fig.add_subplot(gs[6:13, :3])\n", - "distance_ax = fig.add_subplot(gs[14:20, :3])\n", - "ethogram_ax = fig.add_subplot(gs[20, :3])\n", - "position_ax = fig.add_subplot(gs[13:, 3:])\n", - "pellet_ax = fig.add_subplot(gs[2:12, 3])\n", - "time_dist_ax = fig.add_subplot(gs[2:12, 4:])\n", - "\n", - "# position plot\n", - "non_nan = np.logical_and(~np.isnan(position.x), ~np.isnan(position.y))\n", - "analysis_plotting.heatmap(\n", - " position[non_nan], 50, ax=position_ax, bins=500, alpha=0.5\n", - ")\n", - "\n", - "# event rate plots\n", - "in_arena_food_patches = (\n", - " analysis.InArena\n", - " * acquisition.ExperimentFoodPatch.join(\n", - " acquisition.ExperimentFoodPatch.RemovalTime, left=True\n", - " )\n", - " & key\n", - " & \"in_arena_start >= food_patch_install_time\"\n", - " & 'in_arena_start < IFNULL(food_patch_remove_time, \"2200-01-01\")'\n", - ").proj(\"food_patch_description\")\n", - "\n", - "for food_patch_key in in_arena_food_patches.fetch(as_dict=True):\n", - " pellet_times_df = (\n", - " (\n", - " acquisition.FoodPatchEvent * acquisition.EventType\n", - " & food_patch_key\n", - " & 'event_type = \"TriggerPellet\"'\n", - " & f'event_time BETWEEN \"{in_arena_start}\" AND \"{in_arena_end}\"'\n", - " )\n", - " .proj(\"event_time\")\n", - " .fetch(format=\"frame\", order_by=\"event_time\")\n", - " .reset_index()\n", - " )\n", - " pellet_times_df.set_index(\"event_time\", inplace=True)\n", - " analysis_plotting.rateplot(\n", - " pellet_times_df,\n", - " window=\"600s\",\n", - " frequency=500,\n", - " ax=rate_ax,\n", - " smooth=\"120s\",\n", - " start=in_arena_start,\n", - " end=in_arena_end,\n", - " color=InArenaSummaryPlot.color_code[food_patch_key[\"food_patch_description\"]],\n", - " label=food_patch_key['food_patch_serial_number']\n", - " )\n", - " \n", - " # wheel data\n", - " wheel_data = acquisition.FoodPatchWheel.get_wheel_data(\n", - " experiment_name=key[\"experiment_name\"],\n", - " start=pd.Timestamp(in_arena_start),\n", - " end=pd.Timestamp(in_arena_end),\n", - " patch_name=food_patch_key[\"food_patch_description\"],\n", - " using_aeon_io=True,\n", - " )\n", - "\n", - " minutes_elapsed = (wheel_data.index - in_arena_start).total_seconds() / 60\n", - " distance_ax.plot(\n", - " minutes_elapsed,\n", - " wheel_data.distance_travelled.values,\n", - " color=InArenaSummaryPlot.color_code[food_patch_key[\"food_patch_description\"]],\n", - " )\n", - " \n", - " # plot wheel threshold\n", - " wheel_time, wheel_threshold = (acquisition.WheelState.Time & \n", - " food_patch_key & \n", - " f'state_timestamp between \"{in_arena_start}\" and \"{in_arena_end}\"').fetch('state_timestamp', 'threshold')\n", - " wheel_time -= in_arena_start \n", - " wheel_time /= datetime.timedelta(minutes=1)\n", - " \n", - " wheel_time = np.append(wheel_time, position_minutes_elapsed[-1])\n", - " \n", - " for i in range(0, len(wheel_time) - 1):\n", - " threshold_ax.hlines(y=wheel_threshold[i], xmin=wheel_time[i], xmax=wheel_time[i + 1], linewidth=2, \n", - " color=InArenaSummaryPlot.color_code[food_patch_key[\"food_patch_description\"]],\n", - " alpha=0.3\n", - " )\n", - " threshold_change_ind = np.where(wheel_threshold[:-1] != wheel_threshold[1:])[0]\n", - " threshold_ax.vlines(wheel_time[threshold_change_ind], \n", - " ymin=wheel_threshold[threshold_change_ind], \n", - " ymax=wheel_threshold[threshold_change_ind+1], \n", - " linewidth=2, linestyle='--',\n", - " color=InArenaSummaryPlot.color_code[food_patch_key[\"food_patch_description\"]], alpha=0.4)\n", - " \n", - "# ethogram\n", - "in_arena, in_corridor, arena_time, corridor_time = (\n", - " analysis.InArenaTimeDistribution & key\n", - ").fetch1(\n", - " \"in_arena\",\n", - " \"in_corridor\",\n", - " \"time_fraction_in_arena\",\n", - " \"time_fraction_in_corridor\",\n", - ")\n", - "nest_keys, in_nests, nests_times = (\n", - " analysis.InArenaTimeDistribution.Nest & key\n", - ").fetch(\"KEY\", \"in_nest\", \"time_fraction_in_nest\")\n", - "patch_names, in_patches, patches_times = (\n", - " analysis.InArenaTimeDistribution.FoodPatch * acquisition.ExperimentFoodPatch\n", - " & key\n", - ").fetch(\"food_patch_description\", \"in_patch\", \"time_fraction_in_patch\")\n", - "\n", - "ethogram_ax.plot(\n", - " position_minutes_elapsed[in_arena],\n", - " np.full_like(position_minutes_elapsed[in_arena], 0),\n", - " \".\",\n", - " color=InArenaSummaryPlot.color_code[\"arena\"],\n", - " markersize=0.2,\n", - " alpha=0.6,\n", - " label=f\"arena\",\n", - ")\n", - "ethogram_ax.plot(\n", - " position_minutes_elapsed[in_corridor],\n", - " np.full_like(position_minutes_elapsed[in_corridor], 1),\n", - " \".\",\n", - " color=InArenaSummaryPlot.color_code[\"corridor\"],\n", - " markersize=0.5,\n", - " alpha=0.6,\n", - " label=f\"corridor\",\n", - ")\n", - "for in_nest in in_nests:\n", - " ethogram_ax.plot(\n", - " position_minutes_elapsed[in_nest],\n", - " np.full_like(position_minutes_elapsed[in_nest], 2),\n", - " \".\",\n", - " color=InArenaSummaryPlot.color_code[\"nest\"],\n", - " markersize=0.5,\n", - " alpha=0.6,\n", - " label=f\"nest\",\n", - " )\n", - "for patch_idx, (patch_name, in_patch) in enumerate(\n", - " zip(patch_names, in_patches)\n", - "):\n", - " ethogram_ax.plot(\n", - " position_minutes_elapsed[in_patch],\n", - " np.full_like(position_minutes_elapsed[in_patch], (patch_idx + 3)),\n", - " \".\",\n", - " color=InArenaSummaryPlot.color_code[patch_name],\n", - " markersize=0.5,\n", - " alpha=0.6,\n", - " label=f\"{patch_name}\",\n", - " )\n", - "\n", - "# pellet\n", - "patch_names, patches_pellet = (\n", - " analysis.InArenaSummary.FoodPatch * acquisition.ExperimentFoodPatch & key\n", - ").fetch(\"food_patch_description\", \"pellet_count\")\n", - "pellet_ax.bar(\n", - " range(len(patches_pellet)),\n", - " patches_pellet,\n", - " color=[InArenaSummaryPlot.color_code[n] for n in patch_names],\n", - ")\n", - "\n", - "# time distribution\n", - "time_fractions = [arena_time, corridor_time]\n", - "colors = [InArenaSummaryPlot.color_code[\"arena\"], InArenaSummaryPlot.color_code[\"corridor\"]]\n", - "time_fractions.extend(nests_times)\n", - "colors.extend([InArenaSummaryPlot.color_code[\"nest\"] for _ in nests_times])\n", - "time_fractions.extend(patches_times)\n", - "colors.extend([InArenaSummaryPlot.color_code[n] for n in patch_names])\n", - "time_dist_ax.bar(range(len(time_fractions)), time_fractions, color=colors)\n", - "\n", - "# cosmetic\n", - "rate_ax.legend()\n", - "rate_ax.sharex(distance_ax)\n", - "fig.subplots_adjust(hspace=0.1)\n", - "rate_ax.set_ylabel(\"pellets / min\")\n", - "rate_ax.set_title(\"foraging rate (bin size = 10 min)\")\n", - "distance_ax.set_ylabel(\"distance travelled (m)\")\n", - "threshold_ax.set_ylabel(\"threshold\")\n", - "threshold_ax.set_ylim([threshold_ax.get_ylim()[0] - 100, threshold_ax.get_ylim()[1] + 100])\n", - "ethogram_ax.set_xlabel(\"time (min)\")\n", - "analysis_plotting.set_ymargin(distance_ax, 0.2, 0.1)\n", - "for ax in (rate_ax, distance_ax, pellet_ax, time_dist_ax, threshold_ax):\n", - " ax.spines[\"top\"].set_visible(False)\n", - " ax.spines[\"right\"].set_visible(False)\n", - " ax.spines[\"bottom\"].set_visible(False)\n", - " ax.tick_params(bottom=False, labelbottom=False)\n", - "\n", - "ethogram_ax.legend(loc='center left', \n", - " bbox_to_anchor=(1.01, 2.5), prop={'size': 12},\n", - " markerscale=40,\n", - " ) \n", - "ethogram_ax.spines[\"top\"].set_visible(False)\n", - "ethogram_ax.spines[\"right\"].set_visible(False)\n", - "ethogram_ax.spines[\"left\"].set_visible(False)\n", - "ethogram_ax.tick_params(left=False, labelleft=False)\n", - "analysis_plotting.set_ymargin(ethogram_ax, 0.4, 0)\n", - "\n", - "position_ax.set_aspect(\"equal\")\n", - "position_ax.set_axis_off()\n", - "\n", - "pellet_ax.set_ylabel(\"pellets delivered\")\n", - "time_dist_ax.set_ylabel(\"Fraction of session duration\")\n", - "\n", - "plt.tight_layout()\n", - "fig.show()" - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "array([ 100., 100., 100., 100., 100., 100., 100., 100., 100.,\n", - " 100., 100., 100., 100., 100., 100., 100., 100., 100.,\n", - " 100., 100., 100., 100., 100., 100., 100., 100., 100.,\n", - " 100., 100., 100., 100., 100., 100., 100., 100., 100.,\n", - " 100., 100., 100., 100., 100., 100., 100., 100., 100.,\n", - " 100., 100., 1500.])" - ] - }, - "execution_count": 16, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "wheel_threshold" - ] - }, - { - "cell_type": "code", - "execution_count": 23, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "array([0.00013865, 19.434972333333334, 22.726538666666666,\n", - " 22.879405316666666, 23.034872, 23.205572333333333, 23.435872,\n", - " 23.668839, 23.887172333333332, 24.064272, 24.253272,\n", - " 24.443238983333334, 24.64470565, 24.80090565, 24.973972316666668,\n", - " 25.304138666666667, 25.47613865, 25.649271983333332,\n", - " 25.830705666666667, 26.017639, 26.193671983333335,\n", - " 26.40470566666667, 26.57310565, 26.738172333333335,\n", - " 33.79057233333333, 33.994005316666666, 34.197705666666664,\n", - " 34.44197233333333, 34.61690566666667, 34.858305666666666,\n", - " 35.056372333333336, 71.05550566666666, 71.19780533333333,\n", - " 71.50957231666666, 71.66637231666667, 71.94233866666667, 72.103839,\n", - " 72.29610565, 72.51560531666667, 72.81097233333334, 73.114272,\n", - " 73.28393865, 73.46630565, 73.62647198333333, 73.77723898333333,\n", - " 73.94443898333333, 74.12590565, 74.27870566666667,\n", - " 244.86060060000003], dtype=object)" - ] - }, - "execution_count": 23, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "wheel_time" - ] - }, - { - "cell_type": "code", - "execution_count": 41, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "array([], dtype=float64)" - ] - }, - "execution_count": 41, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [] - }, - { - "cell_type": "code", - "execution_count": 42, - "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], - "source": [ - "threshold_change_ind = np.where(wheel_threshold[:-1] != wheel_threshold[1:])[0]\n", - "plt.vlines(wheel_time[threshold_change_ind], \n", - " ymin=wheel_threshold[threshold_change_ind], \n", - " ymax=wheel_threshold[threshold_change_ind+1], \n", - " linewidth=2, linestyle='--',\n", - " color=InArenaSummaryPlot.color_code[food_patch_key[\"food_patch_description\"]], alpha=0.4)\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": 36, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "array([100.])" - ] - }, - "execution_count": 36, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "\n", - "wheel_threshold[threshold_change_ind-1]" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3.9.13 64-bit ('aeon')", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.13" - }, - "orig_nbformat": 4, - "vscode": { - "interpreter": { - "hash": "db60b8d28c97257fefe0af538ee53d7167e755182e7d18d3719c6d10bd23282f" - } - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} From 02597879d2dcde871c537c1a94168af7cc40536d Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 13 Jul 2022 00:59:51 +0000 Subject: [PATCH 033/489] add a new test data --- ...20622090000-21053810-20220622085110-0-0.npy | Bin 0 -> 720124 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/data/exp0.2-r0-20220622090000-21053810-20220622085110-0-0.npy diff --git a/tests/data/exp0.2-r0-20220622090000-21053810-20220622085110-0-0.npy b/tests/data/exp0.2-r0-20220622090000-21053810-20220622085110-0-0.npy new file mode 100644 index 0000000000000000000000000000000000000000..f9ea5c17482b778e8048329a7cdccc9933d05910 GIT binary patch literal 720124 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 }A{?yjD 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
    G$z( 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 gVAcBC@XuZkn25z}{3` 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%0Dx^!6|5c^NHD03ttfcHnD|zn>RUon;Sm8 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

    Sdgkt3S z=os8X1|<@`v}b_gF+Dw;+o5WulO^Itu?nqiDN63g*1? z8}lL(*VaU$`_M>?Qbl6Nqs7?#HA)O<@1%LZ#qi`kx1|rGq<~mo!(#(gUeC1HQt2-1&m-}NFu@y7Y{P5v1NpT8{cM48 zbwOaNClXCP(ILhIF6Hj%xx^iV?c8aL+zk=oZm8$whU^Azu(fvs@p^EF&x*EgXy)#Q zoE~miGr$dDgWb@6q#M@Fa)ZHqH#FMnhNpks@IJ^LTTDD~@Q??_&507f6H#Jm87&RZ z3;GQN=Dr5TF7m>U#@+GdR(Bj&+XDr2dSdsuURdDHy42}|(ARxX6xkR3ZTsQSl77%t z_vg8Ne?)i^nWvG?0z42zCLyi*iP4@SYZZ!}(ijK+_d z8mueQz>9@6?txh3c8$YX{cOmW3?{nsK`vmm^Z}7?2v(n^D5w*Deb!`VtRw zR2HhHX5rc7EbOE^YGM}De0C1X!mEfZ8319Xt7Va=IoQ|bN0&({~XywI|+Dm zfOCk0Vm0=V==3@){Ye3BRy{p@S}gXS zk;Efss0xAn7;Xr7H`7TKt(#!<>H8@20X<8PyE*tgAwYx`{M>XMD(?%6oj zKN~h9vT=7xHk=n^QD4QJIZzhqF;|A{#4pZ^#BSq4<&T#Bbm4Nv4qDyk=HZxWz@{4V(RfsY;Hc2 z{u7_e2=f>6qx6ON*uIjJqhCwtxi@0(@=iuQdoR@kVoNev9AM$K{iTF8`N?!L;=|7@W=JhF+vCT^5z{?UTt5^cl4 zi4IcN*q8T{3aF#Y`#MeQSEo)wb>frOz^9Kj;5Dr#?ibX=1piv7+FlD=e%8Wi)W*Yv z+PHm(_DXE);O2xn7=FACZW-4_k!M|;4y%i|$N5}R7ikWTFzn=rd@o0|@ODJgv5v5a zcZB0MM+97UMD@3h==94G`%LO#s%1SC8P-G9cSnp@)I+t}_3)*2JsfXW4--1o!v@!S z@NZEM(dv58C10nHj!3@ii1*o!nD6U|hn9}mbh9p8mes}YfVy}fbur7XE*y&LAm~&b zJXu-?Uj6F8@<(kPn^zl?KG%Y!M=f+tstM=98c6W1fd|K{10AZvX`chnUw9Ymuo@qz zi`<3!&zm)N$lgGl_FUdI`e=h%#GSTKTBCYvE9j2p-peYk?P;T;fOje8&Qsza_tOKp zwp|}eovE9qaII^K#~Kqf&gWW>Yx02AM!01_T5JfFg&~G>PwZ?113b**HvoKD-C(W2dJ+zPHs!ZgqW}(bb3bdp*p*p@->P^R8|RF=euvbrYKI$f3TtFFk#vdeNoLguH%zT$ZjmE_Z4lmqpW#iPOiU;?wu2biI2-*6lbVpPL+!YU2*e@t23> zTL5ied^#xAE+3S#?FZ$r%R$*N`GA}(%#laua%4eBj!dnSBNwc4WG*SZV!zaXzF&4< z+b^Sv_REwSIbu2_M^;?Q;aV_9_EqIb=F=RB-;*QrHs#2f_#EO0=E#WR{jz`Gelef8 zU(USUCue>4$(Ur?&&k^(m417qG;g;w9=BWC|K265XYGTwPy8$;gOJm2)GSz33jLX_6J&ZO=hlmYgWw1de_^hW*k9E@E{8};ownk>w zTO*Hq(FS+7Rjk`e3H4YZlgBNWX2Y|^#w|-Sxz1SoIfLIgU0!}mlYDiWbZnI>L0&0R zbAnbX&nL@}hRNcao+JUPB$*wvRJ>`+yr?`;lGi4R|F}dcteYrBTWRzB1MSFlj+f%? zv?bSLi7YRP6YJzSnbR*$3LC^p&sXatb>v#fGL4gJ<*_p0Zmcv~6Dtcx$4Zxmu`)s( zD~@ksWKnL66wZ#3@vUOS_pe6m^EF~a`}IA8HR94uBi(6dqR)Y7+Fgs57B*CxRHYhprNH_Ft4NXiu9F6QU(+d9>)~MazRy zy0ld>d!R<%P0&d0FpZe>(8#ws8d=s%BTCx6yJoDBR)x`WxH4Mm)0TzXK8;+tt>M0k zMkZa*aO~E|7_OE6z0-)-AB`0J*2p3K7}1!=NPJk8Q)JM>qlv%#GQ63xXv=S(8&B>w5L%VO)U9nxpg^Oj?(^O?+vuGkrXYz=S9nW z+AP{RBU*mYURPY>XnAfEE!9k-WtC2}{CgfHlaFw{ax98>uA}5wMUJ^uerOUybbUPM3be zyfk7xQ6teYY?nhC`SgW$JxpVyvO$cDs23x}^<%`_Ek;tjW5lR4agBS$$nQZhvZsHH zOz25>0Ke(f7*Q>Zk#@^tWY>SRB}RH2CKmGT82R-sMsnB(T6`sDo?WagHHzh3F|J8p z#K?nFF=F|EY0{o+8rQDh*l$v3Gp0d!thCOGmBA^o@|x~(_NfKQv9e}CtZZP|s0p#M zc^>=Myu}hqyM)PY7E6!`>0E@^2SmvIkc8L;#Kc%2 zN4y!|KUSiNm5fy8ofj*L9&xg=FisqAFOjO^c;eb5N@Z!1tQ(jj**R%a*K?WV-pP{l zZY#w=vsTjQZj?OBt+HtR4r%^%w*;-+FU>z6lqEfm66@}SJl8)hx;Q5ei5KL^$;*=P z;hN|t-joN8?ufGFo`m}n8!z~gtd4jhf7(BlGvUuX&lj@++BZ`$mqmc`J^= z@8nP9d+DA2L0(0Cl&q)%dA_bt9_D?LKMr3+JF`gI-{YQ(Q?Vqae3w|85~&E~UD1s{ zrPGlzS@*15;&UsA1yd>a?^jBP(n{jY5~rwPm1wF}Nl;;>Y|G^s zu~o!qt&)Ym8SeE<&dvNKe;@vm%4xr)pur!pefmc%LjTGc-hVB6^j8|z{wLur|H;pU ze{w}f2ho8#2>hml$r@d3Yomwa0zHgfr;oS2dCvDsfuVN|uztNEz6>&gV?W;OGBtq_ z?;*x>54sa|Ay+;!#RXgHJrAH>%>px&@?3ZtbIkM%UyEn+UHE6fi@C>p)B+Ja#~94>i~)JvC!TDHI?pT-twBYrG7z#{4vE;>ueihx02t((r-SFzIfMiq6(((9IfGds@S6q&1pP zu|{l+HQJK`;;qp?!y1RVH~nF&HA3^P(f6x0b`YEWsIv`>`q@CU*ak1R+o0i98&p2F zLE3v8OnYYouZK3Uz07-K$82yr)doJ%Hki#j&Sl;$TygL zz2@7%E}ni#%zwEJCatu=xJ@?jXBmC@{2{>xx`{UMPPf6RWE<*k+FunrcMI1J zP)e}qOE+9DVRv_TVGDM5Cn~6j@vh(d|FEB#GqWeooH?~;&swv2&NGZT*2Z`}${3E6 z>FnzU=hzy?7}d)dPdEl!J&f^% zx(TQe@p1eLBcQcOdYqq;`FxMFd8RJ7=V?#>k;= z)CA6ziN@$o89`Y*&KO0MlJUl9#$z|;yR*z9o=@!&oKL(?hla5Y=AGx<`OLW$O#Ml` zj>)Xkm32O0cyY2Z8jR*O^y4^iE+tT}h}U}pC4_aKnaO#sggy=zyxSrYw!#*{_l1V1G`P&4i*O{W5+6)snnq$XP3mj}lPR%J+n0~qm$_F-s z=B_pTXSYDaXB!NSYlXCbw&<4M27TPx!F_vsa=3Lwg>NUs$9Bdwbr+1z>53VPyFs^) z9ag-xL$Jyo(I@Q@Y~cWt$qtwp=YV#J4z!gx;6RoGV)i>=WQ7B=`gh0NGu@##*AX+j zJJCX~VY$i$xtXq5y2y?G{5{a2fjbN;-LdC(PxRg9fd-mhsEYH%u@r$rE|948 zg5}xXFx2tJ{z(dK+2Mm-pNOZ?NeTbZ{&=JvfXBrHQRDX@>~J4~>C=W{K;$qKZY37r zsS)ULa3uGZjKaX2(I{7sLBY(i;1@p5b{UVQ9Vg(C!$gc7ItkHXzOZ>~kJ0PxF(B3+ zl?x|h^~%Zc&6$j*F_Y0^!emUbpNzehlX3C43Kg$tW4x)tjA9kG99Lo3F%=SNcT7E{ z!o!;mJU+kno@;dgH`Cjvh&BO z&}o_q$Ng2fG*5+R!79{ZJI5EPU>>2u>%}S_)f&`DqmE* zn~b3W)8O)Q2147-#_v)7h#-b*Q?5zc_6R}v*!eiuYzFLS&A|7)GjPCgCLH2sqHlI2 z#tw|a?H^J2l0-aLlNdDH9fPKWVlno9EK+C3;r919{G6`Fvv+E=jE~0?lLY+HCP3RN z5m~DeiRF@nq=Y0iHcCdMHW^>_Q}AO^3Oc+^!MGm87>Xfg)%8@AwNFF6`Dti#It|g) z({aZ$9X0*aVV#qX=exNMcrP7CbTjbPHUmHVWZ=Ura@iJSKzlR;^&Vz$E(Bn+ZvX

    DZ^CCJ?8(Xw~uMd7*{YXoJJi=xko0RDl#!; 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≫|sT&>}AW9B*hCdU%X3FYsh**(^ie*Ibg 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

    hhK#SoFLD-_3hn`(tF;I3J~Es1wCZpV zt7i7Ka^HdT#bH+cCS92PDufxpsfa#T;1LUt8>IiDTe>bhrz3s<-S<#Flz19AUy$(| z88PV78vJl%ok53=gr7M-+zehY=_b&(!q)u#IPXMupFvhl9B);vIaZzJT(ZShtHyG6 z`ePIPHe>fX&L(GAwIA5Tndg%}oI9e29iRNJYE}6NtDNw2ek-~-aekV`D(}D4Wg6?a zkIb9*IYYV1H=M3;CbEw?bwBN56YHr(v=iD!m(i@3`Y|UnhuvrGlqZz)DCVY`wD}8Z z^cVU-Jz(B%_BaL$-*}{N5Pw2{=}5dhdD*`+F3@L6Wab=*{x&a{r(wa$T{M{ge#6R4;|up8@P*>|F{3z2L3kETQ|_}SF>kZLVu+!Y9af~ zILAJYC~uC8Q@hDq$^L~fZ4L4%kB8ob zey`wv4&F27dFsvz_8GumaIddoZegyuPnd53dktiJ#B#2%n7IR}JBM+2Ci=%PW-VfW zvy^!h-Vf1r+h%k_moeKoOE`(%jH~MzKO>nV>Y&eg#_s-i{@XtdWt=Z}nz@hpVfkMC zi~Ke4DT(e|_AxJG+t3~OkZ?bIkD=2z?60?;u@PAGP-3z*SPuY zfzdH}GU7E)LGzQk$d4Q+?Ang+&O>t@IhW97E4uBZtOt3aA!9!>+|cRDUQ%WR|1f?SfDHq%kyGbX+bLMcFIqod;8Dae^oXKP3^K;lq-p*^B zdC{JJ-bA;DtT%3R?)QZ@_?9-5%3Mu2@fURhJWTOaL?-5(EbIx>akiO(wI*Qz>4(g3 zM%JC1-I*(x=h_gKBhSj*myfWXkv$9LRS0{6Uln+k#@dTgu}qy56(i;>%prC&XPQQ%MQF8lRoP`17hb36IF7(bhm zA58up2R~>7(f2j9k>CYU9!tKtFnf3M{DDpAmj>-&^6ivQrOcs!4S4!dUW2lY@M)Hd zbp$-_k>^SILhvdPKTiAz`SS_;kRCwT7+&3pe}l)^BGeT!J`g_wpIP8m!w(K!N0J{; zd2e7mWgpOe5qT+u6G(rA*D~TEtZjZke-@cJ$omHTBE6qrwU)K9>o-9lVGKV$Uqn7I@?a9JFPCQt0N8mlvE=c(tPLs*+a+o`0yDDzv)_)b%sk z+D*!$fVu3=9#M9b_!!Egp&JG7#qg_(oG0)bg+6mi!n-W}s66K=)z}+WXMNO&w$~n? z_29f^2>u*_-=?Aa1bi@sHQ@sK+bq^(^Qo7))c17u(KCq8W=~1_4{$aHzs|v5vG{W# zbpw1MzYj2hx%=;8`pQz;(+b+!O8NtN1D0^Eu?id4kY2`~k@S4lYx$A)#eqv%o=x=U zJ+#@)tOIvb-#chayU5$Z-gG1FV;jDMe&z6D*< zb{P3t_A+j;zSwY(HU;E9#xtZ7_z3Vg$6DkX>*zbw+g0((g&xg-c&Ne3gxBk5}kbX!#=MGp7 z)~QC;gMX;kFWCH!Hi|z>egM8P&SZc$vUfl?q%6;$kz0s$ZR$j;W?bO@SSB0ej7dofv+I747`g`$Sg_oWU_0s

    kmVW`o>ZKF^196R2 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 literal 0 HcmV?d00001 From 5b1735cf90e036802b5f9c719f596f0008bf37a5 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 13 Jul 2022 01:00:23 +0000 Subject: [PATCH 034/489] update test suites with new test dataset --- pytest.ini | 6 + tests/conftest.py | 146 ++++++++++-------- tests/dj_pipeline/test_acquisition.py | 44 ++++++ tests/dj_pipeline/test_ingestion.py | 47 ------ .../test_pipeline_instantiation.py | 12 +- tests/dj_pipeline/test_qc.py | 9 ++ tests/dj_pipeline/test_tracking.py | 71 +++++++++ 7 files changed, 222 insertions(+), 113 deletions(-) create mode 100644 pytest.ini create mode 100644 tests/dj_pipeline/test_acquisition.py delete mode 100644 tests/dj_pipeline/test_ingestion.py create mode 100644 tests/dj_pipeline/test_qc.py create mode 100644 tests/dj_pipeline/test_tracking.py diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..c6b590af --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +filterwarnings = + error + ignore::UserWarning + +addopts=-s \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 8b651fd9..1a1b21f6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,32 +9,59 @@ # pytest -m """ +import os import pathlib import datajoint as dj import pytest -_tear_down = True +_tear_down = False # always set to True since most fixtures are session-scoped _populate_settings = {"suppress_errors": True} -@pytest.fixture(autouse=True, scope="session") +def data_dir(): + """Returns test data directory + + Returns: + pathlib.Path: directory path + """ + return os.path.join(os.path.dirname(os.path.realpath(__file__)), "data") + + +@pytest.fixture(autouse=True, scope="session") def test_params(): - - return { - "start_ts": "2022-05-24 08:29:42", - "end_ts": "2022-05-24 15:59:00", - "experiment_name": "exp0.2-r0", - "raw_dir": "aeon/data/raw/AEON2/experiment0.2", - "qc_dir": "aeon/data/qc/AEON2/experiment0.2", - "subject_count": 5, - "epoch_count": 1, - "chunk_count": 9, - "experiment_log_message_count": 1, - "subject_enter_exit_count": 1, - "subject_weight_time_count": 1 - } - + + return { + "start_ts": "2022-06-22 08:51:10", + "end_ts": "2022-06-22 14:00:00", + "experiment_name": "exp0.2-r0", + "raw_dir": "aeon/data/raw/AEON2/experiment0.2", + "qc_dir": "aeon/data/qc/AEON2/experiment0.2", + "test_dir": data_dir(), + "subject_count": 5, + "epoch_count": 1, + "chunk_count": 7, + "experiment_log_message_count": 0, + "subject_enter_exit_count": 0, + "subject_weight_time_count": 0, + "camera_qc_count": 40, + "camera_tracking_object_count": 5, + } + + +@pytest.fixture(autouse=True, scope="session") +def dj_config(): + """If dj_local_config exists, load""" + dj_config_fp = pathlib.Path("dj_local_conf.json") + assert dj_config_fp.exists() + dj.config.load(dj_config_fp) + dj.config["safemode"] = False + assert "custom" in dj.config + dj.config["custom"][ + "database.prefix" + ] = f"u_{dj.config['database.user']}_testsuite_" + return + def load_pipeline(): from aeon.dj_pipeline import ( @@ -57,57 +84,38 @@ def load_pipeline(): "report": report, } -def drop_schema(): - _pipeline = load_pipeline() - - _pipeline['report'].schema.drop() - _pipeline['analysis'].schema.drop() - _pipeline['tracking'].schema.drop() - _pipeline['qc'].schema.drop() - _pipeline['acquisition'].schema.drop() - _pipeline['subject'].schema.drop() - _pipeline['lab'].schema.drop() - +def drop_schema(): -@pytest.fixture(autouse=True, scope="session") -def dj_config(): - """If dj_local_config exists, load""" - dj_config_fp = pathlib.Path("dj_local_conf.json") - assert dj_config_fp.exists() - dj.config.load(dj_config_fp) - dj.config["safemode"] = False - assert "custom" in dj.config - dj.config["custom"][ - "database.prefix" - ] = f"u_{dj.config['database.user']}_testsuite_" - return + _pipeline = load_pipeline() + _pipeline["report"].schema.drop() + _pipeline["analysis"].schema.drop() + _pipeline["tracking"].schema.drop() + _pipeline["qc"].schema.drop() + _pipeline["acquisition"].schema.drop() + _pipeline["subject"].schema.drop() + _pipeline["lab"].schema.drop() + print("\n\nAll schemas dropped") @pytest.fixture(autouse=True, scope="session") def pipeline(dj_config): - + _pipeline = load_pipeline() - - if len(_pipeline['acquisition'].Experiment()): - drop_schema() - _pipeline = load_pipeline() - + yield _pipeline if _tear_down: - drop_schema() - @pytest.fixture(autouse=True, scope="session") -def exp_creation(test_params, pipeline): +def experiment_creation(test_params, pipeline): from aeon.dj_pipeline.populate import create_experiment_02 create_experiment_02.main() - + acquisition = pipeline["acquisition"] experiment_name = acquisition.Experiment.fetch1("experiment_name") @@ -128,34 +136,50 @@ def exp_creation(test_params, pipeline): "directory_path": test_params["qc_dir"], } ) - + return -@pytest.fixture -def epoch_chunk_ingestion(test_params, pipeline): +@pytest.fixture(scope="session") +def epoch_chunk_ingestion(test_params, pipeline, experiment_creation): acquisition = pipeline["acquisition"] - + test_params["experiment_name"] - + acquisition.Epoch.ingest_epochs( experiment_name=test_params["experiment_name"], - start=test_params["start_ts"], end=test_params["end_ts"] + start=test_params["start_ts"], + end=test_params["end_ts"], ) - acquisition.Chunk.ingest_chunks( - experiment_name=test_params["experiment_name"] - ) + acquisition.Chunk.ingest_chunks(experiment_name=test_params["experiment_name"]) return -@pytest.fixture +@pytest.fixture(scope="session") def experimentlog_ingestion(pipeline): acquisition = pipeline["acquisition"] - + if not len(acquisition.Chunk()): + raise Exception("Chunk table is empty!") acquisition.ExperimentLog.populate(**_populate_settings) acquisition.SubjectEnterExit.populate(**_populate_settings) acquisition.SubjectWeight.populate(**_populate_settings) return + + +@pytest.fixture(scope="session") +def camera_qc_ingestion(pipeline, epoch_chunk_ingestion): + qc = pipeline["qc"] + qc.CameraQC.populate() + + return + + +@pytest.fixture(scope="session") +def camera_tracking_ingestion(pipeline, camera_qc_ingestion): + tracking = pipeline["tracking"] + tracking.CameraTracking.populate(display_progress=True) + + return diff --git a/tests/dj_pipeline/test_acquisition.py b/tests/dj_pipeline/test_acquisition.py new file mode 100644 index 00000000..8e5eec37 --- /dev/null +++ b/tests/dj_pipeline/test_acquisition.py @@ -0,0 +1,44 @@ +from pytest import mark + + +@mark.ingestion +def test_epoch_chunk_ingestion(test_params, pipeline, epoch_chunk_ingestion): + acquisition = pipeline["acquisition"] + + assert ( + len(acquisition.Epoch & {"experiment_name": test_params["experiment_name"]}) + == test_params["epoch_count"] + ) + assert ( + len(acquisition.Chunk & {"experiment_name": test_params["experiment_name"]}) + == test_params["chunk_count"] + ) + + +@mark.ingestion +def test_experimentlog_ingestion( + test_params, pipeline, epoch_chunk_ingestion, experimentlog_ingestion +): + acquisition = pipeline["acquisition"] + + assert ( + len( + acquisition.ExperimentLog.Message + & {"experiment_name": test_params["experiment_name"]} + ) + == test_params["experiment_log_message_count"] + ) + assert ( + len( + acquisition.SubjectEnterExit.Time + & {"experiment_name": test_params["experiment_name"]} + ) + == test_params["subject_enter_exit_count"] + ) + assert ( + len( + acquisition.SubjectWeight.WeightTime + & {"experiment_name": test_params["experiment_name"]} + ) + == test_params["subject_weight_time_count"] + ) diff --git a/tests/dj_pipeline/test_ingestion.py b/tests/dj_pipeline/test_ingestion.py deleted file mode 100644 index 79358081..00000000 --- a/tests/dj_pipeline/test_ingestion.py +++ /dev/null @@ -1,47 +0,0 @@ -from . import ( - dj_config, - pipeline, - new_experiment, - test_variables, - epoch_chunk_ingestion, - experimentlog_ingestion, -) - - -def test_epoch_chunk_ingestion(epoch_chunk_ingestion, test_variables, pipeline): - acquisition = pipeline["acquisition"] - - assert ( - len(acquisition.Epoch & {"experiment_name": test_variables["experiment_name"]}) - == test_variables["epoch_count"] - ) - assert ( - len(acquisition.Chunk & {"experiment_name": test_variables["experiment_name"]}) - == test_variables["chunk_count"] - ) - - -def test_experimentlog_ingestion(experimentlog_ingestion, test_variables, pipeline): - acquisition = pipeline["acquisition"] - - assert ( - len( - acquisition.ExperimentLog.Message - & {"experiment_name": test_variables["experiment_name"]} - ) - == test_variables["experiment_log_message_count"] - ) - assert ( - len( - acquisition.SubjectEnterExit.Time - & {"experiment_name": test_variables["experiment_name"]} - ) - == test_variables["subject_enter_exit_count"] - ) - assert ( - len( - acquisition.SubjectWeight.WeightTime - & {"experiment_name": test_variables["experiment_name"]} - ) - == test_variables["subject_weight_time_count"] - ) diff --git a/tests/dj_pipeline/test_pipeline_instantiation.py b/tests/dj_pipeline/test_pipeline_instantiation.py index 48652ab3..bdb75b62 100644 --- a/tests/dj_pipeline/test_pipeline_instantiation.py +++ b/tests/dj_pipeline/test_pipeline_instantiation.py @@ -3,13 +3,15 @@ @mark.instantiation def test_pipeline_instantiation(pipeline): - acquisition = pipeline["acquisition"] - report = pipeline["report"] - assert hasattr(acquisition, "FoodPatchEvent") - assert hasattr(report, "InArenaSummaryPlot") + assert hasattr(pipeline["acquisition"], "FoodPatchEvent") + assert hasattr(pipeline["lab"], "Arena") + assert hasattr(pipeline["qc"], "CameraQC") + assert hasattr(pipeline["report"], "InArenaSummaryPlot") + assert hasattr(pipeline["subject"], "Subject") + assert hasattr(pipeline["tracking"], "CameraTracking") + - @mark.instantiation def test_exp_creation(pipeline, test_params): acquisition = pipeline["acquisition"] diff --git a/tests/dj_pipeline/test_qc.py b/tests/dj_pipeline/test_qc.py new file mode 100644 index 00000000..bfe248fc --- /dev/null +++ b/tests/dj_pipeline/test_qc.py @@ -0,0 +1,9 @@ +from pytest import mark + + +@mark.qc +def test_camera_qc_ingestion(test_params, pipeline, camera_qc_ingestion): + + qc = pipeline["qc"] + + assert len(qc.CameraQC()) == test_params["camera_qc_count"] diff --git a/tests/dj_pipeline/test_tracking.py b/tests/dj_pipeline/test_tracking.py new file mode 100644 index 00000000..e98c0ce2 --- /dev/null +++ b/tests/dj_pipeline/test_tracking.py @@ -0,0 +1,71 @@ +import datetime +import pathlib + +import numpy as np +from pytest import mark + +index = 0 +column_name = "position_x" # data column to run test on +file_name = "exp0.2-r0-20220524090000-21053810-20220524082942-0-0.npy" # test file to be saved with get_test_data + + +def save_data_camera(pipeline, test_params): + """save test dataset fetched from tracking.CameraTracking.Object""" + + tracking = pipeline["tracking"] + + key = tracking.CameraTracking.Object().fetch("KEY")[index] + file_name = ( + "-".join( + [ + v.strftime("%Y%m%d%H%M%S") + if isinstance(v, datetime.datetime) + else str(v) + for v in key.values() + ] + ) + + ".npy" + ) + + data = (tracking.CameraTracking.Object() & key).fetch(column_name)[0] + test_file = test_params["test_dir"] + "/" + file_name + np.save(test_file, data) + + return test_file + + +@mark.ingestion +@mark.tracking +def test_camera_tracking_ingestion(test_params, pipeline, camera_tracking_ingestion): + + tracking = pipeline["tracking"] + + assert ( + len(tracking.CameraTracking.Object()) + == test_params["camera_tracking_object_count"] + ) + + key = tracking.CameraTracking.Object().fetch("KEY")[index] + file_name = ( + "-".join( + [ + v.strftime("%Y%m%d%H%M%S") + if isinstance(v, datetime.datetime) + else str(v) + for v in key.values() + ] + ) + + ".npy" + ) + + test_file = pathlib.Path(test_params["test_dir"] + "/" + file_name) + assert test_file.exists() + + print(f"\nTesting {file_name}") + + data = np.load(test_file) + assert np.allclose( + data, + (tracking.CameraTracking.Object() & key).fetch(column_name)[0], + equal_nan=True, + ) From 9c157dbd9be98b585dda5590df70766540d5a2b2 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 13 Jul 2022 01:10:20 +0000 Subject: [PATCH 035/489] move pytest.ini --- tests/pytest.ini | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 tests/pytest.ini diff --git a/tests/pytest.ini b/tests/pytest.ini deleted file mode 100644 index 3148a133..00000000 --- a/tests/pytest.ini +++ /dev/null @@ -1,4 +0,0 @@ -[pytest] -filterwarnings = - error - ignore::UserWarning \ No newline at end of file From cc2199194846a4d1130eb5201052b46014998280 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 13 Jul 2022 01:17:46 +0000 Subject: [PATCH 036/489] change fixture name to experiment_creation --- tests/dj_pipeline/test_pipeline_instantiation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/dj_pipeline/test_pipeline_instantiation.py b/tests/dj_pipeline/test_pipeline_instantiation.py index 27732d22..30377d95 100644 --- a/tests/dj_pipeline/test_pipeline_instantiation.py +++ b/tests/dj_pipeline/test_pipeline_instantiation.py @@ -13,7 +13,7 @@ def test_pipeline_instantiation(pipeline): @mark.instantiation -def test_exp_creation(test_params, pipeline, exp_creation): +def test_experiment_creation(test_params, pipeline, experiment_creation): acquisition = pipeline["acquisition"] experiment_name = test_params["experiment_name"] From 96e1ca301ea3ed7f85f3ee9dd3ac9d2f8ed9f073 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 13 Jul 2022 01:22:15 +0000 Subject: [PATCH 037/489] update populate settings --- tests/conftest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 2f25fa17..949710e5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -173,7 +173,7 @@ def experimentlog_ingestion(pipeline): @pytest.fixture(scope="session") def camera_qc_ingestion(pipeline, epoch_chunk_ingestion): qc = pipeline["qc"] - qc.CameraQC.populate() + qc.CameraQC.populate(**_populate_settings) return @@ -181,6 +181,6 @@ def camera_qc_ingestion(pipeline, epoch_chunk_ingestion): @pytest.fixture(scope="session") def camera_tracking_ingestion(pipeline, camera_qc_ingestion): tracking = pipeline["tracking"] - tracking.CameraTracking.populate(display_progress=True) + tracking.CameraTracking.populate(**_populate_settings) return From f8412af49f457e5f66ef1fc0c30139c074a97f60 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 13 Jul 2022 01:31:33 +0000 Subject: [PATCH 038/489] fix typos --- tests/conftest.py | 7 +++---- tests/dj_pipeline/test_tracking.py | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 949710e5..4f4e1a05 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,10 +20,8 @@ def data_dir(): - """Returns test data directory - - Returns: - pathlib.Path: directory path + """ + Returns test data directory """ return os.path.join(os.path.dirname(os.path.realpath(__file__)), "data") @@ -97,6 +95,7 @@ def drop_schema(): _pipeline["acquisition"].schema.drop() _pipeline["subject"].schema.drop() _pipeline["lab"].schema.drop() + print("\n\nAll schemas dropped") diff --git a/tests/dj_pipeline/test_tracking.py b/tests/dj_pipeline/test_tracking.py index e98c0ce2..5920bfd8 100644 --- a/tests/dj_pipeline/test_tracking.py +++ b/tests/dj_pipeline/test_tracking.py @@ -6,10 +6,10 @@ index = 0 column_name = "position_x" # data column to run test on -file_name = "exp0.2-r0-20220524090000-21053810-20220524082942-0-0.npy" # test file to be saved with get_test_data +file_name = "exp0.2-r0-20220524090000-21053810-20220524082942-0-0.npy" # test file to be saved with save_test_data -def save_data_camera(pipeline, test_params): +def save_test_data(pipeline, test_params): """save test dataset fetched from tracking.CameraTracking.Object""" tracking = pipeline["tracking"] From 01fa50a5d17deb8dcf37faba700c1bf7f5eccfe0 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 15 Jul 2022 17:05:00 -0500 Subject: [PATCH 039/489] Create visit_analysis.py --- aeon/dj_pipeline/analysis/visit_analysis.py | 248 ++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 aeon/dj_pipeline/analysis/visit_analysis.py diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py new file mode 100644 index 00000000..c1322d78 --- /dev/null +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -0,0 +1,248 @@ +import datajoint as dj +import pandas as pd +import numpy as np +import datetime + +from .. import lab, acquisition, tracking, qc +from .. import get_schema_name, dict_to_uuid +from .visit import Visit, VisitEnd + +schema = dj.schema(get_schema_name("analysis")) + + +# ---------- Position Filtering Method ------------------ + + +@schema +class PositionFilteringMethod(dj.Lookup): + definition = """ + pos_filter_method: varchar(16) + --- + pos_filter_method_description: varchar(256) + """ + + contents = [("Kalman", "Online DeepLabCut as part of Bonsai workflow")] + + +@schema +class PositionFilteringParamSet(dj.Lookup): + definition = """ # Parameter set used in a particular PositionFilteringMethod + pos_filter_paramset_id: smallint + --- + -> PositionFilteringMethod + paramset_description: varchar(128) + param_set_hash: uuid + unique index (param_set_hash) + params: longblob # dictionary of all applicable parameters + """ + + +# ---------- Animal Position per Visit ------------------ + + +@schema +class VisitSubjectPosition(dj.Computed): + definition = """ # Animal position during a visit + -> Visit + -> acquisition.Chunk + """ + + class TimeSlice(dj.Part): + definition = """ + # A short time-slice (e.g. 10 minutes) of the recording of a given animal for a visit + -> master + time_slice_start: datetime(6) # datetime of the start of this time slice + --- + time_slice_end: datetime(6) # datetime of the end of this time slice + timestamps: longblob # (datetime) timestamps of the position data + position_x: longblob # (px) animal's x-position, in the arena's coordinate frame + position_y: longblob # (px) animal's y-position, in the arena's coordinate frame + position_z=null: longblob # (px) animal's z-position, in the arena's coordinate frame + """ + + _time_slice_duration = datetime.timedelta(hours=0, minutes=10, seconds=0) + + @property + def key_source(self): + """ + Chunk for all visits: + + visit_start during this Chunk - i.e. first chunk of the visit + + visit_end during this Chunk - i.e. last chunk of the visit + + chunk starts after visit_start and ends before visit_end (or NOW() - i.e. ongoing visits) + """ + return ( + Visit.join(VisitEnd, left=True).proj(visit_end="IFNULL(visit_end, NOW())") + * acquisition.Chunk + & acquisition.SubjectEnterExit + & [ + "visit_start BETWEEN chunk_start AND chunk_end", + "visit_end BETWEEN chunk_start AND chunk_end", + "chunk_start >= visit_start AND chunk_end <= visit_end", + ] + & 'experiment_name in ("exp0.2-r0")' + ) + + def make(self, key): + chunk_start, chunk_end = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end" + ) + + # -- Determine the time to start time_slicing in this chunk + if chunk_start < key["visit_start"] < chunk_end: + # For chunk containing the visit_start - i.e. first chunk of this session + start_time = key["visit_start"] + else: + # For chunks after the first chunk of this session + start_time = chunk_start + + # -- Determine the time to end time_slicing in this chunk + if VisitEnd & key: # finished visit + visit_end = (VisitEnd & key).fetch1("visit_end") + end_time = min(chunk_end, visit_end) + else: # ongoing visit + # get the enter/exit events in this chunk that are after the visit_start + next_enter_exit_events = ( + acquisition.SubjectEnterExit.Time * acquisition.EventType + & key + & f'enter_exit_time > "{key["visit_start"]}"' + ) + if not next_enter_exit_events: + # No enter/exit event: time_slices from this whole chunk + end_time = chunk_end + else: + next_event = next_enter_exit_events.fetch( + as_dict=True, order_by="enter_exit_time DESC", limit=1 + )[0] + if next_event["event_type"] == "SubjectEnteredArena": + raise ValueError(f"Bad Visit - never exited visit") + end_time = next_event["enter_exit_time"] + + # -- Retrieve position data + camera_name = acquisition._ref_device_mapping[key["experiment_name"]] + + assert ( + len(set((tracking.CameraTracking.Object & key).fetch("object_id"))) == 1 + ), "More than one unique object ID found - multiple animal/object mapping not yet supported" + + positiondata = tracking.CameraTracking.get_object_position( + experiment_name=key["experiment_name"], + camera_name=camera_name, + object_id=-1, + start=chunk_start, + end=chunk_end, + ) + + if not len(positiondata): + raise ValueError(f"No position data between {chunk_start} and {chunk_end}") + + timestamps = positiondata.index.to_pydatetime() + x = positiondata.position_x.values + y = positiondata.position_y.values + z = np.full_like(x, 0.0) + + chunk_time_slices = [] + time_slice_start = start_time + while time_slice_start < end_time: + time_slice_end = time_slice_start + min( + self._time_slice_duration, end_time - time_slice_start + ) + in_time_slice = np.logical_and( + timestamps >= time_slice_start, timestamps < time_slice_end + ) + chunk_time_slices.append( + { + **key, + "time_slice_start": time_slice_start, + "time_slice_end": time_slice_end, + "timestamps": timestamps[in_time_slice], + "position_x": x[in_time_slice], + "position_y": y[in_time_slice], + "position_z": z[in_time_slice], + } + ) + time_slice_start = time_slice_end + + self.insert1(key) + self.TimeSlice.insert(chunk_time_slices) + + @classmethod + def get_position(cls, visit_key): + """ + Given a key to a single Visit, return a Pandas DataFrame for the position data + of the subject for the specified Visit time period + """ + assert len(Visit & visit_key) == 1 + + start, end = ( + Visit.join(VisitEnd, left=True).proj(visit_end="IFNULL(visit_end, NOW())") + & visit_key + ).fetch1("visit_start", "visit_end") + + return tracking._get_position( + cls.TimeSlice, + object_attr="subject", + object_name=visit_key["subject"], + start_attr="time_slice_start", + end_attr="time_slice_end", + start=start, + end=end, + fetch_attrs=["timestamps", "position_x", "position_y"], + attrs_to_scale=["position_x", "position_y"], + scale_factor=tracking.pixel_scale, + ) + + +# -------------- Visit-level analysis --------------------- + + +@schema +class VisitTimeDistribution(dj.Computed): + definition = """ + -> Visit + visit_date: date + --- + time_fraction_in_corridor: float # fraction of time the animal spent in the corridor in this session + in_corridor: longblob # array of indices for when the animal is in the corridor (index into the position data) + time_fraction_visit: float # fraction of time the animal spent in the arena in this session + in_arena: longblob # array of indices for when the animal is in the arena (index into the position data) + """ + + class Nest(dj.Part): + definition = """ # Time spent in nest + -> master + -> lab.ArenaNest + --- + time_fraction_in_nest: float # fraction of time the animal spent in this nest in this session + in_nest: longblob # array of indices for when the animal is in this nest (index into the position data) + """ + + class FoodPatch(dj.Part): + definition = """ # Time spent in food patch + -> master + -> acquisition.ExperimentFoodPatch + --- + time_fraction_in_patch: float # fraction of time the animal spent on this patch in this session + in_patch: longblob # array of indices for when the animal is in this patch (index into the position data) + """ + + +@schema +class VisitSummary(dj.Computed): + definition = """ + -> Visit + visit_date: date + --- + total_distance_travelled: float # (m) total distance the animal travelled during this session + total_pellet_count: int # total pellet delivered (triggered) for all patches during this session + total_wheel_distance_travelled: float # total wheel travelled distance for all patches + change_in_weight: float # weight change before/after the session + """ + + class FoodPatch(dj.Part): + definition = """ + -> master + -> acquisition.ExperimentFoodPatch + --- + pellet_count: int # number of pellets being delivered (triggered) by this patch during this session + wheel_distance_travelled: float # wheel travelled distance during this session for this patch + """ From c4d69b6fedd1ee60d0f8c7e765d407283c4dd84c Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 18 Jul 2022 20:32:18 +0000 Subject: [PATCH 040/489] add timestamp restrction --- aeon/dj_pipeline/analysis/visit_analysis.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index c1322d78..88ace7e5 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -1,10 +1,10 @@ +import datetime + import datajoint as dj -import pandas as pd import numpy as np -import datetime +import pandas as pd -from .. import lab, acquisition, tracking, qc -from .. import get_schema_name, dict_to_uuid +from .. import acquisition, dict_to_uuid, get_schema_name, lab, qc, tracking from .visit import Visit, VisitEnd schema = dj.schema(get_schema_name("analysis")) @@ -80,6 +80,7 @@ def key_source(self): "chunk_start >= visit_start AND chunk_end <= visit_end", ] & 'experiment_name in ("exp0.2-r0")' + & "chunk_start < chunk_end" # in some chunks, end timestamp comes before start (timestamp error) ) def make(self, key): From 7ea544e971bfc5c2cf4e8b047396acaa66ad95e6 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 20 Jul 2022 03:54:08 +0000 Subject: [PATCH 041/489] added make functions --- aeon/dj_pipeline/analysis/visit_analysis.py | 368 +++++++++++++++++--- 1 file changed, 327 insertions(+), 41 deletions(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 88ace7e5..9cba8faa 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -1,4 +1,5 @@ import datetime +from datetime import time import datajoint as dj import numpy as np @@ -90,10 +91,10 @@ def make(self, key): # -- Determine the time to start time_slicing in this chunk if chunk_start < key["visit_start"] < chunk_end: - # For chunk containing the visit_start - i.e. first chunk of this session + # For chunk containing the visit_start - i.e. first chunk of this visit start_time = key["visit_start"] else: - # For chunks after the first chunk of this session + # For chunks after the first chunk of this visit start_time = chunk_start # -- Determine the time to end time_slicing in this chunk @@ -166,32 +167,6 @@ def make(self, key): self.insert1(key) self.TimeSlice.insert(chunk_time_slices) - @classmethod - def get_position(cls, visit_key): - """ - Given a key to a single Visit, return a Pandas DataFrame for the position data - of the subject for the specified Visit time period - """ - assert len(Visit & visit_key) == 1 - - start, end = ( - Visit.join(VisitEnd, left=True).proj(visit_end="IFNULL(visit_end, NOW())") - & visit_key - ).fetch1("visit_start", "visit_end") - - return tracking._get_position( - cls.TimeSlice, - object_attr="subject", - object_name=visit_key["subject"], - start_attr="time_slice_start", - end_attr="time_slice_end", - start=start, - end=end, - fetch_attrs=["timestamps", "position_x", "position_y"], - attrs_to_scale=["position_x", "position_y"], - scale_factor=tracking.pixel_scale, - ) - # -------------- Visit-level analysis --------------------- @@ -199,12 +174,13 @@ def get_position(cls, visit_key): @schema class VisitTimeDistribution(dj.Computed): definition = """ - -> Visit - visit_date: date + -> VisitSubjectPosition + visit_date: datetime(6) --- - time_fraction_in_corridor: float # fraction of time the animal spent in the corridor in this session + duration: float # total duration (in hours) + time_fraction_in_corridor: float # fraction of time the animal spent in the corridor in this visit in_corridor: longblob # array of indices for when the animal is in the corridor (index into the position data) - time_fraction_visit: float # fraction of time the animal spent in the arena in this session + time_fraction_in_arena: float # fraction of time the animal spent in the arena in this visit in_arena: longblob # array of indices for when the animal is in the arena (index into the position data) """ @@ -213,7 +189,7 @@ class Nest(dj.Part): -> master -> lab.ArenaNest --- - time_fraction_in_nest: float # fraction of time the animal spent in this nest in this session + time_fraction_in_nest: float # fraction of time the animal spent in this nest in this visit in_nest: longblob # array of indices for when the animal is in this nest (index into the position data) """ @@ -222,21 +198,186 @@ class FoodPatch(dj.Part): -> master -> acquisition.ExperimentFoodPatch --- - time_fraction_in_patch: float # fraction of time the animal spent on this patch in this session + time_fraction_in_patch: float # fraction of time the animal spent on this patch in this visit in_patch: longblob # array of indices for when the animal is in this patch (index into the position data) """ + # Work on finished visits + key_source = (VisitSubjectPosition & Visit) & ( + VisitEnd * VisitSubjectPosition.TimeSlice & "time_slice_end = visit_end" + ) + + def make(self, key): + + visit_start, visit_end = (VisitEnd & key).fetch1("visit_start", "visit_end") + + visit_dates = pd.date_range( + start=pd.Timestamp(visit_start.date()), end=pd.Timestamp(visit_end.date()) + ) + + for visit_date in visit_dates: + print(visit_date) + day_start = datetime.datetime.combine((visit_date).date(), time.min) + day_end = datetime.datetime.combine((visit_date).date(), time.max) + + # duration of the visit on the date + visit_duration = round( + (min(day_end, visit_end) - max(day_start, visit_start)) + / datetime.timedelta(hours=1), + 3, + ) + + # subject's position data in the time_slices per day + slice_keys = ( + VisitSubjectPosition.TimeSlice + & f'time_slice_start >= "{day_start}"' + & f'time_slice_start <= "{day_end}"' + ).fetch("KEY") + + fetch_attrs = ["timestamps", "position_x", "position_y"] + attrs_to_scale = ["position_x", "position_y"] + scale_factor = tracking.pixel_scale + + fetched_data = (VisitSubjectPosition.TimeSlice & slice_keys).fetch( + *fetch_attrs + ) + + timestamp_attr = next(attr for attr in fetch_attrs if "timestamps" in attr) + + # stack and structure in pandas DataFrame + position = pd.DataFrame( + { + k: np.hstack(v) * scale_factor + if k in attrs_to_scale + else np.hstack(v) + for k, v in zip(fetch_attrs, fetched_data) + } + ) + position.set_index(timestamp_attr, inplace=True) + + time_mask = np.logical_and( + position.index >= day_start, position.index < day_end + ) + position[time_mask] + position.rename( + columns={"position_x": "x", "position_y": "y"}, inplace=True + ) + + # in corridor + distance_from_center = tracking.compute_distance( + position[["x", "y"]], + (tracking.arena_center_x, tracking.arena_center_y), + ) + in_corridor = (distance_from_center < tracking.arena_outer_radius) & ( + distance_from_center > tracking.arena_inner_radius + ) + + in_arena = ~in_corridor + + # in nests - loop through all nests in this experiment + in_nest_times = [] + for nest_key in (lab.ArenaNest & key).fetch("KEY"): + in_nest = tracking.is_position_in_nest(position, nest_key) + in_nest_times.append( + { + **key, + **nest_key, + "time_fraction_in_nest": in_nest.mean(), + "in_nest": in_nest, + } + ) + in_arena = in_arena & ~in_nest + + # in food patches - loop through all in-use patches during this visit + + query = acquisition.ExperimentFoodPatch.join( + acquisition.ExperimentFoodPatch.RemovalTime, left=True + ) + + food_patch_keys = ( + query + & ( + VisitSubjectPosition + * acquisition.ExperimentFoodPatch.join( + acquisition.ExperimentFoodPatch.RemovalTime, left=True + ) + & key + & f'"{day_start}" >= food_patch_install_time' + & f'"{day_end}" < IFNULL(food_patch_remove_time, "2200-01-01")' + ).fetch("KEY") + ).fetch("KEY") + + in_food_patch_times = [] + + for food_patch_key in food_patch_keys: + # wheel data + food_patch_description = ( + acquisition.ExperimentFoodPatch & food_patch_key + ).fetch1("food_patch_description") + wheel_data = acquisition.FoodPatchWheel.get_wheel_data( + experiment_name=key["experiment_name"], + start=pd.Timestamp(day_start), + end=pd.Timestamp(day_end), + patch_name=food_patch_description, + using_aeon_io=True, + ) + + patch_position = ( + dj.U( + "food_patch_serial_number", + "food_patch_position_x", + "food_patch_position_y", + "food_patch_description", + ) + & acquisition.ExperimentFoodPatch + * acquisition.ExperimentFoodPatch.Position + & food_patch_key + ).fetch1("food_patch_position_x", "food_patch_position_y") + + in_patch = tracking.is_in_patch( + position, + patch_position, + wheel_data.distance_travelled, + patch_radius=0.2, + ) + + in_food_patch_times.append( + { + **key, + **food_patch_key, + "time_fraction_in_patch": in_patch.mean(), + "in_patch": in_patch.values, + } + ) + + in_arena = in_arena & ~in_patch + + self.insert1( + { + **key, + "visit_date": visit_date.date(), + "duration": visit_duration, + "time_fraction_in_corridor": in_corridor.mean(), + "in_corridor": in_corridor.values, + "time_fraction_in_arena": in_arena.mean(), + "in_arena": in_arena.values, + } + ) + self.Nest.insert(in_nest_times) + self.FoodPatch.insert(in_food_patch_times) + @schema class VisitSummary(dj.Computed): definition = """ - -> Visit - visit_date: date + -> VisitSubjectPosition + visit_date: datetime(6) --- - total_distance_travelled: float # (m) total distance the animal travelled during this session - total_pellet_count: int # total pellet delivered (triggered) for all patches during this session + duration: float # total duration (in hours) + total_distance_travelled: float # (m) total distance the animal travelled during this visit + total_pellet_count: int # total pellet delivered (triggered) for all patches during this visit total_wheel_distance_travelled: float # total wheel travelled distance for all patches - change_in_weight: float # weight change before/after the session + change_in_weight: float # weight change before/after the visit """ class FoodPatch(dj.Part): @@ -244,6 +385,151 @@ class FoodPatch(dj.Part): -> master -> acquisition.ExperimentFoodPatch --- - pellet_count: int # number of pellets being delivered (triggered) by this patch during this session - wheel_distance_travelled: float # wheel travelled distance during this session for this patch + pellet_count: int # number of pellets being delivered (triggered) by this patch during this visit + wheel_distance_travelled: float # wheel travelled distance during this visit for this patch """ + + # Work on finished visits + key_source = (VisitSubjectPosition & Visit) & ( + VisitEnd * VisitSubjectPosition.TimeSlice & "time_slice_end = visit_end" + ) + + def make(self, key): + + visit_start, visit_end = (VisitEnd & key).fetch1("visit_start", "visit_end") + + visit_dates = pd.date_range( + start=pd.Timestamp(visit_start.date()), end=pd.Timestamp(visit_end.date()) + ) + + for visit_date in visit_dates: + print(visit_date) + day_start = datetime.datetime.combine((visit_date).date(), time.min) + day_end = datetime.datetime.combine((visit_date).date(), time.max) + + # duration of the visit on the date + visit_duration = round( + (min(day_end, visit_end) - max(day_start, visit_start)) + / datetime.timedelta(hours=1), + 3, + ) + + # subject weights + weight_start = ( + VisitSubjectPosition.TimeSlice * acquisition.SubjectWeight.WeightTime + & f'weight_time = "{max(day_start, visit_start)}"' + ).fetch1("weight") + + weight_start = ( + acquisition.SubjectWeight.WeightTime + & f'weight_time = "{max(day_start, visit_start)}"' + ).fetch1("weight") + weight_end = ( + acquisition.SubjectWeight.WeightTime + & f'weight_time = "{min(day_end, visit_end)}"' + ).fetch1("weight") + + # subject's position data in the time_slices per day + slice_keys = ( + VisitSubjectPosition.TimeSlice + & f'time_slice_start >= "{day_start}"' + & f'time_slice_start <= "{day_end}"' + ).fetch("KEY") + + fetch_attrs = ["timestamps", "position_x", "position_y"] + attrs_to_scale = ["position_x", "position_y"] + scale_factor = tracking.pixel_scale + + fetched_data = (VisitSubjectPosition.TimeSlice & slice_keys).fetch( + *fetch_attrs + ) + + timestamp_attr = next(attr for attr in fetch_attrs if "timestamps" in attr) + + # stack and structure in pandas DataFrame + position = pd.DataFrame( + { + k: np.hstack(v) * scale_factor + if k in attrs_to_scale + else np.hstack(v) + for k, v in zip(fetch_attrs, fetched_data) + } + ) + position.set_index(timestamp_attr, inplace=True) + + time_mask = np.logical_and( + position.index >= day_start, position.index < day_end + ) + position[time_mask] + position.rename( + columns={"position_x": "x", "position_y": "y"}, inplace=True + ) + + position_diff = np.sqrt( + np.square(np.diff(position.x)) + np.square(np.diff(position.y)) + ) + total_distance_travelled = np.nancumsum(position_diff)[-1] + + # food patch data + food_patch_keys = ( + query + & ( + VisitSubjectPosition + * acquisition.ExperimentFoodPatch.join( + acquisition.ExperimentFoodPatch.RemovalTime, left=True + ) + & key + & f'"{day_start}" >= food_patch_install_time' + & f'"{day_end}" < IFNULL(food_patch_remove_time, "2200-01-01")' + ).fetch("KEY") + ).fetch("KEY") + + food_patch_statistics = [] + + for food_patch_key in food_patch_keys: + pellet_events = ( + acquisition.FoodPatchEvent * acquisition.EventType + & food_patch_key + & 'event_type = "TriggerPellet"' + & f'event_time BETWEEN "{day_start}" AND "{day_end}"' + ).fetch("event_time") + # wheel data + food_patch_description = ( + acquisition.ExperimentFoodPatch & food_patch_key + ).fetch1("food_patch_description") + wheel_data = acquisition.FoodPatchWheel.get_wheel_data( + experiment_name=key["experiment_name"], + start=pd.Timestamp(day_start), + end=pd.Timestamp(day_end), + patch_name=food_patch_description, + using_aeon_io=True, + ) + + food_patch_statistics.append( + { + **key, + **food_patch_key, + "pellet_count": len(pellet_events), + "wheel_distance_travelled": wheel_data.distance_travelled.values[ + -1 + ], + } + ) + + total_pellet_count = np.sum( + [p["pellet_count"] for p in food_patch_statistics] + ) + total_wheel_distance_travelled = np.sum( + [p["wheel_distance_travelled"] for p in food_patch_statistics] + ) + + self.insert1( + { + **key, + "total_pellet_count": total_pellet_count, + "total_wheel_distance_travelled": total_wheel_distance_travelled, + "change_in_weight": weight_end - weight_start, + "total_distance_travelled": total_distance_travelled, + } + ) + self.FoodPatch.insert(food_patch_statistics) From 9f7c2016f900f359c2ad204cdfa01886a84af125 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 20 Jul 2022 20:32:55 +0000 Subject: [PATCH 042/489] =?UTF-8?q?=F0=9F=8E=A8=20added=20make=20method=20?= =?UTF-8?q?for=20VisitTimeDistribution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aeon/dj_pipeline/analysis/visit_analysis.py | 92 +++++++++++---------- 1 file changed, 49 insertions(+), 43 deletions(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 9cba8faa..a44daf4e 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -8,7 +8,7 @@ from .. import acquisition, dict_to_uuid, get_schema_name, lab, qc, tracking from .visit import Visit, VisitEnd -schema = dj.schema(get_schema_name("analysis")) +schema = dj.schema("u_jaeronga_test") # ---------- Position Filtering Method ------------------ @@ -52,13 +52,13 @@ class TimeSlice(dj.Part): definition = """ # A short time-slice (e.g. 10 minutes) of the recording of a given animal for a visit -> master - time_slice_start: datetime(6) # datetime of the start of this time slice + time_slice_start: datetime(6) # datetime of the start of this time slice --- - time_slice_end: datetime(6) # datetime of the end of this time slice - timestamps: longblob # (datetime) timestamps of the position data - position_x: longblob # (px) animal's x-position, in the arena's coordinate frame - position_y: longblob # (px) animal's y-position, in the arena's coordinate frame - position_z=null: longblob # (px) animal's z-position, in the arena's coordinate frame + time_slice_end: datetime(6) # datetime of the end of this time slice + timestamps: longblob # (datetime) timestamps of the position data + position_x: longblob # (px) animal's x-position, in the arena's coordinate frame + position_y: longblob # (px) animal's y-position, in the arena's coordinate frame + position_z=null: longblob # (px) animal's z-position, in the arena's coordinate frame """ _time_slice_duration = datetime.timedelta(hours=0, minutes=10, seconds=0) @@ -174,13 +174,14 @@ def make(self, key): @schema class VisitTimeDistribution(dj.Computed): definition = """ - -> VisitSubjectPosition - visit_date: datetime(6) + -> Visit + visit_end : datetime(6) + visit_date: date + day_duration: float # total duration (in hours) --- - duration: float # total duration (in hours) time_fraction_in_corridor: float # fraction of time the animal spent in the corridor in this visit in_corridor: longblob # array of indices for when the animal is in the corridor (index into the position data) - time_fraction_in_arena: float # fraction of time the animal spent in the arena in this visit + time_fraction_in_arena: float # fraction of time the animal spent in the arena in this visit in_arena: longblob # array of indices for when the animal is in the arena (index into the position data) """ @@ -203,7 +204,7 @@ class FoodPatch(dj.Part): """ # Work on finished visits - key_source = (VisitSubjectPosition & Visit) & ( + key_source = Visit & ( VisitEnd * VisitSubjectPosition.TimeSlice & "time_slice_end = visit_end" ) @@ -216,14 +217,18 @@ def make(self, key): ) for visit_date in visit_dates: + print(visit_date) + day_start = datetime.datetime.combine((visit_date).date(), time.min) day_end = datetime.datetime.combine((visit_date).date(), time.max) + day_start = max(day_start, visit_start) + day_end = min(day_end, visit_end) + # duration of the visit on the date - visit_duration = round( - (min(day_end, visit_end) - max(day_start, visit_start)) - / datetime.timedelta(hours=1), + day_duration = round( + (day_end - day_start) / datetime.timedelta(hours=1), 3, ) @@ -282,6 +287,9 @@ def make(self, key): { **key, **nest_key, + "visit_end": visit_end, + "day_duration": day_duration, + "visit_date": visit_date.date(), "time_fraction_in_nest": in_nest.mean(), "in_nest": in_nest, } @@ -345,6 +353,9 @@ def make(self, key): { **key, **food_patch_key, + "visit_end": visit_end, + "day_duration": day_duration, + "visit_date": visit_date.date(), "time_fraction_in_patch": in_patch.mean(), "in_patch": in_patch.values, } @@ -355,8 +366,9 @@ def make(self, key): self.insert1( { **key, + "visit_end": visit_end, "visit_date": visit_date.date(), - "duration": visit_duration, + "day_duration": day_duration, "time_fraction_in_corridor": in_corridor.mean(), "in_corridor": in_corridor.values, "time_fraction_in_arena": in_arena.mean(), @@ -371,13 +383,13 @@ def make(self, key): class VisitSummary(dj.Computed): definition = """ -> VisitSubjectPosition - visit_date: datetime(6) + visit_date: date --- - duration: float # total duration (in hours) - total_distance_travelled: float # (m) total distance the animal travelled during this visit - total_pellet_count: int # total pellet delivered (triggered) for all patches during this visit - total_wheel_distance_travelled: float # total wheel travelled distance for all patches - change_in_weight: float # weight change before/after the visit + day_duration: float # total duration (in hours) + total_distance_travelled: float # (m) total distance the animal travelled during this visit + total_pellet_count: int # total pellet delivered (triggered) for all patches during this visit + total_wheel_distance_travelled: float # total wheel travelled distance for all patches + change_in_weight: float # weight change before/after the visit """ class FoodPatch(dj.Part): @@ -385,8 +397,8 @@ class FoodPatch(dj.Part): -> master -> acquisition.ExperimentFoodPatch --- - pellet_count: int # number of pellets being delivered (triggered) by this patch during this visit - wheel_distance_travelled: float # wheel travelled distance during this visit for this patch + pellet_count: int # number of pellets being delivered (triggered) by this patch during this visit + wheel_distance_travelled: float # wheel travelled distance during this visit for this patch """ # Work on finished visits @@ -403,32 +415,21 @@ def make(self, key): ) for visit_date in visit_dates: + print(visit_date) + day_start = datetime.datetime.combine((visit_date).date(), time.min) day_end = datetime.datetime.combine((visit_date).date(), time.max) + day_start = max(day_start, visit_start) + day_end = min(day_end, visit_end) + # duration of the visit on the date - visit_duration = round( - (min(day_end, visit_end) - max(day_start, visit_start)) - / datetime.timedelta(hours=1), + day_duration = round( + (day_end - day_start) / datetime.timedelta(hours=1), 3, ) - # subject weights - weight_start = ( - VisitSubjectPosition.TimeSlice * acquisition.SubjectWeight.WeightTime - & f'weight_time = "{max(day_start, visit_start)}"' - ).fetch1("weight") - - weight_start = ( - acquisition.SubjectWeight.WeightTime - & f'weight_time = "{max(day_start, visit_start)}"' - ).fetch1("weight") - weight_end = ( - acquisition.SubjectWeight.WeightTime - & f'weight_time = "{min(day_end, visit_end)}"' - ).fetch1("weight") - # subject's position data in the time_slices per day slice_keys = ( VisitSubjectPosition.TimeSlice @@ -470,7 +471,12 @@ def make(self, key): ) total_distance_travelled = np.nancumsum(position_diff)[-1] - # food patch data + # in food patches - loop through all in-use patches during this visit + + query = acquisition.ExperimentFoodPatch.join( + acquisition.ExperimentFoodPatch.RemovalTime, left=True + ) + food_patch_keys = ( query & ( From e491590aa8a6f3b9f689693b6d109dcd4df4ae54 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 20 Jul 2022 22:10:09 +0000 Subject: [PATCH 043/489] =?UTF-8?q?=F0=9F=8E=A8=20added=20a=20make=20metho?= =?UTF-8?q?d=20for=20VisitSummary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aeon/dj_pipeline/analysis/visit_analysis.py | 29 ++++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index a44daf4e..6586709c 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -288,8 +288,8 @@ def make(self, key): **key, **nest_key, "visit_end": visit_end, - "day_duration": day_duration, "visit_date": visit_date.date(), + "day_duration": day_duration, "time_fraction_in_nest": in_nest.mean(), "in_nest": in_nest, } @@ -354,6 +354,7 @@ def make(self, key): **key, **food_patch_key, "visit_end": visit_end, + "visit_date": visit_date.date(), "day_duration": day_duration, "visit_date": visit_date.date(), "time_fraction_in_patch": in_patch.mean(), @@ -382,14 +383,14 @@ def make(self, key): @schema class VisitSummary(dj.Computed): definition = """ - -> VisitSubjectPosition + -> Visit + visit_end : datetime(6) visit_date: date + day_duration: float # total duration (in hours) --- - day_duration: float # total duration (in hours) total_distance_travelled: float # (m) total distance the animal travelled during this visit total_pellet_count: int # total pellet delivered (triggered) for all patches during this visit total_wheel_distance_travelled: float # total wheel travelled distance for all patches - change_in_weight: float # weight change before/after the visit """ class FoodPatch(dj.Part): @@ -402,7 +403,7 @@ class FoodPatch(dj.Part): """ # Work on finished visits - key_source = (VisitSubjectPosition & Visit) & ( + key_source = Visit & ( VisitEnd * VisitSubjectPosition.TimeSlice & "time_slice_end = visit_end" ) @@ -430,6 +431,15 @@ def make(self, key): 3, ) + ## TODO + # # subject weights + # weight_start = ( + # acquisition.SubjectWeight.WeightTime & f'weight_time = "{day_start}"' + # ).fetch1("weight") + # weight_end = ( + # acquisition.SubjectWeight.WeightTime & f'weight_time = "{day_end}"' + # ).fetch1("weight") + # subject's position data in the time_slices per day slice_keys = ( VisitSubjectPosition.TimeSlice @@ -472,7 +482,6 @@ def make(self, key): total_distance_travelled = np.nancumsum(position_diff)[-1] # in food patches - loop through all in-use patches during this visit - query = acquisition.ExperimentFoodPatch.join( acquisition.ExperimentFoodPatch.RemovalTime, left=True ) @@ -515,6 +524,9 @@ def make(self, key): { **key, **food_patch_key, + "visit_end": visit_end, + "visit_date": visit_date.date(), + "day_duration": day_duration, "pellet_count": len(pellet_events), "wheel_distance_travelled": wheel_data.distance_travelled.values[ -1 @@ -532,9 +544,12 @@ def make(self, key): self.insert1( { **key, + "visit_end": visit_end, + "visit_date": visit_date.date(), + "day_duration": day_duration, "total_pellet_count": total_pellet_count, "total_wheel_distance_travelled": total_wheel_distance_travelled, - "change_in_weight": weight_end - weight_start, + # "change_in_weight": weight_end - weight_start, "total_distance_travelled": total_distance_travelled, } ) From 01aaf2d3760128ccac6a0fc1e51dd78301dfec90 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Thu, 21 Jul 2022 16:18:52 +0000 Subject: [PATCH 044/489] =?UTF-8?q?=F0=9F=90=9B=20object=20id=20could=20be?= =?UTF-8?q?=200=20or=20-1=20in=20exp0.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aeon/dj_pipeline/analysis/visit_analysis.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 6586709c..9b1f6b6a 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -126,10 +126,12 @@ def make(self, key): len(set((tracking.CameraTracking.Object & key).fetch("object_id"))) == 1 ), "More than one unique object ID found - multiple animal/object mapping not yet supported" + object_id = (tracking.CameraTracking.Object & key).fetch("object_id")[0] + positiondata = tracking.CameraTracking.get_object_position( experiment_name=key["experiment_name"], camera_name=camera_name, - object_id=-1, + object_id=object_id, start=chunk_start, end=chunk_end, ) From 2584ee77651e10564c0055cf66cfb6200b98f267 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 21 Jul 2022 16:15:02 -0500 Subject: [PATCH 045/489] storing numpy datetime array as longblob instead of list of py_datetime --- aeon/dj_pipeline/acquisition.py | 8 +- aeon/dj_pipeline/analysis/in_arena.py | 6 +- aeon/dj_pipeline/analysis/visit_analysis.py | 6 +- aeon/dj_pipeline/qc.py | 2 +- .../scripts/update_timestamps_longblob.py | 82 +++++++++++++++++++ aeon/dj_pipeline/tracking.py | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 8 files changed, 95 insertions(+), 15 deletions(-) create mode 100644 aeon/dj_pipeline/scripts/update_timestamps_longblob.py diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 4bf2519f..a61a6b81 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -781,12 +781,10 @@ def make(self, key): end=pd.Timestamp(chunk_end), ) - timestamps = wheel_data.index.to_pydatetime() - self.insert1( { **key, - "timestamps": timestamps, + "timestamps": wheel_data.index.values, "angle": wheel_data.angle.values, "intensity": wheel_data.intensity.values, } @@ -978,12 +976,10 @@ def make(self, key): f"No weight measurement found for {key} - this is unexpected" ) - timestamps = weight_data.index.to_pydatetime() - self.insert1( { **key, - "timestamps": timestamps, + "timestamps": weight_data.index.values, "weight": weight_data.value.values, "confidence": weight_data.stable.values.astype(float), } diff --git a/aeon/dj_pipeline/analysis/in_arena.py b/aeon/dj_pipeline/analysis/in_arena.py index 895f19ee..1fa3f472 100644 --- a/aeon/dj_pipeline/analysis/in_arena.py +++ b/aeon/dj_pipeline/analysis/in_arena.py @@ -232,7 +232,7 @@ def make(self, key): f"No position data between {time_slice_start} and {time_slice_end}" ) - timestamps = positiondata.index.to_pydatetime() + timestamps = positiondata.index.values x = positiondata.position_x.values y = positiondata.position_y.values z = np.full_like(x, 0.0) @@ -242,7 +242,7 @@ def make(self, key): position_diff = np.sqrt( np.square(np.diff(x)) + np.square(np.diff(y)) + np.square(np.diff(z)) ) - time_diff = [t.total_seconds() for t in np.diff(timestamps)] + time_diff = np.diff(timestamps) / np.timedelta64(1, "s") speed = position_diff / time_diff speed = np.hstack((speed[0], speed)) @@ -693,7 +693,7 @@ def make(self, key): ) if pellet_rate_timestamps is None: - pellet_rate_timestamps = pellet_rate.index.to_pydatetime() + pellet_rate_timestamps = pellet_rate.index.values rates[food_patch_key.pop("food_patch_description")] = pellet_rate.values diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index c1322d78..703176f2 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -124,10 +124,12 @@ def make(self, key): len(set((tracking.CameraTracking.Object & key).fetch("object_id"))) == 1 ), "More than one unique object ID found - multiple animal/object mapping not yet supported" + object_id = (tracking.CameraTracking.Object & key).fetch1("object_id") + positiondata = tracking.CameraTracking.get_object_position( experiment_name=key["experiment_name"], camera_name=camera_name, - object_id=-1, + object_id=object_id, start=chunk_start, end=chunk_end, ) @@ -135,7 +137,7 @@ def make(self, key): if not len(positiondata): raise ValueError(f"No position data between {chunk_start} and {chunk_end}") - timestamps = positiondata.index.to_pydatetime() + timestamps = positiondata.index.values x = positiondata.position_x.values y = positiondata.position_y.values z = np.full_like(x, 0.0) diff --git a/aeon/dj_pipeline/qc.py b/aeon/dj_pipeline/qc.py index 79b2184e..96813c53 100644 --- a/aeon/dj_pipeline/qc.py +++ b/aeon/dj_pipeline/qc.py @@ -103,7 +103,7 @@ def make(self, key): "max_harp_delta": deltas.time_delta.max().total_seconds(), "max_camera_delta": deltas.hw_timestamp_delta.max() / 1e9, # convert to seconds - "timestamps": videodata.index.to_pydatetime(), + "timestamps": videodata.index.values, "time_delta": deltas.time_delta.values / np.timedelta64(1, "s"), # convert to seconds "frame_delta": deltas.frame_delta.values, diff --git a/aeon/dj_pipeline/scripts/update_timestamps_longblob.py b/aeon/dj_pipeline/scripts/update_timestamps_longblob.py new file mode 100644 index 00000000..4e6f0809 --- /dev/null +++ b/aeon/dj_pipeline/scripts/update_timestamps_longblob.py @@ -0,0 +1,82 @@ +""" +July 2022 +Upgrade all timestamps longblob fields with datajoint 0.13.7 +""" +import datajoint as dj +from datetime import datetime +import numpy as np +from tqdm import tqdm + +assert dj.__version__ >= "0.13.7" + + +schema = dj.schema("u_thinh_aeonfix") + + +@schema +class TimestampFix(dj.Manual): + definition = """ + full_table_name: varchar(64) + key_hash: uuid # dj.hash.key_hash(key) + """ + + +schema_names = ( + "aeon_acquisition", + "aeon_tracking", + "aeon_qc", + "aeon_report", + "aeon_analysis", +) + + +def main(): + for schema_name in schema_names: + vm = dj.create_virtual_module(schema_name, schema_name) + table_names = [ + ".".join( + [ + dj.utils.to_camel_case(s) + for s in tbl_name.strip("`").split("__") + if s + ] + ) + for tbl_name in vm.schema.list_tables() + ] + for table_name in table_names: + table = get_table(vm, table_name) + print(f"\n---- {schema_name}.{table_name} ----\n") + for attr_name, attr in table.heading.attributes.items(): + if "timestamp" in attr_name and attr.type == "longblob": + for key in tqdm(table.fetch("KEY")): + fix_key = { + "full_table_name": table.full_table_name, + "key_hash": dj.hash.key_hash(key), + } + if TimestampFix & fix_key: + continue + ts = (table & key).fetch1(attr_name) + if not len(ts) or isinstance(ts[0], np.datetime64): + TimestampFix.insert1(fix_key) + continue + assert isinstance(ts[0], datetime) + with table.connection.transaction: + table.update1( + { + **key, + attr_name: np.array(ts).astype("datetime64[ns]"), + } + ) + TimestampFix.insert1(fix_key) + + +def get_table(schema_object, table_object_name): + if "." in table_object_name: + master_name, part_name = table_object_name.split(".") + return getattr(getattr(schema_object, master_name), part_name) + else: + return getattr(schema_object, table_object_name) + + +if __name__ == "__main__": + main() diff --git a/aeon/dj_pipeline/tracking.py b/aeon/dj_pipeline/tracking.py index 8f8066cb..78e44187 100644 --- a/aeon/dj_pipeline/tracking.py +++ b/aeon/dj_pipeline/tracking.py @@ -181,7 +181,7 @@ def make(self, key): { **key, "object_id": obj_id, - "timestamps": obj_position.index.to_pydatetime(), + "timestamps": obj_position.index.values, "position_x": obj_position.x.values, "position_y": obj_position.y.values, "area": obj_position.area.values, diff --git a/pyproject.toml b/pyproject.toml index b2aec0e2..798bf7d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ readme = "readme.md" dependencies = [ "bottleneck>=1.2.1,<2", "datajoint-utilities @ git+https://github.com/datajoint-company/datajoint-utilities", - "datajoint>=0.13.6", + "datajoint>=0.13.7", "dotmap", "fastparquet", "graphviz", diff --git a/requirements.txt b/requirements.txt index f5270e62..9eaad0f7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ black # (dev) blas>=1.1, <2 bottleneck>=1.2.1, <2 dotmap -datajoint>=0.13, <1 +datajoint>=0.13.7, <1 git+https://github.com/vathes/datajoint-utilities.git fastparquet flake8 # (dev) From d6109274ae6c7482f94a39e1a202800afb43e8c2 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 21 Jul 2022 16:17:59 -0500 Subject: [PATCH 046/489] Update device_stream.py --- aeon/dj_pipeline/device_stream.py | 273 ++++++++++++++++++++++++++++-- 1 file changed, 259 insertions(+), 14 deletions(-) diff --git a/aeon/dj_pipeline/device_stream.py b/aeon/dj_pipeline/device_stream.py index 65175e59..96910b12 100644 --- a/aeon/dj_pipeline/device_stream.py +++ b/aeon/dj_pipeline/device_stream.py @@ -1,14 +1,124 @@ import datajoint as dj import pandas as pd import numpy as np +import inspect +import re -from aeon.preprocess import api as aeon_api +import aeon +from aeon.io import api as io_api from . import acquisition from . import get_schema_name -schema = dj.schema(get_schema_name('device_stream')) +# schema = dj.schema(get_schema_name("device_stream")) +schema = dj.schema("u_thinh_device_stream") + + +@schema +class StreamType(dj.Lookup): + """ + Catalog of all steam types for the different device types used across Project Aeon + One StreamType corresponds to one reader class in `aeon.io.reader` + The combination of `stream_reader` and `stream_reader_kwargs` should fully specify + the data loading routine for a particular device, using the `aeon.io.utils` + """ + + definition = """ # Catalog of all stream types used across Project Aeon + stream_type: varchar(16) + --- + stream_reader: varchar(256) # name of the reader class found in `aeon_mecha` package (e.g. aeon.io.reader.Video) + stream_reader_kwargs: longblob # keyword arguments to instantiate the reader class + stream_description='': varchar(256) + """ + + contents = [ + ("Video", "aeon.io.reader.Video", {"pattern": "{}"}, "Video frame metadata"), + ( + "Position", + "aeon.io.reader.Position", + {"pattern": "{}_200"}, + "Position tracking data for the specified camera.", + ), + ( + "Encoder", + "aeon.io.reader.Encoder", + {"pattern": "{}_90"}, + "Wheel magnetic encoder data", + ), + ( + "EnvironmentState", + "aeon.io.reader.Csv", + {"pattern": "{}_EnvironmentState"}, + "Environment state log", + ), + ( + "SubjectState", + "aeon.io.reader.Subject", + {"pattern": "{}_SubjectState"}, + "Subject state log", + ), + ( + "MessageLog", + "aeon.io.reader.Log", + {"pattern": "{}_MessageLog"}, + "Message log data", + ), + ( + "Metadata", + "aeon.io.reader.Metadata", + {"pattern": "{}"}, + "Metadata for acquisition epochs", + ), + ( + "MetadataExp01", + "aeon.io.reader.Metadata", + {"pattern": "{}_2", "columns": ["id", "weight", "event"]}, + "Session metadata for Experiment 0.1", + ), + ( + "Region", + "aeon.schema.foraging._RegionReader", + {"pattern": "{}_201"}, + "Region tracking data for the specified camera", + ), + ( + "DepletionState", + "aeon.schema.foraging._PatchState", + {"pattern": "{}_State"}, + "State of the linear depletion function for foraging patches", + ), + ( + "BeamBreak", + "aeon.io.reader.BitmaskEvent", + {"pattern": "{}_32", "value": 0x22, "tag": "PelletDetected"}, + "Beam break events for pellet detection", + ), + ( + "DeliverPellet", + "aeon.io.reader.BitmaskEvent", + {"pattern": "{}_35", "value": 0x80, "tag": "TriggerPellet"}, + "Pellet delivery commands", + ), + ( + "WeightRaw", + "aeon.schema.foraging._Weight", + {"pattern": "{}_200"}, + "Raw weight measurement for a specific nest", + ), + ( + "WeightFiltered", + "aeon.schema.foraging._Weight", + {"pattern": "{}_202"}, + "Filtered weight measurement for a specific nest", + ), + ( + "WeightSubject", + "aeon.schema.foraging._Weight", + {"pattern": "{}_204"}, + "Subject weight measurement for a specific nest", + ), + ] @schema @@ -18,14 +128,54 @@ class DeviceType(dj.Lookup): The combination of `device_loader` and `device_load_kwargs` should fully specify the data loading routine for a particular device, using the `preprocess/api.py` """ + definition = """ # Catalog of all device types used across Project Aeon - device_type: varchar(16) + device_type: varchar(36) --- device_description: varchar(256) device_loader: varchar(256) device_load_kwargs: longblob """ + class Stream(dj.Part): + definition = """ # Data stream(s) associated with a particular device type + -> master + -> StreamType + """ + + @classmethod + def _insert_contents(cls): + devices_config = [ + ("Camera", "Camera device", ("Video", "Position", "Region")), + ("Metadata", "Metadata", ("Metadata",)), + ( + "ExperimentalMetadata", + "ExperimentalMetadata", + ("EnvironmentState", "SubjectState", "MessageLog"), + ), + ( + "Nest Scale", + "Weight scale at nest", + ("WeightRaw", "WeightFiltered", "WeightSubject"), + ), + ( + "Food Patch", + "Food patch", + ("DepletionState", "Encoder", "BeamBreak", "DeliverPellet"), + ), + ] + for device_type, device_desc, device_streams in devices_config: + if cls & {"device_type": device_type}: + continue + with cls.connection.transaction: + cls.insert1((device_type, device_desc)) + cls.Stream.insert( + [ + {"device_type": device_type, "stream_type": stream_type} + for stream_type in device_streams + ] + ) + @schema class Device(dj.Lookup): @@ -38,12 +188,17 @@ class Device(dj.Lookup): # ---- HELPER ---- -def generate_device_table(device_type): - device_title = device_type.replace('_', ' ').title().replace(' ', '') + +def generate_device_table(device_type, context=None): + if context is None: + context = inspect.currentframe().f_back.f_locals + + schema = dj.schema() + device_title = _prettify(device_type) class ExperimentDevice(dj.Manual): definition = f""" - # {device_title} placement and operation for a particular time period, at a certain location, for a given experiment + # {device_title} placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-{aeon.__version__}) -> acquisition.Experiment -> Device {device_type}_install_time: datetime(6) # time of the {device_type} placed and started operation at this position @@ -58,12 +213,102 @@ class RemovalTime(dj.Part): {device_type}_remove_time: datetime(6) # time of the camera being removed from this position """ - ExperimentDevice.__name__ = device_title + exp_device_table_name = f"Experiment{device_title}" + ExperimentDevice.__name__ = exp_device_table_name + context[exp_device_table_name] = ExperimentDevice - class DeviceDataStream(dj.Imported): - definition = f""" # Raw per-chunk data stream from {device_title} - -> acquisition.Chunk - -> ExperimentDevice - --- - timestamps: longblob # (datetime) timestamps of {device_type} data - """ \ No newline at end of file + # DeviceDataStream table(s) + for stream_detail in ( + StreamType & (DeviceType.Stream & {"device_type": device_type}) + ).fetch(as_dict=True): + stream_type = stream_detail["stream_type"] + stream_title = _prettify(stream_type) + + for i, n in enumerate(stream_detail["stream_reader"].split(".")): + if i == 0: + reader = aeon + else: + reader = getattr(reader, n) + + stream = reader(**stream_detail["stream_reader_kwargs"]) + + table_definition = f""" # Raw per-chunk {stream_title} data stream from {device_title} (auto-generated with aeon_mecha-{aeon.__version__}) + -> Experiment{device_title} + -> acquisition.Chunk + --- + timestamps: longblob # (datetime) timestamps of {stream_type} data + """ + + for col in stream.columns: + if col.startswith("_"): + continue + table_definition += f"{col}: longblob\n\t\t\t" + + @schema + class DeviceDataStream(dj.Imported): + definition = table_definition + + @property + def key_source(self): + f""" + Only the combination of Chunk and {exp_device_table_name} with overlapping time + + Chunk(s) that started after {exp_device_table_name} install time and ended before {exp_device_table_name} remove time + + Chunk(s) that started after {exp_device_table_name} install time for {exp_device_table_name} that are not yet removed + """ + return ( + acquisition.Chunk + * ExperimentDevice.join(ExperimentDevice.RemovalTime, left=True) + & f"chunk_start >= {device_type}_install_time" + & f'chunk_start < IFNULL({device_type}_remove_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory( + key, directory_type=dir_type + ) + + device_description = (ExperimentDevice & key).fetch1( + f"{device_type}_description" + ) + + stream = reader( + **{ + k: v.format(device_description) if k == "pattern" else v + for k, v in stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + if not len(stream_data): + raise ValueError(f"No stream data found for {key}") + + self.insert1( + { + **key, + "timestamps": stream_data.index.values, + **{ + c: stream_data[c].values + for c in stream.columns + if not c.startswith("_") + }, + } + ) + + stream_table_name = f"{device_title}{stream_title}" + DeviceDataStream.__name__ = stream_table_name + + context[stream_table_name] = DeviceDataStream + + +def _prettify(s): + s = re.sub(r"[A-Z]", lambda m: f"_{m.group(0)}", s) + return s.replace("_", " ").title().replace(" ", "") From 79f7e1095aadebac1c57f7703d0ab5862d77ec87 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 21 Jul 2022 17:16:27 -0500 Subject: [PATCH 047/489] Update device_stream.py --- aeon/dj_pipeline/device_stream.py | 34 ++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/aeon/dj_pipeline/device_stream.py b/aeon/dj_pipeline/device_stream.py index 73369158..76040162 100644 --- a/aeon/dj_pipeline/device_stream.py +++ b/aeon/dj_pipeline/device_stream.py @@ -10,9 +10,10 @@ from . import acquisition from . import get_schema_name +logger = dj.logger -# schema = dj.schema(get_schema_name("device_stream")) -schema = dj.schema("u_thinh_device_stream") +schema_name = get_schema_name("device_stream") +schema = dj.schema(schema_name) @schema @@ -49,7 +50,7 @@ class StreamType(dj.Lookup): ( "EnvironmentState", "aeon.io.reader.Csv", - {"pattern": "{}_EnvironmentState"}, + {"pattern": "{}_EnvironmentState", "columns": ["state"]}, "Environment state log", ), ( @@ -182,6 +183,9 @@ class Device(dj.Lookup): """ +DeviceType._insert_contents() + + # ---- HELPER ---- @@ -189,10 +193,13 @@ def generate_device_table(device_type, context=None): if context is None: context = inspect.currentframe().f_back.f_locals - schema = dj.schema() + _schema = dj.schema(context=context) + + device_type_key = {"device_type": device_type} device_title = _prettify(device_type) + device_type = dj.utils.from_camel_case(device_title) - @schema + @_schema class ExperimentDevice(dj.Manual): definition = f""" # {device_title} placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-{aeon.__version__}) @@ -215,12 +222,14 @@ class RemovalTime(dj.Part): context[exp_device_table_name] = ExperimentDevice # DeviceDataStream table(s) - for stream_detail in ( - StreamType & (DeviceType.Stream & {"device_type": device_type}) - ).fetch(as_dict=True): + for stream_detail in (StreamType & (DeviceType.Stream & device_type_key)).fetch( + as_dict=True + ): stream_type = stream_detail["stream_type"] stream_title = _prettify(stream_type) + logger.info(f"Creating stream table: {stream_title}") + for i, n in enumerate(stream_detail["stream_reader"].split(".")): if i == 0: reader = aeon @@ -241,7 +250,7 @@ class RemovalTime(dj.Part): continue table_definition += f"{col}: longblob\n\t\t\t" - @schema + @_schema class DeviceDataStream(dj.Imported): definition = table_definition @@ -302,10 +311,15 @@ def make(self, key): stream_table_name = f"{device_title}{stream_title}" DeviceDataStream.__name__ = stream_table_name - context[stream_table_name] = DeviceDataStream + _schema.activate(schema_name) + def _prettify(s): s = re.sub(r"[A-Z]", lambda m: f"_{m.group(0)}", s) return s.replace("_", " ").title().replace(" ", "") + + +for device_type in DeviceType.fetch("device_type"): + generate_device_table(device_type) From b10be1385b0d1b51b31219b9e70d1d5e7a0b41f8 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 21 Jul 2022 17:17:55 -0500 Subject: [PATCH 048/489] Update device_stream.py --- aeon/dj_pipeline/device_stream.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/device_stream.py b/aeon/dj_pipeline/device_stream.py index 76040162..b7adc95f 100644 --- a/aeon/dj_pipeline/device_stream.py +++ b/aeon/dj_pipeline/device_stream.py @@ -12,7 +12,10 @@ logger = dj.logger -schema_name = get_schema_name("device_stream") +# schema_name = get_schema_name("device_stream") +schema_name = ( + f'u_{dj.config["database.user"]}_device_stream' # still experimental feature +) schema = dj.schema(schema_name) From dea4b4f7b7520c2a88a927777011c0662d19f21d Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 27 Jul 2022 16:01:41 +0000 Subject: [PATCH 049/489] added the area field in VisitSubjectPosition.TimeSlice --- aeon/dj_pipeline/analysis/visit_analysis.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 9b1f6b6a..eeb2b0f9 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -8,7 +8,7 @@ from .. import acquisition, dict_to_uuid, get_schema_name, lab, qc, tracking from .visit import Visit, VisitEnd -schema = dj.schema("u_jaeronga_test") +schema = dj.schema(get_schema_name("visit_analysis")) # ---------- Position Filtering Method ------------------ @@ -18,6 +18,7 @@ class PositionFilteringMethod(dj.Lookup): definition = """ pos_filter_method: varchar(16) + --- pos_filter_method_description: varchar(256) """ @@ -59,6 +60,7 @@ class TimeSlice(dj.Part): position_x: longblob # (px) animal's x-position, in the arena's coordinate frame position_y: longblob # (px) animal's y-position, in the arena's coordinate frame position_z=null: longblob # (px) animal's z-position, in the arena's coordinate frame + area=null: longblob # (px^2) animal's size detected in the camera """ _time_slice_duration = datetime.timedelta(hours=0, minutes=10, seconds=0) @@ -143,6 +145,7 @@ def make(self, key): x = positiondata.position_x.values y = positiondata.position_y.values z = np.full_like(x, 0.0) + area = positiondata.area.values chunk_time_slices = [] time_slice_start = start_time @@ -162,6 +165,7 @@ def make(self, key): "position_x": x[in_time_slice], "position_y": y[in_time_slice], "position_z": z[in_time_slice], + "area": area[in_time_slice], } ) time_slice_start = time_slice_end @@ -241,7 +245,7 @@ def make(self, key): & f'time_slice_start <= "{day_end}"' ).fetch("KEY") - fetch_attrs = ["timestamps", "position_x", "position_y"] + fetch_attrs = ["timestamps", "position_x", "position_y", "area"] attrs_to_scale = ["position_x", "position_y"] scale_factor = tracking.pixel_scale @@ -270,6 +274,10 @@ def make(self, key): columns={"position_x": "x", "position_y": "y"}, inplace=True ) + # filter for objects of the correct size + valid_position = (position.area > 0) & (position.area < 1000) + position[~valid_position] = np.nan + # in corridor distance_from_center = tracking.compute_distance( position[["x", "y"]], @@ -299,7 +307,6 @@ def make(self, key): in_arena = in_arena & ~in_nest # in food patches - loop through all in-use patches during this visit - query = acquisition.ExperimentFoodPatch.join( acquisition.ExperimentFoodPatch.RemovalTime, left=True ) From b059b236935ec29a70b7bd20ebd0793c80413d68 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 27 Jul 2022 11:03:17 -0500 Subject: [PATCH 050/489] Update README.md --- aeon/dj_pipeline/README.md | 58 +++++++++++++++++++++++++++----------- 1 file changed, 41 insertions(+), 17 deletions(-) diff --git a/aeon/dj_pipeline/README.md b/aeon/dj_pipeline/README.md index 93646ce8..74f4c774 100644 --- a/aeon/dj_pipeline/README.md +++ b/aeon/dj_pipeline/README.md @@ -19,42 +19,67 @@ computation routines. ## Core tables +#### Experiment and data acquisition + 1. `Experiment` - the `aquisition.Experiment` table stores meta information about the experiments done in Project Aeon, with secondary information such as the lab/room the experiment is carried out, which animals participating, the directory storing the raw data, etc. -2. `Chunk` - the raw data are acquired by Bonsai and stored as +2. `Epoch` - A recording period reflecting on/off of the hardware acquisition system. +The `aquisition.Epoch` table records all acquisition epochs and their associated configuration for +any particular experiment (in the above `aquisition.Experiment` table). + +3.`Chunk` - the raw data are acquired by Bonsai and stored as a collection of files every one hour - we call this one-hour a time chunk. The `aquisition.Chunk` table records all time chunks and their associated raw data files for -any particular experiment (in the above `aquisition.Experiment` table) +any particular experiment (in the above `aquisition.Experiment` table). A chunk must belong to one epoch. -3. `ExperimentCamera` - the cameras and associated specifications used for this experiment - +#### Devices + +5. `ExperimentCamera` - the cameras and associated specifications used for this experiment - e.g. camera serial number, frame rate, location, time of installation and removal, etc. -4. `ExperimentFoodPatch` - the food-patches and associated specifications used for this experiment - +6. `ExperimentFoodPatch` - the food-patches and associated specifications used for this experiment - e.g. patch serial number, sampling rate of the wheel, location, time of installation and removal, etc. -5. `FoodPatchEvent` - all events (e.g. pellet triggered, pellet delivered, etc.) +7. `ExperimentWeightScale` - the scales for measuring animal weights, usually placed at the nest, one per nest + +#### Data streams + +8. `FoodPatchEvent` - all events (e.g. pellet triggered, pellet delivered, etc.) from a particular `ExperimentFoodPatch` -6. `Session` - a session is defined, for a given animal, as the time period where -the animal enters the arena until it exits (typically 4 to 5 hours long) +9. `FoodPatchWheel` - wheel data (angle, intensity) from a particular `ExperimentFoodPatch` + +10. `WheelState` - wheel states (threshold, d1, delta) associated with a given `ExperimentFoodPatch` + +11. `WeightMeasurement` - scale measurements associated with a given `ExperimentScale` -7. `TimeSlice` - data for each session are stored in smaller time bins called time slices. -Currently, a time slice is defined to be 10-minute long. Storing data in smaller time slices allows for -more efficient searches, queries and fetches from the database. -8. `SubjectPosition` - position data (x, y, speed, area) of the subject in the time slices for -any particular session. +#### Position data -9. `SessionSummary` - a table for computation and storing some summary statistics on a +12. `qc.CameraQC` - quality control procedure applied to each `ExperimentCamera` (e.g. missing frame, etc.) + +13. `tracking.CameraTracking` - position tracking for object(s), from each `ExperimentCamera` + +#### Standard analyses + +14. `Visit` - a `Visit` is defined as a ***period of time*** +that a particular ***animal*** spends time at a particular ***place*** + +15. `VisitSubjectPosition` - position data (x, y, z, area) of the subject for any particular visit. +Position data per visit are stored in smaller time slices (10-minute long) allowing for +more efficient searches, queries and fetches from the database. + +16. `VisitSummary` - a table for computation and storing some summary statistics on a per-session level - i.e. total pellet delivered, total distance the animal travelled, total distance the wheel travelled (or per food-patch), etc. -10. `SessionTimeDistribution` - a table for computation and storing where the animal is at, +17. `VisitTimeDistribution` - a table for computation and storing where the animal is at, for each timepoint, e.g. in the nest, in corridor, in arena, in each of the food patches. This can be used to produce the ethogram plot. +## Pipeline Diagram The diagram below shows the same architecture, with some figures to demonstrate which type of data is stored where. @@ -78,11 +103,10 @@ calling the `.populate()` method for all relevant tables. These routines are prepared in this [auto-processing script](populate/process.py). Essentially, turning on the auto-processing routine amounts to running the -following 3 commands (in different processing threads) +following 2 commands (in different processing threads) aeon_ingest high aeon_ingest mid - - aeon_ingest low + From 988c6c5cca4deebb509cb3f2612b522ab05d68e5 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 27 Jul 2022 13:37:46 -0500 Subject: [PATCH 051/489] code cleanup - remove extra primary attributes --- aeon/dj_pipeline/analysis/visit_analysis.py | 137 ++++++++------------ 1 file changed, 51 insertions(+), 86 deletions(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index d3015d1f..63849265 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -173,6 +173,43 @@ def make(self, key): self.insert1(key) self.TimeSlice.insert(chunk_time_slices) + @classmethod + def get_position(cls, visit_key=None, subject=None, start=None, end=None): + """ + Given a key to a single Visit, return a Pandas DataFrame for the position data + of the subject for the specified Visit time period + """ + if visit_key is not None: + assert len(Visit & visit_key) == 1 + start, end = ( + Visit.join(VisitEnd, left=True).proj( + visit_end="IFNULL(visit_end, NOW())" + ) + & visit_key + ).fetch1("visit_start", "visit_end") + subject = visit_key["subject"] + elif all((subject, start, end)): + start = start + end = end + subject = subject + else: + raise ValueError( + f'Either "visit_key" or all three "subject", "start" and "end" has to be specified' + ) + + return tracking._get_position( + cls.TimeSlice, + object_attr="subject", + object_name=subject, + start_attr="time_slice_start", + end_attr="time_slice_end", + start=start, + end=end, + fetch_attrs=["timestamps", "position_x", "position_y", "area"], + attrs_to_scale=["position_x", "position_y"], + scale_factor=tracking.pixel_scale, + ) + # -------------- Visit-level analysis --------------------- @@ -181,10 +218,9 @@ def make(self, key): class VisitTimeDistribution(dj.Computed): definition = """ -> Visit - visit_end : datetime(6) visit_date: date - day_duration: float # total duration (in hours) --- + day_duration: float # total duration (in hours) time_fraction_in_corridor: float # fraction of time the animal spent in the corridor in this visit in_corridor: longblob # array of indices for when the animal is in the corridor (index into the position data) time_fraction_in_arena: float # fraction of time the animal spent in the arena in this visit @@ -215,19 +251,14 @@ class FoodPatch(dj.Part): ) def make(self, key): - visit_start, visit_end = (VisitEnd & key).fetch1("visit_start", "visit_end") - visit_dates = pd.date_range( start=pd.Timestamp(visit_start.date()), end=pd.Timestamp(visit_end.date()) ) for visit_date in visit_dates: - - print(visit_date) - - day_start = datetime.datetime.combine((visit_date).date(), time.min) - day_end = datetime.datetime.combine((visit_date).date(), time.max) + day_start = datetime.datetime.combine(visit_date.date(), time.min) + day_end = datetime.datetime.combine(visit_date.date(), time.max) day_start = max(day_start, visit_start) day_end = min(day_end, visit_end) @@ -239,45 +270,18 @@ def make(self, key): ) # subject's position data in the time_slices per day - slice_keys = ( - VisitSubjectPosition.TimeSlice - & f'time_slice_start >= "{day_start}"' - & f'time_slice_start <= "{day_end}"' - ).fetch("KEY") - - fetch_attrs = ["timestamps", "position_x", "position_y", "area"] - attrs_to_scale = ["position_x", "position_y"] - scale_factor = tracking.pixel_scale - - fetched_data = (VisitSubjectPosition.TimeSlice & slice_keys).fetch( - *fetch_attrs + position = VisitSubjectPosition.get_position( + subject=key["subject"], start=day_start, end=day_end ) - timestamp_attr = next(attr for attr in fetch_attrs if "timestamps" in attr) - - # stack and structure in pandas DataFrame - position = pd.DataFrame( - { - k: np.hstack(v) * scale_factor - if k in attrs_to_scale - else np.hstack(v) - for k, v in zip(fetch_attrs, fetched_data) - } - ) - position.set_index(timestamp_attr, inplace=True) + # filter for objects of the correct size + valid_position = (position.area > 0) & (position.area < 1000) + position[~valid_position] = np.nan - time_mask = np.logical_and( - position.index >= day_start, position.index < day_end - ) - position[time_mask] position.rename( columns={"position_x": "x", "position_y": "y"}, inplace=True ) - # filter for objects of the correct size - valid_position = (position.area > 0) & (position.area < 1000) - position[~valid_position] = np.nan - # in corridor distance_from_center = tracking.compute_distance( position[["x", "y"]], @@ -297,9 +301,7 @@ def make(self, key): { **key, **nest_key, - "visit_end": visit_end, "visit_date": visit_date.date(), - "day_duration": day_duration, "time_fraction_in_nest": in_nest.mean(), "in_nest": in_nest, } @@ -362,9 +364,6 @@ def make(self, key): { **key, **food_patch_key, - "visit_end": visit_end, - "visit_date": visit_date.date(), - "day_duration": day_duration, "visit_date": visit_date.date(), "time_fraction_in_patch": in_patch.mean(), "in_patch": in_patch.values, @@ -376,7 +375,6 @@ def make(self, key): self.insert1( { **key, - "visit_end": visit_end, "visit_date": visit_date.date(), "day_duration": day_duration, "time_fraction_in_corridor": in_corridor.mean(), @@ -393,10 +391,9 @@ def make(self, key): class VisitSummary(dj.Computed): definition = """ -> Visit - visit_end : datetime(6) visit_date: date - day_duration: float # total duration (in hours) --- + day_duration: float # total duration (in hours) total_distance_travelled: float # (m) total distance the animal travelled during this visit total_pellet_count: int # total pellet delivered (triggered) for all patches during this visit total_wheel_distance_travelled: float # total wheel travelled distance for all patches @@ -417,17 +414,12 @@ class FoodPatch(dj.Part): ) def make(self, key): - visit_start, visit_end = (VisitEnd & key).fetch1("visit_start", "visit_end") - visit_dates = pd.date_range( start=pd.Timestamp(visit_start.date()), end=pd.Timestamp(visit_end.date()) ) for visit_date in visit_dates: - - print(visit_date) - day_start = datetime.datetime.combine((visit_date).date(), time.min) day_end = datetime.datetime.combine((visit_date).date(), time.max) @@ -450,37 +442,13 @@ def make(self, key): # ).fetch1("weight") # subject's position data in the time_slices per day - slice_keys = ( - VisitSubjectPosition.TimeSlice - & f'time_slice_start >= "{day_start}"' - & f'time_slice_start <= "{day_end}"' - ).fetch("KEY") - - fetch_attrs = ["timestamps", "position_x", "position_y"] - attrs_to_scale = ["position_x", "position_y"] - scale_factor = tracking.pixel_scale - - fetched_data = (VisitSubjectPosition.TimeSlice & slice_keys).fetch( - *fetch_attrs - ) - - timestamp_attr = next(attr for attr in fetch_attrs if "timestamps" in attr) - - # stack and structure in pandas DataFrame - position = pd.DataFrame( - { - k: np.hstack(v) * scale_factor - if k in attrs_to_scale - else np.hstack(v) - for k, v in zip(fetch_attrs, fetched_data) - } + position = VisitSubjectPosition.get_position( + subject=key["subject"], start=day_start, end=day_end ) - position.set_index(timestamp_attr, inplace=True) - time_mask = np.logical_and( - position.index >= day_start, position.index < day_end - ) - position[time_mask] + # filter for objects of the correct size + valid_position = (position.area > 0) & (position.area < 1000) + position[~valid_position] = np.nan position.rename( columns={"position_x": "x", "position_y": "y"}, inplace=True ) @@ -533,9 +501,7 @@ def make(self, key): { **key, **food_patch_key, - "visit_end": visit_end, "visit_date": visit_date.date(), - "day_duration": day_duration, "pellet_count": len(pellet_events), "wheel_distance_travelled": wheel_data.distance_travelled.values[ -1 @@ -553,7 +519,6 @@ def make(self, key): self.insert1( { **key, - "visit_end": visit_end, "visit_date": visit_date.date(), "day_duration": day_duration, "total_pellet_count": total_pellet_count, From 8470d9cc05a9dcc9fa0551b1746387ee666381c6 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Thu, 28 Jul 2022 19:01:29 +0000 Subject: [PATCH 052/489] 1. modified the boolean longblob to store only the indices 2. timestamp format conversion from datatime.datetime to np.datetime64[ns] --- aeon/dj_pipeline/analysis/visit_analysis.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 63849265..4665c42b 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -63,7 +63,7 @@ class TimeSlice(dj.Part): area=null: longblob # (px^2) animal's size detected in the camera """ - _time_slice_duration = datetime.timedelta(hours=0, minutes=10, seconds=0) + _time_slice_duration = np.timedelta64(10, "m") @property def key_source(self): @@ -148,7 +148,9 @@ def make(self, key): area = positiondata.area.values chunk_time_slices = [] - time_slice_start = start_time + time_slice_start = np.array(start_time, dtype="datetime64[ns]") + end_time = np.array(end_time, dtype="datetime64[ns]") + while time_slice_start < end_time: time_slice_end = time_slice_start + min( self._time_slice_duration, end_time - time_slice_start @@ -366,7 +368,7 @@ def make(self, key): **food_patch_key, "visit_date": visit_date.date(), "time_fraction_in_patch": in_patch.mean(), - "in_patch": in_patch.values, + "in_patch": np.where(in_patch)[0], } ) @@ -378,9 +380,9 @@ def make(self, key): "visit_date": visit_date.date(), "day_duration": day_duration, "time_fraction_in_corridor": in_corridor.mean(), - "in_corridor": in_corridor.values, + "in_corridor": np.where(in_corridor)[0], "time_fraction_in_arena": in_arena.mean(), - "in_arena": in_arena.values, + "in_arena": np.where(in_arena)[0], } ) self.Nest.insert(in_nest_times) From 24a5d4bae340771be4841ac2f0a1386f6edd1f93 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 28 Jul 2022 15:01:03 -0500 Subject: [PATCH 053/489] update diagrams, update README --- aeon/dj_pipeline/README.md | 17 +- aeon/dj_pipeline/analysis/__init__.py | 1 + aeon/dj_pipeline/analysis/visit_analysis.py | 2 +- .../docs/datajoint_analysis_diagram.svg | 287 ++++++ .../docs/datajoint_overview_diagram.svg | 287 ++++++ .../docs/notebooks/analysis_diagram.svg | 164 ++++ aeon/dj_pipeline/docs/notebooks/diagram.ipynb | 891 ++++++++++++++++-- aeon/dj_pipeline/docs/notebooks/diagram.svg | 409 ++++---- 8 files changed, 1764 insertions(+), 294 deletions(-) create mode 100644 aeon/dj_pipeline/docs/datajoint_analysis_diagram.svg create mode 100644 aeon/dj_pipeline/docs/datajoint_overview_diagram.svg create mode 100644 aeon/dj_pipeline/docs/notebooks/analysis_diagram.svg diff --git a/aeon/dj_pipeline/README.md b/aeon/dj_pipeline/README.md index 74f4c774..bd6b8ac3 100644 --- a/aeon/dj_pipeline/README.md +++ b/aeon/dj_pipeline/README.md @@ -81,10 +81,14 @@ This can be used to produce the ethogram plot. ## Pipeline Diagram -The diagram below shows the same architecture, with some figures -to demonstrate which type of data is stored where. +The diagram below shows the high level overview of the diagram (only the subset of the tables that are most relevant). -![datajoint_pipeline](./docs/datajoint_pipeline.svg) +![datajoint_pipeline](./docs/datajoint_overview_diagram.svg) + + +The diagram below shows the analysis portion of the pipeline (work in progress). + +![datajoint_analysis_pipeline](./docs/datajoint_analysis_diagram.svg) ## Operating the pipeline - how the auto ingestion/processing work? @@ -93,8 +97,11 @@ Some meta information about the experiment is entered - e.g. experiment name, pa animals, cameras, food patches setup, etc. + These information are either entered by hand, or parsed and inserted from configuration yaml files. -+ For experiment 0.1 these info can be inserted by running -the [exp01_insert_meta script](populate/create_experiment_01.py) (just need to do this once) ++ For experiments these info can be inserted by running + + [create_experiment_01](populate/create_experiment_01.py) + + [create_socialexperiment_0](populate/create_socialexperiment_0.py) + + [create_experiment_02](populate/create_experiment_02.py) + (just need to do this once) Tables in DataJoint are written with a `make()` function - instruction to generate and insert new records to itself, based on data from upstream tables. diff --git a/aeon/dj_pipeline/analysis/__init__.py b/aeon/dj_pipeline/analysis/__init__.py index 4a0536dd..01c708ce 100644 --- a/aeon/dj_pipeline/analysis/__init__.py +++ b/aeon/dj_pipeline/analysis/__init__.py @@ -1,2 +1,3 @@ from .in_arena import * from .visit import * +from .visit_analysis import * diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 63849265..b8952339 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -8,7 +8,7 @@ from .. import acquisition, dict_to_uuid, get_schema_name, lab, qc, tracking from .visit import Visit, VisitEnd -schema = dj.schema(get_schema_name("visit_analysis")) +schema = dj.schema(get_schema_name("analysis")) # ---------- Position Filtering Method ------------------ diff --git a/aeon/dj_pipeline/docs/datajoint_analysis_diagram.svg b/aeon/dj_pipeline/docs/datajoint_analysis_diagram.svg new file mode 100644 index 00000000..6cbe98cf --- /dev/null +++ b/aeon/dj_pipeline/docs/datajoint_analysis_diagram.svg @@ -0,0 +1,287 @@ + + + + + +Arena + + +Arena + + + + + +Experiment + + +Experiment + + + + + +Arena->Experiment + + + + +ExperimentFoodPatch + + +ExperimentFoodPatch + + + + + +FoodPatchWheel + + +FoodPatchWheel + + + + + +ExperimentFoodPatch->FoodPatchWheel + + + + +WheelState + + +WheelState + + + + + +ExperimentFoodPatch->WheelState + + + + +FoodPatchEvent + + +FoodPatchEvent + + + + + +ExperimentFoodPatch->FoodPatchEvent + + + + +WeightScale + + +WeightScale + + + + + +ExperimentWeightScale + + +ExperimentWeightScale + + + + + +WeightScale->ExperimentWeightScale + + + + +Camera + + +Camera + + + + + +ExperimentCamera + + +ExperimentCamera + + + + + +Camera->ExperimentCamera + + + + +Epoch + + +Epoch + + + + + +Chunk + + +Chunk + + + + + +Epoch->Chunk + + + + +FoodPatch + + +FoodPatch + + + + + +FoodPatch->ExperimentFoodPatch + + + + +CameraTracking + + +CameraTracking + + + + + +ExperimentCamera->CameraTracking + + + + +qc.CameraQC + + +qc.CameraQC + + + + + +ExperimentCamera->qc.CameraQC + + + + +CameraTracking.Object + + +CameraTracking.Object + + + + + +CameraTracking->CameraTracking.Object + + + + +EventType + + +EventType + + + + + +EventType->FoodPatchEvent + + + + +Chunk->FoodPatchWheel + + + + +Chunk->CameraTracking + + + + +Chunk->WheelState + + + + +Chunk->qc.CameraQC + + + + +WeightMeasurement + + +WeightMeasurement + + + + + +Chunk->WeightMeasurement + + + + +Chunk->FoodPatchEvent + + + + +Experiment->ExperimentFoodPatch + + + + +Experiment->Epoch + + + + +Experiment->ExperimentCamera + + + + +Experiment->Chunk + + + + +Experiment->ExperimentWeightScale + + + + +ExperimentWeightScale->WeightMeasurement + + + + \ No newline at end of file diff --git a/aeon/dj_pipeline/docs/datajoint_overview_diagram.svg b/aeon/dj_pipeline/docs/datajoint_overview_diagram.svg new file mode 100644 index 00000000..6cbe98cf --- /dev/null +++ b/aeon/dj_pipeline/docs/datajoint_overview_diagram.svg @@ -0,0 +1,287 @@ + + + + + +Arena + + +Arena + + + + + +Experiment + + +Experiment + + + + + +Arena->Experiment + + + + +ExperimentFoodPatch + + +ExperimentFoodPatch + + + + + +FoodPatchWheel + + +FoodPatchWheel + + + + + +ExperimentFoodPatch->FoodPatchWheel + + + + +WheelState + + +WheelState + + + + + +ExperimentFoodPatch->WheelState + + + + +FoodPatchEvent + + +FoodPatchEvent + + + + + +ExperimentFoodPatch->FoodPatchEvent + + + + +WeightScale + + +WeightScale + + + + + +ExperimentWeightScale + + +ExperimentWeightScale + + + + + +WeightScale->ExperimentWeightScale + + + + +Camera + + +Camera + + + + + +ExperimentCamera + + +ExperimentCamera + + + + + +Camera->ExperimentCamera + + + + +Epoch + + +Epoch + + + + + +Chunk + + +Chunk + + + + + +Epoch->Chunk + + + + +FoodPatch + + +FoodPatch + + + + + +FoodPatch->ExperimentFoodPatch + + + + +CameraTracking + + +CameraTracking + + + + + +ExperimentCamera->CameraTracking + + + + +qc.CameraQC + + +qc.CameraQC + + + + + +ExperimentCamera->qc.CameraQC + + + + +CameraTracking.Object + + +CameraTracking.Object + + + + + +CameraTracking->CameraTracking.Object + + + + +EventType + + +EventType + + + + + +EventType->FoodPatchEvent + + + + +Chunk->FoodPatchWheel + + + + +Chunk->CameraTracking + + + + +Chunk->WheelState + + + + +Chunk->qc.CameraQC + + + + +WeightMeasurement + + +WeightMeasurement + + + + + +Chunk->WeightMeasurement + + + + +Chunk->FoodPatchEvent + + + + +Experiment->ExperimentFoodPatch + + + + +Experiment->Epoch + + + + +Experiment->ExperimentCamera + + + + +Experiment->Chunk + + + + +Experiment->ExperimentWeightScale + + + + +ExperimentWeightScale->WeightMeasurement + + + + \ No newline at end of file diff --git a/aeon/dj_pipeline/docs/notebooks/analysis_diagram.svg b/aeon/dj_pipeline/docs/notebooks/analysis_diagram.svg new file mode 100644 index 00000000..1addc0d8 --- /dev/null +++ b/aeon/dj_pipeline/docs/notebooks/analysis_diagram.svg @@ -0,0 +1,164 @@ + + + + + +VisitSummary + + +VisitSummary + + + + + +VisitSubjectPosition + + +VisitSubjectPosition + + + + + +VisitSubjectPosition.TimeSlice + + +VisitSubjectPosition.TimeSlice + + + + + +VisitSubjectPosition->VisitSubjectPosition.TimeSlice + + + + +VisitTimeDistribution + + +VisitTimeDistribution + + + + + +ExperimentCamera + + +ExperimentCamera + + + + + +CameraTracking + + +CameraTracking + + + + + +ExperimentCamera->CameraTracking + + + + +Visit + + +Visit + + + + + +Visit->VisitSummary + + + + +Visit->VisitSubjectPosition + + + + +Visit->VisitTimeDistribution + + + + +Place + + +Place + + + + + +Place->Visit + + + + +Chunk + + +Chunk + + + + + +Chunk->VisitSubjectPosition + + + + +Chunk->CameraTracking + + + + +Experiment + + +Experiment + + + + + +Experiment->ExperimentCamera + + + + +Experiment->Chunk + + + + +Experiment.Subject + + +Experiment.Subject + + + + + +Experiment->Experiment.Subject + + + + +Experiment.Subject->Visit + + + + \ No newline at end of file diff --git a/aeon/dj_pipeline/docs/notebooks/diagram.ipynb b/aeon/dj_pipeline/docs/notebooks/diagram.ipynb index f1490521..7d819c2f 100644 --- a/aeon/dj_pipeline/docs/notebooks/diagram.ipynb +++ b/aeon/dj_pipeline/docs/notebooks/diagram.ipynb @@ -3,135 +3,885 @@ { "cell_type": "code", "execution_count": 1, - "source": [ - "import datajoint as dj" + "id": "b18d8c4a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "/nfs/nhome/live/thinh/code/ProjectAeon/aeon\n" + ] + } ], - "outputs": [], - "metadata": {} + "source": [ + "cd ../../../.." + ] }, { "cell_type": "code", "execution_count": 2, + "id": "b9db4795", + "metadata": {}, + "outputs": [], "source": [ - "_db_prefix = 'aeon_'\n", - "\n", - "acquisition = dj.create_virtual_module(\"acquisition\", _db_prefix + \"acquisition\")\n", - "analysis = dj.create_virtual_module(\"analysis\", _db_prefix + \"analysis\")\n", - "lab = dj.create_virtual_module(\"lab\", _db_prefix + \"lab\")\n", - "subject = dj.create_virtual_module(\"subject\", _db_prefix + \"subject\")\n", - "tracking = dj.create_virtual_module(\"tracking\", _db_prefix + \"tracking\")" - ], + "import datajoint as dj" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "571c2760", + "metadata": {}, "outputs": [ { + "name": "stderr", "output_type": "stream", - "name": "stdout", "text": [ - "Connecting jburling@localhost:3306\n" + "[2022-07-28 19:53:12,486][INFO]: Connecting thinh@aeon-db2:3306\n", + "[2022-07-28 19:53:12,497][INFO]: Connected thinh@aeon-db2:3306\n" ] } ], - "metadata": {} + "source": [ + "_db_prefix = 'aeon_'\n", + "\n", + "lab = dj.create_virtual_module(\"lab\", _db_prefix + \"lab\")\n", + "subject = dj.create_virtual_module(\"subject\", _db_prefix + \"subject\")\n", + "acquisition = dj.create_virtual_module(\"acquisition\", _db_prefix + \"acquisition\")\n", + "qc = dj.create_virtual_module(\"qc\", _db_prefix + \"qc\")\n", + "tracking = dj.create_virtual_module(\"tracking\", _db_prefix + \"tracking\")\n", + "analysis = dj.create_virtual_module(\"analysis\", _db_prefix + \"analysis\")" + ] }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 4, + "id": "bcf4bd2e", + "metadata": {}, + "outputs": [], "source": [ "lab.schema.spawn_missing_classes()\n", "subject.schema.spawn_missing_classes()\n", "acquisition.schema.spawn_missing_classes()\n", "tracking.schema.spawn_missing_classes()\n", "analysis.schema.spawn_missing_classes()" - ], - "outputs": [], - "metadata": {} + ] }, { - "cell_type": "code", - "execution_count": 5, + "cell_type": "markdown", + "id": "6d318782", + "metadata": {}, "source": [ - "diagram = (\n", - " dj.Diagram(acquisition.Session)\n", - " + lab.Arena\n", - " + lab.FoodPatch\n", - " + lab.Camera\n", - " + acquisition.ExperimentCamera\n", - " + acquisition.FoodPatchWheel\n", - " + acquisition.FoodPatchEvent\n", - " + acquisition.EventType \n", - " + acquisition.Experiment\n", - " + tracking.SubjectPosition\n", - ")\n", - "\n", - "diagram" - ], + "# High level diagram" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "5c447e42", + "metadata": {}, "outputs": [ { - "output_type": "execute_result", "data": { - "text/plain": [ - "" + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "Arena\n", + "\n", + "\n", + "Arena\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Experiment\n", + "\n", + "\n", + "Experiment\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Arena->Experiment\n", + "\n", + "\n", + "\n", + "\n", + "ExperimentFoodPatch\n", + "\n", + "\n", + "ExperimentFoodPatch\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "FoodPatchWheel\n", + "\n", + "\n", + "FoodPatchWheel\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "ExperimentFoodPatch->FoodPatchWheel\n", + "\n", + "\n", + "\n", + "\n", + "WheelState\n", + "\n", + "\n", + "WheelState\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "ExperimentFoodPatch->WheelState\n", + "\n", + "\n", + "\n", + "\n", + "FoodPatchEvent\n", + "\n", + "\n", + "FoodPatchEvent\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "ExperimentFoodPatch->FoodPatchEvent\n", + "\n", + "\n", + "\n", + "\n", + "WeightScale\n", + "\n", + "\n", + "WeightScale\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "ExperimentWeightScale\n", + "\n", + "\n", + "ExperimentWeightScale\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "WeightScale->ExperimentWeightScale\n", + "\n", + "\n", + "\n", + "\n", + "Camera\n", + "\n", + "\n", + "Camera\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "ExperimentCamera\n", + "\n", + "\n", + "ExperimentCamera\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Camera->ExperimentCamera\n", + "\n", + "\n", + "\n", + "\n", + "Epoch\n", + "\n", + "\n", + "Epoch\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Chunk\n", + "\n", + "\n", + "Chunk\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Epoch->Chunk\n", + "\n", + "\n", + "\n", + "\n", + "FoodPatch\n", + "\n", + "\n", + "FoodPatch\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "FoodPatch->ExperimentFoodPatch\n", + "\n", + "\n", + "\n", + "\n", + "CameraTracking\n", + "\n", + "\n", + "CameraTracking\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "ExperimentCamera->CameraTracking\n", + "\n", + "\n", + "\n", + "\n", + "qc.CameraQC\n", + "\n", + "\n", + "qc.CameraQC\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "ExperimentCamera->qc.CameraQC\n", + "\n", + "\n", + "\n", + "\n", + "CameraTracking.Object\n", + "\n", + "\n", + "CameraTracking.Object\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "CameraTracking->CameraTracking.Object\n", + "\n", + "\n", + "\n", + "\n", + "EventType\n", + "\n", + "\n", + "EventType\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "EventType->FoodPatchEvent\n", + "\n", + "\n", + "\n", + "\n", + "Chunk->FoodPatchWheel\n", + "\n", + "\n", + "\n", + "\n", + "Chunk->CameraTracking\n", + "\n", + "\n", + "\n", + "\n", + "Chunk->WheelState\n", + "\n", + "\n", + "\n", + "\n", + "Chunk->qc.CameraQC\n", + "\n", + "\n", + "\n", + "\n", + "WeightMeasurement\n", + "\n", + "\n", + "WeightMeasurement\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Chunk->WeightMeasurement\n", + "\n", + "\n", + "\n", + "\n", + "Chunk->FoodPatchEvent\n", + "\n", + "\n", + "\n", + "\n", + "Experiment->ExperimentFoodPatch\n", + "\n", + "\n", + "\n", + "\n", + "Experiment->Epoch\n", + "\n", + "\n", + "\n", + "\n", + "Experiment->ExperimentCamera\n", + "\n", + "\n", + "\n", + "\n", + "Experiment->Chunk\n", + "\n", + "\n", + "\n", + "\n", + "Experiment->ExperimentWeightScale\n", + "\n", + "\n", + "\n", + "\n", + "ExperimentWeightScale->WeightMeasurement\n", + "\n", + "\n", + "\n", + "" ], - "image/svg+xml": "\n\n\n\n\nExperiment.Subject\n\n\nExperiment.Subject\n\n\n\n\n\nSession\n\n\nSession\n\n\n\n\n\nExperiment.Subject->Session\n\n\n\n\nTimeSlice\n\n\nTimeSlice\n\n\n\n\n\nSession->TimeSlice\n\n\n\n\nFoodPatchEvent\n\n\nFoodPatchEvent\n\n\n\n\n\nEventType\n\n\nEventType\n\n\n\n\n\nEventType->FoodPatchEvent\n\n\n\n\nArena\n\n\nArena\n\n\n\n\n\nExperiment\n\n\nExperiment\n\n\n\n\n\nArena->Experiment\n\n\n\n\nSubjectPosition\n\n\nSubjectPosition\n\n\n\n\n\nFoodPatchWheel\n\n\nFoodPatchWheel\n\n\n\n\n\nExperimentCamera\n\n\nExperimentCamera\n\n\n\n\n\nExperiment->Experiment.Subject\n\n\n\n\nExperiment->ExperimentCamera\n\n\n\n\nExperimentFoodPatch\n\n\nExperimentFoodPatch\n\n\n\n\n\nExperiment->ExperimentFoodPatch\n\n\n\n\nChunk\n\n\nChunk\n\n\n\n\n\nExperiment->Chunk\n\n\n\n\nTimeSlice->SubjectPosition\n\n\n\n\nFoodPatch\n\n\nFoodPatch\n\n\n\n\n\nFoodPatch->ExperimentFoodPatch\n\n\n\n\nExperimentFoodPatch->FoodPatchEvent\n\n\n\n\nExperimentFoodPatch->FoodPatchWheel\n\n\n\n\nCamera\n\n\nCamera\n\n\n\n\n\nCamera->ExperimentCamera\n\n\n\n\nChunk->FoodPatchEvent\n\n\n\n\nChunk->FoodPatchWheel\n\n\n\n\nChunk->TimeSlice\n\n\n\n" + "text/plain": [ + "" + ] }, + "execution_count": 12, "metadata": {}, - "execution_count": 5 + "output_type": "execute_result" } ], - "metadata": {} - }, - { - "cell_type": "code", - "execution_count": 5, "source": [ "diagram = (\n", - " dj.Diagram(acquisition.Session)\n", + " dj.Diagram(acquisition.Epoch)\n", " + lab.Arena\n", " + lab.FoodPatch\n", " + lab.Camera\n", + " + lab.WeightScale\n", + " + acquisition.Experiment\n", " + acquisition.ExperimentCamera\n", - " + acquisition.FoodPatchEvent\n", " + acquisition.FoodPatchWheel\n", - " + acquisition.Experiment\n", + " + acquisition.FoodPatchEvent\n", + " + acquisition.WheelState\n", " + acquisition.EventType \n", - " + tracking.SubjectPosition\n", - " + analysis.SessionTimeDistribution\n", - " + analysis.SessionTimeDistribution.FoodPatch\n", - " + analysis.SessionTimeDistribution.Nest\n", - " + analysis.SessionSummary\n", - " + analysis.SessionSummary.FoodPatch\n", + " + acquisition.WeightMeasurement\n", + " + qc.CameraQC\n", + " + tracking.CameraTracking.Object\n", ")\n", "\n", "diagram" - ], + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "fc5e04ff", + "metadata": {}, + "outputs": [], + "source": [ + "diagram.save(\"aeon/dj_pipeline/docs/datajoint_overview_diagram.svg\")" + ] + }, + { + "cell_type": "markdown", + "id": "ff290db9", + "metadata": {}, + "source": [ + "# Analysis pipeline diagram" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "ead30859", + "metadata": {}, "outputs": [ { - "output_type": "execute_result", "data": { - "text/plain": [ - "" + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "OverlapVisit.Visit\n", + "\n", + "\n", + "OverlapVisit.Visit\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "VisitSummary\n", + "\n", + "\n", + "VisitSummary\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "VisitSubjectPosition\n", + "\n", + "\n", + "VisitSubjectPosition\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "VisitSubjectPosition.TimeSlice\n", + "\n", + "\n", + "VisitSubjectPosition.TimeSlice\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "VisitSubjectPosition->VisitSubjectPosition.TimeSlice\n", + "\n", + "\n", + "\n", + "\n", + "VisitTimeDistribution\n", + "\n", + "\n", + "VisitTimeDistribution\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "VisitEnd\n", + "\n", + "\n", + "VisitEnd\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "ExperimentCamera\n", + "\n", + "\n", + "ExperimentCamera\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "CameraTracking\n", + "\n", + "\n", + "CameraTracking\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "ExperimentCamera->CameraTracking\n", + "\n", + "\n", + "\n", + "\n", + "Visit\n", + "\n", + "\n", + "Visit\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Visit->OverlapVisit.Visit\n", + "\n", + "\n", + "\n", + "\n", + "Visit->VisitSummary\n", + "\n", + "\n", + "\n", + "\n", + "Visit->VisitSubjectPosition\n", + "\n", + "\n", + "\n", + "\n", + "Visit->VisitTimeDistribution\n", + "\n", + "\n", + "\n", + "\n", + "Visit->VisitEnd\n", + "\n", + "\n", + "\n", + "\n", + "Place\n", + "\n", + "\n", + "Place\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Place->Visit\n", + "\n", + "\n", + "\n", + "\n", + "OverlapVisit\n", + "\n", + "\n", + "OverlapVisit\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Place->OverlapVisit\n", + "\n", + "\n", + "\n", + "\n", + "Chunk\n", + "\n", + "\n", + "Chunk\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Chunk->VisitSubjectPosition\n", + "\n", + "\n", + "\n", + "\n", + "Chunk->CameraTracking\n", + "\n", + "\n", + "\n", + "\n", + "Experiment\n", + "\n", + "\n", + "Experiment\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Experiment->ExperimentCamera\n", + "\n", + "\n", + "\n", + "\n", + "Experiment->Chunk\n", + "\n", + "\n", + "\n", + "\n", + "Experiment->OverlapVisit\n", + "\n", + "\n", + "\n", + "\n", + "Experiment.Subject\n", + "\n", + "\n", + "Experiment.Subject\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Experiment->Experiment.Subject\n", + "\n", + "\n", + "\n", + "\n", + "OverlapVisit->OverlapVisit.Visit\n", + "\n", + "\n", + "\n", + "\n", + "Experiment.Subject->Visit\n", + "\n", + "\n", + "\n", + "" ], - "image/svg+xml": "\n\n\n\n\nSubjectPosition\n\n\nSubjectPosition\n\n\n\n\n\nFoodPatchEvent\n\n\nFoodPatchEvent\n\n\n\n\n\nTimeSlice\n\n\nTimeSlice\n\n\n\n\n\nTimeSlice->SubjectPosition\n\n\n\n\nEventType\n\n\nEventType\n\n\n\n\n\nEventType->FoodPatchEvent\n\n\n\n\nArena\n\n\nArena\n\n\n\n\n\nExperiment\n\n\nExperiment\n\n\n\n\n\nArena->Experiment\n\n\n\n\nArenaNest\n\n\nArenaNest\n\n\n\n\n\nArena->ArenaNest\n\n\n\n\nExperiment.Subject\n\n\nExperiment.Subject\n\n\n\n\n\nSession\n\n\nSession\n\n\n\n\n\nExperiment.Subject->Session\n\n\n\n\nCamera\n\n\nCamera\n\n\n\n\n\nExperimentCamera\n\n\nExperimentCamera\n\n\n\n\n\nCamera->ExperimentCamera\n\n\n\n\nSessionTimeDistribution.FoodPatch\n\n\nSessionTimeDistribution.FoodPatch\n\n\n\n\n\nSessionSummary.FoodPatch\n\n\nSessionSummary.FoodPatch\n\n\n\n\n\nSessionSummary\n\n\nSessionSummary\n\n\n\n\n\nSessionSummary->SessionSummary.FoodPatch\n\n\n\n\nChunk\n\n\nChunk\n\n\n\n\n\nChunk->FoodPatchEvent\n\n\n\n\nChunk->TimeSlice\n\n\n\n\nFoodPatchWheel\n\n\nFoodPatchWheel\n\n\n\n\n\nChunk->FoodPatchWheel\n\n\n\n\nSessionTimeDistribution\n\n\nSessionTimeDistribution\n\n\n\n\n\nSessionTimeDistribution->SessionTimeDistribution.FoodPatch\n\n\n\n\nSessionTimeDistribution.Nest\n\n\nSessionTimeDistribution.Nest\n\n\n\n\n\nSessionTimeDistribution->SessionTimeDistribution.Nest\n\n\n\n\nExperiment->Experiment.Subject\n\n\n\n\nExperiment->Chunk\n\n\n\n\nExperimentFoodPatch\n\n\nExperimentFoodPatch\n\n\n\n\n\nExperiment->ExperimentFoodPatch\n\n\n\n\nExperiment->ExperimentCamera\n\n\n\n\nSession->TimeSlice\n\n\n\n\nSession->SessionSummary\n\n\n\n\nSession->SessionTimeDistribution\n\n\n\n\nArenaNest->SessionTimeDistribution.Nest\n\n\n\n\nFoodPatch\n\n\nFoodPatch\n\n\n\n\n\nFoodPatch->ExperimentFoodPatch\n\n\n\n\nExperimentFoodPatch->FoodPatchEvent\n\n\n\n\nExperimentFoodPatch->SessionTimeDistribution.FoodPatch\n\n\n\n\nExperimentFoodPatch->SessionSummary.FoodPatch\n\n\n\n\nExperimentFoodPatch->FoodPatchWheel\n\n\n\n" + "text/plain": [ + "" + ] }, + "execution_count": 14, "metadata": {}, - "execution_count": 5 + "output_type": "execute_result" } ], - "metadata": {} + "source": [ + "analysis_diagram = (\n", + " dj.Diagram(analysis.Visit)\n", + " + analysis.Place\n", + " + analysis.VisitEnd\n", + " + analysis.OverlapVisit.Visit\n", + " + acquisition.Experiment\n", + " + tracking.CameraTracking\n", + " + analysis.VisitSubjectPosition.TimeSlice\n", + " + analysis.VisitTimeDistribution\n", + " + analysis.VisitSummary\n", + ")\n", + "\n", + "analysis_diagram" + ] }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 15, + "id": "56a3b461", + "metadata": {}, + "outputs": [], "source": [ - "diagram.save(\"aeon/dj_pipeline/docs/notebooks/diagram.svg\")" + "diagram.save(\"aeon/dj_pipeline/docs/datajoint_analysis_diagram.svg\")" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "fd2745c0", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "datajoint_analysis_diagram.svg datajoint_overview_diagram.svg \u001b[0m\u001b[01;34mnotebooks\u001b[0m/\r\n" + ] + } ], + "source": [ + "ls aeon/dj_pipeline/docs/" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fce5849b", + "metadata": {}, "outputs": [], - "metadata": {} + "source": [] } ], "metadata": { + "interpreter": { + "hash": "7ed711d4bdc79410f4b0af3133fc7149926181ad66d1f6e8bc74e4f5ae156023" + }, "kernelspec": { - "name": "python3", - "display_name": "Python 3.9.6 64-bit ('aeon_env': conda)" + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" }, "language_info": { "codemirror_mode": { @@ -143,12 +893,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.6" - }, - "interpreter": { - "hash": "7ed711d4bdc79410f4b0af3133fc7149926181ad66d1f6e8bc74e4f5ae156023" + "version": "3.9.9" } }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/aeon/dj_pipeline/docs/notebooks/diagram.svg b/aeon/dj_pipeline/docs/notebooks/diagram.svg index fe58840c..a0941dfb 100644 --- a/aeon/dj_pipeline/docs/notebooks/diagram.svg +++ b/aeon/dj_pipeline/docs/notebooks/diagram.svg @@ -1,310 +1,287 @@ - - - - + + + + -SubjectPosition - - -SubjectPosition +CameraTracking + + +CameraTracking - - -FoodPatchEvent - - -FoodPatchEvent + + +CameraTracking.Object + + +CameraTracking.Object - - -TimeSlice - - -TimeSlice - + + +CameraTracking->CameraTracking.Object + + + +WeightScale + + +WeightScale + - - -TimeSlice->SubjectPosition - - - -EventType - - -EventType + + +ExperimentWeightScale + + +ExperimentWeightScale - + -EventType->FoodPatchEvent - +WeightScale->ExperimentWeightScale + - - -Arena - - -Arena + + +WheelState + + +WheelState - - -Experiment - - -Experiment + + +ExperimentFoodPatch + + +ExperimentFoodPatch - + -Arena->Experiment - +ExperimentFoodPatch->WheelState + - - -ArenaNest - - -ArenaNest + + +FoodPatchWheel + + +FoodPatchWheel - + -Arena->ArenaNest - - - - -Experiment.Subject - - -Experiment.Subject - - +ExperimentFoodPatch->FoodPatchWheel + - - -Session - - -Session + + +FoodPatchEvent + + +FoodPatchEvent - + -Experiment.Subject->Session - +ExperimentFoodPatch->FoodPatchEvent + - - -Camera - - -Camera + + +WeightMeasurement + + +WeightMeasurement - - -ExperimentCamera - - -ExperimentCamera + + +Arena + + +Arena - - -Camera->ExperimentCamera - - - - -SessionTimeDistribution.FoodPatch - - -SessionTimeDistribution.FoodPatch + + +Experiment + + +Experiment - - -SessionSummary.FoodPatch - - -SessionSummary.FoodPatch - + + +Arena->Experiment + + + +ExperimentWeightScale->WeightMeasurement + - - -SessionSummary - - -SessionSummary + + +EventType + + +EventType - - -SessionSummary->SessionSummary.FoodPatch - + + +EventType->FoodPatchEvent + - - -Chunk - - -Chunk + + +ExperimentCamera + + +ExperimentCamera - - -Chunk->FoodPatchEvent - - - + -Chunk->TimeSlice - +ExperimentCamera->CameraTracking + - - -FoodPatchWheel - - -FoodPatchWheel + + +qc.CameraQC + + +qc.CameraQC - + -Chunk->FoodPatchWheel - +ExperimentCamera->qc.CameraQC + - - -SessionTimeDistribution - - -SessionTimeDistribution + + +Camera + + +Camera - + -SessionTimeDistribution->SessionTimeDistribution.FoodPatch - +Camera->ExperimentCamera + - - -SessionTimeDistribution.Nest - - -SessionTimeDistribution.Nest + + +Epoch + + +Epoch + + + + + +Chunk + + +Chunk - + -SessionTimeDistribution->SessionTimeDistribution.Nest - +Epoch->Chunk + - + -Experiment->Experiment.Subject - +Chunk->CameraTracking + - + -Experiment->Chunk - - - - -ExperimentFoodPatch - - -ExperimentFoodPatch - +Chunk->WheelState + - - + -Experiment->ExperimentFoodPatch - +Chunk->WeightMeasurement + - + -Experiment->ExperimentCamera - +Chunk->FoodPatchWheel + - + -Session->TimeSlice - +Chunk->qc.CameraQC + - + -Session->SessionSummary - +Chunk->FoodPatchEvent + - + -Session->SessionTimeDistribution - +Experiment->ExperimentFoodPatch + - + -ArenaNest->SessionTimeDistribution.Nest - +Experiment->ExperimentWeightScale + - - -FoodPatch - - -FoodPatch - - - - + -FoodPatch->ExperimentFoodPatch - +Experiment->ExperimentCamera + - + -ExperimentFoodPatch->FoodPatchEvent - +Experiment->Epoch + - + -ExperimentFoodPatch->SessionTimeDistribution.FoodPatch - +Experiment->Chunk + - - -ExperimentFoodPatch->SessionSummary.FoodPatch - + + +FoodPatch + + +FoodPatch + - - -ExperimentFoodPatch->FoodPatchWheel - + + + +FoodPatch->ExperimentFoodPatch + \ No newline at end of file From 77f97eef300ece9e2d81d158f267bed4b7863ab1 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 29 Jul 2022 16:54:13 +0000 Subject: [PATCH 054/489] schema name --- aeon/dj_pipeline/analysis/visit_analysis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 4665c42b..82d5bbc4 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -8,7 +8,7 @@ from .. import acquisition, dict_to_uuid, get_schema_name, lab, qc, tracking from .visit import Visit, VisitEnd -schema = dj.schema(get_schema_name("visit_analysis")) +schema = dj.schema(get_schema_name("analysis")) # ---------- Position Filtering Method ------------------ From 941fff549314141a87ea27ad9745095a1de9684d Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 1 Aug 2022 13:32:39 +0000 Subject: [PATCH 055/489] add position timestamp & fixed nancumsum --- aeon/dj_pipeline/analysis/in_arena.py | 10 +++++----- aeon/dj_pipeline/analysis/visit_analysis.py | 14 +++++++------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/aeon/dj_pipeline/analysis/in_arena.py b/aeon/dj_pipeline/analysis/in_arena.py index 1fa3f472..c0012dbe 100644 --- a/aeon/dj_pipeline/analysis/in_arena.py +++ b/aeon/dj_pipeline/analysis/in_arena.py @@ -1,12 +1,12 @@ +import datetime + import datajoint as dj -import pandas as pd import numpy as np -import datetime +import pandas as pd from aeon.analysis import utils as analysis_utils -from .. import lab, acquisition, tracking, qc -from .. import get_schema_name +from .. import acquisition, get_schema_name, lab, qc, tracking schema = dj.schema(get_schema_name("analysis")) @@ -557,7 +557,7 @@ def make(self, key): position_diff = np.sqrt( np.square(np.diff(position.x)) + np.square(np.diff(position.y)) ) - total_distance_travelled = np.nancumsum(position_diff)[-1] + total_distance_travelled = np.nansum(position_diff) # food patch data food_patch_keys = ( diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 82d5bbc4..4cd12752 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -224,9 +224,9 @@ class VisitTimeDistribution(dj.Computed): --- day_duration: float # total duration (in hours) time_fraction_in_corridor: float # fraction of time the animal spent in the corridor in this visit - in_corridor: longblob # array of indices for when the animal is in the corridor (index into the position data) + in_corridor: longblob # array of timestamps for when the animal is in the corridor time_fraction_in_arena: float # fraction of time the animal spent in the arena in this visit - in_arena: longblob # array of indices for when the animal is in the arena (index into the position data) + in_arena: longblob # array of timestamps for when the animal is in the arena """ class Nest(dj.Part): @@ -244,7 +244,7 @@ class FoodPatch(dj.Part): -> acquisition.ExperimentFoodPatch --- time_fraction_in_patch: float # fraction of time the animal spent on this patch in this visit - in_patch: longblob # array of indices for when the animal is in this patch (index into the position data) + in_patch: longblob # array of timestamps for when the animal is in this patch """ # Work on finished visits @@ -368,7 +368,7 @@ def make(self, key): **food_patch_key, "visit_date": visit_date.date(), "time_fraction_in_patch": in_patch.mean(), - "in_patch": np.where(in_patch)[0], + "in_patch": np.array(in_patch.index[np.where(in_patch)]), } ) @@ -380,9 +380,9 @@ def make(self, key): "visit_date": visit_date.date(), "day_duration": day_duration, "time_fraction_in_corridor": in_corridor.mean(), - "in_corridor": np.where(in_corridor)[0], + "in_corridor": np.array(in_corridor.index[np.where(in_corridor)]), "time_fraction_in_arena": in_arena.mean(), - "in_arena": np.where(in_arena)[0], + "in_arena": np.array(in_arena.index[np.where(in_arena)]), } ) self.Nest.insert(in_nest_times) @@ -458,7 +458,7 @@ def make(self, key): position_diff = np.sqrt( np.square(np.diff(position.x)) + np.square(np.diff(position.y)) ) - total_distance_travelled = np.nancumsum(position_diff)[-1] + total_distance_travelled = np.nansum(position_diff) # in food patches - loop through all in-use patches during this visit query = acquisition.ExperimentFoodPatch.join( From b93afdef859c7eee0b4fe0737a524cc7875ad5d7 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 1 Aug 2022 13:27:32 -0500 Subject: [PATCH 056/489] Update aeon/dj_pipeline/analysis/visit_analysis.py Co-authored-by: Thinh Nguyen --- aeon/dj_pipeline/analysis/visit_analysis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 4cd12752..03d3f683 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -382,7 +382,7 @@ def make(self, key): "time_fraction_in_corridor": in_corridor.mean(), "in_corridor": np.array(in_corridor.index[np.where(in_corridor)]), "time_fraction_in_arena": in_arena.mean(), - "in_arena": np.array(in_arena.index[np.where(in_arena)]), + "in_arena": in_arena.index.values[in_arena], } ) self.Nest.insert(in_nest_times) From e151f9872c8369dd2e23dd3016cb39990a913714 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 1 Aug 2022 13:27:52 -0500 Subject: [PATCH 057/489] Update aeon/dj_pipeline/analysis/visit_analysis.py Co-authored-by: Thinh Nguyen --- aeon/dj_pipeline/analysis/visit_analysis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 03d3f683..2c7716a8 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -368,7 +368,7 @@ def make(self, key): **food_patch_key, "visit_date": visit_date.date(), "time_fraction_in_patch": in_patch.mean(), - "in_patch": np.array(in_patch.index[np.where(in_patch)]), + "in_patch": in_patch.index.values[in_patch], } ) From 4b2b8c48088fe5c7895950560ae3a945864ce2df Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 1 Aug 2022 13:28:01 -0500 Subject: [PATCH 058/489] Update aeon/dj_pipeline/analysis/visit_analysis.py Co-authored-by: Thinh Nguyen --- aeon/dj_pipeline/analysis/visit_analysis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 2c7716a8..645cd021 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -380,7 +380,7 @@ def make(self, key): "visit_date": visit_date.date(), "day_duration": day_duration, "time_fraction_in_corridor": in_corridor.mean(), - "in_corridor": np.array(in_corridor.index[np.where(in_corridor)]), + "in_corridor": in_corridor.index.values[in_corridor], "time_fraction_in_arena": in_arena.mean(), "in_arena": in_arena.index.values[in_arena], } From 370d1f1d8d0a66215370489b3193dfad6576459b Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 3 Aug 2022 18:28:45 -0500 Subject: [PATCH 059/489] update README --- aeon/dj_pipeline/README.md | 23 +- .../docs/datajoint_analysis_diagram.svg | 337 ++++++------------ 2 files changed, 115 insertions(+), 245 deletions(-) diff --git a/aeon/dj_pipeline/README.md b/aeon/dj_pipeline/README.md index bd6b8ac3..a6070ba9 100644 --- a/aeon/dj_pipeline/README.md +++ b/aeon/dj_pipeline/README.md @@ -5,10 +5,15 @@ This pipeline models the data organization and data flow custom-built for Projec ## Pipeline architecture -Figure below presents an abbreviated view of the pipeline, showing the core tables -and the overall dataflow +The diagram below shows the high level overview of the diagram (only the subset of the tables that are most relevant). + +![datajoint_pipeline](./docs/datajoint_overview_diagram.svg) + + +The diagram below shows the analysis portion of the pipeline (work in progress). + +![datajoint_analysis_pipeline](./docs/datajoint_analysis_diagram.svg) -![diagram](./docs/diagram.svg) From the diagram above, we can see that the pipeline is organized in layers of tables, going top down, from `lookup`-tier (in gray) and `manual`-tier (in green) tables @@ -79,18 +84,6 @@ distance the wheel travelled (or per food-patch), etc. for each timepoint, e.g. in the nest, in corridor, in arena, in each of the food patches. This can be used to produce the ethogram plot. -## Pipeline Diagram - -The diagram below shows the high level overview of the diagram (only the subset of the tables that are most relevant). - -![datajoint_pipeline](./docs/datajoint_overview_diagram.svg) - - -The diagram below shows the analysis portion of the pipeline (work in progress). - -![datajoint_analysis_pipeline](./docs/datajoint_analysis_diagram.svg) - - ## Operating the pipeline - how the auto ingestion/processing work? Some meta information about the experiment is entered - e.g. experiment name, participating diff --git a/aeon/dj_pipeline/docs/datajoint_analysis_diagram.svg b/aeon/dj_pipeline/docs/datajoint_analysis_diagram.svg index 6cbe98cf..1addc0d8 100644 --- a/aeon/dj_pipeline/docs/datajoint_analysis_diagram.svg +++ b/aeon/dj_pipeline/docs/datajoint_analysis_diagram.svg @@ -1,287 +1,164 @@ - - - - + + + + -Arena - - -Arena +VisitSummary + + +VisitSummary - - -Experiment - - -Experiment - - - - - -Arena->Experiment - - - + -ExperimentFoodPatch - - -ExperimentFoodPatch - - - - - -FoodPatchWheel - - -FoodPatchWheel +VisitSubjectPosition + + +VisitSubjectPosition - - -ExperimentFoodPatch->FoodPatchWheel - - - + -WheelState - - -WheelState +VisitSubjectPosition.TimeSlice + + +VisitSubjectPosition.TimeSlice - - -ExperimentFoodPatch->WheelState - - - - -FoodPatchEvent - - -FoodPatchEvent - + + +VisitSubjectPosition->VisitSubjectPosition.TimeSlice + + + +VisitTimeDistribution + + +VisitTimeDistribution + - - -ExperimentFoodPatch->FoodPatchEvent - - - -WeightScale - - -WeightScale + + +ExperimentCamera + + +ExperimentCamera - - -ExperimentWeightScale - - -ExperimentWeightScale + + +CameraTracking + + +CameraTracking - - -WeightScale->ExperimentWeightScale - + + +ExperimentCamera->CameraTracking + - - -Camera - - -Camera + + +Visit + + +Visit - - -ExperimentCamera - - -ExperimentCamera - + + +Visit->VisitSummary + + + +Visit->VisitSubjectPosition + - - -Camera->ExperimentCamera - + + +Visit->VisitTimeDistribution + - + -Epoch - - -Epoch +Place + + +Place + + +Place->Visit + + - + Chunk - - -Chunk + + +Chunk - + -Epoch->Chunk - +Chunk->VisitSubjectPosition + - - -FoodPatch - - -FoodPatch - - - - + -FoodPatch->ExperimentFoodPatch - +Chunk->CameraTracking + - + -CameraTracking - - -CameraTracking +Experiment + + +Experiment - + -ExperimentCamera->CameraTracking - - - - -qc.CameraQC - - -qc.CameraQC - - +Experiment->ExperimentCamera + - + -ExperimentCamera->qc.CameraQC - - - - -CameraTracking.Object - - -CameraTracking.Object - - - - - -CameraTracking->CameraTracking.Object - +Experiment->Chunk + - + -EventType - - -EventType +Experiment.Subject + + +Experiment.Subject - - -EventType->FoodPatchEvent - - - - -Chunk->FoodPatchWheel - - - - -Chunk->CameraTracking - - - - -Chunk->WheelState - - - - -Chunk->qc.CameraQC - - - - -WeightMeasurement - - -WeightMeasurement - - - - - -Chunk->WeightMeasurement - - - - -Chunk->FoodPatchEvent - - - - -Experiment->ExperimentFoodPatch - - - - -Experiment->Epoch - - - - -Experiment->ExperimentCamera - - - - -Experiment->Chunk - - - - -Experiment->ExperimentWeightScale - + + +Experiment->Experiment.Subject + - - -ExperimentWeightScale->WeightMeasurement - + + +Experiment.Subject->Visit + \ No newline at end of file From 472c9533ede51b7af6b08696ef51c879b2eed238 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 3 Aug 2022 18:28:56 -0500 Subject: [PATCH 060/489] add visit analysis to worker process list --- aeon/dj_pipeline/analysis/visit_analysis.py | 2 +- aeon/dj_pipeline/populate/process.py | 14 ++++++-------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 645cd021..16305747 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -82,7 +82,7 @@ def key_source(self): "visit_end BETWEEN chunk_start AND chunk_end", "chunk_start >= visit_start AND chunk_end <= visit_end", ] - & 'experiment_name in ("exp0.2-r0")' + & 'experiment_name in ("exp0.1-r0", "exp0.2-r0")' & "chunk_start < chunk_end" # in some chunks, end timestamp comes before start (timestamp error) ) diff --git a/aeon/dj_pipeline/populate/process.py b/aeon/dj_pipeline/populate/process.py index dcba112b..59c029c6 100644 --- a/aeon/dj_pipeline/populate/process.py +++ b/aeon/dj_pipeline/populate/process.py @@ -69,9 +69,9 @@ high_priority(acquisition.WheelState) high_priority(acquisition.WeightMeasurement) -high_priority(analysis.InArena) -high_priority(analysis.InArenaEnd) -high_priority(analysis.InArenaTimeSlice) +high_priority( + analysis.ingest_environment_visits, experiment_names=[_current_experiment] +) # configure a worker to process mid-priority tasks mid_priority = DataJointWorker( @@ -85,16 +85,14 @@ mid_priority(qc.CameraQC) mid_priority(tracking.CameraTracking) mid_priority(acquisition.FoodPatchWheel) -mid_priority(analysis.InArenaSubjectPosition) -mid_priority(analysis.InArenaTimeDistribution) -mid_priority(analysis.InArenaSummary) -mid_priority(analysis.InArenaRewardRate) +mid_priority(analysis.VisitSubjectPosition) +mid_priority(analysis.VisitTimeDistribution) +mid_priority(analysis.VisitSummary) # report tables mid_priority(report.delete_outdated_plot_entries) mid_priority(report.SubjectRewardRateDifference) mid_priority(report.SubjectWheelTravelledDistance) mid_priority(report.ExperimentTimeDistribution) -mid_priority(report.InArenaSummaryPlot) # ---- some wrappers to support execution as script or CLI From 165ba6ef303dfc08a6de196139017e84b6e14058 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Thu, 4 Aug 2022 00:11:30 +0000 Subject: [PATCH 061/489] =?UTF-8?q?=E2=9C=A8=20add=20plot=5Fsummary=20in?= =?UTF-8?q?=20VisitSummary=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aeon/dj_pipeline/analysis/visit_analysis.py | 95 ++++++++++++++++++++- 1 file changed, 91 insertions(+), 4 deletions(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 4cd12752..b7323636 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -82,7 +82,7 @@ def key_source(self): "visit_end BETWEEN chunk_start AND chunk_end", "chunk_start >= visit_start AND chunk_end <= visit_end", ] - & 'experiment_name in ("exp0.2-r0")' + & 'experiment_name in ("exp0.1-r0", "exp0.2-r0")' & "chunk_start < chunk_end" # in some chunks, end timestamp comes before start (timestamp error) ) @@ -368,7 +368,7 @@ def make(self, key): **food_patch_key, "visit_date": visit_date.date(), "time_fraction_in_patch": in_patch.mean(), - "in_patch": np.array(in_patch.index[np.where(in_patch)]), + "in_patch": in_patch.index.values[in_patch], } ) @@ -380,9 +380,9 @@ def make(self, key): "visit_date": visit_date.date(), "day_duration": day_duration, "time_fraction_in_corridor": in_corridor.mean(), - "in_corridor": np.array(in_corridor.index[np.where(in_corridor)]), + "in_corridor": in_corridor.index.values[in_corridor], "time_fraction_in_arena": in_arena.mean(), - "in_arena": np.array(in_arena.index[np.where(in_arena)]), + "in_arena": in_arena.index.values[in_arena], } ) self.Nest.insert(in_nest_times) @@ -530,3 +530,90 @@ def make(self, key): } ) self.FoodPatch.insert(food_patch_statistics) + + @classmethod + def plot_summary( + cls, + attr, + experiment_name="exp0.2-r0", + duration_crit=24, + per_food_patch=False, + ): + """plot results from the visit summary table + + Args: + attr (str): name of the attribute to plot (e.g., 'pellet_count', 'wheel_distance_travelled', 'total_distance_travelled') + experiment_name (str): name of the experiment. Defaults to "exp0.2-r0". + duration_crit (int, optional): minimum total duration of the visit to plot (in hrs). Defaults to 24. + per_food_patch (bool, optional): separately plot results from different food patch. Defaults to False. + + Returns: + fig: figure object + + Examples: + >>> fig = VisitSummary.plot_summary(attr='pellet_count', per_food_patch=True) + >>> fig = VisitSummary.plot_summary(attr='wheel_distance_travelled', per_food_patch=True) + >>> fig = VisitSummary.plot_summary(attr='total_distance_travelled') + """ + + import matplotlib.pyplot as plt + import seaborn as sns + + df = pd.DataFrame() + visit_dict = ( + VisitEnd + & f'experiment_name="{experiment_name}"' + & f"visit_duration > {duration_crit}" + ).fetch("subject", "visit_start", "visit_end", as_dict=True) + style = "food_patch_description" if per_food_patch else None + + for _ in visit_dict: + + subject, visit_start, visit_end = ( + _["subject"], + _["visit_start"], + _["visit_end"], + ) + restr = { + "experiment_name": experiment_name, + "subject": subject, + "visit_start": visit_start, + "visit_end": visit_end, + } + if per_food_patch: + temp_df = ( + ( + (cls.FoodPatch & restr) + * acquisition.ExperimentFoodPatch.proj("food_patch_description") + ) + .fetch(format="frame") + .reset_index() + ) + else: + temp_df = ((cls & restr)).fetch(format="frame").reset_index() + temp_df["subject"] = "_".join([subject, visit_start.strftime("%m%d")]) + temp_df["day"] = temp_df["visit_date"] - temp_df["visit_date"].min() + temp_df["day"] = temp_df["day"].dt.days + df = pd.concat([df, temp_df], ignore_index=True) + + fig, ax = plt.subplots(figsize=(12, 6)) + sns.lineplot( + data=df, + x="day", + y=attr, + hue="subject", + style=style, + ax=ax, + marker="o", + ) + + ax.legend( + loc="center left", + bbox_to_anchor=(1.01, 0.5), + prop={"size": 12}, + ) + ax.set_ylabel(attr.replace("_", " ")) + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + plt.tight_layout() + return fig From c605d94fb24a1a22a60ba3fc430fc42db8e3c851 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 4 Aug 2022 10:35:53 -0500 Subject: [PATCH 062/489] bugfix in console script for ingestion --- docker/docker-compose.yml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index db43fc05..a6d1868b 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -56,7 +56,7 @@ services: condition: service_started deploy: mode: replicated - replicas: 3 + replicas: 2 command: ["aeon_ingest", "mid_priority", "--duration=-1", "--sleep=3600"] dev: diff --git a/pyproject.toml b/pyproject.toml index 798bf7d5..bce7b420 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ dev = [ ] [project.scripts] -aeon_ingest = "aeon.dj_pipeline.ingest.process:cli" +aeon_ingest = "aeon.dj_pipeline.populate.process:cli" [project.urls] Homepage = "https://sainsburywellcomecentre.github.io/aeon_docs/" From 8b1ded9fd8e289ede670af2e39210be0c2be6e17 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Thu, 4 Aug 2022 16:17:04 +0000 Subject: [PATCH 063/489] =?UTF-8?q?=E2=9C=A8=20plot=20foraging=20bouts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aeon/dj_pipeline/analysis/visit_analysis.py | 152 ++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index b7323636..a3a54b10 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -388,6 +388,157 @@ def make(self, key): self.Nest.insert(in_nest_times) self.FoodPatch.insert(in_food_patch_times) + def _get_foraging_bouts( + row, wheel_dist_crit=1, min_duration=1, using_aeon_io=False + ): + + # Get number of foraging bouts + nb_bouts = 0 + + in_patch = row["in_patch"] + if np.size(in_patch) == 0: # no food patch position timestamps + return nb_bouts + + change_ind = ( + np.where((np.diff(in_patch) / 1e6) > np.timedelta64(20))[0] + 1 + ) # timestamp index where state changes + + print(row["subject"], row["visit_date"], row["food_patch_description"]) + + if np.size(change_ind) == 0: # one contiguous block + + wheel_start, wheel_end = in_patch[0], in_patch[-1] + ts_duration = (wheel_end - wheel_start) / np.timedelta64( + 1, "s" + ) # in seconds + if ts_duration < min_duration: + return nb_bouts + + wheel_data = acquisition.FoodPatchWheel.get_wheel_data( + experiment_name="exp0.2-r0", + start=wheel_start, + end=wheel_end, + patch_name=row["food_patch_description"], + using_aeon_io=using_aeon_io, + ) + if wheel_data.distance_travelled[-1] > wheel_dist_crit: + return nb_bouts + 1 + else: + return nb_bouts + + # fetch contiguous timestamp blocks + for i in range(len(change_ind) + 1): + if i == 0: + ts_array = in_patch[: change_ind[i]] + elif i == len(change_ind): + ts_array = in_patch[change_ind[i - 1] :] + else: + ts_array = in_patch[change_ind[i - 1] : change_ind[i]] + + ts_duration = (ts_array[-1] - ts_array[0]) / np.timedelta64( + 1, "s" + ) # in seconds + if ts_duration < min_duration: + continue + + wheel_start, wheel_end = ts_array[0], ts_array[-1] + if wheel_start > wheel_end: # skip if timestamps were misaligned + continue + + wheel_data = acquisition.FoodPatchWheel.get_wheel_data( + experiment_name="exp0.2-r0", + start=wheel_start, + end=wheel_end, + patch_name=row["food_patch_description"], + using_aeon_io=using_aeon_io, + ) + + if wheel_data.distance_travelled[-1] > wheel_dist_crit: + nb_bouts += 1 + + print(f"nb_bouts = {nb_bouts}") + return nb_bouts + + @classmethod + def plot_foraging_bouts( + cls, + experiment_name="exp0.2-r0", + duration_crit=24, + wheel_dist_crit=1, + min_duration=1, + using_aeon_io=False, + ): + + import matplotlib.pyplot as plt + import seaborn as sns + + df = pd.DataFrame() + visit_dict = ( + VisitEnd + & f'experiment_name="{experiment_name}"' + & f"visit_duration > {duration_crit}" + ).fetch("subject", "visit_start", "visit_end", as_dict=True) + style = "food_patch_description" + + for _ in visit_dict: + + subject, visit_start, visit_end = ( + _["subject"], + _["visit_start"], + _["visit_end"], + ) + restr = { + "experiment_name": experiment_name, + "subject": subject, + "visit_start": visit_start, + "visit_end": visit_end, + } + + temp_df = ( + ( + (cls.FoodPatch & restr) + * acquisition.ExperimentFoodPatch.proj("food_patch_description") + ) + .fetch(format="frame") + .reset_index() + ) + + temp_df["subject"] = "_".join([subject, visit_start.strftime("%m%d")]) + temp_df["day"] = temp_df["visit_date"] - temp_df["visit_date"].min() + temp_df["day"] = temp_df["day"].dt.days + temp_df["foraging_bouts"] = temp_df.apply( + cls._get_foraging_bouts, + wheel_dist_crit=wheel_dist_crit, + min_duration=min_duration, + using_aeon_io=using_aeon_io, + axis=1, + ) + + df = pd.concat([df, temp_df], ignore_index=True) + + fig, ax = plt.subplots(figsize=(12, 6)) + sns.lineplot( + data=df, + x="day", + y="foraging_bouts", + hue="subject", + style=style, + ax=ax, + marker="o", + ) + + ax.legend( + loc="center left", + bbox_to_anchor=(1.01, 0.5), + prop={"size": 12}, + ) + ax.set_ylabel("foraging_bouts".replace("_", " ")) + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + plt.tight_layout() + + return fig + @schema class VisitSummary(dj.Computed): @@ -616,4 +767,5 @@ def plot_summary( ax.spines["top"].set_visible(False) ax.spines["right"].set_visible(False) plt.tight_layout() + return fig From 7bc41cbe669b1241cfeb5a3e5884ee06a6ad7185 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 4 Aug 2022 12:24:09 -0500 Subject: [PATCH 064/489] bugfix --- aeon/dj_pipeline/acquisition.py | 34 +++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index a61a6b81..1963f094 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -631,22 +631,28 @@ def make(self, key): self.insert1(key) self.Message.insert( - { - **key, - "message_time": r.name, - "message": r.message, - "message_type": r.type, - } - for _, r in log_messages.iterrows() + ( + { + **key, + "message_time": r.name, + "message": r.message, + "message_type": r.type, + } + for _, r in log_messages.iterrows() + ), + skip_duplicates=True, ) self.Message.insert( - { - **key, - "message_time": r.name, - "message": r.state, - "message_type": "EnvironmentState", - } - for _, r in state_messages.iterrows() + ( + { + **key, + "message_time": r.name, + "message": r.state, + "message_type": "EnvironmentState", + } + for _, r in state_messages.iterrows() + ), + skip_duplicates=True, ) From 432ebe709ac9d0686ae06664e1d6d86869da280f Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 8 Aug 2022 02:20:09 +0000 Subject: [PATCH 065/489] added plot_summary_per_visit --- aeon/dj_pipeline/utils/plotting.py | 318 ++++++++++++++++++++++++++++- 1 file changed, 315 insertions(+), 3 deletions(-) diff --git a/aeon/dj_pipeline/utils/plotting.py b/aeon/dj_pipeline/utils/plotting.py index 1ba16701..ec475fa3 100644 --- a/aeon/dj_pipeline/utils/plotting.py +++ b/aeon/dj_pipeline/utils/plotting.py @@ -1,12 +1,13 @@ import datajoint as dj +import matplotlib.pyplot as plt import numpy as np import pandas as pd import plotly.express as px import plotly.io as pio -import matplotlib.pyplot as plt - -from aeon.dj_pipeline import lab, acquisition, analysis +from aeon.dj_pipeline import acquisition, analysis, lab +from aeon.dj_pipeline.analysis.visit import Visit, VisitEnd +from aeon.dj_pipeline.analysis.visit_analysis import VisitSummary, VisitTimeDistribution # pio.renderers.default = 'png' # pio.orca.config.executable = '~/.conda/envs/aeon_env/bin/orca' @@ -197,3 +198,314 @@ def plot_average_time_distribution(session_keys): fig.update_xaxes(tickangle=45) return fig + + +def plot_visit_summary( + visit_key, + attr, + experiment_name="exp0.2-r0", + duration_crit=24, + per_food_patch=False, +): + """plot results from the visit summary table + + Args: + attr (str): name of the attribute to plot (e.g., 'pellet_count', 'wheel_distance_travelled', 'total_distance_travelled') + experiment_name (str): name of the experiment. Defaults to "exp0.2-r0". + duration_crit (int, optional): minimum total duration of the visit to plot (in hrs). Defaults to 24. + per_food_patch (bool, optional): separately plot results from different food patch. Defaults to False. + + Returns: + fig: figure object + + Examples: + >>> fig = VisitSummary.plot_summary(attr='pellet_count', per_food_patch=True) + >>> fig = VisitSummary.plot_summary(attr='wheel_distance_travelled', per_food_patch=True) + >>> fig = VisitSummary.plot_summary(attr='total_distance_travelled') + """ + + import matplotlib.pyplot as plt + import seaborn as sns + + df = pd.DataFrame() + visit_dict = ( + VisitEnd + & f'experiment_name="{experiment_name}"' + & f"visit_duration > {duration_crit}" + ).fetch("subject", "visit_start", "visit_end", as_dict=True) + style = "food_patch_description" if per_food_patch else None + + for _ in visit_dict: + + subject, visit_start, visit_end = ( + _["subject"], + _["visit_start"], + _["visit_end"], + ) + restr = { + "experiment_name": experiment_name, + "subject": subject, + "visit_start": visit_start, + "visit_end": visit_end, + } + if per_food_patch: + visit_per_day_df = ( + ( + (table.FoodPatch & restr) + * acquisition.ExperimentFoodPatch.proj("food_patch_description") + ) + .fetch(format="frame") + .reset_index() + ) + else: + visit_per_day_df = ((table & restr)).fetch(format="frame").reset_index() + visit_per_day_df["subject"] = "_".join([subject, visit_start.strftime("%m%d")]) + visit_per_day_df["day"] = ( + visit_per_day_df["visit_date"] - visit_per_day_df["visit_date"].min() + ) + visit_per_day_df["day"] = visit_per_day_df["day"].dt.days + df = pd.concat([df, visit_per_day_df], ignore_index=True) + + fig, ax = plt.subplots(figsize=(12, 6)) + sns.lineplot( + data=df, + x="day", + y=attr, + hue="subject", + style=style, + ax=ax, + marker="o", + ) + + ax.legend( + loc="center left", + bbox_to_anchor=(1.01, 0.5), + prop={"size": 12}, + ) + ax.set_ylabel(attr.replace("_", " ")) + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + plt.tight_layout() + + return fig + + +def plot_summary_per_visit( + visit_key, + attr, + per_food_patch=False, +): + """plot results from VisitSummary per visit + + Args: + visit_key (dict) : key from the VisitSummary table + attr (str): name of the attribute to plot (e.g., 'pellet_count', 'wheel_distance_travelled', 'total_distance_travelled') + per_food_patch (bool, optional): separately plot results from different food patch. Defaults to False. + + Returns: + fig: figure object + + Examples: + >>> fig = plot_summary_per_visit(VisitSummary, visit_key, attr='pellet_count', per_food_patch=True) + >>> fig = plot_summary_per_visit(VisitSummary, visit_key, attr='wheel_distance_travelled', per_food_patch=True) + >>> fig = plot_summary_per_visit(VisitSummary, visit_key, attr='total_distance_travelled') + """ + + import matplotlib.pyplot as plt + import seaborn as sns + + subject, visit_start = ( + visit_key["subject"], + visit_key["visit_start"], + ) + style = "food_patch_description" if per_food_patch else None + + if per_food_patch: # split by food patch + visit_per_day_df = ( + ( + (VisitSummary.FoodPatch & visit_key) + * acquisition.ExperimentFoodPatch.proj("food_patch_description") + ) + .fetch(format="frame") + .reset_index() + ) + else: + visit_per_day_df = ((VisitSummary & visit_key)).fetch(format="frame").reset_index() + if not attr.startswith("total"): + attr = "total_" + attr + + visit_per_day_df["subject"] = "_".join([subject, visit_start.strftime("%m%d")]) + visit_per_day_df["day"] = ( + visit_per_day_df["visit_date"] - visit_per_day_df["visit_date"].min() + ) + visit_per_day_df["day"] = visit_per_day_df["day"].dt.days + + fig, ax = plt.subplots(figsize=(12, 6)) + sns.lineplot( + data=visit_per_day_df, + x="day", + y=attr, + hue="subject", + style=style, + ax=ax, + marker="o", + ) + + ax.legend( + loc="center left", + bbox_to_anchor=(1.01, 0.5), + prop={"size": 12}, + ) + ax.set_ylabel(attr.replace("_", " ")) + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + plt.tight_layout() + + return fig + + +def _get_foraging_bouts(row, wheel_dist_crit=1, min_duration=1, using_aeon_io=False): + + # Get number of foraging bouts + nb_bouts = 0 + + in_patch = row["in_patch"] + if np.size(in_patch) == 0: # no food patch position timestamps + return nb_bouts + + change_ind = ( + np.where((np.diff(in_patch) / 1e6) > np.timedelta64(20))[0] + 1 + ) # timestamp index where state changes + + print(row["subject"], row["visit_date"], row["food_patch_description"]) + + if np.size(change_ind) == 0: # one contiguous block + + wheel_start, wheel_end = in_patch[0], in_patch[-1] + ts_duration = (wheel_end - wheel_start) / np.timedelta64(1, "s") # in seconds + if ts_duration < min_duration: + return nb_bouts + + wheel_data = acquisition.FoodPatchWheel.get_wheel_data( + experiment_name="exp0.2-r0", + start=wheel_start, + end=wheel_end, + patch_name=row["food_patch_description"], + using_aeon_io=using_aeon_io, + ) + if wheel_data.distance_travelled[-1] > wheel_dist_crit: + return nb_bouts + 1 + else: + return nb_bouts + + # fetch contiguous timestamp blocks + for i in range(len(change_ind) + 1): + if i == 0: + ts_array = in_patch[: change_ind[i]] + elif i == len(change_ind): + ts_array = in_patch[change_ind[i - 1] :] + else: + ts_array = in_patch[change_ind[i - 1] : change_ind[i]] + + ts_duration = (ts_array[-1] - ts_array[0]) / np.timedelta64( + 1, "s" + ) # in seconds + if ts_duration < min_duration: + continue + + wheel_start, wheel_end = ts_array[0], ts_array[-1] + if wheel_start > wheel_end: # skip if timestamps were misaligned + continue + + wheel_data = acquisition.FoodPatchWheel.get_wheel_data( + experiment_name="exp0.2-r0", + start=wheel_start, + end=wheel_end, + patch_name=row["food_patch_description"], + using_aeon_io=using_aeon_io, + ) + + if wheel_data.distance_travelled[-1] > wheel_dist_crit: + nb_bouts += 1 + + print(f"nb_bouts = {nb_bouts}") + return nb_bouts + + +def plot_foraging_bouts( + cls, + experiment_name="exp0.2-r0", + duration_crit=24, + wheel_dist_crit=1, + min_duration=1, + using_aeon_io=False, +): + + import matplotlib.pyplot as plt + import seaborn as sns + + df = pd.DataFrame() + visit_dict = ( + VisitEnd + & f'experiment_name="{experiment_name}"' + & f"visit_duration > {duration_crit}" + ).fetch("subject", "visit_start", "visit_end", as_dict=True) + + for _ in visit_dict: + + subject, visit_start, visit_end = ( + _["subject"], + _["visit_start"], + _["visit_end"], + ) + restr = { + "experiment_name": experiment_name, + "subject": subject, + "visit_start": visit_start, + "visit_end": visit_end, + } + + temp_df = ( + ( + (cls.FoodPatch & restr) + * acquisition.ExperimentFoodPatch.proj("food_patch_description") + ) + .fetch(format="frame") + .reset_index() + ) + + temp_df["subject"] = "_".join([subject, visit_start.strftime("%m%d")]) + temp_df["day"] = temp_df["visit_date"] - temp_df["visit_date"].min() + temp_df["day"] = temp_df["day"].dt.days + temp_df["foraging_bouts"] = temp_df.apply( + cls._get_foraging_bouts, + wheel_dist_crit=wheel_dist_crit, + min_duration=min_duration, + using_aeon_io=using_aeon_io, + axis=1, + ) + + df = pd.concat([df, temp_df], ignore_index=True) + + fig, ax = plt.subplots(figsize=(12, 6)) + sns.lineplot( + data=df, + x="day", + y="foraging_bouts", + hue="subject", + style="food_patch_description", + ax=ax, + marker="o", + ) + + ax.legend( + loc="center left", + bbox_to_anchor=(1.01, 0.5), + prop={"size": 12}, + ) + ax.set_ylabel("foraging_bouts".replace("_", " ")) + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + plt.tight_layout() + + return fig From 34ca916cc9b93418886e25d7b3b4587ca7813e61 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 8 Aug 2022 15:21:21 +0000 Subject: [PATCH 066/489] added plot_foraging_bouts --- aeon/dj_pipeline/analysis/visit_analysis.py | 193 ++------------ aeon/dj_pipeline/utils/plotting.py | 178 ++++--------- aeon/dj_pipeline/visit_report.py | 263 ++++++++++++++++++++ fig.png | Bin 0 -> 115676 bytes 4 files changed, 328 insertions(+), 306 deletions(-) create mode 100644 aeon/dj_pipeline/visit_report.py create mode 100644 fig.png diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index a3a54b10..d4b944e8 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -388,14 +388,19 @@ def make(self, key): self.Nest.insert(in_nest_times) self.FoodPatch.insert(in_food_patch_times) + @classmethod def _get_foraging_bouts( - row, wheel_dist_crit=1, min_duration=1, using_aeon_io=False + cls, + visit_per_day_row, + wheel_dist_crit=None, + min_bout_duration=None, + using_aeon_io=False, ): # Get number of foraging bouts nb_bouts = 0 - in_patch = row["in_patch"] + in_patch = visit_per_day_row["in_patch"] if np.size(in_patch) == 0: # no food patch position timestamps return nb_bouts @@ -403,7 +408,11 @@ def _get_foraging_bouts( np.where((np.diff(in_patch) / 1e6) > np.timedelta64(20))[0] + 1 ) # timestamp index where state changes - print(row["subject"], row["visit_date"], row["food_patch_description"]) + # print( + # visit_per_day_row["subject"], + # visit_per_day_row["visit_date"], + # visit_per_day_row["food_patch_description"], + # ) if np.size(change_ind) == 0: # one contiguous block @@ -411,14 +420,14 @@ def _get_foraging_bouts( ts_duration = (wheel_end - wheel_start) / np.timedelta64( 1, "s" ) # in seconds - if ts_duration < min_duration: + if ts_duration < min_bout_duration: return nb_bouts wheel_data = acquisition.FoodPatchWheel.get_wheel_data( experiment_name="exp0.2-r0", start=wheel_start, end=wheel_end, - patch_name=row["food_patch_description"], + patch_name=visit_per_day_row["food_patch_description"], using_aeon_io=using_aeon_io, ) if wheel_data.distance_travelled[-1] > wheel_dist_crit: @@ -438,7 +447,7 @@ def _get_foraging_bouts( ts_duration = (ts_array[-1] - ts_array[0]) / np.timedelta64( 1, "s" ) # in seconds - if ts_duration < min_duration: + if ts_duration < min_bout_duration: continue wheel_start, wheel_end = ts_array[0], ts_array[-1] @@ -449,96 +458,16 @@ def _get_foraging_bouts( experiment_name="exp0.2-r0", start=wheel_start, end=wheel_end, - patch_name=row["food_patch_description"], + patch_name=visit_per_day_row["food_patch_description"], using_aeon_io=using_aeon_io, ) if wheel_data.distance_travelled[-1] > wheel_dist_crit: nb_bouts += 1 - print(f"nb_bouts = {nb_bouts}") + # print(f"nb_bouts = {nb_bouts}") return nb_bouts - @classmethod - def plot_foraging_bouts( - cls, - experiment_name="exp0.2-r0", - duration_crit=24, - wheel_dist_crit=1, - min_duration=1, - using_aeon_io=False, - ): - - import matplotlib.pyplot as plt - import seaborn as sns - - df = pd.DataFrame() - visit_dict = ( - VisitEnd - & f'experiment_name="{experiment_name}"' - & f"visit_duration > {duration_crit}" - ).fetch("subject", "visit_start", "visit_end", as_dict=True) - style = "food_patch_description" - - for _ in visit_dict: - - subject, visit_start, visit_end = ( - _["subject"], - _["visit_start"], - _["visit_end"], - ) - restr = { - "experiment_name": experiment_name, - "subject": subject, - "visit_start": visit_start, - "visit_end": visit_end, - } - - temp_df = ( - ( - (cls.FoodPatch & restr) - * acquisition.ExperimentFoodPatch.proj("food_patch_description") - ) - .fetch(format="frame") - .reset_index() - ) - - temp_df["subject"] = "_".join([subject, visit_start.strftime("%m%d")]) - temp_df["day"] = temp_df["visit_date"] - temp_df["visit_date"].min() - temp_df["day"] = temp_df["day"].dt.days - temp_df["foraging_bouts"] = temp_df.apply( - cls._get_foraging_bouts, - wheel_dist_crit=wheel_dist_crit, - min_duration=min_duration, - using_aeon_io=using_aeon_io, - axis=1, - ) - - df = pd.concat([df, temp_df], ignore_index=True) - - fig, ax = plt.subplots(figsize=(12, 6)) - sns.lineplot( - data=df, - x="day", - y="foraging_bouts", - hue="subject", - style=style, - ax=ax, - marker="o", - ) - - ax.legend( - loc="center left", - bbox_to_anchor=(1.01, 0.5), - prop={"size": 12}, - ) - ax.set_ylabel("foraging_bouts".replace("_", " ")) - ax.spines["top"].set_visible(False) - ax.spines["right"].set_visible(False) - plt.tight_layout() - - return fig - @schema class VisitSummary(dj.Computed): @@ -681,91 +610,3 @@ def make(self, key): } ) self.FoodPatch.insert(food_patch_statistics) - - @classmethod - def plot_summary( - cls, - attr, - experiment_name="exp0.2-r0", - duration_crit=24, - per_food_patch=False, - ): - """plot results from the visit summary table - - Args: - attr (str): name of the attribute to plot (e.g., 'pellet_count', 'wheel_distance_travelled', 'total_distance_travelled') - experiment_name (str): name of the experiment. Defaults to "exp0.2-r0". - duration_crit (int, optional): minimum total duration of the visit to plot (in hrs). Defaults to 24. - per_food_patch (bool, optional): separately plot results from different food patch. Defaults to False. - - Returns: - fig: figure object - - Examples: - >>> fig = VisitSummary.plot_summary(attr='pellet_count', per_food_patch=True) - >>> fig = VisitSummary.plot_summary(attr='wheel_distance_travelled', per_food_patch=True) - >>> fig = VisitSummary.plot_summary(attr='total_distance_travelled') - """ - - import matplotlib.pyplot as plt - import seaborn as sns - - df = pd.DataFrame() - visit_dict = ( - VisitEnd - & f'experiment_name="{experiment_name}"' - & f"visit_duration > {duration_crit}" - ).fetch("subject", "visit_start", "visit_end", as_dict=True) - style = "food_patch_description" if per_food_patch else None - - for _ in visit_dict: - - subject, visit_start, visit_end = ( - _["subject"], - _["visit_start"], - _["visit_end"], - ) - restr = { - "experiment_name": experiment_name, - "subject": subject, - "visit_start": visit_start, - "visit_end": visit_end, - } - if per_food_patch: - temp_df = ( - ( - (cls.FoodPatch & restr) - * acquisition.ExperimentFoodPatch.proj("food_patch_description") - ) - .fetch(format="frame") - .reset_index() - ) - else: - temp_df = ((cls & restr)).fetch(format="frame").reset_index() - temp_df["subject"] = "_".join([subject, visit_start.strftime("%m%d")]) - temp_df["day"] = temp_df["visit_date"] - temp_df["visit_date"].min() - temp_df["day"] = temp_df["day"].dt.days - df = pd.concat([df, temp_df], ignore_index=True) - - fig, ax = plt.subplots(figsize=(12, 6)) - sns.lineplot( - data=df, - x="day", - y=attr, - hue="subject", - style=style, - ax=ax, - marker="o", - ) - - ax.legend( - loc="center left", - bbox_to_anchor=(1.01, 0.5), - prop={"size": 12}, - ) - ax.set_ylabel(attr.replace("_", " ")) - ax.spines["top"].set_visible(False) - ax.spines["right"].set_visible(False) - plt.tight_layout() - - return fig diff --git a/aeon/dj_pipeline/utils/plotting.py b/aeon/dj_pipeline/utils/plotting.py index ec475fa3..0363ae86 100644 --- a/aeon/dj_pipeline/utils/plotting.py +++ b/aeon/dj_pipeline/utils/plotting.py @@ -4,6 +4,7 @@ import pandas as pd import plotly.express as px import plotly.io as pio +import seaborn as sns from aeon.dj_pipeline import acquisition, analysis, lab from aeon.dj_pipeline.analysis.visit import Visit, VisitEnd @@ -224,9 +225,6 @@ def plot_visit_summary( >>> fig = VisitSummary.plot_summary(attr='total_distance_travelled') """ - import matplotlib.pyplot as plt - import seaborn as sns - df = pd.DataFrame() visit_dict = ( VisitEnd @@ -298,22 +296,19 @@ def plot_summary_per_visit( """plot results from VisitSummary per visit Args: - visit_key (dict) : key from the VisitSummary table - attr (str): name of the attribute to plot (e.g., 'pellet_count', 'wheel_distance_travelled', 'total_distance_travelled') - per_food_patch (bool, optional): separately plot results from different food patch. Defaults to False. + visit_key (dict) : Key from the VisitSummary table + attr (str): Name of the attribute to plot (e.g., 'pellet_count', 'wheel_distance_travelled', 'total_distance_travelled') + per_food_patch (bool, optional): Separately plot results from different food patch. Defaults to False. Returns: - fig: figure object + fig: Figure object Examples: - >>> fig = plot_summary_per_visit(VisitSummary, visit_key, attr='pellet_count', per_food_patch=True) - >>> fig = plot_summary_per_visit(VisitSummary, visit_key, attr='wheel_distance_travelled', per_food_patch=True) - >>> fig = plot_summary_per_visit(VisitSummary, visit_key, attr='total_distance_travelled') + >>> fig = plot_summary_per_visit(visit_key, attr='pellet_count', per_food_patch=True) + >>> fig = plot_summary_per_visit(visit_key, attr='wheel_distance_travelled', per_food_patch=True) + >>> fig = plot_summary_per_visit(visit_key, attr='total_distance_travelled') """ - import matplotlib.pyplot as plt - import seaborn as sns - subject, visit_start = ( visit_key["subject"], visit_key["visit_start"], @@ -330,7 +325,9 @@ def plot_summary_per_visit( .reset_index() ) else: - visit_per_day_df = ((VisitSummary & visit_key)).fetch(format="frame").reset_index() + visit_per_day_df = ( + ((VisitSummary & visit_key)).fetch(format="frame").reset_index() + ) if not attr.startswith("total"): attr = "total_" + attr @@ -345,7 +342,6 @@ def plot_summary_per_visit( data=visit_per_day_df, x="day", y=attr, - hue="subject", style=style, ax=ax, marker="o", @@ -364,135 +360,57 @@ def plot_summary_per_visit( return fig -def _get_foraging_bouts(row, wheel_dist_crit=1, min_duration=1, using_aeon_io=False): - - # Get number of foraging bouts - nb_bouts = 0 - - in_patch = row["in_patch"] - if np.size(in_patch) == 0: # no food patch position timestamps - return nb_bouts - - change_ind = ( - np.where((np.diff(in_patch) / 1e6) > np.timedelta64(20))[0] + 1 - ) # timestamp index where state changes - - print(row["subject"], row["visit_date"], row["food_patch_description"]) - - if np.size(change_ind) == 0: # one contiguous block - - wheel_start, wheel_end = in_patch[0], in_patch[-1] - ts_duration = (wheel_end - wheel_start) / np.timedelta64(1, "s") # in seconds - if ts_duration < min_duration: - return nb_bouts - - wheel_data = acquisition.FoodPatchWheel.get_wheel_data( - experiment_name="exp0.2-r0", - start=wheel_start, - end=wheel_end, - patch_name=row["food_patch_description"], - using_aeon_io=using_aeon_io, - ) - if wheel_data.distance_travelled[-1] > wheel_dist_crit: - return nb_bouts + 1 - else: - return nb_bouts - - # fetch contiguous timestamp blocks - for i in range(len(change_ind) + 1): - if i == 0: - ts_array = in_patch[: change_ind[i]] - elif i == len(change_ind): - ts_array = in_patch[change_ind[i - 1] :] - else: - ts_array = in_patch[change_ind[i - 1] : change_ind[i]] - - ts_duration = (ts_array[-1] - ts_array[0]) / np.timedelta64( - 1, "s" - ) # in seconds - if ts_duration < min_duration: - continue - - wheel_start, wheel_end = ts_array[0], ts_array[-1] - if wheel_start > wheel_end: # skip if timestamps were misaligned - continue - - wheel_data = acquisition.FoodPatchWheel.get_wheel_data( - experiment_name="exp0.2-r0", - start=wheel_start, - end=wheel_end, - patch_name=row["food_patch_description"], - using_aeon_io=using_aeon_io, - ) - - if wheel_data.distance_travelled[-1] > wheel_dist_crit: - nb_bouts += 1 - - print(f"nb_bouts = {nb_bouts}") - return nb_bouts - - def plot_foraging_bouts( - cls, - experiment_name="exp0.2-r0", - duration_crit=24, - wheel_dist_crit=1, - min_duration=1, + visit_key, + wheel_dist_crit=None, + min_bout_duration=None, using_aeon_io=False, ): + """plot the number of foraging bouts per visit - import matplotlib.pyplot as plt - import seaborn as sns - - df = pd.DataFrame() - visit_dict = ( - VisitEnd - & f'experiment_name="{experiment_name}"' - & f"visit_duration > {duration_crit}" - ).fetch("subject", "visit_start", "visit_end", as_dict=True) - - for _ in visit_dict: + Args: + visit_key (dict) : Key from the VisitTimeDistribution table + wheel_dist_crit (int) : Minimum wheel distance travelled (in cm) + min_bout_duration (int) : Minimum foraging bout duration (in seconds) + using_aeon_io (bool) : Use aeon api to calculate wheel distance. Otherwise use datajoint tables. Defaults to False. - subject, visit_start, visit_end = ( - _["subject"], - _["visit_start"], - _["visit_end"], - ) - restr = { - "experiment_name": experiment_name, - "subject": subject, - "visit_start": visit_start, - "visit_end": visit_end, - } + Returns: + fig: Figure object - temp_df = ( - ( - (cls.FoodPatch & restr) - * acquisition.ExperimentFoodPatch.proj("food_patch_description") - ) - .fetch(format="frame") - .reset_index() - ) + Examples: + >>> fig = plot_foraging_bouts(visit_key, wheel_dist_crit=1, min_bout_duration=1) + """ + + subject, visit_start = ( + visit_key["subject"], + visit_key["visit_start"], + ) - temp_df["subject"] = "_".join([subject, visit_start.strftime("%m%d")]) - temp_df["day"] = temp_df["visit_date"] - temp_df["visit_date"].min() - temp_df["day"] = temp_df["day"].dt.days - temp_df["foraging_bouts"] = temp_df.apply( - cls._get_foraging_bouts, - wheel_dist_crit=wheel_dist_crit, - min_duration=min_duration, - using_aeon_io=using_aeon_io, - axis=1, + visit_per_day_df = ( + ( + (VisitTimeDistribution.FoodPatch & visit_key) + * acquisition.ExperimentFoodPatch.proj("food_patch_description") ) + .fetch(format="frame") + .reset_index() + ) - df = pd.concat([df, temp_df], ignore_index=True) + visit_per_day_df["subject"] = "_".join([subject, visit_start.strftime("%m%d")]) + visit_per_day_df["day"] = ( + visit_per_day_df["visit_date"] - visit_per_day_df["visit_date"].min() + ) + visit_per_day_df["day"] = visit_per_day_df["day"].dt.days + visit_per_day_df["foraging_bouts"] = visit_per_day_df.apply( + VisitTimeDistribution._get_foraging_bouts, + args=(wheel_dist_crit, min_bout_duration, using_aeon_io), + axis=1, + ) fig, ax = plt.subplots(figsize=(12, 6)) sns.lineplot( - data=df, + data=visit_per_day_df, x="day", y="foraging_bouts", - hue="subject", style="food_patch_description", ax=ax, marker="o", diff --git a/aeon/dj_pipeline/visit_report.py b/aeon/dj_pipeline/visit_report.py new file mode 100644 index 00000000..6cb503c8 --- /dev/null +++ b/aeon/dj_pipeline/visit_report.py @@ -0,0 +1,263 @@ +import datetime +import json +import os +import pathlib +import re + +import datajoint as dj +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd + +from aeon.analysis import plotting as analysis_plotting +from aeon.dj_pipeline.analysis.visit import Visit, VisitEnd +from aeon.dj_pipeline.analysis.visit_analysis import * + +from . import acquisition, analysis, get_schema_name + +# schema = dj.schema(get_schema_name("report")) +schema = dj.schema(get_schema_name("u_jaeronga_test")) +os.environ["DJ_SUPPORT_FILEPATH_MANAGEMENT"] = "TRUE" + +experiment_name = "exp0.2-r0" +MIN_VISIT_DURATION = 24 # in hours (minimum duration of visit for analysis) +WHEEL_DIST_CRIT = 1 # in cm (minimum wheel distance travelled) +MIN_BOUT_DURATION = 1 # in seconds (minimum foraging bout duration) + + +""" +DataJoint schema dedicated for tables containing figures +""" + + +@schema +class VisitSummaryPlot(dj.Computed): + definition = """ + -> VisitSummary + --- + pellet_count_png: attach + wheel_distance_travelled_png: attach + total_distance_travelled_png: attach + """ + + key_source = dj.U("experiment_name", "subject", "visit_start", "visit_end") & ( + VisitEnd + & f'experiment_name="{experiment_name}"' + & f"visit_duration > {MIN_VISIT_DURATION}" + ) + + def make(self, key): + from aeon.dj_pipeline.utils.plotting import plot_summary_per_visit + + fig = plot_summary_per_visit( + key, + attr="pellet_count", + per_food_patch=True, + ) + + fig = plot_summary_per_visit( + key, + attr="wheel_distance_travelled", + per_food_patch=True, + ) + + fig = plot_summary_per_visit( + key, + attr="total_distance_travelled", + per_food_patch=False, + ) + + # ---- Save fig and insert ---- + save_dir = _make_path(key) + fig_dict = _save_figs( + (fig,), ("summary_plot_png",), save_dir=save_dir, prefix=save_dir.name + ) + + self.insert1({**key, **fig_dict}) + + +# ---- Dynamically updated tables for plots ---- + + +@schema +class SubjectRewardRateDifference(dj.Computed): + definition = """ + -> acquisition.Experiment.Subject + --- + in_arena_count: int + reward_rate_difference_plotly: longblob # dictionary storing the plotly object (from fig.to_plotly_json()) + """ + + key_source = acquisition.Experiment.Subject & analysis.InArenaRewardRate + + def make(self, key): + from aeon.dj_pipeline.utils.plotting import plot_reward_rate_differences + + fig = plot_reward_rate_differences(key) + + fig_json = json.loads(fig.to_json()) + + self.insert1( + { + **key, + "in_arena_count": len(analysis.InArenaRewardRate & key), + "reward_rate_difference_plotly": fig_json, + } + ) + + @classmethod + def delete_outdated_entries(cls): + """ + Each entry in this table correspond to one subject. However, the plot is capturing + data for all sessions. + Hence a dynamic update routine is needed to recompute the plot as new sessions + become available + """ + outdated_entries = ( + cls + * ( + acquisition.Experiment.Subject.aggr( + analysis.InArenaRewardRate, + current_in_arena_count="count(in_arena_start)", + ) + ) + & "in_arena_count != current_in_arena_count" + ) + with dj.config(safemode=False): + (cls & outdated_entries.fetch("KEY")).delete() + + +@schema +class SubjectWheelTravelledDistance(dj.Computed): + definition = """ + -> acquisition.Experiment.Subject + --- + in_arena_count: int + wheel_travelled_distance_plotly: longblob # dictionary storing the plotly object (from fig.to_plotly_json()) + """ + + key_source = acquisition.Experiment.Subject & analysis.InArenaSummary + + def make(self, key): + from aeon.dj_pipeline.utils.plotting import plot_wheel_travelled_distance + + in_arena_keys = (analysis.InArenaSummary & key).fetch("KEY") + + fig = plot_wheel_travelled_distance(in_arena_keys) + + fig_json = json.loads(fig.to_json()) + + self.insert1( + { + **key, + "in_arena_count": len(in_arena_keys), + "wheel_travelled_distance_plotly": fig_json, + } + ) + + @classmethod + def delete_outdated_entries(cls): + """ + Each entry in this table correspond to one subject. However the plot is capturing + data for all sessions. + Hence a dynamic update routine is needed to recompute the plot as new sessions + become available + """ + outdated_entries = ( + cls + * ( + acquisition.Experiment.Subject.aggr( + analysis.InArenaSummary, + current_in_arena_count="count(in_arena_start)", + ) + ) + & "in_arena_count != current_in_arena_count" + ) + with dj.config(safemode=False): + (cls & outdated_entries.fetch("KEY")).delete() + + +@schema +class ExperimentTimeDistribution(dj.Computed): + definition = """ + -> acquisition.Experiment + --- + in_arena_count: int + time_distribution_plotly: longblob # dictionary storing the plotly object (from fig.to_plotly_json()) + """ + + def make(self, key): + from aeon.dj_pipeline.utils.plotting import plot_average_time_distribution + + in_arena_keys = (analysis.InArenaTimeDistribution & key).fetch("KEY") + + fig = plot_average_time_distribution(in_arena_keys) + + fig_json = json.loads(fig.to_json()) + + self.insert1( + { + **key, + "in_arena_count": len(in_arena_keys), + "time_distribution_plotly": fig_json, + } + ) + + @classmethod + def delete_outdated_entries(cls): + """ + Each entry in this table correspond to one subject. However the plot is capturing + data for all sessions. + Hence a dynamic update routine is needed to recompute the plot as new sessions + become available + """ + outdated_entries = ( + cls + * ( + acquisition.Experiment.aggr( + analysis.InArenaTimeDistribution, + current_in_arena_count="count(in_arena_start)", + ) + ) + & "in_arena_count != current_in_arena_count" + ) + with dj.config(safemode=False): + (cls & outdated_entries.fetch("KEY")).delete() + + +def delete_outdated_plot_entries(): + for tbl in ( + SubjectRewardRateDifference, + SubjectWheelTravelledDistance, + ExperimentTimeDistribution, + ): + tbl.delete_outdated_entries() + + +# ---------- HELPER FUNCTIONS -------------- + + +def _make_path(in_arena_key): + store_stage = pathlib.Path(dj.config["stores"]["djstore"]["stage"]) + experiment_name, subject, in_arena_start = (analysis.InArena & in_arena_key).fetch1( + "experiment_name", "subject", "in_arena_start" + ) + output_dir = ( + store_stage + / experiment_name + / subject + / in_arena_start.strftime("%y%m%d_%H%M%S_%f") + ) + output_dir.mkdir(parents=True, exist_ok=True) + return output_dir + + +def _save_figs(figs, fig_names, save_dir, prefix, extension=".png"): + fig_dict = {} + for fig, figname in zip(figs, fig_names): + fig_fp = save_dir / (prefix + "_" + figname + extension) + fig.tight_layout() + fig.savefig(fig_fp, dpi=300) + fig_dict[figname] = fig_fp.as_posix() + + return fig_dict diff --git a/fig.png b/fig.png new file mode 100644 index 0000000000000000000000000000000000000000..b0f68121f5df041d0636a2011093b81e7f3bf65d GIT binary patch literal 115676 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

    o zlZqkt0GfvuioyAUnZtq0b@6`zjjX#Rm%}? z=hEnr=sA3D-$7%kI%WwEN%C$(QgIHbHo!est6ukYb_}F`c56!$gRHBVMIuBC>!syD zPt^SL$wLVo4`|7=b`B#TU@{2>OH8KxDTj24jO>C7x?AEp{QxlL)Nd_oZ;=rr z*O``j;<9X+FE(dqUV8bg2{{1jIkMT3jLLdhZx11!|AC5cVF1)t0TS)g`SgMhEx13|*#J#fo zdGR`Q??I%GB_|*4dQI5i=D1r{dba_Izb;Wd!r3-Ob+H=NGH^3^-8ow^ zY|Zw#*rIP=JMAP{_Yk&9^*!ab%hvL}2LZ^Kw_G4#q>bx|)cgArtdGWVH+&e6vC3n7 zPk1X+j`d>ih%=;XxDP%(jtU6c{WfyNpEdnY{4s6Z4By#Z#wdP;@)O>VY?z`9a<#he zxye0b_QC?MwHmwTN%MbC7t_!(odXLTI*8?wQ+^&e+?|SY;7~|QQ%K{4M{J)=my1eG z6S9~GB80CJ@;X3T*4wN4{OTK7!n#r^oZ`d&nj-7_2(?=AV@Q{Y4=-HDCHJZddhY=& z;{7E1oUg{;7gT{i4g9Xs^-NA#bm6aT91anH-5myC6JNfsI0m8d7y`vfE=O+cOjGTWu!XXj;2jd|U7w$vSv0>MnSM(^~| zk$$=8TD7*w$h4#3zc8{yc<+H8$HSWNKL{2iX65Su+J5Kau+ zXE(yYba9Ic@+q`Dye#~O>64({3QyZ~@{s{ZnEtjUf>%V=oSAHa0@E>XY;Wrww^weV z4yxxVSF_h+T87r++k^SqmaY%bZC(yKp^;a>Sf#N4*|KgIET8$OR?9KwN!R1RSb4vW zt|8_0{r{ort)k*=yI?`w-JL*i3ogNe2X|{6f)m``-8Hyda0%`jTtaYncW9h>^Zj$q ztmzw8UvSfp?Oj#7s(i|9+Uw4~O#PvL4-Du`N09OC4_5dLS@Wuuvqzn}#q|C*Yt@xl zI!7D$X0XK6r+b{skMEkDlGI|;BGWoAlk6ge9HDwuOZIOd?>pTcQjCAMA$(o4L;pTt z!%6h$3>J0W(EidS1RWyRn@2Qt1M1XtsAZCbK2}NYa-#i*o}$2=!rczPHcZ8HF8v;# zBbfc8ytqucXO)0o>fj2C;k!+@vOg(WGd2hSnNMB9t!l(OzD<15K!5@SYnPr2jcc_w z3y)<+RQ_rfE2N$c6JYM8EBp%c8~`0fGUn%p77ZsB@J&@gg9gwNMYOud{+Pa@dTBUq zcQb^B^S&1T^8=7c0%&YJaE)YOtrXi%IrFk2C*!f2@wB9xC?@Z8_cgiQymfEXVDKUU zTwaJ^J8$Rrk76Yg1GAH4{zZ!xB&&?VNu9y5-@AH8sytBF!W1kc(>;1KJ%LBGIOv(T z%{8RFU~05Cp=1h`GS4S%xcZxHamp1d}V{%KyrIZCGwtZoGDfH>4NKS8KvHUCA|rIY_u=N7vzzZvqfLN)GuXQQVx(2hRtbR%Nv zn|RgyZt`^QdJCKvzUHyp(N&F4%_v~F_&b@Y-v90sMlFP~sBQgGh4vxGti#p4(WO*_ z!%exrg!yI-4|h@teOMS*s8M(HmvY~0M9}#afwMF@Lp$A0m zQDTTW$BK#5riQXJ<@V_vCKi~&glYZGx`BaBc0-801Mdh?*50~{4;G{rHoZ3faANtd z{CU3ID~3 z(3G3{39-5gTOswexdFCN#r%TS{+d>4spp5hk~Bk2Teo$QbdAXfagFiGq4){KN92^X zTP71WZEda-6hdjZjN8huCkGXKOxO|$nY3|^j;~fK1s@@6-5iPs{V6yP*W16=4=|ZA zo^*d!R8gaWyc0Nl1_gxyXI*4>w?v8M3|Z=X;kVuBRNnWfF~lgSs3kd@-}02z`V=q6 zrpC5wJyr?VgPw93GOv?~9Q@ZH)+1@ehlQ%RB(E!%1ZHW4Duaom7(WWFDvL8xz#D}6 z*zH`oHyyE15zLp$$#4bhw$Gvz^sK(F)moV+$${_95y;Q?aQ;|xGdKQ zdbnv`ygVU=NkK(@O|%4$#EYJ7kdQ0hwqNA)yP-ievzc7_z6;`njK?p!*^yt6c!Y&y z<9*0MJh%<*NGGp?i^-F|cv}?QzW7#$#Kh;DMy_BZw)0F}ke8o*K<9gS2iKHQpJ`{* z;gpu#t+6Bcod~G{Q{e?dYJXp^Zimz28E-K;KBy#IIugaaKaJz}SDBvPzcC_z%$2KE zQjGc6j~lkUJC2Y75wfj4+5FV7Pru7_$%*5@sAyF8J+^k0tx-wsz7~uKvk{|at<`Vk zUj)sINzQh>8`|dbjZ{CXT(;@JcAqBZ!96UL=k8E3a3zWwDVwvFh(tw9ADqmRPHiAS zRT3jFGzV58chsYISP9k2+*T3WJbB&+t=)uneYwN?{>G8C)-DnOjLHS8lp79|wg#LR z(A7Wv0Y|jDKzF?)zdp3B=n7#3WG97UzAs>T9Z31v8UWubngxL^ z5NU(*jyq2CafYV`+u;rgwe7BN=?}6$sqp03(fNtSdRpWiQF8WFs_3mUu9}iBlVzh?HVMD#OR=xJ$7sOH!h@|QmS$mbgn10n!DHnL0!X9sDtM~$8*4~d z;T3p_(O65KwdON{%8l(efLdEmUS(Dg+Qfs16{04Nd`$A$6<#jji@iGYzcpIq2mKz7! zypMxYUFZPzY?StCQ*&Myiza*S@zWc_LvwK5iilq5fJp;c>Rs6HhD{Krz92*_x|v}5 z?Pr$LWy#HZAWH(e#(%VtkUQh2yv_^lsDbNv_xM_#_^J=q;kB`<_fHM@58KPQlr`{w ziopKEsSd*QlG*7g98-CUJTu^5aB4r_cMn{CKf1qIx99(Qzks_IX8v~r-lt;^tf7YZ zy)fP0qUSkWym1~bbL3Wy!G(^?8QX6B9vQ=_)aVQPq*`v2q(Zjjm-*a(1KGi#Q7}AE z{-DN~+5HEIApQ1jl>6Ta#jgF55&r$v7R^WmU-_rn?GN6RlS)QxmC#ymmg;S2#Npx{bg@oGQ{J?-Uj>)@~ELnZ%^)2I#pQMUs&IZZ{v>UlYTla zzqH-V&Gr6NYho*^&u$bii1^ZP>aUSK_69ru%=l-g)NnwIM9x7}=qB zFd0^SIa=>jbUg0ORVMTiq{eh_@)q+!YeY_gMo=iWF_?FDdna7jH#s?e<kyua#x;b{bq*cA*gli=*|^B zaO&L|YoedOnqPDyi*2!b(>hC*0c2H2k8^~cZoT3go0w1C{1Gypsu6a4 zU8HdR^pFD}`)z)6L0lY)#uc1KG85hmkYnYt_zZTdUv@L?j5}Pj!>@L(ywA7no4girpLXKu>~5#fMIO$oNRQ!_ zjl+q>^7q=Xv%eQ_@$qq>Z_@4E4UjFynOG2XZTLwQ!^PA&x%T${eT`U$Hw|rwqP~*J z56qVxD{+V2+(ay#@3&j~a4cX$g5LiY>=$0%gcjRK${k75`Hb1}{Ug4so!1AyDI0b? z>2pkI=}h!Rj(Nh0w`9>ST9bp#&d`3u0*M9 zx${~*gE@$Arru^OzPF~4Or0h<@ufB5Ly&ushr-C28b|p`Tqtt4?9?&42LAUX=-wL3@dYa|L#{y zzmAxtlEe56OlVzv% zk#QddDc%Am^|@W|JGz|ZW1sDMACw6i*d4BK`v%8|(;U}Zl5cvKDo8Re5m1C*q8S89 zQ!nXpB^CeHF@0;o&_xV!$W$IJ@oprHb!7#Qf(i;hc#>d7C zsLp)>M;AR1Lz996Uv=-<`!kWA71H<6xS<*idRHwbr~+I4gWcu5V0d`QyjhE~jbi+S zW`?BnpCt})k&T3EXKY^dB${y&2^cvdD-?fcEOI9k(sPQ;SK`v7WB%m-()?pC1I?i3 z#GDZ@y1O+Q;g2pf{^Ry@G6OEa z@)hs(7?mNXkI}0-77a-5?5je?zonXUR}};QWctUH>E9kuE&2B**ZJS^;Jbd`o30=H z5UFOj6e?8H9kx6K=adSm3Zg;7%@LBNb@^jvY(62%NuZSWDGpHXnJm<9nKT2%Wb=Zq z@w}6IYrPY4nycN^A0PR0jhn{=^~!dBa{ldXt96mSQiD26I#TTBwUsf_3ru>ZXutl+d^EEVdzy3g%NtO{Dsi;qIsY5y z`f&)t{$`G?>HQMjK{>x?WDOfwdtR@U!r=iP$D!PmjKJ&$r>^3}l1qEa4R7R~yOTVn z&Bvh=cZH1Lzs6gqsV=&Ucb6=|rjgOST64-+awkIQVyUUE^?$t)*C9?C#!|f|LNt<) z8q-;rQwD1~qX%uEKIDm*ETfUjKU#5-H<&JorKOG*(C^fOnCR54j@{0r#}2}_ch5&U z86!@Ae1dI5fA7X?N&B=swUoTbH1F$x7Nwo{rz|7H)nF}-;?H<2Y+6KTQmjm2(eRi{ z`Rrl^f+XV&-M`1Fg(z9o@ke@=ct!6=OMV+1h^=0ctfh|BNS9(I_uQX04YO%=f#lm1y`gI&Q*!Q)ekVhvfzQ6ZC&DiB@%`~Qb+`I;qGne3;lBr7CYPPo z5Xi{_YeSe+Iw4Jb`y*m)=q=Zl)OXjHw`_Lq8?K2mYC=BA@!4aXZlY9HG94g1X zQaB3IWDnwNvR9pb5g-*{17z+eoBksjxP| zl=n`L?$iP^4Oub3gK0(8I$?VKp)gGE*dZ~fGrpbBkAwvcRy zsSMV)4;IJA%^F(GX0D^YfFtr1*m6jEj?ftaC)yMz=) z-Qj$HtsV;h!bCEiIbE@-uXh_!KZm6*Q2G*v=yn2g!86nfw)o&=Ksw&Mv2 zj~6QYFhIbh>pwH~e@O#Cy6g>t7Lf9~7xk%Y^IrysWDL9)9$uS%+YMjL3T{Dg$z!@dYxDr`*ny*8+LMPYxN|h+H~L;9 zvretu43zxiHG(9xG%|idlMlR%4s&2{_?VN=)el}sQ|+gjt^*U^JB~$O>FtySjzxJH z9HZwIdtdspGs9K1OKZ^PgzAnlU+31RYEr>)IwXjH&%wcZ*8Ln>QUMJcWpvVshU4?N z^yj$DFG=gKkkaI^S?%aMh1Ctp?-dzDFTdl!SaO2+!Vdq!5q*&o<7E`0?8jn{Ezd+^uu9S}Y{7pe5;%!rLd(=sxroOGb_uwCjoLNyh5|B6GwNTIXYmZrHW z69duva_Cd`-DwP~0k*m_qrcVJP`H%mEyer}K(Ulceavlh1n$4I8c3=@?J=ku-DZ(P zupZYxJQi=E+f>Xf%h;CNDF0NR>T1!vG`X7LA78?Z&QqGHF@o(PT!#3L*@BS^N5Pl` zNXKa%dOvxjdMKnv4>Nu$bf$x5DXqvfT;n~uuiaBB^(#j^D6a(>-q|B8eM*_=Y9n7| z5S(EFg0^{o`Qq{@kQGZtao>8GdtOEV!dMmN7RSUv%D6q2I&i>7YJ6=ab>nM4V?kr# z?qIFJMpg~9YxtTeEp_AQ{p!cLS4IfflGW|I98qrq~D`q6&4Q(!Cu zTqV9k25Gt;kwRYZ0fXF?r*xO~+LP6V-(_rMEE2JGWWPq(q~{s3u!d`eRuh($ZnN?<=tIovR=mn?P&56y?(D|b!)xDR#Dn`?Z zj#Ia^Kvh>li`kB3Z*Gv8MmebPB@Ue@poKlxfyQ6hjhwqk4{1Julq(;VBti{S*b}au z?2DyDnslh)G0cOF*7A>c5yvUPkeOoTB}WSpTD{GRG(N7Dh0pO|$51vk$(FxPH9y@r5a! zUh=q+S@D4F7jkC}E%mV(l1Lck(Y;DG`=>W8W++)<-dN?d=BV;{{RvrvX0c+Hw@`fQ z0K;F#+(gsMvS??(D})WSSv!7j*&(V!LeId8hlt|90Od!w0@24@X!d^}54z4J5CxE3 z4%@Tx+^Yg-XJ^;f*LS^UJDJ5dp21b2HGI#sb*deU+0bovv$`a{(H2YZ1Z|1t!^>&? zQScy!p?+tP$5L)DhB(~wLeRNX0sHk0{%|sP__;7nY2Ex?fu~qlt>ei{JCq*UjUP-& zteHPUEjA63Zi#G9d~S&n<|GOkDa78g^AT;mzUN&?3+@TrA3Ee5b3mWAIfT01??`|W8Qg~<6LMy6`=X#YTEl2~d1ek!WO z2OiJ844UY1`>4!x`})S6ciB*96phK+b6ISV5rRUH)Y|j2RosiLQWUA!=So862+fpb zu{1?lJ0^*Uc|?6JWZ5>6ZRQ7S&gdnCV_L@VH@E1&TbYrdfEJaCj{$JVh*fGf;Q^sf zW^$;Li0dPptsRi#OADoe3Jdx#iEQ2<*+t+f%lWdCI1fy(i_Vl{*eF`qTKp zDb@Lkp|nZ)kqdDAw~+a2#gNoiMa^hg4ed+a_>&a<<#j&Bm;;y7T8 zF#*?D1(e6`sq*DYT*JYvUopgp4y(FOyLBrh+&WiLhq~KSPB5%wGul=FAk|_@H3^E!wbLT)%y~9WJx-l7_ znW19Q4Yu%jfZ5Fj_Q+(aUH2Dj^EmF@Jlm=uXwY*XYgZjg(EBjEes-4<8d^jBc%1w`CMI*(!M8t)vDsbW&o zbML0a$6q);jQP{q)z^m5s*Wj0yHHhs*2E46kOqI1@1~0zaCLep-$b zg)&{rZO%1!3Ne=i4oo2W8Q^S3TkAIh#h!>f4SS2oM(F5tG`dh0QP@rkiAr?Scd&8& z(nzR?%R;U+6*P(}hzXNm;N`#84=n% zJPBAL#*JJtN^mw=HQuB9`83{J*_^&fT(tY;&0AcStB`t`r@aq>^X0k#r04%zJXNSv zz>g>XWZ)_D;o(7`y7GC(>KEXyEqtj6$H@(P>gLD>Mgt%XXUKPd$C6421!$J3xyxrq zz88d)>FTn=z&p8J8nUC2!}VBjan#Qvqp!JstHNgmktda_kx*@f1m*Ul-x&=Wwlon| z($H;SJb6Q3tou`Ri~XJav9pap&W|T6hr+G?Q|YRinr<#G=i5N&y4g+^VdYyl7?IB_ zvUYQf^E{=w11ryA%J;{usau~q4uTnn{I$oMyyqPNuJ|9y4mzG%?PWP$*I;qP2y|~! z9rRzr_2Bbe@lYAvF1TVm5F3L?&I`%U{hi4Lk=PhvnTEnxKDjEqAuKcps6h9>!3=MY zurR25ANO7g7t_FK&iCbi%C&U^rkgFEm?Xz@qy{}vF)Q&{E>=eDREM1o<0J@xaj5R3 z;XGN{y~|u#E#4!b2seRHD(8xRJBAPSej(heSnxhJfoLQKk^YjDo~u%`g&)GTB4^RR zNAWIYTThXBdtNAwL=M`pi%e=~wnX&&hb)P@OGd=)C<7O))WRy|HVEsE|54C6`ziYc zqtcZ7F8?=1<$RVT^m`FNmGGU5ru%)0`wUM6PMm@SqPf%G*{72I)q(gRAlzZG+0J0n zgeEBh$VZ+onGhRI`9>B6OhTKD3_zyfAlQ z;Z4yvjgF=T8zv1|P=r+epnGka%)+>XLvRdf7#{;trauG&i(cgNAC3*NfsOY5eUIFR z>%!(%uOk6K@!^@>4s=Zn%i#+^5mq7|8p=Rom&T7H#l+hlWZctl;OMFk?Te4=0fGSIDzRviVES%!dI8 zh$Nq(C@$e0%%Xq02YPUglCsggQB{G1z7cv7G7U7rC~_ zHWIO8@#L;@U;NkN0x!0hwOyQajrqdMMXT2p3CIf`uHzl?&E**XaZE{r^~8maD3c%2?5)!pRe~@Re=DK%l}vuyx>LvJbN@(I3?)Ram5UG3V9LlYU4=F&*}a8C z5Jsi-3!EemaI=M1qF}@x^tphZ`6iz3Nvt7&{)9ts=sj&*w%%zmbVoN^U}ywCO)c7w zZ@39XCxKZoz3_rDWN&n^Poi0(`1cqr67g+&XFi~&z_tU`f^41bQ|=? zn@U>Ol$^ADRmnR^Z6>@)Q-t;>>hv&e8G|U zm#fkjf((QK<^z$h<3J**=iM>x1A(b>4?8G^2@%xylepq5Pw|e;&UhK9(7m{q(L!Aj zkLHClI7fJ_>EghUEA#5|%6!q>09QxJZ26va!25}wh@2csqxysCBT}Mf<(IFDQb(uJ z&p+7QdkVURp$+0-MYf50Ffbd$ z4lVFZdD62fXMwsifa+?OH_WQZR`G-aaJfu-f4h*%P45Mon2%5UTxwQaKI`srL{-az zOE*6Am@vGL!n~|H$HrRKXec^pIUyI3spNPSJyMy~wcM;&{^fcVH?GacA-UeA5;C@3 zQH*(_*yUq-M)xU1EGZKExB*aE0xPE>pIK7y9KC%AOUDM$~ZpOA}t$3h|U;aE4uR*-CzxRIdlEV=Hx2wEFA{IYe z5C~M!SMTkTd`N%CE1+|V>s{?)tS$=fz#gF(}sJeCh!DBws^NWJyz!iR2k? z@||L@G+=j^@CkkqPX(umz-UU!-ZsLy8!TYKmv8^xS(3F_%u% zDy!M8Kb2<^;DqXR>u*gK!!W41nQrpOkTbjiXMNt=H#M3HnoH+#`nzs67Wu&r1}r9~ zV+PN+5(0bsnF1xe?jOZdoOKsqYd-2|mle&twc6t{t~k8H2}yuNZvWz~1;?WAijp&r z!$0gNB_&Fj9!QA<^_<4^;jqUR2Z?&B^RCzLq9ErV1?fki-NOJ4mAYhA@@HA0D0B9P z$*IX<|FX8ENdVg zU)}nV{>~M#DNRoVIaR`laXcBa1-zb6s6XZCF*1lYr85>}z%-R9TVdER$Cr#hFA_s0 z;TLTVsaK!end+?LnBUom+$gbutBwN$Y?u<2LK>zlf&2Wk9iU>71GPXE%S6<-=(@pv z5R{%ePB*Xp0o&H87M8($qrbY?e9O>0Ik0tVId&PRll(a8K{x`(?d_9ut*&>-U5&;d ze}x!S=Qg|8!21Je*Q{a^6$-6X8q|A3W-2*967T2Geq8cb#Kni}SUariON$=wMpedG zY`ByXoy;+wzSUMMQ?6(Xqx$cy7yS*TL5NYCcsJs#yTZ1y*A9QNU(sM-gvs>q`Dy>n z>3VRX7azY$HX({Y^If1Pe@FWVI}Zg2dm0!ae9t~ovsx_r8V7ACEwk;q@=}wdF6;sI ztNl6YY%Upgq3LVi-wjcC|7?Cv5=dZt)C?0B!&(wf`JyXo0U($)!M%OCLRYj^hglmq!8yH>RgkI_2>-a}d?)B0U#@ z`|{2JOt0M<^;OkQ*tEyfpSU}1nmsb6BRFkDrB3*0E!3ae9^FT?Dvzdphu&$5`W^L) zGHtoRJ$hi?hCQ_V$^VeAUzx5$I=vn6ZM&8MNOF8)Cdiso%6Ac|n2aK#6+r?`^g2Yvw zZCA4yI|(g2L{m9$0g#7+wA~Wf_Z}RhIdK1jh#o)J{v;O+%v&gQb@CdI6%R*AmVpW4 z37~J6ZxcmSZx>BsQ!2zS%N0-^%Se|PsHiFEc18q>@t>!Qv7%LyX*okn%qOiK=J0q8 zYBc+r?PTA>VuoralMuft_N9n&5UX$eMa0=GNDY-`}9rkS2b|xRC?>6d}Dq2wl!dfk^UV*Vk|qk5yQV; zUcXh^^X9+PjN!n2dBz`_>3H9ybn-#;6qJ_r{RmKe0%HQSibGAdqy<7hXhPb$Inh!o zPJ$Rshx+%rshZWdCsv2o7-U<4(nctSLu&d6A(s`>XVU|)XMuUC!sznJnEK-y0f>HZSR~n#xrJ7${N5>Z(zRMZT2|jjTu!hh%5@#U&h!ftoeU@j zZ@c+Bfe;m8{b!w?qKR49FEfT?o6oHhA8F6{JL%t z?Dg2E2l13yR3_*835kYFAxxz_Z(`Gm^Ke^}Y6FvJYM|b)d@i_X2N__{@()SN*x9F` zC=L`F_A;lGt!)T+&s3p~c^J+p{v#o9ug5jblq{clL zyE#pkP2$c>4^lqbKYo7NKG;oj+VJGg$+k|!1Vkd)Xjo(pg#JL!I`cSqC4ooDL=Z#8 z43fLP-a~h#@ZK{mDE0X|)a3&~M@s5I&VvS?`IHyQ&m5uA>>BhT%F7DGG3?*Vb5CZ6P<$lS zytqbr0@Ut*6zt~vt1az~(w)1lGzl6BNCYBpSx2;K^g+%)QX`1dE7xN)DOdJ1>rguy z8+OGj!Y~QxT!(oQ7PZM%h(PF_iiRtdYw!hcN-z zTjUD{z^DQEu7lA+kRE~#3PI#@i481}=i(kR9fg=&v)^}f!7Y-nWXP?}cErrvlB^3} zEua!qo@&ZvNg^s0JIO1#NDd8Q3F`2mN$qE}Gh{>LZL>V$>mn0Uk8CQsUIi-7HCOx) zs3$qgFU3ze5^a9KX@sm89G}0~w?BB%p5q3=$>&D+Wj_@QGW}Oy8h^p79S!$~O~jV1 zQcE(L?2+CHmZ%kQ0r$$Onsse!s7c~{jyg|RVZ@ag%07xn>dh?cm2QzpXS?V|`!cvc zLBI;k3~@_vC}ZAw;#hIbN291ID1_r7%kc99%7&XBNG#tM#2x1HkPa2bZDkH<@Ub=> z7v%$Yk_^lV8iV)aoba#-Cl;Rvk~Xa(CGY3N>uU=Z_?*7@6hwc`L7D&Lf)k)rt9VaG zXB^_Lm*m@EUSP(b;C4$6Pz-+hb(PGJrqO%fT>L^~E=|TOfIc#@QEZv3nTHef%W0arf9MLM-k@z`SE5`sf1j;7d*#`1Pw7i=K(|2p<(+}a z0w?bcV_74sE2El?`feC2F)E!p10Z??d3nK9{2J%2IS0y#o}cNZbw6O*Vk_jUXc0bs z8aw2WKm9>2zPjf8lZ#nu>Pk@q&6Z7vuj`&0(854(8mz>ee_9xvEI)+(s*J<;aNuH_ z6)91r&UUy)_)#-)k~O9_UZ5V4_ft14k1RT+q+W~wy#V&EChnL(d z8{8ShY>Dy1F4mhd{enn?onY4?@-$--oC4m}mmo*+lsJ*wKr-p3livkxMeSs{1lyEpcPPo+jF5~~4DoSy@ z+P8kF`5g@L0C6AV>Yfx#31cl7S94StjTV_#Y$O{dnZ#_lQ#-P_$@vU(a#yQ%_)%X$1>e5Hl5w(a_<) z?CnJyJRr89k%y{0-^Q{!Y2Xd7`(|59*Ae`k-5vZ}D)2qDMc!{e6{jJHU|b5eplu*qZG7I&c$mY-3^+x8XkhDSA=ES!v`0?G~o(ZyvAf*Ez>Xa#*<8n53A^fNb%_dku?s#`yq7ByaRUf2!{z836|5zv#rnG6BU2D`5g+Wx4}Y;c~+ID@k3&&Lkk{HvTFrb++wqY++(-^T!dU z21XBxGES&7%O>lg>kl|9N^rO@FT^8N(UvtK&8pLXE%98XT0IDJlPRSfxCAYAn&Nr= zAonds2n+A8dYxvebm0Z#3NVBntME8_!|okCcZk@Nl7N}!>kMYK-e{ zYS1Z<0;%~94@0JmqwRi+W^mRf;(@Qz)IEcSKFpr{>eP);DjV9fk$1L|ry@JXtr##? zQG^zY9Tym7P>!l@SAThu!2fd5b0#phehSKtAgxGdrqwVLU^+r7-`ZRdWuWyQO}sFV znMS?>3i~W4gM5D@a-QKo7aQt*GzgSBUEN=A&(w|^E4~aTaFHRD;a5VLH?8gU*_O{p zw*qz(8A>$+dP+d|Ha~?bo@Un9-9+qTU>AN&h&&4~?a$k83SG*R{AvcrZi<~GG6kp< z!rMHu*K0aech?&JtN&%twR;j_D`|~a=5T{E84RrzOm@>q*%-%desBaOAAYqVB_ZVf z4%fwH4Q*JT5k491TPx1S5m~moB5p2`WW%*t;$i~cTyr+q$aYrs%-P{v;dOFfG~$$T z>7q6)7!f}nilKVDW=w72h2ZS!a#u(X9P@tzxRu;n5Hnsi+4}H^X?KEYVjI$DU|{(8 zack~mwMwqB`V(Ej2Yctg)fP1J}mBYCAY2 zr5kV7OidckXhCu z4)BD)oPv8vSCXj=e3HJ@ZcK%m6ykVCZkKNO?5(wnh#s0{{uA&s2eUuQih-J5&QHNCROyZag((l= z?3YH-X6@z!e(5X}`R&_mDsjxz=vw8>{?ierPbg zOxYZQLX2maxcmhl>i7K8!rRhCTOnxSIyZ29LL-3D2SYZ{yL!Mm*y%t8%FC}k@-Ne- z-cou=Cj$^9it21hGY7)elUyk9bh?-&qXAb_)jNq`6d*7^Yvya`$`lJ0(NZ!prx?&i zPr5zs4l>rcu`JuhvcLQG1qIpLJN5T3bpOArbc6HXD&dkuVXAyYSaFFY9rnS%Wmx8B zpSDmTA4mRN|Jo73pH4=HadeC=D%QNX`hBGfcFkP6`GPFyMz)s=LZHH=S~yw3nt$)4 zXIa=;7;{UiT)EghAe8>_4xlpRXA`NlR8=5q%R#s`Jo>}sQs_7`)WDfJ8Anv8){}O0 zE^3S<~%jT$XzmUiG~5{h9W&(tc-G*>&n2tzt0H;LH=n{T#h z)#ht7;JQNxU!xWC*25zT5e+7&^FSWntB_hX-O}%2;zp0p;WWCs0w|blVK6a!YA@$dY>9G7$OpVkmqg36LOzte~plLKgaJz$EdpjiK z>+uzCegF=;d`CIV4NYNBqLfJPyc34s3k(HlEX?re88zY?o#{nEL=fCu*70|`b}Z{i zG;A+9w?9-Qip$&?sLe4hCwO?l$9Dst^t5RJJsb0ecdT?x{~uw0V03J2cT0P@pFz;} zB54552WgQvfF9e1ByW$(;-+o4%M+B5Tb**Ozb^QCoT<@y-}9@JF_#{%Zn1uRtmCTXaz)-@~d zWd__yrde^8ABMIE0dsVEQ8rl;qX{1Nhfd{GPP+z5g@%F84WR%8Vl{6$i;e0`rWf`3 zjA56h0pXu$hraIfvDJf8#HKZ)Y2aaKxZK;QB|Y}Iglh% zcbZ}z75F=cS`)DoBM#7K0mWo0ecIydz-rgXP-_G0w<7H6JT~a{=U^wD1t%j-Tl_v? z?tBI?W}F{?iI)_7o^3hkzIg_fxK9@s*PG2dCl*rLU*8%JjJE>v{N4+!|2jD-V9`yb zd)SYP&J9C3Yh4mnvAFMHH}r9e6->&(R3Z%2a*monEeE^t7CRThB;0Y*zq`|FvUG#l z0_zV*ZmX!)n!vvuEU95POxDmBE|sYNfE&6N)5bd6hym#+KskKDf&Iy8Iq77lAZB8X zYzBS$1i|tR*;Ku~FK=*jGhjVA0Awl~N>55lY|f+s?aDp7dvJMV_xQ4`vaL2-2XEEk zQ8R_5tIH_3PmPJ#?ii6|4P3E045dnlP$^imWm)l&G3pQ@f!7AU0Vg+8_%FX$X#W3Q z8D0IP^%3G#<@>cNFp)d+nV0o4Nb#ydz4`0C-hKoxGQ9BAL=Vvm@!UCW_Rc}-*Esca zI>GZpb*iv&cF>RdYbYsO>O2*OA2;4UpeLthbL zow=d3DxowBW*ylpC)Z?$41JYquN|6-fo*eSfQ$aJhJ3GG!lAA7z9aYnDk(MhVDU_R zP6pNh)wU7*`Q$)k%jWno=2uCLz?jPw-1^Pv!3*DxH zrs*5=rMG_F|G|VDhy8?ryCtCVQ~Id}^kI$n_x}_2j<)^ z`N<3g895x5y$(Vlr*prR1eSld1B3a*V zg(Jb97bG5K@6U4N9f7Wmru3%;m&6Xrta(wx(aF-lYFmIzVg3d2g=(j1CgqY_I@K{0$&<=) z7ku8_mn637H^Czx2#iLwCVAKfw5-woY2mzWvDL7U9vj>T`+i(*-FU}&p<~r3MADyl zBVD=S11q&<4LFy{)^~MLv~VDFrHdh6Jy;}5sNt5#>&25Uw-cnTPKL(a z`14pK)28=leW_T20+T#m06Gs$-IXy8#09u=Bw0ayY<>4{JfL?8e6UukM(gO%Y5Q;$ z?ZiWu5P##~OrVTiwN@x`2M|w;<~c*gjk9}$%X79jr*LgLavHHOE>Xm^CJ+c^2O`Z5 zF10S+tU=N^;`eW^DUK;wmaCn{mMB~Xx9K$8GE}NGJgu+)N2eQ>lLtt(6FB~jG;xL~OrhA3iJwo^ zXhl+jEir|PO{};OG5Y>Lvx|o(Je7Wms0(bB4mHZvB3Z0C8T={BTv&R!vq4EGY_W_% zxo?k-s`!Ya52fCYbIhAoU1=z}xv;7%Q)(;K9qax7koDG4U4>n@Hqz29B_Pt>-6192 z-60^|-Q6kO-QC^YUDDm%-^S{OqJUM&VG>TpZtBwX(h7(`?qlQIfI~iC6UJ3>bEuJ!rXg-!27)W ze%|1YPi6z6n|tESb{W+1=osE?gdv0*Cv8Jp`i`n^Hn3_+NM-Xw?9!a4ToD;?7v+`CJho%K2#IF%|6&I5-w|))P{D~qQ z-PB@vbsQ}NqGkTIGt9$YHl8_9T8)V63?`{6rp9vCMJADYVI(#x-e~8e8yjSr$<{Um zeBUpNzuypC8f>R8x<{23Ik;T2R*Eh;KIZV`45VY_4!=ib!h{X^qCw{m?*u@{_%*nbB~{VuRo;SS0`s>_RQT-10^^p`o*@q1d1{(1}r`T zTZuMJvA4Oz5<%~K>bRWt-Ii|?WN*k+mLXw8KpZ3Y_Y$ZQB}l|t^VV%Y&f+{(`jE0) zv$r5Cs)_oTuY)bnEw2avN>jDw2pdk1s4Z8!;K~yD2E6&^EB0oe6hVNwOPWPR`<;1$ z9+AIzUXgu`?PwTQr7K5mQgX^CWh5cl;ofELui%W0%JElOZb#^cR>~Rh#yyXsKsO#x z;!e+DD@EbcUjN)ISC%0PqxztFQi(?j*Ob z+*5_a;|6Z7GEC>CO<_Y6liAgrp#w}keeGdQZWgEhbKijxzineg^ z9D{=!BPRtbs*Cz)R{6!NS7Gu=cNg1MiiEcFa!~{B8eQ+7pIYkiMqZ#QKe$M18qnKA$E@8+MQFEhWxkFfFPk5am2cCSu^9m?- z7~rJ??3L>s@Ws{I9yS--9gXP<2z9~}m9n4~`y27ic*|_vUK#XCXI%-F?F2y98+dHB zD%vDZ>%YRfx8@F7NtLVjmCeXqv<8V5T4Ho5%44Z!u~git`bpL=CbpVmS=+XQb{>~C zT%^W%ei58$b0r$p=6kw3WyeyLYIEJZeL6NN;c@qfUZSwN_`zj1A#C-a1WMz89U*=E zvxFV$%3dceeLWsH5YefIpq&%cEjjAUh-%80y#sj{?DElV9LFsl`-jaaYiPcks8 zo*1G{P$GL>yK{0%tLprJWx%~lqrEE~sB%SomJ!mQr6~BRK&Ro2I6T~g(9)7jcX3tk zY+&6qL$hv(Pl#SFQ;E`V-jW-a_4EE6hjlR`Le=-qBNhtcaEj*X{9>rz6fM(4J0i0M z9x)hC4SwGwY;Ajnp$i0J|6CXoVEuq)Zp~Y$Zk52I{0{T-wZ4OT&BFtk30JFm+Zi+_ zn?aS86sbfwvnL39_WWk2>(Mn4+?JCL?|gV1&ZFa35=F@~scO-WJF3sXC;>i7{F6Gb zHu@YYOvC0v%wioKX6RH%LC5!x+yPXg&u1OtF8?;&)MZQZm&?)T}`C5m9^m@ zX@>r>j8&G_Ke~xQ^uHGcHv^a%cCGvIP&0A@3WjfVht`H@@-j|jDGprO4LwChGMyW{ zlTzY5eaaH^JEuh&2PEIX6zxpuMkXo8M;ZI^&^`r!X9txZn?EgDuR`Y4PU_R82lJv( zf?!N>=j%j=u+DHt@Y*-4*u3pOhTw>xC-U>l*pIDJr?BNK>IEP(PHlJCET0gEWL@AI zwi#4#FuU?YWr>WEZSX9y|Ms&A;#5BB*|lS-d{2I^IWlJ>6^w1fjSdlZy7;Z$H}j2@ zuSF3Zk?U)Qc{Q5L8{CTL19s?8kaQh37w>P#yKzG{Ck|?q2oJC?iM~0XXEH2TLT$k zYl#VzM2p1_ucDjRLDJPdQX?dYmkqz-hcG{VE`Cl!7X1V~6oyijhG4 z65vczWxi%I%84J>HrqxK4g_I z|2aJTT`fonhBrk-R8A@ItO0t8OVcv$ehwS%Q7g>!II>7=tuL%JC##@*|G^*_qD3D5 z_=ABzPz3)Gx&lmxATnjnX3KCm{(v6L{=DwG1=|j%Dp)PYzYq$F>L_vS^7D_^!ipvL zB~iE4HsojC*PnadVAEg}=rjP+9uSsUJR%)(4KPQ|TX1`pJE3WWbN9H;fOwIWymkN! zz!zpe6xZf&K{CRkPHjmXE#izxAz`Bk_dHOYy>ZC4um3Yw9OCLdJezH)X*{!f()oIY zj>+WCQohau;TkO-5~gzKZsSq3qXAxo#a9s7*@4L+3Ij-(KVbYsz%wA}%&{7kosTp{ z3XZUzpH(dejJI+XYYzI1fGh36>z=7Ci4o{UP*FxQGsR}>wDtr3PF&}!9#jcuc{QV_ z+A@?TE87gns(Vf6?{HRbYmy_5HkpSY$}UwHin@`E*P8;jnN;ZT<34&kn-2(%RzOJ# z-!UlQKCr11cU6eonaxL{Ir%&=F4hzDZWi2TyQ$gv<=)@cMi9C_0{h1pg*t#yBCzq0 z#x!nN=SS=60pQ!HN1I2lH?a$~;N;-#BY&0+%iZKEVNPClVRXC1K8BqPWmx0secM_; z|0hQ})f3wV%=`}DcfNSz1$gvs^HSq;(oQ~VD=kbni`2%iuCw}guxDBzg;_AtrgcVb>l zA({N)q;neqpO!nXx*UnFT~3DS^sYykeZ4W`fI@p4D18&B0}hYqyzCHnzz^T!zx*G# zgnm-x1AutO=oqUCymQ7W=`lxRyz=EJ4&+L3dy*e#it-l(ul?*uU`_u=l6Eq`?*cy9 z8pqhY>B>nVi(VtHN{uWpj@>li&&BCX6{#Io(P-#hfV?Ft9Yh;12UpYOCs)>=%c!qk z!C$E|xwy`1{np0nml0rxpFBSLzzfFi zN{fXP9}{0upPL@}l?9tQpp*)b!RV5x7MJ#4k<*f>4c^jp(Q(rP$hzTf8MNfiziHNP zQ}5#!sa{k<1I&a5H^jHE2%^L`eR<`TAFlAo*+bTTo_drO)O%_B^$SDtJk2i@^A%~ z=!e19p8mv#lH1gXY0VrocFi-8yRf;b9Q`p_K2dce4h8IOYp@=F`-8uO)OzaT{2t zJ@{n_%}D3B4!EvZ)2^Cg8!qPjXvTiZgWP!0gC{hxAvU{hMkac4 z)NQ8?aE*ZRegR6JOkk{)4AICd!;z4bN))f>wo&Gj$H)pkMi~P#R=g>^AEPR$64ZcH<`+nfUa+Re6D1Qpr z#9w~^+qkd#VO@LJ+8ziIB?%a; zhrPK|*B_MA4T1PjXNx$HXj&Cm$;!Dbk-r#)(-(#QrQWc4(@zAL_u~z_B&rCvh=N+r zH&VPc_ju+ImrHAmgWoqIF~>8YIy0v$+iY;=mCwWahWtI?Dd}pHcqJ*C6OQIK9_$3A znl^_fiMnI)M+K$}(U|TWW2>WzT_zi|DgVGNgjeItz4SrBuWTk(xdz8xYgO(bnVl3 z%OE3j`7`#5-XDF_C|=iUdR|IgrpMV4COV#cRH05c{L3?V=Kfp}vtZ6gM2o}5V;?X> zC3mQ~c*c6_pbPP=p^OeQZi2MVH&LA57OFbnVv1!|=HeX0W&yfRJ80Yz=x_4$QIoP`o2Lvz)#!f(KqZ*Xi6<-CDfqj-y_~y^9BWIUa*lTvGTcu z*i6TIo!iEU+VeF~_Yk{rbl()B2NN_ArL|lQFI3+GWCGN@z~Z}^8P@H$p`#%1-aOi|MniMFqB?zt0p~vbet1!#O&VGmY#WS z8*fyaz;P)DVyuaubFSAsuD0SKj)D@cFOZ_;3%2Or6oEOWd*P%WJ&sD#J-kN*uRnhg zA7#+bEA#$EE!asDyi;?4@3iE=o?PdR4CrLLTw3E|BT*#nT$V6`ZRYd=W(y!P92{Fb z7Px1?{)b(KdQi7_09n%=W0yLLT#JH#5EVo=&3?*sD@XH5rplc*J%b6Wt{Iy2yU}+J zZmQS6LhU7CkJ-$edr;B1Y5r}Y@Z)$6Y_7}GwT{HABND_aKhm(mmHex7g+h9fhJw*N zB~h$fJYasWyYZ<2Xg$B5-;sx02<7dB@(+SV=aHk`IXM&~mnayrvQRB=3VQ(h(-rq> zt}rDVzFGIgTKaH0LeqW&L1*%mm2IHHJnJSj8yXK>dnaI7N~UF>7NT@Aa>Tu*z!epxlRhEGp#BGTp48q5r9cfjYN!GHb7}F`$hLmNw>*^A=%e+9fV9_ zKoqdcrfYldJkbx4FI)760%6(OSxqBdWtytn6P|C%XEuj?x7(rm$<$90an2k|a17{tfUUDAJ2ajglPpk$DLR?8AgZBG#@9{;&rD^V+vz|e8Vq1ZF)0A6P1G}BxR z#XQW8zRa&fuij*RgU7t0sJC>33gjmGJ?0V=87HEWUNY6qA4tb8Z(@79)m8 z%GFZ2e_LPG_AK>a7$Y9m1IEjOque_>A$g2FYD-;Pduot-F3#%deL)N3!r-6LLM0u7 zFYm$ObE4s;NJmCmq@DeP4;h}un{ic+W^1^=8nGxuFg3+j08`ZNb41&xf~X52!KiXbbH(36vp?n!d8< zKAIIMT4-R*db|%AgBcwRjO3oAe=C^px+|4*F4K+!&zA5SLc1M>H{bn=w>7Ss2UyKi zNLvj&_rOA)fPI4=?^zCX8`4DkMul!2^n-$R*0~w{u#jUvqxv~g<29amp3lCPWsql@ z53?yyhM4~sJ_?`@0Qjg&S~L2!39J+VpEgfr^acF2sa&m?FH`1RFEFHc!!P%AM|Qwp zfQdLFK1sRb=M~>T82>AFDXP7QVO1c$bF10F3Z+J$H6+1k8l#bQa@?K3vrxAOW>#le z^TGViNA%EXS~B^&IuD${|2;@_e&1ZaE%kd($}?kS6>WOt zAMj8gzW#J>qL&-Sd`?FYnWmQQ%cGLUa9r={vn7);Drmw5{TX)jH0fYKBnV4dqAt@y~1g??m4*Xe#=#YyFRz6OkHeBT5y%yOS)C@=(1- zW2u;yK?)`$-3WmJW#csJ?d5o^08jy-C{F$OiF0fp-HAhfT@OYb7UYwzcLv@R2)fPIkak)a8?Q8xrFNy^4n zu?kjHZR*u{{X^5r_I`2S1d{J-+x3B^rcmXdPcp@`a4GbGu89-2E`mI&R&&MI+k1>B z$i;dXf42&_t%7+VDItByD%-oE3X>!A?9rv&G{hUtm)j%=2)7 zNf?41$^ywCa^2~-_x810`NCNxHP8KLD~G9`0e}pw$O)*YAZ$% zoSHB$Ljj&`gZ$#n?@Op=b!t#jRzD;=G8B*v>~cHLJOFQGn1f69BJ~SVVh@V|z-t5b7kEjZKw16Q!`COyJS7au57k>+ zz(4pR>K)Y||(*?Yq?oT{J zve8b;W&E9;*0mLzFtD6HJW2Kf#_O!%6HAup(oky3@o$i2#f^!r+oF{KQHGjS{49fk z&qRPk-;-CuAOufZ1ScWFjNiRuAd!TZzS#hp9mFwG)JO4%S4g$)tD6-mYuK83oVcvz z`KOKqItNDF_szo=q#xQo2e3w`8NL+)=j;1LrOd)=F=%)=2p5VOW%<(%9N6qHrzO7z8ju(lnuLgT|nx z0q#PKxfJKbI~kkGWQh7#yWm3gA|N+|1x&IK$`Rk;#DatMdQ!1+`}*|$50pK}PejAY z?(moSn+pqlgJt&o;^h9=ebIWW{eO-t(FNQ}`!h#&G5CY?=^_U!GpIII-;< zS~Q&eSKY!VOp6_koCuf=O~a1-jmPyaz$nx)+k!qy~<(D1^}V$do6@Ujy4@pOl{ zYwOO94Xk(5@`@LoTUUyxFktYr7Feh@{*Ed7LXCRYUrp-JKrLO=7v*8AJL2uf+QucD> zAm6TV^VW`YOg!%GEPXETnbo0N`@3x-rGlKQ@h$>@_kCy>T@jo7^I5`G&>TtO7{S*Q z42P30*SD-jq5ZeK(C$G0Pq9HzxQMyOsLH1!FObGyMfWchY%pW)ZL`}X0_ZC71btIQhQ7wBRV3q0TEc( z5S}7E1bO`h5(q!s;qTe(ce4XQbWQCfp`J5$aaR?F36O^IRGnu;TY#4QA;!DE99IYZ z>wdcb)v(;Sn$=8VpO;A2EV&?`M!_UEB{mqkw;#>s$$wIXAY9?JLyyuiDZ&hu---?9GlB|Uor*|ZY zp&(8SS%a<~4iiei#bT$9B(%jC2DCUf5-%||U^DV|y}4MGZ}L-*PD$M$Wxv=#q{?}R$^=tHCEA092gf?DO5`(u zAE!*4@UaL?jZqGIp13!_2+2|WrXtLwCWZuTSlH=2??A#Pi@$0%gSYe4=kOS7*)z|` zXSKbvU?3Gl{V0aQ7vZEl7X4w$r=4$}ieXnJhGTGT0L!b^(pQn+$br-O0TPUX&D-*NdD@DZPjmRp#VFrUfC44diJB8csF=>8$XMR> z&y1J`CVFPvAEm?8SnpL;vzJ%8WOLIS%{|Z4uf0e?O>vY~>)!kE|OX z^aQoV_UBvD{yox{&JcsQvYXNS$5!FJV3&~t?nhN#<)@Ppl?C5+`J;Wd#WO89gX4SK zO3d)M5{-YTgswUh;f4-g*wAfefO>mJn=}#c>-x!r*$Mo2Km^5Yr*l&EL^!Glz+q_! z%H{)}g@dPIgU8knRx3Hb!6ylf$}1FSy!mkdTIeE!yz_=hws#tD>FzORkwwoz7`{jg z>|(@U+6V52xLiWR+9j9Imm+cXk+fw1ZwxgnxD-CjU}8??=aTmccDxUsy&LB8Xyn~o zn+!R+2Etotl5q!-5OEv#@@D6|{d*D&dI-saARV@tW=%q`aA+k7iJp8O%qL3asA@Ne zAxb5J+=3iChko}_OfHfzPeSYR+{wIxD(5qEV4+O5Qpw@Ua=fX#mJh{OK&5C&VYS}- z6K{|P<)bv@T}58#rJjy)UtgNfk!OJNKhk1z*?pOrlOCV%-UBGo!B( zjDMN%UZj*`nOn<=kr00wc-&h-)8f9k*}M(@CfE6#q`i5vRY3WTia@D`Pbv9wNGN^q zI?Cpr$NSi_ixL!^@g^OLnpE^S{SCsw^G{3JG7YJMmKB1|=}k9DD7crvZwzGLP1(U3&F4rut-KPYQ$w zGDOM<`|rT{?p>2GzHaye;4h9GF7mbqu{F4DyxL$rfqg5W*i8BFoHJEblxp5{di6Ya zH5b2tPhI|4d3VQh{&>~*Y+E}Ptl1E6q%H?)sqU{Uk}zP55(LJX|4kCSWBohaHps%i zs+(M!nLb-rfC2=!jHk$ryU&Z)Z{np&(dCSg+4jzMY$v?Pb{uhc!013&)~ytnX_e}~ zK8ns~L<0L;Sfkn!B2jyi#k^0>5S&Scjr1`(&eMCl;{aX=%n5jO(Jm)L^+04{$eaoNnN^+|lmRxeLPpQj=9^!U!2v=0M|l_=lS&4ze=HB31wJ z?+=Es^NC75dtyyEt_rc@pp=eYUJidB;tYd4^{2an&o>7boD=O|Kum9aDUZ{i=v;Cj z%fgcgD~Qk`=nMfuU$S`>^(INLX7unk$s}i%V5M3e`B6JQ1D%zbMtl6ezMKL4<NNE)_W?N*bh|C(o?ji^iW{wTEit`0&vsSQ zuT@q2ENU}f0k4n2r))BrJTcpJS6l34Pz|)$M@!q?Nv-kUOP!PN&!D7{(;aj`(+G5p z{2yJTME4(Zd*o+V8--(b%tiv~mfLJTB=9(YvH`}`R^f+|ioe&Fq(F$}@xXpe=}sfu z5K!bu1!8RQ9IF)>E2C)viC2DNp^2~loYN7J;dt;_Lq?Rh`Pg;d*eE4w){3$@Q*Fv% zOR41!0M51?kf)^;S#Ld{2F`qe#XwzMC5NaE!~>D#L(;X>_*$Ot1d>_(+kd&9nWf$o z+wL+)wjt!PK2Um?$%dXQ-eHdW*X6Fq^lbvyG22QJJ9ir;*nURmkdm)wJ>Bkp0;SSp z<6<^>mepSE16w)XlJv#qs{zrT)au778?8jVS&N0woCVK3%B2{+zyJ#W+1IXwHTi1p z(#aaH@d3s9d(wAGOFb4H;--Y1zE7X&}`chaqxpwP{ ztLVS65R0$QAD}9cyp+HcOa04;`p`(d`!;@3wR2o{?7zc9%$lnZMl~oXfHmwbPVJr! z#O5cnM5W3R`H_S)+D;Fdv)1)n_TmjoF}(BeaXokTy~N857P=`{oBniIDF9WSOGE_} z-^m&i%jnoUDBuXxuJ0PsMET9)VVnLZ3aVyONVYSZ*Kf`V%WLlyYQ|$n|hRi-{qWUE*l&`Vceu`^J8XD+TnfcEE2irN>Tu3ytwh>fa9fJBZP1*>Uq22H#1@Y z^L2r-Ccb$_Tb-_9EYj`UvNo==$qdW>N}F7sgD<- z);xDfLO$~V{09V@mi(60)%T|n!jC`Gp=*Xj2b*TV@mD3BE_PD~0Q?hhc7uUQr+m51 zT;RLAf+-rek-l~n@m&gu;54<06E@^~fM?lw*jp*H4*_7I=C$jF)R|SKGc>5?+~sD7 z>EBLK#M+RrdYH4z(QAWjWji;Ps?Z*50_ZLFd*Hy_eoFUdWcGY|yJ|lyBZXTqWr-2c zRNq_Etr=;+d{zHCi4qP7=APOA$KhKbsVLn%OCzFK0U89cYue){Sb6Pk!d_$^q`c73 z)wJQnz6D2XRzo_w1S#1y1(i4Bj)36w2#;hJh-9nw}N?7)sjxL^k^F_D4|i_-5c;Y9j6pb7xnpO*)ygLtp(nGHJo275){=H#Z~h+8m80zh+;j*7(jHnG`Me!X0;4&zZYSNp@~*V&sXl#Pv6lj;J$4vJ|$q ze%&v+Qz|rN@YgNJc^ahaJW`ZHc)*9mn2xD2?7X)}ai9gP?o>$g>Hs?nt;qM~a|fay z9izW%eFk;?@)}lC@TDv;%xzTZpH?dEd(!z-!C6^e8dxUZl?oBTBj+s5H&h!Z1=rlu zkMCPWQOEUeBkZO&=W*cD&*}Kt^-{PrIN0QPmPF@JPJyP0w_LvZr+GVC-*TsD`M^#H zAG(G)*7RsbmgMV&BW^~{?Fgku zfr!YDd+LDPfpS--79SRsC++_ie)@E;LiD|0)px^Hum`!<1+H(s!)Bc5XfH_x01K0x zO!u$Kg*D`JGgQ7<{>1P$(mokk@J4^Ib~F!p2zlNH^stAPEDiSBNE}iGUb>=kl@8=v z4#xtQ>-k5cE3UX5PPsuL;n5s#LZ>MQo<^TaJKwFX( zS7VyJ_2dh%wh^)u&>x#4J;Cl_{Esh4LI@kS8EwURq&LJ!Br`Z4-3nac^Bv-w&xMJO zCzDENFC3^AAIU>1&)+jQMK8+L3op^3`J==a&+z`0ZH1$4finkGxU5QETU&l{nXZpBEo+b4D~NbJn{0tS)|T0I!HlbNa4j+ zfSGOi7@j=e5Th_~#E{SP1^cs%droJ%;D%6~=2c6*CrHm(-56M- z5h7mJU(k;fqer20oT+;OC24(2^Rx$=_lQj-{G(ELyug5nSvVFGKq7^vKOsy+e<@JHXaZB&hKDn@VYE z#?_b4!&hucsF;u2AOI&!3R6;7*Du@|S;CEL8JoGV@dR~R~tZ0#yGMVoY3W|_#(Ymn`x7-pU^b>B|=BXKb zdD25mQR)&NFis~jt-s1zY2-jW*}7|zKY7{UDniR~k%aWPc#wl@8|tnQIlJfpYq+Lz ztuX_3y_Q}rTKEb3^L$S`9zaBOv!eaHc@NYrcN8LP5o;Dr{29BbjC>%~OQikgFiuUxq1 zCW>>u|B2@M0-7-5ms(KIz*9?!tn~?Z#PE=V;yN;cZg{cEW>>VcuM_Bg$*s_@o-r1{ zqp9wiJJD`a$Dj4r)DC+)A^rQe9#M!d>Thm&5Oc%mnZ7!*xXV#ar8=(htw?PE`3Kob_=O%;V`= zY{sw(G+OJ^r{-D1>wULb`@TBpDx9QI7!!}Ew$~s9tb1dfrpzYb0NSg1H-#1iLhZMr zi*rVPa?dT4vci&=HtYe5{L$V6oGy#n^*b(Ez)OKhbo!sp4?AB7+(7?$y7#%KkMwOb zfQTo&yq~tdtJWWck_=siS6r^(H?l#&*&ikN_JYUa;Be!)uMc!}6!qIfFPs4Vf_p&e z_0#|Fou2+0^}&klKvV5}qJ>c@+N`v|6*4r|tM!z&@re@%eXgA(n;&iRQa$Cf-&b*Q zdleXGkT*cn)$i8{4(`fVQv^+*{*KK#pBAYDbZ&anHiAaSN>)ZHjGmB8#ZfcXWs3vp z7+v?A+Pc1C!+2NKA#NJBOqwem4}YlfBXpQzYE9^eMiTcA1x&_9i4GIBfe8g9Jtx&_ zmL$$cnh)6iBmHh5_x>`Oh^69ES}srf=VP7=r5RD?(_P>_&H0%K0R5803(@tZ(Q9+hnVUB1t6m!0~_B; zgqyD_fDL2o+3>(__X6N*cOF*C2$3iu`ZS;iV$AELON|k!rm^_-$K-2T1BYU!*caN_ z{V)6j_xOwcLpt9`n8E>iFOjg})#@?4EE!r*_TV(4Lkvb6Y8T-vciPC~jQ$o5`0NO! z1{wrogVt_^?Iz|ZqQk+p!#rU0Cv`tgEKpB-9``OKT4d82mIE_kz;N+Q!+F!hj2Mqi zRaAW+gsisq^J0-PCSbz59n=&eI>v0dp>jm2wyK)BSXne;Dp9E9sv_*t^U~iC+q}nC zr^CtYnk+aIXgt3!S>?b9%xQ_iqkYbikQn_glLxGXo!-xo2KN&1j&0d@M<})db_fk2 z7vrw2$zoi!5CLofK}w5@mlpV@N;AQ98b8SlobG1!XR*c_oGu$gkUW9Rb~7q!ZbGZ- zDlc)^gcG$%u>sqZs8m%l|3T#=0`|*c0W32fmC^au3^3wVe>xzKj)GpcnMoJQ!85NLKQkS!b;ODM%eJ5Y2UwNIswzVs5BRUymP{+51*Tf0EY1VN zD|~o_Da5d9p-s6sklGrX>R$*F1x|mxx&aHzoph#Crk2g2q_4oNY4x|?`TDU~+n zt&gNE{#F7ZynUQ!hTJ0jTQ><>^V`+FjX(C6P~Fv@@{aP&bZr9_ao6Sdz0WBBi$^vB zwgxpuPfzsL95oA%L!fS8eX@u=QDmv3+z@4`Qq^@~#!2v;!*>jK3kiU<|E~D$V15Mv zQp7g`Tm-^7b0(XDHSO_M)QKH9I~jSHzWEc`8hI3vxFO>P{FsL11dUk0nCqDxR_;szm#tjUgX~D z#N@5K%$J|3%txaDy__&O8VfrA_|I)1KNAQHF1NZ|%PSwYUD4HGhqf-ATB$~a=m>(p z^p1dvX#}T@+w-v7sxr#N8IgevJ45_@{Mu#H4JvLrSddH!{`|fN->-eRC#FhU{>neH zzwl2uL`thRyrGi|nt#p={Z?H%>+GWsPjn%_cIcgE^4yNI8Rnf&4=M0kX zi-3N}1*qG_oGRl+a^-@U>_YSXMZghE85f%bLqAVHZmJ=Bn8JuHX-fT0CE~BKaTv;S zbSD$HZic4&U#q=T^u|jD&AQCr_87xdRU4n57$0&}*<5)J#-)PCfU~_%e?+AK6wwpP z$5~{01Gc}~Awe)|k~FV7w&#oVy-tdw$uUXisrGOBnL$2gJK-)LxQ>5yMhg&}V<6N) zZ+RA~6gMOnX@?X#gt?hC{EKmM^GpL8#=)3)Ct|g0bv8Hul;-wpxddgyl6~UkIwnUT z%gbSo3kxv$p4qo%utl{s(jCaF)S7`x%v9*hl3B?uT{(mzVrsb4i3?9jhy9F>CZc)p z2bN!TnnH9|sVjeEAb*by*olO-fqsL^TvMNRmyIKF97iXbDt72dWyre`;--8%SCG1} zsWaL&xzsRRb%S9^8W)u^*iKC%GD_5gb2>+*n5P6CSd`ac!ESWFL+gUNN3lK?AK$Kz zkeao~_&WH8nN=oApWBtNkZS}(dm58Us64;lYbjQ$ z?yw7lENC4qdj-tuT%3@i4ke?Q#w27?OqWi*2`Ql)u&Qz_TxI|A+A(-K?EKxn z<_POWk&X~X;}|#<5Wg6v=IgYljAr~r!qBt69GaRm8|a%tI0HBP5yTpsH;5OX><=FO z?Kp|yrU|<$unOy>nH}1OhL1~Vtp5Js5{GbOsl>}s!+77%>oeFI-LuzNZDU?=pKal# zhJ+LTt?#Q-7ZV-Fr{)H%_o!N8SFi?tR~h6J@!nwq8%>*tJ$Bt}7>ion0Qwe%jD@Ck z)&!F`G_b1&0FEKV{|KI=k^$nDK)5m*H>ane*=+m?S=pDZ$^k-_wfs}261m9*ft;#(4bVG;@icK`1z%mLd|e761e@Jk8MeCU_F{asZe7p&dSWi480 zawU%x|JWb1;Y+0x|LN1Q^&>AZsj{ZzshNCUz>F!hJ2lLk@ zlcxSMQz(qG)H7ziL&MWf*A0$l#;9glB z03gcPj^v`jP%xYPY#U!ITCUxVg{b~=3i9m|LqnA`zUwu%V3hYM<#<5a`XEw!(pYrg zIPEU&@L{XO{B=VT$k)jhqdzD(bTtG}0t*2z%6ELkMT|+Jx5$4YVg?n{>k=>OpYYk1Y8Ek8UXHah&`TKj=j+(8l@!LI?|=_=^?X@ z_78|A_P>o^pTAq}e{B4^Efb0j+`$jd7FC%PmMYccHxD8TGc})ODl^F{x#Mk4A#n(RT(;QgokH&blc|w}a*=e#smp&z4PuyJ z0kHE1k0|B1t0S3*HJ4?N7bL>}>Et8+d$PVRN=cuKw=@Ncjx%sywx^I;&~NOC3{$>! zv~fm;7N5O=yd^;d{9bfKR_=yS?sV^x8(~hIt*x$Rk~qG+bf#Kyr2c(*Z<4IEte2P6 zvMUhq9N-0O@fXsEfPjvw$K3JkJhH?Wys>+fAmjdc?ik(HIDm1xttZWP;y%^QL>PUY zjwjH5504qyA^ZU8D0qX^zU{F$OWL*5y#8$3+x6QYh!?jbV=A_45W{>1wh%%RSI1sP zu=W67!7*ubP>|Q9nnBGA^pFPgXp5+kO?oFfw?I3;?fBoFMd4mnWslXX00CW8JOn)M zUXWsZ;hXNYaO!f3e;}!F<}X*?!&_)0GPL`IUOKGs#n!*Jxwwm9AP29rwn5ljFX`#m z4kn>YY;~LnL)KM;Eeh!bw-<*sEUJkN#fm286pVOYYi8A`2tCD!l{=9xHtUS-!>b zf+GFdeyU{@_}&`ii!&}r0tVSWixIx^FmKezVr%M%TThh?RYO3)luaG*wohHCVY+Y0 zG3TC5mQ8jK!}U_u@RAx-$fE-~f`Fo;EO8U`a!_H~aGv%^VD#<`^Gn%sO5^@Vc2HI` z;JR%=6Nes*3e>58g3qNqJ??ciX(8a6;sGDgT2nuDPk(Bptf!F*$O%P(?`}gvIfLl< zapWphJX=2+^V6--gX%6B1_VUch<*M124lGvPtmMH-tmdaW}lXC=*}#>%8-x?+iY{C z89ZW4OM ztbLG$H@;d!`94D6EeT!+WC=Y;NXGn2uW!^yHF#AcRTVT13qYztP`2S+b9 zbYCsYI1y@~xfZ+eVf*b1qufbZS<`zT zD63JgR`j!d9Cd$ zM`NnD;1LPAE}W>?CWddWfzk}mG+pcsSm$Hfw9Q-?z%*|wzyFyzaXkH=GUl+rdn^S_ zf~uD|IuM*Ty%BbnlOeQ^tba!Ki6Vq>DBjN(KZtF&hve}91GY(G% zL{2%JM|iSM@9}xh*hq*VUg0=L^{F)QR3Wd=dC(A|BF_o`)LG&OJyt?j3EVh=8Za$q zQx;POH~#~Z8t1~2d298{t~H(l<7~F9ypfl?h10{$7T=Ee$H*xH1%ywsYqsJ&0n_n; z_M!Ei08>iUuYJxxw$@)^cK^TLzA7xwrb+X~-8BR!NN{&|x8P22mtY~dy99T4cXtmE z9D-X2?(X}L@1L1}cMf(=cKaX~Bzdf=y6UdGs=Jwo#n85O(zbTM)_Vskptso%d}h*o z3YmP$7=A=ilHDHTD7imq895e!XGTZ*orcG4q7uvb(a^9HJ6Pd;cQx&GvT@Y0Pp0HX zu?gFF?>;vBK?xJB%do*@)+zj?<9t|V#xnn-laLz4ch{%DIG!f;JqXNSw1{Y)4D`nOwg zfdDq&yRYh9m-03|v5`sMa(-*Z;*UX@8V|Cp?YgHY>-l$u!fF7%0#BB8fx^EM@qO15Bu?}PL5ifj_5Kp`OM}-ocX}bl?Pw?ohkX?= zofU%fUt?6o9X1Ox73{iP-q=?BUxC#HBrDHPOc#+T8w4J&bQ$@nU(U@*)h66w90S4q zU->W$r*v%y(9x7K1|Sm&K2+PzK^z9I$=SV1zb0UG+ie$E$T-53v^W$=xvX~eWwR0Q zQ}9(U-P(jGM=F)VbA9p|iwED6DPZ8x4Pq1fO%@3TTEt;(cFchVT>KfbW5s!>k2#pMAUg?J;b?h=_2;;-+}R>>3oiR7j*@qGeRV#SpYo3jzD{QHhOY#M z=d5~zK*V310(Q5JvJWpg8!MY3i4GyLGp|9P{-dV8_L*fk*55z<-|Uc>`yu8Jet044SUmHIvnPGt06V zM$P$x!Hn2t1l&0NdulrjJA%k7j-Ic69!0{*H(Gj#Bze7oompXAC*eSwpO!FAU+dp# zUBh;6<3f6VG=6+RxuDkTYPq#SZF@%0)JkF9JX=vc67a^-#3O)igzN96%!gD%c=Yp(wwn1yg5{*(5j$Lt`P$ajfk4Slg)LM*iN) zw9DVGeAM3YlhK{kDe)cH4Oeby$~btV$2Gb9@gi7@aA?l!SBP;^o<(k2(p>IPI9eV_ z128t$z_a11?m};fLgszxi{ew?poSUV_QI41aCCP4{&Rv9tRhrobHp71y)JX?*d$Fq zAfUk797CKEmyn#FtSj3e+3i!e)h&Vb80x{o-HQ6#Gq^!F{}NM3?l?y{>p352_BbX- zf4mYf4dNu>9S*4D-77hrB+!%25Is3I7mqy8Wc0~ z+kB;SyCLW(!!o~g^gx!|;1*~4L$B4RyUBZ~>|G~8&9*tj+{K7(Z@a$AtR$f-xa^uFC>F;VnV1w(^2EBI*uBP#d9RlhXxQYc_Zdq`fe|eC=4U?jR|~7R ze;{y0^N_bP8HfcMe=rA5t+-$s^A(ExQ9Qp6&kd^X}n4W`scQ;X*m-yIR z5+7oF6lgo{G1ZWQq6OU|q686oT*o!w=5E2uj4--Uq-MCtG8JOJp>+KGWrl&2iyoWO zbx25E$GC+rUlkIF`B^_ z(>z6-lHgV%D}Fdlmh^I?0xUQGbn*1MKMzT=EN9IZ9^`UO-z{E?+KQzi6kYKsp+j4~ zY|bXt2OT~fn2n{9*dHRPkV~6V#LY%K;X)=*%4By4Z3X1PT$I$dcCV0();U+N4_j6f z7R#1q2J`be6wc!Y`dQp7#yoEfHZYHUoP;Ew8!^=;-Fr zO&c6jo{~JK$3x=t9;>p3A6Xw(56h=Ofq9R;B7S^y6#>pP{T0zP)!gA+8XOvOLJ7oQ zDR2Z(Jv2tVBUhNScyR{40dtyN)y0ouh{ks44;~E9D@Q^G{Y_X8_;XOu6=3iZz^1=s z_`3MSxU%+6Ezdh#MYm=oY%lQi`j4cZWH=}iqF0tadqN2`(&cj6-~FM|D7Vaxp^`~@ zKkbC7lfC-)j0XvuO$(PO=KNBGY{=u0^&|Wl#XXmBxyNq}|Z6zYQ7pPMsdRhw|5)^7-XZuTirb z?F4Y#nm#3~TQ>qyFQ7hKy#&hicRvsnIjM3M@Nyg-d<HVn72@kTRF~1wji*_%}}sleQ7{$CDi0e-?b8V|TE$ zka*O~&su+8aAn69QoVD(%y zyvD6^T(r27L0w7@;^~DJbyi+$4V?+yzNFQ1#2huIoeU#%d7AdQuT@|k|CrD+}}WmWJdg8*68ZW#`74o^wndG^>{PWJ6x_t&}3N*rA* z3hQvEL`Td(4Yz1O*0pS9r8S}iW$&6>XaMVMQDsd(SWESt;}y3vrHQ4FQTO^@PJi$7 z$GMmynZ*#`qg0gytZwgiKwgyNymKCN4Mxu6v^-cn_GgHc0O6qT<-NV)Fyn0v``%tm z^qcEm6nQjcmJT%qVw$N^#$=&_H>>5sW@Ay?>i*#>BLiH@$G|hY{J{`)vSY8wz>0TB z?)c%Wet^pqD*FgYOm5xA&yKyh>Mo3&5+5L~g`B=btSSjUeerY#Hf?5K2U8&W$yv0f z-O_5tY2(ZltBD5RH%4Arxa_Mw@rTlRY9K({+cr!&RVBJ4^tZ(BB+;%0d#O zt=UaQ8!`^AW}UV>lwg`MvobnTWMOFV;uKv~it$~|X80n2KVYRLu*BP6*60{e(a@B* zD5?(}!4Jnw4UQ3}v9!$W41RM=!!fEFFvAY>SNWzTVemra)BJ+r$Q;^y?_LfB*X$mM z%)}q4IJc=biMj-Niy^0R*ra1sbl&1v5I z6i62MdhxrG0%QB55t`(??M?VwI49TItBHf9+1*BW?R`0LQ;#kT&GWXY+TX0avIB`W zok;Hif9YL+^bP{&cB#UixAhtQrd&Euxu83lBbP4A*~tpPs#4knC*;w+@&nfgtpnGco{yYOG>?AJ zyK#cy9#pa z6;VKnkDMg#ibesh$N}xCmWWf<;^*@f!x>$mpvzq_5WJn;9q%h|`(R|?O34kS6dVN^ zww!zI(!Nz(*9)51*Ov4vW3qRYaWyt09+<`^;Gk3q*B*ZiiuIZAavk%qDLSSpw&u*R zcV2(i@KX3)wY%0oZ0wPbHw2x1m6W9k5Tep|r%eKrSU*N-h;v%OvP3#-HA{iqp|l#78(M%Qa4oO&^uTYbz|=cXcplOZ$#mvRV`j2 zvxx-*2rS^C=XI?@$8?@(m=UJQVra-wZ0f7oEbxG~x;w2fkiiVDfkUWM);?sfJ}#pk zZAK8TxwE#qgD=Ge8%!o@5Uy7{YyXCB=u0Z0PX3LIJ57(96={i(Fzt?oyYQO+Wq-P3 zDj|`0Gum15=?fpC$#hIex2fS@UD+ zhiYcUNnUP$IA-~&y6$~QG-e9M<8_BErOgfwB85SbIHk<2h~2(+2{lDqPRs=Chs)kc z?ep&lNjDhns@4T;Wt8JSqSrKp9&^!22s*~6IoFNyN~7IWW9KA!!r;6(7Vy-y-*=Rx zq<|7tzz?_dN?w&lu)3#^aJlulr3&#>)jsObQ=dJ519HK{xo6lECto4d9 z>Ln&NQ9(d#@_c3Hl|T$G$41v|D(!Z67`bTWQa$63PxC@s#(qUvG%GMELei=i2Xf!e z-2=`ZTNQ2J>#?F$y2q=Qe0=N26G-qhH_0KxW=e3pZ-SeOU-ZYI>daF#l4HL6 z-tN*#jBd@Gal~^JFnm!Fc@d=M@HQ52whd9r(!ff9*SF-Sp#w$?l2Qbs<30`*6D69V+)>ci~vy0Nucq@;h3n~^_)i+wo7#%poCdQz zbsOu=C2tO~R$JC!E)X)23jFZ}=INTB7~V%;#^5U2XC0z3s$6dp`(2C!1yM>7LQJf3 zNbX84{PNtIJgz`lJ7tUk^ZL1R5wi$fV#Xa|IKZp`(et=2lq+Q22@Gmc3)Ee_khig3 zY2?F8cvHG0KF!XFs<-~lns)QdeULf)6vpjlzUXWgH1)#x{ zh=k+2AOfZ|DE!4*IDn#t1dwc?awt6+p~O`l*(vvw`M)TD_wF;gl-J$xf&S790H;CSoH=ftVwk}~{ZzneAEBiB)SsE~wFci?r-Eu$cQ zFdB1m_)x_~T|-m}1PG#75IsAri+R-EW7dna0BPcOuvy;bsov$|={;G?ucvDVH>g&F z+4oayTCw6g(g+BsU7N1>tBs4($z6tJ?0f8gqyp;Ab& zGv9G28~wV9y7ipCwiM?Eh%KA}^)H(TJCSHC>qejbO7j;iYaX_nVx4ag)8|fbpMTQc z1+h0eYlw=v00hG~1%m`E@IyWDpHC#uD=vtPccaUPRYDE9och54jW8YG2XG0NC!sGB zQu?36E`FRHKEj~=I&wD&!Fj?fv9rQUUdtbH9qQ^K!eJLFP!wX=V=5j2jbaKbVLEJ@ z6C&HR`;?_jPm?EvqZ2#?=gP@SMlx8n|14j~v)_^aO?MA1oT=iu^{W`oqe$NOvD-Ih zPBBjFKa%ImAox$PJbc{nKeTi@C0(niIAFXzAF=51r9i}Nbf>SqG_ddpU1hs0@1Q=| zO7+!f%aUM15|;jWU+Ut*E1yo~^EZvEE{ZvV)@k&o zm#O7uM}91N!c5%f6YD!xbMwRMkdPx}U1$bm#{%i4_CzoV;6mekXy_+_(3_cOR>eW(sc|kHKS2e` zZ~*Q|2#1iRz)+EolH_r~f?3Ftux(}C9GjBzb@3FLI>`xA;I`F`>HKQP+9C z(T0VBQpO)o>#;jfDSo`IitVHp??484SoaESY8;$oDBlEim$!PuS{sJLI)jj=0~&E$ zP9K^7)b{zgy^~|-_Abi-)G!t^?hk-DQ0t`)X)2#Z6+?s4)cRPGuI%GOakBTT3k|d} zz3*Y2>WVvr*%(~}rGwJnVXn>UOzIf-WYGtMQA{jIq)3a?G zgH)q|X)|Xc(QdA0 z|KJ<-Y|QWRzIC%kJ0lG03X+)7K!Oi%GQ~lojD3JaVP}6)IEJaxtJsG$(pJWCX zDe6K_2oK-MC7cKi2?6roEJ!s~bsZi#NNxpauPgv2qnJ-l4GK+H=)96wRh!aVpC$_NS298Eqx`q=1%--Iy zK~8}xCm$1PG->-_x_}W!imy$DPOJNLr^No-S-Ry<4c!N_SRS%8<4opg3C%A z72Q90J<$*Ucz}R>tKCn8@#6Y+bCXCBq3U5RLvFZd-YyOdZI6i>@tCmnwM>@!_Cs38 zuu$|8Cv~mF5-C!n=d<^oz`efYQac)u64g>?gzP~B?3xJpp(!7Odk4Hz20Z5%aBbZW zsbarP5;YRJxd3)*6lqi%QR88q&t8qkU?bcnHqiqMD9>u_bLGOYiKp?CNUo$tMLmmb zmN!OY;N&#p-mky>d}TC(f&ad(VVV z3n%~VRUgE;CCibzM|*UZ!go)oa>R7rIoTZ_e}*q z!mtlm+)}m$jDU8q3Xso(Ijnp8lWB8+fpUW)1gjh-G@PkF%pn1e^n1Y+8b|p7^=%Fci62Ax1^pKUXjs z)8)Y>S~07#)*=G)SHi7MhNNYL%m+@t+78S1)Cz(C3K9Efr5>;qliJbnP+d(7W>!hTSDBRBKVI2a82YrJZbUN9u( zI=m-B^eCZ=X5jhm`Vr|-{rPiAZ7jgeFS0=EN<;;k87N+(V2TkxgX}-kP2TEmgsU}q z^4qiCC|JLqScT-i1H78iS^dI6KaMt#U|Wg714U2aP2!>IeFD^9{he{PY(kh~_*$<# zH%|``plp+9T;8XxE>4pq>J4L4(%gf81Uju_a3c3mHpU6l{Gi1^Sc?ao5&g1*&RIrq z-go#|+OZClppObKO1TTm)E%yuPZiHb8BnVt(_KTOg4VnAd}UPPBlF0#hxwDjY{FlI zSLzW!`9TZM6!N~o-r1^N-i9uTvoW3vF7Y~aRgLiGOZb4NpUnMc$Jvr_OVFCG74nva z?tW$(`xEAk#|NP^NxXuP^C)4A{S<-81T@-Y8HU&Fu!kj)z=esll zrfX_fb#wkrZ$Hp~bV4w$sBhcO*)P1!)0<*eoeO8e=+ti1fqxSf1k@_YNDCQSBi!&$ zyk0oG?WO3?7Aq!TfW?uW>xtXlsRjYODKos`63X}lVbjr7csuV(|uD@5FaIo+zs_Uv(i#@^P9Xr_AD z9U#SmIVuKT*1dfVScOZJa~_K+CmlBO8ZJzh0sIZ{ia^}lnjYCs1iUn`Sn!kOuvgjO zJah(}rWxDx^m;t_??N0AOfYo?tjx?+B<2*vT&k^&pvyD6-%%Tk6Fdh}w==%1?REU` za$&D*-uc`c@pLaLf%%U_6h|ACRdjPdnF>&u_)s(<>iHghGdZpOnc35++O@IO@G-+N z5Z#8*9XBI5+Ha{XHC(yLf!`nq9N_Q-9d&3y7E($8gEu!~$qSUDo%KJEaCG2ig3$xg z%O8K>(OM29gZp(iV2yjteaB%NgjUrBqprZ99W3VJg9X6gIV12bOFWq?N-{_0dQP1t2KNbZmeXo)L zx)*{vr#shV=f;5P#g^3`TuX|?`vk$bbq$o#SGobb5&7<#yWdVD^116(%i{ev12iSi zA;?6&57j=UMW`-bn}wf(_g+3T!%MDw4>D4fB~js?t@u3`iTLi_uy!+S>h+k_?3fEnUBWh$7%-1>dO`DTZf z;;lO_D6b&V{wtBPHI$y!CYMwZE5}j$59|a$Qv}uUyxK2{xY3Pu$w&Y)chBWLEI988 z^Jl)25Q=eQq&~E4?e22-Ezi|#VG2`{QwT?b5+De@I@x%i+?etzr4{&tI=bkFc33FE zK+*~D1cZbU#Uy0Z^f0A=JOr0dxXPcOJ{f1p`A+ws`4@Hs{ZY!WxzMs)nj0n804{;f z@TuMdU43iBGQMb4S90NiuVpYAFLzaPBS-jY>57w)ETOp)~bJc|%RW6T(zvawelpow~@v!m*ah{He%FjG`Z z^&`wBqn_PlXBpgBrG>Y8fg;Wl7KUIidX2arVQxSmUI_n5yDgOf@fG*blK-t~>3gm; zj6{I*eiS>Sbr6{q(RmPJYVt!Se>a1^qflT3*ob1^9V-(S8aLO=*F` zZ_qxUK&E$XJ(29u8XnZ$GxClA22pHwV{?aZu`&x~PX;W3hE+2~<9X2<(^&T{K=$c* z!XWi(FP$aKw#yV<*6e#r7_x_lc;utCB3hXq#*~Yfee6$FSQ|>XEO|R>>*6%Ue2HY~ zU&c9%1;5vz1yu74up!m-(3Cpf%#*x2IR z>{>}A=UK&)lrWceyJ7tVX06`}OMb5o1_B(B_*G1-VkthIdU{n>HIA{#-)gYSbL6z2 z$LJSGpo>B@>&1Mk14o7aCiaY>6*!7r{vBT96*R~^98u%ufN<2Mz#rdtoELSv7XgXz z%x`1g2TlNgFok9S7Z)vrt8ckmvml^y7HI7ur_cBioHG`Ht!u|T5_JHq;d9_;B7F3E zdNJt*V?bewlb zM-NEr^rFjT5{X}ncCP>MO==s-uEHg_e?t^+NWhCmocV{>yWnCN|I^cXW{ z+}LPUm0ap2cM=s#Q4I5(#O#B!u?(ehPwFd>XI!PXpe|Y*@Grdg;!!G{#g0%Ttg+rfXHGPqG zW9$V;0ouqeXG5u@{U4^=7mGPa0D>J^-2s1gE{DRkw|HiAGHBhwz!@tmkc>z|WA{XI zv8yOdVojj8?M4tdeoVC!=6Z({&~+)3?*Jrr?0Bd+nR4r`PfIaS*=T9w>2>MfJQDFF z!NmMU+`3eBb|HLu>`lT}sL*iQ1M%%v&?V{9)q$UO6q0^Be%RyLb-3vp7Xwto1Q}Cm zj^+UM%O#u?IVx=}a`9y=&PVTCU(eA+`;(6zT3_6_%-wEps6BQgAb59ke4@-QZ@yR$ zd-SbaBEZ0*9oS0BtbF0qxPqAXzClx|v~;_>@9Pr)5qg2y-=YMxRT0tDhlHFMr4Px8 zvQuJWsiri8fxalA1#EqG6L_SRZD~=98sm=l@@l#!r0VFXz)!i}laVQVs{OpERu~xW zFOjf($ef}BJpr<)&DOIjy^v`$(N2M*xAbCa%UZqQoV28or)@`h_rul;|BZX-jE?r# zK8E17fqNsxEeyR9Zc7ez_&RcE=vwx_WzU%_YAO2E8%dP5N2eumtUN_NMw_#)UGXUv&#{O6 za963URBUNg`!=Avv-R1(xLjr@H{geurp-?1j}-6PQ&(POki>2p5lqzWy@P$3XZOG= zL`HCAt&s6NS#ku2CyGu-`_yHqnW8-xMru0EVmxrB`Zz2hCB#pk#ihA_ z&fJ1;NhI*GWrqmlD&XyQ2h-c9@US>HGg|t|Iy7YO=gPVnn5YV62n9xamOK1ceNR=3 zwA)V#LaZq#hdWkxIs+1oxjZz&s4i^ONdG!<$-O_EQM1tJP6nl+>@bf&LIc`?Z~cU3q3;ErRqQCvoPoEHes5>AoD%w;ePf@K_sS{e1=?;&8Loa%J zvRdPI{@LU4xQX-Y#Rp*#5xaXgz^5F=wUi?=7oZC}FkxDPH67{A>dY4c~o(UQK6R$U5zFtM*(uNt{j zbP}__by_M$VgNRB;k%aa!h_xFq`}(4LM`v?fA~^m@3OOykW%H{R#;p_=no%082lMa z!LmJF?-IB8ym{yiisNTyV(+m|8y;jSRG1%GJ5kiFohDtLZ zF)?UyaZ#0DylO$DYC+qJzSLBO5AAJCWjH#y_}ZBp0epF7y&R_4p0rG9#TxaGao>^b z;E`+zgPA`7J0Gn264gKce)8+xo|YH6sOU}EPx9)@$6ZKNCCsu6c;;_6*6pt^kMpqM ze?Mhdm?ExcQ4YzBgAJ(ESJeai-kY-pQqj={bjl)lT394ZXikCw@KIp%ZeH& z9jx%$>g(%MVTMW1sc~^}C6v}yetW)N$eA*yebsGs8o|p!|NFwL@v;>DWkono?>T(y zw2%_4=4KE9hEuVnS%Zt4l=M+)7hNMNDk>x*Le$#YI&j&@$Y`oUyNQ}8Pq}2{|6v25vp`MMa(pzj*yrLn8Q3C)tf)8ZRZ>#gTWX+5q}BAhx^j}u z_YCxbm&7 z&mh6RAS4@nxWDhFw3g43!BcC$Da>jy4Jj`#514QN$!Z(Wv#mLDv{;8mK!5~@$J^T*@jW}D+0XPEXANVK zIUgxyOVmKT_0*j3NcC%LJLuzXrF|^Z;TS7m7|IMSLh~#A4 zbWVp}_8#Sm=0$!3R=o2itEwEiG`b`P+2Bf6#4Z%m{%XM1NXA0%| z((F1z^YcGk9W7!&KqCseySG+|kkR~V`9fJJo8kungG|iMj!h8<_xyM(VPOH(VaeWy zL`M&OCzqLQw9(cM7FJVJRP+JF(AU!gmd0T>I1)#Be!rX5H#0LcpA(zl@vvWX_i$KM zW6%Sh&g~5U<nQK87=lJ2jPUURdEBnLCN7Id;lttJ;5=Q;C;&Gt z7ovzW1ik>5-5M8E@<7eWiTfs2tEvuY(B$DYEG%qyQI=O2j-Fdj(3G;x-7ddk!rh&l z)4?p9g@pwy5>kVny{IVrAUrLP0V8fqOpMRV^P_rmhxhBtuO_>!$XrtGX>(Q($j;90 zy@c>QyU7nsL_Ahl&&QhvXDVZe_w3e!Dk_-bk$6ym;W_P1f~|X8M!p%;!BiehquqK` zJkYAuJ(VX>V>UsEh|f7W7#mBc(`*k0#2T!hXsXm$?zbo7U;5o;^QHtT;%c~)LJkND zDjPe>N(q7}MlZeSWyw-ve$AcqMBBWX-8CESeZVDZobo z*1+!g^7NHfs~&cyNMY!`UAwPHF!E-Cx}L9eLGRu9whaNHX3v{!BE|i&>9hoM6oGxw zJf6;naCuzH-)yVdi%?^Z>$8?tDxEOt`GWUen1gOTrH05_l=EFx;G3e+LcQe@RO z&6F@{R>g0-N`O-@e$^QLsxynO5Fw#yb=pUNxvugyTyC_ncXX_E<}@ZVt$PQ=iDWh_ zY*04vKAa9)kif(HA=UV59CxH?^*iyqo-PoynrtJMekIHaMdoU~_mjEbbO1 z=`%a|d_F`Jyl6cLU^p8BWVF~hfHsEl@bt`4q@q^U-ueAorFBdEwkb*9>m8ty?n>P@ zRCM&uZx)7*jvf;i=RZ6wIcnoaY_L$N`)1x(?tGZ=fk6mUV+}N`(XI0`vyz=17_ zpzsB34@GYe$C4$!=H}*}^UtRy`Q0oh={aq^BQTw(E*)uv#Fc zq=f0|=>cXB?b_E}0xzWwh2vi}5S|ZLB~Fik1xErZCIrau=iBw6t$}bQz*Yau;Mp## z>f9QLd>kIX@x5L9R<-owskxc6zrTOh5#SSU{Qn(x9Q}X7BgP_3Oian_HmMbT#BVU^ z{|0!}l(n?N&bNp9#>bI_goN&%E+=TL^*cSd0Nc%0D|?F_4%>qyfP6s2VWg=Xc2N`j zkLA5a-{ay)l13d)R=CPKkl!9eYV{XQ4NIGjCHn$mDA#Sn27E^!kX-=Y*kI9?5%+8} zm}m-TtkvAg(R9N2og zF%lXXA68vm{Vi;~A&3AtjPe`2ze2PYvp56+KOI1DaB%S6u6zL=2JPYDfr^Ui!?Esx z;n?Ct_mPzq6YzD(0^a<9|A_eg+t}l7GdLkrB1dr}#VoVu5ZE!`&v=@;#&^j#WbM@T z`3>vECX8k9;B>fO_fF=BOf}i*U+qmn06Q^*Kmq~+96FbEh@V)CMu!Ulf-3t Date: Mon, 8 Aug 2022 15:23:57 +0000 Subject: [PATCH 067/489] added plot_foraging_bouts --- aeon/dj_pipeline/analysis/visit_analysis.py | 193 ++------------------ aeon/dj_pipeline/utils/plotting.py | 178 +++++------------- 2 files changed, 65 insertions(+), 306 deletions(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index a3a54b10..d4b944e8 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -388,14 +388,19 @@ def make(self, key): self.Nest.insert(in_nest_times) self.FoodPatch.insert(in_food_patch_times) + @classmethod def _get_foraging_bouts( - row, wheel_dist_crit=1, min_duration=1, using_aeon_io=False + cls, + visit_per_day_row, + wheel_dist_crit=None, + min_bout_duration=None, + using_aeon_io=False, ): # Get number of foraging bouts nb_bouts = 0 - in_patch = row["in_patch"] + in_patch = visit_per_day_row["in_patch"] if np.size(in_patch) == 0: # no food patch position timestamps return nb_bouts @@ -403,7 +408,11 @@ def _get_foraging_bouts( np.where((np.diff(in_patch) / 1e6) > np.timedelta64(20))[0] + 1 ) # timestamp index where state changes - print(row["subject"], row["visit_date"], row["food_patch_description"]) + # print( + # visit_per_day_row["subject"], + # visit_per_day_row["visit_date"], + # visit_per_day_row["food_patch_description"], + # ) if np.size(change_ind) == 0: # one contiguous block @@ -411,14 +420,14 @@ def _get_foraging_bouts( ts_duration = (wheel_end - wheel_start) / np.timedelta64( 1, "s" ) # in seconds - if ts_duration < min_duration: + if ts_duration < min_bout_duration: return nb_bouts wheel_data = acquisition.FoodPatchWheel.get_wheel_data( experiment_name="exp0.2-r0", start=wheel_start, end=wheel_end, - patch_name=row["food_patch_description"], + patch_name=visit_per_day_row["food_patch_description"], using_aeon_io=using_aeon_io, ) if wheel_data.distance_travelled[-1] > wheel_dist_crit: @@ -438,7 +447,7 @@ def _get_foraging_bouts( ts_duration = (ts_array[-1] - ts_array[0]) / np.timedelta64( 1, "s" ) # in seconds - if ts_duration < min_duration: + if ts_duration < min_bout_duration: continue wheel_start, wheel_end = ts_array[0], ts_array[-1] @@ -449,96 +458,16 @@ def _get_foraging_bouts( experiment_name="exp0.2-r0", start=wheel_start, end=wheel_end, - patch_name=row["food_patch_description"], + patch_name=visit_per_day_row["food_patch_description"], using_aeon_io=using_aeon_io, ) if wheel_data.distance_travelled[-1] > wheel_dist_crit: nb_bouts += 1 - print(f"nb_bouts = {nb_bouts}") + # print(f"nb_bouts = {nb_bouts}") return nb_bouts - @classmethod - def plot_foraging_bouts( - cls, - experiment_name="exp0.2-r0", - duration_crit=24, - wheel_dist_crit=1, - min_duration=1, - using_aeon_io=False, - ): - - import matplotlib.pyplot as plt - import seaborn as sns - - df = pd.DataFrame() - visit_dict = ( - VisitEnd - & f'experiment_name="{experiment_name}"' - & f"visit_duration > {duration_crit}" - ).fetch("subject", "visit_start", "visit_end", as_dict=True) - style = "food_patch_description" - - for _ in visit_dict: - - subject, visit_start, visit_end = ( - _["subject"], - _["visit_start"], - _["visit_end"], - ) - restr = { - "experiment_name": experiment_name, - "subject": subject, - "visit_start": visit_start, - "visit_end": visit_end, - } - - temp_df = ( - ( - (cls.FoodPatch & restr) - * acquisition.ExperimentFoodPatch.proj("food_patch_description") - ) - .fetch(format="frame") - .reset_index() - ) - - temp_df["subject"] = "_".join([subject, visit_start.strftime("%m%d")]) - temp_df["day"] = temp_df["visit_date"] - temp_df["visit_date"].min() - temp_df["day"] = temp_df["day"].dt.days - temp_df["foraging_bouts"] = temp_df.apply( - cls._get_foraging_bouts, - wheel_dist_crit=wheel_dist_crit, - min_duration=min_duration, - using_aeon_io=using_aeon_io, - axis=1, - ) - - df = pd.concat([df, temp_df], ignore_index=True) - - fig, ax = plt.subplots(figsize=(12, 6)) - sns.lineplot( - data=df, - x="day", - y="foraging_bouts", - hue="subject", - style=style, - ax=ax, - marker="o", - ) - - ax.legend( - loc="center left", - bbox_to_anchor=(1.01, 0.5), - prop={"size": 12}, - ) - ax.set_ylabel("foraging_bouts".replace("_", " ")) - ax.spines["top"].set_visible(False) - ax.spines["right"].set_visible(False) - plt.tight_layout() - - return fig - @schema class VisitSummary(dj.Computed): @@ -681,91 +610,3 @@ def make(self, key): } ) self.FoodPatch.insert(food_patch_statistics) - - @classmethod - def plot_summary( - cls, - attr, - experiment_name="exp0.2-r0", - duration_crit=24, - per_food_patch=False, - ): - """plot results from the visit summary table - - Args: - attr (str): name of the attribute to plot (e.g., 'pellet_count', 'wheel_distance_travelled', 'total_distance_travelled') - experiment_name (str): name of the experiment. Defaults to "exp0.2-r0". - duration_crit (int, optional): minimum total duration of the visit to plot (in hrs). Defaults to 24. - per_food_patch (bool, optional): separately plot results from different food patch. Defaults to False. - - Returns: - fig: figure object - - Examples: - >>> fig = VisitSummary.plot_summary(attr='pellet_count', per_food_patch=True) - >>> fig = VisitSummary.plot_summary(attr='wheel_distance_travelled', per_food_patch=True) - >>> fig = VisitSummary.plot_summary(attr='total_distance_travelled') - """ - - import matplotlib.pyplot as plt - import seaborn as sns - - df = pd.DataFrame() - visit_dict = ( - VisitEnd - & f'experiment_name="{experiment_name}"' - & f"visit_duration > {duration_crit}" - ).fetch("subject", "visit_start", "visit_end", as_dict=True) - style = "food_patch_description" if per_food_patch else None - - for _ in visit_dict: - - subject, visit_start, visit_end = ( - _["subject"], - _["visit_start"], - _["visit_end"], - ) - restr = { - "experiment_name": experiment_name, - "subject": subject, - "visit_start": visit_start, - "visit_end": visit_end, - } - if per_food_patch: - temp_df = ( - ( - (cls.FoodPatch & restr) - * acquisition.ExperimentFoodPatch.proj("food_patch_description") - ) - .fetch(format="frame") - .reset_index() - ) - else: - temp_df = ((cls & restr)).fetch(format="frame").reset_index() - temp_df["subject"] = "_".join([subject, visit_start.strftime("%m%d")]) - temp_df["day"] = temp_df["visit_date"] - temp_df["visit_date"].min() - temp_df["day"] = temp_df["day"].dt.days - df = pd.concat([df, temp_df], ignore_index=True) - - fig, ax = plt.subplots(figsize=(12, 6)) - sns.lineplot( - data=df, - x="day", - y=attr, - hue="subject", - style=style, - ax=ax, - marker="o", - ) - - ax.legend( - loc="center left", - bbox_to_anchor=(1.01, 0.5), - prop={"size": 12}, - ) - ax.set_ylabel(attr.replace("_", " ")) - ax.spines["top"].set_visible(False) - ax.spines["right"].set_visible(False) - plt.tight_layout() - - return fig diff --git a/aeon/dj_pipeline/utils/plotting.py b/aeon/dj_pipeline/utils/plotting.py index ec475fa3..0363ae86 100644 --- a/aeon/dj_pipeline/utils/plotting.py +++ b/aeon/dj_pipeline/utils/plotting.py @@ -4,6 +4,7 @@ import pandas as pd import plotly.express as px import plotly.io as pio +import seaborn as sns from aeon.dj_pipeline import acquisition, analysis, lab from aeon.dj_pipeline.analysis.visit import Visit, VisitEnd @@ -224,9 +225,6 @@ def plot_visit_summary( >>> fig = VisitSummary.plot_summary(attr='total_distance_travelled') """ - import matplotlib.pyplot as plt - import seaborn as sns - df = pd.DataFrame() visit_dict = ( VisitEnd @@ -298,22 +296,19 @@ def plot_summary_per_visit( """plot results from VisitSummary per visit Args: - visit_key (dict) : key from the VisitSummary table - attr (str): name of the attribute to plot (e.g., 'pellet_count', 'wheel_distance_travelled', 'total_distance_travelled') - per_food_patch (bool, optional): separately plot results from different food patch. Defaults to False. + visit_key (dict) : Key from the VisitSummary table + attr (str): Name of the attribute to plot (e.g., 'pellet_count', 'wheel_distance_travelled', 'total_distance_travelled') + per_food_patch (bool, optional): Separately plot results from different food patch. Defaults to False. Returns: - fig: figure object + fig: Figure object Examples: - >>> fig = plot_summary_per_visit(VisitSummary, visit_key, attr='pellet_count', per_food_patch=True) - >>> fig = plot_summary_per_visit(VisitSummary, visit_key, attr='wheel_distance_travelled', per_food_patch=True) - >>> fig = plot_summary_per_visit(VisitSummary, visit_key, attr='total_distance_travelled') + >>> fig = plot_summary_per_visit(visit_key, attr='pellet_count', per_food_patch=True) + >>> fig = plot_summary_per_visit(visit_key, attr='wheel_distance_travelled', per_food_patch=True) + >>> fig = plot_summary_per_visit(visit_key, attr='total_distance_travelled') """ - import matplotlib.pyplot as plt - import seaborn as sns - subject, visit_start = ( visit_key["subject"], visit_key["visit_start"], @@ -330,7 +325,9 @@ def plot_summary_per_visit( .reset_index() ) else: - visit_per_day_df = ((VisitSummary & visit_key)).fetch(format="frame").reset_index() + visit_per_day_df = ( + ((VisitSummary & visit_key)).fetch(format="frame").reset_index() + ) if not attr.startswith("total"): attr = "total_" + attr @@ -345,7 +342,6 @@ def plot_summary_per_visit( data=visit_per_day_df, x="day", y=attr, - hue="subject", style=style, ax=ax, marker="o", @@ -364,135 +360,57 @@ def plot_summary_per_visit( return fig -def _get_foraging_bouts(row, wheel_dist_crit=1, min_duration=1, using_aeon_io=False): - - # Get number of foraging bouts - nb_bouts = 0 - - in_patch = row["in_patch"] - if np.size(in_patch) == 0: # no food patch position timestamps - return nb_bouts - - change_ind = ( - np.where((np.diff(in_patch) / 1e6) > np.timedelta64(20))[0] + 1 - ) # timestamp index where state changes - - print(row["subject"], row["visit_date"], row["food_patch_description"]) - - if np.size(change_ind) == 0: # one contiguous block - - wheel_start, wheel_end = in_patch[0], in_patch[-1] - ts_duration = (wheel_end - wheel_start) / np.timedelta64(1, "s") # in seconds - if ts_duration < min_duration: - return nb_bouts - - wheel_data = acquisition.FoodPatchWheel.get_wheel_data( - experiment_name="exp0.2-r0", - start=wheel_start, - end=wheel_end, - patch_name=row["food_patch_description"], - using_aeon_io=using_aeon_io, - ) - if wheel_data.distance_travelled[-1] > wheel_dist_crit: - return nb_bouts + 1 - else: - return nb_bouts - - # fetch contiguous timestamp blocks - for i in range(len(change_ind) + 1): - if i == 0: - ts_array = in_patch[: change_ind[i]] - elif i == len(change_ind): - ts_array = in_patch[change_ind[i - 1] :] - else: - ts_array = in_patch[change_ind[i - 1] : change_ind[i]] - - ts_duration = (ts_array[-1] - ts_array[0]) / np.timedelta64( - 1, "s" - ) # in seconds - if ts_duration < min_duration: - continue - - wheel_start, wheel_end = ts_array[0], ts_array[-1] - if wheel_start > wheel_end: # skip if timestamps were misaligned - continue - - wheel_data = acquisition.FoodPatchWheel.get_wheel_data( - experiment_name="exp0.2-r0", - start=wheel_start, - end=wheel_end, - patch_name=row["food_patch_description"], - using_aeon_io=using_aeon_io, - ) - - if wheel_data.distance_travelled[-1] > wheel_dist_crit: - nb_bouts += 1 - - print(f"nb_bouts = {nb_bouts}") - return nb_bouts - - def plot_foraging_bouts( - cls, - experiment_name="exp0.2-r0", - duration_crit=24, - wheel_dist_crit=1, - min_duration=1, + visit_key, + wheel_dist_crit=None, + min_bout_duration=None, using_aeon_io=False, ): + """plot the number of foraging bouts per visit - import matplotlib.pyplot as plt - import seaborn as sns - - df = pd.DataFrame() - visit_dict = ( - VisitEnd - & f'experiment_name="{experiment_name}"' - & f"visit_duration > {duration_crit}" - ).fetch("subject", "visit_start", "visit_end", as_dict=True) - - for _ in visit_dict: + Args: + visit_key (dict) : Key from the VisitTimeDistribution table + wheel_dist_crit (int) : Minimum wheel distance travelled (in cm) + min_bout_duration (int) : Minimum foraging bout duration (in seconds) + using_aeon_io (bool) : Use aeon api to calculate wheel distance. Otherwise use datajoint tables. Defaults to False. - subject, visit_start, visit_end = ( - _["subject"], - _["visit_start"], - _["visit_end"], - ) - restr = { - "experiment_name": experiment_name, - "subject": subject, - "visit_start": visit_start, - "visit_end": visit_end, - } + Returns: + fig: Figure object - temp_df = ( - ( - (cls.FoodPatch & restr) - * acquisition.ExperimentFoodPatch.proj("food_patch_description") - ) - .fetch(format="frame") - .reset_index() - ) + Examples: + >>> fig = plot_foraging_bouts(visit_key, wheel_dist_crit=1, min_bout_duration=1) + """ + + subject, visit_start = ( + visit_key["subject"], + visit_key["visit_start"], + ) - temp_df["subject"] = "_".join([subject, visit_start.strftime("%m%d")]) - temp_df["day"] = temp_df["visit_date"] - temp_df["visit_date"].min() - temp_df["day"] = temp_df["day"].dt.days - temp_df["foraging_bouts"] = temp_df.apply( - cls._get_foraging_bouts, - wheel_dist_crit=wheel_dist_crit, - min_duration=min_duration, - using_aeon_io=using_aeon_io, - axis=1, + visit_per_day_df = ( + ( + (VisitTimeDistribution.FoodPatch & visit_key) + * acquisition.ExperimentFoodPatch.proj("food_patch_description") ) + .fetch(format="frame") + .reset_index() + ) - df = pd.concat([df, temp_df], ignore_index=True) + visit_per_day_df["subject"] = "_".join([subject, visit_start.strftime("%m%d")]) + visit_per_day_df["day"] = ( + visit_per_day_df["visit_date"] - visit_per_day_df["visit_date"].min() + ) + visit_per_day_df["day"] = visit_per_day_df["day"].dt.days + visit_per_day_df["foraging_bouts"] = visit_per_day_df.apply( + VisitTimeDistribution._get_foraging_bouts, + args=(wheel_dist_crit, min_bout_duration, using_aeon_io), + axis=1, + ) fig, ax = plt.subplots(figsize=(12, 6)) sns.lineplot( - data=df, + data=visit_per_day_df, x="day", y="foraging_bouts", - hue="subject", style="food_patch_description", ax=ax, marker="o", From 4e816f9ff9cdb6898c2bcf36c4c4e533687b5274 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 8 Aug 2022 17:07:50 +0000 Subject: [PATCH 068/489] =?UTF-8?q?=F0=9F=92=84=20convert=20the=20output?= =?UTF-8?q?=20figure=20from=20plot=5Fsummary=5Fper=5Fvisit=20to=20plotly?= =?UTF-8?q?=20object?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aeon/dj_pipeline/utils/plotting.py | 124 +++++------------------------ 1 file changed, 19 insertions(+), 105 deletions(-) diff --git a/aeon/dj_pipeline/utils/plotting.py b/aeon/dj_pipeline/utils/plotting.py index 0363ae86..5c185cdc 100644 --- a/aeon/dj_pipeline/utils/plotting.py +++ b/aeon/dj_pipeline/utils/plotting.py @@ -201,93 +201,6 @@ def plot_average_time_distribution(session_keys): return fig -def plot_visit_summary( - visit_key, - attr, - experiment_name="exp0.2-r0", - duration_crit=24, - per_food_patch=False, -): - """plot results from the visit summary table - - Args: - attr (str): name of the attribute to plot (e.g., 'pellet_count', 'wheel_distance_travelled', 'total_distance_travelled') - experiment_name (str): name of the experiment. Defaults to "exp0.2-r0". - duration_crit (int, optional): minimum total duration of the visit to plot (in hrs). Defaults to 24. - per_food_patch (bool, optional): separately plot results from different food patch. Defaults to False. - - Returns: - fig: figure object - - Examples: - >>> fig = VisitSummary.plot_summary(attr='pellet_count', per_food_patch=True) - >>> fig = VisitSummary.plot_summary(attr='wheel_distance_travelled', per_food_patch=True) - >>> fig = VisitSummary.plot_summary(attr='total_distance_travelled') - """ - - df = pd.DataFrame() - visit_dict = ( - VisitEnd - & f'experiment_name="{experiment_name}"' - & f"visit_duration > {duration_crit}" - ).fetch("subject", "visit_start", "visit_end", as_dict=True) - style = "food_patch_description" if per_food_patch else None - - for _ in visit_dict: - - subject, visit_start, visit_end = ( - _["subject"], - _["visit_start"], - _["visit_end"], - ) - restr = { - "experiment_name": experiment_name, - "subject": subject, - "visit_start": visit_start, - "visit_end": visit_end, - } - if per_food_patch: - visit_per_day_df = ( - ( - (table.FoodPatch & restr) - * acquisition.ExperimentFoodPatch.proj("food_patch_description") - ) - .fetch(format="frame") - .reset_index() - ) - else: - visit_per_day_df = ((table & restr)).fetch(format="frame").reset_index() - visit_per_day_df["subject"] = "_".join([subject, visit_start.strftime("%m%d")]) - visit_per_day_df["day"] = ( - visit_per_day_df["visit_date"] - visit_per_day_df["visit_date"].min() - ) - visit_per_day_df["day"] = visit_per_day_df["day"].dt.days - df = pd.concat([df, visit_per_day_df], ignore_index=True) - - fig, ax = plt.subplots(figsize=(12, 6)) - sns.lineplot( - data=df, - x="day", - y=attr, - hue="subject", - style=style, - ax=ax, - marker="o", - ) - - ax.legend( - loc="center left", - bbox_to_anchor=(1.01, 0.5), - prop={"size": 12}, - ) - ax.set_ylabel(attr.replace("_", " ")) - ax.spines["top"].set_visible(False) - ax.spines["right"].set_visible(False) - plt.tight_layout() - - return fig - - def plot_summary_per_visit( visit_key, attr, @@ -298,7 +211,7 @@ def plot_summary_per_visit( Args: visit_key (dict) : Key from the VisitSummary table attr (str): Name of the attribute to plot (e.g., 'pellet_count', 'wheel_distance_travelled', 'total_distance_travelled') - per_food_patch (bool, optional): Separately plot results from different food patch. Defaults to False. + per_food_patch (bool, optional): Separately plot results from different food patches. Defaults to False. Returns: fig: Figure object @@ -313,7 +226,9 @@ def plot_summary_per_visit( visit_key["subject"], visit_key["visit_start"], ) - style = "food_patch_description" if per_food_patch else None + + per_food_patch = False if attr.startswith("total") else True + color = "food_patch_description" if per_food_patch else None if per_food_patch: # split by food patch visit_per_day_df = ( @@ -337,25 +252,24 @@ def plot_summary_per_visit( ) visit_per_day_df["day"] = visit_per_day_df["day"].dt.days - fig, ax = plt.subplots(figsize=(12, 6)) - sns.lineplot( - data=visit_per_day_df, + fig = px.line( + visit_per_day_df, x="day", y=attr, - style=style, - ax=ax, - marker="o", + color=color, + markers=True, + labels={attr: attr.replace("_", " ")}, + hover_name="visit_date", + hover_data=["visit_date"], + width=700, + height=400, + template="simple_white", + title=visit_per_day_df["subject"][0], ) - - ax.legend( - loc="center left", - bbox_to_anchor=(1.01, 0.5), - prop={"size": 12}, + fig.update_traces(mode="markers+lines", hovertemplate=None) + fig.update_layout( + legend_title="", hovermode="x", yaxis_tickformat="digits", yaxis_range=[0, None] ) - ax.set_ylabel(attr.replace("_", " ")) - ax.spines["top"].set_visible(False) - ax.spines["right"].set_visible(False) - plt.tight_layout() return fig @@ -380,7 +294,7 @@ def plot_foraging_bouts( Examples: >>> fig = plot_foraging_bouts(visit_key, wheel_dist_crit=1, min_bout_duration=1) """ - + subject, visit_start = ( visit_key["subject"], visit_key["visit_start"], From 2e72fb3f7df792aebbf42da37b33db5dd069780d Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 8 Aug 2022 18:32:01 +0000 Subject: [PATCH 069/489] convert plot_foraging_bouts to plotly --- aeon/dj_pipeline/utils/plotting.py | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/aeon/dj_pipeline/utils/plotting.py b/aeon/dj_pipeline/utils/plotting.py index 5c185cdc..fb4a34f6 100644 --- a/aeon/dj_pipeline/utils/plotting.py +++ b/aeon/dj_pipeline/utils/plotting.py @@ -320,24 +320,23 @@ def plot_foraging_bouts( axis=1, ) - fig, ax = plt.subplots(figsize=(12, 6)) - sns.lineplot( - data=visit_per_day_df, + fig = px.line( + visit_per_day_df, x="day", y="foraging_bouts", - style="food_patch_description", - ax=ax, - marker="o", + color="food_patch_description", + markers=True, + labels={"foraging_bouts": "foraging_bouts".replace("_", " ")}, + hover_name="visit_date", + hover_data=["visit_date"], + width=700, + height=400, + template="simple_white", + title=visit_per_day_df["subject"][0], ) - - ax.legend( - loc="center left", - bbox_to_anchor=(1.01, 0.5), - prop={"size": 12}, + fig.update_traces(mode="markers+lines", hovertemplate=None) + fig.update_layout( + legend_title="", hovermode="x", yaxis_tickformat="digits", yaxis_range=[0, None] ) - ax.set_ylabel("foraging_bouts".replace("_", " ")) - ax.spines["top"].set_visible(False) - ax.spines["right"].set_visible(False) - plt.tight_layout() return fig From 7dc8bd1a8db568ace272a11a4bb0855da9ac84f2 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 8 Aug 2022 19:55:46 +0000 Subject: [PATCH 070/489] =?UTF-8?q?=E2=9C=A8=20added=20VisitSummaryPlot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aeon/dj_pipeline/report.py | 68 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/aeon/dj_pipeline/report.py b/aeon/dj_pipeline/report.py index d3006f42..f5bc4ecb 100644 --- a/aeon/dj_pipeline/report.py +++ b/aeon/dj_pipeline/report.py @@ -10,12 +10,18 @@ import pandas as pd from aeon.analysis import plotting as analysis_plotting +from aeon.dj_pipeline.analysis.visit import Visit, VisitEnd +from aeon.dj_pipeline.analysis.visit_analysis import * from . import acquisition, analysis, get_schema_name schema = dj.schema(get_schema_name("report")) os.environ["DJ_SUPPORT_FILEPATH_MANAGEMENT"] = "TRUE" +experiment_name = "exp0.2-r0" +MIN_VISIT_DURATION = 24 # in hours (minimum duration of visit for analysis) +WHEEL_DIST_CRIT = 1 # in cm (minimum wheel distance travelled) +MIN_BOUT_DURATION = 1 # in seconds (minimum foraging bout duration) """ DataJoint schema dedicated for tables containing figures @@ -442,6 +448,68 @@ def delete_outdated_plot_entries(): tbl.delete_outdated_entries() +@schema +class VisitSummaryPlot(dj.Computed): + definition = """ + -> VisitSummary + --- + pellet_count_plotly: longblob # Dictionary storing the plotly object (from fig.to_plotly_json()) + wheel_distance_travelled_plotly: longblob + total_distance_travelled_plotly: longblob + foraging_bouts_plotly: longblob + """ + + key_source = dj.U("experiment_name", "subject", "visit_start", "visit_end") & ( + VisitEnd + & f'experiment_name="{experiment_name}"' + & f"visit_duration > {MIN_VISIT_DURATION}" + ) + + def make(self, key): + from aeon.dj_pipeline.utils.plotting import ( + plot_foraging_bouts, + plot_summary_per_visit, + ) + + fig = plot_summary_per_visit( + key, + attr="pellet_count", + per_food_patch=True, + ) + fig_pellet = json.loads(fig.to_json()) + + fig = plot_summary_per_visit( + key, + attr="wheel_distance_travelled", + per_food_patch=True, + ) + fig_wheel_dist = json.loads(fig.to_json()) + + fig = plot_summary_per_visit( + key, + attr="total_distance_travelled", + ) + fig_total_dist = json.loads(fig.to_json()) + + fig = plot_foraging_bouts( + key, + wheel_dist_crit=WHEEL_DIST_CRIT, + min_bout_duration=MIN_BOUT_DURATION, + using_aeon_io=False, + ) + fig_foraginng_bouts = json.loads(fig.to_json()) + + self.insert1( + { + **key, + "pellet_count_plotly": fig_pellet, + "wheel_distance_travelled_plotly": fig_wheel_dist, + "total_distance_travelled_plotly": fig_total_dist, + "foraging_bouts_plotly": fig_foraginng_bouts, + } + ) + + # ---------- HELPER FUNCTIONS -------------- From bae932d0e1b2746d49d89be933b570fe513deada Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 8 Aug 2022 20:20:25 +0000 Subject: [PATCH 071/489] =?UTF-8?q?=F0=9F=94=A5=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aeon/dj_pipeline/utils/plotting.py | 1 - aeon/dj_pipeline/visit_report.py | 263 ----------------------------- fig.png | Bin 115676 -> 0 bytes 3 files changed, 264 deletions(-) delete mode 100644 aeon/dj_pipeline/visit_report.py delete mode 100644 fig.png diff --git a/aeon/dj_pipeline/utils/plotting.py b/aeon/dj_pipeline/utils/plotting.py index bf15ce57..fb4a34f6 100644 --- a/aeon/dj_pipeline/utils/plotting.py +++ b/aeon/dj_pipeline/utils/plotting.py @@ -5,7 +5,6 @@ import plotly.express as px import plotly.io as pio import seaborn as sns -import seaborn as sns from aeon.dj_pipeline import acquisition, analysis, lab from aeon.dj_pipeline.analysis.visit import Visit, VisitEnd diff --git a/aeon/dj_pipeline/visit_report.py b/aeon/dj_pipeline/visit_report.py deleted file mode 100644 index 6cb503c8..00000000 --- a/aeon/dj_pipeline/visit_report.py +++ /dev/null @@ -1,263 +0,0 @@ -import datetime -import json -import os -import pathlib -import re - -import datajoint as dj -import matplotlib.pyplot as plt -import numpy as np -import pandas as pd - -from aeon.analysis import plotting as analysis_plotting -from aeon.dj_pipeline.analysis.visit import Visit, VisitEnd -from aeon.dj_pipeline.analysis.visit_analysis import * - -from . import acquisition, analysis, get_schema_name - -# schema = dj.schema(get_schema_name("report")) -schema = dj.schema(get_schema_name("u_jaeronga_test")) -os.environ["DJ_SUPPORT_FILEPATH_MANAGEMENT"] = "TRUE" - -experiment_name = "exp0.2-r0" -MIN_VISIT_DURATION = 24 # in hours (minimum duration of visit for analysis) -WHEEL_DIST_CRIT = 1 # in cm (minimum wheel distance travelled) -MIN_BOUT_DURATION = 1 # in seconds (minimum foraging bout duration) - - -""" -DataJoint schema dedicated for tables containing figures -""" - - -@schema -class VisitSummaryPlot(dj.Computed): - definition = """ - -> VisitSummary - --- - pellet_count_png: attach - wheel_distance_travelled_png: attach - total_distance_travelled_png: attach - """ - - key_source = dj.U("experiment_name", "subject", "visit_start", "visit_end") & ( - VisitEnd - & f'experiment_name="{experiment_name}"' - & f"visit_duration > {MIN_VISIT_DURATION}" - ) - - def make(self, key): - from aeon.dj_pipeline.utils.plotting import plot_summary_per_visit - - fig = plot_summary_per_visit( - key, - attr="pellet_count", - per_food_patch=True, - ) - - fig = plot_summary_per_visit( - key, - attr="wheel_distance_travelled", - per_food_patch=True, - ) - - fig = plot_summary_per_visit( - key, - attr="total_distance_travelled", - per_food_patch=False, - ) - - # ---- Save fig and insert ---- - save_dir = _make_path(key) - fig_dict = _save_figs( - (fig,), ("summary_plot_png",), save_dir=save_dir, prefix=save_dir.name - ) - - self.insert1({**key, **fig_dict}) - - -# ---- Dynamically updated tables for plots ---- - - -@schema -class SubjectRewardRateDifference(dj.Computed): - definition = """ - -> acquisition.Experiment.Subject - --- - in_arena_count: int - reward_rate_difference_plotly: longblob # dictionary storing the plotly object (from fig.to_plotly_json()) - """ - - key_source = acquisition.Experiment.Subject & analysis.InArenaRewardRate - - def make(self, key): - from aeon.dj_pipeline.utils.plotting import plot_reward_rate_differences - - fig = plot_reward_rate_differences(key) - - fig_json = json.loads(fig.to_json()) - - self.insert1( - { - **key, - "in_arena_count": len(analysis.InArenaRewardRate & key), - "reward_rate_difference_plotly": fig_json, - } - ) - - @classmethod - def delete_outdated_entries(cls): - """ - Each entry in this table correspond to one subject. However, the plot is capturing - data for all sessions. - Hence a dynamic update routine is needed to recompute the plot as new sessions - become available - """ - outdated_entries = ( - cls - * ( - acquisition.Experiment.Subject.aggr( - analysis.InArenaRewardRate, - current_in_arena_count="count(in_arena_start)", - ) - ) - & "in_arena_count != current_in_arena_count" - ) - with dj.config(safemode=False): - (cls & outdated_entries.fetch("KEY")).delete() - - -@schema -class SubjectWheelTravelledDistance(dj.Computed): - definition = """ - -> acquisition.Experiment.Subject - --- - in_arena_count: int - wheel_travelled_distance_plotly: longblob # dictionary storing the plotly object (from fig.to_plotly_json()) - """ - - key_source = acquisition.Experiment.Subject & analysis.InArenaSummary - - def make(self, key): - from aeon.dj_pipeline.utils.plotting import plot_wheel_travelled_distance - - in_arena_keys = (analysis.InArenaSummary & key).fetch("KEY") - - fig = plot_wheel_travelled_distance(in_arena_keys) - - fig_json = json.loads(fig.to_json()) - - self.insert1( - { - **key, - "in_arena_count": len(in_arena_keys), - "wheel_travelled_distance_plotly": fig_json, - } - ) - - @classmethod - def delete_outdated_entries(cls): - """ - Each entry in this table correspond to one subject. However the plot is capturing - data for all sessions. - Hence a dynamic update routine is needed to recompute the plot as new sessions - become available - """ - outdated_entries = ( - cls - * ( - acquisition.Experiment.Subject.aggr( - analysis.InArenaSummary, - current_in_arena_count="count(in_arena_start)", - ) - ) - & "in_arena_count != current_in_arena_count" - ) - with dj.config(safemode=False): - (cls & outdated_entries.fetch("KEY")).delete() - - -@schema -class ExperimentTimeDistribution(dj.Computed): - definition = """ - -> acquisition.Experiment - --- - in_arena_count: int - time_distribution_plotly: longblob # dictionary storing the plotly object (from fig.to_plotly_json()) - """ - - def make(self, key): - from aeon.dj_pipeline.utils.plotting import plot_average_time_distribution - - in_arena_keys = (analysis.InArenaTimeDistribution & key).fetch("KEY") - - fig = plot_average_time_distribution(in_arena_keys) - - fig_json = json.loads(fig.to_json()) - - self.insert1( - { - **key, - "in_arena_count": len(in_arena_keys), - "time_distribution_plotly": fig_json, - } - ) - - @classmethod - def delete_outdated_entries(cls): - """ - Each entry in this table correspond to one subject. However the plot is capturing - data for all sessions. - Hence a dynamic update routine is needed to recompute the plot as new sessions - become available - """ - outdated_entries = ( - cls - * ( - acquisition.Experiment.aggr( - analysis.InArenaTimeDistribution, - current_in_arena_count="count(in_arena_start)", - ) - ) - & "in_arena_count != current_in_arena_count" - ) - with dj.config(safemode=False): - (cls & outdated_entries.fetch("KEY")).delete() - - -def delete_outdated_plot_entries(): - for tbl in ( - SubjectRewardRateDifference, - SubjectWheelTravelledDistance, - ExperimentTimeDistribution, - ): - tbl.delete_outdated_entries() - - -# ---------- HELPER FUNCTIONS -------------- - - -def _make_path(in_arena_key): - store_stage = pathlib.Path(dj.config["stores"]["djstore"]["stage"]) - experiment_name, subject, in_arena_start = (analysis.InArena & in_arena_key).fetch1( - "experiment_name", "subject", "in_arena_start" - ) - output_dir = ( - store_stage - / experiment_name - / subject - / in_arena_start.strftime("%y%m%d_%H%M%S_%f") - ) - output_dir.mkdir(parents=True, exist_ok=True) - return output_dir - - -def _save_figs(figs, fig_names, save_dir, prefix, extension=".png"): - fig_dict = {} - for fig, figname in zip(figs, fig_names): - fig_fp = save_dir / (prefix + "_" + figname + extension) - fig.tight_layout() - fig.savefig(fig_fp, dpi=300) - fig_dict[figname] = fig_fp.as_posix() - - return fig_dict diff --git a/fig.png b/fig.png deleted file mode 100644 index b0f68121f5df041d0636a2011093b81e7f3bf65d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 115676 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

    o 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%0D|?F_4%>qyfP6s2VWg=Xc2N`j zkLA5a-{ay)l13d)R=CPKkl!9eYV{XQ4NIGjCHn$mDA#Sn27E^!kX-=Y*kI9?5%+8} zm}m-TtkvAg(R9N2og zF%lXXA68vm{Vi;~A&3AtjPe`2ze2PYvp56+KOI1DaB%S6u6zL=2JPYDfr^Ui!?Esx z;n?Ct_mPzq6YzD(0^a<9|A_eg+t}l7GdLkrB1dr}#VoVu5ZE!`&v=@;#&^j#WbM@T z`3>vECX8k9;B>fO_fF=BOf}i*U+qmn06Q^*Kmq~+96FbEh@V)CMu!Ulf-3t Date: Mon, 8 Aug 2022 21:51:00 +0000 Subject: [PATCH 072/489] change function names --- aeon/dj_pipeline/report.py | 10 +++++----- aeon/dj_pipeline/utils/plotting.py | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/aeon/dj_pipeline/report.py b/aeon/dj_pipeline/report.py index f5bc4ecb..85d2b93e 100644 --- a/aeon/dj_pipeline/report.py +++ b/aeon/dj_pipeline/report.py @@ -449,7 +449,7 @@ def delete_outdated_plot_entries(): @schema -class VisitSummaryPlot(dj.Computed): +class VisitDailySummaryPlot(dj.Computed): definition = """ -> VisitSummary --- @@ -468,24 +468,24 @@ class VisitSummaryPlot(dj.Computed): def make(self, key): from aeon.dj_pipeline.utils.plotting import ( plot_foraging_bouts, - plot_summary_per_visit, + plot_visit_daily_summary, ) - fig = plot_summary_per_visit( + fig = plot_visit_daily_summary( key, attr="pellet_count", per_food_patch=True, ) fig_pellet = json.loads(fig.to_json()) - fig = plot_summary_per_visit( + fig = plot_visit_daily_summary( key, attr="wheel_distance_travelled", per_food_patch=True, ) fig_wheel_dist = json.loads(fig.to_json()) - fig = plot_summary_per_visit( + fig = plot_visit_daily_summary( key, attr="total_distance_travelled", ) diff --git a/aeon/dj_pipeline/utils/plotting.py b/aeon/dj_pipeline/utils/plotting.py index fb4a34f6..0e97b751 100644 --- a/aeon/dj_pipeline/utils/plotting.py +++ b/aeon/dj_pipeline/utils/plotting.py @@ -201,7 +201,7 @@ def plot_average_time_distribution(session_keys): return fig -def plot_summary_per_visit( +def plot_visit_daily_summary( visit_key, attr, per_food_patch=False, @@ -217,9 +217,9 @@ def plot_summary_per_visit( fig: Figure object Examples: - >>> fig = plot_summary_per_visit(visit_key, attr='pellet_count', per_food_patch=True) - >>> fig = plot_summary_per_visit(visit_key, attr='wheel_distance_travelled', per_food_patch=True) - >>> fig = plot_summary_per_visit(visit_key, attr='total_distance_travelled') + >>> fig = plot_visit_daily_summary(visit_key, attr='pellet_count', per_food_patch=True) + >>> fig = plot_visit_daily_summary(visit_key, attr='wheel_distance_travelled', per_food_patch=True) + >>> fig = plot_visit_daily_summary(visit_key, attr='total_distance_travelled') """ subject, visit_start = ( @@ -227,7 +227,7 @@ def plot_summary_per_visit( visit_key["visit_start"], ) - per_food_patch = False if attr.startswith("total") else True + per_food_patch = not attr.startswith("total") color = "food_patch_description" if per_food_patch else None if per_food_patch: # split by food patch From 48e96913b9efecc89dc3c47f589fe266e0b727e9 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 9 Aug 2022 15:15:43 +0000 Subject: [PATCH 073/489] =?UTF-8?q?=F0=9F=8E=A8=20move=20=5Fget=5Fforaging?= =?UTF-8?q?=5Fbouts=20to=20plotting.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aeon/dj_pipeline/analysis/visit_analysis.py | 80 ------------------- aeon/dj_pipeline/report.py | 3 +- aeon/dj_pipeline/utils/plotting.py | 85 ++++++++++++++++++++- 3 files changed, 85 insertions(+), 83 deletions(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index d4b944e8..16305747 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -388,86 +388,6 @@ def make(self, key): self.Nest.insert(in_nest_times) self.FoodPatch.insert(in_food_patch_times) - @classmethod - def _get_foraging_bouts( - cls, - visit_per_day_row, - wheel_dist_crit=None, - min_bout_duration=None, - using_aeon_io=False, - ): - - # Get number of foraging bouts - nb_bouts = 0 - - in_patch = visit_per_day_row["in_patch"] - if np.size(in_patch) == 0: # no food patch position timestamps - return nb_bouts - - change_ind = ( - np.where((np.diff(in_patch) / 1e6) > np.timedelta64(20))[0] + 1 - ) # timestamp index where state changes - - # print( - # visit_per_day_row["subject"], - # visit_per_day_row["visit_date"], - # visit_per_day_row["food_patch_description"], - # ) - - if np.size(change_ind) == 0: # one contiguous block - - wheel_start, wheel_end = in_patch[0], in_patch[-1] - ts_duration = (wheel_end - wheel_start) / np.timedelta64( - 1, "s" - ) # in seconds - if ts_duration < min_bout_duration: - return nb_bouts - - wheel_data = acquisition.FoodPatchWheel.get_wheel_data( - experiment_name="exp0.2-r0", - start=wheel_start, - end=wheel_end, - patch_name=visit_per_day_row["food_patch_description"], - using_aeon_io=using_aeon_io, - ) - if wheel_data.distance_travelled[-1] > wheel_dist_crit: - return nb_bouts + 1 - else: - return nb_bouts - - # fetch contiguous timestamp blocks - for i in range(len(change_ind) + 1): - if i == 0: - ts_array = in_patch[: change_ind[i]] - elif i == len(change_ind): - ts_array = in_patch[change_ind[i - 1] :] - else: - ts_array = in_patch[change_ind[i - 1] : change_ind[i]] - - ts_duration = (ts_array[-1] - ts_array[0]) / np.timedelta64( - 1, "s" - ) # in seconds - if ts_duration < min_bout_duration: - continue - - wheel_start, wheel_end = ts_array[0], ts_array[-1] - if wheel_start > wheel_end: # skip if timestamps were misaligned - continue - - wheel_data = acquisition.FoodPatchWheel.get_wheel_data( - experiment_name="exp0.2-r0", - start=wheel_start, - end=wheel_end, - patch_name=visit_per_day_row["food_patch_description"], - using_aeon_io=using_aeon_io, - ) - - if wheel_data.distance_travelled[-1] > wheel_dist_crit: - nb_bouts += 1 - - # print(f"nb_bouts = {nb_bouts}") - return nb_bouts - @schema class VisitSummary(dj.Computed): diff --git a/aeon/dj_pipeline/report.py b/aeon/dj_pipeline/report.py index 85d2b93e..508a1ff1 100644 --- a/aeon/dj_pipeline/report.py +++ b/aeon/dj_pipeline/report.py @@ -18,7 +18,6 @@ schema = dj.schema(get_schema_name("report")) os.environ["DJ_SUPPORT_FILEPATH_MANAGEMENT"] = "TRUE" -experiment_name = "exp0.2-r0" MIN_VISIT_DURATION = 24 # in hours (minimum duration of visit for analysis) WHEEL_DIST_CRIT = 1 # in cm (minimum wheel distance travelled) MIN_BOUT_DURATION = 1 # in seconds (minimum foraging bout duration) @@ -461,7 +460,7 @@ class VisitDailySummaryPlot(dj.Computed): key_source = dj.U("experiment_name", "subject", "visit_start", "visit_end") & ( VisitEnd - & f'experiment_name="{experiment_name}"' + & f"experiment_name= 'exp0.2-r0'" & f"visit_duration > {MIN_VISIT_DURATION}" ) diff --git a/aeon/dj_pipeline/utils/plotting.py b/aeon/dj_pipeline/utils/plotting.py index 0e97b751..7d303a2b 100644 --- a/aeon/dj_pipeline/utils/plotting.py +++ b/aeon/dj_pipeline/utils/plotting.py @@ -315,7 +315,7 @@ def plot_foraging_bouts( ) visit_per_day_df["day"] = visit_per_day_df["day"].dt.days visit_per_day_df["foraging_bouts"] = visit_per_day_df.apply( - VisitTimeDistribution._get_foraging_bouts, + _get_foraging_bouts, args=(wheel_dist_crit, min_bout_duration, using_aeon_io), axis=1, ) @@ -340,3 +340,86 @@ def plot_foraging_bouts( ) return fig + + +def _get_foraging_bouts( + visit_per_day_row, + wheel_dist_crit=None, + min_bout_duration=None, + using_aeon_io=False, +): + """A function that calculates the number of foraging bouts. Works on this table query + + (VisitTimeDistribution.FoodPatch & visit_key) + * acquisition.ExperimentFoodPatch.proj("food_patch_description") + + This will iterate over this table entries and store results in a new column ('foraging_bouts') + + Args: + visit_per_day_row (pd.DataFrame): A single row of the pandas dataframe + + Returns: + nb_bouts (int): Number of foraging bouts + """ + + # Get number of foraging bouts + nb_bouts = 0 + + in_patch = visit_per_day_row["in_patch"] + if np.size(in_patch) == 0: # no food patch position timestamps + return nb_bouts + + change_ind = ( + np.where((np.diff(in_patch) / 1e6) > np.timedelta64(20))[0] + 1 + ) # timestamp index where state changes + + if np.size(change_ind) == 0: # one contiguous block + + wheel_start, wheel_end = in_patch[0], in_patch[-1] + ts_duration = (wheel_end - wheel_start) / np.timedelta64(1, "s") # in seconds + if ts_duration < min_bout_duration: + return nb_bouts + + wheel_data = acquisition.FoodPatchWheel.get_wheel_data( + experiment_name=visit_per_day_row["experiment_name"], + start=wheel_start, + end=wheel_end, + patch_name=visit_per_day_row["food_patch_description"], + using_aeon_io=using_aeon_io, + ) + if wheel_data.distance_travelled[-1] > wheel_dist_crit: + return nb_bouts + 1 + else: + return nb_bouts + + # fetch contiguous timestamp blocks + for i in range(len(change_ind) + 1): + if i == 0: + ts_array = in_patch[: change_ind[i]] + elif i == len(change_ind): + ts_array = in_patch[change_ind[i - 1] :] + else: + ts_array = in_patch[change_ind[i - 1] : change_ind[i]] + + ts_duration = (ts_array[-1] - ts_array[0]) / np.timedelta64( + 1, "s" + ) # in seconds + if ts_duration < min_bout_duration: + continue + + wheel_start, wheel_end = ts_array[0], ts_array[-1] + if wheel_start > wheel_end: # skip if timestamps were misaligned + continue + + wheel_data = acquisition.FoodPatchWheel.get_wheel_data( + experiment_name=visit_per_day_row["experiment_name"], + start=wheel_start, + end=wheel_end, + patch_name=visit_per_day_row["food_patch_description"], + using_aeon_io=using_aeon_io, + ) + + if wheel_data.distance_travelled[-1] > wheel_dist_crit: + nb_bouts += 1 + + return nb_bouts From ef2d047b1e67be99b549379424dcc0284e46402b Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 9 Aug 2022 16:23:26 +0000 Subject: [PATCH 074/489] move global variables --- aeon/dj_pipeline/report.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/aeon/dj_pipeline/report.py b/aeon/dj_pipeline/report.py index 508a1ff1..d92fa8d0 100644 --- a/aeon/dj_pipeline/report.py +++ b/aeon/dj_pipeline/report.py @@ -18,9 +18,6 @@ schema = dj.schema(get_schema_name("report")) os.environ["DJ_SUPPORT_FILEPATH_MANAGEMENT"] = "TRUE" -MIN_VISIT_DURATION = 24 # in hours (minimum duration of visit for analysis) -WHEEL_DIST_CRIT = 1 # in cm (minimum wheel distance travelled) -MIN_BOUT_DURATION = 1 # in seconds (minimum foraging bout duration) """ DataJoint schema dedicated for tables containing figures @@ -458,6 +455,8 @@ class VisitDailySummaryPlot(dj.Computed): foraging_bouts_plotly: longblob """ + MIN_VISIT_DURATION = 24 # in hours (minimum duration of visit for analysis) + key_source = dj.U("experiment_name", "subject", "visit_start", "visit_end") & ( VisitEnd & f"experiment_name= 'exp0.2-r0'" @@ -470,6 +469,9 @@ def make(self, key): plot_visit_daily_summary, ) + WHEEL_DIST_CRIT = 1 # in cm (minimum wheel distance travelled) + MIN_BOUT_DURATION = 1 # in seconds (minimum foraging bout duration) + fig = plot_visit_daily_summary( key, attr="pellet_count", From 7e37667e3a252fc027d1cea8df022c5d11592302 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 9 Aug 2022 12:14:29 -0500 Subject: [PATCH 075/489] Update aeon/dj_pipeline/report.py Co-authored-by: Thinh Nguyen --- aeon/dj_pipeline/report.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/report.py b/aeon/dj_pipeline/report.py index d92fa8d0..bf7bade3 100644 --- a/aeon/dj_pipeline/report.py +++ b/aeon/dj_pipeline/report.py @@ -455,7 +455,7 @@ class VisitDailySummaryPlot(dj.Computed): foraging_bouts_plotly: longblob """ - MIN_VISIT_DURATION = 24 # in hours (minimum duration of visit for analysis) + _min_visit_duration = 24 # in hours (minimum duration of visit for analysis) key_source = dj.U("experiment_name", "subject", "visit_start", "visit_end") & ( VisitEnd From 26a9d649ac6c8c26b4472a317a927a73fbefe7fc Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 9 Aug 2022 12:14:38 -0500 Subject: [PATCH 076/489] Update aeon/dj_pipeline/report.py Co-authored-by: Thinh Nguyen --- aeon/dj_pipeline/report.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/report.py b/aeon/dj_pipeline/report.py index bf7bade3..6e4ed583 100644 --- a/aeon/dj_pipeline/report.py +++ b/aeon/dj_pipeline/report.py @@ -460,7 +460,7 @@ class VisitDailySummaryPlot(dj.Computed): key_source = dj.U("experiment_name", "subject", "visit_start", "visit_end") & ( VisitEnd & f"experiment_name= 'exp0.2-r0'" - & f"visit_duration > {MIN_VISIT_DURATION}" + & f"visit_duration > {self._min_visit_duration}" ) def make(self, key): From d494bc81ee8c25c178f1d0602d4eeb518690254d Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 9 Aug 2022 12:14:48 -0500 Subject: [PATCH 077/489] Update aeon/dj_pipeline/report.py Co-authored-by: Thinh Nguyen --- aeon/dj_pipeline/report.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/report.py b/aeon/dj_pipeline/report.py index 6e4ed583..a728049c 100644 --- a/aeon/dj_pipeline/report.py +++ b/aeon/dj_pipeline/report.py @@ -469,8 +469,8 @@ def make(self, key): plot_visit_daily_summary, ) - WHEEL_DIST_CRIT = 1 # in cm (minimum wheel distance travelled) - MIN_BOUT_DURATION = 1 # in seconds (minimum foraging bout duration) + wheel_dist_crit = 1 # in cm (minimum wheel distance travelled) + min_bout_duration = 1 # in seconds (minimum foraging bout duration) fig = plot_visit_daily_summary( key, From 729fdc4c2251b4651178109331336dbc09bfdcb0 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 9 Aug 2022 17:27:17 +0000 Subject: [PATCH 078/489] change variable naming convention --- aeon/dj_pipeline/report.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/aeon/dj_pipeline/report.py b/aeon/dj_pipeline/report.py index a728049c..def400e8 100644 --- a/aeon/dj_pipeline/report.py +++ b/aeon/dj_pipeline/report.py @@ -455,12 +455,12 @@ class VisitDailySummaryPlot(dj.Computed): foraging_bouts_plotly: longblob """ - _min_visit_duration = 24 # in hours (minimum duration of visit for analysis) + _min_visit_duration = 24 # in hours (minimum duration of visit for analysis) key_source = dj.U("experiment_name", "subject", "visit_start", "visit_end") & ( VisitEnd & f"experiment_name= 'exp0.2-r0'" - & f"visit_duration > {self._min_visit_duration}" + & f"visit_duration > {_min_visit_duration}" ) def make(self, key): @@ -494,8 +494,8 @@ def make(self, key): fig = plot_foraging_bouts( key, - wheel_dist_crit=WHEEL_DIST_CRIT, - min_bout_duration=MIN_BOUT_DURATION, + wheel_dist_crit=wheel_dist_crit, + min_bout_duration=min_bout_duration, using_aeon_io=False, ) fig_foraginng_bouts = json.loads(fig.to_json()) From d4cafbdee498ee29edba02b7d0df79ddf394ac9c Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 9 Aug 2022 21:25:42 +0000 Subject: [PATCH 079/489] =?UTF-8?q?=F0=9F=93=9D=20add=20pytest=20doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/conftest.py | 2 +- tests/readme.md | 61 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 4f4e1a05..0ed5c63c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,7 +15,7 @@ import datajoint as dj import pytest -_tear_down = False # always set to True since most fixtures are session-scoped +_tear_down = True # always set to True since most fixtures are session-scoped _populate_settings = {"suppress_errors": True} diff --git a/tests/readme.md b/tests/readme.md index 4287ca86..ba0602a0 100644 --- a/tests/readme.md +++ b/tests/readme.md @@ -1 +1,60 @@ -# \ No newline at end of file +# Pytest for Project Aeon + +The following pytest routine will test whether the pre-defined datajoint schemas are properly instantiated and the sample data ingested in key datajoint tables. + +For running the test, make sure you are in ```aeon``` virtual environment. +``` +module load miniconda +conda activate aeon +``` + +Currently, the test will use the following sample dataset as defined in ```test_params``` from ```tests/conftest.py``` +```python + "start_ts": "2022-06-22 08:51:10", + "end_ts": "2022-06-22 14:00:00", + "experiment_name": "exp0.2-r0", + "raw_dir": "aeon/data/raw/AEON2/experiment0.2", + "qc_dir": "aeon/data/qc/AEON2/experiment0.2", + "test_dir": data_dir(), + "subject_count": 5, + "epoch_count": 1, + "chunk_count": 7, + "experiment_log_message_count": 0, + "subject_enter_exit_count": 0, + "subject_weight_time_count": 0, + "camera_qc_count": 40, + "camera_tracking_object_count": 5, +``` + +Set `_tear_down=True` in `conftest.py` for proper cleanup of test artifacts after each testing (except for debugging/development purposes). + +The test can then be run with the following simple command at the root directory of the repo: +``` +pytest +``` + +With no command line arguments being specified, the command will run on any modules or functions that start with ```test_```) + +You can add in a series of command line arguments to get a more detailed view of the test results like the following: +``` +pytest --pdb -sv --cov-report term-missing --cov=aeon_mecha -p no:warnings +``` + +The test can also be run on a single pytest module, + +``` +pytest -k +``` +or a function. + +``` +pytest -k +``` + +You can also run tests on a specific marker: + +``` +pytest -m +``` + +For more detailed guides, please refer to [pytest documentation](https://docs.pytest.org/en/7.1.x/contents.html). \ No newline at end of file From 2d191b5456f09793403b04b5c7308dbe268ee60f Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 9 Aug 2022 21:36:06 +0000 Subject: [PATCH 080/489] uppercase readme --- readme.md => README.md | 126 ++++++++++++++++----------------- aeon/{readme.md => README.md} | 0 tests/{readme.md => README.md} | 0 3 files changed, 63 insertions(+), 63 deletions(-) rename readme.md => README.md (98%) rename aeon/{readme.md => README.md} (100%) rename tests/{readme.md => README.md} (100%) diff --git a/readme.md b/README.md similarity index 98% rename from readme.md rename to README.md index 85d11ce2..7fef90ea 100644 --- a/readme.md +++ b/README.md @@ -1,63 +1,63 @@ -# aeon_mecha -![aeon_mecha_env_build_and_tests](https://github.com/SainsburyWellcomeCentre/aeon_mecha/actions/workflows/build_env_run_tests.yml/badge.svg?branch=reorg) -[![aeon_mecha_tests_code_coverage](https://codecov.io/gh/SainsburyWellcomeCentre/aeon_mecha/branch/reorg/graph/badge.svg?token=973EC1CG03)](https://codecov.io/gh/SainsburyWellcomeCentre/aeon_mecha) - -Project Aeon's main repository for manipulating acquired data. Includes preprocessing, querying, plotting, and analysis modules. - -## Set-up Instructions - -The various set-up tools mentioned below do some combination of python version, environment, package, and package dependency management. For basic information on the differences between these tools, see this [blog post](https://dev.to/bowmanjd/python-tools-for-managing-virtual-environments-3bko#hatch). - -### Remote set-up on SWC's HPC - -#### Prereqs - -1. Ssh into the HPC GW1 node and clone this repo to your home directory. - -``` -ssh @ssh.swc.ucl.ac.uk -ssh hpc-gw1 -mkdir ~/ProjectAeon -cd ~/ProjectAeon -git clone https://github.com/SainsburyWellcomeCentre/aeon_mecha -``` - -#### Set-up - -Ensure you stay in the `~/ProjectAeon/aeon_mecha` directory for the rest of the set-up instructions, regardless of which set-up procedure you follow below. - -[Option 1](./docs/env_setup/remote/miniconda_conda_remote_setup.md): miniconda (python distribution) and conda (python version manager, environment manager, package manager, and package dependency manager) - -[Option 2](./docs/env_setup/remote/pyenv_poetry_remote_setup.md): pyenv (python version manager) and poetry (python environment manager, package manager, and package dependency manager) - -[Option 3](./docs/env_setup/remote/pip_venv_remote_setup.md): pip (python package manager) and venv (python environment manager) - -### Local set-up - -#### Prereqs - -1. Install [git](https://git-scm.com/downloads). If you are not familiar with git, just confirm the default settings during installation. - -2. Clone this repository: create a 'ProjectAeon' directory in your home directory, clone this repository there, and `cd` into the cloned directory: -``` -mkdir ~/ProjectAeon -cd ~/ProjectAeon -https://github.com/SainsburyWellcomeCentre/aeon_mecha -cd aeon_mecha -``` - -#### Set-up - -Ensure you stay in the `~/ProjectAeon/aeon_mecha` directory for the rest of the set-up instructions, regardless of which set-up procedure you follow below. All commands below should be run in a bash terminal (Windows users can use the 'mingw64' terminal that comes installed with git). - -[Option 1](./docs/env_setup/local/miniconda_conda_local_setup.md): miniconda (python distribution) and conda (python version manager, environment manager, package manager, and package dependency manager) - -[Option 2](./docs/env_setup/local/pyenv_poetry_local_setup.md): pyenv (python version manager) and poetry (python environment manager, package manager, and package dependency manager) - -[Option 3](./docs/env_setup/local/pip_venv_local_setup.md): pip (python package manager) and venv (python environment manager) - -## Repository Contents - -## Todos - -- add to [repository contents](#repository-contents) +# aeon_mecha +![aeon_mecha_env_build_and_tests](https://github.com/SainsburyWellcomeCentre/aeon_mecha/actions/workflows/build_env_run_tests.yml/badge.svg?branch=reorg) +[![aeon_mecha_tests_code_coverage](https://codecov.io/gh/SainsburyWellcomeCentre/aeon_mecha/branch/reorg/graph/badge.svg?token=973EC1CG03)](https://codecov.io/gh/SainsburyWellcomeCentre/aeon_mecha) + +Project Aeon's main repository for manipulating acquired data. Includes preprocessing, querying, plotting, and analysis modules. + +## Set-up Instructions + +The various set-up tools mentioned below do some combination of python version, environment, package, and package dependency management. For basic information on the differences between these tools, see this [blog post](https://dev.to/bowmanjd/python-tools-for-managing-virtual-environments-3bko#hatch). + +### Remote set-up on SWC's HPC + +#### Prereqs + +1. Ssh into the HPC GW1 node and clone this repo to your home directory. + +``` +ssh @ssh.swc.ucl.ac.uk +ssh hpc-gw1 +mkdir ~/ProjectAeon +cd ~/ProjectAeon +git clone https://github.com/SainsburyWellcomeCentre/aeon_mecha +``` + +#### Set-up + +Ensure you stay in the `~/ProjectAeon/aeon_mecha` directory for the rest of the set-up instructions, regardless of which set-up procedure you follow below. + +[Option 1](./docs/env_setup/remote/miniconda_conda_remote_setup.md): miniconda (python distribution) and conda (python version manager, environment manager, package manager, and package dependency manager) + +[Option 2](./docs/env_setup/remote/pyenv_poetry_remote_setup.md): pyenv (python version manager) and poetry (python environment manager, package manager, and package dependency manager) + +[Option 3](./docs/env_setup/remote/pip_venv_remote_setup.md): pip (python package manager) and venv (python environment manager) + +### Local set-up + +#### Prereqs + +1. Install [git](https://git-scm.com/downloads). If you are not familiar with git, just confirm the default settings during installation. + +2. Clone this repository: create a 'ProjectAeon' directory in your home directory, clone this repository there, and `cd` into the cloned directory: +``` +mkdir ~/ProjectAeon +cd ~/ProjectAeon +https://github.com/SainsburyWellcomeCentre/aeon_mecha +cd aeon_mecha +``` + +#### Set-up + +Ensure you stay in the `~/ProjectAeon/aeon_mecha` directory for the rest of the set-up instructions, regardless of which set-up procedure you follow below. All commands below should be run in a bash terminal (Windows users can use the 'mingw64' terminal that comes installed with git). + +[Option 1](./docs/env_setup/local/miniconda_conda_local_setup.md): miniconda (python distribution) and conda (python version manager, environment manager, package manager, and package dependency manager) + +[Option 2](./docs/env_setup/local/pyenv_poetry_local_setup.md): pyenv (python version manager) and poetry (python environment manager, package manager, and package dependency manager) + +[Option 3](./docs/env_setup/local/pip_venv_local_setup.md): pip (python package manager) and venv (python environment manager) + +## Repository Contents + +## Todos + +- add to [repository contents](#repository-contents) diff --git a/aeon/readme.md b/aeon/README.md similarity index 100% rename from aeon/readme.md rename to aeon/README.md diff --git a/tests/readme.md b/tests/README.md similarity index 100% rename from tests/readme.md rename to tests/README.md From 5a8d159e602cfab213b6e2c6b6589ac24639934b Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 9 Aug 2022 17:47:37 -0500 Subject: [PATCH 081/489] fix table design: VisitDailySummaryPlot --- aeon/dj_pipeline/report.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/aeon/dj_pipeline/report.py b/aeon/dj_pipeline/report.py index def400e8..153729b6 100644 --- a/aeon/dj_pipeline/report.py +++ b/aeon/dj_pipeline/report.py @@ -447,7 +447,7 @@ def delete_outdated_plot_entries(): @schema class VisitDailySummaryPlot(dj.Computed): definition = """ - -> VisitSummary + -> Visit --- pellet_count_plotly: longblob # Dictionary storing the plotly object (from fig.to_plotly_json()) wheel_distance_travelled_plotly: longblob @@ -455,12 +455,11 @@ class VisitDailySummaryPlot(dj.Computed): foraging_bouts_plotly: longblob """ - _min_visit_duration = 24 # in hours (minimum duration of visit for analysis) - - key_source = dj.U("experiment_name", "subject", "visit_start", "visit_end") & ( - VisitEnd + key_source = ( + Visit + & analysis.VisitSummary + & (VisitEnd & f"visit_duration > 24") & f"experiment_name= 'exp0.2-r0'" - & f"visit_duration > {_min_visit_duration}" ) def make(self, key): From 783c851557c003926aa95b5a7dc5ba0e8a90172c Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 9 Aug 2022 17:47:43 -0500 Subject: [PATCH 082/489] update sciviz spec --- .../webapps/sciviz/docker-compose-local.yaml | 21 ++++++++++--------- .../dj_pipeline/webapps/sciviz/specsheet.yaml | 1 + 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml index 55e3956e..2e683a86 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml @@ -10,6 +10,7 @@ services: environment: # - FLASK_ENV=development # enables logging to console from Flask - PHARUS_SPEC_PATH=/main/specs/specsheet.yaml # for dynamic utils spec + - PHARUS_MODE=DEV user: ${HOST_UID}:anaconda volumes: - ./specsheet.yaml:/main/specs/specsheet.yaml #copy the spec over to /main/specs/YOUR_SPEC_NAME @@ -23,17 +24,16 @@ services: gunicorn --bind 0.0.0.0:$${PHARUS_PORT} pharus.server:app & GUNICORN_PID=$$! } - ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -f -i /tmp/keys/buaws-chen.pem ec2-user@3.128.2.214 -L 3306:buaws-chen-cf-rds.c0pqrqs42ez1.us-east-2.rds.amazonaws.com:3306 -N pharus_update echo "[$$(date -u '+%Y-%m-%d %H:%M:%S')][DataJoint]: Monitoring Pharus updates..." INIT_TIME=$$(date +%s) - LAST_MOD_TIME=$$(date -r $$API_SPEC_PATH +%s) + LAST_MOD_TIME=$$(date -r $$PHARUS_SPEC_PATH +%s) DELTA=$$(expr $$LAST_MOD_TIME - $$INIT_TIME) while true; do - CURR_LAST_MOD_TIME=$$(date -r $$API_SPEC_PATH +%s) + CURR_LAST_MOD_TIME=$$(date -r $$PHARUS_SPEC_PATH +%s) CURR_DELTA=$$(expr $$CURR_LAST_MOD_TIME - $$INIT_TIME) if [ "$$DELTA" -lt "$$CURR_DELTA" ]; then - echo "[$$(date -u '+%Y-%m-%d %H:%M:%S')][DataJoint]: Reloading Pharus since \`$$API_SPEC_PATH\` changed." + echo "[$$(date -u '+%Y-%m-%d %H:%M:%S')][DataJoint]: Reloading Pharus since \`$$PHARUS_SPEC_PATH\` changed." pharus_update DELTA=$$CURR_DELTA else @@ -50,8 +50,9 @@ services: image: jverswijver/sci-viz:0.1.3-beta.3 environment: - CHOKIDAR_USEPOLLING=true - - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/utils + - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/api - DJSCIVIZ_SPEC_PATH=specsheet.yaml + - DJSCIVIZ_MODE=DEV volumes: - ./specsheet.yaml:/main/specsheet.yaml # ports: @@ -72,13 +73,13 @@ services: sciviz_update echo "[$$(date -u '+%Y-%m-%d %H:%M:%S')][DataJoint]: Monitoring SciViz updates..." INIT_TIME=$$(date +%s) - LAST_MOD_TIME=$$(date -r $$FRONTEND_SPEC_PATH +%s) + LAST_MOD_TIME=$$(date -r $$DJSCIVIZ_SPEC_PATH +%s) DELTA=$$(expr $$LAST_MOD_TIME - $$INIT_TIME) while true; do - CURR_LAST_MOD_TIME=$$(date -r $$FRONTEND_SPEC_PATH +%s) + CURR_LAST_MOD_TIME=$$(date -r $$DJSCIVIZ_SPEC_PATH +%s) CURR_DELTA=$$(expr $$CURR_LAST_MOD_TIME - $$INIT_TIME) if [ "$$DELTA" -lt "$$CURR_DELTA" ]; then - echo "[$$(date -u '+%Y-%m-%d %H:%M:%S')][DataJoint]: Reloading SciViz since \`$$FRONTEND_SPEC_PATH\` changed." + echo "[$$(date -u '+%Y-%m-%d %H:%M:%S')][DataJoint]: Reloading SciViz since \`$$DJSCIVIZ_SPEC_PATH\` changed." sciviz_update DELTA=$$CURR_DELTA else @@ -88,11 +89,11 @@ services: networks: - main fakeservices.datajoint.io: - image: datajoint/nginx:v0.0.18 + image: datajoint/nginx:v0.1.1 environment: - ADD_pharus_TYPE=REST - ADD_pharus_ENDPOINT=pharus:5000 - - ADD_pharus_PREFIX=/utils + - ADD_pharus_PREFIX=/api - ADD_sciviz_TYPE=REST - ADD_sciviz_ENDPOINT=sci-viz:3000 - ADD_sciviz_PREFIX=/ diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 418ced20..30c410d8 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -1,6 +1,7 @@ version: 'v0.0.0' LabBook: null SciViz: + auth: True pages: Subjects: route: /subjects From d55c935a3b1f01408c05d9813e8e3301282973eb Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 9 Aug 2022 18:46:25 -0500 Subject: [PATCH 083/489] update sciviz for VisitDailySummary --- .../dj_pipeline/webapps/sciviz/specsheet.yaml | 56 ++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 30c410d8..6aa854f1 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -238,7 +238,59 @@ SciViz: report = aeon_report return {'query': report.SessionSummaryPlot(), 'fetch_args': ['summary_plot_png']} - Pipeline Monitor: + VisitDailySummary: + route: /visit_daily_summary + grids: + visit_dailty_summary: + route: /visit_daily_summary_grid1 + type: dynamic + columns: 2 + row_height: 2000 + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_report): + return {'query': aeon_report.VisitDailySummaryPlot.proj(), 'fetch_args': []} + component_templates: + comp1: + route: /visit_daily_summary_pellet_count + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_report): + return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['pellet_count_plotly']) + comp2: + route: /visit_daily_summary_wheel_distance_travelled + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_report): + return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['wheel_distance_travelled_plotly']) + comp3: + route: /visit_daily_summary_total_distance_travelled + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_report): + return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['total_distance_travelled_plotly']) + comp4: + route: /visit_daily_summary_foraging_bouts + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_report): + return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['foraging_bouts_plotly']) + + PipelineMonitor: route: /pipeline_monitor grids: grid1: @@ -332,4 +384,4 @@ SciViz: dj_query: > def dj_query(aeon_workerlog): cls = aeon_workerlog.WorkerLog.proj(..., minutes_elapsed='TIMESTAMPDIFF(MINUTE, process_timestamp, UTC_TIMESTAMP())') - return {'query': cls, 'fetch_args': {'order_by': 'minutes_elapsed ASC'}} \ No newline at end of file + return {'query': cls, 'fetch_args': {'order_by': 'minutes_elapsed ASC'}} From b017b6dbecc0d1943252f3c7e50896d5dbd16bf6 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 19 Aug 2022 22:33:45 +0000 Subject: [PATCH 084/489] =?UTF-8?q?=F0=9F=90=9B=20fixed=20a=20bug=20in=20f?= =?UTF-8?q?oodpatch=20device=20mapping=20in=20exp02?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aeon/dj_pipeline/acquisition.py | 44 ++++++++++++----------- aeon/schema/dataset.py | 62 ++++++++++++++++++--------------- 2 files changed, 57 insertions(+), 49 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 1963f094..ddbb2710 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -1,19 +1,18 @@ -import datajoint as dj import datetime import pathlib + +import datajoint as dj import numpy as np import pandas as pd +from aeon.analysis import utils as analysis_utils from aeon.io import api as io_api -from aeon.schema import dataset as aeon_schema from aeon.io import reader as io_reader -from aeon.analysis import utils as analysis_utils +from aeon.schema import dataset as aeon_schema -from . import get_schema_name -from . import lab, subject -from .utils import paths +from . import get_schema_name, lab, subject from .populate.load_metadata import extract_epoch_metadata, ingest_epoch_metadata - +from .utils import paths schema = dj.schema(get_schema_name("acquisition")) @@ -952,35 +951,38 @@ def key_source(self): * ExperimentWeightScale.join(ExperimentWeightScale.RemovalTime, left=True) & "chunk_start >= weight_scale_install_time" & 'chunk_start < IFNULL(weight_scale_remove_time, "2200-01-01")' + & 'experiment_name="exp0.2-r0"' ) def make(self, key): chunk_start, chunk_end, dir_type = (Chunk & key).fetch1( "chunk_start", "chunk_end", "directory_type" ) - weight_scale_description = (ExperimentWeightScale & key).fetch1( - "weight_scale_description" - ) + weight_scale_descriptions = ("Nest", "WeightNest") raw_data_dir = Experiment.get_data_directory(key, directory_type=dir_type) - device = getattr( - _device_schema_mapping[key["experiment_name"]], weight_scale_description - ) + for weight_scale in weight_scale_descriptions: - weight_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=device.WeightRaw, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) + device = getattr( + _device_schema_mapping[key["experiment_name"]], weight_scale + ) + + weight_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=device.WeightRaw, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + if len(weight_data): + break if not len(weight_data): - # TODO: no weight data? this is unexpected - # (pending a bugfix for https://github.com/SainsburyWellcomeCentre/aeon_mecha/issues/90) raise ValueError( f"No weight measurement found for {key} - this is unexpected" ) + weight_data.sort_index(inplace=True) self.insert1( { diff --git a/aeon/schema/dataset.py b/aeon/schema/dataset.py index 2ab7c6aa..12cff6c1 100644 --- a/aeon/schema/dataset.py +++ b/aeon/schema/dataset.py @@ -1,34 +1,40 @@ from dotmap import DotMap + import aeon.schema.core as stream import aeon.schema.foraging as foraging from aeon.io.device import Device -exp02 = DotMap([ - Device("Metadata", stream.metadata), - Device("ExperimentalMetadata", stream.environment, stream.messageLog), - Device("CameraTop", stream.video, stream.position, foraging.region), - Device("CameraEast", stream.video), - Device("CameraNest", stream.video), - Device("CameraNorth", stream.video), - Device("CameraPatch1", stream.video), - Device("CameraPatch2", stream.video), - Device("CameraSouth", stream.video), - Device("CameraWest", stream.video), - Device("Nest", foraging.weight), - Device("Patch1", foraging.patch), - Device("Patch2", foraging.patch) -]) +exp02 = DotMap( + [ + Device("Metadata", stream.metadata), + Device("ExperimentalMetadata", stream.environment, stream.messageLog), + Device("CameraTop", stream.video, stream.position, foraging.region), + Device("CameraEast", stream.video), + Device("CameraNest", stream.video), + Device("CameraNorth", stream.video), + Device("CameraPatch1", stream.video), + Device("CameraPatch2", stream.video), + Device("CameraSouth", stream.video), + Device("CameraWest", stream.video), + Device("Nest", foraging.weight), + Device("WeightNest", foraging.weight), + Device("Patch1", foraging.patch), + Device("Patch2", foraging.patch), + ] +) -exp01 = DotMap([ - Device("SessionData", foraging.session), - Device("FrameTop", stream.video, stream.position), - Device("FrameEast", stream.video), - Device("FrameGate", stream.video), - Device("FrameNorth", stream.video), - Device("FramePatch1", stream.video), - Device("FramePatch2", stream.video), - Device("FrameSouth", stream.video), - Device("FrameWest", stream.video), - Device("Patch1", foraging.depletionFunction, stream.encoder, foraging.feeder), - Device("Patch2", foraging.depletionFunction, stream.encoder, foraging.feeder) -]) \ No newline at end of file +exp01 = DotMap( + [ + Device("SessionData", foraging.session), + Device("FrameTop", stream.video, stream.position), + Device("FrameEast", stream.video), + Device("FrameGate", stream.video), + Device("FrameNorth", stream.video), + Device("FramePatch1", stream.video), + Device("FramePatch2", stream.video), + Device("FrameSouth", stream.video), + Device("FrameWest", stream.video), + Device("Patch1", foraging.depletionFunction, stream.encoder, foraging.feeder), + Device("Patch2", foraging.depletionFunction, stream.encoder, foraging.feeder), + ] +) From 51f4b8ecfabd4a3943fe64d77e04b03d1196c090 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 22 Aug 2022 22:06:52 +0000 Subject: [PATCH 085/489] fix: :bug: fixed a bug in food patch device mapping (Nest hard-coded) --- aeon/dj_pipeline/acquisition.py | 56 ++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index ddbb2710..1fce4fd9 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -951,47 +951,59 @@ def key_source(self): * ExperimentWeightScale.join(ExperimentWeightScale.RemovalTime, left=True) & "chunk_start >= weight_scale_install_time" & 'chunk_start < IFNULL(weight_scale_remove_time, "2200-01-01")' - & 'experiment_name="exp0.2-r0"' ) def make(self, key): + chunk_start, chunk_end, dir_type = (Chunk & key).fetch1( "chunk_start", "chunk_end", "directory_type" ) - weight_scale_descriptions = ("Nest", "WeightNest") raw_data_dir = Experiment.get_data_directory(key, directory_type=dir_type) - for weight_scale in weight_scale_descriptions: + weight_scale_description = (ExperimentWeightScale & key).fetch1( + "weight_scale_description" + ) - device = getattr( - _device_schema_mapping[key["experiment_name"]], weight_scale - ) + weight_device = getattr( + _device_schema_mapping[key["experiment_name"]], weight_scale_description + ).WeightRaw + + weight_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=weight_device, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + if not len( + weight_data + ): # in some sessions, the food patch device was mapped to "Nest" + + weight_device = getattr( + _device_schema_mapping[key["experiment_name"]], "Nest" + ).WeightRaw weight_data = io_api.load( root=raw_data_dir.as_posix(), - reader=device.WeightRaw, + reader=weight_device, start=pd.Timestamp(chunk_start), end=pd.Timestamp(chunk_end), ) - if len(weight_data): - break - if not len(weight_data): - raise ValueError( - f"No weight measurement found for {key} - this is unexpected" - ) - weight_data.sort_index(inplace=True) + raise ValueError(f"No weight measurement found for {key}") - self.insert1( - { - **key, - "timestamps": weight_data.index.values, - "weight": weight_data.value.values, - "confidence": weight_data.stable.values.astype(float), - } - ) + else: + weight_data.sort_index(inplace=True) + self.insert1( + { + **key, + "timestamps": weight_data.index.values, + "weight": weight_data.value.values, + "confidence": weight_data.stable.values.astype(float), + } + ) # ---- Task Protocol categorization ---- From 048892d34a247d158f994ec89f815b13cf648a11 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 23 Aug 2022 04:06:16 +0000 Subject: [PATCH 086/489] revert: :rewind: revert to device.WeightRaw --- aeon/dj_pipeline/acquisition.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 1fce4fd9..b1375a2e 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -965,28 +965,28 @@ def make(self, key): "weight_scale_description" ) - weight_device = getattr( + device = getattr( _device_schema_mapping[key["experiment_name"]], weight_scale_description - ).WeightRaw + ) weight_data = io_api.load( root=raw_data_dir.as_posix(), - reader=weight_device, + reader=device.WeightRaw, start=pd.Timestamp(chunk_start), end=pd.Timestamp(chunk_end), ) if not len( weight_data - ): # in some sessions, the food patch device was mapped to "Nest" + ): # in some sessions, the food patch deice was mapped to "Nest" - weight_device = getattr( - _device_schema_mapping[key["experiment_name"]], "Nest" - ).WeightRaw + device = getattr( + _device_schema_mapping[key["experiment_name"]], weight_scale_description + ) weight_data = io_api.load( root=raw_data_dir.as_posix(), - reader=weight_device, + reader=device.WeightRaw, start=pd.Timestamp(chunk_start), end=pd.Timestamp(chunk_end), ) From e31f58e8f5e43fe62a44fe6e577686bd476f0945 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 23 Aug 2022 04:09:24 +0000 Subject: [PATCH 087/489] Nest --- aeon/dj_pipeline/acquisition.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index b1375a2e..6a3580f6 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -981,7 +981,7 @@ def make(self, key): ): # in some sessions, the food patch deice was mapped to "Nest" device = getattr( - _device_schema_mapping[key["experiment_name"]], weight_scale_description + _device_schema_mapping[key["experiment_name"]], "Nest" ) weight_data = io_api.load( From c1a08fd9e0b535f8cce1534c5bf727762e6c13bb Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 24 Aug 2022 11:05:14 -0500 Subject: [PATCH 088/489] Update docker-compose-remote.yaml --- aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml index 83c5c4fd..be3ec9f3 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml @@ -46,7 +46,7 @@ services: networks: - main reverse-proxy: - image: datajoint/nginx:v0.1.0 + image: datajoint/nginx:v0.1.1 environment: - ADD_pharus_TYPE=REST - ADD_pharus_ENDPOINT=pharus:5000 From e9375c74c5130ec50842eae6ced1369dde43586c Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 24 Aug 2022 11:06:23 -0500 Subject: [PATCH 089/489] `AnimalObjectMapping` for multi-animal visits --- aeon/dj_pipeline/analysis/visit_analysis.py | 26 +++++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 16305747..e2cb24ee 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -8,6 +8,7 @@ from .. import acquisition, dict_to_uuid, get_schema_name, lab, qc, tracking from .visit import Visit, VisitEnd +logger = dj.logger schema = dj.schema(get_schema_name("analysis")) @@ -42,6 +43,15 @@ class PositionFilteringParamSet(dj.Lookup): # ---------- Animal Position per Visit ------------------ +@schema +class AnimalObjectMapping(dj.Manual): + definition = """ + -> analysis.Visit + --- + object_id: int + """ + + @schema class VisitSubjectPosition(dj.Computed): definition = """ # Animal position during a visit @@ -124,11 +134,17 @@ def make(self, key): # -- Retrieve position data camera_name = acquisition._ref_device_mapping[key["experiment_name"]] - assert ( - len(set((tracking.CameraTracking.Object & key).fetch("object_id"))) == 1 - ), "More than one unique object ID found - multiple animal/object mapping not yet supported" - - object_id = (tracking.CameraTracking.Object & key).fetch1("object_id") + if len(set((tracking.CameraTracking.Object & key).fetch("object_id"))) == 1: + object_id = (tracking.CameraTracking.Object & key).fetch1("object_id") + else: + logger.info( + '"More than one unique object ID found - using animal/object mapping from AnimalObjectMapping"' + ) + if not (AnimalObjectMapping & key): + raise ValueError( + f"More than one unique object ID found for {key} - no AnimalObjectMapping defined" + ) + object_id = (AnimalObjectMapping & key).fetch1("object_id") positiondata = tracking.CameraTracking.get_object_position( experiment_name=key["experiment_name"], From 2d281c28e744453e776d47f6b75ecaba8563e8ce Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 24 Aug 2022 11:06:27 -0500 Subject: [PATCH 090/489] Update tracking.py --- aeon/dj_pipeline/tracking.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/aeon/dj_pipeline/tracking.py b/aeon/dj_pipeline/tracking.py index 78e44187..5af4188d 100644 --- a/aeon/dj_pipeline/tracking.py +++ b/aeon/dj_pipeline/tracking.py @@ -100,6 +100,15 @@ def insert_new_params( # ---------- Video Tracking ------------------ +@schema +class TrackingObjectType(dj.Lookup): + definition = """ + tracking_object_type: varchar(36) + """ + + contents = zip(["body_part", "animal"]) + + @schema class CameraTracking(dj.Imported): definition = """ # Tracked objects position data from a particular camera, using a particular tracking method, for a particular chunk @@ -113,6 +122,7 @@ class Object(dj.Part): -> master object_id: int # object with id = -1 means "unknown/not sure", could potentially be the same object as those with other id value --- + -> [nullable] TrackingObjectType timestamps: longblob # (datetime) timestamps of the position data position_x: longblob # (px) object's x-position, in the arena's coordinate frame position_y: longblob # (px) object's y-position, in the arena's coordinate frame From 62268a2a4408a6bc87be7e7942fc38055f0c5ee1 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 24 Aug 2022 14:03:30 -0500 Subject: [PATCH 091/489] undo `TrackingObjectType`, needs more discussion --- aeon/dj_pipeline/tracking.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/aeon/dj_pipeline/tracking.py b/aeon/dj_pipeline/tracking.py index 5af4188d..78e44187 100644 --- a/aeon/dj_pipeline/tracking.py +++ b/aeon/dj_pipeline/tracking.py @@ -100,15 +100,6 @@ def insert_new_params( # ---------- Video Tracking ------------------ -@schema -class TrackingObjectType(dj.Lookup): - definition = """ - tracking_object_type: varchar(36) - """ - - contents = zip(["body_part", "animal"]) - - @schema class CameraTracking(dj.Imported): definition = """ # Tracked objects position data from a particular camera, using a particular tracking method, for a particular chunk @@ -122,7 +113,6 @@ class Object(dj.Part): -> master object_id: int # object with id = -1 means "unknown/not sure", could potentially be the same object as those with other id value --- - -> [nullable] TrackingObjectType timestamps: longblob # (datetime) timestamps of the position data position_x: longblob # (px) object's x-position, in the arena's coordinate frame position_y: longblob # (px) object's y-position, in the arena's coordinate frame From a2bb62e0aa5b25ac50703297cea71787c6c094d2 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Thu, 25 Aug 2022 13:37:29 +0000 Subject: [PATCH 092/489] feat: :sparkles: filter out maintenance in VisitTimeDistribution --- aeon/dj_pipeline/analysis/visit_analysis.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 16305747..55a07ddd 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -6,6 +6,7 @@ import pandas as pd from .. import acquisition, dict_to_uuid, get_schema_name, lab, qc, tracking +from ..acquisition import Chunk, ExperimentLog from .visit import Visit, VisitEnd schema = dj.schema(get_schema_name("analysis")) @@ -258,6 +259,10 @@ def make(self, key): start=pd.Timestamp(visit_start.date()), end=pd.Timestamp(visit_end.date()) ) + maintenance_period = Chunk & ( + ExperimentLog.Message() & 'message="Maintenance"' + ) # get the maintenance info + for visit_date in visit_dates: day_start = datetime.datetime.combine(visit_date.date(), time.min) day_end = datetime.datetime.combine(visit_date.date(), time.max) @@ -276,6 +281,19 @@ def make(self, key): subject=key["subject"], start=day_start, end=day_end ) + # filter out maintenance period + maintenance_filter = np.full(len(position.index), False) + + maintenance_start, maintenance_stop = query.fetch( + "chunk_start", "chunk_end" + ) + + for start, stop in zip(maintenance_start, maintenance_stop): + maintenance_filter += (position.index >= start) & ( + position.index <= stop + ) + position[maintenance_filter] = np.nan + # filter for objects of the correct size valid_position = (position.area > 0) & (position.area < 1000) position[~valid_position] = np.nan From 3c926ab78d4256abf71307555bd50007d3731d18 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Thu, 25 Aug 2022 13:43:28 +0000 Subject: [PATCH 093/489] type fix --- aeon/dj_pipeline/analysis/visit_analysis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 55a07ddd..c020fb37 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -284,7 +284,7 @@ def make(self, key): # filter out maintenance period maintenance_filter = np.full(len(position.index), False) - maintenance_start, maintenance_stop = query.fetch( + maintenance_start, maintenance_stop = maintenance_period.fetch( "chunk_start", "chunk_end" ) From 9ccd5cf4d98ec889d7a89b64dcd2f1ecfcdd1496 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Thu, 25 Aug 2022 20:23:36 +0000 Subject: [PATCH 094/489] feat: :sparkles: filter out maintenance from VisitSummary & VisitTimeDistribution --- aeon/dj_pipeline/analysis/visit_analysis.py | 128 +++++++++++++++++--- aeon/dj_pipeline/utils/__init__.py | 5 + 2 files changed, 114 insertions(+), 19 deletions(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 8cf5d133..fb0cf6f5 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -7,6 +7,7 @@ from .. import acquisition, dict_to_uuid, get_schema_name, lab, qc, tracking from ..acquisition import Chunk, ExperimentLog +from ..utils import _make_maintenance_filter from .visit import Visit, VisitEnd logger = dj.logger @@ -44,13 +45,13 @@ class PositionFilteringParamSet(dj.Lookup): # ---------- Animal Position per Visit ------------------ -@schema -class AnimalObjectMapping(dj.Manual): - definition = """ - -> analysis.Visit - --- - object_id: int - """ +# @schema +# class AnimalObjectMapping(dj.Manual): +# definition = """ +# -> analysis.Visit +# --- +# object_id: int +# """ @schema @@ -275,10 +276,6 @@ def make(self, key): start=pd.Timestamp(visit_start.date()), end=pd.Timestamp(visit_end.date()) ) - maintenance_period = Chunk & ( - ExperimentLog.Message() & 'message="Maintenance"' - ) # get the maintenance info - for visit_date in visit_dates: day_start = datetime.datetime.combine(visit_date.date(), time.min) day_end = datetime.datetime.combine(visit_date.date(), time.max) @@ -298,17 +295,57 @@ def make(self, key): ) # filter out maintenance period - maintenance_filter = np.full(len(position.index), False) - - maintenance_start, maintenance_stop = maintenance_period.fetch( - "chunk_start", "chunk_end" + log_period = ( + ( + ExperimentLog.Message() + & 'message IN ("Maintenance", "Experiment")' + & f'message_time BETWEEN "{day_start}" AND "{day_end}"' + ) + .fetch(format="frame") + .reset_index() ) - for start, stop in zip(maintenance_start, maintenance_stop): - maintenance_filter += (position.index >= start) & ( - position.index <= stop + if len(log_period): # if log message exists on a given time period + log_period["state_change"] = ( + log_period.replace({"Maintenance": 0, "Experiment": 1})["message"] + .diff() + .abs() + .fillna(1) ) - position[maintenance_filter] = np.nan + + maintenance_filter = np.full( + len(position), False + ) # initialize boolean filter array + + maintenance_start = maintenance_stop = np.nan + + for _, row in log_period.iterrows(): + if row["message"] == "Maintenance" and row["state_change"]: + maintenance_start = row["message_time"] + if row["message"] == "Experiment": + maintenance_stop = row["message_time"] + + if not pd.isnull(maintenance_start) and not pd.isnull( + maintenance_stop + ): + + maintenance_filter = _make_maintenance_filter( + position, + maintenance_filter, + maintenance_start, + maintenance_stop, + ) + maintenance_start = maintenance_stop = np.nan + + if not pd.isnull(maintenance_start) and pd.isnull(maintenance_stop): + maintenance_stop = day_end + maintenance_filter = _make_maintenance_filter( + position, + maintenance_filter, + maintenance_start, + maintenance_stop, + ) + position[maintenance_filter] = np.nan # filter for objects of the correct size valid_position = (position.area > 0) & (position.area < 1000) @@ -482,6 +519,59 @@ def make(self, key): subject=key["subject"], start=day_start, end=day_end ) + # filter out maintenance period + log_period = ( + ( + ExperimentLog.Message() + & 'message IN ("Maintenance", "Experiment")' + & f'message_time BETWEEN "{day_start}" AND "{day_end}"' + ) + .fetch(format="frame") + .reset_index() + ) + + if len(log_period): # if log message exists on a given time period + log_period["state_change"] = ( + log_period.replace({"Maintenance": 0, "Experiment": 1})["message"] + .diff() + .abs() + .fillna(1) + ) + + maintenance_filter = np.full( + len(position), False + ) # initialize boolean filter array + + maintenance_start = maintenance_stop = np.nan + + for _, row in log_period.iterrows(): + if row["message"] == "Maintenance" and row["state_change"]: + maintenance_start = row["message_time"] + if row["message"] == "Experiment": + maintenance_stop = row["message_time"] + + if not pd.isnull(maintenance_start) and not pd.isnull( + maintenance_stop + ): + + maintenance_filter = _make_maintenance_filter( + position, + maintenance_filter, + maintenance_start, + maintenance_stop, + ) + maintenance_start = maintenance_stop = np.nan + + if not pd.isnull(maintenance_start) and pd.isnull(maintenance_stop): + maintenance_stop = day_end + maintenance_filter = _make_maintenance_filter( + position, + maintenance_filter, + maintenance_start, + maintenance_stop, + ) + position[maintenance_filter] = np.nan + # filter for objects of the correct size valid_position = (position.area > 0) & (position.area < 1000) position[~valid_position] = np.nan diff --git a/aeon/dj_pipeline/utils/__init__.py b/aeon/dj_pipeline/utils/__init__.py index e69de29b..83deb24d 100644 --- a/aeon/dj_pipeline/utils/__init__.py +++ b/aeon/dj_pipeline/utils/__init__.py @@ -0,0 +1,5 @@ +def _make_maintenance_filter(position_df, maintenance_filter, start, stop): + """Make a boolean filter for eliminating maintenance period""" + + maintenance_filter += (position_df.index >= start) & (position_df.index <= stop) + return maintenance_filter From bb2f783209e0089c1222ba12f7a4b90836691d87 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 26 Aug 2022 01:53:08 +0000 Subject: [PATCH 095/489] perf: :art: streamline the code if log starts with Maintenane, assume that it's all Experiment before that. If it starts with Experiment, assume it's all Maintenance --- aeon/dj_pipeline/analysis/visit_analysis.py | 196 ++++++++++---------- 1 file changed, 94 insertions(+), 102 deletions(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index fb0cf6f5..6e45bd29 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -276,6 +276,20 @@ def make(self, key): start=pd.Timestamp(visit_start.date()), end=pd.Timestamp(visit_end.date()) ) + # get logs from ExperimentLog + log_df = ( + ( + ExperimentLog.Message() + & 'message IN ("Maintenance", "Experiment")' + & f'message_time BETWEEN "{visit_start}" AND "{visit_end}"' + ) + .fetch(format="frame") + .reset_index() + ) + log_df = log_df[ + log_df["message"].shift() != log_df["message"] + ] # remove duplicates and keep the first one + for visit_date in visit_dates: day_start = datetime.datetime.combine(visit_date.date(), time.min) day_end = datetime.datetime.combine(visit_date.date(), time.max) @@ -294,58 +308,40 @@ def make(self, key): subject=key["subject"], start=day_start, end=day_end ) - # filter out maintenance period - log_period = ( - ( - ExperimentLog.Message() - & 'message IN ("Maintenance", "Experiment")' - & f'message_time BETWEEN "{day_start}" AND "{day_end}"' - ) - .fetch(format="frame") - .reset_index() - ) - - if len(log_period): # if log message exists on a given time period - log_period["state_change"] = ( - log_period.replace({"Maintenance": 0, "Experiment": 1})["message"] - .diff() - .abs() - .fillna(1) + # filter out maintenance period based on logs + daily_log_df = log_df[log_df["message_time"].between(day_start, day_end)] + + maintenance_filter = np.full( + len(position), False + ) # initialize boolean filter array + + start_timestaps = np.array( + np.append( + day_start, + daily_log_df.loc[ + daily_log_df["message"] == "Maintenance", "message_time" + ], + ), + dtype="datetime64[ns]", + ) # start time + + end_timestamps = np.array( + np.append( + daily_log_df.loc[ + daily_log_df["message"] == "Experiment", "message_time" + ], + day_end, + ), + dtype="datetime64[ns]", + ) # end time + + for maintenance_start, maintenance_end in zip( + start_timestaps, end_timestamps + ): + maintenance_filter = _make_maintenance_filter( + position, maintenance_filter, maintenance_start, maintenance_end ) - - maintenance_filter = np.full( - len(position), False - ) # initialize boolean filter array - - maintenance_start = maintenance_stop = np.nan - - for _, row in log_period.iterrows(): - if row["message"] == "Maintenance" and row["state_change"]: - maintenance_start = row["message_time"] - if row["message"] == "Experiment": - maintenance_stop = row["message_time"] - - if not pd.isnull(maintenance_start) and not pd.isnull( - maintenance_stop - ): - - maintenance_filter = _make_maintenance_filter( - position, - maintenance_filter, - maintenance_start, - maintenance_stop, - ) - maintenance_start = maintenance_stop = np.nan - - if not pd.isnull(maintenance_start) and pd.isnull(maintenance_stop): - maintenance_stop = day_end - maintenance_filter = _make_maintenance_filter( - position, - maintenance_filter, - maintenance_start, - maintenance_stop, - ) - position[maintenance_filter] = np.nan + position[maintenance_filter] = np.nan # filter for objects of the correct size valid_position = (position.area > 0) & (position.area < 1000) @@ -492,6 +488,20 @@ def make(self, key): start=pd.Timestamp(visit_start.date()), end=pd.Timestamp(visit_end.date()) ) + # get logs from ExperimentLog + log_df = ( + ( + ExperimentLog.Message() + & 'message IN ("Maintenance", "Experiment")' + & f'message_time BETWEEN "{visit_start}" AND "{visit_end}"' + ) + .fetch(format="frame") + .reset_index() + ) + log_df = log_df[ + log_df["message"].shift() != log_df["message"] + ] # remove duplicates and keep the first one + for visit_date in visit_dates: day_start = datetime.datetime.combine((visit_date).date(), time.min) day_end = datetime.datetime.combine((visit_date).date(), time.max) @@ -519,58 +529,40 @@ def make(self, key): subject=key["subject"], start=day_start, end=day_end ) - # filter out maintenance period - log_period = ( - ( - ExperimentLog.Message() - & 'message IN ("Maintenance", "Experiment")' - & f'message_time BETWEEN "{day_start}" AND "{day_end}"' - ) - .fetch(format="frame") - .reset_index() - ) - - if len(log_period): # if log message exists on a given time period - log_period["state_change"] = ( - log_period.replace({"Maintenance": 0, "Experiment": 1})["message"] - .diff() - .abs() - .fillna(1) + # filter out maintenance period based on logs + daily_log_df = log_df[log_df["message_time"].between(day_start, day_end)] + + maintenance_filter = np.full( + len(position), False + ) # initialize boolean filter array + + start_timestaps = np.array( + np.append( + day_start, + daily_log_df.loc[ + daily_log_df["message"] == "Maintenance", "message_time" + ], + ), + dtype="datetime64[ns]", + ) # start time + + end_timestamps = np.array( + np.append( + daily_log_df.loc[ + daily_log_df["message"] == "Experiment", "message_time" + ], + day_end, + ), + dtype="datetime64[ns]", + ) # end time + + for maintenance_start, maintenance_end in zip( + start_timestaps, end_timestamps + ): + maintenance_filter = _make_maintenance_filter( + position, maintenance_filter, maintenance_start, maintenance_end ) - - maintenance_filter = np.full( - len(position), False - ) # initialize boolean filter array - - maintenance_start = maintenance_stop = np.nan - - for _, row in log_period.iterrows(): - if row["message"] == "Maintenance" and row["state_change"]: - maintenance_start = row["message_time"] - if row["message"] == "Experiment": - maintenance_stop = row["message_time"] - - if not pd.isnull(maintenance_start) and not pd.isnull( - maintenance_stop - ): - - maintenance_filter = _make_maintenance_filter( - position, - maintenance_filter, - maintenance_start, - maintenance_stop, - ) - maintenance_start = maintenance_stop = np.nan - - if not pd.isnull(maintenance_start) and pd.isnull(maintenance_stop): - maintenance_stop = day_end - maintenance_filter = _make_maintenance_filter( - position, - maintenance_filter, - maintenance_start, - maintenance_stop, - ) - position[maintenance_filter] = np.nan + position[maintenance_filter] = np.nan # filter for objects of the correct size valid_position = (position.area > 0) & (position.area < 1000) From c42e8f0b1c31db8754939c27592a7dc0623717ce Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 26 Aug 2022 02:10:13 +0000 Subject: [PATCH 096/489] fix: :fire: remove _make_maintenance_filter and fixed typos --- aeon/dj_pipeline/analysis/visit_analysis.py | 16 ++++++++-------- aeon/dj_pipeline/utils/__init__.py | 5 ----- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 6e45bd29..552bb544 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -315,7 +315,7 @@ def make(self, key): len(position), False ) # initialize boolean filter array - start_timestaps = np.array( + start_timestamps = np.array( np.append( day_start, daily_log_df.loc[ @@ -336,10 +336,10 @@ def make(self, key): ) # end time for maintenance_start, maintenance_end in zip( - start_timestaps, end_timestamps + start_timestamps, end_timestamps ): - maintenance_filter = _make_maintenance_filter( - position, maintenance_filter, maintenance_start, maintenance_end + maintenance_filter += (position.index >= maintenance_start) & ( + position.index <= maintenance_end ) position[maintenance_filter] = np.nan @@ -536,7 +536,7 @@ def make(self, key): len(position), False ) # initialize boolean filter array - start_timestaps = np.array( + start_timestamps = np.array( np.append( day_start, daily_log_df.loc[ @@ -557,10 +557,10 @@ def make(self, key): ) # end time for maintenance_start, maintenance_end in zip( - start_timestaps, end_timestamps + start_timestamps, end_timestamps ): - maintenance_filter = _make_maintenance_filter( - position, maintenance_filter, maintenance_start, maintenance_end + maintenance_filter += (position.index >= maintenance_start) & ( + position.index <= maintenance_end ) position[maintenance_filter] = np.nan diff --git a/aeon/dj_pipeline/utils/__init__.py b/aeon/dj_pipeline/utils/__init__.py index 83deb24d..e69de29b 100644 --- a/aeon/dj_pipeline/utils/__init__.py +++ b/aeon/dj_pipeline/utils/__init__.py @@ -1,5 +0,0 @@ -def _make_maintenance_filter(position_df, maintenance_filter, start, stop): - """Make a boolean filter for eliminating maintenance period""" - - maintenance_filter += (position_df.index >= start) & (position_df.index <= stop) - return maintenance_filter From 901243a0cf618c1a4830269e33c4daa539c58b95 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 31 Aug 2022 11:14:18 -0500 Subject: [PATCH 097/489] bugfix --- aeon/dj_pipeline/analysis/visit_analysis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index e2cb24ee..5e59e190 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -46,7 +46,7 @@ class PositionFilteringParamSet(dj.Lookup): @schema class AnimalObjectMapping(dj.Manual): definition = """ - -> analysis.Visit + -> Visit --- object_id: int """ From 0aa9e11914c5158b586ed94bbd86b6ced0f32819 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 31 Aug 2022 11:16:30 -0500 Subject: [PATCH 098/489] remove VisitSubjectPosition restriction on `exp0.1` and `exp0.2` only --- aeon/dj_pipeline/analysis/visit_analysis.py | 1 - 1 file changed, 1 deletion(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 5e59e190..0132273e 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -92,7 +92,6 @@ def key_source(self): "visit_end BETWEEN chunk_start AND chunk_end", "chunk_start >= visit_start AND chunk_end <= visit_end", ] - & 'experiment_name in ("exp0.1-r0", "exp0.2-r0")' & "chunk_start < chunk_end" # in some chunks, end timestamp comes before start (timestamp error) ) From 86940f5f475a553df8e51dcd1e97338a244082e8 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Thu, 1 Sep 2022 01:33:14 +0000 Subject: [PATCH 099/489] feat: :sparkles: filter position data from the maintenance period in visit_analysis --- aeon/dj_pipeline/analysis/visit_analysis.py | 218 +++++++++++--------- 1 file changed, 116 insertions(+), 102 deletions(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 552bb544..df487eaa 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -1,4 +1,5 @@ import datetime +from collections import deque from datetime import time import datajoint as dj @@ -7,7 +8,6 @@ from .. import acquisition, dict_to_uuid, get_schema_name, lab, qc, tracking from ..acquisition import Chunk, ExperimentLog -from ..utils import _make_maintenance_filter from .visit import Visit, VisitEnd logger = dj.logger @@ -45,13 +45,13 @@ class PositionFilteringParamSet(dj.Lookup): # ---------- Animal Position per Visit ------------------ -# @schema -# class AnimalObjectMapping(dj.Manual): -# definition = """ -# -> analysis.Visit -# --- -# object_id: int -# """ +@schema +class AnimalObjectMapping(dj.Manual): + definition = """ + -> analysis.Visit + --- + object_id: int + """ @schema @@ -277,18 +277,44 @@ def make(self, key): ) # get logs from ExperimentLog - log_df = ( - ( - ExperimentLog.Message() - & 'message IN ("Maintenance", "Experiment")' - & f'message_time BETWEEN "{visit_start}" AND "{visit_end}"' - ) - .fetch(format="frame") - .reset_index() + log_df = dj.U("experiment_name", "message_time", "message") & ( + ExperimentLog.Message() + & 'message IN ("Maintenance", "Experiment")' + & f'message_time BETWEEN "{visit_start}" AND "{visit_end}"' ) - log_df = log_df[ - log_df["message"].shift() != log_df["message"] - ] # remove duplicates and keep the first one + log_df = log_df.fetch(format="frame").reset_index() + log_df = log_df[log_df["message"].shift() != log_df["message"]].reset_index( + drop=True + ) # remove duplicates and keep the first one + + # An experiment starts with visit start (anything before the first maintenance is experiment) + # Delete the row if it starts with "Experiment" + if log_df.iloc[0]["message"] == "Experiment": + log_df.drop(index=0, inplace=True) # look for the first maintenance + + # Last entry is the visit end + if (log_df.tail(1)["message"] == "Maintenance").values: + + log_df_end = log_df.tail(1) + log_df_end["message_time"], log_df_end["message"] = ( + pd.Timestamp(visit_end), + "VisitEnd", + ) + log_df = pd.concat([log_df, log_df_end]) + log_df.reset_index(drop=True, inplace=True) + + start_timestamps = log_df.loc[ + log_df["message"] == "Maintenance", "message_time" + ].values + end_timestamps = log_df.loc[ + log_df["message"] != "Maintenance", "message_time" + ].values + maintenance_period = deque( + [ + (pd.Timestamp(start), pd.Timestamp(end)) + for start, end in zip(start_timestamps, end_timestamps) + ] + ) # queue object. pop out from left after use for visit_date in visit_dates: day_start = datetime.datetime.combine(visit_date.date(), time.min) @@ -309,44 +335,29 @@ def make(self, key): ) # filter out maintenance period based on logs - daily_log_df = log_df[log_df["message_time"].between(day_start, day_end)] - - maintenance_filter = np.full( - len(position), False - ) # initialize boolean filter array - - start_timestamps = np.array( - np.append( - day_start, - daily_log_df.loc[ - daily_log_df["message"] == "Maintenance", "message_time" - ], - ), - dtype="datetime64[ns]", - ) # start time - - end_timestamps = np.array( - np.append( - daily_log_df.loc[ - daily_log_df["message"] == "Experiment", "message_time" - ], - day_end, - ), - dtype="datetime64[ns]", - ) # end time - - for maintenance_start, maintenance_end in zip( - start_timestamps, end_timestamps - ): - maintenance_filter += (position.index >= maintenance_start) & ( + maintenance_filter = np.full(len(position), False) + + while maintenance_period: + (maintenance_start, maintenance_end) = maintenance_period[0] + + if day_end < maintenance_start: + break + bool_mask = np.full(len(position), False) + bool_mask = (position.index >= maintenance_start) & ( position.index <= maintenance_end ) + if bool_mask.sum(): + maintenance_filter += bool_mask + if day_end >= maintenance_end: + maintenance_period.popleft() + else: + break + position[maintenance_filter] = np.nan # filter for objects of the correct size valid_position = (position.area > 0) & (position.area < 1000) position[~valid_position] = np.nan - position.rename( columns={"position_x": "x", "position_y": "y"}, inplace=True ) @@ -489,22 +500,48 @@ def make(self, key): ) # get logs from ExperimentLog - log_df = ( - ( - ExperimentLog.Message() - & 'message IN ("Maintenance", "Experiment")' - & f'message_time BETWEEN "{visit_start}" AND "{visit_end}"' - ) - .fetch(format="frame") - .reset_index() + log_df = dj.U("experiment_name", "message_time", "message") & ( + ExperimentLog.Message() + & 'message IN ("Maintenance", "Experiment")' + & f'message_time BETWEEN "{visit_start}" AND "{visit_end}"' ) - log_df = log_df[ - log_df["message"].shift() != log_df["message"] - ] # remove duplicates and keep the first one + log_df = log_df.fetch(format="frame").reset_index() + log_df = log_df[log_df["message"].shift() != log_df["message"]].reset_index( + drop=True + ) # remove duplicates and keep the first one + + # An experiment starts with visit start (anything before the first maintenance is experiment) + # Delete the row if it starts with "Experiment" + if log_df.iloc[0]["message"] == "Experiment": + log_df.drop(index=0, inplace=True) # look for the first maintenance + + # Last entry is the visit end + if (log_df.tail(1)["message"] == "Maintenance").values: + + log_df_end = log_df.tail(1) + log_df_end["message_time"], log_df_end["message"] = ( + pd.Timestamp(visit_end), + "VisitEnd", + ) + log_df = pd.concat([log_df, log_df_end]) + log_df.reset_index(drop=True, inplace=True) + + start_timestamps = log_df.loc[ + log_df["message"] == "Maintenance", "message_time" + ].values + end_timestamps = log_df.loc[ + log_df["message"] != "Maintenance", "message_time" + ].values + maintenance_period = deque( + [ + (pd.Timestamp(start), pd.Timestamp(end)) + for start, end in zip(start_timestamps, end_timestamps) + ] + ) # queue object. pop out from left after use for visit_date in visit_dates: - day_start = datetime.datetime.combine((visit_date).date(), time.min) - day_end = datetime.datetime.combine((visit_date).date(), time.max) + day_start = datetime.datetime.combine(visit_date.date(), time.min) + day_end = datetime.datetime.combine(visit_date.date(), time.max) day_start = max(day_start, visit_start) day_end = min(day_end, visit_end) @@ -515,53 +552,30 @@ def make(self, key): 3, ) - ## TODO - # # subject weights - # weight_start = ( - # acquisition.SubjectWeight.WeightTime & f'weight_time = "{day_start}"' - # ).fetch1("weight") - # weight_end = ( - # acquisition.SubjectWeight.WeightTime & f'weight_time = "{day_end}"' - # ).fetch1("weight") - # subject's position data in the time_slices per day position = VisitSubjectPosition.get_position( subject=key["subject"], start=day_start, end=day_end ) # filter out maintenance period based on logs - daily_log_df = log_df[log_df["message_time"].between(day_start, day_end)] - - maintenance_filter = np.full( - len(position), False - ) # initialize boolean filter array - - start_timestamps = np.array( - np.append( - day_start, - daily_log_df.loc[ - daily_log_df["message"] == "Maintenance", "message_time" - ], - ), - dtype="datetime64[ns]", - ) # start time - - end_timestamps = np.array( - np.append( - daily_log_df.loc[ - daily_log_df["message"] == "Experiment", "message_time" - ], - day_end, - ), - dtype="datetime64[ns]", - ) # end time - - for maintenance_start, maintenance_end in zip( - start_timestamps, end_timestamps - ): - maintenance_filter += (position.index >= maintenance_start) & ( + maintenance_filter = np.full(len(position), False) + + while maintenance_period: + (maintenance_start, maintenance_end) = maintenance_period[0] + + if day_end < maintenance_start: + break + bool_mask = np.full(len(position), False) + bool_mask = (position.index >= maintenance_start) & ( position.index <= maintenance_end ) + if bool_mask.sum(): + maintenance_filter += bool_mask + if day_end >= maintenance_end: + maintenance_period.popleft() + else: + break + position[maintenance_filter] = np.nan # filter for objects of the correct size From 750132c9a5ef23397171fb7a84e23055f9b6e65d Mon Sep 17 00:00:00 2001 From: JaerongA Date: Sun, 4 Sep 2022 19:46:18 +0000 Subject: [PATCH 100/489] feat: day_end is the start of the next day --- aeon/dj_pipeline/analysis/visit_analysis.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 9ca4be87..ffdb75bd 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -317,7 +317,9 @@ def make(self, key): for visit_date in visit_dates: day_start = datetime.datetime.combine(visit_date.date(), time.min) - day_end = datetime.datetime.combine(visit_date.date(), time.max) + day_end = datetime.datetime.combine( + visit_date.date() + datetime.timedelta(days=1), time.min + ) day_start = max(day_start, visit_start) day_end = min(day_end, visit_end) @@ -540,7 +542,9 @@ def make(self, key): for visit_date in visit_dates: day_start = datetime.datetime.combine(visit_date.date(), time.min) - day_end = datetime.datetime.combine(visit_date.date(), time.max) + day_end = datetime.datetime.combine( + visit_date.date() + datetime.timedelta(days=1), time.min + ) day_start = max(day_start, visit_start) day_end = min(day_end, visit_end) From 2d173d509e66707ff9b630200662d4cc001cf0ae Mon Sep 17 00:00:00 2001 From: JaerongA Date: Sun, 4 Sep 2022 19:57:38 +0000 Subject: [PATCH 101/489] feat: :adhesive_bandage: sort log message time --- aeon/dj_pipeline/analysis/visit_analysis.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index ffdb75bd..72792610 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -281,7 +281,7 @@ def make(self, key): & 'message IN ("Maintenance", "Experiment")' & f'message_time BETWEEN "{visit_start}" AND "{visit_end}"' ) - log_df = log_df.fetch(format="frame").reset_index() + log_df = log_df.fetch(format="frame", order_by="message_time").reset_index() log_df = log_df[log_df["message"].shift() != log_df["message"]].reset_index( drop=True ) # remove duplicates and keep the first one @@ -292,7 +292,7 @@ def make(self, key): log_df.drop(index=0, inplace=True) # look for the first maintenance # Last entry is the visit end - if (log_df.tail(1)["message"] == "Maintenance").values: + if (log_df.iloc[-1]["message"] == "Maintenance"): log_df_end = log_df.tail(1) log_df_end["message_time"], log_df_end["message"] = ( @@ -506,7 +506,7 @@ def make(self, key): & 'message IN ("Maintenance", "Experiment")' & f'message_time BETWEEN "{visit_start}" AND "{visit_end}"' ) - log_df = log_df.fetch(format="frame").reset_index() + log_df = log_df.fetch(format="frame", order_by="message_time").reset_index() log_df = log_df[log_df["message"].shift() != log_df["message"]].reset_index( drop=True ) # remove duplicates and keep the first one @@ -517,7 +517,7 @@ def make(self, key): log_df.drop(index=0, inplace=True) # look for the first maintenance # Last entry is the visit end - if (log_df.tail(1)["message"] == "Maintenance").values: + if (log_df.iloc[-1]["message"] == "Maintenance"): log_df_end = log_df.tail(1) log_df_end["message_time"], log_df_end["message"] = ( From b53bc5e5daf3b13a8130beba7520472ef16b9be6 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Sun, 4 Sep 2022 22:44:14 +0000 Subject: [PATCH 102/489] style: :art: streamline the maintenance filter --- aeon/dj_pipeline/analysis/visit_analysis.py | 50 +++++++++------------ 1 file changed, 21 insertions(+), 29 deletions(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 72792610..a7a17f5b 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -292,7 +292,7 @@ def make(self, key): log_df.drop(index=0, inplace=True) # look for the first maintenance # Last entry is the visit end - if (log_df.iloc[-1]["message"] == "Maintenance"): + if log_df.iloc[-1]["message"] == "Maintenance": log_df_end = log_df.tail(1) log_df_end["message_time"], log_df_end["message"] = ( @@ -319,7 +319,7 @@ def make(self, key): day_start = datetime.datetime.combine(visit_date.date(), time.min) day_end = datetime.datetime.combine( visit_date.date() + datetime.timedelta(days=1), time.min - ) + ) # start of the next day day_start = max(day_start, visit_start) day_end = min(day_end, visit_end) @@ -336,25 +336,21 @@ def make(self, key): ) # filter out maintenance period based on logs - maintenance_filter = np.full(len(position), False) - while maintenance_period: (maintenance_start, maintenance_end) = maintenance_period[0] - if day_end < maintenance_start: + if day_end < maintenance_start: # no more maintenance for this date break - bool_mask = np.full(len(position), False) - bool_mask = (position.index >= maintenance_start) & ( + + maintenance_filter = (position.index >= maintenance_start) & ( position.index <= maintenance_end ) - if bool_mask.sum(): - maintenance_filter += bool_mask - if day_end >= maintenance_end: - maintenance_period.popleft() - else: - break + position[maintenance_filter] = np.nan - position[maintenance_filter] = np.nan + if day_end >= maintenance_end: # remove this range + maintenance_period.popleft() + else: + break # filter for objects of the correct size valid_position = (position.area > 0) & (position.area < 1000) @@ -517,7 +513,7 @@ def make(self, key): log_df.drop(index=0, inplace=True) # look for the first maintenance # Last entry is the visit end - if (log_df.iloc[-1]["message"] == "Maintenance"): + if log_df.iloc[-1]["message"] == "Maintenance": log_df_end = log_df.tail(1) log_df_end["message_time"], log_df_end["message"] = ( @@ -544,7 +540,7 @@ def make(self, key): day_start = datetime.datetime.combine(visit_date.date(), time.min) day_end = datetime.datetime.combine( visit_date.date() + datetime.timedelta(days=1), time.min - ) + ) # start of the next day day_start = max(day_start, visit_start) day_end = min(day_end, visit_end) @@ -561,25 +557,21 @@ def make(self, key): ) # filter out maintenance period based on logs - maintenance_filter = np.full(len(position), False) - while maintenance_period: (maintenance_start, maintenance_end) = maintenance_period[0] - if day_end < maintenance_start: + if day_end < maintenance_start: # no more maintenance for this date break - bool_mask = np.full(len(position), False) - bool_mask = (position.index >= maintenance_start) & ( + + maintenance_filter = (position.index >= maintenance_start) & ( position.index <= maintenance_end ) - if bool_mask.sum(): - maintenance_filter += bool_mask - if day_end >= maintenance_end: - maintenance_period.popleft() - else: - break - - position[maintenance_filter] = np.nan + position[maintenance_filter] = np.nan + + if day_end >= maintenance_end: # remove this range + maintenance_period.popleft() + else: + break # filter for objects of the correct size valid_position = (position.area > 0) & (position.area < 1000) From 8d49bb4ad9ef79dbbdc08ee2b2b7898835a9a5d9 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 15 Sep 2022 14:37:49 -0500 Subject: [PATCH 103/489] clean up - filter maintenance also for wheeldata and pellets --- aeon/dj_pipeline/analysis/visit_analysis.py | 225 ++++++++------------ 1 file changed, 89 insertions(+), 136 deletions(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index a7a17f5b..876e3e86 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -274,53 +274,15 @@ def make(self, key): visit_dates = pd.date_range( start=pd.Timestamp(visit_start.date()), end=pd.Timestamp(visit_end.date()) ) - - # get logs from ExperimentLog - log_df = dj.U("experiment_name", "message_time", "message") & ( - ExperimentLog.Message() - & 'message IN ("Maintenance", "Experiment")' - & f'message_time BETWEEN "{visit_start}" AND "{visit_end}"' + maintenance_period = _get_maintenance_periods( + key["experiment_name"], visit_start, visit_end ) - log_df = log_df.fetch(format="frame", order_by="message_time").reset_index() - log_df = log_df[log_df["message"].shift() != log_df["message"]].reset_index( - drop=True - ) # remove duplicates and keep the first one - - # An experiment starts with visit start (anything before the first maintenance is experiment) - # Delete the row if it starts with "Experiment" - if log_df.iloc[0]["message"] == "Experiment": - log_df.drop(index=0, inplace=True) # look for the first maintenance - - # Last entry is the visit end - if log_df.iloc[-1]["message"] == "Maintenance": - - log_df_end = log_df.tail(1) - log_df_end["message_time"], log_df_end["message"] = ( - pd.Timestamp(visit_end), - "VisitEnd", - ) - log_df = pd.concat([log_df, log_df_end]) - log_df.reset_index(drop=True, inplace=True) - - start_timestamps = log_df.loc[ - log_df["message"] == "Maintenance", "message_time" - ].values - end_timestamps = log_df.loc[ - log_df["message"] != "Maintenance", "message_time" - ].values - maintenance_period = deque( - [ - (pd.Timestamp(start), pd.Timestamp(end)) - for start, end in zip(start_timestamps, end_timestamps) - ] - ) # queue object. pop out from left after use for visit_date in visit_dates: day_start = datetime.datetime.combine(visit_date.date(), time.min) day_end = datetime.datetime.combine( visit_date.date() + datetime.timedelta(days=1), time.min ) # start of the next day - day_start = max(day_start, visit_start) day_end = min(day_end, visit_end) @@ -329,28 +291,14 @@ def make(self, key): (day_end - day_start) / datetime.timedelta(hours=1), 3, ) - # subject's position data in the time_slices per day position = VisitSubjectPosition.get_position( subject=key["subject"], start=day_start, end=day_end ) - # filter out maintenance period based on logs - while maintenance_period: - (maintenance_start, maintenance_end) = maintenance_period[0] - - if day_end < maintenance_start: # no more maintenance for this date - break - - maintenance_filter = (position.index >= maintenance_start) & ( - position.index <= maintenance_end - ) - position[maintenance_filter] = np.nan - - if day_end >= maintenance_end: # remove this range - maintenance_period.popleft() - else: - break + position = _filter_out_maintenance_periods( + position, maintenance_period, day_end + ) # filter for objects of the correct size valid_position = (position.area > 0) & (position.area < 1000) @@ -358,7 +306,6 @@ def make(self, key): position.rename( columns={"position_x": "x", "position_y": "y"}, inplace=True ) - # in corridor distance_from_center = tracking.compute_distance( position[["x", "y"]], @@ -367,9 +314,7 @@ def make(self, key): in_corridor = (distance_from_center < tracking.arena_outer_radius) & ( distance_from_center > tracking.arena_inner_radius ) - in_arena = ~in_corridor - # in nests - loop through all nests in this experiment in_nest_times = [] for nest_key in (lab.ArenaNest & key).fetch("KEY"): @@ -384,12 +329,10 @@ def make(self, key): } ) in_arena = in_arena & ~in_nest - # in food patches - loop through all in-use patches during this visit query = acquisition.ExperimentFoodPatch.join( acquisition.ExperimentFoodPatch.RemovalTime, left=True ) - food_patch_keys = ( query & ( @@ -404,7 +347,6 @@ def make(self, key): ).fetch("KEY") in_food_patch_times = [] - for food_patch_key in food_patch_keys: # wheel data food_patch_description = ( @@ -417,26 +359,19 @@ def make(self, key): patch_name=food_patch_description, using_aeon_io=True, ) - + # filter out maintenance period based on logs + wheel_data = _filter_out_maintenance_periods( + wheel_data, maintenance_period, day_end + ) patch_position = ( - dj.U( - "food_patch_serial_number", - "food_patch_position_x", - "food_patch_position_y", - "food_patch_description", - ) - & acquisition.ExperimentFoodPatch - * acquisition.ExperimentFoodPatch.Position - & food_patch_key + acquisition.ExperimentFoodPatch.Position & food_patch_key ).fetch1("food_patch_position_x", "food_patch_position_y") - in_patch = tracking.is_in_patch( position, patch_position, wheel_data.distance_travelled, patch_radius=0.2, ) - in_food_patch_times.append( { **key, @@ -446,7 +381,6 @@ def make(self, key): "in_patch": in_patch.index.values[in_patch], } ) - in_arena = in_arena & ~in_patch self.insert1( @@ -495,46 +429,9 @@ def make(self, key): visit_dates = pd.date_range( start=pd.Timestamp(visit_start.date()), end=pd.Timestamp(visit_end.date()) ) - - # get logs from ExperimentLog - log_df = dj.U("experiment_name", "message_time", "message") & ( - ExperimentLog.Message() - & 'message IN ("Maintenance", "Experiment")' - & f'message_time BETWEEN "{visit_start}" AND "{visit_end}"' + maintenance_period = _get_maintenance_periods( + key["experiment_name"], visit_start, visit_end ) - log_df = log_df.fetch(format="frame", order_by="message_time").reset_index() - log_df = log_df[log_df["message"].shift() != log_df["message"]].reset_index( - drop=True - ) # remove duplicates and keep the first one - - # An experiment starts with visit start (anything before the first maintenance is experiment) - # Delete the row if it starts with "Experiment" - if log_df.iloc[0]["message"] == "Experiment": - log_df.drop(index=0, inplace=True) # look for the first maintenance - - # Last entry is the visit end - if log_df.iloc[-1]["message"] == "Maintenance": - - log_df_end = log_df.tail(1) - log_df_end["message_time"], log_df_end["message"] = ( - pd.Timestamp(visit_end), - "VisitEnd", - ) - log_df = pd.concat([log_df, log_df_end]) - log_df.reset_index(drop=True, inplace=True) - - start_timestamps = log_df.loc[ - log_df["message"] == "Maintenance", "message_time" - ].values - end_timestamps = log_df.loc[ - log_df["message"] != "Maintenance", "message_time" - ].values - maintenance_period = deque( - [ - (pd.Timestamp(start), pd.Timestamp(end)) - for start, end in zip(start_timestamps, end_timestamps) - ] - ) # queue object. pop out from left after use for visit_date in visit_dates: day_start = datetime.datetime.combine(visit_date.date(), time.min) @@ -550,36 +447,20 @@ def make(self, key): (day_end - day_start) / datetime.timedelta(hours=1), 3, ) - # subject's position data in the time_slices per day position = VisitSubjectPosition.get_position( subject=key["subject"], start=day_start, end=day_end ) - # filter out maintenance period based on logs - while maintenance_period: - (maintenance_start, maintenance_end) = maintenance_period[0] - - if day_end < maintenance_start: # no more maintenance for this date - break - - maintenance_filter = (position.index >= maintenance_start) & ( - position.index <= maintenance_end - ) - position[maintenance_filter] = np.nan - - if day_end >= maintenance_end: # remove this range - maintenance_period.popleft() - else: - break - + position = _filter_out_maintenance_periods( + position, maintenance_period, day_end + ) # filter for objects of the correct size valid_position = (position.area > 0) & (position.area < 1000) position[~valid_position] = np.nan position.rename( columns={"position_x": "x", "position_y": "y"}, inplace=True ) - position_diff = np.sqrt( np.square(np.diff(position.x)) + np.square(np.diff(position.y)) ) @@ -589,7 +470,6 @@ def make(self, key): query = acquisition.ExperimentFoodPatch.join( acquisition.ExperimentFoodPatch.RemovalTime, left=True ) - food_patch_keys = ( query & ( @@ -604,7 +484,6 @@ def make(self, key): ).fetch("KEY") food_patch_statistics = [] - for food_patch_key in food_patch_keys: pellet_events = ( acquisition.FoodPatchEvent * acquisition.EventType @@ -612,6 +491,13 @@ def make(self, key): & 'event_type = "TriggerPellet"' & f'event_time BETWEEN "{day_start}" AND "{day_end}"' ).fetch("event_time") + # filter out maintenance period based on logs + pellet_events = _filter_out_maintenance_periods( + pd.DataFrame(pellet_events).set_index(0), + maintenance_period, + day_end, + dropna=True, + ).index.values # wheel data food_patch_description = ( acquisition.ExperimentFoodPatch & food_patch_key @@ -623,6 +509,10 @@ def make(self, key): patch_name=food_patch_description, using_aeon_io=True, ) + # filter out maintenance period based on logs + wheel_data = _filter_out_maintenance_periods( + wheel_data, maintenance_period, day_end + ) food_patch_statistics.append( { @@ -655,3 +545,66 @@ def make(self, key): } ) self.FoodPatch.insert(food_patch_statistics) + + +def _get_maintenance_periods(experiment_name, start, end): + # get logs from ExperimentLog + log_df = ( + ExperimentLog.Message.proj("message") + & {"experiment_name": experiment_name} + & 'message IN ("Maintenance", "Experiment")' + & f'message_time BETWEEN "{start}" AND "{end}"' + ) + log_df = log_df.fetch(format="frame", order_by="message_time").reset_index() + log_df = log_df[log_df["message"].shift() != log_df["message"]].reset_index( + drop=True + ) # remove duplicates and keep the first one + + # An experiment starts with visit start (anything before the first maintenance is experiment) + # Delete the row if it starts with "Experiment" + if log_df.iloc[0]["message"] == "Experiment": + log_df.drop(index=0, inplace=True) # look for the first maintenance + + # Last entry is the visit end + if log_df.iloc[-1]["message"] == "Maintenance": + log_df_end = log_df.tail(1) + log_df_end["message_time"], log_df_end["message"] = ( + pd.Timestamp(end), + "VisitEnd", + ) + log_df = pd.concat([log_df, log_df_end]) + log_df.reset_index(drop=True, inplace=True) + + start_timestamps = log_df.loc[ + log_df["message"] == "Maintenance", "message_time" + ].values + end_timestamps = log_df.loc[ + log_df["message"] != "Maintenance", "message_time" + ].values + + return deque( + [ + (pd.Timestamp(start), pd.Timestamp(end)) + for start, end in zip(start_timestamps, end_timestamps) + ] + ) # queue object. pop out from left after use + + +def _filter_out_maintenance_periods( + data_df, maintenance_period, end_time, dropna=False +): + while maintenance_period: + (maintenance_start, maintenance_end) = maintenance_period[0] + if end_time < maintenance_start: # no more maintenance for this date + break + maintenance_filter = (data_df.index >= maintenance_start) & ( + data_df.index <= maintenance_end + ) + data_df[maintenance_filter] = np.nan + if end_time >= maintenance_end: # remove this range + maintenance_period.popleft() + else: + break + if dropna: + data_df.dropna(inplace=True) + return data_df From 28e8a9b30541401656a26eb296dd576aee9f48a6 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 15 Sep 2022 17:25:38 -0500 Subject: [PATCH 104/489] add Visit into `mid_priority` automated ingestion --- aeon/dj_pipeline/analysis/visit_analysis.py | 1 - aeon/dj_pipeline/populate/process.py | 6 ++++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 876e3e86..a9aaa242 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -21,7 +21,6 @@ class PositionFilteringMethod(dj.Lookup): definition = """ pos_filter_method: varchar(16) - --- pos_filter_method_description: varchar(256) """ diff --git a/aeon/dj_pipeline/populate/process.py b/aeon/dj_pipeline/populate/process.py index 59c029c6..9435f79b 100644 --- a/aeon/dj_pipeline/populate/process.py +++ b/aeon/dj_pipeline/populate/process.py @@ -85,6 +85,12 @@ mid_priority(qc.CameraQC) mid_priority(tracking.CameraTracking) mid_priority(acquisition.FoodPatchWheel) + +mid_priority( + analysis.visit.ingest_environment_visits, experiment_names=[_current_experiment] +) +mid_priority(analysis.OverlapVisit) + mid_priority(analysis.VisitSubjectPosition) mid_priority(analysis.VisitTimeDistribution) mid_priority(analysis.VisitSummary) From 387d791a105a8865778f8b3bf0f4594e41b81ab8 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 23 Sep 2022 12:15:22 -0500 Subject: [PATCH 105/489] add FilteredWeight table, minor improvements --- aeon/dj_pipeline/acquisition.py | 98 ++++++++++++++++++++-------- aeon/dj_pipeline/populate/process.py | 1 + 2 files changed, 72 insertions(+), 27 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 6a3580f6..7705966c 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -965,46 +965,90 @@ def make(self, key): "weight_scale_description" ) - device = getattr( - _device_schema_mapping[key["experiment_name"]], weight_scale_description - ) - - weight_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=device.WeightRaw, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - if not len( - weight_data - ): # in some sessions, the food patch deice was mapped to "Nest" - + # in some epochs/chunks, the food patch device was mapped to "Nest" + for device_name in (weight_scale_description, "Nest"): device = getattr( - _device_schema_mapping[key["experiment_name"]], "Nest" + _device_schema_mapping[key["experiment_name"]], device_name ) - weight_data = io_api.load( root=raw_data_dir.as_posix(), reader=device.WeightRaw, start=pd.Timestamp(chunk_start), end=pd.Timestamp(chunk_end), ) - - if not len(weight_data): + if len(weight_data): + break + else: raise ValueError(f"No weight measurement found for {key}") + weight_data.sort_index(inplace=True) + self.insert1( + { + **key, + "timestamps": weight_data.index.values, + "weight": weight_data.value.values, + "confidence": weight_data.stable.values.astype(float), + } + ) + + +@schema +class WeightMeasurementFiltered(dj.Imported): + definition = """ # Raw scale measurement associated with a given ExperimentScale + -> WeightMeasurement + --- + weight_filtered: longblob # measured weights filtered + weight_subject_timestamps: longblob # (datetime) timestamps of weight_subject data + weight_subject: longblob # + """ + + def make(self, key): + chunk_start, chunk_end, dir_type = (Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = Experiment.get_data_directory(key, directory_type=dir_type) + weight_scale_description = (ExperimentWeightScale & key).fetch1( + "weight_scale_description" + ) + + # in some epochs/chunks, the food patch device was mapped to "Nest" + for device_name in (weight_scale_description, "Nest"): + device = getattr( + _device_schema_mapping[key["experiment_name"]], device_name + ) + weight_filtered = io_api.load( + root=raw_data_dir.as_posix(), + reader=device.WeightFiltered, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + if len(weight_filtered): + break else: - weight_data.sort_index(inplace=True) - self.insert1( - { - **key, - "timestamps": weight_data.index.values, - "weight": weight_data.value.values, - "confidence": weight_data.stable.values.astype(float), - } + raise ValueError( + f"No filtered weight measurement found for {key} - this is truly unexpected - a bug?" ) + weight_subject = io_api.load( + root=raw_data_dir.as_posix(), + reader=device.WeightSubject, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + assert len(weight_filtered) + + weight_filtered.sort_index(inplace=True) + weight_subject.sort_index(inplace=True) + self.insert1( + { + **key, + "weight_filtered": weight_filtered.value.values, + "weight_subject_timestamps": weight_subject.index.values, + "weight_subject": weight_subject.value.values, + } + ) + # ---- Task Protocol categorization ---- diff --git a/aeon/dj_pipeline/populate/process.py b/aeon/dj_pipeline/populate/process.py index 9435f79b..40409d9a 100644 --- a/aeon/dj_pipeline/populate/process.py +++ b/aeon/dj_pipeline/populate/process.py @@ -68,6 +68,7 @@ high_priority(acquisition.FoodPatchEvent) high_priority(acquisition.WheelState) high_priority(acquisition.WeightMeasurement) +high_priority(acquisition.WeightMeasurementFiltered) high_priority( analysis.ingest_environment_visits, experiment_names=[_current_experiment] From c665c1a2b865003b554631baebebed7f0aea6506 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 23 Sep 2022 12:15:32 -0500 Subject: [PATCH 106/489] minor improvements --- aeon/dj_pipeline/device_stream.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/aeon/dj_pipeline/device_stream.py b/aeon/dj_pipeline/device_stream.py index b7adc95f..b56df8a7 100644 --- a/aeon/dj_pipeline/device_stream.py +++ b/aeon/dj_pipeline/device_stream.py @@ -231,7 +231,7 @@ class RemovalTime(dj.Part): stream_type = stream_detail["stream_type"] stream_title = _prettify(stream_type) - logger.info(f"Creating stream table: {stream_title}") + logger.info(f"Creating stream table: {device_title}{stream_title}") for i, n in enumerate(stream_detail["stream_reader"].split(".")): if i == 0: @@ -324,5 +324,7 @@ def _prettify(s): return s.replace("_", " ").title().replace(" ", "") -for device_type in DeviceType.fetch("device_type"): - generate_device_table(device_type) +def main(): + for device_type in DeviceType.fetch("device_type"): + logger.info(f"Generating stream table(s) for: {device_type}") + generate_device_table(device_type) From d33ebfb7a2cc8f3a672608d31dc6141778707849 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 28 Sep 2022 13:48:13 -0500 Subject: [PATCH 107/489] bugfix, improvements, testing for automatic ingestion of device_stream --- aeon/dj_pipeline/device_stream.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/aeon/dj_pipeline/device_stream.py b/aeon/dj_pipeline/device_stream.py index b56df8a7..864c08a6 100644 --- a/aeon/dj_pipeline/device_stream.py +++ b/aeon/dj_pipeline/device_stream.py @@ -245,6 +245,7 @@ class RemovalTime(dj.Part): -> Experiment{device_title} -> acquisition.Chunk --- + size: int # number of data points acquired from this stream for a given chunk timestamps: longblob # (datetime) timestamps of {stream_type} data """ @@ -256,6 +257,8 @@ class RemovalTime(dj.Part): @_schema class DeviceDataStream(dj.Imported): definition = table_definition + _stream_reader = reader + _stream_detail = stream_detail @property def key_source(self): @@ -283,10 +286,10 @@ def make(self, key): f"{device_type}_description" ) - stream = reader( + stream = self._stream_reader( **{ k: v.format(device_description) if k == "pattern" else v - for k, v in stream_detail["stream_reader_kwargs"].items() + for k, v in self._stream_detail["stream_reader_kwargs"].items() } ) @@ -297,12 +300,10 @@ def make(self, key): end=pd.Timestamp(chunk_end), ) - if not len(stream_data): - raise ValueError(f"No stream data found for {key}") - self.insert1( { **key, + "size": len(stream_data), "timestamps": stream_data.index.values, **{ c: stream_data[c].values @@ -325,6 +326,7 @@ def _prettify(s): def main(): + context = inspect.currentframe().f_back.f_locals for device_type in DeviceType.fetch("device_type"): logger.info(f"Generating stream table(s) for: {device_type}") - generate_device_table(device_type) + generate_device_table(device_type, context=context) From a9208bf97ca0fb87a71e2291ebab132574d04f8f Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 28 Sep 2022 17:14:24 -0500 Subject: [PATCH 108/489] restructure modules/scripts --- aeon/dj_pipeline/README.md | 6 +++--- aeon/dj_pipeline/acquisition.py | 6 +++--- .../create_experiment_01.py | 0 .../create_experiment_02.py | 0 .../create_socialexperiment_0.py | 4 +++- .../setup_yml/Experiment0.1.yml | 0 .../setup_yml/SocialExperiment0.yml | 0 aeon/dj_pipeline/{populate => utils}/load_metadata.py | 2 +- tests/conftest.py | 2 +- 9 files changed, 11 insertions(+), 9 deletions(-) rename aeon/dj_pipeline/{populate => create_experiments}/create_experiment_01.py (100%) rename aeon/dj_pipeline/{populate => create_experiments}/create_experiment_02.py (100%) rename aeon/dj_pipeline/{populate => create_experiments}/create_socialexperiment_0.py (98%) rename aeon/dj_pipeline/{populate => create_experiments}/setup_yml/Experiment0.1.yml (100%) rename aeon/dj_pipeline/{populate => create_experiments}/setup_yml/SocialExperiment0.yml (100%) rename aeon/dj_pipeline/{populate => utils}/load_metadata.py (99%) diff --git a/aeon/dj_pipeline/README.md b/aeon/dj_pipeline/README.md index a6070ba9..3ee08491 100644 --- a/aeon/dj_pipeline/README.md +++ b/aeon/dj_pipeline/README.md @@ -91,9 +91,9 @@ animals, cameras, food patches setup, etc. + These information are either entered by hand, or parsed and inserted from configuration yaml files. + For experiments these info can be inserted by running - + [create_experiment_01](populate/create_experiment_01.py) - + [create_socialexperiment_0](populate/create_socialexperiment_0.py) - + [create_experiment_02](populate/create_experiment_02.py) + + [create_experiment_01](create_experiments/create_experiment_01.py) + + [create_socialexperiment_0](create_experiments/create_socialexperiment_0.py) + + [create_experiment_02](create_experiments/create_experiment_02.py) (just need to do this once) Tables in DataJoint are written with a `make()` function - diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 7705966c..8749a900 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -10,8 +10,8 @@ from aeon.io import reader as io_reader from aeon.schema import dataset as aeon_schema -from . import get_schema_name, lab, subject -from .populate.load_metadata import extract_epoch_metadata, ingest_epoch_metadata +from . import get_schema_name +from .utils.load_metadata import extract_epoch_metadata, ingest_epoch_metadata from .utils import paths schema = dj.schema(get_schema_name("acquisition")) @@ -1122,7 +1122,7 @@ def _load_legacy_subjectdata(experiment_name, data_dir, start, end): return subject_data if experiment_name == "social0-r1": - from aeon.dj_pipeline.populate.create_socialexperiment_0 import fixID + from aeon.dj_pipeline.create_experiments.create_socialexperiment_0 import fixID sessdf = subject_data.copy() sessdf = sessdf[~sessdf.id.str.contains("test")] diff --git a/aeon/dj_pipeline/populate/create_experiment_01.py b/aeon/dj_pipeline/create_experiments/create_experiment_01.py similarity index 100% rename from aeon/dj_pipeline/populate/create_experiment_01.py rename to aeon/dj_pipeline/create_experiments/create_experiment_01.py diff --git a/aeon/dj_pipeline/populate/create_experiment_02.py b/aeon/dj_pipeline/create_experiments/create_experiment_02.py similarity index 100% rename from aeon/dj_pipeline/populate/create_experiment_02.py rename to aeon/dj_pipeline/create_experiments/create_experiment_02.py diff --git a/aeon/dj_pipeline/populate/create_socialexperiment_0.py b/aeon/dj_pipeline/create_experiments/create_socialexperiment_0.py similarity index 98% rename from aeon/dj_pipeline/populate/create_socialexperiment_0.py rename to aeon/dj_pipeline/create_experiments/create_socialexperiment_0.py index ed63b447..e48f4d65 100644 --- a/aeon/dj_pipeline/populate/create_socialexperiment_0.py +++ b/aeon/dj_pipeline/create_experiments/create_socialexperiment_0.py @@ -1,7 +1,9 @@ import pathlib from aeon.dj_pipeline import acquisition, lab, subject -from aeon.dj_pipeline.populate.create_experiment_01 import ingest_exp01_metadata +from aeon.dj_pipeline.create_experiments.create_experiment_01 import ( + ingest_exp01_metadata, +) # ============ Manual and automatic steps to for experiment 0.1 populate ============ experiment_name = "social0-r1" diff --git a/aeon/dj_pipeline/populate/setup_yml/Experiment0.1.yml b/aeon/dj_pipeline/create_experiments/setup_yml/Experiment0.1.yml similarity index 100% rename from aeon/dj_pipeline/populate/setup_yml/Experiment0.1.yml rename to aeon/dj_pipeline/create_experiments/setup_yml/Experiment0.1.yml diff --git a/aeon/dj_pipeline/populate/setup_yml/SocialExperiment0.yml b/aeon/dj_pipeline/create_experiments/setup_yml/SocialExperiment0.yml similarity index 100% rename from aeon/dj_pipeline/populate/setup_yml/SocialExperiment0.yml rename to aeon/dj_pipeline/create_experiments/setup_yml/SocialExperiment0.yml diff --git a/aeon/dj_pipeline/populate/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py similarity index 99% rename from aeon/dj_pipeline/populate/load_metadata.py rename to aeon/dj_pipeline/utils/load_metadata.py index acda2145..2d851372 100644 --- a/aeon/dj_pipeline/populate/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -4,7 +4,7 @@ import yaml from aeon.dj_pipeline import acquisition, lab -from .. import dict_to_uuid +from aeon.dj_pipeline import dict_to_uuid _weight_scale_rate = 100 diff --git a/tests/conftest.py b/tests/conftest.py index 0ed5c63c..c038c0dc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -112,7 +112,7 @@ def pipeline(dj_config): @pytest.fixture(autouse=True, scope="session") def experiment_creation(test_params, pipeline): - from aeon.dj_pipeline.populate import create_experiment_02 + from aeon.dj_pipeline.create_experiments import create_experiment_02 create_experiment_02.main() From bbcbe183af154a60d8292fb2d50ff8493f986f50 Mon Sep 17 00:00:00 2001 From: JR Date: Tue, 4 Oct 2022 09:47:17 -0500 Subject: [PATCH 109/489] style: :hammer: update sci-viz --- .../webapps/sciviz/docker-compose-local.yaml | 8 ++-- .../dj_pipeline/webapps/sciviz/specsheet.yaml | 44 +++++++++---------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml index 2e683a86..bd0bcfe4 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml @@ -6,7 +6,7 @@ services: pharus: cpus: 2.0 mem_limit: 4g - image: jverswijver/pharus:0.4.2-Beta.0 + image: jverswijver/pharus:0.4.2-prerelease-5 environment: # - FLASK_ENV=development # enables logging to console from Flask - PHARUS_SPEC_PATH=/main/specs/specsheet.yaml # for dynamic utils spec @@ -47,7 +47,7 @@ services: sci-viz: cpus: 2.0 mem_limit: 4g - image: jverswijver/sci-viz:0.1.3-beta.3 + image: jverswijver/sci-viz:0.2.0-prerelease-7 environment: - CHOKIDAR_USEPOLLING=true - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/api @@ -89,7 +89,7 @@ services: networks: - main fakeservices.datajoint.io: - image: datajoint/nginx:v0.1.1 + image: datajoint/nginx:v0.2.3 environment: - ADD_pharus_TYPE=REST - ADD_pharus_ENDPOINT=pharus:5000 @@ -104,4 +104,4 @@ services: networks: - main networks: - main: \ No newline at end of file + main: diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 6aa854f1..ecb92ee9 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -1,4 +1,4 @@ -version: 'v0.0.0' +version: "v0.1.0" LabBook: null SciViz: auth: True @@ -18,7 +18,7 @@ SciViz: y: 0 height: 1 width: 1 - type: table + type: antd-table restriction: > def restriction(**kwargs): return dict(**kwargs) @@ -28,51 +28,51 @@ SciViz: subject_session_count = acquisition.Experiment.Subject.aggr(acquisition.SessionEnd.join(acquisition.Session, left=True), ..., session_count='count(session_start)') subject_latest_session = acquisition.Experiment.Subject.aggr(acquisition.Session, session_start='max(session_start)').join(acquisition.SessionEnd, left=True) return {'query': subject_session_count * subject_latest_session, 'fetch_args': []} - Sessions: - route: /sessions + Visits: + route: /visits grids: grid2: type: fixed columns: 1 row_height: 700 components: - Sessions: + Visits: route: /query2 - link: /per_session_report + link: /per_visit_report x: 0 y: 0 height: 1 width: 1 - type: table + type: antd-table restriction: > def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_acquisition): - query = aeon_acquisition.Session.join(aeon_acquisition.SessionEnd, left=True).proj('session_end', session_duration='IFNULL(session_duration, -1)') + def dj_query(aeon_analysis_visit): + query = aeon_analysis_visit.Visit.join(aeon_analysis_visit.VisitEnd, left=True).proj('visit_end', visit_duration='IFNULL(visit_duration, -1)') return {'query': query, 'fetch_args': []} - SessionSummary: - route: /summary_sessions + VisitSummary: + route: /summary_visits grids: grid3: type: fixed columns: 1 row_height: 700 components: - SessionSummary: - route: /sessions_summary_grid3_1 - link: /per_session_report + VisitSummary: + route: /visits_summary_grid3_1 + link: /per_visit_report x: 0 y: 0 height: 1 width: 1 - type: table + type: antd-table restriction: > def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_analysis): - query = aeon_analysis.SessionSummary + def dj_query(aeon_analysis_visit_analysis): + query = aeon_analysis_visit_analysis.VisitSummary return {'query': query, 'fetch_args': []} ExperimentReport: @@ -241,7 +241,7 @@ SciViz: VisitDailySummary: route: /visit_daily_summary grids: - visit_dailty_summary: + visit_daily_summary: route: /visit_daily_summary_grid1 type: dynamic columns: 2 @@ -304,7 +304,7 @@ SciViz: y: 0 height: 1 width: 1 - type: table + type: antd-table restriction: > def restriction(**kwargs): return dict(**kwargs) @@ -332,7 +332,7 @@ SciViz: y: 1 height: 1 width: 1 - type: table + type: antd-table restriction: > def restriction(**kwargs): return dict(**kwargs) @@ -346,7 +346,7 @@ SciViz: y: 2 height: 1 width: 1 - type: table + type: antd-table restriction: > def restriction(**kwargs): return dict(**kwargs) @@ -377,7 +377,7 @@ SciViz: y: 3 height: 1 width: 1 - type: table + type: antd-table restriction: > def restriction(**kwargs): return dict(**kwargs) From 2cc42d119f4bdd0b9b46f5801c82c6468d63de7f Mon Sep 17 00:00:00 2001 From: JR Date: Tue, 4 Oct 2022 15:09:42 -0500 Subject: [PATCH 110/489] fix: :bug: visit summary query update --- .../dj_pipeline/webapps/sciviz/specsheet.yaml | 32 +++---------------- 1 file changed, 5 insertions(+), 27 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index ecb92ee9..ca2d59eb 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -28,31 +28,8 @@ SciViz: subject_session_count = acquisition.Experiment.Subject.aggr(acquisition.SessionEnd.join(acquisition.Session, left=True), ..., session_count='count(session_start)') subject_latest_session = acquisition.Experiment.Subject.aggr(acquisition.Session, session_start='max(session_start)').join(acquisition.SessionEnd, left=True) return {'query': subject_session_count * subject_latest_session, 'fetch_args': []} - Visits: - route: /visits - grids: - grid2: - type: fixed - columns: 1 - row_height: 700 - components: - Visits: - route: /query2 - link: /per_visit_report - x: 0 - y: 0 - height: 1 - width: 1 - type: antd-table - restriction: > - def restriction(**kwargs): - return dict(**kwargs) - dj_query: > - def dj_query(aeon_analysis_visit): - query = aeon_analysis_visit.Visit.join(aeon_analysis_visit.VisitEnd, left=True).proj('visit_end', visit_duration='IFNULL(visit_duration, -1)') - return {'query': query, 'fetch_args': []} VisitSummary: - route: /summary_visits + route: /visit_summary grids: grid3: type: fixed @@ -60,7 +37,7 @@ SciViz: row_height: 700 components: VisitSummary: - route: /visits_summary_grid3_1 + route: /visit_summary_grid3_1 link: /per_visit_report x: 0 y: 0 @@ -71,8 +48,9 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_analysis_visit_analysis): - query = aeon_analysis_visit_analysis.VisitSummary + def dj_query(aeon_analysis): + query = aeon_analysis.Visit.join(aeon_analysis.VisitEnd, left=True).join(aeon_analysis.VisitSummary, left=True) + query = query.proj(visit_end='IFNULL(visit_end, -1)', visit_duration='IFNULL(visit_duration, -1)', day_duration='IFNULL(visit_duration, -1)', total_distance_travelled='IFNULL(visit_duration, -1)', total_pellet_count='IFNULL(visit_duration, -1)', total_wheel_distance_travelled='IFNULL(visit_duration, -1)') return {'query': query, 'fetch_args': []} ExperimentReport: From 8066091c7729de5ef50a0b3eca72ce21528f7201 Mon Sep 17 00:00:00 2001 From: JR Date: Tue, 4 Oct 2022 15:21:59 -0500 Subject: [PATCH 111/489] bump up docker images --- .../webapps/sciviz/docker-compose-remote.yaml | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml index be3ec9f3..17acfadc 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml @@ -6,7 +6,7 @@ services: pharus: cpus: 2.0 mem_limit: 4g - image: jverswijver/pharus:0.4.2-Beta.0 + image: jverswijver/pharus:0.4.2-prerelease-5 environment: # - FLASK_ENV=development # enables logging to console from Flask - PHARUS_SPEC_PATH=/main/specs/specsheet.yaml # for dynamic utils spec @@ -26,7 +26,7 @@ services: sci-viz: cpus: 2.0 mem_limit: 4g - image: jverswijver/sci-viz:0.1.3-beta.3 + image: jverswijver/sci-viz:0.2.0-prerelease-7 environment: - CHOKIDAR_USEPOLLING=true - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/aeon/utils @@ -46,7 +46,7 @@ services: networks: - main reverse-proxy: - image: datajoint/nginx:v0.1.1 + image: datajoint/nginx:v0.2.3 environment: - ADD_pharus_TYPE=REST - ADD_pharus_ENDPOINT=pharus:5000 @@ -54,17 +54,17 @@ services: - ADD_sciviz_TYPE=REST - ADD_sciviz_ENDPOINT=sci-viz:3000 - ADD_sciviz_PREFIX=/ -# - HTTPS_PASSTHRU=TRUE -# - CERTBOT_HOST=letsencrypt:80 + # - HTTPS_PASSTHRU=TRUE + # - CERTBOT_HOST=letsencrypt:80 - DEPLOYMENT_PORT -# - SUBDOMAINS -# - URL -# volumes: -# - ./letsencrypt-keys:/etc/letsencrypt:ro + # - SUBDOMAINS + # - URL + # volumes: + # - ./letsencrypt-keys:/etc/letsencrypt:ro ports: -# - "443:443" + # - "443:443" - "${DEPLOYMENT_PORT}:80" networks: - main networks: - main: \ No newline at end of file + main: From 8fc62c9ab21c07beaca8eed8628ae25663246e3d Mon Sep 17 00:00:00 2001 From: JR Date: Tue, 4 Oct 2022 15:41:26 -0500 Subject: [PATCH 112/489] change -1 to "NA" --- aeon/dj_pipeline/webapps/sciviz/specsheet.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index ca2d59eb..5100ca64 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -50,7 +50,7 @@ SciViz: dj_query: > def dj_query(aeon_analysis): query = aeon_analysis.Visit.join(aeon_analysis.VisitEnd, left=True).join(aeon_analysis.VisitSummary, left=True) - query = query.proj(visit_end='IFNULL(visit_end, -1)', visit_duration='IFNULL(visit_duration, -1)', day_duration='IFNULL(visit_duration, -1)', total_distance_travelled='IFNULL(visit_duration, -1)', total_pellet_count='IFNULL(visit_duration, -1)', total_wheel_distance_travelled='IFNULL(visit_duration, -1)') + query = query.proj(visit_end='IFNULL(visit_end, "NA")', visit_duration='IFNULL(visit_duration, "NA")', day_duration='IFNULL(visit_duration, "NA")', total_distance_travelled='IFNULL(visit_duration, "NA")', total_pellet_count='IFNULL(visit_duration, "NA")', total_wheel_distance_travelled='IFNULL(visit_duration, "NA")') return {'query': query, 'fetch_args': []} ExperimentReport: From 4e1e48cf2619f465346623d8d1e6bec315caf363 Mon Sep 17 00:00:00 2001 From: Jeffrey Erlich Date: Wed, 5 Oct 2022 17:15:54 +0100 Subject: [PATCH 113/489] populate octagon --- aeon/dj_pipeline/populate/create_octagon_1.py | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 aeon/dj_pipeline/populate/create_octagon_1.py diff --git a/aeon/dj_pipeline/populate/create_octagon_1.py b/aeon/dj_pipeline/populate/create_octagon_1.py new file mode 100644 index 00000000..8974f4a3 --- /dev/null +++ b/aeon/dj_pipeline/populate/create_octagon_1.py @@ -0,0 +1,71 @@ +from aeon.dj_pipeline import acquisition, lab, subject + + +# ============ Manual and automatic steps to for experiment 0.2 populate ============ +experiment_name = "oct1.0-r0" +_weight_scale_rate = 20 + + +def create_new_experiment(): + # ---------------- Subject ----------------- + # This will get replaced by content from colony.csv + subject_list = [ + {"subject": "A001", "sex": "U", "subject_birth_date": "2021-01-01"}, + {"subject": "A002", "sex": "U", "subject_birth_date": "2021-01-01"}, + {"subject": "A003", "sex": "U", "subject_birth_date": "2021-01-01"}, + {"subject": "A004", "sex": "U", "subject_birth_date": "2021-01-01"}, + {"subject": "A005", "sex": "U", "subject_birth_date": "2021-01-01"}, + {"subject": "A006", "sex": "U", "subject_birth_date": "2021-01-01"}, + {"subject": "A007", "sex": "U", "subject_birth_date": "2021-01-01"}, + ] + subject.Subject.insert(subject_list, skip_duplicates=True) + + # ---------------- Experiment ----------------- + acquisition.Experiment.insert1( + { + "experiment_name": experiment_name, + "experiment_start_time": "2022-02-22 09-00-00", + "experiment_description": "octagon 1.0", + "arena_name": "octagon-1m", + "lab": "SWC", + "location": "464", + "experiment_type": "first-to-port", + }, + skip_duplicates=True, + ) + acquisition.Experiment.Subject.insert( + [ + {"experiment_name": experiment_name, "subject": s["subject"]} + for s in subject_list + ], + skip_duplicates=True, + ) + + acquisition.Experiment.Directory.insert( + [ + { + "experiment_name": experiment_name, + "repository_name": "ceph_aeon", + "directory_type": "raw", + "directory_path": "aeon/data/raw/OCTAGON01/conf1", + }, + { + "experiment_name": experiment_name, + "repository_name": "ceph_aeon", + "directory_type": "quality-control", + "directory_path": "aeon/data/qc/OCTAGON01/conf1", + }, + ], + skip_duplicates=True, + ) + + + + +def main(): + create_new_experiment() + + +if __name__ == "__main__": + main() + From f68047267c0788f95becc806aae400e42e879d4e Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 6 Oct 2022 09:06:35 -0500 Subject: [PATCH 114/489] add octagon arena --- aeon/dj_pipeline/lab.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/aeon/dj_pipeline/lab.py b/aeon/dj_pipeline/lab.py index a6c95823..2b335790 100644 --- a/aeon/dj_pipeline/lab.py +++ b/aeon/dj_pipeline/lab.py @@ -3,7 +3,7 @@ from . import get_schema_name -schema = dj.schema(get_schema_name('lab')) +schema = dj.schema(get_schema_name("lab")) # ------------------- GENERAL LAB INFORMATION -------------------- @@ -20,8 +20,15 @@ class Lab(dj.Lookup): time_zone : varchar(64) """ - contents = [('SWC', 'Sainsbury Wellcome Centre', 'University College London', - '25 Howland Street London W1T 4JG', 'GMT+1')] + contents = [ + ( + "SWC", + "Sainsbury Wellcome Centre", + "University College London", + "25 Howland Street London W1T 4JG", + "GMT+1", + ) + ] @schema @@ -34,8 +41,10 @@ class Location(dj.Lookup): location_description='' : varchar(255) """ - contents = [('SWC', 'room-0', 'room for experiment 0'), - ('SWC', 'room-1', 'room for social experiment')] + contents = [ + ("SWC", "room-0", "room for experiment 0"), + ("SWC", "room-1", "room for social experiment"), + ] @schema @@ -114,12 +123,13 @@ class Source(dj.Lookup): # ------------------- ARENA INFORMATION -------------------- + @schema class ArenaShape(dj.Lookup): definition = """ arena_shape: varchar(32) """ - contents = zip(['square', 'circular', 'rectangular', 'linear']) + contents = zip(["square", "circular", "rectangular", "linear", "octagon"]) @schema @@ -131,6 +141,7 @@ class Arena(dj.Lookup): + z-dimension: z=0 is the lowest point of the arena (e.g. the ground) TODO: confirm/update this """ + definition = """ arena_name: varchar(32) # unique name of the arena (e.g. circular_2m) --- @@ -142,7 +153,9 @@ class Arena(dj.Lookup): """ contents = [ - ('circle-2m', 'circular arena with 2-meter diameter', 'circular', 2, 2, 0.2)] + ("circle-2m", "circular arena with 2-meter diameter", "circular", 2, 2, 0.2), + ("octagon", "octagon arena", "octagon", 1.8, 1.8, 0.2), + ] @schema @@ -202,4 +215,4 @@ class FoodPatch(dj.Lookup): class WeightScale(dj.Lookup): definition = """ # Physical weight scale devices, identified by unique serial number weight_scale_serial_number: varchar(12) - """ \ No newline at end of file + """ From 75b4dcd2551c3fa3b95d842c5c19755761d76d27 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Thu, 6 Oct 2022 18:49:45 +0000 Subject: [PATCH 115/489] fix: :bug: fix dj query in visit_summary in specsheet.yaml --- aeon/dj_pipeline/webapps/sciviz/specsheet.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 5100ca64..67f29fd2 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -49,8 +49,8 @@ SciViz: return dict(**kwargs) dj_query: > def dj_query(aeon_analysis): - query = aeon_analysis.Visit.join(aeon_analysis.VisitEnd, left=True).join(aeon_analysis.VisitSummary, left=True) - query = query.proj(visit_end='IFNULL(visit_end, "NA")', visit_duration='IFNULL(visit_duration, "NA")', day_duration='IFNULL(visit_duration, "NA")', total_distance_travelled='IFNULL(visit_duration, "NA")', total_pellet_count='IFNULL(visit_duration, "NA")', total_wheel_distance_travelled='IFNULL(visit_duration, "NA")') + query = aeon_analysis.Visit.aggr(aeon_analysis.VisitSummary, ..., duration="SUM(day_duration)", total_distance_travelled="SUM(total_distance_travelled)", total_pellet_count="SUM(total_pellet_count)", total_wheel_distance_travelled="SUM(total_wheel_distance_travelled)", keep_all_rows=True) + query = query.join(aeon_analysis.VisitEnd, left=True) return {'query': query, 'fetch_args': []} ExperimentReport: From 7b71c82a61d0bdbf3668b21f1d38ea47a4e5c7d0 Mon Sep 17 00:00:00 2001 From: JR Date: Sun, 9 Oct 2022 21:34:40 -0500 Subject: [PATCH 116/489] update subjects page --- .../webapps/sciviz/docker-compose-local.yaml | 3 +- .../dj_pipeline/webapps/sciviz/specsheet.yaml | 97 +------------------ 2 files changed, 4 insertions(+), 96 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml index bd0bcfe4..0148851d 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml @@ -1,5 +1,6 @@ +# HOST_UID=$(id -u) docker-compose -f docker-compose-local.yaml down --volumes # HOST_UID=$(id -u) docker-compose -f docker-compose-local.yaml up -# + # Access using fakeservices.datajoint.io version: '2.4' services: diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 67f29fd2..183aaf46 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -25,9 +25,8 @@ SciViz: dj_query: > def dj_query(aeon_acquisition): acquisition = aeon_acquisition - subject_session_count = acquisition.Experiment.Subject.aggr(acquisition.SessionEnd.join(acquisition.Session, left=True), ..., session_count='count(session_start)') - subject_latest_session = acquisition.Experiment.Subject.aggr(acquisition.Session, session_start='max(session_start)').join(acquisition.SessionEnd, left=True) - return {'query': subject_session_count * subject_latest_session, 'fetch_args': []} + query = acquisition.Experiment.Subject.aggr(visit_analysis.VisitEnd.join(visit_analysis.Visit, left=True), first_visit_start='MIN(visit_start)', last_visit_end='MAX(visit_end)', total_visit_count='COUNT(visit_start)', total_visit_duration='SUM(visit_duration)') + return {'query': query, 'fetch_args': []} VisitSummary: route: /visit_summary grids: @@ -125,97 +124,6 @@ SciViz: def dj_query(aeon_report): report = aeon_report return {'query': report.SubjectWheelTravelledDistance(), 'fetch_args': ['wheel_travelled_distance_plotly']} - PerSubjectReport: - hidden: true - route: /per_subject_report - grids: - per_subject_report: - type: fixed - route: /per_subject_report - columns: 1 - row_height: 400 - components: - comp1: - route: /per_subject_meta - x: 0 - y: 0 - height: 1 - width: 1 - type: metadata - restriction: > - def restriction(**kwargs): - return dict(**kwargs) - dj_query: > - def dj_query(aeon_acquisition): - return dict(query=aeon_acquisition.Experiment.Subject(), fetch_args=[]) - comp2: - route: /per_subject_reward_diff_plot - x: 0 - y: 1 - height: 1 - width: 1 - type: plot:plotly:stored_json - restriction: > - def restriction(**kwargs): - return dict(**kwargs) - dj_query: > - def dj_query(aeon_report): - report = aeon_report - return {'query': report.SubjectRewardRateDifference(), 'fetch_args': ['reward_rate_difference_plotly']} - comp3: - route: /per_subject_wheel_distance_travelled - x: 0 - y: 2 - height: 1 - width: 1 - type: plot:plotly:stored_json - restriction: > - def restriction(**kwargs): - return dict(**kwargs) - dj_query: > - def dj_query(aeon_report): - report = aeon_report - return {'query': report.SubjectWheelTravelledDistance(), 'fetch_args': ['wheel_travelled_distance_plotly']} - - PerSessionReport: - hidden: true - route: /per_session_report - grids: - per_session_report: - type: fixed - route: /per_session_report - columns: 1 - row_height: 400 - components: - comp1: - route: /per_session_meta - x: 0 - y: 0 - height: 1 - width: 1 - type: metadata - restriction: > - def restriction(**kwargs): - return dict(**kwargs) - dj_query: > - def dj_query(aeon_analysis): - query = aeon_analysis.SessionSummary() - return {'query': query, 'fetch_args': []} - comp2: - route: /per_session_summary_plot - x: 0 - y: 1 - height: 1 - width: 1 - type: file:image:attach - restriction: > - def restriction(**kwargs): - return dict(**kwargs) - dj_query: > - def dj_query(aeon_report): - report = aeon_report - return {'query': report.SessionSummaryPlot(), 'fetch_args': ['summary_plot_png']} - VisitDailySummary: route: /visit_daily_summary grids: @@ -267,7 +175,6 @@ SciViz: dj_query: > def dj_query(aeon_report): return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['foraging_bouts_plotly']) - PipelineMonitor: route: /pipeline_monitor grids: From 3d791f5ef04d68f65d6b0c46e69bf8d9a4dfaaad Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 10 Oct 2022 17:43:08 +0000 Subject: [PATCH 117/489] feat: :sparkles: add ingest_subject function in load_metadata.py --- aeon/dj_pipeline/populate/load_metadata.py | 61 ++++++---------------- 1 file changed, 16 insertions(+), 45 deletions(-) diff --git a/aeon/dj_pipeline/populate/load_metadata.py b/aeon/dj_pipeline/populate/load_metadata.py index acda2145..cd2315e6 100644 --- a/aeon/dj_pipeline/populate/load_metadata.py +++ b/aeon/dj_pipeline/populate/load_metadata.py @@ -1,14 +1,28 @@ -import re import pathlib +import re from datetime import datetime + +import pandas as pd import yaml from aeon.dj_pipeline import acquisition, lab -from .. import dict_to_uuid +from .. import dict_to_uuid _weight_scale_rate = 100 _weight_scale_nest = 1 +_colony_csv_path = pathlib.Path("/ceph/aeon/aeon/colony/colony.csv") + + +def ingest_subject(colony_csv_path: path.Pathlib = _colony_csv_path) -> None: + """Ingest subject information from the colony.csv file""" + colony_df = pd.read_csv(colony_csv_path, skiprows=[1, 2]) + colony_df.rename(columns={"Id": "subject"}, inplace=True) + colony_df["sex"] = "U" + colony_df["subject_birth_date"] = "2021-01-01" + colony_df["subject_description"] = "" + subject.Subject.insert(colony_df, skip_duplicates=True, ignore_extra_fields=True) + subject.Subject() def extract_epoch_metadata(experiment_name, metadata_yml_filepath): @@ -16,13 +30,10 @@ def extract_epoch_metadata(experiment_name, metadata_yml_filepath): epoch_start = datetime.strptime( metadata_yml_filepath.parent.name, "%Y-%m-%dT%H-%M-%S" ) - with open(metadata_yml_filepath, "r") as f: experiment_setup = yaml.safe_load(f) - commit = experiment_setup.get("Commit", experiment_setup.get("Revision")) assert commit, f'Neither "Commit" nor "Revision" found in {metadata_yml_filepath}' - return { "experiment_name": experiment_name, "epoch_start": epoch_start, @@ -40,24 +51,18 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): + camera/patch location + patch, weightscale serial number """ - metadata_yml_filepath = pathlib.Path(metadata_yml_filepath) file_creation_time = datetime.fromtimestamp(metadata_yml_filepath.stat().st_ctime) epoch_start = datetime.strptime( metadata_yml_filepath.parent.name, "%Y-%m-%dT%H-%M-%S" ) - with open(metadata_yml_filepath, "r") as f: experiment_setup = yaml.safe_load(f) - experiment_key = {"experiment_name": experiment_name} - # Check if there has been any changes in the arena setup # by comparing the "Commit" against the most immediate preceding epoch - commit = experiment_setup.get("Commit", experiment_setup.get("Revision")) assert commit, f'Neither "Commit" nor "Revision" found in {metadata_yml_filepath}' - previous_epoch = (acquisition.Experiment & experiment_key).aggr( acquisition.Epoch & f'epoch_start < "{epoch_start}"', epoch_start="MAX(epoch_start)", @@ -67,7 +72,6 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): ).fetch1("commit"): # if identical commit -> no changes return - if isinstance(experiment_setup["Devices"], list): experiment_devices = experiment_setup.pop("Devices") elif isinstance(experiment_setup["Devices"], dict): @@ -85,7 +89,6 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): device_type = "AudioAmbient" else: raise ValueError(f"Unrecognized Device Type for {device_name}") - experiment_devices.append( {"Name": device_name, "Type": device_type, **device_info} ) @@ -93,29 +96,23 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): raise ValueError( f"Unexpected devices variable type: {type(experiment_setup['Devices'])}" ) - # ---- Video Controller ---- - video_controller = [ device for device in experiment_devices if device["Type"] == "VideoController" ] - assert ( len(video_controller) == 1 ), "Unable to find one unique VideoController device" video_controller = video_controller[0] - device_frequency_mapper = { name: float(value) for name, value in video_controller.items() if name.endswith("Frequency") } - # ---- Load cameras ---- cameras = [ device for device in experiment_devices if device["Type"] == "VideoSource" ] - camera_list, camera_installation_list, camera_removal_list, camera_position_list = ( [], [], @@ -126,7 +123,6 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): # ---- Check if this is a new camera, add to lab.Camera if needed camera_key = {"camera_serial_number": camera["SerialNumber"]} camera_list.append(camera_key) - camera_installation = { "experiment_name": experiment_name, **camera_key, @@ -154,7 +150,6 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): "camera_rotation_y": None, "camera_rotation_z": None, } - # ---- Check if this camera is currently installed # If the same camera serial number is currently installed # check for any changes in configuration, if not, skip this @@ -168,13 +163,10 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): acquisition.ExperimentCamera.Position, left=True ).fetch1() new_camera_config = {**camera_installation, **camera_position} - current_camera_config.pop("camera_install_time") new_camera_config.pop("camera_install_time") - if dict_to_uuid(current_camera_config) == dict_to_uuid(new_camera_config): continue - # ---- Remove old camera camera_removal_list.append( { @@ -182,12 +174,10 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): "camera_remove_time": epoch_start, } ) - # ---- Install new camera camera_installation_list.append(camera_installation) if "position" in camera: camera_position_list.append(camera_position) - # remove the currently installed cameras that are absent in this config camera_removal_list.extend( ( @@ -197,7 +187,6 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): & experiment_key ).fetch("KEY") ) - # ---- Load food patches ---- food_patches = [ device for device in experiment_devices if device["Type"] == "Patch" @@ -214,7 +203,6 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): "food_patch_serial_number": patch.get("SerialNumber") or patch["PortName"] } patch_list.append(patch_key) - patch_installation = { **patch_key, "experiment_name": experiment_name, @@ -240,7 +228,6 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): "food_patch_position_y": None, "food_patch_position_z": None, } - # ---- Check if this camera is currently installed # If the same camera serial number is currently installed # check for any changes in configuration, if not, skip this @@ -255,13 +242,10 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): acquisition.ExperimentFoodPatch.Position, left=True ).fetch1() new_patch_config = {**patch_installation, **patch_position} - current_patch_config.pop("food_patch_install_time") new_patch_config.pop("food_patch_install_time") - if dict_to_uuid(current_patch_config) == dict_to_uuid(new_patch_config): continue - # ---- Remove old food patch patch_removal_list.append( { @@ -269,12 +253,10 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): "food_patch_remove_time": epoch_start, } ) - # ---- Install new food patch patch_installation_list.append(patch_installation) if "position" in patch: patch_position_list.append(patch_position) - # remove the currently installed patches that are absent in this config patch_removal_list.extend( ( @@ -284,7 +266,6 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): & experiment_key ).fetch("KEY") ) - # ---- Load weight scales ---- weight_scales = [ device for device in experiment_devices if device["Type"] == "WeightScale" @@ -301,7 +282,6 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): or weight_scale["PortName"] } weight_scale_list.append(weight_scale_key) - arena_key = (lab.Arena & acquisition.Experiment & experiment_key).fetch1("KEY") weight_scale_installation = { "experiment_name": experiment_name, @@ -312,7 +292,6 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): "weight_scale_description": weight_scale["Name"], "weight_scale_sampling_rate": float(_weight_scale_rate), } - # ---- Check if this weight scale is currently installed - if so, remove it current_weight_scale_query = ( acquisition.ExperimentWeightScale @@ -323,15 +302,12 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): if current_weight_scale_query: current_weight_scale_config = current_weight_scale_query.fetch1() new_weight_scale_config = weight_scale_installation.copy() - current_weight_scale_config.pop("weight_scale_install_time") new_weight_scale_config.pop("weight_scale_install_time") - if dict_to_uuid(current_weight_scale_config) == dict_to_uuid( new_weight_scale_config ): continue - # ---- Remove old weight scale weight_scale_removal_list.append( { @@ -339,10 +315,8 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): "weight_scale_remove_time": epoch_start, } ) - # ---- Install new weight scale weight_scale_installation_list.append(weight_scale_installation) - # remove the currently installed weight scales that are absent in this config weight_scale_removal_list.extend( ( @@ -352,19 +326,16 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): & experiment_key ).fetch("KEY") ) - # ---- insert ---- def insert(): lab.Camera.insert(camera_list, skip_duplicates=True) acquisition.ExperimentCamera.RemovalTime.insert(camera_removal_list) acquisition.ExperimentCamera.insert(camera_installation_list) acquisition.ExperimentCamera.Position.insert(camera_position_list) - lab.FoodPatch.insert(patch_list, skip_duplicates=True) acquisition.ExperimentFoodPatch.RemovalTime.insert(patch_removal_list) acquisition.ExperimentFoodPatch.insert(patch_installation_list) acquisition.ExperimentFoodPatch.Position.insert(patch_position_list) - lab.WeightScale.insert(weight_scale_list, skip_duplicates=True) acquisition.ExperimentWeightScale.RemovalTime.insert(weight_scale_removal_list) acquisition.ExperimentWeightScale.insert(weight_scale_installation_list) From a1345e2d90856c59637dc78be46423dea5c6083a Mon Sep 17 00:00:00 2001 From: JR Date: Mon, 10 Oct 2022 14:03:41 -0500 Subject: [PATCH 118/489] add subject schema in load_metadata.py --- aeon/dj_pipeline/populate/load_metadata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/populate/load_metadata.py b/aeon/dj_pipeline/populate/load_metadata.py index cd2315e6..df246dcf 100644 --- a/aeon/dj_pipeline/populate/load_metadata.py +++ b/aeon/dj_pipeline/populate/load_metadata.py @@ -5,7 +5,7 @@ import pandas as pd import yaml -from aeon.dj_pipeline import acquisition, lab +from aeon.dj_pipeline import acquisition, lab, subject from .. import dict_to_uuid From b9ddf38b7f8bf48fe89cacf1cdbae8e7daf6fb22 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 11 Oct 2022 09:55:04 -0500 Subject: [PATCH 119/489] Update create_octagon_1.py --- .../{populate => create_experiments}/create_octagon_1.py | 3 --- 1 file changed, 3 deletions(-) rename aeon/dj_pipeline/{populate => create_experiments}/create_octagon_1.py (99%) diff --git a/aeon/dj_pipeline/populate/create_octagon_1.py b/aeon/dj_pipeline/create_experiments/create_octagon_1.py similarity index 99% rename from aeon/dj_pipeline/populate/create_octagon_1.py rename to aeon/dj_pipeline/create_experiments/create_octagon_1.py index 8974f4a3..3d91fe16 100644 --- a/aeon/dj_pipeline/populate/create_octagon_1.py +++ b/aeon/dj_pipeline/create_experiments/create_octagon_1.py @@ -59,8 +59,6 @@ def create_new_experiment(): skip_duplicates=True, ) - - def main(): create_new_experiment() @@ -68,4 +66,3 @@ def main(): if __name__ == "__main__": main() - From da19d7b1de2ef9cef6e854415d24ea041df9656f Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 11 Oct 2022 15:27:20 +0000 Subject: [PATCH 120/489] fix: :pencil2: fix typo --- aeon/dj_pipeline/populate/load_metadata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/populate/load_metadata.py b/aeon/dj_pipeline/populate/load_metadata.py index df246dcf..c921c9f5 100644 --- a/aeon/dj_pipeline/populate/load_metadata.py +++ b/aeon/dj_pipeline/populate/load_metadata.py @@ -14,7 +14,7 @@ _colony_csv_path = pathlib.Path("/ceph/aeon/aeon/colony/colony.csv") -def ingest_subject(colony_csv_path: path.Pathlib = _colony_csv_path) -> None: +def ingest_subject(colony_csv_path: pathlib.Path = _colony_csv_path) -> None: """Ingest subject information from the colony.csv file""" colony_df = pd.read_csv(colony_csv_path, skiprows=[1, 2]) colony_df.rename(columns={"Id": "subject"}, inplace=True) From c9a2135765b5ea65b6a52ea6d87e5c39e7fdd607 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 11 Oct 2022 17:29:21 -0500 Subject: [PATCH 121/489] add `octagon01` experiment metadata --- aeon/dj_pipeline/acquisition.py | 2 ++ .../create_experiments/create_octagon_1.py | 2 +- aeon/dj_pipeline/device_stream.py | 16 +++++++++++----- aeon/dj_pipeline/lab.py | 3 ++- aeon/dj_pipeline/utils/load_metadata.py | 14 ++++++++++---- 5 files changed, 26 insertions(+), 11 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 8749a900..29066943 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -23,12 +23,14 @@ "exp0.1-r0": "FrameTop", "social0-r1": "FrameTop", "exp0.2-r0": "CameraTop", + "oct1.0-r0": "CameraTop", } _device_schema_mapping = { "exp0.1-r0": aeon_schema.exp01, "social0-r1": aeon_schema.exp01, "exp0.2-r0": aeon_schema.exp02, + "oct1.0-r0": aeon_schema.exp02, } diff --git a/aeon/dj_pipeline/create_experiments/create_octagon_1.py b/aeon/dj_pipeline/create_experiments/create_octagon_1.py index 3d91fe16..d1b92458 100644 --- a/aeon/dj_pipeline/create_experiments/create_octagon_1.py +++ b/aeon/dj_pipeline/create_experiments/create_octagon_1.py @@ -29,7 +29,7 @@ def create_new_experiment(): "arena_name": "octagon-1m", "lab": "SWC", "location": "464", - "experiment_type": "first-to-port", + "experiment_type": "social", }, skip_duplicates=True, ) diff --git a/aeon/dj_pipeline/device_stream.py b/aeon/dj_pipeline/device_stream.py index 864c08a6..332e99b2 100644 --- a/aeon/dj_pipeline/device_stream.py +++ b/aeon/dj_pipeline/device_stream.py @@ -210,9 +210,17 @@ class ExperimentDevice(dj.Manual): -> Device {device_type}_install_time: datetime(6) # time of the {device_type} placed and started operation at this position --- - {device_type}_description: varchar(36) + {device_type}_name: varchar(36) """ + class Attribute(dj.Part): + definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device + -> master + attribute_name : varchar(32) + --- + attribute_value='': varchar(2000) + """ + class RemovalTime(dj.Part): definition = f""" -> master @@ -282,13 +290,11 @@ def make(self, key): key, directory_type=dir_type ) - device_description = (ExperimentDevice & key).fetch1( - f"{device_type}_description" - ) + device_name = (ExperimentDevice & key).fetch1(f"{device_type}_name") stream = self._stream_reader( **{ - k: v.format(device_description) if k == "pattern" else v + k: v.format(device_name) if k == "pattern" else v for k, v in self._stream_detail["stream_reader_kwargs"].items() } ) diff --git a/aeon/dj_pipeline/lab.py b/aeon/dj_pipeline/lab.py index 2b335790..c51c5bb8 100644 --- a/aeon/dj_pipeline/lab.py +++ b/aeon/dj_pipeline/lab.py @@ -44,6 +44,7 @@ class Location(dj.Lookup): contents = [ ("SWC", "room-0", "room for experiment 0"), ("SWC", "room-1", "room for social experiment"), + ("SWC", "464", "room for social experiment using octagon arena"), ] @@ -154,7 +155,7 @@ class Arena(dj.Lookup): contents = [ ("circle-2m", "circular arena with 2-meter diameter", "circular", 2, 2, 0.2), - ("octagon", "octagon arena", "octagon", 1.8, 1.8, 0.2), + ("octagon-1m", "octagon arena with 1-m diameter", "octagon", 1, 1, 0.2), ] diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 2d851372..ff8259d0 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -1,6 +1,6 @@ import re import pathlib -from datetime import datetime +import datetime import yaml from aeon.dj_pipeline import acquisition, lab @@ -13,7 +13,7 @@ def extract_epoch_metadata(experiment_name, metadata_yml_filepath): metadata_yml_filepath = pathlib.Path(metadata_yml_filepath) - epoch_start = datetime.strptime( + epoch_start = datetime.datetime.strptime( metadata_yml_filepath.parent.name, "%Y-%m-%dT%H-%M-%S" ) @@ -42,8 +42,10 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): """ metadata_yml_filepath = pathlib.Path(metadata_yml_filepath) - file_creation_time = datetime.fromtimestamp(metadata_yml_filepath.stat().st_ctime) - epoch_start = datetime.strptime( + file_creation_time = datetime.datetime.fromtimestamp( + metadata_yml_filepath.stat().st_ctime + ) + epoch_start = datetime.datetime.strptime( metadata_yml_filepath.parent.name, "%Y-%m-%dT%H-%M-%S" ) @@ -83,6 +85,10 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): device_type = "WeightScale" elif device_name.startswith("AudioAmbient"): device_type = "AudioAmbient" + elif device_name.startswith("Wall"): + device_type = "Wall" + elif device_name.startswith("Photodiode"): + device_type = "Photodiode" else: raise ValueError(f"Unrecognized Device Type for {device_name}") From 04c615a9ce117f666d247e2adccb1482246c638e Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 12 Oct 2022 17:40:27 +0000 Subject: [PATCH 122/489] add load_metadata.ingest_subject to DataJointWorker --- aeon/dj_pipeline/populate/process.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/aeon/dj_pipeline/populate/process.py b/aeon/dj_pipeline/populate/process.py index 9435f79b..715472e6 100644 --- a/aeon/dj_pipeline/populate/process.py +++ b/aeon/dj_pipeline/populate/process.py @@ -35,14 +35,15 @@ import sys import datajoint as dj -from datajoint_utilities.dj_worker import ( +from datajoint_utilities.dj_worker import ( # noqa DataJointWorker, - WorkerLog, ErrorLog, + WorkerLog, parse_args, -) # noqa +) from aeon.dj_pipeline import acquisition, analysis, db_prefix, qc, report, tracking +from aeon.dj_pipeline.populate import load_metadata # ---- Some constants ---- @@ -60,6 +61,7 @@ sleep_duration=600, ) +high_priority(load_metadata.ingest_subject) high_priority(acquisition.Epoch.ingest_epochs, experiment_name=_current_experiment) high_priority(acquisition.Chunk.ingest_chunks, experiment_name=_current_experiment) high_priority(acquisition.ExperimentLog) From b3abc113e682dacbf23a68cc6bfc782392ab7c80 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 12 Oct 2022 20:58:34 +0000 Subject: [PATCH 123/489] fix: :bug: convert decimal to double in dj query in spcsheet.yaml --- .../dj_pipeline/webapps/sciviz/specsheet.yaml | 100 +++++++++++++++++- 1 file changed, 98 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 183aaf46..97ad67eb 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -23,10 +23,13 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_acquisition): + def dj_query(aeon_acquisition, aeon_analysis): acquisition = aeon_acquisition + visit_analysis = aeon_analysis query = acquisition.Experiment.Subject.aggr(visit_analysis.VisitEnd.join(visit_analysis.Visit, left=True), first_visit_start='MIN(visit_start)', last_visit_end='MAX(visit_end)', total_visit_count='COUNT(visit_start)', total_visit_duration='SUM(visit_duration)') + query = query.proj("first_visit_start", "last_visit_end", "total_visit_count", total_visit_duration = "CAST(total_visit_duration AS DOUBLE(10, 3))") return {'query': query, 'fetch_args': []} + VisitSummary: route: /visit_summary grids: @@ -37,7 +40,7 @@ SciViz: components: VisitSummary: route: /visit_summary_grid3_1 - link: /per_visit_report + link: / x: 0 y: 0 height: 1 @@ -50,6 +53,7 @@ SciViz: def dj_query(aeon_analysis): query = aeon_analysis.Visit.aggr(aeon_analysis.VisitSummary, ..., duration="SUM(day_duration)", total_distance_travelled="SUM(total_distance_travelled)", total_pellet_count="SUM(total_pellet_count)", total_wheel_distance_travelled="SUM(total_wheel_distance_travelled)", keep_all_rows=True) query = query.join(aeon_analysis.VisitEnd, left=True) + query = query.proj("visit_end", total_pellet_count="CAST(total_pellet_count AS DOUBLE)", duration="CAST(duration AS DOUBLE(10, 3))", total_distance_travelled="CAST(total_distance_travelled AS DOUBLE(10, 3))", total_wheel_distance_travelled="CAST(total_wheel_distance_travelled AS DOUBLE(10, 3))") return {'query': query, 'fetch_args': []} ExperimentReport: @@ -124,6 +128,97 @@ SciViz: def dj_query(aeon_report): report = aeon_report return {'query': report.SubjectWheelTravelledDistance(), 'fetch_args': ['wheel_travelled_distance_plotly']} + + PerSubjectReport: + hidden: true + route: /per_subject_report + grids: + per_subject_report: + type: fixed + route: /per_subject_report + columns: 1 + row_height: 400 + components: + comp1: + route: /per_subject_meta + x: 0 + y: 0 + height: 1 + width: 1 + type: metadata + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_acquisition): + return dict(query=aeon_acquisition.Experiment.Subject(), fetch_args=[]) + comp2: + route: /per_subject_reward_diff_plot + x: 0 + y: 1 + height: 1 + width: 1 + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_report): + report = aeon_report + return {'query': report.SubjectRewardRateDifference(), 'fetch_args': ['reward_rate_difference_plotly']} + comp3: + route: /per_subject_wheel_distance_travelled + x: 0 + y: 2 + height: 1 + width: 1 + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_report): + report = aeon_report + return {'query': report.SubjectWheelTravelledDistance(), 'fetch_args': ['wheel_travelled_distance_plotly']} + + PerVisitReport: + hidden: true + route: / + grids: + type: fixed + route: / + columns: 1 + row_height: 400 + components: + comp1: + route: /per_visit_meta + x: 0 + y: 0 + height: 1 + width: 1 + type: metadata + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_analysis): + query = aeon_analysis.VisitSummary() + return {'query': query, 'fetch_args': []} + comp2: + route: /per_visit_summary_plot + x: 0 + y: 1 + height: 1 + width: 1 + type: file:image:attach + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_report): + report = aeon_report + return {'query': report.VisitSummaryPlot(), 'fetch_args': ['summary_plot_png']} + VisitDailySummary: route: /visit_daily_summary grids: @@ -175,6 +270,7 @@ SciViz: dj_query: > def dj_query(aeon_report): return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['foraging_bouts_plotly']) + PipelineMonitor: route: /pipeline_monitor grids: From a920191fedecbc8ac8ddbd2e3bb2205ac0f29e52 Mon Sep 17 00:00:00 2001 From: JR Date: Thu, 13 Oct 2022 12:37:06 -0500 Subject: [PATCH 124/489] populate Experiment.Subject from colony.csv --- aeon/dj_pipeline/populate/load_metadata.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/populate/load_metadata.py b/aeon/dj_pipeline/populate/load_metadata.py index c921c9f5..6f448dd9 100644 --- a/aeon/dj_pipeline/populate/load_metadata.py +++ b/aeon/dj_pipeline/populate/load_metadata.py @@ -5,7 +5,7 @@ import pandas as pd import yaml -from aeon.dj_pipeline import acquisition, lab, subject +from aeon.dj_pipeline import acquisition, lab, subject, experiment from .. import dict_to_uuid @@ -22,7 +22,7 @@ def ingest_subject(colony_csv_path: pathlib.Path = _colony_csv_path) -> None: colony_df["subject_birth_date"] = "2021-01-01" colony_df["subject_description"] = "" subject.Subject.insert(colony_df, skip_duplicates=True, ignore_extra_fields=True) - subject.Subject() + experiment.Experiment.Subject.insert((subject.Subject * experiment.Experiment).proj(), skip_duplicates=True) def extract_epoch_metadata(experiment_name, metadata_yml_filepath): From fdd608a2d719d726fd3381324cafe6775ceebc2b Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 13 Oct 2022 13:22:28 -0500 Subject: [PATCH 125/489] refactor worker and process --- aeon/dj_pipeline/populate/process.py | 102 +++------------------------ aeon/dj_pipeline/populate/worker.py | 66 +++++++++++++++++ 2 files changed, 74 insertions(+), 94 deletions(-) create mode 100644 aeon/dj_pipeline/populate/worker.py diff --git a/aeon/dj_pipeline/populate/process.py b/aeon/dj_pipeline/populate/process.py index 2728e58d..eee472f2 100644 --- a/aeon/dj_pipeline/populate/process.py +++ b/aeon/dj_pipeline/populate/process.py @@ -31,103 +31,17 @@ """ -import logging import sys +from datajoint_utilities.dj_worker import parse_args + +from aeon.dj_pipeline.populate.worker import high_priority, mid_priority, logger -import datajoint as dj -from datajoint_utilities.dj_worker import ( # noqa - DataJointWorker, - ErrorLog, - WorkerLog, - parse_args, -) - -from aeon.dj_pipeline import acquisition, analysis, db_prefix, qc, report, tracking -from aeon.dj_pipeline.populate import load_metadata - -# ---- Some constants ---- - -_logger = logging.getLogger(__name__) -_current_experiment = "exp0.2-r0" -worker_schema_name = db_prefix + "workerlog" - -# ---- Define worker(s) ---- -# configure a worker to process high-priority tasks -high_priority = DataJointWorker( - "high_priority", - worker_schema_name=worker_schema_name, - db_prefix=db_prefix, - run_duration=-1, - sleep_duration=600, -) - -high_priority(load_metadata.ingest_subject) -high_priority(acquisition.Epoch.ingest_epochs, experiment_name=_current_experiment) -high_priority(acquisition.Chunk.ingest_chunks, experiment_name=_current_experiment) -high_priority(acquisition.ExperimentLog) -high_priority(acquisition.SubjectEnterExit) -high_priority(acquisition.SubjectWeight) -high_priority(acquisition.FoodPatchEvent) -high_priority(acquisition.WheelState) -high_priority(acquisition.WeightMeasurement) -high_priority(acquisition.WeightMeasurementFiltered) - -high_priority( - analysis.ingest_environment_visits, experiment_names=[_current_experiment] -) - -# configure a worker to process mid-priority tasks -mid_priority = DataJointWorker( - "mid_priority", - worker_schema_name=worker_schema_name, - db_prefix=db_prefix, - run_duration=-1, - sleep_duration=120, -) - -mid_priority(qc.CameraQC) -mid_priority(tracking.CameraTracking) -mid_priority(acquisition.FoodPatchWheel) - -mid_priority( - analysis.visit.ingest_environment_visits, experiment_names=[_current_experiment] -) -mid_priority(analysis.OverlapVisit) - -mid_priority(analysis.VisitSubjectPosition) -mid_priority(analysis.VisitTimeDistribution) -mid_priority(analysis.VisitSummary) -# report tables -mid_priority(report.delete_outdated_plot_entries) -mid_priority(report.SubjectRewardRateDifference) -mid_priority(report.SubjectWheelTravelledDistance) -mid_priority(report.ExperimentTimeDistribution) # ---- some wrappers to support execution as script or CLI configured_workers = {"high_priority": high_priority, "mid_priority": mid_priority} -def setup_logging(loglevel): - """ - Setup basic logging - - :param loglevel: minimum loglevel for emitting messages - :type loglevel: int - """ - - if loglevel is None: - loglevel = logging.getLevelName(dj.config.get("loglevel", "INFO")) - - logging.basicConfig( - level=loglevel, - stream=sys.stdout, - format="%(asctime)s %(process)d %(processName)s " - "%(levelname)s %(name)s %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - - def run(**kwargs): """ Run ingestion routine depending on the configured worker @@ -142,9 +56,9 @@ def run(**kwargs): :type loglevel: str, optional """ - setup_logging(kwargs.get("loglevel")) - _logger.debug("Starting ingestion process.") - _logger.info(f"worker_name={kwargs['worker_name']}") + logger.setLevel(kwargs.get("loglevel")) + logger.debug("Starting ingestion process.") + logger.info(f"worker_name={kwargs['worker_name']}") worker = configured_workers[kwargs["worker_name"]] if kwargs.get("duration") is not None: @@ -155,11 +69,11 @@ def run(**kwargs): try: worker.run() except Exception: - _logger.exception( + logger.exception( "action '{}' encountered an exception:".format(kwargs["worker_name"]) ) - _logger.info("Ingestion process ended.") + logger.info("Ingestion process ended.") def cli(): diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py new file mode 100644 index 00000000..0ecd0fc3 --- /dev/null +++ b/aeon/dj_pipeline/populate/worker.py @@ -0,0 +1,66 @@ +import datajoint as dj +from datajoint_utilities.dj_worker import DataJointWorker, WorkerLog, ErrorLog + +from aeon.dj_pipeline import acquisition, analysis, db_prefix, qc, report, tracking +from aeon.dj_pipeline.utils import load_metadata + + +__all__ = ["high_priority", "mid_priority", "WorkerLog", "ErrorLog", "logger"] + +# ---- Some constants ---- +logger = dj.logger +_current_experiment = "exp0.2-r0" +worker_schema_name = db_prefix + "workerlog" + + +# ---- Define worker(s) ---- +# configure a worker to process high-priority tasks +high_priority = DataJointWorker( + "high_priority", + worker_schema_name=worker_schema_name, + db_prefix=db_prefix, + run_duration=-1, + sleep_duration=600, +) + +high_priority(load_metadata.ingest_subject) +high_priority(acquisition.Epoch.ingest_epochs, experiment_name=_current_experiment) +high_priority(acquisition.Chunk.ingest_chunks, experiment_name=_current_experiment) +high_priority(acquisition.ExperimentLog) +high_priority(acquisition.SubjectEnterExit) +high_priority(acquisition.SubjectWeight) +high_priority(acquisition.FoodPatchEvent) +high_priority(acquisition.WheelState) +high_priority(acquisition.WeightMeasurement) +high_priority(acquisition.WeightMeasurementFiltered) + +high_priority( + analysis.ingest_environment_visits, experiment_names=[_current_experiment] +) + +# configure a worker to process mid-priority tasks +mid_priority = DataJointWorker( + "mid_priority", + worker_schema_name=worker_schema_name, + db_prefix=db_prefix, + run_duration=-1, + sleep_duration=120, +) + +mid_priority(qc.CameraQC) +mid_priority(tracking.CameraTracking) +mid_priority(acquisition.FoodPatchWheel) + +mid_priority( + analysis.visit.ingest_environment_visits, experiment_names=[_current_experiment] +) +mid_priority(analysis.OverlapVisit) + +mid_priority(analysis.VisitSubjectPosition) +mid_priority(analysis.VisitTimeDistribution) +mid_priority(analysis.VisitSummary) +# report tables +mid_priority(report.delete_outdated_plot_entries) +mid_priority(report.SubjectRewardRateDifference) +mid_priority(report.SubjectWheelTravelledDistance) +mid_priority(report.ExperimentTimeDistribution) From 5ae5e4c80debf4b451024b082054a5c9deff9722 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Thu, 13 Oct 2022 22:23:23 +0000 Subject: [PATCH 126/489] fix: :bug: fix a bug --- .../dj_pipeline/webapps/sciviz/specsheet.yaml | 72 +++++++++---------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 97ad67eb..4e5ae409 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -40,7 +40,7 @@ SciViz: components: VisitSummary: route: /visit_summary_grid3_1 - link: / + link: /per_visit_report x: 0 y: 0 height: 1 @@ -183,41 +183,42 @@ SciViz: PerVisitReport: hidden: true - route: / + route: /per_visit_report grids: - type: fixed - route: / - columns: 1 - row_height: 400 - components: - comp1: - route: /per_visit_meta - x: 0 - y: 0 - height: 1 - width: 1 - type: metadata - restriction: > - def restriction(**kwargs): - return dict(**kwargs) - dj_query: > - def dj_query(aeon_analysis): - query = aeon_analysis.VisitSummary() - return {'query': query, 'fetch_args': []} - comp2: - route: /per_visit_summary_plot - x: 0 - y: 1 - height: 1 - width: 1 - type: file:image:attach - restriction: > - def restriction(**kwargs): - return dict(**kwargs) - dj_query: > - def dj_query(aeon_report): - report = aeon_report - return {'query': report.VisitSummaryPlot(), 'fetch_args': ['summary_plot_png']} + per_visit_report: + type: fixed + route: /per_visit_report + columns: 1 + row_height: 400 + components: + comp1: + route: /per_visit_meta + x: 0 + y: 0 + height: 1 + width: 1 + type: metadata + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_analysis): + query = aeon_analysis.VisitSummary() + return {'query': query, 'fetch_args': []} + comp2: + route: /per_visit_summary_plot + x: 0 + y: 1 + height: 1 + width: 1 + type: file:image:attach + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_report): + report = aeon_report + return {'query': report.VisitDailySummaryPlot(), 'fetch_args': ['summary_plot_png']} VisitDailySummary: route: /visit_daily_summary @@ -270,7 +271,6 @@ SciViz: dj_query: > def dj_query(aeon_report): return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['foraging_bouts_plotly']) - PipelineMonitor: route: /pipeline_monitor grids: From 300f54661af6c271ac9c3565924595d73e23a2ad Mon Sep 17 00:00:00 2001 From: JaerongA Date: Thu, 13 Oct 2022 22:23:59 +0000 Subject: [PATCH 127/489] fix: :bug: --- aeon/dj_pipeline/populate/load_metadata.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/populate/load_metadata.py b/aeon/dj_pipeline/populate/load_metadata.py index 6f448dd9..9af59c9d 100644 --- a/aeon/dj_pipeline/populate/load_metadata.py +++ b/aeon/dj_pipeline/populate/load_metadata.py @@ -5,7 +5,7 @@ import pandas as pd import yaml -from aeon.dj_pipeline import acquisition, lab, subject, experiment +from aeon.dj_pipeline import acquisition, lab, subject from .. import dict_to_uuid @@ -22,7 +22,9 @@ def ingest_subject(colony_csv_path: pathlib.Path = _colony_csv_path) -> None: colony_df["subject_birth_date"] = "2021-01-01" colony_df["subject_description"] = "" subject.Subject.insert(colony_df, skip_duplicates=True, ignore_extra_fields=True) - experiment.Experiment.Subject.insert((subject.Subject * experiment.Experiment).proj(), skip_duplicates=True) + acquisition.Experiment.Subject.insert( + (subject.Subject * acquisition.Experiment).proj(), skip_duplicates=True + ) def extract_epoch_metadata(experiment_name, metadata_yml_filepath): From f14e741c9dcb1f2734af8b33b9a834ca88badf5d Mon Sep 17 00:00:00 2001 From: JR Date: Fri, 14 Oct 2022 10:05:47 -0500 Subject: [PATCH 128/489] modify specsheet.yaml --- aeon/dj_pipeline/webapps/sciviz/specsheet.yaml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 4e5ae409..ef07cecd 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -27,8 +27,8 @@ SciViz: acquisition = aeon_acquisition visit_analysis = aeon_analysis query = acquisition.Experiment.Subject.aggr(visit_analysis.VisitEnd.join(visit_analysis.Visit, left=True), first_visit_start='MIN(visit_start)', last_visit_end='MAX(visit_end)', total_visit_count='COUNT(visit_start)', total_visit_duration='SUM(visit_duration)') - query = query.proj("first_visit_start", "last_visit_end", "total_visit_count", total_visit_duration = "CAST(total_visit_duration AS DOUBLE(10, 3))") - return {'query': query, 'fetch_args': []} + query = query.proj("first_visit_start", "last_visit_end", "total_visit_count", total_visit_duration="CAST(total_visit_duration AS DOUBLE(10, 3))") + return {'query': query, 'fetch_args': {'order_by': 'last_visit_end DESC'}} VisitSummary: route: /visit_summary @@ -54,7 +54,7 @@ SciViz: query = aeon_analysis.Visit.aggr(aeon_analysis.VisitSummary, ..., duration="SUM(day_duration)", total_distance_travelled="SUM(total_distance_travelled)", total_pellet_count="SUM(total_pellet_count)", total_wheel_distance_travelled="SUM(total_wheel_distance_travelled)", keep_all_rows=True) query = query.join(aeon_analysis.VisitEnd, left=True) query = query.proj("visit_end", total_pellet_count="CAST(total_pellet_count AS DOUBLE)", duration="CAST(duration AS DOUBLE(10, 3))", total_distance_travelled="CAST(total_distance_travelled AS DOUBLE(10, 3))", total_wheel_distance_travelled="CAST(total_wheel_distance_travelled AS DOUBLE(10, 3))") - return {'query': query, 'fetch_args': []} + return {'query': query, 'fetch_args': {'order_by': 'visit_end DESC'}} ExperimentReport: route: /experiment_report @@ -203,7 +203,9 @@ SciViz: return dict(**kwargs) dj_query: > def dj_query(aeon_analysis): - query = aeon_analysis.VisitSummary() + query = aeon_analysis.Visit.aggr(aeon_analysis.VisitSummary, ..., duration="SUM(day_duration)", total_distance_travelled="SUM(total_distance_travelled)", total_pellet_count="SUM(total_pellet_count)", total_wheel_distance_travelled="SUM(total_wheel_distance_travelled)", keep_all_rows=True) + query = query.join(aeon_analysis.VisitEnd, left=True) + query = query.proj("visit_end", total_pellet_count="CAST(total_pellet_count AS DOUBLE)", duration="CAST(duration AS DOUBLE(10, 3))", total_distance_travelled="CAST(total_distance_travelled AS DOUBLE(10, 3))", total_wheel_distance_travelled="CAST(total_wheel_distance_travelled AS DOUBLE(10, 3))") return {'query': query, 'fetch_args': []} comp2: route: /per_visit_summary_plot From 58b41a0c6c69fb61d4326b2caf92d47bdfe9785f Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 14 Oct 2022 12:16:40 -0500 Subject: [PATCH 129/489] bugfix in loglevel --- aeon/dj_pipeline/populate/process.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/populate/process.py b/aeon/dj_pipeline/populate/process.py index eee472f2..46c9f4c8 100644 --- a/aeon/dj_pipeline/populate/process.py +++ b/aeon/dj_pipeline/populate/process.py @@ -56,7 +56,7 @@ def run(**kwargs): :type loglevel: str, optional """ - logger.setLevel(kwargs.get("loglevel")) + logger.setLevel(kwargs.get("loglevel", "INFO")) logger.debug("Starting ingestion process.") logger.info(f"worker_name={kwargs['worker_name']}") From 8c62edf39ee90474c4243fc6e71e36d18fcbcfc7 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 14 Oct 2022 15:30:28 -0500 Subject: [PATCH 130/489] bugfix missing loglevel --- aeon/dj_pipeline/populate/process.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/populate/process.py b/aeon/dj_pipeline/populate/process.py index 46c9f4c8..03ad5521 100644 --- a/aeon/dj_pipeline/populate/process.py +++ b/aeon/dj_pipeline/populate/process.py @@ -32,6 +32,7 @@ """ import sys +import datajoint as dj from datajoint_utilities.dj_worker import parse_args from aeon.dj_pipeline.populate.worker import high_priority, mid_priority, logger @@ -55,8 +56,9 @@ def run(**kwargs): :param loglevel: Set the logging output level :type loglevel: str, optional """ + loglevel = kwargs.get("loglevel") or dj.config.get("loglevel", "INFO") - logger.setLevel(kwargs.get("loglevel", "INFO")) + logger.setLevel(loglevel) logger.debug("Starting ingestion process.") logger.info(f"worker_name={kwargs['worker_name']}") From c122c6492b4b679f15068c45aa8e7747969b4459 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 24 Oct 2022 22:47:48 +0000 Subject: [PATCH 131/489] fix: :bug: fix in_nest value error in_nest now stores position timestamps instead of boolean arrays --- aeon/dj_pipeline/analysis/in_arena.py | 2 +- aeon/dj_pipeline/analysis/visit_analysis.py | 4 ++-- aeon/dj_pipeline/tracking.py | 20 +++++++++----------- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/aeon/dj_pipeline/analysis/in_arena.py b/aeon/dj_pipeline/analysis/in_arena.py index c0012dbe..3b917e9a 100644 --- a/aeon/dj_pipeline/analysis/in_arena.py +++ b/aeon/dj_pipeline/analysis/in_arena.py @@ -466,7 +466,7 @@ def make(self, key): acquisition.ExperimentFoodPatch.Position & food_patch_key ).fetch1("food_patch_position_x", "food_patch_position_y") - in_patch = tracking.is_in_patch( + in_patch = tracking.is_position_in_patch( position, patch_position, wheel_data.distance_travelled, diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index a9aaa242..2438ff4e 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -324,7 +324,7 @@ def make(self, key): **nest_key, "visit_date": visit_date.date(), "time_fraction_in_nest": in_nest.mean(), - "in_nest": in_nest, + "in_nest": in_nest.index.values[in_nest], } ) in_arena = in_arena & ~in_nest @@ -365,7 +365,7 @@ def make(self, key): patch_position = ( acquisition.ExperimentFoodPatch.Position & food_patch_key ).fetch1("food_patch_position_x", "food_patch_position_y") - in_patch = tracking.is_in_patch( + in_patch = tracking.is_position_in_patch( position, patch_position, wheel_data.distance_travelled, diff --git a/aeon/dj_pipeline/tracking.py b/aeon/dj_pipeline/tracking.py index 78e44187..21d3be9a 100644 --- a/aeon/dj_pipeline/tracking.py +++ b/aeon/dj_pipeline/tracking.py @@ -1,13 +1,11 @@ import datajoint as dj -import pandas as pd +import matplotlib.path import numpy as np -from matplotlib import path +import pandas as pd from aeon.io import api as io_api -from . import lab, acquisition, qc -from . import get_schema_name, dict_to_uuid - +from . import acquisition, dict_to_uuid, get_schema_name, lab, qc schema = dj.schema(get_schema_name("tracking")) @@ -231,9 +229,9 @@ def compute_distance(position_df, target, xcol="x", ycol="y"): return np.sqrt(np.square(position_df[[xcol, ycol]] - target).sum(axis=1)) -def is_in_patch( +def is_position_in_patch( position_df, patch_position, wheel_distance_travelled, patch_radius=0.2 -): +) -> pd.Series: distance_from_patch = compute_distance(position_df, patch_position) in_patch = distance_from_patch < patch_radius exit_patch = in_patch.astype(np.int8).diff() < 0 @@ -244,7 +242,7 @@ def is_in_patch( return in_wheel.groupby(time_slice).apply(lambda x: x.cumsum()) > 0 -def is_position_in_nest(position_df, nest_key, xcol="x", ycol="y"): +def is_position_in_nest(position_df, nest_key, xcol="x", ycol="y") -> pd.Series: """ Given the session key and the position data - arrays of x and y return an array of boolean indicating whether or not a position is inside the nest @@ -252,9 +250,9 @@ def is_position_in_nest(position_df, nest_key, xcol="x", ycol="y"): nest_vertices = list( zip(*(lab.ArenaNest.Vertex & nest_key).fetch("vertex_x", "vertex_y")) ) - nest_path = path.Path(nest_vertices) - - return nest_path.contains_points(position_df[[xcol, ycol]]) + nest_path = matplotlib.path.Path(nest_vertices) + position["in_nest"] = nest_path.contains_points(position_df[[xcol, ycol]]) + return position["in_nest"] def _get_position( From ab5c0a5f263b616e5e11ac1b513773066abba787 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 26 Oct 2022 15:14:49 +0000 Subject: [PATCH 132/489] fix typo --- aeon/dj_pipeline/tracking.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/tracking.py b/aeon/dj_pipeline/tracking.py index 21d3be9a..58b09ae7 100644 --- a/aeon/dj_pipeline/tracking.py +++ b/aeon/dj_pipeline/tracking.py @@ -251,8 +251,8 @@ def is_position_in_nest(position_df, nest_key, xcol="x", ycol="y") -> pd.Series: zip(*(lab.ArenaNest.Vertex & nest_key).fetch("vertex_x", "vertex_y")) ) nest_path = matplotlib.path.Path(nest_vertices) - position["in_nest"] = nest_path.contains_points(position_df[[xcol, ycol]]) - return position["in_nest"] + position_df["in_nest"] = nest_path.contains_points(position_df[[xcol, ycol]]) + return position_df["in_nest"] def _get_position( From 1d47d331cd5a6cc44d858ecc4337b9861d5dbd3e Mon Sep 17 00:00:00 2001 From: JaerongA Date: Thu, 27 Oct 2022 17:43:25 +0000 Subject: [PATCH 133/489] docs: :memo: update docs for sciviz deploymoent --- aeon/dj_pipeline/webapps/sciviz/README.md | 39 +++++++++++++---------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/README.md b/aeon/dj_pipeline/webapps/sciviz/README.md index 382b7628..52e24d95 100644 --- a/aeon/dj_pipeline/webapps/sciviz/README.md +++ b/aeon/dj_pipeline/webapps/sciviz/README.md @@ -10,9 +10,10 @@ If you have not done so already, please install the following dependencies: #### Production deployment -To run the application in Production mode, use the command: +To run the application in production mode, use the command at the root: ```bash -SUBDOMAINS=testdev URL=datajoint.io STAGE_CERT=TRUE EMAIL=service-health@datajoint.com HOST_UID=$(id -u) docker-compose -f ./SciViz/docker-compose-remote.yaml up -d +cd aeon/dj_pipeline/webapps/sciviz +SUBDOMAINS=testdev URL=datajoint.io STAGE_CERT=TRUE EMAIL=service-health@datajoint.com HOST_UID=$(id -u) docker-compose -f docker-compose-remote.yaml up -d ``` Please modify `SUBDOMAINS`, `URL`, and `STAGE_CERT` according to your own deployment configuration. @@ -20,14 +21,24 @@ On the example above, the first two arguments are about what site you are going The next two arguments are for web certifications. Set `STAGE_CERT=TRUE` for testing certs and set it to `FALSE` once you are confident in your deployment for production. `EMAIL` is for where notifications related to certs are going to be sent. -To stop the application, use the same command as before but with `down` in place of `up -d` - #### Local dev deployment -To start the application in local mode, use the command: +For local deployment, you need to ensure the connection to the `aeon-db2` server is established. This can be done by establishing the port forwarding as follows in a terminal: + ```bash -HOST_UID=$(id -u) docker-compose -f ./SciViz/docker-compose-local.yaml up +ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no username@ssh.swc.ucl.ac.uk -L 3306:aeon-db2:3306 -N ``` +(replace `username` with your own username) + +Leave this terminal open and open up a new terminal to navigate to the `sciviz` folder +```bash +cd aeon/dj_pipeline/webapps/sciviz +``` +Docker compose up with the following command: +``` +HOST_UID=$(id -u) docker-compose -f docker-compose-local.yaml up +``` +To stop the application, use the same command as before but with `down` in place of `up -d` ## Verify the deployment @@ -38,23 +49,16 @@ HOST_UID=$(id -u) docker-compose -f ./SciViz/docker-compose-local.yaml up - Set `Username` and `Password` to your own database user account (if you need one, please contact Project Aeon admin team). - Click `Connect`. -#### Local dev deployment - -For local deployment, you need to ensure the connection to the `aeon-db2` server is established. This can be done by establishing the port forwarding as follows -``` -ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no username@ssh.swc.ucl.ac.uk -L 3306:aeon-db2:3306 -N -``` - -(replace `username` with your own username) +#### Local dev deployment -- In a Google Chrome browser window, navigate to: [https://fakeservices.datajoint.io](https://fakeservices.datajoint.io) +- In a Google Chrome browser window, navigate to: [https://localhost/login](https://localhost/login) - Set `Host/Database Address` to `host.docker.internal`. - Set `Username` and `Password` to your own database user account (if you need one, please contact Project Aeon admin team). - Click `Connect`. ## Dynamic spec sheet -Sci-Vis is used to build visualization dashboards, this is done through a single spec sheet. The one for this deployment is called `specsheet.yaml` +Sci-Viz is used to build visualization dashboards, this is done through a single spec sheet. The one for this deployment is called `specsheet.yaml` Some notes about the spec sheet if you plan to tweak the website yourself: - Page names under pages must have a unique name without spaces @@ -68,4 +72,5 @@ Some notes about the spec sheet if you plan to tweak the website yourself: def restriction(**kwargs): return dict(**kwargs) ``` -- Overlapping components at the same (x, y) does not work, the grid system will not allow overlapping components it will wrap them horizontally if there is enough space or bump them down to the next row. \ No newline at end of file +- Overlapping components at the same (x, y) does not work, the grid system will not allow overlapping components it will wrap them horizontally if there is enough space or bump them down to the next row. +- Visit this [repo](https://github.com/datajoint/sci-viz) to learn more about Sci-Viz. \ No newline at end of file From 01dbe554ec97d45fba51aa767cf8872d35b8b6d6 Mon Sep 17 00:00:00 2001 From: lochhh Date: Tue, 8 Nov 2022 12:18:17 +0000 Subject: [PATCH 134/489] Include only in-patch positions --- aeon/dj_pipeline/tracking.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/tracking.py b/aeon/dj_pipeline/tracking.py index 58b09ae7..7aee784e 100644 --- a/aeon/dj_pipeline/tracking.py +++ b/aeon/dj_pipeline/tracking.py @@ -239,7 +239,7 @@ def is_position_in_patch( position_df.index, method="pad" ) time_slice = exit_patch.cumsum() - return in_wheel.groupby(time_slice).apply(lambda x: x.cumsum()) > 0 + return in_patch & (in_wheel.groupby(time_slice).apply(lambda x: x.cumsum()) > 0) def is_position_in_nest(position_df, nest_key, xcol="x", ycol="y") -> pd.Series: From c109359c893a5d273c0a4af4a3d23c6f8e8cde37 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Thu, 10 Nov 2022 23:45:45 +0000 Subject: [PATCH 135/489] fix: :bug: fix message log ingestion error --- aeon/dj_pipeline/acquisition.py | 88 +++++++++++---------- aeon/dj_pipeline/analysis/visit_analysis.py | 9 ++- 2 files changed, 55 insertions(+), 42 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 29066943..8815939e 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -11,8 +11,8 @@ from aeon.schema import dataset as aeon_schema from . import get_schema_name -from .utils.load_metadata import extract_epoch_metadata, ingest_epoch_metadata from .utils import paths +from .utils.load_metadata import extract_epoch_metadata, ingest_epoch_metadata schema = dj.schema(get_schema_name("acquisition")) @@ -612,54 +612,62 @@ class Message(dj.Part): def make(self, key): chunk_start, chunk_end = (Chunk & key).fetch1("chunk_start", "chunk_end") - raw_data_dir = Experiment.get_data_directory(key) + self.insert1(key) + # Populate the part table + raw_data_dir = Experiment.get_data_directory(key) device = getattr( _device_schema_mapping[key["experiment_name"]], "ExperimentalMetadata" ) - log_messages = io_api.load( - root=raw_data_dir.as_posix(), - reader=device.MessageLog, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - state_messages = io_api.load( - root=raw_data_dir.as_posix(), - reader=device.EnvironmentState, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - self.insert1(key) - self.Message.insert( - ( - { - **key, - "message_time": r.name, - "message": r.message, - "message_type": r.type, - } - for _, r in log_messages.iterrows() - ), - skip_duplicates=True, - ) - self.Message.insert( - ( - { - **key, - "message_time": r.name, - "message": r.state, - "message_type": "EnvironmentState", - } - for _, r in state_messages.iterrows() - ), - skip_duplicates=True, - ) + try: + log_messages: pd.DataFrame = io_api.load( + root=raw_data_dir.as_posix(), + reader=device.MessageLog, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.Message.insert( + ( + { + **key, + "message_time": r.name, + "message": r.message, + "message_type": r.type, + } + for _, r in log_messages.iterrows() + ), + skip_duplicates=True, + ) + except: + print("Can't read from device.MessageLog") + try: + state_messages: pd.DataFrame = io_api.load( + root=raw_data_dir.as_posix(), + reader=device.EnvironmentState, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) -# ------------------- EVENTS -------------------- + self.Message.insert( + ( + { + **key, + "message_time": r.name, + "message": r.state, + "message_type": "EnvironmentState", + } + for _, r in state_messages.iterrows() + ), + skip_duplicates=True, + ) + except: + print("Can't read from device.EnvironmentState") +# ------------------- EVENTS -------------------- @schema class FoodPatchEvent(dj.Imported): definition = """ # events associated with a given ExperimentFoodPatch diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 2438ff4e..a26a1b33 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -428,6 +428,7 @@ def make(self, key): visit_dates = pd.date_range( start=pd.Timestamp(visit_start.date()), end=pd.Timestamp(visit_end.date()) ) + maintenance_period = _get_maintenance_periods( key["experiment_name"], visit_start, visit_end ) @@ -548,13 +549,17 @@ def make(self, key): def _get_maintenance_periods(experiment_name, start, end): # get logs from ExperimentLog - log_df = ( + query = ( ExperimentLog.Message.proj("message") & {"experiment_name": experiment_name} & 'message IN ("Maintenance", "Experiment")' & f'message_time BETWEEN "{start}" AND "{end}"' ) - log_df = log_df.fetch(format="frame", order_by="message_time").reset_index() + + if len(query) == 0: + return None + + log_df = query.fetch(format="frame", order_by="message_time").reset_index() log_df = log_df[log_df["message"].shift() != log_df["message"]].reset_index( drop=True ) # remove duplicates and keep the first one From 175f8d890ca58d05ef84c8811b71980dc12a264e Mon Sep 17 00:00:00 2001 From: JaerongA Date: Thu, 17 Nov 2022 20:43:16 +0000 Subject: [PATCH 136/489] add dj.logger --- aeon/dj_pipeline/acquisition.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 8815939e..600e21b6 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -14,6 +14,7 @@ from .utils import paths from .utils.load_metadata import extract_epoch_metadata, ingest_epoch_metadata +logger = dj.logger schema = dj.schema(get_schema_name("acquisition")) @@ -641,8 +642,7 @@ def make(self, key): skip_duplicates=True, ) except: - print("Can't read from device.MessageLog") - + logger.warning("Can't read from device.MessageLog") try: state_messages: pd.DataFrame = io_api.load( root=raw_data_dir.as_posix(), @@ -664,7 +664,7 @@ def make(self, key): skip_duplicates=True, ) except: - print("Can't read from device.EnvironmentState") + logger.warning("Can't read from device.EnvironmentState") # ------------------- EVENTS -------------------- From aba5ba2c1dd855eb091f8db005c3757263d502bb Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 17 Nov 2022 17:59:04 -0600 Subject: [PATCH 137/489] auto ingest device types and stream types --- aeon/dj_pipeline/device_stream.py | 245 +++++++++++++++--------------- 1 file changed, 123 insertions(+), 122 deletions(-) diff --git a/aeon/dj_pipeline/device_stream.py b/aeon/dj_pipeline/device_stream.py index 332e99b2..43755ab9 100644 --- a/aeon/dj_pipeline/device_stream.py +++ b/aeon/dj_pipeline/device_stream.py @@ -6,9 +6,11 @@ import aeon from aeon.io import api as io_api +import aeon.schema.core as stream +import aeon.schema.foraging as foraging +import aeon.schema.octagon as octagon -from . import acquisition -from . import get_schema_name +from . import acquisition, dict_to_uuid, get_schema_name logger = dj.logger @@ -29,100 +31,61 @@ class StreamType(dj.Lookup): """ definition = """ # Catalog of all stream types used across Project Aeon - stream_type: varchar(16) + stream_type: varchar(20) --- stream_reader: varchar(256) # name of the reader class found in `aeon_mecha` package (e.g. aeon.io.reader.Video) stream_reader_kwargs: longblob # keyword arguments to instantiate the reader class stream_description='': varchar(256) + stream_hash: uuid # hash of dict(stream_reader_kwargs, stream_reader=stream_reader) + unique index (stream_hash) """ - contents = [ - ("Video", "aeon.io.reader.Video", {"pattern": "{}"}, "Video frame metadata"), - ( - "Position", - "aeon.io.reader.Position", - {"pattern": "{}_200"}, - "Position tracking data for the specified camera.", - ), - ( - "Encoder", - "aeon.io.reader.Encoder", - {"pattern": "{}_90"}, - "Wheel magnetic encoder data", - ), - ( - "EnvironmentState", - "aeon.io.reader.Csv", - {"pattern": "{}_EnvironmentState", "columns": ["state"]}, - "Environment state log", - ), - ( - "SubjectState", - "aeon.io.reader.Subject", - {"pattern": "{}_SubjectState"}, - "Subject state log", - ), - ( - "MessageLog", - "aeon.io.reader.Log", - {"pattern": "{}_MessageLog"}, - "Message log data", - ), - ( - "Metadata", - "aeon.io.reader.Metadata", - {"pattern": "{}"}, - "Metadata for acquisition epochs", - ), - ( - "MetadataExp01", - "aeon.io.reader.Metadata", - {"pattern": "{}_2", "columns": ["id", "weight", "event"]}, - "Session metadata for Experiment 0.1", - ), - ( - "Region", - "aeon.schema.foraging._RegionReader", - {"pattern": "{}_201"}, - "Region tracking data for the specified camera", - ), - ( - "DepletionState", - "aeon.schema.foraging._PatchState", - {"pattern": "{}_State"}, - "State of the linear depletion function for foraging patches", - ), - ( - "BeamBreak", - "aeon.io.reader.BitmaskEvent", - {"pattern": "{}_32", "value": 0x22, "tag": "PelletDetected"}, - "Beam break events for pellet detection", - ), - ( - "DeliverPellet", - "aeon.io.reader.BitmaskEvent", - {"pattern": "{}_35", "value": 0x80, "tag": "TriggerPellet"}, - "Pellet delivery commands", - ), - ( - "WeightRaw", - "aeon.schema.foraging._Weight", - {"pattern": "{}_200"}, - "Raw weight measurement for a specific nest", - ), - ( - "WeightFiltered", - "aeon.schema.foraging._Weight", - {"pattern": "{}_202"}, - "Filtered weight measurement for a specific nest", - ), - ( - "WeightSubject", - "aeon.schema.foraging._Weight", - {"pattern": "{}_204"}, - "Subject weight measurement for a specific nest", - ), - ] + @classmethod + def insert_streams(cls, *streams): + composite = {} + pattern = "{pattern}" + for stream_obj in streams: + if inspect.isclass(stream_obj): + for method in vars(stream_obj).values(): + if isinstance(method, staticmethod): + composite.update(method.__func__(pattern)) + else: + composite.update(stream_obj(pattern)) + + stream_entries = [] + for stream_name, stream_reader in composite.items(): + if stream_name == pattern: + stream_name = stream_reader.__class__.__name__ + entry = { + "stream_type": stream_name, + "stream_reader": f"{stream_reader.__module__}.{stream_reader.__class__.__name__}", + "stream_reader_kwargs": { + k: v + for k, v in vars(stream_reader).items() + if k + in inspect.signature(stream_reader.__class__.__init__).parameters + }, + } + entry["stream_hash"] = dict_to_uuid( + { + **entry["stream_reader_kwargs"], + "stream_reader": entry["stream_reader"], + } + ) + q_param = cls & {"stream_hash": entry["stream_hash"]} + if q_param: # If the specified stream type already exists + pname = q_param.fetch1("stream_type") + if pname != stream_name: + # If the existed stream type does not have the same name: + # human error, trying to add the same content with different name + raise dj.DataJointError( + f"The specified stream type already exists - name: {pname}" + ) + + stream_entries.append(entry) + + cls.insert(stream_entries, skip_duplicates=True) + return stream_entries @schema @@ -143,37 +106,70 @@ class Stream(dj.Part): -> StreamType """ + _devices_config = [ + ( + "Camera", + "Camera device", + (stream.video, stream.position, foraging.region), + ), + ("Metadata", "Metadata", (stream.metadata,)), + ( + "ExperimentalMetadata", + "ExperimentalMetadata", + (stream.environment, stream.messageLog), + ), + ( + "Nest Scale", + "Weight scale at nest", + (foraging.weight,), + ), + ( + "Food Patch", + "Food patch", + (foraging.patch,), + ), + ( + "Food Patch", + "Food patch", + (foraging.patch,), + ), + ( + "Photodiode", + "Photodiode", + (octagon.photodiode,), + ), + ( + "OSC", + "OSC", + (octagon.OSC,), + ), + ( + "TaskLogic", + "TaskLogic", + (octagon.TaskLogic,), + ), + ( + "Wall", + "Wall", + (octagon.Wall,), + ), + ] + @classmethod - def _insert_contents(cls): - devices_config = [ - ("Camera", "Camera device", ("Video", "Position", "Region")), - ("Metadata", "Metadata", ("Metadata",)), - ( - "ExperimentalMetadata", - "ExperimentalMetadata", - ("EnvironmentState", "SubjectState", "MessageLog"), - ), - ( - "Nest Scale", - "Weight scale at nest", - ("WeightRaw", "WeightFiltered", "WeightSubject"), - ), - ( - "Food Patch", - "Food patch", - ("DepletionState", "Encoder", "BeamBreak", "DeliverPellet"), - ), - ] - for device_type, device_desc, device_streams in devices_config: - if cls & {"device_type": device_type}: - continue + def insert_devices(cls): + for device_type, device_desc, device_streams in cls._devices_config: + stream_entries = StreamType.insert_streams(*device_streams) with cls.connection.transaction: - cls.insert1((device_type, device_desc)) + cls.insert1((device_type, device_desc), skip_duplicates=True) cls.Stream.insert( [ - {"device_type": device_type, "stream_type": stream_type} - for stream_type in device_streams - ] + { + "device_type": device_type, + "stream_type": e["stream_type"], + } + for e in stream_entries + ], + skip_duplicates=True, ) @@ -186,9 +182,6 @@ class Device(dj.Lookup): """ -DeviceType._insert_contents() - - # ---- HELPER ---- @@ -253,7 +246,7 @@ class RemovalTime(dj.Part): -> Experiment{device_title} -> acquisition.Chunk --- - size: int # number of data points acquired from this stream for a given chunk + sample_count: int # number of data points acquired from this stream for a given chunk timestamps: longblob # (datetime) timestamps of {stream_type} data """ @@ -294,7 +287,7 @@ def make(self, key): stream = self._stream_reader( **{ - k: v.format(device_name) if k == "pattern" else v + k: v.format(**{k: device_name}) if k == "pattern" else v for k, v in self._stream_detail["stream_reader_kwargs"].items() } ) @@ -331,8 +324,16 @@ def _prettify(s): return s.replace("_", " ").title().replace(" ", "") +# ---- MAIN BLOCK ---- + + def main(): + DeviceType.insert_devices() + context = inspect.currentframe().f_back.f_locals for device_type in DeviceType.fetch("device_type"): logger.info(f"Generating stream table(s) for: {device_type}") generate_device_table(device_type, context=context) + + +main() From 62e7cb35facabc14ce774de89d04a3e345b1f698 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 17 Nov 2022 17:59:28 -0600 Subject: [PATCH 138/489] stopgap function to ingest metadata for octagon experiments --- aeon/dj_pipeline/utils/load_metadata.py | 55 ++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 16d307cf..d0f6b916 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -53,10 +53,11 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): + camera/patch location + patch, weightscale serial number """ + if experiment_name.startswith("oct"): + ingest_epoch_metadata_octagon(experiment_name, metadata_yml_filepath) + return + metadata_yml_filepath = pathlib.Path(metadata_yml_filepath) - file_creation_time = datetime.datetime.fromtimestamp( - metadata_yml_filepath.stat().st_ctime - ) epoch_start = datetime.datetime.strptime( metadata_yml_filepath.parent.name, "%Y-%m-%dT%H-%M-%S" ) @@ -144,7 +145,7 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): camera_position = { **camera_key, "experiment_name": experiment_name, - "camera_install_time": file_creation_time, + "camera_install_time": epoch_start, "camera_position_x": camera["position"]["x"], "camera_position_y": camera["position"]["y"], "camera_position_z": camera["position"]["z"], @@ -225,7 +226,7 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): patch_position = { **patch_key, "experiment_name": experiment_name, - "food_patch_install_time": file_creation_time, + "food_patch_install_time": epoch_start, "food_patch_position_x": patch["position"]["x"], "food_patch_position_y": patch["position"]["y"], "food_patch_position_z": patch["position"]["z"], @@ -353,3 +354,47 @@ def insert(): else: with acquisition.Experiment.connection.transaction: insert() + + +def ingest_epoch_metadata_octagon(experiment_name, metadata_yml_filepath): + """ + Temporary ingestion routine to load devices' meta information for Octagon arena experiments + """ + from aeon.dj_pipeline import device_stream + + oct01_devices = [ + ("Metadata", "Metadata"), + ("CameraTop", "Camera"), + ("CameraColorTop", "Camera"), + ("ExperimentalMetadata", "ExperimentalMetadata"), + ("Photodiode", "Photodiode"), + ("OSC", "OSC"), + ("TaskLogic", "TaskLogic"), + ("Wall1", "Wall"), + ("Wall2", "Wall"), + ("Wall3", "Wall"), + ("Wall4", "Wall"), + ("Wall5", "Wall"), + ("Wall6", "Wall"), + ("Wall7", "Wall"), + ("Wall8", "Wall"), + ] + + epoch_start = datetime.datetime.strptime( + metadata_yml_filepath.parent.name, "%Y-%m-%dT%H-%M-%S" + ) + + for device_idx, (device_name, device_type) in enumerate(oct01_devices): + device_sn = f"oct01_{device_idx}" + device_stream.Device.insert1( + {"device_serial_number": device_sn, "device_type": device_type}, + skip_duplicates=True, + ) + experiment_table = getattr(device_stream, f"Experiment{device_type}") + if not ( + experiment_table + & {"experiment_name": experiment_name, "device_serial_number": device_sn} + ): + experiment_table.insert1( + (experiment_name, device_sn, epoch_start, device_name) + ) From 7a4c2fe660d806c2fde8a6a11074dfe9b705333a Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 18 Nov 2022 18:19:27 +0000 Subject: [PATCH 139/489] update error handling --- aeon/dj_pipeline/acquisition.py | 72 ++++++++++++++++----------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 600e21b6..b5d22c10 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -622,49 +622,49 @@ def make(self, key): ) try: - log_messages: pd.DataFrame = io_api.load( + # handles corrupted files - issue: https://github.com/SainsburyWellcomeCentre/aeon_mecha/issues/153 + log_messages = io_api.load( root=raw_data_dir.as_posix(), reader=device.MessageLog, start=pd.Timestamp(chunk_start), end=pd.Timestamp(chunk_end), ) - - self.Message.insert( - ( - { - **key, - "message_time": r.name, - "message": r.message, - "message_type": r.type, - } - for _, r in log_messages.iterrows() - ), - skip_duplicates=True, - ) - except: + except IndexError: logger.warning("Can't read from device.MessageLog") - try: - state_messages: pd.DataFrame = io_api.load( - root=raw_data_dir.as_posix(), - reader=device.EnvironmentState, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) + log_messages = pd.DataFrame() - self.Message.insert( - ( - { - **key, - "message_time": r.name, - "message": r.state, - "message_type": "EnvironmentState", - } - for _, r in state_messages.iterrows() - ), - skip_duplicates=True, - ) - except: - logger.warning("Can't read from device.EnvironmentState") + state_messages = io_api.load( + root=raw_data_dir.as_posix(), + reader=device.EnvironmentState, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1(key) + self.Message.insert( + ( + { + **key, + "message_time": r.name, + "message": r.message, + "message_type": r.type, + } + for _, r in log_messages.iterrows() + ), + skip_duplicates=True, + ) + self.Message.insert( + ( + { + **key, + "message_time": r.name, + "message": r.state, + "message_type": "EnvironmentState", + } + for _, r in state_messages.iterrows() + ), + skip_duplicates=True, + ) # ------------------- EVENTS -------------------- From 9a217d80b3ff2c7e31ea35c721cce5232424fe77 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 18 Nov 2022 13:15:03 -0600 Subject: [PATCH 140/489] typo fix --- aeon/dj_pipeline/device_stream.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/device_stream.py b/aeon/dj_pipeline/device_stream.py index 43755ab9..bec331ed 100644 --- a/aeon/dj_pipeline/device_stream.py +++ b/aeon/dj_pipeline/device_stream.py @@ -302,7 +302,7 @@ def make(self, key): self.insert1( { **key, - "size": len(stream_data), + "sample_count": len(stream_data), "timestamps": stream_data.index.values, **{ c: stream_data[c].values From 944c5cbdf183cd7a4fdf8b931a6fe88be3173f26 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 22 Nov 2022 11:21:56 -0600 Subject: [PATCH 141/489] rename `device_stream` -> `streams` --- aeon/dj_pipeline/{device_stream.py => streams.py} | 6 ++---- aeon/dj_pipeline/utils/load_metadata.py | 6 +++--- 2 files changed, 5 insertions(+), 7 deletions(-) rename aeon/dj_pipeline/{device_stream.py => streams.py} (98%) diff --git a/aeon/dj_pipeline/device_stream.py b/aeon/dj_pipeline/streams.py similarity index 98% rename from aeon/dj_pipeline/device_stream.py rename to aeon/dj_pipeline/streams.py index bec331ed..2a7f3cd6 100644 --- a/aeon/dj_pipeline/device_stream.py +++ b/aeon/dj_pipeline/streams.py @@ -14,10 +14,8 @@ logger = dj.logger -# schema_name = get_schema_name("device_stream") -schema_name = ( - f'u_{dj.config["database.user"]}_device_stream' # still experimental feature -) +schema_name = get_schema_name("streams") +# schema_name = f'u_{dj.config["database.user"]}_streams' # still experimental feature schema = dj.schema(schema_name) diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index d0f6b916..9b7e701f 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -360,7 +360,7 @@ def ingest_epoch_metadata_octagon(experiment_name, metadata_yml_filepath): """ Temporary ingestion routine to load devices' meta information for Octagon arena experiments """ - from aeon.dj_pipeline import device_stream + from aeon.dj_pipeline import streams oct01_devices = [ ("Metadata", "Metadata"), @@ -386,11 +386,11 @@ def ingest_epoch_metadata_octagon(experiment_name, metadata_yml_filepath): for device_idx, (device_name, device_type) in enumerate(oct01_devices): device_sn = f"oct01_{device_idx}" - device_stream.Device.insert1( + streams.Device.insert1( {"device_serial_number": device_sn, "device_type": device_type}, skip_duplicates=True, ) - experiment_table = getattr(device_stream, f"Experiment{device_type}") + experiment_table = getattr(streams, f"Experiment{device_type}") if not ( experiment_table & {"experiment_name": experiment_name, "device_serial_number": device_sn} From 0c375878bd1a45c51c6979eecca3fec0af452aff Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 22 Nov 2022 11:22:12 -0600 Subject: [PATCH 142/489] add "stream_workers" --- aeon/dj_pipeline/acquisition.py | 2 +- aeon/dj_pipeline/populate/process.py | 13 +++++++-- aeon/dj_pipeline/populate/worker.py | 41 ++++++++++++++++++++++++++-- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 29066943..2b2bd3fb 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -10,7 +10,7 @@ from aeon.io import reader as io_reader from aeon.schema import dataset as aeon_schema -from . import get_schema_name +from . import get_schema_name, lab, subject from .utils.load_metadata import extract_epoch_metadata, ingest_epoch_metadata from .utils import paths diff --git a/aeon/dj_pipeline/populate/process.py b/aeon/dj_pipeline/populate/process.py index 03ad5521..c83347cb 100644 --- a/aeon/dj_pipeline/populate/process.py +++ b/aeon/dj_pipeline/populate/process.py @@ -35,12 +35,21 @@ import datajoint as dj from datajoint_utilities.dj_worker import parse_args -from aeon.dj_pipeline.populate.worker import high_priority, mid_priority, logger +from aeon.dj_pipeline.populate.worker import ( + high_priority, + mid_priority, + streams_worker, + logger, +) # ---- some wrappers to support execution as script or CLI -configured_workers = {"high_priority": high_priority, "mid_priority": mid_priority} +configured_workers = { + "high_priority": high_priority, + "mid_priority": mid_priority, + "streams_worker": streams_worker, +} def run(**kwargs): diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py index 0ecd0fc3..3259292a 100644 --- a/aeon/dj_pipeline/populate/worker.py +++ b/aeon/dj_pipeline/populate/worker.py @@ -1,11 +1,31 @@ import datajoint as dj -from datajoint_utilities.dj_worker import DataJointWorker, WorkerLog, ErrorLog +from datajoint_utilities.dj_worker import ( + DataJointWorker, + WorkerLog, + ErrorLog, + is_djtable, +) -from aeon.dj_pipeline import acquisition, analysis, db_prefix, qc, report, tracking +from aeon.dj_pipeline import ( + acquisition, + analysis, + db_prefix, + qc, + report, + tracking, + streams, +) from aeon.dj_pipeline.utils import load_metadata -__all__ = ["high_priority", "mid_priority", "WorkerLog", "ErrorLog", "logger"] +__all__ = [ + "high_priority", + "mid_priority", + "streams_worker", + "WorkerLog", + "ErrorLog", + "logger", +] # ---- Some constants ---- logger = dj.logger @@ -64,3 +84,18 @@ mid_priority(report.SubjectRewardRateDifference) mid_priority(report.SubjectWheelTravelledDistance) mid_priority(report.ExperimentTimeDistribution) + + +# ---- Define worker(s) ---- +# configure a worker to ingest all data streams +streams_worker = DataJointWorker( + "streams_worker", + worker_schema_name=worker_schema_name, + db_prefix=db_prefix, + run_duration=1, + sleep_duration=600, +) + +for attr in vars(streams).values(): + if is_djtable(attr) and hasattr(attr, "populate"): + streams_worker(attr) From 0c926aa3dca7ed568b0743ae008ad8607a3bb6f1 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 22 Nov 2022 16:36:00 -0600 Subject: [PATCH 143/489] Create dj_example_octagon1_experiment.ipynb --- .../dj_example_octagon1_experiment.ipynb | 5435 +++++++++++++++++ 1 file changed, 5435 insertions(+) create mode 100644 docs/examples/dj_example_octagon1_experiment.ipynb diff --git a/docs/examples/dj_example_octagon1_experiment.ipynb b/docs/examples/dj_example_octagon1_experiment.ipynb new file mode 100644 index 00000000..ae8e72f0 --- /dev/null +++ b/docs/examples/dj_example_octagon1_experiment.ipynb @@ -0,0 +1,5435 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f3c934a4", + "metadata": {}, + "source": [ + "Change directory back to the root dir of this project (where the \"dj_local_conf.json\" file is located)" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "d6fbe7a4", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "/nfs/nhome/live/thinh/code/ProjectAeon/aeon\n" + ] + } + ], + "source": [ + "cd ../.. " + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "f13fd863", + "metadata": {}, + "outputs": [], + "source": [ + "import warnings\n", + "\n", + "warnings.simplefilter(\"ignore\", category=DeprecationWarning)\n", + "warnings.simplefilter(\"ignore\", category=ResourceWarning)\n", + "warnings.simplefilter(\"ignore\", category=FutureWarning)" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "ea5731f0", + "metadata": {}, + "outputs": [], + "source": [ + "import datajoint as dj\n", + "dj.logger.setLevel('ERROR')\n", + "\n", + "dj.config['custom']['database.prefix'] = 'aeon_test_' # data are ingested into schemas prefixed with \"aeon_test_\" for testing" + ] + }, + { + "cell_type": "markdown", + "id": "dda85d72", + "metadata": {}, + "source": [ + "# Pipeline architecture" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "0c9381ac", + "metadata": {}, + "outputs": [], + "source": [ + "# If you have the `datajoint_pipeline` branch of the `aeon_mecha` pip installed\n", + "# you can import the modules directly - if not, comment out this cell and use the cell below\n", + "\n", + "from aeon.dj_pipeline import acquisition, streams" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "18e5852b", + "metadata": {}, + "outputs": [], + "source": [ + "# If you don't have the `datajoint_pipeline` branch of the `aeon_mecha` pip installed\n", + "# then instead of importing the modules, you can use DataJoint's VirtualModule to access the pipeline\n", + "# uncomment and run the codeblock below\n", + "\n", + "#acquisition = dj.VirtualModule('acquisition', 'aeon_test_acquisition')\n", + "#streams = dj.VirtualModule('streams', 'aeon_test_streams')" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "badd7679", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Schema `aeon_test_acquisition`\n", + "\n" + ] + } + ], + "source": [ + "# Note that we're on the \"aeon_test_\" database prefix\n", + "print(acquisition.schema)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "2e0f3ac9", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.lab.Location\n", + "\n", + "\n", + "acquisition.lab.Location\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Experiment\n", + "\n", + "\n", + "acquisition.Experiment\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.lab.Location->acquisition.Experiment\n", + "\n", + "\n", + "\n", + "\n", + "streams.OSCStartNewSession\n", + "\n", + "\n", + "streams.OSCStartNewSession\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.OSCBackgroundColor\n", + "\n", + "\n", + "streams.OSCBackgroundColor\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentOSC\n", + "\n", + "\n", + "streams.ExperimentOSC\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentOSC->streams.OSCStartNewSession\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentOSC->streams.OSCBackgroundColor\n", + "\n", + "\n", + "\n", + "\n", + "streams.OSCGratingsSlice\n", + "\n", + "\n", + "streams.OSCGratingsSlice\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentOSC->streams.OSCGratingsSlice\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentOSC.RemovalTime\n", + "\n", + "\n", + "streams.ExperimentOSC.RemovalTime\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentOSC->streams.ExperimentOSC.RemovalTime\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentOSC.Attribute\n", + "\n", + "\n", + "streams.ExperimentOSC.Attribute\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentOSC->streams.ExperimentOSC.Attribute\n", + "\n", + "\n", + "\n", + "\n", + "streams.OSCEndTrial\n", + "\n", + "\n", + "streams.OSCEndTrial\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentOSC->streams.OSCEndTrial\n", + "\n", + "\n", + "\n", + "\n", + "streams.OSCRunPreTrialNoPoke\n", + "\n", + "\n", + "streams.OSCRunPreTrialNoPoke\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentOSC->streams.OSCRunPreTrialNoPoke\n", + "\n", + "\n", + "\n", + "\n", + "streams.OSCPoke\n", + "\n", + "\n", + "streams.OSCPoke\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentOSC->streams.OSCPoke\n", + "\n", + "\n", + "\n", + "\n", + "streams.OSCSlice\n", + "\n", + "\n", + "streams.OSCSlice\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentOSC->streams.OSCSlice\n", + "\n", + "\n", + "\n", + "\n", + "streams.OSCChangeSubjectState\n", + "\n", + "\n", + "streams.OSCChangeSubjectState\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentOSC->streams.OSCChangeSubjectState\n", + "\n", + "\n", + "\n", + "\n", + "streams.OSCResponse\n", + "\n", + "\n", + "streams.OSCResponse\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentOSC->streams.OSCResponse\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Experiment->streams.ExperimentOSC\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk\n", + "\n", + "\n", + "acquisition.Chunk\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Experiment->acquisition.Chunk\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Epoch\n", + "\n", + "\n", + "acquisition.Epoch\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Experiment->acquisition.Epoch\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Experiment.Directory\n", + "\n", + "\n", + "acquisition.Experiment.Directory\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Experiment->acquisition.Experiment.Directory\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.lab.Arena\n", + "\n", + "\n", + "acquisition.lab.Arena\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.lab.Arena->acquisition.Experiment\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.OSCStartNewSession\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.OSCBackgroundColor\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.OSCGratingsSlice\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.OSCEndTrial\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.OSCRunPreTrialNoPoke\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.OSCPoke\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.OSCSlice\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.OSCChangeSubjectState\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.OSCResponse\n", + "\n", + "\n", + "\n", + "\n", + "streams.Device\n", + "\n", + "\n", + "streams.Device\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.Device->streams.ExperimentOSC\n", + "\n", + "\n", + "\n", + "\n", + "streams.DeviceType\n", + "\n", + "\n", + "streams.DeviceType\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.DeviceType->streams.Device\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Epoch->acquisition.Chunk\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Experiment.Directory->acquisition.Chunk\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.ExperimentType\n", + "\n", + "\n", + "acquisition.ExperimentType\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.ExperimentType->acquisition.Experiment\n", + "\n", + "\n", + "\n", + "" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Diagram for pipeline architecture surrounding the \"ExperimentOSC\" table\n", + "dj.Diagram(streams.ExperimentOSC) + 1 - 2" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "f2359d22", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentPhotodiode.RemovalTime\n", + "\n", + "\n", + "streams.ExperimentPhotodiode.RemovalTime\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentFoodPatch.RemovalTime\n", + "\n", + "\n", + "streams.ExperimentFoodPatch.RemovalTime\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.CameraVideo\n", + "\n", + "\n", + "streams.CameraVideo\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentPhotodiode\n", + "\n", + "\n", + "streams.ExperimentPhotodiode\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentPhotodiode->streams.ExperimentPhotodiode.RemovalTime\n", + "\n", + "\n", + "\n", + "\n", + "streams.PhotodiodePhotodiode\n", + "\n", + "\n", + "streams.PhotodiodePhotodiode\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentPhotodiode->streams.PhotodiodePhotodiode\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentPhotodiode.Attribute\n", + "\n", + "\n", + "streams.ExperimentPhotodiode.Attribute\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentPhotodiode->streams.ExperimentPhotodiode.Attribute\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentExperimentalMetadata.RemovalTime\n", + "\n", + "\n", + "streams.ExperimentExperimentalMetadata.RemovalTime\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.FoodPatchEncoder\n", + "\n", + "\n", + "streams.FoodPatchEncoder\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.TaskLogicDrawBackground\n", + "\n", + "\n", + "streams.TaskLogicDrawBackground\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.OSCStartNewSession\n", + "\n", + "\n", + "streams.OSCStartNewSession\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentTaskLogic.RemovalTime\n", + "\n", + "\n", + "streams.ExperimentTaskLogic.RemovalTime\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.FoodPatchBeamBreak\n", + "\n", + "\n", + "streams.FoodPatchBeamBreak\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.TaskLogicTrialInitiation\n", + "\n", + "\n", + "streams.TaskLogicTrialInitiation\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.CameraPosition\n", + "\n", + "\n", + "streams.CameraPosition\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentOSC.RemovalTime\n", + "\n", + "\n", + "streams.ExperimentOSC.RemovalTime\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentalMetadataSubjectState\n", + "\n", + "\n", + "streams.ExperimentalMetadataSubjectState\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentTaskLogic\n", + "\n", + "\n", + "streams.ExperimentTaskLogic\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentTaskLogic->streams.TaskLogicDrawBackground\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentTaskLogic->streams.ExperimentTaskLogic.RemovalTime\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentTaskLogic->streams.TaskLogicTrialInitiation\n", + "\n", + "\n", + "\n", + "\n", + "streams.TaskLogicGratingsSliceOnset\n", + "\n", + "\n", + "streams.TaskLogicGratingsSliceOnset\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentTaskLogic->streams.TaskLogicGratingsSliceOnset\n", + "\n", + "\n", + "\n", + "\n", + "streams.TaskLogicResponse\n", + "\n", + "\n", + "streams.TaskLogicResponse\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentTaskLogic->streams.TaskLogicResponse\n", + "\n", + "\n", + "\n", + "\n", + "streams.TaskLogicPreTrialState\n", + "\n", + "\n", + "streams.TaskLogicPreTrialState\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentTaskLogic->streams.TaskLogicPreTrialState\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentTaskLogic.Attribute\n", + "\n", + "\n", + "streams.ExperimentTaskLogic.Attribute\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentTaskLogic->streams.ExperimentTaskLogic.Attribute\n", + "\n", + "\n", + "\n", + "\n", + "streams.TaskLogicInterTrialInterval\n", + "\n", + "\n", + "streams.TaskLogicInterTrialInterval\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentTaskLogic->streams.TaskLogicInterTrialInterval\n", + "\n", + "\n", + "\n", + "\n", + "streams.TaskLogicSliceOnset\n", + "\n", + "\n", + "streams.TaskLogicSliceOnset\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentTaskLogic->streams.TaskLogicSliceOnset\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Experiment\n", + "\n", + "\n", + "acquisition.Experiment\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Experiment->streams.ExperimentPhotodiode\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Experiment->streams.ExperimentTaskLogic\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentExperimentalMetadata\n", + "\n", + "\n", + "streams.ExperimentExperimentalMetadata\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Experiment->streams.ExperimentExperimentalMetadata\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentCamera\n", + "\n", + "\n", + "streams.ExperimentCamera\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Experiment->streams.ExperimentCamera\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentOSC\n", + "\n", + "\n", + "streams.ExperimentOSC\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Experiment->streams.ExperimentOSC\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk\n", + "\n", + "\n", + "acquisition.Chunk\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Experiment->acquisition.Chunk\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentFoodPatch\n", + "\n", + "\n", + "streams.ExperimentFoodPatch\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Experiment->streams.ExperimentFoodPatch\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentNestScale\n", + "\n", + "\n", + "streams.ExperimentNestScale\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Experiment->streams.ExperimentNestScale\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Epoch\n", + "\n", + "\n", + "acquisition.Epoch\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Experiment->acquisition.Epoch\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentWall\n", + "\n", + "\n", + "streams.ExperimentWall\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Experiment->streams.ExperimentWall\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentMetadata\n", + "\n", + "\n", + "streams.ExperimentMetadata\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Experiment->streams.ExperimentMetadata\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Experiment.Directory\n", + "\n", + "\n", + "acquisition.Experiment.Directory\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Experiment->acquisition.Experiment.Directory\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentCamera.Attribute\n", + "\n", + "\n", + "streams.ExperimentCamera.Attribute\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.OSCRunPreTrialNoPoke\n", + "\n", + "\n", + "streams.OSCRunPreTrialNoPoke\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.StreamType\n", + "\n", + "\n", + "streams.StreamType\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.DeviceType.Stream\n", + "\n", + "\n", + "streams.DeviceType.Stream\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.StreamType->streams.DeviceType.Stream\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentExperimentalMetadata->streams.ExperimentExperimentalMetadata.RemovalTime\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentExperimentalMetadata->streams.ExperimentalMetadataSubjectState\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentalMetadataEnvironmentState\n", + "\n", + "\n", + "streams.ExperimentalMetadataEnvironmentState\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentExperimentalMetadata->streams.ExperimentalMetadataEnvironmentState\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentalMetadataMessageLog\n", + "\n", + "\n", + "streams.ExperimentalMetadataMessageLog\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentExperimentalMetadata->streams.ExperimentalMetadataMessageLog\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentExperimentalMetadata.Attribute\n", + "\n", + "\n", + "streams.ExperimentExperimentalMetadata.Attribute\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentExperimentalMetadata->streams.ExperimentExperimentalMetadata.Attribute\n", + "\n", + "\n", + "\n", + "\n", + "streams.WallBeamBreak1\n", + "\n", + "\n", + "streams.WallBeamBreak1\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.OSCPoke\n", + "\n", + "\n", + "streams.OSCPoke\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.WallClearLed1\n", + "\n", + "\n", + "streams.WallClearLed1\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentNestScale.Attribute\n", + "\n", + "\n", + "streams.ExperimentNestScale.Attribute\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.NestScaleWeightRaw\n", + "\n", + "\n", + "streams.NestScaleWeightRaw\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.WallSetValve1\n", + "\n", + "\n", + "streams.WallSetValve1\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.WallClearLed0\n", + "\n", + "\n", + "streams.WallClearLed0\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentCamera->streams.CameraVideo\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentCamera->streams.CameraPosition\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentCamera->streams.ExperimentCamera.Attribute\n", + "\n", + "\n", + "\n", + "\n", + "streams.CameraRegion\n", + "\n", + "\n", + "streams.CameraRegion\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentCamera->streams.CameraRegion\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentCamera.RemovalTime\n", + "\n", + "\n", + "streams.ExperimentCamera.RemovalTime\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentCamera->streams.ExperimentCamera.RemovalTime\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentOSC->streams.OSCStartNewSession\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentOSC->streams.ExperimentOSC.RemovalTime\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentOSC->streams.OSCRunPreTrialNoPoke\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentOSC->streams.OSCPoke\n", + "\n", + "\n", + "\n", + "\n", + "streams.OSCGratingsSlice\n", + "\n", + "\n", + "streams.OSCGratingsSlice\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentOSC->streams.OSCGratingsSlice\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentOSC.Attribute\n", + "\n", + "\n", + "streams.ExperimentOSC.Attribute\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentOSC->streams.ExperimentOSC.Attribute\n", + "\n", + "\n", + "\n", + "\n", + "streams.OSCSlice\n", + "\n", + "\n", + "streams.OSCSlice\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentOSC->streams.OSCSlice\n", + "\n", + "\n", + "\n", + "\n", + "streams.OSCChangeSubjectState\n", + "\n", + "\n", + "streams.OSCChangeSubjectState\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentOSC->streams.OSCChangeSubjectState\n", + "\n", + "\n", + "\n", + "\n", + "streams.OSCBackgroundColor\n", + "\n", + "\n", + "streams.OSCBackgroundColor\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentOSC->streams.OSCBackgroundColor\n", + "\n", + "\n", + "\n", + "\n", + "streams.OSCEndTrial\n", + "\n", + "\n", + "streams.OSCEndTrial\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentOSC->streams.OSCEndTrial\n", + "\n", + "\n", + "\n", + "\n", + "streams.OSCResponse\n", + "\n", + "\n", + "streams.OSCResponse\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentOSC->streams.OSCResponse\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentNestScale.RemovalTime\n", + "\n", + "\n", + "streams.ExperimentNestScale.RemovalTime\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.CameraVideo\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.PhotodiodePhotodiode\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.FoodPatchEncoder\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.TaskLogicDrawBackground\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.OSCStartNewSession\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.FoodPatchBeamBreak\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.TaskLogicTrialInitiation\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.CameraPosition\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.ExperimentalMetadataSubjectState\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.OSCRunPreTrialNoPoke\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.WallBeamBreak1\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.OSCPoke\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.ExperimentalMetadataEnvironmentState\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.TaskLogicGratingsSliceOnset\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.WallClearLed1\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.NestScaleWeightRaw\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.WallSetValve1\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.WallClearLed0\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.OSCGratingsSlice\n", + "\n", + "\n", + "\n", + "\n", + "streams.WallSetValve2\n", + "\n", + "\n", + "streams.WallSetValve2\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.WallSetValve2\n", + "\n", + "\n", + "\n", + "\n", + "streams.WallSetValve0\n", + "\n", + "\n", + "streams.WallSetValve0\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.WallSetValve0\n", + "\n", + "\n", + "\n", + "\n", + "streams.WallSetLed1\n", + "\n", + "\n", + "streams.WallSetLed1\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.WallSetLed1\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.TaskLogicResponse\n", + "\n", + "\n", + "\n", + "\n", + "streams.WallClearLed2\n", + "\n", + "\n", + "streams.WallClearLed2\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.WallClearLed2\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.TaskLogicPreTrialState\n", + "\n", + "\n", + "\n", + "\n", + "streams.FoodPatchDepletionState\n", + "\n", + "\n", + "streams.FoodPatchDepletionState\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.FoodPatchDepletionState\n", + "\n", + "\n", + "\n", + "\n", + "streams.WallBeamBreak0\n", + "\n", + "\n", + "streams.WallBeamBreak0\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.WallBeamBreak0\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.CameraRegion\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.ExperimentalMetadataMessageLog\n", + "\n", + "\n", + "\n", + "\n", + "streams.WallSetLed0\n", + "\n", + "\n", + "streams.WallSetLed0\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.WallSetLed0\n", + "\n", + "\n", + "\n", + "\n", + "streams.NestScaleWeightSubject\n", + "\n", + "\n", + "streams.NestScaleWeightSubject\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.NestScaleWeightSubject\n", + "\n", + "\n", + "\n", + "\n", + "streams.WallClearValve2\n", + "\n", + "\n", + "streams.WallClearValve2\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.WallClearValve2\n", + "\n", + "\n", + "\n", + "\n", + "streams.MetadataMetadata\n", + "\n", + "\n", + "streams.MetadataMetadata\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.MetadataMetadata\n", + "\n", + "\n", + "\n", + "\n", + "streams.WallClearValve0\n", + "\n", + "\n", + "streams.WallClearValve0\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.WallClearValve0\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.OSCSlice\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.OSCChangeSubjectState\n", + "\n", + "\n", + "\n", + "\n", + "streams.NestScaleWeightFiltered\n", + "\n", + "\n", + "streams.NestScaleWeightFiltered\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.NestScaleWeightFiltered\n", + "\n", + "\n", + "\n", + "\n", + "streams.FoodPatchDeliverPellet\n", + "\n", + "\n", + "streams.FoodPatchDeliverPellet\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.FoodPatchDeliverPellet\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.TaskLogicInterTrialInterval\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.OSCBackgroundColor\n", + "\n", + "\n", + "\n", + "\n", + "streams.WallBeamBreak2\n", + "\n", + "\n", + "streams.WallBeamBreak2\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.WallBeamBreak2\n", + "\n", + "\n", + "\n", + "\n", + "streams.WallSetLed2\n", + "\n", + "\n", + "streams.WallSetLed2\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.WallSetLed2\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.OSCEndTrial\n", + "\n", + "\n", + "\n", + "\n", + "streams.WallClearValve1\n", + "\n", + "\n", + "streams.WallClearValve1\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.WallClearValve1\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.TaskLogicSliceOnset\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Chunk->streams.OSCResponse\n", + "\n", + "\n", + "\n", + "\n", + "streams.DeviceType\n", + "\n", + "\n", + "streams.DeviceType\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.Device\n", + "\n", + "\n", + "streams.Device\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.DeviceType->streams.Device\n", + "\n", + "\n", + "\n", + "\n", + "streams.DeviceType->streams.DeviceType.Stream\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentFoodPatch->streams.ExperimentFoodPatch.RemovalTime\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentFoodPatch->streams.FoodPatchEncoder\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentFoodPatch->streams.FoodPatchBeamBreak\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentFoodPatch->streams.FoodPatchDepletionState\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentFoodPatch.Attribute\n", + "\n", + "\n", + "streams.ExperimentFoodPatch.Attribute\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentFoodPatch->streams.ExperimentFoodPatch.Attribute\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentFoodPatch->streams.FoodPatchDeliverPellet\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentNestScale->streams.ExperimentNestScale.Attribute\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentNestScale->streams.NestScaleWeightRaw\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentNestScale->streams.ExperimentNestScale.RemovalTime\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentNestScale->streams.NestScaleWeightSubject\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentNestScale->streams.NestScaleWeightFiltered\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentWall.Attribute\n", + "\n", + "\n", + "streams.ExperimentWall.Attribute\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentMetadata.RemovalTime\n", + "\n", + "\n", + "streams.ExperimentMetadata.RemovalTime\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.Device->streams.ExperimentPhotodiode\n", + "\n", + "\n", + "\n", + "\n", + "streams.Device->streams.ExperimentTaskLogic\n", + "\n", + "\n", + "\n", + "\n", + "streams.Device->streams.ExperimentExperimentalMetadata\n", + "\n", + "\n", + "\n", + "\n", + "streams.Device->streams.ExperimentCamera\n", + "\n", + "\n", + "\n", + "\n", + "streams.Device->streams.ExperimentOSC\n", + "\n", + "\n", + "\n", + "\n", + "streams.Device->streams.ExperimentFoodPatch\n", + "\n", + "\n", + "\n", + "\n", + "streams.Device->streams.ExperimentNestScale\n", + "\n", + "\n", + "\n", + "\n", + "streams.Device->streams.ExperimentWall\n", + "\n", + "\n", + "\n", + "\n", + "streams.Device->streams.ExperimentMetadata\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentMetadata.Attribute\n", + "\n", + "\n", + "streams.ExperimentMetadata.Attribute\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Epoch->acquisition.Chunk\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentWall->streams.WallBeamBreak1\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentWall->streams.WallClearLed1\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentWall->streams.WallSetValve1\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentWall->streams.WallClearLed0\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentWall->streams.WallSetValve2\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentWall->streams.WallSetValve0\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentWall->streams.WallSetLed1\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentWall->streams.WallClearLed2\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentWall->streams.WallBeamBreak0\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentWall->streams.WallSetLed0\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentWall->streams.ExperimentWall.Attribute\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentWall->streams.WallClearValve2\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentWall->streams.WallClearValve0\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentWall.RemovalTime\n", + "\n", + "\n", + "streams.ExperimentWall.RemovalTime\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentWall->streams.ExperimentWall.RemovalTime\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentWall->streams.WallBeamBreak2\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentWall->streams.WallSetLed2\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentWall->streams.WallClearValve1\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentMetadata->streams.ExperimentMetadata.RemovalTime\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentMetadata->streams.MetadataMetadata\n", + "\n", + "\n", + "\n", + "\n", + "streams.ExperimentMetadata->streams.ExperimentMetadata.Attribute\n", + "\n", + "\n", + "\n", + "\n", + "acquisition.Experiment.Directory->acquisition.Chunk\n", + "\n", + "\n", + "\n", + "" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Diagram for pipeline architecture related to all \"data streams\"\n", + "dj.Diagram(streams) - 1" + ] + }, + { + "cell_type": "markdown", + "id": "9b4abe75", + "metadata": {}, + "source": [ + "# Explore the data" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "59450f6f", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + "

    \n", + " " + ], + "text/plain": [ + "*experiment_name experiment_start_time experiment_description arena_name lab location experiment_type \n", + "+-----------------+ +-----------------------+ +------------------------+ +------------+ +-----+ +----------+ +-----------------+\n", + "oct1.0-r0 2022-02-22 09:00:00 octagon 1.0 octagon-1m SWC 464 social \n", + " (Total: 1)" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "acquisition.Experiment()" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "a0abbc63", + "metadata": {}, + "outputs": [], + "source": [ + "exp_key = {'experiment_name': 'oct1.0-r0'}" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "3952c870", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " A recording period corresponds to a 1-hour data acquisition\n", + "
    \n", + " \n", + " \n", + " \n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
    \n", + "

    experiment_name

    \n", + " e.g exp0-r0\n", + "
    \n", + "

    chunk_start

    \n", + " datetime of the start of a given acquisition chunk\n", + "
    \n", + "

    chunk_end

    \n", + " datetime of the end of a given acquisition chunk\n", + "
    \n", + "

    directory_type

    \n", + " \n", + "
    \n", + "

    epoch_start

    \n", + " \n", + "
    oct1.0-r01904-01-01 22:00:001904-01-01 23:00:00raw2022-08-22 11:47:16
    oct1.0-r01904-01-02 00:00:001904-01-02 01:00:00raw2022-08-22 14:09:36
    oct1.0-r01904-01-02 23:00:001904-01-03 00:00:00raw2022-08-23 12:41:49
    oct1.0-r01904-01-03 22:00:001904-01-03 23:00:00raw2022-08-24 11:40:58
    oct1.0-r01904-01-04 23:00:001904-01-05 00:00:00raw2022-08-25 13:01:30
    oct1.0-r01904-01-05 00:00:001904-01-05 01:00:00raw2022-08-25 13:24:11
    oct1.0-r01904-01-06 00:00:001904-01-06 01:00:00raw2022-08-26 14:05:07
    oct1.0-r01904-01-06 01:00:001904-01-06 02:00:00raw2022-08-26 14:29:13
    oct1.0-r01904-01-06 23:00:001904-01-07 00:00:00raw2022-08-27 13:31:50
    oct1.0-r01904-01-07 00:00:001904-01-07 01:00:00raw2022-08-27 13:31:50
    oct1.0-r01904-01-09 00:00:001904-01-09 01:00:00raw2022-08-29 14:25:14
    oct1.0-r01904-01-09 01:00:001904-01-09 02:00:00raw2022-08-29 14:25:14
    oct1.0-r01904-01-09 02:00:001904-01-09 03:00:00raw2022-08-29 15:07:10
    oct1.0-r02022-07-04 09:00:002022-07-04 10:00:00raw2022-07-04 12:19:42
    oct1.0-r02022-07-04 13:09:512022-07-04 10:00:00raw2022-07-04 13:09:51
    oct1.0-r02022-07-05 09:00:002022-07-05 10:00:00raw2022-07-05 12:00:23
    oct1.0-r02022-07-05 22:00:002022-07-05 23:00:00raw2022-07-06 10:08:26
    oct1.0-r02022-07-06 10:00:002022-07-06 11:00:00raw2022-07-06 12:38:00
    oct1.0-r02022-07-06 13:36:282022-07-06 11:00:00raw2022-07-06 13:36:28
    oct1.0-r02022-07-07 12:00:002022-07-07 13:00:00raw2022-07-07 13:41:19
    \n", + "

    ...

    \n", + "

    Total: 548

    \n", + " " + ], + "text/plain": [ + "*experiment_name *chunk_start chunk_end directory_type epoch_start \n", + "+-----------------+ +---------------------+ +---------------------+ +----------------+ +---------------------+\n", + "oct1.0-r0 1904-01-01 22:00:00 1904-01-01 23:00:00 raw 2022-08-22 11:47:16 \n", + "oct1.0-r0 1904-01-02 00:00:00 1904-01-02 01:00:00 raw 2022-08-22 14:09:36 \n", + "oct1.0-r0 1904-01-02 23:00:00 1904-01-03 00:00:00 raw 2022-08-23 12:41:49 \n", + "oct1.0-r0 1904-01-03 22:00:00 1904-01-03 23:00:00 raw 2022-08-24 11:40:58 \n", + "oct1.0-r0 1904-01-04 23:00:00 1904-01-05 00:00:00 raw 2022-08-25 13:01:30 \n", + "oct1.0-r0 1904-01-05 00:00:00 1904-01-05 01:00:00 raw 2022-08-25 13:24:11 \n", + "oct1.0-r0 1904-01-06 00:00:00 1904-01-06 01:00:00 raw 2022-08-26 14:05:07 \n", + "oct1.0-r0 1904-01-06 01:00:00 1904-01-06 02:00:00 raw 2022-08-26 14:29:13 \n", + "oct1.0-r0 1904-01-06 23:00:00 1904-01-07 00:00:00 raw 2022-08-27 13:31:50 \n", + "oct1.0-r0 1904-01-07 00:00:00 1904-01-07 01:00:00 raw 2022-08-27 13:31:50 \n", + "oct1.0-r0 1904-01-09 00:00:00 1904-01-09 01:00:00 raw 2022-08-29 14:25:14 \n", + "oct1.0-r0 1904-01-09 01:00:00 1904-01-09 02:00:00 raw 2022-08-29 14:25:14 \n", + "oct1.0-r0 1904-01-09 02:00:00 1904-01-09 03:00:00 raw 2022-08-29 15:07:10 \n", + "oct1.0-r0 2022-07-04 09:00:00 2022-07-04 10:00:00 raw 2022-07-04 12:19:42 \n", + "oct1.0-r0 2022-07-04 13:09:51 2022-07-04 10:00:00 raw 2022-07-04 13:09:51 \n", + "oct1.0-r0 2022-07-05 09:00:00 2022-07-05 10:00:00 raw 2022-07-05 12:00:23 \n", + "oct1.0-r0 2022-07-05 22:00:00 2022-07-05 23:00:00 raw 2022-07-06 10:08:26 \n", + "oct1.0-r0 2022-07-06 10:00:00 2022-07-06 11:00:00 raw 2022-07-06 12:38:00 \n", + "oct1.0-r0 2022-07-06 13:36:28 2022-07-06 11:00:00 raw 2022-07-06 13:36:28 \n", + "oct1.0-r0 2022-07-07 12:00:00 2022-07-07 13:00:00 raw 2022-07-07 13:41:19 \n", + " ...\n", + " (Total: 548)" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "acquisition.Chunk & exp_key" + ] + }, + { + "cell_type": "markdown", + "id": "340efa0d", + "metadata": {}, + "source": [ + "## Records on the devices in use for \"oct1.0-r0\"" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "285bf400", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " Camera placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown)\n", + "
    \n", + " \n", + " \n", + " \n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
    \n", + "

    experiment_name

    \n", + " e.g exp0-r0\n", + "
    \n", + "

    device_serial_number

    \n", + " \n", + "
    \n", + "

    camera_install_time

    \n", + " time of the camera placed and started operation at this position\n", + "
    \n", + "

    camera_name

    \n", + " \n", + "
    oct1.0-r0oct01_12022-07-04 12:19:42CameraTop
    oct1.0-r0oct01_22022-07-04 12:19:42CameraColorTop
    \n", + " \n", + "

    Total: 2

    \n", + " " + ], + "text/plain": [ + "*experiment_name *device_serial_number *camera_install_time camera_name \n", + "+-----------------+ +----------------------+ +---------------------+ +----------------+\n", + "oct1.0-r0 oct01_1 2022-07-04 12:19:42 CameraTop \n", + "oct1.0-r0 oct01_2 2022-07-04 12:19:42 CameraColorTop \n", + " (Total: 2)" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "streams.ExperimentCamera()" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "b5567270", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " Wall placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown)\n", + "
    \n", + " \n", + " \n", + " \n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
    \n", + "

    experiment_name

    \n", + " e.g exp0-r0\n", + "
    \n", + "

    device_serial_number

    \n", + " \n", + "
    \n", + "

    wall_install_time

    \n", + " time of the wall placed and started operation at this position\n", + "
    \n", + "

    wall_name

    \n", + " \n", + "
    oct1.0-r0oct01_102022-07-04 12:19:42Wall4
    oct1.0-r0oct01_112022-07-04 12:19:42Wall5
    oct1.0-r0oct01_122022-07-04 12:19:42Wall6
    oct1.0-r0oct01_132022-07-04 12:19:42Wall7
    oct1.0-r0oct01_142022-07-04 12:19:42Wall8
    oct1.0-r0oct01_72022-07-04 12:19:42Wall1
    oct1.0-r0oct01_82022-07-04 12:19:42Wall2
    oct1.0-r0oct01_92022-07-04 12:19:42Wall3
    \n", + " \n", + "

    Total: 8

    \n", + " " + ], + "text/plain": [ + "*experiment_name *device_serial_number *wall_install_time wall_name \n", + "+-----------------+ +----------------------+ +---------------------+ +-----------+\n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 Wall4 \n", + "oct1.0-r0 oct01_11 2022-07-04 12:19:42 Wall5 \n", + "oct1.0-r0 oct01_12 2022-07-04 12:19:42 Wall6 \n", + "oct1.0-r0 oct01_13 2022-07-04 12:19:42 Wall7 \n", + "oct1.0-r0 oct01_14 2022-07-04 12:19:42 Wall8 \n", + "oct1.0-r0 oct01_7 2022-07-04 12:19:42 Wall1 \n", + "oct1.0-r0 oct01_8 2022-07-04 12:19:42 Wall2 \n", + "oct1.0-r0 oct01_9 2022-07-04 12:19:42 Wall3 \n", + " (Total: 8)" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "streams.ExperimentWall()" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "d9d888cc", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " OSC placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown)\n", + "
    \n", + " \n", + " \n", + " \n", + "\n", + "\n", + "\n", + "
    \n", + "

    experiment_name

    \n", + " e.g exp0-r0\n", + "
    \n", + "

    device_serial_number

    \n", + " \n", + "
    \n", + "

    o_s_c_install_time

    \n", + " time of the o_s_c placed and started operation at this position\n", + "
    \n", + "

    o_s_c_name

    \n", + " \n", + "
    oct1.0-r0oct01_52022-07-04 12:19:42OSC
    \n", + " \n", + "

    Total: 1

    \n", + " " + ], + "text/plain": [ + "*experiment_name *device_serial_number *o_s_c_install_time o_s_c_name \n", + "+-----------------+ +----------------------+ +---------------------+ +------------+\n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 OSC \n", + " (Total: 1)" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "streams.ExperimentOSC()" + ] + }, + { + "cell_type": "markdown", + "id": "866e177c", + "metadata": {}, + "source": [ + "## Query/fetch streams' data" + ] + }, + { + "cell_type": "markdown", + "id": "e91a825c", + "metadata": {}, + "source": [ + "### OSCPoke" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "2c77ec61", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " Raw per-chunk Poke data stream from OSC (auto-generated with aeon_mecha-unknown)\n", + "
    \n", + " \n", + " \n", + " \n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
    \n", + "

    experiment_name

    \n", + " e.g exp0-r0\n", + "
    \n", + "

    device_serial_number

    \n", + " \n", + "
    \n", + "

    o_s_c_install_time

    \n", + " time of the o_s_c placed and started operation at this position\n", + "
    \n", + "

    chunk_start

    \n", + " datetime of the start of a given acquisition chunk\n", + "
    \n", + "

    sample_count

    \n", + " number of data points acquired from this stream for a given chunk\n", + "
    \n", + "

    timestamps

    \n", + " (datetime) timestamps of Poke data\n", + "
    \n", + "

    typetag

    \n", + " \n", + "
    \n", + "

    wall_id

    \n", + " \n", + "
    \n", + "

    poke_id

    \n", + " \n", + "
    \n", + "

    reward

    \n", + " \n", + "
    \n", + "

    reward_interval

    \n", + " \n", + "
    \n", + "

    delay

    \n", + " \n", + "
    \n", + "

    led_delay

    \n", + " \n", + "
    oct1.0-r0oct01_52022-07-04 12:19:422022-07-04 13:09:510=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
    oct1.0-r0oct01_52022-07-04 12:19:422022-07-05 09:00:000=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
    oct1.0-r0oct01_52022-07-04 12:19:422022-07-05 22:00:000=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
    oct1.0-r0oct01_52022-07-04 12:19:422022-07-06 10:00:000=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
    oct1.0-r0oct01_52022-07-04 12:19:422022-07-06 13:36:280=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
    oct1.0-r0oct01_52022-07-04 12:19:422022-07-07 12:00:00152=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
    oct1.0-r0oct01_52022-07-04 12:19:422022-07-07 13:00:0084=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
    oct1.0-r0oct01_52022-07-04 12:19:422022-07-07 14:00:00200=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
    oct1.0-r0oct01_52022-07-04 12:19:422022-07-07 15:00:00252=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
    oct1.0-r0oct01_52022-07-04 12:19:422022-07-07 16:00:0022=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
    oct1.0-r0oct01_52022-07-04 12:19:422022-07-07 16:15:010=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
    oct1.0-r0oct01_52022-07-04 12:19:422022-07-08 11:00:0092=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
    oct1.0-r0oct01_52022-07-04 12:19:422022-07-08 12:00:0076=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
    oct1.0-r0oct01_52022-07-04 12:19:422022-07-08 13:00:00232=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
    oct1.0-r0oct01_52022-07-04 12:19:422022-07-08 13:49:160=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
    oct1.0-r0oct01_52022-07-04 12:19:422022-07-08 14:00:00160=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
    oct1.0-r0oct01_52022-07-04 12:19:422022-07-11 13:00:0092=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
    oct1.0-r0oct01_52022-07-04 12:19:422022-07-11 14:00:00176=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
    oct1.0-r0oct01_52022-07-04 12:19:422022-07-11 15:00:00166=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
    oct1.0-r0oct01_52022-07-04 12:19:422022-07-11 15:22:310=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
    \n", + "

    ...

    \n", + "

    Total: 532

    \n", + " " + ], + "text/plain": [ + "*experiment_name *device_serial_number *o_s_c_install_time *chunk_start sample_count timestamps typetag wall_id poke_id reward reward_int delay led_delay \n", + "+-----------------+ +----------------------+ +---------------------+ +---------------------+ +--------------+ +--------+ +--------+ +--------+ +--------+ +--------+ +--------+ +--------+ +--------+\n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-07-04 13:09:51 0 =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-07-05 09:00:00 0 =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-07-05 22:00:00 0 =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-07-06 10:00:00 0 =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-07-06 13:36:28 0 =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-07-07 12:00:00 152 =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-07-07 13:00:00 84 =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-07-07 14:00:00 200 =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-07-07 15:00:00 252 =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-07-07 16:00:00 22 =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-07-07 16:15:01 0 =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-07-08 11:00:00 92 =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-07-08 12:00:00 76 =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-07-08 13:00:00 232 =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-07-08 13:49:16 0 =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-07-08 14:00:00 160 =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-07-11 13:00:00 92 =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-07-11 14:00:00 176 =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-07-11 15:00:00 166 =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-07-11 15:22:31 0 =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", + " ...\n", + " (Total: 532)" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "streams.OSCPoke()" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "8e1b564b", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " Raw per-chunk Poke data stream from OSC (auto-generated with aeon_mecha-unknown)\n", + "
    \n", + " \n", + " \n", + " \n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
    \n", + "

    experiment_name

    \n", + " e.g exp0-r0\n", + "
    \n", + "

    device_serial_number

    \n", + " \n", + "
    \n", + "

    o_s_c_install_time

    \n", + " time of the o_s_c placed and started operation at this position\n", + "
    \n", + "

    chunk_start

    \n", + " datetime of the start of a given acquisition chunk\n", + "
    \n", + "

    sample_count

    \n", + " number of data points acquired from this stream for a given chunk\n", + "
    \n", + "

    timestamps

    \n", + " (datetime) timestamps of Poke data\n", + "
    \n", + "

    typetag

    \n", + " \n", + "
    \n", + "

    wall_id

    \n", + " \n", + "
    \n", + "

    poke_id

    \n", + " \n", + "
    \n", + "

    reward

    \n", + " \n", + "
    \n", + "

    reward_interval

    \n", + " \n", + "
    \n", + "

    delay

    \n", + " \n", + "
    \n", + "

    led_delay

    \n", + " \n", + "
    oct1.0-r0oct01_52022-07-04 12:19:422022-08-01 08:00:002624=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
    oct1.0-r0oct01_52022-07-04 12:19:422022-08-01 09:00:002640=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
    oct1.0-r0oct01_52022-07-04 12:19:422022-08-01 09:46:590=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
    oct1.0-r0oct01_52022-07-04 12:19:422022-08-02 07:00:003104=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
    oct1.0-r0oct01_52022-07-04 12:19:422022-08-02 08:00:00272=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
    oct1.0-r0oct01_52022-07-04 12:19:422022-08-02 08:33:160=BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB==BLOB=
    \n", + " \n", + "

    Total: 6

    \n", + " " + ], + "text/plain": [ + "*experiment_name *device_serial_number *o_s_c_install_time *chunk_start sample_count timestamps typetag wall_id poke_id reward reward_int delay led_delay \n", + "+-----------------+ +----------------------+ +---------------------+ +---------------------+ +--------------+ +--------+ +--------+ +--------+ +--------+ +--------+ +--------+ +--------+ +--------+\n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-08-01 08:00:00 2624 =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-08-01 09:00:00 2640 =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-08-01 09:46:59 0 =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-08-02 07:00:00 3104 =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-08-02 08:00:00 272 =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-08-02 08:33:16 0 =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= =BLOB= \n", + " (Total: 6)" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "streams.OSCPoke & 'chunk_start BETWEEN \"2022-08-01\" AND \"2022-08-03\"'" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "d58979c4", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    sample_counttimestampstypetagwall_idpoke_idrewardreward_intervaldelayled_delay
    experiment_namedevice_serial_numbero_s_c_install_timechunk_start
    oct1.0-r0oct01_52022-07-04 12:19:422022-08-01 08:00:002624[2022-08-01T08:03:48.654019833, 2022-08-01T08:...[iiifff, iiifff, iiifff, iiifff, iiifff, iiiff...[1, 1, 5, 5, 2, 2, 3, 3, 4, 4, 6, 6, 7, 7, 8, ...[0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, ...[3, 3, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ...[0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, ...[1.464134, 1.464134, 1.464134, 1.464134, 1.464...[32.46413, 32.46413, 32.46413, 32.46413, 32.46...
    2022-08-01 09:00:002640[2022-08-01T09:00:01.149020195, 2022-08-01T09:...[iiifff, iiifff, iiifff, iiifff, iiifff, iiiff...[1, 1, 5, 5, 2, 2, 3, 3, 4, 4, 6, 6, 7, 7, 8, ...[0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, ...[3, 3, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ...[0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, ...[1.081955, 1.081955, 1.081955, 1.081955, 1.081...[32.08195, 32.08195, 32.08195, 32.08195, 32.08...
    2022-08-01 09:46:590[][][][][][][][]
    2022-08-02 07:00:003104[2022-08-02T07:02:03.012030125, 2022-08-02T07:...[iiifff, iiifff, iiifff, iiifff, iiifff, iiiff...[3, 3, 4, 4, 1, 1, 2, 2, 5, 5, 6, 6, 7, 7, 8, ...[0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, ...[6, 6, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ...[0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, ...[1.361024, 1.361024, 1.361024, 1.361024, 1.361...[32.36102, 32.36102, 32.36102, 32.36102, 32.36...
    2022-08-02 08:00:00272[2022-08-02T08:00:43.782020092, 2022-08-02T08:...[iiifff, iiifff, iiifff, iiifff, iiifff, iiiff...[2, 2, 3, 3, 1, 1, 4, 4, 5, 5, 6, 6, 7, 7, 8, ...[0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, ...[6, 6, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ...[0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, ...[0.5257716, 0.5257716, 0.5257716, 0.5257716, 0...[31.52577, 31.52577, 31.52577, 31.52577, 31.52...
    2022-08-02 08:33:160[][][][][][][][]
    \n", + "
    " + ], + "text/plain": [ + " sample_count \\\n", + "experiment_name device_serial_number o_s_c_install_time chunk_start \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-08-01 08:00:00 2624 \n", + " 2022-08-01 09:00:00 2640 \n", + " 2022-08-01 09:46:59 0 \n", + " 2022-08-02 07:00:00 3104 \n", + " 2022-08-02 08:00:00 272 \n", + " 2022-08-02 08:33:16 0 \n", + "\n", + " timestamps \\\n", + "experiment_name device_serial_number o_s_c_install_time chunk_start \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-08-01 08:00:00 [2022-08-01T08:03:48.654019833, 2022-08-01T08:... \n", + " 2022-08-01 09:00:00 [2022-08-01T09:00:01.149020195, 2022-08-01T09:... \n", + " 2022-08-01 09:46:59 [] \n", + " 2022-08-02 07:00:00 [2022-08-02T07:02:03.012030125, 2022-08-02T07:... \n", + " 2022-08-02 08:00:00 [2022-08-02T08:00:43.782020092, 2022-08-02T08:... \n", + " 2022-08-02 08:33:16 [] \n", + "\n", + " typetag \\\n", + "experiment_name device_serial_number o_s_c_install_time chunk_start \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-08-01 08:00:00 [iiifff, iiifff, iiifff, iiifff, iiifff, iiiff... \n", + " 2022-08-01 09:00:00 [iiifff, iiifff, iiifff, iiifff, iiifff, iiiff... \n", + " 2022-08-01 09:46:59 [] \n", + " 2022-08-02 07:00:00 [iiifff, iiifff, iiifff, iiifff, iiifff, iiiff... \n", + " 2022-08-02 08:00:00 [iiifff, iiifff, iiifff, iiifff, iiifff, iiiff... \n", + " 2022-08-02 08:33:16 [] \n", + "\n", + " wall_id \\\n", + "experiment_name device_serial_number o_s_c_install_time chunk_start \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-08-01 08:00:00 [1, 1, 5, 5, 2, 2, 3, 3, 4, 4, 6, 6, 7, 7, 8, ... \n", + " 2022-08-01 09:00:00 [1, 1, 5, 5, 2, 2, 3, 3, 4, 4, 6, 6, 7, 7, 8, ... \n", + " 2022-08-01 09:46:59 [] \n", + " 2022-08-02 07:00:00 [3, 3, 4, 4, 1, 1, 2, 2, 5, 5, 6, 6, 7, 7, 8, ... \n", + " 2022-08-02 08:00:00 [2, 2, 3, 3, 1, 1, 4, 4, 5, 5, 6, 6, 7, 7, 8, ... \n", + " 2022-08-02 08:33:16 [] \n", + "\n", + " poke_id \\\n", + "experiment_name device_serial_number o_s_c_install_time chunk_start \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-08-01 08:00:00 [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, ... \n", + " 2022-08-01 09:00:00 [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, ... \n", + " 2022-08-01 09:46:59 [] \n", + " 2022-08-02 07:00:00 [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, ... \n", + " 2022-08-02 08:00:00 [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, ... \n", + " 2022-08-02 08:33:16 [] \n", + "\n", + " reward \\\n", + "experiment_name device_serial_number o_s_c_install_time chunk_start \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-08-01 08:00:00 [3, 3, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ... \n", + " 2022-08-01 09:00:00 [3, 3, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ... \n", + " 2022-08-01 09:46:59 [] \n", + " 2022-08-02 07:00:00 [6, 6, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ... \n", + " 2022-08-02 08:00:00 [6, 6, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ... \n", + " 2022-08-02 08:33:16 [] \n", + "\n", + " reward_interval \\\n", + "experiment_name device_serial_number o_s_c_install_time chunk_start \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-08-01 08:00:00 [0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, ... \n", + " 2022-08-01 09:00:00 [0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, ... \n", + " 2022-08-01 09:46:59 [] \n", + " 2022-08-02 07:00:00 [0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, ... \n", + " 2022-08-02 08:00:00 [0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, ... \n", + " 2022-08-02 08:33:16 [] \n", + "\n", + " delay \\\n", + "experiment_name device_serial_number o_s_c_install_time chunk_start \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-08-01 08:00:00 [1.464134, 1.464134, 1.464134, 1.464134, 1.464... \n", + " 2022-08-01 09:00:00 [1.081955, 1.081955, 1.081955, 1.081955, 1.081... \n", + " 2022-08-01 09:46:59 [] \n", + " 2022-08-02 07:00:00 [1.361024, 1.361024, 1.361024, 1.361024, 1.361... \n", + " 2022-08-02 08:00:00 [0.5257716, 0.5257716, 0.5257716, 0.5257716, 0... \n", + " 2022-08-02 08:33:16 [] \n", + "\n", + " led_delay \n", + "experiment_name device_serial_number o_s_c_install_time chunk_start \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-08-01 08:00:00 [32.46413, 32.46413, 32.46413, 32.46413, 32.46... \n", + " 2022-08-01 09:00:00 [32.08195, 32.08195, 32.08195, 32.08195, 32.08... \n", + " 2022-08-01 09:46:59 [] \n", + " 2022-08-02 07:00:00 [32.36102, 32.36102, 32.36102, 32.36102, 32.36... \n", + " 2022-08-02 08:00:00 [31.52577, 31.52577, 31.52577, 31.52577, 31.52... \n", + " 2022-08-02 08:33:16 [] " + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df_oscpoke = (streams.OSCPoke & 'chunk_start BETWEEN \"2022-08-01\" AND \"2022-08-03\"').fetch(format='frame')\n", + "df_oscpoke" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "f342ac64", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    sample_counttimestampstypetagwall_idpoke_idrewardreward_intervaldelayled_delay
    experiment_namedevice_serial_numbero_s_c_install_timechunk_start
    oct1.0-r0oct01_52022-07-04 12:19:422022-08-01 08:00:0026242022-08-01 08:03:48.654019833iiifff1030.21.46413432.46413
    2022-08-01 08:00:0026242022-08-01 08:03:48.656030178iiifff1130.21.46413432.46413
    2022-08-01 08:00:0026242022-08-01 08:03:48.828030109iiifff5010.21.46413432.46413
    2022-08-01 08:00:0026242022-08-01 08:03:48.860030174iiifff5110.21.46413432.46413
    2022-08-01 08:00:0026242022-08-01 08:03:48.860030174iiifff2000.21.46413432.46413
    ..............................
    2022-08-02 08:00:002722022-08-02 08:09:47.363009930iiifff5000.21.20319732.2032
    2022-08-02 08:00:002722022-08-02 08:09:47.363009930iiifff5100.21.20319732.2032
    2022-08-02 08:00:002722022-08-02 08:09:47.363009930iiifff8000.21.20319732.2032
    2022-08-02 08:00:002722022-08-02 08:09:47.363009930iiifff8100.21.20319732.2032
    2022-08-02 08:33:160NaTNaNNaNNaNNaNNaNNaNNaN
    \n", + "

    8642 rows × 9 columns

    \n", + "
    " + ], + "text/plain": [ + " sample_count \\\n", + "experiment_name device_serial_number o_s_c_install_time chunk_start \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-08-01 08:00:00 2624 \n", + " 2022-08-01 08:00:00 2624 \n", + " 2022-08-01 08:00:00 2624 \n", + " 2022-08-01 08:00:00 2624 \n", + " 2022-08-01 08:00:00 2624 \n", + "... ... \n", + " 2022-08-02 08:00:00 272 \n", + " 2022-08-02 08:00:00 272 \n", + " 2022-08-02 08:00:00 272 \n", + " 2022-08-02 08:00:00 272 \n", + " 2022-08-02 08:33:16 0 \n", + "\n", + " timestamps \\\n", + "experiment_name device_serial_number o_s_c_install_time chunk_start \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-08-01 08:00:00 2022-08-01 08:03:48.654019833 \n", + " 2022-08-01 08:00:00 2022-08-01 08:03:48.656030178 \n", + " 2022-08-01 08:00:00 2022-08-01 08:03:48.828030109 \n", + " 2022-08-01 08:00:00 2022-08-01 08:03:48.860030174 \n", + " 2022-08-01 08:00:00 2022-08-01 08:03:48.860030174 \n", + "... ... \n", + " 2022-08-02 08:00:00 2022-08-02 08:09:47.363009930 \n", + " 2022-08-02 08:00:00 2022-08-02 08:09:47.363009930 \n", + " 2022-08-02 08:00:00 2022-08-02 08:09:47.363009930 \n", + " 2022-08-02 08:00:00 2022-08-02 08:09:47.363009930 \n", + " 2022-08-02 08:33:16 NaT \n", + "\n", + " typetag \\\n", + "experiment_name device_serial_number o_s_c_install_time chunk_start \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-08-01 08:00:00 iiifff \n", + " 2022-08-01 08:00:00 iiifff \n", + " 2022-08-01 08:00:00 iiifff \n", + " 2022-08-01 08:00:00 iiifff \n", + " 2022-08-01 08:00:00 iiifff \n", + "... ... \n", + " 2022-08-02 08:00:00 iiifff \n", + " 2022-08-02 08:00:00 iiifff \n", + " 2022-08-02 08:00:00 iiifff \n", + " 2022-08-02 08:00:00 iiifff \n", + " 2022-08-02 08:33:16 NaN \n", + "\n", + " wall_id \\\n", + "experiment_name device_serial_number o_s_c_install_time chunk_start \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-08-01 08:00:00 1 \n", + " 2022-08-01 08:00:00 1 \n", + " 2022-08-01 08:00:00 5 \n", + " 2022-08-01 08:00:00 5 \n", + " 2022-08-01 08:00:00 2 \n", + "... ... \n", + " 2022-08-02 08:00:00 5 \n", + " 2022-08-02 08:00:00 5 \n", + " 2022-08-02 08:00:00 8 \n", + " 2022-08-02 08:00:00 8 \n", + " 2022-08-02 08:33:16 NaN \n", + "\n", + " poke_id \\\n", + "experiment_name device_serial_number o_s_c_install_time chunk_start \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-08-01 08:00:00 0 \n", + " 2022-08-01 08:00:00 1 \n", + " 2022-08-01 08:00:00 0 \n", + " 2022-08-01 08:00:00 1 \n", + " 2022-08-01 08:00:00 0 \n", + "... ... \n", + " 2022-08-02 08:00:00 0 \n", + " 2022-08-02 08:00:00 1 \n", + " 2022-08-02 08:00:00 0 \n", + " 2022-08-02 08:00:00 1 \n", + " 2022-08-02 08:33:16 NaN \n", + "\n", + " reward \\\n", + "experiment_name device_serial_number o_s_c_install_time chunk_start \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-08-01 08:00:00 3 \n", + " 2022-08-01 08:00:00 3 \n", + " 2022-08-01 08:00:00 1 \n", + " 2022-08-01 08:00:00 1 \n", + " 2022-08-01 08:00:00 0 \n", + "... ... \n", + " 2022-08-02 08:00:00 0 \n", + " 2022-08-02 08:00:00 0 \n", + " 2022-08-02 08:00:00 0 \n", + " 2022-08-02 08:00:00 0 \n", + " 2022-08-02 08:33:16 NaN \n", + "\n", + " reward_interval \\\n", + "experiment_name device_serial_number o_s_c_install_time chunk_start \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-08-01 08:00:00 0.2 \n", + " 2022-08-01 08:00:00 0.2 \n", + " 2022-08-01 08:00:00 0.2 \n", + " 2022-08-01 08:00:00 0.2 \n", + " 2022-08-01 08:00:00 0.2 \n", + "... ... \n", + " 2022-08-02 08:00:00 0.2 \n", + " 2022-08-02 08:00:00 0.2 \n", + " 2022-08-02 08:00:00 0.2 \n", + " 2022-08-02 08:00:00 0.2 \n", + " 2022-08-02 08:33:16 NaN \n", + "\n", + " delay \\\n", + "experiment_name device_serial_number o_s_c_install_time chunk_start \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-08-01 08:00:00 1.464134 \n", + " 2022-08-01 08:00:00 1.464134 \n", + " 2022-08-01 08:00:00 1.464134 \n", + " 2022-08-01 08:00:00 1.464134 \n", + " 2022-08-01 08:00:00 1.464134 \n", + "... ... \n", + " 2022-08-02 08:00:00 1.203197 \n", + " 2022-08-02 08:00:00 1.203197 \n", + " 2022-08-02 08:00:00 1.203197 \n", + " 2022-08-02 08:00:00 1.203197 \n", + " 2022-08-02 08:33:16 NaN \n", + "\n", + " led_delay \n", + "experiment_name device_serial_number o_s_c_install_time chunk_start \n", + "oct1.0-r0 oct01_5 2022-07-04 12:19:42 2022-08-01 08:00:00 32.46413 \n", + " 2022-08-01 08:00:00 32.46413 \n", + " 2022-08-01 08:00:00 32.46413 \n", + " 2022-08-01 08:00:00 32.46413 \n", + " 2022-08-01 08:00:00 32.46413 \n", + "... ... \n", + " 2022-08-02 08:00:00 32.2032 \n", + " 2022-08-02 08:00:00 32.2032 \n", + " 2022-08-02 08:00:00 32.2032 \n", + " 2022-08-02 08:00:00 32.2032 \n", + " 2022-08-02 08:33:16 NaN \n", + "\n", + "[8642 rows x 9 columns]" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df_oscpoke.explode(['timestamps', 'typetag', 'wall_id', 'poke_id', 'reward', 'reward_interval', 'delay', 'led_delay'])" + ] + }, + { + "cell_type": "markdown", + "id": "82a9e6c6", + "metadata": {}, + "source": [ + "### Wall BreamBreak0" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "c583f629", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " Raw per-chunk BeamBreak0 data stream from Wall (auto-generated with aeon_mecha-unknown)\n", + "
    \n", + " \n", + " \n", + " \n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
    \n", + "

    experiment_name

    \n", + " e.g exp0-r0\n", + "
    \n", + "

    device_serial_number

    \n", + " \n", + "
    \n", + "

    wall_install_time

    \n", + " time of the wall placed and started operation at this position\n", + "
    \n", + "

    chunk_start

    \n", + " datetime of the start of a given acquisition chunk\n", + "
    \n", + "

    sample_count

    \n", + " number of data points acquired from this stream for a given chunk\n", + "
    \n", + "

    timestamps

    \n", + " (datetime) timestamps of BeamBreak0 data\n", + "
    \n", + "

    state

    \n", + " \n", + "
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-04 13:09:510=BLOB==BLOB=
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-05 09:00:000=BLOB==BLOB=
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-05 22:00:000=BLOB==BLOB=
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-06 10:00:000=BLOB==BLOB=
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-06 13:36:280=BLOB==BLOB=
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-07 12:00:0051=BLOB==BLOB=
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-07 13:00:004=BLOB==BLOB=
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-07 14:00:0022=BLOB==BLOB=
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-07 15:00:0071=BLOB==BLOB=
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-07 16:00:004=BLOB==BLOB=
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-07 16:15:010=BLOB==BLOB=
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-08 11:00:0012=BLOB==BLOB=
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-08 12:00:0040=BLOB==BLOB=
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-08 13:00:00152=BLOB==BLOB=
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-08 13:49:160=BLOB==BLOB=
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-08 14:00:0019=BLOB==BLOB=
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-11 13:00:0021=BLOB==BLOB=
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-11 14:00:0043=BLOB==BLOB=
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-11 15:00:0071=BLOB==BLOB=
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-11 15:22:310=BLOB==BLOB=
    \n", + "

    ...

    \n", + "

    Total: 4272

    \n", + " " + ], + "text/plain": [ + "*experiment_name *device_serial_number *wall_install_time *chunk_start sample_count timestamps state \n", + "+-----------------+ +----------------------+ +---------------------+ +---------------------+ +--------------+ +--------+ +--------+\n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-04 13:09:51 0 =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-05 09:00:00 0 =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-05 22:00:00 0 =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-06 10:00:00 0 =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-06 13:36:28 0 =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-07 12:00:00 51 =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-07 13:00:00 4 =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-07 14:00:00 22 =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-07 15:00:00 71 =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-07 16:00:00 4 =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-07 16:15:01 0 =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-08 11:00:00 12 =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-08 12:00:00 40 =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-08 13:00:00 152 =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-08 13:49:16 0 =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-08 14:00:00 19 =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-11 13:00:00 21 =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-11 14:00:00 43 =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-11 15:00:00 71 =BLOB= =BLOB= \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-11 15:22:31 0 =BLOB= =BLOB= \n", + " ...\n", + " (Total: 4272)" + ] + }, + "execution_count": 19, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "streams.WallBeamBreak0()" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "473b2aa5", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + "
    \n", + " \n", + " \n", + " \n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
    \n", + "

    experiment_name

    \n", + " e.g exp0-r0\n", + "
    \n", + "

    device_serial_number

    \n", + " \n", + "
    \n", + "

    wall_install_time

    \n", + " time of the wall placed and started operation at this position\n", + "
    \n", + "

    chunk_start

    \n", + " datetime of the start of a given acquisition chunk\n", + "
    \n", + "

    sample_count

    \n", + " number of data points acquired from this stream for a given chunk\n", + "
    \n", + "

    timestamps

    \n", + " (datetime) timestamps of BeamBreak0 data\n", + "
    \n", + "

    state

    \n", + " \n", + "
    \n", + "

    wall_name

    \n", + " \n", + "
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-04 13:09:510=BLOB==BLOB=Wall4
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-05 09:00:000=BLOB==BLOB=Wall4
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-05 22:00:000=BLOB==BLOB=Wall4
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-06 10:00:000=BLOB==BLOB=Wall4
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-06 13:36:280=BLOB==BLOB=Wall4
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-07 12:00:0051=BLOB==BLOB=Wall4
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-07 13:00:004=BLOB==BLOB=Wall4
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-07 14:00:0022=BLOB==BLOB=Wall4
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-07 15:00:0071=BLOB==BLOB=Wall4
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-07 16:00:004=BLOB==BLOB=Wall4
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-07 16:15:010=BLOB==BLOB=Wall4
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-08 11:00:0012=BLOB==BLOB=Wall4
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-08 12:00:0040=BLOB==BLOB=Wall4
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-08 13:00:00152=BLOB==BLOB=Wall4
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-08 13:49:160=BLOB==BLOB=Wall4
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-08 14:00:0019=BLOB==BLOB=Wall4
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-11 13:00:0021=BLOB==BLOB=Wall4
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-11 14:00:0043=BLOB==BLOB=Wall4
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-11 15:00:0071=BLOB==BLOB=Wall4
    oct1.0-r0oct01_102022-07-04 12:19:422022-07-11 15:22:310=BLOB==BLOB=Wall4
    \n", + "

    ...

    \n", + "

    Total: 534

    \n", + " " + ], + "text/plain": [ + "*experiment_name *device_serial_number *wall_install_time *chunk_start sample_count timestamps state wall_name \n", + "+-----------------+ +----------------------+ +---------------------+ +---------------------+ +--------------+ +--------+ +--------+ +-----------+\n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-04 13:09:51 0 =BLOB= =BLOB= Wall4 \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-05 09:00:00 0 =BLOB= =BLOB= Wall4 \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-05 22:00:00 0 =BLOB= =BLOB= Wall4 \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-06 10:00:00 0 =BLOB= =BLOB= Wall4 \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-06 13:36:28 0 =BLOB= =BLOB= Wall4 \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-07 12:00:00 51 =BLOB= =BLOB= Wall4 \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-07 13:00:00 4 =BLOB= =BLOB= Wall4 \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-07 14:00:00 22 =BLOB= =BLOB= Wall4 \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-07 15:00:00 71 =BLOB= =BLOB= Wall4 \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-07 16:00:00 4 =BLOB= =BLOB= Wall4 \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-07 16:15:01 0 =BLOB= =BLOB= Wall4 \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-08 11:00:00 12 =BLOB= =BLOB= Wall4 \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-08 12:00:00 40 =BLOB= =BLOB= Wall4 \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-08 13:00:00 152 =BLOB= =BLOB= Wall4 \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-08 13:49:16 0 =BLOB= =BLOB= Wall4 \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-08 14:00:00 19 =BLOB= =BLOB= Wall4 \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-11 13:00:00 21 =BLOB= =BLOB= Wall4 \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-11 14:00:00 43 =BLOB= =BLOB= Wall4 \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-11 15:00:00 71 =BLOB= =BLOB= Wall4 \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-07-11 15:22:31 0 =BLOB= =BLOB= Wall4 \n", + " ...\n", + " (Total: 534)" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "streams.WallBeamBreak0 * streams.ExperimentWall & 'wall_name = \"Wall4\"'" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "5017c2a4", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + "
    \n", + " \n", + " \n", + " \n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
    \n", + "

    experiment_name

    \n", + " e.g exp0-r0\n", + "
    \n", + "

    device_serial_number

    \n", + " \n", + "
    \n", + "

    wall_install_time

    \n", + " time of the wall placed and started operation at this position\n", + "
    \n", + "

    chunk_start

    \n", + " datetime of the start of a given acquisition chunk\n", + "
    \n", + "

    sample_count

    \n", + " number of data points acquired from this stream for a given chunk\n", + "
    \n", + "

    timestamps

    \n", + " (datetime) timestamps of BeamBreak0 data\n", + "
    \n", + "

    state

    \n", + " \n", + "
    \n", + "

    wall_name

    \n", + " \n", + "
    oct1.0-r0oct01_102022-07-04 12:19:422022-08-01 08:00:0044=BLOB==BLOB=Wall4
    oct1.0-r0oct01_102022-07-04 12:19:422022-08-01 09:00:0060=BLOB==BLOB=Wall4
    oct1.0-r0oct01_102022-07-04 12:19:422022-08-01 09:46:590=BLOB==BLOB=Wall4
    oct1.0-r0oct01_102022-07-04 12:19:422022-08-02 07:00:0018=BLOB==BLOB=Wall4
    oct1.0-r0oct01_102022-07-04 12:19:422022-08-02 08:00:000=BLOB==BLOB=Wall4
    oct1.0-r0oct01_102022-07-04 12:19:422022-08-02 08:33:160=BLOB==BLOB=Wall4
    \n", + " \n", + "

    Total: 6

    \n", + " " + ], + "text/plain": [ + "*experiment_name *device_serial_number *wall_install_time *chunk_start sample_count timestamps state wall_name \n", + "+-----------------+ +----------------------+ +---------------------+ +---------------------+ +--------------+ +--------+ +--------+ +-----------+\n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-08-01 08:00:00 44 =BLOB= =BLOB= Wall4 \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-08-01 09:00:00 60 =BLOB= =BLOB= Wall4 \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-08-01 09:46:59 0 =BLOB= =BLOB= Wall4 \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-08-02 07:00:00 18 =BLOB= =BLOB= Wall4 \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-08-02 08:00:00 0 =BLOB= =BLOB= Wall4 \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-08-02 08:33:16 0 =BLOB= =BLOB= Wall4 \n", + " (Total: 6)" + ] + }, + "execution_count": 21, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "(streams.WallBeamBreak0 * streams.ExperimentWall\n", + " & 'wall_name = \"Wall4\"'\n", + " & 'chunk_start BETWEEN \"2022-08-01\" AND \"2022-08-03\"')" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "a7b2d027", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    sample_counttimestampsstatewall_name
    experiment_namedevice_serial_numberwall_install_timechunk_start
    oct1.0-r0oct01_102022-07-04 12:19:422022-08-01 08:00:0044[2022-08-01T08:05:37.040832043, 2022-08-01T08:...[True, False, True, False, True, False, True, ...Wall4
    2022-08-01 09:00:0060[2022-08-01T09:01:05.836512089, 2022-08-01T09:...[True, False, True, False, True, False, True, ...Wall4
    2022-08-01 09:46:590[][]Wall4
    2022-08-02 07:00:0018[2022-08-02T07:03:49.623007774, 2022-08-02T07:...[False, False, True, False, True, False, True,...Wall4
    2022-08-02 08:00:000[][]Wall4
    2022-08-02 08:33:160[][]Wall4
    \n", + "
    " + ], + "text/plain": [ + " sample_count \\\n", + "experiment_name device_serial_number wall_install_time chunk_start \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-08-01 08:00:00 44 \n", + " 2022-08-01 09:00:00 60 \n", + " 2022-08-01 09:46:59 0 \n", + " 2022-08-02 07:00:00 18 \n", + " 2022-08-02 08:00:00 0 \n", + " 2022-08-02 08:33:16 0 \n", + "\n", + " timestamps \\\n", + "experiment_name device_serial_number wall_install_time chunk_start \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-08-01 08:00:00 [2022-08-01T08:05:37.040832043, 2022-08-01T08:... \n", + " 2022-08-01 09:00:00 [2022-08-01T09:01:05.836512089, 2022-08-01T09:... \n", + " 2022-08-01 09:46:59 [] \n", + " 2022-08-02 07:00:00 [2022-08-02T07:03:49.623007774, 2022-08-02T07:... \n", + " 2022-08-02 08:00:00 [] \n", + " 2022-08-02 08:33:16 [] \n", + "\n", + " state \\\n", + "experiment_name device_serial_number wall_install_time chunk_start \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-08-01 08:00:00 [True, False, True, False, True, False, True, ... \n", + " 2022-08-01 09:00:00 [True, False, True, False, True, False, True, ... \n", + " 2022-08-01 09:46:59 [] \n", + " 2022-08-02 07:00:00 [False, False, True, False, True, False, True,... \n", + " 2022-08-02 08:00:00 [] \n", + " 2022-08-02 08:33:16 [] \n", + "\n", + " wall_name \n", + "experiment_name device_serial_number wall_install_time chunk_start \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-08-01 08:00:00 Wall4 \n", + " 2022-08-01 09:00:00 Wall4 \n", + " 2022-08-01 09:46:59 Wall4 \n", + " 2022-08-02 07:00:00 Wall4 \n", + " 2022-08-02 08:00:00 Wall4 \n", + " 2022-08-02 08:33:16 Wall4 " + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df_wall4_beambreak0 = (streams.WallBeamBreak0 * streams.ExperimentWall\n", + " & 'wall_name = \"Wall4\"'\n", + " & 'chunk_start BETWEEN \"2022-08-01\" AND \"2022-08-03\"').fetch(format='frame')\n", + "df_wall4_beambreak0" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "1bd6af9b", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    sample_counttimestampsstatewall_name
    experiment_namedevice_serial_numberwall_install_timechunk_start
    oct1.0-r0oct01_102022-07-04 12:19:422022-08-01 08:00:00442022-08-01 08:05:37.040832043TrueWall4
    2022-08-01 08:00:00442022-08-01 08:05:37.196159840FalseWall4
    2022-08-01 08:00:00442022-08-01 08:05:37.473504066TrueWall4
    2022-08-01 08:00:00442022-08-01 08:05:38.539423943FalseWall4
    2022-08-01 08:00:00442022-08-01 08:07:39.035744190TrueWall4
    ...............
    2022-08-02 07:00:00182022-08-02 07:37:16.132671832FalseWall4
    2022-08-02 07:00:00182022-08-02 07:40:44.873568058TrueWall4
    2022-08-02 07:00:00182022-08-02 07:40:45.047200203FalseWall4
    2022-08-02 08:00:000NaTNaNWall4
    2022-08-02 08:33:160NaTNaNWall4
    \n", + "

    125 rows × 4 columns

    \n", + "
    " + ], + "text/plain": [ + " sample_count \\\n", + "experiment_name device_serial_number wall_install_time chunk_start \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-08-01 08:00:00 44 \n", + " 2022-08-01 08:00:00 44 \n", + " 2022-08-01 08:00:00 44 \n", + " 2022-08-01 08:00:00 44 \n", + " 2022-08-01 08:00:00 44 \n", + "... ... \n", + " 2022-08-02 07:00:00 18 \n", + " 2022-08-02 07:00:00 18 \n", + " 2022-08-02 07:00:00 18 \n", + " 2022-08-02 08:00:00 0 \n", + " 2022-08-02 08:33:16 0 \n", + "\n", + " timestamps \\\n", + "experiment_name device_serial_number wall_install_time chunk_start \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-08-01 08:00:00 2022-08-01 08:05:37.040832043 \n", + " 2022-08-01 08:00:00 2022-08-01 08:05:37.196159840 \n", + " 2022-08-01 08:00:00 2022-08-01 08:05:37.473504066 \n", + " 2022-08-01 08:00:00 2022-08-01 08:05:38.539423943 \n", + " 2022-08-01 08:00:00 2022-08-01 08:07:39.035744190 \n", + "... ... \n", + " 2022-08-02 07:00:00 2022-08-02 07:37:16.132671832 \n", + " 2022-08-02 07:00:00 2022-08-02 07:40:44.873568058 \n", + " 2022-08-02 07:00:00 2022-08-02 07:40:45.047200203 \n", + " 2022-08-02 08:00:00 NaT \n", + " 2022-08-02 08:33:16 NaT \n", + "\n", + " state \\\n", + "experiment_name device_serial_number wall_install_time chunk_start \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-08-01 08:00:00 True \n", + " 2022-08-01 08:00:00 False \n", + " 2022-08-01 08:00:00 True \n", + " 2022-08-01 08:00:00 False \n", + " 2022-08-01 08:00:00 True \n", + "... ... \n", + " 2022-08-02 07:00:00 False \n", + " 2022-08-02 07:00:00 True \n", + " 2022-08-02 07:00:00 False \n", + " 2022-08-02 08:00:00 NaN \n", + " 2022-08-02 08:33:16 NaN \n", + "\n", + " wall_name \n", + "experiment_name device_serial_number wall_install_time chunk_start \n", + "oct1.0-r0 oct01_10 2022-07-04 12:19:42 2022-08-01 08:00:00 Wall4 \n", + " 2022-08-01 08:00:00 Wall4 \n", + " 2022-08-01 08:00:00 Wall4 \n", + " 2022-08-01 08:00:00 Wall4 \n", + " 2022-08-01 08:00:00 Wall4 \n", + "... ... \n", + " 2022-08-02 07:00:00 Wall4 \n", + " 2022-08-02 07:00:00 Wall4 \n", + " 2022-08-02 07:00:00 Wall4 \n", + " 2022-08-02 08:00:00 Wall4 \n", + " 2022-08-02 08:33:16 Wall4 \n", + "\n", + "[125 rows x 4 columns]" + ] + }, + "execution_count": 23, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df_wall4_beambreak0.explode(['timestamps', 'state'])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17b70cfb", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From eb64703ccead7521c6dca2cd1abd3e5812763162 Mon Sep 17 00:00:00 2001 From: lochhh Date: Thu, 24 Nov 2022 12:30:06 +0000 Subject: [PATCH 144/489] Add ForagingBout table + plotting functions --- aeon/dj_pipeline/analysis/visit_analysis.py | 143 ++++- aeon/dj_pipeline/report.py | 69 ++- aeon/dj_pipeline/utils/plotting.py | 646 ++++++++++++++++---- 3 files changed, 720 insertions(+), 138 deletions(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 2438ff4e..62d024d4 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -7,7 +7,6 @@ import pandas as pd from .. import acquisition, dict_to_uuid, get_schema_name, lab, qc, tracking -from ..acquisition import Chunk, ExperimentLog from .visit import Visit, VisitEnd logger = dj.logger @@ -273,7 +272,7 @@ def make(self, key): visit_dates = pd.date_range( start=pd.Timestamp(visit_start.date()), end=pd.Timestamp(visit_end.date()) ) - maintenance_period = _get_maintenance_periods( + maintenance_period = get_maintenance_periods( key["experiment_name"], visit_start, visit_end ) @@ -295,7 +294,7 @@ def make(self, key): subject=key["subject"], start=day_start, end=day_end ) # filter out maintenance period based on logs - position = _filter_out_maintenance_periods( + position = filter_out_maintenance_periods( position, maintenance_period, day_end ) @@ -359,7 +358,7 @@ def make(self, key): using_aeon_io=True, ) # filter out maintenance period based on logs - wheel_data = _filter_out_maintenance_periods( + wheel_data = filter_out_maintenance_periods( wheel_data, maintenance_period, day_end ) patch_position = ( @@ -428,7 +427,7 @@ def make(self, key): visit_dates = pd.date_range( start=pd.Timestamp(visit_start.date()), end=pd.Timestamp(visit_end.date()) ) - maintenance_period = _get_maintenance_periods( + maintenance_period = get_maintenance_periods( key["experiment_name"], visit_start, visit_end ) @@ -451,7 +450,7 @@ def make(self, key): subject=key["subject"], start=day_start, end=day_end ) # filter out maintenance period based on logs - position = _filter_out_maintenance_periods( + position = filter_out_maintenance_periods( position, maintenance_period, day_end ) # filter for objects of the correct size @@ -491,7 +490,7 @@ def make(self, key): & f'event_time BETWEEN "{day_start}" AND "{day_end}"' ).fetch("event_time") # filter out maintenance period based on logs - pellet_events = _filter_out_maintenance_periods( + pellet_events = filter_out_maintenance_periods( pd.DataFrame(pellet_events).set_index(0), maintenance_period, day_end, @@ -509,7 +508,7 @@ def make(self, key): using_aeon_io=True, ) # filter out maintenance period based on logs - wheel_data = _filter_out_maintenance_periods( + wheel_data = filter_out_maintenance_periods( wheel_data, maintenance_period, day_end ) @@ -546,10 +545,128 @@ def make(self, key): self.FoodPatch.insert(food_patch_statistics) -def _get_maintenance_periods(experiment_name, start, end): - # get logs from ExperimentLog +@schema +class VisitForagingBout(dj.Computed): + definition = """ # A time period spanning the time when the animal enters a food patch and moves the wheel to when it leaves the food patch + -> Visit + -> acquisition.ExperimentFoodPatch + bout_start: datetime(6) # start time of bout + --- + bout_end: datetime(6) # end time of bout + bout_duration: float # (seconds) + wheel_distance_travelled: float # (cm) + pellet_count: int # number of pellet trigger events + """ + + # Work on 24/7 experiments + key_source = ( + Visit + & VisitSummary + & (VisitEnd & f"visit_duration > 24") + & f"experiment_name= 'exp0.2-r0'" + ) * acquisition.ExperimentFoodPatch + + def make(self, key): + visit_start, visit_end = (VisitEnd & key).fetch1("visit_start", "visit_end") + + # get in_patch timestamps + food_patch_description = (acquisition.ExperimentFoodPatch & key).fetch1( + "food_patch_description" + ) + in_patch_times = np.concatenate( + ( + VisitTimeDistribution.FoodPatch * acquisition.ExperimentFoodPatch & key + ).fetch("in_patch", order_by="visit_date") + ) + maintenance_period = get_maintenance_periods( + key["experiment_name"], visit_start, visit_end + ) + in_patch_times = filter_out_maintenance_periods( + pd.DataFrame( + [[food_patch_description]] * len(in_patch_times), + columns=["region"], + index=in_patch_times, + ), + maintenance_period, + visit_end, + True, + ).index.values + + # get pellet data + chunk_keys = ( + acquisition.Chunk + & f'chunk_start BETWEEN "{pd.Timestamp(visit_start).floor("H")}" AND "{visit_end}"' + ).fetch("KEY") + patch = ( + ( + dj.U("event_time", "event_type") + & ( + acquisition.FoodPatchEvent * acquisition.EventType + & chunk_keys + & key + & f'event_time BETWEEN "{visit_start}" AND "{visit_end}"' + & 'event_type = "TriggerPellet"' + ) + ) + .fetch(order_by="event_time", format="frame") + .reset_index() + .set_index("event_time") + ) + # TODO: handle multiple retries of pellet delivery + maintenance_period = get_maintenance_periods( + key["experiment_name"], visit_start, visit_end + ) + patch = filter_out_maintenance_periods( + patch, maintenance_period, visit_end, True + ) + + if len(in_patch_times): + change_ind = ( + np.where((np.diff(in_patch_times) / 1e6) > np.timedelta64(20))[0] + 1 + ) # timestamp index where state changes + + for i in range(len(change_ind) + 1): + if i == 0: + ts_array = in_patch_times[: change_ind[i]] + elif i == len(change_ind): + ts_array = in_patch_times[change_ind[i - 1] :] + else: + ts_array = in_patch_times[change_ind[i - 1] : change_ind[i]] + + wheel_start, wheel_end = ts_array[0], ts_array[-1] + if wheel_start > wheel_end: # skip if timestamps were misaligned + continue + + wheel_data = acquisition.FoodPatchWheel.get_wheel_data( + experiment_name=key["experiment_name"], + start=wheel_start, + end=wheel_end, + patch_name=food_patch_description, + using_aeon_io=True, + ) + maintenance_period = get_maintenance_periods( + key["experiment_name"], visit_start, visit_end + ) + wheel_data = filter_out_maintenance_periods( + wheel_data, maintenance_period, visit_end, True + ) + self.insert1( + { + **key, + "bout_start": ts_array[0], + "bout_end": ts_array[-1], + "bout_duration": (ts_array[-1] - ts_array[0]) + / np.timedelta64(1, "s"), + "wheel_distance_travelled": wheel_data.distance_travelled[-1], + "pellet_count": len(patch.loc[wheel_start:wheel_end]), + } + ) + + +def get_maintenance_periods(experiment_name, start, end): + # get logs from acquisition.ExperimentLog log_df = ( - ExperimentLog.Message.proj("message") + acquisition.ExperimentLog.Message.proj("message") & {"experiment_name": experiment_name} & 'message IN ("Maintenance", "Experiment")' & f'message_time BETWEEN "{start}" AND "{end}"' @@ -589,9 +706,7 @@ def _get_maintenance_periods(experiment_name, start, end): ) # queue object. pop out from left after use -def _filter_out_maintenance_periods( - data_df, maintenance_period, end_time, dropna=False -): +def filter_out_maintenance_periods(data_df, maintenance_period, end_time, dropna=False): while maintenance_period: (maintenance_start, maintenance_end) = maintenance_period[0] if end_time < maintenance_start: # no more maintenance for this date diff --git a/aeon/dj_pipeline/report.py b/aeon/dj_pipeline/report.py index 153729b6..b6453148 100644 --- a/aeon/dj_pipeline/report.py +++ b/aeon/dj_pipeline/report.py @@ -452,7 +452,12 @@ class VisitDailySummaryPlot(dj.Computed): pellet_count_plotly: longblob # Dictionary storing the plotly object (from fig.to_plotly_json()) wheel_distance_travelled_plotly: longblob total_distance_travelled_plotly: longblob - foraging_bouts_plotly: longblob + weight_patch_plotly: longblob + foraging_bouts_plotly: longblob + foraging_bouts_pellet_count_plotly: longblob + foraging_bouts_duration_plotly: longblob + region_time_fraction_daily_plotly: longblob + region_time_fraction_hourly_plotly: longblob """ key_source = ( @@ -464,12 +469,17 @@ class VisitDailySummaryPlot(dj.Computed): def make(self, key): from aeon.dj_pipeline.utils.plotting import ( - plot_foraging_bouts, + plot_foraging_bouts_count, + plot_foraging_bouts_distribution, plot_visit_daily_summary, + plot_visit_time_distribution, + plot_weight_patch_data, ) - wheel_dist_crit = 1 # in cm (minimum wheel distance travelled) + # bout criteria + min_wheel_dist = 1 # in cm (minimum wheel distance travelled) min_bout_duration = 1 # in seconds (minimum foraging bout duration) + min_pellet_count = 3 # minimum number of pellets fig = plot_visit_daily_summary( key, @@ -491,13 +501,51 @@ def make(self, key): ) fig_total_dist = json.loads(fig.to_json()) - fig = plot_foraging_bouts( + fig = plot_weight_patch_data( key, - wheel_dist_crit=wheel_dist_crit, + ) + fig_weight_patch = json.loads(fig.to_json()) + + fig = plot_foraging_bouts_count( + key, + per_food_patch=True, + min_bout_duration=min_bout_duration, + min_pellet_count=min_pellet_count, + min_wheel_dist=min_wheel_dist, + ) + fig_foraging_bouts = json.loads(fig.to_json()) + + fig = plot_foraging_bouts_distribution( + key, + "pellet_count", + per_food_patch=True, min_bout_duration=min_bout_duration, - using_aeon_io=False, + min_pellet_count=min_pellet_count, + min_wheel_dist=min_wheel_dist, + ) + fig_foraging_bouts_pellet_count = json.loads(fig.to_json()) + + fig = plot_foraging_bouts_distribution( + key, + "bout_duration", + per_food_patch=False, + min_bout_duration=min_bout_duration, + min_pellet_count=min_pellet_count, + min_wheel_dist=min_wheel_dist, + ) + fig_foraging_bouts_duration = json.loads(fig.to_json()) + + fig = plot_visit_time_distribution( + key, + freq="D", + ) + fig_region_time_fraction_daily = json.loads(fig.to_json()) + + fig = plot_visit_time_distribution( + key, + freq="H", ) - fig_foraginng_bouts = json.loads(fig.to_json()) + fig_region_time_fraction_hourly = json.loads(fig.to_json()) self.insert1( { @@ -505,7 +553,12 @@ def make(self, key): "pellet_count_plotly": fig_pellet, "wheel_distance_travelled_plotly": fig_wheel_dist, "total_distance_travelled_plotly": fig_total_dist, - "foraging_bouts_plotly": fig_foraginng_bouts, + "weight_patch_plotly": fig_weight_patch, + "foraging_bouts_plotly": fig_foraging_bouts, + "foraging_bouts_pellet_count_plotly": fig_foraging_bouts_pellet_count, + "foraging_bouts_duration_plotly": fig_foraging_bouts_duration, + "region_time_fraction_daily_plotly": fig_region_time_fraction_daily, + "region_time_fraction_hourly_plotly": fig_region_time_fraction_hourly, } ) diff --git a/aeon/dj_pipeline/utils/plotting.py b/aeon/dj_pipeline/utils/plotting.py index 7d303a2b..96f25eff 100644 --- a/aeon/dj_pipeline/utils/plotting.py +++ b/aeon/dj_pipeline/utils/plotting.py @@ -1,14 +1,21 @@ import datajoint as dj -import matplotlib.pyplot as plt import numpy as np import pandas as pd import plotly.express as px +import plotly.graph_objects as go import plotly.io as pio -import seaborn as sns from aeon.dj_pipeline import acquisition, analysis, lab from aeon.dj_pipeline.analysis.visit import Visit, VisitEnd -from aeon.dj_pipeline.analysis.visit_analysis import VisitSummary, VisitTimeDistribution +from aeon.dj_pipeline.analysis.visit_analysis import ( + VisitSummary, + VisitTimeDistribution, + VisitForagingBout, + get_maintenance_periods, + filter_out_maintenance_periods, +) +from plotly.subplots import make_subplots +from scipy.signal import savgol_filter # pio.renderers.default = 'png' # pio.orca.config.executable = '~/.conda/envs/aeon_env/bin/orca' @@ -222,11 +229,6 @@ def plot_visit_daily_summary( >>> fig = plot_visit_daily_summary(visit_key, attr='total_distance_travelled') """ - subject, visit_start = ( - visit_key["subject"], - visit_key["visit_start"], - ) - per_food_patch = not attr.startswith("total") color = "food_patch_description" if per_food_patch else None @@ -246,180 +248,592 @@ def plot_visit_daily_summary( if not attr.startswith("total"): attr = "total_" + attr - visit_per_day_df["subject"] = "_".join([subject, visit_start.strftime("%m%d")]) visit_per_day_df["day"] = ( visit_per_day_df["visit_date"] - visit_per_day_df["visit_date"].min() ) visit_per_day_df["day"] = visit_per_day_df["day"].dt.days - fig = px.line( + fig = px.bar( visit_per_day_df, - x="day", + x="visit_date", y=attr, color=color, - markers=True, - labels={attr: attr.replace("_", " ")}, + labels={ + attr: attr.replace("_", " "), + "visit_date": "date", + }, hover_name="visit_date", - hover_data=["visit_date"], + hover_data=["day"], width=700, height=400, template="simple_white", - title=visit_per_day_df["subject"][0], + title=visit_key["subject"] + "
    " + attr.replace("_", " ") + " (daily)", ) - fig.update_traces(mode="markers+lines", hovertemplate=None) + fig.update_layout( - legend_title="", hovermode="x", yaxis_tickformat="digits", yaxis_range=[0, None] + legend=dict( + title="", orientation="h", yanchor="bottom", y=0.98, xanchor="right", x=1 + ), + hovermode="x", + yaxis_tickformat="digits", + yaxis_range=[0, None], ) return fig -def plot_foraging_bouts( +def plot_foraging_bouts_count( visit_key, - wheel_dist_crit=None, - min_bout_duration=None, - using_aeon_io=False, + freq="D", + per_food_patch=False, + min_bout_duration=0, + min_pellet_count=0, + min_wheel_dist=0, ): """plot the number of foraging bouts per visit Args: - visit_key (dict) : Key from the VisitTimeDistribution table - wheel_dist_crit (int) : Minimum wheel distance travelled (in cm) - min_bout_duration (int) : Minimum foraging bout duration (in seconds) - using_aeon_io (bool) : Use aeon api to calculate wheel distance. Otherwise use datajoint tables. Defaults to False. + visit_key (dict): Key from the Visit table + freq (str): Frequency level at which the visit time distribution is plotted. Corresponds to pandas freq. + per_food_patch (bool, optional): Separately plot results from different food patches. Defaults to False. + min_bout_duration (int): Minimum foraging bout duration (in seconds) + min_pellet_count (int): Minimum number of pellets + min_wheel_dist (int): Minimum wheel distance travelled (in cm) Returns: fig: Figure object Examples: - >>> fig = plot_foraging_bouts(visit_key, wheel_dist_crit=1, min_bout_duration=1) + >>> fig = plot_foraging_bouts_count(visit_key, freq="D", per_food_patch=True, min_bout_duration=1, min_wheel_dist=1) """ - subject, visit_start = ( - visit_key["subject"], - visit_key["visit_start"], + # Get all foraging bouts for the visit + foraging_bouts = ( + ( + dj.U( + "bout_start", + "bout_end", + "bout_duration", + "food_patch_description", + "pellet_count", + "wheel_distance_travelled", + ) + & (VisitForagingBout * acquisition.ExperimentFoodPatch & visit_key) + ) + .fetch(order_by="bout_start", format="frame") + .reset_index() + ) + + # Apply filter + foraging_bouts = foraging_bouts[ + (foraging_bouts["bout_duration"] >= min_bout_duration) + & (foraging_bouts["pellet_count"] >= min_pellet_count) + & (foraging_bouts["wheel_distance_travelled"] >= min_wheel_dist) + ] + + group_by_attrs = ( + [foraging_bouts["bout_start"].dt.floor(freq), "food_patch_description"] + if per_food_patch + else [foraging_bouts["bout_start"].dt.floor("D")] ) - visit_per_day_df = ( + foraging_bouts_count = ( + foraging_bouts.groupby(group_by_attrs).size().reset_index(name="count") + ) + + visit_start = (VisitEnd & visit_key).fetch1("visit_start") + foraging_bouts_count["day"] = ( + foraging_bouts_count["bout_start"].dt.date - visit_start.date() + ).dt.days + + fig = px.bar( + foraging_bouts_count, + x="bout_start", + y="count", + color="food_patch_description" if per_food_patch else None, + labels={ + "bout_start": "date" if freq == "D" else "time", + }, + hover_data=["day"], + width=700, + height=400, + template="simple_white", + title=visit_key["subject"] + + "
    Foraging bouts: count (freq='" + + freq + + "')", + ) + + fig.update_layout( + legend=dict( + title="", orientation="h", yanchor="bottom", y=0.98, xanchor="right", x=1 + ), + hovermode="x", + yaxis_tickformat="digits", + yaxis_range=[0, None], + ) + + return fig + + +def plot_foraging_bouts_distribution( + visit_key, + attr, + per_food_patch=False, + min_bout_duration=0, + min_pellet_count=0, + min_wheel_dist=0, +): + """plot distribution of foraging bout attributes + + Args: + visit_key (dict): Key from the Visit table + attr (str): Options include: pellet_count, bout_duration, wheel_distance_travelled + per_food_patch (bool, optional): Separately plot results from different food patches. Defaults to False. + min_bout_duration (int): Minimum foraging bout duration (in seconds) + min_pellet_count (int): Minimum number of pellets + min_wheel_dist (int): Minimum wheel distance travelled (in cm) + + Returns: + fig: Figure object + + Examples: + >>> fig = plot_foraging_bouts_distribution(visit_key, "pellet_count", True, 0, 3, 0) + >>> fig = plot_foraging_bouts_distribution(visit_key, "wheel_distance_travelled") + >>> fig = plot_foraging_bouts_distribution(visit_key, "bout_duration") + """ + + # Get all foraging bouts for the visit + foraging_bouts = ( ( - (VisitTimeDistribution.FoodPatch & visit_key) - * acquisition.ExperimentFoodPatch.proj("food_patch_description") + dj.U( + "bout_start", + "bout_end", + "bout_duration", + "food_patch_description", + "pellet_count", + "wheel_distance_travelled", + ) + & (VisitForagingBout * acquisition.ExperimentFoodPatch & visit_key) ) - .fetch(format="frame") + .fetch(order_by="bout_start", format="frame") .reset_index() ) - visit_per_day_df["subject"] = "_".join([subject, visit_start.strftime("%m%d")]) - visit_per_day_df["day"] = ( - visit_per_day_df["visit_date"] - visit_per_day_df["visit_date"].min() + # Apply filter + foraging_bouts = foraging_bouts[ + (foraging_bouts["bout_duration"] >= min_bout_duration) + & (foraging_bouts["pellet_count"] >= min_pellet_count) + & (foraging_bouts["wheel_distance_travelled"] >= min_wheel_dist) + ] + + fig = go.Figure() + if per_food_patch: + patch_names = (acquisition.ExperimentFoodPatch & visit_key).fetch( + "food_patch_description" + ) + for patch in patch_names: + bouts = foraging_bouts[foraging_bouts["food_patch_description"] == patch] + fig.add_trace( + go.Violin( + x=bouts["bout_start"].dt.date, + y=bouts[attr], + legendgroup=patch, + scalegroup=patch, + name=patch, + side="negative" if patch == "Patch1" else "positive", + ) + ) + else: + fig.add_trace( + go.Violin( + x=foraging_bouts["bout_start"].dt.date, + y=foraging_bouts[attr], + ) + ) + + fig.update_traces( + box_visible=True, + meanline_visible=True, ) - visit_per_day_df["day"] = visit_per_day_df["day"].dt.days - visit_per_day_df["foraging_bouts"] = visit_per_day_df.apply( - _get_foraging_bouts, - args=(wheel_dist_crit, min_bout_duration, using_aeon_io), - axis=1, + + fig.update_layout( + title_text=visit_key["subject"] + + "
    Foraging bouts: " + + attr.replace("_", " "), + xaxis_title="date", + yaxis_title=attr.replace("_", " "), + violingap=0, + violingroupgap=0, + violinmode="overlay", + width=700, + height=400, + template="simple_white", + legend=dict(orientation="h", yanchor="bottom", y=1, xanchor="right", x=1), ) - fig = px.line( - visit_per_day_df, - x="day", - y="foraging_bouts", - color="food_patch_description", - markers=True, - labels={"foraging_bouts": "foraging_bouts".replace("_", " ")}, - hover_name="visit_date", - hover_data=["visit_date"], + return fig + + +def plot_visit_time_distribution(visit_key, freq="D"): + """plot fraction of time spent in each region per visit + + Args: + visit_key (dict): Key from the Visit table + freq (str): Frequency level at which the visit time distribution is plotted. Corresponds to pandas freq. + + Returns: + fig: Figure object + + Examples: + >>> fig = plot_visit_time_distribution(visit_key, freq="D") + >>> fig = plot_visit_time_distribution(visit_key, freq="H") + """ + + region = _get_region_data(visit_key) + + # Compute time spent per region + time_spent = ( + region.groupby([region.index.floor(freq), "region"]) + .size() + .reset_index(name="count") + ) + time_spent["time_fraction"] = time_spent["count"] / time_spent.groupby( + "timestamps" + )["count"].transform("sum") + time_spent["day"] = ( + time_spent["timestamps"] - time_spent["timestamps"].min() + ).dt.days + + fig = px.bar( + time_spent, + x="timestamps", + y="time_fraction", + color="region", + hover_data=["day"], + labels={ + "time_fraction": "time fraction", + "timestamps": "date" if freq == "D" else "time", + }, + title=visit_key["subject"] + + "
    Fraction of time spent in each region (freq='" + + freq + + "')", width=700, height=400, template="simple_white", - title=visit_per_day_df["subject"][0], ) - fig.update_traces(mode="markers+lines", hovertemplate=None) + fig.update_layout( - legend_title="", hovermode="x", yaxis_tickformat="digits", yaxis_range=[0, None] + hovermode="x", + yaxis_tickformat="digits", + yaxis_range=[0, None], + legend=dict( + title="", orientation="h", yanchor="bottom", y=0.98, xanchor="right", x=1 + ), ) return fig -def _get_foraging_bouts( - visit_per_day_row, - wheel_dist_crit=None, - min_bout_duration=None, - using_aeon_io=False, +def _get_region_data( + visit_key, attrs=["in_nest", "in_arena", "in_corridor", "in_patch"] ): - """A function that calculates the number of foraging bouts. Works on this table query + """Retrieve region data from VisitTimeDistribution tables. + + Args: + visit_key (dict): Key from the Visit table + attrs (list, optional): List of column names (in VisitTimeDistribution tables) to retrieve. Defaults to all. + + Returns: + region (pd.DataFrame): Timestamped region info + """ + + visit_start, visit_end = (VisitEnd & visit_key).fetch1("visit_start", "visit_end") + region = pd.DataFrame() + + # Get region timestamps + for attr in attrs: + if attr == "in_nest": # Nest + in_nest = np.concatenate( + (VisitTimeDistribution.Nest & visit_key).fetch( + attr, order_by="visit_date" + ) + ) + region = pd.concat( + [ + region, + pd.DataFrame( + [[attr.replace("in_", "")]] * len(in_nest), + columns=["region"], + index=in_nest, + ), + ] + ) + elif attr == "in_patch": # Food patch + # Find all patches + patches = np.unique( + ( + VisitTimeDistribution.FoodPatch * acquisition.ExperimentFoodPatch + & visit_key + ).fetch("food_patch_description") + ) + for patch in patches: + in_patch = np.concatenate( + ( + VisitTimeDistribution.FoodPatch + * acquisition.ExperimentFoodPatch + & visit_key + & f"food_patch_description = '{patch}'" + ).fetch("in_patch", order_by="visit_date") + ) + region = pd.concat( + [ + region, + pd.DataFrame( + [[patch.lower()]] * len(in_patch), + columns=["region"], + index=in_patch, + ), + ] + ) + else: # corridor, arena + in_other = np.concatenate( + (VisitTimeDistribution & visit_key).fetch(attr, order_by="visit_date") + ) + region = pd.concat( + [ + region, + pd.DataFrame( + [[attr.replace("in_", "")]] * len(in_other), + columns=["region"], + index=in_other, + ), + ] + ) + region = region.sort_index().rename_axis("timestamps") - (VisitTimeDistribution.FoodPatch & visit_key) - * acquisition.ExperimentFoodPatch.proj("food_patch_description") + # Exclude data during maintenance + maintenance_period = get_maintenance_periods( + visit_key["experiment_name"], visit_start, visit_end + ) + region = filter_out_maintenance_periods( + region, maintenance_period, visit_end, dropna=True + ) - This will iterate over this table entries and store results in a new column ('foraging_bouts') + return region + + +def plot_weight_patch_data( + visit_key, freq="H", smooth_weight=True, min_weight=0, max_weight=35 +): + """plot subject weight and patch data (pellet trigger count) per visit Args: - visit_per_day_row (pd.DataFrame): A single row of the pandas dataframe + visit_key (dict): Key from the Visit table + freq (str): Frequency level at which patch data is plotted. Corresponds to pandas freq. + smooth_weight (bool, optional): Apply savgol filter to subject weight. Defaults to True + min_weight (bool, optional): Lower bound of subject weight. Defaults to 0 + max_weight (bool, optional): Upper bound of subject weight. Defaults to 35 Returns: - nb_bouts (int): Number of foraging bouts + fig: Figure object + + Examples: + >>> fig = plot_weight_patch_data(visit_key, freq="H", smooth_weight=True) + >>> fig = plot_weight_patch_data(visit_key, freq="D") """ - # Get number of foraging bouts - nb_bouts = 0 + subject_weight = _get_filtered_subject_weight( + visit_key, smooth_weight, min_weight, max_weight + ) - in_patch = visit_per_day_row["in_patch"] - if np.size(in_patch) == 0: # no food patch position timestamps - return nb_bouts + # Count pellet trigger per patch per day/hour/... + patch = _get_patch_data(visit_key) + patch_summary = ( + patch.groupby( + [ + # group by freq and patch + patch.index.to_series().dt.floor(freq), + "food_patch_description", + ] + ) + .count() + .unstack(fill_value=0) # fill none count with 0s + .stack() + .reset_index() + ) - change_ind = ( - np.where((np.diff(in_patch) / 1e6) > np.timedelta64(20))[0] + 1 - ) # timestamp index where state changes + # Get patch names + patch_names = patch["food_patch_description"].unique() + patch_names.sort() + + fig = make_subplots(specs=[[{"secondary_y": True}]]) + + # Add trace for each patch + for p in patch_names: + fig.add_trace( + go.Bar( + x=patch_summary[patch_summary["food_patch_description"] == p][ + "event_time" + ], + y=patch_summary[patch_summary["food_patch_description"] == p][ + "event_type" + ], + name=p, + ), + secondary_y=False, + ) - if np.size(change_ind) == 0: # one contiguous block + # Add subject weight trace + fig.add_trace( + go.Scatter( + x=subject_weight.index, + y=subject_weight["weight_subject"], + mode="lines+markers", + opacity=0.5, + name="subject weight", + marker=dict( + size=3, + ), + legendrank=1, + ), + secondary_y=True, + ) - wheel_start, wheel_end = in_patch[0], in_patch[-1] - ts_duration = (wheel_end - wheel_start) / np.timedelta64(1, "s") # in seconds - if ts_duration < min_bout_duration: - return nb_bouts + fig.update_layout( + barmode="stack", + hovermode="x", + title_text=visit_key["subject"] + + "
    Weight and pellet count (freq='" + + freq + + "')", + xaxis_title="date" if freq == "D" else "time", + yaxis=dict(title="pellet count"), + yaxis2=dict(title="weight"), + width=700, + height=400, + template="simple_white", + legend=dict( + title="", + orientation="h", + yanchor="bottom", + y=0.98, + xanchor="right", + x=1, + traceorder="normal", + ), + ) + + return fig - wheel_data = acquisition.FoodPatchWheel.get_wheel_data( - experiment_name=visit_per_day_row["experiment_name"], - start=wheel_start, - end=wheel_end, - patch_name=visit_per_day_row["food_patch_description"], - using_aeon_io=using_aeon_io, + +def _get_filtered_subject_weight( + visit_key, smooth_weight=True, min_weight=0, max_weight=35 +): + """Retrieve subject weight from WeightMeasurementFiltered table. + + Args: + visit_key (dict): Key from the Visit table + smooth_weight (bool, optional): Apply savgol filter to subject weight. Defaults to True + min_weight (bool, optional): Lower bound of subject weight. Defaults to 0 + max_weight (bool, optional): Upper bound of subject weight. Defaults to 35 + + Returns: + subject_weight (pd.DataFrame): Timestamped weight data + """ + + visit_start, visit_end = (VisitEnd & visit_key).fetch1("visit_start", "visit_end") + + chunk_keys = ( + acquisition.Chunk + & f'chunk_start BETWEEN "{pd.Timestamp(visit_start).floor("H")}" AND "{visit_end}"' + ).fetch("KEY") + + # Create subject_weight dataframe + subject_weight = ( + pd.DataFrame( + ( + dj.U("weight_subject_timestamps", "weight_subject") + & (acquisition.WeightMeasurementFiltered & chunk_keys) + ).fetch(order_by="chunk_start") + ) + .explode(["weight_subject_timestamps", "weight_subject"]) + .set_index("weight_subject_timestamps") + .dropna() + ) + subject_weight = subject_weight.loc[visit_start:visit_end] + + # Exclude data during maintenance + maintenance_period = get_maintenance_periods( + visit_key["experiment_name"], visit_start, visit_end + ) + subject_weight = filter_out_maintenance_periods( + subject_weight, maintenance_period, visit_end, dropna=True + ) + + # Drop rows where weight is out of specified range + subject_weight = subject_weight.drop( + subject_weight[ + (subject_weight["weight_subject"] < min_weight) + | (subject_weight["weight_subject"] > max_weight) + ].index + ) + + # Downsample data to every minute + subject_weight = subject_weight.resample("1T").mean().dropna() + + if smooth_weight: + subject_weight["weight_subject"] = savgol_filter( + subject_weight["weight_subject"], 10, 3 ) - if wheel_data.distance_travelled[-1] > wheel_dist_crit: - return nb_bouts + 1 - else: - return nb_bouts - - # fetch contiguous timestamp blocks - for i in range(len(change_ind) + 1): - if i == 0: - ts_array = in_patch[: change_ind[i]] - elif i == len(change_ind): - ts_array = in_patch[change_ind[i - 1] :] - else: - ts_array = in_patch[change_ind[i - 1] : change_ind[i]] - - ts_duration = (ts_array[-1] - ts_array[0]) / np.timedelta64( - 1, "s" - ) # in seconds - if ts_duration < min_bout_duration: - continue - - wheel_start, wheel_end = ts_array[0], ts_array[-1] - if wheel_start > wheel_end: # skip if timestamps were misaligned - continue - - wheel_data = acquisition.FoodPatchWheel.get_wheel_data( - experiment_name=visit_per_day_row["experiment_name"], - start=wheel_start, - end=wheel_end, - patch_name=visit_per_day_row["food_patch_description"], - using_aeon_io=using_aeon_io, + + return subject_weight + + +def _get_patch_data(visit_key): + """Retrieve all patch (pellet trigger) data from FoodPatchEvent table. + + Args: + visit_key (dict): Key from the Visit table + + Returns: + patch (pd.DataFrame): Timestamped pellet trigger events + """ + + visit_start, visit_end = (VisitEnd & visit_key).fetch1("visit_start", "visit_end") + + chunk_keys = ( + acquisition.Chunk + & f'chunk_start BETWEEN "{pd.Timestamp(visit_start).floor("H")}" AND "{visit_end}"' + ).fetch("KEY") + + # Get pellet trigger dataframe for all patches + patch = ( + ( + dj.U("event_time", "event_type", "food_patch_description") + & ( + acquisition.FoodPatchEvent + * acquisition.EventType + * acquisition.ExperimentFoodPatch + & chunk_keys + & f'event_time BETWEEN "{visit_start}" AND "{visit_end}"' + & 'event_type = "TriggerPellet"' + ) ) + .fetch(order_by="event_time", format="frame") + .reset_index() + .set_index("event_time") + ) + + # TODO: handle repeat attempts (pellet delivery trigger and beam break) - if wheel_data.distance_travelled[-1] > wheel_dist_crit: - nb_bouts += 1 + # Exclude data during maintenance + maintenance_period = get_maintenance_periods( + visit_key["experiment_name"], visit_start, visit_end + ) + patch = filter_out_maintenance_periods( + patch, maintenance_period, visit_end, dropna=True + ) - return nb_bouts + return patch From 7d1e5fb3830591cfba3cad42bf6f79268f7a324a Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 30 Nov 2022 12:04:36 -0600 Subject: [PATCH 145/489] remove duplicate device type --- aeon/dj_pipeline/streams.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index 2a7f3cd6..7549c8e3 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -126,11 +126,6 @@ class Stream(dj.Part): "Food patch", (foraging.patch,), ), - ( - "Food Patch", - "Food patch", - (foraging.patch,), - ), ( "Photodiode", "Photodiode", From 5c578c9be0fa3547c0d3199e2602f48f55329c1e Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 30 Nov 2022 18:11:04 +0000 Subject: [PATCH 146/489] delete insert1 to resolve duplicate error --- aeon/dj_pipeline/acquisition.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index b5d22c10..e1758774 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -613,8 +613,6 @@ class Message(dj.Part): def make(self, key): chunk_start, chunk_end = (Chunk & key).fetch1("chunk_start", "chunk_end") - self.insert1(key) - # Populate the part table raw_data_dir = Experiment.get_data_directory(key) device = getattr( From 9d8fbce6cdb63054e64c1d09e48808f4f6a66981 Mon Sep 17 00:00:00 2001 From: lochhh Date: Thu, 1 Dec 2022 17:09:50 +0000 Subject: [PATCH 147/489] Remove redundant chunk_keys filter --- aeon/dj_pipeline/analysis/visit_analysis.py | 5 ----- aeon/dj_pipeline/utils/plotting.py | 6 ------ 2 files changed, 11 deletions(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 62d024d4..776c1bea 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -593,16 +593,11 @@ def make(self, key): ).index.values # get pellet data - chunk_keys = ( - acquisition.Chunk - & f'chunk_start BETWEEN "{pd.Timestamp(visit_start).floor("H")}" AND "{visit_end}"' - ).fetch("KEY") patch = ( ( dj.U("event_time", "event_type") & ( acquisition.FoodPatchEvent * acquisition.EventType - & chunk_keys & key & f'event_time BETWEEN "{visit_start}" AND "{visit_end}"' & 'event_type = "TriggerPellet"' diff --git a/aeon/dj_pipeline/utils/plotting.py b/aeon/dj_pipeline/utils/plotting.py index 96f25eff..48e0b69a 100644 --- a/aeon/dj_pipeline/utils/plotting.py +++ b/aeon/dj_pipeline/utils/plotting.py @@ -803,11 +803,6 @@ def _get_patch_data(visit_key): visit_start, visit_end = (VisitEnd & visit_key).fetch1("visit_start", "visit_end") - chunk_keys = ( - acquisition.Chunk - & f'chunk_start BETWEEN "{pd.Timestamp(visit_start).floor("H")}" AND "{visit_end}"' - ).fetch("KEY") - # Get pellet trigger dataframe for all patches patch = ( ( @@ -816,7 +811,6 @@ def _get_patch_data(visit_key): acquisition.FoodPatchEvent * acquisition.EventType * acquisition.ExperimentFoodPatch - & chunk_keys & f'event_time BETWEEN "{visit_start}" AND "{visit_end}"' & 'event_type = "TriggerPellet"' ) From 58d2084594c35eac97727d77344ad649ebabd924 Mon Sep 17 00:00:00 2001 From: lochhh Date: Thu, 1 Dec 2022 17:15:50 +0000 Subject: [PATCH 148/489] Auto-ingest VisitForagingBout table --- aeon/dj_pipeline/populate/worker.py | 1 + 1 file changed, 1 insertion(+) diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py index 0ecd0fc3..cf032d85 100644 --- a/aeon/dj_pipeline/populate/worker.py +++ b/aeon/dj_pipeline/populate/worker.py @@ -59,6 +59,7 @@ mid_priority(analysis.VisitSubjectPosition) mid_priority(analysis.VisitTimeDistribution) mid_priority(analysis.VisitSummary) +mid_priority(analysis.VisitForagingBout) # report tables mid_priority(report.delete_outdated_plot_entries) mid_priority(report.SubjectRewardRateDifference) From 40d02222c808a3e45ffb06d189b66dcbf67956f8 Mon Sep 17 00:00:00 2001 From: lochhh Date: Fri, 2 Dec 2022 17:53:30 +0000 Subject: [PATCH 149/489] Update SciViz specsheet --- .../dj_pipeline/webapps/sciviz/specsheet.yaml | 54 +++++++++++++++++-- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index ef07cecd..d4ac4b05 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -222,14 +222,14 @@ SciViz: report = aeon_report return {'query': report.VisitDailySummaryPlot(), 'fetch_args': ['summary_plot_png']} - VisitDailySummary: - route: /visit_daily_summary + Visits247: + route: /visits247 grids: visit_daily_summary: route: /visit_daily_summary_grid1 type: dynamic - columns: 2 - row_height: 2000 + columns: 1 + row_height: 4000 restriction: > def restriction(**kwargs): return dict(**kwargs) @@ -265,6 +265,15 @@ SciViz: def dj_query(aeon_report): return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['total_distance_travelled_plotly']) comp4: + route: /visit_daily_summary_weight_patch + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_report): + return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['weight_patch_plotly']) + comp5: route: /visit_daily_summary_foraging_bouts type: plot:plotly:stored_json restriction: > @@ -273,6 +282,43 @@ SciViz: dj_query: > def dj_query(aeon_report): return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['foraging_bouts_plotly']) + comp6: + route: /visit_daily_summary_foraging_bouts_pellet_count + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_report): + return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['foraging_bouts_pellet_count_plotly']) + comp7: + route: /visit_daily_summary_foraging_bouts_duration + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_report): + return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['foraging_bouts_duration_plotly']) + comp8: + route: /visit_daily_summary_region_time_fraction_daily + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_report): + return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['region_time_fraction_daily_plotly']) + comp9: + route: /visit_daily_summary_region_time_fraction_hourly + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_report): + return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['region_time_fraction_hourly_plotly']) + PipelineMonitor: route: /pipeline_monitor grids: From 00ebb206d04e239a41fc17ce56a18fea9d21022d Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 6 Dec 2022 00:05:27 +0000 Subject: [PATCH 150/489] fix: :bug: fix variable name --- aeon/dj_pipeline/analysis/visit_analysis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 23a0e093..edd6d9df 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -660,7 +660,7 @@ def make(self, key): def get_maintenance_periods(experiment_name, start, end): # get logs from acquisition.ExperimentLog - log_df = ( + query = ( acquisition.ExperimentLog.Message.proj("message") & {"experiment_name": experiment_name} & 'message IN ("Maintenance", "Experiment")' From 44a466663c267d0c27fb6ce8fcb56924b5fca4db Mon Sep 17 00:00:00 2001 From: JaerongA Date: Thu, 8 Dec 2022 04:20:32 +0000 Subject: [PATCH 151/489] feat: :sparkles: add VisitDailySummaryPlot to the worker --- aeon/dj_pipeline/populate/worker.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py index cf032d85..b8307222 100644 --- a/aeon/dj_pipeline/populate/worker.py +++ b/aeon/dj_pipeline/populate/worker.py @@ -1,10 +1,9 @@ import datajoint as dj -from datajoint_utilities.dj_worker import DataJointWorker, WorkerLog, ErrorLog +from datajoint_utilities.dj_worker import DataJointWorker, ErrorLog, WorkerLog from aeon.dj_pipeline import acquisition, analysis, db_prefix, qc, report, tracking from aeon.dj_pipeline.utils import load_metadata - __all__ = ["high_priority", "mid_priority", "WorkerLog", "ErrorLog", "logger"] # ---- Some constants ---- @@ -60,8 +59,10 @@ mid_priority(analysis.VisitTimeDistribution) mid_priority(analysis.VisitSummary) mid_priority(analysis.VisitForagingBout) + # report tables mid_priority(report.delete_outdated_plot_entries) mid_priority(report.SubjectRewardRateDifference) mid_priority(report.SubjectWheelTravelledDistance) mid_priority(report.ExperimentTimeDistribution) +mid_priority(report.VisitDailySummaryPlot) From a783fd7b4f02d671d266845b9341f5953cf26ae8 Mon Sep 17 00:00:00 2001 From: lochhh Date: Tue, 13 Dec 2022 16:12:20 +0000 Subject: [PATCH 152/489] Ignore single in_patch timestamps --- aeon/dj_pipeline/analysis/visit_analysis.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index edd6d9df..73719ac0 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -629,7 +629,9 @@ def make(self, key): ts_array = in_patch_times[change_ind[i - 1] : change_ind[i]] wheel_start, wheel_end = ts_array[0], ts_array[-1] - if wheel_start > wheel_end: # skip if timestamps were misaligned + if ( + wheel_start >= wheel_end + ): # skip if timestamps were misaligned or a single timestamp continue wheel_data = acquisition.FoodPatchWheel.get_wheel_data( From 256cb327da18b8255821974d8233c5941556bf30 Mon Sep 17 00:00:00 2001 From: lochhh Date: Tue, 13 Dec 2022 16:13:13 +0000 Subject: [PATCH 153/489] Skip weight plot if data not found --- aeon/dj_pipeline/utils/plotting.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/aeon/dj_pipeline/utils/plotting.py b/aeon/dj_pipeline/utils/plotting.py index 48e0b69a..c4c92fa1 100644 --- a/aeon/dj_pipeline/utils/plotting.py +++ b/aeon/dj_pipeline/utils/plotting.py @@ -762,6 +762,11 @@ def _get_filtered_subject_weight( .set_index("weight_subject_timestamps") .dropna() ) + + # Return empty dataframe if no weight data + if subject_weight.empty: + return subject_weight + subject_weight = subject_weight.loc[visit_start:visit_end] # Exclude data during maintenance From 6442c90768254beec18469da4770b306a3336a81 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 23 Jan 2023 16:42:58 +0000 Subject: [PATCH 154/489] refactor: :recycle: refactoring & decoupling code for device table generation --- aeon/dj_pipeline/streams.py | 40 ++++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index 7549c8e3..51828560 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -1,24 +1,32 @@ -import datajoint as dj -import pandas as pd -import numpy as np import inspect import re +from functools import cached_property +from typing import NamedTuple + +import datajoint as dj +import pandas as pd import aeon -from aeon.io import api as io_api import aeon.schema.core as stream import aeon.schema.foraging as foraging import aeon.schema.octagon as octagon - -from . import acquisition, dict_to_uuid, get_schema_name +from aeon.dj_pipeline import acquisition, dict_to_uuid, get_schema_name +from aeon.io import api as io_api logger = dj.logger -schema_name = get_schema_name("streams") -# schema_name = f'u_{dj.config["database.user"]}_streams' # still experimental feature + +# schema_name = get_schema_name("streams") +schema_name = f'u_{dj.config["database.user"]}_streams' +# schema_name = f'u_{dj.config["database.user"]}_test' schema = dj.schema(schema_name) +def _prettify(s): + s = re.sub(r"[A-Z]", lambda m: f"_{m.group(0)}", s) + return s.replace("_", " ").title().replace(" ", "") + + @schema class StreamType(dj.Lookup): """ @@ -184,7 +192,6 @@ def generate_device_table(device_type, context=None): _schema = dj.schema(context=context) - device_type_key = {"device_type": device_type} device_title = _prettify(device_type) device_type = dj.utils.from_camel_case(device_title) @@ -219,9 +226,9 @@ class RemovalTime(dj.Part): context[exp_device_table_name] = ExperimentDevice # DeviceDataStream table(s) - for stream_detail in (StreamType & (DeviceType.Stream & device_type_key)).fetch( - as_dict=True - ): + for stream_detail in ( + StreamType & (DeviceType.Stream & {"device_type": device_type}) + ).fetch(as_dict=True): stream_type = stream_detail["stream_type"] stream_title = _prettify(stream_type) @@ -318,15 +325,16 @@ def _prettify(s): # ---- MAIN BLOCK ---- +if __name__ == "__main__": + # Populate StreamType + StreamType.insert_streams() -def main(): + # Populate DeviceType DeviceType.insert_devices() + # Populate device tables context = inspect.currentframe().f_back.f_locals for device_type in DeviceType.fetch("device_type"): logger.info(f"Generating stream table(s) for: {device_type}") generate_device_table(device_type, context=context) - - -main() From 3ef34d121e9e1a978e3a964b57b177fa338142fa Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 23 Jan 2023 17:23:14 +0000 Subject: [PATCH 155/489] feat: :sparkles: restructure the code add device manger & templatets --- aeon/dj_pipeline/streams.py | 405 +++++++++++++++++++++++------------- 1 file changed, 263 insertions(+), 142 deletions(-) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index 51828560..0de63e68 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -1,5 +1,6 @@ import inspect import re +from collections import defaultdict from functools import cached_property from typing import NamedTuple @@ -16,15 +17,67 @@ logger = dj.logger -# schema_name = get_schema_name("streams") -schema_name = f'u_{dj.config["database.user"]}_streams' -# schema_name = f'u_{dj.config["database.user"]}_test' +# schema_name = f'u_{dj.config["database.user"]}_test' # for testing +schema_name = get_schema_name("streams") schema = dj.schema(schema_name) -def _prettify(s): - s = re.sub(r"[A-Z]", lambda m: f"_{m.group(0)}", s) - return s.replace("_", " ").title().replace(" ", "") +class DeviceConfig(NamedTuple): + + type: str + desc: int + streams: tuple + + _DEVICE_CONFIG = [ + ( + "Camera", + "Camera device", + (stream.video, stream.position, foraging.region), + ), + ("Metadata", "Metadata", (stream.metadata,)), + ( + "ExperimentalMetadata", + "ExperimentalMetadata", + (stream.environment, stream.messageLog), + ), + ( + "NestScale", + "Weight scale at nest", + (foraging.weight,), + ), + ( + "FoodPatch", + "Food patch", + (foraging.patch,), + ), + ( + "Photodiode", + "Photodiode", + (octagon.photodiode,), + ), + ( + "OSC", + "OSC", + (octagon.OSC,), + ), + ( + "TaskLogic", + "TaskLogic", + (octagon.TaskLogic,), + ), + ( + "Wall", + "Wall", + (octagon.Wall,), + ), + ] + + @classmethod + def create_device_configs( + cls, + ): + + return [DeviceConfig(*config) for config in cls._DEVICE_CONFIG] @schema @@ -37,27 +90,18 @@ class StreamType(dj.Lookup): """ definition = """ # Catalog of all stream types used across Project Aeon - stream_type: varchar(20) + stream_type: varchar(20) --- - stream_reader: varchar(256) # name of the reader class found in `aeon_mecha` package (e.g. aeon.io.reader.Video) - stream_reader_kwargs: longblob # keyword arguments to instantiate the reader class - stream_description='': varchar(256) - stream_hash: uuid # hash of dict(stream_reader_kwargs, stream_reader=stream_reader) - unique index (stream_hash) + stream_reader: varchar(256) # name of the reader class found in `aeon_mecha` package (e.g. aeon.io.reader.Video) + stream_reader_kwargs: longblob # keyword arguments to instantiate the reader class + stream_description='': varchar(256) + stream_hash: uuid # hash of dict(stream_reader_kwargs, stream_reader=stream_reader) """ - @classmethod - def insert_streams(cls, *streams): - composite = {} - pattern = "{pattern}" - for stream_obj in streams: - if inspect.isclass(stream_obj): - for method in vars(stream_obj).values(): - if isinstance(method, staticmethod): - composite.update(method.__func__(pattern)) - else: - composite.update(stream_obj(pattern)) + @staticmethod + def get_stream_entries(device: DeviceConfig, pattern="{pattern}") -> dict: + composite = aeon.io.device.compositeStream(pattern, *device.streams) stream_entries = [] for stream_name, stream_reader in composite.items(): if stream_name == pattern: @@ -78,21 +122,32 @@ def insert_streams(cls, *streams): "stream_reader": entry["stream_reader"], } ) - q_param = cls & {"stream_hash": entry["stream_hash"]} - if q_param: # If the specified stream type already exists - pname = q_param.fetch1("stream_type") - if pname != stream_name: - # If the existed stream type does not have the same name: - # human error, trying to add the same content with different name - raise dj.DataJointError( - f"The specified stream type already exists - name: {pname}" - ) - stream_entries.append(entry) - cls.insert(stream_entries, skip_duplicates=True) return stream_entries + @classmethod + def insert_streams(cls, device_configs: list = []) -> list[dict]: + + if not device_configs: + device_configs: list[DeviceConfig] = DeviceConfig.create_device_configs() + + for device in device_configs: + + stream_entries = cls.get_stream_entries(device) + for entry in stream_entries: + q_param = cls & {"stream_hash": entry["stream_hash"]} + if q_param: # If the specified stream type already exists + pname = q_param.fetch1("stream_type") + if pname != entry["stream_type"]: + # If the existed stream type does not have the same name: + # human error, trying to add the same content with different name + raise dj.DataJointError( + f"The specified stream type already exists - name: {pname}" + ) + + cls.insert(stream_entries, skip_duplicates=True) + @schema class DeviceType(dj.Lookup): @@ -101,9 +156,9 @@ class DeviceType(dj.Lookup): """ definition = """ # Catalog of all device types used across Project Aeon - device_type: varchar(36) + device_type: varchar(36) --- - device_description='': varchar(256) + device_description='': varchar(256) """ class Stream(dj.Part): @@ -112,60 +167,30 @@ class Stream(dj.Part): -> StreamType """ - _devices_config = [ - ( - "Camera", - "Camera device", - (stream.video, stream.position, foraging.region), - ), - ("Metadata", "Metadata", (stream.metadata,)), - ( - "ExperimentalMetadata", - "ExperimentalMetadata", - (stream.environment, stream.messageLog), - ), - ( - "Nest Scale", - "Weight scale at nest", - (foraging.weight,), - ), - ( - "Food Patch", - "Food patch", - (foraging.patch,), - ), - ( - "Photodiode", - "Photodiode", - (octagon.photodiode,), - ), - ( - "OSC", - "OSC", - (octagon.OSC,), - ), - ( - "TaskLogic", - "TaskLogic", - (octagon.TaskLogic,), - ), - ( - "Wall", - "Wall", - (octagon.Wall,), - ), - ] - @classmethod - def insert_devices(cls): - for device_type, device_desc, device_streams in cls._devices_config: - stream_entries = StreamType.insert_streams(*device_streams) + def insert_devices(cls, device_configs: list = []): + + if not device_configs: + device_configs: list[DeviceConfig] = DeviceConfig.create_device_configs() + + for device in device_configs: + + stream_entries = StreamType.get_stream_entries(device) + with cls.connection.transaction: - cls.insert1((device_type, device_desc), skip_duplicates=True) + + cls.insert1( + { + "device_type": device.type, + "device_description": device.desc, + }, + skip_duplicates=True, + ) + cls.Stream.insert( [ { - "device_type": device_type, + "device_type": device.type, "stream_type": e["stream_type"], } for e in stream_entries @@ -183,56 +208,64 @@ class Device(dj.Lookup): """ -# ---- HELPER ---- - +class DeviceTableTemplate: -def generate_device_table(device_type, context=None): - if context is None: - context = inspect.currentframe().f_back.f_locals + from aeon.dj_pipeline import acquisition - _schema = dj.schema(context=context) + @staticmethod + def get_device_template(device_type): - device_title = _prettify(device_type) - device_type = dj.utils.from_camel_case(device_title) - - @_schema - class ExperimentDevice(dj.Manual): - definition = f""" - # {device_title} placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-{aeon.__version__}) - -> acquisition.Experiment - -> Device - {device_type}_install_time: datetime(6) # time of the {device_type} placed and started operation at this position - --- - {device_type}_name: varchar(36) - """ + device_title = device_type + device_type = dj.utils.from_camel_case(device_type) - class Attribute(dj.Part): - definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device - -> master - attribute_name : varchar(32) - --- - attribute_value='': varchar(2000) - """ - - class RemovalTime(dj.Part): + class ExperimentDevice(dj.Manual): definition = f""" - -> master + # {device_title} placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-{aeon.__version__}) + -> acquisition.Experiment + -> Device + {device_type}_install_time: datetime(6) # time of the {device_type} placed and started operation at this position --- - {device_type}_remove_time: datetime(6) # time of the camera being removed from this position + {device_type}_name: varchar(36) """ - exp_device_table_name = f"Experiment{device_title}" - ExperimentDevice.__name__ = exp_device_table_name - context[exp_device_table_name] = ExperimentDevice + class Attribute(dj.Part): + definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device + -> master + attribute_name : varchar(32) + --- + attribute_value='': varchar(2000) + """ + + class RemovalTime(dj.Part): + definition = f""" + -> master + --- + {device_type}_remove_time: datetime(6) # time of the camera being removed from this position + """ + + logger.info(f"Creating device table: Experiment{device_title}") + ExperimentDevice.__name__ = f"Experiment{device_title}" + + return ExperimentDevice + + def get_device_stream_template(self, device_type, stream_type): - # DeviceDataStream table(s) - for stream_detail in ( - StreamType & (DeviceType.Stream & {"device_type": device_type}) - ).fetch(as_dict=True): - stream_type = stream_detail["stream_type"] - stream_title = _prettify(stream_type) + # device_title = _prettify(device_type) + # stream_title = _prettify(stream_type) - logger.info(f"Creating stream table: {device_title}{stream_title}") + ExperimentDevice = DeviceTableTemplate.get_device_template(device_type) + exp_device_table_name = f"Experiment{device_type}" + + # DeviceDataStream table(s) + stream_detail = ( + StreamType + & ( + DeviceType.Stream + & {"device_type": device_type, "stream_type": stream_type} + ) + ).fetch1() + + logger.info(f"Creating stream table: {device_type}{stream_type}") for i, n in enumerate(stream_detail["stream_reader"].split(".")): if i == 0: @@ -242,8 +275,8 @@ class RemovalTime(dj.Part): stream = reader(**stream_detail["stream_reader_kwargs"]) - table_definition = f""" # Raw per-chunk {stream_title} data stream from {device_title} (auto-generated with aeon_mecha-{aeon.__version__}) - -> Experiment{device_title} + table_definition = f""" # Raw per-chunk {stream_type} data stream from {device_type} (auto-generated with aeon_mecha-{aeon.__version__}) + -> Experiment{device_type} -> acquisition.Chunk --- sample_count: int # number of data points acquired from this stream for a given chunk @@ -255,7 +288,6 @@ class RemovalTime(dj.Part): continue table_definition += f"{col}: longblob\n\t\t\t" - @_schema class DeviceDataStream(dj.Imported): definition = table_definition _stream_reader = reader @@ -312,29 +344,118 @@ def make(self, key): } ) - stream_table_name = f"{device_title}{stream_title}" - DeviceDataStream.__name__ = stream_table_name - context[stream_table_name] = DeviceDataStream + self._DeviceDataStream = DeviceDataStream + self._DeviceDataStream.__name__ = f"{device_type}{stream_type}" + + return self._DeviceDataStream + + +class DeviceTableManager: + def __init__(self, context=None): + + if context is None: + self.context = inspect.currentframe().f_back.f_locals + else: + self.context = context + + self._schema = dj.schema(context=self.context) + self._device_tables = [] + self._device_stream_tables = [] + self._device_types = DeviceType.fetch("device_type") + self._device_stream_map = defaultdict( + list + ) # dictionary for showing hierarchical relationship between device type and stream type + + def _add_device_tables(self): + for device_type in self._device_types: + table_name = f"Experiment{device_type}" + if table_name not in self._device_tables: + self._device_tables.append(table_name) + + def _add_device_stream_tables(self): + for device_type in self._device_types: + for stream_type in ( + StreamType & (DeviceType.Stream & {"device_type": device_type}) + ).fetch("stream_type"): + + table_name = f"{device_type}{stream_type}" + if table_name not in self._device_stream_tables: + self._device_stream_tables.append(table_name) + + self._device_stream_map[device_type].append(stream_type) + + @property + def device_types(self): + return self._device_types + + @cached_property + def device_tables(self) -> list: + self._add_device_tables() + return self._device_tables - _schema.activate(schema_name) + @cached_property + def device_stream_tables(self) -> list: + if not self._device_stream_tables: + self._add_device_stream_tables() + return self._device_stream_tables + @cached_property + def device_stream_map(self) -> dict: -def _prettify(s): - s = re.sub(r"[A-Z]", lambda m: f"_{m.group(0)}", s) - return s.replace("_", " ").title().replace(" ", "") + self._add_device_stream_tables() + return self._device_stream_tables + def create_device_tables(self): -# ---- MAIN BLOCK ---- -if __name__ == "__main__": + for device_table in self.device_tables: - # Populate StreamType + device_type = re.sub(r"\bExperiment", "", device_table) + + table_class = DeviceTableTemplate.get_device_template(device_type) + + self.context[table_class.__name__] = table_class + self._schema(table_class, context=self.context) + + self._schema.activate(schema_name) + + def create_device_stream_tables(self, table_template: DeviceTableTemplate): + + for device_stream in self.device_stream_tables: + + device_type, stream_type = dj.utils.from_camel_case(device_stream).split( + "_" + ) + + table_class = table_template.get_device_stream_template( + device_type, stream_type + ) + + self.context[table_class.__name__] = table_class + self._schema(table_class, context=self.context) + + self._schema.activate(schema_name) + + +# Main function +def main(): + + # # Populate StreamType StreamType.insert_streams() - # Populate DeviceType + # # Populate DeviceType DeviceType.insert_devices() # Populate device tables context = inspect.currentframe().f_back.f_locals - for device_type in DeviceType.fetch("device_type"): - logger.info(f"Generating stream table(s) for: {device_type}") - generate_device_table(device_type, context=context) + tbmg = DeviceTableManager(context=context) + + # # List all tables + tbmg.device_tables + tbmg.device_stream_tables + + # Create device & device stream tables + tbmg.create_device_tables() + tbmg.create_device_stream_tables() + + +main() From 20398b7d01180c2ff888ae1a31839a3875da3151 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 23 Jan 2023 19:28:44 +0000 Subject: [PATCH 156/489] fix: :bug: cleanup & bug fix --- aeon/dj_pipeline/streams.py | 62 +++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 34 deletions(-) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index 0de63e68..bc1a77e0 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -73,11 +73,10 @@ class DeviceConfig(NamedTuple): ] @classmethod - def create_device_configs( - cls, - ): - - return [DeviceConfig(*config) for config in cls._DEVICE_CONFIG] + def create_device_configs(cls, config=None): + if config is None: + config = cls._DEVICE_CONFIG + return [DeviceConfig(*c) for c in config] @schema @@ -209,9 +208,6 @@ class Device(dj.Lookup): class DeviceTableTemplate: - - from aeon.dj_pipeline import acquisition - @staticmethod def get_device_template(device_type): @@ -248,10 +244,8 @@ class RemovalTime(dj.Part): return ExperimentDevice - def get_device_stream_template(self, device_type, stream_type): - - # device_title = _prettify(device_type) - # stream_title = _prettify(stream_type) + @staticmethod + def get_device_stream_template(device_type, stream_type): ExperimentDevice = DeviceTableTemplate.get_device_template(device_type) exp_device_table_name = f"Experiment{device_type}" @@ -344,10 +338,9 @@ def make(self, key): } ) - self._DeviceDataStream = DeviceDataStream - self._DeviceDataStream.__name__ = f"{device_type}{stream_type}" + DeviceDataStream.__name__ = f"{device_type}{stream_type}" - return self._DeviceDataStream + return DeviceDataStream class DeviceTableManager: @@ -390,20 +383,25 @@ def device_types(self): @cached_property def device_tables(self) -> list: + """ + Name of the device tables to be created + """ + self._add_device_tables() return self._device_tables @cached_property def device_stream_tables(self) -> list: - if not self._device_stream_tables: - self._add_device_stream_tables() + """ + Name of the device stream tables to be created + """ + self._add_device_stream_tables() return self._device_stream_tables @cached_property def device_stream_map(self) -> dict: - self._add_device_stream_tables() - return self._device_stream_tables + return self._device_stream_map def create_device_tables(self): @@ -418,27 +416,24 @@ def create_device_tables(self): self._schema.activate(schema_name) - def create_device_stream_tables(self, table_template: DeviceTableTemplate): + def create_device_stream_tables(self): - for device_stream in self.device_stream_tables: + for device_type in self.device_stream_map: - device_type, stream_type = dj.utils.from_camel_case(device_stream).split( - "_" - ) + for stream_type in self.device_stream_map[device_type]: - table_class = table_template.get_device_stream_template( - device_type, stream_type - ) + table_class = DeviceTableTemplate.get_device_stream_template( + device_type, stream_type + ) - self.context[table_class.__name__] = table_class - self._schema(table_class, context=self.context) + self.context[table_class.__name__] = table_class + self._schema(table_class, context=self.context) self._schema.activate(schema_name) # Main function def main(): - # # Populate StreamType StreamType.insert_streams() @@ -446,12 +441,11 @@ def main(): DeviceType.insert_devices() # Populate device tables - context = inspect.currentframe().f_back.f_locals - tbmg = DeviceTableManager(context=context) + tbmg = DeviceTableManager(context=inspect.currentframe().f_locals) # # List all tables - tbmg.device_tables - tbmg.device_stream_tables + # tbmg.device_tables + # tbmg.device_stream_tables # Create device & device stream tables tbmg.create_device_tables() From fd8ada8fba468770b3b71e96f8076bfb42048eff Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 24 Jan 2023 17:56:26 +0000 Subject: [PATCH 157/489] add back unique index & remove logger --- aeon/dj_pipeline/streams.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index bc1a77e0..cc666b63 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -4,13 +4,12 @@ from functools import cached_property from typing import NamedTuple -import datajoint as dj -import pandas as pd - import aeon import aeon.schema.core as stream import aeon.schema.foraging as foraging import aeon.schema.octagon as octagon +import datajoint as dj +import pandas as pd from aeon.dj_pipeline import acquisition, dict_to_uuid, get_schema_name from aeon.io import api as io_api @@ -95,6 +94,7 @@ class StreamType(dj.Lookup): stream_reader_kwargs: longblob # keyword arguments to instantiate the reader class stream_description='': varchar(256) stream_hash: uuid # hash of dict(stream_reader_kwargs, stream_reader=stream_reader) + unique index (stream_hash) """ @staticmethod @@ -239,7 +239,6 @@ class RemovalTime(dj.Part): {device_type}_remove_time: datetime(6) # time of the camera being removed from this position """ - logger.info(f"Creating device table: Experiment{device_title}") ExperimentDevice.__name__ = f"Experiment{device_title}" return ExperimentDevice @@ -259,8 +258,6 @@ def get_device_stream_template(device_type, stream_type): ) ).fetch1() - logger.info(f"Creating stream table: {device_type}{stream_type}") - for i, n in enumerate(stream_detail["stream_reader"].split(".")): if i == 0: reader = aeon @@ -434,6 +431,7 @@ def create_device_stream_tables(self): # Main function def main(): + # # Populate StreamType StreamType.insert_streams() @@ -441,7 +439,7 @@ def main(): DeviceType.insert_devices() # Populate device tables - tbmg = DeviceTableManager(context=inspect.currentframe().f_locals) + tbmg = DeviceTableManager(context=inspect.currentframe().f_back.f_locals) # # List all tables # tbmg.device_tables From 8c4804c1c4386b007d1ad7ca91c0ff0f10aa5a64 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 24 Jan 2023 18:25:06 +0000 Subject: [PATCH 158/489] load namedtuple from collections module --- aeon/dj_pipeline/streams.py | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index cc666b63..694b9906 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -1,8 +1,7 @@ import inspect import re -from collections import defaultdict +from collections import defaultdict, namedtuple from functools import cached_property -from typing import NamedTuple import aeon import aeon.schema.core as stream @@ -21,13 +20,11 @@ schema = dj.schema(schema_name) -class DeviceConfig(NamedTuple): +class DeviceConfig: - type: str - desc: int - streams: tuple + DEVICE = namedtuple("DEVICE", "type desc streams") - _DEVICE_CONFIG = [ + CONFIGS = [ ( "Camera", "Camera device", @@ -72,10 +69,8 @@ class DeviceConfig(NamedTuple): ] @classmethod - def create_device_configs(cls, config=None): - if config is None: - config = cls._DEVICE_CONFIG - return [DeviceConfig(*c) for c in config] + def get_configs(cls): + return [cls.DEVICE(*c) for c in cls.CONFIGS] @schema @@ -126,10 +121,10 @@ def get_stream_entries(device: DeviceConfig, pattern="{pattern}") -> dict: return stream_entries @classmethod - def insert_streams(cls, device_configs: list = []) -> list[dict]: + def insert_streams(cls, device_configs: list[DeviceConfig] = []) -> list[dict]: if not device_configs: - device_configs: list[DeviceConfig] = DeviceConfig.create_device_configs() + device_configs = DeviceConfig.get_configs() for device in device_configs: @@ -170,7 +165,7 @@ class Stream(dj.Part): def insert_devices(cls, device_configs: list = []): if not device_configs: - device_configs: list[DeviceConfig] = DeviceConfig.create_device_configs() + device_configs: list[DeviceConfig] = DeviceConfig.get_configs() for device in device_configs: From e8ab4cb759a592985a4aecb4327ddbf7cd0bd70f Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 24 Jan 2023 18:42:51 +0000 Subject: [PATCH 159/489] remove DeviceTemplate class --- aeon/dj_pipeline/streams.py | 222 ++++++++++++++++++------------------ 1 file changed, 108 insertions(+), 114 deletions(-) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index 694b9906..1d326a0d 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -162,10 +162,10 @@ class Stream(dj.Part): """ @classmethod - def insert_devices(cls, device_configs: list = []): + def insert_devices(cls, device_configs: list[DeviceConfig] = []): if not device_configs: - device_configs: list[DeviceConfig] = DeviceConfig.get_configs() + device_configs = DeviceConfig.get_configs() for device in device_configs: @@ -202,137 +202,133 @@ class Device(dj.Lookup): """ -class DeviceTableTemplate: - @staticmethod - def get_device_template(device_type): +def get_device_template(device_type): + """Returns table class template for ExperimentDevice""" + device_title = device_type + device_type = dj.utils.from_camel_case(device_type) + + class ExperimentDevice(dj.Manual): + definition = f""" + # {device_title} placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-{aeon.__version__}) + -> acquisition.Experiment + -> Device + {device_type}_install_time: datetime(6) # time of the {device_type} placed and started operation at this position + --- + {device_type}_name: varchar(36) + """ - device_title = device_type - device_type = dj.utils.from_camel_case(device_type) + class Attribute(dj.Part): + definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device + -> master + attribute_name : varchar(32) + --- + attribute_value='': varchar(2000) + """ - class ExperimentDevice(dj.Manual): + class RemovalTime(dj.Part): definition = f""" - # {device_title} placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-{aeon.__version__}) - -> acquisition.Experiment - -> Device - {device_type}_install_time: datetime(6) # time of the {device_type} placed and started operation at this position + -> master --- - {device_type}_name: varchar(36) + {device_type}_remove_time: datetime(6) # time of the camera being removed from this position """ - class Attribute(dj.Part): - definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device - -> master - attribute_name : varchar(32) - --- - attribute_value='': varchar(2000) - """ + ExperimentDevice.__name__ = f"Experiment{device_title}" - class RemovalTime(dj.Part): - definition = f""" - -> master - --- - {device_type}_remove_time: datetime(6) # time of the camera being removed from this position - """ + return ExperimentDevice - ExperimentDevice.__name__ = f"Experiment{device_title}" - return ExperimentDevice +def get_device_stream_template(device_type, stream_type): + """Returns table class template for DeviceDataStream""" - @staticmethod - def get_device_stream_template(device_type, stream_type): + ExperimentDevice = get_device_template(device_type) + exp_device_table_name = f"Experiment{device_type}" - ExperimentDevice = DeviceTableTemplate.get_device_template(device_type) - exp_device_table_name = f"Experiment{device_type}" + # DeviceDataStream table(s) + stream_detail = ( + StreamType + & (DeviceType.Stream & {"device_type": device_type, "stream_type": stream_type}) + ).fetch1() - # DeviceDataStream table(s) - stream_detail = ( - StreamType - & ( - DeviceType.Stream - & {"device_type": device_type, "stream_type": stream_type} - ) - ).fetch1() + for i, n in enumerate(stream_detail["stream_reader"].split(".")): + if i == 0: + reader = aeon + else: + reader = getattr(reader, n) - for i, n in enumerate(stream_detail["stream_reader"].split(".")): - if i == 0: - reader = aeon - else: - reader = getattr(reader, n) + stream = reader(**stream_detail["stream_reader_kwargs"]) - stream = reader(**stream_detail["stream_reader_kwargs"]) + table_definition = f""" # Raw per-chunk {stream_type} data stream from {device_type} (auto-generated with aeon_mecha-{aeon.__version__}) + -> Experiment{device_type} + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of {stream_type} data + """ - table_definition = f""" # Raw per-chunk {stream_type} data stream from {device_type} (auto-generated with aeon_mecha-{aeon.__version__}) - -> Experiment{device_type} - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of {stream_type} data + for col in stream.columns: + if col.startswith("_"): + continue + table_definition += f"{col}: longblob\n\t\t\t" + + class DeviceDataStream(dj.Imported): + definition = table_definition + _stream_reader = reader + _stream_detail = stream_detail + + @property + def key_source(self): + f""" + Only the combination of Chunk and {exp_device_table_name} with overlapping time + + Chunk(s) that started after {exp_device_table_name} install time and ended before {exp_device_table_name} remove time + + Chunk(s) that started after {exp_device_table_name} install time for {exp_device_table_name} that are not yet removed """ + return ( + acquisition.Chunk + * ExperimentDevice.join(ExperimentDevice.RemovalTime, left=True) + & f"chunk_start >= {device_type}_install_time" + & f'chunk_start < IFNULL({device_type}_remove_time, "2200-01-01")' + ) - for col in stream.columns: - if col.startswith("_"): - continue - table_definition += f"{col}: longblob\n\t\t\t" - - class DeviceDataStream(dj.Imported): - definition = table_definition - _stream_reader = reader - _stream_detail = stream_detail - - @property - def key_source(self): - f""" - Only the combination of Chunk and {exp_device_table_name} with overlapping time - + Chunk(s) that started after {exp_device_table_name} install time and ended before {exp_device_table_name} remove time - + Chunk(s) that started after {exp_device_table_name} install time for {exp_device_table_name} that are not yet removed - """ - return ( - acquisition.Chunk - * ExperimentDevice.join(ExperimentDevice.RemovalTime, left=True) - & f"chunk_start >= {device_type}_install_time" - & f'chunk_start < IFNULL({device_type}_remove_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory( - key, directory_type=dir_type - ) + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory( + key, directory_type=dir_type + ) - device_name = (ExperimentDevice & key).fetch1(f"{device_type}_name") + device_name = (ExperimentDevice & key).fetch1(f"{device_type}_name") - stream = self._stream_reader( - **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{ - c: stream_data[c].values - for c in stream.columns - if not c.startswith("_") - }, - } - ) + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{ + c: stream_data[c].values + for c in stream.columns + if not c.startswith("_") + }, + } + ) - DeviceDataStream.__name__ = f"{device_type}{stream_type}" + DeviceDataStream.__name__ = f"{device_type}{stream_type}" - return DeviceDataStream + return DeviceDataStream class DeviceTableManager: @@ -401,7 +397,7 @@ def create_device_tables(self): device_type = re.sub(r"\bExperiment", "", device_table) - table_class = DeviceTableTemplate.get_device_template(device_type) + table_class = get_device_template(device_type) self.context[table_class.__name__] = table_class self._schema(table_class, context=self.context) @@ -414,9 +410,7 @@ def create_device_stream_tables(self): for stream_type in self.device_stream_map[device_type]: - table_class = DeviceTableTemplate.get_device_stream_template( - device_type, stream_type - ) + table_class = get_device_stream_template(device_type, stream_type) self.context[table_class.__name__] = table_class self._schema(table_class, context=self.context) From 9991746726df420a5b00b35e3dacb1be0b9b904e Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 24 Jan 2023 18:51:22 +0000 Subject: [PATCH 160/489] add docstring for DeviceConfig --- aeon/dj_pipeline/streams.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index 1d326a0d..434c88cb 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -21,6 +21,16 @@ class DeviceConfig: + """Class for storing & generating device type configuration + + Examples: + + device_configs = DeviceConfig.get_configs() + device = device_configs[0] + print(f"{device.type}, {device.desc}, {device.streams}") + + >> Camera, Camera device, (, , ) + """ DEVICE = namedtuple("DEVICE", "type desc streams") From acf3c00c80d3c0f30b2b3bd282c827c2aaaca792 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 25 Jan 2023 20:25:57 +0000 Subject: [PATCH 161/489] remove DeviceConfig class --- aeon/dj_pipeline/streams.py | 148 ++++++++++++++++++------------------ 1 file changed, 75 insertions(+), 73 deletions(-) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index 434c88cb..0d730470 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -3,12 +3,13 @@ from collections import defaultdict, namedtuple from functools import cached_property +import datajoint as dj +import pandas as pd + import aeon import aeon.schema.core as stream import aeon.schema.foraging as foraging import aeon.schema.octagon as octagon -import datajoint as dj -import pandas as pd from aeon.dj_pipeline import acquisition, dict_to_uuid, get_schema_name from aeon.io import api as io_api @@ -20,67 +21,58 @@ schema = dj.schema(schema_name) -class DeviceConfig: - """Class for storing & generating device type configuration - - Examples: - - device_configs = DeviceConfig.get_configs() - device = device_configs[0] - print(f"{device.type}, {device.desc}, {device.streams}") - - >> Camera, Camera device, (, , ) - """ - - DEVICE = namedtuple("DEVICE", "type desc streams") - - CONFIGS = [ - ( - "Camera", - "Camera device", - (stream.video, stream.position, foraging.region), - ), - ("Metadata", "Metadata", (stream.metadata,)), - ( - "ExperimentalMetadata", - "ExperimentalMetadata", - (stream.environment, stream.messageLog), - ), - ( - "NestScale", - "Weight scale at nest", - (foraging.weight,), - ), - ( - "FoodPatch", - "Food patch", - (foraging.patch,), - ), - ( - "Photodiode", - "Photodiode", - (octagon.photodiode,), - ), - ( - "OSC", - "OSC", - (octagon.OSC,), - ), - ( - "TaskLogic", - "TaskLogic", - (octagon.TaskLogic,), - ), - ( - "Wall", - "Wall", - (octagon.Wall,), - ), - ] - - @classmethod - def get_configs(cls): - return [cls.DEVICE(*c) for c in cls.CONFIGS] +__all__ = [ + "StreamType", + "DeviceType", + "Device", +] + + +# Read from this list of device configurations +# (device_type, description, streams) +DEVICE_CONFIGS = [ + ( + "Camera", + "Camera device", + (stream.video, stream.position, foraging.region), + ), + ("Metadata", "Metadata", (stream.metadata,)), + ( + "ExperimentalMetadata", + "ExperimentalMetadata", + (stream.environment, stream.messageLog), + ), + ( + "NestScale", + "Weight scale at nest", + (foraging.weight,), + ), + ( + "FoodPatch", + "Food patch", + (foraging.patch,), + ), + ( + "Photodiode", + "Photodiode", + (octagon.photodiode,), + ), + ( + "OSC", + "OSC", + (octagon.OSC,), + ), + ( + "TaskLogic", + "TaskLogic", + (octagon.TaskLogic,), + ), + ( + "Wall", + "Wall", + (octagon.Wall,), + ), +] @schema @@ -103,9 +95,9 @@ class StreamType(dj.Lookup): """ @staticmethod - def get_stream_entries(device: DeviceConfig, pattern="{pattern}") -> dict: + def get_stream_entries(device_streams: tuple, pattern="{pattern}") -> dict: - composite = aeon.io.device.compositeStream(pattern, *device.streams) + composite = aeon.io.device.compositeStream(pattern, *device_streams) stream_entries = [] for stream_name, stream_reader in composite.items(): if stream_name == pattern: @@ -131,14 +123,14 @@ def get_stream_entries(device: DeviceConfig, pattern="{pattern}") -> dict: return stream_entries @classmethod - def insert_streams(cls, device_configs: list[DeviceConfig] = []) -> list[dict]: + def insert_streams(cls, device_configs: list[namedtuple] = []): if not device_configs: - device_configs = DeviceConfig.get_configs() + device_configs = get_device_configs() for device in device_configs: - stream_entries = cls.get_stream_entries(device) + stream_entries = cls.get_stream_entries(device.streams) for entry in stream_entries: q_param = cls & {"stream_hash": entry["stream_hash"]} if q_param: # If the specified stream type already exists @@ -172,14 +164,14 @@ class Stream(dj.Part): """ @classmethod - def insert_devices(cls, device_configs: list[DeviceConfig] = []): + def insert_devices(cls, device_configs: list[namedtuple] = []): if not device_configs: - device_configs = DeviceConfig.get_configs() + device_configs = get_device_configs() for device in device_configs: - stream_entries = StreamType.get_stream_entries(device) + stream_entries = StreamType.get_stream_entries(device.streams) with cls.connection.transaction: @@ -212,6 +204,16 @@ class Device(dj.Lookup): """ +## --------- Helper functions & classes --------- ## + + +def get_device_configs(device_configs=DEVICE_CONFIGS) -> list[namedtuple]: + """Returns a list of device configurations from DEVICE_CONFIGS""" + + device = namedtuple("device", "type desc streams") + return [device._make(c) for c in device_configs] + + def get_device_template(device_type): """Returns table class template for ExperimentDevice""" device_title = device_type @@ -431,10 +433,10 @@ def create_device_stream_tables(self): # Main function def main(): - # # Populate StreamType + # Populate StreamType StreamType.insert_streams() - # # Populate DeviceType + # Populate DeviceType DeviceType.insert_devices() # Populate device tables From bc01beacf2b8e132bdd4ce59afa7da71a06bebdb Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 30 Jan 2023 17:08:53 -0600 Subject: [PATCH 162/489] feat: :sparkles: upgrade sciviz to the latest version --- aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml index 0148851d..22011361 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml @@ -7,7 +7,7 @@ services: pharus: cpus: 2.0 mem_limit: 4g - image: jverswijver/pharus:0.4.2-prerelease-5 + image: datajoint/pharus:0.7.2 environment: # - FLASK_ENV=development # enables logging to console from Flask - PHARUS_SPEC_PATH=/main/specs/specsheet.yaml # for dynamic utils spec @@ -48,7 +48,7 @@ services: sci-viz: cpus: 2.0 mem_limit: 4g - image: jverswijver/sci-viz:0.2.0-prerelease-7 + image: datajoint/sci-viz:1.0.0 environment: - CHOKIDAR_USEPOLLING=true - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/api @@ -90,7 +90,7 @@ services: networks: - main fakeservices.datajoint.io: - image: datajoint/nginx:v0.2.3 + image: datajoint/nginx:v0.2.4 environment: - ADD_pharus_TYPE=REST - ADD_pharus_ENDPOINT=pharus:5000 From 7eca7a9472f03c471345c196d5dc336137532c7c Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 30 Jan 2023 17:12:28 -0600 Subject: [PATCH 163/489] Add DataEntry tab --- .../dj_pipeline/webapps/sciviz/specsheet.yaml | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index d4ac4b05..6f62bd41 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -414,3 +414,34 @@ SciViz: def dj_query(aeon_workerlog): cls = aeon_workerlog.WorkerLog.proj(..., minutes_elapsed='TIMESTAMPDIFF(MINUTE, process_timestamp, UTC_TIMESTAMP())') return {'query': cls, 'fetch_args': {'order_by': 'minutes_elapsed ASC'}} + + DataEntry: + route: /subject_entry + grids: + grid5: + type: fixed + columns: 1 + row_height: 1000 + components: + Subject Form: + route: /subject_form + x: 0 + y: 0 + height: 0.5 + width: 1 + type: form + tables: + - aeon_subject.Subject + map: + - type: attribute + input: Subject ID + destination: subject + - type: attribute + input: Sex + destination: sex + - type: attribute + input: Date of Birth + destination: subject_birth_date + - type: attribute + input: Description + destination: subject_description From b26c6874c8f8092cc77470937c295fc1b55b4fa0 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 31 Jan 2023 15:59:26 -0600 Subject: [PATCH 164/489] add Experiment Entry in specsheet --- .../dj_pipeline/webapps/sciviz/specsheet.yaml | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 6f62bd41..05ed3685 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -423,11 +423,11 @@ SciViz: columns: 1 row_height: 1000 components: - Subject Form: + Subject Entry: route: /subject_form x: 0 y: 0 - height: 0.5 + height: 0.3 width: 1 type: form tables: @@ -445,3 +445,31 @@ SciViz: - type: attribute input: Description destination: subject_description + Experiment Entry: + route: /exp_form + x: 0 + y: 0.3 + height: 0.5 + width: 1 + type: form + tables: + - aeon_acquisition.Experiment + map: + - type: attribute + input: Experiment ID + destination: experiment_name + - type: attribute + input: Start Time + destination: experiment_start_time + - type: attribute + input: Description + destination: experiment_description + - type: table + input: Lab Arena + destination: aeon_lab.Arena + - type: table + input: Lab Location + destination: aeon_lab.Location + - type: attribute + input: Experiment Type + destination: experiment_type From d3522eb5052589570c4ee606fa00655f5bead5a8 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 7 Feb 2023 19:00:50 -0600 Subject: [PATCH 165/489] first prototype with sciviz video component --- aeon/dj_pipeline/streams.py | 23 ++--- .../webapps/sciviz/docker-compose-local.yaml | 6 +- .../webapps/sciviz/docker-compose-remote.yaml | 6 +- .../dj_pipeline/webapps/sciviz/specsheet.yaml | 94 +++++++++++++++++++ 4 files changed, 108 insertions(+), 21 deletions(-) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index 0d730470..4a2992a0 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -16,16 +16,16 @@ logger = dj.logger -# schema_name = f'u_{dj.config["database.user"]}_test' # for testing -schema_name = get_schema_name("streams") +schema_name = f'u_{dj.config["database.user"]}_streams' # for testing +# schema_name = get_schema_name("streams") schema = dj.schema(schema_name) -__all__ = [ - "StreamType", - "DeviceType", - "Device", -] +# __all__ = [ +# "StreamType", +# "DeviceType", +# "Device", +# ] # Read from this list of device configurations @@ -129,7 +129,6 @@ def insert_streams(cls, device_configs: list[namedtuple] = []): device_configs = get_device_configs() for device in device_configs: - stream_entries = cls.get_stream_entries(device.streams) for entry in stream_entries: q_param = cls & {"stream_hash": entry["stream_hash"]} @@ -165,16 +164,11 @@ class Stream(dj.Part): @classmethod def insert_devices(cls, device_configs: list[namedtuple] = []): - if not device_configs: device_configs = get_device_configs() - for device in device_configs: - stream_entries = StreamType.get_stream_entries(device.streams) - with cls.connection.transaction: - cls.insert1( { "device_type": device.type, @@ -182,7 +176,6 @@ def insert_devices(cls, device_configs: list[namedtuple] = []): }, skip_duplicates=True, ) - cls.Stream.insert( [ { @@ -451,4 +444,4 @@ def main(): tbmg.create_device_stream_tables() -main() +# main() diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml index 0148851d..d4f70c43 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml @@ -7,7 +7,7 @@ services: pharus: cpus: 2.0 mem_limit: 4g - image: jverswijver/pharus:0.4.2-prerelease-5 + image: datajoint/pharus:0.8.0 environment: # - FLASK_ENV=development # enables logging to console from Flask - PHARUS_SPEC_PATH=/main/specs/specsheet.yaml # for dynamic utils spec @@ -48,7 +48,7 @@ services: sci-viz: cpus: 2.0 mem_limit: 4g - image: jverswijver/sci-viz:0.2.0-prerelease-7 + image: datajoint/sci-viz:1.1.0 environment: - CHOKIDAR_USEPOLLING=true - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/api @@ -90,7 +90,7 @@ services: networks: - main fakeservices.datajoint.io: - image: datajoint/nginx:v0.2.3 + image: datajoint/nginx:v0.2.4 environment: - ADD_pharus_TYPE=REST - ADD_pharus_ENDPOINT=pharus:5000 diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml index 17acfadc..1e400ff7 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml @@ -6,7 +6,7 @@ services: pharus: cpus: 2.0 mem_limit: 4g - image: jverswijver/pharus:0.4.2-prerelease-5 + image: datajoint/pharus:0.8.0 environment: # - FLASK_ENV=development # enables logging to console from Flask - PHARUS_SPEC_PATH=/main/specs/specsheet.yaml # for dynamic utils spec @@ -26,7 +26,7 @@ services: sci-viz: cpus: 2.0 mem_limit: 4g - image: jverswijver/sci-viz:0.2.0-prerelease-7 + image: datajoint/sci-viz:1.1.0 environment: - CHOKIDAR_USEPOLLING=true - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/aeon/utils @@ -46,7 +46,7 @@ services: networks: - main reverse-proxy: - image: datajoint/nginx:v0.2.3 + image: datajoint/nginx:v0.2.4 environment: - ADD_pharus_TYPE=REST - ADD_pharus_ENDPOINT=pharus:5000 diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index d4ac4b05..99a07f3e 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -2,6 +2,29 @@ version: "v0.1.0" LabBook: null SciViz: auth: True + override: | + from pharus.component_interface import SlideshowComponent, type_map + from aeon.dj_pipeline.utils.video import retrieve_video_frames + + class AeonStreamerComponent(SlideshowComponent): + def dj_query_route(self): + fetch_metadata = self.fetch_metadata + + # Dj query provided should return only a video location + fetched_args = (fetch_metadata["query"] & self.restriction).fetch1( + *fetch_metadata["fetch_args"] + ) + + return ( + NumpyEncoder.dumps( + retrieve_video_frames(**fetched_args, **request.args["start_frame"]) + ), + 200, + {"Content-Type": "application/json"}, + ) + + type_map = dict({"aeon_video_streamer": AeonStreamerComponent}, **type_map) + pages: Subjects: route: /subjects @@ -319,6 +342,77 @@ SciViz: def dj_query(aeon_report): return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['region_time_fraction_hourly_plotly']) + VideoStream: + route: /videostream + grids: + grid1: + type: fixed + columns: 2 + row_height: 680 + components: + experiment_dropdown: + x: 0 + y: 0 + height: 1 + width: 1 + type: dropdown-query + channel: stream_experiment_selector + route: /videostream_experiment_dropdown + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_acquisition): + acquisition = aeon_acquisition + return {'query': aeon_acquisition.Experiment(), 'fetch_args': ['experiment_name']} + camera_dropdown: + x: 0 + y: 0 + height: 1 + width: 1 + route: /videostream_camera_dropdown + type: dropdown-query + channel: stream_camera_selector + channels: [ stream_experiment_selector ] + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_acquisition): + acquisition = aeon_acquisition + return {'query': aeon_acquisition.ExperimentCamera(), 'fetch_args': ['camera_description']} + time_range_selector: + x: 0 + y: 1 + height: 1 + width: 1 + type: daterangepicker + channel: stream_time_selector + video_streamer: + x: 1 + y: 0 + height: 1 + width: 3 + type: slideshow + route: /videostream_video_streamer + batch_size: 3 + chunk_size: 50 + buffer_size: 30 + max_FPS: 50 + channels: [ stream_experiment_selector, stream_camera_selector, stream_time_selector ] + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(test_group1_simple): + # SOME QUERY THAT RETURNS ONLY ONE RECORD ONE COLUMN THAT IS THE VIDEO NAME + TableA, TableB = (test_group1_simple.TableA, test_group1_simple.TableB) + q = ((TableA * TableB).proj( + ..., + #_sciviz_font='IF(a_name = "Raphael", "rgb(255, 0, 0)", NULL)', + _sciviz_background='IF(a_name = "Raphael", "rgba(50, 255, 0, 0.16)", "NULL")', + )) + return {'query': q, 'fetch_args': {'order_by': '_sciviz_background ASC'}} PipelineMonitor: route: /pipeline_monitor grids: From 0fdae9a239e1f0f919d3437f1eb97d96afbacdf1 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 7 Feb 2023 19:01:03 -0600 Subject: [PATCH 166/489] video_streamer util function --- aeon/dj_pipeline/utils/video.py | 59 +++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 aeon/dj_pipeline/utils/video.py diff --git a/aeon/dj_pipeline/utils/video.py b/aeon/dj_pipeline/utils/video.py new file mode 100644 index 00000000..d20615c7 --- /dev/null +++ b/aeon/dj_pipeline/utils/video.py @@ -0,0 +1,59 @@ +import numpy as np +import base64 +import pandas as pd +import pathlib +import datetime +import cv2 + +from aeon.io import api as io_api +from aeon.io import video as io_video +import aeon.io.reader as io_reader + + +camera_name = "CameraTop" +start_time = datetime.datetime(2022, 7, 23, 11, 0) +end_time = datetime.datetime(2022, 7, 23, 12, 0) +raw_data_dir = pathlib.Path("/ceph/aeon/aeon/data/raw/AEON2/experiment0.2") + + +def user_input(camera_name, start_time, end_time, desired_fps=50): + pass + + +def retrieve_video_frames( + camera_name, start_time, end_time, desired_fps=50, chunk_size=1000000 +): + # do some data loading + videodata = io_api.load( + root=raw_data_dir.as_posix(), + reader=io_reader.Video(camera_name), + start=pd.Timestamp(start_time), + end=pd.Timestamp(end_time), + ) + + # downsample + actual_fps = 1 / np.median(np.diff(videodata.index) / np.timedelta64(1, "s")) + final_fps = min(desired_fps, actual_fps) + ds_factor = int(np.around(actual_fps / final_fps)) + framedata = videodata[::ds_factor] + + # read frames + frames = io_video.frames(framedata) + + encoded_frames = [] + total_bytes = 0 + for f in frames: + if total_bytes >= chunk_size: + break + encoded_f = cv2.imencode(".jpeg", f)[1].tobytes() + encoded_frames.append(base64.b64encode(encoded_f)) + total_bytes += len(encoded_f) + + return { + "frame_meta": { + "fps": final_fps, + "frame_count": len(encoded_frames), + "end_time": str(framedata.index[len(encoded_frames) - 1]), + }, + "frame_data": encoded_frames, + } From c4adb510654a4658d28c61ec9be1111d2e1f4cc8 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 7 Feb 2023 19:03:11 -0600 Subject: [PATCH 167/489] install aeon_mecha repo --- aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml | 1 + aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml | 1 + 2 files changed, 2 insertions(+) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml index d4f70c43..522999e8 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml @@ -20,6 +20,7 @@ services: - sh - -c - | + pip install git+https://github.com/SainsburyWellcomeCentre/aeon_mecha.git@datajont_pipeline & pharus_update() { [ -z "$$GUNICORN_PID" ] || kill $$GUNICORN_PID gunicorn --bind 0.0.0.0:$${PHARUS_PORT} pharus.server:app & diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml index 1e400ff7..e54fa494 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml @@ -39,6 +39,7 @@ services: - sh - -c - | + pip install git+https://github.com/SainsburyWellcomeCentre/aeon_mecha.git@datajont_pipeline & python frontend_gen.py npm run build mv ./build /usr/share/nginx/html From 8de7ac03e9b22a954222e8264abe8a34116de8f8 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 8 Feb 2023 16:08:38 -0600 Subject: [PATCH 168/489] update sciviz video component --- aeon/dj_pipeline/utils/video.py | 4 +-- .../webapps/sciviz/apk_requirements.txt | 3 +- .../webapps/sciviz/docker-compose-local.yaml | 8 +++-- .../dj_pipeline/webapps/sciviz/specsheet.yaml | 34 ++++++++----------- 4 files changed, 23 insertions(+), 26 deletions(-) diff --git a/aeon/dj_pipeline/utils/video.py b/aeon/dj_pipeline/utils/video.py index d20615c7..d5f113fb 100644 --- a/aeon/dj_pipeline/utils/video.py +++ b/aeon/dj_pipeline/utils/video.py @@ -11,8 +11,8 @@ camera_name = "CameraTop" -start_time = datetime.datetime(2022, 7, 23, 11, 0) -end_time = datetime.datetime(2022, 7, 23, 12, 0) +# start_time = datetime.datetime(2022, 7, 23, 11, 0) +# end_time = datetime.datetime(2022, 7, 23, 12, 0) raw_data_dir = pathlib.Path("/ceph/aeon/aeon/data/raw/AEON2/experiment0.2") diff --git a/aeon/dj_pipeline/webapps/sciviz/apk_requirements.txt b/aeon/dj_pipeline/webapps/sciviz/apk_requirements.txt index f4d7fa57..c9d2ebed 100644 --- a/aeon/dj_pipeline/webapps/sciviz/apk_requirements.txt +++ b/aeon/dj_pipeline/webapps/sciviz/apk_requirements.txt @@ -1 +1,2 @@ -bash \ No newline at end of file +bash +git \ No newline at end of file diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml index 522999e8..bf083f45 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml @@ -7,7 +7,7 @@ services: pharus: cpus: 2.0 mem_limit: 4g - image: datajoint/pharus:0.8.0 + image: jverswijver/pharus:0.8.0py3.9 environment: # - FLASK_ENV=development # enables logging to console from Flask - PHARUS_SPEC_PATH=/main/specs/specsheet.yaml # for dynamic utils spec @@ -20,7 +20,8 @@ services: - sh - -c - | - pip install git+https://github.com/SainsburyWellcomeCentre/aeon_mecha.git@datajont_pipeline & + apk add --update git g++ && + pip install git+https://github.com/SainsburyWellcomeCentre/aeon_mecha.git@datajoint_pipeline && pharus_update() { [ -z "$$GUNICORN_PID" ] || kill $$GUNICORN_PID gunicorn --bind 0.0.0.0:$${PHARUS_PORT} pharus.server:app & @@ -48,13 +49,14 @@ services: - main sci-viz: cpus: 2.0 - mem_limit: 4g + mem_limit: 16g image: datajoint/sci-viz:1.1.0 environment: - CHOKIDAR_USEPOLLING=true - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/api - DJSCIVIZ_SPEC_PATH=specsheet.yaml - DJSCIVIZ_MODE=DEV + - NODE_OPTIONS="--max-old-space-size=12000" volumes: - ./specsheet.yaml:/main/specsheet.yaml # ports: diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 99a07f3e..7f8e27db 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -6,7 +6,7 @@ SciViz: from pharus.component_interface import SlideshowComponent, type_map from aeon.dj_pipeline.utils.video import retrieve_video_frames - class AeonStreamerComponent(SlideshowComponent): + class AeonSlideshowComponent(SlideshowComponent): def dj_query_route(self): fetch_metadata = self.fetch_metadata @@ -23,7 +23,7 @@ SciViz: {"Content-Type": "application/json"}, ) - type_map = dict({"aeon_video_streamer": AeonStreamerComponent}, **type_map) + type_map = dict({"slideshow-aeon": AeonSlideshowComponent}, **type_map) pages: Subjects: @@ -347,8 +347,8 @@ SciViz: grids: grid1: type: fixed - columns: 2 - row_height: 680 + columns: 3 + row_height: 100 components: experiment_dropdown: x: 0 @@ -367,7 +367,7 @@ SciViz: return {'query': aeon_acquisition.Experiment(), 'fetch_args': ['experiment_name']} camera_dropdown: x: 0 - y: 0 + y: 1 height: 1 width: 1 route: /videostream_camera_dropdown @@ -379,11 +379,11 @@ SciViz: return dict(**kwargs) dj_query: > def dj_query(aeon_acquisition): - acquisition = aeon_acquisition - return {'query': aeon_acquisition.ExperimentCamera(), 'fetch_args': ['camera_description']} + q = aeon_acquisition.ExperimentCamera.proj('camera_description') + return {'query': q, 'fetch_args': ['camera_description']} time_range_selector: x: 0 - y: 1 + y: 2 height: 1 width: 1 type: daterangepicker @@ -391,9 +391,9 @@ SciViz: video_streamer: x: 1 y: 0 - height: 1 - width: 3 - type: slideshow + height: 5 + width: 2 + type: slideshow-aeon route: /videostream_video_streamer batch_size: 3 chunk_size: 50 @@ -404,15 +404,9 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(test_group1_simple): - # SOME QUERY THAT RETURNS ONLY ONE RECORD ONE COLUMN THAT IS THE VIDEO NAME - TableA, TableB = (test_group1_simple.TableA, test_group1_simple.TableB) - q = ((TableA * TableB).proj( - ..., - #_sciviz_font='IF(a_name = "Raphael", "rgb(255, 0, 0)", NULL)', - _sciviz_background='IF(a_name = "Raphael", "rgba(50, 255, 0, 0.16)", "NULL")', - )) - return {'query': q, 'fetch_args': {'order_by': '_sciviz_background ASC'}} + def dj_query(aeon_acquisition): + q = aeon_acquisition.ExperimentCamera + return {'query': q, 'fetch_args': []} PipelineMonitor: route: /pipeline_monitor grids: From dcadd496a1cda3e5453f84b48eefeac285c76211 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 8 Feb 2023 16:09:19 -0600 Subject: [PATCH 169/489] add tracking method/params for SLEAP --- aeon/dj_pipeline/tracking.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/tracking.py b/aeon/dj_pipeline/tracking.py index 7aee784e..837789e1 100644 --- a/aeon/dj_pipeline/tracking.py +++ b/aeon/dj_pipeline/tracking.py @@ -26,7 +26,10 @@ class TrackingMethod(dj.Lookup): tracking_method_description: varchar(256) """ - contents = [("DLC", "Online DeepLabCut as part of Bonsai workflow")] + contents = [ + ("DLC", "Online DeepLabCut as part of Bonsai workflow"), + ("SLEAP", "Online SLEAP as part of Bonsai workflow"), + ] @schema @@ -48,7 +51,14 @@ class TrackingParamSet(dj.Lookup): "Default DLC method from online Bonsai - with params as empty dictionary", dict_to_uuid({"tracking_method": "DLC"}), {}, - ) + ), + ( + 1, + "SLEAP", + "Default SLEAP method from online Bonsai - with params as empty dictionary", + dict_to_uuid({"tracking_method": "SLEAP"}), + {}, + ), ] @classmethod From 1af1cf8fb18d0c1e4c489fdc3dd8e4e73be760cd Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Mon, 13 Feb 2023 09:24:43 -0600 Subject: [PATCH 170/489] Update specsheet.yaml --- .../dj_pipeline/webapps/sciviz/specsheet.yaml | 47 ++++++++++--------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 7f8e27db..0fbb8459 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -2,28 +2,29 @@ version: "v0.1.0" LabBook: null SciViz: auth: True - override: | - from pharus.component_interface import SlideshowComponent, type_map - from aeon.dj_pipeline.utils.video import retrieve_video_frames - - class AeonSlideshowComponent(SlideshowComponent): - def dj_query_route(self): - fetch_metadata = self.fetch_metadata - - # Dj query provided should return only a video location - fetched_args = (fetch_metadata["query"] & self.restriction).fetch1( - *fetch_metadata["fetch_args"] - ) - - return ( - NumpyEncoder.dumps( - retrieve_video_frames(**fetched_args, **request.args["start_frame"]) - ), - 200, - {"Content-Type": "application/json"}, - ) - - type_map = dict({"slideshow-aeon": AeonSlideshowComponent}, **type_map) + component_interface: + override: | + from pharus.component_interface import SlideshowComponent, type_map + from aeon.dj_pipeline.utils.video import retrieve_video_frames + + class AeonSlideshowComponent(SlideshowComponent): + def dj_query_route(self): + fetch_metadata = self.fetch_metadata + + # Dj query provided should return only a video location + fetched_args = (fetch_metadata["query"] & self.restriction).fetch1( + *fetch_metadata["fetch_args"] + ) + + return ( + NumpyEncoder.dumps( + retrieve_video_frames(**fetched_args, **request.args["start_frame"]) + ), + 200, + {"Content-Type": "application/json"}, + ) + + type_map = dict({"slideshow:aeon": AeonSlideshowComponent}, **type_map) pages: Subjects: @@ -393,7 +394,7 @@ SciViz: y: 0 height: 5 width: 2 - type: slideshow-aeon + type: slideshow:aeon route: /videostream_video_streamer batch_size: 3 chunk_size: 50 From 61c59891e4bc8f4fefd0a615ce1bc0b33d65e17f Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Mon, 13 Feb 2023 12:34:38 -0600 Subject: [PATCH 171/489] update video streamer --- aeon/dj_pipeline/utils/video.py | 2 +- .../dj_pipeline/webapps/sciviz/docker-compose-local.yaml | 3 ++- aeon/dj_pipeline/webapps/sciviz/specsheet.yaml | 9 +++++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/aeon/dj_pipeline/utils/video.py b/aeon/dj_pipeline/utils/video.py index d5f113fb..712f80bc 100644 --- a/aeon/dj_pipeline/utils/video.py +++ b/aeon/dj_pipeline/utils/video.py @@ -21,7 +21,7 @@ def user_input(camera_name, start_time, end_time, desired_fps=50): def retrieve_video_frames( - camera_name, start_time, end_time, desired_fps=50, chunk_size=1000000 + camera_name, start_time, end_time, desired_fps=50, chunk_size=1000000, **kwargs ): # do some data loading videodata = io_api.load( diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml index bf083f45..6c28c6aa 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml @@ -21,7 +21,8 @@ services: - -c - | apk add --update git g++ && - pip install git+https://github.com/SainsburyWellcomeCentre/aeon_mecha.git@datajoint_pipeline && + git clone -b datajoint_pipeline https://github.com/ttngu207/aeon_mecha.git && + pip install -e ./aeon_mecha && pharus_update() { [ -z "$$GUNICORN_PID" ] || kill $$GUNICORN_PID gunicorn --bind 0.0.0.0:$${PHARUS_PORT} pharus.server:app & diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 0fbb8459..c46db32b 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -4,7 +4,8 @@ SciViz: auth: True component_interface: override: | - from pharus.component_interface import SlideshowComponent, type_map + from pharus.component_interface import SlideshowComponent, NumpyEncoder, type_map + from flask import request from aeon.dj_pipeline.utils.video import retrieve_video_frames class AeonSlideshowComponent(SlideshowComponent): @@ -15,10 +16,14 @@ SciViz: fetched_args = (fetch_metadata["query"] & self.restriction).fetch1( *fetch_metadata["fetch_args"] ) + kwargs = {**fetched_args, **request.args} + kwargs['camera_name'] = kwargs.pop('camera_description') + kwargs['start_time'] = kwargs.pop('startTime') + kwargs['end_time'] = kwargs.pop('endTime') return ( NumpyEncoder.dumps( - retrieve_video_frames(**fetched_args, **request.args["start_frame"]) + retrieve_video_frames(**kwargs) ), 200, {"Content-Type": "application/json"}, From 1f8bafb3ffee64d9b6a4e6c2c0f54daae4cdf320 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Mon, 13 Feb 2023 13:39:45 -0600 Subject: [PATCH 172/489] Update video.py --- aeon/dj_pipeline/utils/video.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/utils/video.py b/aeon/dj_pipeline/utils/video.py index 712f80bc..52acd595 100644 --- a/aeon/dj_pipeline/utils/video.py +++ b/aeon/dj_pipeline/utils/video.py @@ -11,8 +11,8 @@ camera_name = "CameraTop" -# start_time = datetime.datetime(2022, 7, 23, 11, 0) -# end_time = datetime.datetime(2022, 7, 23, 12, 0) +start_time = datetime.datetime(2022, 7, 23, 11, 0) +end_time = datetime.datetime(2022, 7, 23, 12, 0) raw_data_dir = pathlib.Path("/ceph/aeon/aeon/data/raw/AEON2/experiment0.2") @@ -30,6 +30,8 @@ def retrieve_video_frames( start=pd.Timestamp(start_time), end=pd.Timestamp(end_time), ) + if not len(videodata): + raise ValueError("No video data found for the specified camera and time period") # downsample actual_fps = 1 / np.median(np.diff(videodata.index) / np.timedelta64(1, "s")) From 5a7f56809c4691e7b4b39a44687f8c95efdaaf50 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Mon, 13 Feb 2023 14:06:34 -0600 Subject: [PATCH 173/489] Update video.py --- aeon/dj_pipeline/utils/video.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/aeon/dj_pipeline/utils/video.py b/aeon/dj_pipeline/utils/video.py index 52acd595..b320f5a1 100644 --- a/aeon/dj_pipeline/utils/video.py +++ b/aeon/dj_pipeline/utils/video.py @@ -16,10 +16,6 @@ raw_data_dir = pathlib.Path("/ceph/aeon/aeon/data/raw/AEON2/experiment0.2") -def user_input(camera_name, start_time, end_time, desired_fps=50): - pass - - def retrieve_video_frames( camera_name, start_time, end_time, desired_fps=50, chunk_size=1000000, **kwargs ): @@ -31,7 +27,9 @@ def retrieve_video_frames( end=pd.Timestamp(end_time), ) if not len(videodata): - raise ValueError("No video data found for the specified camera and time period") + raise ValueError( + f"No video data found for {camera_name} camera and time period: {start_time} - {end_time}" + ) # downsample actual_fps = 1 / np.median(np.diff(videodata.index) / np.timedelta64(1, "s")) From dfa039723c153237d07952055296f867dd020233 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 14 Feb 2023 10:39:45 -0600 Subject: [PATCH 174/489] Update video.py --- aeon/dj_pipeline/utils/video.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/aeon/dj_pipeline/utils/video.py b/aeon/dj_pipeline/utils/video.py index b320f5a1..21a1d1e2 100644 --- a/aeon/dj_pipeline/utils/video.py +++ b/aeon/dj_pipeline/utils/video.py @@ -49,11 +49,14 @@ def retrieve_video_frames( encoded_frames.append(base64.b64encode(encoded_f)) total_bytes += len(encoded_f) + last_frame_time = framedata.index[len(encoded_frames) - 1] + return { - "frame_meta": { + "frameMeta": { "fps": final_fps, - "frame_count": len(encoded_frames), - "end_time": str(framedata.index[len(encoded_frames) - 1]), + "frameCount": len(encoded_frames), + "endTime": str(last_frame_time), + "finalChunk": bool(last_frame_time >= end_time), }, "frame_data": encoded_frames, } From 112b93459c66a102c85ffad4f75f07a77762b7d4 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 14 Feb 2023 14:22:36 -0600 Subject: [PATCH 175/489] testing - hardcode start/end time --- aeon/dj_pipeline/utils/video.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/aeon/dj_pipeline/utils/video.py b/aeon/dj_pipeline/utils/video.py index 21a1d1e2..4dcb3c18 100644 --- a/aeon/dj_pipeline/utils/video.py +++ b/aeon/dj_pipeline/utils/video.py @@ -17,8 +17,16 @@ def retrieve_video_frames( - camera_name, start_time, end_time, desired_fps=50, chunk_size=1000000, **kwargs + camera_name, + start_time, + end_time, + desired_fps=50, + start_frame=0, + chunk_size=50, + **kwargs, ): + start_time = datetime.datetime(2022, 7, 23, 11, 0) + end_time = datetime.datetime(2022, 7, 23, 12, 0) # do some data loading videodata = io_api.load( root=raw_data_dir.as_posix(), @@ -31,6 +39,8 @@ def retrieve_video_frames( f"No video data found for {camera_name} camera and time period: {start_time} - {end_time}" ) + videodata = videodata[start_frame:] + # downsample actual_fps = 1 / np.median(np.diff(videodata.index) / np.timedelta64(1, "s")) final_fps = min(desired_fps, actual_fps) @@ -41,13 +51,13 @@ def retrieve_video_frames( frames = io_video.frames(framedata) encoded_frames = [] - total_bytes = 0 + frame_count = 0 for f in frames: - if total_bytes >= chunk_size: + if frame_count >= chunk_size: break encoded_f = cv2.imencode(".jpeg", f)[1].tobytes() encoded_frames.append(base64.b64encode(encoded_f)) - total_bytes += len(encoded_f) + frame_count += 1 last_frame_time = framedata.index[len(encoded_frames) - 1] From bbed956762552e0f433c4fbffc794ca0dbdc27ea Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 14 Feb 2023 16:06:39 -0600 Subject: [PATCH 176/489] feat: update sciviz to fix the datetime error --- aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml index 22011361..15709f2b 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml @@ -7,7 +7,7 @@ services: pharus: cpus: 2.0 mem_limit: 4g - image: datajoint/pharus:0.7.2 + image: datajoint/pharus:0.7.3 environment: # - FLASK_ENV=development # enables logging to console from Flask - PHARUS_SPEC_PATH=/main/specs/specsheet.yaml # for dynamic utils spec @@ -48,7 +48,7 @@ services: sci-viz: cpus: 2.0 mem_limit: 4g - image: datajoint/sci-viz:1.0.0 + image: datajoint/sci-viz:1.1.1 environment: - CHOKIDAR_USEPOLLING=true - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/api From da1f23fe4a7ce7e2409627c9e2ca51f4ac62dd91 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 15 Feb 2023 09:45:18 -0600 Subject: [PATCH 177/489] update docker for sciviz prod-mode --- aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml | 3 ++- .../dj_pipeline/webapps/sciviz/docker-compose-remote.yaml | 8 ++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml index 6c28c6aa..91e35bfa 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml @@ -103,9 +103,10 @@ services: - ADD_sciviz_ENDPOINT=sci-viz:3000 - ADD_sciviz_PREFIX=/ - HTTPS_PASSTHRU=TRUE + - DEPLOYMENT_PORT ports: - "443:443" - - "80:80" + - "${DEPLOYMENT_PORT:-80}:80" networks: - main networks: diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml index e54fa494..c5359c26 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml @@ -6,7 +6,7 @@ services: pharus: cpus: 2.0 mem_limit: 4g - image: datajoint/pharus:0.8.0 + image: jverswijver/pharus:0.8.0py3.9 environment: # - FLASK_ENV=development # enables logging to console from Flask - PHARUS_SPEC_PATH=/main/specs/specsheet.yaml # for dynamic utils spec @@ -18,6 +18,9 @@ services: - sh - -c - | + apk add --update git g++ && + git clone -b datajoint_pipeline https://github.com/ttngu207/aeon_mecha.git && + pip install -e ./aeon_mecha && gunicorn --bind 0.0.0.0:$${PHARUS_PORT} pharus.server:app # ports: # - "5000:5000" @@ -25,12 +28,13 @@ services: - main sci-viz: cpus: 2.0 - mem_limit: 4g + mem_limit: 16g image: datajoint/sci-viz:1.1.0 environment: - CHOKIDAR_USEPOLLING=true - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/aeon/utils - DJSCIVIZ_SPEC_PATH=specsheet.yaml + - NODE_OPTIONS="--max-old-space-size=12000" volumes: - ./specsheet.yaml:/main/specsheet.yaml # ports: From 0e4fd9ed499bd5c1bc9c7be98e6ef29daf7faaa3 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 15 Feb 2023 12:40:02 -0600 Subject: [PATCH 178/489] Update docker-compose-remote.yaml --- aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml index c5359c26..7de46f54 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml @@ -43,7 +43,6 @@ services: - sh - -c - | - pip install git+https://github.com/SainsburyWellcomeCentre/aeon_mecha.git@datajont_pipeline & python frontend_gen.py npm run build mv ./build /usr/share/nginx/html From 64c93370cd70721499b6090842c0d4a44615da4c Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 17 Feb 2023 11:10:51 -0600 Subject: [PATCH 179/489] Update docker-compose-local.yaml --- aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml index 91e35bfa..245153cc 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml @@ -16,6 +16,7 @@ services: volumes: - ./specsheet.yaml:/main/specs/specsheet.yaml #copy the spec over to /main/specs/YOUR_SPEC_NAME - ./apk_requirements.txt:/tmp/apk_requirements.txt + - /ceph/aeon/aeon:/ceph/aeon/aeon command: - sh - -c @@ -102,7 +103,7 @@ services: - ADD_sciviz_TYPE=REST - ADD_sciviz_ENDPOINT=sci-viz:3000 - ADD_sciviz_PREFIX=/ - - HTTPS_PASSTHRU=TRUE +# - HTTPS_PASSTHRU=TRUE - DEPLOYMENT_PORT ports: - "443:443" From 49c2cb4c38f9a84b4f9ae988fa4e60ac1d84d00a Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 17 Feb 2023 14:30:06 -0600 Subject: [PATCH 180/489] bugfix --- aeon/dj_pipeline/webapps/sciviz/specsheet.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index c46db32b..b84cad3b 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -20,6 +20,8 @@ SciViz: kwargs['camera_name'] = kwargs.pop('camera_description') kwargs['start_time'] = kwargs.pop('startTime') kwargs['end_time'] = kwargs.pop('endTime') + kwargs['start_frame'] = int(kwargs.pop('start_frame')) + kwargs['chunk_size'] = int(kwargs.pop('chunk_size')) return ( NumpyEncoder.dumps( From 95b71007d8c9d5ec0e61488fa478cd97462c9f0e Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 17 Feb 2023 14:46:54 -0600 Subject: [PATCH 181/489] Update video.py --- aeon/dj_pipeline/utils/video.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/utils/video.py b/aeon/dj_pipeline/utils/video.py index 4dcb3c18..1e4136d8 100644 --- a/aeon/dj_pipeline/utils/video.py +++ b/aeon/dj_pipeline/utils/video.py @@ -56,7 +56,7 @@ def retrieve_video_frames( if frame_count >= chunk_size: break encoded_f = cv2.imencode(".jpeg", f)[1].tobytes() - encoded_frames.append(base64.b64encode(encoded_f)) + encoded_frames.append(base64.b64encode(encoded_f).decode()) frame_count += 1 last_frame_time = framedata.index[len(encoded_frames) - 1] From bab40f24a0398a17212d42639559fa2fa5e9ba29 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Mon, 20 Feb 2023 16:23:01 -0600 Subject: [PATCH 182/489] test yarn start --- .../webapps/sciviz/docker-compose-local.yaml | 27 ++----------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml index 245153cc..99ca6df8 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml @@ -67,31 +67,8 @@ services: - sh - -c - | - sciviz_update() { - [ -z "$$NGINX_PID" ] || kill $$NGINX_PID - rm -R /usr/share/nginx/html - python frontend_gen.py - yarn build - mv ./build /usr/share/nginx/html - nginx -g "daemon off;" & - NGINX_PID=$$! - } - sciviz_update - echo "[$$(date -u '+%Y-%m-%d %H:%M:%S')][DataJoint]: Monitoring SciViz updates..." - INIT_TIME=$$(date +%s) - LAST_MOD_TIME=$$(date -r $$DJSCIVIZ_SPEC_PATH +%s) - DELTA=$$(expr $$LAST_MOD_TIME - $$INIT_TIME) - while true; do - CURR_LAST_MOD_TIME=$$(date -r $$DJSCIVIZ_SPEC_PATH +%s) - CURR_DELTA=$$(expr $$CURR_LAST_MOD_TIME - $$INIT_TIME) - if [ "$$DELTA" -lt "$$CURR_DELTA" ]; then - echo "[$$(date -u '+%Y-%m-%d %H:%M:%S')][DataJoint]: Reloading SciViz since \`$$DJSCIVIZ_SPEC_PATH\` changed." - sciviz_update - DELTA=$$CURR_DELTA - else - sleep 5 - fi - done + python frontend_gen.py + yarn start networks: - main fakeservices.datajoint.io: From 0a67925acf1e560ec262837947d99156fc8eeb83 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Mon, 20 Feb 2023 16:37:15 -0600 Subject: [PATCH 183/489] bugfix --- aeon/dj_pipeline/utils/video.py | 4 +-- .../webapps/sciviz/docker-compose-local.yaml | 27 +++++++++++++++++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/aeon/dj_pipeline/utils/video.py b/aeon/dj_pipeline/utils/video.py index 1e4136d8..0d4f5129 100644 --- a/aeon/dj_pipeline/utils/video.py +++ b/aeon/dj_pipeline/utils/video.py @@ -65,8 +65,8 @@ def retrieve_video_frames( "frameMeta": { "fps": final_fps, "frameCount": len(encoded_frames), - "endTime": str(last_frame_time), + # "endTime": str(last_frame_time), "finalChunk": bool(last_frame_time >= end_time), }, - "frame_data": encoded_frames, + "frames": encoded_frames, } diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml index 99ca6df8..245153cc 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml @@ -67,8 +67,31 @@ services: - sh - -c - | - python frontend_gen.py - yarn start + sciviz_update() { + [ -z "$$NGINX_PID" ] || kill $$NGINX_PID + rm -R /usr/share/nginx/html + python frontend_gen.py + yarn build + mv ./build /usr/share/nginx/html + nginx -g "daemon off;" & + NGINX_PID=$$! + } + sciviz_update + echo "[$$(date -u '+%Y-%m-%d %H:%M:%S')][DataJoint]: Monitoring SciViz updates..." + INIT_TIME=$$(date +%s) + LAST_MOD_TIME=$$(date -r $$DJSCIVIZ_SPEC_PATH +%s) + DELTA=$$(expr $$LAST_MOD_TIME - $$INIT_TIME) + while true; do + CURR_LAST_MOD_TIME=$$(date -r $$DJSCIVIZ_SPEC_PATH +%s) + CURR_DELTA=$$(expr $$CURR_LAST_MOD_TIME - $$INIT_TIME) + if [ "$$DELTA" -lt "$$CURR_DELTA" ]; then + echo "[$$(date -u '+%Y-%m-%d %H:%M:%S')][DataJoint]: Reloading SciViz since \`$$DJSCIVIZ_SPEC_PATH\` changed." + sciviz_update + DELTA=$$CURR_DELTA + else + sleep 5 + fi + done networks: - main fakeservices.datajoint.io: From 670f4ff7a0b0d19feec9f67a2c05f19517caa89f Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Mon, 20 Feb 2023 16:56:20 -0600 Subject: [PATCH 184/489] optimize loading speed --- aeon/dj_pipeline/utils/video.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/aeon/dj_pipeline/utils/video.py b/aeon/dj_pipeline/utils/video.py index 0d4f5129..499b70bc 100644 --- a/aeon/dj_pipeline/utils/video.py +++ b/aeon/dj_pipeline/utils/video.py @@ -25,8 +25,8 @@ def retrieve_video_frames( chunk_size=50, **kwargs, ): - start_time = datetime.datetime(2022, 7, 23, 11, 0) - end_time = datetime.datetime(2022, 7, 23, 12, 0) + start_time = datetime.datetime(2022, 4, 3, 13, 0, 0) + end_time = datetime.datetime(2022, 4, 3, 15, 0, 0) # do some data loading videodata = io_api.load( root=raw_data_dir.as_posix(), @@ -39,25 +39,22 @@ def retrieve_video_frames( f"No video data found for {camera_name} camera and time period: {start_time} - {end_time}" ) - videodata = videodata[start_frame:] + framedata = videodata[start_frame : start_frame + chunk_size] # downsample - actual_fps = 1 / np.median(np.diff(videodata.index) / np.timedelta64(1, "s")) - final_fps = min(desired_fps, actual_fps) - ds_factor = int(np.around(actual_fps / final_fps)) - framedata = videodata[::ds_factor] + # actual_fps = 1 / np.median(np.diff(videodata.index) / np.timedelta64(1, "s")) + # final_fps = min(desired_fps, actual_fps) + # ds_factor = int(np.around(actual_fps / final_fps)) + # framedata = videodata[::ds_factor] + final_fps = desired_fps # read frames frames = io_video.frames(framedata) encoded_frames = [] - frame_count = 0 for f in frames: - if frame_count >= chunk_size: - break encoded_f = cv2.imencode(".jpeg", f)[1].tobytes() encoded_frames.append(base64.b64encode(encoded_f).decode()) - frame_count += 1 last_frame_time = framedata.index[len(encoded_frames) - 1] From 29689204842aabf13031919de36797d6b4b1e88d Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 21 Feb 2023 16:23:47 -0600 Subject: [PATCH 185/489] new sciviz version --- aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml | 2 +- aeon/dj_pipeline/webapps/sciviz/specsheet.yaml | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml index 7de46f54..22114032 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml @@ -29,7 +29,7 @@ services: sci-viz: cpus: 2.0 mem_limit: 16g - image: datajoint/sci-viz:1.1.0 + image: jverswijver/sci-viz:1.1.1-bugfix1 environment: - CHOKIDAR_USEPOLLING=true - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/aeon/utils diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index b84cad3b..7235c5c5 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -4,6 +4,7 @@ SciViz: auth: True component_interface: override: | + from datetime import datetime from pharus.component_interface import SlideshowComponent, NumpyEncoder, type_map from flask import request from aeon.dj_pipeline.utils.video import retrieve_video_frames @@ -18,8 +19,8 @@ SciViz: ) kwargs = {**fetched_args, **request.args} kwargs['camera_name'] = kwargs.pop('camera_description') - kwargs['start_time'] = kwargs.pop('startTime') - kwargs['end_time'] = kwargs.pop('endTime') + kwargs['start_time'] = datetime.utcfromtimestamp(kwargs.pop('startTime')) + kwargs['end_time'] = datetime.utcfromtimestamp(kwargs.pop('endTime')) kwargs['start_frame'] = int(kwargs.pop('start_frame')) kwargs['chunk_size'] = int(kwargs.pop('chunk_size')) From 87581878fa9088bae32ad30c232438e016ea8174 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 21 Feb 2023 16:47:39 -0600 Subject: [PATCH 186/489] bugfix, remove hardcoded start/end time --- aeon/dj_pipeline/utils/video.py | 8 +++----- aeon/dj_pipeline/webapps/sciviz/specsheet.yaml | 4 ++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/aeon/dj_pipeline/utils/video.py b/aeon/dj_pipeline/utils/video.py index 499b70bc..15cdbfb7 100644 --- a/aeon/dj_pipeline/utils/video.py +++ b/aeon/dj_pipeline/utils/video.py @@ -11,8 +11,8 @@ camera_name = "CameraTop" -start_time = datetime.datetime(2022, 7, 23, 11, 0) -end_time = datetime.datetime(2022, 7, 23, 12, 0) +start_time = datetime.datetime(2022, 4, 3, 13, 0, 0) +end_time = datetime.datetime(2022, 4, 3, 15, 0, 0) raw_data_dir = pathlib.Path("/ceph/aeon/aeon/data/raw/AEON2/experiment0.2") @@ -25,8 +25,6 @@ def retrieve_video_frames( chunk_size=50, **kwargs, ): - start_time = datetime.datetime(2022, 4, 3, 13, 0, 0) - end_time = datetime.datetime(2022, 4, 3, 15, 0, 0) # do some data loading videodata = io_api.load( root=raw_data_dir.as_posix(), @@ -62,7 +60,7 @@ def retrieve_video_frames( "frameMeta": { "fps": final_fps, "frameCount": len(encoded_frames), - # "endTime": str(last_frame_time), + "endTime": str(last_frame_time), "finalChunk": bool(last_frame_time >= end_time), }, "frames": encoded_frames, diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 7235c5c5..58ed0d8a 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -19,8 +19,8 @@ SciViz: ) kwargs = {**fetched_args, **request.args} kwargs['camera_name'] = kwargs.pop('camera_description') - kwargs['start_time'] = datetime.utcfromtimestamp(kwargs.pop('startTime')) - kwargs['end_time'] = datetime.utcfromtimestamp(kwargs.pop('endTime')) + kwargs['start_time'] = datetime.utcfromtimestamp(int(kwargs.pop('startTime'))) + kwargs['end_time'] = datetime.utcfromtimestamp(int(kwargs.pop('endTime'))) kwargs['start_frame'] = int(kwargs.pop('start_frame')) kwargs['chunk_size'] = int(kwargs.pop('chunk_size')) From 9ad8ca5c3449a2711c46854cf884baf4a1692220 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 21 Feb 2023 17:00:29 -0600 Subject: [PATCH 187/489] Update docker-compose-local.yaml --- aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml index 245153cc..e21360f0 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml @@ -52,7 +52,7 @@ services: sci-viz: cpus: 2.0 mem_limit: 16g - image: datajoint/sci-viz:1.1.0 + image: jverswijver/sci-viz:1.1.1-bugfix1 environment: - CHOKIDAR_USEPOLLING=true - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/api From 25ebe29cade08315566e34fc9bf5017a2f0ebc4e Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 22 Feb 2023 14:57:42 -0600 Subject: [PATCH 188/489] update sciviz image --- aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml | 2 +- aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml index e21360f0..597d2f3c 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml @@ -52,7 +52,7 @@ services: sci-viz: cpus: 2.0 mem_limit: 16g - image: jverswijver/sci-viz:1.1.1-bugfix1 + image: jverswijver/sci-viz:1.1.1-bugfix2 environment: - CHOKIDAR_USEPOLLING=true - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/api diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml index 22114032..97ef5a67 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml @@ -29,7 +29,7 @@ services: sci-viz: cpus: 2.0 mem_limit: 16g - image: jverswijver/sci-viz:1.1.1-bugfix1 + image: jverswijver/sci-viz:1.1.1-bugfix2 environment: - CHOKIDAR_USEPOLLING=true - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/aeon/utils From 807aaae850415b3705ef8309dc986a8a93718a6f Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 10 Mar 2023 10:55:52 -0600 Subject: [PATCH 189/489] add lab entry component --- .../dj_pipeline/webapps/sciviz/specsheet.yaml | 43 +++++++++++++++---- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 05ed3685..ddc715a0 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -416,17 +416,46 @@ SciViz: return {'query': cls, 'fetch_args': {'order_by': 'minutes_elapsed ASC'}} DataEntry: - route: /subject_entry + route: /data_entry grids: grid5: type: fixed columns: 1 row_height: 1000 components: + Lab Entry: + route: /lab_form + x: 0 + y: 0 + height: 0.4 + width: 1 + type: form + tables: + - aeon_lab.Arena + map: + - type: attribute + input: Arena Name + destination: arena_name + - type: table + input: Arena Shape + destination: aeon_lab.ArenaShape + - type: attribute + input: X Dimension + destination: arena_x_dim + - type: attribute + input: Y Dimension + destination: arena_y_dim + - type: attribute + input: Z Dimension + destination: arena_z_dim + - type: attribute + input: Arena Description + destination: arena_description + Subject Entry: route: /subject_form x: 0 - y: 0 + y: 0.4 height: 0.3 width: 1 type: form @@ -443,13 +472,14 @@ SciViz: input: Date of Birth destination: subject_birth_date - type: attribute - input: Description + input: Subject Description destination: subject_description + Experiment Entry: route: /exp_form x: 0 - y: 0.3 - height: 0.5 + y: 0.7 + height: 0.4 width: 1 type: form tables: @@ -470,6 +500,3 @@ SciViz: - type: table input: Lab Location destination: aeon_lab.Location - - type: attribute - input: Experiment Type - destination: experiment_type From 46105adb4070bb72eeae06db9c846081da3dda23 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Mon, 20 Mar 2023 14:10:37 -0500 Subject: [PATCH 190/489] bugfix --- aeon/dj_pipeline/acquisition.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 8aa952d8..e180382c 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -31,7 +31,7 @@ "exp0.1-r0": aeon_schema.exp01, "social0-r1": aeon_schema.exp01, "exp0.2-r0": aeon_schema.exp02, - "oct1.0-r0": aeon_schema.exp02, + "oct1.0-r0": aeon_schema.octagon01, } From f3dae6e74fe1e7dd7fcb63634370027f68795efd Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 22 Mar 2023 17:46:25 +0000 Subject: [PATCH 191/489] add Colony table --- aeon/dj_pipeline/lab.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/lab.py b/aeon/dj_pipeline/lab.py index c51c5bb8..ff06b34e 100644 --- a/aeon/dj_pipeline/lab.py +++ b/aeon/dj_pipeline/lab.py @@ -2,13 +2,25 @@ from . import get_schema_name - schema = dj.schema(get_schema_name("lab")) # ------------------- GENERAL LAB INFORMATION -------------------- +@schema +class Colony(dj.Lookup): + # This table will interact with Bonsai directly. + definition = """ + subject : varchar(32) + --- + reference_weight=null : float + sex='U' : enum('M', 'F', 'U') + dob=null : date # date of birth + note='' : varchar(1024) + """ + + @schema class Lab(dj.Lookup): definition = """ From ae6bc5837cf6d21cc1fca4eeca28e7df02f988a4 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 22 Mar 2023 18:02:49 +0000 Subject: [PATCH 192/489] add Colony page to SciViz --- .../dj_pipeline/webapps/sciviz/specsheet.yaml | 51 ++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index ee5c567a..51deb0e8 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -3,16 +3,16 @@ LabBook: null SciViz: auth: True component_interface: - override: | + override: | from datetime import datetime from pharus.component_interface import SlideshowComponent, NumpyEncoder, type_map from flask import request from aeon.dj_pipeline.utils.video import retrieve_video_frames - + class AeonSlideshowComponent(SlideshowComponent): def dj_query_route(self): fetch_metadata = self.fetch_metadata - + # Dj query provided should return only a video location fetched_args = (fetch_metadata["query"] & self.restriction).fetch1( *fetch_metadata["fetch_args"] @@ -31,10 +31,44 @@ SciViz: 200, {"Content-Type": "application/json"}, ) - + type_map = dict({"slideshow:aeon": AeonSlideshowComponent}, **type_map) pages: + Colony: + route: /colony_entry + grids: + grid1: + type: fixed + columns: 1 + row_height: 1000 + components: + Colony Entry: + route: /colony_form + x: 0 + y: 0 + height: 0.5 + width: 1 + type: form + tables: + - aeon_lab.Colony + map: + - type: attribute + input: Subject + destination: subject + - type: attribute + input: Reference Weight + destination: reference_weight + - type: attribute + input: Sex + destination: sex + - type: attribute + input: Date of Birth + destination: dob + - type: attribute + input: Note + destination: note + Subjects: route: /subjects grids: @@ -382,7 +416,7 @@ SciViz: route: /videostream_camera_dropdown type: dropdown-query channel: stream_camera_selector - channels: [ stream_experiment_selector ] + channels: [stream_experiment_selector] restriction: > def restriction(**kwargs): return dict(**kwargs) @@ -408,7 +442,12 @@ SciViz: chunk_size: 50 buffer_size: 30 max_FPS: 50 - channels: [ stream_experiment_selector, stream_camera_selector, stream_time_selector ] + channels: + [ + stream_experiment_selector, + stream_camera_selector, + stream_time_selector, + ] restriction: > def restriction(**kwargs): return dict(**kwargs) From 673dbcf7f50674a154b502abb55b16d52c0d5615 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 22 Mar 2023 15:46:31 -0500 Subject: [PATCH 193/489] Update aeon/dj_pipeline/lab.py Co-authored-by: Thinh Nguyen --- aeon/dj_pipeline/lab.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/lab.py b/aeon/dj_pipeline/lab.py index ff06b34e..9c193483 100644 --- a/aeon/dj_pipeline/lab.py +++ b/aeon/dj_pipeline/lab.py @@ -16,7 +16,7 @@ class Colony(dj.Lookup): --- reference_weight=null : float sex='U' : enum('M', 'F', 'U') - dob=null : date # date of birth + subject_birth_date=null : date # date of birth note='' : varchar(1024) """ From 0541026ff357840e6f1e0ce78bcdfabf34deb59c Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 22 Mar 2023 20:53:59 +0000 Subject: [PATCH 194/489] change dob to subject_birth_date --- aeon/dj_pipeline/webapps/sciviz/specsheet.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 51deb0e8..c666c44c 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -64,7 +64,7 @@ SciViz: destination: sex - type: attribute input: Date of Birth - destination: dob + destination: subject_birth_date - type: attribute input: Note destination: note From 3e4c2896ff2f3596ace050c55467e9703fa7d6ab Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 29 Mar 2023 18:28:03 +0000 Subject: [PATCH 195/489] new image url --- docker/image/Dockerfile | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docker/image/Dockerfile b/docker/image/Dockerfile index 30a6e408..f688d5fb 100644 --- a/docker/image/Dockerfile +++ b/docker/image/Dockerfile @@ -10,7 +10,7 @@ ARG IMAGE_CREATED=2021-11-11T11:11:11Z ARG IMAGE_VERSION=v0.0.0a # main build stage ===================================================================== -FROM ghcr.io/iamamutt/conda_base:latest as aeon_mecha_docker_pre +FROM ghcr.io/ttngu207/conda_base:latest as aeon_mecha_docker_pre # inherit global args ARG NEW_USER_NAME @@ -60,10 +60,10 @@ RUN cp -f ${AEON_PKG}/docker/image/apt_requirements.txt /srv/conda/apt_requireme # create the local and global datajoint config files, hard-coding the data paths RUN awk -v DJSTORE="$DJ_EXT_STORE" -v CEPHROOT="$CEPH_ROOT" \ - '{sub(/{{ DJ_EXT_STORE }}/,DJSTORE);sub(/{{ CEPH_ROOT }}/,CEPHROOT)} 1' \ - /tmp/.datajoint_config.json | tee \ - ${AEON_PKG}/dj_local_config.json \ - ${NEW_USER_HOME}/.datajoint_config.json > /dev/null && \ + '{sub(/{{ DJ_EXT_STORE }}/,DJSTORE);sub(/{{ CEPH_ROOT }}/,CEPHROOT)} 1' \ + /tmp/.datajoint_config.json | tee \ + ${AEON_PKG}/dj_local_config.json \ + ${NEW_USER_HOME}/.datajoint_config.json > /dev/null && \ chown ${NEW_USER_UID}:${NEW_USER_GID} ${NEW_USER_HOME}/.datajoint_config.json # install package dependencies from apt_requirements.txt @@ -82,15 +82,15 @@ RUN sudo-run \ pip install -e ${AEON_PKG}/. RUN mkdir -p -m 6775 \ - ${NEW_USER_HOME}/.vscode-server/extensions \ - ${NEW_USER_HOME}/.vscode-server-insiders/extensions && \ + ${NEW_USER_HOME}/.vscode-server/extensions \ + ${NEW_USER_HOME}/.vscode-server-insiders/extensions && \ chown -R ${NEW_USER_NAME}:${NEW_USER_GROUP} ${NEW_USER_HOME}/.vscode-server* && \ echo "$IMAGE_CREATED" > /tmp/$(echo $IMAGE_CREATED | awk '{gsub(/\:/,"-");print}') # delete logs and unnecessary files copied over from this repo RUN rm -rf \ - /var/log/lastlog /var/log/faillog \ - ${AEON_PKG}/docker /var/tmp/* /tmp/* && \ + /var/log/lastlog /var/log/faillog \ + ${AEON_PKG}/docker /var/tmp/* /tmp/* && \ $CONDA_ROOT/bin/conda clean -yqa && \ touch /var/log/lastlog && \ touch /var/log/faillog From 7a02b236656125a4afb67125d7518d306766a83c Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 3 Apr 2023 05:24:00 +0000 Subject: [PATCH 196/489] use aeon api to read metadata --- aeon/dj_pipeline/utils/load_metadata.py | 36 ++++++++++++++++++------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 9b7e701f..fcd3685a 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -1,13 +1,13 @@ +import datetime import pathlib import re -import datetime +import numpy as np import pandas as pd import yaml -from aeon.dj_pipeline import acquisition, lab, subject -from aeon.dj_pipeline import dict_to_uuid - +from aeon.dj_pipeline import acquisition, dict_to_uuid, lab, subject +from aeon.io import api as io_api _weight_scale_rate = 100 _weight_scale_nest = 1 @@ -32,14 +32,26 @@ def extract_epoch_metadata(experiment_name, metadata_yml_filepath): epoch_start = datetime.datetime.strptime( metadata_yml_filepath.parent.name, "%Y-%m-%dT%H-%M-%S" ) - with open(metadata_yml_filepath, "r") as f: - experiment_setup = yaml.safe_load(f) - commit = experiment_setup.get("Commit", experiment_setup.get("Revision")) + experiment_setup: dict = ( + io_api.load( + str(metadata_yml_filepath.parent), + acquisition._device_schema_mapping[experiment_name].Metadata, + ) + .reset_index() + .to_dict("records")[0] + ) + + commit = experiment_setup.get("commit") + if isinstance(commit, float) and np.isnan(commit): + commit = experiment_setup["metadata"]["Revision"] + else: + commit = None + assert commit, f'Neither "Commit" nor "Revision" found in {metadata_yml_filepath}' return { "experiment_name": experiment_name, "epoch_start": epoch_start, - "bonsai_workflow": experiment_setup["Workflow"], + "bonsai_workflow": experiment_setup["workflow"], "commit": commit, "metadata": experiment_setup, "metadata_file_path": metadata_yml_filepath, @@ -61,8 +73,12 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): epoch_start = datetime.datetime.strptime( metadata_yml_filepath.parent.name, "%Y-%m-%dT%H-%M-%S" ) - with open(metadata_yml_filepath, "r") as f: - experiment_setup = yaml.safe_load(f) + + experiment_setup = io_api.load( + str(metadata_yml_filepath.parent), + acquisition._device_schema_mapping[experiment_name].Metadata, + ) + experiment_key = {"experiment_name": experiment_name} # Check if there has been any changes in the arena setup # by comparing the "Commit" against the most immediate preceding epoch From f5f310e55c2523aac8083e30a13b5f72936622e1 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 7 Apr 2023 01:28:09 +0000 Subject: [PATCH 197/489] add get_device_info to dataset.py --- aeon/schema/dataset.py | 95 ++++++++++++++++++++++++++++-------------- 1 file changed, 64 insertions(+), 31 deletions(-) diff --git a/aeon/schema/dataset.py b/aeon/schema/dataset.py index ae787456..f11504ac 100644 --- a/aeon/schema/dataset.py +++ b/aeon/schema/dataset.py @@ -24,34 +24,67 @@ ] ) -exp01 = DotMap([ - Device("SessionData", foraging.session), - Device("FrameTop", stream.video, stream.position), - Device("FrameEast", stream.video), - Device("FrameGate", stream.video), - Device("FrameNorth", stream.video), - Device("FramePatch1", stream.video), - Device("FramePatch2", stream.video), - Device("FrameSouth", stream.video), - Device("FrameWest", stream.video), - Device("Patch1", foraging.depletionFunction, stream.encoder, foraging.feeder), - Device("Patch2", foraging.depletionFunction, stream.encoder, foraging.feeder) -]) - -octagon01 = DotMap([ - Device("Metadata", stream.metadata), - Device("CameraTop", stream.video, stream.position), - Device("CameraColorTop", stream.video), - Device("ExperimentalMetadata", stream.subject_state), - Device("Photodiode", octagon.photodiode), - Device("OSC", octagon.OSC), - Device("TaskLogic", octagon.TaskLogic), - Device("Wall1", octagon.Wall), - Device("Wall2", octagon.Wall), - Device("Wall3", octagon.Wall), - Device("Wall4", octagon.Wall), - Device("Wall5", octagon.Wall), - Device("Wall6", octagon.Wall), - Device("Wall7", octagon.Wall), - Device("Wall8", octagon.Wall) -]) +exp01 = DotMap( + [ + Device("SessionData", foraging.session), + Device("FrameTop", stream.video, stream.position), + Device("FrameEast", stream.video), + Device("FrameGate", stream.video), + Device("FrameNorth", stream.video), + Device("FramePatch1", stream.video), + Device("FramePatch2", stream.video), + Device("FrameSouth", stream.video), + Device("FrameWest", stream.video), + Device("Patch1", foraging.depletionFunction, stream.encoder, foraging.feeder), + Device("Patch2", foraging.depletionFunction, stream.encoder, foraging.feeder), + ] +) + +octagon01 = DotMap( + [ + Device("Metadata", stream.metadata), + Device("CameraTop", stream.video, stream.position), + Device("CameraColorTop", stream.video), + Device("ExperimentalMetadata", stream.subject_state), + Device("Photodiode", octagon.photodiode), + Device("OSC", octagon.OSC), + Device("TaskLogic", octagon.TaskLogic), + Device("Wall1", octagon.Wall), + Device("Wall2", octagon.Wall), + Device("Wall3", octagon.Wall), + Device("Wall4", octagon.Wall), + Device("Wall5", octagon.Wall), + Device("Wall6", octagon.Wall), + Device("Wall7", octagon.Wall), + Device("Wall8", octagon.Wall), + ] +) + + +def get_device_info(schema: DotMap) -> dict[dict]: + """ + Read from the above DotMap object and returns device dictionary {device_name: {stream_name: reader}} + """ + from collections import defaultdict + + device_info = {} + + for device_name in schema: + if not device_name.startswith("_"): + device_info[device_name] = defaultdict(list) + if isinstance(schema[device_name], DotMap): + for stream_type in schema[device_name].keys(): + if schema[device_name][stream_type].__class__.__module__ in [ + "aeon.io.reader", + "aeon.schema.foraging", + "aeon.schema.octagon", + ]: + device_info[device_name]["stream_type"].append(stream_type) + device_info[device_name]["reader"].append( + schema[device_name][stream_type].__class__ + ) + else: + stream_type = schema[device_name].__class__.__name__ + device_info[device_name]["stream_type"].append(stream_type) + device_info[device_name]["reader"].append(schema[device_name].__class__) + return device_info From a522d74f7a579aa313c68e5c6a34813180fdc90c Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 7 Apr 2023 16:16:09 +0000 Subject: [PATCH 198/489] add pattern info to device_info --- aeon/schema/dataset.py | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/aeon/schema/dataset.py b/aeon/schema/dataset.py index f11504ac..aac291e7 100644 --- a/aeon/schema/dataset.py +++ b/aeon/schema/dataset.py @@ -1,3 +1,5 @@ +from pathlib import Path + from dotmap import DotMap import aeon.schema.core as stream @@ -63,10 +65,28 @@ def get_device_info(schema: DotMap) -> dict[dict]: """ - Read from the above DotMap object and returns device dictionary {device_name: {stream_name: reader}} + Read from the above DotMap object and returns a device dictionary as the following. + + Args: + schema (DotMap): DotMap object (e.g., exp02) + + e.g. {'CameraTop': + {'stream_type': ['Video', 'Position', 'Region'], + 'reader': [ + aeon.io.reader.Video, + aeon.io.reader.Position, + aeon.schema.foraging._RegionReader + ], + 'pattern': ['{pattern}', '{pattern}_200', '{pattern}_201'] + } + } """ + import json from collections import defaultdict + schema_json = json.dumps(schema, default=lambda o: o.__dict__, indent=4) + schema_dict = json.loads(schema_json) + device_info = {} for device_name in schema: @@ -87,4 +107,17 @@ def get_device_info(schema: DotMap) -> dict[dict]: stream_type = schema[device_name].__class__.__name__ device_info[device_name]["stream_type"].append(stream_type) device_info[device_name]["reader"].append(schema[device_name].__class__) + + """Add a 'pattern' key with a value of e.g., ['{pattern}_State', '{pattern}_90', '{pattern}_32','{pattern}_35']""" + + for device_name in device_info: + if pattern := schema_dict[device_name].get("pattern"): + pattern = pattern.replace(device_name, "{pattern}") + device_info[device_name]["pattern"].append(pattern) + else: + for stream_type in device_info[device_name]["stream_type"]: + pattern = schema_dict[device_name][stream_type]["pattern"] + pattern = pattern.replace(device_name, "{pattern}") + device_info[device_name]["pattern"].append(pattern) + return device_info From bdde45bc999682e18b31e8cb97b3d9d9a5eb2555 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 7 Apr 2023 16:17:04 +0000 Subject: [PATCH 199/489] add add_device_type to device_info --- aeon/schema/dataset.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/aeon/schema/dataset.py b/aeon/schema/dataset.py index aac291e7..d04e4f6e 100644 --- a/aeon/schema/dataset.py +++ b/aeon/schema/dataset.py @@ -121,3 +121,40 @@ def get_device_info(schema: DotMap) -> dict[dict]: device_info[device_name]["pattern"].append(pattern) return device_info + + +def add_device_type(schema: DotMap, metadata_yml_filepath: Path): + """Update device_info with device_type based on metadata.yml. + + Args: + schema (DotMap): DotMap object (e.g., exp02) + metadata_yml_filepath (Path): Path to metadata.yml. + + Returns: + device_info (dict): Updated device_info. + """ + from aeon.io import api + + meta_data = ( + api.load( + str(metadata_yml_filepath.parent), + schema.Metadata, + ) + .reset_index() + .to_dict("records")[0]["metadata"] + ) + + # Get device_type_mapper based on metadata.yml {'CameraTop': 'VideoSource', 'Patch1': 'Patch'} + device_type_mapper = {} + for item in meta_data.Devices: + device_type_mapper[item.Name] = item.Type + + device_info = { + device_name: { + "device_type": device_type_mapper.get(device_name, None), + **device_info[device_name], + } + for device_name in device_info + } + + return device_info From 4af7def81d2f1c5456afc0c329e262b06a33a3d2 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 7 Apr 2023 16:20:20 +0000 Subject: [PATCH 200/489] add presocial dataset schema --- aeon/schema/dataset.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/aeon/schema/dataset.py b/aeon/schema/dataset.py index d04e4f6e..e87c896b 100644 --- a/aeon/schema/dataset.py +++ b/aeon/schema/dataset.py @@ -5,6 +5,7 @@ import aeon.schema.core as stream import aeon.schema.foraging as foraging import aeon.schema.octagon as octagon +from aeon.io import reader from aeon.io.device import Device exp02 = DotMap( @@ -62,6 +63,20 @@ ] ) +presocial = exp02 +presocial.Patch1.BeamBreak = reader.BitmaskEvent( + pattern="Patch1_32", value=0x22, tag="BeamBroken" +) +presocial.Patch2.BeamBreak = reader.BitmaskEvent( + pattern="Patch2_32", value=0x22, tag="BeamBroken" +) +presocial.Patch1.DeliverPellet = reader.BitmaskEvent( + pattern="Patch1_35", value=0x1, tag="TriggeredPellet" +) +presocial.Patch2.DeliverPellet = reader.BitmaskEvent( + pattern="Patch2_35", value=0x1, tag="TriggeredPellet" +) + def get_device_info(schema: DotMap) -> dict[dict]: """ From c0528cfaad020da5e7439c8b386b85456d3731fd Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 7 Apr 2023 17:04:26 +0000 Subject: [PATCH 201/489] add presocial device mapping --- aeon/dj_pipeline/acquisition.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index e180382c..31d286c0 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -25,6 +25,7 @@ "social0-r1": "FrameTop", "exp0.2-r0": "CameraTop", "oct1.0-r0": "CameraTop", + "presocial0.1-a2": "CameraTop", } _device_schema_mapping = { @@ -32,6 +33,7 @@ "social0-r1": aeon_schema.exp01, "exp0.2-r0": aeon_schema.exp02, "oct1.0-r0": aeon_schema.octagon01, + "presocial0.1-a2": aeon_schema.presocial, } From 99b6478b66b76a7aeac53d6a2deb12b16f85890e Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 7 Apr 2023 17:04:52 +0000 Subject: [PATCH 202/489] add kwargs instead of pattern --- aeon/schema/dataset.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/aeon/schema/dataset.py b/aeon/schema/dataset.py index e87c896b..04ae6f51 100644 --- a/aeon/schema/dataset.py +++ b/aeon/schema/dataset.py @@ -123,17 +123,28 @@ def get_device_info(schema: DotMap) -> dict[dict]: device_info[device_name]["stream_type"].append(stream_type) device_info[device_name]["reader"].append(schema[device_name].__class__) - """Add a 'pattern' key with a value of e.g., ['{pattern}_State', '{pattern}_90', '{pattern}_32','{pattern}_35']""" + """Add a kwargs such as pattern, columns, extension, dtype + e.g., {'pattern': '{pattern}_SubjectState', + 'columns': ['id', 'weight', 'event'], + 'extension': 'csv', + 'dtype': None}""" + # Add a kwargs that includes pattern for device_name in device_info: if pattern := schema_dict[device_name].get("pattern"): - pattern = pattern.replace(device_name, "{pattern}") - device_info[device_name]["pattern"].append(pattern) + schema_dict[device_name]["pattern"] = pattern.replace( + device_name, "{pattern}" + ) + device_info[device_name]["kwargs"].append(schema_dict[device_name]) else: for stream_type in device_info[device_name]["stream_type"]: pattern = schema_dict[device_name][stream_type]["pattern"] - pattern = pattern.replace(device_name, "{pattern}") - device_info[device_name]["pattern"].append(pattern) + schema_dict[device_name][stream_type]["pattern"] = pattern.replace( + device_name, "{pattern}" + ) + device_info[device_name]["kwargs"].append( + schema_dict[device_name][stream_type] + ) return device_info From c39c9e6d43bd5ae31fb7e2ce4fbad0ce79c9ce96 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 7 Apr 2023 18:47:58 +0000 Subject: [PATCH 203/489] minor formatting --- aeon/dj_pipeline/lab.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/lab.py b/aeon/dj_pipeline/lab.py index 9c193483..07ea8b16 100644 --- a/aeon/dj_pipeline/lab.py +++ b/aeon/dj_pipeline/lab.py @@ -16,7 +16,7 @@ class Colony(dj.Lookup): --- reference_weight=null : float sex='U' : enum('M', 'F', 'U') - subject_birth_date=null : date # date of birth + subject_birth_date=null : date # date of birth note='' : varchar(1024) """ From fb9bc01dc76687fc83f5984cd8d2b24bb1fe60d9 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 7 Apr 2023 19:18:31 +0000 Subject: [PATCH 204/489] add device stream hash --- aeon/schema/dataset.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/aeon/schema/dataset.py b/aeon/schema/dataset.py index 04ae6f51..45a11753 100644 --- a/aeon/schema/dataset.py +++ b/aeon/schema/dataset.py @@ -99,6 +99,8 @@ def get_device_info(schema: DotMap) -> dict[dict]: import json from collections import defaultdict + from aeon.dj_pipeline import dict_to_uuid + schema_json = json.dumps(schema, default=lambda o: o.__dict__, indent=4) schema_dict = json.loads(schema_json) @@ -123,27 +125,40 @@ def get_device_info(schema: DotMap) -> dict[dict]: device_info[device_name]["stream_type"].append(stream_type) device_info[device_name]["reader"].append(schema[device_name].__class__) - """Add a kwargs such as pattern, columns, extension, dtype + """Add a kwargs such as pattern, columns, extension, dtype and hash e.g., {'pattern': '{pattern}_SubjectState', 'columns': ['id', 'weight', 'event'], 'extension': 'csv', 'dtype': None}""" - - # Add a kwargs that includes pattern for device_name in device_info: if pattern := schema_dict[device_name].get("pattern"): schema_dict[device_name]["pattern"] = pattern.replace( device_name, "{pattern}" ) - device_info[device_name]["kwargs"].append(schema_dict[device_name]) + + # Add stream_reader_kwargs + kwargs = schema_dict[device_name] + device_info[device_name]["stream_reader_kwargs"].append(kwargs) + stream_reader = device_info[device_name]["stream_reader"] + # Add hash + device_info[device_name]["stream_hash"].append( + dict_to_uuid({**kwargs, "stream_reader": stream_reader}) + ) + else: for stream_type in device_info[device_name]["stream_type"]: pattern = schema_dict[device_name][stream_type]["pattern"] schema_dict[device_name][stream_type]["pattern"] = pattern.replace( device_name, "{pattern}" ) - device_info[device_name]["kwargs"].append( - schema_dict[device_name][stream_type] + # Add stream_reader_kwargs + kwargs = schema_dict[device_name][stream_type] + device_info[device_name]["stream_reader_kwargs"].append(kwargs) + stream_ind = device_info[device_name]["stream_type"].index(stream_type) + stream_reader = device_info[device_name]["stream_reader"][stream_ind] + # Add hash + device_info[device_name]["stream_hash"].append( + dict_to_uuid({**kwargs, "stream_reader": stream_reader}) ) return device_info From 6cd64eeda5993429a75b9ddc516e1fd0cdd07a21 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 7 Apr 2023 19:41:53 +0000 Subject: [PATCH 205/489] move functions to streams.py --- aeon/dj_pipeline/streams.py | 245 ++++++++++++++++++++++++++---------- aeon/schema/dataset.py | 125 ------------------ 2 files changed, 176 insertions(+), 194 deletions(-) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index 4a2992a0..9cf00dc9 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -2,9 +2,11 @@ import re from collections import defaultdict, namedtuple from functools import cached_property +from pathlib import Path import datajoint as dj import pandas as pd +from dotmap import DotMap import aeon import aeon.schema.core as stream @@ -21,11 +23,11 @@ schema = dj.schema(schema_name) -# __all__ = [ -# "StreamType", -# "DeviceType", -# "Device", -# ] +__all__ = [ + "StreamType", + "DeviceType", + "Device", +] # Read from this list of device configurations @@ -85,63 +87,32 @@ class StreamType(dj.Lookup): """ definition = """ # Catalog of all stream types used across Project Aeon - stream_type: varchar(20) + stream_type : varchar(20) --- - stream_reader: varchar(256) # name of the reader class found in `aeon_mecha` package (e.g. aeon.io.reader.Video) - stream_reader_kwargs: longblob # keyword arguments to instantiate the reader class - stream_description='': varchar(256) - stream_hash: uuid # hash of dict(stream_reader_kwargs, stream_reader=stream_reader) + stream_reader : varchar(256) # name of the reader class found in `aeon_mecha` package (e.g. aeon.io.reader.Video) + stream_reader_kwargs : longblob # keyword arguments to instantiate the reader class + stream_description='': varchar(256) + stream_hash : uuid # hash of dict(stream_reader_kwargs, stream_reader=stream_reader) unique index (stream_hash) """ - @staticmethod - def get_stream_entries(device_streams: tuple, pattern="{pattern}") -> dict: - - composite = aeon.io.device.compositeStream(pattern, *device_streams) - stream_entries = [] - for stream_name, stream_reader in composite.items(): - if stream_name == pattern: - stream_name = stream_reader.__class__.__name__ - entry = { - "stream_type": stream_name, - "stream_reader": f"{stream_reader.__module__}.{stream_reader.__class__.__name__}", - "stream_reader_kwargs": { - k: v - for k, v in vars(stream_reader).items() - if k - in inspect.signature(stream_reader.__class__.__init__).parameters - }, - } - entry["stream_hash"] = dict_to_uuid( - { - **entry["stream_reader_kwargs"], - "stream_reader": entry["stream_reader"], - } - ) - stream_entries.append(entry) - - return stream_entries - @classmethod - def insert_streams(cls, device_configs: list[namedtuple] = []): + def insert_streams(cls, schema: DotMap): - if not device_configs: - device_configs = get_device_configs() + stream_entries = get_stream_entries(schema) - for device in device_configs: - stream_entries = cls.get_stream_entries(device.streams) - for entry in stream_entries: - q_param = cls & {"stream_hash": entry["stream_hash"]} - if q_param: # If the specified stream type already exists - pname = q_param.fetch1("stream_type") - if pname != entry["stream_type"]: - # If the existed stream type does not have the same name: - # human error, trying to add the same content with different name - raise dj.DataJointError( - f"The specified stream type already exists - name: {pname}" - ) + for entry in stream_entries: + q_param = cls & {"stream_hash": entry["stream_hash"]} + if q_param: # If the specified stream type already exists + pname = q_param.fetch1("stream_type") + if pname != entry["stream_type"]: + # If the existed stream type does not have the same name: + # human error, trying to add the same content with different name + raise dj.DataJointError( + f"The specified stream type already exists - name: {pname}" + ) - cls.insert(stream_entries, skip_duplicates=True) + cls.insert(stream_entries, skip_duplicates=True) @schema @@ -423,25 +394,161 @@ def create_device_stream_tables(self): self._schema.activate(schema_name) -# Main function -def main(): +def get_device_info(schema: DotMap) -> dict[dict]: + """ + Read from the above DotMap object and returns a device dictionary as the following. + + Args: + schema (DotMap): DotMap object (e.g., exp02, octagon01) + + Returns: + device_info (dict[dict]): A dictionary of device information + + e.g. {'CameraTop': + {'stream_type': ['Video', 'Position', 'Region'], + 'reader': [ + aeon.io.reader.Video, + aeon.io.reader.Position, + aeon.schema.foraging._RegionReader + ], + 'pattern': ['{pattern}', '{pattern}_200', '{pattern}_201'] + } + } + """ + import json + from collections import defaultdict + + from aeon.dj_pipeline import dict_to_uuid + + schema_json = json.dumps(schema, default=lambda o: o.__dict__, indent=4) + schema_dict = json.loads(schema_json) + + device_info = {} + + for device_name in schema: + if not device_name.startswith("_"): + device_info[device_name] = defaultdict(list) + if isinstance(schema[device_name], DotMap): + for stream_type in schema[device_name].keys(): + if schema[device_name][stream_type].__class__.__module__ in [ + "aeon.io.reader", + "aeon.schema.foraging", + "aeon.schema.octagon", + ]: + device_info[device_name]["stream_type"].append(stream_type) + device_info[device_name]["reader"].append( + schema[device_name][stream_type].__class__ + ) + else: + stream_type = schema[device_name].__class__.__name__ + device_info[device_name]["stream_type"].append(stream_type) + device_info[device_name]["reader"].append(schema[device_name].__class__) + + """Add a kwargs such as pattern, columns, extension, dtype and hash + e.g., {'pattern': '{pattern}_SubjectState', + 'columns': ['id', 'weight', 'event'], + 'extension': 'csv', + 'dtype': None}""" + for device_name in device_info: + if pattern := schema_dict[device_name].get("pattern"): + schema_dict[device_name]["pattern"] = pattern.replace( + device_name, "{pattern}" + ) - # Populate StreamType - StreamType.insert_streams() + # Add stream_reader_kwargs + kwargs = schema_dict[device_name] + device_info[device_name]["stream_reader_kwargs"].append(kwargs) + stream_reader = device_info[device_name]["stream_reader"] + # Add hash + device_info[device_name]["stream_hash"].append( + dict_to_uuid({**kwargs, "stream_reader": stream_reader}) + ) - # Populate DeviceType - DeviceType.insert_devices() + else: + for stream_type in device_info[device_name]["stream_type"]: + pattern = schema_dict[device_name][stream_type]["pattern"] + schema_dict[device_name][stream_type]["pattern"] = pattern.replace( + device_name, "{pattern}" + ) + # Add stream_reader_kwargs + kwargs = schema_dict[device_name][stream_type] + device_info[device_name]["stream_reader_kwargs"].append(kwargs) + stream_ind = device_info[device_name]["stream_type"].index(stream_type) + stream_reader = device_info[device_name]["stream_reader"][stream_ind] + # Add hash + device_info[device_name]["stream_hash"].append( + dict_to_uuid({**kwargs, "stream_reader": stream_reader}) + ) - # Populate device tables - tbmg = DeviceTableManager(context=inspect.currentframe().f_back.f_locals) + return device_info - # # List all tables - # tbmg.device_tables - # tbmg.device_stream_tables - # Create device & device stream tables - tbmg.create_device_tables() - tbmg.create_device_stream_tables() +def add_device_type(schema: DotMap, metadata_yml_filepath: Path): + """Update device_info with device_type based on metadata.yml. + Args: + schema (DotMap): DotMap object (e.g., exp02) + metadata_yml_filepath (Path): Path to metadata.yml. -# main() + Returns: + device_info (dict): Updated device_info. + """ + from aeon.io import api + + meta_data = ( + api.load( + str(metadata_yml_filepath.parent), + schema.Metadata, + ) + .reset_index() + .to_dict("records")[0]["metadata"] + ) + + # Get device_type_mapper based on metadata.yml {'CameraTop': 'VideoSource', 'Patch1': 'Patch'} + device_type_mapper = {} + for item in meta_data.Devices: + device_type_mapper[item.Name] = item.Type + + device_info = { + device_name: { + "device_type": device_type_mapper.get(device_name, None), + **device_info[device_name], + } + for device_name in device_info + } + + return device_info + + +def get_stream_entries(schema: DotMap) -> list[dict]: + """Returns a list of dictionaries containing the stream entries for a given device, + + Args: + schema (DotMap): DotMap object (e.g., exp02, octagon01) + + Returns: + stream_info (list[dict]): list of dictionaries containing the stream entries for a given device, + + e.g. {'stream_type': 'EnvironmentState', + 'stream_reader': aeon.io.reader.Csv, + 'stream_reader_kwargs': {'pattern': '{pattern}_EnvironmentState', + 'columns': ['state'], + 'extension': 'csv', + 'dtype': None} + """ + device_info = get_device_info(schema) + return [ + { + "stream_type": stream_type, + "stream_reader": stream_reader, + "stream_reader_kwargs": stream_reader_kwargs, + "stream_hash": stream_hash, + } + for stream_info in device_info.values() + for stream_type, stream_reader, stream_reader_kwargs, stream_hash in zip( + stream_info["stream_type"], + stream_info["stream_reader"], + stream_info["stream_reader_kwargs"], + stream_info["stream_hash"], + ) + ] diff --git a/aeon/schema/dataset.py b/aeon/schema/dataset.py index 45a11753..5aae992f 100644 --- a/aeon/schema/dataset.py +++ b/aeon/schema/dataset.py @@ -1,5 +1,3 @@ -from pathlib import Path - from dotmap import DotMap import aeon.schema.core as stream @@ -76,126 +74,3 @@ presocial.Patch2.DeliverPellet = reader.BitmaskEvent( pattern="Patch2_35", value=0x1, tag="TriggeredPellet" ) - - -def get_device_info(schema: DotMap) -> dict[dict]: - """ - Read from the above DotMap object and returns a device dictionary as the following. - - Args: - schema (DotMap): DotMap object (e.g., exp02) - - e.g. {'CameraTop': - {'stream_type': ['Video', 'Position', 'Region'], - 'reader': [ - aeon.io.reader.Video, - aeon.io.reader.Position, - aeon.schema.foraging._RegionReader - ], - 'pattern': ['{pattern}', '{pattern}_200', '{pattern}_201'] - } - } - """ - import json - from collections import defaultdict - - from aeon.dj_pipeline import dict_to_uuid - - schema_json = json.dumps(schema, default=lambda o: o.__dict__, indent=4) - schema_dict = json.loads(schema_json) - - device_info = {} - - for device_name in schema: - if not device_name.startswith("_"): - device_info[device_name] = defaultdict(list) - if isinstance(schema[device_name], DotMap): - for stream_type in schema[device_name].keys(): - if schema[device_name][stream_type].__class__.__module__ in [ - "aeon.io.reader", - "aeon.schema.foraging", - "aeon.schema.octagon", - ]: - device_info[device_name]["stream_type"].append(stream_type) - device_info[device_name]["reader"].append( - schema[device_name][stream_type].__class__ - ) - else: - stream_type = schema[device_name].__class__.__name__ - device_info[device_name]["stream_type"].append(stream_type) - device_info[device_name]["reader"].append(schema[device_name].__class__) - - """Add a kwargs such as pattern, columns, extension, dtype and hash - e.g., {'pattern': '{pattern}_SubjectState', - 'columns': ['id', 'weight', 'event'], - 'extension': 'csv', - 'dtype': None}""" - for device_name in device_info: - if pattern := schema_dict[device_name].get("pattern"): - schema_dict[device_name]["pattern"] = pattern.replace( - device_name, "{pattern}" - ) - - # Add stream_reader_kwargs - kwargs = schema_dict[device_name] - device_info[device_name]["stream_reader_kwargs"].append(kwargs) - stream_reader = device_info[device_name]["stream_reader"] - # Add hash - device_info[device_name]["stream_hash"].append( - dict_to_uuid({**kwargs, "stream_reader": stream_reader}) - ) - - else: - for stream_type in device_info[device_name]["stream_type"]: - pattern = schema_dict[device_name][stream_type]["pattern"] - schema_dict[device_name][stream_type]["pattern"] = pattern.replace( - device_name, "{pattern}" - ) - # Add stream_reader_kwargs - kwargs = schema_dict[device_name][stream_type] - device_info[device_name]["stream_reader_kwargs"].append(kwargs) - stream_ind = device_info[device_name]["stream_type"].index(stream_type) - stream_reader = device_info[device_name]["stream_reader"][stream_ind] - # Add hash - device_info[device_name]["stream_hash"].append( - dict_to_uuid({**kwargs, "stream_reader": stream_reader}) - ) - - return device_info - - -def add_device_type(schema: DotMap, metadata_yml_filepath: Path): - """Update device_info with device_type based on metadata.yml. - - Args: - schema (DotMap): DotMap object (e.g., exp02) - metadata_yml_filepath (Path): Path to metadata.yml. - - Returns: - device_info (dict): Updated device_info. - """ - from aeon.io import api - - meta_data = ( - api.load( - str(metadata_yml_filepath.parent), - schema.Metadata, - ) - .reset_index() - .to_dict("records")[0]["metadata"] - ) - - # Get device_type_mapper based on metadata.yml {'CameraTop': 'VideoSource', 'Patch1': 'Patch'} - device_type_mapper = {} - for item in meta_data.Devices: - device_type_mapper[item.Name] = item.Type - - device_info = { - device_name: { - "device_type": device_type_mapper.get(device_name, None), - **device_info[device_name], - } - for device_name in device_info - } - - return device_info From 2f81d6813409ea2e63273e4bfefc4167c5297d23 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 7 Apr 2023 21:15:17 +0000 Subject: [PATCH 206/489] add insert_device_type classmethod --- aeon/dj_pipeline/streams.py | 58 ++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 24 deletions(-) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index 9cf00dc9..10cecb4b 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -134,29 +134,39 @@ class Stream(dj.Part): """ @classmethod - def insert_devices(cls, device_configs: list[namedtuple] = []): - if not device_configs: - device_configs = get_device_configs() - for device in device_configs: - stream_entries = StreamType.get_stream_entries(device.streams) - with cls.connection.transaction: - cls.insert1( - { - "device_type": device.type, - "device_description": device.desc, - }, - skip_duplicates=True, - ) - cls.Stream.insert( - [ + def insert_device_types(cls, schema: DotMap, metadata_yml_filepath: Path): + """Use dataset.schema and metadata.yml to insert device types and streams. Only insert device types that were defined both in the device schema (e.g., exp02) and Metadata.yml.""" + device_info = get_device_info(schema) + device_type_mapper = get_device_type(schema, metadata_yml_filepath) + + device_info = { + device_name: { + "device_type": device_type_mapper.get(device_name, None), + **device_info[device_name], + } + for device_name in device_info + } + + for device_name, info in device_info.items(): + if info["device_type"]: + with cls.connections.transaction: + cls.insert1( { - "device_type": device.type, - "stream_type": e["stream_type"], - } - for e in stream_entries - ], - skip_duplicates=True, - ) + "device_type": info["device_type"], + "device_description": "", + }, + skip_duplicates=True, + ) + cls.Stream.insert( + [ + { + "device_type": info["device_type"], + "stream_type": e, + } + for e in info["stream_type"] + ], + skip_duplicates=True, + ) @schema @@ -483,8 +493,8 @@ def get_device_info(schema: DotMap) -> dict[dict]: return device_info -def add_device_type(schema: DotMap, metadata_yml_filepath: Path): - """Update device_info with device_type based on metadata.yml. +def get_device_type(schema: DotMap, metadata_yml_filepath: Path): + """Returns a mapping dictionary between device name and device type based on the dataset schema and metadata.yml from the experiment. Args: schema (DotMap): DotMap object (e.g., exp02) From edcf89e50069f45ce7a244107353dba88a8af678 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 7 Apr 2023 21:43:28 +0000 Subject: [PATCH 207/489] add get_device_mapper --- aeon/dj_pipeline/streams.py | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index 10cecb4b..ccd2bac8 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -181,13 +181,6 @@ class Device(dj.Lookup): ## --------- Helper functions & classes --------- ## -def get_device_configs(device_configs=DEVICE_CONFIGS) -> list[namedtuple]: - """Returns a list of device configurations from DEVICE_CONFIGS""" - - device = namedtuple("device", "type desc streams") - return [device._make(c) for c in device_configs] - - def get_device_template(device_type): """Returns table class template for ExperimentDevice""" device_title = device_type @@ -493,7 +486,7 @@ def get_device_info(schema: DotMap) -> dict[dict]: return device_info -def get_device_type(schema: DotMap, metadata_yml_filepath: Path): +def get_device_mapper(schema: DotMap, metadata_yml_filepath: Path): """Returns a mapping dictionary between device name and device type based on the dataset schema and metadata.yml from the experiment. Args: @@ -501,7 +494,8 @@ def get_device_type(schema: DotMap, metadata_yml_filepath: Path): metadata_yml_filepath (Path): Path to metadata.yml. Returns: - device_info (dict): Updated device_info. + device_type_mapper (dict): {"device_name", "device_type"} + e.g. {'CameraTop': 'VideoSource', 'Patch1': 'Patch'} """ from aeon.io import api @@ -514,20 +508,12 @@ def get_device_type(schema: DotMap, metadata_yml_filepath: Path): .to_dict("records")[0]["metadata"] ) - # Get device_type_mapper based on metadata.yml {'CameraTop': 'VideoSource', 'Patch1': 'Patch'} + # Get device_type_mapper based on metadata.yml device_type_mapper = {} for item in meta_data.Devices: device_type_mapper[item.Name] = item.Type - device_info = { - device_name: { - "device_type": device_type_mapper.get(device_name, None), - **device_info[device_name], - } - for device_name in device_info - } - - return device_info + return device_type_mapper def get_stream_entries(schema: DotMap) -> list[dict]: From 49fa9d3b12cc1510c7053736ecf9520c8f058122 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 7 Apr 2023 22:34:41 +0000 Subject: [PATCH 208/489] remove DEVICE_CONFIGS from streamsm.py --- aeon/dj_pipeline/streams.py | 62 +++---------------------------------- 1 file changed, 4 insertions(+), 58 deletions(-) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index ccd2bac8..de2cc803 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -1,6 +1,7 @@ import inspect +import json import re -from collections import defaultdict, namedtuple +from collections import defaultdict from functools import cached_property from pathlib import Path @@ -9,9 +10,6 @@ from dotmap import DotMap import aeon -import aeon.schema.core as stream -import aeon.schema.foraging as foraging -import aeon.schema.octagon as octagon from aeon.dj_pipeline import acquisition, dict_to_uuid, get_schema_name from aeon.io import api as io_api @@ -30,53 +28,6 @@ ] -# Read from this list of device configurations -# (device_type, description, streams) -DEVICE_CONFIGS = [ - ( - "Camera", - "Camera device", - (stream.video, stream.position, foraging.region), - ), - ("Metadata", "Metadata", (stream.metadata,)), - ( - "ExperimentalMetadata", - "ExperimentalMetadata", - (stream.environment, stream.messageLog), - ), - ( - "NestScale", - "Weight scale at nest", - (foraging.weight,), - ), - ( - "FoodPatch", - "Food patch", - (foraging.patch,), - ), - ( - "Photodiode", - "Photodiode", - (octagon.photodiode,), - ), - ( - "OSC", - "OSC", - (octagon.OSC,), - ), - ( - "TaskLogic", - "TaskLogic", - (octagon.TaskLogic,), - ), - ( - "Wall", - "Wall", - (octagon.Wall,), - ), -] - - @schema class StreamType(dj.Lookup): """ @@ -137,7 +88,7 @@ class Stream(dj.Part): def insert_device_types(cls, schema: DotMap, metadata_yml_filepath: Path): """Use dataset.schema and metadata.yml to insert device types and streams. Only insert device types that were defined both in the device schema (e.g., exp02) and Metadata.yml.""" device_info = get_device_info(schema) - device_type_mapper = get_device_type(schema, metadata_yml_filepath) + device_type_mapper = get_device_mapper(schema, metadata_yml_filepath) device_info = { device_name: { @@ -418,12 +369,7 @@ def get_device_info(schema: DotMap) -> dict[dict]: } } """ - import json - from collections import defaultdict - - from aeon.dj_pipeline import dict_to_uuid - - schema_json = json.dumps(schema, default=lambda o: o.__dict__, indent=4) + schema_json = json.dumps(schema, default=lambda x: x.__dict__, indent=4) schema_dict = json.loads(schema_json) device_info = {} From 2e665eb794f5ff1fa1e1eaf9b50e32f25f9190ca Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 10 Apr 2023 17:56:42 +0000 Subject: [PATCH 209/489] create_presocial exp --- .../create_experiments/create_presocial_a2.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 aeon/dj_pipeline/create_experiments/create_presocial_a2.py diff --git a/aeon/dj_pipeline/create_experiments/create_presocial_a2.py b/aeon/dj_pipeline/create_experiments/create_presocial_a2.py new file mode 100644 index 00000000..74d09a5c --- /dev/null +++ b/aeon/dj_pipeline/create_experiments/create_presocial_a2.py @@ -0,0 +1,61 @@ +from aeon.dj_pipeline import acquisition, lab, subject + +experiment_type = "presocial" +experiment_name = "presocial0.1-a2" # AEON2 acquisition computer +location = "4th floor" + + +def create_new_experiment(): + + lab.Location.insert1({"lab": "SWC", "location": location}, skip_duplicates=True) + + acquisition.ExperimentType.insert1( + {"experiment_type": experiment_type}, skip_duplicates=True + ) + + acquisition.Experiment.insert1( + { + "experiment_name": experiment_name, + "experiment_start_time": "2023-02-25 00:00:00", + "experiment_description": "presocial experiment 0.1 in aeon2", + "arena_name": "circle-2m", + "lab": "SWC", + "location": location, + "experiment_type": experiment_type, + }, + skip_duplicates=True, + ) + + acquisition.Experiment.Subject.insert( + [ + {"experiment_name": experiment_name, "subject": s} + for s in subject.Subject.fetch("subject") + ], + skip_duplicates=True, + ) + + acquisition.Experiment.Directory.insert( + [ + { + "experiment_name": experiment_name, + "repository_name": "ceph_aeon", + "directory_type": "raw", + "directory_path": "aeon/data/raw/AEON2/presocial0.1", + }, + { + "experiment_name": experiment_name, + "repository_name": "ceph_aeon", + "directory_type": "quality-control", + "directory_path": "aeon/data/qc/AEON2/presocial0.1", + }, + ], + skip_duplicates=True, + ) + + +def main(): + create_new_experiment() + + +if __name__ == "__main__": + main() From 0e6dc009bda12db2d6435743d1685cfbceaf2612 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 10 Apr 2023 18:54:58 +0000 Subject: [PATCH 210/489] fix load_metadata to use aeon api --- aeon/dj_pipeline/acquisition.py | 4 +- aeon/dj_pipeline/utils/load_metadata.py | 467 ++++++++++++------------ 2 files changed, 229 insertions(+), 242 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 31d286c0..f28c1ef4 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -12,7 +12,7 @@ from . import get_schema_name, lab, subject from .utils import paths -from .utils.load_metadata import extract_epoch_metadata, ingest_epoch_metadata +from .utils.load_metadata import extract_epoch_config, ingest_epoch_metadata logger = dj.logger schema = dj.schema(get_schema_name("acquisition")) @@ -303,7 +303,7 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): if experiment_name != "exp0.1-r0": metadata_yml_filepath = epoch_dir / "Metadata.yml" if metadata_yml_filepath.exists(): - epoch_config = extract_epoch_metadata( + epoch_config = extract_epoch_config( experiment_name, metadata_yml_filepath ) diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index fcd3685a..30627167 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -1,10 +1,10 @@ import datetime +import json import pathlib import re import numpy as np import pandas as pd -import yaml from aeon.dj_pipeline import acquisition, dict_to_uuid, lab, subject from aeon.io import api as io_api @@ -27,12 +27,21 @@ def ingest_subject(colony_csv_path: pathlib.Path = _colony_csv_path) -> None: ) -def extract_epoch_metadata(experiment_name, metadata_yml_filepath): +def extract_epoch_config(experiment_name: str, metadata_yml_filepath: str) -> dict: + """Parse experiment metadata YAML file and extract epoch configuration. + + Args: + experiment_name (str) + metadata_yml_filepath (str) + + Returns: + dict: epoch_config [dict] + """ metadata_yml_filepath = pathlib.Path(metadata_yml_filepath) epoch_start = datetime.datetime.strptime( metadata_yml_filepath.parent.name, "%Y-%m-%dT%H-%M-%S" ) - experiment_setup: dict = ( + epoch_config: dict = ( io_api.load( str(metadata_yml_filepath.parent), acquisition._device_schema_mapping[experiment_name].Metadata, @@ -41,19 +50,26 @@ def extract_epoch_metadata(experiment_name, metadata_yml_filepath): .to_dict("records")[0] ) - commit = experiment_setup.get("commit") + commit = epoch_config.get("commit") if isinstance(commit, float) and np.isnan(commit): - commit = experiment_setup["metadata"]["Revision"] - else: - commit = None + commit = epoch_config["metadata"]["Revision"] assert commit, f'Neither "Commit" nor "Revision" found in {metadata_yml_filepath}' + + devices: dict[str, dict] = json.loads( + json.dumps( + epoch_config["metadata"]["Devices"], default=lambda x: x.__dict__, indent=4 + ) + ) + + # devices: dict = {d.pop("Name"): d for d in devices} # {deivce_name: device_config} + return { "experiment_name": experiment_name, "epoch_start": epoch_start, - "bonsai_workflow": experiment_setup["workflow"], + "bonsai_workflow": epoch_config["workflow"], "commit": commit, - "metadata": experiment_setup, + "devices": devices, "metadata_file_path": metadata_yml_filepath, } @@ -65,145 +81,108 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): + camera/patch location + patch, weightscale serial number """ + if experiment_name.startswith("oct"): ingest_epoch_metadata_octagon(experiment_name, metadata_yml_filepath) return + experiment_key = {"experiment_name": experiment_name} metadata_yml_filepath = pathlib.Path(metadata_yml_filepath) - epoch_start = datetime.datetime.strptime( - metadata_yml_filepath.parent.name, "%Y-%m-%dT%H-%M-%S" - ) + epoch_config = extract_epoch_config(experiment_name, metadata_yml_filepath) - experiment_setup = io_api.load( - str(metadata_yml_filepath.parent), - acquisition._device_schema_mapping[experiment_name].Metadata, - ) - - experiment_key = {"experiment_name": experiment_name} - # Check if there has been any changes in the arena setup - # by comparing the "Commit" against the most immediate preceding epoch - commit = experiment_setup.get("Commit", experiment_setup.get("Revision")) - assert commit, f'Neither "Commit" nor "Revision" found in {metadata_yml_filepath}' previous_epoch = (acquisition.Experiment & experiment_key).aggr( - acquisition.Epoch & f'epoch_start < "{epoch_start}"', + acquisition.Epoch & f'epoch_start < "{epoch_config["epoch_start"]}"', epoch_start="MAX(epoch_start)", ) - if len(acquisition.Epoch.Config & previous_epoch) and commit == ( + if len(acquisition.Epoch.Config & previous_epoch) and epoch_config["commit"] == ( acquisition.Epoch.Config & previous_epoch ).fetch1("commit"): # if identical commit -> no changes return - if isinstance(experiment_setup["Devices"], list): - experiment_devices = experiment_setup.pop("Devices") - elif isinstance(experiment_setup["Devices"], dict): - experiment_devices = [] - for device_name, device_info in experiment_setup.pop("Devices").items(): - if device_name.startswith("VideoController"): - device_type = "VideoController" - elif all(v in device_info for v in ("TriggerFrequency", "FrameEvents")): - device_type = "VideoSource" - elif all(v in device_info for v in ("PelletDelivered", "PatchEvents")): - device_type = "Patch" - elif all(v in device_info for v in ("TareWeight", "WeightEvents")): - device_type = "WeightScale" - elif device_name.startswith("AudioAmbient"): - device_type = "AudioAmbient" - elif device_name.startswith("Wall"): - device_type = "Wall" - elif device_name.startswith("Photodiode"): - device_type = "Photodiode" - else: - raise ValueError(f"Unrecognized Device Type for {device_name}") - experiment_devices.append( - {"Name": device_name, "Type": device_type, **device_info} - ) - else: - raise ValueError( - f"Unexpected devices variable type: {type(experiment_setup['Devices'])}" - ) - # ---- Video Controller ---- - video_controller = [ - device for device in experiment_devices if device["Type"] == "VideoController" - ] - assert ( - len(video_controller) == 1 - ), "Unable to find one unique VideoController device" - video_controller = video_controller[0] + device_frequency_mapper = { name: float(value) - for name, value in video_controller.items() + for name, value in epoch_config["devices"]["VideoController"].items() if name.endswith("Frequency") } + # ---- Load cameras ---- - cameras = [ - device for device in experiment_devices if device["Type"] == "VideoSource" - ] camera_list, camera_installation_list, camera_removal_list, camera_position_list = ( [], [], [], [], ) - for camera in cameras: - # ---- Check if this is a new camera, add to lab.Camera if needed - camera_key = {"camera_serial_number": camera["SerialNumber"]} - camera_list.append(camera_key) - camera_installation = { - "experiment_name": experiment_name, - **camera_key, - "camera_install_time": epoch_start, - "camera_description": camera["Name"], - "camera_sampling_rate": device_frequency_mapper[camera["TriggerFrequency"]], - "camera_gain": float(camera["Gain"]), - "camera_bin": int(camera["Binning"]), - } - if "position" in camera: - camera_position = { + # Check if this is a new camera, add to lab.Camera if needed + for device_name, device_config in epoch_config["devices"].items(): + if device_config["Type"] == "VideoSource": + camera_key = {"camera_serial_number": device_config["SerialNumber"]} + camera_list.append(camera_key) + + camera_installation = { **camera_key, "experiment_name": experiment_name, - "camera_install_time": epoch_start, - "camera_position_x": camera["position"]["x"], - "camera_position_y": camera["position"]["y"], - "camera_position_z": camera["position"]["z"], + "camera_install_time": epoch_config["epoch_start"], + "camera_description": device_name, + "camera_sampling_rate": device_frequency_mapper[ + device_config["TriggerFrequency"] + ], + "camera_gain": float(device_config["Gain"]), + "camera_bin": int(device_config["Binning"]), } - else: - camera_position = { - "camera_position_x": None, - "camera_position_y": None, - "camera_position_z": None, - "camera_rotation_x": None, - "camera_rotation_y": None, - "camera_rotation_z": None, - } - # ---- Check if this camera is currently installed - # If the same camera serial number is currently installed - # check for any changes in configuration, if not, skip this - current_camera_query = ( - acquisition.ExperimentCamera - acquisition.ExperimentCamera.RemovalTime - & experiment_key - & camera_key - ) - if current_camera_query: - current_camera_config = current_camera_query.join( - acquisition.ExperimentCamera.Position, left=True - ).fetch1() - new_camera_config = {**camera_installation, **camera_position} - current_camera_config.pop("camera_install_time") - new_camera_config.pop("camera_install_time") - if dict_to_uuid(current_camera_config) == dict_to_uuid(new_camera_config): - continue - # ---- Remove old camera - camera_removal_list.append( - { - **current_camera_query.fetch1("KEY"), - "camera_remove_time": epoch_start, + + if "position" in device_config: + camera_position = { + **camera_key, + "experiment_name": experiment_name, + "camera_install_time": epoch_config["epoch_start"], + "camera_position_x": device_config["position"]["x"], + "camera_position_y": device_config["position"]["y"], + "camera_position_z": device_config["position"]["z"], } + else: + camera_position = { + "camera_position_x": None, + "camera_position_y": None, + "camera_position_z": None, + "camera_rotation_x": None, + "camera_rotation_y": None, + "camera_rotation_z": None, + } + + """Check if this camera is currently installed. If the same camera serial number is currently installed check for any changes in configuration. If not, skip this""" + current_camera_query = ( + acquisition.ExperimentCamera - acquisition.ExperimentCamera.RemovalTime + & experiment_key + & camera_key ) - # ---- Install new camera - camera_installation_list.append(camera_installation) - if "position" in camera: - camera_position_list.append(camera_position) - # remove the currently installed cameras that are absent in this config + + if current_camera_query: + current_camera_config = current_camera_query.join( + acquisition.ExperimentCamera.Position, left=True + ).fetch1() + + new_camera_config = {**camera_installation, **camera_position} + current_camera_config.pop("camera_install_time") + new_camera_config.pop("camera_install_time") + + if dict_to_uuid(current_camera_config) == dict_to_uuid( + new_camera_config + ): + continue + # Remove old camera + camera_removal_list.append( + { + **current_camera_query.fetch1("KEY"), + "camera_remove_time": epoch_config["epoch_start"], + } + ) + # Install new camera + camera_installation_list.append(camera_installation) + + if "position" in device_config: + camera_position_list.append(camera_position) + # Remove the currently installed cameras that are absent in this config camera_removal_list.extend( ( acquisition.ExperimentCamera @@ -212,146 +191,154 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): & experiment_key ).fetch("KEY") ) + # ---- Load food patches ---- - food_patches = [ - device for device in experiment_devices if device["Type"] == "Patch" - ] patch_list, patch_installation_list, patch_removal_list, patch_position_list = ( [], [], [], [], ) - for patch in food_patches: - # ---- Check if this is a new food patch, add to lab.FoodPatch if needed - patch_key = { - "food_patch_serial_number": patch.get("SerialNumber") or patch["PortName"] - } - patch_list.append(patch_key) - patch_installation = { - **patch_key, - "experiment_name": experiment_name, - "food_patch_install_time": epoch_start, - "food_patch_description": patch["Name"], - "wheel_sampling_rate": float( - re.search(r"\d+", patch["SampleRate"]).group() - ), - "wheel_radius": float(patch["Radius"]), - } - if "position" in patch: - patch_position = { + + # Check if this is a new food patch, add to lab.FoodPatch if needed + for device_name, device_config in epoch_config["devices"].items(): + if device_config["Type"] == "Patch": + + patch_key = { + "food_patch_serial_number": device_config.get( + "SerialNumber", device_config.get("PortName") + ) + } + patch_list.append(patch_key) + patch_installation = { **patch_key, "experiment_name": experiment_name, - "food_patch_install_time": epoch_start, - "food_patch_position_x": patch["position"]["x"], - "food_patch_position_y": patch["position"]["y"], - "food_patch_position_z": patch["position"]["z"], + "food_patch_install_time": epoch_config["epoch_start"], + "food_patch_description": device_config["Name"], + "wheel_sampling_rate": float( + re.search(r"\d+", device_config["SampleRate"]).group() + ), + "wheel_radius": float(device_config["Radius"]), } - else: - patch_position = { - "food_patch_position_x": None, - "food_patch_position_y": None, - "food_patch_position_z": None, - } - # ---- Check if this camera is currently installed - # If the same camera serial number is currently installed - # check for any changes in configuration, if not, skip this - current_patch_query = ( - acquisition.ExperimentFoodPatch - - acquisition.ExperimentFoodPatch.RemovalTime - & experiment_key - & patch_key - ) - if current_patch_query: - current_patch_config = current_patch_query.join( - acquisition.ExperimentFoodPatch.Position, left=True - ).fetch1() - new_patch_config = {**patch_installation, **patch_position} - current_patch_config.pop("food_patch_install_time") - new_patch_config.pop("food_patch_install_time") - if dict_to_uuid(current_patch_config) == dict_to_uuid(new_patch_config): - continue - # ---- Remove old food patch - patch_removal_list.append( - { - **current_patch_query.fetch1("KEY"), - "food_patch_remove_time": epoch_start, + if "position" in device_config: + patch_position = { + **patch_key, + "experiment_name": experiment_name, + "food_patch_install_time": epoch_config["epoch_start"], + "food_patch_position_x": device_config["position"]["x"], + "food_patch_position_y": device_config["position"]["y"], + "food_patch_position_z": device_config["position"]["z"], + } + else: + patch_position = { + "food_patch_position_x": None, + "food_patch_position_y": None, + "food_patch_position_z": None, } + + """Check if this camera is currently installed. If the same camera serial number is currently installed, check for any changes in configuration, if not, skip this""" + current_patch_query = ( + acquisition.ExperimentFoodPatch + - acquisition.ExperimentFoodPatch.RemovalTime + & experiment_key + & patch_key ) - # ---- Install new food patch - patch_installation_list.append(patch_installation) - if "position" in patch: - patch_position_list.append(patch_position) - # remove the currently installed patches that are absent in this config - patch_removal_list.extend( - ( - acquisition.ExperimentFoodPatch - - acquisition.ExperimentFoodPatch.RemovalTime - - patch_list - & experiment_key - ).fetch("KEY") - ) + if current_patch_query: + current_patch_config = current_patch_query.join( + acquisition.ExperimentFoodPatch.Position, left=True + ).fetch1() + new_patch_config = {**patch_installation, **patch_position} + current_patch_config.pop("food_patch_install_time") + new_patch_config.pop("food_patch_install_time") + if dict_to_uuid(current_patch_config) == dict_to_uuid(new_patch_config): + continue + # Remove old food patch + patch_removal_list.append( + { + **current_patch_query.fetch1("KEY"), + "food_patch_remove_time": epoch_config["epoch_start"], + } + ) + # Install new food patch + patch_installation_list.append(patch_installation) + if "position" in device_config: + patch_position_list.append(patch_position) + # Remove the currently installed patches that are absent in this config + patch_removal_list.extend( + ( + acquisition.ExperimentFoodPatch + - acquisition.ExperimentFoodPatch.RemovalTime + - patch_list + & experiment_key + ).fetch("KEY") + ) + # ---- Load weight scales ---- - weight_scales = [ - device for device in experiment_devices if device["Type"] == "WeightScale" - ] weight_scale_list, weight_scale_installation_list, weight_scale_removal_list = ( [], [], [], ) - for weight_scale in weight_scales: - # ---- Check if this is a new weight scale, add to lab.WeightScale if needed - weight_scale_key = { - "weight_scale_serial_number": weight_scale.get("SerialNumber") - or weight_scale["PortName"] - } - weight_scale_list.append(weight_scale_key) - arena_key = (lab.Arena & acquisition.Experiment & experiment_key).fetch1("KEY") - weight_scale_installation = { - "experiment_name": experiment_name, - **weight_scale_key, - "weight_scale_install_time": epoch_start, - **arena_key, - "nest": _weight_scale_nest, - "weight_scale_description": weight_scale["Name"], - "weight_scale_sampling_rate": float(_weight_scale_rate), - } - # ---- Check if this weight scale is currently installed - if so, remove it - current_weight_scale_query = ( - acquisition.ExperimentWeightScale - - acquisition.ExperimentWeightScale.RemovalTime - & experiment_key - & weight_scale_key - ) - if current_weight_scale_query: - current_weight_scale_config = current_weight_scale_query.fetch1() - new_weight_scale_config = weight_scale_installation.copy() - current_weight_scale_config.pop("weight_scale_install_time") - new_weight_scale_config.pop("weight_scale_install_time") - if dict_to_uuid(current_weight_scale_config) == dict_to_uuid( - new_weight_scale_config - ): - continue - # ---- Remove old weight scale - weight_scale_removal_list.append( - { - **current_weight_scale_query.fetch1("KEY"), - "weight_scale_remove_time": epoch_start, - } + + # Check if this is a new weight scale, add to lab.WeightScale if needed + for device_name, device_config in epoch_config["devices"].items(): + if device_config["Type"] == "WeightScale": + + weight_scale_key = { + "weight_scale_serial_number": device_config.get( + "SerialNumber", device_config.get("PortName") + ) + } + weight_scale_list.append(weight_scale_key) + arena_key = (lab.Arena & acquisition.Experiment & experiment_key).fetch1( + "KEY" ) - # ---- Install new weight scale - weight_scale_installation_list.append(weight_scale_installation) - # remove the currently installed weight scales that are absent in this config - weight_scale_removal_list.extend( - ( - acquisition.ExperimentWeightScale - - acquisition.ExperimentWeightScale.RemovalTime - - weight_scale_list - & experiment_key - ).fetch("KEY") - ) - # ---- insert ---- + weight_scale_installation = { + **weight_scale_key, + **arena_key, + "experiment_name": experiment_name, + "weight_scale_install_time": epoch_config["epoch_start"], + "nest": _weight_scale_nest, + "weight_scale_description": device_name, + "weight_scale_sampling_rate": float(_weight_scale_rate), + } + + # Check if this weight scale is currently installed - if so, remove it + current_weight_scale_query = ( + acquisition.ExperimentWeightScale + - acquisition.ExperimentWeightScale.RemovalTime + & experiment_key + & weight_scale_key + ) + if current_weight_scale_query: + current_weight_scale_config = current_weight_scale_query.fetch1() + new_weight_scale_config = weight_scale_installation.copy() + current_weight_scale_config.pop("weight_scale_install_time") + new_weight_scale_config.pop("weight_scale_install_time") + if dict_to_uuid(current_weight_scale_config) == dict_to_uuid( + new_weight_scale_config + ): + continue + # Remove old weight scale + weight_scale_removal_list.append( + { + **current_weight_scale_query.fetch1("KEY"), + "weight_scale_remove_time": epoch_config["epoch_start"], + } + ) + # Install new weight scale + weight_scale_installation_list.append(weight_scale_installation) + # Remove the currently installed weight scales that are absent in this config + weight_scale_removal_list.extend( + ( + acquisition.ExperimentWeightScale + - acquisition.ExperimentWeightScale.RemovalTime + - weight_scale_list + & experiment_key + ).fetch("KEY") + ) + + # Insert def insert(): lab.Camera.insert(camera_list, skip_duplicates=True) acquisition.ExperimentCamera.RemovalTime.insert(camera_removal_list) From 150bec765af6d285e54d83dd12e39d4a45a2ac71 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 10 Apr 2023 21:48:25 +0000 Subject: [PATCH 211/489] fix keyword error --- aeon/dj_pipeline/streams.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index de2cc803..ca023fe4 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -97,16 +97,19 @@ def insert_device_types(cls, schema: DotMap, metadata_yml_filepath: Path): } for device_name in device_info } - + #! return only a list of device types that have been inserted. for device_name, info in device_info.items(): if info["device_type"]: + + if cls & {"device_type": info["device_type"]}: + continue + with cls.connections.transaction: cls.insert1( { "device_type": info["device_type"], "device_description": "", }, - skip_duplicates=True, ) cls.Stream.insert( [ @@ -116,7 +119,6 @@ def insert_device_types(cls, schema: DotMap, metadata_yml_filepath: Path): } for e in info["stream_type"] ], - skip_duplicates=True, ) @@ -159,10 +161,10 @@ class RemovalTime(dj.Part): definition = f""" -> master --- - {device_type}_remove_time: datetime(6) # time of the camera being removed from this position + {device_type}_remove_time: datetime(6) # time of the {device_type} being removed """ - ExperimentDevice.__name__ = f"Experiment{device_title}" + ExperimentDevice.__name__ = f"{device_title}" return ExperimentDevice @@ -171,7 +173,7 @@ def get_device_stream_template(device_type, stream_type): """Returns table class template for DeviceDataStream""" ExperimentDevice = get_device_template(device_type) - exp_device_table_name = f"Experiment{device_type}" + exp_device_table_name = f"{device_type}" # DeviceDataStream table(s) stream_detail = ( @@ -188,7 +190,7 @@ def get_device_stream_template(device_type, stream_type): stream = reader(**stream_detail["stream_reader_kwargs"]) table_definition = f""" # Raw per-chunk {stream_type} data stream from {device_type} (auto-generated with aeon_mecha-{aeon.__version__}) - -> Experiment{device_type} + -> {device_type} -> acquisition.Chunk --- sample_count: int # number of data points acquired from this stream for a given chunk @@ -262,6 +264,8 @@ def make(self, key): class DeviceTableManager: + + # TODO: Simplify this class. def __init__(self, context=None): if context is None: @@ -360,7 +364,7 @@ def get_device_info(schema: DotMap) -> dict[dict]: e.g. {'CameraTop': {'stream_type': ['Video', 'Position', 'Region'], - 'reader': [ + 'stream_reader': [ aeon.io.reader.Video, aeon.io.reader.Position, aeon.schema.foraging._RegionReader @@ -385,13 +389,15 @@ def get_device_info(schema: DotMap) -> dict[dict]: "aeon.schema.octagon", ]: device_info[device_name]["stream_type"].append(stream_type) - device_info[device_name]["reader"].append( + device_info[device_name]["stream_reader"].append( schema[device_name][stream_type].__class__ ) else: stream_type = schema[device_name].__class__.__name__ device_info[device_name]["stream_type"].append(stream_type) - device_info[device_name]["reader"].append(schema[device_name].__class__) + device_info[device_name]["stream_reader"].append( + schema[device_name].__class__ + ) """Add a kwargs such as pattern, columns, extension, dtype and hash e.g., {'pattern': '{pattern}_SubjectState', From 0423b71316ec59870832cf3ba44370bad5839f0d Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 11 Apr 2023 17:25:33 +0000 Subject: [PATCH 212/489] handle type missing attribut error --- aeon/dj_pipeline/streams.py | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index ca023fe4..388f7dba 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -439,7 +439,7 @@ def get_device_info(schema: DotMap) -> dict[dict]: def get_device_mapper(schema: DotMap, metadata_yml_filepath: Path): - """Returns a mapping dictionary between device name and device type based on the dataset schema and metadata.yml from the experiment. + """Returns a mapping dictionary between device name and device type based on the dataset schema and metadata.yml from the experiment. Store the mapper dictionary and read from it if the type info doesn't exist in Metadata.yml. Args: schema (DotMap): DotMap object (e.g., exp02) @@ -449,8 +449,11 @@ def get_device_mapper(schema: DotMap, metadata_yml_filepath: Path): device_type_mapper (dict): {"device_name", "device_type"} e.g. {'CameraTop': 'VideoSource', 'Patch1': 'Patch'} """ + import os + from aeon.io import api + metadata_yml_filepath = Path(metadata_yml_filepath) meta_data = ( api.load( str(metadata_yml_filepath.parent), @@ -460,10 +463,27 @@ def get_device_mapper(schema: DotMap, metadata_yml_filepath: Path): .to_dict("records")[0]["metadata"] ) - # Get device_type_mapper based on metadata.yml + # Store the mapper dictionary here + repository_root = ( + os.popen("git rev-parse --show-toplevel").read().strip() + ) # repo root path + filename = Path( + repository_root + "/aeon/dj_pipeline/create_experiments/device_type_mapper.json" + ) + device_type_mapper = {} - for item in meta_data.Devices: - device_type_mapper[item.Name] = item.Type + + if filename.is_file(): + with filename.open("r") as f: + device_type_mapper = json.load(f) + + try: # if the device type is not in the mapper, add it + for item in meta_data.Devices: + device_type_mapper[item.Name] = item.Type + with filename.open("w") as f: + json.dump(device_type_mapper, f) + except AttributeError: + pass return device_type_mapper From 45111e8063bee810d3508339ccdf6950a33c44cf Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 11 Apr 2023 19:35:41 +0000 Subject: [PATCH 213/489] insert into streams.StreamType from schema dotmap --- aeon/dj_pipeline/populate/worker.py | 7 +++---- aeon/dj_pipeline/utils/load_metadata.py | 13 ++++++++++++- aeon/schema/dataset.py | 2 ++ 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py index 994ad60a..4c506347 100644 --- a/aeon/dj_pipeline/populate/worker.py +++ b/aeon/dj_pipeline/populate/worker.py @@ -1,8 +1,8 @@ import datajoint as dj from datajoint_utilities.dj_worker import ( DataJointWorker, - WorkerLog, ErrorLog, + WorkerLog, is_djtable, ) @@ -12,12 +12,11 @@ db_prefix, qc, report, - tracking, streams, + tracking, ) from aeon.dj_pipeline.utils import load_metadata - __all__ = [ "high_priority", "mid_priority", @@ -44,6 +43,7 @@ ) high_priority(load_metadata.ingest_subject) +high_priority(load_metadata.ingest_streams) high_priority(acquisition.Epoch.ingest_epochs, experiment_name=_current_experiment) high_priority(acquisition.Chunk.ingest_chunks, experiment_name=_current_experiment) high_priority(acquisition.ExperimentLog) @@ -101,4 +101,3 @@ for attr in vars(streams).values(): if is_djtable(attr) and hasattr(attr, "populate"): streams_worker(attr) - diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 30627167..12512630 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -6,7 +6,7 @@ import numpy as np import pandas as pd -from aeon.dj_pipeline import acquisition, dict_to_uuid, lab, subject +from aeon.dj_pipeline import acquisition, dict_to_uuid, lab, streams, subject from aeon.io import api as io_api _weight_scale_rate = 100 @@ -27,6 +27,17 @@ def ingest_subject(colony_csv_path: pathlib.Path = _colony_csv_path) -> None: ) +def ingest_streams(): + """Insert into stream.streamType table all streams in the dataset schema.""" + from dotmap import DotMap + + from aeon.schema import dataset + + schemas = [v for v in dataset.__dict__.values() if isinstance(v, DotMap)] + for schema in schemas: + streams.StreamType.insert_streams(schema) + + def extract_epoch_config(experiment_name: str, metadata_yml_filepath: str) -> dict: """Parse experiment metadata YAML file and extract epoch configuration. diff --git a/aeon/schema/dataset.py b/aeon/schema/dataset.py index 5aae992f..f74bcce7 100644 --- a/aeon/schema/dataset.py +++ b/aeon/schema/dataset.py @@ -6,6 +6,8 @@ from aeon.io import reader from aeon.io.device import Device +__all__ = ["exp02", "exp01", "octagon01", "presocial"] + exp02 = DotMap( [ Device("Metadata", stream.metadata), From d20902511ea156a7192c9e30345a46520249b8d7 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 11 Apr 2023 19:56:17 +0000 Subject: [PATCH 214/489] move StreamType insertion functions to load_metadata --- aeon/dj_pipeline/streams.py | 191 +---------------------- aeon/dj_pipeline/utils/load_metadata.py | 195 +++++++++++++++++++++++- 2 files changed, 193 insertions(+), 193 deletions(-) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index 388f7dba..3d6d9ca4 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -1,5 +1,4 @@ import inspect -import json import re from collections import defaultdict from functools import cached_property @@ -47,24 +46,6 @@ class StreamType(dj.Lookup): unique index (stream_hash) """ - @classmethod - def insert_streams(cls, schema: DotMap): - - stream_entries = get_stream_entries(schema) - - for entry in stream_entries: - q_param = cls & {"stream_hash": entry["stream_hash"]} - if q_param: # If the specified stream type already exists - pname = q_param.fetch1("stream_type") - if pname != entry["stream_type"]: - # If the existed stream type does not have the same name: - # human error, trying to add the same content with different name - raise dj.DataJointError( - f"The specified stream type already exists - name: {pname}" - ) - - cls.insert(stream_entries, skip_duplicates=True) - @schema class DeviceType(dj.Lookup): @@ -97,7 +78,7 @@ def insert_device_types(cls, schema: DotMap, metadata_yml_filepath: Path): } for device_name in device_info } - #! return only a list of device types that have been inserted. + # Return only a list of device types that have been inserted. for device_name, info in device_info.items(): if info["device_type"]: @@ -350,173 +331,3 @@ def create_device_stream_tables(self): self._schema(table_class, context=self.context) self._schema.activate(schema_name) - - -def get_device_info(schema: DotMap) -> dict[dict]: - """ - Read from the above DotMap object and returns a device dictionary as the following. - - Args: - schema (DotMap): DotMap object (e.g., exp02, octagon01) - - Returns: - device_info (dict[dict]): A dictionary of device information - - e.g. {'CameraTop': - {'stream_type': ['Video', 'Position', 'Region'], - 'stream_reader': [ - aeon.io.reader.Video, - aeon.io.reader.Position, - aeon.schema.foraging._RegionReader - ], - 'pattern': ['{pattern}', '{pattern}_200', '{pattern}_201'] - } - } - """ - schema_json = json.dumps(schema, default=lambda x: x.__dict__, indent=4) - schema_dict = json.loads(schema_json) - - device_info = {} - - for device_name in schema: - if not device_name.startswith("_"): - device_info[device_name] = defaultdict(list) - if isinstance(schema[device_name], DotMap): - for stream_type in schema[device_name].keys(): - if schema[device_name][stream_type].__class__.__module__ in [ - "aeon.io.reader", - "aeon.schema.foraging", - "aeon.schema.octagon", - ]: - device_info[device_name]["stream_type"].append(stream_type) - device_info[device_name]["stream_reader"].append( - schema[device_name][stream_type].__class__ - ) - else: - stream_type = schema[device_name].__class__.__name__ - device_info[device_name]["stream_type"].append(stream_type) - device_info[device_name]["stream_reader"].append( - schema[device_name].__class__ - ) - - """Add a kwargs such as pattern, columns, extension, dtype and hash - e.g., {'pattern': '{pattern}_SubjectState', - 'columns': ['id', 'weight', 'event'], - 'extension': 'csv', - 'dtype': None}""" - for device_name in device_info: - if pattern := schema_dict[device_name].get("pattern"): - schema_dict[device_name]["pattern"] = pattern.replace( - device_name, "{pattern}" - ) - - # Add stream_reader_kwargs - kwargs = schema_dict[device_name] - device_info[device_name]["stream_reader_kwargs"].append(kwargs) - stream_reader = device_info[device_name]["stream_reader"] - # Add hash - device_info[device_name]["stream_hash"].append( - dict_to_uuid({**kwargs, "stream_reader": stream_reader}) - ) - - else: - for stream_type in device_info[device_name]["stream_type"]: - pattern = schema_dict[device_name][stream_type]["pattern"] - schema_dict[device_name][stream_type]["pattern"] = pattern.replace( - device_name, "{pattern}" - ) - # Add stream_reader_kwargs - kwargs = schema_dict[device_name][stream_type] - device_info[device_name]["stream_reader_kwargs"].append(kwargs) - stream_ind = device_info[device_name]["stream_type"].index(stream_type) - stream_reader = device_info[device_name]["stream_reader"][stream_ind] - # Add hash - device_info[device_name]["stream_hash"].append( - dict_to_uuid({**kwargs, "stream_reader": stream_reader}) - ) - - return device_info - - -def get_device_mapper(schema: DotMap, metadata_yml_filepath: Path): - """Returns a mapping dictionary between device name and device type based on the dataset schema and metadata.yml from the experiment. Store the mapper dictionary and read from it if the type info doesn't exist in Metadata.yml. - - Args: - schema (DotMap): DotMap object (e.g., exp02) - metadata_yml_filepath (Path): Path to metadata.yml. - - Returns: - device_type_mapper (dict): {"device_name", "device_type"} - e.g. {'CameraTop': 'VideoSource', 'Patch1': 'Patch'} - """ - import os - - from aeon.io import api - - metadata_yml_filepath = Path(metadata_yml_filepath) - meta_data = ( - api.load( - str(metadata_yml_filepath.parent), - schema.Metadata, - ) - .reset_index() - .to_dict("records")[0]["metadata"] - ) - - # Store the mapper dictionary here - repository_root = ( - os.popen("git rev-parse --show-toplevel").read().strip() - ) # repo root path - filename = Path( - repository_root + "/aeon/dj_pipeline/create_experiments/device_type_mapper.json" - ) - - device_type_mapper = {} - - if filename.is_file(): - with filename.open("r") as f: - device_type_mapper = json.load(f) - - try: # if the device type is not in the mapper, add it - for item in meta_data.Devices: - device_type_mapper[item.Name] = item.Type - with filename.open("w") as f: - json.dump(device_type_mapper, f) - except AttributeError: - pass - - return device_type_mapper - - -def get_stream_entries(schema: DotMap) -> list[dict]: - """Returns a list of dictionaries containing the stream entries for a given device, - - Args: - schema (DotMap): DotMap object (e.g., exp02, octagon01) - - Returns: - stream_info (list[dict]): list of dictionaries containing the stream entries for a given device, - - e.g. {'stream_type': 'EnvironmentState', - 'stream_reader': aeon.io.reader.Csv, - 'stream_reader_kwargs': {'pattern': '{pattern}_EnvironmentState', - 'columns': ['state'], - 'extension': 'csv', - 'dtype': None} - """ - device_info = get_device_info(schema) - return [ - { - "stream_type": stream_type, - "stream_reader": stream_reader, - "stream_reader_kwargs": stream_reader_kwargs, - "stream_hash": stream_hash, - } - for stream_info in device_info.values() - for stream_type, stream_reader, stream_reader_kwargs, stream_hash in zip( - stream_info["stream_type"], - stream_info["stream_reader"], - stream_info["stream_reader_kwargs"], - stream_info["stream_hash"], - ) - ] diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 12512630..b56f86eb 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -2,9 +2,12 @@ import json import pathlib import re +from collections import defaultdict +from pathlib import Path import numpy as np import pandas as pd +from dotmap import DotMap from aeon.dj_pipeline import acquisition, dict_to_uuid, lab, streams, subject from aeon.io import api as io_api @@ -29,13 +32,25 @@ def ingest_subject(colony_csv_path: pathlib.Path = _colony_csv_path) -> None: def ingest_streams(): """Insert into stream.streamType table all streams in the dataset schema.""" - from dotmap import DotMap - from aeon.schema import dataset schemas = [v for v in dataset.__dict__.values() if isinstance(v, DotMap)] for schema in schemas: - streams.StreamType.insert_streams(schema) + + stream_entries = get_stream_entries(schema) + + for entry in stream_entries: + q_param = streams.StreamType & {"stream_hash": entry["stream_hash"]} + if q_param: # If the specified stream type already exists + pname = q_param.fetch1("stream_type") + if pname != entry["stream_type"]: + # If the existed stream type does not have the same name: + # human error, trying to add the same content with different name + raise dj.DataJointError( + f"The specified stream type already exists - name: {pname}" + ) + + streams.StreamType.insert(stream_entries, skip_duplicates=True) def extract_epoch_config(experiment_name: str, metadata_yml_filepath: str) -> dict: @@ -412,3 +427,177 @@ def ingest_epoch_metadata_octagon(experiment_name, metadata_yml_filepath): experiment_table.insert1( (experiment_name, device_sn, epoch_start, device_name) ) + + +# region Get stream & device information +def get_device_info(schema: DotMap) -> dict[dict]: + """ + Read from the above DotMap object and returns a device dictionary as the following. + + Args: + schema (DotMap): DotMap object (e.g., exp02, octagon01) + + Returns: + device_info (dict[dict]): A dictionary of device information + + e.g. {'CameraTop': + {'stream_type': ['Video', 'Position', 'Region'], + 'stream_reader': [ + aeon.io.reader.Video, + aeon.io.reader.Position, + aeon.schema.foraging._RegionReader + ], + 'pattern': ['{pattern}', '{pattern}_200', '{pattern}_201'] + } + } + """ + schema_json = json.dumps(schema, default=lambda x: x.__dict__, indent=4) + schema_dict = json.loads(schema_json) + + device_info = {} + + for device_name in schema: + if not device_name.startswith("_"): + device_info[device_name] = defaultdict(list) + if isinstance(schema[device_name], DotMap): + for stream_type in schema[device_name].keys(): + if schema[device_name][stream_type].__class__.__module__ in [ + "aeon.io.reader", + "aeon.schema.foraging", + "aeon.schema.octagon", + ]: + device_info[device_name]["stream_type"].append(stream_type) + device_info[device_name]["stream_reader"].append( + schema[device_name][stream_type].__class__ + ) + else: + stream_type = schema[device_name].__class__.__name__ + device_info[device_name]["stream_type"].append(stream_type) + device_info[device_name]["stream_reader"].append( + schema[device_name].__class__ + ) + + """Add a kwargs such as pattern, columns, extension, dtype and hash + e.g., {'pattern': '{pattern}_SubjectState', + 'columns': ['id', 'weight', 'event'], + 'extension': 'csv', + 'dtype': None}""" + for device_name in device_info: + if pattern := schema_dict[device_name].get("pattern"): + schema_dict[device_name]["pattern"] = pattern.replace( + device_name, "{pattern}" + ) + + # Add stream_reader_kwargs + kwargs = schema_dict[device_name] + device_info[device_name]["stream_reader_kwargs"].append(kwargs) + stream_reader = device_info[device_name]["stream_reader"] + # Add hash + device_info[device_name]["stream_hash"].append( + dict_to_uuid({**kwargs, "stream_reader": stream_reader}) + ) + + else: + for stream_type in device_info[device_name]["stream_type"]: + pattern = schema_dict[device_name][stream_type]["pattern"] + schema_dict[device_name][stream_type]["pattern"] = pattern.replace( + device_name, "{pattern}" + ) + # Add stream_reader_kwargs + kwargs = schema_dict[device_name][stream_type] + device_info[device_name]["stream_reader_kwargs"].append(kwargs) + stream_ind = device_info[device_name]["stream_type"].index(stream_type) + stream_reader = device_info[device_name]["stream_reader"][stream_ind] + # Add hash + device_info[device_name]["stream_hash"].append( + dict_to_uuid({**kwargs, "stream_reader": stream_reader}) + ) + + return device_info + + +def get_device_mapper(schema: DotMap, metadata_yml_filepath: Path): + """Returns a mapping dictionary between device name and device type based on the dataset schema and metadata.yml from the experiment. Store the mapper dictionary and read from it if the type info doesn't exist in Metadata.yml. + + Args: + schema (DotMap): DotMap object (e.g., exp02) + metadata_yml_filepath (Path): Path to metadata.yml. + + Returns: + device_type_mapper (dict): {"device_name", "device_type"} + e.g. {'CameraTop': 'VideoSource', 'Patch1': 'Patch'} + """ + import os + + from aeon.io import api + + metadata_yml_filepath = Path(metadata_yml_filepath) + meta_data = ( + api.load( + str(metadata_yml_filepath.parent), + schema.Metadata, + ) + .reset_index() + .to_dict("records")[0]["metadata"] + ) + + # Store the mapper dictionary here + repository_root = ( + os.popen("git rev-parse --show-toplevel").read().strip() + ) # repo root path + filename = Path( + repository_root + "/aeon/dj_pipeline/create_experiments/device_type_mapper.json" + ) + + device_type_mapper = {} + + if filename.is_file(): + with filename.open("r") as f: + device_type_mapper = json.load(f) + + try: # if the device type is not in the mapper, add it + for item in meta_data.Devices: + device_type_mapper[item.Name] = item.Type + with filename.open("w") as f: + json.dump(device_type_mapper, f) + except AttributeError: + pass + + return device_type_mapper + + +def get_stream_entries(schema: DotMap) -> list[dict]: + """Returns a list of dictionaries containing the stream entries for a given device, + + Args: + schema (DotMap): DotMap object (e.g., exp02, octagon01) + + Returns: + stream_info (list[dict]): list of dictionaries containing the stream entries for a given device, + + e.g. {'stream_type': 'EnvironmentState', + 'stream_reader': aeon.io.reader.Csv, + 'stream_reader_kwargs': {'pattern': '{pattern}_EnvironmentState', + 'columns': ['state'], + 'extension': 'csv', + 'dtype': None} + """ + device_info = get_device_info(schema) + return [ + { + "stream_type": stream_type, + "stream_reader": stream_reader, + "stream_reader_kwargs": stream_reader_kwargs, + "stream_hash": stream_hash, + } + for stream_info in device_info.values() + for stream_type, stream_reader, stream_reader_kwargs, stream_hash in zip( + stream_info["stream_type"], + stream_info["stream_reader"], + stream_info["stream_reader_kwargs"], + stream_info["stream_hash"], + ) + ] + + +# endregion From c8c928a468e84e277e161567a289de5085202c46 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 11 Apr 2023 22:36:13 +0000 Subject: [PATCH 215/489] move insert_devices into load_metadata --- aeon/dj_pipeline/streams.py | 37 --------------- aeon/dj_pipeline/utils/load_metadata.py | 60 +++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 40 deletions(-) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index 3d6d9ca4..1886b22f 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -65,43 +65,6 @@ class Stream(dj.Part): -> StreamType """ - @classmethod - def insert_device_types(cls, schema: DotMap, metadata_yml_filepath: Path): - """Use dataset.schema and metadata.yml to insert device types and streams. Only insert device types that were defined both in the device schema (e.g., exp02) and Metadata.yml.""" - device_info = get_device_info(schema) - device_type_mapper = get_device_mapper(schema, metadata_yml_filepath) - - device_info = { - device_name: { - "device_type": device_type_mapper.get(device_name, None), - **device_info[device_name], - } - for device_name in device_info - } - # Return only a list of device types that have been inserted. - for device_name, info in device_info.items(): - if info["device_type"]: - - if cls & {"device_type": info["device_type"]}: - continue - - with cls.connections.transaction: - cls.insert1( - { - "device_type": info["device_type"], - "device_description": "", - }, - ) - cls.Stream.insert( - [ - { - "device_type": info["device_type"], - "stream_type": e, - } - for e in info["stream_type"] - ], - ) - @schema class Device(dj.Lookup): diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index b56f86eb..9a8f7cc7 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -31,7 +31,7 @@ def ingest_subject(colony_csv_path: pathlib.Path = _colony_csv_path) -> None: def ingest_streams(): - """Insert into stream.streamType table all streams in the dataset schema.""" + """Insert into streams.streamType table all streams in the dataset schema.""" from aeon.schema import dataset schemas = [v for v in dataset.__dict__.values() if isinstance(v, DotMap)] @@ -53,6 +53,54 @@ def ingest_streams(): streams.StreamType.insert(stream_entries, skip_duplicates=True) +def insert_devices(schema: DotMap, metadata_yml_filepath: Path): + """Use dataset.schema and metadata.yml to insert into streams.DeviceType and streams.Device. Only insert device types that were defined both in the device schema (e.g., exp02) and Metadata.yml.""" + device_info: dict[dict] = get_device_info(schema) + device_type_mapper, device_sn = get_device_mapper(schema, metadata_yml_filepath) + + # Add device type to device_info. + device_info = { + device_name: { + "device_type": device_type_mapper.get(device_name, None), + **device_info[device_name], + } + for device_name in device_info + } + # Return only a list of device types that have been inserted. + for device_name, info in device_info.items(): + + if info["device_type"]: + + streams.DeviceType.insert1( + { + "device_type": info["device_type"], + "device_description": "", + }, + skip_duplicates=True, + ) + streams.DeviceType.Stream.insert( + [ + { + "device_type": info["device_type"], + "stream_type": e, + } + for e in info["stream_type"] + ], + skip_duplicates=True, + ) + + if device_sn[device_name]: + if streams.Device & {"device_serial_number": device_sn[device_name]}: + continue + streams.Device.insert1( + { + "device_serial_number": device_sn[device_name], + "device_type": info["device_type"], + }, + skip_duplicates=True, + ) + + def extract_epoch_config(experiment_name: str, metadata_yml_filepath: str) -> dict: """Parse experiment metadata YAML file and extract epoch configuration. @@ -526,6 +574,8 @@ def get_device_mapper(schema: DotMap, metadata_yml_filepath: Path): Returns: device_type_mapper (dict): {"device_name", "device_type"} e.g. {'CameraTop': 'VideoSource', 'Patch1': 'Patch'} + device_sn (dict): {"device_name", "serial_number"} + e.g. {'CameraTop': '21053810'} """ import os @@ -549,7 +599,8 @@ def get_device_mapper(schema: DotMap, metadata_yml_filepath: Path): repository_root + "/aeon/dj_pipeline/create_experiments/device_type_mapper.json" ) - device_type_mapper = {} + device_type_mapper = {} # {device_name: device_type} + device_sn = {} # device serial number if filename.is_file(): with filename.open("r") as f: @@ -558,12 +609,15 @@ def get_device_mapper(schema: DotMap, metadata_yml_filepath: Path): try: # if the device type is not in the mapper, add it for item in meta_data.Devices: device_type_mapper[item.Name] = item.Type + device_sn[item.Name] = ( + item.SerialNumber if not isinstance(item.SerialNumber, DotMap) else None + ) with filename.open("w") as f: json.dump(device_type_mapper, f) except AttributeError: pass - return device_type_mapper + return device_type_mapper, device_sn def get_stream_entries(schema: DotMap) -> list[dict]: From 1b073393f52f96931e290d2661cab795c5608819 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 12 Apr 2023 17:06:45 +0000 Subject: [PATCH 216/489] fix bugs in device_info and simplify --- aeon/dj_pipeline/utils/load_metadata.py | 197 ++++++++++++------------ 1 file changed, 99 insertions(+), 98 deletions(-) diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 9a8f7cc7..92517c7d 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -1,10 +1,12 @@ import datetime +import inspect import json import pathlib import re from collections import defaultdict from pathlib import Path +import datajoint as dj import numpy as np import pandas as pd from dotmap import DotMap @@ -53,7 +55,7 @@ def ingest_streams(): streams.StreamType.insert(stream_entries, skip_duplicates=True) -def insert_devices(schema: DotMap, metadata_yml_filepath: Path): +def ingest_devices(schema: DotMap, metadata_yml_filepath: Path): """Use dataset.schema and metadata.yml to insert into streams.DeviceType and streams.Device. Only insert device types that were defined both in the device schema (e.g., exp02) and Metadata.yml.""" device_info: dict[dict] = get_device_info(schema) device_type_mapper, device_sn = get_device_mapper(schema, metadata_yml_filepath) @@ -89,16 +91,17 @@ def insert_devices(schema: DotMap, metadata_yml_filepath: Path): skip_duplicates=True, ) - if device_sn[device_name]: - if streams.Device & {"device_serial_number": device_sn[device_name]}: - continue - streams.Device.insert1( - { - "device_serial_number": device_sn[device_name], - "device_type": info["device_type"], - }, - skip_duplicates=True, - ) + if streams.Device & {"device_serial_number": device_sn[device_name]}: + continue + + streams.Device.insert1( + { + "device_serial_number": device_sn[device_name] + or device_name, #! insert device name if not exists + "device_type": info["device_type"], + }, + skip_duplicates=True, + ) def extract_epoch_config(experiment_name: str, metadata_yml_filepath: str) -> dict: @@ -478,6 +481,40 @@ def ingest_epoch_metadata_octagon(experiment_name, metadata_yml_filepath): # region Get stream & device information +def get_stream_entries(schema: DotMap) -> list[dict]: + """Returns a list of dictionaries containing the stream entries for a given device, + + Args: + schema (DotMap): DotMap object (e.g., exp02, octagon01) + + Returns: + stream_info (list[dict]): list of dictionaries containing the stream entries for a given device, + + e.g. {'stream_type': 'EnvironmentState', + 'stream_reader': aeon.io.reader.Csv, + 'stream_reader_kwargs': {'pattern': '{pattern}_EnvironmentState', + 'columns': ['state'], + 'extension': 'csv', + 'dtype': None} + """ + device_info = get_device_info(schema) + return [ + { + "stream_type": stream_type, + "stream_reader": stream_reader, + "stream_reader_kwargs": stream_reader_kwargs, + "stream_hash": stream_hash, + } + for stream_info in device_info.values() + for stream_type, stream_reader, stream_reader_kwargs, stream_hash in zip( + stream_info["stream_type"], + stream_info["stream_reader"], + stream_info["stream_reader_kwargs"], + stream_info["stream_hash"], + ) + ] + + def get_device_info(schema: DotMap) -> dict[dict]: """ Read from the above DotMap object and returns a device dictionary as the following. @@ -499,69 +536,67 @@ def get_device_info(schema: DotMap) -> dict[dict]: } } """ + + def _get_class_path(obj): + return f"{obj.__class__.__module__}.{obj.__class__.__name__}" + schema_json = json.dumps(schema, default=lambda x: x.__dict__, indent=4) schema_dict = json.loads(schema_json) - device_info = {} - for device_name in schema: - if not device_name.startswith("_"): - device_info[device_name] = defaultdict(list) - if isinstance(schema[device_name], DotMap): - for stream_type in schema[device_name].keys(): - if schema[device_name][stream_type].__class__.__module__ in [ - "aeon.io.reader", - "aeon.schema.foraging", - "aeon.schema.octagon", - ]: - device_info[device_name]["stream_type"].append(stream_type) - device_info[device_name]["stream_reader"].append( - schema[device_name][stream_type].__class__ - ) - else: - stream_type = schema[device_name].__class__.__name__ - device_info[device_name]["stream_type"].append(stream_type) - device_info[device_name]["stream_reader"].append( - schema[device_name].__class__ - ) + for device_name, device in schema.items(): + if device_name.startswith("_"): + continue + + device_info[device_name] = defaultdict(list) + + if isinstance(device, DotMap): + for stream_type, stream_obj in device.items(): + if stream_obj.__class__.__module__ in [ + "aeon.io.reader", + "aeon.schema.foraging", + "aeon.schema.octagon", + ]: + device_info[device_name]["stream_type"].append(stream_type) + device_info[device_name]["stream_reader"].append( + _get_class_path(stream_obj) + ) + + required_args = [ + k + for k in inspect.signature(stream_obj.__init__).parameters + if k != "self" + ] + pattern = schema_dict[device_name][stream_type].get("pattern") + schema_dict[device_name][stream_type]["pattern"] = pattern.replace( + device_name, "{pattern}" + ) - """Add a kwargs such as pattern, columns, extension, dtype and hash - e.g., {'pattern': '{pattern}_SubjectState', - 'columns': ['id', 'weight', 'event'], - 'extension': 'csv', - 'dtype': None}""" - for device_name in device_info: - if pattern := schema_dict[device_name].get("pattern"): + kwargs = { + k: v + for k, v in schema_dict[device_name][stream_type].items() + if k in required_args + } + device_info[device_name]["stream_reader_kwargs"].append(kwargs) + else: + stream_type = device.__class__.__name__ + device_info[device_name]["stream_type"].append(stream_type) + device_info[device_name]["stream_reader"].append(_get_class_path(device)) + + required_args = { + k: None + for k in inspect.signature(device.__init__).parameters + if k != "self" + } + pattern = schema_dict[device_name].get("pattern") schema_dict[device_name]["pattern"] = pattern.replace( device_name, "{pattern}" ) - # Add stream_reader_kwargs - kwargs = schema_dict[device_name] + kwargs = { + k: v for k, v in schema_dict[device_name].items() if k in required_args + } device_info[device_name]["stream_reader_kwargs"].append(kwargs) - stream_reader = device_info[device_name]["stream_reader"] - # Add hash - device_info[device_name]["stream_hash"].append( - dict_to_uuid({**kwargs, "stream_reader": stream_reader}) - ) - - else: - for stream_type in device_info[device_name]["stream_type"]: - pattern = schema_dict[device_name][stream_type]["pattern"] - schema_dict[device_name][stream_type]["pattern"] = pattern.replace( - device_name, "{pattern}" - ) - # Add stream_reader_kwargs - kwargs = schema_dict[device_name][stream_type] - device_info[device_name]["stream_reader_kwargs"].append(kwargs) - stream_ind = device_info[device_name]["stream_type"].index(stream_type) - stream_reader = device_info[device_name]["stream_reader"][stream_ind] - # Add hash - device_info[device_name]["stream_hash"].append( - dict_to_uuid({**kwargs, "stream_reader": stream_reader}) - ) - - return device_info def get_device_mapper(schema: DotMap, metadata_yml_filepath: Path): @@ -620,38 +655,4 @@ def get_device_mapper(schema: DotMap, metadata_yml_filepath: Path): return device_type_mapper, device_sn -def get_stream_entries(schema: DotMap) -> list[dict]: - """Returns a list of dictionaries containing the stream entries for a given device, - - Args: - schema (DotMap): DotMap object (e.g., exp02, octagon01) - - Returns: - stream_info (list[dict]): list of dictionaries containing the stream entries for a given device, - - e.g. {'stream_type': 'EnvironmentState', - 'stream_reader': aeon.io.reader.Csv, - 'stream_reader_kwargs': {'pattern': '{pattern}_EnvironmentState', - 'columns': ['state'], - 'extension': 'csv', - 'dtype': None} - """ - device_info = get_device_info(schema) - return [ - { - "stream_type": stream_type, - "stream_reader": stream_reader, - "stream_reader_kwargs": stream_reader_kwargs, - "stream_hash": stream_hash, - } - for stream_info in device_info.values() - for stream_type, stream_reader, stream_reader_kwargs, stream_hash in zip( - stream_info["stream_type"], - stream_info["stream_reader"], - stream_info["stream_reader_kwargs"], - stream_info["stream_hash"], - ) - ] - - # endregion From b479e92850b67f341f75555628928d0e0ff522c0 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 12 Apr 2023 17:08:09 +0000 Subject: [PATCH 217/489] fix table generation --- aeon/dj_pipeline/streams.py | 47 ++++++++++++++----------------------- 1 file changed, 18 insertions(+), 29 deletions(-) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index 1886b22f..809c84ce 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -15,18 +15,11 @@ logger = dj.logger -schema_name = f'u_{dj.config["database.user"]}_streams' # for testing -# schema_name = get_schema_name("streams") +# schema_name = f'u_{dj.config["database.user"]}_streams' # for testing +schema_name = get_schema_name("streams") schema = dj.schema(schema_name) -__all__ = [ - "StreamType", - "DeviceType", - "Device", -] - - @schema class StreamType(dj.Lookup): """ @@ -75,7 +68,7 @@ class Device(dj.Lookup): """ -## --------- Helper functions & classes --------- ## +# region Helper functions for creating device tables. def get_device_template(device_type): @@ -208,8 +201,8 @@ def make(self, key): class DeviceTableManager: + """Class for managing device tables""" - # TODO: Simplify this class. def __init__(self, context=None): if context is None: @@ -218,21 +211,13 @@ def __init__(self, context=None): self.context = context self._schema = dj.schema(context=self.context) - self._device_tables = [] self._device_stream_tables = [] - self._device_types = DeviceType.fetch("device_type") self._device_stream_map = defaultdict( list ) # dictionary for showing hierarchical relationship between device type and stream type - def _add_device_tables(self): - for device_type in self._device_types: - table_name = f"Experiment{device_type}" - if table_name not in self._device_tables: - self._device_tables.append(table_name) - def _add_device_stream_tables(self): - for device_type in self._device_types: + for device_type in self.device_tables: for stream_type in ( StreamType & (DeviceType.Stream & {"device_type": device_type}) ).fetch("stream_type"): @@ -243,18 +228,13 @@ def _add_device_stream_tables(self): self._device_stream_map[device_type].append(stream_type) - @property - def device_types(self): - return self._device_types - @cached_property def device_tables(self) -> list: """ Name of the device tables to be created """ - self._add_device_tables() - return self._device_tables + return list(DeviceType.fetch("device_type")) @cached_property def device_stream_tables(self) -> list: @@ -271,9 +251,7 @@ def device_stream_map(self) -> dict: def create_device_tables(self): - for device_table in self.device_tables: - - device_type = re.sub(r"\bExperiment", "", device_table) + for device_type in self.device_tables: table_class = get_device_template(device_type) @@ -294,3 +272,14 @@ def create_device_stream_tables(self): self._schema(table_class, context=self.context) self._schema.activate(schema_name) + + +# endregion + + +if __name__ == "__main__": + + # Create device & device stream tables + tbmg = DeviceTableManager(context=inspect.currentframe().f_back.f_locals) + tbmg.create_device_tables() + tbmg.create_device_stream_tables() From 1b980f279c1630ad7f187acbb6d12cd59e30cc03 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Thu, 13 Apr 2023 22:40:13 +0000 Subject: [PATCH 218/489] add back stream uuid --- aeon/dj_pipeline/utils/load_metadata.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 92517c7d..52dd4604 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -578,6 +578,12 @@ def _get_class_path(obj): if k in required_args } device_info[device_name]["stream_reader_kwargs"].append(kwargs) + # Add hash + device_info[device_name]["stream_hash"].append( + dict_to_uuid( + {**kwargs, "stream_reader": _get_class_path(stream_obj)} + ) + ) else: stream_type = device.__class__.__name__ device_info[device_name]["stream_type"].append(stream_type) @@ -597,6 +603,11 @@ def _get_class_path(obj): k: v for k, v in schema_dict[device_name].items() if k in required_args } device_info[device_name]["stream_reader_kwargs"].append(kwargs) + # Add hash + device_info[device_name]["stream_hash"].append( + dict_to_uuid({**kwargs, "stream_reader": _get_class_path(device)}) + ) + return device_info def get_device_mapper(schema: DotMap, metadata_yml_filepath: Path): From edd27c8005047ce5bb35d90c6324e2415faeafb3 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 14 Apr 2023 01:06:57 +0000 Subject: [PATCH 219/489] remove DeviceTableManager from streams.py --- aeon/dj_pipeline/streams.py | 97 ++----------------------------------- 1 file changed, 4 insertions(+), 93 deletions(-) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index 809c84ce..37f4596e 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -1,15 +1,8 @@ -import inspect -import re -from collections import defaultdict -from functools import cached_property -from pathlib import Path - import datajoint as dj import pandas as pd -from dotmap import DotMap import aeon -from aeon.dj_pipeline import acquisition, dict_to_uuid, get_schema_name +from aeon.dj_pipeline import acquisition, get_schema_name from aeon.io import api as io_api logger = dj.logger @@ -71,7 +64,7 @@ class Device(dj.Lookup): # region Helper functions for creating device tables. -def get_device_template(device_type): +def get_device_template(device_type: str): """Returns table class template for ExperimentDevice""" device_title = device_type device_type = dj.utils.from_camel_case(device_type) @@ -80,7 +73,7 @@ class ExperimentDevice(dj.Manual): definition = f""" # {device_title} placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-{aeon.__version__}) -> acquisition.Experiment - -> Device + -> streams.Device {device_type}_install_time: datetime(6) # time of the {device_type} placed and started operation at this position --- {device_type}_name: varchar(36) @@ -106,7 +99,7 @@ class RemovalTime(dj.Part): return ExperimentDevice -def get_device_stream_template(device_type, stream_type): +def get_device_stream_template(device_type: str, stream_type: str): """Returns table class template for DeviceDataStream""" ExperimentDevice = get_device_template(device_type) @@ -200,86 +193,4 @@ def make(self, key): return DeviceDataStream -class DeviceTableManager: - """Class for managing device tables""" - - def __init__(self, context=None): - - if context is None: - self.context = inspect.currentframe().f_back.f_locals - else: - self.context = context - - self._schema = dj.schema(context=self.context) - self._device_stream_tables = [] - self._device_stream_map = defaultdict( - list - ) # dictionary for showing hierarchical relationship between device type and stream type - - def _add_device_stream_tables(self): - for device_type in self.device_tables: - for stream_type in ( - StreamType & (DeviceType.Stream & {"device_type": device_type}) - ).fetch("stream_type"): - - table_name = f"{device_type}{stream_type}" - if table_name not in self._device_stream_tables: - self._device_stream_tables.append(table_name) - - self._device_stream_map[device_type].append(stream_type) - - @cached_property - def device_tables(self) -> list: - """ - Name of the device tables to be created - """ - - return list(DeviceType.fetch("device_type")) - - @cached_property - def device_stream_tables(self) -> list: - """ - Name of the device stream tables to be created - """ - self._add_device_stream_tables() - return self._device_stream_tables - - @cached_property - def device_stream_map(self) -> dict: - self._add_device_stream_tables() - return self._device_stream_map - - def create_device_tables(self): - - for device_type in self.device_tables: - - table_class = get_device_template(device_type) - - self.context[table_class.__name__] = table_class - self._schema(table_class, context=self.context) - - self._schema.activate(schema_name) - - def create_device_stream_tables(self): - - for device_type in self.device_stream_map: - - for stream_type in self.device_stream_map[device_type]: - - table_class = get_device_stream_template(device_type, stream_type) - - self.context[table_class.__name__] = table_class - self._schema(table_class, context=self.context) - - self._schema.activate(schema_name) - - # endregion - - -if __name__ == "__main__": - - # Create device & device stream tables - tbmg = DeviceTableManager(context=inspect.currentframe().f_back.f_locals) - tbmg.create_device_tables() - tbmg.create_device_stream_tables() From 67d6b0c964ead3e3c1ce23d162cdc5ee756c5d07 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 14 Apr 2023 01:25:36 +0000 Subject: [PATCH 220/489] create new device tables in ingest_devices --- aeon/dj_pipeline/utils/load_metadata.py | 113 +++++++++++++++++------- 1 file changed, 79 insertions(+), 34 deletions(-) diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 52dd4604..bcf97270 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -11,7 +11,14 @@ import pandas as pd from dotmap import DotMap -from aeon.dj_pipeline import acquisition, dict_to_uuid, lab, streams, subject +from aeon.dj_pipeline import ( + acquisition, + dict_to_uuid, + get_schema_name, + lab, + streams, + subject, +) from aeon.io import api as io_api _weight_scale_rate = 100 @@ -56,52 +63,90 @@ def ingest_streams(): def ingest_devices(schema: DotMap, metadata_yml_filepath: Path): - """Use dataset.schema and metadata.yml to insert into streams.DeviceType and streams.Device. Only insert device types that were defined both in the device schema (e.g., exp02) and Metadata.yml.""" + """Use dataset.schema and metadata.yml to insert into streams.DeviceType and streams.Device. Only insert device types that were defined both in the device schema (e.g., exp02) and Metadata.yml. It then creates new device tables under streams schema.""" device_info: dict[dict] = get_device_info(schema) device_type_mapper, device_sn = get_device_mapper(schema, metadata_yml_filepath) - # Add device type to device_info. + # Add device type to device_info. Only add if device types that are defined in Metadata.yml device_info = { device_name: { - "device_type": device_type_mapper.get(device_name, None), + "device_type": device_type_mapper.get(device_name), **device_info[device_name], } for device_name in device_info + if device_type_mapper.get(device_name) } - # Return only a list of device types that have been inserted. - for device_name, info in device_info.items(): - if info["device_type"]: + # Create a map of device_type to stream_type. + device_stream_map: dict[list] = {} - streams.DeviceType.insert1( - { - "device_type": info["device_type"], - "device_description": "", - }, - skip_duplicates=True, - ) - streams.DeviceType.Stream.insert( - [ - { - "device_type": info["device_type"], - "stream_type": e, - } - for e in info["stream_type"] - ], - skip_duplicates=True, - ) + for device_config in device_info.values(): + device_type = device_config["device_type"] + stream_types = device_config["stream_type"] - if streams.Device & {"device_serial_number": device_sn[device_name]}: - continue + if device_type not in device_stream_map: + device_stream_map[device_type] = [] - streams.Device.insert1( - { - "device_serial_number": device_sn[device_name] - or device_name, #! insert device name if not exists - "device_type": info["device_type"], - }, - skip_duplicates=True, - ) + for stream_type in stream_types: + if stream_type not in device_stream_map[device_type]: + device_stream_map[device_type].append(stream_type) + + # List only new device & stream types that need to be inserted & created. + new_device_types = [ + {"device_type": device_type} + for device_type in device_stream_map.keys() + if not streams.DeviceType & {"device_type": device_type} + ] + + new_device_stream_types = [ + {"device_type": device_type, "stream_type": stream_type} + for device_type, stream_list in device_stream_map.items() + for stream_type in stream_list + if not streams.DeviceType.Stream + & {"device_type": device_type, "stream_type": stream_type} + ] + + new_devices = [ + { + "device_serial_number": device_sn[device_name], + "device_type": device_config["device_type"], + } + for device_name, device_config in device_info.items() + if device_sn[device_name] + and not streams.Device & {"device_serial_number": device_sn[device_name]} + ] + + # Insert new entries. + if new_device_types: + streams.DeviceType.insert(new_device_types) + + if new_device_stream_types: + streams.DeviceType.Stream.insert(new_device_stream_types) + + if new_devices: + streams.Device.insert(new_devices) + + # Create tables. + context = inspect.currentframe().f_back.f_locals + + for device_info in new_device_types: + table_class = streams.get_device_template(device_info["device_type"]) + context[table_class.__name__] = table_class + streams.schema(table_class, context=context) + + # Create device_type tables + for device_info in new_device_stream_types: + table_class = streams.get_device_stream_template( + device_info["device_type"], device_info["stream_type"] + ) + context[table_class.__name__] = table_class + streams.schema(table_class, context=context) + + streams.schema.activate(streams.schema_name, add_objects=context) + vm = dj.VirtualModule(streams.schema_name, streams.schema_name) + for k, v in vm.__dict__.items(): + if "Table" in str(v.__class__): + streams.__dict__[k] = v def extract_epoch_config(experiment_name: str, metadata_yml_filepath: str) -> dict: From b820b0fcaf087fa27840519f89626613709c91de Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 14 Apr 2023 19:23:38 +0000 Subject: [PATCH 221/489] add new device ingestion routine in load_metadata.py --- aeon/dj_pipeline/utils/load_metadata.py | 424 ++++++++---------------- 1 file changed, 141 insertions(+), 283 deletions(-) diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index bcf97270..7197fa69 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -178,20 +178,22 @@ def extract_epoch_config(experiment_name: str, metadata_yml_filepath: str) -> di assert commit, f'Neither "Commit" nor "Revision" found in {metadata_yml_filepath}' - devices: dict[str, dict] = json.loads( + devices: list[dict] = json.loads( json.dumps( epoch_config["metadata"]["Devices"], default=lambda x: x.__dict__, indent=4 ) ) - # devices: dict = {d.pop("Name"): d for d in devices} # {deivce_name: device_config} + devices: dict = { + d.pop("Name"): d for d in devices + } # {deivce_name: device_config} #! may not work for presocial return { "experiment_name": experiment_name, "epoch_start": epoch_start, "bonsai_workflow": epoch_config["workflow"], "commit": commit, - "devices": devices, + "metadata": devices, #! this format might have changed since using aeon metadata reader "metadata_file_path": metadata_yml_filepath, } @@ -203,6 +205,7 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): + camera/patch location + patch, weightscale serial number """ + from aeon.dj_pipeline import streams if experiment_name.startswith("oct"): ingest_epoch_metadata_octagon(experiment_name, metadata_yml_filepath) @@ -224,305 +227,116 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): device_frequency_mapper = { name: float(value) - for name, value in epoch_config["devices"]["VideoController"].items() + for name, value in epoch_config["metadata"]["VideoController"].items() if name.endswith("Frequency") - } + } # May not be needed? - # ---- Load cameras ---- - camera_list, camera_installation_list, camera_removal_list, camera_position_list = ( - [], - [], - [], - [], - ) - # Check if this is a new camera, add to lab.Camera if needed - for device_name, device_config in epoch_config["devices"].items(): - if device_config["Type"] == "VideoSource": - camera_key = {"camera_serial_number": device_config["SerialNumber"]} - camera_list.append(camera_key) - - camera_installation = { - **camera_key, - "experiment_name": experiment_name, - "camera_install_time": epoch_config["epoch_start"], - "camera_description": device_name, - "camera_sampling_rate": device_frequency_mapper[ - device_config["TriggerFrequency"] - ], - "camera_gain": float(device_config["Gain"]), - "camera_bin": int(device_config["Binning"]), - } + # Insert into each device table + for device_name, device_config in epoch_config["metadata"].items(): + if table := getattr(streams, device_config["Type"], None): + device_sn = device_config.get("SerialNumber", device_config.get("PortName")) + device_key = {"device_serial_number": device_sn} - if "position" in device_config: - camera_position = { - **camera_key, + if not ( + table + & { "experiment_name": experiment_name, - "camera_install_time": epoch_config["epoch_start"], - "camera_position_x": device_config["position"]["x"], - "camera_position_y": device_config["position"]["y"], - "camera_position_z": device_config["position"]["z"], + "device_serial_number": device_sn, } - else: - camera_position = { - "camera_position_x": None, - "camera_position_y": None, - "camera_position_z": None, - "camera_rotation_x": None, - "camera_rotation_y": None, - "camera_rotation_z": None, - } - - """Check if this camera is currently installed. If the same camera serial number is currently installed check for any changes in configuration. If not, skip this""" - current_camera_query = ( - acquisition.ExperimentCamera - acquisition.ExperimentCamera.RemovalTime - & experiment_key - & camera_key - ) - - if current_camera_query: - current_camera_config = current_camera_query.join( - acquisition.ExperimentCamera.Position, left=True - ).fetch1() - - new_camera_config = {**camera_installation, **camera_position} - current_camera_config.pop("camera_install_time") - new_camera_config.pop("camera_install_time") - - if dict_to_uuid(current_camera_config) == dict_to_uuid( - new_camera_config - ): - continue - # Remove old camera - camera_removal_list.append( - { - **current_camera_query.fetch1("KEY"), - "camera_remove_time": epoch_config["epoch_start"], - } - ) - # Install new camera - camera_installation_list.append(camera_installation) - - if "position" in device_config: - camera_position_list.append(camera_position) - # Remove the currently installed cameras that are absent in this config - camera_removal_list.extend( - ( - acquisition.ExperimentCamera - - acquisition.ExperimentCamera.RemovalTime - - camera_list - & experiment_key - ).fetch("KEY") - ) + ): - # ---- Load food patches ---- - patch_list, patch_installation_list, patch_removal_list, patch_position_list = ( - [], - [], - [], - [], - ) - - # Check if this is a new food patch, add to lab.FoodPatch if needed - for device_name, device_config in epoch_config["devices"].items(): - if device_config["Type"] == "Patch": - - patch_key = { - "food_patch_serial_number": device_config.get( - "SerialNumber", device_config.get("PortName") - ) - } - patch_list.append(patch_key) - patch_installation = { - **patch_key, - "experiment_name": experiment_name, - "food_patch_install_time": epoch_config["epoch_start"], - "food_patch_description": device_config["Name"], - "wheel_sampling_rate": float( - re.search(r"\d+", device_config["SampleRate"]).group() - ), - "wheel_radius": float(device_config["Radius"]), - } - if "position" in device_config: - patch_position = { - **patch_key, + table_entry = { "experiment_name": experiment_name, - "food_patch_install_time": epoch_config["epoch_start"], - "food_patch_position_x": device_config["position"]["x"], - "food_patch_position_y": device_config["position"]["y"], - "food_patch_position_z": device_config["position"]["z"], - } - else: - patch_position = { - "food_patch_position_x": None, - "food_patch_position_y": None, - "food_patch_position_z": None, + "device_serial_number": device_sn, + f"{dj.utils.from_camel_case(table.__name__)}_install_time": epoch_config[ + "epoch_start" + ], + f"{dj.utils.from_camel_case(table.__name__)}_name": device_name, } - """Check if this camera is currently installed. If the same camera serial number is currently installed, check for any changes in configuration, if not, skip this""" - current_patch_query = ( - acquisition.ExperimentFoodPatch - - acquisition.ExperimentFoodPatch.RemovalTime - & experiment_key - & patch_key - ) - if current_patch_query: - current_patch_config = current_patch_query.join( - acquisition.ExperimentFoodPatch.Position, left=True - ).fetch1() - new_patch_config = {**patch_installation, **patch_position} - current_patch_config.pop("food_patch_install_time") - new_patch_config.pop("food_patch_install_time") - if dict_to_uuid(current_patch_config) == dict_to_uuid(new_patch_config): - continue - # Remove old food patch - patch_removal_list.append( + table_attribute_entry = [ { - **current_patch_query.fetch1("KEY"), - "food_patch_remove_time": epoch_config["epoch_start"], + "experiment_name": experiment_name, + "device_serial_number": device_sn, + f"{dj.utils.from_camel_case(table.__name__)}_install_time": epoch_config[ + "epoch_start" + ], + f"{dj.utils.from_camel_case(table.__name__)}_name": device_name, + "attribute_name": attribute_name, + "attribute_value": attribute_value, } - ) - # Install new food patch - patch_installation_list.append(patch_installation) - if "position" in device_config: - patch_position_list.append(patch_position) - # Remove the currently installed patches that are absent in this config - patch_removal_list.extend( - ( - acquisition.ExperimentFoodPatch - - acquisition.ExperimentFoodPatch.RemovalTime - - patch_list - & experiment_key - ).fetch("KEY") - ) - - # ---- Load weight scales ---- - weight_scale_list, weight_scale_installation_list, weight_scale_removal_list = ( - [], - [], - [], - ) + for attribute_name, attribute_value in device_config.items() + ] - # Check if this is a new weight scale, add to lab.WeightScale if needed - for device_name, device_config in epoch_config["devices"].items(): - if device_config["Type"] == "WeightScale": - - weight_scale_key = { - "weight_scale_serial_number": device_config.get( - "SerialNumber", device_config.get("PortName") + """Check if this camera is currently installed. If the same camera serial number is currently installed check for any changes in configuration. If not, skip this""" + current_device_query = ( + table.Attribute - table.RemovalTime & experiment_key & device_key ) - } - weight_scale_list.append(weight_scale_key) - arena_key = (lab.Arena & acquisition.Experiment & experiment_key).fetch1( - "KEY" - ) - weight_scale_installation = { - **weight_scale_key, - **arena_key, - "experiment_name": experiment_name, - "weight_scale_install_time": epoch_config["epoch_start"], - "nest": _weight_scale_nest, - "weight_scale_description": device_name, - "weight_scale_sampling_rate": float(_weight_scale_rate), - } - - # Check if this weight scale is currently installed - if so, remove it - current_weight_scale_query = ( - acquisition.ExperimentWeightScale - - acquisition.ExperimentWeightScale.RemovalTime - & experiment_key - & weight_scale_key - ) - if current_weight_scale_query: - current_weight_scale_config = current_weight_scale_query.fetch1() - new_weight_scale_config = weight_scale_installation.copy() - current_weight_scale_config.pop("weight_scale_install_time") - new_weight_scale_config.pop("weight_scale_install_time") - if dict_to_uuid(current_weight_scale_config) == dict_to_uuid( - new_weight_scale_config - ): - continue - # Remove old weight scale - weight_scale_removal_list.append( - { - **current_weight_scale_query.fetch1("KEY"), - "weight_scale_remove_time": epoch_config["epoch_start"], - } - ) - # Install new weight scale - weight_scale_installation_list.append(weight_scale_installation) - # Remove the currently installed weight scales that are absent in this config - weight_scale_removal_list.extend( - ( - acquisition.ExperimentWeightScale - - acquisition.ExperimentWeightScale.RemovalTime - - weight_scale_list - & experiment_key - ).fetch("KEY") - ) - - # Insert - def insert(): - lab.Camera.insert(camera_list, skip_duplicates=True) - acquisition.ExperimentCamera.RemovalTime.insert(camera_removal_list) - acquisition.ExperimentCamera.insert(camera_installation_list) - acquisition.ExperimentCamera.Position.insert(camera_position_list) - lab.FoodPatch.insert(patch_list, skip_duplicates=True) - acquisition.ExperimentFoodPatch.RemovalTime.insert(patch_removal_list) - acquisition.ExperimentFoodPatch.insert(patch_installation_list) - acquisition.ExperimentFoodPatch.Position.insert(patch_position_list) - lab.WeightScale.insert(weight_scale_list, skip_duplicates=True) - acquisition.ExperimentWeightScale.RemovalTime.insert(weight_scale_removal_list) - acquisition.ExperimentWeightScale.insert(weight_scale_installation_list) - - if acquisition.Experiment.connection.in_transaction: - insert() - else: - with acquisition.Experiment.connection.transaction: - insert() + if current_device_query: + current_device_config: list[dict] = current_device_query.fetch( + "experiment_name", + "device_serial_number", + "attribute_name", + "attribute_value", + as_dict=True, + ) + new_device_config: list[dict] = [ + { + k: v + for k, v in entry.items() + if k + != f"{dj.utils.from_camel_case(table.__name__)}_install_time" + } + for entry in table_attribute_entry + ] -def ingest_epoch_metadata_octagon(experiment_name, metadata_yml_filepath): - """ - Temporary ingestion routine to load devices' meta information for Octagon arena experiments - """ - from aeon.dj_pipeline import streams + if dict_to_uuid(current_device_config) == dict_to_uuid( + new_device_config + ): # Skip if none of the configuration has changed. + continue + + # Remove old device + table_removal_entry = [ + { + **entry, + f"{dj.utils.from_camel_case(table.__name__)}_removal_time": epoch_config[ + "epoch_start" + ], + } + for entry in current_device_config + ] - oct01_devices = [ - ("Metadata", "Metadata"), - ("CameraTop", "Camera"), - ("CameraColorTop", "Camera"), - ("ExperimentalMetadata", "ExperimentalMetadata"), - ("Photodiode", "Photodiode"), - ("OSC", "OSC"), - ("TaskLogic", "TaskLogic"), - ("Wall1", "Wall"), - ("Wall2", "Wall"), - ("Wall3", "Wall"), - ("Wall4", "Wall"), - ("Wall5", "Wall"), - ("Wall6", "Wall"), - ("Wall7", "Wall"), - ("Wall8", "Wall"), - ] + # Insert into table. + with table.connection.in_transaction: + table.insert1(table_entry) + table.Attribute.insert(table_attribute_entry) + table.RemovalTime.insert(table_removal_entry) - epoch_start = datetime.datetime.strptime( - metadata_yml_filepath.parent.name, "%Y-%m-%dT%H-%M-%S" + # Remove the currently installed devices that are absent in this config + device_removal_list.extend( + (table - table.RemovalTime - device_list & experiment_key).fetch("KEY") ) - for device_idx, (device_name, device_type) in enumerate(oct01_devices): - device_sn = f"oct01_{device_idx}" - streams.Device.insert1( - {"device_serial_number": device_sn, "device_type": device_type}, - skip_duplicates=True, - ) - experiment_table = getattr(streams, f"Experiment{device_type}") - if not ( - experiment_table - & {"experiment_name": experiment_name, "device_serial_number": device_sn} - ): - experiment_table.insert1( - (experiment_name, device_sn, epoch_start, device_name) - ) + # Insert + # def insert(): + # lab.Camera.insert(camera_list, skip_duplicates=True) + # acquisition.ExperimentCamera.RemovalTime.insert(camera_removal_list) + # acquisition.ExperimentCamera.insert(camera_installation_list) + # acquisition.ExperimentCamera.Position.insert(camera_position_list) + # lab.FoodPatch.insert(patch_list, skip_duplicates=True) + # acquisition.ExperimentFoodPatch.RemovalTime.insert(patch_removal_list) + # acquisition.ExperimentFoodPatch.insert(patch_installation_list) + # acquisition.ExperimentFoodPatch.Position.insert(patch_position_list) + # lab.WeightScale.insert(weight_scale_list, skip_duplicates=True) + # acquisition.ExperimentWeightScale.RemovalTime.insert(weight_scale_removal_list) + # acquisition.ExperimentWeightScale.insert(weight_scale_installation_list) + + # if acquisition.Experiment.connection.in_transaction: + # insert() + # else: + # with acquisition.Experiment.connection.transaction: + # insert() # region Get stream & device information @@ -711,4 +525,48 @@ def get_device_mapper(schema: DotMap, metadata_yml_filepath: Path): return device_type_mapper, device_sn +def ingest_epoch_metadata_octagon(experiment_name, metadata_yml_filepath): + """ + Temporary ingestion routine to load devices' meta information for Octagon arena experiments + """ + from aeon.dj_pipeline import streams + + oct01_devices = [ + ("Metadata", "Metadata"), + ("CameraTop", "Camera"), + ("CameraColorTop", "Camera"), + ("ExperimentalMetadata", "ExperimentalMetadata"), + ("Photodiode", "Photodiode"), + ("OSC", "OSC"), + ("TaskLogic", "TaskLogic"), + ("Wall1", "Wall"), + ("Wall2", "Wall"), + ("Wall3", "Wall"), + ("Wall4", "Wall"), + ("Wall5", "Wall"), + ("Wall6", "Wall"), + ("Wall7", "Wall"), + ("Wall8", "Wall"), + ] + + epoch_start = datetime.datetime.strptime( + metadata_yml_filepath.parent.name, "%Y-%m-%dT%H-%M-%S" + ) + + for device_idx, (device_name, device_type) in enumerate(oct01_devices): + device_sn = f"oct01_{device_idx}" + streams.Device.insert1( + {"device_serial_number": device_sn, "device_type": device_type}, + skip_duplicates=True, + ) + experiment_table = getattr(streams, f"Experiment{device_type}") + if not ( + experiment_table + & {"experiment_name": experiment_name, "device_serial_number": device_sn} + ): + experiment_table.insert1( + (experiment_name, device_sn, epoch_start, device_name) + ) + + # endregion From 62fe7ee7ae33787c8b73ac5e0f2a31dc70d9878a Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 14 Apr 2023 19:24:02 +0000 Subject: [PATCH 222/489] add device_type_mapper.json --- aeon/dj_pipeline/create_experiments/device_type_mapper.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 aeon/dj_pipeline/create_experiments/device_type_mapper.json diff --git a/aeon/dj_pipeline/create_experiments/device_type_mapper.json b/aeon/dj_pipeline/create_experiments/device_type_mapper.json new file mode 100644 index 00000000..a802f72a --- /dev/null +++ b/aeon/dj_pipeline/create_experiments/device_type_mapper.json @@ -0,0 +1 @@ +{"VideoController": "VideoController", "CameraTop": "VideoSource", "CameraWest": "VideoSource", "CameraEast": "VideoSource", "CameraNorth": "VideoSource", "CameraSouth": "VideoSource", "CameraPatch1": "VideoSource", "CameraPatch2": "VideoSource", "CameraNest": "VideoSource", "AudioAmbient": "AudioSource", "Patch1": "Patch", "Patch2": "Patch", "WeightNest": "WeightScale", "TrackingTop": "PositionTracking", "ActivityCenter": "ActivityTracking", "ActivityArena": "ActivityTracking", "ActivityNest": "ActivityTracking", "ActivityPatch1": "ActivityTracking", "ActivityPatch2": "ActivityTracking", "InNest": "RegionTracking", "InPatch1": "RegionTracking", "InPatch2": "RegionTracking", "ArenaCenter": "DistanceFromPoint", "InArena": "InRange", "InCorridor": "InRange"} \ No newline at end of file From b40edfdf98818a7529b21a656126e5d557a3cb46 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 14 Apr 2023 19:25:33 +0000 Subject: [PATCH 223/489] change attribute name to removal time --- aeon/dj_pipeline/streams.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index 37f4596e..a9bedb31 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -74,24 +74,24 @@ class ExperimentDevice(dj.Manual): # {device_title} placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-{aeon.__version__}) -> acquisition.Experiment -> streams.Device - {device_type}_install_time: datetime(6) # time of the {device_type} placed and started operation at this position + {device_type}_install_time : datetime(6) # time of the {device_type} placed and started operation at this position --- - {device_type}_name: varchar(36) + {device_type}_name : varchar(36) """ class Attribute(dj.Part): definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device -> master - attribute_name : varchar(32) + attribute_name : varchar(32) --- - attribute_value='': varchar(2000) + attribute_value=null : longblob """ class RemovalTime(dj.Part): definition = f""" -> master --- - {device_type}_remove_time: datetime(6) # time of the {device_type} being removed + {device_type}_removal_time: datetime(6) # time of the {device_type} being removed """ ExperimentDevice.__name__ = f"{device_title}" From fd1835683df2203c0531aeddf7e02175331f2ea2 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 14 Apr 2023 19:27:07 +0000 Subject: [PATCH 224/489] add ingest_device function into ingest_epochs --- aeon/dj_pipeline/acquisition.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index f28c1ef4..cdd10d0a 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -12,7 +12,11 @@ from . import get_schema_name, lab, subject from .utils import paths -from .utils.load_metadata import extract_epoch_config, ingest_epoch_metadata +from .utils.load_metadata import ( + extract_epoch_config, + ingest_devices, + ingest_epoch_metadata, +) logger = dj.logger schema = dj.schema(get_schema_name("acquisition")) @@ -348,6 +352,13 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): cls.insert1(epoch_key) if epoch_config: cls.Config.insert1(epoch_config) + + # Ingest streams.DeviceType, streams.Device and create device tables. + ingest_devices( + _device_schema_mapping[epoch_key["experiment_name"]], + metadata_yml_filepath, + ) + ingest_epoch_metadata(experiment_name, metadata_yml_filepath) epoch_list.append(epoch_key) # update previous epoch From 21988035b8ac3355db38986f11970be598e5acec Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 17 Apr 2023 15:23:22 +0000 Subject: [PATCH 225/489] use PortName if SerialNumber doesn't exist --- aeon/dj_pipeline/utils/load_metadata.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 7197fa69..c8dedc18 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -515,8 +515,9 @@ def get_device_mapper(schema: DotMap, metadata_yml_filepath: Path): for item in meta_data.Devices: device_type_mapper[item.Name] = item.Type device_sn[item.Name] = ( - item.SerialNumber if not isinstance(item.SerialNumber, DotMap) else None - ) + item.SerialNumber or item.PortName or None + ) # assign either the serial number (if it exists) or port name. If neither exists, assign None + with filename.open("w") as f: json.dump(device_type_mapper, f) except AttributeError: From 420c83140b80b5682d92db9ef1a9e967ba5a2eba Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 17 Apr 2023 15:27:50 +0000 Subject: [PATCH 226/489] handle different data structure between exp02 and presocial --- aeon/dj_pipeline/utils/load_metadata.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index c8dedc18..bf4dae9c 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -184,9 +184,10 @@ def extract_epoch_config(experiment_name: str, metadata_yml_filepath: str) -> di ) ) - devices: dict = { - d.pop("Name"): d for d in devices - } # {deivce_name: device_config} #! may not work for presocial + if isinstance(devices, list): # In exp02, it is a list of dict. In presocial. It's a dict of dict. + devices: dict = { + d.pop("Name"): d for d in devices + } # {deivce_name: device_config} return { "experiment_name": experiment_name, From 3d9ca95c75add5725bdb0545c0563e127e24ddc5 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 17 Apr 2023 15:28:37 +0000 Subject: [PATCH 227/489] run insert_stream_types when worker starts --- aeon/dj_pipeline/populate/worker.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py index 4c506347..7b1678dc 100644 --- a/aeon/dj_pipeline/populate/worker.py +++ b/aeon/dj_pipeline/populate/worker.py @@ -30,6 +30,7 @@ logger = dj.logger _current_experiment = "exp0.2-r0" worker_schema_name = db_prefix + "workerlog" +load_metadata.insert_stream_types() # ---- Define worker(s) ---- @@ -41,9 +42,7 @@ run_duration=-1, sleep_duration=600, ) - high_priority(load_metadata.ingest_subject) -high_priority(load_metadata.ingest_streams) high_priority(acquisition.Epoch.ingest_epochs, experiment_name=_current_experiment) high_priority(acquisition.Chunk.ingest_chunks, experiment_name=_current_experiment) high_priority(acquisition.ExperimentLog) From d03dd93f8b75a8ccbb13808b22ab09bb992cb8b1 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 17 Apr 2023 15:29:15 +0000 Subject: [PATCH 228/489] code review --- aeon/dj_pipeline/acquisition.py | 9 +-- aeon/dj_pipeline/streams.py | 26 ++++++++ aeon/dj_pipeline/utils/load_metadata.py | 84 +++++++------------------ 3 files changed, 54 insertions(+), 65 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index cdd10d0a..4a598184 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -10,17 +10,17 @@ from aeon.io import reader as io_reader from aeon.schema import dataset as aeon_schema -from . import get_schema_name, lab, subject +from . import get_schema_name, lab, streams, subject from .utils import paths from .utils.load_metadata import ( extract_epoch_config, - ingest_devices, ingest_epoch_metadata, + insert_device_types, ) logger = dj.logger schema = dj.schema(get_schema_name("acquisition")) - +# streams = dj.VirtualModule("streams", get_schema_name("streams")) # ------------------- Some Constants -------------------------- @@ -354,10 +354,11 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): cls.Config.insert1(epoch_config) # Ingest streams.DeviceType, streams.Device and create device tables. - ingest_devices( + insert_device_types( _device_schema_mapping[epoch_key["experiment_name"]], metadata_yml_filepath, ) + streams.main() ingest_epoch_metadata(experiment_name, metadata_yml_filepath) epoch_list.append(epoch_key) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index a9bedb31..b59a0c66 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -1,3 +1,5 @@ +import inspect + import datajoint as dj import pandas as pd @@ -12,6 +14,8 @@ schema_name = get_schema_name("streams") schema = dj.schema(schema_name) +schema.spawn_missing_classes() + @schema class StreamType(dj.Lookup): @@ -194,3 +198,25 @@ def make(self, key): # endregion + + +def main(): + + context = inspect.currentframe().f_back.f_locals + + # Create tables. + for device_info in (DeviceType).fetch(as_dict=True): + table_class = get_device_template(device_info["device_type"]) + context[table_class.__name__] = table_class + schema(table_class, context=context) + + # Create DeviceDataStream tables. + for device_info in (DeviceType.Stream).fetch(as_dict=True): + table_class = get_device_stream_template( + device_info["device_type"], device_info["stream_type"] + ) + context[table_class.__name__] = table_class + schema(table_class, context=context) + + +main() diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index bf4dae9c..3414c624 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -39,7 +39,7 @@ def ingest_subject(colony_csv_path: pathlib.Path = _colony_csv_path) -> None: ) -def ingest_streams(): +def insert_stream_types(): """Insert into streams.streamType table all streams in the dataset schema.""" from aeon.schema import dataset @@ -62,7 +62,7 @@ def ingest_streams(): streams.StreamType.insert(stream_entries, skip_duplicates=True) -def ingest_devices(schema: DotMap, metadata_yml_filepath: Path): +def insert_device_types(schema: DotMap, metadata_yml_filepath: Path): """Use dataset.schema and metadata.yml to insert into streams.DeviceType and streams.Device. Only insert device types that were defined both in the device schema (e.g., exp02) and Metadata.yml. It then creates new device tables under streams schema.""" device_info: dict[dict] = get_device_info(schema) device_type_mapper, device_sn = get_device_mapper(schema, metadata_yml_filepath) @@ -126,28 +126,6 @@ def ingest_devices(schema: DotMap, metadata_yml_filepath: Path): if new_devices: streams.Device.insert(new_devices) - # Create tables. - context = inspect.currentframe().f_back.f_locals - - for device_info in new_device_types: - table_class = streams.get_device_template(device_info["device_type"]) - context[table_class.__name__] = table_class - streams.schema(table_class, context=context) - - # Create device_type tables - for device_info in new_device_stream_types: - table_class = streams.get_device_stream_template( - device_info["device_type"], device_info["stream_type"] - ) - context[table_class.__name__] = table_class - streams.schema(table_class, context=context) - - streams.schema.activate(streams.schema_name, add_objects=context) - vm = dj.VirtualModule(streams.schema_name, streams.schema_name) - for k, v in vm.__dict__.items(): - if "Table" in str(v.__class__): - streams.__dict__[k] = v - def extract_epoch_config(experiment_name: str, metadata_yml_filepath: str) -> dict: """Parse experiment metadata YAML file and extract epoch configuration. @@ -208,6 +186,8 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): """ from aeon.dj_pipeline import streams + streams = dj.VirtualModule("streams", get_schema_name("streams")) + if experiment_name.startswith("oct"): ingest_epoch_metadata_octagon(experiment_name, metadata_yml_filepath) return @@ -233,11 +213,12 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): } # May not be needed? # Insert into each device table + device_list = [] for device_name, device_config in epoch_config["metadata"].items(): if table := getattr(streams, device_config["Type"], None): device_sn = device_config.get("SerialNumber", device_config.get("PortName")) device_key = {"device_serial_number": device_sn} - + device_list.append(device_key) if not ( table & { @@ -245,7 +226,6 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): "device_serial_number": device_sn, } ): - table_entry = { "experiment_name": experiment_name, "device_serial_number": device_sn, @@ -271,11 +251,13 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): """Check if this camera is currently installed. If the same camera serial number is currently installed check for any changes in configuration. If not, skip this""" current_device_query = ( - table.Attribute - table.RemovalTime & experiment_key & device_key + table - table.RemovalTime & experiment_key & device_key ) if current_device_query: - current_device_config: list[dict] = current_device_query.fetch( + current_device_config: list[dict] = ( + table.Attribute & current_device_query + ).fetch( "experiment_name", "device_serial_number", "attribute_name", @@ -298,46 +280,26 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): continue # Remove old device - table_removal_entry = [ - { - **entry, - f"{dj.utils.from_camel_case(table.__name__)}_removal_time": epoch_config[ - "epoch_start" - ], - } - for entry in current_device_config - ] + table_removal_entry = { + **table_entry, + f"{dj.utils.from_camel_case(table.__name__)}_removal_time": epoch_config[ + "epoch_start" + ], + } # Insert into table. with table.connection.in_transaction: table.insert1(table_entry) table.Attribute.insert(table_attribute_entry) - table.RemovalTime.insert(table_removal_entry) + table.RemovalTime.insert1( + table_removal_entry, ignore_extra_fields=True + ) # Remove the currently installed devices that are absent in this config - device_removal_list.extend( - (table - table.RemovalTime - device_list & experiment_key).fetch("KEY") - ) - - # Insert - # def insert(): - # lab.Camera.insert(camera_list, skip_duplicates=True) - # acquisition.ExperimentCamera.RemovalTime.insert(camera_removal_list) - # acquisition.ExperimentCamera.insert(camera_installation_list) - # acquisition.ExperimentCamera.Position.insert(camera_position_list) - # lab.FoodPatch.insert(patch_list, skip_duplicates=True) - # acquisition.ExperimentFoodPatch.RemovalTime.insert(patch_removal_list) - # acquisition.ExperimentFoodPatch.insert(patch_installation_list) - # acquisition.ExperimentFoodPatch.Position.insert(patch_position_list) - # lab.WeightScale.insert(weight_scale_list, skip_duplicates=True) - # acquisition.ExperimentWeightScale.RemovalTime.insert(weight_scale_removal_list) - # acquisition.ExperimentWeightScale.insert(weight_scale_installation_list) - - # if acquisition.Experiment.connection.in_transaction: - # insert() - # else: - # with acquisition.Experiment.connection.transaction: - # insert() + device_removal_list = ( + table - table.RemovalTime - device_list & experiment_key + ).fetch("KEY") + table.RemovalTime.insert1(device_removal_list, ignore_extra_fields=True) # region Get stream & device information From 54cca8f82975b30e61077939a2ff8fb3e418dc43 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Mon, 17 Apr 2023 15:48:35 -0500 Subject: [PATCH 229/489] Update video.py --- aeon/dj_pipeline/utils/video.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/aeon/dj_pipeline/utils/video.py b/aeon/dj_pipeline/utils/video.py index 15cdbfb7..dc54a60d 100644 --- a/aeon/dj_pipeline/utils/video.py +++ b/aeon/dj_pipeline/utils/video.py @@ -10,9 +10,6 @@ import aeon.io.reader as io_reader -camera_name = "CameraTop" -start_time = datetime.datetime(2022, 4, 3, 13, 0, 0) -end_time = datetime.datetime(2022, 4, 3, 15, 0, 0) raw_data_dir = pathlib.Path("/ceph/aeon/aeon/data/raw/AEON2/experiment0.2") @@ -39,11 +36,6 @@ def retrieve_video_frames( framedata = videodata[start_frame : start_frame + chunk_size] - # downsample - # actual_fps = 1 / np.median(np.diff(videodata.index) / np.timedelta64(1, "s")) - # final_fps = min(desired_fps, actual_fps) - # ds_factor = int(np.around(actual_fps / final_fps)) - # framedata = videodata[::ds_factor] final_fps = desired_fps # read frames From 4fc8aae1073672f4ab9747b6c636ccf2b1c0cf67 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 18 Apr 2023 21:13:58 +0000 Subject: [PATCH 230/489] thinh change --- aeon/dj_pipeline/acquisition.py | 14 +++++++++++--- aeon/dj_pipeline/utils/video.py | 20 ++++++-------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 4a598184..6837893e 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -352,16 +352,24 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): cls.insert1(epoch_key) if epoch_config: cls.Config.insert1(epoch_config) - + if metadata_yml_filepath and metadata_yml_filepath.exists(): + try: # Ingest streams.DeviceType, streams.Device and create device tables. insert_device_types( _device_schema_mapping[epoch_key["experiment_name"]], metadata_yml_filepath, ) streams.main() + with cls.connection.transaction: + ingest_epoch_metadata( + experiment_name, metadata_yml_filepath + ) + epoch_list.append(epoch_key) + except Exception as e: + (cls.Config & epoch_key).delete_quick() + (cls & epoch_key).delete_quick() + raise e - ingest_epoch_metadata(experiment_name, metadata_yml_filepath) - epoch_list.append(epoch_key) # update previous epoch if ( previous_epoch_key diff --git a/aeon/dj_pipeline/utils/video.py b/aeon/dj_pipeline/utils/video.py index 15cdbfb7..60434419 100644 --- a/aeon/dj_pipeline/utils/video.py +++ b/aeon/dj_pipeline/utils/video.py @@ -1,18 +1,15 @@ -import numpy as np import base64 -import pandas as pd -import pathlib import datetime +import pathlib + import cv2 +import numpy as np +import pandas as pd +import aeon.io.reader as io_reader from aeon.io import api as io_api from aeon.io import video as io_video -import aeon.io.reader as io_reader - -camera_name = "CameraTop" -start_time = datetime.datetime(2022, 4, 3, 13, 0, 0) -end_time = datetime.datetime(2022, 4, 3, 15, 0, 0) raw_data_dir = pathlib.Path("/ceph/aeon/aeon/data/raw/AEON2/experiment0.2") @@ -39,11 +36,6 @@ def retrieve_video_frames( framedata = videodata[start_frame : start_frame + chunk_size] - # downsample - # actual_fps = 1 / np.median(np.diff(videodata.index) / np.timedelta64(1, "s")) - # final_fps = min(desired_fps, actual_fps) - # ds_factor = int(np.around(actual_fps / final_fps)) - # framedata = videodata[::ds_factor] final_fps = desired_fps # read frames @@ -64,4 +56,4 @@ def retrieve_video_frames( "finalChunk": bool(last_frame_time >= end_time), }, "frames": encoded_frames, - } + } \ No newline at end of file From 9daa25563be7204beacd8a8567012eec781110fe Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 18 Apr 2023 21:36:41 +0000 Subject: [PATCH 231/489] auto insertion of device entries --- aeon/dj_pipeline/acquisition.py | 3 +- aeon/dj_pipeline/streams.py | 3 - aeon/dj_pipeline/utils/load_metadata.py | 139 ++++++++++++------------ 3 files changed, 69 insertions(+), 76 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 6837893e..9d7cad95 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -10,7 +10,7 @@ from aeon.io import reader as io_reader from aeon.schema import dataset as aeon_schema -from . import get_schema_name, lab, streams, subject +from . import get_schema_name, lab, subject from .utils import paths from .utils.load_metadata import ( extract_epoch_config, @@ -275,6 +275,7 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): - if not specified, ingest all epochs Note: "start" and "end" are datetime specified a string in the format: "%Y-%m-%d %H:%M:%S" """ + from aeon.dj_pipeline import acquisition, streams device_name = _ref_device_mapping.get(experiment_name, "CameraTop") all_chunks, raw_data_dirs = _get_all_chunks(experiment_name, device_name) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index b59a0c66..435bd441 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -217,6 +217,3 @@ def main(): ) context[table_class.__name__] = table_class schema(table_class, context=context) - - -main() diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 3414c624..8c8cca94 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -137,6 +137,7 @@ def extract_epoch_config(experiment_name: str, metadata_yml_filepath: str) -> di Returns: dict: epoch_config [dict] """ + metadata_yml_filepath = pathlib.Path(metadata_yml_filepath) epoch_start = datetime.datetime.strptime( metadata_yml_filepath.parent.name, "%Y-%m-%dT%H-%M-%S" @@ -184,9 +185,8 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): + camera/patch location + patch, weightscale serial number """ - from aeon.dj_pipeline import streams - streams = dj.VirtualModule("streams", get_schema_name("streams")) + # streams = dj.VirtualModule("streams", get_schema_name("streams")) if experiment_name.startswith("oct"): ingest_epoch_metadata_octagon(experiment_name, metadata_yml_filepath) @@ -214,92 +214,87 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): # Insert into each device table device_list = [] + device_removal_list = [] + for device_name, device_config in epoch_config["metadata"].items(): if table := getattr(streams, device_config["Type"], None): device_sn = device_config.get("SerialNumber", device_config.get("PortName")) device_key = {"device_serial_number": device_sn} + device_list.append(device_key) - if not ( - table - & { - "experiment_name": experiment_name, - "device_serial_number": device_sn, - } - ): - table_entry = { - "experiment_name": experiment_name, - "device_serial_number": device_sn, - f"{dj.utils.from_camel_case(table.__name__)}_install_time": epoch_config[ - "epoch_start" - ], - f"{dj.utils.from_camel_case(table.__name__)}_name": device_name, + table_entry = { + "experiment_name": experiment_name, + **device_key, + f"{dj.utils.from_camel_case(table.__name__)}_install_time": epoch_config[ + "epoch_start" + ], + f"{dj.utils.from_camel_case(table.__name__)}_name": device_name, + } + + table_attribute_entry = [ + { + **table_entry, + "attribute_name": attribute_name, + "attribute_value": attribute_value, } + for attribute_name, attribute_value in device_config.items() + ] - table_attribute_entry = [ + """Check if this camera is currently installed. If the same camera serial number is currently installed check for any changes in configuration. If not, skip this""" + current_device_query = ( + table - table.RemovalTime & experiment_key & device_key + ) + + if current_device_query: + current_device_config: list[dict] = ( + table.Attribute & current_device_query + ).fetch( + "experiment_name", + "device_serial_number", + "attribute_name", + "attribute_value", + as_dict=True, + ) + new_device_config: list[dict] = [ { - "experiment_name": experiment_name, - "device_serial_number": device_sn, - f"{dj.utils.from_camel_case(table.__name__)}_install_time": epoch_config[ - "epoch_start" - ], - f"{dj.utils.from_camel_case(table.__name__)}_name": device_name, - "attribute_name": attribute_name, - "attribute_value": attribute_value, + k: v + for k, v in entry.items() + if k + != f"{dj.utils.from_camel_case(table.__name__)}_install_time" } - for attribute_name, attribute_value in device_config.items() + for entry in table_attribute_entry ] - """Check if this camera is currently installed. If the same camera serial number is currently installed check for any changes in configuration. If not, skip this""" - current_device_query = ( - table - table.RemovalTime & experiment_key & device_key - ) - - if current_device_query: - current_device_config: list[dict] = ( - table.Attribute & current_device_query - ).fetch( - "experiment_name", - "device_serial_number", - "attribute_name", - "attribute_value", - as_dict=True, - ) - new_device_config: list[dict] = [ - { - k: v - for k, v in entry.items() - if k - != f"{dj.utils.from_camel_case(table.__name__)}_install_time" - } - for entry in table_attribute_entry - ] + if dict_to_uuid(current_device_config) == dict_to_uuid( + new_device_config + ): # Skip if none of the configuration has changed. + continue - if dict_to_uuid(current_device_config) == dict_to_uuid( - new_device_config - ): # Skip if none of the configuration has changed. - continue - - # Remove old device - table_removal_entry = { - **table_entry, - f"{dj.utils.from_camel_case(table.__name__)}_removal_time": epoch_config[ - "epoch_start" - ], - } + # Remove old device + table_removal_entry = { + **table_entry, + f"{dj.utils.from_camel_case(table.__name__)}_removal_time": epoch_config[ + "epoch_start" + ], + } - # Insert into table. - with table.connection.in_transaction: - table.insert1(table_entry) - table.Attribute.insert(table_attribute_entry) + # Insert into table. + with table.connection.in_transaction: + table.insert1(table_entry, skip_duplicates=True) + table.Attribute.insert(table_attribute_entry, ignore_extra_fields=True) + try: table.RemovalTime.insert1( table_removal_entry, ignore_extra_fields=True ) - - # Remove the currently installed devices that are absent in this config - device_removal_list = ( - table - table.RemovalTime - device_list & experiment_key - ).fetch("KEY") - table.RemovalTime.insert1(device_removal_list, ignore_extra_fields=True) + except NameError: + pass + + # Remove the currently installed devices that are absent in this config + device_removal_list = ( + table - table.RemovalTime - device_list & experiment_key + ).fetch("KEY") + if device_removal_list: + table.RemovalTime.insert(device_removal_list, ignore_extra_fields=True) # region Get stream & device information From 13b3301a734dbf8f445fbe5415f70dbd937e1ecc Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 24 Apr 2023 22:42:08 +0000 Subject: [PATCH 232/489] fix bugs --- aeon/dj_pipeline/acquisition.py | 2 +- aeon/dj_pipeline/utils/load_metadata.py | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 9d7cad95..513c960f 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -360,7 +360,7 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): _device_schema_mapping[epoch_key["experiment_name"]], metadata_yml_filepath, ) - streams.main() + streams.main() # create device tables under streams schema with cls.connection.transaction: ingest_epoch_metadata( experiment_name, metadata_yml_filepath diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 8c8cca94..be85d5b0 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -74,7 +74,7 @@ def insert_device_types(schema: DotMap, metadata_yml_filepath: Path): **device_info[device_name], } for device_name in device_info - if device_type_mapper.get(device_name) + if device_type_mapper.get(device_name) and device_sn.get(device_name) } # Create a map of device_type to stream_type. @@ -180,10 +180,7 @@ def extract_epoch_config(experiment_name: str, metadata_yml_filepath: str) -> di def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): """ - work-in-progress - Missing: - + camera/patch location - + patch, weightscale serial number + Make entries into device tables """ # streams = dj.VirtualModule("streams", get_schema_name("streams")) @@ -212,12 +209,16 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): if name.endswith("Frequency") } # May not be needed? + schema = acquisition._device_schema_mapping[experiment_name] + device_type_mapper, _ = get_device_mapper(schema, metadata_yml_filepath) + # Insert into each device table device_list = [] device_removal_list = [] for device_name, device_config in epoch_config["metadata"].items(): - if table := getattr(streams, device_config["Type"], None): + if table := getattr(streams, device_type_mapper.get(device_name) or "", None): + device_sn = device_config.get("SerialNumber", device_config.get("PortName")) device_key = {"device_serial_number": device_sn} From 835af127c043f52bee5ec79664f380c1de55eb62 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 24 Apr 2023 22:44:09 +0000 Subject: [PATCH 233/489] add device removal time --- aeon/dj_pipeline/utils/load_metadata.py | 30 ++++++++++++------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index be85d5b0..6683d029 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -260,29 +260,28 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): { k: v for k, v in entry.items() - if k - != f"{dj.utils.from_camel_case(table.__name__)}_install_time" + if dj.utils.from_camel_case(table.__name__) not in k } for entry in table_attribute_entry ] - if dict_to_uuid(current_device_config) == dict_to_uuid( - new_device_config + if dict_to_uuid({config["attribute_name"]: config["attribute_value"] for config in current_device_config}) == dict_to_uuid( + {config["attribute_name"]: config["attribute_value"] for config in new_device_config} ): # Skip if none of the configuration has changed. continue # Remove old device - table_removal_entry = { - **table_entry, + device_removal_list.append({ + **current_device_query.fetch1("KEY"), f"{dj.utils.from_camel_case(table.__name__)}_removal_time": epoch_config[ "epoch_start" ], - } + }) # Insert into table. with table.connection.in_transaction: - table.insert1(table_entry, skip_duplicates=True) - table.Attribute.insert(table_attribute_entry, ignore_extra_fields=True) + table.insert1(table_entry, skip_duplicates=True) + table.Attribute.insert(table_attribute_entry, ignore_extra_fields=True) try: table.RemovalTime.insert1( table_removal_entry, ignore_extra_fields=True @@ -290,12 +289,13 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): except NameError: pass - # Remove the currently installed devices that are absent in this config - device_removal_list = ( - table - table.RemovalTime - device_list & experiment_key - ).fetch("KEY") - if device_removal_list: - table.RemovalTime.insert(device_removal_list, ignore_extra_fields=True) + # Remove the currently installed devices that are absent in this config + device_removal_list.extend( + (table - table.RemovalTime - device_list & experiment_key + ).fetch("KEY")) + + if device_removal_list: + table.RemovalTime.insert(device_removal_list, ignore_extra_fields=True) # region Get stream & device information From d1ca8e1344216ee64883b62845a26345c62cf941 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 24 Apr 2023 22:44:23 +0000 Subject: [PATCH 234/489] add mapping dictionary --- aeon/dj_pipeline/create_experiments/device_type_mapper.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/create_experiments/device_type_mapper.json b/aeon/dj_pipeline/create_experiments/device_type_mapper.json index a802f72a..028ae594 100644 --- a/aeon/dj_pipeline/create_experiments/device_type_mapper.json +++ b/aeon/dj_pipeline/create_experiments/device_type_mapper.json @@ -1 +1 @@ -{"VideoController": "VideoController", "CameraTop": "VideoSource", "CameraWest": "VideoSource", "CameraEast": "VideoSource", "CameraNorth": "VideoSource", "CameraSouth": "VideoSource", "CameraPatch1": "VideoSource", "CameraPatch2": "VideoSource", "CameraNest": "VideoSource", "AudioAmbient": "AudioSource", "Patch1": "Patch", "Patch2": "Patch", "WeightNest": "WeightScale", "TrackingTop": "PositionTracking", "ActivityCenter": "ActivityTracking", "ActivityArena": "ActivityTracking", "ActivityNest": "ActivityTracking", "ActivityPatch1": "ActivityTracking", "ActivityPatch2": "ActivityTracking", "InNest": "RegionTracking", "InPatch1": "RegionTracking", "InPatch2": "RegionTracking", "ArenaCenter": "DistanceFromPoint", "InArena": "InRange", "InCorridor": "InRange"} \ No newline at end of file +{"VideoController": "VideoController", "CameraTop": "VideoSource", "CameraWest": "VideoSource", "CameraEast": "VideoSource", "CameraNorth": "VideoSource", "CameraSouth": "VideoSource", "CameraPatch1": "VideoSource", "CameraPatch2": "VideoSource", "CameraNest": "VideoSource", "AudioAmbient": "AudioSource", "Patch1": "Patch", "Patch2": "Patch", "WeightNest": "WeightScale", "TrackingTop": "PositionTracking", "ActivityCenter": "ActivityTracking", "ActivityArena": "ActivityTracking", "ActivityNest": "ActivityTracking", "ActivityPatch1": "ActivityTracking", "ActivityPatch2": "ActivityTracking", "InNest": "RegionTracking", "InPatch1": "RegionTracking", "InPatch2": "RegionTracking", "ArenaCenter": "DistanceFromPoint", "InArena": "InRange", "InCorridor": "InRange", "ClockSynchronizer": null} \ No newline at end of file From a14520f2c32a393b29cde4b1cad7ada210dad9fc Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 24 Apr 2023 22:47:43 +0000 Subject: [PATCH 235/489] support different metadata structure for presocial --- aeon/dj_pipeline/utils/load_metadata.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 6683d029..2b66bb83 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -183,8 +183,6 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): Make entries into device tables """ - # streams = dj.VirtualModule("streams", get_schema_name("streams")) - if experiment_name.startswith("oct"): ingest_epoch_metadata_octagon(experiment_name, metadata_yml_filepath) return @@ -279,15 +277,8 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): }) # Insert into table. - with table.connection.in_transaction: table.insert1(table_entry, skip_duplicates=True) table.Attribute.insert(table_attribute_entry, ignore_extra_fields=True) - try: - table.RemovalTime.insert1( - table_removal_entry, ignore_extra_fields=True - ) - except NameError: - pass # Remove the currently installed devices that are absent in this config device_removal_list.extend( @@ -464,7 +455,7 @@ def get_device_mapper(schema: DotMap, metadata_yml_filepath: Path): ) device_type_mapper = {} # {device_name: device_type} - device_sn = {} # device serial number + device_sn = {} # {device_name: device_sn} if filename.is_file(): with filename.open("r") as f: @@ -472,10 +463,15 @@ def get_device_mapper(schema: DotMap, metadata_yml_filepath: Path): try: # if the device type is not in the mapper, add it for item in meta_data.Devices: - device_type_mapper[item.Name] = item.Type - device_sn[item.Name] = ( - item.SerialNumber or item.PortName or None - ) # assign either the serial number (if it exists) or port name. If neither exists, assign None + if isinstance(item, DotMap): + device_type_mapper[item.Name] = item.Type + device_sn[item.Name] = ( + item.SerialNumber or item.PortName or None + ) # assign either the serial number (if it exists) or port name. If neither exists, assign None + elif isinstance(item, str): # presocial + if meta_data.Devices[item].get("Type"): + device_type_mapper[item] = meta_data.Devices[item].get("Type") + device_sn[item] = meta_data.Devices[item].get("SerialNumber") or meta_data.Devices[item].get("PortName") or None with filename.open("w") as f: json.dump(device_type_mapper, f) From 08fabf83325e6bd085493f0ff2d4c58c4ddb49c1 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 25 Apr 2023 19:09:03 +0000 Subject: [PATCH 236/489] roll back sciviz --- .../webapps/sciviz/docker-compose-local.yaml | 16 +++++----------- .../webapps/sciviz/docker-compose-remote.yaml | 10 +++------- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml index 597d2f3c..15709f2b 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml @@ -7,7 +7,7 @@ services: pharus: cpus: 2.0 mem_limit: 4g - image: jverswijver/pharus:0.8.0py3.9 + image: datajoint/pharus:0.7.3 environment: # - FLASK_ENV=development # enables logging to console from Flask - PHARUS_SPEC_PATH=/main/specs/specsheet.yaml # for dynamic utils spec @@ -16,14 +16,10 @@ services: volumes: - ./specsheet.yaml:/main/specs/specsheet.yaml #copy the spec over to /main/specs/YOUR_SPEC_NAME - ./apk_requirements.txt:/tmp/apk_requirements.txt - - /ceph/aeon/aeon:/ceph/aeon/aeon command: - sh - -c - | - apk add --update git g++ && - git clone -b datajoint_pipeline https://github.com/ttngu207/aeon_mecha.git && - pip install -e ./aeon_mecha && pharus_update() { [ -z "$$GUNICORN_PID" ] || kill $$GUNICORN_PID gunicorn --bind 0.0.0.0:$${PHARUS_PORT} pharus.server:app & @@ -51,14 +47,13 @@ services: - main sci-viz: cpus: 2.0 - mem_limit: 16g - image: jverswijver/sci-viz:1.1.1-bugfix2 + mem_limit: 4g + image: datajoint/sci-viz:1.1.1 environment: - CHOKIDAR_USEPOLLING=true - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/api - DJSCIVIZ_SPEC_PATH=specsheet.yaml - DJSCIVIZ_MODE=DEV - - NODE_OPTIONS="--max-old-space-size=12000" volumes: - ./specsheet.yaml:/main/specsheet.yaml # ports: @@ -103,11 +98,10 @@ services: - ADD_sciviz_TYPE=REST - ADD_sciviz_ENDPOINT=sci-viz:3000 - ADD_sciviz_PREFIX=/ -# - HTTPS_PASSTHRU=TRUE - - DEPLOYMENT_PORT + - HTTPS_PASSTHRU=TRUE ports: - "443:443" - - "${DEPLOYMENT_PORT:-80}:80" + - "80:80" networks: - main networks: diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml index 97ef5a67..06d32761 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml @@ -6,7 +6,7 @@ services: pharus: cpus: 2.0 mem_limit: 4g - image: jverswijver/pharus:0.8.0py3.9 + image: datajoint/pharus:0.7.3 environment: # - FLASK_ENV=development # enables logging to console from Flask - PHARUS_SPEC_PATH=/main/specs/specsheet.yaml # for dynamic utils spec @@ -18,9 +18,6 @@ services: - sh - -c - | - apk add --update git g++ && - git clone -b datajoint_pipeline https://github.com/ttngu207/aeon_mecha.git && - pip install -e ./aeon_mecha && gunicorn --bind 0.0.0.0:$${PHARUS_PORT} pharus.server:app # ports: # - "5000:5000" @@ -28,13 +25,12 @@ services: - main sci-viz: cpus: 2.0 - mem_limit: 16g - image: jverswijver/sci-viz:1.1.1-bugfix2 + mem_limit: 4g + image: datajoint/sci-viz:1.1.1 environment: - CHOKIDAR_USEPOLLING=true - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/aeon/utils - DJSCIVIZ_SPEC_PATH=specsheet.yaml - - NODE_OPTIONS="--max-old-space-size=12000" volumes: - ./specsheet.yaml:/main/specsheet.yaml # ports: From 6e3096539bf3c895fc89147b8dba96cb8a217fae Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 25 Apr 2023 15:44:06 -0500 Subject: [PATCH 237/489] Update video.py --- aeon/dj_pipeline/utils/video.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/aeon/dj_pipeline/utils/video.py b/aeon/dj_pipeline/utils/video.py index dc54a60d..70d1e1c7 100644 --- a/aeon/dj_pipeline/utils/video.py +++ b/aeon/dj_pipeline/utils/video.py @@ -5,15 +5,15 @@ import datetime import cv2 +from aeon.dj_pipeline import acquisition + from aeon.io import api as io_api from aeon.io import video as io_video import aeon.io.reader as io_reader -raw_data_dir = pathlib.Path("/ceph/aeon/aeon/data/raw/AEON2/experiment0.2") - - def retrieve_video_frames( + experiment_name, camera_name, start_time, end_time, @@ -22,6 +22,10 @@ def retrieve_video_frames( chunk_size=50, **kwargs, ): + raw_data_dir = acquisition.Experiment.get_data_directory( + {"experiment_name": experiment_name} + ) + # do some data loading videodata = io_api.load( root=raw_data_dir.as_posix(), From 5dd508e040d969f97fdfad3f3595145b30365f4f Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 25 Apr 2023 15:44:13 -0500 Subject: [PATCH 238/489] update sciviz version --- .../webapps/sciviz/docker-compose-local.yaml | 36 ++++--------------- .../webapps/sciviz/docker-compose-remote.yaml | 16 ++++----- 2 files changed, 13 insertions(+), 39 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml index 597d2f3c..29bca674 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml @@ -7,14 +7,14 @@ services: pharus: cpus: 2.0 mem_limit: 4g - image: jverswijver/pharus:0.8.0py3.9 + image: datajoint/pharus:0.8.4 environment: # - FLASK_ENV=development # enables logging to console from Flask - - PHARUS_SPEC_PATH=/main/specs/specsheet.yaml # for dynamic utils spec + - PHARUS_SPEC_PATH=/main/specsheet.yaml # for dynamic utils spec - PHARUS_MODE=DEV user: ${HOST_UID}:anaconda volumes: - - ./specsheet.yaml:/main/specs/specsheet.yaml #copy the spec over to /main/specs/YOUR_SPEC_NAME + - ./specsheet.yaml:/main/specsheet.yaml #copy the spec over to /main/specs/YOUR_SPEC_NAME - ./apk_requirements.txt:/tmp/apk_requirements.txt - /ceph/aeon/aeon:/ceph/aeon/aeon command: @@ -52,11 +52,11 @@ services: sci-viz: cpus: 2.0 mem_limit: 16g - image: jverswijver/sci-viz:1.1.1-bugfix2 + image: datajoint/sci-viz:2.3.0 environment: - CHOKIDAR_USEPOLLING=true - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/api - - DJSCIVIZ_SPEC_PATH=specsheet.yaml + - DJSCIVIZ_SPEC_PATH=/main/specsheet.yaml - DJSCIVIZ_MODE=DEV - NODE_OPTIONS="--max-old-space-size=12000" volumes: @@ -67,31 +67,7 @@ services: - sh - -c - | - sciviz_update() { - [ -z "$$NGINX_PID" ] || kill $$NGINX_PID - rm -R /usr/share/nginx/html - python frontend_gen.py - yarn build - mv ./build /usr/share/nginx/html - nginx -g "daemon off;" & - NGINX_PID=$$! - } - sciviz_update - echo "[$$(date -u '+%Y-%m-%d %H:%M:%S')][DataJoint]: Monitoring SciViz updates..." - INIT_TIME=$$(date +%s) - LAST_MOD_TIME=$$(date -r $$DJSCIVIZ_SPEC_PATH +%s) - DELTA=$$(expr $$LAST_MOD_TIME - $$INIT_TIME) - while true; do - CURR_LAST_MOD_TIME=$$(date -r $$DJSCIVIZ_SPEC_PATH +%s) - CURR_DELTA=$$(expr $$CURR_LAST_MOD_TIME - $$INIT_TIME) - if [ "$$DELTA" -lt "$$CURR_DELTA" ]; then - echo "[$$(date -u '+%Y-%m-%d %H:%M:%S')][DataJoint]: Reloading SciViz since \`$$DJSCIVIZ_SPEC_PATH\` changed." - sciviz_update - DELTA=$$CURR_DELTA - else - sleep 5 - fi - done + sh sci-viz-hotreload-dev.sh networks: - main fakeservices.datajoint.io: diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml index 97ef5a67..4791edcc 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml @@ -6,14 +6,15 @@ services: pharus: cpus: 2.0 mem_limit: 4g - image: jverswijver/pharus:0.8.0py3.9 + image: datajoint/pharus:0.8.4 environment: # - FLASK_ENV=development # enables logging to console from Flask - - PHARUS_SPEC_PATH=/main/specs/specsheet.yaml # for dynamic utils spec + - PHARUS_SPEC_PATH=/main/specsheet.yaml # for dynamic utils spec user: ${HOST_UID}:anaconda volumes: - - ./specsheet.yaml:/main/specs/specsheet.yaml #copy the spec over to /main/specs/YOUR_SPEC_NAME + - ./specsheet.yaml:/main/specsheet.yaml #copy the spec over to /main/specs/YOUR_SPEC_NAME - ./apk_requirements.txt:/tmp/apk_requirements.txt + - /ceph/aeon/aeon:/ceph/aeon/aeon command: - sh - -c @@ -29,11 +30,11 @@ services: sci-viz: cpus: 2.0 mem_limit: 16g - image: jverswijver/sci-viz:1.1.1-bugfix2 + image: datajoint/sci-viz:2.3.0 environment: - CHOKIDAR_USEPOLLING=true - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/aeon/utils - - DJSCIVIZ_SPEC_PATH=specsheet.yaml + - DJSCIVIZ_SPEC_PATH=/main/specsheet.yaml - NODE_OPTIONS="--max-old-space-size=12000" volumes: - ./specsheet.yaml:/main/specsheet.yaml @@ -43,10 +44,7 @@ services: - sh - -c - | - python frontend_gen.py - npm run build - mv ./build /usr/share/nginx/html - nginx -g "daemon off;" + sh sci-viz-hotreload-prod.sh networks: - main reverse-proxy: From 20144c97c47c1b38d4c7a640ab5cb846e1e99f41 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 25 Apr 2023 17:19:53 -0500 Subject: [PATCH 239/489] Update specsheet.yaml --- aeon/dj_pipeline/webapps/sciviz/specsheet.yaml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index c666c44c..8b40263a 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -36,7 +36,7 @@ SciViz: pages: Colony: - route: /colony_entry + route: /colony_page grids: grid1: type: fixed @@ -68,6 +68,19 @@ SciViz: - type: attribute input: Note destination: note + Colony: + route: /colony_page_table + x: 0 + y: 0 + height: 1 + width: 1 + type: antd-table + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_lab): + return {'query': aeon_lab.Colony(), 'fetch_args': []} Subjects: route: /subjects @@ -422,7 +435,7 @@ SciViz: return dict(**kwargs) dj_query: > def dj_query(aeon_acquisition): - q = aeon_acquisition.ExperimentCamera.proj('camera_description') + q = dj.U('camera_description') & acquisition.ExperimentCamera return {'query': q, 'fetch_args': ['camera_description']} time_range_selector: x: 0 From 6d38d688d41ca67b40ce2223189bbbe10c543066 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 25 Apr 2023 17:28:35 -0500 Subject: [PATCH 240/489] pharus with python 3.9 --- aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml index 4791edcc..effb43c6 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml @@ -6,7 +6,7 @@ services: pharus: cpus: 2.0 mem_limit: 4g - image: datajoint/pharus:0.8.4 + image: jverswijver/pharus:0.8.0py3.9 environment: # - FLASK_ENV=development # enables logging to console from Flask - PHARUS_SPEC_PATH=/main/specsheet.yaml # for dynamic utils spec From 833f013a035fa8cb7e40f564fa1a74020ab1bc7d Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 25 Apr 2023 17:35:24 -0500 Subject: [PATCH 241/489] Update video.py --- aeon/dj_pipeline/utils/video.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/utils/video.py b/aeon/dj_pipeline/utils/video.py index 70d1e1c7..c2ccdb13 100644 --- a/aeon/dj_pipeline/utils/video.py +++ b/aeon/dj_pipeline/utils/video.py @@ -5,8 +5,6 @@ import datetime import cv2 -from aeon.dj_pipeline import acquisition - from aeon.io import api as io_api from aeon.io import video as io_video import aeon.io.reader as io_reader @@ -22,6 +20,8 @@ def retrieve_video_frames( chunk_size=50, **kwargs, ): + from aeon.dj_pipeline import acquisition + raw_data_dir = acquisition.Experiment.get_data_directory( {"experiment_name": experiment_name} ) From 668d9b7901ce74add12b338651dada786fdd189d Mon Sep 17 00:00:00 2001 From: JaerongA Date: Thu, 27 Apr 2023 04:50:22 +0000 Subject: [PATCH 242/489] add device removal time for each device table --- aeon/dj_pipeline/utils/load_metadata.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 2b66bb83..534acf65 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -182,7 +182,7 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): """ Make entries into device tables """ - + if experiment_name.startswith("oct"): ingest_epoch_metadata_octagon(experiment_name, metadata_yml_filepath) return @@ -281,12 +281,18 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): table.Attribute.insert(table_attribute_entry, ignore_extra_fields=True) # Remove the currently installed devices that are absent in this config - device_removal_list.extend( - (table - table.RemovalTime - device_list & experiment_key - ).fetch("KEY")) + for device_type in streams.DeviceType.fetch("device_type"): + table = getattr(streams, device_type) - if device_removal_list: - table.RemovalTime.insert(device_removal_list, ignore_extra_fields=True) + device_removal_list.extend( + (table - table.RemovalTime - device_list & experiment_key + ).fetch("KEY")) + + if device_removal_list: + try: + table.RemovalTime.insert(device_removal_list, ignore_extra_fields=True) + except: + pass # region Get stream & device information From e340e64ee3ba4264ce5eee9e8f74511d9df3cf73 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Thu, 27 Apr 2023 04:53:37 +0000 Subject: [PATCH 243/489] fix streams populate error --- aeon/dj_pipeline/streams.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index 435bd441..d76a8767 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -77,7 +77,7 @@ class ExperimentDevice(dj.Manual): definition = f""" # {device_title} placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-{aeon.__version__}) -> acquisition.Experiment - -> streams.Device + -> Device {device_type}_install_time : datetime(6) # time of the {device_type} placed and started operation at this position --- {device_type}_name : varchar(36) @@ -105,10 +105,10 @@ class RemovalTime(dj.Part): def get_device_stream_template(device_type: str, stream_type: str): """Returns table class template for DeviceDataStream""" - - ExperimentDevice = get_device_template(device_type) - exp_device_table_name = f"{device_type}" - + + context = inspect.currentframe().f_back.f_locals["context"] + ExperimentDevice = context[device_type] + # DeviceDataStream table(s) stream_detail = ( StreamType @@ -144,15 +144,15 @@ class DeviceDataStream(dj.Imported): @property def key_source(self): f""" - Only the combination of Chunk and {exp_device_table_name} with overlapping time - + Chunk(s) that started after {exp_device_table_name} install time and ended before {exp_device_table_name} remove time - + Chunk(s) that started after {exp_device_table_name} install time for {exp_device_table_name} that are not yet removed + Only the combination of Chunk and {device_type} with overlapping time + + Chunk(s) that started after {device_type} install time and ended before {device_type} remove time + + Chunk(s) that started after {device_type} install time for {device_type} that are not yet removed """ return ( acquisition.Chunk * ExperimentDevice.join(ExperimentDevice.RemovalTime, left=True) - & f"chunk_start >= {device_type}_install_time" - & f'chunk_start < IFNULL({device_type}_remove_time, "2200-01-01")' + & f"chunk_start >= {dj.utils.from_camel_case(device_type)}_install_time" + & f'chunk_start < IFNULL({dj.utils.from_camel_case(device_type)}_removal_time, "2200-01-01")' ) def make(self, key): @@ -163,7 +163,7 @@ def make(self, key): key, directory_type=dir_type ) - device_name = (ExperimentDevice & key).fetch1(f"{device_type}_name") + device_name = (ExperimentDevice & key).fetch1(f"{dj.utils.from_camel_case(device_type)}_name") stream = self._stream_reader( **{ @@ -200,16 +200,17 @@ def make(self, key): # endregion -def main(): - context = inspect.currentframe().f_back.f_locals +def main(context=None): + if context is None: + context = inspect.currentframe().f_back.f_locals # Create tables. for device_info in (DeviceType).fetch(as_dict=True): table_class = get_device_template(device_info["device_type"]) context[table_class.__name__] = table_class schema(table_class, context=context) - + # Create DeviceDataStream tables. for device_info in (DeviceType.Stream).fetch(as_dict=True): table_class = get_device_stream_template( @@ -217,3 +218,5 @@ def main(): ) context[table_class.__name__] = table_class schema(table_class, context=context) + +main() \ No newline at end of file From e29c1d18c623d81ee8388cdea08c5cd7bf4fe5d8 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Thu, 27 Apr 2023 04:54:09 +0000 Subject: [PATCH 244/489] fix circular import error --- aeon/dj_pipeline/acquisition.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 513c960f..b4163420 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -12,11 +12,6 @@ from . import get_schema_name, lab, subject from .utils import paths -from .utils.load_metadata import ( - extract_epoch_config, - ingest_epoch_metadata, - insert_device_types, -) logger = dj.logger schema = dj.schema(get_schema_name("acquisition")) @@ -275,7 +270,14 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): - if not specified, ingest all epochs Note: "start" and "end" are datetime specified a string in the format: "%Y-%m-%d %H:%M:%S" """ - from aeon.dj_pipeline import acquisition, streams + from aeon.dj_pipeline import streams + + from .utils.load_metadata import ( + extract_epoch_config, + ingest_epoch_metadata, + insert_device_types, + ) + device_name = _ref_device_mapping.get(experiment_name, "CameraTop") all_chunks, raw_data_dirs = _get_all_chunks(experiment_name, device_name) @@ -354,13 +356,14 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): if epoch_config: cls.Config.insert1(epoch_config) if metadata_yml_filepath and metadata_yml_filepath.exists(): + try: # Ingest streams.DeviceType, streams.Device and create device tables. insert_device_types( _device_schema_mapping[epoch_key["experiment_name"]], metadata_yml_filepath, ) - streams.main() # create device tables under streams schema + streams.main(context=streams.__dict__) # create device tables under streams schema with cls.connection.transaction: ingest_epoch_metadata( experiment_name, metadata_yml_filepath From 4793d8cdcb820ae82d7fa6f5a79ad33d70c15fb7 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 28 Apr 2023 23:44:59 +0000 Subject: [PATCH 245/489] fix bugs in device removal time entry --- aeon/dj_pipeline/utils/load_metadata.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 534acf65..0650a8f7 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -281,20 +281,20 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): table.Attribute.insert(table_attribute_entry, ignore_extra_fields=True) # Remove the currently installed devices that are absent in this config + device_removal = lambda device_type, device_entry: any(dj.utils. from_camel_case(device_type) in k for k in device_entry) # returns True if the device type is found in the attribute name + for device_type in streams.DeviceType.fetch("device_type"): table = getattr(streams, device_type) - + device_removal_list.extend( (table - table.RemovalTime - device_list & experiment_key - ).fetch("KEY")) + ).fetch("KEY")) # could be VideoSource or Patch + + for device_entry in device_removal_list: + if device_removal(device_type, device_entry): + table.RemovalTime.insert1(device_entry) + - if device_removal_list: - try: - table.RemovalTime.insert(device_removal_list, ignore_extra_fields=True) - except: - pass - - # region Get stream & device information def get_stream_entries(schema: DotMap) -> list[dict]: """Returns a list of dictionaries containing the stream entries for a given device, From ae9c66b88fa9d59e40f38f9b7049327d57ab99b5 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 28 Apr 2023 22:07:58 -0500 Subject: [PATCH 246/489] insertion into part tables --- .../dj_pipeline/webapps/sciviz/specsheet.yaml | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index c666c44c..df632159 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -1,7 +1,8 @@ version: "v0.1.0" LabBook: null SciViz: - auth: True + auth: + mode: "database" component_interface: override: | from datetime import datetime @@ -588,7 +589,7 @@ SciViz: input: Arena Description destination: arena_description - Subject Entry: + SubjectEntry: route: /subject_form x: 0 y: 0.4 @@ -636,3 +637,23 @@ SciViz: - type: table input: Lab Location destination: aeon_lab.Location + + Experiment Subjet Entry: + route: /exp_subject_form + x: 0 + y: 1.1 + height: 0.4 + width: 1 + type: form + tables: + - aeon_acquisition.Experiment.Subject + + Experiment Directory Entry: + route: /exp_subject_dir_form + x: 0 + y: 1.5 + height: 0.4 + width: 1 + type: form + tables: + - aeon_acquisition.Experiment.Directory From e7d4cbefd29c0ee44297b15ed3f9ba6f0f1848eb Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 1 May 2023 15:14:15 -0500 Subject: [PATCH 247/489] bug fix in sciviz docker-compose local --- .../webapps/sciviz/docker-compose-local.yaml | 51 +++++++------------ 1 file changed, 17 insertions(+), 34 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml index 15709f2b..f153720c 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml @@ -7,19 +7,23 @@ services: pharus: cpus: 2.0 mem_limit: 4g - image: datajoint/pharus:0.7.3 + image: jverswijver/pharus:0.8.5-PY_VER-3.9 environment: # - FLASK_ENV=development # enables logging to console from Flask - - PHARUS_SPEC_PATH=/main/specs/specsheet.yaml # for dynamic utils spec + - PHARUS_SPEC_PATH=/main/specsheet.yaml # for dynamic utils spec - PHARUS_MODE=DEV - user: ${HOST_UID}:anaconda + user: root volumes: - - ./specsheet.yaml:/main/specs/specsheet.yaml #copy the spec over to /main/specs/YOUR_SPEC_NAME + - ./specsheet.yaml:/main/specsheet.yaml #copy the spec over to /main/specs/YOUR_SPEC_NAME - ./apk_requirements.txt:/tmp/apk_requirements.txt + - /ceph/aeon/aeon:/ceph/aeon/aeon command: - sh - -c - | + apk add --update git g++ && + git clone -b datajoint_pipeline https://github.com/ttngu207/aeon_mecha.git && + pip install -e ./aeon_mecha && pharus_update() { [ -z "$$GUNICORN_PID" ] || kill $$GUNICORN_PID gunicorn --bind 0.0.0.0:$${PHARUS_PORT} pharus.server:app & @@ -47,13 +51,15 @@ services: - main sci-viz: cpus: 2.0 - mem_limit: 4g - image: datajoint/sci-viz:1.1.1 + mem_limit: 16g + image: datajoint/sci-viz:2.3.2 environment: - CHOKIDAR_USEPOLLING=true - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/api - - DJSCIVIZ_SPEC_PATH=specsheet.yaml + - DJSCIVIZ_SPEC_PATH=/main/specsheet.yaml - DJSCIVIZ_MODE=DEV + - NODE_OPTIONS="--max-old-space-size=12000" + user: root volumes: - ./specsheet.yaml:/main/specsheet.yaml # ports: @@ -62,31 +68,7 @@ services: - sh - -c - | - sciviz_update() { - [ -z "$$NGINX_PID" ] || kill $$NGINX_PID - rm -R /usr/share/nginx/html - python frontend_gen.py - yarn build - mv ./build /usr/share/nginx/html - nginx -g "daemon off;" & - NGINX_PID=$$! - } - sciviz_update - echo "[$$(date -u '+%Y-%m-%d %H:%M:%S')][DataJoint]: Monitoring SciViz updates..." - INIT_TIME=$$(date +%s) - LAST_MOD_TIME=$$(date -r $$DJSCIVIZ_SPEC_PATH +%s) - DELTA=$$(expr $$LAST_MOD_TIME - $$INIT_TIME) - while true; do - CURR_LAST_MOD_TIME=$$(date -r $$DJSCIVIZ_SPEC_PATH +%s) - CURR_DELTA=$$(expr $$CURR_LAST_MOD_TIME - $$INIT_TIME) - if [ "$$DELTA" -lt "$$CURR_DELTA" ]; then - echo "[$$(date -u '+%Y-%m-%d %H:%M:%S')][DataJoint]: Reloading SciViz since \`$$DJSCIVIZ_SPEC_PATH\` changed." - sciviz_update - DELTA=$$CURR_DELTA - else - sleep 5 - fi - done + sh sci-viz-hotreload-dev.sh networks: - main fakeservices.datajoint.io: @@ -98,10 +80,11 @@ services: - ADD_sciviz_TYPE=REST - ADD_sciviz_ENDPOINT=sci-viz:3000 - ADD_sciviz_PREFIX=/ - - HTTPS_PASSTHRU=TRUE + # - HTTPS_PASSTHRU=TRUE + - DEPLOYMENT_PORT ports: - "443:443" - - "80:80" + - "${DEPLOYMENT_PORT:-80}:80" networks: - main networks: From c1b580c2be096c36ae0c2989f000b70aa1443642 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 1 May 2023 15:23:32 -0500 Subject: [PATCH 248/489] modify specsheet attribute name mapping in part tables --- .../dj_pipeline/webapps/sciviz/specsheet.yaml | 56 +++++++++++++++---- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index df632159..1f390463 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -69,6 +69,16 @@ SciViz: - type: attribute input: Note destination: note + # component_templates: + # Colony Table: + # route: /colony_meta + # type: metadata + # restriction: > + # def restriction(**kwargs): + # return dict(**kwargs) + # dj_query: > + # def dj_query(aeon_lab): + # return dict(query=aeon_lab.Colony(), fetch_args=[]) Subjects: route: /subjects @@ -564,7 +574,7 @@ SciViz: route: /lab_form x: 0 y: 0 - height: 0.4 + height: 0.5 width: 1 type: form tables: @@ -589,10 +599,10 @@ SciViz: input: Arena Description destination: arena_description - SubjectEntry: + Subject Entry: route: /subject_form x: 0 - y: 0.4 + y: 0.5 height: 0.3 width: 1 type: form @@ -615,15 +625,15 @@ SciViz: Experiment Entry: route: /exp_form x: 0 - y: 0.7 - height: 0.4 + y: 0.8 + height: 0.5 width: 1 type: form tables: - aeon_acquisition.Experiment map: - type: attribute - input: Experiment ID + input: Experiment Name destination: experiment_name - type: attribute input: Start Time @@ -637,23 +647,47 @@ SciViz: - type: table input: Lab Location destination: aeon_lab.Location + - type: attribute + input: Experiment Type + destination: experiment_type - Experiment Subjet Entry: + Experiment Subject Entry: route: /exp_subject_form x: 0 - y: 1.1 - height: 0.4 + y: 1.3 + height: 0.3 width: 1 type: form tables: - aeon_acquisition.Experiment.Subject + map: + - type: table + input: Experiment Name + destination: aeon_acquisition.Experiment + - type: table + input: Subject in the experiment + destination: aeon_subject.Subject Experiment Directory Entry: route: /exp_subject_dir_form x: 0 - y: 1.5 - height: 0.4 + y: 1.6 + height: 0.3 + width: 1 type: form tables: - aeon_acquisition.Experiment.Directory + map: + - type: table + input: Directory Type + destination: aeon_acquisition.DirectoryType + - type: table + input: Pipeline Repository + destination: aeon_acquisition.PipelineRepository + - type: table + input: Experiment Name + destination: aeon_acquisition.Experiment + - type: attribute + input: Path to Experiment Directory + destination: directory_path From d47c550f6789bdf9fa665bd0287e8193aab0cb76 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 1 May 2023 15:53:54 -0500 Subject: [PATCH 249/489] update docker-compose-remote-yaml --- .../webapps/sciviz/docker-compose-remote.yaml | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml index 06d32761..d68918d0 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml @@ -6,18 +6,22 @@ services: pharus: cpus: 2.0 mem_limit: 4g - image: datajoint/pharus:0.7.3 + image: jverswijver/pharus:0.8.5-PY_VER-3.9 environment: # - FLASK_ENV=development # enables logging to console from Flask - - PHARUS_SPEC_PATH=/main/specs/specsheet.yaml # for dynamic utils spec - user: ${HOST_UID}:anaconda + - PHARUS_SPEC_PATH=/main/specsheet.yaml # for dynamic utils spec + user: root volumes: - - ./specsheet.yaml:/main/specs/specsheet.yaml #copy the spec over to /main/specs/YOUR_SPEC_NAME + - ./specsheet.yaml:/main/specsheet.yaml #copy the spec over to /main/specs/YOUR_SPEC_NAME - ./apk_requirements.txt:/tmp/apk_requirements.txt + - /ceph/aeon/aeon:/ceph/aeon/aeon command: - sh - -c - | + apk add --update git g++ && + git clone -b datajoint_pipeline https://github.com/ttngu207/aeon_mecha.git && + pip install -e ./aeon_mecha && gunicorn --bind 0.0.0.0:$${PHARUS_PORT} pharus.server:app # ports: # - "5000:5000" @@ -25,12 +29,13 @@ services: - main sci-viz: cpus: 2.0 - mem_limit: 4g - image: datajoint/sci-viz:1.1.1 + mem_limit: 16g + image: datajoint/sci-viz:2.3.2 environment: - CHOKIDAR_USEPOLLING=true - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/aeon/utils - - DJSCIVIZ_SPEC_PATH=specsheet.yaml + - DJSCIVIZ_SPEC_PATH=/main/specsheet.yaml + - NODE_OPTIONS="--max-old-space-size=12000" volumes: - ./specsheet.yaml:/main/specsheet.yaml # ports: @@ -39,10 +44,7 @@ services: - sh - -c - | - python frontend_gen.py - npm run build - mv ./build /usr/share/nginx/html - nginx -g "daemon off;" + sh sci-viz-hotreload-prod.sh networks: - main reverse-proxy: From 4184863b634e62fadc5aecd8f5888f49b252e6e9 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 16 May 2023 15:49:07 -0500 Subject: [PATCH 250/489] fix: :bug: sciviz bug fix --- .../webapps/sciviz/docker-compose-remote.yaml | 15 ++++++++------- aeon/dj_pipeline/webapps/sciviz/specsheet.yaml | 1 + 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml index d68918d0..6975afda 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml @@ -30,12 +30,13 @@ services: sci-viz: cpus: 2.0 mem_limit: 16g - image: datajoint/sci-viz:2.3.2 + image: jverswijver/sci-viz:2.3.3-hotfix2 environment: - CHOKIDAR_USEPOLLING=true - - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/aeon/utils + - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/api - DJSCIVIZ_SPEC_PATH=/main/specsheet.yaml - NODE_OPTIONS="--max-old-space-size=12000" + user: root volumes: - ./specsheet.yaml:/main/specsheet.yaml # ports: @@ -48,15 +49,15 @@ services: networks: - main reverse-proxy: - image: datajoint/nginx:v0.2.4 + image: datajoint/nginx:v0.2.5 environment: - ADD_pharus_TYPE=REST - ADD_pharus_ENDPOINT=pharus:5000 - - ADD_pharus_PREFIX=/utils + - ADD_pharus_PREFIX=/api - ADD_sciviz_TYPE=REST - ADD_sciviz_ENDPOINT=sci-viz:3000 - ADD_sciviz_PREFIX=/ - # - HTTPS_PASSTHRU=TRUE + # - HTTPS_PASSTHRU=TRUE # - CERTBOT_HOST=letsencrypt:80 - DEPLOYMENT_PORT # - SUBDOMAINS @@ -64,8 +65,8 @@ services: # volumes: # - ./letsencrypt-keys:/etc/letsencrypt:ro ports: - # - "443:443" - - "${DEPLOYMENT_PORT}:80" + # - "443:443" + - "8443:80" networks: - main networks: diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 1f390463..3dee1e19 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -1,6 +1,7 @@ version: "v0.1.0" LabBook: null SciViz: + route: /aeon auth: mode: "database" component_interface: From 11db552748d26d828a6ab3c4a03928a366a9ff65 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 6 Jun 2023 20:48:50 +0000 Subject: [PATCH 251/489] add device table definitions posthoc --- aeon/dj_pipeline/streams.py | 111 ++++++++++++++++++++++++++++++++---- 1 file changed, 99 insertions(+), 12 deletions(-) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index d76a8767..518e8bcd 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -1,4 +1,5 @@ import inspect +import os import datajoint as dj import pandas as pd @@ -196,27 +197,113 @@ def make(self, key): return DeviceDataStream - # endregion +def get_device_table_definition(device_template): + """Returns table definition for ExperimentDevice. + + Args: + device_type (str): Device type (e.g., Patch, VideoSource) + + Returns: + device_table_def (str): Table definition for ExperimentDevice. + """ + + replacements = { + "ExperimentDevice": device_type, + "{device_title}": dj.utils.from_camel_case(device_type), + "{device_type}": dj.utils.from_camel_case(device_type), + "{aeon.__version__}": aeon.__version__ + } + + for old, new in replacements.items(): + device_table_def = device_table_def.replace(old, new) + return device_table_def + "\n\n" def main(context=None): + import re if context is None: context = inspect.currentframe().f_back.f_locals - # Create tables. + # Create DeviceType tables. for device_info in (DeviceType).fetch(as_dict=True): - table_class = get_device_template(device_info["device_type"]) - context[table_class.__name__] = table_class - schema(table_class, context=context) - + if device_info["device_type"] not in locals(): + table_class = get_device_template(device_info["device_type"]) + context[table_class.__name__] = table_class + schema(table_class, context=context) + + device_table_def = inspect.getsource(table_class) + replacements = { + "ExperimentDevice": device_info["device_type"], + "{device_title}": dj.utils.from_camel_case(device_info["device_type"]), + "{device_type}": dj.utils.from_camel_case(device_info["device_type"]), + "{aeon.__version__}": aeon.__version__ + } + for old, new in replacements.items(): + device_table_def = device_table_def.replace(old, new) + full_def = "\t@schema \n" + device_table_def + "\n\n" + if os.path.exists("existing_module.py"): + with open("existing_module.py", "r") as f: + existing_content = f.read() + + if full_def in existing_content: + continue + + with open("existing_module.py", "a") as f: + f.write(full_def) + else: + with open("existing_module.py", "w") as f: + full_def = """import datajoint as dj\n\n""" + full_def + f.write(full_def) + # Create DeviceDataStream tables. for device_info in (DeviceType.Stream).fetch(as_dict=True): - table_class = get_device_stream_template( - device_info["device_type"], device_info["stream_type"] - ) - context[table_class.__name__] = table_class - schema(table_class, context=context) - + + device_type = device_info['device_type'] + stream_type = device_info['stream_type'] + table_name = f"{device_type}{stream_type}" + + if table_name not in locals(): + table_class = get_device_stream_template( + device_type, stream_type) + context[table_class.__name__] = table_class + schema(table_class, context=context) + + device_stream_table_def = inspect.getsource(table_class) + + old_definition = f""" # Raw per-chunk {stream_type} data stream from {device_type} (auto-generated with aeon_mecha-{aeon.__version__}) + -> {device_type} + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of {stream_type} data + """ + + replacements = { + "DeviceDataStream": f"{device_type}{stream_type}","ExperimentDevice": device_type, + 'f"chunk_start >= {dj.utils.from_camel_case(device_type)}_install_time"': f"'chunk_start >= {device_type}_install_time'", + 'f\'chunk_start < IFNULL({dj.utils.from_camel_case(device_type)}_removal_time, "2200-01-01")\'': f'chunk_start < IFNULL({device_type}_removal_time, "2200-01-01")', + "{stream_type}": stream_type, + "{aeon.__version__}": aeon.__version__, + } + for old, new in replacements.items(): + new_definition = old_definition.replace(old, new) + + replacements["table_definition"] = '"""'+new_definition+'"""' + + for old, new in replacements.items(): + device_stream_table_def = device_stream_table_def.replace(old, new) + + full_def = "\t@schema \n" + device_stream_table_def + "\n\n" + + with open("existing_module.py", "r") as f: + existing_content = f.read() + + if full_def in existing_content: + continue + + with open("existing_module.py", "a") as f: + f.write(full_def) + main() \ No newline at end of file From b46fda73cfe4d1099556515639f6a6805d0c0c19 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 28 Jun 2023 15:12:10 +0000 Subject: [PATCH 252/489] add device type mapper --- aeon/dj_pipeline/create_experiments/device_type_mapper.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/create_experiments/device_type_mapper.json b/aeon/dj_pipeline/create_experiments/device_type_mapper.json index 028ae594..5ffbee8f 100644 --- a/aeon/dj_pipeline/create_experiments/device_type_mapper.json +++ b/aeon/dj_pipeline/create_experiments/device_type_mapper.json @@ -1 +1 @@ -{"VideoController": "VideoController", "CameraTop": "VideoSource", "CameraWest": "VideoSource", "CameraEast": "VideoSource", "CameraNorth": "VideoSource", "CameraSouth": "VideoSource", "CameraPatch1": "VideoSource", "CameraPatch2": "VideoSource", "CameraNest": "VideoSource", "AudioAmbient": "AudioSource", "Patch1": "Patch", "Patch2": "Patch", "WeightNest": "WeightScale", "TrackingTop": "PositionTracking", "ActivityCenter": "ActivityTracking", "ActivityArena": "ActivityTracking", "ActivityNest": "ActivityTracking", "ActivityPatch1": "ActivityTracking", "ActivityPatch2": "ActivityTracking", "InNest": "RegionTracking", "InPatch1": "RegionTracking", "InPatch2": "RegionTracking", "ArenaCenter": "DistanceFromPoint", "InArena": "InRange", "InCorridor": "InRange", "ClockSynchronizer": null} \ No newline at end of file +{"VideoController": "VideoController", "CameraTop": "VideoSource", "CameraWest": "VideoSource", "CameraEast": "VideoSource", "CameraNorth": "VideoSource", "CameraSouth": "VideoSource", "CameraPatch1": "VideoSource", "CameraPatch2": "VideoSource", "CameraNest": "VideoSource", "AudioAmbient": "AudioSource", "Patch1": "UndergroundFeeder", "Patch2": "UndergroundFeeder", "WeightNest": "WeightScale", "TrackingTop": "PositionTracking", "ActivityCenter": "ActivityTracking", "ActivityArena": "ActivityTracking", "ActivityNest": "ActivityTracking", "ActivityPatch1": "ActivityTracking", "ActivityPatch2": "ActivityTracking", "InNest": "RegionTracking", "InPatch1": "RegionTracking", "InPatch2": "RegionTracking", "ArenaCenter": "DistanceFromPoint", "InArena": "InRange", "InCorridor": "InRange", "ClockSynchronizer": "Synchronizer"} \ No newline at end of file From 95c8dfcbab0a531bd2e92786d794cb902a479fff Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 28 Jun 2023 15:12:47 +0000 Subject: [PATCH 253/489] allow ingestion from multiple machines --- aeon/dj_pipeline/acquisition.py | 23 ++++++++++------ ...te_presocial_a2.py => create_presocial.py} | 27 ++++++++----------- 2 files changed, 26 insertions(+), 24 deletions(-) rename aeon/dj_pipeline/create_experiments/{create_presocial_a2.py => create_presocial.py} (64%) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index b4163420..585cf2bc 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -25,6 +25,8 @@ "exp0.2-r0": "CameraTop", "oct1.0-r0": "CameraTop", "presocial0.1-a2": "CameraTop", + "presocial0.1-a3": "CameraTop", + "presocial0.1-a4": "CameraTop", } _device_schema_mapping = { @@ -33,6 +35,8 @@ "exp0.2-r0": aeon_schema.exp02, "oct1.0-r0": aeon_schema.octagon01, "presocial0.1-a2": aeon_schema.presocial, + "presocial0.1-a3": aeon_schema.presocial, + "presocial0.1-a4": aeon_schema.presocial, } @@ -120,14 +124,17 @@ class Directory(dj.Part): @classmethod def get_data_directory(cls, experiment_key, directory_type="raw", as_posix=False): - repo_name, dir_path = ( - cls.Directory & experiment_key & {"directory_type": directory_type} - ).fetch1("repository_name", "directory_path") - data_directory = paths.get_repository_path(repo_name) / dir_path - if not data_directory.exists(): - return None - return data_directory.as_posix() if as_posix else data_directory - + + try: + repo_name, dir_path = ( + cls.Directory & experiment_key & {"directory_type": directory_type} + ).fetch1("repository_name", "directory_path") + data_directory = paths.get_repository_path(repo_name) / dir_path + if not data_directory.exists(): + return None + return data_directory.as_posix() if as_posix else data_directory + except dj.errors.DataJointError: + return @classmethod def get_data_directories( cls, experiment_key, directory_types=["raw"], as_posix=False diff --git a/aeon/dj_pipeline/create_experiments/create_presocial_a2.py b/aeon/dj_pipeline/create_experiments/create_presocial.py similarity index 64% rename from aeon/dj_pipeline/create_experiments/create_presocial_a2.py rename to aeon/dj_pipeline/create_experiments/create_presocial.py index 74d09a5c..038d5927 100644 --- a/aeon/dj_pipeline/create_experiments/create_presocial_a2.py +++ b/aeon/dj_pipeline/create_experiments/create_presocial.py @@ -1,9 +1,9 @@ from aeon.dj_pipeline import acquisition, lab, subject -experiment_type = "presocial" -experiment_name = "presocial0.1-a2" # AEON2 acquisition computer +experiment_type = "presocial0.1" +experiment_names = ["presocial0.1-a2", "presocial0.1-a3", "presocial0.1-a4"] location = "4th floor" - +computers = ["AEON2", "AEON3", "AEON4"] def create_new_experiment(): @@ -13,22 +13,23 @@ def create_new_experiment(): {"experiment_type": experiment_type}, skip_duplicates=True ) - acquisition.Experiment.insert1( - { + acquisition.Experiment.insert( + [{ "experiment_name": experiment_name, "experiment_start_time": "2023-02-25 00:00:00", - "experiment_description": "presocial experiment 0.1 in aeon2", + "experiment_description": "presocial experiment 0.1", "arena_name": "circle-2m", "lab": "SWC", "location": location, - "experiment_type": experiment_type, - }, + "experiment_type": experiment_type + } for experiment_name in experiment_names], skip_duplicates=True, ) acquisition.Experiment.Subject.insert( [ {"experiment_name": experiment_name, "subject": s} + for experiment_name in experiment_names for s in subject.Subject.fetch("subject") ], skip_duplicates=True, @@ -40,14 +41,8 @@ def create_new_experiment(): "experiment_name": experiment_name, "repository_name": "ceph_aeon", "directory_type": "raw", - "directory_path": "aeon/data/raw/AEON2/presocial0.1", - }, - { - "experiment_name": experiment_name, - "repository_name": "ceph_aeon", - "directory_type": "quality-control", - "directory_path": "aeon/data/qc/AEON2/presocial0.1", - }, + "directory_path": f"aeon/data/raw/{computer}/{experiment_type}" + } for experiment_name, computer in zip(experiment_names, computers) ], skip_duplicates=True, ) From ef71dc8490460de863ecf17b7f6f28c44a64abbd Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 28 Jun 2023 15:13:18 +0000 Subject: [PATCH 254/489] add device table definition --- aeon/dj_pipeline/streams.py | 30 ++++++------------------------ 1 file changed, 6 insertions(+), 24 deletions(-) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index 518e8bcd..1fb23604 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -198,30 +198,10 @@ def make(self, key): return DeviceDataStream # endregion -def get_device_table_definition(device_template): - """Returns table definition for ExperimentDevice. - - Args: - device_type (str): Device type (e.g., Patch, VideoSource) - - Returns: - device_table_def (str): Table definition for ExperimentDevice. - """ - - replacements = { - "ExperimentDevice": device_type, - "{device_title}": dj.utils.from_camel_case(device_type), - "{device_type}": dj.utils.from_camel_case(device_type), - "{aeon.__version__}": aeon.__version__ - } - - for old, new in replacements.items(): - device_table_def = device_table_def.replace(old, new) - return device_table_def + "\n\n" - def main(context=None): + import re if context is None: context = inspect.currentframe().f_back.f_locals @@ -254,7 +234,7 @@ def main(context=None): f.write(full_def) else: with open("existing_module.py", "w") as f: - full_def = """import datajoint as dj\n\n""" + full_def + full_def = """import datajoint as dj\nimport pandas as pd\nfrom aeon.dj_pipeline import acquisition\nfrom aeon.io import api as io_api\n\n""" + full_def f.write(full_def) # Create DeviceDataStream tables. @@ -282,8 +262,10 @@ def main(context=None): replacements = { "DeviceDataStream": f"{device_type}{stream_type}","ExperimentDevice": device_type, - 'f"chunk_start >= {dj.utils.from_camel_case(device_type)}_install_time"': f"'chunk_start >= {device_type}_install_time'", - 'f\'chunk_start < IFNULL({dj.utils.from_camel_case(device_type)}_removal_time, "2200-01-01")\'': f'chunk_start < IFNULL({device_type}_removal_time, "2200-01-01")', + 'f"chunk_start >= {dj.utils.from_camel_case(device_type)}_install_time"': f"'chunk_start >= {dj.utils.from_camel_case(device_type)}_install_time'", + """f'chunk_start < IFNULL({dj.utils.from_camel_case(device_type)}_removal_time, "2200-01-01")'""": f"""'chunk_start < IFNULL({dj.utils.from_camel_case(device_type)}_removal_time, "2200-01-01")'""", + 'f"{dj.utils.from_camel_case(device_type)}_name"': f"'{dj.utils.from_camel_case(device_type)}_name'", + "{device_type}": device_type, "{stream_type}": stream_type, "{aeon.__version__}": aeon.__version__, } From 0c5ec8ca2d6f73532776422b5d45ce10218f99b1 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Thu, 29 Jun 2023 19:10:09 +0000 Subject: [PATCH 255/489] add stream object --- aeon/dj_pipeline/streams.py | 43 +++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index 1fb23604..fbdb93d8 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -125,12 +125,12 @@ def get_device_stream_template(device_type: str, stream_type: str): stream = reader(**stream_detail["stream_reader_kwargs"]) table_definition = f""" # Raw per-chunk {stream_type} data stream from {device_type} (auto-generated with aeon_mecha-{aeon.__version__}) - -> {device_type} - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of {stream_type} data - """ + -> {device_type} + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of {stream_type} data + """ for col in stream.columns: if col.startswith("_"): @@ -213,7 +213,7 @@ def main(context=None): context[table_class.__name__] = table_class schema(table_class, context=context) - device_table_def = inspect.getsource(table_class) + device_table_def = inspect.getsource(table_class).lstrip() replacements = { "ExperimentDevice": device_info["device_type"], "{device_title}": dj.utils.from_camel_case(device_info["device_type"]), @@ -222,7 +222,7 @@ def main(context=None): } for old, new in replacements.items(): device_table_def = device_table_def.replace(old, new) - full_def = "\t@schema \n" + device_table_def + "\n\n" + full_def = "@schema \n" + device_table_def + "\n\n" if os.path.exists("existing_module.py"): with open("existing_module.py", "r") as f: existing_content = f.read() @@ -234,7 +234,7 @@ def main(context=None): f.write(full_def) else: with open("existing_module.py", "w") as f: - full_def = """import datajoint as dj\nimport pandas as pd\nfrom aeon.dj_pipeline import acquisition\nfrom aeon.io import api as io_api\n\n""" + full_def + full_def = """import datajoint as dj\nimport pandas as pd\n\nimport aeon\nfrom aeon.dj_pipeline import acquisition\nfrom aeon.io import api as io_api\n\n""" + full_def f.write(full_def) # Create DeviceDataStream tables. @@ -250,15 +250,19 @@ def main(context=None): context[table_class.__name__] = table_class schema(table_class, context=context) - device_stream_table_def = inspect.getsource(table_class) + stream_obj = table_class.__dict__["_stream_reader"] + reader = stream_obj.__module__ + '.' + stream_obj.__name__ + stream_detail = table_class.__dict__["_stream_detail"] + + device_stream_table_def = inspect.getsource(table_class).lstrip() - old_definition = f""" # Raw per-chunk {stream_type} data stream from {device_type} (auto-generated with aeon_mecha-{aeon.__version__}) - -> {device_type} - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of {stream_type} data - """ + old_definition = f"""# Raw per-chunk {stream_type} data stream from {device_type} (auto-generated with aeon_mecha-{aeon.__version__}) + -> {device_type} + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of {stream_type} data + """ replacements = { "DeviceDataStream": f"{device_type}{stream_type}","ExperimentDevice": device_type, @@ -276,8 +280,11 @@ def main(context=None): for old, new in replacements.items(): device_stream_table_def = device_stream_table_def.replace(old, new) + + device_stream_table_def = re.sub(r'_stream_reader\s*=\s*reader', f'_stream_reader = {reader}', device_stream_table_def) # insert reader + device_stream_table_def = re.sub(r'_stream_detail\s*=\s*stream_detail', f'_stream_detail = {stream_detail}', device_stream_table_def) # insert stream details - full_def = "\t@schema \n" + device_stream_table_def + "\n\n" + full_def = "@schema \n" + device_stream_table_def + "\n\n" with open("existing_module.py", "r") as f: existing_content = f.read() From 52721f1388ce50d7f19d0d41161352e121b327e5 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 3 Jul 2023 20:41:53 +0000 Subject: [PATCH 256/489] sciviz bug fix --- aeon/dj_pipeline/webapps/sciviz/specsheet.yaml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 29a54cec..6ca93082 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -437,6 +437,7 @@ SciViz: return dict(**kwargs) dj_query: > def dj_query(aeon_acquisition): + acquisition = aeon_acquisition q = dj.U('camera_description') & acquisition.ExperimentCamera return {'query': q, 'fetch_args': ['camera_description']} time_range_selector: @@ -683,15 +684,15 @@ SciViz: tables: - aeon_acquisition.Experiment.Directory map: + - type: table + input: Experiment Name + destination: aeon_acquisition.Experiment - type: table input: Directory Type destination: aeon_acquisition.DirectoryType - type: table input: Pipeline Repository destination: aeon_acquisition.PipelineRepository - - type: table - input: Experiment Name - destination: aeon_acquisition.Experiment - type: attribute input: Path to Experiment Directory destination: directory_path From 93d0f3d2c1b42d3610e864ed5d6f38fdd150028a Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Mon, 3 Jul 2023 16:30:41 -0500 Subject: [PATCH 257/489] refactor - explicit `streams_maker` --- aeon/dj_pipeline/acquisition.py | 16 +- aeon/dj_pipeline/populate/worker.py | 6 +- aeon/dj_pipeline/streams.py | 250 +----------------- aeon/dj_pipeline/streams_maker.py | 331 ++++++++++++++++++++++++ aeon/dj_pipeline/utils/load_metadata.py | 82 +++--- 5 files changed, 400 insertions(+), 285 deletions(-) create mode 100644 aeon/dj_pipeline/streams_maker.py diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 585cf2bc..9a503a3a 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -15,7 +15,6 @@ logger = dj.logger schema = dj.schema(get_schema_name("acquisition")) -# streams = dj.VirtualModule("streams", get_schema_name("streams")) # ------------------- Some Constants -------------------------- @@ -124,7 +123,7 @@ class Directory(dj.Part): @classmethod def get_data_directory(cls, experiment_key, directory_type="raw", as_posix=False): - + try: repo_name, dir_path = ( cls.Directory & experiment_key & {"directory_type": directory_type} @@ -135,6 +134,7 @@ def get_data_directory(cls, experiment_key, directory_type="raw", as_posix=False return data_directory.as_posix() if as_posix else data_directory except dj.errors.DataJointError: return + @classmethod def get_data_directories( cls, experiment_key, directory_types=["raw"], as_posix=False @@ -277,14 +277,14 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): - if not specified, ingest all epochs Note: "start" and "end" are datetime specified a string in the format: "%Y-%m-%d %H:%M:%S" """ - from aeon.dj_pipeline import streams + from aeon.dj_pipeline import streams_maker from .utils.load_metadata import ( extract_epoch_config, ingest_epoch_metadata, insert_device_types, ) - + device_name = _ref_device_mapping.get(experiment_name, "CameraTop") all_chunks, raw_data_dirs = _get_all_chunks(experiment_name, device_name) @@ -363,15 +363,17 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): if epoch_config: cls.Config.insert1(epoch_config) if metadata_yml_filepath and metadata_yml_filepath.exists(): - + try: - # Ingest streams.DeviceType, streams.Device and create device tables. + # Insert new entries for streams.DeviceType, streams.Device. insert_device_types( _device_schema_mapping[epoch_key["experiment_name"]], metadata_yml_filepath, ) - streams.main(context=streams.__dict__) # create device tables under streams schema + # Define and instantiate new devices/stream tables under `streams` schema + streams_maker.main() with cls.connection.transaction: + # Insert devices' installation/removal/settings ingest_epoch_metadata( experiment_name, metadata_yml_filepath ) diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py index 7b1678dc..395023b0 100644 --- a/aeon/dj_pipeline/populate/worker.py +++ b/aeon/dj_pipeline/populate/worker.py @@ -12,11 +12,13 @@ db_prefix, qc, report, - streams, + streams_maker, tracking, ) from aeon.dj_pipeline.utils import load_metadata +streams = streams_maker.main() + __all__ = [ "high_priority", "mid_priority", @@ -98,5 +100,5 @@ ) for attr in vars(streams).values(): - if is_djtable(attr) and hasattr(attr, "populate"): + if is_djtable(attr, dj.user_tables.AutoPopulate): streams_worker(attr) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index fbdb93d8..f642a62f 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -1,5 +1,5 @@ -import inspect -import os +#---- DO NOT MODIFY ---- +#---- THIS FILE IS AUTO-GENERATED BY `streams_maker.py` ---- import datajoint as dj import pandas as pd @@ -8,17 +8,11 @@ from aeon.dj_pipeline import acquisition, get_schema_name from aeon.io import api as io_api -logger = dj.logger +schema_name = get_schema_name('streams') +schema = dj.Schema() -# schema_name = f'u_{dj.config["database.user"]}_streams' # for testing -schema_name = get_schema_name("streams") -schema = dj.schema(schema_name) - -schema.spawn_missing_classes() - - -@schema +@schema class StreamType(dj.Lookup): """ Catalog of all steam types for the different device types used across Project Aeon @@ -38,7 +32,7 @@ class StreamType(dj.Lookup): """ -@schema +@schema class DeviceType(dj.Lookup): """ Catalog of all device types used across Project Aeon @@ -57,7 +51,7 @@ class Stream(dj.Part): """ -@schema +@schema class Device(dj.Lookup): definition = """ # Physical devices, of a particular type, identified by unique serial number device_serial_number: varchar(12) @@ -66,233 +60,3 @@ class Device(dj.Lookup): """ -# region Helper functions for creating device tables. - - -def get_device_template(device_type: str): - """Returns table class template for ExperimentDevice""" - device_title = device_type - device_type = dj.utils.from_camel_case(device_type) - - class ExperimentDevice(dj.Manual): - definition = f""" - # {device_title} placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-{aeon.__version__}) - -> acquisition.Experiment - -> Device - {device_type}_install_time : datetime(6) # time of the {device_type} placed and started operation at this position - --- - {device_type}_name : varchar(36) - """ - - class Attribute(dj.Part): - definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device - -> master - attribute_name : varchar(32) - --- - attribute_value=null : longblob - """ - - class RemovalTime(dj.Part): - definition = f""" - -> master - --- - {device_type}_removal_time: datetime(6) # time of the {device_type} being removed - """ - - ExperimentDevice.__name__ = f"{device_title}" - - return ExperimentDevice - - -def get_device_stream_template(device_type: str, stream_type: str): - """Returns table class template for DeviceDataStream""" - - context = inspect.currentframe().f_back.f_locals["context"] - ExperimentDevice = context[device_type] - - # DeviceDataStream table(s) - stream_detail = ( - StreamType - & (DeviceType.Stream & {"device_type": device_type, "stream_type": stream_type}) - ).fetch1() - - for i, n in enumerate(stream_detail["stream_reader"].split(".")): - if i == 0: - reader = aeon - else: - reader = getattr(reader, n) - - stream = reader(**stream_detail["stream_reader_kwargs"]) - - table_definition = f""" # Raw per-chunk {stream_type} data stream from {device_type} (auto-generated with aeon_mecha-{aeon.__version__}) - -> {device_type} - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of {stream_type} data - """ - - for col in stream.columns: - if col.startswith("_"): - continue - table_definition += f"{col}: longblob\n\t\t\t" - - class DeviceDataStream(dj.Imported): - definition = table_definition - _stream_reader = reader - _stream_detail = stream_detail - - @property - def key_source(self): - f""" - Only the combination of Chunk and {device_type} with overlapping time - + Chunk(s) that started after {device_type} install time and ended before {device_type} remove time - + Chunk(s) that started after {device_type} install time for {device_type} that are not yet removed - """ - return ( - acquisition.Chunk - * ExperimentDevice.join(ExperimentDevice.RemovalTime, left=True) - & f"chunk_start >= {dj.utils.from_camel_case(device_type)}_install_time" - & f'chunk_start < IFNULL({dj.utils.from_camel_case(device_type)}_removal_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory( - key, directory_type=dir_type - ) - - device_name = (ExperimentDevice & key).fetch1(f"{dj.utils.from_camel_case(device_type)}_name") - - stream = self._stream_reader( - **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) - - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{ - c: stream_data[c].values - for c in stream.columns - if not c.startswith("_") - }, - } - ) - - DeviceDataStream.__name__ = f"{device_type}{stream_type}" - - return DeviceDataStream - -# endregion - - -def main(context=None): - - import re - if context is None: - context = inspect.currentframe().f_back.f_locals - - # Create DeviceType tables. - for device_info in (DeviceType).fetch(as_dict=True): - if device_info["device_type"] not in locals(): - table_class = get_device_template(device_info["device_type"]) - context[table_class.__name__] = table_class - schema(table_class, context=context) - - device_table_def = inspect.getsource(table_class).lstrip() - replacements = { - "ExperimentDevice": device_info["device_type"], - "{device_title}": dj.utils.from_camel_case(device_info["device_type"]), - "{device_type}": dj.utils.from_camel_case(device_info["device_type"]), - "{aeon.__version__}": aeon.__version__ - } - for old, new in replacements.items(): - device_table_def = device_table_def.replace(old, new) - full_def = "@schema \n" + device_table_def + "\n\n" - if os.path.exists("existing_module.py"): - with open("existing_module.py", "r") as f: - existing_content = f.read() - - if full_def in existing_content: - continue - - with open("existing_module.py", "a") as f: - f.write(full_def) - else: - with open("existing_module.py", "w") as f: - full_def = """import datajoint as dj\nimport pandas as pd\n\nimport aeon\nfrom aeon.dj_pipeline import acquisition\nfrom aeon.io import api as io_api\n\n""" + full_def - f.write(full_def) - - # Create DeviceDataStream tables. - for device_info in (DeviceType.Stream).fetch(as_dict=True): - - device_type = device_info['device_type'] - stream_type = device_info['stream_type'] - table_name = f"{device_type}{stream_type}" - - if table_name not in locals(): - table_class = get_device_stream_template( - device_type, stream_type) - context[table_class.__name__] = table_class - schema(table_class, context=context) - - stream_obj = table_class.__dict__["_stream_reader"] - reader = stream_obj.__module__ + '.' + stream_obj.__name__ - stream_detail = table_class.__dict__["_stream_detail"] - - device_stream_table_def = inspect.getsource(table_class).lstrip() - - old_definition = f"""# Raw per-chunk {stream_type} data stream from {device_type} (auto-generated with aeon_mecha-{aeon.__version__}) - -> {device_type} - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of {stream_type} data - """ - - replacements = { - "DeviceDataStream": f"{device_type}{stream_type}","ExperimentDevice": device_type, - 'f"chunk_start >= {dj.utils.from_camel_case(device_type)}_install_time"': f"'chunk_start >= {dj.utils.from_camel_case(device_type)}_install_time'", - """f'chunk_start < IFNULL({dj.utils.from_camel_case(device_type)}_removal_time, "2200-01-01")'""": f"""'chunk_start < IFNULL({dj.utils.from_camel_case(device_type)}_removal_time, "2200-01-01")'""", - 'f"{dj.utils.from_camel_case(device_type)}_name"': f"'{dj.utils.from_camel_case(device_type)}_name'", - "{device_type}": device_type, - "{stream_type}": stream_type, - "{aeon.__version__}": aeon.__version__, - } - for old, new in replacements.items(): - new_definition = old_definition.replace(old, new) - - replacements["table_definition"] = '"""'+new_definition+'"""' - - for old, new in replacements.items(): - device_stream_table_def = device_stream_table_def.replace(old, new) - - device_stream_table_def = re.sub(r'_stream_reader\s*=\s*reader', f'_stream_reader = {reader}', device_stream_table_def) # insert reader - device_stream_table_def = re.sub(r'_stream_detail\s*=\s*stream_detail', f'_stream_detail = {stream_detail}', device_stream_table_def) # insert stream details - - full_def = "@schema \n" + device_stream_table_def + "\n\n" - - with open("existing_module.py", "r") as f: - existing_content = f.read() - - if full_def in existing_content: - continue - - with open("existing_module.py", "a") as f: - f.write(full_def) - -main() \ No newline at end of file diff --git a/aeon/dj_pipeline/streams_maker.py b/aeon/dj_pipeline/streams_maker.py new file mode 100644 index 00000000..0bc19ea5 --- /dev/null +++ b/aeon/dj_pipeline/streams_maker.py @@ -0,0 +1,331 @@ +import inspect +from pathlib import Path +import datajoint as dj +import pandas as pd +import re +import importlib + +import aeon +from aeon.dj_pipeline import acquisition, get_schema_name +from aeon.io import api as io_api + +logger = dj.logger + + +# schema_name = f'u_{dj.config["database.user"]}_streams' # for testing +schema_name = get_schema_name("streams") + +STREAMS_MODULE_NAME = "streams" +_STREAMS_MODULE_FILE = Path(__file__).parent / f"{STREAMS_MODULE_NAME}.py" + + +class StreamType(dj.Lookup): + """ + Catalog of all steam types for the different device types used across Project Aeon + One StreamType corresponds to one reader class in `aeon.io.reader` + The combination of `stream_reader` and `stream_reader_kwargs` should fully specify + the data loading routine for a particular device, using the `aeon.io.utils` + """ + + definition = """ # Catalog of all stream types used across Project Aeon + stream_type : varchar(20) + --- + stream_reader : varchar(256) # name of the reader class found in `aeon_mecha` package (e.g. aeon.io.reader.Video) + stream_reader_kwargs : longblob # keyword arguments to instantiate the reader class + stream_description='': varchar(256) + stream_hash : uuid # hash of dict(stream_reader_kwargs, stream_reader=stream_reader) + unique index (stream_hash) + """ + + +class DeviceType(dj.Lookup): + """ + Catalog of all device types used across Project Aeon + """ + + definition = """ # Catalog of all device types used across Project Aeon + device_type: varchar(36) + --- + device_description='': varchar(256) + """ + + class Stream(dj.Part): + definition = """ # Data stream(s) associated with a particular device type + -> master + -> StreamType + """ + + +class Device(dj.Lookup): + definition = """ # Physical devices, of a particular type, identified by unique serial number + device_serial_number: varchar(12) + --- + -> DeviceType + """ + + +# region Helper functions for creating device tables. + + +def get_device_template(device_type: str): + """Returns table class template for ExperimentDevice""" + device_title = device_type + device_type = dj.utils.from_camel_case(device_type) + + class ExperimentDevice(dj.Manual): + definition = f""" + # {device_title} placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-{aeon.__version__}) + -> acquisition.Experiment + -> Device + {device_type}_install_time : datetime(6) # time of the {device_type} placed and started operation at this position + --- + {device_type}_name : varchar(36) + """ + + class Attribute(dj.Part): + definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device + -> master + attribute_name : varchar(32) + --- + attribute_value=null : longblob + """ + + class RemovalTime(dj.Part): + definition = f""" + -> master + --- + {device_type}_removal_time: datetime(6) # time of the {device_type} being removed + """ + + ExperimentDevice.__name__ = f"{device_title}" + + return ExperimentDevice + + +def get_device_stream_template(device_type: str, stream_type: str): + """Returns table class template for DeviceDataStream""" + + context = inspect.currentframe().f_back.f_locals["context"] + ExperimentDevice = context[device_type] + + # DeviceDataStream table(s) + stream_detail = ( + StreamType + & (DeviceType.Stream & {"device_type": device_type, "stream_type": stream_type}) + ).fetch1() + + for i, n in enumerate(stream_detail["stream_reader"].split(".")): + if i == 0: + reader = aeon + else: + reader = getattr(reader, n) + + stream = reader(**stream_detail["stream_reader_kwargs"]) + + table_definition = f""" # Raw per-chunk {stream_type} data stream from {device_type} (auto-generated with aeon_mecha-{aeon.__version__}) + -> {device_type} + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of {stream_type} data + """ + + for col in stream.columns: + if col.startswith("_"): + continue + table_definition += f"{col}: longblob\n\t\t\t" + + class DeviceDataStream(dj.Imported): + definition = table_definition + _stream_reader = reader + _stream_detail = stream_detail + + @property + def key_source(self): + f""" + Only the combination of Chunk and {device_type} with overlapping time + + Chunk(s) that started after {device_type} install time and ended before {device_type} remove time + + Chunk(s) that started after {device_type} install time for {device_type} that are not yet removed + """ + return ( + acquisition.Chunk + * ExperimentDevice.join(ExperimentDevice.RemovalTime, left=True) + & f"chunk_start >= {dj.utils.from_camel_case(device_type)}_install_time" + & f'chunk_start < IFNULL({dj.utils.from_camel_case(device_type)}_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory( + key, directory_type=dir_type + ) + + device_name = (ExperimentDevice & key).fetch1( + f"{dj.utils.from_camel_case(device_type)}_name" + ) + + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{ + c: stream_data[c].values + for c in stream.columns + if not c.startswith("_") + }, + } + ) + + DeviceDataStream.__name__ = f"{device_type}{stream_type}" + + return DeviceDataStream + + +# endregion + + +def main(create_tables=True): + + if not _STREAMS_MODULE_FILE.exists(): + with open(_STREAMS_MODULE_FILE, "w") as f: + imports_str = ( + "#---- DO NOT MODIFY ----\n" + "#---- THIS FILE IS AUTO-GENERATED BY `streams_maker.py` ----\n\n" + "import datajoint as dj\n" + "import pandas as pd\n\n" + "import aeon\n" + "from aeon.dj_pipeline import acquisition, get_schema_name\n" + "from aeon.io import api as io_api\n\n" + "schema_name = get_schema_name('streams')\n" + "schema = dj.Schema()\n\n\n" + ) + f.write(imports_str) + for table_class in (StreamType, DeviceType, Device): + device_table_def = inspect.getsource(table_class).lstrip() + full_def = "@schema \n" + device_table_def + "\n\n" + f.write(full_def) + + streams = importlib.import_module(f"aeon.dj_pipeline.{STREAMS_MODULE_NAME}") + streams.schema.activate(schema_name) + + if create_tables: + # Create DeviceType tables. + for device_info in streams.DeviceType.fetch(as_dict=True): + if hasattr(streams, device_info["device_type"]): + continue + + table_class = get_device_template(device_info["device_type"]) + # context[table_class.__name__] = table_class + # schema(table_class, context=context) + + device_table_def = inspect.getsource(table_class).lstrip() + replacements = { + "ExperimentDevice": device_info["device_type"], + "{device_title}": dj.utils.from_camel_case(device_info["device_type"]), + "{device_type}": dj.utils.from_camel_case(device_info["device_type"]), + "{aeon.__version__}": aeon.__version__, + } + for old, new in replacements.items(): + device_table_def = device_table_def.replace(old, new) + full_def = "@schema \n" + device_table_def + "\n\n" + with open(_STREAMS_MODULE_FILE, "r") as f: + existing_content = f.read() + + if full_def in existing_content: + continue + + with open(_STREAMS_MODULE_FILE, "a") as f: + f.write(full_def) + + # Create DeviceDataStream tables. + for device_info in streams.DeviceType.Stream.fetch(as_dict=True): + + device_type = device_info["device_type"] + stream_type = device_info["stream_type"] + table_name = f"{device_type}{stream_type}" + + if hasattr(streams, table_name): + continue + + table_class = get_device_stream_template(device_type, stream_type) + # context[table_class.__name__] = table_class + # schema(table_class, context=context) + + stream_obj = table_class.__dict__["_stream_reader"] + reader = stream_obj.__module__ + "." + stream_obj.__name__ + stream_detail = table_class.__dict__["_stream_detail"] + + device_stream_table_def = inspect.getsource(table_class).lstrip() + + old_definition = f"""# Raw per-chunk {stream_type} data stream from {device_type} (auto-generated with aeon_mecha-{aeon.__version__}) + -> {device_type} + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of {stream_type} data + """ + + replacements = { + "DeviceDataStream": f"{device_type}{stream_type}", + "ExperimentDevice": device_type, + 'f"chunk_start >= {dj.utils.from_camel_case(device_type)}_install_time"': f"'chunk_start >= {dj.utils.from_camel_case(device_type)}_install_time'", + """f'chunk_start < IFNULL({dj.utils.from_camel_case(device_type)}_removal_time, "2200-01-01")'""": f"""'chunk_start < IFNULL({dj.utils.from_camel_case(device_type)}_removal_time, "2200-01-01")'""", + 'f"{dj.utils.from_camel_case(device_type)}_name"': f"'{dj.utils.from_camel_case(device_type)}_name'", + "{device_type}": device_type, + "{stream_type}": stream_type, + "{aeon.__version__}": aeon.__version__, + } + for old, new in replacements.items(): + new_definition = old_definition.replace(old, new) + + replacements["table_definition"] = '"""' + new_definition + '"""' + + for old, new in replacements.items(): + device_stream_table_def = device_stream_table_def.replace(old, new) + + device_stream_table_def = re.sub( + r"_stream_reader\s*=\s*reader", + f"_stream_reader = {reader}", + device_stream_table_def, + ) # insert reader + device_stream_table_def = re.sub( + r"_stream_detail\s*=\s*stream_detail", + f"_stream_detail = {stream_detail}", + device_stream_table_def, + ) # insert stream details + + full_def = "@schema \n" + device_stream_table_def + "\n\n" + + with open(_STREAMS_MODULE_FILE, "r") as f: + existing_content = f.read() + + if full_def in existing_content: + continue + + with open(_STREAMS_MODULE_FILE, "a") as f: + f.write(full_def) + + importlib.reload(streams) + streams.schema.activate(schema_name) + + return streams + + +streams = main() diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 0650a8f7..090b6b19 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -2,7 +2,6 @@ import inspect import json import pathlib -import re from collections import defaultdict from pathlib import Path @@ -11,16 +10,10 @@ import pandas as pd from dotmap import DotMap -from aeon.dj_pipeline import ( - acquisition, - dict_to_uuid, - get_schema_name, - lab, - streams, - subject, -) +from aeon.dj_pipeline import acquisition, dict_to_uuid, subject, streams_maker from aeon.io import api as io_api + _weight_scale_rate = 100 _weight_scale_nest = 1 _colony_csv_path = pathlib.Path("/ceph/aeon/aeon/colony/colony.csv") @@ -43,6 +36,8 @@ def insert_stream_types(): """Insert into streams.streamType table all streams in the dataset schema.""" from aeon.schema import dataset + streams = dj.VirtualModule("streams", streams_maker.STREAMS_MODULE_NAME) + schemas = [v for v in dataset.__dict__.values() if isinstance(v, DotMap)] for schema in schemas: @@ -64,6 +59,8 @@ def insert_stream_types(): def insert_device_types(schema: DotMap, metadata_yml_filepath: Path): """Use dataset.schema and metadata.yml to insert into streams.DeviceType and streams.Device. Only insert device types that were defined both in the device schema (e.g., exp02) and Metadata.yml. It then creates new device tables under streams schema.""" + streams = dj.VirtualModule("streams", streams_maker.STREAMS_MODULE_NAME) + device_info: dict[dict] = get_device_info(schema) device_type_mapper, device_sn = get_device_mapper(schema, metadata_yml_filepath) @@ -137,7 +134,7 @@ def extract_epoch_config(experiment_name: str, metadata_yml_filepath: str) -> di Returns: dict: epoch_config [dict] """ - + metadata_yml_filepath = pathlib.Path(metadata_yml_filepath) epoch_start = datetime.datetime.strptime( metadata_yml_filepath.parent.name, "%Y-%m-%dT%H-%M-%S" @@ -163,7 +160,9 @@ def extract_epoch_config(experiment_name: str, metadata_yml_filepath: str) -> di ) ) - if isinstance(devices, list): # In exp02, it is a list of dict. In presocial. It's a dict of dict. + if isinstance( + devices, list + ): # In exp02, it is a list of dict. In presocial. It's a dict of dict. devices: dict = { d.pop("Name"): d for d in devices } # {deivce_name: device_config} @@ -182,7 +181,8 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): """ Make entries into device tables """ - + streams = dj.VirtualModule("streams", streams_maker.STREAMS_MODULE_NAME) + if experiment_name.startswith("oct"): ingest_epoch_metadata_octagon(experiment_name, metadata_yml_filepath) return @@ -209,14 +209,14 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): schema = acquisition._device_schema_mapping[experiment_name] device_type_mapper, _ = get_device_mapper(schema, metadata_yml_filepath) - + # Insert into each device table device_list = [] device_removal_list = [] - + for device_name, device_config in epoch_config["metadata"].items(): if table := getattr(streams, device_type_mapper.get(device_name) or "", None): - + device_sn = device_config.get("SerialNumber", device_config.get("PortName")) device_key = {"device_serial_number": device_sn} @@ -263,38 +263,50 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): for entry in table_attribute_entry ] - if dict_to_uuid({config["attribute_name"]: config["attribute_value"] for config in current_device_config}) == dict_to_uuid( - {config["attribute_name"]: config["attribute_value"] for config in new_device_config} + if dict_to_uuid( + { + config["attribute_name"]: config["attribute_value"] + for config in current_device_config + } + ) == dict_to_uuid( + { + config["attribute_name"]: config["attribute_value"] + for config in new_device_config + } ): # Skip if none of the configuration has changed. continue # Remove old device - device_removal_list.append({ - **current_device_query.fetch1("KEY"), - f"{dj.utils.from_camel_case(table.__name__)}_removal_time": epoch_config[ - "epoch_start" - ], - }) + device_removal_list.append( + { + **current_device_query.fetch1("KEY"), + f"{dj.utils.from_camel_case(table.__name__)}_removal_time": epoch_config[ + "epoch_start" + ], + } + ) # Insert into table. table.insert1(table_entry, skip_duplicates=True) table.Attribute.insert(table_attribute_entry, ignore_extra_fields=True) # Remove the currently installed devices that are absent in this config - device_removal = lambda device_type, device_entry: any(dj.utils. from_camel_case(device_type) in k for k in device_entry) # returns True if the device type is found in the attribute name - + device_removal = lambda device_type, device_entry: any( + dj.utils.from_camel_case(device_type) in k for k in device_entry + ) # returns True if the device type is found in the attribute name + for device_type in streams.DeviceType.fetch("device_type"): table = getattr(streams, device_type) device_removal_list.extend( - (table - table.RemovalTime - device_list & experiment_key - ).fetch("KEY")) # could be VideoSource or Patch - + (table - table.RemovalTime - device_list & experiment_key).fetch("KEY") + ) # could be VideoSource or Patch + for device_entry in device_removal_list: if device_removal(device_type, device_entry): table.RemovalTime.insert1(device_entry) - - + + # region Get stream & device information def get_stream_entries(schema: DotMap) -> list[dict]: """Returns a list of dictionaries containing the stream entries for a given device, @@ -474,10 +486,14 @@ def get_device_mapper(schema: DotMap, metadata_yml_filepath: Path): device_sn[item.Name] = ( item.SerialNumber or item.PortName or None ) # assign either the serial number (if it exists) or port name. If neither exists, assign None - elif isinstance(item, str): # presocial + elif isinstance(item, str): # presocial if meta_data.Devices[item].get("Type"): device_type_mapper[item] = meta_data.Devices[item].get("Type") - device_sn[item] = meta_data.Devices[item].get("SerialNumber") or meta_data.Devices[item].get("PortName") or None + device_sn[item] = ( + meta_data.Devices[item].get("SerialNumber") + or meta_data.Devices[item].get("PortName") + or None + ) with filename.open("w") as f: json.dump(device_type_mapper, f) @@ -491,7 +507,7 @@ def ingest_epoch_metadata_octagon(experiment_name, metadata_yml_filepath): """ Temporary ingestion routine to load devices' meta information for Octagon arena experiments """ - from aeon.dj_pipeline import streams + streams = dj.VirtualModule("streams", streams_maker.STREAMS_MODULE_NAME) oct01_devices = [ ("Metadata", "Metadata"), From 37cdfa26f54060feb66fb8532589ccb1aa7fa741 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Mon, 3 Jul 2023 16:52:22 -0500 Subject: [PATCH 258/489] update streams for presocial --- aeon/dj_pipeline/streams.py | 808 ++++++++++++++++++++++++++++++ aeon/dj_pipeline/streams_maker.py | 24 +- 2 files changed, 821 insertions(+), 11 deletions(-) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index f642a62f..120b41fb 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -3,6 +3,7 @@ import datajoint as dj import pandas as pd +from uuid import UUID import aeon from aeon.dj_pipeline import acquisition, get_schema_name @@ -60,3 +61,810 @@ class Device(dj.Lookup): """ +@schema +class Patch(dj.Manual): + definition = f""" + # patch placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown) + -> acquisition.Experiment + -> Device + patch_install_time : datetime(6) # time of the patch placed and started operation at this position + --- + patch_name : varchar(36) + """ + + class Attribute(dj.Part): + definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device + -> master + attribute_name : varchar(32) + --- + attribute_value=null : longblob + """ + + class RemovalTime(dj.Part): + definition = f""" + -> master + --- + patch_removal_time: datetime(6) # time of the patch being removed + """ + + +@schema +class UndergroundFeeder(dj.Manual): + definition = f""" + # underground_feeder placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown) + -> acquisition.Experiment + -> Device + underground_feeder_install_time : datetime(6) # time of the underground_feeder placed and started operation at this position + --- + underground_feeder_name : varchar(36) + """ + + class Attribute(dj.Part): + definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device + -> master + attribute_name : varchar(32) + --- + attribute_value=null : longblob + """ + + class RemovalTime(dj.Part): + definition = f""" + -> master + --- + underground_feeder_removal_time: datetime(6) # time of the underground_feeder being removed + """ + + +@schema +class VideoSource(dj.Manual): + definition = f""" + # video_source placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown) + -> acquisition.Experiment + -> Device + video_source_install_time : datetime(6) # time of the video_source placed and started operation at this position + --- + video_source_name : varchar(36) + """ + + class Attribute(dj.Part): + definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device + -> master + attribute_name : varchar(32) + --- + attribute_value=null : longblob + """ + + class RemovalTime(dj.Part): + definition = f""" + -> master + --- + video_source_removal_time: datetime(6) # time of the video_source being removed + """ + + +@schema +class PatchBeamBreak(dj.Imported): + definition = """# Raw per-chunk BeamBreak data stream from Patch (auto-generated with aeon_mecha-unknown) + -> Patch + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of BeamBreak data + """ + _stream_reader = aeon.io.reader.BitmaskEvent + _stream_detail = {'stream_type': 'BeamBreak', 'stream_reader': 'aeon.io.reader.BitmaskEvent', 'stream_reader_kwargs': {'pattern': '{pattern}_32', 'value': 34, 'tag': 'BeamBroken'}, 'stream_description': '', 'stream_hash': UUID('b14171e6-d27d-117a-ae73-a16c4b5fc8a2')} + + @property + def key_source(self): + f""" + Only the combination of Chunk and Patch with overlapping time + + Chunk(s) that started after Patch install time and ended before Patch remove time + + Chunk(s) that started after Patch install time for Patch that are not yet removed + """ + return ( + acquisition.Chunk + * Patch.join(Patch.RemovalTime, left=True) + & 'chunk_start >= patch_install_time' + & 'chunk_start < IFNULL(patch_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory( + key, directory_type=dir_type + ) + + device_name = (Patch & key).fetch1( + 'patch_name' + ) + + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{ + c: stream_data[c].values + for c in stream.columns + if not c.startswith("_") + }, + } + ) + + +@schema +class PatchDeliverPellet(dj.Imported): + definition = """# Raw per-chunk DeliverPellet data stream from Patch (auto-generated with aeon_mecha-unknown) + -> Patch + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of DeliverPellet data + """ + _stream_reader = aeon.io.reader.BitmaskEvent + _stream_detail = {'stream_type': 'DeliverPellet', 'stream_reader': 'aeon.io.reader.BitmaskEvent', 'stream_reader_kwargs': {'pattern': '{pattern}_35', 'value': 1, 'tag': 'TriggeredPellet'}, 'stream_description': '', 'stream_hash': UUID('c49dda51-2e38-8b49-d1d8-2e54ea928e9c')} + + @property + def key_source(self): + f""" + Only the combination of Chunk and Patch with overlapping time + + Chunk(s) that started after Patch install time and ended before Patch remove time + + Chunk(s) that started after Patch install time for Patch that are not yet removed + """ + return ( + acquisition.Chunk + * Patch.join(Patch.RemovalTime, left=True) + & 'chunk_start >= patch_install_time' + & 'chunk_start < IFNULL(patch_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory( + key, directory_type=dir_type + ) + + device_name = (Patch & key).fetch1( + 'patch_name' + ) + + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{ + c: stream_data[c].values + for c in stream.columns + if not c.startswith("_") + }, + } + ) + + +@schema +class PatchDepletionState(dj.Imported): + definition = """# Raw per-chunk DepletionState data stream from Patch (auto-generated with aeon_mecha-unknown) + -> Patch + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of DepletionState data + """ + _stream_reader = aeon.schema.foraging._PatchState + _stream_detail = {'stream_type': 'DepletionState', 'stream_reader': 'aeon.schema.foraging._PatchState', 'stream_reader_kwargs': {'pattern': '{pattern}_State'}, 'stream_description': '', 'stream_hash': UUID('73025490-348c-18fd-d565-8e682b5b4bcd')} + + @property + def key_source(self): + f""" + Only the combination of Chunk and Patch with overlapping time + + Chunk(s) that started after Patch install time and ended before Patch remove time + + Chunk(s) that started after Patch install time for Patch that are not yet removed + """ + return ( + acquisition.Chunk + * Patch.join(Patch.RemovalTime, left=True) + & 'chunk_start >= patch_install_time' + & 'chunk_start < IFNULL(patch_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory( + key, directory_type=dir_type + ) + + device_name = (Patch & key).fetch1( + 'patch_name' + ) + + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{ + c: stream_data[c].values + for c in stream.columns + if not c.startswith("_") + }, + } + ) + + +@schema +class PatchEncoder(dj.Imported): + definition = """# Raw per-chunk Encoder data stream from Patch (auto-generated with aeon_mecha-unknown) + -> Patch + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of Encoder data + """ + _stream_reader = aeon.io.reader.Encoder + _stream_detail = {'stream_type': 'Encoder', 'stream_reader': 'aeon.io.reader.Encoder', 'stream_reader_kwargs': {'pattern': '{pattern}_90'}, 'stream_description': '', 'stream_hash': UUID('45002714-c31d-b2b8-a6e6-6ae624385cc1')} + + @property + def key_source(self): + f""" + Only the combination of Chunk and Patch with overlapping time + + Chunk(s) that started after Patch install time and ended before Patch remove time + + Chunk(s) that started after Patch install time for Patch that are not yet removed + """ + return ( + acquisition.Chunk + * Patch.join(Patch.RemovalTime, left=True) + & 'chunk_start >= patch_install_time' + & 'chunk_start < IFNULL(patch_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory( + key, directory_type=dir_type + ) + + device_name = (Patch & key).fetch1( + 'patch_name' + ) + + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{ + c: stream_data[c].values + for c in stream.columns + if not c.startswith("_") + }, + } + ) + + +@schema +class UndergroundFeederBeamBreak(dj.Imported): + definition = """# Raw per-chunk BeamBreak data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + -> UndergroundFeeder + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of BeamBreak data + """ + _stream_reader = aeon.io.reader.BitmaskEvent + _stream_detail = {'stream_type': 'BeamBreak', 'stream_reader': 'aeon.io.reader.BitmaskEvent', 'stream_reader_kwargs': {'pattern': '{pattern}_32', 'value': 34, 'tag': 'BeamBroken'}, 'stream_description': '', 'stream_hash': UUID('b14171e6-d27d-117a-ae73-a16c4b5fc8a2')} + + @property + def key_source(self): + f""" + Only the combination of Chunk and UndergroundFeeder with overlapping time + + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time + + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed + """ + return ( + acquisition.Chunk + * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) + & 'chunk_start >= underground_feeder_install_time' + & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory( + key, directory_type=dir_type + ) + + device_name = (UndergroundFeeder & key).fetch1( + 'underground_feeder_name' + ) + + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{ + c: stream_data[c].values + for c in stream.columns + if not c.startswith("_") + }, + } + ) + + +@schema +class UndergroundFeederDeliverPellet(dj.Imported): + definition = """# Raw per-chunk DeliverPellet data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + -> UndergroundFeeder + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of DeliverPellet data + """ + _stream_reader = aeon.io.reader.BitmaskEvent + _stream_detail = {'stream_type': 'DeliverPellet', 'stream_reader': 'aeon.io.reader.BitmaskEvent', 'stream_reader_kwargs': {'pattern': '{pattern}_35', 'value': 1, 'tag': 'TriggeredPellet'}, 'stream_description': '', 'stream_hash': UUID('c49dda51-2e38-8b49-d1d8-2e54ea928e9c')} + + @property + def key_source(self): + f""" + Only the combination of Chunk and UndergroundFeeder with overlapping time + + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time + + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed + """ + return ( + acquisition.Chunk + * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) + & 'chunk_start >= underground_feeder_install_time' + & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory( + key, directory_type=dir_type + ) + + device_name = (UndergroundFeeder & key).fetch1( + 'underground_feeder_name' + ) + + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{ + c: stream_data[c].values + for c in stream.columns + if not c.startswith("_") + }, + } + ) + + +@schema +class UndergroundFeederDepletionState(dj.Imported): + definition = """# Raw per-chunk DepletionState data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + -> UndergroundFeeder + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of DepletionState data + """ + _stream_reader = aeon.schema.foraging._PatchState + _stream_detail = {'stream_type': 'DepletionState', 'stream_reader': 'aeon.schema.foraging._PatchState', 'stream_reader_kwargs': {'pattern': '{pattern}_State'}, 'stream_description': '', 'stream_hash': UUID('73025490-348c-18fd-d565-8e682b5b4bcd')} + + @property + def key_source(self): + f""" + Only the combination of Chunk and UndergroundFeeder with overlapping time + + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time + + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed + """ + return ( + acquisition.Chunk + * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) + & 'chunk_start >= underground_feeder_install_time' + & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory( + key, directory_type=dir_type + ) + + device_name = (UndergroundFeeder & key).fetch1( + 'underground_feeder_name' + ) + + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{ + c: stream_data[c].values + for c in stream.columns + if not c.startswith("_") + }, + } + ) + + +@schema +class UndergroundFeederEncoder(dj.Imported): + definition = """# Raw per-chunk Encoder data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + -> UndergroundFeeder + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of Encoder data + """ + _stream_reader = aeon.io.reader.Encoder + _stream_detail = {'stream_type': 'Encoder', 'stream_reader': 'aeon.io.reader.Encoder', 'stream_reader_kwargs': {'pattern': '{pattern}_90'}, 'stream_description': '', 'stream_hash': UUID('45002714-c31d-b2b8-a6e6-6ae624385cc1')} + + @property + def key_source(self): + f""" + Only the combination of Chunk and UndergroundFeeder with overlapping time + + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time + + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed + """ + return ( + acquisition.Chunk + * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) + & 'chunk_start >= underground_feeder_install_time' + & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory( + key, directory_type=dir_type + ) + + device_name = (UndergroundFeeder & key).fetch1( + 'underground_feeder_name' + ) + + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{ + c: stream_data[c].values + for c in stream.columns + if not c.startswith("_") + }, + } + ) + + +@schema +class VideoSourcePosition(dj.Imported): + definition = """# Raw per-chunk Position data stream from VideoSource (auto-generated with aeon_mecha-unknown) + -> VideoSource + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of Position data + """ + _stream_reader = aeon.io.reader.Position + _stream_detail = {'stream_type': 'Position', 'stream_reader': 'aeon.io.reader.Position', 'stream_reader_kwargs': {'pattern': '{pattern}_200'}, 'stream_description': '', 'stream_hash': UUID('75f9f365-037a-1e9b-ad38-8b2b3783315d')} + + @property + def key_source(self): + f""" + Only the combination of Chunk and VideoSource with overlapping time + + Chunk(s) that started after VideoSource install time and ended before VideoSource remove time + + Chunk(s) that started after VideoSource install time for VideoSource that are not yet removed + """ + return ( + acquisition.Chunk + * VideoSource.join(VideoSource.RemovalTime, left=True) + & 'chunk_start >= video_source_install_time' + & 'chunk_start < IFNULL(video_source_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory( + key, directory_type=dir_type + ) + + device_name = (VideoSource & key).fetch1( + 'video_source_name' + ) + + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{ + c: stream_data[c].values + for c in stream.columns + if not c.startswith("_") + }, + } + ) + + +@schema +class VideoSourceRegion(dj.Imported): + definition = """# Raw per-chunk Region data stream from VideoSource (auto-generated with aeon_mecha-unknown) + -> VideoSource + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of Region data + """ + _stream_reader = aeon.schema.foraging._RegionReader + _stream_detail = {'stream_type': 'Region', 'stream_reader': 'aeon.schema.foraging._RegionReader', 'stream_reader_kwargs': {'pattern': '{pattern}_201'}, 'stream_description': '', 'stream_hash': UUID('6234a429-8ae5-d7dc-41c8-602ac76da029')} + + @property + def key_source(self): + f""" + Only the combination of Chunk and VideoSource with overlapping time + + Chunk(s) that started after VideoSource install time and ended before VideoSource remove time + + Chunk(s) that started after VideoSource install time for VideoSource that are not yet removed + """ + return ( + acquisition.Chunk + * VideoSource.join(VideoSource.RemovalTime, left=True) + & 'chunk_start >= video_source_install_time' + & 'chunk_start < IFNULL(video_source_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory( + key, directory_type=dir_type + ) + + device_name = (VideoSource & key).fetch1( + 'video_source_name' + ) + + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{ + c: stream_data[c].values + for c in stream.columns + if not c.startswith("_") + }, + } + ) + + +@schema +class VideoSourceVideo(dj.Imported): + definition = """# Raw per-chunk Video data stream from VideoSource (auto-generated with aeon_mecha-unknown) + -> VideoSource + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of Video data + """ + _stream_reader = aeon.io.reader.Video + _stream_detail = {'stream_type': 'Video', 'stream_reader': 'aeon.io.reader.Video', 'stream_reader_kwargs': {'pattern': '{pattern}'}, 'stream_description': '', 'stream_hash': UUID('4246295b-789f-206d-b413-7af25b7548b2')} + + @property + def key_source(self): + f""" + Only the combination of Chunk and VideoSource with overlapping time + + Chunk(s) that started after VideoSource install time and ended before VideoSource remove time + + Chunk(s) that started after VideoSource install time for VideoSource that are not yet removed + """ + return ( + acquisition.Chunk + * VideoSource.join(VideoSource.RemovalTime, left=True) + & 'chunk_start >= video_source_install_time' + & 'chunk_start < IFNULL(video_source_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory( + key, directory_type=dir_type + ) + + device_name = (VideoSource & key).fetch1( + 'video_source_name' + ) + + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{ + c: stream_data[c].values + for c in stream.columns + if not c.startswith("_") + }, + } + ) + + diff --git a/aeon/dj_pipeline/streams_maker.py b/aeon/dj_pipeline/streams_maker.py index 0bc19ea5..8b78f3ff 100644 --- a/aeon/dj_pipeline/streams_maker.py +++ b/aeon/dj_pipeline/streams_maker.py @@ -102,16 +102,18 @@ class RemovalTime(dj.Part): return ExperimentDevice -def get_device_stream_template(device_type: str, stream_type: str): +def get_device_stream_template(device_type: str, stream_type: str, streams_module): """Returns table class template for DeviceDataStream""" - context = inspect.currentframe().f_back.f_locals["context"] - ExperimentDevice = context[device_type] + ExperimentDevice = getattr(streams_module, device_type) # DeviceDataStream table(s) stream_detail = ( - StreamType - & (DeviceType.Stream & {"device_type": device_type, "stream_type": stream_type}) + streams_module.StreamType + & ( + streams_module.DeviceType.Stream + & {"device_type": device_type, "stream_type": stream_type} + ) ).fetch1() for i, n in enumerate(stream_detail["stream_reader"].split(".")): @@ -209,7 +211,8 @@ def main(create_tables=True): "#---- DO NOT MODIFY ----\n" "#---- THIS FILE IS AUTO-GENERATED BY `streams_maker.py` ----\n\n" "import datajoint as dj\n" - "import pandas as pd\n\n" + "import pandas as pd\n" + "from uuid import UUID\n\n" "import aeon\n" "from aeon.dj_pipeline import acquisition, get_schema_name\n" "from aeon.io import api as io_api\n\n" @@ -232,8 +235,7 @@ def main(create_tables=True): continue table_class = get_device_template(device_info["device_type"]) - # context[table_class.__name__] = table_class - # schema(table_class, context=context) + streams.__dict__[table_class.__name__] = table_class device_table_def = inspect.getsource(table_class).lstrip() replacements = { @@ -264,9 +266,9 @@ def main(create_tables=True): if hasattr(streams, table_name): continue - table_class = get_device_stream_template(device_type, stream_type) - # context[table_class.__name__] = table_class - # schema(table_class, context=context) + table_class = get_device_stream_template( + device_type, stream_type, streams_module=streams + ) stream_obj = table_class.__dict__["_stream_reader"] reader = stream_obj.__module__ + "." + stream_obj.__name__ From 5678b4a2d6e1a2f2c64c55ac2c04950d942053d9 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 5 Jul 2023 17:04:51 -0500 Subject: [PATCH 259/489] update workers and epochs/chunks ingestion --- aeon/dj_pipeline/populate/process.py | 4 +- aeon/dj_pipeline/populate/worker.py | 72 +++++++++++++++++++--------- 2 files changed, 51 insertions(+), 25 deletions(-) diff --git a/aeon/dj_pipeline/populate/process.py b/aeon/dj_pipeline/populate/process.py index c83347cb..023425ad 100644 --- a/aeon/dj_pipeline/populate/process.py +++ b/aeon/dj_pipeline/populate/process.py @@ -36,7 +36,7 @@ from datajoint_utilities.dj_worker import parse_args from aeon.dj_pipeline.populate.worker import ( - high_priority, + acquisition_worker, mid_priority, streams_worker, logger, @@ -46,7 +46,7 @@ # ---- some wrappers to support execution as script or CLI configured_workers = { - "high_priority": high_priority, + "high_priority": acquisition_worker, "mid_priority": mid_priority, "streams_worker": streams_worker, } diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py index 395023b0..1ca198a0 100644 --- a/aeon/dj_pipeline/populate/worker.py +++ b/aeon/dj_pipeline/populate/worker.py @@ -20,7 +20,7 @@ streams = streams_maker.main() __all__ = [ - "high_priority", + "acquisition_worker", "mid_priority", "streams_worker", "WorkerLog", @@ -30,34 +30,61 @@ # ---- Some constants ---- logger = dj.logger -_current_experiment = "exp0.2-r0" -worker_schema_name = db_prefix + "workerlog" +worker_schema_name = db_prefix + "worker" load_metadata.insert_stream_types() +# ---- Manage experiments for automated ingestion ---- + +schema = dj.Schema(worker_schema_name) + + +@schema +class AutomatedExperimentIngestion(dj.Manual): + definition = """ # experiments to undergo automated ingestion + -> acquisition.Experiment + """ + + +def ingest_colony_epochs_chunks(): + """ + Load and insert subjects from colony.csv + Ingest epochs and chunks + for experiments specified in AutomatedExperimentIngestion + """ + load_metadata.ingest_subject() + experiment_names = AutomatedExperimentIngestion.fetch("experiment_name") + for experiment_name in experiment_names: + acquisition.Epoch.ingest_epochs(experiment_name) + acquisition.Chunk.ingest_chunks(experiment_name) + + +def ingest_environment_visits(): + """ + Extract and insert complete visits + for experiments specified in AutomatedExperimentIngestion + """ + experiment_names = AutomatedExperimentIngestion.fetch("experiment_name") + analysis.ingest_environment_visits(experiment_names) + + # ---- Define worker(s) ---- -# configure a worker to process high-priority tasks -high_priority = DataJointWorker( - "high_priority", +# configure a worker to process `acquisition`-related tasks +acquisition_worker = DataJointWorker( + "acquisition_worker", worker_schema_name=worker_schema_name, db_prefix=db_prefix, run_duration=-1, sleep_duration=600, ) -high_priority(load_metadata.ingest_subject) -high_priority(acquisition.Epoch.ingest_epochs, experiment_name=_current_experiment) -high_priority(acquisition.Chunk.ingest_chunks, experiment_name=_current_experiment) -high_priority(acquisition.ExperimentLog) -high_priority(acquisition.SubjectEnterExit) -high_priority(acquisition.SubjectWeight) -high_priority(acquisition.FoodPatchEvent) -high_priority(acquisition.WheelState) -high_priority(acquisition.WeightMeasurement) -high_priority(acquisition.WeightMeasurementFiltered) - -high_priority( - analysis.ingest_environment_visits, experiment_names=[_current_experiment] -) +acquisition_worker(ingest_colony_epochs_chunks) +acquisition_worker(acquisition.ExperimentLog) +acquisition_worker(acquisition.SubjectEnterExit) +acquisition_worker(acquisition.SubjectWeight) +acquisition_worker(acquisition.FoodPatchEvent) +acquisition_worker(acquisition.WheelState) + +acquisition_worker(ingest_environment_visits) # configure a worker to process mid-priority tasks mid_priority = DataJointWorker( @@ -71,10 +98,9 @@ mid_priority(qc.CameraQC) mid_priority(tracking.CameraTracking) mid_priority(acquisition.FoodPatchWheel) +mid_priority(acquisition.WeightMeasurement) +mid_priority(acquisition.WeightMeasurementFiltered) -mid_priority( - analysis.visit.ingest_environment_visits, experiment_names=[_current_experiment] -) mid_priority(analysis.OverlapVisit) mid_priority(analysis.VisitSubjectPosition) From 4e162310933ec92b71b6908a5d5506a9c2743081 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 6 Jul 2023 16:35:14 -0500 Subject: [PATCH 260/489] update worker containers and config --- aeon/dj_pipeline/acquisition.py | 5 ++--- aeon/dj_pipeline/populate/worker.py | 14 ++++++------- aeon/dj_pipeline/utils/load_metadata.py | 3 ++- aeon/dj_pipeline/{ => utils}/streams_maker.py | 0 docker/docker-compose.yml | 20 +++++++++++-------- 5 files changed, 23 insertions(+), 19 deletions(-) rename aeon/dj_pipeline/{ => utils}/streams_maker.py (100%) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 9a503a3a..03a9a0f4 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -10,7 +10,7 @@ from aeon.io import reader as io_reader from aeon.schema import dataset as aeon_schema -from . import get_schema_name, lab, subject +from . import get_schema_name from .utils import paths logger = dj.logger @@ -277,8 +277,7 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): - if not specified, ingest all epochs Note: "start" and "end" are datetime specified a string in the format: "%Y-%m-%d %H:%M:%S" """ - from aeon.dj_pipeline import streams_maker - + from .utils import streams_maker from .utils.load_metadata import ( extract_epoch_config, ingest_epoch_metadata, diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py index 1ca198a0..b57c4c4e 100644 --- a/aeon/dj_pipeline/populate/worker.py +++ b/aeon/dj_pipeline/populate/worker.py @@ -12,10 +12,10 @@ db_prefix, qc, report, - streams_maker, tracking, ) -from aeon.dj_pipeline.utils import load_metadata +from aeon.dj_pipeline.utils import load_metadata, streams_maker + streams = streams_maker.main() @@ -75,7 +75,7 @@ def ingest_environment_visits(): worker_schema_name=worker_schema_name, db_prefix=db_prefix, run_duration=-1, - sleep_duration=600, + sleep_duration=1200, ) acquisition_worker(ingest_colony_epochs_chunks) acquisition_worker(acquisition.ExperimentLog) @@ -92,7 +92,7 @@ def ingest_environment_visits(): worker_schema_name=worker_schema_name, db_prefix=db_prefix, run_duration=-1, - sleep_duration=120, + sleep_duration=3600, ) mid_priority(qc.CameraQC) @@ -121,10 +121,10 @@ def ingest_environment_visits(): "streams_worker", worker_schema_name=worker_schema_name, db_prefix=db_prefix, - run_duration=1, - sleep_duration=600, + run_duration=-1, + sleep_duration=1200, ) for attr in vars(streams).values(): if is_djtable(attr, dj.user_tables.AutoPopulate): - streams_worker(attr) + streams_worker(attr, max_calls=10) diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 090b6b19..84de0a85 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -10,7 +10,8 @@ import pandas as pd from dotmap import DotMap -from aeon.dj_pipeline import acquisition, dict_to_uuid, subject, streams_maker +from aeon.dj_pipeline import acquisition, dict_to_uuid, subject +from aeon.dj_pipeline.utils import streams_maker from aeon.io import api as io_api diff --git a/aeon/dj_pipeline/streams_maker.py b/aeon/dj_pipeline/utils/streams_maker.py similarity index 100% rename from aeon/dj_pipeline/streams_maker.py rename to aeon/dj_pipeline/utils/streams_maker.py diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index a6d1868b..e2dce70a 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -45,22 +45,26 @@ x-aeon-ingest-common: &aeon-ingest-common max-file: "5" services: - ingest_high: + acquisition_worker: <<: *aeon-ingest-common - command: ["aeon_ingest", "high_priority", "--duration=-1", "--sleep=1200"] + command: ["aeon_ingest", "acquisition_worker"] - ingest_mid: + streams_worker: <<: *aeon-ingest-common depends_on: - ingest_high: + acquisition_worker: condition: service_started deploy: mode: replicated - replicas: 2 - command: ["aeon_ingest", "mid_priority", "--duration=-1", "--sleep=3600"] + replicas: 3 + command: ["aeon_ingest", "streams_worker"] - dev: + ingest_mid: <<: *aeon-ingest-common + depends_on: + acquisition_worker: + condition: service_started deploy: mode: replicated - replicas: 0 + replicas: 2 + command: ["aeon_ingest", "mid_priority"] From ee934cd68a0c95fe7b39da2dec7471a5d9dfab8e Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 7 Jul 2023 17:52:52 +0000 Subject: [PATCH 261/489] fix bugs in video component --- aeon/dj_pipeline/utils/video.py | 17 ++++++++--------- aeon/dj_pipeline/webapps/sciviz/specsheet.yaml | 4 ++-- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/aeon/dj_pipeline/utils/video.py b/aeon/dj_pipeline/utils/video.py index c2ccdb13..ec9f14b5 100644 --- a/aeon/dj_pipeline/utils/video.py +++ b/aeon/dj_pipeline/utils/video.py @@ -1,13 +1,14 @@ -import numpy as np import base64 -import pandas as pd -import pathlib import datetime +from pathlib import Path + import cv2 +import numpy as np +import pandas as pd +import aeon.io.reader as io_reader from aeon.io import api as io_api from aeon.io import video as io_video -import aeon.io.reader as io_reader def retrieve_video_frames( @@ -15,16 +16,14 @@ def retrieve_video_frames( camera_name, start_time, end_time, + raw_data_dir, desired_fps=50, start_frame=0, chunk_size=50, **kwargs, ): - from aeon.dj_pipeline import acquisition - - raw_data_dir = acquisition.Experiment.get_data_directory( - {"experiment_name": experiment_name} - ) + raw_data_dir = Path(raw_data_dir) + assert raw_data_dir.exists() # do some data loading videodata = io_api.load( diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 6ca93082..6de403fb 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -438,8 +438,8 @@ SciViz: dj_query: > def dj_query(aeon_acquisition): acquisition = aeon_acquisition - q = dj.U('camera_description') & acquisition.ExperimentCamera - return {'query': q, 'fetch_args': ['camera_description']} + q = dj.U('camera_description', 'raw_data_dir') & (acquisition.ExperimentCamera * acquisition.Experiment.Directory & 'directory_type = "raw"').proj('camera_description', raw_data_dir="CONCAT('/ceph/aeon/', directory_path)") + return {'query': q, 'fetch_args': []} time_range_selector: x: 0 y: 2 From dd35d605fc6b9a0e7a14b9ed53350ea933774a84 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 7 Jul 2023 17:56:01 +0000 Subject: [PATCH 262/489] pull code from the main repo --- aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml | 2 +- aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml index f153720c..f9242e1b 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml @@ -22,7 +22,7 @@ services: - -c - | apk add --update git g++ && - git clone -b datajoint_pipeline https://github.com/ttngu207/aeon_mecha.git && + git clone -b datajoint_pipeline https://github.com/SainsburyWellcomeCentre/aeon_mecha.git && pip install -e ./aeon_mecha && pharus_update() { [ -z "$$GUNICORN_PID" ] || kill $$GUNICORN_PID diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml index 6975afda..9085d859 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml @@ -20,7 +20,7 @@ services: - -c - | apk add --update git g++ && - git clone -b datajoint_pipeline https://github.com/ttngu207/aeon_mecha.git && + git clone -b datajoint_pipeline https://github.com/SainsburyWellcomeCentre/aeon_mecha.git && pip install -e ./aeon_mecha && gunicorn --bind 0.0.0.0:$${PHARUS_PORT} pharus.server:app # ports: From 2662445c4ef35bbd00515eb83606ca8e02103d4a Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 7 Jul 2023 20:56:12 +0000 Subject: [PATCH 263/489] update docker image --- aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml | 4 ++-- aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml index f9242e1b..2ac3f1ac 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml @@ -52,7 +52,7 @@ services: sci-viz: cpus: 2.0 mem_limit: 16g - image: datajoint/sci-viz:2.3.2 + image: jverswijver/sci-viz:2.3.3-hotfix3 environment: - CHOKIDAR_USEPOLLING=true - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/api @@ -72,7 +72,7 @@ services: networks: - main fakeservices.datajoint.io: - image: datajoint/nginx:v0.2.4 + image: datajoint/nginx:v0.2.5 environment: - ADD_pharus_TYPE=REST - ADD_pharus_ENDPOINT=pharus:5000 diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml index 9085d859..d30fad51 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml @@ -30,7 +30,7 @@ services: sci-viz: cpus: 2.0 mem_limit: 16g - image: jverswijver/sci-viz:2.3.3-hotfix2 + image: jverswijver/sci-viz:2.3.3-hotfix3 environment: - CHOKIDAR_USEPOLLING=true - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/api From 076cf9c6fb510d2b84ae41b2a352c9e3dac873b5 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 7 Jul 2023 20:56:39 +0000 Subject: [PATCH 264/489] fix: :bug: fix specsheet error --- aeon/dj_pipeline/webapps/sciviz/specsheet.yaml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 6de403fb..5b4cbc7d 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -438,8 +438,8 @@ SciViz: dj_query: > def dj_query(aeon_acquisition): acquisition = aeon_acquisition - q = dj.U('camera_description', 'raw_data_dir') & (acquisition.ExperimentCamera * acquisition.Experiment.Directory & 'directory_type = "raw"').proj('camera_description', raw_data_dir="CONCAT('/ceph/aeon/', directory_path)") - return {'query': q, 'fetch_args': []} + q = dj.U('camera_description') & acquisition.ExperimentCamera + return {'query': q, 'fetch_args': ['camera_description']} time_range_selector: x: 0 y: 2 @@ -465,11 +465,12 @@ SciViz: stream_time_selector, ] restriction: > - def restriction(**kwargs): + def restriction(**kwargs): return dict(**kwargs) dj_query: > def dj_query(aeon_acquisition): - q = aeon_acquisition.ExperimentCamera + acquisition = aeon_acquisition + q = dj.U('camera_description', 'raw_data_dir') & (acquisition.ExperimentCamera * acquisition.Experiment.Directory & 'directory_type = "raw"').proj('camera_description', raw_data_dir="CONCAT('/ceph/aeon/', directory_path)") return {'query': q, 'fetch_args': []} PipelineMonitor: route: /pipeline_monitor From 68cb397ba39b90e60a52824aaef8e8869047c4a0 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 7 Jul 2023 17:28:23 -0500 Subject: [PATCH 265/489] enable import streams from aeon.dj_pipeline --- aeon/dj_pipeline/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/aeon/dj_pipeline/__init__.py b/aeon/dj_pipeline/__init__.py index 4664cb93..ea805b57 100644 --- a/aeon/dj_pipeline/__init__.py +++ b/aeon/dj_pipeline/__init__.py @@ -16,6 +16,12 @@ repository_config = dj.config['custom'].get('repository_config', _default_repository_config) +try: + from .utils import streams_maker + streams = dj.VirtualModule("streams", streams_maker.STREAMS_MODULE_NAME) +except: + pass + def get_schema_name(name): return db_prefix + name From 3d66f67c5de2ab7c5be7b4d3ccf75899f28aecac Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Mon, 10 Jul 2023 12:50:54 -0500 Subject: [PATCH 266/489] Apply suggestions from code review Co-authored-by: JaerongA --- aeon/dj_pipeline/utils/streams_maker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/utils/streams_maker.py b/aeon/dj_pipeline/utils/streams_maker.py index 8b78f3ff..46043e94 100644 --- a/aeon/dj_pipeline/utils/streams_maker.py +++ b/aeon/dj_pipeline/utils/streams_maker.py @@ -16,7 +16,7 @@ schema_name = get_schema_name("streams") STREAMS_MODULE_NAME = "streams" -_STREAMS_MODULE_FILE = Path(__file__).parent / f"{STREAMS_MODULE_NAME}.py" +_STREAMS_MODULE_FILE = Path(__file__).parent.parent / f"{STREAMS_MODULE_NAME}.py" class StreamType(dj.Lookup): From f5beb08a98ac72e2caff5e43cc9360b7a7e1dd19 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 19 Jul 2023 05:05:45 +0000 Subject: [PATCH 267/489] fix memory cpu req in pharus --- .../webapps/sciviz/docker-compose-remote.yaml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml index d30fad51..33732b69 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml @@ -1,15 +1,16 @@ +# cd aeon/dj_pipeline/webapps/sciviz/ # HOST_UID=$(id -u) docker-compose -f docker-compose-remote.yaml up -d -# version: '2.4' services: pharus: - cpus: 2.0 - mem_limit: 4g + # cpus: 2.0 + mem_limit: 16g image: jverswijver/pharus:0.8.5-PY_VER-3.9 environment: # - FLASK_ENV=development # enables logging to console from Flask - PHARUS_SPEC_PATH=/main/specsheet.yaml # for dynamic utils spec + user: root volumes: - ./specsheet.yaml:/main/specsheet.yaml #copy the spec over to /main/specs/YOUR_SPEC_NAME @@ -29,7 +30,7 @@ services: - main sci-viz: cpus: 2.0 - mem_limit: 16g + mem_limit: 4g image: jverswijver/sci-viz:2.3.3-hotfix3 environment: - CHOKIDAR_USEPOLLING=true From 3ec54bd7c2d997a8f85ad1482a7a0a0883dc9c2c Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 21 Jul 2023 18:20:06 +0000 Subject: [PATCH 268/489] update video loading function --- aeon/dj_pipeline/utils/video.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/utils/video.py b/aeon/dj_pipeline/utils/video.py index ec9f14b5..fb791b0a 100644 --- a/aeon/dj_pipeline/utils/video.py +++ b/aeon/dj_pipeline/utils/video.py @@ -25,10 +25,10 @@ def retrieve_video_frames( raw_data_dir = Path(raw_data_dir) assert raw_data_dir.exists() - # do some data loading + # Load video data videodata = io_api.load( root=raw_data_dir.as_posix(), - reader=io_reader.Video(camera_name), + reader=io_reader.Video(f"{camera_name}_*"), start=pd.Timestamp(start_time), end=pd.Timestamp(end_time), ) From 1fba855419507f14b09aeb295fad06cb3797e01d Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 21 Jul 2023 18:30:01 +0000 Subject: [PATCH 269/489] increase pharus workers --- aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml index 33732b69..644abcd6 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml @@ -23,7 +23,8 @@ services: apk add --update git g++ && git clone -b datajoint_pipeline https://github.com/SainsburyWellcomeCentre/aeon_mecha.git && pip install -e ./aeon_mecha && - gunicorn --bind 0.0.0.0:$${PHARUS_PORT} pharus.server:app + gunicorn --bind 0.0.0.0:$${PHARUS_PORT} --workers=3 pharus.server:app + # ports: # - "5000:5000" networks: From b804fd871621c47bc29165e51140131aa2f3ea0c Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 21 Jul 2023 18:30:17 +0000 Subject: [PATCH 270/489] add .env path --- aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml index 644abcd6..15a0b3ac 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml @@ -10,7 +10,7 @@ services: environment: # - FLASK_ENV=development # enables logging to console from Flask - PHARUS_SPEC_PATH=/main/specsheet.yaml # for dynamic utils spec - + env_file: ./.env user: root volumes: - ./specsheet.yaml:/main/specsheet.yaml #copy the spec over to /main/specs/YOUR_SPEC_NAME From c27b3a4a1bfdf62e37eea6d9a636b2ecedbbe41a Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 21 Jul 2023 18:32:02 +0000 Subject: [PATCH 271/489] modify batch & chunk size in specsheet --- aeon/dj_pipeline/webapps/sciviz/specsheet.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 5b4cbc7d..27456ae1 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -454,8 +454,8 @@ SciViz: width: 2 type: slideshow:aeon route: /videostream_video_streamer - batch_size: 3 - chunk_size: 50 + batch_size: 6 + chunk_size: 30 buffer_size: 30 max_FPS: 50 channels: From 7a024ea5ed5f6298446ff7aabe1341cdfc219e95 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Thu, 27 Jul 2023 19:44:05 +0000 Subject: [PATCH 272/489] add tables for sleap tracking data --- aeon/dj_pipeline/tracking.py | 73 ++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/aeon/dj_pipeline/tracking.py b/aeon/dj_pipeline/tracking.py index 837789e1..05ff4489 100644 --- a/aeon/dj_pipeline/tracking.py +++ b/aeon/dj_pipeline/tracking.py @@ -3,7 +3,9 @@ import numpy as np import pandas as pd +from aeon.dj_pipeline import acquisition, dict_to_uuid, get_schema_name, lab, qc from aeon.io import api as io_api +from aeon.io import reader from . import acquisition, dict_to_uuid, get_schema_name, lab, qc @@ -231,6 +233,77 @@ def get_object_position( ) +# ---------- VideoSource ------------------ + + +@schema +class VideoSourceTracking(dj.Imported): + definition = """ # Tracked objects position data from a particular VideoSource for multi-animal experiment using the SLEAP tracking method per chunk + -> acquisition.Chunk + -> streams.VideoSource + -> TrackingParamSet + """ + + class Object(dj.Part): + definition = """ # Position data of object tracked by a particular camera tracking + -> master + object_name: varchar(16) + --- + timestamps: longblob # (datetime) timestamps of the position data + class: smallint + class_confidence: longblob + centroid_x: longblob + centroid_y: longblob + centroid_confidence: longblob + """ + + @property + def key_source(self): + ks = acquisition.Chunk * streams.VideoSource * TrackingParamSet + return ks * (streams.VideoSource & f"video_source_name in {tuple(set(acquisition._ref_device_mapping.values()))}").proj() & "tracking_paramset_id = 1" # SLEAP method + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + camera = (streams.VideoSource & key).fetch1("video_source_name") + + raw_data_dir = acquisition.Experiment.get_data_directory( + key, directory_type=dir_type + ) + + device = getattr( + acquisition._device_schema_mapping[key["experiment_name"]], camera + ) + + sleap_reader = reader.Harp(pattern="", columns=["class", "class_confidence", "centroid_x", "centroid_y", "centroid_confidence"]) + tracking_file_path = "/ceph/aeon/aeon/code/scratchpad/ex_ma_tracking/ex_ma_tracking.bin" # temporary + tracking_df = sleap_reader.read(tracking_file_path) + + object_positions = [] + for obj_name in ["body"]: + + for class_id in tracking_df["class"].unique(): + + temp_df = tracking_df[tracking_df["class"] == class_id] + + object_positions.append( + { + **key, + "object_name": obj_name, + "timestamps": temp_df.index.values, + "class": class_id, + "class_confidence": temp_df.class_confidence.values, + "centroid_x": temp_df.centroid_x.values, + "centroid_y": temp_df.centroid_y.values, + "centroid_confidence": temp_df.centroid_confidence.values, + } + ) + + self.insert1(key) + self.Object.insert(object_positions) + + # ---------- HELPER ------------------ From 28eccc3f1f8335cf8240ff8850a825523b809ff7 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 16 Aug 2023 20:06:25 +0000 Subject: [PATCH 273/489] fix: :bug: bug fix in configuraed workers --- aeon/dj_pipeline/populate/process.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aeon/dj_pipeline/populate/process.py b/aeon/dj_pipeline/populate/process.py index 023425ad..b0bc7305 100644 --- a/aeon/dj_pipeline/populate/process.py +++ b/aeon/dj_pipeline/populate/process.py @@ -32,21 +32,21 @@ """ import sys + import datajoint as dj from datajoint_utilities.dj_worker import parse_args from aeon.dj_pipeline.populate.worker import ( acquisition_worker, + logger, mid_priority, streams_worker, - logger, ) - # ---- some wrappers to support execution as script or CLI configured_workers = { - "high_priority": acquisition_worker, + "acquisition_worker": acquisition_worker, "mid_priority": mid_priority, "streams_worker": streams_worker, } From 2be5e2ba155183575ebfa4ce389833c5e0de686a Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 16 Aug 2023 20:15:45 +0000 Subject: [PATCH 274/489] fix: :bug: bug fix in configured_workers --- aeon/dj_pipeline/populate/process.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aeon/dj_pipeline/populate/process.py b/aeon/dj_pipeline/populate/process.py index 023425ad..b0bc7305 100644 --- a/aeon/dj_pipeline/populate/process.py +++ b/aeon/dj_pipeline/populate/process.py @@ -32,21 +32,21 @@ """ import sys + import datajoint as dj from datajoint_utilities.dj_worker import parse_args from aeon.dj_pipeline.populate.worker import ( acquisition_worker, + logger, mid_priority, streams_worker, - logger, ) - # ---- some wrappers to support execution as script or CLI configured_workers = { - "high_priority": acquisition_worker, + "acquisition_worker": acquisition_worker, "mid_priority": mid_priority, "streams_worker": streams_worker, } From 665ef04c1c47836afbbf08060d685d1c5fca94a0 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 17 Aug 2023 11:04:51 -0500 Subject: [PATCH 275/489] add contents to Location for acq machine --- aeon/dj_pipeline/lab.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/aeon/dj_pipeline/lab.py b/aeon/dj_pipeline/lab.py index 9c193483..7cf630ac 100644 --- a/aeon/dj_pipeline/lab.py +++ b/aeon/dj_pipeline/lab.py @@ -57,6 +57,10 @@ class Location(dj.Lookup): ("SWC", "room-0", "room for experiment 0"), ("SWC", "room-1", "room for social experiment"), ("SWC", "464", "room for social experiment using octagon arena"), + ("SWC", "AEON1", "acquisition machine AEON1"), + ("SWC", "AEON2", "acquisition machine AEON2"), + ("SWC", "AEON3", "acquisition machine AEON3"), + ("SWC", "AEON4", "acquisition machine AEON4"), ] From f3c1ce5d70294a075d87c259d58992995ee290a0 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 17 Aug 2023 11:10:40 -0500 Subject: [PATCH 276/489] minor change to machine name --- aeon/dj_pipeline/lab.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/lab.py b/aeon/dj_pipeline/lab.py index 7cf630ac..3d0c7dfa 100644 --- a/aeon/dj_pipeline/lab.py +++ b/aeon/dj_pipeline/lab.py @@ -57,7 +57,7 @@ class Location(dj.Lookup): ("SWC", "room-0", "room for experiment 0"), ("SWC", "room-1", "room for social experiment"), ("SWC", "464", "room for social experiment using octagon arena"), - ("SWC", "AEON1", "acquisition machine AEON1"), + ("SWC", "AEON", "acquisition machine AEON"), ("SWC", "AEON2", "acquisition machine AEON2"), ("SWC", "AEON3", "acquisition machine AEON3"), ("SWC", "AEON4", "acquisition machine AEON4"), From 298b9a279ae6ee5fd268a9f7e704b92170d1bf5d Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 17 Aug 2023 11:32:36 -0500 Subject: [PATCH 277/489] handles `directory_path` being fullpath --- aeon/dj_pipeline/acquisition.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index e180382c..5fc9e0aa 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -122,9 +122,15 @@ def get_data_directory(cls, experiment_key, directory_type="raw", as_posix=False repo_name, dir_path = ( cls.Directory & experiment_key & {"directory_type": directory_type} ).fetch1("repository_name", "directory_path") - data_directory = paths.get_repository_path(repo_name) / dir_path - if not data_directory.exists(): - return None + + dir_path = pathlib.Path(dir_path) + if dir_path.exists(): + assert dir_path.is_relative_to(paths.get_repository_path(repo_name)) + data_directory = dir_path + else: + data_directory = paths.get_repository_path(repo_name) / dir_path + if not data_directory.exists(): + return None return data_directory.as_posix() if as_posix else data_directory @classmethod From a8c0f0ef6d15d43541d2121b59f41b0459f83d0a Mon Sep 17 00:00:00 2001 From: JaerongA Date: Thu, 17 Aug 2023 18:26:47 +0000 Subject: [PATCH 278/489] remove pipeline monitoring page --- .../dj_pipeline/webapps/sciviz/specsheet.yaml | 225 ------------------ 1 file changed, 225 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 27456ae1..5aa0a382 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -472,228 +472,3 @@ SciViz: acquisition = aeon_acquisition q = dj.U('camera_description', 'raw_data_dir') & (acquisition.ExperimentCamera * acquisition.Experiment.Directory & 'directory_type = "raw"').proj('camera_description', raw_data_dir="CONCAT('/ceph/aeon/', directory_path)") return {'query': q, 'fetch_args': []} - PipelineMonitor: - route: /pipeline_monitor - grids: - grid1: - type: fixed - columns: 1 - row_height: 680 - components: - Worker Status: - route: /pipeline_monitor_workerstatus - x: 0 - y: 0 - height: 1 - width: 1 - type: antd-table - restriction: > - def restriction(**kwargs): - return dict(**kwargs) - dj_query: > - def dj_query(aeon_workerlog): - cls = aeon_workerlog.WorkerLog - backtrack_minutes = 60 - recent = ( - cls.proj( - minute_elapsed="TIMESTAMPDIFF(MINUTE, process_timestamp, UTC_TIMESTAMP())" - ) - & f"minute_elapsed < {backtrack_minutes}" - ) - recent_jobs = dj.U("process").aggr( - cls & recent, - worker_count="count(DISTINCT pid)", - minutes_since_oldest="TIMESTAMPDIFF(MINUTE, MIN(process_timestamp), UTC_TIMESTAMP())", - minutes_since_newest="TIMESTAMPDIFF(MINUTE, MAX(process_timestamp), UTC_TIMESTAMP())", - ) - - return {'query': recent_jobs, 'fetch_args': {'order_by': 'minutes_since_newest ASC'}} - Error Log: - route: /pipeline_monitor_errorlog - x: 0 - y: 1 - height: 1 - width: 1 - type: antd-table - restriction: > - def restriction(**kwargs): - return dict(**kwargs) - dj_query: > - def dj_query(aeon_workerlog): - cls = aeon_workerlog.ErrorLog.proj(..., '-error_timestamp', minutes_elapsed='TIMESTAMPDIFF(MINUTE, error_timestamp, UTC_TIMESTAMP())') - return {'query': cls, 'fetch_args': {'order_by': 'minutes_elapsed ASC'}} - Jobs Log: - route: /pipeline_monitor_jobslog - x: 0 - y: 2 - height: 1 - width: 1 - type: antd-table - restriction: > - def restriction(**kwargs): - return dict(**kwargs) - dj_query: > - def dj_query(aeon_workerlog): - workerlog_vm = aeon_workerlog - db_prefix = workerlog_vm.schema.database.replace('workerlog', '') - connection = dj.conn( - host=workerlog_vm.schema.connection.conn_info['host'], - user=workerlog_vm.schema.connection.conn_info['user'], - password=workerlog_vm.schema.connection.conn_info['passwd'], - reset=True) - schema_names = [s for s in dj.list_schemas(connection=connection) if s.startswith(db_prefix)] - jobs_table = None - print(schema_names, flush=True) - for schema_name in schema_names: - vm = dj.VirtualModule(schema_name, schema_name, connection=connection) - jobs_query = dj.U(*vm.schema.jobs.heading.names) & vm.schema.jobs - if jobs_table is None: - jobs_table = jobs_query - else: - jobs_table += jobs_query - jobs_table = jobs_table.proj(..., minutes_elapsed='TIMESTAMPDIFF(MINUTE, timestamp, UTC_TIMESTAMP())') - return {'query': jobs_table, 'fetch_args': {'order_by': 'status DESC, minutes_elapsed ASC'}} - Worker Log: - route: /pipeline_monitor_workerlog - x: 0 - y: 3 - height: 1 - width: 1 - type: antd-table - restriction: > - def restriction(**kwargs): - return dict(**kwargs) - dj_query: > - def dj_query(aeon_workerlog): - cls = aeon_workerlog.WorkerLog.proj(..., minutes_elapsed='TIMESTAMPDIFF(MINUTE, process_timestamp, UTC_TIMESTAMP())') - return {'query': cls, 'fetch_args': {'order_by': 'minutes_elapsed ASC'}} - - DataEntry: - route: /data_entry - grids: - grid5: - type: fixed - columns: 1 - row_height: 1000 - components: - Lab Entry: - route: /lab_form - x: 0 - y: 0 - height: 0.5 - width: 1 - type: form - tables: - - aeon_lab.Arena - map: - - type: attribute - input: Arena Name - destination: arena_name - - type: table - input: Arena Shape - destination: aeon_lab.ArenaShape - - type: attribute - input: X Dimension - destination: arena_x_dim - - type: attribute - input: Y Dimension - destination: arena_y_dim - - type: attribute - input: Z Dimension - destination: arena_z_dim - - type: attribute - input: Arena Description - destination: arena_description - - Subject Entry: - route: /subject_form - x: 0 - y: 0.5 - height: 0.3 - width: 1 - type: form - tables: - - aeon_subject.Subject - map: - - type: attribute - input: Subject ID - destination: subject - - type: attribute - input: Sex - destination: sex - - type: attribute - input: Date of Birth - destination: subject_birth_date - - type: attribute - input: Subject Description - destination: subject_description - - Experiment Entry: - route: /exp_form - x: 0 - y: 0.8 - height: 0.5 - width: 1 - type: form - tables: - - aeon_acquisition.Experiment - map: - - type: attribute - input: Experiment Name - destination: experiment_name - - type: attribute - input: Start Time - destination: experiment_start_time - - type: attribute - input: Description - destination: experiment_description - - type: table - input: Lab Arena - destination: aeon_lab.Arena - - type: table - input: Lab Location - destination: aeon_lab.Location - - type: attribute - input: Experiment Type - destination: experiment_type - - Experiment Subject Entry: - route: /exp_subject_form - x: 0 - y: 1.3 - height: 0.3 - width: 1 - type: form - tables: - - aeon_acquisition.Experiment.Subject - map: - - type: table - input: Experiment Name - destination: aeon_acquisition.Experiment - - type: table - input: Subject in the experiment - destination: aeon_subject.Subject - - Experiment Directory Entry: - route: /exp_subject_dir_form - x: 0 - y: 1.6 - height: 0.3 - - width: 1 - type: form - tables: - - aeon_acquisition.Experiment.Directory - map: - - type: table - input: Experiment Name - destination: aeon_acquisition.Experiment - - type: table - input: Directory Type - destination: aeon_acquisition.DirectoryType - - type: table - input: Pipeline Repository - destination: aeon_acquisition.PipelineRepository - - type: attribute - input: Path to Experiment Directory - destination: directory_path From 88d03792aa163d95e6ba4e45fdbb7dc9f7f42236 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Thu, 17 Aug 2023 18:40:31 +0000 Subject: [PATCH 279/489] update sciviz specsheet --- aeon/dj_pipeline/webapps/sciviz/specsheet.yaml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 27456ae1..21b9964f 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -54,6 +54,7 @@ SciViz: type: form tables: - aeon_lab.Colony + - aeon_subject.Subject map: - type: attribute input: Subject @@ -70,9 +71,12 @@ SciViz: - type: attribute input: Note destination: note + - type: attribute + input: Subject Description + destination: subject_description Colony: route: /colony_page_table - x: 1 + x: 0.5 y: 0 height: 1 width: 1 @@ -655,7 +659,7 @@ SciViz: destination: aeon_lab.Location - type: attribute input: Experiment Type - destination: experiment_type + destination: aeon_acquisition.ExperimentType Experiment Subject Entry: route: /exp_subject_form @@ -695,5 +699,5 @@ SciViz: input: Pipeline Repository destination: aeon_acquisition.PipelineRepository - type: attribute - input: Path to Experiment Directory + input: Full Path to Experiment Data Directory destination: directory_path From 476d34189964d688c4a40376aa1014cb4e2029dd Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 18 Aug 2023 19:48:37 +0000 Subject: [PATCH 280/489] update sciviz specsheet.yaml --- .../dj_pipeline/webapps/sciviz/specsheet.yaml | 365 +++++++----------- 1 file changed, 134 insertions(+), 231 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 21b9964f..00ab97d5 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -76,9 +76,9 @@ SciViz: destination: subject_description Colony: route: /colony_page_table - x: 0.5 - y: 0 - height: 1 + x: 0 + y: 0.5 + height: 0.6 width: 1 type: antd-table restriction: > @@ -87,6 +87,134 @@ SciViz: dj_query: > def dj_query(aeon_lab): return {'query': aeon_lab.Colony(), 'fetch_args': []} + ExperimentEntry: + route: /experiment_entry + grids: + grid5: + type: fixed + columns: 1 + row_height: 1000 + components: + Experiment Entry: + route: /exp_form + x: 0 + y: 0 + height: 0.5 + width: 1 + type: form + tables: + - aeon_acquisition.Experiment + map: + - type: attribute + input: Experiment Name + destination: experiment_name + - type: attribute + input: Start Time + destination: experiment_start_time + - type: attribute + input: Description + destination: experiment_description + - type: table + input: Lab Arena + destination: aeon_lab.Arena + - type: table + input: Lab Location + destination: aeon_lab.Location + - type: attribute + input: Experiment Type + destination: aeon_acquisition.ExperimentType + + Experiment Subject Entry: + route: /exp_subject_form + x: 0 + y: 0.5 + height: 0.3 + width: 1 + type: form + tables: + - aeon_acquisition.Experiment.Subject + map: + - type: table + input: Experiment Name + destination: aeon_acquisition.Experiment + - type: table + input: Subject in the experiment + destination: aeon_subject.Subject + + Experiment Directory Entry: + route: /exp_subject_dir_form + x: 0 + y: 0.8 + height: 0.5 + + width: 1 + type: form + tables: + - aeon_acquisition.Experiment.Directory + map: + - type: table + input: Experiment Name + destination: aeon_acquisition.Experiment + - type: table + input: Directory Type + destination: aeon_acquisition.DirectoryType + - type: table + input: Pipeline Repository + destination: aeon_acquisition.PipelineRepository + - type: attribute + input: Full Path to Experiment Data Directory + destination: directory_path + + LookupEntry: + route: /lab_entry + grids: + grid5: + type: fixed + columns: 1 + row_height: 1000 + components: + Lab Entry: + route: /lab_form + x: 0 + y: 0 + height: 0.5 + width: 1 + type: form + tables: + - aeon_lab.Arena + map: + - type: attribute + input: Arena Name + destination: arena_name + - type: table + input: Arena Shape + destination: aeon_lab.ArenaShape + - type: attribute + input: X Dimension + destination: arena_x_dim + - type: attribute + input: Y Dimension + destination: arena_y_dim + - type: attribute + input: Z Dimension + destination: arena_z_dim + - type: attribute + input: Arena Description + destination: arena_description + + Experiment Type Entry: + route: /exp_type_form + x: 0 + y: 0.5 + height: 0.3 + width: 1 + type: form + tables: + - aeon_acquisition.ExperimentType + map: + - type: attribute + input: Experiment Type + destination: experiment_type Subjects: route: /subjects @@ -458,9 +586,9 @@ SciViz: width: 2 type: slideshow:aeon route: /videostream_video_streamer - batch_size: 6 - chunk_size: 30 - buffer_size: 30 + batch_size: 9 + chunk_size: 60 + buffer_size: 60 max_FPS: 50 channels: [ @@ -476,228 +604,3 @@ SciViz: acquisition = aeon_acquisition q = dj.U('camera_description', 'raw_data_dir') & (acquisition.ExperimentCamera * acquisition.Experiment.Directory & 'directory_type = "raw"').proj('camera_description', raw_data_dir="CONCAT('/ceph/aeon/', directory_path)") return {'query': q, 'fetch_args': []} - PipelineMonitor: - route: /pipeline_monitor - grids: - grid1: - type: fixed - columns: 1 - row_height: 680 - components: - Worker Status: - route: /pipeline_monitor_workerstatus - x: 0 - y: 0 - height: 1 - width: 1 - type: antd-table - restriction: > - def restriction(**kwargs): - return dict(**kwargs) - dj_query: > - def dj_query(aeon_workerlog): - cls = aeon_workerlog.WorkerLog - backtrack_minutes = 60 - recent = ( - cls.proj( - minute_elapsed="TIMESTAMPDIFF(MINUTE, process_timestamp, UTC_TIMESTAMP())" - ) - & f"minute_elapsed < {backtrack_minutes}" - ) - recent_jobs = dj.U("process").aggr( - cls & recent, - worker_count="count(DISTINCT pid)", - minutes_since_oldest="TIMESTAMPDIFF(MINUTE, MIN(process_timestamp), UTC_TIMESTAMP())", - minutes_since_newest="TIMESTAMPDIFF(MINUTE, MAX(process_timestamp), UTC_TIMESTAMP())", - ) - - return {'query': recent_jobs, 'fetch_args': {'order_by': 'minutes_since_newest ASC'}} - Error Log: - route: /pipeline_monitor_errorlog - x: 0 - y: 1 - height: 1 - width: 1 - type: antd-table - restriction: > - def restriction(**kwargs): - return dict(**kwargs) - dj_query: > - def dj_query(aeon_workerlog): - cls = aeon_workerlog.ErrorLog.proj(..., '-error_timestamp', minutes_elapsed='TIMESTAMPDIFF(MINUTE, error_timestamp, UTC_TIMESTAMP())') - return {'query': cls, 'fetch_args': {'order_by': 'minutes_elapsed ASC'}} - Jobs Log: - route: /pipeline_monitor_jobslog - x: 0 - y: 2 - height: 1 - width: 1 - type: antd-table - restriction: > - def restriction(**kwargs): - return dict(**kwargs) - dj_query: > - def dj_query(aeon_workerlog): - workerlog_vm = aeon_workerlog - db_prefix = workerlog_vm.schema.database.replace('workerlog', '') - connection = dj.conn( - host=workerlog_vm.schema.connection.conn_info['host'], - user=workerlog_vm.schema.connection.conn_info['user'], - password=workerlog_vm.schema.connection.conn_info['passwd'], - reset=True) - schema_names = [s for s in dj.list_schemas(connection=connection) if s.startswith(db_prefix)] - jobs_table = None - print(schema_names, flush=True) - for schema_name in schema_names: - vm = dj.VirtualModule(schema_name, schema_name, connection=connection) - jobs_query = dj.U(*vm.schema.jobs.heading.names) & vm.schema.jobs - if jobs_table is None: - jobs_table = jobs_query - else: - jobs_table += jobs_query - jobs_table = jobs_table.proj(..., minutes_elapsed='TIMESTAMPDIFF(MINUTE, timestamp, UTC_TIMESTAMP())') - return {'query': jobs_table, 'fetch_args': {'order_by': 'status DESC, minutes_elapsed ASC'}} - Worker Log: - route: /pipeline_monitor_workerlog - x: 0 - y: 3 - height: 1 - width: 1 - type: antd-table - restriction: > - def restriction(**kwargs): - return dict(**kwargs) - dj_query: > - def dj_query(aeon_workerlog): - cls = aeon_workerlog.WorkerLog.proj(..., minutes_elapsed='TIMESTAMPDIFF(MINUTE, process_timestamp, UTC_TIMESTAMP())') - return {'query': cls, 'fetch_args': {'order_by': 'minutes_elapsed ASC'}} - - DataEntry: - route: /data_entry - grids: - grid5: - type: fixed - columns: 1 - row_height: 1000 - components: - Lab Entry: - route: /lab_form - x: 0 - y: 0 - height: 0.5 - width: 1 - type: form - tables: - - aeon_lab.Arena - map: - - type: attribute - input: Arena Name - destination: arena_name - - type: table - input: Arena Shape - destination: aeon_lab.ArenaShape - - type: attribute - input: X Dimension - destination: arena_x_dim - - type: attribute - input: Y Dimension - destination: arena_y_dim - - type: attribute - input: Z Dimension - destination: arena_z_dim - - type: attribute - input: Arena Description - destination: arena_description - - Subject Entry: - route: /subject_form - x: 0 - y: 0.5 - height: 0.3 - width: 1 - type: form - tables: - - aeon_subject.Subject - map: - - type: attribute - input: Subject ID - destination: subject - - type: attribute - input: Sex - destination: sex - - type: attribute - input: Date of Birth - destination: subject_birth_date - - type: attribute - input: Subject Description - destination: subject_description - - Experiment Entry: - route: /exp_form - x: 0 - y: 0.8 - height: 0.5 - width: 1 - type: form - tables: - - aeon_acquisition.Experiment - map: - - type: attribute - input: Experiment Name - destination: experiment_name - - type: attribute - input: Start Time - destination: experiment_start_time - - type: attribute - input: Description - destination: experiment_description - - type: table - input: Lab Arena - destination: aeon_lab.Arena - - type: table - input: Lab Location - destination: aeon_lab.Location - - type: attribute - input: Experiment Type - destination: aeon_acquisition.ExperimentType - - Experiment Subject Entry: - route: /exp_subject_form - x: 0 - y: 1.3 - height: 0.3 - width: 1 - type: form - tables: - - aeon_acquisition.Experiment.Subject - map: - - type: table - input: Experiment Name - destination: aeon_acquisition.Experiment - - type: table - input: Subject in the experiment - destination: aeon_subject.Subject - - Experiment Directory Entry: - route: /exp_subject_dir_form - x: 0 - y: 1.6 - height: 0.3 - - width: 1 - type: form - tables: - - aeon_acquisition.Experiment.Directory - map: - - type: table - input: Experiment Name - destination: aeon_acquisition.Experiment - - type: table - input: Directory Type - destination: aeon_acquisition.DirectoryType - - type: table - input: Pipeline Repository - destination: aeon_acquisition.PipelineRepository - - type: attribute - input: Full Path to Experiment Data Directory - destination: directory_path From 0e248add8f19f930013a29ae342d7de05c2af45c Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 18 Aug 2023 19:50:54 +0000 Subject: [PATCH 281/489] increase concurrency in pharus --- aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml index 15a0b3ac..b8167fcd 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml @@ -23,7 +23,7 @@ services: apk add --update git g++ && git clone -b datajoint_pipeline https://github.com/SainsburyWellcomeCentre/aeon_mecha.git && pip install -e ./aeon_mecha && - gunicorn --bind 0.0.0.0:$${PHARUS_PORT} --workers=3 pharus.server:app + gunicorn --bind 0.0.0.0:$${PHARUS_PORT} --workers=4 pharus.server:app # ports: # - "5000:5000" From 805694858ae7300d6b67e6f969e5b56292abd3bb Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 18 Aug 2023 23:35:58 +0000 Subject: [PATCH 282/489] fix: :bug: fix a reader pattern --- aeon/dj_pipeline/acquisition.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index c3860946..79c5bb58 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -1127,7 +1127,7 @@ def _get_all_chunks(experiment_name, device_name): chunkdata = io_api.load( root=raw_data_dirs.values(), - reader=io_reader.Chunk(pattern=device_name, extension="csv"), + reader=io_reader.Chunk(pattern=device_name + "*", extension="csv"), ) return chunkdata, raw_data_dirs From 459e81e9d01d5bd85615127c11d716a0c0234961 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 18 Aug 2023 23:36:48 +0000 Subject: [PATCH 283/489] replace presocial with multianimal --- aeon/dj_pipeline/acquisition.py | 8 ++------ aeon/schema/dataset.py | 12 ++++++------ 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 79c5bb58..797520a5 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -23,9 +23,7 @@ "social0-r1": "FrameTop", "exp0.2-r0": "CameraTop", "oct1.0-r0": "CameraTop", - "presocial0.1-a2": "CameraTop", - "presocial0.1-a3": "CameraTop", - "presocial0.1-a4": "CameraTop", + "multianimal": "CameraTop", } _device_schema_mapping = { @@ -33,9 +31,7 @@ "social0-r1": aeon_schema.exp01, "exp0.2-r0": aeon_schema.exp02, "oct1.0-r0": aeon_schema.octagon01, - "presocial0.1-a2": aeon_schema.presocial, - "presocial0.1-a3": aeon_schema.presocial, - "presocial0.1-a4": aeon_schema.presocial, + "multianimal": aeon_schema.multianimal } diff --git a/aeon/schema/dataset.py b/aeon/schema/dataset.py index f74bcce7..0cb2c8a4 100644 --- a/aeon/schema/dataset.py +++ b/aeon/schema/dataset.py @@ -6,7 +6,7 @@ from aeon.io import reader from aeon.io.device import Device -__all__ = ["exp02", "exp01", "octagon01", "presocial"] +__all__ = ["exp02", "exp01", "octagon01", "multianimal"] exp02 = DotMap( [ @@ -63,16 +63,16 @@ ] ) -presocial = exp02 -presocial.Patch1.BeamBreak = reader.BitmaskEvent( +multianimal = exp02 +multianimal.Patch1.BeamBreak = reader.BitmaskEvent( pattern="Patch1_32", value=0x22, tag="BeamBroken" ) -presocial.Patch2.BeamBreak = reader.BitmaskEvent( +multianimal.Patch2.BeamBreak = reader.BitmaskEvent( pattern="Patch2_32", value=0x22, tag="BeamBroken" ) -presocial.Patch1.DeliverPellet = reader.BitmaskEvent( +multianimal.Patch1.DeliverPellet = reader.BitmaskEvent( pattern="Patch1_35", value=0x1, tag="TriggeredPellet" ) -presocial.Patch2.DeliverPellet = reader.BitmaskEvent( +multianimal.Patch2.DeliverPellet = reader.BitmaskEvent( pattern="Patch2_35", value=0x1, tag="TriggeredPellet" ) From d5427fbc362ddab0b23d885a29f4ac9d83b43f44 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 28 Aug 2023 15:46:30 +0000 Subject: [PATCH 284/489] fix streams import error --- aeon/dj_pipeline/__init__.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/aeon/dj_pipeline/__init__.py b/aeon/dj_pipeline/__init__.py index ea805b57..c5593b34 100644 --- a/aeon/dj_pipeline/__init__.py +++ b/aeon/dj_pipeline/__init__.py @@ -1,8 +1,8 @@ +import hashlib import os +import uuid import datajoint as dj -import hashlib -import uuid _default_database_prefix = os.getenv("DJ_DB_PREFIX") or "aeon_" _default_repository_config = {"ceph_aeon": "/ceph/aeon"} @@ -16,12 +16,6 @@ repository_config = dj.config['custom'].get('repository_config', _default_repository_config) -try: - from .utils import streams_maker - streams = dj.VirtualModule("streams", streams_maker.STREAMS_MODULE_NAME) -except: - pass - def get_schema_name(name): return db_prefix + name @@ -36,3 +30,13 @@ def dict_to_uuid(key): hashed.update(str(k).encode()) hashed.update(str(v).encode()) return uuid.UUID(hex=hashed.hexdigest()) + + +try: + from . import streams +except ImportError: + try: + from .utils import streams_maker + streams = dj.VirtualModule("streams", streams_maker.STREAMS_MODULE_NAME) + except: + pass \ No newline at end of file From 14a9caddf49ef08dab1c10c668631853b377fcb8 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 28 Aug 2023 15:48:04 +0000 Subject: [PATCH 285/489] multianimal streams ingestion --- .../device_type_mapper.json | 2 +- aeon/dj_pipeline/streams.py | 318 +----------------- aeon/dj_pipeline/utils/streams_maker.py | 26 +- 3 files changed, 27 insertions(+), 319 deletions(-) diff --git a/aeon/dj_pipeline/create_experiments/device_type_mapper.json b/aeon/dj_pipeline/create_experiments/device_type_mapper.json index 5ffbee8f..c4738ec4 100644 --- a/aeon/dj_pipeline/create_experiments/device_type_mapper.json +++ b/aeon/dj_pipeline/create_experiments/device_type_mapper.json @@ -1 +1 @@ -{"VideoController": "VideoController", "CameraTop": "VideoSource", "CameraWest": "VideoSource", "CameraEast": "VideoSource", "CameraNorth": "VideoSource", "CameraSouth": "VideoSource", "CameraPatch1": "VideoSource", "CameraPatch2": "VideoSource", "CameraNest": "VideoSource", "AudioAmbient": "AudioSource", "Patch1": "UndergroundFeeder", "Patch2": "UndergroundFeeder", "WeightNest": "WeightScale", "TrackingTop": "PositionTracking", "ActivityCenter": "ActivityTracking", "ActivityArena": "ActivityTracking", "ActivityNest": "ActivityTracking", "ActivityPatch1": "ActivityTracking", "ActivityPatch2": "ActivityTracking", "InNest": "RegionTracking", "InPatch1": "RegionTracking", "InPatch2": "RegionTracking", "ArenaCenter": "DistanceFromPoint", "InArena": "InRange", "InCorridor": "InRange", "ClockSynchronizer": "Synchronizer"} \ No newline at end of file +{"VideoController": "CameraController", "CameraTop": "VideoSource", "CameraWest": "VideoSource", "CameraEast": "VideoSource", "CameraNorth": "VideoSource", "CameraSouth": "VideoSource", "CameraPatch1": "VideoSource", "CameraPatch2": "VideoSource", "CameraNest": "VideoSource", "AudioAmbient": "AudioSource", "Patch1": "UndergroundFeeder", "Patch2": "UndergroundFeeder", "WeightNest": "WeightScale", "TrackingTop": "PositionTracking", "ActivityCenter": "ActivityTracking", "ActivityArena": "ActivityTracking", "ActivityNest": "ActivityTracking", "ActivityPatch1": "ActivityTracking", "ActivityPatch2": "ActivityTracking", "InNest": "RegionTracking", "InPatch1": "RegionTracking", "InPatch2": "RegionTracking", "ArenaCenter": "DistanceFromPoint", "InArena": "InRange", "InCorridor": "InRange", "ClockSynchronizer": "Synchronizer", "Rfid": "Rfid Reader"} \ No newline at end of file diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index 120b41fb..9e90164e 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -9,8 +9,7 @@ from aeon.dj_pipeline import acquisition, get_schema_name from aeon.io import api as io_api -schema_name = get_schema_name('streams') -schema = dj.Schema() +schema = dj.Schema(get_schema_name("streams")) @schema @@ -61,33 +60,6 @@ class Device(dj.Lookup): """ -@schema -class Patch(dj.Manual): - definition = f""" - # patch placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown) - -> acquisition.Experiment - -> Device - patch_install_time : datetime(6) # time of the patch placed and started operation at this position - --- - patch_name : varchar(36) - """ - - class Attribute(dj.Part): - definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device - -> master - attribute_name : varchar(32) - --- - attribute_value=null : longblob - """ - - class RemovalTime(dj.Part): - definition = f""" - -> master - --- - patch_removal_time: datetime(6) # time of the patch being removed - """ - - @schema class UndergroundFeeder(dj.Manual): definition = f""" @@ -142,270 +114,6 @@ class RemovalTime(dj.Part): """ -@schema -class PatchBeamBreak(dj.Imported): - definition = """# Raw per-chunk BeamBreak data stream from Patch (auto-generated with aeon_mecha-unknown) - -> Patch - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of BeamBreak data - """ - _stream_reader = aeon.io.reader.BitmaskEvent - _stream_detail = {'stream_type': 'BeamBreak', 'stream_reader': 'aeon.io.reader.BitmaskEvent', 'stream_reader_kwargs': {'pattern': '{pattern}_32', 'value': 34, 'tag': 'BeamBroken'}, 'stream_description': '', 'stream_hash': UUID('b14171e6-d27d-117a-ae73-a16c4b5fc8a2')} - - @property - def key_source(self): - f""" - Only the combination of Chunk and Patch with overlapping time - + Chunk(s) that started after Patch install time and ended before Patch remove time - + Chunk(s) that started after Patch install time for Patch that are not yet removed - """ - return ( - acquisition.Chunk - * Patch.join(Patch.RemovalTime, left=True) - & 'chunk_start >= patch_install_time' - & 'chunk_start < IFNULL(patch_removal_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory( - key, directory_type=dir_type - ) - - device_name = (Patch & key).fetch1( - 'patch_name' - ) - - stream = self._stream_reader( - **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) - - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{ - c: stream_data[c].values - for c in stream.columns - if not c.startswith("_") - }, - } - ) - - -@schema -class PatchDeliverPellet(dj.Imported): - definition = """# Raw per-chunk DeliverPellet data stream from Patch (auto-generated with aeon_mecha-unknown) - -> Patch - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of DeliverPellet data - """ - _stream_reader = aeon.io.reader.BitmaskEvent - _stream_detail = {'stream_type': 'DeliverPellet', 'stream_reader': 'aeon.io.reader.BitmaskEvent', 'stream_reader_kwargs': {'pattern': '{pattern}_35', 'value': 1, 'tag': 'TriggeredPellet'}, 'stream_description': '', 'stream_hash': UUID('c49dda51-2e38-8b49-d1d8-2e54ea928e9c')} - - @property - def key_source(self): - f""" - Only the combination of Chunk and Patch with overlapping time - + Chunk(s) that started after Patch install time and ended before Patch remove time - + Chunk(s) that started after Patch install time for Patch that are not yet removed - """ - return ( - acquisition.Chunk - * Patch.join(Patch.RemovalTime, left=True) - & 'chunk_start >= patch_install_time' - & 'chunk_start < IFNULL(patch_removal_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory( - key, directory_type=dir_type - ) - - device_name = (Patch & key).fetch1( - 'patch_name' - ) - - stream = self._stream_reader( - **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) - - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{ - c: stream_data[c].values - for c in stream.columns - if not c.startswith("_") - }, - } - ) - - -@schema -class PatchDepletionState(dj.Imported): - definition = """# Raw per-chunk DepletionState data stream from Patch (auto-generated with aeon_mecha-unknown) - -> Patch - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of DepletionState data - """ - _stream_reader = aeon.schema.foraging._PatchState - _stream_detail = {'stream_type': 'DepletionState', 'stream_reader': 'aeon.schema.foraging._PatchState', 'stream_reader_kwargs': {'pattern': '{pattern}_State'}, 'stream_description': '', 'stream_hash': UUID('73025490-348c-18fd-d565-8e682b5b4bcd')} - - @property - def key_source(self): - f""" - Only the combination of Chunk and Patch with overlapping time - + Chunk(s) that started after Patch install time and ended before Patch remove time - + Chunk(s) that started after Patch install time for Patch that are not yet removed - """ - return ( - acquisition.Chunk - * Patch.join(Patch.RemovalTime, left=True) - & 'chunk_start >= patch_install_time' - & 'chunk_start < IFNULL(patch_removal_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory( - key, directory_type=dir_type - ) - - device_name = (Patch & key).fetch1( - 'patch_name' - ) - - stream = self._stream_reader( - **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) - - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{ - c: stream_data[c].values - for c in stream.columns - if not c.startswith("_") - }, - } - ) - - -@schema -class PatchEncoder(dj.Imported): - definition = """# Raw per-chunk Encoder data stream from Patch (auto-generated with aeon_mecha-unknown) - -> Patch - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of Encoder data - """ - _stream_reader = aeon.io.reader.Encoder - _stream_detail = {'stream_type': 'Encoder', 'stream_reader': 'aeon.io.reader.Encoder', 'stream_reader_kwargs': {'pattern': '{pattern}_90'}, 'stream_description': '', 'stream_hash': UUID('45002714-c31d-b2b8-a6e6-6ae624385cc1')} - - @property - def key_source(self): - f""" - Only the combination of Chunk and Patch with overlapping time - + Chunk(s) that started after Patch install time and ended before Patch remove time - + Chunk(s) that started after Patch install time for Patch that are not yet removed - """ - return ( - acquisition.Chunk - * Patch.join(Patch.RemovalTime, left=True) - & 'chunk_start >= patch_install_time' - & 'chunk_start < IFNULL(patch_removal_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory( - key, directory_type=dir_type - ) - - device_name = (Patch & key).fetch1( - 'patch_name' - ) - - stream = self._stream_reader( - **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) - - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{ - c: stream_data[c].values - for c in stream.columns - if not c.startswith("_") - }, - } - ) - - @schema class UndergroundFeederBeamBreak(dj.Imported): definition = """# Raw per-chunk BeamBreak data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) @@ -468,7 +176,7 @@ def make(self, key): for c in stream.columns if not c.startswith("_") }, - } + }, ignore_extra_fields=True ) @@ -534,7 +242,7 @@ def make(self, key): for c in stream.columns if not c.startswith("_") }, - } + }, ignore_extra_fields=True ) @@ -548,7 +256,7 @@ class UndergroundFeederDepletionState(dj.Imported): timestamps: longblob # (datetime) timestamps of DepletionState data """ _stream_reader = aeon.schema.foraging._PatchState - _stream_detail = {'stream_type': 'DepletionState', 'stream_reader': 'aeon.schema.foraging._PatchState', 'stream_reader_kwargs': {'pattern': '{pattern}_State'}, 'stream_description': '', 'stream_hash': UUID('73025490-348c-18fd-d565-8e682b5b4bcd')} + _stream_detail = {'stream_type': 'DepletionState', 'stream_reader': 'aeon.schema.foraging._PatchState', 'stream_reader_kwargs': {'pattern': '{pattern}_State_*'}, 'stream_description': '', 'stream_hash': UUID('17c3e36f-3f2e-2494-bbd3-5cb9a23d3039')} @property def key_source(self): @@ -600,7 +308,7 @@ def make(self, key): for c in stream.columns if not c.startswith("_") }, - } + }, ignore_extra_fields=True ) @@ -614,7 +322,7 @@ class UndergroundFeederEncoder(dj.Imported): timestamps: longblob # (datetime) timestamps of Encoder data """ _stream_reader = aeon.io.reader.Encoder - _stream_detail = {'stream_type': 'Encoder', 'stream_reader': 'aeon.io.reader.Encoder', 'stream_reader_kwargs': {'pattern': '{pattern}_90'}, 'stream_description': '', 'stream_hash': UUID('45002714-c31d-b2b8-a6e6-6ae624385cc1')} + _stream_detail = {'stream_type': 'Encoder', 'stream_reader': 'aeon.io.reader.Encoder', 'stream_reader_kwargs': {'pattern': '{pattern}_90_*'}, 'stream_description': '', 'stream_hash': UUID('f96b0b26-26f6-5ff6-b3c7-5aa5adc00c1a')} @property def key_source(self): @@ -666,7 +374,7 @@ def make(self, key): for c in stream.columns if not c.startswith("_") }, - } + }, ignore_extra_fields=True ) @@ -680,7 +388,7 @@ class VideoSourcePosition(dj.Imported): timestamps: longblob # (datetime) timestamps of Position data """ _stream_reader = aeon.io.reader.Position - _stream_detail = {'stream_type': 'Position', 'stream_reader': 'aeon.io.reader.Position', 'stream_reader_kwargs': {'pattern': '{pattern}_200'}, 'stream_description': '', 'stream_hash': UUID('75f9f365-037a-1e9b-ad38-8b2b3783315d')} + _stream_detail = {'stream_type': 'Position', 'stream_reader': 'aeon.io.reader.Position', 'stream_reader_kwargs': {'pattern': '{pattern}_200_*'}, 'stream_description': '', 'stream_hash': UUID('d7727726-1f52-78e1-1355-b863350b6d03')} @property def key_source(self): @@ -732,7 +440,7 @@ def make(self, key): for c in stream.columns if not c.startswith("_") }, - } + }, ignore_extra_fields=True ) @@ -746,7 +454,7 @@ class VideoSourceRegion(dj.Imported): timestamps: longblob # (datetime) timestamps of Region data """ _stream_reader = aeon.schema.foraging._RegionReader - _stream_detail = {'stream_type': 'Region', 'stream_reader': 'aeon.schema.foraging._RegionReader', 'stream_reader_kwargs': {'pattern': '{pattern}_201'}, 'stream_description': '', 'stream_hash': UUID('6234a429-8ae5-d7dc-41c8-602ac76da029')} + _stream_detail = {'stream_type': 'Region', 'stream_reader': 'aeon.schema.foraging._RegionReader', 'stream_reader_kwargs': {'pattern': '{pattern}_201_*'}, 'stream_description': '', 'stream_hash': UUID('6c78b3ac-ffff-e2ab-c446-03e3adf4d80a')} @property def key_source(self): @@ -798,7 +506,7 @@ def make(self, key): for c in stream.columns if not c.startswith("_") }, - } + }, ignore_extra_fields=True ) @@ -812,7 +520,7 @@ class VideoSourceVideo(dj.Imported): timestamps: longblob # (datetime) timestamps of Video data """ _stream_reader = aeon.io.reader.Video - _stream_detail = {'stream_type': 'Video', 'stream_reader': 'aeon.io.reader.Video', 'stream_reader_kwargs': {'pattern': '{pattern}'}, 'stream_description': '', 'stream_hash': UUID('4246295b-789f-206d-b413-7af25b7548b2')} + _stream_detail = {'stream_type': 'Video', 'stream_reader': 'aeon.io.reader.Video', 'stream_reader_kwargs': {'pattern': '{pattern}_*'}, 'stream_description': '', 'stream_hash': UUID('f51c6174-e0c4-a888-3a9d-6f97fb6a019b')} @property def key_source(self): @@ -864,7 +572,7 @@ def make(self, key): for c in stream.columns if not c.startswith("_") }, - } + }, ignore_extra_fields=True ) diff --git a/aeon/dj_pipeline/utils/streams_maker.py b/aeon/dj_pipeline/utils/streams_maker.py index 46043e94..77ce250b 100644 --- a/aeon/dj_pipeline/utils/streams_maker.py +++ b/aeon/dj_pipeline/utils/streams_maker.py @@ -1,9 +1,10 @@ +import importlib import inspect +import re from pathlib import Path + import datajoint as dj import pandas as pd -import re -import importlib import aeon from aeon.dj_pipeline import acquisition, get_schema_name @@ -192,7 +193,7 @@ def make(self, key): for c in stream.columns if not c.startswith("_") }, - } + }, ignore_extra_fields=True ) DeviceDataStream.__name__ = f"{device_type}{stream_type}" @@ -208,16 +209,15 @@ def main(create_tables=True): if not _STREAMS_MODULE_FILE.exists(): with open(_STREAMS_MODULE_FILE, "w") as f: imports_str = ( - "#---- DO NOT MODIFY ----\n" - "#---- THIS FILE IS AUTO-GENERATED BY `streams_maker.py` ----\n\n" - "import datajoint as dj\n" - "import pandas as pd\n" - "from uuid import UUID\n\n" - "import aeon\n" - "from aeon.dj_pipeline import acquisition, get_schema_name\n" - "from aeon.io import api as io_api\n\n" - "schema_name = get_schema_name('streams')\n" - "schema = dj.Schema()\n\n\n" + '#---- DO NOT MODIFY ----\n' + '#---- THIS FILE IS AUTO-GENERATED BY `streams_maker.py` ----\n\n' + 'import datajoint as dj\n' + 'import pandas as pd\n' + 'from uuid import UUID\n\n' + 'import aeon\n' + 'from aeon.dj_pipeline import acquisition, get_schema_name\n' + 'from aeon.io import api as io_api\n\n' + 'schema = dj.Schema(get_schema_name("streams"))\n\n\n' ) f.write(imports_str) for table_class in (StreamType, DeviceType, Device): From 03b88315e771ec23dcbc480b9637c36995cf8bd2 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 29 Aug 2023 21:32:45 +0000 Subject: [PATCH 286/489] VideoSourceTracking table definition --- aeon/dj_pipeline/tracking.py | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/aeon/dj_pipeline/tracking.py b/aeon/dj_pipeline/tracking.py index 05ff4489..df2198e3 100644 --- a/aeon/dj_pipeline/tracking.py +++ b/aeon/dj_pipeline/tracking.py @@ -242,21 +242,39 @@ class VideoSourceTracking(dj.Imported): -> acquisition.Chunk -> streams.VideoSource -> TrackingParamSet + --- + tracking_timestamps: longblob # (datetime) timestamps of the position data """ - class Object(dj.Part): - definition = """ # Position data of object tracked by a particular camera tracking + class Point(dj.Part): + definition = """ + -> master + point_name: varchar(16) + --- + point_x: longblob + point_y: longblob + point_confidence: longblob + """ + + class Pose(dj.Part): + definition = """ -> master - object_name: varchar(16) + pose_name: varchar(16) --- - timestamps: longblob # (datetime) timestamps of the position data class: smallint class_confidence: longblob centroid_x: longblob centroid_y: longblob centroid_confidence: longblob + point_collection: varchar(1000) # List of point names """ - + + class PointCollection(dj.Part): + definition = """ + -> master.Pose + -> master.Point + """ + @property def key_source(self): ks = acquisition.Chunk * streams.VideoSource * TrackingParamSet From 4cadd6f8fb91b08f71d438d9d74021ce05b5f0f8 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 29 Aug 2023 21:33:28 +0000 Subject: [PATCH 287/489] fix: :bug: fix bugs in streams table definitions --- aeon/dj_pipeline/utils/streams_maker.py | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/aeon/dj_pipeline/utils/streams_maker.py b/aeon/dj_pipeline/utils/streams_maker.py index 77ce250b..851c0c84 100644 --- a/aeon/dj_pipeline/utils/streams_maker.py +++ b/aeon/dj_pipeline/utils/streams_maker.py @@ -16,8 +16,8 @@ # schema_name = f'u_{dj.config["database.user"]}_streams' # for testing schema_name = get_schema_name("streams") -STREAMS_MODULE_NAME = "streams" -_STREAMS_MODULE_FILE = Path(__file__).parent.parent / f"{STREAMS_MODULE_NAME}.py" +STREAMS_MODULE_NAME = "aeon_streams" +_STREAMS_MODULE_FILE = Path(__file__).parent.parent / "streams.py" class StreamType(dj.Lookup): @@ -136,7 +136,7 @@ def get_device_stream_template(device_type: str, stream_type: str, streams_modul for col in stream.columns: if col.startswith("_"): continue - table_definition += f"{col}: longblob\n\t\t\t" + table_definition += f"{col}: longblob\n " class DeviceDataStream(dj.Imported): definition = table_definition @@ -198,7 +198,7 @@ def make(self, key): DeviceDataStream.__name__ = f"{device_type}{stream_type}" - return DeviceDataStream + return DeviceDataStream, table_definition # endregion @@ -225,7 +225,7 @@ def main(create_tables=True): full_def = "@schema \n" + device_table_def + "\n\n" f.write(full_def) - streams = importlib.import_module(f"aeon.dj_pipeline.{STREAMS_MODULE_NAME}") + streams = importlib.import_module(f"aeon.dj_pipeline.streams") streams.schema.activate(schema_name) if create_tables: @@ -266,7 +266,7 @@ def main(create_tables=True): if hasattr(streams, table_name): continue - table_class = get_device_stream_template( + table_class, table_definition = get_device_stream_template( device_type, stream_type, streams_module=streams ) @@ -276,14 +276,7 @@ def main(create_tables=True): device_stream_table_def = inspect.getsource(table_class).lstrip() - old_definition = f"""# Raw per-chunk {stream_type} data stream from {device_type} (auto-generated with aeon_mecha-{aeon.__version__}) - -> {device_type} - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of {stream_type} data - """ - + # Replace the definition replacements = { "DeviceDataStream": f"{device_type}{stream_type}", "ExperimentDevice": device_type, @@ -294,10 +287,8 @@ def main(create_tables=True): "{stream_type}": stream_type, "{aeon.__version__}": aeon.__version__, } - for old, new in replacements.items(): - new_definition = old_definition.replace(old, new) - replacements["table_definition"] = '"""' + new_definition + '"""' + replacements["table_definition"] = '"""' + table_definition + '"""' for old, new in replacements.items(): device_stream_table_def = device_stream_table_def.replace(old, new) From 495b207ddd7f0d8097e95589474dfa53b8f3f0c6 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 29 Aug 2023 21:34:22 +0000 Subject: [PATCH 288/489] new streams schema table definitions --- aeon/dj_pipeline/streams.py | 115 +++++++++++++++++++++--------------- 1 file changed, 66 insertions(+), 49 deletions(-) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index 9e90164e..4483d1dd 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -116,13 +116,14 @@ class RemovalTime(dj.Part): @schema class UndergroundFeederBeamBreak(dj.Imported): - definition = """# Raw per-chunk BeamBreak data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) - -> UndergroundFeeder - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of BeamBreak data - """ + definition = """ # Raw per-chunk BeamBreak data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + -> UndergroundFeeder + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of BeamBreak data + event: longblob + """ _stream_reader = aeon.io.reader.BitmaskEvent _stream_detail = {'stream_type': 'BeamBreak', 'stream_reader': 'aeon.io.reader.BitmaskEvent', 'stream_reader_kwargs': {'pattern': '{pattern}_32', 'value': 34, 'tag': 'BeamBroken'}, 'stream_description': '', 'stream_hash': UUID('b14171e6-d27d-117a-ae73-a16c4b5fc8a2')} @@ -182,13 +183,14 @@ def make(self, key): @schema class UndergroundFeederDeliverPellet(dj.Imported): - definition = """# Raw per-chunk DeliverPellet data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) - -> UndergroundFeeder - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of DeliverPellet data - """ + definition = """ # Raw per-chunk DeliverPellet data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + -> UndergroundFeeder + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of DeliverPellet data + event: longblob + """ _stream_reader = aeon.io.reader.BitmaskEvent _stream_detail = {'stream_type': 'DeliverPellet', 'stream_reader': 'aeon.io.reader.BitmaskEvent', 'stream_reader_kwargs': {'pattern': '{pattern}_35', 'value': 1, 'tag': 'TriggeredPellet'}, 'stream_description': '', 'stream_hash': UUID('c49dda51-2e38-8b49-d1d8-2e54ea928e9c')} @@ -248,13 +250,16 @@ def make(self, key): @schema class UndergroundFeederDepletionState(dj.Imported): - definition = """# Raw per-chunk DepletionState data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) - -> UndergroundFeeder - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of DepletionState data - """ + definition = """ # Raw per-chunk DepletionState data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + -> UndergroundFeeder + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of DepletionState data + threshold: longblob + d1: longblob + delta: longblob + """ _stream_reader = aeon.schema.foraging._PatchState _stream_detail = {'stream_type': 'DepletionState', 'stream_reader': 'aeon.schema.foraging._PatchState', 'stream_reader_kwargs': {'pattern': '{pattern}_State_*'}, 'stream_description': '', 'stream_hash': UUID('17c3e36f-3f2e-2494-bbd3-5cb9a23d3039')} @@ -314,13 +319,15 @@ def make(self, key): @schema class UndergroundFeederEncoder(dj.Imported): - definition = """# Raw per-chunk Encoder data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) - -> UndergroundFeeder - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of Encoder data - """ + definition = """ # Raw per-chunk Encoder data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + -> UndergroundFeeder + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of Encoder data + angle: longblob + intensity: longblob + """ _stream_reader = aeon.io.reader.Encoder _stream_detail = {'stream_type': 'Encoder', 'stream_reader': 'aeon.io.reader.Encoder', 'stream_reader_kwargs': {'pattern': '{pattern}_90_*'}, 'stream_description': '', 'stream_hash': UUID('f96b0b26-26f6-5ff6-b3c7-5aa5adc00c1a')} @@ -380,13 +387,20 @@ def make(self, key): @schema class VideoSourcePosition(dj.Imported): - definition = """# Raw per-chunk Position data stream from VideoSource (auto-generated with aeon_mecha-unknown) - -> VideoSource - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of Position data - """ + definition = """ # Raw per-chunk Position data stream from VideoSource (auto-generated with aeon_mecha-unknown) + -> VideoSource + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of Position data + x: longblob + y: longblob + angle: longblob + major: longblob + minor: longblob + area: longblob + id: longblob + """ _stream_reader = aeon.io.reader.Position _stream_detail = {'stream_type': 'Position', 'stream_reader': 'aeon.io.reader.Position', 'stream_reader_kwargs': {'pattern': '{pattern}_200_*'}, 'stream_description': '', 'stream_hash': UUID('d7727726-1f52-78e1-1355-b863350b6d03')} @@ -446,13 +460,14 @@ def make(self, key): @schema class VideoSourceRegion(dj.Imported): - definition = """# Raw per-chunk Region data stream from VideoSource (auto-generated with aeon_mecha-unknown) - -> VideoSource - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of Region data - """ + definition = """ # Raw per-chunk Region data stream from VideoSource (auto-generated with aeon_mecha-unknown) + -> VideoSource + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of Region data + region: longblob + """ _stream_reader = aeon.schema.foraging._RegionReader _stream_detail = {'stream_type': 'Region', 'stream_reader': 'aeon.schema.foraging._RegionReader', 'stream_reader_kwargs': {'pattern': '{pattern}_201_*'}, 'stream_description': '', 'stream_hash': UUID('6c78b3ac-ffff-e2ab-c446-03e3adf4d80a')} @@ -512,13 +527,15 @@ def make(self, key): @schema class VideoSourceVideo(dj.Imported): - definition = """# Raw per-chunk Video data stream from VideoSource (auto-generated with aeon_mecha-unknown) - -> VideoSource - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of Video data - """ + definition = """ # Raw per-chunk Video data stream from VideoSource (auto-generated with aeon_mecha-unknown) + -> VideoSource + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of Video data + hw_counter: longblob + hw_timestamp: longblob + """ _stream_reader = aeon.io.reader.Video _stream_detail = {'stream_type': 'Video', 'stream_reader': 'aeon.io.reader.Video', 'stream_reader_kwargs': {'pattern': '{pattern}_*'}, 'stream_description': '', 'stream_hash': UUID('f51c6174-e0c4-a888-3a9d-6f97fb6a019b')} From 396b22564d53b0c46428fe37de93e4a88e187f25 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 29 Aug 2023 21:37:21 +0000 Subject: [PATCH 289/489] move insert_stream_types into insert_device_types --- aeon/dj_pipeline/populate/worker.py | 11 +---------- aeon/dj_pipeline/utils/load_metadata.py | 9 ++++++--- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py index b57c4c4e..d4aef32e 100644 --- a/aeon/dj_pipeline/populate/worker.py +++ b/aeon/dj_pipeline/populate/worker.py @@ -6,17 +6,9 @@ is_djtable, ) -from aeon.dj_pipeline import ( - acquisition, - analysis, - db_prefix, - qc, - report, - tracking, -) +from aeon.dj_pipeline import acquisition, analysis, db_prefix, qc, report, tracking from aeon.dj_pipeline.utils import load_metadata, streams_maker - streams = streams_maker.main() __all__ = [ @@ -31,7 +23,6 @@ # ---- Some constants ---- logger = dj.logger worker_schema_name = db_prefix + "worker" -load_metadata.insert_stream_types() # ---- Manage experiments for automated ingestion ---- diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 84de0a85..8665d0e9 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -14,7 +14,6 @@ from aeon.dj_pipeline.utils import streams_maker from aeon.io import api as io_api - _weight_scale_rate = 100 _weight_scale_nest = 1 _colony_csv_path = pathlib.Path("/ceph/aeon/aeon/colony/colony.csv") @@ -119,8 +118,12 @@ def insert_device_types(schema: DotMap, metadata_yml_filepath: Path): streams.DeviceType.insert(new_device_types) if new_device_stream_types: - streams.DeviceType.Stream.insert(new_device_stream_types) - + try: + streams.DeviceType.Stream.insert(new_device_stream_types) + except dj.DataJointError: + insert_stream_types() + streams.DeviceType.Stream.insert(new_device_stream_types) + if new_devices: streams.Device.insert(new_devices) From 5abdebaacb19d8f5e2ab9432a37b04bc896c001a Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 29 Aug 2023 22:16:38 +0000 Subject: [PATCH 290/489] change foreign key & key source in VideoSourceTracking --- aeon/dj_pipeline/tracking.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/aeon/dj_pipeline/tracking.py b/aeon/dj_pipeline/tracking.py index df2198e3..bfb501e7 100644 --- a/aeon/dj_pipeline/tracking.py +++ b/aeon/dj_pipeline/tracking.py @@ -3,7 +3,14 @@ import numpy as np import pandas as pd -from aeon.dj_pipeline import acquisition, dict_to_uuid, get_schema_name, lab, qc +from aeon.dj_pipeline import ( + acquisition, + dict_to_uuid, + get_schema_name, + lab, + qc, + streams, +) from aeon.io import api as io_api from aeon.io import reader @@ -240,7 +247,7 @@ def get_object_position( class VideoSourceTracking(dj.Imported): definition = """ # Tracked objects position data from a particular VideoSource for multi-animal experiment using the SLEAP tracking method per chunk -> acquisition.Chunk - -> streams.VideoSource + -> streams.VideoSourcePosition -> TrackingParamSet --- tracking_timestamps: longblob # (datetime) timestamps of the position data @@ -277,8 +284,8 @@ class PointCollection(dj.Part): @property def key_source(self): - ks = acquisition.Chunk * streams.VideoSource * TrackingParamSet - return ks * (streams.VideoSource & f"video_source_name in {tuple(set(acquisition._ref_device_mapping.values()))}").proj() & "tracking_paramset_id = 1" # SLEAP method + ks = acquisition.Chunk * streams.VideoSource * TrackingParamSet + return ks & "experiment_name='multianimal'" & "video_source_name='CameraTop'" & "tracking_paramset_id = 1" # SLEAP & CameraTop def make(self, key): chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( From d7cc9476922668ba0b4835862975a6d4748fa341 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 30 Aug 2023 16:05:42 +0000 Subject: [PATCH 291/489] remove STREAMS_MODULE_NAME --- aeon/dj_pipeline/__init__.py | 2 +- aeon/dj_pipeline/utils/load_metadata.py | 8 ++++---- aeon/dj_pipeline/utils/streams_maker.py | 1 - 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/aeon/dj_pipeline/__init__.py b/aeon/dj_pipeline/__init__.py index c5593b34..1b6a7d7a 100644 --- a/aeon/dj_pipeline/__init__.py +++ b/aeon/dj_pipeline/__init__.py @@ -37,6 +37,6 @@ def dict_to_uuid(key): except ImportError: try: from .utils import streams_maker - streams = dj.VirtualModule("streams", streams_maker.STREAMS_MODULE_NAME) + streams = dj.VirtualModule("streams", streams_maker.schema_name) except: pass \ No newline at end of file diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 8665d0e9..e94b8558 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -36,7 +36,7 @@ def insert_stream_types(): """Insert into streams.streamType table all streams in the dataset schema.""" from aeon.schema import dataset - streams = dj.VirtualModule("streams", streams_maker.STREAMS_MODULE_NAME) + streams = dj.VirtualModule("streams", streams_maker.schema_name) schemas = [v for v in dataset.__dict__.values() if isinstance(v, DotMap)] for schema in schemas: @@ -59,7 +59,7 @@ def insert_stream_types(): def insert_device_types(schema: DotMap, metadata_yml_filepath: Path): """Use dataset.schema and metadata.yml to insert into streams.DeviceType and streams.Device. Only insert device types that were defined both in the device schema (e.g., exp02) and Metadata.yml. It then creates new device tables under streams schema.""" - streams = dj.VirtualModule("streams", streams_maker.STREAMS_MODULE_NAME) + streams = dj.VirtualModule("streams", streams_maker.schema_name) device_info: dict[dict] = get_device_info(schema) device_type_mapper, device_sn = get_device_mapper(schema, metadata_yml_filepath) @@ -185,7 +185,7 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): """ Make entries into device tables """ - streams = dj.VirtualModule("streams", streams_maker.STREAMS_MODULE_NAME) + streams = dj.VirtualModule("streams", streams_maker.schema_name) if experiment_name.startswith("oct"): ingest_epoch_metadata_octagon(experiment_name, metadata_yml_filepath) @@ -511,7 +511,7 @@ def ingest_epoch_metadata_octagon(experiment_name, metadata_yml_filepath): """ Temporary ingestion routine to load devices' meta information for Octagon arena experiments """ - streams = dj.VirtualModule("streams", streams_maker.STREAMS_MODULE_NAME) + streams = dj.VirtualModule("streams", streams_maker.schema_name) oct01_devices = [ ("Metadata", "Metadata"), diff --git a/aeon/dj_pipeline/utils/streams_maker.py b/aeon/dj_pipeline/utils/streams_maker.py index 851c0c84..1258425c 100644 --- a/aeon/dj_pipeline/utils/streams_maker.py +++ b/aeon/dj_pipeline/utils/streams_maker.py @@ -16,7 +16,6 @@ # schema_name = f'u_{dj.config["database.user"]}_streams' # for testing schema_name = get_schema_name("streams") -STREAMS_MODULE_NAME = "aeon_streams" _STREAMS_MODULE_FILE = Path(__file__).parent.parent / "streams.py" From b545b26443d92e54300cfe9dd9acdb7ba2080980 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 1 Sep 2023 16:52:02 +0000 Subject: [PATCH 292/489] modify VideoSourceTracking table design --- aeon/dj_pipeline/tracking.py | 57 +++++++++++++++++------------------- 1 file changed, 27 insertions(+), 30 deletions(-) diff --git a/aeon/dj_pipeline/tracking.py b/aeon/dj_pipeline/tracking.py index bfb501e7..fccb3d12 100644 --- a/aeon/dj_pipeline/tracking.py +++ b/aeon/dj_pipeline/tracking.py @@ -1,3 +1,5 @@ +from pathlib import Path + import datajoint as dj import matplotlib.path import numpy as np @@ -12,7 +14,7 @@ streams, ) from aeon.io import api as io_api -from aeon.io import reader +from aeon.schema.social import Pose from . import acquisition, dict_to_uuid, get_schema_name, lab, qc @@ -247,10 +249,8 @@ def get_object_position( class VideoSourceTracking(dj.Imported): definition = """ # Tracked objects position data from a particular VideoSource for multi-animal experiment using the SLEAP tracking method per chunk -> acquisition.Chunk - -> streams.VideoSourcePosition + -> streams.VideoSource -> TrackingParamSet - --- - tracking_timestamps: longblob # (datetime) timestamps of the position data """ class Point(dj.Part): @@ -260,20 +260,21 @@ class Point(dj.Part): --- point_x: longblob point_y: longblob - point_confidence: longblob + point_likelihood: longblob """ class Pose(dj.Part): definition = """ -> master pose_name: varchar(16) - --- class: smallint - class_confidence: longblob + --- + class_likelihood: longblob centroid_x: longblob centroid_y: longblob - centroid_confidence: longblob - point_collection: varchar(1000) # List of point names + centroid_likelihood: longblob + pose_timestamps: longblob + point_collection=null: varchar(1000) # List of point names """ class PointCollection(dj.Part): @@ -284,49 +285,45 @@ class PointCollection(dj.Part): @property def key_source(self): - ks = acquisition.Chunk * streams.VideoSource * TrackingParamSet - return ks & "experiment_name='multianimal'" & "video_source_name='CameraTop'" & "tracking_paramset_id = 1" # SLEAP & CameraTop + return (acquisition.Chunk & "experiment_name='multianimal'" ) * (streams.VideoSourcePosition & (streams.VideoSource & "video_source_name='CameraTop'")) * (TrackingParamSet & "tracking_paramset_id = 1") # SLEAP & CameraTop def make(self, key): chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( "chunk_start", "chunk_end", "directory_type" ) - camera = (streams.VideoSource & key).fetch1("video_source_name") - raw_data_dir = acquisition.Experiment.get_data_directory( key, directory_type=dir_type ) - device = getattr( - acquisition._device_schema_mapping[key["experiment_name"]], camera - ) + # This needs to be modified later + sleap_reader = Pose(pattern="", columns=["class", "class_confidence", "centroid_x", "centroid_y", "centroid_confidence"]) + tracking_file_path = "/ceph/aeon/aeon/data/processed/test-node1/1234567/2023-08-10T18-31-00/macentroid/test-node1_1234567_2023-08-10T18-31-00_macentroid.bin" # temp file path for testing - sleap_reader = reader.Harp(pattern="", columns=["class", "class_confidence", "centroid_x", "centroid_y", "centroid_confidence"]) - tracking_file_path = "/ceph/aeon/aeon/code/scratchpad/ex_ma_tracking/ex_ma_tracking.bin" # temporary - tracking_df = sleap_reader.read(tracking_file_path) + tracking_df = sleap_reader.read(Path(tracking_file_path)) - object_positions = [] - for obj_name in ["body"]: + pose_list = [] + for part_name in ["body"]: for class_id in tracking_df["class"].unique(): - temp_df = tracking_df[tracking_df["class"] == class_id] + class_df = tracking_df[tracking_df["class"] == class_id] - object_positions.append( + pose_list.append( { **key, - "object_name": obj_name, - "timestamps": temp_df.index.values, + "pose_name": part_name, "class": class_id, - "class_confidence": temp_df.class_confidence.values, - "centroid_x": temp_df.centroid_x.values, - "centroid_y": temp_df.centroid_y.values, - "centroid_confidence": temp_df.centroid_confidence.values, + "class_likelihood": class_df["class_likelihood"].values, + "centroid_x": class_df["x"].values, + "centroid_y": class_df["y"].values, + "centroid_likelihood": class_df["part_likelihood"].values, + "pose_timestamps": class_df.index.values, + "point_collection": "", } ) self.insert1(key) - self.Object.insert(object_positions) + self.Pose.insert(pose_list) # ---------- HELPER ------------------ From 3deac27350e0b48f1db319b51fb4a5aac9220c42 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 1 Sep 2023 17:07:46 +0000 Subject: [PATCH 293/489] chore: :fire: remove streams activate This is redundant since the schema will be activated during import --- aeon/dj_pipeline/utils/streams_maker.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/aeon/dj_pipeline/utils/streams_maker.py b/aeon/dj_pipeline/utils/streams_maker.py index 1258425c..26afa23c 100644 --- a/aeon/dj_pipeline/utils/streams_maker.py +++ b/aeon/dj_pipeline/utils/streams_maker.py @@ -225,7 +225,6 @@ def main(create_tables=True): f.write(full_def) streams = importlib.import_module(f"aeon.dj_pipeline.streams") - streams.schema.activate(schema_name) if create_tables: # Create DeviceType tables. @@ -315,7 +314,6 @@ def main(create_tables=True): f.write(full_def) importlib.reload(streams) - streams.schema.activate(schema_name) return streams From 645c65957596040091de0f1f7048d9a1d3556c64 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 1 Sep 2023 17:16:39 +0000 Subject: [PATCH 294/489] import sleap reader inside make function for now --- aeon/dj_pipeline/tracking.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/tracking.py b/aeon/dj_pipeline/tracking.py index fccb3d12..d5ce9568 100644 --- a/aeon/dj_pipeline/tracking.py +++ b/aeon/dj_pipeline/tracking.py @@ -14,7 +14,6 @@ streams, ) from aeon.io import api as io_api -from aeon.schema.social import Pose from . import acquisition, dict_to_uuid, get_schema_name, lab, qc @@ -288,6 +287,9 @@ def key_source(self): return (acquisition.Chunk & "experiment_name='multianimal'" ) * (streams.VideoSourcePosition & (streams.VideoSource & "video_source_name='CameraTop'")) * (TrackingParamSet & "tracking_paramset_id = 1") # SLEAP & CameraTop def make(self, key): + + from aeon.schema.social import Pose + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( "chunk_start", "chunk_end", "directory_type" ) From 8cdb7fff5930ca5dad36b2e8c3561ae115d9d5f5 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 8 Sep 2023 17:47:22 +0000 Subject: [PATCH 295/489] fix: :bug: fix import error import is_djtable from datajoint_utilities.dj_worker.worker_schema --- aeon/dj_pipeline/populate/worker.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py index d4aef32e..47d1f761 100644 --- a/aeon/dj_pipeline/populate/worker.py +++ b/aeon/dj_pipeline/populate/worker.py @@ -1,10 +1,6 @@ import datajoint as dj -from datajoint_utilities.dj_worker import ( - DataJointWorker, - ErrorLog, - WorkerLog, - is_djtable, -) +from datajoint_utilities.dj_worker import DataJointWorker, ErrorLog, WorkerLog +from datajoint_utilities.dj_worker.worker_schema import is_djtable from aeon.dj_pipeline import acquisition, analysis, db_prefix, qc, report, tracking from aeon.dj_pipeline.utils import load_metadata, streams_maker From 2fb186b773ec4081e09c0041038d97794c2a9879 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 19 Sep 2023 03:28:15 +0000 Subject: [PATCH 296/489] style: :recycle: remove trailing whitespace --- aeon/dj_pipeline/README.md | 53 ++++++++++++++++----------------- aeon/dj_pipeline/__init__.py | 2 +- aeon/dj_pipeline/acquisition.py | 6 ++-- aeon/dj_pipeline/lab.py | 4 +-- aeon/dj_pipeline/report.py | 4 +-- aeon/dj_pipeline/subject.py | 2 +- aeon/dj_pipeline/tracking.py | 36 +++++++++++----------- 7 files changed, 53 insertions(+), 54 deletions(-) diff --git a/aeon/dj_pipeline/README.md b/aeon/dj_pipeline/README.md index 3ee08491..dd99d3c3 100644 --- a/aeon/dj_pipeline/README.md +++ b/aeon/dj_pipeline/README.md @@ -16,10 +16,10 @@ The diagram below shows the analysis portion of the pipeline (work in progress). From the diagram above, we can see that the pipeline is organized in layers of -tables, going top down, from `lookup`-tier (in gray) and `manual`-tier (in green) tables +tables, going top down, from `lookup`-tier (in gray) and `manual`-tier (in green) tables to `imported`-tier (in purple) and `computed`-tier (in red) tables. -Such is also the way the data flows through the pipeline, by a combination of ingestion and +Such is also the way the data flows through the pipeline, by a combination of ingestion and computation routines. ## Core tables @@ -27,31 +27,31 @@ computation routines. #### Experiment and data acquisition 1. `Experiment` - the `aquisition.Experiment` table stores meta information about the experiments -done in Project Aeon, with secondary information such as the lab/room the experiment is carried out, +done in Project Aeon, with secondary information such as the lab/room the experiment is carried out, which animals participating, the directory storing the raw data, etc. 2. `Epoch` - A recording period reflecting on/off of the hardware acquisition system. -The `aquisition.Epoch` table records all acquisition epochs and their associated configuration for -any particular experiment (in the above `aquisition.Experiment` table). +The `aquisition.Epoch` table records all acquisition epochs and their associated configuration for +any particular experiment (in the above `aquisition.Experiment` table). -3.`Chunk` - the raw data are acquired by Bonsai and stored as -a collection of files every one hour - we call this one-hour a time chunk. -The `aquisition.Chunk` table records all time chunks and their associated raw data files for +3.`Chunk` - the raw data are acquired by Bonsai and stored as +a collection of files every one hour - we call this one-hour a time chunk. +The `aquisition.Chunk` table records all time chunks and their associated raw data files for any particular experiment (in the above `aquisition.Experiment` table). A chunk must belong to one epoch. #### Devices -5. `ExperimentCamera` - the cameras and associated specifications used for this experiment - +5. `ExperimentCamera` - the cameras and associated specifications used for this experiment - e.g. camera serial number, frame rate, location, time of installation and removal, etc. -6. `ExperimentFoodPatch` - the food-patches and associated specifications used for this experiment - +6. `ExperimentFoodPatch` - the food-patches and associated specifications used for this experiment - e.g. patch serial number, sampling rate of the wheel, location, time of installation and removal, etc. 7. `ExperimentWeightScale` - the scales for measuring animal weights, usually placed at the nest, one per nest #### Data streams -8. `FoodPatchEvent` - all events (e.g. pellet triggered, pellet delivered, etc.) +8. `FoodPatchEvent` - all events (e.g. pellet triggered, pellet delivered, etc.) from a particular `ExperimentFoodPatch` 9. `FoodPatchWheel` - wheel data (angle, intensity) from a particular `ExperimentFoodPatch` @@ -69,26 +69,26 @@ from a particular `ExperimentFoodPatch` #### Standard analyses -14. `Visit` - a `Visit` is defined as a ***period of time*** +14. `Visit` - a `Visit` is defined as a ***period of time*** that a particular ***animal*** spends time at a particular ***place*** -15. `VisitSubjectPosition` - position data (x, y, z, area) of the subject for any particular visit. -Position data per visit are stored in smaller time slices (10-minute long) allowing for +15. `VisitSubjectPosition` - position data (x, y, z, area) of the subject for any particular visit. +Position data per visit are stored in smaller time slices (10-minute long) allowing for more efficient searches, queries and fetches from the database. -16. `VisitSummary` - a table for computation and storing some summary statistics on a -per-session level - i.e. total pellet delivered, total distance the animal travelled, total +16. `VisitSummary` - a table for computation and storing some summary statistics on a +per-session level - i.e. total pellet delivered, total distance the animal travelled, total distance the wheel travelled (or per food-patch), etc. -17. `VisitTimeDistribution` - a table for computation and storing where the animal is at, -for each timepoint, e.g. in the nest, in corridor, in arena, in each of the food patches. +17. `VisitTimeDistribution` - a table for computation and storing where the animal is at, +for each timepoint, e.g. in the nest, in corridor, in arena, in each of the food patches. This can be used to produce the ethogram plot. ## Operating the pipeline - how the auto ingestion/processing work? -Some meta information about the experiment is entered - e.g. experiment name, participating +Some meta information about the experiment is entered - e.g. experiment name, participating animals, cameras, food patches setup, etc. -+ These information are either entered by hand, or parsed and inserted from configuration ++ These information are either entered by hand, or parsed and inserted from configuration yaml files. + For experiments these info can be inserted by running + [create_experiment_01](create_experiments/create_experiment_01.py) @@ -96,17 +96,16 @@ animals, cameras, food patches setup, etc. + [create_experiment_02](create_experiments/create_experiment_02.py) (just need to do this once) -Tables in DataJoint are written with a `make()` function - -instruction to generate and insert new records to itself, based on data from upstream tables. -Triggering the auto ingestion and processing/computation routine is essentially +Tables in DataJoint are written with a `make()` function - +instruction to generate and insert new records to itself, based on data from upstream tables. +Triggering the auto ingestion and processing/computation routine is essentially calling the `.populate()` method for all relevant tables. -These routines are prepared in this [auto-processing script](populate/process.py). -Essentially, turning on the auto-processing routine amounts to running the +These routines are prepared in this [auto-processing script](populate/process.py). +Essentially, turning on the auto-processing routine amounts to running the following 2 commands (in different processing threads) aeon_ingest high - - aeon_ingest mid + aeon_ingest mid diff --git a/aeon/dj_pipeline/__init__.py b/aeon/dj_pipeline/__init__.py index 1b6a7d7a..474f3da5 100644 --- a/aeon/dj_pipeline/__init__.py +++ b/aeon/dj_pipeline/__init__.py @@ -39,4 +39,4 @@ def dict_to_uuid(key): from .utils import streams_maker streams = dj.VirtualModule("streams", streams_maker.schema_name) except: - pass \ No newline at end of file + pass diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 797520a5..6223e24a 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -125,7 +125,7 @@ def get_data_directory(cls, experiment_key, directory_type="raw", as_posix=False ).fetch1("repository_name", "directory_path") except dj.errors.DataJointError: return - + dir_path = pathlib.Path(dir_path) if dir_path.exists(): assert dir_path.is_relative_to(paths.get_repository_path(repo_name)) @@ -419,7 +419,7 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): @schema class EpochEnd(dj.Manual): - definition = """ + definition = """ -> Epoch --- epoch_end: datetime(6) @@ -1042,7 +1042,7 @@ class WeightMeasurementFiltered(dj.Imported): --- weight_filtered: longblob # measured weights filtered weight_subject_timestamps: longblob # (datetime) timestamps of weight_subject data - weight_subject: longblob # + weight_subject: longblob # """ def make(self, key): diff --git a/aeon/dj_pipeline/lab.py b/aeon/dj_pipeline/lab.py index ed38763b..e724a7cf 100644 --- a/aeon/dj_pipeline/lab.py +++ b/aeon/dj_pipeline/lab.py @@ -14,7 +14,7 @@ class Colony(dj.Lookup): definition = """ subject : varchar(32) --- - reference_weight=null : float + reference_weight=null : float sex='U' : enum('M', 'F', 'U') subject_birth_date=null : date # date of birth note='' : varchar(1024) @@ -24,7 +24,7 @@ class Colony(dj.Lookup): @schema class Lab(dj.Lookup): definition = """ - lab : varchar(24) # Abbreviated lab name + lab : varchar(24) # Abbreviated lab name --- lab_name : varchar(255) # full lab name institution : varchar(255) diff --git a/aeon/dj_pipeline/report.py b/aeon/dj_pipeline/report.py index b6453148..a62d2014 100644 --- a/aeon/dj_pipeline/report.py +++ b/aeon/dj_pipeline/report.py @@ -451,9 +451,9 @@ class VisitDailySummaryPlot(dj.Computed): --- pellet_count_plotly: longblob # Dictionary storing the plotly object (from fig.to_plotly_json()) wheel_distance_travelled_plotly: longblob - total_distance_travelled_plotly: longblob + total_distance_travelled_plotly: longblob weight_patch_plotly: longblob - foraging_bouts_plotly: longblob + foraging_bouts_plotly: longblob foraging_bouts_pellet_count_plotly: longblob foraging_bouts_duration_plotly: longblob region_time_fraction_daily_plotly: longblob diff --git a/aeon/dj_pipeline/subject.py b/aeon/dj_pipeline/subject.py index 1d981266..92b34b86 100644 --- a/aeon/dj_pipeline/subject.py +++ b/aeon/dj_pipeline/subject.py @@ -141,5 +141,5 @@ class FoodDeprivationWeight(dj.Manual): -> Subject weight_time: datetime --- - weight: float + weight: float """ diff --git a/aeon/dj_pipeline/tracking.py b/aeon/dj_pipeline/tracking.py index d5ce9568..dcfe975f 100644 --- a/aeon/dj_pipeline/tracking.py +++ b/aeon/dj_pipeline/tracking.py @@ -31,7 +31,7 @@ @schema class TrackingMethod(dj.Lookup): definition = """ - tracking_method: varchar(16) + tracking_method: varchar(16) --- tracking_method_description: varchar(256) """ @@ -47,7 +47,7 @@ class TrackingParamSet(dj.Lookup): definition = """ # Parameter set used in a particular TrackingMethod tracking_paramset_id: smallint --- - -> TrackingMethod + -> TrackingMethod paramset_description: varchar(128) param_set_hash: uuid unique index (param_set_hash) @@ -255,41 +255,41 @@ class VideoSourceTracking(dj.Imported): class Point(dj.Part): definition = """ -> master - point_name: varchar(16) + point_name: varchar(16) --- point_x: longblob point_y: longblob point_likelihood: longblob """ - + class Pose(dj.Part): definition = """ -> master - pose_name: varchar(16) - class: smallint + pose_name: varchar(16) + class: smallint --- - class_likelihood: longblob - centroid_x: longblob - centroid_y: longblob - centroid_likelihood: longblob - pose_timestamps: longblob - point_collection=null: varchar(1000) # List of point names + class_likelihood: longblob + centroid_x: longblob + centroid_y: longblob + centroid_likelihood: longblob + pose_timestamps: longblob + point_collection=null: varchar(1000) # List of point names """ - + class PointCollection(dj.Part): definition = """ -> master.Pose -> master.Point """ - + @property def key_source(self): return (acquisition.Chunk & "experiment_name='multianimal'" ) * (streams.VideoSourcePosition & (streams.VideoSource & "video_source_name='CameraTop'")) * (TrackingParamSet & "tracking_paramset_id = 1") # SLEAP & CameraTop def make(self, key): - + from aeon.schema.social import Pose - + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( "chunk_start", "chunk_end", "directory_type" ) @@ -305,9 +305,9 @@ def make(self, key): pose_list = [] for part_name in ["body"]: - + for class_id in tracking_df["class"].unique(): - + class_df = tracking_df[tracking_df["class"] == class_id] pose_list.append( From 2e659d104e228eabaf84d2d9ae36f50ce7537a2f Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 19 Sep 2023 16:16:01 +0000 Subject: [PATCH 297/489] apply ruff fix --- .pre-commit-config.yaml | 5 +- aeon/dj_pipeline/README.md | 2 +- aeon/dj_pipeline/__init__.py | 9 +- aeon/dj_pipeline/acquisition.py | 52 ++++------ aeon/dj_pipeline/analysis/in_arena.py | 19 ++-- aeon/dj_pipeline/analysis/visit.py | 18 ++-- aeon/dj_pipeline/analysis/visit_analysis.py | 32 +++---- .../create_experiment_01.py | 6 +- .../create_experiment_02.py | 1 - .../create_experiments/create_octagon_1.py | 3 +- .../create_socialexperiment_0.py | 8 +- aeon/dj_pipeline/lab.py | 5 +- aeon/dj_pipeline/populate/process.py | 8 +- aeon/dj_pipeline/populate/worker.py | 10 +- aeon/dj_pipeline/qc.py | 6 +- aeon/dj_pipeline/report.py | 28 +++--- .../scripts/clone_and_freeze_exp01.py | 6 +- .../scripts/update_timestamps_longblob.py | 10 +- aeon/dj_pipeline/streams.py | 79 +++++++-------- aeon/dj_pipeline/subject.py | 2 - aeon/dj_pipeline/tracking.py | 15 +-- aeon/dj_pipeline/utils/load_metadata.py | 20 ++-- aeon/dj_pipeline/utils/paths.py | 14 ++- aeon/dj_pipeline/utils/plotting.py | 95 ++++++++----------- aeon/dj_pipeline/utils/streams_maker.py | 25 ++--- aeon/dj_pipeline/utils/video.py | 5 +- 26 files changed, 203 insertions(+), 280 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1610386c..8a99ae87 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,9 +40,8 @@ repos: - repo: https://github.com/RobertCraigie/pyright-python rev: v1.1.324 hooks: - - id: pyright - args: [--level, error, --project, ./pyproject.toml] - + - id: pyright + args: [--level, error, --project, ./pyproject.toml] # Pytest is expensive, so we show its set-up but leave it commented out. # - repo: local diff --git a/aeon/dj_pipeline/README.md b/aeon/dj_pipeline/README.md index dd99d3c3..545f0f35 100644 --- a/aeon/dj_pipeline/README.md +++ b/aeon/dj_pipeline/README.md @@ -90,7 +90,7 @@ Some meta information about the experiment is entered - e.g. experiment name, pa animals, cameras, food patches setup, etc. + These information are either entered by hand, or parsed and inserted from configuration yaml files. -+ For experiments these info can be inserted by running ++ For experiments these info can be inserted by running + [create_experiment_01](create_experiments/create_experiment_01.py) + [create_socialexperiment_0](create_experiments/create_socialexperiment_0.py) + [create_experiment_02](create_experiments/create_experiment_02.py) diff --git a/aeon/dj_pipeline/__init__.py b/aeon/dj_pipeline/__init__.py index 474f3da5..6ba136d9 100644 --- a/aeon/dj_pipeline/__init__.py +++ b/aeon/dj_pipeline/__init__.py @@ -17,14 +17,13 @@ _default_repository_config) -def get_schema_name(name): +def get_schema_name(name) -> str: + """Return a schema name.""" return db_prefix + name -def dict_to_uuid(key): - """ - Given a dictionary `key`, returns a hash string as UUID - """ +def dict_to_uuid(key) -> uuid.UUID: + """Given a dictionary `key`, returns a hash string as UUID.""" hashed = hashlib.md5() for k, v in sorted(key.items()): hashed.update(str(k).encode()) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 6223e24a..ac9541a6 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -138,8 +138,10 @@ def get_data_directory(cls, experiment_key, directory_type="raw", as_posix=False @classmethod def get_data_directories( - cls, experiment_key, directory_types=["raw"], as_posix=False + cls, experiment_key, directory_types=None, as_posix=False ): + if directory_types is None: + directory_types = ["raw"] return [ cls.get_data_directory(experiment_key, dir_type, as_posix=as_posix) for dir_type in directory_types @@ -272,18 +274,15 @@ class Config(dj.Part): @classmethod def ingest_epochs(cls, experiment_name, start=None, end=None): - """ - Ingest epochs for the specified "experiment_name" + """Ingest epochs for the specified "experiment_name" Ingest only epochs that start in between the specified (start, end) time - if not specified, ingest all epochs - Note: "start" and "end" are datetime specified a string in the format: "%Y-%m-%d %H:%M:%S" + Note: "start" and "end" are datetime specified a string in the format: "%Y-%m-%d %H:%M:%S". """ from .utils import streams_maker - from .utils.load_metadata import ( - extract_epoch_config, - ingest_epoch_metadata, - insert_device_types, - ) + from .utils.load_metadata import (extract_epoch_config, + ingest_epoch_metadata, + insert_device_types) device_name = _ref_device_mapping.get(experiment_name, "CameraTop") @@ -556,9 +555,7 @@ def make(self, key): pd.Timestamp(chunk_end), ) else: - device = getattr( - _device_schema_mapping[key["experiment_name"]], "ExperimentalMetadata" - ) + device = _device_schema_mapping[key["experiment_name"]].ExperimentalMetadata subject_data = io_api.load( root=raw_data_dir.as_posix(), reader=device.SubjectState, @@ -609,9 +606,7 @@ def make(self, key): pd.Timestamp(chunk_end), ) else: - device = getattr( - _device_schema_mapping[key["experiment_name"]], "ExperimentalMetadata" - ) + device = _device_schema_mapping[key["experiment_name"]].ExperimentalMetadata subject_data = io_api.load( root=raw_data_dir.as_posix(), reader=device.SubjectState, @@ -650,9 +645,7 @@ def make(self, key): # Populate the part table raw_data_dir = Experiment.get_data_directory(key) - device = getattr( - _device_schema_mapping[key["experiment_name"]], "ExperimentalMetadata" - ) + device = _device_schema_mapping[key["experiment_name"]].ExperimentalMetadata try: # handles corrupted files - issue: https://github.com/SainsburyWellcomeCentre/aeon_mecha/issues/153 @@ -714,10 +707,9 @@ class FoodPatchEvent(dj.Imported): @property def key_source(self): - """ - Only the combination of Chunk and ExperimentFoodPatch with overlapping time + """Only the combination of Chunk and ExperimentFoodPatch with overlapping time + Chunk(s) that started after FoodPatch install time and ended before FoodPatch remove time - + Chunk(s) that started after FoodPatch install time for FoodPatch that are not yet removed + + Chunk(s) that started after FoodPatch install time for FoodPatch that are not yet removed. """ return ( Chunk * ExperimentFoodPatch.join(ExperimentFoodPatch.RemovalTime, left=True) @@ -797,10 +789,9 @@ class FoodPatchWheel(dj.Imported): @property def key_source(self): - """ - Only the combination of Chunk and ExperimentFoodPatch with overlapping time + """Only the combination of Chunk and ExperimentFoodPatch with overlapping time + Chunk(s) that started after FoodPatch install time and ended before FoodPatch remove time - + Chunk(s) that started after FoodPatch install time for FoodPatch that are not yet removed + + Chunk(s) that started after FoodPatch install time for FoodPatch that are not yet removed. """ return ( Chunk * ExperimentFoodPatch.join(ExperimentFoodPatch.RemovalTime, left=True) @@ -922,10 +913,9 @@ class Time(dj.Part): @property def key_source(self): - """ - Only the combination of Chunk and ExperimentFoodPatch with overlapping time + """Only the combination of Chunk and ExperimentFoodPatch with overlapping time + Chunk(s) that started after FoodPatch install time and ended before FoodPatch remove time - + Chunk(s) that started after FoodPatch install time for FoodPatch that are not yet removed + + Chunk(s) that started after FoodPatch install time for FoodPatch that are not yet removed. """ return ( Chunk * ExperimentFoodPatch.join(ExperimentFoodPatch.RemovalTime, left=True) @@ -984,10 +974,9 @@ class WeightMeasurement(dj.Imported): @property def key_source(self): - """ - Only the combination of Chunk and ExperimentWeightScale with overlapping time + """Only the combination of Chunk and ExperimentWeightScale with overlapping time + Chunk(s) that started after WeightScale install time and ended before WeightScale remove time - + Chunk(s) that started after WeightScale install time for WeightScale that are not yet removed + + Chunk(s) that started after WeightScale install time for WeightScale that are not yet removed. """ return ( Chunk @@ -1165,7 +1154,8 @@ def _load_legacy_subjectdata(experiment_name, data_dir, start, end): return subject_data if experiment_name == "social0-r1": - from aeon.dj_pipeline.create_experiments.create_socialexperiment_0 import fixID + from aeon.dj_pipeline.create_experiments.create_socialexperiment_0 import \ + fixID sessdf = subject_data.copy() sessdf = sessdf[~sessdf.id.str.contains("test")] diff --git a/aeon/dj_pipeline/analysis/in_arena.py b/aeon/dj_pipeline/analysis/in_arena.py index 3b917e9a..5a3bc894 100644 --- a/aeon/dj_pipeline/analysis/in_arena.py +++ b/aeon/dj_pipeline/analysis/in_arena.py @@ -77,7 +77,7 @@ def make(self, key): acquisition.SubjectEnterExit.Time * acquisition.EventType & {"subject": key["subject"]} & f'enter_exit_time > "{in_arena_start}"' - & f'event_type != "SubjectRemainedInArena"' + & 'event_type != "SubjectRemainedInArena"' ).fetch(as_dict=True, limit=1, order_by="enter_exit_time ASC")[0] if subject_exit["event_type"] != "SubjectExitedArena": @@ -109,12 +109,11 @@ class InArenaTimeSlice(dj.Computed): @property def key_source(self): - """ - Chunk for all sessions: + """Chunk for all sessions: + are not "NeverExitedSession" + in_arena_start during this Chunk - i.e. first chunk of the session + in_arena_end during this Chunk - i.e. last chunk of the session - + chunk starts after in_arena_start and ends before in_arena_end (or NOW() - i.e. session still on going) + + chunk starts after in_arena_start and ends before in_arena_end (or NOW() - i.e. session still on going). """ return ( InArena.join(InArenaEnd, left=True).proj( @@ -207,11 +206,10 @@ class InArenaSubjectPosition(dj.Imported): ) def make(self, key): - """ - The populate logic here relies on the assumption that there is only one subject in the arena at a time + """The populate logic here relies on the assumption that there is only one subject in the arena at a time The positiondata is associated with that one subject currently in the arena at any timepoints For multi-animal experiments, a mapping of object_id-to-subject is needed to populate the right position data - associated with a particular animal + associated with a particular animal. """ time_slice_start, time_slice_end = (InArenaTimeSlice & key).fetch1( "time_slice_start", "time_slice_end" @@ -260,9 +258,8 @@ def make(self, key): @classmethod def get_position(cls, in_arena_key): - """ - Given a key to a single InArena, return a Pandas DataFrame for the position data - of the subject for the specified InArena time period + """Given a key to a single InArena, return a Pandas DataFrame for the position data + of the subject for the specified InArena time period. """ assert len(InArena & in_arena_key) == 1 @@ -299,7 +296,7 @@ class Routine(dj.Part): -> qc.QCRoutine --- -> qc.QCCode - qc_comment: varchar(255) + qc_comment: varchar(255) """ class BadPeriod(dj.Part): diff --git a/aeon/dj_pipeline/analysis/visit.py b/aeon/dj_pipeline/analysis/visit.py index 6e48fb84..449f71ac 100644 --- a/aeon/dj_pipeline/analysis/visit.py +++ b/aeon/dj_pipeline/analysis/visit.py @@ -1,12 +1,11 @@ +import datetime + import datajoint as dj import pandas as pd -import numpy as np -import datetime from aeon.analysis import utils as analysis_utils -from .. import lab, acquisition, tracking, qc -from .. import get_schema_name +from .. import acquisition, get_schema_name, lab, qc, tracking schema = dj.schema(get_schema_name("analysis")) @@ -107,7 +106,7 @@ def make(self, key): visit_end - key["overlap_start"] ).total_seconds() / 3600, - "subject_count": len(set(v["subject"] for v in overlap_visits)), + "subject_count": len({v["subject"] for v in overlap_visits}), } ) self.Visit.insert(overlap_visits, skip_duplicates=True) @@ -116,14 +115,15 @@ def make(self, key): # ---- HELPERS ---- -def ingest_environment_visits(experiment_names=["exp0.2-r0"]): - """ - Function to populate into `Visit` and `VisitEnd` for specified experiments (default: 'exp0.2-r0') +def ingest_environment_visits(experiment_names=None): + """Function to populate into `Visit` and `VisitEnd` for specified experiments (default: 'exp0.2-r0') This ingestion routine handles only those "complete" visits, not ingesting any "on-going" visits - Using "analyze" method: `aeon.analyze.utils.visits()` + Using "analyze" method: `aeon.analyze.utils.visits()`. :param list experiment_names: list of names of the experiment to populate into the Visit table """ + if experiment_names is None: + experiment_names = ["exp0.2-r0"] place_key = {"place": "environment"} for experiment_name in experiment_names: exp_key = {"experiment_name": experiment_name} diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 73719ac0..9bcdd9c8 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -6,7 +6,7 @@ import numpy as np import pandas as pd -from .. import acquisition, dict_to_uuid, get_schema_name, lab, qc, tracking +from .. import acquisition, get_schema_name, lab, tracking from .visit import Visit, VisitEnd logger = dj.logger @@ -19,7 +19,7 @@ @schema class PositionFilteringMethod(dj.Lookup): definition = """ - pos_filter_method: varchar(16) + pos_filter_method: varchar(16) --- pos_filter_method_description: varchar(256) """ @@ -32,7 +32,7 @@ class PositionFilteringParamSet(dj.Lookup): definition = """ # Parameter set used in a particular PositionFilteringMethod pos_filter_paramset_id: smallint --- - -> PositionFilteringMethod + -> PositionFilteringMethod paramset_description: varchar(128) param_set_hash: uuid unique index (param_set_hash) @@ -77,11 +77,10 @@ class TimeSlice(dj.Part): @property def key_source(self): - """ - Chunk for all visits: + """Chunk for all visits: + visit_start during this Chunk - i.e. first chunk of the visit + visit_end during this Chunk - i.e. last chunk of the visit - + chunk starts after visit_start and ends before visit_end (or NOW() - i.e. ongoing visits) + + chunk starts after visit_start and ends before visit_end (or NOW() - i.e. ongoing visits). """ return ( Visit.join(VisitEnd, left=True).proj(visit_end="IFNULL(visit_end, NOW())") @@ -127,7 +126,7 @@ def make(self, key): as_dict=True, order_by="enter_exit_time DESC", limit=1 )[0] if next_event["event_type"] == "SubjectEnteredArena": - raise ValueError(f"Bad Visit - never exited visit") + raise ValueError("Bad Visit - never exited visit") end_time = next_event["enter_exit_time"] # -- Retrieve position data @@ -192,9 +191,8 @@ def make(self, key): @classmethod def get_position(cls, visit_key=None, subject=None, start=None, end=None): - """ - Given a key to a single Visit, return a Pandas DataFrame for the position data - of the subject for the specified Visit time period + """Given a key to a single Visit, return a Pandas DataFrame for the position data + of the subject for the specified Visit time period. """ if visit_key is not None: assert len(Visit & visit_key) == 1 @@ -211,7 +209,7 @@ def get_position(cls, visit_key=None, subject=None, start=None, end=None): subject = subject else: raise ValueError( - f'Either "visit_key" or all three "subject", "start" and "end" has to be specified' + 'Either "visit_key" or all three "subject", "start" and "end" has to be specified' ) return tracking._get_position( @@ -239,9 +237,9 @@ class VisitTimeDistribution(dj.Computed): --- day_duration: float # total duration (in hours) time_fraction_in_corridor: float # fraction of time the animal spent in the corridor in this visit - in_corridor: longblob # array of timestamps for when the animal is in the corridor + in_corridor: longblob # array of timestamps for when the animal is in the corridor time_fraction_in_arena: float # fraction of time the animal spent in the arena in this visit - in_arena: longblob # array of timestamps for when the animal is in the arena + in_arena: longblob # array of timestamps for when the animal is in the arena """ class Nest(dj.Part): @@ -259,7 +257,7 @@ class FoodPatch(dj.Part): -> acquisition.ExperimentFoodPatch --- time_fraction_in_patch: float # fraction of time the animal spent on this patch in this visit - in_patch: longblob # array of timestamps for when the animal is in this patch + in_patch: longblob # array of timestamps for when the animal is in this patch """ # Work on finished visits @@ -551,7 +549,7 @@ class VisitForagingBout(dj.Computed): -> Visit -> acquisition.ExperimentFoodPatch bout_start: datetime(6) # start time of bout - --- + --- bout_end: datetime(6) # end time of bout bout_duration: float # (seconds) wheel_distance_travelled: float # (cm) @@ -562,8 +560,8 @@ class VisitForagingBout(dj.Computed): key_source = ( Visit & VisitSummary - & (VisitEnd & f"visit_duration > 24") - & f"experiment_name= 'exp0.2-r0'" + & (VisitEnd & "visit_duration > 24") + & "experiment_name= 'exp0.2-r0'" ) * acquisition.ExperimentFoodPatch def make(self, key): diff --git a/aeon/dj_pipeline/create_experiments/create_experiment_01.py b/aeon/dj_pipeline/create_experiments/create_experiment_01.py index e6b29317..9189c502 100644 --- a/aeon/dj_pipeline/create_experiments/create_experiment_01.py +++ b/aeon/dj_pipeline/create_experiments/create_experiment_01.py @@ -1,13 +1,15 @@ +import pathlib + import yaml + from aeon.dj_pipeline import acquisition, lab, subject -import pathlib _wheel_sampling_rate = 500 _weight_scale_rate = 100 def ingest_exp01_metadata(metadata_yml_filepath, experiment_name): - with open(metadata_yml_filepath, "r") as f: + with open(metadata_yml_filepath) as f: arena_setup = yaml.full_load(f) device_frequency_mapper = { diff --git a/aeon/dj_pipeline/create_experiments/create_experiment_02.py b/aeon/dj_pipeline/create_experiments/create_experiment_02.py index c5ff7a03..6966d204 100644 --- a/aeon/dj_pipeline/create_experiments/create_experiment_02.py +++ b/aeon/dj_pipeline/create_experiments/create_experiment_02.py @@ -1,6 +1,5 @@ from aeon.dj_pipeline import acquisition, lab, subject - # ============ Manual and automatic steps to for experiment 0.2 populate ============ experiment_name = "exp0.2-r0" _weight_scale_rate = 20 diff --git a/aeon/dj_pipeline/create_experiments/create_octagon_1.py b/aeon/dj_pipeline/create_experiments/create_octagon_1.py index d1b92458..7b09c8a5 100644 --- a/aeon/dj_pipeline/create_experiments/create_octagon_1.py +++ b/aeon/dj_pipeline/create_experiments/create_octagon_1.py @@ -1,5 +1,4 @@ -from aeon.dj_pipeline import acquisition, lab, subject - +from aeon.dj_pipeline import acquisition, subject # ============ Manual and automatic steps to for experiment 0.2 populate ============ experiment_name = "oct1.0-r0" diff --git a/aeon/dj_pipeline/create_experiments/create_socialexperiment_0.py b/aeon/dj_pipeline/create_experiments/create_socialexperiment_0.py index e48f4d65..7e55bac5 100644 --- a/aeon/dj_pipeline/create_experiments/create_socialexperiment_0.py +++ b/aeon/dj_pipeline/create_experiments/create_socialexperiment_0.py @@ -116,9 +116,8 @@ def main(): def fixID(subjid, valid_ids=None, valid_id_file=None): - """ - Legacy helper function for socialexperiment0 - originaly developed by ErlichLab - https://github.com/SainsburyWellcomeCentre/aeon_mecha/blob/ee1fa536b58e82fad01130d7689a70e68f94ec0e/aeon/util/helpers.py#L19 + """Legacy helper function for socialexperiment0 - originaly developed by ErlichLab + https://github.com/SainsburyWellcomeCentre/aeon_mecha/blob/ee1fa536b58e82fad01130d7689a70e68f94ec0e/aeon/util/helpers.py#L19. Attempt to correct the id entered by the technician Attempt to correct the subjid entered by the technician @@ -131,9 +130,10 @@ def fixID(subjid, valid_ids=None, valid_id_file=None): ) """ from os import path + import jellyfish as jl - import pandas as pd import numpy as np + import pandas as pd if not valid_ids: if not valid_id_file: diff --git a/aeon/dj_pipeline/lab.py b/aeon/dj_pipeline/lab.py index e724a7cf..2db6f4b8 100644 --- a/aeon/dj_pipeline/lab.py +++ b/aeon/dj_pipeline/lab.py @@ -151,12 +151,11 @@ class ArenaShape(dj.Lookup): @schema class Arena(dj.Lookup): - """ - Coordinate frame convention: + """Coordinate frame convention: + x-dimension: x=0 is the left most point of the bounding box of the arena + y-dimension: y=0 is the top most point of the bounding box of the arena + z-dimension: z=0 is the lowest point of the arena (e.g. the ground) - TODO: confirm/update this + TODO: confirm/update this. """ definition = """ diff --git a/aeon/dj_pipeline/populate/process.py b/aeon/dj_pipeline/populate/process.py index b0bc7305..d9931ef1 100644 --- a/aeon/dj_pipeline/populate/process.py +++ b/aeon/dj_pipeline/populate/process.py @@ -1,4 +1,4 @@ -"""Start an Aeon ingestion process +"""Start an Aeon ingestion process. This script defines auto-processing routines to operate the DataJoint pipeline for the Aeon project. Three separate "process" functions are defined to call `populate()` for @@ -53,8 +53,7 @@ def run(**kwargs): - """ - Run ingestion routine depending on the configured worker + """Run ingestion routine depending on the configured worker. :param worker_name: Select the worker :type worker_name: str @@ -88,8 +87,7 @@ def run(**kwargs): def cli(): - """ - Calls :func:`run` passing the CLI arguments extracted from `sys.argv` + """Calls :func:`run` passing the CLI arguments extracted from `sys.argv`. This function can be used as entry point to create console scripts with setuptools. """ diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py index 47d1f761..2db6be51 100644 --- a/aeon/dj_pipeline/populate/worker.py +++ b/aeon/dj_pipeline/populate/worker.py @@ -34,10 +34,9 @@ class AutomatedExperimentIngestion(dj.Manual): def ingest_colony_epochs_chunks(): - """ - Load and insert subjects from colony.csv + """Load and insert subjects from colony.csv Ingest epochs and chunks - for experiments specified in AutomatedExperimentIngestion + for experiments specified in AutomatedExperimentIngestion. """ load_metadata.ingest_subject() experiment_names = AutomatedExperimentIngestion.fetch("experiment_name") @@ -47,9 +46,8 @@ def ingest_colony_epochs_chunks(): def ingest_environment_visits(): - """ - Extract and insert complete visits - for experiments specified in AutomatedExperimentIngestion + """Extract and insert complete visits + for experiments specified in AutomatedExperimentIngestion. """ experiment_names = AutomatedExperimentIngestion.fetch("experiment_name") analysis.ingest_environment_visits(experiment_names) diff --git a/aeon/dj_pipeline/qc.py b/aeon/dj_pipeline/qc.py index 96813c53..8b877959 100644 --- a/aeon/dj_pipeline/qc.py +++ b/aeon/dj_pipeline/qc.py @@ -1,12 +1,10 @@ import datajoint as dj -import pandas as pd import numpy as np +import pandas as pd from aeon.io import api as io_api -from . import acquisition -from . import get_schema_name - +from . import acquisition, get_schema_name schema = dj.schema(get_schema_name("qc")) diff --git a/aeon/dj_pipeline/report.py b/aeon/dj_pipeline/report.py index a62d2014..ebde623a 100644 --- a/aeon/dj_pipeline/report.py +++ b/aeon/dj_pipeline/report.py @@ -2,7 +2,6 @@ import json import os import pathlib -import re import datajoint as dj import matplotlib.pyplot as plt @@ -138,7 +137,7 @@ def make(self, key): wheel_time = np.append(wheel_time, position_minutes_elapsed[-1]) - for i in range(0, len(wheel_time) - 1): + for i in range(len(wheel_time) - 1): threshold_ax.hlines( y=wheel_threshold[i], xmin=wheel_time[i], @@ -184,7 +183,7 @@ def make(self, key): color=self.color_code["arena"], markersize=0.5, alpha=0.6, - label=f"arena", + label="arena", ) ethogram_ax.plot( position_minutes_elapsed[in_corridor], @@ -193,7 +192,7 @@ def make(self, key): color=self.color_code["corridor"], markersize=0.5, alpha=0.6, - label=f"corridor", + label="corridor", ) for in_nest in in_nests: ethogram_ax.plot( @@ -203,7 +202,7 @@ def make(self, key): color=self.color_code["nest"], markersize=0.5, alpha=0.6, - label=f"nest", + label="nest", ) for patch_idx, (patch_name, in_patch) in enumerate( zip(patch_names, in_patches) @@ -317,11 +316,10 @@ def make(self, key): @classmethod def delete_outdated_entries(cls): - """ - Each entry in this table correspond to one subject. However, the plot is capturing + """Each entry in this table correspond to one subject. However, the plot is capturing data for all sessions. Hence a dynamic update routine is needed to recompute the plot as new sessions - become available + become available. """ outdated_entries = ( cls @@ -367,11 +365,10 @@ def make(self, key): @classmethod def delete_outdated_entries(cls): - """ - Each entry in this table correspond to one subject. However the plot is capturing + """Each entry in this table correspond to one subject. However the plot is capturing data for all sessions. Hence a dynamic update routine is needed to recompute the plot as new sessions - become available + become available. """ outdated_entries = ( cls @@ -415,11 +412,10 @@ def make(self, key): @classmethod def delete_outdated_entries(cls): - """ - Each entry in this table correspond to one subject. However the plot is capturing + """Each entry in this table correspond to one subject. However the plot is capturing data for all sessions. Hence a dynamic update routine is needed to recompute the plot as new sessions - become available + become available. """ outdated_entries = ( cls @@ -463,8 +459,8 @@ class VisitDailySummaryPlot(dj.Computed): key_source = ( Visit & analysis.VisitSummary - & (VisitEnd & f"visit_duration > 24") - & f"experiment_name= 'exp0.2-r0'" + & (VisitEnd & "visit_duration > 24") + & "experiment_name= 'exp0.2-r0'" ) def make(self, key): diff --git a/aeon/dj_pipeline/scripts/clone_and_freeze_exp01.py b/aeon/dj_pipeline/scripts/clone_and_freeze_exp01.py index 543e1329..426e4159 100644 --- a/aeon/dj_pipeline/scripts/clone_and_freeze_exp01.py +++ b/aeon/dj_pipeline/scripts/clone_and_freeze_exp01.py @@ -1,8 +1,8 @@ -""" -March 2022 -Cloning and archiving schemas and data for experiment 0.1 +"""March 2022 +Cloning and archiving schemas and data for experiment 0.1. """ import os + import datajoint as dj from datajoint_utilities.dj_data_copy import db_migration from datajoint_utilities.dj_data_copy.pipeline_cloning import ClonedPipeline diff --git a/aeon/dj_pipeline/scripts/update_timestamps_longblob.py b/aeon/dj_pipeline/scripts/update_timestamps_longblob.py index 4e6f0809..b8f4b3b0 100644 --- a/aeon/dj_pipeline/scripts/update_timestamps_longblob.py +++ b/aeon/dj_pipeline/scripts/update_timestamps_longblob.py @@ -1,9 +1,9 @@ +"""July 2022 +Upgrade all timestamps longblob fields with datajoint 0.13.7. """ -July 2022 -Upgrade all timestamps longblob fields with datajoint 0.13.7 -""" -import datajoint as dj from datetime import datetime + +import datajoint as dj import numpy as np from tqdm import tqdm @@ -17,7 +17,7 @@ class TimestampFix(dj.Manual): definition = """ full_table_name: varchar(64) - key_hash: uuid # dj.hash.key_hash(key) + key_hash: uuid # dj.hash.key_hash(key) """ diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index 4483d1dd..6d1f9d9b 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -1,9 +1,10 @@ #---- DO NOT MODIFY ---- #---- THIS FILE IS AUTO-GENERATED BY `streams_maker.py` ---- +from uuid import UUID + import datajoint as dj import pandas as pd -from uuid import UUID import aeon from aeon.dj_pipeline import acquisition, get_schema_name @@ -12,13 +13,12 @@ schema = dj.Schema(get_schema_name("streams")) -@schema +@schema class StreamType(dj.Lookup): - """ - Catalog of all steam types for the different device types used across Project Aeon + """Catalog of all steam types for the different device types used across Project Aeon One StreamType corresponds to one reader class in `aeon.io.reader` The combination of `stream_reader` and `stream_reader_kwargs` should fully specify - the data loading routine for a particular device, using the `aeon.io.utils` + the data loading routine for a particular device, using the `aeon.io.utils`. """ definition = """ # Catalog of all stream types used across Project Aeon @@ -32,11 +32,9 @@ class StreamType(dj.Lookup): """ -@schema +@schema class DeviceType(dj.Lookup): - """ - Catalog of all device types used across Project Aeon - """ + """Catalog of all device types used across Project Aeon.""" definition = """ # Catalog of all device types used across Project Aeon device_type: varchar(36) @@ -51,7 +49,7 @@ class Stream(dj.Part): """ -@schema +@schema class Device(dj.Lookup): definition = """ # Physical devices, of a particular type, identified by unique serial number device_serial_number: varchar(12) @@ -60,9 +58,9 @@ class Device(dj.Lookup): """ -@schema +@schema class UndergroundFeeder(dj.Manual): - definition = f""" + definition = """ # underground_feeder placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown) -> acquisition.Experiment -> Device @@ -80,16 +78,16 @@ class Attribute(dj.Part): """ class RemovalTime(dj.Part): - definition = f""" + definition = """ -> master --- underground_feeder_removal_time: datetime(6) # time of the underground_feeder being removed """ -@schema +@schema class VideoSource(dj.Manual): - definition = f""" + definition = """ # video_source placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown) -> acquisition.Experiment -> Device @@ -107,14 +105,14 @@ class Attribute(dj.Part): """ class RemovalTime(dj.Part): - definition = f""" + definition = """ -> master --- video_source_removal_time: datetime(6) # time of the video_source being removed """ -@schema +@schema class UndergroundFeederBeamBreak(dj.Imported): definition = """ # Raw per-chunk BeamBreak data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) -> UndergroundFeeder @@ -129,10 +127,9 @@ class UndergroundFeederBeamBreak(dj.Imported): @property def key_source(self): - f""" - Only the combination of Chunk and UndergroundFeeder with overlapping time + """Only the combination of Chunk and UndergroundFeeder with overlapping time + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time - + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed + + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed. """ return ( acquisition.Chunk @@ -181,7 +178,7 @@ def make(self, key): ) -@schema +@schema class UndergroundFeederDeliverPellet(dj.Imported): definition = """ # Raw per-chunk DeliverPellet data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) -> UndergroundFeeder @@ -196,10 +193,9 @@ class UndergroundFeederDeliverPellet(dj.Imported): @property def key_source(self): - f""" - Only the combination of Chunk and UndergroundFeeder with overlapping time + """Only the combination of Chunk and UndergroundFeeder with overlapping time + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time - + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed + + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed. """ return ( acquisition.Chunk @@ -248,7 +244,7 @@ def make(self, key): ) -@schema +@schema class UndergroundFeederDepletionState(dj.Imported): definition = """ # Raw per-chunk DepletionState data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) -> UndergroundFeeder @@ -265,10 +261,9 @@ class UndergroundFeederDepletionState(dj.Imported): @property def key_source(self): - f""" - Only the combination of Chunk and UndergroundFeeder with overlapping time + """Only the combination of Chunk and UndergroundFeeder with overlapping time + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time - + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed + + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed. """ return ( acquisition.Chunk @@ -317,7 +312,7 @@ def make(self, key): ) -@schema +@schema class UndergroundFeederEncoder(dj.Imported): definition = """ # Raw per-chunk Encoder data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) -> UndergroundFeeder @@ -333,10 +328,9 @@ class UndergroundFeederEncoder(dj.Imported): @property def key_source(self): - f""" - Only the combination of Chunk and UndergroundFeeder with overlapping time + """Only the combination of Chunk and UndergroundFeeder with overlapping time + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time - + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed + + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed. """ return ( acquisition.Chunk @@ -385,7 +379,7 @@ def make(self, key): ) -@schema +@schema class VideoSourcePosition(dj.Imported): definition = """ # Raw per-chunk Position data stream from VideoSource (auto-generated with aeon_mecha-unknown) -> VideoSource @@ -406,10 +400,9 @@ class VideoSourcePosition(dj.Imported): @property def key_source(self): - f""" - Only the combination of Chunk and VideoSource with overlapping time + """Only the combination of Chunk and VideoSource with overlapping time + Chunk(s) that started after VideoSource install time and ended before VideoSource remove time - + Chunk(s) that started after VideoSource install time for VideoSource that are not yet removed + + Chunk(s) that started after VideoSource install time for VideoSource that are not yet removed. """ return ( acquisition.Chunk @@ -458,7 +451,7 @@ def make(self, key): ) -@schema +@schema class VideoSourceRegion(dj.Imported): definition = """ # Raw per-chunk Region data stream from VideoSource (auto-generated with aeon_mecha-unknown) -> VideoSource @@ -473,10 +466,9 @@ class VideoSourceRegion(dj.Imported): @property def key_source(self): - f""" - Only the combination of Chunk and VideoSource with overlapping time + """Only the combination of Chunk and VideoSource with overlapping time + Chunk(s) that started after VideoSource install time and ended before VideoSource remove time - + Chunk(s) that started after VideoSource install time for VideoSource that are not yet removed + + Chunk(s) that started after VideoSource install time for VideoSource that are not yet removed. """ return ( acquisition.Chunk @@ -525,7 +517,7 @@ def make(self, key): ) -@schema +@schema class VideoSourceVideo(dj.Imported): definition = """ # Raw per-chunk Video data stream from VideoSource (auto-generated with aeon_mecha-unknown) -> VideoSource @@ -541,10 +533,9 @@ class VideoSourceVideo(dj.Imported): @property def key_source(self): - f""" - Only the combination of Chunk and VideoSource with overlapping time + """Only the combination of Chunk and VideoSource with overlapping time + Chunk(s) that started after VideoSource install time and ended before VideoSource remove time - + Chunk(s) that started after VideoSource install time for VideoSource that are not yet removed + + Chunk(s) that started after VideoSource install time for VideoSource that are not yet removed. """ return ( acquisition.Chunk diff --git a/aeon/dj_pipeline/subject.py b/aeon/dj_pipeline/subject.py index 92b34b86..42104a0f 100644 --- a/aeon/dj_pipeline/subject.py +++ b/aeon/dj_pipeline/subject.py @@ -1,9 +1,7 @@ import datajoint as dj -from . import lab from . import get_schema_name - schema = dj.schema(get_schema_name('subject')) diff --git a/aeon/dj_pipeline/tracking.py b/aeon/dj_pipeline/tracking.py index dcfe975f..572b23c8 100644 --- a/aeon/dj_pipeline/tracking.py +++ b/aeon/dj_pipeline/tracking.py @@ -5,14 +5,8 @@ import numpy as np import pandas as pd -from aeon.dj_pipeline import ( - acquisition, - dict_to_uuid, - get_schema_name, - lab, - qc, - streams, -) +from aeon.dj_pipeline import (acquisition, dict_to_uuid, get_schema_name, lab, + qc, streams) from aeon.io import api as io_api from . import acquisition, dict_to_uuid, get_schema_name, lab, qc @@ -350,9 +344,8 @@ def is_position_in_patch( def is_position_in_nest(position_df, nest_key, xcol="x", ycol="y") -> pd.Series: - """ - Given the session key and the position data - arrays of x and y - return an array of boolean indicating whether or not a position is inside the nest + """Given the session key and the position data - arrays of x and y + return an array of boolean indicating whether or not a position is inside the nest. """ nest_vertices = list( zip(*(lab.ArenaNest.Vertex & nest_key).fetch("vertex_x", "vertex_y")) diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index e94b8558..6ac7a25a 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -20,7 +20,7 @@ def ingest_subject(colony_csv_path: pathlib.Path = _colony_csv_path) -> None: - """Ingest subject information from the colony.csv file""" + """Ingest subject information from the colony.csv file.""" colony_df = pd.read_csv(colony_csv_path, skiprows=[1, 2]) colony_df.rename(columns={"Id": "subject"}, inplace=True) colony_df["sex"] = "U" @@ -91,7 +91,7 @@ def insert_device_types(schema: DotMap, metadata_yml_filepath: Path): # List only new device & stream types that need to be inserted & created. new_device_types = [ {"device_type": device_type} - for device_type in device_stream_map.keys() + for device_type in device_stream_map if not streams.DeviceType & {"device_type": device_type} ] @@ -123,7 +123,7 @@ def insert_device_types(schema: DotMap, metadata_yml_filepath: Path): except dj.DataJointError: insert_stream_types() streams.DeviceType.Stream.insert(new_device_stream_types) - + if new_devices: streams.Device.insert(new_devices) @@ -138,7 +138,6 @@ def extract_epoch_config(experiment_name: str, metadata_yml_filepath: str) -> di Returns: dict: epoch_config [dict] """ - metadata_yml_filepath = pathlib.Path(metadata_yml_filepath) epoch_start = datetime.datetime.strptime( metadata_yml_filepath.parent.name, "%Y-%m-%dT%H-%M-%S" @@ -182,9 +181,7 @@ def extract_epoch_config(experiment_name: str, metadata_yml_filepath: str) -> di def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): - """ - Make entries into device tables - """ + """Make entries into device tables.""" streams = dj.VirtualModule("streams", streams_maker.schema_name) if experiment_name.startswith("oct"): @@ -313,7 +310,7 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): # region Get stream & device information def get_stream_entries(schema: DotMap) -> list[dict]: - """Returns a list of dictionaries containing the stream entries for a given device, + """Returns a list of dictionaries containing the stream entries for a given device. Args: schema (DotMap): DotMap object (e.g., exp02, octagon01) @@ -347,8 +344,7 @@ def get_stream_entries(schema: DotMap) -> list[dict]: def get_device_info(schema: DotMap) -> dict[dict]: - """ - Read from the above DotMap object and returns a device dictionary as the following. + """Read from the above DotMap object and returns a device dictionary as the following. Args: schema (DotMap): DotMap object (e.g., exp02, octagon01) @@ -508,9 +504,7 @@ def get_device_mapper(schema: DotMap, metadata_yml_filepath: Path): def ingest_epoch_metadata_octagon(experiment_name, metadata_yml_filepath): - """ - Temporary ingestion routine to load devices' meta information for Octagon arena experiments - """ + """Temporary ingestion routine to load devices' meta information for Octagon arena experiments.""" streams = dj.VirtualModule("streams", streams_maker.schema_name) oct01_devices = [ diff --git a/aeon/dj_pipeline/utils/paths.py b/aeon/dj_pipeline/utils/paths.py index 1e30c325..0eb90d89 100644 --- a/aeon/dj_pipeline/utils/paths.py +++ b/aeon/dj_pipeline/utils/paths.py @@ -4,9 +4,8 @@ def get_repository_path(repository_name): - """ - Find the directory's full-path corresponding to a given repository_name, - as configured in dj.config['custom']['repository_config'] + """Find the directory's full-path corresponding to a given repository_name, + as configured in dj.config['custom']['repository_config']. """ repo_path = repository_config.get(repository_name) if repo_path is None: @@ -21,12 +20,11 @@ def get_repository_path(repository_name): def find_root_directory(root_directories, full_path): - """ - Given multiple potential root directories and a full-path, + """Given multiple potential root directories and a full-path, search and return one directory that is the parent of the given path :param root_directories: potential root directories :param full_path: the relative path to search the root directory - :return: full-path (pathlib.Path object) + :return: full-path (pathlib.Path object). """ full_path = pathlib.Path(full_path) @@ -46,6 +44,6 @@ def find_root_directory(root_directories, full_path): except StopIteration: raise FileNotFoundError( - "No valid root directory found (from {})" - " for {}".format(root_directories, full_path) + f"No valid root directory found (from {root_directories})" + f" for {full_path}" ) diff --git a/aeon/dj_pipeline/utils/plotting.py b/aeon/dj_pipeline/utils/plotting.py index c4c92fa1..3d2e3035 100644 --- a/aeon/dj_pipeline/utils/plotting.py +++ b/aeon/dj_pipeline/utils/plotting.py @@ -3,19 +3,14 @@ import pandas as pd import plotly.express as px import plotly.graph_objects as go -import plotly.io as pio +from plotly.subplots import make_subplots +from scipy.signal import savgol_filter from aeon.dj_pipeline import acquisition, analysis, lab -from aeon.dj_pipeline.analysis.visit import Visit, VisitEnd +from aeon.dj_pipeline.analysis.visit import VisitEnd from aeon.dj_pipeline.analysis.visit_analysis import ( - VisitSummary, - VisitTimeDistribution, - VisitForagingBout, - get_maintenance_periods, - filter_out_maintenance_periods, -) -from plotly.subplots import make_subplots -from scipy.signal import savgol_filter + VisitForagingBout, VisitSummary, VisitTimeDistribution, + filter_out_maintenance_periods, get_maintenance_periods) # pio.renderers.default = 'png' # pio.orca.config.executable = '~/.conda/envs/aeon_env/bin/orca' @@ -24,12 +19,11 @@ def plot_reward_rate_differences(subject_keys): - """ - Plotting the reward rate differences between food patches (Patch 2 - Patch 1) + """Plotting the reward rate differences between food patches (Patch 2 - Patch 1) for all sessions from all subjects specified in "subject_keys" Example usage: ``` - subject_keys = (acquisition.Experiment.Subject & 'experiment_name = "exp0.1-r0"').fetch('KEY') + subject_keys = (acquisition.Experiment.Subject & 'experiment_name = "exp0.1-r0"').fetch('KEY'). fig = plot_reward_rate_differences(subject_keys) ``` @@ -68,7 +62,7 @@ def plot_reward_rate_differences(subject_keys): zmax=absZmax, aspect="auto", color_continuous_scale="RdBu_r", - labels=dict(color="Reward Rate
    Patch2-Patch1"), + labels={"color": "Reward Rate
    Patch2-Patch1"}, ) fig.update_layout( xaxis_title="Time (min)", @@ -80,18 +74,16 @@ def plot_reward_rate_differences(subject_keys): def plot_wheel_travelled_distance(session_keys): - """ - Plotting the wheel travelled distance for different patches + """Plotting the wheel travelled distance for different patches for all sessions specified in "session_keys" Example usage: ``` session_keys = (acquisition.Session & acquisition.SessionEnd - & {'experiment_name': 'exp0.1-r0', 'subject': 'BAA-1099794'}).fetch('KEY') + & {'experiment_name': 'exp0.1-r0', 'subject': 'BAA-1099794'}).fetch('KEY'). fig = plot_wheel_travelled_distance(session_keys) ``` """ - distance_travelled_query = ( analysis.InArenaSummary.FoodPatch * acquisition.ExperimentFoodPatch.proj("food_patch_description") @@ -213,7 +205,7 @@ def plot_visit_daily_summary( attr, per_food_patch=False, ): - """plot results from VisitSummary per visit + """Plot results from VisitSummary per visit. Args: visit_key (dict) : Key from the VisitSummary table @@ -228,7 +220,6 @@ def plot_visit_daily_summary( >>> fig = plot_visit_daily_summary(visit_key, attr='wheel_distance_travelled', per_food_patch=True) >>> fig = plot_visit_daily_summary(visit_key, attr='total_distance_travelled') """ - per_food_patch = not attr.startswith("total") color = "food_patch_description" if per_food_patch else None @@ -243,7 +234,7 @@ def plot_visit_daily_summary( ) else: visit_per_day_df = ( - ((VisitSummary & visit_key)).fetch(format="frame").reset_index() + (VisitSummary & visit_key).fetch(format="frame").reset_index() ) if not attr.startswith("total"): attr = "total_" + attr @@ -271,9 +262,9 @@ def plot_visit_daily_summary( ) fig.update_layout( - legend=dict( - title="", orientation="h", yanchor="bottom", y=0.98, xanchor="right", x=1 - ), + legend={ + "title": "", "orientation": "h", "yanchor": "bottom", "y": 0.98, "xanchor": "right", "x": 1 + }, hovermode="x", yaxis_tickformat="digits", yaxis_range=[0, None], @@ -290,7 +281,7 @@ def plot_foraging_bouts_count( min_pellet_count=0, min_wheel_dist=0, ): - """plot the number of foraging bouts per visit + """Plot the number of foraging bouts per visit. Args: visit_key (dict): Key from the Visit table @@ -306,7 +297,6 @@ def plot_foraging_bouts_count( Examples: >>> fig = plot_foraging_bouts_count(visit_key, freq="D", per_food_patch=True, min_bout_duration=1, min_wheel_dist=1) """ - # Get all foraging bouts for the visit foraging_bouts = ( ( @@ -365,9 +355,9 @@ def plot_foraging_bouts_count( ) fig.update_layout( - legend=dict( - title="", orientation="h", yanchor="bottom", y=0.98, xanchor="right", x=1 - ), + legend={ + "title": "", "orientation": "h", "yanchor": "bottom", "y": 0.98, "xanchor": "right", "x": 1 + }, hovermode="x", yaxis_tickformat="digits", yaxis_range=[0, None], @@ -384,7 +374,7 @@ def plot_foraging_bouts_distribution( min_pellet_count=0, min_wheel_dist=0, ): - """plot distribution of foraging bout attributes + """Plot distribution of foraging bout attributes. Args: visit_key (dict): Key from the Visit table @@ -402,7 +392,6 @@ def plot_foraging_bouts_distribution( >>> fig = plot_foraging_bouts_distribution(visit_key, "wheel_distance_travelled") >>> fig = plot_foraging_bouts_distribution(visit_key, "bout_duration") """ - # Get all foraging bouts for the visit foraging_bouts = ( ( @@ -469,14 +458,14 @@ def plot_foraging_bouts_distribution( width=700, height=400, template="simple_white", - legend=dict(orientation="h", yanchor="bottom", y=1, xanchor="right", x=1), + legend={"orientation": "h", "yanchor": "bottom", "y": 1, "xanchor": "right", "x": 1}, ) return fig def plot_visit_time_distribution(visit_key, freq="D"): - """plot fraction of time spent in each region per visit + """Plot fraction of time spent in each region per visit. Args: visit_key (dict): Key from the Visit table @@ -489,7 +478,6 @@ def plot_visit_time_distribution(visit_key, freq="D"): >>> fig = plot_visit_time_distribution(visit_key, freq="D") >>> fig = plot_visit_time_distribution(visit_key, freq="H") """ - region = _get_region_data(visit_key) # Compute time spent per region @@ -528,9 +516,9 @@ def plot_visit_time_distribution(visit_key, freq="D"): hovermode="x", yaxis_tickformat="digits", yaxis_range=[0, None], - legend=dict( - title="", orientation="h", yanchor="bottom", y=0.98, xanchor="right", x=1 - ), + legend={ + "title": "", "orientation": "h", "yanchor": "bottom", "y": 0.98, "xanchor": "right", "x": 1 + }, ) return fig @@ -627,7 +615,7 @@ def _get_region_data( def plot_weight_patch_data( visit_key, freq="H", smooth_weight=True, min_weight=0, max_weight=35 ): - """plot subject weight and patch data (pellet trigger count) per visit + """Plot subject weight and patch data (pellet trigger count) per visit. Args: visit_key (dict): Key from the Visit table @@ -643,7 +631,6 @@ def plot_weight_patch_data( >>> fig = plot_weight_patch_data(visit_key, freq="H", smooth_weight=True) >>> fig = plot_weight_patch_data(visit_key, freq="D") """ - subject_weight = _get_filtered_subject_weight( visit_key, smooth_weight, min_weight, max_weight ) @@ -693,9 +680,9 @@ def plot_weight_patch_data( mode="lines+markers", opacity=0.5, name="subject weight", - marker=dict( - size=3, - ), + marker={ + "size": 3, + }, legendrank=1, ), secondary_y=True, @@ -709,20 +696,20 @@ def plot_weight_patch_data( + freq + "')", xaxis_title="date" if freq == "D" else "time", - yaxis=dict(title="pellet count"), - yaxis2=dict(title="weight"), + yaxis={"title": "pellet count"}, + yaxis2={"title": "weight"}, width=700, height=400, template="simple_white", - legend=dict( - title="", - orientation="h", - yanchor="bottom", - y=0.98, - xanchor="right", - x=1, - traceorder="normal", - ), + legend={ + "title": "", + "orientation": "h", + "yanchor": "bottom", + "y": 0.98, + "xanchor": "right", + "x": 1, + "traceorder": "normal", + }, ) return fig @@ -742,7 +729,6 @@ def _get_filtered_subject_weight( Returns: subject_weight (pd.DataFrame): Timestamped weight data """ - visit_start, visit_end = (VisitEnd & visit_key).fetch1("visit_start", "visit_end") chunk_keys = ( @@ -805,7 +791,6 @@ def _get_patch_data(visit_key): Returns: patch (pd.DataFrame): Timestamped pellet trigger events """ - visit_start, visit_end = (VisitEnd & visit_key).fetch1("visit_start", "visit_end") # Get pellet trigger dataframe for all patches diff --git a/aeon/dj_pipeline/utils/streams_maker.py b/aeon/dj_pipeline/utils/streams_maker.py index 26afa23c..ff9fca39 100644 --- a/aeon/dj_pipeline/utils/streams_maker.py +++ b/aeon/dj_pipeline/utils/streams_maker.py @@ -20,11 +20,10 @@ class StreamType(dj.Lookup): - """ - Catalog of all steam types for the different device types used across Project Aeon + """Catalog of all steam types for the different device types used across Project Aeon One StreamType corresponds to one reader class in `aeon.io.reader` The combination of `stream_reader` and `stream_reader_kwargs` should fully specify - the data loading routine for a particular device, using the `aeon.io.utils` + the data loading routine for a particular device, using the `aeon.io.utils`. """ definition = """ # Catalog of all stream types used across Project Aeon @@ -39,9 +38,7 @@ class StreamType(dj.Lookup): class DeviceType(dj.Lookup): - """ - Catalog of all device types used across Project Aeon - """ + """Catalog of all device types used across Project Aeon.""" definition = """ # Catalog of all device types used across Project Aeon device_type: varchar(36) @@ -68,7 +65,7 @@ class Device(dj.Lookup): def get_device_template(device_type: str): - """Returns table class template for ExperimentDevice""" + """Returns table class template for ExperimentDevice.""" device_title = device_type device_type = dj.utils.from_camel_case(device_type) @@ -103,8 +100,7 @@ class RemovalTime(dj.Part): def get_device_stream_template(device_type: str, stream_type: str, streams_module): - """Returns table class template for DeviceDataStream""" - + """Returns table class template for DeviceDataStream.""" ExperimentDevice = getattr(streams_module, device_type) # DeviceDataStream table(s) @@ -117,10 +113,7 @@ def get_device_stream_template(device_type: str, stream_type: str, streams_modul ).fetch1() for i, n in enumerate(stream_detail["stream_reader"].split(".")): - if i == 0: - reader = aeon - else: - reader = getattr(reader, n) + reader = aeon if i == 0 else getattr(reader, n) stream = reader(**stream_detail["stream_reader_kwargs"]) @@ -224,7 +217,7 @@ def main(create_tables=True): full_def = "@schema \n" + device_table_def + "\n\n" f.write(full_def) - streams = importlib.import_module(f"aeon.dj_pipeline.streams") + streams = importlib.import_module("aeon.dj_pipeline.streams") if create_tables: # Create DeviceType tables. @@ -245,7 +238,7 @@ def main(create_tables=True): for old, new in replacements.items(): device_table_def = device_table_def.replace(old, new) full_def = "@schema \n" + device_table_def + "\n\n" - with open(_STREAMS_MODULE_FILE, "r") as f: + with open(_STREAMS_MODULE_FILE) as f: existing_content = f.read() if full_def in existing_content: @@ -304,7 +297,7 @@ def main(create_tables=True): full_def = "@schema \n" + device_stream_table_def + "\n\n" - with open(_STREAMS_MODULE_FILE, "r") as f: + with open(_STREAMS_MODULE_FILE) as f: existing_content = f.read() if full_def in existing_content: diff --git a/aeon/dj_pipeline/utils/video.py b/aeon/dj_pipeline/utils/video.py index af1e9595..5c4a0b1d 100644 --- a/aeon/dj_pipeline/utils/video.py +++ b/aeon/dj_pipeline/utils/video.py @@ -1,8 +1,7 @@ import base64 -import datetime from pathlib import Path + import cv2 -import numpy as np import pandas as pd import aeon.io.reader as io_reader @@ -58,4 +57,4 @@ def retrieve_video_frames( "finalChunk": bool(last_frame_time >= end_time), }, "frames": encoded_frames, - } \ No newline at end of file + } From 0c08555ca60488a4d0b72f02b33be197a48bba0a Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 19 Sep 2023 18:25:35 +0000 Subject: [PATCH 298/489] style: :art: apply black formatting from pre-commit --- aeon/dj_pipeline/__init__.py | 4 +- aeon/dj_pipeline/acquisition.py | 139 ++++----------- aeon/dj_pipeline/analysis/in_arena.py | 118 ++++--------- aeon/dj_pipeline/analysis/visit.py | 21 +-- aeon/dj_pipeline/analysis/visit_analysis.py | 147 +++++----------- .../create_experiment_01.py | 40 ++--- .../create_experiment_02.py | 5 +- .../create_experiments/create_octagon_1.py | 5 +- .../create_experiments/create_presocial.py | 32 ++-- .../create_socialexperiment_0.py | 21 +-- aeon/dj_pipeline/populate/process.py | 4 +- aeon/dj_pipeline/qc.py | 18 +- aeon/dj_pipeline/report.py | 63 ++----- .../scripts/clone_and_freeze_exp01.py | 39 ++-- .../scripts/update_timestamps_longblob.py | 8 +- aeon/dj_pipeline/subject.py | 2 +- aeon/dj_pipeline/tracking.py | 57 +++--- aeon/dj_pipeline/utils/load_metadata.py | 103 +++-------- aeon/dj_pipeline/utils/paths.py | 3 +- aeon/dj_pipeline/utils/plotting.py | 166 +++++++----------- aeon/dj_pipeline/utils/streams_maker.py | 43 ++--- aeon/dj_pipeline/utils/video.py | 1 + 22 files changed, 329 insertions(+), 710 deletions(-) diff --git a/aeon/dj_pipeline/__init__.py b/aeon/dj_pipeline/__init__.py index 6ba136d9..6a9c64b6 100644 --- a/aeon/dj_pipeline/__init__.py +++ b/aeon/dj_pipeline/__init__.py @@ -13,8 +13,7 @@ db_prefix = dj.config["custom"].get("database.prefix", _default_database_prefix) -repository_config = dj.config['custom'].get('repository_config', - _default_repository_config) +repository_config = dj.config["custom"].get("repository_config", _default_repository_config) def get_schema_name(name) -> str: @@ -36,6 +35,7 @@ def dict_to_uuid(key) -> uuid.UUID: except ImportError: try: from .utils import streams_maker + streams = dj.VirtualModule("streams", streams_maker.schema_name) except: pass diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index ac9541a6..82f4a64c 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -31,7 +31,7 @@ "social0-r1": aeon_schema.exp01, "exp0.2-r0": aeon_schema.exp02, "oct1.0-r0": aeon_schema.octagon01, - "multianimal": aeon_schema.multianimal + "multianimal": aeon_schema.multianimal, } @@ -137,9 +137,7 @@ def get_data_directory(cls, experiment_key, directory_type="raw", as_posix=False return data_directory.as_posix() if as_posix else data_directory @classmethod - def get_data_directories( - cls, experiment_key, directory_types=None, as_posix=False - ): + def get_data_directories(cls, experiment_key, directory_types=None, as_posix=False): if directory_types is None: directory_types = ["raw"] return [ @@ -280,9 +278,7 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): Note: "start" and "end" are datetime specified a string in the format: "%Y-%m-%d %H:%M:%S". """ from .utils import streams_maker - from .utils.load_metadata import (extract_epoch_config, - ingest_epoch_metadata, - insert_device_types) + from .utils.load_metadata import extract_epoch_config, ingest_epoch_metadata, insert_device_types device_name = _ref_device_mapping.get(experiment_name, "CameraTop") @@ -292,21 +288,15 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): for i, (_, chunk) in enumerate(all_chunks.iterrows()): chunk_rep_file = pathlib.Path(chunk.path) epoch_dir = pathlib.Path(chunk_rep_file.as_posix().split(device_name)[0]) - epoch_start = datetime.datetime.strptime( - epoch_dir.name, "%Y-%m-%dT%H-%M-%S" - ) + epoch_start = datetime.datetime.strptime(epoch_dir.name, "%Y-%m-%dT%H-%M-%S") # --- insert to Epoch --- epoch_key = {"experiment_name": experiment_name, "epoch_start": epoch_start} # skip over epochs out of the (start, end) range is_out_of_start_end_range = ( - start - and epoch_start < datetime.datetime.strptime(start, "%Y-%m-%d %H:%M:%S") - ) or ( - end - and epoch_start > datetime.datetime.strptime(end, "%Y-%m-%d %H:%M:%S") - ) + start and epoch_start < datetime.datetime.strptime(start, "%Y-%m-%d %H:%M:%S") + ) or (end and epoch_start > datetime.datetime.strptime(end, "%Y-%m-%d %H:%M:%S")) # skip over those already ingested if cls & epoch_key or epoch_key in epoch_list: @@ -316,9 +306,7 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): if experiment_name != "exp0.1-r0": metadata_yml_filepath = epoch_dir / "Metadata.yml" if metadata_yml_filepath.exists(): - epoch_config = extract_epoch_config( - experiment_name, metadata_yml_filepath - ) + epoch_config = extract_epoch_config(experiment_name, metadata_yml_filepath) metadata_yml_filepath = epoch_config["metadata_file_path"] @@ -340,15 +328,11 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): if i > 0: previous_chunk = all_chunks.iloc[i - 1] previous_chunk_path = pathlib.Path(previous_chunk.path) - previous_epoch_dir = pathlib.Path( - previous_chunk_path.as_posix().split(device_name)[0] - ) + previous_epoch_dir = pathlib.Path(previous_chunk_path.as_posix().split(device_name)[0]) previous_epoch_start = datetime.datetime.strptime( previous_epoch_dir.name, "%Y-%m-%dT%H-%M-%S" ) - previous_chunk_end = previous_chunk.name + datetime.timedelta( - hours=io_api.CHUNK_DURATION - ) + previous_chunk_end = previous_chunk.name + datetime.timedelta(hours=io_api.CHUNK_DURATION) previous_epoch_end = min(previous_chunk_end, epoch_start) previous_epoch_key = { "experiment_name": experiment_name, @@ -362,7 +346,6 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): if epoch_config: cls.Config.insert1(epoch_config) if metadata_yml_filepath and metadata_yml_filepath.exists(): - try: # Insert new entries for streams.DeviceType, streams.Device. insert_device_types( @@ -373,9 +356,7 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): streams_maker.main() with cls.connection.transaction: # Insert devices' installation/removal/settings - ingest_epoch_metadata( - experiment_name, metadata_yml_filepath - ) + ingest_epoch_metadata(experiment_name, metadata_yml_filepath) epoch_list.append(epoch_key) except Exception as e: (cls.Config & epoch_key).delete_quick() @@ -383,20 +364,14 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): raise e # update previous epoch - if ( - previous_epoch_key - and (cls & previous_epoch_key) - and not (EpochEnd & previous_epoch_key) - ): + if previous_epoch_key and (cls & previous_epoch_key) and not (EpochEnd & previous_epoch_key): with cls.connection.transaction: # insert end-time for previous epoch EpochEnd.insert1( { **previous_epoch_key, "epoch_end": previous_epoch_end, - "epoch_duration": ( - previous_epoch_end - previous_epoch_start - ).total_seconds() + "epoch_duration": (previous_epoch_end - previous_epoch_start).total_seconds() / 3600, } ) @@ -460,9 +435,7 @@ def ingest_chunks(cls, experiment_name): for _, chunk in all_chunks.iterrows(): chunk_rep_file = pathlib.Path(chunk.path) epoch_dir = pathlib.Path(chunk_rep_file.as_posix().split(device_name)[0]) - epoch_start = datetime.datetime.strptime( - epoch_dir.name, "%Y-%m-%dT%H-%M-%S" - ) + epoch_start = datetime.datetime.strptime(epoch_dir.name, "%Y-%m-%dT%H-%M-%S") epoch_key = {"experiment_name": experiment_name, "epoch_start": epoch_start} if not (Epoch & epoch_key): @@ -495,12 +468,8 @@ def ingest_chunks(cls, experiment_name): ) chunk_starts.append(chunk_key["chunk_start"]) - chunk_list.append( - {**chunk_key, **directory, "chunk_end": chunk_end, **epoch_key} - ) - file_name_list.append( - chunk_rep_file.name - ) # handle duplicated files in different folders + chunk_list.append({**chunk_key, **directory, "chunk_end": chunk_end, **epoch_key}) + file_name_list.append(chunk_rep_file.name) # handle duplicated files in different folders # -- files -- file_datetime_str = chunk_rep_file.stem.replace(f"{device_name}_", "") @@ -721,15 +690,11 @@ def make(self, key): chunk_start, chunk_end, dir_type = (Chunk & key).fetch1( "chunk_start", "chunk_end", "directory_type" ) - food_patch_description = (ExperimentFoodPatch & key).fetch1( - "food_patch_description" - ) + food_patch_description = (ExperimentFoodPatch & key).fetch1("food_patch_description") raw_data_dir = Experiment.get_data_directory(key, directory_type=dir_type) - device = getattr( - _device_schema_mapping[key["experiment_name"]], food_patch_description - ) + device = getattr(_device_schema_mapping[key["experiment_name"]], food_patch_description) pellet_data = pd.concat( [ @@ -760,8 +725,7 @@ def make(self, key): ] else: event_code_mapper = { - name: code - for code, name in zip(*EventType.fetch("event_code", "event_type")) + name: code for code, name in zip(*EventType.fetch("event_code", "event_type")) } event_list = [ { @@ -803,15 +767,11 @@ def make(self, key): chunk_start, chunk_end, dir_type = (Chunk & key).fetch1( "chunk_start", "chunk_end", "directory_type" ) - food_patch_description = (ExperimentFoodPatch & key).fetch1( - "food_patch_description" - ) + food_patch_description = (ExperimentFoodPatch & key).fetch1("food_patch_description") raw_data_dir = Experiment.get_data_directory(key, directory_type=dir_type) - device = getattr( - _device_schema_mapping[key["experiment_name"]], food_patch_description - ) + device = getattr(_device_schema_mapping[key["experiment_name"]], food_patch_description) wheel_data = io_api.load( root=raw_data_dir.as_posix(), @@ -830,9 +790,7 @@ def make(self, key): ) @classmethod - def get_wheel_data( - cls, experiment_name, start, end, patch_name="Patch1", using_aeon_io=False - ): + def get_wheel_data(cls, experiment_name, start, end, patch_name="Patch1", using_aeon_io=False): if using_aeon_io: key = {"experiment_name": experiment_name} raw_data_dir = Experiment.get_data_directory(key) @@ -877,20 +835,14 @@ def get_wheel_data( timestamp_attr = next(attr for attr in fetch_attrs if "timestamps" in attr) # stack and structure in pandas DataFrame - wheel_data = pd.DataFrame( - {k: np.hstack(v) for k, v in zip(fetch_attrs, fetched_data)} - ) + wheel_data = pd.DataFrame({k: np.hstack(v) for k, v in zip(fetch_attrs, fetched_data)}) wheel_data.set_index(timestamp_attr, inplace=True) - time_mask = np.logical_and( - wheel_data.index >= start, wheel_data.index < end - ) + time_mask = np.logical_and(wheel_data.index >= start, wheel_data.index < end) wheel_data = wheel_data[time_mask] - wheel_data["distance_travelled"] = analysis_utils.distancetravelled( - wheel_data["angle"] - ) + wheel_data["distance_travelled"] = analysis_utils.distancetravelled(wheel_data["angle"]) return wheel_data @@ -927,14 +879,10 @@ def make(self, key): chunk_start, chunk_end, dir_type = (Chunk & key).fetch1( "chunk_start", "chunk_end", "directory_type" ) - food_patch_description = (ExperimentFoodPatch & key).fetch1( - "food_patch_description" - ) + food_patch_description = (ExperimentFoodPatch & key).fetch1("food_patch_description") raw_data_dir = Experiment.get_data_directory(key, directory_type=dir_type) - device = getattr( - _device_schema_mapping[key["experiment_name"]], food_patch_description - ) + device = getattr(_device_schema_mapping[key["experiment_name"]], food_patch_description) wheel_state = io_api.load( root=raw_data_dir.as_posix(), @@ -979,29 +927,23 @@ def key_source(self): + Chunk(s) that started after WeightScale install time for WeightScale that are not yet removed. """ return ( - Chunk - * ExperimentWeightScale.join(ExperimentWeightScale.RemovalTime, left=True) + Chunk * ExperimentWeightScale.join(ExperimentWeightScale.RemovalTime, left=True) & "chunk_start >= weight_scale_install_time" & 'chunk_start < IFNULL(weight_scale_remove_time, "2200-01-01")' ) def make(self, key): - chunk_start, chunk_end, dir_type = (Chunk & key).fetch1( "chunk_start", "chunk_end", "directory_type" ) raw_data_dir = Experiment.get_data_directory(key, directory_type=dir_type) - weight_scale_description = (ExperimentWeightScale & key).fetch1( - "weight_scale_description" - ) + weight_scale_description = (ExperimentWeightScale & key).fetch1("weight_scale_description") # in some epochs/chunks, the food patch device was mapped to "Nest" for device_name in (weight_scale_description, "Nest"): - device = getattr( - _device_schema_mapping[key["experiment_name"]], device_name - ) + device = getattr(_device_schema_mapping[key["experiment_name"]], device_name) weight_data = io_api.load( root=raw_data_dir.as_posix(), reader=device.WeightRaw, @@ -1039,15 +981,11 @@ def make(self, key): "chunk_start", "chunk_end", "directory_type" ) raw_data_dir = Experiment.get_data_directory(key, directory_type=dir_type) - weight_scale_description = (ExperimentWeightScale & key).fetch1( - "weight_scale_description" - ) + weight_scale_description = (ExperimentWeightScale & key).fetch1("weight_scale_description") # in some epochs/chunks, the food patch device was mapped to "Nest" for device_name in (weight_scale_description, "Nest"): - device = getattr( - _device_schema_mapping[key["experiment_name"]], device_name - ) + device = getattr(_device_schema_mapping[key["experiment_name"]], device_name) weight_filtered = io_api.load( root=raw_data_dir.as_posix(), reader=device.WeightFiltered, @@ -1129,9 +1067,7 @@ def _match_experiment_directory(experiment_name, path, directories): repo_path = paths.get_repository_path(directory.pop("repository_name")) break else: - raise FileNotFoundError( - f"Unable to identify the directory" f" where this chunk is from: {path}" - ) + raise FileNotFoundError(f"Unable to identify the directory" f" where this chunk is from: {path}") return raw_data_dir, directory, repo_path @@ -1154,8 +1090,7 @@ def _load_legacy_subjectdata(experiment_name, data_dir, start, end): return subject_data if experiment_name == "social0-r1": - from aeon.dj_pipeline.create_experiments.create_socialexperiment_0 import \ - fixID + from aeon.dj_pipeline.create_experiments.create_socialexperiment_0 import fixID sessdf = subject_data.copy() sessdf = sessdf[~sessdf.id.str.contains("test")] @@ -1165,9 +1100,7 @@ def _load_legacy_subjectdata(experiment_name, data_dir, start, end): sessdf = sessdf[~sessdf.id.str.contains("Animal")] sessdf = sessdf[~sessdf.id.str.contains("white")] - valid_ids = (Experiment.Subject & {"experiment_name": experiment_name}).fetch( - "subject" - ) + valid_ids = (Experiment.Subject & {"experiment_name": experiment_name}).fetch("subject") fix = lambda x: fixID(x, valid_ids=list(valid_ids)) sessdf.id = sessdf.id.apply(fix) @@ -1176,9 +1109,7 @@ def _load_legacy_subjectdata(experiment_name, data_dir, start, end): multi_ids_rows = [] for _, r in multi_ids.iterrows(): for i in r.id.split(";"): - multi_ids_rows.append( - {"time": r.name, "id": i, "weight": r.weight, "event": r.event} - ) + multi_ids_rows.append({"time": r.name, "id": i, "weight": r.weight, "event": r.event}) multi_ids_rows = pd.DataFrame(multi_ids_rows) if len(multi_ids_rows): multi_ids_rows.set_index("time", inplace=True) diff --git a/aeon/dj_pipeline/analysis/in_arena.py b/aeon/dj_pipeline/analysis/in_arena.py index 5a3bc894..7281c396 100644 --- a/aeon/dj_pipeline/analysis/in_arena.py +++ b/aeon/dj_pipeline/analysis/in_arena.py @@ -88,9 +88,7 @@ def make(self, key): duration = (in_arena_end - in_arena_start).total_seconds() / 3600 # insert - self.insert1( - {**key, "in_arena_end": in_arena_end, "in_arena_duration": duration} - ) + self.insert1({**key, "in_arena_end": in_arena_end, "in_arena_duration": duration}) # ------------------- TIMESLICE -------------------- @@ -116,9 +114,7 @@ def key_source(self): + chunk starts after in_arena_start and ends before in_arena_end (or NOW() - i.e. session still on going). """ return ( - InArena.join(InArenaEnd, left=True).proj( - in_arena_end="IFNULL(in_arena_end, NOW())" - ) + InArena.join(InArenaEnd, left=True).proj(in_arena_end="IFNULL(in_arena_end, NOW())") * acquisition.Chunk - NeverExitedArena & acquisition.SubjectEnterExit @@ -132,9 +128,7 @@ def key_source(self): _time_slice_duration = datetime.timedelta(hours=0, minutes=10, seconds=0) def make(self, key): - chunk_start, chunk_end = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end" - ) + chunk_start, chunk_end = (acquisition.Chunk & key).fetch1("chunk_start", "chunk_end") # -- Determine the time to start time_slicing in this chunk if chunk_start < key["in_arena_start"] < chunk_end: @@ -159,18 +153,14 @@ def make(self, key): as_dict=True, order_by="enter_exit_time DESC", limit=1 )[0] if next_event["event_type"] == "SubjectEnteredArena": - NeverExitedArena.insert1( - key, ignore_extra_fields=True, skip_duplicates=True - ) + NeverExitedArena.insert1(key, ignore_extra_fields=True, skip_duplicates=True) return end_time = next_event["enter_exit_time"] chunk_time_slices = [] time_slice_start = start_time while time_slice_start < end_time: - time_slice_end = time_slice_start + min( - self._time_slice_duration, end_time - time_slice_start - ) + time_slice_end = time_slice_start + min(self._time_slice_duration, end_time - time_slice_start) chunk_time_slices.append( { **key, @@ -226,9 +216,7 @@ def make(self, key): ) if not len(positiondata): - raise ValueError( - f"No position data between {time_slice_start} and {time_slice_end}" - ) + raise ValueError(f"No position data between {time_slice_start} and {time_slice_end}") timestamps = positiondata.index.values x = positiondata.position_x.values @@ -237,9 +225,7 @@ def make(self, key): area = positiondata.area.values # speed - TODO: confirm with aeon team if this calculation is sufficient (any smoothing needed?) - position_diff = np.sqrt( - np.square(np.diff(x)) + np.square(np.diff(y)) + np.square(np.diff(z)) - ) + position_diff = np.sqrt(np.square(np.diff(x)) + np.square(np.diff(y)) + np.square(np.diff(z))) time_diff = np.diff(timestamps) / np.timedelta64(1, "s") speed = position_diff / time_diff speed = np.hstack((speed[0], speed)) @@ -263,9 +249,7 @@ def get_position(cls, in_arena_key): """ assert len(InArena & in_arena_key) == 1 - start, end = (InArena * InArenaEnd & in_arena_key).fetch1( - "in_arena_start", "in_arena_end" - ) + start, end = (InArena * InArenaEnd & in_arena_key).fetch1("in_arena_start", "in_arena_end") return tracking._get_position( cls * InArenaTimeSlice.proj("time_slice_end"), @@ -383,22 +367,16 @@ class FoodPatch(dj.Part): # Work on finished Session with TimeSlice and SubjectPosition fully populated only key_source = ( InArena - & ( - InArena * InArenaEnd * InArenaTimeSlice & "time_slice_end = in_arena_end" - ).proj() + & (InArena * InArenaEnd * InArenaTimeSlice & "time_slice_end = in_arena_end").proj() & ( InArena.aggr(InArenaTimeSlice, time_slice_count="count(time_slice_start)") - * InArena.aggr( - InArenaSubjectPosition, tracking_count="count(time_slice_start)" - ) + * InArena.aggr(InArenaSubjectPosition, tracking_count="count(time_slice_start)") & "time_slice_count = tracking_count" ) ) def make(self, key): - in_arena_start, in_arena_end = (InArena * InArenaEnd & key).fetch1( - "in_arena_start", "in_arena_end" - ) + in_arena_start, in_arena_end = (InArena * InArenaEnd & key).fetch1("in_arena_start", "in_arena_end") # subject's position data in the time_slices position = InArenaSubjectPosition.get_position(key) @@ -437,9 +415,7 @@ def make(self, key): food_patch_keys = ( InArena * InArenaEnd - * acquisition.ExperimentFoodPatch.join( - acquisition.ExperimentFoodPatch.RemovalTime, left=True - ) + * acquisition.ExperimentFoodPatch.join(acquisition.ExperimentFoodPatch.RemovalTime, left=True) & key & "in_arena_start >= food_patch_install_time" & 'in_arena_end < IFNULL(food_patch_remove_time, "2200-01-01")' @@ -448,9 +424,9 @@ def make(self, key): in_food_patch_times = [] for food_patch_key in food_patch_keys: # wheel data - food_patch_description = ( - acquisition.ExperimentFoodPatch & food_patch_key - ).fetch1("food_patch_description") + food_patch_description = (acquisition.ExperimentFoodPatch & food_patch_key).fetch1( + "food_patch_description" + ) wheel_data = acquisition.FoodPatchWheel.get_wheel_data( experiment_name=key["experiment_name"], start=pd.Timestamp(in_arena_start), @@ -459,9 +435,9 @@ def make(self, key): using_aeon_io=True, ) - patch_position = ( - acquisition.ExperimentFoodPatch.Position & food_patch_key - ).fetch1("food_patch_position_x", "food_patch_position_y") + patch_position = (acquisition.ExperimentFoodPatch.Position & food_patch_key).fetch1( + "food_patch_position_x", "food_patch_position_y" + ) in_patch = tracking.is_position_in_patch( position, @@ -517,30 +493,24 @@ class FoodPatch(dj.Part): # Work on finished Session with TimeSlice and SubjectPosition fully populated only key_source = ( InArena - & ( - InArena * InArenaEnd * InArenaTimeSlice & "time_slice_end = in_arena_end" - ).proj() + & (InArena * InArenaEnd * InArenaTimeSlice & "time_slice_end = in_arena_end").proj() & ( InArena.aggr(InArenaTimeSlice, time_slice_count="count(time_slice_start)") - * InArena.aggr( - InArenaSubjectPosition, tracking_count="count(time_slice_start)" - ) + * InArena.aggr(InArenaSubjectPosition, tracking_count="count(time_slice_start)") & "time_slice_count = tracking_count" ) ) def make(self, key): - in_arena_start, in_arena_end = (InArena * InArenaEnd & key).fetch1( - "in_arena_start", "in_arena_end" - ) + in_arena_start, in_arena_end = (InArena * InArenaEnd & key).fetch1("in_arena_start", "in_arena_end") # subject weights - weight_start = ( - acquisition.SubjectWeight.WeightTime & f'weight_time = "{in_arena_start}"' - ).fetch1("weight") - weight_end = ( - acquisition.SubjectWeight.WeightTime & f'weight_time = "{in_arena_end}"' - ).fetch1("weight") + weight_start = (acquisition.SubjectWeight.WeightTime & f'weight_time = "{in_arena_start}"').fetch1( + "weight" + ) + weight_end = (acquisition.SubjectWeight.WeightTime & f'weight_time = "{in_arena_end}"').fetch1( + "weight" + ) # subject's position data in this session position = InArenaSubjectPosition.get_position(key) @@ -551,18 +521,14 @@ def make(self, key): ) # filter for objects of the correct size position = position[valid_position] - position_diff = np.sqrt( - np.square(np.diff(position.x)) + np.square(np.diff(position.y)) - ) + position_diff = np.sqrt(np.square(np.diff(position.x)) + np.square(np.diff(position.y))) total_distance_travelled = np.nansum(position_diff) # food patch data food_patch_keys = ( InArena * InArenaEnd - * acquisition.ExperimentFoodPatch.join( - acquisition.ExperimentFoodPatch.RemovalTime, left=True - ) + * acquisition.ExperimentFoodPatch.join(acquisition.ExperimentFoodPatch.RemovalTime, left=True) & key & "in_arena_start >= food_patch_install_time" & 'in_arena_end < IFNULL(food_patch_remove_time, "2200-01-01")' @@ -577,9 +543,9 @@ def make(self, key): & f'event_time BETWEEN "{in_arena_start}" AND "{in_arena_end}"' ).fetch("event_time") # wheel data - food_patch_description = ( - acquisition.ExperimentFoodPatch & food_patch_key - ).fetch1("food_patch_description") + food_patch_description = (acquisition.ExperimentFoodPatch & food_patch_key).fetch1( + "food_patch_description" + ) wheel_data = acquisition.FoodPatchWheel.get_wheel_data( experiment_name=key["experiment_name"], start=pd.Timestamp(in_arena_start), @@ -593,9 +559,7 @@ def make(self, key): **key, **food_patch_key, "pellet_count": len(pellet_events), - "wheel_distance_travelled": wheel_data.distance_travelled.values[ - -1 - ], + "wheel_distance_travelled": wheel_data.distance_travelled.values[-1], } ) @@ -636,9 +600,7 @@ class FoodPatch(dj.Part): key_source = InArenaSummary() def make(self, key): - in_arena_start, in_arena_end = (InArena * InArenaEnd & key).fetch1( - "in_arena_start", "in_arena_end" - ) + in_arena_start, in_arena_end = (InArena * InArenaEnd & key).fetch1("in_arena_start", "in_arena_end") # food patch data food_patch_keys = ( @@ -673,9 +635,7 @@ def make(self, key): no_pellets = True pellet_rate = analysis_utils.get_events_rates( - events=pd.DataFrame({"event_time": pellet_events}).set_index( - "event_time" - ), + events=pd.DataFrame({"event_time": pellet_events}).set_index("event_time"), window_len_sec=600, start=pd.Timestamp(in_arena_start), end=pd.Timestamp(in_arena_end), @@ -685,18 +645,14 @@ def make(self, key): ) if no_pellets: - pellet_rate = pd.Series( - index=pellet_rate.index, data=np.full(len(pellet_rate), 0) - ) + pellet_rate = pd.Series(index=pellet_rate.index, data=np.full(len(pellet_rate), 0)) if pellet_rate_timestamps is None: pellet_rate_timestamps = pellet_rate.index.values rates[food_patch_key.pop("food_patch_description")] = pellet_rate.values - food_patch_reward_rates.append( - {**key, **food_patch_key, "pellet_rate": pellet_rate.values} - ) + food_patch_reward_rates.append({**key, **food_patch_key, "pellet_rate": pellet_rate.values}) self.insert1( { diff --git a/aeon/dj_pipeline/analysis/visit.py b/aeon/dj_pipeline/analysis/visit.py index 449f71ac..74283d62 100644 --- a/aeon/dj_pipeline/analysis/visit.py +++ b/aeon/dj_pipeline/analysis/visit.py @@ -65,14 +65,14 @@ class Visit(dj.Part): @property def key_source(self): - return dj.U("experiment_name", "place", "overlap_start") & ( - Visit & VisitEnd - ).proj(overlap_start="visit_start") + return dj.U("experiment_name", "place", "overlap_start") & (Visit & VisitEnd).proj( + overlap_start="visit_start" + ) def make(self, key): - visit_starts, visit_ends = ( - Visit * VisitEnd & key & {"visit_start": key["overlap_start"]} - ).fetch("visit_start", "visit_end") + visit_starts, visit_ends = (Visit * VisitEnd & key & {"visit_start": key["overlap_start"]}).fetch( + "visit_start", "visit_end" + ) visit_start = min(visit_starts) visit_end = max(visit_ends) @@ -86,9 +86,7 @@ def make(self, key): if len(overlap_query) <= 1: break overlap_visits.extend( - overlap_query.proj(overlap_start=f'"{key["overlap_start"]}"').fetch( - as_dict=True - ) + overlap_query.proj(overlap_start=f'"{key["overlap_start"]}"').fetch(as_dict=True) ) visit_starts, visit_ends = overlap_query.fetch("visit_start", "visit_end") if visit_start == max(visit_starts) and visit_end == max(visit_ends): @@ -102,10 +100,7 @@ def make(self, key): { **key, "overlap_end": visit_end, - "overlap_duration": ( - visit_end - key["overlap_start"] - ).total_seconds() - / 3600, + "overlap_duration": (visit_end - key["overlap_start"]).total_seconds() / 3600, "subject_count": len({v["subject"] for v in overlap_visits}), } ) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 9bcdd9c8..6610beeb 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -83,8 +83,7 @@ def key_source(self): + chunk starts after visit_start and ends before visit_end (or NOW() - i.e. ongoing visits). """ return ( - Visit.join(VisitEnd, left=True).proj(visit_end="IFNULL(visit_end, NOW())") - * acquisition.Chunk + Visit.join(VisitEnd, left=True).proj(visit_end="IFNULL(visit_end, NOW())") * acquisition.Chunk & acquisition.SubjectEnterExit & [ "visit_start BETWEEN chunk_start AND chunk_end", @@ -95,9 +94,7 @@ def key_source(self): ) def make(self, key): - chunk_start, chunk_end = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end" - ) + chunk_start, chunk_end = (acquisition.Chunk & key).fetch1("chunk_start", "chunk_end") # -- Determine the time to start time_slicing in this chunk if chunk_start < key["visit_start"] < chunk_end: @@ -166,12 +163,8 @@ def make(self, key): end_time = np.array(end_time, dtype="datetime64[ns]") while time_slice_start < end_time: - time_slice_end = time_slice_start + min( - self._time_slice_duration, end_time - time_slice_start - ) - in_time_slice = np.logical_and( - timestamps >= time_slice_start, timestamps < time_slice_end - ) + time_slice_end = time_slice_start + min(self._time_slice_duration, end_time - time_slice_start) + in_time_slice = np.logical_and(timestamps >= time_slice_start, timestamps < time_slice_end) chunk_time_slices.append( { **key, @@ -197,10 +190,7 @@ def get_position(cls, visit_key=None, subject=None, start=None, end=None): if visit_key is not None: assert len(Visit & visit_key) == 1 start, end = ( - Visit.join(VisitEnd, left=True).proj( - visit_end="IFNULL(visit_end, NOW())" - ) - & visit_key + Visit.join(VisitEnd, left=True).proj(visit_end="IFNULL(visit_end, NOW())") & visit_key ).fetch1("visit_start", "visit_end") subject = visit_key["subject"] elif all((subject, start, end)): @@ -261,18 +251,14 @@ class FoodPatch(dj.Part): """ # Work on finished visits - key_source = Visit & ( - VisitEnd * VisitSubjectPosition.TimeSlice & "time_slice_end = visit_end" - ) + key_source = Visit & (VisitEnd * VisitSubjectPosition.TimeSlice & "time_slice_end = visit_end") def make(self, key): visit_start, visit_end = (VisitEnd & key).fetch1("visit_start", "visit_end") visit_dates = pd.date_range( start=pd.Timestamp(visit_start.date()), end=pd.Timestamp(visit_end.date()) ) - maintenance_period = get_maintenance_periods( - key["experiment_name"], visit_start, visit_end - ) + maintenance_period = get_maintenance_periods(key["experiment_name"], visit_start, visit_end) for visit_date in visit_dates: day_start = datetime.datetime.combine(visit_date.date(), time.min) @@ -292,16 +278,12 @@ def make(self, key): subject=key["subject"], start=day_start, end=day_end ) # filter out maintenance period based on logs - position = filter_out_maintenance_periods( - position, maintenance_period, day_end - ) + position = filter_out_maintenance_periods(position, maintenance_period, day_end) # filter for objects of the correct size valid_position = (position.area > 0) & (position.area < 1000) position[~valid_position] = np.nan - position.rename( - columns={"position_x": "x", "position_y": "y"}, inplace=True - ) + position.rename(columns={"position_x": "x", "position_y": "y"}, inplace=True) # in corridor distance_from_center = tracking.compute_distance( position[["x", "y"]], @@ -345,9 +327,9 @@ def make(self, key): in_food_patch_times = [] for food_patch_key in food_patch_keys: # wheel data - food_patch_description = ( - acquisition.ExperimentFoodPatch & food_patch_key - ).fetch1("food_patch_description") + food_patch_description = (acquisition.ExperimentFoodPatch & food_patch_key).fetch1( + "food_patch_description" + ) wheel_data = acquisition.FoodPatchWheel.get_wheel_data( experiment_name=key["experiment_name"], start=pd.Timestamp(day_start), @@ -356,12 +338,10 @@ def make(self, key): using_aeon_io=True, ) # filter out maintenance period based on logs - wheel_data = filter_out_maintenance_periods( - wheel_data, maintenance_period, day_end + wheel_data = filter_out_maintenance_periods(wheel_data, maintenance_period, day_end) + patch_position = (acquisition.ExperimentFoodPatch.Position & food_patch_key).fetch1( + "food_patch_position_x", "food_patch_position_y" ) - patch_position = ( - acquisition.ExperimentFoodPatch.Position & food_patch_key - ).fetch1("food_patch_position_x", "food_patch_position_y") in_patch = tracking.is_position_in_patch( position, patch_position, @@ -416,18 +396,14 @@ class FoodPatch(dj.Part): """ # Work on finished visits - key_source = Visit & ( - VisitEnd * VisitSubjectPosition.TimeSlice & "time_slice_end = visit_end" - ) + key_source = Visit & (VisitEnd * VisitSubjectPosition.TimeSlice & "time_slice_end = visit_end") def make(self, key): visit_start, visit_end = (VisitEnd & key).fetch1("visit_start", "visit_end") visit_dates = pd.date_range( start=pd.Timestamp(visit_start.date()), end=pd.Timestamp(visit_end.date()) ) - maintenance_period = get_maintenance_periods( - key["experiment_name"], visit_start, visit_end - ) + maintenance_period = get_maintenance_periods(key["experiment_name"], visit_start, visit_end) for visit_date in visit_dates: day_start = datetime.datetime.combine(visit_date.date(), time.min) @@ -448,18 +424,12 @@ def make(self, key): subject=key["subject"], start=day_start, end=day_end ) # filter out maintenance period based on logs - position = filter_out_maintenance_periods( - position, maintenance_period, day_end - ) + position = filter_out_maintenance_periods(position, maintenance_period, day_end) # filter for objects of the correct size valid_position = (position.area > 0) & (position.area < 1000) position[~valid_position] = np.nan - position.rename( - columns={"position_x": "x", "position_y": "y"}, inplace=True - ) - position_diff = np.sqrt( - np.square(np.diff(position.x)) + np.square(np.diff(position.y)) - ) + position.rename(columns={"position_x": "x", "position_y": "y"}, inplace=True) + position_diff = np.sqrt(np.square(np.diff(position.x)) + np.square(np.diff(position.y))) total_distance_travelled = np.nansum(position_diff) # in food patches - loop through all in-use patches during this visit @@ -495,9 +465,9 @@ def make(self, key): dropna=True, ).index.values # wheel data - food_patch_description = ( - acquisition.ExperimentFoodPatch & food_patch_key - ).fetch1("food_patch_description") + food_patch_description = (acquisition.ExperimentFoodPatch & food_patch_key).fetch1( + "food_patch_description" + ) wheel_data = acquisition.FoodPatchWheel.get_wheel_data( experiment_name=key["experiment_name"], start=pd.Timestamp(day_start), @@ -506,9 +476,7 @@ def make(self, key): using_aeon_io=True, ) # filter out maintenance period based on logs - wheel_data = filter_out_maintenance_periods( - wheel_data, maintenance_period, day_end - ) + wheel_data = filter_out_maintenance_periods(wheel_data, maintenance_period, day_end) food_patch_statistics.append( { @@ -516,15 +484,11 @@ def make(self, key): **food_patch_key, "visit_date": visit_date.date(), "pellet_count": len(pellet_events), - "wheel_distance_travelled": wheel_data.distance_travelled.values[ - -1 - ], + "wheel_distance_travelled": wheel_data.distance_travelled.values[-1], } ) - total_pellet_count = np.sum( - [p["pellet_count"] for p in food_patch_statistics] - ) + total_pellet_count = np.sum([p["pellet_count"] for p in food_patch_statistics]) total_wheel_distance_travelled = np.sum( [p["wheel_distance_travelled"] for p in food_patch_statistics] ) @@ -558,27 +522,20 @@ class VisitForagingBout(dj.Computed): # Work on 24/7 experiments key_source = ( - Visit - & VisitSummary - & (VisitEnd & "visit_duration > 24") - & "experiment_name= 'exp0.2-r0'" + Visit & VisitSummary & (VisitEnd & "visit_duration > 24") & "experiment_name= 'exp0.2-r0'" ) * acquisition.ExperimentFoodPatch def make(self, key): visit_start, visit_end = (VisitEnd & key).fetch1("visit_start", "visit_end") # get in_patch timestamps - food_patch_description = (acquisition.ExperimentFoodPatch & key).fetch1( - "food_patch_description" - ) + food_patch_description = (acquisition.ExperimentFoodPatch & key).fetch1("food_patch_description") in_patch_times = np.concatenate( - ( - VisitTimeDistribution.FoodPatch * acquisition.ExperimentFoodPatch & key - ).fetch("in_patch", order_by="visit_date") - ) - maintenance_period = get_maintenance_periods( - key["experiment_name"], visit_start, visit_end + (VisitTimeDistribution.FoodPatch * acquisition.ExperimentFoodPatch & key).fetch( + "in_patch", order_by="visit_date" + ) ) + maintenance_period = get_maintenance_periods(key["experiment_name"], visit_start, visit_end) in_patch_times = filter_out_maintenance_periods( pd.DataFrame( [[food_patch_description]] * len(in_patch_times), @@ -606,12 +563,8 @@ def make(self, key): .set_index("event_time") ) # TODO: handle multiple retries of pellet delivery - maintenance_period = get_maintenance_periods( - key["experiment_name"], visit_start, visit_end - ) - patch = filter_out_maintenance_periods( - patch, maintenance_period, visit_end, True - ) + maintenance_period = get_maintenance_periods(key["experiment_name"], visit_start, visit_end) + patch = filter_out_maintenance_periods(patch, maintenance_period, visit_end, True) if len(in_patch_times): change_ind = ( @@ -627,9 +580,7 @@ def make(self, key): ts_array = in_patch_times[change_ind[i - 1] : change_ind[i]] wheel_start, wheel_end = ts_array[0], ts_array[-1] - if ( - wheel_start >= wheel_end - ): # skip if timestamps were misaligned or a single timestamp + if wheel_start >= wheel_end: # skip if timestamps were misaligned or a single timestamp continue wheel_data = acquisition.FoodPatchWheel.get_wheel_data( @@ -639,19 +590,14 @@ def make(self, key): patch_name=food_patch_description, using_aeon_io=True, ) - maintenance_period = get_maintenance_periods( - key["experiment_name"], visit_start, visit_end - ) - wheel_data = filter_out_maintenance_periods( - wheel_data, maintenance_period, visit_end, True - ) + maintenance_period = get_maintenance_periods(key["experiment_name"], visit_start, visit_end) + wheel_data = filter_out_maintenance_periods(wheel_data, maintenance_period, visit_end, True) self.insert1( { **key, "bout_start": ts_array[0], "bout_end": ts_array[-1], - "bout_duration": (ts_array[-1] - ts_array[0]) - / np.timedelta64(1, "s"), + "bout_duration": (ts_array[-1] - ts_array[0]) / np.timedelta64(1, "s"), "wheel_distance_travelled": wheel_data.distance_travelled[-1], "pellet_count": len(patch.loc[wheel_start:wheel_end]), } @@ -690,18 +636,11 @@ def get_maintenance_periods(experiment_name, start, end): log_df = pd.concat([log_df, log_df_end]) log_df.reset_index(drop=True, inplace=True) - start_timestamps = log_df.loc[ - log_df["message"] == "Maintenance", "message_time" - ].values - end_timestamps = log_df.loc[ - log_df["message"] != "Maintenance", "message_time" - ].values + start_timestamps = log_df.loc[log_df["message"] == "Maintenance", "message_time"].values + end_timestamps = log_df.loc[log_df["message"] != "Maintenance", "message_time"].values return deque( - [ - (pd.Timestamp(start), pd.Timestamp(end)) - for start, end in zip(start_timestamps, end_timestamps) - ] + [(pd.Timestamp(start), pd.Timestamp(end)) for start, end in zip(start_timestamps, end_timestamps)] ) # queue object. pop out from left after use @@ -710,9 +649,7 @@ def filter_out_maintenance_periods(data_df, maintenance_period, end_time, dropna (maintenance_start, maintenance_end) = maintenance_period[0] if end_time < maintenance_start: # no more maintenance for this date break - maintenance_filter = (data_df.index >= maintenance_start) & ( - data_df.index <= maintenance_end - ) + maintenance_filter = (data_df.index >= maintenance_start) & (data_df.index <= maintenance_end) data_df[maintenance_filter] = np.nan if end_time >= maintenance_end: # remove this range maintenance_period.popleft() diff --git a/aeon/dj_pipeline/create_experiments/create_experiment_01.py b/aeon/dj_pipeline/create_experiments/create_experiment_01.py index 9189c502..7b87bf11 100644 --- a/aeon/dj_pipeline/create_experiments/create_experiment_01.py +++ b/aeon/dj_pipeline/create_experiments/create_experiment_01.py @@ -32,10 +32,7 @@ def ingest_exp01_metadata(metadata_yml_filepath, experiment_name): & camera_key ) if current_camera_query: # If the same camera is currently installed - if ( - current_camera_query.fetch1("camera_install_time") - == arena_setup["start-time"] - ): + if current_camera_query.fetch1("camera_install_time") == arena_setup["start-time"]: # If it is installed at the same time as that read from this yml file # then it is the same ExperimentCamera instance, no need to do anything continue @@ -55,9 +52,7 @@ def ingest_exp01_metadata(metadata_yml_filepath, experiment_name): "experiment_name": experiment_name, "camera_install_time": arena_setup["start-time"], "camera_description": camera["description"], - "camera_sampling_rate": device_frequency_mapper[ - camera["trigger-source"].lower() - ], + "camera_sampling_rate": device_frequency_mapper[camera["trigger-source"].lower()], } ) acquisition.ExperimentCamera.Position.insert1( @@ -73,23 +68,17 @@ def ingest_exp01_metadata(metadata_yml_filepath, experiment_name): # ---- Load food patches ---- for patch in arena_setup["patches"]: # ---- Check if this is a new food patch, add to lab.FoodPatch if needed - patch_key = { - "food_patch_serial_number": patch["serial-number"] or patch["port-name"] - } + patch_key = {"food_patch_serial_number": patch["serial-number"] or patch["port-name"]} if patch_key not in lab.FoodPatch(): lab.FoodPatch.insert1(patch_key) # ---- Check if this food patch is currently installed - if so, remove it current_patch_query = ( - acquisition.ExperimentFoodPatch - - acquisition.ExperimentFoodPatch.RemovalTime + acquisition.ExperimentFoodPatch - acquisition.ExperimentFoodPatch.RemovalTime & {"experiment_name": experiment_name} & patch_key ) if current_patch_query: # If the same food-patch is currently installed - if ( - current_patch_query.fetch1("food_patch_install_time") - == arena_setup["start-time"] - ): + if current_patch_query.fetch1("food_patch_install_time") == arena_setup["start-time"]: # If it is installed at the same time as that read from this yml file # then it is the same ExperimentFoodPatch instance, no need to do anything continue @@ -124,21 +113,16 @@ def ingest_exp01_metadata(metadata_yml_filepath, experiment_name): ) # ---- Load weight scales ---- for weight_scale in arena_setup["weight-scales"]: - weight_scale_key = { - "weight_scale_serial_number": weight_scale["serial-number"] - } + weight_scale_key = {"weight_scale_serial_number": weight_scale["serial-number"]} if weight_scale_key not in lab.WeightScale(): lab.WeightScale.insert1(weight_scale_key) # ---- Check if this weight scale is currently installed - if so, remove it current_weight_scale_query = ( - acquisition.ExperimentWeightScale - - acquisition.ExperimentWeightScale.RemovalTime + acquisition.ExperimentWeightScale - acquisition.ExperimentWeightScale.RemovalTime & {"experiment_name": experiment_name} & weight_scale_key ) - if ( - current_weight_scale_query - ): # If the same weight scale is currently installed + if current_weight_scale_query: # If the same weight scale is currently installed if ( current_weight_scale_query.fetch1("weight_scale_install_time") == arena_setup["start-time"] @@ -266,12 +250,8 @@ def add_arena_setup(): # manually update coordinates of foodpatch and nest patch_coordinates = {"Patch1": (1.13, 1.59, 0), "Patch2": (1.19, 0.50, 0)} - for patch_key in ( - acquisition.ExperimentFoodPatch & {"experiment_name": experiment_name} - ).fetch("KEY"): - patch = (acquisition.ExperimentFoodPatch & patch_key).fetch1( - "food_patch_description" - ) + for patch_key in (acquisition.ExperimentFoodPatch & {"experiment_name": experiment_name}).fetch("KEY"): + patch = (acquisition.ExperimentFoodPatch & patch_key).fetch1("food_patch_description") x, y, z = patch_coordinates[patch] acquisition.ExperimentFoodPatch.Position.update1( { diff --git a/aeon/dj_pipeline/create_experiments/create_experiment_02.py b/aeon/dj_pipeline/create_experiments/create_experiment_02.py index 6966d204..82a8f03f 100644 --- a/aeon/dj_pipeline/create_experiments/create_experiment_02.py +++ b/aeon/dj_pipeline/create_experiments/create_experiment_02.py @@ -30,10 +30,7 @@ def create_new_experiment(): skip_duplicates=True, ) acquisition.Experiment.Subject.insert( - [ - {"experiment_name": experiment_name, "subject": s["subject"]} - for s in subject_list - ], + [{"experiment_name": experiment_name, "subject": s["subject"]} for s in subject_list], skip_duplicates=True, ) diff --git a/aeon/dj_pipeline/create_experiments/create_octagon_1.py b/aeon/dj_pipeline/create_experiments/create_octagon_1.py index 7b09c8a5..4b077e65 100644 --- a/aeon/dj_pipeline/create_experiments/create_octagon_1.py +++ b/aeon/dj_pipeline/create_experiments/create_octagon_1.py @@ -33,10 +33,7 @@ def create_new_experiment(): skip_duplicates=True, ) acquisition.Experiment.Subject.insert( - [ - {"experiment_name": experiment_name, "subject": s["subject"]} - for s in subject_list - ], + [{"experiment_name": experiment_name, "subject": s["subject"]} for s in subject_list], skip_duplicates=True, ) diff --git a/aeon/dj_pipeline/create_experiments/create_presocial.py b/aeon/dj_pipeline/create_experiments/create_presocial.py index 038d5927..05dc0dc8 100644 --- a/aeon/dj_pipeline/create_experiments/create_presocial.py +++ b/aeon/dj_pipeline/create_experiments/create_presocial.py @@ -5,24 +5,25 @@ location = "4th floor" computers = ["AEON2", "AEON3", "AEON4"] -def create_new_experiment(): +def create_new_experiment(): lab.Location.insert1({"lab": "SWC", "location": location}, skip_duplicates=True) - acquisition.ExperimentType.insert1( - {"experiment_type": experiment_type}, skip_duplicates=True - ) + acquisition.ExperimentType.insert1({"experiment_type": experiment_type}, skip_duplicates=True) acquisition.Experiment.insert( - [{ - "experiment_name": experiment_name, - "experiment_start_time": "2023-02-25 00:00:00", - "experiment_description": "presocial experiment 0.1", - "arena_name": "circle-2m", - "lab": "SWC", - "location": location, - "experiment_type": experiment_type - } for experiment_name in experiment_names], + [ + { + "experiment_name": experiment_name, + "experiment_start_time": "2023-02-25 00:00:00", + "experiment_description": "presocial experiment 0.1", + "arena_name": "circle-2m", + "lab": "SWC", + "location": location, + "experiment_type": experiment_type, + } + for experiment_name in experiment_names + ], skip_duplicates=True, ) @@ -41,8 +42,9 @@ def create_new_experiment(): "experiment_name": experiment_name, "repository_name": "ceph_aeon", "directory_type": "raw", - "directory_path": f"aeon/data/raw/{computer}/{experiment_type}" - } for experiment_name, computer in zip(experiment_names, computers) + "directory_path": f"aeon/data/raw/{computer}/{experiment_type}", + } + for experiment_name, computer in zip(experiment_names, computers) ], skip_duplicates=True, ) diff --git a/aeon/dj_pipeline/create_experiments/create_socialexperiment_0.py b/aeon/dj_pipeline/create_experiments/create_socialexperiment_0.py index 7e55bac5..f3d7aa9a 100644 --- a/aeon/dj_pipeline/create_experiments/create_socialexperiment_0.py +++ b/aeon/dj_pipeline/create_experiments/create_socialexperiment_0.py @@ -35,10 +35,7 @@ def create_new_experiment(): skip_duplicates=True, ) acquisition.Experiment.Subject.insert( - [ - {"experiment_name": experiment_name, "subject": s["subject"]} - for s in subject_list - ], + [{"experiment_name": experiment_name, "subject": s["subject"]} for s in subject_list], skip_duplicates=True, ) @@ -93,12 +90,8 @@ def add_arena_setup(): # manually update coordinates of foodpatch and nest patch_coordinates = {"Patch1": (1.13, 1.59, 0), "Patch2": (1.19, 0.50, 0)} - for patch_key in ( - acquisition.ExperimentFoodPatch & {"experiment_name": experiment_name} - ).fetch("KEY"): - patch = (acquisition.ExperimentFoodPatch & patch_key).fetch1( - "food_patch_description" - ) + for patch_key in (acquisition.ExperimentFoodPatch & {"experiment_name": experiment_name}).fetch("KEY"): + patch = (acquisition.ExperimentFoodPatch & patch_key).fetch1("food_patch_description") x, y, z = patch_coordinates[patch] acquisition.ExperimentFoodPatch.Position.update1( { @@ -157,11 +150,15 @@ def fixID(subjid, valid_ids=None, valid_id_file=None): # The subjid is a combo subjid. if ";" in subjid: subjidA, subjidB = subjid.split(";") - return f"{fixID(subjidA.strip(), valid_ids=valid_ids)};{fixID(subjidB.strip(), valid_ids=valid_ids)}" + return ( + f"{fixID(subjidA.strip(), valid_ids=valid_ids)};{fixID(subjidB.strip(), valid_ids=valid_ids)}" + ) if "vs" in subjid: subjidA, tmp, subjidB = subjid.split(" ")[1:] - return f"{fixID(subjidA.strip(), valid_ids=valid_ids)};{fixID(subjidB.strip(), valid_ids=valid_ids)}" + return ( + f"{fixID(subjidA.strip(), valid_ids=valid_ids)};{fixID(subjidB.strip(), valid_ids=valid_ids)}" + ) try: ld = [jl.levenshtein_distance(subjid, x[-len(subjid) :]) for x in valid_ids] diff --git a/aeon/dj_pipeline/populate/process.py b/aeon/dj_pipeline/populate/process.py index d9931ef1..55e84506 100644 --- a/aeon/dj_pipeline/populate/process.py +++ b/aeon/dj_pipeline/populate/process.py @@ -79,9 +79,7 @@ def run(**kwargs): try: worker.run() except Exception: - logger.exception( - "action '{}' encountered an exception:".format(kwargs["worker_name"]) - ) + logger.exception("action '{}' encountered an exception:".format(kwargs["worker_name"])) logger.info("Ingestion process ended.") diff --git a/aeon/dj_pipeline/qc.py b/aeon/dj_pipeline/qc.py index 8b877959..d35019d0 100644 --- a/aeon/dj_pipeline/qc.py +++ b/aeon/dj_pipeline/qc.py @@ -56,9 +56,7 @@ class CameraQC(dj.Imported): def key_source(self): return ( acquisition.Chunk - * acquisition.ExperimentCamera.join( - acquisition.ExperimentCamera.RemovalTime, left=True - ) + * acquisition.ExperimentCamera.join(acquisition.ExperimentCamera.RemovalTime, left=True) & "chunk_start >= camera_install_time" & 'chunk_start < IFNULL(camera_remove_time, "2200-01-01")' ) @@ -68,13 +66,9 @@ def make(self, key): "chunk_start", "chunk_end", "directory_type" ) camera = (acquisition.ExperimentCamera & key).fetch1("camera_description") - raw_data_dir = acquisition.Experiment.get_data_directory( - key, directory_type=dir_type - ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - device = getattr( - acquisition._device_schema_mapping[key["experiment_name"]], camera - ) + device = getattr(acquisition._device_schema_mapping[key["experiment_name"]], camera) videodata = io_api.load( root=raw_data_dir.as_posix(), @@ -99,11 +93,9 @@ def make(self, key): **key, "drop_count": deltas.frame_offset.iloc[-1], "max_harp_delta": deltas.time_delta.max().total_seconds(), - "max_camera_delta": deltas.hw_timestamp_delta.max() - / 1e9, # convert to seconds + "max_camera_delta": deltas.hw_timestamp_delta.max() / 1e9, # convert to seconds "timestamps": videodata.index.values, - "time_delta": deltas.time_delta.values - / np.timedelta64(1, "s"), # convert to seconds + "time_delta": deltas.time_delta.values / np.timedelta64(1, "s"), # convert to seconds "frame_delta": deltas.frame_delta.values, "hw_counter_delta": deltas.hw_counter_delta.values, "hw_timestamp_delta": deltas.hw_timestamp_delta.values, diff --git a/aeon/dj_pipeline/report.py b/aeon/dj_pipeline/report.py index ebde623a..048070ec 100644 --- a/aeon/dj_pipeline/report.py +++ b/aeon/dj_pipeline/report.py @@ -32,9 +32,7 @@ class InArenaSummaryPlot(dj.Computed): summary_plot_png: attach """ - key_source = ( - analysis.InArena & analysis.InArenaTimeDistribution & analysis.InArenaSummary - ) + key_source = analysis.InArena & analysis.InArenaTimeDistribution & analysis.InArenaSummary color_code = { "Patch1": "b", @@ -45,17 +43,15 @@ class InArenaSummaryPlot(dj.Computed): } def make(self, key): - in_arena_start, in_arena_end = ( - analysis.InArena * analysis.InArenaEnd & key - ).fetch1("in_arena_start", "in_arena_end") + in_arena_start, in_arena_end = (analysis.InArena * analysis.InArenaEnd & key).fetch1( + "in_arena_start", "in_arena_end" + ) # subject's position data in the time_slices position = analysis.InArenaSubjectPosition.get_position(key) position.rename(columns={"position_x": "x", "position_y": "y"}, inplace=True) - position_minutes_elapsed = ( - position.index - in_arena_start - ).total_seconds() / 60 + position_minutes_elapsed = (position.index - in_arena_start).total_seconds() / 60 # figure fig = plt.figure(figsize=(20, 9)) @@ -70,16 +66,12 @@ def make(self, key): # position plot non_nan = np.logical_and(~np.isnan(position.x), ~np.isnan(position.y)) - analysis_plotting.heatmap( - position[non_nan], 50, ax=position_ax, bins=500, alpha=0.5 - ) + analysis_plotting.heatmap(position[non_nan], 50, ax=position_ax, bins=500, alpha=0.5) # event rate plots in_arena_food_patches = ( analysis.InArena - * acquisition.ExperimentFoodPatch.join( - acquisition.ExperimentFoodPatch.RemovalTime, left=True - ) + * acquisition.ExperimentFoodPatch.join(acquisition.ExperimentFoodPatch.RemovalTime, left=True) & key & "in_arena_start >= food_patch_install_time" & 'in_arena_start < IFNULL(food_patch_remove_time, "2200-01-01")' @@ -146,9 +138,7 @@ def make(self, key): color=self.color_code[food_patch_key["food_patch_description"]], alpha=0.3, ) - threshold_change_ind = np.where( - wheel_threshold[:-1] != wheel_threshold[1:] - )[0] + threshold_change_ind = np.where(wheel_threshold[:-1] != wheel_threshold[1:])[0] threshold_ax.vlines( wheel_time[threshold_change_ind + 1], ymin=wheel_threshold[threshold_change_ind], @@ -160,20 +150,17 @@ def make(self, key): ) # ethogram - in_arena, in_corridor, arena_time, corridor_time = ( - analysis.InArenaTimeDistribution & key - ).fetch1( + in_arena, in_corridor, arena_time, corridor_time = (analysis.InArenaTimeDistribution & key).fetch1( "in_arena", "in_corridor", "time_fraction_in_arena", "time_fraction_in_corridor", ) - nest_keys, in_nests, nests_times = ( - analysis.InArenaTimeDistribution.Nest & key - ).fetch("KEY", "in_nest", "time_fraction_in_nest") + nest_keys, in_nests, nests_times = (analysis.InArenaTimeDistribution.Nest & key).fetch( + "KEY", "in_nest", "time_fraction_in_nest" + ) patch_names, in_patches, patches_times = ( - analysis.InArenaTimeDistribution.FoodPatch * acquisition.ExperimentFoodPatch - & key + analysis.InArenaTimeDistribution.FoodPatch * acquisition.ExperimentFoodPatch & key ).fetch("food_patch_description", "in_patch", "time_fraction_in_patch") ethogram_ax.plot( @@ -204,9 +191,7 @@ def make(self, key): alpha=0.6, label="nest", ) - for patch_idx, (patch_name, in_patch) in enumerate( - zip(patch_names, in_patches) - ): + for patch_idx, (patch_name, in_patch) in enumerate(zip(patch_names, in_patches)): ethogram_ax.plot( position_minutes_elapsed[in_patch], np.full_like(position_minutes_elapsed[in_patch], (patch_idx + 3)), @@ -247,9 +232,7 @@ def make(self, key): rate_ax.set_title("foraging rate (bin size = 10 min)") distance_ax.set_ylabel("distance travelled (m)") threshold_ax.set_ylabel("threshold") - threshold_ax.set_ylim( - [threshold_ax.get_ylim()[0] - 100, threshold_ax.get_ylim()[1] + 100] - ) + threshold_ax.set_ylim([threshold_ax.get_ylim()[0] - 100, threshold_ax.get_ylim()[1] + 100]) ethogram_ax.set_xlabel("time (min)") analysis_plotting.set_ymargin(distance_ax, 0.2, 0.1) for ax in (rate_ax, distance_ax, pellet_ax, time_dist_ax, threshold_ax): @@ -278,9 +261,7 @@ def make(self, key): # ---- Save fig and insert ---- save_dir = _make_path(key) - fig_dict = _save_figs( - (fig,), ("summary_plot_png",), save_dir=save_dir, prefix=save_dir.name - ) + fig_dict = _save_figs((fig,), ("summary_plot_png",), save_dir=save_dir, prefix=save_dir.name) self.insert1({**key, **fig_dict}) @@ -457,10 +438,7 @@ class VisitDailySummaryPlot(dj.Computed): """ key_source = ( - Visit - & analysis.VisitSummary - & (VisitEnd & "visit_duration > 24") - & "experiment_name= 'exp0.2-r0'" + Visit & analysis.VisitSummary & (VisitEnd & "visit_duration > 24") & "experiment_name= 'exp0.2-r0'" ) def make(self, key): @@ -567,12 +545,7 @@ def _make_path(in_arena_key): experiment_name, subject, in_arena_start = (analysis.InArena & in_arena_key).fetch1( "experiment_name", "subject", "in_arena_start" ) - output_dir = ( - store_stage - / experiment_name - / subject - / in_arena_start.strftime("%y%m%d_%H%M%S_%f") - ) + output_dir = store_stage / experiment_name / subject / in_arena_start.strftime("%y%m%d_%H%M%S_%f") output_dir.mkdir(parents=True, exist_ok=True) return output_dir diff --git a/aeon/dj_pipeline/scripts/clone_and_freeze_exp01.py b/aeon/dj_pipeline/scripts/clone_and_freeze_exp01.py index 426e4159..91e9e449 100644 --- a/aeon/dj_pipeline/scripts/clone_and_freeze_exp01.py +++ b/aeon/dj_pipeline/scripts/clone_and_freeze_exp01.py @@ -7,21 +7,17 @@ from datajoint_utilities.dj_data_copy import db_migration from datajoint_utilities.dj_data_copy.pipeline_cloning import ClonedPipeline -os.environ['DJ_SUPPORT_FILEPATH_MANAGEMENT'] = "TRUE" +os.environ["DJ_SUPPORT_FILEPATH_MANAGEMENT"] = "TRUE" -source_db_prefix = 'aeon_' -target_db_prefix = 'aeon_archived_exp01_' +source_db_prefix = "aeon_" +target_db_prefix = "aeon_archived_exp01_" -schema_name_mapper = {source_db_prefix + schema_name: target_db_prefix + schema_name - for schema_name in ('lab', - 'subject', - 'acquisition', - 'tracking', - 'qc', - 'report', - 'analysis')} +schema_name_mapper = { + source_db_prefix + schema_name: target_db_prefix + schema_name + for schema_name in ("lab", "subject", "acquisition", "tracking", "qc", "report", "analysis") +} -restriction = {'experiment_name': 'exp0.1-r0'} +restriction = {"experiment_name": "exp0.1-r0"} table_block_list = {} @@ -46,13 +42,16 @@ def data_copy(restriction, table_block_list, batch_size=None): orig_schema = dj.create_virtual_module(orig_schema_name, orig_schema_name) cloned_schema = dj.create_virtual_module(cloned_schema_name, cloned_schema_name) - db_migration.migrate_schema(orig_schema, cloned_schema, - restriction=restriction, - table_block_list=table_block_list.get(cloned_schema_name, []), - allow_missing_destination_tables=True, - force_fetch=False, - batch_size=batch_size) + db_migration.migrate_schema( + orig_schema, + cloned_schema, + restriction=restriction, + table_block_list=table_block_list.get(cloned_schema_name, []), + allow_missing_destination_tables=True, + force_fetch=False, + batch_size=batch_size, + ) -if __name__ == '__main__': - print('This is not meant to be run as a script (yet)') +if __name__ == "__main__": + print("This is not meant to be run as a script (yet)") diff --git a/aeon/dj_pipeline/scripts/update_timestamps_longblob.py b/aeon/dj_pipeline/scripts/update_timestamps_longblob.py index b8f4b3b0..3980e6e8 100644 --- a/aeon/dj_pipeline/scripts/update_timestamps_longblob.py +++ b/aeon/dj_pipeline/scripts/update_timestamps_longblob.py @@ -34,13 +34,7 @@ def main(): for schema_name in schema_names: vm = dj.create_virtual_module(schema_name, schema_name) table_names = [ - ".".join( - [ - dj.utils.to_camel_case(s) - for s in tbl_name.strip("`").split("__") - if s - ] - ) + ".".join([dj.utils.to_camel_case(s) for s in tbl_name.strip("`").split("__") if s]) for tbl_name in vm.schema.list_tables() ] for table_name in table_names: diff --git a/aeon/dj_pipeline/subject.py b/aeon/dj_pipeline/subject.py index 42104a0f..74d61758 100644 --- a/aeon/dj_pipeline/subject.py +++ b/aeon/dj_pipeline/subject.py @@ -2,7 +2,7 @@ from . import get_schema_name -schema = dj.schema(get_schema_name('subject')) +schema = dj.schema(get_schema_name("subject")) @schema diff --git a/aeon/dj_pipeline/tracking.py b/aeon/dj_pipeline/tracking.py index 572b23c8..e5a884b7 100644 --- a/aeon/dj_pipeline/tracking.py +++ b/aeon/dj_pipeline/tracking.py @@ -5,8 +5,7 @@ import numpy as np import pandas as pd -from aeon.dj_pipeline import (acquisition, dict_to_uuid, get_schema_name, lab, - qc, streams) +from aeon.dj_pipeline import acquisition, dict_to_uuid, get_schema_name, lab, qc, streams from aeon.io import api as io_api from . import acquisition, dict_to_uuid, get_schema_name, lab, qc @@ -74,18 +73,14 @@ def insert_new_params( tracking_paramset_id: int = None, ): if tracking_paramset_id is None: - tracking_paramset_id = ( - dj.U().aggr(cls, n="max(tracking_paramset_id)").fetch1("n") or 0 - ) + 1 + tracking_paramset_id = (dj.U().aggr(cls, n="max(tracking_paramset_id)").fetch1("n") or 0) + 1 param_dict = { "tracking_method": tracking_method, "tracking_paramset_id": tracking_paramset_id, "paramset_description": paramset_description, "params": params, - "param_set_hash": dict_to_uuid( - {**params, "tracking_method": tracking_method} - ), + "param_set_hash": dict_to_uuid({**params, "tracking_method": tracking_method}), } param_query = cls & {"param_set_hash": param_dict["param_set_hash"]} @@ -149,13 +144,9 @@ def make(self, key): ) camera = (acquisition.ExperimentCamera & key).fetch1("camera_description") - raw_data_dir = acquisition.Experiment.get_data_directory( - key, directory_type=dir_type - ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - device = getattr( - acquisition._device_schema_mapping[key["experiment_name"]], camera - ) + device = getattr(acquisition._device_schema_mapping[key["experiment_name"]], camera) positiondata = io_api.load( root=raw_data_dir.as_posix(), @@ -180,9 +171,7 @@ def make(self, key): # Correct for frame offsets from Camera QC qc_time_offsets = qc_frame_offsets / camera_fs - qc_time_offsets = np.where( - np.isnan(qc_time_offsets), 0, qc_time_offsets - ) # set NaNs to 0 + qc_time_offsets = np.where(np.isnan(qc_time_offsets), 0, qc_time_offsets) # set NaNs to 0 positiondata.index += pd.to_timedelta(qc_time_offsets, "s") object_positions = [] @@ -278,30 +267,32 @@ class PointCollection(dj.Part): @property def key_source(self): - return (acquisition.Chunk & "experiment_name='multianimal'" ) * (streams.VideoSourcePosition & (streams.VideoSource & "video_source_name='CameraTop'")) * (TrackingParamSet & "tracking_paramset_id = 1") # SLEAP & CameraTop + return ( + (acquisition.Chunk & "experiment_name='multianimal'") + * (streams.VideoSourcePosition & (streams.VideoSource & "video_source_name='CameraTop'")) + * (TrackingParamSet & "tracking_paramset_id = 1") + ) # SLEAP & CameraTop def make(self, key): - from aeon.schema.social import Pose chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( "chunk_start", "chunk_end", "directory_type" ) - raw_data_dir = acquisition.Experiment.get_data_directory( - key, directory_type=dir_type - ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) # This needs to be modified later - sleap_reader = Pose(pattern="", columns=["class", "class_confidence", "centroid_x", "centroid_y", "centroid_confidence"]) + sleap_reader = Pose( + pattern="", + columns=["class", "class_confidence", "centroid_x", "centroid_y", "centroid_confidence"], + ) tracking_file_path = "/ceph/aeon/aeon/data/processed/test-node1/1234567/2023-08-10T18-31-00/macentroid/test-node1_1234567_2023-08-10T18-31-00_macentroid.bin" # temp file path for testing tracking_df = sleap_reader.read(Path(tracking_file_path)) pose_list = [] for part_name in ["body"]: - for class_id in tracking_df["class"].unique(): - class_df = tracking_df[tracking_df["class"] == class_id] pose_list.append( @@ -347,9 +338,7 @@ def is_position_in_nest(position_df, nest_key, xcol="x", ycol="y") -> pd.Series: """Given the session key and the position data - arrays of x and y return an array of boolean indicating whether or not a position is inside the nest. """ - nest_vertices = list( - zip(*(lab.ArenaNest.Vertex & nest_key).fetch("vertex_x", "vertex_y")) - ) + nest_vertices = list(zip(*(lab.ArenaNest.Vertex & nest_key).fetch("vertex_x", "vertex_y"))) nest_path = matplotlib.path.Path(nest_vertices) position_df["in_nest"] = nest_path.contains_points(position_df[[xcol, ycol]]) return position_df["in_nest"] @@ -375,9 +364,7 @@ def _get_position( start_query = table & obj_restriction & start_restriction end_query = table & obj_restriction & end_restriction if not (start_query and end_query): - raise ValueError( - f"No position data found for {object_name} between {start} and {end}" - ) + raise ValueError(f"No position data found for {object_name} between {start} and {end}") time_restriction = ( f'{start_attr} >= "{min(start_query.fetch(start_attr))}"' @@ -385,14 +372,10 @@ def _get_position( ) # subject's position data in the time slice - fetched_data = (table & obj_restriction & time_restriction).fetch( - *fetch_attrs, order_by=start_attr - ) + fetched_data = (table & obj_restriction & time_restriction).fetch(*fetch_attrs, order_by=start_attr) if not len(fetched_data[0]): - raise ValueError( - f"No position data found for {object_name} between {start} and {end}" - ) + raise ValueError(f"No position data found for {object_name} between {start} and {end}") timestamp_attr = next(attr for attr in fetch_attrs if "timestamps" in attr) diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 6ac7a25a..9daf51fe 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -40,7 +40,6 @@ def insert_stream_types(): schemas = [v for v in dataset.__dict__.values() if isinstance(v, DotMap)] for schema in schemas: - stream_entries = get_stream_entries(schema) for entry in stream_entries: @@ -50,9 +49,7 @@ def insert_stream_types(): if pname != entry["stream_type"]: # If the existed stream type does not have the same name: # human error, trying to add the same content with different name - raise dj.DataJointError( - f"The specified stream type already exists - name: {pname}" - ) + raise dj.DataJointError(f"The specified stream type already exists - name: {pname}") streams.StreamType.insert(stream_entries, skip_duplicates=True) @@ -99,8 +96,7 @@ def insert_device_types(schema: DotMap, metadata_yml_filepath: Path): {"device_type": device_type, "stream_type": stream_type} for device_type, stream_list in device_stream_map.items() for stream_type in stream_list - if not streams.DeviceType.Stream - & {"device_type": device_type, "stream_type": stream_type} + if not streams.DeviceType.Stream & {"device_type": device_type, "stream_type": stream_type} ] new_devices = [ @@ -109,8 +105,7 @@ def insert_device_types(schema: DotMap, metadata_yml_filepath: Path): "device_type": device_config["device_type"], } for device_name, device_config in device_info.items() - if device_sn[device_name] - and not streams.Device & {"device_serial_number": device_sn[device_name]} + if device_sn[device_name] and not streams.Device & {"device_serial_number": device_sn[device_name]} ] # Insert new entries. @@ -139,9 +134,7 @@ def extract_epoch_config(experiment_name: str, metadata_yml_filepath: str) -> di dict: epoch_config [dict] """ metadata_yml_filepath = pathlib.Path(metadata_yml_filepath) - epoch_start = datetime.datetime.strptime( - metadata_yml_filepath.parent.name, "%Y-%m-%dT%H-%M-%S" - ) + epoch_start = datetime.datetime.strptime(metadata_yml_filepath.parent.name, "%Y-%m-%dT%H-%M-%S") epoch_config: dict = ( io_api.load( str(metadata_yml_filepath.parent), @@ -158,17 +151,11 @@ def extract_epoch_config(experiment_name: str, metadata_yml_filepath: str) -> di assert commit, f'Neither "Commit" nor "Revision" found in {metadata_yml_filepath}' devices: list[dict] = json.loads( - json.dumps( - epoch_config["metadata"]["Devices"], default=lambda x: x.__dict__, indent=4 - ) + json.dumps(epoch_config["metadata"]["Devices"], default=lambda x: x.__dict__, indent=4) ) - if isinstance( - devices, list - ): # In exp02, it is a list of dict. In presocial. It's a dict of dict. - devices: dict = { - d.pop("Name"): d for d in devices - } # {deivce_name: device_config} + if isinstance(devices, list): # In exp02, it is a list of dict. In presocial. It's a dict of dict. + devices: dict = {d.pop("Name"): d for d in devices} # {deivce_name: device_config} return { "experiment_name": experiment_name, @@ -217,7 +204,6 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): for device_name, device_config in epoch_config["metadata"].items(): if table := getattr(streams, device_type_mapper.get(device_name) or "", None): - device_sn = device_config.get("SerialNumber", device_config.get("PortName")) device_key = {"device_serial_number": device_sn} @@ -225,9 +211,7 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): table_entry = { "experiment_name": experiment_name, **device_key, - f"{dj.utils.from_camel_case(table.__name__)}_install_time": epoch_config[ - "epoch_start" - ], + f"{dj.utils.from_camel_case(table.__name__)}_install_time": epoch_config["epoch_start"], f"{dj.utils.from_camel_case(table.__name__)}_name": device_name, } @@ -241,14 +225,10 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): ] """Check if this camera is currently installed. If the same camera serial number is currently installed check for any changes in configuration. If not, skip this""" - current_device_query = ( - table - table.RemovalTime & experiment_key & device_key - ) + current_device_query = table - table.RemovalTime & experiment_key & device_key if current_device_query: - current_device_config: list[dict] = ( - table.Attribute & current_device_query - ).fetch( + current_device_config: list[dict] = (table.Attribute & current_device_query).fetch( "experiment_name", "device_serial_number", "attribute_name", @@ -256,11 +236,7 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): as_dict=True, ) new_device_config: list[dict] = [ - { - k: v - for k, v in entry.items() - if dj.utils.from_camel_case(table.__name__) not in k - } + {k: v for k, v in entry.items() if dj.utils.from_camel_case(table.__name__) not in k} for entry in table_attribute_entry ] @@ -270,10 +246,7 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): for config in current_device_config } ) == dict_to_uuid( - { - config["attribute_name"]: config["attribute_value"] - for config in new_device_config - } + {config["attribute_name"]: config["attribute_value"] for config in new_device_config} ): # Skip if none of the configuration has changed. continue @@ -339,6 +312,7 @@ def get_stream_entries(schema: DotMap) -> list[dict]: stream_info["stream_reader"], stream_info["stream_reader_kwargs"], stream_info["stream_hash"], + strict=True, ) ] @@ -385,14 +359,10 @@ def _get_class_path(obj): "aeon.schema.octagon", ]: device_info[device_name]["stream_type"].append(stream_type) - device_info[device_name]["stream_reader"].append( - _get_class_path(stream_obj) - ) + device_info[device_name]["stream_reader"].append(_get_class_path(stream_obj)) required_args = [ - k - for k in inspect.signature(stream_obj.__init__).parameters - if k != "self" + k for k in inspect.signature(stream_obj.__init__).parameters if k != "self" ] pattern = schema_dict[device_name][stream_type].get("pattern") schema_dict[device_name][stream_type]["pattern"] = pattern.replace( @@ -400,35 +370,23 @@ def _get_class_path(obj): ) kwargs = { - k: v - for k, v in schema_dict[device_name][stream_type].items() - if k in required_args + k: v for k, v in schema_dict[device_name][stream_type].items() if k in required_args } device_info[device_name]["stream_reader_kwargs"].append(kwargs) # Add hash device_info[device_name]["stream_hash"].append( - dict_to_uuid( - {**kwargs, "stream_reader": _get_class_path(stream_obj)} - ) + dict_to_uuid({**kwargs, "stream_reader": _get_class_path(stream_obj)}) ) else: stream_type = device.__class__.__name__ device_info[device_name]["stream_type"].append(stream_type) device_info[device_name]["stream_reader"].append(_get_class_path(device)) - required_args = { - k: None - for k in inspect.signature(device.__init__).parameters - if k != "self" - } + required_args = {k: None for k in inspect.signature(device.__init__).parameters if k != "self"} pattern = schema_dict[device_name].get("pattern") - schema_dict[device_name]["pattern"] = pattern.replace( - device_name, "{pattern}" - ) + schema_dict[device_name]["pattern"] = pattern.replace(device_name, "{pattern}") - kwargs = { - k: v for k, v in schema_dict[device_name].items() if k in required_args - } + kwargs = {k: v for k, v in schema_dict[device_name].items() if k in required_args} device_info[device_name]["stream_reader_kwargs"].append(kwargs) # Add hash device_info[device_name]["stream_hash"].append( @@ -465,12 +423,8 @@ def get_device_mapper(schema: DotMap, metadata_yml_filepath: Path): ) # Store the mapper dictionary here - repository_root = ( - os.popen("git rev-parse --show-toplevel").read().strip() - ) # repo root path - filename = Path( - repository_root + "/aeon/dj_pipeline/create_experiments/device_type_mapper.json" - ) + repository_root = os.popen("git rev-parse --show-toplevel").read().strip() # repo root path + filename = Path(repository_root + "/aeon/dj_pipeline/create_experiments/device_type_mapper.json") device_type_mapper = {} # {device_name: device_type} device_sn = {} # {device_name: device_sn} @@ -525,9 +479,7 @@ def ingest_epoch_metadata_octagon(experiment_name, metadata_yml_filepath): ("Wall8", "Wall"), ] - epoch_start = datetime.datetime.strptime( - metadata_yml_filepath.parent.name, "%Y-%m-%dT%H-%M-%S" - ) + epoch_start = datetime.datetime.strptime(metadata_yml_filepath.parent.name, "%Y-%m-%dT%H-%M-%S") for device_idx, (device_name, device_type) in enumerate(oct01_devices): device_sn = f"oct01_{device_idx}" @@ -536,13 +488,8 @@ def ingest_epoch_metadata_octagon(experiment_name, metadata_yml_filepath): skip_duplicates=True, ) experiment_table = getattr(streams, f"Experiment{device_type}") - if not ( - experiment_table - & {"experiment_name": experiment_name, "device_serial_number": device_sn} - ): - experiment_table.insert1( - (experiment_name, device_sn, epoch_start, device_name) - ) + if not (experiment_table & {"experiment_name": experiment_name, "device_serial_number": device_sn}): + experiment_table.insert1((experiment_name, device_sn, epoch_start, device_name)) # endregion diff --git a/aeon/dj_pipeline/utils/paths.py b/aeon/dj_pipeline/utils/paths.py index 0eb90d89..d1583595 100644 --- a/aeon/dj_pipeline/utils/paths.py +++ b/aeon/dj_pipeline/utils/paths.py @@ -44,6 +44,5 @@ def find_root_directory(root_directories, full_path): except StopIteration: raise FileNotFoundError( - f"No valid root directory found (from {root_directories})" - f" for {full_path}" + f"No valid root directory found (from {root_directories})" f" for {full_path}" ) diff --git a/aeon/dj_pipeline/utils/plotting.py b/aeon/dj_pipeline/utils/plotting.py index 3d2e3035..353b4e36 100644 --- a/aeon/dj_pipeline/utils/plotting.py +++ b/aeon/dj_pipeline/utils/plotting.py @@ -9,8 +9,12 @@ from aeon.dj_pipeline import acquisition, analysis, lab from aeon.dj_pipeline.analysis.visit import VisitEnd from aeon.dj_pipeline.analysis.visit_analysis import ( - VisitForagingBout, VisitSummary, VisitTimeDistribution, - filter_out_maintenance_periods, get_maintenance_periods) + VisitForagingBout, + VisitSummary, + VisitTimeDistribution, + filter_out_maintenance_periods, + get_maintenance_periods, +) # pio.renderers.default = 'png' # pio.orca.config.executable = '~/.conda/envs/aeon_env/bin/orca' @@ -30,17 +34,13 @@ def plot_reward_rate_differences(subject_keys): """ subj_names, sess_starts, rate_timestamps, rate_diffs = ( analysis.InArenaRewardRate & subject_keys - ).fetch( - "subject", "in_arena_start", "pellet_rate_timestamps", "patch2_patch1_rate_diff" - ) + ).fetch("subject", "in_arena_start", "pellet_rate_timestamps", "patch2_patch1_rate_diff") nSessions = len(sess_starts) longest_rateDiff = np.max([len(t) for t in rate_timestamps]) max_session_idx = np.argmax([len(t) for t in rate_timestamps]) - max_session_elapsed_times = ( - rate_timestamps[max_session_idx] - rate_timestamps[max_session_idx][0] - ) + max_session_elapsed_times = rate_timestamps[max_session_idx] - rate_timestamps[max_session_idx][0] x_labels = [t.total_seconds() / 60 for t in max_session_elapsed_times] y_labels = [ @@ -85,15 +85,12 @@ def plot_wheel_travelled_distance(session_keys): ``` """ distance_travelled_query = ( - analysis.InArenaSummary.FoodPatch - * acquisition.ExperimentFoodPatch.proj("food_patch_description") + analysis.InArenaSummary.FoodPatch * acquisition.ExperimentFoodPatch.proj("food_patch_description") & session_keys ) distance_travelled_df = ( - distance_travelled_query.proj( - "food_patch_description", "wheel_distance_travelled" - ) + distance_travelled_query.proj("food_patch_description", "wheel_distance_travelled") .fetch(format="frame") .reset_index() ) @@ -154,8 +151,7 @@ def plot_average_time_distribution(session_keys): & session_keys ) .aggr( - analysis.InArenaTimeDistribution.FoodPatch - * acquisition.ExperimentFoodPatch, + analysis.InArenaTimeDistribution.FoodPatch * acquisition.ExperimentFoodPatch, avg_in_patch="AVG(time_fraction_in_patch)", ) .fetch("subject", "food_patch_description", "avg_in_patch") @@ -233,15 +229,11 @@ def plot_visit_daily_summary( .reset_index() ) else: - visit_per_day_df = ( - (VisitSummary & visit_key).fetch(format="frame").reset_index() - ) + visit_per_day_df = (VisitSummary & visit_key).fetch(format="frame").reset_index() if not attr.startswith("total"): attr = "total_" + attr - visit_per_day_df["day"] = ( - visit_per_day_df["visit_date"] - visit_per_day_df["visit_date"].min() - ) + visit_per_day_df["day"] = visit_per_day_df["visit_date"] - visit_per_day_df["visit_date"].min() visit_per_day_df["day"] = visit_per_day_df["day"].dt.days fig = px.bar( @@ -263,7 +255,12 @@ def plot_visit_daily_summary( fig.update_layout( legend={ - "title": "", "orientation": "h", "yanchor": "bottom", "y": 0.98, "xanchor": "right", "x": 1 + "title": "", + "orientation": "h", + "yanchor": "bottom", + "y": 0.98, + "xanchor": "right", + "x": 1, }, hovermode="x", yaxis_tickformat="digits", @@ -327,14 +324,10 @@ def plot_foraging_bouts_count( else [foraging_bouts["bout_start"].dt.floor("D")] ) - foraging_bouts_count = ( - foraging_bouts.groupby(group_by_attrs).size().reset_index(name="count") - ) + foraging_bouts_count = foraging_bouts.groupby(group_by_attrs).size().reset_index(name="count") visit_start = (VisitEnd & visit_key).fetch1("visit_start") - foraging_bouts_count["day"] = ( - foraging_bouts_count["bout_start"].dt.date - visit_start.date() - ).dt.days + foraging_bouts_count["day"] = (foraging_bouts_count["bout_start"].dt.date - visit_start.date()).dt.days fig = px.bar( foraging_bouts_count, @@ -348,15 +341,17 @@ def plot_foraging_bouts_count( width=700, height=400, template="simple_white", - title=visit_key["subject"] - + "
    Foraging bouts: count (freq='" - + freq - + "')", + title=visit_key["subject"] + "
    Foraging bouts: count (freq='" + freq + "')", ) fig.update_layout( legend={ - "title": "", "orientation": "h", "yanchor": "bottom", "y": 0.98, "xanchor": "right", "x": 1 + "title": "", + "orientation": "h", + "yanchor": "bottom", + "y": 0.98, + "xanchor": "right", + "x": 1, }, hovermode="x", yaxis_tickformat="digits", @@ -418,9 +413,7 @@ def plot_foraging_bouts_distribution( fig = go.Figure() if per_food_patch: - patch_names = (acquisition.ExperimentFoodPatch & visit_key).fetch( - "food_patch_description" - ) + patch_names = (acquisition.ExperimentFoodPatch & visit_key).fetch("food_patch_description") for patch in patch_names: bouts = foraging_bouts[foraging_bouts["food_patch_description"] == patch] fig.add_trace( @@ -447,9 +440,7 @@ def plot_foraging_bouts_distribution( ) fig.update_layout( - title_text=visit_key["subject"] - + "
    Foraging bouts: " - + attr.replace("_", " "), + title_text=visit_key["subject"] + "
    Foraging bouts: " + attr.replace("_", " "), xaxis_title="date", yaxis_title=attr.replace("_", " "), violingap=0, @@ -481,17 +472,11 @@ def plot_visit_time_distribution(visit_key, freq="D"): region = _get_region_data(visit_key) # Compute time spent per region - time_spent = ( - region.groupby([region.index.floor(freq), "region"]) - .size() - .reset_index(name="count") + time_spent = region.groupby([region.index.floor(freq), "region"]).size().reset_index(name="count") + time_spent["time_fraction"] = time_spent["count"] / time_spent.groupby("timestamps")["count"].transform( + "sum" ) - time_spent["time_fraction"] = time_spent["count"] / time_spent.groupby( - "timestamps" - )["count"].transform("sum") - time_spent["day"] = ( - time_spent["timestamps"] - time_spent["timestamps"].min() - ).dt.days + time_spent["day"] = (time_spent["timestamps"] - time_spent["timestamps"].min()).dt.days fig = px.bar( time_spent, @@ -503,10 +488,7 @@ def plot_visit_time_distribution(visit_key, freq="D"): "time_fraction": "time fraction", "timestamps": "date" if freq == "D" else "time", }, - title=visit_key["subject"] - + "
    Fraction of time spent in each region (freq='" - + freq - + "')", + title=visit_key["subject"] + "
    Fraction of time spent in each region (freq='" + freq + "')", width=700, height=400, template="simple_white", @@ -517,16 +499,19 @@ def plot_visit_time_distribution(visit_key, freq="D"): yaxis_tickformat="digits", yaxis_range=[0, None], legend={ - "title": "", "orientation": "h", "yanchor": "bottom", "y": 0.98, "xanchor": "right", "x": 1 + "title": "", + "orientation": "h", + "yanchor": "bottom", + "y": 0.98, + "xanchor": "right", + "x": 1, }, ) return fig -def _get_region_data( - visit_key, attrs=["in_nest", "in_arena", "in_corridor", "in_patch"] -): +def _get_region_data(visit_key, attrs=["in_nest", "in_arena", "in_corridor", "in_patch"]): """Retrieve region data from VisitTimeDistribution tables. Args: @@ -544,9 +529,7 @@ def _get_region_data( for attr in attrs: if attr == "in_nest": # Nest in_nest = np.concatenate( - (VisitTimeDistribution.Nest & visit_key).fetch( - attr, order_by="visit_date" - ) + (VisitTimeDistribution.Nest & visit_key).fetch(attr, order_by="visit_date") ) region = pd.concat( [ @@ -561,16 +544,14 @@ def _get_region_data( elif attr == "in_patch": # Food patch # Find all patches patches = np.unique( - ( - VisitTimeDistribution.FoodPatch * acquisition.ExperimentFoodPatch - & visit_key - ).fetch("food_patch_description") + (VisitTimeDistribution.FoodPatch * acquisition.ExperimentFoodPatch & visit_key).fetch( + "food_patch_description" + ) ) for patch in patches: in_patch = np.concatenate( ( - VisitTimeDistribution.FoodPatch - * acquisition.ExperimentFoodPatch + VisitTimeDistribution.FoodPatch * acquisition.ExperimentFoodPatch & visit_key & f"food_patch_description = '{patch}'" ).fetch("in_patch", order_by="visit_date") @@ -602,19 +583,13 @@ def _get_region_data( region = region.sort_index().rename_axis("timestamps") # Exclude data during maintenance - maintenance_period = get_maintenance_periods( - visit_key["experiment_name"], visit_start, visit_end - ) - region = filter_out_maintenance_periods( - region, maintenance_period, visit_end, dropna=True - ) + maintenance_period = get_maintenance_periods(visit_key["experiment_name"], visit_start, visit_end) + region = filter_out_maintenance_periods(region, maintenance_period, visit_end, dropna=True) return region -def plot_weight_patch_data( - visit_key, freq="H", smooth_weight=True, min_weight=0, max_weight=35 -): +def plot_weight_patch_data(visit_key, freq="H", smooth_weight=True, min_weight=0, max_weight=35): """Plot subject weight and patch data (pellet trigger count) per visit. Args: @@ -631,9 +606,7 @@ def plot_weight_patch_data( >>> fig = plot_weight_patch_data(visit_key, freq="H", smooth_weight=True) >>> fig = plot_weight_patch_data(visit_key, freq="D") """ - subject_weight = _get_filtered_subject_weight( - visit_key, smooth_weight, min_weight, max_weight - ) + subject_weight = _get_filtered_subject_weight(visit_key, smooth_weight, min_weight, max_weight) # Count pellet trigger per patch per day/hour/... patch = _get_patch_data(visit_key) @@ -661,12 +634,8 @@ def plot_weight_patch_data( for p in patch_names: fig.add_trace( go.Bar( - x=patch_summary[patch_summary["food_patch_description"] == p][ - "event_time" - ], - y=patch_summary[patch_summary["food_patch_description"] == p][ - "event_type" - ], + x=patch_summary[patch_summary["food_patch_description"] == p]["event_time"], + y=patch_summary[patch_summary["food_patch_description"] == p]["event_type"], name=p, ), secondary_y=False, @@ -691,10 +660,7 @@ def plot_weight_patch_data( fig.update_layout( barmode="stack", hovermode="x", - title_text=visit_key["subject"] - + "
    Weight and pellet count (freq='" - + freq - + "')", + title_text=visit_key["subject"] + "
    Weight and pellet count (freq='" + freq + "')", xaxis_title="date" if freq == "D" else "time", yaxis={"title": "pellet count"}, yaxis2={"title": "weight"}, @@ -715,9 +681,7 @@ def plot_weight_patch_data( return fig -def _get_filtered_subject_weight( - visit_key, smooth_weight=True, min_weight=0, max_weight=35 -): +def _get_filtered_subject_weight(visit_key, smooth_weight=True, min_weight=0, max_weight=35): """Retrieve subject weight from WeightMeasurementFiltered table. Args: @@ -756,9 +720,7 @@ def _get_filtered_subject_weight( subject_weight = subject_weight.loc[visit_start:visit_end] # Exclude data during maintenance - maintenance_period = get_maintenance_periods( - visit_key["experiment_name"], visit_start, visit_end - ) + maintenance_period = get_maintenance_periods(visit_key["experiment_name"], visit_start, visit_end) subject_weight = filter_out_maintenance_periods( subject_weight, maintenance_period, visit_end, dropna=True ) @@ -775,9 +737,7 @@ def _get_filtered_subject_weight( subject_weight = subject_weight.resample("1T").mean().dropna() if smooth_weight: - subject_weight["weight_subject"] = savgol_filter( - subject_weight["weight_subject"], 10, 3 - ) + subject_weight["weight_subject"] = savgol_filter(subject_weight["weight_subject"], 10, 3) return subject_weight @@ -798,9 +758,7 @@ def _get_patch_data(visit_key): ( dj.U("event_time", "event_type", "food_patch_description") & ( - acquisition.FoodPatchEvent - * acquisition.EventType - * acquisition.ExperimentFoodPatch + acquisition.FoodPatchEvent * acquisition.EventType * acquisition.ExperimentFoodPatch & f'event_time BETWEEN "{visit_start}" AND "{visit_end}"' & 'event_type = "TriggerPellet"' ) @@ -813,11 +771,7 @@ def _get_patch_data(visit_key): # TODO: handle repeat attempts (pellet delivery trigger and beam break) # Exclude data during maintenance - maintenance_period = get_maintenance_periods( - visit_key["experiment_name"], visit_start, visit_end - ) - patch = filter_out_maintenance_periods( - patch, maintenance_period, visit_end, dropna=True - ) + maintenance_period = get_maintenance_periods(visit_key["experiment_name"], visit_start, visit_end) + patch = filter_out_maintenance_periods(patch, maintenance_period, visit_end, dropna=True) return patch diff --git a/aeon/dj_pipeline/utils/streams_maker.py b/aeon/dj_pipeline/utils/streams_maker.py index ff9fca39..c17c7451 100644 --- a/aeon/dj_pipeline/utils/streams_maker.py +++ b/aeon/dj_pipeline/utils/streams_maker.py @@ -106,10 +106,7 @@ def get_device_stream_template(device_type: str, stream_type: str, streams_modul # DeviceDataStream table(s) stream_detail = ( streams_module.StreamType - & ( - streams_module.DeviceType.Stream - & {"device_type": device_type, "stream_type": stream_type} - ) + & (streams_module.DeviceType.Stream & {"device_type": device_type, "stream_type": stream_type}) ).fetch1() for i, n in enumerate(stream_detail["stream_reader"].split(".")): @@ -143,8 +140,7 @@ def key_source(self): + Chunk(s) that started after {device_type} install time for {device_type} that are not yet removed """ return ( - acquisition.Chunk - * ExperimentDevice.join(ExperimentDevice.RemovalTime, left=True) + acquisition.Chunk * ExperimentDevice.join(ExperimentDevice.RemovalTime, left=True) & f"chunk_start >= {dj.utils.from_camel_case(device_type)}_install_time" & f'chunk_start < IFNULL({dj.utils.from_camel_case(device_type)}_removal_time, "2200-01-01")' ) @@ -153,13 +149,9 @@ def make(self, key): chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( "chunk_start", "chunk_end", "directory_type" ) - raw_data_dir = acquisition.Experiment.get_data_directory( - key, directory_type=dir_type - ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - device_name = (ExperimentDevice & key).fetch1( - f"{dj.utils.from_camel_case(device_type)}_name" - ) + device_name = (ExperimentDevice & key).fetch1(f"{dj.utils.from_camel_case(device_type)}_name") stream = self._stream_reader( **{ @@ -180,12 +172,9 @@ def make(self, key): **key, "sample_count": len(stream_data), "timestamps": stream_data.index.values, - **{ - c: stream_data[c].values - for c in stream.columns - if not c.startswith("_") - }, - }, ignore_extra_fields=True + **{c: stream_data[c].values for c in stream.columns if not c.startswith("_")}, + }, + ignore_extra_fields=True, ) DeviceDataStream.__name__ = f"{device_type}{stream_type}" @@ -197,18 +186,17 @@ def make(self, key): def main(create_tables=True): - if not _STREAMS_MODULE_FILE.exists(): with open(_STREAMS_MODULE_FILE, "w") as f: imports_str = ( - '#---- DO NOT MODIFY ----\n' - '#---- THIS FILE IS AUTO-GENERATED BY `streams_maker.py` ----\n\n' - 'import datajoint as dj\n' - 'import pandas as pd\n' - 'from uuid import UUID\n\n' - 'import aeon\n' - 'from aeon.dj_pipeline import acquisition, get_schema_name\n' - 'from aeon.io import api as io_api\n\n' + "#---- DO NOT MODIFY ----\n" + "#---- THIS FILE IS AUTO-GENERATED BY `streams_maker.py` ----\n\n" + "import datajoint as dj\n" + "import pandas as pd\n" + "from uuid import UUID\n\n" + "import aeon\n" + "from aeon.dj_pipeline import acquisition, get_schema_name\n" + "from aeon.io import api as io_api\n\n" 'schema = dj.Schema(get_schema_name("streams"))\n\n\n' ) f.write(imports_str) @@ -249,7 +237,6 @@ def main(create_tables=True): # Create DeviceDataStream tables. for device_info in streams.DeviceType.Stream.fetch(as_dict=True): - device_type = device_info["device_type"] stream_type = device_info["stream_type"] table_name = f"{device_type}{stream_type}" diff --git a/aeon/dj_pipeline/utils/video.py b/aeon/dj_pipeline/utils/video.py index 5c4a0b1d..63b64f24 100644 --- a/aeon/dj_pipeline/utils/video.py +++ b/aeon/dj_pipeline/utils/video.py @@ -20,6 +20,7 @@ def retrieve_video_frames( chunk_size=50, **kwargs, ): + """Retrive video trames from the raw data directory.""" raw_data_dir = Path(raw_data_dir) assert raw_data_dir.exists() From f6b7bf746483dc9427eaed72a0907f0fd1d2aeae Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 19 Sep 2023 20:20:31 +0000 Subject: [PATCH 299/489] style: :art: update docstring & reformatting --- aeon/dj_pipeline/acquisition.py | 6 ++-- aeon/dj_pipeline/analysis/in_arena.py | 10 ++---- aeon/dj_pipeline/analysis/visit.py | 10 +++--- aeon/dj_pipeline/analysis/visit_analysis.py | 4 +-- .../create_socialexperiment_0.py | 7 ++-- aeon/dj_pipeline/populate/process.py | 16 ++------- aeon/dj_pipeline/populate/worker.py | 9 ++--- aeon/dj_pipeline/report.py | 18 ++-------- aeon/dj_pipeline/tracking.py | 2 -- aeon/dj_pipeline/utils/load_metadata.py | 10 ++---- aeon/dj_pipeline/utils/paths.py | 35 ++++++++++++++----- aeon/dj_pipeline/utils/plotting.py | 17 +++++---- aeon/dj_pipeline/utils/streams_maker.py | 6 +--- 13 files changed, 57 insertions(+), 93 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 82f4a64c..bbf98f0a 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -253,7 +253,7 @@ class RemovalTime(dj.Part): @schema class Epoch(dj.Manual): - definition = """ # A recording period reflecting on/off of the hardware acquisition system + definition = """ # A recording period reflecting on/off of the hardware acquisition system. -> Experiment epoch_start: datetime(6) """ @@ -272,9 +272,7 @@ class Config(dj.Part): @classmethod def ingest_epochs(cls, experiment_name, start=None, end=None): - """Ingest epochs for the specified "experiment_name" - Ingest only epochs that start in between the specified (start, end) time - - if not specified, ingest all epochs + """Ingest epochs for the specified "experiment_name". Ingest only epochs that start in between the specified (start, end) time. If not specified, ingest all epochs. Note: "start" and "end" are datetime specified a string in the format: "%Y-%m-%d %H:%M:%S". """ from .utils import streams_maker diff --git a/aeon/dj_pipeline/analysis/in_arena.py b/aeon/dj_pipeline/analysis/in_arena.py index 7281c396..d10a8527 100644 --- a/aeon/dj_pipeline/analysis/in_arena.py +++ b/aeon/dj_pipeline/analysis/in_arena.py @@ -196,11 +196,7 @@ class InArenaSubjectPosition(dj.Imported): ) def make(self, key): - """The populate logic here relies on the assumption that there is only one subject in the arena at a time - The positiondata is associated with that one subject currently in the arena at any timepoints - For multi-animal experiments, a mapping of object_id-to-subject is needed to populate the right position data - associated with a particular animal. - """ + """The populate logic here relies on the assumption that there is only one subject in the arena at a time. The positiondata is associated with that one subject currently in the arena at any timepoints. For multi-animal experiments, a mapping of object_id-to-subject is needed to populate the right position data associated with a particular animal.""" time_slice_start, time_slice_end = (InArenaTimeSlice & key).fetch1( "time_slice_start", "time_slice_end" ) @@ -244,9 +240,7 @@ def make(self, key): @classmethod def get_position(cls, in_arena_key): - """Given a key to a single InArena, return a Pandas DataFrame for the position data - of the subject for the specified InArena time period. - """ + """Given a key to a single InArena, return a Pandas DataFrame for the position data of the subject for the specified InArena time period.""" assert len(InArena & in_arena_key) == 1 start, end = (InArena * InArenaEnd & in_arena_key).fetch1("in_arena_start", "in_arena_end") diff --git a/aeon/dj_pipeline/analysis/visit.py b/aeon/dj_pipeline/analysis/visit.py index 74283d62..3c7e7be7 100644 --- a/aeon/dj_pipeline/analysis/visit.py +++ b/aeon/dj_pipeline/analysis/visit.py @@ -110,13 +110,13 @@ def make(self, key): # ---- HELPERS ---- -def ingest_environment_visits(experiment_names=None): - """Function to populate into `Visit` and `VisitEnd` for specified experiments (default: 'exp0.2-r0') - This ingestion routine handles only those "complete" visits, not ingesting any "on-going" visits - Using "analyze" method: `aeon.analyze.utils.visits()`. +def ingest_environment_visits(experiment_names: list | None = None): + """Function to populate into `Visit` and `VisitEnd` for specified experiments (default: 'exp0.2-r0'). This ingestion routine handles only those "complete" visits, not ingesting any "on-going" visits using "analyze" method: `aeon.analyze.utils.visits()`. - :param list experiment_names: list of names of the experiment to populate into the Visit table + Args: + experiment_names (list, optional): list of names of the experiment to populate into the Visit table. Defaults to None. """ + if experiment_names is None: experiment_names = ["exp0.2-r0"] place_key = {"place": "environment"} diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index 6610beeb..f8d6f23f 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -184,9 +184,7 @@ def make(self, key): @classmethod def get_position(cls, visit_key=None, subject=None, start=None, end=None): - """Given a key to a single Visit, return a Pandas DataFrame for the position data - of the subject for the specified Visit time period. - """ + """Given a key to a single Visit, return a Pandas DataFrame for the position data of the subject for the specified Visit time period.""" if visit_key is not None: assert len(Visit & visit_key) == 1 start, end = ( diff --git a/aeon/dj_pipeline/create_experiments/create_socialexperiment_0.py b/aeon/dj_pipeline/create_experiments/create_socialexperiment_0.py index f3d7aa9a..b50c0a17 100644 --- a/aeon/dj_pipeline/create_experiments/create_socialexperiment_0.py +++ b/aeon/dj_pipeline/create_experiments/create_socialexperiment_0.py @@ -1,9 +1,7 @@ import pathlib from aeon.dj_pipeline import acquisition, lab, subject -from aeon.dj_pipeline.create_experiments.create_experiment_01 import ( - ingest_exp01_metadata, -) +from aeon.dj_pipeline.create_experiments.create_experiment_01 import ingest_exp01_metadata # ============ Manual and automatic steps to for experiment 0.1 populate ============ experiment_name = "social0-r1" @@ -109,8 +107,7 @@ def main(): def fixID(subjid, valid_ids=None, valid_id_file=None): - """Legacy helper function for socialexperiment0 - originaly developed by ErlichLab - https://github.com/SainsburyWellcomeCentre/aeon_mecha/blob/ee1fa536b58e82fad01130d7689a70e68f94ec0e/aeon/util/helpers.py#L19. + """Legacy helper function for socialexperiment0 - originaly developed by ErlichLab (https://github.com/SainsburyWellcomeCentre/aeon_mecha/blob/ee1fa536b58e82fad01130d7689a70e68f94ec0e/aeon/util/helpers.py#L19). Attempt to correct the id entered by the technician Attempt to correct the subjid entered by the technician diff --git a/aeon/dj_pipeline/populate/process.py b/aeon/dj_pipeline/populate/process.py index 55e84506..1549222b 100644 --- a/aeon/dj_pipeline/populate/process.py +++ b/aeon/dj_pipeline/populate/process.py @@ -1,13 +1,8 @@ """Start an Aeon ingestion process. -This script defines auto-processing routines to operate the DataJoint pipeline for the -Aeon project. Three separate "process" functions are defined to call `populate()` for -different groups of tables, depending on their priority in the ingestion routines (high, -mid, low). - -Each process function is run in a while-loop with the total run-duration configurable -via command line argument '--duration' (if not set, runs perpetually) +This script defines auto-processing routines to operate the DataJoint pipeline for the Aeon project. Three separate "process" functions are defined to call `populate()` for different groups of tables, depending on their priority in the ingestion routines (high, mid, low). +Each process function is run in a while-loop with the total run-duration configurable via command line argument '--duration' (if not set, runs perpetually) - the loop will not begin a new cycle after this period of time (in seconds) - the loop will run perpetually if duration<0 or if duration==None - the script will not be killed _at_ this limit, it will keep executing, @@ -36,12 +31,7 @@ import datajoint as dj from datajoint_utilities.dj_worker import parse_args -from aeon.dj_pipeline.populate.worker import ( - acquisition_worker, - logger, - mid_priority, - streams_worker, -) +from aeon.dj_pipeline.populate.worker import acquisition_worker, logger, mid_priority, streams_worker # ---- some wrappers to support execution as script or CLI diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py index 2db6be51..d8c6d405 100644 --- a/aeon/dj_pipeline/populate/worker.py +++ b/aeon/dj_pipeline/populate/worker.py @@ -34,10 +34,7 @@ class AutomatedExperimentIngestion(dj.Manual): def ingest_colony_epochs_chunks(): - """Load and insert subjects from colony.csv - Ingest epochs and chunks - for experiments specified in AutomatedExperimentIngestion. - """ + """Load and insert subjects from colony.csv. Ingest epochs and chunks for experiments specified in AutomatedExperimentIngestion.""" load_metadata.ingest_subject() experiment_names = AutomatedExperimentIngestion.fetch("experiment_name") for experiment_name in experiment_names: @@ -46,9 +43,7 @@ def ingest_colony_epochs_chunks(): def ingest_environment_visits(): - """Extract and insert complete visits - for experiments specified in AutomatedExperimentIngestion. - """ + """Extract and insert complete visits for experiments specified in AutomatedExperimentIngestion.""" experiment_names = AutomatedExperimentIngestion.fetch("experiment_name") analysis.ingest_environment_visits(experiment_names) diff --git a/aeon/dj_pipeline/report.py b/aeon/dj_pipeline/report.py index 048070ec..c87137af 100644 --- a/aeon/dj_pipeline/report.py +++ b/aeon/dj_pipeline/report.py @@ -297,11 +297,7 @@ def make(self, key): @classmethod def delete_outdated_entries(cls): - """Each entry in this table correspond to one subject. However, the plot is capturing - data for all sessions. - Hence a dynamic update routine is needed to recompute the plot as new sessions - become available. - """ + """Each entry in this table correspond to one subject. However, the plot is capturing data for all sessions.Hence a dynamic update routine is needed to recompute the plot as new sessions become available.""" outdated_entries = ( cls * ( @@ -346,11 +342,7 @@ def make(self, key): @classmethod def delete_outdated_entries(cls): - """Each entry in this table correspond to one subject. However the plot is capturing - data for all sessions. - Hence a dynamic update routine is needed to recompute the plot as new sessions - become available. - """ + """Each entry in this table correspond to one subject. However the plot is capturing data for all sessions. Hence a dynamic update routine is needed to recompute the plot as new sessions become available.""" outdated_entries = ( cls * ( @@ -393,11 +385,7 @@ def make(self, key): @classmethod def delete_outdated_entries(cls): - """Each entry in this table correspond to one subject. However the plot is capturing - data for all sessions. - Hence a dynamic update routine is needed to recompute the plot as new sessions - become available. - """ + """Each entry in this table correspond to one subject. However the plot is capturing data for all sessions. Hence a dynamic update routine is needed to recompute the plot as new sessions become available.""" outdated_entries = ( cls * ( diff --git a/aeon/dj_pipeline/tracking.py b/aeon/dj_pipeline/tracking.py index e5a884b7..3d0c6eab 100644 --- a/aeon/dj_pipeline/tracking.py +++ b/aeon/dj_pipeline/tracking.py @@ -8,8 +8,6 @@ from aeon.dj_pipeline import acquisition, dict_to_uuid, get_schema_name, lab, qc, streams from aeon.io import api as io_api -from . import acquisition, dict_to_uuid, get_schema_name, lab, qc - schema = dj.schema(get_schema_name("tracking")) pixel_scale = 0.00192 # 1 px = 1.92 mm diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 9daf51fe..fe3cec6f 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -127,8 +127,8 @@ def extract_epoch_config(experiment_name: str, metadata_yml_filepath: str) -> di """Parse experiment metadata YAML file and extract epoch configuration. Args: - experiment_name (str) - metadata_yml_filepath (str) + experiment_name (str): Name of the experiment. + metadata_yml_filepath (str): path to the metadata YAML file. Returns: dict: epoch_config [dict] @@ -189,12 +189,6 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): # if identical commit -> no changes return - device_frequency_mapper = { - name: float(value) - for name, value in epoch_config["metadata"]["VideoController"].items() - if name.endswith("Frequency") - } # May not be needed? - schema = acquisition._device_schema_mapping[experiment_name] device_type_mapper, _ = get_device_mapper(schema, metadata_yml_filepath) diff --git a/aeon/dj_pipeline/utils/paths.py b/aeon/dj_pipeline/utils/paths.py index d1583595..c10e276c 100644 --- a/aeon/dj_pipeline/utils/paths.py +++ b/aeon/dj_pipeline/utils/paths.py @@ -3,9 +3,17 @@ from aeon.dj_pipeline import repository_config -def get_repository_path(repository_name): - """Find the directory's full-path corresponding to a given repository_name, - as configured in dj.config['custom']['repository_config']. +def get_repository_path(repository_name: str) -> pathlib.Path: + """Find the directory's full-path corresponding to a given repository_name. + + This function looks up the repository path based on the provided repository_name + using the configuration stored in dj.config['custom']['repository_config']. + + Args: + repository_name (str): The name of the repository to find. + + Returns: + pathlib.Path: The full path to the directory corresponding to the repository_name. """ repo_path = repository_config.get(repository_name) if repo_path is None: @@ -19,12 +27,21 @@ def get_repository_path(repository_name): return repo_path -def find_root_directory(root_directories, full_path): - """Given multiple potential root directories and a full-path, - search and return one directory that is the parent of the given path - :param root_directories: potential root directories - :param full_path: the relative path to search the root directory - :return: full-path (pathlib.Path object). +def find_root_directory( + root_directories: str | pathlib.Path, full_path: str | pathlib.Path +) -> pathlib.Path: + """Given multiple potential root directories and a full-path, search and return one directory that is the parent of the given path. + + Args: + root_directories (str | pathlib.Path): A list of potential root directories. + full_path (str | pathlib.Path): The full path to search for the root directory. + + Raises: + FileNotFoundError: If the specified `full_path` does not exist. + FileNotFoundError: If no valid root directory is found among the provided options. + + Returns: + pathlib.Path: The full path to the discovered root directory. """ full_path = pathlib.Path(full_path) diff --git a/aeon/dj_pipeline/utils/plotting.py b/aeon/dj_pipeline/utils/plotting.py index 353b4e36..01cd14e7 100644 --- a/aeon/dj_pipeline/utils/plotting.py +++ b/aeon/dj_pipeline/utils/plotting.py @@ -23,11 +23,11 @@ def plot_reward_rate_differences(subject_keys): - """Plotting the reward rate differences between food patches (Patch 2 - Patch 1) - for all sessions from all subjects specified in "subject_keys" - Example usage: + """Plotting the reward rate differences between food patches (Patch 2 - Patch 1) for all sessions from all subjects specified in "subject_keys". + + Examples: ``` - subject_keys = (acquisition.Experiment.Subject & 'experiment_name = "exp0.1-r0"').fetch('KEY'). + subject_keys = (acquisition.Experiment.Subject & 'experiment_name = "exp0.1-r0"').fetch('KEY') fig = plot_reward_rate_differences(subject_keys) ``` @@ -74,12 +74,12 @@ def plot_reward_rate_differences(subject_keys): def plot_wheel_travelled_distance(session_keys): - """Plotting the wheel travelled distance for different patches - for all sessions specified in "session_keys" - Example usage: + """Plotting the wheel travelled distance for different patches for all sessions specified in "session_keys". + + Examples: ``` session_keys = (acquisition.Session & acquisition.SessionEnd - & {'experiment_name': 'exp0.1-r0', 'subject': 'BAA-1099794'}).fetch('KEY'). + & {'experiment_name': 'exp0.1-r0', 'subject': 'BAA-1099794'}).fetch('KEY') fig = plot_wheel_travelled_distance(session_keys) ``` @@ -521,7 +521,6 @@ def _get_region_data(visit_key, attrs=["in_nest", "in_arena", "in_corridor", "in Returns: region (pd.DataFrame): Timestamped region info """ - visit_start, visit_end = (VisitEnd & visit_key).fetch1("visit_start", "visit_end") region = pd.DataFrame() diff --git a/aeon/dj_pipeline/utils/streams_maker.py b/aeon/dj_pipeline/utils/streams_maker.py index c17c7451..20c58880 100644 --- a/aeon/dj_pipeline/utils/streams_maker.py +++ b/aeon/dj_pipeline/utils/streams_maker.py @@ -20,11 +20,7 @@ class StreamType(dj.Lookup): - """Catalog of all steam types for the different device types used across Project Aeon - One StreamType corresponds to one reader class in `aeon.io.reader` - The combination of `stream_reader` and `stream_reader_kwargs` should fully specify - the data loading routine for a particular device, using the `aeon.io.utils`. - """ + """Catalog of all steam types for the different device types used across Project Aeon. One StreamType corresponds to one reader class in `aeon.io.reader`. The combination of `stream_reader` and `stream_reader_kwargs` should fully specify the data loading routine for a particular device, using the `aeon.io.utils`.""" definition = """ # Catalog of all stream types used across Project Aeon stream_type : varchar(20) From 0dfe8dd46cb9157757f72a0f26ff2f192bb64592 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 19 Sep 2023 20:25:22 +0000 Subject: [PATCH 300/489] add ignore rule for aeon/dj_pipeline --- pyproject.toml | 84 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 60 insertions(+), 24 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c3afe7ba..cb41b3da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,10 +64,7 @@ dev = [ "ruff", "tox", ] -gpu = [ - "cupy", - "dask" -] +gpu = ["cupy", "dask"] [project.scripts] aeon_ingest = "aeon.dj_pipeline.populate.process:cli" @@ -85,29 +82,70 @@ packages = ["aeon"] line-length = 108 color = false exclude = ''' -/( - \.git - | \.mypy_cache - | \.tox - | \.venv - | _build - | build - | dist - | env - | venv -)/ +( + \.git/ + | \.mypy_cache/ + | \.tox/ + | \.venv/ + | _build/ + | build/ + | dist/ + | env/ + | venv/ + | aeon/dj_pipeline/streams.py +) ''' [tool.ruff] -select = ["E", "W", "F", "I", "D", "UP", "S", "B", "A", "C4", "ICN", "PIE", "PT", "SIM", "PL"] -line-length = 108 +select = [ + "E", + "W", + "F", + "I", + "D", + "UP", + "S", + "B", + "A", + "C4", + "ICN", + "PIE", + "PT", + "SIM", + "PL", +] ignore = [ - "E201", "E202", "E203", "E231", "E731", "E702", - "S101", - "PT013", - "PLR0912", "PLR0913", "PLR0915" + "E201", + "E202", + "E203", + "E231", + "E501", # line-length error. Overlapping with black. + "E731", + "E702", + "S101", + "PT013", + "PLR0912", + "PLR0913", + "PLR0915", ] extend-exclude = [".git", ".github", ".idea", ".vscode"] +line-length = 108 + +[tool.ruff.per-file-ignores] +"aeon/dj_pipeline/*" = [ + "B006", + "B021", + "D100", # skip adding docstrings for module + "D101", # skip adding docstrings for table class since it is added inside definition + "D102", # skip adding docstrings for make function + "D103", # skip adding docstrings for public functions + "D104", # ignore missing docstring in public package + "D106", # skip adding docstrings for Part tables + "F401", # ignore unused import errors + "B905", # ignore unused import errors + "E999", +] + [tool.ruff.pydocstyle] convention = "google" @@ -137,6 +175,4 @@ venvPath = "." venv = ".venv" [tool.pytest.ini_options] -markers = [ - "api", -] +markers = ["api"] From bef24bad70270ae2dba20ab22066a1b5727a9959 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 19 Sep 2023 20:35:16 +0000 Subject: [PATCH 301/489] skip applying pre-commit on aeon/dj_pipeline --- pyproject.toml | 41 ++++++++++++----------------------------- 1 file changed, 12 insertions(+), 29 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cb41b3da..dd18bcc9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,18 +82,17 @@ packages = ["aeon"] line-length = 108 color = false exclude = ''' -( - \.git/ - | \.mypy_cache/ - | \.tox/ - | \.venv/ - | _build/ - | build/ - | dist/ - | env/ - | venv/ - | aeon/dj_pipeline/streams.py -) +/( + \.git + | \.mypy_cache + | \.tox + | \.venv + | _build + | build + | dist + | env + | venv +)/ ''' [tool.ruff] @@ -114,6 +113,7 @@ select = [ "SIM", "PL", ] +line-length = 108 ignore = [ "E201", "E202", @@ -129,23 +129,6 @@ ignore = [ "PLR0915", ] extend-exclude = [".git", ".github", ".idea", ".vscode"] -line-length = 108 - -[tool.ruff.per-file-ignores] -"aeon/dj_pipeline/*" = [ - "B006", - "B021", - "D100", # skip adding docstrings for module - "D101", # skip adding docstrings for table class since it is added inside definition - "D102", # skip adding docstrings for make function - "D103", # skip adding docstrings for public functions - "D104", # ignore missing docstring in public package - "D106", # skip adding docstrings for Part tables - "F401", # ignore unused import errors - "B905", # ignore unused import errors - "E999", -] - [tool.ruff.pydocstyle] convention = "google" From 2af1b11ab459a65ed6ca6f62f4d7dbb82a97d37c Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 19 Sep 2023 20:54:18 +0000 Subject: [PATCH 302/489] revert formatting changes of streams.py --- aeon/dj_pipeline/streams.py | 191 +++++++++++++++++------------------- 1 file changed, 92 insertions(+), 99 deletions(-) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index 6d1f9d9b..0d0f8c58 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -13,12 +13,13 @@ schema = dj.Schema(get_schema_name("streams")) -@schema +@schema class StreamType(dj.Lookup): - """Catalog of all steam types for the different device types used across Project Aeon + """ + Catalog of all steam types for the different device types used across Project Aeon One StreamType corresponds to one reader class in `aeon.io.reader` The combination of `stream_reader` and `stream_reader_kwargs` should fully specify - the data loading routine for a particular device, using the `aeon.io.utils`. + the data loading routine for a particular device, using the `aeon.io.utils` """ definition = """ # Catalog of all stream types used across Project Aeon @@ -32,9 +33,11 @@ class StreamType(dj.Lookup): """ -@schema +@schema class DeviceType(dj.Lookup): - """Catalog of all device types used across Project Aeon.""" + """ + Catalog of all device types used across Project Aeon + """ definition = """ # Catalog of all device types used across Project Aeon device_type: varchar(36) @@ -49,7 +52,7 @@ class Stream(dj.Part): """ -@schema +@schema class Device(dj.Lookup): definition = """ # Physical devices, of a particular type, identified by unique serial number device_serial_number: varchar(12) @@ -58,9 +61,9 @@ class Device(dj.Lookup): """ -@schema +@schema class UndergroundFeeder(dj.Manual): - definition = """ + definition = f""" # underground_feeder placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown) -> acquisition.Experiment -> Device @@ -78,16 +81,16 @@ class Attribute(dj.Part): """ class RemovalTime(dj.Part): - definition = """ + definition = f""" -> master --- underground_feeder_removal_time: datetime(6) # time of the underground_feeder being removed """ -@schema +@schema class VideoSource(dj.Manual): - definition = """ + definition = f""" # video_source placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown) -> acquisition.Experiment -> Device @@ -105,31 +108,31 @@ class Attribute(dj.Part): """ class RemovalTime(dj.Part): - definition = """ + definition = f""" -> master --- video_source_removal_time: datetime(6) # time of the video_source being removed """ -@schema +@schema class UndergroundFeederBeamBreak(dj.Imported): - definition = """ # Raw per-chunk BeamBreak data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) - -> UndergroundFeeder - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of BeamBreak data - event: longblob - """ + definition = """# Raw per-chunk BeamBreak data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + -> UndergroundFeeder + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of BeamBreak data + """ _stream_reader = aeon.io.reader.BitmaskEvent _stream_detail = {'stream_type': 'BeamBreak', 'stream_reader': 'aeon.io.reader.BitmaskEvent', 'stream_reader_kwargs': {'pattern': '{pattern}_32', 'value': 34, 'tag': 'BeamBroken'}, 'stream_description': '', 'stream_hash': UUID('b14171e6-d27d-117a-ae73-a16c4b5fc8a2')} @property def key_source(self): - """Only the combination of Chunk and UndergroundFeeder with overlapping time + f""" + Only the combination of Chunk and UndergroundFeeder with overlapping time + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time - + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed. + + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed """ return ( acquisition.Chunk @@ -178,24 +181,24 @@ def make(self, key): ) -@schema +@schema class UndergroundFeederDeliverPellet(dj.Imported): - definition = """ # Raw per-chunk DeliverPellet data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) - -> UndergroundFeeder - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of DeliverPellet data - event: longblob - """ + definition = """# Raw per-chunk DeliverPellet data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + -> UndergroundFeeder + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of DeliverPellet data + """ _stream_reader = aeon.io.reader.BitmaskEvent _stream_detail = {'stream_type': 'DeliverPellet', 'stream_reader': 'aeon.io.reader.BitmaskEvent', 'stream_reader_kwargs': {'pattern': '{pattern}_35', 'value': 1, 'tag': 'TriggeredPellet'}, 'stream_description': '', 'stream_hash': UUID('c49dda51-2e38-8b49-d1d8-2e54ea928e9c')} @property def key_source(self): - """Only the combination of Chunk and UndergroundFeeder with overlapping time + f""" + Only the combination of Chunk and UndergroundFeeder with overlapping time + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time - + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed. + + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed """ return ( acquisition.Chunk @@ -244,26 +247,24 @@ def make(self, key): ) -@schema +@schema class UndergroundFeederDepletionState(dj.Imported): - definition = """ # Raw per-chunk DepletionState data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) - -> UndergroundFeeder - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of DepletionState data - threshold: longblob - d1: longblob - delta: longblob - """ + definition = """# Raw per-chunk DepletionState data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + -> UndergroundFeeder + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of DepletionState data + """ _stream_reader = aeon.schema.foraging._PatchState _stream_detail = {'stream_type': 'DepletionState', 'stream_reader': 'aeon.schema.foraging._PatchState', 'stream_reader_kwargs': {'pattern': '{pattern}_State_*'}, 'stream_description': '', 'stream_hash': UUID('17c3e36f-3f2e-2494-bbd3-5cb9a23d3039')} @property def key_source(self): - """Only the combination of Chunk and UndergroundFeeder with overlapping time + f""" + Only the combination of Chunk and UndergroundFeeder with overlapping time + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time - + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed. + + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed """ return ( acquisition.Chunk @@ -312,25 +313,24 @@ def make(self, key): ) -@schema +@schema class UndergroundFeederEncoder(dj.Imported): - definition = """ # Raw per-chunk Encoder data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) - -> UndergroundFeeder - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of Encoder data - angle: longblob - intensity: longblob - """ + definition = """# Raw per-chunk Encoder data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + -> UndergroundFeeder + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of Encoder data + """ _stream_reader = aeon.io.reader.Encoder _stream_detail = {'stream_type': 'Encoder', 'stream_reader': 'aeon.io.reader.Encoder', 'stream_reader_kwargs': {'pattern': '{pattern}_90_*'}, 'stream_description': '', 'stream_hash': UUID('f96b0b26-26f6-5ff6-b3c7-5aa5adc00c1a')} @property def key_source(self): - """Only the combination of Chunk and UndergroundFeeder with overlapping time + f""" + Only the combination of Chunk and UndergroundFeeder with overlapping time + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time - + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed. + + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed """ return ( acquisition.Chunk @@ -379,30 +379,24 @@ def make(self, key): ) -@schema +@schema class VideoSourcePosition(dj.Imported): - definition = """ # Raw per-chunk Position data stream from VideoSource (auto-generated with aeon_mecha-unknown) - -> VideoSource - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of Position data - x: longblob - y: longblob - angle: longblob - major: longblob - minor: longblob - area: longblob - id: longblob - """ + definition = """# Raw per-chunk Position data stream from VideoSource (auto-generated with aeon_mecha-unknown) + -> VideoSource + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of Position data + """ _stream_reader = aeon.io.reader.Position _stream_detail = {'stream_type': 'Position', 'stream_reader': 'aeon.io.reader.Position', 'stream_reader_kwargs': {'pattern': '{pattern}_200_*'}, 'stream_description': '', 'stream_hash': UUID('d7727726-1f52-78e1-1355-b863350b6d03')} @property def key_source(self): - """Only the combination of Chunk and VideoSource with overlapping time + f""" + Only the combination of Chunk and VideoSource with overlapping time + Chunk(s) that started after VideoSource install time and ended before VideoSource remove time - + Chunk(s) that started after VideoSource install time for VideoSource that are not yet removed. + + Chunk(s) that started after VideoSource install time for VideoSource that are not yet removed """ return ( acquisition.Chunk @@ -451,24 +445,24 @@ def make(self, key): ) -@schema +@schema class VideoSourceRegion(dj.Imported): - definition = """ # Raw per-chunk Region data stream from VideoSource (auto-generated with aeon_mecha-unknown) - -> VideoSource - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of Region data - region: longblob - """ + definition = """# Raw per-chunk Region data stream from VideoSource (auto-generated with aeon_mecha-unknown) + -> VideoSource + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of Region data + """ _stream_reader = aeon.schema.foraging._RegionReader _stream_detail = {'stream_type': 'Region', 'stream_reader': 'aeon.schema.foraging._RegionReader', 'stream_reader_kwargs': {'pattern': '{pattern}_201_*'}, 'stream_description': '', 'stream_hash': UUID('6c78b3ac-ffff-e2ab-c446-03e3adf4d80a')} @property def key_source(self): - """Only the combination of Chunk and VideoSource with overlapping time + f""" + Only the combination of Chunk and VideoSource with overlapping time + Chunk(s) that started after VideoSource install time and ended before VideoSource remove time - + Chunk(s) that started after VideoSource install time for VideoSource that are not yet removed. + + Chunk(s) that started after VideoSource install time for VideoSource that are not yet removed """ return ( acquisition.Chunk @@ -517,25 +511,24 @@ def make(self, key): ) -@schema +@schema class VideoSourceVideo(dj.Imported): - definition = """ # Raw per-chunk Video data stream from VideoSource (auto-generated with aeon_mecha-unknown) - -> VideoSource - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of Video data - hw_counter: longblob - hw_timestamp: longblob - """ + definition = """# Raw per-chunk Video data stream from VideoSource (auto-generated with aeon_mecha-unknown) + -> VideoSource + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of Video data + """ _stream_reader = aeon.io.reader.Video _stream_detail = {'stream_type': 'Video', 'stream_reader': 'aeon.io.reader.Video', 'stream_reader_kwargs': {'pattern': '{pattern}_*'}, 'stream_description': '', 'stream_hash': UUID('f51c6174-e0c4-a888-3a9d-6f97fb6a019b')} @property def key_source(self): - """Only the combination of Chunk and VideoSource with overlapping time + f""" + Only the combination of Chunk and VideoSource with overlapping time + Chunk(s) that started after VideoSource install time and ended before VideoSource remove time - + Chunk(s) that started after VideoSource install time for VideoSource that are not yet removed. + + Chunk(s) that started after VideoSource install time for VideoSource that are not yet removed """ return ( acquisition.Chunk From 47c81fe160d866052fc99e1be101a32833d888a2 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 19 Sep 2023 21:19:09 +0000 Subject: [PATCH 303/489] move env.yml back to the root dir for docker build --- env_config/env.yml => env.yml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename env_config/env.yml => env.yml (100%) diff --git a/env_config/env.yml b/env.yml similarity index 100% rename from env_config/env.yml rename to env.yml From 4e91643ff6c6a703204f168bf0aa85e7194e23dc Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 20 Sep 2023 15:48:25 +0000 Subject: [PATCH 304/489] update env.yml path in Dockerfile --- docker/image/Dockerfile | 2 +- env.yml => env_config/env.yml | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename env.yml => env_config/env.yml (100%) diff --git a/docker/image/Dockerfile b/docker/image/Dockerfile index f688d5fb..a17e0725 100644 --- a/docker/image/Dockerfile +++ b/docker/image/Dockerfile @@ -55,7 +55,7 @@ RUN mkdir -p ${CEPH_ROOT} ${DJ_EXT_STORE} && \ # copy apt-get dependencies, conda environment yml, datajoint config template RUN cp -f ${AEON_PKG}/docker/image/apt_requirements.txt /srv/conda/apt_requirements.txt && \ - cp -f ${AEON_PKG}/env.yml /srv/conda/environment.yml && \ + cp -f ${AEON_PKG}/env_config/env.yml /srv/conda/environment.yml && \ cp -f ${AEON_PKG}/docker/image/.datajoint_config.json /tmp/.datajoint_config.json # create the local and global datajoint config files, hard-coding the data paths diff --git a/env.yml b/env_config/env.yml similarity index 100% rename from env.yml rename to env_config/env.yml From 37786f1a2f7bcaec98c5cc101ffd78dcbfe91b2e Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 29 Sep 2023 21:10:46 +0000 Subject: [PATCH 305/489] add aeon/dj_pipeline for pre-commit check --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8a99ae87..20d1ff13 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ default_language_version: python: python3.11 -files: "^(test|aeon(?!\/dj_pipeline\/).*)$" +files: "^(test|aeon)\/.*$" repos: - repo: meta hooks: From 2c0ab1f66cda6be1fa775a3bfa8e96d2459d377b Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 29 Sep 2023 21:17:45 +0000 Subject: [PATCH 306/489] add ruff rules to ignore --- pyproject.toml | 50 +++++++++++++++++++++----------------------------- 1 file changed, 21 insertions(+), 29 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dd18bcc9..bbe5f1de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,43 +92,35 @@ exclude = ''' | dist | env | venv + | aeon/dj_pipeline/stream.py )/ ''' [tool.ruff] -select = [ - "E", - "W", - "F", - "I", - "D", - "UP", - "S", - "B", - "A", - "C4", - "ICN", - "PIE", - "PT", - "SIM", - "PL", -] +select = ["E", "W", "F", "I", "D", "UP", "S", "B", "A", "C4", "ICN", "PIE", "PT", "SIM", "PL"] line-length = 108 ignore = [ - "E201", - "E202", - "E203", - "E231", - "E501", # line-length error. Overlapping with black. - "E731", - "E702", - "S101", - "PT013", - "PLR0912", - "PLR0913", - "PLR0915", + "E201", "E202", "E203", "E231", "E731", "E702", + "S101", + "PT013", + "PLR0912", "PLR0913", "PLR0915" ] extend-exclude = [".git", ".github", ".idea", ".vscode"] +[tool.ruff.per-file-ignores] +"aeon/dj_pipeline/*" = [ + "B006", + "B021", + "D100", # skip adding docstrings for module + "D101", # skip adding docstrings for table class since it is added inside definition + "D102", # skip adding docstrings for make function + "D103", # skip adding docstrings for public functions + "D104", # ignore missing docstring in public package + "D106", # skip adding docstrings for Part tables + "E501", + "F401", # ignore unused import errors + "B905", # ignore unused import errors + "E999", +] [tool.ruff.pydocstyle] convention = "google" From 300333e06f35d05f39601ccfeb215de33f81d884 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 29 Sep 2023 21:28:42 +0000 Subject: [PATCH 307/489] don't apply pyright on dj_pipeline --- pyproject.toml | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bbe5f1de..32e52598 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,13 +97,36 @@ exclude = ''' ''' [tool.ruff] -select = ["E", "W", "F", "I", "D", "UP", "S", "B", "A", "C4", "ICN", "PIE", "PT", "SIM", "PL"] +select = [ + "E", + "W", + "F", + "I", + "D", + "UP", + "S", + "B", + "A", + "C4", + "ICN", + "PIE", + "PT", + "SIM", + "PL", +] line-length = 108 ignore = [ - "E201", "E202", "E203", "E231", "E731", "E702", - "S101", - "PT013", - "PLR0912", "PLR0913", "PLR0915" + "E201", + "E202", + "E203", + "E231", + "E731", + "E702", + "S101", + "PT013", + "PLR0912", + "PLR0913", + "PLR0915", ] extend-exclude = [".git", ".github", ".idea", ".vscode"] [tool.ruff.per-file-ignores] @@ -116,7 +139,7 @@ extend-exclude = [".git", ".github", ".idea", ".vscode"] "D103", # skip adding docstrings for public functions "D104", # ignore missing docstring in public package "D106", # skip adding docstrings for Part tables - "E501", + "E501", "F401", # ignore unused import errors "B905", # ignore unused import errors "E999", @@ -148,6 +171,7 @@ reportShadowedImports = "error" # *Note*: we may want to set all 'ReportOptional*' rules to "none", but leaving 'em default for now venvPath = "." venv = ".venv" +extend-ignore = ["aeon/dj_pipeline/*"] [tool.pytest.ini_options] markers = ["api"] From 9cf5b6ae20ea0c1b8409b81fe1ffc8a5b20eb877 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 29 Sep 2023 23:14:54 +0000 Subject: [PATCH 308/489] remove multianimal from acquisition.py --- aeon/__init__.py | 2 +- aeon/dj_pipeline/acquisition.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/aeon/__init__.py b/aeon/__init__.py index a2b99fc2..ab2a506c 100644 --- a/aeon/__init__.py +++ b/aeon/__init__.py @@ -10,4 +10,4 @@ del version, PackageNotFoundError # Set functions avaialable directly under the 'aeon' top-level namespace -from aeon.io.api import load \ No newline at end of file +from aeon.io.api import load diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index bbf98f0a..4b763b62 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -23,7 +23,6 @@ "social0-r1": "FrameTop", "exp0.2-r0": "CameraTop", "oct1.0-r0": "CameraTop", - "multianimal": "CameraTop", } _device_schema_mapping = { @@ -31,7 +30,6 @@ "social0-r1": aeon_schema.exp01, "exp0.2-r0": aeon_schema.exp02, "oct1.0-r0": aeon_schema.octagon01, - "multianimal": aeon_schema.multianimal, } @@ -275,6 +273,7 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): """Ingest epochs for the specified "experiment_name". Ingest only epochs that start in between the specified (start, end) time. If not specified, ingest all epochs. Note: "start" and "end" are datetime specified a string in the format: "%Y-%m-%d %H:%M:%S". """ + from .utils import streams_maker from .utils.load_metadata import extract_epoch_config, ingest_epoch_metadata, insert_device_types @@ -678,6 +677,7 @@ def key_source(self): + Chunk(s) that started after FoodPatch install time and ended before FoodPatch remove time + Chunk(s) that started after FoodPatch install time for FoodPatch that are not yet removed. """ + return ( Chunk * ExperimentFoodPatch.join(ExperimentFoodPatch.RemovalTime, left=True) & "chunk_start >= food_patch_install_time" From 7e744d3d74bef213e7cf0e94e213b1e03f4c96bf Mon Sep 17 00:00:00 2001 From: JaerongA Date: Sat, 30 Sep 2023 00:39:29 +0000 Subject: [PATCH 309/489] update rules --- aeon/dj_pipeline/tracking.py | 9 ++++----- pyproject.toml | 15 ++++++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/aeon/dj_pipeline/tracking.py b/aeon/dj_pipeline/tracking.py index 3d0c6eab..cf399805 100644 --- a/aeon/dj_pipeline/tracking.py +++ b/aeon/dj_pipeline/tracking.py @@ -274,11 +274,10 @@ def key_source(self): def make(self, key): from aeon.schema.social import Pose - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - + # chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + # "chunk_start", "chunk_end", "directory_type" + # ) + # raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) # This needs to be modified later sleap_reader = Pose( pattern="", diff --git a/pyproject.toml b/pyproject.toml index 32e52598..45d3dd61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,9 +92,9 @@ exclude = ''' | dist | env | venv - | aeon/dj_pipeline/stream.py )/ ''' +extend-exclude = "aeon/dj_pipeline/streams.py" [tool.ruff] select = [ @@ -128,7 +128,13 @@ ignore = [ "PLR0913", "PLR0915", ] -extend-exclude = [".git", ".github", ".idea", ".vscode"] +extend-exclude = [ + ".git", + ".github", + ".idea", + ".vscode", + "aeon/dj_pipeline/streams.py", +] [tool.ruff.per-file-ignores] "aeon/dj_pipeline/*" = [ "B006", @@ -142,7 +148,7 @@ extend-exclude = [".git", ".github", ".idea", ".vscode"] "E501", "F401", # ignore unused import errors "B905", # ignore unused import errors - "E999", + "E999", "S324", "E722", "S110", "F821", "B904", "UP038", "S607", "S605", "D205", "D202", "F403", "PLR2004", "SIM108", "PLW0127", "PLR2004", "I001" ] [tool.ruff.pydocstyle] @@ -171,7 +177,6 @@ reportShadowedImports = "error" # *Note*: we may want to set all 'ReportOptional*' rules to "none", but leaving 'em default for now venvPath = "." venv = ".venv" -extend-ignore = ["aeon/dj_pipeline/*"] - +exclude= ["aeon/dj_pipeline/*"] [tool.pytest.ini_options] markers = ["api"] From a28d5ce7f20771e2e08c4b7733080d9fa0c1a6c7 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 5 Oct 2023 15:13:59 -0500 Subject: [PATCH 310/489] modify `subject` schema to align with pyrat - added pyrat sync routine --- aeon/dj_pipeline/lab.py | 6 +- aeon/dj_pipeline/subject.py | 338 ++++++++++++++++++++++++++---------- 2 files changed, 248 insertions(+), 96 deletions(-) diff --git a/aeon/dj_pipeline/lab.py b/aeon/dj_pipeline/lab.py index 2db6f4b8..409763df 100644 --- a/aeon/dj_pipeline/lab.py +++ b/aeon/dj_pipeline/lab.py @@ -74,10 +74,10 @@ class UserRole(dj.Lookup): @schema class User(dj.Lookup): definition = """ - user : varchar(32) + user : varchar(32) # swc username --- - user_email='' : varchar(128) - user_cellphone='' : varchar(32) + responsible_owner='' : varchar(32) # pyrat username + responsible_id='' : varchar(32) # pyrat `responsible_id` """ diff --git a/aeon/dj_pipeline/subject.py b/aeon/dj_pipeline/subject.py index 74d61758..dba984dc 100644 --- a/aeon/dj_pipeline/subject.py +++ b/aeon/dj_pipeline/subject.py @@ -1,56 +1,30 @@ +import os +import requests import datajoint as dj +from datetime import datetime, timedelta -from . import get_schema_name +from . import get_schema_name, lab schema = dj.schema(get_schema_name("subject")) @schema -class Strain(dj.Lookup): +class Strain(dj.Manual): definition = """ - # Strain of animal, e.g. C57Bl/6 - strain : varchar(32) # abbreviated strain name + strain_id: int --- - strain_standard_name : varchar(32) # formal name of a strain - strain_desc='' : varchar(255) # description of this strain + strain_name: varchar(64) """ @schema -class Allele(dj.Lookup): +class GeneticBackground(dj.Manual): definition = """ - allele : varchar(32) # abbreviated allele name + gen_bg_id: int --- - allele_standard_name='' : varchar(255) # standard name of an allele + gen_bg: varchar(64) """ - class Source(dj.Part): - definition = """ - -> master - --- - -> lab.Source - source_identifier='' : varchar(255) # id inside the line provider - source_url='' : varchar(255) # link to the line information - expression_data_url='' : varchar(255) # link to the expression pattern from Allen institute brain atlas - """ - - -@schema -class Line(dj.Lookup): - definition = """ - line : varchar(32) # abbreviated name for the line - --- - line_description='' : varchar(2000) - target_phenotype='' : varchar(255) - is_active : boolean # whether the line is in active breeding - """ - - class Allele(dj.Part): - definition = """ - -> master - -> Allele - """ - @schema class Subject(dj.Manual): @@ -63,81 +37,259 @@ class Subject(dj.Manual): subject_description='' : varchar(1024) """ - class Protocol(dj.Part): - definition = """ - -> master - -> lab.Protocol - """ - - class User(dj.Part): - definition = """ - -> master - -> lab.User - """ - - class Line(dj.Part): - definition = """ - -> master - --- - -> Line - """ - - class Strain(dj.Part): - definition = """ - -> master - --- - -> Strain - """ - - class Source(dj.Part): - definition = """ - -> master - --- - -> lab.Source - """ - - class Lab(dj.Part): - definition = """ - -> master - -> lab.Lab - --- - subject_alias='' : varchar(32) # alias of the subject in this lab, if different from the id - """ - @schema -class SubjectDeath(dj.Manual): +class SubjectDetail(dj.Imported): definition = """ -> Subject --- - death_date : date # death date + -> lab.User.proj(responsible_user="user") + -> GeneticBackground + -> Strain + cage_number: varchar(32) """ + def make(self, key): + eartag_or_id = key["subject"] + # cage id, sex, line/strain, genetic background, dob, weight history + params = { + "k": _pyrat_animal_attributes, + "s": "eartag_or_id:asc", + "o": 0, + "l": 10, + "eartag": eartag_or_id, + } + animal_resp = get_pyrat_data(endpoint=f"animals", params=params) + assert ( + len(animal_resp) == 1 + ), f"Found {len(animal_resp)} with eartag {eartag_or_id}, expect only one" + animal_resp = animal_resp[0] + # Insert new subject + subj_key = {"subject": eartag_or_id} + Subject.update1( + { + **subj_key, + "sex": {"f": "F", "m": "M", "?": "U"}[animal_resp["sex"]], + "subject_birth_date": animal_resp["dateborn"], + } + ) + user = (lab.User & {"responsible_id": animal_resp["responsible_id"]}).fetch1("user") + Strain.insert1( + {"strain_id": animal_resp["strain_id"], "strain_name": animal_resp["strain_id"]}, + skip_duplicates=True, + ) + if animal_resp["gen_bg_id"] is not None: + GeneticBackground.insert1( + {"gen_bg_id": animal_resp["gen_bg_id"], "gen_bg": animal_resp["gen_bg"]}, + skip_duplicates=True, + ) + self.insert1( + { + **key, + "responsible_user": user, + "strain_id": animal_resp["strain_id"], + "cage_number": animal_resp["cagenumber"], + "gen_bg_id": animal_resp["gen_bg_id"], + } + ) + + +# ------------------- PYRAT SYNCHRONIZATION -------------------- + @schema -class SubjectCullMethod(dj.Manual): +class PyratIngestionTask(dj.Manual): + """Task to sync new animals from PyRAT""" + definition = """ - -> Subject - --- - cull_method: varchar(255) + pyrat_task_scheduled_time: datetime # (UTC) scheduled time for task execution """ @schema -class Zygosity(dj.Manual): +class PyratIngestion(dj.Imported): + """Ingestion of new animals from PyRAT""" + definition = """ - -> Subject - -> Allele + -> PyratIngestionTask --- - zygosity : enum("Present", "Absent", "Homozygous", "Heterozygous") # zygosity + execution_time: datetime # (UTC) time of task execution + execution_duration: float # (s) duration of task execution + new_pyrat_entry_count: int # number of new PyRAT subject ingested in this round of ingestion """ + key_source = ( + PyratIngestionTask.proj( + seconds_since_scheduled="TIMESTAMPDIFF(SECOND, pyrat_task_scheduled_time, UTC_TIMESTAMP())" + ) + & "seconds_since_scheduled >= 0" + ) + + auto_schedule = True + schedule_interval = 2 # schedule interval in number of days + + def _auto_schedule(self): + utc_now = datetime.utcnow() + + next_task_schedule_time = utc_now + timedelta(days=self.schedule_interval) + if ( + PyratIngestionTask + & f"pyrat_task_scheduled_time BETWEEN '{utc_now}' AND '{next_task_schedule_time}'" + ): + return + + PyratIngestionTask.insert1({"pyrat_task_scheduled_time": next_task_schedule_time}) + + def make(self, key): + execution_time = datetime.utcnow() + """Automatically import or update entries in the Subject table.""" + new_eartags = [] + for responsible_id in lab.User.fetch("responsible_id"): + # 1 - retrieve all animals from this user + animal_resp = get_pyrat_data(endpoint="animals", params=dict(responsible_id=responsible_id)) + for animal_entry in animal_resp: + # 2 - find animal with comment - Project Aeon + eartag_or_id = animal_entry["eartag_or_id"] + comment_resp = get_pyrat_data(endpoint=f"animals/{eartag_or_id}/comments") + for comment in comment_resp: + if comment["content"] is None: + first_attr = comment["attributes"][0] + if ( + first_attr["label"].lower() == "project" + and first_attr["content"].lower() == "aeon" + ): + new_eartags.append(eartag_or_id) + + new_entry_count = 0 + for eartag_or_id in new_eartags: + if Subject & {"subject": eartag_or_id}: + continue + Subject.insert1( + { + "subject": eartag_or_id, + "sex": "U", + "subject_birth_date": "1900-01-01", + } + ) + new_entry_count += 1 + + completion_time = datetime.utcnow() + self.insert1( + { + **key, + "execution_time": execution_time, + "execution_duration": (completion_time - execution_time).total_seconds(), + "new_pyrat_entry_count": new_entry_count, + } + ) + + # auto schedule next task + if self.auto_schedule: + self._auto_schedule() + @schema -class FoodDeprivationWeight(dj.Manual): - definition = """ - -> Subject - weight_time: datetime - --- - weight: float +class CreatePyratIngestionTask(dj.Computed): + definition = """ + -> lab.User """ + + def make(self, key): + """ + Create one new PyratIngestionTask for every newly added user + """ + PyratIngestionTask.insert1({"pyrat_task_scheduled_time": datetime.utcnow()}) + + +_pyrat_animal_attributes = [ + "animalid", + "pupid", + "eartag_or_id", + "prefix", + "state", + "labid", + "cohort_id", + "rfid", + "origin_name", + "sex", + "species_name", + "species_weight_unit", + "sacrifice_reason_name", + "sacrifice_comment", + "sacrifice_actor_username", + "sacrifice_actor_fullname", + "datesacrificed", + "cagenumber", + "cagetype", + "cagelabel", + "cage_owner_username", + "cage_owner_fullname", + "rack_description", + "room_name", + "area_name", + "building_name", + "responsible_id", + "responsible_fullname", + "owner_userid", + "owner_username", + "owner_fullname", + "age_days", + "age_weeks", + "dateborn", + "comments", + "date_last_comment", + "generation", + "gen_bg_id", + "gen_bg", + "strain_id", + "strain_name", + "strain_name_id", + "strain_name_with_id", + "mutations", + "genetically_modified", + "parents", + "licence_title", + "licence_id", + "licence_number", + "classification_id", + "classification", + "pregnant_days", + "plug_date", + "wean_date", + "projects", + "requests", + "weight", + "animal_color", + "animal_user_color", + "import_order_request_id", +] + + +def get_pyrat_data(endpoint: str, params: dict = None, **kwargs): + base_url = "https://swc.pyrat.cloud/api/v3/" + pyrat_system_token = os.getenv("PYRAT_SYSTEM_TOKEN") + pyrat_user_token = os.getenv("PYRAT_USER_TOKEN") + + if pyrat_system_token is None or pyrat_user_token is None: + raise ValueError( + f"The PYRAT tokens must be defined as an environment variable named 'PYRAT_SYSTEM_TOKEN' and 'PYRAT_USER_TOKEN'" + ) + + session = requests.Session() + session.auth = (pyrat_system_token, pyrat_user_token) + + if params is not None: + params_str_list = [] + for k, v in params.items(): + if isinstance(v, (list, tuple)): + for i in v: + params_str_list.append(f"{k}={i}") + else: + params_str_list.append(f"{k}={v}") + params_str = "?" + "&".join(params_str_list) + else: + params_str = "" + + response = session.get(base_url + endpoint + params_str, **kwargs) + + return response.json() if response.status_code == 200 else {"reponse code": response.status_code} From e44bd51f6172b95012ba13f0e7e8ec1ee6e62462 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 24 Oct 2023 19:01:04 -0500 Subject: [PATCH 311/489] added routine to pull from pyrat: weights, comments, procedures --- aeon/dj_pipeline/populate/worker.py | 17 +++- aeon/dj_pipeline/subject.py | 129 +++++++++++++++++++++++++--- 2 files changed, 130 insertions(+), 16 deletions(-) diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py index d8c6d405..6851545e 100644 --- a/aeon/dj_pipeline/populate/worker.py +++ b/aeon/dj_pipeline/populate/worker.py @@ -2,7 +2,7 @@ from datajoint_utilities.dj_worker import DataJointWorker, ErrorLog, WorkerLog from datajoint_utilities.dj_worker.worker_schema import is_djtable -from aeon.dj_pipeline import acquisition, analysis, db_prefix, qc, report, tracking +from aeon.dj_pipeline import subject, acquisition, analysis, db_prefix, qc, report, tracking from aeon.dj_pipeline.utils import load_metadata, streams_maker streams = streams_maker.main() @@ -95,7 +95,20 @@ def ingest_environment_visits(): mid_priority(report.ExperimentTimeDistribution) mid_priority(report.VisitDailySummaryPlot) -# ---- Define worker(s) ---- +# configure a worker to handle pyrat sync +pyrat_worker = DataJointWorker( + "pyrat_worker", + worker_schema_name=worker_schema_name, + db_prefix=db_prefix, + run_duration=-1, + sleep_duration=1200, +) + +pyrat_worker(subject.CreatePyratIngestionTask) +pyrat_worker(subject.PyratIngestion) +pyrat_worker(subject.SubjectDetail) +pyrat_worker(subject.PyratCommentWeightProcedure) + # configure a worker to ingest all data streams streams_worker = DataJointWorker( "streams_worker", diff --git a/aeon/dj_pipeline/subject.py b/aeon/dj_pipeline/subject.py index dba984dc..d4745877 100644 --- a/aeon/dj_pipeline/subject.py +++ b/aeon/dj_pipeline/subject.py @@ -1,10 +1,12 @@ import os import requests +import json import datajoint as dj from datetime import datetime, timedelta from . import get_schema_name, lab +logger = dj.logger schema = dj.schema(get_schema_name("subject")) @@ -44,7 +46,7 @@ class SubjectDetail(dj.Imported): -> Subject --- -> lab.User.proj(responsible_user="user") - -> GeneticBackground + -> [nullable] GeneticBackground -> Strain cage_number: varchar(32) """ @@ -60,9 +62,7 @@ def make(self, key): "eartag": eartag_or_id, } animal_resp = get_pyrat_data(endpoint=f"animals", params=params) - assert ( - len(animal_resp) == 1 - ), f"Found {len(animal_resp)} with eartag {eartag_or_id}, expect only one" + assert len(animal_resp) == 1, f"Found {len(animal_resp)} with eartag {eartag_or_id}, expect one" animal_resp = animal_resp[0] # Insert new subject subj_key = {"subject": eartag_or_id} @@ -78,20 +78,66 @@ def make(self, key): {"strain_id": animal_resp["strain_id"], "strain_name": animal_resp["strain_id"]}, skip_duplicates=True, ) + entry = { + **key, + "responsible_user": user, + "strain_id": animal_resp["strain_id"], + "cage_number": animal_resp["cagenumber"], + } if animal_resp["gen_bg_id"] is not None: GeneticBackground.insert1( {"gen_bg_id": animal_resp["gen_bg_id"], "gen_bg": animal_resp["gen_bg"]}, skip_duplicates=True, ) - self.insert1( - { - **key, - "responsible_user": user, - "strain_id": animal_resp["strain_id"], - "cage_number": animal_resp["cagenumber"], - "gen_bg_id": animal_resp["gen_bg_id"], - } - ) + entry["gen_bg_id"] = animal_resp["gen_bg_id"] + + self.insert1(entry) + + +@schema +class SubjectWeight(dj.Imported): + definition = """ + -> Subject + weight_id: int + --- + weight: float + weight_time: datetime + actor_name: varchar(200) + """ + + +@schema +class SubjectProcedure(dj.Imported): + definition = """ + -> Subject + assign_id: int + --- + procedure_id: int + procedure_name: varchar(200) + procedure_date: date + license_id: int + license_number: varchar(200) + classification_id: int + classification_name: varchar(200) + actor_fullname: varchar(200) + comment=null: varchar(1000) + """ + + +@schema +class SubjectComment(dj.Imported): + definition = """ + -> Subject + comment_id: int + --- + created: datetime + creator_id: int + creator_username: varchar(200) + creator_fullname: varchar(200) + origin: varchar(200) + content=null: varchar(1000) + attributes: varchar(1000) + """ # ------------------- PYRAT SYNCHRONIZATION -------------------- @@ -126,7 +172,7 @@ class PyratIngestion(dj.Imported): ) auto_schedule = True - schedule_interval = 2 # schedule interval in number of days + schedule_interval = 1 # schedule interval in number of days def _auto_schedule(self): utc_now = datetime.utcnow() @@ -173,6 +219,7 @@ def make(self, key): ) new_entry_count += 1 + logger.info(f"Inserting {new_entry_count} new subject(s) from Pyrat") completion_time = datetime.utcnow() self.insert1( { @@ -183,11 +230,65 @@ def make(self, key): } ) + logger.info(f"Extracting weights/comments/procedures") + comment_resp = get_pyrat_data(endpoint=f"animals/{eartag_or_id}/comments") + weight_resp = get_pyrat_data(endpoint=f"animals/{eartag_or_id}/weights") + procedure_resp = get_pyrat_data(endpoint=f"animals/{eartag_or_id}/procedures") + # auto schedule next task if self.auto_schedule: self._auto_schedule() +@schema +class PyratCommentWeightProcedure(dj.Imported): + """Ingestion of new animals from PyRAT""" + + definition = """ + -> PyratIngestion + --- + execution_time: datetime # (UTC) time of task execution + execution_duration: float # (s) duration of task execution + """ + + def make(self, key): + execution_time = datetime.utcnow() + logger.info(f"Extracting weights/comments/procedures") + + for eartag_or_id in Subject.fetch("subject"): + comment_resp = get_pyrat_data(endpoint=f"animals/{eartag_or_id}/comments") + if comment_resp == {"reponse code": 404}: + logger.warning(f"{eartag_or_id} could not be found in Pyrat") + continue + for cmt in comment_resp: + cmt["subject"] = eartag_or_id + cmt["attributes"] = json.dumps(cmt["attributes"], default=str) + SubjectComment.insert(comment_resp, skip_duplicates=True, allow_direct_insert=True) + + weight_resp = get_pyrat_data(endpoint=f"animals/{eartag_or_id}/weights") + SubjectWeight.insert( + [{**v, "subject": eartag_or_id} for v in weight_resp], + skip_duplicates=True, + allow_direct_insert=True, + ) + + procedure_resp = get_pyrat_data(endpoint=f"animals/{eartag_or_id}/procedures") + SubjectProcedure.insert( + [{**v, "subject": eartag_or_id} for v in procedure_resp], + skip_duplicates=True, + allow_direct_insert=True, + ) + + completion_time = datetime.utcnow() + self.insert1( + { + **key, + "execution_time": execution_time, + "execution_duration": (completion_time - execution_time).total_seconds(), + } + ) + + @schema class CreatePyratIngestionTask(dj.Computed): definition = """ From f5a991d9e5d69806ffb60f284c6cd61c358dc984 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 24 Oct 2023 19:03:15 -0500 Subject: [PATCH 312/489] minor bugfix --- aeon/dj_pipeline/subject.py | 1 + 1 file changed, 1 insertion(+) diff --git a/aeon/dj_pipeline/subject.py b/aeon/dj_pipeline/subject.py index d4745877..2739a83e 100644 --- a/aeon/dj_pipeline/subject.py +++ b/aeon/dj_pipeline/subject.py @@ -300,6 +300,7 @@ def make(self, key): Create one new PyratIngestionTask for every newly added user """ PyratIngestionTask.insert1({"pyrat_task_scheduled_time": datetime.utcnow()}) + self.insert1(key) _pyrat_animal_attributes = [ From 91b6a03950b3f94596011348a6afe1787f3ff896 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 24 Oct 2023 19:06:18 -0500 Subject: [PATCH 313/489] code clean up --- aeon/dj_pipeline/subject.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/aeon/dj_pipeline/subject.py b/aeon/dj_pipeline/subject.py index 2739a83e..5c248974 100644 --- a/aeon/dj_pipeline/subject.py +++ b/aeon/dj_pipeline/subject.py @@ -1,4 +1,6 @@ import os +import time + import requests import json import datajoint as dj @@ -230,11 +232,6 @@ def make(self, key): } ) - logger.info(f"Extracting weights/comments/procedures") - comment_resp = get_pyrat_data(endpoint=f"animals/{eartag_or_id}/comments") - weight_resp = get_pyrat_data(endpoint=f"animals/{eartag_or_id}/weights") - procedure_resp = get_pyrat_data(endpoint=f"animals/{eartag_or_id}/procedures") - # auto schedule next task if self.auto_schedule: self._auto_schedule() @@ -300,6 +297,7 @@ def make(self, key): Create one new PyratIngestionTask for every newly added user """ PyratIngestionTask.insert1({"pyrat_task_scheduled_time": datetime.utcnow()}) + time.sleep(1) self.insert1(key) From ef245c820fde477a7305e9ff6893f4afca9b9373 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 24 Oct 2023 23:08:56 -0500 Subject: [PATCH 314/489] set some attributes to nullable --- aeon/dj_pipeline/subject.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/aeon/dj_pipeline/subject.py b/aeon/dj_pipeline/subject.py index 5c248974..1e6a7c60 100644 --- a/aeon/dj_pipeline/subject.py +++ b/aeon/dj_pipeline/subject.py @@ -117,10 +117,10 @@ class SubjectProcedure(dj.Imported): procedure_id: int procedure_name: varchar(200) procedure_date: date - license_id: int - license_number: varchar(200) - classification_id: int - classification_name: varchar(200) + license_id=null: int + license_number=null: varchar(200) + classification_id=null: int + classification_name=null: varchar(200) actor_fullname: varchar(200) comment=null: varchar(1000) """ From cc06eca14edf2bbfed5c76ef3da2622fb83f0920 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 25 Oct 2023 12:34:10 -0500 Subject: [PATCH 315/489] minor optimization --- aeon/dj_pipeline/subject.py | 45 +++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/aeon/dj_pipeline/subject.py b/aeon/dj_pipeline/subject.py index 1e6a7c60..846d4ba8 100644 --- a/aeon/dj_pipeline/subject.py +++ b/aeon/dj_pipeline/subject.py @@ -243,6 +243,7 @@ class PyratCommentWeightProcedure(dj.Imported): definition = """ -> PyratIngestion + -> SubjectDetail --- execution_time: datetime # (UTC) time of task execution execution_duration: float # (s) duration of task execution @@ -252,29 +253,29 @@ def make(self, key): execution_time = datetime.utcnow() logger.info(f"Extracting weights/comments/procedures") - for eartag_or_id in Subject.fetch("subject"): - comment_resp = get_pyrat_data(endpoint=f"animals/{eartag_or_id}/comments") - if comment_resp == {"reponse code": 404}: - logger.warning(f"{eartag_or_id} could not be found in Pyrat") - continue - for cmt in comment_resp: - cmt["subject"] = eartag_or_id - cmt["attributes"] = json.dumps(cmt["attributes"], default=str) - SubjectComment.insert(comment_resp, skip_duplicates=True, allow_direct_insert=True) - - weight_resp = get_pyrat_data(endpoint=f"animals/{eartag_or_id}/weights") - SubjectWeight.insert( - [{**v, "subject": eartag_or_id} for v in weight_resp], - skip_duplicates=True, - allow_direct_insert=True, - ) + eartag_or_id = key["subject"] + comment_resp = get_pyrat_data(endpoint=f"animals/{eartag_or_id}/comments") + if comment_resp == {"reponse code": 404}: + raise ValueError(f"{eartag_or_id} could not be found in Pyrat") + + for cmt in comment_resp: + cmt["subject"] = eartag_or_id + cmt["attributes"] = json.dumps(cmt["attributes"], default=str) + SubjectComment.insert(comment_resp, skip_duplicates=True, allow_direct_insert=True) + + weight_resp = get_pyrat_data(endpoint=f"animals/{eartag_or_id}/weights") + SubjectWeight.insert( + [{**v, "subject": eartag_or_id} for v in weight_resp], + skip_duplicates=True, + allow_direct_insert=True, + ) - procedure_resp = get_pyrat_data(endpoint=f"animals/{eartag_or_id}/procedures") - SubjectProcedure.insert( - [{**v, "subject": eartag_or_id} for v in procedure_resp], - skip_duplicates=True, - allow_direct_insert=True, - ) + procedure_resp = get_pyrat_data(endpoint=f"animals/{eartag_or_id}/procedures") + SubjectProcedure.insert( + [{**v, "subject": eartag_or_id} for v in procedure_resp], + skip_duplicates=True, + allow_direct_insert=True, + ) completion_time = datetime.utcnow() self.insert1( From 69e5af688ebceac0829ba7f19826c18692812125 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 31 Oct 2023 16:13:03 +0000 Subject: [PATCH 316/489] hard-code sciviz host name --- aeon/dj_pipeline/webapps/sciviz/specsheet.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 00ab97d5..ed147bbd 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -4,6 +4,7 @@ SciViz: route: /aeon auth: mode: "database" + hostname: aeon-db2 component_interface: override: | from datetime import datetime From 5ee9ebae7a2a03261dcc5e47022a96aa927407ee Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 31 Oct 2023 16:14:53 +0000 Subject: [PATCH 317/489] update docstring to add sciviz url --- aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml index b8167fcd..90e09622 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml @@ -1,5 +1,6 @@ # cd aeon/dj_pipeline/webapps/sciviz/ # HOST_UID=$(id -u) docker-compose -f docker-compose-remote.yaml up -d +# Access https://www.swc.ucl.ac.uk/aeon/ version: '2.4' services: From e10864eef5b20f9c2421a5df24946caa6beebda4 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 31 Oct 2023 16:43:52 +0000 Subject: [PATCH 318/489] feat: :sparkles: add pyrat_worker to docker-compose --- docker/docker-compose.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index e2dce70a..d59fe25e 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -47,7 +47,11 @@ x-aeon-ingest-common: &aeon-ingest-common services: acquisition_worker: <<: *aeon-ingest-common - command: ["aeon_ingest", "acquisition_worker"] + command: [ "aeon_ingest", "acquisition_worker" ] + + pyrat_worker: + <<: *aeon-ingest-common + command: [ "aeon_ingest", "pyrat_worker" ] streams_worker: <<: *aeon-ingest-common @@ -57,7 +61,7 @@ services: deploy: mode: replicated replicas: 3 - command: ["aeon_ingest", "streams_worker"] + command: [ "aeon_ingest", "streams_worker" ] ingest_mid: <<: *aeon-ingest-common @@ -67,4 +71,4 @@ services: deploy: mode: replicated replicas: 2 - command: ["aeon_ingest", "mid_priority"] + command: [ "aeon_ingest", "mid_priority" ] From 9c31cf7f312ee9053180546353c6b91dddcdeb69 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 31 Oct 2023 21:16:13 +0000 Subject: [PATCH 319/489] fix: :bug: re-configure pyrat worker --- aeon/dj_pipeline/populate/process.py | 3 ++- aeon/dj_pipeline/populate/worker.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/populate/process.py b/aeon/dj_pipeline/populate/process.py index 1549222b..1386937b 100644 --- a/aeon/dj_pipeline/populate/process.py +++ b/aeon/dj_pipeline/populate/process.py @@ -31,7 +31,7 @@ import datajoint as dj from datajoint_utilities.dj_worker import parse_args -from aeon.dj_pipeline.populate.worker import acquisition_worker, logger, mid_priority, streams_worker +from aeon.dj_pipeline.populate.worker import acquisition_worker, logger, mid_priority, streams_worker, pyrat_worker # ---- some wrappers to support execution as script or CLI @@ -39,6 +39,7 @@ "acquisition_worker": acquisition_worker, "mid_priority": mid_priority, "streams_worker": streams_worker, + "pyrat_worker": pyrat_worker, } diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py index 6851545e..2e0d8b67 100644 --- a/aeon/dj_pipeline/populate/worker.py +++ b/aeon/dj_pipeline/populate/worker.py @@ -10,6 +10,7 @@ __all__ = [ "acquisition_worker", "mid_priority", + "pyrat_worker", "streams_worker", "WorkerLog", "ErrorLog", From 90baea7f31e2888a5e7bd3e41bee84960cde92c9 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 1 Nov 2023 10:59:34 -0500 Subject: [PATCH 320/489] added `lab_id` and `available` --- aeon/dj_pipeline/subject.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/aeon/dj_pipeline/subject.py b/aeon/dj_pipeline/subject.py index 846d4ba8..809f0b36 100644 --- a/aeon/dj_pipeline/subject.py +++ b/aeon/dj_pipeline/subject.py @@ -50,7 +50,9 @@ class SubjectDetail(dj.Imported): -> lab.User.proj(responsible_user="user") -> [nullable] GeneticBackground -> Strain - cage_number: varchar(32) + cage_number='': varchar(32) + lab_id='': varchar(128) # pyrat 'labid' + available=1: bool # is this animal available on pyrat """ def make(self, key): @@ -64,13 +66,21 @@ def make(self, key): "eartag": eartag_or_id, } animal_resp = get_pyrat_data(endpoint=f"animals", params=params) - assert len(animal_resp) == 1, f"Found {len(animal_resp)} with eartag {eartag_or_id}, expect one" + if not animal_resp: + self.update1( + { + **key, + "available": False, + } + ) + elif len(animal_resp) > 1: + raise ValueError(f"Found {len(animal_resp)} with eartag {eartag_or_id}, expect one") + animal_resp = animal_resp[0] # Insert new subject - subj_key = {"subject": eartag_or_id} Subject.update1( { - **subj_key, + **key, "sex": {"f": "F", "m": "M", "?": "U"}[animal_resp["sex"]], "subject_birth_date": animal_resp["dateborn"], } @@ -85,6 +95,7 @@ def make(self, key): "responsible_user": user, "strain_id": animal_resp["strain_id"], "cage_number": animal_resp["cagenumber"], + "lab_id": animal_resp["labid"], } if animal_resp["gen_bg_id"] is not None: GeneticBackground.insert1( From 99db843ccab32c57c81799cb4ddd24d7f0386b92 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 1 Nov 2023 11:04:33 -0500 Subject: [PATCH 321/489] added manual table `ExperimentSubject` --- aeon/dj_pipeline/subject.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/subject.py b/aeon/dj_pipeline/subject.py index 809f0b36..616c3536 100644 --- a/aeon/dj_pipeline/subject.py +++ b/aeon/dj_pipeline/subject.py @@ -57,7 +57,7 @@ class SubjectDetail(dj.Imported): def make(self, key): eartag_or_id = key["subject"] - # cage id, sex, line/strain, genetic background, dob, weight history + # cage id, sex, line/strain, genetic background, dob, lab id params = { "k": _pyrat_animal_attributes, "s": "eartag_or_id:asc", @@ -75,8 +75,9 @@ def make(self, key): ) elif len(animal_resp) > 1: raise ValueError(f"Found {len(animal_resp)} with eartag {eartag_or_id}, expect one") + else: + animal_resp = animal_resp[0] - animal_resp = animal_resp[0] # Insert new subject Subject.update1( { @@ -153,6 +154,14 @@ class SubjectComment(dj.Imported): """ +@schema +class ExperimentSubject(dj.Manual): + definition = """ + -> Subject + experiment_name: varchar(32) # e.g. social-AEON3 + """ + + # ------------------- PYRAT SYNCHRONIZATION -------------------- From 7b966e6912b13999c2df04aa5ca9243afd3076b3 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 1 Nov 2023 11:09:37 -0500 Subject: [PATCH 322/489] deprecate `colony.csv` ingestion --- aeon/dj_pipeline/populate/worker.py | 3 +-- aeon/dj_pipeline/utils/load_metadata.py | 3 +++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py index 2e0d8b67..8a16673b 100644 --- a/aeon/dj_pipeline/populate/worker.py +++ b/aeon/dj_pipeline/populate/worker.py @@ -35,8 +35,7 @@ class AutomatedExperimentIngestion(dj.Manual): def ingest_colony_epochs_chunks(): - """Load and insert subjects from colony.csv. Ingest epochs and chunks for experiments specified in AutomatedExperimentIngestion.""" - load_metadata.ingest_subject() + """Ingest epochs and chunks for experiments specified in AutomatedExperimentIngestion.""" experiment_names = AutomatedExperimentIngestion.fetch("experiment_name") for experiment_name in experiment_names: acquisition.Epoch.ingest_epochs(experiment_name) diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index fe3cec6f..8c88c90e 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -14,6 +14,7 @@ from aeon.dj_pipeline.utils import streams_maker from aeon.io import api as io_api +logger = dj.logger _weight_scale_rate = 100 _weight_scale_nest = 1 _colony_csv_path = pathlib.Path("/ceph/aeon/aeon/colony/colony.csv") @@ -21,6 +22,8 @@ def ingest_subject(colony_csv_path: pathlib.Path = _colony_csv_path) -> None: """Ingest subject information from the colony.csv file.""" + logger.warning("The use of 'colony.csv' is deprecated starting Nov 2023", DeprecationWarning) + colony_df = pd.read_csv(colony_csv_path, skiprows=[1, 2]) colony_df.rename(columns={"Id": "subject"}, inplace=True) colony_df["sex"] = "U" From 812dc0379412c9aad2fc2475f940018f358aa50e Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 1 Nov 2023 11:20:15 -0500 Subject: [PATCH 323/489] minor bugfix --- aeon/dj_pipeline/subject.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/aeon/dj_pipeline/subject.py b/aeon/dj_pipeline/subject.py index 616c3536..fbe1d9f9 100644 --- a/aeon/dj_pipeline/subject.py +++ b/aeon/dj_pipeline/subject.py @@ -47,11 +47,11 @@ class SubjectDetail(dj.Imported): definition = """ -> Subject --- - -> lab.User.proj(responsible_user="user") + lab_id='': varchar(128) # pyrat 'labid' + responsible_fullname='': varchar(128) -> [nullable] GeneticBackground - -> Strain + -> [nullable] Strain cage_number='': varchar(32) - lab_id='': varchar(128) # pyrat 'labid' available=1: bool # is this animal available on pyrat """ @@ -67,12 +67,21 @@ def make(self, key): } animal_resp = get_pyrat_data(endpoint=f"animals", params=params) if not animal_resp: - self.update1( - { - **key, - "available": False, - } - ) + if self & key: + self.update1( + { + **key, + "available": False, + } + ) + else: + self.insert1( + { + **key, + "available": False, + } + ) + return elif len(animal_resp) > 1: raise ValueError(f"Found {len(animal_resp)} with eartag {eartag_or_id}, expect one") else: @@ -86,14 +95,13 @@ def make(self, key): "subject_birth_date": animal_resp["dateborn"], } ) - user = (lab.User & {"responsible_id": animal_resp["responsible_id"]}).fetch1("user") Strain.insert1( {"strain_id": animal_resp["strain_id"], "strain_name": animal_resp["strain_id"]}, skip_duplicates=True, ) entry = { **key, - "responsible_user": user, + "responsible_fullname": animal_resp["responsible_fullname"], "strain_id": animal_resp["strain_id"], "cage_number": animal_resp["cagenumber"], "lab_id": animal_resp["labid"], From acaa43ff276ec7cf582d84cd6c2b1f468185bf72 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 1 Nov 2023 11:24:33 -0500 Subject: [PATCH 324/489] check and update subject's "available" periodically --- aeon/dj_pipeline/subject.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/subject.py b/aeon/dj_pipeline/subject.py index fbe1d9f9..d1c7de2d 100644 --- a/aeon/dj_pipeline/subject.py +++ b/aeon/dj_pipeline/subject.py @@ -277,6 +277,8 @@ class PyratCommentWeightProcedure(dj.Imported): execution_duration: float # (s) duration of task execution """ + key_source = SubjectDetail & "available = 1" + def make(self, key): execution_time = datetime.utcnow() logger.info(f"Extracting weights/comments/procedures") @@ -284,7 +286,13 @@ def make(self, key): eartag_or_id = key["subject"] comment_resp = get_pyrat_data(endpoint=f"animals/{eartag_or_id}/comments") if comment_resp == {"reponse code": 404}: - raise ValueError(f"{eartag_or_id} could not be found in Pyrat") + SubjectDetail.update1( + { + **key, + "available": False, + } + ) + return for cmt in comment_resp: cmt["subject"] = eartag_or_id From 0ffaa1fd1cd5b3975f0582c9b1659c066e747e6b Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 1 Nov 2023 11:26:53 -0500 Subject: [PATCH 325/489] minor bugfix --- aeon/dj_pipeline/subject.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/subject.py b/aeon/dj_pipeline/subject.py index d1c7de2d..e3026c3d 100644 --- a/aeon/dj_pipeline/subject.py +++ b/aeon/dj_pipeline/subject.py @@ -277,7 +277,7 @@ class PyratCommentWeightProcedure(dj.Imported): execution_duration: float # (s) duration of task execution """ - key_source = SubjectDetail & "available = 1" + key_source = (PyratIngestion * SubjectDetail) & "available = 1" def make(self, key): execution_time = datetime.utcnow() From 1d8095b3e8247e495143c4215b4fd6a2f20148d4 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 1 Nov 2023 13:03:31 -0500 Subject: [PATCH 326/489] Update aeon/dj_pipeline/subject.py Co-authored-by: JaerongA --- aeon/dj_pipeline/subject.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/subject.py b/aeon/dj_pipeline/subject.py index e3026c3d..f71df192 100644 --- a/aeon/dj_pipeline/subject.py +++ b/aeon/dj_pipeline/subject.py @@ -66,7 +66,7 @@ def make(self, key): "eartag": eartag_or_id, } animal_resp = get_pyrat_data(endpoint=f"animals", params=params) - if not animal_resp: + if len(animal_resp) == 0: if self & key: self.update1( { From 9209b7dbbff24fec9ebe91a4d596f6b53d931f91 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 2 Nov 2023 11:51:59 -0500 Subject: [PATCH 327/489] Use `acquisition.Experiment` to register new aeon experiments --- aeon/dj_pipeline/acquisition.py | 3 +++ aeon/dj_pipeline/populate/worker.py | 6 +++--- aeon/dj_pipeline/subject.py | 8 -------- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 4b763b62..e7a2d25b 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -1046,6 +1046,9 @@ def _get_all_chunks(experiment_name, device_name): if data_dir } + if not raw_data_dirs: + raise ValueError(f"No raw data directory found for experiment: {experiment_name}") + chunkdata = io_api.load( root=raw_data_dirs.values(), reader=io_reader.Chunk(pattern=device_name + "*", extension="csv"), diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py index 8a16673b..75b8b131 100644 --- a/aeon/dj_pipeline/populate/worker.py +++ b/aeon/dj_pipeline/populate/worker.py @@ -3,7 +3,7 @@ from datajoint_utilities.dj_worker.worker_schema import is_djtable from aeon.dj_pipeline import subject, acquisition, analysis, db_prefix, qc, report, tracking -from aeon.dj_pipeline.utils import load_metadata, streams_maker +from aeon.dj_pipeline.utils import streams_maker streams = streams_maker.main() @@ -34,7 +34,7 @@ class AutomatedExperimentIngestion(dj.Manual): """ -def ingest_colony_epochs_chunks(): +def ingest_epochs_chunks(): """Ingest epochs and chunks for experiments specified in AutomatedExperimentIngestion.""" experiment_names = AutomatedExperimentIngestion.fetch("experiment_name") for experiment_name in experiment_names: @@ -57,7 +57,7 @@ def ingest_environment_visits(): run_duration=-1, sleep_duration=1200, ) -acquisition_worker(ingest_colony_epochs_chunks) +acquisition_worker(ingest_epochs_chunks) acquisition_worker(acquisition.ExperimentLog) acquisition_worker(acquisition.SubjectEnterExit) acquisition_worker(acquisition.SubjectWeight) diff --git a/aeon/dj_pipeline/subject.py b/aeon/dj_pipeline/subject.py index e3026c3d..776c7f2e 100644 --- a/aeon/dj_pipeline/subject.py +++ b/aeon/dj_pipeline/subject.py @@ -162,14 +162,6 @@ class SubjectComment(dj.Imported): """ -@schema -class ExperimentSubject(dj.Manual): - definition = """ - -> Subject - experiment_name: varchar(32) # e.g. social-AEON3 - """ - - # ------------------- PYRAT SYNCHRONIZATION -------------------- From 6677edca9e56ac97a050089f12856cab84e0c400 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 3 Nov 2023 18:27:37 +0000 Subject: [PATCH 328/489] feat: :sparkles: update specsheet.yaml with pyrat tables --- .../dj_pipeline/webapps/sciviz/specsheet.yaml | 74 ++++++------------- 1 file changed, 21 insertions(+), 53 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index ed147bbd..759ed9a8 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -26,7 +26,7 @@ SciViz: kwargs['end_time'] = datetime.utcfromtimestamp(int(kwargs.pop('endTime'))) kwargs['start_frame'] = int(kwargs.pop('start_frame')) kwargs['chunk_size'] = int(kwargs.pop('chunk_size')) - + return ( NumpyEncoder.dumps( retrieve_video_frames(**kwargs) @@ -46,39 +46,29 @@ SciViz: columns: 1 row_height: 1000 components: - Colony Entry: - route: /colony_form + Pyrat User Entry: + route: /colony_page_pyrat_user_entry x: 0 y: 0 - height: 0.5 + height: 0.3 width: 1 type: form tables: - - aeon_lab.Colony - - aeon_subject.Subject + - aeon_lab.User map: - type: attribute - input: Subject - destination: subject - - type: attribute - input: Reference Weight - destination: reference_weight - - type: attribute - input: Sex - destination: sex - - type: attribute - input: Date of Birth - destination: subject_birth_date + input: SWC Username + destination: user - type: attribute - input: Note - destination: note + input: Pyrat Responsible Owner + destination: responsible_owner - type: attribute - input: Subject Description - destination: subject_description - Colony: - route: /colony_page_table + input: Pyrat Responsible ID + destination: responsible_id + Pyrat Subjects: + route: /colony_page_pyrat_subjects x: 0 - y: 0.5 + y: 0.3 height: 0.6 width: 1 type: antd-table @@ -86,8 +76,10 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_lab): - return {'query': aeon_lab.Colony(), 'fetch_args': []} + def dj_query(aeon_subject): + query = aeon_subject.Subject * aeon_subject.SubjectDetail & 'available = 1' + return {'query': query, 'fetch_args': []} + ExperimentEntry: route: /experiment_entry grids: @@ -96,7 +88,7 @@ SciViz: columns: 1 row_height: 1000 components: - Experiment Entry: + New Experiment: route: /exp_form x: 0 y: 0 @@ -125,7 +117,7 @@ SciViz: input: Experiment Type destination: aeon_acquisition.ExperimentType - Experiment Subject Entry: + New Experiment Subject: route: /exp_subject_form x: 0 y: 0.5 @@ -142,30 +134,6 @@ SciViz: input: Subject in the experiment destination: aeon_subject.Subject - Experiment Directory Entry: - route: /exp_subject_dir_form - x: 0 - y: 0.8 - height: 0.5 - - width: 1 - type: form - tables: - - aeon_acquisition.Experiment.Directory - map: - - type: table - input: Experiment Name - destination: aeon_acquisition.Experiment - - type: table - input: Directory Type - destination: aeon_acquisition.DirectoryType - - type: table - input: Pipeline Repository - destination: aeon_acquisition.PipelineRepository - - type: attribute - input: Full Path to Experiment Data Directory - destination: directory_path - LookupEntry: route: /lab_entry grids: @@ -598,7 +566,7 @@ SciViz: stream_time_selector, ] restriction: > - def restriction(**kwargs): + def restriction(**kwargs): return dict(**kwargs) dj_query: > def dj_query(aeon_acquisition): From 02e1f3868459c039e9e57ff4252c60499fc31122 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 3 Nov 2023 17:41:23 +0000 Subject: [PATCH 329/489] fix: :bug: ignore type annotations --- aeon/dj_pipeline/utils/paths.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/aeon/dj_pipeline/utils/paths.py b/aeon/dj_pipeline/utils/paths.py index c10e276c..63a13a1f 100644 --- a/aeon/dj_pipeline/utils/paths.py +++ b/aeon/dj_pipeline/utils/paths.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pathlib from aeon.dj_pipeline import repository_config From edc8d02173f68e7fc8b9226e335acef7a8cbf27a Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 3 Nov 2023 18:34:18 +0000 Subject: [PATCH 330/489] fix: :bug: add --ignore-requires-python to avoid python versioning error in pharus --- aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml index 90e09622..a8509e13 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml @@ -1,4 +1,4 @@ -# cd aeon/dj_pipeline/webapps/sciviz/ +# cd aeon/dj_pipeline/webapps/sciviz/ # HOST_UID=$(id -u) docker-compose -f docker-compose-remote.yaml up -d # Access https://www.swc.ucl.ac.uk/aeon/ @@ -23,7 +23,7 @@ services: - | apk add --update git g++ && git clone -b datajoint_pipeline https://github.com/SainsburyWellcomeCentre/aeon_mecha.git && - pip install -e ./aeon_mecha && + pip install -e ./aeon_mecha --ignore-requires-python && gunicorn --bind 0.0.0.0:$${PHARUS_PORT} --workers=4 pharus.server:app # ports: From 9409df25d860cf00ad14d9f8d4f03373a20e5b05 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Mon, 6 Nov 2023 10:29:20 -0600 Subject: [PATCH 331/489] minor improvement --- aeon/dj_pipeline/subject.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/subject.py b/aeon/dj_pipeline/subject.py index 32e06096..317e0d50 100644 --- a/aeon/dj_pipeline/subject.py +++ b/aeon/dj_pipeline/subject.py @@ -220,7 +220,7 @@ def make(self, key): eartag_or_id = animal_entry["eartag_or_id"] comment_resp = get_pyrat_data(endpoint=f"animals/{eartag_or_id}/comments") for comment in comment_resp: - if comment["content"] is None: + if comment["attributes"]: first_attr = comment["attributes"][0] if ( first_attr["label"].lower() == "project" From eda7895475efa29d2b04e1bb486d9a7680e8c9de Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Mon, 6 Nov 2023 13:37:40 -0600 Subject: [PATCH 332/489] added SubjectReferenceWeight --- aeon/dj_pipeline/subject.py | 39 +++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/aeon/dj_pipeline/subject.py b/aeon/dj_pipeline/subject.py index 317e0d50..9a910b2c 100644 --- a/aeon/dj_pipeline/subject.py +++ b/aeon/dj_pipeline/subject.py @@ -162,6 +162,42 @@ class SubjectComment(dj.Imported): """ +@schema +class SubjectReferenceWeight(dj.Manual): + definition = """ + -> SubjectDetail + --- + reference_weight=null: float # animal's reference weight for use in experiment + last_updated_time: datetime # last time (in UTC) when the reference weight was updated + """ + + @classmethod + def get_reference_weight(cls, subject_name): + subj_key = {"subject": subject_name} + + food_restrict_query = SubjectProcedure & subj_key & "procedure_name = 'A99 - food restriction'" + if food_restrict_query: + ref_date = food_restrict_query.fetch("procedure_date", order_by="procedure_date DESC", limit=1)[ + 0 + ] + else: + ref_date = datetime.now().date() + + weight_query = SubjectWeight & subj_key & f"weight_time < '{ref_date}'" + ref_weight = ( + weight_query.fetch("weight", order_by="weight_time DESC", limit=1)[0] if weight_query else None + ) + + entry = { + "subject": subject_name, + "reference_weight": ref_weight, + "last_updated_time": datetime.utcnow(), + } + cls.update1(entry) if cls & {"subject": subject_name} else cls.insert1(entry) + + return ref_weight + + # ------------------- PYRAT SYNCHRONIZATION -------------------- @@ -305,6 +341,9 @@ def make(self, key): allow_direct_insert=True, ) + # compute/update reference weight + SubjectReferenceWeight.get_reference_weight(eartag_or_id) + completion_time = datetime.utcnow() self.insert1( { From 68cdde1ea5694971c8c6d893f3f1dd710f7a2e59 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Mon, 6 Nov 2023 13:50:07 -0600 Subject: [PATCH 333/489] Update specsheet.yaml --- .../dj_pipeline/webapps/sciviz/specsheet.yaml | 43 ++----------------- 1 file changed, 3 insertions(+), 40 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 759ed9a8..58bbbea7 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -77,7 +77,7 @@ SciViz: return dict(**kwargs) dj_query: > def dj_query(aeon_subject): - query = aeon_subject.Subject * aeon_subject.SubjectDetail & 'available = 1' + query = aeon_subject.Subject * aeon_subject.SubjectDetail * aeon_subject.SubjectReferenceWeight.proj('reference_weight', min_since_last_update='TIMESTAMPDIFF(MINUTE, last_updated_time, UTC_TIMESTAMP())') & 'available = 1' return {'query': query, 'fetch_args': []} ExperimentEntry: @@ -134,47 +134,10 @@ SciViz: input: Subject in the experiment destination: aeon_subject.Subject - LookupEntry: - route: /lab_entry - grids: - grid5: - type: fixed - columns: 1 - row_height: 1000 - components: - Lab Entry: - route: /lab_form - x: 0 - y: 0 - height: 0.5 - width: 1 - type: form - tables: - - aeon_lab.Arena - map: - - type: attribute - input: Arena Name - destination: arena_name - - type: table - input: Arena Shape - destination: aeon_lab.ArenaShape - - type: attribute - input: X Dimension - destination: arena_x_dim - - type: attribute - input: Y Dimension - destination: arena_y_dim - - type: attribute - input: Z Dimension - destination: arena_z_dim - - type: attribute - input: Arena Description - destination: arena_description - - Experiment Type Entry: + New Experiment Type: route: /exp_type_form x: 0 - y: 0.5 + y: 0.8 height: 0.3 width: 1 type: form From 9b2af8e973d9376ba40df8a635f5b852566d2cf5 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Mon, 6 Nov 2023 14:01:15 -0600 Subject: [PATCH 334/489] pyrat schedule to every 12hours --- aeon/dj_pipeline/subject.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/subject.py b/aeon/dj_pipeline/subject.py index 9a910b2c..b58397e2 100644 --- a/aeon/dj_pipeline/subject.py +++ b/aeon/dj_pipeline/subject.py @@ -230,12 +230,12 @@ class PyratIngestion(dj.Imported): ) auto_schedule = True - schedule_interval = 1 # schedule interval in number of days + schedule_interval = 12 # schedule interval in number of hours def _auto_schedule(self): utc_now = datetime.utcnow() - next_task_schedule_time = utc_now + timedelta(days=self.schedule_interval) + next_task_schedule_time = utc_now + timedelta(hours=self.schedule_interval) if ( PyratIngestionTask & f"pyrat_task_scheduled_time BETWEEN '{utc_now}' AND '{next_task_schedule_time}'" From aa00413dee6db5c2ff8997a64dcf89020622a3e2 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 6 Nov 2023 20:17:59 +0000 Subject: [PATCH 335/489] fix: :bug: add pyrat api as env --- docker/docker-compose.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index d59fe25e..02d2a67d 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -52,7 +52,8 @@ services: pyrat_worker: <<: *aeon-ingest-common command: [ "aeon_ingest", "pyrat_worker" ] - + env_file: ./.env + streams_worker: <<: *aeon-ingest-common depends_on: From 80849e0ac8a5d97e145a071117f0b34abce2a706 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 6 Nov 2023 20:35:23 +0000 Subject: [PATCH 336/489] chore: :goal_net: raise pyrat api error --- aeon/dj_pipeline/subject.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/aeon/dj_pipeline/subject.py b/aeon/dj_pipeline/subject.py index 32e06096..9e879a8a 100644 --- a/aeon/dj_pipeline/subject.py +++ b/aeon/dj_pipeline/subject.py @@ -1,10 +1,10 @@ +import json import os import time +from datetime import datetime, timedelta -import requests -import json import datajoint as dj -from datetime import datetime, timedelta +import requests from . import get_schema_name, lab @@ -65,7 +65,7 @@ def make(self, key): "l": 10, "eartag": eartag_or_id, } - animal_resp = get_pyrat_data(endpoint=f"animals", params=params) + animal_resp = get_pyrat_data(endpoint="animals", params=params) if len(animal_resp) == 0: if self & key: self.update1( @@ -167,7 +167,7 @@ class SubjectComment(dj.Imported): @schema class PyratIngestionTask(dj.Manual): - """Task to sync new animals from PyRAT""" + """Task to sync new animals from PyRAT.""" definition = """ pyrat_task_scheduled_time: datetime # (UTC) scheduled time for task execution @@ -176,14 +176,14 @@ class PyratIngestionTask(dj.Manual): @schema class PyratIngestion(dj.Imported): - """Ingestion of new animals from PyRAT""" + """Ingestion of new animals from PyRAT.""" definition = """ -> PyratIngestionTask --- execution_time: datetime # (UTC) time of task execution execution_duration: float # (s) duration of task execution - new_pyrat_entry_count: int # number of new PyRAT subject ingested in this round of ingestion + new_pyrat_entry_count: int # number of new PyRAT subject ingested in this round of ingestion """ key_source = ( @@ -214,7 +214,7 @@ def make(self, key): new_eartags = [] for responsible_id in lab.User.fetch("responsible_id"): # 1 - retrieve all animals from this user - animal_resp = get_pyrat_data(endpoint="animals", params=dict(responsible_id=responsible_id)) + animal_resp = get_pyrat_data(endpoint="animals", params={"responsible_id": responsible_id}) for animal_entry in animal_resp: # 2 - find animal with comment - Project Aeon eartag_or_id = animal_entry["eartag_or_id"] @@ -259,7 +259,7 @@ def make(self, key): @schema class PyratCommentWeightProcedure(dj.Imported): - """Ingestion of new animals from PyRAT""" + """Ingestion of new animals from PyRAT.""" definition = """ -> PyratIngestion @@ -273,7 +273,7 @@ class PyratCommentWeightProcedure(dj.Imported): def make(self, key): execution_time = datetime.utcnow() - logger.info(f"Extracting weights/comments/procedures") + logger.info("Extracting weights/comments/procedures") eartag_or_id = key["subject"] comment_resp = get_pyrat_data(endpoint=f"animals/{eartag_or_id}/comments") @@ -317,14 +317,12 @@ def make(self, key): @schema class CreatePyratIngestionTask(dj.Computed): - definition = """ + definition = """ -> lab.User """ def make(self, key): - """ - Create one new PyratIngestionTask for every newly added user - """ + """Create one new PyratIngestionTask for every newly added users.""" PyratIngestionTask.insert1({"pyrat_task_scheduled_time": datetime.utcnow()}) time.sleep(1) self.insert1(key) @@ -393,7 +391,6 @@ def make(self, key): "import_order_request_id", ] - def get_pyrat_data(endpoint: str, params: dict = None, **kwargs): base_url = "https://swc.pyrat.cloud/api/v3/" pyrat_system_token = os.getenv("PYRAT_SYSTEM_TOKEN") @@ -401,7 +398,7 @@ def get_pyrat_data(endpoint: str, params: dict = None, **kwargs): if pyrat_system_token is None or pyrat_user_token is None: raise ValueError( - f"The PYRAT tokens must be defined as an environment variable named 'PYRAT_SYSTEM_TOKEN' and 'PYRAT_USER_TOKEN'" + "The PYRAT tokens must be defined as an environment variable named 'PYRAT_SYSTEM_TOKEN' and 'PYRAT_USER_TOKEN'" ) session = requests.Session() @@ -421,4 +418,7 @@ def get_pyrat_data(endpoint: str, params: dict = None, **kwargs): response = session.get(base_url + endpoint + params_str, **kwargs) - return response.json() if response.status_code == 200 else {"reponse code": response.status_code} + if response.status_code != 200: + raise requests.exceptions.HTTPError(f'PyRat API errored out with response code: {response.status_code}') + + return response.json() From 45ad67916fd0848c5fba5885ae79d7f887fc2c06 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Mon, 6 Nov 2023 15:02:13 -0600 Subject: [PATCH 337/489] handles http errors --- aeon/dj_pipeline/subject.py | 75 +++++++++++++++++++------------------ 1 file changed, 39 insertions(+), 36 deletions(-) diff --git a/aeon/dj_pipeline/subject.py b/aeon/dj_pipeline/subject.py index 76d6641c..52c13016 100644 --- a/aeon/dj_pipeline/subject.py +++ b/aeon/dj_pipeline/subject.py @@ -312,46 +312,49 @@ def make(self, key): logger.info("Extracting weights/comments/procedures") eartag_or_id = key["subject"] - comment_resp = get_pyrat_data(endpoint=f"animals/{eartag_or_id}/comments") - if comment_resp == {"reponse code": 404}: - SubjectDetail.update1( + try: + comment_resp = get_pyrat_data(endpoint=f"animals/{eartag_or_id}/comments") + except requests.exceptions.HTTPError as e: + if e.args[0].endswith("response code: 404"): + SubjectDetail.update1( + { + **key, + "available": False, + } + ) + else: + raise e + else: + for cmt in comment_resp: + cmt["subject"] = eartag_or_id + cmt["attributes"] = json.dumps(cmt["attributes"], default=str) + SubjectComment.insert(comment_resp, skip_duplicates=True, allow_direct_insert=True) + + weight_resp = get_pyrat_data(endpoint=f"animals/{eartag_or_id}/weights") + SubjectWeight.insert( + [{**v, "subject": eartag_or_id} for v in weight_resp], + skip_duplicates=True, + allow_direct_insert=True, + ) + + procedure_resp = get_pyrat_data(endpoint=f"animals/{eartag_or_id}/procedures") + SubjectProcedure.insert( + [{**v, "subject": eartag_or_id} for v in procedure_resp], + skip_duplicates=True, + allow_direct_insert=True, + ) + + # compute/update reference weight + SubjectReferenceWeight.get_reference_weight(eartag_or_id) + finally: + completion_time = datetime.utcnow() + self.insert1( { **key, - "available": False, + "execution_time": execution_time, + "execution_duration": (completion_time - execution_time).total_seconds(), } ) - return - - for cmt in comment_resp: - cmt["subject"] = eartag_or_id - cmt["attributes"] = json.dumps(cmt["attributes"], default=str) - SubjectComment.insert(comment_resp, skip_duplicates=True, allow_direct_insert=True) - - weight_resp = get_pyrat_data(endpoint=f"animals/{eartag_or_id}/weights") - SubjectWeight.insert( - [{**v, "subject": eartag_or_id} for v in weight_resp], - skip_duplicates=True, - allow_direct_insert=True, - ) - - procedure_resp = get_pyrat_data(endpoint=f"animals/{eartag_or_id}/procedures") - SubjectProcedure.insert( - [{**v, "subject": eartag_or_id} for v in procedure_resp], - skip_duplicates=True, - allow_direct_insert=True, - ) - - # compute/update reference weight - SubjectReferenceWeight.get_reference_weight(eartag_or_id) - - completion_time = datetime.utcnow() - self.insert1( - { - **key, - "execution_time": execution_time, - "execution_duration": (completion_time - execution_time).total_seconds(), - } - ) @schema From 1e57e33c833679746ac372d8934e418f76bcf289 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Mon, 6 Nov 2023 17:15:56 -0600 Subject: [PATCH 338/489] update sciviz --- aeon/dj_pipeline/populate/worker.py | 2 +- aeon/dj_pipeline/subject.py | 5 ++- .../dj_pipeline/webapps/sciviz/specsheet.yaml | 45 ++++++++++++------- 3 files changed, 35 insertions(+), 17 deletions(-) diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py index 75b8b131..25b910d1 100644 --- a/aeon/dj_pipeline/populate/worker.py +++ b/aeon/dj_pipeline/populate/worker.py @@ -101,7 +101,7 @@ def ingest_environment_visits(): worker_schema_name=worker_schema_name, db_prefix=db_prefix, run_duration=-1, - sleep_duration=1200, + sleep_duration=10, ) pyrat_worker(subject.CreatePyratIngestionTask) diff --git a/aeon/dj_pipeline/subject.py b/aeon/dj_pipeline/subject.py index 52c13016..4a049a4f 100644 --- a/aeon/dj_pipeline/subject.py +++ b/aeon/dj_pipeline/subject.py @@ -433,6 +433,7 @@ def make(self, key): "import_order_request_id", ] + def get_pyrat_data(endpoint: str, params: dict = None, **kwargs): base_url = "https://swc.pyrat.cloud/api/v3/" pyrat_system_token = os.getenv("PYRAT_SYSTEM_TOKEN") @@ -461,6 +462,8 @@ def get_pyrat_data(endpoint: str, params: dict = None, **kwargs): response = session.get(base_url + endpoint + params_str, **kwargs) if response.status_code != 200: - raise requests.exceptions.HTTPError(f'PyRat API errored out with response code: {response.status_code}') + raise requests.exceptions.HTTPError( + f"PyRat API errored out with response code: {response.status_code}" + ) return response.json() diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 58bbbea7..841f8069 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -46,10 +46,25 @@ SciViz: columns: 1 row_height: 1000 components: + Pyrat Subjects: + route: /colony_page_pyrat_subjects + x: 0 + y: 0 + height: 0.6 + width: 1 + type: antd-table + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_subject): + query = aeon_subject.Subject * aeon_subject.SubjectDetail * aeon_subject.SubjectReferenceWeight.proj('reference_weight', min_since_last_update='TIMESTAMPDIFF(MINUTE, last_updated_time, UTC_TIMESTAMP())') & 'available = 1' + return {'query': query.proj(..., '-available'), 'fetch_args': []} + Pyrat User Entry: route: /colony_page_pyrat_user_entry x: 0 - y: 0 + y: 0.6 height: 0.3 width: 1 type: form @@ -65,20 +80,20 @@ SciViz: - type: attribute input: Pyrat Responsible ID destination: responsible_id - Pyrat Subjects: - route: /colony_page_pyrat_subjects - x: 0 - y: 0.3 - height: 0.6 - width: 1 - type: antd-table - restriction: > - def restriction(**kwargs): - return dict(**kwargs) - dj_query: > - def dj_query(aeon_subject): - query = aeon_subject.Subject * aeon_subject.SubjectDetail * aeon_subject.SubjectReferenceWeight.proj('reference_weight', min_since_last_update='TIMESTAMPDIFF(MINUTE, last_updated_time, UTC_TIMESTAMP())') & 'available = 1' - return {'query': query, 'fetch_args': []} + + Pyrat Sync Task: + route: /colony_page_pyrat_sync_task + x: 0 + y: 0.9 + height: 0.3 + width: 1 + type: form + tables: + - aeon_subject.PyratIngestionTask + map: + - type: attribute + input: Task Scheduled Time + destination: pyrat_task_scheduled_time ExperimentEntry: route: /experiment_entry From 3db1a97a4ced29eafea3cb47ca87d26e5be0b2c3 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 9 Nov 2023 09:26:07 -0600 Subject: [PATCH 339/489] update food restriction procedure name --- aeon/dj_pipeline/subject.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/subject.py b/aeon/dj_pipeline/subject.py index 4a049a4f..06657b7e 100644 --- a/aeon/dj_pipeline/subject.py +++ b/aeon/dj_pipeline/subject.py @@ -175,7 +175,7 @@ class SubjectReferenceWeight(dj.Manual): def get_reference_weight(cls, subject_name): subj_key = {"subject": subject_name} - food_restrict_query = SubjectProcedure & subj_key & "procedure_name = 'A99 - food restriction'" + food_restrict_query = SubjectProcedure & subj_key & "procedure_name = 'R02 - food restriction'" if food_restrict_query: ref_date = food_restrict_query.fetch("procedure_date", order_by="procedure_date DESC", limit=1)[ 0 From c5cc8827d931caffcd68ff84842848ee1753786d Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 28 Nov 2023 19:57:02 +0000 Subject: [PATCH 340/489] fix: :bug: fix file path error --- aeon/dj_pipeline/acquisition.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index e7a2d25b..a74f69b0 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -275,7 +275,9 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): """ from .utils import streams_maker - from .utils.load_metadata import extract_epoch_config, ingest_epoch_metadata, insert_device_types + from .utils.load_metadata import (extract_epoch_config, + ingest_epoch_metadata, + insert_device_types) device_name = _ref_device_mapping.get(experiment_name, "CameraTop") @@ -1041,7 +1043,7 @@ def _get_all_chunks(experiment_name, device_name): as_posix=True, ) raw_data_dirs = { - dir_type: data_dir + dir_type: pathlib.Path(data_dir) for dir_type, data_dir in zip(["quality-control", "raw"], raw_data_dirs) if data_dir } @@ -1050,7 +1052,7 @@ def _get_all_chunks(experiment_name, device_name): raise ValueError(f"No raw data directory found for experiment: {experiment_name}") chunkdata = io_api.load( - root=raw_data_dirs.values(), + root=list(raw_data_dirs.values()), reader=io_reader.Chunk(pattern=device_name + "*", extension="csv"), ) @@ -1091,7 +1093,8 @@ def _load_legacy_subjectdata(experiment_name, data_dir, start, end): return subject_data if experiment_name == "social0-r1": - from aeon.dj_pipeline.create_experiments.create_socialexperiment_0 import fixID + from aeon.dj_pipeline.create_experiments.create_socialexperiment_0 import \ + fixID sessdf = subject_data.copy() sessdf = sessdf[~sessdf.id.str.contains("test")] From 5eb278b7d92b4f7d15330acb66c199c134e351f9 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 28 Nov 2023 19:57:57 +0000 Subject: [PATCH 341/489] add notebook for directory insertion --- .../insert_experiment_directory.ipynb | 445 ++++++++++++++++++ 1 file changed, 445 insertions(+) create mode 100644 aeon/dj_pipeline/create_experiments/insert_experiment_directory.ipynb diff --git a/aeon/dj_pipeline/create_experiments/insert_experiment_directory.ipynb b/aeon/dj_pipeline/create_experiments/insert_experiment_directory.ipynb new file mode 100644 index 00000000..ee14ac1b --- /dev/null +++ b/aeon/dj_pipeline/create_experiments/insert_experiment_directory.ipynb @@ -0,0 +1,445 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "[2023-11-27 23:03:06,933][INFO]: Connecting root@aeon-db2:3306\n", + "[2023-11-27 23:03:06,954][INFO]: Connected root@aeon-db2:3306\n" + ] + } + ], + "source": [ + "import datajoint as dj\n", + "from aeon.dj_pipeline import acquisition" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + "
    \n", + " \n", + " \n", + " \n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
    \n", + "

    experiment_name

    \n", + " e.g exp0-a\n", + "
    \n", + "

    experiment_start_time

    \n", + " datetime of the start of this experiment\n", + "
    \n", + "

    experiment_description

    \n", + " \n", + "
    \n", + "

    arena_name

    \n", + " unique name of the arena (e.g. circular_2m)\n", + "
    \n", + "

    lab

    \n", + " Abbreviated lab name\n", + "
    \n", + "

    location

    \n", + " \n", + "
    \n", + "

    experiment_type

    \n", + " \n", + "
    exp0.1-r02021-06-03 07:00:00experiment 0.1circle-2mSWCroom-0foraging
    exp0.2-r02022-02-22 09:00:00experiment 0.2 - 24/7circle-2mSWCroom-0foraging
    multianimal2023-07-04 15:32:34multianimal testing AEON3circle-2mSWCAEON3multianimal
    social0-r12021-11-30 14:00:00social experiment 0circle-2mSWCroom-1social
    social0.1-a32023-11-22 09:55:09social experiment 0.1 - AEON3circle-2mSWCAEON3social
    social0.1-a42023-11-27 11:22:06social experiment 0.1 - AEON4circle-2mSWCAEON4social
    \n", + " \n", + "

    Total: 6

    \n", + " " + ], + "text/plain": [ + "*experiment_name experiment_start_time experiment_description arena_name lab location experiment_type \n", + "+-----------------+ +-----------------------+ +-------------------------------+ +------------+ +-----+ +----------+ +-----------------+\n", + "exp0.1-r0 2021-06-03 07:00:00 experiment 0.1 circle-2m SWC room-0 foraging \n", + "exp0.2-r0 2022-02-22 09:00:00 experiment 0.2 - 24/7 circle-2m SWC room-0 foraging \n", + "multianimal 2023-07-04 15:32:34 multianimal testing AEON3 circle-2m SWC AEON3 multianimal \n", + "social0-r1 2021-11-30 14:00:00 social experiment 0 circle-2m SWC room-1 social \n", + "social0.1-a3 2023-11-22 09:55:09 social experiment 0.1 - AEON3 circle-2m SWC AEON3 social \n", + "social0.1-a4 2023-11-27 11:22:06 social experiment 0.1 - AEON4 circle-2m SWC AEON4 social \n", + " (Total: 6)" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "acquisition.Experiment()" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " \n", + "
    \n", + " \n", + " \n", + " \n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
    \n", + "

    experiment_name

    \n", + " e.g exp0-a\n", + "
    \n", + "

    directory_type

    \n", + " \n", + "
    \n", + "

    repository_name

    \n", + " \n", + "
    \n", + "

    directory_path

    \n", + " \n", + "
    exp0.1-r0quality-controlceph_aeonaeon/data/qc/AEON/experiment0.1
    exp0.1-r0rawceph_aeonaeon/data/raw/AEON/experiment0.1
    exp0.2-r0quality-controlceph_aeonaeon/data/qc/AEON2/experiment0.2
    exp0.2-r0rawceph_aeonaeon/data/raw/AEON2/experiment0.2
    multianimalrawceph_aeon/ceph/aeon/aeon/data/raw/AEON3/multianimal-test
    social0-r1quality-controlceph_aeonaeon/data/qc/ARENA0/socialexperiment0
    social0-r1rawceph_aeonaeon/data/raw/ARENA0/socialexperiment0
    social0.1-a3rawceph_aeonaeon/data/raw/AEON3/social0.1
    social0.1-a4rawceph_aeonaeon/data/raw/AEON4/social0.1
    \n", + " \n", + "

    Total: 9

    \n", + " " + ], + "text/plain": [ + "*experiment_name *directory_type repository_name directory_path \n", + "+-----------------+ +-----------------+ +-----------------+ +--------------------------------------+\n", + "exp0.1-r0 quality-control ceph_aeon aeon/data/qc/AEON/experiment0.1 \n", + "exp0.1-r0 raw ceph_aeon aeon/data/raw/AEON/experiment0.1 \n", + "exp0.2-r0 quality-control ceph_aeon aeon/data/qc/AEON2/experiment0.2 \n", + "exp0.2-r0 raw ceph_aeon aeon/data/raw/AEON2/experiment0.2 \n", + "multianimal raw ceph_aeon /ceph/aeon/aeon/data/raw/AEON3/multianim\n", + "social0-r1 quality-control ceph_aeon aeon/data/qc/ARENA0/socialexperiment0 \n", + "social0-r1 raw ceph_aeon aeon/data/raw/ARENA0/socialexperiment0 \n", + "social0.1-a3 raw ceph_aeon aeon/data/raw/AEON3/social0.1 \n", + "social0.1-a4 raw ceph_aeon aeon/data/raw/AEON4/social0.1 \n", + " (Total: 9)" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "experiment_name = \"social0.1-a3\"\n", + "computer = \"AEON3\"\n", + "\n", + "acquisition.Experiment.Directory.insert1(\n", + " {\n", + " \"experiment_name\": experiment_name,\n", + " \"repository_name\": \"ceph_aeon\",\n", + " \"directory_type\": \"raw\",\n", + " \"directory_path\": f\"aeon/data/raw/{computer}/social0.1\"\n", + " },\n", + " skip_duplicates=True,\n", + ")\n", + "\n", + "\n", + "experiment_name = \"social0.1-a4\"\n", + "computer = \"AEON4\"\n", + "\n", + "acquisition.Experiment.Directory.insert1(\n", + " {\n", + " \"experiment_name\": experiment_name,\n", + " \"repository_name\": \"ceph_aeon\",\n", + " \"directory_type\": \"raw\",\n", + " \"directory_path\": f\"aeon/data/raw/{computer}/social0.1\"\n", + " },\n", + " skip_duplicates=True,\n", + ")\n", + "\n", + "acquisition.Experiment.Directory()" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " \n", + " experiments to undergo automated ingestion\n", + "
    \n", + " \n", + " \n", + " \n", + "
    \n", + "

    experiment_name

    \n", + " e.g exp0-a\n", + "
    social0.1-a3
    social0.1-a4
    \n", + " \n", + "

    Total: 2

    \n", + " " + ], + "text/plain": [ + "*experiment_name \n", + "+-----------------+\n", + "social0.1-a3 \n", + "social0.1-a4 \n", + " (Total: 2)" + ] + }, + "execution_count": 30, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from aeon.dj_pipeline.populate.worker import AutomatedExperimentIngestion\n", + "\n", + "AutomatedExperimentIngestion.insert1({\"experiment_name\": \"social0.1-a4\"})\n", + "AutomatedExperimentIngestion()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.5" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} From dba5c7751cb30dd269d67c7d6446c4b72be1e4eb Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 28 Nov 2023 23:04:17 +0000 Subject: [PATCH 342/489] build: :heavy_plus_sign: add pillow in pyproject.toml --- pyproject.toml | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 02978f67..458227fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dependencies = [ "numpy>=1.21.0, <2", "opencv-python", "pandas>=1.3", + "pillow", "plotly", "pyarrow", "pydotplus", @@ -138,16 +139,32 @@ extend-exclude = [ "aeon/dj_pipeline/*" = [ "B006", "B021", - "D100", # skip adding docstrings for module - "D101", # skip adding docstrings for table class since it is added inside definition - "D102", # skip adding docstrings for make function - "D103", # skip adding docstrings for public functions - "D104", # ignore missing docstring in public package - "D106", # skip adding docstrings for Part tables + "D100", # skip adding docstrings for module + "D101", # skip adding docstrings for table class since it is added inside definition + "D102", # skip adding docstrings for make function + "D103", # skip adding docstrings for public functions + "D104", # ignore missing docstring in public package + "D106", # skip adding docstrings for Part tables "E501", - "F401", # ignore unused import errors - "B905", # ignore unused import errors - "E999", "S324", "E722", "S110", "F821", "B904", "UP038", "S607", "S605", "D205", "D202", "F403", "PLR2004", "SIM108", "PLW0127", "PLR2004", "I001" + "F401", # ignore unused import errors + "B905", # ignore unused import errors + "E999", + "S324", + "E722", + "S110", + "F821", + "B904", + "UP038", + "S607", + "S605", + "D205", + "D202", + "F403", + "PLR2004", + "SIM108", + "PLW0127", + "PLR2004", + "I001", ] [tool.ruff.pydocstyle] @@ -176,6 +193,6 @@ reportShadowedImports = "error" # *Note*: we may want to set all 'ReportOptional*' rules to "none", but leaving 'em default for now venvPath = "." venv = ".venv" -exclude= ["aeon/dj_pipeline/*"] +exclude = ["aeon/dj_pipeline/*"] [tool.pytest.ini_options] markers = ["api"] From d513e09e579a66d636fc9a3f04c89cdb03bd4fc5 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 29 Nov 2023 01:19:08 +0000 Subject: [PATCH 343/489] build: :heavy_plus_sign: add libtiff5 to apt_requirements.txt --- docker/image/apt_requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/docker/image/apt_requirements.txt b/docker/image/apt_requirements.txt index e0755952..b6322964 100644 --- a/docker/image/apt_requirements.txt +++ b/docker/image/apt_requirements.txt @@ -7,3 +7,4 @@ nvi openssh-client procps tmux +libtiff5 \ No newline at end of file From 0fa21417bfa279aecf320add98dbe5d713a69725 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 29 Nov 2023 17:16:04 +0000 Subject: [PATCH 344/489] add experiment devices & schema --- aeon/dj_pipeline/acquisition.py | 4 ++++ aeon/schema/dataset.py | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index a74f69b0..58b6f8f4 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -23,6 +23,8 @@ "social0-r1": "FrameTop", "exp0.2-r0": "CameraTop", "oct1.0-r0": "CameraTop", + "social0.1-a3": "CameraTop", + "social0.1-a4": "CameraTop" } _device_schema_mapping = { @@ -30,6 +32,8 @@ "social0-r1": aeon_schema.exp01, "exp0.2-r0": aeon_schema.exp02, "oct1.0-r0": aeon_schema.octagon01, + "social0.1-a3": aeon_schema.social01, + "social0.1-a4": aeon_schema.social01 } diff --git a/aeon/schema/dataset.py b/aeon/schema/dataset.py index b9586de4..17ccdb59 100644 --- a/aeon/schema/dataset.py +++ b/aeon/schema/dataset.py @@ -1,6 +1,7 @@ from dotmap import DotMap import aeon.schema.core as stream +from aeon.io import reader from aeon.io.device import Device from aeon.schema import foraging, octagon @@ -57,3 +58,17 @@ Device("Wall8", octagon.Wall), ] ) + +social01 = exp02 +social01.Patch1.BeamBreak = reader.BitmaskEvent( + pattern="Patch1_32", value=0x22, tag="BeamBroken" +) +social01.Patch2.BeamBreak = reader.BitmaskEvent( + pattern="Patch2_32", value=0x22, tag="BeamBroken" +) +social01.Patch1.DeliverPellet = reader.BitmaskEvent( + pattern="Patch1_35", value=0x1, tag="TriggeredPellet" +) +social01.Patch2.DeliverPellet = reader.BitmaskEvent( + pattern="Patch2_35", value=0x1, tag="TriggeredPellet" +) From 79dcb979ea9af7ff2d95e589cbac0d167e13687b Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 29 Nov 2023 17:17:34 +0000 Subject: [PATCH 345/489] fix device_type_mapper.json path --- aeon/dj_pipeline/utils/load_metadata.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 8c88c90e..22ec4478 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -405,8 +405,6 @@ def get_device_mapper(schema: DotMap, metadata_yml_filepath: Path): device_sn (dict): {"device_name", "serial_number"} e.g. {'CameraTop': '21053810'} """ - import os - from aeon.io import api metadata_yml_filepath = Path(metadata_yml_filepath) @@ -420,8 +418,7 @@ def get_device_mapper(schema: DotMap, metadata_yml_filepath: Path): ) # Store the mapper dictionary here - repository_root = os.popen("git rev-parse --show-toplevel").read().strip() # repo root path - filename = Path(repository_root + "/aeon/dj_pipeline/create_experiments/device_type_mapper.json") + filename = Path(__file__).parent.parent / "create_experiments/device_type_mapper.json" device_type_mapper = {} # {device_name: device_type} device_sn = {} # {device_name: device_sn} From a4f26b69d2d2134beefc96ccd07d85982590b066 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 30 Nov 2023 16:41:29 -0600 Subject: [PATCH 346/489] update ingestion for new social experiments --- aeon/dj_pipeline/acquisition.py | 40 +- aeon/dj_pipeline/streams.py | 757 ++++++++++++++++++------ aeon/dj_pipeline/utils/load_metadata.py | 25 +- aeon/schema/dataset.py | 16 +- 4 files changed, 618 insertions(+), 220 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 58b6f8f4..102d4998 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -23,8 +23,6 @@ "social0-r1": "FrameTop", "exp0.2-r0": "CameraTop", "oct1.0-r0": "CameraTop", - "social0.1-a3": "CameraTop", - "social0.1-a4": "CameraTop" } _device_schema_mapping = { @@ -33,7 +31,7 @@ "exp0.2-r0": aeon_schema.exp02, "oct1.0-r0": aeon_schema.octagon01, "social0.1-a3": aeon_schema.social01, - "social0.1-a4": aeon_schema.social01 + "social0.1-a4": aeon_schema.social01, } @@ -95,7 +93,7 @@ class DirectoryType(dj.Lookup): @schema class Experiment(dj.Manual): definition = """ - experiment_name: varchar(32) # e.g exp0-r0 + experiment_name: varchar(32) # e.g exp0-aeon3 --- experiment_start_time: datetime(6) # datetime of the start of this experiment experiment_description: varchar(1000) @@ -272,6 +270,12 @@ class Config(dj.Part): metadata_file_path: varchar(255) # path of the file, relative to the experiment repository """ + class DeviceType(dj.Part): + definition = """ # Device type(s) used in a particular acquisition epoch + -> master + device_type: varchar(36) + """ + @classmethod def ingest_epochs(cls, experiment_name, start=None, end=None): """Ingest epochs for the specified "experiment_name". Ingest only epochs that start in between the specified (start, end) time. If not specified, ingest all epochs. @@ -279,9 +283,7 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): """ from .utils import streams_maker - from .utils.load_metadata import (extract_epoch_config, - ingest_epoch_metadata, - insert_device_types) + from .utils.load_metadata import extract_epoch_config, ingest_epoch_metadata, insert_device_types device_name = _ref_device_mapping.get(experiment_name, "CameraTop") @@ -359,10 +361,17 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): streams_maker.main() with cls.connection.transaction: # Insert devices' installation/removal/settings - ingest_epoch_metadata(experiment_name, metadata_yml_filepath) + epoch_device_types = ingest_epoch_metadata( + experiment_name, metadata_yml_filepath + ) + if epoch_device_types is not None: + cls.DeviceType.insert( + epoch_key | {"device_type": n} for n in epoch_device_types + ) epoch_list.append(epoch_key) except Exception as e: (cls.Config & epoch_key).delete_quick() + (cls.DeviceType & epoch_key).delete_quick() (cls & epoch_key).delete_quick() raise e @@ -452,6 +461,12 @@ def ingest_chunks(cls, experiment_name): epoch_end = (EpochEnd & epoch_key).fetch1("epoch_end") chunk_end = min(chunk_end, epoch_end) + if chunk_start in chunk_starts: + # handle cases where two chunks with identical start_time + # (starts in the same hour) but from 2 consecutive epochs + # using epoch_start as chunk_start in this case + chunk_start = epoch_start + # --- insert to Chunk --- chunk_key = {"experiment_name": experiment_name, "chunk_start": chunk_start} @@ -459,12 +474,6 @@ def ingest_chunks(cls, experiment_name): # skip over those already ingested continue - if chunk_start in chunk_starts: - # handle cases where two chunks with identical start_time - # (starts in the same hour) but from 2 consecutive epochs - # using epoch_start as chunk_start in this case - chunk_key["chunk_start"] = epoch_start - # chunk file and directory raw_data_dir, directory, repo_path = _match_experiment_directory( experiment_name, chunk_rep_file, raw_data_dirs @@ -1097,8 +1106,7 @@ def _load_legacy_subjectdata(experiment_name, data_dir, start, end): return subject_data if experiment_name == "social0-r1": - from aeon.dj_pipeline.create_experiments.create_socialexperiment_0 import \ - fixID + from aeon.dj_pipeline.create_experiments.create_socialexperiment_0 import fixID sessdf = subject_data.copy() sessdf = sessdf[~sessdf.id.str.contains("test")] diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index 0d0f8c58..d20ce337 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -1,5 +1,5 @@ -#---- DO NOT MODIFY ---- -#---- THIS FILE IS AUTO-GENERATED BY `streams_maker.py` ---- +# ---- DO NOT MODIFY ---- +# ---- THIS FILE IS AUTO-GENERATED BY `streams_maker.py` ---- from uuid import UUID @@ -13,7 +13,7 @@ schema = dj.Schema(get_schema_name("streams")) -@schema +@schema class StreamType(dj.Lookup): """ Catalog of all steam types for the different device types used across Project Aeon @@ -33,7 +33,7 @@ class StreamType(dj.Lookup): """ -@schema +@schema class DeviceType(dj.Lookup): """ Catalog of all device types used across Project Aeon @@ -52,7 +52,7 @@ class Stream(dj.Part): """ -@schema +@schema class Device(dj.Lookup): definition = """ # Physical devices, of a particular type, identified by unique serial number device_serial_number: varchar(12) @@ -61,9 +61,9 @@ class Device(dj.Lookup): """ -@schema +@schema class UndergroundFeeder(dj.Manual): - definition = f""" + definition = f""" # underground_feeder placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown) -> acquisition.Experiment -> Device @@ -72,25 +72,25 @@ class UndergroundFeeder(dj.Manual): underground_feeder_name : varchar(36) """ - class Attribute(dj.Part): - definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device + class Attribute(dj.Part): + definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device -> master attribute_name : varchar(32) --- attribute_value=null : longblob """ - class RemovalTime(dj.Part): - definition = f""" + class RemovalTime(dj.Part): + definition = f""" -> master --- underground_feeder_removal_time: datetime(6) # time of the underground_feeder being removed """ -@schema +@schema class VideoSource(dj.Manual): - definition = f""" + definition = f""" # video_source placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown) -> acquisition.Experiment -> Device @@ -99,31 +99,460 @@ class VideoSource(dj.Manual): video_source_name : varchar(36) """ - class Attribute(dj.Part): - definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device + class Attribute(dj.Part): + definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device -> master attribute_name : varchar(32) --- attribute_value=null : longblob """ - class RemovalTime(dj.Part): - definition = f""" + class RemovalTime(dj.Part): + definition = f""" -> master --- video_source_removal_time: datetime(6) # time of the video_source being removed """ -@schema -class UndergroundFeederBeamBreak(dj.Imported): - definition = """# Raw per-chunk BeamBreak data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) - -> UndergroundFeeder +@schema +class VideoSourcePosition(dj.Imported): + definition = """# Raw per-chunk Position data stream from VideoSource (auto-generated with aeon_mecha-unknown) + -> VideoSource + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of Position data + """ + _stream_reader = aeon.io.reader.Position + _stream_detail = { + "stream_type": "Position", + "stream_reader": "aeon.io.reader.Position", + "stream_reader_kwargs": {"pattern": "{pattern}_200_*"}, + "stream_description": "", + "stream_hash": UUID("d7727726-1f52-78e1-1355-b863350b6d03"), + } + + @property + def key_source(self): + f""" + Only the combination of Chunk and VideoSource with overlapping time + + Chunk(s) that started after VideoSource install time and ended before VideoSource remove time + + Chunk(s) that started after VideoSource install time for VideoSource that are not yet removed + """ + return ( + acquisition.Chunk * VideoSource.join(VideoSource.RemovalTime, left=True) + & "chunk_start >= video_source_install_time" + & 'chunk_start < IFNULL(video_source_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + + device_name = (VideoSource & key).fetch1("video_source_name") + + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{c: stream_data[c].values for c in stream.columns if not c.startswith("_")}, + }, + ignore_extra_fields=True, + ) + + +@schema +class VideoSourceRegion(dj.Imported): + definition = """# Raw per-chunk Region data stream from VideoSource (auto-generated with aeon_mecha-unknown) + -> VideoSource + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of Region data + """ + _stream_reader = aeon.schema.foraging._RegionReader + _stream_detail = { + "stream_type": "Region", + "stream_reader": "aeon.schema.foraging._RegionReader", + "stream_reader_kwargs": {"pattern": "{pattern}_201_*"}, + "stream_description": "", + "stream_hash": UUID("6c78b3ac-ffff-e2ab-c446-03e3adf4d80a"), + } + + @property + def key_source(self): + f""" + Only the combination of Chunk and VideoSource with overlapping time + + Chunk(s) that started after VideoSource install time and ended before VideoSource remove time + + Chunk(s) that started after VideoSource install time for VideoSource that are not yet removed + """ + return ( + acquisition.Chunk * VideoSource.join(VideoSource.RemovalTime, left=True) + & "chunk_start >= video_source_install_time" + & 'chunk_start < IFNULL(video_source_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + + device_name = (VideoSource & key).fetch1("video_source_name") + + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{c: stream_data[c].values for c in stream.columns if not c.startswith("_")}, + }, + ignore_extra_fields=True, + ) + + +@schema +class VideoSourceVideo(dj.Imported): + definition = """# Raw per-chunk Video data stream from VideoSource (auto-generated with aeon_mecha-unknown) + -> VideoSource -> acquisition.Chunk --- sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of BeamBreak data + timestamps: longblob # (datetime) timestamps of Video data + """ + _stream_reader = aeon.io.reader.Video + _stream_detail = { + "stream_type": "Video", + "stream_reader": "aeon.io.reader.Video", + "stream_reader_kwargs": {"pattern": "{pattern}_*"}, + "stream_description": "", + "stream_hash": UUID("f51c6174-e0c4-a888-3a9d-6f97fb6a019b"), + } + + @property + def key_source(self): + f""" + Only the combination of Chunk and VideoSource with overlapping time + + Chunk(s) that started after VideoSource install time and ended before VideoSource remove time + + Chunk(s) that started after VideoSource install time for VideoSource that are not yet removed + """ + return ( + acquisition.Chunk * VideoSource.join(VideoSource.RemovalTime, left=True) + & "chunk_start >= video_source_install_time" + & 'chunk_start < IFNULL(video_source_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + + device_name = (VideoSource & key).fetch1("video_source_name") + + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{c: stream_data[c].values for c in stream.columns if not c.startswith("_")}, + }, + ignore_extra_fields=True, + ) + + +@schema +class SpinnakerVideoSource(dj.Manual): + definition = f""" + # spinnaker_video_source placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown) + -> acquisition.Experiment + -> Device + spinnaker_video_source_install_time : datetime(6) # time of the spinnaker_video_source placed and started operation at this position + --- + spinnaker_video_source_name : varchar(36) + """ + + class Attribute(dj.Part): + definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device + -> master + attribute_name : varchar(32) + --- + attribute_value=null : longblob + """ + + class RemovalTime(dj.Part): + definition = f""" + -> master + --- + spinnaker_video_source_removal_time: datetime(6) # time of the spinnaker_video_source being removed + """ + + +@schema +class WeightScale(dj.Manual): + definition = f""" + # weight_scale placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown) + -> acquisition.Experiment + -> Device + weight_scale_install_time : datetime(6) # time of the weight_scale placed and started operation at this position + --- + weight_scale_name : varchar(36) + """ + + class Attribute(dj.Part): + definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device + -> master + attribute_name : varchar(32) + --- + attribute_value=null : longblob + """ + + class RemovalTime(dj.Part): + definition = f""" + -> master + --- + weight_scale_removal_time: datetime(6) # time of the weight_scale being removed + """ +@schema +class SpinnakerVideoSourcePosition(dj.Imported): + definition = """ # Raw per-chunk Position data stream from SpinnakerVideoSource (auto-generated with aeon_mecha-unknown) + -> SpinnakerVideoSource + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of Position data + x: longblob + y: longblob + angle: longblob + major: longblob + minor: longblob + area: longblob + id: longblob + """ + _stream_reader = aeon.io.reader.Position + _stream_detail = {'stream_type': 'Position', 'stream_reader': 'aeon.io.reader.Position', 'stream_reader_kwargs': {'pattern': '{pattern}_200_*'}, 'stream_description': '', 'stream_hash': UUID('d7727726-1f52-78e1-1355-b863350b6d03')} + + @property + def key_source(self): + f""" + Only the combination of Chunk and SpinnakerVideoSource with overlapping time + + Chunk(s) that started after SpinnakerVideoSource install time and ended before SpinnakerVideoSource remove time + + Chunk(s) that started after SpinnakerVideoSource install time for SpinnakerVideoSource that are not yet removed + """ + return ( + acquisition.Chunk * SpinnakerVideoSource.join(SpinnakerVideoSource.RemovalTime, left=True) + & 'chunk_start >= spinnaker_video_source_install_time' + & 'chunk_start < IFNULL(spinnaker_video_source_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + + device_name = (SpinnakerVideoSource & key).fetch1('spinnaker_video_source_name') + + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{c: stream_data[c].values for c in stream.columns if not c.startswith("_")}, + }, + ignore_extra_fields=True, + ) + + +@schema +class SpinnakerVideoSourceRegion(dj.Imported): + definition = """ # Raw per-chunk Region data stream from SpinnakerVideoSource (auto-generated with aeon_mecha-unknown) + -> SpinnakerVideoSource + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of Region data + region: longblob + """ + _stream_reader = aeon.schema.foraging._RegionReader + _stream_detail = {'stream_type': 'Region', 'stream_reader': 'aeon.schema.foraging._RegionReader', 'stream_reader_kwargs': {'pattern': '{pattern}_201_*'}, 'stream_description': '', 'stream_hash': UUID('6c78b3ac-ffff-e2ab-c446-03e3adf4d80a')} + + @property + def key_source(self): + f""" + Only the combination of Chunk and SpinnakerVideoSource with overlapping time + + Chunk(s) that started after SpinnakerVideoSource install time and ended before SpinnakerVideoSource remove time + + Chunk(s) that started after SpinnakerVideoSource install time for SpinnakerVideoSource that are not yet removed """ + return ( + acquisition.Chunk * SpinnakerVideoSource.join(SpinnakerVideoSource.RemovalTime, left=True) + & 'chunk_start >= spinnaker_video_source_install_time' + & 'chunk_start < IFNULL(spinnaker_video_source_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + + device_name = (SpinnakerVideoSource & key).fetch1('spinnaker_video_source_name') + + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{c: stream_data[c].values for c in stream.columns if not c.startswith("_")}, + }, + ignore_extra_fields=True, + ) + + +@schema +class SpinnakerVideoSourceVideo(dj.Imported): + definition = """ # Raw per-chunk Video data stream from SpinnakerVideoSource (auto-generated with aeon_mecha-unknown) + -> SpinnakerVideoSource + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of Video data + hw_counter: longblob + hw_timestamp: longblob + """ + _stream_reader = aeon.io.reader.Video + _stream_detail = {'stream_type': 'Video', 'stream_reader': 'aeon.io.reader.Video', 'stream_reader_kwargs': {'pattern': '{pattern}_*'}, 'stream_description': '', 'stream_hash': UUID('f51c6174-e0c4-a888-3a9d-6f97fb6a019b')} + + @property + def key_source(self): + f""" + Only the combination of Chunk and SpinnakerVideoSource with overlapping time + + Chunk(s) that started after SpinnakerVideoSource install time and ended before SpinnakerVideoSource remove time + + Chunk(s) that started after SpinnakerVideoSource install time for SpinnakerVideoSource that are not yet removed + """ + return ( + acquisition.Chunk * SpinnakerVideoSource.join(SpinnakerVideoSource.RemovalTime, left=True) + & 'chunk_start >= spinnaker_video_source_install_time' + & 'chunk_start < IFNULL(spinnaker_video_source_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + + device_name = (SpinnakerVideoSource & key).fetch1('spinnaker_video_source_name') + + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{c: stream_data[c].values for c in stream.columns if not c.startswith("_")}, + }, + ignore_extra_fields=True, + ) + + +@schema +class UndergroundFeederBeamBreak(dj.Imported): + definition = """ # Raw per-chunk BeamBreak data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + -> UndergroundFeeder + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of BeamBreak data + event: longblob + """ _stream_reader = aeon.io.reader.BitmaskEvent _stream_detail = {'stream_type': 'BeamBreak', 'stream_reader': 'aeon.io.reader.BitmaskEvent', 'stream_reader_kwargs': {'pattern': '{pattern}_32', 'value': 34, 'tag': 'BeamBroken'}, 'stream_description': '', 'stream_hash': UUID('b14171e6-d27d-117a-ae73-a16c4b5fc8a2')} @@ -135,8 +564,7 @@ def key_source(self): + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed """ return ( - acquisition.Chunk - * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) + acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) & 'chunk_start >= underground_feeder_install_time' & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' ) @@ -145,13 +573,9 @@ def make(self, key): chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( "chunk_start", "chunk_end", "directory_type" ) - raw_data_dir = acquisition.Experiment.get_data_directory( - key, directory_type=dir_type - ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - device_name = (UndergroundFeeder & key).fetch1( - 'underground_feeder_name' - ) + device_name = (UndergroundFeeder & key).fetch1('underground_feeder_name') stream = self._stream_reader( **{ @@ -172,24 +596,22 @@ def make(self, key): **key, "sample_count": len(stream_data), "timestamps": stream_data.index.values, - **{ - c: stream_data[c].values - for c in stream.columns - if not c.startswith("_") - }, - }, ignore_extra_fields=True + **{c: stream_data[c].values for c in stream.columns if not c.startswith("_")}, + }, + ignore_extra_fields=True, ) @schema class UndergroundFeederDeliverPellet(dj.Imported): - definition = """# Raw per-chunk DeliverPellet data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) - -> UndergroundFeeder - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of DeliverPellet data - """ + definition = """ # Raw per-chunk DeliverPellet data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + -> UndergroundFeeder + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of DeliverPellet data + event: longblob + """ _stream_reader = aeon.io.reader.BitmaskEvent _stream_detail = {'stream_type': 'DeliverPellet', 'stream_reader': 'aeon.io.reader.BitmaskEvent', 'stream_reader_kwargs': {'pattern': '{pattern}_35', 'value': 1, 'tag': 'TriggeredPellet'}, 'stream_description': '', 'stream_hash': UUID('c49dda51-2e38-8b49-d1d8-2e54ea928e9c')} @@ -201,8 +623,7 @@ def key_source(self): + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed """ return ( - acquisition.Chunk - * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) + acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) & 'chunk_start >= underground_feeder_install_time' & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' ) @@ -211,13 +632,9 @@ def make(self, key): chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( "chunk_start", "chunk_end", "directory_type" ) - raw_data_dir = acquisition.Experiment.get_data_directory( - key, directory_type=dir_type - ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - device_name = (UndergroundFeeder & key).fetch1( - 'underground_feeder_name' - ) + device_name = (UndergroundFeeder & key).fetch1('underground_feeder_name') stream = self._stream_reader( **{ @@ -238,24 +655,24 @@ def make(self, key): **key, "sample_count": len(stream_data), "timestamps": stream_data.index.values, - **{ - c: stream_data[c].values - for c in stream.columns - if not c.startswith("_") - }, - }, ignore_extra_fields=True + **{c: stream_data[c].values for c in stream.columns if not c.startswith("_")}, + }, + ignore_extra_fields=True, ) @schema class UndergroundFeederDepletionState(dj.Imported): - definition = """# Raw per-chunk DepletionState data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) - -> UndergroundFeeder - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of DepletionState data - """ + definition = """ # Raw per-chunk DepletionState data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + -> UndergroundFeeder + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of DepletionState data + threshold: longblob + d1: longblob + delta: longblob + """ _stream_reader = aeon.schema.foraging._PatchState _stream_detail = {'stream_type': 'DepletionState', 'stream_reader': 'aeon.schema.foraging._PatchState', 'stream_reader_kwargs': {'pattern': '{pattern}_State_*'}, 'stream_description': '', 'stream_hash': UUID('17c3e36f-3f2e-2494-bbd3-5cb9a23d3039')} @@ -267,8 +684,7 @@ def key_source(self): + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed """ return ( - acquisition.Chunk - * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) + acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) & 'chunk_start >= underground_feeder_install_time' & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' ) @@ -277,13 +693,9 @@ def make(self, key): chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( "chunk_start", "chunk_end", "directory_type" ) - raw_data_dir = acquisition.Experiment.get_data_directory( - key, directory_type=dir_type - ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - device_name = (UndergroundFeeder & key).fetch1( - 'underground_feeder_name' - ) + device_name = (UndergroundFeeder & key).fetch1('underground_feeder_name') stream = self._stream_reader( **{ @@ -304,24 +716,23 @@ def make(self, key): **key, "sample_count": len(stream_data), "timestamps": stream_data.index.values, - **{ - c: stream_data[c].values - for c in stream.columns - if not c.startswith("_") - }, - }, ignore_extra_fields=True + **{c: stream_data[c].values for c in stream.columns if not c.startswith("_")}, + }, + ignore_extra_fields=True, ) @schema class UndergroundFeederEncoder(dj.Imported): - definition = """# Raw per-chunk Encoder data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) - -> UndergroundFeeder - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of Encoder data - """ + definition = """ # Raw per-chunk Encoder data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + -> UndergroundFeeder + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of Encoder data + angle: longblob + intensity: longblob + """ _stream_reader = aeon.io.reader.Encoder _stream_detail = {'stream_type': 'Encoder', 'stream_reader': 'aeon.io.reader.Encoder', 'stream_reader_kwargs': {'pattern': '{pattern}_90_*'}, 'stream_description': '', 'stream_hash': UUID('f96b0b26-26f6-5ff6-b3c7-5aa5adc00c1a')} @@ -333,8 +744,7 @@ def key_source(self): + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed """ return ( - acquisition.Chunk - * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) + acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) & 'chunk_start >= underground_feeder_install_time' & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' ) @@ -343,13 +753,9 @@ def make(self, key): chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( "chunk_start", "chunk_end", "directory_type" ) - raw_data_dir = acquisition.Experiment.get_data_directory( - key, directory_type=dir_type - ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - device_name = (UndergroundFeeder & key).fetch1( - 'underground_feeder_name' - ) + device_name = (UndergroundFeeder & key).fetch1('underground_feeder_name') stream = self._stream_reader( **{ @@ -370,52 +776,46 @@ def make(self, key): **key, "sample_count": len(stream_data), "timestamps": stream_data.index.values, - **{ - c: stream_data[c].values - for c in stream.columns - if not c.startswith("_") - }, - }, ignore_extra_fields=True + **{c: stream_data[c].values for c in stream.columns if not c.startswith("_")}, + }, + ignore_extra_fields=True, ) @schema -class VideoSourcePosition(dj.Imported): - definition = """# Raw per-chunk Position data stream from VideoSource (auto-generated with aeon_mecha-unknown) - -> VideoSource - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of Position data - """ - _stream_reader = aeon.io.reader.Position - _stream_detail = {'stream_type': 'Position', 'stream_reader': 'aeon.io.reader.Position', 'stream_reader_kwargs': {'pattern': '{pattern}_200_*'}, 'stream_description': '', 'stream_hash': UUID('d7727726-1f52-78e1-1355-b863350b6d03')} +class WeightScaleWeightFiltered(dj.Imported): + definition = """ # Raw per-chunk WeightFiltered data stream from WeightScale (auto-generated with aeon_mecha-unknown) + -> WeightScale + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of WeightFiltered data + value: longblob + stable: longblob + """ + _stream_reader = aeon.schema.foraging._Weight + _stream_detail = {'stream_type': 'WeightFiltered', 'stream_reader': 'aeon.schema.foraging._Weight', 'stream_reader_kwargs': {'pattern': '{pattern}_202_*'}, 'stream_description': '', 'stream_hash': UUID('912fe1d4-991c-54a8-a7a7-5b96f5ffca91')} @property def key_source(self): f""" - Only the combination of Chunk and VideoSource with overlapping time - + Chunk(s) that started after VideoSource install time and ended before VideoSource remove time - + Chunk(s) that started after VideoSource install time for VideoSource that are not yet removed + Only the combination of Chunk and WeightScale with overlapping time + + Chunk(s) that started after WeightScale install time and ended before WeightScale remove time + + Chunk(s) that started after WeightScale install time for WeightScale that are not yet removed """ return ( - acquisition.Chunk - * VideoSource.join(VideoSource.RemovalTime, left=True) - & 'chunk_start >= video_source_install_time' - & 'chunk_start < IFNULL(video_source_removal_time, "2200-01-01")' + acquisition.Chunk * WeightScale.join(WeightScale.RemovalTime, left=True) + & 'chunk_start >= weight_scale_install_time' + & 'chunk_start < IFNULL(weight_scale_removal_time, "2200-01-01")' ) def make(self, key): chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( "chunk_start", "chunk_end", "directory_type" ) - raw_data_dir = acquisition.Experiment.get_data_directory( - key, directory_type=dir_type - ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - device_name = (VideoSource & key).fetch1( - 'video_source_name' - ) + device_name = (WeightScale & key).fetch1('weight_scale_name') stream = self._stream_reader( **{ @@ -436,52 +836,46 @@ def make(self, key): **key, "sample_count": len(stream_data), "timestamps": stream_data.index.values, - **{ - c: stream_data[c].values - for c in stream.columns - if not c.startswith("_") - }, - }, ignore_extra_fields=True + **{c: stream_data[c].values for c in stream.columns if not c.startswith("_")}, + }, + ignore_extra_fields=True, ) @schema -class VideoSourceRegion(dj.Imported): - definition = """# Raw per-chunk Region data stream from VideoSource (auto-generated with aeon_mecha-unknown) - -> VideoSource - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of Region data - """ - _stream_reader = aeon.schema.foraging._RegionReader - _stream_detail = {'stream_type': 'Region', 'stream_reader': 'aeon.schema.foraging._RegionReader', 'stream_reader_kwargs': {'pattern': '{pattern}_201_*'}, 'stream_description': '', 'stream_hash': UUID('6c78b3ac-ffff-e2ab-c446-03e3adf4d80a')} +class WeightScaleWeightRaw(dj.Imported): + definition = """ # Raw per-chunk WeightRaw data stream from WeightScale (auto-generated with aeon_mecha-unknown) + -> WeightScale + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of WeightRaw data + value: longblob + stable: longblob + """ + _stream_reader = aeon.schema.foraging._Weight + _stream_detail = {'stream_type': 'WeightRaw', 'stream_reader': 'aeon.schema.foraging._Weight', 'stream_reader_kwargs': {'pattern': '{pattern}_200_*'}, 'stream_description': '', 'stream_hash': UUID('f5ab9451-7104-2daf-0aa2-d16abb3974ad')} @property def key_source(self): f""" - Only the combination of Chunk and VideoSource with overlapping time - + Chunk(s) that started after VideoSource install time and ended before VideoSource remove time - + Chunk(s) that started after VideoSource install time for VideoSource that are not yet removed + Only the combination of Chunk and WeightScale with overlapping time + + Chunk(s) that started after WeightScale install time and ended before WeightScale remove time + + Chunk(s) that started after WeightScale install time for WeightScale that are not yet removed """ return ( - acquisition.Chunk - * VideoSource.join(VideoSource.RemovalTime, left=True) - & 'chunk_start >= video_source_install_time' - & 'chunk_start < IFNULL(video_source_removal_time, "2200-01-01")' + acquisition.Chunk * WeightScale.join(WeightScale.RemovalTime, left=True) + & 'chunk_start >= weight_scale_install_time' + & 'chunk_start < IFNULL(weight_scale_removal_time, "2200-01-01")' ) def make(self, key): chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( "chunk_start", "chunk_end", "directory_type" ) - raw_data_dir = acquisition.Experiment.get_data_directory( - key, directory_type=dir_type - ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - device_name = (VideoSource & key).fetch1( - 'video_source_name' - ) + device_name = (WeightScale & key).fetch1('weight_scale_name') stream = self._stream_reader( **{ @@ -502,52 +896,46 @@ def make(self, key): **key, "sample_count": len(stream_data), "timestamps": stream_data.index.values, - **{ - c: stream_data[c].values - for c in stream.columns - if not c.startswith("_") - }, - }, ignore_extra_fields=True + **{c: stream_data[c].values for c in stream.columns if not c.startswith("_")}, + }, + ignore_extra_fields=True, ) @schema -class VideoSourceVideo(dj.Imported): - definition = """# Raw per-chunk Video data stream from VideoSource (auto-generated with aeon_mecha-unknown) - -> VideoSource - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of Video data - """ - _stream_reader = aeon.io.reader.Video - _stream_detail = {'stream_type': 'Video', 'stream_reader': 'aeon.io.reader.Video', 'stream_reader_kwargs': {'pattern': '{pattern}_*'}, 'stream_description': '', 'stream_hash': UUID('f51c6174-e0c4-a888-3a9d-6f97fb6a019b')} +class WeightScaleWeightSubject(dj.Imported): + definition = """ # Raw per-chunk WeightSubject data stream from WeightScale (auto-generated with aeon_mecha-unknown) + -> WeightScale + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of WeightSubject data + value: longblob + stable: longblob + """ + _stream_reader = aeon.schema.foraging._Weight + _stream_detail = {'stream_type': 'WeightSubject', 'stream_reader': 'aeon.schema.foraging._Weight', 'stream_reader_kwargs': {'pattern': '{pattern}_204_*'}, 'stream_description': '', 'stream_hash': UUID('01f51299-5709-9e03-b415-3d92a6499202')} @property def key_source(self): f""" - Only the combination of Chunk and VideoSource with overlapping time - + Chunk(s) that started after VideoSource install time and ended before VideoSource remove time - + Chunk(s) that started after VideoSource install time for VideoSource that are not yet removed + Only the combination of Chunk and WeightScale with overlapping time + + Chunk(s) that started after WeightScale install time and ended before WeightScale remove time + + Chunk(s) that started after WeightScale install time for WeightScale that are not yet removed """ return ( - acquisition.Chunk - * VideoSource.join(VideoSource.RemovalTime, left=True) - & 'chunk_start >= video_source_install_time' - & 'chunk_start < IFNULL(video_source_removal_time, "2200-01-01")' + acquisition.Chunk * WeightScale.join(WeightScale.RemovalTime, left=True) + & 'chunk_start >= weight_scale_install_time' + & 'chunk_start < IFNULL(weight_scale_removal_time, "2200-01-01")' ) def make(self, key): chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( "chunk_start", "chunk_end", "directory_type" ) - raw_data_dir = acquisition.Experiment.get_data_directory( - key, directory_type=dir_type - ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - device_name = (VideoSource & key).fetch1( - 'video_source_name' - ) + device_name = (WeightScale & key).fetch1('weight_scale_name') stream = self._stream_reader( **{ @@ -568,12 +956,9 @@ def make(self, key): **key, "sample_count": len(stream_data), "timestamps": stream_data.index.values, - **{ - c: stream_data[c].values - for c in stream.columns - if not c.startswith("_") - }, - }, ignore_extra_fields=True + **{c: stream_data[c].values for c in stream.columns if not c.startswith("_")}, + }, + ignore_extra_fields=True, ) diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 22ec4478..7575611d 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -57,12 +57,12 @@ def insert_stream_types(): streams.StreamType.insert(stream_entries, skip_duplicates=True) -def insert_device_types(schema: DotMap, metadata_yml_filepath: Path): +def insert_device_types(device_schema: DotMap, metadata_yml_filepath: Path): """Use dataset.schema and metadata.yml to insert into streams.DeviceType and streams.Device. Only insert device types that were defined both in the device schema (e.g., exp02) and Metadata.yml. It then creates new device tables under streams schema.""" streams = dj.VirtualModule("streams", streams_maker.schema_name) - device_info: dict[dict] = get_device_info(schema) - device_type_mapper, device_sn = get_device_mapper(schema, metadata_yml_filepath) + device_info: dict[dict] = get_device_info(device_schema) + device_type_mapper, device_sn = get_device_mapper(device_schema, metadata_yml_filepath) # Add device type to device_info. Only add if device types that are defined in Metadata.yml device_info = { @@ -192,10 +192,11 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): # if identical commit -> no changes return - schema = acquisition._device_schema_mapping[experiment_name] - device_type_mapper, _ = get_device_mapper(schema, metadata_yml_filepath) + device_schema = acquisition._device_schema_mapping[experiment_name] + device_type_mapper, _ = get_device_mapper(device_schema, metadata_yml_filepath) # Insert into each device table + epoch_device_types = [] device_list = [] device_removal_list = [] @@ -204,7 +205,16 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): device_sn = device_config.get("SerialNumber", device_config.get("PortName")) device_key = {"device_serial_number": device_sn} + if not (streams.Device & device_key): + logger.warning( + f"Device {device_name} (serial number: {device_sn}) is not yet registered in streams.Device. Skipping..." + ) + # skip if this device (with a serial number) is not yet inserted in streams.Device + continue + device_list.append(device_key) + epoch_device_types.append(table.__name__) + table_entry = { "experiment_name": experiment_name, **device_key, @@ -221,7 +231,7 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): for attribute_name, attribute_value in device_config.items() ] - """Check if this camera is currently installed. If the same camera serial number is currently installed check for any changes in configuration. If not, skip this""" + """Check if this device is currently installed. If the same device serial number is currently installed check for any changes in configuration. If not, skip this""" current_device_query = table - table.RemovalTime & experiment_key & device_key if current_device_query: @@ -256,6 +266,7 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): ], } ) + epoch_device_types.remove(table.__name__) # Insert into table. table.insert1(table_entry, skip_duplicates=True) @@ -277,6 +288,8 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): if device_removal(device_type, device_entry): table.RemovalTime.insert1(device_entry) + return set(epoch_device_types) + # region Get stream & device information def get_stream_entries(schema: DotMap) -> list[dict]: diff --git a/aeon/schema/dataset.py b/aeon/schema/dataset.py index 17ccdb59..9012bc37 100644 --- a/aeon/schema/dataset.py +++ b/aeon/schema/dataset.py @@ -60,15 +60,7 @@ ) social01 = exp02 -social01.Patch1.BeamBreak = reader.BitmaskEvent( - pattern="Patch1_32", value=0x22, tag="BeamBroken" -) -social01.Patch2.BeamBreak = reader.BitmaskEvent( - pattern="Patch2_32", value=0x22, tag="BeamBroken" -) -social01.Patch1.DeliverPellet = reader.BitmaskEvent( - pattern="Patch1_35", value=0x1, tag="TriggeredPellet" -) -social01.Patch2.DeliverPellet = reader.BitmaskEvent( - pattern="Patch2_35", value=0x1, tag="TriggeredPellet" -) +social01.Patch1.BeamBreak = reader.BitmaskEvent(pattern="Patch1_32", value=0x22, tag="BeamBroken") +social01.Patch2.BeamBreak = reader.BitmaskEvent(pattern="Patch2_32", value=0x22, tag="BeamBroken") +social01.Patch1.DeliverPellet = reader.BitmaskEvent(pattern="Patch1_35", value=0x1, tag="TriggeredPellet") +social01.Patch2.DeliverPellet = reader.BitmaskEvent(pattern="Patch2_35", value=0x1, tag="TriggeredPellet") From 72d0458b93b123a040d58d433991c520532b2ad3 Mon Sep 17 00:00:00 2001 From: Jai Date: Fri, 1 Dec 2023 21:34:34 +0000 Subject: [PATCH 347/489] Ensure data filtered by bitmask --- aeon/io/reader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/io/reader.py b/aeon/io/reader.py index 67570608..14d0f700 100644 --- a/aeon/io/reader.py +++ b/aeon/io/reader.py @@ -212,7 +212,7 @@ def read(self, file): specified unique identifier. """ data = super().read(file) - data = data[data.event & self.value > 0] + data = data[data.event == self.value] data["event"] = self.tag return data From f20c24837aab4ecfc8fc0d1d73991040faafd326 Mon Sep 17 00:00:00 2001 From: Jai Date: Fri, 1 Dec 2023 22:54:18 +0000 Subject: [PATCH 348/489] Fix for #290 --- aeon/io/reader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/io/reader.py b/aeon/io/reader.py index 14d0f700..035e22a3 100644 --- a/aeon/io/reader.py +++ b/aeon/io/reader.py @@ -212,7 +212,7 @@ def read(self, file): specified unique identifier. """ data = super().read(file) - data = data[data.event == self.value] + data = data[(data.event & self.value) == self.value] data["event"] = self.tag return data From f8349c0ed08d2e99dfbd38a0399313cfd91b5d20 Mon Sep 17 00:00:00 2001 From: Jai Date: Mon, 4 Dec 2023 17:01:33 +0000 Subject: [PATCH 349/489] Changed 'dataset' name to 'schemas' and refactored --- aeon/dj_pipeline/acquisition.py | 2 +- aeon/schema/{dataset.py => schemas.py} | 55 ++++++++++++++++++++++++++ tests/io/test_api.py | 2 +- 3 files changed, 57 insertions(+), 2 deletions(-) rename aeon/schema/{dataset.py => schemas.py} (56%) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 4c9df4af..916dda23 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -5,7 +5,7 @@ import pandas as pd from aeon.io import api as io_api -from aeon.schema import dataset as aeon_schema +from aeon.schema import schemas as aeon_schema from aeon.io import reader as io_reader from aeon.analysis import utils as analysis_utils diff --git a/aeon/schema/dataset.py b/aeon/schema/schemas.py similarity index 56% rename from aeon/schema/dataset.py rename to aeon/schema/schemas.py index b9586de4..8a3dc333 100644 --- a/aeon/schema/dataset.py +++ b/aeon/schema/schemas.py @@ -57,3 +57,58 @@ Device("Wall8", octagon.Wall), ] ) + +# All recorded social01 streams: + +# *Note* regiser 8 is always the harp heartbeat for any device that has this stream. + +# - Metadata.yml +# - Environment_BlockState +# - Environment_EnvironmentState +# - Environment_LightEvents +# - Environment_MessageLog +# - Environment_SubjectState +# - Environment_SubjectVisits +# - Environment_SubjectWeight +# - CameraTop (200, 201, avi, csv, ,) +# - 200: position +# - 201: region +# - CameraNorth (avi, csv) +# - CameraEast (avi, csv) +# - CameraSouth (avi, csv) +# - CameraWest (avi, csv) +# - CameraPatch1 (avi, csv) +# - CameraPatch2 (avi, csv) +# - CameraPatch3 (avi, csv) +# - CameraNest (avi, csv) +# - ClockSynchronizer (8, 36) +# - 36: +# - Nest (200, 201, 202, 203) +# - 200: weight_raw +# - 201: weight_tare +# - 202: weight_filtered +# - 203: weight_baseline +# - 204: weight_subject +# - Patch1 (8, 32, 35, 36, 87, 90, 91, 200, 201, 202, 203, State) +# - 32: beam_break +# - 35: delivery_set +# - 36: delivery_clear +# - 87: expansion_board +# - 90: enocder_read +# - 91: encoder_mode +# - 200: dispenser_state +# - 201: delivery_manual +# - 202: missed_pellet +# - 203: delivery_retry +# - Patch2 (8, 32, 35, 36, 87, 90, 91, State) +# - Patch3 (8, 32, 35, 36, 87, 90, 91, 200, 203, State) +# - RfidEventsGate (8, 32, 35) +# - 32: entry_id +# - 35: hardware_notifications +# - RfidEventsNest1 (8, 32, 35) +# - RfidEventsNest2 (8, 32, 35) +# - RfidEventsPatch1 (8, 32, 35) +# - RfidEventsPatch2 (8, 32, 35) +# - RfidEventsPatch3 (8, 32, 35) +# - VideoController (8, 32, 33, 34, 35, 36, 45, 52) +# - 32: frame_number \ No newline at end of file diff --git a/tests/io/test_api.py b/tests/io/test_api.py index 48986830..8f9d8c0b 100644 --- a/tests/io/test_api.py +++ b/tests/io/test_api.py @@ -5,7 +5,7 @@ from pytest import mark import aeon -from aeon.schema.dataset import exp02 +from aeon.schema.schemas import exp02 nonmonotonic_path = Path(__file__).parent.parent / "data" / "nonmonotonic" monotonic_path = Path(__file__).parent.parent / "data" / "monotonic" From 38d46c1dd98ee4525d5bee5e1550b3f9bce6b5d5 Mon Sep 17 00:00:00 2001 From: Jai Date: Mon, 4 Dec 2023 17:04:54 +0000 Subject: [PATCH 350/489] Added notebook showing finding harp event bitmasks --- .../get_harp_stream_event_bitmask.ipynb | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/examples/get_harp_stream_event_bitmask.ipynb diff --git a/docs/examples/get_harp_stream_event_bitmask.ipynb b/docs/examples/get_harp_stream_event_bitmask.ipynb new file mode 100644 index 00000000..ffbeb6ef --- /dev/null +++ b/docs/examples/get_harp_stream_event_bitmask.ipynb @@ -0,0 +1,123 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"Jupyter settings and Imports\"\"\"\n", + "\n", + "%load_ext autoreload\n", + "%autoreload 2\n", + "\n", + "from pathlib import Path\n", + "\n", + "import aeon.io.api as api\n", + "from aeon.io import reader" + ] + }, + { + "cell_type": "code", + "execution_count": 44, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "data:\n", + " beambreak\n", + "time \n", + "2023-06-21 10:01:16.633728027 34\n", + "2023-06-21 10:01:16.649184227 32\n", + "2023-06-21 10:01:28.314400196 34\n", + "2023-06-21 10:01:28.331103802 32\n", + "2023-06-21 10:01:38.428864002 34\n", + "... ...\n", + "2023-06-21 11:16:43.647552013 32\n", + "2023-06-21 11:16:43.655648232 34\n", + "2023-06-21 11:16:43.674079895 32\n", + "2023-06-21 11:21:40.381728172 34\n", + "2023-06-21 11:21:40.397024155 32\n", + "\n", + "[196 rows x 1 columns]\n", + "\n", + "\n", + "bitmask:\n", + " 34\n", + "\n", + "\n", + "stream_data:\n", + " event\n", + "time \n", + "2023-06-21 10:01:16.633728027 beambreak\n", + "2023-06-21 10:01:28.314400196 beambreak\n", + "2023-06-21 10:01:38.428864002 beambreak\n", + "2023-06-21 10:01:53.453343868 beambreak\n", + "2023-06-21 10:04:14.685791969 beambreak\n", + "... ...\n", + "2023-06-21 11:15:20.406752110 beambreak\n", + "2023-06-21 11:16:24.036767960 beambreak\n", + "2023-06-21 11:16:43.625472069 beambreak\n", + "2023-06-21 11:16:43.655648232 beambreak\n", + "2023-06-21 11:21:40.381728172 beambreak\n", + "\n", + "[98 rows x 1 columns]\n" + ] + } + ], + "source": [ + "\"\"\"How to find the bitmask associated with any harp stream event and create a new reader: \n", + "example with patch beambreak\"\"\"\n", + "\n", + "# Ensure you have the pattern of the stream (look at the filename), and the expected event name\n", + "pattern = \"Patch1_32*\"\n", + "event_name = \"beambreak\"\n", + "# Set the reader for the stream\n", + "harp_reader = reader.Harp(pattern=pattern, columns=[event_name])\n", + "# Set the root dir and a time range in which you know the stream acquired data\n", + "root = Path(\"/ceph/aeon/aeon/data/raw/AEON3/presocial0.1\")\n", + "start = pd.Timestamp(\"2023-06-21 10:00:00\")\n", + "end = pd.Timestamp(\"2023-06-21 12:00:10\")\n", + "# Get the bitmask as the first value of the loaded stream\n", + "data = api.load(root, harp_reader, start=start, end=end)\n", + "bitmask = data.iloc[0, 0]\n", + "new_reader = reader.BitmaskEvent(pattern, bitmask, event_name)\n", + "stream_data = api.load(root, new_reader, start=start, end=end)\n", + "\n", + "print(f\"data:\\n {data}\\n\\n\")\n", + "print(f\"bitmask:\\n {bitmask}\\n\\n\")\n", + "print(f\"stream_data:\\n {stream_data}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "aeon", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.4" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} From 967f32258092eec4bdb3e7e19b94a59f32106489 Mon Sep 17 00:00:00 2001 From: Jai Date: Mon, 4 Dec 2023 17:31:42 +0000 Subject: [PATCH 351/489] Refactored according to #293 --- aeon/dj_pipeline/acquisition.py | 2 +- aeon/{schema => io/binder}/__init__.py | 0 aeon/{schema => io/binder}/core.py | 0 aeon/{schema => io/binder}/foraging.py | 2 +- aeon/{schema => io/binder}/octagon.py | 0 aeon/{schema => io/binder}/schemas.py | 4 ++-- aeon/{schema => io/binder}/social.py | 0 aeon/io/device.py | 16 ++++++++-------- tests/io/test_api.py | 2 +- 9 files changed, 13 insertions(+), 13 deletions(-) rename aeon/{schema => io/binder}/__init__.py (100%) rename aeon/{schema => io/binder}/core.py (100%) rename aeon/{schema => io/binder}/foraging.py (98%) rename aeon/{schema => io/binder}/octagon.py (100%) rename aeon/{schema => io/binder}/schemas.py (97%) rename aeon/{schema => io/binder}/social.py (100%) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 916dda23..64842d65 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -5,7 +5,7 @@ import pandas as pd from aeon.io import api as io_api -from aeon.schema import schemas as aeon_schema +from aeon.io.binder import schemas as aeon_schema from aeon.io import reader as io_reader from aeon.analysis import utils as analysis_utils diff --git a/aeon/schema/__init__.py b/aeon/io/binder/__init__.py similarity index 100% rename from aeon/schema/__init__.py rename to aeon/io/binder/__init__.py diff --git a/aeon/schema/core.py b/aeon/io/binder/core.py similarity index 100% rename from aeon/schema/core.py rename to aeon/io/binder/core.py diff --git a/aeon/schema/foraging.py b/aeon/io/binder/foraging.py similarity index 98% rename from aeon/schema/foraging.py rename to aeon/io/binder/foraging.py index ffd8fdd9..9267dc77 100644 --- a/aeon/schema/foraging.py +++ b/aeon/io/binder/foraging.py @@ -4,7 +4,7 @@ import aeon.io.device as _device import aeon.io.reader as _reader -import aeon.schema.core as _stream +import aeon.io.binder.core as _stream class Area(_Enum): diff --git a/aeon/schema/octagon.py b/aeon/io/binder/octagon.py similarity index 100% rename from aeon/schema/octagon.py rename to aeon/io/binder/octagon.py diff --git a/aeon/schema/schemas.py b/aeon/io/binder/schemas.py similarity index 97% rename from aeon/schema/schemas.py rename to aeon/io/binder/schemas.py index 8a3dc333..782767e4 100644 --- a/aeon/schema/schemas.py +++ b/aeon/io/binder/schemas.py @@ -1,8 +1,8 @@ from dotmap import DotMap -import aeon.schema.core as stream +import aeon.io.binder.core as stream from aeon.io.device import Device -from aeon.schema import foraging, octagon +from aeon.io.binder import foraging, octagon exp02 = DotMap( [ diff --git a/aeon/schema/social.py b/aeon/io/binder/social.py similarity index 100% rename from aeon/schema/social.py rename to aeon/io/binder/social.py diff --git a/aeon/io/device.py b/aeon/io/device.py index 1a4916e6..23018aaf 100644 --- a/aeon/io/device.py +++ b/aeon/io/device.py @@ -3,16 +3,16 @@ def compositeStream(pattern, *args): """Merges multiple data streams into a single composite stream.""" - composite = {} + registry = {} if args: for stream in args: if inspect.isclass(stream): for method in vars(stream).values(): if isinstance(method, staticmethod): - composite.update(method.__func__(pattern)) + registry.update(method.__func__(pattern)) else: - composite.update(stream(pattern)) - return composite + registry.update(stream(pattern)) + return registry class Device: @@ -31,11 +31,11 @@ class Device: def __init__(self, name, *args, pattern=None): self.name = name - self.stream = compositeStream(name if pattern is None else pattern, *args) + self.registry = compositeStream(name if pattern is None else pattern, *args) def __iter__(self): - if len(self.stream) == 1: - singleton = self.stream.get(self.name, None) + if len(self.registry) == 1: + singleton = self.registry.get(self.name, None) if singleton: return iter((self.name, singleton)) - return iter((self.name, self.stream)) + return iter((self.name, self.registry)) diff --git a/tests/io/test_api.py b/tests/io/test_api.py index 8f9d8c0b..486f1d3f 100644 --- a/tests/io/test_api.py +++ b/tests/io/test_api.py @@ -5,7 +5,7 @@ from pytest import mark import aeon -from aeon.schema.schemas import exp02 +from aeon.io.binder.schemas import exp02 nonmonotonic_path = Path(__file__).parent.parent / "data" / "nonmonotonic" monotonic_path = Path(__file__).parent.parent / "data" / "monotonic" From fe3a7990ba21b1a74faf36db823d4c320fbdb4cd Mon Sep 17 00:00:00 2001 From: Jai Date: Tue, 5 Dec 2023 17:04:54 +0000 Subject: [PATCH 352/489] Fixed #294 --- aeon/io/api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/aeon/io/api.py b/aeon/io/api.py index 2b9bc745..5c16159f 100644 --- a/aeon/io/api.py +++ b/aeon/io/api.py @@ -115,7 +115,8 @@ def load(root, reader, start=None, end=None, time=None, tolerance=None, epoch=No # to fill missing values previous = reader.read(files[i - 1]) data = pd.concat([previous, frame]) - data = data.reindex(values, method="pad", tolerance=tolerance) + data = data.reindex(values, tolerance=tolerance) + data.dropna(inplace=True) else: data.drop(columns="time", inplace=True) dataframes.append(data) From 3164415c7248ccd2dc32a951500b780a81af054c Mon Sep 17 00:00:00 2001 From: Jai Date: Wed, 6 Dec 2023 14:26:43 +0000 Subject: [PATCH 353/489] WIP schema-from-scratch tutorial --- ...understanding_aeon_data_architecture.ipynb | 1163 +++++++++++++++++ 1 file changed, 1163 insertions(+) create mode 100644 docs/examples/understanding_aeon_data_architecture.ipynb diff --git a/docs/examples/understanding_aeon_data_architecture.ipynb b/docs/examples/understanding_aeon_data_architecture.ipynb new file mode 100644 index 00000000..f1f453e8 --- /dev/null +++ b/docs/examples/understanding_aeon_data_architecture.ipynb @@ -0,0 +1,1163 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Questions\n", + "\n", + "1. What is the usecase of the `DigitalBitmask` reader? How is it different to `BitmaskEvent`?" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Aeon data file structure on Ceph" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Raw data**: `/ceph/aeon/aeon/data/raw/////`\n", + "\n", + "e.g. `/ceph/aeon/aeon/data/raw/AEON3/social0.1/2023-12-01T14-30-34/Patch1/Patch1_90_2023-12-02T12-00-00.bin`\n", + "\n", + "**Processed data (e.g. trained and exported SLEAP model)**: `/ceph/aeon/aeon/data/processed/////frozen_graph.pb`\n", + "\n", + "e.g. `/ceph/aeon/aeon/data/processed/test-node1/0000005/2023-11-30T01-29-00/topdown_multianimal_id/frozen_graph.pb`" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Reading Aeon data in Python" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Terminology\n", + "\n", + "**_Chunk Duration_**: The time duration over which experiment data files are written out. Currently, all Aeon experiments write out acquired data to files every hour (1-hour chunks).\n", + "\n", + "**_Acquisition Epoch_**: One run of an experiment workflow. When an experiment workflow restarts, a new epoch starts.\n", + "\n", + "E.g. `ceph/aeon/aeon/data/raw/AEON3/social0.1/2023-12-03T13-05-15` is an acquisition epoch in the Social0.1 experiment. Because the next epoch directory is `ceph/aeon/aeon/data/raw/AEON3/social0.1/2023-12-03T13-30-30`, we know this first epoch lasted only ~25 minutes.\n", + "\n", + "**_Stream_**: Data that comes from a single source.\n", + "\n", + "A single data file is associated with each stream, so often 'stream' and 'file' can be interchanged. If the stream comes from a harp device, the stream-file contains information about the register of the harp device which generated the stream, as well as the associated chunk datetime.\n", + "\n", + "For a harp stream, the filename format is as follows:
    \n", + "`__` e.g. `Patch1_90_2023-12-02T12-00-00.bin`
    \n", + "By convention, harp streams which are acquired in software start with register number '200'; e.g. the largest-blob-centroid-tracking stream filename is: `CameraTop_200*.bin`\n", + "\n", + "Each stream can contain single or multi-dimensional data (e.g. a patch wheel magnetic encoder stream contains information about both the magnetic field strength and angle: however, each dimension is associated with a unique bitmask, and thus can be isolated by applying this bitmask to the stream).\n", + "\n", + "**_Reader_**: A Python class whose instantiated objects each read one particular stream. Simple working principle: each `Reader` has a `read` method which takes in a single stream-file and reads the data in that file into a pandas `DataFrame` (see `aeon/io/reader.py` and `aeon/schema/*.py`).\n", + "\n", + "e.g. `Encoder` readers read values from `Patch__` files (these contain a patch wheel's magnetic encoder readings, to determine how much the wheel has been spun).\n", + "\n", + "Whenever a new device is implemented in an Aeon experiment, a new `Reader` should be created for the acquired data, such that the data can be read and returned in the form of a pandas `DataFrame`.\n", + "\n", + "**_Device_**: A collection of streams grouped together for convenience, often for related streams.\n", + "\n", + "On ceph, we organize streams into device folders:
    e.g. `ceph/aeon/aeon/data/raw/AEON3/social0.1/2023-12-01T14-30-34/Patch1` contains the patch-heartbeat stream (`Patch1_8`), the patch-beambreak stream (`Patch1_32`), the patch-pellet delivery-pin-set stream (`Patch1_35`), the patch-pellet-delivery-pin-cleared stream (`Patch1_36`), the patch-wheel-magnetic-encoder stream (`Patch1_90`), the patch-wheel-magnetic-encoder-mode stream (`Patch1_91`), the patch-feeder-dispenser-state stream (`Patch1_200`), the patch-pellet-manual-delivery stream (`Patch1_201`), the patch-missed-pellet-stream (`Patch1_202`), the patch-pellet-delivery-retry stream (`Patch1_203`), and the patch-state stream (`Patch1_State`).\n", + "\n", + "In code, we create logical devices via the `Device` class (see `aeon/io/device.py`)
    \n", + "e.g. We often define 'Patch' devices that contain `Reader` objects associated with specific streams (as experimenters may not care about analyzing all streams in a `Patch` device folder on ceph), e.g. wheel-magnetic-encoder, state, pellet-delivery-pin-set, and beambreak.\n", + "\n", + "**_Schema_**: A list of devices grouped within a `DotMap` object (see `aeon/docs/examples/schemas.py`). Each experiment is associated with a schema. If a schema changes, then the experiment neccesarily must be different (either in name or version number), as the acquired data is now different.\n", + "\n", + "**_Dataset_**: All data belonging to a particular experiment. \n", + "\n", + "e.g. All data in `ceph/aeon/aeon/data/raw/AEON3/social0.1`" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Code\n", + "\n", + "With this terminology in mind, let's get to the code!" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "> \u001b[0;32m/nfs/nhome/live/jbhagat/ProjectAeon/aeon_mecha/aeon/io/binder/core.py\u001b[0m(33)\u001b[0;36menvironment_state\u001b[0;34m()\u001b[0m\n", + "\u001b[0;32m 32 \u001b[0;31m \u001b[0;32mimport\u001b[0m \u001b[0mipdb\u001b[0m\u001b[0;34m;\u001b[0m \u001b[0mipdb\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mset_trace\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0m\u001b[0;32m---> 33 \u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0;34m{\u001b[0m\u001b[0;34m\"EnvironmentState\"\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0m_reader\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mCsv\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34mf\"{pattern}_EnvironmentState_*\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m[\u001b[0m\u001b[0;34m\"state\"\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m}\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0m\u001b[0;32m 34 \u001b[0;31m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0m\n" + ] + } + ], + "source": [ + "\"\"\"Imports\"\"\"\n", + "\n", + "%load_ext autoreload\n", + "%autoreload 2\n", + "# %flow mode reactive\n", + "\n", + "from datetime import date\n", + "import ipdb\n", + "from itertools import product\n", + "from pathlib import Path\n", + "\n", + "from dotmap import DotMap\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import pandas as pd\n", + "import plotly.express as px\n", + "import plotly.graph_objs as go\n", + "import seaborn as sns\n", + "\n", + "import aeon\n", + "import aeon.io.binder.core as stream\n", + "from aeon.io import api\n", + "from aeon.io import reader\n", + "from aeon.io.device import Device\n", + "from aeon.io.binder.schemas import exp02, exp01\n", + "from aeon.analysis.utils import visits, distancetravelled" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"Set experiment root path and time range / set to load data\"\"\"\n", + "\n", + "root = Path(\"/ceph/aeon/aeon/data/raw/AEON3/social0.1\")\n", + "start_time = pd.Timestamp(\"2023-12-02 10:30:00\")\n", + "end_time = pd.Timestamp(\"2023-12-02 12:30:00\")\n", + "time_set = pd.concat(\n", + " [\n", + " pd.Series(pd.date_range(start_time, start_time + pd.Timedelta(hours=1), freq=\"1s\")),\n", + " pd.Series(pd.date_range(end_time, end_time + pd.Timedelta(hours=1), freq=\"1s\"))\n", + " ]\n", + ")\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"Creating a new `Reader` class\"\"\"\n", + "\n", + "# All readers are subclassed from the base `Reader` class. They thus all contain a `read` method,\n", + "# for returning data from a file in the form of a pandas DataFrame, and the following attributes, \n", + "# which must be specified on object construction:\n", + "# `pattern`: a prefix in the filename used by `aeon.io.api.load` to find matching files to load\n", + "# `columns`: a list of column names for the returned DataFrame\n", + "# `extension`: the file extension of the files to be read\n", + "\n", + "# Using these principles, we can recreate a simple reader for reading subject weight data from the \n", + "# social0.1 experiments, which are saved in .csv format.\n", + "\n", + "# First, we'll create a general Csv reader, subclassed from `Reader`.\n", + "class Csv(reader.Reader):\n", + " \"\"\"Reads data from csv text files, where the first column stores the Aeon timestamp, in seconds.\"\"\"\n", + "\n", + " def __init__(self, pattern, columns, extension=\"csv\"):\n", + " super().__init__(pattern, columns, extension)\n", + "\n", + " def read(self, file):\n", + " return pd.read_csv(file, header=0, names=self.columns, index_col=0)\n", + " \n", + "# Next, we'll create a reader for the subject weight data, subclassed from `Csv`.\n", + "\n", + "# We know from our data that the files of interest start with 'Environment_SubjectWeight' and columns are: \n", + "# 1) Aeon timestamp in seconds from 1904/01/01 (1904 date system)\n", + "# 2) Weight in grams\n", + "# 3) Weight stability confidence (0-1)\n", + "# 4) Subject ID (string)\n", + "# 5) Subject ID (integer)\n", + "# Since the first column (Aeon timestamp) will be set as the index, we'll use the rest as DataFrame columns.\n", + "# And we don't need to define `read`, as it will use the `Csv` class's `read` method.\n", + "\n", + "class Subject_Weight(Csv):\n", + " \"\"\"Reads subject weight data from csv text files.\"\"\"\n", + " \n", + " def __init__(\n", + " self, \n", + " pattern=\"Environment_SubjectWeight*\",\n", + " columns=[\"weight\", \"confidence\", \"subject_id\", \"int_id\"], \n", + " extension=\"csv\"\n", + " ):\n", + " super().__init__(pattern, columns, extension)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    weightconfidencesubject_idint_id
    3.784363e+0929.7999991CAA-11207460
    3.784363e+0929.7999991CAA-11207460
    3.784363e+0929.7999991CAA-11207460
    3.784363e+0929.7999991CAA-11207460
    3.784363e+0929.7999991CAA-11207460
    ...............
    3.784367e+0931.2000011CAA-11207470
    3.784367e+0931.2000011CAA-11207470
    3.784367e+0931.2000011CAA-11207470
    3.784367e+0931.2000011CAA-11207470
    3.784367e+0931.2000011CAA-11207470
    \n", + "

    4382 rows × 4 columns

    \n", + "
    " + ], + "text/plain": [ + " weight confidence subject_id int_id\n", + "3.784363e+09 29.799999 1 CAA-1120746 0\n", + "3.784363e+09 29.799999 1 CAA-1120746 0\n", + "3.784363e+09 29.799999 1 CAA-1120746 0\n", + "3.784363e+09 29.799999 1 CAA-1120746 0\n", + "3.784363e+09 29.799999 1 CAA-1120746 0\n", + "... ... ... ... ...\n", + "3.784367e+09 31.200001 1 CAA-1120747 0\n", + "3.784367e+09 31.200001 1 CAA-1120747 0\n", + "3.784367e+09 31.200001 1 CAA-1120747 0\n", + "3.784367e+09 31.200001 1 CAA-1120747 0\n", + "3.784367e+09 31.200001 1 CAA-1120747 0\n", + "\n", + "[4382 rows x 4 columns]" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    weightconfidencesubject_idint_id
    time
    2023-12-02 10:31:12.84000015329.51CAA-11207460
    2023-12-02 10:31:12.94000005729.51CAA-11207460
    2023-12-02 10:31:13.03999996229.51CAA-11207460
    2023-12-02 10:31:13.09999990529.51CAA-11207460
    2023-12-02 10:31:13.19999980929.51CAA-11207460
    ...............
    2023-12-02 12:27:29.46000003831.11CAA-11207470
    2023-12-02 12:27:29.55999994331.11CAA-11207470
    2023-12-02 12:27:29.61999988631.11CAA-11207470
    2023-12-02 12:27:29.71999979031.11CAA-11207471
    2023-12-02 12:27:29.82000017231.11CAA-11207470
    \n", + "

    10525 rows × 4 columns

    \n", + "
    " + ], + "text/plain": [ + " weight confidence subject_id int_id\n", + "time \n", + "2023-12-02 10:31:12.840000153 29.5 1 CAA-1120746 0\n", + "2023-12-02 10:31:12.940000057 29.5 1 CAA-1120746 0\n", + "2023-12-02 10:31:13.039999962 29.5 1 CAA-1120746 0\n", + "2023-12-02 10:31:13.099999905 29.5 1 CAA-1120746 0\n", + "2023-12-02 10:31:13.199999809 29.5 1 CAA-1120746 0\n", + "... ... ... ... ...\n", + "2023-12-02 12:27:29.460000038 31.1 1 CAA-1120747 0\n", + "2023-12-02 12:27:29.559999943 31.1 1 CAA-1120747 0\n", + "2023-12-02 12:27:29.619999886 31.1 1 CAA-1120747 0\n", + "2023-12-02 12:27:29.719999790 31.1 1 CAA-1120747 1\n", + "2023-12-02 12:27:29.820000172 31.1 1 CAA-1120747 0\n", + "\n", + "[10525 rows x 4 columns]" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    weightconfidencesubject_idint_id
    2023-12-02 10:30:0030.0000001.0CAA-11207470.0
    2023-12-02 10:30:0130.0000001.0CAA-11207470.0
    2023-12-02 10:30:0230.0000001.0CAA-11207470.0
    2023-12-02 10:30:0330.0000001.0CAA-11207470.0
    2023-12-02 10:30:0430.0000001.0CAA-11207470.0
    ...............
    2023-12-02 13:18:2529.7999991.0CAA-11207460.0
    2023-12-02 13:18:2629.7999991.0CAA-11207460.0
    2023-12-02 13:22:0529.9000001.0CAA-11207460.0
    2023-12-02 13:22:1429.7999991.0CAA-11207460.0
    2023-12-02 13:22:1829.7999991.0CAA-11207460.0
    \n", + "

    3691 rows × 4 columns

    \n", + "
    " + ], + "text/plain": [ + " weight confidence subject_id int_id\n", + "2023-12-02 10:30:00 30.000000 1.0 CAA-1120747 0.0\n", + "2023-12-02 10:30:01 30.000000 1.0 CAA-1120747 0.0\n", + "2023-12-02 10:30:02 30.000000 1.0 CAA-1120747 0.0\n", + "2023-12-02 10:30:03 30.000000 1.0 CAA-1120747 0.0\n", + "2023-12-02 10:30:04 30.000000 1.0 CAA-1120747 0.0\n", + "... ... ... ... ...\n", + "2023-12-02 13:18:25 29.799999 1.0 CAA-1120746 0.0\n", + "2023-12-02 13:18:26 29.799999 1.0 CAA-1120746 0.0\n", + "2023-12-02 13:22:05 29.900000 1.0 CAA-1120746 0.0\n", + "2023-12-02 13:22:14 29.799999 1.0 CAA-1120746 0.0\n", + "2023-12-02 13:22:18 29.799999 1.0 CAA-1120746 0.0\n", + "\n", + "[3691 rows x 4 columns]" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "\"\"\"Loading data via a `Reader` object\"\"\"\n", + "\n", + "# We can now load data by specifying a file \n", + "subject_weight_reader = Subject_Weight()\n", + "acq_epoch = \"2023-12-01T14-30-34\"\n", + "weight_file = root / acq_epoch / \"Environment/Environment_SubjectWeight_2023-12-02T12-00-00.csv\"\n", + "display(subject_weight_reader.read(weight_file))\n", + "\n", + "# And we can use `load` to load data across many same-stream files given a time range or time set.\n", + "display(aeon.load(root, subject_weight_reader, start=start_time, end=end_time))\n", + "display(aeon.load(root, subject_weight_reader, time=time_set.values))" + ] + }, + { + "cell_type": "code", + "execution_count": 34, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "bitmasks: [32 34]\n", + "raw data:\n", + " beambreak\n", + "time \n", + "2023-12-02 11:33:03.942463875 34\n", + "2023-12-02 11:33:03.951744080 32\n", + "2023-12-02 11:33:18.500351906 34\n", + "2023-12-02 11:33:18.503456115 32\n", + "2023-12-02 11:33:18.509632111 34\n", + "... ...\n", + "2023-12-02 12:07:19.810304165 32\n", + "2023-12-02 12:07:29.802591801 34\n", + "2023-12-02 12:07:29.808767796 32\n", + "2023-12-02 12:07:40.692639828 34\n", + "2023-12-02 12:07:40.705023766 32\n", + "\n", + "[102 rows x 1 columns]\n", + "\n", + "\n", + "bitmasked data:\n", + " event\n", + "time \n", + "2023-12-02 11:33:03.942463875 beambreak\n", + "2023-12-02 11:33:03.951744080 beambreak\n", + "2023-12-02 11:33:18.500351906 beambreak\n", + "2023-12-02 11:33:18.503456115 beambreak\n", + "2023-12-02 11:33:18.509632111 beambreak\n", + "... ...\n", + "2023-12-02 12:07:19.810304165 beambreak\n", + "2023-12-02 12:07:29.802591801 beambreak\n", + "2023-12-02 12:07:29.808767796 beambreak\n", + "2023-12-02 12:07:40.692639828 beambreak\n", + "2023-12-02 12:07:40.705023766 beambreak\n", + "\n", + "[102 rows x 1 columns]\n" + ] + } + ], + "source": [ + "\"\"\"Updating a `Reader` object\"\"\"\n", + "\n", + "# Occasionally, we may want to tweak the output from a `Reader` object's `read` method, or some tweaks to \n", + "# streams on the acquisition side may require us to make corresponding tweaks to a `Reader` object to\n", + "# ensure it works properly. We'll cover some of these cases here.\n", + "\n", + "# 1. Column changes\n", + "\n", + "# First, if we want to simply change the output from `read`, we can change the columns of an instantiated\n", + "# `Reader` object. Let's change `subject_id` to `id`, and after reading, drop the `confidence` and `int_id`\n", + "# columns.\n", + "subject_weight_reader.columns = [\"weight\", \"confidence\", \"id\", \"int_id\"]\n", + "data = subject_weight_reader.read(weight_file)\n", + "data.drop([\"confidence\", \"int_id\"], axis=1, inplace=True)\n", + "display(data)\n", + "\n", + "\n", + "# 2. Pattern changes\n", + "\n", + "# Next, occasionally a stream's filename may change, in which case we'll need to update the `Reader` \n", + "# object's `pattern` to find the new files using `load`: \n", + "\n", + "# Let's simulate a case where the old SubjectWeight stream was called Weight, and create a `Reader` class.\n", + "class Subject_Weight(Csv):\n", + " \"\"\"Reads subject weight data from csv text files.\"\"\"\n", + " \n", + " def __init__(\n", + " self, \n", + " pattern=\"Environment_Weight*\",\n", + " columns=[\"weight\", \"confidence\", \"subject_id\", \"int_id\"], \n", + " extension=\"csv\"\n", + " ):\n", + " super().__init__(pattern, columns, extension)\n", + "\n", + "# We'll see that we can't find any files with this pattern.\n", + "subject_weight_reader = Subject_Weight()\n", + "data = aeon.load(root, subject_weight_reader, start=start_time, end=end_time)\n", + "display(data) # empty dataframe\n", + "\n", + "# But if we just update the pattern, `load` will find the files.\n", + "subject_weight_reader.pattern = \"Environment_SubjectWeight*\"\n", + "data = aeon.load(root, subject_weight_reader, start=start_time, end=end_time)\n", + "display(data) \n", + "\n", + "\n", + "# 3. Bitmask changes for Harp streams\n", + "\n", + "# Lastly, some Harp streams use bitmasks to distinguish writing out different events to the same file.\n", + "# e.g. The beambreak stream `Patch_32*` writes out events both for when the beam is broken and when\n", + "# it gets reset. Given a Harp stream, we can find all bitmasks associated with it, and choose which one\n", + "# to use to filter the data:\n", + "\n", + "# Given a stream, we can create a `Harp` reader object to find all bitmasks associated with it.\n", + "pattern = \"Patch1_32*\"\n", + "event_name = \"beambreak\"\n", + "harp_reader = reader.Harp(pattern=pattern, columns=[event_name])\n", + "data = api.load(root, harp_reader, start=start_time, end=end_time)\n", + "bitmasks = np.unique(data[event_name].values)\n", + "print(f\"bitmasks: {bitmasks}\")\n", + "\n", + "# Let's set the bitmasks to the first returned unique value, and create a new `Reader` object to use this.\n", + "bitmask = bitmasks[0]\n", + "beambreak_reader = reader.BitmaskEvent(pattern, bitmask, event_name)\n", + "bitmasked_data = api.load(root, beambreak_reader, start=start_time, end=end_time)\n", + "\n", + "print(f\"raw data:\\n {data}\\n\\n\")\n", + "print(f\"bitmasked data:\\n {bitmasked_data}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "data:\n", + " beambreak\n", + "time \n", + "2023-12-02 11:33:03.942463875 34\n", + "2023-12-02 11:33:03.951744080 32\n", + "2023-12-02 11:33:18.500351906 34\n", + "2023-12-02 11:33:18.503456115 32\n", + "2023-12-02 11:33:18.509632111 34\n", + "... ...\n", + "2023-12-02 12:07:19.810304165 32\n", + "2023-12-02 12:07:29.802591801 34\n", + "2023-12-02 12:07:29.808767796 32\n", + "2023-12-02 12:07:40.692639828 34\n", + "2023-12-02 12:07:40.705023766 32\n", + "\n", + "[102 rows x 1 columns]\n", + "\n", + "\n", + "bitmask:\n", + " 34\n", + "\n", + "\n", + "stream_data:\n", + " event\n", + "time \n", + "2023-12-02 11:33:03.942463875 beambreak\n", + "2023-12-02 11:33:18.500351906 beambreak\n", + "2023-12-02 11:33:18.509632111 beambreak\n", + "2023-12-02 11:33:18.515808104 beambreak\n", + "2023-12-02 11:33:43.750751972 beambreak\n", + "2023-12-02 11:33:43.760032177 beambreak\n", + "2023-12-02 11:34:13.048543930 beambreak\n", + "2023-12-02 11:34:13.057824135 beambreak\n", + "2023-12-02 11:34:13.076320171 beambreak\n", + "2023-12-02 11:34:35.263328075 beambreak\n", + "2023-12-02 11:34:35.269504070 beambreak\n", + "2023-12-02 11:34:49.161056042 beambreak\n", + "2023-12-02 11:35:01.140063763 beambreak\n", + "2023-12-02 11:35:24.542560101 beambreak\n", + "2023-12-02 11:35:24.548736095 beambreak\n", + "2023-12-02 11:35:35.697792053 beambreak\n", + "2023-12-02 11:35:35.731743813 beambreak\n", + "2023-12-02 11:35:50.357567787 beambreak\n", + "2023-12-02 11:36:05.535552025 beambreak\n", + "2023-12-02 11:36:05.541759968 beambreak\n", + "2023-12-02 11:36:19.920832157 beambreak\n", + "2023-12-02 11:36:34.256608009 beambreak\n", + "2023-12-02 11:36:51.954944134 beambreak\n", + "2023-12-02 11:37:03.847680092 beambreak\n", + "2023-12-02 11:37:03.853856087 beambreak\n", + "2023-12-02 11:40:01.529439926 beambreak\n", + "2023-12-02 11:44:22.924352169 beambreak\n", + "2023-12-02 11:44:33.175744057 beambreak\n", + "2023-12-02 11:44:51.966368198 beambreak\n", + "2023-12-02 11:45:04.593088150 beambreak\n", + "2023-12-02 11:45:18.151519775 beambreak\n", + "2023-12-02 11:45:18.157663822 beambreak\n", + "2023-12-02 11:45:43.645567894 beambreak\n", + "2023-12-02 11:46:01.303775787 beambreak\n", + "2023-12-02 11:46:26.813504219 beambreak\n", + "2023-12-02 11:46:26.819680214 beambreak\n", + "2023-12-02 11:46:43.139167786 beambreak\n", + "2023-12-02 11:46:43.148416042 beambreak\n", + "2023-12-02 11:46:57.703455925 beambreak\n", + "2023-12-02 11:47:15.047423840 beambreak\n", + "2023-12-02 11:47:33.655744076 beambreak\n", + "2023-12-02 11:47:46.538911819 beambreak\n", + "2023-12-02 11:57:26.466911793 beambreak\n", + "2023-12-02 11:57:38.874559879 beambreak\n", + "2023-12-02 11:57:58.827775955 beambreak\n", + "2023-12-02 11:57:58.833951950 beambreak\n", + "2023-12-02 11:58:22.878240108 beambreak\n", + "2023-12-02 12:07:19.794847965 beambreak\n", + "2023-12-02 12:07:19.807199955 beambreak\n", + "2023-12-02 12:07:29.802591801 beambreak\n", + "2023-12-02 12:07:40.692639828 beambreak\n" + ] + } + ], + "source": [ + "pattern = \"Patch1_32*\"\n", + "event_name = \"beambreak\"\n", + "# Set the reader for the stream\n", + "harp_reader = reader.Harp(pattern=pattern, columns=[event_name])\n", + "# Get the bitmask as the first value of the loaded stream\n", + "data = api.load(root, harp_reader, start=start_time, end=end_time)\n", + "bitmask = data.iloc[0, 0]\n", + "new_reader = reader.BitmaskEvent(pattern, bitmask, event_name)\n", + "stream_data = api.load(root, new_reader, start=start_time, end=end_time)\n", + "\n", + "print(f\"data:\\n {data}\\n\\n\")\n", + "print(f\"bitmask:\\n {bitmask}\\n\\n\")\n", + "print(f\"stream_data:\\n {stream_data}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"Instantiating a `Device` object\"\"\"\n", + "\n", + "# A `Device` object is instantiated from a name, followed by one or more 'binder functions', which \n", + "# return a dictionary of a name paired with a `Reader` object. We call such a dictionary of `:Reader`\n", + "# key-value pairs a 'registry'.\n", + "\n", + "\n", + "# On creation, the `Device` object puts all registries into a single registry, which is accessible via the\n", + "# `registry` attribute.\n", + "\n", + "\n", + "# This is done so that we can create a 'schema' (a DotMap of a list of `Device` objects), where a `Device`\n", + "# object name is a key for the schema, and the `registry` names' (which are keys for the `Device` object) \n", + "# corresponding values are the `Reader` objects associated with that `Device` object.\n", + "# This works because, when a list of `Device` objects are passed into the `DotMap` constructor, the\n", + "# `__iter__` method of the `Device` object returns a tuple of the object's name with its `stream` \n", + "# attribute, which is passed in directly to the DotMap constructor to create a nested DotMap:\n", + "# device_name -> stream_name -> stream `Reader` object.\n", + "\n", + "d = Device(\"ExperimentalMetadata\", stream.environment, stream.messageLog)\n", + "print(d.registry)\n", + "DotMap(d.registry)\n", + "DotMap([d])\n", + "s = DotMap([d])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Social0.1 Data Streams\n", + "\n", + "Now that we've covered streams, devices, and schemas, let's build a schema for the Social0.1 Experiment!\n", + "\n", + "First we'll need to know all the streams we recorded during the Social0.1 experiment: these can be found via\n", + "looking through all devices in an acqusition epoch \n", + "(e.g. `ceph/aeon/aeon/data/raw/AEON3/social0.1/2023-12-01T14-30-34`)\n", + "\n", + "And here they are: (*note: register 8 is always the harp heartbeat for any device that has this stream.*)\n", + "\n", + "- Metadata.yml\n", + "- Environment\n", + " - BlockState\n", + " - EnvironmentState\n", + " - LightEvents\n", + " - MessageLog\n", + " - SubjectState\n", + " - SubjectVisits\n", + " - SubjectWeight\n", + "- CameraTop (200, 201, avi, csv, ,)\n", + " - 200: position\n", + " - 201: region\n", + "- CameraNorth (avi, csv)\n", + "- CameraEast (avi, csv)\n", + "- CameraSouth (avi, csv)\n", + "- CameraWest (avi, csv)\n", + "- CameraPatch1 (avi, csv)\n", + "- CameraPatch2 (avi, csv)\n", + "- CameraPatch3 (avi, csv)\n", + "- CameraNest (avi, csv)\n", + "- ClockSynchronizer (8, 36)\n", + " - 36: hearbeat_out\n", + "- Nest (200, 201, 202, 203)\n", + " - 200: weight_raw\n", + " - 201: weight_tare\n", + " - 202: weight_filtered\n", + " - 203: weight_baseline\n", + " - 204: weight_subject\n", + "- Patch1 (8, 32, 35, 36, 87, 90, 91, 200, 201, 202, 203, State)\n", + " - 32: beam_break\n", + " - 35: delivery_set\n", + " - 36: delivery_clear\n", + " - 87: expansion_board\n", + " - 90: encoder_read\n", + " - 91: encoder_mode\n", + " - 200: dispenser_state\n", + " - 201: delivery_manual\n", + " - 202: missed_pellet\n", + " - 203: delivery_retry\n", + "- Patch2 (8, 32, 35, 36, 87, 90, 91, State)\n", + "- Patch3 (8, 32, 35, 36, 87, 90, 91, 200, 203, State)\n", + "- RfidEventsGate (8, 32, 35)\n", + " - 32: entry_id\n", + " - 35: hardware_notifications\n", + "- RfidEventsNest1 (8, 32, 35)\n", + "- RfidEventsNest2 (8, 32, 35)\n", + "- RfidEventsPatch1 (8, 32, 35)\n", + "- RfidEventsPatch2 (8, 32, 35)\n", + "- RfidEventsPatch3 (8, 32, 35)\n", + "- VideoController (8, 32, 33, 34, 35, 36, 45, 52)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"Creating the Social 0.1 schema\"\"\"\n", + "\n", + "# Above we've listed out all the streams we recorded from during Social0.1, but we won't care to analyze all of them.\n", + "# Instead, we'll create a schema that only contains the streams we want to analyze:\n", + "\n", + "# Metadata\n", + "\n", + "# Environment\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "DotMap(\n", + " [\n", + " Device(\"Metadata\", stream.metadata),\n", + " Device(\"ExperimentalMetadata\", stream.environment, stream.messageLog),\n", + " Device(\"CameraEast\", stream.video),\n", + " Device(\"CameraNest\", stream.video),\n", + " Device(\"CameraNorth\", stream.video),\n", + " Device(\"CameraPatch1\", stream.video),\n", + " Device(\"CameraPatch2\", stream.video),\n", + " Device(\"CameraSouth\", stream.video),\n", + " Device(\"CameraWest\", stream.video),\n", + " ]\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "d = Device(\"ExperimentalMetadata\", stream.environment, stream.messageLog)\n", + "print(d.registry)\n", + "DotMap(d.registry)\n", + "DotMap([d])\n", + "s = DotMap([d])\n", + "s.ExperimentalMetadata.EnvironmentState.pattern" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"Metadata\"\"\"\n", + "\n", + "data = api.load(root, reader.Metadata(), start=start, end=end)\n", + "data.metadata.iloc[0] # get device metadata dotmap" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "d = Device(\"ExperimentalMetadata\", stream.environment, stream.messageLog)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "Device(\"Metadata\", stream.metadata).stream" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "d = Device(\"test\", reader.Video, reader.Metadata)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "d.name" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "exp02.ExperimentalMetadata" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "exp02.Metadata" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "exp02.ExperimentalMetadata.keys()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "api.load(root, exp02.ExperimentalMetadata., start=start, end=end)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"Environment\"\"\"\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"CameraTop\"\"\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"Top quadrant and zoomed in patch cameras\"\"\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"Nest\"\"\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"Patches\"\"\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"Rfids\"\"\"" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "aeon", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.4" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} From f9441d5eb36b86d33a4eea9297781f9b5439e573 Mon Sep 17 00:00:00 2001 From: Jai Date: Wed, 6 Dec 2023 15:02:10 +0000 Subject: [PATCH 354/489] Started 'device' section --- ...understanding_aeon_data_architecture.ipynb | 478 ++++++++++++------ 1 file changed, 322 insertions(+), 156 deletions(-) diff --git a/docs/examples/understanding_aeon_data_architecture.ipynb b/docs/examples/understanding_aeon_data_architecture.ipynb index f1f453e8..e8f69f15 100644 --- a/docs/examples/understanding_aeon_data_architecture.ipynb +++ b/docs/examples/understanding_aeon_data_architecture.ipynb @@ -1,14 +1,5 @@ { "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Questions\n", - "\n", - "1. What is the usecase of the `DigitalBitmask` reader? How is it different to `BitmaskEvent`?" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -91,19 +82,7 @@ "cell_type": "code", "execution_count": 1, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "> \u001b[0;32m/nfs/nhome/live/jbhagat/ProjectAeon/aeon_mecha/aeon/io/binder/core.py\u001b[0m(33)\u001b[0;36menvironment_state\u001b[0;34m()\u001b[0m\n", - "\u001b[0;32m 32 \u001b[0;31m \u001b[0;32mimport\u001b[0m \u001b[0mipdb\u001b[0m\u001b[0;34m;\u001b[0m \u001b[0mipdb\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mset_trace\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0m\u001b[0;32m---> 33 \u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0;34m{\u001b[0m\u001b[0;34m\"EnvironmentState\"\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0m_reader\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mCsv\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34mf\"{pattern}_EnvironmentState_*\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m[\u001b[0m\u001b[0;34m\"state\"\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m}\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0m\u001b[0;32m 34 \u001b[0;31m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0m\n" - ] - } - ], + "outputs": [], "source": [ "\"\"\"Imports\"\"\"\n", "\n", @@ -624,9 +603,301 @@ }, { "cell_type": "code", - "execution_count": 34, + "execution_count": 5, "metadata": {}, "outputs": [ + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    weightid
    3.784363e+0929.799999CAA-1120746
    3.784363e+0929.799999CAA-1120746
    3.784363e+0929.799999CAA-1120746
    3.784363e+0929.799999CAA-1120746
    3.784363e+0929.799999CAA-1120746
    .........
    3.784367e+0931.200001CAA-1120747
    3.784367e+0931.200001CAA-1120747
    3.784367e+0931.200001CAA-1120747
    3.784367e+0931.200001CAA-1120747
    3.784367e+0931.200001CAA-1120747
    \n", + "

    4382 rows × 2 columns

    \n", + "
    " + ], + "text/plain": [ + " weight id\n", + "3.784363e+09 29.799999 CAA-1120746\n", + "3.784363e+09 29.799999 CAA-1120746\n", + "3.784363e+09 29.799999 CAA-1120746\n", + "3.784363e+09 29.799999 CAA-1120746\n", + "3.784363e+09 29.799999 CAA-1120746\n", + "... ... ...\n", + "3.784367e+09 31.200001 CAA-1120747\n", + "3.784367e+09 31.200001 CAA-1120747\n", + "3.784367e+09 31.200001 CAA-1120747\n", + "3.784367e+09 31.200001 CAA-1120747\n", + "3.784367e+09 31.200001 CAA-1120747\n", + "\n", + "[4382 rows x 2 columns]" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    weightconfidencesubject_idint_id
    time
    \n", + "
    " + ], + "text/plain": [ + "Empty DataFrame\n", + "Columns: [weight, confidence, subject_id, int_id]\n", + "Index: []" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    weightconfidencesubject_idint_id
    time
    2023-12-02 10:31:12.84000015329.51CAA-11207460
    2023-12-02 10:31:12.94000005729.51CAA-11207460
    2023-12-02 10:31:13.03999996229.51CAA-11207460
    2023-12-02 10:31:13.09999990529.51CAA-11207460
    2023-12-02 10:31:13.19999980929.51CAA-11207460
    ...............
    2023-12-02 12:27:29.46000003831.11CAA-11207470
    2023-12-02 12:27:29.55999994331.11CAA-11207470
    2023-12-02 12:27:29.61999988631.11CAA-11207470
    2023-12-02 12:27:29.71999979031.11CAA-11207471
    2023-12-02 12:27:29.82000017231.11CAA-11207470
    \n", + "

    10525 rows × 4 columns

    \n", + "
    " + ], + "text/plain": [ + " weight confidence subject_id int_id\n", + "time \n", + "2023-12-02 10:31:12.840000153 29.5 1 CAA-1120746 0\n", + "2023-12-02 10:31:12.940000057 29.5 1 CAA-1120746 0\n", + "2023-12-02 10:31:13.039999962 29.5 1 CAA-1120746 0\n", + "2023-12-02 10:31:13.099999905 29.5 1 CAA-1120746 0\n", + "2023-12-02 10:31:13.199999809 29.5 1 CAA-1120746 0\n", + "... ... ... ... ...\n", + "2023-12-02 12:27:29.460000038 31.1 1 CAA-1120747 0\n", + "2023-12-02 12:27:29.559999943 31.1 1 CAA-1120747 0\n", + "2023-12-02 12:27:29.619999886 31.1 1 CAA-1120747 0\n", + "2023-12-02 12:27:29.719999790 31.1 1 CAA-1120747 1\n", + "2023-12-02 12:27:29.820000172 31.1 1 CAA-1120747 0\n", + "\n", + "[10525 rows x 4 columns]" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, { "name": "stdout", "output_type": "stream", @@ -640,14 +911,6 @@ "2023-12-02 11:33:18.500351906 34\n", "2023-12-02 11:33:18.503456115 32\n", "2023-12-02 11:33:18.509632111 34\n", - "... ...\n", - "2023-12-02 12:07:19.810304165 32\n", - "2023-12-02 12:07:29.802591801 34\n", - "2023-12-02 12:07:29.808767796 32\n", - "2023-12-02 12:07:40.692639828 34\n", - "2023-12-02 12:07:40.705023766 32\n", - "\n", - "[102 rows x 1 columns]\n", "\n", "\n", "bitmasked data:\n", @@ -657,15 +920,7 @@ "2023-12-02 11:33:03.951744080 beambreak\n", "2023-12-02 11:33:18.500351906 beambreak\n", "2023-12-02 11:33:18.503456115 beambreak\n", - "2023-12-02 11:33:18.509632111 beambreak\n", - "... ...\n", - "2023-12-02 12:07:19.810304165 beambreak\n", - "2023-12-02 12:07:29.802591801 beambreak\n", - "2023-12-02 12:07:29.808767796 beambreak\n", - "2023-12-02 12:07:40.692639828 beambreak\n", - "2023-12-02 12:07:40.705023766 beambreak\n", - "\n", - "[102 rows x 1 columns]\n" + "2023-12-02 11:33:18.509632111 beambreak\n" ] } ], @@ -735,132 +990,43 @@ "beambreak_reader = reader.BitmaskEvent(pattern, bitmask, event_name)\n", "bitmasked_data = api.load(root, beambreak_reader, start=start_time, end=end_time)\n", "\n", - "print(f\"raw data:\\n {data}\\n\\n\")\n", - "print(f\"bitmasked data:\\n {bitmasked_data}\")" + "print(f\"raw data:\\n {data.head()}\\n\\n\")\n", + "print(f\"bitmasked data:\\n {bitmasked_data.head()}\")" ] }, { "cell_type": "code", - "execution_count": 29, + "execution_count": 6, "metadata": {}, "outputs": [ { - "name": "stdout", - "output_type": "stream", - "text": [ - "data:\n", - " beambreak\n", - "time \n", - "2023-12-02 11:33:03.942463875 34\n", - "2023-12-02 11:33:03.951744080 32\n", - "2023-12-02 11:33:18.500351906 34\n", - "2023-12-02 11:33:18.503456115 32\n", - "2023-12-02 11:33:18.509632111 34\n", - "... ...\n", - "2023-12-02 12:07:19.810304165 32\n", - "2023-12-02 12:07:29.802591801 34\n", - "2023-12-02 12:07:29.808767796 32\n", - "2023-12-02 12:07:40.692639828 34\n", - "2023-12-02 12:07:40.705023766 32\n", - "\n", - "[102 rows x 1 columns]\n", - "\n", - "\n", - "bitmask:\n", - " 34\n", - "\n", - "\n", - "stream_data:\n", - " event\n", - "time \n", - "2023-12-02 11:33:03.942463875 beambreak\n", - "2023-12-02 11:33:18.500351906 beambreak\n", - "2023-12-02 11:33:18.509632111 beambreak\n", - "2023-12-02 11:33:18.515808104 beambreak\n", - "2023-12-02 11:33:43.750751972 beambreak\n", - "2023-12-02 11:33:43.760032177 beambreak\n", - "2023-12-02 11:34:13.048543930 beambreak\n", - "2023-12-02 11:34:13.057824135 beambreak\n", - "2023-12-02 11:34:13.076320171 beambreak\n", - "2023-12-02 11:34:35.263328075 beambreak\n", - "2023-12-02 11:34:35.269504070 beambreak\n", - "2023-12-02 11:34:49.161056042 beambreak\n", - "2023-12-02 11:35:01.140063763 beambreak\n", - "2023-12-02 11:35:24.542560101 beambreak\n", - "2023-12-02 11:35:24.548736095 beambreak\n", - "2023-12-02 11:35:35.697792053 beambreak\n", - "2023-12-02 11:35:35.731743813 beambreak\n", - "2023-12-02 11:35:50.357567787 beambreak\n", - "2023-12-02 11:36:05.535552025 beambreak\n", - "2023-12-02 11:36:05.541759968 beambreak\n", - "2023-12-02 11:36:19.920832157 beambreak\n", - "2023-12-02 11:36:34.256608009 beambreak\n", - "2023-12-02 11:36:51.954944134 beambreak\n", - "2023-12-02 11:37:03.847680092 beambreak\n", - "2023-12-02 11:37:03.853856087 beambreak\n", - "2023-12-02 11:40:01.529439926 beambreak\n", - "2023-12-02 11:44:22.924352169 beambreak\n", - "2023-12-02 11:44:33.175744057 beambreak\n", - "2023-12-02 11:44:51.966368198 beambreak\n", - "2023-12-02 11:45:04.593088150 beambreak\n", - "2023-12-02 11:45:18.151519775 beambreak\n", - "2023-12-02 11:45:18.157663822 beambreak\n", - "2023-12-02 11:45:43.645567894 beambreak\n", - "2023-12-02 11:46:01.303775787 beambreak\n", - "2023-12-02 11:46:26.813504219 beambreak\n", - "2023-12-02 11:46:26.819680214 beambreak\n", - "2023-12-02 11:46:43.139167786 beambreak\n", - "2023-12-02 11:46:43.148416042 beambreak\n", - "2023-12-02 11:46:57.703455925 beambreak\n", - "2023-12-02 11:47:15.047423840 beambreak\n", - "2023-12-02 11:47:33.655744076 beambreak\n", - "2023-12-02 11:47:46.538911819 beambreak\n", - "2023-12-02 11:57:26.466911793 beambreak\n", - "2023-12-02 11:57:38.874559879 beambreak\n", - "2023-12-02 11:57:58.827775955 beambreak\n", - "2023-12-02 11:57:58.833951950 beambreak\n", - "2023-12-02 11:58:22.878240108 beambreak\n", - "2023-12-02 12:07:19.794847965 beambreak\n", - "2023-12-02 12:07:19.807199955 beambreak\n", - "2023-12-02 12:07:29.802591801 beambreak\n", - "2023-12-02 12:07:40.692639828 beambreak\n" + "ename": "TypeError", + "evalue": "subject_weight_binder() takes 0 positional arguments but 1 was given", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mTypeError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[0;32mIn[6], line 12\u001b[0m\n\u001b[1;32m 9\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21msubject_state_binder\u001b[39m(): \u001b[38;5;66;03m# an example subject state binder function\u001b[39;00m\n\u001b[1;32m 10\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m {\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124msubject_state\u001b[39m\u001b[38;5;124m\"\u001b[39m: reader\u001b[38;5;241m.\u001b[39mSubject(pattern\u001b[38;5;241m=\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mEnvironment_SubjectState_*\u001b[39m\u001b[38;5;124m\"\u001b[39m)}\n\u001b[0;32m---> 12\u001b[0m d \u001b[38;5;241m=\u001b[39m \u001b[43mDevice\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43mSubjectMetadata\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43msubject_weight_binder\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43msubject_state_binder\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 15\u001b[0m \u001b[38;5;66;03m# On creation, the `Device` object puts all registries into a single registry, which is accessible via the\u001b[39;00m\n\u001b[1;32m 16\u001b[0m \u001b[38;5;66;03m# `registry` attribute.\u001b[39;00m\n\u001b[1;32m 17\u001b[0m \n\u001b[0;32m (...)\u001b[0m\n\u001b[1;32m 30\u001b[0m \u001b[38;5;66;03m# DotMap([d])\u001b[39;00m\n\u001b[1;32m 31\u001b[0m \u001b[38;5;66;03m# s = DotMap([d])\u001b[39;00m\n", + "File \u001b[0;32m~/ProjectAeon/aeon_mecha/aeon/io/device.py:34\u001b[0m, in \u001b[0;36mDevice.__init__\u001b[0;34m(self, name, pattern, *args)\u001b[0m\n\u001b[1;32m 32\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21m__init__\u001b[39m(\u001b[38;5;28mself\u001b[39m, name, \u001b[38;5;241m*\u001b[39margs, pattern\u001b[38;5;241m=\u001b[39m\u001b[38;5;28;01mNone\u001b[39;00m):\n\u001b[1;32m 33\u001b[0m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mname \u001b[38;5;241m=\u001b[39m name\n\u001b[0;32m---> 34\u001b[0m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mregistry \u001b[38;5;241m=\u001b[39m \u001b[43mcompositeStream\u001b[49m\u001b[43m(\u001b[49m\u001b[43mname\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;28;43;01mif\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[43mpattern\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;129;43;01mis\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[38;5;28;43;01mNone\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[38;5;28;43;01melse\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[43mpattern\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43margs\u001b[49m\u001b[43m)\u001b[49m\n", + "File \u001b[0;32m~/ProjectAeon/aeon_mecha/aeon/io/device.py:14\u001b[0m, in \u001b[0;36mcompositeStream\u001b[0;34m(pattern, *args)\u001b[0m\n\u001b[1;32m 12\u001b[0m registry\u001b[38;5;241m.\u001b[39mupdate(method\u001b[38;5;241m.\u001b[39m\u001b[38;5;18m__func__\u001b[39m(pattern))\n\u001b[1;32m 13\u001b[0m \u001b[38;5;28;01melse\u001b[39;00m:\n\u001b[0;32m---> 14\u001b[0m registry\u001b[38;5;241m.\u001b[39mupdate(\u001b[43mstream\u001b[49m\u001b[43m(\u001b[49m\u001b[43mpattern\u001b[49m\u001b[43m)\u001b[49m)\n\u001b[1;32m 15\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m registry\n", + "\u001b[0;31mTypeError\u001b[0m: subject_weight_binder() takes 0 positional arguments but 1 was given" ] } ], - "source": [ - "pattern = \"Patch1_32*\"\n", - "event_name = \"beambreak\"\n", - "# Set the reader for the stream\n", - "harp_reader = reader.Harp(pattern=pattern, columns=[event_name])\n", - "# Get the bitmask as the first value of the loaded stream\n", - "data = api.load(root, harp_reader, start=start_time, end=end_time)\n", - "bitmask = data.iloc[0, 0]\n", - "new_reader = reader.BitmaskEvent(pattern, bitmask, event_name)\n", - "stream_data = api.load(root, new_reader, start=start_time, end=end_time)\n", - "\n", - "print(f\"data:\\n {data}\\n\\n\")\n", - "print(f\"bitmask:\\n {bitmask}\\n\\n\")\n", - "print(f\"stream_data:\\n {stream_data}\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], "source": [ "\"\"\"Instantiating a `Device` object\"\"\"\n", "\n", "# A `Device` object is instantiated from a name, followed by one or more 'binder functions', which \n", "# return a dictionary of a name paired with a `Reader` object. We call such a dictionary of `:Reader`\n", - "# key-value pairs a 'registry'.\n", + "# key-value pairs a 'registry'. Each binder function must take in a `pattern` argument, which is used to\n", + "# set the pattern of the `Reader` object it returns.\n", + "def subject_weight_binder(pattern): # an example subject weight binder function\n", + " return {\"subject_weight\": Subject_Weight(pattern=pattern)}\n", + "\n", + "def subject_state_binder(): # an example subject state binder function\n", + " return {\"subject_state\": reader.Subject(pattern=pattern)}\n", + "\n", + "d = Device(\"SubjectMetadata\", subject_weight_binder, subject_state_binder)\n", "\n", "\n", "# On creation, the `Device` object puts all registries into a single registry, which is accessible via the\n", @@ -875,11 +1041,11 @@ "# attribute, which is passed in directly to the DotMap constructor to create a nested DotMap:\n", "# device_name -> stream_name -> stream `Reader` object.\n", "\n", - "d = Device(\"ExperimentalMetadata\", stream.environment, stream.messageLog)\n", - "print(d.registry)\n", - "DotMap(d.registry)\n", - "DotMap([d])\n", - "s = DotMap([d])" + "# d = Device(\"ExperimentalMetadata\", stream.environment, stream.messageLog)\n", + "# print(d.registry)\n", + "# DotMap(d.registry)\n", + "# DotMap([d])\n", + "# s = DotMap([d])" ] }, { From 26394c9c5ee16a62b54fee44a28ab58e9467f4ae Mon Sep 17 00:00:00 2001 From: Jai Date: Wed, 13 Dec 2023 14:51:00 +0000 Subject: [PATCH 355/489] Updated device terminology --- aeon/io/device.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/aeon/io/device.py b/aeon/io/device.py index 23018aaf..d9435a8c 100644 --- a/aeon/io/device.py +++ b/aeon/io/device.py @@ -1,26 +1,25 @@ import inspect -def compositeStream(pattern, *args): - """Merges multiple data streams into a single composite stream.""" +def register(pattern, *args): + """Merges multiple Readers into a single registry.""" registry = {} if args: - for stream in args: - if inspect.isclass(stream): - for method in vars(stream).values(): + for binder_fn in args: + if inspect.isclass(binder_fn): + for method in vars(binder_fn).values(): if isinstance(method, staticmethod): registry.update(method.__func__(pattern)) else: - registry.update(stream(pattern)) + registry.update(binder_fn(pattern)) return registry class Device: - """Groups multiple data streams into a logical device. + """Groups multiple Readers into a logical device. - If a device contains a single stream with the same pattern as the device - `name`, it will be considered a singleton, and the stream reader will be - paired directly with the device without nesting. + If a device contains a single stream reader with the same pattern as the device `name`, it will be + considered a singleton, and the stream reader will be paired directly with the device without nesting. Attributes: name (str): Name of the device. @@ -31,7 +30,7 @@ class Device: def __init__(self, name, *args, pattern=None): self.name = name - self.registry = compositeStream(name if pattern is None else pattern, *args) + self.registry = register(name if pattern is None else pattern, *args) def __iter__(self): if len(self.registry) == 1: From fc779f3002c4df341404dab9bdbfb0efc94b163b Mon Sep 17 00:00:00 2001 From: Jai Date: Wed, 13 Dec 2023 14:51:38 +0000 Subject: [PATCH 356/489] Finished 'device' and 'schema' explanation --- ...understanding_aeon_data_architecture.ipynb | 84 +++++++++++-------- 1 file changed, 47 insertions(+), 37 deletions(-) diff --git a/docs/examples/understanding_aeon_data_architecture.ipynb b/docs/examples/understanding_aeon_data_architecture.ipynb index e8f69f15..c67effe7 100644 --- a/docs/examples/understanding_aeon_data_architecture.ipynb +++ b/docs/examples/understanding_aeon_data_architecture.ipynb @@ -80,9 +80,18 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 19, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The autoreload extension is already loaded. To reload it, use:\n", + " %reload_ext autoreload\n" + ] + } + ], "source": [ "\"\"\"Imports\"\"\"\n", "\n", @@ -104,7 +113,7 @@ "import seaborn as sns\n", "\n", "import aeon\n", - "import aeon.io.binder.core as stream\n", + "import aeon.io.binder.core as core_binder\n", "from aeon.io import api\n", "from aeon.io import reader\n", "from aeon.io.device import Device\n", @@ -594,16 +603,19 @@ "subject_weight_reader = Subject_Weight()\n", "acq_epoch = \"2023-12-01T14-30-34\"\n", "weight_file = root / acq_epoch / \"Environment/Environment_SubjectWeight_2023-12-02T12-00-00.csv\"\n", + "print(\"Read from a single file:\")\n", "display(subject_weight_reader.read(weight_file))\n", "\n", "# And we can use `load` to load data across many same-stream files given a time range or time set.\n", + "print(\"Read from a contiguous time range:\")\n", "display(aeon.load(root, subject_weight_reader, start=start_time, end=end_time))\n", + "print(\"Read from a set of times:\")\n", "display(aeon.load(root, subject_weight_reader, time=time_set.values))" ] }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 6, "metadata": {}, "outputs": [ { @@ -917,10 +929,10 @@ " event\n", "time \n", "2023-12-02 11:33:03.942463875 beambreak\n", - "2023-12-02 11:33:03.951744080 beambreak\n", "2023-12-02 11:33:18.500351906 beambreak\n", - "2023-12-02 11:33:18.503456115 beambreak\n", - "2023-12-02 11:33:18.509632111 beambreak\n" + "2023-12-02 11:33:18.509632111 beambreak\n", + "2023-12-02 11:33:18.515808104 beambreak\n", + "2023-12-02 11:33:43.750751972 beambreak\n" ] } ], @@ -928,7 +940,7 @@ "\"\"\"Updating a `Reader` object\"\"\"\n", "\n", "# Occasionally, we may want to tweak the output from a `Reader` object's `read` method, or some tweaks to \n", - "# streams on the acquisition side may require us to make corresponding tweaks to a `Reader` object to\n", + "# streams on the acquisition side may require us to make corresponding tweaks to a `Reader` object to\n", "# ensure it works properly. We'll cover some of these cases here.\n", "\n", "# 1. Column changes\n", @@ -985,8 +997,8 @@ "bitmasks = np.unique(data[event_name].values)\n", "print(f\"bitmasks: {bitmasks}\")\n", "\n", - "# Let's set the bitmasks to the first returned unique value, and create a new `Reader` object to use this.\n", - "bitmask = bitmasks[0]\n", + "# Let's set the bitmask to '34', and create a new `Reader` object to use this.\n", + "bitmask = 34\n", "beambreak_reader = reader.BitmaskEvent(pattern, bitmask, event_name)\n", "bitmasked_data = api.load(root, beambreak_reader, start=start_time, end=end_time)\n", "\n", @@ -996,20 +1008,17 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 21, "metadata": {}, "outputs": [ { - "ename": "TypeError", - "evalue": "subject_weight_binder() takes 0 positional arguments but 1 was given", - "output_type": "error", - "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mTypeError\u001b[0m Traceback (most recent call last)", - "Cell \u001b[0;32mIn[6], line 12\u001b[0m\n\u001b[1;32m 9\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21msubject_state_binder\u001b[39m(): \u001b[38;5;66;03m# an example subject state binder function\u001b[39;00m\n\u001b[1;32m 10\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m {\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124msubject_state\u001b[39m\u001b[38;5;124m\"\u001b[39m: reader\u001b[38;5;241m.\u001b[39mSubject(pattern\u001b[38;5;241m=\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mEnvironment_SubjectState_*\u001b[39m\u001b[38;5;124m\"\u001b[39m)}\n\u001b[0;32m---> 12\u001b[0m d \u001b[38;5;241m=\u001b[39m \u001b[43mDevice\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43mSubjectMetadata\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43msubject_weight_binder\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43msubject_state_binder\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 15\u001b[0m \u001b[38;5;66;03m# On creation, the `Device` object puts all registries into a single registry, which is accessible via the\u001b[39;00m\n\u001b[1;32m 16\u001b[0m \u001b[38;5;66;03m# `registry` attribute.\u001b[39;00m\n\u001b[1;32m 17\u001b[0m \n\u001b[0;32m (...)\u001b[0m\n\u001b[1;32m 30\u001b[0m \u001b[38;5;66;03m# DotMap([d])\u001b[39;00m\n\u001b[1;32m 31\u001b[0m \u001b[38;5;66;03m# s = DotMap([d])\u001b[39;00m\n", - "File \u001b[0;32m~/ProjectAeon/aeon_mecha/aeon/io/device.py:34\u001b[0m, in \u001b[0;36mDevice.__init__\u001b[0;34m(self, name, pattern, *args)\u001b[0m\n\u001b[1;32m 32\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21m__init__\u001b[39m(\u001b[38;5;28mself\u001b[39m, name, \u001b[38;5;241m*\u001b[39margs, pattern\u001b[38;5;241m=\u001b[39m\u001b[38;5;28;01mNone\u001b[39;00m):\n\u001b[1;32m 33\u001b[0m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mname \u001b[38;5;241m=\u001b[39m name\n\u001b[0;32m---> 34\u001b[0m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mregistry \u001b[38;5;241m=\u001b[39m \u001b[43mcompositeStream\u001b[49m\u001b[43m(\u001b[49m\u001b[43mname\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;28;43;01mif\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[43mpattern\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;129;43;01mis\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[38;5;28;43;01mNone\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[38;5;28;43;01melse\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[43mpattern\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43margs\u001b[49m\u001b[43m)\u001b[49m\n", - "File \u001b[0;32m~/ProjectAeon/aeon_mecha/aeon/io/device.py:14\u001b[0m, in \u001b[0;36mcompositeStream\u001b[0;34m(pattern, *args)\u001b[0m\n\u001b[1;32m 12\u001b[0m registry\u001b[38;5;241m.\u001b[39mupdate(method\u001b[38;5;241m.\u001b[39m\u001b[38;5;18m__func__\u001b[39m(pattern))\n\u001b[1;32m 13\u001b[0m \u001b[38;5;28;01melse\u001b[39;00m:\n\u001b[0;32m---> 14\u001b[0m registry\u001b[38;5;241m.\u001b[39mupdate(\u001b[43mstream\u001b[49m\u001b[43m(\u001b[49m\u001b[43mpattern\u001b[49m\u001b[43m)\u001b[49m)\n\u001b[1;32m 15\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m registry\n", - "\u001b[0;31mTypeError\u001b[0m: subject_weight_binder() takes 0 positional arguments but 1 was given" + "name": "stdout", + "output_type": "stream", + "text": [ + "d.registry={'subject_weight': <__main__.Subject_Weight object at 0x7f590e2eec90>, 'subject_state': }\n", + "schema.SubjectMetadata=DotMap(subject_weight=<__main__.Subject_Weight object at 0x7f590e2eec90>, subject_state=)\n", + "schema.SubjectMetadata.subject_weight=<__main__.Subject_Weight object at 0x7f590e2eec90>\n", + "schema.Metadata=\n" ] } ], @@ -1018,34 +1027,35 @@ "\n", "# A `Device` object is instantiated from a name, followed by one or more 'binder functions', which \n", "# return a dictionary of a name paired with a `Reader` object. We call such a dictionary of `:Reader`\n", - "# key-value pairs a 'registry'. Each binder function must take in a `pattern` argument, which is used to\n", - "# set the pattern of the `Reader` object it returns.\n", + "# key-value pairs a 'registry'. Each binder function must take in a `pattern` argument, which can used to \n", + "# set the pattern of the `Reader` object it returns. This requirement for binder functions is for allowing\n", + "# the `Device` to optionally pass its name to appropriately set the pattern of `Reader` objects it contains.\n", "def subject_weight_binder(pattern): # an example subject weight binder function\n", - " return {\"subject_weight\": Subject_Weight(pattern=pattern)}\n", + " return {\"subject_weight\": subject_weight_reader}\n", "\n", - "def subject_state_binder(): # an example subject state binder function\n", - " return {\"subject_state\": reader.Subject(pattern=pattern)}\n", + "def subject_state_binder(pattern): # an example subject state binder function\n", + " return {\"subject_state\": reader.Subject(pattern=\"Environment_SubjectState*\")}\n", "\n", "d = Device(\"SubjectMetadata\", subject_weight_binder, subject_state_binder)\n", "\n", - "\n", "# On creation, the `Device` object puts all registries into a single registry, which is accessible via the\n", "# `registry` attribute.\n", - "\n", + "print(f\"{d.registry=}\")\n", "\n", "# This is done so that we can create a 'schema' (a DotMap of a list of `Device` objects), where a `Device`\n", - "# object name is a key for the schema, and the `registry` names' (which are keys for the `Device` object) \n", - "# corresponding values are the `Reader` objects associated with that `Device` object.\n", + "# object name is a key for the schema, and the corresponding values of the `registry` names (which are keys\n", + "# for the `Device` object) are the `Reader` objects associated with that `Device` object.\n", + "\n", "# This works because, when a list of `Device` objects are passed into the `DotMap` constructor, the\n", - "# `__iter__` method of the `Device` object returns a tuple of the object's name with its `stream` \n", + "# `__iter__` method of the `Device` object returns a tuple of the object's name with its `stream` \n", "# attribute, which is passed in directly to the DotMap constructor to create a nested DotMap:\n", - "# device_name -> stream_name -> stream `Reader` object.\n", + "# device_name -> stream_name -> stream `Reader` object. This is shown below:\n", "\n", - "# d = Device(\"ExperimentalMetadata\", stream.environment, stream.messageLog)\n", - "# print(d.registry)\n", - "# DotMap(d.registry)\n", - "# DotMap([d])\n", - "# s = DotMap([d])" + "d2 = Device(\"Metadata\", core_binder.metadata) # instantiate Device from a defined binder function\n", + "schema = DotMap([d, d2]) # create schema as DotMap of list of Device objects\n", + "print(f\"{schema.SubjectMetadata=}\") # Device object name as key to schema\n", + "print(f\"{schema.SubjectMetadata.subject_weight=}\") # binder function name yields the Reader object\n", + "print(f\"{schema.Metadata=}\") # for a singleton Device object, Device name alone yields the Reader object" ] }, { From 422395ffab76d1aa03d651df8068b87f91de8e04 Mon Sep 17 00:00:00 2001 From: Jai Date: Wed, 13 Dec 2023 19:54:51 +0000 Subject: [PATCH 357/489] reintroduce aeon.schema --- aeon/dj_pipeline/acquisition.py | 2 +- aeon/{io/binder => schema}/__init__.py | 0 aeon/{io/binder => schema}/core.py | 0 aeon/{io/binder => schema}/foraging.py | 2 +- aeon/{io/binder => schema}/octagon.py | 0 aeon/{io/binder => schema}/schemas.py | 52 +++++++++++++------------- aeon/{io/binder => schema}/social.py | 0 tests/io/test_api.py | 2 +- 8 files changed, 28 insertions(+), 30 deletions(-) rename aeon/{io/binder => schema}/__init__.py (100%) rename aeon/{io/binder => schema}/core.py (100%) rename aeon/{io/binder => schema}/foraging.py (98%) rename aeon/{io/binder => schema}/octagon.py (100%) rename aeon/{io/binder => schema}/schemas.py (63%) rename aeon/{io/binder => schema}/social.py (100%) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 64842d65..6fa0a31f 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -5,7 +5,7 @@ import pandas as pd from aeon.io import api as io_api -from aeon.io.binder import schemas as aeon_schema +from aeon.io import schemas as aeon_schema from aeon.io import reader as io_reader from aeon.analysis import utils as analysis_utils diff --git a/aeon/io/binder/__init__.py b/aeon/schema/__init__.py similarity index 100% rename from aeon/io/binder/__init__.py rename to aeon/schema/__init__.py diff --git a/aeon/io/binder/core.py b/aeon/schema/core.py similarity index 100% rename from aeon/io/binder/core.py rename to aeon/schema/core.py diff --git a/aeon/io/binder/foraging.py b/aeon/schema/foraging.py similarity index 98% rename from aeon/io/binder/foraging.py rename to aeon/schema/foraging.py index 9267dc77..ffd8fdd9 100644 --- a/aeon/io/binder/foraging.py +++ b/aeon/schema/foraging.py @@ -4,7 +4,7 @@ import aeon.io.device as _device import aeon.io.reader as _reader -import aeon.io.binder.core as _stream +import aeon.schema.core as _stream class Area(_Enum): diff --git a/aeon/io/binder/octagon.py b/aeon/schema/octagon.py similarity index 100% rename from aeon/io/binder/octagon.py rename to aeon/schema/octagon.py diff --git a/aeon/io/binder/schemas.py b/aeon/schema/schemas.py similarity index 63% rename from aeon/io/binder/schemas.py rename to aeon/schema/schemas.py index 782767e4..778bf140 100644 --- a/aeon/io/binder/schemas.py +++ b/aeon/schema/schemas.py @@ -1,21 +1,19 @@ from dotmap import DotMap - -import aeon.io.binder.core as stream from aeon.io.device import Device -from aeon.io.binder import foraging, octagon +from aeon.schema import core, foraging, octagon exp02 = DotMap( [ - Device("Metadata", stream.metadata), - Device("ExperimentalMetadata", stream.environment, stream.messageLog), - Device("CameraTop", stream.video, stream.position, foraging.region), - Device("CameraEast", stream.video), - Device("CameraNest", stream.video), - Device("CameraNorth", stream.video), - Device("CameraPatch1", stream.video), - Device("CameraPatch2", stream.video), - Device("CameraSouth", stream.video), - Device("CameraWest", stream.video), + Device("Metadata", core.metadata), + Device("ExperimentalMetadata", core.environment, core.messageLog), + Device("CameraTop", core.video, core.position, foraging.region), + Device("CameraEast", core.video), + Device("CameraNest", core.video), + Device("CameraNorth", core.video), + Device("CameraPatch1", core.video), + Device("CameraPatch2", core.video), + Device("CameraSouth", core.video), + Device("CameraWest", core.video), Device("Nest", foraging.weight), Device("Patch1", foraging.patch), Device("Patch2", foraging.patch), @@ -25,25 +23,25 @@ exp01 = DotMap( [ Device("SessionData", foraging.session), - Device("FrameTop", stream.video, stream.position), - Device("FrameEast", stream.video), - Device("FrameGate", stream.video), - Device("FrameNorth", stream.video), - Device("FramePatch1", stream.video), - Device("FramePatch2", stream.video), - Device("FrameSouth", stream.video), - Device("FrameWest", stream.video), - Device("Patch1", foraging.depletionFunction, stream.encoder, foraging.feeder), - Device("Patch2", foraging.depletionFunction, stream.encoder, foraging.feeder), + Device("FrameTop", core.video, core.position), + Device("FrameEast", core.video), + Device("FrameGate", core.video), + Device("FrameNorth", core.video), + Device("FramePatch1", core.video), + Device("FramePatch2", core.video), + Device("FrameSouth", core.video), + Device("FrameWest", core.video), + Device("Patch1", foraging.depletionFunction, core.encoder, foraging.feeder), + Device("Patch2", foraging.depletionFunction, core.encoder, foraging.feeder), ] ) octagon01 = DotMap( [ - Device("Metadata", stream.metadata), - Device("CameraTop", stream.video, stream.position), - Device("CameraColorTop", stream.video), - Device("ExperimentalMetadata", stream.subject_state), + Device("Metadata", core.metadata), + Device("CameraTop", core.video, core.position), + Device("CameraColorTop", core.video), + Device("ExperimentalMetadata", core.subject_state), Device("Photodiode", octagon.photodiode), Device("OSC", octagon.OSC), Device("TaskLogic", octagon.TaskLogic), diff --git a/aeon/io/binder/social.py b/aeon/schema/social.py similarity index 100% rename from aeon/io/binder/social.py rename to aeon/schema/social.py diff --git a/tests/io/test_api.py b/tests/io/test_api.py index 486f1d3f..8f9d8c0b 100644 --- a/tests/io/test_api.py +++ b/tests/io/test_api.py @@ -5,7 +5,7 @@ from pytest import mark import aeon -from aeon.io.binder.schemas import exp02 +from aeon.schema.schemas import exp02 nonmonotonic_path = Path(__file__).parent.parent / "data" / "nonmonotonic" monotonic_path = Path(__file__).parent.parent / "data" / "monotonic" From f11f97ffe0ef4351b533b95a1c0f9d7fe87ce915 Mon Sep 17 00:00:00 2001 From: Jai Date: Thu, 14 Dec 2023 15:57:32 +0000 Subject: [PATCH 358/489] Refactored to use 'register' --- aeon/schema/core.py | 2 +- aeon/schema/foraging.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/aeon/schema/core.py b/aeon/schema/core.py index 8181c710..4bc75f96 100644 --- a/aeon/schema/core.py +++ b/aeon/schema/core.py @@ -24,7 +24,7 @@ def encoder(pattern): def environment(pattern): """Metadata for environment mode and subjects.""" - return _device.compositeStream(pattern, environment_state, subject_state) + return _device.register(pattern, environment_state, subject_state) def environment_state(pattern): diff --git a/aeon/schema/foraging.py b/aeon/schema/foraging.py index ffd8fdd9..7382f124 100644 --- a/aeon/schema/foraging.py +++ b/aeon/schema/foraging.py @@ -65,7 +65,7 @@ def depletionFunction(pattern): def feeder(pattern): """Feeder commands and events.""" - return _device.compositeStream(pattern, beam_break, deliver_pellet) + return _device.register(pattern, beam_break, deliver_pellet) def beam_break(pattern): @@ -80,12 +80,12 @@ def deliver_pellet(pattern): def patch(pattern): """Data streams for a patch.""" - return _device.compositeStream(pattern, depletionFunction, _stream.encoder, feeder) + return _device.register(pattern, depletionFunction, _stream.encoder, feeder) def weight(pattern): """Weight measurement data streams for a specific nest.""" - return _device.compositeStream(pattern, weight_raw, weight_filtered, weight_subject) + return _device.register(pattern, weight_raw, weight_filtered, weight_subject) def weight_raw(pattern): From 5a095c73daee881f49678d1b267dc63565599512 Mon Sep 17 00:00:00 2001 From: Jai Date: Thu, 14 Dec 2023 15:57:58 +0000 Subject: [PATCH 359/489] Social schema WIP --- ...understanding_aeon_data_architecture.ipynb | 513 ++++++++++++------ 1 file changed, 340 insertions(+), 173 deletions(-) diff --git a/docs/examples/understanding_aeon_data_architecture.ipynb b/docs/examples/understanding_aeon_data_architecture.ipynb index c67effe7..8a1ff942 100644 --- a/docs/examples/understanding_aeon_data_architecture.ipynb +++ b/docs/examples/understanding_aeon_data_architecture.ipynb @@ -80,45 +80,28 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 1, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "The autoreload extension is already loaded. To reload it, use:\n", - " %reload_ext autoreload\n" - ] - } - ], + "outputs": [], "source": [ - "\"\"\"Imports\"\"\"\n", + "\"\"\"Notebook settings and imports.\"\"\"\n", "\n", "%load_ext autoreload\n", "%autoreload 2\n", "# %flow mode reactive\n", "\n", - "from datetime import date\n", - "import ipdb\n", - "from itertools import product\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", "from pathlib import Path\n", "\n", "from dotmap import DotMap\n", - "import matplotlib.pyplot as plt\n", - "import numpy as np\n", - "import pandas as pd\n", - "import plotly.express as px\n", - "import plotly.graph_objs as go\n", - "import seaborn as sns\n", "\n", "import aeon\n", - "import aeon.io.binder.core as core_binder\n", - "from aeon.io import api\n", "from aeon.io import reader\n", - "from aeon.io.device import Device\n", - "from aeon.io.binder.schemas import exp02, exp01\n", - "from aeon.analysis.utils import visits, distancetravelled" + "from aeon.io.device import Device, register\n", + "from aeon.schema import core, foraging, social\n", + "from aeon.schema.schemas import exp02" ] }, { @@ -137,8 +120,7 @@ " pd.Series(pd.date_range(start_time, start_time + pd.Timedelta(hours=1), freq=\"1s\")),\n", " pd.Series(pd.date_range(end_time, end_time + pd.Timedelta(hours=1), freq=\"1s\"))\n", " ]\n", - ")\n", - "\n" + ")" ] }, { @@ -197,6 +179,13 @@ "execution_count": 4, "metadata": {}, "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Read from a single file:\n" + ] + }, { "data": { "text/html": [ @@ -327,6 +316,13 @@ "metadata": {}, "output_type": "display_data" }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Read from a contiguous time range:\n" + ] + }, { "data": { "text/html": [ @@ -465,6 +461,13 @@ "metadata": {}, "output_type": "display_data" }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Read from a set of times:\n" + ] + }, { "data": { "text/html": [ @@ -615,7 +618,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 5, "metadata": {}, "outputs": [ { @@ -993,14 +996,14 @@ "pattern = \"Patch1_32*\"\n", "event_name = \"beambreak\"\n", "harp_reader = reader.Harp(pattern=pattern, columns=[event_name])\n", - "data = api.load(root, harp_reader, start=start_time, end=end_time)\n", + "data = aeon.load(root, harp_reader, start=start_time, end=end_time)\n", "bitmasks = np.unique(data[event_name].values)\n", "print(f\"bitmasks: {bitmasks}\")\n", "\n", "# Let's set the bitmask to '34', and create a new `Reader` object to use this.\n", "bitmask = 34\n", "beambreak_reader = reader.BitmaskEvent(pattern, bitmask, event_name)\n", - "bitmasked_data = api.load(root, beambreak_reader, start=start_time, end=end_time)\n", + "bitmasked_data = aeon.load(root, beambreak_reader, start=start_time, end=end_time)\n", "\n", "print(f\"raw data:\\n {data.head()}\\n\\n\")\n", "print(f\"bitmasked data:\\n {bitmasked_data.head()}\")" @@ -1008,17 +1011,17 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 6, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "d.registry={'subject_weight': <__main__.Subject_Weight object at 0x7f590e2eec90>, 'subject_state': }\n", - "schema.SubjectMetadata=DotMap(subject_weight=<__main__.Subject_Weight object at 0x7f590e2eec90>, subject_state=)\n", - "schema.SubjectMetadata.subject_weight=<__main__.Subject_Weight object at 0x7f590e2eec90>\n", - "schema.Metadata=\n" + "d.registry={'subject_weight': <__main__.Subject_Weight object at 0x7f1a47076690>, 'subject_state': }\n", + "schema.SubjectMetadata=DotMap(subject_weight=<__main__.Subject_Weight object at 0x7f1a47076690>, subject_state=)\n", + "schema.SubjectMetadata.subject_weight=<__main__.Subject_Weight object at 0x7f1a47076690>\n", + "schema.Metadata=\n" ] } ], @@ -1027,9 +1030,11 @@ "\n", "# A `Device` object is instantiated from a name, followed by one or more 'binder functions', which \n", "# return a dictionary of a name paired with a `Reader` object. We call such a dictionary of `:Reader`\n", - "# key-value pairs a 'registry'. Each binder function must take in a `pattern` argument, which can used to \n", + "# key-value pairs a 'registry'. Each binder function requires a `pattern` argument, which can be used to\n", "# set the pattern of the `Reader` object it returns. This requirement for binder functions is for allowing\n", "# the `Device` to optionally pass its name to appropriately set the pattern of `Reader` objects it contains.\n", + "\n", + "# Below are examples of \"empty pattern\" binder functions, where the pattern doesn't get used.\n", "def subject_weight_binder(pattern): # an example subject weight binder function\n", " return {\"subject_weight\": subject_weight_reader}\n", "\n", @@ -1051,13 +1056,73 @@ "# attribute, which is passed in directly to the DotMap constructor to create a nested DotMap:\n", "# device_name -> stream_name -> stream `Reader` object. This is shown below:\n", "\n", - "d2 = Device(\"Metadata\", core_binder.metadata) # instantiate Device from a defined binder function\n", + "d2 = Device(\"Metadata\", core.metadata) # instantiate Device from a defined binder function\n", "schema = DotMap([d, d2]) # create schema as DotMap of list of Device objects\n", "print(f\"{schema.SubjectMetadata=}\") # Device object name as key to schema\n", "print(f\"{schema.SubjectMetadata.subject_weight=}\") # binder function name yields the Reader object\n", "print(f\"{schema.Metadata=}\") # for a singleton Device object, Device name alone yields the Reader object" ] }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "feeder_device.registry={'pellet_trigger': , 'pellet_beambreak': }\n", + "feeder_device_nested.registry={'pellet_trigger': , 'pellet_beambreak': }\n", + "patch_device.registry={'pellet_trigger': , 'pellet_beambreak': , 'Encoder': }\n" + ] + } + ], + "source": [ + "\"\"\"Nested binder functions\"\"\"\n", + "\n", + "# Binder functions can return a dict whose value is actually composed of multiple, rather than a single,\n", + "# `Reader` objects. This is done by creating nested binder functions, via `register`.\n", + "\n", + "# First let's define two standard binder functions, for pellet delivery trigger and beambreak events. \n", + "# In all examples below we'll define \"device-name passed\" binder functions, since the `Device` object which\n", + "# will be instantiated from these functions will pass its name to set the pattern of the corresponding\n", + "# Reader objects.\n", + "def pellet_trigger(pattern):\n", + " \"\"\"Pellet delivery trigger events.\"\"\"\n", + " return {\"pellet_trigger\": reader.BitmaskEvent(f\"{pattern}_35_*\", 0x80, \"PelletTriggered\")}\n", + "\n", + "\n", + "def pellet_beambreak(pattern):\n", + " \"\"\"Pellet beambreak events.\"\"\"\n", + " return {\"pellet_beambreak\": reader.BitmaskEvent(f\"{pattern}_32_*\", 0x22, \"PelletDetected\")}\n", + "\n", + "# Next, we'll define a nested binder function for a \"feeder\", which returns the two binder functions above.\n", + "def feeder(pattern):\n", + " \"\"\"Feeder commands and events.\"\"\"\n", + " return register(pattern, pellet_trigger, pellet_beambreak)\n", + "\n", + "# And further, we can define a higher-level nested binder function for a \"patch\", which includes the\n", + "# magnetic encoder values for a patch's wheel in addition to `feeder`.\n", + "def patch(pattern):\n", + " \"\"\"Data streams for a patch.\"\"\"\n", + " return register(pattern, feeder, core.encoder)\n", + "\n", + "\n", + "# We can now instantiate a `Device` object as done previously, from combinations of binder functions, but \n", + "# also from nested binder functions.\n", + "feeder_device = Device(\"Patch1\", pellet_trigger, pellet_beambreak)\n", + "feeder_device_nested = Device(\"Patch1\", feeder)\n", + "patch_device = Device(\"Patch1\", patch)\n", + "\n", + "# And we can see that `feeder_device` and `feeder_device_nested` are equivalent.\n", + "print(f\"{feeder_device.registry=}\")\n", + "print(f\"{feeder_device_nested.registry=}\")\n", + "\n", + "# And `patch_device` contains the same Reader objects as these plus an `Encoder` Reader.\n", + "print(f\"{patch_device.registry=}\")" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -1073,6 +1138,7 @@ "And here they are: (*note: register 8 is always the harp heartbeat for any device that has this stream.*)\n", "\n", "- Metadata.yml\n", + "- AudioAmbient\n", "- Environment\n", " - BlockState\n", " - EnvironmentState\n", @@ -1126,165 +1192,244 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 8, "metadata": {}, "outputs": [], "source": [ "\"\"\"Creating the Social 0.1 schema\"\"\"\n", "\n", - "# Above we've listed out all the streams we recorded from during Social0.1, but we won't care to analyze all of them.\n", - "# Instead, we'll create a schema that only contains the streams we want to analyze:\n", + "# Above we've listed out all the streams we recorded from during Social0.1, but we won't care to analyze all\n", + "# of them. Instead, we'll create a DotMap schema from Device objects that only contains Readers for the\n", + "# streams we want to analyze.\n", "\n", - "# Metadata\n", + "# We'll see both examples of binder functions we saw previously: 1. \"empty pattern\", and\n", + "# 2. \"device-name passed\".\n", "\n", - "# Environment\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "DotMap(\n", - " [\n", - " Device(\"Metadata\", stream.metadata),\n", - " Device(\"ExperimentalMetadata\", stream.environment, stream.messageLog),\n", - " Device(\"CameraEast\", stream.video),\n", - " Device(\"CameraNest\", stream.video),\n", - " Device(\"CameraNorth\", stream.video),\n", - " Device(\"CameraPatch1\", stream.video),\n", - " Device(\"CameraPatch2\", stream.video),\n", - " Device(\"CameraSouth\", stream.video),\n", - " Device(\"CameraWest\", stream.video),\n", - " ]\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "d = Device(\"ExperimentalMetadata\", stream.environment, stream.messageLog)\n", - "print(d.registry)\n", - "DotMap(d.registry)\n", - "DotMap([d])\n", - "s = DotMap([d])\n", - "s.ExperimentalMetadata.EnvironmentState.pattern" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "\"\"\"Metadata\"\"\"\n", + "# And we'll see both examples of instantiating Device objects we saw previously: 1. from singleton binder\n", + "# functions; 2. from multiple and/or nested binder functions.\n", "\n", - "data = api.load(root, reader.Metadata(), start=start, end=end)\n", - "data.metadata.iloc[0] # get device metadata dotmap" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "d = Device(\"ExperimentalMetadata\", stream.environment, stream.messageLog)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "Device(\"Metadata\", stream.metadata).stream" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "d = Device(\"test\", reader.Video, reader.Metadata)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "d.name" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "exp02.ExperimentalMetadata" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "exp02.Metadata" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "exp02.ExperimentalMetadata.keys()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "api.load(root, exp02.ExperimentalMetadata., start=start, end=end)" + "# (Note, in the simplest case, a schema can always be created from / reduced to \"empty pattern\" binder\n", + "# functions as singletons in Device objects.)\n", + "\n", + "# Metadata (will be a singleton binder function Device object)\n", + "# ---\n", + "\n", + "# `core.metadata` is a \"device-name passed\" binder function that returns a `reader.Metadata` Reader object\n", + "metadata_device = Device(\"Metadata\", core.metadata)\n", + "\n", + "# ---\n", + "\n", + "# Environment (will be a nested, multiple binder function Device object)\n", + "# ---\n", + "\n", + "# BlockState\n", + "cols = [\"pellet_ct\", \"pellet_ct_thresh\", \"due_time\"]\n", + "block_state_reader = reader.Csv(\"Environment_BlockState*\", cols)\n", + "# \"Empty pattern\" binder fn.\n", + "block_state_binder_fn = lambda pattern: {\"block_state\": block_state_reader} \n", + "\n", + "# EnvironmentState\n", + "\n", + "# LightEvents\n", + "\n", + "# MessageLog\n", + "\n", + "# SubjectState\n", + "\n", + "# SubjectVisits\n", + "\n", + "# SubjectWeight\n", + "\n", + "# Nested binder fn Device object.\n", + "environment_device = Device(\n", + " \"Environment\", \n", + " block_state_binder_fn, \n", + " core.environment # readers for \n", + ")\n", + "\n", + "# ---" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 10, "metadata": {}, "outputs": [], "source": [ - "\"\"\"Environment\"\"\"\n" + "d = Device(\"Environment\", block_state_binder_fn)" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 13, "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ - "\"\"\"CameraTop\"\"\"" + "d.registry[\"block_state\"]" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 14, "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    pellet_ctpellet_ct_threshdue_time
    time
    2023-12-02 10:30:44.8520002376420001-01-01T00:00:00.0000000
    2023-12-02 10:30:58.8680000317420001-01-01T00:00:00.0000000
    2023-12-02 10:31:14.0159997948420001-01-01T00:00:00.0000000
    2023-12-02 10:31:29.2859840399420001-01-01T00:00:00.0000000
    2023-12-02 10:31:45.99200010310420001-01-01T00:00:00.0000000
    ............
    2023-12-02 12:27:07.51200008313430001-01-01T00:00:00.0000000
    2023-12-02 12:27:20.61999988614430001-01-01T00:00:00.0000000
    2023-12-02 12:28:36.51398420315430001-01-01T00:00:00.0000000
    2023-12-02 12:29:02.42598390616430001-01-01T00:00:00.0000000
    2023-12-02 12:29:17.12998390217430001-01-01T00:00:00.0000000
    \n", + "

    97 rows × 3 columns

    \n", + "
    " + ], + "text/plain": [ + " pellet_ct pellet_ct_thresh \\\n", + "time \n", + "2023-12-02 10:30:44.852000237 6 42 \n", + "2023-12-02 10:30:58.868000031 7 42 \n", + "2023-12-02 10:31:14.015999794 8 42 \n", + "2023-12-02 10:31:29.285984039 9 42 \n", + "2023-12-02 10:31:45.992000103 10 42 \n", + "... ... ... \n", + "2023-12-02 12:27:07.512000083 13 43 \n", + "2023-12-02 12:27:20.619999886 14 43 \n", + "2023-12-02 12:28:36.513984203 15 43 \n", + "2023-12-02 12:29:02.425983906 16 43 \n", + "2023-12-02 12:29:17.129983902 17 43 \n", + "\n", + " due_time \n", + "time \n", + "2023-12-02 10:30:44.852000237 0001-01-01T00:00:00.0000000 \n", + "2023-12-02 10:30:58.868000031 0001-01-01T00:00:00.0000000 \n", + "2023-12-02 10:31:14.015999794 0001-01-01T00:00:00.0000000 \n", + "2023-12-02 10:31:29.285984039 0001-01-01T00:00:00.0000000 \n", + "2023-12-02 10:31:45.992000103 0001-01-01T00:00:00.0000000 \n", + "... ... \n", + "2023-12-02 12:27:07.512000083 0001-01-01T00:00:00.0000000 \n", + "2023-12-02 12:27:20.619999886 0001-01-01T00:00:00.0000000 \n", + "2023-12-02 12:28:36.513984203 0001-01-01T00:00:00.0000000 \n", + "2023-12-02 12:29:02.425983906 0001-01-01T00:00:00.0000000 \n", + "2023-12-02 12:29:17.129983902 0001-01-01T00:00:00.0000000 \n", + "\n", + "[97 rows x 3 columns]" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ - "\"\"\"Top quadrant and zoomed in patch cameras\"\"\"" + "start_time = pd.Timestamp(\"2023-12-02 10:30:00\")\n", + "end_time = pd.Timestamp(\"2023-12-02 12:30:00\")\n", + "r = d.registry[\"block_state\"]\n", + "aeon.load(root, r, start=start_time, end=end_time)" ] }, { @@ -1293,7 +1438,12 @@ "metadata": {}, "outputs": [], "source": [ - "\"\"\"Nest\"\"\"" + "social01 = DotMap(\n", + " [\n", + " metadata_device,\n", + " \n", + " ]\n", + ")" ] }, { @@ -1302,7 +1452,7 @@ "metadata": {}, "outputs": [], "source": [ - "\"\"\"Patches\"\"\"" + "exp02.ExperimentalMetadata.EnvironmentState" ] }, { @@ -1311,7 +1461,24 @@ "metadata": {}, "outputs": [], "source": [ - "\"\"\"Rfids\"\"\"" + "\"\"\"Test all readers in schema.\"\"\"\n", + "\n", + "def find_obj(dotmap, obj):\n", + " \"\"\"Returns a list of objects of type `obj` found in a DotMap.\"\"\"\n", + " objs = []\n", + " for value in dotmap.values():\n", + " if isinstance(value, obj):\n", + " objs.append(value)\n", + " elif isinstance(value, DotMap):\n", + " objs.extend(find_obj(value, obj))\n", + " return objs\n", + "\n", + "readers = find_obj(social01, reader.Reader)\n", + "for r in readers:\n", + " data = aeon.load(root, r, start=start_time, end=end_time)\n", + " #assert not data.empty, f\"No data found with {r}.\"\n", + " print(f\"{r}: {data.head()=}\")\n", + " " ] } ], From 14d463525d28d4f57c361c1751a6ef6020ff9cd1 Mon Sep 17 00:00:00 2001 From: Jai Date: Thu, 14 Dec 2023 17:35:53 +0000 Subject: [PATCH 360/489] Refactored for consistent snake_case --- aeon/schema/core.py | 2 +- aeon/schema/foraging.py | 4 ++-- aeon/schema/schemas.py | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/aeon/schema/core.py b/aeon/schema/core.py index 4bc75f96..cf60dc46 100644 --- a/aeon/schema/core.py +++ b/aeon/schema/core.py @@ -37,7 +37,7 @@ def subject_state(pattern): return {"SubjectState": _reader.Subject(f"{pattern}_SubjectState_*")} -def messageLog(pattern): +def message_log(pattern): """Message log data.""" return {"MessageLog": _reader.Log(f"{pattern}_MessageLog_*")} diff --git a/aeon/schema/foraging.py b/aeon/schema/foraging.py index 7382f124..df42cc1a 100644 --- a/aeon/schema/foraging.py +++ b/aeon/schema/foraging.py @@ -58,7 +58,7 @@ def region(pattern): return {"Region": _RegionReader(f"{pattern}_201_*")} -def depletionFunction(pattern): +def depletion_function(pattern): """State of the linear depletion function for foraging patches.""" return {"DepletionState": _PatchState(f"{pattern}_State_*")} @@ -80,7 +80,7 @@ def deliver_pellet(pattern): def patch(pattern): """Data streams for a patch.""" - return _device.register(pattern, depletionFunction, _stream.encoder, feeder) + return _device.register(pattern, depletion_function, _stream.encoder, feeder) def weight(pattern): diff --git a/aeon/schema/schemas.py b/aeon/schema/schemas.py index 778bf140..7b61c2d7 100644 --- a/aeon/schema/schemas.py +++ b/aeon/schema/schemas.py @@ -5,7 +5,7 @@ exp02 = DotMap( [ Device("Metadata", core.metadata), - Device("ExperimentalMetadata", core.environment, core.messageLog), + Device("ExperimentalMetadata", core.environment, core.message_log), Device("CameraTop", core.video, core.position, foraging.region), Device("CameraEast", core.video), Device("CameraNest", core.video), @@ -31,8 +31,8 @@ Device("FramePatch2", core.video), Device("FrameSouth", core.video), Device("FrameWest", core.video), - Device("Patch1", foraging.depletionFunction, core.encoder, foraging.feeder), - Device("Patch2", foraging.depletionFunction, core.encoder, foraging.feeder), + Device("Patch1", foraging.depletion_function, core.encoder, foraging.feeder), + Device("Patch2", foraging.depletion_function, core.encoder, foraging.feeder), ] ) From 517110852b40417478a7e294973917a390fe6723 Mon Sep 17 00:00:00 2001 From: Jai Date: Fri, 15 Dec 2023 23:20:06 +0000 Subject: [PATCH 361/489] Removed device name from path --- aeon/schema/social.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/schema/social.py b/aeon/schema/social.py index 97453af3..6ad46ea0 100644 --- a/aeon/schema/social.py +++ b/aeon/schema/social.py @@ -32,7 +32,7 @@ def read( ) -> pd.DataFrame: """Reads data from the Harp-binarized tracking file.""" # Get config file from `file`, then bodyparts from config file. - model_dir = Path(file.stem.replace("_", "/")).parent + model_dir = Path(*Path(file.stem.replace("_", "/")).parent.parts[1:]) config_file_dir = ceph_proc_dir / model_dir if not config_file_dir.exists(): raise FileNotFoundError(f"Cannot find model dir {config_file_dir}") From 8683fee5cb1df7d0e4b2d694fd4e208e7af4fc1d Mon Sep 17 00:00:00 2001 From: Jai Date: Sat, 16 Dec 2023 01:14:00 +0000 Subject: [PATCH 362/489] Finished and tested social schema for data between 2023-12-01 -- 2023-12-08 --- aeon/io/device.py | 2 +- .../get_harp_stream_event_bitmask.ipynb | 1 + ...understanding_aeon_data_architecture.ipynb | 3061 +++++++++++++++-- 3 files changed, 2868 insertions(+), 196 deletions(-) diff --git a/aeon/io/device.py b/aeon/io/device.py index d9435a8c..e8e5cf0f 100644 --- a/aeon/io/device.py +++ b/aeon/io/device.py @@ -23,7 +23,7 @@ class Device: Attributes: name (str): Name of the device. - args (Any): Data streams collected from the device. + args (any): A binder function or class that returns a dictionary of Readers. pattern (str, optional): Pattern used to find raw chunk files, usually in the format `_`. """ diff --git a/docs/examples/get_harp_stream_event_bitmask.ipynb b/docs/examples/get_harp_stream_event_bitmask.ipynb index ffbeb6ef..3c01212b 100644 --- a/docs/examples/get_harp_stream_event_bitmask.ipynb +++ b/docs/examples/get_harp_stream_event_bitmask.ipynb @@ -12,6 +12,7 @@ "%autoreload 2\n", "\n", "from pathlib import Path\n", + "import pandas as pd\n", "\n", "import aeon.io.api as api\n", "from aeon.io import reader" diff --git a/docs/examples/understanding_aeon_data_architecture.ipynb b/docs/examples/understanding_aeon_data_architecture.ipynb index 8a1ff942..e3df6981 100644 --- a/docs/examples/understanding_aeon_data_architecture.ipynb +++ b/docs/examples/understanding_aeon_data_architecture.ipynb @@ -60,9 +60,11 @@ "On ceph, we organize streams into device folders:
    e.g. `ceph/aeon/aeon/data/raw/AEON3/social0.1/2023-12-01T14-30-34/Patch1` contains the patch-heartbeat stream (`Patch1_8`), the patch-beambreak stream (`Patch1_32`), the patch-pellet delivery-pin-set stream (`Patch1_35`), the patch-pellet-delivery-pin-cleared stream (`Patch1_36`), the patch-wheel-magnetic-encoder stream (`Patch1_90`), the patch-wheel-magnetic-encoder-mode stream (`Patch1_91`), the patch-feeder-dispenser-state stream (`Patch1_200`), the patch-pellet-manual-delivery stream (`Patch1_201`), the patch-missed-pellet-stream (`Patch1_202`), the patch-pellet-delivery-retry stream (`Patch1_203`), and the patch-state stream (`Patch1_State`).\n", "\n", "In code, we create logical devices via the `Device` class (see `aeon/io/device.py`)
    \n", - "e.g. We often define 'Patch' devices that contain `Reader` objects associated with specific streams (as experimenters may not care about analyzing all streams in a `Patch` device folder on ceph), e.g. wheel-magnetic-encoder, state, pellet-delivery-pin-set, and beambreak.\n", + "e.g. We often define 'Patch' devices that contain `Reader` objects (in the _`register`_ attribute) that are associated with specific streams (as experimenters may not care about analyzing all streams in a `Patch` device folder on ceph), e.g. wheel-magnetic-encoder, state, pellet-delivery-pin-set, and beambreak.\n", "\n", - "**_Schema_**: A list of devices grouped within a `DotMap` object (see `aeon/docs/examples/schemas.py`). Each experiment is associated with a schema. If a schema changes, then the experiment neccesarily must be different (either in name or version number), as the acquired data is now different.\n", + "One last important aspect of `Device` objects are _binder functions_: on instantiation, `Device` requires at least one argument that is a function that returns a dict of `Reader` objects (these get set into the `Device` object's `registry`). We'll explain this more in detail and show examples below.\n", + "\n", + "**_Schema_**: A list of devices grouped within a `DotMap` object (see `aeon/docs/examples/schemas.py`). Each experiment is associated with a schema. If a schema changes, then the experiment neccesarily must be different (either in name or version number), as the acquired data is now different.\n", "\n", "**_Dataset_**: All data belonging to a particular experiment. \n", "\n", @@ -80,9 +82,18 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 45, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The autoreload extension is already loaded. To reload it, use:\n", + " %reload_ext autoreload\n" + ] + } + ], "source": [ "\"\"\"Notebook settings and imports.\"\"\"\n", "\n", @@ -106,7 +117,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 46, "metadata": {}, "outputs": [], "source": [ @@ -125,7 +136,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 47, "metadata": {}, "outputs": [], "source": [ @@ -176,7 +187,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 48, "metadata": {}, "outputs": [ { @@ -618,7 +629,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 49, "metadata": {}, "outputs": [ { @@ -1011,17 +1022,17 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 50, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "d.registry={'subject_weight': <__main__.Subject_Weight object at 0x7f1a47076690>, 'subject_state': }\n", - "schema.SubjectMetadata=DotMap(subject_weight=<__main__.Subject_Weight object at 0x7f1a47076690>, subject_state=)\n", - "schema.SubjectMetadata.subject_weight=<__main__.Subject_Weight object at 0x7f1a47076690>\n", - "schema.Metadata=\n" + "d.registry={'subject_weight': <__main__.Subject_Weight object at 0x7fa4433d5f50>, 'subject_state': }\n", + "schema.SubjectMetadata=DotMap(subject_weight=<__main__.Subject_Weight object at 0x7fa4433d5f50>, subject_state=)\n", + "schema.SubjectMetadata.subject_weight=<__main__.Subject_Weight object at 0x7fa4433d5f50>\n", + "schema.Metadata=\n" ] } ], @@ -1065,16 +1076,16 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 51, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "feeder_device.registry={'pellet_trigger': , 'pellet_beambreak': }\n", - "feeder_device_nested.registry={'pellet_trigger': , 'pellet_beambreak': }\n", - "patch_device.registry={'pellet_trigger': , 'pellet_beambreak': , 'Encoder': }\n" + "feeder_device.registry={'pellet_trigger': , 'pellet_beambreak': }\n", + "feeder_device_nested.registry={'pellet_trigger': , 'pellet_beambreak': }\n", + "patch_device.registry={'pellet_trigger': , 'pellet_beambreak': , 'Encoder': }\n" ] } ], @@ -1129,7 +1140,7 @@ "source": [ "#### Social0.1 Data Streams\n", "\n", - "Now that we've covered streams, devices, and schemas, let's build a schema for the Social0.1 Experiment!\n", + "Now that we've covered streams, readers, binder functions, devices, and schemas, let's build a schema for the Social0.1 Experiment!\n", "\n", "First we'll need to know all the streams we recorded during the Social0.1 experiment: these can be found via\n", "looking through all devices in an acqusition epoch \n", @@ -1138,7 +1149,8 @@ "And here they are: (*note: register 8 is always the harp heartbeat for any device that has this stream.*)\n", "\n", "- Metadata.yml\n", - "- AudioAmbient\n", + "- AudioAmbient (.wav)\n", + " - .wav: raw audio\n", "- Environment\n", " - BlockState\n", " - EnvironmentState\n", @@ -1147,7 +1159,7 @@ " - SubjectState\n", " - SubjectVisits\n", " - SubjectWeight\n", - "- CameraTop (200, 201, avi, csv, ,)\n", + "- CameraTop (200, 201, .avi, .csv, )\n", " - 200: position\n", " - 201: region\n", "- CameraNorth (avi, csv)\n", @@ -1159,40 +1171,42 @@ "- CameraPatch3 (avi, csv)\n", "- CameraNest (avi, csv)\n", "- ClockSynchronizer (8, 36)\n", - " - 36: hearbeat_out\n", + " - 36: hearbeat out\n", "- Nest (200, 201, 202, 203)\n", - " - 200: weight_raw\n", - " - 201: weight_tare\n", - " - 202: weight_filtered\n", - " - 203: weight_baseline\n", - " - 204: weight_subject\n", + " - 200: raw weight\n", + " - 201: tare weight\n", + " - 202: filtered weight\n", + " - 203: baseline weight\n", + " - 204: subject weight\n", "- Patch1 (8, 32, 35, 36, 87, 90, 91, 200, 201, 202, 203, State)\n", - " - 32: beam_break\n", - " - 35: delivery_set\n", - " - 36: delivery_clear\n", - " - 87: expansion_board\n", - " - 90: encoder_read\n", - " - 91: encoder_mode\n", - " - 200: dispenser_state\n", - " - 201: delivery_manual\n", - " - 202: missed_pellet\n", - " - 203: delivery_retry\n", + " - 32: beambreak\n", + " - 35: set delivery\n", + " - 36: clear delivery\n", + " - 87: expansion board state\n", + " - 90: encoder read\n", + " - 91: encoder mode\n", + " - 200: dispenser state\n", + " - 201: manual delivery\n", + " - 202: missed pellet\n", + " - 203: retry delivery\n", "- Patch2 (8, 32, 35, 36, 87, 90, 91, State)\n", "- Patch3 (8, 32, 35, 36, 87, 90, 91, 200, 203, State)\n", "- RfidEventsGate (8, 32, 35)\n", - " - 32: entry_id\n", - " - 35: hardware_notifications\n", + " - 32: entry id\n", + " - 35: hardware notifications\n", "- RfidEventsNest1 (8, 32, 35)\n", "- RfidEventsNest2 (8, 32, 35)\n", "- RfidEventsPatch1 (8, 32, 35)\n", "- RfidEventsPatch2 (8, 32, 35)\n", "- RfidEventsPatch3 (8, 32, 35)\n", + "- System\n", + " - AvailableMemory\n", "- VideoController (8, 32, 33, 34, 35, 36, 45, 52)" ] }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 52, "metadata": {}, "outputs": [], "source": [ @@ -1211,11 +1225,11 @@ "# (Note, in the simplest case, a schema can always be created from / reduced to \"empty pattern\" binder\n", "# functions as singletons in Device objects.)\n", "\n", - "# Metadata (will be a singleton binder function Device object)\n", + "# Metadata.yml (will be a singleton binder function Device object)\n", "# ---\n", "\n", - "# `core.metadata` is a \"device-name passed\" binder function that returns a `reader.Metadata` Reader object\n", - "metadata_device = Device(\"Metadata\", core.metadata)\n", + "core.metadata # binder function: \"device-name passed\": returns a `reader.Metadata` Reader object\n", + "metadata = Device(\"Metadata\", core.metadata)\n", "\n", "# ---\n", "\n", @@ -1223,28 +1237,137 @@ "# ---\n", "\n", "# BlockState\n", - "cols = [\"pellet_ct\", \"pellet_ct_thresh\", \"due_time\"]\n", - "block_state_reader = reader.Csv(\"Environment_BlockState*\", cols)\n", - "# \"Empty pattern\" binder fn.\n", - "block_state_binder_fn = lambda pattern: {\"block_state\": block_state_reader} \n", + "# binder function: \"device-name passed\"; `pattern` will be set by `Device` object name: \"Environment\"\n", + "block_state_b = lambda pattern: {\n", + " \"BlockState\": reader.Csv(f\"{pattern}_BlockState*\", [\"pellet_ct\", \"pellet_ct_thresh\", \"due_time\"])\n", + "}\n", "\n", "# EnvironmentState\n", + "core.environment_state # binder function: \"device-name passed\"\n", + "\n", + "# Combine EnvironmentState and BlockState\n", + "env_block_state_b = lambda pattern: register(pattern, core.environment_state, block_state_b)\n", "\n", "# LightEvents\n", + "cols = [\"channel\", \"value\"]\n", + "light_events_r = reader.Csv(\"Environment_LightEvents*\", cols)\n", + "light_events_b = lambda pattern: {\"LightEvents\": light_events_r} # binder function: \"empty pattern\"\n", "\n", "# MessageLog\n", + "core.message_log # binder function: \"device-name passed\"\n", "\n", "# SubjectState\n", + "cols = [\"id\", \"weight\", \"type\"]\n", + "subject_state_r = reader.Csv(\"Environment_SubjectState*\", cols)\n", + "subject_state_b = lambda pattern: {\"SubjectState\": subject_state_r} # binder function: \"empty pattern\"\n", "\n", "# SubjectVisits\n", + "cols = [\"id\", \"type\", \"region\"]\n", + "subject_visits_r = reader.Csv(\"Environment_SubjectVisits*\", cols)\n", + "subject_visits_b = lambda pattern: {\"SubjectVisits\": subject_visits_r} # binder function: \"empty pattern\"\n", "\n", "# SubjectWeight\n", + "cols = [\"weight\", \"confidence\", \"subject_id\", \"int_id\"]\n", + "subject_weight_r = reader.Csv(\"Environment_SubjectWeight*\", cols)\n", + "subject_weight_b = lambda pattern: {\"SubjectWeight\": subject_weight_r} # binder function: \"empty pattern\"\n", "\n", "# Nested binder fn Device object.\n", - "environment_device = Device(\n", - " \"Environment\", \n", - " block_state_binder_fn, \n", - " core.environment # readers for \n", + "environment = Device(\n", + " \"Environment\", # device name\n", + " env_block_state_b,\n", + " light_events_b,\n", + " core.message_log\n", + ")\n", + "\n", + "# Separate Device object for subject-specific streams.\n", + "subject = Device(\n", + " \"Subject\",\n", + " subject_state_b,\n", + " subject_visits_b,\n", + " subject_weight_b\n", + ")\n", + "\n", + "# ---\n", + "\n", + "# Camera\n", + "# ---\n", + "\n", + "camera_top_b = lambda pattern: {\"CameraTop\": reader.Video(\"CameraTop*\")}\n", + "camera_top_pos_b = lambda pattern: {\"CameraTopPos\": social.Pose(\"CameraTop_test-node1*\")}\n", + "\n", + "cam_names = [\"North\", \"South\", \"East\", \"West\", \"Patch1\", \"Patch2\", \"Patch3\", \"Nest\"]\n", + "cam_names = [\"Camera\" + name for name in cam_names]\n", + "camera_b = [lambda pattern, name=name: {name: reader.Video(name + \"*\")} for name in cam_names]\n", + "\n", + "camera = Device(\n", + " \"Camera\", \n", + " camera_top_b, \n", + " camera_top_pos_b, \n", + " *camera_b\n", + ")\n", + "\n", + "# ---\n", + "\n", + "# Nest\n", + "# ---\n", + "\n", + "weight_raw_b = lambda pattern: {\"WeightRaw\": reader.Harp(\"Nest_200*\", [\"weight(g)\", \"stability\"])}\n", + "weight_filtered_b = lambda pattern: {\"WeightFiltered\": reader.Harp(\"Nest_202*\", [\"weight(g)\", \"stability\"])}\n", + "\n", + "nest = Device(\n", + " \"Nest\", \n", + " weight_raw_b, \n", + " weight_filtered_b, \n", + ")\n", + "\n", + "# ---\n", + "\n", + "# Patch\n", + "# ---\n", + "\n", + "patches = [\"1\", \"2\", \"3\"]\n", + "patch_streams = [\"32\", \"35\", \"90\", \"201\", \"202\", \"203\", \"State\"]\n", + "patch_names = [\"Patch\" + name + \"_\" + stream for name in patches for stream in patch_streams]\n", + "patch_b = []\n", + "for stream in patch_names:\n", + " if \"32\" in stream:\n", + " fn = lambda pattern, stream=stream: {\n", + " stream: reader.BitmaskEvent(stream + \"*\", value=34, tag=\"beambreak\")\n", + " }\n", + " elif \"35\" in stream:\n", + " fn = lambda pattern, stream=stream: {\n", + " stream: reader.BitmaskEvent(stream + \"*\", value=1, tag=\"delivery\")\n", + " }\n", + " elif \"90\" in stream:\n", + " fn = lambda pattern, stream=stream: {stream: reader.Encoder(stream + \"*\")}\n", + " elif \"201\" in stream:\n", + " fn = lambda pattern, stream=stream: {stream: reader.Harp(stream + \"*\", [\"manual_delivery\"])}\n", + " elif \"202\" in stream:\n", + " fn = lambda pattern, stream=stream: {stream: reader.Harp(stream + \"*\", [\"missed_pellet\"])}\n", + " elif \"203\" in stream:\n", + " fn = lambda pattern, stream=stream: {stream: reader.Harp(stream + \"*\", [\"retried_delivery\"])}\n", + " elif \"State\" in stream:\n", + " fn = lambda pattern, stream=stream: {\n", + " stream: reader.Csv(stream + \"*\", [\"threshold\", \"offset\", \"rate\"])\n", + " }\n", + " patch_b.append(fn)\n", + "\n", + "patch = Device(\n", + " \"Patch\", \n", + " *patch_b\n", + ")\n", + "# ---\n", + "\n", + "# Rfid\n", + "# ---\n", + "\n", + "rfid_names = [\"EventsGate\", \"EventsNest1\", \"EventsNest2\", \"EventsPatch1\", \"EventsPatch2\", \"EventsPatch3\"]\n", + "rfid_names = [\"Rfid\" + name for name in rfid_names]\n", + "rfid_b = [lambda pattern, name=name: {name: reader.Harp(name + \"*\", [\"rfid\"])} for name in rfid_names]\n", + "\n", + "rfid = Device(\n", + " \"Rfid\", \n", + " *rfid_b\n", ")\n", "\n", "# ---" @@ -1252,38 +1375,49 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 53, "metadata": {}, "outputs": [], "source": [ - "d = Device(\"Environment\", block_state_binder_fn)" + "social01 = DotMap(\n", + " [\n", + " metadata,\n", + " environment,\n", + " subject,\n", + " camera,\n", + " nest,\n", + " patch,\n", + " rfid\n", + " ]\n", + ")" ] }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 54, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ - "d.registry[\"block_state\"]" + "# cols = [\"1\", \"2\", \"3\", \"4\", \"5\"]\n", + "# r = reader.Harp(\"RfidEventsGate_32*\", cols)\n", + "# start_time = pd.Timestamp(\"2023-12-02 10:30:00\")\n", + "# end_time = pd.Timestamp(\"2023-12-02 12:30:00\")\n", + "# aeon.load(root, r, start=start_time, end=end_time)" ] }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 56, "metadata": {}, "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Metadata:\n" + ] + }, { "data": { "text/html": [ @@ -1305,9 +1439,9 @@ " \n", " \n", " \n", - " pellet_ct\n", - " pellet_ct_thresh\n", - " due_time\n", + " workflow\n", + " commit\n", + " metadata\n", " \n", " \n", " time\n", @@ -1317,169 +1451,2706 @@ " \n", " \n", " \n", - " \n", - " 2023-12-02 10:30:44.852000237\n", - " 6\n", - " 42\n", - " 0001-01-01T00:00:00.0000000\n", + " \n", + "\n", + "" + ], + "text/plain": [ + "Empty DataFrame\n", + "Columns: [workflow, commit, metadata]\n", + "Index: []" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Environment_EnvironmentState_*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", " \n", + " \n", + " \n", " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + "
    state
    2023-12-02 10:30:58.8680000317420001-01-01T00:00:00.0000000time
    2023-12-02 10:31:14.0159997948420001-01-01T00:00:00.00000002023-12-05 15:28:04.552000046Maintenance
    2023-12-02 10:31:29.2859840399420001-01-01T00:00:00.00000002023-12-05 15:30:23.199999809Experiment
    2023-12-02 10:31:45.99200010310420001-01-01T00:00:00.0000000
    \n", + "
    " + ], + "text/plain": [ + " state\n", + "time \n", + "2023-12-05 15:28:04.552000046 Maintenance\n", + "2023-12-05 15:30:23.199999809 Experiment" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Environment_BlockState*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", " \n", + " \n", + " \n", " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", " \n", " \n", " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", " \n", " \n", " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", " \n", " \n", " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", " \n", " \n", " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", " \n", " \n", " \n", "
    pellet_ctpellet_ct_threshdue_time
    ............time
    2023-12-02 12:27:07.51200008313432023-12-05 15:02:21.03200006533390001-01-01T00:00:00.0000000
    2023-12-02 12:27:20.61999988614432023-12-05 15:02:45.59999990534390001-01-01T00:00:00.0000000
    2023-12-02 12:28:36.51398420315432023-12-05 15:02:56.76399993935390001-01-01T00:00:00.0000000
    2023-12-02 12:29:02.42598390616432023-12-05 15:09:38.00400018636390001-01-01T00:00:00.0000000
    2023-12-02 12:29:17.12998390217432023-12-05 15:09:59.62799978337390001-01-01T00:00:00.0000000
    \n", - "

    97 rows × 3 columns

    \n", "
    " ], "text/plain": [ " pellet_ct pellet_ct_thresh \\\n", "time \n", - "2023-12-02 10:30:44.852000237 6 42 \n", - "2023-12-02 10:30:58.868000031 7 42 \n", - "2023-12-02 10:31:14.015999794 8 42 \n", - "2023-12-02 10:31:29.285984039 9 42 \n", - "2023-12-02 10:31:45.992000103 10 42 \n", - "... ... ... \n", - "2023-12-02 12:27:07.512000083 13 43 \n", - "2023-12-02 12:27:20.619999886 14 43 \n", - "2023-12-02 12:28:36.513984203 15 43 \n", - "2023-12-02 12:29:02.425983906 16 43 \n", - "2023-12-02 12:29:17.129983902 17 43 \n", + "2023-12-05 15:02:21.032000065 33 39 \n", + "2023-12-05 15:02:45.599999905 34 39 \n", + "2023-12-05 15:02:56.763999939 35 39 \n", + "2023-12-05 15:09:38.004000186 36 39 \n", + "2023-12-05 15:09:59.627999783 37 39 \n", "\n", " due_time \n", "time \n", - "2023-12-02 10:30:44.852000237 0001-01-01T00:00:00.0000000 \n", - "2023-12-02 10:30:58.868000031 0001-01-01T00:00:00.0000000 \n", - "2023-12-02 10:31:14.015999794 0001-01-01T00:00:00.0000000 \n", - "2023-12-02 10:31:29.285984039 0001-01-01T00:00:00.0000000 \n", - "2023-12-02 10:31:45.992000103 0001-01-01T00:00:00.0000000 \n", - "... ... \n", - "2023-12-02 12:27:07.512000083 0001-01-01T00:00:00.0000000 \n", - "2023-12-02 12:27:20.619999886 0001-01-01T00:00:00.0000000 \n", - "2023-12-02 12:28:36.513984203 0001-01-01T00:00:00.0000000 \n", - "2023-12-02 12:29:02.425983906 0001-01-01T00:00:00.0000000 \n", - "2023-12-02 12:29:17.129983902 0001-01-01T00:00:00.0000000 \n", - "\n", - "[97 rows x 3 columns]" + "2023-12-05 15:02:21.032000065 0001-01-01T00:00:00.0000000 \n", + "2023-12-05 15:02:45.599999905 0001-01-01T00:00:00.0000000 \n", + "2023-12-05 15:02:56.763999939 0001-01-01T00:00:00.0000000 \n", + "2023-12-05 15:09:38.004000186 0001-01-01T00:00:00.0000000 \n", + "2023-12-05 15:09:59.627999783 0001-01-01T00:00:00.0000000 " ] }, - "execution_count": 14, "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "start_time = pd.Timestamp(\"2023-12-02 10:30:00\")\n", - "end_time = pd.Timestamp(\"2023-12-02 12:30:00\")\n", - "r = d.registry[\"block_state\"]\n", - "aeon.load(root, r, start=start_time, end=end_time)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "social01 = DotMap(\n", - " [\n", - " metadata_device,\n", - " \n", - " ]\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "exp02.ExperimentalMetadata.EnvironmentState" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "\"\"\"Test all readers in schema.\"\"\"\n", - "\n", - "def find_obj(dotmap, obj):\n", - " \"\"\"Returns a list of objects of type `obj` found in a DotMap.\"\"\"\n", - " objs = []\n", - " for value in dotmap.values():\n", - " if isinstance(value, obj):\n", - " objs.append(value)\n", - " elif isinstance(value, DotMap):\n", - " objs.extend(find_obj(value, obj))\n", - " return objs\n", - "\n", - "readers = find_obj(social01, reader.Reader)\n", - "for r in readers:\n", - " data = aeon.load(root, r, start=start_time, end=end_time)\n", - " #assert not data.empty, f\"No data found with {r}.\"\n", - " print(f\"{r}: {data.head()=}\")\n", - " " - ] + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Environment_LightEvents*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    channelvalue
    time
    2023-12-05 15:00:00378
    2023-12-05 15:00:00778
    2023-12-05 15:00:0050
    2023-12-05 15:00:00180
    2023-12-05 15:00:00350
    \n", + "
    " + ], + "text/plain": [ + " channel value\n", + "time \n", + "2023-12-05 15:00:00 3 78\n", + "2023-12-05 15:00:00 7 78\n", + "2023-12-05 15:00:00 5 0\n", + "2023-12-05 15:00:00 18 0\n", + "2023-12-05 15:00:00 35 0" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Environment_MessageLog_*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    prioritytypemessage
    time
    2023-12-05 15:03:02.760000229AlertTrackingFailureCAA-1120747
    2023-12-05 15:06:32.019999981AlertTrackingFailureCAA-1120747
    2023-12-05 15:11:06.400000095AlertTrackingFailureCAA-1120747
    2023-12-05 15:14:37.320000172AlertTrackingFailureCAA-1120747
    2023-12-05 15:19:46.980000019AlertTrackingFailureCAA-1120747
    \n", + "
    " + ], + "text/plain": [ + " priority type message\n", + "time \n", + "2023-12-05 15:03:02.760000229 Alert TrackingFailure CAA-1120747\n", + "2023-12-05 15:06:32.019999981 Alert TrackingFailure CAA-1120747\n", + "2023-12-05 15:11:06.400000095 Alert TrackingFailure CAA-1120747\n", + "2023-12-05 15:14:37.320000172 Alert TrackingFailure CAA-1120747\n", + "2023-12-05 15:19:46.980000019 Alert TrackingFailure CAA-1120747" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Environment_SubjectState*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    idweighttype
    time
    \n", + "
    " + ], + "text/plain": [ + "Empty DataFrame\n", + "Columns: [id, weight, type]\n", + "Index: []" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Environment_SubjectVisits*:\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/nfs/nhome/live/jbhagat/ProjectAeon/aeon_mecha/aeon/io/api.py:149: UserWarning: data index for Environment_SubjectVisits* contains duplicate keys!\n", + " warnings.warn(f\"data index for {reader.pattern} contains duplicate keys!\")\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    idtyperegion
    time
    2023-12-05 15:02:09.440000057CAA-1120747EnterPatch2
    2023-12-05 15:02:09.519999981CAA-1120747ExitPatch2
    2023-12-05 15:02:14.900000095CAA-1120747EnterPatch3
    2023-12-05 15:02:15.000000000CAA-1120747ExitPatch3
    2023-12-05 15:02:15.380000114CAA-1120747EnterPatch3
    \n", + "
    " + ], + "text/plain": [ + " id type region\n", + "time \n", + "2023-12-05 15:02:09.440000057 CAA-1120747 Enter Patch2\n", + "2023-12-05 15:02:09.519999981 CAA-1120747 Exit Patch2\n", + "2023-12-05 15:02:14.900000095 CAA-1120747 Enter Patch3\n", + "2023-12-05 15:02:15.000000000 CAA-1120747 Exit Patch3\n", + "2023-12-05 15:02:15.380000114 CAA-1120747 Enter Patch3" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Environment_SubjectWeight*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    weightconfidencesubject_idint_id
    time
    2023-12-05 15:06:48.53999996229.01CAA-11207471
    2023-12-05 15:06:48.63999986629.01CAA-11207471
    2023-12-05 15:06:48.69999980929.01CAA-11207471
    2023-12-05 15:06:48.80000019129.01CAA-11207471
    2023-12-05 15:06:48.90000009529.01CAA-11207471
    \n", + "
    " + ], + "text/plain": [ + " weight confidence subject_id int_id\n", + "time \n", + "2023-12-05 15:06:48.539999962 29.0 1 CAA-1120747 1\n", + "2023-12-05 15:06:48.639999866 29.0 1 CAA-1120747 1\n", + "2023-12-05 15:06:48.699999809 29.0 1 CAA-1120747 1\n", + "2023-12-05 15:06:48.800000191 29.0 1 CAA-1120747 1\n", + "2023-12-05 15:06:48.900000095 29.0 1 CAA-1120747 1" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Nest_200*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    weight(g)stability
    time
    2023-12-05 15:00:00.000000000-1.21.0
    2023-12-05 15:00:00.159999847-1.21.0
    2023-12-05 15:00:00.260000229-1.21.0
    2023-12-05 15:00:00.340000153-1.21.0
    2023-12-05 15:00:00.420000076-1.21.0
    \n", + "
    " + ], + "text/plain": [ + " weight(g) stability\n", + "time \n", + "2023-12-05 15:00:00.000000000 -1.2 1.0\n", + "2023-12-05 15:00:00.159999847 -1.2 1.0\n", + "2023-12-05 15:00:00.260000229 -1.2 1.0\n", + "2023-12-05 15:00:00.340000153 -1.2 1.0\n", + "2023-12-05 15:00:00.420000076 -1.2 1.0" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Nest_202*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    weight(g)stability
    time
    2023-12-05 15:00:00.000000000-1.21.0
    2023-12-05 15:00:00.159999847-1.21.0
    2023-12-05 15:00:00.260000229-1.21.0
    2023-12-05 15:00:00.340000153-1.21.0
    2023-12-05 15:00:00.420000076-1.21.0
    \n", + "
    " + ], + "text/plain": [ + " weight(g) stability\n", + "time \n", + "2023-12-05 15:00:00.000000000 -1.2 1.0\n", + "2023-12-05 15:00:00.159999847 -1.2 1.0\n", + "2023-12-05 15:00:00.260000229 -1.2 1.0\n", + "2023-12-05 15:00:00.340000153 -1.2 1.0\n", + "2023-12-05 15:00:00.420000076 -1.2 1.0" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Patch1_32*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    event
    time
    2023-12-05 15:02:21.213376045beambreak
    2023-12-05 15:02:45.747712135beambreak
    2023-12-05 15:02:56.878367901beambreak
    2023-12-05 15:09:38.138751984beambreak
    2023-12-05 15:09:59.770847797beambreak
    \n", + "
    " + ], + "text/plain": [ + " event\n", + "time \n", + "2023-12-05 15:02:21.213376045 beambreak\n", + "2023-12-05 15:02:45.747712135 beambreak\n", + "2023-12-05 15:02:56.878367901 beambreak\n", + "2023-12-05 15:09:38.138751984 beambreak\n", + "2023-12-05 15:09:59.770847797 beambreak" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Patch1_35*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    event
    time
    2023-12-05 15:02:21.035488129delivery
    2023-12-05 15:02:45.601503849delivery
    2023-12-05 15:02:56.767488003delivery
    2023-12-05 15:09:38.005504131delivery
    2023-12-05 15:09:59.629504204delivery
    \n", + "
    " + ], + "text/plain": [ + " event\n", + "time \n", + "2023-12-05 15:02:21.035488129 delivery\n", + "2023-12-05 15:02:45.601503849 delivery\n", + "2023-12-05 15:02:56.767488003 delivery\n", + "2023-12-05 15:09:38.005504131 delivery\n", + "2023-12-05 15:09:59.629504204 delivery" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Patch1_90*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    angleintensity
    time
    2023-12-05 15:00:00.000000000140562894
    2023-12-05 15:00:00.001984119140552902
    2023-12-05 15:00:00.004000186140572896
    2023-12-05 15:00:00.005983829140532898
    2023-12-05 15:00:00.007999897140572897
    \n", + "
    " + ], + "text/plain": [ + " angle intensity\n", + "time \n", + "2023-12-05 15:00:00.000000000 14056 2894\n", + "2023-12-05 15:00:00.001984119 14055 2902\n", + "2023-12-05 15:00:00.004000186 14057 2896\n", + "2023-12-05 15:00:00.005983829 14053 2898\n", + "2023-12-05 15:00:00.007999897 14057 2897" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Patch1_201*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    manual_delivery
    time
    \n", + "
    " + ], + "text/plain": [ + "Empty DataFrame\n", + "Columns: [manual_delivery]\n", + "Index: []" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Patch1_202*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    missed_pellet
    time
    2023-12-06 13:06:33.9416961671
    2023-12-06 21:19:29.8788480761
    2023-12-07 09:53:59.8712639811
    2023-12-07 10:08:04.8767681121
    2023-12-07 10:16:46.1244478231
    \n", + "
    " + ], + "text/plain": [ + " missed_pellet\n", + "time \n", + "2023-12-06 13:06:33.941696167 1\n", + "2023-12-06 21:19:29.878848076 1\n", + "2023-12-07 09:53:59.871263981 1\n", + "2023-12-07 10:08:04.876768112 1\n", + "2023-12-07 10:16:46.124447823 1" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Patch1_203*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    retried_delivery
    time
    2023-12-05 16:04:15.7034878731
    2023-12-05 16:17:31.9724798201
    2023-12-05 17:30:23.1815037731
    2023-12-05 17:30:24.1975040441
    2023-12-05 17:30:41.3695039751
    \n", + "
    " + ], + "text/plain": [ + " retried_delivery\n", + "time \n", + "2023-12-05 16:04:15.703487873 1\n", + "2023-12-05 16:17:31.972479820 1\n", + "2023-12-05 17:30:23.181503773 1\n", + "2023-12-05 17:30:24.197504044 1\n", + "2023-12-05 17:30:41.369503975 1" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Patch1_State*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    thresholdoffsetrate
    time
    2023-12-05 15:02:21.032000065255.501030750.01
    2023-12-05 15:02:45.599999905100.117430750.01
    2023-12-05 15:02:56.763999939355.328025750.01
    2023-12-05 15:09:38.004000186307.886556750.01
    2023-12-05 15:09:59.62799978386.638658750.01
    \n", + "
    " + ], + "text/plain": [ + " threshold offset rate\n", + "time \n", + "2023-12-05 15:02:21.032000065 255.501030 75 0.01\n", + "2023-12-05 15:02:45.599999905 100.117430 75 0.01\n", + "2023-12-05 15:02:56.763999939 355.328025 75 0.01\n", + "2023-12-05 15:09:38.004000186 307.886556 75 0.01\n", + "2023-12-05 15:09:59.627999783 86.638658 75 0.01" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Patch2_32*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    event
    time
    2023-12-05 21:57:40.172095776beambreak
    2023-12-05 21:58:17.694560051beambreak
    2023-12-05 21:58:17.703807831beambreak
    2023-12-05 21:58:39.021152020beambreak
    2023-12-05 21:59:02.698304176beambreak
    \n", + "
    " + ], + "text/plain": [ + " event\n", + "time \n", + "2023-12-05 21:57:40.172095776 beambreak\n", + "2023-12-05 21:58:17.694560051 beambreak\n", + "2023-12-05 21:58:17.703807831 beambreak\n", + "2023-12-05 21:58:39.021152020 beambreak\n", + "2023-12-05 21:59:02.698304176 beambreak" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Patch2_35*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    event
    time
    2023-12-05 21:57:40.035488129delivery
    2023-12-05 21:58:17.564479828delivery
    2023-12-05 21:58:38.883488178delivery
    2023-12-05 21:59:00.546495914delivery
    2023-12-05 21:59:01.559487820delivery
    \n", + "
    " + ], + "text/plain": [ + " event\n", + "time \n", + "2023-12-05 21:57:40.035488129 delivery\n", + "2023-12-05 21:58:17.564479828 delivery\n", + "2023-12-05 21:58:38.883488178 delivery\n", + "2023-12-05 21:59:00.546495914 delivery\n", + "2023-12-05 21:59:01.559487820 delivery" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Patch2_90*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    angleintensity
    time
    2023-12-05 15:00:00.00000000039083164
    2023-12-05 15:00:00.00198411939033172
    2023-12-05 15:00:00.00400018639003167
    2023-12-05 15:00:00.00598382938993166
    2023-12-05 15:00:00.00799989739023170
    \n", + "
    " + ], + "text/plain": [ + " angle intensity\n", + "time \n", + "2023-12-05 15:00:00.000000000 3908 3164\n", + "2023-12-05 15:00:00.001984119 3903 3172\n", + "2023-12-05 15:00:00.004000186 3900 3167\n", + "2023-12-05 15:00:00.005983829 3899 3166\n", + "2023-12-05 15:00:00.007999897 3902 3170" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Patch2_201*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    manual_delivery
    time
    \n", + "
    " + ], + "text/plain": [ + "Empty DataFrame\n", + "Columns: [manual_delivery]\n", + "Index: []" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Patch2_202*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    missed_pellet
    time
    \n", + "
    " + ], + "text/plain": [ + "Empty DataFrame\n", + "Columns: [missed_pellet]\n", + "Index: []" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Patch2_203*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    retried_delivery
    time
    2023-12-05 21:59:01.5604801181
    2023-12-05 21:59:02.5695037841
    2023-12-06 03:47:32.9184961321
    2023-12-06 05:24:27.8015041351
    2023-12-06 05:31:37.3375039101
    \n", + "
    " + ], + "text/plain": [ + " retried_delivery\n", + "time \n", + "2023-12-05 21:59:01.560480118 1\n", + "2023-12-05 21:59:02.569503784 1\n", + "2023-12-06 03:47:32.918496132 1\n", + "2023-12-06 05:24:27.801504135 1\n", + "2023-12-06 05:31:37.337503910 1" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Patch2_State*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    thresholdoffsetrate
    time
    2023-12-05 15:10:27.000000000NaN750.0033
    2023-12-05 15:10:27.001984119316.702028750.0033
    2023-12-05 15:10:27.007999897316.702028750.0033
    2023-12-05 16:28:21.000000000NaN750.0020
    2023-12-05 16:28:21.001984119219.666377750.0020
    \n", + "
    " + ], + "text/plain": [ + " threshold offset rate\n", + "time \n", + "2023-12-05 15:10:27.000000000 NaN 75 0.0033\n", + "2023-12-05 15:10:27.001984119 316.702028 75 0.0033\n", + "2023-12-05 15:10:27.007999897 316.702028 75 0.0033\n", + "2023-12-05 16:28:21.000000000 NaN 75 0.0020\n", + "2023-12-05 16:28:21.001984119 219.666377 75 0.0020" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Patch3_32*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    event
    time
    2023-12-05 15:44:45.612192154beambreak
    2023-12-06 06:07:05.146624088beambreak
    2023-12-06 07:04:29.012159824beambreak
    2023-12-06 08:34:13.545279980beambreak
    2023-12-06 08:34:35.653376102beambreak
    \n", + "
    " + ], + "text/plain": [ + " event\n", + "time \n", + "2023-12-05 15:44:45.612192154 beambreak\n", + "2023-12-06 06:07:05.146624088 beambreak\n", + "2023-12-06 07:04:29.012159824 beambreak\n", + "2023-12-06 08:34:13.545279980 beambreak\n", + "2023-12-06 08:34:35.653376102 beambreak" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Patch3_35*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    event
    time
    2023-12-05 15:44:45.477503777delivery
    2023-12-06 06:07:04.042496204delivery
    2023-12-06 06:07:05.049503803delivery
    2023-12-06 07:04:28.900479794delivery
    2023-12-06 08:34:13.445504189delivery
    \n", + "
    " + ], + "text/plain": [ + " event\n", + "time \n", + "2023-12-05 15:44:45.477503777 delivery\n", + "2023-12-06 06:07:04.042496204 delivery\n", + "2023-12-06 06:07:05.049503803 delivery\n", + "2023-12-06 07:04:28.900479794 delivery\n", + "2023-12-06 08:34:13.445504189 delivery" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Patch3_90*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    angleintensity
    time
    2023-12-05 15:00:00.000000000106394119
    2023-12-05 15:00:00.001984119106394120
    2023-12-05 15:00:00.004000186106414121
    2023-12-05 15:00:00.005983829106404118
    2023-12-05 15:00:00.007999897106384118
    \n", + "
    " + ], + "text/plain": [ + " angle intensity\n", + "time \n", + "2023-12-05 15:00:00.000000000 10639 4119\n", + "2023-12-05 15:00:00.001984119 10639 4120\n", + "2023-12-05 15:00:00.004000186 10641 4121\n", + "2023-12-05 15:00:00.005983829 10640 4118\n", + "2023-12-05 15:00:00.007999897 10638 4118" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Patch3_201*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    manual_delivery
    time
    \n", + "
    " + ], + "text/plain": [ + "Empty DataFrame\n", + "Columns: [manual_delivery]\n", + "Index: []" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Patch3_202*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    missed_pellet
    time
    \n", + "
    " + ], + "text/plain": [ + "Empty DataFrame\n", + "Columns: [missed_pellet]\n", + "Index: []" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Patch3_203*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    retried_delivery
    time
    2023-12-06 06:07:05.0504961011
    2023-12-06 08:51:15.8424959181
    \n", + "
    " + ], + "text/plain": [ + " retried_delivery\n", + "time \n", + "2023-12-06 06:07:05.050496101 1\n", + "2023-12-06 08:51:15.842495918 1" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Patch3_State*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    thresholdoffsetrate
    time
    2023-12-05 15:10:27.001984119NaN750.0020
    2023-12-05 15:10:27.004000186545.555314750.0020
    2023-12-05 15:10:27.009984016545.555314750.0020
    2023-12-05 15:44:45.4759998321024.856116750.0020
    2023-12-05 16:28:21.000000000NaN750.0033
    \n", + "
    " + ], + "text/plain": [ + " threshold offset rate\n", + "time \n", + "2023-12-05 15:10:27.001984119 NaN 75 0.0020\n", + "2023-12-05 15:10:27.004000186 545.555314 75 0.0020\n", + "2023-12-05 15:10:27.009984016 545.555314 75 0.0020\n", + "2023-12-05 15:44:45.475999832 1024.856116 75 0.0020\n", + "2023-12-05 16:28:21.000000000 NaN 75 0.0033" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "RfidEventsGate*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    rfid
    time
    2023-12-05 15:03:03.993120193977200010163729
    2023-12-05 15:03:30.682623863977200010164323
    2023-12-05 15:03:31.019872189977200010164323
    2023-12-05 15:03:31.395616055977200010164323
    2023-12-05 15:06:38.510911942977200010164323
    \n", + "
    " + ], + "text/plain": [ + " rfid\n", + "time \n", + "2023-12-05 15:03:03.993120193 977200010163729\n", + "2023-12-05 15:03:30.682623863 977200010164323\n", + "2023-12-05 15:03:31.019872189 977200010164323\n", + "2023-12-05 15:03:31.395616055 977200010164323\n", + "2023-12-05 15:06:38.510911942 977200010164323" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "RfidEventsNest1*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    rfid
    time
    2023-12-05 15:00:00.0000000003784633200
    2023-12-05 15:00:00.0015039443784633200
    2023-12-05 15:00:01.0000000003784633201
    2023-12-05 15:00:01.0015039443784633201
    2023-12-05 15:00:02.0000000003784633202
    \n", + "
    " + ], + "text/plain": [ + " rfid\n", + "time \n", + "2023-12-05 15:00:00.000000000 3784633200\n", + "2023-12-05 15:00:00.001503944 3784633200\n", + "2023-12-05 15:00:01.000000000 3784633201\n", + "2023-12-05 15:00:01.001503944 3784633201\n", + "2023-12-05 15:00:02.000000000 3784633202" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "RfidEventsNest2*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    rfid
    time
    2023-12-05 15:03:33.940767765977200010163729
    2023-12-05 15:08:28.597375870977200010164323
    2023-12-05 15:08:34.070496082977200010164323
    2023-12-05 15:08:50.152063847977200010164323
    2023-12-05 15:08:50.489439964977200010164323
    \n", + "
    " + ], + "text/plain": [ + " rfid\n", + "time \n", + "2023-12-05 15:03:33.940767765 977200010163729\n", + "2023-12-05 15:08:28.597375870 977200010164323\n", + "2023-12-05 15:08:34.070496082 977200010164323\n", + "2023-12-05 15:08:50.152063847 977200010164323\n", + "2023-12-05 15:08:50.489439964 977200010164323" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "RfidEventsPatch1*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    rfid
    time
    2023-12-05 15:00:00.0000000003784633200
    2023-12-05 15:00:00.0015039443784633200
    2023-12-05 15:00:01.0000000003784633201
    2023-12-05 15:00:01.0015039443784633201
    2023-12-05 15:00:02.0000000003784633202
    \n", + "
    " + ], + "text/plain": [ + " rfid\n", + "time \n", + "2023-12-05 15:00:00.000000000 3784633200\n", + "2023-12-05 15:00:00.001503944 3784633200\n", + "2023-12-05 15:00:01.000000000 3784633201\n", + "2023-12-05 15:00:01.001503944 3784633201\n", + "2023-12-05 15:00:02.000000000 3784633202" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "RfidEventsPatch2*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    rfid
    time
    2023-12-05 15:02:33.719103813977200010163729
    2023-12-05 15:02:34.209599972977200010163729
    2023-12-05 15:02:34.608064175977200010163729
    2023-12-05 15:02:35.006527901977200010163729
    2023-12-05 15:02:35.251743793977200010163729
    \n", + "
    " + ], + "text/plain": [ + " rfid\n", + "time \n", + "2023-12-05 15:02:33.719103813 977200010163729\n", + "2023-12-05 15:02:34.209599972 977200010163729\n", + "2023-12-05 15:02:34.608064175 977200010163729\n", + "2023-12-05 15:02:35.006527901 977200010163729\n", + "2023-12-05 15:02:35.251743793 977200010163729" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "RfidEventsPatch3*:\n" + ] + }, + { + "data": { + "text/html": [ + "
    \n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
    rfid
    time
    2023-12-05 15:02:19.841599941977200010164323
    2023-12-05 15:02:20.271039963977200010164323
    2023-12-05 15:02:20.731232166977200010164323
    2023-12-05 15:02:21.130015849977200010164323
    2023-12-05 15:02:21.896927834977200010164323
    \n", + "
    " + ], + "text/plain": [ + " rfid\n", + "time \n", + "2023-12-05 15:02:19.841599941 977200010164323\n", + "2023-12-05 15:02:20.271039963 977200010164323\n", + "2023-12-05 15:02:20.731232166 977200010164323\n", + "2023-12-05 15:02:21.130015849 977200010164323\n", + "2023-12-05 15:02:21.896927834 977200010164323" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "\"\"\"Test all readers in schema.\"\"\"\n", + "\n", + "def find_obj(dotmap, obj):\n", + " \"\"\"Returns a list of objects of type `obj` found in a DotMap.\"\"\"\n", + " objs = []\n", + " for value in dotmap.values():\n", + " if isinstance(value, obj):\n", + " objs.append(value)\n", + " elif isinstance(value, DotMap):\n", + " objs.extend(find_obj(value, obj))\n", + " return objs\n", + "\n", + "readers = find_obj(social01, reader.Reader)\n", + "start_time = pd.Timestamp(\"2023-12-05 15:00:00\")\n", + "end_time = pd.Timestamp(\"2023-12-07 11:00:00\")\n", + "for r in readers:\n", + " data = aeon.load(root, r, start=start_time, end=end_time)\n", + " #assert not data.empty, f\"No data found with {r}.\"\n", + " print(f\"\\n{r.pattern}:\")\n", + " display(data.head())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { From 22c7c81f1396a51067f75569416240832ccc1385 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Mon, 8 Jan 2024 13:39:19 -0600 Subject: [PATCH 363/489] fix(load_metadata): resolve pull conflicts (removed dataset.py) --- aeon/dj_pipeline/utils/load_metadata.py | 4 +- aeon/schema/dataset.py | 66 ------------------------- 2 files changed, 2 insertions(+), 68 deletions(-) delete mode 100644 aeon/schema/dataset.py diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 7575611d..4aabb682 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -37,11 +37,11 @@ def ingest_subject(colony_csv_path: pathlib.Path = _colony_csv_path) -> None: def insert_stream_types(): """Insert into streams.streamType table all streams in the dataset schema.""" - from aeon.schema import dataset + from aeon.io import schemas as aeon_schema streams = dj.VirtualModule("streams", streams_maker.schema_name) - schemas = [v for v in dataset.__dict__.values() if isinstance(v, DotMap)] + schemas = [v for v in aeon_schema.__dict__.values() if isinstance(v, DotMap)] for schema in schemas: stream_entries = get_stream_entries(schema) diff --git a/aeon/schema/dataset.py b/aeon/schema/dataset.py deleted file mode 100644 index 9012bc37..00000000 --- a/aeon/schema/dataset.py +++ /dev/null @@ -1,66 +0,0 @@ -from dotmap import DotMap - -import aeon.schema.core as stream -from aeon.io import reader -from aeon.io.device import Device -from aeon.schema import foraging, octagon - -exp02 = DotMap( - [ - Device("Metadata", stream.metadata), - Device("ExperimentalMetadata", stream.environment, stream.messageLog), - Device("CameraTop", stream.video, stream.position, foraging.region), - Device("CameraEast", stream.video), - Device("CameraNest", stream.video), - Device("CameraNorth", stream.video), - Device("CameraPatch1", stream.video), - Device("CameraPatch2", stream.video), - Device("CameraSouth", stream.video), - Device("CameraWest", stream.video), - Device("Nest", foraging.weight), - Device("Patch1", foraging.patch), - Device("Patch2", foraging.patch), - ] -) - -exp01 = DotMap( - [ - Device("SessionData", foraging.session), - Device("FrameTop", stream.video, stream.position), - Device("FrameEast", stream.video), - Device("FrameGate", stream.video), - Device("FrameNorth", stream.video), - Device("FramePatch1", stream.video), - Device("FramePatch2", stream.video), - Device("FrameSouth", stream.video), - Device("FrameWest", stream.video), - Device("Patch1", foraging.depletionFunction, stream.encoder, foraging.feeder), - Device("Patch2", foraging.depletionFunction, stream.encoder, foraging.feeder), - ] -) - -octagon01 = DotMap( - [ - Device("Metadata", stream.metadata), - Device("CameraTop", stream.video, stream.position), - Device("CameraColorTop", stream.video), - Device("ExperimentalMetadata", stream.subject_state), - Device("Photodiode", octagon.photodiode), - Device("OSC", octagon.OSC), - Device("TaskLogic", octagon.TaskLogic), - Device("Wall1", octagon.Wall), - Device("Wall2", octagon.Wall), - Device("Wall3", octagon.Wall), - Device("Wall4", octagon.Wall), - Device("Wall5", octagon.Wall), - Device("Wall6", octagon.Wall), - Device("Wall7", octagon.Wall), - Device("Wall8", octagon.Wall), - ] -) - -social01 = exp02 -social01.Patch1.BeamBreak = reader.BitmaskEvent(pattern="Patch1_32", value=0x22, tag="BeamBroken") -social01.Patch2.BeamBreak = reader.BitmaskEvent(pattern="Patch2_32", value=0x22, tag="BeamBroken") -social01.Patch1.DeliverPellet = reader.BitmaskEvent(pattern="Patch1_35", value=0x1, tag="TriggeredPellet") -social01.Patch2.DeliverPellet = reader.BitmaskEvent(pattern="Patch2_35", value=0x1, tag="TriggeredPellet") From ff983ddb3933423ea340364e0adb148506909c45 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Mon, 8 Jan 2024 18:48:37 -0600 Subject: [PATCH 364/489] feat(social01): add new aeon schema for social 01 --- aeon/schema/foraging.py | 20 +++++++ aeon/schema/schemas.py | 67 ++++------------------ aeon/schema/social01.py | 123 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 55 deletions(-) create mode 100644 aeon/schema/social01.py diff --git a/aeon/schema/foraging.py b/aeon/schema/foraging.py index df42cc1a..14d76b9f 100644 --- a/aeon/schema/foraging.py +++ b/aeon/schema/foraging.py @@ -78,6 +78,26 @@ def deliver_pellet(pattern): return {"DeliverPellet": _reader.BitmaskEvent(f"{pattern}_35_*", 0x80, "TriggerPellet")} +def pellet_manual_delivery(pattern): + """Manual pellet delivery.""" + return {"ManualDelivery": _reader.Harp(f"{pattern}_*", ["manual_delivery"])} + + +def missed_pellet(pattern): + """Missed pellet delivery.""" + return {"MissedPellet": _reader.Harp(f"{pattern}_*", ["missed_pellet"])} + + +def pellet_retried_delivery(pattern): + """Retry pellet delivery.""" + return {"RetriedDelivery": _reader.Harp(f"{pattern}_*", ["retried_delivery"])} + + +def pellet_depletion_state(pattern): + """Pellet delivery state.""" + return {"DepletionState": _reader.Csv(f"{pattern}_*", ["threshold", "offset", "rate"])} + + def patch(pattern): """Data streams for a patch.""" return _device.register(pattern, depletion_function, _stream.encoder, feeder) diff --git a/aeon/schema/schemas.py b/aeon/schema/schemas.py index 7b61c2d7..abaa5430 100644 --- a/aeon/schema/schemas.py +++ b/aeon/schema/schemas.py @@ -1,6 +1,6 @@ from dotmap import DotMap from aeon.io.device import Device -from aeon.schema import core, foraging, octagon +from aeon.schema import core, foraging, octagon, social01 exp02 = DotMap( [ @@ -56,57 +56,14 @@ ] ) -# All recorded social01 streams: - -# *Note* regiser 8 is always the harp heartbeat for any device that has this stream. - -# - Metadata.yml -# - Environment_BlockState -# - Environment_EnvironmentState -# - Environment_LightEvents -# - Environment_MessageLog -# - Environment_SubjectState -# - Environment_SubjectVisits -# - Environment_SubjectWeight -# - CameraTop (200, 201, avi, csv, ,) -# - 200: position -# - 201: region -# - CameraNorth (avi, csv) -# - CameraEast (avi, csv) -# - CameraSouth (avi, csv) -# - CameraWest (avi, csv) -# - CameraPatch1 (avi, csv) -# - CameraPatch2 (avi, csv) -# - CameraPatch3 (avi, csv) -# - CameraNest (avi, csv) -# - ClockSynchronizer (8, 36) -# - 36: -# - Nest (200, 201, 202, 203) -# - 200: weight_raw -# - 201: weight_tare -# - 202: weight_filtered -# - 203: weight_baseline -# - 204: weight_subject -# - Patch1 (8, 32, 35, 36, 87, 90, 91, 200, 201, 202, 203, State) -# - 32: beam_break -# - 35: delivery_set -# - 36: delivery_clear -# - 87: expansion_board -# - 90: enocder_read -# - 91: encoder_mode -# - 200: dispenser_state -# - 201: delivery_manual -# - 202: missed_pellet -# - 203: delivery_retry -# - Patch2 (8, 32, 35, 36, 87, 90, 91, State) -# - Patch3 (8, 32, 35, 36, 87, 90, 91, 200, 203, State) -# - RfidEventsGate (8, 32, 35) -# - 32: entry_id -# - 35: hardware_notifications -# - RfidEventsNest1 (8, 32, 35) -# - RfidEventsNest2 (8, 32, 35) -# - RfidEventsPatch1 (8, 32, 35) -# - RfidEventsPatch2 (8, 32, 35) -# - RfidEventsPatch3 (8, 32, 35) -# - VideoController (8, 32, 33, 34, 35, 36, 45, 52) -# - 32: frame_number \ No newline at end of file +social01 = DotMap( + [ + Device("Metadata", core.metadata), + Device("Environment", social01.env_block_state_b, social01.light_events_b, core.message_log), + Device("Subject", social01.subject_state_b, social01.subject_visits_b, social01.subject_weight_b), + *social01.camera_devices, + Device("Nest", social01.weight_raw_b, social01.weight_filtered_b), + *social01.patch_devices, + *social01.rfid_devices, + ] +) diff --git a/aeon/schema/social01.py b/aeon/schema/social01.py new file mode 100644 index 00000000..6e131480 --- /dev/null +++ b/aeon/schema/social01.py @@ -0,0 +1,123 @@ +from aeon.io import reader +from aeon.io.device import Device, register +from aeon.schema import core, social, foraging + + +"""Creating the Social 0.1 schema""" + +# Above we've listed out all the streams we recorded from during Social0.1, but we won't care to analyze all +# of them. Instead, we'll create a DotMap schema from Device objects that only contains Readers for the +# streams we want to analyze. + +# We'll see both examples of binder functions we saw previously: 1. "empty pattern", and +# 2. "device-name passed". + +# And we'll see both examples of instantiating Device objects we saw previously: 1. from singleton binder +# functions; 2. from multiple and/or nested binder functions. + +# (Note, in the simplest case, a schema can always be created from / reduced to "empty pattern" binder +# functions as singletons in Device objects.) + +# Metadata.yml (will be a singleton binder function Device object) +# --- + +metadata = Device("Metadata", core.metadata) + +# --- + +# Environment (will be a nested, multiple binder function Device object) +# --- + +# BlockState +# binder function: "device-name passed"; `pattern` will be set by `Device` object name: "Environment" +block_state_b = lambda pattern: { + "BlockState": reader.Csv(f"{pattern}_BlockState*", ["pellet_ct", "pellet_ct_thresh", "due_time"]) +} + +# EnvironmentState + +# Combine EnvironmentState and BlockState +env_block_state_b = lambda pattern: register(pattern, core.environment_state, block_state_b) + +# LightEvents +cols = ["channel", "value"] +light_events_r = reader.Csv("Environment_LightEvents*", cols) +light_events_b = lambda pattern: {"LightEvents": light_events_r} # binder function: "empty pattern" + +# SubjectState +cols = ["id", "weight", "type"] +subject_state_r = reader.Csv("Environment_SubjectState*", cols) +subject_state_b = lambda pattern: {"SubjectState": subject_state_r} # binder function: "empty pattern" + +# SubjectVisits +cols = ["id", "type", "region"] +subject_visits_r = reader.Csv("Environment_SubjectVisits*", cols) +subject_visits_b = lambda pattern: {"SubjectVisits": subject_visits_r} # binder function: "empty pattern" + +# SubjectWeight +cols = ["weight", "confidence", "subject_id", "int_id"] +subject_weight_r = reader.Csv("Environment_SubjectWeight*", cols) +subject_weight_b = lambda pattern: {"SubjectWeight": subject_weight_r} # binder function: "empty pattern" + +# Nested binder fn Device object. +environment = Device("Environment", env_block_state_b, light_events_b, core.message_log) # device name + +# Separate Device object for subject-specific streams. +subject = Device("Subject", subject_state_b, subject_visits_b, subject_weight_b) + +# --- + +# Camera +# --- + +camera_top_pos_b = lambda pattern: {"Pose": social.Pose(f"{pattern}_test-node1*")} + +camera_devices = [Device("CameraTop", core.video, camera_top_pos_b)] + +cam_names = ["North", "South", "East", "West", "Patch1", "Patch2", "Patch3", "Nest"] +cam_names = ["Camera" + name for name in cam_names] + +camera_devices += [Device(cam_name, core.video) for cam_name in cam_names] +# --- + +# Nest +# --- + +weight_raw_b = lambda pattern: {"WeightRaw": reader.Harp("Nest_200*", ["weight(g)", "stability"])} +weight_filtered_b = lambda pattern: {"WeightFiltered": reader.Harp("Nest_202*", ["weight(g)", "stability"])} + +nest = Device( + "Nest", + weight_raw_b, + weight_filtered_b, +) + +# --- + +# Patch +# --- + +patch_names = ["Patch1", "Patch2", "Patch3"] +patch_devices = [ + Device( + patch_name, + foraging.pellet_depletion_state, + core.encoder, + foraging.feeder, + foraging.pellet_manual_delivery, + foraging.missed_pellet, + foraging.pellet_retried_delivery, + ) + for patch_name in patch_names +] +# --- + +# Rfid +# --- + +rfid_names = ["EventsGate", "EventsNest1", "EventsNest2", "EventsPatch1", "EventsPatch2", "EventsPatch3"] +rfid_names = ["Rfid" + name for name in rfid_names] +rfid_devices = [ + Device(rfid_name, lambda pattern=rfid_name: {"RFID": reader.Harp(f"{pattern}_*", ["rfid"])}) + for rfid_name in rfid_names +] From 7436a47ad2d7a5b4c43348c06643eb15be6fab99 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Mon, 8 Jan 2024 18:49:50 -0600 Subject: [PATCH 365/489] rearrange(device_type_mapper) --- aeon/dj_pipeline/create_experiments/device_type_mapper.json | 1 - aeon/dj_pipeline/utils/device_type_mapper.json | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 aeon/dj_pipeline/create_experiments/device_type_mapper.json create mode 100644 aeon/dj_pipeline/utils/device_type_mapper.json diff --git a/aeon/dj_pipeline/create_experiments/device_type_mapper.json b/aeon/dj_pipeline/create_experiments/device_type_mapper.json deleted file mode 100644 index c4738ec4..00000000 --- a/aeon/dj_pipeline/create_experiments/device_type_mapper.json +++ /dev/null @@ -1 +0,0 @@ -{"VideoController": "CameraController", "CameraTop": "VideoSource", "CameraWest": "VideoSource", "CameraEast": "VideoSource", "CameraNorth": "VideoSource", "CameraSouth": "VideoSource", "CameraPatch1": "VideoSource", "CameraPatch2": "VideoSource", "CameraNest": "VideoSource", "AudioAmbient": "AudioSource", "Patch1": "UndergroundFeeder", "Patch2": "UndergroundFeeder", "WeightNest": "WeightScale", "TrackingTop": "PositionTracking", "ActivityCenter": "ActivityTracking", "ActivityArena": "ActivityTracking", "ActivityNest": "ActivityTracking", "ActivityPatch1": "ActivityTracking", "ActivityPatch2": "ActivityTracking", "InNest": "RegionTracking", "InPatch1": "RegionTracking", "InPatch2": "RegionTracking", "ArenaCenter": "DistanceFromPoint", "InArena": "InRange", "InCorridor": "InRange", "ClockSynchronizer": "Synchronizer", "Rfid": "Rfid Reader"} \ No newline at end of file diff --git a/aeon/dj_pipeline/utils/device_type_mapper.json b/aeon/dj_pipeline/utils/device_type_mapper.json new file mode 100644 index 00000000..7f041bd5 --- /dev/null +++ b/aeon/dj_pipeline/utils/device_type_mapper.json @@ -0,0 +1 @@ +{"ClockSynchronizer": "TimestampGenerator", "VideoController": "CameraController", "CameraTop": "SpinnakerVideoSource", "CameraWest": "SpinnakerVideoSource", "CameraEast": "SpinnakerVideoSource", "CameraNorth": "SpinnakerVideoSource", "CameraSouth": "SpinnakerVideoSource", "CameraNest": "SpinnakerVideoSource", "CameraPatch1": "SpinnakerVideoSource", "CameraPatch2": "SpinnakerVideoSource", "CameraPatch3": "SpinnakerVideoSource", "AudioAmbient": "AudioSource", "Patch1": "UndergroundFeeder", "Patch2": "UndergroundFeeder", "Patch3": "UndergroundFeeder", "Nest": "WeightScale", "RfidNest1": "RfidReader", "RfidNest2": "RfidReader", "RfidGate": "RfidReader", "RfidPatch1": "RfidReader", "RfidPatch2": "RfidReader", "RfidPatch3": "RfidReader", "LightCycle": "EnvironmentCondition"} \ No newline at end of file From cf9b2b171c5df9d24bcd858126326acf0ef617b3 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Mon, 8 Jan 2024 18:50:37 -0600 Subject: [PATCH 366/489] feat(streams): incorporate social 0.1 experiments for ingestion --- aeon/dj_pipeline/acquisition.py | 15 +- aeon/dj_pipeline/streams.py | 486 +++++++----------------- aeon/dj_pipeline/utils/load_metadata.py | 15 +- aeon/dj_pipeline/utils/streams_maker.py | 1 + 4 files changed, 145 insertions(+), 372 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 5fd3a674..621f09cb 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -6,7 +6,7 @@ import pandas as pd from aeon.io import api as io_api -from aeon.io import schemas as aeon_schema +from aeon.schema import schemas as aeon_schemas from aeon.io import reader as io_reader from aeon.analysis import utils as analysis_utils @@ -22,16 +22,15 @@ "exp0.1-r0": "FrameTop", "social0-r1": "FrameTop", "exp0.2-r0": "CameraTop", - "oct1.0-r0": "CameraTop", } _device_schema_mapping = { - "exp0.1-r0": aeon_schema.exp01, - "social0-r1": aeon_schema.exp01, - "exp0.2-r0": aeon_schema.exp02, - "oct1.0-r0": aeon_schema.octagon01, - "social0.1-a3": aeon_schema.social01, - "social0.1-a4": aeon_schema.social01, + "exp0.1-r0": aeon_schemas.exp01, + "social0-r1": aeon_schemas.exp01, + "exp0.2-r0": aeon_schemas.exp02, + "oct1.0-r0": aeon_schemas.octagon01, + "social0.1-a3": aeon_schemas.social01, + "social0.1-a4": aeon_schemas.social01, } diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index d20ce337..ff122dcc 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -1,10 +1,9 @@ -# ---- DO NOT MODIFY ---- -# ---- THIS FILE IS AUTO-GENERATED BY `streams_maker.py` ---- - -from uuid import UUID +#---- DO NOT MODIFY ---- +#---- THIS FILE IS AUTO-GENERATED BY `streams_maker.py` ---- import datajoint as dj import pandas as pd +from uuid import UUID import aeon from aeon.dj_pipeline import acquisition, get_schema_name @@ -13,14 +12,9 @@ schema = dj.Schema(get_schema_name("streams")) -@schema +@schema class StreamType(dj.Lookup): - """ - Catalog of all steam types for the different device types used across Project Aeon - One StreamType corresponds to one reader class in `aeon.io.reader` - The combination of `stream_reader` and `stream_reader_kwargs` should fully specify - the data loading routine for a particular device, using the `aeon.io.utils` - """ + """Catalog of all steam types for the different device types used across Project Aeon. One StreamType corresponds to one reader class in `aeon.io.reader`. The combination of `stream_reader` and `stream_reader_kwargs` should fully specify the data loading routine for a particular device, using the `aeon.io.utils`.""" definition = """ # Catalog of all stream types used across Project Aeon stream_type : varchar(20) @@ -33,11 +27,9 @@ class StreamType(dj.Lookup): """ -@schema +@schema class DeviceType(dj.Lookup): - """ - Catalog of all device types used across Project Aeon - """ + """Catalog of all device types used across Project Aeon.""" definition = """ # Catalog of all device types used across Project Aeon device_type: varchar(36) @@ -52,7 +44,7 @@ class Stream(dj.Part): """ -@schema +@schema class Device(dj.Lookup): definition = """ # Physical devices, of a particular type, identified by unique serial number device_serial_number: varchar(12) @@ -61,282 +53,63 @@ class Device(dj.Lookup): """ -@schema -class UndergroundFeeder(dj.Manual): - definition = f""" - # underground_feeder placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown) - -> acquisition.Experiment - -> Device - underground_feeder_install_time : datetime(6) # time of the underground_feeder placed and started operation at this position - --- - underground_feeder_name : varchar(36) - """ - - class Attribute(dj.Part): - definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device - -> master - attribute_name : varchar(32) - --- - attribute_value=null : longblob - """ - - class RemovalTime(dj.Part): +@schema +class SpinnakerVideoSource(dj.Manual): definition = f""" - -> master - --- - underground_feeder_removal_time: datetime(6) # time of the underground_feeder being removed - """ - - -@schema -class VideoSource(dj.Manual): - definition = f""" - # video_source placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown) + # spinnaker_video_source placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown) -> acquisition.Experiment -> Device - video_source_install_time : datetime(6) # time of the video_source placed and started operation at this position + spinnaker_video_source_install_time : datetime(6) # time of the spinnaker_video_source placed and started operation at this position --- - video_source_name : varchar(36) + spinnaker_video_source_name : varchar(36) """ - class Attribute(dj.Part): - definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device + class Attribute(dj.Part): + definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device -> master attribute_name : varchar(32) --- attribute_value=null : longblob """ - class RemovalTime(dj.Part): - definition = f""" + class RemovalTime(dj.Part): + definition = f""" -> master --- - video_source_removal_time: datetime(6) # time of the video_source being removed + spinnaker_video_source_removal_time: datetime(6) # time of the spinnaker_video_source being removed """ -@schema -class VideoSourcePosition(dj.Imported): - definition = """# Raw per-chunk Position data stream from VideoSource (auto-generated with aeon_mecha-unknown) - -> VideoSource - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of Position data - """ - _stream_reader = aeon.io.reader.Position - _stream_detail = { - "stream_type": "Position", - "stream_reader": "aeon.io.reader.Position", - "stream_reader_kwargs": {"pattern": "{pattern}_200_*"}, - "stream_description": "", - "stream_hash": UUID("d7727726-1f52-78e1-1355-b863350b6d03"), - } - - @property - def key_source(self): - f""" - Only the combination of Chunk and VideoSource with overlapping time - + Chunk(s) that started after VideoSource install time and ended before VideoSource remove time - + Chunk(s) that started after VideoSource install time for VideoSource that are not yet removed - """ - return ( - acquisition.Chunk * VideoSource.join(VideoSource.RemovalTime, left=True) - & "chunk_start >= video_source_install_time" - & 'chunk_start < IFNULL(video_source_removal_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - - device_name = (VideoSource & key).fetch1("video_source_name") - - stream = self._stream_reader( - **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) - - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{c: stream_data[c].values for c in stream.columns if not c.startswith("_")}, - }, - ignore_extra_fields=True, - ) - - -@schema -class VideoSourceRegion(dj.Imported): - definition = """# Raw per-chunk Region data stream from VideoSource (auto-generated with aeon_mecha-unknown) - -> VideoSource - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of Region data - """ - _stream_reader = aeon.schema.foraging._RegionReader - _stream_detail = { - "stream_type": "Region", - "stream_reader": "aeon.schema.foraging._RegionReader", - "stream_reader_kwargs": {"pattern": "{pattern}_201_*"}, - "stream_description": "", - "stream_hash": UUID("6c78b3ac-ffff-e2ab-c446-03e3adf4d80a"), - } - - @property - def key_source(self): - f""" - Only the combination of Chunk and VideoSource with overlapping time - + Chunk(s) that started after VideoSource install time and ended before VideoSource remove time - + Chunk(s) that started after VideoSource install time for VideoSource that are not yet removed - """ - return ( - acquisition.Chunk * VideoSource.join(VideoSource.RemovalTime, left=True) - & "chunk_start >= video_source_install_time" - & 'chunk_start < IFNULL(video_source_removal_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - - device_name = (VideoSource & key).fetch1("video_source_name") - - stream = self._stream_reader( - **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) - - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{c: stream_data[c].values for c in stream.columns if not c.startswith("_")}, - }, - ignore_extra_fields=True, - ) - - -@schema -class VideoSourceVideo(dj.Imported): - definition = """# Raw per-chunk Video data stream from VideoSource (auto-generated with aeon_mecha-unknown) - -> VideoSource - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of Video data - """ - _stream_reader = aeon.io.reader.Video - _stream_detail = { - "stream_type": "Video", - "stream_reader": "aeon.io.reader.Video", - "stream_reader_kwargs": {"pattern": "{pattern}_*"}, - "stream_description": "", - "stream_hash": UUID("f51c6174-e0c4-a888-3a9d-6f97fb6a019b"), - } - - @property - def key_source(self): - f""" - Only the combination of Chunk and VideoSource with overlapping time - + Chunk(s) that started after VideoSource install time and ended before VideoSource remove time - + Chunk(s) that started after VideoSource install time for VideoSource that are not yet removed - """ - return ( - acquisition.Chunk * VideoSource.join(VideoSource.RemovalTime, left=True) - & "chunk_start >= video_source_install_time" - & 'chunk_start < IFNULL(video_source_removal_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - - device_name = (VideoSource & key).fetch1("video_source_name") - - stream = self._stream_reader( - **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) - - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{c: stream_data[c].values for c in stream.columns if not c.startswith("_")}, - }, - ignore_extra_fields=True, - ) - - -@schema -class SpinnakerVideoSource(dj.Manual): - definition = f""" - # spinnaker_video_source placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown) +@schema +class UndergroundFeeder(dj.Manual): + definition = f""" + # underground_feeder placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown) -> acquisition.Experiment -> Device - spinnaker_video_source_install_time : datetime(6) # time of the spinnaker_video_source placed and started operation at this position + underground_feeder_install_time : datetime(6) # time of the underground_feeder placed and started operation at this position --- - spinnaker_video_source_name : varchar(36) + underground_feeder_name : varchar(36) """ - class Attribute(dj.Part): - definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device + class Attribute(dj.Part): + definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device -> master attribute_name : varchar(32) --- attribute_value=null : longblob """ - class RemovalTime(dj.Part): - definition = f""" + class RemovalTime(dj.Part): + definition = f""" -> master --- - spinnaker_video_source_removal_time: datetime(6) # time of the spinnaker_video_source being removed + underground_feeder_removal_time: datetime(6) # time of the underground_feeder being removed """ -@schema +@schema class WeightScale(dj.Manual): - definition = f""" + definition = f""" # weight_scale placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown) -> acquisition.Experiment -> Device @@ -345,38 +118,35 @@ class WeightScale(dj.Manual): weight_scale_name : varchar(36) """ - class Attribute(dj.Part): - definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device + class Attribute(dj.Part): + definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device -> master attribute_name : varchar(32) --- attribute_value=null : longblob """ - class RemovalTime(dj.Part): - definition = f""" + class RemovalTime(dj.Part): + definition = f""" -> master --- weight_scale_removal_time: datetime(6) # time of the weight_scale being removed """ + + @schema -class SpinnakerVideoSourcePosition(dj.Imported): - definition = """ # Raw per-chunk Position data stream from SpinnakerVideoSource (auto-generated with aeon_mecha-unknown) +class SpinnakerVideoSourceVideo(dj.Imported): + definition = """ # Raw per-chunk Video data stream from SpinnakerVideoSource (auto-generated with aeon_mecha-unknown) -> SpinnakerVideoSource -> acquisition.Chunk --- sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of Position data - x: longblob - y: longblob - angle: longblob - major: longblob - minor: longblob - area: longblob - id: longblob + timestamps: longblob # (datetime) timestamps of Video data + hw_counter: longblob + hw_timestamp: longblob """ - _stream_reader = aeon.io.reader.Position - _stream_detail = {'stream_type': 'Position', 'stream_reader': 'aeon.io.reader.Position', 'stream_reader_kwargs': {'pattern': '{pattern}_200_*'}, 'stream_description': '', 'stream_hash': UUID('d7727726-1f52-78e1-1355-b863350b6d03')} + _stream_reader = aeon.io.reader.Video + _stream_detail = {'stream_type': 'Video', 'stream_reader': 'aeon.io.reader.Video', 'stream_reader_kwargs': {'pattern': '{pattern}_*'}, 'stream_description': '', 'stream_hash': UUID('f51c6174-e0c4-a888-3a9d-6f97fb6a019b')} @property def key_source(self): @@ -425,29 +195,29 @@ def make(self, key): @schema -class SpinnakerVideoSourceRegion(dj.Imported): - definition = """ # Raw per-chunk Region data stream from SpinnakerVideoSource (auto-generated with aeon_mecha-unknown) - -> SpinnakerVideoSource +class UndergroundFeederBeamBreak(dj.Imported): + definition = """ # Raw per-chunk BeamBreak data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + -> UndergroundFeeder -> acquisition.Chunk --- sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of Region data - region: longblob + timestamps: longblob # (datetime) timestamps of BeamBreak data + event: longblob """ - _stream_reader = aeon.schema.foraging._RegionReader - _stream_detail = {'stream_type': 'Region', 'stream_reader': 'aeon.schema.foraging._RegionReader', 'stream_reader_kwargs': {'pattern': '{pattern}_201_*'}, 'stream_description': '', 'stream_hash': UUID('6c78b3ac-ffff-e2ab-c446-03e3adf4d80a')} + _stream_reader = aeon.io.reader.BitmaskEvent + _stream_detail = {'stream_type': 'BeamBreak', 'stream_reader': 'aeon.io.reader.BitmaskEvent', 'stream_reader_kwargs': {'pattern': '{pattern}_32_*', 'value': 34, 'tag': 'PelletDetected'}, 'stream_description': '', 'stream_hash': UUID('ab975afc-c88d-2b66-d22b-65649b0ea5f0')} @property def key_source(self): f""" - Only the combination of Chunk and SpinnakerVideoSource with overlapping time - + Chunk(s) that started after SpinnakerVideoSource install time and ended before SpinnakerVideoSource remove time - + Chunk(s) that started after SpinnakerVideoSource install time for SpinnakerVideoSource that are not yet removed + Only the combination of Chunk and UndergroundFeeder with overlapping time + + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time + + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed """ return ( - acquisition.Chunk * SpinnakerVideoSource.join(SpinnakerVideoSource.RemovalTime, left=True) - & 'chunk_start >= spinnaker_video_source_install_time' - & 'chunk_start < IFNULL(spinnaker_video_source_removal_time, "2200-01-01")' + acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) + & 'chunk_start >= underground_feeder_install_time' + & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' ) def make(self, key): @@ -456,7 +226,7 @@ def make(self, key): ) raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - device_name = (SpinnakerVideoSource & key).fetch1('spinnaker_video_source_name') + device_name = (UndergroundFeeder & key).fetch1('underground_feeder_name') stream = self._stream_reader( **{ @@ -484,30 +254,29 @@ def make(self, key): @schema -class SpinnakerVideoSourceVideo(dj.Imported): - definition = """ # Raw per-chunk Video data stream from SpinnakerVideoSource (auto-generated with aeon_mecha-unknown) - -> SpinnakerVideoSource +class UndergroundFeederDeliverPellet(dj.Imported): + definition = """ # Raw per-chunk DeliverPellet data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + -> UndergroundFeeder -> acquisition.Chunk --- sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of Video data - hw_counter: longblob - hw_timestamp: longblob + timestamps: longblob # (datetime) timestamps of DeliverPellet data + event: longblob """ - _stream_reader = aeon.io.reader.Video - _stream_detail = {'stream_type': 'Video', 'stream_reader': 'aeon.io.reader.Video', 'stream_reader_kwargs': {'pattern': '{pattern}_*'}, 'stream_description': '', 'stream_hash': UUID('f51c6174-e0c4-a888-3a9d-6f97fb6a019b')} + _stream_reader = aeon.io.reader.BitmaskEvent + _stream_detail = {'stream_type': 'DeliverPellet', 'stream_reader': 'aeon.io.reader.BitmaskEvent', 'stream_reader_kwargs': {'pattern': '{pattern}_35_*', 'value': 128, 'tag': 'TriggerPellet'}, 'stream_description': '', 'stream_hash': UUID('09099227-ab3c-1f71-239e-4c6f017de1fd')} @property def key_source(self): f""" - Only the combination of Chunk and SpinnakerVideoSource with overlapping time - + Chunk(s) that started after SpinnakerVideoSource install time and ended before SpinnakerVideoSource remove time - + Chunk(s) that started after SpinnakerVideoSource install time for SpinnakerVideoSource that are not yet removed + Only the combination of Chunk and UndergroundFeeder with overlapping time + + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time + + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed """ return ( - acquisition.Chunk * SpinnakerVideoSource.join(SpinnakerVideoSource.RemovalTime, left=True) - & 'chunk_start >= spinnaker_video_source_install_time' - & 'chunk_start < IFNULL(spinnaker_video_source_removal_time, "2200-01-01")' + acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) + & 'chunk_start >= underground_feeder_install_time' + & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' ) def make(self, key): @@ -516,7 +285,7 @@ def make(self, key): ) raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - device_name = (SpinnakerVideoSource & key).fetch1('spinnaker_video_source_name') + device_name = (UndergroundFeeder & key).fetch1('underground_feeder_name') stream = self._stream_reader( **{ @@ -544,17 +313,19 @@ def make(self, key): @schema -class UndergroundFeederBeamBreak(dj.Imported): - definition = """ # Raw per-chunk BeamBreak data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) +class UndergroundFeederDepletionState(dj.Imported): + definition = """ # Raw per-chunk DepletionState data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) -> UndergroundFeeder -> acquisition.Chunk --- sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of BeamBreak data - event: longblob + timestamps: longblob # (datetime) timestamps of DepletionState data + threshold: longblob + offset: longblob + rate: longblob """ - _stream_reader = aeon.io.reader.BitmaskEvent - _stream_detail = {'stream_type': 'BeamBreak', 'stream_reader': 'aeon.io.reader.BitmaskEvent', 'stream_reader_kwargs': {'pattern': '{pattern}_32', 'value': 34, 'tag': 'BeamBroken'}, 'stream_description': '', 'stream_hash': UUID('b14171e6-d27d-117a-ae73-a16c4b5fc8a2')} + _stream_reader = aeon.io.reader.Csv + _stream_detail = {'stream_type': 'DepletionState', 'stream_reader': 'aeon.io.reader.Csv', 'stream_reader_kwargs': {'pattern': '{pattern}_*', 'columns': ['threshold', 'offset', 'rate'], 'extension': 'csv', 'dtype': None}, 'stream_description': '', 'stream_hash': UUID('a944b719-c723-08f8-b695-7be616e57bd5')} @property def key_source(self): @@ -603,17 +374,18 @@ def make(self, key): @schema -class UndergroundFeederDeliverPellet(dj.Imported): - definition = """ # Raw per-chunk DeliverPellet data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) +class UndergroundFeederEncoder(dj.Imported): + definition = """ # Raw per-chunk Encoder data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) -> UndergroundFeeder -> acquisition.Chunk --- sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of DeliverPellet data - event: longblob + timestamps: longblob # (datetime) timestamps of Encoder data + angle: longblob + intensity: longblob """ - _stream_reader = aeon.io.reader.BitmaskEvent - _stream_detail = {'stream_type': 'DeliverPellet', 'stream_reader': 'aeon.io.reader.BitmaskEvent', 'stream_reader_kwargs': {'pattern': '{pattern}_35', 'value': 1, 'tag': 'TriggeredPellet'}, 'stream_description': '', 'stream_hash': UUID('c49dda51-2e38-8b49-d1d8-2e54ea928e9c')} + _stream_reader = aeon.io.reader.Encoder + _stream_detail = {'stream_type': 'Encoder', 'stream_reader': 'aeon.io.reader.Encoder', 'stream_reader_kwargs': {'pattern': '{pattern}_90_*'}, 'stream_description': '', 'stream_hash': UUID('f96b0b26-26f6-5ff6-b3c7-5aa5adc00c1a')} @property def key_source(self): @@ -662,19 +434,17 @@ def make(self, key): @schema -class UndergroundFeederDepletionState(dj.Imported): - definition = """ # Raw per-chunk DepletionState data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) +class UndergroundFeederManualDelivery(dj.Imported): + definition = """ # Raw per-chunk ManualDelivery data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) -> UndergroundFeeder -> acquisition.Chunk --- sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of DepletionState data - threshold: longblob - d1: longblob - delta: longblob + timestamps: longblob # (datetime) timestamps of ManualDelivery data + manual_delivery: longblob """ - _stream_reader = aeon.schema.foraging._PatchState - _stream_detail = {'stream_type': 'DepletionState', 'stream_reader': 'aeon.schema.foraging._PatchState', 'stream_reader_kwargs': {'pattern': '{pattern}_State_*'}, 'stream_description': '', 'stream_hash': UUID('17c3e36f-3f2e-2494-bbd3-5cb9a23d3039')} + _stream_reader = aeon.io.reader.Harp + _stream_detail = {'stream_type': 'ManualDelivery', 'stream_reader': 'aeon.io.reader.Harp', 'stream_reader_kwargs': {'pattern': '{pattern}_*', 'columns': ['manual_delivery'], 'extension': 'bin'}, 'stream_description': '', 'stream_hash': UUID('98ce23d4-01c5-a848-dd6b-8b284c323fb0')} @property def key_source(self): @@ -723,18 +493,17 @@ def make(self, key): @schema -class UndergroundFeederEncoder(dj.Imported): - definition = """ # Raw per-chunk Encoder data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) +class UndergroundFeederMissedPellet(dj.Imported): + definition = """ # Raw per-chunk MissedPellet data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) -> UndergroundFeeder -> acquisition.Chunk --- sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of Encoder data - angle: longblob - intensity: longblob + timestamps: longblob # (datetime) timestamps of MissedPellet data + missed_pellet: longblob """ - _stream_reader = aeon.io.reader.Encoder - _stream_detail = {'stream_type': 'Encoder', 'stream_reader': 'aeon.io.reader.Encoder', 'stream_reader_kwargs': {'pattern': '{pattern}_90_*'}, 'stream_description': '', 'stream_hash': UUID('f96b0b26-26f6-5ff6-b3c7-5aa5adc00c1a')} + _stream_reader = aeon.io.reader.Harp + _stream_detail = {'stream_type': 'MissedPellet', 'stream_reader': 'aeon.io.reader.Harp', 'stream_reader_kwargs': {'pattern': '{pattern}_*', 'columns': ['missed_pellet'], 'extension': 'bin'}, 'stream_description': '', 'stream_hash': UUID('2fa12bbc-3207-dddc-f6ee-b79c55b6d9a2')} @property def key_source(self): @@ -783,30 +552,29 @@ def make(self, key): @schema -class WeightScaleWeightFiltered(dj.Imported): - definition = """ # Raw per-chunk WeightFiltered data stream from WeightScale (auto-generated with aeon_mecha-unknown) - -> WeightScale +class UndergroundFeederRetriedDelivery(dj.Imported): + definition = """ # Raw per-chunk RetriedDelivery data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + -> UndergroundFeeder -> acquisition.Chunk --- sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of WeightFiltered data - value: longblob - stable: longblob + timestamps: longblob # (datetime) timestamps of RetriedDelivery data + retried_delivery: longblob """ - _stream_reader = aeon.schema.foraging._Weight - _stream_detail = {'stream_type': 'WeightFiltered', 'stream_reader': 'aeon.schema.foraging._Weight', 'stream_reader_kwargs': {'pattern': '{pattern}_202_*'}, 'stream_description': '', 'stream_hash': UUID('912fe1d4-991c-54a8-a7a7-5b96f5ffca91')} + _stream_reader = aeon.io.reader.Harp + _stream_detail = {'stream_type': 'RetriedDelivery', 'stream_reader': 'aeon.io.reader.Harp', 'stream_reader_kwargs': {'pattern': '{pattern}_*', 'columns': ['retried_delivery'], 'extension': 'bin'}, 'stream_description': '', 'stream_hash': UUID('62f23eab-4469-5740-dfa0-6f1aa754de8e')} @property def key_source(self): f""" - Only the combination of Chunk and WeightScale with overlapping time - + Chunk(s) that started after WeightScale install time and ended before WeightScale remove time - + Chunk(s) that started after WeightScale install time for WeightScale that are not yet removed + Only the combination of Chunk and UndergroundFeeder with overlapping time + + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time + + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed """ return ( - acquisition.Chunk * WeightScale.join(WeightScale.RemovalTime, left=True) - & 'chunk_start >= weight_scale_install_time' - & 'chunk_start < IFNULL(weight_scale_removal_time, "2200-01-01")' + acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) + & 'chunk_start >= underground_feeder_install_time' + & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' ) def make(self, key): @@ -815,7 +583,7 @@ def make(self, key): ) raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - device_name = (WeightScale & key).fetch1('weight_scale_name') + device_name = (UndergroundFeeder & key).fetch1('underground_feeder_name') stream = self._stream_reader( **{ @@ -843,18 +611,18 @@ def make(self, key): @schema -class WeightScaleWeightRaw(dj.Imported): - definition = """ # Raw per-chunk WeightRaw data stream from WeightScale (auto-generated with aeon_mecha-unknown) +class WeightScaleWeightFiltered(dj.Imported): + definition = """ # Raw per-chunk WeightFiltered data stream from WeightScale (auto-generated with aeon_mecha-unknown) -> WeightScale -> acquisition.Chunk --- sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of WeightRaw data - value: longblob - stable: longblob + timestamps: longblob # (datetime) timestamps of WeightFiltered data + weight: longblob + stability: longblob """ - _stream_reader = aeon.schema.foraging._Weight - _stream_detail = {'stream_type': 'WeightRaw', 'stream_reader': 'aeon.schema.foraging._Weight', 'stream_reader_kwargs': {'pattern': '{pattern}_200_*'}, 'stream_description': '', 'stream_hash': UUID('f5ab9451-7104-2daf-0aa2-d16abb3974ad')} + _stream_reader = aeon.io.reader.Harp + _stream_detail = {'stream_type': 'WeightFiltered', 'stream_reader': 'aeon.io.reader.Harp', 'stream_reader_kwargs': {'pattern': '{pattern}_202*', 'columns': ['weight(g)', 'stability'], 'extension': 'bin'}, 'stream_description': '', 'stream_hash': UUID('bd135a97-1161-3dd3-5ca3-e5d342485728')} @property def key_source(self): @@ -903,18 +671,18 @@ def make(self, key): @schema -class WeightScaleWeightSubject(dj.Imported): - definition = """ # Raw per-chunk WeightSubject data stream from WeightScale (auto-generated with aeon_mecha-unknown) +class WeightScaleWeightRaw(dj.Imported): + definition = """ # Raw per-chunk WeightRaw data stream from WeightScale (auto-generated with aeon_mecha-unknown) -> WeightScale -> acquisition.Chunk --- sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of WeightSubject data - value: longblob - stable: longblob + timestamps: longblob # (datetime) timestamps of WeightRaw data + weight: longblob + stability: longblob """ - _stream_reader = aeon.schema.foraging._Weight - _stream_detail = {'stream_type': 'WeightSubject', 'stream_reader': 'aeon.schema.foraging._Weight', 'stream_reader_kwargs': {'pattern': '{pattern}_204_*'}, 'stream_description': '', 'stream_hash': UUID('01f51299-5709-9e03-b415-3d92a6499202')} + _stream_reader = aeon.io.reader.Harp + _stream_detail = {'stream_type': 'WeightRaw', 'stream_reader': 'aeon.io.reader.Harp', 'stream_reader_kwargs': {'pattern': '{pattern}_200*', 'columns': ['weight(g)', 'stability'], 'extension': 'bin'}, 'stream_description': '', 'stream_hash': UUID('0d27b1af-e78b-d889-62c0-41a20df6a015')} @property def key_source(self): diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 4aabb682..59e03e8f 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -18,6 +18,7 @@ _weight_scale_rate = 100 _weight_scale_nest = 1 _colony_csv_path = pathlib.Path("/ceph/aeon/aeon/colony/colony.csv") +_aeon_schemas = ["social01"] def ingest_subject(colony_csv_path: pathlib.Path = _colony_csv_path) -> None: @@ -36,12 +37,12 @@ def ingest_subject(colony_csv_path: pathlib.Path = _colony_csv_path) -> None: def insert_stream_types(): - """Insert into streams.streamType table all streams in the dataset schema.""" - from aeon.io import schemas as aeon_schema + """Insert into streams.streamType table all streams in the aeon schemas.""" + from aeon.schema import schemas as aeon_schemas streams = dj.VirtualModule("streams", streams_maker.schema_name) - schemas = [v for v in aeon_schema.__dict__.values() if isinstance(v, DotMap)] + schemas = [getattr(aeon_schemas, aeon_schema) for aeon_schema in _aeon_schemas] for schema in schemas: stream_entries = get_stream_entries(schema) @@ -58,7 +59,11 @@ def insert_stream_types(): def insert_device_types(device_schema: DotMap, metadata_yml_filepath: Path): - """Use dataset.schema and metadata.yml to insert into streams.DeviceType and streams.Device. Only insert device types that were defined both in the device schema (e.g., exp02) and Metadata.yml. It then creates new device tables under streams schema.""" + """ + Use aeon.schema.schemas and metadata.yml to insert into streams.DeviceType and streams.Device. + Only insert device types that were defined both in the device schema (e.g., exp02) and Metadata.yml. + It then creates new device tables under streams schema. + """ streams = dj.VirtualModule("streams", streams_maker.schema_name) device_info: dict[dict] = get_device_info(device_schema) @@ -431,7 +436,7 @@ def get_device_mapper(schema: DotMap, metadata_yml_filepath: Path): ) # Store the mapper dictionary here - filename = Path(__file__).parent.parent / "create_experiments/device_type_mapper.json" + filename = Path(__file__).parent.parent / "utils/device_type_mapper.json" device_type_mapper = {} # {device_name: device_type} device_sn = {} # {device_name: device_sn} diff --git a/aeon/dj_pipeline/utils/streams_maker.py b/aeon/dj_pipeline/utils/streams_maker.py index 20c58880..e95f14e1 100644 --- a/aeon/dj_pipeline/utils/streams_maker.py +++ b/aeon/dj_pipeline/utils/streams_maker.py @@ -121,6 +121,7 @@ def get_device_stream_template(device_type: str, stream_type: str, streams_modul for col in stream.columns: if col.startswith("_"): continue + col = re.sub(r"\([^)]*\)", "", col) table_definition += f"{col}: longblob\n " class DeviceDataStream(dj.Imported): From 39c8cdd9f26089ad28786897c7fec70890bcf6b5 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 9 Jan 2024 13:55:59 -0600 Subject: [PATCH 367/489] update(sciviz) --- aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml index a8509e13..35742345 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml @@ -7,7 +7,7 @@ services: pharus: # cpus: 2.0 mem_limit: 16g - image: jverswijver/pharus:0.8.5-PY_VER-3.9 + image: datajoint/pharus:0.8.10-py3.9 environment: # - FLASK_ENV=development # enables logging to console from Flask - PHARUS_SPEC_PATH=/main/specsheet.yaml # for dynamic utils spec @@ -33,7 +33,7 @@ services: sci-viz: cpus: 2.0 mem_limit: 4g - image: jverswijver/sci-viz:2.3.3-hotfix3 + image: jverswijver/sci-viz:2.3.4 environment: - CHOKIDAR_USEPOLLING=true - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/api From 7309cd165810c9e4df00e47c313a22844cbcdfa7 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 9 Jan 2024 13:56:37 -0600 Subject: [PATCH 368/489] fix(streams): sanitize column names (remove parenthesis) --- aeon/dj_pipeline/utils/streams_maker.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/utils/streams_maker.py b/aeon/dj_pipeline/utils/streams_maker.py index e95f14e1..2a952c84 100644 --- a/aeon/dj_pipeline/utils/streams_maker.py +++ b/aeon/dj_pipeline/utils/streams_maker.py @@ -169,7 +169,11 @@ def make(self, key): **key, "sample_count": len(stream_data), "timestamps": stream_data.index.values, - **{c: stream_data[c].values for c in stream.columns if not c.startswith("_")}, + **{ + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream.columns + if not c.startswith("_") + }, }, ignore_extra_fields=True, ) @@ -188,6 +192,7 @@ def main(create_tables=True): imports_str = ( "#---- DO NOT MODIFY ----\n" "#---- THIS FILE IS AUTO-GENERATED BY `streams_maker.py` ----\n\n" + "import re\n" "import datajoint as dj\n" "import pandas as pd\n" "from uuid import UUID\n\n" From e6d1ea2e7ba0ed2a3abe285391e20f8fb83f7069 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 9 Jan 2024 14:05:47 -0600 Subject: [PATCH 369/489] update(streams) --- .../device_type_mapper.json | 1 + aeon/dj_pipeline/streams.py | 61 ++++++++++++++++--- 2 files changed, 52 insertions(+), 10 deletions(-) create mode 100644 aeon/dj_pipeline/create_experiments/device_type_mapper.json diff --git a/aeon/dj_pipeline/create_experiments/device_type_mapper.json b/aeon/dj_pipeline/create_experiments/device_type_mapper.json new file mode 100644 index 00000000..848f0f3b --- /dev/null +++ b/aeon/dj_pipeline/create_experiments/device_type_mapper.json @@ -0,0 +1 @@ +{"VideoController": "CameraController", "CameraTop": "SpinnakerVideoSource", "CameraWest": "SpinnakerVideoSource", "CameraEast": "SpinnakerVideoSource", "CameraNorth": "SpinnakerVideoSource", "CameraSouth": "SpinnakerVideoSource", "CameraPatch1": "SpinnakerVideoSource", "CameraPatch2": "SpinnakerVideoSource", "CameraNest": "SpinnakerVideoSource", "AudioAmbient": "AudioSource", "Patch1": "UndergroundFeeder", "Patch2": "UndergroundFeeder", "WeightNest": "WeightScale", "TrackingTop": "PositionTracking", "ActivityCenter": "ActivityTracking", "ActivityArena": "ActivityTracking", "ActivityNest": "ActivityTracking", "ActivityPatch1": "ActivityTracking", "ActivityPatch2": "ActivityTracking", "InNest": "RegionTracking", "InPatch1": "RegionTracking", "InPatch2": "RegionTracking", "ArenaCenter": "DistanceFromPoint", "InArena": "InRange", "InCorridor": "InRange", "ClockSynchronizer": "TimestampGenerator", "Rfid": "Rfid Reader", "CameraPatch3": "SpinnakerVideoSource", "Patch3": "UndergroundFeeder", "Nest": "WeightScale", "RfidNest1": "RfidReader", "RfidNest2": "RfidReader", "RfidGate": "RfidReader", "RfidPatch1": "RfidReader", "RfidPatch2": "RfidReader", "RfidPatch3": "RfidReader", "LightCycle": "EnvironmentCondition"} \ No newline at end of file diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index ff122dcc..19b742a0 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -1,6 +1,7 @@ #---- DO NOT MODIFY ---- #---- THIS FILE IS AUTO-GENERATED BY `streams_maker.py` ---- +import re import datajoint as dj import pandas as pd from uuid import UUID @@ -188,7 +189,11 @@ def make(self, key): **key, "sample_count": len(stream_data), "timestamps": stream_data.index.values, - **{c: stream_data[c].values for c in stream.columns if not c.startswith("_")}, + **{ + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream.columns + if not c.startswith("_") + }, }, ignore_extra_fields=True, ) @@ -247,7 +252,11 @@ def make(self, key): **key, "sample_count": len(stream_data), "timestamps": stream_data.index.values, - **{c: stream_data[c].values for c in stream.columns if not c.startswith("_")}, + **{ + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream.columns + if not c.startswith("_") + }, }, ignore_extra_fields=True, ) @@ -306,7 +315,11 @@ def make(self, key): **key, "sample_count": len(stream_data), "timestamps": stream_data.index.values, - **{c: stream_data[c].values for c in stream.columns if not c.startswith("_")}, + **{ + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream.columns + if not c.startswith("_") + }, }, ignore_extra_fields=True, ) @@ -367,7 +380,11 @@ def make(self, key): **key, "sample_count": len(stream_data), "timestamps": stream_data.index.values, - **{c: stream_data[c].values for c in stream.columns if not c.startswith("_")}, + **{ + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream.columns + if not c.startswith("_") + }, }, ignore_extra_fields=True, ) @@ -427,7 +444,11 @@ def make(self, key): **key, "sample_count": len(stream_data), "timestamps": stream_data.index.values, - **{c: stream_data[c].values for c in stream.columns if not c.startswith("_")}, + **{ + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream.columns + if not c.startswith("_") + }, }, ignore_extra_fields=True, ) @@ -486,7 +507,11 @@ def make(self, key): **key, "sample_count": len(stream_data), "timestamps": stream_data.index.values, - **{c: stream_data[c].values for c in stream.columns if not c.startswith("_")}, + **{ + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream.columns + if not c.startswith("_") + }, }, ignore_extra_fields=True, ) @@ -545,7 +570,11 @@ def make(self, key): **key, "sample_count": len(stream_data), "timestamps": stream_data.index.values, - **{c: stream_data[c].values for c in stream.columns if not c.startswith("_")}, + **{ + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream.columns + if not c.startswith("_") + }, }, ignore_extra_fields=True, ) @@ -604,7 +633,11 @@ def make(self, key): **key, "sample_count": len(stream_data), "timestamps": stream_data.index.values, - **{c: stream_data[c].values for c in stream.columns if not c.startswith("_")}, + **{ + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream.columns + if not c.startswith("_") + }, }, ignore_extra_fields=True, ) @@ -664,7 +697,11 @@ def make(self, key): **key, "sample_count": len(stream_data), "timestamps": stream_data.index.values, - **{c: stream_data[c].values for c in stream.columns if not c.startswith("_")}, + **{ + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream.columns + if not c.startswith("_") + }, }, ignore_extra_fields=True, ) @@ -724,7 +761,11 @@ def make(self, key): **key, "sample_count": len(stream_data), "timestamps": stream_data.index.values, - **{c: stream_data[c].values for c in stream.columns if not c.startswith("_")}, + **{ + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream.columns + if not c.startswith("_") + }, }, ignore_extra_fields=True, ) From f93adbef28bfe71f41bd775c263dd84e5d7992fb Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 10 Jan 2024 11:17:14 -0600 Subject: [PATCH 370/489] feat(schemas): add schema for social 01 experiment --- aeon/io/reader.py | 112 +++++++++++++++++++++ aeon/schema/schemas.py | 82 ++++++--------- aeon/schema/social.py | 223 +++++++++++++++++++---------------------- 3 files changed, 244 insertions(+), 173 deletions(-) diff --git a/aeon/io/reader.py b/aeon/io/reader.py index 035e22a3..9d940135 100644 --- a/aeon/io/reader.py +++ b/aeon/io/reader.py @@ -6,8 +6,10 @@ import numpy as np import pandas as pd from dotmap import DotMap +from pathlib import Path from aeon.io.api import chunk_key +from aeon import util _SECONDS_PER_TICK = 32e-6 _payloadtypes = { @@ -259,6 +261,116 @@ def read(self, file): return data +class Pose(Harp): + """Reader for Harp-binarized tracking data given a model that outputs id, parts, and likelihoods. + + Columns: + class (int): Int ID of a subject in the environment. + class_likelihood (float): Likelihood of the subject's identity. + part (str): Bodypart on the subject. + part_likelihood (float): Likelihood of the specified bodypart. + x (float): X-coordinate of the bodypart. + y (float): Y-coordinate of the bodypart. + """ + + def __init__(self, pattern: str, extension: str = "bin"): + """Pose reader constructor.""" + # `pattern` for this reader should typically be '_*' + super().__init__(pattern, columns=None, extension=extension) + + def read( + self, file: Path, ceph_proc_dir: str | Path = "/ceph/aeon/aeon/data/processed" + ) -> pd.DataFrame: + """Reads data from the Harp-binarized tracking file.""" + # Get config file from `file`, then bodyparts from config file. + model_dir = Path(*Path(file.stem.replace("_", "/")).parent.parts[1:]) + config_file_dir = ceph_proc_dir / model_dir + if not config_file_dir.exists(): + raise FileNotFoundError(f"Cannot find model dir {config_file_dir}") + config_file = self.get_config_file(config_file_dir) + parts = self.get_bodyparts(config_file) + + # Using bodyparts, assign column names to Harp register values, and read data in default format. + columns = ["class", "class_likelihood"] + for part in parts: + columns.extend([f"{part}_x", f"{part}_y", f"{part}_likelihood"]) + self.columns = columns + data = super().read(file) + + # Drop any repeat parts. + unique_parts, unique_idxs = np.unique(parts, return_index=True) + repeat_idxs = np.setdiff1d(np.arange(len(parts)), unique_idxs) + if repeat_idxs: # drop x, y, and likelihood cols for repeat parts (skip first 5 cols) + init_rep_part_col_idx = (repeat_idxs - 1) * 3 + 5 + rep_part_col_idxs = np.concatenate([np.arange(i, i + 3) for i in init_rep_part_col_idx]) + keep_part_col_idxs = np.setdiff1d(np.arange(len(data.columns)), rep_part_col_idxs) + data = data.iloc[:, keep_part_col_idxs] + parts = unique_parts + + # Set new columns, and reformat `data`. + n_parts = len(parts) + part_data_list = [pd.DataFrame()] * n_parts + new_columns = ["class", "class_likelihood", "part", "x", "y", "part_likelihood"] + new_data = pd.DataFrame(columns=new_columns) + for i, part in enumerate(parts): + part_columns = ["class", "class_likelihood", f"{part}_x", f"{part}_y", f"{part}_likelihood"] + part_data = pd.DataFrame(data[part_columns]) + part_data.insert(2, "part", part) + part_data.columns = new_columns + part_data_list[i] = part_data + new_data = pd.concat(part_data_list) + return new_data.sort_index() + + def get_bodyparts(self, file: Path) -> list[str]: + """Returns a list of bodyparts from a model's config file.""" + parts = [] + with open(file) as f: + config = json.load(f) + if file.stem == "confmap_config": # SLEAP + try: + heads = config["model"]["heads"] + parts = [util.find_nested_key(heads, "anchor_part")] + parts += util.find_nested_key(heads, "part_names") + except KeyError as err: + if not parts: + raise KeyError(f"Cannot find bodyparts in {file}.") from err + return parts + + @classmethod + def get_config_file( + cls, + config_file_dir: Path, + config_file_names: None | list[str] = None, + ) -> Path: + """Returns the config file from a model's config directory.""" + if config_file_names is None: + config_file_names = ["confmap_config.json"] # SLEAP (add for other trackers to this list) + config_file = None + for f in config_file_names: + if (config_file_dir / f).exists(): + config_file = config_file_dir / f + break + if config_file is None: + raise FileNotFoundError(f"Cannot find config file in {config_file_dir}") + return config_file + + @classmethod + def class_int2str(cls, data: pd.DataFrame, config_file_dir: Path) -> pd.DataFrame: + """Converts a class integer in a tracking data dataframe to its associated string (subject id).""" + config_file = cls.get_config_file(config_file_dir) + if config_file.stem == "confmap_config": # SLEAP + with open(config_file) as f: + config = json.load(f) + try: + heads = config["model"]["heads"] + classes = util.find_nested_key(heads, "classes") + except KeyError as err: + raise KeyError(f"Cannot find classes in {config_file}.") from err + for i, subj in enumerate(classes): + data.loc[data["class"] == i, "class"] = subj + return data + + def from_dict(data, pattern=None): reader_type = data.get("type", None) if reader_type is not None: diff --git a/aeon/schema/schemas.py b/aeon/schema/schemas.py index 7b61c2d7..ad13f600 100644 --- a/aeon/schema/schemas.py +++ b/aeon/schema/schemas.py @@ -1,6 +1,6 @@ from dotmap import DotMap from aeon.io.device import Device -from aeon.schema import core, foraging, octagon +from aeon.schema import core, foraging, octagon, social exp02 = DotMap( [ @@ -56,57 +56,31 @@ ] ) -# All recorded social01 streams: -# *Note* regiser 8 is always the harp heartbeat for any device that has this stream. - -# - Metadata.yml -# - Environment_BlockState -# - Environment_EnvironmentState -# - Environment_LightEvents -# - Environment_MessageLog -# - Environment_SubjectState -# - Environment_SubjectVisits -# - Environment_SubjectWeight -# - CameraTop (200, 201, avi, csv, ,) -# - 200: position -# - 201: region -# - CameraNorth (avi, csv) -# - CameraEast (avi, csv) -# - CameraSouth (avi, csv) -# - CameraWest (avi, csv) -# - CameraPatch1 (avi, csv) -# - CameraPatch2 (avi, csv) -# - CameraPatch3 (avi, csv) -# - CameraNest (avi, csv) -# - ClockSynchronizer (8, 36) -# - 36: -# - Nest (200, 201, 202, 203) -# - 200: weight_raw -# - 201: weight_tare -# - 202: weight_filtered -# - 203: weight_baseline -# - 204: weight_subject -# - Patch1 (8, 32, 35, 36, 87, 90, 91, 200, 201, 202, 203, State) -# - 32: beam_break -# - 35: delivery_set -# - 36: delivery_clear -# - 87: expansion_board -# - 90: enocder_read -# - 91: encoder_mode -# - 200: dispenser_state -# - 201: delivery_manual -# - 202: missed_pellet -# - 203: delivery_retry -# - Patch2 (8, 32, 35, 36, 87, 90, 91, State) -# - Patch3 (8, 32, 35, 36, 87, 90, 91, 200, 203, State) -# - RfidEventsGate (8, 32, 35) -# - 32: entry_id -# - 35: hardware_notifications -# - RfidEventsNest1 (8, 32, 35) -# - RfidEventsNest2 (8, 32, 35) -# - RfidEventsPatch1 (8, 32, 35) -# - RfidEventsPatch2 (8, 32, 35) -# - RfidEventsPatch3 (8, 32, 35) -# - VideoController (8, 32, 33, 34, 35, 36, 45, 52) -# - 32: frame_number \ No newline at end of file +social01 = DotMap( + [ + Device("Metadata", core.metadata), + Device("Environment", social.env_block_state_b, social.light_events_b, core.message_log), + Device("Subject", social.subject_state_b, social.subject_visits_b, social.subject_weight_b), + Device("CameraTop", core.video, social.camera_top_pos_b), + Device("CameraTop", core.video), + Device("CameraNorth", core.video), + Device("CameraSouth", core.video), + Device("CameraEast", core.video), + Device("CameraWest", core.video), + Device("CameraPatch1", core.video), + Device("CameraPatch2", core.video), + Device("CameraPatch3", core.video), + Device("CameraNest", core.video), + Device("Nest", social.weight_raw_b, social.weight_filtered_b), + Device("Patch1", social.patch_streams_b), + Device("Patch2", social.patch_streams_b), + Device("Patch3", social.patch_streams_b), + Device("EventsGate", social.rfid_b), + Device("EventsNest1", social.rfid_b), + Device("EventsNest2", social.rfid_b), + Device("EventsPatch1", social.rfid_b), + Device("EventsPatch2", social.rfid_b), + Device("EventsPatch3", social.rfid_b), + ] +) diff --git a/aeon/schema/social.py b/aeon/schema/social.py index 6ad46ea0..b1ad040e 100644 --- a/aeon/schema/social.py +++ b/aeon/schema/social.py @@ -1,119 +1,104 @@ -"""Readers for data relevant to Social experiments.""" - -import json -from pathlib import Path - -import numpy as np -import pandas as pd - -import aeon.io.reader as _reader -from aeon import util - - -class Pose(_reader.Harp): - """Reader for Harp-binarized tracking data given a model that outputs id, parts, and likelihoods. - - Columns: - class (int): Int ID of a subject in the environment. - class_likelihood (float): Likelihood of the subject's identity. - part (str): Bodypart on the subject. - part_likelihood (float): Likelihood of the specified bodypart. - x (float): X-coordinate of the bodypart. - y (float): Y-coordinate of the bodypart. - """ - - def __init__(self, pattern: str, extension: str = "bin"): - """Pose reader constructor.""" - # `pattern` for this reader should typically be '_*' - super().__init__(pattern, columns=None, extension=extension) - - def read( - self, file: Path, ceph_proc_dir: str | Path = "/ceph/aeon/aeon/data/processed" - ) -> pd.DataFrame: - """Reads data from the Harp-binarized tracking file.""" - # Get config file from `file`, then bodyparts from config file. - model_dir = Path(*Path(file.stem.replace("_", "/")).parent.parts[1:]) - config_file_dir = ceph_proc_dir / model_dir - if not config_file_dir.exists(): - raise FileNotFoundError(f"Cannot find model dir {config_file_dir}") - config_file = get_config_file(config_file_dir) - parts = self.get_bodyparts(config_file) - - # Using bodyparts, assign column names to Harp register values, and read data in default format. - columns = ["class", "class_likelihood"] - for part in parts: - columns.extend([f"{part}_x", f"{part}_y", f"{part}_likelihood"]) - self.columns = columns - data = super().read(file) - - # Drop any repeat parts. - unique_parts, unique_idxs = np.unique(parts, return_index=True) - repeat_idxs = np.setdiff1d(np.arange(len(parts)), unique_idxs) - if repeat_idxs: # drop x, y, and likelihood cols for repeat parts (skip first 5 cols) - init_rep_part_col_idx = (repeat_idxs - 1) * 3 + 5 - rep_part_col_idxs = np.concatenate([np.arange(i, i + 3) for i in init_rep_part_col_idx]) - keep_part_col_idxs = np.setdiff1d(np.arange(len(data.columns)), rep_part_col_idxs) - data = data.iloc[:, keep_part_col_idxs] - parts = unique_parts - - # Set new columns, and reformat `data`. - n_parts = len(parts) - part_data_list = [pd.DataFrame()] * n_parts - new_columns = ["class", "class_likelihood", "part", "x", "y", "part_likelihood"] - new_data = pd.DataFrame(columns=new_columns) - for i, part in enumerate(parts): - part_columns = ["class", "class_likelihood", f"{part}_x", f"{part}_y", f"{part}_likelihood"] - part_data = pd.DataFrame(data[part_columns]) - part_data.insert(2, "part", part) - part_data.columns = new_columns - part_data_list[i] = part_data - new_data = pd.concat(part_data_list) - return new_data.sort_index() - - def get_bodyparts(self, file: Path) -> list[str]: - """Returns a list of bodyparts from a model's config file.""" - parts = [] - with open(file) as f: - config = json.load(f) - if file.stem == "confmap_config": # SLEAP - try: - heads = config["model"]["heads"] - parts = [util.find_nested_key(heads, "anchor_part")] - parts += util.find_nested_key(heads, "part_names") - except KeyError as err: - if not parts: - raise KeyError(f"Cannot find bodyparts in {file}.") from err - return parts - - -def get_config_file( - config_file_dir: Path, - config_file_names: None | list[str] = None, -) -> Path: - """Returns the config file from a model's config directory.""" - if config_file_names is None: - config_file_names = ["confmap_config.json"] # SLEAP (add for other trackers to this list) - config_file = None - for f in config_file_names: - if (config_file_dir / f).exists(): - config_file = config_file_dir / f - break - if config_file is None: - raise FileNotFoundError(f"Cannot find config file in {config_file_dir}") - return config_file - - -def class_int2str(data: pd.DataFrame, config_file_dir: Path) -> pd.DataFrame: - """Converts a class integer in a tracking data dataframe to its associated string (subject id).""" - config_file = get_config_file(config_file_dir) - if config_file.stem == "confmap_config": # SLEAP - with open(config_file) as f: - config = json.load(f) - try: - heads = config["model"]["heads"] - classes = util.find_nested_key(heads, "classes") - except KeyError as err: - raise KeyError(f"Cannot find classes in {config_file}.") from err - for i, subj in enumerate(classes): - data.loc[data["class"] == i, "class"] = subj - return data +from aeon.io import reader +from aeon.io.device import Device, register +from aeon.schema import core, foraging + + +"""Creating the Social 0.1 schema""" + +# Above we've listed out all the streams we recorded from during Social0.1, but we won't care to analyze all +# of them. Instead, we'll create a DotMap schema from Device objects that only contains Readers for the +# streams we want to analyze. + +# We'll see both examples of binder functions we saw previously: 1. "empty pattern", and +# 2. "device-name passed". + +# And we'll see both examples of instantiating Device objects we saw previously: 1. from singleton binder +# functions; 2. from multiple and/or nested binder functions. + +# (Note, in the simplest case, a schema can always be created from / reduced to "empty pattern" binder +# functions as singletons in Device objects.) + +# Metadata.yml (will be a singleton binder function Device object) +# --- + +metadata = Device("Metadata", core.metadata) + +# --- + +# Environment (will be a nested, multiple binder function Device object) +# --- + +# BlockState +# binder function: "device-name passed"; `pattern` will be set by `Device` object name: "Environment" +block_state_b = lambda pattern: { + "BlockState": reader.Csv(f"{pattern}_BlockState*", ["pellet_ct", "pellet_ct_thresh", "due_time"]) +} + +# EnvironmentState + +# Combine EnvironmentState and BlockState +env_block_state_b = lambda pattern: register(pattern, core.environment_state, block_state_b) + +# LightEvents +cols = ["channel", "value"] +light_events_r = reader.Csv("Environment_LightEvents*", cols) +light_events_b = lambda pattern: {"LightEvents": light_events_r} # binder function: "empty pattern" + +# SubjectState +cols = ["id", "weight", "type"] +subject_state_r = reader.Csv("Environment_SubjectState*", cols) +subject_state_b = lambda pattern: {"SubjectState": subject_state_r} # binder function: "empty pattern" + +# SubjectVisits +cols = ["id", "type", "region"] +subject_visits_r = reader.Csv("Environment_SubjectVisits*", cols) +subject_visits_b = lambda pattern: {"SubjectVisits": subject_visits_r} # binder function: "empty pattern" + +# SubjectWeight +cols = ["weight", "confidence", "subject_id", "int_id"] +subject_weight_r = reader.Csv("Environment_SubjectWeight*", cols) +subject_weight_b = lambda pattern: {"SubjectWeight": subject_weight_r} # binder function: "empty pattern" + +# Nested binder fn Device object. +environment = Device("Environment", env_block_state_b, light_events_b, core.message_log) # device name + +# Separate Device object for subject-specific streams. +subject = Device("Subject", subject_state_b, subject_visits_b, subject_weight_b) + +# --- + +# Camera +# --- + +camera_top_pos_b = lambda pattern: {"Pose": reader.Pose(f"{pattern}_test-node1*")} + +# --- + +# Nest +# --- + +weight_raw_b = lambda pattern: {"WeightRaw": reader.Harp("Nest_200*", ["weight(g)", "stability"])} +weight_filtered_b = lambda pattern: {"WeightFiltered": reader.Harp("Nest_202*", ["weight(g)", "stability"])} + +# --- + +# Patch +# --- + +# Combine streams for Patch device +patch_streams_b = lambda pattern: register( + pattern, + foraging.pellet_depletion_state, + core.encoder, + foraging.feeder, + foraging.pellet_manual_delivery, + foraging.missed_pellet, + foraging.pellet_retried_delivery, +) +# --- + +# Rfid +# --- + +rfid_names = ["EventsGate", "EventsNest1", "EventsNest2", "EventsPatch1", "EventsPatch2", "EventsPatch3"] +rfid_b = lambda pattern: {"RFID": reader.Harp(f"{pattern}_*", ["rfid"])} From 0a7df9dbd667104f52d8d81e34821f4132ff72a9 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 10 Jan 2024 17:06:56 -0600 Subject: [PATCH 371/489] rearrange(social01): moved content to `social.py` --- aeon/schema/schemas.py | 1 - aeon/schema/social01.py | 123 ---------------------------------------- 2 files changed, 124 deletions(-) delete mode 100644 aeon/schema/social01.py diff --git a/aeon/schema/schemas.py b/aeon/schema/schemas.py index ad13f600..f6d258e0 100644 --- a/aeon/schema/schemas.py +++ b/aeon/schema/schemas.py @@ -56,7 +56,6 @@ ] ) - social01 = DotMap( [ Device("Metadata", core.metadata), diff --git a/aeon/schema/social01.py b/aeon/schema/social01.py deleted file mode 100644 index 6e131480..00000000 --- a/aeon/schema/social01.py +++ /dev/null @@ -1,123 +0,0 @@ -from aeon.io import reader -from aeon.io.device import Device, register -from aeon.schema import core, social, foraging - - -"""Creating the Social 0.1 schema""" - -# Above we've listed out all the streams we recorded from during Social0.1, but we won't care to analyze all -# of them. Instead, we'll create a DotMap schema from Device objects that only contains Readers for the -# streams we want to analyze. - -# We'll see both examples of binder functions we saw previously: 1. "empty pattern", and -# 2. "device-name passed". - -# And we'll see both examples of instantiating Device objects we saw previously: 1. from singleton binder -# functions; 2. from multiple and/or nested binder functions. - -# (Note, in the simplest case, a schema can always be created from / reduced to "empty pattern" binder -# functions as singletons in Device objects.) - -# Metadata.yml (will be a singleton binder function Device object) -# --- - -metadata = Device("Metadata", core.metadata) - -# --- - -# Environment (will be a nested, multiple binder function Device object) -# --- - -# BlockState -# binder function: "device-name passed"; `pattern` will be set by `Device` object name: "Environment" -block_state_b = lambda pattern: { - "BlockState": reader.Csv(f"{pattern}_BlockState*", ["pellet_ct", "pellet_ct_thresh", "due_time"]) -} - -# EnvironmentState - -# Combine EnvironmentState and BlockState -env_block_state_b = lambda pattern: register(pattern, core.environment_state, block_state_b) - -# LightEvents -cols = ["channel", "value"] -light_events_r = reader.Csv("Environment_LightEvents*", cols) -light_events_b = lambda pattern: {"LightEvents": light_events_r} # binder function: "empty pattern" - -# SubjectState -cols = ["id", "weight", "type"] -subject_state_r = reader.Csv("Environment_SubjectState*", cols) -subject_state_b = lambda pattern: {"SubjectState": subject_state_r} # binder function: "empty pattern" - -# SubjectVisits -cols = ["id", "type", "region"] -subject_visits_r = reader.Csv("Environment_SubjectVisits*", cols) -subject_visits_b = lambda pattern: {"SubjectVisits": subject_visits_r} # binder function: "empty pattern" - -# SubjectWeight -cols = ["weight", "confidence", "subject_id", "int_id"] -subject_weight_r = reader.Csv("Environment_SubjectWeight*", cols) -subject_weight_b = lambda pattern: {"SubjectWeight": subject_weight_r} # binder function: "empty pattern" - -# Nested binder fn Device object. -environment = Device("Environment", env_block_state_b, light_events_b, core.message_log) # device name - -# Separate Device object for subject-specific streams. -subject = Device("Subject", subject_state_b, subject_visits_b, subject_weight_b) - -# --- - -# Camera -# --- - -camera_top_pos_b = lambda pattern: {"Pose": social.Pose(f"{pattern}_test-node1*")} - -camera_devices = [Device("CameraTop", core.video, camera_top_pos_b)] - -cam_names = ["North", "South", "East", "West", "Patch1", "Patch2", "Patch3", "Nest"] -cam_names = ["Camera" + name for name in cam_names] - -camera_devices += [Device(cam_name, core.video) for cam_name in cam_names] -# --- - -# Nest -# --- - -weight_raw_b = lambda pattern: {"WeightRaw": reader.Harp("Nest_200*", ["weight(g)", "stability"])} -weight_filtered_b = lambda pattern: {"WeightFiltered": reader.Harp("Nest_202*", ["weight(g)", "stability"])} - -nest = Device( - "Nest", - weight_raw_b, - weight_filtered_b, -) - -# --- - -# Patch -# --- - -patch_names = ["Patch1", "Patch2", "Patch3"] -patch_devices = [ - Device( - patch_name, - foraging.pellet_depletion_state, - core.encoder, - foraging.feeder, - foraging.pellet_manual_delivery, - foraging.missed_pellet, - foraging.pellet_retried_delivery, - ) - for patch_name in patch_names -] -# --- - -# Rfid -# --- - -rfid_names = ["EventsGate", "EventsNest1", "EventsNest2", "EventsPatch1", "EventsPatch2", "EventsPatch3"] -rfid_names = ["Rfid" + name for name in rfid_names] -rfid_devices = [ - Device(rfid_name, lambda pattern=rfid_name: {"RFID": reader.Harp(f"{pattern}_*", ["rfid"])}) - for rfid_name in rfid_names -] From 7fabdc11f6891141565fbb170f5449b580648552 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 11 Jan 2024 10:53:07 -0600 Subject: [PATCH 372/489] refactor(social): refactor binder functions to be more consistent with other schema --- aeon/schema/schemas.py | 11 ++++++--- aeon/schema/social.py | 51 +++++++++++++++++++++--------------------- 2 files changed, 33 insertions(+), 29 deletions(-) diff --git a/aeon/schema/schemas.py b/aeon/schema/schemas.py index ad13f600..a4e02ded 100644 --- a/aeon/schema/schemas.py +++ b/aeon/schema/schemas.py @@ -60,10 +60,15 @@ social01 = DotMap( [ Device("Metadata", core.metadata), - Device("Environment", social.env_block_state_b, social.light_events_b, core.message_log), - Device("Subject", social.subject_state_b, social.subject_visits_b, social.subject_weight_b), + Device( + "ExperimentalMetadata", + core.environment, + social.block_state_b, + social.light_events_b, + core.message_log, + ), + Device("Environment", social.environment_b, social.subject_b), Device("CameraTop", core.video, social.camera_top_pos_b), - Device("CameraTop", core.video), Device("CameraNorth", core.video), Device("CameraSouth", core.video), Device("CameraEast", core.video), diff --git a/aeon/schema/social.py b/aeon/schema/social.py index b1ad040e..25e08b76 100644 --- a/aeon/schema/social.py +++ b/aeon/schema/social.py @@ -29,42 +29,39 @@ # --- # BlockState -# binder function: "device-name passed"; `pattern` will be set by `Device` object name: "Environment" block_state_b = lambda pattern: { - "BlockState": reader.Csv(f"{pattern}_BlockState*", ["pellet_ct", "pellet_ct_thresh", "due_time"]) + "BlockState": reader.Csv(f"{pattern}_BlockState_*", ["pellet_ct", "pellet_ct_thresh", "due_time"]) } -# EnvironmentState - -# Combine EnvironmentState and BlockState -env_block_state_b = lambda pattern: register(pattern, core.environment_state, block_state_b) - # LightEvents -cols = ["channel", "value"] -light_events_r = reader.Csv("Environment_LightEvents*", cols) -light_events_b = lambda pattern: {"LightEvents": light_events_r} # binder function: "empty pattern" +light_events_b = lambda pattern: { + "LightEvents": reader.Csv("Environment_LightEvents_*", ["channel", "value"]) +} + +# Combine EnvironmentState, BlockState, LightEvents +environment_b = lambda pattern: register( + pattern, core.environment_state, block_state_b, light_events_b, core.message_log +) # SubjectState -cols = ["id", "weight", "type"] -subject_state_r = reader.Csv("Environment_SubjectState*", cols) -subject_state_b = lambda pattern: {"SubjectState": subject_state_r} # binder function: "empty pattern" +subject_state_b = lambda pattern: { + "SubjectState": reader.Csv("Environment_SubjectState_*", ["id", "weight", "type"]) +} # SubjectVisits -cols = ["id", "type", "region"] -subject_visits_r = reader.Csv("Environment_SubjectVisits*", cols) -subject_visits_b = lambda pattern: {"SubjectVisits": subject_visits_r} # binder function: "empty pattern" +subject_visits_b = lambda pattern: { + "SubjectVisits": reader.Csv("Environment_SubjectVisit_s*", ["id", "type", "region"]) +} # SubjectWeight -cols = ["weight", "confidence", "subject_id", "int_id"] -subject_weight_r = reader.Csv("Environment_SubjectWeight*", cols) -subject_weight_b = lambda pattern: {"SubjectWeight": subject_weight_r} # binder function: "empty pattern" - -# Nested binder fn Device object. -environment = Device("Environment", env_block_state_b, light_events_b, core.message_log) # device name +subject_weight_b = lambda pattern: { + "SubjectWeight": reader.Csv( + "Environment_SubjectWeight_*", ["weight", "confidence", "subject_id", "int_id"] + ) +} # Separate Device object for subject-specific streams. -subject = Device("Subject", subject_state_b, subject_visits_b, subject_weight_b) - +subject_b = lambda pattern: register(pattern, subject_state_b, subject_visits_b, subject_weight_b) # --- # Camera @@ -77,8 +74,10 @@ # Nest # --- -weight_raw_b = lambda pattern: {"WeightRaw": reader.Harp("Nest_200*", ["weight(g)", "stability"])} -weight_filtered_b = lambda pattern: {"WeightFiltered": reader.Harp("Nest_202*", ["weight(g)", "stability"])} +weight_raw_b = lambda pattern: {"WeightRaw": reader.Harp(f"{pattern}_200_*", ["weight(g)", "stability"])} +weight_filtered_b = lambda pattern: { + "WeightFiltered": reader.Harp(f"{pattern}_202_*", ["weight(g)", "stability"]) +} # --- From d74d9c8e9f7a77f207101fa50a4e411f037371b9 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 11 Jan 2024 13:00:44 -0600 Subject: [PATCH 373/489] fix(acquisition): missing imports --- aeon/dj_pipeline/acquisition.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 621f09cb..d2cc8162 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -10,7 +10,7 @@ from aeon.io import reader as io_reader from aeon.analysis import utils as analysis_utils -from . import get_schema_name +from . import get_schema_name, lab, subject from .utils import paths logger = dj.logger From 5b18e1c7a80bacdc446734d25268509a0594074a Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 11 Jan 2024 15:18:21 -0600 Subject: [PATCH 374/489] chore(load_metadata): remove obsolete "ingest_subject" mechanism from colony.csv file --- aeon/dj_pipeline/utils/load_metadata.py | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 59e03e8f..869aaafc 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -17,25 +17,9 @@ logger = dj.logger _weight_scale_rate = 100 _weight_scale_nest = 1 -_colony_csv_path = pathlib.Path("/ceph/aeon/aeon/colony/colony.csv") _aeon_schemas = ["social01"] -def ingest_subject(colony_csv_path: pathlib.Path = _colony_csv_path) -> None: - """Ingest subject information from the colony.csv file.""" - logger.warning("The use of 'colony.csv' is deprecated starting Nov 2023", DeprecationWarning) - - colony_df = pd.read_csv(colony_csv_path, skiprows=[1, 2]) - colony_df.rename(columns={"Id": "subject"}, inplace=True) - colony_df["sex"] = "U" - colony_df["subject_birth_date"] = "2021-01-01" - colony_df["subject_description"] = "" - subject.Subject.insert(colony_df, skip_duplicates=True, ignore_extra_fields=True) - acquisition.Experiment.Subject.insert( - (subject.Subject * acquisition.Experiment).proj(), skip_duplicates=True - ) - - def insert_stream_types(): """Insert into streams.streamType table all streams in the aeon schemas.""" from aeon.schema import schemas as aeon_schemas @@ -162,7 +146,8 @@ def extract_epoch_config(experiment_name: str, metadata_yml_filepath: str) -> di json.dumps(epoch_config["metadata"]["Devices"], default=lambda x: x.__dict__, indent=4) ) - if isinstance(devices, list): # In exp02, it is a list of dict. In presocial. It's a dict of dict. + # Maintain backward compatibility - In exp02, it is a list of dict. From presocial onward, it's a dict of dict. + if isinstance(devices, list): devices: dict = {d.pop("Name"): d for d in devices} # {deivce_name: device_config} return { From cdf2cb543064121a827b5589202db59f9a399726 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 11 Jan 2024 15:19:09 -0600 Subject: [PATCH 375/489] feat(acquisition): specify devices schema for each experiment in a table instead of hard-coding --- aeon/dj_pipeline/acquisition.py | 130 ++++++++++++++++++++++++++------ aeon/schema/schemas.py | 3 + 2 files changed, 111 insertions(+), 22 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index d2cc8162..53b98014 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -10,8 +10,8 @@ from aeon.io import reader as io_reader from aeon.analysis import utils as analysis_utils -from . import get_schema_name, lab, subject -from .utils import paths +from aeon.dj_pipeline import get_schema_name, lab, subject +from aeon.dj_pipeline.utils import paths logger = dj.logger schema = dj.schema(get_schema_name("acquisition")) @@ -24,14 +24,14 @@ "exp0.2-r0": "CameraTop", } -_device_schema_mapping = { - "exp0.1-r0": aeon_schemas.exp01, - "social0-r1": aeon_schemas.exp01, - "exp0.2-r0": aeon_schemas.exp02, - "oct1.0-r0": aeon_schemas.octagon01, - "social0.1-a3": aeon_schemas.social01, - "social0.1-a4": aeon_schemas.social01, -} +# _device_schema_mapping = { +# "exp0.1-r0": aeon_schemas.exp01, +# "social0-r1": aeon_schemas.exp01, +# "exp0.2-r0": aeon_schemas.exp02, +# "oct1.0-r0": aeon_schemas.octagon01, +# "social0.1-a3": aeon_schemas.social01, +# "social0.1-a4": aeon_schemas.social01, +# } # ------------------- Type Lookup ------------------------ @@ -65,6 +65,15 @@ class EventType(dj.Lookup): ] +@schema +class DevicesSchema(dj.Lookup): + definition = """ + devices_schema_name: varchar(32) + """ + + contents = zip(aeon_schemas.__all__) + + # ------------------- Data repository/directory ------------------------ @@ -116,6 +125,13 @@ class Directory(dj.Part): directory_path: varchar(255) """ + class DevicesSchema(dj.Part): + definition = """ + -> master + --- + -> DevicesSchema + """ + @classmethod def get_data_directory(cls, experiment_key, directory_type="raw", as_posix=False): try: @@ -281,8 +297,12 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): Note: "start" and "end" are datetime specified a string in the format: "%Y-%m-%d %H:%M:%S". """ - from .utils import streams_maker - from .utils.load_metadata import extract_epoch_config, ingest_epoch_metadata, insert_device_types + from aeon.dj_pipeline.utils import streams_maker + from aeon.dj_pipeline.utils.load_metadata import ( + extract_epoch_config, + ingest_epoch_metadata, + insert_device_types, + ) device_name = _ref_device_mapping.get(experiment_name, "CameraTop") @@ -352,8 +372,14 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): if metadata_yml_filepath and metadata_yml_filepath.exists(): try: # Insert new entries for streams.DeviceType, streams.Device. + devices_schema = getattr( + aeon_schemas, + (Experiment.DevicesSchema & {"experiment_name": experiment_name}).fetch1( + "devices_schema_name" + ), + ) insert_device_types( - _device_schema_mapping[epoch_key["experiment_name"]], + devices_schema, metadata_yml_filepath, ) # Define and instantiate new devices/stream tables under `streams` schema @@ -535,7 +561,13 @@ def make(self, key): pd.Timestamp(chunk_end), ) else: - device = _device_schema_mapping[key["experiment_name"]].ExperimentalMetadata + devices_schema = getattr( + aeon_schemas, + (Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( + "devices_schema_name" + ), + ) + device = devices_schema.ExperimentalMetadata subject_data = io_api.load( root=raw_data_dir.as_posix(), reader=device.SubjectState, @@ -586,7 +618,13 @@ def make(self, key): pd.Timestamp(chunk_end), ) else: - device = _device_schema_mapping[key["experiment_name"]].ExperimentalMetadata + devices_schema = getattr( + aeon_schemas, + (Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( + "devices_schema_name" + ), + ) + device = devices_schema.ExperimentalMetadata subject_data = io_api.load( root=raw_data_dir.as_posix(), reader=device.SubjectState, @@ -625,7 +663,13 @@ def make(self, key): # Populate the part table raw_data_dir = Experiment.get_data_directory(key) - device = _device_schema_mapping[key["experiment_name"]].ExperimentalMetadata + devices_schema = getattr( + aeon_schemas, + (Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( + "devices_schema_name" + ), + ) + device = devices_schema.ExperimentalMetadata try: # handles corrupted files - issue: https://github.com/SainsburyWellcomeCentre/aeon_mecha/issues/153 @@ -706,7 +750,14 @@ def make(self, key): raw_data_dir = Experiment.get_data_directory(key, directory_type=dir_type) - device = getattr(_device_schema_mapping[key["experiment_name"]], food_patch_description) + devices_schema = getattr( + aeon_schemas, + (Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( + "devices_schema_name" + ), + ) + + device = getattr(devices_schema, food_patch_description) pellet_data = pd.concat( [ @@ -783,7 +834,14 @@ def make(self, key): raw_data_dir = Experiment.get_data_directory(key, directory_type=dir_type) - device = getattr(_device_schema_mapping[key["experiment_name"]], food_patch_description) + devices_schema = getattr( + aeon_schemas, + (Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( + "devices_schema_name" + ), + ) + + device = getattr(devices_schema, food_patch_description) wheel_data = io_api.load( root=raw_data_dir.as_posix(), @@ -807,7 +865,14 @@ def get_wheel_data(cls, experiment_name, start, end, patch_name="Patch1", using_ key = {"experiment_name": experiment_name} raw_data_dir = Experiment.get_data_directory(key) - device = getattr(_device_schema_mapping[key["experiment_name"]], patch_name) + devices_schema = getattr( + aeon_schemas, + (Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( + "devices_schema_name" + ), + ) + + device = getattr(devices_schema, patch_name) wheel_data = io_api.load( root=raw_data_dir.as_posix(), @@ -894,7 +959,14 @@ def make(self, key): food_patch_description = (ExperimentFoodPatch & key).fetch1("food_patch_description") raw_data_dir = Experiment.get_data_directory(key, directory_type=dir_type) - device = getattr(_device_schema_mapping[key["experiment_name"]], food_patch_description) + devices_schema = getattr( + aeon_schemas, + (Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( + "devices_schema_name" + ), + ) + + device = getattr(devices_schema, food_patch_description) wheel_state = io_api.load( root=raw_data_dir.as_posix(), @@ -953,9 +1025,16 @@ def make(self, key): weight_scale_description = (ExperimentWeightScale & key).fetch1("weight_scale_description") + devices_schema = getattr( + aeon_schemas, + (Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( + "devices_schema_name" + ), + ) + # in some epochs/chunks, the food patch device was mapped to "Nest" for device_name in (weight_scale_description, "Nest"): - device = getattr(_device_schema_mapping[key["experiment_name"]], device_name) + device = getattr(devices_schema, device_name) weight_data = io_api.load( root=raw_data_dir.as_posix(), reader=device.WeightRaw, @@ -995,9 +1074,16 @@ def make(self, key): raw_data_dir = Experiment.get_data_directory(key, directory_type=dir_type) weight_scale_description = (ExperimentWeightScale & key).fetch1("weight_scale_description") + devices_schema = getattr( + aeon_schemas, + (Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( + "devices_schema_name" + ), + ) + # in some epochs/chunks, the food patch device was mapped to "Nest" for device_name in (weight_scale_description, "Nest"): - device = getattr(_device_schema_mapping[key["experiment_name"]], device_name) + device = getattr(devices_schema, device_name) weight_filtered = io_api.load( root=raw_data_dir.as_posix(), reader=device.WeightFiltered, diff --git a/aeon/schema/schemas.py b/aeon/schema/schemas.py index 99a5a656..6576a7f3 100644 --- a/aeon/schema/schemas.py +++ b/aeon/schema/schemas.py @@ -88,3 +88,6 @@ Device("EventsPatch3", social.rfid_b), ] ) + + +__all__ = ["exp01", "exp02", "octagon01", "social01"] From 797036e3e16a7a070ec59ac0edf6b56265959af6 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 11 Jan 2024 15:54:51 -0600 Subject: [PATCH 376/489] fix(social): bugfix Rfid binder function --- aeon/schema/schemas.py | 12 ++++++------ aeon/schema/social.py | 9 +++++++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/aeon/schema/schemas.py b/aeon/schema/schemas.py index a4e02ded..2cf8e0e1 100644 --- a/aeon/schema/schemas.py +++ b/aeon/schema/schemas.py @@ -81,11 +81,11 @@ Device("Patch1", social.patch_streams_b), Device("Patch2", social.patch_streams_b), Device("Patch3", social.patch_streams_b), - Device("EventsGate", social.rfid_b), - Device("EventsNest1", social.rfid_b), - Device("EventsNest2", social.rfid_b), - Device("EventsPatch1", social.rfid_b), - Device("EventsPatch2", social.rfid_b), - Device("EventsPatch3", social.rfid_b), + Device("RfidGate", social.rfid_events_b), + Device("RfidNest1", social.rfid_events_b), + Device("RfidNest2", social.rfid_events_b), + Device("RfidPatch1", social.rfid_events_b), + Device("RfidPatch2", social.rfid_events_b), + Device("RfidPatch3", social.rfid_events_b), ] ) diff --git a/aeon/schema/social.py b/aeon/schema/social.py index 25e08b76..324b9708 100644 --- a/aeon/schema/social.py +++ b/aeon/schema/social.py @@ -99,5 +99,10 @@ # Rfid # --- -rfid_names = ["EventsGate", "EventsNest1", "EventsNest2", "EventsPatch1", "EventsPatch2", "EventsPatch3"] -rfid_b = lambda pattern: {"RFID": reader.Harp(f"{pattern}_*", ["rfid"])} + +def rfid_events_b(pattern): + """RFID events reader""" + pattern = pattern.replace("Rfid", "") + if pattern.startswith("Events"): + pattern = pattern.replace("Events", "") + return {"RfidEvents": reader.Harp(f"Rfid{pattern}Events_*", ["rfid"])} From fd5d2e3ae91216a2a10ce4f043ae3a065bb4ef7f Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 11 Jan 2024 18:21:02 -0600 Subject: [PATCH 377/489] fix(acquisition): skip Pose reader (this should be manual) --- aeon/dj_pipeline/acquisition.py | 17 +++++++++-------- aeon/dj_pipeline/utils/load_metadata.py | 11 +++++------ aeon/dj_pipeline/utils/streams_maker.py | 7 +++++++ 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 53b98014..5057a86e 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -304,6 +304,11 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): insert_device_types, ) + devices_schema = getattr( + aeon_schemas, + (Experiment.DevicesSchema & {"experiment_name": experiment_name}).fetch1("devices_schema_name"), + ) + device_name = _ref_device_mapping.get(experiment_name, "CameraTop") all_chunks, raw_data_dirs = _get_all_chunks(experiment_name, device_name) @@ -330,7 +335,9 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): if experiment_name != "exp0.1-r0": metadata_yml_filepath = epoch_dir / "Metadata.yml" if metadata_yml_filepath.exists(): - epoch_config = extract_epoch_config(experiment_name, metadata_yml_filepath) + epoch_config = extract_epoch_config( + experiment_name, devices_schema, metadata_yml_filepath + ) metadata_yml_filepath = epoch_config["metadata_file_path"] @@ -372,12 +379,6 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): if metadata_yml_filepath and metadata_yml_filepath.exists(): try: # Insert new entries for streams.DeviceType, streams.Device. - devices_schema = getattr( - aeon_schemas, - (Experiment.DevicesSchema & {"experiment_name": experiment_name}).fetch1( - "devices_schema_name" - ), - ) insert_device_types( devices_schema, metadata_yml_filepath, @@ -387,7 +388,7 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): with cls.connection.transaction: # Insert devices' installation/removal/settings epoch_device_types = ingest_epoch_metadata( - experiment_name, metadata_yml_filepath + experiment_name, devices_schema, metadata_yml_filepath ) if epoch_device_types is not None: cls.DeviceType.insert( diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 869aaafc..1636820a 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -115,7 +115,7 @@ def insert_device_types(device_schema: DotMap, metadata_yml_filepath: Path): streams.Device.insert(new_devices) -def extract_epoch_config(experiment_name: str, metadata_yml_filepath: str) -> dict: +def extract_epoch_config(experiment_name: str, devices_schema, metadata_yml_filepath: str) -> dict: """Parse experiment metadata YAML file and extract epoch configuration. Args: @@ -130,7 +130,7 @@ def extract_epoch_config(experiment_name: str, metadata_yml_filepath: str) -> di epoch_config: dict = ( io_api.load( str(metadata_yml_filepath.parent), - acquisition._device_schema_mapping[experiment_name].Metadata, + devices_schema.Metadata, ) .reset_index() .to_dict("records")[0] @@ -160,7 +160,7 @@ def extract_epoch_config(experiment_name: str, metadata_yml_filepath: str) -> di } -def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): +def ingest_epoch_metadata(experiment_name, devices_schema, metadata_yml_filepath): """Make entries into device tables.""" streams = dj.VirtualModule("streams", streams_maker.schema_name) @@ -170,7 +170,7 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): experiment_key = {"experiment_name": experiment_name} metadata_yml_filepath = pathlib.Path(metadata_yml_filepath) - epoch_config = extract_epoch_config(experiment_name, metadata_yml_filepath) + epoch_config = extract_epoch_config(experiment_name, devices_schema, metadata_yml_filepath) previous_epoch = (acquisition.Experiment & experiment_key).aggr( acquisition.Epoch & f'epoch_start < "{epoch_config["epoch_start"]}"', @@ -182,8 +182,7 @@ def ingest_epoch_metadata(experiment_name, metadata_yml_filepath): # if identical commit -> no changes return - device_schema = acquisition._device_schema_mapping[experiment_name] - device_type_mapper, _ = get_device_mapper(device_schema, metadata_yml_filepath) + device_type_mapper, _ = get_device_mapper(devices_schema, metadata_yml_filepath) # Insert into each device table epoch_device_types = [] diff --git a/aeon/dj_pipeline/utils/streams_maker.py b/aeon/dj_pipeline/utils/streams_maker.py index 2a952c84..d5963477 100644 --- a/aeon/dj_pipeline/utils/streams_maker.py +++ b/aeon/dj_pipeline/utils/streams_maker.py @@ -108,6 +108,10 @@ def get_device_stream_template(device_type: str, stream_type: str, streams_modul for i, n in enumerate(stream_detail["stream_reader"].split(".")): reader = aeon if i == 0 else getattr(reader, n) + if reader is aeon.io.reader.Pose: + logger.warning("Automatic generation of stream table for Pose reader is not supported. Skipping...") + return None, None + stream = reader(**stream_detail["stream_reader_kwargs"]) table_definition = f""" # Raw per-chunk {stream_type} data stream from {device_type} (auto-generated with aeon_mecha-{aeon.__version__}) @@ -250,6 +254,9 @@ def main(create_tables=True): device_type, stream_type, streams_module=streams ) + if table_class is None: + continue + stream_obj = table_class.__dict__["_stream_reader"] reader = stream_obj.__module__ + "." + stream_obj.__name__ stream_detail = table_class.__dict__["_stream_detail"] From 5cc9cf42652b7ae0aebbb61e3e5bbc04663ba445 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 11 Jan 2024 18:21:18 -0600 Subject: [PATCH 378/489] chore(streams) --- aeon/dj_pipeline/streams.py | 1241 ++++++++++++++++++++--------------- 1 file changed, 710 insertions(+), 531 deletions(-) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index 19b742a0..89579940 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -1,5 +1,5 @@ -#---- DO NOT MODIFY ---- -#---- THIS FILE IS AUTO-GENERATED BY `streams_maker.py` ---- +# ---- DO NOT MODIFY ---- +# ---- THIS FILE IS AUTO-GENERATED BY `streams_maker.py` ---- import re import datajoint as dj @@ -13,7 +13,7 @@ schema = dj.Schema(get_schema_name("streams")) -@schema +@schema class StreamType(dj.Lookup): """Catalog of all steam types for the different device types used across Project Aeon. One StreamType corresponds to one reader class in `aeon.io.reader`. The combination of `stream_reader` and `stream_reader_kwargs` should fully specify the data loading routine for a particular device, using the `aeon.io.utils`.""" @@ -28,7 +28,7 @@ class StreamType(dj.Lookup): """ -@schema +@schema class DeviceType(dj.Lookup): """Catalog of all device types used across Project Aeon.""" @@ -45,7 +45,7 @@ class Stream(dj.Part): """ -@schema +@schema class Device(dj.Lookup): definition = """ # Physical devices, of a particular type, identified by unique serial number device_serial_number: varchar(12) @@ -54,9 +54,9 @@ class Device(dj.Lookup): """ -@schema +@schema class SpinnakerVideoSource(dj.Manual): - definition = f""" + definition = f""" # spinnaker_video_source placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown) -> acquisition.Experiment -> Device @@ -65,25 +65,25 @@ class SpinnakerVideoSource(dj.Manual): spinnaker_video_source_name : varchar(36) """ - class Attribute(dj.Part): - definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device + class Attribute(dj.Part): + definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device -> master attribute_name : varchar(32) --- attribute_value=null : longblob """ - class RemovalTime(dj.Part): - definition = f""" + class RemovalTime(dj.Part): + definition = f""" -> master --- spinnaker_video_source_removal_time: datetime(6) # time of the spinnaker_video_source being removed """ -@schema +@schema class UndergroundFeeder(dj.Manual): - definition = f""" + definition = f""" # underground_feeder placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown) -> acquisition.Experiment -> Device @@ -92,25 +92,25 @@ class UndergroundFeeder(dj.Manual): underground_feeder_name : varchar(36) """ - class Attribute(dj.Part): - definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device + class Attribute(dj.Part): + definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device -> master attribute_name : varchar(32) --- attribute_value=null : longblob """ - class RemovalTime(dj.Part): - definition = f""" + class RemovalTime(dj.Part): + definition = f""" -> master --- underground_feeder_removal_time: datetime(6) # time of the underground_feeder being removed """ -@schema +@schema class WeightScale(dj.Manual): - definition = f""" + definition = f""" # weight_scale placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown) -> acquisition.Experiment -> Device @@ -119,25 +119,25 @@ class WeightScale(dj.Manual): weight_scale_name : varchar(36) """ - class Attribute(dj.Part): - definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device + class Attribute(dj.Part): + definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device -> master attribute_name : varchar(32) --- attribute_value=null : longblob """ - class RemovalTime(dj.Part): - definition = f""" + class RemovalTime(dj.Part): + definition = f""" -> master --- weight_scale_removal_time: datetime(6) # time of the weight_scale being removed """ -@schema +@schema class SpinnakerVideoSourceVideo(dj.Imported): - definition = """ # Raw per-chunk Video data stream from SpinnakerVideoSource (auto-generated with aeon_mecha-unknown) + definition = """ # Raw per-chunk Video data stream from SpinnakerVideoSource (auto-generated with aeon_mecha-unknown) -> SpinnakerVideoSource -> acquisition.Chunk --- @@ -146,62 +146,68 @@ class SpinnakerVideoSourceVideo(dj.Imported): hw_counter: longblob hw_timestamp: longblob """ - _stream_reader = aeon.io.reader.Video - _stream_detail = {'stream_type': 'Video', 'stream_reader': 'aeon.io.reader.Video', 'stream_reader_kwargs': {'pattern': '{pattern}_*'}, 'stream_description': '', 'stream_hash': UUID('f51c6174-e0c4-a888-3a9d-6f97fb6a019b')} - - @property - def key_source(self): - f""" - Only the combination of Chunk and SpinnakerVideoSource with overlapping time - + Chunk(s) that started after SpinnakerVideoSource install time and ended before SpinnakerVideoSource remove time - + Chunk(s) that started after SpinnakerVideoSource install time for SpinnakerVideoSource that are not yet removed - """ - return ( - acquisition.Chunk * SpinnakerVideoSource.join(SpinnakerVideoSource.RemovalTime, left=True) - & 'chunk_start >= spinnaker_video_source_install_time' - & 'chunk_start < IFNULL(spinnaker_video_source_removal_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - - device_name = (SpinnakerVideoSource & key).fetch1('spinnaker_video_source_name') - - stream = self._stream_reader( + _stream_reader = aeon.io.reader.Video + _stream_detail = { + "stream_type": "Video", + "stream_reader": "aeon.io.reader.Video", + "stream_reader_kwargs": {"pattern": "{pattern}_*"}, + "stream_description": "", + "stream_hash": UUID("f51c6174-e0c4-a888-3a9d-6f97fb6a019b"), + } + + @property + def key_source(self): + f""" + Only the combination of Chunk and SpinnakerVideoSource with overlapping time + + Chunk(s) that started after SpinnakerVideoSource install time and ended before SpinnakerVideoSource remove time + + Chunk(s) that started after SpinnakerVideoSource install time for SpinnakerVideoSource that are not yet removed + """ + return ( + acquisition.Chunk * SpinnakerVideoSource.join(SpinnakerVideoSource.RemovalTime, left=True) + & "chunk_start >= spinnaker_video_source_install_time" + & 'chunk_start < IFNULL(spinnaker_video_source_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + + device_name = (SpinnakerVideoSource & key).fetch1("spinnaker_video_source_name") + + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) - - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{ - re.sub(r"\([^)]*\)", "", c): stream_data[c].values - for c in stream.columns - if not c.startswith("_") - }, + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream.columns + if not c.startswith("_") }, - ignore_extra_fields=True, - ) + }, + ignore_extra_fields=True, + ) -@schema +@schema class UndergroundFeederBeamBreak(dj.Imported): - definition = """ # Raw per-chunk BeamBreak data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + definition = """ # Raw per-chunk BeamBreak data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) -> UndergroundFeeder -> acquisition.Chunk --- @@ -209,62 +215,68 @@ class UndergroundFeederBeamBreak(dj.Imported): timestamps: longblob # (datetime) timestamps of BeamBreak data event: longblob """ - _stream_reader = aeon.io.reader.BitmaskEvent - _stream_detail = {'stream_type': 'BeamBreak', 'stream_reader': 'aeon.io.reader.BitmaskEvent', 'stream_reader_kwargs': {'pattern': '{pattern}_32_*', 'value': 34, 'tag': 'PelletDetected'}, 'stream_description': '', 'stream_hash': UUID('ab975afc-c88d-2b66-d22b-65649b0ea5f0')} - - @property - def key_source(self): - f""" - Only the combination of Chunk and UndergroundFeeder with overlapping time - + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time - + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed - """ - return ( - acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) - & 'chunk_start >= underground_feeder_install_time' - & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - - device_name = (UndergroundFeeder & key).fetch1('underground_feeder_name') - - stream = self._stream_reader( + _stream_reader = aeon.io.reader.BitmaskEvent + _stream_detail = { + "stream_type": "BeamBreak", + "stream_reader": "aeon.io.reader.BitmaskEvent", + "stream_reader_kwargs": {"pattern": "{pattern}_32_*", "value": 34, "tag": "PelletDetected"}, + "stream_description": "", + "stream_hash": UUID("ab975afc-c88d-2b66-d22b-65649b0ea5f0"), + } + + @property + def key_source(self): + f""" + Only the combination of Chunk and UndergroundFeeder with overlapping time + + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time + + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed + """ + return ( + acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) + & "chunk_start >= underground_feeder_install_time" + & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + + device_name = (UndergroundFeeder & key).fetch1("underground_feeder_name") + + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) - - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{ - re.sub(r"\([^)]*\)", "", c): stream_data[c].values - for c in stream.columns - if not c.startswith("_") - }, + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream.columns + if not c.startswith("_") }, - ignore_extra_fields=True, - ) + }, + ignore_extra_fields=True, + ) -@schema +@schema class UndergroundFeederDeliverPellet(dj.Imported): - definition = """ # Raw per-chunk DeliverPellet data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + definition = """ # Raw per-chunk DeliverPellet data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) -> UndergroundFeeder -> acquisition.Chunk --- @@ -272,62 +284,68 @@ class UndergroundFeederDeliverPellet(dj.Imported): timestamps: longblob # (datetime) timestamps of DeliverPellet data event: longblob """ - _stream_reader = aeon.io.reader.BitmaskEvent - _stream_detail = {'stream_type': 'DeliverPellet', 'stream_reader': 'aeon.io.reader.BitmaskEvent', 'stream_reader_kwargs': {'pattern': '{pattern}_35_*', 'value': 128, 'tag': 'TriggerPellet'}, 'stream_description': '', 'stream_hash': UUID('09099227-ab3c-1f71-239e-4c6f017de1fd')} - - @property - def key_source(self): - f""" - Only the combination of Chunk and UndergroundFeeder with overlapping time - + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time - + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed - """ - return ( - acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) - & 'chunk_start >= underground_feeder_install_time' - & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - - device_name = (UndergroundFeeder & key).fetch1('underground_feeder_name') - - stream = self._stream_reader( + _stream_reader = aeon.io.reader.BitmaskEvent + _stream_detail = { + "stream_type": "DeliverPellet", + "stream_reader": "aeon.io.reader.BitmaskEvent", + "stream_reader_kwargs": {"pattern": "{pattern}_35_*", "value": 128, "tag": "TriggerPellet"}, + "stream_description": "", + "stream_hash": UUID("09099227-ab3c-1f71-239e-4c6f017de1fd"), + } + + @property + def key_source(self): + f""" + Only the combination of Chunk and UndergroundFeeder with overlapping time + + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time + + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed + """ + return ( + acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) + & "chunk_start >= underground_feeder_install_time" + & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + + device_name = (UndergroundFeeder & key).fetch1("underground_feeder_name") + + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) - - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{ - re.sub(r"\([^)]*\)", "", c): stream_data[c].values - for c in stream.columns - if not c.startswith("_") - }, + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream.columns + if not c.startswith("_") }, - ignore_extra_fields=True, - ) + }, + ignore_extra_fields=True, + ) -@schema +@schema class UndergroundFeederDepletionState(dj.Imported): - definition = """ # Raw per-chunk DepletionState data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + definition = """ # Raw per-chunk DepletionState data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) -> UndergroundFeeder -> acquisition.Chunk --- @@ -337,62 +355,73 @@ class UndergroundFeederDepletionState(dj.Imported): offset: longblob rate: longblob """ - _stream_reader = aeon.io.reader.Csv - _stream_detail = {'stream_type': 'DepletionState', 'stream_reader': 'aeon.io.reader.Csv', 'stream_reader_kwargs': {'pattern': '{pattern}_*', 'columns': ['threshold', 'offset', 'rate'], 'extension': 'csv', 'dtype': None}, 'stream_description': '', 'stream_hash': UUID('a944b719-c723-08f8-b695-7be616e57bd5')} - - @property - def key_source(self): - f""" - Only the combination of Chunk and UndergroundFeeder with overlapping time - + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time - + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed - """ - return ( - acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) - & 'chunk_start >= underground_feeder_install_time' - & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - - device_name = (UndergroundFeeder & key).fetch1('underground_feeder_name') - - stream = self._stream_reader( + _stream_reader = aeon.io.reader.Csv + _stream_detail = { + "stream_type": "DepletionState", + "stream_reader": "aeon.io.reader.Csv", + "stream_reader_kwargs": { + "pattern": "{pattern}_*", + "columns": ["threshold", "offset", "rate"], + "extension": "csv", + "dtype": None, + }, + "stream_description": "", + "stream_hash": UUID("a944b719-c723-08f8-b695-7be616e57bd5"), + } + + @property + def key_source(self): + f""" + Only the combination of Chunk and UndergroundFeeder with overlapping time + + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time + + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed + """ + return ( + acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) + & "chunk_start >= underground_feeder_install_time" + & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + + device_name = (UndergroundFeeder & key).fetch1("underground_feeder_name") + + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) - - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{ - re.sub(r"\([^)]*\)", "", c): stream_data[c].values - for c in stream.columns - if not c.startswith("_") - }, + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream.columns + if not c.startswith("_") }, - ignore_extra_fields=True, - ) + }, + ignore_extra_fields=True, + ) -@schema +@schema class UndergroundFeederEncoder(dj.Imported): - definition = """ # Raw per-chunk Encoder data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + definition = """ # Raw per-chunk Encoder data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) -> UndergroundFeeder -> acquisition.Chunk --- @@ -401,62 +430,68 @@ class UndergroundFeederEncoder(dj.Imported): angle: longblob intensity: longblob """ - _stream_reader = aeon.io.reader.Encoder - _stream_detail = {'stream_type': 'Encoder', 'stream_reader': 'aeon.io.reader.Encoder', 'stream_reader_kwargs': {'pattern': '{pattern}_90_*'}, 'stream_description': '', 'stream_hash': UUID('f96b0b26-26f6-5ff6-b3c7-5aa5adc00c1a')} - - @property - def key_source(self): - f""" - Only the combination of Chunk and UndergroundFeeder with overlapping time - + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time - + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed - """ - return ( - acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) - & 'chunk_start >= underground_feeder_install_time' - & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - - device_name = (UndergroundFeeder & key).fetch1('underground_feeder_name') - - stream = self._stream_reader( + _stream_reader = aeon.io.reader.Encoder + _stream_detail = { + "stream_type": "Encoder", + "stream_reader": "aeon.io.reader.Encoder", + "stream_reader_kwargs": {"pattern": "{pattern}_90_*"}, + "stream_description": "", + "stream_hash": UUID("f96b0b26-26f6-5ff6-b3c7-5aa5adc00c1a"), + } + + @property + def key_source(self): + f""" + Only the combination of Chunk and UndergroundFeeder with overlapping time + + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time + + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed + """ + return ( + acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) + & "chunk_start >= underground_feeder_install_time" + & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + + device_name = (UndergroundFeeder & key).fetch1("underground_feeder_name") + + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) - - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{ - re.sub(r"\([^)]*\)", "", c): stream_data[c].values - for c in stream.columns - if not c.startswith("_") - }, + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream.columns + if not c.startswith("_") }, - ignore_extra_fields=True, - ) + }, + ignore_extra_fields=True, + ) -@schema +@schema class UndergroundFeederManualDelivery(dj.Imported): - definition = """ # Raw per-chunk ManualDelivery data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + definition = """ # Raw per-chunk ManualDelivery data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) -> UndergroundFeeder -> acquisition.Chunk --- @@ -464,62 +499,72 @@ class UndergroundFeederManualDelivery(dj.Imported): timestamps: longblob # (datetime) timestamps of ManualDelivery data manual_delivery: longblob """ - _stream_reader = aeon.io.reader.Harp - _stream_detail = {'stream_type': 'ManualDelivery', 'stream_reader': 'aeon.io.reader.Harp', 'stream_reader_kwargs': {'pattern': '{pattern}_*', 'columns': ['manual_delivery'], 'extension': 'bin'}, 'stream_description': '', 'stream_hash': UUID('98ce23d4-01c5-a848-dd6b-8b284c323fb0')} - - @property - def key_source(self): - f""" - Only the combination of Chunk and UndergroundFeeder with overlapping time - + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time - + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed - """ - return ( - acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) - & 'chunk_start >= underground_feeder_install_time' - & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - - device_name = (UndergroundFeeder & key).fetch1('underground_feeder_name') - - stream = self._stream_reader( + _stream_reader = aeon.io.reader.Harp + _stream_detail = { + "stream_type": "ManualDelivery", + "stream_reader": "aeon.io.reader.Harp", + "stream_reader_kwargs": { + "pattern": "{pattern}_*", + "columns": ["manual_delivery"], + "extension": "bin", + }, + "stream_description": "", + "stream_hash": UUID("98ce23d4-01c5-a848-dd6b-8b284c323fb0"), + } + + @property + def key_source(self): + f""" + Only the combination of Chunk and UndergroundFeeder with overlapping time + + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time + + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed + """ + return ( + acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) + & "chunk_start >= underground_feeder_install_time" + & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + + device_name = (UndergroundFeeder & key).fetch1("underground_feeder_name") + + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) - - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{ - re.sub(r"\([^)]*\)", "", c): stream_data[c].values - for c in stream.columns - if not c.startswith("_") - }, + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream.columns + if not c.startswith("_") }, - ignore_extra_fields=True, - ) + }, + ignore_extra_fields=True, + ) -@schema +@schema class UndergroundFeederMissedPellet(dj.Imported): - definition = """ # Raw per-chunk MissedPellet data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + definition = """ # Raw per-chunk MissedPellet data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) -> UndergroundFeeder -> acquisition.Chunk --- @@ -527,62 +572,72 @@ class UndergroundFeederMissedPellet(dj.Imported): timestamps: longblob # (datetime) timestamps of MissedPellet data missed_pellet: longblob """ - _stream_reader = aeon.io.reader.Harp - _stream_detail = {'stream_type': 'MissedPellet', 'stream_reader': 'aeon.io.reader.Harp', 'stream_reader_kwargs': {'pattern': '{pattern}_*', 'columns': ['missed_pellet'], 'extension': 'bin'}, 'stream_description': '', 'stream_hash': UUID('2fa12bbc-3207-dddc-f6ee-b79c55b6d9a2')} - - @property - def key_source(self): - f""" - Only the combination of Chunk and UndergroundFeeder with overlapping time - + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time - + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed - """ - return ( - acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) - & 'chunk_start >= underground_feeder_install_time' - & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - - device_name = (UndergroundFeeder & key).fetch1('underground_feeder_name') - - stream = self._stream_reader( + _stream_reader = aeon.io.reader.Harp + _stream_detail = { + "stream_type": "MissedPellet", + "stream_reader": "aeon.io.reader.Harp", + "stream_reader_kwargs": { + "pattern": "{pattern}_*", + "columns": ["missed_pellet"], + "extension": "bin", + }, + "stream_description": "", + "stream_hash": UUID("2fa12bbc-3207-dddc-f6ee-b79c55b6d9a2"), + } + + @property + def key_source(self): + f""" + Only the combination of Chunk and UndergroundFeeder with overlapping time + + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time + + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed + """ + return ( + acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) + & "chunk_start >= underground_feeder_install_time" + & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + + device_name = (UndergroundFeeder & key).fetch1("underground_feeder_name") + + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) - - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{ - re.sub(r"\([^)]*\)", "", c): stream_data[c].values - for c in stream.columns - if not c.startswith("_") - }, + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream.columns + if not c.startswith("_") }, - ignore_extra_fields=True, - ) + }, + ignore_extra_fields=True, + ) -@schema +@schema class UndergroundFeederRetriedDelivery(dj.Imported): - definition = """ # Raw per-chunk RetriedDelivery data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + definition = """ # Raw per-chunk RetriedDelivery data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) -> UndergroundFeeder -> acquisition.Chunk --- @@ -590,62 +645,72 @@ class UndergroundFeederRetriedDelivery(dj.Imported): timestamps: longblob # (datetime) timestamps of RetriedDelivery data retried_delivery: longblob """ - _stream_reader = aeon.io.reader.Harp - _stream_detail = {'stream_type': 'RetriedDelivery', 'stream_reader': 'aeon.io.reader.Harp', 'stream_reader_kwargs': {'pattern': '{pattern}_*', 'columns': ['retried_delivery'], 'extension': 'bin'}, 'stream_description': '', 'stream_hash': UUID('62f23eab-4469-5740-dfa0-6f1aa754de8e')} - - @property - def key_source(self): - f""" - Only the combination of Chunk and UndergroundFeeder with overlapping time - + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time - + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed - """ - return ( - acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) - & 'chunk_start >= underground_feeder_install_time' - & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - - device_name = (UndergroundFeeder & key).fetch1('underground_feeder_name') - - stream = self._stream_reader( + _stream_reader = aeon.io.reader.Harp + _stream_detail = { + "stream_type": "RetriedDelivery", + "stream_reader": "aeon.io.reader.Harp", + "stream_reader_kwargs": { + "pattern": "{pattern}_*", + "columns": ["retried_delivery"], + "extension": "bin", + }, + "stream_description": "", + "stream_hash": UUID("62f23eab-4469-5740-dfa0-6f1aa754de8e"), + } + + @property + def key_source(self): + f""" + Only the combination of Chunk and UndergroundFeeder with overlapping time + + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time + + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed + """ + return ( + acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) + & "chunk_start >= underground_feeder_install_time" + & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + + device_name = (UndergroundFeeder & key).fetch1("underground_feeder_name") + + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) - - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{ - re.sub(r"\([^)]*\)", "", c): stream_data[c].values - for c in stream.columns - if not c.startswith("_") - }, + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream.columns + if not c.startswith("_") }, - ignore_extra_fields=True, - ) + }, + ignore_extra_fields=True, + ) -@schema +@schema class WeightScaleWeightFiltered(dj.Imported): - definition = """ # Raw per-chunk WeightFiltered data stream from WeightScale (auto-generated with aeon_mecha-unknown) + definition = """ # Raw per-chunk WeightFiltered data stream from WeightScale (auto-generated with aeon_mecha-unknown) -> WeightScale -> acquisition.Chunk --- @@ -654,62 +719,72 @@ class WeightScaleWeightFiltered(dj.Imported): weight: longblob stability: longblob """ - _stream_reader = aeon.io.reader.Harp - _stream_detail = {'stream_type': 'WeightFiltered', 'stream_reader': 'aeon.io.reader.Harp', 'stream_reader_kwargs': {'pattern': '{pattern}_202*', 'columns': ['weight(g)', 'stability'], 'extension': 'bin'}, 'stream_description': '', 'stream_hash': UUID('bd135a97-1161-3dd3-5ca3-e5d342485728')} - - @property - def key_source(self): - f""" - Only the combination of Chunk and WeightScale with overlapping time - + Chunk(s) that started after WeightScale install time and ended before WeightScale remove time - + Chunk(s) that started after WeightScale install time for WeightScale that are not yet removed - """ - return ( - acquisition.Chunk * WeightScale.join(WeightScale.RemovalTime, left=True) - & 'chunk_start >= weight_scale_install_time' - & 'chunk_start < IFNULL(weight_scale_removal_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - - device_name = (WeightScale & key).fetch1('weight_scale_name') - - stream = self._stream_reader( + _stream_reader = aeon.io.reader.Harp + _stream_detail = { + "stream_type": "WeightFiltered", + "stream_reader": "aeon.io.reader.Harp", + "stream_reader_kwargs": { + "pattern": "{pattern}_202_*", + "columns": ["weight(g)", "stability"], + "extension": "bin", + }, + "stream_description": "", + "stream_hash": UUID("64ee6b2f-508a-c9c9-edcb-ffdfd6b58dbe"), + } + + @property + def key_source(self): + f""" + Only the combination of Chunk and WeightScale with overlapping time + + Chunk(s) that started after WeightScale install time and ended before WeightScale remove time + + Chunk(s) that started after WeightScale install time for WeightScale that are not yet removed + """ + return ( + acquisition.Chunk * WeightScale.join(WeightScale.RemovalTime, left=True) + & "chunk_start >= weight_scale_install_time" + & 'chunk_start < IFNULL(weight_scale_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + + device_name = (WeightScale & key).fetch1("weight_scale_name") + + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) - - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{ - re.sub(r"\([^)]*\)", "", c): stream_data[c].values - for c in stream.columns - if not c.startswith("_") - }, + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream.columns + if not c.startswith("_") }, - ignore_extra_fields=True, - ) + }, + ignore_extra_fields=True, + ) -@schema +@schema class WeightScaleWeightRaw(dj.Imported): - definition = """ # Raw per-chunk WeightRaw data stream from WeightScale (auto-generated with aeon_mecha-unknown) + definition = """ # Raw per-chunk WeightRaw data stream from WeightScale (auto-generated with aeon_mecha-unknown) -> WeightScale -> acquisition.Chunk --- @@ -718,56 +793,160 @@ class WeightScaleWeightRaw(dj.Imported): weight: longblob stability: longblob """ - _stream_reader = aeon.io.reader.Harp - _stream_detail = {'stream_type': 'WeightRaw', 'stream_reader': 'aeon.io.reader.Harp', 'stream_reader_kwargs': {'pattern': '{pattern}_200*', 'columns': ['weight(g)', 'stability'], 'extension': 'bin'}, 'stream_description': '', 'stream_hash': UUID('0d27b1af-e78b-d889-62c0-41a20df6a015')} - - @property - def key_source(self): - f""" - Only the combination of Chunk and WeightScale with overlapping time - + Chunk(s) that started after WeightScale install time and ended before WeightScale remove time - + Chunk(s) that started after WeightScale install time for WeightScale that are not yet removed + _stream_reader = aeon.io.reader.Harp + _stream_detail = { + "stream_type": "WeightRaw", + "stream_reader": "aeon.io.reader.Harp", + "stream_reader_kwargs": { + "pattern": "{pattern}_200_*", + "columns": ["weight(g)", "stability"], + "extension": "bin", + }, + "stream_description": "", + "stream_hash": UUID("7068a927-818f-54f5-48c8-fd06295caa5a"), + } + + @property + def key_source(self): + f""" + Only the combination of Chunk and WeightScale with overlapping time + + Chunk(s) that started after WeightScale install time and ended before WeightScale remove time + + Chunk(s) that started after WeightScale install time for WeightScale that are not yet removed + """ + return ( + acquisition.Chunk * WeightScale.join(WeightScale.RemovalTime, left=True) + & "chunk_start >= weight_scale_install_time" + & 'chunk_start < IFNULL(weight_scale_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + + device_name = (WeightScale & key).fetch1("weight_scale_name") + + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{ + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream.columns + if not c.startswith("_") + }, + }, + ignore_extra_fields=True, + ) + + +@schema +class RfidReader(dj.Manual): + definition = f""" + # rfid_reader placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown) + -> acquisition.Experiment + -> Device + rfid_reader_install_time : datetime(6) # time of the rfid_reader placed and started operation at this position + --- + rfid_reader_name : varchar(36) + """ + + class Attribute(dj.Part): + definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device + -> master + attribute_name : varchar(32) + --- + attribute_value=null : longblob """ - return ( - acquisition.Chunk * WeightScale.join(WeightScale.RemovalTime, left=True) - & 'chunk_start >= weight_scale_install_time' - & 'chunk_start < IFNULL(weight_scale_removal_time, "2200-01-01")' - ) - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + class RemovalTime(dj.Part): + definition = f""" + -> master + --- + rfid_reader_removal_time: datetime(6) # time of the rfid_reader being removed + """ - device_name = (WeightScale & key).fetch1('weight_scale_name') - stream = self._stream_reader( +@schema +class RfidReaderRfidEvents(dj.Imported): + definition = """ # Raw per-chunk RfidEvents data stream from RfidReader (auto-generated with aeon_mecha-unknown) + -> RfidReader + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of RfidEvents data + rfid: longblob + """ + _stream_reader = aeon.io.reader.Harp + _stream_detail = { + "stream_type": "RfidEvents", + "stream_reader": "aeon.io.reader.Harp", + "stream_reader_kwargs": {"pattern": "{pattern}Events_*", "columns": ["rfid"], "extension": "bin"}, + "stream_description": "", + "stream_hash": UUID("1e02e76a-6340-1748-4704-e1b11288b7d3"), + } + + @property + def key_source(self): + f""" + Only the combination of Chunk and RfidReader with overlapping time + + Chunk(s) that started after RfidReader install time and ended before RfidReader remove time + + Chunk(s) that started after RfidReader install time for RfidReader that are not yet removed + """ + return ( + acquisition.Chunk * RfidReader.join(RfidReader.RemovalTime, left=True) + & "chunk_start >= rfid_reader_install_time" + & 'chunk_start < IFNULL(rfid_reader_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + + device_name = (RfidReader & key).fetch1("rfid_reader_name") + + stream = self._stream_reader( + **{ + k: v.format(**{k: device_name}) if k == "pattern" else v + for k, v in self._stream_detail["stream_reader_kwargs"].items() + } + ) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) - - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{ - re.sub(r"\([^)]*\)", "", c): stream_data[c].values - for c in stream.columns - if not c.startswith("_") - }, + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream.columns + if not c.startswith("_") }, - ignore_extra_fields=True, - ) - - + }, + ignore_extra_fields=True, + ) From 522ead35888c5f4fc1b8f41249b0ec79f65c58e8 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 11 Jan 2024 18:21:40 -0600 Subject: [PATCH 379/489] fix(social): typo --- aeon/schema/social.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/schema/social.py b/aeon/schema/social.py index 324b9708..6587757f 100644 --- a/aeon/schema/social.py +++ b/aeon/schema/social.py @@ -50,7 +50,7 @@ # SubjectVisits subject_visits_b = lambda pattern: { - "SubjectVisits": reader.Csv("Environment_SubjectVisit_s*", ["id", "type", "region"]) + "SubjectVisits": reader.Csv("Environment_SubjectVisit_*", ["id", "type", "region"]) } # SubjectWeight From 05abd46ade18a7bcabc6dfd2adf8f178ec78bf2e Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 12 Jan 2024 14:38:18 -0600 Subject: [PATCH 380/489] fix(social): bugfix setting up RFID binder function --- aeon/schema/social.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/schema/social.py b/aeon/schema/social.py index 6587757f..f2bd4f30 100644 --- a/aeon/schema/social.py +++ b/aeon/schema/social.py @@ -105,4 +105,4 @@ def rfid_events_b(pattern): pattern = pattern.replace("Rfid", "") if pattern.startswith("Events"): pattern = pattern.replace("Events", "") - return {"RfidEvents": reader.Harp(f"Rfid{pattern}Events_*", ["rfid"])} + return {"RfidEvents": reader.Harp(f"RfidEvents{pattern}_*", ["rfid"])} From 30f8c060c99c0531ab04c206cef13bf761834392 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 12 Jan 2024 16:34:12 -0600 Subject: [PATCH 381/489] fix(schemas): remove duplicated device in social01 --- aeon/schema/schemas.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/aeon/schema/schemas.py b/aeon/schema/schemas.py index 2cf8e0e1..1e909bbf 100644 --- a/aeon/schema/schemas.py +++ b/aeon/schema/schemas.py @@ -60,14 +60,7 @@ social01 = DotMap( [ Device("Metadata", core.metadata), - Device( - "ExperimentalMetadata", - core.environment, - social.block_state_b, - social.light_events_b, - core.message_log, - ), - Device("Environment", social.environment_b, social.subject_b), + Device("ExperimentalMetadata", social.environment_b, social.subject_b), Device("CameraTop", core.video, social.camera_top_pos_b), Device("CameraNorth", core.video), Device("CameraSouth", core.video), From c6f1ab843e837b2d18a7130213475ae79bdc53f4 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 12 Jan 2024 17:58:55 -0600 Subject: [PATCH 382/489] feat(streams_maker): simplify, improve robustness --- aeon/dj_pipeline/streams.py | 1343 +++++++++++------------ aeon/dj_pipeline/utils/load_metadata.py | 9 +- aeon/dj_pipeline/utils/streams_maker.py | 36 +- 3 files changed, 639 insertions(+), 749 deletions(-) diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index 89579940..49558b03 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -1,5 +1,5 @@ -# ---- DO NOT MODIFY ---- -# ---- THIS FILE IS AUTO-GENERATED BY `streams_maker.py` ---- +#---- DO NOT MODIFY ---- +#---- THIS FILE IS AUTO-GENERATED BY `streams_maker.py` ---- import re import datajoint as dj @@ -9,11 +9,12 @@ import aeon from aeon.dj_pipeline import acquisition, get_schema_name from aeon.io import api as io_api +from aeon.schema import schemas as aeon_schemas schema = dj.Schema(get_schema_name("streams")) -@schema +@schema class StreamType(dj.Lookup): """Catalog of all steam types for the different device types used across Project Aeon. One StreamType corresponds to one reader class in `aeon.io.reader`. The combination of `stream_reader` and `stream_reader_kwargs` should fully specify the data loading routine for a particular device, using the `aeon.io.utils`.""" @@ -28,7 +29,7 @@ class StreamType(dj.Lookup): """ -@schema +@schema class DeviceType(dj.Lookup): """Catalog of all device types used across Project Aeon.""" @@ -45,7 +46,7 @@ class Stream(dj.Part): """ -@schema +@schema class Device(dj.Lookup): definition = """ # Physical devices, of a particular type, identified by unique serial number device_serial_number: varchar(12) @@ -54,9 +55,36 @@ class Device(dj.Lookup): """ -@schema +@schema +class RfidReader(dj.Manual): + definition = f""" + # rfid_reader placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown) + -> acquisition.Experiment + -> Device + rfid_reader_install_time : datetime(6) # time of the rfid_reader placed and started operation at this position + --- + rfid_reader_name : varchar(36) + """ + + class Attribute(dj.Part): + definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device + -> master + attribute_name : varchar(32) + --- + attribute_value=null : longblob + """ + + class RemovalTime(dj.Part): + definition = f""" + -> master + --- + rfid_reader_removal_time: datetime(6) # time of the rfid_reader being removed + """ + + +@schema class SpinnakerVideoSource(dj.Manual): - definition = f""" + definition = f""" # spinnaker_video_source placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown) -> acquisition.Experiment -> Device @@ -65,25 +93,25 @@ class SpinnakerVideoSource(dj.Manual): spinnaker_video_source_name : varchar(36) """ - class Attribute(dj.Part): - definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device + class Attribute(dj.Part): + definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device -> master attribute_name : varchar(32) --- attribute_value=null : longblob """ - class RemovalTime(dj.Part): - definition = f""" + class RemovalTime(dj.Part): + definition = f""" -> master --- spinnaker_video_source_removal_time: datetime(6) # time of the spinnaker_video_source being removed """ -@schema +@schema class UndergroundFeeder(dj.Manual): - definition = f""" + definition = f""" # underground_feeder placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown) -> acquisition.Experiment -> Device @@ -92,25 +120,25 @@ class UndergroundFeeder(dj.Manual): underground_feeder_name : varchar(36) """ - class Attribute(dj.Part): - definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device + class Attribute(dj.Part): + definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device -> master attribute_name : varchar(32) --- attribute_value=null : longblob """ - class RemovalTime(dj.Part): - definition = f""" + class RemovalTime(dj.Part): + definition = f""" -> master --- underground_feeder_removal_time: datetime(6) # time of the underground_feeder being removed """ -@schema +@schema class WeightScale(dj.Manual): - definition = f""" + definition = f""" # weight_scale placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown) -> acquisition.Experiment -> Device @@ -119,25 +147,87 @@ class WeightScale(dj.Manual): weight_scale_name : varchar(36) """ - class Attribute(dj.Part): - definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device + class Attribute(dj.Part): + definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device -> master attribute_name : varchar(32) --- attribute_value=null : longblob """ - class RemovalTime(dj.Part): - definition = f""" + class RemovalTime(dj.Part): + definition = f""" -> master --- weight_scale_removal_time: datetime(6) # time of the weight_scale being removed """ -@schema +@schema +class RfidReaderRfidEvents(dj.Imported): + definition = """ # Raw per-chunk RfidEvents data stream from RfidReader (auto-generated with aeon_mecha-unknown) + -> RfidReader + -> acquisition.Chunk + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps of RfidEvents data + rfid: longblob + """ + + @property + def key_source(self): + f""" + Only the combination of Chunk and RfidReader with overlapping time + + Chunk(s) that started after RfidReader install time and ended before RfidReader remove time + + Chunk(s) that started after RfidReader install time for RfidReader that are not yet removed + """ + return ( + acquisition.Chunk * RfidReader.join(RfidReader.RemovalTime, left=True) + & 'chunk_start >= rfid_reader_install_time' + & 'chunk_start < IFNULL(rfid_reader_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + + device_name = (RfidReader & key).fetch1('rfid_reader_name') + + devices_schema = getattr( + aeon_schemas, + (acquisition.Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( + "devices_schema_name" + ), + ) + stream_reader = getattr(getattr(devices_schema, device_name), "RfidEvents") + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream_reader, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{ + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream_reader.columns + if not c.startswith("_") + }, + }, + ignore_extra_fields=True, + ) + + +@schema class SpinnakerVideoSourceVideo(dj.Imported): - definition = """ # Raw per-chunk Video data stream from SpinnakerVideoSource (auto-generated with aeon_mecha-unknown) + definition = """ # Raw per-chunk Video data stream from SpinnakerVideoSource (auto-generated with aeon_mecha-unknown) -> SpinnakerVideoSource -> acquisition.Chunk --- @@ -146,68 +236,61 @@ class SpinnakerVideoSourceVideo(dj.Imported): hw_counter: longblob hw_timestamp: longblob """ - _stream_reader = aeon.io.reader.Video - _stream_detail = { - "stream_type": "Video", - "stream_reader": "aeon.io.reader.Video", - "stream_reader_kwargs": {"pattern": "{pattern}_*"}, - "stream_description": "", - "stream_hash": UUID("f51c6174-e0c4-a888-3a9d-6f97fb6a019b"), - } - - @property - def key_source(self): - f""" - Only the combination of Chunk and SpinnakerVideoSource with overlapping time - + Chunk(s) that started after SpinnakerVideoSource install time and ended before SpinnakerVideoSource remove time - + Chunk(s) that started after SpinnakerVideoSource install time for SpinnakerVideoSource that are not yet removed - """ - return ( - acquisition.Chunk * SpinnakerVideoSource.join(SpinnakerVideoSource.RemovalTime, left=True) - & "chunk_start >= spinnaker_video_source_install_time" - & 'chunk_start < IFNULL(spinnaker_video_source_removal_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - - device_name = (SpinnakerVideoSource & key).fetch1("spinnaker_video_source_name") - - stream = self._stream_reader( - **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) - - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{ - re.sub(r"\([^)]*\)", "", c): stream_data[c].values - for c in stream.columns - if not c.startswith("_") + + @property + def key_source(self): + f""" + Only the combination of Chunk and SpinnakerVideoSource with overlapping time + + Chunk(s) that started after SpinnakerVideoSource install time and ended before SpinnakerVideoSource remove time + + Chunk(s) that started after SpinnakerVideoSource install time for SpinnakerVideoSource that are not yet removed + """ + return ( + acquisition.Chunk * SpinnakerVideoSource.join(SpinnakerVideoSource.RemovalTime, left=True) + & 'chunk_start >= spinnaker_video_source_install_time' + & 'chunk_start < IFNULL(spinnaker_video_source_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + + device_name = (SpinnakerVideoSource & key).fetch1('spinnaker_video_source_name') + + devices_schema = getattr( + aeon_schemas, + (acquisition.Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( + "devices_schema_name" + ), + ) + stream_reader = getattr(getattr(devices_schema, device_name), "Video") + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream_reader, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{ + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream_reader.columns + if not c.startswith("_") + }, }, - }, - ignore_extra_fields=True, - ) + ignore_extra_fields=True, + ) -@schema +@schema class UndergroundFeederBeamBreak(dj.Imported): - definition = """ # Raw per-chunk BeamBreak data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + definition = """ # Raw per-chunk BeamBreak data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) -> UndergroundFeeder -> acquisition.Chunk --- @@ -215,68 +298,61 @@ class UndergroundFeederBeamBreak(dj.Imported): timestamps: longblob # (datetime) timestamps of BeamBreak data event: longblob """ - _stream_reader = aeon.io.reader.BitmaskEvent - _stream_detail = { - "stream_type": "BeamBreak", - "stream_reader": "aeon.io.reader.BitmaskEvent", - "stream_reader_kwargs": {"pattern": "{pattern}_32_*", "value": 34, "tag": "PelletDetected"}, - "stream_description": "", - "stream_hash": UUID("ab975afc-c88d-2b66-d22b-65649b0ea5f0"), - } - - @property - def key_source(self): - f""" - Only the combination of Chunk and UndergroundFeeder with overlapping time - + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time - + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed - """ - return ( - acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) - & "chunk_start >= underground_feeder_install_time" - & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - - device_name = (UndergroundFeeder & key).fetch1("underground_feeder_name") - - stream = self._stream_reader( - **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) - - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{ - re.sub(r"\([^)]*\)", "", c): stream_data[c].values - for c in stream.columns - if not c.startswith("_") + + @property + def key_source(self): + f""" + Only the combination of Chunk and UndergroundFeeder with overlapping time + + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time + + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed + """ + return ( + acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) + & 'chunk_start >= underground_feeder_install_time' + & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + + device_name = (UndergroundFeeder & key).fetch1('underground_feeder_name') + + devices_schema = getattr( + aeon_schemas, + (acquisition.Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( + "devices_schema_name" + ), + ) + stream_reader = getattr(getattr(devices_schema, device_name), "BeamBreak") + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream_reader, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{ + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream_reader.columns + if not c.startswith("_") + }, }, - }, - ignore_extra_fields=True, - ) + ignore_extra_fields=True, + ) -@schema +@schema class UndergroundFeederDeliverPellet(dj.Imported): - definition = """ # Raw per-chunk DeliverPellet data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + definition = """ # Raw per-chunk DeliverPellet data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) -> UndergroundFeeder -> acquisition.Chunk --- @@ -284,68 +360,61 @@ class UndergroundFeederDeliverPellet(dj.Imported): timestamps: longblob # (datetime) timestamps of DeliverPellet data event: longblob """ - _stream_reader = aeon.io.reader.BitmaskEvent - _stream_detail = { - "stream_type": "DeliverPellet", - "stream_reader": "aeon.io.reader.BitmaskEvent", - "stream_reader_kwargs": {"pattern": "{pattern}_35_*", "value": 128, "tag": "TriggerPellet"}, - "stream_description": "", - "stream_hash": UUID("09099227-ab3c-1f71-239e-4c6f017de1fd"), - } - - @property - def key_source(self): - f""" - Only the combination of Chunk and UndergroundFeeder with overlapping time - + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time - + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed - """ - return ( - acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) - & "chunk_start >= underground_feeder_install_time" - & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - - device_name = (UndergroundFeeder & key).fetch1("underground_feeder_name") - - stream = self._stream_reader( - **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) - - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{ - re.sub(r"\([^)]*\)", "", c): stream_data[c].values - for c in stream.columns - if not c.startswith("_") + + @property + def key_source(self): + f""" + Only the combination of Chunk and UndergroundFeeder with overlapping time + + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time + + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed + """ + return ( + acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) + & 'chunk_start >= underground_feeder_install_time' + & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + + device_name = (UndergroundFeeder & key).fetch1('underground_feeder_name') + + devices_schema = getattr( + aeon_schemas, + (acquisition.Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( + "devices_schema_name" + ), + ) + stream_reader = getattr(getattr(devices_schema, device_name), "DeliverPellet") + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream_reader, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{ + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream_reader.columns + if not c.startswith("_") + }, }, - }, - ignore_extra_fields=True, - ) + ignore_extra_fields=True, + ) -@schema +@schema class UndergroundFeederDepletionState(dj.Imported): - definition = """ # Raw per-chunk DepletionState data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + definition = """ # Raw per-chunk DepletionState data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) -> UndergroundFeeder -> acquisition.Chunk --- @@ -355,73 +424,61 @@ class UndergroundFeederDepletionState(dj.Imported): offset: longblob rate: longblob """ - _stream_reader = aeon.io.reader.Csv - _stream_detail = { - "stream_type": "DepletionState", - "stream_reader": "aeon.io.reader.Csv", - "stream_reader_kwargs": { - "pattern": "{pattern}_*", - "columns": ["threshold", "offset", "rate"], - "extension": "csv", - "dtype": None, - }, - "stream_description": "", - "stream_hash": UUID("a944b719-c723-08f8-b695-7be616e57bd5"), - } - - @property - def key_source(self): - f""" - Only the combination of Chunk and UndergroundFeeder with overlapping time - + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time - + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed - """ - return ( - acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) - & "chunk_start >= underground_feeder_install_time" - & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - - device_name = (UndergroundFeeder & key).fetch1("underground_feeder_name") - - stream = self._stream_reader( - **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) - - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{ - re.sub(r"\([^)]*\)", "", c): stream_data[c].values - for c in stream.columns - if not c.startswith("_") + + @property + def key_source(self): + f""" + Only the combination of Chunk and UndergroundFeeder with overlapping time + + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time + + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed + """ + return ( + acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) + & 'chunk_start >= underground_feeder_install_time' + & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + + device_name = (UndergroundFeeder & key).fetch1('underground_feeder_name') + + devices_schema = getattr( + aeon_schemas, + (acquisition.Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( + "devices_schema_name" + ), + ) + stream_reader = getattr(getattr(devices_schema, device_name), "DepletionState") + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream_reader, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{ + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream_reader.columns + if not c.startswith("_") + }, }, - }, - ignore_extra_fields=True, - ) + ignore_extra_fields=True, + ) -@schema +@schema class UndergroundFeederEncoder(dj.Imported): - definition = """ # Raw per-chunk Encoder data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + definition = """ # Raw per-chunk Encoder data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) -> UndergroundFeeder -> acquisition.Chunk --- @@ -430,68 +487,61 @@ class UndergroundFeederEncoder(dj.Imported): angle: longblob intensity: longblob """ - _stream_reader = aeon.io.reader.Encoder - _stream_detail = { - "stream_type": "Encoder", - "stream_reader": "aeon.io.reader.Encoder", - "stream_reader_kwargs": {"pattern": "{pattern}_90_*"}, - "stream_description": "", - "stream_hash": UUID("f96b0b26-26f6-5ff6-b3c7-5aa5adc00c1a"), - } - - @property - def key_source(self): - f""" - Only the combination of Chunk and UndergroundFeeder with overlapping time - + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time - + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed - """ - return ( - acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) - & "chunk_start >= underground_feeder_install_time" - & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - - device_name = (UndergroundFeeder & key).fetch1("underground_feeder_name") - - stream = self._stream_reader( - **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) - - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{ - re.sub(r"\([^)]*\)", "", c): stream_data[c].values - for c in stream.columns - if not c.startswith("_") + + @property + def key_source(self): + f""" + Only the combination of Chunk and UndergroundFeeder with overlapping time + + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time + + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed + """ + return ( + acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) + & 'chunk_start >= underground_feeder_install_time' + & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + + device_name = (UndergroundFeeder & key).fetch1('underground_feeder_name') + + devices_schema = getattr( + aeon_schemas, + (acquisition.Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( + "devices_schema_name" + ), + ) + stream_reader = getattr(getattr(devices_schema, device_name), "Encoder") + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream_reader, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{ + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream_reader.columns + if not c.startswith("_") + }, }, - }, - ignore_extra_fields=True, - ) + ignore_extra_fields=True, + ) -@schema +@schema class UndergroundFeederManualDelivery(dj.Imported): - definition = """ # Raw per-chunk ManualDelivery data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + definition = """ # Raw per-chunk ManualDelivery data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) -> UndergroundFeeder -> acquisition.Chunk --- @@ -499,72 +549,61 @@ class UndergroundFeederManualDelivery(dj.Imported): timestamps: longblob # (datetime) timestamps of ManualDelivery data manual_delivery: longblob """ - _stream_reader = aeon.io.reader.Harp - _stream_detail = { - "stream_type": "ManualDelivery", - "stream_reader": "aeon.io.reader.Harp", - "stream_reader_kwargs": { - "pattern": "{pattern}_*", - "columns": ["manual_delivery"], - "extension": "bin", - }, - "stream_description": "", - "stream_hash": UUID("98ce23d4-01c5-a848-dd6b-8b284c323fb0"), - } - - @property - def key_source(self): - f""" - Only the combination of Chunk and UndergroundFeeder with overlapping time - + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time - + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed - """ - return ( - acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) - & "chunk_start >= underground_feeder_install_time" - & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - - device_name = (UndergroundFeeder & key).fetch1("underground_feeder_name") - - stream = self._stream_reader( - **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) - - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{ - re.sub(r"\([^)]*\)", "", c): stream_data[c].values - for c in stream.columns - if not c.startswith("_") + + @property + def key_source(self): + f""" + Only the combination of Chunk and UndergroundFeeder with overlapping time + + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time + + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed + """ + return ( + acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) + & 'chunk_start >= underground_feeder_install_time' + & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + + device_name = (UndergroundFeeder & key).fetch1('underground_feeder_name') + + devices_schema = getattr( + aeon_schemas, + (acquisition.Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( + "devices_schema_name" + ), + ) + stream_reader = getattr(getattr(devices_schema, device_name), "ManualDelivery") + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream_reader, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{ + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream_reader.columns + if not c.startswith("_") + }, }, - }, - ignore_extra_fields=True, - ) + ignore_extra_fields=True, + ) -@schema +@schema class UndergroundFeederMissedPellet(dj.Imported): - definition = """ # Raw per-chunk MissedPellet data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + definition = """ # Raw per-chunk MissedPellet data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) -> UndergroundFeeder -> acquisition.Chunk --- @@ -572,72 +611,61 @@ class UndergroundFeederMissedPellet(dj.Imported): timestamps: longblob # (datetime) timestamps of MissedPellet data missed_pellet: longblob """ - _stream_reader = aeon.io.reader.Harp - _stream_detail = { - "stream_type": "MissedPellet", - "stream_reader": "aeon.io.reader.Harp", - "stream_reader_kwargs": { - "pattern": "{pattern}_*", - "columns": ["missed_pellet"], - "extension": "bin", - }, - "stream_description": "", - "stream_hash": UUID("2fa12bbc-3207-dddc-f6ee-b79c55b6d9a2"), - } - - @property - def key_source(self): - f""" - Only the combination of Chunk and UndergroundFeeder with overlapping time - + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time - + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed - """ - return ( - acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) - & "chunk_start >= underground_feeder_install_time" - & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - - device_name = (UndergroundFeeder & key).fetch1("underground_feeder_name") - - stream = self._stream_reader( - **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) - - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{ - re.sub(r"\([^)]*\)", "", c): stream_data[c].values - for c in stream.columns - if not c.startswith("_") + + @property + def key_source(self): + f""" + Only the combination of Chunk and UndergroundFeeder with overlapping time + + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time + + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed + """ + return ( + acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) + & 'chunk_start >= underground_feeder_install_time' + & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + + device_name = (UndergroundFeeder & key).fetch1('underground_feeder_name') + + devices_schema = getattr( + aeon_schemas, + (acquisition.Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( + "devices_schema_name" + ), + ) + stream_reader = getattr(getattr(devices_schema, device_name), "MissedPellet") + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream_reader, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{ + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream_reader.columns + if not c.startswith("_") + }, }, - }, - ignore_extra_fields=True, - ) + ignore_extra_fields=True, + ) -@schema +@schema class UndergroundFeederRetriedDelivery(dj.Imported): - definition = """ # Raw per-chunk RetriedDelivery data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) + definition = """ # Raw per-chunk RetriedDelivery data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) -> UndergroundFeeder -> acquisition.Chunk --- @@ -645,72 +673,61 @@ class UndergroundFeederRetriedDelivery(dj.Imported): timestamps: longblob # (datetime) timestamps of RetriedDelivery data retried_delivery: longblob """ - _stream_reader = aeon.io.reader.Harp - _stream_detail = { - "stream_type": "RetriedDelivery", - "stream_reader": "aeon.io.reader.Harp", - "stream_reader_kwargs": { - "pattern": "{pattern}_*", - "columns": ["retried_delivery"], - "extension": "bin", - }, - "stream_description": "", - "stream_hash": UUID("62f23eab-4469-5740-dfa0-6f1aa754de8e"), - } - - @property - def key_source(self): - f""" - Only the combination of Chunk and UndergroundFeeder with overlapping time - + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time - + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed - """ - return ( - acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) - & "chunk_start >= underground_feeder_install_time" - & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - - device_name = (UndergroundFeeder & key).fetch1("underground_feeder_name") - - stream = self._stream_reader( - **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) - - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{ - re.sub(r"\([^)]*\)", "", c): stream_data[c].values - for c in stream.columns - if not c.startswith("_") + + @property + def key_source(self): + f""" + Only the combination of Chunk and UndergroundFeeder with overlapping time + + Chunk(s) that started after UndergroundFeeder install time and ended before UndergroundFeeder remove time + + Chunk(s) that started after UndergroundFeeder install time for UndergroundFeeder that are not yet removed + """ + return ( + acquisition.Chunk * UndergroundFeeder.join(UndergroundFeeder.RemovalTime, left=True) + & 'chunk_start >= underground_feeder_install_time' + & 'chunk_start < IFNULL(underground_feeder_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + + device_name = (UndergroundFeeder & key).fetch1('underground_feeder_name') + + devices_schema = getattr( + aeon_schemas, + (acquisition.Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( + "devices_schema_name" + ), + ) + stream_reader = getattr(getattr(devices_schema, device_name), "RetriedDelivery") + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream_reader, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{ + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream_reader.columns + if not c.startswith("_") + }, }, - }, - ignore_extra_fields=True, - ) + ignore_extra_fields=True, + ) -@schema +@schema class WeightScaleWeightFiltered(dj.Imported): - definition = """ # Raw per-chunk WeightFiltered data stream from WeightScale (auto-generated with aeon_mecha-unknown) + definition = """ # Raw per-chunk WeightFiltered data stream from WeightScale (auto-generated with aeon_mecha-unknown) -> WeightScale -> acquisition.Chunk --- @@ -719,72 +736,61 @@ class WeightScaleWeightFiltered(dj.Imported): weight: longblob stability: longblob """ - _stream_reader = aeon.io.reader.Harp - _stream_detail = { - "stream_type": "WeightFiltered", - "stream_reader": "aeon.io.reader.Harp", - "stream_reader_kwargs": { - "pattern": "{pattern}_202_*", - "columns": ["weight(g)", "stability"], - "extension": "bin", - }, - "stream_description": "", - "stream_hash": UUID("64ee6b2f-508a-c9c9-edcb-ffdfd6b58dbe"), - } - - @property - def key_source(self): - f""" - Only the combination of Chunk and WeightScale with overlapping time - + Chunk(s) that started after WeightScale install time and ended before WeightScale remove time - + Chunk(s) that started after WeightScale install time for WeightScale that are not yet removed - """ - return ( - acquisition.Chunk * WeightScale.join(WeightScale.RemovalTime, left=True) - & "chunk_start >= weight_scale_install_time" - & 'chunk_start < IFNULL(weight_scale_removal_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - - device_name = (WeightScale & key).fetch1("weight_scale_name") - - stream = self._stream_reader( - **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) - - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{ - re.sub(r"\([^)]*\)", "", c): stream_data[c].values - for c in stream.columns - if not c.startswith("_") + + @property + def key_source(self): + f""" + Only the combination of Chunk and WeightScale with overlapping time + + Chunk(s) that started after WeightScale install time and ended before WeightScale remove time + + Chunk(s) that started after WeightScale install time for WeightScale that are not yet removed + """ + return ( + acquisition.Chunk * WeightScale.join(WeightScale.RemovalTime, left=True) + & 'chunk_start >= weight_scale_install_time' + & 'chunk_start < IFNULL(weight_scale_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + + device_name = (WeightScale & key).fetch1('weight_scale_name') + + devices_schema = getattr( + aeon_schemas, + (acquisition.Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( + "devices_schema_name" + ), + ) + stream_reader = getattr(getattr(devices_schema, device_name), "WeightFiltered") + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream_reader, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{ + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream_reader.columns + if not c.startswith("_") + }, }, - }, - ignore_extra_fields=True, - ) + ignore_extra_fields=True, + ) -@schema +@schema class WeightScaleWeightRaw(dj.Imported): - definition = """ # Raw per-chunk WeightRaw data stream from WeightScale (auto-generated with aeon_mecha-unknown) + definition = """ # Raw per-chunk WeightRaw data stream from WeightScale (auto-generated with aeon_mecha-unknown) -> WeightScale -> acquisition.Chunk --- @@ -793,160 +799,55 @@ class WeightScaleWeightRaw(dj.Imported): weight: longblob stability: longblob """ - _stream_reader = aeon.io.reader.Harp - _stream_detail = { - "stream_type": "WeightRaw", - "stream_reader": "aeon.io.reader.Harp", - "stream_reader_kwargs": { - "pattern": "{pattern}_200_*", - "columns": ["weight(g)", "stability"], - "extension": "bin", - }, - "stream_description": "", - "stream_hash": UUID("7068a927-818f-54f5-48c8-fd06295caa5a"), - } - - @property - def key_source(self): - f""" - Only the combination of Chunk and WeightScale with overlapping time - + Chunk(s) that started after WeightScale install time and ended before WeightScale remove time - + Chunk(s) that started after WeightScale install time for WeightScale that are not yet removed - """ - return ( - acquisition.Chunk * WeightScale.join(WeightScale.RemovalTime, left=True) - & "chunk_start >= weight_scale_install_time" - & 'chunk_start < IFNULL(weight_scale_removal_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - - device_name = (WeightScale & key).fetch1("weight_scale_name") - - stream = self._stream_reader( - **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) - - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{ - re.sub(r"\([^)]*\)", "", c): stream_data[c].values - for c in stream.columns - if not c.startswith("_") - }, - }, - ignore_extra_fields=True, - ) - - -@schema -class RfidReader(dj.Manual): - definition = f""" - # rfid_reader placement and operation for a particular time period, at a certain location, for a given experiment (auto-generated with aeon_mecha-unknown) - -> acquisition.Experiment - -> Device - rfid_reader_install_time : datetime(6) # time of the rfid_reader placed and started operation at this position - --- - rfid_reader_name : varchar(36) - """ - class Attribute(dj.Part): - definition = """ # metadata/attributes (e.g. FPS, config, calibration, etc.) associated with this experimental device - -> master - attribute_name : varchar(32) - --- - attribute_value=null : longblob - """ - - class RemovalTime(dj.Part): - definition = f""" - -> master - --- - rfid_reader_removal_time: datetime(6) # time of the rfid_reader being removed + @property + def key_source(self): + f""" + Only the combination of Chunk and WeightScale with overlapping time + + Chunk(s) that started after WeightScale install time and ended before WeightScale remove time + + Chunk(s) that started after WeightScale install time for WeightScale that are not yet removed """ + return ( + acquisition.Chunk * WeightScale.join(WeightScale.RemovalTime, left=True) + & 'chunk_start >= weight_scale_install_time' + & 'chunk_start < IFNULL(weight_scale_removal_time, "2200-01-01")' + ) + + def make(self, key): + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" + ) + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + + device_name = (WeightScale & key).fetch1('weight_scale_name') + + devices_schema = getattr( + aeon_schemas, + (acquisition.Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( + "devices_schema_name" + ), + ) + stream_reader = getattr(getattr(devices_schema, device_name), "WeightRaw") + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream_reader, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + self.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{ + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream_reader.columns + if not c.startswith("_") + }, + }, + ignore_extra_fields=True, + ) -@schema -class RfidReaderRfidEvents(dj.Imported): - definition = """ # Raw per-chunk RfidEvents data stream from RfidReader (auto-generated with aeon_mecha-unknown) - -> RfidReader - -> acquisition.Chunk - --- - sample_count: int # number of data points acquired from this stream for a given chunk - timestamps: longblob # (datetime) timestamps of RfidEvents data - rfid: longblob - """ - _stream_reader = aeon.io.reader.Harp - _stream_detail = { - "stream_type": "RfidEvents", - "stream_reader": "aeon.io.reader.Harp", - "stream_reader_kwargs": {"pattern": "{pattern}Events_*", "columns": ["rfid"], "extension": "bin"}, - "stream_description": "", - "stream_hash": UUID("1e02e76a-6340-1748-4704-e1b11288b7d3"), - } - - @property - def key_source(self): - f""" - Only the combination of Chunk and RfidReader with overlapping time - + Chunk(s) that started after RfidReader install time and ended before RfidReader remove time - + Chunk(s) that started after RfidReader install time for RfidReader that are not yet removed - """ - return ( - acquisition.Chunk * RfidReader.join(RfidReader.RemovalTime, left=True) - & "chunk_start >= rfid_reader_install_time" - & 'chunk_start < IFNULL(rfid_reader_removal_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - - device_name = (RfidReader & key).fetch1("rfid_reader_name") - - stream = self._stream_reader( - **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } - ) - - stream_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=stream, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "sample_count": len(stream_data), - "timestamps": stream_data.index.values, - **{ - re.sub(r"\([^)]*\)", "", c): stream_data[c].values - for c in stream.columns - if not c.startswith("_") - }, - }, - ignore_extra_fields=True, - ) diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 1636820a..24289f0f 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -34,12 +34,14 @@ def insert_stream_types(): q_param = streams.StreamType & {"stream_hash": entry["stream_hash"]} if q_param: # If the specified stream type already exists pname = q_param.fetch1("stream_type") - if pname != entry["stream_type"]: + if pname == entry["stream_type"]: + continue + else: # If the existed stream type does not have the same name: # human error, trying to add the same content with different name raise dj.DataJointError(f"The specified stream type already exists - name: {pname}") - - streams.StreamType.insert(stream_entries, skip_duplicates=True) + else: + streams.StreamType.insert1(entry) def insert_device_types(device_schema: DotMap, metadata_yml_filepath: Path): @@ -356,6 +358,7 @@ def _get_class_path(obj): "aeon.io.reader", "aeon.schema.foraging", "aeon.schema.octagon", + "aeon.schema.social", ]: device_info[device_name]["stream_type"].append(stream_type) device_info[device_name]["stream_reader"].append(_get_class_path(stream_obj)) diff --git a/aeon/dj_pipeline/utils/streams_maker.py b/aeon/dj_pipeline/utils/streams_maker.py index d5963477..d333387e 100644 --- a/aeon/dj_pipeline/utils/streams_maker.py +++ b/aeon/dj_pipeline/utils/streams_maker.py @@ -9,6 +9,7 @@ import aeon from aeon.dj_pipeline import acquisition, get_schema_name from aeon.io import api as io_api +from aeon.schema import schemas as aeon_schemas logger = dj.logger @@ -130,8 +131,6 @@ def get_device_stream_template(device_type: str, stream_type: str, streams_modul class DeviceDataStream(dj.Imported): definition = table_definition - _stream_reader = reader - _stream_detail = stream_detail @property def key_source(self): @@ -154,16 +153,17 @@ def make(self, key): device_name = (ExperimentDevice & key).fetch1(f"{dj.utils.from_camel_case(device_type)}_name") - stream = self._stream_reader( - **{ - k: v.format(**{k: device_name}) if k == "pattern" else v - for k, v in self._stream_detail["stream_reader_kwargs"].items() - } + devices_schema = getattr( + aeon_schemas, + (acquisition.Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( + "devices_schema_name" + ), ) + stream_reader = getattr(getattr(devices_schema, device_name), "{stream_type}") stream_data = io_api.load( root=raw_data_dir.as_posix(), - reader=stream, + reader=stream_reader, start=pd.Timestamp(chunk_start), end=pd.Timestamp(chunk_end), ) @@ -175,7 +175,7 @@ def make(self, key): "timestamps": stream_data.index.values, **{ re.sub(r"\([^)]*\)", "", c): stream_data[c].values - for c in stream.columns + for c in stream_reader.columns if not c.startswith("_") }, }, @@ -202,7 +202,8 @@ def main(create_tables=True): "from uuid import UUID\n\n" "import aeon\n" "from aeon.dj_pipeline import acquisition, get_schema_name\n" - "from aeon.io import api as io_api\n\n" + "from aeon.io import api as io_api\n" + "from aeon.schema import schemas as aeon_schemas\n\n" 'schema = dj.Schema(get_schema_name("streams"))\n\n\n' ) f.write(imports_str) @@ -257,10 +258,6 @@ def main(create_tables=True): if table_class is None: continue - stream_obj = table_class.__dict__["_stream_reader"] - reader = stream_obj.__module__ + "." + stream_obj.__name__ - stream_detail = table_class.__dict__["_stream_detail"] - device_stream_table_def = inspect.getsource(table_class).lstrip() # Replace the definition @@ -280,17 +277,6 @@ def main(create_tables=True): for old, new in replacements.items(): device_stream_table_def = device_stream_table_def.replace(old, new) - device_stream_table_def = re.sub( - r"_stream_reader\s*=\s*reader", - f"_stream_reader = {reader}", - device_stream_table_def, - ) # insert reader - device_stream_table_def = re.sub( - r"_stream_detail\s*=\s*stream_detail", - f"_stream_detail = {stream_detail}", - device_stream_table_def, - ) # insert stream details - full_def = "@schema \n" + device_stream_table_def + "\n\n" with open(_STREAMS_MODULE_FILE) as f: From 5d21b6ae10b3c3a97791a55388e4f144112f0c41 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 17 Jan 2024 10:01:26 -0600 Subject: [PATCH 383/489] added SLEAPTracking table --- aeon/dj_pipeline/tracking.py | 177 +++++++++++++++++++++++++---------- 1 file changed, 125 insertions(+), 52 deletions(-) diff --git a/aeon/dj_pipeline/tracking.py b/aeon/dj_pipeline/tracking.py index cf399805..0e3b4ec2 100644 --- a/aeon/dj_pipeline/tracking.py +++ b/aeon/dj_pipeline/tracking.py @@ -7,6 +7,7 @@ from aeon.dj_pipeline import acquisition, dict_to_uuid, get_schema_name, lab, qc, streams from aeon.io import api as io_api +from aeon.schema import schemas as aeon_schemas schema = dj.schema(get_schema_name("tracking")) @@ -226,88 +227,160 @@ def get_object_position( @schema -class VideoSourceTracking(dj.Imported): +class SLEAPTracking(dj.Imported): definition = """ # Tracked objects position data from a particular VideoSource for multi-animal experiment using the SLEAP tracking method per chunk -> acquisition.Chunk - -> streams.VideoSource + -> streams.SpinnakerVideoSource -> TrackingParamSet + --- + sample_count: int # number of data points acquired from this stream for a given chunk """ - class Point(dj.Part): + class PoseIdentity(dj.Part): definition = """ -> master - point_name: varchar(16) + identity_idx: smallint --- - point_x: longblob - point_y: longblob - point_likelihood: longblob + identity_name: varchar(16) + identity_likelihood: longblob + anchor_part: varchar(16) # the name of the point used as anchor node for this class """ - class Pose(dj.Part): + class Part(dj.Part): definition = """ - -> master - pose_name: varchar(16) - class: smallint + -> master.PoseIdentity + part_name: varchar(16) --- - class_likelihood: longblob - centroid_x: longblob - centroid_y: longblob - centroid_likelihood: longblob - pose_timestamps: longblob - point_collection=null: varchar(1000) # List of point names - """ - - class PointCollection(dj.Part): - definition = """ - -> master.Pose - -> master.Point + x: longblob + y: longblob + likelihood: longblob + timestamps: longblob """ @property def key_source(self): return ( - (acquisition.Chunk & "experiment_name='multianimal'") - * (streams.VideoSourcePosition & (streams.VideoSource & "video_source_name='CameraTop'")) + acquisition.Chunk + * ( + streams.SpinnakerVideoSource.join(streams.SpinnakerVideoSource.RemovalTime, left=True) + & "spinnaker_video_source_name='CameraTop'" + ) * (TrackingParamSet & "tracking_paramset_id = 1") + & "chunk_start >= spinnaker_video_source_install_time" + & 'chunk_start < IFNULL(spinnaker_video_source_removal_time, "2200-01-01")' ) # SLEAP & CameraTop def make(self, key): - from aeon.schema.social import Pose - - # chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - # "chunk_start", "chunk_end", "directory_type" - # ) - # raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - # This needs to be modified later - sleap_reader = Pose( - pattern="", - columns=["class", "class_confidence", "centroid_x", "centroid_y", "centroid_confidence"], + chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( + "chunk_start", "chunk_end", "directory_type" ) - tracking_file_path = "/ceph/aeon/aeon/data/processed/test-node1/1234567/2023-08-10T18-31-00/macentroid/test-node1_1234567_2023-08-10T18-31-00_macentroid.bin" # temp file path for testing + raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - tracking_df = sleap_reader.read(Path(tracking_file_path)) + device_name = (streams.SpinnakerVideoSource & key).fetch1("spinnaker_video_source_name") - pose_list = [] - for part_name in ["body"]: - for class_id in tracking_df["class"].unique(): - class_df = tracking_df[tracking_df["class"] == class_id] + devices_schema = getattr( + aeon_schemas, + (acquisition.Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( + "devices_schema_name" + ), + ) + stream_reader = getattr(getattr(devices_schema, device_name), "Pose") - pose_list.append( + pose_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream_reader, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + if not len(pose_data): + self.insert1({**key, "sample_count": 0}) + return + + # Find the config file for the SLEAP model + try: + f = next( + raw_data_dir.glob( + f"**/**/{stream_reader.pattern}{io_api.chunk(chunk_start).strftime('%Y-%m-%dT%H-%M-%S')}*.{stream_reader.extension}" + ) + ) + except StopIteration: + raise FileNotFoundError(f"Unable to find HARP bin file for {key}") + else: + config_file = stream_reader.get_config_file( + stream_reader._model_root / Path(*Path(f.stem.replace("_", "/")).parent.parts[1:]) + ) + + # get bodyparts and classes + bodyparts = stream_reader.get_bodyparts(config_file) + anchor_part = bodyparts[0] # anchor_part is always the first one + class_names = stream_reader.get_class_names(config_file) + + # ingest parts and classes + sample_count = 0 + class_entries, part_entries = [], [] + for class_idx in set(pose_data["class"].values.astype(int)): + class_position = pose_data[pose_data["class"] == class_idx] + for part in set(class_position.part.values): + part_position = class_position[class_position.part == part] + part_entries.append( { **key, - "pose_name": part_name, - "class": class_id, - "class_likelihood": class_df["class_likelihood"].values, - "centroid_x": class_df["x"].values, - "centroid_y": class_df["y"].values, - "centroid_likelihood": class_df["part_likelihood"].values, - "pose_timestamps": class_df.index.values, - "point_collection": "", + "identity_idx": class_idx, + "part_name": part, + "timestamps": part_position.index.values, + "x": part_position.x.values, + "y": part_position.y.values, + "likelihood": part_position.part_likelihood.values, } ) + if part == anchor_part: + class_likelihood = part_position.class_likelihood.values + sample_count = len(part_position.index.values) + class_entries.append( + { + **key, + "identity_idx": class_idx, + "identity_name": class_names[class_idx], + "anchor_part": anchor_part, + "identity_likelihood": class_likelihood, + } + ) - self.insert1(key) - self.Pose.insert(pose_list) + self.insert1({**key, "sample_count": sample_count}) + self.Class.insert(class_entries) + self.Part.insert(part_entries) + + @classmethod + def get_object_position( + cls, + experiment_name, + subject_name, + start, + end, + camera_name="CameraTop", + tracking_paramset_id=1, + in_meter=False, + ): + table = ( + cls.Class.proj(part_name="anchor_part") * cls.Part * acquisition.Chunk.proj("chunk_end") + & {"experiment_name": experiment_name} + & {"tracking_paramset_id": tracking_paramset_id} + & (streams.SpinnakerVideoSource & {"spinnaker_video_source_name": camera_name}) + ) + + return _get_position( + table, + object_attr="class_name", + object_name=subject_name, + start_attr="chunk_start", + end_attr="chunk_end", + start=start, + end=end, + fetch_attrs=["timestamps", "x", "y", "likelihood"], + attrs_to_scale=["position_x", "position_y"], + scale_factor=pixel_scale if in_meter else 1, + ) # ---------- HELPER ------------------ From 7f0ca95e5c249243f8c64ed8e55ff5df30e77699 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 17 Jan 2024 10:02:20 -0600 Subject: [PATCH 384/489] feat(reader): update Pose reader --- aeon/io/reader.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/aeon/io/reader.py b/aeon/io/reader.py index 9d940135..44aece5c 100644 --- a/aeon/io/reader.py +++ b/aeon/io/reader.py @@ -273,18 +273,17 @@ class (int): Int ID of a subject in the environment. y (float): Y-coordinate of the bodypart. """ - def __init__(self, pattern: str, extension: str = "bin"): + def __init__(self, pattern: str, model_root: str = "/ceph/aeon/aeon/data/processed"): """Pose reader constructor.""" # `pattern` for this reader should typically be '_*' - super().__init__(pattern, columns=None, extension=extension) + super().__init__(pattern, columns=None) + self._model_root = Path(model_root) - def read( - self, file: Path, ceph_proc_dir: str | Path = "/ceph/aeon/aeon/data/processed" - ) -> pd.DataFrame: + def read(self, file: Path) -> pd.DataFrame: """Reads data from the Harp-binarized tracking file.""" # Get config file from `file`, then bodyparts from config file. model_dir = Path(*Path(file.stem.replace("_", "/")).parent.parts[1:]) - config_file_dir = ceph_proc_dir / model_dir + config_file_dir = self._model_root / model_dir if not config_file_dir.exists(): raise FileNotFoundError(f"Cannot find model dir {config_file_dir}") config_file = self.get_config_file(config_file_dir) @@ -321,6 +320,20 @@ def read( new_data = pd.concat(part_data_list) return new_data.sort_index() + def get_class_names(self, file: Path) -> list[str]: + """Returns a list of classes from a model's config file.""" + classes = None + with open(file) as f: + config = json.load(f) + if file.stem == "confmap_config": # SLEAP + try: + heads = config["model"]["heads"] + classes = util.find_nested_key(heads, "class_vectors")["classes"] + except KeyError as err: + if not classes: + raise KeyError(f"Cannot find class_vectors in {file}.") from err + return classes + def get_bodyparts(self, file: Path) -> list[str]: """Returns a list of bodyparts from a model's config file.""" parts = [] From f259232d8460d4bc9f5594251bfb48a0804ddf58 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 19 Jan 2024 12:18:35 -0600 Subject: [PATCH 385/489] fix(social): bugfix for "Environment" readers --- aeon/schema/schemas.py | 2 +- aeon/schema/social.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/aeon/schema/schemas.py b/aeon/schema/schemas.py index 1e909bbf..113322b2 100644 --- a/aeon/schema/schemas.py +++ b/aeon/schema/schemas.py @@ -60,7 +60,7 @@ social01 = DotMap( [ Device("Metadata", core.metadata), - Device("ExperimentalMetadata", social.environment_b, social.subject_b), + Device("Environment", social.environment_b, social.subject_b), Device("CameraTop", core.video, social.camera_top_pos_b), Device("CameraNorth", core.video), Device("CameraSouth", core.video), diff --git a/aeon/schema/social.py b/aeon/schema/social.py index f2bd4f30..f9e7691d 100644 --- a/aeon/schema/social.py +++ b/aeon/schema/social.py @@ -35,7 +35,7 @@ # LightEvents light_events_b = lambda pattern: { - "LightEvents": reader.Csv("Environment_LightEvents_*", ["channel", "value"]) + "LightEvents": reader.Csv(f"{pattern}_LightEvents_*", ["channel", "value"]) } # Combine EnvironmentState, BlockState, LightEvents @@ -45,18 +45,18 @@ # SubjectState subject_state_b = lambda pattern: { - "SubjectState": reader.Csv("Environment_SubjectState_*", ["id", "weight", "type"]) + "SubjectState": reader.Csv(f"{pattern}_SubjectState_*", ["id", "weight", "type"]) } # SubjectVisits subject_visits_b = lambda pattern: { - "SubjectVisits": reader.Csv("Environment_SubjectVisit_*", ["id", "type", "region"]) + "SubjectVisits": reader.Csv(f"{pattern}_SubjectVisit_*", ["id", "type", "region"]) } # SubjectWeight subject_weight_b = lambda pattern: { "SubjectWeight": reader.Csv( - "Environment_SubjectWeight_*", ["weight", "confidence", "subject_id", "int_id"] + f"{pattern}_SubjectWeight_*", ["weight", "confidence", "subject_id", "int_id"] ) } From e55fcf838a6cfba94a89a0638edd816569fb8225 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Mon, 22 Jan 2024 18:50:17 -0600 Subject: [PATCH 386/489] feat(acquisition): add `Environment` table to store data from Environment's streams --- aeon/dj_pipeline/__init__.py | 17 ++++ aeon/dj_pipeline/acquisition.py | 148 +++++++++++++++++++++++++++++++- 2 files changed, 161 insertions(+), 4 deletions(-) diff --git a/aeon/dj_pipeline/__init__.py b/aeon/dj_pipeline/__init__.py index 6a9c64b6..b319f55b 100644 --- a/aeon/dj_pipeline/__init__.py +++ b/aeon/dj_pipeline/__init__.py @@ -30,6 +30,23 @@ def dict_to_uuid(key) -> uuid.UUID: return uuid.UUID(hex=hashed.hexdigest()) +def fetch_stream(query, drop_pk=True): + """ + Provided a query containing data from a Stream table, + fetch and aggregate the data into one DataFrame indexed by "time" + """ + df = (query & "sample_count > 0").fetch(format="frame").reset_index() + cols2explode = [ + c for c in query.heading.secondary_attributes if query.heading.attributes[c].type == "longblob" + ] + df = df.explode(column=cols2explode) + cols2drop = ["sample_count"] + (query.primary_key if drop_pk else []) + df.drop(columns=cols2drop, inplace=True, errors="ignore") + df.rename(columns={"timestamps": "time"}, inplace=True) + df.set_index("time", inplace=True) + return df + + try: from . import streams except ImportError: diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 5057a86e..840c5396 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -1,6 +1,6 @@ import datetime import pathlib - +import re import datajoint as dj import numpy as np import pandas as pd @@ -670,7 +670,7 @@ def make(self, key): "devices_schema_name" ), ) - device = devices_schema.ExperimentalMetadata + device = devices_schema.Environment try: # handles corrupted files - issue: https://github.com/SainsburyWellcomeCentre/aeon_mecha/issues/153 @@ -684,12 +684,18 @@ def make(self, key): logger.warning("Can't read from device.MessageLog") log_messages = pd.DataFrame() - state_messages = io_api.load( + env_states = io_api.load( root=raw_data_dir.as_posix(), reader=device.EnvironmentState, start=pd.Timestamp(chunk_start), end=pd.Timestamp(chunk_end), ) + block_states = io_api.load( + root=raw_data_dir.as_posix(), + reader=device.BlockState, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) self.insert1(key) self.Message.insert( @@ -712,13 +718,147 @@ def make(self, key): "message": r.state, "message_type": "EnvironmentState", } - for _, r in state_messages.iterrows() + for _, r in env_states.iterrows() ), skip_duplicates=True, ) +# ------------------- ENVIRONMENT -------------------- + + +@schema +class Environment(dj.Imported): + definition = """ # Experiment environments + -> Chunk + """ + + class EnvironmentState(dj.Part): + definition = """ + -> master + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps + state: longblob + """ + + class BlockState(dj.Part): + definition = """ + -> master + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps + pellet_ct: longblob + pellet_ct_thresh: longblob + due_time: longblob + """ + + class LightEvents(dj.Part): + definition = """ + -> master + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps + channel: longblob + value: longblob + """ + + class MessageLog(dj.Part): + definition = """ + -> master + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) + priority: longblob + type: longblob + message: longblob + """ + + class SubjectState(dj.Part): + definition = """ + -> master + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps + id: longblob + weight: longblob + type: longblob + """ + + class SubjectVisits(dj.Part): + definition = """ + -> master + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps + id: longblob + type: longblob + region: longblob + """ + + class SubjectWeight(dj.Part): + definition = """ + -> master + --- + sample_count: int # number of data points acquired from this stream for a given chunk + timestamps: longblob # (datetime) timestamps + weight: longblob + confidence: longblob + subject_id: longblob + int_id: longblob + """ + + def make(self, key): + chunk_start, chunk_end = (Chunk & key).fetch1("chunk_start", "chunk_end") + + # Populate the part table + raw_data_dir = Experiment.get_data_directory(key) + devices_schema = getattr( + aeon_schemas, + (Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( + "devices_schema_name" + ), + ) + device = devices_schema.Environment + + self.insert1(key) + + for stream_type, part_table in [ + ("EnvironmentState", self.EnvironmentState), + ("BlockState", self.BlockState), + ("LightEvents", self.LightEvents), + ("MessageLog", self.MessageLog), + ("SubjectState", self.SubjectState), + ("SubjectVisits", self.SubjectVisits), + ("SubjectWeight", self.SubjectWeight), + ]: + stream_reader = getattr(device, stream_type) + + stream_data = io_api.load( + root=raw_data_dir.as_posix(), + reader=stream_reader, + start=pd.Timestamp(chunk_start), + end=pd.Timestamp(chunk_end), + ) + + part_table.insert1( + { + **key, + "sample_count": len(stream_data), + "timestamps": stream_data.index.values, + **{ + re.sub(r"\([^)]*\)", "", c): stream_data[c].values + for c in stream_reader.columns + if not c.startswith("_") + }, + }, + ignore_extra_fields=True, + ) + + # ------------------- EVENTS -------------------- + + @schema class FoodPatchEvent(dj.Imported): definition = """ # events associated with a given ExperimentFoodPatch From 960174129519fec61b202270af17310e3d2eb111 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Mon, 22 Jan 2024 18:50:44 -0600 Subject: [PATCH 387/489] chore(tracking): cleanup make call for SLEAP ingestion --- aeon/dj_pipeline/tracking.py | 47 ++++++------------------------------ 1 file changed, 7 insertions(+), 40 deletions(-) diff --git a/aeon/dj_pipeline/tracking.py b/aeon/dj_pipeline/tracking.py index 0e3b4ec2..d2460fcd 100644 --- a/aeon/dj_pipeline/tracking.py +++ b/aeon/dj_pipeline/tracking.py @@ -232,8 +232,6 @@ class SLEAPTracking(dj.Imported): -> acquisition.Chunk -> streams.SpinnakerVideoSource -> TrackingParamSet - --- - sample_count: int # number of data points acquired from this stream for a given chunk """ class PoseIdentity(dj.Part): @@ -251,6 +249,7 @@ class Part(dj.Part): -> master.PoseIdentity part_name: varchar(16) --- + sample_count: int # number of data points acquired from this stream for a given chunk x: longblob y: longblob likelihood: longblob @@ -294,7 +293,7 @@ def make(self, key): ) if not len(pose_data): - self.insert1({**key, "sample_count": 0}) + self.insert1(key) return # Find the config file for the SLEAP model @@ -317,8 +316,7 @@ def make(self, key): class_names = stream_reader.get_class_names(config_file) # ingest parts and classes - sample_count = 0 - class_entries, part_entries = [], [] + pose_identity_entries, part_entries = [], [] for class_idx in set(pose_data["class"].values.astype(int)): class_position = pose_data[pose_data["class"] == class_idx] for part in set(class_position.part.values): @@ -332,12 +330,12 @@ def make(self, key): "x": part_position.x.values, "y": part_position.y.values, "likelihood": part_position.part_likelihood.values, + "sample_count": len(part_position.index.values), } ) if part == anchor_part: class_likelihood = part_position.class_likelihood.values - sample_count = len(part_position.index.values) - class_entries.append( + pose_identity_entries.append( { **key, "identity_idx": class_idx, @@ -347,41 +345,10 @@ def make(self, key): } ) - self.insert1({**key, "sample_count": sample_count}) - self.Class.insert(class_entries) + self.insert1(key) + self.PoseIdentity.insert(pose_identity_entries) self.Part.insert(part_entries) - @classmethod - def get_object_position( - cls, - experiment_name, - subject_name, - start, - end, - camera_name="CameraTop", - tracking_paramset_id=1, - in_meter=False, - ): - table = ( - cls.Class.proj(part_name="anchor_part") * cls.Part * acquisition.Chunk.proj("chunk_end") - & {"experiment_name": experiment_name} - & {"tracking_paramset_id": tracking_paramset_id} - & (streams.SpinnakerVideoSource & {"spinnaker_video_source_name": camera_name}) - ) - - return _get_position( - table, - object_attr="class_name", - object_name=subject_name, - start_attr="chunk_start", - end_attr="chunk_end", - start=start, - end=end, - fetch_attrs=["timestamps", "x", "y", "likelihood"], - attrs_to_scale=["position_x", "position_y"], - scale_factor=pixel_scale if in_meter else 1, - ) - # ---------- HELPER ------------------ From 29b9ac186d3b31b753b4b36610a639dbdac1a45d Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 23 Jan 2024 16:11:44 -0600 Subject: [PATCH 388/489] feat(analysis): add Block-level analysis --- aeon/analysis/utils.py | 1 + aeon/dj_pipeline/analysis/block_analysis.py | 161 ++++++++++++++++++++ aeon/dj_pipeline/analysis/visit.py | 73 ++++++++- aeon/dj_pipeline/analysis/visit_analysis.py | 67 +------- 4 files changed, 241 insertions(+), 61 deletions(-) create mode 100644 aeon/dj_pipeline/analysis/block_analysis.py diff --git a/aeon/analysis/utils.py b/aeon/analysis/utils.py index eb738106..c936576c 100644 --- a/aeon/analysis/utils.py +++ b/aeon/analysis/utils.py @@ -10,6 +10,7 @@ def distancetravelled(angle, radius=4.0): :param float radius: The radius of the wheel, in metric units. :return: The total distance travelled on the wheel, in metric units. """ + angle.dropna(inplace=True) maxvalue = int(np.iinfo(np.uint16).max >> 2) jumpthreshold = maxvalue // 2 turns = angle.astype(int).diff() diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py new file mode 100644 index 00000000..9aad5baf --- /dev/null +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -0,0 +1,161 @@ +import datetime +import datajoint as dj +import pandas as pd + +from aeon.analysis import utils as analysis_utils + +from aeon.dj_pipeline import get_schema_name, fetch_stream +from aeon.dj_pipeline import acquisition, tracking, streams +from aeon.dj_pipeline.analysis.visit import ( + get_maintenance_periods, + filter_out_maintenance_periods, +) + +schema = dj.schema(get_schema_name("analysis")) + + +@schema +class Block(dj.Manual): + definition = """ + -> acquisition.Experiment + block_start: datetime(6) + --- + block_end: datetime(6) + """ + + +@schema +class BlockAnalysis(dj.Computed): + definition = """ + -> Block + """ + + class Patch(dj.Part): + definition = """ + -> master + patch_name: varchar(36) # e.g. Patch1, Patch2 + --- + pellet_count: int + pellet_timestamps: longblob + total_distance_travelled: float + cumulative_distance_travelled: longblob + """ + + class Subject(dj.Part): + definition = """ + -> master + subject_name: varchar(32) + --- + weights: longblob + weight_timestamps: longblob + position_x: longblob + position_y: longblob + position_likelihood: longblob + position_timestamps: longblob + """ + + def make(self, key): + block_start, block_end = (Block & key).fetch1("block_start", "block_end") + + start_restriction = f'"{block_start}" BETWEEN chunk_start AND chunk_end' + end_restriction = f'"{block_end}" BETWEEN chunk_start AND chunk_end' + + start_query = acquisition.Chunk & start_restriction + end_query = acquisition.Chunk & end_restriction + if not (start_query and end_query): + raise ValueError(f"No Chunk found between {block_start} and {block_end}") + + time_restriction = ( + f'chunk_start >= "{min(start_query.fetch("chunk_start"))}"' + f' AND chunk_start < "{max(end_query.fetch("chunk_end"))}"' + ) + + # Patch data - TriggerPellet, DepletionState, Encoder (distancetravelled) + maintenance_period = get_maintenance_periods(key["experiment_name"], block_start, block_end) + + patch_query = ( + streams.UndergroundFeeder.join(streams.UndergroundFeeder.RemovalTime, left=True) + & key + & f'"{block_start}" >= underground_feeder_install_time' + & f'"{block_end}" < IFNULL(underground_feeder_removal_time, "2200-01-01")' + ) + patch_keys, patch_names = patch_query.fetch("KEY", "underground_feeder_name") + + food_patch_entries = [] + for patch_key, patch_name in zip(patch_keys, patch_names): + delivered_pellet_df = fetch_stream( + streams.UndergroundFeederBeamBreak & patch_key & time_restriction + )[block_start:block_end] + # filter out maintenance period based on logs + pellet_df = filter_out_maintenance_periods( + delivered_pellet_df, + maintenance_period, + block_end, + dropna=True, + ) + # wheel data (encoder) + encoder_df = fetch_stream(streams.UndergroundFeederEncoder & patch_key & time_restriction)[ + block_start:block_end + ] + # filter out maintenance period based on logs + encoder_df = filter_out_maintenance_periods(encoder_df, maintenance_period, block_end) + distance_travelled = analysis_utils.distancetravelled(encoder_df.angle) + food_patch_entries.append( + { + **key, + "patch_name": patch_name, + "pellet_count": len(pellet_df), + "pellet_timestamps": pellet_df.index.values, + "cumulative_distance_travelled": distance_travelled.values, + "total_distance_travelled": distance_travelled.values[-1], + } + ) + + # Subject data + subject_events_query = acquisition.Environment.SubjectState & key & time_restriction + subject_events_df = fetch_stream(subject_events_query) + + subject_names = set(subject_events_df.id) + subject_entries = [] + for subject_name in subject_names: + # positions - query for CameraTop, identity_name matches subject_name, + pos_query = ( + streams.SpinnakerVideoSource + * tracking.SLEAPTracking.PoseIdentity.proj("identity_name", anchor_part="part_name") + * tracking.SLEAPTracking.Part + & { + "spinnaker_video_source_name": "CameraTop", + "identity_name": subject_name, + } + & time_restriction + ) + pos_df = fetch_stream(pos_query)[block_start:block_end] + pos_df = filter_out_maintenance_periods(pos_df, maintenance_period, block_end) + + # weights + weight_query = acquisition.Environment.SubjectWeight & key & time_restriction + weight_df = fetch_stream(weight_query)[block_start:block_end] + weight_df.query(f"subject_id == '{subject_name}'", inplace=True) + + subject_entries.append( + { + **key, + "subject_name": subject_name, + "weights": weight_df.weight.values, + "weight_timestamps": weight_df.index.values, + "position_x": pos_df.x.values, + "position_y": pos_df.y.values[-1], + "position_likelihood": pos_df.likelihood.values, + "position_timestamps": pos_df.index.values, + } + ) + + +@schema +class BlockDetection(dj.Computed): + definition = """ + -> acquisition.Chunk + """ + + def make(self, key): + pass diff --git a/aeon/dj_pipeline/analysis/visit.py b/aeon/dj_pipeline/analysis/visit.py index 3c7e7be7..5f6de200 100644 --- a/aeon/dj_pipeline/analysis/visit.py +++ b/aeon/dj_pipeline/analysis/visit.py @@ -1,11 +1,13 @@ import datetime - import datajoint as dj import pandas as pd +import numpy as np +from collections import deque from aeon.analysis import utils as analysis_utils -from .. import acquisition, get_schema_name, lab, qc, tracking +from aeon.dj_pipeline import get_schema_name, fetch_stream +from aeon.dj_pipeline import acquisition, lab, qc, tracking schema = dj.schema(get_schema_name("analysis")) @@ -182,3 +184,70 @@ def ingest_environment_visits(experiment_names: list | None = None): }, skip_duplicates=True, ) + + +def get_maintenance_periods(experiment_name, start, end): + # get states from acquisition.Environment.EnvironmentState + start_restriction = f'"{start}" BETWEEN chunk_start AND chunk_end' + end_restriction = f'"{end}" BETWEEN chunk_start AND chunk_end' + + start_query = acquisition.Chunk & start_restriction + end_query = acquisition.Chunk & end_restriction + if not (start_query and end_query): + raise ValueError(f"No Chunk found between {start} and {end}") + + time_restriction = ( + f'chunk_start >= "{min(start_query.fetch("chunk_start"))}"' + f' AND chunk_start < "{max(end_query.fetch("chunk_end"))}"' + ) + + state_query = ( + acquisition.Environment.EnvironmentState & {"experiment_name": experiment_name} & time_restriction + ) + if len(state_query) == 0: + return None + + env_state_df = fetch_stream(state_query)[start:end] + env_state_df.reset_index(inplace=True) + env_state_df = env_state_df[env_state_df["state"].shift() != env_state_df["state"]].reset_index( + drop=True + ) # remove duplicates and keep the first one + # An experiment starts with visit start (anything before the first maintenance is experiment) + # Delete the row if it starts with "Experiment" + if env_state_df.iloc[0]["state"] == "Experiment": + env_state_df.drop(index=0, inplace=True) # look for the first maintenance + if env_state_df.empty: + return deque([]) + + # Last entry is the visit end + if env_state_df.iloc[-1]["state"] == "Maintenance": + log_df_end = pd.DataFrame({"time": [pd.Timestamp(end)], "state": ["VisitEnd"]}) + env_state_df = pd.concat([env_state_df, log_df_end]) + env_state_df.reset_index(drop=True, inplace=True) + + maintenance_starts = env_state_df.loc[env_state_df["state"] == "Maintenance", "time"].values + maintenance_ends = env_state_df.loc[env_state_df["state"] != "Maintenance", "time"].values + + return deque( + [ + (pd.Timestamp(start), pd.Timestamp(end)) + for start, end in zip(maintenance_starts, maintenance_ends) + ] + ) # queue object. pop out from left after use + + +def filter_out_maintenance_periods(data_df, maintenance_period, end_time, dropna=False): + maint_period = maintenance_period.copy() + while maint_period: + (maintenance_start, maintenance_end) = maint_period[0] + if end_time < maintenance_start: # no more maintenance for this date + break + maintenance_filter = (data_df.index >= maintenance_start) & (data_df.index <= maintenance_end) + data_df[maintenance_filter] = np.nan + if end_time >= maintenance_end: # remove this range + maint_period.popleft() + else: + break + if dropna: + data_df.dropna(inplace=True) + return data_df diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index f8d6f23f..f66094f6 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -1,13 +1,18 @@ import datetime -from collections import deque from datetime import time import datajoint as dj import numpy as np import pandas as pd -from .. import acquisition, get_schema_name, lab, tracking -from .visit import Visit, VisitEnd +from aeon.dj_pipeline import get_schema_name +from aeon.dj_pipeline import acquisition, lab, tracking +from aeon.dj_pipeline.analysis.visit import ( + Visit, + VisitEnd, + get_maintenance_periods, + filter_out_maintenance_periods, +) logger = dj.logger schema = dj.schema(get_schema_name("analysis")) @@ -600,59 +605,3 @@ def make(self, key): "pellet_count": len(patch.loc[wheel_start:wheel_end]), } ) - - -def get_maintenance_periods(experiment_name, start, end): - # get logs from acquisition.ExperimentLog - query = ( - acquisition.ExperimentLog.Message.proj("message") - & {"experiment_name": experiment_name} - & 'message IN ("Maintenance", "Experiment")' - & f'message_time BETWEEN "{start}" AND "{end}"' - ) - - if len(query) == 0: - return None - - log_df = query.fetch(format="frame", order_by="message_time").reset_index() - log_df = log_df[log_df["message"].shift() != log_df["message"]].reset_index( - drop=True - ) # remove duplicates and keep the first one - - # An experiment starts with visit start (anything before the first maintenance is experiment) - # Delete the row if it starts with "Experiment" - if log_df.iloc[0]["message"] == "Experiment": - log_df.drop(index=0, inplace=True) # look for the first maintenance - - # Last entry is the visit end - if log_df.iloc[-1]["message"] == "Maintenance": - log_df_end = log_df.tail(1) - log_df_end["message_time"], log_df_end["message"] = ( - pd.Timestamp(end), - "VisitEnd", - ) - log_df = pd.concat([log_df, log_df_end]) - log_df.reset_index(drop=True, inplace=True) - - start_timestamps = log_df.loc[log_df["message"] == "Maintenance", "message_time"].values - end_timestamps = log_df.loc[log_df["message"] != "Maintenance", "message_time"].values - - return deque( - [(pd.Timestamp(start), pd.Timestamp(end)) for start, end in zip(start_timestamps, end_timestamps)] - ) # queue object. pop out from left after use - - -def filter_out_maintenance_periods(data_df, maintenance_period, end_time, dropna=False): - while maintenance_period: - (maintenance_start, maintenance_end) = maintenance_period[0] - if end_time < maintenance_start: # no more maintenance for this date - break - maintenance_filter = (data_df.index >= maintenance_start) & (data_df.index <= maintenance_end) - data_df[maintenance_filter] = np.nan - if end_time >= maintenance_end: # remove this range - maintenance_period.popleft() - else: - break - if dropna: - data_df.dropna(inplace=True) - return data_df From 802100f89435a385972c7eb78f54fdd96e1a05fd Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 23 Jan 2024 16:12:53 -0600 Subject: [PATCH 389/489] fix(social01): bugfix in setting up the readers --- aeon/schema/foraging.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/aeon/schema/foraging.py b/aeon/schema/foraging.py index df42cc1a..d88ae5eb 100644 --- a/aeon/schema/foraging.py +++ b/aeon/schema/foraging.py @@ -70,12 +70,32 @@ def feeder(pattern): def beam_break(pattern): """Beam break events for pellet detection.""" - return {"BeamBreak": _reader.BitmaskEvent(f"{pattern}_32_*", 0x22, "PelletDetected")} + return {"BeamBreak": _reader.BitmaskEvent(f"{pattern}_32_*", 34, "PelletDetected")} def deliver_pellet(pattern): """Pellet delivery commands.""" - return {"DeliverPellet": _reader.BitmaskEvent(f"{pattern}_35_*", 0x80, "TriggerPellet")} + return {"DeliverPellet": _reader.BitmaskEvent(f"{pattern}_35_*", 1, "TriggerPellet")} + + +def pellet_manual_delivery(pattern): + """Manual pellet delivery.""" + return {"ManualDelivery": _reader.Harp(f"{pattern}_201_*", ["manual_delivery"])} + + +def missed_pellet(pattern): + """Missed pellet delivery.""" + return {"MissedPellet": _reader.Harp(f"{pattern}_202_*", ["missed_pellet"])} + + +def pellet_retried_delivery(pattern): + """Retry pellet delivery.""" + return {"RetriedDelivery": _reader.Harp(f"{pattern}_203_*", ["retried_delivery"])} + + +def pellet_depletion_state(pattern): + """Pellet delivery state.""" + return {"DepletionState": _reader.Csv(f"{pattern}_State_*", ["threshold", "offset", "rate"])} def patch(pattern): From 49dc4355a757d4d9c88ad5b2346fc240f86dc198 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 23 Jan 2024 17:54:46 -0600 Subject: [PATCH 390/489] feat(block_analysis): add analysis and plots for Block --- aeon/dj_pipeline/analysis/block_analysis.py | 81 +++++++++++++++++++-- 1 file changed, 73 insertions(+), 8 deletions(-) diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index 9aad5baf..8e3dbf44 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -1,6 +1,7 @@ import datetime import datajoint as dj import pandas as pd +import json from aeon.analysis import utils as analysis_utils @@ -39,6 +40,7 @@ class Patch(dj.Part): pellet_timestamps: longblob total_distance_travelled: float cumulative_distance_travelled: longblob + wheel_timestamps: longblob """ class Subject(dj.Part): @@ -70,6 +72,8 @@ def make(self, key): f' AND chunk_start < "{max(end_query.fetch("chunk_end"))}"' ) + self.insert1(key) + # Patch data - TriggerPellet, DepletionState, Encoder (distancetravelled) maintenance_period = get_maintenance_periods(key["experiment_name"], block_start, block_end) @@ -81,7 +85,6 @@ def make(self, key): ) patch_keys, patch_names = patch_query.fetch("KEY", "underground_feeder_name") - food_patch_entries = [] for patch_key, patch_name in zip(patch_keys, patch_names): delivered_pellet_df = fetch_stream( streams.UndergroundFeederBeamBreak & patch_key & time_restriction @@ -99,15 +102,16 @@ def make(self, key): ] # filter out maintenance period based on logs encoder_df = filter_out_maintenance_periods(encoder_df, maintenance_period, block_end) - distance_travelled = analysis_utils.distancetravelled(encoder_df.angle) - food_patch_entries.append( + encoder_df["distance_travelled"] = analysis_utils.distancetravelled(encoder_df.angle) + self.Patch.insert1( { **key, "patch_name": patch_name, "pellet_count": len(pellet_df), "pellet_timestamps": pellet_df.index.values, - "cumulative_distance_travelled": distance_travelled.values, - "total_distance_travelled": distance_travelled.values[-1], + "cumulative_distance_travelled": encoder_df.distance_travelled.values, + "total_distance_travelled": encoder_df.distance_travelled.values[-1], + "wheel_timestamps": encoder_df.index.values, } ) @@ -116,7 +120,6 @@ def make(self, key): subject_events_df = fetch_stream(subject_events_query) subject_names = set(subject_events_df.id) - subject_entries = [] for subject_name in subject_names: # positions - query for CameraTop, identity_name matches subject_name, pos_query = ( @@ -137,20 +140,82 @@ def make(self, key): weight_df = fetch_stream(weight_query)[block_start:block_end] weight_df.query(f"subject_id == '{subject_name}'", inplace=True) - subject_entries.append( + self.Subject.insert1( { **key, "subject_name": subject_name, "weights": weight_df.weight.values, "weight_timestamps": weight_df.index.values, "position_x": pos_df.x.values, - "position_y": pos_df.y.values[-1], + "position_y": pos_df.y.values, "position_likelihood": pos_df.likelihood.values, "position_timestamps": pos_df.index.values, } ) +@schema +class BlockPlots(dj.Computed): + definition = """ + -> BlockAnalysis + --- + subject_positions_plot: longblob + subject_weights_plot: longblob + patch_distance_travelled_plot: longblob + """ + + def make(self, key): + import plotly.graph_objs as go + + # For position data , set confidence threshold to return position values and downsample by 5x + conf_thresh = 0.9 + downsampling_factor = 5 + + # Make plotly plots + weight_fig = go.Figure() + pos_fig = go.Figure() + for subject_data in (BlockAnalysis.Subject & key).fetch(as_dict=True): + weight_fig.add_trace( + go.Scatter( + x=subject_data["weight_timestamps"], + y=subject_data["weights"], + mode="lines", + name=subject_data["subject_name"], + ) + ) + mask = subject_data["position_likelihood"] > conf_thresh + pos_fig.add_trace( + go.Scatter3d( + x=subject_data["position_x"][mask][::downsampling_factor], + y=subject_data["position_y"][mask][::downsampling_factor], + z=subject_data["position_timestamps"][mask][::downsampling_factor], + mode="lines", + name=subject_data["subject_name"], + ) + ) + + wheel_fig = go.Figure() + for patch_data in (BlockAnalysis.Patch & key).fetch(as_dict=True): + wheel_fig.add_trace( + go.Scatter( + x=patch_data["wheel_timestamps"][::2], + y=patch_data["cumulative_distance_travelled"][::2], + mode="lines", + name=patch_data["patch_name"], + ) + ) + + # insert figures as json-formatted plotly plots + self.insert1( + { + **key, + "subject_positions_plot": json.loads(pos_fig.to_json()), + "subject_weights_plot": json.loads(weight_fig.to_json()), + "patch_distance_travelled_plot": json.loads(wheel_fig.to_json()), + } + ) + + @schema class BlockDetection(dj.Computed): definition = """ From 84ab0189f81d9e146123b57a2bf232517751eb7b Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Mon, 29 Jan 2024 12:21:47 -0600 Subject: [PATCH 391/489] feat(analysis): add logic for BlockDetection and BlockAnalysis --- aeon/analysis/utils.py | 1 - aeon/dj_pipeline/acquisition.py | 17 ++ aeon/dj_pipeline/analysis/block_analysis.py | 170 ++++++++++++++++---- 3 files changed, 159 insertions(+), 29 deletions(-) diff --git a/aeon/analysis/utils.py b/aeon/analysis/utils.py index c936576c..eb738106 100644 --- a/aeon/analysis/utils.py +++ b/aeon/analysis/utils.py @@ -10,7 +10,6 @@ def distancetravelled(angle, radius=4.0): :param float radius: The radius of the wheel, in metric units. :return: The total distance travelled on the wheel, in metric units. """ - angle.dropna(inplace=True) maxvalue = int(np.iinfo(np.uint16).max >> 2) jumpthreshold = maxvalue // 2 turns = angle.astype(int).diff() diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 840c5396..fcd10134 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -1360,3 +1360,20 @@ def _load_legacy_subjectdata(experiment_name, data_dir, start, end): subject_data.sort_index(inplace=True) return subject_data + + +def create_chunk_restriction(experiment_name, start_time, end_time): + """ + Create a time restriction string for the chunks between the specified "start" and "end" times + """ + start_restriction = f'"{start_time}" BETWEEN chunk_start AND chunk_end' + end_restriction = f'"{end_time}" BETWEEN chunk_start AND chunk_end' + start_query = Chunk & {"experiment_name": experiment_name} & start_restriction + end_query = Chunk & {"experiment_name": experiment_name} & end_restriction + if not (start_query and end_query): + raise ValueError(f"No Chunk found between {start_time} and {end_time}") + time_restriction = ( + f'chunk_start >= "{min(start_query.fetch("chunk_start"))}"' + f' AND chunk_start < "{max(end_query.fetch("chunk_end"))}"' + ) + return time_restriction diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index 8e3dbf44..4ce2d8d1 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -2,6 +2,7 @@ import datajoint as dj import pandas as pd import json +import numpy as np from aeon.analysis import utils as analysis_utils @@ -21,7 +22,7 @@ class Block(dj.Manual): -> acquisition.Experiment block_start: datetime(6) --- - block_end: datetime(6) + block_end=null: datetime(6) """ @@ -31,6 +32,8 @@ class BlockAnalysis(dj.Computed): -> Block """ + key_source = Block & "block_end IS NOT NULL" + class Patch(dj.Part): definition = """ -> master @@ -38,9 +41,11 @@ class Patch(dj.Part): --- pellet_count: int pellet_timestamps: longblob - total_distance_travelled: float - cumulative_distance_travelled: longblob + wheel_cumsum_distance_travelled: longblob # wheel's cumulative distance travelled wheel_timestamps: longblob + patch_threshold: longblob + patch_threshold_timestamps: longblob + patch_rate: float """ class Subject(dj.Part): @@ -54,27 +59,30 @@ class Subject(dj.Part): position_y: longblob position_likelihood: longblob position_timestamps: longblob + cumsum_distance_travelled: longblob # subject's cumulative distance travelled """ def make(self, key): + """ + Restrict, fetch and aggregate data from different streams to produce intermediate data products + at a per-block level (for different patches and different subjects) + 1. Query data for all chunks within the block + 2. Fetch streams, filter by maintenance period + 3. Fetch subject position data (SLEAP) + 4. Aggregate and insert into the table + """ block_start, block_end = (Block & key).fetch1("block_start", "block_end") - start_restriction = f'"{block_start}" BETWEEN chunk_start AND chunk_end' - end_restriction = f'"{block_end}" BETWEEN chunk_start AND chunk_end' - - start_query = acquisition.Chunk & start_restriction - end_query = acquisition.Chunk & end_restriction - if not (start_query and end_query): - raise ValueError(f"No Chunk found between {block_start} and {block_end}") - - time_restriction = ( - f'chunk_start >= "{min(start_query.fetch("chunk_start"))}"' - f' AND chunk_start < "{max(end_query.fetch("chunk_end"))}"' + chunk_restriction = acquisition.create_chunk_restriction( + key["experiment_name"], block_start, block_end ) self.insert1(key) # Patch data - TriggerPellet, DepletionState, Encoder (distancetravelled) + # For wheel data, downsample by 50x - 10Hz + wheel_downsampling_factor = 50 + maintenance_period = get_maintenance_periods(key["experiment_name"], block_start, block_end) patch_query = ( @@ -87,8 +95,14 @@ def make(self, key): for patch_key, patch_name in zip(patch_keys, patch_names): delivered_pellet_df = fetch_stream( - streams.UndergroundFeederBeamBreak & patch_key & time_restriction + streams.UndergroundFeederBeamBreak & patch_key & chunk_restriction + )[block_start:block_end] + depletion_state_df = fetch_stream( + streams.UndergroundFeederDepletionState & patch_key & chunk_restriction )[block_start:block_end] + encoder_df = fetch_stream(streams.UndergroundFeederEncoder & patch_key & chunk_restriction)[ + block_start:block_end + ] # filter out maintenance period based on logs pellet_df = filter_out_maintenance_periods( delivered_pellet_df, @@ -96,27 +110,40 @@ def make(self, key): block_end, dropna=True, ) - # wheel data (encoder) - encoder_df = fetch_stream(streams.UndergroundFeederEncoder & patch_key & time_restriction)[ - block_start:block_end - ] - # filter out maintenance period based on logs - encoder_df = filter_out_maintenance_periods(encoder_df, maintenance_period, block_end) + depletion_state_df = filter_out_maintenance_periods( + depletion_state_df, + maintenance_period, + block_end, + dropna=True, + ) + encoder_df = filter_out_maintenance_periods( + encoder_df, maintenance_period, block_end, dropna=True + ) + encoder_df["distance_travelled"] = analysis_utils.distancetravelled(encoder_df.angle) + + patch_rate = depletion_state_df.rate.unique() + assert len(patch_rate) == 1 # expects a single rate for this block + patch_rate = patch_rate[0] + self.Patch.insert1( { **key, "patch_name": patch_name, "pellet_count": len(pellet_df), "pellet_timestamps": pellet_df.index.values, - "cumulative_distance_travelled": encoder_df.distance_travelled.values, - "total_distance_travelled": encoder_df.distance_travelled.values[-1], - "wheel_timestamps": encoder_df.index.values, + "wheel_cumsum_distance_travelled": encoder_df.distance_travelled.values[ + ::wheel_downsampling_factor + ], + "wheel_timestamps": encoder_df.index.values[::wheel_downsampling_factor], + "patch_threshold": depletion_state_df.threshold.values, + "patch_threshold_timestamps": depletion_state_df.index.values, + "patch_rate": patch_rate, } ) # Subject data - subject_events_query = acquisition.Environment.SubjectState & key & time_restriction + subject_events_query = acquisition.Environment.SubjectState & key & chunk_restriction subject_events_df = fetch_stream(subject_events_query) subject_names = set(subject_events_df.id) @@ -130,13 +157,18 @@ def make(self, key): "spinnaker_video_source_name": "CameraTop", "identity_name": subject_name, } - & time_restriction + & chunk_restriction ) pos_df = fetch_stream(pos_query)[block_start:block_end] pos_df = filter_out_maintenance_periods(pos_df, maintenance_period, block_end) + position_diff = np.sqrt( + (np.square(np.diff(pos_df.x.astype(float))) + np.square(np.diff(pos_df.y.astype(float)))) + ) + cumsum_distance_travelled = np.concatenate([[0], np.cumsum(position_diff)]) + # weights - weight_query = acquisition.Environment.SubjectWeight & key & time_restriction + weight_query = acquisition.Environment.SubjectWeight & key & chunk_restriction weight_df = fetch_stream(weight_query)[block_start:block_end] weight_df.query(f"subject_id == '{subject_name}'", inplace=True) @@ -150,10 +182,36 @@ def make(self, key): "position_y": pos_df.y.values, "position_likelihood": pos_df.likelihood.values, "position_timestamps": pos_df.index.values, + "cumsum_distance_travelled": cumsum_distance_travelled, } ) +@schema +class BlockSubjectAnalysis(dj.Computed): + definition = """ + -> BlockAnalysis + """ + + class Patch(dj.Part): + definition = """ + -> master.Patch + -> master.Subject + --- + in_patch_timestamps: longblob # timestamps in which a particular subject is spending time at a particular patch + in_patch_time: float # total seconds spent in this patch for this block + pellet_count: int + pellet_timestamps: longblob + wheel_distance_travelled: longblob # wheel's cumulative distance travelled + wheel_timestamps: longblob + cumulative_sum_preference: longblob + windowed_sum_preference: longblob + """ + + def make(self, key): + pass + + @schema class BlockPlots(dj.Computed): definition = """ @@ -223,4 +281,60 @@ class BlockDetection(dj.Computed): """ def make(self, key): - pass + """ + On a per-chunk basis, check for the presence of new block, insert into Block table + """ + # find the 0s + # that would mark the start of a new block + # if the 0 is the first index - look back at the previous chunk + # if the previous timestamp belongs to a previous epoch -> block_end is the previous timestamp + # else block_end is the timestamp of this 0 + chunk_start, chunk_end = (acquisition.Chunk & key).fetch1("chunk_start", "chunk_end") + exp_key = {"experiment_name": key["experiment_name"]} + # only consider the time period between the last block and the current chunk + previous_block = Block & exp_key & f"block_start <= '{chunk_start}'" + if previous_block: + previous_block_key = previous_block.fetch("KEY", limit=1, order_by="block_start DESC")[0] + previous_block_start = previous_block_key["block_start"] + else: + previous_block_key = None + previous_block_start = (acquisition.Chunk & exp_key).fetch( + "chunk_start", limit=1, order_by="chunk_start" + )[0] + + chunk_restriction = acquisition.create_chunk_restriction( + key["experiment_name"], previous_block_start, chunk_end + ) + + block_query = acquisition.Environment.BlockState & chunk_restriction + block_df = fetch_stream(block_query)[previous_block_start:chunk_end] + + block_ends = block_df[block_df.pellet_ct.diff() < 0] + + block_entries = [] + for idx, block_end in enumerate(block_ends.index): + if idx == 0: + if previous_block_key: + # if there is a previous block - insert "block_end" for the previous block + previous_pellet_time = block_df[:block_end].index[-2] + previous_epoch = ( + acquisition.Epoch.join(acquisition.EpochEnd, left=True) + & exp_key + & f"'{previous_pellet_time}' BETWEEN epoch_start AND IFNULL(epoch_end, '2200-01-01')" + ).fetch1("KEY") + current_epoch = ( + acquisition.Epoch.join(acquisition.EpochEnd, left=True) + & exp_key + & f"'{block_end}' BETWEEN epoch_start AND IFNULL(epoch_end, '2200-01-01')" + ).fetch1("KEY") + + previous_block_key["block_end"] = ( + block_end if current_epoch == previous_epoch else previous_pellet_time + ) + Block.update1(previous_block_key) + else: + block_entries[-1]["block_end"] = block_end + block_entries.append({**exp_key, "block_start": block_end, "block_end": None}) + + Block.insert(block_entries) + self.insert1(key) From fa7b16ed416d7981e5e6849b6fbdf50c63d146ee Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Mon, 29 Jan 2024 17:36:57 -0600 Subject: [PATCH 392/489] feat(get_maintenance_periods): update logic, cleaner & more robust --- aeon/dj_pipeline/analysis/visit.py | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/aeon/dj_pipeline/analysis/visit.py b/aeon/dj_pipeline/analysis/visit.py index 5f6de200..babae2fb 100644 --- a/aeon/dj_pipeline/analysis/visit.py +++ b/aeon/dj_pipeline/analysis/visit.py @@ -188,26 +188,14 @@ def ingest_environment_visits(experiment_names: list | None = None): def get_maintenance_periods(experiment_name, start, end): # get states from acquisition.Environment.EnvironmentState - start_restriction = f'"{start}" BETWEEN chunk_start AND chunk_end' - end_restriction = f'"{end}" BETWEEN chunk_start AND chunk_end' - - start_query = acquisition.Chunk & start_restriction - end_query = acquisition.Chunk & end_restriction - if not (start_query and end_query): - raise ValueError(f"No Chunk found between {start} and {end}") - - time_restriction = ( - f'chunk_start >= "{min(start_query.fetch("chunk_start"))}"' - f' AND chunk_start < "{max(end_query.fetch("chunk_end"))}"' - ) - + chunk_restriction = acquisition.create_chunk_restriction(experiment_name, start, end) state_query = ( - acquisition.Environment.EnvironmentState & {"experiment_name": experiment_name} & time_restriction + acquisition.Environment.EnvironmentState & {"experiment_name": experiment_name} & chunk_restriction ) - if len(state_query) == 0: - return None - env_state_df = fetch_stream(state_query)[start:end] + if env_state_df.empty: + return deque([]) + env_state_df.reset_index(inplace=True) env_state_df = env_state_df[env_state_df["state"].shift() != env_state_df["state"]].reset_index( drop=True From 1ffc86869633431cbffa1b17a32beaaaadd3b73e Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 30 Jan 2024 09:05:17 -0600 Subject: [PATCH 393/489] fix(block_analysis): minor bugfix --- aeon/dj_pipeline/analysis/block_analysis.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index 4ce2d8d1..4e683c9d 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -195,8 +195,9 @@ class BlockSubjectAnalysis(dj.Computed): class Patch(dj.Part): definition = """ - -> master.Patch - -> master.Subject + -> master + -> BlockAnalysis.Patch + -> BlockAnalysis.Subject --- in_patch_timestamps: longblob # timestamps in which a particular subject is spending time at a particular patch in_patch_time: float # total seconds spent in this patch for this block From 18dbcefea5b785f670b1eef73a12e27fbf70ac0e Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 30 Jan 2024 09:44:59 -0600 Subject: [PATCH 394/489] feat(block_analysis): add example notebook --- aeon/dj_pipeline/analysis/block_analysis.py | 4 +- .../notebooks/social01_block_analysis.ipynb | 212 ++++++++++++++++++ 2 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 aeon/dj_pipeline/docs/notebooks/social01_block_analysis.ipynb diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index 4e683c9d..342528f6 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -30,6 +30,8 @@ class Block(dj.Manual): class BlockAnalysis(dj.Computed): definition = """ -> Block + --- + block_duration: float # (hour) """ key_source = Block & "block_end IS NOT NULL" @@ -77,7 +79,7 @@ def make(self, key): key["experiment_name"], block_start, block_end ) - self.insert1(key) + self.insert1({**key, "block_duration": (block_end - block_start).total_seconds() / 3600}) # Patch data - TriggerPellet, DepletionState, Encoder (distancetravelled) # For wheel data, downsample by 50x - 10Hz diff --git a/aeon/dj_pipeline/docs/notebooks/social01_block_analysis.ipynb b/aeon/dj_pipeline/docs/notebooks/social01_block_analysis.ipynb new file mode 100644 index 00000000..f91db18c --- /dev/null +++ b/aeon/dj_pipeline/docs/notebooks/social01_block_analysis.ipynb @@ -0,0 +1,212 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": [ + "import datajoint as dj" + ] + }, + { + "cell_type": "markdown", + "source": [ + "## Create VirtualModule to access `aeon_test_analysis` schema\n", + "Currently, the analysis is on `aeon_test_`, will move to `aeon_` soon (once ready for production)" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%% md\n" + } + } + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [ + "analysis_vm = dj.create_virtual_module('aeon_test_analysis', 'aeon_test_analysis')" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "markdown", + "source": [ + "## Browse Block and BlockAnalysis" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%% md\n" + } + } + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [ + "analysis_vm.Block()" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [ + "analysis_vm.BlockAnalysis()" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [ + "# Pick a block of interest\n", + "block_key = {'experiment_name': 'social0.1-aeon3', 'block_start': '2023-11-24 11:31:07.013984'}" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [ + "analysis_vm.BlockAnalysis & block_key" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [ + "analysis_vm.BlockAnalysis.Patch & block_key" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [ + "analysis_vm.BlockAnalysis.Subject & block_key" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "markdown", + "source": [ + "## Fetch back patch data for the block" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%% md\n" + } + } + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [ + "block_patch_data = (analysis_vm.BlockAnalysis.Patch & block_key).fetch(as_dict=True)" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + }, + { + "cell_type": "markdown", + "source": [ + "## Fetch back subject data for the block" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%% md\n" + } + } + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [ + "block_subject_data = (analysis_vm.BlockAnalysis.Subject & block_key).fetch(as_dict=True)" + ], + "metadata": { + "collapsed": false, + "pycharm": { + "name": "#%%\n" + } + } + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 2 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython2", + "version": "2.7.6" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} \ No newline at end of file From 326a2da248ab151a7b098c7edf936f0a99555877 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 30 Jan 2024 09:49:32 -0600 Subject: [PATCH 395/489] update(notebook) --- aeon/dj_pipeline/docs/notebooks/social01_block_analysis.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/docs/notebooks/social01_block_analysis.ipynb b/aeon/dj_pipeline/docs/notebooks/social01_block_analysis.ipynb index f91db18c..9c012913 100644 --- a/aeon/dj_pipeline/docs/notebooks/social01_block_analysis.ipynb +++ b/aeon/dj_pipeline/docs/notebooks/social01_block_analysis.ipynb @@ -84,7 +84,7 @@ "outputs": [], "source": [ "# Pick a block of interest\n", - "block_key = {'experiment_name': 'social0.1-aeon3', 'block_start': '2023-11-24 11:31:07.013984'}" + "block_key = {'experiment_name': 'social0.1-aeon3', 'block_start': '2023-11-30 18:49:05.001984'}" ], "metadata": { "collapsed": false, From 4f657732fd7e42dc7732edf1f1363c0f20de822e Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 30 Jan 2024 15:05:09 -0600 Subject: [PATCH 396/489] Create clone_and_freeze_exp02.py --- .../scripts/clone_and_freeze_exp02.py | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 aeon/dj_pipeline/scripts/clone_and_freeze_exp02.py diff --git a/aeon/dj_pipeline/scripts/clone_and_freeze_exp02.py b/aeon/dj_pipeline/scripts/clone_and_freeze_exp02.py new file mode 100644 index 00000000..ee1d7356 --- /dev/null +++ b/aeon/dj_pipeline/scripts/clone_and_freeze_exp02.py @@ -0,0 +1,110 @@ +"""Jan 2024 +Cloning and archiving schemas and data for experiment 0.2. +The pipeline code associated with this archived data pipeline is here +https://github.com/SainsburyWellcomeCentre/aeon_mecha/releases/tag/dj_exp02_stable +""" +import os +import inspect +import datajoint as dj +from datajoint_utilities.dj_data_copy import db_migration +from datajoint_utilities.dj_data_copy.pipeline_cloning import ClonedPipeline + +logger = dj.logger +os.environ["DJ_SUPPORT_FILEPATH_MANAGEMENT"] = "TRUE" + +source_db_prefix = "aeon_" +target_db_prefix = "aeon_archived_exp02_" + +schema_name_mapper = { + source_db_prefix + schema_name: target_db_prefix + schema_name + for schema_name in ("lab", "subject", "acquisition", "tracking", "qc", "analysis", "report") +} + +restriction = [{"experiment_name": "exp0.2-r0"}, {"experiment_name": "social0-r1"}] + +table_block_list = {} + +batch_size = None + + +def clone_pipeline(): + diagram = None + for orig_schema_name in schema_name_mapper: + virtual_module = dj.create_virtual_module(orig_schema_name, orig_schema_name) + if diagram is None: + diagram = dj.Diagram(virtual_module) + else: + diagram += dj.Diagram(virtual_module) + + cloned_pipeline = ClonedPipeline(diagram, schema_name_mapper, verbose=True) + cloned_pipeline.instantiate_pipeline(prompt=False) + + +def data_copy(restriction, table_block_list, batch_size=None): + for orig_schema_name, cloned_schema_name in schema_name_mapper.items(): + orig_schema = dj.create_virtual_module(orig_schema_name, orig_schema_name) + cloned_schema = dj.create_virtual_module(cloned_schema_name, cloned_schema_name) + + db_migration.migrate_schema( + orig_schema, + cloned_schema, + restriction=restriction, + table_block_list=table_block_list.get(cloned_schema_name, []), + allow_missing_destination_tables=True, + force_fetch=False, + batch_size=batch_size, + ) + + +def validate(): + """ + Validation of schemas migration + 1. for the provided list of schema names - validate all schemas have been migrated + 2. for each schema - validate all tables have been migrated + 3. for each table, validate all entries have been migrated + """ + missing_schemas = [] + missing_tables = {} + missing_entries = {} + + for orig_schema_name, cloned_schema_name in schema_name_mapper.items(): + logger.info(f"Validate schema: {orig_schema_name}") + source_vm = dj.create_virtual_module(orig_schema_name, orig_schema_name) + + try: + target_vm = dj.create_virtual_module(cloned_schema_name, cloned_schema_name) + except dj.errors.DataJointError: + missing_schemas.append(orig_schema_name) + continue + + missing_tables[orig_schema_name] = [] + missing_entries[orig_schema_name] = {} + + for attr in dir(source_vm): + obj = getattr(source_vm, attr) + if isinstance(obj, dj.user_tables.UserTable) or ( + inspect.isclass(obj) and issubclass(obj, dj.user_tables.UserTable) + ): + source_tbl = obj + try: + target_tbl = getattr(target_vm, attr) + except AttributeError: + missing_tables[orig_schema_name].append(source_tbl.table_name) + continue + logger.info(f"\tValidate entry count: {source_tbl.__name__}") + source_entry_count = len(source_tbl()) + target_entry_count = len(target_tbl()) + missing_entries[orig_schema_name][source_tbl.__name__] = { + "entry_count_diff": source_entry_count - target_entry_count, + "db_size_diff": source_tbl().size_on_disk - target_tbl().size_on_disk, + } + + return { + "missing_schemas": missing_schemas, + "missing_tables": missing_tables, + "missing_entries": missing_entries, + } + + +if __name__ == "__main__": + print("This is not meant to be run as a script (yet)") From ef39963185f67f1d24fcd08407a01d4b7e8e11b9 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 30 Jan 2024 15:45:30 -0600 Subject: [PATCH 397/489] fix(foraging): bugfix, duplicated code in resolving merge conflict --- aeon/schema/foraging.py | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/aeon/schema/foraging.py b/aeon/schema/foraging.py index e8610747..d88ae5eb 100644 --- a/aeon/schema/foraging.py +++ b/aeon/schema/foraging.py @@ -98,26 +98,6 @@ def pellet_depletion_state(pattern): return {"DepletionState": _reader.Csv(f"{pattern}_State_*", ["threshold", "offset", "rate"])} -def pellet_manual_delivery(pattern): - """Manual pellet delivery.""" - return {"ManualDelivery": _reader.Harp(f"{pattern}_*", ["manual_delivery"])} - - -def missed_pellet(pattern): - """Missed pellet delivery.""" - return {"MissedPellet": _reader.Harp(f"{pattern}_*", ["missed_pellet"])} - - -def pellet_retried_delivery(pattern): - """Retry pellet delivery.""" - return {"RetriedDelivery": _reader.Harp(f"{pattern}_*", ["retried_delivery"])} - - -def pellet_depletion_state(pattern): - """Pellet delivery state.""" - return {"DepletionState": _reader.Csv(f"{pattern}_*", ["threshold", "offset", "rate"])} - - def patch(pattern): """Data streams for a patch.""" return _device.register(pattern, depletion_function, _stream.encoder, feeder) From 123d279e7b466c5adae7be698dce23e388d77ce1 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 30 Jan 2024 16:16:19 -0600 Subject: [PATCH 398/489] update(dj_pipeline): remove obsolete tables --- aeon/dj_pipeline/acquisition.py | 710 -------------------- aeon/dj_pipeline/analysis/__init__.py | 1 - aeon/dj_pipeline/analysis/in_arena.py | 658 ------------------ aeon/dj_pipeline/analysis/visit_analysis.py | 3 +- aeon/dj_pipeline/qc.py | 28 +- aeon/dj_pipeline/tracking.py | 120 ---- 6 files changed, 21 insertions(+), 1499 deletions(-) delete mode 100644 aeon/dj_pipeline/analysis/in_arena.py diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index fcd10134..39121f2f 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -161,108 +161,6 @@ def get_data_directories(cls, experiment_key, directory_types=None, as_posix=Fal ] -@schema -class ExperimentCamera(dj.Manual): - definition = """ - # Camera placement and operation for a particular time period, at a certain location, for a given experiment - -> Experiment - -> lab.Camera - camera_install_time: datetime(6) # time of the camera placed and started operation at this position - --- - camera_description: varchar(36) - camera_sampling_rate: float # (Hz) camera frame rate - camera_gain=null: float # gain value applied to the acquired video - camera_bin=null: int # bin-size applied to the acquired video - """ - - class Position(dj.Part): - definition = """ - -> master - --- - camera_position_x: float # (m) x-position, in the arena's coordinate frame - camera_position_y: float # (m) y-position, in the arena's coordinate frame - camera_position_z=0: float # (m) z-position, in the arena's coordinate frame - camera_rotation_x=null: float # - camera_rotation_y=null: float # - camera_rotation_z=null: float # - """ - - # class OpticalConfiguration(dj.Part): - # definition = """ - # -> master - # --- - # calibration_factor: float # px to mm - # """ - - class RemovalTime(dj.Part): - definition = """ - -> master - --- - camera_remove_time: datetime(6) # time of the camera being removed from this position - """ - - -@schema -class ExperimentFoodPatch(dj.Manual): - definition = """ - # Food patch placement and operation for a particular time period, at a certain location, for a given experiment - -> Experiment - -> lab.FoodPatch - food_patch_install_time: datetime(6) # time of the food_patch placed and started operation at this position - --- - food_patch_description: varchar(36) - wheel_sampling_rate: float # (Hz) wheel's sampling rate - wheel_radius=null: float # (cm) - """ - - class RemovalTime(dj.Part): - definition = """ - -> master - --- - food_patch_remove_time: datetime(6) # time of the food_patch being removed from this position - """ - - class Position(dj.Part): - definition = """ - -> master - --- - food_patch_position_x: float # (m) x-position, in the arena's coordinate frame - food_patch_position_y: float # (m) y-position, in the arena's coordinate frame - food_patch_position_z=0: float # (m) z-position, in the arena's coordinate frame - """ - - class Vertex(dj.Part): - definition = """ - -> master - vertex: int - --- - vertex_x: float # (m) x-coordinate of the vertex, in the arena's coordinate frame - vertex_y: float # (m) y-coordinate of the vertex, in the arena's coordinate frame - vertex_z=0: float # (m) z-coordinate of the vertex, in the arena's coordinate frame - """ - - -@schema -class ExperimentWeightScale(dj.Manual): - definition = """ - # Scale for measuring animal weights - -> Experiment - -> lab.WeightScale - weight_scale_install_time: datetime(6) # time of the weight_scale placed and started operation at this position - --- - -> lab.ArenaNest - weight_scale_description: varchar(36) - weight_scale_sampling_rate=null: float # (Hz) weight scale sampling rate - """ - - class RemovalTime(dj.Part): - definition = """ - -> master - --- - weight_scale_remove_time: datetime(6) # time of the weight_scale being removed from this position - """ - - # ------------------- ACQUISITION EPOCH -------------------- @@ -532,198 +430,6 @@ def ingest_chunks(cls, experiment_name): cls.File.insert(file_list) -@schema -class SubjectEnterExit(dj.Imported): - definition = """ # Records of subjects entering/exiting the arena - -> Chunk - """ - - _enter_exit_event_mapper = {"Enter": 220, "Exit": 221, "Remain": 223} - - class Time(dj.Part): - definition = """ # Timestamps of each entering/exiting events - -> master - -> Experiment.Subject - enter_exit_time: datetime(6) # datetime of subject entering/exiting the arena - --- - -> EventType - """ - - def make(self, key): - subject_list = (Experiment.Subject & key).fetch("subject") - chunk_start, chunk_end = (Chunk & key).fetch1("chunk_start", "chunk_end") - raw_data_dir = Experiment.get_data_directory(key) - - if key["experiment_name"] in ("exp0.1-r0", "social0-r1"): - subject_data = _load_legacy_subjectdata( - key["experiment_name"], - raw_data_dir.as_posix(), - pd.Timestamp(chunk_start), - pd.Timestamp(chunk_end), - ) - else: - devices_schema = getattr( - aeon_schemas, - (Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( - "devices_schema_name" - ), - ) - device = devices_schema.ExperimentalMetadata - subject_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=device.SubjectState, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1(key) - self.Time.insert( - ( - { - **key, - "subject": r.id, - "event_code": self._enter_exit_event_mapper[r.event], - "enter_exit_time": r.name, - } - for _, r in subject_data.iterrows() - if r.id in subject_list - ), - skip_duplicates=True, - ) - - -@schema -class SubjectWeight(dj.Imported): - definition = """ # Records of subjects weights - -> Chunk - """ - - class WeightTime(dj.Part): - definition = """ # # Timestamps of each subject weight measurements - -> master - -> Experiment.Subject - weight_time: datetime(6) # datetime of subject weighting - --- - weight: float # - """ - - def make(self, key): - subject_list = (Experiment.Subject & key).fetch("subject") - chunk_start, chunk_end = (Chunk & key).fetch1("chunk_start", "chunk_end") - raw_data_dir = Experiment.get_data_directory(key) - if key["experiment_name"] in ("exp0.1-r0", "social0-r1"): - subject_data = _load_legacy_subjectdata( - key["experiment_name"], - raw_data_dir.as_posix(), - pd.Timestamp(chunk_start), - pd.Timestamp(chunk_end), - ) - else: - devices_schema = getattr( - aeon_schemas, - (Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( - "devices_schema_name" - ), - ) - device = devices_schema.ExperimentalMetadata - subject_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=device.SubjectState, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1(key) - self.WeightTime.insert( - ( - {**key, "subject": r.id, "weight": r.weight, "weight_time": r.name} - for _, r in subject_data.iterrows() - if r.id in subject_list - ), - skip_duplicates=True, - ) - - -@schema -class ExperimentLog(dj.Imported): - definition = """ # Experimenter's annotations - -> Chunk - """ - - class Message(dj.Part): - definition = """ - -> master - message_time: datetime(6) # datetime of the annotation - --- - message_type: varchar(32) - message: varchar(1000) - """ - - def make(self, key): - chunk_start, chunk_end = (Chunk & key).fetch1("chunk_start", "chunk_end") - - # Populate the part table - raw_data_dir = Experiment.get_data_directory(key) - devices_schema = getattr( - aeon_schemas, - (Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( - "devices_schema_name" - ), - ) - device = devices_schema.Environment - - try: - # handles corrupted files - issue: https://github.com/SainsburyWellcomeCentre/aeon_mecha/issues/153 - log_messages = io_api.load( - root=raw_data_dir.as_posix(), - reader=device.MessageLog, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - except IndexError: - logger.warning("Can't read from device.MessageLog") - log_messages = pd.DataFrame() - - env_states = io_api.load( - root=raw_data_dir.as_posix(), - reader=device.EnvironmentState, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - block_states = io_api.load( - root=raw_data_dir.as_posix(), - reader=device.BlockState, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1(key) - self.Message.insert( - ( - { - **key, - "message_time": r.name, - "message": r.message, - "message_type": r.type, - } - for _, r in log_messages.iterrows() - ), - skip_duplicates=True, - ) - self.Message.insert( - ( - { - **key, - "message_time": r.name, - "message": r.state, - "message_type": "EnvironmentState", - } - for _, r in env_states.iterrows() - ), - skip_duplicates=True, - ) - - # ------------------- ENVIRONMENT -------------------- @@ -856,422 +562,6 @@ def make(self, key): ) -# ------------------- EVENTS -------------------- - - -@schema -class FoodPatchEvent(dj.Imported): - definition = """ # events associated with a given ExperimentFoodPatch - -> Chunk - -> ExperimentFoodPatch - event_number: smallint - --- - event_time: datetime(6) # event time - -> EventType - """ - - @property - def key_source(self): - """Only the combination of Chunk and ExperimentFoodPatch with overlapping time - + Chunk(s) that started after FoodPatch install time and ended before FoodPatch remove time - + Chunk(s) that started after FoodPatch install time for FoodPatch that are not yet removed. - """ - - return ( - Chunk * ExperimentFoodPatch.join(ExperimentFoodPatch.RemovalTime, left=True) - & "chunk_start >= food_patch_install_time" - & 'chunk_start < IFNULL(food_patch_remove_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - food_patch_description = (ExperimentFoodPatch & key).fetch1("food_patch_description") - - raw_data_dir = Experiment.get_data_directory(key, directory_type=dir_type) - - devices_schema = getattr( - aeon_schemas, - (Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( - "devices_schema_name" - ), - ) - - device = getattr(devices_schema, food_patch_description) - - pellet_data = pd.concat( - [ - io_api.load( - root=raw_data_dir.as_posix(), - reader=device.DeliverPellet, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ), - io_api.load( - root=raw_data_dir.as_posix(), - reader=device.BeamBreak, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ), - ] - ) - pellet_data.sort_index(inplace=True) - - if not len(pellet_data): - event_list = [ - { - **key, - "event_number": 0, - "event_time": chunk_start, - "event_code": 1000, - } - ] - else: - event_code_mapper = { - name: code for code, name in zip(*EventType.fetch("event_code", "event_type")) - } - event_list = [ - { - **key, - "event_number": r_idx, - "event_time": r_time, - "event_code": event_code_mapper[r.event], - } - for r_idx, (r_time, r) in enumerate(pellet_data.iterrows()) - ] - - self.insert(event_list) - - -@schema -class FoodPatchWheel(dj.Imported): - definition = """ # Wheel data associated with a given ExperimentFoodPatch - -> Chunk - -> ExperimentFoodPatch - --- - timestamps: longblob # (datetime) timestamps of wheel encoder data - angle: longblob # measured angles of the wheel - intensity: longblob - """ - - @property - def key_source(self): - """Only the combination of Chunk and ExperimentFoodPatch with overlapping time - + Chunk(s) that started after FoodPatch install time and ended before FoodPatch remove time - + Chunk(s) that started after FoodPatch install time for FoodPatch that are not yet removed. - """ - return ( - Chunk * ExperimentFoodPatch.join(ExperimentFoodPatch.RemovalTime, left=True) - & "chunk_start >= food_patch_install_time" - & 'chunk_start < IFNULL(food_patch_remove_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - food_patch_description = (ExperimentFoodPatch & key).fetch1("food_patch_description") - - raw_data_dir = Experiment.get_data_directory(key, directory_type=dir_type) - - devices_schema = getattr( - aeon_schemas, - (Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( - "devices_schema_name" - ), - ) - - device = getattr(devices_schema, food_patch_description) - - wheel_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=device.Encoder, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - self.insert1( - { - **key, - "timestamps": wheel_data.index.values, - "angle": wheel_data.angle.values, - "intensity": wheel_data.intensity.values, - } - ) - - @classmethod - def get_wheel_data(cls, experiment_name, start, end, patch_name="Patch1", using_aeon_io=False): - if using_aeon_io: - key = {"experiment_name": experiment_name} - raw_data_dir = Experiment.get_data_directory(key) - - devices_schema = getattr( - aeon_schemas, - (Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( - "devices_schema_name" - ), - ) - - device = getattr(devices_schema, patch_name) - - wheel_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=device.Encoder, - start=pd.Timestamp(start), - end=pd.Timestamp(end), - ) - else: - table = cls * Chunk * ExperimentFoodPatch - obj_restriction = { - "experiment_name": experiment_name, - "food_patch_description": patch_name, - } - start_attr, end_attr = "chunk_start", "chunk_end" - fetch_attrs = ["timestamps", "angle"] - - start_restriction = f'"{start}" BETWEEN {start_attr} AND {end_attr}' - end_restriction = f'"{end}" BETWEEN {start_attr} AND {end_attr}' - - start_query = table & obj_restriction & start_restriction - end_query = table & obj_restriction & end_restriction - if not (start_query and end_query): - raise ValueError(f"No wheel data found between {start} and {end}") - - time_restriction = ( - f'{start_attr} >= "{start_query.fetch1(start_attr)}"' - f' AND {start_attr} < "{end_query.fetch1(end_attr)}"' - ) - - fetched_data = (table & obj_restriction & time_restriction).fetch( - *fetch_attrs, order_by=start_attr - ) - - if not len(fetched_data[0]): - raise ValueError(f"No wheel data found between {start} and {end}") - - timestamp_attr = next(attr for attr in fetch_attrs if "timestamps" in attr) - - # stack and structure in pandas DataFrame - wheel_data = pd.DataFrame({k: np.hstack(v) for k, v in zip(fetch_attrs, fetched_data)}) - wheel_data.set_index(timestamp_attr, inplace=True) - - time_mask = np.logical_and(wheel_data.index >= start, wheel_data.index < end) - - wheel_data = wheel_data[time_mask] - - wheel_data["distance_travelled"] = analysis_utils.distancetravelled(wheel_data["angle"]) - return wheel_data - - -@schema -class WheelState(dj.Imported): - definition = """ # Wheel states associated with a given ExperimentFoodPatch - -> Chunk - -> ExperimentFoodPatch - """ - - class Time(dj.Part): - definition = """ # Threshold, d1, delta state of the wheel - -> master - state_timestamp: datetime(6) - --- - threshold: float - d1: float - delta: float - """ - - @property - def key_source(self): - """Only the combination of Chunk and ExperimentFoodPatch with overlapping time - + Chunk(s) that started after FoodPatch install time and ended before FoodPatch remove time - + Chunk(s) that started after FoodPatch install time for FoodPatch that are not yet removed. - """ - return ( - Chunk * ExperimentFoodPatch.join(ExperimentFoodPatch.RemovalTime, left=True) - & "chunk_start >= food_patch_install_time" - & 'chunk_start < IFNULL(food_patch_remove_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - food_patch_description = (ExperimentFoodPatch & key).fetch1("food_patch_description") - raw_data_dir = Experiment.get_data_directory(key, directory_type=dir_type) - - devices_schema = getattr( - aeon_schemas, - (Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( - "devices_schema_name" - ), - ) - - device = getattr(devices_schema, food_patch_description) - - wheel_state = io_api.load( - root=raw_data_dir.as_posix(), - reader=device.DepletionState, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - # handles rare cases of duplicated state-timestamp - wheel_state = wheel_state[~wheel_state.index.duplicated(keep="first")] - - self.insert1(key) - self.Time.insert( - [ - { - **key, - "state_timestamp": r.name, - "threshold": r.threshold, - "d1": r.d1, - "delta": r.delta, - } - for _, r in wheel_state.iterrows() - ] - ) - - -@schema -class WeightMeasurement(dj.Imported): - definition = """ # Raw scale measurement associated with a given ExperimentScale - -> Chunk - -> ExperimentWeightScale - --- - timestamps: longblob # (datetime) timestamps of scale data - weight: longblob # measured weights - confidence: longblob # confidence level of the measured weights [0-1] - """ - - @property - def key_source(self): - """Only the combination of Chunk and ExperimentWeightScale with overlapping time - + Chunk(s) that started after WeightScale install time and ended before WeightScale remove time - + Chunk(s) that started after WeightScale install time for WeightScale that are not yet removed. - """ - return ( - Chunk * ExperimentWeightScale.join(ExperimentWeightScale.RemovalTime, left=True) - & "chunk_start >= weight_scale_install_time" - & 'chunk_start < IFNULL(weight_scale_remove_time, "2200-01-01")' - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - - raw_data_dir = Experiment.get_data_directory(key, directory_type=dir_type) - - weight_scale_description = (ExperimentWeightScale & key).fetch1("weight_scale_description") - - devices_schema = getattr( - aeon_schemas, - (Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( - "devices_schema_name" - ), - ) - - # in some epochs/chunks, the food patch device was mapped to "Nest" - for device_name in (weight_scale_description, "Nest"): - device = getattr(devices_schema, device_name) - weight_data = io_api.load( - root=raw_data_dir.as_posix(), - reader=device.WeightRaw, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - if len(weight_data): - break - else: - raise ValueError(f"No weight measurement found for {key}") - - weight_data.sort_index(inplace=True) - self.insert1( - { - **key, - "timestamps": weight_data.index.values, - "weight": weight_data.value.values, - "confidence": weight_data.stable.values.astype(float), - } - ) - - -@schema -class WeightMeasurementFiltered(dj.Imported): - definition = """ # Raw scale measurement associated with a given ExperimentScale - -> WeightMeasurement - --- - weight_filtered: longblob # measured weights filtered - weight_subject_timestamps: longblob # (datetime) timestamps of weight_subject data - weight_subject: longblob # - """ - - def make(self, key): - chunk_start, chunk_end, dir_type = (Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = Experiment.get_data_directory(key, directory_type=dir_type) - weight_scale_description = (ExperimentWeightScale & key).fetch1("weight_scale_description") - - devices_schema = getattr( - aeon_schemas, - (Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( - "devices_schema_name" - ), - ) - - # in some epochs/chunks, the food patch device was mapped to "Nest" - for device_name in (weight_scale_description, "Nest"): - device = getattr(devices_schema, device_name) - weight_filtered = io_api.load( - root=raw_data_dir.as_posix(), - reader=device.WeightFiltered, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - if len(weight_filtered): - break - else: - raise ValueError( - f"No filtered weight measurement found for {key} - this is truly unexpected - a bug?" - ) - - weight_subject = io_api.load( - root=raw_data_dir.as_posix(), - reader=device.WeightSubject, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - assert len(weight_filtered) - - weight_filtered.sort_index(inplace=True) - weight_subject.sort_index(inplace=True) - self.insert1( - { - **key, - "weight_filtered": weight_filtered.value.values, - "weight_subject_timestamps": weight_subject.index.values, - "weight_subject": weight_subject.value.values, - } - ) - - -# ---- Task Protocol categorization ---- - - -@schema -class TaskProtocol(dj.Lookup): - definition = """ - task_protocol: int - --- - protocol_params: longblob - protocol_description: varchar(255) - """ - - # ---- HELPERS ---- diff --git a/aeon/dj_pipeline/analysis/__init__.py b/aeon/dj_pipeline/analysis/__init__.py index 01c708ce..724cbb51 100644 --- a/aeon/dj_pipeline/analysis/__init__.py +++ b/aeon/dj_pipeline/analysis/__init__.py @@ -1,3 +1,2 @@ -from .in_arena import * from .visit import * from .visit_analysis import * diff --git a/aeon/dj_pipeline/analysis/in_arena.py b/aeon/dj_pipeline/analysis/in_arena.py deleted file mode 100644 index d10a8527..00000000 --- a/aeon/dj_pipeline/analysis/in_arena.py +++ /dev/null @@ -1,658 +0,0 @@ -import datetime - -import datajoint as dj -import numpy as np -import pandas as pd - -from aeon.analysis import utils as analysis_utils - -from .. import acquisition, get_schema_name, lab, qc, tracking - -schema = dj.schema(get_schema_name("analysis")) - -__all__ = [ - "schema", - "InArena", - "NeverExitedArena", - "InArenaEnd", - "InArenaTimeSlice", - "InArenaSubjectPosition", - "InArenaTimeDistribution", - "InArenaSummary", - "InArenaRewardRate", -] - -# ------------------- SESSION -------------------- - - -@schema -class InArena(dj.Computed): - definition = """ # A time period spanning the time when the animal first enters the arena to when it exits the arena - -> acquisition.Experiment.Subject - in_arena_start: datetime(6) - --- - -> [nullable] acquisition.TaskProtocol - """ - - @property - def key_source(self): - return ( - dj.U("experiment_name", "subject", "in_arena_start") - & ( - acquisition.SubjectEnterExit.Time * acquisition.EventType - & 'event_type = "SubjectEnteredArena"' - ).proj(in_arena_start="enter_exit_time") - & 'experiment_name in ("exp0.1-r0", "exp0.2-r0")' - ) - - def make(self, key): - self.insert1(key) - - -@schema -class NeverExitedArena(dj.Manual): - definition = """ # Bad InArena where the animal seemed to have never exited - -> InArena - """ - - -@schema -class InArenaEnd(dj.Computed): - definition = """ - -> InArena - --- - in_arena_end: datetime(6) - in_arena_duration: float # (hour) - """ - - key_source = InArena - NeverExitedArena & ( - InArena.proj() * acquisition.SubjectEnterExit.Time * acquisition.EventType - & 'event_type = "SubjectExitedArena"' - & "enter_exit_time > in_arena_start" - ) - - def make(self, key): - in_arena_start = key["in_arena_start"] - subject_exit = ( - acquisition.SubjectEnterExit.Time * acquisition.EventType - & {"subject": key["subject"]} - & f'enter_exit_time > "{in_arena_start}"' - & 'event_type != "SubjectRemainedInArena"' - ).fetch(as_dict=True, limit=1, order_by="enter_exit_time ASC")[0] - - if subject_exit["event_type"] != "SubjectExitedArena": - NeverExitedArena.insert1(key, skip_duplicates=True) - return - - in_arena_end = subject_exit["enter_exit_time"] - duration = (in_arena_end - in_arena_start).total_seconds() / 3600 - - # insert - self.insert1({**key, "in_arena_end": in_arena_end, "in_arena_duration": duration}) - - -# ------------------- TIMESLICE -------------------- - - -@schema -class InArenaTimeSlice(dj.Computed): - definition = """ - # A short time-slice (e.g. 10 minutes) of the recording of a given animal in the arena - -> InArena - -> acquisition.Chunk - time_slice_start: datetime(6) # datetime of the start of this time slice - --- - time_slice_end: datetime(6) # datetime of the end of this time slice - """ - - @property - def key_source(self): - """Chunk for all sessions: - + are not "NeverExitedSession" - + in_arena_start during this Chunk - i.e. first chunk of the session - + in_arena_end during this Chunk - i.e. last chunk of the session - + chunk starts after in_arena_start and ends before in_arena_end (or NOW() - i.e. session still on going). - """ - return ( - InArena.join(InArenaEnd, left=True).proj(in_arena_end="IFNULL(in_arena_end, NOW())") - * acquisition.Chunk - - NeverExitedArena - & acquisition.SubjectEnterExit - & [ - "in_arena_start BETWEEN chunk_start AND chunk_end", - "in_arena_end BETWEEN chunk_start AND chunk_end", - "chunk_start >= in_arena_start AND chunk_end <= in_arena_end", - ] - ) - - _time_slice_duration = datetime.timedelta(hours=0, minutes=10, seconds=0) - - def make(self, key): - chunk_start, chunk_end = (acquisition.Chunk & key).fetch1("chunk_start", "chunk_end") - - # -- Determine the time to start time_slicing in this chunk - if chunk_start < key["in_arena_start"] < chunk_end: - # For chunk containing the in_arena_start - i.e. first chunk of this session - start_time = key["in_arena_start"] - else: - # For chunks after the first chunk of this session - start_time = chunk_start - - # -- Determine the time to end time_slicing in this chunk - # get the enter/exit events in this chunk that are after the in_arena_start - next_enter_exit_events = ( - acquisition.SubjectEnterExit.Time * acquisition.EventType - & key - & f'enter_exit_time > "{key["in_arena_start"]}"' - ) - if not next_enter_exit_events: - # No enter/exit event: time_slices from this whole chunk - end_time = chunk_end - else: - next_event = next_enter_exit_events.fetch( - as_dict=True, order_by="enter_exit_time DESC", limit=1 - )[0] - if next_event["event_type"] == "SubjectEnteredArena": - NeverExitedArena.insert1(key, ignore_extra_fields=True, skip_duplicates=True) - return - end_time = next_event["enter_exit_time"] - - chunk_time_slices = [] - time_slice_start = start_time - while time_slice_start < end_time: - time_slice_end = time_slice_start + min(self._time_slice_duration, end_time - time_slice_start) - chunk_time_slices.append( - { - **key, - "time_slice_start": time_slice_start, - "time_slice_end": time_slice_end, - } - ) - time_slice_start = time_slice_end - - self.insert(chunk_time_slices) - - -# ---------- Subject Position ------------------ - - -@schema -class InArenaSubjectPosition(dj.Imported): - definition = """ - -> InArenaTimeSlice - --- - timestamps: longblob # (datetime) timestamps of the position data - position_x: longblob # (px) animal's x-position, in the arena's coordinate frame - position_y: longblob # (px) animal's y-position, in the arena's coordinate frame - position_z=null: longblob # (px) animal's z-position, in the arena's coordinate frame - area=null: longblob # (px^2) animal's size detected in the camera - speed=null: longblob # (px/s) speed - """ - - key_source = InArenaTimeSlice & ( - tracking.CameraTracking * acquisition.ExperimentCamera - & f"camera_description in {tuple(set(acquisition._ref_device_mapping.values()))}" - & "tracking_paramset_id = 0" - ) - - def make(self, key): - """The populate logic here relies on the assumption that there is only one subject in the arena at a time. The positiondata is associated with that one subject currently in the arena at any timepoints. For multi-animal experiments, a mapping of object_id-to-subject is needed to populate the right position data associated with a particular animal.""" - time_slice_start, time_slice_end = (InArenaTimeSlice & key).fetch1( - "time_slice_start", "time_slice_end" - ) - - camera_name = acquisition._ref_device_mapping[key["experiment_name"]] - - positiondata = tracking.CameraTracking.get_object_position( - experiment_name=key["experiment_name"], - camera_name=camera_name, - object_id=-1, - start=time_slice_start, - end=time_slice_end, - ) - - if not len(positiondata): - raise ValueError(f"No position data between {time_slice_start} and {time_slice_end}") - - timestamps = positiondata.index.values - x = positiondata.position_x.values - y = positiondata.position_y.values - z = np.full_like(x, 0.0) - area = positiondata.area.values - - # speed - TODO: confirm with aeon team if this calculation is sufficient (any smoothing needed?) - position_diff = np.sqrt(np.square(np.diff(x)) + np.square(np.diff(y)) + np.square(np.diff(z))) - time_diff = np.diff(timestamps) / np.timedelta64(1, "s") - speed = position_diff / time_diff - speed = np.hstack((speed[0], speed)) - - self.insert1( - { - **key, - "timestamps": timestamps, - "position_x": x, - "position_y": y, - "position_z": z, - "area": area, - "speed": speed, - } - ) - - @classmethod - def get_position(cls, in_arena_key): - """Given a key to a single InArena, return a Pandas DataFrame for the position data of the subject for the specified InArena time period.""" - assert len(InArena & in_arena_key) == 1 - - start, end = (InArena * InArenaEnd & in_arena_key).fetch1("in_arena_start", "in_arena_end") - - return tracking._get_position( - cls * InArenaTimeSlice.proj("time_slice_end"), - object_attr="subject", - object_name=in_arena_key["subject"], - start_attr="time_slice_start", - end_attr="time_slice_end", - start=start, - end=end, - fetch_attrs=["timestamps", "position_x", "position_y", "speed", "area"], - attrs_to_scale=["position_x", "position_y", "speed"], - scale_factor=tracking.pixel_scale, - ) - - -# -------------- InArena-level Quality Control --------------------- - - -@schema -class InArenaQC(dj.Computed): - definition = """ # Quality control performed on each InArena period - -> InArena - """ - - class Routine(dj.Part): - definition = """ # Quality control routine performed on each InArena period - -> master - -> qc.QCRoutine - --- - -> qc.QCCode - qc_comment: varchar(255) - """ - - class BadPeriod(dj.Part): - definition = """ - -> master.Routine - bad_period_start: datetime(6) - --- - bad_period_end: datetime(6) - """ - - def make(self, key): - # depending on which qc_routine - # fetch relevant data from upstream - # import the qc_function from the qc_module - # call the qc_function - expecting a qc_code back, and a list of bad-periods - # store qc results - pass - - -@schema -class BadInArena(dj.Computed): - definition = """ # InArena period labelled as BadInArena and excluded from further analysis - -> InArena - --- - comment='': varchar(255) # any comments for why this is a bad InArena time period - e.g. manually flagged - """ - - -@schema -class GoodInArena(dj.Computed): - definition = """ # InArena determined to be good from quality control assessment - -> InArenaQC - --- - qc_routines: varchar(255) # concatenated list of all the QC routines used for this good/bad conclusion - """ - - class BadPeriod(dj.Part): - definition = """ - -> master - bad_period_start: datetime(6) - --- - bad_period_end: datetime(6) - """ - - def make(self, key): - # aggregate all SessionQC results for this session - # determine Good or Bad Session - # insert BadPeriod (if none inserted, no bad period) - pass - - -# -------------- InArena-level analysis --------------------- - - -@schema -class InArenaTimeDistribution(dj.Computed): - definition = """ - -> InArena - --- - time_fraction_in_corridor: float # fraction of time the animal spent in the corridor in this session - in_corridor: longblob # array of boolean for if the animal is in the corridor (same length as position data) - time_fraction_in_arena: float # fraction of time the animal spent in the arena in this session - in_arena: longblob # array of boolean for if the animal is in the arena (same length as position data) - """ - - class Nest(dj.Part): - definition = """ # Time spent in nest - -> master - -> lab.ArenaNest - --- - time_fraction_in_nest: float # fraction of time the animal spent in this nest in this session - in_nest: longblob # array of boolean for if the animal is in this nest (same length as position data) - """ - - class FoodPatch(dj.Part): - definition = """ # Time spent in food patch - -> master - -> acquisition.ExperimentFoodPatch - --- - time_fraction_in_patch: float # fraction of time the animal spent on this patch in this session - in_patch: longblob # array of boolean for if the animal is in this patch (same length as position data) - """ - - # Work on finished Session with TimeSlice and SubjectPosition fully populated only - key_source = ( - InArena - & (InArena * InArenaEnd * InArenaTimeSlice & "time_slice_end = in_arena_end").proj() - & ( - InArena.aggr(InArenaTimeSlice, time_slice_count="count(time_slice_start)") - * InArena.aggr(InArenaSubjectPosition, tracking_count="count(time_slice_start)") - & "time_slice_count = tracking_count" - ) - ) - - def make(self, key): - in_arena_start, in_arena_end = (InArena * InArenaEnd & key).fetch1("in_arena_start", "in_arena_end") - - # subject's position data in the time_slices - position = InArenaSubjectPosition.get_position(key) - position.rename({"position_x": "x", "position_y": "y"}, inplace=True) - - # filter for objects of the correct size - valid_position = (position.area > 0) & (position.area < 1000) - position[~valid_position] = np.nan - - # in corridor - distance_from_center = tracking.compute_distance( - position[["x", "y"]], - (tracking.arena_center_x, tracking.arena_center_y), - ) - in_corridor = (distance_from_center < tracking.arena_outer_radius) & ( - distance_from_center > tracking.arena_inner_radius - ) - - in_arena = ~in_corridor - - # in nests - loop through all nests in this experiment - in_nest_times = [] - for nest_key in (lab.ArenaNest & key).fetch("KEY"): - in_nest = tracking.is_position_in_nest(position, nest_key) - in_nest_times.append( - { - **key, - **nest_key, - "time_fraction_in_nest": in_nest.mean(), - "in_nest": in_nest, - } - ) - in_arena = in_arena & ~in_nest - - # in food patches - loop through all in-use patches during this session - food_patch_keys = ( - InArena - * InArenaEnd - * acquisition.ExperimentFoodPatch.join(acquisition.ExperimentFoodPatch.RemovalTime, left=True) - & key - & "in_arena_start >= food_patch_install_time" - & 'in_arena_end < IFNULL(food_patch_remove_time, "2200-01-01")' - ).fetch("KEY") - - in_food_patch_times = [] - for food_patch_key in food_patch_keys: - # wheel data - food_patch_description = (acquisition.ExperimentFoodPatch & food_patch_key).fetch1( - "food_patch_description" - ) - wheel_data = acquisition.FoodPatchWheel.get_wheel_data( - experiment_name=key["experiment_name"], - start=pd.Timestamp(in_arena_start), - end=pd.Timestamp(in_arena_end), - patch_name=food_patch_description, - using_aeon_io=True, - ) - - patch_position = (acquisition.ExperimentFoodPatch.Position & food_patch_key).fetch1( - "food_patch_position_x", "food_patch_position_y" - ) - - in_patch = tracking.is_position_in_patch( - position, - patch_position, - wheel_data.distance_travelled, - patch_radius=0.2, - ) - - in_food_patch_times.append( - { - **key, - **food_patch_key, - "time_fraction_in_patch": in_patch.mean(), - "in_patch": in_patch.values, - } - ) - - in_arena = in_arena & ~in_patch - - self.insert1( - { - **key, - "time_fraction_in_corridor": in_corridor.mean(), - "in_corridor": in_corridor.values, - "time_fraction_in_arena": in_arena.mean(), - "in_arena": in_arena.values, - } - ) - self.Nest.insert(in_nest_times) - self.FoodPatch.insert(in_food_patch_times) - - -@schema -class InArenaSummary(dj.Computed): - definition = """ - -> InArena - --- - total_distance_travelled: float # (m) total distance the animal travelled during this session - total_pellet_count: int # total pellet delivered (triggered) for all patches during this session - total_wheel_distance_travelled: float # total wheel travelled distance for all patches - change_in_weight: float # weight change before/after the session - """ - - class FoodPatch(dj.Part): - definition = """ - -> master - -> acquisition.ExperimentFoodPatch - --- - pellet_count: int # number of pellets being delivered (triggered) by this patch during this session - wheel_distance_travelled: float # wheel travelled distance during this session for this patch - """ - - # Work on finished Session with TimeSlice and SubjectPosition fully populated only - key_source = ( - InArena - & (InArena * InArenaEnd * InArenaTimeSlice & "time_slice_end = in_arena_end").proj() - & ( - InArena.aggr(InArenaTimeSlice, time_slice_count="count(time_slice_start)") - * InArena.aggr(InArenaSubjectPosition, tracking_count="count(time_slice_start)") - & "time_slice_count = tracking_count" - ) - ) - - def make(self, key): - in_arena_start, in_arena_end = (InArena * InArenaEnd & key).fetch1("in_arena_start", "in_arena_end") - - # subject weights - weight_start = (acquisition.SubjectWeight.WeightTime & f'weight_time = "{in_arena_start}"').fetch1( - "weight" - ) - weight_end = (acquisition.SubjectWeight.WeightTime & f'weight_time = "{in_arena_end}"').fetch1( - "weight" - ) - - # subject's position data in this session - position = InArenaSubjectPosition.get_position(key) - position.rename({"position_x": "x", "position_y": "y"}, inplace=True) - - valid_position = (position.area > 0) & ( - position.area < 1000 - ) # filter for objects of the correct size - position = position[valid_position] - - position_diff = np.sqrt(np.square(np.diff(position.x)) + np.square(np.diff(position.y))) - total_distance_travelled = np.nansum(position_diff) - - # food patch data - food_patch_keys = ( - InArena - * InArenaEnd - * acquisition.ExperimentFoodPatch.join(acquisition.ExperimentFoodPatch.RemovalTime, left=True) - & key - & "in_arena_start >= food_patch_install_time" - & 'in_arena_end < IFNULL(food_patch_remove_time, "2200-01-01")' - ).fetch("KEY") - - food_patch_statistics = [] - for food_patch_key in food_patch_keys: - pellet_events = ( - acquisition.FoodPatchEvent * acquisition.EventType - & food_patch_key - & 'event_type = "TriggerPellet"' - & f'event_time BETWEEN "{in_arena_start}" AND "{in_arena_end}"' - ).fetch("event_time") - # wheel data - food_patch_description = (acquisition.ExperimentFoodPatch & food_patch_key).fetch1( - "food_patch_description" - ) - wheel_data = acquisition.FoodPatchWheel.get_wheel_data( - experiment_name=key["experiment_name"], - start=pd.Timestamp(in_arena_start), - end=pd.Timestamp(in_arena_end), - patch_name=food_patch_description, - using_aeon_io=True, - ) - - food_patch_statistics.append( - { - **key, - **food_patch_key, - "pellet_count": len(pellet_events), - "wheel_distance_travelled": wheel_data.distance_travelled.values[-1], - } - ) - - total_pellet_count = np.sum([p["pellet_count"] for p in food_patch_statistics]) - total_wheel_distance_travelled = np.sum( - [p["wheel_distance_travelled"] for p in food_patch_statistics] - ) - - self.insert1( - { - **key, - "total_pellet_count": total_pellet_count, - "total_wheel_distance_travelled": total_wheel_distance_travelled, - "change_in_weight": weight_end - weight_start, - "total_distance_travelled": total_distance_travelled, - } - ) - self.FoodPatch.insert(food_patch_statistics) - - -@schema -class InArenaRewardRate(dj.Computed): - definition = """ - -> InArena - --- - pellet_rate_timestamps: longblob # timestamps of the pellet rate over time - patch2_patch1_rate_diff: longblob # rate differences between Patch 2 and Patch 1 - """ - - class FoodPatch(dj.Part): - definition = """ - -> master - -> acquisition.ExperimentFoodPatch - --- - pellet_rate: longblob # computed rate of pellet delivery over time - """ - - key_source = InArenaSummary() - - def make(self, key): - in_arena_start, in_arena_end = (InArena * InArenaEnd & key).fetch1("in_arena_start", "in_arena_end") - - # food patch data - food_patch_keys = ( - ( - InArena - * InArenaEnd - * acquisition.ExperimentFoodPatch.join( - acquisition.ExperimentFoodPatch.RemovalTime, left=True - ) - & key - & "in_arena_start >= food_patch_install_time" - & 'in_arena_end < IFNULL(food_patch_remove_time, "2200-01-01")' - ) - .proj("food_patch_description") - .fetch(as_dict=True) - ) - - pellet_rate_timestamps = None - rates = {} - food_patch_reward_rates = [] - for food_patch_key in food_patch_keys: - no_pellets = False - pellet_events = ( - acquisition.FoodPatchEvent * acquisition.EventType - & food_patch_key - & 'event_type = "TriggerPellet"' - & f'event_time BETWEEN "{in_arena_start}" AND "{in_arena_end}"' - ).fetch("event_time") - - if not pellet_events.size: - pellet_events = np.array([in_arena_start, in_arena_end]) - no_pellets = True - - pellet_rate = analysis_utils.get_events_rates( - events=pd.DataFrame({"event_time": pellet_events}).set_index("event_time"), - window_len_sec=600, - start=pd.Timestamp(in_arena_start), - end=pd.Timestamp(in_arena_end), - frequency="5s", - smooth="120s", - center=True, - ) - - if no_pellets: - pellet_rate = pd.Series(index=pellet_rate.index, data=np.full(len(pellet_rate), 0)) - - if pellet_rate_timestamps is None: - pellet_rate_timestamps = pellet_rate.index.values - - rates[food_patch_key.pop("food_patch_description")] = pellet_rate.values - - food_patch_reward_rates.append({**key, **food_patch_key, "pellet_rate": pellet_rate.values}) - - self.insert1( - { - **key, - "pellet_rate_timestamps": pellet_rate_timestamps, - "patch2_patch1_rate_diff": rates["Patch2"] - rates["Patch1"], - } - ) - self.FoodPatch.insert(food_patch_reward_rates) diff --git a/aeon/dj_pipeline/analysis/visit_analysis.py b/aeon/dj_pipeline/analysis/visit_analysis.py index f66094f6..4569193e 100644 --- a/aeon/dj_pipeline/analysis/visit_analysis.py +++ b/aeon/dj_pipeline/analysis/visit_analysis.py @@ -15,7 +15,8 @@ ) logger = dj.logger -schema = dj.schema(get_schema_name("analysis")) +# schema = dj.schema(get_schema_name("analysis")) +schema = dj.schema() # ---------- Position Filtering Method ------------------ diff --git a/aeon/dj_pipeline/qc.py b/aeon/dj_pipeline/qc.py index d35019d0..b33a030b 100644 --- a/aeon/dj_pipeline/qc.py +++ b/aeon/dj_pipeline/qc.py @@ -4,7 +4,8 @@ from aeon.io import api as io_api -from . import acquisition, get_schema_name +from aeon.dj_pipeline import get_schema_name +from aeon.dj_pipeline import acquisition, streams schema = dj.schema(get_schema_name("qc")) @@ -39,7 +40,7 @@ class QCRoutine(dj.Lookup): class CameraQC(dj.Imported): definition = """ # Quality controls performed on a particular camera for a particular acquisition chunk -> acquisition.Chunk - -> acquisition.ExperimentCamera + -> streams.SpinnakerVideoSource --- drop_count=null: int max_harp_delta: float # (s) @@ -56,23 +57,32 @@ class CameraQC(dj.Imported): def key_source(self): return ( acquisition.Chunk - * acquisition.ExperimentCamera.join(acquisition.ExperimentCamera.RemovalTime, left=True) - & "chunk_start >= camera_install_time" - & 'chunk_start < IFNULL(camera_remove_time, "2200-01-01")' - ) + * ( + streams.SpinnakerVideoSource.join(streams.SpinnakerVideoSource.RemovalTime, left=True) + & "spinnaker_video_source_name='CameraTop'" + ) + & "chunk_start >= spinnaker_video_source_install_time" + & 'chunk_start < IFNULL(spinnaker_video_source_removal_time, "2200-01-01")' + ) # CameraTop def make(self, key): chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( "chunk_start", "chunk_end", "directory_type" ) - camera = (acquisition.ExperimentCamera & key).fetch1("camera_description") + device_name = (streams.SpinnakerVideoSource & key).fetch1("spinnaker_video_source_name") raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - device = getattr(acquisition._device_schema_mapping[key["experiment_name"]], camera) + devices_schema = getattr( + acquisition.aeon_schemas, + (acquisition.Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( + "devices_schema_name" + ), + ) + stream_reader = getattr(getattr(devices_schema, device_name), "Video") videodata = io_api.load( root=raw_data_dir.as_posix(), - reader=device.Video, + reader=stream_reader, start=pd.Timestamp(chunk_start), end=pd.Timestamp(chunk_end), ).reset_index() diff --git a/aeon/dj_pipeline/tracking.py b/aeon/dj_pipeline/tracking.py index d2460fcd..225430fa 100644 --- a/aeon/dj_pipeline/tracking.py +++ b/aeon/dj_pipeline/tracking.py @@ -103,126 +103,6 @@ def insert_new_params( cls.insert1(param_dict) -# ---------- Video Tracking ------------------ - - -@schema -class CameraTracking(dj.Imported): - definition = """ # Tracked objects position data from a particular camera, using a particular tracking method, for a particular chunk - -> acquisition.Chunk - -> acquisition.ExperimentCamera - -> TrackingParamSet - """ - - class Object(dj.Part): - definition = """ # Position data of object tracked by a particular camera tracking - -> master - object_id: int # object with id = -1 means "unknown/not sure", could potentially be the same object as those with other id value - --- - timestamps: longblob # (datetime) timestamps of the position data - position_x: longblob # (px) object's x-position, in the arena's coordinate frame - position_y: longblob # (px) object's y-position, in the arena's coordinate frame - area=null: longblob # (px^2) object's size detected in the camera - """ - - @property - def key_source(self): - ks = acquisition.Chunk * acquisition.ExperimentCamera * TrackingParamSet - return ( - ks - * ( - qc.CameraQC * acquisition.ExperimentCamera - & f"camera_description in {tuple(set(acquisition._ref_device_mapping.values()))}" - ).proj() - & "tracking_paramset_id = 0" - ) - - def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - camera = (acquisition.ExperimentCamera & key).fetch1("camera_description") - - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) - - device = getattr(acquisition._device_schema_mapping[key["experiment_name"]], camera) - - positiondata = io_api.load( - root=raw_data_dir.as_posix(), - reader=device.Position, - start=pd.Timestamp(chunk_start), - end=pd.Timestamp(chunk_end), - ) - - # replace id=NaN with -1 - positiondata.fillna({"id": -1}, inplace=True) - - # Retrieve frame offsets from Camera QC - qc_timestamps, qc_frame_offsets, camera_fs = ( - qc.CameraQC * acquisition.ExperimentCamera & key - ).fetch1("timestamps", "frame_offset", "camera_sampling_rate") - - # For cases where position data is shorter than video data (from QC) - truncate video data - # - fix for issue: https://github.com/SainsburyWellcomeCentre/aeon_mecha/issues/130 - max_frame_count = min(len(positiondata), len(qc_timestamps)) - qc_frame_offsets = qc_frame_offsets[:max_frame_count] - positiondata = positiondata[:max_frame_count] - - # Correct for frame offsets from Camera QC - qc_time_offsets = qc_frame_offsets / camera_fs - qc_time_offsets = np.where(np.isnan(qc_time_offsets), 0, qc_time_offsets) # set NaNs to 0 - positiondata.index += pd.to_timedelta(qc_time_offsets, "s") - - object_positions = [] - for obj_id in set(positiondata.id.values): - obj_position = positiondata[positiondata.id == obj_id] - - object_positions.append( - { - **key, - "object_id": obj_id, - "timestamps": obj_position.index.values, - "position_x": obj_position.x.values, - "position_y": obj_position.y.values, - "area": obj_position.area.values, - } - ) - - self.insert1(key) - self.Object.insert(object_positions) - - @classmethod - def get_object_position( - cls, - experiment_name, - object_id, - start, - end, - camera_name="FrameTop", - tracking_paramset_id=0, - in_meter=False, - ): - table = ( - cls.Object * acquisition.Chunk.proj("chunk_end") - & {"experiment_name": experiment_name} - & {"tracking_paramset_id": tracking_paramset_id} - & (acquisition.ExperimentCamera & {"camera_description": camera_name}) - ) - - return _get_position( - table, - object_attr="object_id", - object_name=object_id, - start_attr="chunk_start", - end_attr="chunk_end", - start=start, - end=end, - fetch_attrs=["timestamps", "position_x", "position_y", "area"], - attrs_to_scale=["position_x", "position_y"], - scale_factor=pixel_scale if in_meter else 1, - ) - - # ---------- VideoSource ------------------ From 81fc3a2e3213aaf9011af30719d05c76cd26bb1f Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 30 Jan 2024 16:26:18 -0600 Subject: [PATCH 399/489] update(report): disable `report` schema for now --- aeon/dj_pipeline/report.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/report.py b/aeon/dj_pipeline/report.py index c87137af..ec88ae7c 100644 --- a/aeon/dj_pipeline/report.py +++ b/aeon/dj_pipeline/report.py @@ -14,7 +14,8 @@ from . import acquisition, analysis, get_schema_name -schema = dj.schema(get_schema_name("report")) +# schema = dj.schema(get_schema_name("report")) +schema = dj.schema() os.environ["DJ_SUPPORT_FILEPATH_MANAGEMENT"] = "TRUE" From 9b654f4ba6ca257499c1011e633c01407b9b1127 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 30 Jan 2024 17:24:37 -0600 Subject: [PATCH 400/489] update(worker): update worker --- aeon/dj_pipeline/analysis/__init__.py | 2 - aeon/dj_pipeline/analysis/block_analysis.py | 2 +- aeon/dj_pipeline/populate/worker.py | 60 ++++++++------------- 3 files changed, 23 insertions(+), 41 deletions(-) diff --git a/aeon/dj_pipeline/analysis/__init__.py b/aeon/dj_pipeline/analysis/__init__.py index 724cbb51..e69de29b 100644 --- a/aeon/dj_pipeline/analysis/__init__.py +++ b/aeon/dj_pipeline/analysis/__init__.py @@ -1,2 +0,0 @@ -from .visit import * -from .visit_analysis import * diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index 342528f6..a5c7ecd9 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -280,7 +280,7 @@ def make(self, key): @schema class BlockDetection(dj.Computed): definition = """ - -> acquisition.Chunk + -> acquisition.Environment """ def make(self, key): diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py index 25b910d1..1ed6f703 100644 --- a/aeon/dj_pipeline/populate/worker.py +++ b/aeon/dj_pipeline/populate/worker.py @@ -2,14 +2,16 @@ from datajoint_utilities.dj_worker import DataJointWorker, ErrorLog, WorkerLog from datajoint_utilities.dj_worker.worker_schema import is_djtable -from aeon.dj_pipeline import subject, acquisition, analysis, db_prefix, qc, report, tracking +from aeon.dj_pipeline import db_prefix +from aeon.dj_pipeline import subject, acquisition, tracking, qc +from aeon.dj_pipeline.analysis import block_analysis from aeon.dj_pipeline.utils import streams_maker streams = streams_maker.main() __all__ = [ "acquisition_worker", - "mid_priority", + "analysis_worker", "pyrat_worker", "streams_worker", "WorkerLog", @@ -45,7 +47,8 @@ def ingest_epochs_chunks(): def ingest_environment_visits(): """Extract and insert complete visits for experiments specified in AutomatedExperimentIngestion.""" experiment_names = AutomatedExperimentIngestion.fetch("experiment_name") - analysis.ingest_environment_visits(experiment_names) + # analysis.ingest_environment_visits(experiment_names) + pass # ---- Define worker(s) ---- @@ -58,42 +61,9 @@ def ingest_environment_visits(): sleep_duration=1200, ) acquisition_worker(ingest_epochs_chunks) -acquisition_worker(acquisition.ExperimentLog) -acquisition_worker(acquisition.SubjectEnterExit) -acquisition_worker(acquisition.SubjectWeight) -acquisition_worker(acquisition.FoodPatchEvent) -acquisition_worker(acquisition.WheelState) - +acquisition_worker(acquisition.Environment) acquisition_worker(ingest_environment_visits) - -# configure a worker to process mid-priority tasks -mid_priority = DataJointWorker( - "mid_priority", - worker_schema_name=worker_schema_name, - db_prefix=db_prefix, - run_duration=-1, - sleep_duration=3600, -) - -mid_priority(qc.CameraQC) -mid_priority(tracking.CameraTracking) -mid_priority(acquisition.FoodPatchWheel) -mid_priority(acquisition.WeightMeasurement) -mid_priority(acquisition.WeightMeasurementFiltered) - -mid_priority(analysis.OverlapVisit) - -mid_priority(analysis.VisitSubjectPosition) -mid_priority(analysis.VisitTimeDistribution) -mid_priority(analysis.VisitSummary) -mid_priority(analysis.VisitForagingBout) - -# report tables -mid_priority(report.delete_outdated_plot_entries) -mid_priority(report.SubjectRewardRateDifference) -mid_priority(report.SubjectWheelTravelledDistance) -mid_priority(report.ExperimentTimeDistribution) -mid_priority(report.VisitDailySummaryPlot) +acquisition_worker(block_analysis.BlockDetection) # configure a worker to handle pyrat sync pyrat_worker = DataJointWorker( @@ -121,3 +91,17 @@ def ingest_environment_visits(): for attr in vars(streams).values(): if is_djtable(attr, dj.user_tables.AutoPopulate): streams_worker(attr, max_calls=10) + +streams_worker(qc.CameraQC, max_calls=10) +streams_worker(tracking.SLEAPTracking, max_calls=10) + +# configure a worker to run the analysis tables +analysis_worker = DataJointWorker( + "analysis_worker", + worker_schema_name=worker_schema_name, + db_prefix=db_prefix, + run_duration=-1, + sleep_duration=3600, +) + +analysis_worker(block_analysis.BlockAnalysis) From bb5a676bc00ab36cab1a0712f436f9b6a48f6b86 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 30 Jan 2024 18:03:46 -0600 Subject: [PATCH 401/489] fix: :bug: fix type annotation error with __future__ --- aeon/util.py | 1 + 1 file changed, 1 insertion(+) diff --git a/aeon/util.py b/aeon/util.py index aed34803..a9dc88bc 100644 --- a/aeon/util.py +++ b/aeon/util.py @@ -1,4 +1,5 @@ """Utility functions.""" +from __future__ import annotations from typing import Any From 6edb3b0d5818aea4b68cdfb8482e6842d0f82832 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 30 Jan 2024 19:03:59 -0600 Subject: [PATCH 402/489] chore(dj_pipeline): remove obsolete tables, helper functions --- aeon/dj_pipeline/acquisition.py | 84 +++++++------------------------ aeon/dj_pipeline/lab.py | 88 --------------------------------- 2 files changed, 17 insertions(+), 155 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 39121f2f..dafb8094 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -230,27 +230,25 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): continue epoch_config, metadata_yml_filepath = None, None - if experiment_name != "exp0.1-r0": - metadata_yml_filepath = epoch_dir / "Metadata.yml" - if metadata_yml_filepath.exists(): - epoch_config = extract_epoch_config( - experiment_name, devices_schema, metadata_yml_filepath - ) - metadata_yml_filepath = epoch_config["metadata_file_path"] + metadata_yml_filepath = epoch_dir / "Metadata.yml" + if metadata_yml_filepath.exists(): + epoch_config = extract_epoch_config(experiment_name, devices_schema, metadata_yml_filepath) - _, directory, repo_path = _match_experiment_directory( - experiment_name, - epoch_config["metadata_file_path"], - raw_data_dirs, - ) - epoch_config = { - **epoch_config, - **directory, - "metadata_file_path": epoch_config["metadata_file_path"] - .relative_to(repo_path) - .as_posix(), - } + metadata_yml_filepath = epoch_config["metadata_file_path"] + + _, directory, repo_path = _match_experiment_directory( + experiment_name, + epoch_config["metadata_file_path"], + raw_data_dirs, + ) + epoch_config = { + **epoch_config, + **directory, + "metadata_file_path": epoch_config["metadata_file_path"] + .relative_to(repo_path) + .as_posix(), + } # find previous epoch end-time previous_epoch_key = None @@ -604,54 +602,6 @@ def _match_experiment_directory(experiment_name, path, directories): return raw_data_dir, directory, repo_path -def _load_legacy_subjectdata(experiment_name, data_dir, start, end): - assert experiment_name in ("exp0.1-r0", "social0-r1") - - reader = io_reader.Subject("SessionData_2") - subject_data = io_api.load( - data_dir, - reader=reader, - start=start, - end=end, - ) - - subject_data.replace("Start", "Enter", inplace=True) - subject_data.replace("End", "Exit", inplace=True) - - if not len(subject_data): - return subject_data - - if experiment_name == "social0-r1": - from aeon.dj_pipeline.create_experiments.create_socialexperiment_0 import fixID - - sessdf = subject_data.copy() - sessdf = sessdf[~sessdf.id.str.contains("test")] - sessdf = sessdf[~sessdf.id.str.contains("jeff")] - sessdf = sessdf[~sessdf.id.str.contains("OAA")] - sessdf = sessdf[~sessdf.id.str.contains("rew")] - sessdf = sessdf[~sessdf.id.str.contains("Animal")] - sessdf = sessdf[~sessdf.id.str.contains("white")] - - valid_ids = (Experiment.Subject & {"experiment_name": experiment_name}).fetch("subject") - - fix = lambda x: fixID(x, valid_ids=list(valid_ids)) - sessdf.id = sessdf.id.apply(fix) - - multi_ids = sessdf[sessdf.id.str.contains(";")] - multi_ids_rows = [] - for _, r in multi_ids.iterrows(): - for i in r.id.split(";"): - multi_ids_rows.append({"time": r.name, "id": i, "weight": r.weight, "event": r.event}) - multi_ids_rows = pd.DataFrame(multi_ids_rows) - if len(multi_ids_rows): - multi_ids_rows.set_index("time", inplace=True) - - subject_data = pd.concat([sessdf[~sessdf.id.str.contains(";")], multi_ids_rows]) - subject_data.sort_index(inplace=True) - - return subject_data - - def create_chunk_restriction(experiment_name, start_time, end_time): """ Create a time restriction string for the chunks between the specified "start" and "end" times diff --git a/aeon/dj_pipeline/lab.py b/aeon/dj_pipeline/lab.py index 409763df..2f10665f 100644 --- a/aeon/dj_pipeline/lab.py +++ b/aeon/dj_pipeline/lab.py @@ -64,13 +64,6 @@ class Location(dj.Lookup): ] -@schema -class UserRole(dj.Lookup): - definition = """ - user_role : varchar(16) - """ - - @schema class User(dj.Lookup): definition = """ @@ -81,63 +74,6 @@ class User(dj.Lookup): """ -@schema -class LabMembership(dj.Lookup): - definition = """ - -> Lab - -> User - --- - -> [nullable] UserRole - """ - - -@schema -class ProtocolType(dj.Lookup): - definition = """ - protocol_type : varchar(32) - """ - - -@schema -class Protocol(dj.Lookup): - definition = """ - # protocol approved by some institutions like IACUC, IRB - protocol : varchar(16) - --- - -> ProtocolType - protocol_description='' : varchar(255) - """ - - -@schema -class Project(dj.Lookup): - definition = """ - project : varchar(32) - --- - project_description='' : varchar(1024) - """ - - -@schema -class ProjectUser(dj.Manual): - definition = """ - -> Project - -> User - """ - - -@schema -class Source(dj.Lookup): - definition = """ - # source or supplier of animals - source : varchar(32) # abbreviated source name - --- - source_name : varchar(255) - contact_details='' : varchar(255) - source_description='' : varchar(255) - """ - - # ------------------- ARENA INFORMATION -------------------- @@ -208,27 +144,3 @@ class Vertex(dj.Part): vertex_y: float # (m) y-coordinate of the vertex, in the arena's coordinate frame vertex_z=0: float # (m) z-coordinate of the vertex, in the arena's coordinate frame """ - - -# ------------------- EQUIPMENTS -------------------- - - -@schema -class Camera(dj.Lookup): - definition = """ # Physical cameras, identified by unique serial number - camera_serial_number: varchar(12) - """ - - -@schema -class FoodPatch(dj.Lookup): - definition = """ # Physical food patch devices, identified by unique serial number - food_patch_serial_number: varchar(12) - """ - - -@schema -class WeightScale(dj.Lookup): - definition = """ # Physical weight scale devices, identified by unique serial number - weight_scale_serial_number: varchar(12) - """ From 80b0b699e6738557dbd363fc9aef97ef289f9ea8 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 30 Jan 2024 19:05:52 -0600 Subject: [PATCH 403/489] refactor(reader): minor tweak in PoseReader --- aeon/dj_pipeline/utils/load_metadata.py | 6 +++--- aeon/io/reader.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 24289f0f..4e180fb8 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -44,7 +44,7 @@ def insert_stream_types(): streams.StreamType.insert1(entry) -def insert_device_types(device_schema: DotMap, metadata_yml_filepath: Path): +def insert_device_types(devices_schema: DotMap, metadata_yml_filepath: Path): """ Use aeon.schema.schemas and metadata.yml to insert into streams.DeviceType and streams.Device. Only insert device types that were defined both in the device schema (e.g., exp02) and Metadata.yml. @@ -52,8 +52,8 @@ def insert_device_types(device_schema: DotMap, metadata_yml_filepath: Path): """ streams = dj.VirtualModule("streams", streams_maker.schema_name) - device_info: dict[dict] = get_device_info(device_schema) - device_type_mapper, device_sn = get_device_mapper(device_schema, metadata_yml_filepath) + device_info: dict[dict] = get_device_info(devices_schema) + device_type_mapper, device_sn = get_device_mapper(devices_schema, metadata_yml_filepath) # Add device type to device_info. Only add if device types that are defined in Metadata.yml device_info = { diff --git a/aeon/io/reader.py b/aeon/io/reader.py index 44aece5c..5c3145fc 100644 --- a/aeon/io/reader.py +++ b/aeon/io/reader.py @@ -277,13 +277,13 @@ def __init__(self, pattern: str, model_root: str = "/ceph/aeon/aeon/data/process """Pose reader constructor.""" # `pattern` for this reader should typically be '_*' super().__init__(pattern, columns=None) - self._model_root = Path(model_root) + self._model_root = model_root def read(self, file: Path) -> pd.DataFrame: """Reads data from the Harp-binarized tracking file.""" # Get config file from `file`, then bodyparts from config file. model_dir = Path(*Path(file.stem.replace("_", "/")).parent.parts[1:]) - config_file_dir = self._model_root / model_dir + config_file_dir = Path(self._model_root) / model_dir if not config_file_dir.exists(): raise FileNotFoundError(f"Cannot find model dir {config_file_dir}") config_file = self.get_config_file(config_file_dir) From e40851c45c83e4772af0d25d1a6bcc849c9f7493 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 30 Jan 2024 19:05:55 -0600 Subject: [PATCH 404/489] Update specsheet.yaml --- aeon/dj_pipeline/webapps/sciviz/specsheet.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 841f8069..68438983 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -112,6 +112,7 @@ SciViz: type: form tables: - aeon_acquisition.Experiment + - aeon_acquisition.Experiment.DevicesSchema map: - type: attribute input: Experiment Name @@ -131,6 +132,9 @@ SciViz: - type: attribute input: Experiment Type destination: aeon_acquisition.ExperimentType + - type: table + input: Devices Schema Name + destination: aeon_acquisition.DevicesSchema New Experiment Subject: route: /exp_subject_form From 5766c0bcf9bdb6f7f40d420bcb4d5878a2882839 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 31 Jan 2024 15:12:56 +0000 Subject: [PATCH 405/489] fix: :bug: fix naming error & streamline BlockPlots make function --- aeon/dj_pipeline/analysis/block_analysis.py | 32 +++++++++++---------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index 342528f6..ee0e7020 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -1,17 +1,15 @@ import datetime -import datajoint as dj -import pandas as pd import json + +import datajoint as dj import numpy as np +import pandas as pd from aeon.analysis import utils as analysis_utils - -from aeon.dj_pipeline import get_schema_name, fetch_stream -from aeon.dj_pipeline import acquisition, tracking, streams -from aeon.dj_pipeline.analysis.visit import ( - get_maintenance_periods, - filter_out_maintenance_periods, -) +from aeon.dj_pipeline import (acquisition, fetch_stream, get_schema_name, + streams, tracking) +from aeon.dj_pipeline.analysis.visit import (filter_out_maintenance_periods, + get_maintenance_periods) schema = dj.schema(get_schema_name("analysis")) @@ -47,7 +45,7 @@ class Patch(dj.Part): wheel_timestamps: longblob patch_threshold: longblob patch_threshold_timestamps: longblob - patch_rate: float + patch_rate: float """ class Subject(dj.Part): @@ -207,7 +205,7 @@ class Patch(dj.Part): pellet_timestamps: longblob wheel_distance_travelled: longblob # wheel's cumulative distance travelled wheel_timestamps: longblob - cumulative_sum_preference: longblob + cumulative_sum_preference: longblob windowed_sum_preference: longblob """ @@ -235,7 +233,10 @@ def make(self, key): # Make plotly plots weight_fig = go.Figure() pos_fig = go.Figure() - for subject_data in (BlockAnalysis.Subject & key).fetch(as_dict=True): + wheel_fig = go.Figure() + + for subject_data in (BlockAnalysis.Subject & key): + # Subject weight over time weight_fig.add_trace( go.Scatter( x=subject_data["weight_timestamps"], @@ -244,6 +245,7 @@ def make(self, key): name=subject_data["subject_name"], ) ) + # Subject position over time mask = subject_data["position_likelihood"] > conf_thresh pos_fig.add_trace( go.Scatter3d( @@ -255,12 +257,12 @@ def make(self, key): ) ) - wheel_fig = go.Figure() - for patch_data in (BlockAnalysis.Patch & key).fetch(as_dict=True): + # Cumulative wheel distance travelled over time + for patch_data in (BlockAnalysis.Patch & key): wheel_fig.add_trace( go.Scatter( x=patch_data["wheel_timestamps"][::2], - y=patch_data["cumulative_distance_travelled"][::2], + y=patch_data["wheel_cumsum_distance_travelled"][::2], mode="lines", name=patch_data["patch_name"], ) From 1271fa49d749a752baa1f175fcfa26a4dd84bdfd Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 31 Jan 2024 16:24:50 +0000 Subject: [PATCH 406/489] feat: :sparkles: add patch_rate_plot and cumulative_pellet_plot to BlockPlots --- aeon/dj_pipeline/analysis/block_analysis.py | 74 +++++++++++++++------ 1 file changed, 53 insertions(+), 21 deletions(-) diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index ee0e7020..6f80ec6e 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -1,15 +1,14 @@ -import datetime import json import datajoint as dj import numpy as np import pandas as pd +import plotly.express as px +import plotly.graph_objs as go from aeon.analysis import utils as analysis_utils -from aeon.dj_pipeline import (acquisition, fetch_stream, get_schema_name, - streams, tracking) -from aeon.dj_pipeline.analysis.visit import (filter_out_maintenance_periods, - get_maintenance_periods) +from aeon.dj_pipeline import acquisition, fetch_stream, get_schema_name, streams, tracking +from aeon.dj_pipeline.analysis.visit import filter_out_maintenance_periods, get_maintenance_periods schema = dj.schema(get_schema_name("analysis")) @@ -63,13 +62,11 @@ class Subject(dj.Part): """ def make(self, key): - """ - Restrict, fetch and aggregate data from different streams to produce intermediate data products - at a per-block level (for different patches and different subjects) - 1. Query data for all chunks within the block - 2. Fetch streams, filter by maintenance period - 3. Fetch subject position data (SLEAP) - 4. Aggregate and insert into the table + """Restrict, fetch and aggregate data from different streams to produce intermediate data products at a per-block level (for different patches and different subjects). + 1. Query data for all chunks within the block. + 2. Fetch streams, filter by maintenance period. + 3. Fetch subject position data (SLEAP). + 4. Aggregate and insert into the table. """ block_start, block_end = (Block & key).fetch1("block_start", "block_end") @@ -163,7 +160,7 @@ def make(self, key): pos_df = filter_out_maintenance_periods(pos_df, maintenance_period, block_end) position_diff = np.sqrt( - (np.square(np.diff(pos_df.x.astype(float))) + np.square(np.diff(pos_df.y.astype(float)))) + np.square(np.diff(pos_df.x.astype(float))) + np.square(np.diff(pos_df.y.astype(float))) ) cumsum_distance_travelled = np.concatenate([[0], np.cumsum(position_diff)]) @@ -221,11 +218,11 @@ class BlockPlots(dj.Computed): subject_positions_plot: longblob subject_weights_plot: longblob patch_distance_travelled_plot: longblob + patch_rate_plot: longblob + cumulative_pellet_plot: longblob """ def make(self, key): - import plotly.graph_objs as go - # For position data , set confidence threshold to return position values and downsample by 5x conf_thresh = 0.9 downsampling_factor = 5 @@ -234,8 +231,10 @@ def make(self, key): weight_fig = go.Figure() pos_fig = go.Figure() wheel_fig = go.Figure() + patch_rate_fig = go.Figure() + cumulative_pellet_fig = go.Figure() - for subject_data in (BlockAnalysis.Subject & key): + for subject_data in BlockAnalysis.Subject & key: # Subject weight over time weight_fig.add_trace( go.Scatter( @@ -258,7 +257,7 @@ def make(self, key): ) # Cumulative wheel distance travelled over time - for patch_data in (BlockAnalysis.Patch & key): + for patch_data in BlockAnalysis.Patch & key: wheel_fig.add_trace( go.Scatter( x=patch_data["wheel_timestamps"][::2], @@ -268,13 +267,48 @@ def make(self, key): ) ) - # insert figures as json-formatted plotly plots + # Create a bar chart for patch rates + patch_df = (BlockAnalysis.Patch & key).fetch(format="frame").reset_index() + patch_rate_fig = px.bar( + patch_df, + x="patch_name", + y="patch_rate", + color="patch_name", + title="Patch Stats: Patch Rate for Each Patch", + labels={"patch_name": "Patch Name", "patch_rate": "Patch Rate"}, + text="patch_rate", + ) + patch_rate_fig.update_layout(bargap=0.2, width=600, height=400, template="simple_white") + + # Cumulative pellets per patch over time + for _, row in patch_df.iterrows(): + timestamps = row["pellet_timestamps"] + total_pellet_count = list(range(1, row["pellet_count"] + 1)) + + cumulative_pellet_fig.add_trace( + go.Scatter(x=timestamps, y=total_pellet_count, mode="lines+markers", name=row["patch_name"]) + ) + + cumulative_pellet_fig.update_layout( + title="Cumulative Pellet Count Over Time", + xaxis_title="Time", + yaxis_title="Cumulative Pellet Count", + width=800, + height=500, + legend_title="Patch Name", + showlegend=True, + template="simple_white", + ) + + # Insert figures as json-formatted plotly plots self.insert1( { **key, "subject_positions_plot": json.loads(pos_fig.to_json()), "subject_weights_plot": json.loads(weight_fig.to_json()), "patch_distance_travelled_plot": json.loads(wheel_fig.to_json()), + "patch_rate_plot": json.loads(patch_rate_fig.to_json()), + "cumulative_pellet_plot": json.loads(cumulative_pellet_fig.to_json()), } ) @@ -286,9 +320,7 @@ class BlockDetection(dj.Computed): """ def make(self, key): - """ - On a per-chunk basis, check for the presence of new block, insert into Block table - """ + """On a per-chunk basis, check for the presence of new block, insert into Block table.""" # find the 0s # that would mark the start of a new block # if the 0 is the first index - look back at the previous chunk From bb8e183b5248d8d7cccc407061a5e024a532738d Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 31 Jan 2024 18:54:30 +0000 Subject: [PATCH 407/489] fix: :bug: fix type hinting issue using 'from future import annotations --- aeon/io/reader.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/aeon/io/reader.py b/aeon/io/reader.py index 44aece5c..e562336a 100644 --- a/aeon/io/reader.py +++ b/aeon/io/reader.py @@ -1,15 +1,17 @@ +from __future__ import annotations + import datetime import json import math import os +from pathlib import Path import numpy as np import pandas as pd from dotmap import DotMap -from pathlib import Path -from aeon.io.api import chunk_key from aeon import util +from aeon.io.api import chunk_key _SECONDS_PER_TICK = 32e-6 _payloadtypes = { From 1228349097e1e88e1fc8b81bb0a6e06c23cdc801 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 31 Jan 2024 13:12:10 -0600 Subject: [PATCH 408/489] feat(acqusition): add tables to store Experiment Notes and to extract ActiveRegions --- aeon/dj_pipeline/acquisition.py | 35 +++++++++++++++++++ .../dj_pipeline/webapps/sciviz/specsheet.yaml | 23 ++++++++++++ 2 files changed, 58 insertions(+) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index dafb8094..5209f2d6 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -4,6 +4,7 @@ import datajoint as dj import numpy as np import pandas as pd +import json from aeon.io import api as io_api from aeon.schema import schemas as aeon_schemas @@ -132,6 +133,15 @@ class DevicesSchema(dj.Part): -> DevicesSchema """ + class Note(dj.Part): + definition = """ + -> master + note_timestamp: datetime + --- + note_type: varchar(64) + note: varchar(1000) + """ + @classmethod def get_data_directory(cls, experiment_key, directory_type="raw", as_posix=False): try: @@ -335,6 +345,31 @@ class EpochEnd(dj.Manual): """ +@schema +class EpochActiveRegion(dj.Imported): + definition = """ + -> Epoch + """ + + class Region(dj.Part): + definition = """ + -> master + region_name: varchar(36) + --- + region_data: longblob + """ + + def make(self, key): + metadata_file_path = (Epoch.Config & key).fetch1("metadata_file_path") + metadata_file_path = paths.get_repository_path("ceph_aeon") / metadata_file_path + with metadata_file_path.open("r") as f: + metadata = json.load(f) + self.insert1(key) + self.Region.insert( + {**key, "region_name": k, "region_data": v} for k, v in metadata["ActiveRegion"].items() + ) + + # ------------------- ACQUISITION CHUNK -------------------- diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 68438983..2227d49c 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -153,6 +153,29 @@ SciViz: input: Subject in the experiment destination: aeon_subject.Subject + New Experiment Note: + route: /exp_subject_form + x: 0 + y: 0.5 + height: 0.3 + width: 1 + type: form + tables: + - aeon_acquisition.Experiment.Note + map: + - type: table + input: Experiment Name + destination: aeon_acquisition.Experiment + - type: attribute + input: Note Time + destination: note_timestamp + - type: attribute + input: Note Type + destination: note_type + - type: attribute + input: Note + destination: note + New Experiment Type: route: /exp_type_form x: 0 From ea382a1eaf6133e62ad32dbff1a9caee2920e477 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 31 Jan 2024 13:44:45 -0600 Subject: [PATCH 409/489] feat(social): update binder function for social0.2 --- aeon/io/reader.py | 4 ++-- aeon/schema/schemas.py | 41 +++++++++++++++++++++++++++++++++++------ aeon/schema/social.py | 9 +++++++-- 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/aeon/io/reader.py b/aeon/io/reader.py index 44aece5c..5c3145fc 100644 --- a/aeon/io/reader.py +++ b/aeon/io/reader.py @@ -277,13 +277,13 @@ def __init__(self, pattern: str, model_root: str = "/ceph/aeon/aeon/data/process """Pose reader constructor.""" # `pattern` for this reader should typically be '_*' super().__init__(pattern, columns=None) - self._model_root = Path(model_root) + self._model_root = model_root def read(self, file: Path) -> pd.DataFrame: """Reads data from the Harp-binarized tracking file.""" # Get config file from `file`, then bodyparts from config file. model_dir = Path(*Path(file.stem.replace("_", "/")).parent.parts[1:]) - config_file_dir = self._model_root / model_dir + config_file_dir = Path(self._model_root) / model_dir if not config_file_dir.exists(): raise FileNotFoundError(f"Cannot find model dir {config_file_dir}") config_file = self.get_config_file(config_file_dir) diff --git a/aeon/schema/schemas.py b/aeon/schema/schemas.py index 113322b2..85aac7a8 100644 --- a/aeon/schema/schemas.py +++ b/aeon/schema/schemas.py @@ -74,11 +74,40 @@ Device("Patch1", social.patch_streams_b), Device("Patch2", social.patch_streams_b), Device("Patch3", social.patch_streams_b), - Device("RfidGate", social.rfid_events_b), - Device("RfidNest1", social.rfid_events_b), - Device("RfidNest2", social.rfid_events_b), - Device("RfidPatch1", social.rfid_events_b), - Device("RfidPatch2", social.rfid_events_b), - Device("RfidPatch3", social.rfid_events_b), + Device("RfidGate", social.rfid_events_social01_b), + Device("RfidNest1", social.rfid_events_social01_b), + Device("RfidNest2", social.rfid_events_social01_b), + Device("RfidPatch1", social.rfid_events_social01_b), + Device("RfidPatch2", social.rfid_events_social01_b), + Device("RfidPatch3", social.rfid_events_social01_b), ] ) + + +social02 = DotMap( + [ + Device("Metadata", core.metadata), + Device("Environment", social.environment_b, social.subject_b), + Device("CameraTop", core.video, social.camera_top_pos_b), + Device("CameraNorth", core.video), + Device("CameraSouth", core.video), + Device("CameraEast", core.video), + Device("CameraWest", core.video), + Device("CameraPatch1", core.video), + Device("CameraPatch2", core.video), + Device("CameraPatch3", core.video), + Device("CameraNest", core.video), + Device("Nest", social.weight_raw_b, social.weight_filtered_b), + Device("Patch1", social.patch_streams_b), + Device("Patch2", social.patch_streams_b), + Device("Patch3", social.patch_streams_b), + Device("Patch1Rfid", social.rfid_events_b), + Device("Patch2Rfid", social.rfid_events_b), + Device("Patch3Rfid", social.rfid_events_b), + Device("NestRfid1", social.rfid_events_b), + Device("NestRfid2", social.rfid_events_b), + Device("GateRfid", social.rfid_events_b), + ] +) + +__all__ = ["exp01", "exp02", "octagon01", "social01", "social02"] diff --git a/aeon/schema/social.py b/aeon/schema/social.py index f9e7691d..269ff877 100644 --- a/aeon/schema/social.py +++ b/aeon/schema/social.py @@ -100,9 +100,14 @@ # --- -def rfid_events_b(pattern): - """RFID events reader""" +def rfid_events_social01_b(pattern): + """RFID events reader (with social0.1 specific logic)""" pattern = pattern.replace("Rfid", "") if pattern.startswith("Events"): pattern = pattern.replace("Events", "") return {"RfidEvents": reader.Harp(f"RfidEvents{pattern}_*", ["rfid"])} + + +def rfid_events_b(pattern): + """RFID events reader""" + return {"RfidEvents": reader.Harp(f"{pattern}Events_*", ["rfid"])} From 1e84f97e625f4167f0a219bd5dc3dcf7bcfc36d0 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 31 Jan 2024 14:17:11 -0600 Subject: [PATCH 410/489] Update block_analysis.py --- aeon/dj_pipeline/analysis/block_analysis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index 62c4be82..dde463a2 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -10,7 +10,7 @@ from aeon.dj_pipeline import acquisition, fetch_stream, get_schema_name, streams, tracking from aeon.dj_pipeline.analysis.visit import filter_out_maintenance_periods, get_maintenance_periods -schema = dj.schema(get_schema_name("analysis")) +schema = dj.schema(get_schema_name("block_analysis")) @schema From 456035bcb13d748d36a6bb4583151b958f70ae97 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 31 Jan 2024 17:45:25 -0600 Subject: [PATCH 411/489] fix(subject): set ref_weight to -1 if not available --- aeon/dj_pipeline/subject.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/subject.py b/aeon/dj_pipeline/subject.py index 06657b7e..20ad8bef 100644 --- a/aeon/dj_pipeline/subject.py +++ b/aeon/dj_pipeline/subject.py @@ -185,7 +185,7 @@ def get_reference_weight(cls, subject_name): weight_query = SubjectWeight & subj_key & f"weight_time < '{ref_date}'" ref_weight = ( - weight_query.fetch("weight", order_by="weight_time DESC", limit=1)[0] if weight_query else None + weight_query.fetch("weight", order_by="weight_time DESC", limit=1)[0] if weight_query else -1 ) entry = { From beae5cc58445028b608b1e2e8317d43fe6e65a0e Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 31 Jan 2024 23:58:46 +0000 Subject: [PATCH 412/489] build: :pushpin: bump sciviz --- aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml index 35742345..c4129f27 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-remote.yaml @@ -33,7 +33,7 @@ services: sci-viz: cpus: 2.0 mem_limit: 4g - image: jverswijver/sci-viz:2.3.4 + image: jverswijver/sci-viz:2.3.5-beta environment: - CHOKIDAR_USEPOLLING=true - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/api From 2efe4ec20ab49927731880f2cec23d0d76e57032 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Thu, 1 Feb 2024 00:00:02 +0000 Subject: [PATCH 413/489] refactor: :truck: modify specsheet to point to archived db --- .../dj_pipeline/webapps/sciviz/specsheet.yaml | 98 +++++++++++-------- 1 file changed, 55 insertions(+), 43 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 841f8069..30f585d6 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -183,9 +183,9 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_acquisition, aeon_analysis): - acquisition = aeon_acquisition - visit_analysis = aeon_analysis + def dj_query(aeon_archived_exp02_acquisition, aeon_archived_exp02_analysis): + acquisition = aeon_archived_exp02_acquisition + visit_analysis = aeon_archived_exp02_analysis query = acquisition.Experiment.Subject.aggr(visit_analysis.VisitEnd.join(visit_analysis.Visit, left=True), first_visit_start='MIN(visit_start)', last_visit_end='MAX(visit_end)', total_visit_count='COUNT(visit_start)', total_visit_duration='SUM(visit_duration)') query = query.proj("first_visit_start", "last_visit_end", "total_visit_count", total_visit_duration="CAST(total_visit_duration AS DOUBLE(10, 3))") return {'query': query, 'fetch_args': {'order_by': 'last_visit_end DESC'}} @@ -210,7 +210,8 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_analysis): + def dj_query(aeon_archived_exp02_analysis): + aeon_analysis = aeon_archived_exp02_analysis query = aeon_analysis.Visit.aggr(aeon_analysis.VisitSummary, ..., duration="SUM(day_duration)", total_distance_travelled="SUM(total_distance_travelled)", total_pellet_count="SUM(total_pellet_count)", total_wheel_distance_travelled="SUM(total_wheel_distance_travelled)", keep_all_rows=True) query = query.join(aeon_analysis.VisitEnd, left=True) query = query.proj("visit_end", total_pellet_count="CAST(total_pellet_count AS DOUBLE)", duration="CAST(duration AS DOUBLE(10, 3))", total_distance_travelled="CAST(total_distance_travelled AS DOUBLE(10, 3))", total_wheel_distance_travelled="CAST(total_wheel_distance_travelled AS DOUBLE(10, 3))") @@ -228,9 +229,9 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_acquisition): - acquisition = aeon_acquisition - return {'query': aeon_acquisition.Experiment(), 'fetch_args': []} + def dj_query(aeon_archived_exp01_acquisition): + acquisition = aeon_archived_exp01_acquisition + return {'query': acquisition.Experiment(), 'fetch_args': []} component_templates: comp3: route: /avg_time_distribution @@ -239,8 +240,8 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_report): - report = aeon_report + def dj_query(aeon_archived_exp01_report): + report = aeon_archived_exp01_report return {'query': report.ExperimentTimeDistribution(), 'fetch_args': ['time_distribution_plotly']} SubjectReport: @@ -255,9 +256,9 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_acquisition): - acquisition = aeon_acquisition - return {'query': aeon_acquisition.Experiment.Subject & {'experiment_name': 'exp0.1-r0'}, 'fetch_args': []} + def dj_query(aeon_archived_exp01_acquisition): + acquisition = aeon_archived_exp01_acquisition + return {'query': acquisition.Experiment.Subject & {'experiment_name': 'exp0.1-r0'}, 'fetch_args': []} component_templates: comp1: route: /subject_meta @@ -266,8 +267,8 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_acquisition): - return dict(query=aeon_acquisition.Experiment.Subject(), fetch_args=[]) + def dj_query(aeon_archived_exp01_acquisition): + return dict(query=aeon_archived_exp01_acquisition.Experiment.Subject(), fetch_args=[]) comp2: route: /reward_diff_plot type: plot:plotly:stored_json @@ -275,8 +276,8 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_report): - report = aeon_report + def dj_query(aeon_archived_exp01_report): + report = aeon_archived_exp01_report return {'query': report.SubjectRewardRateDifference(), 'fetch_args': ['reward_rate_difference_plotly']} comp3: route: /wheel_distance_travelled @@ -285,8 +286,8 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_report): - report = aeon_report + def dj_query(aeon_archived_exp01_report): + report = aeon_archived_exp01_report return {'query': report.SubjectWheelTravelledDistance(), 'fetch_args': ['wheel_travelled_distance_plotly']} PerSubjectReport: @@ -310,8 +311,8 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_acquisition): - return dict(query=aeon_acquisition.Experiment.Subject(), fetch_args=[]) + def dj_query(aeon_archived_exp01_acquisition): + return dict(query=aeon_archived_exp01_acquisition.Experiment.Subject(), fetch_args=[]) comp2: route: /per_subject_reward_diff_plot x: 0 @@ -323,8 +324,8 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_report): - report = aeon_report + def dj_query(aeon_archived_exp01_report): + report = aeon_archived_exp01_report return {'query': report.SubjectRewardRateDifference(), 'fetch_args': ['reward_rate_difference_plotly']} comp3: route: /per_subject_wheel_distance_travelled @@ -337,8 +338,8 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_report): - report = aeon_report + def dj_query(aeon_archived_exp01_report): + report = aeon_archived_exp01_report return {'query': report.SubjectWheelTravelledDistance(), 'fetch_args': ['wheel_travelled_distance_plotly']} PerVisitReport: @@ -362,7 +363,8 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_analysis): + def dj_query(aeon_archived_exp02_analysis): + aeon_analysis = aeon_archived_exp02_analysis query = aeon_analysis.Visit.aggr(aeon_analysis.VisitSummary, ..., duration="SUM(day_duration)", total_distance_travelled="SUM(total_distance_travelled)", total_pellet_count="SUM(total_pellet_count)", total_wheel_distance_travelled="SUM(total_wheel_distance_travelled)", keep_all_rows=True) query = query.join(aeon_analysis.VisitEnd, left=True) query = query.proj("visit_end", total_pellet_count="CAST(total_pellet_count AS DOUBLE)", duration="CAST(duration AS DOUBLE(10, 3))", total_distance_travelled="CAST(total_distance_travelled AS DOUBLE(10, 3))", total_wheel_distance_travelled="CAST(total_wheel_distance_travelled AS DOUBLE(10, 3))") @@ -378,8 +380,8 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_report): - report = aeon_report + def dj_query(aeon_archived_exp02_report): + report = aeon_archived_exp02_report return {'query': report.VisitDailySummaryPlot(), 'fetch_args': ['summary_plot_png']} Visits247: @@ -394,7 +396,8 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_report): + def dj_query(aeon_archived_exp02_report): + aeon_report = aeon_archived_exp02_report return {'query': aeon_report.VisitDailySummaryPlot.proj(), 'fetch_args': []} component_templates: comp1: @@ -404,7 +407,8 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_report): + def dj_query(aeon_archived_exp02_report): + aeon_report = aeon_archived_exp02_report return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['pellet_count_plotly']) comp2: route: /visit_daily_summary_wheel_distance_travelled @@ -413,7 +417,8 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_report): + def dj_query(aeon_archived_exp02_report): + aeon_report = aeon_archived_exp02_report return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['wheel_distance_travelled_plotly']) comp3: route: /visit_daily_summary_total_distance_travelled @@ -422,7 +427,8 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_report): + def dj_query(aeon_archived_exp02_report): + aeon_report = aeon_archived_exp02_report return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['total_distance_travelled_plotly']) comp4: route: /visit_daily_summary_weight_patch @@ -431,7 +437,8 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_report): + def dj_query(aeon_archived_exp02_report): + aeon_report = aeon_archived_exp02_report return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['weight_patch_plotly']) comp5: route: /visit_daily_summary_foraging_bouts @@ -440,7 +447,8 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_report): + def dj_query(aeon_archived_exp02_report): + aeon_report = aeon_archived_exp02_report return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['foraging_bouts_plotly']) comp6: route: /visit_daily_summary_foraging_bouts_pellet_count @@ -449,7 +457,8 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_report): + def dj_query(aeon_archived_exp02_report): + aeon_report = aeon_archived_exp02_report return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['foraging_bouts_pellet_count_plotly']) comp7: route: /visit_daily_summary_foraging_bouts_duration @@ -458,7 +467,8 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_report): + def dj_query(aeon_archived_exp02_report): + aeon_report = aeon_archived_exp02_report return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['foraging_bouts_duration_plotly']) comp8: route: /visit_daily_summary_region_time_fraction_daily @@ -467,7 +477,8 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_report): + def dj_query(aeon_archived_exp02_report): + aeon_report = aeon_archived_exp02_report return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['region_time_fraction_daily_plotly']) comp9: route: /visit_daily_summary_region_time_fraction_hourly @@ -476,7 +487,8 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_report): + def dj_query(aeon_archived_exp02_report): + aeon_report = aeon_archived_exp02_report return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['region_time_fraction_hourly_plotly']) VideoStream: @@ -499,8 +511,8 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_acquisition): - acquisition = aeon_acquisition + def dj_query(aeon_archived_exp02_acquisition): + acquisition = aeon_archived_exp02_acquisition return {'query': aeon_acquisition.Experiment(), 'fetch_args': ['experiment_name']} camera_dropdown: x: 0 @@ -515,8 +527,8 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_acquisition): - acquisition = aeon_acquisition + def dj_query(aeon_archived_exp02_acquisition): + acquisition = aeon_archived_exp02_acquisition q = dj.U('camera_description') & acquisition.ExperimentCamera return {'query': q, 'fetch_args': ['camera_description']} time_range_selector: @@ -547,7 +559,7 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_acquisition): - acquisition = aeon_acquisition + def dj_query(aeon_archived_exp02_acquisition): + acquisition = aeon_archived_exp02_acquisition q = dj.U('camera_description', 'raw_data_dir') & (acquisition.ExperimentCamera * acquisition.Experiment.Directory & 'directory_type = "raw"').proj('camera_description', raw_data_dir="CONCAT('/ceph/aeon/', directory_path)") return {'query': q, 'fetch_args': []} From c9482dd800e2c930648d3062464678162e785c44 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Thu, 1 Feb 2024 01:01:22 +0000 Subject: [PATCH 414/489] feat: :sparkles: add BlockAnalysis page to sciviz --- .../dj_pipeline/webapps/sciviz/specsheet.yaml | 153 ++++++++++++++++-- 1 file changed, 138 insertions(+), 15 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 30f585d6..4c4b06fa 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -50,7 +50,7 @@ SciViz: route: /colony_page_pyrat_subjects x: 0 y: 0 - height: 0.6 + height: 1 width: 1 type: antd-table restriction: > @@ -64,7 +64,7 @@ SciViz: Pyrat User Entry: route: /colony_page_pyrat_user_entry x: 0 - y: 0.6 + y: 1 height: 0.3 width: 1 type: form @@ -81,19 +81,19 @@ SciViz: input: Pyrat Responsible ID destination: responsible_id - Pyrat Sync Task: - route: /colony_page_pyrat_sync_task - x: 0 - y: 0.9 - height: 0.3 - width: 1 - type: form - tables: - - aeon_subject.PyratIngestionTask - map: - - type: attribute - input: Task Scheduled Time - destination: pyrat_task_scheduled_time + Pyrat Sync Task: + route: /colony_page_pyrat_sync_task + x: 0 + y: 1.3 + height: 0.3 + width: 1 + type: form + tables: + - aeon_subject.PyratIngestionTask + map: + - type: attribute + input: Task Scheduled Time + destination: pyrat_task_scheduled_time ExperimentEntry: route: /experiment_entry @@ -491,6 +491,129 @@ SciViz: aeon_report = aeon_archived_exp02_report return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['region_time_fraction_hourly_plotly']) + BlockAnalysis: + route: /block_analysis + grids: + grid3: + type: fixed + columns: 1 + row_height: 700 + components: + BlockAnalysis: + route: /block_analysis_grid + link: /per_block_report + x: 0 + y: 0 + height: 1 + width: 1 + type: antd-table + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_test_analysis): + aeon_analysis = aeon_test_analysis + return {'query': aeon_analysis.Block * aeon_analysis.BlockAnalysis, 'fetch_args': {'order_by': 'block_end DESC'}} + + PerBlockReport: + hidden: true + route: /per_block_report + grids: + per_block_report: + type: fixed + route: /per_block_report + columns: 1 + row_height: 1500 + components: + comp1: + route: /per_block_meta + x: 0 + y: 0 + height: 0.2 + width: 0.8 + type: metadata + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_test_analysis): + aeon_analysis = aeon_test_analysis + query = aeon_analysis.Block * aeon_analysis.BlockAnalysis + return dict(query=query, fetch_args=[]) + comp2: + route: /subject_positions_plot + x: 0 + y: 0.2 + height: 0.5 + width: 0.8 + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_test_analysis): + aeon_analysis = aeon_test_analysis + return {'query': aeon_test_analysis.BlockPlots(), 'fetch_args': ['subject_positions_plot']} + comp3: + route: /subject_weights_plot + x: 0 + y: 0.7 + height: 0.5 + width: 0.8 + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_test_analysis): + aeon_analysis = aeon_test_analysis + return {'query': aeon_test_analysis.BlockPlots(), 'fetch_args': ['subject_weights_plot']} + + comp4: + route: /patch_distance_travelled_plot + x: 0 + y: 1.2 + height: 0.5 + width: 0.8 + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_test_analysis): + aeon_analysis = aeon_test_analysis + return {'query': aeon_test_analysis.BlockPlots(), 'fetch_args': ['patch_distance_travelled_plot']} + + comp5: + route: /patch_rate_plot + x: 0 + y: 1.7 + height: 0.4 + width: 0.6 + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_test_analysis): + aeon_analysis = aeon_test_analysis + return {'query': aeon_test_analysis.BlockPlots(), 'fetch_args': ['patch_rate_plot']} + + comp6: + route: /cumulative_pellet_plot + x: 0 + y: 2.1 + height: 0.4 + width: 0.6 + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_test_analysis): + aeon_analysis = aeon_test_analysis + return {'query': aeon_test_analysis.BlockPlots(), 'fetch_args': ['cumulative_pellet_plot']} + VideoStream: route: /videostream grids: From e40e614aefd962598a44c5b76f25363625988778 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Thu, 1 Feb 2024 16:48:52 +0000 Subject: [PATCH 415/489] fix: :bug: bugfix exp_note_form in sciviz --- aeon/dj_pipeline/webapps/sciviz/specsheet.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index e375d0b9..a65e457e 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -154,9 +154,9 @@ SciViz: destination: aeon_subject.Subject New Experiment Note: - route: /exp_subject_form + route: /exp_note_form x: 0 - y: 0.5 + y: 0.8 height: 0.3 width: 1 type: form @@ -179,7 +179,7 @@ SciViz: New Experiment Type: route: /exp_type_form x: 0 - y: 0.8 + y: 1.1 height: 0.3 width: 1 type: form From 7a9a436b36d23462a0c497882882f9cd69be5e7e Mon Sep 17 00:00:00 2001 From: JaerongA Date: Thu, 1 Feb 2024 19:03:03 +0000 Subject: [PATCH 416/489] docs: :memo: update sciviz README.md --- aeon/dj_pipeline/webapps/sciviz/README.md | 54 ++++++++++------------- 1 file changed, 24 insertions(+), 30 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/README.md b/aeon/dj_pipeline/webapps/sciviz/README.md index 52e24d95..6f1cef44 100644 --- a/aeon/dj_pipeline/webapps/sciviz/README.md +++ b/aeon/dj_pipeline/webapps/sciviz/README.md @@ -6,57 +6,51 @@ If you have not done so already, please install the following dependencies: - [Docker](https://docs.docker.com/get-docker/) - [Docker Compose](https://docs.docker.com/compose/install/) +--- ## Running the application +### Local dev deployment for testing -#### Production deployment - -To run the application in production mode, use the command at the root: -```bash -cd aeon/dj_pipeline/webapps/sciviz -SUBDOMAINS=testdev URL=datajoint.io STAGE_CERT=TRUE EMAIL=service-health@datajoint.com HOST_UID=$(id -u) docker-compose -f docker-compose-remote.yaml up -d -``` -Please modify `SUBDOMAINS`, `URL`, and `STAGE_CERT` according to your own deployment configuration. - -On the example above, the first two arguments are about what site you are going to host this on, the configuration shown here is for `https://testdev.datajoint.io` - -The next two arguments are for web certifications. Set `STAGE_CERT=TRUE` for testing certs and set it to `FALSE` once you are confident in your deployment for production. `EMAIL` is for where notifications related to certs are going to be sent. - -#### Local dev deployment - -For local deployment, you need to ensure the connection to the `aeon-db2` server is established. This can be done by establishing the port forwarding as follows in a terminal: +- For local deployment, you need to ensure the connection to the `aeon-db2` server is established. This can be done by establishing the port forwarding as follows in a terminal (replace `username` with your own username): ```bash ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no username@ssh.swc.ucl.ac.uk -L 3306:aeon-db2:3306 -N ``` -(replace `username` with your own username) -Leave this terminal open and open up a new terminal to navigate to the `sciviz` folder +- Leave this terminal open and open up a new terminal to navigate to the `sciviz` folder. ```bash cd aeon/dj_pipeline/webapps/sciviz ``` -Docker compose up with the following command: +- Docker compose up with the following command: ``` HOST_UID=$(id -u) docker-compose -f docker-compose-local.yaml up ``` -To stop the application, use the same command as before but with `down` in place of `up -d` - -## Verify the deployment -#### Production deployment - -- Navigate to the server's address in a Google Chrome browser window. -- Set `Host/Database Address` to `aeon-db2`. +#### Verify the deployment +- In your web browser, navigate to: [https://localhost/login](https://localhost/login) - Set `Username` and `Password` to your own database user account (if you need one, please contact Project Aeon admin team). - Click `Connect`. +- To stop the application, docker compose down with the following: +``` +HOST_UID=$(id -u) docker-compose -f docker-compose-local.yaml down --volumes +``` +--- -#### Local dev deployment +### Production deployment + +To run the application in production mode, use the command at the root: + +```bash +cd aeon/dj_pipeline/webapps/sciviz +HOST_UID=$(id -u) docker-compose -f docker-compose-remote.yaml up -d +``` -- In a Google Chrome browser window, navigate to: [https://localhost/login](https://localhost/login) -- Set `Host/Database Address` to `host.docker.internal`. +#### Verify the deployment +- Navigate to the server's address on your browser (https://www.swc.ucl.ac.uk/aeon/). - Set `Username` and `Password` to your own database user account (if you need one, please contact Project Aeon admin team). - Click `Connect`. +--- ## Dynamic spec sheet Sci-Viz is used to build visualization dashboards, this is done through a single spec sheet. The one for this deployment is called `specsheet.yaml` @@ -73,4 +67,4 @@ Some notes about the spec sheet if you plan to tweak the website yourself: return dict(**kwargs) ``` - Overlapping components at the same (x, y) does not work, the grid system will not allow overlapping components it will wrap them horizontally if there is enough space or bump them down to the next row. -- Visit this [repo](https://github.com/datajoint/sci-viz) to learn more about Sci-Viz. \ No newline at end of file +- Visit this [repo](https://github.com/datajoint/sci-viz) to learn more about Sci-Viz. From e9ce91fed6c4d9d6fabdadef3122d286871aed54 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 1 Feb 2024 17:41:44 -0600 Subject: [PATCH 417/489] fix(block_analysis): SubjectState is not a reliable indication of subjects in the block, use identities from SLEAP data --- aeon/dj_pipeline/analysis/block_analysis.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index dde463a2..02e3ae4c 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -140,10 +140,9 @@ def make(self, key): ) # Subject data - subject_events_query = acquisition.Environment.SubjectState & key & chunk_restriction - subject_events_df = fetch_stream(subject_events_query) - - subject_names = set(subject_events_df.id) + subject_names = set( + (tracking.SLEAPTracking.PoseIdentity & key & chunk_restriction).fetch("identity_name") + ) for subject_name in subject_names: # positions - query for CameraTop, identity_name matches subject_name, pos_query = ( From d8273ba06ff9a2c382b7322c6ac7725f6b2c7730 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 1 Feb 2024 17:41:50 -0600 Subject: [PATCH 418/489] Update social.py --- aeon/schema/social.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/schema/social.py b/aeon/schema/social.py index 269ff877..a6a70c6b 100644 --- a/aeon/schema/social.py +++ b/aeon/schema/social.py @@ -110,4 +110,4 @@ def rfid_events_social01_b(pattern): def rfid_events_b(pattern): """RFID events reader""" - return {"RfidEvents": reader.Harp(f"{pattern}Events_*", ["rfid"])} + return {"RfidEvents": reader.Harp(f"{pattern}_32*", ["rfid"])} From baa89321644b15c887497e86e9cb3273cf4a0851 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 2 Feb 2024 08:48:37 -0600 Subject: [PATCH 419/489] Update worker.py --- aeon/dj_pipeline/populate/worker.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py index 25b910d1..d41bdeb7 100644 --- a/aeon/dj_pipeline/populate/worker.py +++ b/aeon/dj_pipeline/populate/worker.py @@ -20,7 +20,7 @@ # ---- Some constants ---- logger = dj.logger worker_schema_name = db_prefix + "worker" - +WORKER_MAX_IDLED_CYCLE = 1 # ---- Manage experiments for automated ingestion ---- @@ -55,15 +55,12 @@ def ingest_environment_visits(): worker_schema_name=worker_schema_name, db_prefix=db_prefix, run_duration=-1, + max_idled_cycle=WORKER_MAX_IDLED_CYCLE, sleep_duration=1200, ) acquisition_worker(ingest_epochs_chunks) -acquisition_worker(acquisition.ExperimentLog) -acquisition_worker(acquisition.SubjectEnterExit) -acquisition_worker(acquisition.SubjectWeight) -acquisition_worker(acquisition.FoodPatchEvent) -acquisition_worker(acquisition.WheelState) - +acquisition_worker(acquisition.Environment) +acquisition_worker(acquisition.EpochActiveRegion) acquisition_worker(ingest_environment_visits) # configure a worker to process mid-priority tasks @@ -101,6 +98,7 @@ def ingest_environment_visits(): worker_schema_name=worker_schema_name, db_prefix=db_prefix, run_duration=-1, + max_idled_cycle=1000, sleep_duration=10, ) @@ -115,9 +113,25 @@ def ingest_environment_visits(): worker_schema_name=worker_schema_name, db_prefix=db_prefix, run_duration=-1, + max_idled_cycle=WORKER_MAX_IDLED_CYCLE, sleep_duration=1200, ) for attr in vars(streams).values(): if is_djtable(attr, dj.user_tables.AutoPopulate): streams_worker(attr, max_calls=10) + +streams_worker(qc.CameraQC, max_calls=10) +streams_worker(tracking.SLEAPTracking, max_calls=10) + +# configure a worker to run the analysis tables +analysis_worker = DataJointWorker( + "analysis_worker", + worker_schema_name=worker_schema_name, + db_prefix=db_prefix, + run_duration=-1, + max_idled_cycle=WORKER_MAX_IDLED_CYCLE, + sleep_duration=3600, +) + +analysis_worker(block_analysis.BlockAnalysis) From 46975f475465a9255cbd26d7d64aa91d22d3558e Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 2 Feb 2024 10:59:38 -0600 Subject: [PATCH 420/489] feat(worker): update worker for social01 and social02 --- aeon/dj_pipeline/docs/notebooks/diagram.ipynb | 4 ++-- aeon/dj_pipeline/populate/process.py | 10 ++++++++-- aeon/dj_pipeline/populate/worker.py | 15 ++++++++------- aeon/dj_pipeline/utils/load_metadata.py | 2 +- docker/docker-compose.yml | 8 ++++---- 5 files changed, 23 insertions(+), 16 deletions(-) diff --git a/aeon/dj_pipeline/docs/notebooks/diagram.ipynb b/aeon/dj_pipeline/docs/notebooks/diagram.ipynb index 7d819c2f..179d25a7 100644 --- a/aeon/dj_pipeline/docs/notebooks/diagram.ipynb +++ b/aeon/dj_pipeline/docs/notebooks/diagram.ipynb @@ -857,7 +857,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "datajoint_analysis_diagram.svg datajoint_overview_diagram.svg \u001b[0m\u001b[01;34mnotebooks\u001b[0m/\r\n" + "datajoint_analysis_diagram.svg datajoint_overview_diagram.svg \u001B[0m\u001B[01;34mnotebooks\u001B[0m/\r\n" ] } ], @@ -898,4 +898,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/aeon/dj_pipeline/populate/process.py b/aeon/dj_pipeline/populate/process.py index 1386937b..5c2e4d15 100644 --- a/aeon/dj_pipeline/populate/process.py +++ b/aeon/dj_pipeline/populate/process.py @@ -31,13 +31,19 @@ import datajoint as dj from datajoint_utilities.dj_worker import parse_args -from aeon.dj_pipeline.populate.worker import acquisition_worker, logger, mid_priority, streams_worker, pyrat_worker +from aeon.dj_pipeline.populate.worker import ( + acquisition_worker, + logger, + analysis_worker, + streams_worker, + pyrat_worker, +) # ---- some wrappers to support execution as script or CLI configured_workers = { "acquisition_worker": acquisition_worker, - "mid_priority": mid_priority, + "analysis_worker": analysis_worker, "streams_worker": streams_worker, "pyrat_worker": pyrat_worker, } diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py index 3fd43b53..67a596d7 100644 --- a/aeon/dj_pipeline/populate/worker.py +++ b/aeon/dj_pipeline/populate/worker.py @@ -17,12 +17,13 @@ "WorkerLog", "ErrorLog", "logger", + "AutomatedExperimentIngestion", ] # ---- Some constants ---- logger = dj.logger worker_schema_name = db_prefix + "worker" -WORKER_MAX_IDLED_CYCLE = 1 +WORKER_MAX_IDLED_CYCLE = 3 # ---- Manage experiments for automated ingestion ---- @@ -59,12 +60,12 @@ def ingest_environment_visits(): db_prefix=db_prefix, run_duration=-1, max_idled_cycle=WORKER_MAX_IDLED_CYCLE, - sleep_duration=1200, + sleep_duration=120, ) acquisition_worker(ingest_epochs_chunks) acquisition_worker(acquisition.Environment) acquisition_worker(acquisition.EpochActiveRegion) -acquisition_worker(ingest_environment_visits) +# acquisition_worker(ingest_environment_visits) acquisition_worker(block_analysis.BlockDetection) # configure a worker to handle pyrat sync @@ -73,8 +74,8 @@ def ingest_environment_visits(): worker_schema_name=worker_schema_name, db_prefix=db_prefix, run_duration=-1, - max_idled_cycle=1000, - sleep_duration=10, + max_idled_cycle=500, + sleep_duration=30, ) pyrat_worker(subject.CreatePyratIngestionTask) @@ -89,7 +90,7 @@ def ingest_environment_visits(): db_prefix=db_prefix, run_duration=-1, max_idled_cycle=WORKER_MAX_IDLED_CYCLE, - sleep_duration=1200, + sleep_duration=60, ) for attr in vars(streams).values(): @@ -106,7 +107,7 @@ def ingest_environment_visits(): db_prefix=db_prefix, run_duration=-1, max_idled_cycle=WORKER_MAX_IDLED_CYCLE, - sleep_duration=3600, + sleep_duration=60, ) analysis_worker(block_analysis.BlockAnalysis) diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 4e180fb8..b2334516 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -17,7 +17,7 @@ logger = dj.logger _weight_scale_rate = 100 _weight_scale_nest = 1 -_aeon_schemas = ["social01"] +_aeon_schemas = ["social01", "social02"] def insert_stream_types(): diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 02d2a67d..ca6c69d3 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -61,15 +61,15 @@ services: condition: service_started deploy: mode: replicated - replicas: 3 + replicas: 2 command: [ "aeon_ingest", "streams_worker" ] - ingest_mid: + analysis_worker: <<: *aeon-ingest-common depends_on: acquisition_worker: condition: service_started deploy: mode: replicated - replicas: 2 - command: [ "aeon_ingest", "mid_priority" ] + replicas: 1 + command: [ "aeon_ingest", "analysis_worker" ] From 5804cf4ceacdcdc26a3c031fa77f9bd4d110c273 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 2 Feb 2024 12:11:20 -0600 Subject: [PATCH 421/489] feat(block_analysis): Ensure the relevant streams ingestion are caught up before each block analysis --- aeon/dj_pipeline/analysis/block_analysis.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index 02e3ae4c..e2c6595e 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -11,6 +11,7 @@ from aeon.dj_pipeline.analysis.visit import filter_out_maintenance_periods, get_maintenance_periods schema = dj.schema(get_schema_name("block_analysis")) +logger = dj.logger @schema @@ -74,6 +75,21 @@ def make(self, key): key["experiment_name"], block_start, block_end ) + # Ensure the relevant streams ingestion are caught up to this block + chunk_keys = (acquisition.Chunk & key & chunk_restriction).fetch("KEY") + streams_tables = ( + streams.UndergroundFeederDepletionState, + streams.UndergroundFeederBeamBreak, + streams.UndergroundFeederEncoder, + tracking.SLEAPTracking, + ) + for streams_table in streams_tables: + if len(streams_table & chunk_keys) < len(streams_table.key_source & chunk_keys): + logger.info( + f"{streams_table.__name__} not yet fully ingested for block: {key}. Skip BlockAnalysis (to retry later)..." + ) + return + self.insert1({**key, "block_duration": (block_end - block_start).total_seconds() / 3600}) # Patch data - TriggerPellet, DepletionState, Encoder (distancetravelled) @@ -117,7 +133,7 @@ def make(self, key): encoder_df, maintenance_period, block_end, dropna=True ) - encoder_df["distance_travelled"] = analysis_utils.distancetravelled(encoder_df.angle) + encoder_df["distance_travelled"] = -1 * analysis_utils.distancetravelled(encoder_df.angle) patch_rate = depletion_state_df.rate.unique() assert len(patch_rate) == 1 # expects a single rate for this block From 6745de069ef16939630ce0229a847a5f53842d54 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 2 Feb 2024 12:39:39 -0600 Subject: [PATCH 422/489] chore(worker): minor worker tuning --- aeon/dj_pipeline/populate/worker.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py index 67a596d7..085a2a46 100644 --- a/aeon/dj_pipeline/populate/worker.py +++ b/aeon/dj_pipeline/populate/worker.py @@ -90,7 +90,7 @@ def ingest_environment_visits(): db_prefix=db_prefix, run_duration=-1, max_idled_cycle=WORKER_MAX_IDLED_CYCLE, - sleep_duration=60, + sleep_duration=10, ) for attr in vars(streams).values(): @@ -110,4 +110,5 @@ def ingest_environment_visits(): sleep_duration=60, ) -analysis_worker(block_analysis.BlockAnalysis) +analysis_worker(block_analysis.BlockAnalysis, max_calls=4) +analysis_worker(block_analysis.BlockPlots, max_calls=4) From 218c5551ae611f301a6a90b4b58d6b4779d4128b Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 2 Feb 2024 21:21:13 +0000 Subject: [PATCH 423/489] feat: :sparkles: add a cronjob script for dj workers --- docker/cron_script.bash | 47 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100755 docker/cron_script.bash diff --git a/docker/cron_script.bash b/docker/cron_script.bash new file mode 100755 index 00000000..bc3dbc4a --- /dev/null +++ b/docker/cron_script.bash @@ -0,0 +1,47 @@ +#!/bin/bash + +# This script will be run every 4 hours in a cron job. +# Open up a crontab ('crontab -e') and add the following: +# 0 */4 * * * /path/to/cron_script.bash +# For debugging, run ./cron_script.bash -v + +verbose=0 + +while [[ "$#" -gt 0 ]]; do + case $1 in + -v|--verbose) verbose=1 ;; + esac + shift +done + +# Verbose option for debugging +print_verbose() { + if [ "$verbose" -eq 1 ]; then + printf "[DEBUG] %s - %s\n\n" "$1" "$(date '+%Y-%m-%d %H:%M:%S')" + fi +} + +print_verbose "Starting Ingestion..." +cd /nfs/nhome/live/aeon_db/aeon_mecha/docker/ + +if docker image inspect ghcr.io/sainsburywellcomecentre/aeon_mecha > /dev/null 2>&1; then + print_verbose "Removing existing aeon_mecha image..." + docker image rm ghcr.io/sainsburywellcomecentre/aeon_mecha +fi + +print_verbose "Terminate worker containers..." +docker-compose down +if [ $? -eq 0 ]; then + print_verbose "Workers terminated successfully." +else + print_verbose "Failed to terminate workers." +fi + +print_verbose "Restart containers..." +docker-compose up --detach +if [ $? -eq 0 ]; then + print_verbose "Containers restarted successfully." +else + print_verbose "Failed to restart containers." +fi + From 2ff96c84cd256f1c9db29ae2920755d9e8023751 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 2 Feb 2024 16:21:42 -0600 Subject: [PATCH 424/489] update(worker): fine tune workers --- aeon/dj_pipeline/populate/worker.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py index 085a2a46..5ebbf053 100644 --- a/aeon/dj_pipeline/populate/worker.py +++ b/aeon/dj_pipeline/populate/worker.py @@ -23,7 +23,6 @@ # ---- Some constants ---- logger = dj.logger worker_schema_name = db_prefix + "worker" -WORKER_MAX_IDLED_CYCLE = 3 # ---- Manage experiments for automated ingestion ---- @@ -59,8 +58,8 @@ def ingest_environment_visits(): worker_schema_name=worker_schema_name, db_prefix=db_prefix, run_duration=-1, - max_idled_cycle=WORKER_MAX_IDLED_CYCLE, - sleep_duration=120, + max_idled_cycle=6, + sleep_duration=1200, ) acquisition_worker(ingest_epochs_chunks) acquisition_worker(acquisition.Environment) @@ -74,7 +73,7 @@ def ingest_environment_visits(): worker_schema_name=worker_schema_name, db_prefix=db_prefix, run_duration=-1, - max_idled_cycle=500, + max_idled_cycle=400, sleep_duration=30, ) @@ -89,7 +88,7 @@ def ingest_environment_visits(): worker_schema_name=worker_schema_name, db_prefix=db_prefix, run_duration=-1, - max_idled_cycle=WORKER_MAX_IDLED_CYCLE, + max_idled_cycle=3, sleep_duration=10, ) @@ -106,9 +105,9 @@ def ingest_environment_visits(): worker_schema_name=worker_schema_name, db_prefix=db_prefix, run_duration=-1, - max_idled_cycle=WORKER_MAX_IDLED_CYCLE, - sleep_duration=60, + max_idled_cycle=6, + sleep_duration=1200, ) -analysis_worker(block_analysis.BlockAnalysis, max_calls=4) -analysis_worker(block_analysis.BlockPlots, max_calls=4) +analysis_worker(block_analysis.BlockAnalysis, max_calls=6) +analysis_worker(block_analysis.BlockPlots, max_calls=6) From 85b4c978d32feab003d4b8380a2c9b5c48cd1dcc Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 2 Feb 2024 16:33:42 -0600 Subject: [PATCH 425/489] Update specsheet.yaml --- .../dj_pipeline/webapps/sciviz/specsheet.yaml | 69 ++++++++++++------- 1 file changed, 46 insertions(+), 23 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index a65e457e..7a1f3455 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -107,7 +107,7 @@ SciViz: route: /exp_form x: 0 y: 0 - height: 0.5 + height: 0.4 width: 1 type: form tables: @@ -139,7 +139,7 @@ SciViz: New Experiment Subject: route: /exp_subject_form x: 0 - y: 0.5 + y: 0.4 height: 0.3 width: 1 type: form @@ -156,7 +156,7 @@ SciViz: New Experiment Note: route: /exp_note_form x: 0 - y: 0.8 + y: 0.7 height: 0.3 width: 1 type: form @@ -176,10 +176,33 @@ SciViz: input: Note destination: note + New Experiment Directory: + route: /exp_note_form + x: 0 + y: 1.0 + height: 0.3 + width: 1 + type: form + tables: + - aeon_acquisition.Experiment.Directory + map: + - type: table + input: Experiment Name + destination: aeon_acquisition.Experiment + - type: table + input: Directory Type + destination: aeon_acquisition.DirectoryType + - type: table + input: Pipeline Repository + destination: aeon_acquisition.PipelineRepository + - type: attribute + input: Directory Path + destination: directory_path + New Experiment Type: route: /exp_type_form x: 0 - y: 1.1 + y: 1.3 height: 0.3 width: 1 type: form @@ -538,8 +561,8 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_test_analysis): - aeon_analysis = aeon_test_analysis + def dj_query(aeon_block_analysis): + aeon_analysis = aeon_block_analysis return {'query': aeon_analysis.Block * aeon_analysis.BlockAnalysis, 'fetch_args': {'order_by': 'block_end DESC'}} PerBlockReport: @@ -563,8 +586,8 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_test_analysis): - aeon_analysis = aeon_test_analysis + def dj_query(aeon_block_analysis): + aeon_analysis = aeon_block_analysis query = aeon_analysis.Block * aeon_analysis.BlockAnalysis return dict(query=query, fetch_args=[]) comp2: @@ -578,9 +601,9 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_test_analysis): - aeon_analysis = aeon_test_analysis - return {'query': aeon_test_analysis.BlockPlots(), 'fetch_args': ['subject_positions_plot']} + def dj_query(aeon_block_analysis): + aeon_analysis = aeon_block_analysis + return {'query': aeon_block_analysis.BlockPlots(), 'fetch_args': ['subject_positions_plot']} comp3: route: /subject_weights_plot x: 0 @@ -592,9 +615,9 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_test_analysis): - aeon_analysis = aeon_test_analysis - return {'query': aeon_test_analysis.BlockPlots(), 'fetch_args': ['subject_weights_plot']} + def dj_query(aeon_block_analysis): + aeon_analysis = aeon_block_analysis + return {'query': aeon_block_analysis.BlockPlots(), 'fetch_args': ['subject_weights_plot']} comp4: route: /patch_distance_travelled_plot @@ -607,9 +630,9 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_test_analysis): - aeon_analysis = aeon_test_analysis - return {'query': aeon_test_analysis.BlockPlots(), 'fetch_args': ['patch_distance_travelled_plot']} + def dj_query(aeon_block_analysis): + aeon_analysis = aeon_block_analysis + return {'query': aeon_block_analysis.BlockPlots(), 'fetch_args': ['patch_distance_travelled_plot']} comp5: route: /patch_rate_plot @@ -622,9 +645,9 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_test_analysis): - aeon_analysis = aeon_test_analysis - return {'query': aeon_test_analysis.BlockPlots(), 'fetch_args': ['patch_rate_plot']} + def dj_query(aeon_block_analysis): + aeon_analysis = aeon_block_analysis + return {'query': aeon_block_analysis.BlockPlots(), 'fetch_args': ['patch_rate_plot']} comp6: route: /cumulative_pellet_plot @@ -637,9 +660,9 @@ SciViz: def restriction(**kwargs): return dict(**kwargs) dj_query: > - def dj_query(aeon_test_analysis): - aeon_analysis = aeon_test_analysis - return {'query': aeon_test_analysis.BlockPlots(), 'fetch_args': ['cumulative_pellet_plot']} + def dj_query(aeon_block_analysis): + aeon_analysis = aeon_block_analysis + return {'query': aeon_block_analysis.BlockPlots(), 'fetch_args': ['cumulative_pellet_plot']} VideoStream: route: /videostream From ba6d533d36059ddee5dd72ded723e90c8c53b281 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 2 Feb 2024 17:11:38 -0600 Subject: [PATCH 426/489] fix: bugfix in specsheet and tune worker --- aeon/dj_pipeline/populate/worker.py | 8 ++------ aeon/dj_pipeline/webapps/sciviz/specsheet.yaml | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py index 5ebbf053..3023887d 100644 --- a/aeon/dj_pipeline/populate/worker.py +++ b/aeon/dj_pipeline/populate/worker.py @@ -57,7 +57,6 @@ def ingest_environment_visits(): "acquisition_worker", worker_schema_name=worker_schema_name, db_prefix=db_prefix, - run_duration=-1, max_idled_cycle=6, sleep_duration=1200, ) @@ -72,7 +71,6 @@ def ingest_environment_visits(): "pyrat_worker", worker_schema_name=worker_schema_name, db_prefix=db_prefix, - run_duration=-1, max_idled_cycle=400, sleep_duration=30, ) @@ -87,9 +85,8 @@ def ingest_environment_visits(): "streams_worker", worker_schema_name=worker_schema_name, db_prefix=db_prefix, - run_duration=-1, - max_idled_cycle=3, - sleep_duration=10, + max_idled_cycle=50, + sleep_duration=60, ) for attr in vars(streams).values(): @@ -104,7 +101,6 @@ def ingest_environment_visits(): "analysis_worker", worker_schema_name=worker_schema_name, db_prefix=db_prefix, - run_duration=-1, max_idled_cycle=6, sleep_duration=1200, ) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 7a1f3455..d208e44c 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -177,7 +177,7 @@ SciViz: destination: note New Experiment Directory: - route: /exp_note_form + route: /exp_directory_form x: 0 y: 1.0 height: 0.3 From 3a2979936096808446b4a27f161bf42cea91d39a Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 2 Feb 2024 23:31:04 +0000 Subject: [PATCH 427/489] fix: :bug: compose down workers first before removing images --- docker/cron_script.bash | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/docker/cron_script.bash b/docker/cron_script.bash index bc3dbc4a..8cabf77d 100755 --- a/docker/cron_script.bash +++ b/docker/cron_script.bash @@ -2,14 +2,14 @@ # This script will be run every 4 hours in a cron job. # Open up a crontab ('crontab -e') and add the following: -# 0 */4 * * * /path/to/cron_script.bash +# 0 */4 * * * /path/to/cron_script.bash # For debugging, run ./cron_script.bash -v verbose=0 while [[ "$#" -gt 0 ]]; do case $1 in - -v|--verbose) verbose=1 ;; + -v | --verbose) verbose=1 ;; esac shift done @@ -24,12 +24,7 @@ print_verbose() { print_verbose "Starting Ingestion..." cd /nfs/nhome/live/aeon_db/aeon_mecha/docker/ -if docker image inspect ghcr.io/sainsburywellcomecentre/aeon_mecha > /dev/null 2>&1; then - print_verbose "Removing existing aeon_mecha image..." - docker image rm ghcr.io/sainsburywellcomecentre/aeon_mecha -fi - -print_verbose "Terminate worker containers..." +print_verbose "Terminate running workers..." docker-compose down if [ $? -eq 0 ]; then print_verbose "Workers terminated successfully." @@ -37,11 +32,15 @@ else print_verbose "Failed to terminate workers." fi -print_verbose "Restart containers..." +if docker image inspect ghcr.io/sainsburywellcomecentre/aeon_mecha >/dev/null 2>&1; then + print_verbose "Removing existing aeon_mecha image..." + docker image rm ghcr.io/sainsburywellcomecentre/aeon_mecha +fi + +print_verbose "Restart workers..." docker-compose up --detach if [ $? -eq 0 ]; then - print_verbose "Containers restarted successfully." + print_verbose "workers restarted successfully." else - print_verbose "Failed to restart containers." + print_verbose "Failed to restart workers." fi - From 547b9ed9c6fc02309558b2d3936626875b44d94a Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 5 Feb 2024 23:12:48 +0000 Subject: [PATCH 428/489] chore: :loud_sound: Create a logger for a cron job --- docker/cron_script.bash | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docker/cron_script.bash b/docker/cron_script.bash index 8cabf77d..bcad562d 100755 --- a/docker/cron_script.bash +++ b/docker/cron_script.bash @@ -5,6 +5,11 @@ # 0 */4 * * * /path/to/cron_script.bash # For debugging, run ./cron_script.bash -v +# Create a log file whenever the job gets run. +ROOT_LOG_DIR="/ceph/aeon/aeon/dj_store/logs" +mkdir -p "${ROOT_LOG_DIR}" +LOG_FILE="${ROOT_LOG_DIR}/cron_script_$(date '+%Y%m%d_%H%M%S').log" + verbose=0 while [[ "$#" -gt 0 ]]; do @@ -17,7 +22,7 @@ done # Verbose option for debugging print_verbose() { if [ "$verbose" -eq 1 ]; then - printf "[DEBUG] %s - %s\n\n" "$1" "$(date '+%Y-%m-%d %H:%M:%S')" + echo "[DEBUG] $1 - $(date '+%Y-%m-%d %H:%M:%S')" >>"$LOG_FILE" fi } @@ -25,7 +30,7 @@ print_verbose "Starting Ingestion..." cd /nfs/nhome/live/aeon_db/aeon_mecha/docker/ print_verbose "Terminate running workers..." -docker-compose down +docker-compose down >>"$LOG_FILE" 2>&1 if [ $? -eq 0 ]; then print_verbose "Workers terminated successfully." else @@ -34,13 +39,13 @@ fi if docker image inspect ghcr.io/sainsburywellcomecentre/aeon_mecha >/dev/null 2>&1; then print_verbose "Removing existing aeon_mecha image..." - docker image rm ghcr.io/sainsburywellcomecentre/aeon_mecha + docker image rm ghcr.io/sainsburywellcomecentre/aeon_mecha >>"$LOG_FILE" 2>&1 fi print_verbose "Restart workers..." -docker-compose up --detach +docker-compose up --detach >>"$LOG_FILE" 2>&1 if [ $? -eq 0 ]; then - print_verbose "workers restarted successfully." + print_verbose "Workers restarted successfully." else print_verbose "Failed to restart workers." fi From cfb6004a0e64809693e9e1d620c2c175a26475e3 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 6 Feb 2024 15:29:31 +0000 Subject: [PATCH 429/489] fix: :bug: specify docker path cron job runs a different version of docker-compose which yields version incompatibility issue --- docker/cron_script.bash | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/docker/cron_script.bash b/docker/cron_script.bash index bcad562d..f09c1f86 100755 --- a/docker/cron_script.bash +++ b/docker/cron_script.bash @@ -4,7 +4,6 @@ # Open up a crontab ('crontab -e') and add the following: # 0 */4 * * * /path/to/cron_script.bash # For debugging, run ./cron_script.bash -v - # Create a log file whenever the job gets run. ROOT_LOG_DIR="/ceph/aeon/aeon/dj_store/logs" mkdir -p "${ROOT_LOG_DIR}" @@ -22,7 +21,7 @@ done # Verbose option for debugging print_verbose() { if [ "$verbose" -eq 1 ]; then - echo "[DEBUG] $1 - $(date '+%Y-%m-%d %H:%M:%S')" >>"$LOG_FILE" + echo "[DEBUG] $1 - $(date '+%Y-%m-%d %H:%M:%S')" >> "$LOG_FILE" fi } @@ -30,22 +29,22 @@ print_verbose "Starting Ingestion..." cd /nfs/nhome/live/aeon_db/aeon_mecha/docker/ print_verbose "Terminate running workers..." -docker-compose down >>"$LOG_FILE" 2>&1 +/usr/local/bin/docker-compose down >> "$LOG_FILE" 2>&1 if [ $? -eq 0 ]; then print_verbose "Workers terminated successfully." else print_verbose "Failed to terminate workers." fi -if docker image inspect ghcr.io/sainsburywellcomecentre/aeon_mecha >/dev/null 2>&1; then +if /usr/bin/docker image inspect ghcr.io/sainsburywellcomecentre/aeon_mecha >/dev/null 2>&1; then print_verbose "Removing existing aeon_mecha image..." - docker image rm ghcr.io/sainsburywellcomecentre/aeon_mecha >>"$LOG_FILE" 2>&1 + /usr/bin/docker image rm ghcr.io/sainsburywellcomecentre/aeon_mecha >> "$LOG_FILE" 2>&1 fi print_verbose "Restart workers..." -docker-compose up --detach >>"$LOG_FILE" 2>&1 +/usr/local/bin/docker-compose up --detach >> "$LOG_FILE" 2>&1 if [ $? -eq 0 ]; then print_verbose "Workers restarted successfully." else print_verbose "Failed to restart workers." -fi +fi \ No newline at end of file From f8d3cace6f5c74a5c248ec9a3687eb132df981b2 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 6 Feb 2024 15:55:25 +0000 Subject: [PATCH 430/489] fix cron logger --- docker/cron_script.bash | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker/cron_script.bash b/docker/cron_script.bash index f09c1f86..6923867a 100755 --- a/docker/cron_script.bash +++ b/docker/cron_script.bash @@ -21,8 +21,9 @@ done # Verbose option for debugging print_verbose() { if [ "$verbose" -eq 1 ]; then - echo "[DEBUG] $1 - $(date '+%Y-%m-%d %H:%M:%S')" >> "$LOG_FILE" + echo "[DEBUG] $1 - $(date '+%Y-%m-%d %H:%M:%S')" fi + echo "[DEBUG] $1 - $(date '+%Y-%m-%d %H:%M:%S')" >> "$LOG_FILE" } print_verbose "Starting Ingestion..." From 07f4df53a4325af3971e9dbe33fa6b89fe535a8d Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 6 Feb 2024 17:53:06 -0600 Subject: [PATCH 431/489] fix(social02): bugfix social schema reader --- aeon/schema/social.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/schema/social.py b/aeon/schema/social.py index a6a70c6b..54c08302 100644 --- a/aeon/schema/social.py +++ b/aeon/schema/social.py @@ -50,7 +50,7 @@ # SubjectVisits subject_visits_b = lambda pattern: { - "SubjectVisits": reader.Csv(f"{pattern}_SubjectVisit_*", ["id", "type", "region"]) + "SubjectVisits": reader.Csv(f"{pattern}_SubjectVisits_*", ["id", "type", "region"]) } # SubjectWeight From 700d076c89afb1fb133bfc132878a61affb9de3f Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 6 Feb 2024 17:53:24 -0600 Subject: [PATCH 432/489] update: more robust check for subjects in a given block --- aeon/dj_pipeline/analysis/block_analysis.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index e2c6595e..cc27766e 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -156,9 +156,20 @@ def make(self, key): ) # Subject data - subject_names = set( - (tracking.SLEAPTracking.PoseIdentity & key & chunk_restriction).fetch("identity_name") - ) + # Get all unique subjects that visited the environment over the entire exp; + # For each subject, see 'type' of visit most recent to start of block + # If "Exit", this animal was not in the block. + subject_visits_df = fetch_stream( + acquisition.Environment.SubjectVisits + & key + & f'chunk_start <= "{chunk_keys[-1]["chunk_start"]}"' + )[:block_start] + subject_visits_df = subject_visits_df[subject_visits_df.region == "Environment"] + subject_names = [] + for subject_name in set(subject_visits_df.id): + _df = subject_visits_df[subject_visits_df.id == subject_name] + if _df.type[-1] != "Exit": + subject_names.append(subject_name) for subject_name in subject_names: # positions - query for CameraTop, identity_name matches subject_name, pos_query = ( From b3acd929d7c665746b93c5d8c17315becb591006 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 7 Feb 2024 10:02:24 -0600 Subject: [PATCH 433/489] Update social_experiments_block_analysis.ipynb --- ...nalysis.ipynb => social_experiments_block_analysis.ipynb} | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) rename aeon/dj_pipeline/docs/notebooks/{social01_block_analysis.ipynb => social_experiments_block_analysis.ipynb} (93%) diff --git a/aeon/dj_pipeline/docs/notebooks/social01_block_analysis.ipynb b/aeon/dj_pipeline/docs/notebooks/social_experiments_block_analysis.ipynb similarity index 93% rename from aeon/dj_pipeline/docs/notebooks/social01_block_analysis.ipynb rename to aeon/dj_pipeline/docs/notebooks/social_experiments_block_analysis.ipynb index 9c012913..c7623089 100644 --- a/aeon/dj_pipeline/docs/notebooks/social01_block_analysis.ipynb +++ b/aeon/dj_pipeline/docs/notebooks/social_experiments_block_analysis.ipynb @@ -14,8 +14,7 @@ { "cell_type": "markdown", "source": [ - "## Create VirtualModule to access `aeon_test_analysis` schema\n", - "Currently, the analysis is on `aeon_test_`, will move to `aeon_` soon (once ready for production)" + "## Create VirtualModule to access `aeon_block_analysis` schema" ], "metadata": { "collapsed": false, @@ -29,7 +28,7 @@ "execution_count": null, "outputs": [], "source": [ - "analysis_vm = dj.create_virtual_module('aeon_test_analysis', 'aeon_test_analysis')" + "analysis_vm = dj.create_virtual_module('aeon_block_analysis', 'aeon_block_analysis')" ], "metadata": { "collapsed": false, From d2a51e7653556682607b5f6c7bb2586c249f8aea Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 7 Feb 2024 10:39:14 -0600 Subject: [PATCH 434/489] fix(sciviz): fix typo --- aeon/dj_pipeline/webapps/sciviz/specsheet.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index d208e44c..c7d2229b 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -686,7 +686,7 @@ SciViz: dj_query: > def dj_query(aeon_archived_exp02_acquisition): acquisition = aeon_archived_exp02_acquisition - return {'query': aeon_acquisition.Experiment(), 'fetch_args': ['experiment_name']} + return {'query': acquisition.Experiment(), 'fetch_args': ['experiment_name']} camera_dropdown: x: 0 y: 1 From cf3070b241b0a6aebe4e928babad80ccd54b9ba9 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 15 Feb 2024 13:14:40 -0600 Subject: [PATCH 435/489] fix(reader): bugfix csv reader dropping column when the csv file is empty --- aeon/io/reader.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/aeon/io/reader.py b/aeon/io/reader.py index 23ccdb6b..34f258cd 100644 --- a/aeon/io/reader.py +++ b/aeon/io/reader.py @@ -126,7 +126,13 @@ def __init__(self, pattern, columns, dtype=None, extension="csv"): def read(self, file): """Reads data from the specified CSV text file.""" - return pd.read_csv(file, header=0, names=self.columns, dtype=self.dtype, index_col=0) + return pd.read_csv( + file, + header=0, + names=self.columns, + dtype=self.dtype, + index_col=0 if file.stat().st_size else None, + ) class Subject(Csv): From 8da259ceb7490e67327ce82e76376096a67a951f Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 15 Feb 2024 13:22:32 -0600 Subject: [PATCH 436/489] feat(block_analysis): add BlockSubject analysis based on Jai's initial work --- aeon/dj_pipeline/analysis/block_analysis.py | 142 +++++++++++++++++++- 1 file changed, 137 insertions(+), 5 deletions(-) diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index cc27766e..fd72f8fd 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -5,6 +5,7 @@ import pandas as pd import plotly.express as px import plotly.graph_objs as go +from matplotlib import path as mpl_path from aeon.analysis import utils as analysis_utils from aeon.dj_pipeline import acquisition, fetch_stream, get_schema_name, streams, tracking @@ -226,14 +227,145 @@ class Patch(dj.Part): in_patch_time: float # total seconds spent in this patch for this block pellet_count: int pellet_timestamps: longblob - wheel_distance_travelled: longblob # wheel's cumulative distance travelled - wheel_timestamps: longblob - cumulative_sum_preference: longblob - windowed_sum_preference: longblob + wheel_cumsum_distance_travelled: longblob # wheel's cumulative distance travelled + """ + + class Preference(dj.Part): + definition = """ # Measure of preference for a particular patch from a particular subject + -> master + -> BlockAnalysis.Patch + -> BlockAnalysis.Subject + --- + cumulative_preference_by_wheel: longblob + windowed_preference_by_wheel: longblob + cumulative_preference_by_pellet: longblob + windowed_preference_by_pellet: longblob + cumulative_preference_by_time: longblob + windowed_preference_by_time: longblob + preference_score: float # one representative preference score for the entire block """ def make(self, key): - pass + block_patches = (BlockAnalysis.Patch & key).fetch(as_dict=True) + block_subjects = (BlockAnalysis.Subject & key).fetch(as_dict=True) + subject_names = [s["subject_name"] for s in block_subjects] + # Construct subject position dataframe + subjects_positions_df = pd.concat( + [ + pd.DataFrame( + {"subject_name": [s["subject_name"]] * len(s["position_timestamps"])} + | { + k: s[k] + for k in ( + "position_timestamps", + "position_x", + "position_y", + "position_likelihood", + ) + } + ) + for s in block_subjects + ] + ) + subjects_positions_df.set_index("position_timestamps", inplace=True) + + self.insert1(key) + for i, patch in enumerate(block_patches): + cum_wheel_dist = pd.Series( + index=patch["wheel_timestamps"], data=patch["wheel_cumsum_distance_travelled"] + ) + # Get distance-to-patch at each pose data timestep + patch_region = ( + acquisition.EpochActiveRegion.Region + & key + & {"region_name": f"{patch['patch_name']}Region"} + & f'epoch_start < "{key["block_start"]}"' + ).fetch("region_data", order_by="epoch_start DESC", limit=1)[0] + patch_xy = list(zip(*[(int(p["X"]), int(p["Y"])) for p in patch_region["ArrayOfPoint"]])) + patch_center = np.mean(patch_xy[0]).astype(np.uint32), np.mean(patch_xy[1]).astype(np.uint32) + subjects_xy = subjects_positions_df[["position_x", "position_y"]].values + dist_to_patch = np.sqrt(np.sum((subjects_xy - patch_center) ** 2, axis=1).astype(float)) + dist_to_patch_df = subjects_positions_df[["subject_name"]].copy() + dist_to_patch_df["dist_to_patch"] = dist_to_patch + # Assign pellets and wheel timestamps to subjects + if len(block_subjects) == 1: + cum_wheel_dist_dm = cum_wheel_dist.to_frame(name=subject_names[0]) + patch_df_for_pellets_df = pd.DataFrame( + index=patch["pellet_timestamps"], data={"subject_name": subject_names[0]} + ) + else: + # Assign id based on which subject was closest to patch at time of event + # Get distance-to-patch at each wheel ts and pel del ts, organized by subject + dist_to_patch_wheel_ts_id_df = pd.DataFrame( + index=cum_wheel_dist.index, columns=subject_names + ) + dist_to_patch_pel_ts_id_df = pd.DataFrame( + index=patch["pellet_timestamps"], columns=subject_names + ) + for subject_name in subject_names: + # Find closest match between pose_df indices and wheel indices + dist_to_patch_wheel_ts_subj = pd.merge_asof( + left=dist_to_patch_wheel_ts_id_df[subject_name], + right=dist_to_patch_df[dist_to_patch_df["subject_name"] == subject_name], + left_index=True, + right_index=True, + direction="forward", + tolerance=pd.Timedelta("100ms"), + ) + dist_to_patch_wheel_ts_id_df[subject_name] = dist_to_patch_wheel_ts_subj[ + "dist_to_patch" + ] + # Find closest match between pose_df indices and pel indices + dist_to_patch_pel_ts_subj = pd.merge_asof( + left=dist_to_patch_pel_ts_id_df[subject_name], + right=dist_to_patch_df[dist_to_patch_df["subject_name"] == subject_name], + left_index=True, + right_index=True, + direction="forward", + tolerance=pd.Timedelta("200ms"), + ) + dist_to_patch_pel_ts_id_df[subject_name] = dist_to_patch_pel_ts_subj["dist_to_patch"] + # Get closest subject to patch at each pel del timestep + patch_df_for_pellets_df = pd.DataFrame( + index=patch["pellet_timestamps"], + data={"subject_name": dist_to_patch_pel_ts_id_df.idxmin(axis=1).values}, + ) + + # Get closest subject to patch at each wheel timestep + cum_wheel_dist_subj_df = pd.DataFrame( + index=cum_wheel_dist.index, columns=subject_names, data=0.0 + ) + closest_subjects = dist_to_patch_wheel_ts_id_df.idxmin(axis=1) + wheel_dist = cum_wheel_dist.diff().fillna(cum_wheel_dist.iloc[0]) + # Assign wheel dist to closest subject for each wheel timestep + for subject_name in subject_names: + subj_idxs = cum_wheel_dist_subj_df[closest_subjects == subject_name].index + cum_wheel_dist_subj_df.loc[subj_idxs, subject_name] = wheel_dist[subj_idxs] + cum_wheel_dist_dm = cum_wheel_dist_subj_df.cumsum(axis=0) + + # In Patch Time + patch_bbox = mpl_path.Path(list(zip(*patch_xy))) + in_patch = subjects_positions_df.apply( + lambda row: patch_bbox.contains_point((row["position_x"], row["position_y"])), axis=1 + ) + # Insert data + for subject_name in subject_names: + pellets = patch_df_for_pellets_df[patch_df_for_pellets_df["subject_name"] == subject_name] + subject_in_patch = subjects_positions_df[ + in_patch & (subjects_positions_df["subject_name"] == subject_name) + ] + self.Patch.insert1( + key + | dict( + patch_name=patch["patch_name"], + subject_name=subject_name, + in_patch_timestamps=subject_in_patch.index.values, + in_patch_time=len(subject_in_patch), + pellet_count=len(pellets), + pellet_timestamps=pellets.index.values, + wheel_cumsum_distance_travelled=cum_wheel_dist_dm[subject_name].values, + ) + ) @schema From 080819a1118217ee88dc5a99d28b133f668835d1 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 15 Feb 2024 15:27:38 -0600 Subject: [PATCH 437/489] feat(metadata): explicitly extract and store frame rate for video source --- aeon/dj_pipeline/utils/load_metadata.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index b2334516..18f0f690 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -186,6 +186,9 @@ def ingest_epoch_metadata(experiment_name, devices_schema, metadata_yml_filepath device_type_mapper, _ = get_device_mapper(devices_schema, metadata_yml_filepath) + # Retrieve video controller + video_controller = epoch_config["metadata"].pop("VideoController", {}) + # Insert into each device table epoch_device_types = [] device_list = [] @@ -221,6 +224,14 @@ def ingest_epoch_metadata(experiment_name, devices_schema, metadata_yml_filepath } for attribute_name, attribute_value in device_config.items() ] + if "TriggerFrequency" in device_config: + table_attribute_entry.append( + { + **table_entry, + "attribute_name": "SamplingFrequency", + "attribute_value": video_controller[device_config["TriggerFrequency"]], + } + ) """Check if this device is currently installed. If the same device serial number is currently installed check for any changes in configuration. If not, skip this""" current_device_query = table - table.RemovalTime & experiment_key & device_key From 9aaae2bd317f9361a8b906bc305a9e0843995bfd Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 15 Feb 2024 15:41:12 -0600 Subject: [PATCH 438/489] fix(block_analysis): minor bugfix --- aeon/dj_pipeline/analysis/block_analysis.py | 56 +++++++++++++-------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index fd72f8fd..08cab6e4 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -268,6 +268,16 @@ def make(self, key): ] ) subjects_positions_df.set_index("position_timestamps", inplace=True) + # Get frame rate of CameraTop + camera_fps = int( + ( + streams.SpinnakerVideoSource * streams.SpinnakerVideoSource.Attribute + & key + & 'attribute_name = "SamplingFrequency"' + & 'spinnaker_video_source_name = "CameraTop"' + & f'spinnaker_video_source_install_time < "{key["block_start"]}"' + ).fetch("attribute_value", order_by="spinnaker_video_source_install_time DESC", limit=1)[0] + ) self.insert1(key) for i, patch in enumerate(block_patches): @@ -304,27 +314,31 @@ def make(self, key): ) for subject_name in subject_names: # Find closest match between pose_df indices and wheel indices - dist_to_patch_wheel_ts_subj = pd.merge_asof( - left=dist_to_patch_wheel_ts_id_df[subject_name], - right=dist_to_patch_df[dist_to_patch_df["subject_name"] == subject_name], - left_index=True, - right_index=True, - direction="forward", - tolerance=pd.Timedelta("100ms"), - ) - dist_to_patch_wheel_ts_id_df[subject_name] = dist_to_patch_wheel_ts_subj[ - "dist_to_patch" - ] + if not dist_to_patch_wheel_ts_id_df.empty: + dist_to_patch_wheel_ts_subj = pd.merge_asof( + left=dist_to_patch_wheel_ts_id_df[subject_name], + right=dist_to_patch_df[dist_to_patch_df["subject_name"] == subject_name], + left_index=True, + right_index=True, + direction="forward", + tolerance=pd.Timedelta("100ms"), + ) + dist_to_patch_wheel_ts_id_df[subject_name] = dist_to_patch_wheel_ts_subj[ + "dist_to_patch" + ] # Find closest match between pose_df indices and pel indices - dist_to_patch_pel_ts_subj = pd.merge_asof( - left=dist_to_patch_pel_ts_id_df[subject_name], - right=dist_to_patch_df[dist_to_patch_df["subject_name"] == subject_name], - left_index=True, - right_index=True, - direction="forward", - tolerance=pd.Timedelta("200ms"), - ) - dist_to_patch_pel_ts_id_df[subject_name] = dist_to_patch_pel_ts_subj["dist_to_patch"] + if not dist_to_patch_pel_ts_id_df.empty: + dist_to_patch_pel_ts_subj = pd.merge_asof( + left=dist_to_patch_pel_ts_id_df[subject_name], + right=dist_to_patch_df[dist_to_patch_df["subject_name"] == subject_name], + left_index=True, + right_index=True, + direction="forward", + tolerance=pd.Timedelta("200ms"), + ) + dist_to_patch_pel_ts_id_df[subject_name] = dist_to_patch_pel_ts_subj[ + "dist_to_patch" + ] # Get closest subject to patch at each pel del timestep patch_df_for_pellets_df = pd.DataFrame( index=patch["pellet_timestamps"], @@ -360,7 +374,7 @@ def make(self, key): patch_name=patch["patch_name"], subject_name=subject_name, in_patch_timestamps=subject_in_patch.index.values, - in_patch_time=len(subject_in_patch), + in_patch_time=len(subject_in_patch) / camera_fps, pellet_count=len(pellets), pellet_timestamps=pellets.index.values, wheel_cumsum_distance_travelled=cum_wheel_dist_dm[subject_name].values, From 0f84566a4e05fb04afe1bd4f91ae26991c0500d2 Mon Sep 17 00:00:00 2001 From: Chang Huan Lo Date: Fri, 1 Mar 2024 17:55:54 +0000 Subject: [PATCH 439/489] Update license.md --- license.md | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/license.md b/license.md index 4287ca86..8ef38fca 100644 --- a/license.md +++ b/license.md @@ -1 +1,28 @@ -# \ No newline at end of file +BSD 3-Clause License + +Copyright (c) 2024, University College London + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +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. + +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. + +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. From 04b5c30eae5de16346dbbd301880533fc1cee7d1 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 8 Mar 2024 22:33:51 +0000 Subject: [PATCH 440/489] feat: :sparkles: add SocialExperiment page in sciviz --- .../dj_pipeline/webapps/sciviz/specsheet.yaml | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index c7d2229b..38cfaaf4 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -541,6 +541,50 @@ SciViz: aeon_report = aeon_archived_exp02_report return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['region_time_fraction_hourly_plotly']) + SocialExperiment: + route: /social_experiment + grids: + grid3: + type: fixed + columns: 1 + row_height: 700 + components: + SocialExperiment: + route: /social_experiment_grid + link: /per_social_experiment + x: 0 + y: 0 + height: 1 + width: 1 + type: antd-table + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_acquisition, aeon_block_analysis, aeon_tracking): + + import pandas as pd + acquisition = aeon_acquisition + block_analysis = aeon_block_analysis + tracking = aeon_tracking + + query = acquisition.Experiment.aggr(block_analysis.Block, block_count="COUNT(experiment_name)") + acquisition.Experiment.aggr(acquisition.Chunk, chunk_count="COUNT(experiment_name)", latest_chunk_start="MAX(chunk_start)") + + df = tracking.SLEAPTracking.PoseIdentity.proj("identity_name").fetch(format="frame") + df = df.groupby('experiment_name')['identity_name'].unique().reset_index() + + subject_query = None + + for exp in query.fetch("experiment_name"): + # get identity names for each experiment + identities = df[df['experiment_name'] == exp]['identity_name'].values[0] + if not subject_query: + subject_query = dj.U("experiment_name", "subject") & (query & f"experiment_name = '{exp}'").proj(subject=f"CONCAT('{', '.join(identities)}')") + else: + subject_query += dj.U("experiment_name", "subject") & (query & f"experiment_name = '{exp}'").proj(subject=f"CONCAT('{', '.join(identities)}')") + + query.join(subject_query, left=True).proj(..., participants="subject") + BlockAnalysis: route: /block_analysis grids: From edff5012b2332cb67c63fa25ecc7368bd34b1f58 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Fri, 8 Mar 2024 22:49:27 +0000 Subject: [PATCH 441/489] fix: :bug: add return statement --- aeon/dj_pipeline/webapps/sciviz/specsheet.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 38cfaaf4..5ce11497 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -544,7 +544,7 @@ SciViz: SocialExperiment: route: /social_experiment grids: - grid3: + grid1: type: fixed columns: 1 row_height: 700 @@ -583,7 +583,7 @@ SciViz: else: subject_query += dj.U("experiment_name", "subject") & (query & f"experiment_name = '{exp}'").proj(subject=f"CONCAT('{', '.join(identities)}')") - query.join(subject_query, left=True).proj(..., participants="subject") + return {'query': query.join(subject_query, left=True).proj(..., participants="subject"), 'fetch_args': []} BlockAnalysis: route: /block_analysis From bdd6e05fdf8b6a22daacbf994db8fc9828e85306 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Mon, 11 Mar 2024 19:11:04 +0000 Subject: [PATCH 442/489] feat: :sparkles: simplify with GROUP CONCAT --- .../dj_pipeline/webapps/sciviz/specsheet.yaml | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 5ce11497..ea2ef70b 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -563,27 +563,18 @@ SciViz: dj_query: > def dj_query(aeon_acquisition, aeon_block_analysis, aeon_tracking): - import pandas as pd acquisition = aeon_acquisition block_analysis = aeon_block_analysis tracking = aeon_tracking query = acquisition.Experiment.aggr(block_analysis.Block, block_count="COUNT(experiment_name)") + acquisition.Experiment.aggr(acquisition.Chunk, chunk_count="COUNT(experiment_name)", latest_chunk_start="MAX(chunk_start)") - df = tracking.SLEAPTracking.PoseIdentity.proj("identity_name").fetch(format="frame") - df = df.groupby('experiment_name')['identity_name'].unique().reset_index() + query = query.join(acquisition.Experiment.aggr( + tracking.SLEAPTracking.PoseIdentity, + participants="GROUP_CONCAT(DISTINCT identity_name SEPARATOR ', ')" + ), left=True) - subject_query = None - - for exp in query.fetch("experiment_name"): - # get identity names for each experiment - identities = df[df['experiment_name'] == exp]['identity_name'].values[0] - if not subject_query: - subject_query = dj.U("experiment_name", "subject") & (query & f"experiment_name = '{exp}'").proj(subject=f"CONCAT('{', '.join(identities)}')") - else: - subject_query += dj.U("experiment_name", "subject") & (query & f"experiment_name = '{exp}'").proj(subject=f"CONCAT('{', '.join(identities)}')") - - return {'query': query.join(subject_query, left=True).proj(..., participants="subject"), 'fetch_args': []} + return {'query': query, 'fetch_args': []} BlockAnalysis: route: /block_analysis From 1d5d4f820ad9b0fda52aa50503132b384f2bbcb0 Mon Sep 17 00:00:00 2001 From: Chang Huan Lo Date: Tue, 12 Mar 2024 15:25:41 +0000 Subject: [PATCH 443/489] Update license.md year range --- license.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/license.md b/license.md index 8ef38fca..91a756fc 100644 --- a/license.md +++ b/license.md @@ -1,6 +1,6 @@ BSD 3-Clause License -Copyright (c) 2024, University College London +Copyright (c) 2023-2024, University College London Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: From e6768a9b5f3cf6877c261b8aa9d6b1bd6782d620 Mon Sep 17 00:00:00 2001 From: JaerongA Date: Tue, 19 Mar 2024 23:14:58 +0000 Subject: [PATCH 444/489] fix: :bug: fix KeyError in BlockDetection --- aeon/dj_pipeline/analysis/block_analysis.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index 08cab6e4..ebed430b 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -8,8 +8,10 @@ from matplotlib import path as mpl_path from aeon.analysis import utils as analysis_utils -from aeon.dj_pipeline import acquisition, fetch_stream, get_schema_name, streams, tracking -from aeon.dj_pipeline.analysis.visit import filter_out_maintenance_periods, get_maintenance_periods +from aeon.dj_pipeline import (acquisition, fetch_stream, get_schema_name, + streams, tracking) +from aeon.dj_pipeline.analysis.visit import (filter_out_maintenance_periods, + get_maintenance_periods) schema = dj.schema(get_schema_name("block_analysis")) logger = dj.logger @@ -516,7 +518,8 @@ def make(self, key): ) block_query = acquisition.Environment.BlockState & chunk_restriction - block_df = fetch_stream(block_query)[previous_block_start:chunk_end] + block_df = fetch_stream(block_query) + block_df = block_df[block_df.index.to_series().between(previous_block_start, chunk_end)] block_ends = block_df[block_df.pellet_ct.diff() < 0] From 6fd1d84632fefeba6e8cb36e597a89a2372bca7a Mon Sep 17 00:00:00 2001 From: JaerongA Date: Wed, 20 Mar 2024 00:55:39 +0000 Subject: [PATCH 445/489] apply .sort_index() inside BlockDetection make function --- aeon/dj_pipeline/analysis/block_analysis.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index ebed430b..d7c134b8 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -518,8 +518,7 @@ def make(self, key): ) block_query = acquisition.Environment.BlockState & chunk_restriction - block_df = fetch_stream(block_query) - block_df = block_df[block_df.index.to_series().between(previous_block_start, chunk_end)] + block_df = fetch_stream(block_query).sort_index()[previous_block_start:chunk_end] block_ends = block_df[block_df.pellet_ct.diff() < 0] From 19e925e75249330785ca8e285c58b570569b090d Mon Sep 17 00:00:00 2001 From: glopesdev Date: Wed, 20 Mar 2024 12:29:56 +0000 Subject: [PATCH 446/489] Add object model for new stream io infrastructure --- aeon/io/device.py | 3 ++ aeon/io/streams.py | 72 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 aeon/io/streams.py diff --git a/aeon/io/device.py b/aeon/io/device.py index 1a4916e6..1bae0a13 100644 --- a/aeon/io/device.py +++ b/aeon/io/device.py @@ -1,6 +1,8 @@ import inspect +from typing_extensions import deprecated +@deprecated def compositeStream(pattern, *args): """Merges multiple data streams into a single composite stream.""" composite = {} @@ -15,6 +17,7 @@ def compositeStream(pattern, *args): return composite +@deprecated class Device: """Groups multiple data streams into a logical device. diff --git a/aeon/io/streams.py b/aeon/io/streams.py new file mode 100644 index 00000000..c86167a6 --- /dev/null +++ b/aeon/io/streams.py @@ -0,0 +1,72 @@ +import inspect +from itertools import chain + + +class Stream: + """Represents a single data stream. + + Attributes: + reader (Reader): The reader used to retrieve the stream data. + """ + + def __init__(self, reader): + self.reader = reader + + def __iter__(self): + yield (self.__class__.__name__, self.reader) + + +class StreamGroup: + """Represents a logical group of multiple data streams. + + Attributes: + name (str): Name of the logical group used to find raw files. + """ + + def __init__(self, name, *args): + self.name = name + self._args = args + + def __iter__(self): + for member in chain(vars(self.__class__).values(), self._args): + if inspect.isclass(member): + for stream in iter(member(self.name)): + yield stream + elif isinstance(member, staticmethod): + for stream in iter(member.__func__(self.name)): + yield stream + + +def compositeStream(pattern, *args): + """Merges multiple data streams into a single composite stream.""" + composite = {} + if args: + for stream in args: + composite.update(stream(pattern)) + return composite + + +class Device: + """Groups multiple data streams into a logical device. + + If a device contains a single stream with the same pattern as the device + `name`, it will be considered a singleton, and the stream reader will be + paired directly with the device without nesting. + + Attributes: + name (str): Name of the device. + args (Any): Data streams collected from the device. + pattern (str, optional): Pattern used to find raw chunk files, + usually in the format `_`. + """ + + def __init__(self, name, *args, pattern=None): + self.name = name + self.provider = compositeStream(name if pattern is None else pattern, *args) + + def __iter__(self): + if len(self.provider) == 1: + singleton = self.provider.get(self.name, None) + if singleton: + return iter((self.name, singleton)) + return iter((self.name, self.provider)) From a9eac9154735b56126bd16bedc029433c0c5baa6 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Wed, 20 Mar 2024 12:49:32 +0000 Subject: [PATCH 447/489] Support legacy stream group classes --- aeon/io/streams.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/aeon/io/streams.py b/aeon/io/streams.py index c86167a6..ad4ff11d 100644 --- a/aeon/io/streams.py +++ b/aeon/io/streams.py @@ -1,5 +1,6 @@ import inspect from itertools import chain +from warnings import warn class Stream: @@ -42,7 +43,17 @@ def compositeStream(pattern, *args): composite = {} if args: for stream in args: - composite.update(stream(pattern)) + try: + composite.update(stream(pattern)) + except TypeError: + warn( + f"Stream groups with no constructors are deprecated. {stream}", + category=DeprecationWarning, + ) + if inspect.isclass(stream): + for method in vars(stream).values(): + if isinstance(method, staticmethod): + composite.update(method.__func__(pattern)) return composite From e85986b37a1d636d60ee8c61ee08b11d241fd713 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Wed, 20 Mar 2024 12:52:27 +0000 Subject: [PATCH 448/489] Ensure deprecation message --- aeon/io/device.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aeon/io/device.py b/aeon/io/device.py index 1bae0a13..344298e2 100644 --- a/aeon/io/device.py +++ b/aeon/io/device.py @@ -2,7 +2,7 @@ from typing_extensions import deprecated -@deprecated +@deprecated("Please use the streams module instead.") def compositeStream(pattern, *args): """Merges multiple data streams into a single composite stream.""" composite = {} @@ -17,7 +17,7 @@ def compositeStream(pattern, *args): return composite -@deprecated +@deprecated("Please use the Device class in the streams module instead.") class Device: """Groups multiple data streams into a logical device. From 1e499c7e8e677ea74f9646f87f569930cf58c607 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Wed, 20 Mar 2024 17:32:48 +0000 Subject: [PATCH 449/489] Refactor stream classes for clarity --- aeon/io/streams.py | 70 ++++++++++++++++++++++------------------------ 1 file changed, 33 insertions(+), 37 deletions(-) diff --git a/aeon/io/streams.py b/aeon/io/streams.py index ad4ff11d..39a35931 100644 --- a/aeon/io/streams.py +++ b/aeon/io/streams.py @@ -1,5 +1,4 @@ import inspect -from itertools import chain from warnings import warn @@ -21,40 +20,18 @@ class StreamGroup: """Represents a logical group of multiple data streams. Attributes: - name (str): Name of the logical group used to find raw files. + path (str): Path to the folder where stream chunks are located. + args (Any): Data streams or data stream groups to be included in this stream group. """ - def __init__(self, name, *args): - self.name = name + def __init__(self, path, *args): + self.path = path self._args = args def __iter__(self): - for member in chain(vars(self.__class__).values(), self._args): - if inspect.isclass(member): - for stream in iter(member(self.name)): - yield stream - elif isinstance(member, staticmethod): - for stream in iter(member.__func__(self.name)): - yield stream - - -def compositeStream(pattern, *args): - """Merges multiple data streams into a single composite stream.""" - composite = {} - if args: - for stream in args: - try: - composite.update(stream(pattern)) - except TypeError: - warn( - f"Stream groups with no constructors are deprecated. {stream}", - category=DeprecationWarning, - ) - if inspect.isclass(stream): - for method in vars(stream).values(): - if isinstance(method, staticmethod): - composite.update(method.__func__(pattern)) - return composite + for callable in self._args: + for stream in iter(callable(self.path)): + yield stream class Device: @@ -67,17 +44,36 @@ class Device: Attributes: name (str): Name of the device. args (Any): Data streams collected from the device. - pattern (str, optional): Pattern used to find raw chunk files, - usually in the format `_`. + path (str, optional): Path to the folder where stream chunks are located. """ - def __init__(self, name, *args, pattern=None): + def __init__(self, name, *args, path=None): self.name = name - self.provider = compositeStream(name if pattern is None else pattern, *args) + self._streams = Device._createStreams(name if path is None else path, *args) + + @staticmethod + def _createStreams(path, *args): + streams = {} + if args: + for callable in args: + try: + streams.update(callable(path)) + except TypeError: + if inspect.isclass(callable): + warn( + f"Stream group classes with no constructors are deprecated. {callable}", + category=DeprecationWarning, + ) + for method in vars(callable).values(): + if isinstance(method, staticmethod): + streams.update(method.__func__(path)) + else: + raise + return streams def __iter__(self): - if len(self.provider) == 1: - singleton = self.provider.get(self.name, None) + if len(self._streams) == 1: + singleton = self._streams.get(self.name, None) if singleton: return iter((self.name, singleton)) - return iter((self.name, self.provider)) + return iter((self.name, self._streams)) From 31721cc9db8ed617283d52fcbb3e76e16bd802a7 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Wed, 20 Mar 2024 17:44:24 +0000 Subject: [PATCH 450/489] Ensure name is not None --- aeon/io/streams.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/aeon/io/streams.py b/aeon/io/streams.py index 39a35931..d005b53b 100644 --- a/aeon/io/streams.py +++ b/aeon/io/streams.py @@ -48,6 +48,9 @@ class Device: """ def __init__(self, name, *args, path=None): + if name is None: + raise ValueError("name cannot be None.") + self.name = name self._streams = Device._createStreams(name if path is None else path, *args) From 8f4a90b30f57f82d61c6f10aeb6176b3394e976c Mon Sep 17 00:00:00 2001 From: glopesdev Date: Wed, 20 Mar 2024 18:08:43 +0000 Subject: [PATCH 451/489] Remove unused conditional --- aeon/io/streams.py | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/aeon/io/streams.py b/aeon/io/streams.py index d005b53b..a861da27 100644 --- a/aeon/io/streams.py +++ b/aeon/io/streams.py @@ -52,26 +52,25 @@ def __init__(self, name, *args, path=None): raise ValueError("name cannot be None.") self.name = name - self._streams = Device._createStreams(name if path is None else path, *args) + self._streams = Device._createStreams(name if path is None else path, args) @staticmethod - def _createStreams(path, *args): + def _createStreams(path, args): streams = {} - if args: - for callable in args: - try: - streams.update(callable(path)) - except TypeError: - if inspect.isclass(callable): - warn( - f"Stream group classes with no constructors are deprecated. {callable}", - category=DeprecationWarning, - ) - for method in vars(callable).values(): - if isinstance(method, staticmethod): - streams.update(method.__func__(path)) - else: - raise + for callable in args: + try: + streams.update(callable(path)) + except TypeError: + if inspect.isclass(callable): + warn( + f"Stream group classes with no constructors are deprecated. {callable}", + category=DeprecationWarning, + ) + for method in vars(callable).values(): + if isinstance(method, staticmethod): + streams.update(method.__func__(path)) + else: + raise return streams def __iter__(self): From 75e9d264f336bd0afe624c9c70b2d7518108edd4 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Wed, 20 Mar 2024 18:12:00 +0000 Subject: [PATCH 452/489] Improve deprecation warnings --- aeon/io/device.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aeon/io/device.py b/aeon/io/device.py index 344298e2..54998b17 100644 --- a/aeon/io/device.py +++ b/aeon/io/device.py @@ -2,7 +2,7 @@ from typing_extensions import deprecated -@deprecated("Please use the streams module instead.") +@deprecated("Please use the StreamGroup class from the streams module instead.") def compositeStream(pattern, *args): """Merges multiple data streams into a single composite stream.""" composite = {} @@ -17,7 +17,7 @@ def compositeStream(pattern, *args): return composite -@deprecated("Please use the Device class in the streams module instead.") +@deprecated("The Device class has been moved to the streams module.") class Device: """Groups multiple data streams into a logical device. From b5627a6a9752c18050bc75f381409fff5e680203 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Wed, 20 Mar 2024 18:17:50 +0000 Subject: [PATCH 453/489] Avoid exception handling on deprecated code path --- aeon/io/streams.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/aeon/io/streams.py b/aeon/io/streams.py index a861da27..c7d91a27 100644 --- a/aeon/io/streams.py +++ b/aeon/io/streams.py @@ -58,19 +58,16 @@ def __init__(self, name, *args, path=None): def _createStreams(path, args): streams = {} for callable in args: - try: + if inspect.isclass(callable) and callable.__init__.__code__.co_argcount == 1: + warn( + f"Stream group classes with default constructors are deprecated. {callable}", + category=DeprecationWarning, + ) + for method in vars(callable).values(): + if isinstance(method, staticmethod): + streams.update(method.__func__(path)) + else: streams.update(callable(path)) - except TypeError: - if inspect.isclass(callable): - warn( - f"Stream group classes with no constructors are deprecated. {callable}", - category=DeprecationWarning, - ) - for method in vars(callable).values(): - if isinstance(method, staticmethod): - streams.update(method.__func__(path)) - else: - raise return streams def __iter__(self): From e5e75f04e6803fa49542847bc13fad456627ec29 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Wed, 20 Mar 2024 18:20:46 +0000 Subject: [PATCH 454/489] Avoid clashing with built-in function names --- aeon/io/streams.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/aeon/io/streams.py b/aeon/io/streams.py index c7d91a27..b23073bc 100644 --- a/aeon/io/streams.py +++ b/aeon/io/streams.py @@ -29,8 +29,8 @@ def __init__(self, path, *args): self._args = args def __iter__(self): - for callable in self._args: - for stream in iter(callable(self.path)): + for factory in self._args: + for stream in iter(factory(self.path)): yield stream @@ -57,17 +57,17 @@ def __init__(self, name, *args, path=None): @staticmethod def _createStreams(path, args): streams = {} - for callable in args: - if inspect.isclass(callable) and callable.__init__.__code__.co_argcount == 1: + for factory in args: + if inspect.isclass(factory) and factory.__init__.__code__.co_argcount == 1: warn( - f"Stream group classes with default constructors are deprecated. {callable}", + f"Stream group classes with default constructors are deprecated. {factory}", category=DeprecationWarning, ) - for method in vars(callable).values(): + for method in vars(factory).values(): if isinstance(method, staticmethod): streams.update(method.__func__(path)) else: - streams.update(callable(path)) + streams.update(factory(path)) return streams def __iter__(self): From d3a7a87120061bdfa6b0d6f73d59c70e67e69ff9 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Wed, 20 Mar 2024 21:07:43 +0000 Subject: [PATCH 455/489] Move stream schema classes to the schema module --- aeon/{io => schema}/streams.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename aeon/{io => schema}/streams.py (100%) diff --git a/aeon/io/streams.py b/aeon/schema/streams.py similarity index 100% rename from aeon/io/streams.py rename to aeon/schema/streams.py From 0e9a6a675507b4ac161640a3e8bf0d2f49c3d82e Mon Sep 17 00:00:00 2001 From: glopesdev Date: Wed, 20 Mar 2024 21:51:17 +0000 Subject: [PATCH 456/489] Fix check for auto-generated initializer --- aeon/schema/streams.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/schema/streams.py b/aeon/schema/streams.py index b23073bc..6104178f 100644 --- a/aeon/schema/streams.py +++ b/aeon/schema/streams.py @@ -58,7 +58,7 @@ def __init__(self, name, *args, path=None): def _createStreams(path, args): streams = {} for factory in args: - if inspect.isclass(factory) and factory.__init__.__code__.co_argcount == 1: + if inspect.isclass(factory) and not hasattr(factory.__init__, "__code__"): warn( f"Stream group classes with default constructors are deprecated. {factory}", category=DeprecationWarning, From 51fe0b974d02293cc9b402073114b8ff9463ddb2 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Wed, 20 Mar 2024 21:55:52 +0000 Subject: [PATCH 457/489] Allow automatic import of nested stream schemas --- aeon/schema/streams.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/aeon/schema/streams.py b/aeon/schema/streams.py index 6104178f..f306bf63 100644 --- a/aeon/schema/streams.py +++ b/aeon/schema/streams.py @@ -1,4 +1,5 @@ import inspect +from itertools import chain from warnings import warn @@ -27,9 +28,14 @@ class StreamGroup: def __init__(self, path, *args): self.path = path self._args = args + self._nested = ( + member + for member in vars(self.__class__).values() + if inspect.isclass(member) and issubclass(member, (Stream, StreamGroup)) + ) def __iter__(self): - for factory in self._args: + for factory in chain(self._nested, self._args): for stream in iter(factory(self.path)): yield stream From 9220b1e5a42d7d71cdd440833e012176b337e65e Mon Sep 17 00:00:00 2001 From: glopesdev Date: Wed, 20 Mar 2024 21:56:20 +0000 Subject: [PATCH 458/489] Black formatting --- tests/io/test_api.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/io/test_api.py b/tests/io/test_api.py index 48986830..f7acd77d 100644 --- a/tests/io/test_api.py +++ b/tests/io/test_api.py @@ -19,17 +19,13 @@ def test_load_start_only(): @mark.api def test_load_end_only(): - data = aeon.load( - nonmonotonic_path, exp02.Patch2.Encoder, end=pd.Timestamp("2022-06-06T13:00:49") - ) + data = aeon.load(nonmonotonic_path, exp02.Patch2.Encoder, end=pd.Timestamp("2022-06-06T13:00:49")) assert len(data) > 0 @mark.api def test_load_filter_nonchunked(): - data = aeon.load( - nonmonotonic_path, exp02.Metadata, start=pd.Timestamp("2022-06-06T09:00:00") - ) + data = aeon.load(nonmonotonic_path, exp02.Metadata, start=pd.Timestamp("2022-06-06T09:00:00")) assert len(data) > 0 From 258900ff010e34a678085878373d7415ca476fb7 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Wed, 20 Mar 2024 22:29:49 +0000 Subject: [PATCH 459/489] Refactor dataset module to use stream schemas --- aeon/schema/core.py | 56 +++++--- aeon/schema/dataset.py | 60 ++++---- aeon/schema/foraging.py | 74 ++++++---- aeon/schema/octagon.py | 305 ++++++++++++++++++++-------------------- 4 files changed, 270 insertions(+), 225 deletions(-) diff --git a/aeon/schema/core.py b/aeon/schema/core.py index 8181c710..f3ca95a5 100644 --- a/aeon/schema/core.py +++ b/aeon/schema/core.py @@ -1,47 +1,65 @@ -import aeon.io.device as _device +from aeon.schema.streams import Stream, StreamGroup import aeon.io.reader as _reader -def heartbeat(pattern): +class Heartbeat(Stream): """Heartbeat event for Harp devices.""" - return {"Heartbeat": _reader.Heartbeat(f"{pattern}_8_*")} + def __init__(self, pattern): + super().__init__(_reader.Heartbeat(f"{pattern}_8_*")) -def video(pattern): + +class Video(Stream): """Video frame metadata.""" - return {"Video": _reader.Video(f"{pattern}_*")} + + def __init__(self, pattern): + super().__init__(_reader.Video(f"{pattern}_*")) -def position(pattern): +class Position(Stream): """Position tracking data for the specified camera.""" - return {"Position": _reader.Position(f"{pattern}_200_*")} + + def __init__(self, pattern): + super().__init__(_reader.Position(f"{pattern}_200_*")) -def encoder(pattern): +class Encoder(Stream): """Wheel magnetic encoder data.""" - return {"Encoder": _reader.Encoder(f"{pattern}_90_*")} + def __init__(self, pattern): + super().__init__(_reader.Encoder(f"{pattern}_90_*")) -def environment(pattern): + +class Environment(StreamGroup): """Metadata for environment mode and subjects.""" - return _device.compositeStream(pattern, environment_state, subject_state) + + def __init__(self, pattern): + super().__init__(pattern, EnvironmentState, SubjectState) -def environment_state(pattern): +class EnvironmentState(Stream): """Environment state log.""" - return {"EnvironmentState": _reader.Csv(f"{pattern}_EnvironmentState_*", ["state"])} + def __init__(self, pattern): + super().__init__(_reader.Csv(f"{pattern}_EnvironmentState_*", ["state"])) -def subject_state(pattern): + +class SubjectState(Stream): """Subject state log.""" - return {"SubjectState": _reader.Subject(f"{pattern}_SubjectState_*")} + + def __init__(self, pattern): + super().__init__(_reader.Subject(f"{pattern}_SubjectState_*")) -def messageLog(pattern): +class MessageLog(Stream): """Message log data.""" - return {"MessageLog": _reader.Log(f"{pattern}_MessageLog_*")} + def __init__(self, pattern): + super().__init__(_reader.Log(f"{pattern}_MessageLog_*")) -def metadata(pattern): + +class Metadata(Stream): """Metadata for acquisition epochs.""" - return {pattern: _reader.Metadata(pattern)} + + def __init__(self, pattern): + super().__init__(_reader.Metadata(pattern)) diff --git a/aeon/schema/dataset.py b/aeon/schema/dataset.py index b9586de4..bbb7cbb8 100644 --- a/aeon/schema/dataset.py +++ b/aeon/schema/dataset.py @@ -1,50 +1,50 @@ from dotmap import DotMap import aeon.schema.core as stream -from aeon.io.device import Device +from aeon.schema.streams import Device from aeon.schema import foraging, octagon exp02 = DotMap( [ - Device("Metadata", stream.metadata), - Device("ExperimentalMetadata", stream.environment, stream.messageLog), - Device("CameraTop", stream.video, stream.position, foraging.region), - Device("CameraEast", stream.video), - Device("CameraNest", stream.video), - Device("CameraNorth", stream.video), - Device("CameraPatch1", stream.video), - Device("CameraPatch2", stream.video), - Device("CameraSouth", stream.video), - Device("CameraWest", stream.video), - Device("Nest", foraging.weight), - Device("Patch1", foraging.patch), - Device("Patch2", foraging.patch), + Device("Metadata", stream.Metadata), + Device("ExperimentalMetadata", stream.Environment, stream.MessageLog), + Device("CameraTop", stream.Video, stream.Position, foraging.Region), + Device("CameraEast", stream.Video), + Device("CameraNest", stream.Video), + Device("CameraNorth", stream.Video), + Device("CameraPatch1", stream.Video), + Device("CameraPatch2", stream.Video), + Device("CameraSouth", stream.Video), + Device("CameraWest", stream.Video), + Device("Nest", foraging.Weight), + Device("Patch1", foraging.Patch), + Device("Patch2", foraging.Patch), ] ) exp01 = DotMap( [ - Device("SessionData", foraging.session), - Device("FrameTop", stream.video, stream.position), - Device("FrameEast", stream.video), - Device("FrameGate", stream.video), - Device("FrameNorth", stream.video), - Device("FramePatch1", stream.video), - Device("FramePatch2", stream.video), - Device("FrameSouth", stream.video), - Device("FrameWest", stream.video), - Device("Patch1", foraging.depletionFunction, stream.encoder, foraging.feeder), - Device("Patch2", foraging.depletionFunction, stream.encoder, foraging.feeder), + Device("SessionData", foraging.SessionData), + Device("FrameTop", stream.Video, stream.Position), + Device("FrameEast", stream.Video), + Device("FrameGate", stream.Video), + Device("FrameNorth", stream.Video), + Device("FramePatch1", stream.Video), + Device("FramePatch2", stream.Video), + Device("FrameSouth", stream.Video), + Device("FrameWest", stream.Video), + Device("Patch1", foraging.DepletionFunction, stream.Encoder, foraging.Feeder), + Device("Patch2", foraging.DepletionFunction, stream.Encoder, foraging.Feeder), ] ) octagon01 = DotMap( [ - Device("Metadata", stream.metadata), - Device("CameraTop", stream.video, stream.position), - Device("CameraColorTop", stream.video), - Device("ExperimentalMetadata", stream.subject_state), - Device("Photodiode", octagon.photodiode), + Device("Metadata", stream.Metadata), + Device("CameraTop", stream.Video, stream.Position), + Device("CameraColorTop", stream.Video), + Device("ExperimentalMetadata", stream.SubjectState), + Device("Photodiode", octagon.Photodiode), Device("OSC", octagon.OSC), Device("TaskLogic", octagon.TaskLogic), Device("Wall1", octagon.Wall), diff --git a/aeon/schema/foraging.py b/aeon/schema/foraging.py index ffd8fdd9..c25ff70d 100644 --- a/aeon/schema/foraging.py +++ b/aeon/schema/foraging.py @@ -1,13 +1,11 @@ -from enum import Enum as _Enum - +from enum import Enum import pandas as pd - -import aeon.io.device as _device import aeon.io.reader as _reader import aeon.schema.core as _stream +from aeon.schema.streams import Stream, StreamGroup -class Area(_Enum): +class Area(Enum): Null = 0 Nest = 1 Corridor = 2 @@ -53,56 +51,78 @@ def __init__(self, pattern): super().__init__(pattern, columns=["value", "stable"]) -def region(pattern): +class Region(Stream): """Region tracking data for the specified camera.""" - return {"Region": _RegionReader(f"{pattern}_201_*")} + + def __init__(self, pattern): + super().__init__(_RegionReader(f"{pattern}_201_*")) -def depletionFunction(pattern): +class DepletionFunction(Stream): """State of the linear depletion function for foraging patches.""" - return {"DepletionState": _PatchState(f"{pattern}_State_*")} + + def __init__(self, pattern): + super().__init__(_PatchState(f"{pattern}_State_*")) -def feeder(pattern): +class Feeder(StreamGroup): """Feeder commands and events.""" - return _device.compositeStream(pattern, beam_break, deliver_pellet) + + def __init__(self, pattern): + super().__init__(pattern, BeamBreak, DeliverPellet) -def beam_break(pattern): +class BeamBreak(Stream): """Beam break events for pellet detection.""" - return {"BeamBreak": _reader.BitmaskEvent(f"{pattern}_32_*", 0x22, "PelletDetected")} + + def __init__(self, pattern): + super().__init__(_reader.BitmaskEvent(f"{pattern}_32_*", 0x22, "PelletDetected")) -def deliver_pellet(pattern): +class DeliverPellet(Stream): """Pellet delivery commands.""" - return {"DeliverPellet": _reader.BitmaskEvent(f"{pattern}_35_*", 0x80, "TriggerPellet")} + + def __init__(self, pattern): + super().__init__(_reader.BitmaskEvent(f"{pattern}_35_*", 0x80, "TriggerPellet")) -def patch(pattern): +class Patch(StreamGroup): """Data streams for a patch.""" - return _device.compositeStream(pattern, depletionFunction, _stream.encoder, feeder) + + def __init__(self, pattern): + super().__init__(pattern, DepletionFunction, _stream.Encoder, Feeder) -def weight(pattern): +class Weight(StreamGroup): """Weight measurement data streams for a specific nest.""" - return _device.compositeStream(pattern, weight_raw, weight_filtered, weight_subject) + + def __init__(self, pattern): + super().__init__(pattern, WeightRaw, WeightFiltered, WeightSubject) -def weight_raw(pattern): +class WeightRaw(Stream): """Raw weight measurement for a specific nest.""" - return {"WeightRaw": _Weight(f"{pattern}_200_*")} + + def __init__(self, pattern): + super().__init__(_Weight(f"{pattern}_200_*")) -def weight_filtered(pattern): +class WeightFiltered(Stream): """Filtered weight measurement for a specific nest.""" - return {"WeightFiltered": _Weight(f"{pattern}_202_*")} + + def __init__(self, pattern): + super().__init__(_Weight(f"{pattern}_202_*")) -def weight_subject(pattern): +class WeightSubject(Stream): """Subject weight measurement for a specific nest.""" - return {"WeightSubject": _Weight(f"{pattern}_204_*")} + + def __init__(self, pattern): + super().__init__(_Weight(f"{pattern}_204_*")) -def session(pattern): +class SessionData(Stream): """Session metadata for Experiment 0.1.""" - return {pattern: _reader.Csv(f"{pattern}_2*", columns=["id", "weight", "event"])} + + def __init__(self, pattern): + super().__init__(_reader.Csv(f"{pattern}_2*", columns=["id", "weight", "event"])) diff --git a/aeon/schema/octagon.py b/aeon/schema/octagon.py index a792fac4..2ea85b4e 100644 --- a/aeon/schema/octagon.py +++ b/aeon/schema/octagon.py @@ -1,190 +1,197 @@ import aeon.io.reader as _reader +from aeon.schema.streams import Stream, StreamGroup -def photodiode(pattern): - return {"Photodiode": _reader.Harp(f"{pattern}_44_*", columns=["adc", "encoder"])} +class Photodiode(Stream): + def __init__(self, path): + super().__init__(_reader.Harp(f"{path}_44_*", columns=["adc", "encoder"])) -class OSC: - @staticmethod - def background_color(pattern): - return { - "BackgroundColor": _reader.Csv( - f"{pattern}_backgroundcolor_*", columns=["typetag", "r", "g", "b", "a"] +class OSC(StreamGroup): + def __init__(self, path): + super().__init__(path) + + class BackgroundColor(Stream): + def __init__(self, pattern): + super().__init__( + _reader.Csv(f"{pattern}_backgroundcolor_*", columns=["typetag", "r", "g", "b", "a"]) ) - } - @staticmethod - def change_subject_state(pattern): - return { - "ChangeSubjectState": _reader.Csv( - f"{pattern}_changesubjectstate_*", columns=["typetag", "id", "weight", "event"] + class ChangeSubjectState(Stream): + def __init__(self, pattern): + super().__init__( + _reader.Csv(f"{pattern}_changesubjectstate_*", columns=["typetag", "id", "weight", "event"]) ) - } - @staticmethod - def end_trial(pattern): - return {"EndTrial": _reader.Csv(f"{pattern}_endtrial_*", columns=["typetag", "value"])} + class EndTrial(Stream): + def __init__(self, pattern): + super().__init__(_reader.Csv(f"{pattern}_endtrial_*", columns=["typetag", "value"])) - @staticmethod - def slice(pattern): - return { - "Slice": _reader.Csv( - f"{pattern}_octagonslice_*", columns=["typetag", "wall_id", "r", "g", "b", "a", "delay"] + class Slice(Stream): + def __init__(self, pattern): + super().__init__( + _reader.Csv( + f"{pattern}_octagonslice_*", columns=["typetag", "wall_id", "r", "g", "b", "a", "delay"] + ) ) - } - - @staticmethod - def gratings_slice(pattern): - return { - "GratingsSlice": _reader.Csv( - f"{pattern}_octagongratingsslice_*", - columns=[ - "typetag", - "wall_id", - "contrast", - "opacity", - "spatial_frequency", - "temporal_frequency", - "angle", - "delay", - ], + + class GratingsSlice(Stream): + def __init__(self, pattern): + super().__init__( + _reader.Csv( + f"{pattern}_octagongratingsslice_*", + columns=[ + "typetag", + "wall_id", + "contrast", + "opacity", + "spatial_frequency", + "temporal_frequency", + "angle", + "delay", + ], + ) ) - } - - @staticmethod - def poke(pattern): - return { - "Poke": _reader.Csv( - f"{pattern}_poke_*", - columns=[ - "typetag", - "wall_id", - "poke_id", - "reward", - "reward_interval", - "delay", - "led_delay", - ], + + class Poke(Stream): + def __init__(self, pattern): + super().__init__( + _reader.Csv( + f"{pattern}_poke_*", + columns=[ + "typetag", + "wall_id", + "poke_id", + "reward", + "reward_interval", + "delay", + "led_delay", + ], + ) ) - } - @staticmethod - def response(pattern): - return { - "Response": _reader.Csv( - f"{pattern}_response_*", columns=["typetag", "wall_id", "poke_id", "response_time"] + class Response(Stream): + def __init__(self, pattern): + super().__init__( + _reader.Csv( + f"{pattern}_response_*", columns=["typetag", "wall_id", "poke_id", "response_time"] + ) ) - } - - @staticmethod - def run_pre_trial_no_poke(pattern): - return { - "RunPreTrialNoPoke": _reader.Csv( - f"{pattern}_run_pre_no_poke_*", - columns=[ - "typetag", - "wait_for_poke", - "reward_iti", - "timeout_iti", - "pre_trial_duration", - "activity_reset_flag", - ], + + class RunPreTrialNoPoke(Stream): + def __init__(self, pattern): + super().__init__( + _reader.Csv( + f"{pattern}_run_pre_no_poke_*", + columns=[ + "typetag", + "wait_for_poke", + "reward_iti", + "timeout_iti", + "pre_trial_duration", + "activity_reset_flag", + ], + ) ) - } - @staticmethod - def start_new_session(pattern): - return {"StartNewSession": _reader.Csv(f"{pattern}_startnewsession_*", columns=["typetag", "path"])} + class StartNewSession(Stream): + def __init__(self, pattern): + super().__init__(_reader.Csv(f"{pattern}_startnewsession_*", columns=["typetag", "path"])) + + +class TaskLogic(StreamGroup): + def __init__(self, path): + super().__init__(path) + class TrialInitiation(Stream): + def __init__(self, pattern): + super().__init__(_reader.Harp(f"{pattern}_1_*", columns=["trial_type"])) -class TaskLogic: - @staticmethod - def trial_initiation(pattern): - return {"TrialInitiation": _reader.Harp(f"{pattern}_1_*", columns=["trial_type"])} + class Response(Stream): + def __init__(self, pattern): + super().__init__(_reader.Harp(f"{pattern}_2_*", columns=["wall_id", "poke_id"])) - @staticmethod - def response(pattern): - return {"Response": _reader.Harp(f"{pattern}_2_*", columns=["wall_id", "poke_id"])} + class PreTrialState(Stream): + def __init__(self, pattern): + super().__init__(_reader.Harp(f"{pattern}_3_*", columns=["state"])) - @staticmethod - def pre_trial(pattern): - return {"PreTrialState": _reader.Harp(f"{pattern}_3_*", columns=["state"])} + class InterTrialInterval(Stream): + def __init__(self, pattern): + super().__init__(_reader.Harp(f"{pattern}_4_*", columns=["state"])) - @staticmethod - def inter_trial_interval(pattern): - return {"InterTrialInterval": _reader.Harp(f"{pattern}_4_*", columns=["state"])} + class SliceOnset(Stream): + def __init__(self, pattern): + super().__init__(_reader.Harp(f"{pattern}_10_*", columns=["wall_id"])) - @staticmethod - def slice_onset(pattern): - return {"SliceOnset": _reader.Harp(f"{pattern}_10_*", columns=["wall_id"])} + class DrawBackground(Stream): + def __init__(self, pattern): + super().__init__(_reader.Harp(f"{pattern}_11_*", columns=["state"])) - @staticmethod - def draw_background(pattern): - return {"DrawBackground": _reader.Harp(f"{pattern}_11_*", columns=["state"])} + class GratingsSliceOnset(Stream): + def __init__(self, pattern): + super().__init__(_reader.Harp(f"{pattern}_12_*", columns=["wall_id"])) - @staticmethod - def gratings_slice_onset(pattern): - return {"GratingsSliceOnset": _reader.Harp(f"{pattern}_12_*", columns=["wall_id"])} +class Wall(StreamGroup): + def __init__(self, path): + super().__init__(path) -class Wall: - @staticmethod - def beam_break0(pattern): - return {"BeamBreak0": _reader.DigitalBitmask(f"{pattern}_32_*", 0x1, columns=["state"])} + class BeamBreak0(Stream): + def __init__(self, pattern): + super().__init__(_reader.DigitalBitmask(f"{pattern}_32_*", 0x1, columns=["state"])) - @staticmethod - def beam_break1(pattern): - return {"BeamBreak1": _reader.DigitalBitmask(f"{pattern}_32_*", 0x2, columns=["state"])} + class BeamBreak1(Stream): + def __init__(self, pattern): + super().__init__(_reader.DigitalBitmask(f"{pattern}_32_*", 0x2, columns=["state"])) - @staticmethod - def beam_break2(pattern): - return {"BeamBreak2": _reader.DigitalBitmask(f"{pattern}_32_*", 0x4, columns=["state"])} + class BeamBreak2(Stream): + def __init__(self, pattern): + super().__init__(_reader.DigitalBitmask(f"{pattern}_32_*", 0x4, columns=["state"])) - @staticmethod - def set_led0(pattern): - return {"SetLed0": _reader.BitmaskEvent(f"{pattern}_34_*", 0x1, "Set")} + class SetLed0(Stream): + def __init__(self, pattern): + super().__init__(_reader.BitmaskEvent(f"{pattern}_34_*", 0x1, "Set")) - @staticmethod - def set_led1(pattern): - return {"SetLed1": _reader.BitmaskEvent(f"{pattern}_34_*", 0x2, "Set")} + class SetLed1(Stream): + def __init__(self, pattern): + super().__init__(_reader.BitmaskEvent(f"{pattern}_34_*", 0x2, "Set")) - @staticmethod - def set_led2(pattern): - return {"SetLed2": _reader.BitmaskEvent(f"{pattern}_34_*", 0x4, "Set")} + class SetLed2(Stream): + def __init__(self, pattern): + super().__init__(_reader.BitmaskEvent(f"{pattern}_34_*", 0x4, "Set")) - @staticmethod - def set_valve0(pattern): - return {"SetValve0": _reader.BitmaskEvent(f"{pattern}_34_*", 0x8, "Set")} + class SetValve0(Stream): + def __init__(self, pattern): + super().__init__(_reader.BitmaskEvent(f"{pattern}_34_*", 0x8, "Set")) - @staticmethod - def set_valve1(pattern): - return {"SetValve1": _reader.BitmaskEvent(f"{pattern}_34_*", 0x10, "Set")} + class SetValve1(Stream): + def __init__(self, pattern): + super().__init__(_reader.BitmaskEvent(f"{pattern}_34_*", 0x10, "Set")) - @staticmethod - def set_valve2(pattern): - return {"SetValve2": _reader.BitmaskEvent(f"{pattern}_34_*", 0x20, "Set")} + class SetValve2(Stream): + def __init__(self, pattern): + super().__init__(_reader.BitmaskEvent(f"{pattern}_34_*", 0x20, "Set")) - @staticmethod - def clear_led0(pattern): - return {"ClearLed0": _reader.BitmaskEvent(f"{pattern}_35_*", 0x1, "Clear")} + class ClearLed0(Stream): + def __init__(self, pattern): + super().__init__(_reader.BitmaskEvent(f"{pattern}_35_*", 0x1, "Clear")) - @staticmethod - def clear_led1(pattern): - return {"ClearLed1": _reader.BitmaskEvent(f"{pattern}_35_*", 0x2, "Clear")} + class ClearLed1(Stream): + def __init__(self, pattern): + super().__init__(_reader.BitmaskEvent(f"{pattern}_35_*", 0x2, "Clear")) - @staticmethod - def clear_led2(pattern): - return {"ClearLed2": _reader.BitmaskEvent(f"{pattern}_35_*", 0x4, "Clear")} + class ClearLed2(Stream): + def __init__(self, pattern): + super().__init__(_reader.BitmaskEvent(f"{pattern}_35_*", 0x4, "Clear")) - @staticmethod - def clear_valve0(pattern): - return {"ClearValve0": _reader.BitmaskEvent(f"{pattern}_35_*", 0x8, "Clear")} + class ClearValve0(Stream): + def __init__(self, pattern): + super().__init__(_reader.BitmaskEvent(f"{pattern}_35_*", 0x8, "Clear")) - @staticmethod - def clear_valve1(pattern): - return {"ClearValve1": _reader.BitmaskEvent(f"{pattern}_35_*", 0x10, "Clear")} + class ClearValve1(Stream): + def __init__(self, pattern): + super().__init__(_reader.BitmaskEvent(f"{pattern}_35_*", 0x10, "Clear")) - @staticmethod - def clear_valve2(pattern): - return {"ClearValve2": _reader.BitmaskEvent(f"{pattern}_35_*", 0x20, "Clear")} + class ClearValve2(Stream): + def __init__(self, pattern): + super().__init__(_reader.BitmaskEvent(f"{pattern}_35_*", 0x20, "Clear")) From fd2a704ac329216deb97a2b6ff526f243a1a11e4 Mon Sep 17 00:00:00 2001 From: lochhh Date: Wed, 27 Mar 2024 18:24:46 +0000 Subject: [PATCH 460/489] Add sciviz local specsheet --- .../webapps/sciviz/specsheet-local.yaml | 667 ++++++++++++++++++ 1 file changed, 667 insertions(+) create mode 100644 aeon/dj_pipeline/webapps/sciviz/specsheet-local.yaml diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet-local.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet-local.yaml new file mode 100644 index 00000000..3fa293aa --- /dev/null +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet-local.yaml @@ -0,0 +1,667 @@ +version: "v0.1.0" +LabBook: null +SciViz: + auth: + mode: "database" + hostname: host.docker.internal + pages: + Colony: + route: /colony_page + grids: + grid1: + type: fixed + columns: 1 + row_height: 1000 + components: + Pyrat Subjects: + route: /colony_page_pyrat_subjects + x: 0 + y: 0 + height: 1 + width: 1 + type: antd-table + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_subject): + query = aeon_subject.Subject * aeon_subject.SubjectDetail * aeon_subject.SubjectReferenceWeight.proj('reference_weight', min_since_last_update='TIMESTAMPDIFF(MINUTE, last_updated_time, UTC_TIMESTAMP())') & 'available = 1' + return {'query': query.proj(..., '-available'), 'fetch_args': []} + + Pyrat User Entry: + route: /colony_page_pyrat_user_entry + x: 0 + y: 1 + height: 0.3 + width: 1 + type: form + tables: + - aeon_lab.User + map: + - type: attribute + input: SWC Username + destination: user + - type: attribute + input: Pyrat Responsible Owner + destination: responsible_owner + - type: attribute + input: Pyrat Responsible ID + destination: responsible_id + + Pyrat Sync Task: + route: /colony_page_pyrat_sync_task + x: 0 + y: 1.3 + height: 0.3 + width: 1 + type: form + tables: + - aeon_subject.PyratIngestionTask + map: + - type: attribute + input: Task Scheduled Time + destination: pyrat_task_scheduled_time + + ExperimentEntry: + route: /experiment_entry + grids: + grid5: + type: fixed + columns: 1 + row_height: 1000 + components: + New Experiment: + route: /exp_form + x: 0 + y: 0 + height: 0.4 + width: 1 + type: form + tables: + - aeon_acquisition.Experiment + - aeon_acquisition.Experiment.DevicesSchema + map: + - type: attribute + input: Experiment Name + destination: experiment_name + - type: attribute + input: Start Time + destination: experiment_start_time + - type: attribute + input: Description + destination: experiment_description + - type: table + input: Lab Arena + destination: aeon_lab.Arena + - type: table + input: Lab Location + destination: aeon_lab.Location + - type: attribute + input: Experiment Type + destination: aeon_acquisition.ExperimentType + - type: table + input: Devices Schema Name + destination: aeon_acquisition.DevicesSchema + + New Experiment Subject: + route: /exp_subject_form + x: 0 + y: 0.4 + height: 0.3 + width: 1 + type: form + tables: + - aeon_acquisition.Experiment.Subject + map: + - type: table + input: Experiment Name + destination: aeon_acquisition.Experiment + - type: table + input: Subject in the experiment + destination: aeon_subject.Subject + + New Experiment Note: + route: /exp_note_form + x: 0 + y: 0.7 + height: 0.3 + width: 1 + type: form + tables: + - aeon_acquisition.Experiment.Note + map: + - type: table + input: Experiment Name + destination: aeon_acquisition.Experiment + - type: attribute + input: Note Time + destination: note_timestamp + - type: attribute + input: Note Type + destination: note_type + - type: attribute + input: Note + destination: note + + New Experiment Directory: + route: /exp_directory_form + x: 0 + y: 1.0 + height: 0.3 + width: 1 + type: form + tables: + - aeon_acquisition.Experiment.Directory + map: + - type: table + input: Experiment Name + destination: aeon_acquisition.Experiment + - type: table + input: Directory Type + destination: aeon_acquisition.DirectoryType + - type: table + input: Pipeline Repository + destination: aeon_acquisition.PipelineRepository + - type: attribute + input: Directory Path + destination: directory_path + + New Experiment Type: + route: /exp_type_form + x: 0 + y: 1.3 + height: 0.3 + width: 1 + type: form + tables: + - aeon_acquisition.ExperimentType + map: + - type: attribute + input: Experiment Type + destination: experiment_type + + Subjects: + route: /subjects + grids: + grid1: + type: fixed + columns: 1 + row_height: 700 + components: + Animals: + route: /allsubjects + link: /per_subject_report + x: 0 + y: 0 + height: 1 + width: 1 + type: antd-table + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_archived_exp02_acquisition, aeon_archived_exp02_analysis): + acquisition = aeon_archived_exp02_acquisition + visit_analysis = aeon_archived_exp02_analysis + query = acquisition.Experiment.Subject.aggr(visit_analysis.VisitEnd.join(visit_analysis.Visit, left=True), first_visit_start='MIN(visit_start)', last_visit_end='MAX(visit_end)', total_visit_count='COUNT(visit_start)', total_visit_duration='SUM(visit_duration)') + query = query.proj("first_visit_start", "last_visit_end", "total_visit_count", total_visit_duration="CAST(total_visit_duration AS DOUBLE(10, 3))") + return {'query': query, 'fetch_args': {'order_by': 'last_visit_end DESC'}} + + VisitSummary: + route: /visit_summary + grids: + grid3: + type: fixed + columns: 1 + row_height: 700 + components: + VisitSummary: + route: /visit_summary_grid3_1 + link: /per_visit_report + x: 0 + y: 0 + height: 1 + width: 1 + type: antd-table + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_archived_exp02_analysis): + aeon_analysis = aeon_archived_exp02_analysis + query = aeon_analysis.Visit.aggr(aeon_analysis.VisitSummary, ..., duration="SUM(day_duration)", total_distance_travelled="SUM(total_distance_travelled)", total_pellet_count="SUM(total_pellet_count)", total_wheel_distance_travelled="SUM(total_wheel_distance_travelled)", keep_all_rows=True) + query = query.join(aeon_analysis.VisitEnd, left=True) + query = query.proj("visit_end", total_pellet_count="CAST(total_pellet_count AS DOUBLE)", duration="CAST(duration AS DOUBLE(10, 3))", total_distance_travelled="CAST(total_distance_travelled AS DOUBLE(10, 3))", total_wheel_distance_travelled="CAST(total_wheel_distance_travelled AS DOUBLE(10, 3))") + return {'query': query, 'fetch_args': {'order_by': 'visit_end DESC'}} + + ExperimentReport: + route: /experiment_report + grids: + experiment_report: + route: /experiment_report + type: dynamic + columns: 1 + row_height: 1000 + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_archived_exp01_acquisition): + acquisition = aeon_archived_exp01_acquisition + return {'query': acquisition.Experiment(), 'fetch_args': []} + component_templates: + comp3: + route: /avg_time_distribution + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_archived_exp01_report): + report = aeon_archived_exp01_report + return {'query': report.ExperimentTimeDistribution(), 'fetch_args': ['time_distribution_plotly']} + + SubjectReport: + route: /subject_report + grids: + subject_report: + route: /subject_report + type: dynamic + columns: 2 + row_height: 1000 + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_archived_exp01_acquisition): + acquisition = aeon_archived_exp01_acquisition + return {'query': acquisition.Experiment.Subject & {'experiment_name': 'exp0.1-r0'}, 'fetch_args': []} + component_templates: + comp1: + route: /subject_meta + type: metadata + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_archived_exp01_acquisition): + return dict(query=aeon_archived_exp01_acquisition.Experiment.Subject(), fetch_args=[]) + comp2: + route: /reward_diff_plot + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_archived_exp01_report): + report = aeon_archived_exp01_report + return {'query': report.SubjectRewardRateDifference(), 'fetch_args': ['reward_rate_difference_plotly']} + comp3: + route: /wheel_distance_travelled + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_archived_exp01_report): + report = aeon_archived_exp01_report + return {'query': report.SubjectWheelTravelledDistance(), 'fetch_args': ['wheel_travelled_distance_plotly']} + + PerSubjectReport: + hidden: true + route: /per_subject_report + grids: + per_subject_report: + type: fixed + route: /per_subject_report + columns: 1 + row_height: 400 + components: + comp1: + route: /per_subject_meta + x: 0 + y: 0 + height: 1 + width: 1 + type: metadata + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_archived_exp01_acquisition): + return dict(query=aeon_archived_exp01_acquisition.Experiment.Subject(), fetch_args=[]) + comp2: + route: /per_subject_reward_diff_plot + x: 0 + y: 1 + height: 1 + width: 1 + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_archived_exp01_report): + report = aeon_archived_exp01_report + return {'query': report.SubjectRewardRateDifference(), 'fetch_args': ['reward_rate_difference_plotly']} + comp3: + route: /per_subject_wheel_distance_travelled + x: 0 + y: 2 + height: 1 + width: 1 + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_archived_exp01_report): + report = aeon_archived_exp01_report + return {'query': report.SubjectWheelTravelledDistance(), 'fetch_args': ['wheel_travelled_distance_plotly']} + + PerVisitReport: + hidden: true + route: /per_visit_report + grids: + per_visit_report: + type: fixed + route: /per_visit_report + columns: 1 + row_height: 400 + components: + comp1: + route: /per_visit_meta + x: 0 + y: 0 + height: 1 + width: 1 + type: metadata + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_archived_exp02_analysis): + aeon_analysis = aeon_archived_exp02_analysis + query = aeon_analysis.Visit.aggr(aeon_analysis.VisitSummary, ..., duration="SUM(day_duration)", total_distance_travelled="SUM(total_distance_travelled)", total_pellet_count="SUM(total_pellet_count)", total_wheel_distance_travelled="SUM(total_wheel_distance_travelled)", keep_all_rows=True) + query = query.join(aeon_analysis.VisitEnd, left=True) + query = query.proj("visit_end", total_pellet_count="CAST(total_pellet_count AS DOUBLE)", duration="CAST(duration AS DOUBLE(10, 3))", total_distance_travelled="CAST(total_distance_travelled AS DOUBLE(10, 3))", total_wheel_distance_travelled="CAST(total_wheel_distance_travelled AS DOUBLE(10, 3))") + return {'query': query, 'fetch_args': []} + comp2: + route: /per_visit_summary_plot + x: 0 + y: 1 + height: 1 + width: 1 + type: file:image:attach + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_archived_exp02_report): + report = aeon_archived_exp02_report + return {'query': report.VisitDailySummaryPlot(), 'fetch_args': ['summary_plot_png']} + + Visits247: + route: /visits247 + grids: + visit_daily_summary: + route: /visit_daily_summary_grid1 + type: dynamic + columns: 1 + row_height: 4000 + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_archived_exp02_report): + aeon_report = aeon_archived_exp02_report + return {'query': aeon_report.VisitDailySummaryPlot.proj(), 'fetch_args': []} + component_templates: + comp1: + route: /visit_daily_summary_pellet_count + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_archived_exp02_report): + aeon_report = aeon_archived_exp02_report + return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['pellet_count_plotly']) + comp2: + route: /visit_daily_summary_wheel_distance_travelled + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_archived_exp02_report): + aeon_report = aeon_archived_exp02_report + return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['wheel_distance_travelled_plotly']) + comp3: + route: /visit_daily_summary_total_distance_travelled + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_archived_exp02_report): + aeon_report = aeon_archived_exp02_report + return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['total_distance_travelled_plotly']) + comp4: + route: /visit_daily_summary_weight_patch + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_archived_exp02_report): + aeon_report = aeon_archived_exp02_report + return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['weight_patch_plotly']) + comp5: + route: /visit_daily_summary_foraging_bouts + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_archived_exp02_report): + aeon_report = aeon_archived_exp02_report + return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['foraging_bouts_plotly']) + comp6: + route: /visit_daily_summary_foraging_bouts_pellet_count + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_archived_exp02_report): + aeon_report = aeon_archived_exp02_report + return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['foraging_bouts_pellet_count_plotly']) + comp7: + route: /visit_daily_summary_foraging_bouts_duration + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_archived_exp02_report): + aeon_report = aeon_archived_exp02_report + return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['foraging_bouts_duration_plotly']) + comp8: + route: /visit_daily_summary_region_time_fraction_daily + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_archived_exp02_report): + aeon_report = aeon_archived_exp02_report + return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['region_time_fraction_daily_plotly']) + comp9: + route: /visit_daily_summary_region_time_fraction_hourly + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_archived_exp02_report): + aeon_report = aeon_archived_exp02_report + return dict(query=aeon_report.VisitDailySummaryPlot(), fetch_args=['region_time_fraction_hourly_plotly']) + + SocialExperiment: + route: /social_experiment + grids: + grid1: + type: fixed + columns: 1 + row_height: 700 + components: + SocialExperiment: + route: /social_experiment_grid + link: /per_social_experiment + x: 0 + y: 0 + height: 1 + width: 1 + type: antd-table + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_acquisition, aeon_block_analysis, aeon_tracking): + + acquisition = aeon_acquisition + block_analysis = aeon_block_analysis + tracking = aeon_tracking + + query = acquisition.Experiment.aggr(block_analysis.Block, block_count="COUNT(experiment_name)") + acquisition.Experiment.aggr(acquisition.Chunk, chunk_count="COUNT(experiment_name)", latest_chunk_start="MAX(chunk_start)") + + query = query.join(acquisition.Experiment.aggr( + tracking.SLEAPTracking.PoseIdentity, + participants="GROUP_CONCAT(DISTINCT identity_name SEPARATOR ', ')" + ), left=True) + + return {'query': query, 'fetch_args': []} + + BlockAnalysis: + route: /block_analysis + grids: + grid3: + type: fixed + columns: 1 + row_height: 700 + components: + BlockAnalysis: + route: /block_analysis_grid + link: /per_block_report + x: 0 + y: 0 + height: 1 + width: 1 + type: antd-table + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_block_analysis): + aeon_analysis = aeon_block_analysis + return {'query': aeon_analysis.Block * aeon_analysis.BlockAnalysis, 'fetch_args': {'order_by': 'block_end DESC'}} + + PerBlockReport: + hidden: true + route: /per_block_report + grids: + per_block_report: + type: fixed + route: /per_block_report + columns: 1 + row_height: 1500 + components: + comp1: + route: /per_block_meta + x: 0 + y: 0 + height: 0.2 + width: 0.8 + type: metadata + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_block_analysis): + aeon_analysis = aeon_block_analysis + query = aeon_analysis.Block * aeon_analysis.BlockAnalysis + return dict(query=query, fetch_args=[]) + comp2: + route: /subject_positions_plot + x: 0 + y: 0.2 + height: 0.5 + width: 0.8 + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_block_analysis): + aeon_analysis = aeon_block_analysis + return {'query': aeon_block_analysis.BlockPlots(), 'fetch_args': ['subject_positions_plot']} + comp3: + route: /subject_weights_plot + x: 0 + y: 0.7 + height: 0.5 + width: 0.8 + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_block_analysis): + aeon_analysis = aeon_block_analysis + return {'query': aeon_block_analysis.BlockPlots(), 'fetch_args': ['subject_weights_plot']} + + comp4: + route: /patch_distance_travelled_plot + x: 0 + y: 1.2 + height: 0.5 + width: 0.8 + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_block_analysis): + aeon_analysis = aeon_block_analysis + return {'query': aeon_block_analysis.BlockPlots(), 'fetch_args': ['patch_distance_travelled_plot']} + + comp5: + route: /patch_rate_plot + x: 0 + y: 1.7 + height: 0.4 + width: 0.6 + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_block_analysis): + aeon_analysis = aeon_block_analysis + return {'query': aeon_block_analysis.BlockPlots(), 'fetch_args': ['patch_rate_plot']} + + comp6: + route: /cumulative_pellet_plot + x: 0 + y: 2.1 + height: 0.4 + width: 0.6 + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_block_analysis): + aeon_analysis = aeon_block_analysis + return {'query': aeon_block_analysis.BlockPlots(), 'fetch_args': ['cumulative_pellet_plot']} From b139d8a6790bb9f839b9c4410c777ce2cf390a91 Mon Sep 17 00:00:00 2001 From: lochhh Date: Wed, 27 Mar 2024 18:26:09 +0000 Subject: [PATCH 461/489] Update docker-compose-local.yaml --- .../webapps/sciviz/docker-compose-local.yaml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml index 2ac3f1ac..7703a795 100644 --- a/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/docker-compose-local.yaml @@ -7,14 +7,14 @@ services: pharus: cpus: 2.0 mem_limit: 4g - image: jverswijver/pharus:0.8.5-PY_VER-3.9 + image: datajoint/pharus:0.8.10-py3.9 environment: # - FLASK_ENV=development # enables logging to console from Flask - - PHARUS_SPEC_PATH=/main/specsheet.yaml # for dynamic utils spec + - PHARUS_SPEC_PATH=/main/specsheet-local.yaml # for dynamic utils spec - PHARUS_MODE=DEV user: root volumes: - - ./specsheet.yaml:/main/specsheet.yaml #copy the spec over to /main/specs/YOUR_SPEC_NAME + - ./specsheet-local.yaml:/main/specsheet-local.yaml #copy the spec over to /main/specs/YOUR_SPEC_NAME - ./apk_requirements.txt:/tmp/apk_requirements.txt - /ceph/aeon/aeon:/ceph/aeon/aeon command: @@ -23,12 +23,12 @@ services: - | apk add --update git g++ && git clone -b datajoint_pipeline https://github.com/SainsburyWellcomeCentre/aeon_mecha.git && - pip install -e ./aeon_mecha && + pip install -e ./aeon_mecha --ignore-requires-python && pharus_update() { [ -z "$$GUNICORN_PID" ] || kill $$GUNICORN_PID gunicorn --bind 0.0.0.0:$${PHARUS_PORT} pharus.server:app & GUNICORN_PID=$$! - } + } && pharus_update echo "[$$(date -u '+%Y-%m-%d %H:%M:%S')][DataJoint]: Monitoring Pharus updates..." INIT_TIME=$$(date +%s) @@ -52,16 +52,16 @@ services: sci-viz: cpus: 2.0 mem_limit: 16g - image: jverswijver/sci-viz:2.3.3-hotfix3 + image: jverswijver/sci-viz:2.3.5-beta environment: - CHOKIDAR_USEPOLLING=true - REACT_APP_DJSCIVIZ_BACKEND_PREFIX=/api - - DJSCIVIZ_SPEC_PATH=/main/specsheet.yaml + - DJSCIVIZ_SPEC_PATH=/main/specsheet-local.yaml - DJSCIVIZ_MODE=DEV - NODE_OPTIONS="--max-old-space-size=12000" user: root volumes: - - ./specsheet.yaml:/main/specsheet.yaml + - ./specsheet-local.yaml:/main/specsheet-local.yaml # ports: # - "3000:3000" command: From 264ca9425d5020b4418fb3f8b623f54e95998a85 Mon Sep 17 00:00:00 2001 From: lochhh Date: Wed, 27 Mar 2024 18:26:26 +0000 Subject: [PATCH 462/489] Update sciviz local setup readme --- aeon/dj_pipeline/webapps/sciviz/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/README.md b/aeon/dj_pipeline/webapps/sciviz/README.md index 6f1cef44..a43a5a04 100644 --- a/aeon/dj_pipeline/webapps/sciviz/README.md +++ b/aeon/dj_pipeline/webapps/sciviz/README.md @@ -26,7 +26,7 @@ HOST_UID=$(id -u) docker-compose -f docker-compose-local.yaml up ``` #### Verify the deployment -- In your web browser, navigate to: [https://localhost/login](https://localhost/login) +- In your web browser, navigate to: [https://localhost/](https://localhost/) - Set `Username` and `Password` to your own database user account (if you need one, please contact Project Aeon admin team). - Click `Connect`. - To stop the application, docker compose down with the following: @@ -52,7 +52,8 @@ HOST_UID=$(id -u) docker-compose -f docker-compose-remote.yaml up -d --- ## Dynamic spec sheet -Sci-Viz is used to build visualization dashboards, this is done through a single spec sheet. The one for this deployment is called `specsheet.yaml` +Sci-Viz is used to build visualization dashboards, this is done through a single spec sheet. The one used in production is called `specsheet.yaml`, +and the one used locally is called `specsheet-local.yaml`. Some notes about the spec sheet if you plan to tweak the website yourself: - Page names under pages must have a unique name without spaces From 28e53972bfe5d0d40c68627df280d8525a4b6373 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 3 Apr 2024 19:19:05 -0500 Subject: [PATCH 463/489] update(schemas): update social schemas to use the refactored Stream, StreamGroup and Device --- aeon/schema/schemas.py | 150 ++++++++++++++++++++------------------- aeon/schema/social.py | 113 ----------------------------- aeon/schema/social_01.py | 12 ++++ aeon/schema/social_02.py | 88 +++++++++++++++++++++++ 4 files changed, 177 insertions(+), 186 deletions(-) delete mode 100644 aeon/schema/social.py create mode 100644 aeon/schema/social_01.py create mode 100644 aeon/schema/social_02.py diff --git a/aeon/schema/schemas.py b/aeon/schema/schemas.py index 72820fdc..bc6eaa31 100644 --- a/aeon/schema/schemas.py +++ b/aeon/schema/schemas.py @@ -1,48 +1,51 @@ from dotmap import DotMap -from aeon.io.device import Device -from aeon.schema import core, foraging, octagon, social + +import aeon.schema.core as stream +from aeon.schema.streams import Device +from aeon.schema import foraging, octagon, social_01, social_02 + exp02 = DotMap( [ - Device("Metadata", core.metadata), - Device("ExperimentalMetadata", core.environment, core.message_log), - Device("CameraTop", core.video, core.position, foraging.region), - Device("CameraEast", core.video), - Device("CameraNest", core.video), - Device("CameraNorth", core.video), - Device("CameraPatch1", core.video), - Device("CameraPatch2", core.video), - Device("CameraSouth", core.video), - Device("CameraWest", core.video), - Device("Nest", foraging.weight), - Device("Patch1", foraging.patch), - Device("Patch2", foraging.patch), + Device("Metadata", stream.Metadata), + Device("ExperimentalMetadata", stream.Environment, stream.MessageLog), + Device("CameraTop", stream.Video, stream.Position, foraging.Region), + Device("CameraEast", stream.Video), + Device("CameraNest", stream.Video), + Device("CameraNorth", stream.Video), + Device("CameraPatch1", stream.Video), + Device("CameraPatch2", stream.Video), + Device("CameraSouth", stream.Video), + Device("CameraWest", stream.Video), + Device("Nest", foraging.Weight), + Device("Patch1", foraging.Patch), + Device("Patch2", foraging.Patch), ] ) exp01 = DotMap( [ - Device("SessionData", foraging.session), - Device("FrameTop", core.video, core.position), - Device("FrameEast", core.video), - Device("FrameGate", core.video), - Device("FrameNorth", core.video), - Device("FramePatch1", core.video), - Device("FramePatch2", core.video), - Device("FrameSouth", core.video), - Device("FrameWest", core.video), - Device("Patch1", foraging.depletion_function, core.encoder, foraging.feeder), - Device("Patch2", foraging.depletion_function, core.encoder, foraging.feeder), + Device("SessionData", foraging.SessionData), + Device("FrameTop", stream.Video, stream.Position), + Device("FrameEast", stream.Video), + Device("FrameGate", stream.Video), + Device("FrameNorth", stream.Video), + Device("FramePatch1", stream.Video), + Device("FramePatch2", stream.Video), + Device("FrameSouth", stream.Video), + Device("FrameWest", stream.Video), + Device("Patch1", foraging.DepletionFunction, stream.Encoder, foraging.Feeder), + Device("Patch2", foraging.DepletionFunction, stream.Encoder, foraging.Feeder), ] ) octagon01 = DotMap( [ - Device("Metadata", core.metadata), - Device("CameraTop", core.video, core.position), - Device("CameraColorTop", core.video), - Device("ExperimentalMetadata", core.subject_state), - Device("Photodiode", octagon.photodiode), + Device("Metadata", stream.Metadata), + Device("CameraTop", stream.Video, stream.Position), + Device("CameraColorTop", stream.Video), + Device("ExperimentalMetadata", stream.SubjectState), + Device("Photodiode", octagon.Photodiode), Device("OSC", octagon.OSC), Device("TaskLogic", octagon.TaskLogic), Device("Wall1", octagon.Wall), @@ -58,55 +61,56 @@ social01 = DotMap( [ - Device("Metadata", core.metadata), - Device("Environment", social.environment_b, social.subject_b), - Device("CameraTop", core.video, social.camera_top_pos_b), - Device("CameraNorth", core.video), - Device("CameraSouth", core.video), - Device("CameraEast", core.video), - Device("CameraWest", core.video), - Device("CameraPatch1", core.video), - Device("CameraPatch2", core.video), - Device("CameraPatch3", core.video), - Device("CameraNest", core.video), - Device("Nest", social.weight_raw_b, social.weight_filtered_b), - Device("Patch1", social.patch_streams_b), - Device("Patch2", social.patch_streams_b), - Device("Patch3", social.patch_streams_b), - Device("RfidGate", social.rfid_events_social01_b), - Device("RfidNest1", social.rfid_events_social01_b), - Device("RfidNest2", social.rfid_events_social01_b), - Device("RfidPatch1", social.rfid_events_social01_b), - Device("RfidPatch2", social.rfid_events_social01_b), - Device("RfidPatch3", social.rfid_events_social01_b), + Device("Metadata", stream.Metadata), + Device("Environment", social_02.Environment, social_02.SubjectData), + Device("CameraTop", stream.Video, social_02.Pose), + Device("CameraNorth", stream.Video), + Device("CameraSouth", stream.Video), + Device("CameraEast", stream.Video), + Device("CameraWest", stream.Video), + Device("CameraPatch1", stream.Video), + Device("CameraPatch2", stream.Video), + Device("CameraPatch3", stream.Video), + Device("CameraNest", stream.Video), + Device("Nest", social_02.WeightRaw, social_02.WeightFiltered), + Device("Patch1", social_02.Patch), + Device("Patch2", social_02.Patch), + Device("Patch3", social_02.Patch), + Device("RfidGate", social_01.RfidEvents), + Device("RfidNest1", social_01.RfidEvents), + Device("RfidNest2", social_01.RfidEvents), + Device("RfidPatch1", social_01.RfidEvents), + Device("RfidPatch2", social_01.RfidEvents), + Device("RfidPatch3", social_01.RfidEvents), ] ) social02 = DotMap( [ - Device("Metadata", core.metadata), - Device("Environment", social.environment_b, social.subject_b), - Device("CameraTop", core.video, social.camera_top_pos_b), - Device("CameraNorth", core.video), - Device("CameraSouth", core.video), - Device("CameraEast", core.video), - Device("CameraWest", core.video), - Device("CameraPatch1", core.video), - Device("CameraPatch2", core.video), - Device("CameraPatch3", core.video), - Device("CameraNest", core.video), - Device("Nest", social.weight_raw_b, social.weight_filtered_b), - Device("Patch1", social.patch_streams_b), - Device("Patch2", social.patch_streams_b), - Device("Patch3", social.patch_streams_b), - Device("Patch1Rfid", social.rfid_events_b), - Device("Patch2Rfid", social.rfid_events_b), - Device("Patch3Rfid", social.rfid_events_b), - Device("NestRfid1", social.rfid_events_b), - Device("NestRfid2", social.rfid_events_b), - Device("GateRfid", social.rfid_events_b), + Device("Metadata", stream.Metadata), + Device("Environment", social_02.Environment, social_02.SubjectData), + Device("CameraTop", stream.Video, social_02.Pose), + Device("CameraNorth", stream.Video), + Device("CameraSouth", stream.Video), + Device("CameraEast", stream.Video), + Device("CameraWest", stream.Video), + Device("CameraPatch1", stream.Video), + Device("CameraPatch2", stream.Video), + Device("CameraPatch3", stream.Video), + Device("CameraNest", stream.Video), + Device("Nest", social_02.WeightRaw, social_02.WeightFiltered), + Device("Patch1", social_02.Patch), + Device("Patch2", social_02.Patch), + Device("Patch3", social_02.Patch), + Device("RfidGate", social_02.RfidEvents), + Device("RfidNest1", social_02.RfidEvents), + Device("RfidNest2", social_02.RfidEvents), + Device("RfidPatch1", social_02.RfidEvents), + Device("RfidPatch2", social_02.RfidEvents), + Device("RfidPatch3", social_02.RfidEvents), ] ) + __all__ = ["exp01", "exp02", "octagon01", "social01", "social02"] diff --git a/aeon/schema/social.py b/aeon/schema/social.py deleted file mode 100644 index 54c08302..00000000 --- a/aeon/schema/social.py +++ /dev/null @@ -1,113 +0,0 @@ -from aeon.io import reader -from aeon.io.device import Device, register -from aeon.schema import core, foraging - - -"""Creating the Social 0.1 schema""" - -# Above we've listed out all the streams we recorded from during Social0.1, but we won't care to analyze all -# of them. Instead, we'll create a DotMap schema from Device objects that only contains Readers for the -# streams we want to analyze. - -# We'll see both examples of binder functions we saw previously: 1. "empty pattern", and -# 2. "device-name passed". - -# And we'll see both examples of instantiating Device objects we saw previously: 1. from singleton binder -# functions; 2. from multiple and/or nested binder functions. - -# (Note, in the simplest case, a schema can always be created from / reduced to "empty pattern" binder -# functions as singletons in Device objects.) - -# Metadata.yml (will be a singleton binder function Device object) -# --- - -metadata = Device("Metadata", core.metadata) - -# --- - -# Environment (will be a nested, multiple binder function Device object) -# --- - -# BlockState -block_state_b = lambda pattern: { - "BlockState": reader.Csv(f"{pattern}_BlockState_*", ["pellet_ct", "pellet_ct_thresh", "due_time"]) -} - -# LightEvents -light_events_b = lambda pattern: { - "LightEvents": reader.Csv(f"{pattern}_LightEvents_*", ["channel", "value"]) -} - -# Combine EnvironmentState, BlockState, LightEvents -environment_b = lambda pattern: register( - pattern, core.environment_state, block_state_b, light_events_b, core.message_log -) - -# SubjectState -subject_state_b = lambda pattern: { - "SubjectState": reader.Csv(f"{pattern}_SubjectState_*", ["id", "weight", "type"]) -} - -# SubjectVisits -subject_visits_b = lambda pattern: { - "SubjectVisits": reader.Csv(f"{pattern}_SubjectVisits_*", ["id", "type", "region"]) -} - -# SubjectWeight -subject_weight_b = lambda pattern: { - "SubjectWeight": reader.Csv( - f"{pattern}_SubjectWeight_*", ["weight", "confidence", "subject_id", "int_id"] - ) -} - -# Separate Device object for subject-specific streams. -subject_b = lambda pattern: register(pattern, subject_state_b, subject_visits_b, subject_weight_b) -# --- - -# Camera -# --- - -camera_top_pos_b = lambda pattern: {"Pose": reader.Pose(f"{pattern}_test-node1*")} - -# --- - -# Nest -# --- - -weight_raw_b = lambda pattern: {"WeightRaw": reader.Harp(f"{pattern}_200_*", ["weight(g)", "stability"])} -weight_filtered_b = lambda pattern: { - "WeightFiltered": reader.Harp(f"{pattern}_202_*", ["weight(g)", "stability"]) -} - -# --- - -# Patch -# --- - -# Combine streams for Patch device -patch_streams_b = lambda pattern: register( - pattern, - foraging.pellet_depletion_state, - core.encoder, - foraging.feeder, - foraging.pellet_manual_delivery, - foraging.missed_pellet, - foraging.pellet_retried_delivery, -) -# --- - -# Rfid -# --- - - -def rfid_events_social01_b(pattern): - """RFID events reader (with social0.1 specific logic)""" - pattern = pattern.replace("Rfid", "") - if pattern.startswith("Events"): - pattern = pattern.replace("Events", "") - return {"RfidEvents": reader.Harp(f"RfidEvents{pattern}_*", ["rfid"])} - - -def rfid_events_b(pattern): - """RFID events reader""" - return {"RfidEvents": reader.Harp(f"{pattern}_32*", ["rfid"])} diff --git a/aeon/schema/social_01.py b/aeon/schema/social_01.py new file mode 100644 index 00000000..becfc252 --- /dev/null +++ b/aeon/schema/social_01.py @@ -0,0 +1,12 @@ +import aeon.io.reader as _reader +from aeon.schema.streams import Stream + + +class RfidEvents(Stream): + + def __init__(self, path): + path = path.replace("Rfid", "") + if path.startswith("Events"): + path = path.replace("Events", "") + + super().__init__(_reader.Harp(f"RfidEvents{path}_32*", ["rfid"])) diff --git a/aeon/schema/social_02.py b/aeon/schema/social_02.py new file mode 100644 index 00000000..44c26c91 --- /dev/null +++ b/aeon/schema/social_02.py @@ -0,0 +1,88 @@ +import aeon.io.reader as _reader +from aeon.schema.streams import Stream, StreamGroup +from aeon.schema import core, foraging + + +class Environment(StreamGroup): + + def __init__(self, path): + super().__init__(path) + + EnvironmentState = core.EnvironmentState + + class BlockState(Stream): + def __init__(self, path): + super().__init__(_reader.Csv(f"{path}_BlockState_*", columns=["pellet_ct", "pellet_ct_thresh", "due_time"])) + + class LightEvents(Stream): + def __init__(self, path): + super().__init__(_reader.Csv(f"{path}_LightEvents_*", columns=["channel", "value"])) + + MessageLog = core.MessageLog + + +class SubjectData(StreamGroup): + def __init__(self, path): + super().__init__(path) + + class SubjectState(Stream): + def __init__(self, path): + super().__init__(_reader.Csv(f"{path}_SubjectState_*", columns=["id", "weight", "type"])) + + class SubjectVisits(Stream): + def __init__(self, path): + super().__init__(_reader.Csv(f"{path}_SubjectVisits_*", columns=["id", "type", "region"])) + + class SubjectWeight(Stream): + def __init__(self, path): + super().__init__(_reader.Csv(f"{path}_SubjectWeight_*", columns=["weight", "confidence", "subject_id", "int_id"])) + + +class Pose(Stream): + + def __init__(self, path): + super().__init__(_reader.Pose(f"{path}_test-node1*")) + + +class WeightRaw(Stream): + + def __init__(self, path): + super().__init__(_reader.Harp(f"{path}_200_*", ["weight(g)", "stability"])) + + +class WeightFiltered(Stream): + + def __init__(self, path): + super().__init__(_reader.Harp(f"{path}_202_*", ["weight(g)", "stability"])) + + +class Patch(StreamGroup): + + def __init__(self, path): + super().__init__(path) + + class DepletionState(Stream): + def __init__(self, path): + super().__init__(_reader.Csv(f"{path}_State_*", columns=["threshold", "offset", "rate"])) + + Encoder = core.Encoder + + Feeder = foraging.Feeder + + class ManualDelivery(Stream): + def __init__(self, path): + super().__init__(_reader.Harp(f"{path}_201_*", ["manual_delivery"])) + + class MissedPellet(Stream): + def __init__(self, path): + super().__init__(_reader.Harp(f"{path}_202_*", ["missed_pellet"])) + + class RetriedDelivery(Stream): + def __init__(self, path): + super().__init__(_reader.Harp(f"{path}_203_*", ["retried_delivery"])) + + +class RfidEvents(Stream): + + def __init__(self, path): + super().__init__(_reader.Harp(f"{path}_32*", ["rfid"])) From 8e4b967ea67750d577b3eb23258450997e98601e Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 3 Apr 2024 19:24:56 -0500 Subject: [PATCH 464/489] chore: code cleanup --- aeon/dj_pipeline/acquisition.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 5209f2d6..52dab8dc 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -25,15 +25,6 @@ "exp0.2-r0": "CameraTop", } -# _device_schema_mapping = { -# "exp0.1-r0": aeon_schemas.exp01, -# "social0-r1": aeon_schemas.exp01, -# "exp0.2-r0": aeon_schemas.exp02, -# "oct1.0-r0": aeon_schemas.octagon01, -# "social0.1-a3": aeon_schemas.social01, -# "social0.1-a4": aeon_schemas.social01, -# } - # ------------------- Type Lookup ------------------------ From 0321b3339b87dd42177132d6ef60195b5e435234 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 4 Apr 2024 13:06:22 -0500 Subject: [PATCH 465/489] feat(dj): ingest data by searching multiple directories in specified order --- aeon/dj_pipeline/acquisition.py | 14 ++- aeon/dj_pipeline/analysis/block_analysis.py | 1 + aeon/dj_pipeline/qc.py | 9 +- aeon/dj_pipeline/streams.py | 119 +++++++++----------- aeon/dj_pipeline/tracking.py | 11 +- aeon/dj_pipeline/utils/streams_maker.py | 8 +- 6 files changed, 76 insertions(+), 86 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 52dab8dc..2816b899 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -84,7 +84,7 @@ class DirectoryType(dj.Lookup): directory_type: varchar(16) """ - contents = zip(["raw", "preprocessing", "analysis", "quality-control"]) + contents = zip(["raw", "processed", "qc"]) # ------------------- GENERAL INFORMATION ABOUT AN EXPERIMENT -------------------- @@ -115,6 +115,7 @@ class Directory(dj.Part): --- -> PipelineRepository directory_path: varchar(255) + load_order=1: int # order of priority to load the directory """ class DevicesSchema(dj.Part): @@ -155,10 +156,13 @@ def get_data_directory(cls, experiment_key, directory_type="raw", as_posix=False @classmethod def get_data_directories(cls, experiment_key, directory_types=None, as_posix=False): if directory_types is None: - directory_types = ["raw"] + directory_types = (cls.Directory & experiment_key).fetch( + "directory_type", order_by="load_order" + ) return [ - cls.get_data_directory(experiment_key, dir_type, as_posix=as_posix) + d for dir_type in directory_types + if (d := cls.get_data_directory(experiment_key, dir_type, as_posix=as_posix)) is not None ] @@ -542,7 +546,7 @@ def make(self, key): chunk_start, chunk_end = (Chunk & key).fetch1("chunk_start", "chunk_end") # Populate the part table - raw_data_dir = Experiment.get_data_directory(key) + data_dirs = Experiment.get_data_directories(key) devices_schema = getattr( aeon_schemas, (Experiment.DevicesSchema & {"experiment_name": key["experiment_name"]}).fetch1( @@ -565,7 +569,7 @@ def make(self, key): stream_reader = getattr(device, stream_type) stream_data = io_api.load( - root=raw_data_dir.as_posix(), + root=data_dirs, reader=stream_reader, start=pd.Timestamp(chunk_start), end=pd.Timestamp(chunk_end), diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index d7c134b8..6d120805 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -253,6 +253,7 @@ def make(self, key): subject_names = [s["subject_name"] for s in block_subjects] # Construct subject position dataframe subjects_positions_df = pd.concat( + [ pd.DataFrame( {"subject_name": [s["subject_name"]] * len(s["position_timestamps"])} diff --git a/aeon/dj_pipeline/qc.py b/aeon/dj_pipeline/qc.py index b33a030b..0a9bd4e9 100644 --- a/aeon/dj_pipeline/qc.py +++ b/aeon/dj_pipeline/qc.py @@ -66,11 +66,10 @@ def key_source(self): ) # CameraTop def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) + chunk_start, chunk_end = (acquisition.Chunk & key).fetch1("chunk_start", "chunk_end") + device_name = (streams.SpinnakerVideoSource & key).fetch1("spinnaker_video_source_name") - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + data_dirs = acquisition.Experiment.get_data_directories(key) devices_schema = getattr( acquisition.aeon_schemas, @@ -81,7 +80,7 @@ def make(self, key): stream_reader = getattr(getattr(devices_schema, device_name), "Video") videodata = io_api.load( - root=raw_data_dir.as_posix(), + root=data_dirs, reader=stream_reader, start=pd.Timestamp(chunk_start), end=pd.Timestamp(chunk_end), diff --git a/aeon/dj_pipeline/streams.py b/aeon/dj_pipeline/streams.py index 49558b03..4cd482a0 100644 --- a/aeon/dj_pipeline/streams.py +++ b/aeon/dj_pipeline/streams.py @@ -188,10 +188,9 @@ def key_source(self): ) def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + chunk_start, chunk_end = (acquisition.Chunk & key).fetch1("chunk_start", "chunk_end") + + data_dirs = acquisition.Experiment.get_data_directories(key) device_name = (RfidReader & key).fetch1('rfid_reader_name') @@ -204,7 +203,7 @@ def make(self, key): stream_reader = getattr(getattr(devices_schema, device_name), "RfidEvents") stream_data = io_api.load( - root=raw_data_dir.as_posix(), + root=data_dirs, reader=stream_reader, start=pd.Timestamp(chunk_start), end=pd.Timestamp(chunk_end), @@ -225,7 +224,7 @@ def make(self, key): ) -@schema +@schema class SpinnakerVideoSourceVideo(dj.Imported): definition = """ # Raw per-chunk Video data stream from SpinnakerVideoSource (auto-generated with aeon_mecha-unknown) -> SpinnakerVideoSource @@ -251,10 +250,9 @@ def key_source(self): ) def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + chunk_start, chunk_end = (acquisition.Chunk & key).fetch1("chunk_start", "chunk_end") + + data_dirs = acquisition.Experiment.get_data_directories(key) device_name = (SpinnakerVideoSource & key).fetch1('spinnaker_video_source_name') @@ -267,7 +265,7 @@ def make(self, key): stream_reader = getattr(getattr(devices_schema, device_name), "Video") stream_data = io_api.load( - root=raw_data_dir.as_posix(), + root=data_dirs, reader=stream_reader, start=pd.Timestamp(chunk_start), end=pd.Timestamp(chunk_end), @@ -288,7 +286,7 @@ def make(self, key): ) -@schema +@schema class UndergroundFeederBeamBreak(dj.Imported): definition = """ # Raw per-chunk BeamBreak data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) -> UndergroundFeeder @@ -313,10 +311,9 @@ def key_source(self): ) def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + chunk_start, chunk_end = (acquisition.Chunk & key).fetch1("chunk_start", "chunk_end") + + data_dirs = acquisition.Experiment.get_data_directories(key) device_name = (UndergroundFeeder & key).fetch1('underground_feeder_name') @@ -329,7 +326,7 @@ def make(self, key): stream_reader = getattr(getattr(devices_schema, device_name), "BeamBreak") stream_data = io_api.load( - root=raw_data_dir.as_posix(), + root=data_dirs, reader=stream_reader, start=pd.Timestamp(chunk_start), end=pd.Timestamp(chunk_end), @@ -350,7 +347,7 @@ def make(self, key): ) -@schema +@schema class UndergroundFeederDeliverPellet(dj.Imported): definition = """ # Raw per-chunk DeliverPellet data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) -> UndergroundFeeder @@ -375,10 +372,9 @@ def key_source(self): ) def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + chunk_start, chunk_end = (acquisition.Chunk & key).fetch1("chunk_start", "chunk_end") + + data_dirs = acquisition.Experiment.get_data_directories(key) device_name = (UndergroundFeeder & key).fetch1('underground_feeder_name') @@ -391,7 +387,7 @@ def make(self, key): stream_reader = getattr(getattr(devices_schema, device_name), "DeliverPellet") stream_data = io_api.load( - root=raw_data_dir.as_posix(), + root=data_dirs, reader=stream_reader, start=pd.Timestamp(chunk_start), end=pd.Timestamp(chunk_end), @@ -412,7 +408,7 @@ def make(self, key): ) -@schema +@schema class UndergroundFeederDepletionState(dj.Imported): definition = """ # Raw per-chunk DepletionState data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) -> UndergroundFeeder @@ -439,10 +435,9 @@ def key_source(self): ) def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + chunk_start, chunk_end = (acquisition.Chunk & key).fetch1("chunk_start", "chunk_end") + + data_dirs = acquisition.Experiment.get_data_directories(key) device_name = (UndergroundFeeder & key).fetch1('underground_feeder_name') @@ -455,7 +450,7 @@ def make(self, key): stream_reader = getattr(getattr(devices_schema, device_name), "DepletionState") stream_data = io_api.load( - root=raw_data_dir.as_posix(), + root=data_dirs, reader=stream_reader, start=pd.Timestamp(chunk_start), end=pd.Timestamp(chunk_end), @@ -476,7 +471,7 @@ def make(self, key): ) -@schema +@schema class UndergroundFeederEncoder(dj.Imported): definition = """ # Raw per-chunk Encoder data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) -> UndergroundFeeder @@ -502,10 +497,9 @@ def key_source(self): ) def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + chunk_start, chunk_end = (acquisition.Chunk & key).fetch1("chunk_start", "chunk_end") + + data_dirs = acquisition.Experiment.get_data_directories(key) device_name = (UndergroundFeeder & key).fetch1('underground_feeder_name') @@ -518,7 +512,7 @@ def make(self, key): stream_reader = getattr(getattr(devices_schema, device_name), "Encoder") stream_data = io_api.load( - root=raw_data_dir.as_posix(), + root=data_dirs, reader=stream_reader, start=pd.Timestamp(chunk_start), end=pd.Timestamp(chunk_end), @@ -539,7 +533,7 @@ def make(self, key): ) -@schema +@schema class UndergroundFeederManualDelivery(dj.Imported): definition = """ # Raw per-chunk ManualDelivery data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) -> UndergroundFeeder @@ -564,10 +558,9 @@ def key_source(self): ) def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + chunk_start, chunk_end = (acquisition.Chunk & key).fetch1("chunk_start", "chunk_end") + + data_dirs = acquisition.Experiment.get_data_directories(key) device_name = (UndergroundFeeder & key).fetch1('underground_feeder_name') @@ -580,7 +573,7 @@ def make(self, key): stream_reader = getattr(getattr(devices_schema, device_name), "ManualDelivery") stream_data = io_api.load( - root=raw_data_dir.as_posix(), + root=data_dirs, reader=stream_reader, start=pd.Timestamp(chunk_start), end=pd.Timestamp(chunk_end), @@ -601,7 +594,7 @@ def make(self, key): ) -@schema +@schema class UndergroundFeederMissedPellet(dj.Imported): definition = """ # Raw per-chunk MissedPellet data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) -> UndergroundFeeder @@ -626,10 +619,9 @@ def key_source(self): ) def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + chunk_start, chunk_end = (acquisition.Chunk & key).fetch1("chunk_start", "chunk_end") + + data_dirs = acquisition.Experiment.get_data_directories(key) device_name = (UndergroundFeeder & key).fetch1('underground_feeder_name') @@ -642,7 +634,7 @@ def make(self, key): stream_reader = getattr(getattr(devices_schema, device_name), "MissedPellet") stream_data = io_api.load( - root=raw_data_dir.as_posix(), + root=data_dirs, reader=stream_reader, start=pd.Timestamp(chunk_start), end=pd.Timestamp(chunk_end), @@ -663,7 +655,7 @@ def make(self, key): ) -@schema +@schema class UndergroundFeederRetriedDelivery(dj.Imported): definition = """ # Raw per-chunk RetriedDelivery data stream from UndergroundFeeder (auto-generated with aeon_mecha-unknown) -> UndergroundFeeder @@ -688,10 +680,9 @@ def key_source(self): ) def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + chunk_start, chunk_end = (acquisition.Chunk & key).fetch1("chunk_start", "chunk_end") + + data_dirs = acquisition.Experiment.get_data_directories(key) device_name = (UndergroundFeeder & key).fetch1('underground_feeder_name') @@ -704,7 +695,7 @@ def make(self, key): stream_reader = getattr(getattr(devices_schema, device_name), "RetriedDelivery") stream_data = io_api.load( - root=raw_data_dir.as_posix(), + root=data_dirs, reader=stream_reader, start=pd.Timestamp(chunk_start), end=pd.Timestamp(chunk_end), @@ -725,7 +716,7 @@ def make(self, key): ) -@schema +@schema class WeightScaleWeightFiltered(dj.Imported): definition = """ # Raw per-chunk WeightFiltered data stream from WeightScale (auto-generated with aeon_mecha-unknown) -> WeightScale @@ -751,10 +742,9 @@ def key_source(self): ) def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + chunk_start, chunk_end = (acquisition.Chunk & key).fetch1("chunk_start", "chunk_end") + + data_dirs = acquisition.Experiment.get_data_directories(key) device_name = (WeightScale & key).fetch1('weight_scale_name') @@ -767,7 +757,7 @@ def make(self, key): stream_reader = getattr(getattr(devices_schema, device_name), "WeightFiltered") stream_data = io_api.load( - root=raw_data_dir.as_posix(), + root=data_dirs, reader=stream_reader, start=pd.Timestamp(chunk_start), end=pd.Timestamp(chunk_end), @@ -788,7 +778,7 @@ def make(self, key): ) -@schema +@schema class WeightScaleWeightRaw(dj.Imported): definition = """ # Raw per-chunk WeightRaw data stream from WeightScale (auto-generated with aeon_mecha-unknown) -> WeightScale @@ -814,10 +804,9 @@ def key_source(self): ) def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + chunk_start, chunk_end = (acquisition.Chunk & key).fetch1("chunk_start", "chunk_end") + + data_dirs = acquisition.Experiment.get_data_directories(key) device_name = (WeightScale & key).fetch1('weight_scale_name') @@ -830,7 +819,7 @@ def make(self, key): stream_reader = getattr(getattr(devices_schema, device_name), "WeightRaw") stream_data = io_api.load( - root=raw_data_dir.as_posix(), + root=data_dirs, reader=stream_reader, start=pd.Timestamp(chunk_start), end=pd.Timestamp(chunk_end), diff --git a/aeon/dj_pipeline/tracking.py b/aeon/dj_pipeline/tracking.py index 225430fa..6d1ed307 100644 --- a/aeon/dj_pipeline/tracking.py +++ b/aeon/dj_pipeline/tracking.py @@ -150,10 +150,9 @@ def key_source(self): ) # SLEAP & CameraTop def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + chunk_start, chunk_end = (acquisition.Chunk & key).fetch1("chunk_start", "chunk_end") + + data_dirs = acquisition.Experiment.get_data_directories(key) device_name = (streams.SpinnakerVideoSource & key).fetch1("spinnaker_video_source_name") @@ -166,7 +165,7 @@ def make(self, key): stream_reader = getattr(getattr(devices_schema, device_name), "Pose") pose_data = io_api.load( - root=raw_data_dir.as_posix(), + root=data_dirs, reader=stream_reader, start=pd.Timestamp(chunk_start), end=pd.Timestamp(chunk_end), @@ -179,7 +178,7 @@ def make(self, key): # Find the config file for the SLEAP model try: f = next( - raw_data_dir.glob( + data_dirs.glob( f"**/**/{stream_reader.pattern}{io_api.chunk(chunk_start).strftime('%Y-%m-%dT%H-%M-%S')}*.{stream_reader.extension}" ) ) diff --git a/aeon/dj_pipeline/utils/streams_maker.py b/aeon/dj_pipeline/utils/streams_maker.py index d333387e..3e5acafc 100644 --- a/aeon/dj_pipeline/utils/streams_maker.py +++ b/aeon/dj_pipeline/utils/streams_maker.py @@ -146,10 +146,8 @@ def key_source(self): ) def make(self, key): - chunk_start, chunk_end, dir_type = (acquisition.Chunk & key).fetch1( - "chunk_start", "chunk_end", "directory_type" - ) - raw_data_dir = acquisition.Experiment.get_data_directory(key, directory_type=dir_type) + chunk_start, chunk_end = (acquisition.Chunk & key).fetch1("chunk_start", "chunk_end") + data_dirs = acquisition.Experiment.get_data_directories(key) device_name = (ExperimentDevice & key).fetch1(f"{dj.utils.from_camel_case(device_type)}_name") @@ -162,7 +160,7 @@ def make(self, key): stream_reader = getattr(getattr(devices_schema, device_name), "{stream_type}") stream_data = io_api.load( - root=raw_data_dir.as_posix(), + root=data_dirs, reader=stream_reader, start=pd.Timestamp(chunk_start), end=pd.Timestamp(chunk_end), From 52e7bba37489795691eddbc4fdd0dd3be53639e4 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 4 Apr 2024 13:21:08 -0500 Subject: [PATCH 466/489] feat(sciviz): add `load_order` in Directory input page --- aeon/dj_pipeline/webapps/sciviz/specsheet.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index ea2ef70b..dceab461 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -198,6 +198,9 @@ SciViz: - type: attribute input: Directory Path destination: directory_path + - type: attribute + input: Loading Order + destination: load_order New Experiment Type: route: /exp_type_form From a72c84d616e77d4157886fca753747cb1a137857 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 9 Apr 2024 16:55:30 -0500 Subject: [PATCH 467/489] feat(acquisition): refactor Epoch & Metadata ingestion --- aeon/dj_pipeline/acquisition.py | 201 +++++++++----------- aeon/dj_pipeline/analysis/block_analysis.py | 2 +- aeon/dj_pipeline/populate/worker.py | 7 +- aeon/dj_pipeline/utils/load_metadata.py | 35 ++-- aeon/schema/schemas.py | 12 +- 5 files changed, 121 insertions(+), 136 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 2816b899..c17cc618 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -174,44 +174,14 @@ class Epoch(dj.Manual): definition = """ # A recording period reflecting on/off of the hardware acquisition system. -> Experiment epoch_start: datetime(6) + --- + -> [nullable] Experiment.Directory + epoch_dir='': varchar(255) # path of the directory storing the acquired data for a given epoch """ - class Config(dj.Part): - definition = """ # Metadata for the configuration of a given epoch - -> master - --- - bonsai_workflow: varchar(36) - commit: varchar(64) # e.g. git commit hash of aeon_experiment used to generated this particular epoch - source='': varchar(16) # e.g. aeon_experiment or aeon_acquisition (or others) - metadata: longblob - -> Experiment.Directory - metadata_file_path: varchar(255) # path of the file, relative to the experiment repository - """ - - class DeviceType(dj.Part): - definition = """ # Device type(s) used in a particular acquisition epoch - -> master - device_type: varchar(36) - """ - @classmethod - def ingest_epochs(cls, experiment_name, start=None, end=None): - """Ingest epochs for the specified "experiment_name". Ingest only epochs that start in between the specified (start, end) time. If not specified, ingest all epochs. - Note: "start" and "end" are datetime specified a string in the format: "%Y-%m-%d %H:%M:%S". - """ - - from aeon.dj_pipeline.utils import streams_maker - from aeon.dj_pipeline.utils.load_metadata import ( - extract_epoch_config, - ingest_epoch_metadata, - insert_device_types, - ) - - devices_schema = getattr( - aeon_schemas, - (Experiment.DevicesSchema & {"experiment_name": experiment_name}).fetch1("devices_schema_name"), - ) - + def ingest_epochs(cls, experiment_name): + """Ingest epochs for the specified "experiment_name" """ device_name = _ref_device_mapping.get(experiment_name, "CameraTop") all_chunks, raw_data_dirs = _get_all_chunks(experiment_name, device_name) @@ -221,39 +191,21 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): chunk_rep_file = pathlib.Path(chunk.path) epoch_dir = pathlib.Path(chunk_rep_file.as_posix().split(device_name)[0]) epoch_start = datetime.datetime.strptime(epoch_dir.name, "%Y-%m-%dT%H-%M-%S") - # --- insert to Epoch --- epoch_key = {"experiment_name": experiment_name, "epoch_start": epoch_start} - # skip over epochs out of the (start, end) range - is_out_of_start_end_range = ( - start and epoch_start < datetime.datetime.strptime(start, "%Y-%m-%d %H:%M:%S") - ) or (end and epoch_start > datetime.datetime.strptime(end, "%Y-%m-%d %H:%M:%S")) + if epoch_start == "2023-12-13 15:20:48": + break # skip over those already ingested if cls & epoch_key or epoch_key in epoch_list: continue - epoch_config, metadata_yml_filepath = None, None - - metadata_yml_filepath = epoch_dir / "Metadata.yml" - if metadata_yml_filepath.exists(): - epoch_config = extract_epoch_config(experiment_name, devices_schema, metadata_yml_filepath) - - metadata_yml_filepath = epoch_config["metadata_file_path"] - - _, directory, repo_path = _match_experiment_directory( - experiment_name, - epoch_config["metadata_file_path"], - raw_data_dirs, - ) - epoch_config = { - **epoch_config, - **directory, - "metadata_file_path": epoch_config["metadata_file_path"] - .relative_to(repo_path) - .as_posix(), - } + raw_data_dir, directory, _ = _match_experiment_directory( + experiment_name, + epoch_dir, + raw_data_dirs, + ) # find previous epoch end-time previous_epoch_key = None @@ -271,40 +223,19 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): "epoch_start": previous_epoch_start, } - # insert new epoch - if not is_out_of_start_end_range: - with cls.connection.transaction: - cls.insert1(epoch_key) - if epoch_config: - cls.Config.insert1(epoch_config) - if metadata_yml_filepath and metadata_yml_filepath.exists(): - try: - # Insert new entries for streams.DeviceType, streams.Device. - insert_device_types( - devices_schema, - metadata_yml_filepath, - ) - # Define and instantiate new devices/stream tables under `streams` schema - streams_maker.main() - with cls.connection.transaction: - # Insert devices' installation/removal/settings - epoch_device_types = ingest_epoch_metadata( - experiment_name, devices_schema, metadata_yml_filepath - ) - if epoch_device_types is not None: - cls.DeviceType.insert( - epoch_key | {"device_type": n} for n in epoch_device_types - ) - epoch_list.append(epoch_key) - except Exception as e: - (cls.Config & epoch_key).delete_quick() - (cls.DeviceType & epoch_key).delete_quick() - (cls & epoch_key).delete_quick() - raise e - - # update previous epoch - if previous_epoch_key and (cls & previous_epoch_key) and not (EpochEnd & previous_epoch_key): - with cls.connection.transaction: + with cls.connection.transaction: + # insert new epoch + cls.insert1( + {**epoch_key, **directory, "epoch_dir": epoch_dir.relative_to(raw_data_dir).as_posix()} + ) + epoch_list.append(epoch_key) + + # update previous epoch + if ( + previous_epoch_key + and (cls & previous_epoch_key) + and not (EpochEnd & previous_epoch_key) + ): # insert end-time for previous epoch EpochEnd.insert1( { @@ -327,7 +258,7 @@ def ingest_epochs(cls, experiment_name, start=None, end=None): } ) - print(f"Insert {len(epoch_list)} new Epoch(s)") + logger.info(f"Insert {len(epoch_list)} new Epoch(s)") @schema @@ -341,12 +272,29 @@ class EpochEnd(dj.Manual): @schema -class EpochActiveRegion(dj.Imported): +class EpochConfig(dj.Imported): definition = """ -> Epoch """ - class Region(dj.Part): + class Meta(dj.Part): + definition = """ # Metadata for the configuration of a given epoch + -> master + --- + bonsai_workflow: varchar(36) + commit: varchar(64) # e.g. git commit hash of aeon_experiment used to generated this particular epoch + source='': varchar(16) # e.g. aeon_experiment or aeon_acquisition (or others) + metadata: longblob + metadata_file_path: varchar(255) # path of the file, relative to the experiment repository + """ + + class DeviceType(dj.Part): + definition = """ # Device type(s) used in a particular acquisition epoch + -> master + device_type: varchar(36) + """ + + class ActiveRegion(dj.Part): definition = """ -> master region_name: varchar(36) @@ -355,12 +303,45 @@ class Region(dj.Part): """ def make(self, key): - metadata_file_path = (Epoch.Config & key).fetch1("metadata_file_path") - metadata_file_path = paths.get_repository_path("ceph_aeon") / metadata_file_path - with metadata_file_path.open("r") as f: - metadata = json.load(f) + from aeon.dj_pipeline.utils import streams_maker + from aeon.dj_pipeline.utils.load_metadata import ( + extract_epoch_config, + ingest_epoch_metadata, + insert_device_types, + ) + + experiment_name = key["experiment_name"] + devices_schema = getattr( + aeon_schemas, + (Experiment.DevicesSchema & {"experiment_name": experiment_name}).fetch1("devices_schema_name"), + ) + + dir_type, epoch_dir = (Epoch & key).fetch1("directory_type", "epoch_dir") + data_dir = Experiment.get_data_directory(key, dir_type) + metadata_yml_filepath = data_dir / epoch_dir / "Metadata.yml" + + epoch_config = extract_epoch_config(experiment_name, devices_schema, metadata_yml_filepath) + epoch_config = { + **epoch_config, + "metadata_file_path": metadata_yml_filepath.relative_to(data_dir).as_posix(), + } + + # Insert new entries for streams.DeviceType, streams.Device. + insert_device_types( + devices_schema, + metadata_yml_filepath, + ) + # Define and instantiate new devices/stream tables under `streams` schema + streams_maker.main() + # Insert devices' installation/removal/settings + epoch_device_types = ingest_epoch_metadata(experiment_name, devices_schema, metadata_yml_filepath) + self.insert1(key) - self.Region.insert( + self.Meta.insert1(epoch_config) + self.DeviceType.insert(key | {"device_type": n} for n in epoch_device_types or {}) + with metadata_yml_filepath.open("r") as f: + metadata = json.load(f) + self.ActiveRegion.insert( {**key, "region_name": k, "region_data": v} for k, v in metadata["ActiveRegion"].items() ) @@ -451,7 +432,7 @@ def ingest_chunks(cls, experiment_name): ) # insert - print(f"Insert {len(chunk_list)} new Chunk(s)") + logger.info(f"Insert {len(chunk_list)} new Chunk(s)") with cls.connection.transaction: cls.insert(chunk_list) @@ -594,16 +575,14 @@ def make(self, key): def _get_all_chunks(experiment_name, device_name): - raw_data_dirs = Experiment.get_data_directories( - {"experiment_name": experiment_name}, - directory_types=["quality-control", "raw"], - as_posix=True, - ) + directory_types = ["quality-control", "raw"] raw_data_dirs = { - dir_type: pathlib.Path(data_dir) - for dir_type, data_dir in zip(["quality-control", "raw"], raw_data_dirs) - if data_dir + dir_type: Experiment.get_data_directory( + experiment_key={"experiment_name": experiment_name}, directory_type=dir_type, as_posix=False + ) + for dir_type in directory_types } + raw_data_dirs = {k: v for k, v in raw_data_dirs.items() if v} if not raw_data_dirs: raise ValueError(f"No raw data directory found for experiment: {experiment_name}") diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index 6d120805..54d36a6e 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -289,7 +289,7 @@ def make(self, key): ) # Get distance-to-patch at each pose data timestep patch_region = ( - acquisition.EpochActiveRegion.Region + acquisition.EpochConfig.ActiveRegion & key & {"region_name": f"{patch['patch_name']}Region"} & f'epoch_start < "{key["block_start"]}"' diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py index 3023887d..c1a8f86c 100644 --- a/aeon/dj_pipeline/populate/worker.py +++ b/aeon/dj_pipeline/populate/worker.py @@ -61,8 +61,8 @@ def ingest_environment_visits(): sleep_duration=1200, ) acquisition_worker(ingest_epochs_chunks) +acquisition_worker(acquisition.EpochConfig) acquisition_worker(acquisition.Environment) -acquisition_worker(acquisition.EpochActiveRegion) # acquisition_worker(ingest_environment_visits) acquisition_worker(block_analysis.BlockDetection) @@ -107,3 +107,8 @@ def ingest_environment_visits(): analysis_worker(block_analysis.BlockAnalysis, max_calls=6) analysis_worker(block_analysis.BlockPlots, max_calls=6) + + +def get_workflow_operation_overview(): + from datajoint_utilities.dj_worker.utils import get_workflow_operation_overview + return get_workflow_operation_overview(worker_schema_name=worker_schema_name, db_prefixes=[db_prefix]) diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 18f0f690..56993880 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -7,10 +7,9 @@ import datajoint as dj import numpy as np -import pandas as pd from dotmap import DotMap -from aeon.dj_pipeline import acquisition, dict_to_uuid, subject +from aeon.dj_pipeline import dict_to_uuid from aeon.dj_pipeline.utils import streams_maker from aeon.io import api as io_api @@ -131,7 +130,7 @@ def extract_epoch_config(experiment_name: str, devices_schema, metadata_yml_file epoch_start = datetime.datetime.strptime(metadata_yml_filepath.parent.name, "%Y-%m-%dT%H-%M-%S") epoch_config: dict = ( io_api.load( - str(metadata_yml_filepath.parent), + metadata_yml_filepath.parent.as_posix(), devices_schema.Metadata, ) .reset_index() @@ -164,6 +163,7 @@ def extract_epoch_config(experiment_name: str, devices_schema, metadata_yml_file def ingest_epoch_metadata(experiment_name, devices_schema, metadata_yml_filepath): """Make entries into device tables.""" + from aeon.dj_pipeline import acquisition streams = dj.VirtualModule("streams", streams_maker.schema_name) if experiment_name.startswith("oct"): @@ -178,8 +178,8 @@ def ingest_epoch_metadata(experiment_name, devices_schema, metadata_yml_filepath acquisition.Epoch & f'epoch_start < "{epoch_config["epoch_start"]}"', epoch_start="MAX(epoch_start)", ) - if len(acquisition.Epoch.Config & previous_epoch) and epoch_config["commit"] == ( - acquisition.Epoch.Config & previous_epoch + if len(acquisition.EpochConfig.Meta & previous_epoch) and epoch_config["commit"] == ( + acquisition.EpochConfig.Meta & previous_epoch ).fetch1("commit"): # if identical commit -> no changes return @@ -204,6 +204,7 @@ def ingest_epoch_metadata(experiment_name, devices_schema, metadata_yml_filepath f"Device {device_name} (serial number: {device_sn}) is not yet registered in streams.Device. Skipping..." ) # skip if this device (with a serial number) is not yet inserted in streams.Device + # this should not happen - check if metadata.yml and schemas dotmap are consistent continue device_list.append(device_key) @@ -294,11 +295,11 @@ def ingest_epoch_metadata(experiment_name, devices_schema, metadata_yml_filepath # region Get stream & device information -def get_stream_entries(schema: DotMap) -> list[dict]: +def get_stream_entries(devices_schema: DotMap) -> list[dict]: """Returns a list of dictionaries containing the stream entries for a given device. Args: - schema (DotMap): DotMap object (e.g., exp02, octagon01) + devices_schema (DotMap): DotMap object (e.g., exp02, octagon01) Returns: stream_info (list[dict]): list of dictionaries containing the stream entries for a given device, @@ -310,7 +311,7 @@ def get_stream_entries(schema: DotMap) -> list[dict]: 'extension': 'csv', 'dtype': None} """ - device_info = get_device_info(schema) + device_info = get_device_info(devices_schema) return [ { "stream_type": stream_type, @@ -329,11 +330,11 @@ def get_stream_entries(schema: DotMap) -> list[dict]: ] -def get_device_info(schema: DotMap) -> dict[dict]: +def get_device_info(devices_schema: DotMap) -> dict[dict]: """Read from the above DotMap object and returns a device dictionary as the following. Args: - schema (DotMap): DotMap object (e.g., exp02, octagon01) + devices_schema (DotMap): DotMap object (e.g., exp02, octagon01) Returns: device_info (dict[dict]): A dictionary of device information @@ -353,11 +354,11 @@ def get_device_info(schema: DotMap) -> dict[dict]: def _get_class_path(obj): return f"{obj.__class__.__module__}.{obj.__class__.__name__}" - schema_json = json.dumps(schema, default=lambda x: x.__dict__, indent=4) + schema_json = json.dumps(devices_schema, default=lambda x: x.__dict__, indent=4) schema_dict = json.loads(schema_json) device_info = {} - for device_name, device in schema.items(): + for device_name, device in devices_schema.items(): if device_name.startswith("_"): continue @@ -408,11 +409,11 @@ def _get_class_path(obj): return device_info -def get_device_mapper(schema: DotMap, metadata_yml_filepath: Path): +def get_device_mapper(devices_schema: DotMap, metadata_yml_filepath: Path): """Returns a mapping dictionary between device name and device type based on the dataset schema and metadata.yml from the experiment. Store the mapper dictionary and read from it if the type info doesn't exist in Metadata.yml. Args: - schema (DotMap): DotMap object (e.g., exp02) + devices_schema (DotMap): DotMap object (e.g., exp02) metadata_yml_filepath (Path): Path to metadata.yml. Returns: @@ -421,13 +422,13 @@ def get_device_mapper(schema: DotMap, metadata_yml_filepath: Path): device_sn (dict): {"device_name", "serial_number"} e.g. {'CameraTop': '21053810'} """ - from aeon.io import api + from aeon.io import api as io_api metadata_yml_filepath = Path(metadata_yml_filepath) meta_data = ( - api.load( + io_api.load( str(metadata_yml_filepath.parent), - schema.Metadata, + devices_schema.Metadata, ) .reset_index() .to_dict("records")[0]["metadata"] diff --git a/aeon/schema/schemas.py b/aeon/schema/schemas.py index bc6eaa31..897d54bd 100644 --- a/aeon/schema/schemas.py +++ b/aeon/schema/schemas.py @@ -103,12 +103,12 @@ Device("Patch1", social_02.Patch), Device("Patch2", social_02.Patch), Device("Patch3", social_02.Patch), - Device("RfidGate", social_02.RfidEvents), - Device("RfidNest1", social_02.RfidEvents), - Device("RfidNest2", social_02.RfidEvents), - Device("RfidPatch1", social_02.RfidEvents), - Device("RfidPatch2", social_02.RfidEvents), - Device("RfidPatch3", social_02.RfidEvents), + Device("GateRfid", social_02.RfidEvents), + Device("NestRfid1", social_02.RfidEvents), + Device("NestRfid2", social_02.RfidEvents), + Device("Patch1Rfid", social_02.RfidEvents), + Device("Patch2Rfid", social_02.RfidEvents), + Device("Patch3Rfid", social_02.RfidEvents), ] ) From 5e81aaf3b0c8d42caa9484ec595b9969ec763fa9 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 9 Apr 2024 18:29:38 -0500 Subject: [PATCH 468/489] fix(tracking): bugfix searching for SLEAP model config file for multi-dir --- aeon/dj_pipeline/tracking.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/aeon/dj_pipeline/tracking.py b/aeon/dj_pipeline/tracking.py index 6d1ed307..7e140252 100644 --- a/aeon/dj_pipeline/tracking.py +++ b/aeon/dj_pipeline/tracking.py @@ -176,18 +176,22 @@ def make(self, key): return # Find the config file for the SLEAP model - try: - f = next( - data_dirs.glob( - f"**/**/{stream_reader.pattern}{io_api.chunk(chunk_start).strftime('%Y-%m-%dT%H-%M-%S')}*.{stream_reader.extension}" + for data_dir in data_dirs: + try: + f = next( + data_dir.glob( + f"**/**/{stream_reader.pattern}{io_api.chunk(chunk_start).strftime('%Y-%m-%dT%H-%M-%S')}*.{stream_reader.extension}" + ) ) - ) - except StopIteration: - raise FileNotFoundError(f"Unable to find HARP bin file for {key}") + except StopIteration: + continue + else: + config_file = stream_reader.get_config_file( + stream_reader._model_root / Path(*Path(f.stem.replace("_", "/")).parent.parts[1:]) + ) + break else: - config_file = stream_reader.get_config_file( - stream_reader._model_root / Path(*Path(f.stem.replace("_", "/")).parent.parts[1:]) - ) + raise FileNotFoundError(f"Unable to find SLEAP model config file for: {stream_reader.pattern}") # get bodyparts and classes bodyparts = stream_reader.get_bodyparts(config_file) From a9cea0b6dcbfb99c216803e5a3c31bf842f9260f Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 10 Apr 2024 10:27:47 -0500 Subject: [PATCH 469/489] fix(acquisition): remove debugging codeblock --- aeon/dj_pipeline/acquisition.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index c17cc618..9823435d 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -194,9 +194,6 @@ def ingest_epochs(cls, experiment_name): # --- insert to Epoch --- epoch_key = {"experiment_name": experiment_name, "epoch_start": epoch_start} - if epoch_start == "2023-12-13 15:20:48": - break - # skip over those already ingested if cls & epoch_key or epoch_key in epoch_list: continue From 0ca72d9472ccbce1f617b6d8cbadc914271be9bc Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 11 Apr 2024 10:54:41 -0500 Subject: [PATCH 470/489] feat(block_analysis): retrieve patch offset --- aeon/dj_pipeline/analysis/block_analysis.py | 136 ++++++++++---------- 1 file changed, 71 insertions(+), 65 deletions(-) diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index 54d36a6e..517ff373 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -27,6 +27,72 @@ class Block(dj.Manual): """ +@schema +class BlockDetection(dj.Computed): + definition = """ + -> acquisition.Environment + """ + + def make(self, key): + """On a per-chunk basis, check for the presence of new block, insert into Block table.""" + # find the 0s + # that would mark the start of a new block + # if the 0 is the first index - look back at the previous chunk + # if the previous timestamp belongs to a previous epoch -> block_end is the previous timestamp + # else block_end is the timestamp of this 0 + chunk_start, chunk_end = (acquisition.Chunk & key).fetch1("chunk_start", "chunk_end") + exp_key = {"experiment_name": key["experiment_name"]} + # only consider the time period between the last block and the current chunk + previous_block = Block & exp_key & f"block_start <= '{chunk_start}'" + if previous_block: + previous_block_key = previous_block.fetch("KEY", limit=1, order_by="block_start DESC")[0] + previous_block_start = previous_block_key["block_start"] + else: + previous_block_key = None + previous_block_start = (acquisition.Chunk & exp_key).fetch( + "chunk_start", limit=1, order_by="chunk_start" + )[0] + + chunk_restriction = acquisition.create_chunk_restriction( + key["experiment_name"], previous_block_start, chunk_end + ) + + block_query = acquisition.Environment.BlockState & chunk_restriction + block_df = fetch_stream(block_query).sort_index()[previous_block_start:chunk_end] + + block_ends = block_df[block_df.pellet_ct.diff() < 0] + + block_entries = [] + for idx, block_end in enumerate(block_ends.index): + if idx == 0: + if previous_block_key: + # if there is a previous block - insert "block_end" for the previous block + previous_pellet_time = block_df[:block_end].index[-2] + previous_epoch = ( + acquisition.Epoch.join(acquisition.EpochEnd, left=True) + & exp_key + & f"'{previous_pellet_time}' BETWEEN epoch_start AND IFNULL(epoch_end, '2200-01-01')" + ).fetch1("KEY") + current_epoch = ( + acquisition.Epoch.join(acquisition.EpochEnd, left=True) + & exp_key + & f"'{block_end}' BETWEEN epoch_start AND IFNULL(epoch_end, '2200-01-01')" + ).fetch1("KEY") + + previous_block_key["block_end"] = ( + block_end if current_epoch == previous_epoch else previous_pellet_time + ) + Block.update1(previous_block_key) + else: + block_entries[-1]["block_end"] = block_end + block_entries.append({**exp_key, "block_start": block_end, "block_end": None}) + + Block.insert(block_entries) + self.insert1(key) + + +# ---- Block Analysis and Visualization ---- + @schema class BlockAnalysis(dj.Computed): definition = """ @@ -49,6 +115,7 @@ class Patch(dj.Part): patch_threshold: longblob patch_threshold_timestamps: longblob patch_rate: float + patch_offset: float """ class Subject(dj.Part): @@ -139,8 +206,10 @@ def make(self, key): encoder_df["distance_travelled"] = -1 * analysis_utils.distancetravelled(encoder_df.angle) patch_rate = depletion_state_df.rate.unique() - assert len(patch_rate) == 1 # expects a single rate for this block + patch_offset = depletion_state_df.offset.unique() + assert len(patch_rate) == 1, f"Found multiple patch rates: {patch_rate} for patch: {patch_name}" patch_rate = patch_rate[0] + patch_offset = patch_offset[0] self.Patch.insert1( { @@ -155,6 +224,7 @@ def make(self, key): "patch_threshold": depletion_state_df.threshold.values, "patch_threshold_timestamps": depletion_state_df.index.values, "patch_rate": patch_rate, + "patch_offset": patch_offset, } ) @@ -486,67 +556,3 @@ def make(self, key): "cumulative_pellet_plot": json.loads(cumulative_pellet_fig.to_json()), } ) - - -@schema -class BlockDetection(dj.Computed): - definition = """ - -> acquisition.Environment - """ - - def make(self, key): - """On a per-chunk basis, check for the presence of new block, insert into Block table.""" - # find the 0s - # that would mark the start of a new block - # if the 0 is the first index - look back at the previous chunk - # if the previous timestamp belongs to a previous epoch -> block_end is the previous timestamp - # else block_end is the timestamp of this 0 - chunk_start, chunk_end = (acquisition.Chunk & key).fetch1("chunk_start", "chunk_end") - exp_key = {"experiment_name": key["experiment_name"]} - # only consider the time period between the last block and the current chunk - previous_block = Block & exp_key & f"block_start <= '{chunk_start}'" - if previous_block: - previous_block_key = previous_block.fetch("KEY", limit=1, order_by="block_start DESC")[0] - previous_block_start = previous_block_key["block_start"] - else: - previous_block_key = None - previous_block_start = (acquisition.Chunk & exp_key).fetch( - "chunk_start", limit=1, order_by="chunk_start" - )[0] - - chunk_restriction = acquisition.create_chunk_restriction( - key["experiment_name"], previous_block_start, chunk_end - ) - - block_query = acquisition.Environment.BlockState & chunk_restriction - block_df = fetch_stream(block_query).sort_index()[previous_block_start:chunk_end] - - block_ends = block_df[block_df.pellet_ct.diff() < 0] - - block_entries = [] - for idx, block_end in enumerate(block_ends.index): - if idx == 0: - if previous_block_key: - # if there is a previous block - insert "block_end" for the previous block - previous_pellet_time = block_df[:block_end].index[-2] - previous_epoch = ( - acquisition.Epoch.join(acquisition.EpochEnd, left=True) - & exp_key - & f"'{previous_pellet_time}' BETWEEN epoch_start AND IFNULL(epoch_end, '2200-01-01')" - ).fetch1("KEY") - current_epoch = ( - acquisition.Epoch.join(acquisition.EpochEnd, left=True) - & exp_key - & f"'{block_end}' BETWEEN epoch_start AND IFNULL(epoch_end, '2200-01-01')" - ).fetch1("KEY") - - previous_block_key["block_end"] = ( - block_end if current_epoch == previous_epoch else previous_pellet_time - ) - Block.update1(previous_block_key) - else: - block_entries[-1]["block_end"] = block_end - block_entries.append({**exp_key, "block_start": block_end, "block_end": None}) - - Block.insert(block_entries) - self.insert1(key) From 5fa0a7e02641abacf41b128f2559d911561d4373 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 19 Apr 2024 17:15:10 -0500 Subject: [PATCH 471/489] update(tracking): raise error if no SLEAP data found --- aeon/dj_pipeline/tracking.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/tracking.py b/aeon/dj_pipeline/tracking.py index 7e140252..f46d9ec6 100644 --- a/aeon/dj_pipeline/tracking.py +++ b/aeon/dj_pipeline/tracking.py @@ -172,8 +172,7 @@ def make(self, key): ) if not len(pose_data): - self.insert1(key) - return + raise ValueError(f"No SLEAP data found for {device_name}") # Find the config file for the SLEAP model for data_dir in data_dirs: From a1a2f44ddd5ae84111e2315bc76716a090f5042c Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 19 Apr 2024 17:22:00 -0500 Subject: [PATCH 472/489] chore: update error message --- aeon/dj_pipeline/tracking.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/tracking.py b/aeon/dj_pipeline/tracking.py index f46d9ec6..e7c788fc 100644 --- a/aeon/dj_pipeline/tracking.py +++ b/aeon/dj_pipeline/tracking.py @@ -172,7 +172,7 @@ def make(self, key): ) if not len(pose_data): - raise ValueError(f"No SLEAP data found for {device_name}") + raise ValueError(f"No SLEAP data found for {key['experiment_name']} - {device_name}") # Find the config file for the SLEAP model for data_dir in data_dirs: From 6bf6f03f299de34da8f9f6d421dc314e6d0ff8a4 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 25 Apr 2024 08:54:59 -0500 Subject: [PATCH 473/489] fix: add `.sort_index()` on the streams dataframe --- aeon/dj_pipeline/__init__.py | 1 + aeon/dj_pipeline/analysis/block_analysis.py | 3 +-- aeon/dj_pipeline/utils/load_metadata.py | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/aeon/dj_pipeline/__init__.py b/aeon/dj_pipeline/__init__.py index b319f55b..72e57718 100644 --- a/aeon/dj_pipeline/__init__.py +++ b/aeon/dj_pipeline/__init__.py @@ -44,6 +44,7 @@ def fetch_stream(query, drop_pk=True): df.drop(columns=cols2drop, inplace=True, errors="ignore") df.rename(columns={"timestamps": "time"}, inplace=True) df.set_index("time", inplace=True) + df.sort_index(inplace=True) return df diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index 517ff373..16e78872 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -58,7 +58,7 @@ def make(self, key): ) block_query = acquisition.Environment.BlockState & chunk_restriction - block_df = fetch_stream(block_query).sort_index()[previous_block_start:chunk_end] + block_df = fetch_stream(block_query)[previous_block_start:chunk_end] block_ends = block_df[block_df.pellet_ct.diff() < 0] @@ -323,7 +323,6 @@ def make(self, key): subject_names = [s["subject_name"] for s in block_subjects] # Construct subject position dataframe subjects_positions_df = pd.concat( - [ pd.DataFrame( {"subject_name": [s["subject_name"]] * len(s["position_timestamps"])} diff --git a/aeon/dj_pipeline/utils/load_metadata.py b/aeon/dj_pipeline/utils/load_metadata.py index 56993880..f2639c22 100644 --- a/aeon/dj_pipeline/utils/load_metadata.py +++ b/aeon/dj_pipeline/utils/load_metadata.py @@ -201,10 +201,9 @@ def ingest_epoch_metadata(experiment_name, devices_schema, metadata_yml_filepath if not (streams.Device & device_key): logger.warning( - f"Device {device_name} (serial number: {device_sn}) is not yet registered in streams.Device. Skipping..." + f"Device {device_name} (serial number: {device_sn}) is not yet registered in streams.Device.\nThis should not happen - check if metadata.yml and schemas dotmap are consistent. Skipping..." ) # skip if this device (with a serial number) is not yet inserted in streams.Device - # this should not happen - check if metadata.yml and schemas dotmap are consistent continue device_list.append(device_key) From 193f0472e49c67680d5bb708d26be2a594c7d504 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 25 Apr 2024 08:59:43 -0500 Subject: [PATCH 474/489] fix: more robust automated analysis - skip `BlockAnalysis` that are not ready --- aeon/dj_pipeline/analysis/block_analysis.py | 5 ++--- aeon/dj_pipeline/populate/worker.py | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index 16e78872..83cf62a1 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -155,10 +155,9 @@ def make(self, key): ) for streams_table in streams_tables: if len(streams_table & chunk_keys) < len(streams_table.key_source & chunk_keys): - logger.info( - f"{streams_table.__name__} not yet fully ingested for block: {key}. Skip BlockAnalysis (to retry later)..." + raise ValueError( + f"BlockAnalysis Not Ready - {streams_table.__name__} not yet fully ingested for block: {key}. Skipping (to retry later)..." ) - return self.insert1({**key, "block_duration": (block_end - block_start).total_seconds() / 3600}) diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py index c1a8f86c..18f9e60c 100644 --- a/aeon/dj_pipeline/populate/worker.py +++ b/aeon/dj_pipeline/populate/worker.py @@ -103,6 +103,7 @@ def ingest_environment_visits(): db_prefix=db_prefix, max_idled_cycle=6, sleep_duration=1200, + autoclear_error_patterns=["%BlockAnalysis Not Ready%"], ) analysis_worker(block_analysis.BlockAnalysis, max_calls=6) From 9c0c8c6536f6a246d27f842b4512cc219e0404da Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 25 Apr 2024 11:50:16 -0500 Subject: [PATCH 475/489] fix(chunk): fix first chunk starting before epoch start --- aeon/dj_pipeline/acquisition.py | 7 +------ aeon/dj_pipeline/analysis/block_analysis.py | 1 - aeon/dj_pipeline/populate/worker.py | 2 +- 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/aeon/dj_pipeline/acquisition.py b/aeon/dj_pipeline/acquisition.py index 9823435d..b20c1a0c 100644 --- a/aeon/dj_pipeline/acquisition.py +++ b/aeon/dj_pipeline/acquisition.py @@ -385,18 +385,13 @@ def ingest_chunks(cls, experiment_name): continue chunk_start = chunk.name + chunk_start = max(chunk_start, epoch_start) # first chunk of the epoch starts at epoch_start chunk_end = chunk_start + datetime.timedelta(hours=io_api.CHUNK_DURATION) if EpochEnd & epoch_key: epoch_end = (EpochEnd & epoch_key).fetch1("epoch_end") chunk_end = min(chunk_end, epoch_end) - if chunk_start in chunk_starts: - # handle cases where two chunks with identical start_time - # (starts in the same hour) but from 2 consecutive epochs - # using epoch_start as chunk_start in this case - chunk_start = epoch_start - # --- insert to Chunk --- chunk_key = {"experiment_name": experiment_name, "chunk_start": chunk_start} diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index 83cf62a1..17927918 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -1,5 +1,4 @@ import json - import datajoint as dj import numpy as np import pandas as pd diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py index 18f9e60c..3153ffe6 100644 --- a/aeon/dj_pipeline/populate/worker.py +++ b/aeon/dj_pipeline/populate/worker.py @@ -87,6 +87,7 @@ def ingest_environment_visits(): db_prefix=db_prefix, max_idled_cycle=50, sleep_duration=60, + autoclear_error_patterns=["%BlockAnalysis Not Ready%"], ) for attr in vars(streams).values(): @@ -103,7 +104,6 @@ def ingest_environment_visits(): db_prefix=db_prefix, max_idled_cycle=6, sleep_duration=1200, - autoclear_error_patterns=["%BlockAnalysis Not Ready%"], ) analysis_worker(block_analysis.BlockAnalysis, max_calls=6) From dba170aa9a7d0a5993c2f1c65afa0c7e6e896fc7 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 25 Apr 2024 12:26:24 -0500 Subject: [PATCH 476/489] disable automatic BlockDetection --- aeon/dj_pipeline/analysis/block_analysis.py | 2 +- aeon/dj_pipeline/populate/worker.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index 17927918..e49cf71d 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -86,7 +86,7 @@ def make(self, key): block_entries[-1]["block_end"] = block_end block_entries.append({**exp_key, "block_start": block_end, "block_end": None}) - Block.insert(block_entries) + Block.insert(block_entries, skip_duplicates=True) self.insert1(key) diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py index 3153ffe6..e538c8cb 100644 --- a/aeon/dj_pipeline/populate/worker.py +++ b/aeon/dj_pipeline/populate/worker.py @@ -64,7 +64,7 @@ def ingest_environment_visits(): acquisition_worker(acquisition.EpochConfig) acquisition_worker(acquisition.Environment) # acquisition_worker(ingest_environment_visits) -acquisition_worker(block_analysis.BlockDetection) +# acquisition_worker(block_analysis.BlockDetection) # configure a worker to handle pyrat sync pyrat_worker = DataJointWorker( From 3b8f16a9e0c0dc5a307f070be06035fb4fe4896a Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 2 May 2024 10:16:47 -0500 Subject: [PATCH 477/489] feat(block_analysis): skip subjects with no position data, select first patch rate --- aeon/dj_pipeline/analysis/block_analysis.py | 41 ++++++++++++++++----- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index e49cf71d..3bd97e51 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -5,12 +5,11 @@ import plotly.express as px import plotly.graph_objs as go from matplotlib import path as mpl_path +from datetime import datetime from aeon.analysis import utils as analysis_utils -from aeon.dj_pipeline import (acquisition, fetch_stream, get_schema_name, - streams, tracking) -from aeon.dj_pipeline.analysis.visit import (filter_out_maintenance_periods, - get_maintenance_periods) +from aeon.dj_pipeline import acquisition, fetch_stream, get_schema_name, streams, tracking +from aeon.dj_pipeline.analysis.visit import filter_out_maintenance_periods, get_maintenance_periods schema = dj.schema(get_schema_name("block_analysis")) logger = dj.logger @@ -92,6 +91,7 @@ def make(self, key): # ---- Block Analysis and Visualization ---- + @schema class BlockAnalysis(dj.Computed): definition = """ @@ -203,11 +203,18 @@ def make(self, key): encoder_df["distance_travelled"] = -1 * analysis_utils.distancetravelled(encoder_df.angle) - patch_rate = depletion_state_df.rate.unique() - patch_offset = depletion_state_df.offset.unique() - assert len(patch_rate) == 1, f"Found multiple patch rates: {patch_rate} for patch: {patch_name}" - patch_rate = patch_rate[0] - patch_offset = patch_offset[0] + if len(depletion_state_df.rate.unique()) > 1: + # multiple patch rates per block is unexpected, log a note and pick the first rate to move forward + AnalysisNote.insert1( + { + "note_timestamp": datetime.utcnow(), + "note_type": "Multiple patch rates", + "note": f"Found multiple patch rates for block {key} - patch: {patch_name} - rates: {depletion_state_df.rate.unique()}", + } + ) + + patch_rate = depletion_state_df.rate.iloc[0] + patch_offset = depletion_state_df.offset.iloc[0] self.Patch.insert1( { @@ -256,6 +263,9 @@ def make(self, key): pos_df = fetch_stream(pos_query)[block_start:block_end] pos_df = filter_out_maintenance_periods(pos_df, maintenance_period, block_end) + if pos_df.empty: + continue + position_diff = np.sqrt( np.square(np.diff(pos_df.x.astype(float))) + np.square(np.diff(pos_df.y.astype(float))) ) @@ -553,3 +563,16 @@ def make(self, key): "cumulative_pellet_plot": json.loads(cumulative_pellet_fig.to_json()), } ) + + +# ---- AnalysisNote ---- + + +@schema +class AnalysisNote(dj.Manual): + definition = """ # Generic table to catch all notes generated during analysis + note_timestamp: datetime + --- + note_type='': varchar(64) + note: varchar(3000) + """ From bb3c5071db9962a649d2c3180543a2f78fff1d95 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 2 May 2024 10:18:37 -0500 Subject: [PATCH 478/489] fix(test): remove blank return --- tests/conftest.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index c038c0dc..4236c3e3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -58,7 +58,6 @@ def dj_config(): dj.config["custom"][ "database.prefix" ] = f"u_{dj.config['database.user']}_testsuite_" - return def load_pipeline(): @@ -137,8 +136,6 @@ def experiment_creation(test_params, pipeline): } ) - return - @pytest.fixture(scope="session") def epoch_chunk_ingestion(test_params, pipeline, experiment_creation): @@ -154,8 +151,6 @@ def epoch_chunk_ingestion(test_params, pipeline, experiment_creation): acquisition.Chunk.ingest_chunks(experiment_name=test_params["experiment_name"]) - return - @pytest.fixture(scope="session") def experimentlog_ingestion(pipeline): @@ -166,20 +161,14 @@ def experimentlog_ingestion(pipeline): acquisition.SubjectEnterExit.populate(**_populate_settings) acquisition.SubjectWeight.populate(**_populate_settings) - return - @pytest.fixture(scope="session") def camera_qc_ingestion(pipeline, epoch_chunk_ingestion): qc = pipeline["qc"] qc.CameraQC.populate(**_populate_settings) - return - @pytest.fixture(scope="session") def camera_tracking_ingestion(pipeline, camera_qc_ingestion): tracking = pipeline["tracking"] tracking.CameraTracking.populate(**_populate_settings) - - return From 929d0df83916897d03fc2a618e29ae1a64453a00 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 2 May 2024 16:44:01 -0500 Subject: [PATCH 479/489] fix(block_analysis): bugfix missing experiment_name in query --- aeon/dj_pipeline/analysis/block_analysis.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index 3bd97e51..c9a28657 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -55,17 +55,17 @@ def make(self, key): key["experiment_name"], previous_block_start, chunk_end ) - block_query = acquisition.Environment.BlockState & chunk_restriction - block_df = fetch_stream(block_query)[previous_block_start:chunk_end] + block_state_query = acquisition.Environment.BlockState & exp_key & chunk_restriction + block_state_df = fetch_stream(block_state_query)[previous_block_start:chunk_end] - block_ends = block_df[block_df.pellet_ct.diff() < 0] + block_ends = block_state_df[block_state_df.pellet_ct.diff() < 0] block_entries = [] for idx, block_end in enumerate(block_ends.index): if idx == 0: if previous_block_key: # if there is a previous block - insert "block_end" for the previous block - previous_pellet_time = block_df[:block_end].index[-2] + previous_pellet_time = block_state_df[:block_end].index[-2] previous_epoch = ( acquisition.Epoch.join(acquisition.EpochEnd, left=True) & exp_key @@ -254,6 +254,7 @@ def make(self, key): streams.SpinnakerVideoSource * tracking.SLEAPTracking.PoseIdentity.proj("identity_name", anchor_part="part_name") * tracking.SLEAPTracking.Part + & key & { "spinnaker_video_source_name": "CameraTop", "identity_name": subject_name, From a8f41e3bba95ee489f27a03c899ee223476e6266 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Thu, 2 May 2024 16:45:14 -0500 Subject: [PATCH 480/489] Update worker.py --- aeon/dj_pipeline/populate/worker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py index e538c8cb..3153ffe6 100644 --- a/aeon/dj_pipeline/populate/worker.py +++ b/aeon/dj_pipeline/populate/worker.py @@ -64,7 +64,7 @@ def ingest_environment_visits(): acquisition_worker(acquisition.EpochConfig) acquisition_worker(acquisition.Environment) # acquisition_worker(ingest_environment_visits) -# acquisition_worker(block_analysis.BlockDetection) +acquisition_worker(block_analysis.BlockDetection) # configure a worker to handle pyrat sync pyrat_worker = DataJointWorker( From 3fca9f4387f89708564df4a024e02a9ec1b8453e Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 22 May 2024 20:45:58 -0500 Subject: [PATCH 481/489] feat: add patch preference calculation and block-patch-subject plots --- aeon/analysis/block_plotting.py | 62 ++++ aeon/dj_pipeline/analysis/block_analysis.py | 310 ++++++++++++++------ 2 files changed, 276 insertions(+), 96 deletions(-) create mode 100644 aeon/analysis/block_plotting.py diff --git a/aeon/analysis/block_plotting.py b/aeon/analysis/block_plotting.py new file mode 100644 index 00000000..027da966 --- /dev/null +++ b/aeon/analysis/block_plotting.py @@ -0,0 +1,62 @@ +import os +import pathlib +from colorsys import hls_to_rgb, rgb_to_hls +from contextlib import contextmanager +from pathlib import Path + +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +import plotly +import plotly.express as px +import plotly.graph_objs as go +import seaborn as sns +from numpy.lib.stride_tricks import as_strided + +"""Standardize subject colors, patch colors, and markers.""" + +subject_colors = plotly.colors.qualitative.Plotly +subject_colors_dict = { + "BAA-1104045": subject_colors[0], + "BAA-1104047": subject_colors[1], + "BAA-1104048": subject_colors[2], + "BAA-1104049": subject_colors[3], +} +patch_colors = plotly.colors.qualitative.Dark2 +patch_markers = [ + "circle", + "bowtie", + "square", + "hourglass", + "diamond", + "cross", + "x", + "triangle", + "star", +] +patch_markers_symbols = ["●", "⧓", "■", "⧗", "♦", "✖", "×", "▲", "★"] +patch_markers_dict = { + marker: symbol for marker, symbol in zip(patch_markers, patch_markers_symbols) +} +patch_markers_linestyles = ["solid", "dash", "dot", "dashdot", "longdashdot"] + + +def gen_hex_grad(hex_col, vals, min_l=0.3): + """Generates an array of hex color values based on a gradient defined by unit-normalized values.""" + # Convert hex to rgb to hls + h, l, s = rgb_to_hls( + *[int(hex_col.lstrip("#")[i: i + 2], 16) / 255 for i in (0, 2, 4)] + ) + grad = np.empty(shape=(len(vals),), dtype=" BlockAnalysis.Subject --- cumulative_preference_by_wheel: longblob - windowed_preference_by_wheel: longblob - cumulative_preference_by_pellet: longblob - windowed_preference_by_pellet: longblob cumulative_preference_by_time: longblob - windowed_preference_by_time: longblob - preference_score: float # one representative preference score for the entire block + final_preference_by_wheel: float # cumulative_preference_by_wheel at the end of the block + final_preference_by_time: float # cumulative_preference_by_time at the end of the block """ def make(self, key): block_patches = (BlockAnalysis.Patch & key).fetch(as_dict=True) block_subjects = (BlockAnalysis.Subject & key).fetch(as_dict=True) subject_names = [s["subject_name"] for s in block_subjects] + patch_names = [p["patch_name"] for p in block_patches] # Construct subject position dataframe subjects_positions_df = pd.concat( [ @@ -349,116 +347,151 @@ def make(self, key): ] ) subjects_positions_df.set_index("position_timestamps", inplace=True) - # Get frame rate of CameraTop - camera_fps = int( - ( - streams.SpinnakerVideoSource * streams.SpinnakerVideoSource.Attribute - & key - & 'attribute_name = "SamplingFrequency"' - & 'spinnaker_video_source_name = "CameraTop"' - & f'spinnaker_video_source_install_time < "{key["block_start"]}"' - ).fetch("attribute_value", order_by="spinnaker_video_source_install_time DESC", limit=1)[0] - ) self.insert1(key) - for i, patch in enumerate(block_patches): + + in_patch_radius = 130 # pixels + pref_attrs = ["cum_dist", "cum_time", "cum_pref_dist", "cum_pref_time"] + all_subj_patch_pref_dict = { + p: {s: {a: pd.Series() for a in pref_attrs} for s in subject_names} for p in patch_names + } + + for patch in block_patches: cum_wheel_dist = pd.Series( index=patch["wheel_timestamps"], data=patch["wheel_cumsum_distance_travelled"] ) - # Get distance-to-patch at each pose data timestep - patch_region = ( - acquisition.EpochConfig.ActiveRegion + # Assign pellets and wheel timestamps to subjects + # Assign id based on which subject was closest to patch at time of event + # Get distance-to-patch at each wheel ts and pel del ts, organized by subject + # Get patch x,y from metadata patch rfid loc + patch_center = ( + streams.RfidReader * streams.RfidReader.Attribute & key - & {"region_name": f"{patch['patch_name']}Region"} - & f'epoch_start < "{key["block_start"]}"' - ).fetch("region_data", order_by="epoch_start DESC", limit=1)[0] - patch_xy = list(zip(*[(int(p["X"]), int(p["Y"])) for p in patch_region["ArrayOfPoint"]])) - patch_center = np.mean(patch_xy[0]).astype(np.uint32), np.mean(patch_xy[1]).astype(np.uint32) + & f"rfid_reader_name LIKE '%{patch['patch_name']}%'" + & "attribute_name = 'Location'" + ).fetch1("attribute_value") + patch_center = (int(patch_center["X"]), int(patch_center["Y"])) subjects_xy = subjects_positions_df[["position_x", "position_y"]].values dist_to_patch = np.sqrt(np.sum((subjects_xy - patch_center) ** 2, axis=1).astype(float)) dist_to_patch_df = subjects_positions_df[["subject_name"]].copy() dist_to_patch_df["dist_to_patch"] = dist_to_patch - # Assign pellets and wheel timestamps to subjects - if len(block_subjects) == 1: - cum_wheel_dist_dm = cum_wheel_dist.to_frame(name=subject_names[0]) - patch_df_for_pellets_df = pd.DataFrame( - index=patch["pellet_timestamps"], data={"subject_name": subject_names[0]} - ) - else: - # Assign id based on which subject was closest to patch at time of event - # Get distance-to-patch at each wheel ts and pel del ts, organized by subject - dist_to_patch_wheel_ts_id_df = pd.DataFrame( - index=cum_wheel_dist.index, columns=subject_names - ) - dist_to_patch_pel_ts_id_df = pd.DataFrame( - index=patch["pellet_timestamps"], columns=subject_names - ) - for subject_name in subject_names: - # Find closest match between pose_df indices and wheel indices - if not dist_to_patch_wheel_ts_id_df.empty: - dist_to_patch_wheel_ts_subj = pd.merge_asof( - left=dist_to_patch_wheel_ts_id_df[subject_name], - right=dist_to_patch_df[dist_to_patch_df["subject_name"] == subject_name], - left_index=True, - right_index=True, - direction="forward", - tolerance=pd.Timedelta("100ms"), - ) - dist_to_patch_wheel_ts_id_df[subject_name] = dist_to_patch_wheel_ts_subj[ - "dist_to_patch" - ] - # Find closest match between pose_df indices and pel indices - if not dist_to_patch_pel_ts_id_df.empty: - dist_to_patch_pel_ts_subj = pd.merge_asof( - left=dist_to_patch_pel_ts_id_df[subject_name], - right=dist_to_patch_df[dist_to_patch_df["subject_name"] == subject_name], - left_index=True, - right_index=True, - direction="forward", - tolerance=pd.Timedelta("200ms"), - ) - dist_to_patch_pel_ts_id_df[subject_name] = dist_to_patch_pel_ts_subj[ - "dist_to_patch" - ] - # Get closest subject to patch at each pel del timestep - patch_df_for_pellets_df = pd.DataFrame( - index=patch["pellet_timestamps"], - data={"subject_name": dist_to_patch_pel_ts_id_df.idxmin(axis=1).values}, - ) - # Get closest subject to patch at each wheel timestep - cum_wheel_dist_subj_df = pd.DataFrame( - index=cum_wheel_dist.index, columns=subject_names, data=0.0 - ) - closest_subjects = dist_to_patch_wheel_ts_id_df.idxmin(axis=1) - wheel_dist = cum_wheel_dist.diff().fillna(cum_wheel_dist.iloc[0]) - # Assign wheel dist to closest subject for each wheel timestep - for subject_name in subject_names: - subj_idxs = cum_wheel_dist_subj_df[closest_subjects == subject_name].index - cum_wheel_dist_subj_df.loc[subj_idxs, subject_name] = wheel_dist[subj_idxs] - cum_wheel_dist_dm = cum_wheel_dist_subj_df.cumsum(axis=0) - - # In Patch Time - patch_bbox = mpl_path.Path(list(zip(*patch_xy))) - in_patch = subjects_positions_df.apply( - lambda row: patch_bbox.contains_point((row["position_x"], row["position_y"])), axis=1 + dist_to_patch_wheel_ts_id_df = pd.DataFrame( + index=cum_wheel_dist.index, columns=subject_names + ) + dist_to_patch_pel_ts_id_df = pd.DataFrame( + index=patch["pellet_timestamps"], columns=subject_names ) - # Insert data for subject_name in subject_names: - pellets = patch_df_for_pellets_df[patch_df_for_pellets_df["subject_name"] == subject_name] - subject_in_patch = subjects_positions_df[ - in_patch & (subjects_positions_df["subject_name"] == subject_name) - ] + # Find closest match between pose_df indices and wheel indices + if not dist_to_patch_wheel_ts_id_df.empty: + dist_to_patch_wheel_ts_subj = pd.merge_asof( + left=pd.DataFrame( + dist_to_patch_wheel_ts_id_df[subject_name].copy() + ).reset_index(names="time"), + right=dist_to_patch_df[dist_to_patch_df["subject_name"] == subject_name] + .copy() + .reset_index(names="time"), + on="time", + # left_index=True, + # right_index=True, + direction="nearest", + tolerance=pd.Timedelta("100ms"), + ) + dist_to_patch_wheel_ts_id_df[subject_name] = dist_to_patch_wheel_ts_subj[ + "dist_to_patch" + ].values + # Find closest match between pose_df indices and pel indices + if not dist_to_patch_pel_ts_id_df.empty: + dist_to_patch_pel_ts_subj = pd.merge_asof( + left=pd.DataFrame(dist_to_patch_pel_ts_id_df[subject_name].copy()).reset_index( + names="time" + ), + right=dist_to_patch_df[dist_to_patch_df["subject_name"] == subject_name] + .copy() + .reset_index(names="time"), + on="time", + # left_index=True, + # right_index=True, + direction="nearest", + tolerance=pd.Timedelta("200ms"), + ) + dist_to_patch_pel_ts_id_df[subject_name] = dist_to_patch_pel_ts_subj[ + "dist_to_patch" + ].values + + # Get closest subject to patch at each pel del timestep + closest_subjects_pellet_ts = dist_to_patch_pel_ts_id_df.idxmin(axis=1) + # Get closest subject to patch at each wheel timestep + cum_wheel_dist_subj_df = pd.DataFrame( + index=cum_wheel_dist.index, columns=subject_names, data=0.0 + ) + closest_subjects_wheel_ts = dist_to_patch_wheel_ts_id_df.idxmin(axis=1) + wheel_dist = cum_wheel_dist.diff().fillna(cum_wheel_dist.iloc[0]) + # Assign wheel dist to closest subject for each wheel timestep + for subject_name in subject_names: + subj_idxs = cum_wheel_dist_subj_df[closest_subjects_wheel_ts == subject_name].index + cum_wheel_dist_subj_df.loc[subj_idxs, subject_name] = wheel_dist[subj_idxs] + cum_wheel_dist_subj_df = cum_wheel_dist_subj_df.cumsum(axis=0) + + # In patch time + in_patch = dist_to_patch_wheel_ts_id_df < in_patch_radius + dt = np.median(np.diff(cum_wheel_dist.index)).astype(int) / 1e9 # s + # Fill in `all_subj_patch_pref` + for subject_name in subject_names: + all_subj_patch_pref_dict[patch["patch_name"]][subject_name]["cum_dist"] = ( + cum_wheel_dist_subj_df[subject_name].values + ) + subject_in_patch = in_patch[subject_name] + subject_in_patch_cum_time = subject_in_patch.cumsum().values * dt + all_subj_patch_pref_dict[patch["patch_name"]][subject_name][ + "cum_time" + ] = subject_in_patch_cum_time + subj_pellets = closest_subjects_pellet_ts[closest_subjects_pellet_ts == subject_name] self.Patch.insert1( key | dict( patch_name=patch["patch_name"], subject_name=subject_name, in_patch_timestamps=subject_in_patch.index.values, - in_patch_time=len(subject_in_patch) / camera_fps, - pellet_count=len(pellets), - pellet_timestamps=pellets.index.values, - wheel_cumsum_distance_travelled=cum_wheel_dist_dm[subject_name].values, + in_patch_time=subject_in_patch_cum_time[-1], + pellet_count=len(subj_pellets), + pellet_timestamps=subj_pellets.index.values, + wheel_cumsum_distance_travelled=cum_wheel_dist_subj_df[subject_name].values, + ) + ) + + # Now that we have computed all individual patch and subject values, we iterate again through + # patches and subjects to compute preference scores + for subject_name in subject_names: + # Get sum of subj cum wheel dists and cum in patch time + all_cum_dist = np.sum( + [all_subj_patch_pref_dict[p][subject_name]["cum_dist"][-1] for p in patch_names] + ) + all_cum_time = np.sum( + [all_subj_patch_pref_dict[p][subject_name]["cum_time"][-1] for p in patch_names] + ) + + for patch_name in patch_names: + cum_pref_dist = ( + all_subj_patch_pref_dict[patch_name][subject_name]["cum_dist"] / all_cum_dist + ) + all_subj_patch_pref_dict[patch_name][subject_name]["cum_pref_dist"] = cum_pref_dist + + cum_pref_time = ( + all_subj_patch_pref_dict[patch_name][subject_name]["cum_time"] / all_cum_time + ) + all_subj_patch_pref_dict[patch_name][subject_name]["cum_pref_time"] = cum_pref_time + + self.Preference.insert1( + key + | dict( + patch_name=patch_name, + subject_name=subject_name, + cumulative_preference_by_time=cum_pref_time, + cumulative_preference_by_wheel=cum_pref_dist, + final_preference_by_time=cum_pref_time[-1], + final_preference_by_wheel=cum_pref_dist[-1], ) ) @@ -566,6 +599,91 @@ def make(self, key): ) +@schema +class BlockSubjectPlots(dj.Computed): + definition = """ + -> BlockAnalysis + --- + dist_pref_plot: longblob + time_pref_plot: longblob + """ + + def make(self, key): + from aeon.analysis.block_plotting import subject_colors, patch_markers_linestyles, patch_markers, gen_hex_grad + + patch_names, subject_names = (BlockSubjectAnalysis.Preference & key).fetch("patch_name", "subject_name") + patch_names = set(patch_names) + subject_names = set(subject_names) + + dist_pref_fig, time_pref_fig = go.Figure(), go.Figure() + for subj_i, subj in enumerate(subject_names): + for patch_i, p in enumerate(patch_names): + rate, offset, wheel_ts = (BlockAnalysis.Patch & key & {"patch_name": p}).fetch1("patch_rate", "patch_offset", "wheel_timestamps") + cum_pref_dist, cum_pref_time = (BlockSubjectAnalysis.Preference & key & {"patch_name": p, "subject_name": subj}).fetch1("cumulative_preference_by_wheel", "cumulative_preference_by_time") + pellet_ts = (BlockSubjectAnalysis.Patch & key & {"patch_name": p, "subject_name": subj}).fetch1("pellet_timestamps") + + patch_mean = 1 / rate // 100 * 100 + cur_p = f"P{patch_i + 1}" + + for fig, cum_pref in zip([dist_pref_fig, time_pref_fig], [cum_pref_dist, cum_pref_time]): + fig.add_trace( + go.Scatter( + x=wheel_ts, + y=cum_pref, + mode="lines", # + markers", + line=dict( + width=2, + color=subject_colors[subj_i], + dash=patch_markers_linestyles[patch_i], + ), + name=f"{subj} - {cur_p}: μ: {patch_mean}", + ) + ) + # Add markers for each pellet + cur_cum_pel_ct = pd.merge_asof( + pd.DataFrame(index=pellet_ts).reset_index(names="time"), + pd.DataFrame(index=wheel_ts, data={"cum_pref": cum_pref}).reset_index(names="time"), + on="time", + direction="forward", + tolerance=pd.Timedelta("0.1s"), + ) + if not cur_cum_pel_ct.empty: + fig.add_trace( + go.Scatter( + x=cur_cum_pel_ct["time"], + y=cur_cum_pel_ct["cum_pref"], + mode="markers", + marker=dict( + symbol=patch_markers[patch_i], + # color=gen_hex_grad( + # subject_colors[-1], cur_cum_pel_ct["norm_thresh_val"] + # ), + size=8, + ), + showlegend=False, + # customdata=np.stack((cur_cum_pel_ct["threshold"],), axis=-1), + # hovertemplate="Threshold: %{customdata[0]:.2f} cm", + ) + ) + + for fig, title in zip([dist_pref_fig, time_pref_fig], ["Wheel Distance", "Patch Time"]): + fig.update_layout( + title=f"Cumulative Patch Preference - {title}", + xaxis_title="Time", + yaxis_title="Pref Index", + yaxis=dict(tickvals=np.arange(0, 1.1, 0.1)), + ) + + # Insert figures as json-formatted plotly plots + self.insert1( + { + **key, + "dist_pref_plot": json.loads(dist_pref_fig.to_json()), + "time_pref_plot": json.loads(time_pref_fig.to_json()), + } + ) + + # ---- AnalysisNote ---- From c1ee1b186e55261f929aaa4da542ab5914bb1a69 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 22 May 2024 21:48:05 -0500 Subject: [PATCH 482/489] chore: more robust code + black formatting --- aeon/dj_pipeline/analysis/block_analysis.py | 42 ++++++++++++++------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index 6b9f09ed..1ae3a1e6 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -323,6 +323,8 @@ class Preference(dj.Part): final_preference_by_time: float # cumulative_preference_by_time at the end of the block """ + key_source = BlockAnalysis & BlockAnalysis.Patch & BlockAnalysis.Subject + def make(self, key): block_patches = (BlockAnalysis.Patch & key).fetch(as_dict=True) block_subjects = (BlockAnalysis.Subject & key).fetch(as_dict=True) @@ -365,8 +367,11 @@ def make(self, key): # Get distance-to-patch at each wheel ts and pel del ts, organized by subject # Get patch x,y from metadata patch rfid loc patch_center = ( - streams.RfidReader * streams.RfidReader.Attribute + streams.RfidReader.join(streams.RfidReader.RemovalTime, left=True) + * streams.RfidReader.Attribute & key + & f"'{key['block_start']}' >= rfid_reader_install_time" + & f"'{key['block_start']}' < IFNULL(rfid_reader_removal_time, '2200-01-01')" & f"rfid_reader_name LIKE '%{patch['patch_name']}%'" & "attribute_name = 'Location'" ).fetch1("attribute_value") @@ -376,9 +381,7 @@ def make(self, key): dist_to_patch_df = subjects_positions_df[["subject_name"]].copy() dist_to_patch_df["dist_to_patch"] = dist_to_patch - dist_to_patch_wheel_ts_id_df = pd.DataFrame( - index=cum_wheel_dist.index, columns=subject_names - ) + dist_to_patch_wheel_ts_id_df = pd.DataFrame(index=cum_wheel_dist.index, columns=subject_names) dist_to_patch_pel_ts_id_df = pd.DataFrame( index=patch["pellet_timestamps"], columns=subject_names ) @@ -386,9 +389,9 @@ def make(self, key): # Find closest match between pose_df indices and wheel indices if not dist_to_patch_wheel_ts_id_df.empty: dist_to_patch_wheel_ts_subj = pd.merge_asof( - left=pd.DataFrame( - dist_to_patch_wheel_ts_id_df[subject_name].copy() - ).reset_index(names="time"), + left=pd.DataFrame(dist_to_patch_wheel_ts_id_df[subject_name].copy()).reset_index( + names="time" + ), right=dist_to_patch_df[dist_to_patch_df["subject_name"] == subject_name] .copy() .reset_index(names="time"), @@ -599,7 +602,7 @@ def make(self, key): ) -@schema +# @schema class BlockSubjectPlots(dj.Computed): definition = """ -> BlockAnalysis @@ -609,18 +612,31 @@ class BlockSubjectPlots(dj.Computed): """ def make(self, key): - from aeon.analysis.block_plotting import subject_colors, patch_markers_linestyles, patch_markers, gen_hex_grad + from aeon.analysis.block_plotting import ( + subject_colors, + patch_markers_linestyles, + patch_markers, + gen_hex_grad, + ) - patch_names, subject_names = (BlockSubjectAnalysis.Preference & key).fetch("patch_name", "subject_name") + patch_names, subject_names = (BlockSubjectAnalysis.Preference & key).fetch( + "patch_name", "subject_name" + ) patch_names = set(patch_names) subject_names = set(subject_names) dist_pref_fig, time_pref_fig = go.Figure(), go.Figure() for subj_i, subj in enumerate(subject_names): for patch_i, p in enumerate(patch_names): - rate, offset, wheel_ts = (BlockAnalysis.Patch & key & {"patch_name": p}).fetch1("patch_rate", "patch_offset", "wheel_timestamps") - cum_pref_dist, cum_pref_time = (BlockSubjectAnalysis.Preference & key & {"patch_name": p, "subject_name": subj}).fetch1("cumulative_preference_by_wheel", "cumulative_preference_by_time") - pellet_ts = (BlockSubjectAnalysis.Patch & key & {"patch_name": p, "subject_name": subj}).fetch1("pellet_timestamps") + rate, offset, wheel_ts = (BlockAnalysis.Patch & key & {"patch_name": p}).fetch1( + "patch_rate", "patch_offset", "wheel_timestamps" + ) + cum_pref_dist, cum_pref_time = ( + BlockSubjectAnalysis.Preference & key & {"patch_name": p, "subject_name": subj} + ).fetch1("cumulative_preference_by_wheel", "cumulative_preference_by_time") + pellet_ts = ( + BlockSubjectAnalysis.Patch & key & {"patch_name": p, "subject_name": subj} + ).fetch1("pellet_timestamps") patch_mean = 1 / rate // 100 * 100 cur_p = f"P{patch_i + 1}" From 64c68566bc03c9ffcd52a3c98c360e5c1e66a0f4 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Fri, 24 May 2024 17:49:51 -0500 Subject: [PATCH 483/489] feat(block_analysis): add pref plots --- aeon/dj_pipeline/analysis/block_analysis.py | 44 +++++++++++++++------ 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index 1ae3a1e6..ce426931 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -319,8 +319,8 @@ class Preference(dj.Part): --- cumulative_preference_by_wheel: longblob cumulative_preference_by_time: longblob - final_preference_by_wheel: float # cumulative_preference_by_wheel at the end of the block - final_preference_by_time: float # cumulative_preference_by_time at the end of the block + final_preference_by_wheel=null: float # cumulative_preference_by_wheel at the end of the block + final_preference_by_time=null: float # cumulative_preference_by_time at the end of the block """ key_source = BlockAnalysis & BlockAnalysis.Patch & BlockAnalysis.Subject @@ -607,6 +607,7 @@ class BlockSubjectPlots(dj.Computed): definition = """ -> BlockAnalysis --- + cum_wheel_dist_plot: longblob dist_pref_plot: longblob time_pref_plot: longblob """ @@ -622,8 +623,10 @@ def make(self, key): patch_names, subject_names = (BlockSubjectAnalysis.Preference & key).fetch( "patch_name", "subject_name" ) - patch_names = set(patch_names) - subject_names = set(subject_names) + patch_names = np.unique(patch_names) + subject_names = np.unique(subject_names) + + all_thresh_vals = np.concatenate((BlockAnalysis.Patch & key).fetch("patch_threshold")).astype(float) dist_pref_fig, time_pref_fig = go.Figure(), go.Figure() for subj_i, subj in enumerate(subject_names): @@ -631,6 +634,10 @@ def make(self, key): rate, offset, wheel_ts = (BlockAnalysis.Patch & key & {"patch_name": p}).fetch1( "patch_rate", "patch_offset", "wheel_timestamps" ) + patch_thresh, patch_thresh_ts = (BlockAnalysis.Patch & key & {"patch_name": p}).fetch1( + "patch_threshold", "patch_threshold_timestamps" + ) + cum_pref_dist, cum_pref_time = ( BlockSubjectAnalysis.Preference & key & {"patch_name": p, "subject_name": subj} ).fetch1("cumulative_preference_by_wheel", "cumulative_preference_by_time") @@ -638,8 +645,19 @@ def make(self, key): BlockSubjectAnalysis.Patch & key & {"patch_name": p, "subject_name": subj} ).fetch1("pellet_timestamps") - patch_mean = 1 / rate // 100 * 100 - cur_p = f"P{patch_i + 1}" + patch_thresh = patch_thresh[np.searchsorted(patch_thresh_ts, pellet_ts) - 1] + patch_mean = (1 / rate // 100 * 100) + patch_mean_thresh = patch_mean + offset + cum_pel_ct = pd.DataFrame(index=pellet_ts, data={ + "counter": np.arange(1, len(pellet_ts) + 1), + "threshold": patch_thresh.astype(float), + "mean_thresh": patch_mean_thresh, + "patch_label": f"{p} μ: {patch_mean_thresh}", + }) + cum_pel_ct["norm_thresh_val"] = ( + (cum_pel_ct["threshold"] - all_thresh_vals.min()) + / (all_thresh_vals.max() - all_thresh_vals.min()) + ).round(3) for fig, cum_pref in zip([dist_pref_fig, time_pref_fig], [cum_pref_dist, cum_pref_time]): fig.add_trace( @@ -652,12 +670,12 @@ def make(self, key): color=subject_colors[subj_i], dash=patch_markers_linestyles[patch_i], ), - name=f"{subj} - {cur_p}: μ: {patch_mean}", + name=f"{subj} - {p}: μ: {patch_mean}", ) ) # Add markers for each pellet cur_cum_pel_ct = pd.merge_asof( - pd.DataFrame(index=pellet_ts).reset_index(names="time"), + cum_pel_ct.reset_index(names="time"), pd.DataFrame(index=wheel_ts, data={"cum_pref": cum_pref}).reset_index(names="time"), on="time", direction="forward", @@ -671,14 +689,14 @@ def make(self, key): mode="markers", marker=dict( symbol=patch_markers[patch_i], - # color=gen_hex_grad( - # subject_colors[-1], cur_cum_pel_ct["norm_thresh_val"] - # ), + color=gen_hex_grad( + subject_colors[-1], cur_cum_pel_ct["norm_thresh_val"] + ), size=8, ), showlegend=False, - # customdata=np.stack((cur_cum_pel_ct["threshold"],), axis=-1), - # hovertemplate="Threshold: %{customdata[0]:.2f} cm", + customdata=np.stack((cur_cum_pel_ct["threshold"],), axis=-1), + hovertemplate="Threshold: %{customdata[0]:.2f} cm", ) ) From 4072a0f258a14205fd55d449286168ad3708385b Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 4 Jun 2024 13:32:22 -0500 Subject: [PATCH 484/489] Update block_analysis.py --- aeon/dj_pipeline/analysis/block_analysis.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index ce426931..3ceb3ad4 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -602,12 +602,11 @@ def make(self, key): ) -# @schema +@schema class BlockSubjectPlots(dj.Computed): definition = """ -> BlockAnalysis --- - cum_wheel_dist_plot: longblob dist_pref_plot: longblob time_pref_plot: longblob """ From 01c2aab6ebca2d8211460bad06a6b6a9754ba7ce Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 4 Jun 2024 13:33:21 -0500 Subject: [PATCH 485/489] black format --- aeon/dj_pipeline/analysis/block_analysis.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index 3ceb3ad4..dfe7ef72 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -645,17 +645,20 @@ def make(self, key): ).fetch1("pellet_timestamps") patch_thresh = patch_thresh[np.searchsorted(patch_thresh_ts, pellet_ts) - 1] - patch_mean = (1 / rate // 100 * 100) + patch_mean = 1 / rate // 100 * 100 patch_mean_thresh = patch_mean + offset - cum_pel_ct = pd.DataFrame(index=pellet_ts, data={ - "counter": np.arange(1, len(pellet_ts) + 1), - "threshold": patch_thresh.astype(float), - "mean_thresh": patch_mean_thresh, - "patch_label": f"{p} μ: {patch_mean_thresh}", - }) + cum_pel_ct = pd.DataFrame( + index=pellet_ts, + data={ + "counter": np.arange(1, len(pellet_ts) + 1), + "threshold": patch_thresh.astype(float), + "mean_thresh": patch_mean_thresh, + "patch_label": f"{p} μ: {patch_mean_thresh}", + }, + ) cum_pel_ct["norm_thresh_val"] = ( - (cum_pel_ct["threshold"] - all_thresh_vals.min()) - / (all_thresh_vals.max() - all_thresh_vals.min()) + (cum_pel_ct["threshold"] - all_thresh_vals.min()) + / (all_thresh_vals.max() - all_thresh_vals.min()) ).round(3) for fig, cum_pref in zip([dist_pref_fig, time_pref_fig], [cum_pref_dist, cum_pref_time]): From 083912f1480e69ee9e9104573ca0e0d75aa66437 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 4 Jun 2024 13:44:03 -0500 Subject: [PATCH 486/489] Update specsheet.yaml --- .../dj_pipeline/webapps/sciviz/specsheet.yaml | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index dceab461..7a12a3bf 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -94,7 +94,6 @@ SciViz: - type: attribute input: Task Scheduled Time destination: pyrat_task_scheduled_time - ExperimentEntry: route: /experiment_entry grids: @@ -215,7 +214,6 @@ SciViz: - type: attribute input: Experiment Type destination: experiment_type - Subjects: route: /subjects grids: @@ -702,6 +700,36 @@ SciViz: aeon_analysis = aeon_block_analysis return {'query': aeon_block_analysis.BlockPlots(), 'fetch_args': ['cumulative_pellet_plot']} + comp7: + route: /dist_pref_plot + x: 0 + y: 2.1 + height: 0.3 + width: 0.6 + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_block_analysis): + aeon_analysis = aeon_block_analysis + return {'query': aeon_block_analysis.BlockSubjectPlots(), 'fetch_args': ['dist_pref_plot']} + + comp8: + route: /time_pref_plot + x: 0 + y: 2.4 + height: 0.3 + width: 0.6 + type: plot:plotly:stored_json + restriction: > + def restriction(**kwargs): + return dict(**kwargs) + dj_query: > + def dj_query(aeon_block_analysis): + aeon_analysis = aeon_block_analysis + return {'query': aeon_block_analysis.BlockSubjectPlots(), 'fetch_args': ['time_pref_plot']} + VideoStream: route: /videostream grids: From 6a89ec181cbab21b114987fb9d197b364a9aef19 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 4 Jun 2024 13:50:26 -0500 Subject: [PATCH 487/489] feat: update comment, add to worker --- aeon/dj_pipeline/analysis/block_analysis.py | 6 +++--- aeon/dj_pipeline/populate/worker.py | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index dfe7ef72..7fac7779 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -605,10 +605,10 @@ def make(self, key): @schema class BlockSubjectPlots(dj.Computed): definition = """ - -> BlockAnalysis + -> BlockSubjectAnalysis --- - dist_pref_plot: longblob - time_pref_plot: longblob + dist_pref_plot: longblob # Cumulative Patch Preference by Wheel Distance - per subject per patch + time_pref_plot: longblob # Cumulative Patch Preference by Time - per subject per patch """ def make(self, key): diff --git a/aeon/dj_pipeline/populate/worker.py b/aeon/dj_pipeline/populate/worker.py index 3153ffe6..cbcdbb57 100644 --- a/aeon/dj_pipeline/populate/worker.py +++ b/aeon/dj_pipeline/populate/worker.py @@ -108,7 +108,8 @@ def ingest_environment_visits(): analysis_worker(block_analysis.BlockAnalysis, max_calls=6) analysis_worker(block_analysis.BlockPlots, max_calls=6) - +analysis_worker(block_analysis.BlockSubjectAnalysis, max_calls=6) +analysis_worker(block_analysis.BlockSubjectPlots, max_calls=6) def get_workflow_operation_overview(): from datajoint_utilities.dj_worker.utils import get_workflow_operation_overview From 2d12bf617622b0db096204c3658361df71dd79db Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Tue, 4 Jun 2024 15:38:34 -0500 Subject: [PATCH 488/489] fix: skip over 0 pellet patch --- aeon/dj_pipeline/analysis/block_analysis.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/aeon/dj_pipeline/analysis/block_analysis.py b/aeon/dj_pipeline/analysis/block_analysis.py index 7fac7779..43d71f84 100644 --- a/aeon/dj_pipeline/analysis/block_analysis.py +++ b/aeon/dj_pipeline/analysis/block_analysis.py @@ -644,6 +644,9 @@ def make(self, key): BlockSubjectAnalysis.Patch & key & {"patch_name": p, "subject_name": subj} ).fetch1("pellet_timestamps") + if not len(pellet_ts): + continue + patch_thresh = patch_thresh[np.searchsorted(patch_thresh_ts, pellet_ts) - 1] patch_mean = 1 / rate // 100 * 100 patch_mean_thresh = patch_mean + offset From 1eba54aa0cd54fcc3c6761226126da5e3d4268b2 Mon Sep 17 00:00:00 2001 From: Thinh Nguyen Date: Wed, 5 Jun 2024 10:48:21 -0500 Subject: [PATCH 489/489] update sciviz plots layout --- aeon/dj_pipeline/webapps/sciviz/specsheet.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml index 7a12a3bf..4a58076a 100644 --- a/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml +++ b/aeon/dj_pipeline/webapps/sciviz/specsheet.yaml @@ -703,8 +703,8 @@ SciViz: comp7: route: /dist_pref_plot x: 0 - y: 2.1 - height: 0.3 + y: 2.5 + height: 0.4 width: 0.6 type: plot:plotly:stored_json restriction: > @@ -718,8 +718,8 @@ SciViz: comp8: route: /time_pref_plot x: 0 - y: 2.4 - height: 0.3 + y: 2.9 + height: 0.4 width: 0.6 type: plot:plotly:stored_json restriction: >