[39] Recommandations : les trois règles du #153 #154

Merged
gabriel merged 7 commits from lenaic/39-regles-recommandations into develop 2026-09-07 13:05:48 +00:00
Owner

Ferme #39, et applique le #153.

Gabriel a écrit le ticket de spécification pendant que j'écrivais la mécanique :
pour chacune des trois règles, l'observation, la fenêtre, la condition exacte, la
phrase rendue et l'action. Rien n'est inventé ici.

r1  prevision_kw >= 0,90 x capacite_kw
r2  trois moyennes horaires consécutives >= 0,85 x capacite_kw
r3  taux_disponibilite du jour < 0,80

Le contrat d'entrée colle au schéma, pas à mon idée du schéma

Les champs reprennent les colonnes réelles des migrations : valeur_prevue_kw de
prevision (0011), moyenne_kw de mesure_horaire (0016), taux_disponibilite
de qualite_jour (0013), capacite_kw du référentiel (0008). Le #153 restreint
les sources à la zone or, ni argent ni bronze.

Les règles ne vont pas chercher leurs données, on les leur apporte. Sans ça,
trois règles liraient la même grandeur de trois façons.

Trois verdicts, et non deux

Une règle rend un déclenchement, un silence, ou une indécision en nommant ce
qui lui a manqué.

Sans le troisième, « je n'ai pas les données » et « tout va bien » rendent la
même chose, et un site muet passe pour un site sain. R2 en est l'exemple :
deux heures de mesure ne permettent pas de juger d'une persistance sur trois, et
se taire laisserait croire que la charge est normale.

Le cycle de vie, et la décision qui le gouverne

cycle.rapprocher confronte les recommandations actives à ce qu'une passe vient
de produire, et dit quoi ouvrir, quoi garder, quoi résoudre. L'état ne vit pas
ici, la persistance appartient à l'appelant.

On ne résout que sur un silence, jamais sur une indécision. Une règle qui n'a
pas pu juger ne prouve pas que le problème a disparu ; résoudre annoncerait
« c'est réglé » alors qu'on n'a pas regardé. Le moteur enregistre donc les
silences, distincts des indécisions.

L'identité d'une recommandation active est le couple site et règle, pas la
version : recaler un seuil ne doit ni fermer ni rouvrir ce qui est en cours.

Les seuils se changent sans toucher à la mécanique

Chacun vit dans une constante SEUIL en tête de son module. Le #153 les annonce
comme des valeurs de départ à recaler après 24 à 48 h de données réelles : les
changer coûte un nombre et un incrément de correctif de version.

Éprouvé

90 cas, couverture à 100 % sur 269 instructions, pour un palier exigé à 85 %.
ruff et mypy strict propres.

Dont la preuve que demande le #153 : un parc de quatre sites produit exactement
trois recommandations, une par règle, le quatrième site restant muet. Et un site
sans zone or rend trois indécisions au lieu de passer pour sain.

Les bornes sont éprouvées des deux côtés : 90 % pile déclenche R1 (« >= » dans le
ticket), 80 % pile ne déclenche pas R3 (« < » dans le ticket).

Deux écarts entre la spécification et le schéma existant

À trancher, et je ne les ai pas tranchés seul. Détail en commentaire du #153.

qualite_jour (migration 0013) n'a pas de colonne releves_imputes : elle porte
releves_manquants et un repartition_methode en jsonb. Le contrat expose le
champ, la requête qui l'alimente devra le tirer du jsonb.

La table recommandation (migration 0014) n'a ni colonne de statut, ni
resolved_at, ni champ d'action
: seulement libelle. Et sa contrainte
d'unicité, (site_id, horodatage, regle_id, regle_version), décrit un rejeu
idempotent par horodatage, ce qui n'est pas le même modèle qu'une recommandation
qui s'ouvre une fois et se referme plus tard. Le rapprochement est prêt côté
code ; la persistance demande une décision de schéma.

Un choix d'emplacement à valider

La chaîne attendait ces règles dans services/api/enervision_api/rules, déclaré
en zone sensible depuis le #40. Le module vit ailleurs : les règles n'ont besoin
de rien de l'API, et les y enfermer obligerait à monter l'API pour les éprouver.
Le #39 met de toute façon l'affichage hors périmètre.

