temptours+tours

This commit is contained in:
2025-12-09 11:27:02 -05:00
commit f28439c9bb
49 changed files with 18416 additions and 0 deletions

0
engine/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

124
engine/config.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 92 KiB

1
engine/constants.py Normal file
View File

@ -0,0 +1 @@
BATCH_SIZE = 1000

74
engine/db.py Normal file
View File

@ -0,0 +1,74 @@
"""
db.py
======
Module de gestion de la connexion MySQL pour le moteur de résultats.
Ce module est séparé pour éviter de dupliquer la logique de connexion
dans plusieurs fichiers Python. Si plus tard tu veux réutiliser cette
connexion dans d'autres scripts, tu n'auras qu'à importer get_connection().
"""
import mysql.connector
from mysql.connector import Error
# ---------------------------------------------------------------------------
# CONFIGURATION DE LA BASE DE DONNÉES
# ---------------------------------------------------------------------------
# ⚠ IMPORTANT :
# - Adapte ces valeurs à TON environnement (dev / prod).
# - Idéalement, tu peux plus tard les lire à partir d'un fichier
# (ex: info_serveur.txt) pour éviter de hardcoder les mots de passe.
# ---------------------------------------------------------------------------
DB_CONFIG = {
"host": "serveur-prod.progiweb.net",
"user": "ms1reader_bd", # ajuste au besoin
"password": "X~=v]lg-l]4,T2FF", # ajuste au besoin
"database": "ms1reader_centrale",
"port": 3306
}
TABLE_NAME = "reader_lectures"
def get_connection():
"""
Crée et retourne une connexion MySQL.
Returns
-------
mysql.connector.connection_cext.CMySQLConnection
Connexion déjà ouverte vers la base configurée dans DB_CONFIG.
Raises
------
mysql.connector.Error
Si la connexion échoue (mauvais host, user, password, etc.).
"""
return mysql.connector.connect(**DB_CONFIG)
def test_connection():
"""
Petit utilitaire pour tester rapidement la connexion.
Tu peux exécuter ce fichier directement :
python -m engine.db
"""
try:
cnx = get_connection()
if cnx.is_connected():
print("[OK] Connexion MySQL réussie.")
else:
print("[ERREUR] Connexion MySQL NON établie.")
except Error as e:
print(f"[ERREUR] Mysql : {e}")
finally:
try:
if cnx and cnx.is_connected():
cnx.close()
except NameError:
# cnx n'existe pas si la connexion a échoué avant son affectation
pass
if __name__ == "__main__":
test_connection()

33
engine/engine.py Normal file
View File

@ -0,0 +1,33 @@
from mysql.connector import Error
from engine.db import get_connection
from engine.engine_components.active_courses import get_active_con_ids
from engine.engine_components.process_course import process_course
from engine.logger import log_info, log_debug, log_warn, log_error
def run_once():
try:
cnx = get_connection()
cur = cnx.cursor(dictionary=True)
con_ids = get_active_con_ids(cur)
if not con_ids:
log_info("[INFO] Aucune course active.")
return
for con_id in con_ids:
process_course(cur, cnx, con_id)
except Error as e:
log_error(f"[ERREUR MYSQL] {e}")
finally:
try:
cur.close()
cnx.close()
except:
pass
if __name__ == "__main__":
run_once()

View File

View File

@ -0,0 +1,7 @@
from typing import List
def get_active_con_ids(cur) -> List[int]:
sql = "SELECT con_id FROM reader_config WHERE rea_active = 1"
cur.execute(sql)
rows = cur.fetchall()
return [int(r["con_id"]) for r in rows]

View File

@ -0,0 +1,12 @@
from typing import Optional, Dict
def get_classement_by_tag(cur, classement_table: str, tag: str) -> Optional[Dict]:
pattern = f"%*{tag}*%"
sql = f"""
SELECT cla_info_dossard, con_id, cla_list_tag
FROM `{classement_table}`
WHERE cla_list_tag LIKE %s
LIMIT 1
"""
cur.execute(sql, (pattern,))
return cur.fetchone()

View File

