Skip to content

Commit 201cfa7

Browse files
mihowclaude
andauthored
fix(counts): refresh deployment cached counts in pipeline.save_results (#1243)
save_results() refreshed update_calculated_fields_for_events for the batch but never the parent Deployment, leaving deployment.occurrences_count and deployment.taxa_count stale until something else (a manual deployment.save) ran. Reproduced as the "Station counts for occurrences and taxa are not always getting updated" report. Adds a per-batch deployment refresh next to the existing event refresh, so it covers every result-write path (sync MLJob.process_images, async NATS process_nats_pipeline_result, and the Celery batch wrapper) and fires incrementally during long jobs instead of only at the end. Includes a regression test that exercises save_results end-to-end with a real deployment + event + image and asserts the cached counts move off zero. Verified to fail on origin/main and pass with the patch. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 938841e commit 201cfa7

2 files changed

Lines changed: 113 additions & 0 deletions

File tree

ami/ml/models/pipeline.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -995,6 +995,10 @@ def save_results(
995995
event_ids = [img.event_id for img in source_images] # type: ignore
996996
update_calculated_fields_for_events(pks=event_ids)
997997

998+
deployment_ids = {img.deployment_id for img in source_images if img.deployment_id}
999+
for deployment in Deployment.objects.filter(pk__in=deployment_ids):
1000+
deployment.update_calculated_fields(save=True)
1001+
9981002
total_time = time.time() - start_time
9991003
job_logger.info(f"Saved results from pipeline {pipeline} in {total_time:.2f} seconds")
10001004

ami/ml/tests.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
from ami.base.serializers import reverse_with_params
1111
from ami.main.models import (
1212
Classification,
13+
Deployment,
1314
Detection,
15+
Event,
1416
Project,
1517
SourceImage,
1618
SourceImageCollection,
@@ -1404,3 +1406,110 @@ def test_update_state_returns_none_when_state_genuinely_missing(self):
14041406
# Do NOT call initialize_job — the total key doesn't exist.
14051407
progress = self.manager.update_state({"img1", "img2"}, "process")
14061408
self.assertIsNone(progress)
1409+
1410+
1411+
class TestSaveResultsRefreshesDeploymentCounts(TestCase):
1412+
"""save_results must refresh Deployment cached counts, not just Event counts.
1413+
1414+
Reproduces the "Station counts for occurrences and taxa are not always
1415+
getting updated" report: prior to the fix, save_results refreshed
1416+
update_calculated_fields_for_events but never the parent Deployment, so
1417+
deployment.occurrences_count / taxa_count stayed at the pre-job value
1418+
until something else (a manual deployment.save) ran.
1419+
"""
1420+
1421+
def setUp(self):
1422+
self.project = Project.objects.create(name="Refresh Counts Project")
1423+
self.deployment = Deployment.objects.create(name="d1", project=self.project)
1424+
event_time = datetime.datetime(2026, 4, 16, 22, 0, 0)
1425+
self.event = Event.objects.create(
1426+
project=self.project,
1427+
deployment=self.deployment,
1428+
group_by="2026-04-16",
1429+
start=event_time,
1430+
end=event_time,
1431+
)
1432+
self.image = SourceImage.objects.create(
1433+
deployment=self.deployment,
1434+
project=self.project,
1435+
event=self.event,
1436+
timestamp=event_time,
1437+
path="refresh_counts_test.jpg",
1438+
)
1439+
self.collection = SourceImageCollection.objects.create(project=self.project, name="c")
1440+
self.collection.images.add(self.image)
1441+
1442+
self.pipeline = Pipeline.objects.create(name="Refresh Counts Pipeline (Random)")
1443+
self.algorithms = {
1444+
key: get_or_create_algorithm_and_category_map(val) for key, val in ALGORITHM_CHOICES.items()
1445+
}
1446+
self.pipeline.algorithms.set(
1447+
[
1448+
self.algorithms["random-detector"],
1449+
self.algorithms["random-binary-classifier"],
1450+
self.algorithms["random-species-classifier"],
1451+
]
1452+
)
1453+
1454+
self.deployment.update_calculated_fields(save=True)
1455+
self.deployment.refresh_from_db()
1456+
self.assertEqual(self.deployment.occurrences_count, 0)
1457+
self.assertEqual(self.deployment.taxa_count, 0)
1458+
1459+
def _fake_results(self):
1460+
detector = ALGORITHM_CHOICES["random-detector"]
1461+
binary_classifier = ALGORITHM_CHOICES["random-binary-classifier"]
1462+
species_classifier = ALGORITHM_CHOICES["random-species-classifier"]
1463+
assert binary_classifier.category_map and species_classifier.category_map
1464+
1465+
detection = DetectionResponse(
1466+
source_image_id=self.image.pk,
1467+
bbox=BoundingBox(x1=0.0, y1=0.0, x2=1.0, y2=1.0),
1468+
inference_time=0.1,
1469+
algorithm=AlgorithmReference(name=detector.name, key=detector.key),
1470+
timestamp=self.image.timestamp,
1471+
classifications=[
1472+
ClassificationResponse(
1473+
classification=binary_classifier.category_map.labels[0],
1474+
labels=binary_classifier.category_map.labels,
1475+
scores=[0.95],
1476+
algorithm=AlgorithmReference(name=binary_classifier.name, key=binary_classifier.key),
1477+
timestamp=self.image.timestamp,
1478+
terminal=False,
1479+
),
1480+
ClassificationResponse(
1481+
classification=species_classifier.category_map.labels[0],
1482+
labels=species_classifier.category_map.labels,
1483+
scores=[0.85],
1484+
algorithm=AlgorithmReference(name=species_classifier.name, key=species_classifier.key),
1485+
timestamp=self.image.timestamp,
1486+
terminal=True,
1487+
),
1488+
],
1489+
)
1490+
return PipelineResultsResponse(
1491+
pipeline=self.pipeline.slug,
1492+
algorithms={
1493+
detector.key: detector,
1494+
binary_classifier.key: binary_classifier,
1495+
species_classifier.key: species_classifier,
1496+
},
1497+
total_time=0.01,
1498+
source_images=[SourceImageResponse(id=self.image.pk, url=self.image.path)],
1499+
detections=[detection],
1500+
)
1501+
1502+
def test_deployment_counts_refresh_after_save_results(self):
1503+
save_results(self._fake_results())
1504+
1505+
self.deployment.refresh_from_db()
1506+
self.assertGreater(
1507+
self.deployment.occurrences_count,
1508+
0,
1509+
"Deployment.occurrences_count should reflect occurrences created by save_results",
1510+
)
1511+
self.assertGreater(
1512+
self.deployment.taxa_count,
1513+
0,
1514+
"Deployment.taxa_count should reflect taxa from occurrences created by save_results",
1515+
)

0 commit comments

Comments
 (0)