@open3cl/engine 1.8.14 → 1.8.15

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.
@@ -62,7 +62,170 @@ export function tempDistributionChPourTempFonc(emChDe, emChDu) {
62
62
  return tempDistribution;
63
63
  }
64
64
 
65
+ /**
66
+ * Périodes d'installation des émetteurs (table temp_fonc_30 / temp_fonc_100), de la plus ancienne
67
+ * à la plus récente.
68
+ */
69
+ export const PERIODES_INSTALLATION_EMETTEUR = ['avant 1981', 'entre 1981 et 2000', 'après 2000'];
70
+
71
+ /**
72
+ * Tolérance (°C) pour considérer qu'une température de fonctionnement issue des tables reproduit
73
+ * celle stockée dans le DPE.
74
+ */
75
+ export const TOLERANCE_TEMP_FONC = 0.01;
76
+
77
+ /**
78
+ * 13.2.1.5 Chaudières basse température et condensation
79
+ * « Si l'année d'installation des émetteurs est inconnue, prendre l'année de construction du
80
+ * bâtiment. »
81
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §13.2.1.5
82
+ *
83
+ * @param ac {number} année de construction du bâtiment
84
+ * @return {string} période d'installation des émetteurs
85
+ */
86
+ export function periodeEmetteurAnneeConstruction(ac) {
87
+ if (ac < 1981) return PERIODES_INSTALLATION_EMETTEUR[0];
88
+ if (ac < 2000) return PERIODES_INSTALLATION_EMETTEUR[1];
89
+ return PERIODES_INSTALLATION_EMETTEUR[2];
90
+ }
91
+
92
+ function estNumerique(valeur) {
93
+ return (
94
+ valeur !== null && valeur !== undefined && valeur !== '' && Number.isFinite(Number(valeur))
95
+ );
96
+ }
97
+
98
+ /**
99
+ * Lignes des tables temp_fonc_30 / temp_fonc_100 retenues pour un générateur (maximum sur les
100
+ * émetteurs), en affectant `periodeParDefaut` aux émetteurs dont la période d'installation n'est
101
+ * pas saisie. Les émetteurs sans réseau de distribution (enum_temp_distribution_ch_id = 1) sont
102
+ * exclus. Utilisé uniquement pour la déduction de période en mode bug_for_bug_compat.
103
+ *
104
+ * @param de {object} donnee_entree du générateur
105
+ * @param em_ch {object[]} émetteurs de l'installation
106
+ * @param periodeParDefaut {string} période affectée aux émetteurs sans période saisie
107
+ * @return {{row_30: object|null, row_100: object|null}}
108
+ */
109
+ export function temp_fonc_pour_periode(de, em_ch, periodeParDefaut) {
110
+ let row_30 = null;
111
+ let row_100 = null;
112
+ for (const em of em_ch) {
113
+ const em_ch_de = em.donnee_entree;
114
+ const em_ch_du = em.donnee_utilisateur;
115
+ if (String(requestInputID(em_ch_de, em_ch_du, 'temp_distribution_ch')) === '1') continue;
116
+ const matcher = {
117
+ enum_type_generateur_ch_id: de.enum_type_generateur_ch_id,
118
+ enum_temp_distribution_ch_id: tempDistributionChPourTempFonc(em_ch_de, em_ch_du),
119
+ periode_emetteurs:
120
+ requestInput(em_ch_de, em_ch_du, 'periode_installation_emetteur') || periodeParDefaut
121
+ };
122
+ const r30 = tv('temp_fonc_30', matcher);
123
+ const r100 = tv('temp_fonc_100', matcher);
124
+ if (r30 && (!row_30 || Number(r30.temp_fonc_30) > Number(row_30.temp_fonc_30))) row_30 = r30;
125
+ if (r100 && (!row_100 || Number(r100.temp_fonc_100) > Number(row_100.temp_fonc_100))) {
126
+ row_100 = r100;
127
+ }
128
+ }
129
+ return { row_30, row_100 };
130
+ }
131
+
132
+ /**
133
+ * Mode bug_for_bug_compat uniquement (issue #220).
134
+ *
135
+ * Certains logiciels (ex. 4.1.1 / moteur 3cl-2024.6.1.0, LICIEL) calculent les températures de
136
+ * fonctionnement avec une période d'installation des émetteurs qu'ils n'exportent pas, mais
137
+ * stockent temp_fonc_30 / temp_fonc_100 dans les données intermédiaires du générateur. La période
138
+ * est alors retrouvée en testant chaque période candidate (appliquée à tous les émetteurs sans
139
+ * période saisie) et en ne retenant que celles qui reproduisent les DEUX valeurs du DPE.
140
+ *
141
+ * - une seule candidate : retenue ;
142
+ * - plusieurs : celle de l'année de construction si elle en fait partie, sinon la plus récente ;
143
+ * - aucune : null (repli sur l'année de construction, valeurs hors tables).
144
+ *
145
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §13.2.1.5
146
+ *
147
+ * @param tempFonc30Dpe {number} temp_fonc_30 d'origine du DPE
148
+ * @param tempFonc100Dpe {number} temp_fonc_100 d'origine du DPE
149
+ * @param de {object} donnee_entree du générateur
150
+ * @param em_ch {object[]} émetteurs de l'installation
151
+ * @param ac {number} année de construction
152
+ * @return {string|null} période déduite, ou null si aucune période ne reproduit le DPE
153
+ */
154
+ export function periodeEmetteursDeduiteDuDpe(tempFonc30Dpe, tempFonc100Dpe, de, em_ch, ac) {
155
+ const candidates = PERIODES_INSTALLATION_EMETTEUR.filter((periode) => {
156
+ const { row_30, row_100 } = temp_fonc_pour_periode(de, em_ch, periode);
157
+ return (
158
+ row_30 &&
159
+ row_100 &&
160
+ Math.abs(Number(row_30.temp_fonc_30) - tempFonc30Dpe) <= TOLERANCE_TEMP_FONC &&
161
+ Math.abs(Number(row_100.temp_fonc_100) - tempFonc100Dpe) <= TOLERANCE_TEMP_FONC
162
+ );
163
+ });
164
+ if (candidates.length === 0) return null;
165
+ const periodeConstruction = periodeEmetteurAnneeConstruction(ac);
166
+ if (candidates.includes(periodeConstruction)) return periodeConstruction;
167
+ return candidates[candidates.length - 1];
168
+ }
169
+
170
+ /**
171
+ * Indique si la déduction de la période des émetteurs à partir des données intermédiaires du DPE
172
+ * doit être tentée : mode bug_for_bug_compat, au moins un émetteur (avec réseau de distribution)
173
+ * sans période saisie, et temp_fonc_30 ET temp_fonc_100 d'origine présentes et numériques.
174
+ *
175
+ * @param tempFonc30Dpe {*} temp_fonc_30 d'origine du DPE
176
+ * @param tempFonc100Dpe {*} temp_fonc_100 d'origine du DPE
177
+ * @param em_ch {object[]} émetteurs de l'installation
178
+ * @return {boolean}
179
+ */
180
+ export function deductionPeriodeEmetteursActive(tempFonc30Dpe, tempFonc100Dpe, em_ch) {
181
+ if (!bug_for_bug_compat) return false;
182
+ if (!estNumerique(tempFonc30Dpe) || !estNumerique(tempFonc100Dpe)) return false;
183
+ return em_ch.some(
184
+ (em) =>
185
+ String(requestInputID(em.donnee_entree, em.donnee_utilisateur, 'temp_distribution_ch')) !==
186
+ '1' &&
187
+ !requestInput(em.donnee_entree, em.donnee_utilisateur, 'periode_installation_emetteur')
188
+ );
189
+ }
190
+
191
+ /**
192
+ * Températures de fonctionnement à 30 % et 100 % de charge.
193
+ *
194
+ * `di` est la donnee_intermediaire du générateur issue du DPE d'entrée : à l'appel, temp_fonc_30 /
195
+ * temp_fonc_100 contiennent les valeurs d'origine du DPE (jamais une valeur recalculée par
196
+ * Open3CL). En mode bug_for_bug_compat, elles servent uniquement à retrouver la période
197
+ * d'installation des émetteurs non exportée (issue #220) ; les valeurs retenues proviennent
198
+ * toujours des tables (tv_temp_fonc_30_id / tv_temp_fonc_100_id renseignés).
199
+ *
200
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §13.2.1.5
201
+ */
65
202
  export function tv_temp_fonc_30_100(di, de, du, em_ch, ac) {
203
+ const tempFonc30Dpe = di.temp_fonc_30;
204
+ const tempFonc100Dpe = di.temp_fonc_100;
205
+ if (deductionPeriodeEmetteursActive(tempFonc30Dpe, tempFonc100Dpe, em_ch)) {
206
+ const periode = periodeEmetteursDeduiteDuDpe(
207
+ Number(tempFonc30Dpe),
208
+ Number(tempFonc100Dpe),
209
+ de,
210
+ em_ch,
211
+ ac
212
+ );
213
+ if (periode) {
214
+ const { row_30, row_100 } = temp_fonc_pour_periode(de, em_ch, periode);
215
+ console.warn(
216
+ `période d'installation des émetteurs déduite des données intermédiaires du DPE : ${periode} (générateur ${de.description}, temp_fonc_30 = ${row_30.temp_fonc_30}, temp_fonc_100 = ${row_100.temp_fonc_100})`
217
+ );
218
+ de.tv_temp_fonc_30_id = row_30.tv_temp_fonc_30_id;
219
+ di.temp_fonc_30 = Number(row_30.temp_fonc_30);
220
+ de.tv_temp_fonc_100_id = row_100.tv_temp_fonc_100_id;
221
+ di.temp_fonc_100 = Number(row_100.temp_fonc_100);
222
+ return;
223
+ }
224
+ console.warn(
225
+ `période d'installation des émetteurs non déductible pour le générateur ${de.description} : valeurs hors tables / donnée d'entrée incohérente (temp_fonc_30 = ${tempFonc30Dpe}, temp_fonc_100 = ${tempFonc100Dpe}). Repli sur l'année de construction.`
226
+ );
227
+ }
228
+
66
229
  for (const em of em_ch) {
67
230
  const em_ch_de = em.donnee_entree;
68
231
  const em_ch_du = em.donnee_utilisateur;
@@ -73,9 +236,7 @@ export function tv_temp_fonc_30_100(di, de, du, em_ch, ac) {
73
236
  };
74
237
 
75
238
  if (!matcher.periode_emetteurs) {
76
- if (ac < 1981) matcher.periode_emetteurs = 'avant 1981';
77
- else if (ac < 2000) matcher.periode_emetteurs = 'entre 1981 et 2000';
78
- else matcher.periode_emetteurs = 'après 2000';
239
+ matcher.periode_emetteurs = periodeEmetteurAnneeConstruction(ac);
79
240
  }
80
241
 
81
242
  const row_30 = tv('temp_fonc_30', matcher);
@@ -38,8 +38,14 @@ vi.mock('./utils.js', () => ({
38
38
  requestInputID: vi.fn((de, du, field) => de[`enum_${field}_id`])
39
39
  }));
40
40
 
41
- const { tv_temp_fonc_30_100, tempDistributionChPourTempFonc, calc_generateur_combustion_ch } =
42
- await import('./13.2_generateur_combustion_ch.js');
41
+ const {
42
+ tv_temp_fonc_30_100,
43
+ tempDistributionChPourTempFonc,
44
+ calc_generateur_combustion_ch,
45
+ periodeEmetteursDeduiteDuDpe,
46
+ periodeEmetteurAnneeConstruction,
47
+ PERIODES_INSTALLATION_EMETTEUR
48
+ } = await import('./13.2_generateur_combustion_ch.js');
43
49
  const { tv } = await import('./utils.js');
44
50
 
45
51
  beforeEach(() => {
@@ -194,6 +200,284 @@ describe('tv_temp_fonc_30_100 - températures de fonctionnement', () => {
194
200
  );
195
201
  });
196
202
 
203
+ /**
204
+ * Issue #220 - période d'installation des émetteurs absente du DPE.
205
+ *
206
+ * Mode strict : « Si l'année d'installation des émetteurs est inconnue, prendre l'année de
207
+ * construction du bâtiment. » En mode bug_for_bug_compat uniquement, la période est retrouvée à
208
+ * partir des temp_fonc_30 / temp_fonc_100 d'origine stockées dans le DPE (règle de l'expert).
209
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §13.2.1.5
210
+ */
211
+ describe('tv_temp_fonc_30_100 - période des émetteurs absente (issue #220)', () => {
212
+ /**
213
+ * Table de test (contrôlée, indépendante des tables réelles) pour une chaudière à condensation :
214
+ * [température de distribution][période] -> [id, temp_fonc_30, temp_fonc_100].
215
+ */
216
+ const TABLE = {
217
+ 2: {
218
+ 'avant 1981': ['1', '32', '60'],
219
+ 'entre 1981 et 2000': ['4', '24.5', '35'],
220
+ 'après 2000': ['7', '24.5', '35']
221
+ },
222
+ 3: {
223
+ 'avant 1981': ['2', '38', '80'],
224
+ 'entre 1981 et 2000': ['5', '35', '70'],
225
+ 'après 2000': ['8', '32', '60']
226
+ },
227
+ 4: {
228
+ 'avant 1981': ['3', '38', '80'],
229
+ 'entre 1981 et 2000': ['6', '35', '70'],
230
+ 'après 2000': ['9', '35', '70']
231
+ }
232
+ };
233
+
234
+ function tvTable(table, matcher) {
235
+ const ligne = TABLE[matcher.enum_temp_distribution_ch_id]?.[matcher.periode_emetteurs];
236
+ if (!ligne) return null;
237
+ return table === 'temp_fonc_30'
238
+ ? { tv_temp_fonc_30_id: `30-${ligne[0]}`, temp_fonc_30: ligne[1] }
239
+ : { tv_temp_fonc_100_id: `100-${ligne[0]}`, temp_fonc_100: ligne[2] };
240
+ }
241
+
242
+ function emetteur(tempDistribution, periode) {
243
+ const de = { enum_temp_distribution_ch_id: tempDistribution };
244
+ if (periode) de.periode_installation_emetteur = periode;
245
+ return { donnee_entree: de, donnee_utilisateur: {} };
246
+ }
247
+
248
+ const genCondensation = () => ({ enum_type_generateur_ch_id: '97', description: 'chaudière' });
249
+
250
+ let warnSpy;
251
+ beforeEach(() => {
252
+ tv.mockImplementation(tvTable);
253
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
254
+ return () => warnSpy.mockRestore();
255
+ });
256
+
257
+ test('T1 strict : période absente, construction 1900 -> « avant 1981 » (38/80), valeurs du DPE ignorées', () => {
258
+ const di = { temp_fonc_30: 32, temp_fonc_100: 60 };
259
+ const de = genCondensation();
260
+ tv_temp_fonc_30_100(di, de, {}, [emetteur('3')], 1900);
261
+ // Valeurs DPE (32) < table (38) : la table l'emporte, aucune déduction en mode strict
262
+ expect(di.temp_fonc_30).toBe(38);
263
+ expect(di.temp_fonc_100).toBe(80);
264
+ expect(de.tv_temp_fonc_30_id).toBe('30-2');
265
+ expect(de.tv_temp_fonc_100_id).toBe('100-2');
266
+ expect(tv).not.toHaveBeenCalledWith(
267
+ 'temp_fonc_30',
268
+ expect.objectContaining({ periode_emetteurs: 'après 2000' })
269
+ );
270
+ expect(warnSpy).not.toHaveBeenCalled();
271
+ });
272
+
273
+ test('T2 bug_for_bug_compat : DPE à 32/60 -> période « après 2000 » déduite, valeurs et ids issus des tables', () => {
274
+ state.bug = true;
275
+ const di = { temp_fonc_30: 32, temp_fonc_100: 60 };
276
+ const de = genCondensation();
277
+ tv_temp_fonc_30_100(di, de, {}, [emetteur('3')], 1900);
278
+ expect(di.temp_fonc_30).toBe(32);
279
+ expect(di.temp_fonc_100).toBe(60);
280
+ expect(de.tv_temp_fonc_30_id).toBe('30-8');
281
+ expect(de.tv_temp_fonc_100_id).toBe('100-8');
282
+ expect(warnSpy).toHaveBeenCalledWith(
283
+ expect.stringContaining(
284
+ "période d'installation des émetteurs déduite des données intermédiaires du DPE : après 2000"
285
+ )
286
+ );
287
+ });
288
+
289
+ test('T2 bis bug_for_bug_compat : valeurs du DPE sous forme de chaînes acceptées', () => {
290
+ state.bug = true;
291
+ const di = { temp_fonc_30: '32', temp_fonc_100: '60' };
292
+ tv_temp_fonc_30_100(di, genCondensation(), {}, [emetteur('3')], 1900);
293
+ expect(di.temp_fonc_30).toBe(32);
294
+ expect(di.temp_fonc_100).toBe(60);
295
+ });
296
+
297
+ test('T3 bug_for_bug_compat : période saisie « avant 1981 » jamais écrasée (38/80 conservé)', () => {
298
+ state.bug = true;
299
+ const di = { temp_fonc_30: 32, temp_fonc_100: 60 };
300
+ const de = genCondensation();
301
+ tv_temp_fonc_30_100(di, de, {}, [emetteur('3', 'avant 1981')], 1900);
302
+ expect(di.temp_fonc_30).toBe(38);
303
+ expect(di.temp_fonc_100).toBe(80);
304
+ expect(de.tv_temp_fonc_30_id).toBe('30-2');
305
+ expect(warnSpy).not.toHaveBeenCalled();
306
+ });
307
+
308
+ test('T4 bug_for_bug_compat : distribution haute, construction 1990, DPE 35/70 -> 2 candidates, période de construction retenue', () => {
309
+ state.bug = true;
310
+ const di = { temp_fonc_30: 35, temp_fonc_100: 70 };
311
+ const de = genCondensation();
312
+ tv_temp_fonc_30_100(di, de, {}, [emetteur('4')], 1990);
313
+ expect(di.temp_fonc_30).toBe(35);
314
+ expect(di.temp_fonc_100).toBe(70);
315
+ // « entre 1981 et 2000 » (ids 6) et non « après 2000 » (ids 9)
316
+ expect(de.tv_temp_fonc_30_id).toBe('30-6');
317
+ expect(de.tv_temp_fonc_100_id).toBe('100-6');
318
+ });
319
+
320
+ test('T5 bug_for_bug_compat : DPE 33/62 hors tables -> repli sur l’année de construction et avertissement', () => {
321
+ state.bug = true;
322
+ const di = { temp_fonc_30: 33, temp_fonc_100: 62 };
323
+ const de = genCondensation();
324
+ tv_temp_fonc_30_100(di, de, {}, [emetteur('3')], 1990);
325
+ // Repli strict : « entre 1981 et 2000 » -> 35/70 (> 33/62 donc retenu)
326
+ expect(di.temp_fonc_30).toBe(35);
327
+ expect(di.temp_fonc_100).toBe(70);
328
+ expect(de.tv_temp_fonc_30_id).toBe('30-5');
329
+ expect(warnSpy).toHaveBeenCalledWith(
330
+ expect.stringContaining("valeurs hors tables / donnée d'entrée incohérente")
331
+ );
332
+ });
333
+
334
+ test.each([
335
+ ['temp_fonc_100 absente', { temp_fonc_30: 32 }],
336
+ ['temp_fonc_30 absente', { temp_fonc_100: 60 }],
337
+ ['temp_fonc_30 non numérique', { temp_fonc_30: 'abc', temp_fonc_100: 60 }],
338
+ ['temp_fonc_100 vide', { temp_fonc_30: 32, temp_fonc_100: '' }],
339
+ ['temp_fonc_30 nulle', { temp_fonc_30: null, temp_fonc_100: 60 }]
340
+ ])(
341
+ 'T6 bug_for_bug_compat : %s -> aucune déduction (règle de l’année de construction)',
342
+ (_, di) => {
343
+ state.bug = true;
344
+ tv_temp_fonc_30_100(di, genCondensation(), {}, [emetteur('3')], 1900);
345
+ expect(tv).not.toHaveBeenCalledWith(
346
+ 'temp_fonc_30',
347
+ expect.objectContaining({ periode_emetteurs: 'après 2000' })
348
+ );
349
+ expect(tv).not.toHaveBeenCalledWith(
350
+ 'temp_fonc_30',
351
+ expect.objectContaining({ periode_emetteurs: 'entre 1981 et 2000' })
352
+ );
353
+ expect(warnSpy).not.toHaveBeenCalled();
354
+ }
355
+ );
356
+
357
+ test('T7 bug_for_bug_compat : deux émetteurs sans période (moyenne + haute), DPE 35/70 -> même période pour tous, règle du maximum', () => {
358
+ state.bug = true;
359
+ const di = { temp_fonc_30: 35, temp_fonc_100: 70 };
360
+ const de = genCondensation();
361
+ /**
362
+ * avant 1981 : max(38, 38) / max(80, 80) = 38/80 -> rejetée
363
+ * entre 1981 et 2000 : max(35, 35) / max(70, 70) = 35/70 -> candidate
364
+ * après 2000 : max(32, 35) / max(60, 70) = 35/70 -> candidate
365
+ * Construction 1900 (« avant 1981 ») hors candidates -> la plus récente : « après 2000 ».
366
+ */
367
+ tv_temp_fonc_30_100(di, de, {}, [emetteur('3'), emetteur('4')], 1900);
368
+ expect(di.temp_fonc_30).toBe(35);
369
+ expect(di.temp_fonc_100).toBe(70);
370
+ // Maximum atteint par l'émetteur « haute » en « après 2000 » (ids 9)
371
+ expect(de.tv_temp_fonc_30_id).toBe('30-9');
372
+ expect(de.tv_temp_fonc_100_id).toBe('100-9');
373
+ });
374
+
375
+ test('bug_for_bug_compat : un émetteur saisi et un émetteur sans période -> seule la période absente est déduite', () => {
376
+ state.bug = true;
377
+ const di = { temp_fonc_30: 35, temp_fonc_100: 70 };
378
+ const de = genCondensation();
379
+ // Émetteur saisi « entre 1981 et 2000 » (moyenne : 35/70) + émetteur basse sans période
380
+ tv_temp_fonc_30_100(di, de, {}, [emetteur('3', 'entre 1981 et 2000'), emetteur('2')], 1900);
381
+ expect(di.temp_fonc_30).toBe(35);
382
+ expect(di.temp_fonc_100).toBe(70);
383
+ expect(tv).toHaveBeenCalledWith(
384
+ 'temp_fonc_30',
385
+ expect.objectContaining({
386
+ enum_temp_distribution_ch_id: '3',
387
+ periode_emetteurs: 'entre 1981 et 2000'
388
+ })
389
+ );
390
+ expect(tv).not.toHaveBeenCalledWith(
391
+ 'temp_fonc_30',
392
+ expect.objectContaining({
393
+ enum_temp_distribution_ch_id: '3',
394
+ periode_emetteurs: 'après 2000'
395
+ })
396
+ );
397
+ });
398
+
399
+ test('bug_for_bug_compat : émetteur sans réseau de distribution (id 1) exclu', () => {
400
+ state.bug = true;
401
+ const di = { temp_fonc_30: 32, temp_fonc_100: 60 };
402
+ tv_temp_fonc_30_100(di, genCondensation(), {}, [emetteur('1'), emetteur('3')], 1900);
403
+ expect(di.temp_fonc_30).toBe(32);
404
+ expect(di.temp_fonc_100).toBe(60);
405
+ });
406
+
407
+ test('bug_for_bug_compat : seul un émetteur sans réseau de distribution est sans période -> aucune déduction', () => {
408
+ state.bug = true;
409
+ const di = { temp_fonc_30: 32, temp_fonc_100: 60 };
410
+ tv_temp_fonc_30_100(
411
+ di,
412
+ genCondensation(),
413
+ {},
414
+ [emetteur('1'), emetteur('3', 'avant 1981')],
415
+ 1900
416
+ );
417
+ expect(di.temp_fonc_30).toBe(38);
418
+ expect(di.temp_fonc_100).toBe(80);
419
+ expect(warnSpy).not.toHaveBeenCalled();
420
+ });
421
+
422
+ test('bug_for_bug_compat : une seule des deux valeurs reproduite -> période rejetée (tolérance 0,01 °C)', () => {
423
+ state.bug = true;
424
+ // 32/60.02 : « après 2000 » ne reproduit que temp_fonc_30 -> aucune candidate
425
+ const di = { temp_fonc_30: 32, temp_fonc_100: 60.02 };
426
+ tv_temp_fonc_30_100(di, genCondensation(), {}, [emetteur('3')], 1900);
427
+ expect(di.temp_fonc_30).toBe(38);
428
+ expect(di.temp_fonc_100).toBe(80);
429
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('valeurs hors tables'));
430
+ });
431
+
432
+ test('bug_for_bug_compat : écart inférieur à la tolérance accepté', () => {
433
+ state.bug = true;
434
+ const di = { temp_fonc_30: 32.005, temp_fonc_100: 59.995 };
435
+ tv_temp_fonc_30_100(di, genCondensation(), {}, [emetteur('3')], 1900);
436
+ expect(di.temp_fonc_30).toBe(32);
437
+ expect(di.temp_fonc_100).toBe(60);
438
+ });
439
+ });
440
+
441
+ describe('periodeEmetteursDeduiteDuDpe', () => {
442
+ const de = { enum_type_generateur_ch_id: '97' };
443
+ const em = [{ donnee_entree: { enum_temp_distribution_ch_id: '3' }, donnee_utilisateur: {} }];
444
+
445
+ test('aucune ligne de table trouvée pour une période : période non candidate', () => {
446
+ tv.mockReturnValue(null);
447
+ expect(periodeEmetteursDeduiteDuDpe(32, 60, de, em, 1900)).toBeNull();
448
+ });
449
+
450
+ test('ligne temp_fonc_100 absente : période non candidate', () => {
451
+ tv.mockImplementation((table) =>
452
+ table === 'temp_fonc_30' ? { tv_temp_fonc_30_id: '1', temp_fonc_30: '32' } : null
453
+ );
454
+ expect(periodeEmetteursDeduiteDuDpe(32, 60, de, em, 1900)).toBeNull();
455
+ });
456
+
457
+ test('toutes les périodes candidates : période de construction retenue', () => {
458
+ tv.mockImplementation((table) =>
459
+ table === 'temp_fonc_30'
460
+ ? { tv_temp_fonc_30_id: '1', temp_fonc_30: '32' }
461
+ : { tv_temp_fonc_100_id: '1', temp_fonc_100: '60' }
462
+ );
463
+ expect(periodeEmetteursDeduiteDuDpe(32, 60, de, em, 1900)).toBe(
464
+ PERIODES_INSTALLATION_EMETTEUR[0]
465
+ );
466
+ expect(periodeEmetteursDeduiteDuDpe(32, 60, de, em, 2010)).toBe('après 2000');
467
+ });
468
+ });
469
+
470
+ describe('periodeEmetteurAnneeConstruction', () => {
471
+ test.each([
472
+ [1980, 'avant 1981'],
473
+ [1981, 'entre 1981 et 2000'],
474
+ [1999, 'entre 1981 et 2000'],
475
+ [2000, 'après 2000']
476
+ ])('année de construction %s : %s', (ac, periode) => {
477
+ expect(periodeEmetteurAnneeConstruction(ac)).toBe(periode);
478
+ });
479
+ });
480
+
197
481
  describe('tempDistributionChPourTempFonc', () => {
198
482
  test('émetteur sans type d’émission : température déclarée renvoyée telle quelle', () => {
199
483
  expect(tempDistributionChPourTempFonc({ enum_temp_distribution_ch_id: '3' }, {})).toBe('3');
package/README.md CHANGED
@@ -370,57 +370,57 @@ Ces résultats sont **générés automatiquement** à la fin de `npm run test:co
370
370
 
371
371
  <!-- CORPUS:START -->
372
372
 
373
- > **Version `1.8.12`** · à publier · branche `main` · généré le 2026-10-02
373
+ > **Version `1.8.14`** · à publier · branche `main` · généré le 2026-10-02
374
374
  > Seuil de tolérance **5%**
375
375
 
376
376
  <table>
377
377
  <tr>
378
378
  <td align="center"><strong>9</strong><br/><sub>corpus</sub></td>
379
379
  <td align="center"><strong>89 990</strong><br/><sub>DPE analysés</sub></td>
380
- <td align="center"><strong>60 087</strong><br/><sub>DPE conformes</sub></td>
381
- <td align="center"><strong>66,77 %</strong><br/><sub>réussite globale</sub></td>
380
+ <td align="center"><strong>60 314</strong><br/><sub>DPE conformes</sub></td>
381
+ <td align="center"><strong>67,02 %</strong><br/><sub>réussite globale</sub></td>
382
382
  </tr>
383
383
  </table>
384
384
 
385
385
  | | Corpus | Réussite | | DPE conformes |
386
386
  | :-: | :---------------------------------------------------------------------------------------------------------------------- | ----------: | :--------------------- | -------------: |
387
- | 🔴 | **Généraliste**<br/><sub>`corpus_dpe.csv`</sub> | **46,31 %** | `█████████░░░░░░░░░░░` | 4 631 / 10 000 |
387
+ | 🔴 | **Généraliste**<br/><sub>`corpus_dpe.csv`</sub> | **46,36 %** | `█████████░░░░░░░░░░░` | 4 636 / 10 000 |
388
388
  | 🟢 | **Appartement · chauffage individuel (2025)**<br/><sub>`dpe_appartement_individuel_chauffage_individuel_2025.csv`</sub> | **92,42 %** | `██████████████████░░` | 9 242 / 10 000 |
389
- | 🟢 | **Logement individuel (2025)**<br/><sub>`dpe_logement_individuel_2025.csv`</sub> | **89,39 %** | `██████████████████░░` | 8 932 / 9 992 |
390
- | 🟢 | **Maison individuelle (2025)**<br/><sub>`dpe_maison_individuelle_2025.csv`</sub> | **88,41 %** | `██████████████████░░` | 8 841 / 10 000 |
391
- | 🟡 | **Immeuble · chauffage individuel**<br/><sub>`dpe_immeuble_chauffage_individuel.csv`</sub> | **74,24 %** | `███████████████░░░░░` | 7 423 / 9 999 |
392
- | 🟡 | **Appartement · chauffage collectif (2025)**<br/><sub>`dpe_appartement_individuel_chauffage_collectif_2025.csv`</sub> | **71,10 %** | `██████████████░░░░░░` | 7 110 / 10 000 |
393
- | 🟡 | **Immeuble · chauffage collectif**<br/><sub>`dpe_immeuble_chauffage_collectif.csv`</sub> | **62,65 %** | `█████████████░░░░░░░` | 6 264 / 9 999 |
394
- | 🔴 | **Immeuble · chauffage mixte**<br/><sub>`dpe_immeuble_chauffage_mixte.csv`</sub> | **49,05 %** | `██████████░░░░░░░░░░` | 4 905 / 10 000 |
389
+ | 🟢 | **Logement individuel (2025)**<br/><sub>`dpe_logement_individuel_2025.csv`</sub> | **89,42 %** | `██████████████████░░` | 8 935 / 9 992 |
390
+ | 🟢 | **Maison individuelle (2025)**<br/><sub>`dpe_maison_individuelle_2025.csv`</sub> | **88,50 %** | `██████████████████░░` | 8 850 / 10 000 |
391
+ | 🟡 | **Immeuble · chauffage individuel**<br/><sub>`dpe_immeuble_chauffage_individuel.csv`</sub> | **75,85 %** | `███████████████░░░░░` | 7 584 / 9 999 |
392
+ | 🟡 | **Appartement · chauffage collectif (2025)**<br/><sub>`dpe_appartement_individuel_chauffage_collectif_2025.csv`</sub> | **71,18 %** | `██████████████░░░░░░` | 7 118 / 10 000 |
393
+ | 🟡 | **Immeuble · chauffage collectif**<br/><sub>`dpe_immeuble_chauffage_collectif.csv`</sub> | **62,67 %** | `█████████████░░░░░░░` | 6 266 / 9 999 |
394
+ | 🔴 | **Immeuble · chauffage mixte**<br/><sub>`dpe_immeuble_chauffage_mixte.csv`</sub> | **49,44 %** | `██████████░░░░░░░░░░` | 4 944 / 10 000 |
395
395
  | 🔴 | **Individuel généré depuis l'immeuble (2026)**<br/><sub>`dpe_individuel_a_partir_dpe_immeuble_2026.csv`</sub> | **27,39 %** | `█████░░░░░░░░░░░░░░░` | 2 739 / 10 000 |
396
396
 
397
397
  <sub>🟢 ≥ 85 % · 🟡 ≥ 60 % · 🔴 < 60 %</sub>
398
398
 
399
399
  #### Temps d’exécution
400
400
 
401
- > Durée de l’appel à `calcul_3cl` par DPE, sur 89 950 calculs.
401
+ > Durée de l’appel à `calcul_3cl` par DPE, sur 89 956 calculs.
402
402
  > La copie défensive de l’entrée et la lecture du fichier sont exclues de la mesure.
403
403
 
404
404
  <table>
405
405
  <tr>
406
- <td align="center"><strong>5,30 ms</strong><br/><sub>moyenne</sub></td>
407
- <td align="center"><strong>4,64 ms</strong><br/><sub>médiane</sub></td>
406
+ <td align="center"><strong>5,23 ms</strong><br/><sub>moyenne</sub></td>
407
+ <td align="center"><strong>4,55 ms</strong><br/><sub>médiane</sub></td>
408
408
  <td align="center"><strong>0,11 ms</strong><br/><sub>min</sub></td>
409
- <td align="center"><strong>600,7 ms</strong><br/><sub>max</sub></td>
409
+ <td align="center"><strong>622,7 ms</strong><br/><sub>max</sub></td>
410
410
  </tr>
411
411
  </table>
412
412
 
413
413
  | Corpus | Moyenne | Médiane | Min | Max | p95 | p99 |
414
414
  | :--------------------------------------------- | ------: | ------: | ------: | -------: | ------: | ------: |
415
- | **Généraliste** | 4,30 ms | 3,86 ms | 0,20 ms | 30,9 ms | 7,81 ms | 10,7 ms |
416
- | **Appartement · chauffage individuel (2025)** | 3,75 ms | 3,39 ms | 1,45 ms | 27,1 ms | 6,51 ms | 8,46 ms |
417
- | **Logement individuel (2025)** | 5,31 ms | 4,77 ms | 1,60 ms | 39,2 ms | 9,77 ms | 12,9 ms |
418
- | **Maison individuelle (2025)** | 7,02 ms | 6,29 ms | 2,10 ms | 50,3 ms | 12,9 ms | 19,2 ms |
419
- | **Immeuble · chauffage individuel** | 6,13 ms | 5,23 ms | 0,11 ms | 95,6 ms | 12,4 ms | 19,6 ms |
420
- | **Appartement · chauffage collectif (2025)** | 4,61 ms | 4,32 ms | 1,56 ms | 34,9 ms | 7,47 ms | 10,9 ms |
421
- | **Immeuble · chauffage collectif** | 5,38 ms | 4,58 ms | 0,20 ms | 100,0 ms | 9,96 ms | 16,9 ms |
422
- | **Immeuble · chauffage mixte** | 5,18 ms | 4,17 ms | 0,19 ms | 600,7 ms | 11,0 ms | 18,2 ms |
423
- | **Individuel généré depuis l'immeuble (2026)** | 5,99 ms | 5,17 ms | 1,63 ms | 48,8 ms | 11,5 ms | 16,9 ms |
415
+ | **Généraliste** | 4,22 ms | 3,76 ms | 0,22 ms | 34,7 ms | 7,72 ms | 11,0 ms |
416
+ | **Appartement · chauffage individuel (2025)** | 3,84 ms | 3,52 ms | 1,45 ms | 30,9 ms | 6,58 ms | 8,40 ms |
417
+ | **Logement individuel (2025)** | 4,70 ms | 4,15 ms | 1,59 ms | 35,3 ms | 8,68 ms | 12,1 ms |
418
+ | **Maison individuelle (2025)** | 6,54 ms | 5,89 ms | 1,98 ms | 41,2 ms | 11,9 ms | 17,0 ms |
419
+ | **Immeuble · chauffage individuel** | 6,29 ms | 5,33 ms | 0,11 ms | 132,2 ms | 12,8 ms | 20,0 ms |
420
+ | **Appartement · chauffage collectif (2025)** | 4,41 ms | 4,00 ms | 1,48 ms | 33,8 ms | 7,37 ms | 9,64 ms |
421
+ | **Immeuble · chauffage collectif** | 6,38 ms | 5,37 ms | 0,20 ms | 105,0 ms | 13,4 ms | 24,1 ms |
422
+ | **Immeuble · chauffage mixte** | 4,95 ms | 4,03 ms | 0,20 ms | 622,7 ms | 10,3 ms | 16,3 ms |
423
+ | **Individuel généré depuis l'immeuble (2026)** | 5,76 ms | 4,91 ms | 1,65 ms | 48,0 ms | 11,0 ms | 16,8 ms |
424
424
 
425
425
  <sub>La moyenne est tirée vers le haut par les DPE collectifs, dont le coût atteint plusieurs
426
426
  dizaines de fois la médiane : c’est la médiane qui décrit le cas courant, et p95/p99 le cas
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.8.14';
36
+ const LIB_VERSION = '1.8.15';
37
37
 
38
38
  const dpeSanitizerService = new DpeSanitizerService();
39
39
 
@@ -1,5 +1,25 @@
1
1
  import { ChTvStore } from '../../../dpe/infrastructure/ch/chTv.store.js';
2
2
  import { inject } from 'dioma';
3
+ import { bug_for_bug_compat } from '../../../../utils.js';
4
+ import { logger } from '../../../../core/util/logger/log-service.js';
5
+
6
+ /**
7
+ * Périodes d'installation des émetteurs (enum_periode_installation_emetteur_id), de la plus
8
+ * ancienne à la plus récente.
9
+ */
10
+ export const PERIODES_INSTALLATION_EMETTEUR = [1, 2, 3];
11
+
12
+ /**
13
+ * Tolérance (°C) pour considérer qu'une température de fonctionnement issue des tables reproduit
14
+ * celle stockée dans le DPE.
15
+ */
16
+ export const TOLERANCE_TEMP_FONC = 0.01;
17
+
18
+ function estNumerique(valeur) {
19
+ return (
20
+ valeur !== null && valeur !== undefined && valeur !== '' && Number.isFinite(Number(valeur))
21
+ );
22
+ }
3
23
 
4
24
  /**
5
25
  * Calcul des données des émetteurs de chauffage
@@ -28,69 +48,173 @@ export class EmetteurChService {
28
48
  *
29
49
  * @param ctx {Contexte}
30
50
  * @param emetteurChauffage {EmetteurChauffage}
51
+ * @param periodeParDefaut {number|undefined} période à retenir si elle n'est pas saisie (déduite
52
+ * des données intermédiaires du DPE en mode bug_for_bug_compat, cf. issue #220)
31
53
  */
32
- periodeInstallationEmetteur(ctx, emetteurChauffage) {
54
+ periodeInstallationEmetteur(ctx, emetteurChauffage, periodeParDefaut = undefined) {
33
55
  const periodeInstallationEmetteur = parseInt(
34
56
  emetteurChauffage.donnee_entree.enum_periode_installation_emetteur_id
35
57
  );
36
58
 
37
59
  if (!periodeInstallationEmetteur) {
60
+ if (periodeParDefaut) {
61
+ return periodeParDefaut;
62
+ }
38
63
  return ctx.anneeConstruction < 1981 ? 1 : ctx.anneeConstruction < 2000 ? 2 : 3;
39
64
  }
40
65
 
41
66
  return periodeInstallationEmetteur;
42
67
  }
43
68
 
69
+ /**
70
+ * Émetteurs pris en compte pour les températures de fonctionnement : ceux disposant d'un réseau
71
+ * de distribution (enum_temp_distribution_ch_id ≠ 1).
72
+ *
73
+ * @param emetteursChauffage {EmetteurChauffage[]}
74
+ * @return {EmetteurChauffage[]}
75
+ */
76
+ #emetteursAvecDistribution(emetteursChauffage) {
77
+ return emetteursChauffage.filter(
78
+ (emetteurChauffage) =>
79
+ parseInt(emetteurChauffage.donnee_entree.enum_temp_distribution_ch_id) !== 1
80
+ );
81
+ }
82
+
83
+ /**
84
+ * Mode bug_for_bug_compat uniquement (issue #220).
85
+ *
86
+ * Certains logiciels calculent les températures de fonctionnement avec une période
87
+ * d'installation des émetteurs qu'ils n'exportent pas, mais stockent temp_fonc_30 /
88
+ * temp_fonc_100 dans les données intermédiaires du générateur. Chaque période candidate est
89
+ * affectée à tous les émetteurs sans période saisie ; seules celles qui reproduisent les DEUX
90
+ * valeurs du DPE (tolérance 0,01 °C) sont retenues :
91
+ * - une seule candidate : retenue ;
92
+ * - plusieurs : celle de l'année de construction si elle en fait partie, sinon la plus récente ;
93
+ * - aucune : undefined (repli sur l'année de construction).
94
+ *
95
+ * Aucune déduction si le mode bug_for_bug_compat est désactivé, si l'une des deux températures
96
+ * du DPE est absente ou non numérique, ou si tous les émetteurs ont une période saisie.
97
+ *
98
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §13.2.1.5
99
+ *
100
+ * @param ctx {Contexte}
101
+ * @param generateurChauffageDE {GenerateurChauffageDE}
102
+ * @param emetteursChauffage {EmetteurChauffage[]}
103
+ * @param temperaturesDpe {{temp_fonc_30: number, temp_fonc_100: number}|undefined} valeurs
104
+ * d'origine des données intermédiaires du générateur dans le DPE
105
+ * @return {number|undefined} période déduite (enum_periode_installation_emetteur_id)
106
+ */
107
+ periodeInstallationEmetteurDeduite(
108
+ ctx,
109
+ generateurChauffageDE,
110
+ emetteursChauffage,
111
+ temperaturesDpe
112
+ ) {
113
+ if (
114
+ !bug_for_bug_compat ||
115
+ !estNumerique(temperaturesDpe?.temp_fonc_30) ||
116
+ !estNumerique(temperaturesDpe?.temp_fonc_100)
117
+ ) {
118
+ return undefined;
119
+ }
120
+
121
+ const emetteursSansPeriode = this.#emetteursAvecDistribution(emetteursChauffage).filter(
122
+ (emetteurChauffage) =>
123
+ !parseInt(emetteurChauffage.donnee_entree.enum_periode_installation_emetteur_id)
124
+ );
125
+ if (emetteursSansPeriode.length === 0) {
126
+ return undefined;
127
+ }
128
+
129
+ const tempFonc30Dpe = Number(temperaturesDpe.temp_fonc_30);
130
+ const tempFonc100Dpe = Number(temperaturesDpe.temp_fonc_100);
131
+
132
+ const candidates = PERIODES_INSTALLATION_EMETTEUR.filter((periode) => {
133
+ const { temp_fonc_30, temp_fonc_100 } = this.temperatureFonctionnement(
134
+ ctx,
135
+ generateurChauffageDE,
136
+ emetteursChauffage,
137
+ periode
138
+ );
139
+ return (
140
+ estNumerique(temp_fonc_30) &&
141
+ estNumerique(temp_fonc_100) &&
142
+ Math.abs(temp_fonc_30 - tempFonc30Dpe) <= TOLERANCE_TEMP_FONC &&
143
+ Math.abs(temp_fonc_100 - tempFonc100Dpe) <= TOLERANCE_TEMP_FONC
144
+ );
145
+ });
146
+
147
+ if (candidates.length === 0) {
148
+ logger.warn(
149
+ `période d'installation des émetteurs non déductible : valeurs hors tables / donnée d'entrée incohérente (temp_fonc_30 = ${tempFonc30Dpe}, temp_fonc_100 = ${tempFonc100Dpe}). Repli sur l'année de construction.`
150
+ );
151
+ return undefined;
152
+ }
153
+
154
+ const periodeConstruction = this.periodeInstallationEmetteur(ctx, { donnee_entree: {} });
155
+ const periode = candidates.includes(periodeConstruction)
156
+ ? periodeConstruction
157
+ : candidates[candidates.length - 1];
158
+
159
+ logger.info(
160
+ `période d'installation des émetteurs déduite des données intermédiaires du DPE : ${periode} (temp_fonc_30 = ${tempFonc30Dpe}, temp_fonc_100 = ${tempFonc100Dpe})`
161
+ );
162
+ return periode;
163
+ }
164
+
44
165
  /**
45
166
  * Calcul des températures de fonctionnement à 30% ou 100%
46
167
  *
47
168
  * @param ctx {Contexte}
48
169
  * @param generateurChauffageDE {GenerateurChauffageDE}
49
170
  * @param emetteursChauffage {EmetteurChauffage[]}
171
+ * @param periodeParDefaut {number|undefined} période affectée aux émetteurs sans période saisie
172
+ * (à défaut : année de construction)
50
173
  * @return {{temp_fonc_30: number, temp_fonc_100: number}}
51
174
  */
52
- temperatureFonctionnement(ctx, generateurChauffageDE, emetteursChauffage) {
175
+ temperatureFonctionnement(
176
+ ctx,
177
+ generateurChauffageDE,
178
+ emetteursChauffage,
179
+ periodeParDefaut = undefined
180
+ ) {
53
181
  let temperatureFonctionnement30;
54
182
  let temperatureFonctionnement100;
55
183
 
56
- emetteursChauffage
57
- .filter(
58
- (emetteurChauffage) =>
59
- parseInt(emetteurChauffage.donnee_entree.enum_temp_distribution_ch_id) !== 1
60
- )
61
- .forEach((emetteurChauffage) => {
62
- // Récupération de la température de distribution
63
- const temperatureDistribution = Number(
64
- emetteurChauffage.donnee_entree.enum_temp_distribution_ch_id
65
- );
66
-
67
- // Récupération de la période d'installation de distribution
68
- const periodeInstallationEmetteur = this.periodeInstallationEmetteur(
69
- ctx,
70
- emetteurChauffage
71
- );
72
-
73
- const tempFonctionnement30 = this.#chTvStore.temperatureFonctionnement(
74
- '30',
75
- generateurChauffageDE.enum_type_generateur_ch_id,
76
- temperatureDistribution,
77
- periodeInstallationEmetteur
78
- );
79
-
80
- const tempFonctionnement100 = this.#chTvStore.temperatureFonctionnement(
81
- '100',
82
- generateurChauffageDE.enum_type_generateur_ch_id,
83
- temperatureDistribution,
84
- periodeInstallationEmetteur
85
- );
86
-
87
- if (!temperatureFonctionnement30 || tempFonctionnement30 > temperatureFonctionnement30) {
88
- temperatureFonctionnement30 = tempFonctionnement30;
89
- }
90
- if (!temperatureFonctionnement100 || tempFonctionnement100 > temperatureFonctionnement100) {
91
- temperatureFonctionnement100 = tempFonctionnement100;
92
- }
93
- });
184
+ this.#emetteursAvecDistribution(emetteursChauffage).forEach((emetteurChauffage) => {
185
+ // Récupération de la température de distribution
186
+ const temperatureDistribution = Number(
187
+ emetteurChauffage.donnee_entree.enum_temp_distribution_ch_id
188
+ );
189
+
190
+ // Récupération de la période d'installation de distribution
191
+ const periodeInstallationEmetteur = this.periodeInstallationEmetteur(
192
+ ctx,
193
+ emetteurChauffage,
194
+ periodeParDefaut
195
+ );
196
+
197
+ const tempFonctionnement30 = this.#chTvStore.temperatureFonctionnement(
198
+ '30',
199
+ generateurChauffageDE.enum_type_generateur_ch_id,
200
+ temperatureDistribution,
201
+ periodeInstallationEmetteur
202
+ );
203
+
204
+ const tempFonctionnement100 = this.#chTvStore.temperatureFonctionnement(
205
+ '100',
206
+ generateurChauffageDE.enum_type_generateur_ch_id,
207
+ temperatureDistribution,
208
+ periodeInstallationEmetteur
209
+ );
210
+
211
+ if (!temperatureFonctionnement30 || tempFonctionnement30 > temperatureFonctionnement30) {
212
+ temperatureFonctionnement30 = tempFonctionnement30;
213
+ }
214
+ if (!temperatureFonctionnement100 || tempFonctionnement100 > temperatureFonctionnement100) {
215
+ temperatureFonctionnement100 = tempFonctionnement100;
216
+ }
217
+ });
94
218
 
