@open3cl/engine 1.8.9 → 1.8.10

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/9_besoin_ch.js CHANGED
@@ -1,7 +1,11 @@
1
1
  import enums from './enums.js';
2
2
  import tvs from './tv.js';
3
3
  import { calc_besoin_ecs_j } from './11_besoin_ecs.js';
4
- import { calc_Qrec_gen_j } from './9_generateur_ch.js';
4
+ import {
5
+ calc_Qrec_gen_ecs_j,
6
+ calc_Qrec_gen_j,
7
+ isGenerateurEcsSeulRecuperable
8
+ } from './9_generateur_ch.js';
5
9
  import { calc_ai_j, calc_as_j } from './6.1_apport_gratuit.js';
6
10
  import { calc_sse_j } from './6.2_surface_sud_equivalente.js';
7
11
  import { bug_for_bug_compat, mois_liste } from './utils.js';
@@ -105,6 +109,20 @@ export default function calc_besoin_ch(
105
109
  )
106
110
  );
107
111
 
112
+ /**
113
+ * 9.1.1 - Générateurs de la collection ECS assurant l'ECS uniquement, avec Qp0 > 0 et situés en
114
+ * volume chauffé : leurs pertes de génération sont aussi récupérées pour le chauffage.
115
+ * Les générateurs 'chauffage + ecs' sont exclus car déjà présents dans la collection chauffage.
116
+ * @see issue #153
117
+ *
118
+ * @type {GenerateurEcsItem[]}
119
+ */
120
+ const gen_ecs_recup = instal_ecs.flatMap((inst_ecs) =>
121
+ (inst_ecs.generateur_ecs_collection?.generateur_ecs || []).filter(
122
+ isGenerateurEcsSeulRecuperable
123
+ )
124
+ );
125
+
108
126
  const besoin_ch_mois = {};
109
127
  const besoin_ch_mois_dep = {};
110
128
  for (const mois of mois_liste) {
@@ -145,6 +163,13 @@ export default function calc_besoin_ch(
145
163
  gen_recup_dep += calc_Qrec_gen_j(gen_ch, nref21, Bch_hp_j_dep) / ratio;
146
164
  });
147
165
 
166
+ gen_ecs_recup.forEach((gen_ecs) => {
167
+ // Même facteur 1000 que pour les générateurs de chauffage (compatibilité « bug for bug »)
168
+ const ratio = bug_for_bug_compat ? 1000 : 1;
169
+ gen_recup += calc_Qrec_gen_ecs_j(gen_ecs, nref19) / ratio;
170
+ gen_recup_dep += calc_Qrec_gen_ecs_j(gen_ecs, nref21) / ratio;
171
+ });
172
+
148
173
  pertes_generateur_ch_recup += gen_recup;
149
174
  pertes_generateur_ch_recup_depensier += gen_recup_dep;
150
175
 
@@ -42,7 +42,9 @@ vi.mock('./11_besoin_ecs.js', () => ({
42
42
  }));
43
43
 
44
44
  vi.mock('./9_generateur_ch.js', () => ({
45
- calc_Qrec_gen_j: vi.fn()
45
+ calc_Qrec_gen_j: vi.fn(),
46
+ calc_Qrec_gen_ecs_j: vi.fn(),
47
+ isGenerateurEcsSeulRecuperable: vi.fn()
46
48
  }));
47
49
 
48
50
  const utilState = vi.hoisted(() => ({ bug: false }));
@@ -59,7 +61,8 @@ const { default: tvsBch } = await import('./tv.js');
59
61
  const { calc_ai_j, calc_as_j } = await import('./6.1_apport_gratuit.js');
60
62
  const { calc_sse_j } = await import('./6.2_surface_sud_equivalente.js');
61
63
  const { calc_besoin_ecs_j } = await import('./11_besoin_ecs.js');
62
- const { calc_Qrec_gen_j } = await import('./9_generateur_ch.js');
64
+ const { calc_Qrec_gen_j, calc_Qrec_gen_ecs_j, isGenerateurEcsSeulRecuperable } =
65
+ await import('./9_generateur_ch.js');
63
66
 
