Files
moteurs/dist/autonomie/noyau/algo.js
T
JulesandClaude Fable 5.1 8465bf7204 v0.1.1 : version et dist reconstruit
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
2026-10-05 16:39:16 +02:00

154 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
};
}