95
219
  return {
96
220
  temp_fonc_30: temperatureFonctionnement30,
@@ -1,6 +1,27 @@
1
1
  import { beforeEach, describe, expect, test, vi } from 'vitest';
2
- import { ChTvStore } from '../../../dpe/infrastructure/ch/chTv.store.js';
3
- import { EmetteurChService } from './emetteur-ch.service.js';
2
+
3
+ /**
4
+ * Dépendances mockées :
5
+ * - `bug_for_bug_compat` (utils.js) : piloté par le test ;
6
+ * - `logger` : traces de déduction de la période des émetteurs (issue #220).
7
+ */
8
+ const state = vi.hoisted(() => ({ bug: false }));
9
+
10
+ vi.mock('../../../../utils.js', async (importOriginal) => ({
11
+ ...(await importOriginal()),
12
+ get bug_for_bug_compat() {
13
+ return state.bug;
14
+ }
15
+ }));
16
+
17
+ vi.mock('../../../../core/util/logger/log-service.js', () => ({
18
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }
19
+ }));
20
+
21
+ const { ChTvStore } = await import('../../../dpe/infrastructure/ch/chTv.store.js');
22
+ const { EmetteurChService, PERIODES_INSTALLATION_EMETTEUR } =
23
+ await import('./emetteur-ch.service.js');
24
+ const { logger } = await import('../../../../core/util/logger/log-service.js');
4
25
 
