Skip to content

feat(ph): switch calibration to two-points (Gravity v2.0)#9

Merged
gaetanars merged 6 commits intomainfrom
feat/ph-two-point-calibration
Apr 26, 2026
Merged

feat(ph): switch calibration to two-points (Gravity v2.0)#9
gaetanars merged 6 commits intomainfrom
feat/ph-two-point-calibration

Conversation

@gaetanars
Copy link
Copy Markdown
Owner

Summary

Le calcul du pH passe d'une calibration single-point pH 7 (offset additif sur la valeur d'usine, pente figée à 3.56 pH/V) à une calibration two-points pH 7 + pH 4 qui recalcule slope ET intercept. Le vieillissement de la sonde Gravity v2.0 (DFRobot SEN0161-V2) ne produira plus silencieusement un pH faux : un cycle de calibration en bains tampons de 6 min 48 s pose une nouvelle droite de Nernst et l'expose en diagnostics HA (slope, intercept, V_pH7, V_pH4, dernier résultat).

L'opérateur déclenche la séquence depuis HA via "Démarrer calibration pH" et est guidé par notifications. Les valeurs sont rejetées avec messages explicites en cas de bains inversés, sonde HS, capture instable ou capteur indisponible — le firmware préserve toujours la dernière calibration valide.

Comportement

Avant Après
1 bouton "pH Calibration 7.00" → corrige uniquement l'offset, pente figée 2 boutons : "Démarrer calibration pH" (séquence pH 7 → pH 4 guidée) et "Reset calibration pH usine"
1 sonde diagnostic pH Offset 4 sondes diagnostic numériques + 1 text_sensor : pH Slope, pH Intercept, V_pH7, V_pH4, pH Calibration Last Result
Pas de garde-fou 5 lignes de défense : NaN guard sur capture, multi-capture spread max 50 mV, V_pH7 > V_pH4, slope ∈ [2.5, 5.0] pH/V, verrou anti-race reset-vs-calibration
pool_ph continue de latcher pendant calibration pool_ph figé pendant calib + 3 min après (purge de la fenêtre glissante du moving-average)

Décisions techniques

  • Script _ph_calibration en mode: single. La séquence est un pipeline à état partiel (V_pH7 capturé avant V_pH4). restart annulerait avec un état partiellement consistant ; queued max_runs:1 drop silencieusement les triggers. single ignore le re-press en cours.

  • Verrou g_ph_calibration_in_progress (R13). mode: single empêche le re-press du démarrage mais ne protège pas contre des écritures concurrentes du bouton reset. Le flag bloque le reset pendant la séquence et est libéré comme dernière action de chaque chemin de sortie (succès + 3 abort handlers) pour fermer la race "reset entre flag-flip et notif success" identifiée par adversarial review.

  • Multi-capture 3 échantillons espacés de 12 s, spread max 50 mV (R14). ADS1115 update_interval=10s cache la dernière valeur entre publications ; un espacement de 5 s aurait collapsé 3 captures en 2 effective samples. 12 s > 10 s garantit qu'au moins une nouvelle publication ADS1115 a eu lieu entre captures.

  • Cooldown post-calibration (R15 + R-2). Le filtre sliding_window_moving_average (180 s) sur realtime_ph reste saturé de bains tampons après la fin de la calibration. Sans cooldown, le tick interval suivant immédiatement la calibration latcherait un mix bain/pool dans pool_ph. Un compteur g_ph_post_cal_cooldown set à 3 ticks (3 min) à chaque sortie, decrement à chaque interval, prolonge le gating jusqu'à ce que la fenêtre glissante se vide.

  • text_sensor alimenté par un code int (R16). Le brainstorm initial spécifiait un global g_ph_last_result de type std::string. L'implémentation a opté pour g_ph_last_result_code (int + lookup lambda dans le text_sensor) parce que restore_value: true n'est pas fiable sur les std::string globals en ESPHome (le mécanisme de préférences travaille mal avec les types de taille variable). Comportement utilisateur identique ; codes 0-8 documentés en header de fichier et co-localisés avec le switch.

