@open3cl/engine 1.8.7 → 1.8.9

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.7';
36
+ const LIB_VERSION = '1.8.9';
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(
@@ -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) => {