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,153 +1,94 @@
|
||||
#!/usr/bin/env node
|
||||
// Tests de l'EVCS Advisor Shadow V1 : exécute le VRAI code de BUILD_PROMPT et
|
||||
// VALIDATE_PARSE (sandbox vm) contre des réponses LLM simulées.
|
||||
// Vérifie aussi la GARANTIE MODE OMBRE : BUILD_HA_CALLS ne touche que advisor_*/logbook.
|
||||
// Intégré au push (tools/n8n-push.mjs) pour le workflow advisor.
|
||||
|
||||
// Tests de l'EVCS Advisor en RELECTEUR de la journee (08/10/2026) : execute le
|
||||
// VRAI code de BUILD_PROMPT, VALIDATE_PARSE et BUILD_HA_CALLS (sandbox vm)
|
||||
// contre des reponses LLM simulees. Verifie aussi la GARANTIE : BUILD_HA_CALLS
|
||||
// ne touche que les entites advisor et le logbook.
|
||||
// Integre au push (tools/n8n-push.mjs) pour le workflow advisor.
|
||||
import fs from 'fs';
|
||||
import vm from 'vm';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '../..');
|
||||
const src = f => fs.readFileSync(path.join(ROOT, 'src/advisor', f), 'utf8');
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const src = (f) => fs.readFileSync(path.join(ROOT, 'src/advisor', f), 'utf8');
|
||||
const BUILD_PROMPT = src('BUILD_PROMPT.js');
|
||||
const VALIDATE_PARSE = src('VALIDATE_PARSE.js');
|
||||
const BUILD_HA_CALLS = src('BUILD_HA_CALLS.js');
|
||||
|
||||
function run(code, inputJson, nodeOutputs = {}) {
|
||||
const ctx = {
|
||||
$input: { first: () => ({ json: inputJson }) },
|
||||
$: name => ({ first: () => ({ json: nodeOutputs[name] }) }),
|
||||
console, Date, Intl, Math, Number, Object, String, JSON, Array, Boolean,
|
||||
};
|
||||
// Horloge figee : 08/10/2026 07:35 a Paris.
|
||||
const FIXE = Date.parse('2026-10-08T07:35:00+02:00');
|
||||
class DateFixe extends Date { constructor(...a) { super(...(a.length ? a : [FIXE])); } static now() { return FIXE; } }
|
||||
|
||||
function run(code, inputJson, nodeOutputs = {}, D = DateFixe) {
|
||||
const ctx = { $input: { first: () => ({ json: inputJson }) }, $: (name) => ({ first: () => ({ json: nodeOutputs[name] }) }),
|
||||
console, Date: D, Intl, Math, Number, Object, String, JSON, Array, Boolean, Set, Map };
|
||||
vm.createContext(ctx);
|
||||
return vm.runInContext(`(function(){\n${code}\n})()`, ctx);
|
||||
}
|
||||
|
||||
// Snapshot simulé (soir d'été, VW branchée, SOC 80)
|
||||
const snapshot = {
|
||||
data: JSON.stringify({
|
||||
advisor_enabled: 'on', advisor_agree_count: '10', advisor_diverge_count: '2',
|
||||
advisor_prev_strategy: 'auto_solar_plus',
|
||||
soc_percent: '80', battery_current_a: '-5', pv_power_w: '500', pv_power_10m_avg_w: '600',
|
||||
house_ac_power_w: '900', grid_state: 'off', acin1_available: '0',
|
||||
evcs_vehicle_connected: 'on', evcs_charging: 'off', evcs_current_a: '0',
|
||||
evcs_surplus_state: 'idle_wait_conditions', evcs_source_mode: 'Solaire',
|
||||
user_strategy: 'auto_solar_plus', allow_grid_hc_night: 'on', allow_grid_hc_midday: 'on',
|
||||
night_target_soc_actual: '35', current_max_auto_a: '22', plafond_edf_on_a: '20',
|
||||
departure_time: '08:50:00', departure_priority_enabled: 'on', required_energy_kwh: '10.5',
|
||||
today_remaining_kwh: '1.2', tomorrow_kwh: '45',
|
||||
}),
|
||||
};
|
||||
// Constats reels du 07-08/10 (sortie de tools/revue-jour.mjs sur la base).
|
||||
const CONSTATS = [
|
||||
{ code: 'batterie_sous_objectif_midi', gravite: 'a_examiner', texte: 'Fin des heures creuses de midi (14:24) : batterie a 35 % pour 43,6 % vises, il manquait 2,6 kWh.', contexte: 'Maintien du courant' },
|
||||
{ code: 'edf_heures_pleines', gravite: 'a_examiner', texte: 'EDF ferme en heures pleines de 18:06 a 01:24 (7 h 18), 8,3 kWh achetes, batterie a 21 % a la fermeture ; ferme par Victron - Grid ON si SoC < 22%.', contexte: 'Conditions de démarrage non réunies' },
|
||||
{ code: 'voiture_bloquee_session_hc', gravite: 'a_examiner', texte: "Voiture branchee mais bloquee de 01:24 a 05:14 (3 h 50) alors qu'EDF etait ferme en heures creuses (etat night_no_grid_hc_lock).", contexte: 'Nuit sans autorisation HC réseau | Nuit EVCS: target=38.8% (BILAN)' },
|
||||
{ code: 'charge_max_midi', gravite: 'info', texte: 'info', contexte: '' },
|
||||
];
|
||||
const snap = (o = {}) => ({ data: JSON.stringify({ advisor_enabled: 'on', revue_jour_date: '2026-10-08',
|
||||
revue_jour_constats: CONSTATS, advisor_revue_date: '2026-10-07', ...o }) });
|
||||
|
||||
let failures = 0;
|
||||
function check(label, cond, detail = '') {
|
||||
if (cond) console.log(`✅ ${label}`);
|
||||
else { failures++; console.log(`❌ ${label} ${detail}`); }
|
||||
}
|
||||
const check = (label, cond, detail = '') => { if (cond) console.log(`✅ ${label}`); else { failures++; console.log(`❌ ${label} ${detail}`); } };
|
||||
|
||||
// ---------- BUILD_PROMPT ----------
|
||||
const promptOut = run(BUILD_PROMPT, snapshot)[0].json;
|
||||
check('BUILD_PROMPT: enabled=true', promptOut.enabled === true);
|
||||
check('BUILD_PROMPT: 2 messages system+user', promptOut.messages?.length === 2 && promptOut.messages[0].role === 'system');
|
||||
check('BUILD_PROMPT: contexte transporté', promptOut.context?.soc === 80 && promptOut.context?.userStrategy === 'auto_solar_plus');
|
||||
// La borne de 6000 datait du modele local d aout (contexte etroit). Le modele
|
||||
// charge aujourd hui annonce 98 304 tokens ; la vraie contrainte est le cout
|
||||
// de latence, pas le contexte. On garde une borne pour eviter la derive du
|
||||
// briefing, elargie a la mission d observation et a l historique des evenements.
|
||||
check('BUILD_PROMPT: prompt < 9000 chars (garde-fou contre la derive)', (promptOut.messages[0].content.length + promptOut.messages[1].content.length) < 9000);
|
||||
const p = run(BUILD_PROMPT, snap())[0].json;
|
||||
check('PROMPT: actif quand la revue du jour a des points et pas encore de reponse', p.enabled === true);
|
||||
check('PROMPT: seuls les constats a examiner sont transmis (pas les infos)', p.context.constats.length === 3 && !p.context.constats.some((c) => c.code === 'charge_max_midi'));
|
||||
check('PROMPT: chaque constat et son contexte sont dans le message', CONSTATS.slice(0, 3).every((c) => p.messages[1].content.includes(c.texte)));
|
||||
check('PROMPT: < 6000 caracteres', p.messages[0].content.length + p.messages[1].content.length < 6000);
|
||||
check('PROMPT: kill-switch OFF -> inactif', run(BUILD_PROMPT, snap({ advisor_enabled: 'off' }))[0].json.enabled === false);
|
||||
check('PROMPT: deja repondu aujourd hui -> inactif', run(BUILD_PROMPT, snap({ advisor_revue_date: '2026-10-08' }))[0].json.enabled === false);
|
||||
check('PROMPT: revue d hier -> inactif', run(BUILD_PROMPT, snap({ revue_jour_date: '2026-10-07' }))[0].json.enabled === false);
|
||||
check('PROMPT: rien a examiner -> inactif (pas d appel au modele)', run(BUILD_PROMPT, snap({ revue_jour_constats: [CONSTATS[3]] }))[0].json.enabled === false);
|
||||
const D11 = class extends Date { constructor(...a) { super(...(a.length ? a : [Date.parse('2026-10-08T11:00:00+02:00')])); } };
|
||||
check('PROMPT: pas de relance apres 10 h', run(BUILD_PROMPT, snap(), {}, D11)[0].json.enabled === false);
|
||||
|
||||
const killSwitchOut = run(BUILD_PROMPT, { data: JSON.stringify({ ...JSON.parse(snapshot.data), advisor_enabled: 'off' }) })[0].json;
|
||||
check('BUILD_PROMPT: kill-switch OFF → enabled=false', killSwitchOut.enabled === false);
|
||||
// ---------- VALIDATE_PARSE ----------
|
||||
const ctxNode = { BUILD_PROMPT: p };
|
||||
const rep = (obj) => ({ model: 'qwen3-coder', choices: [{ message: { content: typeof obj === 'string' ? obj : JSON.stringify(obj) } }] });
|
||||
const v = (obj) => run(VALIDATE_PARSE, rep(obj), ctxNode)[0].json;
|
||||
|
||||
// ---------- VALIDATE_PARSE : 7 réponses LLM simulées ----------
|
||||
const ctxNode = { BUILD_PROMPT: promptOut };
|
||||
const validAdvice = '{"constat":"Contacteur reseau stable, batterie en charge.","gravite":"aucune","ce_qui_cloche":"","piste":"","confidence":0.85}';
|
||||
const bon = { pistes: [
|
||||
{ code: 'voiture_bloquee_session_hc', piste: 'Le mode « sans EDF » bloquait la voiture alors que la session HC etait engagee pour la batterie : verifier la regle qui garde la voiture bloquee.' },
|
||||
{ code: 'edf_heures_pleines', piste: "L'automatisation « Grid ON si SoC < 22% » a ferme EDF a 21 % : la recharge de midi n'a pas suffi." },
|
||||
] };
|
||||
const o1 = v(bon);
|
||||
check('VALIDATE: reponse valide -> 2 pistes', o1.status === 'ok' && o1.pistes.length === 2 && o1.rejetees === 0, JSON.stringify(o1));
|
||||
check('VALIDATE: <think> et fences tolerees', v('<think>...</think>\n```json\n' + JSON.stringify(bon) + '\n```').pistes.length === 2);
|
||||
const o2 = v({ pistes: [{ code: 'edf_heures_pleines', piste: 'Il aurait fallu 60 % a 14:24 pour eviter les 8,3 kWh.' }] });
|
||||
check('VALIDATE: ANTI-INVENTION — chiffre absent du constat (60) -> piste ecartee', o2.pistes.length === 0 && o2.rejetees === 1, JSON.stringify(o2));
|
||||
const o3 = v({ pistes: [{ code: 'edf_heures_pleines', piste: 'Fermeture a 21 %, 8,3 kWh : revoir la recharge de midi.' }] });
|
||||
check('VALIDATE: chiffres du constat (21, 8,3) acceptes', o3.pistes.length === 1, JSON.stringify(o3));
|
||||
check('VALIDATE: code inconnu -> ecarte', v({ pistes: [{ code: 'invente', piste: 'x' }] }).rejetees === 1);
|
||||
check('VALIDATE: un constat info n est pas relu', v({ pistes: [{ code: 'charge_max_midi', piste: 'x' }] }).pistes.length === 0);
|
||||
check('VALIDATE: doublon de code -> un seul garde', v({ pistes: [bon.pistes[0], bon.pistes[0]] }).pistes.length === 1);
|
||||
check('VALIDATE: texte sans JSON -> invalid', v('Je pense que tout va bien.').status === 'invalid');
|
||||
check('VALIDATE: pistes absent -> invalid', v({ constat: 'x' }).status === 'invalid');
|
||||
check('VALIDATE: LLM hors ligne -> llm_offline', run(VALIDATE_PARSE, { error: 'ECONNREFUSED' }, ctxNode)[0].json.status === 'llm_offline');
|
||||
|
||||
const cases = [
|
||||
{
|
||||
label: 'réponse valide → ok',
|
||||
resp: { model: 'qwen3.6-35b', choices: [{ message: { content: validAdvice } }] },
|
||||
expect: o => o.status === 'ok' && o.advice.gravite === 'aucune' && o.advice.constat.includes('stable'),
|
||||
},
|
||||
{
|
||||
label: 'balises <think> + fences markdown → ok',
|
||||
resp: { model: 'qwen3-vl', choices: [{ message: { content: `<think>je réfléchis...\nblabla</think>\n\`\`\`json\n${validAdvice}\n\`\`\`` } }] },
|
||||
expect: o => o.status === 'ok' && o.advice.confidence === 0.85,
|
||||
},
|
||||
{
|
||||
label: 'confidence hors bornes → invalid',
|
||||
resp: { model: 'x', choices: [{ message: { content: validAdvice.replace('"confidence":0.85', '"confidence":7') } }] },
|
||||
expect: o => o.status === 'invalid' && o.advice === null,
|
||||
},
|
||||
{
|
||||
label: 'texte sans JSON → invalid',
|
||||
resp: { model: 'x', choices: [{ message: { content: 'Je recommande le mode solaire.' } }] },
|
||||
expect: o => o.status === 'invalid',
|
||||
},
|
||||
{
|
||||
label: 'les deux LLM en échec → llm_offline',
|
||||
resp: { error: 'connect ECONNREFUSED 10.10.10.8:8000' },
|
||||
expect: o => o.status === 'llm_offline' && o.advice === null,
|
||||
},
|
||||
];
|
||||
// ---------- BUILD_HA_CALLS : garantie de perimetre ----------
|
||||
const calls = run(BUILD_HA_CALLS, o1, ctxNode).map((i) => i.json);
|
||||
const permis = (c) => c.domain === 'logbook' || c.state_entity === 'sensor.evcs_advisor_revue'
|
||||
|| /^input_text\.evcs_advisor_/.test(c.data?.entity_id || '');
|
||||
check('PERIMETRE: aucune ecriture hors entites advisor / logbook', calls.every(permis), JSON.stringify(calls.filter((c) => !permis(c))));
|
||||
const capteur = calls.find((c) => c.state_entity === 'sensor.evcs_advisor_revue');
|
||||
check('PERIMETRE: capteur de la revue publie, date du jour, pistes en attribut', !!capteur && capteur.data.state === '2026-10-08' && capteur.data.attributes.pistes.length === 2);
|
||||
const hl = run(BUILD_HA_CALLS, run(VALIDATE_PARSE, { error: 'x' }, ctxNode)[0].json, ctxNode).map((i) => i.json);
|
||||
check('PERIMETRE: hors ligne -> statut ecrit, aucun capteur (relance au cycle suivant)', !hl.some((c) => c.state_entity) && hl.some((c) => String(c.data?.value || '').startsWith('llm_offline')));
|
||||
|
||||
for (const c of cases) {
|
||||
const out = run(VALIDATE_PARSE, c.resp, ctxNode)[0].json;
|
||||
check(`VALIDATE: ${c.label}`, c.expect(out), `(obtenu: ${JSON.stringify(out).slice(0, 120)})`);
|
||||
}
|
||||
|
||||
// ---------- BUILD_HA_CALLS : garantie mode ombre ----------
|
||||
const okValidated = run(VALIDATE_PARSE, cases[0].resp, ctxNode)[0].json;
|
||||
const calls = run(BUILD_HA_CALLS, okValidated, ctxNode).map(i => i.json);
|
||||
const ALLOWED = /^(input_text|input_number)\.evcs_advisor_/;
|
||||
const violations = calls.filter(c => !(c.domain === 'logbook' || ALLOWED.test(c.data.entity_id || '')));
|
||||
check('OMBRE: aucune écriture hors advisor_*/logbook', violations.length === 0, JSON.stringify(violations));
|
||||
check('OMBRE: écrit constat + reason', calls.some(c => (c.data.entity_id || '').includes('advisor_constat')) && calls.some(c => (c.data.entity_id || '').includes('advisor_reason')));
|
||||
|
||||
// ── LE COMPTEUR MESURE LE VOLUME DE SIGNALEMENTS (12/09/2026) ─────────
|
||||
// Un observateur qui signale en permanence ne sert a rien. On ne peut pas
|
||||
// mesurer automatiquement s il a RAISON ; on peut mesurer s il crie au loup.
|
||||
function compteur(json) {
|
||||
const v = run(VALIDATE_PARSE, { model: 'x', choices: [{ message: { content: json } }] }, ctxNode)[0].json;
|
||||
const out = run(BUILD_HA_CALLS, v, ctxNode).map(i => i.json);
|
||||
const id = k => out.some(c => (c.data.entity_id || '').includes(k));
|
||||
return { calme: id('agree_count'), signale: id('diverge_count') };
|
||||
}
|
||||
const rienASignaler = compteur(validAdvice);
|
||||
check('COMPTEUR: gravite aucune → compte comme calme', rienASignaler.calme && !rienASignaler.signale);
|
||||
const signalement = compteur(validAdvice.replace('"gravite":"aucune"', '"gravite":"haute"'));
|
||||
check('COMPTEUR: gravite haute → compte comme signalement', signalement.signale && !signalement.calme);
|
||||
|
||||
// ── OBSERVATION : le constat et la gravite sont obligatoires ───────────
|
||||
function valider(json) {
|
||||
return run(VALIDATE_PARSE, { model: 'x', choices: [{ message: { content: json } }] }, ctxNode)[0].json;
|
||||
}
|
||||
const osc = valider(validAdvice
|
||||
.replace('"constat":"Contacteur reseau stable, batterie en charge."', '"constat":"Le contacteur reseau a bascule 4 fois en 10 minutes."')
|
||||
.replace('"gravite":"aucune"', '"gravite":"haute"')
|
||||
.replace('"ce_qui_cloche":""', '"ce_qui_cloche":"deux ecrivains avec des seuils differents sur le meme actionneur"'));
|
||||
check('OBSERVATION: constat + gravite haute acceptes',
|
||||
osc.status === 'ok' && osc.advice.gravite === 'haute' && osc.advice.constat.includes('bascule'));
|
||||
|
||||
check('OBSERVATION: constat vide → REJET',
|
||||
valider(validAdvice.replace('"constat":"Contacteur reseau stable, batterie en charge."', '"constat":""')).status === 'invalid');
|
||||
check('OBSERVATION: gravite hors enum → REJET',
|
||||
valider(validAdvice.replace('"gravite":"aucune"', '"gravite":"catastrophique"')).status === 'invalid');
|
||||
|
||||
const oscCalls = run(BUILD_HA_CALLS, osc, ctxNode).map(i => i.json);
|
||||
check('OBSERVATION: constat, gravite et diagnostic publies',
|
||||
['constat', 'gravite', 'ce_qui_cloche'].every(k => oscCalls.some(c => (c.data.entity_id || '').includes(`advisor_${k}`))));
|
||||
check('OMBRE: l observation ne sort pas du perimetre advisor_*',
|
||||
oscCalls.every(c => c.domain === 'logbook' || /^(input_text|input_number)\.evcs_advisor_/.test(c.data.entity_id || '')));
|
||||
|
||||
const casHorsLigne = cases.find(c => c.label.includes('llm_offline'));
|
||||
const offlineValidated = run(VALIDATE_PARSE, casHorsLigne.resp, ctxNode)[0].json;
|
||||
const offlineCalls = run(BUILD_HA_CALLS, offlineValidated, ctxNode).map(i => i.json);
|
||||
check('OMBRE: llm_offline → statut écrit, pas de recommandation', offlineCalls.every(c => !(c.data.entity_id || '').includes('advisor_constat')) && offlineCalls.some(c => (c.data.value || '').toString().startsWith('llm_offline')));
|
||||
// ---------- Execute node : routage states / services ----------
|
||||
const wf = JSON.parse(fs.readFileSync(path.join(ROOT, 'workflows/evcs-advisor-shadow-v1.json'), 'utf8'));
|
||||
const url = wf.nodes.find((n) => n.name === 'Execute HA Service Advisor').parameters.url;
|
||||
check('WORKFLOW: un item state_entity part vers /api/states', url.includes("$json.state_entity ? 'http://10.10.10.17:8123/api/states/'"));
|
||||
|
||||
console.log(failures ? `\n${failures} ÉCHEC(S) — NE PAS POUSSER` : '\nADVISOR OK — tous les tests passent');
|
||||
process.exit(failures ? 1 : 0);
|
||||
|
||||
@@ -107,6 +107,7 @@ const SUMMARIZE = {
|
||||
hcFloorPct: o.hcReach ? o.hcReach.floorPct : null,
|
||||
hcFloorReachPct: o.hcReach ? o.hcReach.floorReachPct : null,
|
||||
hcMaxCharge: o.hcReach ? o.hcReach.maxCharge : null,
|
||||
nightEveningActive: o.hcReach && o.hcReach.nightEvening ? o.hcReach.nightEvening.active : null,
|
||||
hcNightFloorPrudentPct: o.hcReach ? o.hcReach.nightFloorPrudentPct : null,
|
||||
// Nuit prevue par la soiree (28/09).
|
||||
nightModelActive: o.nightModel ? o.nightModel.active : false,
|
||||
|
||||
@@ -417,8 +417,12 @@ export const balanceScenarios = [
|
||||
id: 'bilan-hc-nuit-2709-prevision-forecast-solar',
|
||||
label: 'Nuit 26-27/09, prevision 25,1 kWh : P12 plancher 48,4 % (nuit suivante), reseau vise midi 25,3 %',
|
||||
snapshot: snap2709('2026-09-27T01:20:00+02:00', 72, NUIT_2709),
|
||||
// 08/10 : la nuit prepare aussi le soir. Profil habituel : batterie prevue a
|
||||
// 59,6 % a 19 h pour 64,7 % requis -> +5,1 points (courbe plate, pas
|
||||
// d'ecretage) : 25,3 + 5,0 = 30,3 % en fin de nuit. Le 27/09 reel (22 kWh)
|
||||
// a vide la batterie a 01:00 : le sens est le bon.
|
||||
expect: { usable: true, requiredSocPct: 40.2, prudentRequiredSocPct: 48.4, gridNeeded: false,
|
||||
hcReachActive: true, hcFloorPct: 25.3, hcNightFloorPrudentPct: 48.4 },
|
||||
hcReachActive: true, nightEveningActive: true, hcFloorPct: 30.3, hcNightFloorPrudentPct: 48.4 },
|
||||
},
|
||||
{
|
||||
// Meme nuit, journee annoncee sombre (18 kWh, prevision VRM du 27) : le
|
||||
@@ -428,7 +432,7 @@ export const balanceScenarios = [
|
||||
id: 'bilan-hc-nuit-2709-journee-sombre-bloque-p12',
|
||||
label: 'Nuit 26-27/09, journee sombre (18 kWh) : P12 plancher 72,1 % > SOC 72 → voiture sur le reseau HC',
|
||||
snapshot: snap2709('2026-09-27T01:20:00+02:00', 72, NUIT_2709, SOMBRE),
|
||||
expect: { usable: true, requiredSocPct: 41.4, prudentRequiredSocPct: 72.1, gridNeeded: false,
|
||||
expect: { usable: true, requiredSocPct: 54, prudentRequiredSocPct: 72.1, gridNeeded: false,
|
||||
hcReachActive: true, hcNightFloorPrudentPct: 72.1 },
|
||||
},
|
||||
{
|
||||
@@ -598,7 +602,16 @@ export const balanceScenarios = [
|
||||
label: 'Fenetre HC de NUIT, decision jour faible du jour : la nuit garde « tenir jusqu a midi »',
|
||||
snapshot: Object.assign(snap2709('2026-09-27T01:20:00+02:00', 72, NUIT_2709),
|
||||
{ ecs_nuit_jour: '2026-09-27|on|x' }),
|
||||
expect: { usable: true, hcReachActive: true, hcMaxCharge: false, hcFloorPct: 25.3 },
|
||||
// Pas de charge maximale la nuit ; la nuit prepare le soir (30,3 %, voir le
|
||||
// scenario du 27/09 ci-dessus).
|
||||
expect: { usable: true, hcReachActive: true, hcMaxCharge: false, nightEveningActive: true, hcFloorPct: 30.3 },
|
||||
},
|
||||
{
|
||||
node: 'BALANCE',
|
||||
id: 'bilan-hc-nuit-journee-ensoleillee-rien-ne-change',
|
||||
label: 'Nuit, journee annoncee forte (prevision x2) : le soir est atteint au solaire, la nuit garde « tenir jusqu a midi »',
|
||||
snapshot: snap2709('2026-09-27T01:20:00+02:00', 72, NUIT_2709, 2),
|
||||
expect: { usable: true, hcReachActive: true, nightEveningActive: false },
|
||||
},
|
||||
{
|
||||
node: 'BALANCE',
|
||||
|
||||
@@ -3232,18 +3232,18 @@ export const scenarios = [
|
||||
time: '2026-09-30T04:00:00+02:00',
|
||||
overrides: { ...BUDGET_NUIT, balance_last_run: '30/09 03:55', soc_percent: '20', night_charge_mode: 'sans_edf',
|
||||
night_budget_prev: '3.7|4000', evcs_total_energy_kwh: '4003.75' },
|
||||
// 04/10 (option A du proprietaire) : EDF se ferme pour la BATTERIE, la
|
||||
// voiture reste bloquee « sans EDF » (avant : grid_forced, voiture sur EDF).
|
||||
expect: { sourceMode: 'blocked', desiredCharging: false, edfOnAction: true, takeoverNeeded: true },
|
||||
// 08/10 (proprietaire) : EDF se ferme pour la BATTERIE en heures creuses, la
|
||||
// voiture en profite meme « sans EDF » (option A du 04/10 abandonnee).
|
||||
expect: { sourceMode: 'grid_forced', edfOnAction: true, takeoverNeeded: true },
|
||||
},
|
||||
{
|
||||
id: 'budget-sans-edf-reprise-reseau-voiture-bloquee',
|
||||
label: 'Nuit du 03-04/10 rejouee : budget 1,41 atteint, SOC 39 sous la cible 39,5 a 02:57 : EDF ferme pour la batterie, la JEEP ne prend PAS le reseau',
|
||||
label: 'Nuit du 03-04/10 rejouee : budget 1,41 atteint, SOC 39 sous la cible 39,5 a 02:57 : EDF ferme pour la batterie, la JEEP en profite (08/10)',
|
||||
time: '2026-10-04T02:57:00+02:00',
|
||||
overrides: { ...BUDGET_NUIT, balance_last_run: '04/10 02:55', soc_percent: '39',
|
||||
balance_required_soc: '39.5', balance_required_soc_prudent: '40.4',
|
||||
night_charge_mode: 'sans_edf', night_budget_prev: '1.41|4038.8', evcs_total_energy_kwh: '4040.2' },
|
||||
expect: { sourceMode: 'blocked', desiredCharging: false, edfOnAction: true, takeoverNeeded: true },
|
||||
expect: { sourceMode: 'grid_forced', edfOnAction: true, takeoverNeeded: true },
|
||||
},
|
||||
{
|
||||
id: 'budget-bilan-perime-sans-edf-rien',
|
||||
@@ -3291,8 +3291,8 @@ export const scenarios = [
|
||||
balance_required_soc: '28.6', balance_required_soc_prudent: '29.7',
|
||||
evcs_vehicle: 'JEEP', departure_time_jeep: '06:30:00', departure_day_ok: 'on',
|
||||
night_charge_mode: 'sans_edf', night_budget_prev: '5.93|4010', evcs_total_energy_kwh: '4015' },
|
||||
// « sans EDF » : l'urgence ferme EDF pour la batterie, la voiture reste bloquee (04/10).
|
||||
expect: { sourceMode: 'blocked', desiredCharging: false, edfOnAction: true },
|
||||
// « sans EDF » : l'urgence ferme EDF pour la batterie, la voiture en profite (08/10).
|
||||
expect: { sourceMode: 'grid_forced', edfOnAction: true },
|
||||
},
|
||||
{
|
||||
id: 'depart-matin-pas-un-jour-de-depart',
|
||||
|
||||
82
tests/revue-jour/run.mjs
Normal file
82
tests/revue-jour/run.mjs
Normal file
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env node
|
||||
// Tests de la revue de la journee (tools/revue-jour.mjs, 08/10/2026).
|
||||
// Donnees synthetiques calquees sur la journee du 07 au 08/10.
|
||||
import { constatsRevue } from '../../tools/revue-jour.mjs';
|
||||
|
||||
const T = (s) => Date.parse(`2026-10-${s}+02:00`);
|
||||
const F = [{ nom: 'nuit', debut: '01:24', fin: '07:24' }, { nom: 'midi', debut: '12:24', fin: '14:24' }];
|
||||
const base = () => ({
|
||||
debut: T('07T07:25:00'), fin: T('08T07:25:00'), fenetres: F,
|
||||
contacteur: [{ ts: T('07T07:25:00'), v: 'off' }],
|
||||
edfKwh: [{ ts: T('07T07:00:00'), v: 100 }],
|
||||
reseauW: [{ ts: T('07T07:25:00'), v: 0 }],
|
||||
decisions: [{ ts: T('07T07:25:00'), profil: 'outside_window', source_mode: 'Solaire', etat: 'idle', session_engagee: false, cible_matin_pct: 30, raison: 'r' }],
|
||||
soc: [{ ts: T('07T07:25:00'), v: 40 }],
|
||||
requis: [{ ts: T('07T07:25:00'), v: 30 }],
|
||||
});
|
||||
|
||||
let echecs = 0;
|
||||
const ok = (id, cond, info = '') => { if (cond) console.log(`✅ ${id}`); else { echecs++; console.log(`❌ ${id} ${info}`); } };
|
||||
const codes = (r) => r.constats.map((c) => c.code).join(',');
|
||||
|
||||
{ const r = constatsRevue(base());
|
||||
ok('rien-a-signaler', r.constats.length === 0 && /Rien a signaler/.test(r.texte), r.texte); }
|
||||
|
||||
{ // 07/10 18:06 -> 01:24, 8,3 kWh, ferme par l'automatisation SoC < 22 %
|
||||
const d = base();
|
||||
d.contacteur.push({ ts: T('07T18:04:27'), v: 'on', auteur: 'Victron - Grid ON si SoC < 22%' });
|
||||
d.reseauW.push({ ts: T('07T18:06:00'), v: 1800 }, { ts: T('08T07:24:30'), v: 0 });
|
||||
d.edfKwh.push({ ts: T('07T18:05:00'), v: 100 }, { ts: T('08T01:24:00'), v: 108.3 }, { ts: T('08T07:24:00'), v: 113 });
|
||||
d.soc.push({ ts: T('07T18:00:00'), v: 21 });
|
||||
const r = constatsRevue(d); const c = r.constats.find((x) => x.code === 'edf_heures_pleines');
|
||||
ok('hp-releve-0710', !!c && c.chiffres.minutes === 438 && c.chiffres.kwh === 8.3 && /18:06 a 01:24/.test(c.texte)
|
||||
&& /Grid ON si SoC < 22%/.test(c.texte) && r.constats.filter((x) => x.code === 'edf_heures_pleines').length === 1, JSON.stringify(r.constats)); }
|
||||
|
||||
{ const d = base(); // reseau 4 min en heures pleines : ignore
|
||||
d.reseauW.push({ ts: T('07T19:00:00'), v: 900 }, { ts: T('07T19:04:00'), v: 0 });
|
||||
ok('hp-breve-ignoree', constatsRevue(d).constats.length === 0); }
|
||||
|
||||
{ const d = base(); // reseau en heures creuses seulement : rien
|
||||
d.reseauW.push({ ts: T('08T01:24:00'), v: 2000 }, { ts: T('08T07:24:00'), v: 0 });
|
||||
ok('hc-pas-un-constat', constatsRevue(d).constats.length === 0, codes(constatsRevue(d))); }
|
||||
|
||||
{ const d = base(); // ecart de 3 min dans une fermeture : une seule plage
|
||||
d.reseauW.push({ ts: T('07T19:00:00'), v: 900 }, { ts: T('07T19:30:00'), v: 0 }, { ts: T('07T19:33:00'), v: 900 }, { ts: T('07T20:00:00'), v: 0 });
|
||||
const r = constatsRevue(d); ok('hp-ecart-bref-fusionne', r.constats.length === 1 && r.constats[0].chiffres.minutes === 60, JSON.stringify(r.constats.map((x) => x.chiffres))); }
|
||||
|
||||
const sessionNuit = (d, etat, source = 'Bloqué') => {
|
||||
d.contacteur.push({ ts: T('08T01:23:50'), v: 'on' });
|
||||
d.decisions.push({ ts: T('08T01:24:00'), profil: 'night_hc', source_mode: source, etat, session_engagee: true, cible_matin_pct: 30.7, raison: 'Nuit sans autorisation HC réseau' },
|
||||
{ ts: T('08T05:14:00'), profil: 'night_hc', source_mode: 'Réseau forcé', etat: 'charging_hold_grid', session_engagee: true, cible_matin_pct: 30.7, raison: 'x' });
|
||||
d.soc.push({ ts: T('08T07:00:00'), v: 30 });
|
||||
};
|
||||
{ const d = base(); sessionNuit(d, 'night_no_grid_hc_lock');
|
||||
const r = constatsRevue(d); const c = r.constats.find((x) => x.code === 'voiture_bloquee_session_hc');
|
||||
ok('voiture-bloquee-0810', !!c && c.chiffres.minutes === 230 && /01:24 a 05:14/.test(c.texte), JSON.stringify(r.constats)); }
|
||||
{ const d = base(); sessionNuit(d, 'night_recovery_done_idle');
|
||||
ok('voiture-pleine-pas-un-constat', !codes(constatsRevue(d)).includes('voiture_bloquee')); }
|
||||
{ const d = base(); sessionNuit(d, 'vehicle_not_connected');
|
||||
ok('pas-de-voiture-pas-un-constat', !codes(constatsRevue(d)).includes('voiture_bloquee')); }
|
||||
|
||||
{ const d = base(); sessionNuit(d, 'night_no_grid_hc_lock');
|
||||
d.decisions[d.decisions.length - 1].cible_matin_pct = 40; // objectif 40, SOC 30 a 07:24
|
||||
const c = constatsRevue(d).constats.find((x) => x.code === 'batterie_sous_objectif_nuit');
|
||||
ok('nuit-sous-objectif', !!c && c.chiffres.manqueKwh === 3 && /il manquait 3 kWh/.test(c.texte), JSON.stringify(c)); }
|
||||
{ const d = base(); sessionNuit(d, 'night_no_grid_hc_lock'); // objectif 30,7, SOC 30 : moins de 2 points
|
||||
ok('nuit-objectif-tenu', !codes(constatsRevue(d)).includes('batterie_sous_objectif')); }
|
||||
|
||||
const sessionMidi = (d, requis) => {
|
||||
d.decisions.push({ ts: T('07T12:24:00'), profil: 'midday_hc_or_solar', source_mode: 'Réseau HC autorisé', etat: 'x', session_engagee: true, cible_matin_pct: 30, raison: 'm' },
|
||||
{ ts: T('07T14:25:00'), profil: 'outside_window', source_mode: 'Solaire', etat: 'idle', session_engagee: false, cible_matin_pct: 30, raison: 'r' });
|
||||
d.soc.push({ ts: T('07T12:20:00'), v: 31 }, { ts: T('07T14:20:00'), v: 35 });
|
||||
d.requis.push({ ts: T('07T12:25:00'), v: requis });
|
||||
};
|
||||
{ const d = base(); sessionMidi(d, 43.6);
|
||||
const c = constatsRevue(d).constats.find((x) => x.code === 'batterie_sous_objectif_midi');
|
||||
ok('midi-sous-objectif-0710', !!c && /35 % pour 43,6 % vises, il manquait 2,6 kWh/.test(c.texte), JSON.stringify(c)); }
|
||||
{ const d = base(); sessionMidi(d, 100);
|
||||
const r = constatsRevue(d); const c = r.constats.find((x) => x.code === 'charge_max_midi');
|
||||
ok('midi-charge-max-info', !!c && c.gravite === 'info' && /31 % -> 35 % \(\+1,2 kWh\)/.test(c.texte) && !codes(r).includes('sous_objectif'), JSON.stringify(r.constats)); }
|
||||
|
||||
console.log(echecs ? `\n${echecs} ECHEC(S)` : '\nREVUE OK');
|
||||
process.exit(echecs ? 1 : 0);
|
||||
Reference in New Issue
Block a user