@open3cl/engine 1.8.48 → 1.8.49

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.
@@ -122,6 +122,28 @@ export function caracteristiquesGenerateurCombustion(row, pn, ratio, E, F, type,
122
122
  return caracteristiques;
123
123
  }
124
124
 
125
+ /**
126
+ * Estimation de la puissance nominale (W) d'un générateur à combustion dont la puissance n'est pas connue :
127
+ * Pch = Sh1 / Shtot × 1,2 × GV × (19 − Tbase) / 0,95³
128
+ *
129
+ * Si le générateur n'alimente qu'une partie du logement, la puissance est proratisée à la surface desservie.
130
+ * Le ratio est borné à ]0 ; 1] : une surface absente, nulle ou supérieure à Sh ne modifie pas Pch.
131
+ *
132
+ * Remarque : la méthode ne prévoit cette estimation que pour les générateurs individuels. Pour un générateur
133
+ * collectif sans pn, la même formule (GV du logement, convention pn = Pe) est une extrapolation.
134
+ *
135
+ * @see Methode_de_calcul_3CL_DPE_2021-338.pdf - §13.2.2.4 Calcul des puissances nominales (p.90-91)
136
+ *
137
+ * @param GV {number} déperditions enveloppe + renouvellement d'air (W/K)
138
+ * @param tbase {number} température extérieure de base (°C)
139
+ * @param ratioSurface {number} Sh_installation / Sh
140
+ * @returns {number}
141
+ */
142
+ export function pnEstimee(GV, tbase, ratioSurface = 1) {
143
+ const prorata = ratioSurface > 0 && ratioSurface < 1 ? ratioSurface : 1;
144
+ return (prorata * 1.2 * GV * (19 - tbase)) / 0.95 ** 3;
145
+ }
146
+
125
147
  /**
126
148
  * Si la méthode de saisie n'est pas "Valeur forfaitaire" mais "caractéristiques saisies"
127
149
  * Documentation 3CL : "Pour les installations récentes ou recommandées, les caractéristiques réelles des chaudières présentées sur les bases
@@ -139,8 +161,19 @@ export function caracteristiquesGenerateurCombustion(row, pn, ratio, E, F, type,
139
161
  * @param GV {number} déperdition de l'enveloppe
140
162
  * @param tbase {number} température de fonctionnement du générateur
141
163
  * @param methodeSaisie {number} méthode de saisie des caractéristiques du générateur
164
+ * @param ratioSurface {number} part de la surface du logement desservie par le générateur
165
+ * (Sh_installation / Sh), utilisée pour proratiser la puissance nominale estimée (§13.2.2.4)
142
166
  */
