LOT P1.0 : paquet @transformer/moteurs v0.1.0

Extraction du cœur autonomie de calculs.trans-former.fr (12 modules, sans
singleton, couplages sans mutation, console derrière debug), découpage en trois
moteurs d'axe + intégrateur (mode global = calculs, mode par_axe = plateforme),
moteurs commun.contexte (Corse 2A/2B, DOM 97x corrigés) et commun.geometrie,
table des coefficients harmonisés (docs/CONSTANTES.md pour Jules, R-7),
validation structurelle des données (schemas/), tests hors ligne : 28 assertions
d'origine + épingle générée depuis le code d'astro-pro + écarts d'harmonisation
figés + contrat des moteurs. dist/ committé.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019UBHYPdeQYy2m1GVJp6b12
This commit is contained in:
Jules
2026-09-28 17:06:49 +02:00
co-authored by Claude Fable 5.1
commit 69809df5d1
123 changed files with 13225 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
node_modules/
+86
View File
@@ -0,0 +1,86 @@
# AGENTS.md — @transformer/moteurs
## Ce que c'est
Moteurs de calcul purs pour la rénovation et l'autonomie d'une maison, partagés par
`calculs.trans-former.fr` (repo `astro-pro`) et `plateforme.trans-former.fr` (repo `reinnover`).
Code et coefficients sourcés ; **JAMAIS de prix**. Dépôt Gitea public `jules/moteurs`, nom npm
`@transformer/moteurs`, consommé par étiquette : `git+https://git.trans-former.fr/jules/moteurs.git#vX.Y.Z`.
Version `v0.1.0` (LOT P1.0, 28/09/2026) : `commun` (contrat, coefficients, contexte, géométrie,
argent, validation) et `autonomie` (noyau V2 extrait de calculs, trois moteurs d'axe, intégrateur,
taux de couverture, prix des options). `isolation`, `autoconstruction`, `estimatif` arrivent en `v0.2.0` (P1.3).
## Règles opposables (un agent qui les viole a tort, quel que soit le prompt)
1. `calculer(entrees, donnees)` ne lit ni réseau, ni disque, ni horloge, ni DOM, ni globale, ni aléa.
Mêmes entrées + mêmes données = même résultat. `test/paquet.test.mjs` grep `src/` et refuse
`fetch`, `require`, `node:*`, `process`, `Date`, `Math.random`, `console`, `localStorage`, `import *.json`.
2. Les données (prix, barèmes, options, tables) entrent en paramètre, validées par `valider(table, json)`
par l'appelant. Aucune constante de prix dans `src/`. Une mise à jour de prix ne produit jamais une
version du paquet. `algo-config.json` est une donnée : livrée dans `fixtures/`, injectée, jamais importée.
3. Chaque coefficient physique existe **une fois**, dans `src/commun/coefficients.ts`, avec `valeur`,
`source`, `date` (et `note` pour le veto). Tout coefficient lu apparaît dans `resultat.sources` (les
moteurs d'axe enregistrent les lectures de la table par Proxy) ; toute valeur par défaut appliquée
dans `resultat.avertissements`. Les heuristiques de conception (poids, modulateurs, matrices,
tolérances) restent dans `algo-config.json`, marquées non sourcées. La table lisible par Jules est
`docs/CONSTANTES.md` ; sans veto sous une semaine, la table du code est la référence.
4. Aucune dépendance de production (`typescript` en dev seulement). `dist/` (JS ESM + `.d.ts`) est
**committé** à chaque étiquette ; `schemas/*.schema.json` est réécrit par `npm run build` depuis
`src/commun/schemas.ts` (source de vérité).
5. Une formule change → nouvelle étiquette (patch : correction ; mineur : nouvelle sortie ; majeur :
entrée modifiée) et montée de version chez les deux consommateurs le même jour. **Jamais de branche
épinglée** chez un consommateur. `src/version.ts` = `package.json` (test).
6. Tests hors ligne (`node --test test/`, sur `dist/`), fixtures figées et datées dans `fixtures/`.
Les 28 tests d'origine du cœur autonomie (`test/noyau-origine.test.mjs`) et le test de découpage par
axe (`test/decoupage.test.mjs`, épingle générée depuis le code d'astro-pro) **ne se désactivent
jamais**. Refiger une épingle (`scripts/pin-origine.mjs`, `npm run ecarts -- --ecrire <date>`) est un
acte volontaire, écrit dans le commit, après relecture de `docs/CONSTANTES.md`.
7. Français avec accents dans les libellés, la doc et les messages ; identifiants en français sans
accents (`sous_total_eur_ht`, `taux_couverture`) ; codes d'axe internes `eau` / `energie` / `alim`
(les postes publiés disent `alimentation`).
8. Un module du noyau (`src/autonomie/noyau/`) se modifie pour corriger un bug **avec un test qui le
montre** et une ligne dans le `CHANGELOG` du commit ; jamais « en passant ». Les bugs latents connus
sont listés dans le RECAP du lot P1.0 (scoreurs sans parse de `parametre_dim`, `_conflit_avec` perdu,
Intl dans les messages de `reajuste`).
## Carte du dépôt
```
src/
├── version.ts étiquette portée par chaque résultat (= package.json)
├── commun/
│ ├── contrat.ts Source, Resultat, Moteur, Trace (collecteur sources/avertissements)
│ ├── coefficients.ts LA table (valeur, source, date, note) + verifieTableCoefficients
│ ├── contexte.ts commun.contexte : CP → département (2A/2B, 97x) → zone RT, biogéo, pluvio, DJU
│ ├── geometrie.ts commun.geometrie : emprise, niveaux, hauteur, pente, mitoyenneté → parois, SHAB, volume
│ ├── argent.ts fourchettes, arrondis, mensualité / annuité / coût du crédit
│ ├── schemas.ts forme de chaque JSON de données (→ schemas/*.schema.json au build)
│ └── valider.ts valider(table, json) : type, required, properties, items, enum, minItems
└── autonomie/
├── noyau/ V2 de calculs, sans singleton : algo (selectionner + finaliseSelection),
│ pondere-axes, alloue-budget, selectionne-poste, adequation, scoreurs,
│ dimension, couplages (copie, ne mute plus), reajuste, axes-actifs, types
├── axe.ts fabrique des moteurs d'axe (selectionnePoste + finaliseSelection à un axe + taux)
├── eau.ts · energie.ts · alimentation.ts
├── integrateur.ts contexte + poids + budgets ; mode 'global' (= calculs) ou 'par_axe' (= plateforme)
├── taux.ts couverture eau / énergie (autoconsommation appliquée) / alimentation
└── prix.ts prixOption, prixPreset, presetLePlusProche (données d'options, pas de montant)
schemas/ fixtures/ test/ scripts/ docs/CONSTANTES.md dist/
```
## Comment publier
```
npm test # pretest = build ; 4 fichiers de tests + les 28 d'origine, tous hors ligne
git add -A && git commit
git tag vX.Y.Z && git push && git push --tags
```
puis monter `#vX.Y.Z` dans `reinnover/package.json` (et `astro-pro` à partir de P1.5) **le même jour**,
`npm install` chez le consommateur, commit du lockfile.
## Ce qu'un agent ne fait pas ici
Lire NocoDB · appeler une API · écrire un prix ou un barème · importer depuis `astro-pro` ou
`reinnover` · importer un JSON dans `src/` · désactiver ou affaiblir un test · épingler une branche ·
ajouter une dépendance de production · publier sans `dist/` reconstruit et committé.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jules Rouaud (Transformations résilientes)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+90
View File
@@ -0,0 +1,90 @@
# @transformer/moteurs
Moteurs de calcul **purs** pour la rénovation et l'autonomie d'une maison, partagés par
[calculs.trans-former.fr](https://calculs.trans-former.fr) et [plateforme.trans-former.fr](https://plateforme.trans-former.fr).
Un moteur reçoit des entrées et des données, rend des sorties **avec ses sources et ses avertissements**,
et ne lit rien d'autre : ni réseau, ni disque, ni horloge. **Jamais de prix dans le paquet** : les prix,
barèmes et tables sont des données publiées ailleurs et injectées.
Licence MIT (le contenu de la plateforme reste sous sa propre licence).
## Ce que le paquet contient (v0.1.0)
| Moteur (`id`) | Entrées | Données injectées | Sorties |
|---|---|---|---|
| `commun.contexte` | code postal | `france_climat` | département (Corse 2A/2B, DOM 97x), zone RT, zone biogéographique, pluviométrie, DJU |
| `commun.geometrie` | emprise, niveaux, hauteur, pente, mitoyenneté, SHAB connue (facultatif) | — | murs extérieurs et opaques, vitrage, toiture, plancher bas, SHAB estimée, volume |
| `autonomie.eau` · `autonomie.energie` · `autonomie.alimentation` | autodiag, budget d'axe, contexte | `postes`, `options`, `algo_config` | postes retenus (typologie, dimension, coût), alternatives, statut, journal des couplages, taux de couverture de l'axe |
| `autonomie.integrateur` | autodiag, budget total, mode `global` (= calculs) ou `par_axe` (= plateforme), axes actifs | idem + `france_climat` | contexte, poids d'axes, budget par axe, sélection par axe, total, statut, taux par axe |
Plus, dans `commun` : la table des coefficients (`COEFFICIENTS`), `valider(table, json)`, les helpers
d'argent (fourchettes, mensualité, annuité) ; dans `autonomie` : le noyau V2 complet (`autonomie.noyau.selectionner`),
les taux de couverture et les prix d'options (`prixOption`, `prixPreset`).
## Consommer
```json
"dependencies": {
"@transformer/moteurs": "git+https://git.trans-former.fr/jules/moteurs.git#v0.1.0"
}
```
Toujours une **étiquette** (`#vX.Y.Z`), jamais une branche : deux sites qui ne calculent pas pareil sans
que personne ne le sache est exactement le risque à éviter. Le dépôt est public, `dist/` est committé :
l'installation ne demande aucune étape de build, seulement `git` sur la machine qui fait `npm ci`
(piège connu : `node:22-alpine` n'a pas `git` → `apk add --no-cache git`).
```ts
import { contexte, eau, integrateur, valider } from '@transformer/moteurs'
// 1. les données viennent de l'extérieur (URL publiée, instantané daté…) et se valident à la frontière
for (const [table, json] of Object.entries(donnees)) {
const v = valider(table, json)
if (!v.ok) throw new Error(v.erreurs.join('\n'))
}
// 2. contexte depuis le code postal
const ctx = contexte.calculer({ code_postal: '31170' }, { france_climat: donnees.france_climat }).sorties
// 3. un moteur d'axe sur son budget
const r = eau.calculer(
{ autodiag: { nb_personnes: 4, surface_toit_m2: 80, surface_parcelle_m2: 800, orientation_toit: 'sud-est', temps_dispo: 2 }, budget_axe: 14000, contexte: ctx },
{ postes: donnees.postes, options: donnees.options, algo_config: donnees.algo_config },
)
r.sorties.postes // typologies retenues, dimensions, coûts
r.sorties.taux_couverture // % du besoin substituable couvert
r.sources // chaque coefficient et chaque prix utilisés, avec source et date
r.avertissements // chaque défaut appliqué, chaque donnée manquante
r.version // '0.1.0'
```
Le mode `global` de l'intégrateur reproduit exactement le simulateur de calculs (pipeline complet,
couplages et réajustement entre axes) ; le mode `par_axe` calcule chaque branche du menu sur son
budget et additionne.
Sous-chemins : `@transformer/moteurs/commun`, `/autonomie`, `/autonomie/eau`, `/autonomie/energie`,
`/autonomie/alimentation`, `/autonomie/integrateur`, `/autonomie/noyau`, `/schemas/<table>.schema.json`,
`/fixtures/<fichier>` (`algo-config.json`, `profils-reference.json`).
## Les coefficients
Chaque grandeur physique existe une fois, dans `src/commun/coefficients.ts`, avec sa source et sa date ;
la version lisible est [`docs/CONSTANTES.md`](docs/CONSTANTES.md) (ancienne valeur → retenue, source,
ce que ça change). Les heuristiques de conception du simulateur (poids d'axes, matrices, tolérances) sont
des données : `fixtures/algo-config.json`, injectées.
## Développer
```bash
npm install # typescript seulement
npm test # build (tsc → dist/, schémas) puis node --test test/ — hors ligne
npm run ecarts # table des écarts origine → harmonisée sur les trois profils de référence
```
Règles du dépôt et carte détaillée : [`AGENTS.md`](AGENTS.md). Jeux de données figés : [`fixtures/README.md`](fixtures/README.md).
## Publier une étiquette
`npm test` vert → `git commit` (avec `dist/` et `schemas/` reconstruits) → `git tag vX.Y.Z` →
`git push && git push --tags` → monter la version chez les consommateurs le même jour. Patch = correction
de formule ; mineur = nouvelle sortie ou nouveau moteur ; majeur = entrée modifiée.
+3
View File
@@ -0,0 +1,3 @@
export type { EntreesAxe, DonneesAutonomie, SortiesAxe } from './axe.js';
export declare const alimentation: import("../index.js").Moteur<import("./axe.js").EntreesAxe, import("./axe.js").DonneesAutonomie, import("./axe.js").SortiesAxe>;
export default alimentation;
+6
View File
@@ -0,0 +1,6 @@
// alimentation.ts — moteur `autonomie.alimentation` : étude foncière, strates
// haute, moyenne (haies) et basse, pour un budget d'axe donné.
// Code d'axe interne : 'alim' (postes.json dit « alimentation »).
import { moteurAxe } from './axe.js';
export const alimentation = moteurAxe('alim', 'autonomie.alimentation');
export default alimentation;
+45
View File
@@ -0,0 +1,45 @@
import type { Moteur, Resultat } from '../commun/contrat.js';
import { Trace } from '../commun/contrat.js';
import { type TableCoefficients } from '../commun/coefficients.js';
import type { Contexte } from '../commun/contexte.js';
import type { AlgoConfig, Autodiag, Axe, Option, Poste, PosteResultat, Statut } from './noyau/types.js';
import { type DetailAlim, type DetailEau, type DetailEnergie } from './taux.js';
export interface EntreesAxe {
autodiag: Autodiag;
/** Budget alloué à cet axe (EUR HT). */
budget_axe: number;
/** Contexte climatique (moteur `commun.contexte`). */
contexte: Contexte;
}
export interface DonneesAutonomie {
postes: Poste[];
options: Option[];
algo_config: AlgoConfig;
/** Table de coefficients ; par défaut la table harmonisée du paquet. */
coefficients?: TableCoefficients;
}
export interface SortiesAxe {
axe: Axe;
postes: PosteResultat[];
/** Codes des typologies retenues, dans l'ordre des postes. */
typologies: string[];
sous_total_eur_ht: number;
budget_alloue: number;
ecart_budget_pct: number;
status: Statut;
message_status?: string;
/** Journal des couplages (FORCE / MANQUANT / WARN / CONFLIT). */
couplages: string[];
/** Taux de couverture de l'axe en %, null si non calculable. */
taux_couverture: number | null;
taux_detail: DetailEau | DetailEnergie | DetailAlim | null;
}
/** Enregistre les clés de coefficients lues pendant un calcul (Proxy en lecture seule). */
export declare function enregistreLectures(table: TableCoefficients): {
table: TableCoefficients;
lues: Set<keyof TableCoefficients>;
};
/** Ajoute aux sources les coefficients lus et les prix des typologies retenues. */
export declare function traceSelection(trace: Trace, coefficients: TableCoefficients, lues: Set<keyof TableCoefficients>, postes: PosteResultat[], options: Option[], couplages: string[]): void;
export declare function calculeAxe(axe: Axe, entrees: EntreesAxe, donnees: DonneesAutonomie): Resultat<SortiesAxe>;
export declare function moteurAxe(axe: Axe, id: string): Moteur<EntreesAxe, DonneesAutonomie, SortiesAxe>;
+97
View File
@@ -0,0 +1,97 @@
// axe.ts — fabrique des moteurs d'axe `autonomie.eau`, `autonomie.energie`,
// `autonomie.alimentation` (LOT P1.0, découpage par axe).
//
// Un moteur d'axe = selectionnePoste(axe) + couplages de l'axe + réajustement
// au budget de l'axe + taux de couverture de l'axe. Il réutilise la queue du
// pipeline d'origine (`finaliseSelection`) sur une sélection où les deux
// autres axes sont vides et désactivés (`configPourAxes`) : par construction,
// il rend exactement ce que `selectionner()` rend en mode « un seul axe
// actif » avec ce budget — c'est le test de non-régression du découpage.
import { Trace } from '../commun/contrat.js';
import { COEFFICIENTS } from '../commun/coefficients.js';
import { VERSION } from '../version.js';
import { finaliseSelection } from './noyau/algo.js';
import { configPourAxes } from './noyau/axes-actifs.js';
import { selectionnePoste } from './noyau/selectionne-poste.js';
import { AXES } from './noyau/types.js';
import { aCaptage, batterieSelectionnee, kwcSelectionnes, mlHaiesSelectionnes, tauxAlim, tauxEau, tauxEnergie } from './taux.js';
/** Enregistre les clés de coefficients lues pendant un calcul (Proxy en lecture seule). */
export function enregistreLectures(table) {
const lues = new Set();
const proxy = new Proxy(table, {
get(cible, prop, recepteur) {
if (typeof prop === 'string' && prop in cible)
lues.add(prop);
return Reflect.get(cible, prop, recepteur);
},
});
return { table: proxy, lues };
}
function axeVide() {
return { postes: [], sous_total_eur_ht: 0, budget_alloue: 0, poids: 0 };
}
/** Ajoute aux sources les coefficients lus et les prix des typologies retenues. */
export function traceSelection(trace, coefficients, lues, postes, options, couplages) {
for (const cle of lues) {
const c = coefficients[cle];
const v = c.valeur;
trace.source(cle, typeof v === 'number' || typeof v === 'string' ? v : JSON.stringify(v), c.source, c.date);
}
for (const p of postes) {
const o = options.find((x) => x.code === p.typologie_choisie);
trace.source(`option.${p.typologie_choisie}`, p.cout_eur_ht, String(o?.source ?? 'options (calculs.trans-former.fr)'), String(o?.source_date ?? ''));
}
for (const ligne of couplages) {
if (ligne.startsWith('MANQUANT') || ligne.startsWith('WARN'))
trace.avertit(`couplage : ${ligne}`);
}
}
export function calculeAxe(axe, entrees, donnees) {
const trace = new Trace();
const { table: coefficients, lues } = enregistreLectures(donnees.coefficients ?? COEFFICIENTS);
const actifs = Object.fromEntries(AXES.map((a) => [a, a === axe]));
const reglages = { config: configPourAxes(donnees.algo_config, actifs), coefficients };
const inputs = entrees.autodiag;
const budget = Math.max(0, entrees.budget_axe || 0);
const selection = {};
for (const a of AXES)
selection[a] = a === axe ? selectionnePoste(budget, inputs, entrees.contexte, donnees.options, donnees.postes, a, reglages) : axeVide();
selection[axe].poids = 100;
const fin = finaliseSelection(selection, budget, donnees.options, donnees.postes, inputs, entrees.contexte, reglages);
const postes = fin.axes[axe]?.postes ?? [];
const sous_total = fin.axes[axe]?.sous_total_eur_ht ?? 0;
// Un poste forcé dans un autre axe par un couplage inter-axes serait perdu ici :
// aucun dans les données (inventaire §4), on le signale si ça arrive.
for (const a of AXES) {
if (a !== axe && (fin.axes[a]?.postes.length ?? 0) > 0)
trace.avertit(`couplage inter-axes : ${fin.axes[a].postes.length} poste(s) forcé(s) dans l'axe ${a}, hors du périmètre du moteur ${axe}`);
}
let taux;
if (axe === 'eau') {
taux = tauxEau({ surface_toit_m2: inputs.surface_toit_m2, nb_personnes: inputs.nb_personnes, pluviometrie_mm: entrees.contexte.pluviometrie_mm, a_captage: aCaptage(postes) }, coefficients, trace);
}
else if (axe === 'energie') {
taux = tauxEnergie({ kwc: kwcSelectionnes(postes), batterie_kwh: batterieSelectionnee(postes), orientation_toit: inputs.orientation_toit, nb_personnes: inputs.nb_personnes, contexte: entrees.contexte }, coefficients, trace);
}
else {
taux = tauxAlim({ ml: mlHaiesSelectionnes(postes), nb_personnes: inputs.nb_personnes }, coefficients, trace);
}
traceSelection(trace, donnees.coefficients ?? COEFFICIENTS, lues, postes, donnees.options, fin.couplages);
const sorties = {
axe,
postes,
typologies: postes.map((p) => p.typologie_choisie),
sous_total_eur_ht: sous_total,
budget_alloue: budget,
ecart_budget_pct: budget > 0 ? +(((sous_total - budget) / budget) * 100).toFixed(2) : 0,
status: fin.status,
message_status: fin.message_status,
couplages: fin.couplages,
taux_couverture: taux.taux,
taux_detail: taux.detail,
};
return trace.resultat(sorties, VERSION);
}
export function moteurAxe(axe, id) {
return { id, calculer: (entrees, donnees) => calculeAxe(axe, entrees, donnees) };
}
+3
View File
@@ -0,0 +1,3 @@
export type { EntreesAxe, DonneesAutonomie, SortiesAxe } from './axe.js';
export declare const eau: import("../index.js").Moteur<import("./axe.js").EntreesAxe, import("./axe.js").DonneesAutonomie, import("./axe.js").SortiesAxe>;
export default eau;
+5
View File
@@ -0,0 +1,5 @@
// eau.ts — moteur `autonomie.eau` : captage, filtration, stockage,
// distribution, eaux grises et épuration, pour un budget d'axe donné.
import { moteurAxe } from './axe.js';
export const eau = moteurAxe('eau', 'autonomie.eau');
export default eau;
+3
View File
@@ -0,0 +1,3 @@
export type { EntreesAxe, DonneesAutonomie, SortiesAxe } from './axe.js';
export declare const energie: import("../index.js").Moteur<import("./axe.js").EntreesAxe, import("./axe.js").DonneesAutonomie, import("./axe.js").SortiesAxe>;
export default energie;
+5
View File
@@ -0,0 +1,5 @@
// energie.ts — moteur `autonomie.energie` : photovoltaïque, solaire thermique,
// chauffage bois, émetteurs, pilotage, pour un budget d'axe donné.
import { moteurAxe } from './axe.js';
export const energie = moteurAxe('energie', 'autonomie.energie');
export default energie;
+11
View File
@@ -0,0 +1,11 @@
export { eau } from './eau.js';
export { energie } from './energie.js';
export { alimentation } from './alimentation.js';
export { integrateur, calculeIntegrateur, agregeStatuts } from './integrateur.js';
export type { EntreesIntegrateur, DonneesIntegrateur, SortiesIntegrateur } from './integrateur.js';
export { calculeAxe, moteurAxe } from './axe.js';
export type { EntreesAxe, DonneesAutonomie, SortiesAxe } from './axe.js';
export * from './taux.js';
export * from './prix.js';
export * as noyau from './noyau/index.js';
export type { Axe, Autodiag, InputsSimulation, Poste, Option, AlgoConfig, PosteResultat, AxeResultat, Statut, PoidsAxes } from './noyau/types.js';
+9
View File
@@ -0,0 +1,9 @@
// @transformer/moteurs/autonomie — trois moteurs d'axe, un intégrateur, le noyau.
export { eau } from './eau.js';
export { energie } from './energie.js';
export { alimentation } from './alimentation.js';
export { integrateur, calculeIntegrateur, agregeStatuts } from './integrateur.js';
export { calculeAxe, moteurAxe } from './axe.js';
export * from './taux.js';
export * from './prix.js';
export * as noyau from './noyau/index.js';
+37
View File
@@ -0,0 +1,37 @@
import type { Moteur, Resultat } from '../commun/contrat.js';
import type { Contexte, LigneClimat } from '../commun/contexte.js';
import { type DonneesAutonomie, type SortiesAxe } from './axe.js';
import { type AxesActifs } from './noyau/axes-actifs.js';
import type { Autodiag, Axe, PoidsAxes, Statut } from './noyau/types.js';
export interface EntreesIntegrateur {
autodiag: Autodiag;
budget_total: number;
/** 'global' = pipeline d'origine (calculs) ; 'par_axe' = somme des moteurs d'axe (plateforme). */
mode?: 'global' | 'par_axe';
/** Axes désactivés par l'habitant (toggles) ; absent = les trois. */
axes_actifs?: AxesActifs;
/** P1.5 : aménagements cochés ; ignoré en v0.1 (signalé). */
amenagements_coches?: string[];
}
export interface DonneesIntegrateur extends DonneesAutonomie {
france_climat: LigneClimat[];
}
export interface SortiesIntegrateur {
mode: 'global' | 'par_axe';
contexte: Contexte;
poids: PoidsAxes;
budgets: PoidsAxes;
axes: Record<Axe, Pick<SortiesAxe, 'postes' | 'typologies' | 'sous_total_eur_ht' | 'budget_alloue' | 'status' | 'taux_couverture' | 'taux_detail'>>;
total_eur_ht: number;
budget_cible: number;
ecart_budget_pct: number;
status: Statut;
message_status?: string;
couplages: string[];
taux_couverture: Record<Axe, number | null>;
}
/** Statut agrégé : BUDGET_INSUFFISANT l'emporte, puis AJOUTER_OPTIONNEL, sinon OK. */
export declare function agregeStatuts(statuts: Statut[]): Statut;
export declare function calculeIntegrateur(entrees: EntreesIntegrateur, donnees: DonneesIntegrateur): Resultat<SortiesIntegrateur>;
export declare const integrateur: Moteur<EntreesIntegrateur, DonneesIntegrateur, SortiesIntegrateur>;
export default integrateur;
+114
View File
@@ -0,0 +1,114 @@
// integrateur.ts — moteur `autonomie.integrateur` : contexte + pondération des
// axes + allocation du budget + agrégation.
//
// Deux modes :
// - 'global' (défaut) : le pipeline complet d'origine (`selectionner`), avec
// couplages et réajustement GLOBAUX entre axes — celui de calculs ;
// - 'par_axe' : chaque axe est calculé par son moteur d'axe sur son budget,
// puis les sorties sont additionnées — celui du menu de la plateforme.
// Le mode « aménagements cochés » (recalcul sans relancer l'allocation) est
// P1.5 : une liste transmise est ignorée et signalée.
import { Trace } from '../commun/contrat.js';
import { COEFFICIENTS } from '../commun/coefficients.js';
import { deriveContexte } from '../commun/contexte.js';
import { VERSION } from '../version.js';
import { calculeAxe, enregistreLectures, traceSelection } from './axe.js';
import { selectionner } from './noyau/algo.js';
import { alloueBudget } from './noyau/alloue-budget.js';
import { configPourAxes } from './noyau/axes-actifs.js';
import { pondereAxes } from './noyau/pondere-axes.js';
import { AXES } from './noyau/types.js';
import { aCaptage, batterieSelectionnee, kwcSelectionnes, mlHaiesSelectionnes, tauxAlim, tauxEau, tauxEnergie } from './taux.js';
/** Statut agrégé : BUDGET_INSUFFISANT l'emporte, puis AJOUTER_OPTIONNEL, sinon OK. */
export function agregeStatuts(statuts) {
if (statuts.includes('BUDGET_INSUFFISANT'))
return 'BUDGET_INSUFFISANT';
if (statuts.includes('AJOUTER_OPTIONNEL'))
return 'AJOUTER_OPTIONNEL';
return 'OK';
}
export function calculeIntegrateur(entrees, donnees) {
const trace = new Trace();
const mode = entrees.mode ?? 'global';
const coefficients_base = donnees.coefficients ?? COEFFICIENTS;
const { table: coefficients, lues } = enregistreLectures(coefficients_base);
const config = configPourAxes(donnees.algo_config, entrees.axes_actifs);
const inputs = { ...entrees.autodiag, budget_eur: entrees.budget_total };
if (entrees.amenagements_coches && entrees.amenagements_coches.length) {
trace.avertit('aménagements cochés ignorés : le mode « coché » arrive en P1.5');
}
const ctx = deriveContexte(inputs.code_postal, donnees.france_climat, coefficients);
trace.absorbe(ctx);
const contexte = ctx.sorties;
const poids = pondereAxes(inputs, contexte, config, false);
const budgets = alloueBudget(entrees.budget_total, poids);
const axes = {};
const taux_couverture = {};
let total = 0;
let status;
let message_status;
let couplages = [];
if (mode === 'par_axe') {
const statuts = [];
for (const axe of AXES) {
const actif = !entrees.axes_actifs || entrees.axes_actifs[axe] !== false;
if (!actif || budgets[axe] <= 0) {
axes[axe] = { postes: [], typologies: [], sous_total_eur_ht: 0, budget_alloue: budgets[axe], status: 'OK', taux_couverture: null, taux_detail: null };
taux_couverture[axe] = null;
continue;
}
const r = calculeAxe(axe, { autodiag: entrees.autodiag, budget_axe: budgets[axe], contexte }, { ...donnees, coefficients: coefficients_base });
trace.absorbe(r);
const s = r.sorties;
axes[axe] = { postes: s.postes, typologies: s.typologies, sous_total_eur_ht: s.sous_total_eur_ht, budget_alloue: s.budget_alloue, status: s.status, taux_couverture: s.taux_couverture, taux_detail: s.taux_detail };
taux_couverture[axe] = s.taux_couverture;
total += s.sous_total_eur_ht;
statuts.push(s.status);
couplages = couplages.concat(s.couplages);
}
status = agregeStatuts(statuts);
}
else {
const r = selectionner(inputs, donnees.options, donnees.postes, donnees.france_climat, { config, coefficients }, false);
const tous = [];
for (const axe of AXES) {
const a = r.axes[axe];
const postes = a?.postes ?? [];
tous.push(...postes);
let t;
if (axe === 'eau')
t = tauxEau({ surface_toit_m2: inputs.surface_toit_m2, nb_personnes: inputs.nb_personnes, pluviometrie_mm: contexte.pluviometrie_mm, a_captage: aCaptage(postes) }, coefficients, trace);
else if (axe === 'energie')
t = tauxEnergie({ kwc: kwcSelectionnes(postes), batterie_kwh: batterieSelectionnee(postes), orientation_toit: inputs.orientation_toit, nb_personnes: inputs.nb_personnes, contexte }, coefficients, trace);
else
t = tauxAlim({ ml: mlHaiesSelectionnes(postes), nb_personnes: inputs.nb_personnes }, coefficients, trace);
axes[axe] = { postes, typologies: postes.map((p) => p.typologie_choisie), sous_total_eur_ht: a?.sous_total_eur_ht ?? 0, budget_alloue: a?.budget_alloue ?? budgets[axe], status: r.status, taux_couverture: t.taux, taux_detail: t.detail };
taux_couverture[axe] = t.taux;
}
total = r.total_eur_ht;
status = r.status;
message_status = r.message_status;
couplages = r.couplages;
traceSelection(trace, coefficients_base, lues, tous, donnees.options, couplages);
}
const sorties = {
mode,
contexte,
poids,
budgets,
axes,
total_eur_ht: total,
budget_cible: entrees.budget_total,
ecart_budget_pct: entrees.budget_total > 0 ? +(((total - entrees.budget_total) / entrees.budget_total) * 100).toFixed(2) : 0,
status,
message_status,
couplages,
taux_couverture,
};
return trace.resultat(sorties, VERSION);
}
export const integrateur = {
id: 'autonomie.integrateur',
calculer: calculeIntegrateur,
};
export default integrateur;
+17
View File
@@ -0,0 +1,17 @@
import type { Autodiag, Contexte, Option, Poste, Reglages } from './types.js';
export interface AdequationDebug {
score: number;
scoreurs: Record<string, {
score: number;
poids: number;
}>;
avertissement?: string;
}
/**
* Calcule le score d'adéquation contextuelle d'une typologie [0..1].
*
* @param typologies_selectionnees — sélections déjà faites (pour score_couplage_disponible)
* @returns score ∈ [0..1] ; en debug, { score, scoreurs }
*/
export declare function adequation_contexte(typo: Option, inputs: Autodiag, poste: Poste, contexte_derived: Contexte, reglages: Reglages, typologies_selectionnees?: string[], debug?: false): number;
export declare function adequation_contexte(typo: Option, inputs: Autodiag, poste: Poste, contexte_derived: Contexte, reglages: Reglages, typologies_selectionnees: string[], debug: true): AdequationDebug;
+59
View File
@@ -0,0 +1,59 @@
// adequation.ts — adequation_contexte(typo, inputs, poste, contexte_derived) → [0..1]
// Spec: V2-algo-spec-FONCTIONS.md §1 — origine calculs/src/lib/autonomie/adequation.js.
// Correction LOT P1.0 : `reglages` en paramètre (plus de getConfig()) ; le
// console.warn de debug devient un champ `avertissement` du retour debug.
import { clamp01 } from './calibrage.js';
import { score_pluviometrie, score_climat_chauffage, score_climat_meridional, score_taille_foyer, score_surface_toit, score_orientation_toit, score_surface_parcelle, score_temps_dispo, score_metres_haie, score_couplage_disponible, } from './scoreurs.js';
// Catalogue de dispatch des scoreurs par nom de critère
const SCOREURS = {
pluviometrie: score_pluviometrie,
climat_chauffage: score_climat_chauffage,
climat_meridional: score_climat_meridional,
taille_foyer: score_taille_foyer,
surface_toit: score_surface_toit,
orientation_toit: score_orientation_toit,
surface_parcelle: score_surface_parcelle,
temps_dispo: score_temps_dispo,
metres_haie_possibles: score_metres_haie,
couplage_disponible: score_couplage_disponible,
};
export function adequation_contexte(typo, inputs, poste, contexte_derived, reglages, typologies_selectionnees = [], debug = false) {
const matrix = reglages.config.adequation_weights?.[poste.code];
if (!matrix) {
if (debug)
return { score: 0.5, scoreurs: {}, avertissement: `[adequation_contexte] Pas de matrice pour poste "${poste.code}" — fallback 0.5` };
return 0.5;
}
let weighted_sum = 0;
let total_weight = 0;
const debug_scores = {};
for (const [critere, poids] of Object.entries(matrix)) {
if (poids === 0 || !SCOREURS[critere])
continue;
const scoreur = SCOREURS[critere];
let s;
if (critere === 'couplage_disponible') {
s = scoreur(typo, inputs, contexte_derived, poste, typologies_selectionnees, reglages);
}
else {
s = scoreur(typo, inputs, contexte_derived, poste, [], reglages);
}
weighted_sum += s * poids;
total_weight += poids;
if (debug)
debug_scores[critere] = { score: +s.toFixed(3), poids };
}
let score = total_weight > 0 ? weighted_sum / total_weight : 0.5;
// §1.4 Bonus / malus globaux
if (typo.flag_diy && inputs.temps_dispo === 3)
score *= 1.3;
if (typo.complexite_pose === 3 && inputs.temps_dispo === 1)
score *= 0.5;
if (!typo.accessible_fr)
score *= 0.6;
score = clamp01(score);
if (debug) {
return { score, scoreurs: debug_scores };
}
return score;
}
+28
View File
@@ -0,0 +1,28 @@
import type { Autodiag, Contexte, EtapeDebug, InputsSimulation, LigneClimat, Option, Poste, Reglages, Selection, SortieSelection, Statut } from './types.js';
export interface SelectionFinalisee {
axes: Selection;
total_eur_ht: number;
status: Statut;
message_status?: string;
couplages: string[];
debug_reajust: string[] | null;
}
/**
* Étapes 4 à 6 du pipeline d'origine : couplages contraints, réajustement au
* budget, re-passe couplages bornée à deux tours, total. Ne touche pas à la
* sélection reçue (appliqueCouplages copie).
*/
export declare function finaliseSelection(selection: Selection, budget_total: number, options: Option[], postes: Poste[], inputs: Autodiag, contexte: Contexte, reglages: Reglages, debugMode?: boolean, debug?: {
steps: EtapeDebug[];
}): SelectionFinalisee;
/**
* Sélectionne les typologies optimales selon budget + contexte.
*
* @param inputs — saisie utilisateur (autodiag + budget_eur)
* @param options — typologies BDD (options.json)
* @param postes — postes BDD (postes.json)
* @param climat — données climatiques (france_climat.json)
* @param reglages — { config: algo-config.json parsé, coefficients }
* @param debugMode — active la trace debug exhaustive
*/
export declare function selectionner(inputs: InputsSimulation, options: Option[], postes: Poste[], climat: LigneClimat[], reglages: Reglages, debugMode?: boolean): SortieSelection;
+135
View File
@@ -0,0 +1,135 @@
// algo.ts — module principal orchestrateur des 6 étapes
// Spec: V2-algo-spec.md + V2-algo-spec-FONCTIONS.md — origine calculs/src/lib/autonomie/algo.js.
// Zero-dep, isomorphe navigateur/Node.
//
// Corrections LOT P1.0 : plus d'initConfig (les réglages passent en paramètre) ;
// le contexte vient de commun/contexte.ts (Corse 2A/2B et DOM 971-976 corrigés,
// une seule version) ; la sortie porte en plus `contexte` et `avertissements`.
// C'est le pipeline COMPLET d'origine (couplages et réajustement globaux), celui
// que calculs.trans-former.fr consommera en P1.5 sans changement de résultat.
//
// Découpage par axe (LOT P1.0) : la queue du pipeline (étapes 4 à 6) est
// isolée dans `finaliseSelection`, que les moteurs d'axe réutilisent telle
// quelle sur une sélection à un seul axe. Un moteur d'axe est donc, par
// construction, le mode « un seul axe actif » de `selectionner`.
import { deriveContexte } from '../../commun/contexte.js';
import { pondereAxes } from './pondere-axes.js';
import { alloueBudget } from './alloue-budget.js';
import { selectionnePoste } from './selectionne-poste.js';
import { appliqueCouplages } from './couplages.js';
import { reajusteBudget } from './reajuste.js';
const AXES_CODE = ['eau', 'energie', 'alim'];
function sommeAxes(axes) {
return Math.round(AXES_CODE.reduce((s, axe) => s + ((axes[axe] && axes[axe].sous_total_eur_ht) || 0), 0));
}
/**
* Étapes 4 à 6 du pipeline d'origine : couplages contraints, réajustement au
* budget, re-passe couplages bornée à deux tours, total. Ne touche pas à la
* sélection reçue (appliqueCouplages copie).
*/
export function finaliseSelection(selection, budget_total, options, postes, inputs, contexte, reglages, debugMode = false, debug) {
const config = reglages.config;
// --- Étape 4 : couplages contraints ---
const apres_couplages = appliqueCouplages(selection, options, postes, reglages, debugMode);
if (debug)
debug.steps.push({ etape: 4, label: 'couplages', log: apres_couplages.couplages_appliques });
// --- Étape 5 : réajustement budget ---
const final_pre = reajusteBudget(apres_couplages, budget_total, options, postes, inputs, contexte, reglages, debugMode);
if (debug)
debug.steps.push({ etape: 5, label: 'reajustement', status: final_pre.status, total: final_pre.total, log: final_pre.debug_reajust });
// Re-passe couplages post-réajustement, avec réabsorption des coûts forcés :
// le CAS B (underspend) peut ajouter ou upgrader des typologies ayant des
// couplages_requis. La passe couplages force les requis manquants
// (ex: bois_poele_hydro → bois_conduit_fumee) — coûts contraints, inclus
// dans le total final (comportement intentionnel).
// Correctif V3-1 (recalibrage) : ces ajouts pouvaient faire franchir le seuil
// d'overrun SANS rééquilibrage ni changement de status (profil A : +8,9 % avec
// status OK). Boucle bornée à 2 passes : couplages → si overrun, réajuster →
// couplages. CAS A ne fait que réduire, la convergence est garantie ; si le
// seuil reste franchi, reajusteBudget pose BUDGET_INSUFFISANT (status honnête).
const tol_over = (config.tolerances && config.tolerances.budget_overrun_pct) || 5;
const seuil_over = budget_total * (1 + tol_over / 100);
let etat = {
axes: final_pre.axes,
couplages_appliques: [...(final_pre.couplages_appliques || [])],
};
let status_final = final_pre.status;
let message_final = final_pre.message;
for (let passe = 0; passe < 2; passe++) {
// IMPORTANT: appliqueCouplages attend selection = { eau, energie, alim } directement
const avec = appliqueCouplages(etat.axes, options, postes, reglages, false);
etat = {
axes: avec.axes,
couplages_appliques: [...etat.couplages_appliques, ...(avec.couplages_appliques || [])],
};
if (sommeAxes(etat.axes) <= seuil_over)
break;
const re = reajusteBudget(etat, budget_total, options, postes, inputs, contexte, reglages, false);
etat = {
axes: re.axes,
couplages_appliques: [...etat.couplages_appliques, ...(re.couplages_appliques || [])],
};
status_final = re.status;
message_final = re.message;
}
return {
axes: etat.axes,
total_eur_ht: sommeAxes(etat.axes),
status: status_final,
message_status: message_final || undefined,
couplages: etat.couplages_appliques,
debug_reajust: final_pre.debug_reajust,
};
}
/**
* Sélectionne les typologies optimales selon budget + contexte.
*
* @param inputs — saisie utilisateur (autodiag + budget_eur)
* @param options — typologies BDD (options.json)
* @param postes — postes BDD (postes.json)
* @param climat — données climatiques (france_climat.json)
* @param reglages — { config: algo-config.json parsé, coefficients }
* @param debugMode — active la trace debug exhaustive
*/
export function selectionner(inputs, options, postes, climat, reglages, debugMode = false) {
const debug = { steps: [] };
const config = reglages.config;
// --- Étape 0 : dériver le contexte depuis code postal ---
const ctx = deriveContexte(inputs.code_postal, climat, reglages.coefficients);
const contexte = ctx.sorties;
if (debugMode)
debug.steps.push({ etape: 0, label: 'contexte', data: contexte });
// --- Étape 1 : pondération axes ---
const poids_debug = debugMode ? pondereAxes(inputs, contexte, config, true) : null;
const poids = poids_debug ? poids_debug.poids : pondereAxes(inputs, contexte, config, false);
if (poids_debug)
debug.steps.push({ etape: 1, label: 'ponderation', poids, log: poids_debug.log });
// --- Étape 2 : allocation budget ---
const budgets = alloueBudget(inputs.budget_eur, poids);
if (debugMode)
debug.steps.push({ etape: 2, label: 'allocation', budgets });
// --- Étape 3 : sélection par axe ---
const selection = {};
for (const axe of AXES_CODE) {
const res = selectionnePoste(budgets[axe], inputs, contexte, options, postes, axe, reglages, debugMode);
res.poids = poids[axe];
selection[axe] = res;
}
if (debugMode)
debug.steps.push({ etape: 3, label: 'selection', summary: Object.fromEntries(AXES_CODE.map((a) => [a, selection[a].sous_total_eur_ht])) });
// --- Étapes 4 à 6 ---
const fin = finaliseSelection(selection, inputs.budget_eur, options, postes, inputs, contexte, reglages, debugMode, debugMode ? debug : undefined);
const ecart = inputs.budget_eur > 0 ? ((fin.total_eur_ht - inputs.budget_eur) / inputs.budget_eur) * 100 : 0;
return {
axes: fin.axes,
total_eur_ht: fin.total_eur_ht,
budget_cible: inputs.budget_eur,
ecart_budget_pct: +ecart.toFixed(2),
status: fin.status,
message_status: fin.message_status,
couplages: fin.couplages,
contexte,
avertissements: ctx.avertissements,
debug: debugMode ? debug : undefined,
};
}
+9
View File
@@ -0,0 +1,9 @@
import type { PoidsAxes } from './types.js';
/**
* Alloue le budget total entre les 3 axes selon leurs poids.
*
* @param budget_total — EUR
* @param poids — somme = 100
* @returns en EUR
*/
export declare function alloueBudget(budget_total: number, poids: PoidsAxes): PoidsAxes;
+16
View File
@@ -0,0 +1,16 @@
// alloue-budget.ts — Étape 2 : allocation budget par axe
// Spec: V2-algo-spec.md §Étape 2 — origine calculs/src/lib/autonomie/alloue-budget.js, inchangé.
/**
* Alloue le budget total entre les 3 axes selon leurs poids.
*
* @param budget_total — EUR
* @param poids — somme = 100
* @returns en EUR
*/
export function alloueBudget(budget_total, poids) {
return {
eau: (budget_total * poids.eau) / 100,
energie: (budget_total * poids.energie) / 100,
alim: (budget_total * poids.alim) / 100,
};
}
+20
View File
@@ -0,0 +1,20 @@
import type { AlgoConfig, Axe } from './types.js';
import { AXES } from './types.js';
export type AxesActifs = Partial<Record<Axe, boolean>>;
/**
* Transforme algo-config pour neutraliser les axes désactivés.
* - poids_axes_base[axe] = 0 pour tout axe inactif
* - tous les deltas de modulateurs référençant un axe inactif sont retirés
* - marqueur `_axes_desactives` (lu par reajuste.ts pour ne pas réinjecter
* des postes optionnels dans un axe éteint lors du CAS B underspend)
*
* @param config — algo-config.json parsé (non muté)
* @returns nouvelle config
*/
export declare function configPourAxes(config: AlgoConfig, actifs: AxesActifs | null | undefined): AlgoConfig;
/**
* Vrai si au moins un axe est actif (0 axe actif = état vide géré par l'UI,
* l'algo ne doit pas tourner).
*/
export declare function auMoinsUnAxe(actifs: AxesActifs | null | undefined): boolean;
export { AXES };
+48
View File
@@ -0,0 +1,48 @@
// axes-actifs.ts — Toggles d'axes (V3 vague 1, M2)
// Produit une config dérivée où les axes désactivés ont un poids nul,
// sans toucher aux modules cœur de l'algo. La désactivation passe
// uniquement par les inputs/config (poids), conformément à la spec.
// Origine calculs/src/lib/autonomie/axes-actifs.js, inchangé (typé).
import { AXES } from './types.js';
/**
* Transforme algo-config pour neutraliser les axes désactivés.
* - poids_axes_base[axe] = 0 pour tout axe inactif
* - tous les deltas de modulateurs référençant un axe inactif sont retirés
* - marqueur `_axes_desactives` (lu par reajuste.ts pour ne pas réinjecter
* des postes optionnels dans un axe éteint lors du CAS B underspend)
*
* @param config — algo-config.json parsé (non muté)
* @returns nouvelle config
*/
export function configPourAxes(config, actifs) {
const desactives = AXES.filter((a) => actifs && actifs[a] === false);
if (desactives.length === 0)
return config;
// Clone profond (config petite, JSON-safe)
const cfg = JSON.parse(JSON.stringify(config));
cfg.poids_axes_base = cfg.poids_axes_base || { eau: 40, energie: 40, alim: 20 };
for (const axe of desactives)
cfg.poids_axes_base[axe] = 0;
// Neutraliser les deltas des modulateurs qui re-donneraient du poids
// à un axe désactivé (ex : zone MED -> eau+10, parcelle grande -> alim+10).
const mod = cfg.modulateurs || {};
for (const key of Object.keys(mod)) {
const val = mod[key];
if (val && typeof val === 'object' && !Array.isArray(val)) {
for (const axe of desactives) {
if (axe in val)
delete val[axe];
}
}
}
cfg._axes_desactives = desactives;
return cfg;
}
/**
* Vrai si au moins un axe est actif (0 axe actif = état vide géré par l'UI,
* l'algo ne doit pas tourner).
*/
export function auMoinsUnAxe(actifs) {
return AXES.some((a) => !actifs || actifs[a] !== false);
}
export { AXES };
+4
View File
@@ -0,0 +1,4 @@
export { ZONE_BIOGEO_MAP, zoneRtToClass } from '../../commun/contexte.js';
export declare function clamp01(x: number): number;
export declare function clamp(x: number, min: number, max: number): number;
export declare function roundToStep(value: number, step: number): number;
+19
View File
@@ -0,0 +1,19 @@
// calibrage.ts — helpers numériques du noyau.
//
// Origine : calculs/src/lib/autonomie/calibrage.js. Correction LOT P1.0 : le
// singleton `_config` (initConfig / getConfig) est retiré, la configuration
// passe en paramètre (Reglages) chez les cinq appelants. ORIENTATION_FACTORS et
// DJU_DEFAULTS_BY_ZONE vivent désormais dans commun/coefficients.ts (une seule
// définition, sourcée) ; ZONE_BIOGEO_MAP et zoneRtToClass dans commun/contexte.ts.
export { ZONE_BIOGEO_MAP, zoneRtToClass } from '../../commun/contexte.js';
export function clamp01(x) {
return Math.max(0, Math.min(1, x));
}
export function clamp(x, min, max) {
return Math.max(min, Math.min(max, x));
}
export function roundToStep(value, step) {
if (!step || step <= 0)
return value;
return Math.round(value / step) * step;
}
+12
View File
@@ -0,0 +1,12 @@
import type { Option, Poste, Reglages, Selection } from './types.js';
export interface ResultatCouplages {
axes: Selection;
couplages_appliques: string[];
}
/**
* Post-traitement déterministe des couplages.
*
* Gestion alias : algo-config.json § couplages_alias permet de mapper
* des codes legacy (ex: stherm_ballon_tampon) vers les codes BDD réels (th_ballon_500).
*/
export declare function appliqueCouplages(selection_recue: Selection, options: Option[], postes: Poste[] | null | undefined, reglages: Reglages, _debug?: boolean): ResultatCouplages;
+129
View File
@@ -0,0 +1,129 @@
// couplages.ts — Étape 4 : application des couplages contraints
// Spec: V2-algo-spec.md §Étape 4 — origine calculs/src/lib/autonomie/couplages.js.
// Corrections LOT P1.0 : `reglages` en paramètre (plus de getConfig()) ; la
// sélection reçue n'est PLUS mutée (copie des axes et de leurs postes, le
// résultat est renvoyé dans `axes`). Tous les appelants d'origine lisaient
// déjà le retour, le comportement est identique.
//
// Note (origine, conservée) : le marqueur `_conflit_avec` de la passe 2 est
// posé sur des copies de postes et n'atteint jamais la sortie ; le
// « downgrade planifié » n'a donc jamais lieu. À corriger en patch, avec test.
import { cout_typologie, dimension_optimale } from './dimension.js';
function copieSelection(selection) {
const copie = {};
for (const axe of Object.keys(selection)) {
const a = selection[axe];
if (!a)
continue;
const ax = { ...a, postes: a.postes.map((p) => ({ ...p })) };
copie[axe] = ax;
}
return copie;
}
/**
* Post-traitement déterministe des couplages.
*
* Gestion alias : algo-config.json § couplages_alias permet de mapper
* des codes legacy (ex: stherm_ballon_tampon) vers les codes BDD réels (th_ballon_500).
*/
export function appliqueCouplages(selection_recue, options, postes, reglages, _debug = false) {
const alias = reglages.config.couplages_alias || {};
const selection = copieSelection(selection_recue);
// Construire un mapping poste_id (num) → axe via postes BDD
const posteIdToAxe = {};
if (postes) {
for (const p of postes) {
// Normaliser axe : "alimentation" → "alim"
const axe_norm = (p.axe === 'alimentation' ? 'alim' : p.axe);
posteIdToAxe[p.Id] = axe_norm;
}
}
function resolveCode(code) {
return alias[code] || code;
}
const axes_list = ['eau', 'energie', 'alim'];
const tous_postes = axes_list.flatMap((axe) => (selection[axe] && selection[axe].postes ? selection[axe].postes : []).map((p) => Object.assign({}, p, { _axe: axe })));
const codes_selectionnes = new Set(tous_postes.map((p) => p.typologie_choisie));
const couplages_log = [];
// Passe 1 : couplages_requis
for (const poste of tous_postes) {
const requis_list = poste.couplages_requis || [];
for (const requis_raw of requis_list) {
const requis = resolveCode(requis_raw);
if (!codes_selectionnes.has(requis)) {
// Chercher la typo en BDD (code exact ou alias original)
let typo_requise = options.find((o) => o.code === requis);
if (!typo_requise && requis !== requis_raw) {
typo_requise = options.find((o) => o.code === requis_raw);
}
if (typo_requise) {
// Trouver l'axe via mapping poste_id → axe
const axe_cible = posteIdToAxe[typo_requise.poste_id] || trouverAxeParCode(typo_requise.code);
const cible = axe_cible ? selection[axe_cible] : undefined;
if (axe_cible && cible) {
const dim = dimension_optimale(typo_requise, {}, {}, reglages.coefficients);
const cout = cout_typologie(typo_requise, dim, {});
const cout_force = Math.round(cout || 0);
const unite_dim = dim ? dim.unite : typo_requise.unite || '';
const valeur_dim = dim ? dim.valeur : typo_requise.quantite_default || 1;
cible.postes.push({
poste_id: String(typo_requise.poste_id) + '_couplage',
poste_nom: 'Couplage requis (' + typo_requise.nom + ')',
obligatoire: true,
budget_alloue_poste: cout_force,
typologie_choisie: typo_requise.code,
nom_affichage: typo_requise.nom,
description_pedago: typo_requise.description_pedago || '',
dimension: { valeur: valeur_dim, unite: unite_dim },
cout_eur_ht: cout_force,
score_adequation: 0.8,
score_final: 0,
alternatives: [],
notes_techniques: typo_requise.notes_techniques || '',
flag_diy: typo_requise.flag_diy || false,
complexite_pose: typo_requise.complexite_pose || 2,
couplages_requis: [],
couplages_exclus: [],
manuel: false,
force_par_couplage: true,
});
cible.sous_total_eur_ht += cout_force;
codes_selectionnes.add(typo_requise.code);
couplages_log.push('FORCE "' + typo_requise.code + '" (requis par "' + poste.typologie_choisie + '") +' + cout_force + 'EUR dans axe ' + axe_cible);
}
else {
couplages_log.push('WARN axe non trouve pour "' + requis + '" (requis par "' + poste.typologie_choisie + '")');
}
}
else {
couplages_log.push('MANQUANT "' + requis + '" (requis par "' + poste.typologie_choisie + '") — code BDD non trouve');
}
}
}
}
// Passe 2 : couplages_exclus
for (const poste of tous_postes) {
const exclus_list = poste.couplages_exclus || [];
for (const exclu_raw of exclus_list) {
const exclu = resolveCode(exclu_raw);
if (codes_selectionnes.has(exclu)) {
const poste_conflit = tous_postes.find((p) => p.typologie_choisie === exclu);
if (poste_conflit && !poste_conflit.force_par_couplage) {
poste_conflit._conflit_avec = poste.typologie_choisie;
couplages_log.push('CONFLIT "' + exclu + '" avec "' + poste.typologie_choisie + '" — downgrade planifié');
}
}
}
}
return { axes: selection, couplages_appliques: couplages_log };
}
// Fallback heuristique si poste_id absent du mapping
function trouverAxeParCode(code) {
if (/captage|stockage|filtrat|distrib|eg_|stherm_ballon/.test(code))
return 'eau';
if (/^th_|^pv_|^bois_|^pil_|^emet_|ballon_tampon|conduit_fumee/.test(code))
return 'energie';
if (/^etude_|^haute_|^moy_|^basse_/.test(code))
return 'alim';
return 'energie';
}
+27
View File
@@ -0,0 +1,27 @@
import type { TableCoefficients } from '../../commun/coefficients.js';
import type { Autodiag, Contexte, Dimension, Option } from './types.js';
/**
* Calcule la valeur optimale du paramètre dimensionnant pour une typologie.
*
* Particularité ballon_tampon (th_ballon_500) :
* - unite BDD = "forfait" (contrainte schéma NocoDB)
* - prix_par_unite = 1.2 EUR/L
* - parametre_dim.unite = "L" (source de vérité pour l'affichage et le calcul)
* → L'algo utilise toujours parametre_dim.unite quand présent, sinon fallback option.unite
*/
export declare function dimension_optimale(typo: Option, inputs: Partial<Autodiag>, contexte_derived: Partial<Contexte>, coefficients: TableCoefficients): Dimension | null;
/**
* Calcule le coût HT d'une typologie pour une dimension donnée.
*
* Hiérarchie des prix (3 cas BDD) :
* 1. prix_fixe + prix_par_unite × dim.valeur (V2 enrichi, ex: cuve béton, PV)
* 2. prix_par_unite × dim.valeur seul (ex: th_ballon_500)
* 3. prix_mat_min + pose si pro requis (forfaits legacy sans parametre_dim enrichi)
*
* Pour les postes pro (complexite_pose=3) et temps_dispo < 3, ajouter prix_pro_min.
* Fallback final : (prix_mat_min + prix_mat_max) / 2
*
* Les prix sont des DONNÉES de l'option (publiées par calculs) ; la seule
* heuristique du code est « pose pro = 50 % du matériel si prix_pro absent ».
*/
export declare function cout_typologie(typo: Option, dim_result: Dimension | null | undefined, inputs?: Partial<Autodiag> | null): number;
+113
View File
@@ -0,0 +1,113 @@
// dimension.ts — dimension_optimale(typo, inputs, contexte_derived, coefficients) → Dimension | null
// Spec: V2-algo-spec-FONCTIONS.md §2 — origine calculs/src/lib/autonomie/dimension.js.
// Correction LOT P1.0 : facteurs d'orientation et DJU par défaut lus dans la
// table de coefficients passée en paramètre (plus de constante de module).
import { zoneRtToClass, clamp, roundToStep } from './calibrage.js';
import { parseParam } from './types.js';
/**
* Calcule la valeur optimale du paramètre dimensionnant pour une typologie.
*
* Particularité ballon_tampon (th_ballon_500) :
* - unite BDD = "forfait" (contrainte schéma NocoDB)
* - prix_par_unite = 1.2 EUR/L
* - parametre_dim.unite = "L" (source de vérité pour l'affichage et le calcul)
* → L'algo utilise toujours parametre_dim.unite quand présent, sinon fallback option.unite
*/
export function dimension_optimale(typo, inputs, contexte_derived, coefficients) {
const param = parseParam(typo.parametre_dim);
if (!param)
return null;
const strategie = param.strategie || 'fixe';
let valeur;
switch (strategie) {
case 'par_personne': {
const base = param.base != null ? param.base : 0;
const coef = param.coef != null ? param.coef : 1;
valeur = base + (inputs.nb_personnes || 4) * coef;
break;
}
case 'par_surface_toit': {
const table = coefficients.orientation_pv.valeur;
const orient = (inputs.orientation_toit ? table[inputs.orientation_toit] : undefined) || 0.8;
const coef = param.coef != null ? param.coef : 0.1;
valeur = (inputs.surface_toit_m2 || 0) * coef * orient;
break;
}
case 'par_surface_parcelle': {
const reserve = param.reserve_construit != null ? param.reserve_construit : 100;
const utile = Math.max(0, (inputs.surface_parcelle_m2 || 0) - reserve);
const coef = param.coef != null ? param.coef : 0.05;
valeur = utile * coef;
break;
}
case 'par_dju': {
const zone_class = zoneRtToClass(contexte_derived.zone_climatique_rt);
const dju = contexte_derived.dju || coefficients.dju_defaut_par_classe_rt.valeur[zone_class] || 2200;
const coef = param.coef != null ? param.coef : 0.005;
valeur = dju * coef;
break;
}
case 'par_metres_haie': {
const coef = param.coef != null ? param.coef : 1.0;
valeur = (inputs.metres_haie_possibles || 0) * coef;
break;
}
case 'fixe':
default:
valeur = param.default != null ? param.default : 1;
break;
}
// Clamp + arrondi step
const min = param.min != null ? param.min : 0;
const max = param.max != null ? param.max : Infinity;
const step = param.step != null ? param.step : 0;
valeur = clamp(valeur, min, max);
valeur = roundToStep(valeur, step);
// Unite : parametre_dim.unite prioritaire sur option.unite (cas ballon_tampon)
const unite = param.unite || typo.unite || '';
return { valeur, unite, strategie };
}
/**
* Calcule le coût HT d'une typologie pour une dimension donnée.
*
* Hiérarchie des prix (3 cas BDD) :
* 1. prix_fixe + prix_par_unite × dim.valeur (V2 enrichi, ex: cuve béton, PV)
* 2. prix_par_unite × dim.valeur seul (ex: th_ballon_500)
* 3. prix_mat_min + pose si pro requis (forfaits legacy sans parametre_dim enrichi)
*
* Pour les postes pro (complexite_pose=3) et temps_dispo < 3, ajouter prix_pro_min.
* Fallback final : (prix_mat_min + prix_mat_max) / 2
*
* Les prix sont des DONNÉES de l'option (publiées par calculs) ; la seule
* heuristique du code est « pose pro = 50 % du matériel si prix_pro absent ».
*/
export function cout_typologie(typo, dim_result, inputs) {
const prix_fixe = typo.prix_fixe != null ? typo.prix_fixe : 0;
const prix_par_unite = typo.prix_par_unite;
const prix_mat_min = typo.prix_mat_min != null ? typo.prix_mat_min : 0;
const prix_mat_max = typo.prix_mat_max != null ? typo.prix_mat_max : prix_mat_min;
const prix_pro_min = typo.prix_pro_min != null ? typo.prix_pro_min : 0;
// Cas 1 & 2 : prix_par_unite disponible (schéma V2 enrichi)
if (prix_par_unite != null && prix_par_unite > 0) {
const dim_val = dim_result ? dim_result.valeur : typo.quantite_default || 1;
const base = prix_fixe + prix_par_unite * dim_val;
// Forfait pro si complexite=3 et temps_dispo=1
if (typo.complexite_pose === 3 && inputs && inputs.temps_dispo === 1 && prix_pro_min > 0) {
return Math.max(base, prix_pro_min);
}
return base;
}
// Cas 3 : forfait legacy (prix_mat_min/max seulement)
if (prix_mat_min > 0) {
const mid_mat = (prix_mat_min + prix_mat_max) / 2;
// Si complexite_pose = 3 et pose pro : ajouter coût pose pro
if (typo.complexite_pose === 3) {
const pose = prix_pro_min > 0 ? prix_pro_min : mid_mat * 0.5;
return mid_mat + pose;
}
// complexite 1-2 : coût mat seul (DIY inclus)
return mid_mat;
}
// Fallback ultime : 0 (sécurité, ne devrait pas arriver)
return 0;
}
+18
View File
@@ -0,0 +1,18 @@
export { selectionner } from './algo.js';
export { dimension_optimale, cout_typologie } from './dimension.js';
export { adequation_contexte } from './adequation.js';
export type { AdequationDebug } from './adequation.js';
export { pondereAxes } from './pondere-axes.js';
export type { PoidsDebug } from './pondere-axes.js';
export { alloueBudget } from './alloue-budget.js';
export { selectionnePoste } from './selectionne-poste.js';
export { appliqueCouplages } from './couplages.js';
export type { ResultatCouplages } from './couplages.js';
export { reajusteBudget } from './reajuste.js';
export type { EntreeReajuste, ResultatReajuste } from './reajuste.js';
export { configPourAxes, auMoinsUnAxe } from './axes-actifs.js';
export type { AxesActifs } from './axes-actifs.js';
export { clamp01, clamp, roundToStep, ZONE_BIOGEO_MAP, zoneRtToClass } from './calibrage.js';
export * from './scoreurs.js';
export { AXES, parseJsonArr, parseParam } from './types.js';
export type { Axe, Autodiag, InputsSimulation, Poste, Option, ParamDim, AlgoConfig, Reglages, Dimension, Alternative, PosteResultat, AxeResultat, Selection, Statut, PoidsAxes, EtapeDebug, SortieSelection, Contexte, LigneClimat, Orientation, } from './types.js';
+14
View File
@@ -0,0 +1,14 @@
// @transformer/moteurs/autonomie/noyau — le cœur V2 du simulateur autonomie,
// pipeline complet (celui de calculs.trans-former.fr), sans singleton ni I/O.
export { selectionner } from './algo.js';
export { dimension_optimale, cout_typologie } from './dimension.js';
export { adequation_contexte } from './adequation.js';
export { pondereAxes } from './pondere-axes.js';
export { alloueBudget } from './alloue-budget.js';
export { selectionnePoste } from './selectionne-poste.js';
export { appliqueCouplages } from './couplages.js';
export { reajusteBudget } from './reajuste.js';
export { configPourAxes, auMoinsUnAxe } from './axes-actifs.js';
export { clamp01, clamp, roundToStep, ZONE_BIOGEO_MAP, zoneRtToClass } from './calibrage.js';
export * from './scoreurs.js';
export { AXES, parseJsonArr, parseParam } from './types.js';
+13
View File
@@ -0,0 +1,13 @@
import type { AlgoConfig, Autodiag, Contexte, PoidsAxes } from './types.js';
export interface PoidsDebug {
poids: PoidsAxes;
log: string[];
}
/**
* Calcule les poids des 3 axes (eau/energie/alim) selon le contexte utilisateur.
* Base 40/40/20, modulations documentées dans algo-config.json § modulateurs.
*
* @returns somme = 100 ; en debug, { poids, log }
*/
export declare function pondereAxes(inputs: Autodiag, contexte_derived: Pick<Contexte, 'zone_biogeo' | 'pluviometrie_mm'>, config: AlgoConfig, debug?: false): PoidsAxes;
export declare function pondereAxes(inputs: Autodiag, contexte_derived: Pick<Contexte, 'zone_biogeo' | 'pluviometrie_mm'>, config: AlgoConfig, debug: true): PoidsDebug;
+91
View File
@@ -0,0 +1,91 @@
// pondere-axes.ts — Étape 1 : pondération des axes selon contexte
// Spec: V2-algo-spec.md §Étape 1 — origine calculs/src/lib/autonomie/pondere-axes.js.
// Correction LOT P1.0 : `config` en paramètre (plus de getConfig()).
export function pondereAxes(inputs, contexte_derived, config, debug = false) {
const base = config.poids_axes_base ?? { eau: 40, energie: 40, alim: 20 };
const mod = (config.modulateurs ?? {});
const poids = { eau: base.eau, energie: base.energie, alim: base.alim };
const log = debug ? [] : null;
// SI surface_toit < seuil → poids_energie -= 15, redistribuer eau+5 alim+10
const toit_seuil = mod.toit_petit_seuil_m2 ?? 30;
if ((inputs.surface_toit_m2 ?? 0) < toit_seuil) {
const delta = mod.toit_petit_delta ?? { energie: -15, eau: 5, alim: 10 };
poids.energie += delta.energie ?? 0;
poids.eau += delta.eau ?? 0;
poids.alim += delta.alim ?? 0;
if (log)
log.push(`toit < ${toit_seuil}m² → Δenergie${delta.energie}, Δeau+${delta.eau}, Δalim+${delta.alim}`);
}
// SI surface_toit > 100 m² → poids_energie += 5
if ((inputs.surface_toit_m2 ?? 0) > 100) {
poids.energie += 5;
if (log)
log.push('toit > 100m² → Δenergie+5');
}
// SI nb_personnes >= 5 → poids_eau += 10, poids_alim += 5, poids_energie -= 15
const famille_seuil = mod.famille_grande_seuil ?? 5;
if ((inputs.nb_personnes ?? 0) >= famille_seuil) {
const delta = mod.famille_grande_delta ?? { eau: 10, alim: 5, energie: -15 };
poids.eau += delta.eau ?? 0;
poids.alim += delta.alim ?? 0;
poids.energie += delta.energie ?? 0;
if (log)
log.push(`nb_pers >= ${famille_seuil} → Δeau+${delta.eau}, Δalim+${delta.alim}, Δenergie${delta.energie}`);
}
// SI zone_biogeo == 'MED' → poids_eau += 10
if (contexte_derived.zone_biogeo === 'MED') {
const delta = mod.biogeo_med_delta ?? { eau: 10 };
poids.eau += delta.eau ?? 0;
if (log)
log.push(`zone MED → Δeau+${delta.eau}`);
}
// SI pluviometrie_mm < 600 → poids_eau += 5
const pluvio_seuil = mod.pluvio_basse_seuil_mm ?? 600;
if ((contexte_derived.pluviometrie_mm ?? 800) < pluvio_seuil) {
const delta = mod.pluvio_basse_delta ?? { eau: 5 };
poids.eau += delta.eau ?? 0;
if (log)
log.push(`pluvio < ${pluvio_seuil}mm → Δeau+${delta.eau}`);
}
// SI surface_parcelle < 300 m² → poids_alim = 0
const parcelle_petite = mod.parcelle_petite_seuil_m2 ?? 300;
if ((inputs.surface_parcelle_m2 ?? 0) < parcelle_petite) {
poids.alim = 0;
if (log)
log.push(`parcelle < ${parcelle_petite}m² → alim=0`);
}
// SI surface_parcelle >= 1000 m² ET temps_dispo == 3 → poids_alim += 10
const parcelle_grande = mod.parcelle_grande_seuil_m2 ?? 1000;
if ((inputs.surface_parcelle_m2 ?? 0) >= parcelle_grande && inputs.temps_dispo === 3) {
const delta = mod.parcelle_grande_temps3_delta ?? { alim: 10 };
poids.alim += delta.alim ?? 0;
if (log)
log.push(`parcelle >= ${parcelle_grande}m² + temps=3 → Δalim+${delta.alim}`);
}
// Axes explicitement désactivés par l'utilisateur (V3 toggles d'axes) :
// marqueur posé par axes-actifs.ts (configPourAxes). Les modulations
// hardcodées ci-dessus (ex : toit > 100 m² -> energie +5) ne doivent pas
// réactiver un axe éteint. Absent = comportement historique inchangé.
const desactives = config._axes_desactives || [];
for (const axe of desactives) {
if (axe in poids && poids[axe] !== 0) {
poids[axe] = 0;
if (log)
log.push('axe ' + axe + ' désactivé (toggle UI) -> poids 0');
}
}
// Normalisation : somme = 100
const total = poids.eau + poids.energie + poids.alim;
if (total !== 100 && total > 0) {
poids.eau = Math.round((poids.eau / total) * 100);
poids.energie = Math.round((poids.energie / total) * 100);
poids.alim = 100 - poids.eau - poids.energie; // garantie somme exacte
}
// Garde-fou : pas de poids négatif
poids.eau = Math.max(0, poids.eau);
poids.energie = Math.max(0, poids.energie);
poids.alim = Math.max(0, poids.alim);
if (debug)
return { poids, log: log ?? [] };
return poids;
}
+14
View File
@@ -0,0 +1,14 @@
import type { Autodiag, Contexte, Option, Poste, Reglages, Selection, Statut } from './types.js';
export interface EntreeReajuste {
axes: Selection;
couplages_appliques?: string[];
}
export interface ResultatReajuste {
axes: Selection;
total: number;
status: Statut;
message: string;
couplages_appliques: string[];
debug_reajust: string[] | null;
}
export declare function reajusteBudget(apres_couplages: EntreeReajuste, budget_total: number, options: Option[], postes: Poste[] | null | undefined, inputs: Autodiag, contexte_derived: Contexte, reglages: Reglages, debug?: boolean): ResultatReajuste;
+299
View File
@@ -0,0 +1,299 @@
// reajuste.ts — Étape 5 : réajustement budget global
// Spec: V2-algo-spec.md §Étape 5 — origine calculs/src/lib/autonomie/reajuste.js.
// Correction LOT P1.0 : `reglages` en paramètre (plus de getConfig()).
// Comportement d'origine conservé : la fonction modifie EN PLACE les axes
// reçus (qui sont déjà la copie produite par appliqueCouplages) et les renvoie.
//
// Note (origine, conservée) : les messages utilisent toLocaleString('fr-FR')
// (Intl). Texte de présentation, non assert par les tests ; à sortir du noyau
// en patch si l'octet-pour-octet inter-environnements devient un besoin.
import { cout_typologie, dimension_optimale } from './dimension.js';
import { parseJsonArr } from './types.js';
export function reajusteBudget(apres_couplages, budget_total, options, postes, inputs, contexte_derived, reglages, debug = false) {
const config = reglages.config;
const coefficients = reglages.coefficients;
const tolerances = config.tolerances || {};
const overrun_seuil = 1 + (tolerances.budget_overrun_pct || 5) / 100;
const underspend_seuil = 1 - (tolerances.budget_underspend_pct || 15) / 100;
const axes = apres_couplages.axes;
const axes_list = ['eau', 'energie', 'alim'];
function getTousPostes() {
return axes_list.flatMap((axe) => (axes[axe] && axes[axe].postes ? axes[axe].postes : []).map((p) => Object.assign({}, p, { _axe: axe })));
}
function calcTotal() {
return axes_list.reduce((s, axe) => s + (axes[axe] ? axes[axe].sous_total_eur_ht || 0 : 0), 0);
}
function recalcSousTotal(axe) {
const a = axes[axe];
if (a) {
a.sous_total_eur_ht = a.postes.reduce((s, p) => s + (p.cout_eur_ht || 0), 0);
}
}
let total = calcTotal();
let status = 'OK';
let message = '';
const log = debug ? [] : null;
// CAS A : overrun
if (total > budget_total * overrun_seuil) {
if (log)
log.push('CAS A : total ' + total + ' > seuil ' + Math.round(budget_total * overrun_seuil));
let iterations = 0;
while (total > budget_total * overrun_seuil && iterations < 80) {
iterations++;
// Correctif V3-1 (recalibrage) : downgrade "au plus pres du budget" au
// lieu du moins cher. Historiquement, le poste candidat sautait vers son
// alternative LA MOINS CHERE (ex : poele de masse -> rocket stove,
// -23 000 EUR la ou quelques centaines suffisaient a repasser sous le
// seuil). Desormais : parmi TOUS les downgrades possibles on choisit
// celui dont le total resultant est le plus proche du budget cible —
// evite a la fois le surcout et le sous-equipement massif.
const tous = getTousPostes().filter((p) => !p.force_par_couplage && !p.manuel);
const candidats = [];
for (const poste_candidat of tous) {
const poste_id_num = _posteIdNum(poste_candidat, options);
const alternatives = options.filter((o) => o.poste_id === poste_id_num && o.actif !== false && o.code !== poste_candidat.typologie_choisie);
const alts_scored = alternatives
.map((alt) => {
const dim = dimension_optimale(alt, inputs, contexte_derived, coefficients);
const cout = cout_typologie(alt, dim, inputs);
return { alt, dim, cout };
})
.filter((a) => a.cout < (poste_candidat.cout_eur_ht || 0));
for (const a of alts_scored) {
candidats.push({
poste: poste_candidat,
alt: a,
diff: (poste_candidat.cout_eur_ht || 0) - Math.round(a.cout),
});
}
}
let choix = null;
if (candidats.length > 0) {
// total resultant le plus proche du budget (tiebreak : pire score d'abord)
candidats.sort((a, b) => Math.abs(total - a.diff - budget_total) - Math.abs(total - b.diff - budget_total) ||
(a.poste.score_final || 0) - (b.poste.score_final || 0));
choix = candidats[0];
}
let downgraded = false;
if (choix) {
const poste_candidat = choix.poste;
const meilleur = choix.alt;
const diff = choix.diff;
const axe_poste = poste_candidat._axe;
const ax = axes[axe_poste];
const idx = ax.postes.findIndex((p) => p.typologie_choisie === poste_candidat.typologie_choisie);
if (idx >= 0) {
const p = ax.postes[idx];
ax.postes[idx] = Object.assign({}, p, {
typologie_choisie: meilleur.alt.code,
nom_affichage: meilleur.alt.nom,
description_pedago: meilleur.alt.description_pedago || '',
dimension: meilleur.dim || p.dimension,
cout_eur_ht: Math.round(meilleur.cout),
couplages_requis: parseJsonArr(meilleur.alt.couplages_requis),
couplages_exclus: parseJsonArr(meilleur.alt.couplages_exclus),
_downgraded: true,
});
recalcSousTotal(axe_poste);
total -= diff;
downgraded = true;
if (log)
log.push('Downgrade ' + poste_candidat.typologie_choisie + ' -> ' + meilleur.alt.code + ' -' + diff + 'EUR');
}
}
if (!downgraded) {
const optionnels = getTousPostes().filter((p) => !p.obligatoire && !p.force_par_couplage && !p.manuel);
optionnels.sort((a, b) => (a.score_final || 0) - (b.score_final || 0));
if (optionnels.length > 0) {
const pire = optionnels[0];
const axe_poste = pire._axe;
const ax = axes[axe_poste];
const idx = ax.postes.findIndex((p) => p.typologie_choisie === pire.typologie_choisie);
if (idx >= 0) {
total -= ax.postes[idx].cout_eur_ht || 0;
ax.postes.splice(idx, 1);
recalcSousTotal(axe_poste);
downgraded = true;
if (log)
log.push('Retrait optionnel ' + pire.typologie_choisie);
}
}
if (!downgraded)
break;
}
}
if (total > budget_total * overrun_seuil) {
status = 'BUDGET_INSUFFISANT';
message =
'Budget insuffisant pour équiper tous les postes obligatoires. Estimation minimale : ' +
Math.round(total).toLocaleString('fr-FR') +
' EUR. Votre budget : ' +
Math.round(budget_total).toLocaleString('fr-FR') +
' EUR.';
if (log)
log.push('BUDGET_INSUFFISANT — toutes alternatives épuisées');
}
}
// CAS B : underspend
else if (total < budget_total * underspend_seuil) {
if (log)
log.push('CAS B : total ' + total + ' < seuil ' + Math.round(budget_total * underspend_seuil));
// Axes explicitement désactivés par l'utilisateur (V3 toggles d'axes) :
// ne jamais y réinjecter de postes optionnels. Marqueur posé par
// axes-actifs.ts (configPourAxes). Absent = comportement historique inchangé.
const axes_desactives = new Set(config._axes_desactives || []);
// Identifier les postes BDD non encore sélectionnés (optionnels manquants)
const codes_selectionnes = new Set(getTousPostes().map((p) => p.typologie_choisie));
// Construire mapping poste_code → axe via postes BDD
const posteIdToAxe = {};
if (postes) {
for (const p of postes) {
const axe_norm = (p.axe === 'alimentation' ? 'alim' : p.axe);
posteIdToAxe[p.Id] = axe_norm;
}
}
let iterations = 0;
while (total < budget_total * underspend_seuil && iterations < 80) {
iterations++;
let upgraded = false;
// Stratégie 1 : upgrader des typologies existantes
const tous = getTousPostes().filter((p) => !p.manuel);
tous.sort((a, b) => (b.score_adequation || 0) - (a.score_adequation || 0));
for (const poste_candidat of tous) {
const poste_id_num = _posteIdNum(poste_candidat, options);
const alternatives = options.filter((o) => o.poste_id === poste_id_num && o.actif !== false && o.code !== poste_candidat.typologie_choisie);
const alts_scored = alternatives
.map((alt) => {
const dim = dimension_optimale(alt, inputs, contexte_derived, coefficients);
const cout = cout_typologie(alt, dim, inputs);
// Estimer le coût des couplages requis non encore sélectionnés
const codes_sel = new Set(getTousPostes().map((p) => p.typologie_choisie));
const alias = (config && config.couplages_alias) || {};
const requis_list = parseJsonArr(alt.couplages_requis);
let cout_couplages_requis = 0;
for (const r of requis_list) {
const r_resolved = alias[r] || r;
if (!codes_sel.has(r) && !codes_sel.has(r_resolved)) {
const typo_req = options.find((o) => o.code === r_resolved || o.code === r);
if (typo_req) {
const dim_req = dimension_optimale(typo_req, inputs, contexte_derived, coefficients);
cout_couplages_requis += cout_typologie(typo_req, dim_req, inputs);
}
}
}
return { alt, dim, cout, cout_couplages_requis };
// Ne conserver que les upgrades qui ne dépassent pas le budget global
// en incluant le coût estimé des couplages requis non encore satisfaits
})
.filter((a) => {
const diff_potential = a.cout + a.cout_couplages_requis - (poste_candidat.cout_eur_ht || 0);
return a.cout > (poste_candidat.cout_eur_ht || 0) && total + diff_potential <= budget_total * overrun_seuil;
});
if (alts_scored.length > 0) {
alts_scored.sort((a, b) => a.cout - b.cout);
const meilleur = alts_scored[0];
const diff = Math.round(meilleur.cout) - (poste_candidat.cout_eur_ht || 0);
const axe_poste = poste_candidat._axe;
const ax = axes[axe_poste];
const idx = ax.postes.findIndex((p) => p.typologie_choisie === poste_candidat.typologie_choisie);
if (idx >= 0) {
const p = ax.postes[idx];
ax.postes[idx] = Object.assign({}, p, {
typologie_choisie: meilleur.alt.code,
nom_affichage: meilleur.alt.nom,
description_pedago: meilleur.alt.description_pedago || '',
dimension: meilleur.dim || p.dimension,
cout_eur_ht: Math.round(meilleur.cout),
// IMPORTANT: mettre à jour les couplages de la nouvelle typo (pas hériter ancienne)
couplages_requis: parseJsonArr(meilleur.alt.couplages_requis),
couplages_exclus: parseJsonArr(meilleur.alt.couplages_exclus),
_upgraded: true,
});
recalcSousTotal(axe_poste);
total += diff;
upgraded = true;
if (log)
log.push('Upgrade ' + poste_candidat.typologie_choisie + ' -> ' + meilleur.alt.code + ' +' + diff + 'EUR');
break;
}
}
}
// Stratégie 2 : ajouter des postes optionnels non encore sélectionnés
if (!upgraded) {
const postes_manquants = options.filter((o) => {
if (o.actif === false || codes_selectionnes.has(o.code))
return false;
// Ne pas ajouter de poste appartenant à un axe désactivé (V3)
if (axes_desactives.size > 0 && posteIdToAxe[o.poste_id] && axes_desactives.has(posteIdToAxe[o.poste_id]))
return false;
return true;
});
// Trier par rapport score/cout et taille raisonnable
const candidats_add = postes_manquants
.map((o) => {
const dim = dimension_optimale(o, inputs, contexte_derived, coefficients);
const cout = cout_typologie(o, dim, inputs);
return { opt: o, dim, cout };
})
.filter((a) => a.cout > 0 && total + a.cout <= budget_total * overrun_seuil);
candidats_add.sort((a, b) => a.cout - b.cout);
if (candidats_add.length > 0) {
const best = candidats_add[candidats_add.length - 1]; // le plus cher qui rentre
const axe_target = posteIdToAxe[best.opt.poste_id] || 'energie';
const ax = axes[axe_target];
if (ax) {
const valeur_dim = best.dim ? best.dim.valeur : best.opt.quantite_default || 1;
const unite_dim = best.dim ? best.dim.unite : best.opt.unite || '';
ax.postes.push({
poste_id: best.opt.poste_id,
poste_nom: best.opt.nom,
obligatoire: false,
budget_alloue_poste: Math.round(best.cout),
typologie_choisie: best.opt.code,
nom_affichage: best.opt.nom,
description_pedago: best.opt.description_pedago || '',
dimension: { valeur: valeur_dim, unite: unite_dim },
cout_eur_ht: Math.round(best.cout),
score_adequation: 0.6,
score_final: 0,
alternatives: [],
notes_techniques: best.opt.notes_techniques || '',
flag_diy: best.opt.flag_diy || false,
complexite_pose: best.opt.complexite_pose || 2,
couplages_requis: [],
couplages_exclus: [],
manuel: false,
_added_cas_b: true,
});
ax.sous_total_eur_ht += Math.round(best.cout);
total += Math.round(best.cout);
codes_selectionnes.add(best.opt.code);
upgraded = true;
if (log)
log.push('Ajout optionnel ' + best.opt.code + ' +' + Math.round(best.cout) + 'EUR');
}
}
}
if (!upgraded) {
status = 'AJOUTER_OPTIONNEL';
message = 'Budget non consommé (' + Math.round(budget_total - total).toLocaleString('fr-FR') + ' EUR disponibles). Postes optionnels suggérés non inclus.';
if (log)
log.push('AJOUTER_OPTIONNEL — budget non consommable');
break;
}
}
}
total = calcTotal();
return {
axes,
total: Math.round(total),
status,
message,
couplages_appliques: apres_couplages.couplages_appliques ?? [],
debug_reajust: log,
};
}
function _posteIdNum(poste_result, options) {
const typo = options.find((o) => o.code === poste_result.typologie_choisie);
return typo ? typo.poste_id : -1;
}
+47
View File
@@ -0,0 +1,47 @@
import type { Autodiag, Contexte, Option, Poste, Reglages } from './types.js';
/**
* score_pluviometrie — pertinent pour stockage eau, récupération pluie
* Volume récupéré annuel vs capacité de la cuve (cible : 2 mois de réserve)
*/
export declare function score_pluviometrie(typo: Option, inputs: Autodiag, contexte_derived: Contexte, _poste: Poste, _sel: string[], reglages: Reglages): number;
/**
* score_climat_chauffage — pertinent pour postes chauffage, ECS
* Plus le DJU est élevé, plus une typologie puissante est pertinente
*/
export declare function score_climat_chauffage(typo: Option, _inputs: Autodiag, contexte_derived: Contexte): number;
/**
* score_climat_meridional — bonus solutions sécheresse/canicule zone MED
*/
export declare function score_climat_meridional(typo: Option, _inputs: Autodiag, contexte_derived: Contexte): number;
/**
* score_taille_foyer — pertinent pour postes dimensionnables au nb personnes
*/
export declare function score_taille_foyer(typo: Option, inputs: Autodiag): number;
/**
* score_surface_toit — pertinent pour PV, récupération eau pluie
*/
export declare function score_surface_toit(typo: Option, inputs: Autodiag, _contexte: Contexte, _poste: Poste, _sel: string[], reglages: Reglages): number;
/**
* score_orientation_toit — PV uniquement
* Sud = 1.0, dégradation linéaire, nord = forte pénalité
*/
export declare function score_orientation_toit(_typo: Option, inputs: Autodiag, _contexte: Contexte, poste: Poste | undefined, _sel: string[], reglages: Reglages): number;
/**
* score_surface_parcelle — pertinent pour alim, phyto-épuration, irrigation
*/
export declare function score_surface_parcelle(typo: Option, inputs: Autodiag): number;
/**
* score_temps_dispo — modulation par disponibilité utilisateur
*/
export declare function score_temps_dispo(typo: Option, inputs: Autodiag): number;
/**
* score_metres_haie — pertinent pour haies
*/
export declare function score_metres_haie(typo: Option, inputs: Autodiag): number;
/**
* score_couplage_disponible — bonus synergies, calculé post-sélection initiale (étape 4)
* @param typologies_selectionnees — codes déjà sélectionnés
*/
export declare function score_couplage_disponible(typo: Option, _inputs: Autodiag, _contexte: Contexte, _poste: Poste, typologies_selectionnees?: string[]): number;
/** Facteur d'orientation PV depuis la table de coefficients ; undefined si orientation inconnue. */
export declare function facteurOrientation(reglages: Reglages, orientation: string | undefined): number | undefined;
+170
View File
@@ -0,0 +1,170 @@
// scoreurs.ts — 10 scoreurs réutilisables [0..1]
// Spec: V2-algo-spec-FONCTIONS.md §1.2 — origine calculs/src/lib/autonomie/scoreurs.js.
// Correction LOT P1.0 : les coefficients (rendement de captage, facteurs
// d'orientation) arrivent par `reglages` au lieu d'une constante de module.
//
// Note (bug latent d'origine, conservé pour la non-régression, à corriger en
// patch) : score_pluviometrie, score_climat_chauffage et score_metres_haie
// lisent `typo.parametre_dim` sans le parser ; quand il arrive en JSON
// sérialisé (cas des données publiées), ils retombent sur leurs défauts.
import { clamp01 } from './calibrage.js';
import { parseJsonArr } from './types.js';
function paramBrut(typo) {
// Reproduit `typo.parametre_dim?.default` de l'origine : un objet est lu,
// une chaîne JSON ne l'est PAS (ses propriétés sont undefined).
const p = typo.parametre_dim;
return p && typeof p === 'object' ? p : null;
}
/**
* score_pluviometrie — pertinent pour stockage eau, récupération pluie
* Volume récupéré annuel vs capacité de la cuve (cible : 2 mois de réserve)
*/
export function score_pluviometrie(typo, inputs, contexte_derived, _poste, _sel, reglages) {
const surface = inputs.surface_toit_m2 ?? 0;
const pluvio = contexte_derived.pluviometrie_mm ?? 800;
const volume_recup_annuel = surface * (pluvio / 1000) * reglages.coefficients.captage_rendement.valeur;
const cible_2mois = volume_recup_annuel / 6;
const dim_default = paramBrut(typo)?.default ?? 8;
if (cible_2mois <= 0)
return 0.3;
const ratio = dim_default / cible_2mois;
return clamp01(1 - 0.5 * Math.abs(1 - ratio));
}
/**
* score_climat_chauffage — pertinent pour postes chauffage, ECS
* Plus le DJU est élevé, plus une typologie puissante est pertinente
*/
export function score_climat_chauffage(typo, _inputs, contexte_derived) {
const dju = contexte_derived.dju ?? 2200;
const puissance = paramBrut(typo)?.default ?? 5;
const puissance_cible = dju / 500; // heuristique ~5 kW pour H1
if (puissance_cible <= 0)
return 0.5;
return clamp01(1 - (0.4 * Math.abs(puissance - puissance_cible)) / puissance_cible);
}
/**
* score_climat_meridional — bonus solutions sécheresse/canicule zone MED
*/
export function score_climat_meridional(typo, _inputs, contexte_derived) {
const is_med = contexte_derived.zone_biogeo === 'MED';
if (!is_med)
return 0.5; // neutre hors MED
const tags = typo.tags ?? [];
const med_tags = ['frais_passif', 'sec', 'mediterraneen'];
return med_tags.some((t) => tags.includes(t)) ? 1.0 : 0.5;
}
/**
* score_taille_foyer — pertinent pour postes dimensionnables au nb personnes
*/
export function score_taille_foyer(typo, inputs) {
const param = paramBrut(typo);
if (!param || param.strategie !== 'par_personne')
return 0.5;
const base = param.base ?? 0;
const coef = param.coef ?? 1;
const min = param.min ?? 0;
const max = param.max ?? 100;
const dim_calc = base + (inputs.nb_personnes ?? 4) * coef;
if (max === min)
return 0.5;
const ratio_in_range = clamp01((dim_calc - min) / (max - min));
return 0.4 + 0.6 * ratio_in_range;
}
/**
* score_surface_toit — pertinent pour PV, récupération eau pluie
*/
export function score_surface_toit(typo, inputs, _contexte, _poste, _sel, reglages) {
const param = paramBrut(typo);
if (!param || param.strategie !== 'par_surface_toit')
return 0.5;
const orient_factor = facteurOrientation(reglages, inputs.orientation_toit) ?? 0.8;
const surface_utilisable = (inputs.surface_toit_m2 ?? 0) * orient_factor;
const coef = param.coef ?? 0.1;
const min = param.min ?? 0;
const max = param.max ?? 100;
const taille_optimale = surface_utilisable * coef;
if (max === min)
return 0.5;
const ratio_in_range = clamp01((taille_optimale - min) / (max - min));
return 0.3 + 0.7 * ratio_in_range;
}
/**
* score_orientation_toit — PV uniquement
* Sud = 1.0, dégradation linéaire, nord = forte pénalité
*/
export function score_orientation_toit(_typo, inputs, _contexte, poste, _sel, reglages) {
// On applique ce score dès qu'on est sur un poste PV (energie_pv)
if (!poste || !poste.code?.includes('pv'))
return 0.5;
return facteurOrientation(reglages, inputs.orientation_toit) ?? 0.5;
}
/**
* score_surface_parcelle — pertinent pour alim, phyto-épuration, irrigation
*/
export function score_surface_parcelle(typo, inputs) {
const surface_utile = Math.max(0, (inputs.surface_parcelle_m2 ?? 0) - 100);
const surface_min = typo.surface_min_m2 ?? 0;
if (surface_utile < surface_min)
return 0.0; // hard exclude
const surface_optimale = typo.surface_optimale_m2 ?? 500;
const ratio = surface_utile / surface_optimale;
return clamp01(0.4 + 0.6 * Math.min(ratio, 1));
}
/**
* score_temps_dispo — modulation par disponibilité utilisateur
*/
export function score_temps_dispo(typo, inputs) {
const td = inputs.temps_dispo ?? 2;
const pose = typo.complexite_pose ?? 2;
const diy = typo.flag_diy ?? false;
if (td === 1) {
if (pose === 1 && diy)
return 0.8;
if (pose === 3)
return 0.4;
return 0.6;
}
if (td === 3) {
if (diy)
return 1.0;
if (pose === 3)
return 0.7;
return 0.7;
}
return 0.7; // td === 2
}
/**
* score_metres_haie — pertinent pour haies
*/
export function score_metres_haie(typo, inputs) {
const metres = inputs.metres_haie_possibles ?? 0;
// Origine : `if (!param) …` sur le champ brut — une chaîne JSON (truthy)
// passe donc dans la branche « max ?? 100 ». Conservé tel quel.
const brut = typo.parametre_dim;
if (!brut)
return metres > 10 ? 0.6 : 0.3;
const max = paramBrut(typo)?.max ?? 100;
return clamp01(0.3 + 0.7 * Math.min(metres / max, 1));
}
/**
* score_couplage_disponible — bonus synergies, calculé post-sélection initiale (étape 4)
* @param typologies_selectionnees — codes déjà sélectionnés
*/
export function score_couplage_disponible(typo, _inputs, _contexte, _poste, typologies_selectionnees = []) {
const requis = parseJsonArr(typo.couplages_requis);
const exclus = parseJsonArr(typo.couplages_exclus);
const has_requis = requis.some((c) => typologies_selectionnees.includes(c));
const has_exclus = exclus.some((c) => typologies_selectionnees.includes(c));
if (has_exclus)
return 0.0;
if (has_requis)
return 1.0;
return 0.5;
}
/** Facteur d'orientation PV depuis la table de coefficients ; undefined si orientation inconnue. */
export function facteurOrientation(reglages, orientation) {
if (!orientation)
return undefined;
const table = reglages.coefficients.orientation_pv.valeur;
return table[orientation];
}
+8
View File
@@ -0,0 +1,8 @@
import type { Autodiag, Axe, AxeResultat, Contexte, Option, Poste, Reglages } from './types.js';
/**
* Sélectionne les meilleures typologies pour un axe donné.
*
* @param budget_axe — EUR alloué à cet axe
* @param axe — "eau" | "energie" | "alim"
*/
export declare function selectionnePoste(budget_axe: number, inputs: Autodiag, contexte_derived: Contexte, options: Option[], postes: Poste[], axe: Axe, reglages: Reglages, debug?: boolean): AxeResultat;
+148
View File
@@ -0,0 +1,148 @@
// selectionne-poste.ts — Étape 3 : sélection des typologies par poste
// Spec: V2-algo-spec.md §Étape 3 + V2-algo-spec-FONCTIONS.md §3
// Origine calculs/src/lib/autonomie/selectionne-poste.js.
// Correction LOT P1.0 : `reglages` en paramètre (plus de getConfig()) ; le
// console.warn de debug est versé dans `debug_log` du résultat.
import { adequation_contexte } from './adequation.js';
import { dimension_optimale, cout_typologie } from './dimension.js';
import { parseJsonArr } from './types.js';
/**
* Sélectionne les meilleures typologies pour un axe donné.
*
* @param budget_axe — EUR alloué à cet axe
* @param axe — "eau" | "energie" | "alim"
*/
export function selectionnePoste(budget_axe, inputs, contexte_derived, options, postes, axe, reglages, debug = false) {
const config = reglages.config;
const bonus_malus = config.bonus_malus || {};
const postes_poids_cfg = config.postes_poids || {};
const tolerances = config.tolerances || {};
const tolerance_pct = (tolerances.tolerance_par_poste_pct || 20) / 100;
const debug_log = debug ? [] : undefined;
// Normaliser le code axe pour matcher la BDD ("alimentation" dans postes BDD)
const axe_bdd = axe === 'alim' ? 'alimentation' : axe;
// Postes de l'axe, triés par ordre d'affichage
const postes_axe = postes.filter((p) => p.axe === axe_bdd && p.actif !== false).sort((a, b) => (a.ordre || 0) - (b.ordre || 0));
if (postes_axe.length === 0) {
return { postes: [], sous_total_eur_ht: 0, budget_alloue: budget_axe, poids: 0 };
}
// Enrichir les postes avec poids depuis config (fallback 50)
const postes_enrichis = postes_axe.map((p) => ({
...p,
poids_effectif: postes_poids_cfg[p.code] && postes_poids_cfg[p.code].poids != null ? postes_poids_cfg[p.code].poids : 50,
}));
// Poids total pour répartition budget
const poids_total = postes_enrichis.reduce((s, p) => s + p.poids_effectif, 0);
const resultats_postes = [];
for (const poste of postes_enrichis) {
// Budget alloué à ce poste (proportionnel au poids)
const budget_poste = poids_total > 0 ? (budget_axe * poste.poids_effectif) / poids_total : budget_axe / postes_enrichis.length;
// Si alim avec budget 0 (parcelle trop petite) → skip
if (budget_axe === 0)
continue;
// Typologies disponibles pour ce poste
const typos_poste = options.filter((o) => o.poste_id === poste.Id && o.actif !== false);
if (typos_poste.length === 0)
continue;
// Filtrer par accessibilité contexte (§3.4)
let typos_filtrees = typos_poste.filter((typo) => {
// Exclure complexité 3 si temps_dispo = 1 (sauf si c'est la seule option)
if (inputs.temps_dispo === 1 && typo.complexite_pose === 3)
return false;
// Exclure PV sur toit nord
if (poste.code === 'energie_pv' && inputs.orientation_toit === 'nord')
return false;
return true;
});
// Garde-fou : si trop restrictif, revenir à toutes
if (typos_filtrees.length === 0)
typos_filtrees = typos_poste;
// Scorer chaque typologie (§3.5)
const scored = typos_filtrees.map((typo) => {
const dim = dimension_optimale(typo, inputs, contexte_derived, reglages.coefficients);
const cout = cout_typologie(typo, dim, inputs);
const adq_result = debug
? adequation_contexte(typo, inputs, poste, contexte_derived, reglages, [], true)
: adequation_contexte(typo, inputs, poste, contexte_derived, reglages, [], false);
const score_adequation = debug && adq_result && typeof adq_result === 'object' && 'score' in adq_result
? adq_result.score
: typeof adq_result === 'number'
? adq_result
: 0.5;
// Boost DIY temps=3
let score_utilite = (poste.poids_effectif || 50) * score_adequation;
if (inputs.temps_dispo === 3 && typo.flag_diy) {
score_utilite *= bonus_malus.boost_diy_temps3 || 1.3;
}
const score_final = cout > 0 ? score_utilite / cout : 0;
return {
typo,
dim,
cout: Math.round(cout),
score_adequation,
score_utilite,
score_final,
debug_adq: debug && typeof adq_result === 'object' ? adq_result : undefined,
};
});
// Trier par score_final décroissant
scored.sort((a, b) => b.score_final - a.score_final);
// §3.6 : sélectionner le meilleur dont cout <= budget_poste × (1 + tolerance)
// Ne PAS prendre un poste si le coût dépasse largement le budget (anti-overrun)
const seuil_strict = budget_poste * (1 + tolerance_pct);
// Seuil assoupli : si aucun poste ne rentre, prendre le moins cher
let choix = scored.find((s) => s.cout <= seuil_strict);
if (!choix && scored.length > 0) {
// Pas de typo dans le budget : prendre la moins chère
const par_cout = [...scored].sort((a, b) => a.cout - b.cout);
choix = par_cout[0];
if (debug_log)
debug_log.push('[selectionnePoste] Aucune typo dans budget pour poste ' + poste.code + ' (budget=' + Math.round(budget_poste) + ', min cout=' + choix.cout + ')');
}
if (!choix)
continue;
// Alternatives (2-4 autres options scorées)
const alternatives = scored
.filter((s) => s.typo.code !== choix.typo.code)
.slice(0, 3)
.map((s) => ({
code: s.typo.code,
nom_affichage: s.typo.nom,
cout_eur_ht: s.cout,
score_final: +s.score_final.toFixed(4),
dimension: s.dim ? { valeur: s.dim.valeur, unite: s.dim.unite } : null,
}));
const couplages_requis = parseJsonArr(choix.typo.couplages_requis);
const couplages_exclus = parseJsonArr(choix.typo.couplages_exclus);
resultats_postes.push({
poste_id: poste.code,
poste_nom: poste.poste,
obligatoire: poste.obligatoire || false,
budget_alloue_poste: Math.round(budget_poste),
typologie_choisie: choix.typo.code,
nom_affichage: choix.typo.nom,
description_pedago: choix.typo.description_pedago || '',
dimension: choix.dim || { valeur: choix.typo.quantite_default || 1, unite: choix.typo.unite || '' },
cout_eur_ht: choix.cout,
score_adequation: +choix.score_adequation.toFixed(3),
score_final: +choix.score_final.toFixed(4),
alternatives,
notes_techniques: choix.typo.notes_techniques || '',
flag_diy: choix.typo.flag_diy || false,
complexite_pose: choix.typo.complexite_pose || 2,
couplages_requis,
couplages_exclus,
manuel: false,
});
}
const sous_total = resultats_postes.reduce((s, p) => s + p.cout_eur_ht, 0);
const resultat = {
postes: resultats_postes,
sous_total_eur_ht: sous_total,
budget_alloue: budget_axe,
poids: 0, // rempli par l'orchestrateur
};
if (debug_log && debug_log.length)
resultat.debug_log = debug_log;
return resultat;
}
+180
View File
@@ -0,0 +1,180 @@
import type { TableCoefficients, Orientation } from '../../commun/coefficients.js';
import type { Contexte, LigneClimat } from '../../commun/contexte.js';
export type { Contexte, LigneClimat, Orientation };
export type Axe = 'eau' | 'energie' | 'alim';
export declare const AXES: readonly Axe[];
/** Réponses de l'autodiag utiles aux moteurs (toutes facultatives : le noyau applique ses défauts). */
export interface Autodiag {
code_postal?: string | number;
nb_personnes?: number;
surface_parcelle_m2?: number;
surface_toit_m2?: number;
orientation_toit?: Orientation | string;
pente_toit_deg?: number;
metres_haie_possibles?: number;
/** 1 = faire poser, 2 = participation ponctuelle, 3 = auto-pose. */
temps_dispo?: 1 | 2 | 3 | number;
}
/** Entrées de `selectionner()` : autodiag + budget total. */
export interface InputsSimulation extends Autodiag {
budget_eur: number;
}
export interface Poste {
Id: number;
axe: string;
code: string;
poste: string;
ordre?: number | null;
obligatoire?: boolean | null;
actif?: boolean | null;
[autre: string]: unknown;
}
export interface ParamDim {
nom?: string;
strategie?: string;
base?: number;
coef?: number;
min?: number;
max?: number;
step?: number;
default?: number;
unite?: string;
reserve_construit?: number;
}
export interface Option {
Id: number;
poste_id: number;
code: string;
nom: string;
typologie?: string | null;
actif?: boolean | null;
prix_mat_min?: number | null;
prix_mat_max?: number | null;
prix_pro_min?: number | null;
prix_pro_max?: number | null;
prix_par_unite?: number | null;
prix_fixe?: number | null;
unite?: string | null;
quantite_default?: number | null;
/** JSON sérialisé (NocoDB) ou objet déjà parsé. */
parametre_dim?: string | ParamDim | null;
couplages_requis?: string | string[] | null;
couplages_exclus?: string | string[] | null;
flag_diy?: boolean | null;
complexite_pose?: number | null;
accessible_fr?: boolean | null;
surface_min_m2?: number | null;
surface_optimale_m2?: number | null;
tags?: string[] | null;
description_pedago?: string | null;
notes_techniques?: string | null;
[autre: string]: unknown;
}
export interface AlgoConfig {
poids_axes_base?: Record<Axe, number>;
modulateurs?: Record<string, unknown>;
adequation_weights?: Record<string, Record<string, number>>;
tolerances?: {
budget_overrun_pct?: number;
budget_underspend_pct?: number;
tolerance_par_poste_pct?: number;
};
bonus_malus?: {
boost_diy_temps3?: number;
malus_pose3_temps1?: number;
malus_non_accessible_fr?: number;
};
couplages_alias?: Record<string, string>;
postes_poids?: Record<string, {
poids?: number;
}>;
/** Marqueur posé par axes-actifs.ts (configPourAxes). */
_axes_desactives?: Axe[];
[autre: string]: unknown;
}
/** Ce qu'un calcul reçoit en plus des données : heuristiques + coefficients sourcés. */
export interface Reglages {
config: AlgoConfig;
coefficients: TableCoefficients;
}
export interface Dimension {
valeur: number;
unite: string;
strategie?: string;
}
export interface Alternative {
code: string;
nom_affichage: string;
cout_eur_ht: number;
score_final: number;
dimension: {
valeur: number;
unite: string;
} | null;
}
export interface PosteResultat {
poste_id: string | number;
poste_nom: string;
obligatoire: boolean;
budget_alloue_poste: number;
typologie_choisie: string;
nom_affichage: string;
description_pedago: string;
dimension: Dimension;
cout_eur_ht: number;
score_adequation: number;
score_final: number;
alternatives: Alternative[];
notes_techniques: string;
flag_diy: boolean;
complexite_pose: number;
couplages_requis: string[];
couplages_exclus: string[];
manuel: boolean;
force_par_couplage?: boolean;
_axe?: Axe;
_downgraded?: boolean;
_upgraded?: boolean;
_added_cas_b?: boolean;
_conflit_avec?: string;
}
export interface AxeResultat {
postes: PosteResultat[];
sous_total_eur_ht: number;
budget_alloue: number;
poids: number;
/** Journal de débogage (seulement si debug). */
debug_log?: string[];
}
/** Sélection sur les trois axes (un axe peut manquer quand un moteur d'axe tourne seul). */
export type Selection = Partial<Record<Axe, AxeResultat>>;
export type Statut = 'OK' | 'BUDGET_INSUFFISANT' | 'AJOUTER_OPTIONNEL';
export interface PoidsAxes {
eau: number;
energie: number;
alim: number;
}
export interface EtapeDebug {
etape: number;
label: string;
[autre: string]: unknown;
}
export interface SortieSelection {
axes: Selection;
total_eur_ht: number;
budget_cible: number;
ecart_budget_pct: number;
status: Statut;
message_status?: string;
couplages: string[];
contexte: Contexte;
/** Avertissements du contexte (code postal inconnu → défauts). */
avertissements: string[];
debug?: {
steps: EtapeDebug[];
};
}
/** Parse un champ JSON sérialisé par NocoDB (tableau) ; tolère un tableau déjà parsé. */
export declare function parseJsonArr(val: unknown): string[];
/** Parse parametre_dim (string JSON ou objet). */
export declare function parseParam(raw: unknown): ParamDim | null;
+35
View File
@@ -0,0 +1,35 @@
// types.ts — types du noyau autonomie (V2 algo, calculs.trans-former.fr).
// Les formes de données (Poste, Option, LigneClimat) reflètent les JSON publiés
// par calculs ; les schémas de validation sont dans commun/schemas.ts.
export const AXES = ['eau', 'energie', 'alim'];
/** Parse un champ JSON sérialisé par NocoDB (tableau) ; tolère un tableau déjà parsé. */
export function parseJsonArr(val) {
if (!val || val === 'null')
return [];
if (Array.isArray(val))
return val;
if (typeof val !== 'string')
return [];
try {
const p = JSON.parse(val);
return Array.isArray(p) ? p : [];
}
catch {
return [];
}
}
/** Parse parametre_dim (string JSON ou objet). */
export function parseParam(raw) {
if (!raw)
return null;
if (typeof raw === 'object')
return raw;
if (typeof raw !== 'string')
return null;
try {
return JSON.parse(raw);
}
catch {
return null;
}
}
+29
View File
@@ -0,0 +1,29 @@
import type { FourchetteOuverte } from '../commun/argent.js';
import type { Option } from './noyau/types.js';
export type ModePose = 'auto' | 'pro';
export interface PrixOption extends FourchetteOuverte {
/** Vrai si le mode pro est demandé mais que l'option n'a pas de prix pro. */
autoOnly: boolean;
}
export interface Preset {
Id: number;
axe: string;
niveau: string;
actif?: boolean | null;
[autre: string]: unknown;
}
export interface PresetOption {
preset_id: number;
option_id: number;
quantite?: number | null;
[autre: string]: unknown;
}
/** Fourchette d'une option × quantité : colonnes prix_mat_* (auto-pose) ou prix_pro_* (faire poser). */
export declare function prixOption(opt: Option, qty: number | null | undefined, pose: ModePose): PrixOption;
/** Prix total d'un preset (somme de ses options × quantités) pour un mode de pose. */
export declare function prixPreset(preset: Preset, presetOptions: PresetOption[], optionsById: Record<number, Option>, pose: ModePose): {
min: number;
max: number;
};
/** Niveau de preset (light/standard/complet) dont le prix médian est le plus proche du montant cible, pour un axe (code BDD). */
export declare function presetLePlusProche(cible: number, axe_bdd: string, presets: Preset[], presetOptions: PresetOption[], optionsById: Record<number, Option>, pose: ModePose): string;
+59
View File
@@ -0,0 +1,59 @@
// prix.ts — fourchettes de prix d'une option selon le mode de pose, prix d'un
// preset, preset le plus proche d'un montant. Origine
// calculs/src/scripts/autonomie/prix.js + matchPresetNiveaux de simulation.js.
// Les prix sont des DONNÉES des options (publiées par calculs) ; ce module ne
// porte aucun montant. Le formatage (« 1 200 € HT ») reste côté site.
/** Fourchette d'une option × quantité : colonnes prix_mat_* (auto-pose) ou prix_pro_* (faire poser). */
export function prixOption(opt, qty, pose) {
const q = qty || opt.quantite_default || 1;
let min;
let max;
if (pose === 'auto') {
min = opt.prix_mat_min;
max = opt.prix_mat_max;
}
else {
min = opt.prix_pro_min != null ? opt.prix_pro_min : opt.prix_mat_min;
max = opt.prix_pro_max != null ? opt.prix_pro_max : opt.prix_mat_max;
}
if (min == null && max == null)
return { min: null, max: null, autoOnly: false };
return {
min: min != null ? min * q : null,
max: max != null ? max * q : null,
autoOnly: pose === 'pro' && opt.prix_pro_min == null && opt.prix_pro_max == null,
};
}
/** Prix total d'un preset (somme de ses options × quantités) pour un mode de pose. */
export function prixPreset(preset, presetOptions, optionsById, pose) {
let min = 0;
let max = 0;
for (const po of presetOptions) {
if (po.preset_id !== preset.Id)
continue;
const opt = optionsById[po.option_id];
if (!opt)
continue;
const p = prixOption(opt, po.quantite || 1, pose);
if (p.min == null)
continue;
min += p.min;
max += p.max != null ? p.max : p.min;
}
return { min, max };
}
/** Niveau de preset (light/standard/complet) dont le prix médian est le plus proche du montant cible, pour un axe (code BDD). */
export function presetLePlusProche(cible, axe_bdd, presets, presetOptions, optionsById, pose) {
let best = null;
let bestDist = Infinity;
for (const preset of presets.filter((p) => p.axe === axe_bdd && p.actif !== false)) {
const prix = prixPreset(preset, presetOptions, optionsById, pose);
const mid = (prix.min + prix.max) / 2;
const dist = Math.abs(mid - cible);
if (dist < bestDist) {
bestDist = dist;
best = preset;
}
}
return best ? best.niveau : 'standard';
}
+71
View File
@@ -0,0 +1,71 @@
import type { TableCoefficients } from '../commun/coefficients.js';
import type { Contexte } from '../commun/contexte.js';
import { Trace } from '../commun/contrat.js';
import type { PosteResultat } from './noyau/types.js';
export interface DetailEau {
captable_m3: number;
besoin_m3: number;
besoin_substituable_m3: number;
}
export interface DetailEnergie {
kwc: number;
kwh_par_kwc: number;
facteur_orientation: number;
production_kwh: number;
batterie_kwh: number;
taux_autoconso: number;
autoconsomme_kwh: number;
besoin_kwh: number;
}
export interface DetailAlim {
ml: number;
reco_ml: number;
}
/** kWc installés dans la sélection (dimensions en kWc). */
export declare function kwcSelectionnes(postes: PosteResultat[]): number;
/** kWh de batterie dans la sélection (dimensions en kWh). */
export declare function batterieSelectionnee(postes: PosteResultat[]): number;
/** Mètres linéaires de haie dans la sélection. */
export declare function mlHaiesSelectionnes(postes: PosteResultat[]): number;
/** Vrai si la sélection porte un équipement de captage. */
export declare function aCaptage(postes: PosteResultat[]): boolean;
/**
* EAU : volume captable vs besoin substituable. null si aucun captage.
*/
export declare function tauxEau(entrees: {
surface_toit_m2?: number;
nb_personnes?: number;
pluviometrie_mm: number;
a_captage: boolean;
}, coefficients: TableCoefficients, trace: Trace): {
taux: number | null;
detail: DetailEau | null;
};
/** Taux d'autoconsommation selon la batterie : 0 kWh → sans, ≥ 10 kWh → avec, linéaire entre. */
export declare function tauxAutoconso(batterie_kwh: number, coefficients: TableCoefficients): number;
/** Productible PV (kWh/kWc/an) pour un contexte, depuis la table de coefficients. */
export declare function productiblePv(contexte: Pick<Contexte, 'zone_climatique_rt' | 'zone_biogeo_code'>, coefficients: TableCoefficients, trace: Trace): number;
/**
* ÉNERGIE : production PV autoconsommée vs besoin d'électricité spécifique.
* null si aucun kWc dans la sélection.
*/
export declare function tauxEnergie(entrees: {
kwc: number;
batterie_kwh: number;
orientation_toit?: string;
nb_personnes?: number;
contexte: Pick<Contexte, 'zone_climatique_rt' | 'zone_biogeo_code'>;
}, coefficients: TableCoefficients, trace: Trace): {
taux: number | null;
detail: DetailEnergie | null;
};
/**
* ALIMENTATION : mètres de haie vs recommandation. null si aucune haie.
*/
export declare function tauxAlim(entrees: {
ml: number;
nb_personnes?: number;
}, coefficients: TableCoefficients, trace: Trace): {
taux: number | null;
detail: DetailAlim | null;
};
+135
View File
@@ -0,0 +1,135 @@
// taux.ts — taux de couverture par axe, depuis une sélection de postes.
// Origine calculs/src/scripts/autonomie/taux.js (formules 99-brief §7), sorti
// du view-model : les entrées sont des grandeurs, plus l'état de l'interface.
// Coefficients lus dans la table injectée ; chaque lecture est tracée.
//
// Changement d'harmonisation (LOT P1.0, point 5) : la couverture ÉNERGIE
// applique le taux d'autoconsommation (0,30 sans batterie, 0,70 avec 10 kWh,
// interpolation linéaire entre les deux) — l'origine comparait la production
// brute au besoin, ce qui surestimait la couverture.
import { Trace } from '../commun/contrat.js';
function lit(trace, coefficients, cle, valeur) {
const c = coefficients[cle];
trace.source(cle, valeur, c.source, c.date);
}
/** kWc installés dans la sélection (dimensions en kWc). */
export function kwcSelectionnes(postes) {
let kwc = 0;
for (const p of postes)
if (p.dimension && p.dimension.unite === 'kWc')
kwc += p.dimension.valeur || 0;
return kwc;
}
/** kWh de batterie dans la sélection (dimensions en kWh). */
export function batterieSelectionnee(postes) {
let kwh = 0;
for (const p of postes)
if (p.dimension && p.dimension.unite === 'kWh')
kwh += p.dimension.valeur || 0;
return kwh;
}
/** Mètres linéaires de haie dans la sélection. */
export function mlHaiesSelectionnes(postes) {
let ml = 0;
for (const p of postes)
if (p.dimension && p.dimension.unite === 'ml')
ml += p.dimension.valeur || 0;
return ml;
}
/** Vrai si la sélection porte un équipement de captage. */
export function aCaptage(postes) {
return postes.some((p) => String(p.typologie_choisie).startsWith('captage') || String(p.poste_id).includes('captage'));
}
/**
* EAU : volume captable vs besoin substituable. null si aucun captage.
*/
export function tauxEau(entrees, coefficients, trace) {
if (!entrees.a_captage)
return { taux: null, detail: null };
const nb = entrees.nb_personnes || 1;
const rendement = coefficients.captage_rendement.valeur;
const besoin_l_an = coefficients.besoin_eau_l_j_pers.valeur * 365;
const part = coefficients.part_eau_substituable.valeur;
lit(trace, coefficients, 'captage_rendement', rendement);
lit(trace, coefficients, 'besoin_eau_l_j_pers', coefficients.besoin_eau_l_j_pers.valeur);
lit(trace, coefficients, 'part_eau_substituable', part);
const captableL = (entrees.surface_toit_m2 || 0) * (entrees.pluviometrie_mm / 1000) * rendement * 1000;
const besoinSubstL = nb * besoin_l_an * part;
if (!(besoinSubstL > 0 && captableL > 0))
return { taux: null, detail: null };
return {
taux: Math.min(100, Math.round((captableL / besoinSubstL) * 100)),
detail: {
captable_m3: Math.round(captableL / 1000),
besoin_m3: Math.round((nb * besoin_l_an) / 1000),
besoin_substituable_m3: Math.round(besoinSubstL / 1000),
},
};
}
/** Taux d'autoconsommation selon la batterie : 0 kWh → sans, ≥ 10 kWh → avec, linéaire entre. */
export function tauxAutoconso(batterie_kwh, coefficients) {
const sans = coefficients.autoconso_sans_batterie.valeur;
const avec = coefficients.autoconso_avec_batterie_10kwh.valeur;
const t = Math.max(0, Math.min(1, batterie_kwh / 10));
return sans + (avec - sans) * t;
}
/** Productible PV (kWh/kWc/an) pour un contexte, depuis la table de coefficients. */
export function productiblePv(contexte, coefficients, trace) {
const c = coefficients.productible_pv_kwh_kwc.valeur;
const cle = c.par === 'zone_rt' ? contexte.zone_climatique_rt : String(contexte.zone_biogeo_code);
const v = c.table[cle];
if (v == null) {
trace.avertit(`productible PV : aucune valeur pour ${c.par} « ${cle} », défaut ${c.defaut} kWh/kWc`);
return c.defaut;
}
if (c.par === 'zone_rt' && cle === 'H2d')
trace.avertit('productible PV H2d interpolé entre H2c et H3 (non sourcé)');
return v;
}
/**
* ÉNERGIE : production PV autoconsommée vs besoin d'électricité spécifique.
* null si aucun kWc dans la sélection.
*/
export function tauxEnergie(entrees, coefficients, trace) {
if (!(entrees.kwc > 0))
return { taux: null, detail: null };
const nb = entrees.nb_personnes || 1;
const kwh_par_kwc = productiblePv(entrees.contexte, coefficients, trace);
lit(trace, coefficients, 'productible_pv_kwh_kwc', kwh_par_kwc);
const table = coefficients.orientation_pv.valeur;
const coef = (entrees.orientation_toit ? table[entrees.orientation_toit] : undefined) ?? 1;
if (entrees.orientation_toit && table[entrees.orientation_toit] == null)
trace.avertit(`orientation « ${entrees.orientation_toit} » inconnue : facteur 1 appliqué`);
lit(trace, coefficients, 'orientation_pv', coef);
const production = entrees.kwc * kwh_par_kwc * coef;
const autoconso = tauxAutoconso(entrees.batterie_kwh, coefficients);
lit(trace, coefficients, 'autoconso_sans_batterie', coefficients.autoconso_sans_batterie.valeur);
lit(trace, coefficients, 'autoconso_avec_batterie_10kwh', coefficients.autoconso_avec_batterie_10kwh.valeur);
const besoin = nb * coefficients.besoin_elec_kwh_pers_an.valeur;
lit(trace, coefficients, 'besoin_elec_kwh_pers_an', coefficients.besoin_elec_kwh_pers_an.valeur);
const autoconsomme = production * autoconso;
return {
taux: Math.min(100, Math.round((autoconsomme / besoin) * 100)),
detail: {
kwc: entrees.kwc,
kwh_par_kwc,
facteur_orientation: coef,
production_kwh: Math.round(production),
batterie_kwh: entrees.batterie_kwh,
taux_autoconso: +autoconso.toFixed(3),
autoconsomme_kwh: Math.round(autoconsomme),
besoin_kwh: besoin,
},
};
}
/**
* ALIMENTATION : mètres de haie vs recommandation. null si aucune haie.
*/
export function tauxAlim(entrees, coefficients, trace) {
const nb = entrees.nb_personnes || 1;
const reco = nb * coefficients.ml_haie_par_pers.valeur;
lit(trace, coefficients, 'ml_haie_par_pers', coefficients.ml_haie_par_pers.valeur);
if (!(entrees.ml > 0 && reco > 0))
return { taux: null, detail: null };
return { taux: Math.min(100, Math.round((entrees.ml / reco) * 100)), detail: { ml: entrees.ml, reco_ml: reco } };
}
+28
View File
@@ -0,0 +1,28 @@
export interface Fourchette {
min: number;
max: number;
}
/** Fourchette dont les deux bornes peuvent manquer (prix pro absent, etc.). */
export interface FourchetteOuverte {
min: number | null;
max: number | null;
}
export declare function fourchette(min: number, max: number): Fourchette;
export declare function pointMedian(f: Fourchette): number;
/** Somme de fourchettes ; une borne manquante est remplacée par l'autre. */
export declare function additionneFourchettes(liste: FourchetteOuverte[]): Fourchette;
/** Arrondi au pas donné (pas ≤ 0 → valeur inchangée). */
export declare function arrondiAuPas(valeur: number, pas: number): number;
/** Arrondi à deux décimales. */
export declare function arrondi2(n: number): number;
/**
* Mensualité d'un prêt à taux fixe (formule d'annuité constante).
* @param capital montant emprunté
* @param taux_annuel_pct taux nominal annuel en % (0 → capital / nombre de mois)
* @param duree_annees durée en années
*/
export declare function mensualite(capital: number, taux_annuel_pct: number, duree_annees: number): number;
/** Annuité (12 mensualités). */
export declare function annuite(capital: number, taux_annuel_pct: number, duree_annees: number): number;
/** Coût total du crédit (intérêts seuls). */
export declare function coutDuCredit(capital: number, taux_annuel_pct: number, duree_annees: number): number;
+57
View File
@@ -0,0 +1,57 @@
// argent.ts — fourchettes, arrondis, annuité d'un prêt. Fonctions pures,
// sans formatage (l'affichage « 1 200 € HT » appartient aux sites, pas au
// paquet : Intl dépend de l'environnement).
export function fourchette(min, max) {
return min <= max ? { min, max } : { min: max, max: min };
}
export function pointMedian(f) {
return (f.min + f.max) / 2;
}
/** Somme de fourchettes ; une borne manquante est remplacée par l'autre. */
export function additionneFourchettes(liste) {
let min = 0;
let max = 0;
for (const f of liste) {
if (f.min == null && f.max == null)
continue;
const a = f.min ?? f.max ?? 0;
const b = f.max ?? f.min ?? 0;
min += a;
max += b;
}
return { min, max };
}
/** Arrondi au pas donné (pas ≤ 0 → valeur inchangée). */
export function arrondiAuPas(valeur, pas) {
if (!pas || pas <= 0)
return valeur;
return Math.round(valeur / pas) * pas;
}
/** Arrondi à deux décimales. */
export function arrondi2(n) {
return Math.round(n * 100) / 100;
}
/**
* Mensualité d'un prêt à taux fixe (formule d'annuité constante).
* @param capital montant emprunté
* @param taux_annuel_pct taux nominal annuel en % (0 → capital / nombre de mois)
* @param duree_annees durée en années
*/
export function mensualite(capital, taux_annuel_pct, duree_annees) {
const n = Math.round(duree_annees * 12);
if (n <= 0 || capital <= 0)
return 0;
const r = taux_annuel_pct / 100 / 12;
if (r === 0)
return capital / n;
return (capital * r) / (1 - Math.pow(1 + r, -n));
}
/** Annuité (12 mensualités). */
export function annuite(capital, taux_annuel_pct, duree_annees) {
return mensualite(capital, taux_annuel_pct, duree_annees) * 12;
}
/** Coût total du crédit (intérêts seuls). */
export function coutDuCredit(capital, taux_annuel_pct, duree_annees) {
const n = Math.round(duree_annees * 12);
return mensualite(capital, taux_annuel_pct, duree_annees) * n - capital;
}
+69
View File
@@ -0,0 +1,69 @@
export type Orientation = 'sud' | 'sud-est' | 'sud-ouest' | 'est' | 'ouest' | 'nord';
export type ClasseRt = 'H1' | 'H2' | 'H3';
export interface Coefficient<T = number> {
valeur: T;
/** Document ou norme qui fixe la valeur ; « heuristique … » si non sourcée. */
source: string;
/** Date du document source (AAAA-MM-JJ). */
date: string;
/** Fourchette, variante, réserve : ce que Jules doit savoir pour mettre un veto. */
note?: string;
}
export interface TableCoefficients {
/** Rendement de captage d'une toiture (part de la pluie réellement collectée). */
captage_rendement: Coefficient;
/** Rendement par type de toiture, pour un formulaire enrichi (non utilisé par les moteurs v0.1). */
captage_rendement_par_toiture: Coefficient<Record<'tuile' | 'ardoise' | 'gravillonnee' | 'vegetalisee', number>>;
/** Besoin domestique total, litres par jour et par personne. */
besoin_eau_l_j_pers: Coefficient;
/** Part du besoin substituable par l'eau de pluie (WC, douche, lessive, jardin). */
part_eau_substituable: Coefficient;
/** Facteur de production PV selon l'orientation du toit (sud 30° = 1). */
orientation_pv: Coefficient<Record<Orientation, number>>;
/** Productible PV annuel (kWh/kWc/an), par zone : `par` dit quelle clé du contexte indexe la table. */
productible_pv_kwh_kwc: Coefficient<{
par: 'zone_rt' | 'zone_biogeo_code';
table: Record<string, number>;
defaut: number;
}>;
/** Part de la production PV autoconsommée, sans batterie. */
autoconso_sans_batterie: Coefficient;
/** Part de la production PV autoconsommée, avec 10 kWh de batterie. */
autoconso_avec_batterie_10kwh: Coefficient;
/** Besoin d'électricité spécifique, kWh par personne et par an (hors chauffage électrique). */
besoin_elec_kwh_pers_an: Coefficient;
/** DJU base 18 par défaut, par classe de zone RT, si la ligne climat n'en porte pas. */
dju_defaut_par_classe_rt: Coefficient<Record<ClasseRt, number>>;
/** Mètres linéaires de haie fruitière par personne pour couvrir les fruits d'un foyer. */
ml_haie_par_pers: Coefficient;
/** Contexte climatique appliqué quand le département est inconnu (déclaré en avertissement). */
contexte_defaut: Coefficient<{
zone_rt: string;
zone_biogeo_code: number;
pluviometrie_mm: number;
dju: number;
}>;
/** SHAB estimée = emprise × niveaux × ce ratio (murs, refends, escalier). */
geometrie_ratio_shab_emprise: Coefficient;
/** Surface vitrée = SHAB × ce ratio. */
geometrie_ratio_vitrage_shab: Coefficient;
/** Hauteur d'un niveau par défaut (m), si la hauteur totale n'est pas connue. */
geometrie_hauteur_etage_m: Coefficient;
/** Périmètre estimé = 4 × √(emprise × facteur) (rectangle allongé). */
geometrie_facteur_perimetre: Coefficient;
/** Part du périmètre retirée par côté mitoyen. */
geometrie_part_mur_mitoyen: Coefficient;
/** Majoration de la surface de toiture (débords, recouvrements). */
geometrie_majoration_toiture: Coefficient;
/** Pente de toit par défaut (degrés) si inconnue. */
geometrie_pente_defaut_deg: Coefficient;
}
/** Table harmonisée (LOT P1.0, 28/09/2026). Référence tant que Jules ne met pas de veto (R-7). */
export declare const COEFFICIENTS: TableCoefficients;
/** Clés de la table, pour valider un jeu de coefficients injecté (fixture, veto). */
export declare const CLES_COEFFICIENTS: (keyof TableCoefficients)[];
/**
* Vérifie qu'un objet a la forme d'une table de coefficients (toutes les clés,
* chaque entrée avec valeur, source et date). Renvoie la liste des défauts.
*/
export declare function verifieTableCoefficients(table: unknown): string[];
+160
View File
@@ -0,0 +1,160 @@
// coefficients.ts — chaque grandeur physique UNE fois, avec source et date.
//
// Règle : le document de recherche l'emporte sur le code quand ils divergent
// (SPEC phase 1 §4.1, LOT P1.0 point 5). La table lisible par Jules est
// docs/CONSTANTES.md (une ligne par grandeur : ancienne(s) valeur(s) →
// retenue, source, date). Le jeu de coefficients d'ORIGINE (valeurs du code
// de calculs.trans-former.fr au 28/09/2026) vit dans
// fixtures/coefficients-2026-05.json et sert au test de non-régression.
//
// Les poids d'axes, modulateurs, matrices d'adéquation et tolérances de
// algo-config.json ne sont PAS ici : ce sont des heuristiques de conception,
// non sourcées, livrées comme données (fixtures/algo-config.json) et injectées.
const D_RECHERCHE = '2026-04-28';
const D_LOT = '2026-09-28';
const S_EAU = '1 PROJETS/DEV/Simulateur autonomie V2/01-recherche-eau.md';
const S_ENERGIE = '1 PROJETS/DEV/Simulateur autonomie V2/02-recherche-energie.md';
const S_BRIEF = '1 PROJETS/DEV/Simulateur autonomie V2/99-brief-atis-dev.md';
const S_V1 = 'calculs.trans-former.fr, simulateur autoconstruction V1 (public/simulateurs/autoconstruction/app.js, calcSurfaces)';
/** Table harmonisée (LOT P1.0, 28/09/2026). Référence tant que Jules ne met pas de veto (R-7). */
export const COEFFICIENTS = {
captage_rendement: {
valeur: 0.8,
source: `${S_EAU} §3 « Captable max » et §7 n°6 (NF EN 16941-1, recueil Nény p.16)`,
date: D_RECHERCHE,
note: 'fourchette 0,75 (zone sèche) à 0,85 (zone humide, gouttières optimisées) ; le code portait 0,7 (scoreurs), 0,8 (taux) et 0,85 (V1 bioclimatique)',
},
captage_rendement_par_toiture: {
valeur: { tuile: 0.85, ardoise: 0.85, gravillonnee: 0.5, vegetalisee: 0.3 },
source: `${S_EAU} §7 n°6 (ADEME, réf. NF EN 16941-1)`,
date: D_RECHERCHE,
note: 'à brancher quand le formulaire demande le type de toiture ; non utilisé en v0.1',
},
besoin_eau_l_j_pers: {
valeur: 150,
source: `${S_EAU} §3 « Besoin eau » (INSEE / Eau France 2024) ; ${S_BRIEF} §7.1`,
date: D_RECHERCHE,
note: '54 750 L/an/personne ; le V1 comptait 46,5 m³/an',
},
part_eau_substituable: {
valeur: 0.7,
source: `${S_EAU} §3 ; ${S_BRIEF} §7.1 (WC 30 + douche 60 + lessive 30 + jardin 25 L/j sur 150)`,
date: D_RECHERCHE,
note: 'le V1 comptait 32 % (15 m³ sur 46,5)',
},
orientation_pv: {
valeur: { sud: 1, 'sud-est': 0.95, 'sud-ouest': 0.95, est: 0.8, ouest: 0.8, nord: 0.6 },
source: `${S_ENERGIE} §3.2 « Variantes orientation toiture »`,
date: D_RECHERCHE,
note: 'source : SE/SO 95 %, E/O 80-85 %, N 60-65 % (bas de fourchette retenu) ; le brief §7.2 prenait les milieux 0,82 et 0,62 ; le code portait 0,93 / 0,78 / 0,40',
},
productible_pv_kwh_kwc: {
valeur: {
par: 'zone_rt',
table: { H1a: 1000, H1b: 1050, H1c: 1200, H2a: 1100, H2b: 1200, H2c: 1250, H2d: 1300, H3: 1400 },
defaut: 1100,
},
source: `${S_ENERGIE} §3.2 (PVGIS, sud 30°, pertes 14 %)`,
date: D_RECHERCHE,
note: 'H1a : moyenne Lille 950 / Paris 1 050 ; H2d : interpolé entre H2c et H3 par la MOE, non sourcé (déclaré en avertissement) ; défaut = H2a ; le code portait une table par zone biogéographique allant jusqu\'à 1 550, au-dessus du maximum de la source',
},
autoconso_sans_batterie: {
valeur: 0.3,
source: `${S_ENERGIE} §3.2 ; ${S_BRIEF} §7.2`,
date: D_RECHERCHE,
note: 'absent du code : la couverture énergie comparait la production brute au besoin',
},
autoconso_avec_batterie_10kwh: {
valeur: 0.7,
source: `${S_ENERGIE} §3.2 ; ${S_BRIEF} §7.2`,
date: D_RECHERCHE,
note: 'entre 0 et 10 kWh de batterie, le moteur interpole linéairement (choix MOE, non sourcé)',
},
besoin_elec_kwh_pers_an: {
valeur: 1000,
source: `${S_ENERGIE} §3.1 (ADEME 2023, RTE 2024) ; ${S_BRIEF} §7.2`,
date: D_RECHERCHE,
note: 'fourchette 900 à 1 200 ; hors chauffage électrique, ECS et véhicule',
},
dju_defaut_par_classe_rt: {
valeur: { H1: 2800, H2: 2200, H3: 1500 },
source: 'heuristique de conception V2 (calibrage.js et algo-config.json), non sourcée',
date: '2026-05-01',
note: 'repli seulement : la table france_climat porte un DJU par département',
},
ml_haie_par_pers: {
valeur: 25,
source: `${S_BRIEF} §7.3 (correction Jules du 28/04/2026) ; 03-recherche-alimentation.md §5`,
date: D_RECHERCHE,
note: '50 ml/personne pour un excédent transformable',
},
contexte_defaut: {
valeur: { zone_rt: 'H2a', zone_biogeo_code: 4, pluviometrie_mm: 800, dju: 2200 },
source: 'heuristique de conception V2 (algo.js deriveContexte, simulation.js), non sourcée',
date: '2026-05-01',
note: 'appliqué seulement si le département est absent de france_climat ; toujours déclaré en avertissement',
},
geometrie_ratio_shab_emprise: {
valeur: 0.8,
source: 'heuristique MOE (murs extérieurs ~0,3 m, refends, escalier), non sourcée',
date: D_LOT,
note: 'hypothèse porteuse du P1.2 : à confronter à la surface DPE des adresses A et B (écart visé < 20 %)',
},
geometrie_ratio_vitrage_shab: {
valeur: 1 / 6,
source: `${S_V1} (vit = S/6) ; règle RT 2012 (baies ≥ 1/6 de la SHAB)`,
date: '2025-01-01',
note: 'la SPEC §4.1 écrit « 15 % » ; 1/6 = 16,7 %',
},
geometrie_hauteur_etage_m: {
valeur: 2.5,
source: 'heuristique MOE (hauteur sous plafond courante), non sourcée',
date: D_LOT,
},
geometrie_facteur_perimetre: {
valeur: 1.3,
source: `${S_V1} (perimetre = √(S × 1,3) × 4)`,
date: '2025-01-01',
},
geometrie_part_mur_mitoyen: {
valeur: 0.25,
source: 'heuristique MOE (un côté mitoyen = un quart du périmètre), non sourcée',
date: D_LOT,
},
geometrie_majoration_toiture: {
valeur: 1.05,
source: `${S_V1} (toiture = S / cos α × 1,05)`,
date: '2025-01-01',
},
geometrie_pente_defaut_deg: {
valeur: 30,
source: 'heuristique MOE (pente courante des toitures à deux versants), non sourcée',
date: D_LOT,
},
};
/** Clés de la table, pour valider un jeu de coefficients injecté (fixture, veto). */
export const CLES_COEFFICIENTS = Object.keys(COEFFICIENTS);
/**
* Vérifie qu'un objet a la forme d'une table de coefficients (toutes les clés,
* chaque entrée avec valeur, source et date). Renvoie la liste des défauts.
*/
export function verifieTableCoefficients(table) {
const erreurs = [];
if (!table || typeof table !== 'object')
return ['table absente ou non objet'];
const t = table;
for (const cle of CLES_COEFFICIENTS) {
const c = t[cle];
if (!c || typeof c !== 'object') {
erreurs.push(`${cle} : absent`);
continue;
}
if (!('valeur' in c))
erreurs.push(`${cle} : valeur absente`);
if (typeof c.source !== 'string' || !c.source)
erreurs.push(`${cle} : source absente`);
if (typeof c.date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(c.date))
erreurs.push(`${cle} : date absente ou non AAAA-MM-JJ`);
}
return erreurs;
}
+49
View File
@@ -0,0 +1,49 @@
import type { Moteur, Resultat } from './contrat.js';
import { type TableCoefficients } from './coefficients.js';
export interface LigneClimat {
dept: string;
nom_dept?: string;
zone_rt: string;
pluvio_mm_an?: number | null;
dju_base_18?: number | null;
zone_biogeo?: number | null;
[autre: string]: unknown;
}
export type ClasseRt = 'H1' | 'H2' | 'H3';
export type ZoneBiogeo = 'C' | 'M' | 'OC' | 'MED';
export interface Contexte {
/** Département tel que trouvé (« 2A », « 971 », « 75 ») ou null si inconnu. */
departement: string | null;
zone_climatique_rt: string;
zone_rt_class: ClasseRt;
/** Zone écologique (biogéographique) en lettres : C, M, OC, MED. */
zone_biogeo: ZoneBiogeo;
/** Zone biogéographique numérique (1 à 8) telle que portée par france_climat. */
zone_biogeo_code: number;
pluviometrie_mm: number;
dju: number;
}
export interface EntreesContexte {
code_postal: string;
}
export interface DonneesContexte {
france_climat: LigneClimat[];
coefficients?: TableCoefficients;
}
/** Zone biogéographique numérique → libellé (V2 calibrage.js, inchangé). */
export declare const ZONE_BIOGEO_MAP: Record<number, ZoneBiogeo>;
/** Sous-zone RT (H1a, H2d…) → classe principale. */
export declare function zoneRtToClass(zone_rt: string | null | undefined): ClasseRt;
/**
* Département depuis un code postal : DOM (97x → trois caractères), Corse
* (20000-20190 → 2A, 20200-20620 → 2B, autre 20xxx → 2A), sinon deux chiffres.
* Reprend `deptFromCp` de calculs (scripts/autonomie/data.js), seule version
* qui gérait ces cas. Renvoie null si le code postal est inexploitable.
*/
export declare function departementDepuisCodePostal(code_postal: string | number | null | undefined): string | null;
/**
* Dérive le contexte climatique. Fonction pure, utilisée par le moteur
* `commun.contexte` et par le noyau autonomie (`selectionner`).
*/
export declare function deriveContexte(code_postal: string | number | null | undefined, climat: LigneClimat[], coefficients?: TableCoefficients): Resultat<Contexte>;
export declare const contexte: Moteur<EntreesContexte, DonneesContexte, Contexte>;
+102
View File
@@ -0,0 +1,102 @@
// contexte.ts — moteur `commun.contexte` : code postal → département → zone.
// UNE seule version (le noyau autonomie l'utilise aussi). Corse 2A/2B et
// DOM 971-976 corrigés : le code d'origine (algo.js) prenait deux chiffres et
// retombait sur H2a pour ces départements.
import { Trace } from './contrat.js';
import { COEFFICIENTS } from './coefficients.js';
import { VERSION } from '../version.js';
/** Zone biogéographique numérique → libellé (V2 calibrage.js, inchangé). */
export const ZONE_BIOGEO_MAP = {
1: 'C', // Continental
2: 'M', // Montagnard
3: 'OC', // Océanique côtier
4: 'OC', // Océanique intérieur
5: 'MED', // Méditerranéen continental
6: 'MED', // Méditerranéen côtier
7: 'MED', // Pré-alpin méditerranéen
8: 'OC', // Semi-océanique
};
/** Sous-zone RT (H1a, H2d…) → classe principale. */
export function zoneRtToClass(zone_rt) {
if (!zone_rt)
return 'H2';
if (zone_rt.startsWith('H1'))
return 'H1';
if (zone_rt.startsWith('H2'))
return 'H2';
if (zone_rt === 'H3')
return 'H3';
return 'H2';
}
/**
* Département depuis un code postal : DOM (97x → trois caractères), Corse
* (20000-20190 → 2A, 20200-20620 → 2B, autre 20xxx → 2A), sinon deux chiffres.
* Reprend `deptFromCp` de calculs (scripts/autonomie/data.js), seule version
* qui gérait ces cas. Renvoie null si le code postal est inexploitable.
*/
export function departementDepuisCodePostal(code_postal) {
const cp = String(code_postal ?? '').trim();
if (!/^\d{5}$/.test(cp))
return cp.length >= 2 && /^\d{2}/.test(cp) ? cp.slice(0, 2) : null;
if (cp.startsWith('97'))
return cp.slice(0, 3);
if (cp.startsWith('20')) {
const n = Number(cp);
if (n >= 20000 && n <= 20190)
return '2A';
if (n >= 20200 && n <= 20620)
return '2B';
return '2A';
}
return cp.slice(0, 2);
}
/**
* Dérive le contexte climatique. Fonction pure, utilisée par le moteur
* `commun.contexte` et par le noyau autonomie (`selectionner`).
*/
export function deriveContexte(code_postal, climat, coefficients = COEFFICIENTS) {
const trace = new Trace();
const departement = departementDepuisCodePostal(code_postal);
const ligne = departement ? climat.find((c) => c.dept === departement) : undefined;
if (!ligne) {
const d = coefficients.contexte_defaut;
trace.avertit(`contexte par défaut appliqué (${d.valeur.zone_rt}, ${d.valeur.pluviometrie_mm} mm, ${d.valeur.dju} DJU) : ` +
(departement ? `département ${departement} absent de france_climat` : `code postal « ${code_postal ?? ''} » inexploitable`));
trace.source('contexte_defaut', d.valeur.zone_rt, d.source, d.date);
return trace.resultat({
departement,
zone_climatique_rt: d.valeur.zone_rt,
zone_rt_class: zoneRtToClass(d.valeur.zone_rt),
zone_biogeo: ZONE_BIOGEO_MAP[d.valeur.zone_biogeo_code] ?? 'OC',
zone_biogeo_code: d.valeur.zone_biogeo_code,
pluviometrie_mm: d.valeur.pluviometrie_mm,
dju: d.valeur.dju,
}, VERSION);
}
const d = coefficients.contexte_defaut;
const zone_biogeo_code = ligne.zone_biogeo ?? d.valeur.zone_biogeo_code;
if (ligne.zone_biogeo == null)
trace.avertit(`zone biogéographique absente pour ${departement}, défaut ${d.valeur.zone_biogeo_code}`);
const pluviometrie_mm = ligne.pluvio_mm_an ?? d.valeur.pluviometrie_mm;
if (ligne.pluvio_mm_an == null)
trace.avertit(`pluviométrie absente pour ${departement}, défaut ${d.valeur.pluviometrie_mm} mm`);
const dju = ligne.dju_base_18 ?? d.valeur.dju;
if (ligne.dju_base_18 == null)
trace.avertit(`DJU absent pour ${departement}, défaut ${d.valeur.dju}`);
trace.source(`france_climat.${departement}`, ligne.zone_rt, String(ligne.source_climat ?? 'france_climat'), String(ligne.source_date ?? ''));
return trace.resultat({
departement,
zone_climatique_rt: ligne.zone_rt,
zone_rt_class: zoneRtToClass(ligne.zone_rt),
zone_biogeo: ZONE_BIOGEO_MAP[zone_biogeo_code] ?? 'OC',
zone_biogeo_code,
pluviometrie_mm,
dju,
}, VERSION);
}
export const contexte = {
id: 'commun.contexte',
calculer(entrees, donnees) {
return deriveContexte(entrees.code_postal, donnees.france_climat, donnees.coefficients ?? COEFFICIENTS);
},
};
+38
View File
@@ -0,0 +1,38 @@
/** Une constante ou une donnée effectivement utilisée par un calcul. */
export interface Source {
cle: string;
valeur: number | string;
source: string;
date: string;
}
export interface Resultat<S> {
sorties: S;
/** Tout coefficient et tout prix effectivement utilisé. */
sources: Source[];
/** Toute donnée manquante, toute valeur par défaut appliquée. */
avertissements: string[];
/** Étiquette du paquet (`vX.Y.Z`). */
version: string;
}
export interface Moteur<E, D, S> {
/** 'autonomie.eau', 'commun.geometrie', … */
id: string;
calculer(entrees: E, donnees: D): Resultat<S>;
}
/**
* Collecteur de sources et d'avertissements d'un calcul. Une source est
* enregistrée une fois par clé (la première valeur gagne) ; un avertissement
* une fois par texte. Objet interne, sans I/O.
*/
export declare class Trace {
private readonly sources;
private readonly avertissements;
source(cle: string, valeur: number | string, source: string, date: string): void;
avertit(texte: string): void;
/** Verse dans ce collecteur les sources et avertissements d'un autre résultat. */
absorbe(autre: {
sources: Source[];
avertissements: string[];
}): void;
resultat<S>(sorties: S, version: string): Resultat<S>;
}
+41
View File
@@ -0,0 +1,41 @@
// contrat.ts — le contrat d'un moteur (SPEC phase 1 §4.1).
//
// Quatre règles opposables au code :
// ① `calculer` ne lit ni réseau, ni disque, ni horloge, ni DOM, ni variable
// globale, ni aléa : mêmes entrées + mêmes données = même résultat.
// ② les données entrent en paramètre, validées à la frontière par l'appelant
// (`valider(table, json)`) ;
// ③ aucun prix, aucun barème dans le paquet ;
// ④ toute valeur par défaut appliquée est déclarée dans `avertissements`,
// toute constante utilisée dans `sources`.
/**
* Collecteur de sources et d'avertissements d'un calcul. Une source est
* enregistrée une fois par clé (la première valeur gagne) ; un avertissement
* une fois par texte. Objet interne, sans I/O.
*/
export class Trace {
sources = new Map();
avertissements = new Set();
source(cle, valeur, source, date) {
if (!this.sources.has(cle))
this.sources.set(cle, { cle, valeur, source, date });
}
avertit(texte) {
this.avertissements.add(texte);
}
/** Verse dans ce collecteur les sources et avertissements d'un autre résultat. */
absorbe(autre) {
for (const s of autre.sources)
this.source(s.cle, s.valeur, s.source, s.date);
for (const a of autre.avertissements)
this.avertit(a);
}
resultat(sorties, version) {
return {
sorties,
sources: [...this.sources.values()],
avertissements: [...this.avertissements],
version,
};
}
}
+37
View File
@@ -0,0 +1,37 @@
import type { Moteur } from './contrat.js';
import { type TableCoefficients } from './coefficients.js';
export interface EntreesGeometrie {
/** Emprise au sol du bâti (m²). */
emprise_m2: number;
/** Nombre de niveaux habitables (1 = plain-pied). */
niveaux?: number;
/** Hauteur totale du bâti hors combles (m) ; sinon niveaux × hauteur d'étage par défaut. */
hauteur_m?: number;
/** Pente du toit (degrés) ; sinon pente par défaut. */
pente_toit_deg?: number;
/** Côtés mitoyens (0, 1 ou 2). */
mitoyennete?: 0 | 1 | 2;
/** Surface habitable connue (DPE, acte) : remplace l'estimation. */
surface_habitable_m2?: number;
}
export interface DonneesGeometrie {
coefficients?: TableCoefficients;
}
export interface SortiesGeometrie {
/** Surface habitable, connue ou estimée (m²). */
shab_m2: number;
shab_estimee: boolean;
perimetre_m: number;
/** Périmètre exposé après retrait des côtés mitoyens (m). */
perimetre_expose_m: number;
murs_exterieurs_m2: number;
murs_opaques_m2: number;
vitrage_m2: number;
toiture_m2: number;
plancher_bas_m2: number;
volume_m3: number;
hauteur_m: number;
hauteur_etage_m: number;
}
export declare function calculeGeometrie(entrees: EntreesGeometrie, coefficients?: TableCoefficients): import("./contrat.js").Resultat<SortiesGeometrie>;
export declare const geometrie: Moteur<EntreesGeometrie, DonneesGeometrie, SortiesGeometrie>;
+79
View File
@@ -0,0 +1,79 @@
// geometrie.ts — moteur `commun.geometrie` : emprise, niveaux, hauteur, pente,
// mitoyenneté → surfaces de parois, SHAB estimée, volume. Reprend les formules
// de `calcSurfaces` du simulateur autoconstruction V1 (calculs), sans le DOM,
// avec les coefficients sortis dans coefficients.ts.
import { Trace } from './contrat.js';
import { COEFFICIENTS } from './coefficients.js';
import { arrondi2 } from './argent.js';
import { VERSION } from '../version.js';
export function calculeGeometrie(entrees, coefficients = COEFFICIENTS) {
const trace = new Trace();
const c = coefficients;
const emprise = Math.max(0, entrees.emprise_m2 || 0);
const niveaux = Math.max(1, Math.round(entrees.niveaux ?? 1));
if (entrees.niveaux == null)
trace.avertit('nombre de niveaux inconnu : 1 niveau supposé');
let hauteur = entrees.hauteur_m ?? 0;
if (!(hauteur > 0)) {
hauteur = niveaux * c.geometrie_hauteur_etage_m.valeur;
trace.avertit(`hauteur inconnue : ${niveaux} × ${c.geometrie_hauteur_etage_m.valeur} m supposés`);
trace.source('geometrie_hauteur_etage_m', c.geometrie_hauteur_etage_m.valeur, c.geometrie_hauteur_etage_m.source, c.geometrie_hauteur_etage_m.date);
}
const hauteur_etage = hauteur / niveaux;
let pente = entrees.pente_toit_deg;
if (pente == null || !(pente >= 0) || pente >= 90) {
pente = c.geometrie_pente_defaut_deg.valeur;
trace.avertit(`pente de toit inconnue : ${pente}° supposés`);
trace.source('geometrie_pente_defaut_deg', pente, c.geometrie_pente_defaut_deg.source, c.geometrie_pente_defaut_deg.date);
}
const mitoyen = Math.min(2, Math.max(0, Math.round(entrees.mitoyennete ?? 0)));
// SHAB
let shab;
let shab_estimee;
if (entrees.surface_habitable_m2 && entrees.surface_habitable_m2 > 0) {
shab = entrees.surface_habitable_m2;
shab_estimee = false;
}
else {
shab = emprise * niveaux * c.geometrie_ratio_shab_emprise.valeur;
shab_estimee = true;
trace.avertit('surface habitable estimée depuis l\'emprise (aucune surface connue)');
trace.source('geometrie_ratio_shab_emprise', c.geometrie_ratio_shab_emprise.valeur, c.geometrie_ratio_shab_emprise.source, c.geometrie_ratio_shab_emprise.date);
}
// Périmètre (V1 : √(S × 1,3) × 4) et part exposée
const perimetre = 4 * Math.sqrt(emprise * c.geometrie_facteur_perimetre.valeur);
trace.source('geometrie_facteur_perimetre', c.geometrie_facteur_perimetre.valeur, c.geometrie_facteur_perimetre.source, c.geometrie_facteur_perimetre.date);
const perimetre_expose = perimetre * (1 - c.geometrie_part_mur_mitoyen.valeur * mitoyen);
if (mitoyen > 0)
trace.source('geometrie_part_mur_mitoyen', c.geometrie_part_mur_mitoyen.valeur, c.geometrie_part_mur_mitoyen.source, c.geometrie_part_mur_mitoyen.date);
// Parois
const murs_exterieurs = perimetre_expose * hauteur;
const vitrage = shab * c.geometrie_ratio_vitrage_shab.valeur;
trace.source('geometrie_ratio_vitrage_shab', arrondi2(c.geometrie_ratio_vitrage_shab.valeur), c.geometrie_ratio_vitrage_shab.source, c.geometrie_ratio_vitrage_shab.date);
const murs_opaques = Math.max(0, murs_exterieurs - vitrage);
const rad = (pente * Math.PI) / 180;
const toiture = (emprise / Math.cos(rad)) * c.geometrie_majoration_toiture.valeur;
trace.source('geometrie_majoration_toiture', c.geometrie_majoration_toiture.valeur, c.geometrie_majoration_toiture.source, c.geometrie_majoration_toiture.date);
const volume = shab * hauteur_etage;
const sorties = {
shab_m2: arrondi2(shab),
shab_estimee,
perimetre_m: arrondi2(perimetre),
perimetre_expose_m: arrondi2(perimetre_expose),
murs_exterieurs_m2: arrondi2(murs_exterieurs),
murs_opaques_m2: arrondi2(murs_opaques),
vitrage_m2: arrondi2(vitrage),
toiture_m2: arrondi2(toiture),
plancher_bas_m2: arrondi2(emprise),
volume_m3: arrondi2(volume),
hauteur_m: arrondi2(hauteur),
hauteur_etage_m: arrondi2(hauteur_etage),
};
return trace.resultat(sorties, VERSION);
}
export const geometrie = {
id: 'commun.geometrie',
calculer(entrees, donnees) {
return calculeGeometrie(entrees, donnees?.coefficients ?? COEFFICIENTS);
},
};
+15
View File
@@ -0,0 +1,15 @@
export type { Source, Resultat, Moteur } from './contrat.js';
export { Trace } from './contrat.js';
export type { Coefficient, TableCoefficients, Orientation, ClasseRt } from './coefficients.js';
export { COEFFICIENTS, CLES_COEFFICIENTS, verifieTableCoefficients } from './coefficients.js';
export type { LigneClimat, Contexte, EntreesContexte, DonneesContexte, ZoneBiogeo } from './contexte.js';
export { contexte, deriveContexte, departementDepuisCodePostal, zoneRtToClass, ZONE_BIOGEO_MAP } from './contexte.js';
export type { EntreesGeometrie, DonneesGeometrie, SortiesGeometrie } from './geometrie.js';
export { geometrie, calculeGeometrie } from './geometrie.js';
export type { Fourchette, FourchetteOuverte } from './argent.js';
export { fourchette, pointMedian, additionneFourchettes, arrondiAuPas, arrondi2, mensualite, annuite, coutDuCredit } from './argent.js';
export type { Schema, NomTable, TypeJson } from './schemas.js';
export { SCHEMAS, NOMS_TABLES } from './schemas.js';
export type { Validation } from './valider.js';
export { valider, validerContre } from './valider.js';
export { VERSION } from '../version.js';
+8
View File
@@ -0,0 +1,8 @@
export { Trace } from './contrat.js';
export { COEFFICIENTS, CLES_COEFFICIENTS, verifieTableCoefficients } from './coefficients.js';
export { contexte, deriveContexte, departementDepuisCodePostal, zoneRtToClass, ZONE_BIOGEO_MAP } from './contexte.js';
export { geometrie, calculeGeometrie } from './geometrie.js';
export { fourchette, pointMedian, additionneFourchettes, arrondiAuPas, arrondi2, mensualite, annuite, coutDuCredit } from './argent.js';
export { SCHEMAS, NOMS_TABLES } from './schemas.js';
export { valider, validerContre } from './valider.js';
export { VERSION } from '../version.js';
+14
View File
@@ -0,0 +1,14 @@
export type TypeJson = 'string' | 'number' | 'integer' | 'boolean' | 'null' | 'object' | 'array';
export interface Schema {
$id?: string;
description?: string;
type: TypeJson | TypeJson[];
required?: string[];
properties?: Record<string, Schema>;
items?: Schema;
enum?: (string | number | boolean | null)[];
minItems?: number;
}
export type NomTable = 'postes' | 'options' | 'france_climat' | 'algo_config';
export declare const SCHEMAS: Record<NomTable, Schema>;
export declare const NOMS_TABLES: NomTable[];
+121
View File
@@ -0,0 +1,121 @@
// schemas.ts — forme attendue de chaque JSON de données injecté dans les
// moteurs. Source de vérité des fichiers `schemas/<table>.schema.json`, que
// `npm run build` réécrit depuis ce module (scripts/ecrire-schemas.mjs) : le
// paquet reste sans I/O, et un consommateur peut lire les JSON.
//
// Sous-ensemble de JSON Schema compris par `valider()` : type (simple ou
// liste), required, properties, items, enum, minItems. Vérification
// structurelle maison, pas un validateur JSON Schema complet.
const nombreOuNull = { type: ['number', 'null'] };
const chaineOuNull = { type: ['string', 'null'] };
const booleenOuNull = { type: ['boolean', 'null'] };
export const SCHEMAS = {
postes: {
$id: 'https://git.trans-former.fr/jules/moteurs/schemas/postes.schema.json',
description: 'Postes du simulateur autonomie (calculs.trans-former.fr/calculs/data/postes.json). Champs lus par les moteurs.',
type: 'array',
minItems: 1,
items: {
type: 'object',
required: ['Id', 'axe', 'code', 'poste'],
properties: {
Id: { type: 'integer' },
axe: { type: 'string', enum: ['eau', 'energie', 'alimentation'] },
code: { type: 'string' },
poste: { type: 'string' },
ordre: nombreOuNull,
obligatoire: booleenOuNull,
actif: booleenOuNull,
description: chaineOuNull,
},
},
},
options: {
$id: 'https://git.trans-former.fr/jules/moteurs/schemas/options.schema.json',
description: 'Typologies (options) du simulateur autonomie. Les prix y sont des DONNÉES publiées par calculs, jamais des constantes du paquet. parametre_dim, couplages_requis et couplages_exclus arrivent en JSON sérialisé (NocoDB) ou déjà parsés.',
type: 'array',
minItems: 1,
items: {
type: 'object',
required: ['Id', 'poste_id', 'code', 'nom'],
properties: {
Id: { type: 'integer' },
poste_id: { type: 'integer' },
code: { type: 'string' },
nom: { type: 'string' },
typologie: chaineOuNull,
actif: booleenOuNull,
prix_mat_min: nombreOuNull,
prix_mat_max: nombreOuNull,
prix_pro_min: nombreOuNull,
prix_pro_max: nombreOuNull,
prix_par_unite: nombreOuNull,
prix_fixe: nombreOuNull,
unite: chaineOuNull,
quantite_default: nombreOuNull,
parametre_dim: { type: ['string', 'object', 'null'] },
couplages_requis: { type: ['string', 'array', 'null'] },
couplages_exclus: { type: ['string', 'array', 'null'] },
flag_diy: booleenOuNull,
complexite_pose: nombreOuNull,
accessible_fr: booleenOuNull,
filiere_agreee: booleenOuNull,
surface_min_m2: nombreOuNull,
surface_optimale_m2: nombreOuNull,
tags: { type: ['array', 'null'] },
source: chaineOuNull,
source_date: chaineOuNull,
description: chaineOuNull,
description_pedago: chaineOuNull,
notes_techniques: chaineOuNull,
},
},
},
france_climat: {
$id: 'https://git.trans-former.fr/jules/moteurs/schemas/france_climat.schema.json',
description: 'Une ligne par département (2A, 2B et 971-976 compris) : zone RT, pluviométrie, DJU base 18, zone biogéographique numérique.',
type: 'array',
minItems: 1,
items: {
type: 'object',
required: ['dept', 'zone_rt'],
properties: {
dept: { type: 'string' },
nom_dept: chaineOuNull,
zone_rt: { type: 'string', enum: ['H1a', 'H1b', 'H1c', 'H2a', 'H2b', 'H2c', 'H2d', 'H3'] },
pluvio_mm_an: nombreOuNull,
dju_base_18: nombreOuNull,
zone_biogeo: nombreOuNull,
source_climat: chaineOuNull,
source_date: chaineOuNull,
},
},
},
algo_config: {
$id: 'https://git.trans-former.fr/jules/moteurs/schemas/algo_config.schema.json',
description: 'Heuristiques de conception du simulateur autonomie (poids, modulateurs, matrices, tolérances, alias, poids des postes). Données non sourcées, injectées, jamais importées.',
type: 'object',
required: ['poids_axes_base', 'modulateurs', 'adequation_weights', 'tolerances', 'bonus_malus', 'couplages_alias', 'postes_poids'],
properties: {
poids_axes_base: {
type: 'object',
required: ['eau', 'energie', 'alim'],
properties: { eau: { type: 'number' }, energie: { type: 'number' }, alim: { type: 'number' } },
},
modulateurs: { type: 'object' },
adequation_weights: { type: 'object' },
tolerances: {
type: 'object',
properties: {
budget_overrun_pct: { type: 'number' },
budget_underspend_pct: { type: 'number' },
tolerance_par_poste_pct: { type: 'number' },
},
},
bonus_malus: { type: 'object' },
couplages_alias: { type: 'object' },
postes_poids: { type: 'object' },
},
},
};
export const NOMS_TABLES = Object.keys(SCHEMAS);
+12
View File
@@ -0,0 +1,12 @@
import { type NomTable, type Schema } from './schemas.js';
export interface Validation {
ok: boolean;
erreurs: string[];
}
/**
* Valide un JSON de données contre le schéma de sa table. Renvoie au plus
* vingt erreurs, chacune avec son chemin.
*/
export declare function valider(table: NomTable, json: unknown): Validation;
/** Valide un objet contre un schéma arbitraire (même sous-ensemble). */
export declare function validerContre(schema: Schema, json: unknown, nom?: string): Validation;
+77
View File
@@ -0,0 +1,77 @@
// valider.ts — validation structurelle d'un JSON de données contre le schéma
// de sa table (schemas/<table>.schema.json, incarné par schemas.ts). Sans
// dépendance, sans I/O. Ce n'est pas un validateur JSON Schema complet : type,
// required, properties, items, enum, minItems.
import { SCHEMAS } from './schemas.js';
const MAX_ERREURS = 20;
function typeDe(v) {
if (v === null)
return 'null';
if (Array.isArray(v))
return 'array';
if (typeof v === 'number')
return Number.isInteger(v) ? 'integer' : 'number';
if (typeof v === 'string')
return 'string';
if (typeof v === 'boolean')
return 'boolean';
return 'object';
}
function typeAccepte(attendu, reel) {
const liste = Array.isArray(attendu) ? attendu : [attendu];
return liste.some((t) => t === reel || (t === 'number' && reel === 'integer'));
}
function verifie(schema, valeur, chemin, erreurs) {
if (erreurs.length >= MAX_ERREURS)
return;
const reel = typeDe(valeur);
if (!typeAccepte(schema.type, reel)) {
erreurs.push(`${chemin} : type ${reel}, attendu ${Array.isArray(schema.type) ? schema.type.join('|') : schema.type}`);
return;
}
if (schema.enum && !schema.enum.includes(valeur)) {
erreurs.push(`${chemin} : valeur « ${String(valeur)} » hors de ${schema.enum.map(String).join(', ')}`);
}
if (reel === 'array') {
const tableau = valeur;
if (schema.minItems != null && tableau.length < schema.minItems) {
erreurs.push(`${chemin} : ${tableau.length} élément(s), minimum ${schema.minItems}`);
}
if (schema.items) {
for (let i = 0; i < tableau.length && erreurs.length < MAX_ERREURS; i++) {
verifie(schema.items, tableau[i], `${chemin}[${i}]`, erreurs);
}
}
}
else if (reel === 'object') {
const objet = valeur;
for (const cle of schema.required ?? []) {
if (!(cle in objet) || objet[cle] === undefined)
erreurs.push(`${chemin}.${cle} : requis, absent`);
}
if (schema.properties) {
for (const [cle, sous] of Object.entries(schema.properties)) {
if (cle in objet && objet[cle] !== undefined)
verifie(sous, objet[cle], `${chemin}.${cle}`, erreurs);
}
}
}
}
/**
* Valide un JSON de données contre le schéma de sa table. Renvoie au plus
* vingt erreurs, chacune avec son chemin.
*/
export function valider(table, json) {
const schema = SCHEMAS[table];
if (!schema)
return { ok: false, erreurs: [`table inconnue : ${String(table)}`] };
const erreurs = [];
verifie(schema, json, table, erreurs);
return { ok: erreurs.length === 0, erreurs };
}
/** Valide un objet contre un schéma arbitraire (même sous-ensemble). */
export function validerContre(schema, json, nom = '$') {
const erreurs = [];
verifie(schema, json, nom, erreurs);
return { ok: erreurs.length === 0, erreurs };
}
+5
View File
@@ -0,0 +1,5 @@
export * from './commun/index.js';
export * as autonomie from './autonomie/index.js';
export { eau, energie, alimentation, integrateur } from './autonomie/index.js';
/** Identifiants des moteurs livrés par cette version. */
export declare const MOTEURS: readonly [string, string, string, string, string, string];
+9
View File
@@ -0,0 +1,9 @@
// @transformer/moteurs — point d'entrée. Sous-chemins : ./commun, ./autonomie,
// ./autonomie/{eau,energie,alimentation,integrateur,noyau}.
export * from './commun/index.js';
export * as autonomie from './autonomie/index.js';
export { eau, energie, alimentation, integrateur } from './autonomie/index.js';
import { contexte, geometrie } from './commun/index.js';
import { eau, energie, alimentation, integrateur } from './autonomie/index.js';
/** Identifiants des moteurs livrés par cette version. */
export const MOTEURS = [contexte.id, geometrie.id, eau.id, energie.id, alimentation.id, integrateur.id];
+1
View File
@@ -0,0 +1 @@
export declare const VERSION = "0.1.0";
+5
View File
@@ -0,0 +1,5 @@
// version.ts — étiquette du paquet, portée par chaque résultat de moteur.
// Doit être égale à "version" de package.json : un test le vérifie
// (test/paquet.test.mjs). On ne lit pas package.json ici : le paquet ne fait
// aucune I/O, et un JSON importé serait embarqué par les bundlers.
export const VERSION = '0.1.0';
+47
View File
@@ -0,0 +1,47 @@
# CONSTANTES — table des coefficients harmonisés (v0.1.0, 28/09/2026)
> **Pour Jules, 20 minutes, veto ligne à ligne.** Règle appliquée : *le document de recherche l'emporte sur le code quand ils divergent* (SPEC phase 1 §4.1). Chaque ligne dit ce que le code portait, ce que le paquet retient, d'où ça vient, et ce que ça change. **Sans veto sous une semaine (le 5 octobre), cette table est la référence.** Un veto = la ligne, la valeur voulue, une source si tu en as une ; je corrige `src/commun/coefficients.ts`, je refige les écarts, nouvelle étiquette.
>
> Sources : `01-recherche-eau.md`, `02-recherche-energie.md`, `99-brief-atis-dev.md` (tous du 28/04/2026, dossier `Simulateur autonomie V2`) ; code de `calculs.trans-former.fr` au 28/09 ; simulateur autoconstruction V1 pour la géométrie.
## 1. Ce qui change vraiment (cinq lignes)
| Grandeur | Le code portait | Retenu | Source | Effet mesuré sur les trois profils de référence |
|---|---|---|---|---|
| **Rendement de captage** | trois valeurs : 0,7 (score de pluviométrie), 0,8 (taux de couverture), 0,85 (V1) | **0,80** (fourchette 0,75 zone sèche à 0,85 zone humide) | recherche eau §3 et §7 n°6 : NF EN 16941-1, recueil Nény p.16 | aucune typologie ne change ; couverture eau **+3 à +4 points** (A 23 → 27 %, B 24 → 27 %, C 20 → 23 %) |
| **Facteur d'orientation PV** | sud 1 · SE/SO 0,93 · E/O 0,78 · **nord 0,40** | sud 1 · SE/SO **0,95** · E/O **0,80** · nord **0,60** (bas de fourchette) | recherche énergie §3.2 (SE/SO 95 %, E/O 80-85 %, N 60-65 %). *Variante : le brief §7.2 prenait les milieux 0,82 et 0,62* | aucune typologie ne change (les trois profils sont sud ou sud-est) ; le nord n'est plus pénalisé deux fois plus que la source |
| **Taux d'autoconsommation** | **absent** : la couverture énergie comparait la production brute au besoin | **0,30 sans batterie, 0,70 avec 10 kWh**, linéaire entre les deux (l'interpolation est un choix MOE, non sourcé) | recherche énergie §3.2, brief §7.2 | c'est **le** changement visible : couverture énergie **A 22 → 6 %**, **B 100 → 80 %** (mode par axe, avec batterie), **C 100 → 63 %** (global) et 10 → 3 % (par axe). Le simulateur promettait trop |
| **Productible PV** (kWh/kWc/an) | table par zone **biogéographique**, 1 050 à **1 550** (zone 8, au-dessus du maximum de la source) | table par zone **RT** : H1a 1 000 · H1b 1 050 · H1c 1 200 · H2a 1 100 · H2b 1 200 · H2c 1 250 · **H2d 1 300** · H3 1 400 ; défaut 1 100 | recherche énergie §3.2 (PVGIS, sud 30°, pertes 14 %). H1a = moyenne Lille 950 / Paris 1 050. **H2d interpolé par la MOE** (04, 07, 26, 84 : pas de ville dans la source), déclaré en avertissement à chaque usage | inclus dans les écarts d'énergie ci-dessus (Paris 1 100 → 1 000, Bouches-du-Rhône 1 480 → 1 400) |
| **Contexte Corse et DOM** | `algo.js` prenait deux chiffres du code postal : 2A, 2B, 971-976 retombaient sur **H2a, 800 mm, 2 200 DJU** pendant que l'affichage utilisait la vraie ligne | une seule fonction, `departementDepuisCodePostal` (20000-20190 → 2A, 20200-20620 → 2B, 97xxx → trois caractères) | code de `data.js` (l'interface avait déjà la bonne logique) | Corse : H3 (700-750 mm, 1 500-1 600 DJU). DOM : la table `france_climat` n'a **ni pluviométrie ni DJU** pour 971-976 (« DOM tropical ») → défauts 800 mm / 2 200 DJU appliqués **et déclarés** ; à compléter dans NocoDB si un jour une adresse DOM arrive |
## 2. Ce qui ne change pas, désormais sourcé et unique
| Grandeur | Valeur | Source | Note |
|---|---|---|---|
| Besoin d'eau domestique | 150 L/jour/personne (54 750 L/an) | recherche eau §3 (INSEE, Eau France 2024), brief §7.1 | le V1 comptait 46,5 m³/an |
| Part substituable par l'eau de pluie | 70 % | recherche eau §3 : WC 30 + douche 60 + lessive 30 + jardin 25 L/j sur 150 | le V1 comptait 32 % |
| Rendement par type de toiture | tuile 0,85 · ardoise 0,85 · gravillonnée 0,50 · végétalisée 0,30 | recherche eau §7 n°6 (ADEME, NF EN 16941-1) | **non utilisé** en v0.1 : à brancher quand le formulaire demande le type de toit |
| Besoin d'électricité spécifique | 1 000 kWh/personne/an (fourchette 900-1 200), hors chauffage, ECS et véhicule | recherche énergie §3.1 (ADEME 2023, RTE 2024) | |
| Haie fruitière | 25 ml/personne (50 ml pour un excédent transformable) | brief §7.3, correction Jules du 28/04 | |
| DJU par défaut | H1 2 800 · H2 2 200 · H3 1 500 | heuristique V2, **non sourcée** | repli seulement : `france_climat` porte un DJU par département |
| Contexte par défaut | H2a, zone 4, 800 mm, 2 200 DJU | heuristique V2, **non sourcée** | appliqué seulement si le département est inconnu, toujours déclaré en avertissement |
## 3. Géométrie (nouveau moteur `commun.geometrie`, reprend `calcSurfaces` du V1)
| Grandeur | Valeur | Source | Note |
|---|---|---|---|
| Périmètre estimé | 4 × √(emprise × 1,3) | V1 autoconstruction | rectangle allongé ; pour 73 m² : 38,97 m |
| Surface vitrée | SHAB / 6 (16,7 %) | V1 (`vit = S/6`) ; règle RT 2012 (baies ≥ 1/6 de la SHAB) | la SPEC écrit « 15 % » : j'ai gardé 1/6, dis-moi si tu veux 15 % |
| Majoration de toiture | emprise / cos(pente) × 1,05 | V1 | |
| **SHAB estimée** | emprise × niveaux × **0,80** | **heuristique MOE, non sourcée** (murs extérieurs ~0,3 m, refends, escalier) | **hypothèse porteuse du P1.2** : maison B de l'étude (73 m² × 2 niveaux) → 116,8 m² ; la surface DPE n'est pas dans l'étude, l'écart se mesure au premier appel de l'adaptateur DPE (P1.1), visé < 20 % |
| Hauteur d'étage par défaut | 2,5 m | heuristique MOE | si la hauteur du bâti est inconnue |
| Mitoyenneté | −25 % du périmètre par côté mitoyen | heuristique MOE | |
| Pente par défaut | 30° | heuristique MOE | si inconnue |
## 4. Heuristiques restées dans le code (non déplacées, pour que tu saches qu'elles existent)
Ce ne sont pas des grandeurs physiques mais des choix de conception du simulateur V2 (spec d'algo « heuristique déterministe, calibrable sur trois profils »). Elles vivent dans `algo-config.json` (poids d'axes 40/40/20, modulateurs, matrices d'adéquation, tolérances ±5 % / −15 %, bonus DIY 1,3, malus 0,5 et 0,6, poids des postes) ou dans le noyau : pose pro = 50 % du matériel quand `prix_pro` manque (`cout_typologie`) ; cible de cuve = 2 mois de récupération (`score_pluviometrie`) ; puissance cible = DJU / 500 (`score_climat_chauffage`) ; 100 m² retirés de la parcelle pour le bâti (`score_surface_parcelle`, `par_surface_parcelle`). Les changer relève du recalibrage, pas de l'harmonisation.
## 5. Pour mettre un veto
Réponds par le numéro de ligne et la valeur : « §1 orientation : nord 0,62 (milieu du brief) » suffit. Je corrige la table, je relance `npm run ecarts`, je refige les écarts avec la date, nouvelle étiquette `v0.1.1`, montée dans `reinnover` le même jour.
+14
View File
@@ -0,0 +1,14 @@
# fixtures/ — jeux de données figés et datés
Les tests tournent hors ligne sur ces copies. Aucun fichier d'ici n'est importé par `src/` : les moteurs reçoivent leurs données en paramètre. Un consommateur peut lire `algo-config.json` et `profils-reference.json` par `@transformer/moteurs/fixtures/<fichier>` (exposés par `exports`).
| Fichier | Contenu | Origine | Date de l'instantané |
|---|---|---|---|
| `postes.json` (14 lignes) · `options.json` (85) · `france_climat.json` (101, dont 2A, 2B, 971-976) | tables du simulateur autonomie, **prix compris** (ce sont des données de test, jamais des constantes du paquet) | `astro-pro/tests/fixtures/` (copie de la publication `calculs.trans-former.fr/calculs/data/`) | 2026-08-16 |
| `algo-config.json` | heuristiques de conception du noyau (poids, modulateurs, matrices, tolérances, alias, poids des postes) ; les clés `orientation_factors` et `dju_defaults_by_zone` y figurent encore mais **ne sont plus lues** (les coefficients vivent dans `src/commun/coefficients.ts`) | `astro-pro/src/lib/autonomie/algo-config.json` | 2026-09-28 (inchangé depuis mai 2026) |
| `profils-reference.json` | les trois profils de calibrage A / B / C | `astro-pro/tests/profils-reference.json` | 2026-09-28 |
| `coefficients-2026-05.json` | jeu de coefficients **d'origine** (valeurs du code de calculs), injecté par le test de non-régression | relevé sur `astro-pro` master `c628cf9` | 2026-09-28 |
| `sorties-origine-2026-09-28.json` | sorties de `selectionner()` **d'origine** sur les trois profils (pipeline complet + mode un seul axe), générées par `scripts/pin-origine.mjs` depuis le code d'astro-pro | `astro-pro` master `c628cf9` | 2026-09-28 |
| `ecarts-harmonisation-2026-09-28.json` | écarts de sélection et de taux entre coefficients d'origine et table harmonisée (`npm run ecarts -- --ecrire <date>`) ; relus par Jules avec `docs/CONSTANTES.md` | ce paquet | 2026-09-28 |
Refiger une épingle est un acte volontaire : on relit `docs/CONSTANTES.md`, on relance le script avec la date du jour, on met à jour les tests qui la citent, et on l'écrit dans le message de commit.
+186
View File
@@ -0,0 +1,186 @@
{
"poids_axes_base": {
"eau": 40,
"energie": 40,
"alim": 20
},
"modulateurs": {
"toit_petit_seuil_m2": 30,
"toit_petit_delta": {
"energie": -15,
"eau": 5,
"alim": 10
},
"famille_grande_seuil": 5,
"famille_grande_delta": {
"eau": 10,
"alim": 5,
"energie": -15
},
"biogeo_med_delta": {
"eau": 10
},
"pluvio_basse_seuil_mm": 600,
"pluvio_basse_delta": {
"eau": 5
},
"parcelle_petite_seuil_m2": 300,
"parcelle_petite_alim_zero": true,
"parcelle_grande_seuil_m2": 1000,
"parcelle_grande_temps3_delta": {
"alim": 10
}
},
"adequation_weights": {
"_comment": "Cf. V2-algo-spec-FONCTIONS.md §1.3 + codes postes BDD post-M1a",
"eau_captage": {
"pluviometrie": 30,
"surface_toit": 30,
"taille_foyer": 10,
"temps_dispo": 5
},
"eau_stockage": {
"pluviometrie": 25,
"taille_foyer": 25,
"surface_parcelle": 5,
"temps_dispo": 5
},
"eau_filtration": {
"taille_foyer": 15,
"temps_dispo": 10
},
"eau_distribution": {
"taille_foyer": 20,
"temps_dispo": 5
},
"eau_eg_epuration": {
"taille_foyer": 20,
"surface_parcelle": 30,
"temps_dispo": 5
},
"energie_pv": {
"climat_chauffage": 5,
"taille_foyer": 10,
"surface_toit": 35,
"orientation_toit": 25,
"temps_dispo": 5
},
"energie_solaire_th": {
"climat_chauffage": 5,
"taille_foyer": 30,
"temps_dispo": 5
},
"energie_chauffage_bois": {
"climat_chauffage": 35,
"taille_foyer": 15,
"temps_dispo": 10
},
"energie_emetteurs": {
"taille_foyer": 20,
"temps_dispo": 5
},
"energie_pilotage": {
"taille_foyer": 10,
"temps_dispo": 5
},
"alim_etude_fonciere": {
"surface_parcelle": 20,
"temps_dispo": 5
},
"alim_strate_haute": {
"climat_meridional": 5,
"surface_parcelle": 25,
"taille_foyer": 5,
"temps_dispo": 10
},
"alim_strate_moyenne": {
"surface_parcelle": 15,
"metres_haie_possibles": 30,
"temps_dispo": 10
},
"alim_strate_basse": {
"climat_meridional": 10,
"surface_parcelle": 30,
"taille_foyer": 10,
"temps_dispo": 15
}
},
"orientation_factors": {
"sud": 1.0,
"sud-est": 0.93,
"sud-ouest": 0.93,
"est": 0.78,
"ouest": 0.78,
"nord": 0.4
},
"dju_defaults_by_zone": {
"H1": 2800,
"H2": 2200,
"H3": 1500
},
"tolerances": {
"budget_overrun_pct": 5,
"budget_underspend_pct": 5,
"tolerance_par_poste_pct": 20
},
"bonus_malus": {
"boost_diy_temps3": 1.3,
"malus_pose3_temps1": 0.5,
"malus_non_accessible_fr": 0.6
},
"couplages_alias": {
"stherm_ballon_tampon": "th_ballon_500",
"emetteur_pcbt": "emet_pcbt",
"batterie_min_10kwh": "pv_batterie_lifepo4_5",
"cuve_decantation": "eg_separation_source",
"stockage_cuve_pehd_horsol": "stockage_pehd_horssol_5m3",
"distrib_kit_double_reseau": "distrib_kit_double_reseau",
"pv_batterie_lifepo4": "pv_batterie_lifepo4_5",
"pv_kit_string": "pv_kit_3kwc_string",
"pv_onduleur_hybride": "pv_onduleur_hybride_5kw"
},
"postes_poids": {
"eau_captage": {
"poids": 15
},
"eau_filtration": {
"poids": 15
},
"eau_stockage": {
"poids": 40
},
"eau_distribution": {
"poids": 20
},
"eau_eg_epuration": {
"poids": 10
},
"energie_pv": {
"poids": 35
},
"energie_solaire_th": {
"poids": 15
},
"energie_chauffage_bois": {
"poids": 30
},
"energie_emetteurs": {
"poids": 10
},
"energie_pilotage": {
"poids": 10
},
"alim_etude_fonciere": {
"poids": 10
},
"alim_strate_haute": {
"poids": 30
},
"alim_strate_moyenne": {
"poids": 30
},
"alim_strate_basse": {
"poids": 30
}
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"_commentaire": "Jeu de coefficients D'ORIGINE : valeurs du code de calculs.trans-former.fr (astro-pro master c628cf9, relevé le 2026-09-28 ; code écrit en mai 2026). Sert UNIQUEMENT au test de non-régression (le noyau extrait rend la même sélection que l'original). La référence est src/commun/coefficients.ts (table harmonisée) — docs/CONSTANTES.md donne, ligne à ligne, ancienne valeur → retenue.",
"captage_rendement": { "valeur": 0.7, "source": "scoreurs.js score_pluviometrie (0,7) ; taux.js portait 0,8 ; V1 bioclimatique 0,85", "date": "2026-05-01" },
"captage_rendement_par_toiture": { "valeur": { "tuile": 0.85, "ardoise": 0.85, "gravillonnee": 0.5, "vegetalisee": 0.3 }, "source": "absent du code (01-recherche-eau §7 n°6)", "date": "2026-04-28" },
"besoin_eau_l_j_pers": { "valeur": 150, "source": "taux.js BESOIN_EAU_L_PERS_AN = 54 750", "date": "2026-05-01" },
"part_eau_substituable": { "valeur": 0.7, "source": "taux.js PART_SUBSTITUABLE", "date": "2026-05-01" },
"orientation_pv": { "valeur": { "sud": 1.0, "sud-est": 0.93, "sud-ouest": 0.93, "est": 0.78, "ouest": 0.78, "nord": 0.4 }, "source": "calibrage.js ORIENTATION_FACTORS (dupliqué dans algo-config.json orientation_factors, non lu)", "date": "2026-05-01" },
"productible_pv_kwh_kwc": { "valeur": { "par": "zone_biogeo_code", "table": { "1": 1050, "2": 1180, "3": 1280, "4": 1100, "5": 1480, "6": 1230, "7": 1280, "8": 1550 }, "defaut": 1200 }, "source": "constants.js FALLBACK_PVGIS (table par zone biogéographique) ; pvgis.js défaut 1 200", "date": "2026-05-01" },
"autoconso_sans_batterie": { "valeur": 1.0, "source": "absent du code : taux.js comparait la production brute au besoin (équivaut à un taux de 1)", "date": "2026-05-01" },
"autoconso_avec_batterie_10kwh": { "valeur": 1.0, "source": "absent du code (idem)", "date": "2026-05-01" },
"besoin_elec_kwh_pers_an": { "valeur": 1000, "source": "taux.js BESOIN_ELEC_KWH_PERS_AN", "date": "2026-05-01" },
"dju_defaut_par_classe_rt": { "valeur": { "H1": 2800, "H2": 2200, "H3": 1500 }, "source": "calibrage.js DJU_DEFAULTS_BY_ZONE", "date": "2026-05-01" },
"ml_haie_par_pers": { "valeur": 25, "source": "taux.js ML_HAIE_PAR_PERS", "date": "2026-05-01" },
"contexte_defaut": { "valeur": { "zone_rt": "H2a", "zone_biogeo_code": 4, "pluviometrie_mm": 800, "dju": 2200 }, "source": "algo.js deriveContexte (défauts)", "date": "2026-05-01" },
"geometrie_ratio_shab_emprise": { "valeur": 1.0, "source": "V1 autoconstruction : shab = S (plain-pied, aucune correction)", "date": "2025-01-01" },
"geometrie_ratio_vitrage_shab": { "valeur": 0.16666666666666666, "source": "V1 autoconstruction : vit = S / 6", "date": "2025-01-01" },
"geometrie_hauteur_etage_m": { "valeur": 2.5, "source": "absent du code (Z saisi par l'utilisateur)", "date": "2025-01-01" },
"geometrie_facteur_perimetre": { "valeur": 1.3, "source": "V1 autoconstruction : perimetre = √(S × 1,3) × 4", "date": "2025-01-01" },
"geometrie_part_mur_mitoyen": { "valeur": 0.25, "source": "absent du code", "date": "2025-01-01" },
"geometrie_majoration_toiture": { "valeur": 1.05, "source": "V1 autoconstruction : toiture = S / cos α × 1,05", "date": "2025-01-01" },
"geometrie_pente_defaut_deg": { "valeur": 30, "source": "absent du code (A saisi par l'utilisateur)", "date": "2025-01-01" }
}
@@ -0,0 +1,535 @@
{
"_commentaire": "Écarts de sélection et de taux entre le jeu de coefficients d'origine et la table harmonisée, sur les trois profils de référence, figés le 2026-09-28. Un changement de coefficient ou de code qui modifie ces écarts casse test/decoupage.test.mjs : c'est voulu, on relit CONSTANTES.md avant de refiger.",
"date": "2026-09-28",
"ecarts": [
{
"profil": "A — Frugal urbain",
"mode": "taux (par_axe)",
"axe": "eau",
"origine": "23 %",
"harmonisee": "27 %"
},
{
"profil": "A — Frugal urbain",
"mode": "taux (global)",
"axe": "eau",
"origine": "23 %",
"harmonisee": "27 %"
},
{
"profil": "A — Frugal urbain",
"mode": "taux (par_axe)",
"axe": "energie",
"origine": "22 %",
"harmonisee": "6 %"
},
{
"profil": "A — Frugal urbain",
"mode": "taux (global)",
"axe": "energie",
"origine": "22 %",
"harmonisee": "6 %"
},
{
"profil": "B — Famille moyenne",
"mode": "taux (par_axe)",
"axe": "eau",
"origine": "24 %",
"harmonisee": "27 %"
},
{
"profil": "B — Famille moyenne",
"mode": "taux (global)",
"axe": "eau",
"origine": "24 %",
"harmonisee": "27 %"
},
{
"profil": "B — Famille moyenne",
"mode": "taux (par_axe)",
"axe": "energie",
"origine": "100 %",
"harmonisee": "80 %"
},
{
"profil": "C — Autonomie large",
"mode": "taux (par_axe)",
"axe": "eau",
"origine": "20 %",
"harmonisee": "23 %"
},
{
"profil": "C — Autonomie large",
"mode": "taux (global)",
"axe": "eau",
"origine": "20 %",
"harmonisee": "23 %"
},
{
"profil": "C — Autonomie large",
"mode": "taux (par_axe)",
"axe": "energie",
"origine": "10 %",
"harmonisee": "3 %"
},
{
"profil": "C — Autonomie large",
"mode": "taux (global)",
"axe": "energie",
"origine": "100 %",
"harmonisee": "63 %"
}
],
"lignes": [
{
"profil": "A — Frugal urbain",
"jeu": "origine",
"global": {
"total": 15180,
"status": "OK",
"typologies": {
"eau": [
"captage_descente_directe",
"filtration_particulaire_simple",
"stockage_souple_5m3",
"distrib_gravitaire",
"eg_noue",
"eg_pedoepuration",
"eg_fpv_autoconstruit"
],
"energie": [
"pv_batterie_plomb_5",
"th_ballon_500",
"bois_poele_double",
"emet_vannes_thermo",
"pil_home_assistant",
"bois_conduit_fumee"
],
"alim": []
},
"taux": {
"eau": 23,
"energie": 22,
"alim": null
}
},
"par_axe": {
"eau": {
"typologies": [
"captage_descente_directe",
"filtration_particulaire_simple",
"stockage_souple_5m3",
"distrib_gravitaire",
"eg_pedoepuration",
"distrib_kit_double_reseau",
"eg_fpv_autoconstruit"
],
"sous_total": 8130,
"status": "BUDGET_INSUFFISANT",
"taux": 23
},
"energie": {
"typologies": [
"pv_kit_beem_balcon",
"th_ballon_500",
"bois_poele_granules",
"emet_radiateur_acier",
"pil_routeur_ecs",
"bois_conduit_fumee"
],
"sous_total": 7110,
"status": "OK",
"taux": 22
},
"alim": {
"typologies": [],
"sous_total": 0,
"status": "OK",
"taux": null
}
}
},
{
"profil": "A — Frugal urbain",
"jeu": "harmonisee",
"global": {
"total": 15180,
"status": "OK",
"typologies": {
"eau": [
"captage_descente_directe",
"filtration_particulaire_simple",
"stockage_souple_5m3",
"distrib_gravitaire",
"eg_noue",
"eg_pedoepuration",
"eg_fpv_autoconstruit"
],
"energie": [
"pv_batterie_plomb_5",
"th_ballon_500",
"bois_poele_double",
"emet_vannes_thermo",
"pil_home_assistant",
"bois_conduit_fumee"
],
"alim": []
},
"taux": {
"eau": 27,
"energie": 6,
"alim": null
}
},
"par_axe": {
"eau": {
"typologies": [
"captage_descente_directe",
"filtration_particulaire_simple",
"stockage_souple_5m3",
"distrib_gravitaire",
"eg_pedoepuration",
"distrib_kit_double_reseau",
"eg_fpv_autoconstruit"
],
"sous_total": 8130,
"status": "BUDGET_INSUFFISANT",
"taux": 27
},
"energie": {
"typologies": [
"pv_kit_beem_balcon",
"th_ballon_500",
"bois_poele_granules",
"emet_radiateur_acier",
"pil_routeur_ecs",
"bois_conduit_fumee"
],
"sous_total": 7110,
"status": "OK",
"taux": 6
},
"alim": {
"typologies": [],
"sous_total": 0,
"status": "OK",
"taux": null
}
}
},
{
"profil": "B — Famille moyenne",
"jeu": "origine",
"global": {
"total": 36661,
"status": "OK",
"typologies": {
"eau": [
"captage_descente_directe",
"filtration_particulaire_simple",
"stockage_souple_5m3",
"distrib_gravitaire",
"eg_noue",
"eg_pedoepuration",
"eg_fpv_autoconstruit"
],
"energie": [
"pv_monitoring",
"th_ballon_500",
"bois_poele_masse_indus",
"emet_seche_serviette",
"pil_delesteur",
"bois_conduit_fumee"
],
"alim": [
"etude_topo_ensoleil",
"haute_fruit_coque",
"moy_petit_fruit_3L",
"basse_compost_3bacs"
]
},
"taux": {
"eau": 24,
"energie": null,
"alim": null
}
},
"par_axe": {
"eau": {
"typologies": [
"captage_descente_directe",
"filtration_particulaire_simple",
"stockage_pehd_enterree_5m3",
"distrib_gravitaire",
"eg_pedoepuration",
"eg_pedoepuration"
],
"sous_total": 14645,
"status": "OK",
"taux": 24
},
"energie": {
"typologies": [
"pv_kit_3kwc_string",
"th_ballon_500",
"bois_poele_granules",
"emet_seche_serviette",
"pil_delesteur",
"bois_conduit_fumee"
],
"sous_total": 13455,
"status": "OK",
"taux": 100
},
"alim": {
"typologies": [
"etude_analyse_labo",
"haute_plantation_pose_pro",
"moy_haie_biodiv_complete",
"basse_planche_bec",
"basse_planche_bec"
],
"sous_total": 6985,
"status": "OK",
"taux": 50
}
}
},
{
"profil": "B — Famille moyenne",
"jeu": "harmonisee",
"global": {
"total": 36661,
"status": "OK",
"typologies": {
"eau": [
"captage_descente_directe",
"filtration_particulaire_simple",
"stockage_souple_5m3",
"distrib_gravitaire",
"eg_noue",
"eg_pedoepuration",
"eg_fpv_autoconstruit"
],
"energie": [
"pv_monitoring",
"th_ballon_500",
"bois_poele_masse_indus",
"emet_seche_serviette",
"pil_delesteur",
"bois_conduit_fumee"
],
"alim": [
"etude_topo_ensoleil",
"haute_fruit_coque",
"moy_petit_fruit_3L",
"basse_compost_3bacs"
]
},
"taux": {
"eau": 27,
"energie": null,
"alim": null
}
},
"par_axe": {
"eau": {
"typologies": [
"captage_descente_directe",
"filtration_particulaire_simple",
"stockage_pehd_enterree_5m3",
"distrib_gravitaire",
"eg_pedoepuration",
"eg_pedoepuration"
],
"sous_total": 14645,
"status": "OK",
"taux": 27
},
"energie": {
"typologies": [
"pv_kit_3kwc_string",
"th_ballon_500",
"bois_poele_granules",
"emet_seche_serviette",
"pil_delesteur",
"bois_conduit_fumee"
],
"sous_total": 13455,
"status": "OK",
"taux": 80
},
"alim": {
"typologies": [
"etude_analyse_labo",
"haute_plantation_pose_pro",
"moy_haie_biodiv_complete",
"basse_planche_bec",
"basse_planche_bec"
],
"sous_total": 6985,
"status": "OK",
"taux": 50
}
}
},
{
"profil": "C — Autonomie large",
"jeu": "origine",
"global": {
"total": 77465,
"status": "OK",
"typologies": {
"eau": [
"captage_capteur_sol",
"filtration_3_corps_uv",
"stockage_beton_5m3",
"distrib_gravitaire",
"eg_phytostation"
],
"energie": [
"pv_kit_3kwc_micro",
"th_ballon_500",
"bois_chaudiere_granules",
"emet_vannes_thermo",
"pil_onduleur_hybride",
"bois_conduit_fumee"
],
"alim": [
"etude_analyse_labo",
"haute_haute_tige",
"moy_haie_biodiv_complete",
"basse_planche_bec"
]
},
"taux": {
"eau": 20,
"energie": 100,
"alim": 67
}
},
"par_axe": {
"eau": {
"typologies": [
"captage_capteur_sol",
"filtration_3_corps_uv",
"stockage_beton_5m3",
"distrib_per_bleu_interieur",
"eg_phytostation",
"eg_phytostation",
"eg_phytostation",
"eg_pedoepuration",
"distrib_kit_double_reseau"
],
"sous_total": 41000,
"status": "OK",
"taux": 20
},
"energie": {
"typologies": [
"pv_batterie_plomb_5",
"th_ballon_500",
"bois_poele_hydro",
"emet_radiateur_acier",
"pil_routeur_ecs",
"bois_conduit_fumee"
],
"sous_total": 19210,
"status": "OK",
"taux": 10
},
"alim": {
"typologies": [
"etude_analyse_labo",
"haute_haute_tige",
"moy_haie_biodiv_complete",
"basse_planche_bec",
"basse_planche_bec"
],
"sous_total": 20855,
"status": "OK",
"taux": 67
}
}
},
{
"profil": "C — Autonomie large",
"jeu": "harmonisee",
"global": {
"total": 77465,
"status": "OK",
"typologies": {
"eau": [
"captage_capteur_sol",
"filtration_3_corps_uv",
"stockage_beton_5m3",
"distrib_gravitaire",
"eg_phytostation"
],
"energie": [
"pv_kit_3kwc_micro",
"th_ballon_500",
"bois_chaudiere_granules",
"emet_vannes_thermo",
"pil_onduleur_hybride",
"bois_conduit_fumee"
],
"alim": [
"etude_analyse_labo",
"haute_haute_tige",
"moy_haie_biodiv_complete",
"basse_planche_bec"
]
},
"taux": {
"eau": 23,
"energie": 63,
"alim": 67
}
},
"par_axe": {
"eau": {
"typologies": [
"captage_capteur_sol",
"filtration_3_corps_uv",
"stockage_beton_5m3",
"distrib_per_bleu_interieur",
"eg_phytostation",
"eg_phytostation",
"eg_phytostation",
"eg_pedoepuration",
"distrib_kit_double_reseau"
],
"sous_total": 41000,
"status": "OK",
"taux": 23
},
"energie": {
"typologies": [
"pv_batterie_plomb_5",
"th_ballon_500",
"bois_poele_hydro",
"emet_radiateur_acier",
"pil_routeur_ecs",
"bois_conduit_fumee"
],
"sous_total": 19210,
"status": "OK",
"taux": 3
},
"alim": {
"typologies": [
"etude_analyse_labo",
"haute_haute_tige",
"moy_haie_biodiv_complete",
"basse_planche_bec",
"basse_planche_bec"
],
"sous_total": 20855,
"status": "OK",
"taux": 67
}
}
}
]
}
+103
View File
@@ -0,0 +1,103 @@
[
{"dept":"01","nom_dept":"Ain","zone_rt":"H1c","pluvio_mm_an":1050,"dju_base_18":2700,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France normales 1991-2020 + RE2020","source_date":"2026-04-28"},
{"dept":"02","nom_dept":"Aisne","zone_rt":"H1a","pluvio_mm_an":720,"dju_base_18":2600,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"03","nom_dept":"Allier","zone_rt":"H1c","pluvio_mm_an":780,"dju_base_18":2550,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"04","nom_dept":"Alpes-de-Haute-Provence","zone_rt":"H2d","pluvio_mm_an":750,"dju_base_18":2400,"zone_biogeo":6,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"05","nom_dept":"Hautes-Alpes","zone_rt":"H1c","pluvio_mm_an":830,"dju_base_18":3100,"zone_biogeo":6,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"06","nom_dept":"Alpes-Maritimes","zone_rt":"H3","pluvio_mm_an":820,"dju_base_18":1500,"zone_biogeo":5,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"07","nom_dept":"Ardèche","zone_rt":"H2d","pluvio_mm_an":1100,"dju_base_18":2200,"zone_biogeo":5,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"08","nom_dept":"Ardennes","zone_rt":"H1b","pluvio_mm_an":950,"dju_base_18":2900,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"09","nom_dept":"Ariège","zone_rt":"H2c","pluvio_mm_an":1000,"dju_base_18":2500,"zone_biogeo":7,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"10","nom_dept":"Aube","zone_rt":"H1b","pluvio_mm_an":700,"dju_base_18":2700,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"11","nom_dept":"Aude","zone_rt":"H3","pluvio_mm_an":650,"dju_base_18":1800,"zone_biogeo":5,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"12","nom_dept":"Aveyron","zone_rt":"H2c","pluvio_mm_an":900,"dju_base_18":2700,"zone_biogeo":7,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"13","nom_dept":"Bouches-du-Rhône","zone_rt":"H3","pluvio_mm_an":540,"dju_base_18":1750,"zone_biogeo":5,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"14","nom_dept":"Calvados","zone_rt":"H2a","pluvio_mm_an":850,"dju_base_18":2500,"zone_biogeo":1,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"15","nom_dept":"Cantal","zone_rt":"H1c","pluvio_mm_an":1100,"dju_base_18":3000,"zone_biogeo":7,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"16","nom_dept":"Charente","zone_rt":"H2b","pluvio_mm_an":850,"dju_base_18":2200,"zone_biogeo":2,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"17","nom_dept":"Charente-Maritime","zone_rt":"H2b","pluvio_mm_an":820,"dju_base_18":2100,"zone_biogeo":2,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"18","nom_dept":"Cher","zone_rt":"H1c","pluvio_mm_an":750,"dju_base_18":2500,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"19","nom_dept":"Corrèze","zone_rt":"H1c","pluvio_mm_an":1100,"dju_base_18":2600,"zone_biogeo":3,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"21","nom_dept":"Côte-d'Or","zone_rt":"H1c","pluvio_mm_an":750,"dju_base_18":2700,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"22","nom_dept":"Côtes-d'Armor","zone_rt":"H2a","pluvio_mm_an":900,"dju_base_18":2400,"zone_biogeo":1,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"23","nom_dept":"Creuse","zone_rt":"H1c","pluvio_mm_an":1000,"dju_base_18":2700,"zone_biogeo":3,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"24","nom_dept":"Dordogne","zone_rt":"H2c","pluvio_mm_an":900,"dju_base_18":2200,"zone_biogeo":2,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"25","nom_dept":"Doubs","zone_rt":"H1c","pluvio_mm_an":1200,"dju_base_18":3000,"zone_biogeo":6,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"26","nom_dept":"Drôme","zone_rt":"H2d","pluvio_mm_an":900,"dju_base_18":2200,"zone_biogeo":5,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"27","nom_dept":"Eure","zone_rt":"H1a","pluvio_mm_an":700,"dju_base_18":2500,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"28","nom_dept":"Eure-et-Loir","zone_rt":"H1a","pluvio_mm_an":650,"dju_base_18":2500,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"29","nom_dept":"Finistère","zone_rt":"H2a","pluvio_mm_an":1200,"dju_base_18":2300,"zone_biogeo":1,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"2A","nom_dept":"Corse-du-Sud","zone_rt":"H3","pluvio_mm_an":700,"dju_base_18":1500,"zone_biogeo":5,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"2B","nom_dept":"Haute-Corse","zone_rt":"H3","pluvio_mm_an":750,"dju_base_18":1600,"zone_biogeo":5,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"30","nom_dept":"Gard","zone_rt":"H3","pluvio_mm_an":700,"dju_base_18":1800,"zone_biogeo":5,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"31","nom_dept":"Haute-Garonne","zone_rt":"H2c","pluvio_mm_an":650,"dju_base_18":2200,"zone_biogeo":3,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"32","nom_dept":"Gers","zone_rt":"H2c","pluvio_mm_an":750,"dju_base_18":2200,"zone_biogeo":3,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"33","nom_dept":"Gironde","zone_rt":"H2c","pluvio_mm_an":900,"dju_base_18":2050,"zone_biogeo":2,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"34","nom_dept":"Hérault","zone_rt":"H3","pluvio_mm_an":650,"dju_base_18":1700,"zone_biogeo":5,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"35","nom_dept":"Ille-et-Vilaine","zone_rt":"H2a","pluvio_mm_an":750,"dju_base_18":2400,"zone_biogeo":1,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"36","nom_dept":"Indre","zone_rt":"H1c","pluvio_mm_an":750,"dju_base_18":2500,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"37","nom_dept":"Indre-et-Loire","zone_rt":"H2b","pluvio_mm_an":700,"dju_base_18":2400,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"38","nom_dept":"Isère","zone_rt":"H1c","pluvio_mm_an":1000,"dju_base_18":2700,"zone_biogeo":6,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"39","nom_dept":"Jura","zone_rt":"H1c","pluvio_mm_an":1200,"dju_base_18":2900,"zone_biogeo":6,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"40","nom_dept":"Landes","zone_rt":"H2c","pluvio_mm_an":1100,"dju_base_18":2000,"zone_biogeo":2,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"41","nom_dept":"Loir-et-Cher","zone_rt":"H1c","pluvio_mm_an":700,"dju_base_18":2500,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"42","nom_dept":"Loire","zone_rt":"H1c","pluvio_mm_an":750,"dju_base_18":2600,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"43","nom_dept":"Haute-Loire","zone_rt":"H1c","pluvio_mm_an":900,"dju_base_18":2900,"zone_biogeo":7,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"44","nom_dept":"Loire-Atlantique","zone_rt":"H2b","pluvio_mm_an":820,"dju_base_18":2300,"zone_biogeo":1,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"45","nom_dept":"Loiret","zone_rt":"H1a","pluvio_mm_an":650,"dju_base_18":2500,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"46","nom_dept":"Lot","zone_rt":"H2c","pluvio_mm_an":850,"dju_base_18":2300,"zone_biogeo":3,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"47","nom_dept":"Lot-et-Garonne","zone_rt":"H2c","pluvio_mm_an":800,"dju_base_18":2150,"zone_biogeo":3,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"48","nom_dept":"Lozère","zone_rt":"H2d","pluvio_mm_an":1100,"dju_base_18":2900,"zone_biogeo":7,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"49","nom_dept":"Maine-et-Loire","zone_rt":"H2b","pluvio_mm_an":700,"dju_base_18":2400,"zone_biogeo":1,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"50","nom_dept":"Manche","zone_rt":"H2a","pluvio_mm_an":900,"dju_base_18":2400,"zone_biogeo":1,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"51","nom_dept":"Marne","zone_rt":"H1b","pluvio_mm_an":700,"dju_base_18":2700,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"52","nom_dept":"Haute-Marne","zone_rt":"H1b","pluvio_mm_an":850,"dju_base_18":2800,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"53","nom_dept":"Mayenne","zone_rt":"H2b","pluvio_mm_an":750,"dju_base_18":2400,"zone_biogeo":1,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"54","nom_dept":"Meurthe-et-Moselle","zone_rt":"H1b","pluvio_mm_an":800,"dju_base_18":2800,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"55","nom_dept":"Meuse","zone_rt":"H1b","pluvio_mm_an":900,"dju_base_18":2800,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"56","nom_dept":"Morbihan","zone_rt":"H2a","pluvio_mm_an":900,"dju_base_18":2300,"zone_biogeo":1,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"57","nom_dept":"Moselle","zone_rt":"H1b","pluvio_mm_an":800,"dju_base_18":2800,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"58","nom_dept":"Nièvre","zone_rt":"H1c","pluvio_mm_an":800,"dju_base_18":2600,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"59","nom_dept":"Nord","zone_rt":"H1a","pluvio_mm_an":750,"dju_base_18":2600,"zone_biogeo":1,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"60","nom_dept":"Oise","zone_rt":"H1a","pluvio_mm_an":700,"dju_base_18":2500,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"61","nom_dept":"Orne","zone_rt":"H2a","pluvio_mm_an":800,"dju_base_18":2500,"zone_biogeo":1,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"62","nom_dept":"Pas-de-Calais","zone_rt":"H1a","pluvio_mm_an":850,"dju_base_18":2600,"zone_biogeo":1,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"63","nom_dept":"Puy-de-Dôme","zone_rt":"H1c","pluvio_mm_an":800,"dju_base_18":2700,"zone_biogeo":7,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"64","nom_dept":"Pyrénées-Atlantiques","zone_rt":"H2c","pluvio_mm_an":1300,"dju_base_18":2100,"zone_biogeo":2,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"65","nom_dept":"Hautes-Pyrénées","zone_rt":"H2c","pluvio_mm_an":1000,"dju_base_18":2400,"zone_biogeo":7,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"66","nom_dept":"Pyrénées-Orientales","zone_rt":"H3","pluvio_mm_an":600,"dju_base_18":1700,"zone_biogeo":5,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"67","nom_dept":"Bas-Rhin","zone_rt":"H1b","pluvio_mm_an":650,"dju_base_18":2700,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"68","nom_dept":"Haut-Rhin","zone_rt":"H1b","pluvio_mm_an":700,"dju_base_18":2700,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"69","nom_dept":"Rhône","zone_rt":"H1c","pluvio_mm_an":830,"dju_base_18":2500,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"70","nom_dept":"Haute-Saône","zone_rt":"H1b","pluvio_mm_an":1050,"dju_base_18":2800,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"71","nom_dept":"Saône-et-Loire","zone_rt":"H1c","pluvio_mm_an":800,"dju_base_18":2600,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"72","nom_dept":"Sarthe","zone_rt":"H2b","pluvio_mm_an":700,"dju_base_18":2400,"zone_biogeo":1,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"73","nom_dept":"Savoie","zone_rt":"H1c","pluvio_mm_an":1200,"dju_base_18":3000,"zone_biogeo":6,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"74","nom_dept":"Haute-Savoie","zone_rt":"H1c","pluvio_mm_an":1300,"dju_base_18":2900,"zone_biogeo":6,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"75","nom_dept":"Paris","zone_rt":"H1a","pluvio_mm_an":640,"dju_base_18":2450,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"76","nom_dept":"Seine-Maritime","zone_rt":"H1a","pluvio_mm_an":850,"dju_base_18":2500,"zone_biogeo":1,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"77","nom_dept":"Seine-et-Marne","zone_rt":"H1a","pluvio_mm_an":680,"dju_base_18":2500,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"78","nom_dept":"Yvelines","zone_rt":"H1a","pluvio_mm_an":680,"dju_base_18":2500,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"79","nom_dept":"Deux-Sèvres","zone_rt":"H2b","pluvio_mm_an":850,"dju_base_18":2300,"zone_biogeo":2,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"80","nom_dept":"Somme","zone_rt":"H1a","pluvio_mm_an":750,"dju_base_18":2600,"zone_biogeo":1,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"81","nom_dept":"Tarn","zone_rt":"H2c","pluvio_mm_an":850,"dju_base_18":2200,"zone_biogeo":3,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"82","nom_dept":"Tarn-et-Garonne","zone_rt":"H2c","pluvio_mm_an":750,"dju_base_18":2150,"zone_biogeo":3,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"83","nom_dept":"Var","zone_rt":"H3","pluvio_mm_an":700,"dju_base_18":1600,"zone_biogeo":5,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"84","nom_dept":"Vaucluse","zone_rt":"H3","pluvio_mm_an":650,"dju_base_18":1850,"zone_biogeo":5,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"85","nom_dept":"Vendée","zone_rt":"H2b","pluvio_mm_an":820,"dju_base_18":2300,"zone_biogeo":2,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"86","nom_dept":"Vienne","zone_rt":"H2b","pluvio_mm_an":750,"dju_base_18":2400,"zone_biogeo":2,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"87","nom_dept":"Haute-Vienne","zone_rt":"H1c","pluvio_mm_an":1050,"dju_base_18":2500,"zone_biogeo":3,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"88","nom_dept":"Vosges","zone_rt":"H1b","pluvio_mm_an":1100,"dju_base_18":2900,"zone_biogeo":6,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"89","nom_dept":"Yonne","zone_rt":"H1b","pluvio_mm_an":700,"dju_base_18":2600,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"90","nom_dept":"Territoire de Belfort","zone_rt":"H1b","pluvio_mm_an":1100,"dju_base_18":2800,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"91","nom_dept":"Essonne","zone_rt":"H1a","pluvio_mm_an":680,"dju_base_18":2500,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"92","nom_dept":"Hauts-de-Seine","zone_rt":"H1a","pluvio_mm_an":640,"dju_base_18":2450,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"93","nom_dept":"Seine-Saint-Denis","zone_rt":"H1a","pluvio_mm_an":640,"dju_base_18":2450,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"94","nom_dept":"Val-de-Marne","zone_rt":"H1a","pluvio_mm_an":640,"dju_base_18":2450,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"95","nom_dept":"Val-d'Oise","zone_rt":"H1a","pluvio_mm_an":680,"dju_base_18":2500,"zone_biogeo":4,"verifie":false,"source_climat":"Météo-France + RE2020","source_date":"2026-04-28"},
{"dept":"971","nom_dept":"Guadeloupe","zone_rt":"H3","pluvio_mm_an":null,"dju_base_18":null,"zone_biogeo":8,"verifie":false,"source_climat":"DOM tropical — pluvio variable 1000-3500mm selon altitude, DJU non pertinent","source_date":"2026-04-28"},
{"dept":"972","nom_dept":"Martinique","zone_rt":"H3","pluvio_mm_an":null,"dju_base_18":null,"zone_biogeo":8,"verifie":false,"source_climat":"DOM tropical — idem","source_date":"2026-04-28"},
{"dept":"973","nom_dept":"Guyane","zone_rt":"H3","pluvio_mm_an":null,"dju_base_18":null,"zone_biogeo":8,"verifie":false,"source_climat":"DOM équatorial — pluvio 2000-4000mm","source_date":"2026-04-28"},
{"dept":"974","nom_dept":"La Réunion","zone_rt":"H3","pluvio_mm_an":null,"dju_base_18":null,"zone_biogeo":8,"verifie":false,"source_climat":"DOM tropical insulaire — pluvio très variable littoral/montagne","source_date":"2026-04-28"},
{"dept":"976","nom_dept":"Mayotte","zone_rt":"H3","pluvio_mm_an":null,"dju_base_18":null,"zone_biogeo":8,"verifie":false,"source_climat":"DOM tropical — pluvio 1000-1500mm","source_date":"2026-04-28"}
]
File diff suppressed because it is too large Load Diff
+170
View File
@@ -0,0 +1,170 @@
[
{
"Id": 1,
"CreatedAt": "2026-04-28 19:54:29+00:00",
"UpdatedAt": null,
"axe": "eau",
"code": "eau_captage",
"poste": "Captage",
"ordre": 10,
"description": "Comment vous récupérez l'eau de pluie",
"obligatoire": true,
"actif": true
},
{
"Id": 2,
"CreatedAt": "2026-04-28 19:54:29+00:00",
"UpdatedAt": null,
"axe": "eau",
"code": "eau_filtration",
"poste": "Filtration",
"ordre": 20,
"description": "Étapes de potabilisation",
"obligatoire": true,
"actif": true
},
{
"Id": 3,
"CreatedAt": "2026-04-28 19:54:29+00:00",
"UpdatedAt": null,
"axe": "eau",
"code": "eau_stockage",
"poste": "Stockage",
"ordre": 30,
"description": "Volume cuve selon nb pers et pluvio",
"obligatoire": true,
"actif": true
},
{
"Id": 4,
"CreatedAt": "2026-04-28 19:54:29+00:00",
"UpdatedAt": null,
"axe": "eau",
"code": "eau_distribution",
"poste": "Distribution",
"ordre": 40,
"description": "Pompes et réseau de distribution",
"obligatoire": true,
"actif": true
},
{
"Id": 5,
"CreatedAt": "2026-04-28 19:54:29+00:00",
"UpdatedAt": null,
"axe": "eau",
"code": "eau_eg_epuration",
"poste": "Eaux grises + Épuration",
"ordre": 50,
"description": "Recyclage EG et assainissement",
"obligatoire": false,
"actif": true
},
{
"Id": 6,
"CreatedAt": "2026-04-28 19:54:29+00:00",
"UpdatedAt": null,
"axe": "energie",
"code": "energie_pv",
"poste": "Solaire PV",
"ordre": 10,
"description": "Production électrique panneaux + batteries",
"obligatoire": false,
"actif": true
},
{
"Id": 7,
"CreatedAt": "2026-04-28 19:54:29+00:00",
"UpdatedAt": null,
"axe": "energie",
"code": "energie_solaire_th",
"poste": "Solaire thermique",
"ordre": 20,
"description": "CESI / SSC pour ECS et chauffage",
"obligatoire": false,
"actif": true
},
{
"Id": 8,
"CreatedAt": "2026-04-28 19:54:29+00:00",
"UpdatedAt": null,
"axe": "energie",
"code": "energie_chauffage_bois",
"poste": "Chauffage bois",
"ordre": 30,
"description": "Poêles et chaudières bois ou granulés",
"obligatoire": false,
"actif": true
},
{
"Id": 9,
"CreatedAt": "2026-04-28 19:54:29+00:00",
"UpdatedAt": null,
"axe": "energie",
"code": "energie_emetteurs",
"poste": "Émetteurs",
"ordre": 40,
"description": "Radiateurs, plancher chauffant, mur chauffant",
"obligatoire": false,
"actif": true
},
{
"Id": 10,
"CreatedAt": "2026-04-28 19:54:29+00:00",
"UpdatedAt": null,
"axe": "energie",
"code": "energie_pilotage",
"poste": "Pilotage / ilotage",
"ordre": 50,
"description": "Monitoring, onduleur hybride, ilotage",
"obligatoire": false,
"actif": true
},
{
"Id": 11,
"CreatedAt": "2026-04-28 19:54:29+00:00",
"UpdatedAt": null,
"axe": "alimentation",
"code": "alim_etude_fonciere",
"poste": "Étude foncière",
"ordre": 10,
"description": "Diagnostic sol, topo, plan paysager",
"obligatoire": false,
"actif": true
},
{
"Id": 12,
"CreatedAt": "2026-04-28 19:54:29+00:00",
"UpdatedAt": null,
"axe": "alimentation",
"code": "alim_strate_haute",
"poste": "Strate haute",
"ordre": 20,
"description": "Arbres fruitiers haute-tige et demi-tige",
"obligatoire": false,
"actif": true
},
{
"Id": 13,
"CreatedAt": "2026-04-28 19:54:29+00:00",
"UpdatedAt": null,
"axe": "alimentation",
"code": "alim_strate_moyenne",
"poste": "Strate moyenne",
"ordre": 30,
"description": "Petits fruits et haies fruitières",
"obligatoire": false,
"actif": true
},
{
"Id": 14,
"CreatedAt": "2026-04-28 19:54:29+00:00",
"UpdatedAt": null,
"axe": "alimentation",
"code": "alim_strate_basse",
"poste": "Strate basse",
"ordre": 40,
"description": "Vivaces, médicinales, légumes",
"obligatoire": false,
"actif": true
}
]
+64
View File
@@ -0,0 +1,64 @@
[
{
"nom": "A — Frugal urbain",
"inputs": {
"budget_eur": 15000,
"code_postal": "75019",
"nb_personnes": 2,
"surface_parcelle_m2": 200,
"surface_toit_m2": 40,
"orientation_toit": "sud",
"pente_toit_deg": 30,
"metres_haie_possibles": 0,
"temps_dispo": 1
},
"notes": "Toit 40m2 → énergie+eau prioritaires; parcelle 200m2 < 300 → alim=0; temps=1 → pas complexite_pose=3",
"attendus_indicatifs": [
"captage_filtre_collecteur",
"filtration_particulaire_simple",
"stockage_pehd_horssol_5m3",
"pv_kit_3kwc_string"
]
},
{
"nom": "B — Famille moyenne",
"inputs": {
"budget_eur": 35000,
"code_postal": "31000",
"nb_personnes": 4,
"surface_parcelle_m2": 800,
"surface_toit_m2": 80,
"orientation_toit": "sud-est",
"pente_toit_deg": 30,
"metres_haie_possibles": 50,
"temps_dispo": 2
},
"notes": "4 pers, 800m2 parcelle, toit 80m2. Cuve ~9m3, PV 9kWc (80×0.15×0.93 clamp 9), potager.",
"attendus_indicatifs": [
"stockage_beton_5m3",
"pv_kit_3kwc_string",
"basse_serre_6m2"
]
},
{
"nom": "C — Autonomie large",
"inputs": {
"budget_eur": 80000,
"code_postal": "13100",
"nb_personnes": 6,
"surface_parcelle_m2": 2500,
"surface_toit_m2": 120,
"orientation_toit": "sud",
"pente_toit_deg": 25,
"metres_haie_possibles": 100,
"temps_dispo": 3
},
"notes": "6 pers, 2500m2, H3 (13xxx = Bouches-du-Rhône). Grande parcelle + temps=3 → alim max. PV 9kWc clamp (120×0.15×1.0=18 → clamp 9). Bois poêle hydro requis pour budget.",
"attendus_indicatifs": [
"stockage_beton_5m3",
"pv_kit_3kwc_string",
"bois_poele_hydro",
"basse_serre_6m2"
]
}
]
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@transformer/moteurs",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@transformer/moteurs",
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
"typescript": "^5.9.0"
},
"engines": {
"node": ">=22"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}
+66
View File
@@ -0,0 +1,66 @@
{
"name": "@transformer/moteurs",
"version": "0.1.0",
"description": "Moteurs de calcul purs pour la rénovation et l'autonomie d'une maison, partagés par calculs.trans-former.fr et plateforme.trans-former.fr. Code et coefficients sourcés ; jamais de prix dans le paquet.",
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://git.trans-former.fr/jules/moteurs.git"
},
"engines": {
"node": ">=22"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./commun": {
"types": "./dist/commun/index.d.ts",
"default": "./dist/commun/index.js"
},
"./autonomie": {
"types": "./dist/autonomie/index.d.ts",
"default": "./dist/autonomie/index.js"
},
"./autonomie/eau": {
"types": "./dist/autonomie/eau.d.ts",
"default": "./dist/autonomie/eau.js"
},
"./autonomie/energie": {
"types": "./dist/autonomie/energie.d.ts",
"default": "./dist/autonomie/energie.js"
},
"./autonomie/alimentation": {
"types": "./dist/autonomie/alimentation.d.ts",
"default": "./dist/autonomie/alimentation.js"
},
"./autonomie/integrateur": {
"types": "./dist/autonomie/integrateur.d.ts",
"default": "./dist/autonomie/integrateur.js"
},
"./autonomie/noyau": {
"types": "./dist/autonomie/noyau/index.d.ts",
"default": "./dist/autonomie/noyau/index.js"
},
"./schemas/*": "./schemas/*",
"./fixtures/*": "./fixtures/*",
"./package.json": "./package.json"
},
"files": [
"dist",
"src",
"schemas",
"fixtures"
],
"scripts": {
"build": "node scripts/nettoyer-dist.mjs && tsc -p tsconfig.json && node scripts/ecrire-schemas.mjs",
"pretest": "npm run build",
"test": "node --test",
"ecarts": "node scripts/ecarts-harmonisation.mjs"
},
"devDependencies": {
"typescript": "^5.9.0"
}
}
+65
View File
@@ -0,0 +1,65 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.trans-former.fr/jules/moteurs/schemas/algo_config.schema.json",
"description": "Heuristiques de conception du simulateur autonomie (poids, modulateurs, matrices, tolérances, alias, poids des postes). Données non sourcées, injectées, jamais importées.",
"type": "object",
"required": [
"poids_axes_base",
"modulateurs",
"adequation_weights",
"tolerances",
"bonus_malus",
"couplages_alias",
"postes_poids"
],
"properties": {
"poids_axes_base": {
"type": "object",
"required": [
"eau",
"energie",
"alim"
],
"properties": {
"eau": {
"type": "number"
},
"energie": {
"type": "number"
},
"alim": {
"type": "number"
}
}
},
"modulateurs": {
"type": "object"
},
"adequation_weights": {
"type": "object"
},
"tolerances": {
"type": "object",
"properties": {
"budget_overrun_pct": {
"type": "number"
},
"budget_underspend_pct": {
"type": "number"
},
"tolerance_par_poste_pct": {
"type": "number"
}
}
},
"bonus_malus": {
"type": "object"
},
"couplages_alias": {
"type": "object"
},
"postes_poids": {
"type": "object"
}
}
}
+68
View File
@@ -0,0 +1,68 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.trans-former.fr/jules/moteurs/schemas/france_climat.schema.json",
"description": "Une ligne par département (2A, 2B et 971-976 compris) : zone RT, pluviométrie, DJU base 18, zone biogéographique numérique.",
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": [
"dept",
"zone_rt"
],
"properties": {
"dept": {
"type": "string"
},
"nom_dept": {
"type": [
"string",
"null"
]
},
"zone_rt": {
"type": "string",
"enum": [
"H1a",
"H1b",
"H1c",
"H2a",
"H2b",
"H2c",
"H2d",
"H3"
]
},
"pluvio_mm_an": {
"type": [
"number",
"null"
]
},
"dju_base_18": {
"type": [
"number",
"null"
]
},
"zone_biogeo": {
"type": [
"number",
"null"
]
},
"source_climat": {
"type": [
"string",
"null"
]
},
"source_date": {
"type": [
"string",
"null"
]
}
}
}
}
+183
View File
@@ -0,0 +1,183 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.trans-former.fr/jules/moteurs/schemas/options.schema.json",
"description": "Typologies (options) du simulateur autonomie. Les prix y sont des DONNÉES publiées par calculs, jamais des constantes du paquet. parametre_dim, couplages_requis et couplages_exclus arrivent en JSON sérialisé (NocoDB) ou déjà parsés.",
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": [
"Id",
"poste_id",
"code",
"nom"
],
"properties": {
"Id": {
"type": "integer"
},
"poste_id": {
"type": "integer"
},
"code": {
"type": "string"
},
"nom": {
"type": "string"
},
"typologie": {
"type": [
"string",
"null"
]
},
"actif": {
"type": [
"boolean",
"null"
]
},
"prix_mat_min": {
"type": [
"number",
"null"
]
},
"prix_mat_max": {
"type": [
"number",
"null"
]
},
"prix_pro_min": {
"type": [
"number",
"null"
]
},
"prix_pro_max": {
"type": [
"number",
"null"
]
},
"prix_par_unite": {
"type": [
"number",
"null"
]
},
"prix_fixe": {
"type": [
"number",
"null"
]
},
"unite": {
"type": [
"string",
"null"
]
},
"quantite_default": {
"type": [
"number",
"null"
]
},
"parametre_dim": {
"type": [
"string",
"object",
"null"
]
},
"couplages_requis": {
"type": [
"string",
"array",
"null"
]
},
"couplages_exclus": {
"type": [
"string",
"array",
"null"
]
},
"flag_diy": {
"type": [
"boolean",
"null"
]
},
"complexite_pose": {
"type": [
"number",
"null"
]
},
"accessible_fr": {
"type": [
"boolean",
"null"
]
},
"filiere_agreee": {
"type": [
"boolean",
"null"
]
},
"surface_min_m2": {
"type": [
"number",
"null"
]
},
"surface_optimale_m2": {
"type": [
"number",
"null"
]
},
"tags": {
"type": [
"array",
"null"
]
},
"source": {
"type": [
"string",
"null"
]
},
"source_date": {
"type": [
"string",
"null"
]
},
"description": {
"type": [
"string",
"null"
]
},
"description_pedago": {
"type": [
"string",
"null"
]
},
"notes_techniques": {
"type": [
"string",
"null"
]
}
}
}
}
+59
View File
@@ -0,0 +1,59 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.trans-former.fr/jules/moteurs/schemas/postes.schema.json",
"description": "Postes du simulateur autonomie (calculs.trans-former.fr/calculs/data/postes.json). Champs lus par les moteurs.",
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": [
"Id",
"axe",
"code",
"poste"
],
"properties": {
"Id": {
"type": "integer"
},
"axe": {
"type": "string",
"enum": [
"eau",
"energie",
"alimentation"
]
},
"code": {
"type": "string"
},
"poste": {
"type": "string"
},
"ordre": {
"type": [
"number",
"null"
]
},
"obligatoire": {
"type": [
"boolean",
"null"
]
},
"actif": {
"type": [
"boolean",
"null"
]
},
"description": {
"type": [
"string",
"null"
]
}
}
}
}
+83
View File
@@ -0,0 +1,83 @@
// ecarts-harmonisation.mjs — fait tourner les trois profils de référence avec
// le jeu de coefficients D'ORIGINE puis avec la table HARMONISÉE, et écrit les
// écarts de sélection ligne par ligne (markdown sur stdout ; JSON dans
// fixtures/ecarts-harmonisation-<date>.json avec --ecrire). Ce sont ces écarts
// que Jules relit avec docs/CONSTANTES.md (LOT P1.0 point 4, amendement du 28/09).
// Usage : node scripts/ecarts-harmonisation.mjs [--ecrire AAAA-MM-JJ]
import { readFileSync, writeFileSync } from 'node:fs'
import { COEFFICIENTS } from '../dist/commun/coefficients.js'
import { deriveContexte } from '../dist/commun/contexte.js'
import { eau, energie, alimentation, integrateur } from '../dist/autonomie/index.js'
const fx = new URL('../fixtures/', import.meta.url)
const lire = (f) => JSON.parse(readFileSync(new URL(f, fx), 'utf8'))
const options = lire('options.json')
const postes = lire('postes.json')
const france_climat = lire('france_climat.json')
const algo_config = lire('algo-config.json')
const profils = lire('profils-reference.json')
const origine = lire('coefficients-2026-05.json')
const AXES = ['eau', 'energie', 'alim']
const MOTEURS = { eau, energie, alim: alimentation }
export function calculeEcarts() {
const lignes = []
for (const profil of profils) {
for (const jeu of [
['origine', origine],
['harmonisee', COEFFICIENTS],
]) {
const [nom, coefficients] = jeu
const donnees = { postes, options, algo_config, france_climat, coefficients }
const g = integrateur.calculer({ autodiag: profil.inputs, budget_total: profil.inputs.budget_eur, mode: 'global' }, donnees).sorties
const contexte = deriveContexte(profil.inputs.code_postal, france_climat, coefficients).sorties
const par_axe = {}
for (const axe of AXES) {
const r = MOTEURS[axe].calculer({ autodiag: profil.inputs, budget_axe: g.budgets[axe], contexte }, donnees).sorties
par_axe[axe] = { typologies: r.typologies, sous_total: r.sous_total_eur_ht, status: r.status, taux: r.taux_couverture }
}
lignes.push({ profil: profil.nom, jeu: nom, global: { total: g.total_eur_ht, status: g.status, typologies: Object.fromEntries(AXES.map((a) => [a, g.axes[a].typologies])), taux: g.taux_couverture }, par_axe })
}
}
// Diff origine → harmonisée, par profil
const ecarts = []
for (const profil of profils) {
const o = lignes.find((l) => l.profil === profil.nom && l.jeu === 'origine')
const h = lignes.find((l) => l.profil === profil.nom && l.jeu === 'harmonisee')
for (const axe of AXES) {
const to = o.global.typologies[axe].join(' + ')
const th = h.global.typologies[axe].join(' + ')
if (to !== th) ecarts.push({ profil: profil.nom, mode: 'global', axe, origine: to, harmonisee: th })
const ao = o.par_axe[axe]
const ah = h.par_axe[axe]
if (ao.typologies.join('+') !== ah.typologies.join('+') || ao.sous_total !== ah.sous_total) {
ecarts.push({ profil: profil.nom, mode: 'par_axe', axe, origine: `${ao.typologies.join(' + ')} (${ao.sous_total} €)`, harmonisee: `${ah.typologies.join(' + ')} (${ah.sous_total} €)` })
}
if (ao.taux !== ah.taux) ecarts.push({ profil: profil.nom, mode: 'taux (par_axe)', axe, origine: `${ao.taux} %`, harmonisee: `${ah.taux} %` })
if (o.global.taux[axe] !== h.global.taux[axe]) ecarts.push({ profil: profil.nom, mode: 'taux (global)', axe, origine: `${o.global.taux[axe]} %`, harmonisee: `${h.global.taux[axe]} %` })
}
if (o.global.total !== h.global.total || o.global.status !== h.global.status) {
ecarts.push({ profil: profil.nom, mode: 'global', axe: 'total', origine: `${o.global.total} € (${o.global.status})`, harmonisee: `${h.global.total} € (${h.global.status})` })
}
}
return { lignes, ecarts }
}
const estCli = import.meta.url === new URL(`file:///${process.argv[1].replace(/\\/g, '/')}`).href || process.argv[1]?.endsWith('ecarts-harmonisation.mjs')
if (estCli) {
const { lignes, ecarts } = calculeEcarts()
console.log('| Profil | Mode | Axe | Origine (coefficients du code) | Harmonisée (table du paquet) |')
console.log('|---|---|---|---|---|')
for (const e of ecarts) console.log(`| ${e.profil} | ${e.mode} | ${e.axe} | ${e.origine} | ${e.harmonisee} |`)
if (ecarts.length === 0) console.log('| (aucun écart) | | | | |')
console.log()
console.log('Taux de couverture (eau / énergie / alim), par profil :')
for (const l of lignes) console.log(`- ${l.profil} — ${l.jeu} : ${AXES.map((a) => `${a} ${l.global.taux[a] ?? 'null'} %`).join(' · ')} — total ${l.global.total} € (${l.global.status})`)
const i = process.argv.indexOf('--ecrire')
if (i > 0) {
const date = process.argv[i + 1] ?? new Date().toISOString().slice(0, 10)
const cible = new URL(`ecarts-harmonisation-${date}.json`, fx)
writeFileSync(cible, JSON.stringify({ _commentaire: `Écarts de sélection et de taux entre le jeu de coefficients d'origine et la table harmonisée, sur les trois profils de référence, figés le ${date}. Un changement de coefficient ou de code qui modifie ces écarts casse test/decoupage.test.mjs : c'est voulu, on relit CONSTANTES.md avant de refiger.`, date, ecarts, lignes }, null, 2) + '\n', 'utf8')
console.log(`\nécrit : fixtures/ecarts-harmonisation-${date}.json`)
}
}
+12
View File
@@ -0,0 +1,12 @@
// Réécrit schemas/<table>.schema.json depuis dist/commun/schemas.js (source de
// vérité : src/commun/schemas.ts). Lancé par `npm run build` après tsc.
import { writeFileSync, mkdirSync } from 'node:fs'
import { SCHEMAS } from '../dist/commun/schemas.js'
const dossier = new URL('../schemas/', import.meta.url)
mkdirSync(dossier, { recursive: true })
for (const [table, schema] of Object.entries(SCHEMAS)) {
const json = { $schema: 'https://json-schema.org/draft/2020-12/schema', ...schema }
writeFileSync(new URL(`${table}.schema.json`, dossier), JSON.stringify(json, null, 2) + '\n', 'utf8')
}
console.log(`schemas/ : ${Object.keys(SCHEMAS).length} fichiers écrits`)
+3
View File
@@ -0,0 +1,3 @@
// Vide dist/ avant tsc (portable Windows / Linux, sans rm -rf).
import { rmSync } from 'node:fs'
rmSync(new URL('../dist/', import.meta.url), { recursive: true, force: true })
+76
View File
@@ -0,0 +1,76 @@
// pin-origine.mjs — génère fixtures/sorties-origine-<date>.json DEPUIS LE CODE
// D'ORIGINE (clone astro-pro, src/lib/autonomie), pas depuis ce paquet : c'est
// ce qui fait du test de non-régression une vraie épingle.
//
// Rejouable seulement avec un clone d'astro-pro à la révision d'origine
// (master c628cf9 au 2026-09-28) : ASTRO_PRO_DIR=~/dev/astro-pro par défaut.
// Usage : node scripts/pin-origine.mjs [AAAA-MM-JJ]
import { readFileSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
const ASTRO = process.env.ASTRO_PRO_DIR ?? join(homedir(), 'dev', 'astro-pro')
const date = process.argv[2] ?? new Date().toISOString().slice(0, 10)
const lib = (f) => pathToFileURL(join(ASTRO, 'src', 'lib', 'autonomie', f)).href
const { selectionner } = await import(lib('algo.js'))
const { configPourAxes } = await import(lib('axes-actifs.js'))
const { initConfig } = await import(lib('calibrage.js'))
const fx = new URL('../fixtures/', import.meta.url)
const lire = (f) => JSON.parse(readFileSync(new URL(f, fx), 'utf8'))
const options = lire('options.json')
const postes = lire('postes.json')
const climat = lire('france_climat.json')
const config = lire('algo-config.json')
const profils = lire('profils-reference.json')
initConfig(config)
const AXES = ['eau', 'energie', 'alim']
const resume = (r) => ({
total_eur_ht: r.total_eur_ht,
status: r.status,
ecart_budget_pct: r.ecart_budget_pct,
axes: Object.fromEntries(
AXES.map((a) => [
a,
{
budget_alloue: r.axes[a]?.budget_alloue ?? 0,
poids: r.axes[a]?.poids ?? 0,
sous_total_eur_ht: r.axes[a]?.sous_total_eur_ht ?? 0,
postes: (r.axes[a]?.postes ?? []).map((p) => ({
poste_id: p.poste_id,
typologie_choisie: p.typologie_choisie,
dimension: p.dimension ? { valeur: p.dimension.valeur, unite: p.dimension.unite } : null,
cout_eur_ht: p.cout_eur_ht,
force_par_couplage: !!p.force_par_couplage,
})),
},
]),
),
couplages: r.couplages,
})
const sortie = {
_commentaire: `Sorties de selectionner() D'ORIGINE (astro-pro src/lib/autonomie, coefficients du code) sur les trois profils de référence, générées le ${date} par scripts/pin-origine.mjs. 'global' = pipeline complet ; 'par_axe' = mode un seul axe actif (configPourAxes) avec le budget que le pipeline global avait alloué à cet axe.`,
date,
profils: {},
}
for (const profil of profils) {
const global = selectionner(profil.inputs, options, postes, climat, config, false)
const par_axe = {}
for (const axe of AXES) {
const budget = global.axes[axe]?.budget_alloue ?? 0
const actifs = Object.fromEntries(AXES.map((a) => [a, a === axe]))
const r = selectionner({ ...profil.inputs, budget_eur: budget }, options, postes, climat, configPourAxes(config, actifs), false)
par_axe[axe] = { budget_axe: budget, ...resume(r) }
}
sortie.profils[profil.nom] = { inputs: profil.inputs, global: resume(global), par_axe }
}
const cible = new URL(`sorties-origine-${date}.json`, fx)
writeFileSync(cible, JSON.stringify(sortie, null, 2) + '\n', 'utf8')
console.log(`écrit : fixtures/sorties-origine-${date}.json`)
for (const [nom, p] of Object.entries(sortie.profils)) {
console.log(`${nom} : total ${p.global.total_eur_ht} (${p.global.status})`, AXES.map((a) => `${a}=${p.global.axes[a].postes.map((x) => x.typologie_choisie).join('+')}`).join(' | '))
}
+8
View File
@@ -0,0 +1,8 @@
// alimentation.ts — moteur `autonomie.alimentation` : étude foncière, strates
// haute, moyenne (haies) et basse, pour un budget d'axe donné.
// Code d'axe interne : 'alim' (postes.json dit « alimentation »).
import { moteurAxe } from './axe.js'
export type { EntreesAxe, DonneesAutonomie, SortiesAxe } from './axe.js'
export const alimentation = moteurAxe('alim', 'autonomie.alimentation')
export default alimentation
+143
View File
@@ -0,0 +1,143 @@
// axe.ts — fabrique des moteurs d'axe `autonomie.eau`, `autonomie.energie`,
// `autonomie.alimentation` (LOT P1.0, découpage par axe).
//
// Un moteur d'axe = selectionnePoste(axe) + couplages de l'axe + réajustement
// au budget de l'axe + taux de couverture de l'axe. Il réutilise la queue du
// pipeline d'origine (`finaliseSelection`) sur une sélection où les deux
// autres axes sont vides et désactivés (`configPourAxes`) : par construction,
// il rend exactement ce que `selectionner()` rend en mode « un seul axe
// actif » avec ce budget — c'est le test de non-régression du découpage.
import type { Moteur, Resultat } from '../commun/contrat.js'
import { Trace } from '../commun/contrat.js'
import { COEFFICIENTS, type TableCoefficients } from '../commun/coefficients.js'
import type { Contexte } from '../commun/contexte.js'
import { VERSION } from '../version.js'
import { finaliseSelection } from './noyau/algo.js'
import { configPourAxes } from './noyau/axes-actifs.js'
import { selectionnePoste } from './noyau/selectionne-poste.js'
import type { AlgoConfig, Autodiag, Axe, AxeResultat, Option, Poste, PosteResultat, Reglages, Selection, Statut } from './noyau/types.js'
import { AXES } from './noyau/types.js'
import { aCaptage, batterieSelectionnee, kwcSelectionnes, mlHaiesSelectionnes, tauxAlim, tauxEau, tauxEnergie, type DetailAlim, type DetailEau, type DetailEnergie } from './taux.js'
export interface EntreesAxe {
autodiag: Autodiag
/** Budget alloué à cet axe (EUR HT). */
budget_axe: number
/** Contexte climatique (moteur `commun.contexte`). */
contexte: Contexte
}
export interface DonneesAutonomie {
postes: Poste[]
options: Option[]
algo_config: AlgoConfig
/** Table de coefficients ; par défaut la table harmonisée du paquet. */
coefficients?: TableCoefficients
}
export interface SortiesAxe {
axe: Axe
postes: PosteResultat[]
/** Codes des typologies retenues, dans l'ordre des postes. */
typologies: string[]
sous_total_eur_ht: number
budget_alloue: number
ecart_budget_pct: number
status: Statut
message_status?: string
/** Journal des couplages (FORCE / MANQUANT / WARN / CONFLIT). */
couplages: string[]
/** Taux de couverture de l'axe en %, null si non calculable. */
taux_couverture: number | null
taux_detail: DetailEau | DetailEnergie | DetailAlim | null
}
/** Enregistre les clés de coefficients lues pendant un calcul (Proxy en lecture seule). */
export function enregistreLectures(table: TableCoefficients): { table: TableCoefficients; lues: Set<keyof TableCoefficients> } {
const lues = new Set<keyof TableCoefficients>()
const proxy = new Proxy(table, {
get(cible, prop, recepteur) {
if (typeof prop === 'string' && prop in cible) lues.add(prop as keyof TableCoefficients)
return Reflect.get(cible, prop, recepteur)
},
})
return { table: proxy, lues }
}
function axeVide(): AxeResultat {
return { postes: [], sous_total_eur_ht: 0, budget_alloue: 0, poids: 0 }
}
/** Ajoute aux sources les coefficients lus et les prix des typologies retenues. */
export function traceSelection(trace: Trace, coefficients: TableCoefficients, lues: Set<keyof TableCoefficients>, postes: PosteResultat[], options: Option[], couplages: string[]): void {
for (const cle of lues) {
const c = coefficients[cle]
const v = c.valeur
trace.source(cle, typeof v === 'number' || typeof v === 'string' ? v : JSON.stringify(v), c.source, c.date)
}
for (const p of postes) {
const o = options.find((x) => x.code === p.typologie_choisie)
trace.source(`option.${p.typologie_choisie}`, p.cout_eur_ht, String(o?.source ?? 'options (calculs.trans-former.fr)'), String(o?.source_date ?? ''))
}
for (const ligne of couplages) {
if (ligne.startsWith('MANQUANT') || ligne.startsWith('WARN')) trace.avertit(`couplage : ${ligne}`)
}
}
export function calculeAxe(axe: Axe, entrees: EntreesAxe, donnees: DonneesAutonomie): Resultat<SortiesAxe> {
const trace = new Trace()
const { table: coefficients, lues } = enregistreLectures(donnees.coefficients ?? COEFFICIENTS)
const actifs = Object.fromEntries(AXES.map((a) => [a, a === axe])) as Record<Axe, boolean>
const reglages: Reglages = { config: configPourAxes(donnees.algo_config, actifs), coefficients }
const inputs = entrees.autodiag
const budget = Math.max(0, entrees.budget_axe || 0)
const selection: Selection = {}
for (const a of AXES) selection[a] = a === axe ? selectionnePoste(budget, inputs, entrees.contexte, donnees.options, donnees.postes, a, reglages) : axeVide()
selection[axe]!.poids = 100
const fin = finaliseSelection(selection, budget, donnees.options, donnees.postes, inputs, entrees.contexte, reglages)
const postes = fin.axes[axe]?.postes ?? []
const sous_total = fin.axes[axe]?.sous_total_eur_ht ?? 0
// Un poste forcé dans un autre axe par un couplage inter-axes serait perdu ici :
// aucun dans les données (inventaire §4), on le signale si ça arrive.
for (const a of AXES) {
if (a !== axe && (fin.axes[a]?.postes.length ?? 0) > 0) trace.avertit(`couplage inter-axes : ${fin.axes[a]!.postes.length} poste(s) forcé(s) dans l'axe ${a}, hors du périmètre du moteur ${axe}`)
}
let taux: { taux: number | null; detail: DetailEau | DetailEnergie | DetailAlim | null }
if (axe === 'eau') {
taux = tauxEau({ surface_toit_m2: inputs.surface_toit_m2, nb_personnes: inputs.nb_personnes, pluviometrie_mm: entrees.contexte.pluviometrie_mm, a_captage: aCaptage(postes) }, coefficients, trace)
} else if (axe === 'energie') {
taux = tauxEnergie(
{ kwc: kwcSelectionnes(postes), batterie_kwh: batterieSelectionnee(postes), orientation_toit: inputs.orientation_toit, nb_personnes: inputs.nb_personnes, contexte: entrees.contexte },
coefficients,
trace,
)
} else {
taux = tauxAlim({ ml: mlHaiesSelectionnes(postes), nb_personnes: inputs.nb_personnes }, coefficients, trace)
}
traceSelection(trace, donnees.coefficients ?? COEFFICIENTS, lues, postes, donnees.options, fin.couplages)
const sorties: SortiesAxe = {
axe,
postes,
typologies: postes.map((p) => p.typologie_choisie),
sous_total_eur_ht: sous_total,
budget_alloue: budget,
ecart_budget_pct: budget > 0 ? +(((sous_total - budget) / budget) * 100).toFixed(2) : 0,
status: fin.status,
message_status: fin.message_status,
couplages: fin.couplages,
taux_couverture: taux.taux,
taux_detail: taux.detail,
}
return trace.resultat(sorties, VERSION)
}
export function moteurAxe(axe: Axe, id: string): Moteur<EntreesAxe, DonneesAutonomie, SortiesAxe> {
return { id, calculer: (entrees, donnees) => calculeAxe(axe, entrees, donnees) }
}
+7
View File
@@ -0,0 +1,7 @@
// eau.ts — moteur `autonomie.eau` : captage, filtration, stockage,
// distribution, eaux grises et épuration, pour un budget d'axe donné.
import { moteurAxe } from './axe.js'
export type { EntreesAxe, DonneesAutonomie, SortiesAxe } from './axe.js'
export const eau = moteurAxe('eau', 'autonomie.eau')
export default eau
+7
View File
@@ -0,0 +1,7 @@
// energie.ts — moteur `autonomie.energie` : photovoltaïque, solaire thermique,
// chauffage bois, émetteurs, pilotage, pour un budget d'axe donné.
import { moteurAxe } from './axe.js'
export type { EntreesAxe, DonneesAutonomie, SortiesAxe } from './axe.js'
export const energie = moteurAxe('energie', 'autonomie.energie')
export default energie
+12
View File
@@ -0,0 +1,12 @@
// @transformer/moteurs/autonomie — trois moteurs d'axe, un intégrateur, le noyau.
export { eau } from './eau.js'
export { energie } from './energie.js'
export { alimentation } from './alimentation.js'
export { integrateur, calculeIntegrateur, agregeStatuts } from './integrateur.js'
export type { EntreesIntegrateur, DonneesIntegrateur, SortiesIntegrateur } from './integrateur.js'
export { calculeAxe, moteurAxe } from './axe.js'
export type { EntreesAxe, DonneesAutonomie, SortiesAxe } from './axe.js'
export * from './taux.js'
export * from './prix.js'
export * as noyau from './noyau/index.js'
export type { Axe, Autodiag, InputsSimulation, Poste, Option, AlgoConfig, PosteResultat, AxeResultat, Statut, PoidsAxes } from './noyau/types.js'
+150
View File
@@ -0,0 +1,150 @@
// integrateur.ts — moteur `autonomie.integrateur` : contexte + pondération des
// axes + allocation du budget + agrégation.
//
// Deux modes :
// - 'global' (défaut) : le pipeline complet d'origine (`selectionner`), avec
// couplages et réajustement GLOBAUX entre axes — celui de calculs ;
// - 'par_axe' : chaque axe est calculé par son moteur d'axe sur son budget,
// puis les sorties sont additionnées — celui du menu de la plateforme.
// Le mode « aménagements cochés » (recalcul sans relancer l'allocation) est
// P1.5 : une liste transmise est ignorée et signalée.
import type { Moteur, Resultat } from '../commun/contrat.js'
import { Trace } from '../commun/contrat.js'
import { COEFFICIENTS } from '../commun/coefficients.js'
import type { Contexte, LigneClimat } from '../commun/contexte.js'
import { deriveContexte } from '../commun/contexte.js'
import { VERSION } from '../version.js'
import { calculeAxe, enregistreLectures, traceSelection, type DonneesAutonomie, type SortiesAxe } from './axe.js'
import { selectionner } from './noyau/algo.js'
import { alloueBudget } from './noyau/alloue-budget.js'
import { configPourAxes, type AxesActifs } from './noyau/axes-actifs.js'
import { pondereAxes } from './noyau/pondere-axes.js'
import type { Autodiag, Axe, PoidsAxes, PosteResultat, Statut } from './noyau/types.js'
import { AXES } from './noyau/types.js'
import { aCaptage, batterieSelectionnee, kwcSelectionnes, mlHaiesSelectionnes, tauxAlim, tauxEau, tauxEnergie } from './taux.js'
export interface EntreesIntegrateur {
autodiag: Autodiag
budget_total: number
/** 'global' = pipeline d'origine (calculs) ; 'par_axe' = somme des moteurs d'axe (plateforme). */
mode?: 'global' | 'par_axe'
/** Axes désactivés par l'habitant (toggles) ; absent = les trois. */
axes_actifs?: AxesActifs
/** P1.5 : aménagements cochés ; ignoré en v0.1 (signalé). */
amenagements_coches?: string[]
}
export interface DonneesIntegrateur extends DonneesAutonomie {
france_climat: LigneClimat[]
}
export interface SortiesIntegrateur {
mode: 'global' | 'par_axe'
contexte: Contexte
poids: PoidsAxes
budgets: PoidsAxes
axes: Record<Axe, Pick<SortiesAxe, 'postes' | 'typologies' | 'sous_total_eur_ht' | 'budget_alloue' | 'status' | 'taux_couverture' | 'taux_detail'>>
total_eur_ht: number
budget_cible: number
ecart_budget_pct: number
status: Statut
message_status?: string
couplages: string[]
taux_couverture: Record<Axe, number | null>
}
/** Statut agrégé : BUDGET_INSUFFISANT l'emporte, puis AJOUTER_OPTIONNEL, sinon OK. */
export function agregeStatuts(statuts: Statut[]): Statut {
if (statuts.includes('BUDGET_INSUFFISANT')) return 'BUDGET_INSUFFISANT'
if (statuts.includes('AJOUTER_OPTIONNEL')) return 'AJOUTER_OPTIONNEL'
return 'OK'
}
export function calculeIntegrateur(entrees: EntreesIntegrateur, donnees: DonneesIntegrateur): Resultat<SortiesIntegrateur> {
const trace = new Trace()
const mode = entrees.mode ?? 'global'
const coefficients_base = donnees.coefficients ?? COEFFICIENTS
const { table: coefficients, lues } = enregistreLectures(coefficients_base)
const config = configPourAxes(donnees.algo_config, entrees.axes_actifs)
const inputs = { ...entrees.autodiag, budget_eur: entrees.budget_total }
if (entrees.amenagements_coches && entrees.amenagements_coches.length) {
trace.avertit('aménagements cochés ignorés : le mode « coché » arrive en P1.5')
}
const ctx = deriveContexte(inputs.code_postal, donnees.france_climat, coefficients)
trace.absorbe(ctx)
const contexte = ctx.sorties
const poids = pondereAxes(inputs, contexte, config, false)
const budgets = alloueBudget(entrees.budget_total, poids)
const axes = {} as SortiesIntegrateur['axes']
const taux_couverture = {} as Record<Axe, number | null>
let total = 0
let status: Statut
let message_status: string | undefined
let couplages: string[] = []
if (mode === 'par_axe') {
const statuts: Statut[] = []
for (const axe of AXES) {
const actif = !entrees.axes_actifs || entrees.axes_actifs[axe] !== false
if (!actif || budgets[axe] <= 0) {
axes[axe] = { postes: [], typologies: [], sous_total_eur_ht: 0, budget_alloue: budgets[axe], status: 'OK', taux_couverture: null, taux_detail: null }
taux_couverture[axe] = null
continue
}
const r = calculeAxe(axe, { autodiag: entrees.autodiag, budget_axe: budgets[axe], contexte }, { ...donnees, coefficients: coefficients_base })
trace.absorbe(r)
const s = r.sorties
axes[axe] = { postes: s.postes, typologies: s.typologies, sous_total_eur_ht: s.sous_total_eur_ht, budget_alloue: s.budget_alloue, status: s.status, taux_couverture: s.taux_couverture, taux_detail: s.taux_detail }
taux_couverture[axe] = s.taux_couverture
total += s.sous_total_eur_ht
statuts.push(s.status)
couplages = couplages.concat(s.couplages)
}
status = agregeStatuts(statuts)
} else {
const r = selectionner(inputs, donnees.options, donnees.postes, donnees.france_climat, { config, coefficients }, false)
const tous: PosteResultat[] = []
for (const axe of AXES) {
const a = r.axes[axe]
const postes = a?.postes ?? []
tous.push(...postes)
let t: { taux: number | null; detail: SortiesAxe['taux_detail'] }
if (axe === 'eau') t = tauxEau({ surface_toit_m2: inputs.surface_toit_m2, nb_personnes: inputs.nb_personnes, pluviometrie_mm: contexte.pluviometrie_mm, a_captage: aCaptage(postes) }, coefficients, trace)
else if (axe === 'energie') t = tauxEnergie({ kwc: kwcSelectionnes(postes), batterie_kwh: batterieSelectionnee(postes), orientation_toit: inputs.orientation_toit, nb_personnes: inputs.nb_personnes, contexte }, coefficients, trace)
else t = tauxAlim({ ml: mlHaiesSelectionnes(postes), nb_personnes: inputs.nb_personnes }, coefficients, trace)
axes[axe] = { postes, typologies: postes.map((p) => p.typologie_choisie), sous_total_eur_ht: a?.sous_total_eur_ht ?? 0, budget_alloue: a?.budget_alloue ?? budgets[axe], status: r.status, taux_couverture: t.taux, taux_detail: t.detail }
taux_couverture[axe] = t.taux
}
total = r.total_eur_ht
status = r.status
message_status = r.message_status
couplages = r.couplages
traceSelection(trace, coefficients_base, lues, tous, donnees.options, couplages)
}
const sorties: SortiesIntegrateur = {
mode,
contexte,
poids,
budgets,
axes,
total_eur_ht: total,
budget_cible: entrees.budget_total,
ecart_budget_pct: entrees.budget_total > 0 ? +(((total - entrees.budget_total) / entrees.budget_total) * 100).toFixed(2) : 0,
status,
message_status,
couplages,
taux_couverture,
}
return trace.resultat(sorties, VERSION)
}
export const integrateur: Moteur<EntreesIntegrateur, DonneesIntegrateur, SortiesIntegrateur> = {
id: 'autonomie.integrateur',
calculer: calculeIntegrateur,
}
export default integrateur
+98
View File
@@ -0,0 +1,98 @@
// adequation.ts — adequation_contexte(typo, inputs, poste, contexte_derived) → [0..1]
// Spec: V2-algo-spec-FONCTIONS.md §1 — origine calculs/src/lib/autonomie/adequation.js.
// Correction LOT P1.0 : `reglages` en paramètre (plus de getConfig()) ; le
// console.warn de debug devient un champ `avertissement` du retour debug.
import { clamp01 } from './calibrage.js'
import {
score_pluviometrie,
score_climat_chauffage,
score_climat_meridional,
score_taille_foyer,
score_surface_toit,
score_orientation_toit,
score_surface_parcelle,
score_temps_dispo,
score_metres_haie,
score_couplage_disponible,
} from './scoreurs.js'
import type { Autodiag, Contexte, Option, Poste, Reglages } from './types.js'
type Scoreur = (typo: Option, inputs: Autodiag, contexte: Contexte, poste: Poste, sel: string[], reglages: Reglages) => number
// Catalogue de dispatch des scoreurs par nom de critère
const SCOREURS: Record<string, Scoreur> = {
pluviometrie: score_pluviometrie,
climat_chauffage: score_climat_chauffage,
climat_meridional: score_climat_meridional,
taille_foyer: score_taille_foyer,
surface_toit: score_surface_toit,
orientation_toit: score_orientation_toit,
surface_parcelle: score_surface_parcelle,
temps_dispo: score_temps_dispo,
metres_haie_possibles: score_metres_haie,
couplage_disponible: score_couplage_disponible,
}
export interface AdequationDebug {
score: number
scoreurs: Record<string, { score: number; poids: number }>
avertissement?: string
}
/**
* Calcule le score d'adéquation contextuelle d'une typologie [0..1].
*
* @param typologies_selectionnees — sélections déjà faites (pour score_couplage_disponible)
* @returns score ∈ [0..1] ; en debug, { score, scoreurs }
*/
export function adequation_contexte(typo: Option, inputs: Autodiag, poste: Poste, contexte_derived: Contexte, reglages: Reglages, typologies_selectionnees?: string[], debug?: false): number
export function adequation_contexte(typo: Option, inputs: Autodiag, poste: Poste, contexte_derived: Contexte, reglages: Reglages, typologies_selectionnees: string[], debug: true): AdequationDebug
export function adequation_contexte(
typo: Option,
inputs: Autodiag,
poste: Poste,
contexte_derived: Contexte,
reglages: Reglages,
typologies_selectionnees: string[] = [],
debug = false,
): number | AdequationDebug {
const matrix = reglages.config.adequation_weights?.[poste.code]
if (!matrix) {
if (debug) return { score: 0.5, scoreurs: {}, avertissement: `[adequation_contexte] Pas de matrice pour poste "${poste.code}" — fallback 0.5` }
return 0.5
}
let weighted_sum = 0
let total_weight = 0
const debug_scores: Record<string, { score: number; poids: number }> = {}
for (const [critere, poids] of Object.entries(matrix)) {
if (poids === 0 || !SCOREURS[critere]) continue
const scoreur = SCOREURS[critere]
let s: number
if (critere === 'couplage_disponible') {
s = scoreur(typo, inputs, contexte_derived, poste, typologies_selectionnees, reglages)
} else {
s = scoreur(typo, inputs, contexte_derived, poste, [], reglages)
}
weighted_sum += s * poids
total_weight += poids
if (debug) debug_scores[critere] = { score: +s.toFixed(3), poids }
}
let score = total_weight > 0 ? weighted_sum / total_weight : 0.5
// §1.4 Bonus / malus globaux
if (typo.flag_diy && inputs.temps_dispo === 3) score *= 1.3
if (typo.complexite_pose === 3 && inputs.temps_dispo === 1) score *= 0.5
if (!typo.accessible_fr) score *= 0.6
score = clamp01(score)
if (debug) {
return { score, scoreurs: debug_scores }
}
return score
}
+173
View File
@@ -0,0 +1,173 @@
// algo.ts — module principal orchestrateur des 6 étapes
// Spec: V2-algo-spec.md + V2-algo-spec-FONCTIONS.md — origine calculs/src/lib/autonomie/algo.js.
// Zero-dep, isomorphe navigateur/Node.
//
// Corrections LOT P1.0 : plus d'initConfig (les réglages passent en paramètre) ;
// le contexte vient de commun/contexte.ts (Corse 2A/2B et DOM 971-976 corrigés,
// une seule version) ; la sortie porte en plus `contexte` et `avertissements`.
// C'est le pipeline COMPLET d'origine (couplages et réajustement globaux), celui
// que calculs.trans-former.fr consommera en P1.5 sans changement de résultat.
//
// Découpage par axe (LOT P1.0) : la queue du pipeline (étapes 4 à 6) est
// isolée dans `finaliseSelection`, que les moteurs d'axe réutilisent telle
// quelle sur une sélection à un seul axe. Un moteur d'axe est donc, par
// construction, le mode « un seul axe actif » de `selectionner`.
import { deriveContexte } from '../../commun/contexte.js'
import { pondereAxes } from './pondere-axes.js'
import { alloueBudget } from './alloue-budget.js'
import { selectionnePoste } from './selectionne-poste.js'
import { appliqueCouplages } from './couplages.js'
import { reajusteBudget } from './reajuste.js'
import type { Autodiag, Axe, Contexte, EtapeDebug, InputsSimulation, LigneClimat, Option, Poste, Reglages, Selection, SortieSelection, Statut } from './types.js'
export interface SelectionFinalisee {
axes: Selection
total_eur_ht: number
status: Statut
message_status?: string
couplages: string[]
debug_reajust: string[] | null
}
const AXES_CODE: Axe[] = ['eau', 'energie', 'alim']
function sommeAxes(axes: Selection): number {
return Math.round(AXES_CODE.reduce((s, axe) => s + ((axes[axe] && axes[axe].sous_total_eur_ht) || 0), 0))
}
/**
* Étapes 4 à 6 du pipeline d'origine : couplages contraints, réajustement au
* budget, re-passe couplages bornée à deux tours, total. Ne touche pas à la
* sélection reçue (appliqueCouplages copie).
*/
export function finaliseSelection(
selection: Selection,
budget_total: number,
options: Option[],
postes: Poste[],
inputs: Autodiag,
contexte: Contexte,
reglages: Reglages,
debugMode = false,
debug?: { steps: EtapeDebug[] },
): SelectionFinalisee {
const config = reglages.config
// --- Étape 4 : couplages contraints ---
const apres_couplages = appliqueCouplages(selection, options, postes, reglages, debugMode)
if (debug) debug.steps.push({ etape: 4, label: 'couplages', log: apres_couplages.couplages_appliques })
// --- Étape 5 : réajustement budget ---
const final_pre = reajusteBudget(apres_couplages, budget_total, options, postes, inputs, contexte, reglages, debugMode)
if (debug) debug.steps.push({ etape: 5, label: 'reajustement', status: final_pre.status, total: final_pre.total, log: final_pre.debug_reajust })
// Re-passe couplages post-réajustement, avec réabsorption des coûts forcés :
// le CAS B (underspend) peut ajouter ou upgrader des typologies ayant des
// couplages_requis. La passe couplages force les requis manquants
// (ex: bois_poele_hydro → bois_conduit_fumee) — coûts contraints, inclus
// dans le total final (comportement intentionnel).
// Correctif V3-1 (recalibrage) : ces ajouts pouvaient faire franchir le seuil
// d'overrun SANS rééquilibrage ni changement de status (profil A : +8,9 % avec
// status OK). Boucle bornée à 2 passes : couplages → si overrun, réajuster →
// couplages. CAS A ne fait que réduire, la convergence est garantie ; si le
// seuil reste franchi, reajusteBudget pose BUDGET_INSUFFISANT (status honnête).
const tol_over = (config.tolerances && config.tolerances.budget_overrun_pct) || 5
const seuil_over = budget_total * (1 + tol_over / 100)
let etat: { axes: Selection; couplages_appliques: string[] } = {
axes: final_pre.axes,
couplages_appliques: [...(final_pre.couplages_appliques || [])],
}
let status_final: Statut = final_pre.status
let message_final = final_pre.message
for (let passe = 0; passe < 2; passe++) {
// IMPORTANT: appliqueCouplages attend selection = { eau, energie, alim } directement
const avec = appliqueCouplages(etat.axes, options, postes, reglages, false)
etat = {
axes: avec.axes,
couplages_appliques: [...etat.couplages_appliques, ...(avec.couplages_appliques || [])],
}
if (sommeAxes(etat.axes) <= seuil_over) break
const re = reajusteBudget(etat, budget_total, options, postes, inputs, contexte, reglages, false)
etat = {
axes: re.axes,
couplages_appliques: [...etat.couplages_appliques, ...(re.couplages_appliques || [])],
}
status_final = re.status
message_final = re.message
}
return {
axes: etat.axes,
total_eur_ht: sommeAxes(etat.axes),
status: status_final,
message_status: message_final || undefined,
couplages: etat.couplages_appliques,
debug_reajust: final_pre.debug_reajust,
}
}
/**
* Sélectionne les typologies optimales selon budget + contexte.
*
* @param inputs — saisie utilisateur (autodiag + budget_eur)
* @param options — typologies BDD (options.json)
* @param postes — postes BDD (postes.json)
* @param climat — données climatiques (france_climat.json)
* @param reglages — { config: algo-config.json parsé, coefficients }
* @param debugMode — active la trace debug exhaustive
*/
export function selectionner(
inputs: InputsSimulation,
options: Option[],
postes: Poste[],
climat: LigneClimat[],
reglages: Reglages,
debugMode = false,
): SortieSelection {
const debug: { steps: EtapeDebug[] } = { steps: [] }
const config = reglages.config
// --- Étape 0 : dériver le contexte depuis code postal ---
const ctx = deriveContexte(inputs.code_postal, climat, reglages.coefficients)
const contexte = ctx.sorties
if (debugMode) debug.steps.push({ etape: 0, label: 'contexte', data: contexte })
// --- Étape 1 : pondération axes ---
const poids_debug = debugMode ? pondereAxes(inputs, contexte, config, true) : null
const poids = poids_debug ? poids_debug.poids : pondereAxes(inputs, contexte, config, false)
if (poids_debug) debug.steps.push({ etape: 1, label: 'ponderation', poids, log: poids_debug.log })
// --- Étape 2 : allocation budget ---
const budgets = alloueBudget(inputs.budget_eur, poids)
if (debugMode) debug.steps.push({ etape: 2, label: 'allocation', budgets })
// --- Étape 3 : sélection par axe ---
const selection: Selection = {}
for (const axe of AXES_CODE) {
const res = selectionnePoste(budgets[axe], inputs, contexte, options, postes, axe, reglages, debugMode)
res.poids = poids[axe]
selection[axe] = res
}
if (debugMode) debug.steps.push({ etape: 3, label: 'selection', summary: Object.fromEntries(AXES_CODE.map((a) => [a, selection[a]!.sous_total_eur_ht])) })
// --- Étapes 4 à 6 ---
const fin = finaliseSelection(selection, inputs.budget_eur, options, postes, inputs, contexte, reglages, debugMode, debugMode ? debug : undefined)
const ecart = inputs.budget_eur > 0 ? ((fin.total_eur_ht - inputs.budget_eur) / inputs.budget_eur) * 100 : 0
return {
axes: fin.axes,
total_eur_ht: fin.total_eur_ht,
budget_cible: inputs.budget_eur,
ecart_budget_pct: +ecart.toFixed(2),
status: fin.status,
message_status: fin.message_status,
couplages: fin.couplages,
contexte,
avertissements: ctx.avertissements,
debug: debugMode ? debug : undefined,
}
}
+19
View File
@@ -0,0 +1,19 @@
// alloue-budget.ts — Étape 2 : allocation budget par axe
// Spec: V2-algo-spec.md §Étape 2 — origine calculs/src/lib/autonomie/alloue-budget.js, inchangé.
import type { PoidsAxes } from './types.js'
/**
* Alloue le budget total entre les 3 axes selon leurs poids.
*
* @param budget_total — EUR
* @param poids — somme = 100
* @returns en EUR
*/
export function alloueBudget(budget_total: number, poids: PoidsAxes): PoidsAxes {
return {
eau: (budget_total * poids.eau) / 100,
energie: (budget_total * poids.energie) / 100,
alim: (budget_total * poids.alim) / 100,
}
}
+56
View File
@@ -0,0 +1,56 @@
// axes-actifs.ts — Toggles d'axes (V3 vague 1, M2)
// Produit une config dérivée où les axes désactivés ont un poids nul,
// sans toucher aux modules cœur de l'algo. La désactivation passe
// uniquement par les inputs/config (poids), conformément à la spec.
// Origine calculs/src/lib/autonomie/axes-actifs.js, inchangé (typé).
import type { AlgoConfig, Axe } from './types.js'
import { AXES } from './types.js'
export type AxesActifs = Partial<Record<Axe, boolean>>
/**
* Transforme algo-config pour neutraliser les axes désactivés.
* - poids_axes_base[axe] = 0 pour tout axe inactif
* - tous les deltas de modulateurs référençant un axe inactif sont retirés
* - marqueur `_axes_desactives` (lu par reajuste.ts pour ne pas réinjecter
* des postes optionnels dans un axe éteint lors du CAS B underspend)
*
* @param config — algo-config.json parsé (non muté)
* @returns nouvelle config
*/
export function configPourAxes(config: AlgoConfig, actifs: AxesActifs | null | undefined): AlgoConfig {
const desactives = AXES.filter((a) => actifs && actifs[a] === false)
if (desactives.length === 0) return config
// Clone profond (config petite, JSON-safe)
const cfg = JSON.parse(JSON.stringify(config)) as AlgoConfig
cfg.poids_axes_base = cfg.poids_axes_base || { eau: 40, energie: 40, alim: 20 }
for (const axe of desactives) cfg.poids_axes_base[axe] = 0
// Neutraliser les deltas des modulateurs qui re-donneraient du poids
// à un axe désactivé (ex : zone MED -> eau+10, parcelle grande -> alim+10).
const mod = cfg.modulateurs || {}
for (const key of Object.keys(mod)) {
const val = mod[key]
if (val && typeof val === 'object' && !Array.isArray(val)) {
for (const axe of desactives) {
if (axe in (val as Record<string, unknown>)) delete (val as Record<string, unknown>)[axe]
}
}
}
cfg._axes_desactives = desactives
return cfg
}
/**
* Vrai si au moins un axe est actif (0 axe actif = état vide géré par l'UI,
* l'algo ne doit pas tourner).
*/
export function auMoinsUnAxe(actifs: AxesActifs | null | undefined): boolean {
return AXES.some((a) => !actifs || actifs[a] !== false)
}
export { AXES }
+20
View File
@@ -0,0 +1,20 @@
// calibrage.ts — helpers numériques du noyau.
//
// Origine : calculs/src/lib/autonomie/calibrage.js. Correction LOT P1.0 : le
// singleton `_config` (initConfig / getConfig) est retiré, la configuration
// passe en paramètre (Reglages) chez les cinq appelants. ORIENTATION_FACTORS et
// DJU_DEFAULTS_BY_ZONE vivent désormais dans commun/coefficients.ts (une seule
// définition, sourcée) ; ZONE_BIOGEO_MAP et zoneRtToClass dans commun/contexte.ts.
export { ZONE_BIOGEO_MAP, zoneRtToClass } from '../../commun/contexte.js'
export function clamp01(x: number): number {
return Math.max(0, Math.min(1, x))
}
export function clamp(x: number, min: number, max: number): number {
return Math.max(min, Math.min(max, x))
}
export function roundToStep(value: number, step: number): number {
if (!step || step <= 0) return value
return Math.round(value / step) * step
}
+145
View File
@@ -0,0 +1,145 @@
// couplages.ts — Étape 4 : application des couplages contraints
// Spec: V2-algo-spec.md §Étape 4 — origine calculs/src/lib/autonomie/couplages.js.
// Corrections LOT P1.0 : `reglages` en paramètre (plus de getConfig()) ; la
// sélection reçue n'est PLUS mutée (copie des axes et de leurs postes, le
// résultat est renvoyé dans `axes`). Tous les appelants d'origine lisaient
// déjà le retour, le comportement est identique.
//
// Note (origine, conservée) : le marqueur `_conflit_avec` de la passe 2 est
// posé sur des copies de postes et n'atteint jamais la sortie ; le
// « downgrade planifié » n'a donc jamais lieu. À corriger en patch, avec test.
import { cout_typologie, dimension_optimale } from './dimension.js'
import type { Axe, AxeResultat, Option, Poste, PosteResultat, Reglages, Selection } from './types.js'
export interface ResultatCouplages {
axes: Selection
couplages_appliques: string[]
}
function copieSelection(selection: Selection): Selection {
const copie: Selection = {}
for (const axe of Object.keys(selection) as Axe[]) {
const a = selection[axe]
if (!a) continue
const ax: AxeResultat = { ...a, postes: a.postes.map((p) => ({ ...p })) }
copie[axe] = ax
}
return copie
}
/**
* Post-traitement déterministe des couplages.
*
* Gestion alias : algo-config.json § couplages_alias permet de mapper
* des codes legacy (ex: stherm_ballon_tampon) vers les codes BDD réels (th_ballon_500).
*/
export function appliqueCouplages(selection_recue: Selection, options: Option[], postes: Poste[] | null | undefined, reglages: Reglages, _debug = false): ResultatCouplages {
const alias = reglages.config.couplages_alias || {}
const selection = copieSelection(selection_recue)
// Construire un mapping poste_id (num) → axe via postes BDD
const posteIdToAxe: Record<number, Axe> = {}
if (postes) {
for (const p of postes) {
// Normaliser axe : "alimentation" → "alim"
const axe_norm = (p.axe === 'alimentation' ? 'alim' : p.axe) as Axe
posteIdToAxe[p.Id] = axe_norm
}
}
function resolveCode(code: string): string {
return alias[code] || code
}
const axes_list: Axe[] = ['eau', 'energie', 'alim']
const tous_postes: PosteResultat[] = axes_list.flatMap((axe) =>
(selection[axe] && selection[axe].postes ? selection[axe].postes : []).map((p) => Object.assign({}, p, { _axe: axe })),
)
const codes_selectionnes = new Set(tous_postes.map((p) => p.typologie_choisie))
const couplages_log: string[] = []
// Passe 1 : couplages_requis
for (const poste of tous_postes) {
const requis_list = poste.couplages_requis || []
for (const requis_raw of requis_list) {
const requis = resolveCode(requis_raw)
if (!codes_selectionnes.has(requis)) {
// Chercher la typo en BDD (code exact ou alias original)
let typo_requise = options.find((o) => o.code === requis)
if (!typo_requise && requis !== requis_raw) {
typo_requise = options.find((o) => o.code === requis_raw)
}
if (typo_requise) {
// Trouver l'axe via mapping poste_id → axe
const axe_cible = posteIdToAxe[typo_requise.poste_id] || trouverAxeParCode(typo_requise.code)
const cible = axe_cible ? selection[axe_cible] : undefined
if (axe_cible && cible) {
const dim = dimension_optimale(typo_requise, {}, {}, reglages.coefficients)
const cout = cout_typologie(typo_requise, dim, {})
const cout_force = Math.round(cout || 0)
const unite_dim = dim ? dim.unite : typo_requise.unite || ''
const valeur_dim = dim ? dim.valeur : typo_requise.quantite_default || 1
cible.postes.push({
poste_id: String(typo_requise.poste_id) + '_couplage',
poste_nom: 'Couplage requis (' + typo_requise.nom + ')',
obligatoire: true,
budget_alloue_poste: cout_force,
typologie_choisie: typo_requise.code,
nom_affichage: typo_requise.nom,
description_pedago: typo_requise.description_pedago || '',
dimension: { valeur: valeur_dim, unite: unite_dim },
cout_eur_ht: cout_force,
score_adequation: 0.8,
score_final: 0,
alternatives: [],
notes_techniques: typo_requise.notes_techniques || '',
flag_diy: typo_requise.flag_diy || false,
complexite_pose: typo_requise.complexite_pose || 2,
couplages_requis: [],
couplages_exclus: [],
manuel: false,
force_par_couplage: true,
})
cible.sous_total_eur_ht += cout_force
codes_selectionnes.add(typo_requise.code)
couplages_log.push('FORCE "' + typo_requise.code + '" (requis par "' + poste.typologie_choisie + '") +' + cout_force + 'EUR dans axe ' + axe_cible)
} else {
couplages_log.push('WARN axe non trouve pour "' + requis + '" (requis par "' + poste.typologie_choisie + '")')
}
} else {
couplages_log.push('MANQUANT "' + requis + '" (requis par "' + poste.typologie_choisie + '") — code BDD non trouve')
}
}
}
}
// Passe 2 : couplages_exclus
for (const poste of tous_postes) {
const exclus_list = poste.couplages_exclus || []
for (const exclu_raw of exclus_list) {
const exclu = resolveCode(exclu_raw)
if (codes_selectionnes.has(exclu)) {
const poste_conflit = tous_postes.find((p) => p.typologie_choisie === exclu)
if (poste_conflit && !poste_conflit.force_par_couplage) {
poste_conflit._conflit_avec = poste.typologie_choisie
couplages_log.push('CONFLIT "' + exclu + '" avec "' + poste.typologie_choisie + '" — downgrade planifié')
}
}
}
}
return { axes: selection, couplages_appliques: couplages_log }
}
// Fallback heuristique si poste_id absent du mapping
function trouverAxeParCode(code: string): Axe {
if (/captage|stockage|filtrat|distrib|eg_|stherm_ballon/.test(code)) return 'eau'
if (/^th_|^pv_|^bois_|^pil_|^emet_|ballon_tampon|conduit_fumee/.test(code)) return 'energie'
if (/^etude_|^haute_|^moy_|^basse_/.test(code)) return 'alim'
return 'energie'
}
+135
View File
@@ -0,0 +1,135 @@
// dimension.ts — dimension_optimale(typo, inputs, contexte_derived, coefficients) → Dimension | null
// Spec: V2-algo-spec-FONCTIONS.md §2 — origine calculs/src/lib/autonomie/dimension.js.
// Correction LOT P1.0 : facteurs d'orientation et DJU par défaut lus dans la
// table de coefficients passée en paramètre (plus de constante de module).
import { zoneRtToClass, clamp, roundToStep } from './calibrage.js'
import type { TableCoefficients } from '../../commun/coefficients.js'
import type { Autodiag, Contexte, Dimension, Option } from './types.js'
import { parseParam } from './types.js'
/**
* Calcule la valeur optimale du paramètre dimensionnant pour une typologie.
*
* Particularité ballon_tampon (th_ballon_500) :
* - unite BDD = "forfait" (contrainte schéma NocoDB)
* - prix_par_unite = 1.2 EUR/L
* - parametre_dim.unite = "L" (source de vérité pour l'affichage et le calcul)
* → L'algo utilise toujours parametre_dim.unite quand présent, sinon fallback option.unite
*/
export function dimension_optimale(
typo: Option,
inputs: Partial<Autodiag>,
contexte_derived: Partial<Contexte>,
coefficients: TableCoefficients,
): Dimension | null {
const param = parseParam(typo.parametre_dim)
if (!param) return null
const strategie = param.strategie || 'fixe'
let valeur: number
switch (strategie) {
case 'par_personne': {
const base = param.base != null ? param.base : 0
const coef = param.coef != null ? param.coef : 1
valeur = base + (inputs.nb_personnes || 4) * coef
break
}
case 'par_surface_toit': {
const table = coefficients.orientation_pv.valeur as Record<string, number>
const orient = (inputs.orientation_toit ? table[inputs.orientation_toit] : undefined) || 0.8
const coef = param.coef != null ? param.coef : 0.1
valeur = (inputs.surface_toit_m2 || 0) * coef * orient
break
}
case 'par_surface_parcelle': {
const reserve = param.reserve_construit != null ? param.reserve_construit : 100
const utile = Math.max(0, (inputs.surface_parcelle_m2 || 0) - reserve)
const coef = param.coef != null ? param.coef : 0.05
valeur = utile * coef
break
}
case 'par_dju': {
const zone_class = zoneRtToClass(contexte_derived.zone_climatique_rt)
const dju = contexte_derived.dju || coefficients.dju_defaut_par_classe_rt.valeur[zone_class] || 2200
const coef = param.coef != null ? param.coef : 0.005
valeur = dju * coef
break
}
case 'par_metres_haie': {
const coef = param.coef != null ? param.coef : 1.0
valeur = (inputs.metres_haie_possibles || 0) * coef
break
}
case 'fixe':
default:
valeur = param.default != null ? param.default : 1
break
}
// Clamp + arrondi step
const min = param.min != null ? param.min : 0
const max = param.max != null ? param.max : Infinity
const step = param.step != null ? param.step : 0
valeur = clamp(valeur, min, max)
valeur = roundToStep(valeur, step)
// Unite : parametre_dim.unite prioritaire sur option.unite (cas ballon_tampon)
const unite = param.unite || typo.unite || ''
return { valeur, unite, strategie }
}
/**
* Calcule le coût HT d'une typologie pour une dimension donnée.
*
* Hiérarchie des prix (3 cas BDD) :
* 1. prix_fixe + prix_par_unite × dim.valeur (V2 enrichi, ex: cuve béton, PV)
* 2. prix_par_unite × dim.valeur seul (ex: th_ballon_500)
* 3. prix_mat_min + pose si pro requis (forfaits legacy sans parametre_dim enrichi)
*
* Pour les postes pro (complexite_pose=3) et temps_dispo < 3, ajouter prix_pro_min.
* Fallback final : (prix_mat_min + prix_mat_max) / 2
*
* Les prix sont des DONNÉES de l'option (publiées par calculs) ; la seule
* heuristique du code est « pose pro = 50 % du matériel si prix_pro absent ».
*/
export function cout_typologie(typo: Option, dim_result: Dimension | null | undefined, inputs?: Partial<Autodiag> | null): number {
const prix_fixe = typo.prix_fixe != null ? typo.prix_fixe : 0
const prix_par_unite = typo.prix_par_unite
const prix_mat_min = typo.prix_mat_min != null ? typo.prix_mat_min : 0
const prix_mat_max = typo.prix_mat_max != null ? typo.prix_mat_max : prix_mat_min
const prix_pro_min = typo.prix_pro_min != null ? typo.prix_pro_min : 0
// Cas 1 & 2 : prix_par_unite disponible (schéma V2 enrichi)
if (prix_par_unite != null && prix_par_unite > 0) {
const dim_val = dim_result ? dim_result.valeur : typo.quantite_default || 1
const base = prix_fixe + prix_par_unite * dim_val
// Forfait pro si complexite=3 et temps_dispo=1
if (typo.complexite_pose === 3 && inputs && inputs.temps_dispo === 1 && prix_pro_min > 0) {
return Math.max(base, prix_pro_min)
}
return base
}
// Cas 3 : forfait legacy (prix_mat_min/max seulement)
if (prix_mat_min > 0) {
const mid_mat = (prix_mat_min + prix_mat_max) / 2
// Si complexite_pose = 3 et pose pro : ajouter coût pose pro
if (typo.complexite_pose === 3) {
const pose = prix_pro_min > 0 ? prix_pro_min : mid_mat * 0.5
return mid_mat + pose
}
// complexite 1-2 : coût mat seul (DIY inclus)
return mid_mat
}
// Fallback ultime : 0 (sécurité, ne devrait pas arriver)
return 0
}
+41
View File
@@ -0,0 +1,41 @@
// @transformer/moteurs/autonomie/noyau — le cœur V2 du simulateur autonomie,
// pipeline complet (celui de calculs.trans-former.fr), sans singleton ni I/O.
export { selectionner } from './algo.js'
export { dimension_optimale, cout_typologie } from './dimension.js'
export { adequation_contexte } from './adequation.js'
export type { AdequationDebug } from './adequation.js'
export { pondereAxes } from './pondere-axes.js'
export type { PoidsDebug } from './pondere-axes.js'
export { alloueBudget } from './alloue-budget.js'
export { selectionnePoste } from './selectionne-poste.js'
export { appliqueCouplages } from './couplages.js'
export type { ResultatCouplages } from './couplages.js'
export { reajusteBudget } from './reajuste.js'
export type { EntreeReajuste, ResultatReajuste } from './reajuste.js'
export { configPourAxes, auMoinsUnAxe } from './axes-actifs.js'
export type { AxesActifs } from './axes-actifs.js'
export { clamp01, clamp, roundToStep, ZONE_BIOGEO_MAP, zoneRtToClass } from './calibrage.js'
export * from './scoreurs.js'
export { AXES, parseJsonArr, parseParam } from './types.js'
export type {
Axe,
Autodiag,
InputsSimulation,
Poste,
Option,
ParamDim,
AlgoConfig,
Reglages,
Dimension,
Alternative,
PosteResultat,
AxeResultat,
Selection,
Statut,
PoidsAxes,
EtapeDebug,
SortieSelection,
Contexte,
LigneClimat,
Orientation,
} from './types.js'

Some files were not shown because too many files have changed in this diff Show More