[EF-10] Le tableau de bord lit la zone or : cinq lectures quittent les fixtures (#179) #182
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
3 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
g2/enervision!182
Loading…
Reference in a new issue
No description provided.
Delete branch "marvin/179-tableau-de-bord-zone-or"
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?
Ce que ça change
Cinq des huit lectures du tableau de bord quittent
fixtures.pyet lisent PostgreSQL : les alertes, la traçabilité d'une valeur, le journal de collecte, et les deux courbes. La zone or est peuplée depuis le 07/09, l'écran Qualité affichait encore quatre alertes inventées alors que la base en porte 1 742.Les trois qui restent —
sites,synthese_parc,recommandations_site— portent la prévision et les recommandations, dont les tables sont vides. Elles suivront le #37 et le #39.Closes #179
Preuve
Ce que la base porte réellement, en préproduction, et que ces lectures servent maintenant :
Trois commits, à relire dans l'ordre
ded567e5d58dd8b6ce34aOù regarder en priorité
1.
Methodepasse de trois à quatre valeurs. C'est le correctif qui bloquait tout le reste. La forme réduite faisait porter ànonedeux sens distincts — « valeur mesurée » et « trou assumé » — alors que l'ADR 0006 nommemeasuredle régime de la valeur présente. La contrainte depublic.mesureaccepte les quatre depuis la 0010, et 89,5 % des lignes réelles portentmeasured: sans ce correctif, l'endpoint de traçabilité rendait 500 sur la grande majorité des points, pas sur un cas limite.2. La prévision devient facultative dans le contrat, et c'est une décision.
public.previsionest vide : le modèle H+1 est entraîné et promu (#36), rien ne sert encore ses valeurs (#37). L'API rendnull, l'écran affiche « — » et la raison. Un chiffre dérivé d'autre chose serait invérifiable, et la première question du jury serait d'où il sort.ecart_reference_pctsuit : sans les deux termes, il n'y a pas d'écart, et rendre zéro laisserait croire que la prévision colle à la référence.3.
reference_kwest calculée, pas inventée. C'est la persistance — la valeur observée à la même heure la veille — que le schéma de sortie nomme ainsi depuis le début. Elle se déduit demesure_horaireseule, sans attendre le service d'inférence. Un historique trop court rend zéro : la courbe reste affichable, seul le repère manque.4. Les courbes lisent
mesure_horaire, jamais une moyenne recalculée — le §12 pose la règle, « les recalculer donnerait deux vérités pour une question ». Mais l'agrégat continu ne porte pas l'imputation, or le front trace ces segments autrement (EF-04). D'où une jointure surpublic.mesurequi ne sert qu'à ce drapeau : la valeur affichée reste celle de l'agrégat. C'est le point que je relirais en premier si j'étais relecteur.5. Le filtre par site est dans la requête SQL, pas appliqué après coup : c'est le contrôle d'accès à la ressource de l'ENF-02. Le faire en Python ramènerait d'abord en mémoire des lignes que l'appelant n'a pas le droit de voir.
Comment les tests tiennent sans PostgreSQL
FakeConnscript ses réponses, appariées par FRAGMENT de requête et non par égalité — écrire le SQL complet dans chaque cas en ferait la copie d'un fichier de production que personne ne relirait, et au premierorder bydéplacé tous les cas rougiraient sans qu'aucun comportement n'ait changé.Le docstring dit ce qu'elle ne garantit PAS : ce n'est pas un simulateur de PostgreSQL. L'exactitude du SQL relève de l'intégration, la projection des lignes en modèles se prouve ici. Confondre les deux donnerait un faux qui ment sur ce qu'il garantit.
Son constructeur reste sans paramètre, et c'est écrit en commentaire :
conftestpasse la classe àapp.dependency_overrides[get_db], et FastAPI lit la signature de__init__comme celle d'une dépendance. Un argument de configuration y devient un champ de corps de requête, et toute route POST rend 422 sur un corps valide. Constaté, 84 tests en erreur.Un faux positif que le branchement a révélé
Le cas
test_repartition_methode_couvre_les_releves_attendusaffirmait « somme(répartition) + manquants == attendus ». Vrai du jeu figé, faux de la donnée réelle : la zone or calcule les manquants comme « attendus − mesurées − imputées », et la répartition compte les quatre régimes sur TOUTE la série — les additionner comptaitnonedeux fois.Le vrai invariant est « somme == attendus » et « répartition['none'] == manquants ». Vérifié sur SITE006 le 2026-09-06 : 1317 + 122 + 1 = 1440, manquants 1. C'est exactement le genre de faux positif qu'un jeu figé entretient sans qu'on le sache.
Relecture
[EF-10] Le tableau de bord lit la zone or : cinq lectures quittent les fixtures (#179)to WIP: [EF-10] Le tableau de bord lit la zone or : cinq lectures quittent les fixtures (#179)WIP: [EF-10] Le tableau de bord lit la zone or : cinq lectures quittent les fixtures (#179)to [EF-10] Le tableau de bord lit la zone or : cinq lectures quittent les fixtures (#179)Relu à J-1, donc uniquement ce qui bloque ou ce qui se verra à la démonstration. Le reste — l'énumération à quatre valeurs, la prévision facultative assumée jusqu'au front, la traçabilité lue à la clé
(site_id, horodatage), le journal de collecte, le faux positif corrigé sur la répartition — tient, et les justifications sont au bon endroit.Un bloquant, deux points critiques.
1. Bloquant —
/v1/parc/mesuresn'agrège pas les sitesLes deux requêtes de la courbe rendent une courbe fausse dès qu'il y a plus d'un site, c'est-à-dire dans le cas nominal (sept sites visibles,
_sites_visiblesles rend tous).Fenêtre 24 h.
mesure_horaireporte une ligne par(site_id, heure)(migration 0016,group by site_id, heure).SQL_SERIE_HEUREregroupe surgroup by h.heure, h.moyenne_kw: le couple est distinct par site, donc chaque heure ressort autant de fois qu'il y a de sites, chacune à l'échelle d'un site.Reproduit sur trois sites et deux heures :
ProfilHoraireParc.vuetracestore.profilParc.pointsen abscisse par index, une étiquette toutes les quatre heures : il recevra 168 points au lieu de 24, chaque heure répétée sept fois. Le critère « profil horaire » du #24 devient une dent de scie.Fenêtres 7 j et 30 j.
SQL_SERIE_JOURfaitavg(h.moyenne_kw)sur toutes les lignes de tous les sites : le résultat est la consommation moyenne d'un site, pas celle du parc. À côté,capacite_totale_kwest une somme etSQL_REFERENCEfaitsum(moyenne_kw)— la courbe et ses deux repères ne sont pas à la même échelle, d'un facteur voisin du nombre de sites. Le castest_la_courbe_reste_sous_la_capacite_du_parc, supprimé par la branche, gardait exactement cet invariant.Le correctif est le même des deux côtés : sommer sur les sites avant de regrouper. Pour l'heure,
sum(h.moyenne_kw)avecgroup by h.heureseul. Pour le jour, agréger en deux temps — la somme du parc par heure, puis la moyenne de ces heures sur la journée.Pourquoi rien ne l'a vu :
FakeConnscripte les réponses, ce qui est le bon choix et c'est bien argumenté, mais son docstring renvoie àtests/integration/pour l'exactitude du SQL — or aucun test d'intégration n'exécute ces cinq requêtes.tests/integration/gold/test_chargement_postgres.pyest un banc de temps de réponse, et saSQL_PARC_HORAIREne passe pas par le dépôt. La promesse du docstring n'est pas tenue aujourd'hui ; un seul cas d'intégration surserie_parcavec deux sites suffirait à fermer la classe entière.2. Critique — le front ignore
measured, et garde l'ancien sens denoneservices/dashboard/src/api/glossaire.jsporte trois entrées :La branche fait exactement l'inverse en base :
measuredest la valeur mesurée,noneest le trou assumé. Donc, à l'écran :libelleMethoderetombe sur la clé brute : « 1317 en measured », en anglais, à côté de « 1 en mesure directe » pour le trou. Le contresens est affiché sur l'écran Qualité, celui de la qualité de données ;descriptionMethoderend « measured » tel quel pour 89,5 % des points.Deux lignes dans
glossaire.js. Tant qu'on y est, le docstring deQualiteJour(models.py:398) dit encore «NONEcouvrant les mesures directes » — c'est la phrase que le reste de la branche corrige.3. Critique —
/v1/alerteset la synthèse du parc chargent les 1 742 lignesSQL_ALERTESn'a ni limite ni filtre sur l'état.alertes_ouvertesappellealertes()puis filtre en Python, etsynthese_parcs'en sert pour un compte et une ventilation par sévérité : l'écran d'accueil ramène donc 1 742 lignes pour en afficher un entier.ListeAlertes.vueles rend toutes, sans pagination — et comme la 0018 poseetat not null default 'ouverte'et que rien ne résout d'alerte, les 1 742 sont ouvertes.Le pavé du parc mérite un
count(*) ... group by severite, et la liste une borne (ou le filtrewhere etat = 'ouverte', que l'indexalerte_ouvertesde la 0018 attend déjà).Ce que je note sans bloquer
public.mesuremultiplie les lignes par 60 avant l'agrégat : sur 30 j × 7 sites, ~300 000 lignes jointes par appel. Conséquence de fond dansSQL_SERIE_JOUR—avg(h.moyenne_kw)porte sur les lignes jointes, donc chaque heure y pèse son nombre de minutes présentes. Une sous-requête agrégée par heure, jointe ensuite, évite les deux.imputeerisque de saturer : la note de fin de la 0010 recense 347 574 lignes historiques portantnoneavec une valeur non nulle, non normalisées.bool_or(methode_imputation <> 'measured')les compte toutes comme imputées. À regarder sur la préproduction avant de conclure que la courbe dit vrai./alertespromet toujours qu'« une alerte sanssite_idsort toujours » (le filtre est maintenant dans le SQL, et la colonne estnot null), et/qualite/collecteannonce « trois derniers jours » alors queSQL_JOURNAL_COLLECTEn'a plus de limite._POINT_ABSENT: le commentaire dit « il faut que l'appel échoue plutôt que de rendre un objet à moitié faux », le code rend un point à l'epoch. L'un des deux a raison.test_dashboard_parc.pya été reformaté à 88 colonnes, hors du périmètreruff format services packagesannoncé dans la description — d'où des retours à la ligne qui n'apportent rien au diff.Le point 1 est le seul qui m'empêche d'approuver : c'est l'écran d'accueil, et la courbe est fausse dans les trois fenêtres. Les points 2 et 3 sont courts et se voient en démonstration.
Les trois points sont traités, et le bloquant est vérifié sur la préproduction
Commit
40a68f2. La relecture avait raison sur les trois, et le point 1 était bien un défaut de fond — pas une approximation.1. La courbe du parc n'agrégeait pas les sites
Diagnostic exact.
mesure_horaireporte une ligne par(site_id, heure), etgroup by heure, moyenne_kwne regroupe rien puisque le couple est distinct par site.Mesuré sur la préproduction, fenêtre 24 h, sept sites visibles :
Sept points par heure, chacun à l'échelle d'un site. Sur
ProfilHoraireParc.vue, qui trace en abscisse par index, c'était bien la dent de scie annoncée.Les deux requêtes agrègent maintenant en deux temps : somme sur les sites, puis regroupement — et pour la fenêtre journalière, somme du parc par heure puis moyenne de ces heures.
La remarque sur la jointure était juste aussi, et elle allait plus loin qu'une question de volume. Jointe dans l'agrégat, elle multipliait chaque ligne horaire par ses soixante minutes avant le regroupement : l'
avgjournalier pondérait donc chaque heure par son nombre de minutes présentes. La sous-requête agrégée à part corrige la justesse en même temps que les 300 000 lignes.Le cas d'intégration qui manquait
Vous avez mis le doigt sur la vraie cause : le docstring de
FakeConnrenvoyait àtests/integration/pour l'exactitude du SQL, et rien ne l'y exécutait. La promesse n'était tenue par personne, et aucun double ne pouvait voir ce défaut — il rend ce qu'on lui donne.tests/integration/test_lectures_tableau_de_bord.py: deux sites, deux heures, des valeurs choisies pour que les trois cas se distinguent d'un coup d'oeil — une somme correcte donne 300 et 330, une moyenne donnerait 150 et 165, une absence de regroupement rendrait quatre points.2. Le front ignorait
measuredCorrigé dans
glossaire.js, avec les deux entrées :measuredpour la valeur mesurée,nonepour le trou assumé. Et le docstring deQualiteJourqui portait encore l'ancien sens — merci, c'était exactement la phrase que le reste de la branche corrigeait.3. Les 1 742 lignes chargées pour un compte
La liste est bornée à 200, et la ventilation du pavé passe par un
count(*) ... group by severite. C'est bien l'index partielalerte_ouvertesde la 0018 qui sert, posé pour ça.Une borne plutôt qu'une pagination : la pagination est un contrat — curseur, total, « page suivante » côté front — et l'écran n'en a pas besoin pour montrer ce qui vient d'arriver.
Ce que la vérification a révélé en plus
Votre note sur la saturation du drapeau n'était pas seulement un risque, c'est un défaut sur la fenêtre 30 j. La préproduction porte 347 574 lignes
noneavec une valeur, antérieures à la normalisation de la 0010 :Sur 24 h le drapeau restait juste — 1 385 mesurées, 556 interpolées — mais sur 30 jours chaque journée serait ressortie imputée. Le prédicat nomme désormais les deux régimes d'imputation au lieu de dire « tout sauf mesuré », ce qui est aussi plus juste sémantiquement : un trou n'est pas une reconstitution, il n'a pas de valeur retenue et ne pèse sur aucun agrégat.
Les autres remarques
Les deux descriptions OpenAPI sont réécrites. Le commentaire de
_POINT_ABSENTcontredisait son code — c'était le commentaire qui avait tort, une courbe de parc vide reste affichable, seuls ses deux repères n'ont pas de sens. Les trois réenroulements à 88 colonnes hors périmètre sont annulés.915 unitaires · 5 d'intégration · ruff check et ruff format · mypy strictLes trois corrections sont bonnes. Je ne fusionne pas encore : le job « Tableau de bord » est rouge, et pour une raison qui tient au point 2.
Ce que j'ai vérifié de la correction
Le bloquant est traité au bon endroit. Les deux requêtes agrègent en deux temps, la sous-requête d'imputation est sortie de l'agrégat — ce qui corrige la pondération de l'
avgjournalier en même temps que le volume — et le prédicat qui nommeinterpolatedetforward_fillau lieu de « tout sauf mesuré » est plus juste que ce que j'avais suggéré : un trou n'est pas une reconstitution. Le cas d'intégration choisit ses valeurs pour que somme, moyenne et absence de regroupement se distinguent d'un coup d'œil, et il nettoie derrière lui.La ventilation par
count(*) ... group by severitefait mieux que corriger le volume :alertes_ouvertesdevient une propriété dérivée de la carte, donc l'invariant « total == somme de la ventilation » ne peut plus se défaire.Ce qui reste —
npm run test:unit, quatre casLe changement de
glossaire.jsn'a pas été porté dans les cas qui l'exercent. Reproduit en local :(Le cinquième,
paquet-de-production, échoue aussi sur9cb280ddans mon environnement — il n'est pas de votre fait.)Deux de ces quatre ne sont pas un renommage de chaîne.
TracabiliteDetail.test.jsmonte une tracemethode: 'none'avecvaleur_retenue_kw: 104etvaleur_brute_kw: 104, etsite.test.js:106fait de même. C'est un cas que l'API ne peut plus produire :nonesignifie qu'aucune valeur n'a été retenue, ettracabilite_sitelèveSourceIndisponible— 503 — dès quevaleur_kwest nul. Ces deux jeux d'essai décrivent donc une ligne qui n'existe pas. Ils voulaient dire « mesure directe » : c'estmeasuredqu'ils doivent porter, et le cas retrouve alors son libellé sans qu'on touche à l'assertion. Même chose pour la fixture du journal, dont les 1 050 relevés sont des mesures.« Contrôles statiques » : rien à corriger
Il était rouge au premier passage, sur un contrôle sans rapport avec la branche —
listen_ip=127.0.0.1de cadvisor, dansinfra/compose/supervision/docker-compose.yml. J'ai rejouétests/ci/test-supervision.shsur le merge exact que la chaîne a testé (94e8f9b+40a68f2) : 42 contrôles, aucun échec. J'ai relancé le job, il est vert. Un aléa de l'exécuteur, pas un défaut de la branche — je le signale pour que personne n'aille chercher.Une remarque, non bloquante
Le plafond de 200 et le compte en base ne parlent plus de la même chose. Le pavé du parc affiche le total des ouvertes — 1 742 aujourd'hui — pendant que
ListeAlertes.vueécrit « 200 ouvertes sur 200 »,store.alertes.lengthétant désormais borné. Deux écrans qui se contredisent devant le jury. Une mention du genre « 200 plus récentes » dans l'en-tête de la liste suffirait.Corrigez les quatre cas et je fusionne dès que la chaîne est verte.
La chaîne a rougi sur quatre cas du tableau de bord : `glossaire.js` a gagné `measured` et rendu à `none` son sens de trou assumé, mais les cas qui l'exercent affirmaient encore l'ancien. Faute de ma part — j'ai modifié le glossaire sans rejouer la suite du tableau de bord derrière. DEUX JEUX D'ESSAI SE CONTREDISAIENT, et c'est plus intéressant qu'un simple renommage. `JournalCollecte` posait `repartition_methode: { none: 1050, interpolated: 147 }` avec `releves_manquants: 243` et `releves_attendus: 1440`. Sous le sens de la migration 0010, `none` est le trou assumé : la somme aurait dû faire les attendus, et `none` valoir les manquants. La forme juste est donc `{ measured: 1050, interpolated: 147, none: 243 }` — 1050 + 147 + 243 = 1440, et `none` = 243 = les manquants. C'est exactement l'invariant relevé sur SITE006 en production, qui est justement le site de ce jeu. `TracabiliteDetail` posait `methode: 'none'` avec `valeur_brute_kw: 104`. Un trou assumé n'a par définition aucune valeur : la fixture décrivait une ligne qui ne peut pas exister. C'est `measured`. Les deux ne « passaient » que parce que le front traduisait `none` par « mesure directe ». Corriger le glossaire les a mis à nu, ce qui est le comportement attendu d'un jeu d'essai qui dit la vérité de la donnée. `descriptionMethode('measured')` garde la formulation que l'équipe avait écrite — « mesure directe, aucune reconstitution » — déplacée sur la bonne clé plutôt que réécrite : elle s'accorde avec le libellé court. 373 cas du tableau de bord, 915 unitaires Python, ruff check et format. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>