@ -0,0 +1,57 @@
# engine/engine_components/course_config.py
from datetime import datetime
import pytz
from engine.logger import log_debug, log_warn
LOCAL_TZ = "America/Toronto"
def load_course_config(cur, con_id: int):
"""
Charge toutes les configs det_type = 2 (config de course)
et retourne start_time / end_time convertis en datetime LOCAL.
"""
sql = """
SELECT det_clef, det_value1
FROM reader_config_detail
WHERE det_type = 2
AND det_ref1_id = %s
"""
cur.execute(sql, (con_id,))
rows = cur.fetchall()
cfg = {}
for row in rows:
clef = row["det_clef"]
val = row["det_value1"]
cfg[clef] = val
tz = pytz.timezone(LOCAL_TZ)
start = None
end = None
# Convertir start_time si présent
if "clef_start_time" in cfg:
try:
start = tz.localize(datetime.fromtimestamp(int(cfg["clef_start_time"])))
except Exception as e:
log_warn(f"[CONFIG][CON {con_id}] start_time invalide: {e}")
# Convertir fin_time si présent
if "clef_fin_time" in cfg:
try:
end = tz.localize(datetime.fromtimestamp(int(cfg["clef_fin_time"])))
except Exception as e:
log_warn(f"[CONFIG][CON {con_id}] end_time invalide: {e}")
log_debug(f"[CONFIG COURSE] con_id={con_id} start={start} end={end}")
return {
"start_time": start,
"end_time": end,
"raw": cfg
}

View File

@ -0,0 +1,85 @@
def insert_detail_record(
cur,
cnx,
con_id: int,
dossard: str,
lecture: dict,
tp_id: int,
tp_name: str,
controller_location: str,
priority: int,
controller_name: str,
dt_local,
status=1,
reason_code=None,
reason_ref_id=None,
tour=None,
tempstour=None,
tempstour_str=None # <-- AJOUT OBLIGATOIRE
):
if isinstance(dt_local, tuple):
dt_local = dt_local[1]
detail_table = f"resultv2_{con_id}_detail"
sql = f"""
INSERT INTO `{detail_table}`
(con_id, tp_id, tp_name,
cla_info_dossard, rcourse_id,
timestamp_read,
timestamp_read_local,
timestamp_read_local_str,
raw_tag,
reader_location, controller_priority,
controller_name,
source,
status, reason_code, reason_ref_id,
tour, TempsTour, tempstour_str)
VALUES (%s, %s, %s,
%s, %s,
%s,
%s,
%s,
%s,
%s, %s,
%s,
%s,
%s, %s, %s,
%s, %s, %s)
"""
timestamp_read_local_unix = f"{dt_local.timestamp():.2f}"
timestamp_read_local_str = (
f"{dt_local.strftime('%Y-%m-%d %H:%M:%S')}."
f"{dt_local.microsecond // 10000:02d}"
)
if isinstance(tempstour, tuple):
tempstour = None
data = (
con_id,
tp_id,
tp_name,
dossard,
lecture["rcourse_id"],
lecture["chrinfo_time"],
timestamp_read_local_unix,
timestamp_read_local_str,
lecture["chrinfo_tag"],
controller_location,
priority,
controller_name,
"LIVE",
status,
reason_code,
reason_ref_id,
tour,
tempstour,
tempstour_str
)
cur.execute(sql, data)
cnx.commit()

View File

@ -0,0 +1,16 @@
def get_last_processed_read_id(cur, engine_table: str):
sql = f"SELECT last_processed_read_id FROM `{engine_table}` WHERE id = 1"
cur.execute(sql)
row = cur.fetchone()
return int(row["last_processed_read_id"]) if row else None
def update_last_processed_read_id(cur, cnx, engine_table: str, new_value: int):
sql = f"""
UPDATE `{engine_table}`
SET last_processed_read_id = %s,
updated_at = NOW()
WHERE id = 1
"""
cur.execute(sql, (new_value,))
cnx.commit()

View File

@ -0,0 +1,12 @@
from engine.constants import BATCH_SIZE
def get_new_lectures(cur, last_processed: int):
sql = """
SELECT rcourse_id, chrinfo_tag, chrinfo_time, chrinfo_location
FROM reader_lectures
WHERE rcourse_id > %s
ORDER BY rcourse_id ASC
LIMIT %s
"""
cur.execute(sql, (last_processed, BATCH_SIZE))
return cur.fetchall()

View File

