@open3cl/engine 1.8.6 → 1.8.8

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.
@@ -14,11 +14,6 @@ export function calc_sse_j(bv_list, ets, ca, zc, mois) {
14
14
  return sseBaiesExt;
15
15
  }
16
16
 
17
- // Certaines vérandas sont dupliqués dans les DPE.
18
- if (Array.isArray(ets)) {
19
- ets = ets[0];
20
- }
21
-
22
17
  /**
23
18
  * 6.3 Traitement des espaces tampons solarisés
24
19
  * Sse_ver n'est calculée que pour les baies vitrées qui séparent le logement de l'espace tampon
@@ -28,6 +23,82 @@ export function calc_sse_j(bv_list, ets, ca, zc, mois) {
28
23
  return sseBaiesExt;
29
24
  }
30
25
 
26
+ /**
27
+ * Plusieurs espaces tampons solarisés (issue #101) : T, bver, Sst et Ssd sont propres à chaque
28
+ * véranda. Sse = Sse_ext + Σ(v) Sse_veranda_v, chaque baie adjacence 10 n'étant comptée que dans
29
+ * la véranda sur laquelle elle donne (baie.reference_lnc = ets.reference).
30
+ */
31
+ const etsList = dedoublonnerEts(Array.isArray(ets) ? ets : [ets]);
32
+ const baiesParEts = rattacherBaiesAuxEts(baiesAdjVeranda, etsList);
33
+
34
+ return etsList.reduce(
35
+ (acc, etsItem, idx) => acc + calc_sse_veranda_j(etsItem, baiesParEts[idx], zc, mois),
36
+ sseBaiesExt
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Certaines vérandas sont dupliquées dans les DPE : une même véranda (même référence, ou à défaut
42
+ * même contenu) ne doit être comptée qu'une seule fois.
43
+ *
44
+ * @param etsList {EtsItem[]}
45
+ * @returns {EtsItem[]}
46
+ */
47
+ export function dedoublonnerEts(etsList) {
48
+ const vues = new Set();
49
+ return etsList.filter((etsItem) => {
50
+ if (!etsItem) return false;
51
+ const reference = etsItem.donnee_entree?.reference;
52
+ const cle = reference ? `ref:${reference}` : `json:${JSON.stringify(etsItem)}`;
53
+ if (vues.has(cle)) return false;
54
+ vues.add(cle);
55
+ return true;
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Rattache chaque baie vitrée adjacence 10 à l'espace tampon sur lequel elle donne
61
+ * (baie.donnee_entree.reference_lnc = ets.donnee_entree.reference).
62
+ *
63
+ * Une baie sans lien exploitable (reference_lnc absente ou ne correspondant à aucune véranda) est
64
+ * rattachée à la première véranda : c'est le comportement historique, et le seul possible quand le
65
+ * DPE ne comporte qu'une véranda. La méthode ne permet pas de faire mieux pour une donnée manquante.
66
+ *
67
+ * @param baiesAdjVeranda {BaieVitreeItem[]}
68
+ * @param etsList {EtsItem[]}
69
+ * @returns {BaieVitreeItem[][]} baies par véranda, dans l'ordre de etsList
70
+ */
71
+ export function rattacherBaiesAuxEts(baiesAdjVeranda, etsList) {
72
+ const baiesParEts = etsList.map(() => []);
73
+ // Aucune véranda exploitable : les baies adjacence 10 ne peuvent être rattachées à aucun ETS
74
+ if (etsList.length === 0) {
75
+ return baiesParEts;
76
+ }
77
+ baiesAdjVeranda.forEach((bv) => {
78
+ const referenceLnc = bv.donnee_entree.reference_lnc;
79
+ const idx = referenceLnc
80
+ ? etsList.findIndex((etsItem) => etsItem.donnee_entree?.reference === referenceLnc)
81
+ : -1;
82
+ baiesParEts[idx === -1 ? 0 : idx].push(bv);
83
+ });
84
+ return baiesParEts;
85
+ }
86
+
87
+ /**
88
+ * Surface sud équivalente apportée par une véranda sur le mois j (§6.3).
89
+ *
90
+ * @param ets {EtsItem}
91
+ * @param baiesAdjVeranda {BaieVitreeItem[]} baies séparant le logement de cette véranda
92
+ * @param zc {string}
93
+ * @param mois {string}
94
+ * @returns {number}
95
+ */
96
+ function calc_sse_veranda_j(ets, baiesAdjVeranda, zc, mois) {
97
+ // Véranda sans baie vitrée vers le logement : aucun apport solaire
98
+ if (baiesAdjVeranda.length === 0) {
99
+ return 0;
100
+ }
101
+
31
102
  const bver = ets.donnee_intermediaire.bver;
32
103
  const T = ets.donnee_intermediaire.coef_transparence_ets;
33
104
 
@@ -80,7 +151,7 @@ export function calc_sse_j(bv_list, ets, ca, zc, mois) {
80
151
  */
81
152
  const SseVerandaj = Ssdj + ssIndj * bver;
82
153
 
83
- return sseBaiesExt + SseVerandaj;
154
+ return SseVerandaj;
84
155
  }
85
156
 
86
157
  /**
@@ -8,7 +8,7 @@ import { describe, expect, test, vi } from 'vitest';
8
8
  */
9
9
  vi.mock('./enums.js', () => ({
10
10
  default: {
11
- orientation: { 1: 'sud', 2: 'horizontal' },
11
+ orientation: { 1: 'sud', 2: 'horizontal', 3: 'est', 4: 'nord' },
12
12
  inclinaison_vitrage: { 3: 'verticale', 4: 'horizontal' }
13
13
  }
14
14
  }));
@@ -18,6 +18,11 @@ vi.mock('./tv.js', () => ({
18
18
  c1: {
19
19
  h1a: {
20
20
  Janvier: { 'sud verticale': 0.5, horizontal: 0.3 }
21
+ },
22
+ // Coefficients C1 de janvier en h1a (sud = 1, est = 0,40, nord = 0,31) pour les cas
23
+ // de test d'Olivier (issue #101)
24
+ h1b: {
25
+ Janvier: { 'sud verticale': 1, 'est verticale': 0.4, 'nord verticale': 0.31 }
21
26
  }
22
27
  }
23
28
  }
@@ -28,7 +33,8 @@ vi.mock('./utils.js', () => ({
28
33
  mois_liste: ['Janvier']
29
34
  }));
30
35
 
31
- const { calc_sse_j, calc_sse } = await import('./6.2_surface_sud_equivalente.js');
36
+ const { calc_sse_j, calc_sse, dedoublonnerEts, rattacherBaiesAuxEts } =
37
+ await import('./6.2_surface_sud_equivalente.js');
32
38
 
33
39
  /** Fabrique une baie vitrée avec les données d'entrée/intermédiaires utiles au calcul. */
34
40
  function baie({ adjacence, surface, orientationId = '1', inclinaisonId = '3', sw = 1 }) {
@@ -212,3 +218,133 @@ describe('calc_sse - surface sud équivalente annuelle', () => {
212
218
  expect(calc_sse('ca1', 'h1a', bvList, null)).toBeCloseTo(0.4, 10);
213
219
  });
214
220
  });
221
+
222
+ /**
223
+ * 6.3 Plusieurs espaces tampons solarisés (issue #101) : T, bver, Sst et Ssd sont propres à chaque
224
+ * véranda ; Sse = Sse_ext + Σ(v) Sse_veranda_v. Une baie adjacence 10 n'entre que dans la véranda
225
+ * sur laquelle elle donne (reference_lnc = ets.reference).
226
+ * Cas de test fournis par Olivier (thermicien), zone h1b mockée avec C1 sud = 1, est = 0,40,
227
+ * nord = 0,31 ; Fe = 1.
228
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §6.3
229
+ */
230
+ describe('calc_sse_j - plusieurs espaces tampons solarisés (issue #101)', () => {
231
+ const SUD = '1';
232
+ const EST = '3';
233
+ const NORD = '4';
234
+
235
+ function baieInt(surface, orientationId, referenceLnc) {
236
+ const bv = baie({ adjacence: '10', surface, orientationId, sw: 0.47 });
237
+ if (referenceLnc !== undefined) bv.donnee_entree.reference_lnc = referenceLnc;
238
+ return bv;
239
+ }
240
+
241
+ function veranda(reference, T, bver, baiesExt) {
242
+ return {
243
+ donnee_entree: { reference },
244
+ donnee_intermediaire: { bver, coef_transparence_ets: T },
245
+ baie_ets_collection: {
246
+ baie_ets: baiesExt.map(([surface, orientationId]) =>
247
+ baie({ adjacence: '1', surface, orientationId })
248
+ )
249
+ }
250
+ };
251
+ }
252
+
253
+ // Baie extérieure 10 m² sud, Sw = 0,5 => Sse_ext = 5
254
+ const baieExt = baie({ adjacence: '1', surface: 10, orientationId: SUD, sw: 0.5 });
255
+ // V1 : T = 0,62, bver = 0,55, baies vers l'extérieur 8 m² sud + 4 m² est
256
+ const V1 = veranda('V1', 0.62, 0.55, [
257
+ [8, SUD],
258
+ [4, EST]
259
+ ]);
260
+ // V2 : T = 0,45, bver = 0,85, baie vers l'extérieur 6 m² nord
261
+ const V2 = veranda('V2', 0.45, 0.85, [[6, NORD]]);
262
+ const baiesV1 = [baieInt(4, SUD, 'V1'), baieInt(2, SUD, 'V1')];
263
+ const baiesV2 = [baieInt(2, NORD, 'V2'), baieInt(1, NORD, 'V2')];
264
+
265
+ // SseV1 = 1,7484 + (4,992 - 1,7484) * 0,55 = 3,53238
266
+ const SSE_V1 = 3.53238;
267
+ // SseV2 = 0,196695 + (0,71424 - 0,196695) * 0,85 = 0,63660825
268
+ const SSE_V2 = 0.63660825;
269
+
270
+ test('CT1 - cas de l’issue : Sse = Sse_ext + SseV1 + SseV2 avec T et bver propres', () => {
271
+ const bvList = [baieExt, ...baiesV1, ...baiesV2];
272
+ expect(calc_sse_j(bvList, [V1, V2], 'ca1', 'h1b', 'Janvier')).toBeCloseTo(
273
+ 5 + SSE_V1 + SSE_V2,
274
+ 9
275
+ );
276
+ });
277
+
278
+ test('CT2 - seules les baies de V2 : le Sst de V1 n’est pas appliqué', () => {
279
+ const bvList = [baieExt, ...baiesV2];
280
+ expect(calc_sse_j(bvList, [V1, V2], 'ca1', 'h1b', 'Janvier')).toBeCloseTo(5 + SSE_V2, 9);
281
+ });
282
+
283
+ test('CT3 - seules les baies de V1 : V2 sans baie vers le logement apporte 0', () => {
284
+ const bvList = [baieExt, ...baiesV1];
285
+ expect(calc_sse_j(bvList, [V1, V2], 'ca1', 'h1b', 'Janvier')).toBeCloseTo(5 + SSE_V1, 9);
286
+ });
287
+
288
+ test('CT4 - véranda dupliquée : pas de double comptage', () => {
289
+ const bvList = [baieExt, ...baiesV1];
290
+ expect(calc_sse_j(bvList, [V1, structuredClone(V1)], 'ca1', 'h1b', 'Janvier')).toBeCloseTo(
291
+ 5 + SSE_V1,
292
+ 9
293
+ );
294
+ });
295
+
296
+ test('baies sans reference_lnc : rattachées à la première véranda (comportement historique)', () => {
297
+ const bvList = [baieExt, baieInt(4, SUD), baieInt(2, SUD)];
298
+ expect(calc_sse_j(bvList, [V1, V2], 'ca1', 'h1b', 'Janvier')).toBeCloseTo(5 + SSE_V1, 9);
299
+ });
300
+
301
+ test('l’ordre des vérandas dans la collection est sans effet', () => {
302
+ const bvList = [baieExt, ...baiesV1, ...baiesV2];
303
+ expect(calc_sse_j(bvList, [V2, V1], 'ca1', 'h1b', 'Janvier')).toBeCloseTo(
304
+ 5 + SSE_V1 + SSE_V2,
305
+ 9
306
+ );
307
+ });
308
+ });
309
+
310
+ describe('dedoublonnerEts / rattacherBaiesAuxEts', () => {
311
+ test('dédoublonnage par référence, à défaut par contenu identique ; éléments vides ignorés', () => {
312
+ const a = { donnee_entree: { reference: 'A' }, x: 1 };
313
+ const aBis = { donnee_entree: { reference: 'A' }, x: 2 };
314
+ const sansRef = { donnee_intermediaire: { bver: 1 } };
315
+ const autreSansRef = { donnee_intermediaire: { bver: 2 } };
316
+ expect(
317
+ dedoublonnerEts([a, aBis, sansRef, structuredClone(sansRef), autreSansRef, null])
318
+ ).toEqual([a, sansRef, autreSansRef]);
319
+ });
320
+
321
+ test('rattachement par reference_lnc ; référence inconnue => première véranda', () => {
322
+ const ets = [{ donnee_entree: { reference: 'A' } }, { donnee_entree: { reference: 'B' } }];
323
+ const bA = { donnee_entree: { reference_lnc: 'A' } };
324
+ const bB = { donnee_entree: { reference_lnc: 'B' } };
325
+ const bInconnue = { donnee_entree: { reference_lnc: 'Z' } };
326
+ const bSans = { donnee_entree: {} };
327
+ expect(rattacherBaiesAuxEts([bA, bB, bInconnue, bSans], ets)).toEqual([
328
+ [bA, bInconnue, bSans],
329
+ [bB]
330
+ ]);
331
+ });
332
+
333
+ test('aucune véranda exploitable (ets = [null]) : baies non rattachées, seules les baies extérieures comptent', () => {
334
+ const bA = { donnee_entree: { reference_lnc: 'A' } };
335
+ expect(rattacherBaiesAuxEts([bA], [])).toEqual([]);
336
+ const bvList = [
337
+ baie({ adjacence: '1', surface: 10, sw: 0.5 }),
338
+ baie({ adjacence: '10', surface: 4 })
339
+ ];
340
+ // c1 h1a janvier sud = 0,5 => 10 × 0,5 × 0,5
341
+ expect(calc_sse_j(bvList, [null], 'ca1', 'h1a', 'Janvier')).toBeCloseTo(2.5, 9);
342
+ });
343
+
344
+ test('véranda sans donnee_entree : seules les baies sans lien lui sont rattachées', () => {
345
+ const ets = [{}, { donnee_entree: { reference: 'B' } }];
346
+ const bB = { donnee_entree: { reference_lnc: 'B' } };
347
+ const bSans = { donnee_entree: {} };
348
+ expect(rattacherBaiesAuxEts([bB, bSans], ets)).toEqual([[bSans], [bB]]);
349
+ });
350
+ });
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.5`** · à publier · branche `main` · généré le 2026-10-01
373
+ > **Version `1.8.7`** · branche `main` · généré le 2026-10-02
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,01 ms</strong><br/><sub>moyenne</sub></td>
407
- <td align="center"><strong>4,31 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>567,2 ms</strong><br/><sub>max</sub></td>
406
+ <td align="center"><strong>4,80 ms</strong><br/><sub>moyenne</sub></td>
407
+ <td align="center"><strong>4,20 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>518,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,04 ms | 3,63 ms | 0,21 ms | 24,9 ms | 7,45 ms | 10,8 ms |
416
- | **Appartement · chauffage individuel (2025)** | 3,20 ms | 2,93 ms | 1,18 ms | 28,6 ms | 5,51 ms | 7,45 ms |
417
- | **Logement individuel (2025)** | 4,73 ms | 4,22 ms | 1,38 ms | 33,1 ms | 8,88 ms | 12,4 ms |
418
- | **Maison individuelle (2025)** | 5,37 ms | 4,79 ms | 1,68 ms | 52,5 ms | 9,69 ms | 13,3 ms |
419
- | **Immeuble · chauffage individuel** | 7,43 ms | 6,08 ms | 0,19 ms | 137,8 ms | 16,0 ms | 28,0 ms |
420
- | **Appartement · chauffage collectif (2025)** | 3,82 ms | 3,40 ms | 1,24 ms | 31,6 ms | 6,52 ms | 8,64 ms |
421
- | **Immeuble · chauffage collectif** | 5,47 ms | 4,55 ms | 0,13 ms | 104,3 ms | 10,4 ms | 19,2 ms |
422
- | **Immeuble · chauffage mixte** | 5,31 ms | 4,38 ms | 0,22 ms | 567,2 ms | 10,9 ms | 17,8 ms |
423
- | **Individuel généré depuis l'immeuble (2026)** | 5,72 ms | 4,85 ms | 1,75 ms | 61,2 ms | 11,0 ms | 17,3 ms |
415
+ | **Généraliste** | 4,33 ms | 3,90 ms | 0,20 ms | 36,9 ms | 7,87 ms | 11,1 ms |
416
+ | **Appartement · chauffage individuel (2025)** | 3,48 ms | 3,15 ms | 1,06 ms | 34,8 ms | 6,09 ms | 7,83 ms |
417
+ | **Logement individuel (2025)** | 4,86 ms | 4,30 ms | 1,30 ms | 29,8 ms | 9,41 ms | 13,1 ms |
418
+ | **Maison individuelle (2025)** | 5,48 ms | 4,96 ms | 1,65 ms | 35,8 ms | 9,87 ms | 12,8 ms |
419
+ | **Immeuble · chauffage individuel** | 5,89 ms | 5,04 ms | 0,11 ms | 75,0 ms | 11,9 ms | 18,7 ms |
420
+ | **Appartement · chauffage collectif (2025)** | 3,86 ms | 3,58 ms | 1,18 ms | 31,7 ms | 6,29 ms | 7,87 ms |
421
+ | **Immeuble · chauffage collectif** | 5,21 ms | 4,43 ms | 0,11 ms | 110,0 ms | 9,58 ms | 16,9 ms |
422
+ | **Immeuble · chauffage mixte** | 4,89 ms | 4,01 ms | 0,20 ms | 518,4 ms | 10,0 ms | 17,0 ms |
423
+ | **Individuel généré depuis l'immeuble (2026)** | 5,22 ms | 4,47 ms | 1,61 ms | 40,6 ms | 9,86 ms | 15,4 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
@@ -10,7 +10,11 @@ export class FileStore {
10
10
  * @return {Promise<any>}
11
11
  */
12
12
  async downloadXlsxFileAndConvertToJson(url) {
13
- return fetch(url)
13
+ const parsedUrl = new URL(url);
14
+ if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
15
+ throw new Error(`Unsupported protocol for url: ${url}`);
16
+ }
17
+ return fetch(parsedUrl)
14
18
  .then((res) => res.arrayBuffer())
15
19
  .then((buffer) =>
16
20
  this.#excelWorkBookToJson(XLSX.read(buffer, { type: 'string', raw: false }))
@@ -39,6 +39,7 @@ describe('FileStore', () => {
39
39
  vi.mocked(XLSX.utils.sheet_to_json).mockReset();
40
40
  vi.mocked(XLSX.utils.encode_col).mockClear();
41
41
  vi.mocked(XLSX.utils.encode_row).mockClear();
42
+ vi.mocked(global.fetch).mockClear();
42
43
 
43
44
  // Comportements par défaut « nominaux ».
44
45
  vi.mocked(readFile).mockImplementation((path, cb) => cb(null, Buffer.from(new ArrayBuffer(1))));
@@ -57,13 +58,44 @@ describe('FileStore', () => {
57
58
  const output = await fileStore.downloadXlsxFileAndConvertToJson('http://localhost:8080');
58
59
 
59
60
  expect(output).toEqual({ Sheet1: [] });
60
- // Le buffer téléchargé est lu par XLSX avec les bonnes options.
61
- expect(fetch).toHaveBeenCalledWith('http://localhost:8080');
61
+ // La protection CWE-918 construit une véritable instance `URL` (validée) avant l'appel à
62
+ // `fetch` : on vérifie le type et le `href` réel plutôt qu'une égalité de chaîne, car
63
+ // `new URL()` normalise l'URL (ajout du `/` final).
64
+ expect(fetch).toHaveBeenCalledTimes(1);
65
+ const [urlArgument] = vi.mocked(fetch).mock.calls[0];
66
+ expect(urlArgument).toBeInstanceOf(URL);
67
+ expect(urlArgument.href).toBe('http://localhost:8080/');
62
68
  expect(XLSX.read).toHaveBeenCalledWith(expect.anything(), {
63
69
  type: 'string',
64
70
  raw: false
65
71
  });
66
72
  });
73
+
74
+ describe('validation du protocole (CWE-918)', () => {
75
+ test.each(['http://localhost:8080', 'https://localhost:8080'])(
76
+ 'accepte les URLs avec le protocole %s',
77
+ async (url) => {
78
+ const fileStore = new FileStore();
79
+
80
+ await expect(fileStore.downloadXlsxFileAndConvertToJson(url)).resolves.toEqual({
81
+ Sheet1: []
82
+ });
83
+
84
+ expect(fetch).toHaveBeenCalledTimes(1);
85
+ }
86
+ );
87
+
88
+ test("rejette une URL avec un protocole non http/https (ex. file://) et n'appelle pas fetch", async () => {
89
+ const fileStore = new FileStore();
90
+ const url = 'file:///etc/passwd';
91
+
92
+ await expect(fileStore.downloadXlsxFileAndConvertToJson(url)).rejects.toThrow(
93
+ `Unsupported protocol for url: ${url}`
94
+ );
95
+
96
+ expect(fetch).not.toHaveBeenCalled();
97
+ });
98
+ });
67
99
  });
68
100
 
69
101
  describe('readLocalOdsFileAndConvertToJson', () => {
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.6';
36
+ const LIB_VERSION = '1.8.8';
37
37
 
38
38
  const dpeSanitizerService = new DpeSanitizerService();
39
39
 
@@ -42,88 +42,154 @@ export class SurfaceSudEquivalenteService {
42
42
  */
43
43
  ssdMois(ctx, enveloppe, mois) {
44
44
  const baiesVitrees = enveloppe.baie_vitree_collection?.baie_vitree || [];
45
- let ets = enveloppe.ets_collection?.ets || [];
45
+ const zc = ctx.zoneClimatique.id;
46
46
 
47
- let SseVerandaj = 0;
47
+ /**
48
+ * Baies vitrées donnant directement sur l'extérieur
49
+ */
50
+ const sseBaiesExt = baiesVitrees
51
+ .filter((bv) => parseInt(bv.donnee_entree.enum_type_adjacence_id) === 1)
52
+ .reduce((acc, bv) => acc + this.ssdBaieMois(bv, zc, mois), 0);
48
53
 
49
54
  /**
50
- * Si une véranda est présente, on calcul l'mpact de l’espace tampon solarisé sur les apports solaires à travers
51
- * les baies vitrées qui séparent le logement de l'espace tampon
52
- *
53
55
  * 6.3 Traitement des espaces tampons solarisés
54
56
  * 10 - 'espace tampon solarisé (véranda,loggia fermée)'
57
+ *
58
+ * Plusieurs espaces tampons solarisés (issue #101) : T, bver, Sst et Ssd sont propres à chaque
59
+ * véranda. Sse = Sse_ext + Σ(v) Sse_veranda_v, chaque baie adjacence 10 n'étant comptée que dans
60
+ * la véranda sur laquelle elle donne (baie.reference_lnc = ets.reference).
61
+ * Sse_ver n'est calculée que pour les baies vitrées qui séparent le logement de l'espace tampon :
62
+ * sans aucune de ces baies, les apports de l'espace tampon n'entrent pas dans le logement.
55
63
  */
56
- if (ets) {
57
- // Certaines vérandas sont dupliqués dans les DPE.
58
- if (Array.isArray(ets)) {
59
- ets = ets[0];
60
- }
61
-
62
- if (ets) {
63
- const bver = ets.donnee_intermediaire.bver;
64
- const T = ets.donnee_intermediaire.coef_transparence_ets;
65
-
66
- /**
67
- * Surface sud équivalente représentant l’impact des apports solaires associés au rayonnement solaire
68
- * traversant directement l’espace tampon pour arriver dans la partie habitable du logement
69
- * Calculés pour les baies vitrées qui séparent le logement de l'espace tampon
70
- * @type {number}
71
- */
72
- const Ssdj = this.getBaiesSurEspaceTampon(baiesVitrees).reduce((acc, bv) => {
73
- return acc + T * this.ssdBaieMois(bv, ctx.zoneClimatique.id, mois);
74
- }, 0);
75
-
76
- /**
77
- * Surface sud équivalente représentant les apports solaires indirects dans le logement
78
- */
79
- let baies = ets.baie_ets_collection.baie_ets;
80
-
81
- if (!Array.isArray(baies)) {
82
- baies = [baies];
83
- }
84
-
85
- /**
86
- * Apports totaux à travers l'espace tampon
87
- * @type {number}
88
- */
89
- const Sstj = baies.reduce((acc, bv) => {
90
- return acc + this.ssdBaieMois(bv, ctx.zoneClimatique.id, mois, 0.8 * T + 0.024);
91
- }, 0);
92
-
93
- /**
94
- * Surface sud équivalente représentant l’impact des apports solaires indirects associés au rayonnement
95
- * solaire entrant dans la partie habitable du logement après de multiples réflexions dans l’espace tampon solarisé
96
- * @type {number}
97
- */
98
- const Ssindj = Sstj - Ssdj;
99
-
100
- /**
101
- * Impact de l’espace tampon solarisé sur les apports solaires à travers les baies vitrées qui séparent le logement
102
- * de l'espace tampon
103
- * @type {number}
104
- */
105
- SseVerandaj = Ssdj + Ssindj * bver;
106
- }
64
+ const etsList = this.dedoublonnerEts(this.getEtsList(enveloppe));
65
+ const baiesParEts = this.rattacherBaiesAuxEts(
66
+ this.getBaiesSurEspaceTampon(baiesVitrees),
67
+ etsList
68
+ );
69
+
70
+ return etsList.reduce(
71
+ (acc, ets, idx) => acc + this.sseVerandaMois(ets, baiesParEts[idx], zc, mois),
72
+ sseBaiesExt
73
+ );
74
+ }
75
+
76
+ /**
77
+ * Surface sud équivalente apportée par une véranda sur un mois donné (§6.3)
78
+ *
79
+ * @param ets {Ets}
80
+ * @param baiesAdjVeranda {BaieVitree[]} baies vitrées séparant le logement de cette véranda
81
+ * @param zc {string} zone climatique du logement
82
+ * @param mois {string}
83
+ * @returns {number}
84
+ */
85
+ sseVerandaMois(ets, baiesAdjVeranda, zc, mois) {
86
+ // Véranda sans baie vitrée vers le logement : aucun apport solaire
87
+ if (baiesAdjVeranda.length === 0) {
88
+ return 0;
107
89
  }
108
90
 
109
- return baiesVitrees.reduce((acc, baieVitree) => {
110
- const typeAdjacence = parseInt(baieVitree.donnee_entree.enum_type_adjacence_id);
91
+ const bver = ets.donnee_intermediaire.bver;
92
+ const T = ets.donnee_intermediaire.coef_transparence_ets;
93
+
94
+ /**
95
+ * Surface sud équivalente représentant l’impact des apports solaires associés au rayonnement solaire
96
+ * traversant directement l’espace tampon pour arriver dans la partie habitable du logement
97
+ * Calculés pour les baies vitrées qui séparent le logement de cet espace tampon
98
+ * @type {number}
99
+ */
100
+ const Ssdj = baiesAdjVeranda.reduce((acc, bv) => acc + T * this.ssdBaieMois(bv, zc, mois), 0);
111
101
 
112
- /**
113
- * 6.3 Traitement des espaces tampons solarisés
114
- * 10 - 'espace tampon solarisé (véranda,loggia fermée)'
115
- */
116
- if (typeAdjacence === 10 && ets) {
117
- return acc + SseVerandaj;
118
- }
102
+ /**
103
+ * Baies de l'espace tampon donnant sur l'extérieur (propres à chaque véranda)
104
+ */
105
+ let baies = ets.baie_ets_collection?.baie_ets || [];
119
106
 
120
- // Pour les fenêtres qui ne donnent pas sur l'extérieur, pas de surface sud équivalente
121
- if (typeAdjacence !== 1) {
122
- return acc;
123
- }
107
+ if (!Array.isArray(baies)) {
108
+ baies = [baies];
109
+ }
124
110
 
125
- return acc + this.ssdBaieMois(baieVitree, ctx.zoneClimatique.id, mois);
111
+ /**
112
+ * Apports totaux à travers l'espace tampon
113
+ * @type {number}
114
+ */
115
+ const Sstj = baies.reduce((acc, bv) => {
116
+ return acc + this.ssdBaieMois(bv, zc, mois, 0.8 * T + 0.024);
126
117
  }, 0);
118
+
119
+ /**
120
+ * Surface sud équivalente représentant l’impact des apports solaires indirects associés au rayonnement
121
+ * solaire entrant dans la partie habitable du logement après de multiples réflexions dans l’espace tampon solarisé
122
+ * @type {number}
123
+ */
124
+ const Ssindj = Sstj - Ssdj;
125
+
126
+ /**
127
+ * Impact de l’espace tampon solarisé sur les apports solaires à travers les baies vitrées qui séparent le logement
128
+ * de l'espace tampon
129
+ */
130
+ return Ssdj + Ssindj * bver;
131
+ }
132
+
133
+ /**
134
+ * Liste des espaces tampons solarisés du logement (la collection peut contenir un objet unique,
135
+ * un tableau, ou être absente selon les DPE)
136
+ *
137
+ * @param enveloppe {Enveloppe}
138
+ * @returns {Ets[]}
139
+ */
140
+ getEtsList(enveloppe) {
141
+ const ets = enveloppe.ets_collection?.ets;
142
+ if (!ets) {
143
+ return [];
144
+ }
145
+ return Array.isArray(ets) ? ets : [ets];
146
+ }
147
+
148
+ /**
149
+ * Certaines vérandas sont dupliquées dans les DPE : une même véranda (même référence, ou à défaut
150
+ * même contenu) ne doit être comptée qu'une seule fois.
151
+ *
152
+ * @param etsList {Ets[]}
153
+ * @returns {Ets[]}
154
+ */
155
+ dedoublonnerEts(etsList) {
156
+ const vues = new Set();
157
+ return etsList.filter((ets) => {
158
+ if (!ets) return false;
159
+ const reference = ets.donnee_entree?.reference;
160
+ const cle = reference ? `ref:${reference}` : `json:${JSON.stringify(ets)}`;
161
+ if (vues.has(cle)) return false;
162
+ vues.add(cle);
163
+ return true;
164
+ });
165
+ }
166
+
167
+ /**
168
+ * Rattache chaque baie vitrée adjacence 10 à l'espace tampon sur lequel elle donne
169
+ * (baie.donnee_entree.reference_lnc = ets.donnee_entree.reference).
170
+ *
171
+ * Une baie sans lien exploitable (reference_lnc absente ou ne correspondant à aucune véranda) est
172
+ * rattachée à la première véranda : c'est le comportement historique, et le seul possible quand le
173
+ * DPE ne comporte qu'une véranda.
174
+ *
175
+ * @param baiesAdjVeranda {BaieVitree[]}
176
+ * @param etsList {Ets[]}
177
+ * @returns {BaieVitree[][]} baies par véranda, dans l'ordre de etsList
178
+ */
179
+ rattacherBaiesAuxEts(baiesAdjVeranda, etsList) {
180
+ const baiesParEts = etsList.map(() => []);
181
+ // Aucune véranda exploitable : les baies adjacence 10 ne peuvent être rattachées à aucun ETS
182
+ if (etsList.length === 0) {
183
+ return baiesParEts;
184
+ }
185
+ baiesAdjVeranda.forEach((bv) => {
186
+ const referenceLnc = bv.donnee_entree.reference_lnc;
187
+ const idx = referenceLnc
188
+ ? etsList.findIndex((ets) => ets.donnee_entree?.reference === referenceLnc)
189
+ : -1;
190
+ baiesParEts[idx === -1 ? 0 : idx].push(bv);
191
+ });
192
+ return baiesParEts;
127
193
  }
128
194
 
129
195
  /**
@@ -0,0 +1,280 @@
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest';
2
+ import { SurfaceSudEquivalenteService } from './surface-sud-equivalente.service.js';
3
+
4
+ /**
5
+ * Orientations utilisées dans les tests (identifiants arbitraires : le store est un double de test)
6
+ */
7
+ const SUD = '1';
8
+ const NORD = '2';
9
+ const EST = '3';
10
+ const HORIZONTAL = '5';
11
+
12
+ /**
13
+ * Coefficients C1 de janvier (sud = 1, est = 0,40, nord = 0,31, horizontal = 0,3) utilisés pour les
14
+ * cas de test d'Olivier (issue #101). Indexés par orientation.
15
+ */
16
+ const C1 = { [SUD]: 1, [NORD]: 0.31, [EST]: 0.4, [HORIZONTAL]: 0.3 };
17
+
18
+ /** @type {{ getCoefficientBaieVitree: import('vitest').Mock }} */
19
+ let tvStore;
20
+
21
+ /** @type {SurfaceSudEquivalenteService} */
22
+ let service;
23
+
24
+ /** @type {Contexte} */
25
+ const ctx = { zoneClimatique: { id: '2', value: 'h1b' } };
26
+
27
+ /** Fabrique une baie vitrée avec les données d'entrée/intermédiaires utiles au calcul. */
28
+ function baie({ adjacence = '1', surface, orientationId = SUD, sw = 1, referenceLnc }) {
29
+ const bv = {
30
+ donnee_entree: {
31
+ enum_type_adjacence_id: adjacence,
32
+ enum_orientation_id: orientationId,
33
+ enum_inclinaison_vitrage_id: '3',
34
+ surface_totale_baie: surface
35
+ },
36
+ donnee_intermediaire: { sw }
37
+ };
38
+ if (referenceLnc !== undefined) bv.donnee_entree.reference_lnc = referenceLnc;
39
+ return bv;
40
+ }
41
+
42
+ /** Fabrique une baie de l'espace tampon donnant sur l'extérieur (pas de donnée intermédiaire). */
43
+ function baieEts(surface, orientationId) {
44
+ return {
45
+ donnee_entree: {
46
+ enum_orientation_id: orientationId,
47
+ enum_inclinaison_vitrage_id: '3',
48
+ surface_totale_baie: surface
49
+ }
50
+ };
51
+ }
52
+
53
+ /** Fabrique une véranda avec ses propres T, bver et baies extérieures. */
54
+ function veranda(reference, T, bver, baiesExt) {
55
+ return {
56
+ donnee_entree: { reference },
57
+ donnee_intermediaire: { bver, coef_transparence_ets: T },
58
+ baie_ets_collection: {
59
+ baie_ets: baiesExt.map(([surface, orientationId]) => baieEts(surface, orientationId))
60
+ }
61
+ };
62
+ }
63
+
64
+ function enveloppe(baiesVitrees, ets) {
65
+ return {
66
+ baie_vitree_collection: { baie_vitree: baiesVitrees },
67
+ ets_collection: ets === undefined ? {} : { ets }
68
+ };
69
+ }
70
+
71
+ describe('Calcul de la surface sud équivalente (§6.2 / §6.3)', () => {
72
+ beforeEach(() => {
73
+ tvStore = {
74
+ getCoefficientBaieVitree: vi.fn((orientation) => C1[orientation])
75
+ };
76
+ service = new SurfaceSudEquivalenteService(tvStore);
77
+ });
78
+
79
+ describe('ssdBaieMois', () => {
80
+ test('interroge la table C1 avec orientation, inclinaison, zone climatique et mois', () => {
81
+ const bv = baie({ surface: 10, orientationId: EST, sw: 0.5 });
82
+ // 10 * 0,40 * 0,5
83
+ expect(service.ssdBaieMois(bv, '2', 'Janvier')).toBeCloseTo(2, 9);
84
+ expect(tvStore.getCoefficientBaieVitree).toHaveBeenCalledWith(3, 3, 2, 'Janvier');
85
+ });
86
+
87
+ test('inclinaison absente : vitrage vertical (3) par défaut ; facteurs fe1/fe2 appliqués', () => {
88
+ const bv = baie({ surface: 10, sw: 0.5 });
89
+ delete bv.donnee_entree.enum_inclinaison_vitrage_id;
90
+ bv.donnee_intermediaire.fe1 = 0.8;
91
+ bv.donnee_intermediaire.fe2 = 0.5;
92
+ expect(service.ssdBaieMois(bv, '2', 'Janvier')).toBeCloseTo(10 * 0.5 * 0.8 * 0.5, 9);
93
+ expect(tvStore.getCoefficientBaieVitree).toHaveBeenCalledWith(1, 3, 2, 'Janvier');
94
+ });
95
+
96
+ test('un coefficient explicite remplace le facteur solaire sw de la baie', () => {
97
+ expect(service.ssdBaieMois(baieEts(10, SUD), '2', 'Janvier', 0.3)).toBeCloseTo(3, 9);
98
+ });
99
+ });
100
+
101
+ describe('ssdMois - sans espace tampon solarisé', () => {
102
+ test('somme des baies donnant sur l’extérieur ; les autres adjacences sont ignorées', () => {
103
+ const bvList = [
104
+ baie({ surface: 10, sw: 0.5 }),
105
+ baie({ adjacence: '10', surface: 4 }),
106
+ baie({ adjacence: '3', surface: 4 })
107
+ ];
108
+ expect(service.ssdMois(ctx, enveloppe(bvList), 'Janvier')).toBeCloseTo(5, 9);
109
+ });
110
+
111
+ test('ets_collection vide (tableau) ou absente : seules les baies extérieures comptent', () => {
112
+ const bvList = [baie({ surface: 10, sw: 0.5 }), baie({ adjacence: '10', surface: 4 })];
113
+ expect(service.ssdMois(ctx, enveloppe(bvList, []), 'Janvier')).toBeCloseTo(5, 9);
114
+ expect(
115
+ service.ssdMois(ctx, { baie_vitree_collection: { baie_vitree: bvList } }, 'Janvier')
116
+ ).toBeCloseTo(5, 9);
117
+ });
118
+
119
+ test('aucune baie vitrée : surface sud équivalente nulle', () => {
120
+ expect(service.ssdMois(ctx, {}, 'Janvier')).toBe(0);
121
+ });
122
+ });
123
+
124
+ describe('ssdMois - un espace tampon solarisé', () => {
125
+ test('apports directs + indirects : Sse = Sse_ext + Ssd + (Sst - Ssd) × bver', () => {
126
+ // T = 0,5, bver = 0,8, baie extérieure ETS 10 m² sud (unique, non tableau)
127
+ const ets = {
128
+ donnee_entree: {},
129
+ donnee_intermediaire: { bver: 0.8, coef_transparence_ets: 0.5 },
130
+ baie_ets_collection: { baie_ets: baieEts(10, SUD) }
131
+ };
132
+ const bvList = [baie({ surface: 10, sw: 0.5 }), baie({ adjacence: '10', surface: 2 })];
133
+ // Ssd = 0,5 × 2 × 1 × 1 = 1 ; Sst = 10 × (0,8 × 0,5 + 0,024) = 4,24
134
+ // Sse_ver = 1 + (4,24 - 1) × 0,8 = 3,592
135
+ expect(service.ssdMois(ctx, enveloppe(bvList, ets), 'Janvier')).toBeCloseTo(5 + 3.592, 9);
136
+ });
137
+
138
+ test('ETS sans baie adjacence 10 : bver et T sans effet, Sse_ver non ajoutée', () => {
139
+ const ets = veranda('V', 0.5, 0.8, [[10, SUD]]);
140
+ const bvList = [baie({ surface: 10, sw: 0.5 })];
141
+ expect(service.ssdMois(ctx, enveloppe(bvList, ets), 'Janvier')).toBeCloseTo(5, 9);
142
+ });
143
+
144
+ test('ETS sans baie extérieure : Sst = 0', () => {
145
+ const ets = {
146
+ donnee_entree: {},
147
+ donnee_intermediaire: { bver: 0.5, coef_transparence_ets: 0.5 }
148
+ };
149
+ const bvList = [baie({ adjacence: '10', surface: 2 })];
150
+ // Ssd = 1 ; Sse_ver = 1 + (0 - 1) × 0,5 = 0,5
151
+ expect(service.ssdMois(ctx, enveloppe(bvList, ets), 'Janvier')).toBeCloseTo(0.5, 9);
152
+ });
153
+
154
+ test('la Sse_ver n’est comptée qu’une fois quel que soit le nombre de baies adjacence 10', () => {
155
+ const ets = veranda('V', 0.5, 0.8, [[10, SUD]]);
156
+ const uneBaie = [baie({ adjacence: '10', surface: 2 })];
157
+ const deuxBaies = [
158
+ baie({ adjacence: '10', surface: 1 }),
159
+ baie({ adjacence: '10', surface: 1 })
160
+ ];
161
+ expect(service.ssdMois(ctx, enveloppe(deuxBaies, ets), 'Janvier')).toBeCloseTo(
162
+ service.ssdMois(ctx, enveloppe(uneBaie, ets), 'Janvier'),
163
+ 9
164
+ );
165
+ });
166
+ });
167
+
168
+ /**
169
+ * 6.3 Plusieurs espaces tampons solarisés (issue #101) : T, bver, Sst et Ssd sont propres à chaque
170
+ * véranda ; Sse = Sse_ext + Σ(v) Sse_veranda_v. Une baie adjacence 10 n'entre que dans la véranda
171
+ * sur laquelle elle donne (reference_lnc = ets.reference).
172
+ * Cas de test fournis par Olivier (thermicien) : C1 sud = 1, est = 0,40, nord = 0,31 ; Fe = 1.
173
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §6.3
174
+ */
175
+ describe('ssdMois - plusieurs espaces tampons solarisés (issue #101)', () => {
176
+ // Baie extérieure 10 m² sud, Sw = 0,5 => Sse_ext = 5
177
+ const baieExt = baie({ surface: 10, sw: 0.5 });
178
+ // V1 : T = 0,62, bver = 0,55, baies vers l'extérieur 8 m² sud + 4 m² est
179
+ const V1 = veranda('V1', 0.62, 0.55, [
180
+ [8, SUD],
181
+ [4, EST]
182
+ ]);
183
+ // V2 : T = 0,45, bver = 0,85, baie vers l'extérieur 6 m² nord
184
+ const V2 = veranda('V2', 0.45, 0.85, [[6, NORD]]);
185
+ const baiesV1 = [
186
+ baie({ adjacence: '10', surface: 4, sw: 0.47, referenceLnc: 'V1' }),
187
+ baie({ adjacence: '10', surface: 2, sw: 0.47, referenceLnc: 'V1' })
188
+ ];
189
+ const baiesV2 = [
190
+ baie({ adjacence: '10', surface: 2, orientationId: NORD, sw: 0.47, referenceLnc: 'V2' }),
191
+ baie({ adjacence: '10', surface: 1, orientationId: NORD, sw: 0.47, referenceLnc: 'V2' })
192
+ ];
193
+
194
+ // SseV1 = 1,7484 + (4,992 - 1,7484) × 0,55 = 3,53238
195
+ const SSE_V1 = 3.53238;
196
+ // SseV2 = 0,196695 + (0,71424 - 0,196695) × 0,85 = 0,63660825
197
+ const SSE_V2 = 0.63660825;
198
+
199
+ test('CT1 - cas de l’issue : Sse = Sse_ext + SseV1 + SseV2 avec T et bver propres', () => {
200
+ const env = enveloppe([baieExt, ...baiesV1, ...baiesV2], [V1, V2]);
201
+ expect(service.ssdMois(ctx, env, 'Janvier')).toBeCloseTo(5 + SSE_V1 + SSE_V2, 9);
202
+ });
203
+
204
+ test('CT2 - seules les baies de V2 : le Sst de V1 n’est pas appliqué', () => {
205
+ const env = enveloppe([baieExt, ...baiesV2], [V1, V2]);
206
+ expect(service.ssdMois(ctx, env, 'Janvier')).toBeCloseTo(5 + SSE_V2, 9);
207
+ });
208
+
209
+ test('CT3 - seules les baies de V1 : V2 sans baie vers le logement apporte 0', () => {
210
+ const env = enveloppe([baieExt, ...baiesV1], [V1, V2]);
211
+ expect(service.ssdMois(ctx, env, 'Janvier')).toBeCloseTo(5 + SSE_V1, 9);
212
+ });
213
+
214
+ test('CT4 - véranda dupliquée : pas de double comptage', () => {
215
+ const env = enveloppe([baieExt, ...baiesV1], [V1, structuredClone(V1)]);
216
+ expect(service.ssdMois(ctx, env, 'Janvier')).toBeCloseTo(5 + SSE_V1, 9);
217
+ });
218
+
219
+ test('baies sans reference_lnc : rattachées à la première véranda (comportement historique)', () => {
220
+ const env = enveloppe(
221
+ [
222
+ baieExt,
223
+ baie({ adjacence: '10', surface: 4, sw: 0.47 }),
224
+ baie({ adjacence: '10', surface: 2, sw: 0.47 })
225
+ ],
226
+ [V1, V2]
227
+ );
228
+ expect(service.ssdMois(ctx, env, 'Janvier')).toBeCloseTo(5 + SSE_V1, 9);
229
+ });
230
+
231
+ test('l’ordre des vérandas dans la collection est sans effet', () => {
232
+ const env = enveloppe([baieExt, ...baiesV1, ...baiesV2], [V2, V1]);
233
+ expect(service.ssdMois(ctx, env, 'Janvier')).toBeCloseTo(5 + SSE_V1 + SSE_V2, 9);
234
+ });
235
+
236
+ test('execute somme la surface sud équivalente de chaque mois', () => {
237
+ const env = enveloppe([baieExt, ...baiesV1, ...baiesV2], [V1, V2]);
238
+ // C1 indépendant du mois dans le double de test : 12 × valeur de janvier
239
+ expect(service.execute(ctx, env)).toBeCloseTo(12 * (5 + SSE_V1 + SSE_V2), 9);
240
+ });
241
+ });
242
+
243
+ describe('dedoublonnerEts / rattacherBaiesAuxEts / getEtsList', () => {
244
+ test('getEtsList : objet unique, tableau ou absence', () => {
245
+ const ets = { donnee_entree: {} };
246
+ expect(service.getEtsList({ ets_collection: { ets } })).toEqual([ets]);
247
+ expect(service.getEtsList({ ets_collection: { ets: [ets] } })).toEqual([ets]);
248
+ expect(service.getEtsList({})).toEqual([]);
249
+ });
250
+
251
+ test('dédoublonnage par référence, à défaut par contenu identique ; éléments vides ignorés', () => {
252
+ const a = { donnee_entree: { reference: 'A' }, x: 1 };
253
+ const aBis = { donnee_entree: { reference: 'A' }, x: 2 };
254
+ const sansRef = { donnee_intermediaire: { bver: 1 } };
255
+ const autreSansRef = { donnee_intermediaire: { bver: 2 } };
256
+ expect(
257
+ service.dedoublonnerEts([a, aBis, sansRef, structuredClone(sansRef), autreSansRef, null])
258
+ ).toEqual([a, sansRef, autreSansRef]);
259
+ });
260
+
261
+ test('rattachement par reference_lnc ; référence inconnue ou absente => première véranda', () => {
262
+ const ets = [{ donnee_entree: { reference: 'A' } }, { donnee_entree: { reference: 'B' } }];
263
+ const bA = { donnee_entree: { reference_lnc: 'A' } };
264
+ const bB = { donnee_entree: { reference_lnc: 'B' } };
265
+ const bInconnue = { donnee_entree: { reference_lnc: 'Z' } };
266
+ const bSans = { donnee_entree: {} };
267
+ expect(service.rattacherBaiesAuxEts([bA, bB, bInconnue, bSans], ets)).toEqual([
268
+ [bA, bInconnue, bSans],
269
+ [bB]
270
+ ]);
271
+ });
272
+
273
+ test('véranda sans donnee_entree : seules les baies sans lien lui sont rattachées', () => {
274
+ const ets = [{}, { donnee_entree: { reference: 'B' } }];
275
+ const bB = { donnee_entree: { reference_lnc: 'B' } };
276
+ const bSans = { donnee_entree: {} };
277
+ expect(service.rattacherBaiesAuxEts([bB, bSans], ets)).toEqual([[bSans], [bB]]);
278
+ });
279
+ });
280
+ });
@@ -138,7 +138,10 @@ export class EngineService {
138
138
  delete m.baie_vitree_double_fenetre?.donnee_intermediaire;
139
139
  delete m.donnee_intermediaire;
140
140
  });
141
- delete dpe.logement.enveloppe.ets_collection.ets?.donnee_intermediaire;
141
+ // Un DPE peut comporter un ou plusieurs espaces tampons solarisés (objet ou tableau)
142
+ [dpe.logement.enveloppe.ets_collection.ets]
143
+ .flat()
144
+ .forEach((ets) => delete ets?.donnee_intermediaire);
142
145
  dpe.logement.enveloppe.plancher_bas_collection.plancher_bas?.map(
143
146
  (m) => delete m.donnee_intermediaire
144
147
  );
@@ -227,4 +227,18 @@ describe('EngineService (orchestration de la méthode 3CL)', () => {
227
227
  // Aucun tableau interne ni production_elec_enr : les `?.` court-circuitent
228
228
  expect(() => service.execute(dpeMinimal())).not.toThrow();
229
229
  });
230
+
231
+ test('nettoie les données intermédiaires de chaque espace tampon solarisé (issue #101)', () => {
232
+ const dpe = dpeComplet();
233
+ dpe.logement.enveloppe.ets_collection.ets = [
234
+ { donnee_intermediaire: { bver: 0.55 } },
235
+ { donnee_intermediaire: { bver: 0.85 } },
236
+ null
237
+ ];
238
+ const resultat = service.execute(dpe);
239
+ const [v1, v2, vide] = resultat.logement.enveloppe.ets_collection.ets;
240
+ expect(v1.donnee_intermediaire).toBeUndefined();
241
+ expect(v2.donnee_intermediaire).toBeUndefined();
242
+ expect(vide).toBeNull();
243
+ });
230
244
  });
@@ -258,14 +258,15 @@ export class DeperditionEnveloppeService {
258
258
  }
259
259
  });
260
260
 
261
- let ets = enveloppe.ets_collection?.ets;
261
+ /**
262
+ * Un DPE peut comporter plusieurs espaces tampons solarisés (issue #101) : bver et le coefficient
263
+ * de transparence sont propres à chacun d'eux.
264
+ */
265
+ const ets = enveloppe.ets_collection?.ets;
262
266
  if (ets) {
263
- // Certaines vérandas sont dupliqués dans les DPE.
264
- if (Array.isArray(ets)) {
265
- ets = ets[0];
266
- }
267
-
268
- ets.donnee_intermediaire = this.#espaceTamponService.execute(ctx, ets);
267
+ (Array.isArray(ets) ? ets : [ets]).forEach((e) => {
268
+ e.donnee_intermediaire = this.#espaceTamponService.execute(ctx, e);
269
+ });
269
270
  }
270
271
 
271
272
  enveloppe.pont_thermique_collection.pont_thermique?.forEach((pt) => {
@@ -0,0 +1,230 @@
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest';
2
+ import { DeperditionEnveloppeService } from './deperdition-enveloppe.service.js';
3
+
4
+ /**
5
+ * Tests unitaires isolés de `DeperditionEnveloppeService`.
6
+ *
7
+ * Les services de déperdition par type de paroi sont remplacés par des doubles de test
8
+ * (spies `vi.fn`) : seule la logique d'agrégation propre au service est vérifiée
9
+ * (sommes b × S × U, surfaces déperditives / isolées / menuiseries, espaces tampons).
10
+ */
11
+
12
+ let murService;
13
+ let porteService;
14
+ let plancherBasService;
15
+ let plancherHautService;
16
+ let baieVitreeService;
17
+ let espaceTamponService;
18
+ let pontThermiqueService;
19
+ let ventilationService;
20
+
21
+ /** @type {DeperditionEnveloppeService} */
22
+ let service;
23
+
24
+ const ctx = { zoneClimatique: { value: 'h1b' } };
25
+
26
+ /** Logement sans aucun élément d'enveloppe ni ventilation. */
27
+ function logementVide() {
28
+ return {
29
+ enveloppe: {
30
+ mur_collection: {},
31
+ porte_collection: {},
32
+ plancher_bas_collection: {},
33
+ plancher_haut_collection: {},
34
+ baie_vitree_collection: {},
35
+ ets_collection: {},
36
+ pont_thermique_collection: {}
37
+ },
38
+ ventilation_collection: {}
39
+ };
40
+ }
41
+
42
+ describe('DeperditionEnveloppeService (§3 déperditions de l’enveloppe)', () => {
43
+ beforeEach(() => {
44
+ murService = { execute: vi.fn() };
45
+ porteService = { execute: vi.fn() };
46
+ plancherBasService = { execute: vi.fn() };
47
+ plancherHautService = { execute: vi.fn() };
48
+ baieVitreeService = { execute: vi.fn() };
49
+ espaceTamponService = { execute: vi.fn() };
50
+ pontThermiqueService = { execute: vi.fn() };
51
+ ventilationService = { execute: vi.fn() };
52
+ service = new DeperditionEnveloppeService(
53
+ murService,
54
+ porteService,
55
+ plancherBasService,
56
+ plancherHautService,
57
+ baieVitreeService,
58
+ espaceTamponService,
59
+ pontThermiqueService,
60
+ ventilationService
61
+ );
62
+ });
63
+
64
+ test('logement sans aucun élément : toutes les déperditions sont nulles', () => {
65
+ const logement = logementVide();
66
+ expect(service.deperditions(ctx, logement)).toEqual({
67
+ deperdition_mur: 0,
68
+ deperdition_plancher_bas: 0,
69
+ deperdition_plancher_haut: 0,
70
+ deperdition_baie_vitree: 0,
71
+ deperdition_pont_thermique: 0,
72
+ deperdition_porte: 0,
73
+ hperm: 0,
74
+ hvent: 0,
75
+ deperdition_enveloppe: 0
76
+ });
77
+ expect(espaceTamponService.execute).not.toHaveBeenCalled();
78
+ });
79
+
80
+ test('agrège b × S × U par paroi et transmet les surfaces à la ventilation', () => {
81
+ const logement = logementVide();
82
+ const env = logement.enveloppe;
83
+ env.mur_collection.mur = [
84
+ // déperditif, non isolé (type 2)
85
+ { donnee_entree: { surface_paroi_opaque: 10, enum_type_isolation_id: '2' } },
86
+ // déperditif, isolé, mais sur local non déperditif (22) : hors surface déperditive
87
+ {
88
+ donnee_entree: {
89
+ surface_paroi_opaque: 5,
90
+ enum_type_isolation_id: '3',
91
+ enum_type_adjacence_id: '22'
92
+ }
93
+ },
94
+ // b = 0 : ni surface déperditive, ni isolée
95
+ { donnee_entree: { surface_paroi_opaque: 100, enum_type_isolation_id: '3' } }
96
+ ];
97
+ murService.execute
98
+ .mockReturnValueOnce({ b: 1, umur: 2 })
99
+ .mockReturnValueOnce({ b: 0.5, umur: 1 })
100
+ .mockReturnValueOnce({ b: 0, umur: 1 });
101
+
102
+ env.porte_collection.porte = [
103
+ { donnee_entree: { surface_porte: 2, presence_joint: true } },
104
+ { donnee_entree: { surface_porte: 3, presence_joint: false } }
105
+ ];
106
+ porteService.execute
107
+ .mockReturnValueOnce({ b: 1, uporte: 3 })
108
+ .mockReturnValueOnce({ b: 0, uporte: 3 });
109
+
110
+ env.plancher_bas_collection.plancher_bas = [{ donnee_entree: { surface_paroi_opaque: 20 } }];
111
+ plancherBasService.execute.mockReturnValue({ b: 1, upb_final: 0.5 });
112
+
113
+ env.plancher_haut_collection.plancher_haut = [
114
+ { donnee_entree: { surface_paroi_opaque: 30, enum_type_isolation_id: '1' } },
115
+ {
116
+ donnee_entree: {
117
+ surface_paroi_opaque: 7,
118
+ enum_type_isolation_id: '4',
119
+ enum_type_adjacence_id: '22'
120
+ }
121
+ },
122
+ { donnee_entree: { surface_paroi_opaque: 50, enum_type_isolation_id: '4' } }
123
+ ];
124
+ plancherHautService.execute
125
+ .mockReturnValueOnce({ b: 1, uph: 1 })
126
+ .mockReturnValueOnce({ b: 1, uph: 1 })
127
+ .mockReturnValueOnce({ b: 0, uph: 1 });
128
+
129
+ env.baie_vitree_collection.baie_vitree = [
130
+ { donnee_entree: { surface_totale_baie: 4, presence_joint: true } },
131
+ { donnee_entree: { surface_totale_baie: 6 } }
132
+ ];
133
+ baieVitreeService.execute
134
+ .mockReturnValueOnce({ b: 1, u_menuiserie: 2 })
135
+ .mockReturnValueOnce({ b: 0, u_menuiserie: 2 });
136
+
137
+ env.pont_thermique_collection.pont_thermique = [
138
+ { donnee_entree: { l: 10, pourcentage_valeur_pont_thermique: 0.5 } },
139
+ { donnee_entree: { l: 4 } }
140
+ ];
141
+ pontThermiqueService.execute.mockReturnValue({ k: 0.5 });
142
+
143
+ logement.ventilation_collection.ventilation = [{ donnee_entree: { id: 'v' } }];
144
+ ventilationService.execute.mockReturnValue({ hvent: 10, hperm: 5 });
145
+
146
+ const resultat = service.deperditions(ctx, logement);
147
+
148
+ expect(resultat).toEqual({
149
+ deperdition_mur: 10 * 2 + 0.5 * 5 * 1,
150
+ deperdition_porte: 2 * 3,
151
+ deperdition_plancher_bas: 20 * 0.5,
152
+ deperdition_plancher_haut: 30 + 7,
153
+ deperdition_baie_vitree: 4 * 2,
154
+ deperdition_pont_thermique: 10 * 0.5 * 0.5 + 4 * 0.5,
155
+ hvent: 10,
156
+ hperm: 5,
157
+ deperdition_enveloppe: 22.5 + 6 + 10 + 37 + 8 + 4.5 + 10 + 5
158
+ });
159
+
160
+ // Délégation aux services de paroi
161
+ expect(plancherBasService.execute).toHaveBeenCalledWith(
162
+ ctx,
163
+ env.plancher_bas_collection.plancher_bas[0].donnee_entree,
164
+ env.plancher_bas_collection.plancher_bas
165
+ );
166
+ expect(pontThermiqueService.execute).toHaveBeenCalledWith(
167
+ ctx,
168
+ env,
169
+ env.pont_thermique_collection.pont_thermique[0].donnee_entree
170
+ );
171
+
172
+ // Surfaces transmises à la ventilation :
173
+ // déperditive = mur 10 + porte 2 + plancher haut 30 + baie 4 = 46
174
+ // isolée = mur 5 + plancher haut 7 = 12 ; non isolée = mur 10 + plancher haut 30 = 40
175
+ // menuiseries avec joint = porte 2 + baie 4 = 6 ; sans joint = porte 3 + baie 6 = 9
176
+ expect(ventilationService.execute).toHaveBeenCalledWith(ctx, { id: 'v' }, 46, 12, 40, 6, 9);
177
+ });
178
+
179
+ test('les surfaces sont réinitialisées à chaque calcul', () => {
180
+ const logement = logementVide();
181
+ logement.enveloppe.porte_collection.porte = [{ donnee_entree: { surface_porte: 2 } }];
182
+ porteService.execute.mockReturnValue({ b: 1, uporte: 1 });
183
+ logement.ventilation_collection.ventilation = [{ donnee_entree: {} }];
184
+ ventilationService.execute.mockReturnValue({ hvent: 0, hperm: 0 });
185
+
186
+ service.deperditions(ctx, logement);
187
+ service.deperditions(ctx, logement);
188
+
189
+ expect(ventilationService.execute).toHaveBeenLastCalledWith(ctx, {}, 2, 0, 0, 0, 2);
190
+ });
191
+
192
+ describe('espaces tampons solarisés', () => {
193
+ test('un ETS unique (objet) reçoit ses données intermédiaires', () => {
194
+ const logement = logementVide();
195
+ const ets = { donnee_entree: { tv_coef_transparence_ets_id: '2' } };
196
+ logement.enveloppe.ets_collection.ets = ets;
197
+ espaceTamponService.execute.mockReturnValue({ bver: 0.55, coef_transparence_ets: 0.62 });
198
+
199
+ service.deperditions(ctx, logement);
200
+
201
+ expect(espaceTamponService.execute).toHaveBeenCalledWith(ctx, ets);
202
+ expect(ets.donnee_intermediaire).toEqual({ bver: 0.55, coef_transparence_ets: 0.62 });
203
+ });
204
+
205
+ test('plusieurs ETS : bver et T calculés pour chacun d’eux (issue #101)', () => {
206
+ const logement = logementVide();
207
+ const V1 = { donnee_entree: { reference: 'V1' } };
208
+ const V2 = { donnee_entree: { reference: 'V2' } };
209
+ logement.enveloppe.ets_collection.ets = [V1, V2];
210
+ espaceTamponService.execute
211
+ .mockReturnValueOnce({ bver: 0.55, coef_transparence_ets: 0.62 })
212
+ .mockReturnValueOnce({ bver: 0.85, coef_transparence_ets: 0.45 });
213
+
214
+ service.deperditions(ctx, logement);
215
+
216
+ expect(espaceTamponService.execute).toHaveBeenCalledTimes(2);
217
+ expect(espaceTamponService.execute).toHaveBeenNthCalledWith(1, ctx, V1);
218
+ expect(espaceTamponService.execute).toHaveBeenNthCalledWith(2, ctx, V2);
219
+ expect(V1.donnee_intermediaire).toEqual({ bver: 0.55, coef_transparence_ets: 0.62 });
220
+ expect(V2.donnee_intermediaire).toEqual({ bver: 0.85, coef_transparence_ets: 0.45 });
221
+ });
222
+
223
+ test('sans ets_collection : aucun calcul d’espace tampon', () => {
224
+ const logement = logementVide();
225
+ delete logement.enveloppe.ets_collection;
226
+ service.deperditions(ctx, logement);
227
+ expect(espaceTamponService.execute).not.toHaveBeenCalled();
228
+ });
229
+ });
230
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open3cl/engine",
3
- "version": "1.8.6",
3
+ "version": "1.8.8",
4
4
  "description": "Open Source 3CL-DPE engine",
5
5
  "main": "index.js",
6
6
  "directories": {