@open3cl/engine 1.8.48 → 1.8.50

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.
@@ -9,6 +9,11 @@ import { updateGenerateurPacs } from './13.2_generateur_pac.js';
9
9
  import getFicheTechnique from './ficheTechnique.js';
10
10
  import { evaluateFormula as evaluateTvFormula } from './formula.js';
11
11
  import { conventionPuissanceNominale } from './13.2_generateur_combustion_pn.js';
12
+ import {
13
+ anneeInstallationGenerateur,
14
+ pnEstimeeDimensionnement,
15
+ valeurChaudiereMurale
16
+ } from './13.2_generateur_combustion_pdim.js';
12
17
 
13
18
  function criterePn(Pn, matcher) {
14
19
  let critere_list = tvColumnLines('generateur_combustion', 'critere_pn', matcher);
@@ -122,6 +127,117 @@ export function caracteristiquesGenerateurCombustion(row, pn, ratio, E, F, type,
122
127
  return caracteristiques;
123
128
  }
124
129
 
130
+ /**
131
+ * Estimation de la puissance nominale (W) d'un générateur à combustion dont la puissance n'est pas connue :
132
+ * Pch = Sh1 / Shtot × 1,2 × GV × (19 − Tbase) / 0,95³
133
+ *
134
+ * Si le générateur n'alimente qu'une partie du logement, la puissance est proratisée à la surface desservie.
135
+ * Le ratio est borné à ]0 ; 1] : une surface absente, nulle ou supérieure à Sh ne modifie pas Pch.
136
+ *
137
+ * Remarque : la méthode ne prévoit cette estimation que pour les générateurs individuels. Pour un générateur
138
+ * collectif sans pn, la même formule (GV du logement, convention pn = Pe) est une extrapolation.
139
+ *
140
+ * @see Methode_de_calcul_3CL_DPE_2021-338.pdf - §13.2.2.4 Calcul des puissances nominales (p.90-91)
141
+ *
142
+ * @param GV {number} déperditions enveloppe + renouvellement d'air (W/K)
143
+ * @param tbase {number} température extérieure de base (°C)
144
+ * @param ratioSurface {number} Sh_installation / Sh
145
+ * @returns {number}
146
+ */
147
+ export function pnEstimee(GV, tbase, ratioSurface = 1) {
148
+ const prorata = ratioSurface > 0 && ratioSurface < 1 ? ratioSurface : 1;
149
+ return (prorata * 1.2 * GV * (19 - tbase)) / 0.95 ** 3;
150
+ }
151
+
152
+ /**
153
+ * Pose murale d'une chaudière, lue dans la sous-fiche technique « Chaudière murale » (catégorie 7
154
+ * pour le chauffage, 8 pour l'ECS) du générateur.
155
+ *
156
+ * @param dpe {FullDpe}
157
+ * @param de {Donnee_entree}
158
+ * @param type {'ch' | 'ecs'}
159
+ * @returns {boolean|undefined} undefined si non renseigné
160
+ */
161
+ export function chaudiereMurale(dpe, de, type) {
162
+ const fiche = getFicheTechnique(dpe, type === 'ch' ? '7' : '8', 'Chaudière murale', [
163
+ de.description
164
+ ]);
165
+ return valeurChaudiereMurale(fiche?.valeur);
166
+ }
167
+
168
+ /**
169
+ * Volume de stockage (L) de la production d'ECS assurée par un générateur de chauffage mixte :
170
+ * générateur ECS mixte de même `reference_generateur_mixte`, sinon générateur ECS mixte unique.
171
+ *
172
+ * @param dpe {FullDpe}
173
+ * @param de {Donnee_entree} données du générateur de chauffage
174
+ * @returns {number|undefined}
175
+ */
176
+ export function volumeStockageEcsMixte(dpe, de) {
177
+ const liste = (x) => (x === undefined || x === null ? [] : Array.isArray(x) ? x : [x]);
178
+ const generateursEcsMixtes = liste(dpe?.logement?.installation_ecs_collection?.installation_ecs)
179
+ .flatMap((installation) => liste(installation.generateur_ecs_collection?.generateur_ecs))
180
+ .filter((generateur) => String(generateur?.donnee_entree?.enum_usage_generateur_id) === '3');
181
+
182
+ const reference = de.reference_generateur_mixte;
183
+ const jumeau =
184
+ (reference !== undefined &&
185
+ reference !== null &&
186
+ generateursEcsMixtes.find(
187
+ (generateur) =>
188
+ String(generateur.donnee_entree.reference_generateur_mixte) === String(reference)
189
+ )) ||
190
+ (generateursEcsMixtes.length === 1 ? generateursEcsMixtes[0] : undefined);
191
+
192
+ return jumeau?.donnee_entree.volume_stockage;
193
+ }
194
+
195
+ /**
196
+ * Puissance nominale estimée (W) d'un générateur à combustion dont `pn` est absente.
197
+ *
198
+ * Méthode 3CL-DPE 2021 §13.2.2.4 : Pdim = max(Pch ; Pecs), puis table Pdim → Pn pour les chaudières
199
+ * (cf. 13.2_generateur_combustion_pdim.js). La méthode ne vise que les générateurs individuels :
200
+ * pour une installation collective, Pch est conservée (comportement historique).
201
+ *
202
+ * @param dpe {FullDpe}
203
+ * @param de {Donnee_entree} données du générateur
204
+ * @param type {'ch' | 'ecs'}
205
+ * @param pch {number} Pch (W) proratisée à la surface desservie
206
+ * @param optionsPn {{installationIndividuelle?: boolean, volumeStockage?: number}}
207
+ * @returns {number} Pn (W)
208
+ */
209
+ export function pnEstimeeGenerateur(dpe, de, type, pch, optionsPn = {}) {
210
+ const installationIndividuelle =
211
+ optionsPn.installationIndividuelle ?? String(de.enum_type_installation_id) === '1';
212
+ if (!installationIndividuelle) {
213
+ return pch;
214
+ }
215
+
216
+ const usage = String(de.enum_usage_generateur_id);
217
+ const usageEcs = type === 'ecs' || usage === '3';
218
+ const usageChauffage = type === 'ch' || usage === '3';
219
+ const volumeStockage =
220
+ optionsPn.volumeStockage ??
221
+ (type === 'ecs' ? de.volume_stockage : usageEcs ? volumeStockageEcsMixte(dpe, de) : undefined);
222
+
223
+ const libelleTypeGenerateur =
224
+ enums[`type_generateur_${type}`]?.[de[`enum_type_generateur_${type}_id`]];
225
+
226
+ return pnEstimeeDimensionnement({
227
+ pch,
228
+ volumeStockage,
229
+ usageEcs,
230
+ usageChauffage,
231
+ libelleTypeGenerateur,
232
+ murale: chaudiereMurale(dpe, de, type),
233
+ anneeInstallation: anneeInstallationGenerateur(
234
+ de.annee_installation,
235
+ dpe?.logement?.caracteristique_generale?.annee_construction,
236
+ libelleTypeGenerateur
237
+ )
238
+ });
239
+ }
240
+
125
241
  /**
126
242
  * Si la méthode de saisie n'est pas "Valeur forfaitaire" mais "caractéristiques saisies"
127
243
  * 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 +255,22 @@ export function caracteristiquesGenerateurCombustion(row, pn, ratio, E, F, type,
139
255
  * @param GV {number} déperdition de l'enveloppe
140
256
  * @param tbase {number} température de fonctionnement du générateur
141
257
  * @param methodeSaisie {number} méthode de saisie des caractéristiques du générateur
258
+ * @param ratioSurface {number} part de la surface du logement desservie par le générateur
259
+ * (Sh_installation / Sh), utilisée pour proratiser la puissance nominale estimée (§13.2.2.4)
260
+ * @param optionsPn {{installationIndividuelle?: boolean, volumeStockage?: number}} données de
261
+ * l'installation pour l'estimation de Pn par Pdim (§13.2.2.4, cf. pnEstimeeGenerateur)
142
262
  */
143
- export function tv_generateur_combustion(dpe, di, de, type, GV, tbase, methodeSaisie) {
263
+ export function tv_generateur_combustion(
264
+ dpe,
265
+ di,
266
+ de,
267
+ type,
268
+ GV,
269
+ tbase,
270
+ methodeSaisie,
271
+ ratioSurface = 1,
272
+ optionsPn = {}
273
+ ) {
144
274
  const typeGenerateurKey = `enum_type_generateur_${type}_id`;
145
275
  let enumTypeGenerateurId = de[typeGenerateurKey];
146
276
  let row;
@@ -207,7 +337,7 @@ export function tv_generateur_combustion(dpe, di, de, type, GV, tbase, methodeSa
207
337
  } else {
208
338
  // Calcul de la puissance nominale si non définie
209
339
  if (!di.pn) {
210
- di.pn = (1.2 * GV * (19 - tbase)) / 0.95 ** 3;
340
+ di.pn = pnEstimeeGenerateur(dpe, de, type, pnEstimee(GV, tbase, ratioSurface), optionsPn);
211
341
  }
212
342
 
213
343
  selectionLigne = (pn, ratio) => {
@@ -59,6 +59,14 @@ vi.mock('./ficheTechnique.js', () => ({
59
59
  vi.mock('./13.2_generateur_combustion_pn.js', () => ({
60
60
  conventionPuissanceNominale: vi.fn((pn, ratio) => ({ pn, ratio, convention: 'individualisee' }))
61
61
  }));
62
+ /**
63
+ * Estimation Pdim → Pn (issue #271) : testée dans son propre spec. Par défaut, le double renvoie Pch.
64
+ */
65
+ vi.mock('./13.2_generateur_combustion_pdim.js', () => ({
66
+ pnEstimeeDimensionnement: vi.fn(({ pch }) => pch),
67
+ anneeInstallationGenerateur: vi.fn(() => 2018),
68
+ valeurChaudiereMurale: vi.fn((v) => (v === 'oui' ? true : v === 'non' ? false : undefined))
69
+ }));
62
70
 
63
71
  const {
64
72
  tv_generateur_combustion,
@@ -68,8 +76,14 @@ const {
68
76
  valeurVeilleuse,
69
77
  caracteristiquesGenerateurCombustion,
70
78
  puissanceFormuleQp0,
71
- PN_MAX_QP0_CHAUDIERE_GAZ
79
+ PN_MAX_QP0_CHAUDIERE_GAZ,
80
+ pnEstimee,
81
+ pnEstimeeGenerateur,
82
+ chaudiereMurale,
83
+ volumeStockageEcsMixte
72
84
  } = await import('./13.2_generateur_combustion.js');
85
+ const { pnEstimeeDimensionnement, anneeInstallationGenerateur } =
86
+ await import('./13.2_generateur_combustion_pdim.js');
73
87
  const { tv, tvColumnLines } = await import('./utils.js');
74
88
  const { conventionPuissanceNominale } = await import('./13.2_generateur_combustion_pn.js');
75
89
  const { updateGenerateurBouilleurs } = await import('./13.2_generateur_combustion_bouilleur.js');
@@ -127,6 +141,22 @@ describe('tv_generateur_combustion - caractéristiques du générateur', () => {
127
141
  expect(di.pn).toBe(12345);
128
142
  });
129
143
 
144
+ test('puissance nominale absente, générateur desservant 40 % du logement : Pn proratisée (issue #264)', () => {
145
+ tv.mockReturnValue({ ...ROW_DEFAUT });
146
+ const di = {};
147
+ const de = { enum_type_generateur_ch_id: '89', ratio_virtualisation: 1, presence_ventouse: 0 };
148
+ tv_generateur_combustion({}, di, de, 'ch', 200, -9, 1, 0.4);
149
+ expect(di.pn).toBeCloseTo(0.4 * 7837.877241580406, 9);
150
+ });
151
+
152
+ test('puissance nominale renseignée : le prorata de surface ne s’applique pas', () => {
153
+ tv.mockReturnValue({ ...ROW_DEFAUT });
154
+ const di = { pn: 12345 };
155
+ const de = { enum_type_generateur_ch_id: '89', ratio_virtualisation: 1, presence_ventouse: 0 };
156
+ tv_generateur_combustion({}, di, de, 'ch', 200, -9, 1, 0.4);
157
+ expect(di.pn).toBe(12345);
158
+ });
159
+
130
160
  test('méthode forfaitaire (1) en chauffage : rpn, rpint, qp0 et pveil renseignés', () => {
131
161
  tv.mockReturnValue({ ...ROW_DEFAUT });
132
162
  const di = { pn: 20000 };
@@ -1430,3 +1460,217 @@ describe('puissanceFormuleQp0 - plafond de Pn à 400 kW pour les chaudières gaz
1430
1460
  expect(qp0).toBeCloseTo(127.5468019078, 6);
1431
1461
  });
1432
1462
  });
1463
+
1464
+ /**
1465
+ * Estimation de la puissance nominale d'un générateur à combustion (pn absente).
1466
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §13.2.2.4
1467
+ */
1468
+ describe('pnEstimee - puissance nominale estimée (issue #264)', () => {
1469
+ // 1,2 × 200 × (19 − (−9)) / 0,95³ -- valeur de référence de régression
1470
+ const PCH_LOGEMENT = 7837.877241580406;
1471
+
1472
+ test('générateur desservant tout le logement : Pch = 1,2 × GV × (19 − Tbase) / 0,95³', () => {
1473
+ expect(pnEstimee(200, -9)).toBeCloseTo(PCH_LOGEMENT, 9);
1474
+ expect(pnEstimee(200, -9, 1)).toBeCloseTo(PCH_LOGEMENT, 9);
1475
+ });
1476
+
1477
+ test('générateur desservant une partie du logement : Pch1 = Sh1 / Shtot × Pch', () => {
1478
+ expect(pnEstimee(200, -9, 0.25)).toBeCloseTo(0.25 * PCH_LOGEMENT, 9);
1479
+ });
1480
+
1481
+ test('deux générateurs se partageant le logement : Pch1 + Pch2 = Pch', () => {
1482
+ expect(pnEstimee(200, -9, 0.3) + pnEstimee(200, -9, 0.7)).toBeCloseTo(PCH_LOGEMENT, 9);
1483
+ });
1484
+
1485
+ test.each([
1486
+ ['nul', 0],
1487
+ ['négatif', -0.5],
1488
+ ['supérieur à 1', 1.4],
1489
+ ['non numérique', NaN],
1490
+ ['absent', undefined]
1491
+ ])('ratio %s : pas de prorata (Pch du logement)', (_libelle, ratio) => {
1492
+ expect(pnEstimee(200, -9, ratio)).toBeCloseTo(PCH_LOGEMENT, 9);
1493
+ });
1494
+ });
1495
+
1496
+ /**
1497
+ * Pn estimée par la puissance de dimensionnement (issue #271).
1498
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §13.2.2.4
1499
+ */
1500
+ describe('pnEstimeeGenerateur - Pdim = max(Pch ; Pecs) et table Pdim → Pn (issue #271)', () => {
1501
+ const dpeAvecEcsMixte = (generateursEcs) => ({
1502
+ logement: {
1503
+ caracteristique_generale: { annee_construction: '1975' },
1504
+ installation_ecs_collection: {
1505
+ installation_ecs: [{ generateur_ecs_collection: { generateur_ecs: generateursEcs } }]
1506
+ }
1507
+ }
1508
+ });
1509
+
1510
+ beforeEach(() => {
1511
+ vi.mocked(pnEstimeeDimensionnement).mockClear();
1512
+ vi.mocked(anneeInstallationGenerateur).mockClear();
1513
+ vi.mocked(getFicheTechnique).mockReset();
1514
+ });
1515
+
1516
+ test('installation collective : Pch conservée, pas de table', () => {
1517
+ const de = { enum_type_installation_id: '2', enum_type_generateur_ch_id: '89' };
1518
+ expect(pnEstimeeGenerateur({}, de, 'ch', 7800)).toBe(7800);
1519
+ expect(pnEstimeeDimensionnement).not.toHaveBeenCalled();
1520
+ });
1521
+
1522
+ test('option installationIndividuelle prioritaire sur la donnée du générateur', () => {
1523
+ const de = { enum_type_installation_id: '1', enum_type_generateur_ecs_id: 'A' };
1524
+ expect(pnEstimeeGenerateur({}, de, 'ecs', 7800, { installationIndividuelle: false })).toBe(
1525
+ 7800
1526
+ );
1527
+ expect(pnEstimeeDimensionnement).not.toHaveBeenCalled();
1528
+ });
1529
+
1530
+ test('chaudière de chauffage seul : paramètres transmis (libellé, pose murale, année)', () => {
1531
+ vi.mocked(getFicheTechnique).mockReturnValue({ valeur: 'oui' });
1532
+ const dpe = { logement: { caracteristique_generale: { annee_construction: '1975' } } };
1533
+ const de = {
1534
+ description: 'Chaudière gaz',
1535
+ enum_type_installation_id: '1',
1536
+ enum_usage_generateur_id: '1',
1537
+ enum_type_generateur_ch_id: '89',
1538
+ annee_installation: '2010'
1539
+ };
1540
+ pnEstimeeGenerateur(dpe, de, 'ch', 7800);
1541
+
1542
+ expect(getFicheTechnique).toHaveBeenCalledWith(dpe, '7', 'Chaudière murale', ['Chaudière gaz']);
1543
+ expect(anneeInstallationGenerateur).toHaveBeenCalledWith(
1544
+ '2010',
1545
+ '1975',
1546
+ 'chaudière gaz standard 2001-2015'
1547
+ );
1548
+ expect(pnEstimeeDimensionnement).toHaveBeenCalledWith({
1549
+ pch: 7800,
1550
+ volumeStockage: undefined,
1551
+ usageEcs: false,
1552
+ usageChauffage: true,
1553
+ libelleTypeGenerateur: 'chaudière gaz standard 2001-2015',
1554
+ murale: true,
1555
+ anneeInstallation: 2018
1556
+ });
1557
+ });
1558
+
1559
+ test('chaudière mixte : volume de stockage du générateur ECS jumeau (reference_generateur_mixte)', () => {
1560
+ const dpe = dpeAvecEcsMixte([
1561
+ {
1562
+ donnee_entree: {
1563
+ enum_usage_generateur_id: '3',
1564
+ reference_generateur_mixte: 'A',
1565
+ volume_stockage: 0
1566
+ }
1567
+ },
1568
+ {
1569
+ donnee_entree: {
1570
+ enum_usage_generateur_id: '3',
1571
+ reference_generateur_mixte: 'B',
1572
+ volume_stockage: 150
1573
+ }
1574
+ }
1575
+ ]);
1576
+ const de = {
1577
+ enum_type_installation_id: '1',
1578
+ enum_usage_generateur_id: '3',
1579
+ enum_type_generateur_ch_id: '89',
1580
+ reference_generateur_mixte: 'B'
1581
+ };
1582
+ pnEstimeeGenerateur(dpe, de, 'ch', 7800);
1583
+ expect(pnEstimeeDimensionnement).toHaveBeenCalledWith(
1584
+ expect.objectContaining({ volumeStockage: 150, usageEcs: true, usageChauffage: true })
1585
+ );
1586
+ });
1587
+
1588
+ test('générateur ECS : volume de stockage du générateur, usage ECS seul', () => {
1589
+ vi.mocked(getFicheTechnique).mockReturnValue({ valeur: 'non' });
1590
+ const de = {
1591
+ description: 'Chaudière gaz',
1592
+ enum_usage_generateur_id: '2',
1593
+ enum_type_generateur_ecs_id: 'A',
1594
+ volume_stockage: 10
1595
+ };
1596
+ pnEstimeeGenerateur({}, de, 'ecs', 7800, { installationIndividuelle: true });
1597
+ expect(getFicheTechnique).toHaveBeenCalledWith({}, '8', 'Chaudière murale', ['Chaudière gaz']);
1598
+ expect(pnEstimeeDimensionnement).toHaveBeenCalledWith(
1599
+ expect.objectContaining({
1600
+ volumeStockage: 10,
1601
+ usageEcs: true,
1602
+ usageChauffage: false,
1603
+ libelleTypeGenerateur: 'chaudière gaz',
1604
+ murale: false
1605
+ })
1606
+ );
1607
+ });
1608
+
1609
+ test('volume de stockage transmis en option prioritaire', () => {
1610
+ const de = { enum_type_installation_id: '1', enum_usage_generateur_id: '2' };
1611
+ pnEstimeeGenerateur({}, de, 'ecs', 7800, { volumeStockage: 300 });
1612
+ expect(pnEstimeeDimensionnement).toHaveBeenCalledWith(
1613
+ expect.objectContaining({ volumeStockage: 300 })
1614
+ );
1615
+ });
1616
+
1617
+ test('tv_generateur_combustion : pn absente estimée via pnEstimeeGenerateur', () => {
1618
+ vi.mocked(pnEstimeeDimensionnement).mockReturnValueOnce(24000);
1619
+ vi.mocked(tv).mockReturnValue({ tv_generateur_combustion_id: '1', rpn: '90', rpint: '90' });
1620
+ const di = {};
1621
+ const de = {
1622
+ enum_type_generateur_ch_id: '89',
1623
+ enum_type_installation_id: '1',
1624
+ enum_usage_generateur_id: '1'
1625
+ };
1626
+ tv_generateur_combustion({}, di, de, 'ch', 200, -9, 1);
1627
+ expect(di.pn).toBe(24000);
1628
+ });
1629
+ });
1630
+
1631
+ describe('volumeStockageEcsMixte - volume de stockage de l’ECS d’un générateur mixte', () => {
1632
+ const ecs = (usage, reference, volume) => ({
1633
+ donnee_entree: {
1634
+ enum_usage_generateur_id: usage,
1635
+ reference_generateur_mixte: reference,
1636
+ volume_stockage: volume
1637
+ }
1638
+ });
1639
+ const dpe = (generateurs) => ({
1640
+ logement: {
1641
+ installation_ecs_collection: {
1642
+ installation_ecs: { generateur_ecs_collection: { generateur_ecs: generateurs } }
1643
+ }
1644
+ }
1645
+ });
1646
+
1647
+ test('jumeau par reference_generateur_mixte', () => {
1648
+ const d = dpe([ecs('3', 'R1', 80), ecs('3', 'R2', 200)]);
1649
+ expect(volumeStockageEcsMixte(d, { reference_generateur_mixte: 'R2' })).toBe(200);
1650
+ });
1651
+
1652
+ test('sans référence : générateur ECS mixte unique', () => {
1653
+ const d = dpe([ecs('2', undefined, 300), ecs('3', undefined, 0)]);
1654
+ expect(volumeStockageEcsMixte(d, {})).toBe(0);
1655
+ });
1656
+
1657
+ test('plusieurs générateurs mixtes sans référence commune : volume inconnu', () => {
1658
+ const d = dpe([ecs('3', 'R1', 80), ecs('3', 'R2', 200)]);
1659
+ expect(volumeStockageEcsMixte(d, { reference_generateur_mixte: 'R3' })).toBeUndefined();
1660
+ expect(volumeStockageEcsMixte(d, {})).toBeUndefined();
1661
+ });
1662
+
1663
+ test('aucune installation ECS', () => {
1664
+ expect(volumeStockageEcsMixte({}, {})).toBeUndefined();
1665
+ expect(
1666
+ volumeStockageEcsMixte(dpe(ecs('3', 'R', 50)), { reference_generateur_mixte: 'R' })
1667
+ ).toBe(50);
1668
+ });
1669
+ });
1670
+
1671
+ describe('chaudiereMurale - sous-fiche « Chaudière murale »', () => {
1672
+ test('fiche absente : pose inconnue', () => {
1673
+ vi.mocked(getFicheTechnique).mockReturnValue(null);
1674
+ expect(chaudiereMurale({}, { description: 'x' }, 'ch')).toBeUndefined();
1675
+ });
1676
+ });
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Estimation de la puissance nominale d'un générateur à combustion individuel lorsque `pn` n'est
3
+ * pas connue (repli du moteur quand `donnee_intermediaire.pn` est absente).
4
+ *
5
+ * Méthode 3CL-DPE 2021, §13.2.2.4 « Calcul des puissances nominales » (p.90-91) :
6
+ * 1. Pch = Sh1/Shtot × 1,2 × GV × (19 − Tbase) / (1000 × 0,95³) (cf. `pnEstimee`)
7
+ * 2. Pecs selon le type de production d'ECS (volume de stockage Vs) ;
8
+ * 3. Pdim = max(Pch ; Pecs) ;
9
+ * 4. Pn des chaudières lue dans la table Pdim → Pn (chaudières murales installées avant 2006 ou
10
+ * chaudières au sol / chaudières murales installées à partir de 2006).
11
+ *
12
+ * Les autres générateurs à combustion (radiateurs gaz, générateurs d'air chaud, poêles,
13
+ * accumulateurs, chauffe-eau…) conservent Pn = Pdim.
14
+ *
15
+ * Le moteur de référence (Tribu) ne fait pas cette estimation (Pn est une donnée d'entrée
16
+ * obligatoire) : elle relève du logiciel de saisie et sert surtout aux saisies incomplètes et aux
17
+ * recommandations.
18
+ *
19
+ * @see Methode_de_calcul_3CL_DPE_2021-338.pdf - §13.2.2.4 Calcul des puissances nominales (p.90-91)
20
+ */
21
+
22
+ /**
23
+ * Année à partir de laquelle les chaudières murales relèvent de la seconde colonne de la table
24
+ * Pdim → Pn.
25
+ */
26
+ export const ANNEE_CHAUDIERE_MURALE_RECENTE = 2006;
27
+
28
+ /**
29
+ * Table Pdim → Pn (kW) des chaudières : [Pdim max (kW), Pn murale < 2006 ou sol, Pn murale ≥ 2006].
30
+ * Au-delà de 40 kW : Pn = (Partie entière(Pdim / 5) + 1) × 5 pour les deux colonnes.
31
+ */
32
+ const TABLE_PDIM_PN = [
33
+ [5, 18, 5],
34
+ [10, 18, 10],
35
+ [13, 18, 13],
36
+ [18, 18, 18],
37
+ [24, 24, 24],
38
+ [28, 28, 28],
39
+ [32, 32, 32],
40
+ [40, 40, 40]
41
+ ];
42
+
43
+ /**
44
+ * Puissance de dimensionnement nécessaire pour l'ECS (kW) selon le volume de stockage (L).
45
+ *
46
+ * | Type de production | Volume de stockage | Pecs (kW) |
47
+ * |--------------------|--------------------|----------------------------|
48
+ * | Instantanée | Vs = 0 | 21 |
49
+ * | Semi-instantanée | 0 < Vs ≤ 20 | 21 − 0,8 × Vs |
50
+ * | Semi-accumulation | 20 < Vs ≤ 150 | 5 − 1,751 × (Vs − 20) / 65 |
51
+ * | Accumulation | 150 < Vs | (7,14 × Vs + 428) / 1000 |
52
+ *
53
+ * Formules reprises telles que publiées (p.91).
54
+ *
55
+ * @param volumeStockage {number|string|undefined} Vs (L)
56
+ * @returns {number|undefined} Pecs (kW), undefined si le volume n'est pas renseigné
57
+ */
58
+ export function puissanceEcs(volumeStockage) {
59
+ if (volumeStockage === undefined || volumeStockage === null || volumeStockage === '') {
60
+ return undefined;
61
+ }
62
+ const vs = Number(volumeStockage);
63
+ if (!Number.isFinite(vs) || vs < 0) return undefined;
64
+
65
+ if (vs === 0) return 21;
66
+ if (vs <= 20) return 21 - 0.8 * vs;
67
+ if (vs <= 150) return 5 - (1.751 * (vs - 20)) / 65;
68
+ return (7.14 * vs + 428) / 1000;
69
+ }
70
+
71
+ /**
72
+ * Pn (kW) d'une chaudière à partir de sa puissance de dimensionnement (table §13.2.2.4).
73
+ *
74
+ * @param pdim {number} Pdim (kW)
75
+ * @param muraleRecente {boolean} true pour une chaudière murale installée à partir de 2006,
76
+ * false pour une chaudière murale installée avant 2006 ou une chaudière au sol
77
+ * @returns {number} Pn (kW)
78
+ */
79
+ export function pnChaudiere(pdim, muraleRecente) {
80
+ for (const [pdimMax, pnAncienneOuSol, pnMuraleRecente] of TABLE_PDIM_PN) {
81
+ if (pdim <= pdimMax) {
82
+ return muraleRecente ? pnMuraleRecente : pnAncienneOuSol;
83
+ }
84
+ }
85
+ return (Math.floor(pdim / 5) + 1) * 5;
86
+ }
87
+
88
+ /**
89
+ * Le générateur est-il une chaudière (table Pdim → Pn) ?
90
+ * Les « chaudière(s) … multi bâtiment modélisée comme un réseau de chaleur » ne sont pas
91
+ * concernées (générateurs collectifs).
92
+ *
93
+ * @param libelleTypeGenerateur {string|undefined} libellé enum_type_generateur_ch / _ecs
94
+ * @returns {boolean}
95
+ */
96
+ export function isChaudiere(libelleTypeGenerateur) {
97
+ const libelle = (libelleTypeGenerateur ?? '').toString().toLowerCase();
98
+ return libelle.includes('chaudière') && !libelle.includes('réseau de chaleur');
99
+ }
100
+
101
+ /**
102
+ * Intervalle [min ; max] des années d'installation possibles d'après le libellé du type de
103
+ * générateur (« avant 1981 », « 2001-2015 », « après 2015 », « à partir de 2012 »…).
104
+ *
105
+ * @param libelleTypeGenerateur {string|undefined}
106
+ * @returns {{min: number, max: number}}
107
+ */
108
+ export function periodeInstallationLibelle(libelleTypeGenerateur) {
109
+ const libelle = (libelleTypeGenerateur ?? '').toString().toLowerCase();
110
+ let match = libelle.match(/(\d{4})\s*-\s*(\d{4})/);
111
+ if (match) return { min: Number(match[1]), max: Number(match[2]) };
112
+
113
+ match = libelle.match(/avant (\d{4})/);
114
+ if (match) return { min: -Infinity, max: Number(match[1]) - 1 };
115
+
116
+ match = libelle.match(/(?:après|à partir de) (\d{4})/);
117
+ if (match) {
118
+ // « après 2015 » succède à « 2001-2015 » : 2015 appartient déjà à la période précédente
119
+ return { min: Number(match[1]) + (libelle.includes('après') ? 1 : 0), max: Infinity };
120
+ }
121
+
122
+ return { min: -Infinity, max: Infinity };
123
+ }
124
+
125
+ /**
126
+ * Année d'installation du générateur : année saisie, sinon année de construction ramenée dans la
127
+ * période indiquée par le type de générateur (une « chaudière gaz à condensation après 2015 » d'un
128
+ * bâtiment de 1970 a été installée après 2015).
129
+ *
130
+ * @param anneeInstallation {number|string|undefined} année d'installation saisie
131
+ * @param anneeConstruction {number|string|undefined} année de construction du bâtiment
132
+ * @param libelleTypeGenerateur {string|undefined}
133
+ * @returns {number|undefined}
134
+ */
135
+ export function anneeInstallationGenerateur(
136
+ anneeInstallation,
137
+ anneeConstruction,
138
+ libelleTypeGenerateur
139
+ ) {
140
+ const saisie = parseInt(anneeInstallation, 10);
141
+ if (Number.isFinite(saisie)) return saisie;
142
+
143
+ const { min, max } = periodeInstallationLibelle(libelleTypeGenerateur);
144
+ const construction = parseInt(anneeConstruction, 10);
145
+ if (Number.isFinite(construction)) return Math.min(Math.max(construction, min), max);
146
+
147
+ // Période entièrement connue d'un seul côté de 2006 : la borne suffit
148
+ if (Number.isFinite(min) && min >= ANNEE_CHAUDIERE_MURALE_RECENTE) return min;
149
+ if (Number.isFinite(max) && max < ANNEE_CHAUDIERE_MURALE_RECENTE) return max;
150
+ return undefined;
151
+ }
152
+
153
+ /**
154
+ * Valeur de la sous-fiche technique « Chaudière murale » → true (oui), false (non), undefined.
155
+ * @param valeur {string|undefined}
156
+ * @returns {boolean|undefined}
157
+ */
158
+ export function valeurChaudiereMurale(valeur) {
159
+ const v = (valeur ?? '').toString().trim().toLowerCase();
160
+ if (v.startsWith('oui')) return true;
161
+ if (v.startsWith('non')) return false;
162
+ return undefined;
163
+ }
164
+
165
+ /**
166
+ * Puissance nominale estimée (W) d'un générateur à combustion sans `pn` (§13.2.2.4).
167
+ *
168
+ * Choix de colonne de la table Pdim → Pn :
169
+ * - chaudière murale (fiche technique « Chaudière murale : oui ») installée à partir de 2006 →
170
+ * seconde colonne ;
171
+ * - chaudière au sol (« Chaudière murale : non ») ou installée avant 2006 → première colonne ;
172
+ * - type de pose inconnu → seconde colonne si installée à partir de 2006 (chaudières récentes
173
+ * majoritairement murales), première sinon.
174
+ *
175
+ * @param params {{
176
+ * pch: number, Pch (W), déjà proratisée à la surface desservie
177
+ * volumeStockage?: number|string, Vs (L) de la production d'ECS assurée par le générateur
178
+ * usageEcs?: boolean, le générateur produit de l'ECS (usage ECS ou mixte)
179
+ * usageChauffage?: boolean, le générateur assure le chauffage (défaut true)
180
+ * libelleTypeGenerateur?: string,
181
+ * murale?: boolean, pose murale (undefined si inconnue)
182
+ * anneeInstallation?: number année d'installation (undefined si inconnue)
183
+ * }}
184
+ * @returns {number} Pn (W)
185
+ */
186
+ export function pnEstimeeDimensionnement({
187
+ pch,
188
+ volumeStockage,
189
+ usageEcs = false,
190
+ usageChauffage = true,
191
+ libelleTypeGenerateur,
192
+ murale,
193
+ anneeInstallation
194
+ }) {
195
+ const pchKw = usageChauffage ? pch / 1000 : 0;
196
+ const pecsKw = usageEcs ? puissanceEcs(volumeStockage) : undefined;
197
+ const pdim = Math.max(pchKw, pecsKw ?? 0);
198
+
199
+ // Aucune donnée exploitable pour l'ECS d'un générateur ECS seul : comportement historique (Pch)
200
+ if (!usageChauffage && pecsKw === undefined) {
201
+ return pch;
202
+ }
203
+
204
+ if (!isChaudiere(libelleTypeGenerateur)) {
205
+ return pdim * 1000;
206
+ }
207
+
208
+ const recente =
209
+ anneeInstallation !== undefined && anneeInstallation >= ANNEE_CHAUDIERE_MURALE_RECENTE;
210
+ const muraleRecente = recente && murale !== false;
211
+ return pnChaudiere(pdim, muraleRecente) * 1000;
212
+ }
@@ -0,0 +1,259 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import {
3
+ ANNEE_CHAUDIERE_MURALE_RECENTE,
4
+ anneeInstallationGenerateur,
5
+ isChaudiere,
6
+ periodeInstallationLibelle,
7
+ pnChaudiere,
8
+ pnEstimeeDimensionnement,
9
+ puissanceEcs,
10
+ valeurChaudiereMurale
11
+ } from './13.2_generateur_combustion_pdim.js';
12
+
13
+ /**
14
+ * Module de calcul pur (aucune dépendance) : pas de mock nécessaire.
15
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §13.2.2.4 Calcul des puissances nominales (p.90-91)
16
+ */
17
+ describe('puissanceEcs - puissance de dimensionnement ECS selon le volume de stockage', () => {
18
+ test('production instantanée (Vs = 0) : Pecs = 21 kW', () => {
19
+ expect(puissanceEcs(0)).toBe(21);
20
+ expect(puissanceEcs('0.000')).toBe(21);
21
+ });
22
+
23
+ test('semi-instantanée (0 < Vs ≤ 20) : Pecs = 21 − 0,8 × Vs', () => {
24
+ expect(puissanceEcs(10)).toBeCloseTo(13, 9);
25
+ expect(puissanceEcs(20)).toBeCloseTo(5, 9);
26
+ });
27
+
28
+ test('semi-accumulation (20 < Vs ≤ 150) : Pecs = 5 − 1,751 × (Vs − 20) / 65', () => {
29
+ expect(puissanceEcs(85)).toBeCloseTo(5 - 1.751, 9);
30
+ expect(puissanceEcs(150)).toBeCloseTo(5 - (1.751 * 130) / 65, 9);
31
+ });
32
+
33
+ test('accumulation (Vs > 150) : Pecs = (7,14 × Vs + 428) / 1000', () => {
34
+ expect(puissanceEcs(200)).toBeCloseTo((7.14 * 200 + 428) / 1000, 9);
35
+ });
36
+
37
+ test.each([undefined, null, '', 'abc', -5])('volume %s non exploitable : undefined', (vs) => {
38
+ expect(puissanceEcs(vs)).toBeUndefined();
39
+ });
40
+ });
41
+
42
+ describe('pnChaudiere - table Pdim → Pn des chaudières', () => {
43
+ test.each([
44
+ [3, 18, 5],
45
+ [5, 18, 5],
46
+ [7, 18, 10],
47
+ [12, 18, 13],
48
+ [15, 18, 18],
49
+ [18, 18, 18],
50
+ [20, 24, 24],
51
+ [26, 28, 28],
52
+ [30, 32, 32],
53
+ [40, 40, 40]
54
+ ])('Pdim = %s kW : Pn = %s kW (murale < 2006 ou sol), %s kW (murale ≥ 2006)', (pdim, a, b) => {
55
+ expect(pnChaudiere(pdim, false)).toBe(a);
56
+ expect(pnChaudiere(pdim, true)).toBe(b);
57
+ });
58
+
59
+ test('Pdim > 40 kW : Pn = (Partie entière(Pdim / 5) + 1) × 5, quelle que soit la colonne', () => {
60
+ expect(pnChaudiere(41, false)).toBe(45);
61
+ expect(pnChaudiere(45, true)).toBe(50);
62
+ expect(pnChaudiere(57.3, true)).toBe(60);
63
+ });
64
+
65
+ test('Pn ≥ Pdim sur toute la table', () => {
66
+ for (let pdim = 0.5; pdim < 80; pdim += 0.5) {
67
+ expect(pnChaudiere(pdim, false)).toBeGreaterThanOrEqual(pdim);
68
+ expect(pnChaudiere(pdim, true)).toBeGreaterThanOrEqual(pdim);
69
+ }
70
+ });
71
+ });
72
+
73
+ describe('isChaudiere - générateurs concernés par la table Pdim → Pn', () => {
74
+ test.each([
75
+ ['chaudière gaz à condensation après 2015', true],
76
+ ['pompe à chaleur hybride : partie chaudière chaudière gaz à condensation après 2015', true],
77
+ ['chaudière(s) gaz multi bâtiment modélisée comme un réseau de chaleur', false],
78
+ ['radiateur à gaz indépendant ou autonome après 2006', false],
79
+ ['générateur à air chaud à combustion avant 2006', false],
80
+ ['accumulateur gaz classique après 2000', false],
81
+ [undefined, false]
82
+ ])('%s → %s', (libelle, attendu) => {
83
+ expect(isChaudiere(libelle)).toBe(attendu);
84
+ });
85
+ });
86
+
87
+ describe('periodeInstallationLibelle - période d’installation déduite du type de générateur', () => {
88
+ test.each([
89
+ ['chaudière gaz standard 2001-2015', 2001, 2015],
90
+ ['chaudière gaz classique avant 1981', -Infinity, 1980],
91
+ ['chaudière gaz à condensation après 2015', 2016, Infinity],
92
+ ['poêle à bois bouilleur bûche installé à partir de 2012', 2012, Infinity],
93
+ ['chaudière électrique', -Infinity, Infinity],
94
+ [undefined, -Infinity, Infinity]
95
+ ])('%s → [%s ; %s]', (libelle, min, max) => {
96
+ expect(periodeInstallationLibelle(libelle)).toStrictEqual({ min, max });
97
+ });
98
+ });
99
+
100
+ describe('anneeInstallationGenerateur - année d’installation retenue', () => {
101
+ test('année d’installation saisie prioritaire', () => {
102
+ expect(anneeInstallationGenerateur('2003', 1970, 'chaudière gaz après 2015')).toBe(2003);
103
+ });
104
+
105
+ test('repli : année de construction si elle est compatible avec le type de générateur', () => {
106
+ expect(anneeInstallationGenerateur(undefined, '2010', 'chaudière gaz standard 2001-2015')).toBe(
107
+ 2010
108
+ );
109
+ });
110
+
111
+ test('repli : année de construction ramenée dans la période du type de générateur', () => {
112
+ expect(
113
+ anneeInstallationGenerateur(undefined, 1970, 'chaudière gaz à condensation après 2015')
114
+ ).toBe(2016);
115
+ expect(anneeInstallationGenerateur(undefined, 2020, 'chaudière gaz classique avant 1981')).toBe(
116
+ 1980
117
+ );
118
+ });
119
+
120
+ test('sans année de construction : borne de la période si elle tranche par rapport à 2006', () => {
121
+ expect(anneeInstallationGenerateur(undefined, undefined, 'chaudière gaz après 2015')).toBe(
122
+ 2016
123
+ );
124
+ expect(anneeInstallationGenerateur(undefined, undefined, 'chaudière gaz avant 1981')).toBe(
125
+ 1980
126
+ );
127
+ expect(
128
+ anneeInstallationGenerateur(undefined, undefined, 'chaudière gaz standard 2001-2015')
129
+ ).toBeUndefined();
130
+ expect(
131
+ anneeInstallationGenerateur(undefined, undefined, 'chaudière électrique')
132
+ ).toBeUndefined();
133
+ });
134
+ });
135
+
136
+ describe('valeurChaudiereMurale - sous-fiche « Chaudière murale »', () => {
137
+ test.each([
138
+ ['oui', true],
139
+ [' Oui ', true],
140
+ ['non', false],
141
+ ['NC', undefined],
142
+ [undefined, undefined]
143
+ ])('%s → %s', (valeur, attendu) => {
144
+ expect(valeurChaudiereMurale(valeur)).toBe(attendu);
145
+ });
146
+ });
147
+
148
+ describe('pnEstimeeDimensionnement - Pn estimée (W) d’un générateur sans pn', () => {
149
+ const CHAUDIERE = 'chaudière gaz à condensation après 2015';
150
+
151
+ test('chaudière de chauffage seul, murale récente : Pn = table(Pch) seconde colonne', () => {
152
+ expect(
153
+ pnEstimeeDimensionnement({
154
+ pch: 7800,
155
+ libelleTypeGenerateur: CHAUDIERE,
156
+ murale: true,
157
+ anneeInstallation: 2018
158
+ })
159
+ ).toBe(10000);
160
+ });
161
+
162
+ test('chaudière au sol : première colonne (plancher 18 kW) même installée après 2006', () => {
163
+ expect(
164
+ pnEstimeeDimensionnement({
165
+ pch: 7800,
166
+ libelleTypeGenerateur: CHAUDIERE,
167
+ murale: false,
168
+ anneeInstallation: 2018
169
+ })
170
+ ).toBe(18000);
171
+ });
172
+
173
+ test('chaudière murale installée avant 2006 : première colonne', () => {
174
+ expect(
175
+ pnEstimeeDimensionnement({
176
+ pch: 2300,
177
+ libelleTypeGenerateur: 'chaudière gaz standard 1991-2000',
178
+ murale: true,
179
+ anneeInstallation: ANNEE_CHAUDIERE_MURALE_RECENTE - 1
180
+ })
181
+ ).toBe(18000);
182
+ });
183
+
184
+ test('pose inconnue : seconde colonne si installée à partir de 2006, première sinon', () => {
185
+ const base = { pch: 2300, libelleTypeGenerateur: CHAUDIERE };
186
+ expect(pnEstimeeDimensionnement({ ...base, anneeInstallation: 2006 })).toBe(5000);
187
+ expect(pnEstimeeDimensionnement({ ...base, anneeInstallation: 2005 })).toBe(18000);
188
+ expect(pnEstimeeDimensionnement({ ...base, anneeInstallation: undefined })).toBe(18000);
189
+ });
190
+
191
+ test('chaudière mixte à production instantanée : Pdim = max(Pch ; 21 kW)', () => {
192
+ expect(
193
+ pnEstimeeDimensionnement({
194
+ pch: 7800,
195
+ volumeStockage: 0,
196
+ usageEcs: true,
197
+ libelleTypeGenerateur: CHAUDIERE,
198
+ murale: true,
199
+ anneeInstallation: 2018
200
+ })
201
+ ).toBe(24000);
202
+ });
203
+
204
+ test('chaudière mixte à accumulation : Pecs faible, Pdim = Pch', () => {
205
+ expect(
206
+ pnEstimeeDimensionnement({
207
+ pch: 12500,
208
+ volumeStockage: 200,
209
+ usageEcs: true,
210
+ libelleTypeGenerateur: CHAUDIERE,
211
+ murale: true,
212
+ anneeInstallation: 2018
213
+ })
214
+ ).toBe(13000);
215
+ });
216
+
217
+ test('générateur ECS seul : Pdim = Pecs (Pch ignorée)', () => {
218
+ expect(
219
+ pnEstimeeDimensionnement({
220
+ pch: 30000,
221
+ volumeStockage: 10,
222
+ usageEcs: true,
223
+ usageChauffage: false,
224
+ libelleTypeGenerateur: CHAUDIERE,
225
+ murale: true,
226
+ anneeInstallation: 2018
227
+ })
228
+ ).toBe(13000);
229
+ });
230
+
231
+ test('générateur ECS seul sans volume de stockage : Pch conservée (comportement historique)', () => {
232
+ expect(
233
+ pnEstimeeDimensionnement({
234
+ pch: 7800,
235
+ usageEcs: true,
236
+ usageChauffage: false,
237
+ libelleTypeGenerateur: CHAUDIERE
238
+ })
239
+ ).toBe(7800);
240
+ });
241
+
242
+ test('générateur non-chaudière (radiateur gaz, air chaud…) : Pn = Pdim', () => {
243
+ expect(
244
+ pnEstimeeDimensionnement({
245
+ pch: 7800,
246
+ libelleTypeGenerateur: 'radiateur à gaz indépendant ou autonome après 2006'
247
+ })
248
+ ).toBeCloseTo(7800, 9);
249
+ expect(
250
+ pnEstimeeDimensionnement({
251
+ pch: 3000,
252
+ volumeStockage: 0,
253
+ usageEcs: true,
254
+ usageChauffage: false,
255
+ libelleTypeGenerateur: 'chauffe-eau gaz à production instantanée après 2015'
256
+ })
257
+ ).toBeCloseTo(21000, 9);
258
+ });
259
+ });
@@ -380,7 +380,10 @@ export default function calc_gen_ecs(dpe, gen_ecs, ecs_di, ecs_de, GV, ca_id, zc
380
380
  const tbase = Tbase[ca][zc.slice(0, 2)];
381
381
 
382
382
  const methodeSaisie = parseInt(de.enum_methode_saisie_carac_sys_id);
383
- tv_generateur_combustion(dpe, di, de, 'ecs', GV, tbase, methodeSaisie);
383
+ // §13.2.2.4 : Pn estimée (si absente) à partir de Pecs (volume de stockage) pour les installations individuelles
384
+ tv_generateur_combustion(dpe, di, de, 'ecs', GV, tbase, methodeSaisie, 1, {
385
+ installationIndividuelle: String(ecs_de.enum_type_installation_id) === '1'
386
+ });
384
387
 
385
388
  if (bug_for_bug_compat) {
386
389
  if (di.qp0 < 1) {
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.49`** · 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>
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>
406
+ <td align="center"><strong>5,56 ms</strong><br/><sub>moyenne</sub></td>
407
+ <td align="center"><strong>4,89 ms</strong><br/><sub>médiane</sub></td>
408
+ <td align="center"><strong>0,13 ms</strong><br/><sub>min</sub></td>
409
+ <td align="center"><strong>581,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** | 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,50 ms | 4,07 ms | 0,21 ms | 36,7 ms | 8,24 ms | 11,2 ms |
416
+ | **Appartement · chauffage individuel (2025)** | 4,06 ms | 3,74 ms | 1,54 ms | 29,8 ms | 6,88 ms | 8,57 ms |
417
+ | **Logement individuel (2025)** | 5,52 ms | 4,95 ms | 1,71 ms | 34,2 ms | 10,1 ms | 13,7 ms |
418
+ | **Maison individuelle (2025)** | 6,52 ms | 5,95 ms | 2,14 ms | 49,4 ms | 11,6 ms | 14,7 ms |
419
+ | **Immeuble · chauffage individuel** | 6,74 ms | 5,70 ms | 0,13 ms | 159,0 ms | 13,9 ms | 21,9 ms |
420
+ | **Appartement · chauffage collectif (2025)** | 4,52 ms | 4,20 ms | 1,58 ms | 39,5 ms | 7,25 ms | 9,05 ms |
421
+ | **Immeuble · chauffage collectif** | 5,77 ms | 4,91 ms | 0,13 ms | 131,8 ms | 10,7 ms | 18,8 ms |
422
+ | **Immeuble · chauffage mixte** | 5,79 ms | 4,72 ms | 0,21 ms | 581,4 ms | 12,2 ms | 19,9 ms |
423
+ | **Individuel généré depuis l'immeuble (2026)** | 6,62 ms | 5,72 ms | 1,79 ms | 58,3 ms | 12,5 ms | 20,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.50';
37
37
 
38
38
  const dpeSanitizerService = new DpeSanitizerService();
39
39
 
@@ -4,6 +4,11 @@ import { excel_to_js_exec } from '../../../../utils.js';
4
4
  import { TvStore } from '../../../dpe/infrastructure/tv.store.js';
5
5
  import { EmetteurChService } from './emetteur-ch.service.js';
6
6
  import { TypeGenerateur } from '../../../dpe/domain/models/type-generateur.model.js';
7
+ import enums from '../../../../enums.js';
8
+ import {
9
+ anneeInstallationGenerateur,
10
+ pnEstimeeDimensionnement
11
+ } from '../../../../13.2_generateur_combustion_pdim.js';
7
12
 
8
13
  /**
9
14
  * Calcul des données de calcul pour chacun des générateurs
@@ -73,7 +78,12 @@ export class GenerateurChService {
73
78
  if (generateurChauffage.donnee_utilisateur.combustion) {
74
79
  // Calcul de la puissance nominale si non définie
75
80
  if (!generateurChauffageDI.pn) {
76
- generateurChauffage.donnee_intermediaire.pn = this.pn(ctx, logement);
81
+ generateurChauffage.donnee_intermediaire.pn = this.pn(
82
+ ctx,
83
+ logement,
84
+ installationChauffage,
85
+ generateurChauffage
86
+ );
77
87
  }
78
88
 
79
89
  generateurChauffage.donnee_utilisateur.generateur = this.#chTvStore.getGenerateurCombustion(
@@ -201,11 +211,72 @@ export class GenerateurChService {
201
211
  *
202
212
  * @param ctx {Contexte}
203
213
  * @param logement {Logement}
214
+ * @param installationChauffage {InstallationChauffage}
215
+ * @param generateurChauffage {GenerateurChauffage}
204
216
  * @return {number}
205
217
  */
206
- pn(ctx, logement) {
218
+ pn(ctx, logement, installationChauffage, generateurChauffage) {
207
219
  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;
220
+ // §13.2.2.4 : générateur individuel n'alimentant qu'une partie du logement => prorata Sh_installation / Sh.
221
+ // Un générateur collectif dessert tout le bâtiment : pas de prorata.
222
+ const installationDE = installationChauffage?.donnee_entree;
223
+ const ratio =
224
+ String(installationDE?.enum_type_installation_id) === '1'
225
+ ? installationDE.surface_chauffee / ctx.surfaceHabitable
226
+ : 1;
227
+ const prorata = ratio > 0 && ratio < 1 ? ratio : 1;
228
+ const pch =
229
+ (prorata * 1.2 * logement.sortie.deperdition.deperdition_enveloppe * (19 - Tbase)) /
230
+ 0.95 ** 3;
231
+
232
+ // §13.2.2.4 : Pdim = max(Pch ; Pecs) puis table Pdim → Pn des chaudières (générateurs individuels)
233
+ if (String(installationDE?.enum_type_installation_id) !== '1' || !generateurChauffage) {
234
+ return pch;
235
+ }
236
+
237
+ const generateurDE = generateurChauffage.donnee_entree;
238
+ const usageMixte = String(generateurDE.enum_usage_generateur_id) === '3';
239
+ const libelleTypeGenerateur = enums.type_generateur_ch[generateurDE.enum_type_generateur_ch_id];
240
+
241
+ return pnEstimeeDimensionnement({
242
+ pch,
243
+ volumeStockage: usageMixte ? this.volumeStockageEcsMixte(logement, generateurDE) : undefined,
244
+ usageEcs: usageMixte,
245
+ libelleTypeGenerateur,
246
+ // Fiches techniques non disponibles dans la nouvelle architecture : pose murale inconnue
247
+ murale: undefined,
248
+ anneeInstallation: anneeInstallationGenerateur(
249
+ generateurDE.annee_installation,
250
+ ctx.anneeConstruction,
251
+ libelleTypeGenerateur
252
+ )
253
+ });
254
+ }
255
+
256
+ /**
257
+ * Volume de stockage (L) de la production d'ECS assurée par un générateur de chauffage mixte :
258
+ * générateur ECS mixte de même `reference_generateur_mixte`, sinon générateur ECS mixte unique.
259
+ *
260
+ * @param logement {Logement}
261
+ * @param generateurChauffageDE {GenerateurChauffageDE}
262
+ * @return {number|undefined}
263
+ */
264
+ volumeStockageEcsMixte(logement, generateurChauffageDE) {
265
+ const generateursEcsMixtes = (logement.installation_ecs_collection?.installation_ecs || [])
266
+ .flatMap((installation) => installation.generateur_ecs_collection?.generateur_ecs || [])
267
+ .filter((generateur) => String(generateur.donnee_entree?.enum_usage_generateur_id) === '3');
268
+
269
+ const reference = generateurChauffageDE.reference_generateur_mixte;
270
+ const jumeau =
271
+ (reference !== undefined &&
272
+ reference !== null &&
273
+ generateursEcsMixtes.find(
274
+ (generateur) =>
275
+ String(generateur.donnee_entree.reference_generateur_mixte) === String(reference)
276
+ )) ||
277
+ (generateursEcsMixtes.length === 1 ? generateursEcsMixtes[0] : undefined);
278
+
279
+ return jumeau?.donnee_entree.volume_stockage;
209
280
  }
210
281
 
211
282
  /**
@@ -79,3 +79,153 @@ 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
+ });
131
+
132
+ /**
133
+ * Puissance nominale estimée par la puissance de dimensionnement : Pdim = max(Pch ; Pecs) puis table
134
+ * Pdim → Pn des chaudières (le calcul de la table est testé dans 13.2_generateur_combustion_pdim.spec.js).
135
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §13.2.2.4
136
+ */
137
+ describe('Puissance nominale estimée : Pdim et table Pdim → Pn (issue #271)', () => {
138
+ /** @type {TvStore} */
139
+ let tvStore;
140
+ const ctx = {
141
+ altitude: { id: 1 },
142
+ zoneClimatique: { id: 1 },
143
+ surfaceHabitable: 100,
144
+ anneeConstruction: 1975
145
+ };
146
+ const installation = { donnee_entree: { surface_chauffee: 100, enum_type_installation_id: 1 } };
147
+ const logementAvecEcs = (generateursEcs) => ({
148
+ sortie: { deperdition: { deperdition_enveloppe: 200 } },
149
+ installation_ecs_collection: {
150
+ installation_ecs: [{ generateur_ecs_collection: { generateur_ecs: generateursEcs } }]
151
+ }
152
+ });
153
+ const generateur = (de) => ({ donnee_entree: de });
154
+
155
+ beforeEach(() => {
156
+ tvStore = new TvStore();
157
+ service = new GenerateurChService(new ChTvStore(), tvStore, new EmetteurChService());
158
+ vi.spyOn(tvStore, 'getTempBase').mockReturnValue(-9);
159
+ });
160
+
161
+ test('chaudière de chauffage seul installée après 2015 : Pch = 7,84 kW → Pn = 10 kW', () => {
162
+ const logement = logementAvecEcs([]);
163
+ const gen = generateur({ enum_type_generateur_ch_id: '97', enum_usage_generateur_id: 1 });
164
+ expect(service.pn(ctx, logement, installation, gen)).toBe(10000);
165
+ });
166
+
167
+ test('chaudière 1991-2000 (pose inconnue, avant 2006) : plancher 18 kW', () => {
168
+ const logement = logementAvecEcs([]);
169
+ const gen = generateur({ enum_type_generateur_ch_id: '88', enum_usage_generateur_id: 1 });
170
+ expect(service.pn(ctx, logement, installation, gen)).toBe(18000);
171
+ });
172
+
173
+ test('chaudière mixte instantanée : Pdim = max(Pch ; 21 kW) → Pn = 24 kW', () => {
174
+ const logement = logementAvecEcs([
175
+ { donnee_entree: { enum_usage_generateur_id: 2, volume_stockage: 300 } },
176
+ { donnee_entree: { enum_usage_generateur_id: 3, volume_stockage: 0 } }
177
+ ]);
178
+ const gen = generateur({ enum_type_generateur_ch_id: '97', enum_usage_generateur_id: 3 });
179
+ expect(service.pn(ctx, logement, installation, gen)).toBe(24000);
180
+ });
181
+
182
+ test('radiateur gaz : Pn = Pdim (pas de table)', () => {
183
+ const logement = logementAvecEcs([]);
184
+ const gen = generateur({ enum_type_generateur_ch_id: '54', enum_usage_generateur_id: 1 });
185
+ expect(service.pn(ctx, logement, installation, gen)).toBeCloseTo(7837.877241580406, 9);
186
+ });
187
+
188
+ test('installation collective : Pch conservée', () => {
189
+ const logement = logementAvecEcs([]);
190
+ const collective = { donnee_entree: { enum_type_installation_id: 2 } };
191
+ const gen = generateur({ enum_type_generateur_ch_id: '97', enum_usage_generateur_id: 1 });
192
+ expect(service.pn(ctx, logement, collective, gen)).toBeCloseTo(7837.877241580406, 9);
193
+ });
194
+
195
+ test('volume de stockage du générateur ECS mixte jumeau (reference_generateur_mixte)', () => {
196
+ const ecs = (reference, volume) => ({
197
+ donnee_entree: {
198
+ enum_usage_generateur_id: '3',
199
+ reference_generateur_mixte: reference,
200
+ volume_stockage: volume
201
+ }
202
+ });
203
+ const logement = logementAvecEcs([ecs('A', 0), ecs('B', 200)]);
204
+ expect(service.volumeStockageEcsMixte(logement, { reference_generateur_mixte: 'B' })).toBe(200);
205
+ expect(service.volumeStockageEcsMixte(logement, {})).toBeUndefined();
206
+ expect(service.volumeStockageEcsMixte({}, {})).toBeUndefined();
207
+ expect(
208
+ service.volumeStockageEcsMixte(
209
+ { installation_ecs_collection: { installation_ecs: [{}] } },
210
+ { reference_generateur_mixte: 'B' }
211
+ )
212
+ ).toBeUndefined();
213
+ });
214
+
215
+ test('execute : le générateur est transmis à l’estimation de pn', () => {
216
+ vi.spyOn(service, 'pn').mockReturnValue(24000);
217
+ vi.spyOn(service, 'caracteristiques').mockReturnValue({});
218
+ const gen = {
219
+ donnee_entree: { enum_type_generateur_ch_id: '97', enum_lien_generateur_emetteur_id: 1 },
220
+ donnee_intermediaire: {}
221
+ };
222
+ const inst = {
223
+ donnee_entree: { enum_type_installation_id: 1 },
224
+ generateur_chauffage_collection: { generateur_chauffage: [gen] }
225
+ };
226
+ const logement = logementAvecEcs([]);
227
+ service.execute(ctx, logement, inst);
228
+ expect(service.pn).toHaveBeenCalledWith(ctx, logement, inst, gen);
229
+ expect(gen.donnee_intermediaire.pn).toBe(24000);
230
+ });
231
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open3cl/engine",
3
- "version": "1.8.48",
3
+ "version": "1.8.50",
4
4
  "description": "Open Source 3CL-DPE engine",
5
5
  "main": "index.js",
6
6
  "directories": {