5
26
  /** @type {EmetteurChService} **/
6
27
  let service;
@@ -10,6 +31,7 @@ let chTvStore;
10
31
 
11
32
  describe('Calcul des caractéristiques des générateurs de chauffage', () => {
12
33
  beforeEach(() => {
34
+ state.bug = false;
13
35
  chTvStore = new ChTvStore();
14
36
  service = new EmetteurChService(chTvStore);
15
37
  });
@@ -37,6 +59,19 @@ describe('Calcul des caractéristiques des générateurs de chauffage', () => {
37
59
  ).toBe(3);
38
60
  });
39
61
 
62
+ test("Période par défaut (déduite) utilisée uniquement si la période n'est pas saisie", () => {
63
+ expect(
64
+ service.periodeInstallationEmetteur({ anneeConstruction: 1850 }, { donnee_entree: {} }, 3)
65
+ ).toBe(3);
66
+ expect(
67
+ service.periodeInstallationEmetteur(
68
+ { anneeConstruction: 1850 },
69
+ { donnee_entree: { enum_periode_installation_emetteur_id: 1 } },
70
+ 3
71
+ )
72
+ ).toBe(1);
73
+ });
74
+
40
75
  test('Détermination des températures de fonctionnement à 30 et 100% de charge', () => {
41
76
  vi.spyOn(chTvStore, 'temperatureFonctionnement')
42
77
  .mockReturnValueOnce(25)
@@ -76,3 +111,206 @@ describe('Calcul des caractéristiques des générateurs de chauffage', () => {
76
111
  expect(chTvStore.temperatureFonctionnement).toHaveBeenCalledWith('100', 80, 2, 2);
77
112
  });
78
113
  });
114
+
115
+ /**
116
+ * Issue #220 - période d'installation des émetteurs absente du DPE.
117
+ * Mode strict : année de construction. Mode bug_for_bug_compat : période retrouvée à partir des
118
+ * temp_fonc_30 / temp_fonc_100 d'origine du DPE.
119
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §13.2.1.5
120
+ */
121
+ describe('Déduction de la période des émetteurs depuis les données du DPE (issue #220)', () => {
122
+ /**
123
+ * Table de test contrôlée (chaudière à condensation) :
124
+ * [température de distribution][période] -> [temp_fonc_30, temp_fonc_100].
125
+ */
126
+ const TABLE = {
127
+ 2: { 1: [32, 60], 2: [24.5, 35], 3: [24.5, 35] },
128
+ 3: { 1: [38, 80], 2: [35, 70], 3: [32, 60] },
129
+ 4: { 1: [38, 80], 2: [35, 70], 3: [35, 70] }
130
+ };
131
+
132
+ const generateurDE = { enum_type_generateur_ch_id: '97' };
133
+
134
+ function emetteur(tempDistribution, periode) {
135
+ const de = { enum_temp_distribution_ch_id: tempDistribution };
136
+ if (periode) de.enum_periode_installation_emetteur_id = periode;
137
+ return { donnee_entree: de };
138
+ }
139
+
140
+ beforeEach(() => {
141
+ state.bug = true;
142
+ vi.mocked(logger.info).mockReset();
143
+ vi.mocked(logger.warn).mockReset();
144
+ chTvStore = new ChTvStore();
145
+ service = new EmetteurChService(chTvStore);
146
+ vi.spyOn(chTvStore, 'temperatureFonctionnement').mockImplementation(
147
+ (pourcentage, _type, tempDistribution, periode) =>
148
+ TABLE[tempDistribution]?.[periode]?.[pourcentage === '30' ? 0 : 1]
149
+ );
150
+ });
151
+
152
+ test('périodes candidates ordonnées de la plus ancienne à la plus récente', () => {
153
+ expect(PERIODES_INSTALLATION_EMETTEUR).toStrictEqual([1, 2, 3]);
154
+ });
155
+
156
+ test('T1 strict : bug_for_bug_compat désactivé -> aucune déduction (année de construction)', () => {
157
+ state.bug = false;
158
+ const ctx = { anneeConstruction: 1900 };
159
+ const periode = service.periodeInstallationEmetteurDeduite(ctx, generateurDE, [emetteur(3)], {
160
+ temp_fonc_30: 32,
161
+ temp_fonc_100: 60
162
+ });
163
+ expect(periode).toBeUndefined();
164
+ expect(chTvStore.temperatureFonctionnement).not.toHaveBeenCalled();
165
+ expect(
166
+ service.temperatureFonctionnement(ctx, generateurDE, [emetteur(3)], periode)
167
+ ).toStrictEqual({ temp_fonc_30: 38, temp_fonc_100: 80 });
168
+ });
169
+
170
+ test('T2 bug_for_bug_compat : DPE 32/60, construction 1900 -> période « après 2000 » (3)', () => {
171
+ const ctx = { anneeConstruction: 1900 };
172
+ const periode = service.periodeInstallationEmetteurDeduite(ctx, generateurDE, [emetteur(3)], {
173
+ temp_fonc_30: 32,
174
+ temp_fonc_100: 60
175
+ });
176
+ expect(periode).toBe(3);
177
+ expect(
178
+ service.temperatureFonctionnement(ctx, generateurDE, [emetteur(3)], periode)
179
+ ).toStrictEqual({ temp_fonc_30: 32, temp_fonc_100: 60 });
180
+ expect(logger.info).toHaveBeenCalledWith(
181
+ expect.stringContaining(
182
+ "période d'installation des émetteurs déduite des données intermédiaires du DPE"
183
+ )
184
+ );
185
+ });
186
+
187
+ test('T2 bis bug_for_bug_compat : valeurs du DPE sous forme de chaînes acceptées', () => {
188
+ expect(
189
+ service.periodeInstallationEmetteurDeduite(
190
+ { anneeConstruction: 1900 },
191
+ generateurDE,
192
+ [emetteur(3)],
193
+ { temp_fonc_30: '32', temp_fonc_100: '60' }
194
+ )
195
+ ).toBe(3);
196
+ });
197
+
198
+ test('T3 bug_for_bug_compat : période saisie -> aucune déduction, période saisie conservée', () => {
199
+ const ctx = { anneeConstruction: 1900 };
200
+ const emetteurs = [emetteur(3, 1)];
201
+ const periode = service.periodeInstallationEmetteurDeduite(ctx, generateurDE, emetteurs, {
202
+ temp_fonc_30: 32,
203
+ temp_fonc_100: 60
204
+ });
205
+ expect(periode).toBeUndefined();
206
+ expect(service.temperatureFonctionnement(ctx, generateurDE, emetteurs, 3)).toStrictEqual({
207
+ temp_fonc_30: 38,
208
+ temp_fonc_100: 80
209
+ });
210
+ });
211
+
212
+ test('T4 bug_for_bug_compat : distribution haute, construction 1990, DPE 35/70 -> période de construction (2)', () => {
213
+ expect(
214
+ service.periodeInstallationEmetteurDeduite(
215
+ { anneeConstruction: 1990 },
216
+ generateurDE,
217
+ [emetteur(4)],
218
+ { temp_fonc_30: 35, temp_fonc_100: 70 }
219
+ )
220
+ ).toBe(2);
221
+ });
222
+
223
+ test('T5 bug_for_bug_compat : DPE 33/62 hors tables -> aucune candidate, avertissement', () => {
224
+ expect(
225
+ service.periodeInstallationEmetteurDeduite(
226
+ { anneeConstruction: 1990 },
227
+ generateurDE,
228
+ [emetteur(3)],
229
+ { temp_fonc_30: 33, temp_fonc_100: 62 }
230
+ )
231
+ ).toBeUndefined();
232
+ expect(logger.warn).toHaveBeenCalledWith(
233
+ expect.stringContaining("valeurs hors tables / donnée d'entrée incohérente")
234
+ );
235
+ });
236
+
237
+ test.each([
238
+ ['données intermédiaires absentes', undefined],
239
+ ['temp_fonc_100 absente', { temp_fonc_30: 32 }],
240
+ ['temp_fonc_30 absente', { temp_fonc_100: 60 }],
241
+ ['temp_fonc_30 non numérique', { temp_fonc_30: 'abc', temp_fonc_100: 60 }],
242
+ ['temp_fonc_100 vide', { temp_fonc_30: 32, temp_fonc_100: '' }],
243
+ ['temp_fonc_30 nulle', { temp_fonc_30: null, temp_fonc_100: 60 }]
244
+ ])('T6 bug_for_bug_compat : %s -> aucune déduction', (_, di) => {
245
+ expect(
246
+ service.periodeInstallationEmetteurDeduite(
247
+ { anneeConstruction: 1900 },
248
+ generateurDE,
249
+ [emetteur(3)],
250
+ di
251
+ )
252
+ ).toBeUndefined();
253
+ expect(chTvStore.temperatureFonctionnement).not.toHaveBeenCalled();
254
+ });
255
+
256
+ test('T7 bug_for_bug_compat : deux émetteurs sans période (moyenne + haute), DPE 35/70 -> règle du maximum, période la plus récente', () => {
257
+ /**
258
+ * 1 : max(38, 38) / max(80, 80) -> rejetée ; 2 : 35/70 -> candidate ;
259
+ * 3 : max(32, 35) / max(60, 70) = 35/70 -> candidate.
260
+ * Construction 1900 (période 1) hors candidates -> la plus récente : 3.
261
+ */
262
+ const ctx = { anneeConstruction: 1900 };
263
+ const emetteurs = [emetteur(3), emetteur(4)];
264
+ const periode = service.periodeInstallationEmetteurDeduite(ctx, generateurDE, emetteurs, {
265
+ temp_fonc_30: 35,
266
+ temp_fonc_100: 70
267
+ });
268
+ expect(periode).toBe(3);
269
+ expect(service.temperatureFonctionnement(ctx, generateurDE, emetteurs, periode)).toStrictEqual({
270
+ temp_fonc_30: 35,
271
+ temp_fonc_100: 70
272
+ });
273
+ });
274
+
275
+ test('bug_for_bug_compat : seul un émetteur sans réseau de distribution est sans période -> aucune déduction', () => {
276
+ expect(
277
+ service.periodeInstallationEmetteurDeduite(
278
+ { anneeConstruction: 1900 },
279
+ generateurDE,
280
+ [emetteur(1), emetteur(3, 1)],
281
+ { temp_fonc_30: 32, temp_fonc_100: 60 }
282
+ )
283
+ ).toBeUndefined();
284
+ });
285
+
286
+ test('bug_for_bug_compat : aucune valeur de table trouvée -> aucune candidate', () => {
287
+ vi.mocked(chTvStore.temperatureFonctionnement).mockReturnValue(undefined);
288
+ expect(
289
+ service.periodeInstallationEmetteurDeduite(
290
+ { anneeConstruction: 1900 },
291
+ generateurDE,
292
+ [emetteur(3)],
293
+ { temp_fonc_30: 32, temp_fonc_100: 60 }
294
+ )
295
+ ).toBeUndefined();
296
+ });
297
+
298
+ test('bug_for_bug_compat : une seule des deux valeurs reproduite -> période rejetée (tolérance 0,01 °C)', () => {
299
+ expect(
300
+ service.periodeInstallationEmetteurDeduite(
301
+ { anneeConstruction: 1900 },
302
+ generateurDE,
303
+ [emetteur(3)],
304
+ { temp_fonc_30: 32, temp_fonc_100: 60.02 }
305
+ )
306
+ ).toBeUndefined();
307
+ expect(
308
+ service.periodeInstallationEmetteurDeduite(
309
+ { anneeConstruction: 1900 },
310
+ generateurDE,
311
+ [emetteur(3)],
312
+ { temp_fonc_30: 32.005, temp_fonc_100: 59.995 }
313
+ )
314
+ ).toBe(3);
315
+ });
316
+ });
@@ -309,10 +309,24 @@ export class GenerateurChService {
309
309
  temp_fonc_100: generateurChauffageDI.temp_fonc_100
310
310
  };
311
311
  } else {
312
+ /**
313
+ * Mode bug_for_bug_compat (issue #220) : période d'installation des émetteurs non exportée
314
+ * retrouvée à partir des temp_fonc_30 / temp_fonc_100 d'origine du DPE (données
315
+ * intermédiaires du générateur, non encore recalculées à ce stade). Sans déduction possible,
316
+ * la règle §13.2.1.5 s'applique (année de construction).
317
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §13.2.1.5
318
+ */
319
+ const periodeDeduite = this.#emetteurChService.periodeInstallationEmetteurDeduite(
320
+ ctx,
321
+ generateurChauffageDE,
322
+ emetteursChauffage,
323
+ generateurChauffageDI
324
+ );
312
325
  return this.#emetteurChService.temperatureFonctionnement(