Les tests, eux, sont bien dans tests/unit/rules/, chemin que le #153 nomme.

Si l'équipe préfère l'autre emplacement pour le module, le déplacement coûte un
git mv.

Ferme #39, et applique le #153. Gabriel a écrit le ticket de spécification pendant que j'écrivais la mécanique : pour chacune des trois règles, l'observation, la fenêtre, la condition exacte, la phrase rendue et l'action. **Rien n'est inventé ici.** ``` r1 prevision_kw >= 0,90 x capacite_kw r2 trois moyennes horaires consécutives >= 0,85 x capacite_kw r3 taux_disponibilite du jour < 0,80 ``` ## Le contrat d'entrée colle au schéma, pas à mon idée du schéma Les champs reprennent les colonnes réelles des migrations : `valeur_prevue_kw` de `prevision` (0011), `moyenne_kw` de `mesure_horaire` (0016), `taux_disponibilite` de `qualite_jour` (0013), `capacite_kw` du référentiel (0008). Le #153 restreint les sources à la zone or, ni argent ni bronze. Les règles ne vont pas chercher leurs données, on les leur apporte. Sans ça, trois règles liraient la même grandeur de trois façons. ## Trois verdicts, et non deux Une règle rend un déclenchement, un silence, ou une **indécision** en nommant ce qui lui a manqué. Sans le troisième, « je n'ai pas les données » et « tout va bien » rendent la même chose, et **un site muet passe pour un site sain**. R2 en est l'exemple : deux heures de mesure ne permettent pas de juger d'une persistance sur trois, et se taire laisserait croire que la charge est normale. ## Le cycle de vie, et la décision qui le gouverne `cycle.rapprocher` confronte les recommandations actives à ce qu'une passe vient de produire, et dit quoi ouvrir, quoi garder, quoi résoudre. L'état ne vit pas ici, la persistance appartient à l'appelant. **On ne résout que sur un silence, jamais sur une indécision.** Une règle qui n'a pas pu juger ne prouve pas que le problème a disparu ; résoudre annoncerait « c'est réglé » alors qu'on n'a pas regardé. Le moteur enregistre donc les silences, distincts des indécisions. L'identité d'une recommandation active est le couple **site et règle**, pas la version : recaler un seuil ne doit ni fermer ni rouvrir ce qui est en cours. ## Les seuils se changent sans toucher à la mécanique Chacun vit dans une constante `SEUIL` en tête de son module. Le #153 les annonce comme des valeurs de départ à recaler après 24 à 48 h de données réelles : les changer coûte un nombre et un incrément de correctif de version. ## Éprouvé **90 cas, couverture à 100 %** sur 269 instructions, pour un palier exigé à 85 %. `ruff` et `mypy` strict propres. Dont la preuve que demande le #153 : un parc de quatre sites produit exactement trois recommandations, une par règle, le quatrième site restant muet. Et un site sans zone or rend **trois indécisions** au lieu de passer pour sain. Les bornes sont éprouvées des deux côtés : 90 % pile déclenche R1 (« >= » dans le ticket), 80 % pile ne déclenche pas R3 (« < » dans le ticket). ## Deux écarts entre la spécification et le schéma existant À trancher, et je ne les ai pas tranchés seul. Détail en commentaire du #153. `qualite_jour` (migration 0013) n'a pas de colonne `releves_imputes` : elle porte `releves_manquants` et un `repartition_methode` en jsonb. Le contrat expose le champ, la requête qui l'alimente devra le tirer du jsonb. La table `recommandation` (migration 0014) n'a **ni colonne de statut, ni `resolved_at`, ni champ d'action** : seulement `libelle`. Et sa contrainte d'unicité, `(site_id, horodatage, regle_id, regle_version)`, décrit un rejeu idempotent par horodatage, ce qui n'est pas le même modèle qu'une recommandation qui s'ouvre une fois et se referme plus tard. Le rapprochement est prêt côté code ; la persistance demande une décision de schéma. ## Un choix d'emplacement à valider La chaîne attendait ces règles dans `services/api/enervision_api/rules`, déclaré en zone sensible depuis le #40. Le module vit ailleurs : les règles n'ont besoin de rien de l'API, et les y enfermer obligerait à monter l'API pour les éprouver. Le #39 met de toute façon l'affichage hors périmètre. Les tests, eux, sont bien dans `tests/unit/rules/`, chemin que le #153 nomme. **Si l'équipe préfère l'autre emplacement pour le module, le déplacement coûte un `git mv`.**
lenaic self-assigned this 2026-09-04 13:43:10 +00:00
Le #39 exige que le contenu métier des trois règles vienne d'un ticket de
spécification. Ce ticket n'existe pas : ni seuils, ni conditions de
déclenchement, ni formulations. Le catalogue est donc vide, et le rester est
délibéré : écrire ici des règles inventées reviendrait à livrer un jugement
métier sous couvert de code.