@ -0,0 +1,197 @@
from engine.engine_components.tp_config import load_tp_config_for_con, resolve_tp_for_lecture
from engine.engine_components.course_config import load_course_config
from engine.engine_components.engine_state import get_last_processed_read_id, update_last_processed_read_id
from engine.engine_components.lectures import get_new_lectures
from engine.engine_components.classement import get_classement_by_tag
from engine.engine_components.detail_writer import insert_detail_record
from engine.logger import log_info, log_debug
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from decimal import Decimal
from engine.utils import format_temps_decimal
LOCAL_TZ = ZoneInfo("America/Toronto")
_last_valid_read = {} # (tp_id, dossard) -> datetime locale
def ts_to_utc(ts):
ts = Decimal(str(ts))
seconds = int(ts)
micro = int((ts - Decimal(seconds)) * Decimal(1_000_000))
return datetime.fromtimestamp(seconds, tz=timezone.utc).replace(microsecond=micro)
def _normalize_course_time(dt):
if dt is None:
return None, None
dt_utc = dt.replace(tzinfo=timezone.utc)
dt_local = dt_utc.astimezone(LOCAL_TZ)
return dt_local, dt_utc
def process_course(cur, cnx, con_id: int):
tp_config = load_tp_config_for_con(cur, con_id)
course_cfg = load_course_config(cur, con_id)
raw_start = course_cfg["start_time"]
raw_end = course_cfg["end_time"]
start_local, start_utc = _normalize_course_time(raw_start)
end_local, end_utc = _normalize_course_time(raw_end)
engine_table = f"resultv2_{con_id}_lecture"
classement_table = f"resultv2_{con_id}_classement"
# last_processed
last_processed = get_last_processed_read_id(cur, engine_table)
if last_processed is None:
last_processed = 0
log_info(f"[STATE] con_id={con_id} last_processed={last_processed}")
lectures = get_new_lectures(cur, last_processed)
log_info(f"[NEW LECTURES] con_id={con_id} count={len(lectures)}")
if not lectures:
return
max_id = last_processed
for lec in lectures:
rcourse_id = int(lec["rcourse_id"])
chr_loc = lec["chrinfo_location"]
timestamp = Decimal(lec["chrinfo_time"])
max_id = max(max_id, rcourse_id)
dt_utc = ts_to_utc(timestamp)
dt_local = dt_utc.astimezone(LOCAL_TZ)
log_debug(f"[READ] id={rcourse_id} utc={dt_utc} loc={chr_loc}")
# 1) start/end
if start_utc and dt_utc < start_utc:
log_debug(f"[REJECT START] id={rcourse_id} dt_utc={dt_utc} < start_utc={start_utc}")
continue
if end_utc and dt_utc > end_utc:
log_debug(f"[REJECT END] id={rcourse_id} dt_utc={dt_utc} > end_utc={end_utc}")
continue
status = 1
reason_code = None
reason_ref_id = None
# 2) coureur (BIB doit exister dans le classement)
tag = lec["chrinfo_tag"]
coureur = get_classement_by_tag(cur, classement_table, tag)
if not coureur:
# IMPORTANT : on ignore complètement
continue
dossard = coureur["cla_info_dossard"]
# 3) TP obligatoire
tp_info = resolve_tp_for_lecture(tp_config, con_id, chr_loc)
if not tp_info:
continue
tp_id = tp_info["tp_id"]
tp_name = tp_info["tp_name"]
prio = tp_info["priority"]
controller = tp_info["controller_name"]
det_id = tp_info["det_id"]
# 4) interval rule
interval_cfg = (
tp_config[tp_id]["config"]
.get("config_tp_interval", {})
.get("clef_temp_minimum_read_entre", [])
)
interval_sec = 0
if interval_cfg:
try:
interval_sec = int(interval_cfg[0]["value1"])
except:
interval_sec = 0
last_dt = _last_valid_read.get((tp_id, dossard))
if last_dt and interval_sec > 0:
delta = (dt_local - last_dt).total_seconds()
if delta < interval_sec:
status = 0
reason_code = "SKIP_INTERVAL"
reason_ref_id = det_id
log_debug(f"[SKIP INTERVAL] id={rcourse_id} {delta:.1f}s < {interval_sec}s")
# 5) INSERT
detail_table = f"resultv2_{con_id}_detail"
# 5) tours + tempstour
tour, prev_ts = compute_tour(cur, detail_table, tp_id, dossard)
if prev_ts is None:
tempstour = Decimal("0")
else:
tempstour = timestamp - prev_ts
tempstour_str = format_temps_decimal(tempstour)
insert_detail_record(
cur, cnx, con_id,
dossard, lec,
tp_id, tp_name,
chr_loc, prio, controller,
dt_local,
status=status,
reason_code=reason_code,
reason_ref_id=reason_ref_id,
tour=tour,
tempstour=tempstour,
tempstour_str=tempstour_str
)
# Mise à jour du dernier read VALIDE
if status == 1:
_last_valid_read[(tp_id, dossard)] = dt_local
log_info(f"[OK] id={rcourse_id} tag={tag} dossard={dossard} tp={tp_name} prio={prio}")
update_last_processed_read_id(cur, cnx, engine_table, max_id)
def compute_tour(cur, detail_table, tp_id, dossard):
"""
Retourne (tour, prev_timestamp_decimal)
- tour = dernier_tour + 1
- prev_timestamp_decimal = timestamp_read du tour précédent (Decimal) ou None si premier tour
"""
cur.execute(f"""
SELECT tour, timestamp_read
FROM `{detail_table}`
WHERE tp_id = %s
AND cla_info_dossard = %s
AND status = 1
ORDER BY detail_id DESC
LIMIT 1
""", (tp_id, dossard))
row = cur.fetchone()
# Première lecture → tour 1, pas de temps précédent
if not row or row["tour"] is None:
return 1, None
last_tour = row["tour"]
prev_ts = Decimal(str(row["timestamp_read"]))
return last_tour + 1, prev_ts

