@open3cl/engine 1.8.49 → 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);
@@ -144,6 +149,95 @@ export function pnEstimee(GV, tbase, ratioSurface = 1) {
144
149
  return (prorata * 1.2 * GV * (19 - tbase)) / 0.95 ** 3;
145
150
  }
146
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
+
147
241
  /**
148
242
  * Si la méthode de saisie n'est pas "Valeur forfaitaire" mais "caractéristiques saisies"
149
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
@@ -163,6 +257,8 @@ export function pnEstimee(GV, tbase, ratioSurface = 1) {
163
257
  * @param methodeSaisie {number} méthode de saisie des caractéristiques du générateur
164
258
  * @param ratioSurface {number} part de la surface du logement desservie par le générateur
165
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)
166
262
  */
167
263
  export function tv_generateur_combustion(
168
264
  dpe,
@@ -172,7 +268,8 @@ export function tv_generateur_combustion(
172
268
  GV,
173
269
  tbase,
174
270
  methodeSaisie,
175
- ratioSurface = 1
271
+ ratioSurface = 1,
272
+ optionsPn = {}
176
273
  ) {
177
274
  const typeGenerateurKey = `enum_type_generateur_${type}_id`;
178
275
  let enumTypeGenerateurId = de[typeGenerateurKey];
@@ -240,7 +337,7 @@ export function tv_generateur_combustion(
240
337
  } else {
241
338
  // Calcul de la puissance nominale si non définie
242
339
  if (!di.pn) {
243
- di.pn = pnEstimee(GV, tbase, ratioSurface);
340
+ di.pn = pnEstimeeGenerateur(dpe, de, type, pnEstimee(GV, tbase, ratioSurface), optionsPn);
244
341
  }
245
342
 
246
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,
@@ -69,8 +77,13 @@ const {
69
77
  caracteristiquesGenerateurCombustion,
70
78
  puissanceFormuleQp0,
71
79
  PN_MAX_QP0_CHAUDIERE_GAZ,
72
- pnEstimee
80
+ pnEstimee,
81
+ pnEstimeeGenerateur,
82
+ chaudiereMurale,
83
+ volumeStockageEcsMixte
73
84
  } = await import('./13.2_generateur_combustion.js');
85
+ const { pnEstimeeDimensionnement, anneeInstallationGenerateur } =
86
+ await import('./13.2_generateur_combustion_pdim.js');
74
87
  const { tv, tvColumnLines } = await import('./utils.js');
75
88
  const { conventionPuissanceNominale } = await import('./13.2_generateur_combustion_pn.js');
76
89
  const { updateGenerateurBouilleurs } = await import('./13.2_generateur_combustion_bouilleur.js');
@@ -1479,3 +1492,185 @@ describe('pnEstimee - puissance nominale estimée (issue #264)', () => {
1479
1492
  expect(pnEstimee(200, -9, ratio)).toBeCloseTo(PCH_LOGEMENT, 9);
1480
1493
  });
1481
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/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.48`** · branche `main` · généré le 2026-10-12
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,92 ms</strong><br/><sub>moyenne</sub></td>
407
- <td align="center"><strong>5,25 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>590,9 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** | 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 |
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.49';
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
@@ -76,7 +81,8 @@ export class GenerateurChService {
76
81
  generateurChauffage.donnee_intermediaire.pn = this.pn(
77
82
  ctx,
78
83
  logement,
79
- installationChauffage
84
+ installationChauffage,
85
+ generateurChauffage
80
86
  );
81
87
  }
82
88
 
@@ -206,9 +212,10 @@ export class GenerateurChService {
206
212
  * @param ctx {Contexte}
207
213
  * @param logement {Logement}
208
214
  * @param installationChauffage {InstallationChauffage}
215
+ * @param generateurChauffage {GenerateurChauffage}
209
216
  * @return {number}
210
217
  */
211
- pn(ctx, logement, installationChauffage) {
218
+ pn(ctx, logement, installationChauffage, generateurChauffage) {
212
219
  const Tbase = this.#tvStore.getTempBase(ctx.altitude.id, ctx.zoneClimatique.id);
213
220
  // §13.2.2.4 : générateur individuel n'alimentant qu'une partie du logement => prorata Sh_installation / Sh.
214
221
  // Un générateur collectif dessert tout le bâtiment : pas de prorata.
@@ -218,9 +225,58 @@ export class GenerateurChService {
218
225
  ? installationDE.surface_chauffee / ctx.surfaceHabitable
219
226
  : 1;
220
227
  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
- );
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;
224
280
  }
225
281
 
226
282
  /**
@@ -128,3 +128,104 @@ describe('Puissance nominale estimée des générateurs de chauffage (issue #264
128
128
  expect(service.pn(ctx, logement, installation)).toBeCloseTo(PCH_LOGEMENT, 9);
129
129
  });
130
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.49",
3
+ "version": "1.8.50",
4
4
  "description": "Open Source 3CL-DPE engine",
5
5
  "main": "index.js",
6
6
  "directories": {