@open3cl/engine 1.7.0 → 1.7.2
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/10_besoin_fr.spec.js +1 -0
- package/10_clim.spec.js +1 -0
- package/11_besoin_ecs.spec.js +1 -0
- package/11_ecs.spec.js +1 -0
- package/12.4_pac.spec.js +1 -0
- package/13.2_generateur_combustion.js +2 -10
- package/13.2_generateur_combustion.spec.js +3 -3
- package/13.2_generateur_combustion_ch.spec.js +3 -3
- package/14_generateur_ecs.spec.js +1 -0
- package/15_conso_aux.spec.js +3 -3
- package/2021_04_13_confort_ete.spec.js +1 -0
- package/3.1_b.js +45 -33
- package/3.1_b.spec.js +1 -0
- package/3.2.1_mur.spec.js +1 -0
- package/3.2.2_plancher_bas.spec.js +1 -0
- package/3.2.3_plancher_haut.spec.js +1 -0
- package/3.3.1.4_porte.spec.js +1 -0
- package/3.3_baie_vitree.js +32 -10
- package/3.3_baie_vitree.spec.js +1 -0
- package/3.4_pont_thermique.js +21 -13
- package/3.4_pont_thermique.spec.js +2 -1
- package/4_ventilation.spec.js +1 -0
- package/5_conso_ventilation.spec.js +1 -0
- package/6.2_surface_sud_equivalente.spec.js +1 -0
- package/9_besoin_ch.spec.js +1 -0
- package/9_chauffage.spec.js +3 -3
- package/9_conso_ch.spec.js +1 -0
- package/9_emetteur_ch.spec.js +1 -0
- package/9_generateur_ch.spec.js +1 -0
- package/README.md +442 -291
- package/conso.spec.js +1 -0
- package/engine.js +3 -1
- package/engine.spec.js +1 -0
- package/ficheTechnique.js +133 -53
- package/ficheTechnique.spec.js +46 -14
- package/formula.js +251 -0
- package/formula.spec.js +99 -0
- package/package.json +15 -15
- package/utils.js +195 -44
- package/utils.spec.js +69 -0
- package/features/engine/domain/apport_et_besoin/apport-et-besoin.service.spec.js +0 -222
- package/features/engine/domain/apport_et_besoin/ch/perte-ch-recup.service.spec.js +0 -345
- package/features/engine/domain/apport_et_besoin/ecs/besoin-ecs.service.spec.js +0 -87
- package/features/engine/domain/apport_et_besoin/ecs/perte-ecs-recup.service.spec.js +0 -260
- package/features/engine/domain/apport_et_besoin/froid/besoin-froid.service.spec.js +0 -272
- package/features/engine/domain/apport_et_besoin/surface-sud-equivalente.service.spec.js +0 -231
- package/features/engine/domain/ch/generateur-ch.service.spec.js +0 -849
- package/features/engine/domain/conso/froid/conso-froid.service.spec.js +0 -233
- package/features/engine/domain/ecs/installation-ecs.service.spec.js +0 -337
- package/features/engine/domain/enveloppe/baie_vitree/deperdition-baie-vitree.service.spec.js +0 -607
- package/features/engine/domain/enveloppe/deperdition-enveloppe.service.spec.js +0 -550
- package/features/engine/domain/enveloppe/espace_tampon/espace-tampon.service.spec.js +0 -82
- package/features/engine/domain/enveloppe/mur/deperdition-mur.service.spec.js +0 -542
- package/features/engine/domain/enveloppe/plancher_bas/deperdition-plancher-bas.service.spec.js +0 -430
- package/features/engine/domain/enveloppe/plancher_haut/deperdition-plancher-haut.service.spec.js +0 -411
- package/features/engine/domain/enveloppe/pont_thermique/deperdition-pont-thermique.service.spec.js +0 -653
- package/features/engine/domain/enveloppe/porte/deperdition-porte.service.spec.js +0 -117
- package/features/engine/domain/enveloppe/ventilation/deperdition-ventilation.service.spec.js +0 -508
package/conso.spec.js
CHANGED
|
@@ -38,6 +38,7 @@ vi.mock('./tv.js', () => ({
|
|
|
38
38
|
}));
|
|
39
39
|
|
|
40
40
|
vi.mock('./utils.js', () => ({
|
|
41
|
+
set_bug_for_bug_compat: vi.fn(),
|
|
41
42
|
tv: vi.fn((table, matcher) => {
|
|
42
43
|
// Réseau de chaleur "R1" connu, "R2" inconnu
|
|
43
44
|
if (matcher?.identifiant_reseau === 'R1') return { contenu_co2_acv: 0.15 };
|
package/engine.js
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
collectionCanBeEmpty,
|
|
24
24
|
containsAnySubstring,
|
|
25
25
|
isEffetJoule,
|
|
26
|
+
set_bug_for_bug_compat,
|
|
26
27
|
use_enum_as_string,
|
|
27
28
|
useEnumAsString,
|
|
28
29
|
xmlParser
|
|
@@ -32,7 +33,7 @@ import getFicheTechnique from './ficheTechnique.js';
|
|
|
32
33
|
import { ProductionENR } from './16.2_production_enr.js';
|
|
33
34
|
import DpeSanitizerService from './dpe-sanitizer.service.js';
|
|
34
35
|
|
|
35
|
-
const LIB_VERSION = '1.7.
|
|
36
|
+
const LIB_VERSION = '1.7.2';
|
|
36
37
|
|
|
37
38
|
const dpeSanitizerService = new DpeSanitizerService();
|
|
38
39
|
|
|
@@ -71,6 +72,7 @@ export function calcul_3cl_xml(dpeXmlContent, options) {
|
|
|
71
72
|
* @return {FullDpe}
|
|
72
73
|
*/
|
|
73
74
|
export function calcul_3cl(inputDpe, options) {
|
|
75
|
+
set_bug_for_bug_compat();
|
|
74
76
|
if (!options) options = { sanitize: true };
|
|
75
77
|
let dpe;
|
|
76
78
|
if (options.sanitize) {
|
package/engine.spec.js
CHANGED
package/ficheTechnique.js
CHANGED
|
@@ -1,3 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recherche dans les fiches techniques d'un DPE.
|
|
3
|
+
*
|
|
4
|
+
* L'implémentation précédente rebalayait la totalité des fiches à chaque appel, concaténait les
|
|
5
|
+
* sous-fiches dans un `reduce` (`acc.concat(...)` recopie l'accumulateur à chaque itération) et
|
|
6
|
+
* remettait en minuscules des valeurs qui ne changent jamais. Le coût par appel croissait en
|
|
7
|
+
* `fiches^1,3`.
|
|
8
|
+
*
|
|
9
|
+
* Sur un DPE de logement individuel, une poignée de fiches : invisible. Sur un DPE collectif,
|
|
10
|
+
* les fiches suivent les installations — un DPE mesuré comptait 235 fiches et 1597 sous-fiches
|
|
11
|
+
* pour 73 générateurs — et la fonction représentait alors **54 % du temps de `calcul_3cl`**,
|
|
12
|
+
* le ramasse-miettes des concaténations comptant pour 10 % de plus.
|
|
13
|
+
*
|
|
14
|
+
* Les fiches techniques ne sont jamais modifiées pendant un calcul : elles sont donc indexées
|
|
15
|
+
* une fois par DPE, par catégorie, avec les minuscules pré-calculées. L'index est mémorisé dans
|
|
16
|
+
* une `WeakMap` sur le DPE lui-même, donc libéré avec lui — rien à vider, aucune fuite possible
|
|
17
|
+
* sur un process qui enchaîne les calculs.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** @type {WeakMap<object, Map<string, {sousFiches: any[], valeursMinuscules: (string|null)[], champsMinuscules: Map<string, (string|null)[]>}[]>>} */
|
|
21
|
+
const indexParDpe = new WeakMap();
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Regroupe les sous-fiches par catégorie, en conservant l'ordre du document — l'ordre détermine
|
|
25
|
+
* quelle correspondance est retournée en premier.
|
|
26
|
+
*
|
|
27
|
+
* @param dpe {FullDpe}
|
|
28
|
+
*/
|
|
29
|
+
function indexerFichesTechniques(dpe) {
|
|
30
|
+
const parCategorie = new Map();
|
|
31
|
+
|
|
32
|
+
/** @type {FicheTechniqueItem[]} */
|
|
33
|
+
let fichesTechniques = dpe.fiche_technique_collection?.fiche_technique || [];
|
|
34
|
+
if (!Array.isArray(fichesTechniques)) {
|
|
35
|
+
fichesTechniques = [fichesTechniques];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const ficheTechnique of fichesTechniques) {
|
|
39
|
+
if (!ficheTechnique) continue;
|
|
40
|
+
|
|
41
|
+
const categorie = ficheTechnique.enum_categorie_fiche_technique_id;
|
|
42
|
+
let groupes = parCategorie.get(categorie);
|
|
43
|
+
if (!groupes) {
|
|
44
|
+
groupes = [];
|
|
45
|
+
parCategorie.set(categorie, groupes);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const brut = ficheTechnique.sous_fiche_technique_collection?.sous_fiche_technique;
|
|
49
|
+
const sousFiches = brut === undefined ? [] : Array.isArray(brut) ? brut : [brut];
|
|
50
|
+
|
|
51
|
+
groupes.push({
|
|
52
|
+
sousFiches,
|
|
53
|
+
// `valeur` en minuscules, calculée une fois ; null quand ce n'est pas une chaîne, auquel
|
|
54
|
+
// cas la comparaison se fait par identité stricte comme avant.
|
|
55
|
+
valeursMinuscules: sousFiches.map((sousFiche) =>
|
|
56
|
+
typeof sousFiche?.valeur === 'string' ? sousFiche.valeur.toLowerCase() : null
|
|
57
|
+
),
|
|
58
|
+
// Les autres champs sont mis en minuscules à la demande : seuls `description` et `valeur`
|
|
59
|
+
// sont interrogés en pratique, inutile de préparer le reste.
|
|
60
|
+
champsMinuscules: new Map()
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return parCategorie;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param groupe {{sousFiches: any[], champsMinuscules: Map<string, (string|null)[]>}}
|
|
69
|
+
* @param field {string}
|
|
70
|
+
* @returns {(string|null)[]}
|
|
71
|
+
*/
|
|
72
|
+
function champEnMinuscules(groupe, field) {
|
|
73
|
+
let valeurs = groupe.champsMinuscules.get(field);
|
|
74
|
+
if (valeurs === undefined) {
|
|
75
|
+
valeurs = groupe.sousFiches.map((sousFiche) =>
|
|
76
|
+
sousFiche && sousFiche[field] ? sousFiche[field].toString().toLowerCase() : null
|
|
77
|
+
);
|
|
78
|
+
groupe.champsMinuscules.set(field, valeurs);
|
|
79
|
+
}
|
|
80
|
+
return valeurs;
|
|
81
|
+
}
|
|
82
|
+
|
|
1
83
|
/**
|
|
2
84
|
* Retourne si elle existe la fiche technique contenant le texte description et
|
|
3
85
|
* présente dans la catégorie de fiches techniques ayant pour catégorie categoryFicheTechiqueId
|
|
@@ -15,72 +97,70 @@ export default function getFicheTechnique(
|
|
|
15
97
|
classifications = [],
|
|
16
98
|
field = 'description'
|
|
17
99
|
) {
|
|
18
|
-
|
|
19
|
-
|
|
100
|
+
let index = indexParDpe.get(dpe);
|
|
101
|
+
if (index === undefined) {
|
|
102
|
+
index = indexerFichesTechniques(dpe);
|
|
103
|
+
indexParDpe.set(dpe, index);
|
|
104
|
+
}
|
|
20
105
|
|
|
21
|
-
|
|
22
|
-
|
|
106
|
+
const groupes = index.get(categoryFicheTechiqueId);
|
|
107
|
+
if (!groupes) {
|
|
108
|
+
return null;
|
|
23
109
|
}
|
|
24
110
|
|
|
25
111
|
classifications = classifications.filter((classification) => classification);
|
|
112
|
+
const aiguilles = classifications.map((classification) =>
|
|
113
|
+
classification.toString().toLowerCase()
|
|
114
|
+
);
|
|
115
|
+
const descriptionMinuscule = description.toLowerCase();
|
|
26
116
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (classifications.length) {
|
|
36
|
-
/** @type {SousFicheTechniqueItem[]} */
|
|
37
|
-
let sousFichesTechniques =
|
|
38
|
-
ficheTechnique.sous_fiche_technique_collection.sous_fiche_technique;
|
|
39
|
-
|
|
40
|
-
if (!Array.isArray(sousFichesTechniques)) {
|
|
41
|
-
sousFichesTechniques = [sousFichesTechniques];
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const allContained = classifications.every((classification) =>
|
|
45
|
-
sousFichesTechniques.some((ficheTechnique) => {
|
|
46
|
-
const valeur = ficheTechnique?.valeur;
|
|
47
|
-
|
|
48
|
-
if (typeof valeur === 'string') {
|
|
49
|
-
return (
|
|
50
|
-
ficheTechnique.valeur
|
|
51
|
-
.toLowerCase()
|
|
52
|
-
.indexOf(classification.toString().toLowerCase()) !== -1
|
|
53
|
-
);
|
|
54
|
-
}
|
|
117
|
+
for (const groupe of groupes) {
|
|
118
|
+
/**
|
|
119
|
+
* Plusieurs collections de fiches techniques peuvent exister pour la même catégorie (pour 2
|
|
120
|
+
* systèmes ECS par exemple). Le champ classification permet de trouver la collection qui
|
|
121
|
+
* convient en filtrant sur une seconde donnée.
|
|
122
|
+
*/
|
|
123
|
+
if (classifications.length) {
|
|
124
|
+
let toutesPresentes = true;
|
|
55
125
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
126
|
+
for (let i = 0; i < classifications.length; i++) {
|
|
127
|
+
const aiguille = aiguilles[i];
|
|
128
|
+
let presente = false;
|
|
59
129
|
|
|
60
|
-
|
|
61
|
-
|
|
130
|
+
for (let j = 0; j < groupe.sousFiches.length; j++) {
|
|
131
|
+
const valeurMinuscule = groupe.valeursMinuscules[j];
|
|
132
|
+
if (valeurMinuscule !== null) {
|
|
133
|
+
if (valeurMinuscule.indexOf(aiguille) !== -1) {
|
|
134
|
+
presente = true;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
} else if (groupe.sousFiches[j].valeur === classifications[i]) {
|
|
138
|
+
presente = true;
|
|
139
|
+
break;
|
|
62
140
|
}
|
|
63
141
|
}
|
|
64
142
|
|
|
65
|
-
|
|
143
|
+
if (!presente) {
|
|
144
|
+
toutesPresentes = false;
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
66
147
|
}
|
|
67
|
-
return acc;
|
|
68
|
-
}, []);
|
|
69
|
-
|
|
70
|
-
if (!fichesTechniques.length) {
|
|
71
|
-
return null;
|
|
72
|
-
}
|
|
73
148
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
ficheTechnique &&
|
|
77
|
-
ficheTechnique[field] &&
|
|
78
|
-
ficheTechnique[field].toString().toLowerCase().indexOf(description.toLowerCase()) !== -1
|
|
79
|
-
);
|
|
149
|
+
if (!toutesPresentes) continue;
|
|
150
|
+
}
|
|
80
151
|
|
|
81
|
-
|
|
82
|
-
|
|
152
|
+
// L'ancienne implémentation aplatissait toutes les catégories correspondantes avant de
|
|
153
|
+
// filtrer : la première correspondance du tableau aplati est la première correspondance du
|
|
154
|
+
// premier groupe qui en contient une. Parcourir les groupes dans l'ordre donne le même
|
|
155
|
+
// résultat, sans construire le tableau intermédiaire.
|
|
156
|
+
const champs = champEnMinuscules(groupe, field);
|
|
157
|
+
for (let j = 0; j < groupe.sousFiches.length; j++) {
|
|
158
|
+
const champ = champs[j];
|
|
159
|
+
if (champ !== null && champ.indexOf(descriptionMinuscule) !== -1) {
|
|
160
|
+
return groupe.sousFiches[j];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
83
163
|
}
|
|
84
164
|
|
|
85
|
-
return
|
|
165
|
+
return null;
|
|
86
166
|
}
|
package/ficheTechnique.spec.js
CHANGED
|
@@ -188,21 +188,53 @@ describe('FicheTechnique service tests', () => {
|
|
|
188
188
|
|
|
189
189
|
fiche = getFicheTechnique(dpe, '10', 'non existing');
|
|
190
190
|
expect(fiche).toBeNull();
|
|
191
|
+
});
|
|
191
192
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
193
|
+
/**
|
|
194
|
+
* Les fiches techniques sont indexées une fois par objet DPE : chaque cas utilise donc un DPE
|
|
195
|
+
* neuf, muter un DPE déjà interrogé n'aurait aucun effet.
|
|
196
|
+
*/
|
|
197
|
+
describe('fiches techniques absentes ou incomplètes', () => {
|
|
198
|
+
const buildDpe = (ficheTechnique) => ({
|
|
199
|
+
fiche_technique_collection: { fiche_technique: ficheTechnique }
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test.each([
|
|
203
|
+
['sans fiche_technique_collection', {}],
|
|
204
|
+
['avec fiche_technique null', buildDpe(null)],
|
|
205
|
+
['avec une fiche technique null', buildDpe([null])],
|
|
206
|
+
[
|
|
207
|
+
'sans sous_fiche_technique_collection',
|
|
208
|
+
buildDpe([
|
|
209
|
+
{ enum_categorie_fiche_technique_id: '10', sous_fiche_technique_collection: null }
|
|
210
|
+
])
|
|
211
|
+
],
|
|
212
|
+
[
|
|
213
|
+
'avec des sous-fiches null ou sans description',
|
|
214
|
+
buildDpe([
|
|
215
|
+
{
|
|
216
|
+
enum_categorie_fiche_technique_id: '10',
|
|
217
|
+
sous_fiche_technique_collection: {
|
|
218
|
+
sous_fiche_technique: [null, { valeur: 'plusieurs' }]
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
])
|
|
222
|
+
]
|
|
223
|
+
])('retourne null %s', (_, dpeIncomplet) => {
|
|
224
|
+
expect(getFicheTechnique(dpeIncomplet, '10', 'exposées')).toBeNull();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('ignore les fiches techniques null au milieu des autres', () => {
|
|
228
|
+
const fiche = { description: 'Façades exposées: plusieurs', valeur: 'plusieurs' };
|
|
229
|
+
const dpeAvecTrou = buildDpe([
|
|
230
|
+
null,
|
|
231
|
+
{
|
|
232
|
+
enum_categorie_fiche_technique_id: '10',
|
|
233
|
+
sous_fiche_technique_collection: { sous_fiche_technique: [fiche] }
|
|
234
|
+
}
|
|
235
|
+
]);
|
|
204
236
|
|
|
205
|
-
|
|
206
|
-
|
|
237
|
+
expect(getFicheTechnique(dpeAvecTrou, '10', 'exposées')).toBe(fiche);
|
|
238
|
+
});
|
|
207
239
|
});
|
|
208
240
|
});
|
package/formula.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Évaluation des formules tableur présentes dans les tables de valeurs.
|
|
3
|
+
*
|
|
4
|
+
* Remplace `mathjs` — dont seul `evaluate()` était utilisé, pour une grammaire très réduite —
|
|
5
|
+
* par un analyseur dédié. mathjs tirait 113 modules (mathjs, decimal.js, fraction.js,
|
|
6
|
+
* typed-function, seedrandom, @babel/runtime) et ~500 ms d'initialisation, et re-parsait la
|
|
7
|
+
* chaîne de la formule à chaque appel.
|
|
8
|
+
*
|
|
9
|
+
* Grammaire supportée, alignée sur ce que mathjs acceptait pour ces formules :
|
|
10
|
+
* - nombres décimaux, séparateur `.` ou `,` (`0,085` ≡ `0.085`)
|
|
11
|
+
* - pourcentages : `0.6%` vaut 0.006
|
|
12
|
+
* - variables : `Pn`, `logPn`, `E`, `F`
|
|
13
|
+
* - fonction : `log10(x)`
|
|
14
|
+
* - opérateurs : `+` `-` `*` `/` `^` et parenthèses
|
|
15
|
+
* - multiplication implicite : `2 logPn` ≡ `2 * logPn`
|
|
16
|
+
*
|
|
17
|
+
* `^` est associatif à droite et lie plus fort que le moins unaire (`-2^2` vaut -4,
|
|
18
|
+
* `2^-1` vaut 0.5), comme dans mathjs.
|
|
19
|
+
*
|
|
20
|
+
* Chaque formule n'est analysée qu'une fois : le résultat est une fermeture mise en cache.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const VARIABLES = ['Pn', 'logPn', 'E', 'F'];
|
|
24
|
+
const FUNCTIONS = { log10: Math.log10 };
|
|
25
|
+
|
|
26
|
+
/* --------------------------------------------------------------------------------------- */
|
|
27
|
+
/* Analyse lexicale */
|
|
28
|
+
/* --------------------------------------------------------------------------------------- */
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param source {string}
|
|
32
|
+
* @returns {{type: string, value: *}[]}
|
|
33
|
+
*/
|
|
34
|
+
function tokenize(source) {
|
|
35
|
+
const tokens = [];
|
|
36
|
+
let i = 0;
|
|
37
|
+
|
|
38
|
+
while (i < source.length) {
|
|
39
|
+
const c = source[i];
|
|
40
|
+
|
|
41
|
+
if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
|
|
42
|
+
i += 1;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (c >= '0' && c <= '9') {
|
|
47
|
+
let j = i;
|
|
48
|
+
while (j < source.length && source[j] >= '0' && source[j] <= '9') j += 1;
|
|
49
|
+
if (source[j] === '.' || source[j] === ',') {
|
|
50
|
+
j += 1;
|
|
51
|
+
while (j < source.length && source[j] >= '0' && source[j] <= '9') j += 1;
|
|
52
|
+
}
|
|
53
|
+
let value = Number(source.slice(i, j).replace(',', '.'));
|
|
54
|
+
i = j;
|
|
55
|
+
// Un `%` suffixant un nombre en fait une fraction : `0.6%` → 0.006.
|
|
56
|
+
while (i < source.length && (source[i] === ' ' || source[i] === '\t')) i += 1;
|
|
57
|
+
if (source[i] === '%') {
|
|
58
|
+
value /= 100;
|
|
59
|
+
i += 1;
|
|
60
|
+
}
|
|
61
|
+
tokens.push({ type: 'number', value });
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (/[A-Za-z_]/.test(c)) {
|
|
66
|
+
let j = i;
|
|
67
|
+
while (j < source.length && /[A-Za-z0-9_]/.test(source[j])) j += 1;
|
|
68
|
+
tokens.push({ type: 'name', value: source.slice(i, j) });
|
|
69
|
+
i = j;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if ('+-*/^()'.includes(c)) {
|
|
74
|
+
tokens.push({ type: c, value: c });
|
|
75
|
+
i += 1;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
throw new SyntaxError(`caractère inattendu \`${c}\` dans la formule \`${source}\``);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return tokens;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* --------------------------------------------------------------------------------------- */
|
|
86
|
+
/* Analyse syntaxique — descente récursive, produit une fermeture (scope) => number */
|
|
87
|
+
/* --------------------------------------------------------------------------------------- */
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param source {string}
|
|
91
|
+
* @returns {(scope: {Pn: number, logPn: number, E: number, F: number}) => number}
|
|
92
|
+
*/
|
|
93
|
+
function parse(source) {
|
|
94
|
+
const tokens = tokenize(source);
|
|
95
|
+
let pos = 0;
|
|
96
|
+
|
|
97
|
+
const peek = () => tokens[pos];
|
|
98
|
+
const eat = (type) => {
|
|
99
|
+
if (!tokens[pos] || tokens[pos].type !== type) {
|
|
100
|
+
throw new SyntaxError(`\`${type}\` attendu dans la formule \`${source}\``);
|
|
101
|
+
}
|
|
102
|
+
return tokens[pos++];
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** Un jeton qui peut démarrer un facteur — sert à détecter la multiplication implicite. */
|
|
106
|
+
const startsFactor = () => {
|
|
107
|
+
const t = peek();
|
|
108
|
+
return !!t && (t.type === 'number' || t.type === 'name' || t.type === '(');
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
function parseExpression() {
|
|
112
|
+
let left = parseTerm();
|
|
113
|
+
while (peek() && (peek().type === '+' || peek().type === '-')) {
|
|
114
|
+
const op = tokens[pos++].type;
|
|
115
|
+
const right = parseTerm();
|
|
116
|
+
const l = left;
|
|
117
|
+
left = op === '+' ? (s) => l(s) + right(s) : (s) => l(s) - right(s);
|
|
118
|
+
}
|
|
119
|
+
return left;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseTerm() {
|
|
123
|
+
let left = parseUnary();
|
|
124
|
+
for (;;) {
|
|
125
|
+
const t = peek();
|
|
126
|
+
if (t && (t.type === '*' || t.type === '/')) {
|
|
127
|
+
const op = tokens[pos++].type;
|
|
128
|
+
const right = parseUnary();
|
|
129
|
+
const l = left;
|
|
130
|
+
left = op === '*' ? (s) => l(s) * right(s) : (s) => l(s) / right(s);
|
|
131
|
+
} else if (startsFactor()) {
|
|
132
|
+
// multiplication implicite : `2 logPn`
|
|
133
|
+
const right = parseUnary();
|
|
134
|
+
const l = left;
|
|
135
|
+
left = (s) => l(s) * right(s);
|
|
136
|
+
} else {
|
|
137
|
+
return left;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function parseUnary() {
|
|
143
|
+
const t = peek();
|
|
144
|
+
if (t && (t.type === '+' || t.type === '-')) {
|
|
145
|
+
pos += 1;
|
|
146
|
+
const operand = parseUnary();
|
|
147
|
+
return t.type === '-' ? (s) => -operand(s) : operand;
|
|
148
|
+
}
|
|
149
|
+
return parsePower();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function parsePower() {
|
|
153
|
+
const base = parsePrimary();
|
|
154
|
+
if (peek() && peek().type === '^') {
|
|
155
|
+
pos += 1;
|
|
156
|
+
// associatif à droite, et l'exposant peut être signé : `(Pn)^-0.4`
|
|
157
|
+
const exponent = parseUnary();
|
|
158
|
+
return (s) => base(s) ** exponent(s);
|
|
159
|
+
}
|
|
160
|
+
return base;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parsePrimary() {
|
|
164
|
+
const t = peek();
|
|
165
|
+
if (!t) throw new SyntaxError(`formule incomplète : \`${source}\``);
|
|
166
|
+
|
|
167
|
+
if (t.type === 'number') {
|
|
168
|
+
pos += 1;
|
|
169
|
+
const { value } = t;
|
|
170
|
+
return () => value;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (t.type === '(') {
|
|
174
|
+
pos += 1;
|
|
175
|
+
const inner = parseExpression();
|
|
176
|
+
eat(')');
|
|
177
|
+
return inner;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (t.type === 'name') {
|
|
181
|
+
pos += 1;
|
|
182
|
+
const name = t.value;
|
|
183
|
+
|
|
184
|
+
if (peek() && peek().type === '(') {
|
|
185
|
+
const fn = FUNCTIONS[name];
|
|
186
|
+
if (!fn) throw new SyntaxError(`fonction inconnue \`${name}\` dans \`${source}\``);
|
|
187
|
+
pos += 1;
|
|
188
|
+
const arg = parseExpression();
|
|
189
|
+
eat(')');
|
|
190
|
+
return (s) => fn(arg(s));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!VARIABLES.includes(name)) {
|
|
194
|
+
throw new SyntaxError(`variable inconnue \`${name}\` dans \`${source}\``);
|
|
195
|
+
}
|
|
196
|
+
return (s) => s[name];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
throw new SyntaxError(`jeton inattendu \`${t.value}\` dans \`${source}\``);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const compiled = parseExpression();
|
|
203
|
+
if (pos !== tokens.length) {
|
|
204
|
+
throw new SyntaxError(`fin de formule inattendue dans \`${source}\``);
|
|
205
|
+
}
|
|
206
|
+
return compiled;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/* --------------------------------------------------------------------------------------- */
|
|
210
|
+
/* API */
|
|
211
|
+
/* --------------------------------------------------------------------------------------- */
|
|
212
|
+
|
|
213
|
+
const compiledFormulas = new Map();
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Compile une formule (ou la récupère depuis le cache).
|
|
217
|
+
*
|
|
218
|
+
* @param formula {string}
|
|
219
|
+
* @returns {(scope: {Pn: number, logPn: number, E: number, F: number}) => number}
|
|
220
|
+
*/
|
|
221
|
+
export function compileFormula(formula) {
|
|
222
|
+
let compiled = compiledFormulas.get(formula);
|
|
223
|
+
if (compiled === undefined) {
|
|
224
|
+
compiled = parse(formula);
|
|
225
|
+
compiledFormulas.set(formula, compiled);
|
|
226
|
+
}
|
|
227
|
+
return compiled;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Évalue une formule tableur, ou retourne la valeur telle quelle si c'en est déjà une.
|
|
232
|
+
*
|
|
233
|
+
* @param formulaOrValue {string|number} Formule tableur ou valeur numérique.
|
|
234
|
+
* @param pn {number} Puissance nominale en W (exposée à la formule sous `Pn`, en kW).
|
|
235
|
+
* @param E {number} [Coefficient forfaitaire E]
|
|
236
|
+
* @param F {number} [Coefficient forfaitaire F]
|
|
237
|
+
* @returns {number}
|
|
238
|
+
*/
|
|
239
|
+
export function evaluateFormula(formulaOrValue, pn, E, F) {
|
|
240
|
+
const formula =
|
|
241
|
+
typeof formulaOrValue === 'string'
|
|
242
|
+
? formulaOrValue.replace(/(\d),(\d)/g, '$1.$2')
|
|
243
|
+
: formulaOrValue;
|
|
244
|
+
|
|
245
|
+
if (!isNaN(formula)) {
|
|
246
|
+
return Number(formula);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const Pn = pn / 1000;
|
|
250
|
+
return compileFormula(formula)({ Pn, logPn: Math.log10(Pn), E, F });
|
|
251
|
+
}
|
package/formula.spec.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { compileFormula, evaluateFormula } from './formula.js';
|
|
3
|
+
|
|
4
|
+
describe('formula', () => {
|
|
5
|
+
describe('evaluateFormula', () => {
|
|
6
|
+
test.each([
|
|
7
|
+
[42, 42],
|
|
8
|
+
['42', 42],
|
|
9
|
+
['0.5', 0.5],
|
|
10
|
+
['12,5', 12.5]
|
|
11
|
+
])('retourne la valeur numérique telle quelle : %s → %s', (input, expected) => {
|
|
12
|
+
expect(evaluateFormula(input, 24000)).toBe(expected);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('expose Pn en kW à partir de pn en W', () => {
|
|
16
|
+
expect(evaluateFormula('Pn', 24000)).toBe(24);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('expose logPn = log10(Pn)', () => {
|
|
20
|
+
expect(evaluateFormula('logPn', 100000)).toBeCloseTo(2, 10);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('expose les coefficients E et F', () => {
|
|
24
|
+
expect(evaluateFormula('E + F * logPn', 100000, 3, 1.5)).toBeCloseTo(6, 10);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('accepte la virgule comme séparateur décimal', () => {
|
|
28
|
+
expect(evaluateFormula('0,085 * Pn', 10000)).toBeCloseTo(0.85, 10);
|
|
29
|
+
expect(evaluateFormula('84,5 + 2 logPn', 100000)).toBeCloseTo(88.5, 10);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('évalue des formules représentatives des tables de valeurs', () => {
|
|
33
|
+
// rendement à pleine charge d'une chaudière : 84 + 2 logPn
|
|
34
|
+
expect(evaluateFormula('84 + 2 logPn', 100000)).toBeCloseTo(88, 10);
|
|
35
|
+
// pertes à l'arrêt : exposant négatif signé
|
|
36
|
+
expect(evaluateFormula('2.5 * (Pn)^-0.4', 32000)).toBeCloseTo(2.5 * 32 ** -0.4, 10);
|
|
37
|
+
// pourcentage
|
|
38
|
+
expect(evaluateFormula('0.6% * Pn', 50000)).toBeCloseTo(0.3, 10);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('compileFormula', () => {
|
|
43
|
+
const evaluate = (formula, scope = {}) => compileFormula(formula)(scope);
|
|
44
|
+
|
|
45
|
+
test.each([
|
|
46
|
+
['2 + 3 * 4', 14],
|
|
47
|
+
['(2 + 3) * 4', 20],
|
|
48
|
+
['10 / 4', 2.5],
|
|
49
|
+
['8 - 3 - 2', 3],
|
|
50
|
+
['16 / 4 / 2', 2],
|
|
51
|
+
['2^3^2', 512],
|
|
52
|
+
['-2^2', -4],
|
|
53
|
+
['2^-1', 0.5],
|
|
54
|
+
['-(3 - 5)', 2],
|
|
55
|
+
['+3', 3],
|
|
56
|
+
['--3', 3],
|
|
57
|
+
['log10(1000)', 3],
|
|
58
|
+
['log10(10 * 10)', 2],
|
|
59
|
+
['2(3 + 1)', 8],
|
|
60
|
+
['2 log10(100)', 4],
|
|
61
|
+
['0.6%', 0.006],
|
|
62
|
+
['50 %', 0.5],
|
|
63
|
+
['0,5 + 1', 1.5],
|
|
64
|
+
[' 1 +\t2\n', 3]
|
|
65
|
+
])('%s → %s', (formula, expected) => {
|
|
66
|
+
expect(evaluate(formula)).toBeCloseTo(expected, 10);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('lit les variables dans le scope', () => {
|
|
70
|
+
const scope = { Pn: 20, logPn: Math.log10(20), E: 2, F: 3 };
|
|
71
|
+
expect(evaluate('Pn', scope)).toBe(20);
|
|
72
|
+
expect(evaluate('E F', scope)).toBe(6);
|
|
73
|
+
expect(evaluate('E + F Pn', scope)).toBe(62);
|
|
74
|
+
expect(evaluate('logPn', scope)).toBeCloseTo(Math.log10(20), 10);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('met en cache la formule compilée', () => {
|
|
78
|
+
const first = compileFormula('Pn * 2 + 1');
|
|
79
|
+
expect(compileFormula('Pn * 2 + 1')).toBe(first);
|
|
80
|
+
expect(first({ Pn: 3 })).toBe(7);
|
|
81
|
+
expect(first({ Pn: 5 })).toBe(11);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test.each([
|
|
85
|
+
['2 $ 3', /caractère inattendu `\$`/],
|
|
86
|
+
['foo + 1', /variable inconnue `foo`/],
|
|
87
|
+
['sqrt(4)', /fonction inconnue `sqrt`/],
|
|
88
|
+
['(2 + 3', /`\)` attendu/],
|
|
89
|
+
['log10(2', /`\)` attendu/],
|
|
90
|
+
['2 +', /formule incomplète/],
|
|
91
|
+
['', /formule incomplète/],
|
|
92
|
+
['2 )', /fin de formule inattendue/],
|
|
93
|
+
['* 2', /jeton inattendu `\*`/]
|
|
94
|
+
])('rejette la formule invalide `%s`', (formula, message) => {
|
|
95
|
+
expect(() => compileFormula(formula)).toThrow(SyntaxError);
|
|
96
|
+
expect(() => compileFormula(formula)).toThrow(message);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|