This commit is contained in:
2026-05-07 11:13:51 +02:00
parent 1bc7e5eb9e
commit 1575445df4
8 changed files with 141 additions and 64 deletions
+119 -36
View File
@@ -34,7 +34,7 @@ Métriques résumé (summary) :
Created on 25 avr. 2026
@author: denis
"""
import asyncio
#import asyncio
import csv
import io
import json
@@ -42,8 +42,8 @@ import logging
import math
import os
import time
from datetime import datetime, timezone
from datetime import datetime
from typing import Optional
logger = logging.getLogger(__name__)
@@ -667,14 +667,14 @@ class ReductStoreClient:
self.bucket_name = bucket
self.quota_type = quota_type
self.quota_size = quota_size
self._client = None
self._bucket = asyncio.run(self.create_bucket())
self.entry_name = "metrics"
self._client = None
self._bucket = None
async def create_bucket(self):
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,
@@ -682,43 +682,45 @@ class ReductStoreClient:
)
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."""
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,
)
self._bucket = await self._create_bucket()
logger.info(f"ReductStore connecté : {self.url} / {self.bucket_name}")
'''
async def store_metric(
self,
record: dict,
experiment: str,
well: str,
entry_name: str = "metrics",
planarian: int = 0,
record_type: str = "metrics",
record_type: str = "frame",
uuid: str = "",
ts_us: Optional[int] = None,
):
"""Stocke un enregistrement dans ReductStore."""
"""
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.
"""
if self._bucket is None:
await self.connect()
ts_us = ts_us or int(time.time() * 1_000_000)
# 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
await self._bucket.write(
entry_name = entry_name,
entry_name = "metrics",
data = json.dumps(record).encode("utf-8"),
timestamp = ts_us,
timestamp = unique_ts,
labels = {
"experiment": experiment,
"well": well,
"planarian": str(planarian),
"record_type": record_type,
"uuid": uuid,
},
content_type = "application/json",
)
@@ -733,7 +735,7 @@ class ReductStoreClient:
experiment: str,
well: str,
planarian: int = 0,
record_type: str = "frame",
record_type: str = "metrics",
start: Optional[datetime] = None,
stop: Optional[datetime] = None,
) -> list:
@@ -756,45 +758,121 @@ class ReductStoreClient:
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:02d}_{record_type}.csv"
return os.path.join(dirpath, filename)
async def export_csv(
self,
filepath: str,
experiment: str,
well: str,
planarian: int = 0,
record_type: str = "frame",
record_type: str = "metrics",
output_dir: str = ".",
start: Optional[datetime] = None,
stop: Optional[datetime] = None,
) -> int:
"""Exporte les données vers un fichier CSV."""
) -> 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
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)
"""
records = await self.get_tracking_data(
experiment, well, planarian, record_type, start, stop)
if not records:
logger.warning(f"Aucune donnée pour {experiment}/{well}/{planarian}")
return 0
os.makedirs(os.path.dirname(os.path.abspath(filepath)), exist_ok=True)
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 len(records)
return filepath, len(records)
async def export_csv_response(
self,
experiment: str,
well: str,
planarian: int = 0,
record_type: str = "frame",
record_type: str = "metrics",
start: Optional[datetime] = None,
stop: Optional[datetime] = None,
) -> tuple:
"""Génère le contenu CSV en mémoire (pour réponse HTTP Django)."""
"""
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, planarian, record_type, start, 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")
@@ -803,7 +881,12 @@ class ReductStoreClient:
return out.getvalue(), len(records)
async def close(self):
"""Ferme la connexion ReductStore."""
if self._client:
await self._client.close()
logger.info("ReductStore déconnecté")
"""
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é")