64
67
  /**
65
68
  * 9. Besoins de chauffage (Bch)
@@ -401,3 +404,149 @@ describe('calc_besoin_ch - branches complémentaires', () => {
401
404
  expect(ret.pertes_generateur_ch_recup).toBeCloseTo(1, 9);
402
405
  });
403
406
  });
407
+
408
+ /**
409
+ * 9.1.1 - Pertes récupérées de génération : générateurs de la collection ECS assurant l'ECS
410
+ * uniquement, avec Qp0 > 0 et en volume chauffé (issue #153).
411
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §9.1.1
412
+ */
413
+ describe('calc_besoin_ch - récupération des pertes des générateurs ECS seuls (#153)', () => {
414
+ const genEcsSeul = {
415
+ donnee_entree: { enum_usage_generateur_id: '2', position_volume_chauffe: 1 },
416
+ donnee_intermediaire: { qp0: 100 }
417
+ };
418
+ const genEcsMixte = {
419
+ donnee_entree: { enum_usage_generateur_id: '3', position_volume_chauffe: 1 },
420
+ donnee_intermediaire: { qp0: 100 }
421
+ };
422
+ const instal_ecs = [
423
+ {
424
+ donnee_entree: { enum_type_installation_id: '2', rdim: 1 },
425
+ generateur_ecs_collection: { generateur_ecs: [genEcsSeul, genEcsMixte] }
426
+ }
427
+ ];
428
+
429
+ beforeEach(() => {
430
+ utilState.bug = false;
431
+ vi.mocked(calc_ai_j).mockReset().mockReturnValue(5000);
432
+ vi.mocked(calc_as_j).mockReset().mockReturnValue(2000);
433
+ vi.mocked(calc_sse_j).mockReset().mockReturnValue(10);
434
+ vi.mocked(calc_besoin_ecs_j).mockReset().mockReturnValue(10);
435
+ vi.mocked(calc_Qrec_gen_j).mockReset().mockReturnValue(0);
436
+ vi.mocked(calc_Qrec_gen_ecs_j).mockReset();
437
+ vi.mocked(isGenerateurEcsSeulRecuperable)
438
+ .mockReset()
439
+ .mockImplementation((gen) => gen === genEcsSeul);
440
+ });
441
+
442
+ test('seuls les générateurs retenus par isGenerateurEcsSeulRecuperable sont calculés', () => {
443
+ vi.mocked(calc_Qrec_gen_ecs_j).mockReturnValue(500);
444
+ calc_besoin_ch(0, 0, 0, 0, 100, 100, 3, instal_ecs, [], [], null, 'maison', 1);
445
+
446
+ // un appel conventionnel (nref19 = 100) et un appel dépensier (nref21 = 120) pour le seul mois
447
+ expect(calc_Qrec_gen_ecs_j).toHaveBeenCalledTimes(2);
448
+ expect(calc_Qrec_gen_ecs_j).toHaveBeenCalledWith(genEcsSeul, 100);
449
+ expect(calc_Qrec_gen_ecs_j).toHaveBeenCalledWith(genEcsSeul, 120);
450
+ expect(calc_Qrec_gen_ecs_j).not.toHaveBeenCalledWith(genEcsMixte, expect.anything());
451
+ });
452
+
453
+ test('la récupération du générateur ECS seul s’ajoute aux pertes récupérées et réduit le besoin', () => {
454
+ vi.mocked(isGenerateurEcsSeulRecuperable).mockReturnValue(false);
455
+ const sansRecup = calc_besoin_ch(
456
+ 0,
457
+ 0,
458
+ 0,
459
+ 0,
460
+ 100,
461
+ 100,
462
+ 3,
463
+ instal_ecs,
464
+ [],
465
+ [],
466
+ null,
467
+ 'maison',
468
+ 1
469
+ );
470
+
471
+ vi.mocked(isGenerateurEcsSeulRecuperable).mockImplementation((gen) => gen === genEcsSeul);
472
+ vi.mocked(calc_Qrec_gen_ecs_j).mockImplementation((gen, nref) => (nref === 100 ? 500 : 600));
473
+ const avecRecup = calc_besoin_ch(
474
+ 0,
475
+ 0,
476
+ 0,
477
+ 0,
478
+ 100,
479
+ 100,
480
+ 3,
481
+ instal_ecs,
482
+ [],
483
+ [],
484
+ null,
485
+ 'maison',
486
+ 1
487
+ );
488
+
489
+ expect(avecRecup.pertes_generateur_ch_recup).toBeCloseTo(500, 9);
490
+ expect(avecRecup.pertes_generateur_ch_recup_depensier).toBeCloseTo(600, 9);
491
+ // 500 Wh récupérés => besoin réduit de 0,5 kWh
492
+ expect(sansRecup.besoin_ch - avecRecup.besoin_ch).toBeCloseTo(0.5, 9);
493
+ expect(sansRecup.besoin_ch_depensier - avecRecup.besoin_ch_depensier).toBeCloseTo(0.6, 9);
494
+ });
495
+
496
+ test('cumul avec la récupération des générateurs de chauffage', () => {
497
+ vi.mocked(calc_Qrec_gen_j).mockReturnValue(1000);
498
+ vi.mocked(calc_Qrec_gen_ecs_j).mockReturnValue(500);
499
+ const instal_ch = [
500
+ {
501
+ generateur_chauffage_collection: {
502
+ generateur_chauffage: [
503
+ { donnee_intermediaire: { qp0: 1 }, donnee_entree: { position_volume_chauffe: 1 } }
504
+ ]
505
+ }
506
+ }
507
+ ];
508
+ const ret = calc_besoin_ch(
509
+ 0,
510
+ 0,
511
+ 0,
512
+ 0,
513
+ 100,
514
+ 100,
515
+ 3,
516
+ instal_ecs,
517
+ instal_ch,
518
+ [],
519
+ null,
520
+ 'maison',
521
+ 1
522
+ );
523
+ expect(ret.pertes_generateur_ch_recup).toBeCloseTo(1500, 9);
524
+ });
525
+
526
+ test('compatibilité "bug for bug" : même facteur 1000 que pour les générateurs de chauffage', () => {
527
+ utilState.bug = true;
528
+ vi.mocked(calc_Qrec_gen_ecs_j).mockReturnValue(500);
529
+ const ret = calc_besoin_ch(0, 0, 0, 0, 100, 100, 3, instal_ecs, [], [], null, 'maison', 1);
530
+ expect(ret.pertes_generateur_ch_recup).toBeCloseTo(0.5, 9);
531
+ });
532
+
533
+ test('installation ECS sans générateur : pas de récupération', () => {
534
+ const ret = calc_besoin_ch(
535
+ 0,
536
+ 0,
537
+ 0,
538
+ 0,
539
+ 100,
540
+ 100,
541
+ 3,
542
+ [{ donnee_entree: { enum_type_installation_id: '2' }, generateur_ecs_collection: {} }],
543
+ [],
544
+ [],
545
+ null,
546
+ 'maison',
547
+ 1
548
+ );
549
+ expect(calc_Qrec_gen_ecs_j).not.toHaveBeenCalled();
550
+ expect(ret.pertes_generateur_ch_recup).toBe(0);
551
+ });
552
+ });
@@ -44,6 +44,52 @@ export function calc_Qrec_gen_j(gen_ch, nrefj, Bch_hp_j) {
44
44
  return 0.48 * Cper * di.qp0 * Dperj || 0;
45
45
  }
