// 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'; }