Skip to content

Commit e19296a

Browse files
authored
Merge pull request #1746 from AllenNeuralDynamics/develop
[production merge] 2026-05-29
2 parents d3e7141 + 76f6e7f commit e19296a

3 files changed

Lines changed: 87 additions & 120 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ dependencies = [
2626
"pyOSC3@git+https://github.com/glopesdev/pyosc3.git@master",
2727
"newscale@git+https://github.com/AllenNeuralDynamics/python-newscale@axes-on-target",
2828
"aind-auto-train@git+https://github.com/AllenNeuralDynamics/aind-foraging-behavior-bonsai-automatic-training.git@main",
29-
"aind-slims-api@git+https://github.com/AllenNeuralDynamics/aind-slims-api@main",
3029
"aind-dynamic-foraging-models >= 0.12.2",
3130
"aind-dynamic-foraging-basic-analysis >= 0.3.34",
3231
"aind-behavior-services >=0.8, <0.9",
@@ -48,6 +47,7 @@ dependencies = [
4847
"winkerberos; sys_platform == 'win32'",
4948
"ldap3; sys_platform == 'win32'",
5049
"msal; sys_platform == 'win32'",
50+
"log-schema==0.2.10.dev1"
5151
]
5252

5353
[project.optional-dependencies]

src/foraging_gui/Foraging.py

Lines changed: 84 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,11 @@
2828
import requests
2929
import serial
3030
import yaml
31+
32+
import log_schema
3133
from aind_auto_train.schema.task import TrainingStage
3234
from aind_behavior_services.session import AindBehaviorSessionModel
3335
from aind_data_schema.core.session import Session
34-
from aind_slims_api import SlimsClient, models
3536
from matplotlib.backends.backend_qt5agg import (
3637
NavigationToolbar2QT as NavigationToolbar,
3738
)
@@ -238,9 +239,6 @@ def __init__(self, parent=None, box_number=1, start_bonsai_ide=True):
238239
# Connect to Bonsai
239240
self._InitializeBonsai()
240241

241-
# connect to Slims
242-
self._ConnectSlims()
243-
244242
# Set up threads
245243
self.threadpool = QThreadPool() # get animal response
246244
self.threadpool2 = QThreadPool() # get animal lick
@@ -351,6 +349,9 @@ def __init__(self, parent=None, box_number=1, start_bonsai_ide=True):
351349

352350
# load the rig metadata
353351
self._load_rig_metadata()
352+
353+
# setup life-cycle logger
354+
self.lifecycle_logger = self.setup_lifecycle_logger()
354355

355356
# Initializes session log handler as None
356357
self.session_log_handler = None
@@ -365,6 +366,27 @@ def __init__(self, parent=None, box_number=1, start_bonsai_ide=True):
365366
self._ReconnectBonsai()
366367
logging.info("Start up complete")
367368

369+
def setup_lifecycle_logger(self) -> logging.Logger:
370+
371+
"""
372+
Creates logger for start, stop, and failure events with formatter adhering to aind log standards.
373+
"""
374+
375+
# Ensure the directory exists
376+
os.makedirs(Path(self.Settings["lifecycle_log_dir"]), exist_ok=True)
377+
378+
lifecycle_logger = logging.getLogger("lifecycle")
379+
lifecycle_logger.setLevel(logging.INFO)
380+
381+
timestamp = datetime.now().strftime("%Y%m%dT%H%M%SZ")
382+
filename = f"lifecycle_log_{timestamp}.jsonl"
383+
file_handler = logging.FileHandler(os.path.join(self.Settings["lifecycle_log_dir"], filename), encoding="utf-8")
384+
file_handler.setLevel(logging.INFO)
385+
file_handler.setFormatter(log_schema.DefaultFormatter())
386+
lifecycle_logger.addHandler(file_handler)
387+
388+
return lifecycle_logger
389+
368390
def _load_rig_metadata(self):
369391
"""Load the latest rig metadata"""
370392

@@ -1594,6 +1616,7 @@ def _restartlogging(self, log_folder=None,start_from_camera=False):
15941616
self.Save.setStyleSheet(
15951617
"color: white;background-color : mediumorchid"
15961618
)
1619+
15971620
else:
15981621
# temporary logging
15991622
loggingtype = 1
@@ -1961,11 +1984,17 @@ def _GetSettings(self):
19611984
"aind_watchdog_service",
19621985
"manifest",
19631986
),
1987+
"lifecycle_log_dir": os.path.join(
1988+
os.path.expanduser("~"),
1989+
"Documents",
1990+
"lifecycle_logs",
1991+
),
19641992
"transfer_service_job_type": "dynamic_foraging_compression",
19651993
"auto_engage": True,
19661994
"clear_figure_after_save": True,
19671995
"add_default_project_name": True,
19681996
"check_schedule": False,
1997+
"waterlog_exe_path": "C://Program Files/AIBS_MPE/waterlog/waterlog.exe",
19691998
}
19701999

