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
149 lines
7.3 KiB
JavaScript
149 lines
7.3 KiB
JavaScript
// selectionne-poste.ts — Étape 3 : sélection des typologies par poste
|
||
// Spec: V2-algo-spec.md §Étape 3 + V2-algo-spec-FONCTIONS.md §3
|
||
// Origine calculs/src/lib/autonomie/selectionne-poste.js.
|
||
// Correction LOT P1.0 : `reglages` en paramètre (plus de getConfig()) ; le
|
||
// console.warn de debug est versé dans `debug_log` du résultat.
|
||
import { adequation_contexte } from './adequation.js';
|
||
import { dimension_optimale, cout_typologie } from './dimension.js';
|
||
import { parseJsonArr } from './types.js';
|
||
/**
|
||
* Sélectionne les meilleures typologies pour un axe donné.
|
||
*
|
||
* @param budget_axe — EUR alloué à cet axe
|
||
* @param axe — "eau" | "energie" | "alim"
|
||
*/
|
||
export function selectionnePoste(budget_axe, inputs, contexte_derived, options, postes, axe, reglages, debug = false) {
|
||
const config = reglages.config;
|
||
const bonus_malus = config.bonus_malus || {};
|
||
const postes_poids_cfg = config.postes_poids || {};
|
||
const tolerances = config.tolerances || {};
|
||
const tolerance_pct = (tolerances.tolerance_par_poste_pct || 20) / 100;
|
||
const debug_log = debug ? [] : undefined;
|
||
// Normaliser le code axe pour matcher la BDD ("alimentation" dans postes BDD)
|
||
const axe_bdd = axe === 'alim' ? 'alimentation' : axe;
|
||
// Postes de l'axe, triés par ordre d'affichage
|
||
const postes_axe = postes.filter((p) => p.axe === axe_bdd && p.actif !== false).sort((a, b) => (a.ordre || 0) - (b.ordre || 0));
|
||
if (postes_axe.length === 0) {
|
||
return { postes: [], sous_total_eur_ht: 0, budget_alloue: budget_axe, poids: 0 };
|
||
}
|
||
// Enrichir les postes avec poids depuis config (fallback 50)
|
||
const postes_enrichis = postes_axe.map((p) => ({
|
||
...p,
|
||
poids_effectif: postes_poids_cfg[p.code] && postes_poids_cfg[p.code].poids != null ? postes_poids_cfg[p.code].poids : 50,
|
||
}));
|
||
// Poids total pour répartition budget
|
||
const poids_total = postes_enrichis.reduce((s, p) => s + p.poids_effectif, 0);
|
||
const resultats_postes = [];
|
||
for (const poste of postes_enrichis) {
|
||
// Budget alloué à ce poste (proportionnel au poids)
|
||
const budget_poste = poids_total > 0 ? (budget_axe * poste.poids_effectif) / poids_total : budget_axe / postes_enrichis.length;
|
||
// Si alim avec budget 0 (parcelle trop petite) → skip
|
||
if (budget_axe === 0)
|
||
continue;
|
||
// Typologies disponibles pour ce poste
|
||
const typos_poste = options.filter((o) => o.poste_id === poste.Id && o.actif !== false);
|
||
if (typos_poste.length === 0)
|
||
continue;
|
||
// Filtrer par accessibilité contexte (§3.4)
|
||
let typos_filtrees = typos_poste.filter((typo) => {
|
||
// Exclure complexité 3 si temps_dispo = 1 (sauf si c'est la seule option)
|
||
if (inputs.temps_dispo === 1 && typo.complexite_pose === 3)
|
||
return false;
|
||
// Exclure PV sur toit nord
|
||
if (poste.code === 'energie_pv' && inputs.orientation_toit === 'nord')
|
||
return false;
|
||
return true;
|
||
});
|
||
// Garde-fou : si trop restrictif, revenir à toutes
|
||
if (typos_filtrees.length === 0)
|
||
typos_filtrees = typos_poste;
|
||
// Scorer chaque typologie (§3.5)
|
||
const scored = typos_filtrees.map((typo) => {
|
||
const dim = dimension_optimale(typo, inputs, contexte_derived, reglages.coefficients);
|
||
const cout = cout_typologie(typo, dim, inputs);
|
||
const adq_result = debug
|
||
? adequation_contexte(typo, inputs, poste, contexte_derived, reglages, [], true)
|
||
: adequation_contexte(typo, inputs, poste, contexte_derived, reglages, [], false);
|
||
const score_adequation = debug && adq_result && typeof adq_result === 'object' && 'score' in adq_result
|
||
? adq_result.score
|
||
: typeof adq_result === 'number'
|
||
? adq_result
|
||
: 0.5;
|
||
// Boost DIY temps=3
|
||
let score_utilite = (poste.poids_effectif || 50) * score_adequation;
|
||
if (inputs.temps_dispo === 3 && typo.flag_diy) {
|
||
score_utilite *= bonus_malus.boost_diy_temps3 || 1.3;
|
||
}
|
||
const score_final = cout > 0 ? score_utilite / cout : 0;
|
||
return {
|
||
typo,
|
||
dim,
|
||
cout: Math.round(cout),
|
||
score_adequation,
|
||
score_utilite,
|
||
score_final,
|
||
debug_adq: debug && typeof adq_result === 'object' ? adq_result : undefined,
|
||
};
|
||
});
|
||
// Trier par score_final décroissant
|
||
scored.sort((a, b) => b.score_final - a.score_final);
|
||
// §3.6 : sélectionner le meilleur dont cout <= budget_poste × (1 + tolerance)
|
||
// Ne PAS prendre un poste si le coût dépasse largement le budget (anti-overrun)
|
||
const seuil_strict = budget_poste * (1 + tolerance_pct);
|
||
// Seuil assoupli : si aucun poste ne rentre, prendre le moins cher
|
||
let choix = scored.find((s) => s.cout <= seuil_strict);
|
||
if (!choix && scored.length > 0) {
|
||
// Pas de typo dans le budget : prendre la moins chère
|
||
const par_cout = [...scored].sort((a, b) => a.cout - b.cout);
|
||
choix = par_cout[0];
|
||
if (debug_log)
|
||
debug_log.push('[selectionnePoste] Aucune typo dans budget pour poste ' + poste.code + ' (budget=' + Math.round(budget_poste) + ', min cout=' + choix.cout + ')');
|
||
}
|
||
if (!choix)
|
||
continue;
|
||
// Alternatives (2-4 autres options scorées)
|
||
const alternatives = scored
|
||
.filter((s) => s.typo.code !== choix.typo.code)
|
||
.slice(0, 3)
|
||
.map((s) => ({
|
||
code: s.typo.code,
|
||
nom_affichage: s.typo.nom,
|
||
cout_eur_ht: s.cout,
|
||
score_final: +s.score_final.toFixed(4),
|
||
dimension: s.dim ? { valeur: s.dim.valeur, unite: s.dim.unite } : null,
|
||
}));
|
||
const couplages_requis = parseJsonArr(choix.typo.couplages_requis);
|
||
const couplages_exclus = parseJsonArr(choix.typo.couplages_exclus);
|
||
resultats_postes.push({
|
||
poste_id: poste.code,
|
||
poste_nom: poste.poste,
|
||
obligatoire: poste.obligatoire || false,
|
||
budget_alloue_poste: Math.round(budget_poste),
|
||
typologie_choisie: choix.typo.code,
|
||
nom_affichage: choix.typo.nom,
|
||
description_pedago: choix.typo.description_pedago || '',
|
||
dimension: choix.dim || { valeur: choix.typo.quantite_default || 1, unite: choix.typo.unite || '' },
|
||
cout_eur_ht: choix.cout,
|
||
score_adequation: +choix.score_adequation.toFixed(3),
|
||
score_final: +choix.score_final.toFixed(4),
|
||
alternatives,
|
||
notes_techniques: choix.typo.notes_techniques || '',
|
||
flag_diy: choix.typo.flag_diy || false,
|
||
complexite_pose: choix.typo.complexite_pose || 2,
|
||
couplages_requis,
|
||
couplages_exclus,
|
||
manuel: false,
|
||
});
|
||
}
|
||
const sous_total = resultats_postes.reduce((s, p) => s + p.cout_eur_ht, 0);
|
||
const resultat = {
|
||
postes: resultats_postes,
|
||
sous_total_eur_ht: sous_total,
|
||
budget_alloue: budget_axe,
|
||
poids: 0, // rempli par l'orchestrateur
|
||
};
|
||
if (debug_log && debug_log.length)
|
||
resultat.debug_log = debug_log;
|
||
return resultat;
|
||
}
|