@open3cl/engine 1.8.13 → 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.
@@ -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>59 886</strong><br/><sub>DPE conformes</sub></td>
381
- <td align="center"><strong>66,55 %</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 |
388
- | 🟢 | **Appartement · chauffage individuel (2025)**<br/><sub>`dpe_appartement_individuel_chauffage_individuel_2025.csv`</sub> | **91,80 %** | `██████████████████░░` | 9 180 / 10 000 |
389
- | 🟢 | **Logement individuel (2025)**<br/><sub>`dpe_logement_individuel_2025.csv`</sub> | **88,70 %** | `██████████████████░░` | 8 863 / 9 992 |
390
- | 🟢 | **Maison individuelle (2025)**<br/><sub>`dpe_maison_individuelle_2025.csv`</sub> | **88,40 %** | `██████████████████░░` | 8 840 / 10 000 |
391
- | 🟡 | **Immeuble · chauffage individuel**<br/><sub>`dpe_immeuble_chauffage_individuel.csv`</sub> | **74,14 %** | `███████████████░░░░░` | 7 413 / 9 999 |
392
- | 🟡 | **Appartement · chauffage collectif (2025)**<br/><sub>`dpe_appartement_individuel_chauffage_collectif_2025.csv`</sub> | **70,63 %** | `██████████████░░░░░░` | 7 063 / 10 000 |
393
- | 🟡 | **Immeuble · chauffage collectif**<br/><sub>`dpe_immeuble_chauffage_collectif.csv`</sub> | **62,52 %** | `█████████████░░░░░░░` | 6 251 / 9 999 |
394
- | 🔴 | **Immeuble · chauffage mixte**<br/><sub>`dpe_immeuble_chauffage_mixte.csv`</sub> | **49,06 %** | `██████████░░░░░░░░░░` | 4 906 / 10 000 |
387
+ | 🔴 | **Généraliste**<br/><sub>`corpus_dpe.csv`</sub> | **46,36 %** | `█████████░░░░░░░░░░░` | 4 636 / 10 000 |
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,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,49 ms</strong><br/><sub>moyenne</sub></td>
407
- <td align="center"><strong>4,81 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>842,5 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,60 ms | 4,18 ms | 0,22 ms | 34,3 ms | 8,29 ms | 11,3 ms |
416
- | **Appartement · chauffage individuel (2025)** | 3,94 ms | 3,52 ms | 1,41 ms | 29,4 ms | 6,94 ms | 10,8 ms |
417
- | **Logement individuel (2025)** | 5,12 ms | 4,56 ms | 1,59 ms | 38,6 ms | 9,60 ms | 13,1 ms |
418
- | **Maison individuelle (2025)** | 6,45 ms | 5,83 ms | 1,90 ms | 41,6 ms | 11,7 ms | 15,6 ms |
419
- | **Immeuble · chauffage individuel** | 6,64 ms | 5,67 ms | 0,11 ms | 74,6 ms | 13,5 ms | 21,9 ms |
420
- | **Appartement · chauffage collectif (2025)** | 4,74 ms | 4,39 ms | 1,57 ms | 34,6 ms | 7,84 ms | 11,9 ms |
421
- | **Immeuble · chauffage collectif** | 5,91 ms | 5,13 ms | 0,14 ms | 145,5 ms | 11,0 ms | 19,2 ms |
422
- | **Immeuble · chauffage mixte** | 5,97 ms | 4,83 ms | 0,21 ms | 842,5 ms | 12,5 ms | 21,1 ms |
423
- | **Individuel généré depuis l'immeuble (2026)** | 6,01 ms | 5,13 ms | 1,68 ms | 57,9 ms | 11,4 ms | 17,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.13';
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,