19712000
# Try to load the ForagingSettings.json file
@@ -2097,71 +2126,8 @@ def _GetSettings(self):
20972126
]
20982127
self.rig_name = "{}".format(self.current_box)
20992128

2100-
def _ConnectSlims(self):
2101-
"""
2102-
Connect to Slims
2103-
"""
2104-
try:
2105-
logging.info("Attempting to connect to Slims")
2106-
self.slims_client = SlimsClient(
2107-
username=os.environ["SLIMS_USERNAME"],
2108-
password=os.environ["SLIMS_PASSWORD"],
2109-
)
2110-
except KeyError as e:
2111-
raise KeyError(
2112-
"SLIMS_USERNAME and SLIMS_PASSWORD do not exist as "
2113-
f"environment variables on machine. Please add. {e}"
2114-
)
2115-
2116-
try:
2117-
self.slims_client.fetch_model(
2118-
models.SlimsMouseContent, barcode="00000000"
2119-
)
2120-
except Exception as e:
2121-
if "Status 401 – Unauthorized" in str(
2122-
e
2123-
): # catch error if username and password are incorrect
2124-
raise Exception(
2125-
f"Exception trying to read from Slims: {e}.\n"
2126-
f" Please check credentials:\n"
2127-
f"Username: {os.environ['SLIMS_USERNAME']}\n"
2128-
f"Password: {os.environ['SLIMS_PASSWORD']}"
2129-
)
2130-
elif "No record found" not in str(
2131-
e
2132-
): # bypass if mouse doesn't exist
2133-
raise Exception(f"Exception trying to read from Slims: {e}.\n")
2134-
logging.info("Successfully connected to Slims")
2135-
2136-
def _AddWaterLogResult(self, session: Session):
2137-
"""
2138-
Add WaterLogResult to slims based on current state of gui
2139-
2140-
:param session: Session object to pull water information from
2141-
2142-
"""
2143-
2144-
try: # try and find mouse
2145-
logging.info(
2146-
f"Attempting to fetch mouse {session.subject_id} from Slims"
2147-
)
2148-
mouse = self.slims_client.fetch_model(
2149-
models.SlimsMouseContent, barcode=session.subject_id
2150-
)
2151-
except Exception as e:
2152-
if "No record found" in str(
2153-
e
2154-
): # if no mouse found or validation errors on mouse
2155-
logging.warning(
2156-
f'"No record found" error while trying to fetch mouse {session.subject_id}. '
2157-
f"Will not log water."
2158-
)
2159-
return
2160-
else:
2161-
logging.error(
2162-
f"While fetching mouse {session.subject_id} model, unexpected error occurred: {e}"
2163-
)
2164-
raise e
2129+
def _AddWaterlogResult(self, session: Session):
2130+
"""Send weight/water information to databases via waterlog app cli"""
21652131

21662132
# extract water information
21672133
logging.info("Extracting water information from first stimulus epoch")
@@ -2171,53 +2137,41 @@ def _AddWaterLogResult(self, session: Session):
21712137
for k, v in water_json
21722138
}
21732139

