-- ===================================================================== -- Mémoire de mesures EVCS — schéma PostgreSQL (proposition A) -- Appliqué automatiquement à la création de la base par le compose. -- ===================================================================== -- -- Ce que cette base garde, et que ni l'historique de Home Assistant ni les -- statistiques long terme ne savent garder : -- -- 1. ce que n8n CALCULE — SOC requis, heure de croisement, décisions et -- leurs motifs. Ce ne sont pas des capteurs, donc aucune statistique. -- 2. des VECTEURS — une prévision est 24 valeurs pour une journée. -- 3. du TEXTE — un motif de censure, une raison de décision. -- 4. des JOINTURES — « la prévision publiée à 04:00 contre la -- production réelle de 13 h » est ici une requête, ailleurs un exercice. -- -- Ce qu'elle ne cherche PAS à faire : le flux brut de tous les états. C'est le -- rôle d'InfluxDB, à côté (voir compose.yaml). -- -- Convention : tout horodatage est en timestamptz, écrit en UTC, lu en -- Europe/Paris. Ne jamais stocker d'heure locale nue — le passage à l'heure -- d'hiver du 25/10 rendrait deux heures indiscernables. SET timezone = 'Europe/Paris'; -- --------------------------------------------------------------------- -- 1. MESURES — séries échantillonnées, format long -- --------------------------------------------------------------------- -- Format long (une ligne par couple clé/instant) et non large : les grandeurs -- suivies changent au fil des sessions, et une table large impose une -- migration à chaque ajout. L'index couvre l'accès réel : « telle clé, sur -- telle plage de temps ». CREATE TABLE mesure ( ts timestamptz NOT NULL, cle text NOT NULL, valeur double precision NOT NULL, PRIMARY KEY (cle, ts) ); COMMENT ON TABLE mesure IS 'Grandeurs numériques échantillonnées par n8n. Cadence = celle du workflow.'; -- --------------------------------------------------------------------- -- 2. BILAN — une ligne par exécution du bilan énergétique -- --------------------------------------------------------------------- CREATE TABLE bilan ( ts timestamptz PRIMARY KEY, statut text, soc_pct double precision, energie_a_couvrir_kwh double precision, soc_requis_pct double precision, soc_requis_prudent_pct double precision, heure_croisement text, reseau_necessaire boolean, reseau_energie_kwh double precision, manque_stockage_kwh double precision, pv_censure boolean, motif_censure text, orage_niveau text, orage_reserve_kwh double precision, -- Le reste du bloc tel que publié, pour ne pas avoir à migrer la table -- chaque fois que le bilan gagne un champ. brut jsonb ); COMMENT ON COLUMN bilan.soc_requis_pct IS 'NON écrêté au-delà de 100 : une valeur supérieure est le signal de sous-dimensionnement du stockage (HANDOFF §26).'; -- --------------------------------------------------------------------- -- 3. DECISION — une ligne par changement de décision du contrôleur -- --------------------------------------------------------------------- -- Pas une ligne toutes les 30 s : seulement quand la signature change. Sinon -- 2 880 lignes par jour dont 2 800 identiques, et la table devient illisible. CREATE TABLE decision ( ts timestamptz PRIMARY KEY, profil text, source_mode text, etat text, decision text, charge_desiree boolean, courant_a double precision, soc_pct double precision, cible_matin_pct double precision, takeover_reseau boolean, session_engagee boolean, raison text, brut jsonb ); -- --------------------------------------------------------------------- -- 4. PREVISION — une ligne par publication, avec le vecteur horaire -- --------------------------------------------------------------------- -- `publie_a` est l'instant de publication, `jour` le jour prévu. Les deux sont -- nécessaires : comparer une prévision à la réalité n'a de sens que si l'on -- sait QUAND elle a été faite. La clé primaire les porte donc toutes les deux — -- plusieurs prévisions du même jour, faites à des heures différentes, doivent -- coexister. CREATE TABLE prevision ( publie_a timestamptz NOT NULL, jour date NOT NULL, source text NOT NULL DEFAULT 'forecast.solar', total_kwh double precision, horaire double precision[] NOT NULL, -- 24 valeurs, index 0 = 00 h PRIMARY KEY (publie_a, jour) ); COMMENT ON TABLE prevision IS 'Prévision TELLE QU''ELLE A ÉTÉ PUBLIÉE. Sans cet horodatage de publication, on ne peut pas mesurer le biais de la prévision.'; -- --------------------------------------------------------------------- -- 5. EVENEMENT — ce qu''on veut pouvoir retrouver un an après -- --------------------------------------------------------------------- -- Déploiements, incidents, interventions manuelles, changements de matériel. -- Une courbe sans ses événements ne s'interprète pas : « le rendement a changé -- le 12 novembre » n'a de sens qu'avec la ligne qui dit qu'un pack a été -- rebranché ce jour-là. CREATE TABLE evenement ( ts timestamptz NOT NULL DEFAULT now(), genre text NOT NULL, -- deploiement | incident | intervention | materiel source text, -- qui l'a écrit resume text NOT NULL, detail jsonb, id bigserial PRIMARY KEY ); CREATE INDEX evenement_ts_idx ON evenement (ts DESC); -- --------------------------------------------------------------------- -- 6. SESSION_CHARGE — une ligne par charge de vehicule -- --------------------------------------------------------------------- -- Raison d'etre : l'etiquette du vehicule n'existe QUE pendant la session. -- `input_select.victron_evcs_vehicle` est un reglage manuel, il change, et -- rien ne conserve sa valeur au moment ou la charge a eu lieu. Sans cet -- enregistrement, une session d'il y a trois semaines est anonyme pour -- toujours — et toute analyse d'usage devient impossible. -- -- ⚠️ On ne DEVINE pas le vehicule. Mesure du 05/09/2026 : a consigne identique -- de 20 A, la VW accepte 3 680 W et la JEEP 3 655 W, soit 0,1 A d'ecart. Ils -- sont electriquement indiscernables. L'heure ne dit rien non plus : en semaine -- la JEEP charge le midi et la VW la nuit, le week-end c'est l'inverse. CREATE TABLE session_charge ( debut timestamptz PRIMARY KEY, fin timestamptz NOT NULL, terminee boolean NOT NULL DEFAULT false, vehicule text, -- valeur du selecteur PENDANT la session vehicule_sur boolean NOT NULL DEFAULT true, -- le selecteur n'a pas change -- Le compteur de la BORNE, qui fait foi : il compte depuis le vrai debut -- de la session, la ou l'integration ci-dessous ne couvre que ce que le -- service a vu. Le 05/09 : 8,0 kWh cote borne, 0,75 cote integration, -- le detecteur n'ayant demarre qu'a 03:16 pour une session ouverte a 01:25. energie_station_kwh double precision, energie_kwh double precision NOT NULL DEFAULT 0, -- Part venue du reseau : on se fie au `source_mode` du controleur, son -- INTENTION, et non a la puissance reseau instantanee — laquelle peut -- alimenter la maison pendant que le soleil alimente la voiture. energie_reseau_kwh double precision NOT NULL DEFAULT 0, p_max_w double precision, courant_max_a double precision, consigne_max_a double precision, source_mode text, debranche_a timestamptz, -- confirme le vehicule apres coup brut jsonb ); COMMENT ON COLUMN session_charge.debranche_a IS 'Heure de debranchement : les deux vehicules partent a des heures differentes, ce qui confirme l''etiquette apres coup.'; CREATE INDEX session_charge_debut_idx ON session_charge (debut DESC); -- --------------------------------------------------------------------- -- Index de lecture -- --------------------------------------------------------------------- CREATE INDEX mesure_ts_idx ON mesure (ts DESC); CREATE INDEX bilan_ts_idx ON bilan (ts DESC); CREATE INDEX decision_ts_idx ON decision (ts DESC); CREATE INDEX prevision_jour_idx ON prevision (jour, publie_a DESC); -- --------------------------------------------------------------------- -- Vue de confort : la question qui a bloqué l'analyse du 03/09 -- --------------------------------------------------------------------- -- « Ce que le bilan exigeait, contre le SOC réellement atteint, heure par -- heure. » Trois lignes de SQL ici ; impossible sans cette base. CREATE VIEW bilan_contre_reel AS SELECT date_trunc('hour', ts) AS heure, round(avg(soc_requis_pct)::numeric, 1) AS soc_requis, round(avg(soc_pct)::numeric, 1) AS soc_reel, round(avg(soc_pct - soc_requis_pct)::numeric, 1) AS marge, round(avg(energie_a_couvrir_kwh)::numeric, 2) AS a_couvrir_kwh FROM bilan GROUP BY 1 ORDER BY 1 DESC; INSERT INTO evenement (genre, source, resume, detail) VALUES ('deploiement', 'claude', 'Création de la mémoire de mesures : PostgreSQL sur 10.10.10.22', '{"motif": "historique HA limité à ~20 j et sans valeurs calculées", "handoff": "§42"}');