diff --git a/bats_ai/core/admin/nabat/admin.py b/bats_ai/core/admin/nabat/admin.py index 53ef3a83..ce8da323 100644 --- a/bats_ai/core/admin/nabat/admin.py +++ b/bats_ai/core/admin/nabat/admin.py @@ -1,6 +1,8 @@ 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 ( @@ -9,6 +11,7 @@ NABatRecordingAnnotation, NABatSpectrogram, ) +from bats_ai.core.models.nabat.nabat_pulse_metadata import NABatPulseMetadata # Register models for the NaBat category @@ -88,7 +91,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..c88018fb --- /dev/null +++ b/bats_ai/core/migrations/0041_rename_grts_cell_nabatrecording_sample_frame_id_and_more.py @@ -0,0 +1,70 @@ +# Generated by Django 6.0.7 on 2026-08-05 20:08 + +from __future__ import annotations + +import django.contrib.gis.db.models.fields +from django.db import migrations, models +import django.db.models.deletion + + +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" + ), + ), + ], + options={ + "verbose_name": "NABat Pulse Metadata", + "verbose_name_plural": "NABat Pulse Metadata", + }, + ), + ] diff --git a/bats_ai/core/models/nabat/__init__.py b/bats_ai/core/models/nabat/__init__.py index a1b9c6f3..a67f8b9b 100644 --- a/bats_ai/core/models/nabat/__init__.py +++ b/bats_ai/core/models/nabat/__init__.py @@ -1,12 +1,14 @@ 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 __all__ = [ "NABatCompressedSpectrogram", + "NABatPulseMetadata", "NABatRecording", "NABatRecordingAnnotation", "NABatSpectrogram", 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..039d087b 100644 --- a/bats_ai/core/tasks/nabat/tasks.py +++ b/bats_ai/core/tasks/nabat/tasks.py @@ -9,7 +9,8 @@ from django.contrib.gis.geos import LineString, Point, Polygon import requests -from bats_ai.core.models import ProcessingTask, PulseMetadata +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, @@ -66,8 +67,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 +93,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 +122,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, ) diff --git a/bats_ai/core/views/species.py b/bats_ai/core/views/species.py index 8dee3f50..f1afc021 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: @@ -40,20 +34,29 @@ class SpeciesSchema(Schema): in_range: bool | None = None +class SpeciesQuerySchema(Schema): + grts_cell_id: int | None = None + sample_frame_id: int = DEFAULT_SAMPLE_FRAME_ID + recording_id: int | None = None + nabat: bool | None = None + + @router.get("/", response=list[SpeciesSchema], auth=None) -def get_species( - request: HttpRequest, - grts_cell_id: int | None = Query(None), - sample_frame_id: int = Query(DEFAULT_SAMPLE_FRAME_ID), - recording_id: int | None = Query(None), -): - sample_frame_id = normalize_sample_frame_id(sample_frame_id) +def get_species(request: HttpRequest, q: Query[SpeciesQuerySchema]): + grts_cell_id = q.grts_cell_id + sample_frame_id = normalize_sample_frame_id(q.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, - ) + if q.recording_id is not None: + recording = None + if not q.nabat: + recording = get_object_or_404( + Recording.objects.only("grts_cell_id", "sample_frame_id"), + pk=q.recording_id, + ) + else: + recording = get_object_or_404( + NABatRecording.objects.only("grts_cell_id", "sample_frame_id"), pk=q.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 @@