diff --git a/HANDOFF.md b/HANDOFF.md index 961ff7a..8216d3b 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4891,6 +4891,63 @@ jugement conditionne a la POINTE et non a l'energie**, parce qu'une pointe a 2 819 W pendant deux minutes n'a rien d'anodin meme si le total reste faible — et les incidents techniques. +### Suite de tests pour l'Auto Charge Manager (0 -> 20 scenarios) +Troisieme workflow structurant, et le seul sans filet : il decide ce que la +BATTERIE a le droit de prendre (limites DVCC en courant et en tension), donc +c'est lui qui plafonnait le parc a 93 % — et il n'etait meme pas suivi dans le +depot avant ce matin. + +`tests/golden-acm/`, sur le patron de `tests/golden-hysteresis/` : le VRAI code +du node « Compute Charge Targets » execute dans une sandbox `vm` avec horloge +epinglee, sur un **snapshot reel** obtenu en rendant le gabarit du node +« HA Snapshot via Template » contre Home Assistant (20 champs). Pas de fixture +invente : c'est la lecon du 24/08, ou le fixture de l'hysteresis portait les +valeurs de REPLI du code (22/35) au lieu des seuils de production (20/30) et +masquait un bug de bande. + +Les vingt scenarios suivent l'ordre reel des branches — automatisation +desactivee, secours SOC < 20 hors HC, takeover EVCS, hiver+HC, ete+HC, HC midi, +fenetre jour, hors fenetre, puis l'override qui se superpose a tout : + +- gardes : automatisation OFF (reglages conserves), secours a SOC 18 ; +- takeover : recharge vers la cible, **maintien a 2 A (le releve reel du 06/09 + a 05:06)**, bornage d'une cible aberrante a 95 %, et le contrefactuel + « takeover demande mais contacteur OUVERT » qui ne doit pas mordre ; +- nuit : paire hiver sombre / hiver ensoleille (cibles 80 et 40 selon la bande + de prevision), paire ete secours / ete repos autour du seuil de 22 % ; +- midi : rattrapage reseau, et son contrefactuel a PV 2 000 W ou la garde + `pvPowerW < 1500` doit couper le rattrapage ; +- jour : boost, mid, **topoff a 52,4 V (le correctif du jour)**, et le hold + au-dessus de 95 % qui redescend a 50,6 V — on atteint le haut, on n'y sejourne + pas ; +- override actif contre override expire ; +- garde de publication : cible identique a l'existant, on n'ecrit rien. + +Preuve par mutation, cinq directions, **chacune faisant tomber exactement UN +scenario, different a chaque fois** : tension de topoff ramenee a 51,3 · bornage +de la cible de takeover retire · secours SOC critique supprime · garde PV du +rattrapage midi relachee · echeance de l'override ignoree. Restauration -> 20/20. + +Cablee dans `tools/n8n-push.mjs` (`isAcmWorkflow`), bloquante au push comme les +trois autres suites. Verifie : le push de l'ACM execute bien les 20 scenarios +avant le PUT. + +### Suivre la conversation depuis le telephone +`claude --remote-control [nom]` est un drapeau de DEMARRAGE : une session deja +lancee ne peut pas etre rattachee apres coup. Il existe aussi +`claude --teleport [session]`. `ListAgents` confirme qu'une session Remote +Control existe deja sur le compte (« Configurer alertes electriques pour demo », +hors ligne) — le mecanisme est donc en place cote compte. + +Pour une session pilotable depuis le telephone, il faut donc la demarrer ainsi +sur le poste, **dans tmux** pour qu'elle survive a la fermeture du terminal : +`tmux new -s evcs 'claude --remote-control "EVCS"'`. + +⚠️ La session courante n'a PAS ete demarree avec ce drapeau : elle ne sera pas +joignable depuis le telephone. Et une session Claude, meme pilotable a distance, +reste un poste de travail — ce n'est pas elle qui doit porter la surveillance de +nuit, d'ou le rapport par courriel loge dans le service d'historisation. + ### Reste ouvert - Detecteur de gel dans le service d'historisation : une grandeur qui ne bouge pas alors qu'une autre du meme bilan bouge = alerte. Non fait. diff --git a/docs/mesures/batteries/202609052040-suivi.csv b/docs/mesures/batteries/202609052040-suivi.csv index 5ffdb84..f1a95ec 100644 --- a/docs/mesures/batteries/202609052040-suivi.csv +++ b/docs/mesures/batteries/202609052040-suivi.csv @@ -440,3 +440,11 @@ heure_reelle,etat,courant_mA,c1_mV,c2_mV,c3_mV,c4_mV,c5_mV,c6_mV,c7_mV,c8_mV,c9_ 2026-09-06T13:11:09.944Z,Y230202C30371425,,3328,3327,3327,3328,3328,3327,3327,3326,3327,3328,3328,3327,3326,3327,3327,2 2026-09-06T13:11:10.099Z,Y230202C30371431,,3323,3323,3324,3324,3325,3323,3323,3324,3324,3322,3324,3323,3324,3324,3325,3 2026-09-06T13:11:10.225Z,Y230202C30371427,,3328,3327,3327,3327,3326,3328,3326,3326,3327,3328,3328,3327,3328,3328,3326,2 +2026-09-06T13:21:10.328Z,Y230202C30371426,,3320,3321,3321,3322,3322,3320,3321,3322,3319,3322,3321,3321,3322,3322,3322,3 +2026-09-06T13:21:10.453Z,Y230202C30371425,,3363,3365,3363,3368,3364,3364,3365,3362,3366,3365,3364,3365,3358,3365,3363,10 +2026-09-06T13:21:10.548Z,Y230202C30371431,,3366,3367,3367,3367,3366,3366,3366,3367,3367,3354,3366,3366,3367,3366,3366,13 +2026-09-06T13:21:10.674Z,Y230202C30371427,,3368,3365,3369,3368,3361,3369,3366,3367,3370,3370,3367,3368,3371,3371,3364,10 +2026-09-06T13:31:10.763Z,Y230202C30371426,,3365,3367,3367,3367,3366,3366,3367,3367,3355,3366,3366,3367,3367,3367,3365,12 +2026-09-06T13:31:10.870Z,Y230202C30371425,,3312,3312,3312,3312,3311,3311,3312,3311,3312,3312,3312,3312,3311,3312,3311,1 +2026-09-06T13:31:10.997Z,Y230202C30371431,,3312,3312,3312,3312,3312,3312,3312,3312,3312,3310,3312,3312,3312,3312,3312,2 +2026-09-06T13:31:11.122Z,Y230202C30371427,,3301,3302,3302,3302,3300,3300,3302,3302,3302,3301,3301,3303,3303,3303,3300,3 diff --git a/tests/golden-acm/fixture-snapshot.json b/tests/golden-acm/fixture-snapshot.json new file mode 100644 index 0000000..b49a229 --- /dev/null +++ b/tests/golden-acm/fixture-snapshot.json @@ -0,0 +1,22 @@ +{ + "auto_enabled": "on", + "battery_current_a": "-3.5999999046325684", + "battery_voltage_v": "50.4900016784668", + "current_max_charge_current_a": "30", + "current_max_charge_voltage_v": "50.6", + "edf_switch": "off", + "last_signature": "2026-09-06|day_pv_hold|pv_hold|i=30|v=50.6", + "night_takeover": "off", + "night_target_soc": "35.0", + "notifications_enabled": "on", + "override_current_a": "113.0", + "override_enabled": "off", + "override_profile": "manual", + "override_reason": "test override", + "override_until": "2026-03-16 01:00:00", + "override_voltage_v": "51.3", + "pv_power_w": "4740.237852870943", + "soc_percent": "97.0", + "today_remaining_kwh": "22.27", + "tomorrow_kwh": "29.909" +} diff --git a/tests/golden-acm/run.mjs b/tests/golden-acm/run.mjs new file mode 100644 index 0000000..ee4ad0e --- /dev/null +++ b/tests/golden-acm/run.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node +// Suite de tests GOLDEN du workflow "Victron - Auto Charge Manager V1.2". +// Execute le VRAI code du node "Compute Charge Targets" sur un snapshot HA reel +// modifie par scenario, avec horloge simulee. +// +// Ce workflow decide ce que la batterie a le droit de PRENDRE (limites DVCC en +// courant et en tension). Il gouvernait donc le plafond a 93 % de SOC corrige +// le 06/09, et il n'avait aucun test : il n'etait meme pas suivi dans le depot. +// +// Usage : +// node tests/golden-acm/run.mjs # assertions (exit 1 si echec) +// node tests/golden-acm/run.mjs --print # affiche les resumes obtenus +// node tests/golden-acm/run.mjs --only=topoff +// +// REGLE : cette suite DOIT passer avant tout push du workflow +// (integree a tools/n8n-push.mjs). + +import fs from 'fs'; +import vm from 'vm'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { scenarios } from './scenarios.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '../..'); +const WORKFLOW_FILE = path.join(ROOT, 'workflows/victron-auto-charge-manager-v1.2.json'); + +const PRINT = process.argv.includes('--print'); +const only = (process.argv.find((a) => a.startsWith('--only=')) || '').slice(7); + +const wf = JSON.parse(fs.readFileSync(WORKFLOW_FILE, 'utf8')); +const fixture = JSON.parse(fs.readFileSync(path.join(__dirname, 'fixture-snapshot.json'), 'utf8')); +const nodeCode = wf.nodes.find((n) => n.name === 'Compute Charge Targets').parameters.jsCode; + +// Le node lit l'heure par new Date() sans argument ; les autres usages de Date +// prennent des arguments et ne doivent PAS etre epingles. +function makeFakeDate(fixedMs) { + return class FakeDate extends Date { + constructor(...a) { if (a.length === 0) super(fixedMs); else super(...a); } + static now() { return fixedMs; } + }; +} + +function runNode(jsCode, inputJson, fixedMs) { + const ctx = { + $input: { first: () => ({ json: inputJson }) }, + console, Intl, Math, Number, Object, String, JSON, Array, Boolean, RegExp, + Date: makeFakeDate(fixedMs), + }; + vm.createContext(ctx); + return vm.runInContext(`(function(){\n${jsCode}\n})()`, ctx)[0].json; +} + +function summarize(out) { + const c = out.computed || {}; + const d = out.decision || {}; + return { + mode: d.mode, + profil: d.effective_profile, + courantA: d.target_current_a, + tensionV: d.target_voltage_v, + alerte: !!d.critical_alert, + publieCourant: !!d.should_publish_current, + publieTension: !!d.should_publish_voltage, + overrideActif: !!c.override_active, + }; +} + +let echecs = 0, joues = 0; +for (const sc of scenarios) { + if (only && !sc.id.includes(only)) continue; + joues++; + const fixedMs = new Date(sc.time).getTime(); + if (!Number.isFinite(fixedMs)) throw new Error(`${sc.id} : time invalide`); + + const raw = { ...fixture, ...sc.overrides }; + let out; + try { + out = runNode(nodeCode, { data: JSON.stringify(raw) }, fixedMs); + } catch (e) { + echecs++; console.log(`💥 ${sc.id} : CRASH — ${e.message}`); continue; + } + const got = summarize(out); + + if (PRINT) { + console.log(`\n─── ${sc.id} | ${sc.label}`); + console.log(' expect:', JSON.stringify(got)); + if (out.decision?.short_reason) console.log(' raison:', out.decision.short_reason); + continue; + } + + const diffs = []; + for (const [k, v] of Object.entries(sc.expect || {})) { + if (JSON.stringify(got[k]) !== JSON.stringify(v)) { + diffs.push(`${k} : attendu ${JSON.stringify(v)}, obtenu ${JSON.stringify(got[k])}`); + } + } + if (diffs.length) { + echecs++; + console.log(`❌ ${sc.id} | ${sc.label}`); + for (const d of diffs) console.log(` ${d}`); + if (out.decision?.short_reason) console.log(` raison: ${out.decision.short_reason}`); + } else { + console.log(`✅ ${sc.id}`); + } +} + +if (!PRINT) { + console.log(echecs + ? `\n${echecs}/${joues} ÉCHEC(S) — NE PAS POUSSER EN PRODUCTION` + : `\nGOLDEN ACM OK — ${joues}/${joues} scénarios conformes`); + process.exit(echecs ? 1 : 0); +} diff --git a/tests/golden-acm/scenarios.mjs b/tests/golden-acm/scenarios.mjs new file mode 100644 index 0000000..426f114 --- /dev/null +++ b/tests/golden-acm/scenarios.mjs @@ -0,0 +1,206 @@ +// Scenarios golden pour le node "Compute Charge Targets" +// (workflow "Victron - Auto Charge Manager V1.2", id cTeeHy4ePiVeELLh). +// +// Ce workflow decide ce que la BATTERIE a le droit de prendre : les limites +// DVCC en courant et en tension. Il gouverne donc le haut de la reserve — c'est +// lui qui plafonnait le parc a 93 % de SOC jusqu'au 06/09 — et il n'avait aucun +// test : il n'etait meme pas suivi dans le depot. +// +// Le fixture est un snapshot REEL, capture le 06/09/2026 en rendant le gabarit +// du node "HA Snapshot via Template" contre Home Assistant. Ne pas le remplacer +// par des valeurs inventees : c'est la lecon du 24/08, ou le fixture de +// l'hysteresis portait les valeurs de REPLI du code (22/35) au lieu des seuils +// de production (20/30), ce qui masquait un bug de bande. +// +// ⚠️ ORDRE DES BRANCHES, a garder en tete en lisant les attendus : +// 1. automatisation desactivee +// 2. secours SOC < 20 hors fenetres HC +// 3. takeover EVCS nuit (recover / hold) +// 4. hiver + HC nuit +// 5. ete + HC nuit +// 6. HC midi (rattrapage, seulement si SOC < 85 ET forecast < 8 ET PV < 1500) +// 7. fenetre jour (boost / mid / topoff / hold selon le SOC) +// 8. hors fenetre +// puis l'override se superpose a tout. + +const ETE = '2026-07-15'; +const HIVER = '2026-01-15'; +const h = (jour, hhmm) => `${jour}T${hhmm}:00+02:00`; +const hHiver = (hhmm) => `${HIVER}T${hhmm}:00+01:00`; + +// Le parc a quatre modules du 06/09 : le BMS autorise 53,2 V, et nos profils +// restent tous en dessous. +const parcNeuf = { soc_percent: '60', battery_voltage_v: '51.0', battery_current_a: '20' }; + +export const scenarios = [ + // ---------------------------------------------------------------- gardes + { + id: 'acm-automatisation-desactivee', + label: "Toggle d'automatisation OFF : les reglages en place sont conserves tels quels", + time: h(ETE, '11:00'), + overrides: { ...parcNeuf, auto_enabled: 'off', + current_max_charge_current_a: '77', current_max_charge_voltage_v: '50.9' }, + expect: { mode: 'automation_disabled', profil: 'manual_keep_current', + courantA: 77, tensionV: 50.9, publieCourant: false, publieTension: false }, + }, + { + id: 'acm-secours-soc-critique', + label: 'SOC 18 % hors fenetres HC : secours a 60 A / 50,4 V et alerte critique', + time: h(ETE, '10:00'), + overrides: { ...parcNeuf, soc_percent: '18' }, + expect: { mode: 'emergency_rescue', profil: 'emergency_rescue', + courantA: 60, tensionV: 50.4, alerte: true }, + }, + + // ------------------------------------------------------- takeover de nuit + { + id: 'acm-takeover-recharge-vers-la-cible', + label: 'Takeover EVCS, EDF ferme, SOC 30 sous la cible 50 : recharge a 30 A', + time: h(ETE, '02:30'), + overrides: { ...parcNeuf, soc_percent: '30', night_takeover: 'on', + edf_switch: 'on', night_target_soc: '50' }, + expect: { mode: 'evcs_takeover_recover', profil: 'night_slow', + courantA: 30, tensionV: 50.1 }, + }, + { + id: 'acm-takeover-maintien-a-la-cible', + label: 'RELEVE 06/09 05:06 : cible atteinte, la batterie est tenue a 2 A pendant que le reseau porte la maison', + time: h(ETE, '05:06'), + overrides: { ...parcNeuf, soc_percent: '55', night_takeover: 'on', + edf_switch: 'on', night_target_soc: '49.4' }, + expect: { mode: 'evcs_takeover_hold', profil: 'takeover_hold', + courantA: 2, tensionV: 50.1 }, + }, + { + id: 'acm-takeover-cible-plafonnee-a-95', + label: 'GARDE : une cible aberrante de 120 % est ramenee a 95, donc SOC 96 tient deja', + time: h(ETE, '03:00'), + overrides: { ...parcNeuf, soc_percent: '96', night_takeover: 'on', + edf_switch: 'on', night_target_soc: '120' }, + expect: { mode: 'evcs_takeover_hold', courantA: 2 }, + }, + { + id: 'acm-takeover-sans-edf-ne-mord-pas', + label: "CONTREFACTUEL : takeover demande mais contacteur OUVERT — on ne bride pas la batterie sur du reseau absent", + time: h(ETE, '03:00'), + overrides: { ...parcNeuf, soc_percent: '30', night_takeover: 'on', + edf_switch: 'off', night_target_soc: '50' }, + expect: { mode: 'summer_night_idle' }, + }, + + // ------------------------------------------------------------ nuit HC + { + id: 'acm-hiver-nuit-forecast-sombre', + label: 'Hiver, HC nuit, demain sombre (5 kWh) : on remonte vers 80 %', + time: hHiver('03:00'), + overrides: { ...parcNeuf, soc_percent: '50', + today_remaining_kwh: '0', tomorrow_kwh: '5' }, + expect: { mode: 'winter_night_charge_window', profil: 'night_slow', courantA: 30 }, + }, + { + id: 'acm-hiver-nuit-forecast-ensoleille', + label: 'PAIRE : meme nuit d hiver mais demain ensoleille (30 kWh), cible 40 % deja depassee', + time: hHiver('03:00'), + overrides: { ...parcNeuf, soc_percent: '50', + today_remaining_kwh: '0', tomorrow_kwh: '30' }, + expect: { mode: 'winter_night_idle' }, + }, + { + id: 'acm-ete-nuit-secours', + label: 'Ete, HC nuit, SOC 20 sous le seuil de 22 : secours', + time: h(ETE, '03:00'), + overrides: { ...parcNeuf, soc_percent: '20' }, + expect: { mode: 'summer_night_rescue_window', profil: 'night_slow' }, + }, + { + id: 'acm-ete-nuit-repos', + label: 'PAIRE : meme nuit d ete a SOC 50, aucun besoin reseau', + time: h(ETE, '03:00'), + overrides: { ...parcNeuf, soc_percent: '50' }, + expect: { mode: 'summer_night_idle' }, + }, + + // ------------------------------------------------------------ midi HC + { + id: 'acm-midi-rattrapage', + label: 'HC midi, SOC 60, journee sombre (5 kWh) et PV a 800 W : rattrapage reseau a 30 A', + time: h(ETE, '13:00'), + overrides: { ...parcNeuf, soc_percent: '60', today_remaining_kwh: '5', pv_power_w: '800' }, + expect: { mode: 'midday_catchup_window', profil: 'midday_grid_catchup', + courantA: 30, tensionV: 50.8 }, + }, + { + id: 'acm-midi-pas-de-rattrapage-si-le-soleil-revient', + label: 'CONTREFACTUEL : meme midi mais PV a 2000 W — le rattrapage ne mord plus, on repasse en profil jour', + time: h(ETE, '13:00'), + overrides: { ...parcNeuf, soc_percent: '60', today_remaining_kwh: '5', pv_power_w: '2000' }, + expect: { mode: 'day_pv_boost', profil: 'pv_boost' }, + }, + + // ------------------------------------------------------------ jour + { + id: 'acm-jour-boost', + label: 'Jour, SOC 60 sous 80 : capture PV maximale, 130 A a 51,5 V', + time: h(ETE, '11:00'), + overrides: { ...parcNeuf, soc_percent: '60', pv_power_w: '4000' }, + expect: { mode: 'day_pv_boost', profil: 'pv_boost', courantA: 130, tensionV: 51.5 }, + }, + { + id: 'acm-jour-mid', + label: 'Jour, SOC 85 entre 80 et 90 : 110 A a 51,2 V', + time: h(ETE, '11:00'), + overrides: { ...parcNeuf, soc_percent: '85', pv_power_w: '4000', battery_voltage_v: '50.5' }, + expect: { mode: 'day_pv_mid', profil: 'pv_mid', courantA: 110, tensionV: 51.2 }, + }, + { + id: 'acm-jour-topoff-52v4', + label: "LE CORRECTIF DU 06/09 : SOC 93 dans la tranche 90-95, la tension monte a 52,4 V et le parc reprend de la charge", + time: h(ETE, '15:00'), + overrides: { ...parcNeuf, soc_percent: '93', pv_power_w: '4000', battery_voltage_v: '51.1' }, + expect: { mode: 'day_pv_topoff', profil: 'pv_topoff', courantA: 80, tensionV: 52.4 }, + }, + { + id: 'acm-jour-hold-au-dessus-de-95', + label: "GARDE : au-dessus de 95 % on redescend a 50,6 V — on atteint le haut, on n'y sejourne pas", + time: h(ETE, '15:00'), + overrides: { ...parcNeuf, soc_percent: '96', pv_power_w: '4000' }, + expect: { mode: 'day_pv_hold', profil: 'pv_hold', courantA: 30, tensionV: 50.6 }, + }, + { + id: 'acm-hors-fenetre', + label: 'Hors fenetre utile (21 h en ete) : profil de maintien', + time: h(ETE, '21:00'), + overrides: { ...parcNeuf, soc_percent: '70' }, + expect: { mode: 'off_window_idle', profil: 'pv_hold', courantA: 30, tensionV: 50.6 }, + }, + + // ------------------------------------------------------------ override + { + id: 'acm-override-manuel-actif', + label: 'Override manuel valide : les valeurs saisies prennent la main sur le profil calcule', + time: h(ETE, '11:00'), + overrides: { ...parcNeuf, soc_percent: '60', override_enabled: 'on', + override_profile: 'manual', override_current_a: '12', override_voltage_v: '50.0', + override_until: '2026-07-15 23:00:00' }, + expect: { overrideActif: true, profil: 'override_manual', courantA: 12, tensionV: 50 }, + }, + { + id: 'acm-override-expire-sans-effet', + label: "CONTREFACTUEL : meme override mais l'echeance est passee — le profil calcule reprend", + time: h(ETE, '11:00'), + overrides: { ...parcNeuf, soc_percent: '60', override_enabled: 'on', + override_profile: 'manual', override_current_a: '12', override_voltage_v: '50.0', + override_until: '2026-07-15 09:00:00' }, + expect: { overrideActif: false, mode: 'day_pv_boost', courantA: 130 }, + }, + + // ------------------------------------------------------------ publication + { + id: 'acm-pas-de-publication-si-identique', + label: "GARDE : cible egale a ce qui est deja regle — on n'ecrit ni le courant ni la tension", + time: h(ETE, '21:00'), + overrides: { ...parcNeuf, soc_percent: '70', + current_max_charge_current_a: '30', current_max_charge_voltage_v: '50.6' }, + expect: { publieCourant: false, publieTension: false }, + }, +]; diff --git a/tools/n8n-push.mjs b/tools/n8n-push.mjs index 1fa32c2..fb8cddc 100644 --- a/tools/n8n-push.mjs +++ b/tools/n8n-push.mjs @@ -109,6 +109,7 @@ console.log('Syntaxe OK sur tous les nodes jsCode.'); const isEvcsWorkflow = path.basename(INPUT) === 'evcs-surplus-controller-v2.6.json'; const isAdvisorWorkflow = path.basename(INPUT) === 'evcs-advisor-shadow-v1.json'; const isHysteresisWorkflow = path.basename(INPUT) === 'victron-grid-hysteresis-v1.json'; +const isAcmWorkflow = path.basename(INPUT) === 'victron-auto-charge-manager-v1.2.json'; const isBalanceWorkflow = path.basename(INPUT) === 'evcs-energy-balance-v1.json' || path.basename(INPUT) === 'evcs-energy-profiles-daily-v1.json' || path.basename(INPUT) === 'evcs-solar-forecast-v1.json'; @@ -117,6 +118,7 @@ const testSuite = isEvcsWorkflow && fs.existsSync('tests/golden/run.mjs') ? 'tests/golden/run.mjs' : isAdvisorWorkflow && fs.existsSync('tests/advisor/run.mjs') ? 'tests/advisor/run.mjs' : isHysteresisWorkflow && fs.existsSync('tests/golden-hysteresis/run.mjs') ? 'tests/golden-hysteresis/run.mjs' : + isAcmWorkflow && fs.existsSync('tests/golden-acm/run.mjs') ? 'tests/golden-acm/run.mjs' : isBalanceWorkflow && fs.existsSync('tests/energy-balance/run.mjs') ? 'tests/energy-balance/run.mjs' : null; if (testSuite) {