46
46
 
47
+ /**
48
+ * 9.1.1 - Pertes récupérées de génération pour le chauffage sur le mois j (Wh)
49
+ * pour un générateur de la collection ECS assurant l'ECS uniquement.
50
+ *
51
+ * Pour les générateurs assurant l'ECS uniquement :
52
+ * Dperj = Nrefj * 1790 / 8760
53
+ * Qgen_rec_j = 0,48 * Cper * Qp0 * Dperj
54
+ *
55
+ * Seuls les générateurs avec des pertes à l'arrêt (Qp0 > 0), situés en volume chauffé et dont
56
+ * l'usage est 'ecs' sont concernés. Les générateurs 'chauffage + ecs' sont déjà pris en compte via
57
+ * la collection des générateurs de chauffage (cf. issue #153).
58
+ *
59
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §9.1.1
60
+ *
61
+ * @param gen_ecs {GenerateurEcsItem}
62
+ * @param nrefj {number}
63
+ * @returns {number}
64
+ */
65
+ export function calc_Qrec_gen_ecs_j(gen_ecs, nrefj) {
66
+ const de = gen_ecs.donnee_entree;
67
+ const di = gen_ecs.donnee_intermediaire || {};
68
+
69
+ if (!isGenerateurEcsSeulRecuperable(gen_ecs)) return 0;
70
+
71
+ const Cper = Number(de.presence_ventouse) === 1 ? 0.75 : 0.5;
72
+ return 0.48 * Cper * di.qp0 * pertes_gen_ecs(nrefj);
73
+ }
74
+
75
+ /**
76
+ * Un générateur ECS est concerné par la récupération des pertes de génération pour le chauffage
77
+ * s'il a des pertes à l'arrêt (Qp0 > 0), s'il est en volume chauffé et si son usage est 'ecs' seul
78
+ * (enum_usage_generateur_id = 2).
79
+ *
80
+ * @param gen_ecs {GenerateurEcsItem}
81
+ * @returns {boolean}
82
+ */
83
+ export function isGenerateurEcsSeulRecuperable(gen_ecs) {
84
+ const de = gen_ecs.donnee_entree || {};
85
+ const di = gen_ecs.donnee_intermediaire || {};
86
+ return (
87
+ Number(di.qp0 || 0) > 0 &&
88
+ Number(de.position_volume_chauffe ?? 0) === 1 &&
89
+ Number(de.enum_usage_generateur_id) === 2
90
+ );
91
+ }
92
+
47
93
  function tv_rendement_generation(di, de, du) {
48
94
  const matcher = {
49
95
  enum_type_generateur_ch_id: requestInputID(de, du, 'type_generateur_ch')
@@ -59,6 +59,8 @@ vi.mock('./13.2_generateur_combustion.js', () => ({
59
59
 
60
60
  const {
61
61
  calc_Qrec_gen_j,
62
+ calc_Qrec_gen_ecs_j,
63
+ isGenerateurEcsSeulRecuperable,
62
64
  type_generateur_ch,
63
65
  checkForGeneratorType,
64
66
  calc_generateur_ch,
@@ -147,6 +149,71 @@ describe('calc_Qrec_gen_j - pertes de génération récupérées', () => {
147
149
  });
148
150
  });
149
151
 
152
+ /**
153
+ * 9.1.1 Pertes récupérées de génération pour le chauffage : générateur de la collection ECS
154
+ * assurant l'ECS uniquement (issue #153).
155
+ * Dperj = Nrefj * 1790 / 8760 ; Qgen_rec_j = 0,48 * Cper * Qp0 * Dperj
156
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §9.1.1
157
+ */
158
+ describe('calc_Qrec_gen_ecs_j / isGenerateurEcsSeulRecuperable - générateur ECS seul', () => {
159
+ /** Générateur ECS seul (usage 2), en volume chauffé, avec Qp0 = 100 W. */
160
+ function generateurEcs(overridesDe = {}, di = { qp0: 100 }) {
161
+ return {
162
+ donnee_entree: {
163
+ position_volume_chauffe: 1,
164
+ enum_usage_generateur_id: '2',
165
+ presence_ventouse: 0,
166
+ ...overridesDe
167
+ },
168
+ donnee_intermediaire: di
169
+ };
170
+ }
171
+
172
+ test('ECS seul, sans ventouse (Cper = 0.5) : 0.48 * 0.5 * Qp0 * Nref * 1790 / 8760', () => {
173
+ // 0.48 * 0.5 * 100 * (50 * 1790 / 8760) = 245.205479452...
174
+ expect(calc_Qrec_gen_ecs_j(generateurEcs(), 50)).toBeCloseTo(245.20547945205482, 9);
175
+ });
176
+
177
+ test('ECS seul, avec ventouse (Cper = 0.75)', () => {
178
+ // 0.48 * 0.75 * 100 * (50 * 1790 / 8760) = 367.808219178...
179
+ const gen = generateurEcs({ presence_ventouse: 1 });
180
+ expect(calc_Qrec_gen_ecs_j(gen, 50)).toBeCloseTo(367.8082191780822, 9);
181
+ });
182
+
183
+ test('la récupération est indépendante du besoin de chauffage et proportionnelle à Nref', () => {
184
+ const gen = generateurEcs();
185
+ expect(calc_Qrec_gen_ecs_j(gen, 100)).toBeCloseTo(2 * calc_Qrec_gen_ecs_j(gen, 50), 9);
186
+ });
187
+
188
+ test('générateur hors volume chauffé : aucune récupération', () => {
189
+ const gen = generateurEcs({ position_volume_chauffe: 0 });
190
+ expect(isGenerateurEcsSeulRecuperable(gen)).toBe(false);
191
+ expect(calc_Qrec_gen_ecs_j(gen, 50)).toBe(0);
192
+ });
193
+
194
+ test('position en volume chauffé non renseignée : aucune récupération', () => {
195
+ const gen = generateurEcs({ position_volume_chauffe: undefined });
196
+ expect(calc_Qrec_gen_ecs_j(gen, 50)).toBe(0);
197
+ });
198
+
199
+ test("usage 'chauffage + ecs' : exclu (déjà compté dans la collection chauffage)", () => {
200
+ const gen = generateurEcs({ enum_usage_generateur_id: '3' });
201
+ expect(isGenerateurEcsSeulRecuperable(gen)).toBe(false);
202
+ expect(calc_Qrec_gen_ecs_j(gen, 50)).toBe(0);
203
+ });
204
+
205
+ test('Qp0 nul ou absent : aucune récupération', () => {
206
+ expect(calc_Qrec_gen_ecs_j(generateurEcs({}, { qp0: 0 }), 50)).toBe(0);
207
+ expect(calc_Qrec_gen_ecs_j(generateurEcs({}, null), 50)).toBe(0);
208
+ expect(isGenerateurEcsSeulRecuperable({})).toBe(false);
209
+ });
210
+
211
+ test('identifiants numériques acceptés (usage 2, position 1)', () => {
212
+ const gen = generateurEcs({ enum_usage_generateur_id: 2, position_volume_chauffe: '1' });
213
+ expect(isGenerateurEcsSeulRecuperable(gen)).toBe(true);
214
+ });
215
+ });
216
+
150
217
  /**
151
218
  * Détermination du type de générateur exploitable pour un usage donné.
152
219
  */
package/README.md CHANGED
@@ -377,19 +377,19 @@ Ces résultats sont **générés automatiquement** à la fin de `npm run test:co
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 757</strong><br/><sub>DPE conformes</sub></td>
381
- <td align="center"><strong>66,40 %</strong><br/><sub>réussite globale</sub></td>
380
+ <td align="center"><strong>59 879</strong><br/><sub>DPE conformes</sub></td>
381
+ <td align="center"><strong>66,54 %</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,23 %** | `█████████░░░░░░░░░░░` | 4 623 / 10 000 |
388
- | 🟢 | **Appartement · chauffage individuel (2025)**<br/><sub>`dpe_appartement_individuel_chauffage_individuel_2025.csv`</sub> | **91,76 %** | `██████████████████░░` | 9 176 / 10 000 |
389
- | 🟢 | **Logement individuel (2025)**<br/><sub>`dpe_logement_individuel_2025.csv`</sub> | **88,41 %** | `██████████████████░░` | 8 834 / 9 992 |
387
+ | 🔴 | **Généraliste**<br/><sub>`corpus_dpe.csv`</sub> | **46,28 %** | `█████████░░░░░░░░░░░` | 4 628 / 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
390
  | 🟢 | **Maison individuelle (2025)**<br/><sub>`dpe_maison_individuelle_2025.csv`</sub> | **88,40 %** | `██████████████████░░` | 8 840 / 10 000 |
391
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> | **69,77 %** | `██████████████░░░░░░` | 6 977 / 10 000 |
392
+ | 🟡 | **Appartement · chauffage collectif (2025)**<br/><sub>`dpe_appartement_individuel_chauffage_collectif_2025.csv`</sub> | **70,61 %** | `██████████████░░░░░░` | 7 061 / 10 000 |
393
393
  | 🟡 | **Immeuble · chauffage collectif**<br/><sub>`dpe_immeuble_chauffage_collectif.csv`</sub> | **62,50 %** | `████████████░░░░░░░░` | 6 249 / 9 999 |
394
394
  | 🔴 | **Immeuble · chauffage mixte**<br/><sub>`dpe_immeuble_chauffage_mixte.csv`</sub> | **49,06 %** | `██████████░░░░░░░░░░` | 4 906 / 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 |
@@ -403,24 +403,24 @@ Ces résultats sont **générés automatiquement** à la fin de `npm run test:co
403
403
 
404
404
  <table>
405
405
  <tr>
406
- <td align="center"><strong>4,74 ms</strong><br/><sub>moyenne</sub></td>
407
- <td align="center"><strong>4,14 ms</strong><br/><sub>médiane</sub></td>
408
- <td align="center"><strong>0,11 ms</strong><br/><sub>min</sub></td>
409
- <td align="center"><strong>480,3 ms</strong><br/><sub>max</sub></td>
406
+ <td align="center"><strong>5,31 ms</strong><br/><sub>moyenne</sub></td>
407
+ <td align="center"><strong>4,65 ms</strong><br/><sub>médiane</sub></td>
408
+ <td align="center"><strong>0,12 ms</strong><br/><sub>min</sub></td>
409
+ <td align="center"><strong>893,4 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,07 ms | 3,66 ms | 0,21 ms | 29,4 ms | 7,48 ms | 10,1 ms |
416
- | **Appartement · chauffage individuel (2025)** | 3,58 ms | 3,27 ms | 1,22 ms | 23,6 ms | 6,14 ms | 7,97 ms |
417
- | **Logement individuel (2025)** | 4,62 ms | 4,06 ms | 1,45 ms | 35,7 ms | 8,83 ms | 12,5 ms |
418
- | **Maison individuelle (2025)** | 5,74 ms | 5,21 ms | 1,74 ms | 49,6 ms | 10,4 ms | 13,7 ms |
419
- | **Immeuble · chauffage individuel** | 5,70 ms | 4,84 ms | 0,11 ms | 73,3 ms | 11,6 ms | 17,9 ms |
420
- | **Appartement · chauffage collectif (2025)** | 4,07 ms | 3,70 ms | 1,30 ms | 30,5 ms | 6,90 ms | 10,3 ms |
421
- | **Immeuble · chauffage collectif** | 4,90 ms | 4,11 ms | 0,12 ms | 101,4 ms | 9,06 ms | 15,4 ms |
422
- | **Immeuble · chauffage mixte** | 4,60 ms | 3,75 ms | 0,19 ms | 480,3 ms | 9,46 ms | 15,9 ms |
423
- | **Individuel généré depuis l'immeuble (2026)** | 5,38 ms | 4,62 ms | 1,56 ms | 41,0 ms | 10,3 ms | 15,8 ms |
415
+ | **Généraliste** | 4,56 ms | 4,09 ms | 0,21 ms | 37,2 ms | 8,41 ms | 12,0 ms |
416
+ | **Appartement · chauffage individuel (2025)** | 4,01 ms | 3,65 ms | 1,47 ms | 33,8 ms | 6,95 ms | 9,38 ms |
417
+ | **Logement individuel (2025)** | 4,98 ms | 4,43 ms | 1,62 ms | 38,0 ms | 9,30 ms | 12,8 ms |
418
+ | **Maison individuelle (2025)** | 5,92 ms | 5,33 ms | 1,85 ms | 40,3 ms | 10,6 ms | 13,7 ms |
419
+ | **Immeuble · chauffage individuel** | 6,70 ms | 5,72 ms | 0,13 ms | 136,8 ms | 13,4 ms | 22,2 ms |
420
+ | **Appartement · chauffage collectif (2025)** | 4,60 ms | 4,36 ms | 1,46 ms | 37,2 ms | 7,27 ms | 9,45 ms |
421
+ | **Immeuble · chauffage collectif** | 5,31 ms | 4,38 ms | 0,12 ms | 99,5 ms | 9,95 ms | 17,7 ms |
422
+ | **Immeuble · chauffage mixte** | 5,38 ms | 4,34 ms | 0,20 ms | 893,4 ms | 11,2 ms | 18,5 ms |
423
+ | **Individuel généré depuis l'immeuble (2026)** | 6,36 ms | 5,53 ms | 1,67 ms | 50,2 ms | 12,4 ms | 19,1 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.9';
36
+ const LIB_VERSION = '1.8.10';
37
37
 
38
38
  const dpeSanitizerService = new DpeSanitizerService();
39
39
 
@@ -52,27 +52,73 @@ export class PerteChRecupService {
52
52
  */
53
53
  pertesGenerateurChRecup(ctx, logement, depensier) {
54
54
  const generateursWithPertesGeneration = this.generateursWithPertesGeneration(logement);
55
+ const generateursEcsSeulsWithPertesGeneration =
56
+ this.generateursEcsSeulsWithPertesGeneration(logement);
55
57
 
56
58
  return mois_liste.reduce((acc, mois) => {
59
+ const nref = this.#tvStore.getData(
60
+ depensier ? 'nref21' : 'nref19',
61
+ ctx.altitude.value,
62
+ ctx.zoneClimatique.value,
63
+ mois,
64
+ ctx.inertie.ilpa
65
+ );
57
66
  return (
58
67
  acc +
59
68
  this.Qrec(
60
69
  generateursWithPertesGeneration,
61
- this.#tvStore.getData(
62
- depensier ? 'nref21' : 'nref19',
63
- ctx.altitude.value,
64
- ctx.zoneClimatique.value,
65
- mois,
66
- ctx.inertie.ilpa
67
- ),
70
+ nref,
68
71
  depensier
69
72
  ? logement.donnees_de_calcul.besoinChauffageDepensierHP[mois]
70
73
  : logement.donnees_de_calcul.besoinChauffageHP[mois]
71
- )
74
+ ) +
75
+ this.QrecEcsSeul(generateursEcsSeulsWithPertesGeneration, nref)
72
76
  );
73
77
  }, 0);
74
78
  }
75
79
 
80
+ /**
81
+ * Générateurs de la collection ECS assurant l'ECS uniquement dont les pertes de génération sont
82
+ * récupérées pour le chauffage (issue #153) :
83
+ * — pertes à l'arrêt Qp0 > 0
84
+ * — situés en volume chauffé
85
+ * — usage ECS uniquement (enum_usage_generateur_id = 2). Les générateurs 'chauffage + ecs' sont
86
+ * déjà pris en compte dans la collection des générateurs de chauffage.
87
+ *
88
+ * @param logement {Logement}
89
+ * @return {GenerateurEcs[]}
90
+ */
91
+ generateursEcsSeulsWithPertesGeneration(logement) {
92
+ const installationsEcs = logement.installation_ecs_collection?.installation_ecs || [];
93
+
94
+ return installationsEcs.flatMap((installation) =>
95
+ (installation.generateur_ecs_collection?.generateur_ecs || []).filter((generateurEcs) => {
96
+ const generateurEcsDE = generateurEcs.donnee_entree || {};
97
+ const generateurEcsDI = generateurEcs.donnee_intermediaire || {};
98
+ return (
99
+ Number(generateurEcsDI.qp0 || 0) > 0 &&
100
+ Number(generateurEcsDE.position_volume_chauffe ?? 0) === 1 &&
101
+ parseInt(generateurEcsDE.enum_usage_generateur_id) === 2
102
+ );
103
+ })
104
+ );
105
+ }
106
+
107
+ /**
108
+ * Pertes récupérées de génération pour le chauffage sur le mois j (Wh) pour les générateurs
109
+ * assurant l'ECS uniquement : Dperj = Nrefj * 1790 / 8760
110
+ *
111
+ * @param generateurs {GenerateurEcs[]}
112
+ * @param nref {number}
113
+ * @return {number}
114
+ */
115
+ QrecEcsSeul(generateurs, nref) {
116
+ return generateurs.reduce((acc, generateur) => {
117
+ const Cper = Number(generateur.donnee_entree.presence_ventouse || 0) === 1 ? 0.75 : 0.5;
118
+ return acc + 0.48 * Cper * generateur.donnee_intermediaire.qp0 * this.#pertes_gen_ecs(nref);
119
+ }, 0);
120
+ }
121
+
76
122
  /**
77
123
  * Pertes récupérées des générateurs de chauffage pour le chauffage
78
124
  * 9.1.1 Consommation de chauffage
@@ -0,0 +1,185 @@
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest';
2
+ import { FrTvStore } from '../../../../dpe/infrastructure/froid/frTv.store.js';
3
+ import { PerteChRecupService } from './perte-ch-recup.service.js';
4
+ import { mois_liste } from '../../../../../utils.js';
5
+
6
+ /** @type {PerteChRecupService} **/
7
+ let service;
8
+
9
+ /** @type {FrTvStore} **/
10
+ let tvStore;
11
+
12
+ /**
13
+ * 9.1.1 Pertes récupérées de génération pour le chauffage
14
+ * Qgen_rec_j = 0,48 * Cper * Qp0 * Dperj
15
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §9.1.1
16
+ */
17
+ describe('Calcul des pertes récupérées de génération pour le chauffage', () => {
18
+ /** @type {Contexte} */
19
+ const ctx = {
20
+ altitude: { value: '400-800m' },
21
+ zoneClimatique: { value: 'h1a' },
22
+ inertie: { ilpa: 0 }
23
+ };
24
+
25
+ /** Besoins de chauffage hors pertes (kWh) : 10 kWh par mois */
26
+ const donneesDeCalcul = {
27
+ besoinChauffageHP: Object.fromEntries(mois_liste.map((m) => [m, 10])),
28
+ besoinChauffageDepensierHP: Object.fromEntries(mois_liste.map((m) => [m, 12]))
29
+ };
30
+
31
+ /** Générateur ECS seul (usage 2), en volume chauffé, Qp0 = 100 W */
32
+ function generateurEcs(de = {}, di = { qp0: 100 }) {
33
+ return {
34
+ donnee_entree: {
35
+ enum_usage_generateur_id: 2,
36
+ position_volume_chauffe: 1,
37
+ presence_ventouse: 0,
38
+ ...de
39
+ },
40
+ donnee_intermediaire: di
41
+ };
42
+ }
43
+
44
+ /** Logement avec une installation ECS et, optionnellement, des générateurs de chauffage */
45
+ function logement(generateursEcs = [], generateursCh = []) {
46
+ return {
47
+ donnees_de_calcul: donneesDeCalcul,
48
+ installation_ecs_collection: {
49
+ installation_ecs: [{ generateur_ecs_collection: { generateur_ecs: generateursEcs } }]
50
+ },
51
+ installation_chauffage_collection: {
52
+ installation_chauffage: [
53
+ { generateur_chauffage_collection: { generateur_chauffage: generateursCh } }
54
+ ]
55
+ }
56
+ };
57
+ }
58
+
59
+ beforeEach(() => {
60
+ tvStore = new FrTvStore();
61
+ service = new PerteChRecupService(tvStore);
62
+ // Nref constant : 100 h (conventionnel) / 120 h (dépensier) pour chacun des 12 mois
63
+ vi.spyOn(tvStore, 'getData').mockImplementation((type) => (type === 'nref21' ? 120 : 100));
64
+ });
65
+
66
+ describe('Générateurs ECS assurant l’ECS uniquement (issue #153)', () => {
67
+ test('ECS seul sans ventouse : 0,48 * 0,5 * Qp0 * Nref * 1790 / 8760 par mois', () => {
68
+ const result = service.execute(ctx, logement([generateurEcs()]));
69
+
70
+ // 12 * 0.48 * 0.5 * 100 * (100 * 1790 / 8760) / 1000 = 5.884931506... kWh
71
+ expect(result.pertes_generateur_ch_recup).toBeCloseTo(5.884931506849315, 9);
72
+ // 12 * 0.48 * 0.5 * 100 * (120 * 1790 / 8760) / 1000 = 7.061917808... kWh
73
+ expect(result.pertes_generateur_ch_recup_depensier).toBeCloseTo(7.061917808219178, 9);
74
+ });
75
+
76
+ test('ECS seul avec ventouse : Cper = 0,75', () => {
77
+ const result = service.execute(ctx, logement([generateurEcs({ presence_ventouse: 1 })]));
78
+ expect(result.pertes_generateur_ch_recup).toBeCloseTo(1.5 * 5.884931506849315, 9);
79
+ });
80
+
81
+ test.each([
82
+ { cas: 'hors volume chauffé', de: { position_volume_chauffe: 0 }, di: { qp0: 100 } },
83
+ {
84
+ cas: 'position non renseignée',
85
+ de: { position_volume_chauffe: undefined },
86
+ di: { qp0: 100 }
87
+ },
88
+ {
89
+ cas: "usage 'chauffage + ecs' (déjà compté)",
90
+ de: { enum_usage_generateur_id: 3 },
91
+ di: { qp0: 100 }
92
+ },
93
+ { cas: 'Qp0 nul', de: {}, di: { qp0: 0 } },
94
+ { cas: 'Qp0 absent', de: {}, di: null }
95
+ ])('pas de récupération pour un générateur ECS $cas', ({ de, di }) => {
96
+ const result = service.execute(ctx, logement([generateurEcs(de, di)]));
97
+ expect(result.pertes_generateur_ch_recup).toBe(0);
98
+ expect(result.pertes_generateur_ch_recup_depensier).toBe(0);
99
+ });
100
+
101
+ test('logement sans installation ECS ni chauffage : aucune récupération', () => {
102
+ const result = service.execute(ctx, { donnees_de_calcul: donneesDeCalcul });
103
+ expect(result.pertes_generateur_ch_recup).toBe(0);
104
+ });
105
+
106
+ test('installation ECS sans collection de générateurs : aucune récupération', () => {
107
+ const result = service.execute(ctx, {
108
+ donnees_de_calcul: donneesDeCalcul,
109
+ installation_ecs_collection: { installation_ecs: [{}] }
110
+ });
111
+ expect(result.pertes_generateur_ch_recup).toBe(0);
112
+ });
113
+ });
114
+
115
+ describe('Générateurs de chauffage', () => {
116
+ /** Générateur de chauffage en volume chauffé, Pn = 10 000 W, Qp0 = 100 W */
117
+ function generateurCh(de = {}) {
118
+ return {
119
+ donnee_entree: {
120
+ position_volume_chauffe: 1,
121
+ enum_type_generateur_ch_id: 85,
122
+ enum_usage_generateur_id: 1,
123
+ ...de
124
+ },
125
+ donnee_intermediaire: { pn: 10000, qp0: 100 }
126
+ };
127
+ }
128
+
129
+ test('usage chauffage : Dperj = min(Nref, 1,3 * Bch / (0,3 * Pn))', () => {
130
+ const result = service.execute(ctx, logement([], [generateurCh()]));
131
+ // Dperj = min(100, 1.3 * 10 * 1000 / 3000) = 4.333.. h
132
+ // 12 * 0.48 * 0.5 * 100 * 4.333.. / 1000 = 1.248 kWh
133
+ expect(result.pertes_generateur_ch_recup).toBeCloseTo(1.248, 9);
134
+ });
135
+
136
+ test('usage ECS : Dperj = Nref * 1790 / 8760', () => {
137
+ const result = service.execute(
138
+ ctx,
139
+ logement([], [generateurCh({ enum_usage_generateur_id: 2 })])
140
+ );
141
+ expect(result.pertes_generateur_ch_recup).toBeCloseTo(5.884931506849315, 9);
142
+ });
143
+
144
+ test('usage chauffage + ECS avec ventouse : cumul des deux durées, Cper = 0,75', () => {
145
+ const result = service.execute(
146
+ ctx,
147
+ logement([], [generateurCh({ enum_usage_generateur_id: 3, presence_ventouse: 1 })])
148
+ );
149
+ // Dperj = min(100, 4.333.. + 20.4337..) ; 12 * 0.48 * 0.75 * 100 * Dperj / 1000
150
+ expect(result.pertes_generateur_ch_recup).toBeCloseTo(10.699397260273972, 9);
151
+ });
152
+
153
+ test.each([
154
+ { cas: 'hors volume chauffé', de: { position_volume_chauffe: 0 } },
155
+ { cas: 'à air chaud (50)', de: { enum_type_generateur_ch_id: 50 } },
156
+ { cas: 'à air chaud (52)', de: { enum_type_generateur_ch_id: 52 } }
157
+ ])('pas de récupération pour un générateur de chauffage $cas', ({ de }) => {
158
+ const result = service.execute(ctx, logement([], [generateurCh(de)]));
159
+ expect(result.pertes_generateur_ch_recup).toBe(0);
160
+ });
161
+
162
+ test('installation de chauffage sans collection de générateurs : aucune récupération', () => {
163
+ const result = service.execute(ctx, {
164
+ donnees_de_calcul: donneesDeCalcul,
165
+ installation_chauffage_collection: { installation_chauffage: [{}] }
166
+ });
167
+ expect(result.pertes_generateur_ch_recup).toBe(0);
168
+ });
169
+
170
+ test('cumul générateur de chauffage + générateur ECS seul', () => {
171
+ const result = service.execute(ctx, logement([generateurEcs()], [generateurCh()]));
172
+ expect(result.pertes_generateur_ch_recup).toBeCloseTo(1.248 + 5.884931506849315, 9);
173
+ });
174
+
175
+ test('un générateur ECS mixte (usage 3) n’est pas compté deux fois', () => {
176
+ const ch = generateurCh({ enum_usage_generateur_id: 3 });
177
+ const seul = service.execute(ctx, logement([], [ch]));
178
+ const avecEcsMixte = service.execute(
179
+ ctx,
180
+ logement([generateurEcs({ enum_usage_generateur_id: 3 })], [ch])
181
+ );
182
+ expect(avecEcsMixte.pertes_generateur_ch_recup).toBe(seul.pertes_generateur_ch_recup);
183
+ });
184
+ });
185
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open3cl/engine",
3
- "version": "1.8.9",
3
+ "version": "1.8.10",
4
4
  "description": "Open Source 3CL-DPE engine",
5
5
  "main": "index.js",
6
6
  "directories": {