2174-
# extract software information
2140+
# extract software information to send to waterlog once it can accept it
21752141
logging.info("Extracting software information from first data stream")
21762142
software = session.stimulus_epochs[0].software[0]
2143+
# Access sw name/version with (software.url, software.version)
2144+
2145+
waterlog_args = [
2146+
self.Settings['waterlog_exe_path'],
2147+
'--username',
2148+
self.behavior_session_model.experimenter[0],
2149+
'--mouse-id',
2150+
session.subject_id,
2151+
'--mouse-weight',
2152+
session.animal_weight_post,
2153+
'--comment',
2154+
session.notes,
2155+
'--earned-water',
2156+
water["water_in_session_total"],
2157+
'--water-supplement-ml',
2158+
water["water_after_session"],
2159+
'--water-supplement-delivered',
2160+
]
21772161

2178-
# create model
2179-
logging.info(
2180-
"Creating SlimsWaterlogResult based on session information."
2181-
)
2182-
print(water)
2183-
model = models.SlimsWaterlogResult(
2184-
mouse_pk=mouse.pk,
2185-
date=datetime.now(),
2186-
weight_g=session.animal_weight_post,
2187-
operator=self.behavior_session_model.experimenter[0],
2188-
water_earned_ml=water["water_in_session_total"],
2189-
water_supplement_delivered_ml=water["water_after_session"],
2190-
water_supplement_recommended_ml=None,
2191-
total_water_ml=water["water_in_session_total"]+water["water_after_session"],
2192-
comments=session.notes,
2193-
workstation=session.rig_id,
2194-
sw_source=software.url,
2195-
sw_version=software.version,
2196-
test_pk=self.slims_client.fetch_pk(
2197-
"Test", test_name="test_waterlog"
2198-
),
2199-
)
2162+
logging.info("Sending water info to waterlog")
2163+
process = subprocess.run([str(arg) for arg in waterlog_args])
22002164

2201-
# check if mouse already has waterlog for at session time and if, so update model
2202-
logging.info(
2203-
f"Fetching previous waterlog for mouse {session.subject_id}"
2204-
)
2205-
waterlog = self.slims_client.fetch_models(
2206-
models.SlimsWaterlogResult, mouse_pk=mouse.pk, start=0, end=1
2207-
)
2208-
if waterlog != [] and waterlog[0].date.strftime(
2209-
"%Y-%m-%d %H:%M:%S"
2210-
) == session.session_start_time.astimezone(timezone.utc).strftime(
2211-
"%Y-%m-%d %H:%M:%S"
2212-
):
2213-
logging.info(
2214-
"Waterlog information already exists for this session. Updating waterlog in Slims."
2215-
)
2216-
model.pk = waterlog[0].pk
2217-
self.slims_client.update_model(model=model)
2218-
else:
2219-
logging.info("Adding waterlog to Slims.")
2220-
self.slims_client.add_model(model)
2165+
QMessageBox.information(self, "Waterlog", "Go to waterlog app to submit water information.")
2166+
2167+
try:
2168+
process.check_returncode()
2169+
except Exception:
2170+
logging.warning(
2171+
f"Waterlog data for mouse {self.behavior_session_model.subject} cannot be sent to waterlog exe"
2172+
f", message: {process.stdout}, {process.stderr}",
2173+
exc_info=True,
2174+
)
22212175

