LOT P1.0 : paquet @transformer/moteurs v0.1.0

Extraction du cœur autonomie de calculs.trans-former.fr (12 modules, sans
singleton, couplages sans mutation, console derrière debug), découpage en trois
moteurs d'axe + intégrateur (mode global = calculs, mode par_axe = plateforme),
moteurs commun.contexte (Corse 2A/2B, DOM 97x corrigés) et commun.geometrie,
table des coefficients harmonisés (docs/CONSTANTES.md pour Jules, R-7),
validation structurelle des données (schemas/), tests hors ligne : 28 assertions
d'origine + épingle générée depuis le code d'astro-pro + écarts d'harmonisation
figés + contrat des moteurs. dist/ committé.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UBHYPdeQYy2m1GVJp6b12
This commit is contained in:
Jules
2026-09-28 17:06:49 +02:00
co-authored by Claude Fable 5.1
commit 69809df5d1
123 changed files with 13225 additions and 0 deletions
+279
View File
@@ -0,0 +1,279 @@
// Tests d'origine du cœur autonomie (calculs/tests/test-algo.js, 28 assertions,
// fixtures figées du 16/08/2026) portés en node:test. Ces tests ne se désactivent
// jamais (AGENTS.md règle 6). Coefficients d'ORIGINE injectés
// (fixtures/coefficients-2026-05.json) : ils vérifient l'extraction, pas l'harmonisation.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import {
selectionner,
dimension_optimale,
cout_typologie,
configPourAxes,
auMoinsUnAxe,
} from '../dist/autonomie/noyau/index.js';
const options = JSON.parse(readFileSync(new URL('../fixtures/options.json', import.meta.url), 'utf8'));
const postes = JSON.parse(readFileSync(new URL('../fixtures/postes.json', import.meta.url), 'utf8'));
const climat = JSON.parse(readFileSync(new URL('../fixtures/france_climat.json', import.meta.url), 'utf8'));
const config = JSON.parse(readFileSync(new URL('../fixtures/algo-config.json', import.meta.url), 'utf8'));
const coefficients = JSON.parse(readFileSync(new URL('../fixtures/coefficients-2026-05.json', import.meta.url), 'utf8'));
const profils = JSON.parse(readFileSync(new URL('../fixtures/profils-reference.json', import.meta.url), 'utf8'));
const reglages = { config, coefficients };
// ========== SECTION 1 : Typologies echantillon M1a-RECAP ==========
test('Section 1 : typologies echantillon M1a-RECAP', () => {
const inputs_ref = { nb_personnes: 4, surface_toit_m2: 80, orientation_toit: 'sud', surface_parcelle_m2: 800, metres_haie_possibles: 0, temps_dispo: 2 };
const ctx_ref = { dju: 2200, zone_biogeo: 'OC', pluviometrie_mm: 800, zone_climatique_rt: 'H2a', zone_rt_class: 'H2' };
// 1a — Cuve beton enterree : 4 pers → base 3 + 4×1.5 = 9 m³
{
const typo = options.find(o => o.code === 'stockage_beton_5m3');
if (typo) {
const dim = dimension_optimale(typo, inputs_ref, ctx_ref, coefficients);
assert.ok(dim && dim.valeur === 9, 'Cuve beton 4 pers = 9 m3 (base 3 + 4x1.5)');
assert.ok(dim && dim.unite === 'm³', 'Cuve beton unite = m3');
} else { console.log(' SKIP: stockage_beton_5m3 non trouve'); }
}
// 1b — PV kit string toit 80m² Sud : 80×0.15×1.0 = 12 → clamp max 9 kWc
{
const typo = options.find(o => o.code === 'pv_kit_3kwc_string');
if (typo) {
const dim = dimension_optimale(typo, inputs_ref, ctx_ref, coefficients);
assert.ok(dim && dim.valeur === 9, 'PV toit 80m2 Sud = 9 kWc (clamp max)');
assert.ok(dim && dim.unite === 'kWc', 'PV unite = kWc');
} else { console.log(' SKIP: pv_kit_3kwc_string non trouve'); }
}
// 1c — Serre tunnel 4 pers : base 3 + 4×3 = 15 → step 6 → 18 m²
{
const typo = options.find(o => o.code === 'basse_serre_6m2');
if (typo) {
const dim = dimension_optimale(typo, inputs_ref, ctx_ref, coefficients);
if (dim) { assert.ok(dim.valeur === 18, 'Serre 4 pers = 18 m2 (step 6: 15 -> 18)'); }
else { console.log(' SKIP: basse_serre_6m2 param_dim manquant (strategie fixe)'); }
} else { console.log(' SKIP: basse_serre_6m2 non trouve'); }
}
// 1d — Phyto Aquatiris 4 pers : base 1 + 4×1 = 5 EH
{
const typo = options.find(o => o.code === 'eg_aquatiris' || o.code === 'eg_filiere_agreee_aquatiris');
if (typo) {
const dim = dimension_optimale(typo, inputs_ref, ctx_ref, coefficients);
if (dim && dim.strategie === 'par_personne') { assert.ok(dim.valeur === 5, 'Phyto 4 pers = 5 EH'); }
else { console.log(' SKIP: aquatiris strategie fixe ou param manquant'); }
} else { console.log(' SKIP: eg_aquatiris non trouve'); }
}
// 1e — th_ballon_500 : unite = L (particularite ballon_tampon)
{
const typo = options.find(o => o.code === 'th_ballon_500');
if (typo) {
const dim = dimension_optimale(typo, inputs_ref, ctx_ref, coefficients);
assert.ok(dim && dim.unite === 'L', 'ballon_tampon unite = L (pas forfait)');
const cout = cout_typologie(typo, dim);
assert.ok(typeof cout === 'number' && cout > 0, 'ballon_tampon cout > 0 (prix_par_unite 1.2 EUR/L x volume_L)');
if (dim) console.log(' th_ballon_500:', dim.valeur, dim.unite, '=', cout, 'EUR');
} else { console.log(' SKIP: th_ballon_500 non trouve'); }
}
});
// ========== SECTION 2 : 3 profils de reference ==========
test('Section 2 : profils de reference (critere 1 : ecart +-5% budget)', () => {
const resultats_profils = [];
for (const profil of profils) {
const t0 = performance.now();
const result = selectionner(profil.inputs, options, postes, climat, reglages, false);
const dt = performance.now() - t0;
console.log('--- ' + profil.nom + ' (' + dt.toFixed(1) + 'ms) ---');
console.log('Total: ' + result.total_eur_ht + ' EUR (cible ' + result.budget_cible + ' EUR, ecart ' + result.ecart_budget_pct.toFixed(1) + '%)');
console.log('Status:', result.status);
if (process.env.VERBOSE) {
for (const axe of ['eau', 'energie', 'alim']) {
if (result.axes[axe]) {
console.log(' ' + axe + ': ' + result.axes[axe].sous_total_eur_ht + ' EUR (' + result.axes[axe].postes.length + ' postes)');
for (const p of result.axes[axe].postes) {
const dim = p.dimension ? (p.dimension.valeur + p.dimension.unite) : '';
console.log(' - ' + p.nom_affichage + ' [' + p.typologie_choisie + '] ' + dim + ' : ' + p.cout_eur_ht + ' EUR');
}
}
}
}
assert.ok(Math.abs(result.ecart_budget_pct) <= 5, profil.nom + ' : ecart ' + result.ecart_budget_pct.toFixed(1) + '% (<= 5%)');
assert.ok(dt < 50, profil.nom + ' : ' + dt.toFixed(1) + 'ms (< 50ms)');
assert.ok(result.status !== undefined, profil.nom + ' : status defini');
resultats_profils.push({ profil, result, dt });
}
});
// ========== SECTION 3 : Determinisme (100 iterations) ==========
test('Section 3 : determinisme (100 iterations)', () => {
const profil_test = profils[1];
const r1 = selectionner(profil_test.inputs, options, postes, climat, reglages, false);
let deterministe = true;
for (let i = 0; i < 99; i++) {
const r = selectionner(profil_test.inputs, options, postes, climat, reglages, false);
if (r.total_eur_ht !== r1.total_eur_ht || r.status !== r1.status) {
deterministe = false;
console.error(' Iteration ' + (i + 2) + ' : total=' + r.total_eur_ht + ' != ' + r1.total_eur_ht);
break;
}
}
assert.ok(deterministe, 'Determinisme : 100 iterations profil B -> meme resultat');
});
// ========== SECTION 4 : BUDGET_INSUFFISANT ==========
test('Section 4 : BUDGET_INSUFFISANT', () => {
const inputs_mini = { budget_eur: 100, code_postal: '75019', nb_personnes: 4, surface_parcelle_m2: 500, surface_toit_m2: 50, orientation_toit: 'sud', pente_toit_deg: 30, metres_haie_possibles: 0, temps_dispo: 1 };
let no_crash = true;
let result_mini;
try { result_mini = selectionner(inputs_mini, options, postes, climat, reglages, false); }
catch (e) { no_crash = false; console.error(' CRASH:', e.message); }
assert.ok(no_crash, 'Budget 100 EUR : pas de crash');
if (result_mini) {
assert.ok(['OK', 'BUDGET_INSUFFISANT', 'AJOUTER_OPTIONNEL'].includes(result_mini.status), 'Budget 100 EUR : status valide');
console.log(' Status:', result_mini.status, '|', result_mini.message_status || '(pas de message)');
}
});
// ========== SECTION 5 : Mode debug ==========
test('Section 5 : mode debug', () => {
const result_debug = selectionner(profils[0].inputs, options, postes, climat, reglages, true);
assert.ok(result_debug.debug !== undefined, 'Mode debug : objet debug present');
assert.ok(Array.isArray(result_debug.debug.steps), 'Mode debug : steps est un tableau');
assert.ok(result_debug.debug.steps.length >= 5, 'Mode debug : >= 5 etapes tracees');
console.log(' Etapes tracees:', result_debug.debug.steps.map(s => s.label || s.etape).join(', '));
});
// ========== SECTION 6 : Couplages (20 simulations) ==========
test('Section 6 : couplages contraints (20 simulations)', () => {
// Utiliser les codes BDD réels (après résolution des alias de algo-config.json)
// stherm_ballon_tampon → th_ballon_500 (alias BDD)
const COUPLAGES_REQUIS_ATTENDUS = {
'bois_poele_hydro': ['bois_conduit_fumee', 'th_ballon_500'],
'filtration_osmose_inverse': ['filtration_particulaire_simple'],
'filtration_uv_standalone': ['filtration_particulaire_simple'],
};
const configs_test = [
{ budget_eur: 5000, cp: '75000', pers: 1, parcelle: 100, toit: 20, orient: 'sud', temps: 1 },
{ budget_eur: 10000, cp: '31000', pers: 2, parcelle: 300, toit: 40, orient: 'sud', temps: 1 },
{ budget_eur: 20000, cp: '69000', pers: 3, parcelle: 500, toit: 60, orient: 'sud-est', temps: 2 },
{ budget_eur: 35000, cp: '13100', pers: 4, parcelle: 800, toit: 80, orient: 'sud', temps: 2 },
{ budget_eur: 50000, cp: '06000', pers: 5, parcelle: 1200, toit: 100, orient: 'sud', temps: 3 },
{ budget_eur: 80000, cp: '01000', pers: 6, parcelle: 2500, toit: 120, orient: 'sud', temps: 3 },
{ budget_eur: 15000, cp: '59000', pers: 2, parcelle: 400, toit: 50, orient: 'est', temps: 1 },
{ budget_eur: 25000, cp: '33000', pers: 3, parcelle: 700, toit: 70, orient: 'ouest', temps: 2 },
{ budget_eur: 45000, cp: '67000', pers: 4, parcelle: 1000, toit: 90, orient: 'sud-ouest',temps: 3 },
{ budget_eur: 60000, cp: '38000', pers: 5, parcelle: 1500, toit: 110, orient: 'sud', temps: 2 },
{ budget_eur: 12000, cp: '44000', pers: 2, parcelle: 250, toit: 35, orient: 'sud', temps: 1 },
{ budget_eur: 30000, cp: '34000', pers: 3, parcelle: 600, toit: 65, orient: 'sud-est', temps: 2 },
{ budget_eur: 55000, cp: '76000', pers: 5, parcelle: 900, toit: 95, orient: 'sud', temps: 3 },
{ budget_eur: 18000, cp: '29000', pers: 2, parcelle: 350, toit: 45, orient: 'nord', temps: 1 },
{ budget_eur: 40000, cp: '83000', pers: 4, parcelle: 1100, toit: 85, orient: 'sud', temps: 2 },
{ budget_eur: 70000, cp: '64000', pers: 6, parcelle: 3000, toit: 130, orient: 'sud', temps: 3 },
{ budget_eur: 22000, cp: '57000', pers: 3, parcelle: 450, toit: 55, orient: 'est', temps: 2 },
{ budget_eur: 48000, cp: '30000', pers: 5, parcelle: 1300, toit: 105, orient: 'sud-ouest',temps: 3 },
{ budget_eur: 8000, cp: '75019', pers: 1, parcelle: 100, toit: 25, orient: 'sud', temps: 1 },
{ budget_eur: 65000, cp: '11000', pers: 6, parcelle: 2000, toit: 115, orient: 'sud', temps: 3 },
];
let violations = 0;
for (let i = 0; i < configs_test.length; i++) {
const c = configs_test[i];
const inputs_fuzz = {
budget_eur: c.budget_eur, code_postal: c.cp, nb_personnes: c.pers,
surface_parcelle_m2: c.parcelle, surface_toit_m2: c.toit,
orientation_toit: c.orient, pente_toit_deg: 30,
metres_haie_possibles: c.parcelle > 500 ? 30 : 0, temps_dispo: c.temps,
};
let result_fuzz;
try { result_fuzz = selectionner(inputs_fuzz, options, postes, climat, reglages, false); }
catch (e) { violations++; console.error(' Simu ' + (i + 1) + ' CRASH:', e.message); continue; }
const tous_codes = ['eau', 'energie', 'alim']
.flatMap(axe => result_fuzz.axes[axe] ? result_fuzz.axes[axe].postes : [])
.map(p => p.typologie_choisie);
for (const [typo_code, requis_list] of Object.entries(COUPLAGES_REQUIS_ATTENDUS)) {
if (tous_codes.includes(typo_code)) {
for (const requis of requis_list) {
if (!tous_codes.includes(requis)) {
violations++;
console.error(' Simu ' + (i + 1) + ' : COUPLAGE VIOLE — ' + typo_code + ' sans ' + requis);
}
}
}
}
}
assert.ok(violations === 0, 'Couplages : 0 violation sur 20 simulations (trouve: ' + violations + ')');
});
// ========== SECTION 7 : Toggles d'axes (V3 M2) ==========
test('Section 7 : toggles d axes (V3 M2)', () => {
const combinaisons = [
{ nom: 'eau seul', actifs: { eau: true, energie: false, alim: false } },
{ nom: 'energie seul', actifs: { eau: false, energie: true, alim: false } },
{ nom: 'alim seul', actifs: { eau: false, energie: false, alim: true } },
{ nom: 'eau+energie', actifs: { eau: true, energie: true, alim: false } },
{ nom: 'eau+alim', actifs: { eau: true, energie: false, alim: true } },
{ nom: 'energie+alim', actifs: { eau: false, energie: true, alim: true } },
];
let ok7 = true;
for (const profil of profils) {
for (const combo of combinaisons) {
const cfg = configPourAxes(config, combo.actifs);
const r = selectionner(profil.inputs, options, postes, climat, { config: cfg, coefficients }, false);
// 1. Axe désactivé -> 0 poste, 0 EUR
for (const axe of ['eau', 'energie', 'alim']) {
if (combo.actifs[axe] === false) {
const a = r.axes[axe];
if (a && (a.postes.length > 0 || a.sous_total_eur_ht > 0)) {
ok7 = false;
console.error(' FAIL: ' + profil.nom + ' / ' + combo.nom + ' — axe ' + axe + ' desactive mais ' + a.postes.length + ' postes, ' + a.sous_total_eur_ht + ' EUR');
}
}
}
// 2. Le total reste calibre sur le budget saisi (jamais au-dela de +25%)
if (r.total_eur_ht > profil.inputs.budget_eur * 1.25) {
ok7 = false;
console.error(' FAIL: ' + profil.nom + ' / ' + combo.nom + ' — total ' + r.total_eur_ht + ' >> budget ' + profil.inputs.budget_eur);
}
// 3. Déterminisme conservé
const r2 = selectionner(profil.inputs, options, postes, climat, { config: configPourAxes(config, combo.actifs), coefficients }, false);
if (r2.total_eur_ht !== r.total_eur_ht) {
ok7 = false;
console.error(' FAIL: ' + profil.nom + ' / ' + combo.nom + ' — non deterministe');
}
}
}
assert.ok(ok7, 'Toggles axes : 3 profils x 6 combinaisons (axe eteint = 0 poste, total calibre, deterministe)');
// Cas limite : 0 axe actif -> l'UI ne lance pas l'algo (garde auMoinsUnAxe)
assert.equal(auMoinsUnAxe({ eau: false, energie: false, alim: false }), false, '0 axe actif detecte (etat vide explicite, pas de crash)');
assert.equal(auMoinsUnAxe({ eau: true, energie: false, alim: false }), true, 'auMoinsUnAxe : 1 axe actif OK');
// Config d'origine non mutée
assert.ok(!config._axes_desactives, 'configPourAxes ne mute pas la config d origine');
});