La regle du 05/09 ferme quand la maison depasse 80 % du plafond de decharge du parc — elle a mordu au bon instant hier soir, a -97,4 A pour 100 A autorises. Mais elle relachait sur un seul echantillon sous 50 %, et la soiree offrait quatorze occasions de le faire. Le troisieme pic est tombe deux minutes avant l'ouverture reelle : decale de cinq minutes, elle refermait, et chaque aller-retour du contacteur coute 50 a 150 Wh. Les accalmies mesurees sont franchement bimodales — treize de 5 min au plus pendant la cuisson, une de 201 min apres. Le relachement se mesure donc en duree, et la duree vient du bilan energetique : 20 min tant que la batterie est condamnee a toucher son plancher avant les heures creuses (l'energie reseau du soir est due de toute facon, la tenir ne change que son heure), 6 min des qu'elle passerait la nuit seule (chaque kWh du soir deplacerait alors un kWh d'heures creuses vers des heures pleines). Premiere lecture du bilan par le workflow d'hysteresis. Le dwell se compte sur la maison, jamais sur le contacteur : l'horodatage est ecrit qu'il soit ouvert ou ferme, sans quoi la decision detruirait sa propre cause. Et parce que la tension batterie s'est tue 27 minutes hier soir en pleine cuisson, le plafond retombe sur la tension nominale du parc, et a defaut l'horodatage — un fait persiste — tient la protection jusqu'au bout du dwell. Golden-hysteresis 11 -> 20. Six mutations, chacune tombant sur un jeu different. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
271 lines
14 KiB
JavaScript
271 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
// Le relachement du contacteur EDF se mesure en DUREE, et la duree vient du
|
|
// bilan energetique.
|
|
//
|
|
// La regle du 05/09 ferme quand la maison depasse 80 % de ce que le parc sait
|
|
// delivrer, et relachait des que la charge repassait sous 50 % — sur un SEUL
|
|
// echantillon. Mesure de la soiree du 05/09 (pas d'une minute, seuil 2550 W) :
|
|
//
|
|
// pendant la cuisson 20:33 -> 21:37 13 accalmies, la plus longue 5 min
|
|
// apres la cuisson 21:40 -> 01:00 une seule accalmie 201 min
|
|
//
|
|
// Treize occasions de rendre le reseau au milieu du service, alors que le
|
|
// troisieme pic (4236 W a 21:01:55) est tombe deux minutes avant l'ouverture
|
|
// reelle : decale de cinq minutes, la regle refermait, et chaque aller-retour
|
|
// du contacteur coute 50 a 150 Wh de transitoire.
|
|
//
|
|
// ⚠️ LE DWELL SE COMPTE SUR LA MAISON, JAMAIS SUR LE CONTACTEUR NI SUR LE
|
|
// COURANT BATTERIE. Meme raison qu'au 05/09 : fermer le contacteur fait chuter
|
|
// le courant batterie, s'en servir comme critere de relachement ferait que la
|
|
// decision detruirait sa propre cause. L'horodatage ecrit est celui de la
|
|
// derniere charge maison HAUTE, mis a jour que le contacteur soit ouvert ou
|
|
// ferme.
|
|
//
|
|
// LA DUREE VIENT DU BILAN. Tant que la batterie est de toute facon condamnee a
|
|
// toucher son plancher avant l'ouverture des heures creuses, le partage
|
|
// batterie/reseau de la soiree est fige : tenir le contacteur plus longtemps ne
|
|
// change que l'HEURE d'achat, pas le montant. Des que la batterie passerait la
|
|
// nuit toute seule, chaque kWh reseau du soir deplace au contraire un kWh
|
|
// d'heures creuses vers des heures pleines — et il faut relacher vite.
|
|
//
|
|
// ⚠️ APPROXIMATION ASSUMEE : le bilan publie `required_soc`, le SOC necessaire
|
|
// pour atteindre le CROISEMENT PV, cible strictement plus lointaine que
|
|
// l'ouverture des heures creuses. `soc < required_soc` est donc NECESSAIRE mais
|
|
// pas SUFFISANT pour « la batterie n'atteindra pas 01:24 ». L'erreur est
|
|
// unilaterale : on tient parfois le long dwell une nuit ou le court aurait
|
|
// suffi, ce qui coute ~0,4 kWh deplaces des heures creuses vers les heures
|
|
// pleines et protege le parc. C'est la direction voulue.
|
|
//
|
|
// node tools/patch-20260906-relachement-sur-duree.mjs
|
|
|
|
import fs from 'node:fs';
|
|
|
|
const F = 'workflows/victron-grid-hysteresis-v1.json';
|
|
const wf = JSON.parse(fs.readFileSync(F, 'utf8'));
|
|
const snap = wf.nodes.find((n) => n.name === 'HA Snapshot Grid Hysteresis');
|
|
const calc = wf.nodes.find((n) => n.name === 'Compute Grid Hysteresis');
|
|
const build = wf.nodes.find((n) => n.name === 'Build Grid Hysteresis Actions');
|
|
if (!snap || !calc || !build) throw new Error('noeuds introuvables');
|
|
|
|
function remplace(texte, avant, apres, quoi) {
|
|
if (!texte.includes(avant)) throw new Error(`ancre introuvable (${quoi}) : ${avant.slice(0, 60)}`);
|
|
return texte.replace(avant, apres);
|
|
}
|
|
|
|
// ------------------------------------------------------- 1. le snapshot
|
|
let p = JSON.stringify(snap.parameters);
|
|
if (!p.includes('charge_high_last_at')) {
|
|
const ancre = 'battery_voltage_v\\": states(\\"sensor.victron_battery_voltage\\"),';
|
|
p = remplace(p, ancre, ancre
|
|
+ '\\n \\"charge_high_last_at\\": states(\\"input_datetime.victron_grid_charge_high_last_at\\"),'
|
|
+ '\\n \\"charge_dwell_long_min\\": states(\\"input_number.victron_grid_charge_dwell_long_min\\"),'
|
|
+ '\\n \\"charge_dwell_court_min\\": states(\\"input_number.victron_grid_charge_dwell_court_min\\"),'
|
|
+ '\\n \\"balance_required_soc\\": states(\\"input_number.evcs_balance_required_soc\\"),'
|
|
+ '\\n \\"balance_status\\": states(\\"input_text.evcs_balance_status\\"),'
|
|
+ '\\n \\"balance_last_run\\": states(\\"input_text.evcs_balance_last_run\\"),',
|
|
'snapshot');
|
|
snap.parameters = JSON.parse(p);
|
|
console.log(' snapshot : +charge_high_last_at, +2 durees, +3 champs du bilan');
|
|
} else console.log(' snapshot : deja fait');
|
|
|
|
let c = calc.parameters.jsCode;
|
|
|
|
// ------------------------------------------------------- 2. le parseur
|
|
if (!c.includes('function parseRunStampToMs')) {
|
|
const ancre = ` return new Date(y, mo - 1, d, h, mi, se).getTime();
|
|
}`;
|
|
c = remplace(c, ancre, ancre + `
|
|
|
|
// Le bilan energetique publie son horodatage au format "DD/MM HH:mm" (le meme
|
|
// que l'advisor). On le ramene au format de parseLocalStampToMs plutot que
|
|
// d'ajouter un second systeme de dates : toute l'arithmetique de ce node se
|
|
// fait sur une heure murale de Paris, la conversion se compense d'elle-meme.
|
|
function parseRunStampToMs(s, year, nowMsRef) {
|
|
const m = (s ?? '').toString().trim().match(/^(\\d{2})\\/(\\d{2}) (\\d{2}):(\\d{2})$/);
|
|
if (!m) return null;
|
|
|
|
const build = (y) => parseLocalStampToMs(\`\${y}-\${m[2]}-\${m[1]} \${m[3]}:\${m[4]}:00\`);
|
|
let ts = build(year);
|
|
// Passage d'annee : un horodatage qui tomberait plus d'un jour dans le futur
|
|
// vient forcement de l'annee precedente.
|
|
if (ts !== null && nowMsRef !== null && ts - nowMsRef > 24 * 3600 * 1000) ts = build(year - 1);
|
|
return ts;
|
|
}`, 'parseur');
|
|
console.log(' calcul : parseur d horodatage du bilan');
|
|
}
|
|
|
|
// ------------------------------------------------------- 3. le dwell
|
|
if (!c.includes('dwellEcoule')) {
|
|
const ancre = 'const chargeEncoreElevee = plafondDechargeW > 0 && houseAcPowerW >= seuilRelachementW;';
|
|
c = remplace(c, ancre, `const chargeSousSeuilBas = plafondDechargeW > 0 && houseAcPowerW < seuilRelachementW;
|
|
|
|
// ======================================================
|
|
// LE RELACHEMENT SE MESURE EN DUREE (06/09/2026)
|
|
// ======================================================
|
|
// Soiree du 05/09, seuil de relachement 2550 W, pas d'une minute :
|
|
// pendant la cuisson 20:33->21:37 : 13 accalmies, la plus longue 5 min
|
|
// apres la cuisson 21:40->01:00 : 1 accalmie, 201 min
|
|
// Un creux de 20 minutes n'existe pas tant que ca cuisine. Le relachement
|
|
// instantane offrait donc 13 occasions de rendre le reseau au milieu du
|
|
// service, chacune facturee 50 a 150 Wh de transitoire.
|
|
//
|
|
// L'horodatage porte la derniere charge maison HAUTE et il est ecrit que le
|
|
// contacteur soit ouvert ou ferme : il suit une grandeur que la decision ne
|
|
// modifie pas. Absent ou illisible => dwell repute ecoule => comportement
|
|
// d'avant ce correctif, jamais une supposition.
|
|
const chargeHighLastAt = (input.charge_high_last_at ?? '').toString().trim();
|
|
const chargeHighLastMs = parseLocalStampToMs(chargeHighLastAt);
|
|
const minutesDepuisChargeHaute =
|
|
chargeHighLastMs !== null && nowMs !== null
|
|
? Math.max(0, (nowMs - chargeHighLastMs) / 60000)
|
|
: null;
|
|
|
|
// La duree vient du BILAN. Tant que la batterie touchera son plancher avant
|
|
// l'ouverture des heures creuses, l'energie reseau du soir est due de toute
|
|
// facon : la tenir plus longtemps ne change que l'heure d'achat. Des qu'elle
|
|
// passerait la nuit seule, chaque kWh du soir deplace un kWh d'heures creuses
|
|
// vers des heures pleines, et il faut relacher vite.
|
|
//
|
|
// ⚠️ required_soc vise le CROISEMENT PV, plus lointain que 01:24 : le test est
|
|
// necessaire mais pas suffisant. L'erreur est unilaterale (long dwell une nuit
|
|
// ou le court aurait suffi, ~0,4 kWh deplaces, parc menage) — direction voulue.
|
|
const BALANCE_MAX_AGE_MIN = 30; // six cycles du bilan, qui tourne aux 5 min
|
|
const balanceStatusOk = low(input.balance_status) === 'ok';
|
|
const balanceRequiredSocPct = num(input.balance_required_soc, NaN);
|
|
const balanceRunMs = parseRunStampToMs(input.balance_last_run, now.year, nowMs);
|
|
const balanceAgeMin =
|
|
balanceRunMs !== null && nowMs !== null ? (nowMs - balanceRunMs) / 60000 : null;
|
|
const balanceUsable =
|
|
balanceStatusOk &&
|
|
Number.isFinite(balanceRequiredSocPct) &&
|
|
balanceAgeMin !== null &&
|
|
balanceAgeMin <= BALANCE_MAX_AGE_MIN &&
|
|
balanceAgeMin >= -5; // horodatage dans le futur : rejete
|
|
const reserveDejaCondamnee = balanceUsable && soc < balanceRequiredSocPct;
|
|
|
|
const dwellMinutes = reserveDejaCondamnee
|
|
? Math.max(0, num(input.charge_dwell_long_min, 20))
|
|
: Math.max(0, num(input.charge_dwell_court_min, 6));
|
|
|
|
const dwellEcoule =
|
|
minutesDepuisChargeHaute === null ? true : minutesDepuisChargeHaute >= dwellMinutes;
|
|
|
|
// Le reseau reste du tant que la charge n'est pas retombee ET restee basse.
|
|
const chargeEncoreElevee = plafondDechargeW > 0 && !(chargeSousSeuilBas && dwellEcoule);`,
|
|
'dwell');
|
|
console.log(' calcul : relachement sur duree, duree gagee sur le bilan');
|
|
}
|
|
|
|
// ------------------------------------------------------- 4. la trace
|
|
if (!c.includes('reserve deja condamnee')) {
|
|
const ancre = ` state = 'hold_on_charge_encore_elevee';
|
|
reason.push(
|
|
\`Charge maison \${Math.round(houseAcPowerW)}W encore au-dessus de \${Math.round(seuilRelachementW)}W : on garde le reseau\`);`;
|
|
c = remplace(c, ancre, ` state = 'hold_on_charge_encore_elevee';
|
|
reason.push(chargeSousSeuilBas
|
|
? \`Charge maison retombee a \${Math.round(houseAcPowerW)}W depuis \${round1(minutesDepuisChargeHaute || 0)} min : \`
|
|
+ \`on garde le reseau jusqu'a \${dwellMinutes} min \`
|
|
+ \`(\${reserveDejaCondamnee ? 'reserve deja condamnee, le reseau du soir est du de toute facon' : 'reserve suffisante, relachement rapide'})\`
|
|
: \`Charge maison \${Math.round(houseAcPowerW)}W encore au-dessus de \${Math.round(seuilRelachementW)}W : on garde le reseau\`);`,
|
|
'trace');
|
|
console.log(' calcul : la raison distingue le dwell de la charge haute');
|
|
}
|
|
|
|
// ------------------------------------------------------- 5. l ecriture
|
|
if (!c.includes('setChargeHighLastAt')) {
|
|
const ancre = `const setLastOffAt =
|
|
gridAction === 'turn_off';`;
|
|
c = remplace(c, ancre, ancre + `
|
|
|
|
// Horodatage de la derniere charge maison HAUTE. Ecrit quel que soit l'etat du
|
|
// contacteur : c'est ce qui garantit que le compteur de dwell ne depend pas de
|
|
// la decision qu'il alimente.
|
|
const setChargeHighLastAt = plafondDechargeW > 0 && !chargeSousSeuilBas;`, 'ecriture');
|
|
|
|
c = remplace(c, ` set_last_off_at: setLastOffAt,`,
|
|
` set_last_off_at: setLastOffAt,
|
|
set_charge_high_last_at: setChargeHighLastAt,`, 'decision');
|
|
|
|
c = remplace(c, ` helper_last_off_at: {`,
|
|
` helper_charge_high_last_at: {
|
|
domain: 'input_datetime',
|
|
service: 'set_datetime',
|
|
data: {
|
|
entity_id: 'input_datetime.victron_grid_charge_high_last_at',
|
|
datetime: now.stamp
|
|
}
|
|
},
|
|
|
|
helper_last_off_at: {`, 'appel');
|
|
console.log(' calcul : ecriture de l horodatage de charge haute');
|
|
}
|
|
|
|
// ------------------------------------------------------- 6. le debug
|
|
if (!c.includes('dwell_minutes:')) {
|
|
c = remplace(c, ` charge_au_dessus_du_parc: chargeAuDessusDuParc,`,
|
|
` charge_au_dessus_du_parc: chargeAuDessusDuParc,
|
|
charge_sous_seuil_bas: chargeSousSeuilBas,
|
|
minutes_depuis_charge_haute:
|
|
minutesDepuisChargeHaute !== null ? round1(minutesDepuisChargeHaute) : null,
|
|
dwell_minutes: dwellMinutes,
|
|
dwell_ecoule: dwellEcoule,
|
|
balance_usable: balanceUsable,
|
|
balance_required_soc_pct:
|
|
Number.isFinite(balanceRequiredSocPct) ? round1(balanceRequiredSocPct) : null,
|
|
balance_age_min: balanceAgeMin !== null ? round1(balanceAgeMin) : null,
|
|
reserve_deja_condamnee: reserveDejaCondamnee,`, 'debug');
|
|
console.log(' calcul : debug expose');
|
|
}
|
|
|
|
// ------------------------------------------------- 7bis. capteur muet
|
|
// Le 05/09 de 21:22:21 a 21:49:35, `sensor.victron_battery_current` et
|
|
// `sensor.victron_battery_voltage` n'ont RIEN publie — 27 minutes — pendant que
|
|
// la consommation continuait de se mettre a jour normalement. Sans repli, le
|
|
// plafond tombait a zero et la protection s'evanouissait pile a l'heure du
|
|
// diner. Deux filets, tous deux fondes sur du mesure ou du persiste :
|
|
if (!c.includes('TENSION_PARC_NOMINALE_V')) {
|
|
c = remplace(c, `const plafondDechargeW = plafondDechargeA > 0 && tensionParcV > 0
|
|
? plafondDechargeA * tensionParcV : 0;`,
|
|
`// La tension a disparu 27 min d'affilee le 05/09 alors que le plafond en
|
|
// AMPERES restait lisible. Sur toute la soiree la tension mesuree n'est pas
|
|
// sortie de 48,85-51,5 V : un nominal a 51 V vaut au pire 5 % d'erreur sur le
|
|
// seuil, contre 100 % si la regle s'eteint.
|
|
const TENSION_PARC_NOMINALE_V = 51;
|
|
const plafondDechargeW = plafondDechargeA > 0
|
|
? plafondDechargeA * (tensionParcV > 0 ? tensionParcV : TENSION_PARC_NOMINALE_V)
|
|
: 0;`, 'tension nominale');
|
|
console.log(' calcul : repli sur la tension nominale du parc');
|
|
}
|
|
|
|
if (!c.includes('protectionRecente')) {
|
|
c = remplace(c, `const chargeEncoreElevee = plafondDechargeW > 0 && !(chargeSousSeuilBas && dwellEcoule);`,
|
|
`// Si le plafond devient illisible EN COURS de protection, l'horodatage de
|
|
// charge haute reste, lui, un fait PERSISTE : on tient jusqu'a la fin du dwell
|
|
// plutot que de rendre le reseau sur un capteur muet. La memoire ne survit pas
|
|
// au dwell — l'horodatage n'etant ecrit que quand le plafond est lisible, un
|
|
// capteur mort depuis des heures rend bien la regle inerte.
|
|
const protectionRecente = !dwellEcoule;
|
|
const chargeEncoreElevee = plafondDechargeW > 0
|
|
? !(chargeSousSeuilBas && dwellEcoule)
|
|
: protectionRecente;`, 'memoire de protection');
|
|
console.log(' calcul : la protection survit a un capteur muet');
|
|
}
|
|
|
|
calc.parameters.jsCode = c;
|
|
|
|
// ------------------------------------------------- 7. Build Actions
|
|
let b = build.parameters.jsCode;
|
|
if (!b.includes('helper_charge_high_last_at')) {
|
|
b = remplace(b, `if (decision.set_last_off_at) {`,
|
|
`if (decision.set_charge_high_last_at) {
|
|
pushAction('helper_charge_high_last_at', calls.helper_charge_high_last_at, true);
|
|
}
|
|
|
|
if (decision.set_last_off_at) {`, 'build');
|
|
build.parameters.jsCode = b;
|
|
console.log(' actions : ecriture de l horodatage poussee');
|
|
} else console.log(' actions : deja fait');
|
|
|
|
fs.writeFileSync(F, JSON.stringify(wf, null, 2) + '\n');
|
|
console.log(`\n${F} ecrit.`);
|