Compare commits
4
Commits
v0.1.0
...
fix/v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f593d2edf | ||
|
|
8465bf7204 | ||
|
|
91c755f884 | ||
|
|
648015a879 |
@@ -35,14 +35,21 @@ taux de couverture, prix des options). `isolation`, `autoconstruction`, `estimat
|
||||
Les 28 tests d'origine du cœur autonomie (`test/noyau-origine.test.mjs`) et le test de découpage par
|
||||
axe (`test/decoupage.test.mjs`, épingle générée depuis le code d'astro-pro) **ne se désactivent
|
||||
jamais**. Refiger une épingle (`scripts/pin-origine.mjs`, `npm run ecarts -- --ecrire <date>`) est un
|
||||
acte volontaire, écrit dans le commit, après relecture de `docs/CONSTANTES.md`.
|
||||
acte volontaire, écrit dans le commit, après relecture de `docs/CONSTANTES.md`. **L'épingle d'origine
|
||||
ne se regénère pas pour absorber un correctif** : depuis v0.1.1 elle porte des défauts corrigés, et
|
||||
chaque cellule qui s'en écarte est figée dans `fixtures/ecarts-correctifs-<date>.json`
|
||||
(`node scripts/ecarts-correctifs.mjs --ecrire <date>`) ; une cellule absente de ce fichier doit
|
||||
rester identique à l'origine. Les tests de correctifs (`test/correctifs-v0.1.1.test.mjs`) et de
|
||||
monotonie (`test/monotonie.test.mjs`) ne se désactivent pas non plus.
|
||||
7. Français avec accents dans les libellés, la doc et les messages ; identifiants en français sans
|
||||
accents (`sous_total_eur_ht`, `taux_couverture`) ; codes d'axe internes `eau` / `energie` / `alim`
|
||||
(les postes publiés disent `alimentation`).
|
||||
8. Un module du noyau (`src/autonomie/noyau/`) se modifie pour corriger un bug **avec un test qui le
|
||||
montre** et une ligne dans le `CHANGELOG` du commit ; jamais « en passant ». Les bugs latents connus
|
||||
sont listés dans le RECAP du lot P1.0 (scoreurs sans parse de `parametre_dim`, `_conflit_avec` perdu,
|
||||
Intl dans les messages de `reajuste`).
|
||||
Intl dans les messages de `reajuste`). Invariants tenus depuis v0.1.1 : une typologie n'apparaît
|
||||
qu'une fois par axe, un poste n'a qu'une ligne (la quantité est `dimension`), une ligne forcée par
|
||||
couplage est lâchée dès que plus rien ne la requiert, le journal n'écrit chaque FORCE qu'une fois.
|
||||
|
||||
## Carte du dépôt
|
||||
|
||||
@@ -69,6 +76,21 @@ src/
|
||||
schemas/ fixtures/ test/ scripts/ docs/CONSTANTES.md dist/
|
||||
```
|
||||
|
||||
## Comment ajouter un moteur (P1.3 : isolation, autoconstruction, estimatif → v0.2.0)
|
||||
|
||||
1. Ses tables de données : une entrée par table dans `src/commun/schemas.ts` (`NomTable` s'étend seul,
|
||||
`valider` suit) ; les fixtures datées dans `fixtures/` ; **jamais un prix dans `src/`**.
|
||||
2. Ses coefficients physiques : dans `TableCoefficients` + `COEFFICIENTS` (valeur, source, date, note)
|
||||
**et** dans `fixtures/coefficients-2026-05.json` (le test exige les mêmes clés ; pour une grandeur
|
||||
absente du code d'origine, mettre la même valeur et le dire en `source`), puis une ligne dans
|
||||
`docs/CONSTANTES.md`.
|
||||
3. Le moteur : `src/<domaine>/<nom>.ts` exporte un `Moteur<E, D, S>` avec un `id` `'<domaine>.<nom>'`,
|
||||
construit son résultat avec `Trace` (chaque coefficient lu → `trace.source`, chaque défaut →
|
||||
`trace.avertit`), rend `VERSION`. Un `index.ts` par domaine ; l'`id` ajouté à `MOTEURS` (`src/index.ts`).
|
||||
4. `package.json` : une entrée `exports` par sous-chemin (`./isolation`, …) ; version mineure.
|
||||
5. Tests hors ligne dans `test/<domaine>.test.mjs` (contrat : déterminisme, sources, avertissements,
|
||||
un cas chiffré à la main), `npm test` vert, `dist/` reconstruit, étiquette, montée chez `reinnover`.
|
||||
|
||||
## Comment publier
|
||||
|
||||
```
|
||||
|
||||
Vendored
+29
-11
@@ -55,22 +55,40 @@ export function finaliseSelection(selection, budget_total, options, postes, inpu
|
||||
};
|
||||
let status_final = final_pre.status;
|
||||
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
|
||||
const avec = appliqueCouplages(etat.axes, options, postes, reglages, false);
|
||||
etat = {
|
||||
axes: avec.axes,
|
||||
couplages_appliques: [...etat.couplages_appliques, ...(avec.couplages_appliques || [])],
|
||||
};
|
||||
if (sommeAxes(etat.axes) <= seuil_over)
|
||||
prend(appliqueCouplages(etat.axes, options, postes, reglages, false));
|
||||
if (dans_la_bande(sommeAxes(etat.axes)))
|
||||
break;
|
||||
const re = reajusteBudget(etat, budget_total, options, postes, inputs, contexte, reglages, false);
|
||||
etat = {
|
||||
axes: re.axes,
|
||||
couplages_appliques: [...etat.couplages_appliques, ...(re.couplages_appliques || [])],
|
||||
};
|
||||
etat = { axes: re.axes, couplages_appliques: etat.couplages_appliques };
|
||||
status_final = re.status;
|
||||
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 {
|
||||
axes: etat.axes,
|
||||
|
||||
Vendored
+11
@@ -3,6 +3,17 @@ export interface ResultatCouplages {
|
||||
axes: Selection;
|
||||
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.
|
||||
*
|
||||
|
||||
Vendored
+66
-2
@@ -8,6 +8,13 @@
|
||||
// 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 = {};
|
||||
@@ -20,6 +27,62 @@ function copieSelection(selection) {
|
||||
}
|
||||
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.
|
||||
*
|
||||
@@ -41,10 +104,11 @@ export function appliqueCouplages(selection_recue, options, postes, reglages, _d
|
||||
function resolveCode(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 codes_selectionnes = new Set(tous_postes.map((p) => p.typologie_choisie));
|
||||
const couplages_log = [];
|
||||
// Passe 1 : couplages_requis
|
||||
for (const poste of tous_postes) {
|
||||
const requis_list = poste.couplages_requis || [];
|
||||
|
||||
Vendored
+3
-2
@@ -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 { adequation_contexte } 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 { alloueBudget } from './alloue-budget.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 { reajusteBudget } from './reajuste.js';
|
||||
export type { EntreeReajuste, ResultatReajuste } from './reajuste.js';
|
||||
|
||||
Vendored
+2
-2
@@ -1,12 +1,12 @@
|
||||
// @transformer/moteurs/autonomie/noyau — le cœur V2 du simulateur autonomie,
|
||||
// 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 { adequation_contexte } from './adequation.js';
|
||||
export { pondereAxes } from './pondere-axes.js';
|
||||
export { alloueBudget } from './alloue-budget.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 { configPourAxes, auMoinsUnAxe } from './axes-actifs.js';
|
||||
export { clamp01, clamp, roundToStep, ZONE_BIOGEO_MAP, zoneRtToClass } from './calibrage.js';
|
||||
|
||||
Vendored
+98
-19
@@ -7,6 +7,21 @@
|
||||
// Note (origine, conservée) : les messages utilisent toLocaleString('fr-FR')
|
||||
// (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.
|
||||
//
|
||||
// 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 { parseJsonArr } from './types.js';
|
||||
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 overrun_seuil = 1 + (tolerances.budget_overrun_pct || 5) / 100;
|
||||
const underspend_seuil = 1 - (tolerances.budget_underspend_pct || 15) / 100;
|
||||
const alias = config.couplages_alias || {};
|
||||
const axes = apres_couplages.axes;
|
||||
const axes_list = ['eau', 'energie', 'alim'];
|
||||
function getTousPostes() {
|
||||
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() {
|
||||
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
|
||||
// celui dont le total resultant est le plus proche du budget cible —
|
||||
// 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 = [];
|
||||
for (const poste_candidat of tous) {
|
||||
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
|
||||
.map((alt) => {
|
||||
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({
|
||||
poste: poste_candidat,
|
||||
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,
|
||||
nom_affichage: meilleur.alt.nom,
|
||||
description_pedago: meilleur.alt.description_pedago || '',
|
||||
dimension: meilleur.dim || p.dimension,
|
||||
dimension: dimensionDe(meilleur.alt, meilleur.dim),
|
||||
cout_eur_ht: Math.round(meilleur.cout),
|
||||
couplages_requis: parseJsonArr(meilleur.alt.couplages_requis),
|
||||
couplages_exclus: parseJsonArr(meilleur.alt.couplages_exclus),
|
||||
_downgraded: true,
|
||||
});
|
||||
recalcSousTotal(axe_poste);
|
||||
total -= diff;
|
||||
downgraded = true;
|
||||
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) {
|
||||
@@ -110,7 +164,6 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
|
||||
const ax = axes[axe_poste];
|
||||
const idx = ax.postes.findIndex((p) => p.typologie_choisie === pire.typologie_choisie);
|
||||
if (idx >= 0) {
|
||||
total -= ax.postes[idx].cout_eur_ht || 0;
|
||||
ax.postes.splice(idx, 1);
|
||||
recalcSousTotal(axe_poste);
|
||||
downgraded = true;
|
||||
@@ -121,6 +174,10 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
|
||||
if (!downgraded)
|
||||
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) {
|
||||
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
|
||||
// axes-actifs.ts (configPourAxes). Absent = comportement historique inchangé.
|
||||
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
|
||||
const posteIdToAxe = {};
|
||||
if (postes) {
|
||||
@@ -156,18 +211,26 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
|
||||
while (total < budget_total * underspend_seuil && iterations < 80) {
|
||||
iterations++;
|
||||
let upgraded = false;
|
||||
// Stratégie 1 : upgrader des typologies existantes
|
||||
const tous = getTousPostes().filter((p) => !p.manuel);
|
||||
// v0.1.1 : état réel à chaque tour (l'origine figeait la liste des codes
|
||||
// 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));
|
||||
for (const poste_candidat of tous) {
|
||||
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
|
||||
.map((alt) => {
|
||||
const dim = dimension_optimale(alt, inputs, contexte_derived, coefficients);
|
||||
const cout = cout_typologie(alt, dim, inputs);
|
||||
// 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 requis_list = parseJsonArr(alt.couplages_requis);
|
||||
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 };
|
||||
// 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
|
||||
// (v0.1.1 : et en déduisant les accessoires forcés que le changement lâche)
|
||||
})
|
||||
.filter((a) => {
|
||||
const diff_potential = a.cout + a.cout_couplages_requis - (poste_candidat.cout_eur_ht || 0);
|
||||
return a.cout > (poste_candidat.cout_eur_ht || 0) && total + diff_potential <= budget_total * overrun_seuil;
|
||||
const diff_potential = a.cout + a.cout_couplages_requis - (poste_candidat.cout_eur_ht || 0) - coutLibere(poste_candidat, a.alt, instantane);
|
||||
// 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) {
|
||||
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,
|
||||
nom_affichage: meilleur.alt.nom,
|
||||
description_pedago: meilleur.alt.description_pedago || '',
|
||||
dimension: meilleur.dim || p.dimension,
|
||||
dimension: dimensionDe(meilleur.alt, meilleur.dim),
|
||||
cout_eur_ht: Math.round(meilleur.cout),
|
||||
// IMPORTANT: mettre à jour les couplages de la nouvelle typo (pas hériter ancienne)
|
||||
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
|
||||
// (v0.1.1 : un poste = une ligne ; la quantité passe par `dimension`)
|
||||
if (!upgraded) {
|
||||
const postes_manquants = options.filter((o) => {
|
||||
if (o.actif === false || codes_selectionnes.has(o.code))
|
||||
return false;
|
||||
if (postes_occupes.has(o.poste_id))
|
||||
return false;
|
||||
// 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]))
|
||||
return false;
|
||||
@@ -260,14 +328,15 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
|
||||
notes_techniques: best.opt.notes_techniques || '',
|
||||
flag_diy: best.opt.flag_diy || false,
|
||||
complexite_pose: best.opt.complexite_pose || 2,
|
||||
couplages_requis: [],
|
||||
couplages_exclus: [],
|
||||
// v0.1.1 : la ligne dit ce qu'elle requiert (l'origine écrivait [] :
|
||||
// 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,
|
||||
_added_cas_b: true,
|
||||
});
|
||||
ax.sous_total_eur_ht += Math.round(best.cout);
|
||||
total += Math.round(best.cout);
|
||||
codes_selectionnes.add(best.opt.code);
|
||||
upgraded = true;
|
||||
if (log)
|
||||
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,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* 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) {
|
||||
const typo = options.find((o) => o.code === poste_result.typologie_choisie);
|
||||
return typo ? typo.poste_id : -1;
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
export declare const VERSION = "0.1.0";
|
||||
export declare const VERSION = "0.1.1";
|
||||
|
||||
Vendored
+1
-1
@@ -2,4 +2,4 @@
|
||||
// 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
|
||||
// aucune I/O, et un JSON importé serait embarqué par les bundlers.
|
||||
export const VERSION = '0.1.0';
|
||||
export const VERSION = '0.1.1';
|
||||
|
||||
+3
-1
@@ -9,11 +9,13 @@
|
||||
| Grandeur | Le code portait | Retenu | Source | Effet mesuré sur les trois profils de référence |
|
||||
|---|---|---|---|---|
|
||||
| **Rendement de captage** | trois valeurs : 0,7 (score de pluviométrie), 0,8 (taux de couverture), 0,85 (V1) | **0,80** (fourchette 0,75 zone sèche à 0,85 zone humide) | recherche eau §3 et §7 n°6 : NF EN 16941-1, recueil Nény p.16 | aucune typologie ne change ; couverture eau **+3 à +4 points** (A 23 → 27 %, B 24 → 27 %, C 20 → 23 %) |
|
||||
| **Facteur d'orientation PV** | sud 1 · SE/SO 0,93 · E/O 0,78 · **nord 0,40** | sud 1 · SE/SO **0,95** · E/O **0,80** · nord **0,60** (bas de fourchette) | recherche énergie §3.2 (SE/SO 95 %, E/O 80-85 %, N 60-65 %). *Variante : le brief §7.2 prenait les milieux 0,82 et 0,62* | aucune typologie ne change (les trois profils sont sud ou sud-est) ; le nord n'est plus pénalisé deux fois plus que la source |
|
||||
| **Facteur d'orientation PV** | sud 1 · SE/SO 0,93 · E/O 0,78 · **nord 0,40** | sud 1 · SE/SO **0,95** · E/O **0,80** · nord **0,60** (bas de fourchette) | recherche énergie §3.2 (SE/SO 95 %, E/O 80-85 %, N 60-65 %). *Variante : le brief §7.2 prenait les milieux 0,82 et 0,62* | aucune typologie ne change (les trois profils sont sud ou sud-est) ; mesuré aussi sur B et C avec un toit **est** puis **nord** : aucun kit PV n'est retenu dans ces cas, avec l'une ou l'autre table, donc aucune sélection ne bouge non plus ; l'effet du facteur se verra sur le dimensionnement (kWc) d'un toit est/ouest quand un kit est retenu |
|
||||
| **Taux d'autoconsommation** | **absent** : la couverture énergie comparait la production brute au besoin | **0,30 sans batterie, 0,70 avec 10 kWh**, linéaire entre les deux (l'interpolation est un choix MOE, non sourcé) | recherche énergie §3.2, brief §7.2 | c'est **le** changement visible : couverture énergie **A 22 → 6 %**, **B 100 → 80 %** (mode par axe, avec batterie), **C 100 → 63 %** (global) et 10 → 3 % (par axe). Le simulateur promettait trop |
|
||||
| **Productible PV** (kWh/kWc/an) | table par zone **biogéographique**, 1 050 à **1 550** (zone 8, au-dessus du maximum de la source) | table par zone **RT** : H1a 1 000 · H1b 1 050 · H1c 1 200 · H2a 1 100 · H2b 1 200 · H2c 1 250 · **H2d 1 300** · H3 1 400 ; défaut 1 100 | recherche énergie §3.2 (PVGIS, sud 30°, pertes 14 %). H1a = moyenne Lille 950 / Paris 1 050. **H2d interpolé par la MOE** (04, 07, 26, 84 : pas de ville dans la source), déclaré en avertissement à chaque usage | inclus dans les écarts d'énergie ci-dessus (Paris 1 100 → 1 000, Bouches-du-Rhône 1 480 → 1 400) |
|
||||
| **Contexte Corse et DOM** | `algo.js` prenait deux chiffres du code postal : 2A, 2B, 971-976 retombaient sur **H2a, 800 mm, 2 200 DJU** pendant que l'affichage utilisait la vraie ligne | une seule fonction, `departementDepuisCodePostal` (20000-20190 → 2A, 20200-20620 → 2B, 97xxx → trois caractères) | code de `data.js` (l'interface avait déjà la bonne logique) | Corse : H3 (700-750 mm, 1 500-1 600 DJU). DOM : la table `france_climat` n'a **ni pluviométrie ni DJU** pour 971-976 (« DOM tropical ») → défauts 800 mm / 2 200 DJU appliqués **et déclarés** ; à compléter dans NocoDB si un jour une adresse DOM arrive |
|
||||
|
||||
> **Correction du 05/10 (v0.1.1), sans changement de coefficient.** Deux des effets « mesurés » ci-dessus n'existaient pas : **A énergie 22 → 6 %** (mode global) et **C énergie 10 → 3 %** (mode par axe) étaient calculés sur des kWc fantômes — une batterie au plomb retenue comme ligne du poste PV **héritait de la dimension « 0,4 kWc »** de la ligne qu'elle remplaçait (défaut du réajustement, corrigé). Dans ces deux cas la sélection ne porte aucun kit PV : la couverture énergie est **null** (« non calculable »), avec l'une ou l'autre table. Les écarts refigés (`fixtures/ecarts-harmonisation-2026-10-05.json`) ne portent plus ces deux lignes ; les quatre autres effets du tableau (captage, autoconsommation A par axe 22 → 6 %, B 100 → 80 %, C global 100 → 63 %) sont inchangés. Le reste de cette table reste à relire tel quel.
|
||||
|
||||
## 2. Ce qui ne change pas, désormais sourcé et unique
|
||||
|
||||
| Grandeur | Valeur | Source | Note |
|
||||
|
||||
+3
-2
@@ -8,7 +8,8 @@ Les tests tournent hors ligne sur ces copies. Aucun fichier d'ici n'est importé
|
||||
| `algo-config.json` | heuristiques de conception du noyau (poids, modulateurs, matrices, tolérances, alias, poids des postes) ; les clés `orientation_factors` et `dju_defaults_by_zone` y figurent encore mais **ne sont plus lues** (les coefficients vivent dans `src/commun/coefficients.ts`) | `astro-pro/src/lib/autonomie/algo-config.json` | 2026-09-28 (inchangé depuis mai 2026) |
|
||||
| `profils-reference.json` | les trois profils de calibrage A / B / C | `astro-pro/tests/profils-reference.json` | 2026-09-28 |
|
||||
| `coefficients-2026-05.json` | jeu de coefficients **d'origine** (valeurs du code de calculs), injecté par le test de non-régression | relevé sur `astro-pro` master `c628cf9` | 2026-09-28 |
|
||||
| `sorties-origine-2026-09-28.json` | sorties de `selectionner()` **d'origine** sur les trois profils (pipeline complet + mode un seul axe), générées par `scripts/pin-origine.mjs` depuis le code d'astro-pro | `astro-pro` master `c628cf9` | 2026-09-28 |
|
||||
| `ecarts-harmonisation-2026-09-28.json` | écarts de sélection et de taux entre coefficients d'origine et table harmonisée (`npm run ecarts -- --ecrire <date>`) ; relus par Jules avec `docs/CONSTANTES.md` | ce paquet | 2026-09-28 |
|
||||
| `sorties-origine-2026-09-28.json` | sorties de `selectionner()` **d'origine** sur les trois profils (pipeline complet + mode un seul axe), générées par `scripts/pin-origine.mjs` depuis le code d'astro-pro. **Porte les défauts corrigés en v0.1.1** (doublons, accessoires forcés orphelins, statut non monotone, journal répété) : elle n'est pas regénérée, les écarts voulus sont figés à côté | `astro-pro` master `c628cf9` | 2026-09-28 |
|
||||
| `ecarts-correctifs-2026-10-05.json` | écarts voulus entre l'épingle d'origine et ce paquet (coefficients d'origine), cellule par cellule, avec le détail des lignes (`node scripts/ecarts-correctifs.mjs --ecrire <date>`). Une cellule absente doit rester identique à l'origine ; un nouvel écart ne se refige que pour corriger un défaut du noyau, avec son test | ce paquet v0.1.1 | 2026-10-05 |
|
||||
| `ecarts-harmonisation-2026-10-05.json` | écarts de sélection et de taux entre coefficients d'origine et table harmonisée (`npm run ecarts -- --ecrire <date>`) ; relus par Jules avec `docs/CONSTANTES.md`. Refigé le 05/10 après le correctif v0.1.1 : les écarts sont identiques à ceux du 28/09 (seuls les totaux de référence des lignes ont bougé) | ce paquet | 2026-10-05 |
|
||||
|
||||
Refiger une épingle est un acte volontaire : on relit `docs/CONSTANTES.md`, on relance le script avec la date du jour, on met à jour les tests qui la citent, et on l'écrit dans le message de commit.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+60
-92
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"_commentaire": "Écarts de sélection et de taux entre le jeu de coefficients d'origine et la table harmonisée, sur les trois profils de référence, figés le 2026-09-28. Un changement de coefficient ou de code qui modifie ces écarts casse test/decoupage.test.mjs : c'est voulu, on relit CONSTANTES.md avant de refiger.",
|
||||
"date": "2026-09-28",
|
||||
"_commentaire": "Écarts de sélection et de taux entre le jeu de coefficients d'origine et la table harmonisée, sur les trois profils de référence, figés le 2026-10-05. Un changement de coefficient ou de code qui modifie ces écarts casse test/decoupage.test.mjs : c'est voulu, on relit CONSTANTES.md avant de refiger.",
|
||||
"date": "2026-10-05",
|
||||
"ecarts": [
|
||||
{
|
||||
"profil": "A — Frugal urbain",
|
||||
@@ -23,13 +23,6 @@
|
||||
"origine": "22 %",
|
||||
"harmonisee": "6 %"
|
||||
},
|
||||
{
|
||||
"profil": "A — Frugal urbain",
|
||||
"mode": "taux (global)",
|
||||
"axe": "energie",
|
||||
"origine": "22 %",
|
||||
"harmonisee": "6 %"
|
||||
},
|
||||
{
|
||||
"profil": "B — Famille moyenne",
|
||||
"mode": "taux (par_axe)",
|
||||
@@ -65,13 +58,6 @@
|
||||
"origine": "20 %",
|
||||
"harmonisee": "23 %"
|
||||
},
|
||||
{
|
||||
"profil": "C — Autonomie large",
|
||||
"mode": "taux (par_axe)",
|
||||
"axe": "energie",
|
||||
"origine": "10 %",
|
||||
"harmonisee": "3 %"
|
||||
},
|
||||
{
|
||||
"profil": "C — Autonomie large",
|
||||
"mode": "taux (global)",
|
||||
@@ -85,31 +71,30 @@
|
||||
"profil": "A — Frugal urbain",
|
||||
"jeu": "origine",
|
||||
"global": {
|
||||
"total": 15180,
|
||||
"total": 15700,
|
||||
"status": "OK",
|
||||
"typologies": {
|
||||
"eau": [
|
||||
"captage_descente_directe",
|
||||
"filtration_particulaire_simple",
|
||||
"filtration_3_corps_uv",
|
||||
"stockage_souple_5m3",
|
||||
"distrib_gravitaire",
|
||||
"distrib_kit_double_reseau",
|
||||
"eg_noue",
|
||||
"eg_pedoepuration",
|
||||
"eg_fpv_autoconstruit"
|
||||
],
|
||||
"energie": [
|
||||
"pv_batterie_plomb_5",
|
||||
"th_ballon_500",
|
||||
"bois_poele_double",
|
||||
"bois_poele_masse_diy",
|
||||
"emet_vannes_thermo",
|
||||
"pil_home_assistant",
|
||||
"pil_routeur_ecs",
|
||||
"bois_conduit_fumee"
|
||||
],
|
||||
"alim": []
|
||||
},
|
||||
"taux": {
|
||||
"eau": 23,
|
||||
"energie": 22,
|
||||
"energie": null,
|
||||
"alim": null
|
||||
}
|
||||
},
|
||||
@@ -117,15 +102,14 @@
|
||||
"eau": {
|
||||
"typologies": [
|
||||
"captage_descente_directe",
|
||||
"filtration_particulaire_simple",
|
||||
"filtration_3_corps_uv",
|
||||
"stockage_souple_5m3",
|
||||
"distrib_gravitaire",
|
||||
"eg_pedoepuration",
|
||||
"distrib_kit_double_reseau",
|
||||
"eg_fpv_autoconstruit"
|
||||
"eg_recyclage_spareau",
|
||||
"eg_separation_source"
|
||||
],
|
||||
"sous_total": 8130,
|
||||
"status": "BUDGET_INSUFFISANT",
|
||||
"sous_total": 7575,
|
||||
"status": "OK",
|
||||
"taux": 23
|
||||
},
|
||||
"energie": {
|
||||
@@ -138,7 +122,7 @@
|
||||
"bois_conduit_fumee"
|
||||
],
|
||||
"sous_total": 7110,
|
||||
"status": "OK",
|
||||
"status": "AJOUTER_OPTIONNEL",
|
||||
"taux": 22
|
||||
},
|
||||
"alim": {
|
||||
@@ -153,31 +137,30 @@
|
||||
"profil": "A — Frugal urbain",
|
||||
"jeu": "harmonisee",
|
||||
"global": {
|
||||
"total": 15180,
|
||||
"total": 15700,
|
||||
"status": "OK",
|
||||
"typologies": {
|
||||
"eau": [
|
||||
"captage_descente_directe",
|
||||
"filtration_particulaire_simple",
|
||||
"filtration_3_corps_uv",
|
||||
"stockage_souple_5m3",
|
||||
"distrib_gravitaire",
|
||||
"distrib_kit_double_reseau",
|
||||
"eg_noue",
|
||||
"eg_pedoepuration",
|
||||
"eg_fpv_autoconstruit"
|
||||
],
|
||||
"energie": [
|
||||
"pv_batterie_plomb_5",
|
||||
"th_ballon_500",
|
||||
"bois_poele_double",
|
||||
"bois_poele_masse_diy",
|
||||
"emet_vannes_thermo",
|
||||
"pil_home_assistant",
|
||||
"pil_routeur_ecs",
|
||||
"bois_conduit_fumee"
|
||||
],
|
||||
"alim": []
|
||||
},
|
||||
"taux": {
|
||||
"eau": 27,
|
||||
"energie": 6,
|
||||
"energie": null,
|
||||
"alim": null
|
||||
}
|
||||
},
|
||||
@@ -185,15 +168,14 @@
|
||||
"eau": {
|
||||
"typologies": [
|
||||
"captage_descente_directe",
|
||||
"filtration_particulaire_simple",
|
||||
"filtration_3_corps_uv",
|
||||
"stockage_souple_5m3",
|
||||
"distrib_gravitaire",
|
||||
"eg_pedoepuration",
|
||||
"distrib_kit_double_reseau",
|
||||
"eg_fpv_autoconstruit"
|
||||
"eg_recyclage_spareau",
|
||||
"eg_separation_source"
|
||||
],
|
||||
"sous_total": 8130,
|
||||
"status": "BUDGET_INSUFFISANT",
|
||||
"sous_total": 7575,
|
||||
"status": "OK",
|
||||
"taux": 27
|
||||
},
|
||||
"energie": {
|
||||
@@ -206,7 +188,7 @@
|
||||
"bois_conduit_fumee"
|
||||
],
|
||||
"sous_total": 7110,
|
||||
"status": "OK",
|
||||
"status": "AJOUTER_OPTIONNEL",
|
||||
"taux": 6
|
||||
},
|
||||
"alim": {
|
||||
@@ -221,7 +203,7 @@
|
||||
"profil": "B — Famille moyenne",
|
||||
"jeu": "origine",
|
||||
"global": {
|
||||
"total": 36661,
|
||||
"total": 34183,
|
||||
"status": "OK",
|
||||
"typologies": {
|
||||
"eau": [
|
||||
@@ -229,9 +211,7 @@
|
||||
"filtration_particulaire_simple",
|
||||
"stockage_souple_5m3",
|
||||
"distrib_gravitaire",
|
||||
"eg_noue",
|
||||
"eg_pedoepuration",
|
||||
"eg_fpv_autoconstruit"
|
||||
"eg_autoepure"
|
||||
],
|
||||
"energie": [
|
||||
"pv_monitoring",
|
||||
@@ -242,10 +222,10 @@
|
||||
"bois_conduit_fumee"
|
||||
],
|
||||
"alim": [
|
||||
"etude_topo_ensoleil",
|
||||
"haute_fruit_coque",
|
||||
"etude_analyse_labo",
|
||||
"haute_plantation_pose_pro",
|
||||
"moy_petit_fruit_3L",
|
||||
"basse_compost_3bacs"
|
||||
"basse_medicinales_lot30"
|
||||
]
|
||||
},
|
||||
"taux": {
|
||||
@@ -257,14 +237,14 @@
|
||||
"par_axe": {
|
||||
"eau": {
|
||||
"typologies": [
|
||||
"captage_descente_directe",
|
||||
"captage_capteur_sol",
|
||||
"filtration_particulaire_simple",
|
||||
"stockage_pehd_enterree_5m3",
|
||||
"distrib_gravitaire",
|
||||
"eg_pedoepuration",
|
||||
"eg_pedoepuration"
|
||||
"distrib_per_bleu_interieur",
|
||||
"eg_autoepure",
|
||||
"distrib_kit_double_reseau"
|
||||
],
|
||||
"sous_total": 14645,
|
||||
"sous_total": 13595,
|
||||
"status": "OK",
|
||||
"taux": 24
|
||||
},
|
||||
@@ -286,11 +266,10 @@
|
||||
"etude_analyse_labo",
|
||||
"haute_plantation_pose_pro",
|
||||
"moy_haie_biodiv_complete",
|
||||
"basse_planche_bec",
|
||||
"basse_planche_bec"
|
||||
],
|
||||
"sous_total": 6985,
|
||||
"status": "OK",
|
||||
"sous_total": 4360,
|
||||
"status": "AJOUTER_OPTIONNEL",
|
||||
"taux": 50
|
||||
}
|
||||
}
|
||||
@@ -299,7 +278,7 @@
|
||||
"profil": "B — Famille moyenne",
|
||||
"jeu": "harmonisee",
|
||||
"global": {
|
||||
"total": 36661,
|
||||
"total": 34183,
|
||||
"status": "OK",
|
||||
"typologies": {
|
||||
"eau": [
|
||||
@@ -307,9 +286,7 @@
|
||||
"filtration_particulaire_simple",
|
||||
"stockage_souple_5m3",
|
||||
"distrib_gravitaire",
|
||||
"eg_noue",
|
||||
"eg_pedoepuration",
|
||||
"eg_fpv_autoconstruit"
|
||||
"eg_autoepure"
|
||||
],
|
||||
"energie": [
|
||||
"pv_monitoring",
|
||||
@@ -320,10 +297,10 @@
|
||||
"bois_conduit_fumee"
|
||||
],
|
||||
"alim": [
|
||||
"etude_topo_ensoleil",
|
||||
"haute_fruit_coque",
|
||||
"etude_analyse_labo",
|
||||
"haute_plantation_pose_pro",
|
||||
"moy_petit_fruit_3L",
|
||||
"basse_compost_3bacs"
|
||||
"basse_medicinales_lot30"
|
||||
]
|
||||
},
|
||||
"taux": {
|
||||
@@ -335,14 +312,14 @@
|
||||
"par_axe": {
|
||||
"eau": {
|
||||
"typologies": [
|
||||
"captage_descente_directe",
|
||||
"captage_capteur_sol",
|
||||
"filtration_particulaire_simple",
|
||||
"stockage_pehd_enterree_5m3",
|
||||
"distrib_gravitaire",
|
||||
"eg_pedoepuration",
|
||||
"eg_pedoepuration"
|
||||
"distrib_per_bleu_interieur",
|
||||
"eg_autoepure",
|
||||
"distrib_kit_double_reseau"
|
||||
],
|
||||
"sous_total": 14645,
|
||||
"sous_total": 13595,
|
||||
"status": "OK",
|
||||
"taux": 27
|
||||
},
|
||||
@@ -364,11 +341,10 @@
|
||||
"etude_analyse_labo",
|
||||
"haute_plantation_pose_pro",
|
||||
"moy_haie_biodiv_complete",
|
||||
"basse_planche_bec",
|
||||
"basse_planche_bec"
|
||||
],
|
||||
"sous_total": 6985,
|
||||
"status": "OK",
|
||||
"sous_total": 4360,
|
||||
"status": "AJOUTER_OPTIONNEL",
|
||||
"taux": 50
|
||||
}
|
||||
}
|
||||
@@ -416,13 +392,10 @@
|
||||
"stockage_beton_5m3",
|
||||
"distrib_per_bleu_interieur",
|
||||
"eg_phytostation",
|
||||
"eg_phytostation",
|
||||
"eg_phytostation",
|
||||
"eg_pedoepuration",
|
||||
"distrib_kit_double_reseau"
|
||||
],
|
||||
"sous_total": 41000,
|
||||
"status": "OK",
|
||||
"sous_total": 18400,
|
||||
"status": "AJOUTER_OPTIONNEL",
|
||||
"taux": 20
|
||||
},
|
||||
"energie": {
|
||||
@@ -436,18 +409,17 @@
|
||||
],
|
||||
"sous_total": 19210,
|
||||
"status": "OK",
|
||||
"taux": 10
|
||||
"taux": null
|
||||
},
|
||||
"alim": {
|
||||
"typologies": [
|
||||
"etude_analyse_labo",
|
||||
"haute_haute_tige",
|
||||
"moy_haie_biodiv_complete",
|
||||
"basse_planche_bec",
|
||||
"basse_planche_bec"
|
||||
],
|
||||
"sous_total": 20855,
|
||||
"status": "OK",
|
||||
"sous_total": 11855,
|
||||
"status": "AJOUTER_OPTIONNEL",
|
||||
"taux": 67
|
||||
}
|
||||
}
|
||||
@@ -495,13 +467,10 @@
|
||||
"stockage_beton_5m3",
|
||||
"distrib_per_bleu_interieur",
|
||||
"eg_phytostation",
|
||||
"eg_phytostation",
|
||||
"eg_phytostation",
|
||||
"eg_pedoepuration",
|
||||
"distrib_kit_double_reseau"
|
||||
],
|
||||
"sous_total": 41000,
|
||||
"status": "OK",
|
||||
"sous_total": 18400,
|
||||
"status": "AJOUTER_OPTIONNEL",
|
||||
"taux": 23
|
||||
},
|
||||
"energie": {
|
||||
@@ -515,18 +484,17 @@
|
||||
],
|
||||
"sous_total": 19210,
|
||||
"status": "OK",
|
||||
"taux": 3
|
||||
"taux": null
|
||||
},
|
||||
"alim": {
|
||||
"typologies": [
|
||||
"etude_analyse_labo",
|
||||
"haute_haute_tige",
|
||||
"moy_haie_biodiv_complete",
|
||||
"basse_planche_bec",
|
||||
"basse_planche_bec"
|
||||
],
|
||||
"sous_total": 20855,
|
||||
"status": "OK",
|
||||
"sous_total": 11855,
|
||||
"status": "AJOUTER_OPTIONNEL",
|
||||
"taux": 67
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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.",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// ecarts-correctifs.mjs — compare, sur les trois profils de référence et avec le
|
||||
// jeu de coefficients D'ORIGINE, ce que le paquet rend aujourd'hui à l'épingle
|
||||
// générée depuis le code d'astro-pro (fixtures/sorties-origine-<date>.json).
|
||||
//
|
||||
// Pourquoi ce script existe (v0.1.1) : l'épingle d'origine porte les défauts
|
||||
// que le correctif répare (SPEC menu §12 n° 1, 2, 3, 5 : doublons, accessoires
|
||||
// forcés orphelins, statut non monotone, journal FORCE répété). La regénérer
|
||||
// depuis astro-pro reproduirait les défauts ; la regénérer depuis ce paquet en
|
||||
// ferait une épingle sur soi-même. Elle reste donc telle quelle, et chaque écart
|
||||
// voulu est écrit ici, cellule par cellule, dans fixtures/ecarts-correctifs-<date>.json.
|
||||
// test/decoupage.test.mjs exige que les écarts calculés soient EXACTEMENT ceux
|
||||
// figés : une cellule non listée doit rester identique à l'origine.
|
||||
// Usage : node scripts/ecarts-correctifs.mjs [--ecrire AAAA-MM-JJ]
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { eau, energie, alimentation, autonomie } from '../dist/index.js'
|
||||
|
||||
const fx = new URL('../fixtures/', import.meta.url)
|
||||
const lire = (f) => JSON.parse(readFileSync(new URL(f, fx), 'utf8'))
|
||||
const options = lire('options.json')
|
||||
const postes = lire('postes.json')
|
||||
const france_climat = lire('france_climat.json')
|
||||
const algo_config = lire('algo-config.json')
|
||||
const profils = lire('profils-reference.json')
|
||||
const origine = lire('coefficients-2026-05.json')
|
||||
export const PIN_ORIGINE = 'sorties-origine-2026-09-28.json'
|
||||
const pin = lire(PIN_ORIGINE)
|
||||
const AXES = ['eau', 'energie', 'alim']
|
||||
const MOTEURS = { eau, energie, alim: alimentation }
|
||||
|
||||
const resumePostes = (liste) => liste.map((p) => ({ poste_id: p.poste_id, typologie_choisie: p.typologie_choisie, dimension: p.dimension ? { valeur: p.dimension.valeur, unite: p.dimension.unite } : null, cout_eur_ht: p.cout_eur_ht, force_par_couplage: !!p.force_par_couplage }))
|
||||
const lignePostes = (liste) => liste.map((p) => `${p.typologie_choisie}${p.force_par_couplage ? '*' : ''} ${p.cout_eur_ht}`).join(' + ')
|
||||
|
||||
/** Compare une cellule (profil × mode × axe) à l'épingle ; rend les écarts champ par champ. */
|
||||
function compareAxe(profil, mode, axe, attendu, obtenu) {
|
||||
const ecarts = []
|
||||
const push = (champ, o, c) => ecarts.push({ profil, mode, axe, champ, origine: o, corrige: c })
|
||||
if (attendu.sous_total_eur_ht !== obtenu.sous_total_eur_ht) push('sous_total_eur_ht', attendu.sous_total_eur_ht, obtenu.sous_total_eur_ht)
|
||||
const po = JSON.stringify(attendu.postes)
|
||||
const pc = JSON.stringify(resumePostes(obtenu.postes))
|
||||
if (po !== pc) push('postes', lignePostes(attendu.postes), lignePostes(obtenu.postes))
|
||||
return ecarts
|
||||
}
|
||||
|
||||
export function calculeEcartsCorrectifs() {
|
||||
const ecarts = []
|
||||
const cellules = {}
|
||||
for (const profil of profils) {
|
||||
const p = pin.profils[profil.nom]
|
||||
const g = autonomie.noyau.selectionner(profil.inputs, options, postes, france_climat, { config: algo_config, coefficients: origine }, false)
|
||||
for (const axe of AXES) ecarts.push(...compareAxe(profil.nom, 'global', axe, p.global.axes[axe], g.axes[axe]))
|
||||
if (p.global.total_eur_ht !== g.total_eur_ht) ecarts.push({ profil: profil.nom, mode: 'global', axe: 'total', champ: 'total_eur_ht', origine: p.global.total_eur_ht, corrige: g.total_eur_ht })
|
||||
if (p.global.status !== g.status) ecarts.push({ profil: profil.nom, mode: 'global', axe: 'total', champ: 'status', origine: p.global.status, corrige: g.status })
|
||||
if (p.global.ecart_budget_pct !== g.ecart_budget_pct) ecarts.push({ profil: profil.nom, mode: 'global', axe: 'total', champ: 'ecart_budget_pct', origine: p.global.ecart_budget_pct, corrige: g.ecart_budget_pct })
|
||||
if (JSON.stringify(p.global.couplages) !== JSON.stringify(g.couplages)) ecarts.push({ profil: profil.nom, mode: 'global', axe: 'total', champ: 'couplages', origine: p.global.couplages, corrige: g.couplages })
|
||||
cellules[`${profil.nom} / global`] = { total: g.total_eur_ht, status: g.status, couplages: g.couplages, axes: Object.fromEntries(AXES.map((a) => [a, { sous_total: g.axes[a].sous_total_eur_ht, postes: resumePostes(g.axes[a].postes) }])) }
|
||||
|
||||
for (const axe of AXES) {
|
||||
const a = p.par_axe[axe]
|
||||
const r = MOTEURS[axe].calculer({ autodiag: profil.inputs, budget_axe: a.budget_axe, contexte: g.contexte }, { postes, options, algo_config, coefficients: origine }).sorties
|
||||
const obtenu = { sous_total_eur_ht: r.sous_total_eur_ht, postes: r.postes }
|
||||
ecarts.push(...compareAxe(profil.nom, 'par_axe', axe, a.axes[axe], obtenu))
|
||||
if (a.status !== r.status) ecarts.push({ profil: profil.nom, mode: 'par_axe', axe, champ: 'status', origine: a.status, corrige: r.status })
|
||||
if (JSON.stringify(a.couplages) !== JSON.stringify(r.couplages)) ecarts.push({ profil: profil.nom, mode: 'par_axe', axe, champ: 'couplages', origine: a.couplages, corrige: r.couplages })
|
||||
cellules[`${profil.nom} / par_axe / ${axe}`] = { budget_axe: a.budget_axe, sous_total: r.sous_total_eur_ht, status: r.status, couplages: r.couplages, postes: resumePostes(r.postes) }
|
||||
}
|
||||
}
|
||||
return { ecarts, cellules }
|
||||
}
|
||||
|
||||
const estCli = process.argv[1]?.replace(/\\/g, '/').endsWith('ecarts-correctifs.mjs')
|
||||
if (estCli) {
|
||||
const { ecarts, cellules } = calculeEcartsCorrectifs()
|
||||
console.log('| Profil | Mode | Axe | Champ | Origine (astro-pro) | Corrigé (ce paquet) |')
|
||||
console.log('|---|---|---|---|---|---|')
|
||||
const court = (v) => (Array.isArray(v) ? `${v.length} ligne(s)` : String(v))
|
||||
for (const e of ecarts) console.log(`| ${e.profil} | ${e.mode} | ${e.axe} | ${e.champ} | ${court(e.origine)} | ${court(e.corrige)} |`)
|
||||
if (ecarts.length === 0) console.log('| (aucun écart) | | | | | |')
|
||||
const i = process.argv.indexOf('--ecrire')
|
||||
if (i > 0) {
|
||||
const date = process.argv[i + 1] ?? new Date().toISOString().slice(0, 10)
|
||||
const cible = new URL(`ecarts-correctifs-${date}.json`, fx)
|
||||
writeFileSync(
|
||||
cible,
|
||||
JSON.stringify(
|
||||
{
|
||||
_commentaire: `Écarts voulus entre l'épingle d'origine (${PIN_ORIGINE}, code astro-pro) et ce paquet, coefficients d'origine, trois profils de référence, figés le ${date} par scripts/ecarts-correctifs.mjs. L'épingle d'origine n'est pas regénérée : elle porte les défauts corrigés (SPEC menu branches fiches §12 n° 1, 2, 3, 5). Toute cellule absente d'ici doit rester identique à l'origine ; un nouvel écart casse test/decoupage.test.mjs, et se refige sciemment, avec sa cause dans le commit.`,
|
||||
date,
|
||||
epingle_origine: PIN_ORIGINE,
|
||||
ecarts,
|
||||
cellules,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
'utf8',
|
||||
)
|
||||
console.log(`\nécrit : fixtures/ecarts-correctifs-${date}.json`)
|
||||
}
|
||||
}
|
||||
+30
-11
@@ -82,21 +82,40 @@ export function finaliseSelection(
|
||||
let status_final: Statut = final_pre.status
|
||||
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: number) => t <= seuil_over && t >= seuil_under
|
||||
const prend = (avec: { axes: Selection; couplages_appliques: string[] }) => {
|
||||
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
|
||||
const avec = appliqueCouplages(etat.axes, options, postes, reglages, false)
|
||||
etat = {
|
||||
axes: avec.axes,
|
||||
couplages_appliques: [...etat.couplages_appliques, ...(avec.couplages_appliques || [])],
|
||||
}
|
||||
if (sommeAxes(etat.axes) <= seuil_over) break
|
||||
prend(appliqueCouplages(etat.axes, options, postes, reglages, false))
|
||||
if (dans_la_bande(sommeAxes(etat.axes))) break
|
||||
const re = reajusteBudget(etat, budget_total, options, postes, inputs, contexte, reglages, false)
|
||||
etat = {
|
||||
axes: re.axes,
|
||||
couplages_appliques: [...etat.couplages_appliques, ...(re.couplages_appliques || [])],
|
||||
}
|
||||
etat = { axes: re.axes, couplages_appliques: etat.couplages_appliques }
|
||||
status_final = re.status
|
||||
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 {
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
// 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'
|
||||
import type { Axe, AxeResultat, Option, Poste, PosteResultat, Reglages, Selection } from './types.js'
|
||||
@@ -28,6 +35,58 @@ function copieSelection(selection: Selection): Selection {
|
||||
return copie
|
||||
}
|
||||
|
||||
const AXES_LIST: Axe[] = ['eau', 'energie', 'alim']
|
||||
|
||||
/** Codes requis (alias résolus) par les lignes NON forcées de la sélection. */
|
||||
export function codesRequisPar(axes: Selection, alias: Record<string, string>): Set<string> {
|
||||
const requis = new Set<string>()
|
||||
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: Selection): Set<string> {
|
||||
const codes = new Set<string>()
|
||||
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: Selection, alias: Record<string, string>): string[] {
|
||||
const requis = codesRequisPar(axes, alias)
|
||||
const choisis = codesChoisis(axes)
|
||||
const log: string[] = []
|
||||
for (const axe of AXES_LIST) {
|
||||
const a = axes[axe]
|
||||
if (!a) continue
|
||||
const gardes: PosteResultat[] = []
|
||||
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.
|
||||
*
|
||||
@@ -52,13 +111,16 @@ export function appliqueCouplages(selection_recue: Selection, options: Option[],
|
||||
return alias[code] || code
|
||||
}
|
||||
|
||||
const axes_list: Axe[] = ['eau', 'energie', 'alim']
|
||||
const axes_list: Axe[] = AXES_LIST
|
||||
|
||||
// Passe 0 (v0.1.1) : lâcher les accessoires forcés dont le principal a disparu.
|
||||
const couplages_log: string[] = retireForcesOrphelins(selection, alias)
|
||||
|
||||
const tous_postes: PosteResultat[] = 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 couplages_log: string[] = []
|
||||
|
||||
// Passe 1 : couplages_requis
|
||||
for (const poste of tous_postes) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @transformer/moteurs/autonomie/noyau — le cœur V2 du simulateur autonomie,
|
||||
// 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 type { SelectionFinalisee } from './algo.js'
|
||||
export { dimension_optimale, cout_typologie } from './dimension.js'
|
||||
export { adequation_contexte } from './adequation.js'
|
||||
export type { AdequationDebug } from './adequation.js'
|
||||
@@ -8,7 +9,7 @@ export { pondereAxes } from './pondere-axes.js'
|
||||
export type { PoidsDebug } from './pondere-axes.js'
|
||||
export { alloueBudget } from './alloue-budget.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 { reajusteBudget } from './reajuste.js'
|
||||
export type { EntreeReajuste, ResultatReajuste } from './reajuste.js'
|
||||
|
||||
@@ -7,7 +7,22 @@
|
||||
// Note (origine, conservée) : les messages utilisent toLocaleString('fr-FR')
|
||||
// (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.
|
||||
//
|
||||
// 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 type { Autodiag, Axe, Contexte, Dimension, Option, Poste, PosteResultat, Reglages, Selection, Statut } from './types.js'
|
||||
import { parseJsonArr } from './types.js'
|
||||
@@ -41,6 +56,7 @@ export function reajusteBudget(
|
||||
const tolerances = config.tolerances || {}
|
||||
const overrun_seuil = 1 + (tolerances.budget_overrun_pct || 5) / 100
|
||||
const underspend_seuil = 1 - (tolerances.budget_underspend_pct || 15) / 100
|
||||
const alias = config.couplages_alias || {}
|
||||
|
||||
const axes = apres_couplages.axes
|
||||
const axes_list: Axe[] = ['eau', 'energie', 'alim']
|
||||
@@ -49,6 +65,35 @@ export function reajusteBudget(
|
||||
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(): boolean {
|
||||
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: PosteResultat, nouveau: Option | null, instantane: PosteResultat[]): number {
|
||||
const requis_autres = new Set<string>()
|
||||
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(): number {
|
||||
return axes_list.reduce((s, axe) => s + (axes[axe] ? axes[axe].sous_total_eur_ht || 0 : 0), 0)
|
||||
}
|
||||
@@ -80,12 +125,16 @@ export function reajusteBudget(
|
||||
// seuil). Desormais : parmi TOUS les downgrades possibles on choisit
|
||||
// celui dont le total resultant est le plus proche du budget cible —
|
||||
// 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: { poste: PosteResultat; alt: { alt: Option; dim: Dimension | null; cout: number }; diff: number }[] = []
|
||||
for (const poste_candidat of tous) {
|
||||
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
|
||||
.map((alt) => {
|
||||
@@ -99,7 +148,8 @@ export function reajusteBudget(
|
||||
candidats.push({
|
||||
poste: poste_candidat,
|
||||
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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -129,16 +179,15 @@ export function reajusteBudget(
|
||||
typologie_choisie: meilleur.alt.code,
|
||||
nom_affichage: meilleur.alt.nom,
|
||||
description_pedago: meilleur.alt.description_pedago || '',
|
||||
dimension: meilleur.dim || p.dimension,
|
||||
dimension: dimensionDe(meilleur.alt, meilleur.dim),
|
||||
cout_eur_ht: Math.round(meilleur.cout),
|
||||
couplages_requis: parseJsonArr(meilleur.alt.couplages_requis),
|
||||
couplages_exclus: parseJsonArr(meilleur.alt.couplages_exclus),
|
||||
_downgraded: true,
|
||||
})
|
||||
recalcSousTotal(axe_poste)
|
||||
total -= diff
|
||||
downgraded = true
|
||||
if (log) log.push('Downgrade ' + poste_candidat.typologie_choisie + ' -> ' + meilleur.alt.code + ' -' + diff + 'EUR')
|
||||
if (log) log.push('Downgrade ' + poste_candidat.typologie_choisie + ' -> ' + meilleur.alt.code + ' -' + diff + 'EUR (accessoires compris)')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +200,6 @@ export function reajusteBudget(
|
||||
const ax = axes[axe_poste]!
|
||||
const idx = ax.postes.findIndex((p) => p.typologie_choisie === pire.typologie_choisie)
|
||||
if (idx >= 0) {
|
||||
total -= ax.postes[idx].cout_eur_ht || 0
|
||||
ax.postes.splice(idx, 1)
|
||||
recalcSousTotal(axe_poste)
|
||||
downgraded = true
|
||||
@@ -160,6 +208,11 @@ export function reajusteBudget(
|
||||
}
|
||||
if (!downgraded) 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) {
|
||||
@@ -183,9 +236,6 @@ export function reajusteBudget(
|
||||
// axes-actifs.ts (configPourAxes). Absent = comportement historique inchangé.
|
||||
const axes_desactives = new Set<Axe>(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
|
||||
const posteIdToAxe: Record<number, Axe> = {}
|
||||
if (postes) {
|
||||
@@ -201,20 +251,29 @@ export function reajusteBudget(
|
||||
|
||||
let upgraded = false
|
||||
|
||||
// Stratégie 1 : upgrader des typologies existantes
|
||||
const tous = getTousPostes().filter((p) => !p.manuel)
|
||||
// v0.1.1 : état réel à chaque tour (l'origine figeait la liste des codes
|
||||
// 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))
|
||||
|
||||
for (const poste_candidat of tous) {
|
||||
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
|
||||
.map((alt) => {
|
||||
const dim = dimension_optimale(alt, inputs, contexte_derived, coefficients)
|
||||
const cout = cout_typologie(alt, dim, inputs)
|
||||
// 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 requis_list = parseJsonArr(alt.couplages_requis)
|
||||
let cout_couplages_requis = 0
|
||||
@@ -231,10 +290,12 @@ export function reajusteBudget(
|
||||
return { alt, dim, cout, cout_couplages_requis }
|
||||
// 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
|
||||
// (v0.1.1 : et en déduisant les accessoires forcés que le changement lâche)
|
||||
})
|
||||
.filter((a) => {
|
||||
const diff_potential = a.cout + a.cout_couplages_requis - (poste_candidat.cout_eur_ht || 0)
|
||||
return a.cout > (poste_candidat.cout_eur_ht || 0) && total + diff_potential <= budget_total * overrun_seuil
|
||||
const diff_potential = a.cout + a.cout_couplages_requis - (poste_candidat.cout_eur_ht || 0) - coutLibere(poste_candidat, a.alt, instantane)
|
||||
// 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) {
|
||||
@@ -250,7 +311,7 @@ export function reajusteBudget(
|
||||
typologie_choisie: meilleur.alt.code,
|
||||
nom_affichage: meilleur.alt.nom,
|
||||
description_pedago: meilleur.alt.description_pedago || '',
|
||||
dimension: meilleur.dim || p.dimension,
|
||||
dimension: dimensionDe(meilleur.alt, meilleur.dim),
|
||||
cout_eur_ht: Math.round(meilleur.cout),
|
||||
// IMPORTANT: mettre à jour les couplages de la nouvelle typo (pas hériter ancienne)
|
||||
couplages_requis: parseJsonArr(meilleur.alt.couplages_requis),
|
||||
@@ -267,9 +328,11 @@ export function reajusteBudget(
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const postes_manquants = options.filter((o) => {
|
||||
if (o.actif === false || codes_selectionnes.has(o.code)) return false
|
||||
if (postes_occupes.has(o.poste_id)) return false
|
||||
// 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])) return false
|
||||
return true
|
||||
@@ -309,14 +372,15 @@ export function reajusteBudget(
|
||||
notes_techniques: best.opt.notes_techniques || '',
|
||||
flag_diy: best.opt.flag_diy || false,
|
||||
complexite_pose: best.opt.complexite_pose || 2,
|
||||
couplages_requis: [],
|
||||
couplages_exclus: [],
|
||||
// v0.1.1 : la ligne dit ce qu'elle requiert (l'origine écrivait [] :
|
||||
// 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,
|
||||
_added_cas_b: true,
|
||||
})
|
||||
ax.sous_total_eur_ht += Math.round(best.cout)
|
||||
total += Math.round(best.cout)
|
||||
codes_selectionnes.add(best.opt.code)
|
||||
upgraded = true
|
||||
if (log) log.push('Ajout optionnel ' + best.opt.code + ' +' + Math.round(best.cout) + 'EUR')
|
||||
}
|
||||
@@ -344,6 +408,17 @@ export function reajusteBudget(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: Option, dim: Dimension | null): Dimension {
|
||||
return dim || { valeur: alt.quantite_default || 1, unite: alt.unite || '' }
|
||||
}
|
||||
|
||||
function _posteIdNum(poste_result: PosteResultat, options: Option[]): number {
|
||||
const typo = options.find((o) => o.code === poste_result.typologie_choisie)
|
||||
return typo ? typo.poste_id : -1
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@
|
||||
// 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
|
||||
// aucune I/O, et un JSON importé serait embarqué par les bundlers.
|
||||
export const VERSION = '0.1.0'
|
||||
export const VERSION = '0.1.1'
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// Correctifs v0.1.1 — SPEC menu branches fiches §12, n° 1, 2, 3, 5 et 7.
|
||||
// Chaque test ÉCHOUE sur v0.1.0 (vérifié sur master 648015a) et passe après le
|
||||
// correctif. Jeux : la maison d'essai du 02/10 (3 personnes, toit 90 m² sud,
|
||||
// parcelle 600 m², CP 31000, 30 ml de haie, temps 2), la maison B de P1.2 à
|
||||
// 6 000 € (toit 89 m², parcelle 457 m², sans personnes ni orientation, temps 1)
|
||||
// et le profil de référence A. Coefficients harmonisés du paquet.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { eau, energie, alimentation, contexte as moteurContexte, autonomie, COEFFICIENTS } from '../dist/index.js'
|
||||
|
||||
const lire = (f) => JSON.parse(readFileSync(new URL(`../fixtures/${f}`, import.meta.url), 'utf8'))
|
||||
const options = lire('options.json')
|
||||
const postes = lire('postes.json')
|
||||
const france_climat = lire('france_climat.json')
|
||||
const algo_config = lire('algo-config.json')
|
||||
const profils = lire('profils-reference.json')
|
||||
const donnees = { postes, options, algo_config, france_climat }
|
||||
const MOTEURS = { eau, energie, alim: alimentation }
|
||||
const alias = algo_config.couplages_alias
|
||||
|
||||
const ESSAI = { code_postal: '31000', nb_personnes: 3, surface_toit_m2: 90, orientation_toit: 'sud', surface_parcelle_m2: 600, pente_toit_deg: 30, metres_haie_possibles: 30, temps_dispo: 2 }
|
||||
const MAISON_B = { code_postal: '31000', surface_toit_m2: 89, pente_toit_deg: 30, temps_dispo: 1, surface_parcelle_m2: 457 }
|
||||
const ctx31 = moteurContexte.calculer({ code_postal: '31000' }, { france_climat }).sorties
|
||||
const calcule = (axe, autodiag, budget) => MOTEURS[axe].calculer({ autodiag, budget_axe: budget, contexte: ctx31 }, donnees).sorties
|
||||
|
||||
const requisDe = (code) => {
|
||||
const o = options.find((x) => x.code === code)
|
||||
return o ? autonomie.noyau.parseJsonArr(o.couplages_requis).map((r) => alias[r] || r) : []
|
||||
}
|
||||
/** Lignes forcées que plus aucune ligne non forcée ne requiert. */
|
||||
const orphelins = (s) => s.postes.filter((p) => p.force_par_couplage && !s.postes.some((y) => !y.force_par_couplage && requisDe(y.typologie_choisie).includes(p.typologie_choisie))).map((p) => p.typologie_choisie)
|
||||
const doublons = (s) => s.typologies.filter((c, i) => s.typologies.indexOf(c) !== i)
|
||||
const postesMultiples = (s) => {
|
||||
const n = {}
|
||||
for (const p of s.postes) if (!p.force_par_couplage) n[p.poste_id] = (n[p.poste_id] || 0) + 1
|
||||
return Object.entries(n).filter(([, k]) => k > 1).map(([k]) => k)
|
||||
}
|
||||
|
||||
test('n° 1 — une typologie n\'est jamais retenue deux fois dans un axe, un poste n\'a qu\'une ligne (essai : eau 15 000 et 25 000, énergie 6 000, jardin 15 000)', () => {
|
||||
for (const [axe, budget] of [['eau', 15000], ['eau', 25000], ['energie', 6000], ['alim', 15000], ['alim', 40000]]) {
|
||||
const s = calcule(axe, ESSAI, budget)
|
||||
assert.deepEqual(doublons(s), [], `${axe} ${budget} : doublons`)
|
||||
assert.deepEqual(postesMultiples(s), [], `${axe} ${budget} : plusieurs lignes pour un poste`)
|
||||
}
|
||||
})
|
||||
|
||||
test('n° 2 — le journal des couplages n\'écrit chaque FORCE qu\'une fois, et un poste déjà retenu n\'est pas re-forcé (B de P1.2, énergie 6 000 ; A, eau 7 500)', () => {
|
||||
for (const [axe, autodiag, budget] of [['energie', MAISON_B, 6000], ['eau', profils[0].inputs, 7500], ['energie', ESSAI, 10000]]) {
|
||||
const s = calcule(axe, autodiag, budget)
|
||||
const force = s.couplages.filter((l) => l.startsWith('FORCE'))
|
||||
assert.equal(new Set(force).size, force.length, `${axe} ${budget} : FORCE répété — ${force.join(' | ')}`)
|
||||
const forces = s.postes.filter((p) => p.force_par_couplage).map((p) => p.typologie_choisie)
|
||||
for (const code of forces) assert.equal(s.typologies.filter((c) => c === code).length, 1, `${axe} ${budget} : ${code} forcé alors qu'il est déjà là`)
|
||||
assert.equal(s.sous_total_eur_ht, s.postes.reduce((a, p) => a + p.cout_eur_ht, 0), 'sous-total = somme des lignes')
|
||||
}
|
||||
// RECAP-LOT-P1.2 §5 ① : sur B à 6 000 € le conduit sortait deux fois (5 320 € au lieu de 3 940 €)
|
||||
const b = calcule('energie', MAISON_B, 6000)
|
||||
assert.equal(b.typologies.filter((c) => c === 'bois_conduit_fumee').length, 1, 'un seul conduit')
|
||||
})
|
||||
|
||||
test('n° 3 — EAU : correcte à 3 000 €, elle ne devient pas « budget insuffisant » à 6 000 € (essai) ni à 5 500-7 500 € (A, B)', () => {
|
||||
const e3 = calcule('eau', ESSAI, 3000)
|
||||
const e6 = calcule('eau', ESSAI, 6000)
|
||||
assert.equal(e3.status, 'OK')
|
||||
assert.notEqual(e6.status, 'BUDGET_INSUFFISANT', e6.message_status)
|
||||
for (const p of profils.slice(0, 2)) {
|
||||
for (const b of [5500, 6000, 6500, 7000, 7500]) {
|
||||
const ctx = moteurContexte.calculer({ code_postal: p.inputs.code_postal }, { france_climat }).sorties
|
||||
const s = eau.calculer({ autodiag: p.inputs, budget_axe: b, contexte: ctx }, donnees).sorties
|
||||
assert.notEqual(s.status, 'BUDGET_INSUFFISANT', `${p.nom} eau ${b} : ${s.message_status}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('n° 5 — aucun accessoire forcé sans son principal (conduit sans poêle, ballon sans poêle hydro, filière sans noue) : essai et A, tous budgets', () => {
|
||||
for (const [nom, autodiag] of [['essai', ESSAI], ['A', profils[0].inputs], ['B-P1.2', MAISON_B]]) {
|
||||
for (const axe of ['eau', 'energie']) {
|
||||
for (const b of [3000, 4000, 6000, 7500, 10000, 15000, 18000, 25000]) {
|
||||
const s = calcule(axe, autodiag, b)
|
||||
assert.deepEqual(orphelins(s), [], `${nom} ${axe} ${b} : accessoire forcé orphelin`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('n° 5 — noyau : appliqueCouplages lâche une ligne forcée dont le principal a disparu ; un downgrade du principal lâche ses accessoires', () => {
|
||||
const reglages = { config: autonomie.noyau.configPourAxes(algo_config, { eau: false, energie: true, alim: false }), coefficients: COEFFICIENTS }
|
||||
const vide = () => ({ postes: [], sous_total_eur_ht: 0, budget_alloue: 0, poids: 0 })
|
||||
const sel = { eau: vide(), alim: vide(), energie: autonomie.noyau.selectionnePoste(25000, ESSAI, ctx31, options, postes, 'energie', reglages) }
|
||||
// Le poste chauffage prend le poêle hydro (il requiert conduit + ballon tampon)
|
||||
const typo = options.find((o) => o.code === 'bois_poele_hydro')
|
||||
const cout = Math.round(autonomie.noyau.cout_typologie(typo, null, ESSAI))
|
||||
const i = sel.energie.postes.findIndex((p) => p.poste_id === 'energie_chauffage_bois')
|
||||
sel.energie.postes[i] = { ...sel.energie.postes[i], typologie_choisie: typo.code, nom_affichage: typo.nom, cout_eur_ht: cout, couplages_requis: autonomie.noyau.parseJsonArr(typo.couplages_requis), couplages_exclus: [] }
|
||||
sel.energie.sous_total_eur_ht = sel.energie.postes.reduce((a, p) => a + p.cout_eur_ht, 0)
|
||||
const c1 = autonomie.noyau.appliqueCouplages(sel, options, postes, reglages)
|
||||
const forces = c1.axes.energie.postes.filter((p) => p.force_par_couplage).map((p) => p.typologie_choisie)
|
||||
assert.ok(forces.includes('bois_conduit_fumee'), 'conduit forcé')
|
||||
// Le principal disparaît : la passe suivante retire ses accessoires, et le sous-total suit
|
||||
const sans = { ...c1.axes, energie: { ...c1.axes.energie, postes: c1.axes.energie.postes.filter((p) => p.typologie_choisie !== 'bois_poele_hydro') } }
|
||||
sans.energie.sous_total_eur_ht = sans.energie.postes.reduce((a, p) => a + p.cout_eur_ht, 0)
|
||||
const c2 = autonomie.noyau.appliqueCouplages(sans, options, postes, reglages)
|
||||
assert.ok(c2.couplages_appliques.some((l) => l.startsWith('RETIRE "bois_conduit_fumee"')), c2.couplages_appliques.join(' | '))
|
||||
assert.ok(!c2.axes.energie.postes.some((p) => p.force_par_couplage && p.typologie_choisie === 'bois_conduit_fumee'))
|
||||
assert.equal(c2.axes.energie.sous_total_eur_ht, c2.axes.energie.postes.reduce((a, p) => a + p.cout_eur_ht, 0))
|
||||
// CAS A sur un budget trop petit : le poêle hydro redescend, le conduit forcé ne reste pas verrouillé
|
||||
const r = autonomie.noyau.reajusteBudget(c1, 8000, options, postes, ESSAI, ctx31, reglages, false)
|
||||
const fin = autonomie.noyau.appliqueCouplages(r.axes, options, postes, reglages).axes.energie
|
||||
assert.deepEqual(orphelins({ postes: fin.postes }), [], 'après réajustement, aucun accessoire orphelin')
|
||||
})
|
||||
|
||||
test('n° 2 (journal) — finaliseSelection n\'additionne pas son propre journal à chaque tour', () => {
|
||||
const reglages = { config: autonomie.noyau.configPourAxes(algo_config, { eau: true, energie: false, alim: false }), coefficients: COEFFICIENTS }
|
||||
const vide = () => ({ postes: [], sous_total_eur_ht: 0, budget_alloue: 0, poids: 0 })
|
||||
const sel = { energie: vide(), alim: vide(), eau: autonomie.noyau.selectionnePoste(7500, profils[0].inputs, ctx31, options, postes, 'eau', reglages) }
|
||||
const fin = autonomie.noyau.finaliseSelection(sel, 7500, options, postes, profils[0].inputs, ctx31, reglages)
|
||||
const force = fin.couplages.filter((l) => l.startsWith('FORCE'))
|
||||
assert.equal(new Set(force).size, force.length, fin.couplages.join(' | '))
|
||||
})
|
||||
|
||||
test('trouvé en chemin (n° 6) — une ligne remplacée par le réajustement porte SA dimension, pas celle de la ligne remplacée', () => {
|
||||
for (const [axe, autodiag, budgets] of [['energie', ESSAI, [6000, 10000, 40000]], ['eau', ESSAI, [6000, 15000]], ['alim', profils[2].inputs, [21600]]]) {
|
||||
for (const b of budgets) {
|
||||
const s = calcule(axe, autodiag, b)
|
||||
for (const p of s.postes) {
|
||||
const o = options.find((x) => x.code === p.typologie_choisie)
|
||||
const attendu = autonomie.noyau.dimension_optimale(o, autodiag, ctx31, COEFFICIENTS) || { valeur: o.quantite_default || 1, unite: o.unite || '' }
|
||||
assert.deepEqual({ valeur: p.dimension.valeur, unite: p.dimension.unite }, { valeur: attendu.valeur, unite: attendu.unite }, `${axe} ${b} : ${p.typologie_choisie}`)
|
||||
}
|
||||
if (axe === 'energie' && !s.postes.some((p) => p.dimension.unite === 'kWc')) assert.equal(s.taux_couverture, null, `${b} : sans kWc, pas de taux`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('n° 7 — jardin : plus de lignes répétées, la quantité d\'un poste est sa dimension (essai 15 000, B 7 000)', () => {
|
||||
const s = calcule('alim', ESSAI, 15000)
|
||||
assert.deepEqual(doublons(s), [])
|
||||
const planche = s.postes.find((p) => p.typologie_choisie === 'basse_planche_bec')
|
||||
assert.ok(planche && planche.dimension.unite === 'm²' && planche.dimension.valeur > 0, 'la planche porte sa surface')
|
||||
assert.equal(s.postes.length, 4, 'quatre postes, quatre lignes')
|
||||
const ctxB = moteurContexte.calculer({ code_postal: profils[1].inputs.code_postal }, { france_climat }).sorties
|
||||
const b = alimentation.calculer({ autodiag: profils[1].inputs, budget_axe: 7000, contexte: ctxB }, donnees).sorties
|
||||
assert.deepEqual(doublons(b), [])
|
||||
// Le budget que les répétitions consommaient reste disponible, et le statut le dit
|
||||
assert.equal(b.status, 'AJOUTER_OPTIONNEL')
|
||||
})
|
||||
+36
-22
@@ -1,20 +1,26 @@
|
||||
// Non-régression du découpage par axe (LOT P1.0 point 4, amendé le 28/09).
|
||||
// Non-régression du découpage par axe (LOT P1.0 point 4, amendé le 28/09 et le 05/10).
|
||||
//
|
||||
// 1. Avec le jeu de coefficients D'ORIGINE (fixtures/coefficients-2026-05.json),
|
||||
// le pipeline complet extrait rend, sur les trois profils de référence, la
|
||||
// même sélection que selectionner() d'origine — épinglée dans
|
||||
// sélection de selectionner() d'origine — épinglée dans
|
||||
// fixtures/sorties-origine-2026-09-28.json, générée DEPUIS le code d'astro-pro
|
||||
// (scripts/pin-origine.mjs), pas depuis ce paquet.
|
||||
// 2. Chaque moteur d'axe, à budget d'axe égal, rend la sélection du mode
|
||||
// « un seul axe actif » de selectionner() d'origine (même épingle).
|
||||
// (scripts/pin-origine.mjs), pas depuis ce paquet — AUX ÉCARTS CORRECTIFS PRÈS :
|
||||
// l'épingle d'origine porte les défauts corrigés en v0.1.1 (doublons,
|
||||
// accessoires forcés orphelins, statut non monotone, journal répété ; SPEC menu
|
||||
// §12) et n'est pas regénérée. Chaque cellule qui s'en écarte est figée dans
|
||||
// fixtures/ecarts-correctifs-<date>.json (scripts/ecarts-correctifs.mjs) ; une
|
||||
// cellule absente de ce fichier doit rester identique à l'origine.
|
||||
// 2. Chaque moteur d'axe, à budget d'axe égal, rend la sélection du mode « un
|
||||
// seul axe actif » de selectionner() de ce paquet (propriété du découpage).
|
||||
// 3. Avec la table HARMONISÉE, les écarts de sélection et de taux par rapport à
|
||||
// l'origine sont ceux figés dans fixtures/ecarts-harmonisation-2026-09-28.json
|
||||
// l'origine sont ceux figés dans fixtures/ecarts-harmonisation-<date>.json
|
||||
// (relus par Jules avec docs/CONSTANTES.md). Les modifier = refiger sciemment.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { eau, energie, alimentation, integrateur, autonomie, COEFFICIENTS } from '../dist/index.js'
|
||||
import { calculeEcarts } from '../scripts/ecarts-harmonisation.mjs'
|
||||
import { calculeEcartsCorrectifs, PIN_ORIGINE } from '../scripts/ecarts-correctifs.mjs'
|
||||
|
||||
const lire = (f) => JSON.parse(readFileSync(new URL(`../fixtures/${f}`, import.meta.url), 'utf8'))
|
||||
const options = lire('options.json')
|
||||
@@ -24,40 +30,48 @@ const algo_config = lire('algo-config.json')
|
||||
const profils = lire('profils-reference.json')
|
||||
const origine = lire('coefficients-2026-05.json')
|
||||
const pin = lire('sorties-origine-2026-09-28.json')
|
||||
const ecartsPin = lire('ecarts-harmonisation-2026-09-28.json')
|
||||
const ecartsPin = lire('ecarts-harmonisation-2026-10-05.json')
|
||||
const correctifsPin = lire('ecarts-correctifs-2026-10-05.json')
|
||||
const AXES = ['eau', 'energie', 'alim']
|
||||
const MOTEURS = { eau, energie, alim: alimentation }
|
||||
|
||||
const resumePostes = (liste) => liste.map((p) => ({ poste_id: p.poste_id, typologie_choisie: p.typologie_choisie, dimension: p.dimension ? { valeur: p.dimension.valeur, unite: p.dimension.unite } : null, cout_eur_ht: p.cout_eur_ht, force_par_couplage: !!p.force_par_couplage }))
|
||||
|
||||
test('pipeline complet extrait = selectionner() d\'origine, coefficients d\'origine, trois profils', () => {
|
||||
test('pipeline complet extrait = selectionner() d\'origine aux écarts correctifs figés près (coefficients d\'origine, trois profils)', () => {
|
||||
assert.equal(PIN_ORIGINE, 'sorties-origine-2026-09-28.json', 'l\'épingle d\'origine reste celle du code d\'astro-pro')
|
||||
assert.equal(correctifsPin.epingle_origine, PIN_ORIGINE)
|
||||
const { ecarts } = calculeEcartsCorrectifs()
|
||||
assert.deepEqual(ecarts, correctifsPin.ecarts, 'les écarts avec l\'origine ont changé : ce n\'est voulu que pour corriger un défaut du noyau, avec test ; refiger avec node scripts/ecarts-correctifs.mjs --ecrire <date> et le dire dans le commit')
|
||||
// Un correctif ne fait jamais apparaître un « budget insuffisant » là où l'origine n'en avait pas,
|
||||
// ni un total hors de la bande ±5 % en mode global.
|
||||
for (const e of ecarts) {
|
||||
if (e.champ === 'status') assert.notEqual(e.corrige, 'BUDGET_INSUFFISANT', `${e.profil}/${e.mode}/${e.axe}`)
|
||||
if (e.champ === 'ecart_budget_pct') assert.ok(Math.abs(e.corrige) <= 5, `${e.profil} : écart global ${e.corrige} %`)
|
||||
}
|
||||
// Ce que l'origine avait de juste reste : budgets alloués et poids des axes, inchangés
|
||||
for (const profil of profils) {
|
||||
const attendu = pin.profils[profil.nom].global
|
||||
const r = autonomie.noyau.selectionner(profil.inputs, options, postes, france_climat, { config: algo_config, coefficients: origine }, false)
|
||||
assert.equal(r.total_eur_ht, attendu.total_eur_ht, `${profil.nom} : total`)
|
||||
assert.equal(r.status, attendu.status, `${profil.nom} : statut`)
|
||||
assert.equal(r.ecart_budget_pct, attendu.ecart_budget_pct, `${profil.nom} : écart`)
|
||||
assert.deepEqual(r.couplages, attendu.couplages, `${profil.nom} : journal des couplages`)
|
||||
for (const axe of AXES) {
|
||||
assert.equal(r.axes[axe].budget_alloue, attendu.axes[axe].budget_alloue, `${profil.nom}/${axe} : budget alloué`)
|
||||
assert.equal(r.axes[axe].poids, attendu.axes[axe].poids, `${profil.nom}/${axe} : poids`)
|
||||
assert.equal(r.axes[axe].sous_total_eur_ht, attendu.axes[axe].sous_total_eur_ht, `${profil.nom}/${axe} : sous-total`)
|
||||
assert.deepEqual(resumePostes(r.axes[axe].postes), attendu.axes[axe].postes, `${profil.nom}/${axe} : postes`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('moteur d\'axe = mode « un seul axe actif » de selectionner() d\'origine, à budget d\'axe égal', () => {
|
||||
test('moteur d\'axe = mode « un seul axe actif » de selectionner() de ce paquet, à budget d\'axe égal', () => {
|
||||
for (const profil of profils) {
|
||||
const contexte = autonomie.noyau.selectionner(profil.inputs, options, postes, france_climat, { config: algo_config, coefficients: origine }, false).contexte
|
||||
for (const axe of AXES) {
|
||||
const attendu = pin.profils[profil.nom].par_axe[axe]
|
||||
const r = MOTEURS[axe].calculer({ autodiag: profil.inputs, budget_axe: attendu.budget_axe, contexte }, { postes, options, algo_config, coefficients: origine })
|
||||
const budget = pin.profils[profil.nom].par_axe[axe].budget_axe
|
||||
const actifs = Object.fromEntries(AXES.map((a) => [a, a === axe]))
|
||||
const attendu = autonomie.noyau.selectionner({ ...profil.inputs, budget_eur: budget }, options, postes, france_climat, { config: autonomie.noyau.configPourAxes(algo_config, actifs), coefficients: origine }, false)
|
||||
const r = MOTEURS[axe].calculer({ autodiag: profil.inputs, budget_axe: budget, contexte }, { postes, options, algo_config, coefficients: origine })
|
||||
assert.equal(r.sorties.axe, axe)
|
||||
assert.equal(r.sorties.sous_total_eur_ht, attendu.axes[axe].sous_total_eur_ht, `${profil.nom}/${axe} : sous-total`)
|
||||
assert.equal(r.sorties.status, attendu.status, `${profil.nom}/${axe} : statut`)
|
||||
assert.deepEqual(resumePostes(r.sorties.postes), attendu.axes[axe].postes, `${profil.nom}/${axe} : postes`)
|
||||
assert.deepEqual(r.sorties.typologies, attendu.axes[axe].postes.map((p) => p.typologie_choisie))
|
||||
assert.deepEqual(resumePostes(r.sorties.postes), resumePostes(attendu.axes[axe].postes), `${profil.nom}/${axe} : postes`)
|
||||
assert.deepEqual(r.sorties.couplages, attendu.couplages, `${profil.nom}/${axe} : journal des couplages`)
|
||||
assert.ok(!r.avertissements.some((a) => /inter-axes/.test(a)), 'aucun couplage inter-axes dans les données')
|
||||
}
|
||||
}
|
||||
@@ -67,7 +81,7 @@ test('intégrateur mode global = selectionner() ; mode par_axe = somme des moteu
|
||||
for (const profil of profils) {
|
||||
const donnees = { postes, options, algo_config, france_climat, coefficients: origine }
|
||||
const g = integrateur.calculer({ autodiag: profil.inputs, budget_total: profil.inputs.budget_eur, mode: 'global' }, donnees)
|
||||
const attendu = pin.profils[profil.nom].global
|
||||
const attendu = autonomie.noyau.selectionner(profil.inputs, options, postes, france_climat, { config: algo_config, coefficients: origine }, false)
|
||||
assert.equal(g.sorties.mode, 'global')
|
||||
assert.equal(g.sorties.total_eur_ht, attendu.total_eur_ht)
|
||||
assert.equal(g.sorties.status, attendu.status)
|
||||
@@ -78,8 +92,8 @@ test('intégrateur mode global = selectionner() ; mode par_axe = somme des moteu
|
||||
assert.deepEqual(p.sorties.budgets, g.sorties.budgets, 'même allocation de budget dans les deux modes')
|
||||
let somme = 0
|
||||
for (const axe of AXES) {
|
||||
const a = pin.profils[profil.nom].par_axe[axe]
|
||||
assert.deepEqual(p.sorties.axes[axe].typologies, a.axes[axe].postes.map((x) => x.typologie_choisie), `${profil.nom}/${axe} : par_axe = moteur d'axe`)
|
||||
const a = MOTEURS[axe].calculer({ autodiag: profil.inputs, budget_axe: g.sorties.budgets[axe], contexte: g.sorties.contexte }, donnees).sorties
|
||||
assert.deepEqual(p.sorties.axes[axe].typologies, a.typologies, `${profil.nom}/${axe} : par_axe = moteur d'axe`)
|
||||
somme += p.sorties.axes[axe].sous_total_eur_ht
|
||||
}
|
||||
assert.equal(p.sorties.total_eur_ht, somme)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Monotonie en fonction du budget (SPEC menu branches fiches §12, v0.1.1) : sur
|
||||
// les trois profils de référence et les trois axes, de 1 000 à 40 000 € par pas
|
||||
// de 500 €, le statut ne repasse jamais de « correct » à « budget insuffisant »,
|
||||
// et le sous-total ne décroît pas au-delà de la tolérance de dépassement
|
||||
// (`budget_overrun_pct`, 5 % du budget). La stricte croissance n'est pas tenue
|
||||
// par l'heuristique (l'étape 3 choisit poste par poste, par paliers ; constaté
|
||||
// −4,4 % au plus sur A/énergie, voir RECAP-LOT-v0.1.1) : la borne retenue est
|
||||
// la bande de tolérance, celle que l'habitant voit déjà comme « correct ».
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { eau, energie, alimentation, contexte as moteurContexte } from '../dist/index.js'
|
||||
|
||||
const lire = (f) => JSON.parse(readFileSync(new URL(`../fixtures/${f}`, import.meta.url), 'utf8'))
|
||||
const options = lire('options.json')
|
||||
const postes = lire('postes.json')
|
||||
const france_climat = lire('france_climat.json')
|
||||
const algo_config = lire('algo-config.json')
|
||||
const profils = lire('profils-reference.json')
|
||||
const donnees = { postes, options, algo_config, france_climat }
|
||||
const MOTEURS = { eau, energie, alim: alimentation }
|
||||
const tol = (algo_config.tolerances.budget_overrun_pct || 5) / 100
|
||||
|
||||
const BUDGETS = []
|
||||
for (let b = 1000; b <= 40000; b += 500) BUDGETS.push(b)
|
||||
|
||||
for (const profil of profils) {
|
||||
const ctx = moteurContexte.calculer({ code_postal: profil.inputs.code_postal }, { france_climat }).sorties
|
||||
for (const [axe, moteur] of Object.entries(MOTEURS)) {
|
||||
test(`${profil.nom} / ${axe} : statut jamais OK → INSUFFISANT, sous-total jamais en baisse au-delà de la tolérance (1 000 → 40 000 € par 500)`, () => {
|
||||
const serie = BUDGETS.map((b) => {
|
||||
const s = moteur.calculer({ autodiag: profil.inputs, budget_axe: b, contexte: ctx }, donnees).sorties
|
||||
assert.equal(s.sous_total_eur_ht, s.postes.reduce((a, p) => a + p.cout_eur_ht, 0), `${b} : sous-total = somme des lignes`)
|
||||
return { b, status: s.status, st: s.sous_total_eur_ht }
|
||||
})
|
||||
for (let i = 1; i < serie.length; i++) {
|
||||
const p = serie[i - 1]
|
||||
const c = serie[i]
|
||||
assert.ok(!(p.status !== 'BUDGET_INSUFFISANT' && c.status === 'BUDGET_INSUFFISANT'), `statut ${p.status} à ${p.b} € puis BUDGET_INSUFFISANT à ${c.b} €`)
|
||||
assert.ok(c.st >= p.st - c.b * tol, `sous-total ${p.st} € à ${p.b} € puis ${c.st} € à ${c.b} € (baisse > ${tol * 100} % du budget)`)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user