diff --git a/src/3.2.1_mur.js b/src/3.2.1_mur.js index b197eae3..f7c3aa43 100644 --- a/src/3.2.1_mur.js +++ b/src/3.2.1_mur.js @@ -80,6 +80,63 @@ function matcherEpaisseurTable(matcher) { }; } +/** + * Matériaux de structure des parois dites « anciennes », seules concernées par l'enduit isolant : + * « parois […] constituées de matériaux traditionnels à savoir pierres, terre, mur à colombage, + * brique ancienne ». + * 2, 3 - pierre de taille et moellons ; 4 - pisé / béton de terre ; 5, 6 - pan de bois (colombage) ; + * 7 - bois (rondin) ; 8, 9 - briques pleines ; 21 - autre matériau traditionnel ancien. + * + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.2.1 + * @see https://github.com/Open3CL/engine/issues/295 + */ +export const MATERIAUX_PAROI_ANCIENNE = [2, 3, 4, 5, 6, 7, 8, 9, 21]; + +/** + * Umur0 interpolé linéairement entre les deux colonnes d'épaisseur de la table Umur0 qui encadrent + * strictement l'épaisseur saisie (pratique de certains logiciels, ex : béton banché de 32 cm). + * + * La méthode ne précise pas de règle d'interpolation pour Umur0 : cette valeur n'est utilisée qu'en + * compatibilité (bug_for_bug), lorsque le DPE d'origine l'a manifestement appliquée. + * + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.2.1.2 + * @see https://github.com/Open3CL/engine/issues/295 + * + * @param enumMateriauxStructureMurId {string|number} + * @param epaisseur {number|string} + * @returns {number|undefined} Umur0 interpolé, ou undefined si l'épaisseur n'est pas numérique, + * est une colonne de la table ou se situe hors des colonnes de la table + */ +export function umur0Interpole(enumMateriauxStructureMurId, epaisseur) { + const ep = Number(epaisseur); + if (!Number.isFinite(ep)) return undefined; + + const points = tvs.umur0 + .filter( + (row) => + String(row.enum_materiaux_structure_mur_id) === String(enumMateriauxStructureMurId) && + row.epaisseur_structure + ) + .map((row) => ({ + epaisseur: parseFloat(String(row.epaisseur_structure).replace(/[≤≥\s]/g, '')), + umur0: Number(row.umur0) + })) + .filter((point) => Number.isFinite(point.epaisseur)) + .sort((a, b) => a.epaisseur - b.epaisseur); + + for (let i = 0; i < points.length - 1; i++) { + const bas = points[i]; + const haut = points[i + 1]; + if (ep > bas.epaisseur && ep < haut.epaisseur) { + return ( + bas.umur0 + + ((haut.umur0 - bas.umur0) * (ep - bas.epaisseur)) / (haut.epaisseur - bas.epaisseur) + ); + } + } + return undefined; +} + function tv_umur0(di, de, du) { const matcher = { enum_materiaux_structure_mur_id: de.enum_materiaux_structure_mur_id @@ -142,6 +199,27 @@ function tv_umur0(di, de, du) { if (row) { di.umur0 = Number(row.umur0); de.tv_umur0_id = Number(row.tv_umur0_id); + + /** + * Certains logiciels interpolent Umur0 entre les deux colonnes d'épaisseur qui encadrent + * l'épaisseur saisie (ex : 2,28 pour un béton banché de 32 cm, entre 30 et 35 cm). + * Si l'Umur0 du DPE correspond à cette interpolation (tolérance empirique de 0,006), on la reprend. + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.2.1.2 + * @see https://github.com/Open3CL/engine/issues/295 + */ + if (bug_for_bug_compat && du.umur0_avant !== undefined) { + const umur0Interp = umur0Interpole( + de.enum_materiaux_structure_mur_id, + matcher.epaisseur_structure + ); + if ( + umur0Interp !== undefined && + Math.abs(umur0Interp - di.umur0) > 0.001 && + Math.abs(umur0Interp - Number(du.umur0_avant)) < 0.006 + ) { + di.umur0 = umur0Interp; + } + } } else { console.error('!! pas de valeur forfaitaire trouvée pour umur0 !!'); } @@ -242,7 +320,25 @@ function calc_umur0(di, de, du, versionDpe) { * @see https://github.com/Open3CL/engine/issues/146 */ const type_isolation = parseInt(de.enum_type_isolation_id) || 1; - const hasIteOrIti = versionDpe >= 2.4 && [3, 4, 6, 7, 8].includes(type_isolation); + const isolationIteOuIti = [3, 4, 6, 7, 8].includes(type_isolation); + let hasIteOrIti = versionDpe >= 2.4 && isolationIteOuIti; + + /** + * DPE antérieurs à la version 2.4 : certains logiciels n'y cumulent déjà pas le doublage avec une + * isolation ITE/ITI. Si l'Umur0 du DPE vaut min(2,5 ; Umur0 de la paroi nue), le doublage n'a pas + * été appliqué : on ne l'applique pas non plus. + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.2.1 + * @see https://github.com/Open3CL/engine/issues/295 + */ + if ( + bug_for_bug_compat && + !hasIteOrIti && + isolationIteOuIti && + umur0_avant !== undefined && + Math.abs(Number(umur0_avant) - Math.min(2.5, Number(di.umur0))) < 0.001 + ) { + hasIteOrIti = true; + } // Si présence d'un doublage et pas d'isolation ITE/ITI if (type_doublage > 2 && !hasIteOrIti) { @@ -272,10 +368,24 @@ function calc_umur0(di, de, du, versionDpe) { const umur0Enduit = 1 / (1 / di.umur0 + 0.7); di.umur0Enduit = umur0Enduit; + /** + * L'Umur0 exporté par certains logiciels est la valeur avant enduit, alors que l'Umur exporté + * intègre l'enduit. Si l'Umur du DPE vaut min(2,5 ; Umur0 avec enduit), le DPE a bien appliqué + * l'enduit : l'Umur0 d'origine ne doit pas être conservé. + * Garde : l'enduit isolant ne vaut que pour les parois anciennes (MATERIAUX_PAROI_ANCIENNE). + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.2.1 + * @see https://github.com/Open3CL/engine/issues/295 + */ + const enduitAppliqueDansDpe = + MATERIAUX_PAROI_ANCIENNE.includes(parseInt(de.enum_materiaux_structure_mur_id)) && + umur_avant !== undefined && + Math.abs(Number(umur_avant) - Math.min(2.5, umur0Enduit)) < 0.001; + if ( bug_for_bug_compat && umur0_avant !== undefined && - parseFloat(umur0_avant).toFixed(3) === Math.min(2.5, di.umur0).toFixed(3) + parseFloat(umur0_avant).toFixed(3) === Math.min(2.5, di.umur0).toFixed(3) && + !enduitAppliqueDansDpe ) { console.error(` Le mur ${de.description} est une paroi dite 'paroi ancienne avec enduit', la valeur de Umur0 devrait prendre en compte cet enduit et devrait être ${umur0Enduit}. diff --git a/src/3.2.1_mur.spec.js b/src/3.2.1_mur.spec.js index b74c85ab..1de4a093 100644 --- a/src/3.2.1_mur.spec.js +++ b/src/3.2.1_mur.spec.js @@ -37,7 +37,32 @@ vi.mock('./tv.js', () => ({ { tv_umur0_id: '144', enum_materiaux_structure_mur_id: '26', epaisseur_structure: '10' }, { tv_umur0_id: '145', enum_materiaux_structure_mur_id: '26', epaisseur_structure: '15' }, { tv_umur0_id: '146', enum_materiaux_structure_mur_id: '26', epaisseur_structure: '20' }, - { tv_umur0_id: '151', enum_materiaux_structure_mur_id: '26', epaisseur_structure: '≥ 45' } + { tv_umur0_id: '151', enum_materiaux_structure_mur_id: '26', epaisseur_structure: '≥ 45' }, + // Béton banché (13) avec valeurs d'Umur0, pour l'interpolation (issue #295) + { + tv_umur0_id: '80', + enum_materiaux_structure_mur_id: '13', + epaisseur_structure: '35', + umur0: '2.2' + }, + { + tv_umur0_id: '79', + enum_materiaux_structure_mur_id: '13', + epaisseur_structure: '30', + umur0: '2.4' + }, + { + tv_umur0_id: '81', + enum_materiaux_structure_mur_id: '13', + epaisseur_structure: '≥ 45', + umur0: '1.9' + }, + { + tv_umur0_id: '82', + enum_materiaux_structure_mur_id: '13', + epaisseur_structure: 'non numérique', + umur0: '9' + } ] } })); @@ -54,7 +79,12 @@ vi.mock('./utils.js', () => ({ } })); -const { default: calc_mur, epaisseurStructureTable } = await import('./3.2.1_mur.js'); +const { + default: calc_mur, + epaisseurStructureTable, + umur0Interpole, + MATERIAUX_PAROI_ANCIENNE +} = await import('./3.2.1_mur.js'); const { tv, getThicknessFromDescription } = await import('./utils.js'); /** Table `umur0` figée : U0 forfaitaire de la paroi nue. */ @@ -1006,3 +1036,243 @@ describe('epaisseurStructureTable - épaisseur hors table (issue #138)', () => { expect(mur.donnee_entree.epaisseur_structure).toBe(16); }); }); + +/** + * Correctifs de compatibilité (bug_for_bug) sur les murs. + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.2.1 + * @see https://github.com/Open3CL/engine/issues/295 + */ +describe('issue #295 - enduit isolant sur paroi ancienne (MUR1)', () => { + /** Umur0 table (mock) = 2 ; Umur0 avec enduit = 1 / (1/2 + 0,7). */ + const UMUR0_ENDUIT = 0.8333333333333334; + + /** + * @param materiau {string} + * @param donnee_intermediaire {object} + */ + const murAvecEnduit = (materiau, donnee_intermediaire) => ({ + donnee_entree: { + methode_saisie_u: 'non isolé', + methode_saisie_u0: + 'déterminé selon le matériau et épaisseur à partir de la table de valeur forfaitaire', + enum_materiaux_structure_mur_id: materiau, + epaisseur_structure: 30, + enum_type_doublage_id: '2', + enduit_isolant_paroi_ancienne: 1, + description: 'mur' + }, + donnee_intermediaire + }); + + beforeEach(() => { + bugForBugCompat = true; + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + test.each([['2'], ['8'], ['9']])( + "matériau ancien %s : Umur0 exporté avant enduit mais Umur avec enduit => l'enduit est appliqué", + (materiau) => { + // Cas 2495E2914184U : umur0 exporté = 2 (avant enduit), umur exporté = Umur0 avec enduit + const mur = murAvecEnduit(materiau, { umur0: 2, umur: UMUR0_ENDUIT }); + + calc_mur(mur, 'h1a', '1', '0'); + + expect(mur.donnee_intermediaire.umur0).toBeCloseTo(UMUR0_ENDUIT, 9); + expect(mur.donnee_intermediaire.umur).toBeCloseTo(UMUR0_ENDUIT, 9); + } + ); + + test("matériau ancien : Umur du DPE sans enduit => l'Umur0 d'origine est conservé", () => { + const mur = murAvecEnduit('2', { umur0: 2, umur: 2 }); + + calc_mur(mur, 'h1a', '1', '0'); + + expect(mur.donnee_intermediaire.umur0).toBe(2); + }); + + test.each([['1'], ['12'], ['13']])( + "matériau %s (paroi non ancienne) : la correspondance de l'Umur avec enduit n'est pas retenue", + (materiau) => { + const mur = murAvecEnduit(materiau, { umur0: 2, umur: UMUR0_ENDUIT }); + + calc_mur(mur, 'h1a', '1', '0'); + + expect(mur.donnee_intermediaire.umur0).toBe(2); + } + ); + + test('hors bug_for_bug : l’enduit est toujours appliqué', () => { + bugForBugCompat = false; + const mur = murAvecEnduit('13', { umur0: 2, umur: 2 }); + + calc_mur(mur, 'h1a', '1', '0'); + + expect(mur.donnee_intermediaire.umur0).toBeCloseTo(UMUR0_ENDUIT, 9); + }); + + test('liste des matériaux de paroi ancienne (pierres, terre, colombage, brique ancienne)', () => { + expect(MATERIAUX_PAROI_ANCIENNE).toEqual([2, 3, 4, 5, 6, 7, 8, 9, 21]); + // inconnu, blocs de béton creux et béton banché ne sont pas des parois anciennes + [1, 12, 13].forEach((materiau) => expect(MATERIAUX_PAROI_ANCIENNE).not.toContain(materiau)); + }); +}); + +describe('issue #295 - interpolation de Umur0 entre deux épaisseurs (MUR2)', () => { + test.each([ + { epaisseur: 32, attendu: 2.32, cas: '32 cm entre 30 (2,4) et 35 (2,2)' }, + { epaisseur: '33', attendu: 2.28, cas: 'épaisseur fournie en chaîne' }, + { epaisseur: 40, attendu: 2.05, cas: '40 cm entre 35 (2,2) et « ≥ 45 » (1,9)' } + ])('umur0Interpole : $cas', ({ epaisseur, attendu }) => { + expect(umur0Interpole('13', epaisseur)).toBeCloseTo(attendu, 9); + }); + + test.each([ + { materiau: '13', epaisseur: 30, cas: 'épaisseur égale à une colonne' }, + { materiau: '13', epaisseur: 25, cas: 'sous la première colonne' }, + { materiau: '13', epaisseur: 50, cas: 'au-delà de la dernière colonne' }, + { materiau: '13', epaisseur: 'abc', cas: 'épaisseur non numérique' }, + { materiau: '13', epaisseur: undefined, cas: 'épaisseur absente' }, + { materiau: '99', epaisseur: 32, cas: 'matériau absent de la table' } + ])('umur0Interpole : pas d’interpolation ($cas)', ({ materiau, epaisseur }) => { + expect(umur0Interpole(materiau, epaisseur)).toBeUndefined(); + }); + + /** + * @param epaisseur {number} + * @param donnee_intermediaire {object|undefined} + */ + const murBetonBanche = (epaisseur, donnee_intermediaire) => ({ + donnee_entree: { + methode_saisie_u: 'non isolé', + methode_saisie_u0: + 'déterminé selon le matériau et épaisseur à partir de la table de valeur forfaitaire', + enum_materiaux_structure_mur_id: '13', + epaisseur_structure: epaisseur, + enum_type_doublage_id: '2', + description: 'mur en béton banché' + }, + donnee_intermediaire + }); + + beforeEach(() => { + // La table (mockée) renvoie la colonne 30 cm du béton banché + tv.mockImplementation((table) => { + if (table === 'umur0') return { umur0: '2.4', tv_umur0_id: '79' }; + return null; + }); + }); + + test("bug_for_bug : Umur0 du DPE égal à l'interpolation => valeur interpolée retenue", () => { + bugForBugCompat = true; + // Cas 2463E3034751T : Umur0 interpolé entre deux colonnes + const mur = murBetonBanche(32, { umur0: 2.318 }); + + calc_mur(mur, 'h1a', '1', '0'); + + expect(tv).toHaveBeenCalledWith('umur0', { + enum_materiaux_structure_mur_id: '13', + epaisseur_structure: 30 + }); + expect(mur.donnee_intermediaire.umur0).toBeCloseTo(2.32, 9); + }); + + test.each([ + { bug: true, avant: { umur0: 2.4 }, cas: 'Umur0 du DPE égal à la colonne de la table' }, + { bug: true, avant: { umur0: 2.326 }, cas: 'écart à l’interpolation ≥ 0,006' }, + { bug: true, avant: undefined, cas: 'pas d’Umur0 dans le DPE' }, + { bug: false, avant: { umur0: 2.32 }, cas: 'hors bug_for_bug' } + ])('valeur de la table conservée : $cas', ({ bug, avant }) => { + bugForBugCompat = bug; + + const mur = murBetonBanche(32, avant); + calc_mur(mur, 'h1a', '1', '0'); + + expect(mur.donnee_intermediaire.umur0).toBe(2.4); + }); + + test('bug_for_bug : interpolation confondue avec la valeur de la table => table conservée', () => { + bugForBugCompat = true; + tv.mockImplementation((table) => { + if (table === 'umur0') return { umur0: '2.3205', tv_umur0_id: '79' }; + return null; + }); + + const mur = murBetonBanche(32, { umur0: 2.32 }); + calc_mur(mur, 'h1a', '1', '0'); + + expect(mur.donnee_intermediaire.umur0).toBe(2.3205); + }); + + test('bug_for_bug : épaisseur égale à une colonne => table conservée', () => { + bugForBugCompat = true; + + const mur = murBetonBanche(30, { umur0: 2.4 }); + calc_mur(mur, 'h1a', '1', '0'); + + expect(mur.donnee_intermediaire.umur0).toBe(2.4); + }); +}); + +describe('issue #295 - doublage et isolation ITE/ITI pour les DPE < 2.4 (MUR3)', () => { + /** Umur0 avec doublage type 5 : 1 / (1/2 + 0,21). */ + const UMUR0_AVEC_DOUBLAGE = 1.4084507042253522; + + /** + * @param enum_type_isolation_id {string} + * @param donnee_intermediaire {object|undefined} + */ + const murIsoleAvecDoublage = (enum_type_isolation_id, donnee_intermediaire) => ({ + donnee_entree: { + methode_saisie_u: 'epaisseur isolation saisie justifiée par mesure ou observation', + methode_saisie_u0: + 'déterminé selon le matériau et épaisseur à partir de la table de valeur forfaitaire', + enum_materiaux_structure_mur_id: '5', + epaisseur_structure: 30, + epaisseur_isolation: 10, + enum_type_doublage_id: '5', + enum_type_isolation_id + }, + donnee_intermediaire + }); + + test.each([['3'], ['4'], ['6'], ['7'], ['8']])( + 'bug_for_bug, v2.2, isolation %s : Umur0 du DPE = paroi nue => doublage non appliqué', + (typeIsolation) => { + bugForBugCompat = true; + // Cas 2375E0900891Q : Umur0 exporté = min(2,5 ; Umur0 nu) + const mur = murIsoleAvecDoublage(typeIsolation, { umur0: 2 }); + + calc_mur(mur, 'h1a', '1', '0', 2.2); + + expect(mur.donnee_intermediaire.umur0).toBe(2); + } + ); + + test.each([ + { + bug: true, + iso: '3', + avant: { umur0: UMUR0_AVEC_DOUBLAGE }, + cas: 'Umur0 du DPE avec doublage' + }, + { bug: true, iso: '3', avant: undefined, cas: 'pas d’Umur0 dans le DPE' }, + { bug: true, iso: '2', avant: { umur0: 2 }, cas: 'mur sans isolation ITE/ITI' }, + { bug: false, iso: '3', avant: { umur0: 2 }, cas: 'hors bug_for_bug' } + ])('v2.2 : doublage appliqué ($cas)', ({ bug, iso, avant }) => { + bugForBugCompat = bug; + + const mur = murIsoleAvecDoublage(iso, avant); + calc_mur(mur, 'h1a', '1', '0', 2.2); + + expect(mur.donnee_intermediaire.umur0).toBeCloseTo(UMUR0_AVEC_DOUBLAGE, 9); + }); + + test("bug_for_bug, v2.4 : comportement inchangé (doublage non cumulé à l'ITI)", () => { + bugForBugCompat = true; + + const mur = murIsoleAvecDoublage('3', { umur0: UMUR0_AVEC_DOUBLAGE }); + calc_mur(mur, 'h1a', '1', '0', 2.4); + + expect(mur.donnee_intermediaire.umur0).toBe(2); + }); +});