Ce qui n'en dépend pas est fait, et éprouvé.

Le contrat d'entrée d'abord, parce qu'il décide du reste. Les règles ne vont pas
chercher leurs données, on les leur apporte : sans ça, trois règles liraient la
même grandeur de trois façons. C'est aussi ce qui rend le module indépendant de
la zone or et du service d'inférence, qui n'existent pas encore.

Trois verdicts et non deux. Une règle rend un déclenchement, un silence, ou une
INDÉCISION en nommant ce qui lui a manqué. Le troisième est celui qu'on oublie :
sans lui, « je n'ai pas les données » et « tout va bien » rendent la même chose,
et un site muet passe pour un site sain. C'est la distinction que la zone argent
fait entre une minute jamais collectée et une valeur nulle remontée.

La traçabilité que demande l'EF-09 tient dans la sortie : la règle, sa version,
l'instant de la donnée, celui de l'évaluation, et les valeurs déclenchantes. Les
deux instants sont distincts, les confondre rendrait un rejeu indiscernable de
la passe d'origine.

Le moteur reçoit son horloge au lieu de la prendre, comme le job de la zone
argent, sans quoi rien ne serait éprouvable. Une règle qui lève une exception
n'emporte pas les autres, mais rien n'est avalé : défaillances et indécisions
ressortent dans le résultat. L'ordre de sortie est stable.

Le module vit à part plutôt que dans `enervision_api/rules`, où la chaîne
l'attendait. Les règles n'ont besoin de rien de l'API, et les y enfermer
obligerait à monter l'API pour les éprouver. Le chemin de l'API reste déclaré en
zone sensible, vide et sauté tant qu'il l'est.

37 cas, couverture à 100 % pour un palier exigé à 85 %. mypy strict et ruff
propres.

Contribue au #39
Fusionner develop dans la branche des recommandations
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 20s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m21s
0834461d4b
feat(recommandations): les trois règles du #153
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 19s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m26s
43770df9b8
Gabriel a écrit le ticket de spécification, #153 : pour chacune des trois,
l'observation, la fenêtre, la condition exacte, la phrase rendue et l'action
recommandée. Rien n'est inventé ici.

  r1  prevision_kw >= 0,90 x capacite_kw
  r2  trois moyennes horaires consécutives >= 0,85 x capacite_kw
  r3  taux_disponibilite du jour < 0,80

Le contrat d'entrée s'aligne sur les colonnes réelles de la zone or plutôt que
sur des noms choisis ici : `valeur_prevue_kw` de `prevision`, `moyenne_kw` de
`mesure_horaire`, `taux_disponibilite` de `qualite_jour`, `capacite_kw` du
référentiel. Le #153 restreint les sources à la zone or, ni argent ni bronze.

Chaque seuil vit dans une constante en tête de son module. Le #153 les annonce
comme des valeurs de départ à recaler après 24 à 48 h : les changer doit coûter
un nombre et un incrément de correctif, jamais une retouche de la mécanique.

Les trois règles se déclarent indécidables plutôt que silencieuses quand la
donnée manque, en nommant ce qui manque. R2 en particulier : deux heures ne
permettent pas de juger d'une persistance sur trois, et se taire laisserait
croire que la charge est normale. Une capacité nulle ou négative est refusée
avant la division, pas après.

Le cycle de vie demandé par le #153 vit dans `cycle.rapprocher`, qui confronte
l'état connu au résultat d'une passe et dit quoi ouvrir, garder, résoudre. L'état
n'est pas ici, la persistance appartient à l'appelant.

