@open3cl/engine 1.8.67 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/11_ecs.js CHANGED
@@ -58,11 +58,12 @@ function tv_rendement_distribution_ecs(di, de, du, pvc) {
58
58
  * Calcul installation par installation pour les réseaux collectifs bouclés ou avec traçage.
59
59
  *
60
60
  * @param {object} de - donnee_entree de l'installation ECS
61
+ * @param {object} di - donnee_intermediaire de l'installation ECS (reçoit la longueur de bouclage lb)
61
62
  * @param {number} surfaceImmeuble - surface habitable totale de l'immeuble (m²)
62
63
  * @param {object} becsParMois - besoin ECS mensuel de l'immeuble {Janvier: X, Février: X, ...} [kWh]
63
64
  * @returns {number} conso_auxiliaire_distribution_ecs_installation [Wh]
64
65
  */
65
- function calc_auxiliaire_distribution_ecs_immeuble(de, surfaceImmeuble, becsParMois) {
66
+ function calc_auxiliaire_distribution_ecs_immeuble(de, di, surfaceImmeuble, becsParMois) {
66
67
  const type_installation = enums.type_installation[de.enum_type_installation_id];
67
68
 
68
69
  // Seulement pour les installations collectives
@@ -95,6 +96,7 @@ function calc_auxiliaire_distribution_ecs_immeuble(de, surfaceImmeuble, becsParM
95
96
  // Étape 3 : Calcul de Lb pour l'installation i
96
97
  // Lb = 1.2 × (1.1 × Sh_inst/nombre_niveaux + 4 × nombre_niveaux)
97
98
  const Lb = 1.2 * (1.1 * (Sh_inst / Niv_inst) + 4 * Niv_inst);
99
+ di.lb = Lb;
98
100
 
99
101
  // Étape 4 : DeltaPb = 0.2 × Lb + 10
100
102
  const DeltaPb = 0.2 * Lb + 10;
@@ -287,6 +289,7 @@ export default function calc_ecs(
287
289
  if (th === 'immeuble' && becsParMois && Object.keys(becsParMois).length > 0) {
288
290
  const conso_aux_wh = calc_auxiliaire_distribution_ecs_immeuble(
289
291
  de,
292
+ di,
290
293
  surfaceImmeuble,
291
294
  becsParMois
292
295
  );
package/11_ecs.spec.js CHANGED
@@ -734,4 +734,36 @@ describe('calc_ecs - auxiliaires de distribution ECS (immeuble)', () => {
734
734
 
735
735
  expect(sansNiveau).toBe(avecUnNiveau);
736
736
  });
737
+
738
+ /** Calcule l'ECS d'un immeuble et retourne la donnée intermédiaire de l'installation. */
739
+ function donneeIntermediaireImmeuble(deExtra) {
740
+ const ecs = makeEcs({ tv_rendement_distribution_ecs_id: '5', ...deExtra });
741
+ calc_ecs({}, ecs, 100, 200, 1, 'ca1', 'h1a', 'immeuble', false, 400, null, false, {
742
+ Janvier: 10000
743
+ });
744
+ return ecs.donnee_intermediaire;
745
+ }
746
+
747
+ test('réseau bouclé : longueur de bouclage lb exposée dans la donnée intermédiaire', () => {
748
+ const di = donneeIntermediaireImmeuble({
749
+ enum_type_installation_id: '2',
750
+ enum_bouclage_reseau_ecs_id: '2',
751
+ surface_habitable: 100,
752
+ nombre_niveau_installation_ecs: '2'
753
+ });
754
+
755
+ // Lb = 1,2 × (1,1 × 100 / 2 + 4 × 2) = 75,6 m
756
+ expect(di.lb).toBeCloseTo(75.6, 9);
757
+ });
758
+
759
+ test.each([
760
+ ['réseau avec traçage', { enum_type_installation_id: '2', enum_bouclage_reseau_ecs_id: '3' }],
761
+ [
762
+ 'réseau collectif non bouclé',
763
+ { enum_type_installation_id: '2', enum_bouclage_reseau_ecs_id: '1' }
764
+ ],
765
+ ['installation individuelle', { enum_type_installation_id: '1' }]
766
+ ])('%s : pas de longueur de bouclage lb', (_libelle, de) => {
767
+ expect(donneeIntermediaireImmeuble(de).lb).toBeUndefined();
768
+ });
737
769
  });
package/15_conso_aux.js CHANGED
@@ -283,6 +283,12 @@ function getPuissanceCirculateur(em_ch, de, di, du, surfaceHabitable, GV, Tbase)
283
283
  // Calcul de la longueur du réseau le plus défavorisé
284
284
  const Lem = 5 * Fcot * (nbNiveauChauffage + (surfaceInstallation / nbNiveauChauffage) ** 0.5);
285
285
 
286
+ // Exposition de la longueur du réseau (lb) dans les données intermédiaires des émetteurs desservis
287
+ em_ch.forEach((em) => {
288
+ em.donnee_intermediaire = em.donnee_intermediaire || {};
289
+ em.donnee_intermediaire.lb = Lem;
290
+ });
291
+
286
292
  // Pertes de charge du réseau (kPa)
287
293
  const deltaPemnom = 0.15 * Lem + deltaPem;
288
294
 
@@ -412,6 +418,7 @@ export function conso_aux_distribution_ecs(
412
418
  // Pour l'appartement, on utilise la surface à l'échelle de l'immeuble
413
419
  const Sh = (Sh_install / Sh_logement) * Sh_immeuble_ratio;
414
420
  const Lb = 4 * Math.pow(Sh / Niv_inst_ecs, 0.5) + 6 * (Niv_inst_ecs - 0.5);
421
+ di.lb = Lb;
415
422
 
416
423
  // Etape 4: DeltaPb (kPa) - perte de charge dans le bouclage
417
424
  const DeltaPb = 0.2 * Lb + 10;
@@ -389,6 +389,73 @@ describe('conso_aux_distribution_ch - auxiliaires de distribution', () => {
389
389
  );
390
390
  });
391
391
 
392
+ /**
393
+ * La longueur du réseau le plus défavorisé (Lem) est exposée sous le nom lb dans la donnée
394
+ * intermédiaire de chaque émetteur desservi par le circulateur.
395
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §15.2.1
396
+ */
397
+ test('longueur du réseau lb exposée dans la donnée intermédiaire des émetteurs', () => {
398
+ const em = [
399
+ {
400
+ donnee_entree: {
401
+ enum_type_emission_distribution_id: '10',
402
+ enum_temp_distribution_ch_id: '3'
403
+ },
404
+ donnee_intermediaire: { rendement_emission: 0.95 }
405
+ }
406
+ ];
407
+ conso_aux_distribution_ch(em, {}, {}, {}, 100, 1, 1, '0', 100000);
408
+ // Lem = 5 × Fcot × (Niv + (Sh / Niv)^0,5) = 5 × 0,802 × (1 + 10) = 44,11 m
409
+ expect(em[0].donnee_intermediaire.lb).toBeCloseTo(44.11, 9);
410
+ // les autres données intermédiaires de l'émetteur sont conservées
411
+ expect(em[0].donnee_intermediaire.rendement_emission).toBe(0.95);
412
+ });
413
+
414
+ test('lb : plancher chauffant sur 2 niveaux, donnée intermédiaire absente créée', () => {
415
+ const em = [
416
+ {
417
+ donnee_entree: {
418
+ enum_type_emission_distribution_id: '6',
419
+ enum_temp_distribution_ch_id: '3'
420
+ }
421
+ }
422
+ ];
423
+ conso_aux_distribution_ch(em, { nombre_niveau_installation_ch: 2 }, {}, {}, 200, 1, 1, '0', 1);
424
+ // Lem = 5 × 0,156 × (2 + (200 / 2)^0,5) = 9,36 m
425
+ expect(em[0].donnee_intermediaire.lb).toBeCloseTo(9.36, 9);
426
+ });
427
+
428
+ test('lb : seuls les émetteurs reliés au générateur reçoivent la longueur du réseau', () => {
429
+ const plancher = {
430
+ donnee_entree: {
431
+ enum_type_emission_distribution_id: '6',
432
+ enum_temp_distribution_ch_id: '3',
433
+ enum_lien_generateur_emetteur_id: '1'
434
+ }
435
+ };
436
+ const convecteur = {
437
+ donnee_entree: {
438
+ enum_type_emission_distribution_id: '1',
439
+ enum_temp_distribution_ch_id: '3',
440
+ enum_lien_generateur_emetteur_id: '2'
441
+ }
442
+ };
443
+ conso_aux_distribution_ch(
444
+ [plancher, convecteur],
445
+ { enum_lien_generateur_emetteur_id: '1' },
446
+ {},
447
+ {},
448
+ 100,
449
+ 1,
450
+ 1,
451
+ '0',
452
+ 100000
453
+ );
454
+ // Lem = 5 × 0,156 × (1 + 10) = 8,58 m
455
+ expect(plancher.donnee_intermediaire.lb).toBeCloseTo(8.58, 9);
456
+ expect(convecteur.donnee_intermediaire).toBeUndefined();
457
+ });
458
+
392
459
  test('aucun émetteur relié au générateur : tous les émetteurs sont conservés', () => {
393
460
  const em = (lien) => ({
394
461
  donnee_entree: {
@@ -776,6 +843,40 @@ describe("conso_aux_distribution_ecs - auxiliaires de distribution d'ECS", () =>
776
843
  expect(di.conso_auxiliaire_distribution_ecs).toBeCloseTo(2.4208575056559525, 9);
777
844
  });
778
845
 
846
+ test('réseau bouclé : longueur de bouclage lb exposée dans la donnée intermédiaire', () => {
847
+ const di = {};
848
+ conso_aux_distribution_ecs(
849
+ {},
850
+ {
851
+ enum_type_installation_id: '2',
852
+ enum_bouclage_reseau_ecs_id: '2',
853
+ surface_habitable: 50,
854
+ nombre_niveau_installation_ecs: 4
855
+ },
856
+ di,
857
+ 50,
858
+ 1600,
859
+ 1,
860
+ 1,
861
+ 30
862
+ );
863
+ // Sh = 1600 m² (échelle immeuble) : Lb = 4 × (1600 / 4)^0,5 + 6 × (4 − 0,5) = 80 + 21 = 101 m
864
+ expect(di.lb).toBeCloseTo(101, 9);
865
+ });
866
+
867
+ test.each([
868
+ [
869
+ 'installation individuelle',
870
+ { enum_type_installation_id: '1', enum_bouclage_reseau_ecs_id: '2' }
871
+ ],
872
+ ['réseau non bouclé', { enum_type_installation_id: '2', enum_bouclage_reseau_ecs_id: '1' }],
873
+ ['réseau avec traçage', { enum_type_installation_id: '2', enum_bouclage_reseau_ecs_id: '3' }]
874
+ ])('%s : pas de longueur de bouclage lb', (_libelle, de) => {
875
+ const di = { besoin_ecs: 1000 };
876
+ conso_aux_distribution_ecs({}, de, di, 50, 2000, 1, 1, 30);
877
+ expect(di.lb).toBeUndefined();
878
+ });
879
+
779
880
  test('plus de niveaux desservis : bouclage plus court, donc consommation plus faible', () => {
780
881
  const scenario = (Niv) => {
781
882
  const di = {};
package/3.2.1_mur.js CHANGED
@@ -304,12 +304,15 @@ function calc_umur0(di, de, du, versionDpe) {
304
304
 
305
305
  // Certaines descriptions contiennent des informations sur le type de doublage
306
306
  if (bug_for_bug_compat && (type_doublage === 1 || type_doublage === 2)) {
307
- if (de.description.toLowerCase().indexOf('doublage connu (plâtre, brique') !== -1) {
308
- type_doublage = 5;
309
- } else if (de.description.toLowerCase().indexOf('doublage indéterminé avec lame') !== -1) {
310
- type_doublage = 4;
311
- } else if (de.description.toLowerCase().indexOf('doublage indéterminé ou lame') !== -1) {
312
- type_doublage = 3;
307
+ const description = de.description?.toLowerCase();
308
+ if (description) {
309
+ if (description.toLowerCase().indexOf('doublage connu (plâtre, brique') !== -1) {
310
+ type_doublage = 5;
311
+ } else if (description.toLowerCase().indexOf('doublage indéterminé avec lame') !== -1) {
312
+ type_doublage = 4;
313
+ } else if (description.toLowerCase().indexOf('doublage indéterminé ou lame') !== -1) {
314
+ type_doublage = 3;
315
+ }
313
316
  }
314
317
  }
315
318
 
package/4_ventilation.js CHANGED
@@ -29,6 +29,15 @@ export function presenceJointNonRenseignee(porte_list, bv_list) {
29
29
  );
30
30
  }
31
31
 
32
+ /**
33
+ * Vrai si la ligne de la table `q4pa_conv` couvre la période de construction du DPE.
34
+ */
35
+ export function isLigneQ4paMemePeriode(rowQ4paConv, cg) {
36
+ return String(rowQ4paConv?.enum_periode_construction_id ?? '')
37
+ .split('|')
38
+ .includes(String(cg.enum_periode_construction_id));
39
+ }
40
+
32
41
  function tv_q4pa_conv(di, de, cg, mur_list, ph_list, porte_list, bv_list) {
33
42
  const surfaces = mur_list.concat(ph_list);
34
43
  const surface_isolee = surfaces.reduce((acc, s) => {
@@ -91,11 +100,29 @@ function tv_q4pa_conv(di, de, cg, mur_list, ph_list, porte_list, bv_list) {
91
100
  });
92
101
 
93
102
  if (rowQ4paConv) {
103
+ let ligneDpeReprise = false;
104
+ /**
105
+ * Le moteur de référence reçoit Q4Pa_conv en entrée : c'est le logiciel de saisie qui choisit
106
+ * la ligne de la table. Certains logiciels (LICIEL notamment) retiennent une ligne dont le
107
+ * critère `isolation_surfaces` contredit l'isolation déclarée des parois (ex. ligne 10 « avant
108
+ * 1948, surfaces isolées », 2, alors que toutes les parois sont non isolées → ligne 1, 4,6).
109
+ * Si la ligne saisie correspond à la période de construction du DPE, on la reprend en entier
110
+ * (isolation et joints) pour reproduire le calcul du logiciel.
111
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §4
112
+ */
94
113
  if (rowQ4paConv.isolation_surfaces !== isolation_surfaces) {
95
- console.error(
96
- `Calcul de hperm pour la ventilation ${de.description}. Le DPE considère isolation_surfaces = ${rowQ4paConv.isolation_surfaces}
97
- alors que ce devrait être isolation_surfaces = ${isolation_surfaces}. La valeur recalculée isolation_surfaces = ${isolation_surfaces} est utilisée.`
98
- );
114
+ if (isLigneQ4paMemePeriode(rowQ4paConv, cg)) {
115
+ console.error(
116
+ `Calcul de hperm pour la ventilation ${de.description}. Le DPE considère isolation_surfaces = ${rowQ4paConv.isolation_surfaces} \n alors que ce devrait être isolation_surfaces = ${isolation_surfaces}. La ligne q4pa_conv ${de.tv_q4pa_conv_id} du DPE est conservée.`
117
+ );
118
+ isolation_surfaces = rowQ4paConv.isolation_surfaces;
119
+ pjt = rowQ4paConv.presence_joints_menuiserie === '1' ? '1' : '0';
120
+ ligneDpeReprise = true;
121
+ } else {
122
+ console.error(
123
+ `Calcul de hperm pour la ventilation ${de.description}. Le DPE considère isolation_surfaces = ${rowQ4paConv.isolation_surfaces} \n alors que ce devrait être isolation_surfaces = ${isolation_surfaces}. La valeur recalculée isolation_surfaces = ${isolation_surfaces} est utilisée.`
124
+ );
125
+ }
99
126
  }
100
127
 
101
128
  /**
@@ -105,9 +132,9 @@ function tv_q4pa_conv(di, de, cg, mur_list, ph_list, porte_list, bv_list) {
105
132
  * sans joint.
106
133
  * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §4
107
134
  */
108
- if (presenceJointNonRenseignee(porte_list, bv_list)) {
135
+ if (!ligneDpeReprise && presenceJointNonRenseignee(porte_list, bv_list)) {
109
136
  pjt = rowQ4paConv.presence_joints_menuiserie === '1' ? '1' : '0';
110
- } else if (rowQ4paConv.presence_joints_menuiserie !== pjt) {
137
+ } else if (!ligneDpeReprise && rowQ4paConv.presence_joints_menuiserie !== pjt) {
111
138
  console.error(
112
139
  `Calcul de hperm pour la ventilation ${de.description}. Le DPE considère presence_joints_menuiserie = ${rowQ4paConv.presence_joints_menuiserie}
113
140
  alors que ce devrait être presence_joints_menuiserie = ${pjt}. La valeur recalculée presence_joints_menuiserie = ${pjt} est utilisée.`
@@ -22,7 +22,8 @@ vi.mock('./5_conso_ventilation.js', () => ({
22
22
  const {
23
23
  default: calc_ventilation,
24
24
  calc_hperm,
25
- presenceJointNonRenseignee
25
+ presenceJointNonRenseignee,
26
+ isLigneQ4paMemePeriode
26
27
  } = await import('./4_ventilation.js');
27
28
  const { tv } = await import('./utils.js');
28
29
  const { default: calc_pvent } = await import('./5_conso_ventilation.js');
@@ -480,6 +481,86 @@ describe('calc_ventilation - compatibilité bug_for_bug_compat activée', () =>
480
481
  errorSpy.mockRestore();
481
482
  });
482
483
  });
484
+
485
+ /**
486
+ * Ligne q4pa_conv du DPE dont le critère d'isolation contredit les parois (2287E1201982M :
487
+ * ligne 10 « avant 1948, surfaces isolées » alors que toutes les parois sont non isolées).
488
+ */
489
+ describe('isolation_surfaces incohérent avec la ligne du DPE', () => {
490
+ const murNonIsole = [
491
+ {
492
+ donnee_intermediaire: { b: 1 },
493
+ donnee_entree: { enum_type_isolation_id: '2', surface_paroi_opaque: 100 }
494
+ }
495
+ ];
496
+ const baieSansJoint = [{ donnee_entree: { surface_totale_baie: 10, presence_joint: 0 } }];
497
+ const vtLigne = (id) => ({
498
+ donnee_entree: { enum_type_ventilation_id: '1', tv_q4pa_conv_id: id, description: 'v' }
499
+ });
500
+ const ROW_10 = {
501
+ q4pa_conv: '2',
502
+ tv_q4pa_conv_id: '10',
503
+ enum_periode_construction_id: '1',
504
+ isolation_surfaces: '1'
505
+ };
506
+
507
+ test('ligne de la même période : isolation et joints de la ligne du DPE repris', async () => {
508
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
509
+ const { calc_ventilation_bug, tvBug } = await chargerAvecBug(ROW_10);
510
+
511
+ calc_ventilation_bug(vtLigne(10), CG, 'th', 80, 100, murNonIsole, [], [], baieSansJoint);
512
+
513
+ expect(tvBug).toHaveBeenLastCalledWith(
514
+ 'q4pa_conv',
515
+ expect.objectContaining({ isolation_surfaces: '1', presence_joints_menuiserie: '0' })
516
+ );
517
+ expect(errorSpy).toHaveBeenCalledWith(
518
+ expect.stringContaining('La ligne q4pa_conv 10 du DPE est conservée')
519
+ );
520
+ // les joints de la ligne reprise concordent : pas de second message
521
+ expect(errorSpy).toHaveBeenCalledTimes(1);
522
+ errorSpy.mockRestore();
523
+ });
524
+
525
+ test('ligne 12 (non isolé, avec joints) reprise en entier, malgré des baies sans joint', async () => {
526
+ const { calc_ventilation_bug, tvBug } = await chargerAvecBug({
527
+ q4pa_conv: '2.5',
528
+ tv_q4pa_conv_id: '12',
529
+ enum_periode_construction_id: '1',
530
+ isolation_surfaces: '0',
531
+ presence_joints_menuiserie: '1'
532
+ });
533
+ vi.spyOn(console, 'error').mockImplementation(() => {});
534
+ const { murs } = surfacesIsolantes();
535
+
536
+ calc_ventilation_bug(vtLigne(12), CG, 'th', 80, 100, murs, [], [], baieSansJoint);
537
+
538
+ expect(tvBug).toHaveBeenLastCalledWith(
539
+ 'q4pa_conv',
540
+ expect.objectContaining({ isolation_surfaces: '0', presence_joints_menuiserie: '1' })
541
+ );
542
+ });
543
+
544
+ test('ligne d’une autre période : la valeur recalculée est conservée', async () => {
545
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
546
+ const { calc_ventilation_bug, tvBug } = await chargerAvecBug({
547
+ ...ROW_10,
548
+ tv_q4pa_conv_id: '11',
549
+ enum_periode_construction_id: '2'
550
+ });
551
+
552
+ calc_ventilation_bug(vtLigne(11), CG, 'th', 80, 100, murNonIsole, [], [], baieSansJoint);
553
+
554
+ expect(tvBug).toHaveBeenLastCalledWith(
555
+ 'q4pa_conv',
556
+ expect.objectContaining({ isolation_surfaces: '0' })
557
+ );
558
+ expect(errorSpy).toHaveBeenCalledWith(
559
+ expect.stringContaining('La valeur recalculée isolation_surfaces = 0 est utilisée')
560
+ );
561
+ errorSpy.mockRestore();
562
+ });
563
+ });
483
564
  });
484
565
 
485
566
  describe('presenceJointNonRenseignee', () => {
@@ -506,3 +587,28 @@ describe('presenceJointNonRenseignee', () => {
506
587
  ).toBe(false);
507
588
  });
508
589
  });
590
+
591
+ describe('isLigneQ4paMemePeriode', () => {
592
+ test('période contenue dans une liste « a|b|c » : vrai', () => {
593
+ expect(
594
+ isLigneQ4paMemePeriode(
595
+ { enum_periode_construction_id: '3|4|5' },
596
+ { enum_periode_construction_id: 4 }
597
+ )
598
+ ).toBe(true);
599
+ });
600
+
601
+ test('période différente : faux', () => {
602
+ expect(
603
+ isLigneQ4paMemePeriode(
604
+ { enum_periode_construction_id: '2' },
605
+ { enum_periode_construction_id: '1' }
606
+ )
607
+ ).toBe(false);
608
+ });
609
+
610
+ test('ligne sans période (ou absente) : faux', () => {
611
+ expect(isLigneQ4paMemePeriode({}, { enum_periode_construction_id: '1' })).toBe(false);
612
+ expect(isLigneQ4paMemePeriode(null, { enum_periode_construction_id: '1' })).toBe(false);
613
+ });
614
+ });
package/README.md CHANGED
@@ -154,11 +154,12 @@ flowchart LR
154
154
 
155
155
  ### L'écosystème Open3CL
156
156
 
157
- | Ressource | Description |
158
- | :--------------------------------------------------------------- | :------------------------------------------------------------------------- |
159
- | [Démo 3D](https://open3cl.github.io/engine/) | Visualisation 3D des déperditions d'un DPE, recalculées dans le navigateur |
160
- | [open3cl.fr](https://open3cl.fr) | Le site du projet |
161
- | [@open3cl/engine](https://www.npmjs.com/package/@open3cl/engine) | Le paquet npm |
157
+ | Ressource | Description |
158
+ | :--------------------------------------------------------------------- | :------------------------------------------------------------------------- |
159
+ | [Démo 3D](https://open3cl.github.io/engine/) | Visualisation 3D des déperditions d'un DPE, recalculées dans le navigateur |
160
+ | [Rapports de corpus](https://open3cl.github.io/engine/reports/corpus/) | Rapport HTML sur les corpus DPE |
161
+ | [open3cl.fr](https://open3cl.fr) | Le site du projet |
162
+ | [@open3cl/engine](https://www.npmjs.com/package/@open3cl/engine) | Le paquet npm |
162
163
 
163
164
  <p align="right">(<a href="#readme-top">Retour sommaire</a>)</p>
164
165
 
@@ -358,11 +359,11 @@ npm run test:corpus -- corpus-file-path=corpus.csv
358
359
  npm run test:corpus -- dpes-code=2592E1233185X
359
360
  ```
360
361
 
361
- | Argument | Description |
362
- | :------------------------ | :------------------------------------------------------------------------- |
363
- | `corpus-file-path=<path>` | Fichier de corpus à analyser (défaut : `test/corpus/files/corpus_dpe.csv`) |
364
- | `dpes-folder-path=<path>` | Dossier de cache des DPE. Un DPE déjà présent n'est pas retéléchargé |
365
- | `dpes-code=<code>` | Ne rejoue qu'un seul DPE |
362
+ | Argument | Description |
363
+ | :------------------------ | :------------------------------------------------------------------------------- |
364
+ | `corpus-file-path=<path>` | Fichier de corpus à analyser (défaut : tous les fichiers de `test/corpus/files`) |
365
+ | `dpes-folder-path=<path>` | Dossier de cache des DPE. Un DPE déjà présent n'est pas retéléchargé |
366
+ | `dpes-code=<code>` | Ne rejoue qu'un seul DPE |
366
367
 
367
368
  > [!TIP]
368
369
  > Définissez `DPE_FOLDER_PATH` une fois pour toutes plutôt que de répéter `dpes-folder-path`. Les DPE absents du cache
@@ -375,57 +376,57 @@ Ces résultats sont **générés automatiquement** à la fin de `npm run test:co
375
376
 
376
377
  <!-- CORPUS:START -->
377
378
 
378
- > **Version `1.8.66`** · branche `main` · généré le 2026-10-16
379
+ > **Version `1.8.67`** · branche `main` · généré le 2026-10-16
379
380
  > Seuil de tolérance **5%**
380
381
 
381
382
  <table>
382
383
  <tr>
383
384
  <td align="center"><strong>9</strong><br/><sub>corpus</sub></td>
384
- <td align="center"><strong>88 332</strong><br/><sub>DPE analysés</sub></td>
385
- <td align="center"><strong>71 434</strong><br/><sub>DPE conformes</sub></td>
386
- <td align="center"><strong>80,87 %</strong><br/><sub>réussite globale</sub></td>
385
+ <td align="center"><strong>88 475</strong><br/><sub>DPE analysés</sub></td>
386
+ <td align="center"><strong>75 663</strong><br/><sub>DPE conformes</sub></td>
387
+ <td align="center"><strong>85,52 %</strong><br/><sub>réussite globale</sub></td>
387
388
  </tr>
388
389
  </table>
389
390
 
390
- | | Corpus | Réussite | | DPE conformes |
391
- | :-: | :---------------------------------------------------------------------------------------------------------------------- | ----------: | :--------------------- | ------------: |
392
- | 🔴 | **Généraliste**<br/><sub>`corpus_dpe.csv`</sub> | **49,26 %** | `██████████░░░░░░░░░░` | 4 856 / 9 857 |
393
- | 🟢 | **Appartement · chauffage individuel (2025)**<br/><sub>`dpe_appartement_individuel_chauffage_individuel_2025.csv`</sub> | **95,39 %** | `███████████████████░` | 9 437 / 9 893 |
394
- | 🟢 | **Appartement · chauffage collectif (2025)**<br/><sub>`dpe_appartement_individuel_chauffage_collectif_2025.csv`</sub> | **94,54 %** | `███████████████████░` | 9 384 / 9 926 |
395
- | 🟢 | **Logement individuel (2025)**<br/><sub>`dpe_logement_individuel_2025.csv`</sub> | **93,29 %** | `███████████████████░` | 9 173 / 9 833 |
396
- | 🟢 | **Maison individuelle (2025)**<br/><sub>`dpe_maison_individuelle_2025.csv`</sub> | **92,03 %** | `██████████████████░░` | 8 992 / 9 771 |
397
- | 🟢 | **Immeuble · chauffage collectif**<br/><sub>`dpe_immeuble_chauffage_collectif.csv`</sub> | **89,10 %** | `██████████████████░░` | 8 677 / 9 739 |
398
- | 🟢 | **Immeuble · chauffage individuel**<br/><sub>`dpe_immeuble_chauffage_individuel.csv`</sub> | **86,00 %** | `█████████████████░░░` | 8 535 / 9 924 |
399
- | 🟡 | **Immeuble · chauffage mixte**<br/><sub>`dpe_immeuble_chauffage_mixte.csv`</sub> | **70,48 %** | `██████████████░░░░░░` | 6 746 / 9 571 |
400
- | 🔴 | **Individuel généré depuis l'immeuble (2026)**<br/><sub>`dpe_individuel_a_partir_dpe_immeuble_2026.csv`</sub> | **57,38 %** | `███████████░░░░░░░░░` | 5 634 / 9 818 |
391
+ | | Corpus | Réussite | | DPE conformes |
392
+ | :-: | :---------------------------------------------------------------------------------------------------------------------- | ----------: | :--------------------- | -------------: |
393
+ | 🟢 | **Appartement · chauffage individuel (2025)**<br/><sub>`dpe_appartement_individuel_chauffage_individuel_2025.csv`</sub> | **95,39 %** | `███████████████████░` | 9 437 / 9 893 |
394
+ | 🟢 | **Appartement · chauffage collectif (2025)**<br/><sub>`dpe_appartement_individuel_chauffage_collectif_2025.csv`</sub> | **94,54 %** | `███████████████████░` | 9 384 / 9 926 |
395
+ | 🟢 | **Logement individuel (2025)**<br/><sub>`dpe_logement_individuel_2025.csv`</sub> | **93,29 %** | `███████████████████░` | 9 173 / 9 833 |
396
+ | 🟢 | **Maison individuelle (2025)**<br/><sub>`dpe_maison_individuelle_2025.csv`</sub> | **92,03 %** | `██████████████████░░` | 8 992 / 9 771 |
397
+ | 🟢 | **Maison individuelle (2026)**<br/><sub>`dpe_maison_individuelle_2026.csv`</sub> | **90,41 %** | `██████████████████░░` | 9 041 / 10 000 |
398
+ | 🟢 | **Immeuble · chauffage collectif**<br/><sub>`dpe_immeuble_chauffage_collectif.csv`</sub> | **89,10 %** | `██████████████████░░` | 8 677 / 9 739 |
399
+ | 🟢 | **Immeuble · chauffage individuel**<br/><sub>`dpe_immeuble_chauffage_individuel.csv`</sub> | **86,00 %** | `█████████████████░░░` | 8 535 / 9 924 |
400
+ | 🟡 | **Immeuble · chauffage mixte**<br/><sub>`dpe_immeuble_chauffage_mixte.csv`</sub> | **70,48 %** | `██████████████░░░░░░` | 6 746 / 9 571 |
401
+ | 🔴 | **Individuel généré depuis l'immeuble (2026)**<br/><sub>`dpe_individuel_a_partir_dpe_immeuble_2026.csv`</sub> | **57,83 %** | `████████████░░░░░░░░` | 5 678 / 9 818 |
401
402
 
402
403
  <sub>🟢 ≥ 85 % · 🟡 ≥ 60 % · 🔴 < 60 %</sub>
403
404
 
404
405
  #### Temps d’exécution
405
406
 
406
- > Durée de l’appel à `calcul_3cl` par DPE, sur 88 298 calculs.
407
+ > Durée de l’appel à `calcul_3cl` par DPE, sur 88 460 calculs.
407
408
  > La copie défensive de l’entrée et la lecture du fichier sont exclues de la mesure.
408
409
 
409
410
  <table>
410
411
  <tr>
411
- <td align="center"><strong>5,90 ms</strong><br/><sub>moyenne</sub></td>
412
- <td align="center"><strong>5,27 ms</strong><br/><sub>médiane</sub></td>
413
- <td align="center"><strong>0,11 ms</strong><br/><sub>min</sub></td>
414
- <td align="center"><strong>874,0 ms</strong><br/><sub>max</sub></td>
412
+ <td align="center"><strong>10,2 ms</strong><br/><sub>moyenne</sub></td>
413
+ <td align="center"><strong>8,72 ms</strong><br/><sub>médiane</sub></td>
414
+ <td align="center"><strong>0,18 ms</strong><br/><sub>min</sub></td>
415
+ <td align="center"><strong>618,1 ms</strong><br/><sub>max</sub></td>
415
416
  </tr>
416
417
  </table>
417
418
 
418
419
  | Corpus | Moyenne | Médiane | Min | Max | p95 | p99 |
419
420
  | :--------------------------------------------- | ------: | ------: | ------: | -------: | ------: | ------: |
420
- | **Généraliste** | 4,71 ms | 4,25 ms | 0,21 ms | 35,5 ms | 8,47 ms | 11,9 ms |
421
- | **Appartement · chauffage individuel (2025)** | 4,35 ms | 4,04 ms | 1,48 ms | 41,0 ms | 7,22 ms | 9,56 ms |
422
- | **Appartement · chauffage collectif (2025)** | 4,56 ms | 4,20 ms | 1,42 ms | 38,5 ms | 7,37 ms | 9,29 ms |
423
- | **Logement individuel (2025)** | 6,03 ms | 5,55 ms | 1,79 ms | 39,2 ms | 10,8 ms | 14,1 ms |
424
- | **Maison individuelle (2025)** | 7,11 ms | 6,66 ms | 2,05 ms | 40,4 ms | 12,1 ms | 16,0 ms |
425
- | **Immeuble · chauffage collectif** | 6,10 ms | 5,34 ms | 0,22 ms | 102,6 ms | 11,1 ms | 19,0 ms |
426
- | **Immeuble · chauffage individuel** | 7,30 ms | 6,25 ms | 0,11 ms | 146,4 ms | 14,7 ms | 23,0 ms |
427
- | **Immeuble · chauffage mixte** | 6,13 ms | 5,11 ms | 0,18 ms | 874,0 ms | 12,7 ms | 20,3 ms |
428
- | **Individuel généré depuis l'immeuble (2026)** | 6,82 ms | 6,06 ms | 1,88 ms | 56,5 ms | 12,8 ms | 19,6 ms |
421
+ | **Appartement · chauffage individuel (2025)** | 6,87 ms | 6,25 ms | 1,73 ms | 79,3 ms | 12,0 ms | 17,0 ms |
422
+ | **Appartement · chauffage collectif (2025)** | 7,91 ms | 6,85 ms | 2,08 ms | 66,2 ms | 15,1 ms | 21,7 ms |
423
+ | **Logement individuel (2025)** | 9,05 ms | 8,08 ms | 2,03 ms | 104,0 ms | 16,9 ms | 24,1 ms |
424
+ | **Maison individuelle (2025)** | 11,2 ms | 9,96 ms | 2,50 ms | 109,6 ms | 20,2 ms | 30,4 ms |
425
+ | **Maison individuelle (2026)** | 11,8 ms | 10,3 ms | 2,74 ms | 120,6 ms | 22,7 ms | 34,3 ms |
426
+ | **Immeuble · chauffage collectif** | 12,0 ms | 9,62 ms | 0,21 ms | 197,5 ms | 25,2 ms | 42,9 ms |
427
+ | **Immeuble · chauffage individuel** | 12,2 ms | 10,3 ms | 0,18 ms | 228,3 ms | 24,6 ms | 39,3 ms |
428
+ | **Immeuble · chauffage mixte** | 9,68 ms | 7,89 ms | 0,35 ms | 618,1 ms | 20,2 ms | 33,5 ms |
429
+ | **Individuel généré depuis l'immeuble (2026)** | 10,8 ms | 9,27 ms | 2,42 ms | 114,5 ms | 20,3 ms | 34,2 ms |
429
430
 
430
431
  <sub>La moyenne est tirée vers le haut par les DPE collectifs, dont le coût atteint plusieurs
431
432
  dizaines de fois la médiane : c’est la médiane qui décrit le cas courant, et p95/p99 le cas
@@ -2,6 +2,14 @@ import { ObjectUtil } from './core/util/infrastructure/object-util.js';
2
2
  import { set, has } from 'lodash-es';
3
3
 
4
4
  const nodesToMap = [
5
+ 'mur',
6
+ 'plancher_bas',
7
+ 'plancher_haut',
8
+ 'baie_vitree',
9
+ 'porte',
10
+ 'pont_thermique',
11
+ 'ventilation',
12
+ 'climatisation',
5
13
  'installation_ecs',
6
14
  'generateur_ecs',
7
15
  'installation_chauffage',
@@ -95,6 +95,15 @@ describe('DpeSanitizerService - transformation des valeurs', () => {
95
95
  expect(valueTransform(generateur, 'generateur_ecs')).toEqual([generateur]);
96
96
  });
97
97
 
98
+ test.each(COLLECTIONS.map((path) => path.split('.').pop()))(
99
+ 'encapsule dans un tableau un nœud %s unique (collection à un seul élément)',
100
+ (key) => {
101
+ const valueTransform = getValueTransform();
102
+ const node = { donnee_entree: { description: 'unique' } };
103
+ expect(valueTransform(node, key)).toEqual([node]);
104
+ }
105
+ );
106
+
98
107
  test('ne ré-encapsule pas un nœud déjà sous forme de tableau', () => {
99
108
  const valueTransform = getValueTransform();
100
109
  const deja = [{ id: 1 }];
package/engine.js CHANGED
@@ -35,7 +35,7 @@ import DpeSanitizerService from './dpe-sanitizer.service.js';
35
35
  import { reconstituerRdimDpeGenereImmeuble } from './17.2_rdim_dpe_genere_immeuble.js';
36
36
  import { reconstituerRdimEcsIndividuelleUnique } from './11_rdim_ecs_individuelle_unique.js';
37
37
 
38
- const LIB_VERSION = '1.8.67';
38
+ const LIB_VERSION = '1.9.0';
39
39
 
40
40
  const dpeSanitizerService = new DpeSanitizerService();
41
41
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open3cl/engine",
3
- "version": "1.8.67",
3
+ "version": "1.9.0",
4
4
  "description": "Open Source 3CL-DPE engine",
5
5
  "main": "index.js",
6
6
  "directories": {
@@ -19,6 +19,7 @@
19
19
  "test:corpus": "node test/corpus/run_all_corpus_checks.js",
20
20
  "test:corpus:all": "npm run reports:clean && node test/corpus/run_all_corpus_checks.js && npm run reports:readme",
21
21
  "test:corpus:all+database": "npm run test:corpus:all && node test/corpus/corpus_database.js",
22
+ "test:corpus:one": "npm run test:corpus -- corpus-file-path=dpe_maison_individuelle_2026.csv",
22
23
  "qa:lint": "eslint .",
23
24
  "qa:lint:fix": "npm run qa:lint -- --fix",
24
25
  "qa:duplication": "jscpd",