Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,11 +251,18 @@ const c = calcul_3cl(dpeData, { sanitize: false });
// Depuis un XML ADEME
const d = calcul_3cl_xml(xmlString);
const e = calcul_3cl_xml(xmlString, { sanitize: false });

// Analyse du corpus ADEME uniquement : reproduire les choix du logiciel de saisie
const f = calcul_3cl(dpeData, { sanitize: true, compat_for_corpus_analysis: true });
```

| Option | Défaut | Effet |
| :--------- | :----- | :----------------------------------------------------------------------------------------------------- |
| `sanitize` | `true` | Normalise et corrige les incohérences du DPE d'entrée avant calcul. Désactivez-le pour un calcul brut. |
| Option | Défaut | Effet |
| :--------------------------- | :------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sanitize` | `true` | Normalise et corrige les incohérences du DPE d'entrée avant calcul. Désactivez-le pour un calcul brut. |
| `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). |

> Dès qu'un objet `options` est passé, `sanitize` vaut `false` s'il n'est pas précisé : indiquez
> `sanitize: true` avec `compat_for_corpus_analysis`.

<details>
<summary><strong>Exemple d'objet JSON d'entrée (partiel)</strong></summary>
Expand Down
22 changes: 19 additions & 3 deletions src/4_ventilation.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { tv, requestInputID, requestInput, bug_for_bug_compat } from './utils.js';
import {
tv,
requestInputID,
requestInput,
bug_for_bug_compat,
compat_for_corpus_analysis
} from './utils.js';
import calc_pvent from './5_conso_ventilation.js';

function tv_debits_ventilation(di, de, du) {
Expand Down Expand Up @@ -119,6 +125,11 @@ function tv_q4pa_conv(di, de, cg, mur_list, ph_list, porte_list, bv_list) {
* Si la ligne saisie correspond à la période de construction du DPE, on la reprend en entier
* (isolation et joints) pour reproduire le calcul du logiciel.
*
* Cette reprise n'est faite qu'avec l'option `compat_for_corpus_analysis` (analyse du corpus
* ADEME). Par défaut, la ligne contradictoire est seulement signalée et q4pa_conv est
* recalculé selon la méthode : un contrôle de cohérence du DPE doit détecter l'erreur et un
* scénario de travaux ne doit pas la propager.
*
* Sens unique : la reprise ne s'applique que si la ligne indique des surfaces isolées alors que
* le recalcul les donne non isolées. Dans le sens inverse (ligne « non isolées », recalcul
* « isolées »), le DPE a pu être modifié par l'appelant (scénario d'isolation des parois) :
Expand All @@ -127,6 +138,7 @@ function tv_q4pa_conv(di, de, cg, mur_list, ph_list, porte_list, bv_list) {
*/
if (rowQ4paConv.isolation_surfaces !== isolation_surfaces) {
if (
compat_for_corpus_analysis &&
isLigneQ4paSurfacesIsolees(rowQ4paConv, isolation_surfaces) &&
isLigneQ4paMemePeriode(rowQ4paConv, cg)
) {
Expand All @@ -147,10 +159,14 @@ function tv_q4pa_conv(di, de, cg, mur_list, ph_list, porte_list, bv_list) {
* Issue #294 : aucune menuiserie n'exporte `presence_joint`. Aucune donnée ne contredit alors
* la ligne q4pa_conv retenue par le logiciel (ex. ligne 12 « avant 1948 avec joints », 2,5) :
* on reprend sa valeur `presence_joints_menuiserie` au lieu de considérer les menuiseries
* sans joint.
* sans joint. Uniquement avec l'option `compat_for_corpus_analysis`.
* @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §4
*/
if (!ligneDpeReprise && presenceJointNonRenseignee(porte_list, bv_list)) {
if (
compat_for_corpus_analysis &&
!ligneDpeReprise &&
presenceJointNonRenseignee(porte_list, bv_list)
) {
pjt = rowQ4paConv.presence_joints_menuiserie === '1' ? '1' : '0';
} else if (!ligneDpeReprise && rowQ4paConv.presence_joints_menuiserie !== pjt) {
console.error(
Expand Down
89 changes: 83 additions & 6 deletions src/4_ventilation.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
* Dépendances mockées pour isoler le module de ventilation :
* - `tv` : accès aux tables `debits_ventilation` / `q4pa_conv`, on contrôle les lignes ;
* - `requestInput` / `requestInputID` : passe-plats vers les données d'entrée ;
* - `bug_for_bug_compat` : désactivé pour isoler le comportement nominal ;
* - `bug_for_bug_compat` / `compat_for_corpus_analysis` : désactivés pour isoler le comportement
* nominal ;
* - `calc_pvent` (5_conso_ventilation.js) : espion, la conso de ventilation est testée ailleurs.
*/
vi.mock('./utils.js', () => ({
set_bug_for_bug_compat: vi.fn(),
tv: vi.fn(),
requestInput: (de, du, field) => de[field],
requestInputID: (de, du, field) => de[`enum_${field}_id`],
bug_for_bug_compat: false
bug_for_bug_compat: false,
compat_for_corpus_analysis: false
}));

vi.mock('./5_conso_ventilation.js', () => ({
Expand Down Expand Up @@ -301,10 +303,12 @@ describe('calc_ventilation - orchestration', () => {
*/
describe('calc_ventilation - compatibilité bug_for_bug_compat activée', () => {
/**
* Recharge le module avec `bug_for_bug_compat = true`. Le double de `tv` renvoie une ligne
* `q4pa_conv` distincte selon que le matcher cible l'identifiant déjà saisi ou le matcher complet.
* Recharge le module avec `bug_for_bug_compat = true` et `compat_for_corpus_analysis` (vrai par
* défaut : reprise de la ligne du DPE pour l'analyse de corpus). Le double de `tv` renvoie une
* ligne `q4pa_conv` distincte selon que le matcher cible l'identifiant déjà saisi ou le matcher
* complet.
*/
async function chargerAvecBug(rowParId) {
async function chargerAvecBug(rowParId, compat = true) {
vi.resetModules();
const tvBug = vi.fn((table, matcher) => {
if (table === 'debits_ventilation') return { ...ROW_DEBITS };
Expand All @@ -318,7 +322,8 @@ describe('calc_ventilation - compatibilité bug_for_bug_compat activée', () =>
tv: tvBug,
requestInput: (de, du, field) => de[field],
requestInputID: (de, du, field) => de[`enum_${field}_id`],
bug_for_bug_compat: true
bug_for_bug_compat: true,
compat_for_corpus_analysis: compat
}));
vi.doMock('./5_conso_ventilation.js', () => ({ default: vi.fn() }));
const { default: calc_ventilation_bug } = await import('./4_ventilation.js');
Expand Down Expand Up @@ -625,6 +630,78 @@ describe('calc_ventilation - compatibilité bug_for_bug_compat activée', () =>
errorSpy.mockRestore();
});
});

/**
* Sans l'option `compat_for_corpus_analysis` (comportement par défaut) : la ligne q4pa_conv du DPE
* n'est jamais reprise, q4pa_conv est recalculé selon la méthode. Les écarts sont seulement
* signalés (contrôle de cohérence d'un DPE, scénarios de travaux).
*/
describe('sans compat_for_corpus_analysis : ligne du DPE jamais reprise', () => {
const murNonIsole = [
{
donnee_intermediaire: { b: 1 },
donnee_entree: { enum_type_isolation_id: '2', surface_paroi_opaque: 100 }
}
];
const vtLigne = (id) => ({
donnee_entree: { enum_type_ventilation_id: '1', tv_q4pa_conv_id: id, description: 'v' }
});

test('ligne « surfaces isolées » de la même période (#312) : isolation et joints recalculés', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { calc_ventilation_bug, tvBug } = await chargerAvecBug(
{
q4pa_conv: '2',
tv_q4pa_conv_id: '11',
enum_periode_construction_id: '1',
isolation_surfaces: '1',
presence_joints_menuiserie: '1'
},
false
);
const baieSansJoint = [{ donnee_entree: { surface_totale_baie: 10, presence_joint: 0 } }];

calc_ventilation_bug(vtLigne(11), CG, 'th', 80, 100, murNonIsole, [], [], baieSansJoint);

expect(tvBug).toHaveBeenLastCalledWith(
'q4pa_conv',
expect.objectContaining({ isolation_surfaces: '0', presence_joints_menuiserie: '0' })
);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('La valeur recalculée isolation_surfaces = 0 est utilisée')
);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('La valeur recalculée presence_joints_menuiserie = 0 est utilisée')
);
expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining('est conservée'));
errorSpy.mockRestore();
});

test('presence_joint non exporté (#294) : joints recalculés à « 0 » et écart signalé', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { calc_ventilation_bug, tvBug } = await chargerAvecBug(
{
q4pa_conv: '2.5',
tv_q4pa_conv_id: '12',
isolation_surfaces: '0',
presence_joints_menuiserie: '1'
},
false
);
const baies = [{ donnee_entree: { surface_totale_baie: 10 } }];

calc_ventilation_bug(vtLigne(12), CG, 'th', 80, 100, murNonIsole, [], [], baies);

expect(tvBug).toHaveBeenLastCalledWith(
'q4pa_conv',
expect.objectContaining({ isolation_surfaces: '0', presence_joints_menuiserie: '0' })
);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('La valeur recalculée presence_joints_menuiserie = 0 est utilisée')
);
errorSpy.mockRestore();
});
});
});

describe('presenceJointNonRenseignee', () => {
Expand Down
11 changes: 9 additions & 2 deletions src/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
containsAnySubstring,
isEffetJoule,
set_bug_for_bug_compat,
set_compat_for_corpus_analysis,
use_enum_as_string,
useEnumAsString,
xmlParser
Expand Down Expand Up @@ -58,7 +59,7 @@ export function getVersion() {
/**
* Run the engine with a full dpe xml content
* @param dpeXmlContent {string} A full dpe xml content
* @param options {{sanitize: boolean}?}
* @param options {{sanitize?: boolean, compat_for_corpus_analysis?: boolean}?} voir `calcul_3cl`
* @return {FullDpe}
*/
export function calcul_3cl_xml(dpeXmlContent, options) {
Expand All @@ -70,12 +71,18 @@ export function calcul_3cl_xml(dpeXmlContent, options) {
/**
* Run the engine with a javascript plain object dpe
* @param inputDpe {FullDpe} A full plain object dpe
* @param options {{sanitize: boolean}?}
* @param options {{sanitize?: boolean, compat_for_corpus_analysis?: boolean}?}
* - `sanitize` (défaut `true` si `options` est absent) : nettoyage du DPE d'entrée ;
* - `compat_for_corpus_analysis` (défaut `false`) : reprend les valeurs choisies par le logiciel de
* saisie qui contredisent les données d'entrée (ligne q4pa_conv, issues #312 et #294), pour
* reproduire les DPE du corpus ADEME. À réserver à l'analyse de corpus : jamais pour le contrôle
* d'un DPE ni pour les scénarios de travaux.
* @return {FullDpe}
*/
export function calcul_3cl(inputDpe, options) {
set_bug_for_bug_compat();
if (!options) options = { sanitize: true };
set_compat_for_corpus_analysis(options.compat_for_corpus_analysis === true);
let dpe;
if (options.sanitize) {
dpe = dpeSanitizerService.execute(inputDpe);
Expand Down
48 changes: 48 additions & 0 deletions src/engine.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ let useEnumAsStringFlag = false;

vi.mock('./utils.js', () => ({
set_bug_for_bug_compat: vi.fn(),
set_compat_for_corpus_analysis: vi.fn(),
add_references: vi.fn(),
get bug_for_bug_compat() {
return bugForBugCompat;
Expand Down Expand Up @@ -104,6 +105,7 @@ const {
collectionCanBeEmpty,
containsAnySubstring,
isEffetJoule,
set_compat_for_corpus_analysis,
useEnumAsString,
xmlParser
} = await import('./utils.js');
Expand Down Expand Up @@ -365,6 +367,52 @@ describe('calcul_3cl - garde-fous précoces', () => {
});
});

/**
* Option `compat_for_corpus_analysis` : reprise des valeurs du logiciel de saisie (ligne q4pa_conv)
* réservée à l'analyse du corpus ADEME. Désactivée par défaut et repositionnée à chaque appel.
*/
describe('calcul_3cl - option compat_for_corpus_analysis', () => {
const dpeNonSupporte = () => ({ administratif: { enum_modele_dpe_id: '99' } });

beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {});
});

test('sans options : désactivée', () => {
calcul_3cl(dpeNonSupporte());
expect(set_compat_for_corpus_analysis).toHaveBeenCalledExactlyOnceWith(false);
});

test('options sans le champ : désactivée', () => {
calcul_3cl(dpeNonSupporte(), { sanitize: false });
expect(set_compat_for_corpus_analysis).toHaveBeenCalledExactlyOnceWith(false);
});

test('valeur non booléenne « truthy » : désactivée (seul true active la reprise)', () => {
calcul_3cl(dpeNonSupporte(), { sanitize: false, compat_for_corpus_analysis: 'true' });
expect(set_compat_for_corpus_analysis).toHaveBeenCalledExactlyOnceWith(false);
});

test('true : activée, sans changer la sanitisation demandée', () => {
const dpe = dpeNonSupporte();
calcul_3cl(dpe, { sanitize: true, compat_for_corpus_analysis: true });
expect(set_compat_for_corpus_analysis).toHaveBeenCalledExactlyOnceWith(true);
expect(sanitizeExecute).toHaveBeenCalledWith(dpe);
});

test('repositionnée à chaque appel : un appel par défaut après un appel activé la désactive', () => {
calcul_3cl(dpeNonSupporte(), { sanitize: false, compat_for_corpus_analysis: true });
calcul_3cl(dpeNonSupporte());
expect(vi.mocked(set_compat_for_corpus_analysis).mock.calls).toEqual([[true], [false]]);
});

test('calcul_3cl_xml transmet l’option', () => {
vi.mocked(xmlParser.parse).mockReturnValue({ dpe: dpeNonSupporte() });
calcul_3cl_xml('<dpe/>', { sanitize: false, compat_for_corpus_analysis: true });
expect(set_compat_for_corpus_analysis).toHaveBeenCalledExactlyOnceWith(true);
});
});

describe('calcul_3cl_xml - garde sur le modèle de DPE', () => {
test('parse le XML puis retourne null pour un modèle non supporté', () => {
const dpe = { administratif: { enum_modele_dpe_id: '99' } };
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { DeperditionService } from '../deperdition.service.js';

import { logger } from '../../../../../core/util/logger/log-service.js';
import { bug_for_bug_compat } from '../../../../../utils.js';
import { bug_for_bug_compat, compat_for_corpus_analysis } from '../../../../../utils.js';
import { TypeHabitation } from '../../../../dpe/domain/models/type-habitation.model.js';
import { TypeVentilation } from '../../../../dpe/domain/models/type-ventilation.model.js';

Expand Down Expand Up @@ -100,12 +100,14 @@ export class DeperditionVentilationService extends DeperditionService {
presenceJointsMenuiserie = '1';
} else if (
bug_for_bug_compat &&
compat_for_corpus_analysis &&
presenceJointNonRenseignee &&
this.tvStore.getQ4paConvById(ventilationDE.tv_q4pa_conv_id)
?.presence_joints_menuiserie === '1'
) {
// Issue #294 : aucune menuiserie n'exporte presence_joint, rien ne contredit la ligne
// q4pa_conv « avant 1948 avec joints » retenue par le DPE => on la reprend.
// q4pa_conv « avant 1948 avec joints » retenue par le DPE => on la reprend, uniquement
// pour l'analyse du corpus ADEME (option compat_for_corpus_analysis).
presenceJointsMenuiserie = '1';
}
}
Expand Down
Loading