143
- export function tv_generateur_combustion(dpe, di, de, type, GV, tbase, methodeSaisie) {
167
+ export function tv_generateur_combustion(
168
+ dpe,
169
+ di,
170
+ de,
171
+ type,
172
+ GV,
173
+ tbase,
174
+ methodeSaisie,
175
+ ratioSurface = 1
176
+ ) {
144
177
  const typeGenerateurKey = `enum_type_generateur_${type}_id`;
145
178
  let enumTypeGenerateurId = de[typeGenerateurKey];
146
179
  let row;
@@ -207,7 +240,7 @@ export function tv_generateur_combustion(dpe, di, de, type, GV, tbase, methodeSa
207
240
  } else {
208
241
  // Calcul de la puissance nominale si non définie
209
242
  if (!di.pn) {
210
- di.pn = (1.2 * GV * (19 - tbase)) / 0.95 ** 3;
243
+ di.pn = pnEstimee(GV, tbase, ratioSurface);
211
244
  }
212
245
 
213
246
  selectionLigne = (pn, ratio) => {
@@ -68,7 +68,8 @@ const {
68
68
  valeurVeilleuse,
69
69
  caracteristiquesGenerateurCombustion,
70
70
  puissanceFormuleQp0,
71
- PN_MAX_QP0_CHAUDIERE_GAZ
71
+ PN_MAX_QP0_CHAUDIERE_GAZ,
72
+ pnEstimee
72
73
  } = await import('./13.2_generateur_combustion.js');
73
74
  const { tv, tvColumnLines } = await import('./utils.js');
74
75
  const { conventionPuissanceNominale } = await import('./13.2_generateur_combustion_pn.js');
@@ -127,6 +128,22 @@ describe('tv_generateur_combustion - caractéristiques du générateur', () => {
127
128
  expect(di.pn).toBe(12345);
128
129
  });
129
130
 
131
+ test('puissance nominale absente, générateur desservant 40 % du logement : Pn proratisée (issue #264)', () => {
132
+ tv.mockReturnValue({ ...ROW_DEFAUT });
133
+ const di = {};
134
+ const de = { enum_type_generateur_ch_id: '89', ratio_virtualisation: 1, presence_ventouse: 0 };
135
+ tv_generateur_combustion({}, di, de, 'ch', 200, -9, 1, 0.4);
136
+ expect(di.pn).toBeCloseTo(0.4 * 7837.877241580406, 9);
137
+ });
138
+
139
+ test('puissance nominale renseignée : le prorata de surface ne s’applique pas', () => {
140
+ tv.mockReturnValue({ ...ROW_DEFAUT });
141
+ const di = { pn: 12345 };
142
+ const de = { enum_type_generateur_ch_id: '89', ratio_virtualisation: 1, presence_ventouse: 0 };
143
+ tv_generateur_combustion({}, di, de, 'ch', 200, -9, 1, 0.4);
144
+ expect(di.pn).toBe(12345);
145
+ });
146
+
130
147
  test('méthode forfaitaire (1) en chauffage : rpn, rpint, qp0 et pveil renseignés', () => {
131
148
  tv.mockReturnValue({ ...ROW_DEFAUT });
132
149
  const di = { pn: 20000 };
@@ -1430,3 +1447,35 @@ describe('puissanceFormuleQp0 - plafond de Pn à 400 kW pour les chaudières gaz
1430
1447
  expect(qp0).toBeCloseTo(127.5468019078, 6);
1431
1448
  });
1432
1449
  });
1450
+
1451
+ /**
1452
+ * Estimation de la puissance nominale d'un générateur à combustion (pn absente).
1453
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §13.2.2.4
1454
+ */
1455
+ describe('pnEstimee - puissance nominale estimée (issue #264)', () => {
1456
+ // 1,2 × 200 × (19 − (−9)) / 0,95³ -- valeur de référence de régression
1457
+ const PCH_LOGEMENT = 7837.877241580406;
1458
+
1459
+ test('générateur desservant tout le logement : Pch = 1,2 × GV × (19 − Tbase) / 0,95³', () => {
1460
+ expect(pnEstimee(200, -9)).toBeCloseTo(PCH_LOGEMENT, 9);
1461
+ expect(pnEstimee(200, -9, 1)).toBeCloseTo(PCH_LOGEMENT, 9);
1462
+ });
1463
+
1464
+ test('générateur desservant une partie du logement : Pch1 = Sh1 / Shtot × Pch', () => {
1465
+ expect(pnEstimee(200, -9, 0.25)).toBeCloseTo(0.25 * PCH_LOGEMENT, 9);
1466
+ });
1467
+
1468
+ test('deux générateurs se partageant le logement : Pch1 + Pch2 = Pch', () => {
1469
+ expect(pnEstimee(200, -9, 0.3) + pnEstimee(200, -9, 0.7)).toBeCloseTo(PCH_LOGEMENT, 9);
1470
+ });
1471
+
1472
+ test.each([
1473
+ ['nul', 0],
1474
+ ['négatif', -0.5],
1475
+ ['supérieur à 1', 1.4],
1476
+ ['non numérique', NaN],
1477
+ ['absent', undefined]
1478
+ ])('ratio %s : pas de prorata (Pch du logement)', (_libelle, ratio) => {
1479
+ expect(pnEstimee(200, -9, ratio)).toBeCloseTo(PCH_LOGEMENT, 9);
1480
+ });
1481
+ });
package/9_chauffage.js CHANGED
@@ -80,7 +80,11 @@ export default function calc_chauffage(
80
80
 
81
81
  if (genChDu.isCombustionGenerator) {
82
82
  const methodeSaisie = parseInt(genChDe.enum_methode_saisie_carac_sys_id);
83
- tv_generateur_combustion(dpe, genChDi, genChDe, 'ch', GV, tbase, methodeSaisie);
83
+ // §13.2.2.4 : Pn estimée proratisée à la surface chauffée par l'installation (individuelle uniquement :
84
+ // un générateur collectif dessert tout le bâtiment, la surface chauffée du logement n'est pas la sienne)
85
+ const ratioSurface =
86
+ String(genChDe.enum_type_installation_id) === '1' ? genChDe.surface_chauffee / Sh : 1;
87
+ tv_generateur_combustion(dpe, genChDi, genChDe, 'ch', GV, tbase, methodeSaisie, ratioSurface);
84
88
 
85
89
  const type_gen_ch_list = tvColumnIDs('temp_fonc_30', 'type_generateur_ch');
86
90
  if (type_gen_ch_list.includes(genChDe.enum_type_generateur_ch_id)) {
@@ -336,6 +336,42 @@ describe('calc_chauffage - générateurs à combustion', () => {
336
336
  expect(tv_temp_fonc_30_100).toHaveBeenCalled();
337
337
  });
338
338
 
339
+ /**
340
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §13.2.2.4 (prorata de Pch à la surface desservie)
341
+ */
342
+ test('ratio surface chauffée de l’installation / Sh transmis pour la Pn estimée (issue #264)', () => {
343
+ const ch = installation(
344
+ [generateur({ enum_type_generateur_ch_id: '5', enum_methode_saisie_carac_sys_id: '1' })],
345
+ [emetteur('1')],
346
+ { surface_chauffee: 40, enum_type_installation_id: '1' }
347
+ );
348
+ appel(ch, { Sh: 100, GV: 150 });
349
+ const args = vi.mocked(tv_generateur_combustion).mock.calls[0];
350
+ expect(args[3]).toBe('ch');
351
+ expect(args[4]).toBe(150);
352
+ expect(args[7]).toBeCloseTo(0.4, 9);
353
+ });
354
+
355
+ test('surface chauffée absente : repli sur Sh, ratio de 1', () => {
356
+ const ch = installation(
357
+ [generateur({ enum_type_generateur_ch_id: '5', enum_methode_saisie_carac_sys_id: '1' })],
358
+ [emetteur('1')],
359
+ { surface_chauffee: undefined, enum_type_installation_id: '1' }
360
+ );
361
+ appel(ch, { Sh: 100 });
362
+ expect(vi.mocked(tv_generateur_combustion).mock.calls[0][7]).toBe(1);
363
+ });
364
+
365
+ test('installation collective : pas de prorata, le générateur dessert tout le bâtiment', () => {
366
+ const ch = installation(
367
+ [generateur({ enum_type_generateur_ch_id: '5', enum_methode_saisie_carac_sys_id: '1' })],
368
+ [emetteur('1')],
369
+ { surface_chauffee: 40, enum_type_installation_id: '2' }
370
+ );
371
+ appel(ch, { Sh: 100 });
372
+ expect(vi.mocked(tv_generateur_combustion).mock.calls[0][7]).toBe(1);
373
+ });
374
+
339
375
  test('méthode 5 avec températures déjà connues : pas de recalcul', () => {
340
376
  const ch = installation(
341
377
  [
package/README.md CHANGED
@@ -370,7 +370,7 @@ 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.47`** · branche `main` · généré le 2026-10-11
373
+ > **Version `1.8.48`** · branche `main` · généré le 2026-10-12
374
374
  > Seuil de tolérance **5%**
375
375
 
376
376
  <table>
@@ -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>5,29 ms</strong><br/><sub>moyenne</sub></td>
407
- <td align="center"><strong>4,62 ms</strong><br/><sub>médiane</sub></td>
406
+ <td align="center"><strong>5,92 ms</strong><br/><sub>moyenne</sub></td>
407
+ <td align="center"><strong>5,25 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>817,3 ms</strong><br/><sub>max</sub></td>
409
+ <td align="center"><strong>590,9 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** | 5,04 ms | 4,61 ms | 0,23 ms | 39,5 ms | 9,03 ms | 12,6 ms |
416
- | **Appartement · chauffage individuel (2025)** | 4,02 ms | 3,69 ms | 1,36 ms | 32,4 ms | 6,82 ms | 8,63 ms |
417
- | **Logement individuel (2025)** | 5,35 ms | 4,77 ms | 1,73 ms | 44,9 ms | 9,93 ms | 13,3 ms |
418
- | **Maison individuelle (2025)** | 5,82 ms | 5,20 ms | 1,91 ms | 74,9 ms | 10,4 ms | 13,9 ms |
419
- | **Immeuble · chauffage individuel** | 6,33 ms | 5,37 ms | 0,11 ms | 115,2 ms | 12,8 ms | 20,1 ms |
420
- | **Appartement · chauffage collectif (2025)** | 4,28 ms | 3,80 ms | 1,50 ms | 38,6 ms | 7,24 ms | 9,61 ms |
421
- | **Immeuble · chauffage collectif** | 5,52 ms | 4,68 ms | 0,13 ms | 106,4 ms | 10,2 ms | 17,1 ms |
422
- | **Immeuble · chauffage mixte** | 5,32 ms | 4,33 ms | 0,21 ms | 817,3 ms | 10,8 ms | 18,5 ms |
423
- | **Individuel généré depuis l'immeuble (2026)** | 5,91 ms | 5,10 ms | 1,83 ms | 65,4 ms | 11,3 ms | 17,1 ms |
415
+ | **Généraliste** | 4,99 ms | 4,39 ms | 0,21 ms | 95,5 ms | 9,05 ms | 14,6 ms |
416
+ | **Appartement · chauffage individuel (2025)** | 4,42 ms | 4,12 ms | 1,64 ms | 30,2 ms | 7,40 ms | 9,25 ms |
417
+ | **Logement individuel (2025)** | 5,71 ms | 5,18 ms | 1,70 ms | 46,8 ms | 10,4 ms | 14,1 ms |
418
+ | **Maison individuelle (2025)** | 6,69 ms | 6,10 ms | 1,94 ms | 58,0 ms | 11,7 ms | 15,7 ms |
419
+ | **Immeuble · chauffage individuel** | 7,25 ms | 6,20 ms | 0,11 ms | 141,9 ms | 14,6 ms | 23,6 ms |
420
+ | **Appartement · chauffage collectif (2025)** | 4,82 ms | 4,57 ms | 1,68 ms | 37,0 ms | 7,67 ms | 9,89 ms |
421
+ | **Immeuble · chauffage collectif** | 6,49 ms | 5,77 ms | 0,15 ms | 116,1 ms | 11,7 ms | 20,1 ms |
422
+ | **Immeuble · chauffage mixte** | 6,05 ms | 4,97 ms | 0,22 ms | 590,9 ms | 12,7 ms | 20,7 ms |
423
+ | **Individuel généré depuis l'immeuble (2026)** | 6,87 ms | 5,95 ms | 1,71 ms | 72,7 ms | 13,3 ms | 22,3 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, ratioProrataPvLogement } from './16.2_production_enr.js';
34
34
  import DpeSanitizerService from './dpe-sanitizer.service.js';
35
35
 
36
- const LIB_VERSION = '1.8.48';
36
+ const LIB_VERSION = '1.8.49';
37
37
 
38
38
  const dpeSanitizerService = new DpeSanitizerService();
39
39
 
@@ -73,7 +73,11 @@ export class GenerateurChService {
73
73
  if (generateurChauffage.donnee_utilisateur.combustion) {
74
74
  // Calcul de la puissance nominale si non définie
75
75
  if (!generateurChauffageDI.pn) {
76
- generateurChauffage.donnee_intermediaire.pn = this.pn(ctx, logement);
76
+ generateurChauffage.donnee_intermediaire.pn = this.pn(
77
+ ctx,
78
+ logement,
79
+ installationChauffage
80
+ );
77
81
  }
78
82
 
79
83
  generateurChauffage.donnee_utilisateur.generateur = this.#chTvStore.getGenerateurCombustion(
@@ -201,11 +205,22 @@ export class GenerateurChService {
201
205
  *
202
206
  * @param ctx {Contexte}
203
207
  * @param logement {Logement}
208
+ * @param installationChauffage {InstallationChauffage}
204
209
  * @return {number}
205
210
  */
206
- pn(ctx, logement) {
211
+ pn(ctx, logement, installationChauffage) {
207
212
  const Tbase = this.#tvStore.getTempBase(ctx.altitude.id, ctx.zoneClimatique.id);
208
- return (1.2 * logement.sortie.deperdition.deperdition_enveloppe * (19 - Tbase)) / 0.95 ** 3;
213
+ // §13.2.2.4 : générateur individuel n'alimentant qu'une partie du logement => prorata Sh_installation / Sh.
214
+ // Un générateur collectif dessert tout le bâtiment : pas de prorata.
215
+ const installationDE = installationChauffage?.donnee_entree;
216
+ const ratio =
217
+ String(installationDE?.enum_type_installation_id) === '1'
218
+ ? installationDE.surface_chauffee / ctx.surfaceHabitable
219
+ : 1;
220
+ const prorata = ratio > 0 && ratio < 1 ? ratio : 1;
221
+ return (
222
+ (prorata * 1.2 * logement.sortie.deperdition.deperdition_enveloppe * (19 - Tbase)) / 0.95 ** 3
223
+ );
209
224
  }
210
225
 
211
226
  /**
@@ -79,3 +79,52 @@ describe('Températures de fonctionnement des générateurs de chauffage', () =>
79
79
  );
80
80
  });
81
81
  });
82
+
83
+ /**
84
+ * Puissance nominale estimée d'un générateur à combustion sans pn.
85
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §13.2.2.4
86
+ */
87
+ describe('Puissance nominale estimée des générateurs de chauffage (issue #264)', () => {
88
+ /** @type {TvStore} */
89
+ let tvStore;
90
+ const ctx = { altitude: { id: 1 }, zoneClimatique: { id: 1 }, surfaceHabitable: 100 };
91
+ const logement = { sortie: { deperdition: { deperdition_enveloppe: 200 } } };
92
+ // 1,2 × 200 × (19 − (−9)) / 0,95³ -- valeur de référence de régression
93
+ const PCH_LOGEMENT = 7837.877241580406;
94
+
95
+ beforeEach(() => {
96
+ tvStore = new TvStore();
97
+ service = new GenerateurChService(new ChTvStore(), tvStore, new EmetteurChService());
98
+ vi.spyOn(tvStore, 'getTempBase').mockReturnValue(-9);
99
+ });
100
+
101
+ test('installation desservant tout le logement : Pch sans prorata', () => {
102
+ const installation = { donnee_entree: { surface_chauffee: 100, enum_type_installation_id: 1 } };
103
+ expect(service.pn(ctx, logement, installation)).toBeCloseTo(PCH_LOGEMENT, 9);
104
+ expect(tvStore.getTempBase).toHaveBeenCalledWith(1, 1);
105
+ });
106
+
107
+ test('installation desservant 40 % du logement : Pch proratisée à la surface chauffée', () => {
108
+ const installation = { donnee_entree: { surface_chauffee: 40, enum_type_installation_id: 1 } };
109
+ expect(service.pn(ctx, logement, installation)).toBeCloseTo(0.4 * PCH_LOGEMENT, 9);
110
+ });
111
+
112
+ test.each([
113
+ ['surface chauffée absente', { donnee_entree: { enum_type_installation_id: 1 } }],
114
+ [
115
+ 'installation collective',
116
+ { donnee_entree: { surface_chauffee: 40, enum_type_installation_id: 2 } }
117
+ ],
118
+ ['installation absente', undefined],
119
+ [
120
+ 'surface chauffée supérieure à Sh',
121
+ { donnee_entree: { surface_chauffee: 130, enum_type_installation_id: 1 } }
122
+ ],
123
+ [
124
+ 'surface chauffée nulle',
125
+ { donnee_entree: { surface_chauffee: 0, enum_type_installation_id: 1 } }
126
+ ]
127
+ ])('%s : pas de prorata', (_libelle, installation) => {
128
+ expect(service.pn(ctx, logement, installation)).toBeCloseTo(PCH_LOGEMENT, 9);
129
+ });
130
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open3cl/engine",
3
- "version": "1.8.48",
3
+ "version": "1.8.49",
4
4
  "description": "Open Source 3CL-DPE engine",
5
5
  "main": "index.js",
6
6
  "directories": {