La reponse du proprietaire pilote le selecteur, et deux ecrivains cessent de se battre

Corriger le selecteur a la main ne corrige rien : « demain si je dis JEEP il va
rester sur VW, donc la solution c'est de le piloter quand je reponds ».

Le selecteur n'est pas qu'une etiquette. Le CONTROLEUR n8n le lit pour en
deduire l'heure de depart (VW 08:50, JEEP 06:30), donc minutesUntilDeparture,
donc batteryRecoveryUrgent — qui coupe P12 et ferme EDF trente minutes avant le
depart suppose. Mesure de cette nuit : selecteur reste sur JEEP alors que la VW
etait branchee et confirmee par WhatsApp a 19:56 => EDF se serait ferme a 06:00
pour rien, la voiture etant pleine depuis 05:30 et la batterie a 51 %.

Deux correctifs, indissociables.

1. piloterSelecteurVehicule() : une reponse du proprietaire ecrit
   input_select.victron_evcs_vehicle. UNE SEULE FOIS par reponse — la fonction
   appelante est idempotente par confirmed_at — donc le dernier mot reste a
   l'humain quel que soit le canal. Seulement pour les labels qui SONT des
   options du selecteur : MEGANE, INVITE et ZERO n'y figurent pas, et l'heure de
   depart n'a aucun sens pour un vehicule de passage.

2. ecrireSession() n'ecrase plus une session confirmee. Cette ligne reecrivait
   le vehicule depuis le selecteur a CHAQUE CYCLE d'une session ouverte, pendant
   que appliquerEtiquettesEnAttente remettait l'etiquette juste apres : deux
   ecrivains sur la meme colonne toutes les 60 s, et une ligne de journal a
   chaque passe — une soixantaine le 15/09. Le drapeau vehicule_sur dit « un
   humain a confirme » : quand il est vrai, le selecteur n'a plus voix au
   chapitre.

Preuve du diagnostic, par l'experience : le dernier « Etiquette VW posee » est a
01:40:58, le selecteur passe a VW a 01:41:59, et plus une seule ligne ensuite.
La bagarre s'arrete a la seconde ou les deux ecrivains s'accordent.

Preuve du correctif 2, en lecture seule sur les lignes reelles : sur les deux
sessions VW, l'expression d'avant ecrirait JEEP, la nouvelle garde VW ; sur les
sessions JEEP les deux concordent. Et sur un cas synthetique non confirme, la
nouvelle expression suit bien le selecteur — la garde est ciblee.

DEFAUT TROUVE EN RELISANT LA SORTIE DU PREMIER ESSAI A BLANC : sans garde, un
--dry-run aurait pilote le selecteur pour de vrai. Seule l'egalite fortuite
actuel === label avait evite l'ecriture. Garde posee, puis eprouvee en mettant
le selecteur sur JEEP : la sortie annonce « [blanc] piloterait JEEP -> VW » et
le selecteur est reste JEEP.

MANUEL : capacite des vehicules. La VW annonce 10 kWh de plaque pour 42 km ;
la reference MESUREE au compteur de la borne est 7,70 kWh pour une charge menee
a son terme (15/09 01:25-03:41). C'est ce chiffre qui dimensionne une nuit, pas
la plaque. La JEEP n'est pas mesuree de la meme facon : ne pas lui appliquer
les 7,70 kWh.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
This commit is contained in:
Tallagrand Thierry
2026-09-16 01:47:14 +02:00
parent b6010b9342
commit d3ef261e78
3 changed files with 199 additions and 3 deletions

View File

@@ -305,3 +305,107 @@ views:
une écriture sort de ce périmètre.
Détail de la démarche : `docs/CHANTIER_BILAN_ENERGETIQUE.md`.
# =====================================================
# PREVISION CONTRE REEL (15/09/2026)
# =====================================================
# Les trois series viennent de `sensor.evcs_prevision_contre_reel`, publie
# toutes les dix minutes par le service d'historisation depuis PostgreSQL —
# Home Assistant ne garde aucun historique des previsions, ses input_text
# etant ecrases a chaque publication.
- title: Prévision vs réel
path: prevision
icon: mdi:chart-timeline-variant
cards:
- type: markdown
content: >
## Comment lire cette courbe
⚠️ **Une heure bridée n'est pas comparable.** Quand la batterie est
pleine et qu'aucun preneur ne se présente, la production se cale sur
la consommation : elle passe sous la prévision sans que la prévision
soit fausse. La bande grise du bas dit quand c'est le cas.
Le chiffre en tête — l'écart moyen — ne compte **que les heures non
bridées**. C'est le seul qui juge vraiment la prévision.
Deux exemples mesurés : le 13/09 à 15 h, prévu 5,32 kWh et réalisé
1,82 avec 100 % de bridage — l'écart n'est pas météorologique. Le
15/09 à 13 h, prévu 3,92 et réalisé 7,12 sans aucun bridage — là, la
prévision **sous-estime** de 82 %.
- type: entities
entities:
- entity: sensor.evcs_prevision_contre_reel
name: Écart moyen (heures comparables)
- type: attribute
entity: sensor.evcs_prevision_contre_reel
attribute: heures_comparables
name: Heures comparables
- type: attribute
entity: sensor.evcs_prevision_contre_reel
attribute: heures_total
name: Heures dans la fenêtre
- type: custom:apexcharts-card
header:
show: true
title: Production prévue et réalisée, heure par heure
show_states: false
graph_span: 7d
span:
end: hour
apex_config:
chart:
height: 340
stroke:
width: [2, 2]
legend:
position: top
yaxis:
- title: {text: kWh par heure}
decimals: 1
series:
- entity: sensor.evcs_prevision_contre_reel
name: Prévue
type: line
color: '#f0a202'
data_generator: |
return (entity.attributes.predite || []);
- entity: sensor.evcs_prevision_contre_reel
name: Réalisée
type: area
opacity: 0.25
color: '#2d8cf0'
data_generator: |
return (entity.attributes.reelle || []);
- type: custom:apexcharts-card
header:
show: true
title: Part de l'heure où la production était bridée
show_states: false
graph_span: 7d
span:
end: hour
apex_config:
chart:
height: 150
yaxis:
- min: 0
max: 100
title: {text: '% de l heure'}
legend:
show: false
series:
- entity: sensor.evcs_prevision_contre_reel
name: Bridage
type: area
opacity: 0.4
color: '#9e9e9e'
data_generator: |
return (entity.attributes.bridage || []);

View File

@@ -110,6 +110,23 @@ module n), `log`, `stat`.
Deux véhicules alternent : **VW** (hybride, départ ~08:50) et **JEEP**
(départ ~06:30) — cf. §11.5.
### Capacite des vehicules (donnee par le proprietaire, 16/09/2026)
La **VW** est un hybride rechargeable de **10 kWh** annonces, pour une autonomie
electrique de **42 km** batterie pleine. Reference MESUREE au compteur de la
borne : une charge menee a son terme depuis un niveau bas a pris **7,70 kWh au
compteur** (session du 15/09 01:25-03:41, 2 h 17 a 3,68 kW). C'est le chiffre a
utiliser pour dimensionner une nuit de charge — pas les 10 kWh de plaque, qui
n'incluent ni la reserve basse du constructeur ni le rendement du chargeur
embarque.
Regle de trois utilisable : l'autonomie restante affichee au tableau de bord
donne directement la fraction manquante. 8 km sur 42 => il manque 81 % d'une
charge pleine, soit environ **6,2 kWh au compteur**.
⚠️ La capacite de la JEEP n'est pas encore mesuree de la meme facon. Ne pas lui
appliquer les 7,70 kWh de la VW.
### Routine de charge des deux vehicules (donnee par le proprietaire, 05/09/2026)
| vehicule | conducteur | quand il charge |

View File

@@ -832,7 +832,16 @@ async function ecrireSession(sql, client, st) {
-- un selecteur perime depuis six jours restant « sur » — mais
-- « UN HUMAIN A CONFIRME ». Seule une reponse du proprietaire,
-- via le concierge, peut le mettre a vrai.
vehicule = coalesce($7, vehicule)
-- ⚠️ ET IL NE DOIT PAS ECRASER UNE CONFIRMATION HUMAINE.
-- Defaut mesure le 16/09 : cette ligne reecrivait le vehicule depuis
-- le selecteur A CHAQUE CYCLE d'une session ouverte, pendant que
-- appliquerEtiquettesEnAttente remettait l'etiquette du concierge
-- juste apres. Deux ecrivains sur la meme colonne, toutes les 60 s,
-- et une ligne de journal a chaque passe : une soixantaine le 15/09.
-- Le drapeau vehicule_sur dit « un humain a confirme » : quand il
-- est vrai, le selecteur n'a plus voix au chapitre.
vehicule = CASE WHEN vehicule_sur THEN vehicule
ELSE coalesce($7, vehicule) END
WHERE debut = $1`,
[ouverte.debut, maintenant, p, dt,
num(g('sensor.victron_evcs_current')?.state),
@@ -1061,6 +1070,67 @@ const CANAUX_HUMAINS = new Set(['whatsapp', 'proprietaire']);
const LABELS = new Set(['VW', 'JEEP', 'MEGANE', 'INVITE', 'ZERO']);
let dernierBlancVu = null; // anti-repetition du journal en essai a blanc
// ---------------------------------------------------------------------
// LA REPONSE DU PROPRIETAIRE PILOTE LE SELECTEUR
// ---------------------------------------------------------------------
// Demande du proprietaire, 16/09/2026 : « si je dis JEEP il va rester sur VW,
// donc la solution c'est de le piloter quand je reponds ».
//
// Il a raison, et ce n'est pas cosmetique. La base n'est pas le seul lecteur du
// vehicule : le CONTROLEUR n8n lit input_select.victron_evcs_vehicle pour en
// deduire l'heure de depart (VW 08:50, JEEP 06:30), donc minutesUntilDeparture,
// donc batteryRecoveryUrgent — qui coupe P12 et ferme EDF trente minutes avant
// le depart suppose. Un selecteur perime ne fausse pas une etiquette, il change
// une decision d'energie. Mesure la nuit du 16/09 : selecteur reste sur JEEP
// alors que la VW etait branchee et confirmee par WhatsApp => EDF se serait
// ferme a 06:00 pour rien.
//
// ⚠️ UNE SEULE ECRITURE PAR REPONSE, jamais un reassert cyclique. La fonction
// appelante est idempotente par confirmed_at : elle ne repasse pas ici tant que
// le proprietaire n'a pas repondu autre chose. C'est ce qui laisse le dernier
// mot a l'humain, quel que soit le canal — s'il change le selecteur a la main
// apres avoir repondu, on ne le contredit pas.
//
// ⚠️ Et seulement pour les labels qui SONT des options du selecteur. MEGANE,
// INVITE ou ZERO n'y figurent pas : ecrire une option inexistante echouerait, et
// surtout la notion d'heure de depart n'a aucun sens pour un vehicule de
// passage. Dans ce cas on ne touche a rien et le controleur garde son reglage.
async function piloterSelecteurVehicule(sql, st, label, source) {
const actuel = txt(st.get('input_select.victron_evcs_vehicle')?.state);
const options = st.get('input_select.victron_evcs_vehicle')?.attributes?.options || [];
if (!options.includes(label)) return 0;
if (actuel === label) return 0;
// ⚠️ L'essai a blanc ne doit RIEN ecrire, et cette fonction est la premiere du
// service a toucher un actionneur du controleur. Sans cette garde, un
// --dry-run aurait pilote le selecteur pour de vrai : trouve en relisant la
// sortie du premier essai, ou seule l'egalite fortuite actuel === label a
// evite l'ecriture.
if (DRY) {
console.log(` [blanc] piloterait le selecteur ${actuel || 'inconnu'} -> ${label}`);
return 0;
}
try {
const r = await fetch(`${HA_URL}/api/services/input_select/select_option`, {
method: 'POST',
headers: { Authorization: `Bearer ${HA_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_id: 'input_select.victron_evcs_vehicle', option: label }),
signal: AbortSignal.timeout(8000),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
} catch (e) {
await journaliser(sql, 'vehicule',
`Selecteur vehicule NON pilote (${actuel || 'inconnu'} -> ${label}) : ${e.message}`,
{ origine: 'airtria', source_airtria: source, label, avant: actuel, echec: e.message });
return 1;
}
await journaliser(sql, 'vehicule',
`Selecteur vehicule pilote par le concierge : ${actuel || 'inconnu'} -> ${label}`,
{ origine: 'airtria', source_airtria: source, label, avant: actuel,
entite: 'input_select.victron_evcs_vehicle',
motif: 'la reponse du proprietaire fait foi, et le controleur lit le selecteur' });
return 1;
}
async function appliquerEtiquetteConcierge(sql, client, st) {
const brut = txt(st.get('input_text.airtria_vehicle_label')?.state);
if (!brut || brut === 'unknown' || brut === 'unavailable') return 0;
@@ -1224,6 +1294,11 @@ async function appliquerEtiquetteConcierge(sql, client, st) {
[branchement, label, source, confirmedAt || null]);
}
// Le controleur lit le selecteur : la reponse doit y arriver, pas seulement
// dans la base. Une seule fois, ici, parce que cette fonction ne repasse que
// sur une nouvelle reponse.
const sel = appliquer ? await piloterSelecteurVehicule(sql, st, label, source) : 0;
const { rowCount } = cibles.size ? await sql(
`UPDATE session_charge SET vehicule = $2, vehicule_sur = $3
WHERE debut = ANY($1::timestamptz[]) AND (vehicule IS DISTINCT FROM $2 OR vehicule_sur IS DISTINCT FROM $3)`,
@@ -1232,8 +1307,8 @@ async function appliquerEtiquetteConcierge(sql, client, st) {
cibles.size
? `Etiquette ${label} appliquee par le concierge (${cibles.size} session${cibles.size > 1 ? 's' : ''}, ${source})`
: `Etiquette ${label} retenue pour le branchement en cours (aucune session encore, ${source})`,
{ ...detail, branchement });
return (rowCount || 0) + 1;
{ ...detail, branchement, selecteur_pilote: !!sel });
return (rowCount || 0) + sel + 1;
}
// ---------------------------------------------------------------------