313
326
  ctx,
314
327
  generateurChauffage.donnee_entree,
315
- emetteursChauffage
328
+ emetteursChauffage,
329
+ periodeDeduite
316
330
  );
317
331
  }
318
332
  }
@@ -0,0 +1,81 @@
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest';
2
+ import { GenerateurChService } from './generateur-ch.service.js';
3
+ import { EmetteurChService } from './emetteur-ch.service.js';
4
+ import { ChTvStore } from '../../../dpe/infrastructure/ch/chTv.store.js';
5
+ import { TvStore } from '../../../dpe/infrastructure/tv.store.js';
6
+
7
+ /** @type {GenerateurChService} **/
8
+ let service;
9
+
10
+ /** @type {EmetteurChService} **/
11
+ let emetteurChService;
12
+
13
+ /**
14
+ * Températures de fonctionnement du générateur (délégation au service émetteurs).
15
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §13.2.1.5
16
+ */
17
+ describe('Températures de fonctionnement des générateurs de chauffage', () => {
18
+ const ctx = { anneeConstruction: 1900 };
19
+ const emetteurs = [{ donnee_entree: { enum_temp_distribution_ch_id: 3 } }];
20
+
21
+ beforeEach(() => {
22
+ emetteurChService = new EmetteurChService(new ChTvStore());
23
+ service = new GenerateurChService(new ChTvStore(), new TvStore(), emetteurChService);
24
+ vi.spyOn(emetteurChService, 'temperatureFonctionnement').mockReturnValue({
25
+ temp_fonc_30: 32,
26
+ temp_fonc_100: 60
27
+ });
28
+ vi.spyOn(emetteurChService, 'periodeInstallationEmetteurDeduite').mockReturnValue(3);
29
+ });
30
+
31
+ test('caractéristiques saisies (méthode 5) avec temp_fonc_30 et temp_fonc_100 : valeurs conservées', () => {
32
+ const generateur = {
33
+ donnee_entree: { enum_methode_saisie_carac_sys_id: 5, enum_type_generateur_ch_id: '97' },
34
+ donnee_intermediaire: { temp_fonc_30: 41, temp_fonc_100: 72 }
35
+ };
36
+ expect(service.temperatureFonctionnement(ctx, generateur, emetteurs)).toStrictEqual({
37
+ temp_fonc_30: 41,
38
+ temp_fonc_100: 72
39
+ });
40
+ expect(emetteurChService.periodeInstallationEmetteurDeduite).not.toHaveBeenCalled();
41
+ expect(emetteurChService.temperatureFonctionnement).not.toHaveBeenCalled();
42
+ });
43
+
44
+ test('valeurs forfaitaires : période déduite des données intermédiaires du DPE transmise au calcul (issue #220)', () => {
45
+ const generateurDE = { enum_methode_saisie_carac_sys_id: 1, enum_type_generateur_ch_id: '97' };
46
+ const generateurDI = { temp_fonc_30: 32, temp_fonc_100: 60 };
47
+ const generateur = { donnee_entree: generateurDE, donnee_intermediaire: generateurDI };
48
+
49
+ expect(service.temperatureFonctionnement(ctx, generateur, emetteurs)).toStrictEqual({
50
+ temp_fonc_30: 32,
51
+ temp_fonc_100: 60
52
+ });
53
+ // Les valeurs d'origine du DPE (di du générateur) servent à la déduction
54
+ expect(emetteurChService.periodeInstallationEmetteurDeduite).toHaveBeenCalledWith(
55
+ ctx,
56
+ generateurDE,
57
+ emetteurs,
58
+ generateurDI
59
+ );
60
+ expect(emetteurChService.temperatureFonctionnement).toHaveBeenCalledWith(
61
+ ctx,
62
+ generateurDE,
63
+ emetteurs,
64
+ 3
65
+ );
66
+ });
67
+
68
+ test('méthode 5 sans temp_fonc_100 : calcul forfaitaire depuis les émetteurs', () => {
69
+ vi.mocked(emetteurChService.periodeInstallationEmetteurDeduite).mockReturnValue(undefined);
70
+ const generateurDE = { enum_methode_saisie_carac_sys_id: 5, enum_type_generateur_ch_id: '97' };
71
+ const generateur = { donnee_entree: generateurDE, donnee_intermediaire: { temp_fonc_30: 32 } };
72
+
73
+ service.temperatureFonctionnement(ctx, generateur, emetteurs);
74
+ expect(emetteurChService.temperatureFonctionnement).toHaveBeenCalledWith(
75
+ ctx,
76
+ generateurDE,
77
+ emetteurs,
78
+ undefined
79
+ );
80
+ });
81
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open3cl/engine",
3
- "version": "1.8.14",
3
+ "version": "1.8.15",
4
4
  "description": "Open Source 3CL-DPE engine",
5
5
  "main": "index.js",
6
6
  "directories": {