@open3cl/engine 1.8.65 → 1.8.67

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.
package/10_clim.js CHANGED
@@ -14,10 +14,22 @@ function tv_seer(di, de, du, zc_id) {
14
14
  }
15
15
  }
16
16
 
17
- export default function calc_clim(clim, bfr, bfr_dep, zc_id, Sh) {
17
+ /**
18
+ * @param clim {object} climatisation
19
+ * @param bfr {number} besoin de froid du bâtiment
20
+ * @param bfr_dep {number} besoin de froid dépensier du bâtiment
21
+ * @param zc_id {string} zone climatique
22
+ * @param Sh {number} surface de référence du calcul (SH immeuble pour un DPE généré depuis l'immeuble)
23
+ * @param prorataLogement {number} part du logement dans la consommation de froid de l'installation
24
+ * (Sh_logement / SH_immeuble pour un DPE appartement généré à partir de l'immeuble, 1 sinon). Les
25
+ * données intermédiaires restent à l'échelle de l'installation, comme dans le DPE de référence ;
26
+ * le prorata n'est appliqué qu'à l'agrégation des consommations du logement (conso.js).
27
+ * @see https://github.com/Open3CL/engine/issues/303
28
+ */
29
+ export default function calc_clim(clim, bfr, bfr_dep, zc_id, Sh, prorataLogement = 1) {
18
30
  const de = clim.donnee_entree;
19
31
  const di = clim.donnee_intermediaire || {};
20
- const du = {};
32
+ const du = { prorata_logement: prorataLogement };
21
33
 
22
34
  const rs = de.surface_clim / Sh;
23
35
  di.besoin_fr = bfr * rs;
package/10_clim.spec.js CHANGED
@@ -105,6 +105,32 @@ describe('calc_clim - consommation de climatisation', () => {
105
105
  expect(c.donnee_intermediaire.eer).toBe(3);
106
106
  });
107
107
 
108
+ /**
109
+ * DPE appartement généré à partir de l'immeuble : la part du logement est transmise via
110
+ * donnee_utilisateur, les données intermédiaires restant à l'échelle de l'installation.
111
+ * @see https://github.com/Open3CL/engine/issues/303
112
+ */
113
+ test('prorata logement : mémorisé, données intermédiaires à l’échelle de l’installation', () => {
114
+ tv.mockReturnValue({ tv_seer_id: '5', eer: 3 });
115
+ const c = clim({ surface_clim: 100, enum_methode_saisie_carac_sys_id: '1' });
116
+
117
+ calc_clim(c, 1000, 1500, '1', 100, 0.25);
118
+
119
+ expect(c.donnee_utilisateur.prorata_logement).toBe(0.25);
120
+ // inchangé par le prorata : 0.9 * 1000 / 3
121
+ expect(c.donnee_intermediaire.besoin_fr).toBeCloseTo(1000, 9);
122
+ expect(c.donnee_intermediaire.conso_fr).toBeCloseTo(300, 9);
123
+ });
124
+
125
+ test('prorata logement absent : 1 par défaut', () => {
126
+ tv.mockReturnValue({ tv_seer_id: '5', eer: 3 });
127
+ const c = clim({ surface_clim: 100, enum_methode_saisie_carac_sys_id: '1' });
128
+
129
+ calc_clim(c, 1000, 1500, '1', 100);
130
+
131
+ expect(c.donnee_utilisateur.prorata_logement).toBe(1);
132
+ });
133
+
108
134
  test('aucune ligne forfaitaire trouvée : EER indéfini, conso non finie', () => {
109
135
  tv.mockReturnValue(null);
110
136
  const c = clim({ surface_clim: 50, enum_methode_saisie_carac_sys_id: '1' });
package/3.2.1_mur.js CHANGED
@@ -80,6 +80,63 @@ function matcherEpaisseurTable(matcher) {
80
80
  };
81
81
  }
82
82
 
83
+ /**
84
+ * Matériaux de structure des parois dites « anciennes », seules concernées par l'enduit isolant :
85
+ * « parois […] constituées de matériaux traditionnels à savoir pierres, terre, mur à colombage,
86
+ * brique ancienne ».
87
+ * 2, 3 - pierre de taille et moellons ; 4 - pisé / béton de terre ; 5, 6 - pan de bois (colombage) ;
88
+ * 7 - bois (rondin) ; 8, 9 - briques pleines ; 21 - autre matériau traditionnel ancien.
89
+ *
90
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.2.1
91
+ * @see https://github.com/Open3CL/engine/issues/295
92
+ */
93
+ export const MATERIAUX_PAROI_ANCIENNE = [2, 3, 4, 5, 6, 7, 8, 9, 21];
94
+
95
+ /**
96
+ * Umur0 interpolé linéairement entre les deux colonnes d'épaisseur de la table Umur0 qui encadrent
97
+ * strictement l'épaisseur saisie (pratique de certains logiciels, ex : béton banché de 32 cm).
98
+ *
99
+ * La méthode ne précise pas de règle d'interpolation pour Umur0 : cette valeur n'est utilisée qu'en
100
+ * compatibilité (bug_for_bug), lorsque le DPE d'origine l'a manifestement appliquée.
101
+ *
102
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.2.1.2
103
+ * @see https://github.com/Open3CL/engine/issues/295
104
+ *
105
+ * @param enumMateriauxStructureMurId {string|number}
106
+ * @param epaisseur {number|string}
107
+ * @returns {number|undefined} Umur0 interpolé, ou undefined si l'épaisseur n'est pas numérique,
108
+ * est une colonne de la table ou se situe hors des colonnes de la table
109
+ */
110
+ export function umur0Interpole(enumMateriauxStructureMurId, epaisseur) {
111
+ const ep = Number(epaisseur);
112
+ if (!Number.isFinite(ep)) return undefined;
113
+
114
+ const points = tvs.umur0
115
+ .filter(
116
+ (row) =>
117
+ String(row.enum_materiaux_structure_mur_id) === String(enumMateriauxStructureMurId) &&
118
+ row.epaisseur_structure
119
+ )
120
+ .map((row) => ({
121
+ epaisseur: parseFloat(String(row.epaisseur_structure).replace(/[≤≥\s]/g, '')),
122
+ umur0: Number(row.umur0)
123
+ }))
124
+ .filter((point) => Number.isFinite(point.epaisseur))
125
+ .sort((a, b) => a.epaisseur - b.epaisseur);
126
+
127
+ for (let i = 0; i < points.length - 1; i++) {
128
+ const bas = points[i];
129
+ const haut = points[i + 1];
130
+ if (ep > bas.epaisseur && ep < haut.epaisseur) {
131
+ return (
132
+ bas.umur0 +
133
+ ((haut.umur0 - bas.umur0) * (ep - bas.epaisseur)) / (haut.epaisseur - bas.epaisseur)
134
+ );
135
+ }
136
+ }
137
+ return undefined;
138
+ }
139
+
83
140
  function tv_umur0(di, de, du) {
84
141
  const matcher = {
85
142
  enum_materiaux_structure_mur_id: de.enum_materiaux_structure_mur_id
@@ -142,6 +199,27 @@ function tv_umur0(di, de, du) {
142
199
  if (row) {
143
200
  di.umur0 = Number(row.umur0);
144
201
  de.tv_umur0_id = Number(row.tv_umur0_id);
202
+
203
+ /**
204
+ * Certains logiciels interpolent Umur0 entre les deux colonnes d'épaisseur qui encadrent
205
+ * l'épaisseur saisie (ex : 2,28 pour un béton banché de 32 cm, entre 30 et 35 cm).
206
+ * Si l'Umur0 du DPE correspond à cette interpolation (tolérance empirique de 0,006), on la reprend.
207
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.2.1.2
208
+ * @see https://github.com/Open3CL/engine/issues/295
209
+ */
210
+ if (bug_for_bug_compat && du.umur0_avant !== undefined) {
211
+ const umur0Interp = umur0Interpole(
212
+ de.enum_materiaux_structure_mur_id,
213
+ matcher.epaisseur_structure
214
+ );
215
+ if (
216
+ umur0Interp !== undefined &&
217
+ Math.abs(umur0Interp - di.umur0) > 0.001 &&
218
+ Math.abs(umur0Interp - Number(du.umur0_avant)) < 0.006
219
+ ) {
220
+ di.umur0 = umur0Interp;
221
+ }
222
+ }
145
223
  } else {
146
224
  console.error('!! pas de valeur forfaitaire trouvée pour umur0 !!');
147
225
  }
@@ -242,7 +320,25 @@ function calc_umur0(di, de, du, versionDpe) {
242
320
  * @see https://github.com/Open3CL/engine/issues/146
243
321
  */
244
322
  const type_isolation = parseInt(de.enum_type_isolation_id) || 1;
245
- const hasIteOrIti = versionDpe >= 2.4 && [3, 4, 6, 7, 8].includes(type_isolation);
323
+ const isolationIteOuIti = [3, 4, 6, 7, 8].includes(type_isolation);
324
+ let hasIteOrIti = versionDpe >= 2.4 && isolationIteOuIti;
325
+
326
+ /**
327
+ * DPE antérieurs à la version 2.4 : certains logiciels n'y cumulent déjà pas le doublage avec une
328
+ * isolation ITE/ITI. Si l'Umur0 du DPE vaut min(2,5 ; Umur0 de la paroi nue), le doublage n'a pas
329
+ * été appliqué : on ne l'applique pas non plus.
330
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.2.1
331
+ * @see https://github.com/Open3CL/engine/issues/295
332
+ */
333
+ if (
334
+ bug_for_bug_compat &&
335
+ !hasIteOrIti &&
336
+ isolationIteOuIti &&
337
+ umur0_avant !== undefined &&
338
+ Math.abs(Number(umur0_avant) - Math.min(2.5, Number(di.umur0))) < 0.001
339
+ ) {
340
+ hasIteOrIti = true;
341
+ }
246
342
 
247
343
  // Si présence d'un doublage et pas d'isolation ITE/ITI
248
344
  if (type_doublage > 2 && !hasIteOrIti) {
@@ -272,10 +368,24 @@ function calc_umur0(di, de, du, versionDpe) {
272
368
  const umur0Enduit = 1 / (1 / di.umur0 + 0.7);
273
369
  di.umur0Enduit = umur0Enduit;
274
370
 
371
+ /**
372
+ * L'Umur0 exporté par certains logiciels est la valeur avant enduit, alors que l'Umur exporté
373
+ * intègre l'enduit. Si l'Umur du DPE vaut min(2,5 ; Umur0 avec enduit), le DPE a bien appliqué
374
+ * l'enduit : l'Umur0 d'origine ne doit pas être conservé.
375
+ * Garde : l'enduit isolant ne vaut que pour les parois anciennes (MATERIAUX_PAROI_ANCIENNE).
376
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.2.1
377
+ * @see https://github.com/Open3CL/engine/issues/295
378
+ */
379
+ const enduitAppliqueDansDpe =
380
+ MATERIAUX_PAROI_ANCIENNE.includes(parseInt(de.enum_materiaux_structure_mur_id)) &&
381
+ umur_avant !== undefined &&
382
+ Math.abs(Number(umur_avant) - Math.min(2.5, umur0Enduit)) < 0.001;
383
+
275
384
  if (
276
385
  bug_for_bug_compat &&
277
386
  umur0_avant !== undefined &&
278
- parseFloat(umur0_avant).toFixed(3) === Math.min(2.5, di.umur0).toFixed(3)
387
+ parseFloat(umur0_avant).toFixed(3) === Math.min(2.5, di.umur0).toFixed(3) &&
388
+ !enduitAppliqueDansDpe
279
389
  ) {
280
390
  console.error(`
281
391
  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}.
package/3.2.1_mur.spec.js CHANGED
@@ -37,7 +37,32 @@ vi.mock('./tv.js', () => ({
37
37
  { tv_umur0_id: '144', enum_materiaux_structure_mur_id: '26', epaisseur_structure: '10' },
38
38
  { tv_umur0_id: '145', enum_materiaux_structure_mur_id: '26', epaisseur_structure: '15' },
39
39
  { tv_umur0_id: '146', enum_materiaux_structure_mur_id: '26', epaisseur_structure: '20' },
40
- { tv_umur0_id: '151', enum_materiaux_structure_mur_id: '26', epaisseur_structure: '≥ 45' }
40
+ { tv_umur0_id: '151', enum_materiaux_structure_mur_id: '26', epaisseur_structure: '≥ 45' },
41
+ // Béton banché (13) avec valeurs d'Umur0, pour l'interpolation (issue #295)
42
+ {
43
+ tv_umur0_id: '80',
44
+ enum_materiaux_structure_mur_id: '13',
45
+ epaisseur_structure: '35',
46
+ umur0: '2.2'
47
+ },
48
+ {
49
+ tv_umur0_id: '79',
50
+ enum_materiaux_structure_mur_id: '13',
51
+ epaisseur_structure: '30',
52
+ umur0: '2.4'
53
+ },
54
+ {
55
+ tv_umur0_id: '81',
56
+ enum_materiaux_structure_mur_id: '13',
57
+ epaisseur_structure: '≥ 45',
58
+ umur0: '1.9'
59
+ },
60
+ {
61
+ tv_umur0_id: '82',
62
+ enum_materiaux_structure_mur_id: '13',
63
+ epaisseur_structure: 'non numérique',
64
+ umur0: '9'
65
+ }
41
66
  ]
42
67
  }
43
68
  }));
@@ -54,7 +79,12 @@ vi.mock('./utils.js', () => ({
54
79
  }
55
80
  }));
56
81
 
57
- const { default: calc_mur, epaisseurStructureTable } = await import('./3.2.1_mur.js');
82
+ const {
83
+ default: calc_mur,
84
+ epaisseurStructureTable,
85
+ umur0Interpole,
86
+ MATERIAUX_PAROI_ANCIENNE
87
+ } = await import('./3.2.1_mur.js');
58
88
  const { tv, getThicknessFromDescription } = await import('./utils.js');
59
89
 
60
90
  /** Table `umur0` figée : U0 forfaitaire de la paroi nue. */
@@ -1006,3 +1036,243 @@ describe('epaisseurStructureTable - épaisseur hors table (issue #138)', () => {
1006
1036
  expect(mur.donnee_entree.epaisseur_structure).toBe(16);
1007
1037
  });
1008
1038
  });
1039
+
1040
+ /**
1041
+ * Correctifs de compatibilité (bug_for_bug) sur les murs.
1042
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.2.1
1043
+ * @see https://github.com/Open3CL/engine/issues/295
1044
+ */
1045
+ describe('issue #295 - enduit isolant sur paroi ancienne (MUR1)', () => {
1046
+ /** Umur0 table (mock) = 2 ; Umur0 avec enduit = 1 / (1/2 + 0,7). */
1047
+ const UMUR0_ENDUIT = 0.8333333333333334;
1048
+
1049
+ /**
1050
+ * @param materiau {string}
1051
+ * @param donnee_intermediaire {object}
1052
+ */
1053
+ const murAvecEnduit = (materiau, donnee_intermediaire) => ({
1054
+ donnee_entree: {
1055
+ methode_saisie_u: 'non isolé',
1056
+ methode_saisie_u0:
1057
+ 'déterminé selon le matériau et épaisseur à partir de la table de valeur forfaitaire',
1058
+ enum_materiaux_structure_mur_id: materiau,
1059
+ epaisseur_structure: 30,
1060
+ enum_type_doublage_id: '2',
1061
+ enduit_isolant_paroi_ancienne: 1,
1062
+ description: 'mur'
1063
+ },
1064
+ donnee_intermediaire
1065
+ });
1066
+
1067
+ beforeEach(() => {
1068
+ bugForBugCompat = true;
1069
+ vi.spyOn(console, 'error').mockImplementation(() => {});
1070
+ });
1071
+
1072
+ test.each([['2'], ['8'], ['9']])(
1073
+ "matériau ancien %s : Umur0 exporté avant enduit mais Umur avec enduit => l'enduit est appliqué",
1074
+ (materiau) => {
1075
+ // Cas 2495E2914184U : umur0 exporté = 2 (avant enduit), umur exporté = Umur0 avec enduit
1076
+ const mur = murAvecEnduit(materiau, { umur0: 2, umur: UMUR0_ENDUIT });
1077
+
1078
+ calc_mur(mur, 'h1a', '1', '0');
1079
+
1080
+ expect(mur.donnee_intermediaire.umur0).toBeCloseTo(UMUR0_ENDUIT, 9);
1081
+ expect(mur.donnee_intermediaire.umur).toBeCloseTo(UMUR0_ENDUIT, 9);
1082
+ }
1083
+ );
1084
+
1085
+ test("matériau ancien : Umur du DPE sans enduit => l'Umur0 d'origine est conservé", () => {
1086
+ const mur = murAvecEnduit('2', { umur0: 2, umur: 2 });
1087
+
1088
+ calc_mur(mur, 'h1a', '1', '0');
1089
+
1090
+ expect(mur.donnee_intermediaire.umur0).toBe(2);
1091
+ });
1092
+
1093
+ test.each([['1'], ['12'], ['13']])(
1094
+ "matériau %s (paroi non ancienne) : la correspondance de l'Umur avec enduit n'est pas retenue",
1095
+ (materiau) => {
1096
+ const mur = murAvecEnduit(materiau, { umur0: 2, umur: UMUR0_ENDUIT });
1097
+
1098
+ calc_mur(mur, 'h1a', '1', '0');
1099
+
1100
+ expect(mur.donnee_intermediaire.umur0).toBe(2);
1101
+ }
1102
+ );
1103
+
1104
+ test('hors bug_for_bug : l’enduit est toujours appliqué', () => {
1105
+ bugForBugCompat = false;
1106
+ const mur = murAvecEnduit('13', { umur0: 2, umur: 2 });
1107
+
1108
+ calc_mur(mur, 'h1a', '1', '0');
1109
+
1110
+ expect(mur.donnee_intermediaire.umur0).toBeCloseTo(UMUR0_ENDUIT, 9);
1111
+ });
1112
+
1113
+ test('liste des matériaux de paroi ancienne (pierres, terre, colombage, brique ancienne)', () => {
1114
+ expect(MATERIAUX_PAROI_ANCIENNE).toEqual([2, 3, 4, 5, 6, 7, 8, 9, 21]);
1115
+ // inconnu, blocs de béton creux et béton banché ne sont pas des parois anciennes
1116
+ [1, 12, 13].forEach((materiau) => expect(MATERIAUX_PAROI_ANCIENNE).not.toContain(materiau));
1117
+ });
1118
+ });
1119
+
1120
+ describe('issue #295 - interpolation de Umur0 entre deux épaisseurs (MUR2)', () => {
1121
+ test.each([
1122
+ { epaisseur: 32, attendu: 2.32, cas: '32 cm entre 30 (2,4) et 35 (2,2)' },
1123
+ { epaisseur: '33', attendu: 2.28, cas: 'épaisseur fournie en chaîne' },
1124
+ { epaisseur: 40, attendu: 2.05, cas: '40 cm entre 35 (2,2) et « ≥ 45 » (1,9)' }
1125
+ ])('umur0Interpole : $cas', ({ epaisseur, attendu }) => {
1126
+ expect(umur0Interpole('13', epaisseur)).toBeCloseTo(attendu, 9);
1127
+ });
1128
+
1129
+ test.each([
1130
+ { materiau: '13', epaisseur: 30, cas: 'épaisseur égale à une colonne' },
1131
+ { materiau: '13', epaisseur: 25, cas: 'sous la première colonne' },
1132
+ { materiau: '13', epaisseur: 50, cas: 'au-delà de la dernière colonne' },
1133
+ { materiau: '13', epaisseur: 'abc', cas: 'épaisseur non numérique' },
1134
+ { materiau: '13', epaisseur: undefined, cas: 'épaisseur absente' },
1135
+ { materiau: '99', epaisseur: 32, cas: 'matériau absent de la table' }
1136
+ ])('umur0Interpole : pas d’interpolation ($cas)', ({ materiau, epaisseur }) => {
1137
+ expect(umur0Interpole(materiau, epaisseur)).toBeUndefined();
1138
+ });
1139
+
1140
+ /**
1141
+ * @param epaisseur {number}
1142
+ * @param donnee_intermediaire {object|undefined}
1143
+ */
1144
+ const murBetonBanche = (epaisseur, donnee_intermediaire) => ({
1145
+ donnee_entree: {
1146
+ methode_saisie_u: 'non isolé',
1147
+ methode_saisie_u0:
1148
+ 'déterminé selon le matériau et épaisseur à partir de la table de valeur forfaitaire',
1149
+ enum_materiaux_structure_mur_id: '13',
1150
+ epaisseur_structure: epaisseur,
1151
+ enum_type_doublage_id: '2',
1152
+ description: 'mur en béton banché'
1153
+ },
1154
+ donnee_intermediaire
1155
+ });
1156
+
1157
+ beforeEach(() => {
1158
+ // La table (mockée) renvoie la colonne 30 cm du béton banché
1159
+ tv.mockImplementation((table) => {
1160
+ if (table === 'umur0') return { umur0: '2.4', tv_umur0_id: '79' };
1161
+ return null;
1162
+ });
1163
+ });
1164
+
1165
+ test("bug_for_bug : Umur0 du DPE égal à l'interpolation => valeur interpolée retenue", () => {
1166
+ bugForBugCompat = true;
1167
+ // Cas 2463E3034751T : Umur0 interpolé entre deux colonnes
1168
+ const mur = murBetonBanche(32, { umur0: 2.318 });
1169
+
1170
+ calc_mur(mur, 'h1a', '1', '0');
1171
+
1172
+ expect(tv).toHaveBeenCalledWith('umur0', {
1173
+ enum_materiaux_structure_mur_id: '13',
1174
+ epaisseur_structure: 30
1175
+ });
1176
+ expect(mur.donnee_intermediaire.umur0).toBeCloseTo(2.32, 9);
1177
+ });
1178
+
1179
+ test.each([
1180
+ { bug: true, avant: { umur0: 2.4 }, cas: 'Umur0 du DPE égal à la colonne de la table' },
1181
+ { bug: true, avant: { umur0: 2.326 }, cas: 'écart à l’interpolation ≥ 0,006' },
1182
+ { bug: true, avant: undefined, cas: 'pas d’Umur0 dans le DPE' },
1183
+ { bug: false, avant: { umur0: 2.32 }, cas: 'hors bug_for_bug' }
1184
+ ])('valeur de la table conservée : $cas', ({ bug, avant }) => {
1185
+ bugForBugCompat = bug;
1186
+
1187
+ const mur = murBetonBanche(32, avant);
1188
+ calc_mur(mur, 'h1a', '1', '0');
1189
+
1190
+ expect(mur.donnee_intermediaire.umur0).toBe(2.4);
1191
+ });
1192
+
1193
+ test('bug_for_bug : interpolation confondue avec la valeur de la table => table conservée', () => {
1194
+ bugForBugCompat = true;
1195
+ tv.mockImplementation((table) => {
1196
+ if (table === 'umur0') return { umur0: '2.3205', tv_umur0_id: '79' };
1197
+ return null;
1198
+ });
1199
+
1200
+ const mur = murBetonBanche(32, { umur0: 2.32 });
1201
+ calc_mur(mur, 'h1a', '1', '0');
1202
+
1203
+ expect(mur.donnee_intermediaire.umur0).toBe(2.3205);
1204
+ });
1205
+
1206
+ test('bug_for_bug : épaisseur égale à une colonne => table conservée', () => {
1207
+ bugForBugCompat = true;
1208
+
1209
+ const mur = murBetonBanche(30, { umur0: 2.4 });
1210
+ calc_mur(mur, 'h1a', '1', '0');
1211
+
1212
+ expect(mur.donnee_intermediaire.umur0).toBe(2.4);
1213
+ });
1214
+ });
1215
+
1216
+ describe('issue #295 - doublage et isolation ITE/ITI pour les DPE < 2.4 (MUR3)', () => {
1217
+ /** Umur0 avec doublage type 5 : 1 / (1/2 + 0,21). */
1218
+ const UMUR0_AVEC_DOUBLAGE = 1.4084507042253522;
1219
+
1220
+ /**
1221
+ * @param enum_type_isolation_id {string}
1222
+ * @param donnee_intermediaire {object|undefined}
1223
+ */
1224
+ const murIsoleAvecDoublage = (enum_type_isolation_id, donnee_intermediaire) => ({
1225
+ donnee_entree: {
1226
+ methode_saisie_u: 'epaisseur isolation saisie justifiée par mesure ou observation',
1227
+ methode_saisie_u0:
1228
+ 'déterminé selon le matériau et épaisseur à partir de la table de valeur forfaitaire',
1229
+ enum_materiaux_structure_mur_id: '5',
1230
+ epaisseur_structure: 30,
1231
+ epaisseur_isolation: 10,
1232
+ enum_type_doublage_id: '5',
1233
+ enum_type_isolation_id
1234
+ },
1235
+ donnee_intermediaire
1236
+ });
1237
+
1238
+ test.each([['3'], ['4'], ['6'], ['7'], ['8']])(
1239
+ 'bug_for_bug, v2.2, isolation %s : Umur0 du DPE = paroi nue => doublage non appliqué',
1240
+ (typeIsolation) => {
1241
+ bugForBugCompat = true;
1242
+ // Cas 2375E0900891Q : Umur0 exporté = min(2,5 ; Umur0 nu)
1243
+ const mur = murIsoleAvecDoublage(typeIsolation, { umur0: 2 });
1244
+
1245
+ calc_mur(mur, 'h1a', '1', '0', 2.2);
1246
+
1247
+ expect(mur.donnee_intermediaire.umur0).toBe(2);
1248
+ }
1249
+ );
1250
+
1251
+ test.each([
1252
+ {
1253
+ bug: true,
1254
+ iso: '3',
1255
+ avant: { umur0: UMUR0_AVEC_DOUBLAGE },
1256
+ cas: 'Umur0 du DPE avec doublage'
1257
+ },
1258
+ { bug: true, iso: '3', avant: undefined, cas: 'pas d’Umur0 dans le DPE' },
1259
+ { bug: true, iso: '2', avant: { umur0: 2 }, cas: 'mur sans isolation ITE/ITI' },
1260
+ { bug: false, iso: '3', avant: { umur0: 2 }, cas: 'hors bug_for_bug' }
1261
+ ])('v2.2 : doublage appliqué ($cas)', ({ bug, iso, avant }) => {
1262
+ bugForBugCompat = bug;
1263
+
1264
+ const mur = murIsoleAvecDoublage(iso, avant);
1265
+ calc_mur(mur, 'h1a', '1', '0', 2.2);
1266
+
1267
+ expect(mur.donnee_intermediaire.umur0).toBeCloseTo(UMUR0_AVEC_DOUBLAGE, 9);
1268
+ });
1269
+
1270
+ test("bug_for_bug, v2.4 : comportement inchangé (doublage non cumulé à l'ITI)", () => {
1271
+ bugForBugCompat = true;
1272
+
1273
+ const mur = murIsoleAvecDoublage('3', { umur0: UMUR0_AVEC_DOUBLAGE });
1274
+ calc_mur(mur, 'h1a', '1', '0', 2.4);
1275
+
1276
+ expect(mur.donnee_intermediaire.umur0).toBe(2);
1277
+ });
1278
+ });
package/README.md CHANGED
@@ -375,57 +375,57 @@ Ces résultats sont **générés automatiquement** à la fin de `npm run test:co
375
375
 
376
376
  <!-- CORPUS:START -->
377
377
 
378
- > **Version `1.8.64`** · branche `main` · généré le 2026-10-15
378
+ > **Version `1.8.66`** · branche `main` · généré le 2026-10-16
379
379
  > Seuil de tolérance **5%**
380
380
 
381
381
  <table>
382
382
  <tr>
383
383
  <td align="center"><strong>9</strong><br/><sub>corpus</sub></td>
384
- <td align="center"><strong>89 418</strong><br/><sub>DPE analysés</sub></td>
385
- <td align="center"><strong>71 309</strong><br/><sub>DPE conformes</sub></td>
386
- <td align="center"><strong>79,75 %</strong><br/><sub>réussite globale</sub></td>
384
+ <td align="center"><strong>88 332</strong><br/><sub>DPE analysés</sub></td>
385
+ <td align="center"><strong>71 434</strong><br/><sub>DPE conformes</sub></td>
386
+ <td align="center"><strong>80,87 %</strong><br/><sub>réussite globale</sub></td>
387
387
  </tr>
388
388
  </table>
389
389
 
390
- | | Corpus | Réussite | | DPE conformes |
391
- | :-: | :---------------------------------------------------------------------------------------------------------------------- | ----------: | :--------------------- | -------------: |
392
- | 🔴 | **Généraliste**<br/><sub>`corpus_dpe.csv`</sub> | **47,91 %** | `██████████░░░░░░░░░░` | 4 791 / 9 999 |
393
- | 🟢 | **Appartement · chauffage individuel (2025)**<br/><sub>`dpe_appartement_individuel_chauffage_individuel_2025.csv`</sub> | **94,37 %** | `███████████████████░` | 9 437 / 10 000 |
394
- | 🟢 | **Appartement · chauffage collectif (2025)**<br/><sub>`dpe_appartement_individuel_chauffage_collectif_2025.csv`</sub> | **93,84 %** | `███████████████████░` | 9 384 / 10 000 |
395
- | 🟢 | **Logement individuel (2025)**<br/><sub>`dpe_logement_individuel_2025.csv`</sub> | **91,80 %** | `██████████████████░░` | 9 173 / 9 992 |
396
- | 🟢 | **Maison individuelle (2025)**<br/><sub>`dpe_maison_individuelle_2025.csv`</sub> | **89,93 %** | `██████████████████░░` | 8 992 / 9 999 |
397
- | 🟢 | **Immeuble · chauffage collectif**<br/><sub>`dpe_immeuble_chauffage_collectif.csv`</sub> | **88,39 %** | `██████████████████░░` | 8 675 / 9 814 |
398
- | 🟢 | **Immeuble · chauffage individuel**<br/><sub>`dpe_immeuble_chauffage_individuel.csv`</sub> | **85,35 %** | `█████████████████░░░` | 8 533 / 9 998 |
399
- | 🟡 | **Immeuble · chauffage mixte**<br/><sub>`dpe_immeuble_chauffage_mixte.csv`</sub> | **69,57 %** | `██████████████░░░░░░` | 6 690 / 9 616 |
400
- | 🔴 | **Individuel généré depuis l'immeuble (2026)**<br/><sub>`dpe_individuel_a_partir_dpe_immeuble_2026.csv`</sub> | **56,34 %** | `███████████░░░░░░░░░` | 5 634 / 10 000 |
390
+ | | Corpus | Réussite | | DPE conformes |
391
+ | :-: | :---------------------------------------------------------------------------------------------------------------------- | ----------: | :--------------------- | ------------: |
392
+ | 🔴 | **Généraliste**<br/><sub>`corpus_dpe.csv`</sub> | **49,26 %** | `██████████░░░░░░░░░░` | 4 856 / 9 857 |
393
+ | 🟢 | **Appartement · chauffage individuel (2025)**<br/><sub>`dpe_appartement_individuel_chauffage_individuel_2025.csv`</sub> | **95,39 %** | `███████████████████░` | 9 437 / 9 893 |
394
+ | 🟢 | **Appartement · chauffage collectif (2025)**<br/><sub>`dpe_appartement_individuel_chauffage_collectif_2025.csv`</sub> | **94,54 %** | `███████████████████░` | 9 384 / 9 926 |
395
+ | 🟢 | **Logement individuel (2025)**<br/><sub>`dpe_logement_individuel_2025.csv`</sub> | **93,29 %** | `███████████████████░` | 9 173 / 9 833 |
396
+ | 🟢 | **Maison individuelle (2025)**<br/><sub>`dpe_maison_individuelle_2025.csv`</sub> | **92,03 %** | `██████████████████░░` | 8 992 / 9 771 |
397
+ | 🟢 | **Immeuble · chauffage collectif**<br/><sub>`dpe_immeuble_chauffage_collectif.csv`</sub> | **89,10 %** | `██████████████████░░` | 8 677 / 9 739 |
398
+ | 🟢 | **Immeuble · chauffage individuel**<br/><sub>`dpe_immeuble_chauffage_individuel.csv`</sub> | **86,00 %** | `█████████████████░░░` | 8 535 / 9 924 |
399
+ | 🟡 | **Immeuble · chauffage mixte**<br/><sub>`dpe_immeuble_chauffage_mixte.csv`</sub> | **70,48 %** | `██████████████░░░░░░` | 6 746 / 9 571 |
400
+ | 🔴 | **Individuel généré depuis l'immeuble (2026)**<br/><sub>`dpe_individuel_a_partir_dpe_immeuble_2026.csv`</sub> | **57,38 %** | `███████████░░░░░░░░░` | 5 634 / 9 818 |
401
401
 
402
402
  <sub>🟢 ≥ 85 % · 🟡 ≥ 60 % · 🔴 < 60 %</sub>
403
403
 
404
404
  #### Temps d’exécution
405
405
 
406
- > Durée de l’appel à `calcul_3cl` par DPE, sur 89 383 calculs.
406
+ > Durée de l’appel à `calcul_3cl` par DPE, sur 88 298 calculs.
407
407
  > La copie défensive de l’entrée et la lecture du fichier sont exclues de la mesure.
408
408
 
409
409
  <table>
410
410
  <tr>
411
- <td align="center"><strong>6,03 ms</strong><br/><sub>moyenne</sub></td>
412
- <td align="center"><strong>5,41 ms</strong><br/><sub>médiane</sub></td>
413
- <td align="center"><strong>0,19 ms</strong><br/><sub>min</sub></td>
414
- <td align="center"><strong>763,7 ms</strong><br/><sub>max</sub></td>
411
+ <td align="center"><strong>5,90 ms</strong><br/><sub>moyenne</sub></td>
412
+ <td align="center"><strong>5,27 ms</strong><br/><sub>médiane</sub></td>
413
+ <td align="center"><strong>0,11 ms</strong><br/><sub>min</sub></td>
414
+ <td align="center"><strong>874,0 ms</strong><br/><sub>max</sub></td>
415
415
  </tr>
416
416
  </table>
417
417
 
418
418
  | Corpus | Moyenne | Médiane | Min | Max | p95 | p99 |
419
419
  | :--------------------------------------------- | ------: | ------: | ------: | -------: | ------: | ------: |
420
- | **Généraliste** | 5,69 ms | 5,26 ms | 0,31 ms | 43,3 ms | 9,94 ms | 14,5 ms |
421
- | **Appartement · chauffage individuel (2025)** | 4,38 ms | 4,08 ms | 1,56 ms | 34,5 ms | 7,30 ms | 9,34 ms |
422
- | **Appartement · chauffage collectif (2025)** | 5,28 ms | 5,15 ms | 1,71 ms | 45,6 ms | 8,16 ms | 10,4 ms |
423
- | **Logement individuel (2025)** | 5,57 ms | 5,06 ms | 1,69 ms | 39,2 ms | 10,1 ms | 13,6 ms |
424
- | **Maison individuelle (2025)** | 6,58 ms | 5,99 ms | 1,96 ms | 45,7 ms | 11,6 ms | 15,3 ms |
425
- | **Immeuble · chauffage collectif** | 6,51 ms | 5,85 ms | 0,23 ms | 163,5 ms | 11,7 ms | 19,1 ms |
426
- | **Immeuble · chauffage individuel** | 7,69 ms | 6,64 ms | 0,19 ms | 121,8 ms | 15,4 ms | 24,7 ms |
427
- | **Immeuble · chauffage mixte** | 5,92 ms | 4,86 ms | 0,19 ms | 763,7 ms | 12,1 ms | 20,2 ms |
428
- | **Individuel généré depuis l'immeuble (2026)** | 6,65 ms | 5,78 ms | 1,75 ms | 60,9 ms | 12,6 ms | 20,5 ms |
420
+ | **Généraliste** | 4,71 ms | 4,25 ms | 0,21 ms | 35,5 ms | 8,47 ms | 11,9 ms |
421
+ | **Appartement · chauffage individuel (2025)** | 4,35 ms | 4,04 ms | 1,48 ms | 41,0 ms | 7,22 ms | 9,56 ms |
422
+ | **Appartement · chauffage collectif (2025)** | 4,56 ms | 4,20 ms | 1,42 ms | 38,5 ms | 7,37 ms | 9,29 ms |
423
+ | **Logement individuel (2025)** | 6,03 ms | 5,55 ms | 1,79 ms | 39,2 ms | 10,8 ms | 14,1 ms |
424
+ | **Maison individuelle (2025)** | 7,11 ms | 6,66 ms | 2,05 ms | 40,4 ms | 12,1 ms | 16,0 ms |
425
+ | **Immeuble · chauffage collectif** | 6,10 ms | 5,34 ms | 0,22 ms | 102,6 ms | 11,1 ms | 19,0 ms |
426
+ | **Immeuble · chauffage individuel** | 7,30 ms | 6,25 ms | 0,11 ms | 146,4 ms | 14,7 ms | 23,0 ms |
427
+ | **Immeuble · chauffage mixte** | 6,13 ms | 5,11 ms | 0,18 ms | 874,0 ms | 12,7 ms | 20,3 ms |
428
+ | **Individuel généré depuis l'immeuble (2026)** | 6,82 ms | 6,06 ms | 1,88 ms | 56,5 ms | 12,8 ms | 19,6 ms |
429
429
 
430
430
  <sub>La moyenne est tirée vers le haut par les DPE collectifs, dont le coût atteint plusieurs
431
431
  dizaines de fois la médiane : c’est la médiane qui décrit le cas courant, et p95/p99 le cas
package/conso.js CHANGED
@@ -983,6 +983,16 @@ function getChauffageConso(gen_ch, field, coef, prorataChauffage, prefix, facteu
983
983
  }, 0);
984
984
  }
985
985
 
986
+ /**
987
+ * Part du logement dans la consommation de froid d'une installation (1 par défaut), positionnée par
988
+ * calc_clim pour les DPE appartement générés à partir de l'immeuble (issue #303).
989
+ * @param fr {object} climatisation
990
+ * @return {number}
991
+ */
992
+ export function prorataFroidLogement(fr) {
993
+ return fr.donnee_utilisateur?.prorata_logement ?? 1;
994
+ }
995
+
986
996
  function calc_conso_pond(
987
997
  Sh,
988
998
  zc_id,
@@ -1086,13 +1096,15 @@ function calc_conso_pond(
1086
1096
  ret.fr = fr_list.reduce((acc, fr) => {
1087
1097
  const typeEnergie = getTypeEnergie(fr.donnee_entree, 'fr');
1088
1098
  const conso =
1089
- fr.donnee_intermediaire.conso_fr * (typeEnergie === 'électricité fr' ? fAc('fr') : 1);
1099
+ fr.donnee_intermediaire.conso_fr *
1100
+ prorataFroidLogement(fr) *
1101
+ (typeEnergie === 'électricité fr' ? fAc('fr') : 1);
1090
1102
 
1091
1103
  return acc + getConso(coef, typeEnergie, conso);
1092
1104
  }, 0);
1093
1105
 
1094
1106
  ret.fr_depensier = fr_list.reduce((acc, fr) => {
1095
- const conso = fr.donnee_intermediaire.conso_fr_depensier;
1107
+ const conso = fr.donnee_intermediaire.conso_fr_depensier * prorataFroidLogement(fr);
1096
1108
  const typeEnergie = getTypeEnergie(fr.donnee_entree, 'fr');
1097
1109
 
1098
1110
  return acc + getConso(coef, typeEnergie, conso);
package/conso.spec.js CHANGED
@@ -71,7 +71,8 @@ const {
71
71
  ARRETES_RESEAU_CHALEUR,
72
72
  COEF_GES_AUTRES_RESEAUX_CHALEUR,
73
73
  indexArreteReseauChaleur,
74
- getContenuCo2ReseauChaleur
74
+ getContenuCo2ReseauChaleur,
75
+ prorataFroidLogement
75
76
  } = await import('./conso.js');
76
77
  const { tv } = await import('./utils.js');
77
78
 
@@ -298,6 +299,49 @@ describe('calc_conso - agrégation des consommations', () => {
298
299
  expect(net.cout.cout_eclairage).toBeCloseTo(brut.cout.cout_eclairage, 9);
299
300
  });
300
301
 
302
+ /**
303
+ * DPE appartement généré à partir de l'immeuble : conso de froid ramenée au logement par le
304
+ * prorata mémorisé dans donnee_utilisateur.prorata_logement.
305
+ * @see https://github.com/Open3CL/engine/issues/303
306
+ */
307
+ test('froid : conso et conso dépensière proratisées au logement', () => {
308
+ const fr = (du) => [
309
+ {
310
+ donnee_entree: { enum_type_energie_id: '1' },
311
+ donnee_intermediaire: { conso_fr: 400, conso_fr_depensier: 1000 },
312
+ donnee_utilisateur: du
313
+ }
314
+ ];
315
+ const plein = calc_conso(100, 1, 1, [], [], [], fr(undefined), 1, 1, DATE_DPE, coef_ep);
316
+ const lot = calc_conso(
317
+ 100,
318
+ 1,
319
+ 1,
320
+ [],
321
+ [],
322
+ [],
323
+ fr({ prorata_logement: 0.25 }),
324
+ 1,
325
+ 1,
326
+ DATE_DPE,
327
+ coef_ep
328
+ );
329
+
330
+ expect(plein.ef_conso.conso_fr).toBeCloseTo(400, 9);
331
+ expect(lot.ef_conso.conso_fr).toBeCloseTo(100, 9);
332
+ expect(lot.ef_conso.conso_fr_depensier).toBeCloseTo(250, 9);
333
+ expect(lot.emission_ges.emission_ges_fr).toBeCloseTo(
334
+ plein.emission_ges.emission_ges_fr * 0.25,
335
+ 9
336
+ );
337
+ });
338
+
339
+ test('prorataFroidLogement : valeur mémorisée, 1 par défaut', () => {
340
+ expect(prorataFroidLogement({ donnee_utilisateur: { prorata_logement: 0.3 } })).toBe(0.3);
341
+ expect(prorataFroidLogement({ donnee_utilisateur: {} })).toBe(1);
342
+ expect(prorataFroidLogement({})).toBe(1);
343
+ });
344
+
301
345
  test('autoconsommation PV : froid électrique minoré, froid non électrique non minoré', () => {
302
346
  const frElec = [
303
347
  {
package/engine.js CHANGED
@@ -35,7 +35,7 @@ import DpeSanitizerService from './dpe-sanitizer.service.js';
35
35
  import { reconstituerRdimDpeGenereImmeuble } from './17.2_rdim_dpe_genere_immeuble.js';
36
36
  import { reconstituerRdimEcsIndividuelleUnique } from './11_rdim_ecs_individuelle_unique.js';
37
37
 
38
- const LIB_VERSION = '1.8.65';
38
+ const LIB_VERSION = '1.8.67';
39
39
 
40
40
  const dpeSanitizerService = new DpeSanitizerService();
41
41
 
@@ -330,7 +330,18 @@ export function calcul_3cl(inputDpe, options) {
330
330
 
331
331
  const bfr = apport_et_besoin.besoin_fr;
332
332
  const bfr_dep = apport_et_besoin.besoin_fr_depensier;
333
- clim.forEach((clim) => calc_clim(clim, bfr, bfr_dep, zc_id, ShChauffageAndEcs));
333
+ /**
334
+ * DPE appartement généré à partir de l'immeuble : besoin et conso de froid calculés à l'échelle de
335
+ * l'installation (immeuble), puis ramenés au logement au prorata Sh_logement / SH_immeuble, comme le
336
+ * chauffage et l'ECS (Tribu `Calcul_installation_froid.cs` l.62-64, `Calcul_batiment.cs`
337
+ * l.1114-1138).
338
+ * @see https://github.com/Open3CL/engine/issues/303
339
+ */
340
+ const prorataFroid =
341
+ dpeGenereImmeuble && Number(ShChauffageAndEcs) > Number(Sh) && Number(Sh) > 0
342
+ ? Number(Sh) / Number(ShChauffageAndEcs)
343
+ : 1;
344
+ clim.forEach((clim) => calc_clim(clim, bfr, bfr_dep, zc_id, ShChauffageAndEcs, prorataFroid));
334
345
 
335
346
  /**
336
347
  * La consommation ECS est obtenu pour certains types de DPE par virtualisation des générateurs collectifs en générateurs individuels virtuels
package/engine.spec.js CHANGED
@@ -776,6 +776,38 @@ describe('calcul_3cl - surfaces immeuble / appartement et prorata', () => {
776
776
  // Auxiliaires ECS : Sh = surface logement, ramenée à l'immeuble dans le calcul.
777
777
  expect(conso_aux_distribution_ecs.mock.calls[0][3]).toBe(80);
778
778
  expect(conso_aux_distribution_ecs.mock.calls[0][4]).toBe(200);
779
+ // Froid : pas un DPE généré depuis l'immeuble, conso de froid non proratisée (#303).
780
+ expect(calc_clim.mock.calls[0][4]).toBe(200);
781
+ expect(calc_clim.mock.calls[0][5]).toBe(1);
782
+ });
783
+
784
+ /**
785
+ * @see https://github.com/Open3CL/engine/issues/303
786
+ */
787
+ test.each(['10', '11', '12', '13', '33', '34', '38', '39', '40'])(
788
+ "DPE appartement généré à partir de l'immeuble (map %s) : conso de froid proratisée Sh / SH",
789
+ (mapId) => {
790
+ enums.methode_application_dpe_log = { [mapId]: 'dpe appartement généré immeuble' };
791
+ const dpe = makeDpe();
792
+ dpe.logement.caracteristique_generale.enum_methode_application_dpe_log_id = mapId;
793
+
794
+ calcul_3cl(dpe, { sanitize: false });
795
+
796
+ // Besoin calculé sur SH immeuble (200), part du logement = 80 / 200.
797
+ expect(calc_clim.mock.calls[0][4]).toBe(200);
798
+ expect(calc_clim.mock.calls[0][5]).toBeCloseTo(0.4, 9);
799
+ }
800
+ );
801
+
802
+ test("DPE généré depuis l'immeuble avec SH immeuble ≤ Sh logement : prorata de froid neutre", () => {
803
+ enums.methode_application_dpe_log = { 10: 'dpe appartement généré immeuble' };
804
+ const dpe = makeDpe();
805
+ dpe.logement.caracteristique_generale.enum_methode_application_dpe_log_id = '10';
806
+ dpe.logement.caracteristique_generale.surface_habitable_immeuble = 80;
807
+
808
+ calcul_3cl(dpe, { sanitize: false });
809
+
810
+ expect(calc_clim.mock.calls[0][5]).toBe(1);
779
811
  });
780
812
 
781
813
  test('appartement : rapport SH immeuble / SH logement transmis au calcul du coût', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open3cl/engine",
3
- "version": "1.8.65",
3
+ "version": "1.8.67",
4
4
  "description": "Open Source 3CL-DPE engine",
5
5
  "main": "index.js",
6
6
  "directories": {