@open3cl/engine 1.7.1 → 1.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@ import { updateGenerateurBouilleurs } from './13.2_generateur_combustion_bouille
4
4
  import { updateGenerateurChaudieres } from './13.2_generateur_combustion_chaudiere.js';
5
5
  import { updateGenerateurPacs } from './13.2_generateur_pac.js';
6
6
  import getFicheTechnique from './ficheTechnique.js';
7
- import { evaluate } from 'mathjs';
7
+ import { evaluateFormula as evaluateTvFormula } from './formula.js';
8
8
 
9
9
  function criterePn(Pn, matcher) {
10
10
  let critere_list = tvColumnLines('generateur_combustion', 'critere_pn', matcher);
@@ -38,15 +38,7 @@ const F_tab = {
38
38
  };
39
39
 
40
40
  function evaluateFormula(formulaOrValue, pn, E, F) {
41
- const pnVariable = pn / 1000;
42
- if (!isNaN(formulaOrValue)) {
43
- return Number(formulaOrValue);
44
- }
45
- const variables = new Map();
46
- variables.set('Pn', pnVariable);
47
- variables.set('E', E);
48
- variables.set('F', F);
49
- return evaluate(formulaOrValue, variables);
41
+ return evaluateTvFormula(formulaOrValue, pn, E, F);
50
42
  }
51
43
 
52
44
  /**
package/3.1_b.js CHANGED
@@ -1,8 +1,47 @@
1
1
  import enums from './enums.js';
2
2
  import { tv, requestInput, requestInputID, bug_for_bug_compat } from './utils.js';
3
3
 
4
+ const RANGES = [0.25, 0.5, 0.75, 1, 1.25, 2, 2.5, 3, 3.5, 4, 6, 8, 10, 25, 50];
5
+
6
+ /**
7
+ * Identifiants de type d'adjacence pour lesquels le ratio aiu/aue est pris en compte.
8
+ */
9
+ const ADJACENCE_AIU_AUE_IDS = new Set([
10
+ '8',
11
+ '9',
12
+ '11',
13
+ '12',
14
+ '13',
15
+ '14',
16
+ '15',
17
+ '16',
18
+ '17',
19
+ '18',
20
+ '19',
21
+ '21'
22
+ ]);
23
+
24
+ const TYPE_ADJACENCE_IDS = Object.keys(enums.type_adjacence);
25
+
26
+ /**
27
+ * Formate une borne du ratio aiu/aue au format des tables de valeurs (`0,25`, `1,00`…).
28
+ *
29
+ * `toLocaleString(undefined, { minimumFractionDigits: 2, … })` construisait un
30
+ * `Intl.NumberFormat` à chaque appel (~29 µs), ce qui représentait l'essentiel du
31
+ * temps CPU de `b()`. `toFixed(2)` produit exactement les mêmes chaînes pour toutes
32
+ * les valeurs de `RANGES`, en ~0,2 µs, et sans dépendre de la locale de la machine —
33
+ * le `.replace('.', ',')` qui suivait était d'ailleurs un no-op sur une machine en
34
+ * locale française et une vraie conversion ailleurs.
35
+ *
36
+ * @param borne {number}
37
+ * @returns {string}
38
+ */
39
+ function formatBorneAiuAue(borne) {
40
+ return borne.toFixed(2).replace('.', ',');
41
+ }
42
+
4
43
  export function findRanges(inputNumber) {
5
- const ranges = [0.25, 0.5, 0.75, 1, 1.25, 2, 2.5, 3, 3.5, 4, 6, 8, 10, 25, 50];
44
+ const ranges = RANGES;
6
45
  const result = [];
7
46
 
8
47
  if (inputNumber < ranges[0]) {
@@ -28,7 +67,7 @@ export function findRanges(inputNumber) {
28
67
  export default function b(di, de, du, zc_id) {
29
68
  const zc = enums.zone_climatique[zc_id].slice(0, 2);
30
69
 
31
- du.enum_type_adjacence_id = Object.keys(enums.type_adjacence);
70
+ du.enum_type_adjacence_id = TYPE_ADJACENCE_IDS;
32
71
  const matcher = {
33
72
  enum_type_adjacence_id: requestInputID(de, du, 'type_adjacence')
34
73
  };
@@ -37,11 +76,7 @@ export default function b(di, de, du, zc_id) {
37
76
  matcher.zone_climatique = zc;
38
77
  matcher.enum_cfg_isolation_lnc_id = requestInputID(de, du, 'cfg_isolation_lnc');
39
78
  /* du.enum_cfg_isolation_lnc_id = ['6', '7', '8', '9', '10', '11'] */
40
- } else if (
41
- ['8', '9', '11', '12', '13', '14', '15', '16', '17', '18', '19', '21'].includes(
42
- de.enum_type_adjacence_id.toString()
43
- )
44
- ) {
79
+ } else if (ADJACENCE_AIU_AUE_IDS.has(de.enum_type_adjacence_id.toString())) {
45
80
  if (de.surface_aue === 0) {
46
81
  // cf page 10
47
82
  // NOTE: bizarre de regarder aue pour un local chauffé non accessible
@@ -96,36 +131,13 @@ export default function b(di, de, du, zc_id) {
96
131
  // Si le rapport des surfaces est égale à la borne inférieure, utilisation de la borne supérieure
97
132
  // et prise en compte de l'égalité
98
133
  if (ranges[0] === de.surface_aiu / de.surface_aue) {
99
- matcher.aiu_aue_max =
100
- '≤ ' +
101
- ranges[0]
102
- .toLocaleString(undefined, {
103
- minimumFractionDigits: 2,
104
- maximumFractionDigits: 2,
105
- useGrouping: false
106
- })
107
- .replace('.', ',');
134
+ matcher.aiu_aue_max = '≤ ' + formatBorneAiuAue(ranges[0]);
108
135
  } else {
109
- matcher.aiu_aue_min =
110
- ranges[0]
111
- .toLocaleString(undefined, {
112
- minimumFractionDigits: 2,
113
- maximumFractionDigits: 2,
114
- useGrouping: false
115
- })
116
- .replace('.', ',') + ' <';
136
+ matcher.aiu_aue_min = formatBorneAiuAue(ranges[0]) + ' <';
117
137
  }
118
138
  }
119
139
  if (ranges[1]) {
120
- matcher.aiu_aue_max =
121
- '≤ ' +
122
- ranges[1]
123
- .toLocaleString(undefined, {
124
- minimumFractionDigits: 2,
125
- maximumFractionDigits: 2,
126
- useGrouping: false
127
- })
128
- .replace('.', ',');
140
+ matcher.aiu_aue_max = '≤ ' + formatBorneAiuAue(ranges[1]);
129
141
  }
130
142
  }
131
143
  }
package/3.2.1_mur.js CHANGED
@@ -122,7 +122,7 @@ function tv_umur(di, de, du, pc_id, zc, effetJoule) {
122
122
  }
123
123
  }
124
124
 
125
- function calc_umur0(di, de, du) {
125
+ function calc_umur0(di, de, du, versionDpe) {
126
126
  const umur0_avant = du.umur0_avant;
127
127
  const umur_avant = du.umur_avant;
128
128
 
@@ -166,8 +166,17 @@ function calc_umur0(di, de, du) {
166
166
  }
167
167
  }
168
168
 
169
- // Si présence d'un doublage
170
- if (type_doublage > 2) {
169
+ /**
170
+ * Le doublage NE doit PAS être cumulé à une isolation ITE ou ITI.
171
+ * Il doit uniquement être pris en compte en l'absence d'isolation ITE/ITI (ex: ITR seule, non isolé, inconnu).
172
+ * Types avec ITE ou ITI : 3 (iti), 4 (ite), 6 (iti+ite), 7 (iti+itr), 8 (ite+itr)
173
+ * @see https://github.com/Open3CL/engine/issues/146
174
+ */
175
+ const type_isolation = parseInt(de.enum_type_isolation_id) || 1;
176
+ const hasIteOrIti = versionDpe >= 2.4 && [3, 4, 6, 7, 8].includes(type_isolation);
177
+
178
+ // Si présence d'un doublage et pas d'isolation ITE/ITI
179
+ if (type_doublage > 2 && !hasIteOrIti) {
171
180
  let umur0Doublage;
172
181
 
173
182
  // 3 - doublage indéterminé ou lame d'air inf 15 mm
@@ -212,7 +221,7 @@ function calc_umur0(di, de, du) {
212
221
  di.umur0 = Math.min(2.5, di.umur0);
213
222
  }
214
223
 
215
- export default function calc_mur(mur, zc, pc_id, effetJoule) {
224
+ export default function calc_mur(mur, zc, pc_id, effetJoule, versionDpe) {
216
225
  const de = mur.donnee_entree;
217
226
  const du = {};
218
227
  const di = {};
@@ -228,12 +237,12 @@ export default function calc_mur(mur, zc, pc_id, effetJoule) {
228
237
  const methode_saisie_u = requestInput(de, du, 'methode_saisie_u');
229
238
  switch (methode_saisie_u) {
230
239
  case 'non isolé':
231
- calc_umur0(di, de, du);
240
+ calc_umur0(di, de, du, versionDpe);
232
241
  di.umur = Math.min(di.umur0, 2.5);
233
242
  break;
234
243
  case 'epaisseur isolation saisie justifiée par mesure ou observation':
235
244
  case 'epaisseur isolation saisie justifiée à partir des documents justificatifs autorisés': {
236
- calc_umur0(di, de, du);
245
+ calc_umur0(di, de, du, versionDpe);
237
246
  const epaisseurIsolation = requestInput(de, du, 'epaisseur_isolation', 'int') * 0.01;
238
247
 
239
248
  if (epaisseurIsolation) {
@@ -251,7 +260,7 @@ export default function calc_mur(mur, zc, pc_id, effetJoule) {
251
260
  }
252
261
  case "resistance isolation saisie justifiée observation de l'isolant installé et mesure de son épaisseur":
253
262
  case 'resistance isolation saisie justifiée à partir des documents justificatifs autorisés': {
254
- calc_umur0(di, de, du);
263
+ calc_umur0(di, de, du, versionDpe);
255
264
  const resistanceIsolation = requestInput(de, du, 'resistance_isolation', 'float');
256
265
 
257
266
  if (resistanceIsolation) {
@@ -268,19 +277,19 @@ export default function calc_mur(mur, zc, pc_id, effetJoule) {
268
277
  break;
269
278
  }
270
279
  case 'isolation inconnue (table forfaitaire)':
271
- calc_umur0(di, de, du);
280
+ calc_umur0(di, de, du, versionDpe);
272
281
  tv_umur(di, de, du, pc_id, zc, effetJoule);
273
282
  di.umur = Math.min(di.umur, di.umur0);
274
283
  break;
275
284
  case "année d'isolation différente de l'année de construction saisie justifiée (table forfaitaire)": {
276
- calc_umur0(di, de, du);
285
+ calc_umur0(di, de, du, versionDpe);
277
286
  const pi_id = requestInputID(de, du, 'periode_isolation') || pc_id;
278
287
  tv_umur(di, de, du, pi_id, zc, effetJoule);
279
288
  di.umur = Math.min(di.umur, di.umur0);
280
289
  break;
281
290
  }
282
291
  case 'année de construction saisie (table forfaitaire)': {
283
- calc_umur0(di, de, du);
292
+ calc_umur0(di, de, du, versionDpe);
284
293
  // Si l'année d'isolation est connue, il faut l'utiliser et pas l'année de construction
285
294
  let pi_id = de.enum_periode_isolation_id || pc_id;
286
295
  if (!de.enum_periode_isolation_id) {
package/3.2.1_mur.spec.js CHANGED
@@ -819,3 +819,108 @@ describe('calc_mur - méthode de saisie de U inconnue', () => {
819
819
  warn.mockRestore();
820
820
  });
821
821
  });
822
+
823
+ /**
824
+ * Le doublage n'est plus cumulé à une isolation ITE ou ITI à partir de la version 2.4 du DPE.
825
+ * Les DPE antérieurs (2 à 2.3) ont été établis avec le cumul : on conserve ce comportement.
826
+ * @see https://github.com/Open3CL/engine/issues/146
827
+ */
828
+ describe('calc_mur - doublage et isolation ITE/ITI (#146)', () => {
829
+ /** Umur0 issu de la table forfaitaire mockée. */
830
+ const UMUR0_TABLE = 2;
831
+ /** Umur0 avec doublage type 4 ou 5 cumulé : 1 / (1/2 + 0,21). */
832
+ const UMUR0_AVEC_DOUBLAGE = 1.4084507042253522;
833
+
834
+ /**
835
+ * @param enum_type_isolation_id {string}
836
+ * @param enum_type_doublage_id {string}
837
+ * @return {object}
838
+ */
839
+ const murIsoleAvecDoublage = (enum_type_isolation_id, enum_type_doublage_id = '5') => ({
840
+ donnee_entree: {
841
+ methode_saisie_u: 'epaisseur isolation saisie justifiée par mesure ou observation',
842
+ methode_saisie_u0:
843
+ 'déterminé selon le matériau et épaisseur à partir de la table de valeur forfaitaire',
844
+ enum_materiaux_structure_mur_id: '5',
845
+ epaisseur_structure: 30,
846
+ epaisseur_isolation: 10,
847
+ enum_type_doublage_id,
848
+ enum_type_isolation_id
849
+ }
850
+ });
851
+
852
+ describe('DPE en version 2.4 et supérieure', () => {
853
+ test.each([
854
+ ['iti', '3'],
855
+ ['ite', '4'],
856
+ ['iti+ite', '6'],
857
+ ['iti+itr', '7'],
858
+ ['ite+itr', '8']
859
+ ])("isolation %s : le doublage n'est pas cumulé", (_label, typeIsolation) => {
860
+ const mur = murIsoleAvecDoublage(typeIsolation);
861
+
862
+ calc_mur(mur, 'h1a', '1', '0', 2.4);
863
+
864
+ expect(mur.donnee_intermediaire.umur0).toBeCloseTo(UMUR0_TABLE, 9);
865
+ });
866
+
867
+ test.each([
868
+ ['inconnu', '1'],
869
+ ['non isolé', '2'],
870
+ ['itr', '5'],
871
+ ["isolé mais type d'isolation inconnu", '9']
872
+ ])('isolation %s : le doublage est cumulé', (_label, typeIsolation) => {
873
+ const mur = murIsoleAvecDoublage(typeIsolation);
874
+
875
+ calc_mur(mur, 'h1a', '1', '0', 2.4);
876
+
877
+ expect(mur.donnee_intermediaire.umur0).toBeCloseTo(UMUR0_AVEC_DOUBLAGE, 9);
878
+ });
879
+
880
+ test('type_isolation absent : le doublage est cumulé', () => {
881
+ const mur = murIsoleAvecDoublage(undefined);
882
+
883
+ calc_mur(mur, 'h1a', '1', '0', 2.5);
884
+
885
+ expect(mur.donnee_intermediaire.umur0).toBeCloseTo(UMUR0_AVEC_DOUBLAGE, 9);
886
+ });
887
+ });
888
+
889
+ describe('DPE antérieurs à la version 2.4', () => {
890
+ test.each([[2], [2.1], [2.2], [2.3]])(
891
+ 'version %s : le doublage reste cumulé à une isolation ITI',
892
+ (versionDpe) => {
893
+ const mur = murIsoleAvecDoublage('3');
894
+
895
+ calc_mur(mur, 'h1a', '1', '0', versionDpe);
896
+
897
+ expect(mur.donnee_intermediaire.umur0).toBeCloseTo(UMUR0_AVEC_DOUBLAGE, 9);
898
+ }
899
+ );
900
+
901
+ test('version absente : le doublage reste cumulé à une isolation ITE', () => {
902
+ const mur = murIsoleAvecDoublage('4');
903
+
904
+ calc_mur(mur, 'h1a', '1', '0');
905
+
906
+ expect(mur.donnee_intermediaire.umur0).toBeCloseTo(UMUR0_AVEC_DOUBLAGE, 9);
907
+ });
908
+ });
909
+
910
+ test.each([
911
+ ['isolation inconnue (table forfaitaire)'],
912
+ [
913
+ "année d'isolation différente de l'année de construction saisie justifiée (table forfaitaire)"
914
+ ],
915
+ ['année de construction saisie (table forfaitaire)'],
916
+ ['resistance isolation saisie justifiée à partir des documents justificatifs autorisés']
917
+ ])('methode_saisie_u "%s" : la version du DPE est prise en compte', (methode_saisie_u) => {
918
+ const mur = murIsoleAvecDoublage('3');
919
+ mur.donnee_entree.methode_saisie_u = methode_saisie_u;
920
+ mur.donnee_entree.resistance_isolation = 2.5;
921
+
922
+ calc_mur(mur, 'h1a', '1', '0', 2.4);
923
+
924
+ expect(mur.donnee_intermediaire.umur0).toBeCloseTo(UMUR0_TABLE, 9);
925
+ });
926
+ });
@@ -23,6 +23,37 @@ function tv_ug(di, de, du) {
23
23
  }
24
24
  }
25
25
 
26
+ /**
27
+ * Valeurs de Ug tabulées dans `uw`, indexées par (type de baie, matériau de menuiserie).
28
+ *
29
+ * Le scan des 723 lignes de `tvs.uw` — avec un `split('|')` par ligne — était refait
30
+ * pour chaque baie vitrée de chaque DPE, alors que la table est statique.
31
+ *
32
+ * Le tableau est partagé et `getRange()` le trie sur place : le tri par défaut de
33
+ * `Array#sort` étant déterministe, la première mise en cache le laisse trié et les
34
+ * appels suivants produisent exactement le même résultat qu'avec un tableau reconstruit.
35
+ */
36
+ const uwUgValuesCache = new Map();
37
+
38
+ function uwUgValues(enum_type_baie_id, enum_type_materiaux_menuiserie_id) {
39
+ const cacheKey = `${enum_type_baie_id}${enum_type_materiaux_menuiserie_id}`;
40
+ let values = uwUgValuesCache.get(cacheKey);
41
+ if (values === undefined) {
42
+ values = tvs.uw
43
+ .filter((row) => {
44
+ return (
45
+ row.enum_type_baie_id.split('|').includes(enum_type_baie_id) &&
46
+ row.enum_type_materiaux_menuiserie_id
47
+ .split('|')
48
+ .includes(enum_type_materiaux_menuiserie_id)
49
+ );
50
+ })
51
+ .map((row) => parseFloat(row.ug));
52
+ uwUgValuesCache.set(cacheKey, values);
53
+ }
54
+ return values;
55
+ }
56
+
26
57
  function tv_uw(di, de) {
27
58
  const enum_type_baie_id = de.enum_type_baie_id;
28
59
  const matcher = { enum_type_baie_id };
@@ -46,16 +77,7 @@ function tv_uw(di, de) {
46
77
  matcher.enum_type_materiaux_menuiserie_id = enum_type_materiaux_menuiserie_id;
47
78
 
48
79
  // Récupération de toutes les valeurs de Ug présentes dans les tables Uw pour le type de baie et matériaux de la baie vitrée
49
- const ugValues = tvs.uw
50
- .filter((row) => {
51
- return (
52
- row.enum_type_baie_id.split('|').includes(enum_type_baie_id) &&
53
- row.enum_type_materiaux_menuiserie_id
54
- .split('|')
55
- .includes(enum_type_materiaux_menuiserie_id)
56
- );
57
- })
58
- .map((row) => parseFloat(row.ug));
80
+ const ugValues = uwUgValues(enum_type_baie_id, enum_type_materiaux_menuiserie_id);
59
81
 
60
82
  /**
61
83
  * 3.3.2 Coefficients Uw des fenêtres / portes-fenêtres
@@ -1,6 +1,6 @@
1
1
  import enums from './enums.js';
2
2
  import { isNil } from 'lodash-es';
3
- import { tv, requestInput, compareReferences, bug_for_bug_compat } from './utils.js';
3
+ import { tv, requestInput, cleanReference, bug_for_bug_compat } from './utils.js';
4
4
 
5
5
  function defaultValue(type_liaison, pt_di, de) {
6
6
  if (pt_di.k === 0) {
@@ -115,10 +115,20 @@ function tv_k(pt_di, di, de, du, pc_id, logement) {
115
115
  }
116
116
  }
117
117
 
118
+ /**
119
+ * Références du pont thermique nettoyées une seule fois : elles sont confrontées à toutes les
120
+ * parois de plusieurs listes ci-dessous, et `compareReferences` les renettoyait à chaque
121
+ * comparaison. Les affectations de `de.reference_1` / `de.reference_2` ont toutes eu lieu
122
+ * au-dessus, dans le bloc de récupération par description.
123
+ */
124
+ const cleaned_reference_1 = cleanReference(de.reference_1);
125
+ const cleaned_reference_2 = cleanReference(de.reference_2);
126
+
118
127
  let mur = mur_list.find((mur, murIndex) => {
128
+ const cleaned_mur_reference = cleanReference(mur.donnee_entree.reference);
119
129
  if (
120
- compareReferences(mur.donnee_entree.reference, de.reference_1) ||
121
- compareReferences(mur.donnee_entree.reference, de.reference_2)
130
+ cleaned_mur_reference === cleaned_reference_1 ||
131
+ cleaned_mur_reference === cleaned_reference_2
122
132
  ) {
123
133
  return true;
124
134
  }
@@ -190,11 +200,10 @@ function tv_k(pt_di, di, de, du, pc_id, logement) {
190
200
  case 'plancher bas / mur':
191
201
  case 'plancher haut lourd / mur': {
192
202
  const plancher_list = ph_list.concat(pb_list);
193
- const plancher = plancher_list.find(
194
- (plancher) =>
195
- compareReferences(plancher.donnee_entree.reference, de.reference_1) ||
196
- compareReferences(plancher.donnee_entree.reference, de.reference_2)
197
- );
203
+ const plancher = plancher_list.find((plancher) => {
204
+ const cleaned = cleanReference(plancher.donnee_entree.reference);
205
+ return cleaned === cleaned_reference_1 || cleaned === cleaned_reference_2;
206
+ });
198
207
  if (!plancher) {
199
208
  di.k = defaultValue(type_liaison, pt_di, de);
200
209
  console.error(
@@ -331,11 +340,10 @@ function tv_k(pt_di, di, de, du, pc_id, logement) {
331
340
  }
332
341
 
333
342
  const menuiserie_list = bv_list.concat(porte_list);
334
- const menuiserie = menuiserie_list.find(
335
- (men) =>
336
- compareReferences(men.donnee_entree.reference, de.reference_1) ||
337
- compareReferences(men.donnee_entree.reference, de.reference_2)
338
- );
343
+ const menuiserie = menuiserie_list.find((men) => {
344
+ const cleaned = cleanReference(men.donnee_entree.reference);
345
+ return cleaned === cleaned_reference_1 || cleaned === cleaned_reference_2;
346
+ });
339
347
  if (!menuiserie) {
340
348
  di.k = defaultValue(type_liaison, pt_di, de);
341
349
  console.error(
@@ -30,7 +30,7 @@ vi.mock('./utils.js', () => ({
30
30
  set_bug_for_bug_compat: vi.fn(),
31
31
  tv: vi.fn(),
32
32
  requestInput: (de, du, field) => de[field],
33
- compareReferences: (a, b) => a === b,
33
+ cleanReference: (reference) => reference,
34
34
  get bug_for_bug_compat() {
35
35
  return state.bug;
36
36
  }
package/3_deperdition.js CHANGED
@@ -224,7 +224,12 @@ export default function calc_deperdition(cg, zc, th, effetJoule, dpe, Sh) {
224
224
  const declaredPontsThermiques = structuredClone(pt_list);
225
225
  const vt_list = logement.ventilation_collection.ventilation || [];
226
226
 
227
- mur_list.forEach((mur) => calc_mur(mur, zc, pc, effetJoule));
227
+ /**
228
+ * Version de la méthode appliquée au DPE. Absente des DPE partiels : les calculs qui en
229
+ * dépendent retombent alors sur le comportement historique.
230
+ */
231
+ const versionDpe = parseFloat(dpe.administratif?.enum_version_id);
232
+ mur_list.forEach((mur) => calc_mur(mur, zc, pc, effetJoule, versionDpe));
228
233
  pb_list.forEach((pb) => calc_pb(pb, zc, pc, effetJoule, pb_list));
229
234
  ph_list.forEach((ph) => calc_ph(ph, zc, pc, effetJoule));
230
235
  bv_list.forEach((bv) => calc_bv(bv, zc));
@@ -125,6 +125,7 @@ describe('Upt - déperdition linéique des ponts thermiques', () => {
125
125
 
126
126
  /** Fabrique un DPE minimal avec une enveloppe pré-calculée. */
127
127
  function makeDpe({
128
+ enum_version_id = '2.4',
128
129
  mur = [],
129
130
  pb = [],
130
131
  ph = [],
@@ -141,6 +142,7 @@ function makeDpe({
141
142
  }
142
143
  return {
143
144
  numero_dpe: 'TEST',
145
+ administratif: enum_version_id === null ? undefined : { enum_version_id },
144
146
  logement: {
145
147
  sortie: { deperdition },
146
148
  enveloppe: {
@@ -239,6 +241,51 @@ describe("calc_deperdition - agrégation des déperditions de l'enveloppe", () =
239
241
  expect(calc_ventilation).toHaveBeenCalledTimes(1);
240
242
  });
241
243
 
244
+ /**
245
+ * Certains calculs de Umur dépendent de la version de la méthode appliquée au DPE
246
+ * (cumul du doublage avec une isolation ITE/ITI avant la version 2.4).
247
+ * @see https://github.com/Open3CL/engine/issues/146
248
+ */
249
+ describe('version du DPE transmise au calcul des murs', () => {
250
+ /** @return {object[]} */
251
+ const murCollection = () => [
252
+ {
253
+ donnee_entree: { surface_paroi_opaque: 10, enum_type_adjacence_id: '1' },
254
+ donnee_intermediaire: { umur: 0.5, b: 1 }
255
+ }
256
+ ];
257
+
258
+ test.each([
259
+ ['2.3', 2.3],
260
+ ['2.4', 2.4],
261
+ ['2.5', 2.5]
262
+ ])('enum_version_id "%s" transmis à calc_mur', (enum_version_id, expected) => {
263
+ const dpe = makeDpe({
264
+ enum_version_id,
265
+ mur: murCollection(),
266
+ ventilation: [{ donnee_intermediaire: { hvent: 0, hperm: 0 } }],
267
+ deperdition_mur: 5
268
+ });
269
+
270
+ calc_deperdition({ enum_periode_construction_id: '1' }, 'h1a', 'th', '0', dpe, 100);
271
+
272
+ expect(calc_mur).toHaveBeenCalledWith(expect.anything(), 'h1a', '1', '0', expected);
273
+ });
274
+
275
+ test('bloc administratif absent : calc_mur reçoit NaN et conserve le comportement historique', () => {
276
+ const dpe = makeDpe({
277
+ enum_version_id: null,
278
+ mur: murCollection(),
279
+ ventilation: [{ donnee_intermediaire: { hvent: 0, hperm: 0 } }],
280
+ deperdition_mur: 5
281
+ });
282
+
283
+ calc_deperdition({ enum_periode_construction_id: '1' }, 'h1a', 'th', '0', dpe, 100);
284
+
285
+ expect(calc_mur).toHaveBeenCalledWith(expect.anything(), 'h1a', '1', '0', NaN);
286
+ });
287
+ });
288
+
242
289
  /**
243
290
  * calc_Sdep (surface déperditive transmise à la ventilation) exclut les parois à coefficient
244
291
  * b nul et les parois adjacentes à un local non déperditif. Les parois à déperdition nulle
package/README.md CHANGED
@@ -385,32 +385,62 @@ Ces résultats sont **générés automatiquement** à la fin de `npm run test:co
385
385
 
386
386
  <!-- CORPUS:START -->
387
387
 
388
- > **Version `1.6.2`** · branche `main` · généré le 2026-09-11
388
+ > **Version `1.6.2`** · branche `pr-174` · généré le 2026-09-20
389
389
  > Seuil de tolérance **5%**
390
390
 
391
391
  <table>
392
392
  <tr>
393
393
  <td align="center"><strong>9</strong><br/><sub>corpus</sub></td>
394
394
  <td align="center"><strong>89 996</strong><br/><sub>DPE analysés</sub></td>
395
- <td align="center"><strong>59 497</strong><br/><sub>DPE conformes</sub></td>
396
- <td align="center"><strong>66,11 %</strong><br/><sub>réussite globale</sub></td>
395
+ <td align="center"><strong>59 522</strong><br/><sub>DPE conformes</sub></td>
396
+ <td align="center"><strong>66,14 %</strong><br/><sub>réussite globale</sub></td>
397
397
  </tr>
398
398
  </table>
399
399
 
400
400
  | | Corpus | Réussite | | DPE conformes |
401
401
  | :-: | :---------------------------------------------------------------------------------------------------------------------- | ----------: | :--------------------- | -------------: |
402
402
  | 🔴 | **Généraliste**<br/><sub>`corpus_dpe.csv`</sub> | **45,97 %** | `█████████░░░░░░░░░░░` | 4 597 / 10 000 |
403
- | 🟢 | **Appartement · chauffage individuel (2025)**<br/><sub>`dpe_appartement_individuel_chauffage_individuel_2025.csv`</sub> | **91,55 %** | `██████████████████░░` | 9 155 / 10 000 |
404
- | 🟢 | **Logement individuel (2025)**<br/><sub>`dpe_logement_individuel_2025.csv`</sub> | **88,14 %** | `██████████████████░░` | 8 812 / 9 998 |
405
- | 🟢 | **Maison individuelle (2025)**<br/><sub>`dpe_maison_individuelle_2025.csv`</sub> | **87,93 %** | `██████████████████░░` | 8 793 / 10 000 |
406
- | 🟡 | **Immeuble · chauffage individuel**<br/><sub>`dpe_immeuble_chauffage_individuel.csv`</sub> | **73,76 %** | `███████████████░░░░░` | 7 375 / 9 999 |
407
- | 🟡 | **Appartement · chauffage collectif (2025)**<br/><sub>`dpe_appartement_individuel_chauffage_collectif_2025.csv`</sub> | **69,47 %** | `██████████████░░░░░░` | 6 947 / 10 000 |
408
- | 🟡 | **Immeuble · chauffage collectif**<br/><sub>`dpe_immeuble_chauffage_collectif.csv`</sub> | **62,27 %** | `████████████░░░░░░░░` | 6 226 / 9 999 |
409
- | 🔴 | **Immeuble · chauffage mixte**<br/><sub>`dpe_immeuble_chauffage_mixte.csv`</sub> | **48,37 %** | `██████████░░░░░░░░░░` | 4 837 / 10 000 |
403
+ | 🟢 | **Appartement · chauffage individuel (2025)**<br/><sub>`dpe_appartement_individuel_chauffage_individuel_2025.csv`</sub> | **91,62 %** | `██████████████████░░` | 9 162 / 10 000 |
404
+ | 🟢 | **Logement individuel (2025)**<br/><sub>`dpe_logement_individuel_2025.csv`</sub> | **88,16 %** | `██████████████████░░` | 8 814 / 9 998 |
405
+ | 🟢 | **Maison individuelle (2025)**<br/><sub>`dpe_maison_individuelle_2025.csv`</sub> | **87,99 %** | `██████████████████░░` | 8 799 / 10 000 |
406
+ | 🟡 | **Immeuble · chauffage individuel**<br/><sub>`dpe_immeuble_chauffage_individuel.csv`</sub> | **73,78 %** | `███████████████░░░░░` | 7 377 / 9 999 |
407
+ | 🟡 | **Appartement · chauffage collectif (2025)**<br/><sub>`dpe_appartement_individuel_chauffage_collectif_2025.csv`</sub> | **69,51 %** | `██████████████░░░░░░` | 6 951 / 10 000 |
408
+ | 🟡 | **Immeuble · chauffage collectif**<br/><sub>`dpe_immeuble_chauffage_collectif.csv`</sub> | **62,30 %** | `████████████░░░░░░░░` | 6 229 / 9 999 |
409
+ | 🔴 | **Immeuble · chauffage mixte**<br/><sub>`dpe_immeuble_chauffage_mixte.csv`</sub> | **48,38 %** | `██████████░░░░░░░░░░` | 4 838 / 10 000 |
410
410
  | 🔴 | **Individuel généré depuis l'immeuble (2026)**<br/><sub>`dpe_individuel_a_partir_dpe_immeuble_2026.csv`</sub> | **27,55 %** | `██████░░░░░░░░░░░░░░` | 2 755 / 10 000 |
411
411
 
412
412
  <sub>🟢 ≥ 85 % · 🟡 ≥ 60 % · 🔴 < 60 %</sub>
413
413
 
414
+ #### Temps d’exécution
415
+
416
+ > Durée de l’appel à `calcul_3cl` par DPE, sur 89 956 calculs.
417
+ > La copie défensive de l’entrée et la lecture du fichier sont exclues de la mesure.
418
+
419
+ <table>
420
+ <tr>
421
+ <td align="center"><strong>5,23 ms</strong><br/><sub>moyenne</sub></td>
422
+ <td align="center"><strong>4,61 ms</strong><br/><sub>médiane</sub></td>
423
+ <td align="center"><strong>0,10 ms</strong><br/><sub>min</sub></td>
424
+ <td align="center"><strong>577,0 ms</strong><br/><sub>max</sub></td>
425
+ </tr>
426
+ </table>
427
+
428
+ | Corpus | Moyenne | Médiane | Min | Max | p95 | p99 |
429
+ | :--------------------------------------------- | ------: | ------: | ------: | -------: | ------: | ------: |
430
+ | **Généraliste** | 4,37 ms | 3,96 ms | 0,22 ms | 35,5 ms | 7,93 ms | 10,8 ms |
431
+ | **Appartement · chauffage individuel (2025)** | 3,49 ms | 3,20 ms | 1,13 ms | 30,2 ms | 6,09 ms | 7,79 ms |
432
+ | **Logement individuel (2025)** | 4,96 ms | 4,38 ms | 1,34 ms | 43,1 ms | 9,48 ms | 12,9 ms |
433
+ | **Maison individuelle (2025)** | 5,88 ms | 5,34 ms | 1,68 ms | 47,0 ms | 10,5 ms | 14,2 ms |
434
+ | **Immeuble · chauffage individuel** | 6,66 ms | 5,73 ms | 0,10 ms | 83,7 ms | 13,6 ms | 21,0 ms |
435
+ | **Appartement · chauffage collectif (2025)** | 4,09 ms | 3,75 ms | 1,26 ms | 37,7 ms | 6,88 ms | 9,51 ms |
436
+ | **Immeuble · chauffage collectif** | 6,04 ms | 5,23 ms | 0,15 ms | 148,9 ms | 11,5 ms | 21,3 ms |
437
+ | **Immeuble · chauffage mixte** | 5,79 ms | 4,77 ms | 0,18 ms | 577,0 ms | 12,4 ms | 20,2 ms |
438
+ | **Individuel généré depuis l'immeuble (2026)** | 5,82 ms | 5,08 ms | 1,65 ms | 71,8 ms | 11,1 ms | 16,9 ms |
439
+
440
+ <sub>La moyenne est tirée vers le haut par les DPE collectifs, dont le coût atteint plusieurs
441
+ dizaines de fois la médiane : c’est la médiane qui décrit le cas courant, et p95/p99 le cas
442
+ défavorable réel.</sub>
443
+
414
444
  <!-- CORPUS:END -->
415
445
 
416
446
  📈 Historique complet des versions : [docs/CORPUS-HISTORY.md](docs/CORPUS-HISTORY.md)
package/engine.js CHANGED
@@ -33,7 +33,7 @@ import getFicheTechnique from './ficheTechnique.js';
33
33
  import { ProductionENR } from './16.2_production_enr.js';
34
34
  import DpeSanitizerService from './dpe-sanitizer.service.js';
35
35
 
36
- const LIB_VERSION = '1.7.1';
36
+ const LIB_VERSION = '1.7.3';
37
37
 
38
38
  const dpeSanitizerService = new DpeSanitizerService();
39
39