// 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, }; }