Patch (AGENTS règle 5 : correction, aucune sortie nouvelle — la quantité d'un poste passe par `dimension`, qui existait). Étiquette et merge au checkpoint de la session de pilotage ; montée dans reinnover le même jour (ouvre P1.2b). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgtn7bJxTLiL2fRCkQMgtR
154 lines
8.4 KiB
JavaScript
154 lines
8.4 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;
|
||
// Correctif v0.1.1 (SPEC menu §12 n° 2 et 3) : `reajusteBudget` renvoie en
|
||
// écho le journal qu'il reçoit ; l'origine le ré-ajoutait à chaque tour (chaque
|
||
// FORCE apparaissait deux à quatre fois). Le journal ne s'enrichit que des
|
||
// lignes nouvelles de `appliqueCouplages`. La boucle ne se contentait que du
|
||
// dépassement : un réajustement qui lâche des accessoires forcés (principal
|
||
// retiré ou changé) laissait le total loin sous le budget sans rien recombler.
|
||
// Désormais : couplages → dans la bande [−underspend, +overrun] ? fin ;
|
||
// sinon réajuste (CAS A ou B) → couplages…, trois tours au plus, et toujours
|
||
// une passe couplages en dernier. Si le dernier forçage refranchit le seuil,
|
||
// le statut le dit.
|
||
const tol_under = (config.tolerances && config.tolerances.budget_underspend_pct) || 15;
|
||
const seuil_under = budget_total * (1 - tol_under / 100);
|
||
const dans_la_bande = (t) => t <= seuil_over && t >= seuil_under;
|
||
const prend = (avec) => {
|
||
etat = { axes: avec.axes, couplages_appliques: [...etat.couplages_appliques, ...(avec.couplages_appliques || [])] };
|
||
};
|
||
for (let passe = 0; passe < 3; passe++) {
|
||
// IMPORTANT: appliqueCouplages attend selection = { eau, energie, alim } directement
|
||
prend(appliqueCouplages(etat.axes, options, postes, reglages, false));
|
||
if (dans_la_bande(sommeAxes(etat.axes)))
|
||
break;
|
||
const re = reajusteBudget(etat, budget_total, options, postes, inputs, contexte, reglages, false);
|
||
etat = { axes: re.axes, couplages_appliques: etat.couplages_appliques };
|
||
status_final = re.status;
|
||
message_final = re.message;
|
||
// Bloqué (rien à ajouter, ou plancher atteint) ou dernier tour : une passe couplages et fin
|
||
if (passe === 2 || re.status !== 'OK') {
|
||
prend(appliqueCouplages(etat.axes, options, postes, reglages, false));
|
||
break;
|
||
}
|
||
}
|
||
if (sommeAxes(etat.axes) > seuil_over && status_final === 'OK') {
|
||
status_final = 'BUDGET_INSUFFISANT';
|
||
message_final = 'Budget insuffisant : les couplages requis portent le total à ' + sommeAxes(etat.axes) + ' EUR pour un budget de ' + Math.round(budget_total) + ' EUR.';
|
||
}
|
||
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,
|
||
};
|
||
}
|