Validation

  • esphome config valide sur les 8 presets (salt_full, salt_minimal, salt_wo_redox, salt_wo_ph, salt_booster_full, salt_booster_minimal, salt_booster_wo_redox, salt_booster_wo_ph) avec ESPHome 2026.4.1 local après sed-rewrite des URLs github:// vers !include.
  • Math vérifiée par dérivation manuelle des AE1/AE2/AE3 du brainstorm origin.
  • Tous les exit paths du script clearrent le flag g_ph_calibration_in_progress ET set le cooldown : 4 occurrences de chaque (vérifié par grep dans le commit f362025).

Tests hardware requis post-flash (pas de test suite en firmware-as-config) :

  • Calibration nominale en bains tampons, vérifier la concordance ±0.1 pH avec un pH-mètre de référence sur la plage 6.8–8.0
  • Race test : presser "Reset calibration pH usine" pendant qu'une calibration tourne. Attendu : notif "Calibration en cours, reset ignoré", g_ph_slope et g_ph_intercept inchangés
  • Gating test : démarrer calibration avec pompe tournante depuis > pump_uptime_delay, vérifier que pool_ph reste figé pendant la calib + 3 min après
  • Recovery test : couper le WiFi pendant la séquence. Après reconnexion, vérifier que pH Calibration Last Result reflète l'issue (R16)

Post-Deploy Monitoring & Validation

  • Flash : esphome run salt_full.yaml (ou preset choisi), monitorer esphome logs
  • Boot : confirmer g_ph_calibration_in_progress = false (éphémère, repart à false) et g_ph_last_result_code restauré depuis NVS (0 si jamais calibré, sinon code de la dernière issue)
  • Cycle de calibration (~6 min 48 s) : vérifier les 4 notifs HA aux étapes clés (départ pH 7, transition pH 4, succès) et le pH Calibration Last Result final = OK pente=X.XXX, intercept=Y.YYY
  • Failure signals : text_sensor reste sur Echec:... ou Rejet:... sans cause hardware identifiable, OU pool_ph aberrant > 3 min après fin de calibration
  • Rollback : OTA back firmware antérieur, OU bouton "Reset calibration pH usine" pour revenir aux valeurs d'usine (3.56, -1.889)

Known Residuals (accepted, not blocking)

ID Sev Description Décision
R-8 P3 Scratch globals g_ph_cap_a/b/c réutilisés silencieusement entre pH 7 et pH 4 sans documentation explicite Cosmetic — code correct, comment manquant
R-9 P3 g_ph_abort lit comme un flag subsystem-wide vs script-only Cosmetic — scope en pratique limité au script
R-10 P3 g_ph_calibration_in_progress non exposé en binary_sensor Acceptable — automations HA inferent depuis les notifs persistentes ; calibration manuelle infréquente
R-11 P3 g_ph_last_result_code int non exposé en sensor numérique Acceptable — agents non-FR doivent parser le text_sensor français mais c'est un cas marginal
R-12 Test plan documentation gaps (NaN sur 2ᵉ/3ᵉ sous-capture, seuil exact 50 mV, recovery durant 1ʳᵉ stabilisation pH 7) À compléter dans le plan si révision
adv-004 V_pH7/V_pH4 persistés sur rejet R4/R5 Intentionnel par design — audit SAV explicitement documenté dans le plan + brainstorm

Pipeline de génération

Produit via /ce-brainstorm/ce-plan/ce-work/ce-doc-review (3 reviewers, 5 findings appliqués) → /ce-code-review (8 reviewers, cross-reviewer agreement sur 2 P2, 6 fixes appliqués via le Residual Work Gate). Origine : docs/brainstorms/2026-04-25-001-ph-two-point-calibration-requirements.md. Plan : docs/plans/2026-04-25-001-feat-ph-two-point-calibration-plan.md.

BREAKING CHANGE

L'OTA depuis la version précédente du firmware invalide l'ancien global g_ph_offset. Les nouveaux globals g_ph_slope et g_ph_intercept démarrent sur leurs initial_value (3.56, -1.889) — qui reproduisent la formule pré-calibration pH = 3.56 × V − 1.889 du firmware précédent.

Tant que g_ph_offset était à 0 (cas le plus courant pour une sonde non encore manuellement calibrée), le pH affiché reste numériquement identique post-OTA. Si une calibration single-point était en place (par ex. g_ph_offset = 0.15 après réglage manuel), la nouvelle calibration two-points doit être exécutée pour retrouver une mesure correcte.