LA DÉCISION DE CE MODULE : on ne résout que sur un silence, jamais sur une
indécision. Une règle qui n'a pas pu juger ne prouve pas que le problème a
disparu, et résoudre annoncerait « c'est réglé » alors qu'on n'a pas regardé.
Le moteur enregistre donc les silences, distincts des indécisions.

L'identité d'une recommandation active est le couple site et règle, pas la
version : recaler un seuil ne doit ni fermer ni rouvrir ce qui est en cours.

Les tests passent de `tests/unit/recommandations` à `tests/unit/rules`, chemin
que le #153 nomme dans sa section de vérification.

90 cas, couverture à 100 % sur 269 instructions pour un palier exigé à 85 %.
Dont la preuve demandée par le ticket : un parc de quatre sites produisant
exactement trois recommandations, une par règle, le quatrième site restant muet,
et un site sans zone or rendant trois indécisions plutôt que de passer pour sain.

Contribue au #39, ferme la dépendance sur #153
lenaic changed title from [39] Recommandations : la mécanique des règles, sans les règles to [39] Recommandations : les trois règles du #153 2026-09-04 13:52:21 +00:00
lenaic requested review from gabriel 2026-09-04 13:53:19 +00:00
fix(recommandations): quatre défauts trouvés en relisant ma propre branche
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 19s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m29s
727860e9c0
Deux fonctionnels, deux de forme.

R1 déclenchait sur une prévision périmée, et l'affichait. Une prévision émise
pour midi, évaluée à 14 h, produisait « la consommation prévue à 12h00 atteint
480 kW » et recommandait de délester pour une heure déjà passée. Un exploitant
qui lit ça une fois ne lit plus les suivantes. La règle exige maintenant que la
prévision vise l'avenir, dans les deux heures : deux et non une, pour qu'une
passe en retard de dix minutes reste jugeable.

R2 ne pouvait pas vérifier que les trois heures se suivaient. Le #153 dit
« consécutives », et le contrat ne transportait que des nombres nus : la règle
était structurellement incapable de distinguer 11h, 12h, 13h de 9h, 14h, 18h, et
annonçait une pointe persistante qui n'avait jamais eu lieu. Le cas n'est pas
théorique, R3 existe précisément parce que la collecte a des trous.

Le contrat transporte donc `MoyenneHoraire`, l'heure avec la valeur. R2 trie,
vérifie la contiguïté au pas d'une heure, et refuse une fenêtre trop ancienne :
trois heures d'hier ne disent rien de la charge de maintenant.

Quatre `assert` servaient à rétrécir les types pour mypy. `python -O` les
supprime, et le code aurait continué avec des `None`. Remplacés par un `if` qui
rétrécit aussi bien et que rien ne supprime. Le motif d'indécision continue de
nommer précisément ce qui manque.

Et un de mes tests portait un nom faux : « la mise à plat est prête pour la table
recommandation » alors qu'aucune clé ne porte le nom de sa colonne. Il passait
parce qu'il vérifiait la fonction contre elle-même, ce que j'ai reproché à mon
propre banc ce matin. Renommé, et doublé d'un test qui consigne l'écart avec la
migration 0014 plutôt que de le laisser découvrir.

98 cas, couverture à 100 % sur 286 instructions.
Member

Relu #154 en entier — mécanique, règles et cycle de vie sont solides, rien à redire sur le fond. Prêt à merger côté code.

Le point bloquant (schéma recommandation) est réglé sur #153 : Olivier a validé, ticket de suivi #164 ouvert pour la migration. cycle.rapprocher n'en dépend pas pour merger, il est déjà écrit et éprouvé pour recevoir la persistance une fois #164 fermé.

