Noyau : doublons, accessoires forcés orphelins, statut non monotone (SPEC menu §12 n° 1, 2, 3, 5, 7)
Observé le 02/10 sur une maison d'essai et reproduit sur les trois profils de
référence : une même typologie retenue jusqu'à sept fois dans un axe, des
accessoires forcés sans principal (conduit sans poêle, ballon sans poêle hydro),
EAU correcte à 3 000 € mais « budget insuffisant, minimum 7 280 € » à 6 000 €,
chaque FORCE écrit deux à quatre fois dans le journal. Un test par défaut, qui
échoue sur v0.1.0 (master 648015a) et passe ici.
CHANGELOG
- reajuste (CAS B, stratégie 1) : une ligne forcée par couplage n'est plus
« upgradée » (l'origine la triait en tête avec son score 0,8 et la
transformait, puis la passe couplages re-forçait l'original) ; la cible d'un
upgrade n'est jamais le choix d'un autre poste ; le coût des couplages requis
compte le requis égal au code actuel de la ligne ; un upgrade dont l'effet net
baisse le total (il lâche un accessoire plus cher que lui) est refusé.
- reajuste (CAS B, stratégie 2) : un poste = une ligne (l'origine testait le
code et pas le poste, sur une liste figée avant la boucle : jusqu'à cinq
basse_planche_bec). La quantité d'un poste est sa dimension. La ligne ajoutée
porte ses vrais couplages (l'origine écrivait []).
- reajuste (CAS A) : un downgrade ne cible jamais le choix d'un autre poste ; la
baisse réelle compte les accessoires forcés lâchés ; après chaque retrait ou
downgrade, les accessoires orphelins sont lâchés (le plancher de coût n'est
plus verrouillé par des couplages que plus rien ne requiert).
- reajuste : une ligne remplacée porte SA dimension, pas celle de la ligne
remplacée (une batterie au plomb affichait « 0,4 kWc », une chaudière « 6 ml » ;
le taux énergie lisait ces kWc fantômes).
- couplages : passe 0 — RETIRE les lignes forcées que plus rien ne requiert,
ABSORBE celles dont la typologie est déjà le choix d'un poste ; helpers
codesRequisPar, codesChoisis, retireForcesOrphelins exportés.
- algo (finaliseSelection) : le journal ne se ré-ajoute plus à lui-même à
chaque tour (reajusteBudget le renvoie en écho) ; la boucle vise la bande
[−underspend, +overrun] et pas seulement le dépassement, trois tours au plus,
toujours une passe couplages en dernier ; si le dernier forçage refranchit le
seuil, le statut le dit. finaliseSelection exporté du noyau.
Tests : test/correctifs-v0.1.1.test.mjs (n° 1, 2, 3, 5, 7 + dimension héritée),
test/monotonie.test.mjs (trois profils × trois axes, 1 000 → 40 000 € par 500 :
le statut ne repasse jamais de correct à insuffisant ; le sous-total ne baisse
jamais au-delà de la tolérance de dépassement — la stricte croissance n'est pas
tenue par l'heuristique, −4,4 % au plus sur A/énergie, voir RECAP-LOT-v0.1.1).
Les 28 tests d'origine passent, inchangés.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgtn7bJxTLiL2fRCkQMgtR
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
648015a879
commit
91c755f884
@@ -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')
|
||||
})
|
||||
@@ -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