@open3cl/engine 1.9.1 → 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) {
@@ -119,6 +125,11 @@ function tv_q4pa_conv(di, de, cg, mur_list, ph_list, porte_list, bv_list) {
119
125
  * Si la ligne saisie correspond à la période de construction du DPE, on la reprend en entier
120
126
  * (isolation et joints) pour reproduire le calcul du logiciel.
121
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
+ *
122
133
  * Sens unique : la reprise ne s'applique que si la ligne indique des surfaces isolées alors que
123
134
  * le recalcul les donne non isolées. Dans le sens inverse (ligne « non isolées », recalcul
124
135
  * « isolées »), le DPE a pu être modifié par l'appelant (scénario d'isolation des parois) :
@@ -127,6 +138,7 @@ function tv_q4pa_conv(di, de, cg, mur_list, ph_list, porte_list, bv_list) {
127
138
  */
128
139
  if (rowQ4paConv.isolation_surfaces !== isolation_surfaces) {
129
140
  if (
141
+ compat_for_corpus_analysis &&
130
142
  isLigneQ4paSurfacesIsolees(rowQ4paConv, isolation_surfaces) &&
131
143
  isLigneQ4paMemePeriode(rowQ4paConv, cg)
132
144
  ) {
@@ -147,10 +159,14 @@ function tv_q4pa_conv(di, de, cg, mur_list, ph_list, porte_list, bv_list) {
147
159
  * Issue #294 : aucune menuiserie n'exporte `presence_joint`. Aucune donnée ne contredit alors
148
160
  * la ligne q4pa_conv retenue par le logiciel (ex. ligne 12 « avant 1948 avec joints », 2,5) :
149
161
  * on reprend sa valeur `presence_joints_menuiserie` au lieu de considérer les menuiseries
150
- * sans joint.
162
+ * sans joint. Uniquement avec l'option `compat_for_corpus_analysis`.
151
163
  * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §4
152
164
  */
153
- if (!ligneDpeReprise && presenceJointNonRenseignee(porte_list, bv_list)) {
165
+ if (
166
+ compat_for_corpus_analysis &&
167
+ !ligneDpeReprise &&
168
+ presenceJointNonRenseignee(porte_list, bv_list)
169
+ ) {
154
170
  pjt = rowQ4paConv.presence_joints_menuiserie === '1' ? '1' : '0';
155
171
  } else if (!ligneDpeReprise && rowQ4paConv.presence_joints_menuiserie !== pjt) {
156
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', () => ({
@@ -301,10 +303,12 @@ describe('calc_ventilation - orchestration', () => {
301
303
  */
302
304
  describe('calc_ventilation - compatibilité bug_for_bug_compat activée', () => {
303
305
  /**
304
- * Recharge le module avec `bug_for_bug_compat = true`. Le double de `tv` renvoie une ligne
305
- * `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.
306
310
  */
307
- async function chargerAvecBug(rowParId) {
311
+ async function chargerAvecBug(rowParId, compat = true) {
308
312
  vi.resetModules();
309
313
  const tvBug = vi.fn((table, matcher) => {
310
314
  if (table === 'debits_ventilation') return { ...ROW_DEBITS };
@@ -318,7 +322,8 @@ describe('calc_ventilation - compatibilité bug_for_bug_compat activée', () =>
318
322
  tv: tvBug,
319
323
  requestInput: (de, du, field) => de[field],
320
324
  requestInputID: (de, du, field) => de[`enum_${field}_id`],
321
- bug_for_bug_compat: true
325
+ bug_for_bug_compat: true,
326
+ compat_for_corpus_analysis: compat
322
327
  }));
323
328
  vi.doMock('./5_conso_ventilation.js', () => ({ default: vi.fn() }));
324
329
  const { default: calc_ventilation_bug } = await import('./4_ventilation.js');
@@ -625,6 +630,78 @@ describe('calc_ventilation - compatibilité bug_for_bug_compat activée', () =>
625
630
  errorSpy.mockRestore();
626
631
  });
627
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
+ });
628
705
  });
629
706
 
630
707
  describe('presenceJointNonRenseignee', () => {
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.1';
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.1",
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);