Compound Engineering
Claude Code

Capture le travail de cadrage pour migrer packages/ph.yaml d'une calibration
single-point pH 7 (offset additif) vers une calibration two-points pH 7+pH 4
qui recalcule slope ET intercept. Le plan intègre les findings de la
doc-review du 2026-04-26 : verrou anti-race reset-vs-calibration,
multi-capture spread check (couvre staleness I²C invisibles au NaN guard),
gating de l'interval pendant la calib (évite la pollution de pool_ph),
text_sensor de recovery résilient à la déconnexion HA.
Remplace l'unique offset additif `g_ph_offset` par un modèle slope+intercept
explicite (`pH = g_ph_slope * V + g_ph_intercept`). Les valeurs d'usine
(3.56, -1.889) reproduisent la formule pré-calibration — comportement
inchangé tant qu'aucune calibration n'est lancée.

Mise en place des globals dont U2 a besoin :
- g_ph_slope, g_ph_intercept (restored, valeurs d'usine au premier boot)
- g_v_ph7, g_v_ph4 (restored, dernières captures pour audit/SAV)
- g_ph_calibration_in_progress (NON restored — repart à false au boot,
  le bouton reset reste utilisable même après reboot mid-séquence)
- g_ph_last_result_code (restored, int — std::string n'est pas fiable
  en restore_value sur ESPHome ; le text_sensor mappe le code vers une
  chaîne via lambda)

Surface diagnostic (R10, R11, R16) :
- pH Slope, pH Intercept, V_pH7, V_pH4 (sensors numériques)
- pH Calibration Last Result (text_sensor avec lookup par code)

Le bloc `interval: 1min` qui latche `g_store_pool_ph` est conditionné sur
`!id(g_ph_calibration_in_progress)` (R15) — la calibration U2 figera
proprement `pool_ph` même si la pompe tournait depuis longtemps.

L'OTA depuis l'ancien firmware invalide `g_ph_offset` ; les valeurs
d'usine reproduisent la formule pré-calibration donc le pH affiché reste
numériquement identique tant que l'utilisateur ne lance pas la nouvelle
calibration two-points (livrée en U2).

Refs : R1, R2, R3, R10, R11, R13, R15, R16
Plan : docs/plans/2026-04-25-001-feat-ph-two-point-calibration-plan.md
…otection

Ajoute la séquence interactive complète : un bouton "Démarrer calibration pH"
qui orchestre pH 7 → pH 4 (~6 min 20 s) avec garde-fous, et un bouton
"Reset calibration pH usine" gardé contre la race avec le script.

Script `_ph_calibration` (mode: single) :
- Verrou g_ph_calibration_in_progress posé à l'entrée, libéré sur les
  4 chemins de sortie (3 abort handlers + 1 succès) — vérifié par grep
  qui retourne exactement 4 occurrences de "= false"
- Multi-capture R14 : 3 échantillons espacés de 5 s en fin de fenêtre
  de stabilisation 180 s, spread max 50 mV. Couvre les staleness I²C
  invisibles au NaN guard (ex: bus figé qui retourne dernière valeur
  valide, latence I²C qui produit lecture non-stabilisée)
- NaN guard R12 sur chaque sous-capture : sur NaN détecté, code d'erreur
  4 (pH 7) ou 5 (pH 4), abort, aucun global slope/intercept écrit
- Garde-fous R4 + R5 calculés sur variables locales avant écriture :
  V_pH7 > V_pH4 (sinon code 2 "bains inversés"), slope ∈ [2.5, 5.0]
  (sinon code 3 "pente hors plage", message dynamique inclut la pente
  rejetée calculée à partir des V capturés)
- Notifications HA wrappées dans api.connected: ; les messages dynamiques
  (slope/intercept, V capturées, spread mV) embarqués via snprintf

Bouton "Reset calibration pH usine" (R13) :
- Vérifie g_ph_calibration_in_progress en premier ; si true, notif
  "Reset ignoré, calibration en cours" sans écrire
- Sinon (branche else) : restaure slope=3.56, intercept=-1.889,
  code 8 ("Reset usine"), met à jour 4 sondes diagnostic + realtime_ph,
  notifie. Ne touche pas g_v_ph7 / g_v_ph4 (audit historique conservé)

Validation locale : tous les 8 presets (les 6 incluant ph.yaml + les 2
sans) passent `esphome config` après sed-rewrite vers !include.

Refs : R4, R5, R6, R7, R8, R9, R12, R13, R14, R16
Plan : docs/plans/2026-04-25-001-feat-ph-two-point-calibration-plan.md
Met à jour le README pour refléter le nouveau flux pH :
- Tables boutons : "pH Calibration 7.00" remplacé par "Démarrer calibration pH"
  (séquence two-points guidée par notifs HA) et "Reset calibration pH usine"
  (refusé pendant qu'une calib tourne)
- Table packages : description packages/ph.yaml mise à jour pour mentionner
  slope+intercept + 4 sondes diagnostic + text_sensor d'audit
- Nouvelle section "## Calibration pH (procédure)" :
  matériel, préparation (couper la pompe), 6 étapes détaillées avec les
  notifications HA attendues, durée totale 6 min 20 s, table des codes
  de rejet (2/3/4-5/6-7), recovery via le text_sensor `pH Calibration
  Last Result` quand HA déconnecte mid-séquence, limitations (pas de
  compensation T°, exclusivement Gravity v2.0 non-inversé), fréquence
  recommandée saisonnière

Pas de changement dashboard : la review feasibility a confirmé que
homeassistant/dashboard/frangipool.yaml ne référence que sensor.frangipool_ph
(préservé par l'OTA), pas l'ancien `pH Offset` ni le bouton single-point.
Les 4 nouvelles sondes diagnostic + text_sensor apparaissent automatiquement
dans HA mais ne sont pas pré-cartographiées dans le dashboard, par défaut
"drop dashboard refs" du CLAUDE.md.

Plan U1/U2/U3 marqués done.

Plan : docs/plans/2026-04-25-001-feat-ph-two-point-calibration-plan.md
…, sample spacing, magic numbers)

Code review (8 reviewers) a remonté 4 vrais bugs et 2 P1 maintainability,
tous corrigés ici. Le code reste fonctionnellement équivalent au commit
précédent au niveau du happy path mais durcit les bords.

R-1 (P2 race adv-001) — Reset entre flag-flip et success notif
Le `g_ph_calibration_in_progress = false` était la 1ère action du chemin
succès, suivi de 5 actions (4 component.update + 1 notif). Si l'opérateur
pressait Reset entre la 1ère et la 5ème action, le code 8 ("Reset usine")
écrasait le code 1 ("OK") avant que le text_sensor ne refresh, mais la
notif success tirait quand même son message snprintf depuis g_ph_slope/
intercept qui venaient d'être ramenés à 3.56/-1.889. Résultat : notif
"Calibration pH OK pente=3.560 intercept=-1.889" simultanée avec
text_sensor "Reset usine". Fix : déplacer le flag clear en TOUTE DERNIÈRE
action sur les 4 chemins de sortie (succès + 3 abort handlers). Pendant
toute la séquence component.update + notif, le flag reste true et le
bouton Reset notifie "Calibration en cours, ignoré" comme prévu.

R-2 (P2 filter contamination adv-003 + REL-01 cross-confirmed)
Le sliding_window_moving_average (180s) sur realtime_ph reste saturé de
voltages bain-tampon ~3 min après la fin de la calibration. Quand le
flag clear arrive, le tick interval suivant latche `realtime_ph.state`
dans `g_store_pool_ph` — qui est encore une moyenne mix bain/pool.
Conséquence : pool_ph dans HA affiche ~4-5 pH pendant 1-3 min après
calibration, potentiellement déclenchant des automations HA qui lisent
pool_ph. Fix : nouveau global `g_ph_post_cal_cooldown` (int, non-restored,
init 0). Set à 3 ticks (3 min) à chaque sortie du script. Decrement à
chaque interval tick de 1 min. 4ème branche AND ajoutée au gating de
l'interval : `g_ph_post_cal_cooldown <= 0`. Le latch reste figé jusqu'à
ce que la fenêtre glissante se vide naturellement.

R-3 (P3 ADS1115 cache adv-002)
ADS1115 update_interval=10s → publie 1 valeur tous les 10s. Captures
multi-sample espacées de 5s lisaient potentiellement la même valeur
cachée 2 fois. Spread check (50 mV) opérait alors sur 2 effective
samples au lieu de 3. Fix : `delay: 5s` → `delay: 12s` (4 occurrences,
2 par bain). 12s > 10s garantit qu'au moins une nouvelle publication
ADS1115 a eu lieu entre captures. Total séquence passe de ~6m20s à
~6m48s — coût marginal pour 3 samples vraiment distincts.

R-4 (P3 zero-guard text_sensor case 3 — correctness + REL-02 + project-standards)
Sous partial NVS restore, g_ph_last_result_code=3 pourrait être restauré
avec g_v_ph7=g_v_ph4=0.0, faisant `3.0f / 0.0f` = +inf dans le snprintf
du text_sensor. Fix : guard `denom != 0.0f ? 3.0f / denom : 0.0f`. Pas
de crash, juste pente=0.00 affichée plutôt que +inf.

R-5 (P1 magic constants M-01)
Les littéraux 7.0f, 4.0f, 3.0f (= 7-4) et les bornes 2.5f / 5.0f étaient
disséminés dans 2 lambdas (validation + text_sensor case 3). Fix :
constantes nommées au top du lambda de validation : PH_BUF_HIGH=7,
PH_BUF_LOW=4, PH_RANGE=3, SLOPE_MIN=2.5, SLOPE_MAX=5. Slope et intercept
candidats dérivent maintenant des constantes. Si les buffers changent
un jour (improbable, vu Scope Boundaries), plus de drift entre les sites.

R-6 (P1 result code coupling M-02)
La table des codes 0-8 n'existait qu'en commentaire d'en-tête de fichier.
Le default du switch retournait silencieusement "" (corrigé par safe_auto
en "Jamais calibré"). Si un futur dev ajoutait un code dans le script
sans toucher au switch, aucun signal compile/runtime. Fix : table
autoritative dupliquée en commentaire C++ juste au-dessus du switch,
co-localisée avec le seul consumer.

Header comment mis à jour pour refléter R14 12s + R15 cooldown.

Validation locale : esphome config passe sur 4 presets représentatifs
(salt_full, salt_minimal, salt_booster_full, salt_wo_redox).

Cross-reviewer agreements : adv-003 + REL-01 (filter), correctness #1
+ REL-02 + project-standards PS-010 (case-3 zero division).

Plan : docs/plans/2026-04-25-001-feat-ph-two-point-calibration-plan.md
Run artifact : .context/compound-engineering/ce-code-review/20260426-210326-1cec8a02/
Toutes les implementation units (U1 + U2 + U3) sont landed sur la branche.
Doc-review et code-review (8 reviewers) ont chacun appliqué leurs safe_auto
fixes ; les real bugs identifiés (R-1 race, R-2 filter contamination,
R-3 sample spacing, R-4 zero-guard) plus les P1 maintainability (R-5
named consts, R-6 result code coupling) ont été appliqués via le fixer
pass dans le commit f362025.
@gaetanars gaetanars merged commit eb31048 into main Apr 26, 2026
8 checks passed
@gaetanars gaetanars deleted the feat/ph-two-point-calibration branch April 26, 2026 19:37
gaetanars added a commit that referenced this pull request Apr 26, 2026
Capture le workaround découvert en implémentant `packages/ph.yaml` (PR #9,
commit eb31048) : `restore_value: true` n'est pas fiable sur les globals
ESPHome de type `std::string` (le mécanisme NVS travaille avec des slots
de taille fixe). Pattern réutilisable : encoder l'état dans un global
`int` (codes énumérés), exposer la chaîne dérivée via un `text_sensor`
template avec un lambda switch/case + default sentinel.

Le brainstorm/plan initial spécifiait un global string ; au moment de
coder j'ai constaté la limitation et adopté ce workaround. Identifié
comme "net-new institutional knowledge" par ce-learnings-researcher
pendant le code-review (run 20260426-210326-1cec8a02).

Catégorie : design-patterns/ (nouveau dossier — premier doc)
Track : knowledge / design_pattern
Référence : packages/ph.yaml `g_ph_last_result_code` + `text_sensor: pH Calibration Last Result`
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant