Nuit : la voiture profite d'EDF ferme pour la batterie, la nuit prepare le soir ; conseiller IA relecteur de la journee (revue calculee + pistes)
This commit is contained in:
@@ -1,67 +1,41 @@
|
||||
// =====================================================
|
||||
// EVCS ADVISOR SHADOW V1 — BUILD_HA_CALLS
|
||||
// Écrit les helpers advisor_* + divergence + compteurs + logbook.
|
||||
// GARANTIE MODE OMBRE : ce node ne produit QUE des écritures vers
|
||||
// des entités advisor_* et le logbook. AUCUN switch, AUCUNE stratégie
|
||||
// réelle, AUCUNE commande borne. Ne pas ajouter d'autres entités ici.
|
||||
// EVCS ADVISOR — BUILD_HA_CALLS (relecteur, 08/10/2026)
|
||||
// GARANTIE : ce node n'ecrit QUE des entites advisor (input_text.evcs_advisor_*,
|
||||
// sensor.evcs_advisor_revue) et le logbook. AUCUN switch, AUCUNE strategie,
|
||||
// AUCUNE commande. Ne pas ajouter d'autres entites ici.
|
||||
// Les items `state_entity` sont envoyes a /api/states (capteur avec attributs),
|
||||
// les autres a /api/services/<domain>/<service>.
|
||||
// =====================================================
|
||||
|
||||
const { status, detail, advice, modelUsed, context } = $input.first().json;
|
||||
const { status, detail, pistes, rejetees, modelUsed, context } = $input.first().json;
|
||||
|
||||
function stampParis() {
|
||||
const p = new Intl.DateTimeFormat('fr-FR', {
|
||||
timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
}).formatToParts(new Date());
|
||||
const g = t => p.find(x => x.type === t)?.value || '';
|
||||
const p = new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false }).formatToParts(new Date());
|
||||
const g = (t) => p.find((x) => x.type === t)?.value || '';
|
||||
return `${g('day')}/${g('month')} ${g('hour')}:${g('minute')}`;
|
||||
}
|
||||
|
||||
const calls = [];
|
||||
function push(domain, service, data) {
|
||||
calls.push({ json: { domain, service, data } });
|
||||
}
|
||||
function setText(entity, value) {
|
||||
push('input_text', 'set_value', { entity_id: entity, value: (value ?? '').toString().slice(0, 250) });
|
||||
}
|
||||
function setNumber(entity, value) {
|
||||
push('input_number', 'set_value', { entity_id: entity, value });
|
||||
}
|
||||
const push = (domain, service, data) => calls.push({ json: { domain, service, data } });
|
||||
const setText = (entity, value) => push('input_text', 'set_value', { entity_id: entity, value: (value ?? '').toString().slice(0, 250) });
|
||||
|
||||
// Statut + horodatage + modèle : écrits à CHAQUE run
|
||||
setText('input_text.evcs_advisor_status', status + (detail ? `: ${detail}` : ''));
|
||||
setText('input_text.evcs_advisor_last_run', stampParis());
|
||||
setText('input_text.evcs_advisor_model', modelUsed);
|
||||
|
||||
if (status === 'ok' && advice) {
|
||||
// Observation — la seule sortie desormais. Le conseiller ne planifie plus :
|
||||
// la planification est faite par le bilan, qui est calcule et mesure, et la
|
||||
// campagne du 12/09 a montre que lui demander les deux degradait les deux
|
||||
// (ecart de cible passe de 0 a 35 points sur certains cas des l ajout de la
|
||||
// mission d observation).
|
||||
setText('input_text.evcs_advisor_constat', advice.constat);
|
||||
setText('input_text.evcs_advisor_gravite', advice.gravite);
|
||||
setText('input_text.evcs_advisor_ce_qui_cloche', advice.ceQuiCloche || 'rien de signale');
|
||||
// Quand il n'y a rien a signaler il n'y a pas de piste, et recopier le
|
||||
// constat faisait afficher la meme phrase deux fois sur la carte — et
|
||||
// partir en double dans la colonne `piste` de la table d'observation.
|
||||
if (status === 'ok' && Array.isArray(pistes)) {
|
||||
calls.push({ json: { state_entity: 'sensor.evcs_advisor_revue', data: {
|
||||
state: context.jour,
|
||||
attributes: { friendly_name: 'Revue du conseiller IA', pistes, rejetees, modele: modelUsed,
|
||||
avertissement: 'Avis a verifier, pas des faits. Les constats chiffres sont dans sensor.evcs_revue_jour.' },
|
||||
} } });
|
||||
setText('input_text.evcs_advisor_reason',
|
||||
`[${Math.round(advice.confidence * 100)}%] ${advice.piste || 'rien a creuser'}`);
|
||||
|
||||
// Un observateur qui signale en permanence ne sert a rien : on compte le
|
||||
// VOLUME de signalements pour pouvoir le constater.
|
||||
if (advice.gravite === 'aucune') {
|
||||
setNumber('input_number.evcs_advisor_agree_count', context.agreeCount + 1);
|
||||
} else {
|
||||
setNumber('input_number.evcs_advisor_diverge_count', context.divergeCount + 1);
|
||||
}
|
||||
|
||||
`Revue du ${context.jour} : ${pistes.length} piste(s)${rejetees ? `, ${rejetees} ecartee(s)` : ''}`);
|
||||
push('logbook', 'log', {
|
||||
name: 'EVCS Advisor (observateur)',
|
||||
message: `[${advice.gravite}] ${advice.constat}`
|
||||
+ (advice.ceQuiCloche ? ` || ${advice.ceQuiCloche}` : '')
|
||||
+ (advice.piste ? ` || piste : ${advice.piste}` : ''),
|
||||
entity_id: 'input_text.evcs_advisor_constat',
|
||||
name: 'EVCS Advisor (relecteur)',
|
||||
message: pistes.map((p) => `[${p.code}] ${p.piste}`).join(' || ') || 'aucune piste retenue',
|
||||
entity_id: 'input_text.evcs_advisor_reason',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,317 +1,80 @@
|
||||
// =====================================================
|
||||
// EVCS ADVISOR SHADOW V1 — BUILD_PROMPT
|
||||
// Construit le prompt LLM à partir du snapshot HA compact.
|
||||
// MODE OMBRE : ce workflow n'écrit que des helpers advisor_*.
|
||||
// EVCS ADVISOR — BUILD_PROMPT (RELECTEUR DE LA JOURNEE, 08/10/2026)
|
||||
//
|
||||
// Le conseiller n'observe plus l'instantane toutes les 15 min : 1 500 « rien a
|
||||
// signaler » contre 871 signalements sans suite, et aucun des rates du 07/10
|
||||
// (HC de midi perdue, EDF en heures pleines a 18:03, VW bloquee de 01:24 a
|
||||
// 05:14 avec EDF ferme) n'avait ete vu. Il RELIT desormais, une fois par jour,
|
||||
// les constats CALCULES par le service d'historisation (tools/revue-jour.mjs,
|
||||
// sensor.evcs_revue_jour) et propose pour chacun une piste a examiner.
|
||||
// Doctrine §79 : les chiffres viennent du code, le modele ne fait que relire.
|
||||
// Il ne pilote rien.
|
||||
//
|
||||
// Il tourne quand : le conseiller est actif, la revue du jour est publiee, il
|
||||
// y a au moins un point a examiner, et il n'a pas deja repondu aujourd'hui.
|
||||
// =====================================================
|
||||
|
||||
function num(v, d) {
|
||||
const n = Number.parseFloat(v);
|
||||
return Number.isFinite(n) ? n : d;
|
||||
}
|
||||
function onOff(v) {
|
||||
return (v ?? 'off').toString().trim().toLowerCase() === 'on';
|
||||
}
|
||||
|
||||
const raw = $input.first().json;
|
||||
const input = typeof raw.data === 'string' ? JSON.parse(raw.data) : (raw.data || raw);
|
||||
const onOff = (v) => (v ?? 'off').toString().trim().toLowerCase() === 'on';
|
||||
|
||||
const jourParis = new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' });
|
||||
let constats = input.revue_jour_constats;
|
||||
if (typeof constats === 'string') { try { constats = JSON.parse(constats); } catch { constats = []; } }
|
||||
if (!Array.isArray(constats)) constats = [];
|
||||
const aExaminer = constats.filter((c) => c && c.gravite === 'a_examiner' && c.code && c.texte);
|
||||
|
||||
const revueDuJour = (input.revue_jour_date || '').toString().trim() === jourParis;
|
||||
const dejaRepondu = (input.advisor_revue_date || '').toString().trim() === jourParis;
|
||||
// Pas de relance au-dela de 10 h : un modele qui echoue ne doit pas etre
|
||||
// rappele toute la journee (le courriel part a 08:15 sans lui).
|
||||
const heureParis = Number(new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Paris', hour: '2-digit', hour12: false }).format(new Date()));
|
||||
|
||||
const ctx = {
|
||||
advisorEnabled: onOff(input.advisor_enabled),
|
||||
agreeCount: num(input.advisor_agree_count, 0),
|
||||
divergeCount: num(input.advisor_diverge_count, 0),
|
||||
|
||||
soc: num(input.soc_percent, NaN),
|
||||
batteryCurrentA: num(input.battery_current_a, NaN),
|
||||
pvW: num(input.pv_power_w, 0),
|
||||
pvAvg10mW: num(input.pv_power_10m_avg_w, 0),
|
||||
houseW: num(input.house_ac_power_w, 0),
|
||||
gridOn: onOff(input.grid_state),
|
||||
acinAvailable: num(input.acin1_available, 0) === 1,
|
||||
|
||||
vehicleConnected: onOff(input.evcs_vehicle_connected),
|
||||
evcsCharging: onOff(input.evcs_charging),
|
||||
evcsCurrentA: num(input.evcs_current_a, 0),
|
||||
surplusState: (input.evcs_surplus_state || '').toString(),
|
||||
sourceMode: (input.evcs_source_mode || '').toString(),
|
||||
|
||||
userStrategy: (input.user_strategy || 'auto_solar_plus').toString().trim().toLowerCase(),
|
||||
allowGridHcNight: onOff(input.allow_grid_hc_night),
|
||||
allowGridHcMidday: onOff(input.allow_grid_hc_midday),
|
||||
nightTargetActual: num(input.night_target_soc_actual, 35),
|
||||
currentMaxAutoA: num(input.current_max_auto_a, 22),
|
||||
plafondEdfOnA: num(input.plafond_edf_on_a, 20),
|
||||
|
||||
departureTime: (input.departure_time || '').toString(),
|
||||
departurePriority: onOff(input.departure_priority_enabled),
|
||||
requiredEnergyKwh: num(input.required_energy_kwh, 0),
|
||||
|
||||
todayRemainingKwh: num(input.today_remaining_kwh, 0),
|
||||
tomorrowKwh: num(input.tomorrow_kwh, 0),
|
||||
|
||||
// ── BILAN ENERGETIQUE ────────────────────────────────────────────────
|
||||
// Ces grandeurs REMPLACENT les regles de seuil. Elles n'etaient pas
|
||||
// transmises jusqu'au 12/09 : le conseiller raisonnait sur des paliers
|
||||
// que le bilan avait deja demontres faux, et il ne pouvait pas mieux faire.
|
||||
balanceStatus: (input.balance_status || 'indisponible').toString().trim(),
|
||||
balanceLastRun: (input.balance_last_run || '').toString().trim(),
|
||||
balanceRequiredSoc: num(input.balance_required_soc, NaN),
|
||||
balanceRequiredSocPrudent: num(input.balance_required_soc_prudent, NaN),
|
||||
balanceEnergyToCoverKwh: num(input.balance_energy_to_cover_kwh, NaN),
|
||||
balanceCrossingHour: (input.balance_crossing_hour || '').toString().trim(),
|
||||
balanceGridNeeded: onOff(input.balance_grid_needed),
|
||||
balanceGridEnergyNeededKwh: num(input.balance_grid_energy_needed_kwh, NaN),
|
||||
balancePvCensored: onOff(input.balance_pv_censored),
|
||||
balanceCensorReason: (input.balance_censor_reason || '').toString().trim(),
|
||||
balanceStorageShortfallKwh: num(input.balance_storage_shortfall_kwh, NaN),
|
||||
balanceNextDeficitKwh: num(input.balance_next_deficit_kwh, NaN),
|
||||
balanceNextDeficitWindow: (input.balance_next_deficit_window || '').toString().trim(),
|
||||
stormLevel: (input.balance_storm_level || 'indisponible').toString().trim(),
|
||||
stormReserveSoc: num(input.balance_storm_reserve_soc, NaN),
|
||||
|
||||
// ── PARC ET MATERIEL, MESURES ────────────────────────────────────────
|
||||
modulesEnService: num(input.modules_en_service, 4),
|
||||
bmsMaxDischargeA: num(input.bms_max_discharge_current_a, NaN),
|
||||
bmsMaxChargeA: num(input.bms_max_charge_current_a, NaN),
|
||||
gridSetpointW: num(input.grid_setpoint_w, NaN),
|
||||
vehicleSelected: (input.vehicle_selected || 'inconnu').toString().trim(),
|
||||
// Historique recent des transitions, fourni par le snapshot. C est la SUITE
|
||||
// des evenements qui revele les contradictions entre automates ; un
|
||||
// instantane ne peut pas les montrer.
|
||||
evenements: String(input.evenements_recents || '')
|
||||
.split('|').map(x => x.trim()).filter(Boolean).slice(0, 12)
|
||||
.map(x => `- ${x}`),
|
||||
|
||||
mppt: (() => {
|
||||
const modes = String(input.mppt_operation_modes || '')
|
||||
.split(',').map(x => x.trim().toLowerCase())
|
||||
.filter(x => x && x !== 'unavailable' && x !== 'unknown');
|
||||
return {
|
||||
total: modes.length,
|
||||
aFond: modes.filter(x => x === 'mppt_active').length,
|
||||
brides: modes.filter(x => x.includes('limited')).length,
|
||||
};
|
||||
})(),
|
||||
jour: jourParis,
|
||||
revueDuJour,
|
||||
dejaRepondu,
|
||||
constats: aExaminer.map((c) => ({ code: c.code, texte: String(c.texte), contexte: String(c.contexte || '') })),
|
||||
};
|
||||
|
||||
// Capacite REELLEMENT livree a la maison, deduite de la courbe mesuree le
|
||||
// 03/09 sur 576 paliers et du parc declare. Un US5000 compte pour DEUX
|
||||
// equivalents US2000C. Aucune constante : si le parc change, ceci suit.
|
||||
// GRANDEURS DERIVEES — on les CALCULE pour lui (12/09/2026, iteration 2).
|
||||
// Campagne de rejeu : sur le releve du 03/09 19:44 (PV 758 W, maison 1202 W)
|
||||
// il a recommande de charger la voiture a 28 A. Les deux nombres etaient dans
|
||||
// son prompt ; il n a pas fait la soustraction. Et sur celui du 05/09 20:40
|
||||
// (batterie a -97,4 A pour une limite declaree de 100 A) il a repondu « SOC
|
||||
// 79 % est suffisant » sans mentionner ni le courant ni la limite.
|
||||
// Un modele raisonne bien sur UN nombre, mal sur deux qu il doit combiner.
|
||||
// Meme principe que le bilan : on lui donne la grandeur, pas les ingredients.
|
||||
const maisonHorsVoitureW = Math.max(0, ctx.houseW - (ctx.evcsCharging ? ctx.evcsCurrentA * 230 : 0));
|
||||
const excedentSolaireW = Math.round(ctx.pvW - maisonHorsVoitureW);
|
||||
const margeRechargeW = ctx.gridOn && Number.isFinite(ctx.gridSetpointW)
|
||||
? Math.round(ctx.gridSetpointW - maisonHorsVoitureW)
|
||||
: null;
|
||||
// Le bilan dit lui-meme si la batterie doit monter. A defaut de bilan
|
||||
// exploitable on ne tranche pas, et la marge reste un simple constat.
|
||||
const batterieDoitMonter = Number.isFinite(ctx.balanceRequiredSoc) && Number.isFinite(ctx.soc)
|
||||
? ctx.soc < ctx.balanceRequiredSoc
|
||||
: null;
|
||||
const margeDechargeA = Number.isFinite(ctx.bmsMaxDischargeA) && Number.isFinite(ctx.batteryCurrentA)
|
||||
? Math.round(ctx.bmsMaxDischargeA - Math.abs(Math.min(0, ctx.batteryCurrentA)))
|
||||
: null;
|
||||
const partDuPlafond = Number.isFinite(ctx.bmsMaxDischargeA) && ctx.bmsMaxDischargeA > 0
|
||||
? Math.round(100 * Math.abs(Math.min(0, ctx.batteryCurrentA)) / ctx.bmsMaxDischargeA)
|
||||
: null;
|
||||
|
||||
const kwhParPointHaut = 0.169 * ctx.modulesEnService / 7;
|
||||
const kwhParPointBas = 0.097 * ctx.modulesEnService / 7;
|
||||
const capaciteUtileKwh = (67 * kwhParPointHaut + 11 * kwhParPointBas) * 0.955;
|
||||
|
||||
// Heure Paris
|
||||
const parisFmt = new Intl.DateTimeFormat('fr-FR', {
|
||||
timeZone: 'Europe/Paris',
|
||||
weekday: 'long', day: '2-digit', month: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
});
|
||||
const nowParis = parisFmt.format(new Date());
|
||||
|
||||
const systemPrompt = [
|
||||
"Tu es le PLANIFICATEUR ENERGIE d'une maison en autoconsommation, en Bretagne.",
|
||||
'Tu es en MODE OBSERVATION : tes reponses sont enregistrees et comparees au reel,',
|
||||
'elles ne pilotent rien.',
|
||||
"Tu es le RELECTEUR du systeme energie d'une maison en Bretagne : 16 kWc de solaire,",
|
||||
'batterie de ~23 kWh utiles (0,30 kWh par point de SOC), Victron ESS SANS injection',
|
||||
'(le solaire est bride quand personne ne le prend), abonnement EDF 12 kVA, contacteur',
|
||||
'EDF ouvert la plupart du temps, heures creuses 01:24-07:24 et 12:24-14:24, borne de',
|
||||
'recharge pour trois vehicules. Des automates decident : le controleur de la borne,',
|
||||
"le bilan energetique (SOC requis), l'ACM (courant de charge), l'hysteresis EDF, des",
|
||||
'automatisations Home Assistant.',
|
||||
'',
|
||||
'INSTALLATION, CHIFFRES MESURES (pas des ordres de grandeur) :',
|
||||
`- Batterie : ${ctx.modulesEnService} equivalents US2000C, soit ${capaciteUtileKwh.toFixed(1)} kWh`,
|
||||
` reellement livres a la maison entre 100 % et le plancher de securite de 22 %.`,
|
||||
` Un point de SOC vaut ${Math.round(kwhParPointHaut * 1000)} Wh au-dessus de 33 %,`,
|
||||
` ${Math.round(kwhParPointBas * 1000)} Wh en dessous.`,
|
||||
'- Champ PV 16,1 kWc sur QUATRE orientations (sud-est 40, sud 35, ouest 45, un suiveur).',
|
||||
'- Victron ESS, AUCUNE injection possible : la production est bridee des que rien',
|
||||
' ne peut l absorber. Une PV mesuree basse ne veut donc PAS dire ressource faible.',
|
||||
'- Borne EVCS 6-28 A. Reseau EDF commutable, heures creuses 01:24-07:24 et 12:24-14:24.',
|
||||
'- Chaque fermeture du contacteur coute 50 a 150 Wh de transitoire : une session',
|
||||
' longue et dimensionnee vaut mieux que des creneaux repetes.',
|
||||
'On te donne des CONSTATS MESURES sur les dernieres 24 h. Ce sont des FAITS : ne les',
|
||||
'conteste pas et ne les recalcule pas. Pour chacun, propose UNE piste : la cause la',
|
||||
'plus probable et ce qu il faut verifier ou corriger. Tu ne pilotes rien.',
|
||||
'',
|
||||
'LA MAISON, telle qu elle consomme vraiment :',
|
||||
'- Quatre personnes. Les gros appels sont BREFS et cales sur les repas :',
|
||||
' bouilloire vers 05:45, grille-pain vers 08:00, cuisson du soir entre 20 h et 23 h.',
|
||||
"- L heure du diner varie de TROIS HEURES d un jour a l autre : un profil horaire",
|
||||
' moyen ne represente ni l un ni l autre.',
|
||||
'REGLES ABSOLUES :',
|
||||
'- N ecris AUCUN chiffre qui ne figure pas dans le constat ou son contexte.',
|
||||
'- Si tu ne vois pas de cause, dis « cause non identifiable avec ces donnees ».',
|
||||
'- Une piste fait au plus 280 caracteres, en francais, sans jargon inutile.',
|
||||
'- Le contexte est la raison publiee par le controleur au debut de l episode.',
|
||||
'',
|
||||
'CE QUI REMPLACE LES REGLES DE SEUIL :',
|
||||
'un bilan energetique tourne toutes les 5 minutes et te donne, pour l instant present,',
|
||||
'l heure de croisement ou la production repassera au-dessus de la consommation,',
|
||||
'l energie que la batterie doit couvrir d ici la, et le SOC qui serait necessaire.',
|
||||
'RAISONNE SUR CES CHIFFRES. Il n existe plus de palier du type',
|
||||
'"prevision forte => cible basse" : cette regle a ete mesuree fausse sur cette maison.',
|
||||
'Si le bilan est indisponible ou perime, dis-le dans ta raison et reste prudent.',
|
||||
'',
|
||||
'CE QUE TU DOIS SAVOIR ET QUE PERSONNE NE TE DIRA AUTREMENT :',
|
||||
'- SANS VEHICULE BRANCHE IL Y A QUAND MEME QUELQUE CHOSE A DECIDER : c est la MAISON',
|
||||
' qui doit traverser la nuit. Une batterie au plancher pendant les heures creuses,',
|
||||
' alors que le bilan reclame davantage, est une occasion manquee qui se paiera au',
|
||||
' plein tarif le matin.',
|
||||
'- Les chargeurs solaires declarent eux-memes s ils se BRIDENT. Si certains se',
|
||||
' brident, il reste de la ressource REFUSEE : lui donner un preneur (voiture,',
|
||||
' batterie) ne coute rien. S ils sont tous a fond, la production mesuree EST la',
|
||||
' ressource.',
|
||||
'- L hiver la nuit a couvrir est plus longue de deux heures en temps solaire : la',
|
||||
' cible du matin y monte naturellement bien plus haut qu en ete.',
|
||||
'',
|
||||
'DEUX REGLES QUI PRIMENT SUR TOUT LE RESTE :',
|
||||
'1. EXCEDENT SOLAIRE. Charger un vehicule ne se decide pas sur la production',
|
||||
' mais sur ce qui RESTE une fois la maison servie. Cet excedent t est donne',
|
||||
' plus bas. S il est NEGATIF, charger la voiture revient a vider la batterie',
|
||||
' solaire : ne le fais pas, quelle que soit la prevision. S il est nul ou a',
|
||||
' peine positif ALORS QUE DES CHARGEURS SE BRIDENT, la ressource existe mais',
|
||||
' est refusee : la charger est gratuit.',
|
||||
'2. LIMITE DU PARC. La marge de decharge restante t est donnee plus bas. Si la',
|
||||
' batterie depasse 80 % de ce que le parc declare accepter, c est un sujet de',
|
||||
' PROTECTION DU MATERIEL, pas de confort : il faut appeler le reseau meme si',
|
||||
' le SOC parait confortable et meme hors heures creuses. Le proprietaire a un',
|
||||
' abonnement 12 kVA et prefere payer quelques kWh plutot que fragiliser le parc.',
|
||||
'',
|
||||
// <<< CARTE GENEREE — ne pas editer a la main, voir tools/carte-decisions.mjs
|
||||
'QUI ECRIT SUR QUOI (carte generee depuis les sources) :',
|
||||
'- Contacteur reseau EDF : proprietaire = Battery / Grid Hysteresis Fix V1, AUTRES ECRIVAINS : EVCS Surplus Controller V2.6, automations, scripts',
|
||||
'- Courant de la borne : proprietaire = EVCS Surplus Controller V2.6, AUTRES ECRIVAINS : scripts',
|
||||
'- Verrou hysteresis : proprietaire = Battery / Grid Hysteresis Fix V1, AUTRES ECRIVAINS : scripts',
|
||||
'SEUILS DE SOC FIGES dans le code : 20, 22, 25, 35, 40, 60, 80, 85, 90, 95, 100 % — chacun peut contredire le bilan.',
|
||||
'Quand un actionneur bascule de facon incoherente, le coupable le plus probable',
|
||||
'est un ecrivain AUTRE QUE SON PROPRIETAIRE. Nomme-le.',
|
||||
// CARTE GENEREE >>>
|
||||
'',
|
||||
'TU NE PILOTES RIEN ET TU NE CHOISIS AUCUNE STRATEGIE.',
|
||||
'La planification energetique est faite par le bilan, qui est calcule et mesure.',
|
||||
'Ton seul travail est de REGARDER LE SYSTEME SE COMPORTER et de dire ce qui cloche.',
|
||||
'',
|
||||
'Reponds UNIQUEMENT avec un objet JSON valide, sans texte autour, au format EXACT :',
|
||||
'{"constat":"ce que tu observes, factuel, max 200 caracteres",',
|
||||
'"gravite":"aucune|faible|moyenne|haute",',
|
||||
'"ce_qui_cloche":"la contradiction ou l incoherence, ou \'\' si rien",',
|
||||
'"piste":"ce qu il faudrait verifier ou corriger, ou \'\' si rien",',
|
||||
'"confidence":<0.0-1.0>}',
|
||||
'',
|
||||
'ECHELLE DE GRAVITE, ancree sur des cas reels de cette installation :',
|
||||
'- aucune : tout est coherent. C EST LE CAS LE PLUS FREQUENT ET DE LOIN.',
|
||||
' N invente pas un probleme pour justifier une reponse.',
|
||||
'- faible : un ecart sans consequence mesurable — une valeur un peu hors cible,',
|
||||
' un etat transitoire qui se resorbe seul.',
|
||||
'- moyenne : quelque chose coute de l energie ou de l argent sans danger materiel.',
|
||||
' Exemple reel : la consigne reseau laissee haute en plein soleil, qui',
|
||||
' evince la production — 5,6 kW de solaire remplaces par du reseau.',
|
||||
'- haute : un actionneur bascule de facon repetee, OU le materiel est sollicite',
|
||||
' pres de ses limites, OU un automate defait ce qu un autre vient de',
|
||||
' faire. Deux exemples reels : le contacteur reseau qui bascule six',
|
||||
' fois en dix minutes le 12/09 — chaque bascule coute 50 a 150 Wh et',
|
||||
' use le relais, et aucun automate ne le voyait ; et le 05/09 a 20:40,',
|
||||
' le parc a -97,4 A pour une limite declaree de 100 A, soit 3 A de',
|
||||
' marge, pendant que le reseau restait disponible et inutilise.',
|
||||
'UNE OSCILLATION EST TOUJOURS DE GRAVITE HAUTE, meme si chaque bascule prise',
|
||||
'isolement parait anodine : c est la repetition qui fait le degat, et c est',
|
||||
'precisement ce qu aucune regle locale ne peut voir.',
|
||||
'Reponds UNIQUEMENT en JSON : {"pistes":[{"code":"<code du constat>","piste":"<texte>"}]}',
|
||||
].join('\n');
|
||||
|
||||
// Les etats du controleur sont des identifiants internes. Le conseiller n'a
|
||||
// aucune raison de savoir que `day_vehicle_full_idle` veut dire « le vehicule
|
||||
// est plein, il n'absorbera plus rien » — et sans le savoir il signale comme
|
||||
// une anomalie un systeme qui a raison.
|
||||
const ETATS = {
|
||||
vehicle_not_connected: 'aucun vehicule branche',
|
||||
idle_wait_conditions: 'a l arret, conditions de demarrage non reunies',
|
||||
idle_wait_source: 'a l arret, en attente d une source',
|
||||
day_vehicle_full_idle: 'vehicule PLEIN, il n absorbera plus rien (memoire du jour)',
|
||||
night_recovery_done_idle: 'charge de nuit terminee ET batterie a la cible',
|
||||
night_session_done_idle: 'charge de nuit terminee, batterie encore sous la cible',
|
||||
departure_done_idle: 'charge avant depart terminee',
|
||||
vehicle_full_or_paused: 'le vehicule ne demande plus de courant',
|
||||
start_min_current: 'demarrage au courant minimal',
|
||||
start_grid_hc_min_current: 'demarrage sur le reseau en heures creuses',
|
||||
start_manual_local_min_current: 'demarrage manuel au courant minimal',
|
||||
charging_hold_grid: 'en charge sur le reseau, consigne tenue',
|
||||
charging_hold_manual_local: 'en charge, consigne manuelle',
|
||||
charging_hold_cloud_tolerance: 'en charge, tolerance nuage en cours',
|
||||
charging_step_up_battery: 'en charge, courant en hausse sur le solaire',
|
||||
charging_step_up_grid_hc: 'en charge, courant en hausse sur le reseau',
|
||||
charging_step_down_battery: 'en charge, courant en baisse',
|
||||
charging_step_down_system_cap: 'en charge, courant ramene au plafond du systeme',
|
||||
battery_current_hard_stop: 'ARRET : la batterie se dechargeait trop fort',
|
||||
stop_conditions_lost: 'ARRET : conditions de maintien perdues',
|
||||
stop_source_lost: 'ARRET : la source a disparu',
|
||||
hold_min_run: 'maintenu le temps de la duree minimale de marche',
|
||||
};
|
||||
const etatLisible = (e) => ETATS[e] ? `${ETATS[e]} (${e})` : e;
|
||||
|
||||
function nombre(v, unite, defaut) {
|
||||
return Number.isFinite(v) ? `${v}${unite}` : (defaut || 'inconnu');
|
||||
}
|
||||
|
||||
const bilanLignes = ctx.balanceStatus === 'ok'
|
||||
? [
|
||||
`- heure de croisement production/consommation : ${ctx.balanceCrossingHour || 'inconnue'}`,
|
||||
`- energie que la batterie doit couvrir d ici la : ${nombre(ctx.balanceEnergyToCoverKwh, ' kWh')}`,
|
||||
`- SOC qui serait necessaire : ${nombre(ctx.balanceRequiredSoc, ' %')} (prudent : ${nombre(ctx.balanceRequiredSocPrudent, ' %')})`,
|
||||
`- reseau juge necessaire : ${ctx.balanceGridNeeded ? 'OUI' : 'non'}${Number.isFinite(ctx.balanceGridEnergyNeededKwh) ? ` (${ctx.balanceGridEnergyNeededKwh} kWh)` : ''}`,
|
||||
`- production actuellement bridee : ${ctx.balancePvCensored ? `OUI (${ctx.balanceCensorReason})` : 'non'}`,
|
||||
`- manque de stockage identifie : ${nombre(ctx.balanceStorageShortfallKwh, ' kWh')}`,
|
||||
`- prochaine traversee deficitaire : ${nombre(ctx.balanceNextDeficitKwh, ' kWh')} ${ctx.balanceNextDeficitWindow || ''}`,
|
||||
`- vigilance orage : ${ctx.stormLevel}${Number.isFinite(ctx.stormReserveSoc) ? `, reserve conseillee ${ctx.stormReserveSoc} %` : ''}`,
|
||||
`- calcule a ${ctx.balanceLastRun || 'heure inconnue'}`,
|
||||
]
|
||||
: [`- INDISPONIBLE (statut ${ctx.balanceStatus}) : raisonne sans lui, et dis-le.`];
|
||||
|
||||
const userPrompt = [
|
||||
`Nous sommes ${nowParis} (heure de Paris).`,
|
||||
`Revue du ${jourParis}. ${aExaminer.length} constat(s) a examiner :`,
|
||||
'',
|
||||
'ETAT MESURE :',
|
||||
`- Batterie : SOC ${ctx.soc} %, courant ${ctx.batteryCurrentA} A (positif = charge)`,
|
||||
`- PV : ${Math.round(ctx.pvW)} W instantane, ${Math.round(ctx.pvAvg10mW)} W sur 10 min`,
|
||||
`- Chargeurs solaires : ${ctx.mppt.aFond}/${ctx.mppt.total} a pleine puissance, ${ctx.mppt.brides} qui se BRIDENT`,
|
||||
`- Maison : ${Math.round(ctx.houseW)} W, dont voiture ${ctx.evcsCharging ? Math.round(ctx.evcsCurrentA * 230) : 0} W`,
|
||||
`- EXCEDENT SOLAIRE (production moins maison hors voiture) : ${excedentSolaireW > 0 ? `+${excedentSolaireW} W, il RESTE du solaire` : excedentSolaireW < 0 ? `${excedentSolaireW} W` : '0 W, exactement a l equilibre'}${ctx.mppt.brides > 0 ? ` — ATTENTION, ce chiffre est CENSURE : ${ctx.mppt.brides} chargeurs se retiennent, la ressource reelle est PLUS HAUTE${excedentSolaireW < 0 ? ' et ce chiffre negatif ne prouve PAS qu il manque du soleil' : ''}` : excedentSolaireW < 0 ? ', DEFICIT reel : la batterie porte deja la maison' : ''}`,
|
||||
`- MARGE DE DECHARGE du parc : ${margeDechargeA === null ? 'inconnue' : `${margeDechargeA} A restants`}${partDuPlafond === null ? '' : ` — la batterie est a ${partDuPlafond} % de son plafond`}`,
|
||||
`- Reseau EDF : ${ctx.gridOn ? 'PRESENT, il peut alimenter la maison' : 'ABSENT, la maison vit sur batterie et solaire'} (contacteur ${ctx.gridOn ? 'ferme' : 'ouvert'}, AC-IN ${ctx.acinAvailable ? 'present' : 'absent'})`,
|
||||
`- Consigne de puissance reseau : ${nombre(ctx.gridSetpointW, ' W')}${margeRechargeW === null ? '' : batterieDoitMonter === false && margeRechargeW > 0 ? ` — la batterie n a PAS besoin de monter (SOC ${ctx.soc} %, bilan ${ctx.balanceRequiredSoc} %), donc ces ${margeRechargeW} W de demande reseau n ont aucune destination : ils ne peuvent que remplacer du solaire` : margeRechargeW > 0 ? ` — il reste ${margeRechargeW} W pour RECHARGER la batterie, qui en a besoin` : ' — une fois la maison servie il ne reste RIEN pour recharger la batterie'}`,
|
||||
`- Limites declarees par le parc : charge ${nombre(ctx.bmsMaxChargeA, ' A')}, decharge ${nombre(ctx.bmsMaxDischargeA, ' A')}`,
|
||||
`- Vehicule : ${ctx.vehicleConnected ? `BRANCHE (${ctx.vehicleSelected})` : 'AUCUN branche'}${ctx.evcsCharging ? `, en charge a ${ctx.evcsCurrentA} A` : ''}`,
|
||||
`- Controleur : ${etatLisible(ctx.surplusState)} | source ${ctx.sourceMode} | strategie reelle ${ctx.userStrategy}`,
|
||||
`- Cible SOC matin actuellement appliquee : ${ctx.nightTargetActual} %`,
|
||||
`- Prevision : ${ctx.todayRemainingKwh} kWh restant aujourd hui, ${ctx.tomorrowKwh} kWh demain`,
|
||||
...aExaminer.map((c, i) => `${i + 1}. code=${c.code}\n constat : ${c.texte}\n contexte : ${String(c.contexte || '(aucun)').slice(0, 400)}`),
|
||||
'',
|
||||
'BILAN ENERGETIQUE :',
|
||||
...bilanLignes,
|
||||
'',
|
||||
'DERNIERS EVENEMENTS DU SYSTEME (le plus recent en premier) :',
|
||||
...(ctx.evenements.length ? ctx.evenements : ['- aucun evenement transmis']),
|
||||
'',
|
||||
'Quelle est ta recommandation ? JSON uniquement.',
|
||||
'JSON uniquement.',
|
||||
].join('\n');
|
||||
|
||||
return [{
|
||||
json: {
|
||||
enabled: ctx.advisorEnabled,
|
||||
enabled: ctx.advisorEnabled && revueDuJour && !dejaRepondu && aExaminer.length > 0 && heureParis < 10,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
context: ctx,
|
||||
}
|
||||
},
|
||||
}];
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
"advisor_enabled": states("input_boolean.evcs_advisor_enabled"),
|
||||
"advisor_agree_count": states("input_number.evcs_advisor_agree_count"),
|
||||
"advisor_diverge_count": states("input_number.evcs_advisor_diverge_count"),
|
||||
"revue_jour_date": states("sensor.evcs_revue_jour"),
|
||||
"revue_jour_constats": state_attr("sensor.evcs_revue_jour", "constats") or [],
|
||||
"advisor_revue_date": states("sensor.evcs_advisor_revue"),
|
||||
|
||||
"soc_percent": states("sensor.victron_battery_soc"),
|
||||
"battery_current_a": states("sensor.victron_battery_current"),
|
||||
|
||||
@@ -1,38 +1,25 @@
|
||||
// =====================================================
|
||||
// EVCS ADVISOR SHADOW V1 — VALIDATE_PARSE
|
||||
// EVCS ADVISOR — VALIDATE_PARSE (relecteur, 08/10/2026)
|
||||
// Valide STRICTEMENT la sortie LLM (locale OU cloud, format OpenAI).
|
||||
// Sortie invalide = REJET TOTAL (status invalid), jamais de clamp silencieux.
|
||||
// - JSON {"pistes":[{code, piste}]} ; sinon REJET TOTAL.
|
||||
// - une piste dont le code n'est pas celui d'un constat est ecartee ;
|
||||
// - ANTI-INVENTION : une piste qui contient un nombre absent du constat et de
|
||||
// son contexte est ecartee (le modele ne produit aucun chiffre, §79).
|
||||
// =====================================================
|
||||
|
||||
const resp = $input.first().json;
|
||||
const context = $('BUILD_PROMPT').first().json.context;
|
||||
|
||||
|
||||
function fail(status, detail) {
|
||||
return [{
|
||||
json: {
|
||||
status,
|
||||
detail: (detail || '').toString().slice(0, 200),
|
||||
advice: null,
|
||||
modelUsed: (resp?.model || 'aucun').toString().slice(0, 60),
|
||||
context,
|
||||
}
|
||||
}];
|
||||
return [{ json: { status, detail: (detail || '').toString().slice(0, 200), pistes: null, rejetees: 0,
|
||||
modelUsed: (resp?.model || 'aucun').toString().slice(0, 60), context } }];
|
||||
}
|
||||
|
||||
// 1. Réponse LLM présente ? (les deux backends ont échoué => item d'erreur n8n)
|
||||
const content = resp?.choices?.[0]?.message?.content;
|
||||
if (typeof content !== 'string' || !content.trim()) {
|
||||
return fail('llm_offline', resp?.error?.message || resp?.error || 'aucune réponse LLM');
|
||||
}
|
||||
|
||||
// 2. Nettoyage : balises de raisonnement (<think>...</think>) et fences markdown
|
||||
let text = content
|
||||
.replace(/<think>[\s\S]*?<\/think>/gi, '')
|
||||
.replace(/```(?:json)?/gi, '')
|
||||
.trim();
|
||||
|
||||
// 3. Extraction du premier objet JSON équilibré
|
||||
const text = content.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/```(?:json)?/gi, '').trim();
|
||||
const start = text.indexOf('{');
|
||||
if (start === -1) return fail('invalid', 'aucun JSON dans la réponse');
|
||||
let depth = 0, end = -1;
|
||||
@@ -41,47 +28,26 @@ for (let i = start; i < text.length; i++) {
|
||||
else if (text[i] === '}') { depth--; if (depth === 0) { end = i; break; } }
|
||||
}
|
||||
if (end === -1) return fail('invalid', 'JSON non terminé');
|
||||
|
||||
let obj;
|
||||
try {
|
||||
obj = JSON.parse(text.slice(start, end + 1));
|
||||
} catch (e) {
|
||||
return fail('invalid', `JSON invalide: ${e.message}`);
|
||||
try { obj = JSON.parse(text.slice(start, end + 1)); } catch (e) { return fail('invalid', `JSON invalide: ${e.message}`); }
|
||||
if (!Array.isArray(obj.pistes)) return fail('invalid', 'champ pistes absent');
|
||||
|
||||
// Nombres d'un texte, normalises (virgule decimale -> point, sans zeros inutiles).
|
||||
const nombres = (s) => (String(s).match(/\d+(?:[.,]\d+)?/g) || []).map((x) => String(Number(x.replace(',', '.'))));
|
||||
|
||||
const parCode = new Map(context.constats.map((c) => [c.code, c]));
|
||||
const pistes = [];
|
||||
let rejetees = 0;
|
||||
for (const p of obj.pistes) {
|
||||
const code = (p?.code ?? '').toString().trim();
|
||||
const piste = (p?.piste ?? '').toString().trim();
|
||||
const c = parCode.get(code);
|
||||
if (!c || !piste) { rejetees++; continue; }
|
||||
const permis = new Set(nombres(`${c.texte} ${c.contexte}`));
|
||||
if (nombres(piste).some((n) => !permis.has(n))) { rejetees++; continue; }
|
||||
if (pistes.some((x) => x.code === code)) { rejetees++; continue; }
|
||||
pistes.push({ code, piste: piste.slice(0, 280) });
|
||||
}
|
||||
|
||||
// 4. Validation stricte du schéma (enums + bornes)
|
||||
// ── OBSERVATION (12/09/2026) ───────────────────────────────────────────
|
||||
// La mission principale du conseiller n est plus de choisir une strategie
|
||||
// parmi cinq, mais de REPERER CE QUI CLOCHE. Les pannes de ce systeme sont
|
||||
// des contradictions entre automates — chacun localement correct — et elles
|
||||
// ne se voient que dans la suite des evenements. Le 12/09 le contacteur
|
||||
// reseau a bascule toutes les 4 minutes pendant dix minutes : aucun automate
|
||||
// ne l a vu, un humain oui.
|
||||
const GRAVITES = ['aucune', 'faible', 'moyenne', 'haute'];
|
||||
const constat = (obj.constat ?? '').toString().trim();
|
||||
if (!constat) return fail('invalid', 'constat vide');
|
||||
const gravite = (obj.gravite ?? '').toString().trim().toLowerCase();
|
||||
if (!GRAVITES.includes(gravite)) return fail('invalid', `gravite hors enum: ${obj.gravite}`);
|
||||
const ceQuiCloche = (obj.ce_qui_cloche ?? '').toString().trim();
|
||||
const piste = (obj.piste ?? '').toString().trim();
|
||||
|
||||
const confidence = Number(obj.confidence);
|
||||
if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) {
|
||||
return fail('invalid', `confidence hors bornes: ${obj.confidence}`);
|
||||
}
|
||||
|
||||
return [{
|
||||
json: {
|
||||
status: 'ok',
|
||||
detail: '',
|
||||
advice: {
|
||||
constat: constat.slice(0, 200),
|
||||
gravite,
|
||||
ceQuiCloche: ceQuiCloche.slice(0, 200),
|
||||
piste: piste.slice(0, 200),
|
||||
confidence: Math.round(confidence * 100) / 100,
|
||||
},
|
||||
modelUsed: (resp.model || 'inconnu').toString().slice(0, 60),
|
||||
context,
|
||||
}
|
||||
}];
|
||||
return [{ json: { status: 'ok', detail: '', pistes, rejetees,
|
||||
modelUsed: (resp.model || 'inconnu').toString().slice(0, 60), context } }];
|
||||
|
||||
@@ -458,7 +458,59 @@ const hcFloorPrudentReachPct = Math.max(hcReachFloorPct(slotsP80, nextHcStartMs)
|
||||
// ---------------------------------------------------------------------
|
||||
const hcMiddayWindow = hcReachActive && hcNightReachActive && nextHcStartMs === nextNightHcStartMs;
|
||||
const hcMaxCharge = hcMiddayWindow && ecsNuitAujourdhui;
|
||||
const hcFloorTypicalPct = hcMaxCharge ? 100 : hcFloorReachPct;
|
||||
// ---------------------------------------------------------------------
|
||||
// LA NUIT PREPARE AUSSI LE SOIR QUAND LA JOURNEE S'ANNONCE FAIBLE (08/10/2026)
|
||||
//
|
||||
// Nuit du 07-08/10 : session engagee, mais la cible « tenir jusqu'a midi »
|
||||
// valait 30,7 % alors que le meme bilan annoncait 99,3 % requis pour la
|
||||
// traversee de 18 h. Les heures creuses de la nuit coutent le meme prix que
|
||||
// celles de midi, et midi est borne par la maison (cuisine, voiture, BOOST).
|
||||
// Pendant la fenetre de NUIT : plus petit SOC de fin de fenetre qui, avec le
|
||||
// profil habituel (production ecretee a 100 %), amene la batterie au SOC requis
|
||||
// au debut de la traversee du soir. Si c'est hors d'atteinte, celui qui en
|
||||
// approche le plus SANS gaspiller (au-dela, le solaire du jour serait ecrete).
|
||||
// Journee suffisante -> rien ne change (le plus petit SOC est le plancher
|
||||
// « tenir jusqu'a midi »).
|
||||
// ---------------------------------------------------------------------
|
||||
const hcNightWindow = hcReachActive && !hcMiddayWindow;
|
||||
let nightEvening = { active: false };
|
||||
if (hcNightWindow) {
|
||||
const ndEve = nextDeficitWindow(slotsP50);
|
||||
if (ndEve.found && Number.isFinite(num(ndEve.startMs, NaN)) && ndEve.startMs > hcWindowEndMs) {
|
||||
const socAtEvening = (s0) => {
|
||||
let soc = s0;
|
||||
for (const sl of slotsP50) {
|
||||
const debut = num(sl.startMs, NaN);
|
||||
if (!Number.isFinite(debut)) continue;
|
||||
const a = Math.max(debut, hcWindowEndMs);
|
||||
const b = Math.min(debut + 3600000, ndEve.startMs);
|
||||
if (b <= a) continue;
|
||||
const net = sl.netKwh * (b - a) / 3600000;
|
||||
soc = net >= 0 ? Math.min(100, socAfterEnergy(soc, net))
|
||||
: Math.max(socFloorPct, socForEnergy(Math.max(0, energyBetweenSoc(0, soc) + net), 0));
|
||||
}
|
||||
return soc;
|
||||
};
|
||||
const requis = ndEve.requiredSocAtStartPct;
|
||||
const base = Math.max(socFloorPct, hcFloorReachPct);
|
||||
const objectif = Math.min(requis, socAtEvening(100)) - 0.05;
|
||||
let floor = base;
|
||||
if (socAtEvening(base) < objectif) {
|
||||
let lo = base, hi = 100;
|
||||
for (let k = 0; k < 30; k++) { const m = (lo + hi) / 2; if (socAtEvening(m) >= objectif) hi = m; else lo = m; }
|
||||
floor = hi;
|
||||
}
|
||||
nightEvening = {
|
||||
active: floor > base + 0.05,
|
||||
floorPct: round1(floor),
|
||||
eveningRequiredPct: round1(requis),
|
||||
eveningProjectedPct: round1(socAtEvening(base)),
|
||||
eveningStartHour: ndEve.startHour,
|
||||
};
|
||||
}
|
||||
}
|
||||
const hcFloorTypicalPct = hcMaxCharge ? 100
|
||||
: (nightEvening.active ? Math.max(hcFloorReachPct, nightEvening.floorPct) : hcFloorReachPct);
|
||||
const hcFloorPrudentPct = hcMaxCharge ? 100 : hcFloorPrudentReachPct;
|
||||
const typical = coverage(slotsP50, hcFloorTypicalPct);
|
||||
const prudent = coverage(slotsP80, hcFloorPrudentPct);
|
||||
@@ -917,6 +969,8 @@ return [{
|
||||
// Jour faible a midi : cible 100 % (charge maximale) ; floorReachPct garde
|
||||
// le plancher « tenir jusqu'a 01:24 » calcule, pour comparaison.
|
||||
maxCharge: hcMaxCharge,
|
||||
// Nuit d'un jour annonce faible : SOC de fin de nuit pour arriver au soir.
|
||||
nightEvening,
|
||||
floorReachPct: round1(Math.min(100, hcFloorReachPct)),
|
||||
floorPrudentPct: round1(Math.min(100, hcFloorPrudentPct)),
|
||||
nextNightHcStartMs: hcNightReachActive ? nextNightHcStartMs : null,
|
||||
|
||||
@@ -160,7 +160,10 @@ const reason = balance.crossing.found
|
||||
// Fenetre HC (28/09) : dire quand c'est « tenir jusqu'a la prochaine HC » qui
|
||||
// fixe l'exigence, sinon la raison affiche un croisement qui ne l'explique pas.
|
||||
const hc = balance.hcReach || {};
|
||||
const hcText = hc.active && hc.maxCharge
|
||||
const ne = hc.nightEvening || {};
|
||||
const hcText = hc.active && ne.active
|
||||
? ` Journée faible annoncée : la nuit charge à ${ne.floorPct}% (batterie prévue à ${ne.eveningProjectedPct}% à ${ne.eveningStartHour} h pour ${ne.eveningRequiredPct}% requis ; tenir jusqu'à midi demandait ${hc.floorReachPct}%).`
|
||||
: hc.active && hc.maxCharge
|
||||
? ` Jour faible : charge maximale en HC de midi (tenir jusqu'à ${new Date(hc.nextHcStartMs).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' })} demandait ${hc.floorReachPct}%).`
|
||||
: hc.active && hc.raisesRequirement
|
||||
? ` Tenir jusqu'à la prochaine HC (${new Date(hc.nextHcStartMs).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' })}) → ${hc.floorPct}% en fin de fenêtre.`
|
||||
|
||||
@@ -1150,6 +1150,7 @@ return [{
|
||||
nightChargeMode: nightPrev.nightChargeMode || 'sans_edf',
|
||||
nightCarWaitsNoGrid: !!nightPrev.nightCarWaitsNoGrid,
|
||||
nightBatteryGridOnly: !!nightPrev.nightBatteryGridOnly,
|
||||
nightCarRidesGridSession: !!nightPrev.nightCarRidesGridSession,
|
||||
maxBatteryDischargePoints,
|
||||
gridTakeoverTriggerPct,
|
||||
|
||||
|
||||
@@ -304,9 +304,12 @@ const reason = [...decision.reason];
|
||||
if (night.nightBudgetKwh !== null && night.nightBudgetKwh !== undefined) {
|
||||
const f = (x) => String(Math.round(x * 100) / 100).replace('.', ',');
|
||||
reason.push(`Budget batterie nuit ${f(night.nightBudgetDeliveredKwh ?? 0)}/${f(night.nightBudgetKwh)} kWh` +
|
||||
(night.nightBatteryGridOnly ? ' — sans EDF : EDF ferme pour la batterie seule, la voiture attend le soleil'
|
||||
(night.nightCarRidesGridSession ? ' — sans EDF, mais EDF est ferme pour la batterie en heures creuses : la voiture en profite'
|
||||
: night.nightBatteryGridOnly ? ' — sans EDF : EDF ferme pour la batterie seule, la voiture attend le soleil'
|
||||
: night.nightCarWaitsNoGrid ? ' — atteint, sans EDF : la voiture attend le soleil'
|
||||
: night.nightBudgetReached ? ' — atteint, EDF finit la charge' : ''));
|
||||
} else if (night.nightCarRidesGridSession) {
|
||||
reason.push('Sans EDF, mais EDF est ferme pour la batterie en heures creuses : la voiture en profite');
|
||||
} else if (night.nightCarWaitsNoGrid) {
|
||||
reason.push('Pas de budget batterie exploitable, sans EDF : la voiture attend le soleil');
|
||||
}
|
||||
|
||||
@@ -866,6 +866,17 @@ const nightGridTakeoverNeeded =
|
||||
)
|
||||
);
|
||||
|
||||
// « Sans EDF » MAIS EDF DEJA FERME POUR LA BATTERIE EN HC (08/10/2026,
|
||||
// proprietaire) : la voiture en profite. Nuit du 07-08/10 : session engagee des
|
||||
// 01:24 (jour faible), toute la maison sur le reseau en heures creuses, et la
|
||||
// VW branchee restait bloquee par « sans EDF ». Revient sur l'option A du 04/10.
|
||||
// « Sans EDF » garde son sens quand EDF n'a aucune autre raison d'etre ferme :
|
||||
// la voiture ne fait jamais fermer le contacteur a elle seule.
|
||||
const nightCarRidesGridSession =
|
||||
nightSessionActive &&
|
||||
nightCarWaitsNoGrid &&
|
||||
nightGridTakeoverNeeded;
|
||||
|
||||
const nightPreferredSourceMode =
|
||||
!nightSessionActive
|
||||
? 'blocked'
|
||||
@@ -878,7 +889,7 @@ const nightPreferredSourceMode =
|
||||
// laisse la JEEP prendre 4,7 kWh au reseau jusqu'au plein. La protection
|
||||
// du materiel reste entiere : EDF se ferme quand meme (takeover), seule la
|
||||
// voiture attend le soleil.
|
||||
: nightCarWaitsNoGrid
|
||||
: nightCarWaitsNoGrid && !nightCarRidesGridSession
|
||||
? 'blocked'
|
||||
: batteryAssistAllowed
|
||||
? 'battery_assist_night'
|
||||
@@ -895,10 +906,14 @@ const nightPreferredSourceMode =
|
||||
// reseau. Sans ce drapeau, la fermeture d'EDF et la consigne de recharge, qui
|
||||
// suivent la source de la VOITURE, seraient perdues avec elle (golden
|
||||
// budget-sans-edf-plancher-dur-edf-protege, depart-matin-…-sous-la-cible).
|
||||
// Depuis le 08/10 la voiture profite de la session : ce drapeau ne vaut plus
|
||||
// que si elle ne peut pas en profiter (garde de securite conservee, vide en
|
||||
// pratique).
|
||||
const nightBatteryGridOnly =
|
||||
nightSessionActive &&
|
||||
nightCarWaitsNoGrid &&
|
||||
nightGridTakeoverNeeded;
|
||||
nightGridTakeoverNeeded &&
|
||||
!nightCarRidesGridSession;
|
||||
|
||||
const batteryRecoveryAllowedNow =
|
||||
nightSessionActive &&
|
||||
@@ -1291,6 +1306,7 @@ return [{
|
||||
nightChargeMode: params.nightChargeMode,
|
||||
nightCarWaitsNoGrid,
|
||||
nightBatteryGridOnly,
|
||||
nightCarRidesGridSession,
|
||||
nightGridSessionCommitted,
|
||||
middayVehicleGridNeeded,
|
||||
nightGridSessionUncovered,
|
||||
|
||||
Reference in New Issue
Block a user