@open3cl/engine 1.8.21 → 1.8.22

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.
@@ -55,7 +55,15 @@ function tv_upb(di, de, du, pc_id, zc, effetJoule) {
55
55
 
56
56
  const values_2s_p = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 14, 16, 18, 20];
57
57
 
58
- function tv_ue(di, de, du, pc_id, pb_list) {
58
+ /**
59
+ * Ue du plancher bas courant, calculé plancher par plancher (issue #46).
60
+ * 2S/P est déterminé avec la surface (surface_ue, à défaut surface_paroi_opaque) et le périmètre
61
+ * (perimetre_ue) du seul plancher courant, sans sommer les autres planchers de même adjacence.
62
+ * Les valeurs absentes du tableau sont obtenues par interpolation linéaire sur upb.
63
+ *
64
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.2.2 (tableau ue)
65
+ */
66
+ function tv_ue(di, de, du, pc_id) {
59
67
  const type_adjacence = enums.type_adjacence[de.enum_type_adjacence_id];
60
68
  let type_adjacence_plancher;
61
69
  let upb1, upb2;
@@ -72,21 +80,9 @@ function tv_ue(di, de, du, pc_id, pb_list) {
72
80
  [upb1, upb2] = getRange(di.upb, [0.31, 0.34, 0.37, 0.41, 0.45, 0.83, 1.43, 3.33]);
73
81
  }
74
82
 
75
- /**
76
- * La surface Ue est la surface de tous les planchers bas ayant le même type d'adjacence
77
- * Le périmètre Ue est le périmètre de tous les planchers bas ayant le même type d'adjacence
78
- */
79
- const { surfaceUe, perimetreUe } = pb_list.reduce(
80
- (acc, plancherBas) => {
81
- if (plancherBas.donnee_entree.enum_type_adjacence_id === de.enum_type_adjacence_id) {
82
- acc.surfaceUe +=
83
- plancherBas.donnee_entree.surface_ue || plancherBas.donnee_entree.surface_paroi_opaque;
84
- acc.perimetreUe += plancherBas.donnee_entree.perimetre_ue || 0;
85
- }
86
- return acc;
87
- },
88
- { surfaceUe: 0, perimetreUe: 0 }
89
- );
83
+ // Surface et périmètre Ue du plancher courant uniquement
84
+ const surfaceUe = de.surface_ue || de.surface_paroi_opaque;
85
+ const perimetreUe = de.perimetre_ue || 0;
90
86
 
91
87
  const matcher = {
92
88
  type_adjacence_plancher,
@@ -134,7 +130,7 @@ function calc_upb0(di, de, du) {
134
130
  }
135
131
  }
136
132
 
137
- export default function calc_pb(pb, zc, pc_id, effetJoule, pb_list) {
133
+ export default function calc_pb(pb, zc, pc_id, effetJoule) {
138
134
  const de = pb.donnee_entree;
139
135
  const du = {};
140
136
  const di = {};
@@ -246,7 +242,7 @@ export default function calc_pb(pb, zc, pc_id, effetJoule, pb_list) {
246
242
  case 'vide sanitaire':
247
243
  case 'sous-sol non chauffé':
248
244
  case 'terre-plein':
249
- tv_ue(di, de, du, pc_id, pb_list);
245
+ tv_ue(di, de, du, pc_id);
250
246
  di.upb_final = de.ue;
251
247
  break;
252
248
  default:
@@ -561,14 +561,17 @@ describe('calc_pb - calcul de Ue (tv_ue)', () => {
561
561
  enum_type_plancher_bas_id: '9',
562
562
  type_adjacence: 'terre-plein',
563
563
  enum_type_adjacence_id: '5',
564
- surface_paroi_opaque: 4 // pas de surface_ue => fallback
564
+ surface_paroi_opaque: 4, // pas de surface_ue => fallback
565
+ perimetre_ue: 2
565
566
  }
566
567
  };
567
568
 
568
569
  calc_pb(pb, 'h1a', '1', '0', [pb1, pb, autre]);
569
570
 
570
- // surfaceUe = 20 (pb1.surface_ue) + 4 (pb.surface_paroi_opaque) = 24 ; perimetreUe = 12 + 0 = 12
571
- // 2S/P = round(48/12) = 4 (valeur présente dans values_2s_p)
571
+ // Calcul plancher par plancher (#46) : les autres planchers (pb1, autre) sont ignorés.
572
+ // Ce test encodait auparavant la somme par adjacence (S = 20 + 4, P = 12 + 0) ; perimetre_ue
573
+ // est désormais porté par le plancher courant pour conserver 2S/P = 4.
574
+ // surfaceUe = 4 (pb.surface_paroi_opaque) ; perimetreUe = 2 => 2S/P = round(8/2) = 4
572
575
  expect(tv).toHaveBeenCalledWith('ue', {
573
576
  type_adjacence_plancher: 'terre plein bâtiment construit avant 2001',
574
577
  '2s_p': '^4$',
@@ -652,4 +655,109 @@ describe('calc_pb - calcul de Ue (tv_ue)', () => {
652
655
 
653
656
  expect(pb.donnee_intermediaire.upb_final).toBe(0.19);
654
657
  });
658
+
659
+ /**
660
+ * Issue #46 : ue calculé plancher par plancher, 2S/P issu du seul plancher courant.
661
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.2.2 (tableau ue : « Les données ne figurant
662
+ * pas dans le tableau peuvent être obtenues par interpolation et extrapolation »)
663
+ */
664
+ describe('calcul plancher par plancher (#46)', () => {
665
+ /** Plancher non isolé (methode 1) dont upb = upb0 = 1,45. */
666
+ const plancher = (donnees) => ({
667
+ donnee_entree: {
668
+ enum_methode_saisie_u_id: '1',
669
+ methode_saisie_u0:
670
+ 'déterminé selon le matériau et épaisseur à partir de la table de valeur forfaitaire',
671
+ enum_type_plancher_bas_id: '2',
672
+ type_adjacence: 'terre-plein',
673
+ enum_type_adjacence_id: '5',
674
+ ...donnees
675
+ }
676
+ });
677
+
678
+ /** Table ue mockée par couple (2s_p, upb) : terre-plein avant 2001. */
679
+ const UE = {
680
+ '^7$': { 0.85: 0.33, 1.5: 0.39 },
681
+ '^2$': { 0.85: 0.55, 1.5: 0.8 },
682
+ '^1$': { 0.85: 0.7, 1.5: 0.9 },
683
+ '^4$': { 0.85: 0.45, 1.5: 0.6 }
684
+ };
685
+
686
+ beforeEach(() => {
687
+ getRange.mockReturnValue([0.85, 1.5]);
688
+ tv.mockImplementation((table, matcher) => {
689
+ if (table === 'upb0') return { upb0: '1.45', tv_upb0_id: '2' };
690
+ if (table === 'ue') return { ue: String(UE[matcher['2s_p']][matcher.upb]) };
691
+ return null;
692
+ });
693
+ });
694
+
695
+ test('2187E0982591I plancher 1 : 2S/P = 7 et ue = 0,385384615 malgré un autre terre-plein', () => {
696
+ const pb1 = plancher({ surface_paroi_opaque: 44.8, perimetre_ue: 12.8 });
697
+ const pb3 = plancher({ surface_paroi_opaque: 10.5, perimetre_ue: 10 });
698
+
699
+ calc_pb(pb1, 'h1a', '1', '0', [pb1, pb3]);
700
+
701
+ // 2S/P = 2 * 44,8 / 12,8 = 7 (somme par adjacence : 2 * 55,3 / 22,8 ≈ 4,85 => 5)
702
+ expect(tv).toHaveBeenCalledWith('ue', {
703
+ type_adjacence_plancher: 'terre plein bâtiment construit avant 2001',
704
+ '2s_p': '^7$',
705
+ upb: '0.85'
706
+ });
707
+ expect(tv).toHaveBeenCalledWith('ue', {
708
+ type_adjacence_plancher: 'terre plein bâtiment construit avant 2001',
709
+ '2s_p': '^7$',
710
+ upb: '1.5'
711
+ });
712
+ expect(tv).not.toHaveBeenCalledWith('ue', expect.objectContaining({ '2s_p': '^5$' }));
713
+ // ue = 0,33 + (0,39 - 0,33) * (1,45 - 0,85) / (1,5 - 0,85) = 0,385384615 (valeur ADEME)
714
+ expect(pb1.donnee_entree.ue).toBeCloseTo(0.385384615, 9);
715
+ expect(pb1.donnee_intermediaire.upb_final).toBeCloseTo(0.385384615, 9);
716
+ });
717
+
718
+ test('deux planchers de même adjacence avec surface_ue / perimetre_ue différents : ue différents', () => {
719
+ const pbA = plancher({ surface_paroi_opaque: 50, surface_ue: 70, perimetre_ue: 20 });
720
+ const pbB = plancher({ surface_paroi_opaque: 50, surface_ue: 10, perimetre_ue: 10 });
721
+ const liste = [pbA, pbB];
722
+
723
+ liste.forEach((pb) => calc_pb(pb, 'h1a', '1', '0', liste));
724
+
725
+ // pbA : 2 * 70 / 20 = 7 => ue interpolé entre 0,33 et 0,39
726
+ expect(pbA.donnee_entree.ue).toBeCloseTo(0.385384615, 9);
727
+ // pbB : 2 * 10 / 10 = 2 => 0,55 + 0,25 * 0,6 / 0,65 (valeur de référence de régression)
728
+ expect(pbB.donnee_entree.ue).toBeCloseTo(0.780769231, 9);
729
+ expect(pbA.donnee_entree.ue).not.toBeCloseTo(pbB.donnee_entree.ue, 3);
730
+ });
731
+
732
+ test('surface_ue absente : surface_paroi_opaque du plancher courant utilisée', () => {
733
+ const pb = plancher({ surface_paroi_opaque: 20, perimetre_ue: 10 });
734
+ const autre = plancher({ surface_ue: 500, surface_paroi_opaque: 500, perimetre_ue: 10 });
735
+
736
+ calc_pb(pb, 'h1a', '1', '0', [pb, autre]);
737
+
738
+ // 2S/P = 2 * 20 / 10 = 4
739
+ expect(tv).toHaveBeenCalledWith('ue', expect.objectContaining({ '2s_p': '^4$' }));
740
+ // ue = 0,45 + 0,15 * 0,6 / 0,65 (valeur de référence de régression)
741
+ expect(pb.donnee_entree.ue).toBeCloseTo(0.588461538, 9);
742
+ });
743
+
744
+ test('surface_ue prioritaire sur surface_paroi_opaque', () => {
745
+ const pb = plancher({ surface_paroi_opaque: 20, surface_ue: 35, perimetre_ue: 10 });
746
+
747
+ calc_pb(pb, 'h1a', '1', '0', [pb]);
748
+
749
+ // 2S/P = 2 * 35 / 10 = 7
750
+ expect(tv).toHaveBeenCalledWith('ue', expect.objectContaining({ '2s_p': '^7$' }));
751
+ });
752
+
753
+ test('perimetre_ue absent : comportement inchangé (division par 0 => 2S/P = 1), même si un autre plancher en a un', () => {
754
+ const pb = plancher({ surface_paroi_opaque: 20 });
755
+ const autre = plancher({ surface_paroi_opaque: 20, perimetre_ue: 10 });
756
+
757
+ calc_pb(pb, 'h1a', '1', '0', [pb, autre]);
758
+
759
+ expect(tv).toHaveBeenCalledWith('ue', expect.objectContaining({ '2s_p': '^1$' }));
760
+ expect(pb.donnee_entree.ue).toBeCloseTo(0.884615385, 9);
761
+ });
762
+ });
655
763
  });
package/3_deperdition.js CHANGED
@@ -230,7 +230,7 @@ export default function calc_deperdition(cg, zc, th, effetJoule, dpe, Sh) {
230
230
  */
231
231
  const versionDpe = parseFloat(dpe.administratif?.enum_version_id);
232
232
  mur_list.forEach((mur) => calc_mur(mur, zc, pc, effetJoule, versionDpe));
233
- pb_list.forEach((pb) => calc_pb(pb, zc, pc, effetJoule, pb_list));
233
+ pb_list.forEach((pb) => calc_pb(pb, zc, pc, effetJoule));
234
234
  ph_list.forEach((ph) => calc_ph(ph, zc, pc, effetJoule));
235
235
  bv_list.forEach((bv) => calc_bv(bv, zc));
236
236
  porte_list.forEach((porte) => calc_porte(porte, zc));
package/README.md CHANGED
@@ -370,28 +370,28 @@ 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.20`** · branche `main` · généré le 2026-10-03
373
+ > **Version `1.8.21`** · à publier · branche `main` · généré le 2026-10-04
374
374
  > Seuil de tolérance **5%**
375
375
 
376
376
  <table>
377
377
  <tr>
378
378
  <td align="center"><strong>9</strong><br/><sub>corpus</sub></td>
379
379
  <td align="center"><strong>89 990</strong><br/><sub>DPE analysés</sub></td>
380
- <td align="center"><strong>60 702</strong><br/><sub>DPE conformes</sub></td>
381
- <td align="center"><strong>67,45 %</strong><br/><sub>réussite globale</sub></td>
380
+ <td align="center"><strong>60 708</strong><br/><sub>DPE conformes</sub></td>
381
+ <td align="center"><strong>67,46 %</strong><br/><sub>réussite globale</sub></td>
382
382
  </tr>
383
383
  </table>
384
384
 
385
385
  | | Corpus | Réussite | | DPE conformes |
386
386
  | :-: | :---------------------------------------------------------------------------------------------------------------------- | ----------: | :--------------------- | -------------: |
387
- | 🔴 | **Généraliste**<br/><sub>`corpus_dpe.csv`</sub> | **46,00 %** | `█████████░░░░░░░░░░░` | 4 600 / 10 000 |
387
+ | 🔴 | **Généraliste**<br/><sub>`corpus_dpe.csv`</sub> | **46,01 %** | `█████████░░░░░░░░░░░` | 4 601 / 10 000 |
388
388
  | 🟢 | **Appartement · chauffage individuel (2025)**<br/><sub>`dpe_appartement_individuel_chauffage_individuel_2025.csv`</sub> | **92,52 %** | `███████████████████░` | 9 252 / 10 000 |
389
389
  | 🟢 | **Logement individuel (2025)**<br/><sub>`dpe_logement_individuel_2025.csv`</sub> | **89,46 %** | `██████████████████░░` | 8 939 / 9 992 |
390
390
  | 🟢 | **Maison individuelle (2025)**<br/><sub>`dpe_maison_individuelle_2025.csv`</sub> | **88,56 %** | `██████████████████░░` | 8 856 / 10 000 |
391
391
  | 🟡 | **Immeuble · chauffage individuel**<br/><sub>`dpe_immeuble_chauffage_individuel.csv`</sub> | **76,88 %** | `███████████████░░░░░` | 7 687 / 9 999 |
392
392
  | 🟡 | **Appartement · chauffage collectif (2025)**<br/><sub>`dpe_appartement_individuel_chauffage_collectif_2025.csv`</sub> | **71,24 %** | `██████████████░░░░░░` | 7 124 / 10 000 |
393
- | 🟡 | **Immeuble · chauffage collectif**<br/><sub>`dpe_immeuble_chauffage_collectif.csv`</sub> | **63,89 %** | `█████████████░░░░░░░` | 6 388 / 9 999 |
394
- | 🔴 | **Immeuble · chauffage mixte**<br/><sub>`dpe_immeuble_chauffage_mixte.csv`</sub> | **51,62 %** | `██████████░░░░░░░░░░` | 5 162 / 10 000 |
393
+ | 🟡 | **Immeuble · chauffage collectif**<br/><sub>`dpe_immeuble_chauffage_collectif.csv`</sub> | **63,93 %** | `█████████████░░░░░░░` | 6 392 / 9 999 |
394
+ | 🔴 | **Immeuble · chauffage mixte**<br/><sub>`dpe_immeuble_chauffage_mixte.csv`</sub> | **51,63 %** | `██████████░░░░░░░░░░` | 5 163 / 10 000 |
395
395
  | 🔴 | **Individuel généré depuis l'immeuble (2026)**<br/><sub>`dpe_individuel_a_partir_dpe_immeuble_2026.csv`</sub> | **26,94 %** | `█████░░░░░░░░░░░░░░░` | 2 694 / 10 000 |
396
396
 
397
397
  <sub>🟢 ≥ 85 % · 🟡 ≥ 60 % · 🔴 < 60 %</sub>
@@ -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>4,95 ms</strong><br/><sub>moyenne</sub></td>
407
- <td align="center"><strong>4,30 ms</strong><br/><sub>médiane</sub></td>
408
- <td align="center"><strong>0,10 ms</strong><br/><sub>min</sub></td>
409
- <td align="center"><strong>724,9 ms</strong><br/><sub>max</sub></td>
406
+ <td align="center"><strong>5,13 ms</strong><br/><sub>moyenne</sub></td>
407
+ <td align="center"><strong>4,49 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>780,0 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,19 ms | 3,71 ms | 0,22 ms | 33,3 ms | 7,80 ms | 11,2 ms |
416
- | **Appartement · chauffage individuel (2025)** | 3,59 ms | 3,27 ms | 1,49 ms | 23,0 ms | 6,22 ms | 7,89 ms |
417
- | **Logement individuel (2025)** | 4,66 ms | 4,15 ms | 1,59 ms | 32,1 ms | 8,69 ms | 12,0 ms |
418
- | **Maison individuelle (2025)** | 5,50 ms | 4,93 ms | 1,66 ms | 39,4 ms | 9,87 ms | 12,9 ms |
419
- | **Immeuble · chauffage individuel** | 5,79 ms | 4,95 ms | 0,10 ms | 71,4 ms | 11,4 ms | 18,3 ms |
420
- | **Appartement · chauffage collectif (2025)** | 3,96 ms | 3,53 ms | 1,39 ms | 33,9 ms | 6,54 ms | 8,61 ms |
421
- | **Immeuble · chauffage collectif** | 5,20 ms | 4,33 ms | 0,11 ms | 104,7 ms | 9,90 ms | 17,0 ms |
422
- | **Immeuble · chauffage mixte** | 5,84 ms | 4,87 ms | 0,20 ms | 724,9 ms | 12,2 ms | 19,1 ms |
423
- | **Individuel généré depuis l'immeuble (2026)** | 5,84 ms | 4,98 ms | 1,67 ms | 68,7 ms | 11,2 ms | 17,9 ms |
415
+ | **Généraliste** | 4,96 ms | 4,47 ms | 0,26 ms | 35,6 ms | 9,14 ms | 13,2 ms |
416
+ | **Appartement · chauffage individuel (2025)** | 3,66 ms | 3,34 ms | 1,41 ms | 32,4 ms | 6,30 ms | 8,15 ms |
417
+ | **Logement individuel (2025)** | 4,59 ms | 4,08 ms | 1,55 ms | 34,5 ms | 8,40 ms | 11,9 ms |
418
+ | **Maison individuelle (2025)** | 5,16 ms | 4,74 ms | 1,91 ms | 43,3 ms | 8,62 ms | 11,6 ms |
419
+ | **Immeuble · chauffage individuel** | 6,91 ms | 5,90 ms | 0,11 ms | 135,6 ms | 14,0 ms | 22,1 ms |
420
+ | **Appartement · chauffage collectif (2025)** | 4,19 ms | 3,80 ms | 1,57 ms | 34,7 ms | 6,86 ms | 8,80 ms |
421
+ | **Immeuble · chauffage collectif** | 5,82 ms | 4,94 ms | 0,13 ms | 171,1 ms | 10,8 ms | 19,3 ms |
422
+ | **Immeuble · chauffage mixte** | 5,18 ms | 4,21 ms | 0,18 ms | 780,0 ms | 10,8 ms | 17,0 ms |
423
+ | **Individuel généré depuis l'immeuble (2026)** | 5,76 ms | 4,94 ms | 1,57 ms | 70,7 ms | 10,9 ms | 16,9 ms |
424
424
 
425
425
  <sub>La moyenne est tirée vers le haut par les DPE collectifs, dont le coût atteint plusieurs
426
426
  dizaines de fois la médiane : c’est la médiane qui décrit le cas courant, et p95/p99 le cas
package/engine.js CHANGED
@@ -33,7 +33,7 @@ import getFicheTechnique from './ficheTechnique.js';
33
33
  import { ProductionENR } from './16.2_production_enr.js';
34
34
  import DpeSanitizerService from './dpe-sanitizer.service.js';
35
35
 
36
- const LIB_VERSION = '1.8.21';
36
+ const LIB_VERSION = '1.8.22';
37
37
 
38
38
  const dpeSanitizerService = new DpeSanitizerService();
39
39
 
@@ -207,11 +207,7 @@ export class DeperditionEnveloppeService {
207
207
 
208
208
  const plancherBas = enveloppe.plancher_bas_collection.plancher_bas || [];
209
209
  plancherBas?.forEach((pb) => {
210
- pb.donnee_intermediaire = this.#deperditionPlancherBasService.execute(
211
- ctx,
212
- pb.donnee_entree,
213
- plancherBas
214
- );
210
+ pb.donnee_intermediaire = this.#deperditionPlancherBasService.execute(ctx, pb.donnee_entree);
215
211
  deperditions.deperdition_plancher_bas +=
216
212
  pb.donnee_intermediaire.b *
217
213
  pb.donnee_entree.surface_paroi_opaque *
@@ -158,10 +158,10 @@ describe('DeperditionEnveloppeService (§3 déperditions de l’enveloppe)', ()
158
158
  });
159
159
 
160
160
  // Délégation aux services de paroi
161
+ // Ue calculé plancher par plancher (#46) : la liste des planchers n'est plus transmise
161
162
  expect(plancherBasService.execute).toHaveBeenCalledWith(
162
163
  ctx,
163
- env.plancher_bas_collection.plancher_bas[0].donnee_entree,
164
- env.plancher_bas_collection.plancher_bas
164
+ env.plancher_bas_collection.plancher_bas[0].donnee_entree
165
165
  );
166
166
  expect(pontThermiqueService.execute).toHaveBeenCalledWith(
167
167
  ctx,
@@ -22,13 +22,12 @@ export class DeperditionPlancherBasService extends DeperditionService {
22
22
  /**
23
23
  * @param ctx {Contexte}
24
24
  * @param pbDE {PlancherBasDE}
25
- * @param plancherBas {PlancherBas[]}
26
25
  * @return {PlancherBasDI}
27
26
  */
28
- execute(ctx, pbDE, plancherBas) {
27
+ execute(ctx, pbDE) {
29
28
  const upb0 = this.#upb0(pbDE);
30
29
  const upb = this.#upb(pbDE, upb0, ctx);
31
- const upb_final = this.#upbFinal(pbDE, upb, ctx, plancherBas);
30
+ const upb_final = this.#upbFinal(pbDE, upb, ctx);
32
31
  const b = this.b({
33
32
  enumTypeAdjacenceId: pbDE.enum_type_adjacence_id,
34
33
  surfaceAiu: pbDE.surface_aiu,
@@ -113,10 +112,9 @@ export class DeperditionPlancherBasService extends DeperditionService {
113
112
  * @param pbDE {PlancherBasDE}
114
113
  * @param upb {number}
115
114
  * @param ctx {Contexte}
116
- * @param plancherBas {PlancherBas[]}
117
115
  * @return {number|undefined}
118
116
  */
119
- #upbFinal(pbDE, upb, ctx, plancherBas) {
117
+ #upbFinal(pbDE, upb, ctx) {
120
118
  if (pbDE.calcul_ue === 1) {
121
119
  return pbDE.ue;
122
120
  }
@@ -131,23 +129,13 @@ export class DeperditionPlancherBasService extends DeperditionService {
131
129
  }
132
130
 
133
131
  /**
134
- * La surface Ue est la surface de tous les planchers bas ayant le même type d'adjacence
135
- * Le périmètre Ue est le périmètre de tous les planchers bas ayant le même type d'adjacence
132
+ * Calcul plancher par plancher (issue #46) : 2S/P est déterminé avec la surface (surface_ue, à
133
+ * défaut surface_paroi_opaque) et le périmètre (perimetre_ue) du seul plancher courant, sans
134
+ * sommer les autres planchers de même adjacence.
135
+ * @see Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.2.2 (tableau ue)
136
136
  */
137
- const { surfaceUe, perimetreUe } = plancherBas
138
- .filter(
139
- (plancherBas) =>
140
- pbDE.enum_type_adjacence_id === plancherBas.donnee_entree.enum_type_adjacence_id
141
- )
142
- .reduce(
143
- (acc, plancherBas) => {
144
- acc.surfaceUe +=
145
- plancherBas.donnee_entree.surface_ue || plancherBas.donnee_entree.surface_paroi_opaque;
146
- acc.perimetreUe += plancherBas.donnee_entree.perimetre_ue || 0;
147
- return acc;
148
- },
149
- { surfaceUe: 0, perimetreUe: 0 }
150
- );
137
+ const surfaceUe = pbDE.surface_ue || pbDE.surface_paroi_opaque;
138
+ const perimetreUe = pbDE.perimetre_ue || 0;
151
139
 
152
140
  const available2sp = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 14, 16, 18, 20];
153
141
  let dsp = perimetreUe ? Math.round((2 * surfaceUe) / perimetreUe) : 1;
@@ -0,0 +1,250 @@
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest';
2
+ import { DeperditionPlancherBasService } from './deperdition-plancher-bas.service.js';
3
+
4
+ /**
5
+ * Tests unitaires isolés de `DeperditionPlancherBasService` (nouvelle architecture).
6
+ *
7
+ * Le `TvStore` est remplacé par un double de test (spies `vi.fn`) et le coefficient b (classe de
8
+ * base) est espionné : seule la logique propre au service (Upb0, Upb, Ue, type d'isolation) est
9
+ * vérifiée, sans dépendre des tables de valeurs réelles.
10
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.2.2
11
+ */
12
+
13
+ /** @type {{ getUpb0: import('vitest').Mock, getUpb: import('vitest').Mock, getUeByUpd: import('vitest').Mock }} */
14
+ let tvStore;
15
+
16
+ /** @type {DeperditionPlancherBasService} */
17
+ let service;
18
+
19
+ const ctx = {
20
+ zoneClimatique: { id: '1', value: 'h1a' },
21
+ effetJoule: false,
22
+ enumPeriodeConstructionId: '1'
23
+ };
24
+
25
+ beforeEach(() => {
26
+ tvStore = {
27
+ getUpb0: vi.fn(() => 1.45),
28
+ getUpb: vi.fn(() => 0.4),
29
+ getUeByUpd: vi.fn(() => 0.385384615)
30
+ };
31
+ service = new DeperditionPlancherBasService(tvStore);
32
+ vi.spyOn(service, 'b').mockReturnValue(1);
33
+ });
34
+
35
+ /** Plancher non isolé, U0 forfaitaire, adjacence extérieur par défaut. */
36
+ const plancherDE = (donnees = {}) => ({
37
+ enum_methode_saisie_u0_id: '2',
38
+ enum_methode_saisie_u_id: '1',
39
+ enum_type_plancher_bas_id: '2',
40
+ enum_type_adjacence_id: '1',
41
+ ...donnees
42
+ });
43
+
44
+ describe('DeperditionPlancherBasService - execute()', () => {
45
+ test('transmet les données du plancher au calcul de b', () => {
46
+ const pbDE = plancherDE({
47
+ surface_aiu: 10,
48
+ surface_aue: 20,
49
+ enum_cfg_isolation_lnc_id: '3'
50
+ });
51
+
52
+ const di = service.execute(ctx, pbDE);
53
+
54
+ expect(service.b).toHaveBeenCalledWith({
55
+ enumTypeAdjacenceId: '1',
56
+ surfaceAiu: 10,
57
+ surfaceAue: 20,
58
+ enumCfgIsolationLncId: '3',
59
+ zoneClimatique: 'h1a'
60
+ });
61
+ expect(di).toEqual({ upb0: 1.45, upb: 1.45, upb_final: 1.45, b: 1 });
62
+ });
63
+ });
64
+
65
+ describe('DeperditionPlancherBasService - calcul de Upb0', () => {
66
+ test.each(['1', '2'])('méthode u0 %s : valeur forfaitaire de la table upb0', (methode) => {
67
+ const di = service.execute(ctx, plancherDE({ enum_methode_saisie_u0_id: methode }));
68
+
69
+ expect(tvStore.getUpb0).toHaveBeenCalledWith('2');
70
+ expect(di.upb0).toBe(1.45);
71
+ });
72
+
73
+ test('méthode u0 5 (u saisi) : upb0 non déterminé', () => {
74
+ const di = service.execute(
75
+ ctx,
76
+ plancherDE({ enum_methode_saisie_u0_id: '5', enum_methode_saisie_u_id: '9', upb_saisi: 0.3 })
77
+ );
78
+
79
+ expect(di.upb0).toBeUndefined();
80
+ expect(di.upb).toBe(0.3);
81
+ });
82
+
83
+ test('u0 saisi : valeur saisie retenue', () => {
84
+ const di = service.execute(
85
+ ctx,
86
+ plancherDE({ enum_methode_saisie_u0_id: '3', upb0_saisi: 1.2 })
87
+ );
88
+
89
+ expect(tvStore.getUpb0).not.toHaveBeenCalled();
90
+ expect(di.upb0).toBe(1.2);
91
+ });
92
+ });
93
+
94
+ describe('DeperditionPlancherBasService - calcul de Upb', () => {
95
+ test('non isolé : upb = min(upb0, 2)', () => {
96
+ tvStore.getUpb0.mockReturnValue(3);
97
+
98
+ expect(service.execute(ctx, plancherDE()).upb).toBe(2);
99
+ });
100
+
101
+ test.each(['2', '7', '8'])(
102
+ 'méthode u %s (table forfaitaire) : min(upb nu, upb de la table)',
103
+ (methode) => {
104
+ const di = service.execute(ctx, plancherDE({ enum_methode_saisie_u_id: methode }));
105
+
106
+ // Construction avant 1975 sans période d'isolation => période d'isolation 3 (1975-1977)
107
+ expect(tvStore.getUpb).toHaveBeenCalledWith('3', '1', false);
108
+ expect(di.upb).toBe(0.4);
109
+ }
110
+ );
111
+
112
+ test.each(['3', '4'])('méthode u %s : épaisseur d’isolation', (methode) => {
113
+ const di = service.execute(
114
+ ctx,
115
+ plancherDE({ enum_methode_saisie_u_id: methode, epaisseur_isolation: 10 })
116
+ );
117
+
118
+ // 1 / (1 / 1,45 + 0,1 / 0,042) arrondi à 1e-5 (valeur de référence de régression)
119
+ expect(di.upb).toBe(0.32567);
120
+ });
121
+
122
+ test.each(['5', '6'])('méthode u %s : résistance d’isolation', (methode) => {
123
+ const di = service.execute(
124
+ ctx,
125
+ plancherDE({ enum_methode_saisie_u_id: methode, resistance_isolation: 2 })
126
+ );
127
+
128
+ // 1 / (1 / 1,45 + 2) arrondi à 1e-5 (valeur de référence de régression)
129
+ expect(di.upb).toBe(0.37179);
130
+ });
131
+
132
+ test('saisie directe : upb saisi', () => {
133
+ const di = service.execute(ctx, plancherDE({ enum_methode_saisie_u_id: '9', upb_saisi: 0.25 }));
134
+
135
+ expect(di.upb).toBe(0.25);
136
+ });
137
+ });
138
+
139
+ describe('DeperditionPlancherBasService - calcul de upb_final (Ue)', () => {
140
+ test('calcul_ue = 1 : ue du DPE retenu', () => {
141
+ const di = service.execute(
142
+ ctx,
143
+ plancherDE({ enum_type_adjacence_id: '5', calcul_ue: 1, ue: 0.38538462 })
144
+ );
145
+
146
+ expect(di.upb_final).toBe(0.38538462);
147
+ expect(tvStore.getUeByUpd).not.toHaveBeenCalled();
148
+ });
149
+
150
+ test('adjacence sans Ue (extérieur) : upb_final = upb', () => {
151
+ const di = service.execute(ctx, plancherDE({ enum_type_adjacence_id: '1' }));
152
+
153
+ expect(di.upb_final).toBe(1.45);
154
+ expect(tvStore.getUeByUpd).not.toHaveBeenCalled();
155
+ });
156
+
157
+ /**
158
+ * Issue #46 : ue calculé plancher par plancher, 2S/P issu du seul plancher courant.
159
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.2.2 (tableau ue)
160
+ */
161
+ describe('calcul plancher par plancher (#46)', () => {
162
+ test('2187E0982591I plancher 1 : 2S/P = 2 * 44,8 / 12,8 = 7', () => {
163
+ const di = service.execute(
164
+ ctx,
165
+ plancherDE({ enum_type_adjacence_id: '5', surface_paroi_opaque: 44.8, perimetre_ue: 12.8 })
166
+ );
167
+
168
+ expect(tvStore.getUeByUpd).toHaveBeenCalledWith('5', '1', 7, 1.45);
169
+ expect(di.upb_final).toBe(0.38538);
170
+ });
171
+
172
+ test('deux planchers de même adjacence : chacun son propre 2S/P', () => {
173
+ const pbA = plancherDE({
174
+ enum_type_adjacence_id: '5',
175
+ surface_paroi_opaque: 50,
176
+ surface_ue: 70,
177
+ perimetre_ue: 20
178
+ });
179
+ const pbB = plancherDE({
180
+ enum_type_adjacence_id: '5',
181
+ surface_paroi_opaque: 50,
182
+ surface_ue: 10,
183
+ perimetre_ue: 10
184
+ });
185
+ tvStore.getUeByUpd.mockImplementation((adj, pc, dsp) => (dsp === 7 ? 0.38 : 0.78));
186
+
187
+ const diA = service.execute(ctx, pbA);
188
+ const diB = service.execute(ctx, pbB);
189
+
190
+ // pbA : 2 * 70 / 20 = 7 ; pbB : 2 * 10 / 10 = 2 (somme par adjacence : 2 * 80 / 30 => 5)
191
+ expect(tvStore.getUeByUpd).toHaveBeenNthCalledWith(1, '5', '1', 7, 1.45);
192
+ expect(tvStore.getUeByUpd).toHaveBeenNthCalledWith(2, '5', '1', 2, 1.45);
193
+ expect(diA.upb_final).toBe(0.38);
194
+ expect(diB.upb_final).toBe(0.78);
195
+ });
196
+
197
+ test('surface_ue absente : surface_paroi_opaque utilisée', () => {
198
+ service.execute(
199
+ ctx,
200
+ plancherDE({ enum_type_adjacence_id: '3', surface_paroi_opaque: 20, perimetre_ue: 10 })
201
+ );
202
+
203
+ expect(tvStore.getUeByUpd).toHaveBeenCalledWith('3', '1', 4, 1.45);
204
+ });
205
+
206
+ test('2S/P hors tableau : valeur disponible la plus proche (11 => 10)', () => {
207
+ service.execute(
208
+ ctx,
209
+ plancherDE({ enum_type_adjacence_id: '6', surface_ue: 55, perimetre_ue: 10 })
210
+ );
211
+
212
+ expect(tvStore.getUeByUpd).toHaveBeenCalledWith('6', '1', 10, 1.45);
213
+ });
214
+
215
+ test('perimetre_ue absent : 2S/P = 1 (comportement inchangé)', () => {
216
+ service.execute(ctx, plancherDE({ enum_type_adjacence_id: '5', surface_paroi_opaque: 20 }));
217
+
218
+ expect(tvStore.getUeByUpd).toHaveBeenCalledWith('5', '1', 1, 1.45);
219
+ });
220
+ });
221
+ });
222
+
223
+ describe('DeperditionPlancherBasService - typeIsolation()', () => {
224
+ test.each([
225
+ ['terre-plein avant 2001', '5', undefined, '6', 2],
226
+ ['terre-plein à partir de 2001', '5', undefined, '7', 4],
227
+ ['terre-plein, période d’isolation prioritaire', '5', '7', '1', 4],
228
+ ['autre adjacence avant 1975', '3', undefined, '2', 2],
229
+ ['autre adjacence à partir de 1975', '3', undefined, '3', 4]
230
+ ])('isolation inconnue, %s', (_, adjacence, periodeIsolation, periodeConstruction, attendu) => {
231
+ expect(
232
+ service.typeIsolation(
233
+ { enumPeriodeConstructionId: periodeConstruction },
234
+ {
235
+ enum_type_isolation_id: '1',
236
+ enum_type_adjacence_id: adjacence,
237
+ enum_periode_isolation_id: periodeIsolation
238
+ }
239
+ )
240
+ ).toBe(attendu);
241
+ });
242
+
243
+ test('isolé mais type d’isolation inconnu (9) : ITE (4)', () => {
244
+ expect(service.typeIsolation(ctx, { enum_type_isolation_id: '9' })).toBe(4);
245
+ });
246
+
247
+ test('type d’isolation connu : renvoyé tel quel', () => {
248
+ expect(service.typeIsolation(ctx, { enum_type_isolation_id: '3' })).toBe(3);
249
+ });
250
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open3cl/engine",
3
- "version": "1.8.21",
3
+ "version": "1.8.22",
4
4
  "description": "Open Source 3CL-DPE engine",
5
5
  "main": "index.js",
6
6
  "directories": {