@open3cl/engine 1.7.3 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/11_besoin_ecs.js CHANGED
@@ -24,11 +24,14 @@ export function calc_besoin_ecs_j(ca, mois, zc, nadeq, depensier) {
24
24
  export default function calc_besoin_ecs(ca, zc, nadeq) {
25
25
  const ret = {
26
26
  besoin_ecs: 0,
27
- besoin_ecs_depensier: 0
27
+ besoin_ecs_depensier: 0,
28
+ besoin_ecs_par_mois: {}
28
29
  };
29
30
  for (const mois of mois_liste) {
30
- ret.besoin_ecs += calc_besoin_ecs_j(ca, mois, zc, nadeq, false);
31
+ const becs_j = calc_besoin_ecs_j(ca, mois, zc, nadeq, false);
32
+ ret.besoin_ecs += becs_j;
31
33
  ret.besoin_ecs_depensier += calc_besoin_ecs_j(ca, mois, zc, nadeq, true);
34
+ ret.besoin_ecs_par_mois[mois] = becs_j;
32
35
  }
33
36
  return ret;
34
37
  }
package/11_ecs.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import enums from './enums.js';
2
2
  import calc_gen_ecs from './14_generateur_ecs.js';
3
- import { tv, requestInput } from './utils.js';
3
+ import { tv, requestInput, mois_liste, Njj } from './utils.js';
4
4
 
5
5
  function tv_rendement_distribution_ecs(di, de, du, pvc) {
6
6
  let matcher = {};
@@ -49,6 +49,89 @@ function tv_rendement_distribution_ecs(di, de, du, pvc) {
49
49
  }
50
50
  }
51
51
 
52
+ /**
53
+ * Calcul de la consommation des auxiliaires de distribution ECS - CAS IMMEUBLE
54
+ * Calcul installation par installation pour les réseaux collectifs bouclés ou avec traçage.
55
+ *
56
+ * @param {object} de - donnee_entree de l'installation ECS
57
+ * @param {number} surfaceImmeuble - surface habitable totale de l'immeuble (m²)
58
+ * @param {object} becsParMois - besoin ECS mensuel de l'immeuble {Janvier: X, Février: X, ...} [kWh]
59
+ * @returns {number} conso_auxiliaire_distribution_ecs_installation [Wh]
60
+ */
61
+ function calc_auxiliaire_distribution_ecs_immeuble(de, surfaceImmeuble, becsParMois) {
62
+ const type_installation = enums.type_installation[de.enum_type_installation_id];
63
+
64
+ // Seulement pour les installations collectives
65
+ if (!type_installation || !type_installation.includes('collective')) {
66
+ return 0;
67
+ }
68
+
69
+ const enum_bouclage_id = String(de.enum_bouclage_reseau_ecs_id);
70
+ const surface_installation = de.surface_habitable || 0;
71
+ const surface_immeuble_total = surfaceImmeuble || 1;
72
+ const ratio_surface = surface_installation / surface_immeuble_total;
73
+
74
+ // CAS 1 : réseau non bouclé → 0
75
+ if (enum_bouclage_id === '1') {
76
+ return 0;
77
+ }
78
+
79
+ // CAS 3 : traçage → 0.14 × BECS_annuel × ratio_surface [Wh]
80
+ if (enum_bouclage_id === '3') {
81
+ const becs_annuel_kwh = Object.values(becsParMois).reduce((acc, v) => acc + v, 0);
82
+ // becs_annuel est en kWh → × 1000 pour obtenir des Wh
83
+ return 0.14 * becs_annuel_kwh * 1000 * ratio_surface;
84
+ }
85
+
86
+ // CAS 2 : réseau bouclé → calcul complet 9 étapes [Wh]
87
+ if (enum_bouclage_id === '2') {
88
+ const Sh_inst = surface_installation;
89
+ const Niv_inst = Number(de.nombre_niveau_installation_ecs) || 1;
90
+
91
+ // Étape 3 : Calcul de Lb pour l'installation i
92
+ // Lb = 1.2 × (1.1 × Sh_inst/nombre_niveaux + 4 × nombre_niveaux)
93
+ const Lb = 1.2 * (1.1 * (Sh_inst / Niv_inst) + 4 * Niv_inst);
94
+
95
+ // Étape 4 : DeltaPb = 0.2 × Lb + 10
96
+ const DeltaPb = 0.2 * Lb + 10;
97
+
98
+ let Qcirc_annuel = 0;
99
+
100
+ for (const mois of mois_liste) {
101
+ const Njj_mois = Njj[mois];
102
+ const becs_immeuble_j_kwh = becsParMois[mois] || 0;
103
+
104
+ // Étape 1 : Qdwj_i [kWh]
105
+ const Qdwj_i = 0.24 * becs_immeuble_j_kwh * ratio_surface;
106
+
107
+ // Étape 2 : qdwj_i [m³/h]
108
+ // Note: la spec officielle utilise Qdwj en Wh et divise par 1000 ensuite.
109
+ // Le moteur fournit becs en kWh, donc Qdwj est en kWh → on ne divise pas par 1000.
110
+ const qdwj_i = Qdwj_i / (5.815 * 5 * Njj_mois);
111
+
112
+ // Étape 5 : Phyd,j [W]
113
+ // Phyd,j = DeltaPb [kPa] × 1000 × qdwj_i [m³/h] / 3600
114
+ const Phyd_j = (DeltaPb * 1000 * qdwj_i) / 3600;
115
+
116
+ // Étape 6 : Effcirb,j (rendement pompe)
117
+ const Effcirb_j = Phyd_j > 0 ? 0.035 * Math.pow(Phyd_j, 0.45) : 0;
118
+
119
+ // Étape 7 : Pcirb,j [W] = max(Phyd,j / Effcirb,j, 20)
120
+ const Pcirb_j = Effcirb_j > 0 ? Math.max(Phyd_j / Effcirb_j, 20) : 20;
121
+
122
+ // Étape 8 : Qcirb,j [Wh]
123
+ const Qcirb_j = (5 * Pcirb_j + 19 * 20) * Njj_mois;
124
+
125
+ Qcirc_annuel += Qcirb_j;
126
+ }
127
+
128
+ // Étape 9 : conso annuelle en Wh
129
+ return Qcirc_annuel;
130
+ }
131
+
132
+ return 0;
133
+ }
134
+
52
135
  export default function calc_ecs(
53
136
  dpe,
54
137
  ecs,
@@ -61,7 +144,8 @@ export default function calc_ecs(
61
144
  virtualisationECS,
62
145
  surfaceImmeuble,
63
146
  nombreAppartements,
64
- isImmeubleSystemEcsIndividuels
147
+ isImmeubleSystemEcsIndividuels,
148
+ becsParMois
65
149
  ) {
66
150
  const de = ecs.donnee_entree;
67
151
  const di = {};
@@ -101,6 +185,19 @@ export default function calc_ecs(
101
185
  0
102
186
  );
103
187
 
188
+ // Calcul de la consommation des auxiliaires de distribution ECS pour l'immeuble
189
+ // Résultat en Wh, converti en kWh pour cohérence avec le reste du moteur
190
+ if (th === 'immeuble' && becsParMois && Object.keys(becsParMois).length > 0) {
191
+ const conso_aux_wh = calc_auxiliaire_distribution_ecs_immeuble(
192
+ de,
193
+ surfaceImmeuble,
194
+ becsParMois
195
+ );
196
+ di.conso_auxiliaire_distribution_ecs = conso_aux_wh / 1000;
197
+ } else {
198
+ di.conso_auxiliaire_distribution_ecs = 0;
199
+ }
200
+
104
201
  ecs.donnee_intermediaire = di;
105
202
  ecs.donnee_utilisateur = du;
106
203
  }
package/11_ecs.spec.js CHANGED
@@ -21,7 +21,10 @@ vi.mock('./enums.js', () => ({
21
21
  vi.mock('./utils.js', () => ({
22
22
  set_bug_for_bug_compat: vi.fn(),
23
23
  tv: vi.fn(),
24
- requestInput: vi.fn()
24
+ requestInput: vi.fn(),
25
+ // Calendrier réduit à deux mois pour le calcul mensuel des auxiliaires de distribution
26
+ mois_liste: ['Janvier', 'Février'],
27
+ Njj: { Janvier: 31, Février: 28 }
25
28
  }));
26
29
 
27
30
  vi.mock('./14_generateur_ecs.js', () => ({
@@ -281,3 +284,138 @@ describe('calc_ecs - agrégation des consommations', () => {
281
284
  expect(ecs.donnee_intermediaire.conso_ecs_depensier).toBe(28);
282
285
  });
283
286
  });
287
+
288
+ /**
289
+ * Consommation des auxiliaires de distribution ECS - cas immeuble
290
+ * Seuls les réseaux collectifs bouclés ou avec traçage consomment de l'énergie.
291
+ */
292
+ describe('calc_ecs - auxiliaires de distribution ECS (immeuble)', () => {
293
+ /** Calcule l'ECS d'un immeuble et retourne la conso des auxiliaires de distribution [kWh]. */
294
+ function consoAuxDistribution(deExtra, becsParMois, surfaceImmeuble = 400, th = 'immeuble') {
295
+ const ecs = makeEcs({ tv_rendement_distribution_ecs_id: '5', ...deExtra });
296
+ calc_ecs(
297
+ {},
298
+ ecs,
299
+ 100,
300
+ 200,
301
+ 1,
302
+ 'ca1',
303
+ 'h1a',
304
+ th,
305
+ false,
306
+ surfaceImmeuble,
307
+ null,
308
+ false,
309
+ becsParMois
310
+ );
311
+ return ecs.donnee_intermediaire.conso_auxiliaire_distribution_ecs;
312
+ }
313
+
314
+ test.each([
315
+ ['logement autre qu’un immeuble', 'maison', { Janvier: 1000 }],
316
+ ['besoin mensuel absent', 'immeuble', undefined],
317
+ ['besoin mensuel vide', 'immeuble', {}]
318
+ ])('%s : pas de consommation', (_libelle, th, becsParMois) => {
319
+ expect(
320
+ consoAuxDistribution(
321
+ { enum_type_installation_id: '2', enum_bouclage_reseau_ecs_id: '2' },
322
+ becsParMois,
323
+ 400,
324
+ th
325
+ )
326
+ ).toBe(0);
327
+ });
328
+
329
+ test.each([
330
+ ['type d’installation inconnu', { enum_type_installation_id: '9' }],
331
+ ['installation individuelle', { enum_type_installation_id: '1' }],
332
+ [
333
+ 'réseau collectif non bouclé',
334
+ { enum_type_installation_id: '2', enum_bouclage_reseau_ecs_id: '1' }
335
+ ],
336
+ ['type de bouclage non renseigné', { enum_type_installation_id: '2' }]
337
+ ])('%s : pas de consommation', (_libelle, de) => {
338
+ expect(consoAuxDistribution(de, { Janvier: 1000 })).toBe(0);
339
+ });
340
+
341
+ test('réseau avec traçage : 0,14 × besoin annuel × ratio de surface', () => {
342
+ const conso = consoAuxDistribution(
343
+ { enum_type_installation_id: '2', enum_bouclage_reseau_ecs_id: 3, surface_habitable: 100 },
344
+ { Janvier: 100, Février: 50 }
345
+ );
346
+
347
+ // 0,14 × 150 kWh × 1000 × (100 / 400) = 5250 Wh => 5,25 kWh
348
+ expect(conso).toBeCloseTo(5.25, 9);
349
+ });
350
+
351
+ test('réseau avec traçage sans surfaces renseignées : ratio de surface nul', () => {
352
+ const conso = consoAuxDistribution(
353
+ { enum_type_installation_id: '3', enum_bouclage_reseau_ecs_id: '3' },
354
+ { Janvier: 100 },
355
+ null
356
+ );
357
+
358
+ // surface_habitable absente => 0 ; surface de l'immeuble absente => 1
359
+ expect(conso).toBe(0);
360
+ });
361
+
362
+ test('réseau bouclé sans besoin : puissance minimale du circulateur de 20 W', () => {
363
+ const conso = consoAuxDistribution(
364
+ { enum_type_installation_id: '2', enum_bouclage_reseau_ecs_id: '2', surface_habitable: 100 },
365
+ { Janvier: 0 }
366
+ );
367
+
368
+ // Pcirb = 20 W chaque mois : (5 × 20 + 19 × 20) × (31 + 28) jours = 28320 Wh
369
+ expect(conso).toBeCloseTo(28.32, 9);
370
+ });
371
+
372
+ test('réseau bouclé à faible besoin : puissance du circulateur ramenée au minimum de 20 W', () => {
373
+ const conso = consoAuxDistribution(
374
+ {
375
+ enum_type_installation_id: '2',
376
+ enum_bouclage_reseau_ecs_id: '2',
377
+ surface_habitable: 100,
378
+ nombre_niveau_installation_ecs: 2
379
+ },
380
+ { Janvier: 1000, Février: 1000 }
381
+ );
382
+
383
+ // Phyd / Effcirb < 20 W => Pcirb = 20 W, identique au cas sans besoin
384
+ expect(conso).toBeCloseTo(28.32, 9);
385
+ });
386
+
387
+ test('réseau bouclé : calcul complet de la consommation du circulateur', () => {
388
+ const conso = consoAuxDistribution(
389
+ {
390
+ enum_type_installation_id: '2',
391
+ enum_bouclage_reseau_ecs_id: '2',
392
+ surface_habitable: 100,
393
+ nombre_niveau_installation_ecs: '2'
394
+ },
395
+ { Janvier: 10000 }
396
+ );
397
+
398
+ // Lb = 1,2 × (1,1 × 100 / 2 + 4 × 2) = 75,6 m ; ΔPb = 0,2 × Lb + 10 = 25,12 kPa
399
+ // Janvier : Phyd ≈ 4,645 W => Pcirb ≈ 66,49 W ; Février (besoin absent) : Pcirb = 20 W
400
+ // Référence de régression (calcul des 9 étapes de la méthode) : 35526,3879475995 Wh
401
+ expect(conso).toBeCloseTo(35.526387947599495, 9);
402
+ });
403
+
404
+ test('réseau bouclé sans nombre de niveaux : un seul niveau par défaut', () => {
405
+ const avecUnNiveau = consoAuxDistribution(
406
+ {
407
+ enum_type_installation_id: '2',
408
+ enum_bouclage_reseau_ecs_id: '2',
409
+ surface_habitable: 100,
410
+ nombre_niveau_installation_ecs: 1
411
+ },
412
+ { Janvier: 10000 }
413
+ );
414
+ const sansNiveau = consoAuxDistribution(
415
+ { enum_type_installation_id: '2', enum_bouclage_reseau_ecs_id: '2', surface_habitable: 100 },
416
+ { Janvier: 10000 }
417
+ );
418
+
419
+ expect(sansNiveau).toBe(avecUnNiveau);
420
+ });
421
+ });
@@ -32,13 +32,43 @@ const K = {
32
32
  'réseau de froid urbain': undefined
33
33
  };
34
34
 
35
+ /**
36
+ * Planchers / plafonds chauffants sur réseau d'eau chaude « basse ou moyenne température
37
+ * (inf 65°c) » (enum_type_emission_distribution_id 12, 14, 16, 18).
38
+ */
39
+ export const EMISSION_PLANCHER_PLAFOND_CHAUFFANT_BT = ['12', '14', '16', '18'];
40
+
41
+ /**
42
+ * Température de distribution à utiliser pour les tables temp_fonc_30 / temp_fonc_100.
43
+ *
44
+ * Un plancher (ou plafond) chauffant à eau < 65 °C fonctionne en basse température : comme le
45
+ * moteur de référence CSTB (Tribu), qui déduit la ligne « basse » du type d'émetteur plancher
46
+ * chauffant, on retient « basse » (id 2) lorsque le DPE déclare « moyenne » (id 3) pour ces
47
+ * émetteurs. Les logiciels du marché (ex. LICIEL) calculent ainsi mais exportent « moyenne ».
48
+ *
49
+ * @param emChDe {object} donnee_entree de l'émetteur
50
+ * @param emChDu {object} donnee_utilisateur de l'émetteur
51
+ * @return {string|undefined}
52
+ */
53
+ export function tempDistributionChPourTempFonc(emChDe, emChDu) {
54
+ const tempDistribution = requestInputID(emChDe, emChDu, 'temp_distribution_ch');
55
+ const typeEmission = String(emChDe?.enum_type_emission_distribution_id ?? '');
56
+ if (
57
+ String(tempDistribution) === '3' &&
58
+ EMISSION_PLANCHER_PLAFOND_CHAUFFANT_BT.includes(typeEmission)
59
+ ) {
60
+ return '2';
61
+ }
62
+ return tempDistribution;
63
+ }
64
+
35
65
  export function tv_temp_fonc_30_100(di, de, du, em_ch, ac) {
36
66
  for (const em of em_ch) {
37
67
  const em_ch_de = em.donnee_entree;
38
68
  const em_ch_du = em.donnee_utilisateur;
39
69
  const matcher = {
40
70
  enum_type_generateur_ch_id: de.enum_type_generateur_ch_id,
41
- enum_temp_distribution_ch_id: requestInputID(em_ch_de, em_ch_du, 'temp_distribution_ch'),
71
+ enum_temp_distribution_ch_id: tempDistributionChPourTempFonc(em_ch_de, em_ch_du),
42
72
  periode_emetteurs: requestInput(em_ch_de, em_ch_du, 'periode_installation_emetteur')
43
73
  };
44
74
 
@@ -180,7 +210,9 @@ export function calc_generateur_combustion_ch(dpe, di, de, du) {
180
210
 
181
211
  // Pveil and QP0 are in kW
182
212
  const Pveil = di.pveil / 1000;
183
- const QP0 = di.qp0 / 1000;
213
+ // rg est d'abord calculé sur PCS (puis × k pour revenir en PCI) : QP0 doit donc être exprimé
214
+ // sur PCS, comme dans QPx() (qp0 × k). Cf. issue #205.
215
+ const QP0 = (di.qp0 * k) / 1000;
184
216
 
185
217
  const rg_pcs = Pmfou / (Pmcons + 0.45 * QP0 + Pveil);
186
218
  const rg_pcs_dep = Pmfou_dep / (Pmcons_dep + 0.45 * QP0 + Pveil);
@@ -38,7 +38,7 @@ vi.mock('./utils.js', () => ({
38
38
  requestInputID: vi.fn((de, du, field) => de[`enum_${field}_id`])
39
39
  }));
40
40
 
41
- const { tv_temp_fonc_30_100, calc_generateur_combustion_ch } =
41
+ const { tv_temp_fonc_30_100, tempDistributionChPourTempFonc, calc_generateur_combustion_ch } =
42
42
  await import('./13.2_generateur_combustion_ch.js');
43
43
  const { tv } = await import('./utils.js');
44
44
 
@@ -138,6 +138,75 @@ describe('tv_temp_fonc_30_100 - températures de fonctionnement', () => {
138
138
  expect(errSpy).toHaveBeenCalledTimes(2);
139
139
  errSpy.mockRestore();
140
140
  });
141
+
142
+ /**
143
+ * Plancher / plafond chauffant à eau < 65 °C : la ligne « basse » est retenue même si le DPE
144
+ * déclare « moyenne » (comportement du moteur de référence CSTB / Tribu).
145
+ */
146
+ test.each(['12', '14', '16', '18'])(
147
+ 'plancher/plafond chauffant BT (émission %s) déclaré « moyenne » : ligne « basse » utilisée',
148
+ (typeEmission) => {
149
+ tv.mockReturnValue({ tv_temp_fonc_30_id: '1', temp_fonc_30: '24.5' });
150
+ tv_temp_fonc_30_100(
151
+ {},
152
+ { enum_type_generateur_ch_id: '97' },
153
+ {},
154
+ [emetteur({ enum_type_emission_distribution_id: typeEmission })],
155
+ 2018
156
+ );
157
+ expect(tv).toHaveBeenCalledWith(
158
+ 'temp_fonc_30',
159
+ expect.objectContaining({ enum_temp_distribution_ch_id: '2' })
160
+ );
161
+ expect(tv).toHaveBeenCalledWith(
162
+ 'temp_fonc_100',
163
+ expect.objectContaining({ enum_temp_distribution_ch_id: '2' })
164
+ );
165
+ }
166
+ );
167
+
168
+ test.each([
169
+ ['radiateur (35), moyenne', '35', '3', '3'],
170
+ ['plancher chauffant HT (13), moyenne', '13', '3', '3'],
171
+ ['plancher chauffant BT (14), haute', '14', '4', '4'],
172
+ ['plancher chauffant BT (14), basse', '14', '2', '2']
173
+ ])(
174
+ '%s : température de distribution déclarée conservée',
175
+ (_, typeEmission, declaree, attendue) => {
176
+ tv.mockReturnValue({ tv_temp_fonc_30_id: '1', temp_fonc_30: '40' });
177
+ tv_temp_fonc_30_100(
178
+ {},
179
+ { enum_type_generateur_ch_id: '97' },
180
+ {},
181
+ [
182
+ emetteur({
183
+ enum_type_emission_distribution_id: typeEmission,
184
+ enum_temp_distribution_ch_id: declaree
185
+ })
186
+ ],
187
+ 2018
188
+ );
189
+ expect(tv).toHaveBeenCalledWith(
190
+ 'temp_fonc_30',
191
+ expect.objectContaining({ enum_temp_distribution_ch_id: attendue })
192
+ );
193
+ }
194
+ );
195
+ });
196
+
197
+ describe('tempDistributionChPourTempFonc', () => {
198
+ test('émetteur sans type d’émission : température déclarée renvoyée telle quelle', () => {
199
+ expect(tempDistributionChPourTempFonc({ enum_temp_distribution_ch_id: '3' }, {})).toBe('3');
200
+ });
201
+
202
+ test('type d’émission numérique (14) : reconnu comme plancher chauffant BT', () => {
203
+ expect(
204
+ tempDistributionChPourTempFonc(
205
+ { enum_temp_distribution_ch_id: 3, enum_type_emission_distribution_id: 14 },
206
+ {}
207
+ )
208
+ ).toBe('2');
209
+ });
141
210
  });
142
211
 
143
212
  /**
@@ -156,9 +225,9 @@ describe('calc_generateur_combustion_ch - rendement de génération', () => {
156
225
  };
157
226
  calc_generateur_combustion_ch({}, di, de, { cdimref: 0.8, cdimrefDep: 0.6 });
158
227
  // valeurs de référence de régression (module réel, enums réels équivalents)
159
- expect(di.rg).toBeCloseTo(0.8582190936918361, 9);
160
- expect(di.rg_dep).toBeCloseTo(0.8706053178411902, 9);
161
- expect(di.rendement_generation).toBeCloseTo(0.8582190936918361, 9);
228
+ expect(di.rg).toBeCloseTo(0.8575549045559866, 9);
229
+ expect(di.rg_dep).toBeCloseTo(0.8700736982536121, 9);
230
+ expect(di.rendement_generation).toBeCloseTo(0.8575549045559866, 9);
162
231
  });
163
232
 
164
233
  test('chaudière bois : branche de calcul dédiée (QP50 / QP100)', () => {
@@ -171,8 +240,8 @@ describe('calc_generateur_combustion_ch - rendement de génération', () => {
171
240
  };
172
241
  calc_generateur_combustion_ch({}, di, de, { cdimref: 0.9, cdimrefDep: 0.7 });
173
242
  // valeurs de référence de régression
174
- expect(di.rg).toBeCloseTo(0.7044775930164003, 9);
175
- expect(di.rg_dep).toBeCloseTo(0.7138933157828895, 9);
243
+ expect(di.rg).toBeCloseTo(0.7040089707689774, 9);
244
+ expect(di.rg_dep).toBeCloseTo(0.713512984577247, 9);
176
245
  });
177
246
 
178
247
  test('chaudière à condensation avec régulation : température de fonctionnement à 30 % utilisée', () => {
@@ -186,8 +255,8 @@ describe('calc_generateur_combustion_ch - rendement de génération', () => {
186
255
  };
187
256
  calc_generateur_combustion_ch({}, di, de, { cdimref: 0.8, cdimrefDep: 0.6 });
188
257
  // valeurs de référence de régression
189
- expect(di.rg).toBeCloseTo(0.9281152399114512, 9);
190
- expect(di.rg_dep).toBeCloseTo(0.9344861707155592, 9);
258
+ expect(di.rg).toBeCloseTo(0.9275325681176758, 9);
259
+ expect(di.rg_dep).toBeCloseTo(0.9340267431264844, 9);
191
260
  });
192
261
 
193
262
  test('radiateur à gaz : branche de calcul dédiée', () => {
@@ -201,8 +270,8 @@ describe('calc_generateur_combustion_ch - rendement de génération', () => {
201
270
  };
202
271
  calc_generateur_combustion_ch({}, di, de, { cdimref: 0.8, cdimrefDep: 0.6 });
203
272
  // valeurs de référence de régression
204
- expect(di.rg).toBeCloseTo(0.8851321252668564, 9);
205
- expect(di.rg_dep).toBeCloseTo(0.8869221230591896, 9);
273
+ expect(di.rg).toBeCloseTo(0.8842491989709215, 9);
274
+ expect(di.rg_dep).toBeCloseTo(0.8862325713702337, 9);
206
275
  });
207
276
 
208
277
  test('générateur à air chaud : branche de calcul dédiée', () => {
@@ -231,8 +300,8 @@ describe('calc_generateur_combustion_ch - rendement de génération', () => {
231
300
  };
232
301
  calc_generateur_combustion_ch({}, di, de, { cdimref: 0.8, cdimrefDep: 0.6 });
233
302
  // valeurs de référence de régression
234
- expect(di.rg).toBeCloseTo(0.8513271171577527, 9);
235
- expect(di.rg_dep).toBeCloseTo(0.8655664711109103, 9);
303
+ expect(di.rg).toBeCloseTo(0.8505103134051144, 9);
304
+ expect(di.rg_dep).toBeCloseTo(0.8649097139284332, 9);
236
305
  });
237
306
 
238
307
  test('type de générateur inconnu : aucun rendement calculé (avertissement)', () => {
@@ -262,8 +331,8 @@ describe('calc_generateur_combustion_ch - rendement de génération', () => {
262
331
  };
263
332
  calc_generateur_combustion_ch({}, di, de, {});
264
333
  // Cdimref = 0 -> x/0 = Infinity -> min(1, ...) = 1 pour tous les x, nominal = dépensier
265
- expect(di.rg).toBeCloseTo(0.8967281540325838, 9);
266
- expect(di.rg_dep).toBeCloseTo(0.8967281540325838, 9);
334
+ expect(di.rg).toBeCloseTo(0.8963697027040485, 9);
335
+ expect(di.rg_dep).toBeCloseTo(0.8963697027040485, 9);
267
336
  });
268
337
 
269
338
  test('bug_for_bug_compat : correction de qp0 exprimé en kW (< 1) vers des W', () => {
@@ -281,7 +350,7 @@ describe('calc_generateur_combustion_ch - rendement de génération', () => {
281
350
  // qp0 < 1 -> multiplié par 1000 => 200 (passage kW -> W)
282
351
  expect(di.qp0).toBe(200);
283
352
  // valeur de référence de régression (chaudière standard, pn = 20000, qp0 corrigé à 200)
284
- expect(di.rg).toBeCloseTo(0.8566147558351076, 9);
353
+ expect(di.rg).toBeCloseTo(0.8557877790761849, 9);
285
354
  warnSpy.mockRestore();
286
355
  });
287
356
 
@@ -320,4 +389,68 @@ describe('calc_generateur_combustion_ch - rendement de génération', () => {
320
389
  // Avec régulation (tf plus basse) le rendement est plus élevé
321
390
  expect(diAvec.rg).toBeGreaterThan(diSans.rg);
322
391
  });
392
+
393
+ /**
394
+ * Référence externe (et non régression) : autotest État CSTB `APP2-0-1`, moteur
395
+ * Moteur_DPE.dll v2025.11.1.0 (Tribu). Chaudière gaz à condensation 2001-2015, Pn = 23 kW,
396
+ * valeurs par défaut. Les grandeurs intermédiaires ci-dessous sont celles de la sortie Tribu
397
+ * (Rpn, Rpint, Qp0, Tfonc30/100, Cdimref = 0.05 / TchFinal(Ch_5)).
398
+ * Avant correctif (QP0 non converti sur PCS) : rg = 0.8597041838253313. Cf. issue #205.
399
+ */
400
+ test('autotest État APP2-0-1 : rg identique à Tribu (QP0 exprimé sur PCS)', () => {
401
+ const di = {
402
+ pn: 23000,
403
+ rpn: 0.9236172783601759,
404
+ rpint: 0.983617278360176,
405
+ qp0: 230,
406
+ pveil: 0,
407
+ temp_fonc_30: 38,
408
+ temp_fonc_100: 80
409
+ };
410
+ const de = {
411
+ enum_type_generateur_ch_id: '96',
412
+ enum_type_energie_id: '2',
413
+ type_energie: 'gaz naturel',
414
+ presence_regulation_combustion: true,
415
+ description: 'Chaudière gaz condensation'
416
+ };
417
+ const cdimref = 0.05 / 0.00603495141488493;
418
+ calc_generateur_combustion_ch({}, di, de, { cdimref, cdimrefDep: cdimref });
419
+ // Rg Tribu (sortie APP2-0-1_Sortie.xml) : 0.8511886946001722
420
+ expect(di.rg).toBeCloseTo(0.8511886946001722, 12);
421
+ });
422
+
423
+ test('les pertes à charge nulle QP0 réduisent le rendement de génération', () => {
424
+ const di = () => ({
425
+ pn: 23000,
426
+ rpn: 0.92,
427
+ rpint: 0.98,
428
+ qp0: 230,
429
+ pveil: 0,
430
+ temp_fonc_30: 38,
431
+ temp_fonc_100: 80
432
+ });
433
+ const de = {
434
+ enum_type_generateur_ch_id: '96',
435
+ enum_type_energie_id: '2',
436
+ presence_regulation_combustion: true,
437
+ description: 't'
438
+ };
439
+ const avecPertes = di();
440
+ calc_generateur_combustion_ch(
441
+ {},
442
+ avecPertes,
443
+ { ...de, type_energie: 'gaz naturel' },
444
+ { cdimref: 8 }
445
+ );
446
+ const sansPertes = { ...di(), qp0: 0 };
447
+ calc_generateur_combustion_ch(
448
+ {},
449
+ sansPertes,
450
+ { ...de, type_energie: 'gaz naturel' },
451
+ { cdimref: 8 }
452
+ );
453
+ // le terme 0.45 × QP0 réduit bien le rendement
454
+ expect(avecPertes.rg).toBeLessThan(sansPertes.rg);
455
+ });
323
456
  });
package/README.md CHANGED
@@ -23,8 +23,6 @@ Implémentation open source du moteur Open3CL de l'ADEME.
23
23
  &middot;
24
24
  <a href="https://github.com/Open3CL/engine/issues/new?labels=enhancement&template=feature-request---.md">Créer une feature</a>
25
25
  &middot;
26
- <a href="https://open3cl.github.io/engine/build/">Démo en ligne</a>
27
- &middot;
28
26
  <a href="https://open3cl.github.io/engine/reports/corpus">Rapports de corpus</a>
29
27
  </p>
30
28
  </div>
@@ -57,7 +55,6 @@ Implémentation open source du moteur Open3CL de l'ADEME.
57
55
  <li><a href="#api-publique">API publique</a></li>
58
56
  <li><a href="#options-de-calcul">Options de calcul</a></li>
59
57
  <li><a href="#lire-le-résultat">Lire le résultat</a></li>
60
- <li><a href="#tester-un-dpe-sans-écrire-de-code">Tester un DPE sans écrire de code</a></li>
61
58
  </ul>
62
59
  </li>
63
60
  <li><a href="#-tests-de-corpus">Tests de corpus</a>
@@ -142,12 +139,11 @@ flowchart LR
142
139
 
143
140
  ### L'écosystème Open3CL
144
141
 
145
- | Ressource | Description |
146
- | :-------------------------------------------------------------------- | :------------------------------------------------------------- |
147
- | [open3cl.fr](https://open3cl.fr) | Le site du projet |
148
- | [Démonstrateur](https://open3cl.github.io/engine/build/) | Chargez un DPE XML et comparez les sorties du moteur, en ligne |
149
- | [Rapports de corpus](https://open3cl.github.io/engine/reports/corpus) | Le tableau de bord interactif des résultats sur DPE réels |
150
- | [@open3cl/engine](https://www.npmjs.com/package/@open3cl/engine) | Le paquet npm |
142
+ | Ressource | Description |
143
+ | :-------------------------------------------------------------------- | :-------------------------------------------------------- |
144
+ | [open3cl.fr](https://open3cl.fr) | Le site du projet |
145
+ | [Rapports de corpus](https://open3cl.github.io/engine/reports/corpus) | Le tableau de bord interactif des résultats sur DPE réels |
146
+ | [@open3cl/engine](https://www.npmjs.com/package/@open3cl/engine) | Le paquet npm |
151
147
 
152
148
  <p align="right">(<a href="#readme-top">Retour sommaire</a>)</p>
153
149
 
@@ -292,17 +288,6 @@ Le DPE renvoyé est l'objet d'entrée, enrichi de `logement.sortie` :
292
288
  | `sortie.confort_ete` / `qualite_isolation` | Indicateurs de confort d'été et de qualité d'isolation |
293
289
  | `sortie.production_electricite` | Production photovoltaïque |
294
290
 
295
- ### Tester un DPE sans écrire de code
296
-
297
- Le [démonstrateur en ligne](https://open3cl.github.io/engine/build/) accepte un DPE au format XML, l'envoie au moteur et
298
- affiche **côte à côte** les valeurs du DPE d'origine, celles calculées par Open3CL, et le différentiel.
299
-
300
- C'est le moyen le plus rapide de qualifier un écart : problème dans le DPE d'origine, ou bug dans la librairie ?
301
-
302
- <p align="right">(<a href="#readme-top">Retour sommaire</a>)</p>
303
-
304
- ---
305
-
306
291
  ## 🧪 Tests de corpus
307
292
 
308
293
  > Un corpus, c'est une liste de numéros de DPE réels. Le moteur les rejoue **tous**, compare ses sorties à celles du
@@ -385,7 +370,7 @@ Ces résultats sont **générés automatiquement** à la fin de `npm run test:co
385
370
 
386
371
  <!-- CORPUS:START -->
387
372
 
388
- > **Version `1.6.2`** · branche `pr-174` · généré le 2026-09-20
373
+ > **Version `1.8.0`** · à publier · branche `main` · généré le 2026-09-24
389
374
  > Seuil de tolérance **5%**
390
375
 
391
376
  <table>
@@ -418,24 +403,24 @@ Ces résultats sont **générés automatiquement** à la fin de `npm run test:co
418
403
 
419
404
  <table>
420
405
  <tr>
421
- <td align="center"><strong>5,23 ms</strong><br/><sub>moyenne</sub></td>
422
- <td align="center"><strong>4,61 ms</strong><br/><sub>médiane</sub></td>
423
- <td align="center"><strong>0,10 ms</strong><br/><sub>min</sub></td>
424
- <td align="center"><strong>577,0 ms</strong><br/><sub>max</sub></td>
406
+ <td align="center"><strong>5,43 ms</strong><br/><sub>moyenne</sub></td>
407
+ <td align="center"><strong>4,72 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>594,4 ms</strong><br/><sub>max</sub></td>
425
410
  </tr>
426
411
  </table>
427
412
 
428
413
  | Corpus | Moyenne | Médiane | Min | Max | p95 | p99 |
429
414
  | :--------------------------------------------- | ------: | ------: | ------: | -------: | ------: | ------: |
430
- | **Généraliste** | 4,37 ms | 3,96 ms | 0,22 ms | 35,5 ms | 7,93 ms | 10,8 ms |
431
- | **Appartement · chauffage individuel (2025)** | 3,49 ms | 3,20 ms | 1,13 ms | 30,2 ms | 6,09 ms | 7,79 ms |
432
- | **Logement individuel (2025)** | 4,96 ms | 4,38 ms | 1,34 ms | 43,1 ms | 9,48 ms | 12,9 ms |
433
- | **Maison individuelle (2025)** | 5,88 ms | 5,34 ms | 1,68 ms | 47,0 ms | 10,5 ms | 14,2 ms |
434
- | **Immeuble · chauffage individuel** | 6,66 ms | 5,73 ms | 0,10 ms | 83,7 ms | 13,6 ms | 21,0 ms |
435
- | **Appartement · chauffage collectif (2025)** | 4,09 ms | 3,75 ms | 1,26 ms | 37,7 ms | 6,88 ms | 9,51 ms |
436
- | **Immeuble · chauffage collectif** | 6,04 ms | 5,23 ms | 0,15 ms | 148,9 ms | 11,5 ms | 21,3 ms |
437
- | **Immeuble · chauffage mixte** | 5,79 ms | 4,77 ms | 0,18 ms | 577,0 ms | 12,4 ms | 20,2 ms |
438
- | **Individuel généré depuis l'immeuble (2026)** | 5,82 ms | 5,08 ms | 1,65 ms | 71,8 ms | 11,1 ms | 16,9 ms |
415
+ | **Généraliste** | 4,33 ms | 3,93 ms | 0,21 ms | 30,4 ms | 7,86 ms | 10,9 ms |
416
+ | **Appartement · chauffage individuel (2025)** | 3,61 ms | 3,20 ms | 1,24 ms | 26,4 ms | 6,48 ms | 10,4 ms |
417
+ | **Logement individuel (2025)** | 5,42 ms | 4,74 ms | 1,53 ms | 61,7 ms | 10,6 ms | 14,8 ms |
418
+ | **Maison individuelle (2025)** | 6,55 ms | 5,93 ms | 1,72 ms | 50,5 ms | 11,9 ms | 16,0 ms |
419
+ | **Immeuble · chauffage individuel** | 6,70 ms | 5,75 ms | 0,11 ms | 111,2 ms | 13,5 ms | 22,6 ms |
420
+ | **Appartement · chauffage collectif (2025)** | 3,84 ms | 3,46 ms | 1,11 ms | 58,2 ms | 6,43 ms | 9,51 ms |
421
+ | **Immeuble · chauffage collectif** | 5,43 ms | 4,58 ms | 0,13 ms | 99,6 ms | 10,2 ms | 18,9 ms |
422
+ | **Immeuble · chauffage mixte** | 6,56 ms | 5,20 ms | 0,21 ms | 594,4 ms | 14,5 ms | 25,1 ms |
423
+ | **Individuel généré depuis l'immeuble (2026)** | 6,44 ms | 5,70 ms | 1,76 ms | 43,1 ms | 12,3 ms | 18,5 ms |
439
424
 
440
425
  <sub>La moyenne est tirée vers le haut par les DPE collectifs, dont le coût atteint plusieurs
441
426
  dizaines de fois la médiane : c’est la médiane qui décrit le cas courant, et p95/p99 le cas
package/conso.js CHANGED
@@ -249,6 +249,7 @@ export default function calc_conso(
249
249
  null,
250
250
  prorataECS,
251
251
  prorataChauffage,
252
+ ecs,
252
253
  conso_aux_distribution_ecs_total
253
254
  ),
254
255
  ep_conso: calc_conso_pond(
@@ -262,6 +263,7 @@ export default function calc_conso(
262
263
  coeffEp,
263
264
  prorataECS,
264
265
  prorataChauffage,
266
+ ecs,
265
267
  conso_aux_distribution_ecs_total
266
268
  ),
267
269
  emission_ges: calc_conso_pond(
@@ -275,6 +277,7 @@ export default function calc_conso(
275
277
  coef_ges,
276
278
  prorataECS,
277
279
  prorataChauffage,
280
+ ecs,
278
281
  conso_aux_distribution_ecs_total
279
282
  ),
280
283
  cout: calc_conso_pond(
@@ -288,6 +291,7 @@ export default function calc_conso(
288
291
  coef_cout,
289
292
  prorataECS,
290
293
  prorataChauffage,
294
+ ecs,
291
295
  conso_aux_distribution_ecs_total
292
296
  )
293
297
  };
@@ -343,7 +347,8 @@ export default function calc_conso(
343
347
  '',
344
348
  null,
345
349
  prorataECS,
346
- prorataChauffage
350
+ prorataChauffage,
351
+ type_energie === 'électricité' ? ecs : []
347
352
  );
348
353
  conso_en = {
349
354
  conso_ch: conso_en._ch,
@@ -474,7 +479,7 @@ function calc_conso_pond(
474
479
  coef,
475
480
  prorataECS,
476
481
  prorataChauffage,
477
- conso_aux_distribution_ecs_total
482
+ ecs_installations
478
483
  ) {
479
484
  const ret = {};
480
485
  ret.auxiliaire_ventilation = vt_list.reduce((acc, vt) => {
@@ -538,11 +543,10 @@ function calc_conso_pond(
538
543
  return acc + getConso(coef, 'électricité auxiliaire', conso);
539
544
  }, 0);
540
545
 
541
- ret.auxiliaire_distribution_ecs = getConso(
542
- coef,
543
- 'électricité auxiliaire',
544
- conso_aux_distribution_ecs_total || 0
545
- );
546
+ ret.auxiliaire_distribution_ecs = ecs_installations.reduce((acc, inst) => {
547
+ const conso = inst.donnee_intermediaire.conso_auxiliaire_distribution_ecs || 0;
548
+ return acc + getConso(coef, 'électricité auxiliaire', conso);
549
+ }, 0);
546
550
 
547
551
  ret.ecs = getEcsConso(gen_ecs, 'conso_ecs', coef, prorataECS, prefix);
548
552
 
package/engine.js CHANGED
@@ -33,7 +33,7 @@ import getFicheTechnique from './ficheTechnique.js';
33
33
  import { ProductionENR } from './16.2_production_enr.js';
34
34
  import DpeSanitizerService from './dpe-sanitizer.service.js';
35
35
 
36
- const LIB_VERSION = '1.7.3';
36
+ const LIB_VERSION = '1.8.1';
37
37
 
38
38
  const dpeSanitizerService = new DpeSanitizerService();
39
39
 
@@ -437,7 +437,8 @@ export function calcul_3cl(inputDpe, options) {
437
437
  virtualisationECS,
438
438
  dpe.logement.caracteristique_generale.surface_habitable_immeuble,
439
439
  dpe.logement.caracteristique_generale.nombre_appartement,
440
- isImmeubleSystemEcsIndividuels
440
+ isImmeubleSystemEcsIndividuels,
441
+ apport_et_besoin.besoin_ecs_par_mois || {}
441
442
  );
442
443
 
443
444
  if (!dpeGenereImmeuble) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open3cl/engine",
3
- "version": "1.7.3",
3
+ "version": "1.8.1",
4
4
  "description": "Open Source 3CL-DPE engine",
5
5
  "main": "index.js",
6
6
  "directories": {
@@ -29,7 +29,9 @@
29
29
  "assets:sync": "node scripts/synchronize_assets.js",
30
30
  "version:update": "node scripts/update_version.js",
31
31
  "reports:preview": "npm run http-server dist/reports/corpus -o",
32
- "reports:readme": "node scripts/generate_corpus_readme.js"
32
+ "reports:readme": "node scripts/generate_corpus_readme.js",
33
+ "reports:stamp": "node scripts/stamp_corpus_release.js",
34
+ "reports:version": "node scripts/corpus_version.js"
33
35
  },
34
36
  "repository": {
35
37
  "type": "git",
@@ -52,6 +54,7 @@
52
54
  "@commitlint/cz-commitlint": "^19.2.0",
53
55
  "@duckdb/node-api": "^1.3.2-alpha.25",
54
56
  "@semantic-release/changelog": "^6.0.3",
57
+ "@semantic-release/commit-analyzer": "^13.0.1",
55
58
  "@semantic-release/exec": "^7.1.0",
56
59
  "@semantic-release/git": "^10.0.1",
57
60
  "@semantic-release/github": "^12.0.2",
@@ -70,6 +73,7 @@
70
73
  "fast-csv": "^5.0.2",
71
74
  "http-server": "^14.1.1",
72
75
  "husky": "^9.0.11",
76
+ "js-yaml": "^4.3.2",
73
77
  "jscpd": "^3.5.10",
74
78
  "piscina": "^5.1.2",
75
79
  "prettier": "^3.2.5",