recommandations : le job qui fait tourner les trois règles sur la zone or (#39) #175
No reviewers
Labels
No labels
Compat/Breaking
EC01
EC02
EC03
EC04
EC05
EC06
Kind/BDD
Kind/Back
Kind/Bug
Kind/CICD
Kind/Cloud
Kind/Contenu
Kind/Data
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Front
Kind/IA
Kind/Infra
Kind/Monitoring
Kind/Security
Kind/Testing
Portée/Post-jury
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
ops/alerte
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
g2/enervision!175
Loading…
Reference in a new issue
No description provided.
Delete branch "lenaic/39-job-recommandations"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
La #154 a livré les règles, le moteur et le rapprochement. Personne ne les appelait :
public.recommandationest restée vide, et la vue Site du tableau de bord n'a rien à afficher. Le critère de vérification du #39 demande « trois recommandations produites avec leur justification » ; c'est ce module qui les produit.Preuve, sur les données réelles
Joué sur le serveur le 08/09 à 07h30, contre
enervision_prod:Le point principal : R2 était structurellement muette
Sa fenêtre de fraîcheur valait trois heures. Or
mesure_horaireest un agrégat continu dont la politique porteend_offset => 1 hour(migration 0016) : TimescaleDB ne matérialise qu'un seau entièrement contenu dans la fenêtre, et la politique se rejoue toutes les heures. Le seau le plus récent a donc déjà entre deux et trois heures.Mesuré le 08/09 à 07h29, sur une politique pourtant saine, dernier passage à 06h57 sans échec :
Sur les 10 283 moyennes horaires et 434 lignes de qualité en base, la règle n'aurait jamais rien dit, quelle que soit la charge réelle. Et la charge réelle est là : jusqu'à 239 % de la capacité déclarée sur SITE006, 1469 heures au-dessus de 85 % sur SITE003.
La fenêtre passe à six heures, chiffre mesuré et non choisi. Deux tests l'épinglent : le cas de production redevient jugeable, la borne reste, et le commentaire dit pourquoi la resserrer rend la règle silencieuse.
Ce que ça ajoute
entrepot.pypsycopgimporté tardivement pour que les 123 tests tournent sans pilotejob.pybin/recommandations-hourly.shload-postgresqui écrit ce que les règles lisenton conflict do nothinget nondo update: une recommandation est un fait daté, la réécrire avec une justification produite plus tard rendrait la trace d'EF-09 mensongère.Un second défaut trouvé en chemin
tests/unit/silverporte déjàtest_job.pyettest_entrepot.py. Sans__init__.py, pytest refusait de charger les deux paires : 724 tests interrompus à la collecte. Les miens portent le suffixe du paquet.Ce que la base ne sait pas porter, et qui reste ouvert côté #153
qualite_journ'a pas de colonnereleves_imputes. Elle est dérivée derepartition_methode,forward_fillplusinterpolated. Trois tests gardent la dérivation, dont celui qui refuse de rendre 0 quand la répartition manque : « zéro imputé » et « on ne sait pas » ne sont pas la même chose.public.recommandationn'a nigravite, niaction, ni statut. Les deux premières rejoignentvaleurs_declenchantesfaute de colonne ; le cycle de vie avecresolved_atque le #153 spécifie n'a toujours aucun emplacement en base.L'état des trois règles aujourd'hui
public.previsionest vide, elle attend le job d'inférence du #37.Silence, distinct d'une indécision : la règle a jugé et n'a rien à dire, elle n'a pas échoué à juger. À dire au jury plutôt que de le laisser passer pour un oubli.Vérifications
Une ligne a été écrite en base pendant l'essai, celle de SITE003 ci-dessus. C'est une vraie recommandation sur de vraies données, je ne l'ai pas retirée.
Patch appliquée en local, les 123 tests du paquet passent, et j'ai rejoué la fenêtre de R2 en simulation. Le travail est solide et la découverte du
end_offsetest juste et bien démontrée. Cinq points, du plus gênant au moins.1. Le correctif de R2 est à moitié fait — elle redeviendra muette
FRAICHEUR_MAXpasse à 6 h, maisentrepot.HEURES_REMONTEESvaut 6 lui aussi. Or la requête ne rend que les seauxheure >= maintenant - 6 h: c'est la fenêtre de lecture, pas la fenêtre de fraîcheur, qui borne la règle. Simulé sur le cas réel (cron à la minute 37, seaux horaires) :Le retard mesuré le 08/09 est de 3 h 29 : on est exactement sur la dernière valeur qui marche, avec zéro marge. Le commentaire de
r2affirme pourtant que six heures couvrent « le retard structurel de l'agrégat plus un cycle de rafraîchissement manqué » — un cycle manqué fait tomber à deux seaux et R2 se tait. Pas avec le motif « hors de la fenêtre glissante » qu'on chercherait, mais avec « 2 moyennes disponibles », ce qui envoie chercher au mauvais endroit.Le nouveau test ne l'attrape pas :
test_le_retard_structurel_de_l_agregat_reste_jugeablefabrique troisMoyenneHoraireà la main et court-circuite l'entrepôt, donc il épingleFRAICHEUR_MAXsans jamais éprouver ce que la requête rend.Correctif :
HEURES_REMONTEES = 9(soitFRAICHEUR_MAX + HEURES), et un test qui parte dedepuis = maintenant - HEURES_REMONTEES.2. Un seau
moyenne_kwà NULL fait tomber toute la passeentrepot.py:moyenne_kw=float(ligne["moyenne_kw"]), sans garde, et la requête ne filtre pas les NULL.mesure_horairecalculeavg(valeur_kw) filter (where indicateur_qualite <> 'critical')(migration 0016). Une heure entière dont tous les relevés sontcritical— une heure de collecte perdue, exactement ce que la 0013 et R3 servent à rendre visible — produit une ligne àmoyenne_kwNULL.float(None)lèveTypeError, hors de touttry: ça traverseexecuter,mainne reconnaît pas le nom,raise, trace brute et code de sortie 1, qui ne figure dans aucun des quatre codes annoncés par le docstring.C'est le mode de panne que la demande dit vouloir écarter, et il suffit d'une heure muette sur un des sept sites pour perdre les recommandations des six autres.
and moyenne_kw is not nulldans lewheresuffit.3.
--jourproduit une recommandation chimère, et l'idempotence annoncée n'existe pasobservations(jour, maintenant=maintenant)n'utilisejourque pour la jointure surqualite_jour.mesure_horaireetprevisionsont lus par rapport àmaintenant. Donc--jour 2026-07-09juge la qualité du 9 juillet avec les charges et la prévision d'aujourd'hui : R3 parle d'une journée, R2 d'une autre, dans la même passe.Et
observee_a = observation.instant = maintenant, donchorodatageporte l'heure de la passe. La clauseunique (site_id, horodatage, regle_id, regle_version)que la migration 0014 commente « rejeu idempotent d'une journée » ne peut jamais être touchée : leon conflict do nothingest décoratif, deux rejeux du même jour écrivent deux lignes. La non-duplication repose entièrement suractives()+rapprocher— choix défendable, mais alors le commentaire deecrire()raconte une protection qui n'agit pas.4. La jointure sur
previsionprend la prévision la plus lointaine, pas la prochaineorder by horodatage desc limit 1rend lemax(horodatage), ethorodatageest l'instant prévu (0011). R1 exige0 < prevision_pour - instant <= 2 h. Tant que le #37 n'écrit qu'un h+1 par heure ça tombe juste ; dès qu'il écrit plusieurs horizons — ou qu'il rejoue, ce que le contratPrevisionH1annonce explicitement pour le #36 — R1 devient définitivement indécise sur « prévision trop lointaine », et personne ne le verra puisqu'elle est déjà indécise pour cause de table vide.Une ligne, et ça tient dans les deux cas :
5. Le journal annonce des résolutions qui ne sont écrites nulle part
%d résolue(s)compterapprochement.a_resoudre, mais rien ne les persiste — la table n'a pas de statut, c'est dit et assumé. Le compte n'est donc pas « on a résolu N recommandations » mais « N conditions sont retombées, et on n'en garde aucune trace ». Sur une passe qui se réclame de ne rien dire de faux dans son journal, la nuance vaut le mot.Le reste tient : la frontière
entrepot/ règles est respectée, l'import tardif de psycopg est cohérent avec l'ETL,deps-services.pyramasse bienpsycopg[binary]par sonrglobdonc le venv de déploiement sera servi,ENERVISION_RACINEest posé par le rôleapp, la minute 37 est justifiée, et la dérivation dereleves_imputesavec son refus de rendre 0 quand la répartition manque est le bon appel.Les points 1 et 2 me semblent bloquants : l'un rend muette la seule règle qui déclenche, l'autre fait tomber la passe entière sur une donnée normale.