tube aligner

This commit is contained in:
2026-04-21 00:19:37 +02:00
parent 42677121e3
commit 04da5da162
24 changed files with 1644 additions and 452 deletions
+9 -3
View File
@@ -7,11 +7,16 @@ class WellAdmin(admin.ModelAdmin):
list_display = ('name', 'author',)
class ConfigurationAdmin(admin.ModelAdmin):
list_display = ('name', 'author', 'use_rpicam', 'video_width_capture', 'video_height_capture', 'video_frame_rate', 'active',)
list_display = ('name', 'author', 'use_rpicam', 'video_width_capture', 'video_height_capture', 'video_frame_rate', 'px_per_mm', 'active',)
class MultiWellAdmin(admin.ModelAdmin):
list_filter = ('author',)
list_display = ('label', 'position', 'author', 'order', 'xbase', 'ybase', 'duration', 'feed', 'active',)
list_filter = ('author', )
list_display = ('label', 'position', 'author', 'order', 'xbase', 'ybase', 'duration', 'feed', 'default', 'well_position', 'active',)
class WellPositionAdmin(admin.ModelAdmin):
list_filter = ('author', 'multiwell')
list_display = ('multiwell__position', 'well__name', 'order', 'x', 'y', 'author',)
class ObservationMultiWellDetailInline(admin.TabularInline):
model = models.ObservationMultiWellDetail
@@ -60,5 +65,6 @@ class SessionAdmin(admin.ModelAdmin):
admin.site.register(models.Configuration, ConfigurationAdmin)
admin.site.register(models.Well, WellAdmin)
admin.site.register(models.MultiWell, MultiWellAdmin)
admin.site.register(models.WellPostion, WellPositionAdmin)
admin.site.register(models.Observation, ObservationAdmin)
admin.site.register(models.Session, SessionAdmin)
+63 -3
View File
@@ -44,6 +44,8 @@ class Configuration(models.Model):
# Grbl configuration
grbl_xmax = models.FloatField(_("Grbl Xmax"), help_text=_("CNC Grbl Xmax en mm"), blank=False, default=350.0)
grbl_ymax = models.FloatField(_("Grbl Ymax"), help_text=_("CNC Grbl Ymax en mm"), blank=False, default=250.0)
px_per_mm = models.FloatField(_("Pixel / mm"), help_text=_('Rapport pixel / déplacement en pixel/mm'), blank=False, default=2.5)
# camera configuration
use_rpicam = models.BooleanField(_("Utiliser rpicam"), help_text=_("Par défaaut. Sinon USB webcam"), default=True)
webcam_device_index = models.PositiveSmallIntegerField(_("Index de la webcam"), help_text=_("Index de la webcam (0, 1, ...) si présente"), default=2)
@@ -57,7 +59,7 @@ class Configuration(models.Model):
calibration_default_multiwell = models.CharField(_("Multi-puits de calibration par défaut"), help_text=_("Position du multi-puits de calibration par défaut"), max_length=8, choices=MULTIWELL_POSITION, default='HG')
calibration_default_feed = models.PositiveIntegerField(_("Vitesse de calibration"), help_text=_("Vitesse de déplacement pour la calibration en mm/mn"), default=1000)
calibration_default_step = models.FloatField(_("Pas de calibration"), help_text=_("Pas de déplacement pour la calibration en mm"), default=1.0)
calibration_default_duration = models.FloatField(_("Duruée calibration"), help_text=_("Durée de pose entre chaque puits en s"), default=3.0)
active = models.BooleanField(_("Actif"), default=False)
class Meta:
@@ -80,15 +82,19 @@ class Well(models.Model):
def __str__(self):
return f'{self.name}'
class MultiWell(models.Model):
label = models.CharField(_("Label"), help_text=_("Label du multi-puit"), max_length=100, null=True, blank=True)
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True)
position = models.CharField(_("Position"), help_text=_('Position du multi-puits sur la table'), unique=True, max_length=8, choices=MULTIWELL_POSITION, null=True, blank=False)
default = models.BooleanField(_("Par défaut"), help_text=_('Multi-puit par défaut'), default=False)
cols = models.PositiveSmallIntegerField(_("Colonnes"), help_text=_('Nombre de colonnes'), blank=False, default=6)
rows = models.PositiveSmallIntegerField(_("Lignes"), help_text=_('Nombre de lignes'), blank=False, default=4)
rows = models.PositiveSmallIntegerField(_("Lignes"), help_text=_('Nombre de lignes'), blank=False, default=4)
diameter = models.FloatField(_("Diamètre"), help_text=_('Diamètre des tubes en mm'), blank=False, default=16.0)
row_def = models.CharField(_("Définition"), help_text=_('Définition des lignes'), max_length=16, null=True, blank=False, default="A,B,C,D")
row_order = models.CharField(_("Ordre"), help_text=_('Ordre de lecture en serpentin'), max_length=16, null=True, blank=False, default="D,C,B,A")
row_order = models.CharField(_("Ordre ligne"), help_text=_('Ordre ligne de puit. Lecture en serpentin dans le sens des +- X'), max_length=16, null=True, blank=False, default="D,C,B,A")
order = models.PositiveSmallIntegerField(_("Ordre"), help_text=_('Ordre de lecture du multi-puit'), blank=False, default=0)
duration = models.PositiveIntegerField(_("Durée"), help_text=_('Durée du film en secondes'), blank=False, default=120)
@@ -98,10 +104,14 @@ class MultiWell(models.Model):
dx = models.FloatField(_("Pas X"), help_text=_('Pas ou interval sur X en mm'), blank=False, default=19.5)
dy = models.FloatField(_("Pas Y"), help_text=_('Pas ou interval sur Y en mm'), blank=False, default=19.5)
feed = models.PositiveIntegerField(_("Vitesse"), help_text=_('Vitesse déplacement en mm/mn '), blank=False, default=1000)
well_position = models.BooleanField(_("Positions"), help_text=_('Positions des puits générées ?'), default=False)
active = models.BooleanField(_("Active"), default=True)
def config(self):
return dict(
position=self.position,
cols=self.cols,
rows=self.rows,
row_def=self.row_def,
@@ -139,6 +149,56 @@ class MultiWell(models.Model):
def __str__(self):
return f'{self.position}: {self.label}'
class WellPostion(models.Model):
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True)
well = models.ForeignKey(Well, verbose_name=_("Puit"), on_delete=models.SET_NULL, null=True, blank=True)
multiwell = models.ForeignKey(MultiWell, verbose_name=_("Multi-puits"), on_delete=models.SET_NULL, null=True, blank=True)
order = models.PositiveSmallIntegerField(_("Ordre"), help_text=_('Ordre de lecture du puit'), blank=False, default=0)
x = models.FloatField(_("X"), help_text=_('Axe X en mm'), blank=False, default=10.0)
y = models.FloatField(_("Y"), help_text=_('Axe Y en mm'), blank=False, default=10.0)
class Meta:
ordering = ['order']
unique_together = ["multiwell", "well"]
verbose_name = _("Position d'un puit")
verbose_name_plural = _("Position des puits")
def __str__(self):
return f'{self.multiwell.position}: {self.well.name}'
@receiver(post_save, sender=MultiWell)
def create_well_position(sender, instance, created, **kwargs):
if not instance.well_position:
row_order = instance.row_order.split(',')
n = 0
for row in range(instance.rows):
if row % 2 == 0:
cols = range(instance.cols)
else:
cols = range(instance.cols - 1, -1, -1)
for col in cols:
x = instance.xbase + col * instance.dx
y = instance.ybase + row * instance.dy
try:
name = f'{row_order[row]}{col+1}'
well = Well.objects.get(name__exact=name)
WellPostion.objects.update_or_create(
multiwell=instance,
well=well,
author=instance.author,
defaults={'order': n, 'x': round(x, 4), 'y': round(y, 4)}
)
n += 1
except:
pass
instance.well_position=True
instance.save()
class Observation(models.Model):
title = models.CharField(_("Titre de l'observation"), max_length=100, null=True, blank=False)
comment = models.TextField(_("Commentaires"), help_text=_("Descriptions de l'observations"), null=True, blank=True)
+321
View File
@@ -0,0 +1,321 @@
'''
Created on 20 avr. 2026
@author: denis
'''
import logging
import time
from django.utils.translation import gettext_lazy as _
from threading import Thread, Event
from django.utils import timezone
from django.utils.html import mark_safe
from modules import grbl
from . import models
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class WellIterator:
"""Itérateur personnalisé pour naviguer dans les Wells"""
def __init__(self, wells_queryset):
self.wells = list(wells_queryset) # Convertir en liste
self.current_index = -1
self.total_count = len(self.wells)
def __iter__(self):
"""Permet d'utiliser l'itérateur dans une boucle for"""
return self
def __next__(self):
"""Retourne l'élément suivant"""
self.current_index += 1
if self.current_index >= self.total_count:
raise StopIteration
return self.wells[self.current_index]
def next(self):
"""Méthode next() pour avancer manuellement"""
if self.current_index + 1 < self.total_count:
self.current_index += 1
return self.wells[self.current_index]
raise StopIteration("Fin de la liste atteinte")
def previous(self):
"""Méthode previous() pour revenir en arrière"""
if self.current_index > 0:
self.current_index -= 1
return self.wells[self.current_index]
raise StopIteration("Début de la liste atteint")
def seek(self, index):
"""Méthode seek() pour sauter à un index spécifique"""
if 0 <= index < self.total_count:
self.current_index = index
return self.wells[index]
raise IndexError(f"Index {index} hors limites (0-{self.total_count - 1})")
def get_current(self):
"""Retourne l'élément courant"""
if -1 < self.current_index < self.total_count:
return self.wells[self.current_index]
return None
def reset(self):
"""Réinitialise l'itérateur au début"""
self.current_index = -1
class MultiWellManager:
def __init__(self, process):
self.process = process
self.cnc_controller = process.grbl
self.stop_playing = Event()
self.well_iterator = None
self.scanner = None
self.multiwel = None
self.set_default_values()
self.set_multiwell()
def set_default_values(self, feed=None, step=None, duration=None):
self._feed = feed or self.process.conf.calibration_default_feed
self._step = step or self.process.conf.calibration_default_step
self._duration = duration or self.process.conf.calibration_default_duration
def set_multiwell(self, position=None):
if position is None:
self.multiwell = models.MultiWell.objects.filter(default=True).first()
else:
self.multiwell = models.MultiWell.by_position(position)
wells = models.WellPostion.objects.filter(multiwell_id=self.multiwell.id).order_by('order').all()
self.well_iterator = WellIterator(wells)
self.position = self.multiwell.position
self._xbase = self.multiwell.xbase
self._ybase = self.multiwell.ybase
self._dx = self.multiwell.dx
self._dy = self.multiwell.dy
return self.multiwell.config()
def multiwell_buttons(self):
multiwells = []
multiwells.append('''<div class="w3-border well-btn">''')
for w in self.well_iterator:
multiwells.append(f"""<button class="w3-button well" value="{w.order}" onclick="goto_well(this)">{w.well.name}</button>""")
multiwells.append('''</div>''')
self.well_iterator.reset()
return mark_safe("\n".join(multiwells))
def _grid_scanning_capture(self, uuid, duration):
self.process.data.uuid = uuid
self.process.data.record = True
start = time.monotonic()
while not self.stop_playing.is_set():
if time.monotonic() - start > duration:
break
self.cnc_controller.wait_for(1.0)
logger.info(f"Arrêter l'enregistrement {uuid}")
self.process.data.record = False
self.process.data.uuid = None
def _grid_scanning(self, observation, xnext=0, ynext=0):
multiwell = observation.multiwell
wells = models.WellPostion.objects.filter(multiwell_id=multiwell.id).order_by('order').all()
self.stop_playing = Event()
for w in wells:
if self.stop_playing.is_set():
break
self.cnc_controller.move_to(w.x, w.y, feed=w.multiwell.feed)
uuid = f'{self.process.data.session}-{multiwell.position}-{w.well.name}'
self._grid_scanning_capture(uuid, multiwell.duration)
logger.info(f"Scan terminé — retour à l'origine (X={xnext:.1f} Y={ynext:.1f})")
self.cnc_controller.move_to(xnext, ynext, feed=multiwell.feed*2)
def _start_scanning(self, session, observations):
xynext = []
for obs in observations:
xynext.append((obs.multiwell.xbase, obs.multiwell.ybase))
xynext.append((0, 0))
pos = 1
self.process.data.session = session.id
started = timezone.now()
for obs in observations:
obs.started = timezone.now()
obs.save()
xnext, ynext = xynext[pos]
pos +=1
self._grid_scanning(obs, xnext=xnext, ynext=ynext)
obs.finished = timezone.now()
obs.save()
session.finished = timezone.now()
session.active = False
session.scanning_task.enabled = False
session.save()
logger.info(f"==== Session {session.name} terminée à {session.finished} après {session.finished - started} secondes.")
def halt_scanning(self):
self.process.data.record = False
return self.stop_playing.set()
def scanning(self, sid):
try:
session = models.Session.objects.get(pk=sid)
observations = models.SessionObservation.observation_by_session(sid)
Thread(target=self._start_scanning, args=(session, observations, ), daemon=True).start()
except Exception as e:
print("MultiWellManager::scan error", e)
def previous_well(self):
w = self.well_iterator.previous()
self.cnc_controller.move_to(w.x, w.y, feed=w.multiwell.feed)
return {"state": "previous", "msg": f">>> ({w.x}, {w.y})"}
def next_well(self):
w = self.well_iterator.next()
self.cnc_controller.move_to(w.x, w.y, feed=w.multiwell.feed)
return {"state": "next", "msg": f">>> ({w.x}, {w.y})"}
def goto_well(self, numwell):
w = self.well_iterator.seek(numwell)
self.cnc_controller.move_to(w.x, w.y, feed=w.multiwell.feed)
return {"state": "goto", "msg": f">>> ({w.x}, {w.y})"}
def set_well_position(self):
w = self.well_iterator.get_current()
w.x, w.y = self.cnc_controller.get_mpos()
w.save()
return {"state": "well_position", "msg": f">>> saved ({w.x}, {w.y})"}
def _scanning_test(self, xnext=0, ynext=0):
self.stop_playing = Event()
for w in self.well_iterator:
if self.stop_playing.is_set():
break
self.cnc_controller.move_to(w.x, w.y, feed=w.multiwell.feed)
start = time.monotonic()
while not self.stop_playing.is_set():
if time.monotonic() - start > self.duration:
break
self.cnc_controller.wait_for(1.0)
logger.info(f"Arrêter la simulation")
self.well_iterator.reset()
logger.info(f"Scan terminé — retour à l'origine (X={xnext:.1f} Y={ynext:.1f})")
self.cnc_controller.move_to(xnext, ynext, feed=self.multiwell.feed*2)
def scan_test(self):
Thread(target=self._scanning_test, daemon=True).start()
@property
def position(self):
return self._position
@position.setter
def position(self, value):
self._position = value
@property
def duration(self):
return self._duration
@duration.setter
def duration(self, value):
self._duration = value
@property
def step(self):
return self._step
@step.setter
def step(self, value):
self._step = value
@property
def feed(self):
return self._feed
@feed.setter
def feed(self, value):
self._feed = value
@property
def xbase(self):
return self._xbase
@xbase.setter
def xbase(self, value):
self._xbase = value
@property
def ybase(self):
return self._ybase
@ybase.setter
def ybase(self, value):
self._ybase = value
@property
def dx(self):
return self._dx
@dx.setter
def dx(self, value):
self._dx = value
@property
def dy(self):
return self._dy
@dy.setter
def dy(self, value):
self._dy = value
def set_xy_step(self):
models.MultiWell.objects.filter(position__exact=self.position).update(dx=self.dx, dy=self.dy)
def set_position(self):
x, y = self.cnc_controller.get_mpos()
self.cnc_controller.wait_for(2.0)
models.MultiWell.objects.filter(position__exact=self.position).update(xbase=x, ybase=y)
self._xbase, self._ybase = x, y
def calib_toggle_debug(self):
""" Active / désactive le mode debug sur le stream."""
aligner = self.process.cam._aligner
aligner.debug = not aligner.debug
return {"state": "debug", "msg": f"Debug: {aligner.debug}"}
+131 -207
View File
@@ -4,6 +4,8 @@ import os
os.environ['OPENCV_LOG_LEVEL']="0"
os.environ['OPENCV_FFMPEG_LOGLEVEL']="0"
import cv2
import numpy as np
from django.utils.translation import gettext_lazy as _
from datetime import datetime
import time, asyncio, bisect
@@ -25,14 +27,16 @@ from modules import reductstore, grbl, utils
## camera devices
from modules.circular_crop import CircularCrop, CropStrategy
from .multiwell import MultiWellManager
from . import models
@dataclass
class ProcTag:
class ProcessData:
play: bool = True
record: bool = False
uuid: str = None
session: int = 0
frame: bytes = None
logger = get_task_logger(__name__)
redisDB = Redis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=0, decode_responses=True)
@@ -132,159 +136,12 @@ class CameraRecordManager():
asyncio.run(self.remove_uuid(uuid, start, stop, when=when))
class MultiWellManager:
def __init__(self, position, feed=None, step=None, process=None):
self.set_multiwell(position)
self._feed = feed
self._step = step
self.process = process
self.tag = process.tag
self.scanner = None
def set_multiwell(self, position):
self._position = position
self.well = models.MultiWell.by_position(position)
self._xbase = self.well.xbase
self._ybase = self.well.ybase
self._dx = self.well.dx
self._dy = self.well.dy
def _start_test(self):
self.scanner.start()
def _start(self, machine, session, observations):
xynext = []
for obs in observations:
xynext.append((obs.multiwell.xbase, obs.multiwell.ybase))
xynext.append((0, 0))
pos = 1
self.tag.session = session.id
started = timezone.now()
for obs in observations:
conf = obs.multiwell.config()
self.scanner = grbl.GridScanner(machine, process=self.process, **conf)
obs.started = timezone.now()
obs.save()
xnext, ynext = xynext[pos]
pos +=1
self.scanner.start(xnext=xnext, ynext=ynext, position=obs.multiwell.position)
obs.finished = timezone.now()
obs.save()
session.finished = timezone.now()
session.active = False
session.scanning_task.enabled = False
session.save()
logger.info(f"==== Session {session.name} terminée à {session.finished} après {session.finished - started} secondes.")
def scan_test(self, machine, duration=5.0):
conf = self.well.config()
conf['duration'] = duration
conf['feed'] = self.feed
conf['xnext'] = self._xbase
conf['ynext'] = self._ybase
self.tag.session = 0
self.scanner = grbl.GridScanner(machine, process=self.process, **conf)
Thread(target=self._start_test, daemon=True).start()
def scan(self, machine, sid):
try:
session = models.Session.objects.get(pk=sid)
observations = models.SessionObservation.observation_by_session(sid)
Thread(target=self._start, args=(machine, session, observations, ), daemon=True).start()
except Exception as e:
print("MultiWellManager::scan error", e)
def halt(self):
if self.scanner:
self.scanner.halt()
@property
def position(self):
return self._position
@position.setter
def position(self, value):
self._position = value
@property
def step(self):
return self._step
@step.setter
def step(self, value):
self._step = value
@property
def feed(self):
return self._feed
@feed.setter
def feed(self, value):
self._feed = value
@property
def xbase(self):
return self._xbase
@xbase.setter
def xbase(self, value):
self._xbase = value
@property
def ybase(self):
return self._ybase
@ybase.setter
def ybase(self, value):
self._ybase = value
@property
def dx(self):
return self._dx
@dx.setter
def dx(self, value):
self._dx = value
@property
def dy(self):
return self._dy
@dy.setter
def dy(self, value):
self._dy = value
def set_xy_step(self):
models.MultiWell.objects.filter(position__exact=self.position).update(dx=self.dx, dy=self.dy)
def set_position(self, machine):
x, y = machine.get_mpos()
machine.wait_for(2.0)
models.MultiWell.objects.filter(position__exact=self.position).update(xbase=x, ybase=y)
self._xbase, self._ybase = x, y
class ScannerProcess(Task):
'''
video_quality = settings.VIDEO_JPG_QUALITY
image_quality = settings.IMAGE_JPG_QUALITY
video_fps = settings.VIDEO_FPS
video_width = settings.VIDEO_WIDTH
video_height = settings.VIDEO_HEIGHT
crop_radius = settings.CALIBRATION_CROP_RADIUS
default_multiwell = settings.CALIBRATION_DEFAULT_MULTIWELL
default_feed = settings.CALIBRATION_DEFAULT_FEED
default_step = settings.CALIBRATION_DEFAULT_STEP'''
def __init__(self):
def __init__(self, use_tracking=False):
super().__init__()
self.use_tracking = use_tracking
self.channel_layer = get_channel_layer()
self.group = f'scanner_proc'
self.stop_event = Event()
@@ -294,7 +151,7 @@ class ScannerProcess(Task):
self.multiwel = None
self.conf = None
self.record_queue = Queue()
self.tag = ProcTag()
self.data = ProcessData()
self.manager = None
self.recordDB = CameraRecordManager(cameraDB)
@@ -313,21 +170,37 @@ class ScannerProcess(Task):
self.video_fps = self.conf.video_frame_rate
self.video_width = self.conf.video_width_capture
self.video_height = self.conf.video_height_capture
self.crop_radius = self.conf.calibration_crop_radius
self.default_multiwell = self.conf.calibration_default_multiwell
self.default_feed = self.conf.calibration_default_feed
self.default_step = self.conf.calibration_default_step
self.video_jpg_quality = [int(cv2.IMWRITE_JPEG_QUALITY), self.video_quality]
self.image_jpg_quality = [int(cv2.IMWRITE_JPEG_QUALITY), self.image_quality]
self.grbl_xmax = self.conf.grbl_xmax
self.grbl_ymax = self.conf.grbl_ymax
#self.crop = CircularCrop(radius=self.crop_radius, strategy=CropStrategy.CROP_JPEG, jpeg_quality=self.image_quality)
self.crop = self.set_crop_radius(self.crop_radius)
'''
if not self.conf.use_rpicam:
if settings.TEST_VIDEOFILE:
from modules.videofile_capture import VideoFileCapture
self.cam = VideoFileCapture(
video_file=settings.MEDIA_ROOT / 'simulation' / 'part4-5fps.mp4',
fps=self.video_fps,
width=self.video_width,
height=self.video_height,
jpeg_quality=self.video_quality,
use_tracking=self.use_tracking,
px_per_mm = self.conf.px_per_mm,
display=self._display,
video_lists=[],
)
'''
settings.MEDIA_ROOT / 'simulation' / 'part1-5fps.mp4',
settings.MEDIA_ROOT / 'simulation' / 'part2-5fps.mp4',
settings.MEDIA_ROOT / 'simulation' / 'part3-5fps.mp4',
settings.MEDIA_ROOT / 'simulation' / 'part4-5fps.mp4',
settings.MEDIA_ROOT / 'simulation' / 'part5-5fps.mp4',
]
)
'''
elif not self.conf.use_rpicam:
from modules.webcam_capture import WebcamCapture
self.cam = WebcamCapture(
device_index=self.conf.webcam_device_index,
@@ -335,6 +208,9 @@ class ScannerProcess(Task):
width=self.video_width,
height=self.video_height,
jpeg_quality=self.video_quality,
use_tracking=self.use_tracking,
px_per_mm = self.conf.px_per_mm,
display=self._display,
)
else:
from modules.picamera2_capture import PiCamera2Capture
@@ -343,27 +219,15 @@ class ScannerProcess(Task):
width=self.video_width,
height=self.video_height,
jpeg_quality=self.video_quality,
use_tracking=self.use_tracking,
px_per_mm = self.conf.px_per_mm,
display=self._display,
)
'''
from modules.videofile_capture import VideoFileCapture
self.cam = VideoFileCapture(
video_file=settings.MEDIA_ROOT / 'simulation' / 'part2-5fps.mp4',
fps=self.video_fps,
width=self.video_width,
height=self.video_height,
jpeg_quality=self.video_quality,
video_lists = [
settings.MEDIA_ROOT / 'simulation' / 'part1-5fps.mp4',
settings.MEDIA_ROOT / 'simulation' / 'part2-5fps.mp4',
settings.MEDIA_ROOT / 'simulation' / 'part3-5fps.mp4',
settings.MEDIA_ROOT / 'simulation' / 'part4-5fps.mp4',
settings.MEDIA_ROOT / 'simulation' / 'part5-5fps.mp4',
]
)
self.cam.set_frame_callback(self._on_frame)
self.cam.set_median(False)
self.cam._active_median = False
self.cam.set_circular_crop(None)
self.stop_event.clear()
self.start_services()
except Exception as e:
@@ -399,10 +263,11 @@ class ScannerProcess(Task):
self._send(**msg)
def _on_frame(self, jpeg_bytes: bytes, ts: datetime, metrics: dict) -> None:
if self.tag.record:
self.data.frame = jpeg_bytes
if self.data.record:
# record images
self.record_queue.put((self.tag.uuid, ts, jpeg_bytes, metrics))
if self.tag.play:
self.record_queue.put((self.data.uuid, ts, jpeg_bytes, metrics))
if self.data.play:
# play image
self._send(ts=ts.timestamp(), jpeg=base64.b64encode(jpeg_bytes).decode(), **metrics)
@@ -411,7 +276,7 @@ class ScannerProcess(Task):
while not self.stop_event.is_set():
try:
(uuid, ts, frame, metrics) = self.record_queue.get()
labels = dict(fps=self.video_fps, session=self.tag.session, detected="1" if metrics.get("detected") else "0")
labels = dict(fps=self.video_fps, session=self.data.session, detected="1" if metrics.get("detected") else "0")
if metrics.get("detected"):
labels.update({
"cx" : str(metrics["cx"]),
@@ -441,13 +306,7 @@ class ScannerProcess(Task):
pubsub = redisDB.pubsub()
pubsub.subscribe(self.group)
self._init_grbl()
self.manager = MultiWellManager(
self.default_multiwell,
feed=self.default_feed,
step=self.default_step,
process=self
)
self.manager = MultiWellManager(process=self)
for message in pubsub.listen():
try:
@@ -466,7 +325,7 @@ class ScannerProcess(Task):
topic = cmd.get("topic")
if topic == 'init':
self.cam.set_circular_crop(self.crop)
self.cam.set_median(is_median=False)
self.cam._active_median = False
self.grbl.go_origin(feed=self.manager.feed)
elif topic == 'scan':
@@ -474,65 +333,129 @@ class ScannerProcess(Task):
if sid == "0":
self._send(state='error', msg=str(_('La session est nulle!...')))
else:
self.cam.set_median(is_median=False)
self.manager.scan(self.grbl, sid)
self.cam._active_median = False
self.manager.scanning(sid)
elif cmd["type"]=="calibrate":
topic = cmd.get("topic")
value = cmd.get("value")
buttons = None
if topic == 'init':
self.manager.set_default_values(
feed=int(cmd.get("feed")),
step=float(cmd.get("step")),
duration=float(cmd.get("duration"))
)
position = cmd.get("position")
self.manager.set_multiwell(position)
self.cam.set_circular_crop(None)
self.cam._active_median = False
buttons = self.manager.multiwell_buttons()
if topic == 'init':
self.manager.feed = int(cmd.get("feed", self.default_feed))
self.manager.step = float(cmd.get("step", self.default_step))
position = cmd.get("position", self.default_multiwell)
if self.manager.position != position:
self.manager.set_multiwell(position)
self.cam.set_circular_crop(None)
self.cam.set_median(is_median=False)
elif topic == 'up':
self.grbl.move_relative(dy=self.manager.step, feed=self.manager.feed)
elif topic == 'down':
self.grbl.move_relative(dy=-self.manager.step, feed=self.manager.feed)
elif topic == 'right':
self.grbl.move_relative(dx=self.manager.step, feed=self.manager.feed)
elif topic == 'left':
self.grbl.move_relative(dx=-self.manager.step, feed=self.manager.feed)
elif topic == 'median':
self.cam.set_median(is_median=value)
elif topic == 'crop':
self.cam.set_circular_crop(self.crop) if value else self.cam.set_circular_crop(None)
self.cam._active_median = not self.cam._active_median
continue
elif topic == 'crop':
self.cam._active_crop = not self.cam._active_crop
self.cam.set_circular_crop(self.crop) if self.cam._active_crop else self.cam.set_circular_crop(None)
continue
elif topic == 'crop_radius':
self.conf.calibration_crop_radius=int(value)
self.crop = self.set_crop_radius(self.conf.calibration_crop_radius)
self.conf.save()
self.cam.set_circular_crop(self.crop)
continue
continue
elif topic == 'position':
self.manager.set_multiwell(value)
buttons = self.manager.multiwell_buttons()
elif topic == 'step':
self.manager.step = float(value)
elif topic == 'feed':
self.manager.feed = int(value)
elif topic == 'duration':
self.manager.duration = float(value)
elif topic == 'goto_0':
self.grbl.go_origin(feed=self.manager.feed)
self.manager.well_iterator.reset()
elif topic == 'goto_xy':
self.grbl.move_to(self.manager.xbase, self.manager.ybase, feed=self.manager.feed)
self.manager.well_iterator.reset()
elif topic == 'xy_base':
self.manager.set_position(self.grbl)
self.manager.set_position()
elif topic == 'dx':
self.manager.dx = float(value)
elif topic == 'dy':
self.manager.dy = float(value)
elif topic == 'xy_step':
self.manager.set_xy_step()
elif topic == 'test':
self.manager.scan_test(self.grbl)
pass
#self.manager.scan_test()
continue
elif topic == 'center':
#self.manager.scan_test()
dx_mm = self.cam._last_detection["offset_x_mm"]
dy_mm = self.cam._last_detection["offset_y_mm"]
self.grbl.move_to(self.grbl.x + dx_mm, self.grbl.y + dy_mm, feed=150)
msg = f"Correction CNC move_relative(dx={dx_mm:.3f}, dy={dy_mm:.3f})"
self._send(state='center', msg=msg)
continue
elif topic == 'halt':
self.manager.halt()
self.manager.halt_scanning()
continue
elif topic == 'calib_debug':
msg = self.manager.calib_toggle_debug()
self._send(**msg)
continue
elif topic == 'previous':
msg = self.manager.previous_well()
self._send(**msg)
continue
elif topic == 'next':
msg = self.manager.next_well()
self._send(**msg)
continue
elif topic == 'goto':
msg = self.manager.goto_well(int(value))
self._send(**msg)
continue
elif topic == 'set_well':
msg = self.manager.set_well_position()
self._send(**msg)
continue
self._send(
xbase=self.manager.xbase,
ybase=self.manager.ybase,
@@ -541,7 +464,8 @@ class ScannerProcess(Task):
xy=True,
dxy=True,
dx=self.manager.dx,
dy=self.manager.dy
dy=self.manager.dy,
buttons=buttons,
)
except Exception as e:
@@ -32,3 +32,17 @@
align-self: start;
grid-area: move;
}
.well {
padding: 0.2em;
}
.well-btn {
display: grid;
grid-template-columns: repeat(6, 1fr);
justify-items: center;
align-items: center;
}
@@ -9,17 +9,14 @@ class ScannerManager {
this.debug_count = 0
}
toggle_median() { this.axes = !this.axes; return this.axes; }
toggle_crop() { this.croping = !this.croping; return this.croping; }
init_controls() {
this.ts = sId("_ts");
this.cx = sId("_cx");
this.cy = sId("_cy");
this.speed_px_s = sId("_speed_px_s");
this.ts = sId("_ts");
this.cx = sId("_cx");
this.cy = sId("_cy");
this.speed_px_s = sId("_speed_px_s");
this.axial_speed = sId("_axial_speed");
this.axial_pos = sId("_axial_pos");
this.area_px = sId("_area_px");
this.axial_pos = sId("_axial_pos");
this.area_px = sId("_area_px");
this.frame_count = sId("_count");
const goto_0 = sId("_goto-0");
@@ -30,6 +27,7 @@ class ScannerManager {
const down = sId("_down");
const left = sId("_left");
const right = sId("_right");
this.duration = sId("_duration");
this.feed = sId("_feed");
this.step = sId("_step");
this.well = sId("_well");
@@ -40,34 +38,50 @@ class ScannerManager {
this.xbase = sId("_xbase");
this.ybase = sId("_ybase");
this.debug = sId("_debug");
this.well_btn = sId("_well_btn");
const test = sId("_test");
const halt = sId("_halt");
const calib_debug = sId("_calib_debug");
const calib_center = sId("_calib_center");
const previous = sId("_previous");
const next = sId("_next");
const set_well = sId("_set_well");
const median = sId("_median");
const crop = sId("_crop");
const crop_radius = sId("_crop_radius");
up.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "up" }); });
down.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "down" }); });
left.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "left" }); });
right.addEventListener('mousedown', (e) => { this._send({ type: 'calibrate', topic: "right" }); });
goto_0.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "goto_0" }); });
goto_xy.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "goto_xy" }); });
goto_0.addEventListener('click', (e) => { this.clear_buttons(); this._send({ type: 'calibrate', topic: "goto_0" }); });
goto_xy.addEventListener('click', (e) => { this.clear_buttons(); this._send({ type: 'calibrate', topic: "goto_xy" }); });
xy_base.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "xy_base" }); });
xy_step.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "xy_step" }); });
median.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "median", value: this.toggle_median() }); });
crop.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "crop", value: this.toggle_crop() }); });
calib_debug.addEventListener('click',(e) => { this._send({ type: 'calibrate', topic: "calib_debug" }); });
previous.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "previous" }); });
next.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "next" }); });
set_well.addEventListener('click',(e) => { this._send({ type: 'calibrate', topic: "set_well" }); });
median.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "median" }); });
crop.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "crop" }); });
crop_radius.addEventListener('change',(e) => { this._send({ type: 'calibrate', topic: "crop_radius", value: crop_radius.value }); });
this.well.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "position", value: e.target.value }); });
this.step.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "step", value: e.target.value }); });
this.feed.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "feed", value: e.target.value }); });
this.duration.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "duration", value: e.target.value }); });
this.dx.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "dx", value: e.target.value }); });
this.dy.addEventListener("change", (e) => { this._send({ type: 'calibrate', topic: "dy", value: e.target.value }); });
test.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "test" }); });
calib_center.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "center" }); });
halt.addEventListener('click', (e) => { this._send({ type: 'calibrate', topic: "halt" }); });
}
@@ -85,7 +99,7 @@ class ScannerManager {
if (payload.state) { this.debug.insertAdjacentHTML('afterbegin', `<li>[ ${++this.debug_count} - ${payload.state} ]: ${payload.msg}</li>`); }
if (payload.ts) { this.ts.textContent = timestampToLocalISOString(payload.ts); }
if (payload.detected) {
if (payload.detected && use_tracking) {
this.cx.textContent = payload.cx; this.cy.textContent = payload.cy;
this.speed_px_s.textContent = payload.speed_px_s;
this.axial_speed.textContent = payload.axial_speed;
@@ -93,19 +107,24 @@ class ScannerManager {
this.area_px.textContent = payload.area_px;
this.frame_count.textContent = payload.count;
}
if (payload.buttons) { this.well_btn.innerHTML = payload.buttons; }
} catch(e) { console.log(e); }
}
clear_buttons() { document.querySelectorAll('button.w3-button.well').forEach(btn => {btn.classList.remove('w3-green'); }); }
goto_well(b) { this.clear_buttons(); b.classList.add('w3-green'); this._send({ type: 'calibrate', topic: "goto", value: b.value }); }
init() {
this.axes = 0;
this.cropping = 0;
this.clear_buttons();
this._send({
type: 'calibrate',
topic: "init",
feed: this.feed.value,
step: this.step.value,
position: this.well.value
position: this.well.value,
duration: this.duration.value
});
}
start() { this._send({ type: 'scanner', topic: "start"}); }
@@ -48,7 +48,7 @@ class ScannerManager {
if (payload.xy) { this.x.textContent=payload.x.toFixed(2); this.y.textContent=payload.y.toFixed(2); }
if (payload.state) { this.debug.insertAdjacentHTML('afterbegin', `<li>[ ${++this.debug_count} - ${payload.state} ]: ${payload.msg}</li>`); }
if (payload.ts) { this.ts.textContent = timestampToLocalISOString(payload.ts); }
if (payload.detected) {
if (payload.detected && use_tracking) {
this.cx.textContent = payload.cx; this.cy.textContent = payload.cy;
this.speed_px_s.textContent = payload.speed_px_s;
this.axial_speed.textContent = payload.axial_speed;
+2 -1
View File
@@ -3,6 +3,7 @@ import asyncio
from celery import shared_task, group, chord, chain
from celery.utils.log import get_task_logger
from django.utils import timezone
from django.conf import settings
from .process import ScannerProcess, ReplayProcess
from .export_tasks import shm_download_video, export_images_zip, export_video_mp4
@@ -18,7 +19,7 @@ class ScannerTaskManager:
def start_scanner(self):
if self.scanner is None:
self.scanner = ScannerProcess()
self.scanner = ScannerProcess(use_tracking=settings.TRACKING)
self.scanner.start()
def stop_scanner(self):
@@ -1,5 +1,5 @@
{% extends 'scanner/base.html' %}
{% load i18n home_tags %}
{% load i18n home_tags scanner_tags %}
{% block styles %}
{{ block.super }}
@@ -11,15 +11,15 @@
<div class="container w3-black">
<div class="header">
<div class="w3-row w3-row-padding">
<div class="w3-col" style="width:30%">
<div class="w3-col" style="width:15%">
{% trans 'Position multi-puit' %}<br>
<select id="_well" class="w3-select">
{% for w in wells %}
<option value="{{ w.position }}" {% if w.position == 'HG' %}selected{% endif %}>{{ w }}</option>
<option value="{{ w.position }}" {% if w.position == default_position %}selected{% endif %}>{{ w }}</option>
{% endfor %}
</select>
</div>
<div class="w3-col" style="width:20%">
<div class="w3-col" style="width:15%">
<div>{% trans 'Vitesse' %}</div>
<select id="_feed" class="w3-select">
<option value="500">500 mm/mn</option>
@@ -32,7 +32,7 @@
</select>
</div>
<div class="w3-col" style="width:20%">
<div class="w3-col" style="width:15%">
<div>{% trans 'Pas' %}</div>
<select id="_step" class="w3-select">
<option value="0.1">0.1 mm</option>
@@ -50,75 +50,122 @@
<option value="50.0">50.0 mm</option>
</select>
</div>
<div class="w3-col" style="width:30%">
<div class="w3-col" style="width:15%">
<div>{% trans 'Durée' %}</div>
<select id="_duration" class="w3-select" title="{% trans 'Durée entre vidéos' %}">
<option value="1.0">1 s</option>
<option value="2.0">2 s</option>
<option value="3.0">3 s</option>
<option value="4.0">4 s</option>
<option value="5.0" selected>5 s</option>
<option value="10">10 s</option>
</select>
</div>
<div class="w3-col" style="width:40%">
<div class="w3-margin-top w3-padding w3-right">
<span id="_ts"></span><br>
</div>
</div>
</div>
</div>
<div class="move w3-padding-small w3-center">
<div class="move w3-center">
<div class="w3-row">
<div class="w3-half">{% trans 'dx' %}<br><input id="_dx" type="number" min="15.0" max="25.0" step="0.01" value=""/></div>
<div class="w3-half">{% trans 'dy' %}<br><input id="_dy" type="number" min="15.0" max="25.0" step="0.01" value=""/></div>
<div class="w3-col">
<button id="_xy-step" class="w3-button w3-warning w3-round-large w3-block w3-large">
<i class="fa-solid fa-left-right"></i> {% trans 'Définir (dx, dy)' %}
</button>
</div>
</div>
<div><button id="_up" class="w3-button w3-dark-xlight w3-round-large w3-margin-small w3-block">&#x2191; +Y {% trans 'A Droite' %}</button></div>
<div><button id="_left" class="w3-button w3-dark-xlight w3-round-large w3-margin-small w3-block">&#x2190; -X {% trans 'En bas' %}</button></div>
<div><button id="_right" class="w3-button w3-dark-xlight w3-round-large w3-margin-small w3-block">&#x2192; +X {% trans 'En haut' %}</button></div>
<div><button id="_down" class="w3-button w3-dark-xlight w3-round-large w3-margin-small w3-block">&#x2193; -Y {% trans 'A Gauche' %}</button></div>
<div class="w3-row">
<div id="_well_btn" class="w3-col"></div>
<div class="w3-half">
<button id="_previous" class="w3-button w3-light-blue w3-round-large w3-block" title="{% trans 'Précédent' %}">
<i class="fa-solid fa-circle-left w3-large"></i>
</button>
</div>
<div class="w3-half">
<button id="_next" class="w3-button w3-light-blue w3-round-large w3-block" title="{% trans 'Suivant' %}">
<i class="fa-solid fa-circle-right w3-large"></i>
</button>
</div>
<div class="w3-col">
<button id="_set_well" class="w3-button w3-warning w3-round-large w3-block">
<i class="fa-solid fa-circle-check"></i> {% trans 'Définir position' %}
</button>
</div>
<div class="w3-col">
<button id="_calib_center" class="w3-button w3-warning w3-round-large w3-block">
<i class="fa-regular fa-circle"></i> {% trans 'Centrer manuel' %}
</button>
</div>
</div>
<button id="_xy-step" class="w3-button w3-warning w3-round-large w3-block w3-large">
<i class="fa-solid fa-left-right"></i> {% trans 'Définir (dx, dy)' %}
</button>
<hr>
<div><button id="_up" class="w3-button w3-dark-xlight w3-round-large w3-margin-small w3-block">&#x2191; +Y {% trans 'En haut' %}</button></div>
<div><button id="_left" class="w3-button w3-dark-xlight w3-round-large w3-margin-small w3-block">&#x2190; -X {% trans 'A gauches' %}</button></div>
<div><button id="_right" class="w3-button w3-dark-xlight w3-round-large w3-margin-small w3-block">&#x2192; +X {% trans 'A droite' %}</button></div>
<div><button id="_down" class="w3-button w3-dark-xlight w3-round-large w3-margin-small w3-block">&#x2193; -Y {% trans 'En Bas' %}</button></div>
<hr>
<button id="_xy-base" class="w3-button w3-warning w3-round-large w3-margin-small w3-block">{% trans 'Définir base ' %}</button>
</div>
<div class="scan w3-center">
<div class="w3-row">
<div class="w3-half">X<br><span id="_x"></span></div>
<div class="w3-half">Y<br><span id="_y"></span></div>
</div>
<button id="_goto-0" class="w3-button w3-light-blue w3-round w3-round-large w3-margin-small w3-block">Origine (0, 0)</button>
<button id="_goto-xy" class="w3-button w3-light-blue w3-round-large w3-margin-small w3-block w3-margin-bottom">
{% trans 'Aller à la base' %}<br>(<span id="_xbase"></span>, <span id="_ybase"></span>)
</button>
<button id="_xy-base" class="w3-button w3-warning w3-round-large w3-margin-small w3-block">
<i class="fa-solid fa-circle-check"></i> {% trans 'Définir base' %}
</button>
<hr>
<button id="_median" class="w3-button w3-teal w3-round-large w3-margin-small w3-block"><i class="fa-solid fa-crosshairs"></i> {% trans 'Axes' %}</button>
<button id="_crop" class="w3-button w3-teal w3-round-large w3-margin-small w3-block w3-margin-bottom"><i class="fa-solid fa-crop"></i> {% trans 'Recadrer' %}</button>
<button id="_calib_debug" class="w3-button w3-teal w3-round-large w3-margin-small w3-block">
<i class="fa-solid fa-triangle-exclamation"></i> {% trans 'Debug' %}
</button>
<button id="_median" class="w3-button w3-teal w3-round-large w3-margin-small w3-block">
<i class="fa-solid fa-crosshairs"></i> {% trans 'Axes' %}
</button>
<button id="_crop" class="w3-button w3-teal w3-round-large w3-margin-small w3-block w3-margin-bottom">
<i class="fa-solid fa-crop"></i> {% trans 'Recadrer' %}
</button>
<span>
{% trans 'Rayon' %}: <input id="_crop_radius" type="number" min="100" max="1200" step="1" value="{{ conf.calibration_crop_radius }}" title="{% trans 'Rayon de cadrage' %}"/>
</span>
<hr>
<button id="_test" class="w3-button w3-warning w3-round-large w3-margin-small w3-block">{% trans 'Tester le cirduit' %}</button>
<button id="_halt" class="w3-button w3-red w3-round-large w3-margin-small w3-block"><i class="fa-solid fa-hand"></i> {% trans 'ARRET' %}</button>
<button id="_test" class="w3-button w3-warning w3-round-large w3-margin-small w3-block">
<i class="fa-solid fa-circle-check"></i> {% trans 'Tester le cirduit' %}
</button>
<button id="_halt" class="w3-button w3-red w3-round-large w3-margin-small w3-block">
<i class="fa-solid fa-hand"></i> {% trans 'ARRET' %}
</button>
</div>
{% include 'scanner/scan-image.html' %}
</div>
<ul id="_debug" class="w3-scroll-y" style="height: 30vh"></ul>
{% endblock %}
{% block js_footer %}
{{ block.super }}
<script src="/static/scanner/js/calibration.js"></script>
<script>
const container = sId("scan-img");
const ws_route = "{{ ws_route }}";
// ---- Point d'entrée ----
(async () => {
const manager = new ScannerManager(container);
const protocol = location.protocol === "https:" ? "wss" : "ws";
const wsUrl = `${protocol}://${location.host}/${ws_route}`;
const socket = new MetadataSocket(wsUrl);
socket.setManager(manager);
socket.connect();
manager.registerSocket(socket);
})();
const ws_route = "{{ ws_route }}";
const use_tracking = "{{ use_tracking }}" == "True";
</script>
<script src="/static/scanner/js/calibration.js"></script>
<script>
const manager = new ScannerManager(container);
const protocol = location.protocol === "https:" ? "wss" : "ws";
const wsUrl = `${protocol}://${location.host}/${ws_route}`;
const socket = new MetadataSocket(wsUrl);
socket.setManager(manager);
socket.connect();
manager.registerSocket(socket);
function goto_well(b) { manager.goto_well(b); }
</script>
{% endblock %}
@@ -43,11 +43,13 @@
{% block js_footer %}
{{ block.super }}
<script src="/static/scanner/js/main.js"></script>
<script>
const container = sId("scan-img");
const ws_route = "{{ ws_route }}";
const use_tracking = "{{ use_tracking }}" == "True";
</script>
<script src="/static/scanner/js/main.js"></script>
<script>
// ---- Point d'entrée ----
(async () => {
const manager = new ScannerManager(container);
@@ -1,5 +1,6 @@
<div class="scanner w3-row">
{% if use_tracking %}
<div class="w3-col w3-small" style="width:180px">
<div>Num: <span id="_count"></span></div>
<div>Aire: <span id="_area_px"></span></div>
@@ -10,6 +11,7 @@
<div>V.Ax: <span id="_axial_speed"></span> px/s</div>
<div>Ax pos: <span id="_axial_pos"></span></div>
</div>
{% endif %}
<div class="w3-rest">
<img id="scan-img" class="w3-image">
</div>
@@ -1,6 +1,7 @@
# encoding: utf-8
from django import template
from django.utils.html import mark_safe
from .. import models
register = template.Library()
@@ -25,3 +26,4 @@ def multiwell_cards(sid, observations):
return mark_safe("\n".join(multiwells))
+3
View File
@@ -33,11 +33,14 @@ def stats_view(request):
return JsonResponse({"error": str(e)}, status=500)
def global_context(request, **ctx):
default_multiwell = models.MultiWell.objects.filter(default=True).first()
return dict(
app_title=settings.APP_TITLE,
app_sub_title=settings.APP_SUB_TITLE,
domain_server=settings.DOMAIN_SERVER,
use_tracking=settings.TRACKING,
conf=models.Configuration.objects.filter(active=True).first(),
default_position = default_multiwell.position or 'HD',
**ctx
)