@open3cl/engine 1.9.0 → 1.10.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/4_ventilation.js CHANGED
@@ -1,4 +1,10 @@
1
- import { tv, requestInputID, requestInput, bug_for_bug_compat } from './utils.js';
1
+ import {
2
+ tv,
3
+ requestInputID,
4
+ requestInput,
5
+ bug_for_bug_compat,
6
+ compat_for_corpus_analysis
7
+ } from './utils.js';
2
8
  import calc_pvent from './5_conso_ventilation.js';
3
9
 
4
10
  function tv_debits_ventilation(di, de, du) {
@@ -38,6 +44,16 @@ export function isLigneQ4paMemePeriode(rowQ4paConv, cg) {
38
44
  .includes(String(cg.enum_periode_construction_id));
39
45
  }
40
46
 
47
+ /**
48
+ * Vrai si la ligne de la table `q4pa_conv` considère les surfaces isolées (`isolation_surfaces` = 1)
49
+ * alors que le recalcul à partir des parois les donne non isolées (`isolation_surfaces` = 0).
50
+ */
51
+ export function isLigneQ4paSurfacesIsolees(rowQ4paConv, isolationSurfacesRecalculee) {
52
+ return (
53
+ String(rowQ4paConv.isolation_surfaces) === '1' && String(isolationSurfacesRecalculee) === '0'
54
+ );
55
+ }
56
+
41
57
  function tv_q4pa_conv(di, de, cg, mur_list, ph_list, porte_list, bv_list) {
42
58
  const surfaces = mur_list.concat(ph_list);
43
59
  const surface_isolee = surfaces.reduce((acc, s) => {
@@ -108,10 +124,24 @@ function tv_q4pa_conv(di, de, cg, mur_list, ph_list, porte_list, bv_list) {
108
124
  * 1948, surfaces isolées », 2, alors que toutes les parois sont non isolées → ligne 1, 4,6).
109
125
  * Si la ligne saisie correspond à la période de construction du DPE, on la reprend en entier
110
126
  * (isolation et joints) pour reproduire le calcul du logiciel.
127
+ *
128
+ * Cette reprise n'est faite qu'avec l'option `compat_for_corpus_analysis` (analyse du corpus
129
+ * ADEME). Par défaut, la ligne contradictoire est seulement signalée et q4pa_conv est
130
+ * recalculé selon la méthode : un contrôle de cohérence du DPE doit détecter l'erreur et un
131
+ * scénario de travaux ne doit pas la propager.
132
+ *
133
+ * Sens unique : la reprise ne s'applique que si la ligne indique des surfaces isolées alors que
134
+ * le recalcul les donne non isolées. Dans le sens inverse (ligne « non isolées », recalcul
135
+ * « isolées »), le DPE a pu être modifié par l'appelant (scénario d'isolation des parois) :
136
+ * la valeur recalculée est utilisée.
111
137
  * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §4
112
138
  */
113
139
  if (rowQ4paConv.isolation_surfaces !== isolation_surfaces) {
114
- if (isLigneQ4paMemePeriode(rowQ4paConv, cg)) {
140
+ if (
141
+ compat_for_corpus_analysis &&
142
+ isLigneQ4paSurfacesIsolees(rowQ4paConv, isolation_surfaces) &&
143
+ isLigneQ4paMemePeriode(rowQ4paConv, cg)
144
+ ) {
115
145
  console.error(
116
146
  `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
147
  );
@@ -129,10 +159,14 @@ function tv_q4pa_conv(di, de, cg, mur_list, ph_list, porte_list, bv_list) {
129
159
  * Issue #294 : aucune menuiserie n'exporte `presence_joint`. Aucune donnée ne contredit alors
130
160
  * la ligne q4pa_conv retenue par le logiciel (ex. ligne 12 « avant 1948 avec joints », 2,5) :
131
161
  * on reprend sa valeur `presence_joints_menuiserie` au lieu de considérer les menuiseries
132
- * sans joint.
162
+ * sans joint. Uniquement avec l'option `compat_for_corpus_analysis`.
133
163
  * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §4
134
164
  */
135
- if (!ligneDpeReprise && presenceJointNonRenseignee(porte_list, bv_list)) {
165
+ if (
166
+ compat_for_corpus_analysis &&
167
+ !ligneDpeReprise &&
168
+ presenceJointNonRenseignee(porte_list, bv_list)
169
+ ) {
136
170
  pjt = rowQ4paConv.presence_joints_menuiserie === '1' ? '1' : '0';
137
171
  } else if (!ligneDpeReprise && rowQ4paConv.presence_joints_menuiserie !== pjt) {
138
172
  console.error(
@@ -4,7 +4,8 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
4
4
  * Dépendances mockées pour isoler le module de ventilation :
5
5
  * - `tv` : accès aux tables `debits_ventilation` / `q4pa_conv`, on contrôle les lignes ;
6
6
  * - `requestInput` / `requestInputID` : passe-plats vers les données d'entrée ;
7
- * - `bug_for_bug_compat` : désactivé pour isoler le comportement nominal ;
7
+ * - `bug_for_bug_compat` / `compat_for_corpus_analysis` : désactivés pour isoler le comportement
8
+ * nominal ;
8
9
  * - `calc_pvent` (5_conso_ventilation.js) : espion, la conso de ventilation est testée ailleurs.
9
10
  */
10
11
  vi.mock('./utils.js', () => ({
@@ -12,7 +13,8 @@ vi.mock('./utils.js', () => ({
12
13
  tv: vi.fn(),
13
14
  requestInput: (de, du, field) => de[field],
14
15
  requestInputID: (de, du, field) => de[`enum_${field}_id`],
15
- bug_for_bug_compat: false
16
+ bug_for_bug_compat: false,
17
+ compat_for_corpus_analysis: false
16
18
  }));
17
19
 
18
20
  vi.mock('./5_conso_ventilation.js', () => ({
@@ -23,7 +25,8 @@ const {
23
25
  default: calc_ventilation,
24
26
  calc_hperm,
25
27
  presenceJointNonRenseignee,
26
- isLigneQ4paMemePeriode
28
+ isLigneQ4paMemePeriode,
29
+ isLigneQ4paSurfacesIsolees
27
30
  } = await import('./4_ventilation.js');
28
31
  const { tv } = await import('./utils.js');
29
32
  const { default: calc_pvent } = await import('./5_conso_ventilation.js');
@@ -300,10 +303,12 @@ describe('calc_ventilation - orchestration', () => {
300
303
  */
301
304
  describe('calc_ventilation - compatibilité bug_for_bug_compat activée', () => {
302
305
  /**
303
- * Recharge le module avec `bug_for_bug_compat = true`. Le double de `tv` renvoie une ligne
304
- * `q4pa_conv` distincte selon que le matcher cible l'identifiant déjà saisi ou le matcher complet.
306
+ * Recharge le module avec `bug_for_bug_compat = true` et `compat_for_corpus_analysis` (vrai par
307
+ * défaut : reprise de la ligne du DPE pour l'analyse de corpus). Le double de `tv` renvoie une
308
+ * ligne `q4pa_conv` distincte selon que le matcher cible l'identifiant déjà saisi ou le matcher
309
+ * complet.
305
310
  */
306
- async function chargerAvecBug(rowParId) {
311
+ async function chargerAvecBug(rowParId, compat = true) {
307
312
  vi.resetModules();
308
313
  const tvBug = vi.fn((table, matcher) => {
309
314
  if (table === 'debits_ventilation') return { ...ROW_DEBITS };
@@ -317,7 +322,8 @@ describe('calc_ventilation - compatibilité bug_for_bug_compat activée', () =>
317
322
  tv: tvBug,
318
323
  requestInput: (de, du, field) => de[field],
319
324
  requestInputID: (de, du, field) => de[`enum_${field}_id`],
320
- bug_for_bug_compat: true
325
+ bug_for_bug_compat: true,
326
+ compat_for_corpus_analysis: compat
321
327
  }));
322
328
  vi.doMock('./5_conso_ventilation.js', () => ({ default: vi.fn() }));
323
329
  const { default: calc_ventilation_bug } = await import('./4_ventilation.js');
@@ -522,25 +528,88 @@ describe('calc_ventilation - compatibilité bug_for_bug_compat activée', () =>
522
528
  errorSpy.mockRestore();
523
529
  });
524
530
 
525
- test('ligne 12 (non isolé, avec joints) reprise en entier, malgré des baies sans joint', async () => {
531
+ test('ligne isolée de la même période, joints absents de la ligne : joints « 0 » repris', async () => {
532
+ const { calc_ventilation_bug, tvBug } = await chargerAvecBug(ROW_10);
533
+ vi.spyOn(console, 'error').mockImplementation(() => {});
534
+ const baieAvecJoint = [{ donnee_entree: { surface_totale_baie: 10, presence_joint: 1 } }];
535
+
536
+ calc_ventilation_bug(vtLigne(10), CG, 'th', 80, 100, murNonIsole, [], [], baieAvecJoint);
537
+
538
+ // la ligne reprise prime sur les joints recalculés ('1')
539
+ expect(tvBug).toHaveBeenLastCalledWith(
540
+ 'q4pa_conv',
541
+ expect.objectContaining({ isolation_surfaces: '1', presence_joints_menuiserie: '0' })
542
+ );
543
+ });
544
+
545
+ test('ligne isolée de la même période avec joints : joints « 1 » repris malgré des baies sans joint', async () => {
526
546
  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',
547
+ ...ROW_10,
548
+ tv_q4pa_conv_id: '11',
531
549
  presence_joints_menuiserie: '1'
532
550
  });
533
551
  vi.spyOn(console, 'error').mockImplementation(() => {});
534
- const { murs } = surfacesIsolantes();
535
552
 
536
- calc_ventilation_bug(vtLigne(12), CG, 'th', 80, 100, murs, [], [], baieSansJoint);
553
+ calc_ventilation_bug(vtLigne(11), CG, 'th', 80, 100, murNonIsole, [], [], baieSansJoint);
537
554
 
538
555
  expect(tvBug).toHaveBeenLastCalledWith(
539
556
  'q4pa_conv',
540
- expect.objectContaining({ isolation_surfaces: '0', presence_joints_menuiserie: '1' })
557
+ expect.objectContaining({ isolation_surfaces: '1', presence_joints_menuiserie: '1' })
541
558
  );
542
559
  });
543
560
 
561
+ /**
562
+ * Sens unique : ligne « non isolées » alors que les parois sont isolées (cas d'un scénario de
563
+ * rénovation où l'appelant isole les murs du DPE). La ligne n'est pas reprise : l'isolation et
564
+ * les joints sont recalculés comme avant #312.
565
+ */
566
+ describe('ligne non isolée alors que les parois sont isolées (sens inverse)', () => {
567
+ const ROW_12 = {
568
+ q4pa_conv: '2.5',
569
+ tv_q4pa_conv_id: '12',
570
+ enum_periode_construction_id: '1',
571
+ isolation_surfaces: '0',
572
+ presence_joints_menuiserie: '1'
573
+ };
574
+
575
+ test('isolation recalculée utilisée et écart des joints signalé', async () => {
576
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
577
+ const { calc_ventilation_bug, tvBug } = await chargerAvecBug(ROW_12);
578
+ const { murs } = surfacesIsolantes();
579
+
580
+ calc_ventilation_bug(vtLigne(12), CG, 'th', 80, 100, murs, [], [], baieSansJoint);
581
+
582
+ expect(tvBug).toHaveBeenLastCalledWith(
583
+ 'q4pa_conv',
584
+ expect.objectContaining({ isolation_surfaces: '1', presence_joints_menuiserie: '0' })
585
+ );
586
+ expect(errorSpy).toHaveBeenCalledWith(
587
+ expect.stringContaining('La valeur recalculée isolation_surfaces = 1 est utilisée')
588
+ );
589
+ expect(errorSpy).toHaveBeenCalledWith(
590
+ expect.stringContaining(
591
+ 'La valeur recalculée presence_joints_menuiserie = 0 est utilisée'
592
+ )
593
+ );
594
+ expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining('est conservée'));
595
+ errorSpy.mockRestore();
596
+ });
597
+
598
+ test('presence_joint non exporté : seuls les joints de la ligne du DPE sont repris', async () => {
599
+ vi.spyOn(console, 'error').mockImplementation(() => {});
600
+ const { calc_ventilation_bug, tvBug } = await chargerAvecBug(ROW_12);
601
+ const { murs } = surfacesIsolantes();
602
+ const baies = [{ donnee_entree: { surface_totale_baie: 10 } }];
603
+
604
+ calc_ventilation_bug(vtLigne(12), CG, 'th', 80, 100, murs, [], [], baies);
605
+
606
+ expect(tvBug).toHaveBeenLastCalledWith(
607
+ 'q4pa_conv',
608
+ expect.objectContaining({ isolation_surfaces: '1', presence_joints_menuiserie: '1' })
609
+ );
610
+ });
611
+ });
612
+
544
613
  test('ligne d’une autre période : la valeur recalculée est conservée', async () => {
545
614
  const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
546
615
  const { calc_ventilation_bug, tvBug } = await chargerAvecBug({
@@ -561,6 +630,78 @@ describe('calc_ventilation - compatibilité bug_for_bug_compat activée', () =>
561
630
  errorSpy.mockRestore();
562
631
  });
563
632
  });
633
+
634
+ /**
635
+ * Sans l'option `compat_for_corpus_analysis` (comportement par défaut) : la ligne q4pa_conv du DPE
636
+ * n'est jamais reprise, q4pa_conv est recalculé selon la méthode. Les écarts sont seulement
637
+ * signalés (contrôle de cohérence d'un DPE, scénarios de travaux).
638
+ */
639
+ describe('sans compat_for_corpus_analysis : ligne du DPE jamais reprise', () => {
640
+ const murNonIsole = [
641
+ {
642
+ donnee_intermediaire: { b: 1 },
643
+ donnee_entree: { enum_type_isolation_id: '2', surface_paroi_opaque: 100 }
644
+ }
645
+ ];
646
+ const vtLigne = (id) => ({
647
+ donnee_entree: { enum_type_ventilation_id: '1', tv_q4pa_conv_id: id, description: 'v' }
648
+ });
649
+
650
+ test('ligne « surfaces isolées » de la même période (#312) : isolation et joints recalculés', async () => {
651
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
652
+ const { calc_ventilation_bug, tvBug } = await chargerAvecBug(
653
+ {
654
+ q4pa_conv: '2',
655
+ tv_q4pa_conv_id: '11',
656
+ enum_periode_construction_id: '1',
657
+ isolation_surfaces: '1',
658
+ presence_joints_menuiserie: '1'
659
+ },
660
+ false
661
+ );
662
+ const baieSansJoint = [{ donnee_entree: { surface_totale_baie: 10, presence_joint: 0 } }];
663
+
664
+ calc_ventilation_bug(vtLigne(11), CG, 'th', 80, 100, murNonIsole, [], [], baieSansJoint);
665
+
666
+ expect(tvBug).toHaveBeenLastCalledWith(
667
+ 'q4pa_conv',
668
+ expect.objectContaining({ isolation_surfaces: '0', presence_joints_menuiserie: '0' })
669
+ );
670
+ expect(errorSpy).toHaveBeenCalledWith(
671
+ expect.stringContaining('La valeur recalculée isolation_surfaces = 0 est utilisée')
672
+ );
673
+ expect(errorSpy).toHaveBeenCalledWith(
674
+ expect.stringContaining('La valeur recalculée presence_joints_menuiserie = 0 est utilisée')
675
+ );
676
+ expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining('est conservée'));
677
+ errorSpy.mockRestore();
678
+ });
679
+
680
+ test('presence_joint non exporté (#294) : joints recalculés à « 0 » et écart signalé', async () => {
681
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
682
+ const { calc_ventilation_bug, tvBug } = await chargerAvecBug(
683
+ {
684
+ q4pa_conv: '2.5',
685
+ tv_q4pa_conv_id: '12',
686
+ isolation_surfaces: '0',
687
+ presence_joints_menuiserie: '1'
688
+ },
689
+ false
690
+ );
691
+ const baies = [{ donnee_entree: { surface_totale_baie: 10 } }];
692
+
693
+ calc_ventilation_bug(vtLigne(12), CG, 'th', 80, 100, murNonIsole, [], [], baies);
694
+
695
+ expect(tvBug).toHaveBeenLastCalledWith(
696
+ 'q4pa_conv',
697
+ expect.objectContaining({ isolation_surfaces: '0', presence_joints_menuiserie: '0' })
698
+ );
699
+ expect(errorSpy).toHaveBeenCalledWith(
700
+ expect.stringContaining('La valeur recalculée presence_joints_menuiserie = 0 est utilisée')
701
+ );
702
+ errorSpy.mockRestore();
703
+ });
704
+ });
564
705
  });
565
706
 
566
707
  describe('presenceJointNonRenseignee', () => {
@@ -612,3 +753,19 @@ describe('isLigneQ4paMemePeriode', () => {
612
753
  expect(isLigneQ4paMemePeriode(null, { enum_periode_construction_id: '1' })).toBe(false);
613
754
  });
614
755
  });
756
+
757
+ describe('isLigneQ4paSurfacesIsolees', () => {
758
+ test('ligne « surfaces isolées » et recalcul « non isolées » : vrai', () => {
759
+ expect(isLigneQ4paSurfacesIsolees({ isolation_surfaces: '1' }, '0')).toBe(true);
760
+ expect(isLigneQ4paSurfacesIsolees({ isolation_surfaces: 1 }, 0)).toBe(true);
761
+ });
762
+
763
+ test('ligne « non isolées » et recalcul « isolées » : faux (sens inverse)', () => {
764
+ expect(isLigneQ4paSurfacesIsolees({ isolation_surfaces: '0' }, '1')).toBe(false);
765
+ });
766
+
767
+ test('valeurs concordantes ou ligne sans critère : faux', () => {
768
+ expect(isLigneQ4paSurfacesIsolees({ isolation_surfaces: '1' }, '1')).toBe(false);
769
+ expect(isLigneQ4paSurfacesIsolees({}, '0')).toBe(false);
770
+ });
771
+ });
package/README.md CHANGED
@@ -251,11 +251,18 @@ const c = calcul_3cl(dpeData, { sanitize: false });
251
251
  // Depuis un XML ADEME
252
252
  const d = calcul_3cl_xml(xmlString);
253
253
  const e = calcul_3cl_xml(xmlString, { sanitize: false });
254
+
255
+ // Analyse du corpus ADEME uniquement : reproduire les choix du logiciel de saisie
256
+ const f = calcul_3cl(dpeData, { sanitize: true, compat_for_corpus_analysis: true });
254
257
  ```
255
258
 
256
- | Option | Défaut | Effet |
257
- | :--------- | :----- | :----------------------------------------------------------------------------------------------------- |
258
- | `sanitize` | `true` | Normalise et corrige les incohérences du DPE d'entrée avant calcul. Désactivez-le pour un calcul brut. |
259
+ | Option | Défaut | Effet |
260
+ | :--------------------------- | :------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
261
+ | `sanitize` | `true` | Normalise et corrige les incohérences du DPE d'entrée avant calcul. Désactivez-le pour un calcul brut. |
262
+ | `compat_for_corpus_analysis` | `false` | Reprend la ligne `q4pa_conv` choisie par le logiciel de saisie même si elle contredit l'isolation des parois ou les joints déclarés, pour reproduire les DPE du corpus ADEME. À n'utiliser que pour l'analyse de corpus. Désactivé, le moteur recalcule `q4pa_conv` selon la méthode (contrôle de cohérence d'un DPE, scénarios de travaux). |
263
+
264
+ > Dès qu'un objet `options` est passé, `sanitize` vaut `false` s'il n'est pas précisé : indiquez
265
+ > `sanitize: true` avec `compat_for_corpus_analysis`.
259
266
 
260
267
  <details>
261
268
  <summary><strong>Exemple d'objet JSON d'entrée (partiel)</strong></summary>
@@ -376,28 +383,28 @@ Ces résultats sont **générés automatiquement** à la fin de `npm run test:co
376
383
 
377
384
  <!-- CORPUS:START -->
378
385
 
379
- > **Version `1.8.67`** · branche `main` · généré le 2026-10-16
386
+ > **Version `1.9.1`** · branche `main` · généré le 2026-10-26
380
387
  > Seuil de tolérance **5%**
381
388
 
382
389
  <table>
383
390
  <tr>
384
391
  <td align="center"><strong>9</strong><br/><sub>corpus</sub></td>
385
392
  <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>
393
+ <td align="center"><strong>75 700</strong><br/><sub>DPE conformes</sub></td>
394
+ <td align="center"><strong>85,56 %</strong><br/><sub>réussite globale</sub></td>
388
395
  </tr>
389
396
  </table>
390
397
 
391
398
  | | Corpus | Réussite | | DPE conformes |
392
399
  | :-: | :---------------------------------------------------------------------------------------------------------------------- | ----------: | :--------------------- | -------------: |
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 |
400
+ | 🟢 | **Appartement · chauffage individuel (2025)**<br/><sub>`dpe_appartement_individuel_chauffage_individuel_2025.csv`</sub> | **95,43 %** | `███████████████████░` | 9 441 / 9 893 |
401
+ | 🟢 | **Appartement · chauffage collectif (2025)**<br/><sub>`dpe_appartement_individuel_chauffage_collectif_2025.csv`</sub> | **94,55 %** | `███████████████████░` | 9 385 / 9 926 |
402
+ | 🟢 | **Logement individuel (2025)**<br/><sub>`dpe_logement_individuel_2025.csv`</sub> | **93,36 %** | `███████████████████░` | 9 180 / 9 833 |
403
+ | 🟢 | **Maison individuelle (2025)**<br/><sub>`dpe_maison_individuelle_2025.csv`</sub> | **92,14 %** | `██████████████████░░` | 9 003 / 9 771 |
404
+ | 🟢 | **Maison individuelle (2026)**<br/><sub>`dpe_maison_individuelle_2026.csv`</sub> | **90,46 %** | `██████████████████░░` | 9 046 / 10 000 |
405
+ | 🟢 | **Immeuble · chauffage collectif**<br/><sub>`dpe_immeuble_chauffage_collectif.csv`</sub> | **89,13 %** | `██████████████████░░` | 8 680 / 9 739 |
406
+ | 🟢 | **Immeuble · chauffage individuel**<br/><sub>`dpe_immeuble_chauffage_individuel.csv`</sub> | **86,04 %** | `█████████████████░░░` | 8 539 / 9 924 |
407
+ | 🟡 | **Immeuble · chauffage mixte**<br/><sub>`dpe_immeuble_chauffage_mixte.csv`</sub> | **70,50 %** | `██████████████░░░░░░` | 6 748 / 9 571 |
401
408
  | 🔴 | **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 |
402
409
 
403
410
  <sub>🟢 ≥ 85 % · 🟡 ≥ 60 % · 🔴 < 60 %</sub>
@@ -409,24 +416,24 @@ Ces résultats sont **générés automatiquement** à la fin de `npm run test:co
409
416
 
410
417
  <table>
411
418
  <tr>
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>
419
+ <td align="center"><strong>6,66 ms</strong><br/><sub>moyenne</sub></td>
420
+ <td align="center"><strong>5,79 ms</strong><br/><sub>médiane</sub></td>
421
+ <td align="center"><strong>0,13 ms</strong><br/><sub>min</sub></td>
422
+ <td align="center"><strong>911,0 ms</strong><br/><sub>max</sub></td>
416
423
  </tr>
417
424
  </table>
418
425
 
419
426
  | Corpus | Moyenne | Médiane | Min | Max | p95 | p99 |
420
427
  | :--------------------------------------------- | ------: | ------: | ------: | -------: | ------: | ------: |
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 |
428
+ | **Appartement · chauffage individuel (2025)** | 5,04 ms | 4,55 ms | 1,68 ms | 58,6 ms | 8,75 ms | 11,6 ms |
429
+ | **Appartement · chauffage collectif (2025)** | 5,96 ms | 5,55 ms | 2,00 ms | 56,7 ms | 9,64 ms | 12,0 ms |
430
+ | **Logement individuel (2025)** | 6,17 ms | 5,46 ms | 1,75 ms | 60,4 ms | 11,5 ms | 16,1 ms |
431
+ | **Maison individuelle (2025)** | 6,97 ms | 6,28 ms | 2,02 ms | 59,3 ms | 12,3 ms | 16,7 ms |
432
+ | **Maison individuelle (2026)** | 7,06 ms | 6,30 ms | 2,00 ms | 45,5 ms | 12,6 ms | 17,6 ms |
433
+ | **Immeuble · chauffage collectif** | 6,94 ms | 5,88 ms | 0,14 ms | 144,5 ms | 12,8 ms | 22,1 ms |
434
+ | **Immeuble · chauffage individuel** | 7,88 ms | 6,68 ms | 0,13 ms | 172,2 ms | 15,7 ms | 25,4 ms |
435
+ | **Immeuble · chauffage mixte** | 6,74 ms | 5,47 ms | 0,22 ms | 911,0 ms | 14,1 ms | 22,5 ms |
436
+ | **Individuel généré depuis l'immeuble (2026)** | 7,21 ms | 5,97 ms | 1,81 ms | 91,1 ms | 13,7 ms | 22,1 ms |
430
437
 
431
438
  <sub>La moyenne est tirée vers le haut par les DPE collectifs, dont le coût atteint plusieurs
432
439
  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
@@ -24,6 +24,7 @@ import {
24
24
  containsAnySubstring,
25
25
  isEffetJoule,
26
26
  set_bug_for_bug_compat,
27
+ set_compat_for_corpus_analysis,
27
28
  use_enum_as_string,
28
29
  useEnumAsString,
29
30
  xmlParser
@@ -35,7 +36,7 @@ import DpeSanitizerService from './dpe-sanitizer.service.js';
35
36
  import { reconstituerRdimDpeGenereImmeuble } from './17.2_rdim_dpe_genere_immeuble.js';
36
37
  import { reconstituerRdimEcsIndividuelleUnique } from './11_rdim_ecs_individuelle_unique.js';
37
38
 
38
- const LIB_VERSION = '1.9.0';
39
+ const LIB_VERSION = '1.10.0';
39
40
 
40
41
  const dpeSanitizerService = new DpeSanitizerService();
41
42
 
@@ -58,7 +59,7 @@ export function getVersion() {
58
59
  /**
59
60
  * Run the engine with a full dpe xml content
60
61
  * @param dpeXmlContent {string} A full dpe xml content
61
- * @param options {{sanitize: boolean}?}
62
+ * @param options {{sanitize?: boolean, compat_for_corpus_analysis?: boolean}?} voir `calcul_3cl`
62
63
  * @return {FullDpe}
63
64
  */
64
65
  export function calcul_3cl_xml(dpeXmlContent, options) {
@@ -70,12 +71,18 @@ export function calcul_3cl_xml(dpeXmlContent, options) {
70
71
  /**
71
72
  * Run the engine with a javascript plain object dpe
72
73
  * @param inputDpe {FullDpe} A full plain object dpe
73
- * @param options {{sanitize: boolean}?}
74
+ * @param options {{sanitize?: boolean, compat_for_corpus_analysis?: boolean}?}
75
+ * - `sanitize` (défaut `true` si `options` est absent) : nettoyage du DPE d'entrée ;
76
+ * - `compat_for_corpus_analysis` (défaut `false`) : reprend les valeurs choisies par le logiciel de
77
+ * saisie qui contredisent les données d'entrée (ligne q4pa_conv, issues #312 et #294), pour
78
+ * reproduire les DPE du corpus ADEME. À réserver à l'analyse de corpus : jamais pour le contrôle
79
+ * d'un DPE ni pour les scénarios de travaux.
74
80
  * @return {FullDpe}
75
81
  */
76
82
  export function calcul_3cl(inputDpe, options) {
77
83
  set_bug_for_bug_compat();
78
84
  if (!options) options = { sanitize: true };
85
+ set_compat_for_corpus_analysis(options.compat_for_corpus_analysis === true);
79
86
  let dpe;
80
87
  if (options.sanitize) {
81
88
  dpe = dpeSanitizerService.execute(inputDpe);
package/engine.spec.js CHANGED
@@ -21,6 +21,7 @@ let useEnumAsStringFlag = false;
21
21
 
22
22
  vi.mock('./utils.js', () => ({
23
23
  set_bug_for_bug_compat: vi.fn(),
24
+ set_compat_for_corpus_analysis: vi.fn(),
24
25
  add_references: vi.fn(),
25
26
  get bug_for_bug_compat() {
26
27
  return bugForBugCompat;
@@ -104,6 +105,7 @@ const {
104
105
  collectionCanBeEmpty,
105
106
  containsAnySubstring,
106
107
  isEffetJoule,
108
+ set_compat_for_corpus_analysis,
107
109
  useEnumAsString,
108
110
  xmlParser
109
111
  } = await import('./utils.js');
@@ -365,6 +367,52 @@ describe('calcul_3cl - garde-fous précoces', () => {
365
367
  });
366
368
  });
367
369
 
370
+ /**
371
+ * Option `compat_for_corpus_analysis` : reprise des valeurs du logiciel de saisie (ligne q4pa_conv)
372
+ * réservée à l'analyse du corpus ADEME. Désactivée par défaut et repositionnée à chaque appel.
373
+ */
374
+ describe('calcul_3cl - option compat_for_corpus_analysis', () => {
375
+ const dpeNonSupporte = () => ({ administratif: { enum_modele_dpe_id: '99' } });
376
+
377
+ beforeEach(() => {
378
+ vi.spyOn(console, 'error').mockImplementation(() => {});
379
+ });
380
+
381
+ test('sans options : désactivée', () => {
382
+ calcul_3cl(dpeNonSupporte());
383
+ expect(set_compat_for_corpus_analysis).toHaveBeenCalledExactlyOnceWith(false);
384
+ });
385
+
386
+ test('options sans le champ : désactivée', () => {
387
+ calcul_3cl(dpeNonSupporte(), { sanitize: false });
388
+ expect(set_compat_for_corpus_analysis).toHaveBeenCalledExactlyOnceWith(false);
389
+ });
390
+
391
+ test('valeur non booléenne « truthy » : désactivée (seul true active la reprise)', () => {
392
+ calcul_3cl(dpeNonSupporte(), { sanitize: false, compat_for_corpus_analysis: 'true' });
393
+ expect(set_compat_for_corpus_analysis).toHaveBeenCalledExactlyOnceWith(false);
394
+ });
395
+
396
+ test('true : activée, sans changer la sanitisation demandée', () => {
397
+ const dpe = dpeNonSupporte();
398
+ calcul_3cl(dpe, { sanitize: true, compat_for_corpus_analysis: true });
399
+ expect(set_compat_for_corpus_analysis).toHaveBeenCalledExactlyOnceWith(true);
400
+ expect(sanitizeExecute).toHaveBeenCalledWith(dpe);
401
+ });
402
+
403
+ test('repositionnée à chaque appel : un appel par défaut après un appel activé la désactive', () => {
404
+ calcul_3cl(dpeNonSupporte(), { sanitize: false, compat_for_corpus_analysis: true });
405
+ calcul_3cl(dpeNonSupporte());
406
+ expect(vi.mocked(set_compat_for_corpus_analysis).mock.calls).toEqual([[true], [false]]);
407
+ });
408
+
409
+ test('calcul_3cl_xml transmet l’option', () => {
410
+ vi.mocked(xmlParser.parse).mockReturnValue({ dpe: dpeNonSupporte() });
411
+ calcul_3cl_xml('<dpe/>', { sanitize: false, compat_for_corpus_analysis: true });
412
+ expect(set_compat_for_corpus_analysis).toHaveBeenCalledExactlyOnceWith(true);
413
+ });
414
+ });
415
+
368
416
  describe('calcul_3cl_xml - garde sur le modèle de DPE', () => {
369
417
  test('parse le XML puis retourne null pour un modèle non supporté', () => {
370
418
  const dpe = { administratif: { enum_modele_dpe_id: '99' } };
@@ -1,7 +1,7 @@
1
1
  import { DeperditionService } from '../deperdition.service.js';
2
2
 
3
3
  import { logger } from '../../../../../core/util/logger/log-service.js';
4
- import { bug_for_bug_compat } from '../../../../../utils.js';
4
+ import { bug_for_bug_compat, compat_for_corpus_analysis } from '../../../../../utils.js';
5
5
  import { TypeHabitation } from '../../../../dpe/domain/models/type-habitation.model.js';
6
6
  import { TypeVentilation } from '../../../../dpe/domain/models/type-ventilation.model.js';
7
7
 
@@ -100,12 +100,14 @@ export class DeperditionVentilationService extends DeperditionService {
100
100
  presenceJointsMenuiserie = '1';
101
101
  } else if (
102
102
  bug_for_bug_compat &&
103
+ compat_for_corpus_analysis &&
103
104
  presenceJointNonRenseignee &&
104
105
  this.tvStore.getQ4paConvById(ventilationDE.tv_q4pa_conv_id)
105
106
  ?.presence_joints_menuiserie === '1'
106
107
  ) {
107
108
  // Issue #294 : aucune menuiserie n'exporte presence_joint, rien ne contredit la ligne
108
- // q4pa_conv « avant 1948 avec joints » retenue par le DPE => on la reprend.
109
+ // q4pa_conv « avant 1948 avec joints » retenue par le DPE => on la reprend, uniquement
110
+ // pour l'analyse du corpus ADEME (option compat_for_corpus_analysis).
109
111
  presenceJointsMenuiserie = '1';
110
112
  }
111
113
  }
@@ -1,11 +1,14 @@
1
1
  import { beforeEach, describe, expect, test, vi } from 'vitest';
2
2
 
3
- /** `bug_for_bug_compat` (utils.js) piloté par le test. */
4
- const state = vi.hoisted(() => ({ bug: false }));
3
+ /** `bug_for_bug_compat` et `compat_for_corpus_analysis` (utils.js) pilotés par le test. */
4
+ const state = vi.hoisted(() => ({ bug: false, compat: false }));
5
5
  vi.mock('../../../../../utils.js', async (importOriginal) => ({
6
6
  ...(await importOriginal()),
7
7
  get bug_for_bug_compat() {
8
8
  return state.bug;
9
+ },
10
+ get compat_for_corpus_analysis() {
11
+ return state.compat;
9
12
  }
10
13
  }));
11
14
 
@@ -32,6 +35,7 @@ describe('DeperditionVentilationService.q4paConv', () => {
32
35
  };
33
36
  service = new DeperditionVentilationService(tvStore);
34
37
  state.bug = false;
38
+ state.compat = false;
35
39
  });
36
40
 
37
41
  test('Q4Pa mesuré saisi : utilisé directement', () => {
@@ -52,6 +56,7 @@ describe('DeperditionVentilationService.q4paConv', () => {
52
56
  describe('presence_joint non exporté (issue #294)', () => {
53
57
  test('hors bug_for_bug_compat : la ligne du DPE est ignorée', () => {
54
58
  state.bug = false;
59
+ state.compat = true;
55
60
  tvStore.getQ4paConvById.mockReturnValue({ presence_joints_menuiserie: '1' });
56
61
 
57
62
  service.q4paConv({ tv_q4pa_conv_id: 12 }, ctx, 0, 100, 0, 10, true);
@@ -59,8 +64,20 @@ describe('DeperditionVentilationService.q4paConv', () => {
59
64
  expect(tvStore.getQ4paConv).toHaveBeenCalledWith('1', 'APPARTEMENT', '0', undefined);
60
65
  });
61
66
 
62
- test('bug_for_bug_compat et ligne DPE « avec joints » : joints repris', () => {
67
+ test('par défaut (sans compat_for_corpus_analysis) : la ligne du DPE est ignorée', () => {
63
68
  state.bug = true;
69
+ state.compat = false;
70
+ tvStore.getQ4paConvById.mockReturnValue({ presence_joints_menuiserie: '1' });
71
+
72
+ service.q4paConv({ tv_q4pa_conv_id: 12 }, ctx, 0, 100, 0, 10, true);
73
+
74
+ expect(tvStore.getQ4paConvById).not.toHaveBeenCalled();
75
+ expect(tvStore.getQ4paConv).toHaveBeenCalledWith('1', 'APPARTEMENT', '0', undefined);
76
+ });
77
+
78
+ test('compat_for_corpus_analysis et ligne DPE « avec joints » : joints repris', () => {
79
+ state.bug = true;
80
+ state.compat = true;
64
81
  tvStore.getQ4paConvById.mockReturnValue({ presence_joints_menuiserie: '1' });
65
82
 
66
83
  service.q4paConv({ tv_q4pa_conv_id: 12 }, ctx, 0, 100, 0, 10, true);
@@ -69,8 +86,9 @@ describe('DeperditionVentilationService.q4paConv', () => {
69
86
  expect(tvStore.getQ4paConv).toHaveBeenCalledWith('1', 'APPARTEMENT', '0', '1');
70
87
  });
71
88
 
72
- test('bug_for_bug_compat mais presence_joint renseigné : pas de reprise', () => {
89
+ test('compat_for_corpus_analysis mais presence_joint renseigné : pas de reprise', () => {
73
90
  state.bug = true;
91
+ state.compat = true;
74
92
  tvStore.getQ4paConvById.mockReturnValue({ presence_joints_menuiserie: '1' });
75
93
 
76
94
  service.q4paConv({ tv_q4pa_conv_id: 12 }, ctx, 0, 100, 0, 10, false);
@@ -78,8 +96,9 @@ describe('DeperditionVentilationService.q4paConv', () => {
78
96
  expect(tvStore.getQ4paConv).toHaveBeenCalledWith('1', 'APPARTEMENT', '0', undefined);
79
97
  });
80
98
 
81
- test('bug_for_bug_compat et ligne DPE sans joints : pas de reprise', () => {
99
+ test('compat_for_corpus_analysis et ligne DPE sans joints : pas de reprise', () => {
82
100
  state.bug = true;
101
+ state.compat = true;
83
102
  tvStore.getQ4paConvById.mockReturnValue({ presence_joints_menuiserie: '' });
84
103
 
85
104
  service.q4paConv({ tv_q4pa_conv_id: 1 }, ctx, 0, 100, 0, 10, true);
@@ -89,6 +108,7 @@ describe('DeperditionVentilationService.q4paConv', () => {
89
108
 
90
109
  test('surfaces majoritairement isolées : le critère des joints ne s’applique pas', () => {
91
110
  state.bug = true;
111
+ state.compat = true;
92
112
  tvStore.getQ4paConvById.mockReturnValue({ presence_joints_menuiserie: '1' });
93
113
 
94
114
  service.q4paConv({ tv_q4pa_conv_id: 12 }, ctx, 100, 0, 0, 10, true);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open3cl/engine",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Open Source 3CL-DPE engine",
5
5
  "main": "index.js",
6
6
  "directories": {
package/utils.js CHANGED
@@ -40,6 +40,29 @@ export function set_bug_for_bug_compat() {
40
40
  bug_for_bug_compat = true;
41
41
  }
42
42
 
43
+ /**
44
+ * Compatibilité réservée à l'analyse du corpus ADEME.
45
+ *
46
+ * Activée, le moteur reprend certaines valeurs choisies par le logiciel de saisie même
47
+ * lorsqu'elles contredisent les données d'entrée (ex. ligne `tv_q4pa_conv_id` incohérente avec
48
+ * l'isolation des parois ou les joints des menuiseries, issues #312 et #294), dans le seul but de
49
+ * reproduire les résultats des DPE du corpus.
50
+ *
51
+ * Ne jamais l'activer pour contrôler la cohérence d'un DPE ni pour calculer des scénarios de
52
+ * travaux : l'erreur du logiciel de saisie serait alors masquée ou propagée.
53
+ *
54
+ * Positionnée à chaque appel de `calcul_3cl` à partir de l'option du même nom.
55
+ * @type {boolean}
56
+ */
57
+ export let compat_for_corpus_analysis = false;
58
+
59
+ /**
60
+ * @param value {boolean} `true` uniquement pour l'analyse du corpus ADEME
61
+ */
62
+ export function set_compat_for_corpus_analysis(value = false) {
63
+ compat_for_corpus_analysis = value === true;
64
+ }
65
+
43
66
  export function useEnumAsString(jsonObj) {
44
67
  for (const key in jsonObj) {
45
68
  if (jsonObj.hasOwnProperty(key)) {
package/utils.spec.js CHANGED
@@ -35,6 +35,7 @@ vi.mock('./tv.js', () => ({
35
35
  const {
36
36
  xmlParser,
37
37
  set_bug_for_bug_compat,
38
+ set_compat_for_corpus_analysis,
38
39
  useEnumAsString,
39
40
  set_use_enum_as_string,
40
41
  set_tv_match_optimized_version,
@@ -171,6 +172,20 @@ describe('Utils unit tests', () => {
171
172
  expect(utils.bug_for_bug_compat).toBe(true);
172
173
  });
173
174
 
175
+ test('compat_for_corpus_analysis : désactivé par défaut, seul true l’active', () => {
176
+ expect(utils.compat_for_corpus_analysis).toBe(false);
177
+ set_compat_for_corpus_analysis(true);
178
+ expect(utils.compat_for_corpus_analysis).toBe(true);
179
+ set_compat_for_corpus_analysis();
180
+ expect(utils.compat_for_corpus_analysis).toBe(false);
181
+ set_compat_for_corpus_analysis(true);
182
+ set_compat_for_corpus_analysis('true');
183
+ expect(utils.compat_for_corpus_analysis).toBe(false);
184
+ set_compat_for_corpus_analysis(true);
185
+ set_compat_for_corpus_analysis(false);
186
+ expect(utils.compat_for_corpus_analysis).toBe(false);
187
+ });
188
+
174
189
  test('set_use_enum_as_string active le drapeau', () => {
175
190
  set_use_enum_as_string();
176
191
  expect(utils.use_enum_as_string).toBe(true);