Files
2026-05-19 10:53:51 +02:00

956 lines
37 KiB
Python

"""
modules/planarian_metrics.py
Intégration des métriques EthoVision XT + comportementales dans PlanarianScanner.
Métriques par frame :
Mobilité : velocity, distance, moving, mobility_state
Thigmo : dist_to_wall_mm, near_wall
Photo : dist_to_light_mm, heading_to_light_deg, fleeing_light
Chemo : dist_to_food_mm, heading_to_food_deg, approaching_food, in_food_zone
Social : nearest_neighbour_mm, in_avoid_zone, in_aggreg_zone, chem_repulsion_level
Métriques résumé (summary) :
Mobilité : movedCenter_pointTotal_mm, velocity_mean_mm_s, durations par état
Thigmo : thigmotaxis_pct_time_near_wall
Photo : photo_pct_time_fleeing, photo_mean_dist_mm, photo_latency_s
Chemo : chemo_pct_time_approaching, chemo_pct_time_in_zone,
chemo_latency_s, chemo_mean_dist_mm
Social : social_pct_time_avoiding, social_pct_time_aggregating,
social_mean_nn_mm, social_contact_events
Created on 25 avr. 2026
@author: denis
"""
import csv
import io
import json
import logging
import math
import os
import time
from datetime import datetime, timezone
from typing import Optional
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constantes EthoVision (seuils de mobilité par défaut)
# ---------------------------------------------------------------------------
THRESH_IMMOBILE_DEFAULT = 0.2 # en-dessous : Immobile (mm/s)
THRESH_MOBILE_DEFAULT = 1.5 # entre les deux : Mobile, au-delà : Highly mobile
STATE_IMMOBILE = "Immobile"
STATE_MOBILE = "Mobile"
STATE_HIGH_MOBILE = "Highly mobile"
# Paramètres comportementaux (défauts)
BEHAVIOUR_DEFAULTS = {
# Thigmotactisme
"thigmotaxis_wall_dist_mm": 1.0,
# Phototactisme
"photo_mode": "none",
"photo_strength": 0.0,
"photo_x": 0.5,
"photo_y": 0.5,
"photo_flee_angle_deg": 90.0, # angle max tête/source pour considérer "fuite"
# Chimiotactisme
"chemo_strength": 0.0,
"chemo_x": 0.5,
"chemo_y": 0.5,
"chemo_radius_mm": 2.0,
"chemo_approach_angle_deg": 90.0, # angle max tête/nourriture pour considérer "approche"
# Interactions inter-individus
"avoid_radius_mm": 3.0,
"aggreg_radius_mm": 6.0,
}
# ---------------------------------------------------------------------------
# Helpers géométriques
# ---------------------------------------------------------------------------
def _angle_between_deg(vx1: float, vy1: float, vx2: float, vy2: float) -> float:
"""
Calcule l'angle en degrés entre deux vecteurs 2D.
Retourne 0.0 si l'un des vecteurs est nul.
Args:
vx1, vy1 : premier vecteur
vx2, vy2 : second vecteur
Returns:
angle en degrés [0, 180]
"""
n1 = math.sqrt(vx1**2 + vy1**2)
n2 = math.sqrt(vx2**2 + vy2**2)
if n1 < 1e-9 or n2 < 1e-9:
return 0.0
cos_a = max(-1.0, min(1.0, (vx1 * vx2 + vy1 * vy2) / (n1 * n2)))
return math.degrees(math.acos(cos_a))
def _heading_to_target_deg(
cx: float, cy: float,
tx: float, ty: float,
dx: float, dy: float,
) -> float:
"""
Calcule l'angle entre la direction de déplacement et le vecteur vers une cible.
Args:
cx, cy : position courante
tx, ty : position cible
dx, dy : vecteur de déplacement (cx - prev_cx, cy - prev_cy)
Returns:
angle en degrés [0, 180] — 0 = va droit vers la cible, 180 = fuit
"""
to_target_x = tx - cx
to_target_y = ty - cy
return _angle_between_deg(dx, dy, to_target_x, to_target_y)
# ---------------------------------------------------------------------------
# Classe EthoVisionMetrics
# ---------------------------------------------------------------------------
class EthoVisionMetrics:
"""
Calcule et accumule toutes les métriques comportementales compatibles
EthoVision XT à partir des données brutes de PlanarianTracker.
Métriques calculées :
- Mobilité EthoVision (distance, vitesse, états Immobile/Mobile/Très mobile)
- Thigmotactisme (distance paroi, % temps près du bord)
- Phototactisme (distance source, orientation, % fuite, latence)
- Chimiotactisme (distance nourriture, % approche, % zone, latence)
- Interactions inter-individus (voisin le plus proche, évitement,
agrégation, répulsion chimique, événements de contact)
Une instance par planaire suivi.
Usage :
metrics = EthoVisionMetrics(px_per_mm=26.25, fps=10, behaviour={...})
for frame in capture:
raw = tracker.process(frame, ts)
record = metrics.update(
raw,
well_radius_mm = 8.0,
arena_center_px = (250, 250),
photo_source_px = (100, 100),
others_pos_mm = [(x1,y1), (x2,y2)],
chem_level = 0.3,
)
await client.store_metric(record, ...)
summary = metrics.summary()
"""
def __init__(
self,
px_per_mm: float,
fps: float,
thresh_immobile: float = THRESH_IMMOBILE_DEFAULT,
thresh_mobile: float = THRESH_MOBILE_DEFAULT,
behaviour: Optional[dict] = None,
):
"""
Args:
px_per_mm : facteur de conversion pixels → mm
fps : fréquence de capture (images/s)
thresh_immobile : seuil vitesse Immobile/Mobile (mm/s)
thresh_mobile : seuil vitesse Mobile/Très mobile (mm/s)
behaviour : dict de paramètres comportementaux (cf. BEHAVIOUR_DEFAULTS)
"""
self.px_per_mm = px_per_mm
self.fps = fps
self.dt = 1.0 / fps
self.thresh_immobile = thresh_immobile
self.thresh_mobile = thresh_mobile
self.beh = {**BEHAVIOUR_DEFAULTS, **(behaviour or {})}
# --- Accumulateurs mobilité ---
self.total_distance_mm = 0.0
self.duration_moving_s = 0.0
self.duration_stopped_s = 0.0
self.frame_count = 0
self._mob_counts = {STATE_IMMOBILE: 0, STATE_MOBILE: 0, STATE_HIGH_MOBILE: 0}
self._mob_durations = {STATE_IMMOBILE: 0.0, STATE_MOBILE: 0.0, STATE_HIGH_MOBILE: 0.0}
self._current_state = None
# --- Accumulateurs thigmotactisme ---
self._near_wall_frames = 0
# --- Accumulateurs phototactisme ---
self._flee_frames = 0 # frames en fuite
self._photo_dist_sum = 0.0 # somme distances source
self._photo_dist_count = 0
self._photo_latency_s = None # temps avant 1ère fuite (s)
# --- Accumulateurs chimiotactisme ---
self._approach_frames = 0 # frames en approche nourriture
self._in_zone_frames = 0 # frames dans la zone nourriture
self._chemo_dist_sum = 0.0
self._chemo_dist_count = 0
self._chemo_latency_s = None # temps avant 1ère entrée zone (s)
# --- Accumulateurs interactions inter-individus ---
self._avoid_frames = 0 # frames en zone d'évitement
self._aggreg_frames = 0 # frames en zone d'agrégation
self._nn_sum = 0.0 # somme distances voisin le plus proche
self._nn_count = 0
self._contact_events = 0 # transitions False→True de in_avoid_zone
self._prev_in_avoid = False
# --- Position précédente (vecteur de déplacement) ---
self._prev_cx_mm = None
self._prev_cy_mm = None
self._prev_ts = None
# ------------------------------------------------------------------ #
# Helpers internes
# ------------------------------------------------------------------ #
def _px_to_mm(self, px: float) -> float:
"""Convertit des pixels en millimètres."""
return px / self.px_per_mm
def _classify(self, v: float) -> str:
"""Classifie la vitesse en état de mobilité EthoVision."""
if v <= self.thresh_immobile:
return STATE_IMMOBILE
elif v <= self.thresh_mobile:
return STATE_MOBILE
return STATE_HIGH_MOBILE
def _elapsed_s(self) -> float:
"""Temps écoulé depuis le début de la session (s)."""
return self.frame_count * self.dt
# ------------------------------------------------------------------ #
# Méthode principale
# ------------------------------------------------------------------ #
def update(
self,
raw: dict,
well_radius_mm: float = 8.0,
arena_center_px: tuple = (250, 250),
photo_source_px: Optional[tuple] = None,
others_pos_mm: Optional[list] = None,
chem_level: float = 0.0,
) -> dict:
"""
Calcule toutes les métriques comportementales pour une frame.
Args:
raw : dict brut de PlanarianTracker.process()
clés : detected, cx, cy, speed_px_s, ts
well_radius_mm : rayon du puits en mm
arena_center_px : centre de l'arène en pixels (cx, cy)
photo_source_px : position de la source lumineuse en pixels (ou None)
others_pos_mm : liste de (x_mm, y_mm) des autres planaires
chem_level : concentration chimique locale [0-1] (depuis ChemicalMap)
Returns:
dict complet prêt pour ReductStore
"""
self.frame_count += 1
ts = raw.get("timestamp", time.time())
if not raw.get("detected", False):
self.duration_stopped_s += self.dt
state = self._current_state or STATE_IMMOBILE
self._mob_durations[state] += self.dt
return {"timestamp": ts, "detected": False}
# --- Position en mm (relative au centre de l'arène) ---
cx_px = raw["cx"] - arena_center_px[0]
cy_px = raw["cy"] - arena_center_px[1]
cx_mm = self._px_to_mm(cx_px)
cy_mm = self._px_to_mm(cy_px)
# --- Vitesse / distance ---
speed_px_s = raw.get("speed_px_s", 0.0)
velocity_mm_s = self._px_to_mm(speed_px_s)
dist_mm = velocity_mm_s * self.dt
self.total_distance_mm += dist_mm
# Vecteur de déplacement (pour calculs d'angle)
if self._prev_cx_mm is not None:
move_dx = cx_mm - self._prev_cx_mm
move_dy = cy_mm - self._prev_cy_mm
else:
move_dx, move_dy = 0.0, 0.0
# --- Mobilité ---
is_moving = velocity_mm_s > self.thresh_immobile
if is_moving:
self.duration_moving_s += self.dt
else:
self.duration_stopped_s += self.dt
new_state = self._classify(velocity_mm_s)
if new_state != self._current_state:
self._mob_counts[new_state] += 1
self._current_state = new_state
self._mob_durations[new_state] += self.dt
# ================================================================
# THIGMOTACTISME
# ================================================================
well_radius_px = well_radius_mm * self.px_per_mm
dist_center_px = math.sqrt(cx_px**2 + cy_px**2)
dist_wall_mm = self._px_to_mm(well_radius_px - dist_center_px)
near_wall_thr = self.beh.get("thigmotaxis_wall_dist_mm", 1.0)
is_near_wall = dist_wall_mm < near_wall_thr
if is_near_wall:
self._near_wall_frames += 1
# ================================================================
# PHOTOTACTISME
# ================================================================
photo_mode = self.beh.get("photo_mode", "none")
dist_light_mm = 0.0
heading_light_deg = 0.0
fleeing_light = False
if photo_mode != "none" and photo_source_px is not None:
lx_px = photo_source_px[0] - arena_center_px[0]
ly_px = photo_source_px[1] - arena_center_px[1]
lx_mm = self._px_to_mm(lx_px)
ly_mm = self._px_to_mm(ly_px)
dl = math.sqrt((cx_mm - lx_mm)**2 + (cy_mm - ly_mm)**2)
dist_light_mm = dl
self._photo_dist_sum += dl
self._photo_dist_count += 1
# Angle entre déplacement et direction vers la source
heading_light_deg = _heading_to_target_deg(
cx_mm, cy_mm, lx_mm, ly_mm, move_dx, move_dy
)
# Fuite = planaire s'éloigne de la source (angle > seuil)
flee_thr = self.beh.get("photo_flee_angle_deg", 90.0)
fleeing_light = (heading_light_deg > flee_thr) and is_moving
if fleeing_light:
self._flee_frames += 1
if self._photo_latency_s is None:
self._photo_latency_s = self._elapsed_s()
# ================================================================
# CHIMIOTACTISME
# ================================================================
chemo_x_frac = self.beh.get("chemo_x", 0.5)
chemo_y_frac = self.beh.get("chemo_y", 0.5)
chemo_r_mm = self.beh.get("chemo_radius_mm", 2.0)
chemo_strength= self.beh.get("chemo_strength", 0.0)
dist_food_mm = 0.0
heading_food_deg = 0.0
approaching_food = False
in_food_zone = False
if chemo_strength > 0.0:
# Position nourriture en mm relative au centre
fx_mm = (chemo_x_frac - 0.5) * 2.0 * well_radius_mm
fy_mm = (chemo_y_frac - 0.5) * 2.0 * well_radius_mm
df = math.sqrt((cx_mm - fx_mm)**2 + (cy_mm - fy_mm)**2)
dist_food_mm = df
self._chemo_dist_sum += df
self._chemo_dist_count += 1
in_food_zone = df <= chemo_r_mm
if in_food_zone:
self._in_zone_frames += 1
if self._chemo_latency_s is None:
self._chemo_latency_s = self._elapsed_s()
heading_food_deg = _heading_to_target_deg(
cx_mm, cy_mm, fx_mm, fy_mm, move_dx, move_dy
)
approach_thr = self.beh.get("chemo_approach_angle_deg", 90.0)
approaching_food = (heading_food_deg < approach_thr) and is_moving
if approaching_food:
self._approach_frames += 1
# ================================================================
# INTERACTIONS INTER-INDIVIDUS
# ================================================================
avoid_r_mm = self.beh.get("avoid_radius_mm", 3.0)
aggreg_r_mm = self.beh.get("aggreg_radius_mm", 6.0)
nearest_nn_mm = float("inf")
in_avoid_zone = False
in_aggreg_zone = False
if others_pos_mm:
for ox_mm, oy_mm in others_pos_mm:
d = math.sqrt((cx_mm - ox_mm)**2 + (cy_mm - oy_mm)**2)
if d < nearest_nn_mm:
nearest_nn_mm = d
if nearest_nn_mm < avoid_r_mm:
in_avoid_zone = True
self._avoid_frames += 1
elif nearest_nn_mm < aggreg_r_mm:
in_aggreg_zone = True
self._aggreg_frames += 1
self._nn_sum += nearest_nn_mm
self._nn_count += 1
# Événement de contact : transition vers zone d'évitement
if in_avoid_zone and not self._prev_in_avoid:
self._contact_events += 1
else:
nearest_nn_mm = 0.0
self._prev_in_avoid = in_avoid_zone
# --- Mise à jour position précédente ---
self._prev_cx_mm = cx_mm
self._prev_cy_mm = cy_mm
self._prev_ts = ts
# ================================================================
# RECORD COMPLET
# ================================================================
return {
# Identification
"timestamp": ts,
"detected": True,
# Position (mm, relative au centre)
"x_mm": round(cx_mm, 4),
"y_mm": round(cy_mm, 4),
# Position brute pixels
"cx_px": raw["cx"],
"cy_px": raw["cy"],
# Mobilité EthoVision
"velocity_mm_s": round(velocity_mm_s, 4),
"distance_mm": round(dist_mm, 4),
"total_distance_mm": round(self.total_distance_mm, 4),
"moving": int(is_moving),
"duration_moving_s": round(self.duration_moving_s, 3),
"duration_stopped_s": round(self.duration_stopped_s, 3),
"mobility_state": new_state,
"mobility_immobile_freq": self._mob_counts[STATE_IMMOBILE],
"mobility_immobile_duration_s": round(self._mob_durations[STATE_IMMOBILE], 3),
"mobility_mobile_freq": self._mob_counts[STATE_MOBILE],
"mobility_mobile_duration_s": round(self._mob_durations[STATE_MOBILE], 3),
"mobility_high_mobile_freq": self._mob_counts[STATE_HIGH_MOBILE],
"mobility_high_mobile_duration_s": round(self._mob_durations[STATE_HIGH_MOBILE], 3),
# Thigmotactisme
"dist_to_wall_mm": round(dist_wall_mm, 4),
"near_wall": int(is_near_wall),
# Phototactisme
"dist_to_light_mm": round(dist_light_mm, 4),
"heading_to_light_deg": round(heading_light_deg, 2),
"fleeing_light": int(fleeing_light),
# Chimiotactisme
"dist_to_food_mm": round(dist_food_mm, 4),
"heading_to_food_deg": round(heading_food_deg, 2),
"approaching_food": int(approaching_food),
"in_food_zone": int(in_food_zone),
# Interactions inter-individus
"nearest_neighbour_mm": round(nearest_nn_mm, 4) if nearest_nn_mm != float("inf") else 0.0,
"in_avoid_zone": int(in_avoid_zone),
"in_aggreg_zone": int(in_aggreg_zone),
"chem_repulsion_level": round(chem_level, 4),
# Passthrough tracker
"area_px": raw.get("area_px", 0),
"axial_pos": raw.get("axial_pos", 0.0),
"axial_speed": raw.get("axial_speed", 0.0),
}
# ------------------------------------------------------------------ #
# Résumé de session
# ------------------------------------------------------------------ #
def summary(self) -> dict:
"""
Retourne le résumé global de la session.
Nomenclature EthoVision XT + métriques comportementales.
À appeler en fin d'expérience.
Returns:
dict avec toutes les métriques agrégées
"""
total_s = self.frame_count * self.dt
det = max(self._photo_dist_count, 1) # frames avec détection
return {
# Identification session
"total_frames": self.frame_count,
"total_duration_s": round(total_s, 3),
# --- Mobilité EthoVision ---
"movedCenter_pointTotal_mm": round(self.total_distance_mm, 4),
"velocity_mean_mm_s": round(
self.total_distance_mm / total_s if total_s > 0 else 0.0, 4),
"movement_moving_duration_s": round(self.duration_moving_s, 3),
"movement_not_moving_duration_s": round(self.duration_stopped_s, 3),
"mobility_immobile_frequency": self._mob_counts[STATE_IMMOBILE],
"mobility_immobile_duration_s": round(self._mob_durations[STATE_IMMOBILE], 3),
"mobility_mobile_frequency": self._mob_counts[STATE_MOBILE],
"mobility_mobile_duration_s": round(self._mob_durations[STATE_MOBILE], 3),
"mobility_highly_mobile_frequency": self._mob_counts[STATE_HIGH_MOBILE],
"mobility_highly_mobile_duration_s": round(self._mob_durations[STATE_HIGH_MOBILE], 3),
# --- Thigmotactisme ---
"thigmotaxis_pct_time_near_wall": round(
100.0 * self._near_wall_frames / max(self.frame_count, 1), 2),
# --- Phototactisme ---
"photo_pct_time_fleeing": round(
100.0 * self._flee_frames / max(self.frame_count, 1), 2),
"photo_mean_dist_mm": round(
self._photo_dist_sum / max(self._photo_dist_count, 1), 4),
"photo_latency_s": round(self._photo_latency_s, 3)
if self._photo_latency_s is not None else None,
# --- Chimiotactisme ---
"chemo_pct_time_approaching": round(
100.0 * self._approach_frames / max(self.frame_count, 1), 2),
"chemo_pct_time_in_zone": round(
100.0 * self._in_zone_frames / max(self.frame_count, 1), 2),
"chemo_latency_s": round(self._chemo_latency_s, 3)
if self._chemo_latency_s is not None else None,
"chemo_mean_dist_mm": round(
self._chemo_dist_sum / max(self._chemo_dist_count, 1), 4),
# --- Interactions inter-individus ---
"social_pct_time_avoiding": round(
100.0 * self._avoid_frames / max(self.frame_count, 1), 2),
"social_pct_time_aggregating": round(
100.0 * self._aggreg_frames / max(self.frame_count, 1), 2),
"social_mean_nn_mm": round(
self._nn_sum / max(self._nn_count, 1), 4),
"social_contact_events": self._contact_events,
}
def reset(self):
"""Réinitialise tous les accumulateurs (changement de puits ou planaire)."""
self.__init__(
self.px_per_mm, self.fps,
self.thresh_immobile, self.thresh_mobile, self.beh,
)
@staticmethod
def _empty_record(ts: float) -> dict:
"""Enregistrement vide (planaire non détecté)."""
return {"timestamp": ts, "detected": False}
# ---------------------------------------------------------------------------
# Paramètres expérimentaux
# ---------------------------------------------------------------------------
class ExperimentParams:
"""
Conteneur des paramètres d'une expérience.
Instanciable depuis un dict, un fichier CSV ou un modèle Django.
"""
REQUIRED = {"experiment", "well", "px_per_mm", "fps"}
DEFAULTS = {
"well_radius_mm": 8.0,
"thresh_immobile": THRESH_IMMOBILE_DEFAULT,
"thresh_mobile": THRESH_MOBILE_DEFAULT,
"planarian_count": 1,
"tube_axis": "vertical",
"min_area_px": 20,
"max_area_ratio": 0.10,
**BEHAVIOUR_DEFAULTS,
}
def __init__(self, data: dict):
missing = self.REQUIRED - set(data.keys())
if missing:
raise ValueError(f"Paramètres manquants : {missing}")
merged = {**self.DEFAULTS, **data}
for k, v in merged.items():
setattr(self, k, self._cast(k, v))
@staticmethod
def _cast(key: str, value):
"""Cast automatique des valeurs CSV (toutes en string) vers le bon type."""
float_keys = {
"px_per_mm", "fps", "well_radius_mm", "thresh_immobile", "thresh_mobile",
"photo_strength", "photo_x", "photo_y", "photo_flee_angle_deg",
"chemo_strength", "chemo_x", "chemo_y", "chemo_radius_mm",
"chemo_approach_angle_deg", "thigmotaxis_wall_dist_mm",
"avoid_radius_mm", "aggreg_radius_mm", "max_area_ratio",
}
int_keys = {"planarian_count", "min_area_px"}
if key in float_keys:
return float(value)
if key in int_keys:
return int(value)
if isinstance(value, str) and value.lower() in ("true", "false"):
return value.lower() == "true"
return value
@classmethod
def from_csv_row(cls, row: dict) -> "ExperimentParams":
"""Instancie depuis une ligne de csv.DictReader."""
return cls(row)
@classmethod
def from_csv_file(cls, filepath: str) -> list:
"""Charge toutes les expériences d'un fichier CSV."""
results = []
with open(filepath, newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
try:
results.append(cls.from_csv_row(row))
except ValueError as e:
logger.warning(f"Ligne ignorée : {e}{row}")
return results
def to_dict(self) -> dict:
"""Sérialise en dict."""
return {k: getattr(self, k)
for k in {**self.DEFAULTS, **{r: None for r in self.REQUIRED}}}
def build_metrics(self) -> EthoVisionMetrics:
"""Construit l'instance EthoVisionMetrics pour ces paramètres."""
behaviour = {k: getattr(self, k) for k in BEHAVIOUR_DEFAULTS if hasattr(self, k)}
return EthoVisionMetrics(
px_per_mm = self.px_per_mm,
fps = self.fps,
thresh_immobile = self.thresh_immobile,
thresh_mobile = self.thresh_mobile,
behaviour = behaviour,
)
# ---------------------------------------------------------------------------
# Client ReductStore
# ---------------------------------------------------------------------------
class ReductStoreClient:
"""
Interface asynchrone avec ReductStore pour PlanarianScanner.
Labels : experiment | well | planarian | record_type (frame|summary)
"""
def __init__(
self,
url: str = "http://localhost:8383",
token: str = "",
bucket: str = "planarian_metrics",
quota_type=None,
quota_size=1000_000_000
):
self.url = url
self.token = token
self.bucket_name = bucket
self.quota_type = quota_type
self.quota_size = quota_size
self._client = None
self._bucket = None
async def _create_bucket(self):
from reduct import Client, BucketSettings
self._client = Client(self.url, api_token=self.token)
settings = BucketSettings(
quota_type=self.quota_type,
quota_size=self.quota_size,
exist_ok=True,
)
return await self._client.create_bucket(self.bucket_name, settings, exist_ok=True)
async def connect(self):
"""Initialise la connexion et crée le bucket si nécessaire."""
self._bucket = await self._create_bucket()
logger.info(f"ReductStore connecté : {self.url} / {self.bucket_name}")
'''
async def connect(self):
"""Initialise la connexion et crée le bucket si nécessaire."""
from reduct import Client, BucketSettings, QuotaType
self._client = Client(self.url, api_token=self.token)
self._bucket = await self._client.create_bucket(
self.bucket_name,
BucketSettings(quota_type=QuotaType.NONE),
exist_ok=True,
)
logger.info(f"ReductStore connecté : {self.url} / {self.bucket_name}")
'''
async def store_metric(self, record: dict, experiment: str, well: str, uuid: str,
planarian: int = 0, record_type: str = "frame", ts_us: Optional[int] = None, ):
"""
Stocke un enregistrement dans ReductStore.
Le timestamp est rendu unique par planaire en ajoutant l'index
du planaire comme offset sub-microseconde — évite le 409 Conflict
quand plusieurs planaires du même puits écrivent dans la même frame.
Args:
uuid : identifiant unique de session (permet de filtrer
plusieurs sessions d'un même puits/expérience)
record : dict de métriques (issu de EthoVisionMetrics.update())
experiment : identifiant de l'expérience
well : identifiant du puits
planarian : index du planaire (défaut 0)
record_type : "frame" ou "summary"
ts_us : timestamp en microsecondes (défaut : maintenant)
"""
if self._bucket is None:
await self.connect()
# ts_us de base + offset planaire (0, 1, 2…) pour unicité garantie
base_ts = ts_us or int(time.time() * 1_000_000)
unique_ts = base_ts + planarian
labels = {
"experiment": experiment,
"well": well,
"planarian": str(planarian),
"record_type": record_type,
}
"""
if uuid:
labels["uuid"] = uuid"""
await self._bucket.write(
#entry_name = "metrics",
entry_name = uuid,
data = json.dumps(record).encode("utf-8"),
timestamp = unique_ts,
labels = labels,
content_type = "application/json",
)
async def store_summary(
self,
summary: dict,
experiment: str,
well: str,
uuid: str,
planarian: int = 0,
record_type: str = "summary",
):
"""
Stocke le résumé de fin de session dans ReductStore.
Args:
summary : dict issu de EthoVisionMetrics.summary()
experiment : identifiant de l'expérience
well : identifiant du puits
uuid : identifiant unique de session (même valeur que
celle utilisée dans store_metric pour cette session)
planarian : index du planaire
"""
await self.store_metric(record=summary,experiment=experiment,
well=well, uuid=uuid, planarian=planarian, record_type=record_type)
async def get_tracking_data(
self,
experiment: str,
well: str,
uuid: str,
planarian: int = 0,
record_type: str = "frame",
start: Optional[datetime] = None,
stop: Optional[datetime] = None,
) -> list:
"""
Récupère les enregistrements filtrés par labels.
Args:
experiment : identifiant de l'expérience
well : identifiant du puits
uuid : filtre sur une session spécifique (optionnel —
si vide, retourne toutes les sessions)
planarian : index du planaire
record_type : "frame" | "summary"
start, stop : plage temporelle (datetime UTC, optionnel)
"""
if self._bucket is None:
await self.connect()
when = {
"$and": [
#{"&experiment": {"$contains": experiment}},
#{"&well": {"$contains": well}},
{"&planarian": {"$contains": str(planarian)}},
{"&record_type": {"$contains": record_type}},
]
}
kwargs = {'when': when}
if start:
kwargs["start"] = int(start.timestamp() * 1_000_000)
if stop:
kwargs["stop"] = int(stop.timestamp() * 1_000_000)
records = []
async for rec in self._bucket.query(uuid, **kwargs):
try:
records.append(json.loads(await rec.read_all()))
except Exception as e:
logger.warning(f"Entrée illisible ignorée : {e}")
return records
@staticmethod
def _convert_timestamps(records: list) -> list:
"""
Convertit le champ 'timestamp' (epoch float secondes) en ISO 8601 UTC
dans chaque enregistrement.
Args:
records : liste de dicts issus de ReductStore
Returns:
nouvelle liste avec timestamp converti (originaux non modifiés)
"""
converted = []
for r in records:
row = dict(r)
ts = row.get("timestamp")
if ts is not None:
try:
row["timestamp"] = (
datetime.fromtimestamp(float(ts), tz=timezone.utc)
.strftime("%Y-%m-%dT%H:%M:%S.%f") + "Z"
)
except (ValueError, TypeError, OSError):
pass
converted.append(row)
return converted
@staticmethod
def _build_filepath(output_dir: str, experiment: str,
well: str, planarian: int, record_type: str) -> str:
"""
Construit le chemin du fichier CSV de sortie.
Nom : <experiment>_<well>_planaire<NN>_<record_type>.csv
Args:
output_dir : répertoire de sortie (créé si absent)
experiment : identifiant de l'expérience
well : identifiant du puits
planarian : index du planaire
record_type : "frame" ou "summary"
Returns:
chemin absolu du fichier CSV
"""
dirpath = os.path.abspath(output_dir)
os.makedirs(dirpath, exist_ok=True)
filename = f"{experiment}_{well}_planaire-{planarian}-{record_type}.csv"
return os.path.join(dirpath, filename)
async def export_csv(
self,
experiment: str,
well: str,
uuid: str,
planarian: int = 0,
record_type: str = "frame",
output_dir: str = ".",
start: Optional[datetime] = None,
stop: Optional[datetime] = None,
) -> tuple:
"""
Exporte les données depuis ReductStore vers un fichier CSV.
Le répertoire de sortie est choisi via output_dir.
Le champ timestamp est converti en ISO 8601 UTC.
Args:
experiment : identifiant de l'expérience
well : identifiant du puits
uuid : filtre sur une session spécifique (optionnel —
si vide, retourne toutes les sessions)
planarian : index du planaire
record_type : "frame" | "summary"
output_dir : répertoire de sortie (défaut : répertoire courant)
start, stop : plage temporelle (datetime UTC, optionnel)
Returns:
tuple (filepath, nb_lignes)
"""
# record, experiment, well, uuid,
records = await self.get_tracking_data(
experiment,
well,
uuid,
planarian=planarian,
record_type=record_type,
start=start,
stop=stop,
)
if not records:
logger.warning(f"Aucune donnée pour {experiment}/{well}/{planarian}")
return "", 0
records = self._convert_timestamps(records)
filepath = self._build_filepath(output_dir, experiment, well, planarian, record_type)
fieldnames = list(dict.fromkeys(k for r in records for k in r.keys()))
with open(filepath, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
writer.writerows(records)
logger.info(f"Export CSV : {len(records)} lignes → {filepath}")
return filepath, len(records)
async def export_csv_response(
self,
experiment: str,
well: str,
uuid: str,
planarian: int = 0,
record_type: str = "frame",
start: Optional[datetime] = None,
stop: Optional[datetime] = None,
) -> tuple:
"""
Génère le contenu CSV en mémoire (pour réponse HTTP Django).
Le champ timestamp est converti en ISO 8601 UTC.
Returns:
tuple (contenu_csv_str, nb_lignes)
"""
records = await self.get_tracking_data(
experiment,
well,
uuid,
planarian=planarian,
record_type=record_type,
start=start,
stop=stop,
)
if not records:
return "", 0
records = self._convert_timestamps(records)
fieldnames = list(dict.fromkeys(k for r in records for k in r.keys()))
out = io.StringIO()
writer = csv.DictWriter(out, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
writer.writerows(records)
return out.getvalue(), len(records)
async def close(self):
"""
Ferme la connexion ReductStore.
Note : reduct-py >= 1.x ne nécessite pas de fermeture explicite —
la méthode est conservée pour compatibilité d'interface.
"""
self._client = None
self._bucket = None
logger.info("ReductStore déconnecté")