api : le seuil du seau reconstitué se calcule sur le type que la base rend (#267) #272

Merged
marvin merged 1 commit from gabriel/267-seau-impute-decimal into develop 2026-09-10 20:21:27 +00:00
Member

Ce qui se passait

Depuis le déploiement de main du 10/09 (#271), les quatre fenêtres du tableau de bord rendent 500 en production. L'écran /graphiques affiche « Aucune source n'a répondu » : les sept appels de courbe échouent ensemble, donc etatErreurTotale est vrai. Le message est exact, c'est l'API qui tombe.

Journal d'accès de Caddy :

200 GET /api/v1/sites
200 GET /api/v1/parc/synthese
500 GET /api/v1/sites/SITE001/mesures?fenetre=7j     <- et les 6 autres sites
500 GET /api/v1/parc/mesures?fenetre=7j

La cause

File "enervision_api/dashboard/repository.py", line 732, in _seau_impute
    return minutes_imputees >= SEUIL_SEAU_IMPUTE * minutes_retenues
TypeError: unsupported operand type(s) for *: 'float' and 'decimal.Decimal'

Les deux comptes n'ont pas le même type :

Colonne SQL Type rendu
minutes_imputees count(*) filter (…) bigintint
minutes_retenues sum(releves_pris_en_compte) bigint élargi en numericDecimal

Decimal refuse d'être multiplié par un float, et SEUIL_SEAU_IMPUTE en est un.

La garde if not minutes_retenues or not minutes_imputees masque la faute sur les seaux sans imputation. Elle n'en masquait aucun ce jour-là :

 fenêtre | seaux imputés | total
 24h     |            25 |    25
 7j      |             8 |     8

Pourquoi la chaîne était verte

Deux raisons qui s'additionnent, et la première est la vraie :

  • la fausse connexion mentait sur le type. tests/unit/api/conftest.py rendait minutes_retenues et minutes_ecartees en int nu, là où la base rend un Decimal — alors qu'elle prenait soin de modéliser le numeric de moyenne_kw. _seau_impute ne voyait donc jamais le type qui casse ;
  • le cas d'intégration qui l'aurait attrapé ne tourne pas. tests/integration/test_lectures_tableau_de_bord.py fait bien varier la part imputée autour du seuil sur un vrai PostgreSQL, mais il porte pytestmark = pytest.mark.skipif(not URL, …) et la chaîne ne lance que pytest tests/unit (ci.yml:374). Le cas existe, il n'a jamais été exécuté.

Ce que fait cette demande

  • _seau_impute convertit les deux comptes avant de comparer, avec le motif écrit dans la docstring ;
  • le jeu d'essai rend désormais les types que la base rend. À lui seul, il fait tomber les deux cas préexistants du #267 sur la ligne fautive — la couverture était là, seul le type manquait ;
  • un troisième cas nomme les deux types explicitement, pour que cette couverture ne se perde pas si quelqu'un simplifie la fixture un jour.

Vérifié

sans le correctif : 3 failed, 23 passed   (dont les 2 cas préexistants du #267)
avec le correctif : 248 passed            (tests/unit/api au complet)
ruff check + format : OK sur repository.py
mypy --strict        : Success, 23 source files

Ce que cette demande ne fait PAS

Le trou de couverture — des cas d'intégration écrits mais jamais exécutés par la chaîne — dépasse ce correctif et n'est pas refermé ici. Il mérite son propre ticket : tant qu'il est ouvert, une régression de type peut repasser exactement par le même chemin.

Urgence

L'écran principal est mort et la soutenance EC02 est demain 11/09 à 10h45. À relire en priorité, puis à déployer sur main.

## Ce qui se passait Depuis le déploiement de `main` du 10/09 (#271), **les quatre fenêtres du tableau de bord rendent 500** en production. L'écran `/graphiques` affiche « Aucune source n'a répondu » : les sept appels de courbe échouent ensemble, donc `etatErreurTotale` est vrai. Le message est exact, c'est l'API qui tombe. Journal d'accès de Caddy : ``` 200 GET /api/v1/sites 200 GET /api/v1/parc/synthese 500 GET /api/v1/sites/SITE001/mesures?fenetre=7j <- et les 6 autres sites 500 GET /api/v1/parc/mesures?fenetre=7j ``` ## La cause ``` File "enervision_api/dashboard/repository.py", line 732, in _seau_impute return minutes_imputees >= SEUIL_SEAU_IMPUTE * minutes_retenues TypeError: unsupported operand type(s) for *: 'float' and 'decimal.Decimal' ``` Les deux comptes n'ont pas le même type : | Colonne | SQL | Type rendu | |---|---|---| | `minutes_imputees` | `count(*) filter (…)` | `bigint` → `int` | | `minutes_retenues` | `sum(releves_pris_en_compte)` | `bigint` élargi en **`numeric`** → **`Decimal`** | `Decimal` refuse d'être multiplié par un `float`, et `SEUIL_SEAU_IMPUTE` en est un. La garde `if not minutes_retenues or not minutes_imputees` masque la faute sur les seaux sans imputation. Elle n'en masquait aucun ce jour-là : ``` fenêtre | seaux imputés | total 24h | 25 | 25 7j | 8 | 8 ``` ## Pourquoi la chaîne était verte Deux raisons qui s'additionnent, et la première est la vraie : - **la fausse connexion mentait sur le type.** `tests/unit/api/conftest.py` rendait `minutes_retenues` et `minutes_ecartees` en `int` nu, là où la base rend un `Decimal` — alors qu'elle prenait soin de modéliser le `numeric` de `moyenne_kw`. `_seau_impute` ne voyait donc jamais le type qui casse ; - **le cas d'intégration qui l'aurait attrapé ne tourne pas.** `tests/integration/test_lectures_tableau_de_bord.py` fait bien varier la part imputée autour du seuil sur un vrai PostgreSQL, mais il porte `pytestmark = pytest.mark.skipif(not URL, …)` et la chaîne ne lance que `pytest tests/unit` (`ci.yml:374`). Le cas existe, il n'a jamais été exécuté. ## Ce que fait cette demande - `_seau_impute` convertit **les deux** comptes avant de comparer, avec le motif écrit dans la docstring ; - le jeu d'essai rend désormais les types que la base rend. **À lui seul, il fait tomber les deux cas préexistants du #267** sur la ligne fautive — la couverture était là, seul le type manquait ; - un troisième cas nomme les deux types explicitement, pour que cette couverture ne se perde pas si quelqu'un simplifie la fixture un jour. ### Vérifié ``` sans le correctif : 3 failed, 23 passed (dont les 2 cas préexistants du #267) avec le correctif : 248 passed (tests/unit/api au complet) ruff check + format : OK sur repository.py mypy --strict : Success, 23 source files ``` ## Ce que cette demande ne fait PAS Le trou de couverture — des cas d'intégration écrits mais jamais exécutés par la chaîne — dépasse ce correctif et **n'est pas refermé ici**. Il mérite son propre ticket : tant qu'il est ouvert, une régression de type peut repasser exactement par le même chemin. ## Urgence L'écran principal est mort et la soutenance EC02 est demain 11/09 à 10h45. À relire en priorité, puis à déployer sur `main`.
api : le seuil du seau reconstitué se calcule sur le type que la base rend (#267)
All checks were successful
Intégration / Contrôles statiques du dépôt (pull_request) Successful in 10s
Intégration / Terraform — format, validité et lint (pull_request) Successful in 16s
Intégration / Checkov — audit de la configuration (pull_request) Successful in 17s
Intégration / Tableau de bord — dépendances, tests et construction (pull_request) Successful in 49s
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 6m15s
938dd656c8
Les quatre fenêtres du tableau de bord rendaient 500 en production depuis le
déploiement de main du 10/09 : l'écran affichait « Aucune source n'a répondu »
sur les sept sites, les sept appels de courbe échouant ensemble.

    File "enervision_api/dashboard/repository.py", line 732, in _seau_impute
        return minutes_imputees >= SEUIL_SEAU_IMPUTE * minutes_retenues
    TypeError: unsupported operand type(s) for *: 'float' and 'decimal.Decimal'

Les deux comptes n'ont pas le même type. « minutes_imputees » vient d'un
count(*) filter, donc un bigint, donc un int. « minutes_retenues » vient d'un
sum(releves_pris_en_compte) : PostgreSQL élargit la somme d'un bigint en
numeric, et psycopg rend un Decimal. Or Decimal refuse d'être multiplié par un
float, et SEUIL_SEAU_IMPUTE en est un.

La garde « if not minutes_retenues or not minutes_imputees » masquait la faute
sur les seaux sans imputation. Elle n'en masquait aucun ce jour-là : les
vingt-cinq seaux de la fenêtre 24 h comme les huit de la fenêtre 7 j portaient
tous au moins une minute reconstituée.

Pourquoi la suite unitaire était verte : la fausse connexion rendait
« minutes_retenues » et « minutes_ecartees » en int nu là où la base rend un
Decimal — elle prenait pourtant soin de modéliser le numeric de « moyenne_kw ».
Le jeu d'essai est corrigé, et il suffit à faire tomber les deux cas
préexistants du #267 sur la ligne fautive. Un troisième cas nomme les deux
types explicitement, pour que la couverture ne se perde pas si quelqu'un
simplifie la fixture un jour.

Le cas d'intégration qui aurait attrapé ceci existe
(tests/integration/test_lectures_tableau_de_bord.py, la part imputée autour du
seuil) mais ne tourne pas : il est marqué skipif sans base, et la chaîne ne
lance que « pytest tests/unit ». Ce trou-là dépasse ce correctif et n'est pas
refermé ici.
gabriel 2026-09-10 20:03:24 +00:00
marvin approved these changes 2026-09-10 20:20:27 +00:00
marvin left a comment

Relu et rejoué en local : pytest tests/unit/api → 248 passed sur la branche, et 4 failed dès qu'on rétablit la ligne fautive (les 2 cas préexistants du #267 + le nouveau). La correction de la fixture qui rend enfin minutes_retenues/minutes_ecartees en Decimal est ce qui redonne du mordant à la couverture. ruff + mypy --strict OK sur repository.py, les 6 jobs de la chaîne sont verts.

Le correctif est juste : conversion des deux opérandes, robuste aussi pour le chemin 10 min où minutes_retenues est un int.

OK pour fusionner et déployer sur main. Le trou de couverture (cas d'intégration jamais exécutés par la chaîne) reste ouvert et mérite son propre ticket, comme le dit la demande.

Relu et rejoué en local : `pytest tests/unit/api` → 248 passed sur la branche, et 4 failed dès qu'on rétablit la ligne fautive (les 2 cas préexistants du #267 + le nouveau). La correction de la fixture qui rend enfin `minutes_retenues`/`minutes_ecartees` en `Decimal` est ce qui redonne du mordant à la couverture. `ruff` + `mypy --strict` OK sur `repository.py`, les 6 jobs de la chaîne sont verts. Le correctif est juste : conversion des deux opérandes, robuste aussi pour le chemin 10 min où `minutes_retenues` est un `int`. OK pour fusionner et déployer sur `main`. Le trou de couverture (cas d'intégration jamais exécutés par la chaîne) reste ouvert et mérite son propre ticket, comme le dit la demande.
marvin merged commit a701582c55 into develop 2026-09-10 20:21:27 +00:00
marvin deleted branch gabriel/267-seau-impute-decimal 2026-09-10 20:21:27 +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!272
No description provided.