@open3cl/engine 1.8.12 → 1.8.14

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.
@@ -200,6 +200,7 @@ export function tv_generateur_combustion(dpe, di, de, type, GV, tbase, methodeSa
200
200
  if (
201
201
  bug_for_bug_compat &&
202
202
  type === 'ecs' &&
203
+ row &&
203
204
  row.tv_generateur_combustion_id !== de.tv_generateur_combustion_id
204
205
  ) {
205
206
  const rowDpe = tv('generateur_combustion', {
@@ -223,7 +224,7 @@ export function tv_generateur_combustion(dpe, di, de, type, GV, tbase, methodeSa
223
224
 
224
225
  if (!row) {
225
226
  console.error(
226
- 'Pas de valeur forfaitaire trouvée pour le générateur à combustion ${de.description}'
227
+ `Pas de valeur forfaitaire trouvée pour le générateur à combustion ${de.description} (${typeGenerateurKey} = '${enumTypeGenerateurId}')`
227
228
  );
228
229
  return;
229
230
  }
@@ -377,21 +378,21 @@ function checkEcsVsChauffageForMixteGeneration(dpe, de, typeGenerateurKey) {
377
378
  );
378
379
  }, []);
379
380
 
380
- if (generateursChMixtes.length) {
381
- const firstChGenerateurMixte = generateursChMixtes[0];
382
- const firstChGenerateurMixteId =
383
- firstChGenerateurMixte.donnee_entree.enum_type_generateur_ch_id;
381
+ const chGenerateurMixte = findGenerateurChMixteJumeau(generateursChMixtes, de);
382
+
383
+ if (chGenerateurMixte) {
384
+ const chGenerateurMixteId = chGenerateurMixte.donnee_entree.enum_type_generateur_ch_id;
384
385
  const typeGenerateurEcs = enums.type_generateur_ecs;
385
386
  const typeGenerateurCh = enums.type_generateur_ch;
386
387
 
387
388
  const ecsGenerateurType = typeGenerateurEcs[ecsGenerateurId];
388
- const chGenerateurType = typeGenerateurCh[firstChGenerateurMixteId];
389
+ const chGenerateurType = typeGenerateurCh[chGenerateurMixteId];
389
390
 
390
391
  // Si les 2 générateurs n'ont pas le même type
391
392
  if (ecsGenerateurType !== chGenerateurType) {
392
393
  // Recherche de l'identifiant du générateur ECS identique au générateur de chauffage
393
394
  const newEcsGenerateurId = Object.keys(typeGenerateurEcs).find(
394
- (key) => typeGenerateurEcs[key] === typeGenerateurCh[firstChGenerateurMixteId]
395
+ (key) => typeGenerateurEcs[key] === typeGenerateurCh[chGenerateurMixteId]
395
396
  );
396
397
 
397
398
  if (newEcsGenerateurId) {
@@ -407,3 +408,64 @@ function checkEcsVsChauffageForMixteGeneration(dpe, de, typeGenerateurKey) {
407
408
 
408
409
  return ecsGenerateurId;
409
410
  }
411
+
412
+ /**
413
+ * Recherche du générateur de chauffage mixte « jumeau » du générateur ECS mixte.
414
+ * Méthode 3CL-DPE 2021 §13.2 et §14 : un générateur mixte chauffage + ECS est un seul et même
415
+ * appareil, avec les mêmes caractéristiques pour les deux usages. Il ne faut donc pas apparier le
416
+ * générateur ECS avec n'importe quel générateur de chauffage mixte du DPE (issue #210).
417
+ *
418
+ * Ordre de recherche :
419
+ * 1. même `reference_generateur_mixte` ;
420
+ * 2. même `tv_generateur_combustion_id` ;
421
+ * 3. même énergie (`enum_type_energie_id`), si un seul générateur de chauffage mixte correspond ;
422
+ * 4. générateur de chauffage mixte unique du DPE, si son énergie n'est pas incompatible.
423
+ *
424
+ * @param generateursChMixtes {GenerateurChauffageItem[]} générateurs de chauffage d'usage 'chauffage + ecs'
425
+ * @param de {Donnee_entree} données du générateur ECS
426
+ * @returns {GenerateurChauffageItem|null} null si aucun jumeau ne peut être identifié sans ambiguïté
427
+ */
428
+ export function findGenerateurChMixteJumeau(generateursChMixtes, de) {
429
+ const sameValue = (a, b) =>
430
+ a !== undefined && a !== null && b !== undefined && b !== null && String(a) === String(b);
431
+
432
+ if (de.reference_generateur_mixte) {
433
+ const generateur = generateursChMixtes.find((ch) =>
434
+ sameValue(ch.donnee_entree.reference_generateur_mixte, de.reference_generateur_mixte)
435
+ );
436
+ if (generateur) return generateur;
437
+ }
438
+
439
+ if (de.tv_generateur_combustion_id) {
440
+ const generateur = generateursChMixtes.find((ch) =>
441
+ sameValue(ch.donnee_entree.tv_generateur_combustion_id, de.tv_generateur_combustion_id)
442
+ );
443
+ if (generateur) return generateur;
444
+ }
445
+
446
+ if (de.enum_type_energie_id) {
447
+ const generateurs = generateursChMixtes.filter((ch) =>
448
+ sameValue(ch.donnee_entree.enum_type_energie_id, de.enum_type_energie_id)
449
+ );
450
+ if (generateurs.length === 1) return generateurs[0];
451
+ // Plusieurs générateurs de même énergie et de même type : jumeaux identiques,
452
+ // aucune ambiguïté sur le type à retenir pour l'ECS (cas 2275E1945671L)
453
+ const type = generateurs[0]?.donnee_entree.enum_type_generateur_ch_id;
454
+ if (
455
+ generateurs.length > 1 &&
456
+ generateurs.every((ch) => sameValue(ch.donnee_entree.enum_type_generateur_ch_id, type))
457
+ ) {
458
+ return generateurs[0];
459
+ }
460
+ }
461
+
462
+ if (generateursChMixtes.length === 1) {
463
+ const generateur = generateursChMixtes[0];
464
+ const energieCh = generateur.donnee_entree.enum_type_energie_id;
465
+ if (!de.enum_type_energie_id || !energieCh || sameValue(energieCh, de.enum_type_energie_id)) {
466
+ return generateur;
467
+ }
468
+ }
469
+
470
+ return null;
471
+ }
@@ -62,6 +62,7 @@ vi.mock('./13.2_generateur_combustion_pn.js', () => ({
62
62
 
63
63
  const {
64
64
  tv_generateur_combustion,
65
+ findGenerateurChMixteJumeau,
65
66
  updateGenerateurCombustion,
66
67
  caracteristiquesGenerateurCombustion
67
68
  } = await import('./13.2_generateur_combustion.js');
@@ -607,6 +608,201 @@ describe('tv_generateur_combustion - génération mixte (checkEcsVsChauffageForM
607
608
  });
608
609
  });
609
610
 
611
+ /**
612
+ * Issue #210 : l'ECS mixte doit être appariée avec SON générateur de chauffage mixte (même appareil),
613
+ * et non avec le premier générateur de chauffage mixte du DPE.
614
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §13.2 et §14
615
+ */
616
+ describe('findGenerateurChMixteJumeau - appariement ECS / chauffage mixte (issue #210)', () => {
617
+ const ch = (donnees) => ({ donnee_entree: { enum_usage_generateur_id: '3', ...donnees } });
618
+
619
+ test('même reference_generateur_mixte : prioritaire sur le tv_generateur_combustion_id', () => {
620
+ const chTv = ch({ tv_generateur_combustion_id: '11' });
621
+ const chRef = ch({ reference_generateur_mixte: 'GEN#1', tv_generateur_combustion_id: '13' });
622
+ const de = { reference_generateur_mixte: 'GEN#1', tv_generateur_combustion_id: '11' };
623
+ expect(findGenerateurChMixteJumeau([chTv, chRef], de)).toBe(chRef);
624
+ });
625
+
626
+ test('reference_generateur_mixte sans correspondance : repli sur le tv_generateur_combustion_id', () => {
627
+ const chElec = ch({ reference_generateur_mixte: 'AUTRE', tv_generateur_combustion_id: null });
628
+ const chGaz = ch({ tv_generateur_combustion_id: 11 });
629
+ const de = { reference_generateur_mixte: 'GEN#1', tv_generateur_combustion_id: '11' };
630
+ // Comparaison insensible au type (nombre / chaîne)
631
+ expect(findGenerateurChMixteJumeau([chElec, chGaz], de)).toBe(chGaz);
632
+ });
633
+
634
+ test('cas 2448E4606320D : ECS gaz (tv 11) appariée au CH 95 (tv 11) et non au premier CH mixte (106, électrique)', () => {
635
+ const ch106 = ch({ enum_type_generateur_ch_id: '106', enum_type_energie_id: '1' });
636
+ const ch95 = ch({
637
+ enum_type_generateur_ch_id: '95',
638
+ tv_generateur_combustion_id: '11',
639
+ enum_type_energie_id: '2'
640
+ });
641
+ const de = {
642
+ enum_type_generateur_ecs_id: '55',
643
+ tv_generateur_combustion_id: '11',
644
+ enum_type_energie_id: '2'
645
+ };
646
+ expect(findGenerateurChMixteJumeau([ch106, ch95], de)).toBe(ch95);
647
+ });
648
+
649
+ test('cas 2193E1043519Y : ECS gaz (tv 13) appariée au CH 97 (tv 13) et non au CH 111 (réseau de chaleur)', () => {
650
+ const ch111 = ch({ enum_type_generateur_ch_id: '111', enum_type_energie_id: '2' });
651
+ const ch97 = ch({
652
+ enum_type_generateur_ch_id: '97',
653
+ tv_generateur_combustion_id: '13',
654
+ enum_type_energie_id: '2'
655
+ });
656
+ const de = {
657
+ enum_type_generateur_ecs_id: '57',
658
+ tv_generateur_combustion_id: '13',
659
+ enum_type_energie_id: '2'
660
+ };
661
+ expect(findGenerateurChMixteJumeau([ch111, ch97], de)).toBe(ch97);
662
+ });
663
+
664
+ test('pas de référence ni de tv commun : un seul générateur de même énergie → retenu', () => {
665
+ const chElec = ch({ enum_type_energie_id: '1' });
666
+ const chFioul = ch({ enum_type_energie_id: '3' });
667
+ const de = { tv_generateur_combustion_id: '99', enum_type_energie_id: '3' };
668
+ expect(findGenerateurChMixteJumeau([chElec, chFioul], de)).toBe(chFioul);
669
+ });
670
+
671
+ test('plusieurs générateurs de même énergie sans autre critère : ambiguïté → aucun appariement', () => {
672
+ const de = { enum_type_energie_id: '2' };
673
+ const generateurs = [ch({ enum_type_energie_id: '2' }), ch({ enum_type_energie_id: '2' })];
674
+ expect(findGenerateurChMixteJumeau(generateurs, de)).toBeNull();
675
+ });
676
+
677
+ test('plusieurs générateurs de même énergie et de types différents : ambiguïté → aucun appariement', () => {
678
+ const de = { enum_type_energie_id: '13' };
679
+ const generateurs = [
680
+ ch({ enum_type_generateur_ch_id: '134', enum_type_energie_id: '13' }),
681
+ ch({ enum_type_generateur_ch_id: '92', enum_type_energie_id: '13' })
682
+ ];
683
+ expect(findGenerateurChMixteJumeau(generateurs, de)).toBeNull();
684
+ });
685
+
686
+ test('cas 2275E1945671L : plusieurs générateurs de même énergie et de même type → le premier est retenu', () => {
687
+ const ch1 = ch({
688
+ enum_type_generateur_ch_id: '134',
689
+ enum_type_energie_id: '13',
690
+ tv_generateur_combustion_id: '8'
691
+ });
692
+ const ch2 = ch({
693
+ enum_type_generateur_ch_id: 134,
694
+ enum_type_energie_id: 13,
695
+ tv_generateur_combustion_id: '8'
696
+ });
697
+ const de = {
698
+ enum_type_generateur_ecs_id: '92',
699
+ tv_generateur_combustion_id: '1',
700
+ enum_type_energie_id: '13'
701
+ };
702
+ expect(findGenerateurChMixteJumeau([ch1, ch2], de)).toBe(ch1);
703
+ });
704
+
705
+ test('générateur de chauffage mixte unique sans énergie renseignée : retenu (comportement historique)', () => {
706
+ const unique = ch({ enum_type_generateur_ch_id: 'X' });
707
+ expect(findGenerateurChMixteJumeau([unique], { enum_type_energie_id: '2' })).toBe(unique);
708
+ expect(findGenerateurChMixteJumeau([unique], {})).toBe(unique);
709
+ });
710
+
711
+ test('générateur de chauffage mixte unique d’une autre énergie : pas son jumeau → aucun appariement', () => {
712
+ const unique = ch({ enum_type_energie_id: '1' });
713
+ expect(findGenerateurChMixteJumeau([unique], { enum_type_energie_id: '2' })).toBeNull();
714
+ });
715
+
716
+ test('aucun générateur de chauffage mixte : aucun appariement', () => {
717
+ expect(findGenerateurChMixteJumeau([], { tv_generateur_combustion_id: '11' })).toBeNull();
718
+ });
719
+ });
720
+
721
+ describe('tv_generateur_combustion - génération mixte avec plusieurs générateurs de chauffage (issue #210)', () => {
722
+ function dpeAvecChMixtes(generateurs) {
723
+ return {
724
+ logement: {
725
+ installation_chauffage_collection: {
726
+ installation_chauffage: generateurs.map((donnees) => ({
727
+ generateur_chauffage_collection: {
728
+ generateur_chauffage: [
729
+ { donnee_entree: { enum_usage_generateur_id: '3', ...donnees } }
730
+ ]
731
+ }
732
+ }))
733
+ }
734
+ }
735
+ };
736
+ }
737
+
738
+ test('le type ECS est aligné sur le jumeau (même tv) et non sur le premier générateur mixte', () => {
739
+ state.bug = true;
740
+ tv.mockReturnValue({ ...ROW_DEFAUT });
741
+ // 1er CH mixte 'X' (fioul, autre tv), jumeau 'Y' (gaz, même tv 42) -> ECS 'A' (gaz) conservée
742
+ const dpe = dpeAvecChMixtes([
743
+ { enum_type_generateur_ch_id: 'X', tv_generateur_combustion_id: '7' },
744
+ { enum_type_generateur_ch_id: 'Y', tv_generateur_combustion_id: '42' }
745
+ ]);
746
+ const de = {
747
+ enum_type_generateur_ecs_id: 'A',
748
+ enum_usage_generateur_id: '3',
749
+ tv_generateur_combustion_id: '42',
750
+ ratio_virtualisation: 1,
751
+ presence_ventouse: 0
752
+ };
753
+ tv_generateur_combustion(dpe, { pn: 20000 }, de, 'ecs', 200, -9, 1);
754
+ expect(tv).toHaveBeenCalledWith(
755
+ 'generateur_combustion',
756
+ expect.objectContaining({ enum_type_generateur_ecs_id: 'A' })
757
+ );
758
+ expect(tv).not.toHaveBeenCalledWith(
759
+ 'generateur_combustion',
760
+ expect.objectContaining({ enum_type_generateur_ecs_id: 'B' })
761
+ );
762
+ });
763
+
764
+ test('jumeau non identifiable (ambiguïté) : type ECS conservé', () => {
765
+ state.bug = true;
766
+ tv.mockReturnValue({ ...ROW_DEFAUT });
767
+ // Deux CH mixtes de même énergie mais de types différents : aucun jumeau certain
768
+ const dpe = dpeAvecChMixtes([
769
+ { enum_type_generateur_ch_id: 'X', enum_type_energie_id: '2' },
770
+ { enum_type_generateur_ch_id: 'Z', enum_type_energie_id: '2' }
771
+ ]);
772
+ const de = {
773
+ enum_type_generateur_ecs_id: 'A',
774
+ enum_usage_generateur_id: '3',
775
+ enum_type_energie_id: '2',
776
+ tv_generateur_combustion_id: '42',
777
+ ratio_virtualisation: 1,
778
+ presence_ventouse: 0
779
+ };
780
+ tv_generateur_combustion(dpe, { pn: 20000 }, de, 'ecs', 200, -9, 1);
781
+ expect(tv).toHaveBeenCalledWith(
782
+ 'generateur_combustion',
783
+ expect.objectContaining({ enum_type_generateur_ecs_id: 'A' })
784
+ );
785
+ });
786
+
787
+ test('aucune ligne forfaitaire pour un générateur ECS mixte : erreur sans plantage', () => {
788
+ state.bug = true;
789
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
790
+ tv.mockReturnValue(null);
791
+ const de = {
792
+ enum_type_generateur_ecs_id: 'A',
793
+ enum_usage_generateur_id: '3',
794
+ tv_generateur_combustion_id: '42',
795
+ ratio_virtualisation: 1,
796
+ presence_ventouse: 0
797
+ };
798
+ expect(() =>
799
+ tv_generateur_combustion(dpeAvecChMixtes([]), { pn: 20000 }, de, 'ecs', 200, -9, 1)
800
+ ).not.toThrow();
801
+ expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('Pas de valeur forfaitaire'));
802
+ errSpy.mockRestore();
803
+ });
804
+ });
805
+
610
806
  /**
611
807
  * 13.2 - Orchestration des substitutions de générateurs et enrichissement fiches techniques.
612
808
  * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §13.2
@@ -213,6 +213,28 @@ function tv_k(pt_di, di, de, du, pc_id, logement) {
213
213
  return;
214
214
  }
215
215
 
216
+ /**
217
+ * 3.4 Calcul des déperditions par les ponts thermiques (issue #212)
218
+ * « Les ponts thermiques des parois au niveau des circulations communes ne sont pas pris en compte. »
219
+ * La règle porte sur les parois : elle s'applique au plancher de la liaison comme au mur.
220
+ * 14 à 18 - Circulations communes / halls d'entrée
221
+ * 22 - Local non déperditif (local à usage d'habitation chauffé)
222
+ *
223
+ * Ambiguïté : la méthode ne tranche pas explicitement le cas du plancher en adjacence 22 et les moteurs
224
+ * certifiés divergent (3cl-2024.6.1.0 exporte k = 0, Tribu 1.4.25.1 et BBS 2024.6.1.0 conservent la
225
+ * valeur forfaitaire sur les « planchers mitoyens »). Comme pour le mur ci-dessus, on s'appuie sur la
226
+ * valeur du DPE : k = 0 si le DPE a retenu k = 0, sinon la valeur forfaitaire est recalculée.
227
+ */
228
+ if (
229
+ ['14', '15', '16', '17', '18', '22'].includes(
230
+ String(plancher.donnee_entree.enum_type_adjacence_id)
231
+ ) &&
232
+ pt_di.k === 0
233
+ ) {
234
+ di.k = 0;
235
+ return;
236
+ }
237
+
216
238
  /**
217
239
  * 3.4.3 Plancher haut / mur
218
240
  * Les ponts thermiques des planchers haut en structure légère sont négligés.
@@ -889,3 +889,131 @@ describe('calc_pont_thermique - cas limites', () => {
889
889
  expect(pt.donnee_intermediaire.k).toBeUndefined();
890
890
  });
891
891
  });
892
+
893
+ /**
894
+ * Issue #212 : l'exclusion des parois non déperditives s'applique aussi au plancher des liaisons
895
+ * plancher bas / mur et plancher haut lourd / mur, pas seulement au mur.
896
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.4
897
+ */
898
+ describe('calc_pont_thermique - plancher sur paroi non déperditive (issue #212)', () => {
899
+ /** Mur extérieur lourd isolé ITI + plancher lourd isolé ITE avec l'adjacence donnée. */
900
+ function liaison(type_liaison, adjacencePlancher, k = 0.9) {
901
+ const mur = [
902
+ {
903
+ donnee_entree: { reference: 'M1', enum_type_adjacence_id: '1', type_isolation: 'iti' },
904
+ donnee_utilisateur: {}
905
+ }
906
+ ];
907
+ const plancher = {
908
+ donnee_entree: {
909
+ reference: 'P1',
910
+ enum_type_adjacence_id: adjacencePlancher,
911
+ enum_type_plancher_bas_id: '1',
912
+ enum_type_plancher_haut_id: '1',
913
+ type_isolation: 'ite'
914
+ },
915
+ donnee_utilisateur: {}
916
+ };
917
+ const pt = pontThermique(
918
+ {
919
+ type_liaison,
920
+ enum_type_liaison_id: type_liaison === 'plancher bas / mur' ? '1' : '3',
921
+ reference_1: 'P1',
922
+ reference_2: 'M1',
923
+ description: 'plancher / mur'
924
+ },
925
+ k
926
+ );
927
+ return { mur, plancher, pt };
928
+ }
929
+
930
+ test('cas 2557E3382433Y : plancher bas sur local habitation chauffé (22), mur extérieur, k DPE nul : k = 0', () => {
931
+ const { mur, plancher, pt } = liaison('plancher bas / mur', '22', 0);
932
+
933
+ calc_pont_thermique(pt, '1', logement({ mur, pb: [plancher] }));
934
+
935
+ expect(pt.donnee_intermediaire.k).toBe(0);
936
+ expect(tv).not.toHaveBeenCalled();
937
+ });
938
+
939
+ test('plancher mitoyen (22) avec k DPE non nul (Tribu / BBS) : valeur forfaitaire recalculée', () => {
940
+ tv.mockReturnValue({ k: '0.71', tv_pont_thermique_id: '12' });
941
+ const { mur, plancher, pt } = liaison('plancher bas / mur', '22', 0.71);
942
+
943
+ calc_pont_thermique(pt, '1', logement({ mur, pb: [plancher] }));
944
+
945
+ expect(tv).toHaveBeenCalledWith('pont_thermique', {
946
+ enum_type_liaison_id: '1',
947
+ isolation_mur: '^iti$',
948
+ isolation_plancher: '^ite$'
949
+ });
950
+ expect(pt.donnee_intermediaire.k).toBe(0.71);
951
+ });
952
+
953
+ test('cas 2534E3387900I : plancher haut lourd sous local habitation chauffé (22), mur extérieur : k = 0', () => {
954
+ const { mur, plancher, pt } = liaison('plancher haut lourd / mur', '22', 0);
955
+
956
+ calc_pont_thermique(pt, '1', logement({ mur, ph: [plancher] }));
957
+
958
+ expect(pt.donnee_intermediaire.k).toBe(0);
959
+ expect(tv).not.toHaveBeenCalled();
960
+ });
961
+
962
+ test.each(['14', '15', '16', '17', '18'])(
963
+ 'plancher bas sur circulation commune / hall (%s) avec k DPE nul : k = 0 sans accès table',
964
+ (adjacence) => {
965
+ const { mur, plancher, pt } = liaison('plancher bas / mur', adjacence, 0);
966
+
967
+ calc_pont_thermique(pt, '1', logement({ mur, pb: [plancher] }));
968
+
969
+ expect(pt.donnee_intermediaire.k).toBe(0);
970
+ expect(tv).not.toHaveBeenCalled();
971
+ }
972
+ );
973
+
974
+ test('plancher haut lourd sous circulation (14) avec k DPE nul : k = 0', () => {
975
+ const { mur, plancher, pt } = liaison('plancher haut lourd / mur', '14', 0);
976
+
977
+ calc_pont_thermique(pt, '1', logement({ mur, ph: [plancher] }));
978
+
979
+ expect(pt.donnee_intermediaire.k).toBe(0);
980
+ expect(tv).not.toHaveBeenCalled();
981
+ });
982
+
983
+ test('plancher bas sur hall (17) avec k DPE non nul : valeur forfaitaire conservée (même règle que pour le mur)', () => {
984
+ tv.mockReturnValue({ k: '0.71', tv_pont_thermique_id: '12' });
985
+ const { mur, plancher, pt } = liaison('plancher bas / mur', '17', 0.71);
986
+
987
+ calc_pont_thermique(pt, '1', logement({ mur, pb: [plancher] }));
988
+
989
+ expect(tv).toHaveBeenCalledWith('pont_thermique', {
990
+ enum_type_liaison_id: '1',
991
+ isolation_mur: '^iti$',
992
+ isolation_plancher: '^ite$'
993
+ });
994
+ expect(pt.donnee_intermediaire.k).toBe(0.71);
995
+ });
996
+
997
+ test('contre-test : plancher bas sur sous-sol non chauffé (6) : pont thermique compté (pas de b sur les PT)', () => {
998
+ tv.mockReturnValue({ k: '0.71', tv_pont_thermique_id: '12' });
999
+ const { mur, plancher, pt } = liaison('plancher bas / mur', '6', 0);
1000
+
1001
+ calc_pont_thermique(pt, '1', logement({ mur, pb: [plancher] }));
1002
+
1003
+ expect(tv).toHaveBeenCalledWith('pont_thermique', {
1004
+ enum_type_liaison_id: '1',
1005
+ isolation_mur: '^iti$',
1006
+ isolation_plancher: '^ite$'
1007
+ });
1008
+ expect(pt.donnee_intermediaire.k).toBe(0.71);
1009
+ });
1010
+
1011
+ test('contre-test : plancher bas sur local tertiaire (20) : pont thermique compté', () => {
1012
+ tv.mockReturnValue({ k: '0.71', tv_pont_thermique_id: '12' });
1013
+ const { mur, plancher, pt } = liaison('plancher bas / mur', '20', 0.71);
1014
+
1015
+ calc_pont_thermique(pt, '1', logement({ mur, pb: [plancher] }));
1016
+
1017
+ expect(pt.donnee_intermediaire.k).toBe(0.71);
1018
+ });
1019
+ });
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.10`** · à publier · branche `main` · généré le 2026-10-02
373
+ > **Version `1.8.12`** · à publier · branche `main` · généré le 2026-10-02
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>59 881</strong><br/><sub>DPE conformes</sub></td>
381
- <td align="center"><strong>66,54 %</strong><br/><sub>réussite globale</sub></td>
380
+ <td align="center"><strong>60 087</strong><br/><sub>DPE conformes</sub></td>
381
+ <td align="center"><strong>66,77 %</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,28 %** | `█████████░░░░░░░░░░░` | 4 628 / 10 000 |
388
- | 🟢 | **Appartement · chauffage individuel (2025)**<br/><sub>`dpe_appartement_individuel_chauffage_individuel_2025.csv`</sub> | **91,80 %** | `██████████████████░░` | 9 180 / 10 000 |
389
- | 🟢 | **Logement individuel (2025)**<br/><sub>`dpe_logement_individuel_2025.csv`</sub> | **88,70 %** | `██████████████████░░` | 8 863 / 9 992 |
390
- | 🟢 | **Maison individuelle (2025)**<br/><sub>`dpe_maison_individuelle_2025.csv`</sub> | **88,40 %** | `██████████████████░░` | 8 840 / 10 000 |
391
- | 🟡 | **Immeuble · chauffage individuel**<br/><sub>`dpe_immeuble_chauffage_individuel.csv`</sub> | **74,14 %** | `███████████████░░░░░` | 7 413 / 9 999 |
392
- | 🟡 | **Appartement · chauffage collectif (2025)**<br/><sub>`dpe_appartement_individuel_chauffage_collectif_2025.csv`</sub> | **70,61 %** | `██████████████░░░░░░` | 7 061 / 10 000 |
393
- | 🟡 | **Immeuble · chauffage collectif**<br/><sub>`dpe_immeuble_chauffage_collectif.csv`</sub> | **62,52 %** | `█████████████░░░░░░░` | 6 251 / 9 999 |
394
- | 🔴 | **Immeuble · chauffage mixte**<br/><sub>`dpe_immeuble_chauffage_mixte.csv`</sub> | **49,06 %** | `██████████░░░░░░░░░░` | 4 906 / 10 000 |
387
+ | 🔴 | **Généraliste**<br/><sub>`corpus_dpe.csv`</sub> | **46,31 %** | `█████████░░░░░░░░░░░` | 4 631 / 10 000 |
388
+ | 🟢 | **Appartement · chauffage individuel (2025)**<br/><sub>`dpe_appartement_individuel_chauffage_individuel_2025.csv`</sub> | **92,42 %** | `██████████████████░░` | 9 242 / 10 000 |
389
+ | 🟢 | **Logement individuel (2025)**<br/><sub>`dpe_logement_individuel_2025.csv`</sub> | **89,39 %** | `██████████████████░░` | 8 932 / 9 992 |
390
+ | 🟢 | **Maison individuelle (2025)**<br/><sub>`dpe_maison_individuelle_2025.csv`</sub> | **88,41 %** | `██████████████████░░` | 8 841 / 10 000 |
391
+ | 🟡 | **Immeuble · chauffage individuel**<br/><sub>`dpe_immeuble_chauffage_individuel.csv`</sub> | **74,24 %** | `███████████████░░░░░` | 7 423 / 9 999 |
392
+ | 🟡 | **Appartement · chauffage collectif (2025)**<br/><sub>`dpe_appartement_individuel_chauffage_collectif_2025.csv`</sub> | **71,10 %** | `██████████████░░░░░░` | 7 110 / 10 000 |
393
+ | 🟡 | **Immeuble · chauffage collectif**<br/><sub>`dpe_immeuble_chauffage_collectif.csv`</sub> | **62,65 %** | `█████████████░░░░░░░` | 6 264 / 9 999 |
394
+ | 🔴 | **Immeuble · chauffage mixte**<br/><sub>`dpe_immeuble_chauffage_mixte.csv`</sub> | **49,05 %** | `██████████░░░░░░░░░░` | 4 905 / 10 000 |
395
395
  | 🔴 | **Individuel généré depuis l'immeuble (2026)**<br/><sub>`dpe_individuel_a_partir_dpe_immeuble_2026.csv`</sub> | **27,39 %** | `█████░░░░░░░░░░░░░░░` | 2 739 / 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>5,70 ms</strong><br/><sub>moyenne</sub></td>
407
- <td align="center"><strong>4,90 ms</strong><br/><sub>médiane</sub></td>
408
- <td align="center"><strong>0,12 ms</strong><br/><sub>min</sub></td>
409
- <td align="center"><strong>798,8 ms</strong><br/><sub>max</sub></td>
406
+ <td align="center"><strong>5,30 ms</strong><br/><sub>moyenne</sub></td>
407
+ <td align="center"><strong>4,64 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>600,7 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,45 ms | 3,97 ms | 0,21 ms | 28,3 ms | 8,17 ms | 12,3 ms |
416
- | **Appartement · chauffage individuel (2025)** | 3,56 ms | 3,23 ms | 1,44 ms | 32,5 ms | 6,21 ms | 8,35 ms |
417
- | **Logement individuel (2025)** | 5,38 ms | 4,83 ms | 1,58 ms | 40,8 ms | 9,99 ms | 13,4 ms |
418
- | **Maison individuelle (2025)** | 9,51 ms | 7,86 ms | 1,74 ms | 102,7 ms | 21,7 ms | 32,8 ms |
419
- | **Immeuble · chauffage individuel** | 7,00 ms | 6,01 ms | 0,12 ms | 104,2 ms | 14,2 ms | 21,8 ms |
420
- | **Appartement · chauffage collectif (2025)** | 4,19 ms | 3,78 ms | 1,54 ms | 35,7 ms | 6,90 ms | 8,89 ms |
421
- | **Immeuble · chauffage collectif** | 5,77 ms | 4,97 ms | 0,22 ms | 99,3 ms | 10,8 ms | 17,2 ms |
422
- | **Immeuble · chauffage mixte** | 5,60 ms | 4,49 ms | 0,19 ms | 798,8 ms | 11,9 ms | 19,9 ms |
423
- | **Individuel généré depuis l'immeuble (2026)** | 5,85 ms | 4,98 ms | 1,60 ms | 49,9 ms | 11,1 ms | 16,4 ms |
415
+ | **Généraliste** | 4,30 ms | 3,86 ms | 0,20 ms | 30,9 ms | 7,81 ms | 10,7 ms |
416
+ | **Appartement · chauffage individuel (2025)** | 3,75 ms | 3,39 ms | 1,45 ms | 27,1 ms | 6,51 ms | 8,46 ms |
417
+ | **Logement individuel (2025)** | 5,31 ms | 4,77 ms | 1,60 ms | 39,2 ms | 9,77 ms | 12,9 ms |
418
+ | **Maison individuelle (2025)** | 7,02 ms | 6,29 ms | 2,10 ms | 50,3 ms | 12,9 ms | 19,2 ms |
419
+ | **Immeuble · chauffage individuel** | 6,13 ms | 5,23 ms | 0,11 ms | 95,6 ms | 12,4 ms | 19,6 ms |
420
+ | **Appartement · chauffage collectif (2025)** | 4,61 ms | 4,32 ms | 1,56 ms | 34,9 ms | 7,47 ms | 10,9 ms |
421
+ | **Immeuble · chauffage collectif** | 5,38 ms | 4,58 ms | 0,20 ms | 100,0 ms | 9,96 ms | 16,9 ms |
422
+ | **Immeuble · chauffage mixte** | 5,18 ms | 4,17 ms | 0,19 ms | 600,7 ms | 11,0 ms | 18,2 ms |
423
+ | **Individuel généré depuis l'immeuble (2026)** | 5,99 ms | 5,17 ms | 1,63 ms | 48,8 ms | 11,5 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.12';
36
+ const LIB_VERSION = '1.8.14';
37
37
 
38
38
  const dpeSanitizerService = new DpeSanitizerService();
39
39
 
@@ -175,7 +175,10 @@ export class DeperditionPontThermiqueService extends DeperditionService {
175
175
  return this.defaultValue(pontThermiqueDE);
176
176
  }
177
177
 
178
- if (!this.plancherBasHasPontThermique(plancherBasDE)) {
178
+ if (
179
+ !this.plancherHasPontThermiqueAdjacence(plancherBasDE) ||
180
+ !this.plancherBasHasPontThermique(plancherBasDE)
181
+ ) {
179
182
  return 0;
180
183
  }
181
184
 
@@ -206,7 +209,10 @@ export class DeperditionPontThermiqueService extends DeperditionService {
206
209
  return this.defaultValue(pontThermiqueDE);
207
210
  }
208
211
 
209
- if (!this.plancherHautHasPontThermique(plancherHautDE)) {
212
+ if (
213
+ !this.plancherHasPontThermiqueAdjacence(plancherHautDE) ||
214
+ !this.plancherHautHasPontThermique(plancherHautDE)
215
+ ) {
210
216
  return 0;
211
217
  }
212
218
 
@@ -391,6 +397,26 @@ export class DeperditionPontThermiqueService extends DeperditionService {
391
397
  return true;
392
398
  }
393
399
 
400
+ /**
401
+ * Retourne true si l'adjacence du plancher (bas ou haut) permet un pont thermique avec le mur (issue #212)
402
+ *
403
+ * 3.4 Calcul des déperditions par les ponts thermiques
404
+ * Les ponts thermiques des parois au niveau des circulations communes ne sont pas pris en compte.
405
+ * La règle porte sur les parois en général : elle s'applique au plancher de la liaison comme au mur.
406
+ * 14 - Circulation sans ouverture directe sur l'extérieur
407
+ * 15 - Circulation avec ouverture directe sur l'extérieur
408
+ * 16 - Circulation avec bouche ou gaine de désenfumage ouverte en permanence
409
+ * 17 - Hall d'entrée avec dispositif de fermeture automatique
410
+ * 18 - Hall d'entrée sans dispositif de fermeture automatique
411
+ * 22 - Local non déperditif (local à usage d'habitation chauffé) : le plancher n'est pas déperditif
412
+ *
413
+ * @param plancherDE {PlancherBasDE|PlancherHautDE}
414
+ * @return {boolean}
415
+ */
416
+ plancherHasPontThermiqueAdjacence(plancherDE) {
417
+ return ![14, 15, 16, 17, 18, 22].includes(parseInt(plancherDE.enum_type_adjacence_id));
418
+ }
419
+
394
420
  /**
395
421
  * Retourne true si le plancher haut est concerné par un pont thermique
396
422
  * @param plancherHautDE {PlancherHautDE}
@@ -0,0 +1,125 @@
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest';
2
+ import { DeperditionPontThermiqueService } from './deperdition-pont-thermique.service.js';
3
+
4
+ /**
5
+ * Tests unitaires isolés de `DeperditionPontThermiqueService` (nouvelle architecture).
6
+ *
7
+ * Le store des tables de valeurs et les services de déperdition des parois sont remplacés par des
8
+ * doubles de test : seule la logique propre au service (exclusions de ponts thermiques) est vérifiée.
9
+ * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.4
10
+ */
11
+
12
+ let tvStore;
13
+ let murService;
14
+ let plancherHautService;
15
+ let plancherBasService;
16
+
17
+ /** @type {DeperditionPontThermiqueService} */
18
+ let service;
19
+
20
+ const ctx = {};
21
+
22
+ beforeEach(() => {
23
+ tvStore = {
24
+ getKForPlancher: vi.fn(() => 0.71),
25
+ getKForMur: vi.fn(() => 0.82),
26
+ getKForMenuiserie: vi.fn(() => 0.3),
27
+ getKForMurById: vi.fn(() => 0.5)
28
+ };
29
+ murService = { typeIsolation: vi.fn(() => 3) };
30
+ plancherHautService = { typeIsolation: vi.fn(() => 4) };
31
+ plancherBasService = { typeIsolation: vi.fn(() => 4) };
32
+ service = new DeperditionPontThermiqueService(
33
+ tvStore,
34
+ murService,
35
+ plancherHautService,
36
+ plancherBasService
37
+ );
38
+ });
39
+
40
+ /** Enveloppe : mur extérieur lourd + plancher (bas ou haut) lourd avec l'adjacence donnée. */
41
+ function enveloppe(adjacencePlancher, adjacenceMur = '1') {
42
+ const plancher = {
43
+ donnee_entree: {
44
+ reference: 'P1',
45
+ enum_type_adjacence_id: adjacencePlancher,
46
+ enum_type_plancher_bas_id: '1',
47
+ enum_type_plancher_haut_id: '1'
48
+ }
49
+ };
50
+ return {
51
+ mur_collection: {
52
+ mur: [
53
+ {
54
+ donnee_entree: {
55
+ reference: 'M1',
56
+ enum_type_adjacence_id: adjacenceMur,
57
+ enum_materiaux_structure_mur_id: '1'
58
+ }
59
+ }
60
+ ]
61
+ },
62
+ plancher_bas_collection: { plancher_bas: [plancher] },
63
+ plancher_haut_collection: { plancher_haut: [plancher] }
64
+ };
65
+ }
66
+
67
+ function pontThermique(enumTypeLiaisonId) {
68
+ return {
69
+ enum_methode_saisie_pont_thermique_id: '1',
70
+ enum_type_liaison_id: enumTypeLiaisonId,
71
+ reference_1: 'P1',
72
+ reference_2: 'M1',
73
+ description: 'plancher / mur'
74
+ };
75
+ }
76
+
77
+ describe('DeperditionPontThermiqueService - adjacence du plancher (issue #212)', () => {
78
+ test.each(['14', '15', '16', '17', '18', '22'])(
79
+ 'adjacence %s : plancher sans pont thermique',
80
+ (adjacence) => {
81
+ expect(service.plancherHasPontThermiqueAdjacence({ enum_type_adjacence_id: adjacence })).toBe(
82
+ false
83
+ );
84
+ }
85
+ );
86
+
87
+ test.each(['1', '3', '5', '6', '7', '20'])(
88
+ 'adjacence %s : plancher avec pont thermique',
89
+ (adj) => {
90
+ expect(service.plancherHasPontThermiqueAdjacence({ enum_type_adjacence_id: adj })).toBe(true);
91
+ }
92
+ );
93
+
94
+ test('cas 2557E3382433Y : plancher bas / mur, plancher sur local habitation chauffé (22) : k = 0', () => {
95
+ const di = service.execute(ctx, enveloppe('22'), pontThermique('1'));
96
+ expect(di.k).toBe(0);
97
+ expect(tvStore.getKForPlancher).not.toHaveBeenCalled();
98
+ });
99
+
100
+ test('cas 2534E3387900I : plancher haut lourd / mur, plafond sous local habitation chauffé (22) : k = 0', () => {
101
+ const di = service.execute(ctx, enveloppe('22'), pontThermique('3'));
102
+ expect(di.k).toBe(0);
103
+ expect(tvStore.getKForPlancher).not.toHaveBeenCalled();
104
+ });
105
+
106
+ test('plancher bas sur hall d’entrée (17) : k = 0', () => {
107
+ expect(service.execute(ctx, enveloppe('17'), pontThermique('1')).k).toBe(0);
108
+ });
109
+
110
+ test('contre-test : plancher bas sur sous-sol non chauffé (6) : valeur tabulée (pas de b sur les PT)', () => {
111
+ const di = service.execute(ctx, enveloppe('6'), pontThermique('1'));
112
+ expect(tvStore.getKForPlancher).toHaveBeenCalledWith(1, expect.anything(), expect.anything());
113
+ expect(di.k).toBe(0.71);
114
+ });
115
+
116
+ test('contre-test : plancher haut lourd sur extérieur : valeur tabulée', () => {
117
+ const di = service.execute(ctx, enveloppe('1'), pontThermique('3'));
118
+ expect(tvStore.getKForPlancher).toHaveBeenCalledWith(3, expect.anything(), expect.anything());
119
+ expect(di.k).toBe(0.71);
120
+ });
121
+
122
+ test('mur sur circulation (14) : k = 0 quelle que soit l’adjacence du plancher', () => {
123
+ expect(service.execute(ctx, enveloppe('1', '14'), pontThermique('1')).k).toBe(0);
124
+ });
125
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open3cl/engine",
3
- "version": "1.8.12",
3
+ "version": "1.8.14",
4
4
  "description": "Open Source 3CL-DPE engine",
5
5
  "main": "index.js",
6
6
  "directories": {
@@ -17,7 +17,7 @@
17
17
  "test:unit": "vitest run src",
18
18
  "test:unit:ci": "vitest run src --coverage",
19
19
  "test:corpus": "node test/corpus/run_all_corpus_checks.js",
20
- "test:corpus:all": "node test/corpus/run_all_corpus_checks.js && npm run reports:readme",
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
22
  "qa:lint": "eslint .",
23
23
  "qa:lint:fix": "npm run qa:lint -- --fix",
@@ -29,6 +29,7 @@
29
29
  "assets:sync": "node scripts/synchronize_assets.js",
30
30
  "version:update": "node scripts/update_version.js",
31
31
  "reports:preview": "npm run http-server dist/reports/corpus -o",
32
+ "reports:clean": "node scripts/clean_corpus_reports.js",
32
33
  "reports:readme": "node scripts/generate_corpus_readme.js",
33
34
  "reports:stamp": "node scripts/stamp_corpus_release.js",
34
35
  "reports:version": "node scripts/corpus_version.js"