# planarian/models.py from django.db import models from django.dispatch import receiver from django.db.models.signals import post_save from django.utils.translation import gettext_lazy as _ from django.contrib.auth.models import User from scanner.models import Experiment, Well, WellPosition, Configuration class ExperimentConfig(models.Model): """ Paramètres d'une expérience PlanarianScanner. Peut être créé depuis Django admin, une vue formulaire ou un import CSV. """ author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Auteur", null=True, blank=True) # --- Identification --- idendifier = models.CharField( max_length=100, verbose_name=_("Identifiant d'expérience"), help_text=_("Ex : exp_2026_04_25_ctrl"), ) experiment = models.ForeignKey(Experiment, on_delete=models.CASCADE, related_name="experiment_well" , null=True, blank=True) well = models.ForeignKey(Well, verbose_name="Puit", on_delete=models.CASCADE, related_name="well_experiment", null=True, blank=True ) description = models.TextField( blank=True, verbose_name=_("Description"), ) created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Créé le")) # --- Calibration optique --- # px_per_mm, fps, well_radius_mm px_per_mm = models.FloatField( default=26.25, verbose_name=_("Pixels par mm"), help_text=_("Facteur de calibration optique"), ) fps = models.FloatField( default=5.0, verbose_name=_("FPS de capture"), ) well_radius_mm = models.FloatField( default=8.0, verbose_name=_("Rayon du puits (mm)"), ) # --- Seuils de mobilité EthoVision --- thresh_immobile = models.FloatField( default=0.2, verbose_name=_("Seuil Immobile (mm/s)"), ) thresh_mobile = models.FloatField( default=1.5, verbose_name=_("Seuil Mobile (mm/s)"), ) # --- Tracker --- tube_axis = models.CharField( max_length=10, default="vertical", choices=[("vertical", _("Vertical")), ("horizontal", _("Horizontal"))], verbose_name=_("Axe du tube"), ) min_area_px = models.IntegerField( default=20, verbose_name=_("Surface min détection (px²)"), ) planarian_count = models.IntegerField( default=1, verbose_name=_("Nombre de planaires"), ) # --- Thigmotactisme --- thigmotaxis_wall_dist_mm = models.FloatField( default=1.0, verbose_name=_("Distance paroi thigmotactisme (mm)"), ) # --- Phototactisme --- PHOTO_MODES = [ ("none", _("Désactivé")), ("fixed", _("Source fixe")), ("sine", _("Source sinusoïdale")), ("radial", _("Gradient radial")), ] photo_mode = models.CharField( max_length=10, default="none", choices=PHOTO_MODES, verbose_name=_("Mode phototactisme"), ) photo_strength = models.FloatField(default=0.0, verbose_name=_("Intensité phototactisme")) photo_x = models.FloatField(default=0.5, verbose_name=_("Source lumière X (0-1)")) photo_y = models.FloatField(default=0.5, verbose_name=_("Source lumière Y (0-1)")) # --- Chimiotactisme --- chemo_strength = models.FloatField(default=0.0, verbose_name=_("Intensité chimiotactisme")) chemo_x = models.FloatField(default=0.5, verbose_name=_("Nourriture X (0-1)")) chemo_y = models.FloatField(default=0.5, verbose_name=_("Nourriture Y (0-1)")) chemo_radius_mm = models.FloatField(default=2.0, verbose_name=_("Rayon nourriture (mm)")) # --- Interactions inter-individus --- avoid_radius_mm = models.FloatField(default=3.0, verbose_name=_("Rayon évitement (mm)")) aggreg_radius_mm = models.FloatField(default=6.0, verbose_name=_("Rayon agrégation (mm)")) class Meta: verbose_name = _("Configuration expérience") verbose_name_plural = _("Configurations expériences") unique_together = ("experiment", "well") ordering = ["-created_at"] def __str__(self): return f"{self.experiment} / {self.well.name}" def get_session(self): return self.experiment.session_experiments.first() if self.experiment else None def to_params_dict(self) -> dict: """Retourne un dict compatible avec ExperimentParams.""" return { "experiment": self.idendifier, "well": self.well.name, "px_per_mm": self.px_per_mm, "fps": self.fps, "well_radius_mm": self.well_radius_mm, "thresh_immobile": self.thresh_immobile, "thresh_mobile": self.thresh_mobile, "tube_axis": self.tube_axis, "min_area_px": self.min_area_px, "planarian_count": self.planarian_count, "thigmotaxis_wall_dist_mm": self.thigmotaxis_wall_dist_mm, "photo_mode": self.photo_mode, "photo_strength": self.photo_strength, "chemo_strength": self.chemo_strength, "chemo_x": self.chemo_x, "chemo_y": self.chemo_y, "chemo_radius_mm": self.chemo_radius_mm, "avoid_radius_mm": self.avoid_radius_mm, "aggreg_radius_mm": self.aggreg_radius_mm, } def save(self, *args, **kwargs): session = self.get_session() position = self.experiment.multiwell.position dte = self.experiment.multiwell.finished.isoformat() self.idendifier = f'{session}-{position}-{self.well.name}-{dte}' super().save(*args, **kwargs) @receiver(post_save, sender=ExperimentConfig) def create_well_position(sender, instance, created, **kwargs): active_well = WellPosition.active_well(instance.multiwel, instance.well) instance.px_per_mm = active_well.px_per_mm instance.well_radius_mm = instance.experiment.multiwell.diameter / 2 conf = Configuration.active_config() instance.fps = conf.video_frame_rate instance.save()