Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,7 @@ containers/notebooks/app/.Trash-0

data/alert_samples/
data/triangulated_sequences/

# day replay runtime files
replay_control/*.json
.replay_map.jsonl
149 changes: 149 additions & 0 deletions REPLAY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# Rejouer une journée réelle sur la plateforme locale

Rejoue une journée de prod SDIS-77 (dump `~/pyronear/test/analyse77/<date>/`, jours
disponibles : 2026-07-10 à 2026-07-13) contre la stack locale, avec une **horloge
simulée pilotable** : la plateforme affiche la journée aux vraies heures (datée
d'aujourd'hui), et on avance/pause/ralentit à la demande.

## 1. Prérequis (une fois)

**Image API avec l'horloge simulée** — deux branches dans le worktree
`~/pyronear/api/pyro-api-replay-20260710`, selon la journée à rejouer (la prod a
déployé #629 entre le 10 au soir et le 12 au matin — vérifié sur les données :
la tolérance bbox de #629 est visible dans les séquences du 12) :

| Journée rejouée | Branche à builder |
|---|---|
| 2026-07-10 (et 11 matin) | `replay-clock-20260710` (avant #629 — les doublons d'alertes sont fidèles) |
| 2026-07-12, 2026-07-13 | `replay-clock-20260712` (#629+#630+#631 + patch horloge) |

```bash
cd ~/pyronear/api/pyro-api-replay-20260710
git checkout replay-clock-20260712 # ou replay-clock-20260710 selon la journée
make build # -> image pyronear/alert-api:latest
```

(Pour rejouer avec le code actuel : cherry-pick des 2 commits d'horloge sur main,
puis `make build`.)

`/etc/hosts` doit contenir `127.0.0.1 minio` (pour voir les images dans la plateforme).

## 2. Lancer la stack

```bash
cd ~/pyronear/devops/pyro-envdev
docker compose -f docker-compose.yml -f docker-compose.replay.yml up -d db minio pyro_api init_script frontend
```

Attendre que le seed se termine (`docker logs -f init` → "completed successfully").

- Plateforme : **http://localhost:8080** — compte **`test77` / `test`**
- API : http://localhost:5050/docs — superadmin `mateo`/`mateo` (cf. `.env`)

## 3. Lancer le replay interactif

```bash
python3 scripts/replay_day.py --day 2026-07-10 --ctl
```

Le script mappe les caméras/poses de prod sur l'env local, redémarre l'API sur
l'horloge pilotable, et démarre **en pause** juste avant la première détection.
Il tourne en avant-plan jusqu'à la fin de la journée (le laisser ouvert dans son
terminal, piloter depuis un autre).

## 4. Piloter (`scripts/replay_ctl.py`)

Effet immédiat, aucun redémarrage :

```bash
python3 scripts/replay_ctl.py status # heure simulée, vitesse, prochaines étapes
python3 scripts/replay_ctl.py next # ➜ étape suivante : avance rapide, atterrit
# 3 min APRÈS le début de la séquence
# (images déjà visibles), puis PAUSE
python3 scripts/replay_ctl.py next 10 # idem mais reprend la lecture à 10x
python3 scripts/replay_ctl.py goto 12h00 # avance jusqu'à 12h00 HEURE DE PARIS, puis PAUSE
python3 scripts/replay_ctl.py goto 16:50 10 # idem mais reprend à 10x en arrivant
python3 scripts/replay_ctl.py play 60 # lecture 60x (journée en ~11 min)
python3 scripts/replay_ctl.py slow 5 # ralenti 5x
python3 scripts/replay_ctl.py pause # gel de la simulation
```

Repères pour le 10/07 (heures affichées, Paris) : **12h00-12h15 feu de Barbizon**
(croix-augas-01 puis triangulation nemours-02, enchevêtrement de doublons pré-#629),
16h00-17h30 série d'alertes de l'après-midi, 18h50-19h40 cluster du soir.

On ne peut pas revenir en arrière (les données sont en base) — pour revoir un
moment, reset (§6) et `goto`.

## 5. Rejouer une autre journée (11, 12, 13/07…)

Toujours **reset d'abord** (l'ancien jour pollue les fenêtres de regroupement et la
vue live). Si on change d'époque (10-11/07 ↔ 12-13/07), rebuilder l'image sur la
bonne branche (§1) **avant** le `up`. Puis relancer le driver avec la date voulue :

```bash
cd ~/pyronear/devops/pyro-envdev
# arrêter le driver en cours (Ctrl-C dans son terminal, ou pkill)
pkill -f replay_day.py
rm -f replay_control/clock.json replay_control/steps.json
docker compose -f docker-compose.yml -f docker-compose.replay.yml down -v
docker compose -f docker-compose.yml -f docker-compose.replay.yml up -d db minio pyro_api init_script frontend
# attendre le seed: docker logs -f init -> "completed successfully"

python3 scripts/replay_day.py --day 2026-07-12 --ctl
```

Journées disponibles dans le dump : `2026-07-10`, `2026-07-11`, `2026-07-12`,
`2026-07-13`. Le pilotage (`replay_ctl.py`) est identique quel que soit le jour.

Repères (heures affichées, Paris) :
- **12/07** : 14h41 détection du feu d'Achères/Noisy sur croix-augas-02
(localisation fausse à 5,5 km via le panache, corrigée à 17h35 — alertes
multiples), après-midi très chargée 14h-17h30.
- **13/07** : 09h24 fumée sur l'axe Faisanderie (croix-augas-02), 14h32 la
séquence « détection SDIS » (azimut 222°), 19h04 confirmation moret — journée
la plus chaotique (le feu de 800 ha brûle toute la journée).

## 6. Tout remettre à zéro

```bash
cd ~/pyronear/devops/pyro-envdev
pkill -f replay_day.py
rm -f replay_control/clock.json replay_control/steps.json
docker compose -f docker-compose.yml -f docker-compose.replay.yml down -v
docker compose -f docker-compose.yml -f docker-compose.replay.yml up -d db minio pyro_api init_script frontend
```

À faire **entre deux journées rejouées** (sinon l'ancien jour pollue les fenêtres
de regroupement et la vue live).

## Comment ça marche

- L'API (patchée) lit son horloge dans `replay_control/clock.json` (monté dans le
conteneur) : segments `(real0, sim0, speed)` relus à chaud — `replay_ctl.py` ne
fait qu'ajouter des segments. Tout le pipeline (fenêtres de séquences,
triangulation, vue live « 24 h ») vit dans ce temps simulé ; les JWT restent sur
l'horloge réelle.
- `replay_day.py --ctl` poste les détections dès que l'horloge les rend « dues »,
avec **une file séquentielle par caméra** (comme en prod — la concurrence
intra-caméra crée des séquences dupliquées) ; l'avance rapide est plafonnée à
60x, sinon la file prend du retard sur l'horloge et une séquence active peut se
scinder à la frontière du saut.
- Fidélité mesurée vs prod (10/07 à 60x) : mêmes regroupements sur les événements
multi-caméras, localisations identiques à ~5 m, horodatages à ±3 s.

## Modes secondaires

- `--clock` (sans `--ctl`) : horloge fixe à `--speed`, sans pilotage (le mode du
premier test de bout en bout).
- Sans `--clock` : posts à l'heure réelle + réécriture SQL des horodatages
historiques à la fin (`--no-restore-times` pour désactiver) ; nécessite les
fenêtres réduites — obsolète, préférer `--ctl`.
- `--dry-run` : vérifie le mapping caméras/poses sans rien poster.

## Limites

- Seules les séquences validées en prod sont dans le dump (le bruit non validé
n'est pas rejoué) ; les 2 séquences > 100 détections sont tronquées à 100.
- 5 détections à bbox dégénérée `(0,0,0,0,0)` sont filtrées (l'API les rejette).
- Le gate temporel est désactivé (fail-open, fidèle au comportement du 10/07).
23 changes: 23 additions & 0 deletions docker-compose.replay.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Override for accelerated day replays (scripts/replay_day.py).
#
# Clock mode (recommended, scripts/replay_day.py --clock): the API runs with a
# simulated accelerated clock (patch on branch replay-clock-20260710 of pyro-api).
# created_at values land directly on the replayed day's hours and every
# utcnow-anchored window behaves exactly as in production — no window scaling.
# The three REPLAY_CLOCK_* variables are exported by replay_day.py --clock when
# it restarts pyro_api.
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.replay.yml up -d ...
services:
pyro_api:
environment:
- REPLAY_CLOCK_SPEED=${REPLAY_CLOCK_SPEED:-}
- REPLAY_CLOCK_SIM_ORIGIN=${REPLAY_CLOCK_SIM_ORIGIN:-}
- REPLAY_CLOCK_REAL_ORIGIN=${REPLAY_CLOCK_REAL_ORIGIN:-}
# interactive mode: piecewise clock controlled live via scripts/replay_ctl.py
- REPLAY_CLOCK_FILE=/replay/clock.json
# disable the temporal-model gate for replays (fail-open, era-faithful)
- TEMPORAL_API_URL=
volumes:
- ./replay_control:/replay:ro
Empty file added replay_control/.gitkeep
Empty file.
115 changes: 115 additions & 0 deletions scripts/live_retimer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Companion of replay_day.py: shifts replayed data to the real prod timestamps *while
the replay runs*, so the platform's historical-day page fills in live.

Rewriting a row that is still inside the API's grouping windows would break sequence
matching and triangulation, so a sequence is only retimed once it is complete (all its
detections posted) AND idle for --age seconds — past every scaled window. Alerts have
their bounds recomputed from their sequences on every pass (idempotent), so an alert's
started_at flips to the historical time as soon as its first sequence is retimed.

Run in parallel with the replay:
python3 scripts/replay_day.py --day 2026-07-10 --speed 60 --no-restore-times &
python3 scripts/live_retimer.py

Exits after a final full pass when the replay writes its end-marker.
"""

import argparse
import json
import subprocess
import time
from pathlib import Path

ENVDEV_ROOT = Path(__file__).resolve().parent.parent

CASCADE_SQL = """
UPDATE sequences s SET started_at = sub.mn, last_seen_at = sub.mx
FROM (SELECT sequence_id, MIN(created_at) mn, MAX(created_at) mx
FROM detections WHERE sequence_id IN ({seq_subquery}) GROUP BY sequence_id) sub
WHERE s.id = sub.sequence_id;
UPDATE alerts a SET started_at = sub.mn, last_seen_at = sub.mx
FROM (SELECT asq.alert_id, MIN(s.started_at) mn, MAX(s.last_seen_at) mx
FROM alerts_sequences asq JOIN sequences s ON s.id = asq.sequence_id
GROUP BY asq.alert_id) sub
WHERE a.id = sub.alert_id;
"""


def psql(sql):
return subprocess.run(
["docker", "compose", "exec", "-T", "db", "psql",
"-U", "dummy_pg_user", "-d", "dummy_pg_db", "-v", "ON_ERROR_STOP=1", "-f", "-"],
input=sql, text=True, cwd=ENVDEV_ROOT, capture_output=True,
)


def retime(entries):
values = ",".join(f"({e['id']},'{e['ts']}')" for e in entries)
ids = ",".join(str(e["id"]) for e in entries)
seq_subquery = f"SELECT DISTINCT sequence_id FROM detections WHERE id IN ({ids}) AND sequence_id IS NOT NULL"
sql = (
"BEGIN;\n"
f"UPDATE detections d SET created_at = v.ts::timestamp FROM (VALUES {values}) AS v(id, ts) WHERE d.id = v.id;\n"
+ CASCADE_SQL.format(seq_subquery=seq_subquery)
+ "COMMIT;\n"
)
return psql(sql)


def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--map-file", type=Path, default=ENVDEV_ROOT / ".replay_map.jsonl")
ap.add_argument("--age", type=float, default=150.0,
help="seconds a complete sequence must stay idle before retiming (default 150)")
ap.add_argument("--interval", type=float, default=15.0)
args = ap.parse_args()

while not args.map_file.exists():
time.sleep(1)

done_marker = False
retimed_seqs = set()
print(f"⏱ live retimer démarré (age {args.age:g}s, passe toutes les {args.interval:g}s)")
while True:
by_seq = {}
for line in args.map_file.read_text().splitlines():
try:
e = json.loads(line)
except json.JSONDecodeError:
continue
if e.get("done"):
done_marker = True
continue
by_seq.setdefault(e["seq"], []).append(e)

now = time.time()
batch, batch_seqs = [], []
for seq, entries in by_seq.items():
if seq in retimed_seqs:
continue
complete = len(entries) >= entries[0]["total"]
idle = now - max(e["posted"] for e in entries)
if done_marker or (complete and idle >= args.age):
batch.extend(entries)
batch_seqs.append(seq)

if batch:
proc = retime(batch)
if proc.returncode != 0:
print(f" ✗ retiming KO: {proc.stderr.strip()[:300]}")
else:
retimed_seqs.update(batch_seqs)
first = min(e["ts"] for e in batch)[11:19]
last = max(e["ts"] for e in batch)[11:19]
print(f" ✓ {len(batch_seqs)} séquence(s) re-datée(s) ({len(batch)} détections, {first}->{last} UTC)"
f" — total {len(retimed_seqs)} séquences", flush=True)

if done_marker:
print("🏁 replay terminé, dernière passe faite — retimer stoppé")
break
time.sleep(args.interval)


if __name__ == "__main__":
main()
64 changes: 64 additions & 0 deletions scripts/replay_clock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Shared helpers for the file-driven replay clock (clock.json segments)."""

import json
from datetime import datetime, timezone
from pathlib import Path

ENVDEV_ROOT = Path(__file__).resolve().parent.parent
CONTROL_DIR = ENVDEV_ROOT / "replay_control"
CLOCK_FILE = CONTROL_DIR / "clock.json"
STEPS_FILE = CONTROL_DIR / "steps.json"


def real_utcnow():
return datetime.now(timezone.utc).replace(tzinfo=None)


def read_segments():
try:
data = json.loads(CLOCK_FILE.read_text())
except (OSError, json.JSONDecodeError):
return []
segs = [
(datetime.fromisoformat(s["real0"]), datetime.fromisoformat(s["sim0"]), float(s["speed"]))
for s in data.get("segments", [])
]
return sorted(segs, key=lambda s: s[0])


def write_segments(segments):
CONTROL_DIR.mkdir(exist_ok=True)
payload = {
"segments": [
{"real0": r.isoformat(), "sim0": s.isoformat(), "speed": sp} for r, s, sp in segments
]
}
tmp = CLOCK_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(payload, indent=1))
tmp.replace(CLOCK_FILE)


def sim_now(segments=None, now=None):
segments = read_segments() if segments is None else segments
now = now or real_utcnow()
if not segments:
return now, 1.0
active = None
for seg in segments:
if seg[0] <= now:
active = seg
else:
break
if active is None:
return segments[0][1], 0.0
real0, sim0, speed = active
return sim0 + (now - real0) * speed, speed


def append_segment(sim0, speed, real0=None):
"""Anchor a new segment at `real0` (default: now) starting from sim time `sim0`."""
segments = read_segments()
real0 = real0 or real_utcnow()
segments = [s for s in segments if s[0] < real0]
segments.append((real0, sim0, float(speed)))
write_segments(segments)
Loading
Loading