Files
moteurs/dist/autonomie/noyau/algo.js
T
JulesandClaude Fable 5.1 69809df5d1 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
2026-09-28 17:06:49 +02:00

136 lines
7.0 KiB
JavaScript

// algo.ts — module principal orchestrateur des 6 étapes
// Spec: V2-algo-spec.md + V2-algo-spec-FONCTIONS.md — origine calculs/src/lib/autonomie/algo.js.
// Zero-dep, isomorphe navigateur/Node.
//
// Corrections LOT P1.0 : plus d'initConfig (les réglages passent en paramètre) ;
// le contexte vient de commun/contexte.ts (Corse 2A/2B et DOM 971-976 corrigés,
// une seule version) ; la sortie porte en plus `contexte` et `avertissements`.
// C'est le pipeline COMPLET d'origine (couplages et réajustement globaux), celui
// que calculs.trans-former.fr consommera en P1.5 sans changement de résultat.
//
// Découpage par axe (LOT P1.0) : la queue du pipeline (étapes 4 à 6) est
// isolée dans `finaliseSelection`, que les moteurs d'axe réutilisent telle
// quelle sur une sélection à un seul axe. Un moteur d'axe est donc, par
// construction, le mode « un seul axe actif » de `selectionner`.
import { deriveContexte } from '../../commun/contexte.js';
import { pondereAxes } from './pondere-axes.js';
import { alloueBudget } from './alloue-budget.js';
import { selectionnePoste } from './selectionne-poste.js';
import { appliqueCouplages } from './couplages.js';
import { reajusteBudget } from './reajuste.js';
const AXES_CODE = ['eau', 'energie', 'alim'];
function sommeAxes(axes) {
return Math.round(AXES_CODE.reduce((s, axe) => s + ((axes[axe] && axes[axe].sous_total_eur_ht) || 0), 0));
}
/**
* Étapes 4 à 6 du pipeline d'origine : couplages contraints, réajustement au
* budget, re-passe couplages bornée à deux tours, total. Ne touche pas à la
* sélection reçue (appliqueCouplages copie).
*/
export function finaliseSelection(selection, budget_total, options, postes, inputs, contexte, reglages, debugMode = false, debug) {
const config = reglages.config;
// --- Étape 4 : couplages contraints ---
const apres_couplages = appliqueCouplages(selection, options, postes, reglages, debugMode);
if (debug)
debug.steps.push({ etape: 4, label: 'couplages', log: apres_couplages.couplages_appliques });
// --- Étape 5 : réajustement budget ---
const final_pre = reajusteBudget(apres_couplages, budget_total, options, postes, inputs, contexte, reglages, debugMode);
if (debug)
debug.steps.push({ etape: 5, label: 'reajustement', status: final_pre.status, total: final_pre.total, log: final_pre.debug_reajust });
// Re-passe couplages post-réajustement, avec réabsorption des coûts forcés :
// le CAS B (underspend) peut ajouter ou upgrader des typologies ayant des
// couplages_requis. La passe couplages force les requis manquants
// (ex: bois_poele_hydro → bois_conduit_fumee) — coûts contraints, inclus
// dans le total final (comportement intentionnel).
// Correctif V3-1 (recalibrage) : ces ajouts pouvaient faire franchir le seuil
// d'overrun SANS rééquilibrage ni changement de status (profil A : +8,9 % avec
// status OK). Boucle bornée à 2 passes : couplages → si overrun, réajuster →
// couplages. CAS A ne fait que réduire, la convergence est garantie ; si le
// seuil reste franchi, reajusteBudget pose BUDGET_INSUFFISANT (status honnête).
const tol_over = (config.tolerances && config.tolerances.budget_overrun_pct) || 5;
const seuil_over = budget_total * (1 + tol_over / 100);
let etat = {
axes: final_pre.axes,
couplages_appliques: [...(final_pre.couplages_appliques || [])],
};
let status_final = final_pre.status;
let message_final = final_pre.message;
for (let passe = 0; passe < 2; passe++) {
// IMPORTANT: appliqueCouplages attend selection = { eau, energie, alim } directement
const avec = appliqueCouplages(etat.axes, options, postes, reglages, false);
etat = {
axes: avec.axes,
couplages_appliques: [...etat.couplages_appliques, ...(avec.couplages_appliques || [])],
};
if (sommeAxes(etat.axes) <= seuil_over)
break;
const re = reajusteBudget(etat, budget_total, options, postes, inputs, contexte, reglages, false);
etat = {
axes: re.axes,
couplages_appliques: [...etat.couplages_appliques, ...(re.couplages_appliques || [])],
};
status_final = re.status;
message_final = re.message;
}
return {
axes: etat.axes,
total_eur_ht: sommeAxes(etat.axes),
status: status_final,
message_status: message_final || undefined,
couplages: etat.couplages_appliques,
debug_reajust: final_pre.debug_reajust,
};
}
/**
* Sélectionne les typologies optimales selon budget + contexte.
*
* @param inputs — saisie utilisateur (autodiag + budget_eur)
* @param options — typologies BDD (options.json)
* @param postes — postes BDD (postes.json)
* @param climat — données climatiques (france_climat.json)
* @param reglages — { config: algo-config.json parsé, coefficients }
* @param debugMode — active la trace debug exhaustive
*/
export function selectionner(inputs, options, postes, climat, reglages, debugMode = false) {
const debug = { steps: [] };
const config = reglages.config;
// --- Étape 0 : dériver le contexte depuis code postal ---
const ctx = deriveContexte(inputs.code_postal, climat, reglages.coefficients);
const contexte = ctx.sorties;
if (debugMode)
debug.steps.push({ etape: 0, label: 'contexte', data: contexte });
// --- Étape 1 : pondération axes ---
const poids_debug = debugMode ? pondereAxes(inputs, contexte, config, true) : null;
const poids = poids_debug ? poids_debug.poids : pondereAxes(inputs, contexte, config, false);
if (poids_debug)
debug.steps.push({ etape: 1, label: 'ponderation', poids, log: poids_debug.log });
// --- Étape 2 : allocation budget ---
const budgets = alloueBudget(inputs.budget_eur, poids);
if (debugMode)
debug.steps.push({ etape: 2, label: 'allocation', budgets });
// --- Étape 3 : sélection par axe ---
const selection = {};
for (const axe of AXES_CODE) {
const res = selectionnePoste(budgets[axe], inputs, contexte, options, postes, axe, reglages, debugMode);
res.poids = poids[axe];
selection[axe] = res;
}
if (debugMode)
debug.steps.push({ etape: 3, label: 'selection', summary: Object.fromEntries(AXES_CODE.map((a) => [a, selection[a].sous_total_eur_ht])) });
// --- Étapes 4 à 6 ---
const fin = finaliseSelection(selection, inputs.budget_eur, options, postes, inputs, contexte, reglages, debugMode, debugMode ? debug : undefined);
const ecart = inputs.budget_eur > 0 ? ((fin.total_eur_ht - inputs.budget_eur) / inputs.budget_eur) * 100 : 0;
return {
axes: fin.axes,
total_eur_ht: fin.total_eur_ht,
budget_cible: inputs.budget_eur,
ecart_budget_pct: +ecart.toFixed(2),
status: fin.status,
message_status: fin.message_status,
couplages: fin.couplages,
contexte,
avertissements: ctx.avertissements,
debug: debugMode ? debug : undefined,
};
}