Files
moteurs/dist/autonomie/noyau/couplages.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

194 lines
9.3 KiB
JavaScript

// couplages.ts — Étape 4 : application des couplages contraints
// Spec: V2-algo-spec.md §Étape 4 — origine calculs/src/lib/autonomie/couplages.js.
// Corrections LOT P1.0 : `reglages` en paramètre (plus de getConfig()) ; la
// sélection reçue n'est PLUS mutée (copie des axes et de leurs postes, le
// résultat est renvoyé dans `axes`). Tous les appelants d'origine lisaient
// déjà le retour, le comportement est identique.
//
// Note (origine, conservée) : le marqueur `_conflit_avec` de la passe 2 est
// posé sur des copies de postes et n'atteint jamais la sortie ; le
// « downgrade planifié » n'a donc jamais lieu. À corriger en patch, avec test.
//
// Correctif v0.1.1 (SPEC menu §12 n° 2 et 5) : une ligne forcée que plus
// aucune ligne ne requiert est RETIRÉE (passe 0). À l'origine, un accessoire
// forcé (conduit, ballon tampon, filière d'épuration) survivait au retrait ou
// au downgrade de son principal, verrouillé pour toujours par
// `force_par_couplage` — d'où des conduits sans poêle et des planchers de
// coût infranchissables (statut INSUFFISANT à 6 000 € mais OK à 3 000 €).
import { cout_typologie, dimension_optimale } from './dimension.js';
function copieSelection(selection) {
const copie = {};
for (const axe of Object.keys(selection)) {
const a = selection[axe];
if (!a)
continue;
const ax = { ...a, postes: a.postes.map((p) => ({ ...p })) };
copie[axe] = ax;
}
return copie;
}
const AXES_LIST = ['eau', 'energie', 'alim'];
/** Codes requis (alias résolus) par les lignes NON forcées de la sélection. */
export function codesRequisPar(axes, alias) {
const requis = new Set();
for (const axe of AXES_LIST) {
for (const p of axes[axe]?.postes ?? []) {
if (p.force_par_couplage)
continue;
for (const r of p.couplages_requis || [])
requis.add(alias[r] || r);
}
}
return requis;
}
/** Codes des lignes NON forcées (les choix de postes, par opposition aux accessoires forcés). */
export function codesChoisis(axes) {
const codes = new Set();
for (const axe of AXES_LIST)
for (const p of axes[axe]?.postes ?? [])
if (!p.force_par_couplage)
codes.add(p.typologie_choisie);
return codes;
}
/**
* Retire EN PLACE les lignes forcées devenues inutiles et recalcule les
* sous-totaux : celles que plus aucune ligne ne requiert (orphelines), et
* celles dont la typologie est déjà le choix d'un poste (le requis est
* satisfait par ce choix, la ligne forcée est absorbée). Rend le journal.
*/
export function retireForcesOrphelins(axes, alias) {
const requis = codesRequisPar(axes, alias);
const choisis = codesChoisis(axes);
const log = [];
for (const axe of AXES_LIST) {
const a = axes[axe];
if (!a)
continue;
const gardes = [];
for (const p of a.postes) {
if (p.force_par_couplage && !requis.has(p.typologie_choisie)) {
log.push('RETIRE "' + p.typologie_choisie + '" (plus requis par aucun poste) -' + (p.cout_eur_ht || 0) + 'EUR dans axe ' + axe);
}
else if (p.force_par_couplage && choisis.has(p.typologie_choisie)) {
log.push('ABSORBE "' + p.typologie_choisie + '" (déjà le choix d\'un poste) -' + (p.cout_eur_ht || 0) + 'EUR dans axe ' + axe);
}
else {
gardes.push(p);
}
}
if (gardes.length !== a.postes.length) {
a.postes = gardes;
a.sous_total_eur_ht = gardes.reduce((s, p) => s + (p.cout_eur_ht || 0), 0);
}
}
return log;
}
/**
* Post-traitement déterministe des couplages.
*
* Gestion alias : algo-config.json § couplages_alias permet de mapper
* des codes legacy (ex: stherm_ballon_tampon) vers les codes BDD réels (th_ballon_500).
*/
export function appliqueCouplages(selection_recue, options, postes, reglages, _debug = false) {
const alias = reglages.config.couplages_alias || {};
const selection = copieSelection(selection_recue);
// Construire un mapping poste_id (num) → axe via postes BDD
const posteIdToAxe = {};
if (postes) {
for (const p of postes) {
// Normaliser axe : "alimentation" → "alim"
const axe_norm = (p.axe === 'alimentation' ? 'alim' : p.axe);
posteIdToAxe[p.Id] = axe_norm;
}
}
function resolveCode(code) {
return alias[code] || code;
}
const axes_list = AXES_LIST;
// Passe 0 (v0.1.1) : lâcher les accessoires forcés dont le principal a disparu.
const couplages_log = retireForcesOrphelins(selection, alias);
const tous_postes = axes_list.flatMap((axe) => (selection[axe] && selection[axe].postes ? selection[axe].postes : []).map((p) => Object.assign({}, p, { _axe: axe })));
const codes_selectionnes = new Set(tous_postes.map((p) => p.typologie_choisie));
// Passe 1 : couplages_requis
for (const poste of tous_postes) {
const requis_list = poste.couplages_requis || [];
for (const requis_raw of requis_list) {
const requis = resolveCode(requis_raw);
if (!codes_selectionnes.has(requis)) {
// Chercher la typo en BDD (code exact ou alias original)
let typo_requise = options.find((o) => o.code === requis);
if (!typo_requise && requis !== requis_raw) {
typo_requise = options.find((o) => o.code === requis_raw);
}
if (typo_requise) {
// Trouver l'axe via mapping poste_id → axe
const axe_cible = posteIdToAxe[typo_requise.poste_id] || trouverAxeParCode(typo_requise.code);
const cible = axe_cible ? selection[axe_cible] : undefined;
if (axe_cible && cible) {
const dim = dimension_optimale(typo_requise, {}, {}, reglages.coefficients);
const cout = cout_typologie(typo_requise, dim, {});
const cout_force = Math.round(cout || 0);
const unite_dim = dim ? dim.unite : typo_requise.unite || '';
const valeur_dim = dim ? dim.valeur : typo_requise.quantite_default || 1;
cible.postes.push({
poste_id: String(typo_requise.poste_id) + '_couplage',
poste_nom: 'Couplage requis (' + typo_requise.nom + ')',
obligatoire: true,
budget_alloue_poste: cout_force,
typologie_choisie: typo_requise.code,
nom_affichage: typo_requise.nom,
description_pedago: typo_requise.description_pedago || '',
dimension: { valeur: valeur_dim, unite: unite_dim },
cout_eur_ht: cout_force,
score_adequation: 0.8,
score_final: 0,
alternatives: [],
notes_techniques: typo_requise.notes_techniques || '',
flag_diy: typo_requise.flag_diy || false,
complexite_pose: typo_requise.complexite_pose || 2,
couplages_requis: [],
couplages_exclus: [],
manuel: false,
force_par_couplage: true,
});
cible.sous_total_eur_ht += cout_force;
codes_selectionnes.add(typo_requise.code);
couplages_log.push('FORCE "' + typo_requise.code + '" (requis par "' + poste.typologie_choisie + '") +' + cout_force + 'EUR dans axe ' + axe_cible);
}
else {
couplages_log.push('WARN axe non trouve pour "' + requis + '" (requis par "' + poste.typologie_choisie + '")');
}
}
else {
couplages_log.push('MANQUANT "' + requis + '" (requis par "' + poste.typologie_choisie + '") — code BDD non trouve');
}
}
}
}
// Passe 2 : couplages_exclus
for (const poste of tous_postes) {
const exclus_list = poste.couplages_exclus || [];
for (const exclu_raw of exclus_list) {
const exclu = resolveCode(exclu_raw);
if (codes_selectionnes.has(exclu)) {
const poste_conflit = tous_postes.find((p) => p.typologie_choisie === exclu);
if (poste_conflit && !poste_conflit.force_par_couplage) {
poste_conflit._conflit_avec = poste.typologie_choisie;
couplages_log.push('CONFLIT "' + exclu + '" avec "' + poste.typologie_choisie + '" — downgrade planifié');
}
}
}
}
return { axes: selection, couplages_appliques: couplages_log };
}
// Fallback heuristique si poste_id absent du mapping
function trouverAxeParCode(code) {
if (/captage|stockage|filtrat|distrib|eg_|stherm_ballon/.test(code))
return 'eau';
if (/^th_|^pv_|^bois_|^pil_|^emet_|ballon_tampon|conduit_fumee/.test(code))
return 'energie';
if (/^etude_|^haute_|^moy_|^basse_/.test(code))
return 'alim';
return 'energie';
}