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
300 lines
16 KiB
JavaScript
300 lines
16 KiB
JavaScript
// reajuste.ts — Étape 5 : réajustement budget global
|
|
// Spec: V2-algo-spec.md §Étape 5 — origine calculs/src/lib/autonomie/reajuste.js.
|
|
// Correction LOT P1.0 : `reglages` en paramètre (plus de getConfig()).
|
|
// Comportement d'origine conservé : la fonction modifie EN PLACE les axes
|
|
// reçus (qui sont déjà la copie produite par appliqueCouplages) et les renvoie.
|
|
//
|
|
// Note (origine, conservée) : les messages utilisent toLocaleString('fr-FR')
|
|
// (Intl). Texte de présentation, non assert par les tests ; à sortir du noyau
|
|
// en patch si l'octet-pour-octet inter-environnements devient un besoin.
|
|
import { cout_typologie, dimension_optimale } from './dimension.js';
|
|
import { parseJsonArr } from './types.js';
|
|
export function reajusteBudget(apres_couplages, budget_total, options, postes, inputs, contexte_derived, reglages, debug = false) {
|
|
const config = reglages.config;
|
|
const coefficients = reglages.coefficients;
|
|
const tolerances = config.tolerances || {};
|
|
const overrun_seuil = 1 + (tolerances.budget_overrun_pct || 5) / 100;
|
|
const underspend_seuil = 1 - (tolerances.budget_underspend_pct || 15) / 100;
|
|
const axes = apres_couplages.axes;
|
|
const axes_list = ['eau', 'energie', 'alim'];
|
|
function getTousPostes() {
|
|
return axes_list.flatMap((axe) => (axes[axe] && axes[axe].postes ? axes[axe].postes : []).map((p) => Object.assign({}, p, { _axe: axe })));
|
|
}
|
|
function calcTotal() {
|
|
return axes_list.reduce((s, axe) => s + (axes[axe] ? axes[axe].sous_total_eur_ht || 0 : 0), 0);
|
|
}
|
|
function recalcSousTotal(axe) {
|
|
const a = axes[axe];
|
|
if (a) {
|
|
a.sous_total_eur_ht = a.postes.reduce((s, p) => s + (p.cout_eur_ht || 0), 0);
|
|
}
|
|
}
|
|
let total = calcTotal();
|
|
let status = 'OK';
|
|
let message = '';
|
|
const log = debug ? [] : null;
|
|
// CAS A : overrun
|
|
if (total > budget_total * overrun_seuil) {
|
|
if (log)
|
|
log.push('CAS A : total ' + total + ' > seuil ' + Math.round(budget_total * overrun_seuil));
|
|
let iterations = 0;
|
|
while (total > budget_total * overrun_seuil && iterations < 80) {
|
|
iterations++;
|
|
// Correctif V3-1 (recalibrage) : downgrade "au plus pres du budget" au
|
|
// lieu du moins cher. Historiquement, le poste candidat sautait vers son
|
|
// alternative LA MOINS CHERE (ex : poele de masse -> rocket stove,
|
|
// -23 000 EUR la ou quelques centaines suffisaient a repasser sous le
|
|
// seuil). Desormais : parmi TOUS les downgrades possibles on choisit
|
|
// celui dont le total resultant est le plus proche du budget cible —
|
|
// evite a la fois le surcout et le sous-equipement massif.
|
|
const tous = getTousPostes().filter((p) => !p.force_par_couplage && !p.manuel);
|
|
const candidats = [];
|
|
for (const poste_candidat of tous) {
|
|
const poste_id_num = _posteIdNum(poste_candidat, options);
|
|
const alternatives = options.filter((o) => o.poste_id === poste_id_num && o.actif !== false && o.code !== poste_candidat.typologie_choisie);
|
|
const alts_scored = alternatives
|
|
.map((alt) => {
|
|
const dim = dimension_optimale(alt, inputs, contexte_derived, coefficients);
|
|
const cout = cout_typologie(alt, dim, inputs);
|
|
return { alt, dim, cout };
|
|
})
|
|
.filter((a) => a.cout < (poste_candidat.cout_eur_ht || 0));
|
|
for (const a of alts_scored) {
|
|
candidats.push({
|
|
poste: poste_candidat,
|
|
alt: a,
|
|
diff: (poste_candidat.cout_eur_ht || 0) - Math.round(a.cout),
|
|
});
|
|
}
|
|
}
|
|
let choix = null;
|
|
if (candidats.length > 0) {
|
|
// total resultant le plus proche du budget (tiebreak : pire score d'abord)
|
|
candidats.sort((a, b) => Math.abs(total - a.diff - budget_total) - Math.abs(total - b.diff - budget_total) ||
|
|
(a.poste.score_final || 0) - (b.poste.score_final || 0));
|
|
choix = candidats[0];
|
|
}
|
|
let downgraded = false;
|
|
if (choix) {
|
|
const poste_candidat = choix.poste;
|
|
const meilleur = choix.alt;
|
|
const diff = choix.diff;
|
|
const axe_poste = poste_candidat._axe;
|
|
const ax = axes[axe_poste];
|
|
const idx = ax.postes.findIndex((p) => p.typologie_choisie === poste_candidat.typologie_choisie);
|
|
if (idx >= 0) {
|
|
const p = ax.postes[idx];
|
|
ax.postes[idx] = Object.assign({}, p, {
|
|
typologie_choisie: meilleur.alt.code,
|
|
nom_affichage: meilleur.alt.nom,
|
|
description_pedago: meilleur.alt.description_pedago || '',
|
|
dimension: meilleur.dim || p.dimension,
|
|
cout_eur_ht: Math.round(meilleur.cout),
|
|
couplages_requis: parseJsonArr(meilleur.alt.couplages_requis),
|
|
couplages_exclus: parseJsonArr(meilleur.alt.couplages_exclus),
|
|
_downgraded: true,
|
|
});
|
|
recalcSousTotal(axe_poste);
|
|
total -= diff;
|
|
downgraded = true;
|
|
if (log)
|
|
log.push('Downgrade ' + poste_candidat.typologie_choisie + ' -> ' + meilleur.alt.code + ' -' + diff + 'EUR');
|
|
}
|
|
}
|
|
if (!downgraded) {
|
|
const optionnels = getTousPostes().filter((p) => !p.obligatoire && !p.force_par_couplage && !p.manuel);
|
|
optionnels.sort((a, b) => (a.score_final || 0) - (b.score_final || 0));
|
|
if (optionnels.length > 0) {
|
|
const pire = optionnels[0];
|
|
const axe_poste = pire._axe;
|
|
const ax = axes[axe_poste];
|
|
const idx = ax.postes.findIndex((p) => p.typologie_choisie === pire.typologie_choisie);
|
|
if (idx >= 0) {
|
|
total -= ax.postes[idx].cout_eur_ht || 0;
|
|
ax.postes.splice(idx, 1);
|
|
recalcSousTotal(axe_poste);
|
|
downgraded = true;
|
|
if (log)
|
|
log.push('Retrait optionnel ' + pire.typologie_choisie);
|
|
}
|
|
}
|
|
if (!downgraded)
|
|
break;
|
|
}
|
|
}
|
|
if (total > budget_total * overrun_seuil) {
|
|
status = 'BUDGET_INSUFFISANT';
|
|
message =
|
|
'Budget insuffisant pour équiper tous les postes obligatoires. Estimation minimale : ' +
|
|
Math.round(total).toLocaleString('fr-FR') +
|
|
' EUR. Votre budget : ' +
|
|
Math.round(budget_total).toLocaleString('fr-FR') +
|
|
' EUR.';
|
|
if (log)
|
|
log.push('BUDGET_INSUFFISANT — toutes alternatives épuisées');
|
|
}
|
|
}
|
|
// CAS B : underspend
|
|
else if (total < budget_total * underspend_seuil) {
|
|
if (log)
|
|
log.push('CAS B : total ' + total + ' < seuil ' + Math.round(budget_total * underspend_seuil));
|
|
// Axes explicitement désactivés par l'utilisateur (V3 toggles d'axes) :
|
|
// ne jamais y réinjecter de postes optionnels. Marqueur posé par
|
|
// axes-actifs.ts (configPourAxes). Absent = comportement historique inchangé.
|
|
const axes_desactives = new Set(config._axes_desactives || []);
|
|
// Identifier les postes BDD non encore sélectionnés (optionnels manquants)
|
|
const codes_selectionnes = new Set(getTousPostes().map((p) => p.typologie_choisie));
|
|
// Construire mapping poste_code → axe via postes BDD
|
|
const posteIdToAxe = {};
|
|
if (postes) {
|
|
for (const p of postes) {
|
|
const axe_norm = (p.axe === 'alimentation' ? 'alim' : p.axe);
|
|
posteIdToAxe[p.Id] = axe_norm;
|
|
}
|
|
}
|
|
let iterations = 0;
|
|
while (total < budget_total * underspend_seuil && iterations < 80) {
|
|
iterations++;
|
|
let upgraded = false;
|
|
// Stratégie 1 : upgrader des typologies existantes
|
|
const tous = getTousPostes().filter((p) => !p.manuel);
|
|
tous.sort((a, b) => (b.score_adequation || 0) - (a.score_adequation || 0));
|
|
for (const poste_candidat of tous) {
|
|
const poste_id_num = _posteIdNum(poste_candidat, options);
|
|
const alternatives = options.filter((o) => o.poste_id === poste_id_num && o.actif !== false && o.code !== poste_candidat.typologie_choisie);
|
|
const alts_scored = alternatives
|
|
.map((alt) => {
|
|
const dim = dimension_optimale(alt, inputs, contexte_derived, coefficients);
|
|
const cout = cout_typologie(alt, dim, inputs);
|
|
// Estimer le coût des couplages requis non encore sélectionnés
|
|
const codes_sel = new Set(getTousPostes().map((p) => p.typologie_choisie));
|
|
const alias = (config && config.couplages_alias) || {};
|
|
const requis_list = parseJsonArr(alt.couplages_requis);
|
|
let cout_couplages_requis = 0;
|
|
for (const r of requis_list) {
|
|
const r_resolved = alias[r] || r;
|
|
if (!codes_sel.has(r) && !codes_sel.has(r_resolved)) {
|
|
const typo_req = options.find((o) => o.code === r_resolved || o.code === r);
|
|
if (typo_req) {
|
|
const dim_req = dimension_optimale(typo_req, inputs, contexte_derived, coefficients);
|
|
cout_couplages_requis += cout_typologie(typo_req, dim_req, inputs);
|
|
}
|
|
}
|
|
}
|
|
return { alt, dim, cout, cout_couplages_requis };
|
|
// Ne conserver que les upgrades qui ne dépassent pas le budget global
|
|
// en incluant le coût estimé des couplages requis non encore satisfaits
|
|
})
|
|
.filter((a) => {
|
|
const diff_potential = a.cout + a.cout_couplages_requis - (poste_candidat.cout_eur_ht || 0);
|
|
return a.cout > (poste_candidat.cout_eur_ht || 0) && total + diff_potential <= budget_total * overrun_seuil;
|
|
});
|
|
if (alts_scored.length > 0) {
|
|
alts_scored.sort((a, b) => a.cout - b.cout);
|
|
const meilleur = alts_scored[0];
|
|
const diff = Math.round(meilleur.cout) - (poste_candidat.cout_eur_ht || 0);
|
|
const axe_poste = poste_candidat._axe;
|
|
const ax = axes[axe_poste];
|
|
const idx = ax.postes.findIndex((p) => p.typologie_choisie === poste_candidat.typologie_choisie);
|
|
if (idx >= 0) {
|
|
const p = ax.postes[idx];
|
|
ax.postes[idx] = Object.assign({}, p, {
|
|
typologie_choisie: meilleur.alt.code,
|
|
nom_affichage: meilleur.alt.nom,
|
|
description_pedago: meilleur.alt.description_pedago || '',
|
|
dimension: meilleur.dim || p.dimension,
|
|
cout_eur_ht: Math.round(meilleur.cout),
|
|
// IMPORTANT: mettre à jour les couplages de la nouvelle typo (pas hériter ancienne)
|
|
couplages_requis: parseJsonArr(meilleur.alt.couplages_requis),
|
|
couplages_exclus: parseJsonArr(meilleur.alt.couplages_exclus),
|
|
_upgraded: true,
|
|
});
|
|
recalcSousTotal(axe_poste);
|
|
total += diff;
|
|
upgraded = true;
|
|
if (log)
|
|
log.push('Upgrade ' + poste_candidat.typologie_choisie + ' -> ' + meilleur.alt.code + ' +' + diff + 'EUR');
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// Stratégie 2 : ajouter des postes optionnels non encore sélectionnés
|
|
if (!upgraded) {
|
|
const postes_manquants = options.filter((o) => {
|
|
if (o.actif === false || codes_selectionnes.has(o.code))
|
|
return false;
|
|
// Ne pas ajouter de poste appartenant à un axe désactivé (V3)
|
|
if (axes_desactives.size > 0 && posteIdToAxe[o.poste_id] && axes_desactives.has(posteIdToAxe[o.poste_id]))
|
|
return false;
|
|
return true;
|
|
});
|
|
// Trier par rapport score/cout et taille raisonnable
|
|
const candidats_add = postes_manquants
|
|
.map((o) => {
|
|
const dim = dimension_optimale(o, inputs, contexte_derived, coefficients);
|
|
const cout = cout_typologie(o, dim, inputs);
|
|
return { opt: o, dim, cout };
|
|
})
|
|
.filter((a) => a.cout > 0 && total + a.cout <= budget_total * overrun_seuil);
|
|
candidats_add.sort((a, b) => a.cout - b.cout);
|
|
if (candidats_add.length > 0) {
|
|
const best = candidats_add[candidats_add.length - 1]; // le plus cher qui rentre
|
|
const axe_target = posteIdToAxe[best.opt.poste_id] || 'energie';
|
|
const ax = axes[axe_target];
|
|
if (ax) {
|
|
const valeur_dim = best.dim ? best.dim.valeur : best.opt.quantite_default || 1;
|
|
const unite_dim = best.dim ? best.dim.unite : best.opt.unite || '';
|
|
ax.postes.push({
|
|
poste_id: best.opt.poste_id,
|
|
poste_nom: best.opt.nom,
|
|
obligatoire: false,
|
|
budget_alloue_poste: Math.round(best.cout),
|
|
typologie_choisie: best.opt.code,
|
|
nom_affichage: best.opt.nom,
|
|
description_pedago: best.opt.description_pedago || '',
|
|
dimension: { valeur: valeur_dim, unite: unite_dim },
|
|
cout_eur_ht: Math.round(best.cout),
|
|
score_adequation: 0.6,
|
|
score_final: 0,
|
|
alternatives: [],
|
|
notes_techniques: best.opt.notes_techniques || '',
|
|
flag_diy: best.opt.flag_diy || false,
|
|
complexite_pose: best.opt.complexite_pose || 2,
|
|
couplages_requis: [],
|
|
couplages_exclus: [],
|
|
manuel: false,
|
|
_added_cas_b: true,
|
|
});
|
|
ax.sous_total_eur_ht += Math.round(best.cout);
|
|
total += Math.round(best.cout);
|
|
codes_selectionnes.add(best.opt.code);
|
|
upgraded = true;
|
|
if (log)
|
|
log.push('Ajout optionnel ' + best.opt.code + ' +' + Math.round(best.cout) + 'EUR');
|
|
}
|
|
}
|
|
}
|
|
if (!upgraded) {
|
|
status = 'AJOUTER_OPTIONNEL';
|
|
message = 'Budget non consommé (' + Math.round(budget_total - total).toLocaleString('fr-FR') + ' EUR disponibles). Postes optionnels suggérés non inclus.';
|
|
if (log)
|
|
log.push('AJOUTER_OPTIONNEL — budget non consommable');
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
total = calcTotal();
|
|
return {
|
|
axes,
|
|
total: Math.round(total),
|
|
status,
|
|
message,
|
|
couplages_appliques: apres_couplages.couplages_appliques ?? [],
|
|
debug_reajust: log,
|
|
};
|
|
}
|
|
function _posteIdNum(poste_result, options) {
|
|
const typo = options.find((o) => o.code === poste_result.typologie_choisie);
|
|
return typo ? typo.poste_id : -1;
|
|
}
|