From 69809df5d18201993dba0c824bdace0d26d43225 Mon Sep 17 00:00:00 2001 From: Jules Date: Mon, 28 Sep 2026 17:06:49 +0200 Subject: [PATCH] LOT P1.0 : paquet @transformer/moteurs v0.1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_019UBHYPdeQYy2m1GVJp6b12 --- .gitignore | 1 + AGENTS.md | 86 + LICENSE | 21 + README.md | 90 + dist/autonomie/alimentation.d.ts | 3 + dist/autonomie/alimentation.js | 6 + dist/autonomie/axe.d.ts | 45 + dist/autonomie/axe.js | 97 + dist/autonomie/eau.d.ts | 3 + dist/autonomie/eau.js | 5 + dist/autonomie/energie.d.ts | 3 + dist/autonomie/energie.js | 5 + dist/autonomie/index.d.ts | 11 + dist/autonomie/index.js | 9 + dist/autonomie/integrateur.d.ts | 37 + dist/autonomie/integrateur.js | 114 + dist/autonomie/noyau/adequation.d.ts | 17 + dist/autonomie/noyau/adequation.js | 59 + dist/autonomie/noyau/algo.d.ts | 28 + dist/autonomie/noyau/algo.js | 135 + dist/autonomie/noyau/alloue-budget.d.ts | 9 + dist/autonomie/noyau/alloue-budget.js | 16 + dist/autonomie/noyau/axes-actifs.d.ts | 20 + dist/autonomie/noyau/axes-actifs.js | 48 + dist/autonomie/noyau/calibrage.d.ts | 4 + dist/autonomie/noyau/calibrage.js | 19 + dist/autonomie/noyau/couplages.d.ts | 12 + dist/autonomie/noyau/couplages.js | 129 + dist/autonomie/noyau/dimension.d.ts | 27 + dist/autonomie/noyau/dimension.js | 113 + dist/autonomie/noyau/index.d.ts | 18 + dist/autonomie/noyau/index.js | 14 + dist/autonomie/noyau/pondere-axes.d.ts | 13 + dist/autonomie/noyau/pondere-axes.js | 91 + dist/autonomie/noyau/reajuste.d.ts | 14 + dist/autonomie/noyau/reajuste.js | 299 ++ dist/autonomie/noyau/scoreurs.d.ts | 47 + dist/autonomie/noyau/scoreurs.js | 170 + dist/autonomie/noyau/selectionne-poste.d.ts | 8 + dist/autonomie/noyau/selectionne-poste.js | 148 + dist/autonomie/noyau/types.d.ts | 180 ++ dist/autonomie/noyau/types.js | 35 + dist/autonomie/prix.d.ts | 29 + dist/autonomie/prix.js | 59 + dist/autonomie/taux.d.ts | 71 + dist/autonomie/taux.js | 135 + dist/commun/argent.d.ts | 28 + dist/commun/argent.js | 57 + dist/commun/coefficients.d.ts | 69 + dist/commun/coefficients.js | 160 + dist/commun/contexte.d.ts | 49 + dist/commun/contexte.js | 102 + dist/commun/contrat.d.ts | 38 + dist/commun/contrat.js | 41 + dist/commun/geometrie.d.ts | 37 + dist/commun/geometrie.js | 79 + dist/commun/index.d.ts | 15 + dist/commun/index.js | 8 + dist/commun/schemas.d.ts | 14 + dist/commun/schemas.js | 121 + dist/commun/valider.d.ts | 12 + dist/commun/valider.js | 77 + dist/index.d.ts | 5 + dist/index.js | 9 + dist/version.d.ts | 1 + dist/version.js | 5 + docs/CONSTANTES.md | 47 + fixtures/README.md | 14 + fixtures/algo-config.json | 186 ++ fixtures/coefficients-2026-05.json | 22 + fixtures/ecarts-harmonisation-2026-09-28.json | 535 ++++ fixtures/france_climat.json | 103 + fixtures/options.json | 2722 +++++++++++++++++ fixtures/postes.json | 170 + fixtures/profils-reference.json | 64 + fixtures/sorties-origine-2026-09-28.json | 1379 +++++++++ package-lock.json | 33 + package.json | 66 + schemas/algo_config.schema.json | 65 + schemas/france_climat.schema.json | 68 + schemas/options.schema.json | 183 ++ schemas/postes.schema.json | 59 + scripts/ecarts-harmonisation.mjs | 83 + scripts/ecrire-schemas.mjs | 12 + scripts/nettoyer-dist.mjs | 3 + scripts/pin-origine.mjs | 76 + src/autonomie/alimentation.ts | 8 + src/autonomie/axe.ts | 143 + src/autonomie/eau.ts | 7 + src/autonomie/energie.ts | 7 + src/autonomie/index.ts | 12 + src/autonomie/integrateur.ts | 150 + src/autonomie/noyau/adequation.ts | 98 + src/autonomie/noyau/algo.ts | 173 ++ src/autonomie/noyau/alloue-budget.ts | 19 + src/autonomie/noyau/axes-actifs.ts | 56 + src/autonomie/noyau/calibrage.ts | 20 + src/autonomie/noyau/couplages.ts | 145 + src/autonomie/noyau/dimension.ts | 135 + src/autonomie/noyau/index.ts | 41 + src/autonomie/noyau/pondere-axes.ts | 112 + src/autonomie/noyau/reajuste.ts | 350 +++ src/autonomie/noyau/scoreurs.ts | 167 + src/autonomie/noyau/selectionne-poste.ts | 188 ++ src/autonomie/noyau/types.ts | 208 ++ src/autonomie/prix.ts | 82 + src/autonomie/taux.ts | 166 + src/commun/argent.ts | 72 + src/commun/coefficients.ts | 220 ++ src/commun/contexte.ts | 149 + src/commun/contrat.ts | 67 + src/commun/geometrie.ts | 122 + src/commun/index.ts | 16 + src/commun/schemas.ts | 139 + src/commun/valider.ts | 79 + src/index.ts | 11 + src/version.ts | 5 + test/commun.test.mjs | 127 + test/decoupage.test.mjs | 96 + test/moteurs.test.mjs | 109 + test/noyau-origine.test.mjs | 279 ++ test/paquet.test.mjs | 104 + tsconfig.json | 23 + 123 files changed, 13225 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 dist/autonomie/alimentation.d.ts create mode 100644 dist/autonomie/alimentation.js create mode 100644 dist/autonomie/axe.d.ts create mode 100644 dist/autonomie/axe.js create mode 100644 dist/autonomie/eau.d.ts create mode 100644 dist/autonomie/eau.js create mode 100644 dist/autonomie/energie.d.ts create mode 100644 dist/autonomie/energie.js create mode 100644 dist/autonomie/index.d.ts create mode 100644 dist/autonomie/index.js create mode 100644 dist/autonomie/integrateur.d.ts create mode 100644 dist/autonomie/integrateur.js create mode 100644 dist/autonomie/noyau/adequation.d.ts create mode 100644 dist/autonomie/noyau/adequation.js create mode 100644 dist/autonomie/noyau/algo.d.ts create mode 100644 dist/autonomie/noyau/algo.js create mode 100644 dist/autonomie/noyau/alloue-budget.d.ts create mode 100644 dist/autonomie/noyau/alloue-budget.js create mode 100644 dist/autonomie/noyau/axes-actifs.d.ts create mode 100644 dist/autonomie/noyau/axes-actifs.js create mode 100644 dist/autonomie/noyau/calibrage.d.ts create mode 100644 dist/autonomie/noyau/calibrage.js create mode 100644 dist/autonomie/noyau/couplages.d.ts create mode 100644 dist/autonomie/noyau/couplages.js create mode 100644 dist/autonomie/noyau/dimension.d.ts create mode 100644 dist/autonomie/noyau/dimension.js create mode 100644 dist/autonomie/noyau/index.d.ts create mode 100644 dist/autonomie/noyau/index.js create mode 100644 dist/autonomie/noyau/pondere-axes.d.ts create mode 100644 dist/autonomie/noyau/pondere-axes.js create mode 100644 dist/autonomie/noyau/reajuste.d.ts create mode 100644 dist/autonomie/noyau/reajuste.js create mode 100644 dist/autonomie/noyau/scoreurs.d.ts create mode 100644 dist/autonomie/noyau/scoreurs.js create mode 100644 dist/autonomie/noyau/selectionne-poste.d.ts create mode 100644 dist/autonomie/noyau/selectionne-poste.js create mode 100644 dist/autonomie/noyau/types.d.ts create mode 100644 dist/autonomie/noyau/types.js create mode 100644 dist/autonomie/prix.d.ts create mode 100644 dist/autonomie/prix.js create mode 100644 dist/autonomie/taux.d.ts create mode 100644 dist/autonomie/taux.js create mode 100644 dist/commun/argent.d.ts create mode 100644 dist/commun/argent.js create mode 100644 dist/commun/coefficients.d.ts create mode 100644 dist/commun/coefficients.js create mode 100644 dist/commun/contexte.d.ts create mode 100644 dist/commun/contexte.js create mode 100644 dist/commun/contrat.d.ts create mode 100644 dist/commun/contrat.js create mode 100644 dist/commun/geometrie.d.ts create mode 100644 dist/commun/geometrie.js create mode 100644 dist/commun/index.d.ts create mode 100644 dist/commun/index.js create mode 100644 dist/commun/schemas.d.ts create mode 100644 dist/commun/schemas.js create mode 100644 dist/commun/valider.d.ts create mode 100644 dist/commun/valider.js create mode 100644 dist/index.d.ts create mode 100644 dist/index.js create mode 100644 dist/version.d.ts create mode 100644 dist/version.js create mode 100644 docs/CONSTANTES.md create mode 100644 fixtures/README.md create mode 100644 fixtures/algo-config.json create mode 100644 fixtures/coefficients-2026-05.json create mode 100644 fixtures/ecarts-harmonisation-2026-09-28.json create mode 100644 fixtures/france_climat.json create mode 100644 fixtures/options.json create mode 100644 fixtures/postes.json create mode 100644 fixtures/profils-reference.json create mode 100644 fixtures/sorties-origine-2026-09-28.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 schemas/algo_config.schema.json create mode 100644 schemas/france_climat.schema.json create mode 100644 schemas/options.schema.json create mode 100644 schemas/postes.schema.json create mode 100644 scripts/ecarts-harmonisation.mjs create mode 100644 scripts/ecrire-schemas.mjs create mode 100644 scripts/nettoyer-dist.mjs create mode 100644 scripts/pin-origine.mjs create mode 100644 src/autonomie/alimentation.ts create mode 100644 src/autonomie/axe.ts create mode 100644 src/autonomie/eau.ts create mode 100644 src/autonomie/energie.ts create mode 100644 src/autonomie/index.ts create mode 100644 src/autonomie/integrateur.ts create mode 100644 src/autonomie/noyau/adequation.ts create mode 100644 src/autonomie/noyau/algo.ts create mode 100644 src/autonomie/noyau/alloue-budget.ts create mode 100644 src/autonomie/noyau/axes-actifs.ts create mode 100644 src/autonomie/noyau/calibrage.ts create mode 100644 src/autonomie/noyau/couplages.ts create mode 100644 src/autonomie/noyau/dimension.ts create mode 100644 src/autonomie/noyau/index.ts create mode 100644 src/autonomie/noyau/pondere-axes.ts create mode 100644 src/autonomie/noyau/reajuste.ts create mode 100644 src/autonomie/noyau/scoreurs.ts create mode 100644 src/autonomie/noyau/selectionne-poste.ts create mode 100644 src/autonomie/noyau/types.ts create mode 100644 src/autonomie/prix.ts create mode 100644 src/autonomie/taux.ts create mode 100644 src/commun/argent.ts create mode 100644 src/commun/coefficients.ts create mode 100644 src/commun/contexte.ts create mode 100644 src/commun/contrat.ts create mode 100644 src/commun/geometrie.ts create mode 100644 src/commun/index.ts create mode 100644 src/commun/schemas.ts create mode 100644 src/commun/valider.ts create mode 100644 src/index.ts create mode 100644 src/version.ts create mode 100644 test/commun.test.mjs create mode 100644 test/decoupage.test.mjs create mode 100644 test/moteurs.test.mjs create mode 100644 test/noyau-origine.test.mjs create mode 100644 test/paquet.test.mjs create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d3245af --- /dev/null +++ b/AGENTS.md @@ -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 `) 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é. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e7b3af5 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3846145 --- /dev/null +++ b/README.md @@ -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/.schema.json`, +`/fixtures/` (`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. diff --git a/dist/autonomie/alimentation.d.ts b/dist/autonomie/alimentation.d.ts new file mode 100644 index 0000000..3a8c3d9 --- /dev/null +++ b/dist/autonomie/alimentation.d.ts @@ -0,0 +1,3 @@ +export type { EntreesAxe, DonneesAutonomie, SortiesAxe } from './axe.js'; +export declare const alimentation: import("../index.js").Moteur; +export default alimentation; diff --git a/dist/autonomie/alimentation.js b/dist/autonomie/alimentation.js new file mode 100644 index 0000000..c22a574 --- /dev/null +++ b/dist/autonomie/alimentation.js @@ -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; diff --git a/dist/autonomie/axe.d.ts b/dist/autonomie/axe.d.ts new file mode 100644 index 0000000..80c639d --- /dev/null +++ b/dist/autonomie/axe.d.ts @@ -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; +}; +/** Ajoute aux sources les coefficients lus et les prix des typologies retenues. */ +export declare function traceSelection(trace: Trace, coefficients: TableCoefficients, lues: Set, postes: PosteResultat[], options: Option[], couplages: string[]): void; +export declare function calculeAxe(axe: Axe, entrees: EntreesAxe, donnees: DonneesAutonomie): Resultat; +export declare function moteurAxe(axe: Axe, id: string): Moteur; diff --git a/dist/autonomie/axe.js b/dist/autonomie/axe.js new file mode 100644 index 0000000..0592561 --- /dev/null +++ b/dist/autonomie/axe.js @@ -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) }; +} diff --git a/dist/autonomie/eau.d.ts b/dist/autonomie/eau.d.ts new file mode 100644 index 0000000..f6f4a0f --- /dev/null +++ b/dist/autonomie/eau.d.ts @@ -0,0 +1,3 @@ +export type { EntreesAxe, DonneesAutonomie, SortiesAxe } from './axe.js'; +export declare const eau: import("../index.js").Moteur; +export default eau; diff --git a/dist/autonomie/eau.js b/dist/autonomie/eau.js new file mode 100644 index 0000000..efb31ea --- /dev/null +++ b/dist/autonomie/eau.js @@ -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; diff --git a/dist/autonomie/energie.d.ts b/dist/autonomie/energie.d.ts new file mode 100644 index 0000000..b376797 --- /dev/null +++ b/dist/autonomie/energie.d.ts @@ -0,0 +1,3 @@ +export type { EntreesAxe, DonneesAutonomie, SortiesAxe } from './axe.js'; +export declare const energie: import("../index.js").Moteur; +export default energie; diff --git a/dist/autonomie/energie.js b/dist/autonomie/energie.js new file mode 100644 index 0000000..ea4aa6d --- /dev/null +++ b/dist/autonomie/energie.js @@ -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; diff --git a/dist/autonomie/index.d.ts b/dist/autonomie/index.d.ts new file mode 100644 index 0000000..4fac153 --- /dev/null +++ b/dist/autonomie/index.d.ts @@ -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'; diff --git a/dist/autonomie/index.js b/dist/autonomie/index.js new file mode 100644 index 0000000..841afdf --- /dev/null +++ b/dist/autonomie/index.js @@ -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'; diff --git a/dist/autonomie/integrateur.d.ts b/dist/autonomie/integrateur.d.ts new file mode 100644 index 0000000..e9e18d7 --- /dev/null +++ b/dist/autonomie/integrateur.d.ts @@ -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>; + total_eur_ht: number; + budget_cible: number; + ecart_budget_pct: number; + status: Statut; + message_status?: string; + couplages: string[]; + taux_couverture: Record; +} +/** 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; +export declare const integrateur: Moteur; +export default integrateur; diff --git a/dist/autonomie/integrateur.js b/dist/autonomie/integrateur.js new file mode 100644 index 0000000..99d4f31 --- /dev/null +++ b/dist/autonomie/integrateur.js @@ -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; diff --git a/dist/autonomie/noyau/adequation.d.ts b/dist/autonomie/noyau/adequation.d.ts new file mode 100644 index 0000000..894aa7d --- /dev/null +++ b/dist/autonomie/noyau/adequation.d.ts @@ -0,0 +1,17 @@ +import type { Autodiag, Contexte, Option, Poste, Reglages } from './types.js'; +export interface AdequationDebug { + score: number; + scoreurs: Record; + 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; diff --git a/dist/autonomie/noyau/adequation.js b/dist/autonomie/noyau/adequation.js new file mode 100644 index 0000000..6438bb4 --- /dev/null +++ b/dist/autonomie/noyau/adequation.js @@ -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; +} diff --git a/dist/autonomie/noyau/algo.d.ts b/dist/autonomie/noyau/algo.d.ts new file mode 100644 index 0000000..1dbffe3 --- /dev/null +++ b/dist/autonomie/noyau/algo.d.ts @@ -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; diff --git a/dist/autonomie/noyau/algo.js b/dist/autonomie/noyau/algo.js new file mode 100644 index 0000000..aa39977 --- /dev/null +++ b/dist/autonomie/noyau/algo.js @@ -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, + }; +} diff --git a/dist/autonomie/noyau/alloue-budget.d.ts b/dist/autonomie/noyau/alloue-budget.d.ts new file mode 100644 index 0000000..afb0480 --- /dev/null +++ b/dist/autonomie/noyau/alloue-budget.d.ts @@ -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; diff --git a/dist/autonomie/noyau/alloue-budget.js b/dist/autonomie/noyau/alloue-budget.js new file mode 100644 index 0000000..727f85f --- /dev/null +++ b/dist/autonomie/noyau/alloue-budget.js @@ -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, + }; +} diff --git a/dist/autonomie/noyau/axes-actifs.d.ts b/dist/autonomie/noyau/axes-actifs.d.ts new file mode 100644 index 0000000..9cedb55 --- /dev/null +++ b/dist/autonomie/noyau/axes-actifs.d.ts @@ -0,0 +1,20 @@ +import type { AlgoConfig, Axe } from './types.js'; +import { AXES } from './types.js'; +export type AxesActifs = Partial>; +/** + * 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 }; diff --git a/dist/autonomie/noyau/axes-actifs.js b/dist/autonomie/noyau/axes-actifs.js new file mode 100644 index 0000000..6338900 --- /dev/null +++ b/dist/autonomie/noyau/axes-actifs.js @@ -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 }; diff --git a/dist/autonomie/noyau/calibrage.d.ts b/dist/autonomie/noyau/calibrage.d.ts new file mode 100644 index 0000000..08d582f --- /dev/null +++ b/dist/autonomie/noyau/calibrage.d.ts @@ -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; diff --git a/dist/autonomie/noyau/calibrage.js b/dist/autonomie/noyau/calibrage.js new file mode 100644 index 0000000..dfafdb5 --- /dev/null +++ b/dist/autonomie/noyau/calibrage.js @@ -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; +} diff --git a/dist/autonomie/noyau/couplages.d.ts b/dist/autonomie/noyau/couplages.d.ts new file mode 100644 index 0000000..d30b565 --- /dev/null +++ b/dist/autonomie/noyau/couplages.d.ts @@ -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; diff --git a/dist/autonomie/noyau/couplages.js b/dist/autonomie/noyau/couplages.js new file mode 100644 index 0000000..83727d5 --- /dev/null +++ b/dist/autonomie/noyau/couplages.js @@ -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'; +} diff --git a/dist/autonomie/noyau/dimension.d.ts b/dist/autonomie/noyau/dimension.d.ts new file mode 100644 index 0000000..9bfdc77 --- /dev/null +++ b/dist/autonomie/noyau/dimension.d.ts @@ -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, contexte_derived: Partial, 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 | null): number; diff --git a/dist/autonomie/noyau/dimension.js b/dist/autonomie/noyau/dimension.js new file mode 100644 index 0000000..c949c14 --- /dev/null +++ b/dist/autonomie/noyau/dimension.js @@ -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; +} diff --git a/dist/autonomie/noyau/index.d.ts b/dist/autonomie/noyau/index.d.ts new file mode 100644 index 0000000..b383940 --- /dev/null +++ b/dist/autonomie/noyau/index.d.ts @@ -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'; diff --git a/dist/autonomie/noyau/index.js b/dist/autonomie/noyau/index.js new file mode 100644 index 0000000..2f95e87 --- /dev/null +++ b/dist/autonomie/noyau/index.js @@ -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'; diff --git a/dist/autonomie/noyau/pondere-axes.d.ts b/dist/autonomie/noyau/pondere-axes.d.ts new file mode 100644 index 0000000..56eeadf --- /dev/null +++ b/dist/autonomie/noyau/pondere-axes.d.ts @@ -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, config: AlgoConfig, debug?: false): PoidsAxes; +export declare function pondereAxes(inputs: Autodiag, contexte_derived: Pick, config: AlgoConfig, debug: true): PoidsDebug; diff --git a/dist/autonomie/noyau/pondere-axes.js b/dist/autonomie/noyau/pondere-axes.js new file mode 100644 index 0000000..c92c6ec --- /dev/null +++ b/dist/autonomie/noyau/pondere-axes.js @@ -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; +} diff --git a/dist/autonomie/noyau/reajuste.d.ts b/dist/autonomie/noyau/reajuste.d.ts new file mode 100644 index 0000000..59919bf --- /dev/null +++ b/dist/autonomie/noyau/reajuste.d.ts @@ -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; diff --git a/dist/autonomie/noyau/reajuste.js b/dist/autonomie/noyau/reajuste.js new file mode 100644 index 0000000..12f44c6 --- /dev/null +++ b/dist/autonomie/noyau/reajuste.js @@ -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; +} diff --git a/dist/autonomie/noyau/scoreurs.d.ts b/dist/autonomie/noyau/scoreurs.d.ts new file mode 100644 index 0000000..20fac26 --- /dev/null +++ b/dist/autonomie/noyau/scoreurs.d.ts @@ -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; diff --git a/dist/autonomie/noyau/scoreurs.js b/dist/autonomie/noyau/scoreurs.js new file mode 100644 index 0000000..fba81e4 --- /dev/null +++ b/dist/autonomie/noyau/scoreurs.js @@ -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]; +} diff --git a/dist/autonomie/noyau/selectionne-poste.d.ts b/dist/autonomie/noyau/selectionne-poste.d.ts new file mode 100644 index 0000000..75e1b2a --- /dev/null +++ b/dist/autonomie/noyau/selectionne-poste.d.ts @@ -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; diff --git a/dist/autonomie/noyau/selectionne-poste.js b/dist/autonomie/noyau/selectionne-poste.js new file mode 100644 index 0000000..fcfb083 --- /dev/null +++ b/dist/autonomie/noyau/selectionne-poste.js @@ -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; +} diff --git a/dist/autonomie/noyau/types.d.ts b/dist/autonomie/noyau/types.d.ts new file mode 100644 index 0000000..ab4021a --- /dev/null +++ b/dist/autonomie/noyau/types.d.ts @@ -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; + modulateurs?: Record; + adequation_weights?: Record>; + 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; + postes_poids?: Record; + /** 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>; +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; diff --git a/dist/autonomie/noyau/types.js b/dist/autonomie/noyau/types.js new file mode 100644 index 0000000..88a6929 --- /dev/null +++ b/dist/autonomie/noyau/types.js @@ -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; + } +} diff --git a/dist/autonomie/prix.d.ts b/dist/autonomie/prix.d.ts new file mode 100644 index 0000000..9588a5a --- /dev/null +++ b/dist/autonomie/prix.d.ts @@ -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, 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, pose: ModePose): string; diff --git a/dist/autonomie/prix.js b/dist/autonomie/prix.js new file mode 100644 index 0000000..97cca79 --- /dev/null +++ b/dist/autonomie/prix.js @@ -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'; +} diff --git a/dist/autonomie/taux.d.ts b/dist/autonomie/taux.d.ts new file mode 100644 index 0000000..d80179a --- /dev/null +++ b/dist/autonomie/taux.d.ts @@ -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, 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; +}, 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; +}; diff --git a/dist/autonomie/taux.js b/dist/autonomie/taux.js new file mode 100644 index 0000000..f52c0b0 --- /dev/null +++ b/dist/autonomie/taux.js @@ -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 } }; +} diff --git a/dist/commun/argent.d.ts b/dist/commun/argent.d.ts new file mode 100644 index 0000000..f27bce1 --- /dev/null +++ b/dist/commun/argent.d.ts @@ -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; diff --git a/dist/commun/argent.js b/dist/commun/argent.js new file mode 100644 index 0000000..61f5f97 --- /dev/null +++ b/dist/commun/argent.js @@ -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; +} diff --git a/dist/commun/coefficients.d.ts b/dist/commun/coefficients.d.ts new file mode 100644 index 0000000..3a47d6e --- /dev/null +++ b/dist/commun/coefficients.d.ts @@ -0,0 +1,69 @@ +export type Orientation = 'sud' | 'sud-est' | 'sud-ouest' | 'est' | 'ouest' | 'nord'; +export type ClasseRt = 'H1' | 'H2' | 'H3'; +export interface Coefficient { + 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>; + /** 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>; + /** 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; + 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>; + /** 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[]; diff --git a/dist/commun/coefficients.js b/dist/commun/coefficients.js new file mode 100644 index 0000000..dd35c9c --- /dev/null +++ b/dist/commun/coefficients.js @@ -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; +} diff --git a/dist/commun/contexte.d.ts b/dist/commun/contexte.d.ts new file mode 100644 index 0000000..fd4824e --- /dev/null +++ b/dist/commun/contexte.d.ts @@ -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; +/** 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; +export declare const contexte: Moteur; diff --git a/dist/commun/contexte.js b/dist/commun/contexte.js new file mode 100644 index 0000000..0303a8f --- /dev/null +++ b/dist/commun/contexte.js @@ -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); + }, +}; diff --git a/dist/commun/contrat.d.ts b/dist/commun/contrat.d.ts new file mode 100644 index 0000000..384b23e --- /dev/null +++ b/dist/commun/contrat.d.ts @@ -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 { + 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 { + /** 'autonomie.eau', 'commun.geometrie', … */ + id: string; + calculer(entrees: E, donnees: D): Resultat; +} +/** + * 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(sorties: S, version: string): Resultat; +} diff --git a/dist/commun/contrat.js b/dist/commun/contrat.js new file mode 100644 index 0000000..59d0b36 --- /dev/null +++ b/dist/commun/contrat.js @@ -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, + }; + } +} diff --git a/dist/commun/geometrie.d.ts b/dist/commun/geometrie.d.ts new file mode 100644 index 0000000..fd1e3a8 --- /dev/null +++ b/dist/commun/geometrie.d.ts @@ -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; +export declare const geometrie: Moteur; diff --git a/dist/commun/geometrie.js b/dist/commun/geometrie.js new file mode 100644 index 0000000..905eea4 --- /dev/null +++ b/dist/commun/geometrie.js @@ -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); + }, +}; diff --git a/dist/commun/index.d.ts b/dist/commun/index.d.ts new file mode 100644 index 0000000..7aa24ce --- /dev/null +++ b/dist/commun/index.d.ts @@ -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'; diff --git a/dist/commun/index.js b/dist/commun/index.js new file mode 100644 index 0000000..f7d12c8 --- /dev/null +++ b/dist/commun/index.js @@ -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'; diff --git a/dist/commun/schemas.d.ts b/dist/commun/schemas.d.ts new file mode 100644 index 0000000..94c7f77 --- /dev/null +++ b/dist/commun/schemas.d.ts @@ -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; + items?: Schema; + enum?: (string | number | boolean | null)[]; + minItems?: number; +} +export type NomTable = 'postes' | 'options' | 'france_climat' | 'algo_config'; +export declare const SCHEMAS: Record; +export declare const NOMS_TABLES: NomTable[]; diff --git a/dist/commun/schemas.js b/dist/commun/schemas.js new file mode 100644 index 0000000..76a2005 --- /dev/null +++ b/dist/commun/schemas.js @@ -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/
.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); diff --git a/dist/commun/valider.d.ts b/dist/commun/valider.d.ts new file mode 100644 index 0000000..e1691fb --- /dev/null +++ b/dist/commun/valider.d.ts @@ -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; diff --git a/dist/commun/valider.js b/dist/commun/valider.js new file mode 100644 index 0000000..98debbe --- /dev/null +++ b/dist/commun/valider.js @@ -0,0 +1,77 @@ +// valider.ts — validation structurelle d'un JSON de données contre le schéma +// de sa table (schemas/
.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 }; +} diff --git a/dist/index.d.ts b/dist/index.d.ts new file mode 100644 index 0000000..1d4bf36 --- /dev/null +++ b/dist/index.d.ts @@ -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]; diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..4cbdbb6 --- /dev/null +++ b/dist/index.js @@ -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]; diff --git a/dist/version.d.ts b/dist/version.d.ts new file mode 100644 index 0000000..561ad91 --- /dev/null +++ b/dist/version.d.ts @@ -0,0 +1 @@ +export declare const VERSION = "0.1.0"; diff --git a/dist/version.js b/dist/version.js new file mode 100644 index 0000000..1b9b898 --- /dev/null +++ b/dist/version.js @@ -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'; diff --git a/docs/CONSTANTES.md b/docs/CONSTANTES.md new file mode 100644 index 0000000..5aa579a --- /dev/null +++ b/docs/CONSTANTES.md @@ -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. diff --git a/fixtures/README.md b/fixtures/README.md new file mode 100644 index 0000000..3039a7c --- /dev/null +++ b/fixtures/README.md @@ -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/` (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 `) ; 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. diff --git a/fixtures/algo-config.json b/fixtures/algo-config.json new file mode 100644 index 0000000..e97a27a --- /dev/null +++ b/fixtures/algo-config.json @@ -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 + } + } +} \ No newline at end of file diff --git a/fixtures/coefficients-2026-05.json b/fixtures/coefficients-2026-05.json new file mode 100644 index 0000000..4dd600f --- /dev/null +++ b/fixtures/coefficients-2026-05.json @@ -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" } +} diff --git a/fixtures/ecarts-harmonisation-2026-09-28.json b/fixtures/ecarts-harmonisation-2026-09-28.json new file mode 100644 index 0000000..a446919 --- /dev/null +++ b/fixtures/ecarts-harmonisation-2026-09-28.json @@ -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 + } + } + } + ] +} diff --git a/fixtures/france_climat.json b/fixtures/france_climat.json new file mode 100644 index 0000000..517a83a --- /dev/null +++ b/fixtures/france_climat.json @@ -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"} +] diff --git a/fixtures/options.json b/fixtures/options.json new file mode 100644 index 0000000..ac32819 --- /dev/null +++ b/fixtures/options.json @@ -0,0 +1,2722 @@ +[ + { + "Id": 51, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:34+00:00", + "poste_id": 1, + "code": "captage_descente_directe", + "nom": "Descente directe + crépine", + "description": "Branchement direct gouttière + crépine anti-feuilles", + "prix_mat_min": 50, + "prix_mat_max": 150, + "prix_pro_min": 200, + "prix_pro_max": 400, + "unite": "forfait", + "quantite_default": 1, + "source": "cuve-expert.fr / pompe-moteur.fr", + "source_date": "2026-04-15", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "captage_descent_crepine", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Raccordement direct de la gouttière à la cuve avec crépine anti-feuilles en sortie.", + "notes_techniques": "Crépine inox 280-440 µm. Aucune pré-filtration fine, eaux non potables. DIY 1-2h. Solution la plus simple du captage.", + "accessible_fr": true + }, + { + "Id": 52, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:35+00:00", + "poste_id": 1, + "code": "captage_filtre_collecteur", + "nom": "Filtre collecteur dérivation gouttière", + "description": "Collecteur dérivé sur PVC, filtration grossière 0.4-0.8 mm, by-pass", + "prix_mat_min": 80, + "prix_mat_max": 250, + "prix_pro_min": 250, + "prix_pro_max": 600, + "unite": "forfait", + "quantite_default": 1, + "source": "leroymerlin.fr / neatfx.fr", + "source_date": "2026-04-15", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "captage_filtre_derivation", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Collecteur dévié sur descente PVC qui filtre grossièrement l'eau avant la cuve.", + "notes_techniques": "Filtration 0.4-0.8 mm, by-pass trop-plein intégré. Modèles : 3P, Garantia, Speedy. 1h pose DIY. Standard petites cuves.", + "accessible_fr": true + }, + { + "Id": 53, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:35+00:00", + "poste_id": 1, + "code": "captage_filtre_inox_amont", + "nom": "Filtre auto-nettoyant inox amont", + "description": "Filtre cyclonique ou tamis inox 280-440 µm en amont cuve", + "prix_mat_min": 200, + "prix_mat_max": 600, + "prix_pro_min": 500, + "prix_pro_max": 1200, + "unite": "forfait", + "quantite_default": 1, + "source": "recuperereaupluie.com", + "source_date": "2026-04-15", + "notes": "Indispensable si cuve enterrée + double réseau", + "filiere_agreee": false, + "actif": true, + "typologie": "captage_filtre_inox_amont", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 2, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Filtre auto-nettoyant inox placé avant la cuve pour retenir sables et débris fins.", + "notes_techniques": "Tamis inox 280-440 µm, cyclonique ou vortex. Perte de volume 5-10%. Indispensable pour double réseau WC/lessive. Nettoyage annuel.", + "accessible_fr": true + }, + { + "Id": 54, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:54:26+00:00", + "poste_id": 1, + "code": "captage_chove_chuva", + "nom": "Système Chove Chuva + pré-filtre 150 µm", + "description": "Filtration eau de pluie potabilisation amont, débit 3 m³/h", + "prix_mat_min": 350, + "prix_mat_max": 800, + "prix_pro_min": 800, + "prix_pro_max": 1800, + "unite": "forfait", + "quantite_default": 1, + "source": "calcul interne", + "source_date": "2020-01-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "captage_potabilisation_amont", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 3, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Système de filtration amont qui rend l'eau de pluie potable avant même le stockage.", + "notes_techniques": "Technologie Chove Chuva (brésilien) non distribuée en France. Alternative à valider : kit potabilisation UV 3 corps (pré-filtre 60 µm + charbon actif + UV-C) disponible chez 1h2o3.com ou aquafiltre.com. Débit 1500 L/h. Maintenance : changement lampe UV annuel + cartouches 6 mois.", + "accessible_fr": false + }, + { + "Id": 55, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:35+00:00", + "poste_id": 1, + "code": "captage_capteur_sol", + "nom": "Capteur sol (puits filtrant / drainage)", + "description": "Drains horizontaux + couche filtrante gravier-sable", + "prix_mat_min": 300, + "prix_mat_max": 1200, + "prix_pro_min": 1500, + "prix_pro_max": 4000, + "unite": "forfait", + "quantite_default": 1, + "source": "ootravaux.fr / aquasoluces.fr", + "source_date": "2026-04-15", + "notes": "Eaux non potables uniquement", + "filiere_agreee": false, + "actif": true, + "typologie": "captage_sol_drainant", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 3, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Drains enfouis dans la parcelle qui récupèrent le ruissellement vers la cuve enterrée.", + "notes_techniques": "Tranchée drainante gravier-sable. Eaux non potables uniquement. Pour terrains pentus. Terrassement requis. Débit variable selon pluviométrie.", + "accessible_fr": true + }, + { + "Id": 56, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:35+00:00", + "poste_id": 2, + "code": "filtration_particulaire_simple", + "nom": "Particulaire simple (cartouche 25-50 µm)", + "description": "Porte-filtre 10\" bobine polypropylène, après pompe", + "prix_mat_min": 30, + "prix_mat_max": 80, + "prix_pro_min": 150, + "prix_pro_max": 300, + "unite": "forfait", + "quantite_default": 1, + "source": "manomano.fr / 1h2o3.com", + "source_date": "2026-04-15", + "notes": "Niveau minimal usage WC/lessive/jardin", + "filiere_agreee": false, + "actif": true, + "typologie": "filtration_particulaire_simple", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Filtre à cartouche basique pour retenir les particules avant usage jardin ou WC.", + "notes_techniques": "Porte-filtre 10\" bobine polypropylène 25-50 µm. Niveau minimal (WC, lessive, jardin). Changement cartouche tous les 3 mois. Débit 1500 L/h.", + "accessible_fr": true + }, + { + "Id": 57, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:35+00:00", + "poste_id": 2, + "code": "filtration_2_corps", + "nom": "Multi-étages 2 corps (sédiment + charbon)", + "description": "Corps double 10\", déchloration, particules fines", + "prix_mat_min": 80, + "prix_mat_max": 200, + "prix_pro_min": 300, + "prix_pro_max": 600, + "unite": "forfait", + "quantite_default": 1, + "source": "1h2o3.com kit potabilisation", + "source_date": "2026-04-15", + "notes": "Niveau douche acceptable, pas potable", + "filiere_agreee": false, + "actif": true, + "typologie": "filtration_multi_charbon", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Double filtre sédiment et charbon actif qui améliore goût et qualité de l'eau pour la douche.", + "notes_techniques": "Corps double 10\" : sédiment 5 µm + charbon actif. Déchlorination, goût/odeur. Niveau 'douche acceptable', pas potable seul. Changement 6 mois.", + "accessible_fr": true + }, + { + "Id": 58, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:35+00:00", + "poste_id": 2, + "code": "filtration_3_corps_uv", + "nom": "Multi-étages 3 corps + UV 30 mJ/cm²", + "description": "60 µm + 10 µm + charbon actif + lampe UV-C 25-55 W", + "prix_mat_min": 400, + "prix_mat_max": 900, + "prix_pro_min": 800, + "prix_pro_max": 1800, + "unite": "forfait", + "quantite_default": 1, + "source": "1h2o3.com / entre2-eaux.com", + "source_date": "2026-04-15", + "notes": "Potabilisation, conforme EN 1717", + "filiere_agreee": false, + "actif": true, + "typologie": "filtration_complete_uv", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 2, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Kit potabilisation complet 3 corps et UV qui rend l'eau de pluie potable pour toute la maison.", + "notes_techniques": "60 µm + 10 µm + charbon actif + UV-C 25-55 W 30 mJ/cm². Conforme EN 1717. Débit 1500 L/h. Changement lampe UV annuel.", + "accessible_fr": true + }, + { + "Id": 59, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:36+00:00", + "poste_id": 2, + "code": "filtration_osmose_inverse", + "nom": "Osmose inverse 5-6 étages domestique", + "description": "Sédiment + charbon + membrane RO + post-filtre, 180-300 L/jour", + "prix_mat_min": 200, + "prix_mat_max": 700, + "prix_pro_min": 500, + "prix_pro_max": 1500, + "unite": "forfait", + "quantite_default": 1, + "source": "naturewater.eu / lemeilleuravis.fr", + "source_date": "2026-04-15", + "notes": "Boisson + cuisine uniquement, perte 30-50%", + "filiere_agreee": false, + "actif": true, + "typologie": "filtration_osmose_inverse", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 2, + "temps_install_h": null, + "couplages_requis": "[\"filtration_particulaire_simple\"]", + "couplages_exclus": "[\"stockage_cuve_beton_enterree\"]", + "description_pedago": "Osmoseur sous l'évier qui produit une eau ultra-pure pour la boisson et la cuisine uniquement.", + "notes_techniques": "5-6 étages : sédiment + charbon + membrane RO + post-filtre. Débit 180-300 L/j. Rejet 30-50% eau. Uniquement boisson/cuisine. Changement membrane 2-3 ans.", + "accessible_fr": true + }, + { + "Id": 60, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:36+00:00", + "poste_id": 2, + "code": "filtration_uv_standalone", + "nom": "UV-C standalone (sans pré-filtration)", + "description": "Stérilisateur UV 25-55 W inox", + "prix_mat_min": 150, + "prix_mat_max": 450, + "prix_pro_min": 350, + "prix_pro_max": 800, + "unite": "forfait", + "quantite_default": 1, + "source": "esa-evolutions.fr", + "source_date": "2026-04-15", + "notes": "À coupler systématiquement", + "filiere_agreee": false, + "actif": true, + "typologie": "filtration_uv_standalone", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[\"filtration_particulaire_simple\"]", + "couplages_exclus": "[]", + "description_pedago": "Lampe UV-C en complément d'un pré-filtre pour éliminer les bactéries résiduelles de l'eau.", + "notes_techniques": "Stérilisateur UV 25-55 W inox. INUTILE sans pré-filtre. Lampe à changer tous les 12 mois. Débit max 1500 L/h. Complément indispensable potabilisation.", + "accessible_fr": true + }, + { + "Id": 61, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:36+00:00", + "poste_id": 3, + "code": "stockage_pehd_horssol_5m3", + "nom": "Cuve PEHD hors-sol 3-5 m³", + "description": "Polyéthylène HD anti-UV, vertical", + "prix_mat_min": 416, + "prix_mat_max": 1800, + "prix_pro_min": 800, + "prix_pro_max": 2500, + "unite": "forfait", + "quantite_default": 1, + "source": "cuve-expert.fr", + "source_date": "2026-04-15", + "notes": "Volume bas, gel l'hiver si non isolée", + "filiere_agreee": false, + "actif": true, + "typologie": "stockage_cuve_pehd_horsol", + "parametre_dim": "{\"nom\": \"volume_m3\", \"min\": 3, \"max\": 5, \"step\": 1, \"default\": 4, \"unite\": \"m\\u00b3\", \"strategie\": \"par_personne\", \"base\": 2, \"coef\": 0.5}", + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Cuve plastique hors-sol de 3 à 5 m³ pour stocker l'eau de pluie simplement au-dessus du sol.", + "notes_techniques": "PEHD anti-UV, vertical, raccord standard. Gel hivernal si non isolée. Usage : jardin + WC. Durée vie 15-20 ans. Ombrage obligatoire.", + "accessible_fr": true + }, + { + "Id": 62, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:36+00:00", + "poste_id": 3, + "code": "stockage_pehd_enterree_5m3", + "nom": "Cuve PEHD enterrée 3-5 m³", + "description": "PEHD renforcée, passage piéton ou véhicule", + "prix_mat_min": 1200, + "prix_mat_max": 3200, + "prix_pro_min": 3500, + "prix_pro_max": 6500, + "unite": "forfait", + "quantite_default": 1, + "source": "cuve-expert.fr / neatfx.fr", + "source_date": "2026-04-15", + "notes": "Standard light pour 2-3 pers", + "filiere_agreee": false, + "actif": true, + "typologie": "stockage_cuve_pehd_enterree", + "parametre_dim": "{\"nom\": \"volume_m3\", \"min\": 3, \"max\": 15, \"step\": 1, \"default\": 8, \"unite\": \"m\\u00b3\", \"strategie\": \"par_personne\", \"base\": 2, \"coef\": 1.5}", + "prix_par_unite": 380, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 3, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Cuve plastique enterrée de 3 à 15 m³ qui stocke l'eau de pluie à l'abri du gel toute l'année.", + "notes_techniques": "PEHD renforcée passage véhicule, 1-2 m profondeur. T° stable 8-15°C. Standard 4-5 pers = 8 m³. Durée vie 30+ ans. Pose pelleteuse requise.", + "accessible_fr": true + }, + { + "Id": 64, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:36+00:00", + "poste_id": 3, + "code": "stockage_beton_5m3", + "nom": "Cuve béton enterrée 5 m³", + "description": "Béton préfa, neutralise pH", + "prix_mat_min": 1700, + "prix_mat_max": 2500, + "prix_pro_min": 4000, + "prix_pro_max": 7000, + "unite": "forfait", + "quantite_default": 1, + "source": "tubao.fr / pompe-moteur.fr", + "source_date": "2026-04-15", + "notes": "Plus lourd à poser, durée vie 50+ ans", + "filiere_agreee": false, + "actif": true, + "typologie": "stockage_cuve_beton_enterree", + "parametre_dim": "{\"nom\": \"volume_m3\", \"min\": 5, \"max\": 10, \"step\": 1, \"default\": 8, \"unite\": \"m\\u00b3\", \"strategie\": \"par_personne\", \"base\": 3, \"coef\": 1.5}", + "prix_par_unite": 480, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 3, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[\"filtration_osmose_inverse\"]", + "description_pedago": "Cuve béton enterrée de 5 à 10 m³, durable 50 ans et qui neutralise naturellement le pH de l'eau.", + "notes_techniques": "Béton préfabriqué. Neutralise pH alcalin. Durée vie 50+ ans. Plus lourd à poser. Incompatible osmose inverse. Raccordement filtration en sortie.", + "accessible_fr": true + }, + { + "Id": 66, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:36+00:00", + "poste_id": 3, + "code": "stockage_souple_5m3", + "nom": "Cuve souple (vessie) 5 m³", + "description": "Polyester PVC, pose dalle stabilisée, anti-UV", + "prix_mat_min": 250, + "prix_mat_max": 1200, + "prix_pro_min": 500, + "prix_pro_max": 2200, + "unite": "forfait", + "quantite_default": 1, + "source": "edenea.com / citerne-rain-o.fr", + "source_date": "2026-04-15", + "notes": "Solution low-cost, durée 10-15 ans", + "filiere_agreee": false, + "actif": true, + "typologie": "stockage_cuve_souple", + "parametre_dim": "{\"nom\": \"volume_m3\", \"min\": 5, \"max\": 10, \"step\": 1, \"default\": 5, \"unite\": \"m\\u00b3\", \"strategie\": \"par_personne\", \"base\": 2, \"coef\": 1.0}", + "prix_par_unite": 95, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Cuve souple en toile posée sur dalle, solution low-cost pour stocker de grands volumes d'eau.", + "notes_techniques": "Polyester enduit PVC, anti-UV. Pose sur dalle stabilisée. Durée vie 10-15 ans. Option crapaudine pour 10 m³. Ombre recommandée.", + "accessible_fr": true + }, + { + "Id": 69, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:37+00:00", + "poste_id": 4, + "code": "distrib_pompe_surface", + "nom": "Pompe surpresseur surface (jet 0.8-1.1 kW)", + "description": "Pompe + ballon 19-24 L, démarrage automatique", + "prix_mat_min": 150, + "prix_mat_max": 450, + "prix_pro_min": 400, + "prix_pro_max": 900, + "unite": "forfait", + "quantite_default": 1, + "source": "pompe-moteur.fr / leroymerlin.fr", + "source_date": "2026-04-15", + "notes": "Pour cuve hors-sol uniquement", + "filiere_agreee": false, + "actif": true, + "typologie": "distrib_pompe_surface", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[\"stockage_cuve_pehd_horsol\"]", + "couplages_exclus": "[\"stockage_cuve_pehd_enterree\", \"stockage_cuve_beton_enterree\"]", + "description_pedago": "Pompe surpresseur posée au sol qui aspire l'eau de la cuve hors-sol vers les robinets.", + "notes_techniques": "0.8-1.1 kW + ballon 19-24 L, démarrage automatique. HMT < 8 m uniquement. Pour cuve hors-sol. Bruit modéré. Local technique requis.", + "accessible_fr": true + }, + { + "Id": 70, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:37+00:00", + "poste_id": 4, + "code": "distrib_pompe_immergee", + "nom": "Pompe immergée multicellulaire + flotteur", + "description": "Pompe inox 0.75-1.5 kW", + "prix_mat_min": 250, + "prix_mat_max": 800, + "prix_pro_min": 600, + "prix_pro_max": 1600, + "unite": "forfait", + "quantite_default": 1, + "source": "pompes-h2o.com", + "source_date": "2026-04-15", + "notes": "Standard cuve enterrée, silencieuse", + "filiere_agreee": false, + "actif": true, + "typologie": "distrib_pompe_immergee", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 2, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[\"stockage_cuve_pehd_horsol\"]", + "description_pedago": "Pompe immergée dans la cuve enterrée qui distribue l'eau silencieusement sous pression constante.", + "notes_techniques": "Inox 0.75-1.5 kW + tuyau aspiration + flotteur. Standard cuve enterrée. Silencieuse. HMT 20-40 m. Maintenance légère tous les 5 ans.", + "accessible_fr": true + }, + { + "Id": 71, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:37+00:00", + "poste_id": 4, + "code": "distrib_kit_double_reseau", + "nom": "Kit pompe + gestionnaire double réseau", + "description": "Pompe + coffret bascule auto, conforme EN 1717", + "prix_mat_min": 800, + "prix_mat_max": 1700, + "prix_pro_min": 1500, + "prix_pro_max": 3200, + "unite": "forfait", + "quantite_default": 1, + "source": "pompes-h2o.com / pompe-moteur.fr", + "source_date": "2026-04-15", + "notes": "Obligatoire pour usage interne", + "filiere_agreee": false, + "actif": true, + "typologie": "distrib_kit_double_reseau", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 2, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Kit pompe et coffret qui bascule automatiquement entre eau de pluie et eau de ville si la cuve est vide.", + "notes_techniques": "Conforme EN 1717 (garde d'air obligatoire). Usage intérieur WC + lessive. Bascule auto si cuve vide. Obligatoire usage non-potable intérieur.", + "accessible_fr": true + }, + { + "Id": 72, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:37+00:00", + "poste_id": 4, + "code": "distrib_gravitaire", + "nom": "Réseau gravitaire (cuve haute, sans pompe)", + "description": "Cuve placée en hauteur, pression par charge hydraulique", + "prix_mat_min": 100, + "prix_mat_max": 400, + "prix_pro_min": 300, + "prix_pro_max": 1000, + "unite": "forfait", + "quantite_default": 1, + "source": "wiki.lowtechlab.org / eautarcie.org", + "source_date": "2026-04-15", + "notes": "Zéro énergie, pression 0.3-0.6 bar", + "filiere_agreee": false, + "actif": true, + "typologie": "distrib_gravitaire", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Réseau gravitaire depuis une cuve surélevée, zéro énergie, pression naturelle par charge hydraulique.", + "notes_techniques": "Cuve en combles ou butte. Pression 0.3-0.6 bar. Zéro électricité. Adapté jardin + WC. Pression insuffisante pour douche standard.", + "accessible_fr": true + }, + { + "Id": 73, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:37+00:00", + "poste_id": 4, + "code": "distrib_per_bleu_interieur", + "nom": "Double réseau dédié intérieur (PER bleu)", + "description": "Tubes PER + raccords + percements WC + lave-linge", + "prix_mat_min": 300, + "prix_mat_max": 800, + "prix_pro_min": 1200, + "prix_pro_max": 3000, + "unite": "forfait", + "quantite_default": 1, + "source": "DTU 60.11 + NF P 16-005", + "source_date": "2026-01-01", + "notes": "Réglementé, carnet entretien obligatoire", + "filiere_agreee": false, + "actif": true, + "typologie": "distrib_double_reseau_interieur", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 3, + "temps_install_h": null, + "couplages_requis": "[\"distrib_kit_double_reseau\"]", + "couplages_exclus": "[]", + "description_pedago": "Tuyauterie PER bleue dédiée qui amène l'eau de pluie aux WC et au lave-linge en circuit séparé.", + "notes_techniques": "Tubes PER bleu + raccords + percements. Signalétique réglementaire NF P 16-005. Carnet entretien obligatoire. Plombier requis. DTU 60.11.", + "accessible_fr": true + }, + { + "Id": 74, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:54:26+00:00", + "poste_id": 5, + "code": "eg_separation_source", + "nom": "Séparation EG/EN à la source", + "description": "Plomberie séparée douche/lavabo/lessive vers cuve EG", + "prix_mat_min": 200, + "prix_mat_max": 700, + "prix_pro_min": 1500, + "prix_pro_max": 4000, + "unite": "forfait", + "quantite_default": 1, + "source": "calcul interne + Belle Île 2023", + "source_date": "2023-01-01", + "notes": "Pré-requis valorisation EG", + "filiere_agreee": false, + "actif": true, + "typologie": "eg_separation_source", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 3, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Plomberie séparée qui envoie douche et lavabo vers une cuve eaux grises distincte des eaux noires.", + "notes_techniques": "2e descente PVC + regards. Pré-requis pour valoriser les EG. À prévoir en conception ou rénovation lourde. Nécessaire avant recyclage EG.", + "accessible_fr": true + }, + { + "Id": 75, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:37+00:00", + "poste_id": 5, + "code": "eg_recyclage_spareau", + "nom": "Recyclage EG douche → chasse WC (Spareau)", + "description": "Caisson filtration EG + pompe + cuve tampon 50-200 L", + "prix_mat_min": 1500, + "prix_mat_max": 4800, + "prix_pro_min": 3500, + "prix_pro_max": 8000, + "unite": "forfait", + "quantite_default": 1, + "source": "Étude Belle Île", + "source_date": "2023-10-01", + "notes": "Économise ~80 L/jour pour 2 pers", + "filiere_agreee": false, + "actif": true, + "typologie": "eg_recyclage_spareau", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 2, + "temps_install_h": null, + "couplages_requis": "[\"eg_separation_source\"]", + "couplages_exclus": "[]", + "description_pedago": "Caisson compact qui filtre l'eau de douche et la réinjecte directement dans les chasses WC.", + "notes_techniques": "Spareau ou Hydraloop. Cuve tampon 50-200 L + pompe + filtration. Economie ~80 L/j pour 2 pers. Usage WC uniquement. Réglementaire.", + "accessible_fr": true + }, + { + "Id": 76, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:38+00:00", + "poste_id": 5, + "code": "eg_fpv_autoconstruit", + "nom": "Filtre planté vertical FPV auto-construit", + "description": "Bassin maçonné/EPDM + graviers + roseaux, 2 m²/EH", + "prix_mat_min": 800, + "prix_mat_max": 2500, + "prix_pro_min": 2000, + "prix_pro_max": 5000, + "unite": "forfait", + "quantite_default": 1, + "source": "wiki.lowtechlab.org / aqualogik.fr", + "source_date": "2026-04-15", + "notes": "NON agréé seul", + "filiere_agreee": false, + "actif": true, + "typologie": "eg_fpv_autoconstruit", + "parametre_dim": "{\"nom\": \"surface_m2\", \"min\": 6, \"max\": 20, \"step\": 1, \"default\": 10, \"unite\": \"m\\u00b2\", \"strategie\": \"par_personne\", \"base\": 4, \"coef\": 1.5, \"reserve_construit\": 0}", + "prix_par_unite": 200, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 2, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[\"eg_filiere_agreee_aquatiris\", \"eg_filiere_agreee_autoepure\", \"eg_filiere_agreee_autres\"]", + "description_pedago": "Bassin planté de roseaux auto-construit qui épure les eaux grises par filtration naturelle et végétale.", + "notes_techniques": "Bassin maçonné/EPDM + graviers + roseaux. 2-5 m²/EH. NON agréé seul pour ANC (besoin filtre horizontal aval). DIY possible. Durée vie 20+ ans.", + "accessible_fr": true + }, + { + "Id": 77, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:38+00:00", + "poste_id": 5, + "code": "eg_aquatiris", + "nom": "Filière FPV+FPH agréée Aquatiris", + "description": "Filtre vertical + horizontal, 5 m²/EH, agrément 2011-022", + "prix_mat_min": 4500, + "prix_mat_max": 9000, + "prix_pro_min": 7500, + "prix_pro_max": 14000, + "unite": "forfait", + "quantite_default": 1, + "source": "aquatiris.com + JO 2011", + "source_date": "2011-01-01", + "notes": "Agrément ministériel 2011-022", + "filiere_agreee": true, + "actif": true, + "typologie": "eg_filiere_agreee_aquatiris", + "parametre_dim": "{\"nom\": \"nb_eh\", \"min\": 4, \"max\": 20, \"step\": 1, \"default\": 5, \"unite\": \"EH\", \"strategie\": \"par_personne\", \"base\": 1, \"coef\": 1}", + "prix_par_unite": 900, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 3, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[\"eg_fpv_autoconstruit\"]", + "description_pedago": "Filière phyto agréée Aquatiris associant deux filtres plantés pour un assainissement autonome complet.", + "notes_techniques": "FPV + FPH, agrément ministériel 2011-022. 5 m²/EH total. 4-20 EH. Surface foncière 1.3x surface utile. Robustesse prouvée.", + "accessible_fr": true + }, + { + "Id": 78, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:38+00:00", + "poste_id": 5, + "code": "eg_autoepure", + "nom": "Filière Autoepure (Innovea/STOC)", + "description": "Filtre planté agréé compact 5-12 EH, agrément 2011-0004 bis", + "prix_mat_min": 5500, + "prix_mat_max": 11000, + "prix_pro_min": 8500, + "prix_pro_max": 15000, + "unite": "forfait", + "quantite_default": 1, + "source": "stoc-environnement.fr", + "source_date": "2011-01-01", + "notes": "Agrément ministériel 2011-0004 bis", + "filiere_agreee": true, + "actif": true, + "typologie": "eg_filiere_agreee_autoepure", + "parametre_dim": "{\"nom\": \"nb_eh\", \"min\": 5, \"max\": 12, \"step\": 1, \"default\": 5, \"unite\": \"EH\", \"strategie\": \"par_personne\", \"base\": 1, \"coef\": 1}", + "prix_par_unite": 1050, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 3, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[\"eg_fpv_autoconstruit\"]", + "description_pedago": "Filière phyto agréée Autoepure en filtre sable végétalisé compact pour 5 à 12 équivalents-habitants.", + "notes_techniques": "Innovea / STOC Environnement. Agrément 2011-0004 bis. 5-12 EH. Compact, bassin maçonné. Entretien annuel. Légifrance JORFTEXT000024999235.", + "accessible_fr": true + }, + { + "Id": 79, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:38+00:00", + "poste_id": 5, + "code": "eg_phytostation", + "nom": "Filière Phytostation (Inphyto)", + "description": "Filtre planté compact agréé, 4-20 EH", + "prix_mat_min": 6000, + "prix_mat_max": 12000, + "prix_pro_min": 9500, + "prix_pro_max": 16000, + "unite": "forfait", + "quantite_default": 1, + "source": "portail ANC gouv", + "source_date": "2026-04-15", + "notes": "Agrément actif", + "filiere_agreee": true, + "actif": true, + "typologie": "eg_filiere_agreee_autres", + "parametre_dim": "{\"nom\": \"nb_eh\", \"min\": 4, \"max\": 20, \"step\": 1, \"default\": 5, \"unite\": \"EH\", \"strategie\": \"par_personne\", \"base\": 1, \"coef\": 1}", + "prix_par_unite": 1100, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 3, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[\"eg_fpv_autoconstruit\"]", + "description_pedago": "Filières plantées agréées alternatives (Phytostation, Ecophyltre, Sebico) à choisir selon devis local.", + "notes_techniques": "Phytostation (Inphyto), Ecophyltre, Sebico Biomeris. 4-20 EH selon modèle. Agréments actifs portail ANC gouv. Prix à valider par devis local.", + "accessible_fr": true + }, + { + "Id": 81, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:38+00:00", + "poste_id": 5, + "code": "eg_pedoepuration", + "nom": "Pédoépuration (tertre épandage planté)", + "description": "Massif sableux + couche organique, 5-10 m²/EH", + "prix_mat_min": 2000, + "prix_mat_max": 5000, + "prix_pro_min": 4500, + "prix_pro_max": 9000, + "unite": "forfait", + "quantite_default": 1, + "source": "aehb-conseil.fr / aquasoluces.fr", + "source_date": "2026-04-15", + "notes": "Non agréée seule", + "filiere_agreee": false, + "actif": true, + "typologie": "eg_pedoepuration", + "parametre_dim": "{\"nom\": \"surface_m2\", \"min\": 10, \"max\": 50, \"step\": 2, \"default\": 20, \"unite\": \"m\\u00b2\", \"strategie\": \"par_personne\", \"base\": 6, \"coef\": 2, \"reserve_construit\": 0}", + "prix_par_unite": 400, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 2, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Tertre d'épandage planté qui filtre les effluents à travers un massif sableux et végétalisé.", + "notes_techniques": "Massif sableux + couche organique + plantations. 5-10 m²/EH. Filière non agréée seule, complément FPV. Installation terrassement requis.", + "accessible_fr": true + }, + { + "Id": 82, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:54:26+00:00", + "poste_id": 5, + "code": "eg_noue", + "nom": "Noue d'infiltration paysagée (EG seules)", + "description": "Tranchée plantée drainante, irrigation passive jardin", + "prix_mat_min": 300, + "prix_mat_max": 1200, + "prix_pro_min": 800, + "prix_pro_max": 2500, + "unite": "forfait", + "quantite_default": 1, + "source": "calcul interne", + "source_date": "2020-01-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "eg_noue_infiltration", + "parametre_dim": "{\"nom\": \"longueur_ml\", \"min\": 5, \"max\": 30, \"step\": 1, \"default\": 10, \"unite\": \"ml\", \"strategie\": \"par_metres_haie\", \"coef\": 0.2}", + "prix_par_unite": 80, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[\"eg_fpv_autoconstruit\"]", + "couplages_exclus": "[]", + "description_pedago": "Tranchée paysagée plantée qui infiltre les eaux grises traitées dans le sol pour irriguer le jardin.", + "notes_techniques": "Tranchée drainante 5-15 ml. Aval FPV uniquement. Interdit pour eaux noires. Irrigation passive jardin. Pose DIY possible. 80 EUR/ml mat.", + "accessible_fr": true + }, + { + "Id": 83, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:38+00:00", + "poste_id": 5, + "code": "eg_tlb", + "nom": "Toilette sèche à séparation (TLB)", + "description": "Élimine 100% EN, modèles Biolan/Cévennette/Lécopot/Bibok", + "prix_mat_min": 200, + "prix_mat_max": 1700, + "prix_pro_min": 500, + "prix_pro_max": 2800, + "unite": "forfait", + "quantite_default": 1, + "source": "biolan-france.com / cevennette.fr", + "source_date": "2026-04-15", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "eg_toilette_seche", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Toilette sèche à séparation qui supprime totalement les eaux noires et divise par deux les besoins eau.", + "notes_techniques": "TLB à séparation urine/fèces. Biolan, Cévennette, Lécopot, Bibok. Eliimne 100% EN. Réglementaire si évacuation propre déjections. Compostage requis.", + "accessible_fr": true + }, + { + "Id": 84, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:39+00:00", + "poste_id": 6, + "code": "pv_kit_3kwc_string", + "nom": "Kit PV 3 kWc string toiture sud", + "description": "6-8 panneaux 400-450 Wc + onduleur string + coffrets + fixations", + "prix_mat_min": 2400, + "prix_mat_max": 3600, + "prix_pro_min": 6000, + "prix_pro_max": 9000, + "unite": "forfait", + "quantite_default": 1, + "source": "photovoltaique.info ; Hespul ; ADEME 2024", + "source_date": "2025-09-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "pv_kit_string", + "parametre_dim": "{\"nom\": \"puissance_kwc\", \"min\": 3, \"max\": 9, \"step\": 1, \"default\": 6, \"unite\": \"kWc\", \"strategie\": \"par_surface_toit\", \"coef\": 0.15}", + "prix_par_unite": 800, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 3, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Kit panneaux solaires toiture avec onduleur string pour produire ton électricité en autoconsommation.", + "notes_techniques": "3-9 kWc, onduleur string 3-10 kW. Rendement 21%. Surface ~5 m²/kWc. Production 950-1400 kWh/kWc/an selon zone. Garantie 25 ans panneaux.", + "accessible_fr": true + }, + { + "Id": 85, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:39+00:00", + "poste_id": 6, + "code": "pv_kit_3kwc_micro", + "nom": "Kit PV 3 kWc micro-onduleurs", + "description": "Idem mais micro-onduleurs Enphase IQ8, monitoring panneau", + "prix_mat_min": 3300, + "prix_mat_max": 4500, + "prix_pro_min": 7500, + "prix_pro_max": 10500, + "unite": "forfait", + "quantite_default": 1, + "source": "Enphase France ; Solarcoop 2025", + "source_date": "2025-09-01", + "notes": "+25-30% vs string, recommandé si ombrage", + "filiere_agreee": false, + "actif": true, + "typologie": "pv_kit_micro_onduleurs", + "parametre_dim": "{\"nom\": \"puissance_kwc\", \"min\": 3, \"max\": 9, \"step\": 1, \"default\": 3, \"unite\": \"kWc\", \"strategie\": \"par_surface_toit\", \"coef\": 0.15}", + "prix_par_unite": 1000, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 3, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Kit PV avec micro-onduleurs par panneau, optimal si le toit a des zones d'ombrage partiel.", + "notes_techniques": "1 micro-onduleur Enphase IQ8 par panneau. Monitoring panneau par panneau. +25-30% coût vs string. Tolérance ombrage totale. Compatible orientations multiples.", + "accessible_fr": true + }, + { + "Id": 88, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:39+00:00", + "poste_id": 6, + "code": "pv_kit_beem_balcon", + "nom": "Kit Beem balcon plug & play 0.4-0.8 kWc", + "description": "Panneaux à poser, micro-onduleur intégré, prise classique", + "prix_mat_min": 600, + "prix_mat_max": 1500, + "prix_pro_min": 800, + "prix_pro_max": 1700, + "unite": "forfait", + "quantite_default": 1, + "source": "Beem Energy 2025", + "source_date": "2025-09-01", + "notes": "Auto-pose strict", + "filiere_agreee": false, + "actif": true, + "typologie": "pv_balcon_plug_play", + "parametre_dim": "{\"nom\": \"puissance_kwc\", \"min\": 0.4, \"max\": 0.8, \"step\": 0.2, \"default\": 0.4, \"unite\": \"kWc\", \"strategie\": \"fixe\"}", + "prix_par_unite": 1400, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Petits panneaux branchés en prise classique sur balcon pour réduire la facture sans travaux.", + "notes_techniques": "0.4-0.8 kWc, micro-onduleur intégré, prise standard. Auto-pose strict. Production 400-900 kWh/an. Rentabilité limitée. Ticket d'entrée bas.", + "accessible_fr": true + }, + { + "Id": 89, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:39+00:00", + "poste_id": 6, + "code": "pv_onduleur_hybride_5kw", + "nom": "Surcoût onduleur hybride 5 kW", + "description": "Huawei / Solax / Victron, injection batterie + secours", + "prix_mat_min": 1200, + "prix_mat_max": 2200, + "prix_pro_min": 1500, + "prix_pro_max": 2800, + "unite": "forfait", + "quantite_default": 1, + "source": "Solarcoop ; Victron 2025", + "source_date": "2025-09-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "pv_onduleur_hybride", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 3, + "temps_install_h": null, + "couplages_requis": "[\"pv_kit_string\"]", + "couplages_exclus": "[]", + "description_pedago": "Surcoût onduleur hybride qui prépare l'installation PV pour accueillir une batterie ultérieurement.", + "notes_techniques": "Huawei SUN2000 / Solax / Victron 5 kW. Pré-requis batterie LiFePO4 + ilotage. Compatibilité batterie à valider selon marque. Forfait 1200-2200 EUR.", + "accessible_fr": true + }, + { + "Id": 90, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:39+00:00", + "poste_id": 6, + "code": "pv_batterie_lifepo4_5", + "nom": "Batterie LiFePO4 5 kWh", + "description": "Pylontech US5000 / BYD HVS, 6000 cycles, 10 ans garantie", + "prix_mat_min": 1800, + "prix_mat_max": 2800, + "prix_pro_min": 2500, + "prix_pro_max": 3800, + "unite": "forfait", + "quantite_default": 1, + "source": "Pylontech FR ; Solarcoop 2025", + "source_date": "2025-09-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "pv_batterie_lifepo4", + "parametre_dim": "{\"nom\": \"capacite_kwh\", \"min\": 5, \"max\": 15, \"step\": 5, \"default\": 10, \"unite\": \"kWh\", \"strategie\": \"par_personne\", \"base\": 2, \"coef\": 2}", + "prix_par_unite": 470, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 2, + "temps_install_h": null, + "couplages_requis": "[\"pv_onduleur_hybride\"]", + "couplages_exclus": "[\"pv_batterie_plomb\"]", + "description_pedago": "Batterie lithium-fer-phosphate qui stocke le surplus PV pour l'utiliser le soir et les nuits sans soleil.", + "notes_techniques": "Pylontech US5000 / BYD HVS. 6000 cycles, garantie 10 ans. 5-15 kWh. 470 EUR/kWh. Durée vie 12-15 ans vs 4-7 ans plomb. Nécessite onduleur hybride.", + "accessible_fr": true + }, + { + "Id": 93, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:39+00:00", + "poste_id": 6, + "code": "pv_batterie_plomb_5", + "nom": "Batterie plomb AGM/GEL 5 kWh utile", + "description": "8x batteries 12V 200 Ah série/parallèle", + "prix_mat_min": 1500, + "prix_mat_max": 2500, + "prix_pro_min": 2200, + "prix_pro_max": 3200, + "unite": "forfait", + "quantite_default": 1, + "source": "Victron ; Hellroad 2025", + "source_date": "2025-09-01", + "notes": "Déconseillé sauf budget serré", + "filiere_agreee": false, + "actif": true, + "typologie": "pv_batterie_plomb", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 2, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[\"pv_batterie_lifepo4\"]", + "description_pedago": "Batteries plomb AGM/GEL, solution d'entrée de gamme pour stocker l'énergie solaire à petit budget.", + "notes_techniques": "8x batteries 12V 200 Ah. Rendement 80% vs 95% LiFePO4. Durée 4-7 ans. Déconseillé sauf budget serré. Entretien annuel. Remplacement précoce.", + "accessible_fr": true + }, + { + "Id": 94, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:40+00:00", + "poste_id": 6, + "code": "pv_monitoring", + "nom": "Monitoring (compteur prod + appli)", + "description": "Solaredge / Enphase Envoy / Shelly EM + Home Assistant", + "prix_mat_min": 200, + "prix_mat_max": 600, + "prix_pro_min": 350, + "prix_pro_max": 850, + "unite": "forfait", + "quantite_default": 1, + "source": "Solaredge ; Enphase ; Shelly 2025", + "source_date": "2025-09-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "pv_monitoring", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Boîtier de suivi production-consommation pour optimiser l'autoconsommation solaire au quotidien.", + "notes_techniques": "Solaredge / Enphase Envoy / Shelly EM + Home Assistant. Inclus avec micro-onduleurs. Pilotage 1-2 charges (CE, ballon ECS). 200-600 EUR mat.", + "accessible_fr": true + }, + { + "Id": 95, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:40+00:00", + "poste_id": 7, + "code": "th_cesi_4m2", + "nom": "CESI capteurs plans 4 m² + ballon 300 L", + "description": "Couverture 50-70% ECS pour 3-4 pers", + "prix_mat_min": 2500, + "prix_mat_max": 4000, + "prix_pro_min": 5500, + "prix_pro_max": 8500, + "unite": "forfait", + "quantite_default": 1, + "source": "ADEME guide CESI 2024 ; Tecsol", + "source_date": "2024-12-01", + "notes": "Éligible MaPrimeRénov'", + "filiere_agreee": false, + "actif": true, + "typologie": "stherm_cesi_plans", + "parametre_dim": "{\"nom\": \"surface_m2\", \"min\": 4, \"max\": 6, \"step\": 1, \"default\": 4, \"unite\": \"m\\u00b2\", \"strategie\": \"par_personne\", \"base\": 2, \"coef\": 0.5}", + "prix_par_unite": 625, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 3, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Chauffe-eau solaire à capteurs plans qui couvre 50 à 75% des besoins en eau chaude de la maison.", + "notes_techniques": "4-6 m², ballon 300-400 L, couverture 50-75% ECS. Eligible MaPrimeRénov' ~2500 EUR. ROI 12-15 ans sans aide. Surface toit dégagée Sud requis.", + "accessible_fr": true + }, + { + "Id": 97, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:40+00:00", + "poste_id": 7, + "code": "th_cesi_sousvide_4", + "nom": "CESI capteurs sous vide 4 m² + ballon 300 L", + "description": "Capteurs tubes, meilleur rendement froid/diffus", + "prix_mat_min": 3200, + "prix_mat_max": 5000, + "prix_pro_min": 6500, + "prix_pro_max": 10000, + "unite": "forfait", + "quantite_default": 1, + "source": "Viessmann ; Wagner 2024", + "source_date": "2024-09-01", + "notes": "Pertinent climat continental", + "filiere_agreee": false, + "actif": true, + "typologie": "stherm_cesi_sous_vide", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 3, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Chauffe-eau solaire à tubes sous vide, plus performant que les capteurs plans par temps froid.", + "notes_techniques": "4 m², ballon 300 L. +25% rendement vs capteurs plans. Pertinent en H1 continental. Fragile aux chocs (grêle). Rendement hivernal supérieur.", + "accessible_fr": true + }, + { + "Id": 98, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:40+00:00", + "poste_id": 7, + "code": "th_ssc_10m2", + "nom": "SSC plans 10 m² + ballon tampon 800 L", + "description": "ECS + appoint chauffage basse temp", + "prix_mat_min": 6500, + "prix_mat_max": 10000, + "prix_pro_min": 13000, + "prix_pro_max": 19000, + "unite": "forfait", + "quantite_default": 1, + "source": "Tecsol ; ADEME SSC 2024", + "source_date": "2024-12-01", + "notes": "Pertinent avec émetteurs basse temp", + "filiere_agreee": false, + "actif": true, + "typologie": "stherm_ssc", + "parametre_dim": "{\"nom\": \"surface_m2\", \"min\": 10, \"max\": 15, \"step\": 1, \"default\": 10, \"unite\": \"m\\u00b2\", \"strategie\": \"par_surface_toit\", \"coef\": 0.12}", + "prix_par_unite": 650, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 3, + "temps_install_h": null, + "couplages_requis": "[\"emetteur_pcbt\", \"stherm_ballon_tampon\"]", + "couplages_exclus": "[]", + "description_pedago": "Système Solaire Combiné qui assure eau chaude et appoint de chauffage depuis les panneaux du toit.", + "notes_techniques": "10-15 m² + ballon tampon 800-1500 L. Couverture chauffage 25-50% selon zone H1-H3. Exige émetteurs basse temp (PCBT). Surface Sud 30-50°.", + "accessible_fr": true + }, + { + "Id": 100, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:40+00:00", + "poste_id": 7, + "code": "th_ballon_500", + "nom": "Ballon tampon seul 500 L (multi-énergie)", + "description": "Inox/émail avec serpentin solaire + bois", + "prix_mat_min": 1000, + "prix_mat_max": 1800, + "prix_pro_min": 1800, + "prix_pro_max": 2800, + "unite": "forfait", + "quantite_default": 1, + "source": "Atlantic ; ACV ; OkoFen 2025", + "source_date": "2025-03-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "stherm_ballon_tampon", + "parametre_dim": "{\"nom\": \"volume_l\", \"min\": 500, \"max\": 1000, \"step\": 100, \"default\": 500, \"unite\": \"L\", \"strategie\": \"fixe\"}", + "prix_par_unite": 1.2, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 2, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Ballon tampon multi-énergie qui stocke la chaleur du poêle hydro et du solaire thermique.", + "notes_techniques": "Inox/email, 500-1000 L, serpentin solaire + bois. 1.2 EUR/L mat. Volume cible 25 L/kW puissance poêle. Raccordement plombier spécialisé.", + "accessible_fr": true + }, + { + "Id": 102, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:40+00:00", + "poste_id": 8, + "code": "bois_poele_simple", + "nom": "Poêle bûches simple 7-9 kW", + "description": "Poêle acier (Invicta/Supra/Godin), rendement 75-80%", + "prix_mat_min": 700, + "prix_mat_max": 1500, + "prix_pro_min": 1800, + "prix_pro_max": 3500, + "unite": "forfait", + "quantite_default": 1, + "source": "Invicta ; Supra 2025 ; ADEME", + "source_date": "2025-02-01", + "notes": "Flamme verte 6-7 étoiles", + "filiere_agreee": false, + "actif": true, + "typologie": "bois_poele_simple", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 2, + "temps_install_h": null, + "couplages_requis": "[\"bois_conduit_fumee\"]", + "couplages_exclus": "[]", + "description_pedago": "Poêle à bûches basique pour chauffer la pièce principale sans circuit hydraulique.", + "notes_techniques": "7-9 kW, rendement 75-80%, Flamme Verte 6-7 étoiles. Marques : Invicta, Supra, Godin. Hors conduit fumée. Usage pièce principale uniquement.", + "accessible_fr": true + }, + { + "Id": 103, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:40+00:00", + "poste_id": 8, + "code": "bois_poele_double", + "nom": "Poêle bûches double combustion 8-10 kW", + "description": "Stuv/Brisach/Jotul, rendement 78-85%, 7 étoiles", + "prix_mat_min": 2000, + "prix_mat_max": 4500, + "prix_pro_min": 4500, + "prix_pro_max": 8500, + "unite": "forfait", + "quantite_default": 1, + "source": "Stuv ; Brisach ; Jotul 2025", + "source_date": "2025-02-01", + "notes": "Standard MI moderne", + "filiere_agreee": false, + "actif": true, + "typologie": "bois_poele_double_comb", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 2, + "temps_install_h": null, + "couplages_requis": "[\"bois_conduit_fumee\"]", + "couplages_exclus": "[]", + "description_pedago": "Poêle bûches double combustion Flamme Verte 7 étoiles, référence pour une maison individuelle moderne.", + "notes_techniques": "8-10 kW, rendement 78-85%, Flamme Verte 7 étoiles. Stuv, Brisach, Jotul. Eligible MaPrimeRénov'. Hors conduit. Standard MI contemporaine.", + "accessible_fr": true + }, + { + "Id": 104, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:41+00:00", + "poste_id": 8, + "code": "bois_poele_hydro", + "nom": "Poêle bûches hydro 12-18 kW", + "description": "Edilkamin/Hydro-Stuv/La Nordica, raccordé ballon, 80-87%", + "prix_mat_min": 3500, + "prix_mat_max": 7500, + "prix_pro_min": 8000, + "prix_pro_max": 14000, + "unite": "forfait", + "quantite_default": 1, + "source": "Edilkamin ; La Nordica 2025", + "source_date": "2025-03-01", + "notes": "Nécessite ballon tampon", + "filiere_agreee": false, + "actif": true, + "typologie": "bois_poele_hydro", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 3, + "temps_install_h": null, + "couplages_requis": "[\"bois_conduit_fumee\", \"stherm_ballon_tampon\"]", + "couplages_exclus": "[]", + "description_pedago": "Poêle bûches raccordé au circuit hydraulique qui chauffe toute la maison et l'eau chaude sanitaire.", + "notes_techniques": "12-18 kW, rendement 80-87%. Edilkamin Berga / Hydro-Stuv / La Nordica. Nécessite ballon tampon 500-1000 L + circuit hydraulique.", + "accessible_fr": true + }, + { + "Id": 105, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:44:49+00:00", + "poste_id": 8, + "code": "bois_poele_masse_indus", + "nom": "Poele de masse industriel (Tulikivi / Hiemstra)", + "description": "Tulikivi/Hiemstra, 1-2 flambées/jour, rendement 80-85%", + "prix_mat_min": 8000, + "prix_mat_max": 16000, + "prix_pro_min": 12000, + "prix_pro_max": 24000, + "unite": "forfait", + "quantite_default": 1, + "source": "Tulikivi France 2025 ; Brunner", + "source_date": "2025-03-01", + "notes": "Dalle porteuse requise", + "filiere_agreee": false, + "actif": true, + "typologie": "poele_masse_industriel", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": 12000, + "flag_diy": false, + "complexite_pose": 3, + "temps_install_h": null, + "couplages_requis": "[\"bois_conduit_fumee\"]", + "couplages_exclus": "[]", + "description_pedago": "Poele de masse en steatite qui accumule 1 a 2 flambees et restitue de la chaleur douce pendant 24h.", + "notes_techniques": "Tulikivi / Hiemstra / Brunner. 8000-16000 EUR mat. 800-2000 kg. Dalle porteuse obligatoire. Rendement 80-85%. 1-2 flambees/jour, restitution 12-24h.", + "accessible_fr": true + }, + { + "Id": 106, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:44:49+00:00", + "poste_id": 8, + "code": "bois_poele_masse_diy", + "nom": "Poele de masse auto-construit (kit foyer + adobe)", + "description": "Kit foyer Brunner/Lehmhus + plans béton réfractaire", + "prix_mat_min": 2500, + "prix_mat_max": 5500, + "prix_pro_min": null, + "prix_pro_max": null, + "unite": "forfait", + "quantite_default": 1, + "source": "Habitat Vivant 2024 ; Brunner", + "source_date": "2024-11-01", + "notes": "Auto-pose stricte, 200-300h chantier", + "filiere_agreee": false, + "actif": true, + "typologie": "poele_masse_diy", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": 4000, + "flag_diy": true, + "complexite_pose": 3, + "temps_install_h": 250, + "couplages_requis": "[\"bois_conduit_fumee\"]", + "couplages_exclus": "[]", + "description_pedago": "Poele de masse auto-construit en beton refractaire ou adobe, pour les batisseurs experimentes passionnes.", + "notes_techniques": "Kit foyer Brunner/Lehmhus + plans. 2500-5500 EUR mat. 200-300h chantier. Stage formation ~1500 EUR conseille. Hors-cadre assureur sans DTU. flag_diy=true.", + "accessible_fr": true + }, + { + "Id": 107, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:41+00:00", + "poste_id": 8, + "code": "bois_rocket_stove", + "nom": "Rocket stove mass heater (DIY)", + "description": "Foyer J-tube + masse adobe/cob, design Wisner", + "prix_mat_min": 400, + "prix_mat_max": 1500, + "prix_pro_min": null, + "prix_pro_max": null, + "unite": "forfait", + "quantite_default": 1, + "source": "Erica Wisner 2014 ; permies.com", + "source_date": "2024-11-01", + "notes": "Pas certifié, hors assurance", + "filiere_agreee": false, + "actif": true, + "typologie": "bois_rocket_stove", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 2, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Foyer rocket stove auto-construit en cob ou adobe, pour chauffer une cabane ou une dépendance.", + "notes_techniques": "Foyer J-tube, masse thermique adobe/cob. 400-1500 EUR mat. Pas certifié, hors assurance. Pour dépendances uniquement, pas résidence principale. Permies.com réf.", + "accessible_fr": true + }, + { + "Id": 108, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:41+00:00", + "poste_id": 8, + "code": "bois_poele_granules", + "nom": "Poêle à granulés 8-10 kW", + "description": "OkoFen/Edilkamin/MCZ, autonomie 24-72h, 87-92%", + "prix_mat_min": 2500, + "prix_mat_max": 4500, + "prix_pro_min": 4500, + "prix_pro_max": 7500, + "unite": "forfait", + "quantite_default": 1, + "source": "OkoFen ; MCZ 2025 ; ADEME", + "source_date": "2025-02-01", + "notes": "Flamme Verte 7 étoiles", + "filiere_agreee": false, + "actif": true, + "typologie": "bois_poele_granules", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 2, + "temps_install_h": null, + "couplages_requis": "[\"bois_conduit_fumee\"]", + "couplages_exclus": "[]", + "description_pedago": "Poêle à granulés automatique qui s'allume seul et tient 24 à 72h avec un plein de pellets.", + "notes_techniques": "8-10 kW, rendement 87-92%, Flamme Verte 7 étoiles. OkoFen, MCZ, Edilkamin. Dépend électricité (vis sans fin). Granulés ENplus A1 obligatoire.", + "accessible_fr": true + }, + { + "Id": 109, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:41+00:00", + "poste_id": 8, + "code": "bois_chaudiere_granules", + "nom": "Chaudière granulés 15-25 kW + silo 4-6 t", + "description": "OkoFen Pellematic/Hargassner, 92-95%, ballon 800 L", + "prix_mat_min": 12000, + "prix_mat_max": 20000, + "prix_pro_min": 18000, + "prix_pro_max": 30000, + "unite": "forfait", + "quantite_default": 1, + "source": "OkoFen ; Hargassner 2025", + "source_date": "2025-02-01", + "notes": "MaPrimeRénov' jusqu'à 11k EUR", + "filiere_agreee": false, + "actif": true, + "typologie": "bois_chaudiere_granules", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 3, + "temps_install_h": null, + "couplages_requis": "[\"bois_conduit_fumee\", \"stherm_ballon_tampon\"]", + "couplages_exclus": "[]", + "description_pedago": "Chaudière granulés avec silo intégré pour chauffer toute la maison en autonomie plusieurs semaines.", + "notes_techniques": "15-25 kW, rendement 92-95%. OkoFen Pellematic / Hargassner. Silo 4-6 t. MaPrimeRénov' jusqu'à 11000 EUR. Cible MI 120-180 m² mal isolée.", + "accessible_fr": true + }, + { + "Id": 110, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:41+00:00", + "poste_id": 8, + "code": "bois_conduit_fumee", + "nom": "Conduit fumée inox isolé double paroi", + "description": "DN150-180, 5-7 m, sortie toiture DTU 24.1", + "prix_mat_min": 800, + "prix_mat_max": 1800, + "prix_pro_min": 1500, + "prix_pro_max": 3500, + "unite": "forfait", + "quantite_default": 1, + "source": "Poujoulat ; Tubest 2025", + "source_date": "2025-03-01", + "notes": "Toujours additionnel", + "filiere_agreee": false, + "actif": true, + "typologie": "bois_conduit_fumee", + "parametre_dim": "{\"nom\": \"longueur_ml\", \"min\": 4, \"max\": 10, \"step\": 1, \"default\": 6, \"unite\": \"ml\", \"strategie\": \"fixe\"}", + "prix_par_unite": 230, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 2, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Conduit inox double paroi isolé pour évacuer les fumées en toute sécurité de tout appareil bois.", + "notes_techniques": "DN150-180, 5-7 ml, sortie toiture DTU 24.1. Poujoulat / Tubest. Tubage conduit existant 800-1500 EUR. Toujours additionnel. Ramonage 2x/an.", + "accessible_fr": true + }, + { + "Id": 111, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:41+00:00", + "poste_id": 9, + "code": "emet_radiateur_acier", + "nom": "Radiateur acier panneau (basse temp)", + "description": "Stelrad/Acova type 22, 1500-2000 W, 60°C", + "prix_mat_min": 80, + "prix_mat_max": 250, + "prix_pro_min": 200, + "prix_pro_max": 500, + "unite": "forfait", + "quantite_default": 1, + "source": "Stelrad ; Acova 2025", + "source_date": "2025-02-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "emetteur_radiateur_acier", + "parametre_dim": "{\"nom\": \"nb_radiateurs\", \"min\": 1, \"max\": 15, \"step\": 1, \"default\": 6, \"unite\": \"unit\\u00e9s\", \"strategie\": \"par_personne\", \"base\": 1, \"coef\": 1}", + "prix_par_unite": 165, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 2, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Radiateur acier panneau compatible basse température, idéal pour pompe à chaleur ou poêle hydro.", + "notes_techniques": "Stelrad / Acova type 22. 1500-2000 W. 60°C max. 165 EUR/unité mat. 1 radiateur pour 15-20 m². Compatibilité basse temp recommandée.", + "accessible_fr": true + }, + { + "Id": 112, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:41+00:00", + "poste_id": 9, + "code": "emet_radiateur_fonte", + "nom": "Radiateur fonte (rénovation patrimoine)", + "description": "Fonte aluminium ou vraie, 1000-1500 W, inertie élevée", + "prix_mat_min": 250, + "prix_mat_max": 700, + "prix_pro_min": 400, + "prix_pro_max": 950, + "unite": "forfait", + "quantite_default": 1, + "source": "Acova ; Roca 2025", + "source_date": "2025-02-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "emetteur_radiateur_fonte", + "parametre_dim": "{\"nom\": \"nb_radiateurs\", \"min\": 1, \"max\": 12, \"step\": 1, \"default\": 6, \"unite\": \"unit\\u00e9s\", \"strategie\": \"par_personne\", \"base\": 1, \"coef\": 1}", + "prix_par_unite": 475, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 2, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Radiateur fonte à forte inertie pour rénovation patrimoniale avec chauffage basse température.", + "notes_techniques": "Fonte aluminium ou vraie. 1000-1500 W. Inertie forte. Favorable poêle hydro + bas régime. Récupération possible. 475 EUR/unité mat.", + "accessible_fr": true + }, + { + "Id": 113, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:42+00:00", + "poste_id": 9, + "code": "emet_pcbt", + "nom": "Plancher chauffant basse temp (PCBT)", + "description": "Tubes PER 16/20 + chape liquide 5 cm + collecteurs + isolant", + "prix_mat_min": 35, + "prix_mat_max": 60, + "prix_pro_min": 75, + "prix_pro_max": 130, + "unite": "m2", + "quantite_default": 1, + "source": "Rehau ; Uponor 2025 ; ADEME", + "source_date": "2024-12-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "emetteur_pcbt", + "parametre_dim": "{\"nom\": \"surface_m2\", \"min\": 20, \"max\": 200, \"step\": 10, \"default\": 80, \"unite\": \"m\\u00b2\", \"strategie\": \"par_surface_parcelle\", \"coef\": 0.5, \"reserve_construit\": 100}", + "prix_par_unite": 47, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 3, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Plancher chauffant eau chaude dans la chape, confort optimal par rayonnement basse température.", + "notes_techniques": "Tubes PER 16/20 + chape liquide 5 cm + collecteurs + isolant TMS. 35-60 EUR/m². Exige -7 cm HSP. ROI long via PAC/SSC. Rehau / Uponor.", + "accessible_fr": true + }, + { + "Id": 114, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:42+00:00", + "poste_id": 9, + "code": "emet_mur_chauffant", + "nom": "Mur chauffant eau chaude", + "description": "Tubes PER 12/14 sur ossature + finition argile/plâtre", + "prix_mat_min": 60, + "prix_mat_max": 110, + "prix_pro_min": 130, + "prix_pro_max": 220, + "unite": "m2", + "quantite_default": 1, + "source": "Rotex ; Sempatap 2024", + "source_date": "2024-09-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "emetteur_mur_chauffant", + "parametre_dim": "{\"nom\": \"surface_m2\", \"min\": 10, \"max\": 100, \"step\": 5, \"default\": 30, \"unite\": \"m\\u00b2\", \"strategie\": \"fixe\"}", + "prix_par_unite": 85, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 2, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Mur chauffant eau chaude encastré dans l'enduit, qualité d'air et inertie thermique améliorées.", + "notes_techniques": "Tubes PER 12/14 sur ossature, finition argile/plâtre. 60-110 EUR/m². Inertie + qualité air. Auto-constructeur exigeant. Rotex / Sempatap réf.", + "accessible_fr": true + }, + { + "Id": 115, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:42+00:00", + "poste_id": 9, + "code": "emet_seche_serviette", + "nom": "Sèche-serviette eau chaude SDB", + "description": "Acova Atoll/Cala, 500-1000 W", + "prix_mat_min": 200, + "prix_mat_max": 600, + "prix_pro_min": 400, + "prix_pro_max": 950, + "unite": "forfait", + "quantite_default": 1, + "source": "Acova 2025", + "source_date": "2025-02-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "emetteur_seche_serviette", + "parametre_dim": "{\"nom\": \"nb_sdb\", \"min\": 1, \"max\": 4, \"step\": 1, \"default\": 1, \"unite\": \"unit\\u00e9s\", \"strategie\": \"fixe\"}", + "prix_par_unite": 400, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 2, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Sèche-serviette raccordé au circuit chauffage pour chauffer confortablement la salle de bain.", + "notes_techniques": "Acova Atoll / Cala. 500-1000 W. 400 EUR/unité mat. À prévoir 1 par SDB. Raccordement plomberie chauffage central.", + "accessible_fr": true + }, + { + "Id": 116, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:42+00:00", + "poste_id": 9, + "code": "emet_vannes_thermo", + "nom": "Vannes thermostatiques connectées", + "description": "Tado/Netatmo/Heatzy, pilotage pièce par pièce", + "prix_mat_min": 60, + "prix_mat_max": 120, + "prix_pro_min": 90, + "prix_pro_max": 180, + "unite": "forfait", + "quantite_default": 1, + "source": "Tado ; Heatzy 2025", + "source_date": "2025-03-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "emetteur_vannes_thermostatiques", + "parametre_dim": "{\"nom\": \"nb_vannes\", \"min\": 1, \"max\": 15, \"step\": 1, \"default\": 6, \"unite\": \"unit\\u00e9s\", \"strategie\": \"par_personne\", \"base\": 1, \"coef\": 1}", + "prix_par_unite": 90, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Vannes thermostatiques connectées pour réguler la chaleur pièce par pièce et économiser 15% de chauffage.", + "notes_techniques": "Tado / Netatmo / Heatzy. 60-120 EUR/unité. Pilotage pièce par pièce. Économies 10-20% sur chauffage. Compatible tout radiateur.", + "accessible_fr": true + }, + { + "Id": 117, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:42+00:00", + "poste_id": 10, + "code": "pil_monitoring_comwatt", + "nom": "Monitoring autoconso simple (Comwatt Power)", + "description": "Boîtier + appli, prod + conso, pilote 1-2 charges", + "prix_mat_min": 300, + "prix_mat_max": 700, + "prix_pro_min": 500, + "prix_pro_max": 1100, + "unite": "forfait", + "quantite_default": 1, + "source": "Comwatt 2025", + "source_date": "2025-03-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "pilotage_monitoring_simple", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Boîtier de monitoring qui visualise production et consommation solaire en temps réel pour optimiser.", + "notes_techniques": "Comwatt Power. Mesure prod + conso. Pilote 1-2 charges (CE, voiture). Sans batterie. 300-700 EUR. ROI via autoconso optimisée.", + "accessible_fr": true + }, + { + "Id": 118, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:42+00:00", + "poste_id": 10, + "code": "pil_routeur_ecs", + "nom": "Monitoring + routeur ECS (MyLight/Beem)", + "description": "Routeur surplus PV vers chauffe-eau", + "prix_mat_min": 350, + "prix_mat_max": 800, + "prix_pro_min": 600, + "prix_pro_max": 1200, + "unite": "forfait", + "quantite_default": 1, + "source": "MyLight Systems ; Beem 2025", + "source_date": "2025-03-01", + "notes": "ROI 3-5 ans", + "filiere_agreee": false, + "actif": true, + "typologie": "pilotage_routeur_ecs", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Routeur de surplus PV qui redirige l'excédent solaire vers le ballon d'eau chaude gratuitement.", + "notes_techniques": "MyLight Systems / Beem Box. Économie 200-400 EUR/an. ROI 3-5 ans. Compatibilité onduleur à valider. 350-800 EUR mat.", + "accessible_fr": true + }, + { + "Id": 119, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:43+00:00", + "poste_id": 10, + "code": "pil_onduleur_hybride", + "nom": "Onduleur hybride 5-6 kW (sans ilotage)", + "description": "Huawei SUN2000/Solax X1, batterie haute tension", + "prix_mat_min": 1500, + "prix_mat_max": 2800, + "prix_pro_min": 2200, + "prix_pro_max": 3800, + "unite": "forfait", + "quantite_default": 1, + "source": "Huawei ; Solax ; Solarcoop 2025", + "source_date": "2025-03-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "pilotage_onduleur_hybride_base", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 3, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Onduleur hybride qui gère l'autoconsommation et prépare le raccordement d'une batterie future.", + "notes_techniques": "Huawei SUN2000 / Solax X1. 5-6 kW. Batterie haute tension compatible. 1500-2800 EUR mat. Compatibilité batterie souvent verrouillée par marque.", + "accessible_fr": true + }, + { + "Id": 120, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:43+00:00", + "poste_id": 10, + "code": "pil_onduleur_backup", + "nom": "Onduleur hybride 5 kW + backup (ilotage partiel)", + "description": "Victron MultiPlus II / Solax X1-Hybrid avec EPS", + "prix_mat_min": 2500, + "prix_mat_max": 4500, + "prix_pro_min": 3500, + "prix_pro_max": 6500, + "unite": "forfait", + "quantite_default": 1, + "source": "Victron ; Solax 2025", + "source_date": "2025-03-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "pilotage_backup_partiel", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 3, + "temps_install_h": null, + "couplages_requis": "[\"pv_batterie_lifepo4\"]", + "couplages_exclus": "[]", + "description_pedago": "Onduleur hybride avec backup partiel qui maintient frigo et éclairage en cas de coupure de réseau.", + "notes_techniques": "Victron MultiPlus II / Solax X1-Hybrid EPS. Quelques circuits prioritaires (frigo, éclairage, box). Nécessite batterie LiFePO4. Pas toute la maison.", + "accessible_fr": true + }, + { + "Id": 121, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:43+00:00", + "poste_id": 10, + "code": "pil_ilotage_complet", + "nom": "Système ilotage complet (off-grid ready)", + "description": "Victron Quattro 8 kVA + Cerbo GX + ATS", + "prix_mat_min": 4500, + "prix_mat_max": 8500, + "prix_pro_min": 7000, + "prix_pro_max": 13000, + "unite": "forfait", + "quantite_default": 1, + "source": "Victron 2025 ; Solarcoop", + "source_date": "2025-03-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "pilotage_ilotage_complet", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 3, + "temps_install_h": null, + "couplages_requis": "[\"pv_batterie_lifepo4\"]", + "couplages_exclus": "[]", + "description_pedago": "Système ilotage complet Victron qui fait fonctionner toute la maison hors réseau plusieurs jours.", + "notes_techniques": "Victron Quattro 8 kVA + Cerbo GX + tableau secours + ATS. Off-grid ready. 4500-8500 EUR mat. Etude charge critique requise. Nécessite batterie 10+ kWh.", + "accessible_fr": true + }, + { + "Id": 122, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:43+00:00", + "poste_id": 10, + "code": "pil_home_assistant", + "nom": "Home Assistant (auto-construction)", + "description": "NUC/Pi 5 + HA Yellow/Green + capteurs Shelly", + "prix_mat_min": 200, + "prix_mat_max": 600, + "prix_pro_min": null, + "prix_pro_max": null, + "unite": "forfait", + "quantite_default": 1, + "source": "Home Assistant ; Shelly 2025", + "source_date": "2025-03-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "pilotage_home_assistant", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Centrale domotique open-source qui pilote automatiquement tout le système énergie et eau de la maison.", + "notes_techniques": "NUC / Raspberry Pi 5 + Home Assistant Yellow/Green + capteurs Shelly. 200-600 EUR. 20-40h paramétrage. Intègre PV + bois + thermostats. DIY expert.", + "accessible_fr": true + }, + { + "Id": 123, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:43+00:00", + "poste_id": 10, + "code": "pil_delesteur", + "nom": "Délesteur électrique", + "description": "Hager/Schneider, gère pic conso et coupe charges", + "prix_mat_min": 250, + "prix_mat_max": 500, + "prix_pro_min": 450, + "prix_pro_max": 850, + "unite": "forfait", + "quantite_default": 1, + "source": "Hager ; Schneider 2025", + "source_date": "2025-03-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "pilotage_delesteur", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 2, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Délesteur électrique qui coupe automatiquement les charges non prioritaires pour éviter les dépassements.", + "notes_techniques": "Hager / Schneider. Gère pic conso. 250-500 EUR mat. Pertinent abonnement 6-9 kVA avec PAC ou voiture élec. Pose électricien.", + "accessible_fr": true + }, + { + "Id": 124, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:43+00:00", + "poste_id": 11, + "code": "etude_diag_diy", + "nom": "Diagnostic sol DIY", + "description": "Test bocal sédimentation + pH bandelettes + bêche test", + "prix_mat_min": 30, + "prix_mat_max": 80, + "prix_pro_min": null, + "prix_pro_max": null, + "unite": "forfait", + "quantite_default": 1, + "source": "LPO Jardin nature 2024", + "source_date": "2024-06-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "etude_sol_diy", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Test de sol rapide à faire soi-même pour connaitre texture et pH avant de planter le jardin.", + "notes_techniques": "Test bocal sédimentation + pH bandelettes + bêche test. 30-80 EUR mat. 1/2 journée. Précision limitée vs labo. LPO Jardin nature réf.", + "accessible_fr": true + }, + { + "Id": 125, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:43+00:00", + "poste_id": 11, + "code": "etude_analyse_labo", + "nom": "Analyse labo SOL pédologique", + "description": "Prélèvements + N/P/K + texture + matière org.", + "prix_mat_min": 80, + "prix_mat_max": 150, + "prix_pro_min": 250, + "prix_pro_max": 450, + "unite": "forfait", + "quantite_default": 1, + "source": "Auréa, Sadef labos 2026", + "source_date": "2026-01-01", + "notes": "Recommandé > 500 m² productifs", + "filiere_agreee": false, + "actif": true, + "typologie": "etude_sol_labo", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Analyse de sol en laboratoire pour connaitre précisément azote, phosphore, potassium et structure.", + "notes_techniques": "N/P/K + texture + matière organique. Labos : Auréa, Sadef. 80-150 EUR. Recommandé > 500 m² productifs. Résultats en 2-3 semaines.", + "accessible_fr": true + }, + { + "Id": 126, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:44+00:00", + "poste_id": 11, + "code": "etude_topo_ensoleil", + "nom": "Topo + ensoleillement", + "description": "Relevé pentes, exposition, ombres (drone ou manuel)", + "prix_mat_min": 0, + "prix_mat_max": 50, + "prix_pro_min": 600, + "prix_pro_max": 1500, + "unite": "forfait", + "quantite_default": 1, + "source": "Géomètre / paysagiste 2026", + "source_date": "2026-02-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "etude_topo", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Relevé de terrain qui identifie pentes, exposition et ombres pour optimiser l'implantation des cultures.", + "notes_techniques": "Pentes + exposition + ombres portées (drone ou manuel). DIY possible. Géomètre 600-1500 EUR pro. Souvent inclus dans mission archi.", + "accessible_fr": true + }, + { + "Id": 127, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:44+00:00", + "poste_id": 11, + "code": "etude_plan_paysager", + "nom": "Plan paysager productif", + "description": "Conception forêt-jardin / zonage permaculture", + "prix_mat_min": 0, + "prix_mat_max": 200, + "prix_pro_min": 1500, + "prix_pro_max": 4500, + "unite": "forfait", + "quantite_default": 1, + "source": "Paysagiste perma 2026", + "source_date": "2026-03-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "etude_plan_paysager", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Plan de forêt-jardin ou de permaculture qui organise toutes les strates productives de la parcelle.", + "notes_techniques": "Conception forêt-jardin / zonage permaculture. DIY possible. Paysagiste perma 1500-4500 EUR. Forfait variable selon surface et complexité.", + "accessible_fr": true + }, + { + "Id": 128, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:44+00:00", + "poste_id": 11, + "code": "etude_eau_hydraulique", + "nom": "Étude eau / hydraulique parcelle", + "description": "Implantation noues, mares, swales, écoulement", + "prix_mat_min": 0, + "prix_mat_max": 100, + "prix_pro_min": 800, + "prix_pro_max": 2500, + "unite": "forfait", + "quantite_default": 1, + "source": "Permaculture Design 2025", + "source_date": "2025-09-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "etude_hydraulique", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Etude des écoulements parcellaires pour placer noues, mares et swales aux bons endroits.", + "notes_techniques": "Implantation noues, mares, swales, sens d'écoulement. DIY si expérimenté. Bureau permaculture 800-2500 EUR. Souvent couplé étude eau pluviale.", + "accessible_fr": true + }, + { + "Id": 129, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:44+00:00", + "poste_id": 12, + "code": "haute_scion_racines_nues", + "nom": "Fruitier scion racines nues", + "description": "Pommier/poirier/prunier 1-2 ans, à former", + "prix_mat_min": 12, + "prix_mat_max": 25, + "prix_pro_min": 35, + "prix_pro_max": 60, + "unite": "plant", + "quantite_default": 1, + "source": "Pépinière Lemonnier 2026", + "source_date": "2026-02-01", + "notes": "Reprise excellente", + "filiere_agreee": false, + "actif": true, + "typologie": "arbre_scion_racines_nues", + "parametre_dim": "{\"nom\": \"nb_plants\", \"min\": 1, \"max\": 30, \"step\": 1, \"default\": 4, \"unite\": \"plants\", \"strategie\": \"par_personne\", \"base\": 1, \"coef\": 1}", + "prix_par_unite": 18.5, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Jeune fruitier en racines nues à former soi-même, la solution la moins chère pour démarrer un verger.", + "notes_techniques": "Pommier / poirier / prunier 1-2 ans. 12-25 EUR/plant. Reprise excellente (automne-hiver). Formation 3-5 ans. Pépinières : Lemonnier, Naudet.", + "accessible_fr": true + }, + { + "Id": 130, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:44+00:00", + "poste_id": 12, + "code": "haute_demi_tige", + "nom": "Fruitier demi-tige formé", + "description": "Tronc 1m, premières charpentières formées", + "prix_mat_min": 35, + "prix_mat_max": 70, + "prix_pro_min": 80, + "prix_pro_max": 150, + "unite": "plant", + "quantite_default": 1, + "source": "Pépinières Naudet 2026", + "source_date": "2026-02-01", + "notes": "Compromis prix/maturité", + "filiere_agreee": false, + "actif": true, + "typologie": "arbre_demi_tige", + "parametre_dim": "{\"nom\": \"nb_plants\", \"min\": 1, \"max\": 20, \"step\": 1, \"default\": 6, \"unite\": \"plants\", \"strategie\": \"par_personne\", \"base\": 1, \"coef\": 1}", + "prix_par_unite": 52.5, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Fruitier demi-tige déjà formé, compromis idéal entre prix accessible et entrée en production rapide.", + "notes_techniques": "Tronc 1 m, premières charpentières formées. 35-70 EUR/plant. Maturité 3-5 ans. Pour verger diversifié. Espacement 4x4 m. Pépinières Naudet.", + "accessible_fr": true + }, + { + "Id": 131, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:44+00:00", + "poste_id": 12, + "code": "haute_haute_tige", + "nom": "Fruitier haute-tige formé", + "description": "Tronc 1.8m, port libre, pré-verger", + "prix_mat_min": 60, + "prix_mat_max": 120, + "prix_pro_min": 130, + "prix_pro_max": 250, + "unite": "plant", + "quantite_default": 1, + "source": "Naudet/Lemonnier 2026", + "source_date": "2026-02-01", + "notes": "Longévité 60-100 ans", + "filiere_agreee": false, + "actif": true, + "typologie": "arbre_haute_tige", + "parametre_dim": "{\"nom\": \"nb_plants\", \"min\": 1, \"max\": 20, \"step\": 1, \"default\": 4, \"unite\": \"plants\", \"strategie\": \"par_personne\", \"base\": 1, \"coef\": 0.8}", + "prix_par_unite": 90, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Fruitier haute-tige à port libre pour créer un pré-verger qui durera 60 à 100 ans.", + "notes_techniques": "Tronc 1.8 m, port libre. 60-120 EUR/plant. Longévité 60-100 ans. Espacement 5x5 m. Pour pré-verger, pas potager. Pépinières Lemonnier.", + "accessible_fr": true + }, + { + "Id": 132, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:44+00:00", + "poste_id": 12, + "code": "haute_conteneur_5_10L", + "nom": "Fruitier conteneur 5-10L", + "description": "Plus cher mais plantation toute saison", + "prix_mat_min": 30, + "prix_mat_max": 60, + "prix_pro_min": 70, + "prix_pro_max": 130, + "unite": "plant", + "quantite_default": 1, + "source": "Truffaut/jardineries 2026", + "source_date": "2026-03-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "arbre_conteneur", + "parametre_dim": "{\"nom\": \"nb_plants\", \"min\": 1, \"max\": 20, \"step\": 1, \"default\": 4, \"unite\": \"plants\", \"strategie\": \"par_personne\", \"base\": 1, \"coef\": 0.8}", + "prix_par_unite": 45, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Fruitier en pot à planter toute l'année, plus cher mais reprise immédiate garantie en toute saison.", + "notes_techniques": "Pot 5-10 L. 30-60 EUR/plant. Plantation toute saison. Variétés courantes. Truffaut / jardineries pro. Arrosage impératif 1er été.", + "accessible_fr": true + }, + { + "Id": 133, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:45+00:00", + "poste_id": 12, + "code": "haute_fruit_coque", + "nom": "Fruit à coque (noyer/châtaignier)", + "description": "Plant 80-120 cm, racines nues", + "prix_mat_min": 18, + "prix_mat_max": 35, + "prix_pro_min": 50, + "prix_pro_max": 100, + "unite": "plant", + "quantite_default": 1, + "source": "Pépinière Bauchery 2026", + "source_date": "2026-02-01", + "notes": "Encombrement >10m", + "filiere_agreee": false, + "actif": true, + "typologie": "arbre_fruit_coque", + "parametre_dim": "{\"nom\": \"nb_plants\", \"min\": 1, \"max\": 10, \"step\": 1, \"default\": 2, \"unite\": \"plants\", \"strategie\": \"par_personne\", \"base\": 0, \"coef\": 0.5}", + "prix_par_unite": 26.5, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Noyer ou châtaignier pour produire des fruits à coque qui se conservent un an sans transformation.", + "notes_techniques": "80-120 cm racines nues. 18-35 EUR/plant. Encombrement > 10 m à maturité. Espacement 10x10 m. Pour pré-verger spacieux. Pépinière Bauchery.", + "accessible_fr": true + }, + { + "Id": 134, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:45+00:00", + "poste_id": 12, + "code": "haute_plantation_pose_pro", + "nom": "Plantation arbre fruitier (pose pro)", + "description": "Trou + tuteur + paillage + 1er arrosage", + "prix_mat_min": null, + "prix_mat_max": null, + "prix_pro_min": 80, + "prix_pro_max": 180, + "unite": "plant", + "quantite_default": 1, + "source": "AFAC + paysagiste 2025", + "source_date": "2025-10-01", + "notes": "Hors prix du plant", + "filiere_agreee": false, + "actif": true, + "typologie": "arbre_plantation_pro", + "parametre_dim": "{\"nom\": \"nb_plants\", \"min\": 1, \"max\": 20, \"step\": 1, \"default\": 4, \"unite\": \"plants\", \"strategie\": \"fixe\"}", + "prix_par_unite": 130, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 2, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Service complet de plantation pro : trou, tuteur, paillage et premier arrosage pour garantir la reprise.", + "notes_techniques": "Hors prix du plant. 80-180 EUR/arbre posé. AFAC + paysagiste. Garantie reprise. Recommandé pour haute-tige et plantations de masse.", + "accessible_fr": true + }, + { + "Id": 135, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:45+00:00", + "poste_id": 13, + "code": "moy_petit_fruit_3L", + "nom": "Petit fruit caduc unitaire", + "description": "Cassissier/groseillier/framboisier conteneur 3L", + "prix_mat_min": 8, + "prix_mat_max": 18, + "prix_pro_min": 18, + "prix_pro_max": 35, + "unite": "plant", + "quantite_default": 1, + "source": "Lemonnier/Naudet 2026", + "source_date": "2026-02-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "haie_petit_fruit_caduc", + "parametre_dim": "{\"nom\": \"nb_plants\", \"min\": 3, \"max\": 50, \"step\": 1, \"default\": 6, \"unite\": \"plants\", \"strategie\": \"par_personne\", \"base\": 2, \"coef\": 1}", + "prix_par_unite": 13, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Plants de petits fruits caduc pour garnir la strate moyenne : cassis, groseilles, framboisiers productifs.", + "notes_techniques": "Cassissier / groseillier / framboisier conteneur 3 L. 8-18 EUR/plant. 3-5 plants/foyer par espèce. Espacement 1.5 m. Production dès an 2.", + "accessible_fr": true + }, + { + "Id": 136, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:27:25+00:00", + "poste_id": 13, + "code": "moy_haie_biodiv_rn", + "nom": "Plant haie racines nues AFAC", + "description": "Cornouiller, fusain, prunellier, charme... 60-90cm", + "prix_mat_min": 1.5, + "prix_mat_max": 4, + "prix_pro_min": 4, + "prix_pro_max": 9, + "unite": "plant", + "quantite_default": 1, + "source": "AFAC + Plant de Pays 2025", + "source_date": "2025-12-01", + "notes": "Tarif filière", + "filiere_agreee": false, + "actif": true, + "typologie": "haie_racines_nues", + "parametre_dim": "{\"nom\": \"nb_plants\", \"min\": 10, \"max\": 200, \"step\": 5, \"default\": 25, \"unite\": \"plants\", \"strategie\": \"par_metres_haie\", \"coef\": 1.5}", + "prix_par_unite": 2.5, + "prix_fixe": 0, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Plants de haie en racines nues AFAC, la solution la moins chère pour implanter une haie biodiversité.", + "notes_techniques": "Cornouiller, fusain, prunellier, charme 60-90 cm. 1.50-4 EUR/plant. Tarif filière AFAC. Plantation automne-hiver. Paillage BRF recommandé.", + "accessible_fr": true + }, + { + "Id": 137, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:27:25+00:00", + "poste_id": 13, + "code": "moy_haie_fruitiere_cont", + "nom": "Plant haie fruitiere conteneur", + "description": "Noisetier, sureau, cornouiller mâle, amélanchier", + "prix_mat_min": 8, + "prix_mat_max": 20, + "prix_pro_min": 18, + "prix_pro_max": 40, + "unite": "plant", + "quantite_default": 1, + "source": "Variétés Anciennes 2026", + "source_date": "2026-02-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "haie_conteneur", + "parametre_dim": "{\"nom\": \"nb_plants\", \"min\": 10, \"max\": 200, \"step\": 5, \"default\": 25, \"unite\": \"plants\", \"strategie\": \"par_metres_haie\", \"coef\": 1.5}", + "prix_par_unite": 14, + "prix_fixe": 0, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Plants de haie fruitière en conteneur déjà étoffés, plantables toute l'année pour une prise rapide.", + "notes_techniques": "Noisetier, sureau, cornouiller mâle, amélanchier. 8-20 EUR/plant. Plantation toute saison. Plant déjà étoffé. Pépinière Variétés Anciennes réf.", + "accessible_fr": true + }, + { + "Id": 138, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:45+00:00", + "poste_id": 13, + "code": "moy_haie_biodiv_complete", + "nom": "Haie biodiv complète posée", + "description": "1.5 plants/ml, paillage BRF, protection lapin, 3 strates", + "prix_mat_min": 12, + "prix_mat_max": 25, + "prix_pro_min": 28, + "prix_pro_max": 55, + "unite": "ml", + "quantite_default": 1, + "source": "AFAC-Agroforesteries 2025", + "source_date": "2025-11-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "haie_complete_posee", + "parametre_dim": "{\"nom\": \"longueur_ml\", \"min\": 10, \"max\": 200, \"step\": 5, \"default\": 40, \"unite\": \"ml\", \"strategie\": \"par_metres_haie\", \"coef\": 1.0}", + "prix_par_unite": 22, + "prix_fixe": null, + "flag_diy": false, + "complexite_pose": 2, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Haie clé en main posée par un pro avec plants, paillage BRF et protection lapin inclus.", + "notes_techniques": "1.5-2 plants/ml, paillage BRF, protection lapin, 3 strates. 18-35 EUR/ml posé. Aide PCAE possible. AFAC-Agroforesteries réf. Garantie reprise.", + "accessible_fr": true + }, + { + "Id": 140, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:45+00:00", + "poste_id": 13, + "code": "moy_petit_fruit_palisse", + "nom": "Petit fruit palissé (mûre/kiwi/vigne)", + "description": "Pieds + structure bois + fil tendeur", + "prix_mat_min": 25, + "prix_mat_max": 60, + "prix_pro_min": 60, + "prix_pro_max": 130, + "unite": "ml", + "quantite_default": 1, + "source": "Catalogue grimpants Naudet 2026", + "source_date": "2026-03-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "haie_petit_fruit_palisse", + "parametre_dim": "{\"nom\": \"longueur_ml\", \"min\": 2, \"max\": 30, \"step\": 1, \"default\": 5, \"unite\": \"ml\", \"strategie\": \"par_metres_haie\", \"coef\": 0.3}", + "prix_par_unite": 42.5, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Fruits grimpants palissés (mûre, kiwi, vigne) sur structure bois pour valoriser murets et clôtures.", + "notes_techniques": "Pieds + structure bois + fil tendeur. 25-60 EUR/ml. 1 pied/2-3 ml. Mûre, kiwi, vigne. Taille annuelle requise. Production an 3-5.", + "accessible_fr": true + }, + { + "Id": 141, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:45+00:00", + "poste_id": 14, + "code": "basse_brf", + "nom": "Préparation sol potager BRF", + "description": "Déchaumage + 8-10cm BRF + 3 mois maturation", + "prix_mat_min": 5, + "prix_mat_max": 12, + "prix_pro_min": 18, + "prix_pro_max": 35, + "unite": "m2", + "quantite_default": 1, + "source": "Bec Hellouin 2024", + "source_date": "2024-08-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "potager_preparation_sol", + "parametre_dim": "{\"nom\": \"surface_m2\", \"min\": 10, \"max\": 500, \"step\": 10, \"default\": 50, \"unite\": \"m\\u00b2\", \"strategie\": \"par_surface_parcelle\", \"coef\": 0.15, \"reserve_construit\": 100}", + "prix_par_unite": 10, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Préparation du sol en BRF ou lasagne pour créer une terre vivante avant de planter le potager.", + "notes_techniques": "BRF : 8-10 cm + 3 mois maturation. Lasagne : carton + paille + compost. 8-18 EUR/m² mat. DIY si broyeur dispo. Bec Hellouin réf.", + "accessible_fr": true + }, + { + "Id": 143, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:46+00:00", + "poste_id": 14, + "code": "basse_planche_bec", + "nom": "Planche permanente type Bec Hellouin", + "description": "Buttes 80cm + sentiers + bordures bois", + "prix_mat_min": 25, + "prix_mat_max": 50, + "prix_pro_min": 60, + "prix_pro_max": 110, + "unite": "m2", + "quantite_default": 1, + "source": "Bec Hellouin 2019", + "source_date": "2019-01-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "potager_planches_permanentes", + "parametre_dim": "{\"nom\": \"surface_m2\", \"min\": 10, \"max\": 500, \"step\": 10, \"default\": 50, \"unite\": \"m\\u00b2\", \"strategie\": \"par_surface_parcelle\", \"coef\": 0.1, \"reserve_construit\": 100}", + "prix_par_unite": 37.5, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 2, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Planches surélevées biointensives type Bec Hellouin pour une production maximale par mètre carré.", + "notes_techniques": "Buttes 80 cm + sentiers + bordures bois. 25-50 EUR/m². Méthode Bec Hellouin. Rendement 2-3x conventionnel. Entretien régulier requis.", + "accessible_fr": true + }, + { + "Id": 144, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:46+00:00", + "poste_id": 14, + "code": "basse_serre_6m2", + "nom": "Serre tunnel 6m² (1.8 x 3)", + "description": "Arceaux acier + bâche 200µ, montage perso", + "prix_mat_min": 250, + "prix_mat_max": 450, + "prix_pro_min": null, + "prix_pro_max": null, + "unite": "forfait", + "quantite_default": 1, + "source": "Richel/Filclair 2026", + "source_date": "2026-02-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "potager_serre_tunnel", + "parametre_dim": "{\"nom\": \"surface_m2\", \"min\": 6, \"max\": 30, \"step\": 6, \"default\": 18, \"unite\": \"m\\u00b2\", \"strategie\": \"par_personne\", \"base\": 3, \"coef\": 3}", + "prix_par_unite": 42, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Serre tunnel qui allonge la saison de 1 à 2 mois et protège les cultures des aléas climatiques.", + "notes_techniques": "Arceaux acier + bâche 200 µm. 6-30 m². 42 EUR/m² + fixe 100 EUR. Richel / Filclair. Allonge saison 1-2 mois. Recommandé > 3 pers autonomie sérieuse.", + "accessible_fr": true + }, + { + "Id": 146, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:46+00:00", + "poste_id": 14, + "code": "basse_chassis_vitre", + "nom": "Châssis vitré bois 1m²", + "description": "Semis précoces et hivernage", + "prix_mat_min": 80, + "prix_mat_max": 200, + "prix_pro_min": 200, + "prix_pro_max": 400, + "unite": "forfait", + "quantite_default": 1, + "source": "Plans + bois local 2025", + "source_date": "2025-04-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "potager_chassis_vitre", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Châssis vitré en bois pour lancer les semis précocement et protéger les cultures sensibles au froid.", + "notes_techniques": "1 m² bois + vitre. 80-200 EUR mat. DIY simple. Semis 4-6 semaines avant dernier gel. Hivernage herbes aromatiques. Plans ADEME.", + "accessible_fr": true + }, + { + "Id": 147, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:46+00:00", + "poste_id": 14, + "code": "basse_vivaces_lot50", + "nom": "Vivaces alimentaires (lot 50)", + "description": "Topinambours, oseille, ciboulette, livèche...", + "prix_mat_min": 80, + "prix_mat_max": 180, + "prix_pro_min": 180, + "prix_pro_max": 350, + "unite": "forfait", + "quantite_default": 1, + "source": "Le Jardin de Sauveterre 2025", + "source_date": "2025-10-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "potager_vivaces_alimentaires", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Lot de 50 plantes vivaces comestibles qui produisent sans ressemer après la première plantation.", + "notes_techniques": "Topinambours, oseille, ciboulette, livèche, oignons rocambole. 80-180 EUR/lot. Couvre 30-50 m². Pépinière Le Jardin de Sauveterre réf.", + "accessible_fr": true + }, + { + "Id": 148, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:46+00:00", + "poste_id": 14, + "code": "basse_medicinales_lot30", + "nom": "Médicinales / aromatiques (lot 30)", + "description": "Sauge, thym, romarin, mélisse, menthe, lavande...", + "prix_mat_min": 50, + "prix_mat_max": 130, + "prix_pro_min": 130, + "prix_pro_max": 250, + "unite": "forfait", + "quantite_default": 1, + "source": "Pépinière des Sources 2025", + "source_date": "2025-11-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "potager_medicinales", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Lot de 30 plantes médicinales et aromatiques vivaces pour tisanes, cuisine et santé au quotidien.", + "notes_techniques": "Sauge, thym, romarin, mélisse, menthe, lavande. 50-130 EUR/lot. Pépinière des Sources réf. Petits massifs proches habitation. Taille légère annuelle.", + "accessible_fr": true + }, + { + "Id": 149, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:46+00:00", + "poste_id": 14, + "code": "basse_mare_3_8m2", + "nom": "Mare pédagogique 3-8 m²", + "description": "Bâche EPDM + plantes hélophytes + pose", + "prix_mat_min": 250, + "prix_mat_max": 600, + "prix_pro_min": 800, + "prix_pro_max": 1800, + "unite": "forfait", + "quantite_default": 1, + "source": "Catalogue Aquiflor 2025", + "source_date": "2025-09-01", + "notes": "", + "filiere_agreee": false, + "actif": true, + "typologie": "potager_mare", + "parametre_dim": "{\"nom\": \"surface_m2\", \"min\": 3, \"max\": 12, \"step\": 1, \"default\": 5, \"unite\": \"m\\u00b2\", \"strategie\": \"fixe\"}", + "prix_par_unite": 65, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Mare de 3 à 12 m² qui attire les auxiliaires du jardin et crée un microclimat humide favorable.", + "notes_techniques": "Bâche EPDM + plantes hélophytes. 65 EUR/m² + fixe 60 EUR. Auxiliaires + microclimat. Aquiflor réf. 3-8 m² recommandé.", + "accessible_fr": true + }, + { + "Id": 150, + "CreatedAt": "2026-04-28 19:57:02+00:00", + "UpdatedAt": "2026-04-30 10:23:46+00:00", + "poste_id": 14, + "code": "basse_compost_3bacs", + "nom": "Compost à 3 bacs bois", + "description": "Système de rotation, palettes ou douelles", + "prix_mat_min": 0, + "prix_mat_max": 200, + "prix_pro_min": 200, + "prix_pro_max": 500, + "unite": "forfait", + "quantite_default": 1, + "source": "Plans ADEME / récup 2024", + "source_date": "2024-06-01", + "notes": "DIY palettes = quasi gratuit", + "filiere_agreee": false, + "actif": true, + "typologie": "potager_compost", + "parametre_dim": null, + "prix_par_unite": null, + "prix_fixe": null, + "flag_diy": true, + "complexite_pose": 1, + "temps_install_h": null, + "couplages_requis": "[]", + "couplages_exclus": "[]", + "description_pedago": "Compost à 3 bacs en bois pour recycler tous les déchets végétaux de la maison en engrais naturel.", + "notes_techniques": "Système rotation 3 bacs, palettes récup ou douelles. 0-200 EUR mat. DIY palettes quasi gratuit. ADEME plans. Capacité 4-6 pers.", + "accessible_fr": true + } +] diff --git a/fixtures/postes.json b/fixtures/postes.json new file mode 100644 index 0000000..30bc7c2 --- /dev/null +++ b/fixtures/postes.json @@ -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 + } +] diff --git a/fixtures/profils-reference.json b/fixtures/profils-reference.json new file mode 100644 index 0000000..e3c0528 --- /dev/null +++ b/fixtures/profils-reference.json @@ -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" + ] + } +] \ No newline at end of file diff --git a/fixtures/sorties-origine-2026-09-28.json b/fixtures/sorties-origine-2026-09-28.json new file mode 100644 index 0000000..c1d9b42 --- /dev/null +++ b/fixtures/sorties-origine-2026-09-28.json @@ -0,0 +1,1379 @@ +{ + "_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 2026-09-28 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": "2026-09-28", + "profils": { + "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 + }, + "global": { + "total_eur_ht": 15180, + "status": "OK", + "ecart_budget_pct": 1.2, + "axes": { + "eau": { + "budget_alloue": 7500, + "poids": 50, + "sous_total_eur_ht": 7280, + "postes": [ + { + "poste_id": "eau_captage", + "typologie_choisie": "captage_descente_directe", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 100, + "force_par_couplage": false + }, + { + "poste_id": "eau_filtration", + "typologie_choisie": "filtration_particulaire_simple", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 55, + "force_par_couplage": false + }, + { + "poste_id": "eau_stockage", + "typologie_choisie": "stockage_souple_5m3", + "dimension": { + "valeur": 5, + "unite": "m³" + }, + "cout_eur_ht": 475, + "force_par_couplage": false + }, + { + "poste_id": "eau_distribution", + "typologie_choisie": "distrib_gravitaire", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 250, + "force_par_couplage": false + }, + { + "poste_id": "eau_eg_epuration", + "typologie_choisie": "eg_noue", + "dimension": { + "valeur": 5, + "unite": "ml" + }, + "cout_eur_ht": 400, + "force_par_couplage": false + }, + { + "poste_id": "5_couplage", + "typologie_choisie": "eg_pedoepuration", + "dimension": { + "valeur": 10, + "unite": "m²" + }, + "cout_eur_ht": 4000, + "force_par_couplage": true + }, + { + "poste_id": "5_couplage", + "typologie_choisie": "eg_fpv_autoconstruit", + "dimension": { + "valeur": 10, + "unite": "m²" + }, + "cout_eur_ht": 2000, + "force_par_couplage": true + } + ] + }, + "energie": { + "budget_alloue": 7500, + "poids": 50, + "sous_total_eur_ht": 7900, + "postes": [ + { + "poste_id": "energie_pv", + "typologie_choisie": "pv_batterie_plomb_5", + "dimension": { + "valeur": 0.4, + "unite": "kWc" + }, + "cout_eur_ht": 2000, + "force_par_couplage": false + }, + { + "poste_id": "energie_solaire_th", + "typologie_choisie": "th_ballon_500", + "dimension": { + "valeur": 500, + "unite": "L" + }, + "cout_eur_ht": 600, + "force_par_couplage": false + }, + { + "poste_id": "energie_chauffage_bois", + "typologie_choisie": "bois_poele_double", + "dimension": { + "valeur": 6, + "unite": "ml" + }, + "cout_eur_ht": 3250, + "force_par_couplage": false + }, + { + "poste_id": "energie_emetteurs", + "typologie_choisie": "emet_vannes_thermo", + "dimension": { + "valeur": 3, + "unite": "unités" + }, + "cout_eur_ht": 270, + "force_par_couplage": false + }, + { + "poste_id": "energie_pilotage", + "typologie_choisie": "pil_home_assistant", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 400, + "force_par_couplage": false + }, + { + "poste_id": "8_couplage", + "typologie_choisie": "bois_conduit_fumee", + "dimension": { + "valeur": 6, + "unite": "ml" + }, + "cout_eur_ht": 1380, + "force_par_couplage": true + } + ] + }, + "alim": { + "budget_alloue": 0, + "poids": 0, + "sous_total_eur_ht": 0, + "postes": [] + } + }, + "couplages": [ + "FORCE \"eg_fpv_autoconstruit\" (requis par \"eg_noue\") +2000EUR dans axe eau", + "FORCE \"eg_fpv_autoconstruit\" (requis par \"eg_noue\") +2000EUR dans axe eau", + "FORCE \"bois_conduit_fumee\" (requis par \"bois_poele_masse_diy\") +1380EUR dans axe energie", + "FORCE \"eg_fpv_autoconstruit\" (requis par \"eg_noue\") +2000EUR dans axe eau", + "FORCE \"eg_fpv_autoconstruit\" (requis par \"eg_noue\") +2000EUR dans axe eau", + "FORCE \"bois_conduit_fumee\" (requis par \"bois_poele_masse_diy\") +1380EUR dans axe energie" + ] + }, + "par_axe": { + "eau": { + "budget_axe": 7500, + "total_eur_ht": 8130, + "status": "BUDGET_INSUFFISANT", + "ecart_budget_pct": 8.4, + "axes": { + "eau": { + "budget_alloue": 7500, + "poids": 100, + "sous_total_eur_ht": 8130, + "postes": [ + { + "poste_id": "eau_captage", + "typologie_choisie": "captage_descente_directe", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 100, + "force_par_couplage": false + }, + { + "poste_id": "eau_filtration", + "typologie_choisie": "filtration_particulaire_simple", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 55, + "force_par_couplage": false + }, + { + "poste_id": "eau_stockage", + "typologie_choisie": "stockage_souple_5m3", + "dimension": { + "valeur": 5, + "unite": "m³" + }, + "cout_eur_ht": 475, + "force_par_couplage": false + }, + { + "poste_id": "eau_distribution", + "typologie_choisie": "distrib_gravitaire", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 250, + "force_par_couplage": false + }, + { + "poste_id": "5_couplage", + "typologie_choisie": "eg_pedoepuration", + "dimension": { + "valeur": 10, + "unite": "m²" + }, + "cout_eur_ht": 4000, + "force_par_couplage": true + }, + { + "poste_id": "4_couplage", + "typologie_choisie": "distrib_kit_double_reseau", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 1250, + "force_par_couplage": true + }, + { + "poste_id": "5_couplage", + "typologie_choisie": "eg_fpv_autoconstruit", + "dimension": { + "valeur": 10, + "unite": "m²" + }, + "cout_eur_ht": 2000, + "force_par_couplage": true + } + ] + }, + "energie": { + "budget_alloue": 0, + "poids": 0, + "sous_total_eur_ht": 0, + "postes": [] + }, + "alim": { + "budget_alloue": 0, + "poids": 0, + "sous_total_eur_ht": 0, + "postes": [] + } + }, + "couplages": [ + "FORCE \"eg_fpv_autoconstruit\" (requis par \"eg_noue\") +2000EUR dans axe eau", + "FORCE \"distrib_kit_double_reseau\" (requis par \"distrib_per_bleu_interieur\") +1250EUR dans axe eau", + "FORCE \"eg_fpv_autoconstruit\" (requis par \"eg_noue\") +2000EUR dans axe eau", + "FORCE \"eg_fpv_autoconstruit\" (requis par \"eg_noue\") +2000EUR dans axe eau", + "FORCE \"distrib_kit_double_reseau\" (requis par \"distrib_per_bleu_interieur\") +1250EUR dans axe eau", + "FORCE \"eg_fpv_autoconstruit\" (requis par \"eg_noue\") +2000EUR dans axe eau", + "FORCE \"eg_fpv_autoconstruit\" (requis par \"eg_noue\") +2000EUR dans axe eau", + "FORCE \"distrib_kit_double_reseau\" (requis par \"distrib_per_bleu_interieur\") +1250EUR dans axe eau", + "FORCE \"eg_fpv_autoconstruit\" (requis par \"eg_noue\") +2000EUR dans axe eau", + "FORCE \"eg_fpv_autoconstruit\" (requis par \"eg_noue\") +2000EUR dans axe eau", + "FORCE \"distrib_kit_double_reseau\" (requis par \"distrib_per_bleu_interieur\") +1250EUR dans axe eau", + "FORCE \"eg_fpv_autoconstruit\" (requis par \"eg_noue\") +2000EUR dans axe eau" + ] + }, + "energie": { + "budget_axe": 7500, + "total_eur_ht": 7110, + "status": "OK", + "ecart_budget_pct": -5.2, + "axes": { + "eau": { + "budget_alloue": 0, + "poids": 0, + "sous_total_eur_ht": 0, + "postes": [] + }, + "energie": { + "budget_alloue": 7500, + "poids": 100, + "sous_total_eur_ht": 7110, + "postes": [ + { + "poste_id": "energie_pv", + "typologie_choisie": "pv_kit_beem_balcon", + "dimension": { + "valeur": 0.4, + "unite": "kWc" + }, + "cout_eur_ht": 560, + "force_par_couplage": false + }, + { + "poste_id": "energie_solaire_th", + "typologie_choisie": "th_ballon_500", + "dimension": { + "valeur": 500, + "unite": "L" + }, + "cout_eur_ht": 600, + "force_par_couplage": false + }, + { + "poste_id": "energie_chauffage_bois", + "typologie_choisie": "bois_poele_granules", + "dimension": { + "valeur": 6, + "unite": "ml" + }, + "cout_eur_ht": 3500, + "force_par_couplage": false + }, + { + "poste_id": "energie_emetteurs", + "typologie_choisie": "emet_radiateur_acier", + "dimension": { + "valeur": 3, + "unite": "unités" + }, + "cout_eur_ht": 495, + "force_par_couplage": false + }, + { + "poste_id": "energie_pilotage", + "typologie_choisie": "pil_routeur_ecs", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 575, + "force_par_couplage": false + }, + { + "poste_id": "8_couplage", + "typologie_choisie": "bois_conduit_fumee", + "dimension": { + "valeur": 6, + "unite": "ml" + }, + "cout_eur_ht": 1380, + "force_par_couplage": true + } + ] + }, + "alim": { + "budget_alloue": 0, + "poids": 0, + "sous_total_eur_ht": 0, + "postes": [] + } + }, + "couplages": [ + "FORCE \"bois_conduit_fumee\" (requis par \"bois_poele_granules\") +1380EUR dans axe energie", + "FORCE \"bois_conduit_fumee\" (requis par \"bois_poele_granules\") +1380EUR dans axe energie" + ] + }, + "alim": { + "budget_axe": 0, + "total_eur_ht": 0, + "status": "OK", + "ecart_budget_pct": 0, + "axes": { + "eau": { + "budget_alloue": 0, + "poids": 0, + "sous_total_eur_ht": 0, + "postes": [] + }, + "energie": { + "budget_alloue": 0, + "poids": 0, + "sous_total_eur_ht": 0, + "postes": [] + }, + "alim": { + "budget_alloue": 0, + "poids": 0, + "sous_total_eur_ht": 0, + "postes": [] + } + }, + "couplages": [] + } + } + }, + "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 + }, + "global": { + "total_eur_ht": 36661, + "status": "OK", + "ecart_budget_pct": 4.75, + "axes": { + "eau": { + "budget_alloue": 14000, + "poids": 40, + "sous_total_eur_ht": 9375, + "postes": [ + { + "poste_id": "eau_captage", + "typologie_choisie": "captage_descente_directe", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 100, + "force_par_couplage": false + }, + { + "poste_id": "eau_filtration", + "typologie_choisie": "filtration_particulaire_simple", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 55, + "force_par_couplage": false + }, + { + "poste_id": "eau_stockage", + "typologie_choisie": "stockage_souple_5m3", + "dimension": { + "valeur": 6, + "unite": "m³" + }, + "cout_eur_ht": 570, + "force_par_couplage": false + }, + { + "poste_id": "eau_distribution", + "typologie_choisie": "distrib_gravitaire", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 250, + "force_par_couplage": false + }, + { + "poste_id": "eau_eg_epuration", + "typologie_choisie": "eg_noue", + "dimension": { + "valeur": 10, + "unite": "ml" + }, + "cout_eur_ht": 800, + "force_par_couplage": false + }, + { + "poste_id": "5_couplage", + "typologie_choisie": "eg_pedoepuration", + "dimension": { + "valeur": 14, + "unite": "m²" + }, + "cout_eur_ht": 5600, + "force_par_couplage": true + }, + { + "poste_id": "5_couplage", + "typologie_choisie": "eg_fpv_autoconstruit", + "dimension": { + "valeur": 10, + "unite": "m²" + }, + "cout_eur_ht": 2000, + "force_par_couplage": true + } + ] + }, + "energie": { + "budget_alloue": 14000, + "poids": 40, + "sous_total_eur_ht": 27155, + "postes": [ + { + "poste_id": "energie_pv", + "typologie_choisie": "pv_monitoring", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 400, + "force_par_couplage": false + }, + { + "poste_id": "energie_solaire_th", + "typologie_choisie": "th_ballon_500", + "dimension": { + "valeur": 500, + "unite": "L" + }, + "cout_eur_ht": 600, + "force_par_couplage": false + }, + { + "poste_id": "energie_chauffage_bois", + "typologie_choisie": "bois_poele_masse_indus", + "dimension": { + "valeur": 6, + "unite": "ml" + }, + "cout_eur_ht": 24000, + "force_par_couplage": false + }, + { + "poste_id": "energie_emetteurs", + "typologie_choisie": "emet_seche_serviette", + "dimension": { + "valeur": 1, + "unite": "unités" + }, + "cout_eur_ht": 400, + "force_par_couplage": false + }, + { + "poste_id": "energie_pilotage", + "typologie_choisie": "pil_delesteur", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 375, + "force_par_couplage": false + }, + { + "poste_id": "8_couplage", + "typologie_choisie": "bois_conduit_fumee", + "dimension": { + "valeur": 6, + "unite": "ml" + }, + "cout_eur_ht": 1380, + "force_par_couplage": true + } + ] + }, + "alim": { + "budget_alloue": 7000, + "poids": 20, + "sous_total_eur_ht": 131, + "postes": [ + { + "poste_id": "alim_etude_fonciere", + "typologie_choisie": "etude_topo_ensoleil", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 0, + "force_par_couplage": false + }, + { + "poste_id": "alim_strate_haute", + "typologie_choisie": "haute_fruit_coque", + "dimension": { + "valeur": 2, + "unite": "plants" + }, + "cout_eur_ht": 53, + "force_par_couplage": false + }, + { + "poste_id": "alim_strate_moyenne", + "typologie_choisie": "moy_petit_fruit_3L", + "dimension": { + "valeur": 6, + "unite": "plants" + }, + "cout_eur_ht": 78, + "force_par_couplage": false + }, + { + "poste_id": "alim_strate_basse", + "typologie_choisie": "basse_compost_3bacs", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 0, + "force_par_couplage": false + } + ] + } + }, + "couplages": [ + "FORCE \"eg_fpv_autoconstruit\" (requis par \"eg_noue\") +2000EUR dans axe eau", + "FORCE \"eg_fpv_autoconstruit\" (requis par \"eg_noue\") +2000EUR dans axe eau", + "FORCE \"bois_conduit_fumee\" (requis par \"bois_poele_masse_indus\") +1380EUR dans axe energie", + "FORCE \"eg_fpv_autoconstruit\" (requis par \"eg_noue\") +2000EUR dans axe eau", + "FORCE \"eg_fpv_autoconstruit\" (requis par \"eg_noue\") +2000EUR dans axe eau", + "FORCE \"bois_conduit_fumee\" (requis par \"bois_poele_masse_indus\") +1380EUR dans axe energie" + ] + }, + "par_axe": { + "eau": { + "budget_axe": 14000, + "total_eur_ht": 14645, + "status": "OK", + "ecart_budget_pct": 4.61, + "axes": { + "eau": { + "budget_alloue": 14000, + "poids": 100, + "sous_total_eur_ht": 14645, + "postes": [ + { + "poste_id": "eau_captage", + "typologie_choisie": "captage_descente_directe", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 100, + "force_par_couplage": false + }, + { + "poste_id": "eau_filtration", + "typologie_choisie": "filtration_particulaire_simple", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 55, + "force_par_couplage": false + }, + { + "poste_id": "eau_stockage", + "typologie_choisie": "stockage_pehd_enterree_5m3", + "dimension": { + "valeur": 8, + "unite": "m³" + }, + "cout_eur_ht": 3040, + "force_par_couplage": false + }, + { + "poste_id": "eau_distribution", + "typologie_choisie": "distrib_gravitaire", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 250, + "force_par_couplage": false + }, + { + "poste_id": "eau_eg_epuration", + "typologie_choisie": "eg_pedoepuration", + "dimension": { + "valeur": 14, + "unite": "m²" + }, + "cout_eur_ht": 5600, + "force_par_couplage": false + }, + { + "poste_id": "5_couplage", + "typologie_choisie": "eg_pedoepuration", + "dimension": { + "valeur": 14, + "unite": "m²" + }, + "cout_eur_ht": 5600, + "force_par_couplage": true + } + ] + }, + "energie": { + "budget_alloue": 0, + "poids": 0, + "sous_total_eur_ht": 0, + "postes": [] + }, + "alim": { + "budget_alloue": 0, + "poids": 0, + "sous_total_eur_ht": 0, + "postes": [] + } + }, + "couplages": [ + "FORCE \"eg_fpv_autoconstruit\" (requis par \"eg_noue\") +2000EUR dans axe eau" + ] + }, + "energie": { + "budget_axe": 14000, + "total_eur_ht": 13455, + "status": "OK", + "ecart_budget_pct": -3.89, + "axes": { + "eau": { + "budget_alloue": 0, + "poids": 0, + "sous_total_eur_ht": 0, + "postes": [] + }, + "energie": { + "budget_alloue": 14000, + "poids": 100, + "sous_total_eur_ht": 13455, + "postes": [ + { + "poste_id": "energie_pv", + "typologie_choisie": "pv_kit_3kwc_string", + "dimension": { + "valeur": 9, + "unite": "kWc" + }, + "cout_eur_ht": 7200, + "force_par_couplage": false + }, + { + "poste_id": "energie_solaire_th", + "typologie_choisie": "th_ballon_500", + "dimension": { + "valeur": 500, + "unite": "L" + }, + "cout_eur_ht": 600, + "force_par_couplage": false + }, + { + "poste_id": "energie_chauffage_bois", + "typologie_choisie": "bois_poele_granules", + "dimension": { + "valeur": 6, + "unite": "ml" + }, + "cout_eur_ht": 3500, + "force_par_couplage": false + }, + { + "poste_id": "energie_emetteurs", + "typologie_choisie": "emet_seche_serviette", + "dimension": { + "valeur": 1, + "unite": "unités" + }, + "cout_eur_ht": 400, + "force_par_couplage": false + }, + { + "poste_id": "energie_pilotage", + "typologie_choisie": "pil_delesteur", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 375, + "force_par_couplage": false + }, + { + "poste_id": "8_couplage", + "typologie_choisie": "bois_conduit_fumee", + "dimension": { + "valeur": 6, + "unite": "ml" + }, + "cout_eur_ht": 1380, + "force_par_couplage": true + } + ] + }, + "alim": { + "budget_alloue": 0, + "poids": 0, + "sous_total_eur_ht": 0, + "postes": [] + } + }, + "couplages": [ + "FORCE \"bois_conduit_fumee\" (requis par \"bois_poele_masse_diy\") +1380EUR dans axe energie", + "FORCE \"bois_conduit_fumee\" (requis par \"bois_poele_masse_diy\") +1380EUR dans axe energie" + ] + }, + "alim": { + "budget_axe": 7000, + "total_eur_ht": 6985, + "status": "OK", + "ecart_budget_pct": -0.21, + "axes": { + "eau": { + "budget_alloue": 0, + "poids": 0, + "sous_total_eur_ht": 0, + "postes": [] + }, + "energie": { + "budget_alloue": 0, + "poids": 0, + "sous_total_eur_ht": 0, + "postes": [] + }, + "alim": { + "budget_alloue": 7000, + "poids": 100, + "sous_total_eur_ht": 6985, + "postes": [ + { + "poste_id": "alim_etude_fonciere", + "typologie_choisie": "etude_analyse_labo", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 115, + "force_par_couplage": false + }, + { + "poste_id": "alim_strate_haute", + "typologie_choisie": "haute_plantation_pose_pro", + "dimension": { + "valeur": 4, + "unite": "plants" + }, + "cout_eur_ht": 520, + "force_par_couplage": false + }, + { + "poste_id": "alim_strate_moyenne", + "typologie_choisie": "moy_haie_biodiv_complete", + "dimension": { + "valeur": 50, + "unite": "ml" + }, + "cout_eur_ht": 1100, + "force_par_couplage": false + }, + { + "poste_id": "alim_strate_basse", + "typologie_choisie": "basse_planche_bec", + "dimension": { + "valeur": 70, + "unite": "m²" + }, + "cout_eur_ht": 2625, + "force_par_couplage": false + }, + { + "poste_id": 14, + "typologie_choisie": "basse_planche_bec", + "dimension": { + "valeur": 70, + "unite": "m²" + }, + "cout_eur_ht": 2625, + "force_par_couplage": false + } + ] + } + }, + "couplages": [] + } + } + }, + "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 + }, + "global": { + "total_eur_ht": 77465, + "status": "OK", + "ecart_budget_pct": -3.17, + "axes": { + "eau": { + "budget_alloue": 40000, + "poids": 50, + "sous_total_eur_ht": 15650, + "postes": [ + { + "poste_id": "eau_captage", + "typologie_choisie": "captage_capteur_sol", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 2250, + "force_par_couplage": false + }, + { + "poste_id": "eau_filtration", + "typologie_choisie": "filtration_3_corps_uv", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 650, + "force_par_couplage": false + }, + { + "poste_id": "eau_stockage", + "typologie_choisie": "stockage_beton_5m3", + "dimension": { + "valeur": 10, + "unite": "m³" + }, + "cout_eur_ht": 4800, + "force_par_couplage": false + }, + { + "poste_id": "eau_distribution", + "typologie_choisie": "distrib_gravitaire", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 250, + "force_par_couplage": false + }, + { + "poste_id": "eau_eg_epuration", + "typologie_choisie": "eg_phytostation", + "dimension": { + "valeur": 7, + "unite": "EH" + }, + "cout_eur_ht": 7700, + "force_par_couplage": false + } + ] + }, + "energie": { + "budget_alloue": 18400, + "poids": 23, + "sous_total_eur_ht": 49960, + "postes": [ + { + "poste_id": "energie_pv", + "typologie_choisie": "pv_kit_3kwc_micro", + "dimension": { + "valeur": 9, + "unite": "kWc" + }, + "cout_eur_ht": 9000, + "force_par_couplage": false + }, + { + "poste_id": "energie_solaire_th", + "typologie_choisie": "th_ballon_500", + "dimension": { + "valeur": 500, + "unite": "L" + }, + "cout_eur_ht": 600, + "force_par_couplage": false + }, + { + "poste_id": "energie_chauffage_bois", + "typologie_choisie": "bois_chaudiere_granules", + "dimension": { + "valeur": 6, + "unite": "ml" + }, + "cout_eur_ht": 34000, + "force_par_couplage": false + }, + { + "poste_id": "energie_emetteurs", + "typologie_choisie": "emet_vannes_thermo", + "dimension": { + "valeur": 7, + "unite": "unités" + }, + "cout_eur_ht": 630, + "force_par_couplage": false + }, + { + "poste_id": "energie_pilotage", + "typologie_choisie": "pil_onduleur_hybride", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 4350, + "force_par_couplage": false + }, + { + "poste_id": "8_couplage", + "typologie_choisie": "bois_conduit_fumee", + "dimension": { + "valeur": 6, + "unite": "ml" + }, + "cout_eur_ht": 1380, + "force_par_couplage": true + } + ] + }, + "alim": { + "budget_alloue": 21600, + "poids": 27, + "sous_total_eur_ht": 11855, + "postes": [ + { + "poste_id": "alim_etude_fonciere", + "typologie_choisie": "etude_analyse_labo", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 115, + "force_par_couplage": false + }, + { + "poste_id": "alim_strate_haute", + "typologie_choisie": "haute_haute_tige", + "dimension": { + "valeur": 6, + "unite": "plants" + }, + "cout_eur_ht": 540, + "force_par_couplage": false + }, + { + "poste_id": "alim_strate_moyenne", + "typologie_choisie": "moy_haie_biodiv_complete", + "dimension": { + "valeur": 100, + "unite": "ml" + }, + "cout_eur_ht": 2200, + "force_par_couplage": false + }, + { + "poste_id": "alim_strate_basse", + "typologie_choisie": "basse_planche_bec", + "dimension": { + "valeur": 240, + "unite": "m²" + }, + "cout_eur_ht": 9000, + "force_par_couplage": false + } + ] + } + }, + "couplages": [ + "FORCE \"bois_conduit_fumee\" (requis par \"bois_chaudiere_granules\") +1380EUR dans axe energie" + ] + }, + "par_axe": { + "eau": { + "budget_axe": 40000, + "total_eur_ht": 41000, + "status": "OK", + "ecart_budget_pct": 2.5, + "axes": { + "eau": { + "budget_alloue": 40000, + "poids": 100, + "sous_total_eur_ht": 41000, + "postes": [ + { + "poste_id": "eau_captage", + "typologie_choisie": "captage_capteur_sol", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 2250, + "force_par_couplage": false + }, + { + "poste_id": "eau_filtration", + "typologie_choisie": "filtration_3_corps_uv", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 650, + "force_par_couplage": false + }, + { + "poste_id": "eau_stockage", + "typologie_choisie": "stockage_beton_5m3", + "dimension": { + "valeur": 10, + "unite": "m³" + }, + "cout_eur_ht": 4800, + "force_par_couplage": false + }, + { + "poste_id": "eau_distribution", + "typologie_choisie": "distrib_per_bleu_interieur", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 1750, + "force_par_couplage": false + }, + { + "poste_id": "eau_eg_epuration", + "typologie_choisie": "eg_phytostation", + "dimension": { + "valeur": 7, + "unite": "EH" + }, + "cout_eur_ht": 7700, + "force_par_couplage": false + }, + { + "poste_id": 5, + "typologie_choisie": "eg_phytostation", + "dimension": { + "valeur": 7, + "unite": "EH" + }, + "cout_eur_ht": 7700, + "force_par_couplage": false + }, + { + "poste_id": 5, + "typologie_choisie": "eg_phytostation", + "dimension": { + "valeur": 7, + "unite": "EH" + }, + "cout_eur_ht": 7700, + "force_par_couplage": false + }, + { + "poste_id": 5, + "typologie_choisie": "eg_pedoepuration", + "dimension": { + "valeur": 18, + "unite": "m²" + }, + "cout_eur_ht": 7200, + "force_par_couplage": false + }, + { + "poste_id": "4_couplage", + "typologie_choisie": "distrib_kit_double_reseau", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 1250, + "force_par_couplage": true + } + ] + }, + "energie": { + "budget_alloue": 0, + "poids": 0, + "sous_total_eur_ht": 0, + "postes": [] + }, + "alim": { + "budget_alloue": 0, + "poids": 0, + "sous_total_eur_ht": 0, + "postes": [] + } + }, + "couplages": [ + "FORCE \"distrib_kit_double_reseau\" (requis par \"distrib_per_bleu_interieur\") +1250EUR dans axe eau" + ] + }, + "energie": { + "budget_axe": 18400, + "total_eur_ht": 19210, + "status": "OK", + "ecart_budget_pct": 4.4, + "axes": { + "eau": { + "budget_alloue": 0, + "poids": 0, + "sous_total_eur_ht": 0, + "postes": [] + }, + "energie": { + "budget_alloue": 18400, + "poids": 100, + "sous_total_eur_ht": 19210, + "postes": [ + { + "poste_id": "energie_pv", + "typologie_choisie": "pv_batterie_plomb_5", + "dimension": { + "valeur": 0.4, + "unite": "kWc" + }, + "cout_eur_ht": 2000, + "force_par_couplage": false + }, + { + "poste_id": "energie_solaire_th", + "typologie_choisie": "th_ballon_500", + "dimension": { + "valeur": 500, + "unite": "L" + }, + "cout_eur_ht": 600, + "force_par_couplage": false + }, + { + "poste_id": "energie_chauffage_bois", + "typologie_choisie": "bois_poele_hydro", + "dimension": { + "valeur": 6, + "unite": "ml" + }, + "cout_eur_ht": 13500, + "force_par_couplage": false + }, + { + "poste_id": "energie_emetteurs", + "typologie_choisie": "emet_radiateur_acier", + "dimension": { + "valeur": 7, + "unite": "unités" + }, + "cout_eur_ht": 1155, + "force_par_couplage": false + }, + { + "poste_id": "energie_pilotage", + "typologie_choisie": "pil_routeur_ecs", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 575, + "force_par_couplage": false + }, + { + "poste_id": "8_couplage", + "typologie_choisie": "bois_conduit_fumee", + "dimension": { + "valeur": 6, + "unite": "ml" + }, + "cout_eur_ht": 1380, + "force_par_couplage": true + } + ] + }, + "alim": { + "budget_alloue": 0, + "poids": 0, + "sous_total_eur_ht": 0, + "postes": [] + } + }, + "couplages": [ + "FORCE \"bois_conduit_fumee\" (requis par \"bois_poele_hydro\") +1380EUR dans axe energie" + ] + }, + "alim": { + "budget_axe": 21600, + "total_eur_ht": 20855, + "status": "OK", + "ecart_budget_pct": -3.45, + "axes": { + "eau": { + "budget_alloue": 0, + "poids": 0, + "sous_total_eur_ht": 0, + "postes": [] + }, + "energie": { + "budget_alloue": 0, + "poids": 0, + "sous_total_eur_ht": 0, + "postes": [] + }, + "alim": { + "budget_alloue": 21600, + "poids": 100, + "sous_total_eur_ht": 20855, + "postes": [ + { + "poste_id": "alim_etude_fonciere", + "typologie_choisie": "etude_analyse_labo", + "dimension": { + "valeur": 1, + "unite": "forfait" + }, + "cout_eur_ht": 115, + "force_par_couplage": false + }, + { + "poste_id": "alim_strate_haute", + "typologie_choisie": "haute_haute_tige", + "dimension": { + "valeur": 6, + "unite": "plants" + }, + "cout_eur_ht": 540, + "force_par_couplage": false + }, + { + "poste_id": "alim_strate_moyenne", + "typologie_choisie": "moy_haie_biodiv_complete", + "dimension": { + "valeur": 100, + "unite": "ml" + }, + "cout_eur_ht": 2200, + "force_par_couplage": false + }, + { + "poste_id": "alim_strate_basse", + "typologie_choisie": "basse_planche_bec", + "dimension": { + "valeur": 240, + "unite": "m²" + }, + "cout_eur_ht": 9000, + "force_par_couplage": false + }, + { + "poste_id": 14, + "typologie_choisie": "basse_planche_bec", + "dimension": { + "valeur": 240, + "unite": "m²" + }, + "cout_eur_ht": 9000, + "force_par_couplage": false + } + ] + } + }, + "couplages": [] + } + } + } + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..144441d --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b0083e2 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/schemas/algo_config.schema.json b/schemas/algo_config.schema.json new file mode 100644 index 0000000..393bd2f --- /dev/null +++ b/schemas/algo_config.schema.json @@ -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" + } + } +} diff --git a/schemas/france_climat.schema.json b/schemas/france_climat.schema.json new file mode 100644 index 0000000..c25a038 --- /dev/null +++ b/schemas/france_climat.schema.json @@ -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" + ] + } + } + } +} diff --git a/schemas/options.schema.json b/schemas/options.schema.json new file mode 100644 index 0000000..edc389f --- /dev/null +++ b/schemas/options.schema.json @@ -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" + ] + } + } + } +} diff --git a/schemas/postes.schema.json b/schemas/postes.schema.json new file mode 100644 index 0000000..24c45a5 --- /dev/null +++ b/schemas/postes.schema.json @@ -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" + ] + } + } + } +} diff --git a/scripts/ecarts-harmonisation.mjs b/scripts/ecarts-harmonisation.mjs new file mode 100644 index 0000000..b0d1243 --- /dev/null +++ b/scripts/ecarts-harmonisation.mjs @@ -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-.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`) + } +} diff --git a/scripts/ecrire-schemas.mjs b/scripts/ecrire-schemas.mjs new file mode 100644 index 0000000..e51c4b4 --- /dev/null +++ b/scripts/ecrire-schemas.mjs @@ -0,0 +1,12 @@ +// Réécrit schemas/
.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`) diff --git a/scripts/nettoyer-dist.mjs b/scripts/nettoyer-dist.mjs new file mode 100644 index 0000000..3216d7c --- /dev/null +++ b/scripts/nettoyer-dist.mjs @@ -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 }) diff --git a/scripts/pin-origine.mjs b/scripts/pin-origine.mjs new file mode 100644 index 0000000..e9df0d6 --- /dev/null +++ b/scripts/pin-origine.mjs @@ -0,0 +1,76 @@ +// pin-origine.mjs — génère fixtures/sorties-origine-.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(' | ')) +} diff --git a/src/autonomie/alimentation.ts b/src/autonomie/alimentation.ts new file mode 100644 index 0000000..90f74e0 --- /dev/null +++ b/src/autonomie/alimentation.ts @@ -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 diff --git a/src/autonomie/axe.ts b/src/autonomie/axe.ts new file mode 100644 index 0000000..8bca612 --- /dev/null +++ b/src/autonomie/axe.ts @@ -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 } { + const lues = new Set() + 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, 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 { + const trace = new Trace() + const { table: coefficients, lues } = enregistreLectures(donnees.coefficients ?? COEFFICIENTS) + const actifs = Object.fromEntries(AXES.map((a) => [a, a === axe])) as Record + 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 { + return { id, calculer: (entrees, donnees) => calculeAxe(axe, entrees, donnees) } +} diff --git a/src/autonomie/eau.ts b/src/autonomie/eau.ts new file mode 100644 index 0000000..3a3aa22 --- /dev/null +++ b/src/autonomie/eau.ts @@ -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 diff --git a/src/autonomie/energie.ts b/src/autonomie/energie.ts new file mode 100644 index 0000000..9e21e07 --- /dev/null +++ b/src/autonomie/energie.ts @@ -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 diff --git a/src/autonomie/index.ts b/src/autonomie/index.ts new file mode 100644 index 0000000..7df9753 --- /dev/null +++ b/src/autonomie/index.ts @@ -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' diff --git a/src/autonomie/integrateur.ts b/src/autonomie/integrateur.ts new file mode 100644 index 0000000..8bc80b8 --- /dev/null +++ b/src/autonomie/integrateur.ts @@ -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> + total_eur_ht: number + budget_cible: number + ecart_budget_pct: number + status: Statut + message_status?: string + couplages: string[] + taux_couverture: Record +} + +/** 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 { + 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 + 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 = { + id: 'autonomie.integrateur', + calculer: calculeIntegrateur, +} +export default integrateur diff --git a/src/autonomie/noyau/adequation.ts b/src/autonomie/noyau/adequation.ts new file mode 100644 index 0000000..c4bd533 --- /dev/null +++ b/src/autonomie/noyau/adequation.ts @@ -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 = { + 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 + 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 = {} + + 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 +} diff --git a/src/autonomie/noyau/algo.ts b/src/autonomie/noyau/algo.ts new file mode 100644 index 0000000..59c87bb --- /dev/null +++ b/src/autonomie/noyau/algo.ts @@ -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, + } +} diff --git a/src/autonomie/noyau/alloue-budget.ts b/src/autonomie/noyau/alloue-budget.ts new file mode 100644 index 0000000..9eea429 --- /dev/null +++ b/src/autonomie/noyau/alloue-budget.ts @@ -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, + } +} diff --git a/src/autonomie/noyau/axes-actifs.ts b/src/autonomie/noyau/axes-actifs.ts new file mode 100644 index 0000000..b1ffcc7 --- /dev/null +++ b/src/autonomie/noyau/axes-actifs.ts @@ -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> + +/** + * 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)) delete (val as Record)[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 } diff --git a/src/autonomie/noyau/calibrage.ts b/src/autonomie/noyau/calibrage.ts new file mode 100644 index 0000000..f653dd8 --- /dev/null +++ b/src/autonomie/noyau/calibrage.ts @@ -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 +} diff --git a/src/autonomie/noyau/couplages.ts b/src/autonomie/noyau/couplages.ts new file mode 100644 index 0000000..32e1420 --- /dev/null +++ b/src/autonomie/noyau/couplages.ts @@ -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 = {} + 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' +} diff --git a/src/autonomie/noyau/dimension.ts b/src/autonomie/noyau/dimension.ts new file mode 100644 index 0000000..4b36654 --- /dev/null +++ b/src/autonomie/noyau/dimension.ts @@ -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, + contexte_derived: Partial, + 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 + 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 | 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 +} diff --git a/src/autonomie/noyau/index.ts b/src/autonomie/noyau/index.ts new file mode 100644 index 0000000..41df655 --- /dev/null +++ b/src/autonomie/noyau/index.ts @@ -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' diff --git a/src/autonomie/noyau/pondere-axes.ts b/src/autonomie/noyau/pondere-axes.ts new file mode 100644 index 0000000..5f7abc2 --- /dev/null +++ b/src/autonomie/noyau/pondere-axes.ts @@ -0,0 +1,112 @@ +// 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()). + +import type { AlgoConfig, Autodiag, Contexte, PoidsAxes } from './types.js' + +type Delta = Partial> + +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 function pondereAxes(inputs: Autodiag, contexte_derived: Pick, config: AlgoConfig, debug?: false): PoidsAxes +export function pondereAxes(inputs: Autodiag, contexte_derived: Pick, config: AlgoConfig, debug: true): PoidsDebug +export function pondereAxes(inputs: Autodiag, contexte_derived: Pick, config: AlgoConfig, debug = false): PoidsAxes | PoidsDebug { + const base = config.poids_axes_base ?? { eau: 40, energie: 40, alim: 20 } + const mod = (config.modulateurs ?? {}) as Record + + const poids: PoidsAxes = { eau: base.eau, energie: base.energie, alim: base.alim } + const log: string[] | null = debug ? [] : null + + // SI surface_toit < seuil → poids_energie -= 15, redistribuer eau+5 alim+10 + const toit_seuil = (mod.toit_petit_seuil_m2 as number | undefined) ?? 30 + if ((inputs.surface_toit_m2 ?? 0) < toit_seuil) { + const delta = (mod.toit_petit_delta as Delta | undefined) ?? { 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 as number | undefined) ?? 5 + if ((inputs.nb_personnes ?? 0) >= famille_seuil) { + const delta = (mod.famille_grande_delta as Delta | undefined) ?? { 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 as Delta | undefined) ?? { 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 as number | undefined) ?? 600 + if ((contexte_derived.pluviometrie_mm ?? 800) < pluvio_seuil) { + const delta = (mod.pluvio_basse_delta as Delta | undefined) ?? { 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 as number | undefined) ?? 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 as number | undefined) ?? 1000 + if ((inputs.surface_parcelle_m2 ?? 0) >= parcelle_grande && inputs.temps_dispo === 3) { + const delta = (mod.parcelle_grande_temps3_delta as Delta | undefined) ?? { 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 +} diff --git a/src/autonomie/noyau/reajuste.ts b/src/autonomie/noyau/reajuste.ts new file mode 100644 index 0000000..0a290eb --- /dev/null +++ b/src/autonomie/noyau/reajuste.ts @@ -0,0 +1,350 @@ +// 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 type { Autodiag, Axe, Contexte, Dimension, Option, Poste, PosteResultat, Reglages, Selection, Statut } from './types.js' +import { parseJsonArr } 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 function reajusteBudget( + apres_couplages: EntreeReajuste, + budget_total: number, + options: Option[], + postes: Poste[] | null | undefined, + inputs: Autodiag, + contexte_derived: Contexte, + reglages: Reglages, + debug = false, +): ResultatReajuste { + 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: Axe[] = ['eau', 'energie', 'alim'] + + function getTousPostes(): PosteResultat[] { + return axes_list.flatMap((axe) => (axes[axe] && axes[axe].postes ? axes[axe].postes : []).map((p) => Object.assign({}, p, { _axe: axe }))) + } + + function calcTotal(): number { + return axes_list.reduce((s, axe) => s + (axes[axe] ? axes[axe].sous_total_eur_ht || 0 : 0), 0) + } + + function recalcSousTotal(axe: Axe): void { + 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: Statut = 'OK' + let message = '' + const log: string[] | null = 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: { poste: PosteResultat; alt: { alt: Option; dim: Dimension | null; cout: number }; diff: number }[] = [] + 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: (typeof candidats)[number] | null = 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 as 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 as 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: Record = {} + if (postes) { + for (const p of postes) { + const axe_norm = (p.axe === 'alimentation' ? 'alim' : p.axe) as 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 as 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: Axe = 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: PosteResultat, options: Option[]): number { + const typo = options.find((o) => o.code === poste_result.typologie_choisie) + return typo ? typo.poste_id : -1 +} diff --git a/src/autonomie/noyau/scoreurs.ts b/src/autonomie/noyau/scoreurs.ts new file mode 100644 index 0000000..734f8f1 --- /dev/null +++ b/src/autonomie/noyau/scoreurs.ts @@ -0,0 +1,167 @@ +// 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 type { Autodiag, Contexte, Option, ParamDim, Poste, Reglages } from './types.js' +import { parseJsonArr } from './types.js' + +function paramBrut(typo: Option): ParamDim | null { + // 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: Option, inputs: Autodiag, contexte_derived: Contexte, _poste: Poste, _sel: string[], reglages: Reglages): number { + 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: Option, _inputs: Autodiag, contexte_derived: Contexte): number { + 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: Option, _inputs: Autodiag, contexte_derived: Contexte): number { + 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: Option, inputs: Autodiag): number { + 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: Option, inputs: Autodiag, _contexte: Contexte, _poste: Poste, _sel: string[], reglages: Reglages): number { + 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: Option, inputs: Autodiag, _contexte: Contexte, poste: Poste | undefined, _sel: string[], reglages: Reglages): number { + // 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: Option, inputs: Autodiag): number { + 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: Option, inputs: Autodiag): number { + 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: Option, inputs: Autodiag): number { + 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: Option, _inputs: Autodiag, _contexte: Contexte, _poste: Poste, typologies_selectionnees: string[] = []): number { + 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: Reglages, orientation: string | undefined): number | undefined { + if (!orientation) return undefined + const table = reglages.coefficients.orientation_pv.valeur as Record + return table[orientation] +} diff --git a/src/autonomie/noyau/selectionne-poste.ts b/src/autonomie/noyau/selectionne-poste.ts new file mode 100644 index 0000000..d204802 --- /dev/null +++ b/src/autonomie/noyau/selectionne-poste.ts @@ -0,0 +1,188 @@ +// 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 type { Alternative, Autodiag, Axe, AxeResultat, Contexte, Dimension, Option, Poste, PosteResultat, Reglages } from './types.js' +import { parseJsonArr } from './types.js' + +interface Scored { + typo: Option + dim: Dimension | null + cout: number + score_adequation: number + score_utilite: number + score_final: number + debug_adq?: unknown +} + +/** + * 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: number, + inputs: Autodiag, + contexte_derived: Contexte, + options: Option[], + postes: Poste[], + axe: Axe, + reglages: Reglages, + debug = false, +): AxeResultat { + 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: string[] | undefined = 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 as number) : 50, + })) + + // Poids total pour répartition budget + const poids_total = postes_enrichis.reduce((s, p) => s + p.poids_effectif, 0) + + const resultats_postes: PosteResultat[] = [] + + 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: 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: unknown = 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 as { score: number }).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: Alternative[] = 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: AxeResultat = { + 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 +} diff --git a/src/autonomie/noyau/types.ts b/src/autonomie/noyau/types.ts new file mode 100644 index 0000000..9f38832 --- /dev/null +++ b/src/autonomie/noyau/types.ts @@ -0,0 +1,208 @@ +// 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. + +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 const AXES: readonly Axe[] = ['eau', 'energie', 'alim'] + +/** 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 + modulateurs?: Record + adequation_weights?: Record> + 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 + postes_poids?: Record + /** 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> + +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 function parseJsonArr(val: unknown): string[] { + if (!val || val === 'null') return [] + if (Array.isArray(val)) return val as string[] + if (typeof val !== 'string') return [] + try { + const p = JSON.parse(val) + return Array.isArray(p) ? (p as string[]) : [] + } catch { + return [] + } +} + +/** Parse parametre_dim (string JSON ou objet). */ +export function parseParam(raw: unknown): ParamDim | null { + if (!raw) return null + if (typeof raw === 'object') return raw as ParamDim + if (typeof raw !== 'string') return null + try { + return JSON.parse(raw) as ParamDim + } catch { + return null + } +} diff --git a/src/autonomie/prix.ts b/src/autonomie/prix.ts new file mode 100644 index 0000000..11acc39 --- /dev/null +++ b/src/autonomie/prix.ts @@ -0,0 +1,82 @@ +// 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. + +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 function prixOption(opt: Option, qty: number | null | undefined, pose: ModePose): PrixOption { + const q = qty || opt.quantite_default || 1 + let min: number | null | undefined + let max: number | null | undefined + 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: Preset, presetOptions: PresetOption[], optionsById: Record, pose: ModePose): { min: number; max: number } { + 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: number, axe_bdd: string, presets: Preset[], presetOptions: PresetOption[], optionsById: Record, pose: ModePose): string { + let best: Preset | null = 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' +} diff --git a/src/autonomie/taux.ts b/src/autonomie/taux.ts new file mode 100644 index 0000000..fb4beea --- /dev/null +++ b/src/autonomie/taux.ts @@ -0,0 +1,166 @@ +// 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 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 +} + +function lit(trace: Trace, coefficients: TableCoefficients, cle: keyof TableCoefficients, valeur: number | string): void { + 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: PosteResultat[]): number { + 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: PosteResultat[]): number { + 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: PosteResultat[]): number { + 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: PosteResultat[]): boolean { + 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: { surface_toit_m2?: number; nb_personnes?: number; pluviometrie_mm: number; a_captage: boolean }, + coefficients: TableCoefficients, + trace: Trace, +): { taux: number | null; detail: DetailEau | null } { + 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: number, coefficients: TableCoefficients): number { + 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: Pick, coefficients: TableCoefficients, trace: Trace): number { + 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: { kwc: number; batterie_kwh: number; orientation_toit?: string; nb_personnes?: number; contexte: Pick }, + coefficients: TableCoefficients, + trace: Trace, +): { taux: number | null; detail: DetailEnergie | null } { + 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 as Record + 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: { ml: number; nb_personnes?: number }, coefficients: TableCoefficients, trace: Trace): { taux: number | null; detail: DetailAlim | null } { + 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 } } +} diff --git a/src/commun/argent.ts b/src/commun/argent.ts new file mode 100644 index 0000000..5f76a38 --- /dev/null +++ b/src/commun/argent.ts @@ -0,0 +1,72 @@ +// 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 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 function fourchette(min: number, max: number): Fourchette { + return min <= max ? { min, max } : { min: max, max: min } +} + +export function pointMedian(f: Fourchette): number { + return (f.min + f.max) / 2 +} + +/** Somme de fourchettes ; une borne manquante est remplacée par l'autre. */ +export function additionneFourchettes(liste: FourchetteOuverte[]): Fourchette { + 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: number, pas: number): number { + if (!pas || pas <= 0) return valeur + return Math.round(valeur / pas) * pas +} + +/** Arrondi à deux décimales. */ +export function arrondi2(n: number): number { + 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: number, taux_annuel_pct: number, duree_annees: number): number { + 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: number, taux_annuel_pct: number, duree_annees: number): number { + return mensualite(capital, taux_annuel_pct, duree_annees) * 12 +} + +/** Coût total du crédit (intérêts seuls). */ +export function coutDuCredit(capital: number, taux_annuel_pct: number, duree_annees: number): number { + const n = Math.round(duree_annees * 12) + return mensualite(capital, taux_annuel_pct, duree_annees) * n - capital +} diff --git a/src/commun/coefficients.ts b/src/commun/coefficients.ts new file mode 100644 index 0000000..db1974c --- /dev/null +++ b/src/commun/coefficients.ts @@ -0,0 +1,220 @@ +// 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. + +export type Orientation = 'sud' | 'sud-est' | 'sud-ouest' | 'est' | 'ouest' | 'nord' +export type ClasseRt = 'H1' | 'H2' | 'H3' + +export interface Coefficient { + 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 { + // --- EAU --- + /** 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> + /** 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 + + // --- ÉNERGIE --- + /** Facteur de production PV selon l'orientation du toit (sud 30° = 1). */ + orientation_pv: Coefficient> + /** 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; 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> + + // --- ALIMENTATION --- + /** Mètres linéaires de haie fruitière par personne pour couvrir les fruits d'un foyer. */ + ml_haie_par_pers: Coefficient + + // --- CONTEXTE --- + /** 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 }> + + // --- GÉOMÉTRIE --- + /** 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 +} + +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: TableCoefficients = { + 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) as (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 function verifieTableCoefficients(table: unknown): string[] { + const erreurs: string[] = [] + if (!table || typeof table !== 'object') return ['table absente ou non objet'] + const t = table as Record + for (const cle of CLES_COEFFICIENTS) { + const c = t[cle] as Record | undefined + 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 +} diff --git a/src/commun/contexte.ts b/src/commun/contexte.ts new file mode 100644 index 0000000..3d07097 --- /dev/null +++ b/src/commun/contexte.ts @@ -0,0 +1,149 @@ +// 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 type { Moteur, Resultat } from './contrat.js' +import { Trace } from './contrat.js' +import { COEFFICIENTS, type TableCoefficients } from './coefficients.js' +import { VERSION } from '../version.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 const ZONE_BIOGEO_MAP: Record = { + 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: string | null | undefined): ClasseRt { + 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: string | number | null | undefined): string | null { + 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: string | number | null | undefined, + climat: LigneClimat[], + coefficients: TableCoefficients = COEFFICIENTS, +): Resultat { + 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: Moteur = { + id: 'commun.contexte', + calculer(entrees, donnees) { + return deriveContexte(entrees.code_postal, donnees.france_climat, donnees.coefficients ?? COEFFICIENTS) + }, +} diff --git a/src/commun/contrat.ts b/src/commun/contrat.ts new file mode 100644 index 0000000..71a8a65 --- /dev/null +++ b/src/commun/contrat.ts @@ -0,0 +1,67 @@ +// 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`. + +/** 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 { + 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 { + /** 'autonomie.eau', 'commun.geometrie', … */ + id: string + calculer(entrees: E, donnees: D): Resultat +} + +/** + * 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 { + private readonly sources = new Map() + private readonly avertissements = new Set() + + source(cle: string, valeur: number | string, source: string, date: string): void { + if (!this.sources.has(cle)) this.sources.set(cle, { cle, valeur, source, date }) + } + + avertit(texte: string): void { + this.avertissements.add(texte) + } + + /** Verse dans ce collecteur les sources et avertissements d'un autre résultat. */ + absorbe(autre: { sources: Source[]; avertissements: string[] }): void { + 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: S, version: string): Resultat { + return { + sorties, + sources: [...this.sources.values()], + avertissements: [...this.avertissements], + version, + } + } +} diff --git a/src/commun/geometrie.ts b/src/commun/geometrie.ts new file mode 100644 index 0000000..3bbcaeb --- /dev/null +++ b/src/commun/geometrie.ts @@ -0,0 +1,122 @@ +// 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 type { Moteur } from './contrat.js' +import { Trace } from './contrat.js' +import { COEFFICIENTS, type TableCoefficients } from './coefficients.js' +import { arrondi2 } from './argent.js' +import { VERSION } from '../version.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 function calculeGeometrie(entrees: EntreesGeometrie, coefficients: TableCoefficients = 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: number + let shab_estimee: boolean + 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: SortiesGeometrie = { + 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: Moteur = { + id: 'commun.geometrie', + calculer(entrees, donnees) { + return calculeGeometrie(entrees, donnees?.coefficients ?? COEFFICIENTS) + }, +} diff --git a/src/commun/index.ts b/src/commun/index.ts new file mode 100644 index 0000000..183417a --- /dev/null +++ b/src/commun/index.ts @@ -0,0 +1,16 @@ +// @transformer/moteurs/commun — contrat, coefficients, contexte, géométrie, argent, validation. +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' diff --git a/src/commun/schemas.ts b/src/commun/schemas.ts new file mode 100644 index 0000000..f2cc27f --- /dev/null +++ b/src/commun/schemas.ts @@ -0,0 +1,139 @@ +// schemas.ts — forme attendue de chaque JSON de données injecté dans les +// moteurs. Source de vérité des fichiers `schemas/
.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. + +export type TypeJson = 'string' | 'number' | 'integer' | 'boolean' | 'null' | 'object' | 'array' + +export interface Schema { + $id?: string + description?: string + type: TypeJson | TypeJson[] + required?: string[] + properties?: Record + items?: Schema + enum?: (string | number | boolean | null)[] + minItems?: number +} + +export type NomTable = 'postes' | 'options' | 'france_climat' | 'algo_config' + +const nombreOuNull: Schema = { type: ['number', 'null'] } +const chaineOuNull: Schema = { type: ['string', 'null'] } +const booleenOuNull: Schema = { type: ['boolean', 'null'] } + +export const SCHEMAS: Record = { + 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) as NomTable[] diff --git a/src/commun/valider.ts b/src/commun/valider.ts new file mode 100644 index 0000000..224467d --- /dev/null +++ b/src/commun/valider.ts @@ -0,0 +1,79 @@ +// valider.ts — validation structurelle d'un JSON de données contre le schéma +// de sa table (schemas/
.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, type NomTable, type Schema, type TypeJson } from './schemas.js' + +export interface Validation { + ok: boolean + erreurs: string[] +} + +const MAX_ERREURS = 20 + +function typeDe(v: unknown): TypeJson { + 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: TypeJson | TypeJson[], reel: TypeJson): boolean { + const liste = Array.isArray(attendu) ? attendu : [attendu] + return liste.some((t) => t === reel || (t === 'number' && reel === 'integer')) +} + +function verifie(schema: Schema, valeur: unknown, chemin: string, erreurs: string[]): void { + 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 as string | number | boolean | null)) { + erreurs.push(`${chemin} : valeur « ${String(valeur)} » hors de ${schema.enum.map(String).join(', ')}`) + } + if (reel === 'array') { + const tableau = valeur as unknown[] + 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 as Record + 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: NomTable, json: unknown): Validation { + const schema = SCHEMAS[table] + if (!schema) return { ok: false, erreurs: [`table inconnue : ${String(table)}`] } + const erreurs: string[] = [] + 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: Schema, json: unknown, nom = '$'): Validation { + const erreurs: string[] = [] + verifie(schema, json, nom, erreurs) + return { ok: erreurs.length === 0, erreurs } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..d16dea0 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,11 @@ +// @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] as const diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..977cb51 --- /dev/null +++ b/src/version.ts @@ -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' diff --git a/test/commun.test.mjs b/test/commun.test.mjs new file mode 100644 index 0000000..777dc18 --- /dev/null +++ b/test/commun.test.mjs @@ -0,0 +1,127 @@ +// Tests des moteurs et helpers communs : contexte (Corse, DOM, inconnu), +// géométrie (maison B de l'étude), argent, validation des données. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { contexte, departementDepuisCodePostal, geometrie, mensualite, annuite, coutDuCredit, arrondiAuPas, additionneFourchettes, valider, VERSION, COEFFICIENTS } from '../dist/index.js' + +const lire = (f) => JSON.parse(readFileSync(new URL(`../fixtures/${f}`, import.meta.url), 'utf8')) +const france_climat = lire('france_climat.json') + +test('contexte : département depuis code postal (Corse 2A/2B, DOM 97x, métropole)', () => { + assert.equal(departementDepuisCodePostal('20000'), '2A') + assert.equal(departementDepuisCodePostal('20190'), '2A') + assert.equal(departementDepuisCodePostal('20200'), '2B') + assert.equal(departementDepuisCodePostal('20620'), '2B') + assert.equal(departementDepuisCodePostal('20999'), '2A') + assert.equal(departementDepuisCodePostal('97100'), '971') + assert.equal(departementDepuisCodePostal('97600'), '976') + assert.equal(departementDepuisCodePostal('75019'), '75') + assert.equal(departementDepuisCodePostal(31000), '31') + assert.equal(departementDepuisCodePostal(''), null) + assert.equal(departementDepuisCodePostal(null), null) +}) + +test('contexte : 2A, 2B et 971 tombent sur leur ligne france_climat (H3), pas sur le défaut H2a', () => { + for (const [cp, dept] of [ + ['20000', '2A'], + ['20200', '2B'], + ['97100', '971'], + ]) { + const r = contexte.calculer({ code_postal: cp }, { france_climat }) + const ligne = france_climat.find((c) => c.dept === dept) + assert.ok(ligne, `fixture : ligne ${dept} attendue`) + assert.equal(r.sorties.departement, dept) + assert.equal(r.sorties.zone_climatique_rt, ligne.zone_rt) + assert.equal(r.sorties.zone_climatique_rt, 'H3', `${dept} est en H3 dans la fixture ; l'origine donnait H2a`) + assert.equal(r.version, VERSION) + assert.ok(r.sources.some((s) => s.cle === `france_climat.${dept}`), 'la ligne climat est tracée en source') + if (ligne.pluvio_mm_an != null) { + assert.equal(r.sorties.pluviometrie_mm, ligne.pluvio_mm_an) + assert.equal(r.sorties.dju, ligne.dju_base_18) + assert.deepEqual(r.avertissements, [], `${cp} : aucun défaut appliqué`) + } else { + // DOM : la fixture ne porte ni pluviométrie ni DJU (« DOM tropical ») → défauts déclarés + assert.equal(r.sorties.pluviometrie_mm, COEFFICIENTS.contexte_defaut.valeur.pluviometrie_mm) + assert.ok(r.avertissements.some((a) => /pluviométrie absente/.test(a)), `${cp} : pluviométrie par défaut déclarée`) + assert.ok(r.avertissements.some((a) => /DJU absent/.test(a)), `${cp} : DJU par défaut déclaré`) + } + } +}) + +test('contexte : code postal inconnu → défaut déclaré en avertissement et en source', () => { + const r = contexte.calculer({ code_postal: '98000' }, { france_climat }) + assert.equal(r.sorties.zone_climatique_rt, COEFFICIENTS.contexte_defaut.valeur.zone_rt) + assert.equal(r.avertissements.length, 1) + assert.match(r.avertissements[0], /défaut/) + assert.ok(r.sources.some((s) => s.cle === 'contexte_defaut')) +}) + +test('géométrie : maison B de l\'étude (Tournefeuille, emprise 73 m², 2 niveaux, 5 m) — SHAB estimée à confronter au DPE en P1.1', () => { + const r = geometrie.calculer({ emprise_m2: 73, niveaux: 2, hauteur_m: 5, pente_toit_deg: 30, mitoyennete: 0 }, {}) + const s = r.sorties + // SHAB = 73 × 2 × 0,80 = 116,8 m² + assert.equal(s.shab_m2, 116.8) + assert.equal(s.shab_estimee, true) + assert.equal(s.plancher_bas_m2, 73) + assert.equal(s.hauteur_etage_m, 2.5) + assert.equal(s.volume_m3, 292) + // périmètre 4 × √(73 × 1,3) = 38,97 m ; murs ext × 5 m = 194,83 m² ; vitrage 116,8/6 = 19,47 ; opaques 175,37 + assert.equal(s.perimetre_m, 38.97) + assert.equal(s.murs_exterieurs_m2, 194.83) + assert.equal(s.vitrage_m2, 19.47) + assert.equal(s.murs_opaques_m2, 175.37) + // toiture 73 / cos 30° × 1,05 = 88,51 + assert.equal(s.toiture_m2, 88.51) + assert.ok(r.avertissements.some((a) => /estimée/.test(a)), 'SHAB estimée déclarée') + assert.ok(r.sources.some((x) => x.cle === 'geometrie_ratio_shab_emprise')) + // La surface habitable du DPE 2631E0015736Q n'est pas dans l'étude (aucune recherche web dans ce lot) : + // l'écart SHAB estimée vs DPE se mesure au premier appel de l'adaptateur DPE, P1.1. + console.log(` maison B : SHAB estimée ${s.shab_m2} m² (ratio 0,80) — surface DPE : à lire en P1.1 (écart visé < 20 %)`) +}) + +test('géométrie : mitoyenneté et surface connue', () => { + const seul = geometrie.calculer({ emprise_m2: 100, niveaux: 1, hauteur_m: 2.5 }, {}).sorties + const mitoyen = geometrie.calculer({ emprise_m2: 100, niveaux: 1, hauteur_m: 2.5, mitoyennete: 2 }, {}).sorties + assert.equal(mitoyen.perimetre_expose_m, +(seul.perimetre_m * 0.5).toFixed(2)) + assert.ok(mitoyen.murs_exterieurs_m2 < seul.murs_exterieurs_m2) + const connue = geometrie.calculer({ emprise_m2: 100, niveaux: 1, hauteur_m: 2.5, surface_habitable_m2: 95 }, {}) + assert.equal(connue.sorties.shab_m2, 95) + assert.equal(connue.sorties.shab_estimee, false) + assert.ok(!connue.avertissements.some((a) => /estimée/.test(a))) + const sansRien = geometrie.calculer({ emprise_m2: 80 }, {}) + assert.equal(sansRien.avertissements.length, 4, 'niveaux, hauteur, pente inconnus + SHAB estimée → quatre avertissements') +}) + +test('argent : mensualité, annuité, coût du crédit, arrondis, fourchettes', () => { + // 10 000 € à 3 % sur 10 ans → 96,56 €/mois (formule d'annuité constante) + assert.equal(+mensualite(10000, 3, 10).toFixed(2), 96.56) + assert.equal(+annuite(10000, 3, 10).toFixed(2), 1158.73) + assert.equal(+coutDuCredit(10000, 3, 10).toFixed(2), 1587.29) + assert.equal(mensualite(12000, 0, 10), 100) + assert.equal(mensualite(0, 3, 10), 0) + assert.equal(arrondiAuPas(1234, 50), 1250) + assert.equal(arrondiAuPas(7, 0), 7) + assert.deepEqual(additionneFourchettes([{ min: 10, max: 20 }, { min: null, max: 5 }, { min: 3, max: null }, { min: null, max: null }]), { min: 18, max: 28 }) +}) + +test('valider : les fixtures passent, une donnée cassée est refusée avec son chemin', () => { + for (const [table, fichier] of [ + ['postes', 'postes.json'], + ['options', 'options.json'], + ['france_climat', 'france_climat.json'], + ['algo_config', 'algo-config.json'], + ]) { + const v = valider(table, lire(fichier)) + assert.deepEqual(v.erreurs, [], `${table} : fixture valide`) + assert.equal(v.ok, true) + } + const cassees = lire('postes.json').map((p, i) => (i === 2 ? { ...p, axe: 'feu', Id: 'trois' } : p)) + const v = valider('postes', cassees) + assert.equal(v.ok, false) + assert.ok(v.erreurs.some((e) => e.startsWith('postes[2].Id')), 'chemin de l\'erreur') + assert.ok(v.erreurs.some((e) => /postes\[2\]\.axe.*feu/.test(e))) + assert.equal(valider('postes', {}).ok, false) + assert.equal(valider('postes', []).ok, false, 'minItems 1') + assert.equal(valider('algo_config', { poids_axes_base: { eau: 40, energie: 40, alim: 20 } }).ok, false, 'clés requises manquantes') +}) diff --git a/test/decoupage.test.mjs b/test/decoupage.test.mjs new file mode 100644 index 0000000..f8a65fa --- /dev/null +++ b/test/decoupage.test.mjs @@ -0,0 +1,96 @@ +// Non-régression du découpage par axe (LOT P1.0 point 4, amendé le 28/09). +// +// 1. Avec le jeu de coefficients D'ORIGINE (fixtures/coefficients-2026-05.json), +// le pipeline complet extrait rend, sur les trois profils de référence, la +// même sélection que selectionner() d'origine — épinglée dans +// fixtures/sorties-origine-2026-09-28.json, générée DEPUIS le code d'astro-pro +// (scripts/pin-origine.mjs), pas depuis ce paquet. +// 2. Chaque moteur d'axe, à budget d'axe égal, rend la sélection du mode +// « un seul axe actif » de selectionner() d'origine (même épingle). +// 3. Avec la table HARMONISÉE, les écarts de sélection et de taux par rapport à +// l'origine sont ceux figés dans fixtures/ecarts-harmonisation-2026-09-28.json +// (relus par Jules avec docs/CONSTANTES.md). Les modifier = refiger sciemment. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { eau, energie, alimentation, integrateur, autonomie, COEFFICIENTS } from '../dist/index.js' +import { calculeEcarts } from '../scripts/ecarts-harmonisation.mjs' + +const lire = (f) => JSON.parse(readFileSync(new URL(`../fixtures/${f}`, import.meta.url), '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 pin = lire('sorties-origine-2026-09-28.json') +const ecartsPin = lire('ecarts-harmonisation-2026-09-28.json') +const AXES = ['eau', 'energie', 'alim'] +const MOTEURS = { eau, energie, alim: alimentation } + +const resumePostes = (liste) => liste.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 })) + +test('pipeline complet extrait = selectionner() d\'origine, coefficients d\'origine, trois profils', () => { + for (const profil of profils) { + const attendu = pin.profils[profil.nom].global + const r = autonomie.noyau.selectionner(profil.inputs, options, postes, france_climat, { config: algo_config, coefficients: origine }, false) + assert.equal(r.total_eur_ht, attendu.total_eur_ht, `${profil.nom} : total`) + assert.equal(r.status, attendu.status, `${profil.nom} : statut`) + assert.equal(r.ecart_budget_pct, attendu.ecart_budget_pct, `${profil.nom} : écart`) + assert.deepEqual(r.couplages, attendu.couplages, `${profil.nom} : journal des couplages`) + for (const axe of AXES) { + assert.equal(r.axes[axe].budget_alloue, attendu.axes[axe].budget_alloue, `${profil.nom}/${axe} : budget alloué`) + assert.equal(r.axes[axe].poids, attendu.axes[axe].poids, `${profil.nom}/${axe} : poids`) + assert.equal(r.axes[axe].sous_total_eur_ht, attendu.axes[axe].sous_total_eur_ht, `${profil.nom}/${axe} : sous-total`) + assert.deepEqual(resumePostes(r.axes[axe].postes), attendu.axes[axe].postes, `${profil.nom}/${axe} : postes`) + } + } +}) + +test('moteur d\'axe = mode « un seul axe actif » de selectionner() d\'origine, à budget d\'axe égal', () => { + for (const profil of profils) { + const contexte = autonomie.noyau.selectionner(profil.inputs, options, postes, france_climat, { config: algo_config, coefficients: origine }, false).contexte + for (const axe of AXES) { + const attendu = pin.profils[profil.nom].par_axe[axe] + const r = MOTEURS[axe].calculer({ autodiag: profil.inputs, budget_axe: attendu.budget_axe, contexte }, { postes, options, algo_config, coefficients: origine }) + assert.equal(r.sorties.axe, axe) + assert.equal(r.sorties.sous_total_eur_ht, attendu.axes[axe].sous_total_eur_ht, `${profil.nom}/${axe} : sous-total`) + assert.equal(r.sorties.status, attendu.status, `${profil.nom}/${axe} : statut`) + assert.deepEqual(resumePostes(r.sorties.postes), attendu.axes[axe].postes, `${profil.nom}/${axe} : postes`) + assert.deepEqual(r.sorties.typologies, attendu.axes[axe].postes.map((p) => p.typologie_choisie)) + assert.ok(!r.avertissements.some((a) => /inter-axes/.test(a)), 'aucun couplage inter-axes dans les données') + } + } +}) + +test('intégrateur mode global = selectionner() ; mode par_axe = somme des moteurs d\'axe', () => { + for (const profil of profils) { + const donnees = { postes, options, algo_config, france_climat, coefficients: origine } + const g = integrateur.calculer({ autodiag: profil.inputs, budget_total: profil.inputs.budget_eur, mode: 'global' }, donnees) + const attendu = pin.profils[profil.nom].global + assert.equal(g.sorties.mode, 'global') + assert.equal(g.sorties.total_eur_ht, attendu.total_eur_ht) + assert.equal(g.sorties.status, attendu.status) + for (const axe of AXES) assert.deepEqual(g.sorties.axes[axe].typologies, attendu.axes[axe].postes.map((p) => p.typologie_choisie)) + + const p = integrateur.calculer({ autodiag: profil.inputs, budget_total: profil.inputs.budget_eur, mode: 'par_axe' }, donnees) + assert.equal(p.sorties.mode, 'par_axe') + assert.deepEqual(p.sorties.budgets, g.sorties.budgets, 'même allocation de budget dans les deux modes') + let somme = 0 + for (const axe of AXES) { + const a = pin.profils[profil.nom].par_axe[axe] + assert.deepEqual(p.sorties.axes[axe].typologies, a.axes[axe].postes.map((x) => x.typologie_choisie), `${profil.nom}/${axe} : par_axe = moteur d'axe`) + somme += p.sorties.axes[axe].sous_total_eur_ht + } + assert.equal(p.sorties.total_eur_ht, somme) + assert.equal(p.version, g.version) + } +}) + +test('table harmonisée : écarts avec l\'origine figés (sélection et taux, trois profils)', () => { + const { ecarts } = calculeEcarts() + assert.deepEqual(ecarts, ecartsPin.ecarts, 'les écarts ont changé : relire docs/CONSTANTES.md puis refiger avec npm run ecarts -- --ecrire ') + // Ce que l'harmonisation change sur les trois profils : aucune typologie, seulement les taux. + assert.ok(ecarts.every((e) => e.mode.startsWith('taux')), 'sur les trois profils de référence, seule la couverture change') + assert.notDeepEqual(COEFFICIENTS.orientation_pv.valeur, origine.orientation_pv.valeur, 'la table harmonisée diffère bien de l\'origine') +}) diff --git a/test/moteurs.test.mjs b/test/moteurs.test.mjs new file mode 100644 index 0000000..5aa3ebd --- /dev/null +++ b/test/moteurs.test.mjs @@ -0,0 +1,109 @@ +// Le contrat d'un moteur (SPEC §4.1) tenu par les moteurs d'axe et l'intégrateur : +// déterminisme, version, sources (coefficients lus + prix des typologies), avertissements. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { eau, energie, alimentation, integrateur, contexte, VERSION, COEFFICIENTS, autonomie } from '../dist/index.js' + +const lire = (f) => JSON.parse(readFileSync(new URL(`../fixtures/${f}`, import.meta.url), '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 donnees = { postes, options, algo_config, france_climat } +const B = profils[1] +const ctxB = contexte.calculer({ code_postal: B.inputs.code_postal }, { france_climat }).sorties + +test('déterminisme : deux appels identiques, même résultat octet pour octet', () => { + for (const m of [eau, energie, alimentation]) { + const a = m.calculer({ autodiag: B.inputs, budget_axe: 14000, contexte: ctxB }, donnees) + const b = m.calculer({ autodiag: B.inputs, budget_axe: 14000, contexte: ctxB }, donnees) + assert.equal(JSON.stringify(a), JSON.stringify(b), m.id) + } + const a = integrateur.calculer({ autodiag: B.inputs, budget_total: 35000, mode: 'par_axe' }, donnees) + const b = integrateur.calculer({ autodiag: B.inputs, budget_total: 35000, mode: 'par_axe' }, donnees) + assert.equal(JSON.stringify(a), JSON.stringify(b)) +}) + +test('les données reçues ne sont pas mutées par un calcul', () => { + const avant = JSON.stringify({ options, postes, algo_config, france_climat, COEFFICIENTS }) + integrateur.calculer({ autodiag: B.inputs, budget_total: 35000, mode: 'global' }, donnees) + eau.calculer({ autodiag: B.inputs, budget_axe: 14000, contexte: ctxB }, donnees) + assert.equal(JSON.stringify({ options, postes, algo_config, france_climat, COEFFICIENTS }), avant) +}) + +test('chaque résultat porte la version, les coefficients lus et les prix des typologies retenues', () => { + const r = eau.calculer({ autodiag: B.inputs, budget_axe: 14000, contexte: ctxB }, donnees) + assert.equal(r.version, VERSION) + assert.equal(r.sorties.axe, 'eau') + assert.ok(r.sorties.postes.length >= 4, 'eau : au moins quatre postes obligatoires') + const cles = r.sources.map((s) => s.cle) + assert.ok(cles.includes('captage_rendement'), 'le rendement de captage est lu (scoreur et taux)') + assert.ok(cles.includes('besoin_eau_l_j_pers') && cles.includes('part_eau_substituable')) + for (const p of r.sorties.postes) assert.ok(cles.includes(`option.${p.typologie_choisie}`), `prix de ${p.typologie_choisie} tracé`) + for (const s of r.sources) { + assert.ok(s.source, `${s.cle} : source`) + assert.ok(typeof s.date === 'string', `${s.cle} : date`) + } + assert.equal(typeof r.sorties.taux_couverture, 'number') + assert.ok(r.sorties.taux_detail && r.sorties.taux_detail.captable_m3 > 0) +}) + +test('énergie : l\'autoconsommation est appliquée au taux de couverture (0,30 sans batterie, 0,70 avec 10 kWh)', () => { + const r = energie.calculer({ autodiag: { ...B.inputs, orientation_toit: 'sud' }, budget_axe: 14000, contexte: ctxB }, donnees) + const d = r.sorties.taux_detail + assert.ok(d && d.kwc > 0, 'un kit PV est retenu à ce budget') + const attendu = d.batterie_kwh >= 10 ? 0.7 : d.batterie_kwh <= 0 ? 0.3 : null + if (attendu != null) assert.equal(d.taux_autoconso, attendu) + assert.equal(d.autoconsomme_kwh, Math.round(d.production_kwh * d.taux_autoconso)) + assert.equal(d.kwh_par_kwc, COEFFICIENTS.productible_pv_kwh_kwc.valeur.table[ctxB.zone_climatique_rt]) + const cles = r.sources.map((s) => s.cle) + for (const c of ['orientation_pv', 'productible_pv_kwh_kwc', 'autoconso_sans_batterie', 'autoconso_avec_batterie_10kwh', 'besoin_elec_kwh_pers_an']) assert.ok(cles.includes(c), c) + assert.ok(!r.avertissements.some((a) => /H2d/.test(a)), 'B est en H2c : pas d\'avertissement H2d') +}) + +test('énergie : zone H2d → avertissement « interpolé » ; orientation inconnue → avertissement', () => { + const ctx = contexte.calculer({ code_postal: '07000' }, { france_climat }).sorties + assert.equal(ctx.zone_climatique_rt, 'H2d') + const r = energie.calculer({ autodiag: { ...B.inputs, code_postal: '07000', orientation_toit: 'nord-ouest' }, budget_axe: 14000, contexte: ctx }, donnees) + if (r.sorties.taux_couverture != null) { + assert.ok(r.avertissements.some((a) => /H2d/.test(a))) + assert.ok(r.avertissements.some((a) => /orientation/.test(a))) + } +}) + +test('alimentation : parcelle trop petite → budget 0 → aucun poste, taux null, statut OK', () => { + const A = profils[0] + const ctxA = contexte.calculer({ code_postal: A.inputs.code_postal }, { france_climat }).sorties + const r = alimentation.calculer({ autodiag: A.inputs, budget_axe: 0, contexte: ctxA }, donnees) + assert.deepEqual(r.sorties.postes, []) + assert.equal(r.sorties.taux_couverture, null) + assert.equal(r.sorties.status, 'OK') +}) + +test('intégrateur : aménagements cochés ignorés et signalés (P1.5) ; axes désactivés respectés', () => { + const r = integrateur.calculer({ autodiag: B.inputs, budget_total: 35000, mode: 'par_axe', amenagements_coches: ['x'], axes_actifs: { alim: false } }, donnees) + assert.ok(r.avertissements.some((a) => /P1\.5/.test(a))) + assert.equal(r.sorties.budgets.alim, 0) + assert.deepEqual(r.sorties.axes.alim.postes, []) + assert.ok(r.sorties.axes.eau.postes.length > 0 && r.sorties.axes.energie.postes.length > 0) + assert.equal(r.sorties.poids.eau + r.sorties.poids.energie + r.sorties.poids.alim, 100) +}) + +test('code postal inconnu : l\'avertissement du contexte remonte dans l\'intégrateur', () => { + const r = integrateur.calculer({ autodiag: { ...B.inputs, code_postal: '98000' }, budget_total: 35000 }, donnees) + assert.ok(r.avertissements.some((a) => /contexte par défaut/.test(a))) + assert.equal(r.sorties.contexte.departement, '98') +}) + +test('noyau : configPourAxes ne mute pas la config ; parseParam / parseJsonArr tolèrent objet et chaîne', () => { + const avant = JSON.stringify(algo_config) + autonomie.noyau.configPourAxes(algo_config, { eau: false }) + assert.equal(JSON.stringify(algo_config), avant) + assert.deepEqual(autonomie.noyau.parseJsonArr('["a","b"]'), ['a', 'b']) + assert.deepEqual(autonomie.noyau.parseJsonArr(['a']), ['a']) + assert.deepEqual(autonomie.noyau.parseJsonArr('null'), []) + assert.deepEqual(autonomie.noyau.parseParam('{"strategie":"fixe","default":3}'), { strategie: 'fixe', default: 3 }) + assert.equal(autonomie.noyau.parseParam('{oops'), null) +}) diff --git a/test/noyau-origine.test.mjs b/test/noyau-origine.test.mjs new file mode 100644 index 0000000..36319af --- /dev/null +++ b/test/noyau-origine.test.mjs @@ -0,0 +1,279 @@ +// Tests d'origine du cœur autonomie (calculs/tests/test-algo.js, 28 assertions, +// fixtures figées du 16/08/2026) portés en node:test. Ces tests ne se désactivent +// jamais (AGENTS.md règle 6). Coefficients d'ORIGINE injectés +// (fixtures/coefficients-2026-05.json) : ils vérifient l'extraction, pas l'harmonisation. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { performance } from 'node:perf_hooks'; + +import { + selectionner, + dimension_optimale, + cout_typologie, + configPourAxes, + auMoinsUnAxe, +} from '../dist/autonomie/noyau/index.js'; + +const options = JSON.parse(readFileSync(new URL('../fixtures/options.json', import.meta.url), 'utf8')); +const postes = JSON.parse(readFileSync(new URL('../fixtures/postes.json', import.meta.url), 'utf8')); +const climat = JSON.parse(readFileSync(new URL('../fixtures/france_climat.json', import.meta.url), 'utf8')); +const config = JSON.parse(readFileSync(new URL('../fixtures/algo-config.json', import.meta.url), 'utf8')); +const coefficients = JSON.parse(readFileSync(new URL('../fixtures/coefficients-2026-05.json', import.meta.url), 'utf8')); +const profils = JSON.parse(readFileSync(new URL('../fixtures/profils-reference.json', import.meta.url), 'utf8')); + +const reglages = { config, coefficients }; + +// ========== SECTION 1 : Typologies echantillon M1a-RECAP ========== + +test('Section 1 : typologies echantillon M1a-RECAP', () => { + const inputs_ref = { nb_personnes: 4, surface_toit_m2: 80, orientation_toit: 'sud', surface_parcelle_m2: 800, metres_haie_possibles: 0, temps_dispo: 2 }; + const ctx_ref = { dju: 2200, zone_biogeo: 'OC', pluviometrie_mm: 800, zone_climatique_rt: 'H2a', zone_rt_class: 'H2' }; + + // 1a — Cuve beton enterree : 4 pers → base 3 + 4×1.5 = 9 m³ + { + const typo = options.find(o => o.code === 'stockage_beton_5m3'); + if (typo) { + const dim = dimension_optimale(typo, inputs_ref, ctx_ref, coefficients); + assert.ok(dim && dim.valeur === 9, 'Cuve beton 4 pers = 9 m3 (base 3 + 4x1.5)'); + assert.ok(dim && dim.unite === 'm³', 'Cuve beton unite = m3'); + } else { console.log(' SKIP: stockage_beton_5m3 non trouve'); } + } + + // 1b — PV kit string toit 80m² Sud : 80×0.15×1.0 = 12 → clamp max 9 kWc + { + const typo = options.find(o => o.code === 'pv_kit_3kwc_string'); + if (typo) { + const dim = dimension_optimale(typo, inputs_ref, ctx_ref, coefficients); + assert.ok(dim && dim.valeur === 9, 'PV toit 80m2 Sud = 9 kWc (clamp max)'); + assert.ok(dim && dim.unite === 'kWc', 'PV unite = kWc'); + } else { console.log(' SKIP: pv_kit_3kwc_string non trouve'); } + } + + // 1c — Serre tunnel 4 pers : base 3 + 4×3 = 15 → step 6 → 18 m² + { + const typo = options.find(o => o.code === 'basse_serre_6m2'); + if (typo) { + const dim = dimension_optimale(typo, inputs_ref, ctx_ref, coefficients); + if (dim) { assert.ok(dim.valeur === 18, 'Serre 4 pers = 18 m2 (step 6: 15 -> 18)'); } + else { console.log(' SKIP: basse_serre_6m2 param_dim manquant (strategie fixe)'); } + } else { console.log(' SKIP: basse_serre_6m2 non trouve'); } + } + + // 1d — Phyto Aquatiris 4 pers : base 1 + 4×1 = 5 EH + { + const typo = options.find(o => o.code === 'eg_aquatiris' || o.code === 'eg_filiere_agreee_aquatiris'); + if (typo) { + const dim = dimension_optimale(typo, inputs_ref, ctx_ref, coefficients); + if (dim && dim.strategie === 'par_personne') { assert.ok(dim.valeur === 5, 'Phyto 4 pers = 5 EH'); } + else { console.log(' SKIP: aquatiris strategie fixe ou param manquant'); } + } else { console.log(' SKIP: eg_aquatiris non trouve'); } + } + + // 1e — th_ballon_500 : unite = L (particularite ballon_tampon) + { + const typo = options.find(o => o.code === 'th_ballon_500'); + if (typo) { + const dim = dimension_optimale(typo, inputs_ref, ctx_ref, coefficients); + assert.ok(dim && dim.unite === 'L', 'ballon_tampon unite = L (pas forfait)'); + const cout = cout_typologie(typo, dim); + assert.ok(typeof cout === 'number' && cout > 0, 'ballon_tampon cout > 0 (prix_par_unite 1.2 EUR/L x volume_L)'); + if (dim) console.log(' th_ballon_500:', dim.valeur, dim.unite, '=', cout, 'EUR'); + } else { console.log(' SKIP: th_ballon_500 non trouve'); } + } +}); + +// ========== SECTION 2 : 3 profils de reference ========== + +test('Section 2 : profils de reference (critere 1 : ecart +-5% budget)', () => { + const resultats_profils = []; + + for (const profil of profils) { + const t0 = performance.now(); + const result = selectionner(profil.inputs, options, postes, climat, reglages, false); + const dt = performance.now() - t0; + + console.log('--- ' + profil.nom + ' (' + dt.toFixed(1) + 'ms) ---'); + console.log('Total: ' + result.total_eur_ht + ' EUR (cible ' + result.budget_cible + ' EUR, ecart ' + result.ecart_budget_pct.toFixed(1) + '%)'); + console.log('Status:', result.status); + + if (process.env.VERBOSE) { + for (const axe of ['eau', 'energie', 'alim']) { + if (result.axes[axe]) { + console.log(' ' + axe + ': ' + result.axes[axe].sous_total_eur_ht + ' EUR (' + result.axes[axe].postes.length + ' postes)'); + for (const p of result.axes[axe].postes) { + const dim = p.dimension ? (p.dimension.valeur + p.dimension.unite) : ''; + console.log(' - ' + p.nom_affichage + ' [' + p.typologie_choisie + '] ' + dim + ' : ' + p.cout_eur_ht + ' EUR'); + } + } + } + } + + assert.ok(Math.abs(result.ecart_budget_pct) <= 5, profil.nom + ' : ecart ' + result.ecart_budget_pct.toFixed(1) + '% (<= 5%)'); + assert.ok(dt < 50, profil.nom + ' : ' + dt.toFixed(1) + 'ms (< 50ms)'); + assert.ok(result.status !== undefined, profil.nom + ' : status defini'); + resultats_profils.push({ profil, result, dt }); + } +}); + +// ========== SECTION 3 : Determinisme (100 iterations) ========== + +test('Section 3 : determinisme (100 iterations)', () => { + const profil_test = profils[1]; + const r1 = selectionner(profil_test.inputs, options, postes, climat, reglages, false); + let deterministe = true; + for (let i = 0; i < 99; i++) { + const r = selectionner(profil_test.inputs, options, postes, climat, reglages, false); + if (r.total_eur_ht !== r1.total_eur_ht || r.status !== r1.status) { + deterministe = false; + console.error(' Iteration ' + (i + 2) + ' : total=' + r.total_eur_ht + ' != ' + r1.total_eur_ht); + break; + } + } + assert.ok(deterministe, 'Determinisme : 100 iterations profil B -> meme resultat'); +}); + +// ========== SECTION 4 : BUDGET_INSUFFISANT ========== + +test('Section 4 : BUDGET_INSUFFISANT', () => { + const inputs_mini = { budget_eur: 100, code_postal: '75019', nb_personnes: 4, surface_parcelle_m2: 500, surface_toit_m2: 50, orientation_toit: 'sud', pente_toit_deg: 30, metres_haie_possibles: 0, temps_dispo: 1 }; + let no_crash = true; + let result_mini; + try { result_mini = selectionner(inputs_mini, options, postes, climat, reglages, false); } + catch (e) { no_crash = false; console.error(' CRASH:', e.message); } + assert.ok(no_crash, 'Budget 100 EUR : pas de crash'); + if (result_mini) { + assert.ok(['OK', 'BUDGET_INSUFFISANT', 'AJOUTER_OPTIONNEL'].includes(result_mini.status), 'Budget 100 EUR : status valide'); + console.log(' Status:', result_mini.status, '|', result_mini.message_status || '(pas de message)'); + } +}); + +// ========== SECTION 5 : Mode debug ========== + +test('Section 5 : mode debug', () => { + const result_debug = selectionner(profils[0].inputs, options, postes, climat, reglages, true); + assert.ok(result_debug.debug !== undefined, 'Mode debug : objet debug present'); + assert.ok(Array.isArray(result_debug.debug.steps), 'Mode debug : steps est un tableau'); + assert.ok(result_debug.debug.steps.length >= 5, 'Mode debug : >= 5 etapes tracees'); + console.log(' Etapes tracees:', result_debug.debug.steps.map(s => s.label || s.etape).join(', ')); +}); + +// ========== SECTION 6 : Couplages (20 simulations) ========== + +test('Section 6 : couplages contraints (20 simulations)', () => { + // Utiliser les codes BDD réels (après résolution des alias de algo-config.json) + // stherm_ballon_tampon → th_ballon_500 (alias BDD) + const COUPLAGES_REQUIS_ATTENDUS = { + 'bois_poele_hydro': ['bois_conduit_fumee', 'th_ballon_500'], + 'filtration_osmose_inverse': ['filtration_particulaire_simple'], + 'filtration_uv_standalone': ['filtration_particulaire_simple'], + }; + + const configs_test = [ + { budget_eur: 5000, cp: '75000', pers: 1, parcelle: 100, toit: 20, orient: 'sud', temps: 1 }, + { budget_eur: 10000, cp: '31000', pers: 2, parcelle: 300, toit: 40, orient: 'sud', temps: 1 }, + { budget_eur: 20000, cp: '69000', pers: 3, parcelle: 500, toit: 60, orient: 'sud-est', temps: 2 }, + { budget_eur: 35000, cp: '13100', pers: 4, parcelle: 800, toit: 80, orient: 'sud', temps: 2 }, + { budget_eur: 50000, cp: '06000', pers: 5, parcelle: 1200, toit: 100, orient: 'sud', temps: 3 }, + { budget_eur: 80000, cp: '01000', pers: 6, parcelle: 2500, toit: 120, orient: 'sud', temps: 3 }, + { budget_eur: 15000, cp: '59000', pers: 2, parcelle: 400, toit: 50, orient: 'est', temps: 1 }, + { budget_eur: 25000, cp: '33000', pers: 3, parcelle: 700, toit: 70, orient: 'ouest', temps: 2 }, + { budget_eur: 45000, cp: '67000', pers: 4, parcelle: 1000, toit: 90, orient: 'sud-ouest',temps: 3 }, + { budget_eur: 60000, cp: '38000', pers: 5, parcelle: 1500, toit: 110, orient: 'sud', temps: 2 }, + { budget_eur: 12000, cp: '44000', pers: 2, parcelle: 250, toit: 35, orient: 'sud', temps: 1 }, + { budget_eur: 30000, cp: '34000', pers: 3, parcelle: 600, toit: 65, orient: 'sud-est', temps: 2 }, + { budget_eur: 55000, cp: '76000', pers: 5, parcelle: 900, toit: 95, orient: 'sud', temps: 3 }, + { budget_eur: 18000, cp: '29000', pers: 2, parcelle: 350, toit: 45, orient: 'nord', temps: 1 }, + { budget_eur: 40000, cp: '83000', pers: 4, parcelle: 1100, toit: 85, orient: 'sud', temps: 2 }, + { budget_eur: 70000, cp: '64000', pers: 6, parcelle: 3000, toit: 130, orient: 'sud', temps: 3 }, + { budget_eur: 22000, cp: '57000', pers: 3, parcelle: 450, toit: 55, orient: 'est', temps: 2 }, + { budget_eur: 48000, cp: '30000', pers: 5, parcelle: 1300, toit: 105, orient: 'sud-ouest',temps: 3 }, + { budget_eur: 8000, cp: '75019', pers: 1, parcelle: 100, toit: 25, orient: 'sud', temps: 1 }, + { budget_eur: 65000, cp: '11000', pers: 6, parcelle: 2000, toit: 115, orient: 'sud', temps: 3 }, + ]; + + let violations = 0; + for (let i = 0; i < configs_test.length; i++) { + const c = configs_test[i]; + const inputs_fuzz = { + budget_eur: c.budget_eur, code_postal: c.cp, nb_personnes: c.pers, + surface_parcelle_m2: c.parcelle, surface_toit_m2: c.toit, + orientation_toit: c.orient, pente_toit_deg: 30, + metres_haie_possibles: c.parcelle > 500 ? 30 : 0, temps_dispo: c.temps, + }; + + let result_fuzz; + try { result_fuzz = selectionner(inputs_fuzz, options, postes, climat, reglages, false); } + catch (e) { violations++; console.error(' Simu ' + (i + 1) + ' CRASH:', e.message); continue; } + + const tous_codes = ['eau', 'energie', 'alim'] + .flatMap(axe => result_fuzz.axes[axe] ? result_fuzz.axes[axe].postes : []) + .map(p => p.typologie_choisie); + + for (const [typo_code, requis_list] of Object.entries(COUPLAGES_REQUIS_ATTENDUS)) { + if (tous_codes.includes(typo_code)) { + for (const requis of requis_list) { + if (!tous_codes.includes(requis)) { + violations++; + console.error(' Simu ' + (i + 1) + ' : COUPLAGE VIOLE — ' + typo_code + ' sans ' + requis); + } + } + } + } + } + assert.ok(violations === 0, 'Couplages : 0 violation sur 20 simulations (trouve: ' + violations + ')'); +}); + +// ========== SECTION 7 : Toggles d'axes (V3 M2) ========== + +test('Section 7 : toggles d axes (V3 M2)', () => { + const combinaisons = [ + { nom: 'eau seul', actifs: { eau: true, energie: false, alim: false } }, + { nom: 'energie seul', actifs: { eau: false, energie: true, alim: false } }, + { nom: 'alim seul', actifs: { eau: false, energie: false, alim: true } }, + { nom: 'eau+energie', actifs: { eau: true, energie: true, alim: false } }, + { nom: 'eau+alim', actifs: { eau: true, energie: false, alim: true } }, + { nom: 'energie+alim', actifs: { eau: false, energie: true, alim: true } }, + ]; + + let ok7 = true; + for (const profil of profils) { + for (const combo of combinaisons) { + const cfg = configPourAxes(config, combo.actifs); + const r = selectionner(profil.inputs, options, postes, climat, { config: cfg, coefficients }, false); + + // 1. Axe désactivé -> 0 poste, 0 EUR + for (const axe of ['eau', 'energie', 'alim']) { + if (combo.actifs[axe] === false) { + const a = r.axes[axe]; + if (a && (a.postes.length > 0 || a.sous_total_eur_ht > 0)) { + ok7 = false; + console.error(' FAIL: ' + profil.nom + ' / ' + combo.nom + ' — axe ' + axe + ' desactive mais ' + a.postes.length + ' postes, ' + a.sous_total_eur_ht + ' EUR'); + } + } + } + + // 2. Le total reste calibre sur le budget saisi (jamais au-dela de +25%) + if (r.total_eur_ht > profil.inputs.budget_eur * 1.25) { + ok7 = false; + console.error(' FAIL: ' + profil.nom + ' / ' + combo.nom + ' — total ' + r.total_eur_ht + ' >> budget ' + profil.inputs.budget_eur); + } + + // 3. Déterminisme conservé + const r2 = selectionner(profil.inputs, options, postes, climat, { config: configPourAxes(config, combo.actifs), coefficients }, false); + if (r2.total_eur_ht !== r.total_eur_ht) { + ok7 = false; + console.error(' FAIL: ' + profil.nom + ' / ' + combo.nom + ' — non deterministe'); + } + } + } + assert.ok(ok7, 'Toggles axes : 3 profils x 6 combinaisons (axe eteint = 0 poste, total calibre, deterministe)'); + + // Cas limite : 0 axe actif -> l'UI ne lance pas l'algo (garde auMoinsUnAxe) + assert.equal(auMoinsUnAxe({ eau: false, energie: false, alim: false }), false, '0 axe actif detecte (etat vide explicite, pas de crash)'); + assert.equal(auMoinsUnAxe({ eau: true, energie: false, alim: false }), true, 'auMoinsUnAxe : 1 axe actif OK'); + + // Config d'origine non mutée + assert.ok(!config._axes_desactives, 'configPourAxes ne mute pas la config d origine'); +}); diff --git a/test/paquet.test.mjs b/test/paquet.test.mjs new file mode 100644 index 0000000..889dd84 --- /dev/null +++ b/test/paquet.test.mjs @@ -0,0 +1,104 @@ +// Tests du paquet lui-même : version, schémas en phase, tables de coefficients +// bien formées, pureté du code (aucune I/O, aucune horloge, aucun aléa dans +// src/), et absence des anciennes constantes dupliquées (LOT P1.0 point 6). +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { VERSION, SCHEMAS, NOMS_TABLES, COEFFICIENTS, verifieTableCoefficients, MOTEURS } from '../dist/index.js' + +const racine = fileURLToPath(new URL('..', import.meta.url)) +const lire = (rel) => readFileSync(join(racine, rel), 'utf8') + +function fichiersTs(dossier) { + const out = [] + for (const nom of readdirSync(dossier)) { + const chemin = join(dossier, nom) + if (statSync(chemin).isDirectory()) out.push(...fichiersTs(chemin)) + else if (nom.endsWith('.ts')) out.push(chemin) + } + return out +} + +/** Lignes de code hors commentaires (// et /* *\/ sur une ligne) et hors chaînes de `note:`/`source:`. */ +function lignesDeCode(texte) { + return texte + .split('\n') + .map((l) => l.replace(/\/\/.*$/, '').replace(/\/\*.*?\*\//g, '')) + .filter((l) => l.trim() && !/^\s*\*/.test(l) && !/^\s*(note|source):\s*['"`]/.test(l)) +} + +test('VERSION du code = version de package.json', () => { + const pkg = JSON.parse(lire('package.json')) + assert.equal(VERSION, pkg.version) + assert.equal(pkg.name, '@transformer/moteurs') + assert.deepEqual(pkg.dependencies ?? {}, {}, 'zéro dépendance de production') +}) + +test('schemas/*.schema.json en phase avec src/commun/schemas.ts', () => { + for (const table of NOMS_TABLES) { + const json = JSON.parse(lire(`schemas/${table}.schema.json`)) + delete json.$schema + assert.deepEqual(json, JSON.parse(JSON.stringify(SCHEMAS[table])), `${table}.schema.json diffère de SCHEMAS.${table} : relancer npm run build`) + } +}) + +test('les deux tables de coefficients sont complètes, sourcées et datées', () => { + assert.deepEqual(verifieTableCoefficients(COEFFICIENTS), []) + const origine = JSON.parse(lire('fixtures/coefficients-2026-05.json')) + assert.deepEqual(verifieTableCoefficients(origine), []) + for (const cle of Object.keys(COEFFICIENTS)) assert.ok(cle in origine, `fixture d'origine : clé ${cle} manquante`) +}) + +test('six moteurs livrés, identifiants stables', () => { + assert.deepEqual([...MOTEURS], ['commun.contexte', 'commun.geometrie', 'autonomie.eau', 'autonomie.energie', 'autonomie.alimentation', 'autonomie.integrateur']) +}) + +test('pureté : src/ ne lit ni réseau, ni disque, ni horloge, ni DOM, ni aléa, ni console', () => { + const interdits = [ + [/\bfetch\s*\(/, 'fetch'], + [/\brequire\s*\(/, 'require'], + [/from\s+['"]node:/, 'import node:'], + [/from\s+['"](fs|path|os|http|https|child_process)['"]/, 'module Node'], + [/\bprocess\./, 'process'], + [/\bDate\.now\b|\bnew Date\b/, 'horloge'], + [/\bMath\.random\b/, 'aléa'], + [/\b(localStorage|sessionStorage|document|window)\b/, 'DOM'], + [/\bconsole\./, 'console'], + [/\bimport\s.*\.json['"]/, 'import JSON (les données s\'injectent)'], + ] + const fautes = [] + for (const f of fichiersTs(join(racine, 'src'))) { + const lignes = lignesDeCode(readFileSync(f, 'utf8')) + for (const [re, nom] of interdits) { + const l = lignes.find((x) => re.test(x)) + if (l) fautes.push(`${f.slice(racine.length)} : ${nom} — ${l.trim()}`) + } + } + assert.deepEqual(fautes, []) +}) + +test('anciennes constantes : plus aucune définition en double dans src/', () => { + // Chaque grandeur existe UNE fois, dans coefficients.ts (LOT P1.0 point 6). + const patrons = [ + [/ORIENTATION_FACTORS|DJU_DEFAULTS_BY_ZONE|FALLBACK_PVGIS|RENDEMENT_CAPTAGE|BESOIN_EAU_L_PERS_AN|PART_SUBSTITUABLE|BESOIN_ELEC_KWH_PERS_AN|ML_HAIE_PAR_PERS/, 'constante de module d\'origine'], + [/\b0\.93\b|\b0\.78\b|\b0\.40\b/, 'facteurs d\'orientation d\'origine'], + [/\b1550\b|\b1480\b|\b54750\b/, 'productible 1 550 / 1 480 ou besoin 54 750'], + [/\(pluvio \/ 1000\) \* 0\.7\b/, 'rendement de captage 0,7 dans score_pluviometrie'], + [/\b0\.85\b/, 'rendement 0,85'], + ] + const fautes = [] + for (const f of fichiersTs(join(racine, 'src'))) { + if (f.endsWith('coefficients.ts')) continue + const lignes = lignesDeCode(readFileSync(f, 'utf8')) + for (const [re, nom] of patrons) { + const l = lignes.find((x) => re.test(x)) + if (l) fautes.push(`${f.slice(racine.length)} : ${nom} — ${l.trim()}`) + } + } + assert.deepEqual(fautes, []) + // Dans coefficients.ts, les anciennes valeurs n'apparaissent qu'en note (chaîne), jamais en valeur. + const coefs = lignesDeCode(lire('src/commun/coefficients.ts')).join('\n') + assert.ok(!/valeur:\s*0\.85\b/.test(coefs) && !/\b1550\b/.test(coefs) && !/nord:\s*0\.4\b/.test(coefs), 'coefficients.ts porte une valeur d\'origine') +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..cafc324 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "types": [], + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": false, + "sourceMap": false, + "strict": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "resolveJsonModule": false, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +}