diff --git a/dashboards/bilan_energetique.yaml b/dashboards/bilan_energetique.yaml index 8849058..482bf3d 100644 --- a/dashboards/bilan_energetique.yaml +++ b/dashboards/bilan_energetique.yaml @@ -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 || []); diff --git a/docs/MANUEL_COMPLET.md b/docs/MANUEL_COMPLET.md index b03f750..2de6783 100644 --- a/docs/MANUEL_COMPLET.md +++ b/docs/MANUEL_COMPLET.md @@ -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 | diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs index 8456b43..3419d94 100644 --- a/tools/historise-evcs.mjs +++ b/tools/historise-evcs.mjs @@ -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; } // ---------------------------------------------------------------------