LOT P1.0 : paquet @transformer/moteurs v0.1.0

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
This commit is contained in:
Jules
2026-09-28 17:06:49 +02:00
co-authored by Claude Fable 5.1
commit 69809df5d1
123 changed files with 13225 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
// 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;
}