Deux points encore ouverts, non bloquants :

  1. Emplacement du moduleservices/recommendations vs services/api/enervision_api/rules (zone sensible depuis #40). La PR propose un git mv si l'équipe préfère l'autre emplacement : à trancher.
  2. Nommagevaleurs de R1/R2 utilise la clé capacity_kw (anglais) alors que R3 et le reste du contrat (Observation, motifs) utilisent capacite_kw (français). Sans impact fonctionnel, mais maintenant figé dans les tests : à corriger avant que #38 (API) ne s'appuie sur ce contrat, sinon l'incohérence se propage.
Relu #154 en entier — mécanique, règles et cycle de vie sont solides, rien à redire sur le fond. Prêt à merger côté code. Le point bloquant (schéma `recommandation`) est réglé sur #153 : Olivier a validé, ticket de suivi #164 ouvert pour la migration. `cycle.rapprocher` n'en dépend pas pour merger, il est déjà écrit et éprouvé pour recevoir la persistance une fois #164 fermé. Deux points encore ouverts, non bloquants : 1. **Emplacement du module** — `services/recommendations` vs `services/api/enervision_api/rules` (zone sensible depuis #40). La PR propose un `git mv` si l'équipe préfère l'autre emplacement : à trancher. 2. **Nommage** — `valeurs` de R1/R2 utilise la clé `capacity_kw` (anglais) alors que R3 et le reste du contrat (`Observation`, motifs) utilisent `capacite_kw` (français). Sans impact fonctionnel, mais maintenant figé dans les tests : à corriger avant que #38 (API) ne s'appuie sur ce contrat, sinon l'incohérence se propage.
gabriel approved these changes 2026-09-07 08:19:32 +00:00
Dismissed
Merge branch 'develop' into lenaic/39-regles-recommandations
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Aucun secret commité (pull_request) Successful in 4s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 19s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m23s
a9daba7592
recommandations: deux pertes silencieuses, et le module enfin typé (#39)
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 6s
Intégration / Aucun secret commité (pull_request) Successful in 3s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 20s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 3m54s
5d230d546a
Trois défauts trouvés en relisant ma propre branche avant de la faire fusionner.

1. rapprocher() perdait une recommandation sans trace.
   `emises` était une compréhension de dictionnaire sur (site, règle) : deux
   observations du même site dans une passe, et la seconde écrasait la
   première. Ce n'est pas théorique, evaluer() boucle sur les observations et
   les trie par (site_id, instant), ce qui montre bien qu'on en attend
   plusieurs. La plus récente est retenue, l'autre est RENDUE à l'appelant dans
   Rapprochement.doublons au lieu de disparaître.

2. en_ligne() rendait une copie de surface.
   dict(self.valeurs) ne copie que le premier niveau, or les valeurs de R2
   portent deux listes. Un appelant qui triait ou vidait valeurs["heures"]
   réécrivait la justification d'une recommandation déclarée frozen, c'est à
   dire exactement ce qui permet de refaire le jugement six mois plus tard.
   Copie profonde ici et dans le moteur.

3. services/recommendations/ n'avait AUCUN pyproject.toml.
   La boucle mypy de la chaîne n'ouvre que ceux qui déclarent [tool.mypy] : le
   module n'était pas typé du tout, pendant que la demande annonçait
   « mypy --strict propre ». Il l'est maintenant, 11 fichiers, sans erreur. Le
   format suit les 100 colonnes des autres services.

Et un test qui affirmait plus qu'il ne vérifiait : il s'appelait « les valeurs
déclenchantes sont celles du ticket » et exigeait la clé `heures`, absente du
#153. Il dit maintenant que c'est un ajout, et pourquoi.

Les trois correctifs sont éprouvés dans les deux sens : rouge sur chaque défaut
réintroduit un par un. Le premier test de la copie profonde est d'ailleurs
passé au vert sur le défaut, parce qu'il comparait la liste à elle même ; il
compare maintenant à un littéral, et le piège est écrit dedans.

103 tests, 100 % de couverture sur 299 instructions.
lenaic dismissed gabriel's review 2026-09-07 11:08:50 +00:00
Reason:

New commits pushed, approval review dismissed automatically according to repository settings

Merge remote-tracking branch 'origin/develop' into lenaic/39-regles-recommandations
All checks were successful
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 33s
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 5s
Intégration / Workflows — lint et audit de sécurité (pull_request) Successful in 18s
Intégration / Python — qualité, tests et dépendances (pull_request) Successful in 4m16s
952d9228bb
# Conflicts:
#	pytest.ini
gabriel merged commit 631765e737 into develop 2026-09-07 13:05:48 +00:00
gabriel deleted branch lenaic/39-regles-recommandations 2026-09-07 13:05:49 +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!154
No description provided.