diff --git a/pyproject.toml b/pyproject.toml index 830550e4..69c8e988 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ server = [ 'fastapi==0.115.0', 'uvicorn[standard]==0.31.0', 'python-dateutil', - 'aind-slims-api==0.1.15', + 'aind-slims-api==0.1.17', 'azure-identity==1.15.0' ] diff --git a/src/aind_metadata_service/response_handler.py b/src/aind_metadata_service/response_handler.py index 5c78595f..d8a2fbfb 100644 --- a/src/aind_metadata_service/response_handler.py +++ b/src/aind_metadata_service/response_handler.py @@ -13,6 +13,7 @@ ViralMaterial, ) from aind_data_schema.core.rig import Rig +from aind_data_schema.core.session import Session from aind_data_schema.core.subject import Subject from aind_metadata_mapper.core import JobResponse from fastapi import Response @@ -35,6 +36,7 @@ ProtocolInformation, Instrument, Rig, + Session, ) @@ -89,37 +91,42 @@ def no_data_found_error_response(cls): message="No Data Found.", ) + @staticmethod + def _validate_model(model) -> Optional[str]: + """Helper method to validate a model and return validation errors.""" + validation_error = None + try: + model.__class__.model_validate(model.model_dump()) + except ValidationError as e: + validation_error = repr(e) + except (AttributeError, ValueError, KeyError) as oe: + validation_error = repr(oe) + return validation_error + def _map_data_response( # noqa: C901 self, validate: bool = True ) -> Union[Response, JSONResponse]: """Map ModelResponse with StatusCodes.DB_RESPONDED to a JSONResponse. Perform validations, bypasses validation if flag is set to False.""" + if len(self.aind_models) == 0: status_code = StatusCodes.NO_DATA_FOUND.value content_data = None message = "No Data Found." + elif len(self.aind_models) == 1: aind_model = self.aind_models[0] content_data = jsonable_encoder( json.loads(aind_model.model_dump_json()) ) if validate: - validation_error = None - try: - aind_model.__class__.model_validate( - aind_model.model_dump() - ) - except ValidationError as e: - validation_error = repr(e) - except (AttributeError, ValueError, KeyError) as oe: - validation_error = repr(oe) + validation_error = self._validate_model(aind_model) if validation_error: status_code = StatusCodes.INVALID_DATA.value message = f"Validation Errors: {validation_error}" else: status_code = StatusCodes.VALID_DATA.value message = "Valid Model." - # if validate flag is False else: status_code = StatusCodes.UNPROCESSIBLE_ENTITY.value message = ( @@ -132,6 +139,7 @@ def _map_data_response( # noqa: C901 "There was an error retrieving records from one or more of" " the databases." ) + else: status_code = StatusCodes.MULTIPLE_RESPONSES.value message = "Multiple Items Found." @@ -139,6 +147,25 @@ def _map_data_response( # noqa: C901 jsonable_encoder(json.loads(model.model_dump_json())) for model in self.aind_models ] + + if validate: + # Validate each model and accumulate errors + validation_errors = [] + for model in self.aind_models: + error = self._validate_model(model) + print(error) + if error: + validation_errors.append(error) + + if validation_errors: + message += ( + f" Validation Errors: {', '.join(validation_errors)}" + ) + else: + message += " All Models Valid." + else: + message += " Models have not been validated." + return JSONResponse( status_code=status_code, content=({"message": message, "data": content_data}), diff --git a/src/aind_metadata_service/server.py b/src/aind_metadata_service/server.py index 49a9a20c..3f529956 100644 --- a/src/aind_metadata_service/server.py +++ b/src/aind_metadata_service/server.py @@ -22,6 +22,7 @@ SharePointClient, SharepointSettings, ) +from aind_metadata_service.slims.client import SlimsHandler, SlimsSettings from aind_metadata_service.smartsheet.client import ( SmartSheetClient, SmartsheetSettings, @@ -36,7 +37,6 @@ ) from aind_metadata_service.tars.client import AzureSettings, TarsClient from aind_metadata_service.tars.mapping import TarsResponseHandler -from aind_metadata_service.slims.client import SlimsSettings, SlimsHandler SMARTSHEET_FUNDING_ID = os.getenv("SMARTSHEET_FUNDING_ID") SMARTSHEET_FUNDING_TOKEN = os.getenv("SMARTSHEET_API_TOKEN") @@ -122,6 +122,15 @@ async def retrieve_rig(rig_id): return model_response.map_to_json_response(validate=False) +@app.get("/ecephys_sessions_by_subject/{subject_id}") +async def retrieve_sessions(subject_id): + """Retrieves sessions from slims""" + model_response = await run_in_threadpool( + slims_client.get_sessions_model_response, subject_id=subject_id + ) + return model_response.map_to_json_response() + + @app.get("/protocols/{protocol_name}") async def retrieve_protocols( protocol_name, diff --git a/src/aind_metadata_service/slims/client.py b/src/aind_metadata_service/slims/client.py index c0736cb9..7e04d6eb 100644 --- a/src/aind_metadata_service/slims/client.py +++ b/src/aind_metadata_service/slims/client.py @@ -4,6 +4,10 @@ from aind_data_schema.core.instrument import Instrument from aind_data_schema.core.rig import Rig +from aind_slims_api import SlimsClient +from aind_slims_api.exceptions import SlimsRecordNotFound +from aind_slims_api.models.instrument import SlimsInstrumentRdrc +from aind_slims_api.operations.ecephys_session import fetch_ecephys_sessions from pydantic import Extra, Field, SecretStr from pydantic_settings import BaseSettings from requests.models import Response @@ -11,9 +15,7 @@ from aind_metadata_service.client import StatusCodes from aind_metadata_service.response_handler import ModelResponse -from aind_slims_api.exceptions import SlimsRecordNotFound -from aind_slims_api import SlimsClient -from aind_slims_api.models.instrument import SlimsInstrumentRdrc +from aind_metadata_service.slims.mapping import SlimsSessionMapper class SlimsSettings(BaseSettings): @@ -114,3 +116,26 @@ def get_rig_model_response(self, input_id) -> ModelResponse: except Exception as e: logging.error(repr(e)) return ModelResponse.internal_server_error_response() + + def get_sessions_model_response(self, subject_id: str) -> ModelResponse: + """ + Fetches sessions for a given subject ID from SLIMS. + """ + try: + sessions = fetch_ecephys_sessions( + subject_id=subject_id, client=self.client + ) + if sessions: + mapper = SlimsSessionMapper() + mapped_sessions = mapper.map_sessions(sessions, subject_id) + return ModelResponse( + aind_models=mapped_sessions, + status_code=StatusCodes.DB_RESPONDED, + ) + else: + return ModelResponse.no_data_found_error_response() + except SlimsRecordNotFound: + return ModelResponse.no_data_found_error_response() + except Exception as e: + logging.error(repr(e)) + return ModelResponse.internal_server_error_response() diff --git a/src/aind_metadata_service/slims/mapping.py b/src/aind_metadata_service/slims/mapping.py new file mode 100644 index 00000000..53ae6a67 --- /dev/null +++ b/src/aind_metadata_service/slims/mapping.py @@ -0,0 +1,429 @@ +""" +Module to map data from SLIMS to the Session model. +""" + +from enum import Enum +from typing import List, Optional, Tuple + +from aind_data_schema.components.coordinates import CcfCoords, Coordinates3d +from aind_data_schema.components.devices import SpoutSide +from aind_data_schema.core.procedures import CoordinateReferenceLocation +from aind_data_schema.core.session import ( + DomeModule, + LaserConfig, + LightEmittingDiodeConfig, + ManipulatorModule, + RewardDeliveryConfig, + RewardSolution, + RewardSpoutConfig, + Session, + SpeakerConfig, + StimulusEpoch, + StimulusModality, + Stream, +) +from aind_data_schema_models.modalities import Modality +from aind_slims_api.models.ecephys_session import ( + SlimsBrainStructureRdrc, + SlimsRewardDeliveryRdrc, + SlimsRewardSpoutsRdrc, + SlimsStimulusEpochsResult, +) +from aind_slims_api.operations.ecephys_session import ( + EcephysSession as SlimsEcephysSession, +) +from aind_slims_api.operations.ecephys_session import ( + SlimsRewardDeliveryInfo, + SlimsStream, + SlimsStreamModule, +) + + +class SlimsStreamModalities(Enum): + """Enum class for stream modalities in SLIMS.""" + + ECEPHYS = "Ecephys" + BEHAVIOR = "Behavior" + BEHAVIOR_VIDEOS = "Behavior videos" + CONFOCAL = "Confocal" + ELECTROMYOGRAPHY = "Electromyography" + FMOST = "Fmost" + ICEPHYS = "Icephys" + FIB = "Fib" + ISI = "Isi" + MERFISH = "Merfish" + MRI = "Mri" + POPHYS = "POphys" + SLAP = "Slap" + SPIM = "Spim" + + +class SlimsRewardSolution(str, Enum): + """Enum class for reward solution in SLIMS.""" + + WATER = "Water" + OTHER = "Other, (if Other, specify below)" + + +class SlimsSessionMapper: + """Client for interacting with SLIMS and mapping session data.""" + + def map_sessions( + self, sessions: List[SlimsEcephysSession], subject_id: str + ) -> List[Session]: + """Maps SLIMS sessions to AIND session models.""" + return [ + self._map_session(session, subject_id=subject_id) + for session in sessions + ] + + def _map_session( + self, session: SlimsEcephysSession, subject_id: str + ) -> Session: + """Map a single SLIMS session to the AIND session model.""" + session_type = getattr(session.session_group, "session_type", None) + mouse_platform_name = getattr( + session.session_group, "mouse_platform_name", None + ) + active_mouse_platform = getattr( + session.session_group, "active_mouse_platform", False + ) + + rig_id = getattr(session.session_instrument, "name", None) + + session_result = ( + session.session_result + if hasattr(session, "session_result") + else None + ) + + animal_weight_prior = getattr(session_result, "weight_prior_g", None) + animal_weight_post = getattr(session_result, "weight_post_g", None) + reward_consumed_total = getattr( + session_result, "reward_consumed_vol", None + ) + + streams = [ + self._map_stream(stream) + for stream in getattr(session, "streams", []) + ] + stimulus_epochs = [ + self._map_stimulus_epoch(epoch) + for epoch in getattr(session, "stimulus_epochs", []) + ] + reward_delivery_info = ( + self._map_reward_delivery(getattr(session, "reward_delivery")) + if getattr(session, "reward_delivery") + else None + ) + + # model_construct because start and end times are not stored in SLIMS + return Session.model_construct( + rig_id=rig_id, + subject_id=subject_id, + session_type=session_type, + mouse_platform_name=mouse_platform_name, + active_mouse_platform=active_mouse_platform, + animal_weight_prior=animal_weight_prior, + animal_weight_post=animal_weight_post, + data_streams=streams, + stimulus_epochs=stimulus_epochs, + reward_delivery=reward_delivery_info, + reward_consumed_total=reward_consumed_total, + ) + + def _map_reward_delivery( + self, reward_info: SlimsRewardDeliveryInfo + ) -> RewardDeliveryConfig: + """Map reward info from SLIMS to RewardDeliveryConfig model.""" + + slims_reward_delivery = getattr(reward_info, "reward_delivery", None) + slims_reward_spouts = getattr(reward_info, "reward_spouts", None) + + reward_solution, notes = ( + self._map_reward_solution(slims_reward_delivery) + if slims_reward_delivery + else (None, None) + ) + + reward_spouts = ( + [self._map_reward_spouts(slims_reward_spouts)] + if slims_reward_spouts + else [] + ) + + return RewardDeliveryConfig( + reward_solution=reward_solution, + reward_spouts=reward_spouts, + notes=notes, + ) + + @staticmethod + def _map_reward_solution( + reward_delivery: SlimsRewardDeliveryRdrc, + ) -> tuple[Optional[RewardSolution], Optional[str]]: + """Map reward solution and notes.""" + + slims_reward_solution = getattr( + reward_delivery, "reward_solution", None + ) + + if slims_reward_solution == SlimsRewardSolution.WATER: + return RewardSolution.WATER, None + if slims_reward_solution == SlimsRewardSolution.OTHER: + notes = getattr(reward_delivery, "other_reward_solution", None) + return RewardSolution.OTHER, notes + + return None, None + + def _map_reward_spouts( + self, reward_spout: SlimsRewardSpoutsRdrc + ) -> RewardSpoutConfig: + """Map reward spout info to RewardSpoutConfig model""" + + spout_side = getattr(reward_spout, "spout_side", None) + return RewardSpoutConfig.model_construct( + side=self._map_spout_side(spout_side) if spout_side else None, + starting_position=getattr(reward_spout, "starting_position", None), + variable_position=getattr(reward_spout, "variable_position", None), + ) + + @staticmethod + def _map_spout_side(spout_side: str) -> SpoutSide: + """Maps SLIMS input spout side to SpoutSide""" + + spout_side_lower = spout_side.lower() + if "left" in spout_side_lower: + return SpoutSide.LEFT + if "right" in spout_side_lower: + return SpoutSide.RIGHT + if "center" in spout_side_lower: + return SpoutSide.CENTER + + return SpoutSide.OTHER + + def _map_stimulus_epoch( + self, stimulus_epoch: SlimsStimulusEpochsResult + ) -> StimulusEpoch: + """Maps stimulus epoch data from SLIMS to StimulusEpoch model""" + stimulus_name = getattr(stimulus_epoch, "stimulus_name", None) + stimulus_device_names = getattr( + stimulus_epoch, "stimulus_device_names", None + ) + stimulus_modalities = [ + StimulusModality(modality) + for modality in getattr(stimulus_epoch, "stimulus_modalities", []) + ] + reward_consumed_during_epoch = getattr( + stimulus_epoch, "reward_consumed_during_epoch", None + ) + speaker_config = self._map_speaker_config( + speaker_name=getattr(stimulus_epoch, "speaker_name"), + speaker_volume=getattr(stimulus_epoch, "speaker_volume"), + ) + light_source_config = self._map_light_source_config(stimulus_epoch) + # Using model construct because missing start and end times + return StimulusEpoch.model_construct( + stimulus_name=stimulus_name, + stimulus_device_names=stimulus_device_names, + stimulus_modalities=stimulus_modalities, + reward_consumed_during_epoch=reward_consumed_during_epoch, + speaker_config=speaker_config, + light_source_config=light_source_config, + ) + + @staticmethod + def _map_light_source_config( + stimulus_epoch, + ) -> List[LaserConfig | LightEmittingDiodeConfig]: + """Maps light source data from SLIMS to list of configs""" + light_sources = [] + if getattr(stimulus_epoch, "laser_name", None) and getattr( + stimulus_epoch, "laser_wavelength", None + ): + laser = LaserConfig( + name=getattr(stimulus_epoch, "laser_name", None), + wavelength=getattr(stimulus_epoch, "laser_wavelength", None), + excitation_power=getattr( + stimulus_epoch, "laser_excitation_power", None + ), + ) + light_sources.append(laser) + if getattr(stimulus_epoch, "led_name", None): + led = LightEmittingDiodeConfig( + name=getattr(stimulus_epoch, "led_name"), + excitation_power=getattr( + stimulus_epoch, "led_excitation_power_mw", None + ), + ) + light_sources.append(led) + return light_sources + + @staticmethod + def _map_speaker_config( + speaker_name: Optional[str], speaker_volume: Optional[float] + ) -> SpeakerConfig: + """Maps speaker config""" + return ( + SpeakerConfig(name=speaker_name, volume=speaker_volume) + if speaker_name + else None + ) + + def _map_stream(self, stream: SlimsStream) -> Stream: + """Map stream data from SLIMS to the Stream model.""" + stream_modalities = [ + self._map_stream_modality(modality) + for modality in getattr(stream, "stream_modalities", []) + ] + daq_names = getattr(stream, "daq_names", []) + camera_names = getattr(stream, "camera_names", []) + + stick_microscopes, ephys_modules = self._map_stream_modules( + stream.stream_modules + ) + + return Stream.model_construct( + daq_names=daq_names, + camera_names=camera_names, + stream_modalities=stream_modalities, + stick_microscopes=stick_microscopes, + ephys_modules=ephys_modules, + ) + + def _map_stream_modules( + self, stream_modules: Optional[List[SlimsStreamModule]] + ) -> Tuple[List[DomeModule], List[ManipulatorModule]]: + """ + Map stream modules to either stick microscopes or manipulators. + Parameters + ---------- + stream_modules: List of stream modules from SLIMS + Returns + ------- + Tuple containing lists of stick microscopes and ephys modules + """ + stick_microscopes, ephys_modules = [], [] + + for stream_module in stream_modules: + if self._is_manipulator_module(stream_module): + ephys_modules.append( + self._map_manipulator_module(stream_module) + ) + else: + stick_microscopes.append(self._map_dome_module(stream_module)) + + return stick_microscopes, ephys_modules + + @staticmethod + def _is_manipulator_module(stream_module: SlimsStreamModule) -> bool: + """ + Checks if stream module contains fields for a manipulator module. + """ + return ( + getattr(stream_module, "primary_targeted_structure", None) + or getattr(stream_module, "ccf_coordinate_ap", None) + or getattr(stream_module, "manipulator_x", None) + or getattr(stream_module, "bregma_target_ap", None) + ) + + def _map_manipulator_module( + self, stream_module: SlimsStreamModule + ) -> ManipulatorModule: + """ + Map a stream module to a ManipulatorModule instance. + """ + primary_targeted_structure = self._map_targeted_structure( + getattr(stream_module, "primary_targeted_structure", None) + ) + other_targeted_structures = [ + self._map_targeted_structure(structure_name) + for structure_name in getattr( + stream_module, "secondary_targeted_structures", [] + ) + ] + return ManipulatorModule.model_construct( + assembly_name=getattr(stream_module, "assembly_name", None), + arc_angle=getattr(stream_module, "arc_angle", None), + module_angle=getattr(stream_module, "module_angle", None), + rotation_angle=getattr(stream_module, "rotation_angle", None), + coordinate_transform=getattr( + stream_module, "coordinate_transform", None + ), + primary_targeted_structure=primary_targeted_structure, + other_targeted_structures=other_targeted_structures, + targetted_ccf_coordinates=self._map_ccf_coords( + ap=getattr(stream_module, "ccf_coordinate_ap", None), + ml=getattr(stream_module, "ccf_coordinate_ml", None), + dv=getattr(stream_module, "ccf_coordinate_dv", None), + ), + manipulator_coordinates=self._map_3d_coords( + x=getattr(stream_module, "manipulator_x", None), + y=getattr(stream_module, "manipulator_y", None), + z=getattr(stream_module, "manipulator_z", None), + ), + # TODO: map anatomical coordinates once unit is defined + # anatomical_coordinates=self._map_3d_coords( + # x=getattr(stream_module, "bregma_target_ap", None), + # y=getattr(stream_module, "bregma_target_ml", None), + # z=getattr(stream_module, "bregma_target_dv", None), + # ), + anatomical_reference=( + CoordinateReferenceLocation.BREGMA + if getattr(stream_module, "bregma_target_ap", None) + else None + ), + surface_z=getattr(stream_module, "surface_z", None), + dye=getattr(stream_module, "dye", None), + implant_hole_number=getattr(stream_module, "implant_hole", None), + notes="Anatomical Coordinates mapped AP:X, ML:Y, DV:Z", + ) + + @staticmethod + def _map_dome_module(stream_module: SlimsStreamModule) -> DomeModule: + """ + Map a stream module to a DomeModule instance. + """ + return DomeModule.model_construct( + assembly_name=getattr(stream_module, "assembly_name", None), + arc_angle=getattr(stream_module, "arc_angle", None), + module_angle=getattr(stream_module, "module_angle", None), + rotation_angle=getattr(stream_module, "rotation_angle", None), + coordinate_transform=getattr( + stream_module, "coordinate_transform", None + ), + ) + + @staticmethod + def _map_targeted_structure(structure_record: SlimsBrainStructureRdrc): + """Map targeted structure""" + return getattr(structure_record, "name", None) + + @staticmethod + def _map_stream_modality(modality: str) -> Optional[Modality]: + """Map stream modality to the Modality enum.""" + modality_mapping = { + SlimsStreamModalities.ELECTROMYOGRAPHY.value: Modality.EMG, + SlimsStreamModalities.SPIM.value: Modality.SPIM, + SlimsStreamModalities.MRI.value: Modality.MRI, + SlimsStreamModalities.ISI.value: Modality.ISI, + SlimsStreamModalities.FMOST.value: Modality.FMOST, + } + return modality_mapping.get( + modality, + Modality.from_abbreviation(modality.lower().replace(" ", "-")), + ) + + @staticmethod + def _map_ccf_coords( + ml: Optional[float], ap: Optional[float], dv: Optional[float] + ) -> Optional[CcfCoords]: + """Map coordinates to CcfCoords.""" + return CcfCoords(ml=ml, ap=ap, dv=dv) if ml and ap and dv else None + + @staticmethod + def _map_3d_coords( + x: Optional[float], y: Optional[float], z: Optional[float] + ) -> Optional[Coordinates3d]: + """Map coordinates to 3D space.""" + return Coordinates3d(x=x, y=y, z=z) if x and y and z else None diff --git a/tests/resources/slims/mapped/ecephys_session.json b/tests/resources/slims/mapped/ecephys_session.json new file mode 100644 index 00000000..c970b3e5 --- /dev/null +++ b/tests/resources/slims/mapped/ecephys_session.json @@ -0,0 +1,136 @@ +{ + "describedBy": "https://raw.githubusercontent.com/AllenNeuralDynamics/aind-data-schema/main/src/aind_data_schema/core/session.py", + "schema_version": "1.0.1", + "protocol_id": [], + "session_end_time": null, + "session_type": null, + "iacuc_protocol": null, + "rig_id": "323_EPHYS1_OPTO_20240212", + "calibrations": [], + "maintenance": [], + "subject_id": "000000", + "animal_weight_prior": 10.0, + "animal_weight_post": 10.0, + "weight_unit": "gram", + "anaesthesia": null, + "data_streams": [ + { + "daq_names": [ + "Harp Behavior", + "NPopto Basestation" + ], + "camera_names": ["Camera1", "Camera2"], + "light_sources": [], + "ephys_modules": [ + { + "assembly_name": "assembly name test", + "arc_angle": 3.0, + "module_angle": 3.0, + "angle_unit": "degrees", + "rotation_angle": 3.0, + "coordinate_transform": null, + "calibration_date": null, + "notes": "Anatomical Coordinates mapped AP:X, ML:Y, DV:Z", + "primary_targeted_structure": "Nucleus accumbens", + "other_targeted_structure": null, + "targeted_ccf_coordinates": [], + "manipulator_coordinates": { + "x": "1.0", + "y": "1.0", + "z": "1.0", + "unit": "micrometer" + }, + "anatomical_coordinates": null, + "anatomical_reference": "Bregma", + "surface_z": null, + "surface_z_unit": "micrometer", + "dye": null, + "implant_hole_number": 9.0 + } + ], + "stick_microscopes": [ + { + "assembly_name": "45881", + "arc_angle": 1.0, + "module_angle": 1.0, + "angle_unit": "degrees", + "rotation_angle": 1.0, + "coordinate_transform": null, + "calibration_date": null, + "notes": null + } + ], + "manipulator_modules": [], + "detectors": [], + "fiber_connections": [], + "fiber_modules": [], + "ophys_fovs": [], + "slap_fovs": [], + "stack_parameters": null, + "mri_scans": [], + "stream_modalities": [ + { + "name": "Behavior", + "abbreviation": "behavior" + }, + { + "name": "Extracellular electrophysiology", + "abbreviation": "ecephys" + } + ], + "software": [], + "notes": null + } + ], + "stimulus_epochs": [ + { + "stimulus_name": "stim1", + "session_number": null, + "software": [], + "script": null, + "stimulus_modalities": [ + "Auditory", + "Olfactory" + ], + "stimulus_parameters": null, + "stimulus_device_names": "device1, device2", + "speaker_config": { + "name": "speaker1", + "volume": "1.0", + "volume_unit": "decibels" + }, + "light_source_config": [ + { + "device_type": "Light emitting diode", + "name": "diode", + "excitation_power": "12.0", + "excitation_power_unit": "milliwatt" + } + ], + "output_parameters": {}, + "reward_consumed_during_epoch": 12.0, + "reward_consumed_unit": "microliter", + "trials_total": null, + "trials_finished": null, + "trials_rewarded": null, + "notes": null + } + ], + "mouse_platform_name": null, + "active_mouse_platform": false, + "headframe_registration": null, + "reward_delivery": { + "reward_solution": "Water", + "reward_spouts": [ + { + "side": "Right", + "starting_position": "xyz", + "variable_position": true + } + ], + "notes": null + }, + "reward_consumed_total": 12.0, + "reward_consumed_unit": "milliliter", + "notes": null +} diff --git a/tests/resources/slims/mapped/ecephys_session2.json b/tests/resources/slims/mapped/ecephys_session2.json new file mode 100644 index 00000000..a8605795 --- /dev/null +++ b/tests/resources/slims/mapped/ecephys_session2.json @@ -0,0 +1,128 @@ +{ + "describedBy": "https://raw.githubusercontent.com/AllenNeuralDynamics/aind-data-schema/main/src/aind_data_schema/core/session.py", + "schema_version": "1.0.1", + "protocol_id": [], + "session_end_time": null, + "session_type": null, + "iacuc_protocol": null, + "rig_id": "323_EPHYS1_OPTO_20240212", + "calibrations": [], + "maintenance": [], + "subject_id": "000000", + "animal_weight_prior": 10.0, + "animal_weight_post": 10.0, + "weight_unit": "gram", + "anaesthesia": null, + "data_streams": [ + { + "daq_names": [ + "Harp Behavior", + "NPopto Basestation" + ], + "camera_names": ["Camera1", "Camera2"], + "light_sources": [], + "ephys_modules": [ + { + "assembly_name": "assembly name test", + "arc_angle": 3.0, + "module_angle": 3.0, + "angle_unit": "degrees", + "rotation_angle": 3.0, + "coordinate_transform": null, + "calibration_date": null, + "notes": "Anatomical Coordinates mapped AP:X, ML:Y, DV:Z", + "primary_targeted_structure": "Nucleus accumbens", + "other_targeted_structure": null, + "targeted_ccf_coordinates": [], + "manipulator_coordinates": { + "x": "1.0", + "y": "1.0", + "z": "1.0", + "unit": "micrometer" + }, + "anatomical_coordinates": null, + "anatomical_reference": "Bregma", + "surface_z": null, + "surface_z_unit": "micrometer", + "dye": null, + "implant_hole_number": 9.0 + } + ], + "stick_microscopes": [ + { + "assembly_name": "45881", + "arc_angle": 1.0, + "module_angle": 1.0, + "angle_unit": "degrees", + "rotation_angle": 1.0, + "coordinate_transform": null, + "calibration_date": null, + "notes": null + } + ], + "manipulator_modules": [], + "detectors": [], + "fiber_connections": [], + "fiber_modules": [], + "ophys_fovs": [], + "slap_fovs": [], + "stack_parameters": null, + "mri_scans": [], + "stream_modalities": [ + { + "name": "Behavior", + "abbreviation": "behavior" + }, + { + "name": "Extracellular electrophysiology", + "abbreviation": "ecephys" + } + ], + "software": [], + "notes": null + } + ], + "stimulus_epochs": [ + { + "stimulus_name": "stim1", + "session_number": null, + "software": [], + "script": null, + "stimulus_modalities": [ + "Auditory", + "Olfactory" + ], + "stimulus_parameters": null, + "stimulus_device_names": "device1, device2", + "speaker_config": { + "name": "speaker1", + "volume": "1.0", + "volume_unit": "decibels" + }, + "light_source_config": [ + { + "device_type": "Laser", + "name": "laserA", + "wavelength": 12, + "wavelength_unit": "nanometer", + "excitation_power": "1.0", + "excitation_power_unit": "milliwatt" + } + ], + "output_parameters": {}, + "reward_consumed_during_epoch": 12.0, + "reward_consumed_unit": "microliter", + "trials_total": null, + "trials_finished": null, + "trials_rewarded": null, + "notes": null + } + ], + "mouse_platform_name": null, + "active_mouse_platform": false, + "headframe_registration": null, + "reward_delivery": null, + "reward_consumed_total": 12.0, + "reward_consumed_unit": "milliliter", + "notes": null +} diff --git a/tests/resources/slims/ephys_rig.json b/tests/resources/slims/mapped/ephys_rig.json similarity index 100% rename from tests/resources/slims/ephys_rig.json rename to tests/resources/slims/mapped/ephys_rig.json diff --git a/tests/resources/slims/instrument.json b/tests/resources/slims/mapped/instrument.json similarity index 100% rename from tests/resources/slims/instrument.json rename to tests/resources/slims/mapped/instrument.json diff --git a/tests/resources/slims/attachment_json_entity.json b/tests/resources/slims/raw/attachment_json_entity.json similarity index 100% rename from tests/resources/slims/attachment_json_entity.json rename to tests/resources/slims/raw/attachment_json_entity.json diff --git a/tests/resources/slims/raw/ecephys_session_response.json b/tests/resources/slims/raw/ecephys_session_response.json new file mode 100644 index 00000000..e982fed6 --- /dev/null +++ b/tests/resources/slims/raw/ecephys_session_response.json @@ -0,0 +1,167 @@ +{ + "session_group": { + "pk": 64952, + "created_on": 1729807856372, + "json_entity": {}, + "name": "Group of Sessions", + "experimentrun_pk": 41181 + }, + "session_instrument": { + "pk": 1743, + "created_on": 1711642574300, + "json_entity": {}, + "name": "323_EPHYS1_OPTO_20240212" + }, + "session_result": { + "pk": 2894, + "created_on": 1729807858852, + "json_entity": {}, + "test_label": "Mouse Session", + "mouse_session_id": "RSLT0000002946", + "mouse_session": null, + "weight_prior_g": 10.0, + "weight_post_g": 10.0, + "reward_consumed_vol": 12.0, + "reward_delivery_pk": 3671, + "mouse_pk": 3280, + "mouse_session_pk": null, + "experiment_run_step_pk": 64953 + }, + "streams": [ + { + "pk": 2895, + "created_on": 1729807859729, + "json_entity": {}, + "test_label": "Streams", + "mouse_session": null, + "stream_modalities": [ + "Behavior", + "Ecephys" + ], + "daq_names": [ + "Harp Behavior", + "NPopto Basestation" + ], + "camera_names": [ + "Camera1", + "Camera2" + ], + "stream_modules_pk": [ + 3467, + 3672 + ], + "mouse_pk": 3280, + "mouse_session_pk": 2894, + "experiment_run_step_pk": 64954, + "stream_modules": [ + { + "pk": 3467, + "created_on": 1726521670522, + "json_entity": {}, + "implant_hole": 9.0, + "assembly_name": "assembly name test", + "probe_name": "ephys probe name test", + "primary_targeted_structure_pk": 1897, + "secondary_targeted_structures_pk": null, + "arc_angle": 3.0, + "module_angle": 3.0, + "rotation_angle": 3.0, + "coordinate_transform": null, + "ccf_coordinate_ap": 3.0, + "ccf_coordinate_ml": 3.0, + "ccf_coordinate_dv": 3.0, + "ccf_version": 3.0, + "bregma_target_ap": 3.0, + "bregma_target_ml": 3.0, + "bregma_target_dv": 3.0, + "surface_z": null, + "manipulator_x": 1.0, + "manipulator_y": 1.0, + "manipulator_z": 1.0, + "dye": null, + "fiber_connections_pk": null, + "primary_targeted_structure": { + "pk": 1897, + "created_on": 1714684334335, + "json_entity": {}, + "name": "Nucleus accumbens" + }, + "secondary_targeted_structures": [] + }, + { + "pk": 3672, + "created_on": 1729812275378, + "json_entity": {}, + "implant_hole": null, + "assembly_name": "45881", + "probe_name": null, + "primary_targeted_structure_pk": null, + "secondary_targeted_structures_pk": null, + "arc_angle": 1.0, + "module_angle": 1.0, + "rotation_angle": 1.0, + "coordinate_transform": null, + "ccf_coordinate_ap": null, + "ccf_coordinate_ml": null, + "ccf_coordinate_dv": null, + "ccf_version": null, + "bregma_target_ap": null, + "bregma_target_ml": null, + "bregma_target_dv": null, + "surface_z": null, + "manipulator_x": null, + "manipulator_y": null, + "manipulator_z": null, + "dye": null, + "fiber_connections_pk": null, + "primary_targeted_structure": null, + "secondary_targeted_structures": [] + } + ] + } + ], + "reward_delivery": { + "reward_delivery": { + "pk": 3671, + "created_on": 1729808236573, + "json_entity": {}, + "reward_spouts_pk": 3670, + "reward_solution": "Water", + "other_reward_solution": null + }, + "reward_spouts": { + "pk": 3670, + "created_on": 1729808198683, + "json_entity": {}, + "spout_side": "Right", + "starting_position": "xyz", + "variable_position": true + } + }, + "stimulus_epochs": [ + { + "pk": 2896, + "created_on": 1729807860197, + "json_entity": {}, + "test_label": "Stimulus Epochs", + "mouse_session": null, + "stimulus_device_names": "device1, device2", + "stimulus_name": "stim1", + "stimulus_modalities": [ + "Auditory", + "Olfactory" + ], + "reward_consumed_during_epoch": 12.0, + "led_name": "diode", + "led_excitation_power_mw": 12.0, + "laser_name": "laserA", + "laser_wavelength": null, + "laser_excitation_power": 1.0, + "speaker_name": "speaker1", + "speaker_volume": 1.0, + "mouse_pk": 3280, + "mouse_session_pk": 2894, + "experiment_run_step_pk": 64955 + } + ] +} diff --git a/tests/resources/slims/raw/ecephys_session_response2.json b/tests/resources/slims/raw/ecephys_session_response2.json new file mode 100644 index 00000000..f542da21 --- /dev/null +++ b/tests/resources/slims/raw/ecephys_session_response2.json @@ -0,0 +1,146 @@ +{ + "session_group": { + "pk": 64952, + "created_on": 1729807856372, + "json_entity": {}, + "name": "Group of Sessions", + "experimentrun_pk": 41181 + }, + "session_instrument": { + "pk": 1743, + "created_on": 1711642574300, + "json_entity": {}, + "name": "323_EPHYS1_OPTO_20240212" + }, + "session_result": { + "pk": 2894, + "created_on": 1729807858852, + "json_entity": {}, + "test_label": "Mouse Session", + "mouse_session_id": "RSLT0000002946", + "mouse_session": null, + "weight_prior_g": 10.0, + "weight_post_g": 10.0, + "reward_consumed_vol": 12.0, + "mouse_pk": 3280, + "mouse_session_pk": null, + "experiment_run_step_pk": 64953 + }, + "streams": [ + { + "pk": 2895, + "created_on": 1729807859729, + "json_entity": {}, + "test_label": "Streams", + "mouse_session": null, + "stream_modalities": [ + "Behavior", + "Ecephys" + ], + "daq_names": [ + "Harp Behavior", + "NPopto Basestation" + ], + "camera_names": [ + "Camera1", + "Camera2" + ], + "stream_modules_pk": [ + 3467, + 3672 + ], + "mouse_pk": 3280, + "mouse_session_pk": 2894, + "experiment_run_step_pk": 64954, + "stream_modules": [ + { + "pk": 3467, + "created_on": 1726521670522, + "json_entity": {}, + "implant_hole": 9.0, + "assembly_name": "assembly name test", + "probe_name": "ephys probe name test", + "primary_targeted_structure_pk": 1897, + "secondary_targeted_structures_pk": null, + "arc_angle": 3.0, + "module_angle": 3.0, + "rotation_angle": 3.0, + "coordinate_transform": null, + "ccf_coordinate_ap": 3.0, + "ccf_coordinate_ml": 3.0, + "ccf_coordinate_dv": 3.0, + "ccf_version": 3.0, + "bregma_target_ap": 3.0, + "bregma_target_ml": 3.0, + "bregma_target_dv": 3.0, + "surface_z": null, + "manipulator_x": 1.0, + "manipulator_y": 1.0, + "manipulator_z": 1.0, + "dye": null, + "fiber_connections_pk": null, + "primary_targeted_structure": { + "pk": 1897, + "created_on": 1714684334335, + "json_entity": {}, + "name": "Nucleus accumbens" + }, + "secondary_targeted_structures": [] + }, + { + "pk": 3672, + "created_on": 1729812275378, + "json_entity": {}, + "implant_hole": null, + "assembly_name": "45881", + "probe_name": null, + "primary_targeted_structure_pk": null, + "secondary_targeted_structures_pk": null, + "arc_angle": 1.0, + "module_angle": 1.0, + "rotation_angle": 1.0, + "coordinate_transform": null, + "ccf_coordinate_ap": null, + "ccf_coordinate_ml": null, + "ccf_coordinate_dv": null, + "ccf_version": null, + "bregma_target_ap": null, + "bregma_target_ml": null, + "bregma_target_dv": null, + "surface_z": null, + "manipulator_x": null, + "manipulator_y": null, + "manipulator_z": null, + "dye": null, + "fiber_connections_pk": null, + "primary_targeted_structure": null, + "secondary_targeted_structures": [] + } + ] + } + ], + "stimulus_epochs": [ + { + "pk": 2896, + "created_on": 1729807860197, + "json_entity": {}, + "test_label": "Stimulus Epochs", + "mouse_session": null, + "stimulus_device_names": "device1, device2", + "stimulus_name": "stim1", + "stimulus_modalities": [ + "Auditory", + "Olfactory" + ], + "reward_consumed_during_epoch": 12.0, + "laser_name": "laserA", + "laser_wavelength": 12.0, + "laser_excitation_power": 1.0, + "speaker_name": "speaker1", + "speaker_volume": 1.0, + "mouse_pk": 3280, + "mouse_session_pk": 2894, + "experiment_run_step_pk": 64955 + } + ] +} diff --git a/tests/resources/slims/instrument_json_entity.json b/tests/resources/slims/raw/instrument_json_entity.json similarity index 100% rename from tests/resources/slims/instrument_json_entity.json rename to tests/resources/slims/raw/instrument_json_entity.json diff --git a/tests/resources/slims/json_entity.json b/tests/resources/slims/raw/json_entity.json similarity index 100% rename from tests/resources/slims/json_entity.json rename to tests/resources/slims/raw/json_entity.json diff --git a/tests/slims/test_client.py b/tests/slims/test_client.py index 68b9bbce..c385ad82 100644 --- a/tests/slims/test_client.py +++ b/tests/slims/test_client.py @@ -1,15 +1,31 @@ """Testing SlimsHandler""" +import json +import os import unittest -from unittest.mock import patch, MagicMock -from requests.models import Response -from aind_metadata_service.client import StatusCodes -from aind_slims_api.exceptions import SlimsRecordNotFound +from pathlib import Path +from unittest.mock import MagicMock, patch + from aind_data_schema.core.instrument import Instrument from aind_data_schema.core.rig import Rig +from aind_slims_api.exceptions import SlimsRecordNotFound +from aind_slims_api.models.instrument import SlimsInstrumentRdrc +from aind_slims_api.operations.ecephys_session import ( + EcephysSession as SlimsEcephysSession, +) +from requests.models import Response +from aind_metadata_service.client import StatusCodes from aind_metadata_service.slims.client import SlimsHandler, SlimsSettings -from aind_slims_api.models.instrument import SlimsInstrumentRdrc + +RESOURCES_DIR = ( + Path(os.path.dirname(os.path.realpath(__file__))) + / ".." + / "resources" + / "slims" +) +RAW_DIR = RESOURCES_DIR / "raw" +MAPPED_DIR = RESOURCES_DIR / "mapped" class TestSlimsHandler(unittest.TestCase): @@ -24,6 +40,15 @@ def setUp(self, mock_slims_client): self.mock_client = mock_slims_client.return_value self.handler = SlimsHandler(settings) + with open(RAW_DIR / "ecephys_session_response.json", "r") as f: + slims_data1 = json.load(f) + with open(MAPPED_DIR / "ecephys_session.json", encoding="utf-8") as f: + expected_data1 = json.load(f) + self.slims_sessions = [ + SlimsEcephysSession.model_validate(slims_data1), + ] + self.expected_sessions = [expected_data1] + def test_is_json_file_true(self): """Test that _is_json_file returns True for valid JSON response.""" mock_response = MagicMock(spec=Response) @@ -38,11 +63,11 @@ def test_is_json_file_false(self): @patch( "aind_metadata_service.slims.client.SlimsHandler._is_json_file", - return_value=True, ) def test_get_instrument_model_response_success(self, mock_is_json_file): """Test successful response from get_instrument_model_response.""" mock_inst = MagicMock() + mock_is_json_file.return_value = True self.mock_client.fetch_model.return_value = mock_inst mock_attachment = MagicMock() self.mock_client.fetch_attachment.return_value = mock_attachment @@ -194,6 +219,49 @@ def test_get_rig_model_response_invalid_response(self): response.status_code, StatusCodes.INTERNAL_SERVER_ERROR ) + @patch("aind_metadata_service.slims.client.SlimsSessionMapper") + @patch("aind_metadata_service.slims.client.fetch_ecephys_sessions") + def test_get_sessions_model_response_success( + self, mock_fetch_sessions, mock_mapper + ): + """Tests that sessions data is fetched as expected.""" + mock_fetch_sessions.return_value = self.slims_sessions + mock_mapper_instance = mock_mapper.return_value + mock_mapper_instance.map_sessions.return_value = self.expected_sessions + response = self.handler.get_sessions_model_response("test_id") + + self.assertEqual(response.aind_models, self.expected_sessions) + self.assertEqual(response.status_code, StatusCodes.DB_RESPONDED) + + @patch("aind_metadata_service.slims.client.fetch_ecephys_sessions") + def test_get_sessions_model_response_no_data(self, mock_fetch_sessions): + """Tests no data found response.""" + mock_fetch_sessions.return_value = [] + response = self.handler.get_sessions_model_response("test_id") + + self.assertEqual(response.status_code, StatusCodes.NO_DATA_FOUND) + + @patch("aind_metadata_service.slims.client.fetch_ecephys_sessions") + def test_get_sessions_model_response_unexpected_error( + self, mock_fetch_sessions + ): + """Tests internal server error.""" "" + mock_fetch_sessions.side_effect = Exception("Unexpected error") + + response = self.handler.get_sessions_model_response("test_id") + + # Assert that the response is internal server error + self.assertEqual( + response.status_code, StatusCodes.INTERNAL_SERVER_ERROR + ) + + def test_get_sessions_model_response_not_found(self): + """Test response when SlimsRecordNotFound is raised.""" + self.mock_client.fetch_model.side_effect = SlimsRecordNotFound + + response = self.handler.get_sessions_model_response("test_id") + self.assertEqual(response.status_code, StatusCodes.NO_DATA_FOUND) + if __name__ == "__main__": unittest.main() diff --git a/tests/slims/test_mapping.py b/tests/slims/test_mapping.py new file mode 100644 index 00000000..dab85367 --- /dev/null +++ b/tests/slims/test_mapping.py @@ -0,0 +1,112 @@ +"""Tests Slims Mapper""" + +import json +import os +import unittest +from pathlib import Path + +from aind_data_schema.components.devices import SpoutSide +from aind_data_schema.core.session import RewardSolution +from aind_slims_api.models.ecephys_session import SlimsRewardDeliveryRdrc +from aind_slims_api.operations.ecephys_session import ( + EcephysSession as SlimsEcephysSession, +) + +from aind_metadata_service.slims.mapping import SlimsSessionMapper + +RESOURCES_DIR = ( + Path(os.path.dirname(os.path.realpath(__file__))) + / ".." + / "resources" + / "slims" +) +RAW_DIR = RESOURCES_DIR / "raw" +MAPPED_DIR = RESOURCES_DIR / "mapped" + + +class TestSlimsSessionMapper(unittest.TestCase): + """Class to test methods of SLimsSessionMapper""" + + def setUp(self): + """Sets up test class""" + self.mapper = SlimsSessionMapper() + with open(RAW_DIR / "ecephys_session_response.json") as f: + slims_data1 = json.load(f) + with open(RAW_DIR / "ecephys_session_response2.json") as f: + slims_data2 = json.load(f) + with open(MAPPED_DIR / "ecephys_session.json") as f: + expected_data1 = json.load(f) + with open(MAPPED_DIR / "ecephys_session2.json") as f: + expected_data2 = json.load(f) + self.slims_sessions = [ + SlimsEcephysSession.model_validate(slims_data1), + SlimsEcephysSession.model_validate(slims_data2), + ] + self.expected_sessions = [expected_data1, expected_data2] + + def test_map_sessions(self): + """Tests map sessions""" + sessions = self.mapper.map_sessions( + sessions=self.slims_sessions, subject_id="000000" + ) + self.assertEqual(len(sessions), 2) + mapped_session_json1 = sessions[0].model_dump_json() + mapped_session_json_parsed1 = json.loads(mapped_session_json1) + self.assertEqual( + mapped_session_json_parsed1, self.expected_sessions[0] + ) + mapped_session_json2 = sessions[1].model_dump_json() + mapped_session_json_parsed2 = json.loads(mapped_session_json2) + self.assertEqual( + mapped_session_json_parsed2, self.expected_sessions[1] + ) + + def test_map_spout_side(self): + """Tests spout side is mapped correctly.""" + self.assertEqual(self.mapper._map_spout_side("left"), SpoutSide.LEFT) + self.assertEqual(self.mapper._map_spout_side("LEFT"), SpoutSide.LEFT) + self.assertEqual( + self.mapper._map_spout_side("Left side"), SpoutSide.LEFT + ) + self.assertEqual(self.mapper._map_spout_side("right"), SpoutSide.RIGHT) + self.assertEqual(self.mapper._map_spout_side("RIGHT"), SpoutSide.RIGHT) + self.assertEqual( + self.mapper._map_spout_side("Right spout"), SpoutSide.RIGHT + ) + self.assertEqual( + self.mapper._map_spout_side("center"), SpoutSide.CENTER + ) + self.assertEqual( + self.mapper._map_spout_side("CENTER"), SpoutSide.CENTER + ) + self.assertEqual( + self.mapper._map_spout_side("Center spout"), SpoutSide.CENTER + ) + self.assertEqual( + self.mapper._map_spout_side("unknown"), SpoutSide.OTHER + ) + self.assertEqual( + self.mapper._map_spout_side("random text"), SpoutSide.OTHER + ) + self.assertEqual(self.mapper._map_spout_side(""), SpoutSide.OTHER) + + def test_map_reward_solution(self): + """Tests that reward solution is mapper correctly.""" + reward_delivery = SlimsRewardDeliveryRdrc.model_construct( + reward_solution="Other, (if Other, specify below)", + other_reward_solution="Some Solution", + ) + solution, notes = self.mapper._map_reward_solution(reward_delivery) + self.assertEqual(solution, RewardSolution.OTHER) + self.assertEqual(notes, "Some Solution") + + reward_delivery2 = SlimsRewardDeliveryRdrc.model_construct( + reward_solution=None, other_reward_solution=None + ) + self.assertEqual( + (None, None), self.mapper._map_reward_solution(reward_delivery2) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_response_handler.py b/tests/test_response_handler.py index 95c4dcdb..33f60e35 100644 --- a/tests/test_response_handler.py +++ b/tests/test_response_handler.py @@ -195,13 +195,41 @@ def test_no_validation(self): self.assertEqual(expected_json.body, actual_json.body) def test_multiple_items_response(self): - """Test multiple item response""" + """Test multiple item response with validation.""" models = [sp_valid_model, sp_valid_model] model_response = ModelResponse( status_code=StatusCodes.DB_RESPONDED, aind_models=models ) actual_json = model_response.map_to_json_response() + models_json = [ + jsonable_encoder(json.loads(model.model_dump_json())) + for model in models + ] + validation_error = ModelResponse._validate_model(sp_valid_model) + expected_json = JSONResponse( + status_code=300, + content=( + { + "message": f"Multiple Items Found. Validation Errors:" + f" {validation_error}, {validation_error}", + "data": models_json, + } + ), + ) + + self.assertEqual(StatusCodes.DB_RESPONDED, model_response.status_code) + self.assertEqual(expected_json.status_code, actual_json.status_code) + self.assertEqual(expected_json.body, actual_json.body) + + def test_multiple_items_response_no_validation(self): + """Test multiple item response""" + models = [sp_valid_model, sp_valid_model] + model_response = ModelResponse( + status_code=StatusCodes.DB_RESPONDED, aind_models=models + ) + actual_json = model_response.map_to_json_response(validate=False) + models_json = [ jsonable_encoder(json.loads(model.model_dump_json())) for model in models @@ -210,7 +238,8 @@ def test_multiple_items_response(self): status_code=300, content=( { - "message": "Multiple Items Found.", + "message": "Multiple Items Found." + " Models have not been validated.", "data": models_json, } ),