Skip to content

Commit 961761a

Browse files
authored
feat: add support for calibration objects (#1471)
* feat: add support for phantoms Phantom implemented as a Device * refactor: phantom has a description and optional list of component devices * refactor: Phantom -> Calibration object * feat: new validator for calibration tag * tests: adding coverage * chore: lint * tests: repair tests that were broken by a merge * chore: lint * tests: coverage for new validator * fix: code got removed at some point
1 parent b3b9c62 commit 961761a

File tree

5 files changed

+76
-4
lines changed

5 files changed

+76
-4
lines changed

examples/data_description.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
d = DataDescription(
1313
modalities=[Modality.ECEPHYS, Modality.BEHAVIOR_VIDEOS],
14-
subject_id="12345",
14+
subject_id="123456",
1515
creation_time=datetime(2022, 2, 21, 16, 30, 1, tzinfo=timezone.utc),
1616
institution=Organization.AIND,
1717
investigators=[Person(name="Daniel Birman", registry_identifier="0000-0003-3748-6289")], # Include ORCID IDs

src/aind_data_schema/components/subjects.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from pydantic_core.core_schema import ValidationInfo
1313

1414
from aind_data_schema.base import DataModel
15+
from aind_data_schema.components.devices import Device
1516
from aind_data_schema.utils.validators import TimeValidation
1617

1718

@@ -140,3 +141,15 @@ class HumanSubject(DataModel):
140141
description="Where the subject was acquired from.",
141142
title="Source",
142143
)
144+
145+
146+
class CalibrationObject(DataModel):
147+
"""Description of a calibration object"""
148+
149+
empty: bool = Field(
150+
default=False, title="Empty", description="Set to true if the calibration was performed with no object."
151+
)
152+
description: str = Field(..., title="Description")
153+
objects: Optional[list[Device]] = Field(
154+
default=None, title="Objects", description="For calibration objects that are built up from one or more devices."
155+
)

src/aind_data_schema/core/metadata.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from aind_data_schema.base import DataCoreModel
2222
from aind_data_schema.components.identifiers import DatabaseIdentifiers
23+
from aind_data_schema.components.subjects import CalibrationObject
2324
from aind_data_schema.core.acquisition import Acquisition
2425
from aind_data_schema.core.data_description import DataDescription
2526
from aind_data_schema.core.instrument import Instrument
@@ -242,6 +243,29 @@ def validate_acquisition_connections(self):
242243

243244
return self
244245

246+
@model_validator(mode="after")
247+
def validate_calibration_object_tags(self):
248+
"""Validator to ensure 'calibration' tag is present when subject is a CalibrationObject"""
249+
250+
if (
251+
self.subject
252+
and self.subject.subject_details
253+
and isinstance(self.subject.subject_details, CalibrationObject)
254+
and self.data_description
255+
):
256+
if self.data_description.tags is None:
257+
# Initialize tags list if it doesn't exist
258+
self.data_description.tags = []
259+
260+
if "calibration" not in self.data_description.tags:
261+
warnings.warn(
262+
"Subject is a CalibrationObject but 'calibration' tag is missing from data_description.tags. "
263+
"Adding 'calibration' tag automatically."
264+
)
265+
self.data_description.tags.append("calibration")
266+
267+
return self
268+
245269
@model_validator(mode="after")
246270
def validate_training_protocol_references(self):
247271
"""Validate that training_protocol_name in StimulusEpoch matches a TrainingProtocol in procedures"""

src/aind_data_schema/core/subject.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from pydantic import Field, SkipValidation
66

77
from aind_data_schema.base import DataCoreModel, Discriminated
8-
from aind_data_schema.components.subjects import HumanSubject, MouseSubject
8+
from aind_data_schema.components.subjects import HumanSubject, MouseSubject, CalibrationObject
99

1010

1111
class Subject(DataCoreModel):
@@ -20,6 +20,8 @@ class Subject(DataCoreModel):
2020
title="Subject ID",
2121
)
2222

23-
subject_details: Discriminated[MouseSubject | HumanSubject] = Field(..., title="Subject Details")
23+
subject_details: Discriminated[MouseSubject | HumanSubject | CalibrationObject] = Field(
24+
..., title="Subject Details"
25+
)
2426

2527
notes: Optional[str] = Field(default=None, title="Notes")

tests/test_metadata.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from aind_data_schema.components.coordinates import CoordinateSystemLibrary
1414
from aind_data_schema.components.devices import EphysAssembly, EphysProbe, Laser, Manipulator
1515
from aind_data_schema.components.identifiers import Code, Database, Person
16-
from aind_data_schema.components.subjects import BreedingInfo, Housing, MouseSubject, Sex, Species
16+
from aind_data_schema.components.subjects import BreedingInfo, Housing, MouseSubject, Sex, Species, CalibrationObject
1717
from aind_data_schema.components.surgery_procedures import BrainInjection
1818
from aind_data_schema.core.acquisition import Acquisition, AcquisitionSubjectDetails, DataStream
1919
from aind_data_schema.core.data_description import DataDescription, Funding
@@ -29,6 +29,9 @@
2929
from aind_data_schema.components.subject_procedures import TrainingProtocol
3030
from aind_data_schema.core.acquisition import StimulusEpoch
3131

32+
from examples.data_description import d as data_description
33+
34+
3235
EXAMPLES_DIR = Path(__file__).parents[1] / "examples"
3336
EPHYS_INST_JSON = EXAMPLES_DIR / "ephys_instrument.json"
3437
EPHYS_SESSION_JSON = EXAMPLES_DIR / "ephys_acquisition.json"
@@ -786,6 +789,36 @@ def test_validate_time_constraints_processing(self):
786789
self.assertIn("must be after", str(context.exception))
787790
self.assertIn("start_date_time", str(context.exception))
788791

792+
def test_validate_calibration_object_tags(self):
793+
"""Tests that calibration tag warning is issued when subject is CalibrationObject but tag is missing"""
794+
795+
# Create a subject with CalibrationObject
796+
calibration_subject = Subject(
797+
subject_id="calibration_object_001",
798+
subject_details=CalibrationObject(
799+
description="Test calibration object",
800+
),
801+
)
802+
803+
# Use the existing data_description from class setup (which doesn't have 'calibration' tag)
804+
805+
# This should trigger a warning since subject is CalibrationObject but no 'calibration' tag
806+
with self.assertWarns(UserWarning) as w:
807+
metadata = Metadata(
808+
name="Test Metadata",
809+
location="Test Location",
810+
subject=calibration_subject,
811+
data_description=data_description,
812+
)
813+
814+
warning_messages = [str(warning.message) for warning in w.warnings]
815+
self.assertIn(
816+
"Subject is a CalibrationObject but 'calibration' tag is missing from data_description.tags. "
817+
"Adding 'calibration' tag automatically.",
818+
warning_messages,
819+
)
820+
self.assertIsNotNone(metadata)
821+
789822

790823
if __name__ == "__main__":
791824
unittest.main()

0 commit comments

Comments
 (0)