View File

@ -0,0 +1,162 @@
from typing import Dict, Any, List
from engine.logger import log_debug, log_info
def load_tp_config_for_con(cur, con_id: int) -> Dict[int, Dict[str, Dict[str, List[Dict[str, Any]]]]]:
"""
Charge TOUTE la configuration TP (det_type = 1) pour un con_id.
Structure retournée :
{
tp_id: {
"tp_name": "...",
"config": {
"config_tp": {
"clef_controleur": [
{ det_id, value1, value2, value3 }
],
"clef_temp_read": [
...
]
},
"config_tp_interval": {
"clef_temp_minimum_read_entre": [
{ det_id, value1, value2, value3 }
]
}
}
}
}
"""
# 1) Lire la liste des TP pour ce con_id
sql_tp = """
SELECT tp_id, tp_name
FROM reader_config_timingpoint
WHERE con_id = %s
"""
cur.execute(sql_tp, (con_id,))
tps = cur.fetchall()
if not tps:
log_info(f"[CONFIG] Aucun TP trouvé pour con_id={con_id}")
return {}
# Préparer structure finale
tp_config: Dict[int, Dict] = {}
for tp in tps:
tp_id = tp["tp_id"]
tp_config[tp_id] = {
"tp_name": tp["tp_name"],
"config": {}
}
# 2) Lire toutes les configs det_type = 1 pour ce con_id
sql_conf = """
SELECT
d.det_id,
d.det_ref1_id AS tp_id,
d.det_groupe,
d.det_clef,
d.det_value1,
d.det_value2,
d.det_value3
FROM reader_config_detail d
JOIN reader_config_timingpoint tp ON tp.tp_id = d.det_ref1_id
WHERE tp.con_id = %s
AND d.det_type = 1
ORDER BY d.det_groupe, d.det_clef, d.det_id
"""
cur.execute(sql_conf, (con_id,))
rows = cur.fetchall()
# 3) Assembler
for row in rows:
tp_id = row["tp_id"]
group = row["det_groupe"] or "default"
clef = row["det_clef"]
if group not in tp_config[tp_id]["config"]:
tp_config[tp_id]["config"][group] = {}
if clef not in tp_config[tp_id]["config"][group]:
tp_config[tp_id]["config"][group][clef] = []
tp_config[tp_id]["config"][group][clef].append({
"det_id": row["det_id"],
"value1": row["det_value1"],
"value2": row["det_value2"],
"value3": row["det_value3"],
})
# 4) Affichage debug clair
log_info(f"[CONFIG] Chargé config TP pour con_id={con_id}")
for tp_id, data in tp_config.items():
log_info(f" TP {tp_id} ({data['tp_name']})")
for group, clefs in data["config"].items():
log_debug(f" [{group}]")
for clef, items in clefs.items():
for it in items:
log_debug(
f" {clef}: det_id={it['det_id']} "
f"v1={it['value1']} v2={it['value2']} v3={it['value3']}"
)
return tp_config
def resolve_tp_for_lecture(tp_config, con_id: int, chr_location: str):
"""
Retourne l'entrée TP correspondant à la location lue.
On cherche dans les configs TP la clef :
config_tp → clef_controleur → value1 = chr_location
On choisit automatiquement :
- la priorité la plus basse (1 est meilleur que 2)
- si ties : on prend la première
Retourne un dict complet :
{
"tp_id": ...,
"tp_name": "...",
"priority": ...,
"controller_name": "...",
"det_id": ...
}
"""
best = None
for tp_id, data in tp_config.items():
# Toute config pour ce timing point
groups = data.get("config", {})
# Groupe config_tp (celui des contrôleurs)
cfg_tp = groups.get("config_tp", {})
# Liste des clefs clef_controleur
controllers = cfg_tp.get("clef_controleur", [])
for ctrl in controllers:
if ctrl["value1"] != chr_location:
continue
prio = int(ctrl["value2"]) if ctrl["value2"] else 999
item = {
"tp_id": tp_id,
"tp_name": data["tp_name"],
"priority": prio,
"controller_name": ctrl["value3"],
"det_id": ctrl["det_id"],
}
# Choisir le meilleur contrôleur (prio la plus petite)
if best is None or prio < best["priority"]:
best = item
return best