22222176
def _InitializeBonsai(self):
22232177
"""
@@ -3937,7 +3891,7 @@ def _Save(self, ForceSave=0, SaveAs=0, SaveContinue=0, BackupSave=0):
39373891
if self.CreateNewFolder == 1:
39383892
self._GetSaveFolder()
39393893
self.CreateNewFolder = 0
3940-
3894+
39413895
if not os.path.exists(os.path.dirname(self.SaveFileJson)):
39423896
os.makedirs(os.path.dirname(self.SaveFileJson))
39433897
logging.info(
@@ -4171,7 +4125,7 @@ def _Save(self, ForceSave=0, SaveAs=0, SaveContinue=0, BackupSave=0):
41714125
# save random reward parameters
41724126
Obj['random_reward_par']=self.RandomReward_dialog.random_reward_par
41734127

4174-
# generate the metadata file and update slims
4128+
# generate the metadata file and update waterlog
41754129
try:
41764130
# save the metadata collected in the metadata dialogue
41774131
self.Metadata_dialog._save_metadata_dialog_parameters()
@@ -4218,13 +4172,19 @@ def _Save(self, ForceSave=0, SaveAs=0, SaveContinue=0, BackupSave=0):
42184172
]
42194173
and session is not None
42204174
):
4221-
self._AddWaterLogResult(session)
4175+
self._AddWaterlogResult(session)
42224176
elif self.BaseWeight.text() == "" or self.WeightAfter.text() == "":
4223-
logging.warning(f"Waterlog for mouse {self.behavior_session_model.subject} cannot be added to slims"
4177+
logging.warning(f"Waterlog for mouse {self.behavior_session_model.subject} cannot be added to database"
42244178
f" due do unrecorded weight information.")
42254179
elif session is None:
4226-
logging.warning(f"Waterlog for mouse {self.behavior_session_model.subject} cannot be added to slims"
4180+
logging.warning(f"Waterlog for mouse {self.behavior_session_model.subject} cannot be added to database"
42274181
f" due do metadata generation failure.")
4182+
4183+
# add complete log to lifecycle
4184+
self.lifecycle_logger.info("Session ended.", extra={"subject_id": self.behavior_session_model.subject,
4185+
"acquisition_name": self.behavior_session_model.session_name,
4186+
"event_type": "stage_complete"})
4187+
42284188
except Exception as e:
42294189
logging.warning(
42304190
"Meta data is not saved!",
@@ -6186,6 +6146,10 @@ def _Start(self):
61866146
# Start logging if the formal logging is not started
61876147
if self.logging_type != 0:
61886148
self.Ot_log_folder = self._restartlogging()
6149+
# Need to log start event after session_name has been set in_restartlogging
6150+
self.lifecycle_logger.info("Session started.", extra={"subject_id": self.behavior_session_model.subject,
6151+
"acquisition_name": self.behavior_session_model.session_name,
6152+
"event_type": "stage_start"})
61896153
except Exception as e:
61906154
if "ConnectionAbortedError" in str(e):
61916155
logging.info("lost bonsai connection: restartlogging()")
@@ -6641,6 +6605,9 @@ def _StartTrialLoop(self, GeneratedTrials, worker1, worker_save):
66416605
self.ANewTrial = 1
66426606
self.Start.setChecked(False)
66436607
self.Start.setStyleSheet("background-color : none")
6608+
self.lifecycle_logger.info("Session failed.", extra={"subject_id": self.behavior_session_model.subject,
6609+
"acquisition_name": self.behavior_session_model.session_name,
6610+
"event_type": "stage_failure"})
66446611
break
66456612
# receive licks and update figures
66466613
if self.actionDrawing_after_stopping.isChecked() == False:
@@ -7432,7 +7399,6 @@ def setup_loki_logging(box_number):
74327399
handler.setLevel(logging.INFO)
74337400
logger.root.addHandler(handler)
74347401

7435-
74367402
def start_gui_log_file(box_number):
74377403
"""
74387404
Starts a log file for the gui.

src/foraging_gui/settings_model.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from typing import Literal, Optional
22

33
from pydantic import BaseModel, Field
4-
4+
from pathlib import Path
55

66
class BonsaiSettingsModel(BaseModel):
77
"""
@@ -182,6 +182,7 @@ class DFTSettingsModel(BaseModel):
182182
save_each_trial: bool
183183
AutomaticUpload: bool
184184
manifest_flag_dir: str
185+
lifecycle_log_dir: Path
185186
transfer_service_job_type: str
186187
auto_engage: bool
187188
clear_figure_after_save: bool

0 commit comments

Comments
 (0)