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
136 lines
7.0 KiB
JavaScript
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,
|
|
};
|
|
}
|