39
engine/info.txt Normal file
View File

@ -0,0 +1,39 @@
a valider
attention sur le serveur mysql le % devrais enlever
config
TABLE reader_config_detail — RÈGLES GÉNÉRALES
---------------------------------------------
1) det_type
- 1 = règles associées aux Timing Points (TP)
- 2 = règles associées aux Courses
=> Toujours utiliser det_type pour savoir si la règle appartient à un TP ou à une course.
2) det_ref1_id
- Pour det_type = 1 → det_ref1_id = tp_id
- Pour det_type = 2 → det_ref1_id = con_id
=> Identifie exactement à quel TP ou à quelle course la règle appartient.
3) det_groupe
- Sert uniquement au regroupement logique / visuel.
- NE DOIT PAS être utilisé pour la logique du moteur.
=> Le moteur utilise seulement det_type + det_ref1_id + det_clef.
4) det_clef
- Nom symbolique de la règle (ex: clef_controleur, clef_temp_minimum_read_entre, clef_start_time).
5) det_value1, det_value2, det_value3
- Contiennent les valeurs de la règle.
- Le moteur doit toujours accepter 1, 2 ou 3 valeurs selon le type de règle.
6) det_trie
- Indique l'ordre de priorité entre plusieurs règles d'un même type pour un même TP ou course.
- Le moteur doit TOUJOURS trier par det_trie ASC avant d'interpréter.
=> Une règle avec det_trie = 1 est appliquée avant une règle avec det_trie = 10.
7) Source unique
- Toutes les informations de configuration (TP et course) doivent provenir de reader_config_detail.
- Aucune autre source de règle ne doit être utilisée dans le moteur.

21
engine/logger.py Normal file
View File

@ -0,0 +1,21 @@
import sys
# ENGINE_MODE = "DEV" ou "PROD"
ENGINE_MODE = "DEV" # change à PROD sur ton serveur
def log_debug(msg: str):
if ENGINE_MODE == "DEV":
print(f"[DEBUG] {msg}")
def log_info(msg: str):
if ENGINE_MODE == "DEV":
print(f"[INFO] {msg}")
def log_warn(msg: str):
# On affiche les warnings en DEV mais pas en PROD
if ENGINE_MODE == "DEV":
print(f"[WARN] {msg}")
def log_error(msg: str):
# Toujours afficher les erreurs, même en production
print(f"[ERROR] {msg}", file=sys.stderr)

16
engine/utils.py Normal file
View File

@ -0,0 +1,16 @@
from decimal import Decimal
def format_temps_decimal(d):
"""
Formatte un Decimal représentant un nombre de secondes
en HH:MM:SS.mmm — exemple : 00:06:52.820
"""
d = Decimal(d)
total_sec = float(d)
hours = int(total_sec // 3600)
minutes = int((total_sec % 3600) // 60)
seconds = total_sec % 60
return f"{hours:02d}:{minutes:02d}:{seconds:06.3f}"

Binary file not shown.

View File

@ -0,0 +1,20 @@
# engine/utils/time_utils.py
from datetime import datetime, timezone, timedelta
import pytz
LOCAL_TZ = pytz.timezone("America/Toronto")
def utc_ts_to_local_datetime(ts: float):
"""Timestamp UNIX (UTC réel) → datetime locale America/Toronto."""
return datetime.fromtimestamp(ts, timezone.utc).astimezone(LOCAL_TZ)
def utc_ts_to_utc_datetime(ts: float):
"""Timestamp UNIX (UTC réel) → datetime UTC."""
return datetime.fromtimestamp(ts, timezone.utc)
def local_datetime_to_utc(dt):
"""Datetime locale → UTC."""
if dt is None:
return None
return dt.astimezone(timezone.utc)