recommandations : le job qui fait tourner les trois règles sur la zone or (#39) #175

Merged
lenaic merged 5 commits from lenaic/39-job-recommandations into develop 2026-09-08 08:56:01 +00:00
Owner

La #154 a livré les règles, le moteur et le rapprochement. Personne ne les appelait : public.recommandation est 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 :

passe terminée : 1 écrite(s), 0 maintenue(s), 0 résolue(s),
                 13 silence(s), 7 indécision(s), 0 défaillance(s)
site      SITE003
regle     r2 v1.0.0
libelle   Site Data Center Marseille : la charge moyenne dépasse 85 % de la
          capacité depuis 3 heures (94 %, 94 %, 94 %). La marge de sécurité
          est entamée.
valeurs   {"seuil": 0.85, "charges_horaires": [...], "heures": [...],
           "capacity_kw": ..., "gravite": ..., "action": "..."}

Le point principal : R2 était structurellement muette

Sa fenêtre de fraîcheur valait trois heures. Or mesure_horaire est un agrégat continu dont la politique porte end_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 :

max(mesure)          06:59   retard 0:30
max(mesure_horaire)  04:00   retard 3:29

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

Fichier Rôle
entrepot.py le seul module du paquet à connaître PostgreSQL, psycopg importé tardivement pour que les 123 tests tournent sans pilote
job.py la passe, son journal, et ses codes de sortie 0/2/3/4
bin/recommandations-hourly.sh le lanceur cron, minute 37, dix minutes derrière load-postgres qui écrit ce que les règles lisent

on conflict do nothing et non do 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/silver porte déjà test_job.py et test_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_jour n'a pas de colonne releves_imputes. Elle est dérivée de repartition_methode, forward_fill plus interpolated. 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.recommandation n'a ni gravite, ni action, ni statut. Les deux premières rejoignent valeurs_declenchantes faute de colonne ; le cycle de vie avec resolved_at que le #153 spécifie n'a toujours aucun emplacement en base.

L'état des trois règles aujourd'hui

  • R1 indécise : public.prevision est vide, elle attend le job d'inférence du #37.
  • R2 déclenche, et c'est la preuve ci-dessus.
  • R3 se tait, et c'est une bonne nouvelle : le taux de disponibilité va de 0,9812 à 1,0000 sur les 434 lignes, aucune sous son seuil de 0,80. Le moteur l'enregistre comme un 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

123 tests sur le paquet, 91 % de couverture
724 tests sur la suite complète hors API
ruff, ruff format, mypy --strict          propres
ansible-lint profil production, yamllint  propres
shellcheck sur les quinze scripts         propre
banc du rôle app                          15 contrôles, 0 échec

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.

La #154 a livré les règles, le moteur et le rapprochement. **Personne ne les appelait** : `public.recommandation` est 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` : ``` passe terminée : 1 écrite(s), 0 maintenue(s), 0 résolue(s), 13 silence(s), 7 indécision(s), 0 défaillance(s) ``` ``` site SITE003 regle r2 v1.0.0 libelle Site Data Center Marseille : la charge moyenne dépasse 85 % de la capacité depuis 3 heures (94 %, 94 %, 94 %). La marge de sécurité est entamée. valeurs {"seuil": 0.85, "charges_horaires": [...], "heures": [...], "capacity_kw": ..., "gravite": ..., "action": "..."} ``` ## Le point principal : R2 était structurellement muette Sa fenêtre de fraîcheur valait trois heures. Or `mesure_horaire` est un **agrégat continu** dont la politique porte `end_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 : ``` max(mesure) 06:59 retard 0:30 max(mesure_horaire) 04:00 retard 3:29 ``` 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 | Fichier | Rôle | |---|---| | `entrepot.py` | le **seul** module du paquet à connaître PostgreSQL, `psycopg` importé tardivement pour que les 123 tests tournent sans pilote | | `job.py` | la passe, son journal, et ses codes de sortie 0/2/3/4 | | `bin/recommandations-hourly.sh` | le lanceur cron, minute **37**, dix minutes derrière `load-postgres` qui écrit ce que les règles lisent | `on conflict do nothing` et non `do 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/silver` porte déjà `test_job.py` et `test_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_jour` **n'a pas** de colonne `releves_imputes`. Elle est dérivée de `repartition_methode`, `forward_fill` plus `interpolated`. 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.recommandation` n'a ni `gravite`, ni `action`, ni statut. Les deux premières rejoignent `valeurs_declenchantes` faute de colonne ; le cycle de vie avec `resolved_at` que le #153 spécifie n'a toujours aucun emplacement en base. ## L'état des trois règles aujourd'hui - **R1 indécise** : `public.prevision` est vide, elle attend le job d'inférence du #37. - **R2 déclenche**, et c'est la preuve ci-dessus. - **R3 se tait, et c'est une bonne nouvelle** : le taux de disponibilité va de 0,9812 à 1,0000 sur les 434 lignes, aucune sous son seuil de 0,80. Le moteur l'enregistre comme un `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 ``` 123 tests sur le paquet, 91 % de couverture 724 tests sur la suite complète hors API ruff, ruff format, mypy --strict propres ansible-lint profil production, yamllint propres shellcheck sur les quinze scripts propre banc du rôle app 15 contrôles, 0 échec ``` 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.
lenaic self-assigned this 2026-09-08 07:34:34 +00:00
recommandations: le job qui fait tourner les trois règles sur la zone or (#39)
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 8s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 23s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 38s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m45s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 5m34s
1d0ab31181
CE QUI MANQUAIT. La #154 a livré les règles, le moteur et le rapprochement,
tous éprouvés sans base. Personne ne les appelait : public.recommandation est
restée vide, et la vue Site du tableau de bord n'avait rien à afficher. Le
critère de vérification du #39 demande « trois recommandations produites avec
leur justification » ; c'est ce module qui les produit.

Trois pièces : entrepot.py, seul module du paquet à connaître PostgreSQL et à
importer psycopg tardivement, pour que les 123 tests du paquet tournent sans
pilote ; job.py, la passe et ses codes de sortie ; et le lanceur cron, planifié
à la minute 37, dix minutes derrière load-postgres qui écrit ce que les règles
lisent.

PREUVE SUR LES DONNÉES RÉELLES, le 08/09 à 07h30 :

  passe terminée : 1 écrite(s), 0 maintenue(s), 0 résolue(s),
                   13 silence(s), 7 indécision(s), 0 défaillance(s)

  site      SITE003
  regle     r2 v1.0.0
  libelle   Site Data Center Marseille : la charge moyenne dépasse 85 % de la
            capacité depuis 3 heures (94 %, 94 %, 94 %).

DEUX DÉFAUTS TROUVÉS EN LE BRANCHANT, ET C'EST LE POINT PRINCIPAL.

1. R2 était STRUCTURELLEMENT MUETTE. Sa fenêtre de fraîcheur valait trois
   heures. Or mesure_horaire est un agrégat continu dont la politique porte
   end_offset => 1 hour : TimescaleDB ne matérialise qu'un seau entièrement
   contenu dans la fenêtre, et la politique se rejoue toutes les heures, donc
   le seau le plus récent a DÉJÀ entre deux et trois heures. Mesuré à 3 h 29 le
   08/09 à 07h29, sur une politique pourtant saine, dernier passage à 06h57
   sans échec. Sur les 434 lignes de qualite_jour et 10 283 moyennes horaires,
   la règle n'aurait jamais rien dit.
   La fenêtre passe à six heures, chiffre mesuré et non choisi, et deux tests
   l'épinglent : le cas de production redevient jugeable, la borne reste.

2. Collision de noms de fichiers de test. tests/unit/silver porte déjà
   test_job.py et test_entrepot.py ; sans __init__.py, pytest refusait de
   charger les deux paires. Les miens portent le suffixe du paquet.

CE QUE LA BASE NE SAIT PAS PORTER, et qui reste ouvert côté #153 :

- qualite_jour n'a pas de colonne releves_imputes. Elle est dérivée de
  repartition_methode, forward_fill plus interpolated. 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.recommandation n'a ni gravite ni action ni statut. Les deux premières
  rejoignent valeurs_declenchantes faute de colonne ; le cycle de vie avec
  resolved_at que le #153 spécifie n'a toujours aucun emplacement.

R1 reste indécise tant que public.prevision est vide : elle attend le job
d'inférence du #37. R3 se tait, et c'est une bonne nouvelle : le taux de
disponibilité va de 0,9812 à 1,0000 sur les 434 lignes, aucune sous son seuil
de 0,80. Le moteur enregistre ça comme un Silence, distinct d'une indécision.

123 tests sur le paquet, 91 % de couverture, 724 sur la suite complète hors
API. ruff, mypy strict, ansible-lint profil production, yamllint, shellcheck :
propres. Banc du rôle app : 15 contrôles, aucun échec.
Member

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_offset est 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_MAX passe à 6 h, mais entrepot.HEURES_REMONTEES vaut 6 lui aussi. Or la requête ne rend que les seaux heure >= 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) :

retard 3h -> dernier seau 04:00, 3 seaux lus  -> Declenchement
retard 4h -> dernier seau 03:00, 2 seaux lus  -> Indecidable : 2 moyenne(s), 3 nécessaires

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 r2 affirme 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_jugeable fabrique trois MoyenneHoraire à la main et court-circuite l'entrepôt, donc il épingle FRAICHEUR_MAX sans jamais éprouver ce que la requête rend.

Correctif : HEURES_REMONTEES = 9 (soit FRAICHEUR_MAX + HEURES), et un test qui parte de depuis = maintenant - HEURES_REMONTEES.

2. Un seau moyenne_kw à NULL fait tomber toute la passe

entrepot.py : moyenne_kw=float(ligne["moyenne_kw"]), sans garde, et la requête ne filtre pas les NULL.

mesure_horaire calcule avg(valeur_kw) filter (where indicateur_qualite <> 'critical') (migration 0016). Une heure entière dont tous les relevés sont critical — une heure de collecte perdue, exactement ce que la 0013 et R3 servent à rendre visible — produit une ligne à moyenne_kw NULL. float(None) lève TypeError, hors de tout try : ça traverse executer, main ne 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 null dans le where suffit.

3. --jour produit une recommandation chimère, et l'idempotence annoncée n'existe pas

observations(jour, maintenant=maintenant) n'utilise jour que pour la jointure sur qualite_jour. mesure_horaire et prevision sont lus par rapport à maintenant. Donc --jour 2026-07-09 juge 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, donc horodatage porte l'heure de la passe. La clause unique (site_id, horodatage, regle_id, regle_version) que la migration 0014 commente « rejeu idempotent d'une journée » ne peut jamais être touchée : le on conflict do nothing est décoratif, deux rejeux du même jour écrivent deux lignes. La non-duplication repose entièrement sur actives() + rapprocher — choix défendable, mais alors le commentaire de ecrire() raconte une protection qui n'agit pas.

4. La jointure sur prevision prend la prévision la plus lointaine, pas la prochaine

order by horodatage desc limit 1 rend le max(horodatage), et horodatage est l'instant prévu (0011). R1 exige 0 < 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 contrat PrevisionH1 annonce 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 :

where site_id = s.site_id and horodatage > %(maintenant)s
order by horodatage asc limit 1

5. Le journal annonce des résolutions qui ne sont écrites nulle part

%d résolue(s) compte rapprochement.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.py ramasse bien psycopg[binary] par son rglob donc le venv de déploiement sera servi, ENERVISION_RACINE est posé par le rôle app, la minute 37 est justifiée, et la dérivation de releves_imputes avec 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.

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_offset` est 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_MAX` passe à 6 h, mais `entrepot.HEURES_REMONTEES` vaut 6 lui aussi. Or la requête ne rend que les seaux `heure >= 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) : ``` retard 3h -> dernier seau 04:00, 3 seaux lus -> Declenchement retard 4h -> dernier seau 03:00, 2 seaux lus -> Indecidable : 2 moyenne(s), 3 nécessaires ``` 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 `r2` affirme 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_jugeable` fabrique trois `MoyenneHoraire` à la main et court-circuite l'entrepôt, donc il épingle `FRAICHEUR_MAX` sans jamais éprouver ce que la requête rend. Correctif : `HEURES_REMONTEES = 9` (soit `FRAICHEUR_MAX + HEURES`), et un test qui parte de `depuis = maintenant - HEURES_REMONTEES`. ## 2. Un seau `moyenne_kw` à NULL fait tomber toute la passe `entrepot.py` : `moyenne_kw=float(ligne["moyenne_kw"])`, sans garde, et la requête ne filtre pas les NULL. `mesure_horaire` calcule `avg(valeur_kw) filter (where indicateur_qualite <> 'critical')` (migration 0016). Une heure entière dont tous les relevés sont `critical` — une heure de collecte perdue, exactement ce que la 0013 et R3 servent à rendre visible — produit une ligne à `moyenne_kw` NULL. `float(None)` lève `TypeError`, hors de tout `try` : ça traverse `executer`, `main` ne 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 null` dans le `where` suffit. ## 3. `--jour` produit une recommandation chimère, et l'idempotence annoncée n'existe pas `observations(jour, maintenant=maintenant)` n'utilise `jour` que pour la jointure sur `qualite_jour`. `mesure_horaire` et `prevision` sont lus par rapport à `maintenant`. Donc `--jour 2026-07-09` juge **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`, donc `horodatage` porte l'heure de la passe. La clause `unique (site_id, horodatage, regle_id, regle_version)` que la migration 0014 commente « rejeu idempotent d'une journée » ne peut jamais être touchée : le `on conflict do nothing` est décoratif, deux rejeux du même jour écrivent deux lignes. La non-duplication repose entièrement sur `actives()` + `rapprocher` — choix défendable, mais alors le commentaire de `ecrire()` raconte une protection qui n'agit pas. ## 4. La jointure sur `prevision` prend la prévision la plus lointaine, pas la prochaine `order by horodatage desc limit 1` rend le `max(horodatage)`, et `horodatage` est l'instant **prévu** (0011). R1 exige `0 < 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 contrat `PrevisionH1` annonce 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 : ```sql where site_id = s.site_id and horodatage > %(maintenant)s order by horodatage asc limit 1 ``` ## 5. Le journal annonce des résolutions qui ne sont écrites nulle part `%d résolue(s)` compte `rapprochement.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.py` ramasse bien `psycopg[binary]` par son `rglob` donc le venv de déploiement sera servi, `ENERVISION_RACINE` est posé par le rôle `app`, la minute 37 est justifiée, et la dérivation de `releves_imputes` avec 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.
db: le cycle de vie d'une recommandation entre en base (#164)
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 8s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 25s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 36s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 5m21s
ab69e1344e
`cycle.rapprocher`, écrit au #154, attendait de savoir où écrire. La 0019
ajoute à `public.recommandation` les trois colonnes du #153 : `statut`
(active/résolue, défaut active), `resolue_a`, et `action` — la consigne, qui
sépare une recommandation d'une alerte et que la 0014 avait oubliée. La 0014
n'est pas retouchée.

L'unicité (site, horodatage, règle, version) tombe : elle laissait coexister
deux lignes actives pour le même site et la même règle, et faisait de la
version une part de l'identité, alors que le #153 pose l'inverse — recaler un
seuil ne ferme ni ne rouvre ce qui est en cours. À sa place, un index unique
partiel sur (site_id, regle_id) où statut = 'active'.

`action text not null` devant des lignes déjà écrites (critère 5) : `default ''`
puis `drop default`. Le défaut rend l'ajout sûr quelle que soit l'histoire de
la base — sans lui, la migration échoue dès la première ligne présente, au
démarrage du serveur ; son retrait fait qu'une insertion qui oublie l'action
échoue au lieu d'écrire une consigne vide. La table est vide partout
aujourd'hui : le chargement de la zone or ne remplit que `mesure`,
`qualite_jour` et `alerte`, et l'API sert des fixtures.

Une contrainte de plus que le ticket ne demandait : `(statut = 'resolue') =
(resolue_a is not null)`. L'état et son instant ne peuvent pas se contredire,
et c'est l'invariant que la colonne existe pour tenir.

0017 était pris par le #36 et 0018 par le #168 pendant que le #164 attendait :
le fichier est donc la 0019, et non la 0017 du ticket.

Le fichier rejoint la liste `NOUVEAUX` de test_migrations_zone_or.py
(critère 4) et porte ses propres cas dans
tests/unit/db/test_migration_recommandation_cycle.py, dont la garde de dérive
avec `cycle.Resolution` et `Recommandation` du moteur.
recommandations: le job s'aligne sur le cycle de vie en base (#39, #164)
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 9s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 36s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 20s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m7s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 5m23s
65f3312607
La migration 0019 du #164 change le schéma sous ce module, et deux points
auraient cassé la passe horaire en production sans qu'aucun test ne bronche.

1. `on conflict (site_id, horodatage, regle_id, regle_version)` visait
   l'unicité de la 0014, que la 0019 SUPPRIME. PostgreSQL rend 42P10, « no
   unique or exclusion constraint matching the ON CONFLICT specification », et
   l'insertion entière échoue. Le on conflict vise maintenant l'index unique
   partiel, PRÉDICAT COMPRIS : sans le `where statut = 'active'`, l'index
   partiel n'est pas reconnu.

2. `action` est devenue une colonne `not null` sans défaut. L'insertion ne
   la renseignait pas — elle la rangeait dans valeurs_declenchantes faute de
   colonne — ce qui vaut 23502. Elle sort du JSON et va où elle doit.
   `gravite` y reste, elle n'a toujours pas de colonne.

Et deux conséquences que la migration rend enfin possibles :

3. `actives()` lit `statut` au lieu d'une fenêtre de 26 heures. L'heuristique
   oubliait une recommandation encore ouverte mais plus vieille que la fenêtre,
   la faisait ré-émettre, et l'index unique partiel aurait refusé l'insertion.

4. Les résolutions sont ÉCRITES, plus seulement comptées. Sans statut,
   a_resoudre était journalisé puis perdu : une recommandation restait ouverte
   pour toujours, et avec l'index partiel la règle ne pouvait plus jamais
   ré-émettre pour ce site.

Le SQL n'était couvert par rien : les tests du job passent par un double, ceux
de l'entrepôt ne couvraient que _imputes et dsn. Trois contrôles croisent
maintenant le texte du SQL avec celui de la migration, comme le contrat de
prévision croise son format d'identifiant avec la 0008.

Éprouvé en rouge sur quatre défauts réintroduits un par un : l'ancienne cible
du on conflict, la disparition d'action, et l'appel à resoudre retiré du job.

126 tests, ruff et mypy --strict propres.
recommandations: la fenêtre de lecture borne la règle, et un seau nul ne tue plus la passe (#39)
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 39s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 20s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m17s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 5m28s
418cc14510
Deux retours de Gabriel, tous deux fondés, et le premier rendait mon propre
correctif inopérant.

1. FRAICHEUR_MAX à 6 h ne servait à rien avec HEURES_REMONTEES à 6.
   C'est la fenêtre de LECTURE qui borne la règle, pas celle de fraîcheur : à
   3 h de retard la requête ne rend que trois seaux, à 4 h elle n'en rend que
   deux, et R2 se tait sur « 2 moyennes disponibles » au lieu de « hors de la
   fenêtre glissante ». Le retard mesuré le 08/09 était de 3 h 29 : je tenais
   avec ZÉRO marge, et un cycle de rafraîchissement manqué faisait taire la
   règle — exactement ce que mon commentaire prétendait avoir couvert.

   La valeur se DÉRIVE maintenant des deux constantes de la règle,
   FRAICHEUR_MAX + HEURES, soit 9. Recaler l'une sans l'autre est la faute
   qu'on répare : elle ne peut plus se refaire.

2. Un seau moyenne_kw à NULL faisait tomber toute la passe.
   mesure_horaire calcule avg(...) filter (where indicateur_qualite <>
   'critical') : une heure dont TOUS les relevés sont critiques rend NULL.
   C'est un état normal, c'est même ce que R3 sert à rendre visible.
   float(None) levait un TypeError hors de tout try : sept sites perdaient
   leurs recommandations parce qu'une heure d'un seul site était trouée.

   Le seau est écarté, pas remplacé par zéro : une heure sans mesure
   exploitable n'est pas une heure à zéro kW. R2 voit un trou et se dit
   indécise, ce qui est la vérité.

Le regroupement des seaux sort dans _moyennes_par_site() pour être éprouvable :
le garde ne s'observait pas tant qu'il vivait dans une fonction qui ouvre une
connexion. On éprouve le comportement, pas le texte.

Quatre contrôles ajoutés, éprouvés en rouge sur les deux défauts réintroduits.

130 tests, ruff et mypy --strict propres.
gabriel approved these changes 2026-09-08 08:42:29 +00:00
Merge branch 'develop' into lenaic/39-job-recommandations
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 41s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 19s
Infra Ansible / Playbooks Ansible valides (pull_request) Successful in 1m3s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 5m28s
e796fb5f93
lenaic merged commit cac3ec7367 into develop 2026-09-08 08:56:01 +00:00
lenaic deleted branch lenaic/39-job-recommandations 2026-09-08 08:56:01 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
g2/enervision!175
No description provided.