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
This commit is contained in:
Jules
2026-10-05 16:39:16 +02:00
co-authored by Claude Fable 5.1
parent 91c755f884
commit 8465bf7204
10 changed files with 213 additions and 40 deletions
+29 -11
View File
@@ -55,22 +55,40 @@ export function finaliseSelection(selection, budget_total, options, postes, inpu
}; };
let status_final = final_pre.status; let status_final = final_pre.status;
let message_final = final_pre.message; let message_final = final_pre.message;
for (let passe = 0; passe < 2; passe++) { // Correctif v0.1.1 (SPEC menu §12 n° 2 et 3) : `reajusteBudget` renvoie en
// écho le journal qu'il reçoit ; l'origine le ré-ajoutait à chaque tour (chaque
// FORCE apparaissait deux à quatre fois). Le journal ne s'enrichit que des
// lignes nouvelles de `appliqueCouplages`. La boucle ne se contentait que du
// dépassement : un réajustement qui lâche des accessoires forcés (principal
// retiré ou changé) laissait le total loin sous le budget sans rien recombler.
// Désormais : couplages → dans la bande [−underspend, +overrun] ? fin ;
// sinon réajuste (CAS A ou B) → couplages…, trois tours au plus, et toujours
// une passe couplages en dernier. Si le dernier forçage refranchit le seuil,
// le statut le dit.
const tol_under = (config.tolerances && config.tolerances.budget_underspend_pct) || 15;
const seuil_under = budget_total * (1 - tol_under / 100);
const dans_la_bande = (t) => t <= seuil_over && t >= seuil_under;
const prend = (avec) => {
etat = { axes: avec.axes, couplages_appliques: [...etat.couplages_appliques, ...(avec.couplages_appliques || [])] };
};
for (let passe = 0; passe < 3; passe++) {
// IMPORTANT: appliqueCouplages attend selection = { eau, energie, alim } directement // IMPORTANT: appliqueCouplages attend selection = { eau, energie, alim } directement
const avec = appliqueCouplages(etat.axes, options, postes, reglages, false); prend(appliqueCouplages(etat.axes, options, postes, reglages, false));
etat = { if (dans_la_bande(sommeAxes(etat.axes)))
axes: avec.axes,
couplages_appliques: [...etat.couplages_appliques, ...(avec.couplages_appliques || [])],
};
if (sommeAxes(etat.axes) <= seuil_over)
break; break;
const re = reajusteBudget(etat, budget_total, options, postes, inputs, contexte, reglages, false); const re = reajusteBudget(etat, budget_total, options, postes, inputs, contexte, reglages, false);
etat = { etat = { axes: re.axes, couplages_appliques: etat.couplages_appliques };
axes: re.axes,
couplages_appliques: [...etat.couplages_appliques, ...(re.couplages_appliques || [])],
};
status_final = re.status; status_final = re.status;
message_final = re.message; message_final = re.message;
// Bloqué (rien à ajouter, ou plancher atteint) ou dernier tour : une passe couplages et fin
if (passe === 2 || re.status !== 'OK') {
prend(appliqueCouplages(etat.axes, options, postes, reglages, false));
break;
}
}
if (sommeAxes(etat.axes) > seuil_over && status_final === 'OK') {
status_final = 'BUDGET_INSUFFISANT';
message_final = 'Budget insuffisant : les couplages requis portent le total à ' + sommeAxes(etat.axes) + ' EUR pour un budget de ' + Math.round(budget_total) + ' EUR.';
} }
return { return {
axes: etat.axes, axes: etat.axes,
+11
View File
@@ -3,6 +3,17 @@ export interface ResultatCouplages {
axes: Selection; axes: Selection;
couplages_appliques: string[]; couplages_appliques: string[];
} }
/** Codes requis (alias résolus) par les lignes NON forcées de la sélection. */
export declare function codesRequisPar(axes: Selection, alias: Record<string, string>): Set<string>;
/** Codes des lignes NON forcées (les choix de postes, par opposition aux accessoires forcés). */
export declare function codesChoisis(axes: Selection): Set<string>;
/**
* 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 declare function retireForcesOrphelins(axes: Selection, alias: Record<string, string>): string[];
/** /**
* Post-traitement déterministe des couplages. * Post-traitement déterministe des couplages.
* *
+66 -2
View File
@@ -8,6 +8,13 @@
// Note (origine, conservée) : le marqueur `_conflit_avec` de la passe 2 est // 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 // 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. // « 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'; import { cout_typologie, dimension_optimale } from './dimension.js';
function copieSelection(selection) { function copieSelection(selection) {
const copie = {}; const copie = {};
@@ -20,6 +27,62 @@ function copieSelection(selection) {
} }
return copie; 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. * Post-traitement déterministe des couplages.
* *
@@ -41,10 +104,11 @@ export function appliqueCouplages(selection_recue, options, postes, reglages, _d
function resolveCode(code) { function resolveCode(code) {
return alias[code] || code; return alias[code] || code;
} }
const axes_list = ['eau', 'energie', 'alim']; 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 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)); const codes_selectionnes = new Set(tous_postes.map((p) => p.typologie_choisie));
const couplages_log = [];
// Passe 1 : couplages_requis // Passe 1 : couplages_requis
for (const poste of tous_postes) { for (const poste of tous_postes) {
const requis_list = poste.couplages_requis || []; const requis_list = poste.couplages_requis || [];
+3 -2
View File
@@ -1,4 +1,5 @@
export { selectionner } from './algo.js'; export { selectionner, finaliseSelection } from './algo.js';
export type { SelectionFinalisee } from './algo.js';
export { dimension_optimale, cout_typologie } from './dimension.js'; export { dimension_optimale, cout_typologie } from './dimension.js';
export { adequation_contexte } from './adequation.js'; export { adequation_contexte } from './adequation.js';
export type { AdequationDebug } from './adequation.js'; export type { AdequationDebug } from './adequation.js';
@@ -6,7 +7,7 @@ export { pondereAxes } from './pondere-axes.js';
export type { PoidsDebug } from './pondere-axes.js'; export type { PoidsDebug } from './pondere-axes.js';
export { alloueBudget } from './alloue-budget.js'; export { alloueBudget } from './alloue-budget.js';
export { selectionnePoste } from './selectionne-poste.js'; export { selectionnePoste } from './selectionne-poste.js';
export { appliqueCouplages } from './couplages.js'; export { appliqueCouplages, codesChoisis, codesRequisPar, retireForcesOrphelins } from './couplages.js';
export type { ResultatCouplages } from './couplages.js'; export type { ResultatCouplages } from './couplages.js';
export { reajusteBudget } from './reajuste.js'; export { reajusteBudget } from './reajuste.js';
export type { EntreeReajuste, ResultatReajuste } from './reajuste.js'; export type { EntreeReajuste, ResultatReajuste } from './reajuste.js';
+2 -2
View File
@@ -1,12 +1,12 @@
// @transformer/moteurs/autonomie/noyau — le cœur V2 du simulateur autonomie, // @transformer/moteurs/autonomie/noyau — le cœur V2 du simulateur autonomie,
// pipeline complet (celui de calculs.trans-former.fr), sans singleton ni I/O. // pipeline complet (celui de calculs.trans-former.fr), sans singleton ni I/O.
export { selectionner } from './algo.js'; export { selectionner, finaliseSelection } from './algo.js';
export { dimension_optimale, cout_typologie } from './dimension.js'; export { dimension_optimale, cout_typologie } from './dimension.js';
export { adequation_contexte } from './adequation.js'; export { adequation_contexte } from './adequation.js';
export { pondereAxes } from './pondere-axes.js'; export { pondereAxes } from './pondere-axes.js';
export { alloueBudget } from './alloue-budget.js'; export { alloueBudget } from './alloue-budget.js';
export { selectionnePoste } from './selectionne-poste.js'; export { selectionnePoste } from './selectionne-poste.js';
export { appliqueCouplages } from './couplages.js'; export { appliqueCouplages, codesChoisis, codesRequisPar, retireForcesOrphelins } from './couplages.js';
export { reajusteBudget } from './reajuste.js'; export { reajusteBudget } from './reajuste.js';
export { configPourAxes, auMoinsUnAxe } from './axes-actifs.js'; export { configPourAxes, auMoinsUnAxe } from './axes-actifs.js';
export { clamp01, clamp, roundToStep, ZONE_BIOGEO_MAP, zoneRtToClass } from './calibrage.js'; export { clamp01, clamp, roundToStep, ZONE_BIOGEO_MAP, zoneRtToClass } from './calibrage.js';
+98 -19
View File
@@ -7,6 +7,21 @@
// Note (origine, conservée) : les messages utilisent toLocaleString('fr-FR') // Note (origine, conservée) : les messages utilisent toLocaleString('fr-FR')
// (Intl). Texte de présentation, non assert par les tests ; à sortir du noyau // (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. // en patch si l'octet-pour-octet inter-environnements devient un besoin.
//
// Correctifs v0.1.1 (SPEC menu §12 n° 1, 2, 3, 5, 7) :
// - CAS A : un downgrade ne cible jamais une typologie déjà présente (le conduit
// forcé se retrouvait en double) ; après chaque retrait ou downgrade, les
// accessoires forcés que plus rien ne requiert sont lâchés (le plancher de
// coût n'est plus verrouillé par des couplages orphelins).
// - CAS B, stratégie 1 : une ligne forcée n'est jamais « upgradée » (elle est
// une contrainte, pas un choix ; l'origine la triait en tête avec son score
// 0,8 et la transformait, puis la passe suivante re-forçait l'original) ;
// la cible d'un upgrade n'est jamais une typologie déjà présente.
// - CAS B, stratégie 2 : un poste = une ligne. L'origine ajoutait une 2e, 3e…
// option au même poste (jusqu'à cinq `basse_planche_bec`), parce qu'elle
// testait le code et pas le poste, sur une liste figée avant la boucle.
// La quantité d'un poste est portée par `dimension`, pas par des lignes.
import { codesChoisis, retireForcesOrphelins } from './couplages.js';
import { cout_typologie, dimension_optimale } from './dimension.js'; import { cout_typologie, dimension_optimale } from './dimension.js';
import { parseJsonArr } from './types.js'; import { parseJsonArr } from './types.js';
export function reajusteBudget(apres_couplages, budget_total, options, postes, inputs, contexte_derived, reglages, debug = false) { export function reajusteBudget(apres_couplages, budget_total, options, postes, inputs, contexte_derived, reglages, debug = false) {
@@ -15,11 +30,46 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
const tolerances = config.tolerances || {}; const tolerances = config.tolerances || {};
const overrun_seuil = 1 + (tolerances.budget_overrun_pct || 5) / 100; const overrun_seuil = 1 + (tolerances.budget_overrun_pct || 5) / 100;
const underspend_seuil = 1 - (tolerances.budget_underspend_pct || 15) / 100; const underspend_seuil = 1 - (tolerances.budget_underspend_pct || 15) / 100;
const alias = config.couplages_alias || {};
const axes = apres_couplages.axes; const axes = apres_couplages.axes;
const axes_list = ['eau', 'energie', 'alim']; const axes_list = ['eau', 'energie', 'alim'];
function getTousPostes() { function getTousPostes() {
return axes_list.flatMap((axe) => (axes[axe] && axes[axe].postes ? axes[axe].postes : []).map((p) => Object.assign({}, p, { _axe: axe }))); return axes_list.flatMap((axe) => (axes[axe] && axes[axe].postes ? axes[axe].postes : []).map((p) => Object.assign({}, p, { _axe: axe })));
} }
/** Lâche les accessoires forcés orphelins ou absorbés ; vrai si quelque chose a bougé. */
function lacheOrphelins() {
const retires = retireForcesOrphelins(axes, alias);
if (retires.length && log)
log.push(...retires);
return retires.length > 0;
}
/**
* Coût des lignes forcées que lâcherait le passage de `ligne` à la typologie
* `nouveau` (null = retrait de la ligne) : ses requis que plus personne ne
* demande, et la ligne forcée que le nouveau choix absorbe. Sert aux
* arbitrages (CAS A « au plus près du budget », CAS B « tient dans le seuil »)
* pour que le total prévu soit celui que la passe couplages rendra.
*/
function coutLibere(ligne, nouveau, instantane) {
const requis_autres = new Set();
for (const p of instantane) {
if (p.force_par_couplage || p === ligne || (p.typologie_choisie === ligne.typologie_choisie && p._axe === ligne._axe))
continue;
for (const r of p.couplages_requis || [])
requis_autres.add(alias[r] || r);
}
if (nouveau)
for (const r of parseJsonArr(nouveau.couplages_requis))
requis_autres.add(alias[r] || r);
let libere = 0;
for (const p of instantane) {
if (!p.force_par_couplage)
continue;
if (!requis_autres.has(p.typologie_choisie) || (nouveau && p.typologie_choisie === nouveau.code))
libere += p.cout_eur_ht || 0;
}
return libere;
}
function calcTotal() { function calcTotal() {
return axes_list.reduce((s, axe) => s + (axes[axe] ? axes[axe].sous_total_eur_ht || 0 : 0), 0); return axes_list.reduce((s, axe) => s + (axes[axe] ? axes[axe].sous_total_eur_ht || 0 : 0), 0);
} }
@@ -47,11 +97,15 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
// seuil). Desormais : parmi TOUS les downgrades possibles on choisit // seuil). Desormais : parmi TOUS les downgrades possibles on choisit
// celui dont le total resultant est le plus proche du budget cible — // celui dont le total resultant est le plus proche du budget cible —
// evite a la fois le surcout et le sous-equipement massif. // evite a la fois le surcout et le sous-equipement massif.
const tous = getTousPostes().filter((p) => !p.force_par_couplage && !p.manuel); const instantane = getTousPostes();
const tous = instantane.filter((p) => !p.force_par_couplage && !p.manuel);
const choisis = codesChoisis(axes);
const candidats = []; const candidats = [];
for (const poste_candidat of tous) { for (const poste_candidat of tous) {
const poste_id_num = _posteIdNum(poste_candidat, options); 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); // v0.1.1 : jamais vers le choix d'un autre poste (une ligne forcée de ce
// code, elle, est absorbée par la passe couplages)
const alternatives = options.filter((o) => o.poste_id === poste_id_num && o.actif !== false && o.code !== poste_candidat.typologie_choisie && !choisis.has(o.code));
const alts_scored = alternatives const alts_scored = alternatives
.map((alt) => { .map((alt) => {
const dim = dimension_optimale(alt, inputs, contexte_derived, coefficients); const dim = dimension_optimale(alt, inputs, contexte_derived, coefficients);
@@ -63,7 +117,8 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
candidats.push({ candidats.push({
poste: poste_candidat, poste: poste_candidat,
alt: a, alt: a,
diff: (poste_candidat.cout_eur_ht || 0) - Math.round(a.cout), // v0.1.1 : la baisse réelle compte les accessoires forcés que ce changement lâche
diff: (poste_candidat.cout_eur_ht || 0) - Math.round(a.cout) + coutLibere(poste_candidat, a.alt, instantane),
}); });
} }
} }
@@ -88,17 +143,16 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
typologie_choisie: meilleur.alt.code, typologie_choisie: meilleur.alt.code,
nom_affichage: meilleur.alt.nom, nom_affichage: meilleur.alt.nom,
description_pedago: meilleur.alt.description_pedago || '', description_pedago: meilleur.alt.description_pedago || '',
dimension: meilleur.dim || p.dimension, dimension: dimensionDe(meilleur.alt, meilleur.dim),
cout_eur_ht: Math.round(meilleur.cout), cout_eur_ht: Math.round(meilleur.cout),
couplages_requis: parseJsonArr(meilleur.alt.couplages_requis), couplages_requis: parseJsonArr(meilleur.alt.couplages_requis),
couplages_exclus: parseJsonArr(meilleur.alt.couplages_exclus), couplages_exclus: parseJsonArr(meilleur.alt.couplages_exclus),
_downgraded: true, _downgraded: true,
}); });
recalcSousTotal(axe_poste); recalcSousTotal(axe_poste);
total -= diff;
downgraded = true; downgraded = true;
if (log) if (log)
log.push('Downgrade ' + poste_candidat.typologie_choisie + ' -> ' + meilleur.alt.code + ' -' + diff + 'EUR'); log.push('Downgrade ' + poste_candidat.typologie_choisie + ' -> ' + meilleur.alt.code + ' -' + diff + 'EUR (accessoires compris)');
} }
} }
if (!downgraded) { if (!downgraded) {
@@ -110,7 +164,6 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
const ax = axes[axe_poste]; const ax = axes[axe_poste];
const idx = ax.postes.findIndex((p) => p.typologie_choisie === pire.typologie_choisie); const idx = ax.postes.findIndex((p) => p.typologie_choisie === pire.typologie_choisie);
if (idx >= 0) { if (idx >= 0) {
total -= ax.postes[idx].cout_eur_ht || 0;
ax.postes.splice(idx, 1); ax.postes.splice(idx, 1);
recalcSousTotal(axe_poste); recalcSousTotal(axe_poste);
downgraded = true; downgraded = true;
@@ -121,6 +174,10 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
if (!downgraded) if (!downgraded)
break; break;
} }
// v0.1.1 : le principal a pu disparaître ou changer — ses accessoires forcés
// ne comptent plus dans le plancher.
lacheOrphelins();
total = calcTotal();
} }
if (total > budget_total * overrun_seuil) { if (total > budget_total * overrun_seuil) {
status = 'BUDGET_INSUFFISANT'; status = 'BUDGET_INSUFFISANT';
@@ -142,8 +199,6 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
// ne jamais y réinjecter de postes optionnels. Marqueur posé par // ne jamais y réinjecter de postes optionnels. Marqueur posé par
// axes-actifs.ts (configPourAxes). Absent = comportement historique inchangé. // axes-actifs.ts (configPourAxes). Absent = comportement historique inchangé.
const axes_desactives = new Set(config._axes_desactives || []); 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 // Construire mapping poste_code → axe via postes BDD
const posteIdToAxe = {}; const posteIdToAxe = {};
if (postes) { if (postes) {
@@ -156,18 +211,26 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
while (total < budget_total * underspend_seuil && iterations < 80) { while (total < budget_total * underspend_seuil && iterations < 80) {
iterations++; iterations++;
let upgraded = false; let upgraded = false;
// Stratégie 1 : upgrader des typologies existantes // v0.1.1 : état réel à chaque tour (l'origine figeait la liste des codes
const tous = getTousPostes().filter((p) => !p.manuel); // avant la boucle et ne la mettait pas à jour après un upgrade)
const instantane = getTousPostes();
const codes_selectionnes = codesChoisis(axes);
const postes_occupes = new Set(instantane.map((p) => _posteIdNum(p, options)));
// Stratégie 1 : upgrader des typologies existantes (jamais une ligne forcée :
// c'est une contrainte, la passe couplages re-forcerait l'original)
const tous = instantane.filter((p) => !p.manuel && !p.force_par_couplage);
tous.sort((a, b) => (b.score_adequation || 0) - (a.score_adequation || 0)); tous.sort((a, b) => (b.score_adequation || 0) - (a.score_adequation || 0));
for (const poste_candidat of tous) { for (const poste_candidat of tous) {
const poste_id_num = _posteIdNum(poste_candidat, options); 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 alternatives = options.filter((o) => o.poste_id === poste_id_num && o.actif !== false && o.code !== poste_candidat.typologie_choisie && !codes_selectionnes.has(o.code));
const alts_scored = alternatives const alts_scored = alternatives
.map((alt) => { .map((alt) => {
const dim = dimension_optimale(alt, inputs, contexte_derived, coefficients); const dim = dimension_optimale(alt, inputs, contexte_derived, coefficients);
const cout = cout_typologie(alt, dim, inputs); const cout = cout_typologie(alt, dim, inputs);
// Estimer le coût des couplages requis non encore sélectionnés // Estimer le coût des couplages requis non encore sélectionnés
const codes_sel = new Set(getTousPostes().map((p) => p.typologie_choisie)); // (v0.1.1 : la ligne qu'on remplace ne compte pas — un requis égal à
// son code actuel devra être forcé après l'upgrade)
const codes_sel = new Set(instantane.filter((p) => !(p.typologie_choisie === poste_candidat.typologie_choisie && p._axe === poste_candidat._axe)).map((p) => p.typologie_choisie));
const alias = (config && config.couplages_alias) || {}; const alias = (config && config.couplages_alias) || {};
const requis_list = parseJsonArr(alt.couplages_requis); const requis_list = parseJsonArr(alt.couplages_requis);
let cout_couplages_requis = 0; let cout_couplages_requis = 0;
@@ -184,10 +247,12 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
return { alt, dim, cout, cout_couplages_requis }; return { alt, dim, cout, cout_couplages_requis };
// Ne conserver que les upgrades qui ne dépassent pas le budget global // 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 // en incluant le coût estimé des couplages requis non encore satisfaits
// (v0.1.1 : et en déduisant les accessoires forcés que le changement lâche)
}) })
.filter((a) => { .filter((a) => {
const diff_potential = a.cout + a.cout_couplages_requis - (poste_candidat.cout_eur_ht || 0); const diff_potential = a.cout + a.cout_couplages_requis - (poste_candidat.cout_eur_ht || 0) - coutLibere(poste_candidat, a.alt, instantane);
return a.cout > (poste_candidat.cout_eur_ht || 0) && total + diff_potential <= budget_total * overrun_seuil; // v0.1.1 : un « upgrade » dont l'effet net fait baisser le total n'en est pas un
return a.cout > (poste_candidat.cout_eur_ht || 0) && diff_potential > 0 && total + diff_potential <= budget_total * overrun_seuil;
}); });
if (alts_scored.length > 0) { if (alts_scored.length > 0) {
alts_scored.sort((a, b) => a.cout - b.cout); alts_scored.sort((a, b) => a.cout - b.cout);
@@ -202,7 +267,7 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
typologie_choisie: meilleur.alt.code, typologie_choisie: meilleur.alt.code,
nom_affichage: meilleur.alt.nom, nom_affichage: meilleur.alt.nom,
description_pedago: meilleur.alt.description_pedago || '', description_pedago: meilleur.alt.description_pedago || '',
dimension: meilleur.dim || p.dimension, dimension: dimensionDe(meilleur.alt, meilleur.dim),
cout_eur_ht: Math.round(meilleur.cout), cout_eur_ht: Math.round(meilleur.cout),
// IMPORTANT: mettre à jour les couplages de la nouvelle typo (pas hériter ancienne) // IMPORTANT: mettre à jour les couplages de la nouvelle typo (pas hériter ancienne)
couplages_requis: parseJsonArr(meilleur.alt.couplages_requis), couplages_requis: parseJsonArr(meilleur.alt.couplages_requis),
@@ -219,10 +284,13 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
} }
} }
// Stratégie 2 : ajouter des postes optionnels non encore sélectionnés // Stratégie 2 : ajouter des postes optionnels non encore sélectionnés
// (v0.1.1 : un poste = une ligne ; la quantité passe par `dimension`)
if (!upgraded) { if (!upgraded) {
const postes_manquants = options.filter((o) => { const postes_manquants = options.filter((o) => {
if (o.actif === false || codes_selectionnes.has(o.code)) if (o.actif === false || codes_selectionnes.has(o.code))
return false; return false;
if (postes_occupes.has(o.poste_id))
return false;
// Ne pas ajouter de poste appartenant à un axe désactivé (V3) // 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])) if (axes_desactives.size > 0 && posteIdToAxe[o.poste_id] && axes_desactives.has(posteIdToAxe[o.poste_id]))
return false; return false;
@@ -260,14 +328,15 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
notes_techniques: best.opt.notes_techniques || '', notes_techniques: best.opt.notes_techniques || '',
flag_diy: best.opt.flag_diy || false, flag_diy: best.opt.flag_diy || false,
complexite_pose: best.opt.complexite_pose || 2, complexite_pose: best.opt.complexite_pose || 2,
couplages_requis: [], // v0.1.1 : la ligne dit ce qu'elle requiert (l'origine écrivait [] :
couplages_exclus: [], // un poêle ajouté ici n'aurait jamais eu son conduit)
couplages_requis: parseJsonArr(best.opt.couplages_requis),
couplages_exclus: parseJsonArr(best.opt.couplages_exclus),
manuel: false, manuel: false,
_added_cas_b: true, _added_cas_b: true,
}); });
ax.sous_total_eur_ht += Math.round(best.cout); ax.sous_total_eur_ht += Math.round(best.cout);
total += Math.round(best.cout); total += Math.round(best.cout);
codes_selectionnes.add(best.opt.code);
upgraded = true; upgraded = true;
if (log) if (log)
log.push('Ajout optionnel ' + best.opt.code + ' +' + Math.round(best.cout) + 'EUR'); log.push('Ajout optionnel ' + best.opt.code + ' +' + Math.round(best.cout) + 'EUR');
@@ -293,6 +362,16 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
debug_reajust: log, debug_reajust: log,
}; };
} }
/**
* Dimension d'une ligne qui change de typologie : celle calculée pour la
* nouvelle option, sinon le forfait de l'option (même convention que
* selectionnePoste). v0.1.1 : l'origine héritait de la dimension de la ligne
* remplacée (une batterie au plomb affichée « 0,4 kWc », une chaudière « 6 ml »),
* et le taux de couverture énergie lisait ces kWc fantômes.
*/
function dimensionDe(alt, dim) {
return dim || { valeur: alt.quantite_default || 1, unite: alt.unite || '' };
}
function _posteIdNum(poste_result, options) { function _posteIdNum(poste_result, options) {
const typo = options.find((o) => o.code === poste_result.typologie_choisie); const typo = options.find((o) => o.code === poste_result.typologie_choisie);
return typo ? typo.poste_id : -1; return typo ? typo.poste_id : -1;
+1 -1
View File
@@ -1 +1 @@
export declare const VERSION = "0.1.0"; export declare const VERSION = "0.1.1";
+1 -1
View File
@@ -2,4 +2,4 @@
// Doit être égale à "version" de package.json : un test le vérifie // Doit être égale à "version" de package.json : un test le vérifie
// (test/paquet.test.mjs). On ne lit pas package.json ici : le paquet ne fait // (test/paquet.test.mjs). On ne lit pas package.json ici : le paquet ne fait
// aucune I/O, et un JSON importé serait embarqué par les bundlers. // aucune I/O, et un JSON importé serait embarqué par les bundlers.
export const VERSION = '0.1.0'; export const VERSION = '0.1.1';
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@transformer/moteurs", "name": "@transformer/moteurs",
"version": "0.1.0", "version": "0.1.1",
"description": "Moteurs de calcul purs pour la rénovation et l'autonomie d'une maison, partagés par calculs.trans-former.fr et plateforme.trans-former.fr. Code et coefficients sourcés ; jamais de prix dans le paquet.", "description": "Moteurs de calcul purs pour la rénovation et l'autonomie d'une maison, partagés par calculs.trans-former.fr et plateforme.trans-former.fr. Code et coefficients sourcés ; jamais de prix dans le paquet.",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -2,4 +2,4 @@
// Doit être égale à "version" de package.json : un test le vérifie // Doit être égale à "version" de package.json : un test le vérifie
// (test/paquet.test.mjs). On ne lit pas package.json ici : le paquet ne fait // (test/paquet.test.mjs). On ne lit pas package.json ici : le paquet ne fait
// aucune I/O, et un JSON importé serait embarqué par les bundlers. // aucune I/O, et un JSON importé serait embarqué par les bundlers.
export const VERSION = '0.1.0' export const VERSION = '0.1.1'