From 84df47b050b7127ba873e1042c39e2a6775018ff Mon Sep 17 00:00:00 2001 From: naglepuff Date: Wed, 5 Aug 2026 15:18:23 -0400 Subject: [PATCH 1/4] Update NABat models for batbot Also renames the grts_cell column of NABat Recordings to sample_frame_id, to match the plain Recording model. --- bats_ai/core/admin/nabat/admin.py | 34 ++++++++++++++---- ...nabatrecording_sample_frame_id_and_more.py | 35 +++++++++++++++++++ bats_ai/core/models/nabat/__init__.py | 2 ++ .../core/models/nabat/nabat_pulse_metadata.py | 21 +++++++++++ bats_ai/core/models/nabat/nabat_recording.py | 2 +- bats_ai/core/tasks/nabat/tasks.py | 23 ++++++------ 6 files changed, 97 insertions(+), 20 deletions(-) create mode 100644 bats_ai/core/migrations/0041_rename_grts_cell_nabatrecording_sample_frame_id_and_more.py create mode 100644 bats_ai/core/models/nabat/nabat_pulse_metadata.py diff --git a/bats_ai/core/admin/nabat/admin.py b/bats_ai/core/admin/nabat/admin.py index 53ef3a83..7421e516 100644 --- a/bats_ai/core/admin/nabat/admin.py +++ b/bats_ai/core/admin/nabat/admin.py @@ -1,14 +1,13 @@ from __future__ import annotations +from django import forms from django.contrib import admin +from django.contrib.gis.db import models as gis_models from django.utils.html import format_html_join -from bats_ai.core.models.nabat import ( - NABatCompressedSpectrogram, - NABatRecording, - NABatRecordingAnnotation, - NABatSpectrogram, -) +from bats_ai.core.models.nabat import (NABatCompressedSpectrogram, NABatRecording, + NABatRecordingAnnotation, NABatSpectrogram) +from bats_ai.core.models.nabat.nabat_pulse_metadata import NABatPulseMetadata # Register models for the NaBat category @@ -88,7 +87,28 @@ class NABatRecordingAdmin(admin.ModelAdmin): "comments", "recording_location", "grts_cell_id", - "grts_cell", + "sample_frame_id", ] search_fields = ["name", "recording_id", "recording_location"] list_filter = ["name", "recording_id", "recording_location"] + + +@admin.register(NABatPulseMetadata) +class NABatPulseMetadataAdmin(admin.ModelAdmin): + formfield_overrides = { + gis_models.GeometryField: { + "widget": forms.Textarea(attrs={"rows": 4, "cols": 80}), + }, + } + + list_display = [ + "nabat_recording", + "index", + "bounding_box", + "curve", + "char_freq", + "knee", + "heel", + "slopes", + ] + list_select_related = True diff --git a/bats_ai/core/migrations/0041_rename_grts_cell_nabatrecording_sample_frame_id_and_more.py b/bats_ai/core/migrations/0041_rename_grts_cell_nabatrecording_sample_frame_id_and_more.py new file mode 100644 index 00000000..772af0be --- /dev/null +++ b/bats_ai/core/migrations/0041_rename_grts_cell_nabatrecording_sample_frame_id_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 6.0.7 on 2026-08-05 18:56 + +import django.contrib.gis.db.models.fields +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0040_alter_grtscells_id'), + ] + + operations = [ + migrations.RenameField( + model_name='nabatrecording', + old_name='grts_cell', + new_name='sample_frame_id', + ), + migrations.CreateModel( + name='NABatPulseMetadata', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('index', models.IntegerField()), + ('bounding_box', django.contrib.gis.db.models.fields.PolygonField(srid=4326)), + ('contours', models.JSONField(blank=True, null=True)), + ('curve', django.contrib.gis.db.models.fields.LineStringField(blank=True, null=True, srid=4326)), + ('char_freq', django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326)), + ('knee', django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326)), + ('heel', django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326)), + ('slopes', models.JSONField(blank=True, null=True)), + ('nabat_recording', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.nabatrecording')), + ], + ), + ] diff --git a/bats_ai/core/models/nabat/__init__.py b/bats_ai/core/models/nabat/__init__.py index a1b9c6f3..85e60c67 100644 --- a/bats_ai/core/models/nabat/__init__.py +++ b/bats_ai/core/models/nabat/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations from .nabat_compressed_spectrogram import NABatCompressedSpectrogram +from .nabat_pulse_metadata import NABatPulseMetadata from .nabat_recording import NABatRecording from .nabat_recording_annotation import NABatRecordingAnnotation from .nabat_spectrogram import NABatSpectrogram @@ -10,4 +11,5 @@ "NABatRecording", "NABatRecordingAnnotation", "NABatSpectrogram", + "NABatPulseMetadata", ] diff --git a/bats_ai/core/models/nabat/nabat_pulse_metadata.py b/bats_ai/core/models/nabat/nabat_pulse_metadata.py new file mode 100644 index 00000000..f45aff6d --- /dev/null +++ b/bats_ai/core/models/nabat/nabat_pulse_metadata.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from django.contrib.gis.db import models + +from .nabat_recording import NABatRecording + + +class NABatPulseMetadata(models.Model): + nabat_recording = models.ForeignKey(NABatRecording, on_delete=models.CASCADE) + index = models.IntegerField(null=False, blank=False) + bounding_box = models.PolygonField(null=False, blank=False) + contours = models.JSONField(null=True, blank=True) + curve = models.LineStringField(null=True, blank=True) + char_freq = models.PointField(null=True, blank=True) + knee = models.PointField(null=True, blank=True) + heel = models.PointField(null=True, blank=True) + slopes = models.JSONField(null=True, blank=True) + + class Meta: + verbose_name = "NABat Pulse Metadata" + verbose_name_plural = "NABat Pulse Metadata" diff --git a/bats_ai/core/models/nabat/nabat_recording.py b/bats_ai/core/models/nabat/nabat_recording.py index 62b90489..fa4246d2 100644 --- a/bats_ai/core/models/nabat/nabat_recording.py +++ b/bats_ai/core/models/nabat/nabat_recording.py @@ -20,7 +20,7 @@ class NABatRecording(TimeStampedModel, models.Model): comments = models.TextField(blank=True, null=True) recording_location = models.GeometryField(srid=4326, blank=True, null=True) grts_cell_id = models.IntegerField(blank=True, null=True) - grts_cell = models.IntegerField(blank=True, null=True) + sample_frame_id = models.IntegerField(blank=True, null=True) public = models.BooleanField(default=False) software_name = models.TextField(blank=True, null=True) software_developer = models.TextField(blank=True, null=True) diff --git a/bats_ai/core/tasks/nabat/tasks.py b/bats_ai/core/tasks/nabat/tasks.py index 512d04b0..93256fd3 100644 --- a/bats_ai/core/tasks/nabat/tasks.py +++ b/bats_ai/core/tasks/nabat/tasks.py @@ -1,19 +1,18 @@ from __future__ import annotations import logging -from pathlib import Path import tempfile +from pathlib import Path from typing import TYPE_CHECKING +import requests from django.conf import settings from django.contrib.gis.geos import LineString, Point, Polygon -import requests -from bats_ai.core.models import ProcessingTask, PulseMetadata -from bats_ai.utils.spectrogram_utils import ( - generate_nabat_compressed_spectrogram, - generate_nabat_spectrogram, -) +from bats_ai.core.models import ProcessingTask +from bats_ai.core.models.nabat import NABatPulseMetadata +from bats_ai.utils.spectrogram_utils import (generate_nabat_compressed_spectrogram, + generate_nabat_spectrogram) if TYPE_CHECKING: from bats_ai.core.models.nabat import NABatRecording @@ -66,8 +65,8 @@ def generate_spectrograms( # noqa: C901, PLR0915 segment_index_map = {} contour_segments = compressed.get("contours", {}).get("segments", []) for segment in contour_segments: - pulse_metadata_obj, _ = PulseMetadata.objects.get_or_create( - recording=compressed_obj.recording, + pulse_metadata_obj, _ = NABatPulseMetadata.objects.get_or_create( + nabat_recording=compressed_obj.nabat_recording, index=segment["segment_index"], defaults={ "contours": segment["contours"], @@ -92,7 +91,7 @@ def generate_spectrograms( # noqa: C901, PLR0915 "heel": Point(segment["heel_ms"], segment["heel_hz"]), "slopes": segment.get("slopes"), } - # `PulseMetadata.bounding_box` is non-nullable, so always populate it + # `NABatPulseMetadata.bounding_box` is non-nullable, so always populate it # for rows not created from `compressed["contours"]`. segment_bbox = segment.get("bbox") if segment_bbox and len(segment_bbox) == 4: @@ -121,8 +120,8 @@ def generate_spectrograms( # noqa: C901, PLR0915 if not settings.BATAI_SAVE_SPECTROGRAM_CONTOURS: defaults["contours"] = [] - PulseMetadata.objects.update_or_create( - recording=compressed_obj.recording, + NABatPulseMetadata.objects.update_or_create( + nabat_recording=compressed_obj.nabat_recording, index=segment["segment_index"], defaults=defaults, ) From 5c6161ff85cbfaa9ba10f227364fb3aa70bac899 Mon Sep 17 00:00:00 2001 From: naglepuff Date: Wed, 5 Aug 2026 15:18:58 -0400 Subject: [PATCH 2/4] Allow species endpoint to work with NABat recs --- bats_ai/core/views/species.py | 26 +++++++++++---------- client/src/api/NABatApi.ts | 21 +++++++++++++++++ client/src/views/NABat/NABatSpectrogram.vue | 5 ++-- 3 files changed, 38 insertions(+), 14 deletions(-) diff --git a/bats_ai/core/views/species.py b/bats_ai/core/views/species.py index 8dee3f50..0b6f6f65 100644 --- a/bats_ai/core/views/species.py +++ b/bats_ai/core/views/species.py @@ -2,19 +2,13 @@ from typing import TYPE_CHECKING -from django.db.models import ( - BooleanField, - Case, - Exists, - OuterRef, - Value, - When, -) +from django.db.models import BooleanField, Case, Exists, OuterRef, Value, When from django.shortcuts import get_object_or_404 from ninja import Query, Router, Schema from bats_ai.core.constants import DEFAULT_SAMPLE_FRAME_ID from bats_ai.core.models import GRTSCells, Recording, Species, SpeciesRange +from bats_ai.core.models.nabat.nabat_recording import NABatRecording from bats_ai.core.utils.grts_utils import normalize_sample_frame_id if TYPE_CHECKING: @@ -46,14 +40,22 @@ def get_species( grts_cell_id: int | None = Query(None), sample_frame_id: int = Query(DEFAULT_SAMPLE_FRAME_ID), recording_id: int | None = Query(None), + nabat: bool | None = Query(None) ): sample_frame_id = normalize_sample_frame_id(sample_frame_id) if recording_id is not None: - recording = get_object_or_404( - Recording.objects.only("grts_cell_id", "sample_frame_id"), - pk=recording_id, - ) + recording = None + if not nabat: + recording = get_object_or_404( + Recording.objects.only("grts_cell_id", "sample_frame_id"), + pk=recording_id, + ) + else: + recording = get_object_or_404( + NABatRecording.objects.only("grts_cell_id", "sample_frame_id"), + pk=recording_id + ) grts_cell_id = recording.grts_cell_id sample_frame_id = normalize_sample_frame_id( recording.sample_frame_id diff --git a/client/src/api/NABatApi.ts b/client/src/api/NABatApi.ts index 4d7ffdd3..81f65835 100644 --- a/client/src/api/NABatApi.ts +++ b/client/src/api/NABatApi.ts @@ -5,6 +5,7 @@ import { type ProcessingTask, type Spectrogram, type UpdateFileAnnotation, + type Species, } from "./api"; export interface NABatRecordingCompleteResponse { @@ -85,6 +86,25 @@ async function getNABatFileAnnotations(recordingId: number) { ); } +async function getNABatSpecies({ + recordingId, + grtsCellId, + sampleFrameId, +}: { + recordingId?: number; + grtsCellId?: number; + sampleFrameId?: number; +}) { + return axiosInstance.get("/species/", { + params: { + recording_id: recordingId, + grts_cell_id: grtsCellId, + sample_frame_id: sampleFrameId, + nabat: true, + }, + }); +} + async function getNABatFileAnnotationDetails( recordingId: number, apiToken?: string, @@ -261,6 +281,7 @@ export { getNABatSpectrogramCompressed, getNABatRecordingFileAnnotations, getNABatFileAnnotations, + getNABatSpecies, getNABatFileAnnotationDetails, putNABatFileAnnotation, patchNABatFileAnnotationLocal, diff --git a/client/src/views/NABat/NABatSpectrogram.vue b/client/src/views/NABat/NABatSpectrogram.vue index 9e0e64bc..101e549a 100644 --- a/client/src/views/NABat/NABatSpectrogram.vue +++ b/client/src/views/NABat/NABatSpectrogram.vue @@ -1,9 +1,10 @@