diff --git a/test_tube_scanner/.env.example b/test_tube_scanner/.env.example
index 5a9edb8..a212b9b 100644
--- a/test_tube_scanner/.env.example
+++ b/test_tube_scanner/.env.example
@@ -20,6 +20,7 @@ EXPORTS_LOCAL_PATH=/home/rpi4/exports
EXPORT_REMOTE_PATH=/mnt/exports
REDUCTSTORE_PATH=/home/rpi4/medias
CSV_EXPORT_DIR=/home/rpi4/exports/csv
+BACKUP_DIR=/home/rpi4/backup/mariadb
####
# django email
diff --git a/test_tube_scanner/backup/services.py b/test_tube_scanner/backup/services.py
new file mode 100644
index 0000000..e219eae
--- /dev/null
+++ b/test_tube_scanner/backup/services.py
@@ -0,0 +1,61 @@
+'''
+Created on 18 mai 2026
+
+@author: denis
+'''
+from pathlib import Path
+from datetime import datetime
+import subprocess
+import gzip
+import shutil
+
+from django.conf import settings
+
+BACKUP_DIR = Path(settings.BACKUP_DIR)
+
+
+def mariadb_backup():
+ BACKUP_DIR.mkdir(parents=True, exist_ok=True)
+
+ db = settings.DATABASES["default"]
+
+ db_name = db["NAME"]
+ db_user = db["USER"]
+ db_password = db["PASSWORD"]
+ db_host = db.get("HOST", "localhost")
+ db_port = str(db.get("PORT", 3306))
+
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+
+ sql_file = BACKUP_DIR / f"{db_name}_{timestamp}.sql"
+ gz_file = BACKUP_DIR / f"{db_name}_{timestamp}.sql.gz"
+
+ cmd = [
+ "mariadb-dump",
+ "--single-transaction",
+ "--quick",
+ "--routines",
+ "--triggers",
+ "-h", db_host,
+ "-P", db_port,
+ "-u", db_user,
+ f"-p{db_password}",
+ db_name,
+ ]
+
+ with open(sql_file, "wb") as f:
+ result = subprocess.run(
+ cmd,
+ stdout=f,
+ stderr=subprocess.PIPE,
+ check=True,
+ )
+
+ with open(sql_file, "rb") as f_in:
+ with gzip.open(gz_file, "wb") as f_out:
+ shutil.copyfileobj(f_in, f_out)
+
+ sql_file.unlink()
+
+ return str(gz_file)
+
diff --git a/test_tube_scanner/backup/tasks.py b/test_tube_scanner/backup/tasks.py
new file mode 100644
index 0000000..5d96064
--- /dev/null
+++ b/test_tube_scanner/backup/tasks.py
@@ -0,0 +1,18 @@
+'''
+Created on 18 mai 2026
+
+@author: denis
+'''
+from celery import shared_task
+
+from .services import mariadb_backup
+
+
+@shared_task
+def backup_mariadb_task():
+ path = mariadb_backup()
+
+ return {
+ "status": "ok",
+ "backup": path,
+ }
\ No newline at end of file
diff --git a/test_tube_scanner/home/celerymodule.py b/test_tube_scanner/home/celerymodule.py
index 05eaf15..3482f1a 100644
--- a/test_tube_scanner/home/celerymodule.py
+++ b/test_tube_scanner/home/celerymodule.py
@@ -8,3 +8,6 @@ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'home.settings')
appc = Celery('home')
appc.config_from_object('django.conf:settings', namespace='CELERY')
appc.autodiscover_tasks()
+
+worker_prefetch_multiplier = 1
+task_acks_late = True
diff --git a/test_tube_scanner/home/settings.py b/test_tube_scanner/home/settings.py
index 7a39ad7..30bbfc5 100644
--- a/test_tube_scanner/home/settings.py
+++ b/test_tube_scanner/home/settings.py
@@ -345,6 +345,7 @@ DJANGO_CELERY_BEAT_TZ_AWARE = False
CELERY_ENABLE_UTC = True
CELERY_TASK_TRACK_STARTED = True
CELERY_TASK_TIME_LIMIT = 30 * 60
+CELERY_TASK_SOFT_TIME_LIMIT = 20 * 60
CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler'
CELERY_ACCEPT_CONTENT = ['application/json']
CELERY_RESULT_SERIALIZER = 'json'
@@ -362,6 +363,8 @@ REDUCTSTORE_PORT = config('REDUCTSTORE_PORT', cast=int)
REDUCTSTORE_URL = f'http://{REDUCTSTORE_HOST}:{REDUCTSTORE_PORT}'
REDUCTSTORE_PATH = config("REDUCTSTORE_PATH")
+BACKUP_DIR = config('BACKUP_DIR')
+
## servers app
#
USER = config('USER')
diff --git a/test_tube_scanner/modules/planarian_metrics.py b/test_tube_scanner/modules/planarian_metrics.py
index 21e9fca..aadd745 100644
--- a/test_tube_scanner/modules/planarian_metrics.py
+++ b/test_tube_scanner/modules/planarian_metrics.py
@@ -687,16 +687,8 @@ class ReductStoreClient:
logger.info(f"ReductStore connecté : {self.url} / {self.bucket_name}")
'''
- async def store_metric(
- self,
- record: dict,
- experiment: str,
- well: str,
- planarian: int = 0,
- record_type: str = "frame",
- uuid: str = "",
- ts_us: Optional[int] = None,
- ):
+ 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.
@@ -705,17 +697,18 @@ class ReductStoreClient:
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"
- uuid : identifiant unique de session (permet de filtrer
- plusieurs sessions d'un même puits/expérience)
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
@@ -725,10 +718,13 @@ class ReductStoreClient:
"planarian": str(planarian),
"record_type": record_type,
}
+ """
if uuid:
- labels["uuid"] = uuid
+ labels["uuid"] = uuid"""
+
await self._bucket.write(
- entry_name = "metrics",
+ #entry_name = "metrics",
+ entry_name = uuid,
data = json.dumps(record).encode("utf-8"),
timestamp = unique_ts,
labels = labels,
@@ -740,8 +736,9 @@ class ReductStoreClient:
summary: dict,
experiment: str,
well: str,
+ uuid: str,
planarian: int = 0,
- uuid: str = "",
+ record_type: str = "summary",
):
"""
Stocke le résumé de fin de session dans ReductStore.
@@ -750,26 +747,20 @@ class ReductStoreClient:
summary : dict issu de EthoVisionMetrics.summary()
experiment : identifiant de l'expérience
well : identifiant du puits
- planarian : index du planaire
uuid : identifiant unique de session (même valeur que
- celle utilisée dans store_metric pour cette session)
+ celle utilisée dans store_metric pour cette session)
+ planarian : index du planaire
"""
- await self.store_metric(
- record = summary,
- experiment = experiment,
- well = well,
- planarian = planarian,
- record_type = "summary",
- uuid = uuid,
- )
+ 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",
- uuid: str = "",
start: Optional[datetime] = None,
stop: Optional[datetime] = None,
) -> list:
@@ -779,29 +770,31 @@ class ReductStoreClient:
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"
- uuid : filtre sur une session spécifique (optionnel —
- si vide, retourne toutes les sessions)
start, stop : plage temporelle (datetime UTC, optionnel)
"""
if self._bucket is None:
await self.connect()
- labels = {
- "experiment": experiment,
- "well": well,
- "planarian": str(planarian),
- "record_type": record_type,
+
+ when = {
+ "$and": [
+ #{"&experiment": {"$contains": experiment}},
+ #{"&well": {"$contains": well}},
+ {"&planarian": {"$contains": str(planarian)}},
+ {"&record_type": {"$contains": record_type}},
+ ]
}
- if uuid:
- labels["uuid"] = uuid
- kwargs = {"include": labels}
+ 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("metrics", **kwargs):
+ async for rec in self._bucket.query(uuid, **kwargs):
try:
records.append(json.loads(await rec.read_all()))
except Exception as e:
@@ -854,16 +847,16 @@ class ReductStoreClient:
"""
dirpath = os.path.abspath(output_dir)
os.makedirs(dirpath, exist_ok=True)
- filename = f"{experiment}_{well}_planaire{planarian:02d}_{record_type}.csv"
+ 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",
- uuid: str = "",
output_dir: str = ".",
start: Optional[datetime] = None,
stop: Optional[datetime] = None,
@@ -876,25 +869,34 @@ class ReductStoreClient:
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)
- uuid : filtre sur une session spécifique (optionnel —
- si vide, retourne toutes les sessions)
start, stop : plage temporelle (datetime UTC, optionnel)
Returns:
tuple (filepath, nb_lignes)
"""
+
+ # record, experiment, well, uuid,
records = await self.get_tracking_data(
- experiment, well, planarian, record_type, uuid, start, stop)
+ 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)
+ 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:
@@ -909,9 +911,9 @@ class ReductStoreClient:
self,
experiment: str,
well: str,
+ uuid: str,
planarian: int = 0,
record_type: str = "frame",
- uuid: str = "",
start: Optional[datetime] = None,
stop: Optional[datetime] = None,
) -> tuple:
@@ -923,7 +925,15 @@ class ReductStoreClient:
tuple (contenu_csv_str, nb_lignes)
"""
records = await self.get_tracking_data(
- experiment, well, planarian, record_type, uuid, start, stop)
+ experiment,
+ well,
+ uuid,
+ planarian=planarian,
+ record_type=record_type,
+ start=start,
+ stop=stop,
+ )
+
if not records:
return "", 0
records = self._convert_timestamps(records)
diff --git a/test_tube_scanner/planarian/export_service.py b/test_tube_scanner/planarian/export_service.py
new file mode 100644
index 0000000..a79a639
--- /dev/null
+++ b/test_tube_scanner/planarian/export_service.py
@@ -0,0 +1,68 @@
+
+import logging
+import asyncio
+
+from asgiref.sync import async_to_sync
+from django.conf import settings
+from modules.planarian_metrics import ReductStoreClient
+
+logger = logging.getLogger(__name__)
+
+
+def _get_reduct_client() -> ReductStoreClient:
+ """Instancie le client ReductStore depuis les settings Django."""
+ return ReductStoreClient(url=settings.REDUCTSTORE_URL, token=settings.REDUCTSTORE_TOKEN)
+
+
+def export_csv_sync(
+ *,
+ experiment,
+ well,
+ uuid,
+ planarian,
+ record_type,
+ start=None,
+ stop=None,
+):
+ #@async_to_sync
+ async def _run():
+ client = _get_reduct_client()
+
+ await client.connect()
+ return await client.export_csv_response(
+ experiment=experiment,
+ well=well,
+ uuid=uuid,
+ planarian=planarian,
+ record_type=record_type,
+ start=start,
+ stop=stop,
+ )
+ #return _run()
+ return asyncio.run(_run())
+
+def export_csv_file_sync(
+ *,
+ experiment,
+ well,
+ uuid,
+ planarian,
+ record_type,
+):
+ async def _run():
+ client = _get_reduct_client()
+
+ await client.connect()
+ try:
+ return await client.export_csv(
+ experiment=experiment,
+ well=well,
+ uuid=uuid,
+ planarian=planarian,
+ record_type=record_type,
+ output_dir=settings.CSV_EXPORT_DIR,
+ )
+ finally:
+ await client.close()
+ return asyncio.run(_run())
+
diff --git a/test_tube_scanner/planarian/models.py b/test_tube_scanner/planarian/models.py
index 7fa58e1..1abacd9 100644
--- a/test_tube_scanner/planarian/models.py
+++ b/test_tube_scanner/planarian/models.py
@@ -143,7 +143,8 @@ class ExperimentConfig(models.Model):
def get_session(self):
- return self.experiment_key.session_experiments.first() if self.experiment_key else None
+ se = self.experiment_key.session_experiments.first() if self.experiment_key else None
+ return se.session
def to_params_dict(self) -> dict:
diff --git a/test_tube_scanner/planarian/tasks.py b/test_tube_scanner/planarian/tasks.py
index 90edbf9..aa69eeb 100644
--- a/test_tube_scanner/planarian/tasks.py
+++ b/test_tube_scanner/planarian/tasks.py
@@ -1,54 +1,80 @@
# tasks.py
-from celery import shared_task
+from celery import shared_task, group
from celery.utils.log import get_task_logger
-from django.conf import settings
-from asgiref.sync import async_to_sync
-from modules.planarian_metrics import ReductStoreClient
-from scanner.models import SessionExperiment
+from scanner.models import SessionExperiment, get_uuid_from_session, Experiment
from .models import ExperimentConfig
-
+from .export_service import export_csv_file_sync
logger = get_task_logger(__name__)
-def _get_reduct_client() -> ReductStoreClient:
- """Instancie le client ReductStore depuis les settings Django."""
- return ReductStoreClient(url=settings.REDUCTSTORE_URL, token=settings.REDUCTSTORE_TOKEN)
+
+@shared_task(bind=True, autoretry_for=(Exception,), retry_backoff=True, retry_kwargs={"max_retries": 3},)
+def export_single_metric(self, *, experiment, well, uuid, planarian, record_type,):
+ """
+ Exporte UN fichier CSV.
+ """
+ logger.info( f"Export start {experiment} {well} {planarian} {record_type}")
+ f, n = export_csv_file_sync(
+ experiment=experiment,
+ well=well,
+ uuid=uuid,
+ planarian=planarian,
+ record_type=record_type,
+ )
+ logger.info( f"Export done {f} ({n} rows)" )
+ return {"file": f, "rows": n, }
@shared_task
-def export_experiment_metrics(experiment):
+def export_experiment_metrics_task(experiment_id):
+ experiment = Experiment.objects.filter(pk=experiment_id).first()
+ if not experiment:
+ return
- @async_to_sync
- async def _do_export(well, planarian, record_type):
- client = _get_reduct_client()
-
- await client.connect()
- try:
- f, n = await client.export_csv(
- experiment = experiment.identifier,
- well = well,
- planarian = planarian,
- record_type = record_type,
- output_dir = settings.CSV_EXPORT_DIR,
- )
- logger.warning(f"Export CSV {settings.CSV_EXPORT_DIR}/{f} done, {n} lignes")
- except Exception as e:
- logger.error(f"Erreur export CSV: {e}")
-
- experiment_configs = ExperimentConfig.objects.filter(experiment_key_id=experiment.id).order_by('well').all()
- for conf in experiment_configs:
+ jobs = []
+ configs = (ExperimentConfig.objects.filter(experiment_key_id=experiment.id).order_by("well"))
+ for conf in configs:
well = conf.well
count = conf.planarian_count
-
+ session = conf.get_session()
+ uuid = get_uuid_from_session(session.id, conf.experiment_key.multiwell.position, well, )
+
for record_type in ["frame", "summary"]:
for planarian in range(count):
- _do_export(well, planarian, record_type)
-
+ '''
+ jobs.append(
+ export_single_metric.s(
+ experiment=experiment.identifier,
+ well=well,
+ uuid=uuid,
+ planarian=planarian,
+ record_type=record_type,
+ )
+ )
+
+ '''
+ try:
+ f, n = export_csv_file_sync(
+ experiment=experiment.identifier,
+ well=well,
+ uuid=uuid,
+ planarian=planarian,
+ record_type=record_type,
+ )
+ logger.info( f"Export done {f} ({n} rows)" )
+ except Exception as e:
+ logger.exception(e)
+ '''
+ result = group(jobs).apply_async()
+ logger.info(f"{len(jobs)} exports launched group_id={result.id}")
+ return {"group_id": result.id,"jobs": len(jobs), }'''
+
+
@shared_task
-def export_session_metrics(session):
- experiments = SessionExperiment.experiment_by_session(session.id)
+def export_session_metrics_task(session_id):
+ experiments = SessionExperiment.experiment_by_session(session_id, active=False)
for experiment in experiments:
- export_experiment_metrics(experiment)
+ export_experiment_metrics_task.delay(experiment.id)
\ No newline at end of file
diff --git a/test_tube_scanner/planarian/templates/planarian/base.html b/test_tube_scanner/planarian/templates/planarian/base.html
index 2f58afa..c793344 100644
--- a/test_tube_scanner/planarian/templates/planarian/base.html
+++ b/test_tube_scanner/planarian/templates/planarian/base.html
@@ -27,6 +27,12 @@
{% trans "Exporter les metrics de l'expérience" %}
$> {{ export_csv_destination }}
+
+
+ {% trans "Exporter les metrics de la session" %}
+ $> {{ export_csv_destination }}
+
+
{% endif %}
{% endblock %}
{% endblock %}
@@ -34,6 +40,7 @@
{% block js_footer %}
{{ block.super }}