@open3cl/engine 1.8.8 → 1.8.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/engine.js CHANGED
@@ -33,7 +33,7 @@ import getFicheTechnique from './ficheTechnique.js';
33
33
  import { ProductionENR } from './16.2_production_enr.js';
34
34
  import DpeSanitizerService from './dpe-sanitizer.service.js';
35
35
 
36
- const LIB_VERSION = '1.8.8';
36
+ const LIB_VERSION = '1.8.10';
37
37
 
38
38
  const dpeSanitizerService = new DpeSanitizerService();
39
39
 
@@ -573,7 +573,8 @@ export function calcul_3cl(inputDpe, options) {
573
573
  prorataECS,
574
574
  prorataChauffage,
575
575
  dateDpe,
576
- coef_ep
576
+ coef_ep,
577
+ th === 'immeuble' ? Nb_lgt : 1
577
578
  );
578
579
 
579
580
  const production_electricite = productionENR.calculateEnr(
@@ -595,7 +596,8 @@ export function calcul_3cl(inputDpe, options) {
595
596
  prorataECS,
596
597
  prorataChauffage,
597
598
  dateDpe,
598
- coef_ep_1_7
599
+ coef_ep_1_7,
600
+ th === 'immeuble' ? Nb_lgt : 1
599
601
  );
600
602
 
601
603
  productionENR.calculateEnr(
@@ -618,7 +620,8 @@ export function calcul_3cl(inputDpe, options) {
618
620
  prorataECS,
619
621
  prorataChauffage,
620
622
  dateDpe,
621
- coef_ep_2_3
623
+ coef_ep_2_3,
624
+ th === 'immeuble' ? Nb_lgt : 1
622
625
  );
623
626
 
624
627
  productionENR.calculateEnr(
@@ -52,27 +52,73 @@ export class PerteChRecupService {
52
52
  */
53
53
  pertesGenerateurChRecup(ctx, logement, depensier) {
54
54
  const generateursWithPertesGeneration = this.generateursWithPertesGeneration(logement);
55
+ const generateursEcsSeulsWithPertesGeneration =
56
+ this.generateursEcsSeulsWithPertesGeneration(logement);
55
57
 
56
58
  return mois_liste.reduce((acc, mois) => {
59
+ const nref = this.#tvStore.getData(
60
+ depensier ? 'nref21' : 'nref19',
61
+ ctx.altitude.value,
62
+ ctx.zoneClimatique.value,
63
+ mois,
64
+ ctx.inertie.ilpa
65
+ );
57
66
  return (
58
67
  acc +
59
68
  this.Qrec(
60
69
  generateursWithPertesGeneration,
61
- this.#tvStore.getData(
62
- depensier ? 'nref21' : 'nref19',
63
- ctx.altitude.value,
64
- ctx.zoneClimatique.value,
65
- mois,
66
- ctx.inertie.ilpa
67
- ),
70
+ nref,
68
71
  depensier
69
72
  ? logement.donnees_de_calcul.besoinChauffageDepensierHP[mois]
70
73
  : logement.donnees_de_calcul.besoinChauffageHP[mois]
71
- )
74
+ ) +
75
+ this.QrecEcsSeul(generateursEcsSeulsWithPertesGeneration, nref)
72
76
  );
73
77
  }, 0);
74
78
  }
75
79
 
80
+ /**
81
+ * Générateurs de la collection ECS assurant l'ECS uniquement dont les pertes de génération sont
82
+ * récupérées pour le chauffage (issue #153) :
83
+ * — pertes à l'arrêt Qp0 > 0
84
+ * — situés en volume chauffé
85
+ * — usage ECS uniquement (enum_usage_generateur_id = 2). Les générateurs 'chauffage + ecs' sont
86
+ * déjà pris en compte dans la collection des générateurs de chauffage.
87
+ *
88
+ * @param logement {Logement}
89
+ * @return {GenerateurEcs[]}
90
+ */
91
+ generateursEcsSeulsWithPertesGeneration(logement) {
92
+ const installationsEcs = logement.installation_ecs_collection?.installation_ecs || [];
93
+
94
+ return installationsEcs.flatMap((installation) =>
95
+ (installation.generateur_ecs_collection?.generateur_ecs || []).filter((generateurEcs) => {
96
+ const generateurEcsDE = generateurEcs.donnee_entree || {};
97
+ const generateurEcsDI = generateurEcs.donnee_intermediaire || {};
98
+ return (
99
+ Number(generateurEcsDI.qp0 || 0) > 0 &&
100
+ Number(generateurEcsDE.position_volume_chauffe ?? 0) === 1 &&
101
+ parseInt(generateurEcsDE.enum_usage_generateur_id) === 2
102
+ );
103
+ })
104
+ );
105
+ }
106
+
107
+ /**
108
+ * Pertes récupérées de génération pour le chauffage sur le mois j (Wh) pour les générateurs
109
+ * assurant l'ECS uniquement : Dperj = Nrefj * 1790 / 8760
110
+ *
111
+ * @param generateurs {GenerateurEcs[]}
112
+ * @param nref {number}
113
+ * @return {number}
114
+ */
115
+ QrecEcsSeul(generateurs, nref) {
116
+ return generateurs.reduce((acc, generateur) => {
117
+ const Cper = Number(generateur.donnee_entree.presence_ventouse || 0) === 1 ? 0.75 : 0.5;
118
+ return acc + 0.48 * Cper * generateur.donnee_intermediaire.qp0 * this.#pertes_gen_ecs(nref);
119
+ }, 0);
120
+ }
121
+
76
122
  /**
77
123
  * Pertes récupérées des générateurs de chauffage pour le chauffage
78
124
  * 9.1.1 Consommation de chauffage
@@ -0,0 +1,185 @@
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest';
2
+ import { FrTvStore } from '../../../../dpe/infrastructure/froid/frTv.store.js';
3
+ import { PerteChRecupService } from './perte-ch-recup.service.js';
4
+ import { mois_liste } from '../../../../../utils.js';
5
+
6
+ /** @type {PerteChRecupService} **/
7
+ let service;
8
+
9
+ /** @type {FrTvStore} **/
10
+ let tvStore;
11
+
12
+ /**
13
+ * 9.1.1 Pertes récupérées de génération pour le chauffage
14
+ * Qgen_rec_j = 0,48 * Cper * Qp0 * Dperj
15
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §9.1.1
16
+ */
17
+ describe('Calcul des pertes récupérées de génération pour le chauffage', () => {
18
+ /** @type {Contexte} */
19
+ const ctx = {
20
+ altitude: { value: '400-800m' },
21
+ zoneClimatique: { value: 'h1a' },
22
+ inertie: { ilpa: 0 }
23
+ };
24
+
25
+ /** Besoins de chauffage hors pertes (kWh) : 10 kWh par mois */
26
+ const donneesDeCalcul = {
27
+ besoinChauffageHP: Object.fromEntries(mois_liste.map((m) => [m, 10])),
28
+ besoinChauffageDepensierHP: Object.fromEntries(mois_liste.map((m) => [m, 12]))
29
+ };
30
+
31
+ /** Générateur ECS seul (usage 2), en volume chauffé, Qp0 = 100 W */
32
+ function generateurEcs(de = {}, di = { qp0: 100 }) {
33
+ return {
34
+ donnee_entree: {
35
+ enum_usage_generateur_id: 2,
36
+ position_volume_chauffe: 1,
37
+ presence_ventouse: 0,
38
+ ...de
39
+ },
40
+ donnee_intermediaire: di
41
+ };
42
+ }
43
+
44
+ /** Logement avec une installation ECS et, optionnellement, des générateurs de chauffage */
45
+ function logement(generateursEcs = [], generateursCh = []) {
46
+ return {
47
+ donnees_de_calcul: donneesDeCalcul,
48
+ installation_ecs_collection: {
49
+ installation_ecs: [{ generateur_ecs_collection: { generateur_ecs: generateursEcs } }]
50
+ },
51
+ installation_chauffage_collection: {
52
+ installation_chauffage: [
53
+ { generateur_chauffage_collection: { generateur_chauffage: generateursCh } }
54
+ ]
55
+ }
56
+ };
57
+ }
58
+
59
+ beforeEach(() => {
60
+ tvStore = new FrTvStore();
61
+ service = new PerteChRecupService(tvStore);
62
+ // Nref constant : 100 h (conventionnel) / 120 h (dépensier) pour chacun des 12 mois
63
+ vi.spyOn(tvStore, 'getData').mockImplementation((type) => (type === 'nref21' ? 120 : 100));
64
+ });
65
+
66
+ describe('Générateurs ECS assurant l’ECS uniquement (issue #153)', () => {
67
+ test('ECS seul sans ventouse : 0,48 * 0,5 * Qp0 * Nref * 1790 / 8760 par mois', () => {
68
+ const result = service.execute(ctx, logement([generateurEcs()]));
69
+
70
+ // 12 * 0.48 * 0.5 * 100 * (100 * 1790 / 8760) / 1000 = 5.884931506... kWh
71
+ expect(result.pertes_generateur_ch_recup).toBeCloseTo(5.884931506849315, 9);
72
+ // 12 * 0.48 * 0.5 * 100 * (120 * 1790 / 8760) / 1000 = 7.061917808... kWh
73
+ expect(result.pertes_generateur_ch_recup_depensier).toBeCloseTo(7.061917808219178, 9);
74
+ });
75
+
76
+ test('ECS seul avec ventouse : Cper = 0,75', () => {
77
+ const result = service.execute(ctx, logement([generateurEcs({ presence_ventouse: 1 })]));
78
+ expect(result.pertes_generateur_ch_recup).toBeCloseTo(1.5 * 5.884931506849315, 9);
79
+ });
80
+
81
+ test.each([
82
+ { cas: 'hors volume chauffé', de: { position_volume_chauffe: 0 }, di: { qp0: 100 } },
83
+ {
84
+ cas: 'position non renseignée',
85
+ de: { position_volume_chauffe: undefined },
86
+ di: { qp0: 100 }
87
+ },
88
+ {
89
+ cas: "usage 'chauffage + ecs' (déjà compté)",
90
+ de: { enum_usage_generateur_id: 3 },
91
+ di: { qp0: 100 }
92
+ },
93
+ { cas: 'Qp0 nul', de: {}, di: { qp0: 0 } },
94
+ { cas: 'Qp0 absent', de: {}, di: null }
95
+ ])('pas de récupération pour un générateur ECS $cas', ({ de, di }) => {
96
+ const result = service.execute(ctx, logement([generateurEcs(de, di)]));
97
+ expect(result.pertes_generateur_ch_recup).toBe(0);
98
+ expect(result.pertes_generateur_ch_recup_depensier).toBe(0);
99
+ });
100
+
101
+ test('logement sans installation ECS ni chauffage : aucune récupération', () => {
102
+ const result = service.execute(ctx, { donnees_de_calcul: donneesDeCalcul });
103
+ expect(result.pertes_generateur_ch_recup).toBe(0);
104
+ });
105
+
106
+ test('installation ECS sans collection de générateurs : aucune récupération', () => {
107
+ const result = service.execute(ctx, {
108
+ donnees_de_calcul: donneesDeCalcul,
109
+ installation_ecs_collection: { installation_ecs: [{}] }
110
+ });
111
+ expect(result.pertes_generateur_ch_recup).toBe(0);
112
+ });
113
+ });
114
+
115
+ describe('Générateurs de chauffage', () => {
116
+ /** Générateur de chauffage en volume chauffé, Pn = 10 000 W, Qp0 = 100 W */
117
+ function generateurCh(de = {}) {
118
+ return {
119
+ donnee_entree: {
120
+ position_volume_chauffe: 1,
121
+ enum_type_generateur_ch_id: 85,
122
+ enum_usage_generateur_id: 1,
123
+ ...de
124
+ },
125
+ donnee_intermediaire: { pn: 10000, qp0: 100 }
126
+ };
127
+ }
128
+
129
+ test('usage chauffage : Dperj = min(Nref, 1,3 * Bch / (0,3 * Pn))', () => {
130
+ const result = service.execute(ctx, logement([], [generateurCh()]));
131
+ // Dperj = min(100, 1.3 * 10 * 1000 / 3000) = 4.333.. h
132
+ // 12 * 0.48 * 0.5 * 100 * 4.333.. / 1000 = 1.248 kWh
133
+ expect(result.pertes_generateur_ch_recup).toBeCloseTo(1.248, 9);
134
+ });
135
+
136
+ test('usage ECS : Dperj = Nref * 1790 / 8760', () => {
137
+ const result = service.execute(
138
+ ctx,
139
+ logement([], [generateurCh({ enum_usage_generateur_id: 2 })])
140
+ );
141
+ expect(result.pertes_generateur_ch_recup).toBeCloseTo(5.884931506849315, 9);
142
+ });
143
+
144
+ test('usage chauffage + ECS avec ventouse : cumul des deux durées, Cper = 0,75', () => {
145
+ const result = service.execute(
146
+ ctx,
147
+ logement([], [generateurCh({ enum_usage_generateur_id: 3, presence_ventouse: 1 })])
148
+ );
149
+ // Dperj = min(100, 4.333.. + 20.4337..) ; 12 * 0.48 * 0.75 * 100 * Dperj / 1000
150
+ expect(result.pertes_generateur_ch_recup).toBeCloseTo(10.699397260273972, 9);
151
+ });
152
+
153
+ test.each([
154
+ { cas: 'hors volume chauffé', de: { position_volume_chauffe: 0 } },
155
+ { cas: 'à air chaud (50)', de: { enum_type_generateur_ch_id: 50 } },
156
+ { cas: 'à air chaud (52)', de: { enum_type_generateur_ch_id: 52 } }
157
+ ])('pas de récupération pour un générateur de chauffage $cas', ({ de }) => {
158
+ const result = service.execute(ctx, logement([], [generateurCh(de)]));
159
+ expect(result.pertes_generateur_ch_recup).toBe(0);
160
+ });
161
+
162
+ test('installation de chauffage sans collection de générateurs : aucune récupération', () => {
163
+ const result = service.execute(ctx, {
164
+ donnees_de_calcul: donneesDeCalcul,
165
+ installation_chauffage_collection: { installation_chauffage: [{}] }
166
+ });
167
+ expect(result.pertes_generateur_ch_recup).toBe(0);
168
+ });
169
+
170
+ test('cumul générateur de chauffage + générateur ECS seul', () => {
171
+ const result = service.execute(ctx, logement([generateurEcs()], [generateurCh()]));
172
+ expect(result.pertes_generateur_ch_recup).toBeCloseTo(1.248 + 5.884931506849315, 9);
173
+ });
174
+
175
+ test('un générateur ECS mixte (usage 3) n’est pas compté deux fois', () => {
176
+ const ch = generateurCh({ enum_usage_generateur_id: 3 });
177
+ const seul = service.execute(ctx, logement([], [ch]));
178
+ const avecEcsMixte = service.execute(
179
+ ctx,
180
+ logement([generateurEcs({ enum_usage_generateur_id: 3 })], [ch])
181
+ );
182
+ expect(avecEcsMixte.pertes_generateur_ch_recup).toBe(seul.pertes_generateur_ch_recup);
183
+ });
184
+ });
185
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open3cl/engine",
3
- "version": "1.8.8",
3
+ "version": "1.8.10",
4
4
  "description": "Open Source 3CL-DPE engine",
5
5
  "main": "index.js",
6
6
  "directories": {
package/utils.js CHANGED
@@ -277,10 +277,15 @@ export function requestInput(de, du, field, type) {
277
277
  return enums[field][de[enum_name]];
278
278
  } else {
279
279
  // not enums
280
+ // `!type` couvre aussi NaN, 0 et '' (valeurs falsy)
280
281
  if (!type) {
281
282
  console.error(`requestInput: type is not defined for ${field}`);
282
283
  return null;
283
284
  }
285
+ if (!['string', 'number', 'boolean'].includes(typeof type)) {
286
+ console.error(`requestInput: type has an unexpected type (${typeof type}) for ${field}`);
287
+ return null;
288
+ }
284
289
  du[field] = type;
285
290
  return de[field];
286
291
  }
package/utils.spec.js CHANGED
@@ -287,6 +287,59 @@ describe('Utils unit tests', () => {
287
287
  spy.mockRestore();
288
288
  });
289
289
 
290
+ test.each([
291
+ ['NaN', Number.NaN],
292
+ ['0', 0],
293
+ ['une chaîne vide', '']
294
+ ])(
295
+ 'requestInput : champ non enum avec %s comme type est traité comme un type absent',
296
+ (_libelle, type) => {
297
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
298
+ const de = { epaisseur: 5 };
299
+ const du = {};
300
+ expect(requestInput(de, du, 'epaisseur', type)).toBeNull();
301
+ expect(spy).toHaveBeenCalledWith('requestInput: type is not defined for epaisseur');
302
+ // Le type invalide n'est pas publié dans donnee_utilisateur
303
+ expect(du).toStrictEqual({});
304
+ spy.mockRestore();
305
+ }
306
+ );
307
+
308
+ test.each([
309
+ ['un objet', { a: 1 }, 'object'],
310
+ ['un tableau', ['number'], 'object'],
311
+ ['une fonction', () => 'number', 'function'],
312
+ ['un symbole', Symbol('number'), 'symbol'],
313
+ ['un bigint', 10n, 'bigint']
314
+ ])(
315
+ 'requestInput : champ non enum avec %s comme type journalise une erreur et retourne null',
316
+ (_libelle, type, typeAttendu) => {
317
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
318
+ const de = { epaisseur: 5 };
319
+ const du = {};
320
+ expect(requestInput(de, du, 'epaisseur', type)).toBeNull();
321
+ expect(spy).toHaveBeenCalledWith(
322
+ `requestInput: type has an unexpected type (${typeAttendu}) for epaisseur`
323
+ );
324
+ expect(du).toStrictEqual({});
325
+ spy.mockRestore();
326
+ }
327
+ );
328
+
329
+ test.each([
330
+ ['une chaîne', 'string'],
331
+ ['un nombre', 1],
332
+ ['un booléen', true]
333
+ ])('requestInput : champ non enum avec %s comme type est accepté', (_libelle, type) => {
334
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
335
+ const de = { epaisseur: 5 };
336
+ const du = {};
337
+ expect(requestInput(de, du, 'epaisseur', type)).toBe(5);
338
+ expect(du.epaisseur).toBe(type);
339
+ expect(spy).not.toHaveBeenCalled();
340
+ spy.mockRestore();
341
+ });
342
+
290
343
  test('requestInputID : sans type publie les clés et retourne l identifiant', () => {
291
344
  const de = { enum_materiaux_id: '1' };
292
345
  const du = {};