From 0c1b8ca465935a5925076af02011806b383a57d0 Mon Sep 17 00:00:00 2001 From: Georg Raiser Date: Fri, 12 Dec 2025 14:47:52 +0530 Subject: [PATCH 1/7] initial commit - FiberInsertions and other related tables - draft --- alyx/actions/admin.py | 17 ++- alyx/actions/models.py | 9 ++ alyx/experiments/models.py | 218 +++++++++++++++++++++++++++++++++++++ 3 files changed, 243 insertions(+), 1 deletion(-) diff --git a/alyx/actions/admin.py b/alyx/actions/admin.py index 4dd8bce7a..2a0622cdd 100644 --- a/alyx/actions/admin.py +++ b/alyx/actions/admin.py @@ -17,7 +17,8 @@ from alyx.base import (BaseAdmin, DefaultListFilter, BaseInlineAdmin, get_admin_url) from .models import (OtherAction, ProcedureType, Session, EphysSession, Surgery, VirusInjection, WaterAdministration, WaterRestriction, Weighing, WaterType, - Notification, NotificationRule, Cull, CullReason, CullMethod, ImagingSession + Notification, NotificationRule, Cull, CullReason, CullMethod, ImagingSession, + FiberInsertion ) from data.models import Dataset, FileRecord from misc.admin import NoteInline @@ -682,6 +683,12 @@ class ProbeInsertionInline(TabularInline): fields = ('name', 'model') extra = 0 +class FiberInsertionInline(TabularInline): + fk_name = "session" + show_change_link = True + model = FiberInsertion + fields = ('name', 'fiber_model') + extra = 0 class FOVInline(TabularInline): fk_name = 'session' @@ -699,6 +706,14 @@ def get_queryset(self, request): return qs.filter(procedures__name__icontains='ephys') +class PhotometrySessionAdmin(SessionAdmin): + inlines = [FiberInsertionInline, TasksAdminInline, WaterAdminInline, DatasetInline, NoteInline] + + def get_queryset(self, request): + qs = super(PhotometrySessionAdmin, self).get_queryset(request) + return qs.filter(procedures__name__icontains='Fiber photometry') + + class ImagingSessionAdmin(SessionAdmin): inlines = [FOVInline, TasksAdminInline, WaterAdminInline, DatasetInline, NoteInline] list_filter = [('users', RelatedDropdownFilter), diff --git a/alyx/actions/models.py b/alyx/actions/models.py index e6be347c2..5ee962072 100644 --- a/alyx/actions/models.py +++ b/alyx/actions/models.py @@ -316,6 +316,15 @@ class ImagingSession(Session): class Meta: proxy = True +class PhotometrySession(Session): + """ + This proxy class allows to register as a different admin page. + The database is left untouched. + New methods are fine but not new fields. + For what defines an photometry session see actions.admin.PhotometrySessionAdmin.get_queryset. + """ + class Meta: + proxy = True class WaterRestriction(BaseAction): """ diff --git a/alyx/experiments/models.py b/alyx/experiments/models.py index c4766c37d..f50c6e2ce 100644 --- a/alyx/experiments/models.py +++ b/alyx/experiments/models.py @@ -220,6 +220,224 @@ def subject(self): return self.chronic_insertion.subject.nickname +class FiberModel(BaseModel): # maybe this shouldn't be based on a ProbeModel but rather have + """ + A model for an optical fiber cannula, implanted in a brain + as used by fiber photometry or optogenetics experiments + """ + + fiber_manufacturer = models.CharField( + max_length=255, + null=True, + help_text="manufacturer's name, e.g. Doric", + ) + fiber_model = models.CharField( + max_length=255, + null=True, + help_text="manufacturer's part number e.g. MFC__mm_ZF1.25_FLT", + ) + na = models.FloatField(null=False, help_text="numerical aperture of the fiber, e.g. .54") + diameter = models.FloatField(null=False, help_text="fiber diameter in um, e.g. 200") + length = models.FloatField(null=False, help_text="fiber length in mm, e.g. 6") + tip_type = models.CharField(default="flat", null=False, help_text="fiber tip type, e.g. flat, tapered etc.") + tip_parameter = models.FloatFiled(null=True, help_text="fiber shape parameter, e.g. tip angle, taper length") + description = models.CharField( + max_length=255, + null=True, + blank=True, + help_text="any additional description", + ) + + def __str__(self): + # TODO here: depending on tip shape, maybe we want to include more information here + # those two are the most important though + return f"NA:{self.na}, diameter:{self.diameter}" + + +class ChronicFiberInsertion(ChronicRecording): + """ + note: ChronicRecording is empy, this is a BaseAction + could also just directly inherit from that, but maybe it's + set up like this for future extensability + TODO DOCME + """ + + model = models.ForeignKey( + FiberModel, + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name="chronic_fiber_insertion", + ) + + def __str__(self): + return "%s %s %s" % (self.name, self.subject.nickname, self.serial) + + +class FiberInsertion(BaseModel): + """ + Describe an optical fiber insertion used for fiber photometry + recordings or optogenetics + """ + + objects = BaseManager() + + session = models.ForeignKey( + "actions.PhotometrySession", + blank=True, + null=True, # these insertions have meaning on session level only, so I think it should be True + on_delete=models.CASCADE, + related_name="fiber_insertion", + ) + + fiber_model = models.ForeignKey( + FiberModel, + blank=True, + null=False, # TODO should this be True + on_delete=models.SET_NULL, # doesn't this clash with nullable? + related_name="fiber_insertion", + ) + + datasets = models.ManyToManyField( + "data.Dataset", + blank=True, + related_name="fiber_insertion", + ) + + chronic_insertion = models.ForeignKey( + ChronicFiberInsertion, + blank=True, + on_delete=models.SET_NULL, + null=False, + related_name="fiber_insertion", + ) + + auto_datetime = models.DateTimeField( + auto_now=True, + blank=True, + null=True, + verbose_name="last updated", + ) + + def __str__(self): + return "%s %s" % (self.name, str(self.session)) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["name", "session"], + name="unique_fiber_insertion_name_per_session", + ) + ] + + @property + def subject(self): + return self.session.subject.nickname + + @property + def datetime(self): + return self.session.start_time + + +class FiberTrajectoryEstimate(models.Model): + """ + Describes a probe insertion trajectory - always a straight line + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + INSERTION_DATA_SOURCES = [ + # (70, 'Ephys aligned histology track',), # doesn't exist for fiber + (50, 'Histology track',), + # (30, 'Micro-manipulator',), # doesn't exist for fiber + (10, 'Planned',), + ] + + fiber_insertion = models.ForeignKey(FiberInsertion, blank=True, null=False, + on_delete=models.CASCADE, + related_name='fiber_trajectory_estimate') + chronic_fiber_insertion = models.ForeignKey(ChronicFiberInsertion, blank=True, null=False, + on_delete=models.CASCADE, + related_name='fiber_trajectory_estimate') + x = models.FloatField(null=True, help_text=X_HELP_TEXT, verbose_name='x-ml (um)') + y = models.FloatField(null=True, help_text=Y_HELP_TEXT, verbose_name='y-ap (um)') + z = models.FloatField(null=True, help_text=Z_HELP_TEXT, verbose_name='z-dv (um)') + depth = models.FloatField(null=True, help_text="probe insertion depth (um)") + theta = models.FloatField(null=True, + help_text="Polar angle ie. from vertical, (degrees) [0-180]", + validators=[MinValueValidator(0), MaxValueValidator(180)]) + phi = models.FloatField(null=True, + help_text="Azimuth from right (degrees), anti-clockwise, [0-360]", + validators=[MinValueValidator(-180), MaxValueValidator(360)]) + roll = models.FloatField(null=True, + validators=[MinValueValidator(0), MaxValueValidator(360)]) + _phelp = ' / '.join([str(s[0]) + ': ' + s[1] for s in INSERTION_DATA_SOURCES]) + provenance = models.IntegerField(default=10, choices=INSERTION_DATA_SOURCES, help_text=_phelp) + coordinate_system = models.ForeignKey(CoordinateSystem, null=True, blank=True, + on_delete=models.SET_NULL, + help_text='3D coordinate system used.') + datetime = models.DateTimeField(auto_now=True, verbose_name='last update') + json = models.JSONField(null=True, blank=True, + help_text="Structured data, formatted in a user-defined way") + + class Meta: + constraints = [ + models.UniqueConstraint(fields=['provenance', ''], + condition=models.Q(fiber_insertion__isnull=True), + name='unique_trajectory_per_chronic_provenance'), + models.UniqueConstraint(fields=['provenance', 'fiber_insertion'], + condition=models.Q(fiber_insertion__isnull=False), + name='unique_trajectory_per_provenance'), + ] + + def __str__(self): + if self.fiber_insertion: + return "%s %s/%s" % \ + (self.get_provenance_display(), str(self.session), self.fiber_insertion.name) + elif self.chronic_fiber_insertion: + return "%s %s/%s" % \ + (self.get_provenance_display(), self.chronic_fiber_insertion.subject.nickname, + self.chronic_fiber_insertion.name) + else: + return super().__str__() + + @property + def probe_name(self): + if self.fiber_insertion: + return self.fiber_insertion.name + elif self.chronic_fiber_insertion: + return self.chronic_fiber_insertion.name + + @property + def session(self): + if self.fiber_insertion: + return self.fiber_insertion.session + + @property + def subject(self): + if self.fiber_insertion: + return self.fiber_insertion.session.subject.nickname + elif self.chronic_fiber_insertion: + return self.chronic_fiber_insertion.subject.nickname + + +class FiberTipLocation(BaseModel): + # modelled after a "channel" in ephys + x = models.FloatField(blank=True, null=True, help_text=X_HELP_TEXT, verbose_name='x-ml (um)') + y = models.FloatField(blank=True, null=True, help_text=Y_HELP_TEXT, verbose_name='y-ap (um)') + z = models.FloatField(blank=True, null=True, help_text=Z_HELP_TEXT, verbose_name='z-dv (um)') + brain_region = models.ForeignKey(BrainRegion, default=0, null=True, blank=True, + on_delete=models.SET_NULL, related_name='channels') + fiber_trajectory_estimate = models.ForeignKey(FiberTrajectoryEstimate, null=True, blank=True, + on_delete=models.CASCADE, related_name='fiber_tip_location') + + class Meta: + constraints = [models.UniqueConstraint(fields=['fiber_trajectory_estimate'], + name='unique_fiber_trajectory_estimate')] + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + self.fiber_trajectory_estimate.save() # this will bump the datetime auto-update of trajectory + + class Channel(BaseModel): axial = models.FloatField(blank=True, null=True, help_text=("Distance in micrometers along the probe from the tip." From 74b83d1314ee4ef24604817f2392b2e9b00c706c Mon Sep 17 00:00:00 2001 From: Georg Raiser Date: Fri, 12 Dec 2025 15:21:18 +0530 Subject: [PATCH 2/7] typo fix --- alyx/experiments/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alyx/experiments/models.py b/alyx/experiments/models.py index f50c6e2ce..04d6a5fe5 100644 --- a/alyx/experiments/models.py +++ b/alyx/experiments/models.py @@ -240,7 +240,7 @@ class FiberModel(BaseModel): # maybe this shouldn't be based on a ProbeModel bu diameter = models.FloatField(null=False, help_text="fiber diameter in um, e.g. 200") length = models.FloatField(null=False, help_text="fiber length in mm, e.g. 6") tip_type = models.CharField(default="flat", null=False, help_text="fiber tip type, e.g. flat, tapered etc.") - tip_parameter = models.FloatFiled(null=True, help_text="fiber shape parameter, e.g. tip angle, taper length") + tip_parameter = models.FloatField(null=True, help_text="fiber shape parameter, e.g. tip angle, taper length") description = models.CharField( max_length=255, null=True, From 163b582248cd813191f6e2bfca7868dbd79e3bc2 Mon Sep 17 00:00:00 2001 From: Georg Raiser Date: Fri, 12 Dec 2025 15:33:37 +0530 Subject: [PATCH 3/7] import fix --- alyx/actions/admin.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/alyx/actions/admin.py b/alyx/actions/admin.py index 2a0622cdd..ce1fe78e3 100644 --- a/alyx/actions/admin.py +++ b/alyx/actions/admin.py @@ -17,15 +17,14 @@ from alyx.base import (BaseAdmin, DefaultListFilter, BaseInlineAdmin, get_admin_url) from .models import (OtherAction, ProcedureType, Session, EphysSession, Surgery, VirusInjection, WaterAdministration, WaterRestriction, Weighing, WaterType, - Notification, NotificationRule, Cull, CullReason, CullMethod, ImagingSession, - FiberInsertion + Notification, NotificationRule, Cull, CullReason, CullMethod, ImagingSession, ) from data.models import Dataset, FileRecord from misc.admin import NoteInline from misc.models import Note from subjects.models import Subject from .water_control import WaterControl -from experiments.models import ProbeInsertion, FOV +from experiments.models import ProbeInsertion, FOV, FiberInsertion from jobs.models import Task logger = logging.getLogger(__name__) From b9bdcaa920fcfe19ccb2889c285b9a38da4649e1 Mon Sep 17 00:00:00 2001 From: Georg Raiser Date: Fri, 12 Dec 2025 15:43:42 +0530 Subject: [PATCH 4/7] another one --- alyx/experiments/models.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/alyx/experiments/models.py b/alyx/experiments/models.py index 04d6a5fe5..140ff9acd 100644 --- a/alyx/experiments/models.py +++ b/alyx/experiments/models.py @@ -380,7 +380,7 @@ class FiberTrajectoryEstimate(models.Model): class Meta: constraints = [ - models.UniqueConstraint(fields=['provenance', ''], + models.UniqueConstraint(fields=['provenance', 'chronic_fiber_insertion'], condition=models.Q(fiber_insertion__isnull=True), name='unique_trajectory_per_chronic_provenance'), models.UniqueConstraint(fields=['provenance', 'fiber_insertion'], @@ -390,12 +390,9 @@ class Meta: def __str__(self): if self.fiber_insertion: - return "%s %s/%s" % \ - (self.get_provenance_display(), str(self.session), self.fiber_insertion.name) + return f"{self.get_provenance_display()} {self.session}/{self.fiber_insertion.name}" elif self.chronic_fiber_insertion: - return "%s %s/%s" % \ - (self.get_provenance_display(), self.chronic_fiber_insertion.subject.nickname, - self.chronic_fiber_insertion.name) + return f"{self.get_provenance_display()} {self.chronic_fiber_insertion.subject.nickname}/{self.chronic_fiber_insertion.name}" else: return super().__str__() From b514f268f54aa96cdf9d34685654c149b327bb07 Mon Sep 17 00:00:00 2001 From: Georg Raiser Date: Fri, 19 Dec 2025 12:05:15 +0530 Subject: [PATCH 5/7] bugfix --- alyx/experiments/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alyx/experiments/models.py b/alyx/experiments/models.py index 140ff9acd..946bec3eb 100644 --- a/alyx/experiments/models.py +++ b/alyx/experiments/models.py @@ -382,7 +382,7 @@ class Meta: constraints = [ models.UniqueConstraint(fields=['provenance', 'chronic_fiber_insertion'], condition=models.Q(fiber_insertion__isnull=True), - name='unique_trajectory_per_chronic_provenance'), + name='unique_fiber_trajectory_per_chronic_provenance'), models.UniqueConstraint(fields=['provenance', 'fiber_insertion'], condition=models.Q(fiber_insertion__isnull=False), name='unique_trajectory_per_provenance'), From e096f10e0b2c59a1f13e66f8388f39d836d4cfa8 Mon Sep 17 00:00:00 2001 From: Georg Raiser Date: Fri, 19 Dec 2025 12:10:59 +0530 Subject: [PATCH 6/7] bugfix --- alyx/experiments/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alyx/experiments/models.py b/alyx/experiments/models.py index 946bec3eb..aa98e0ae5 100644 --- a/alyx/experiments/models.py +++ b/alyx/experiments/models.py @@ -385,7 +385,7 @@ class Meta: name='unique_fiber_trajectory_per_chronic_provenance'), models.UniqueConstraint(fields=['provenance', 'fiber_insertion'], condition=models.Q(fiber_insertion__isnull=False), - name='unique_trajectory_per_provenance'), + name='unique_fiber_trajectory_per_provenance'), ] def __str__(self): From da3920f21f751f35912a420c6f4d47caaa75d0b2 Mon Sep 17 00:00:00 2001 From: Georg Raiser Date: Fri, 19 Dec 2025 13:17:09 +0530 Subject: [PATCH 7/7] bugfix --- alyx/experiments/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/alyx/experiments/models.py b/alyx/experiments/models.py index aa98e0ae5..8b3f26efc 100644 --- a/alyx/experiments/models.py +++ b/alyx/experiments/models.py @@ -422,7 +422,7 @@ class FiberTipLocation(BaseModel): y = models.FloatField(blank=True, null=True, help_text=Y_HELP_TEXT, verbose_name='y-ap (um)') z = models.FloatField(blank=True, null=True, help_text=Z_HELP_TEXT, verbose_name='z-dv (um)') brain_region = models.ForeignKey(BrainRegion, default=0, null=True, blank=True, - on_delete=models.SET_NULL, related_name='channels') + on_delete=models.SET_NULL, related_name='fiber_tip_location') fiber_trajectory_estimate = models.ForeignKey(FiberTrajectoryEstimate, null=True, blank=True, on_delete=models.CASCADE, related_name='fiber_tip_location')